Files
bot-pontos/backend/internal/httpapi/handler_test.go
2026-10-23 22:31:02 -03:00

153 lines
4.6 KiB
Go

package httpapi
import (
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/netip"
"testing"
"botpontos/internal/config"
"botpontos/internal/domain"
)
type fakePinger struct{ err error }
func (f fakePinger) Ping(context.Context) error { return f.err }
var discard = slog.New(slog.NewTextHandler(io.Discard, nil))
func serve(t *testing.T, db Pinger, method, path string) *httptest.ResponseRecorder {
t.Helper()
rec := httptest.NewRecorder()
NewHandler(db, nil, config.Config{}, discard).ServeHTTP(rec, httptest.NewRequest(method, path, nil))
return rec
}
func TestLive(t *testing.T) {
rec := serve(t, fakePinger{err: errors.New("banco fora")}, http.MethodGet, "/api/v1/health/live")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, esperado 200 (live não depende do banco)", rec.Code)
}
if rec.Header().Get("X-Request-ID") == "" {
t.Error("X-Request-ID ausente")
}
}
func TestReadyWithDatabase(t *testing.T) {
rec := serve(t, fakePinger{}, http.MethodGet, "/api/v1/health/ready")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, esperado 200", rec.Code)
}
}
func TestReadyWithoutDatabase(t *testing.T) {
rec := serve(t, fakePinger{err: errors.New("conexão recusada")}, http.MethodGet, "/api/v1/health/ready")
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, esperado 503", rec.Code)
}
assertProblem(t, rec, "DB_UNAVAILABLE")
}
func TestUnknownRoute(t *testing.T) {
rec := serve(t, fakePinger{}, http.MethodGet, "/api/v1/nao-existe")
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, esperado 404", rec.Code)
}
assertProblem(t, rec, "NOT_FOUND")
}
func TestProtectedRouteWithoutSession(t *testing.T) {
rec := serve(t, fakePinger{}, http.MethodGet, "/api/v1/users")
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, esperado 401", rec.Code)
}
assertProblem(t, rec, "UNAUTHENTICATED")
}
func TestWriteErrorValidation(t *testing.T) {
a := &API{log: discard}
rec := httptest.NewRecorder()
a.writeError(rec, domain.ValidationErrors{{Field: "lunch_minutes", Message: "inválido"}})
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("status = %d, esperado 422", rec.Code)
}
var body struct {
Errors []domain.ValidationError `json:"errors"`
}
_ = json.NewDecoder(rec.Body).Decode(&body)
if len(body.Errors) != 1 || body.Errors[0].Field != "lunch_minutes" {
t.Errorf("erros de campo inesperados: %+v", body.Errors)
}
}
func TestSameOrigin(t *testing.T) {
a := &API{cfg: config.Config{Web: config.Web{PublicURL: "https://botpontos.lan"}}}
cases := []struct {
origin, referer string
want bool
}{
{"https://botpontos.lan", "", true},
{"https://evil.example", "", false},
{"", "https://botpontos.lan/usuarios", true},
{"", "https://botpontos.lan.evil.example/", false},
{"", "", false},
}
for _, c := range cases {
r := httptest.NewRequest(http.MethodPost, "/api/v1/users/1/activate", nil)
if c.origin != "" {
r.Header.Set("Origin", c.origin)
}
if c.referer != "" {
r.Header.Set("Referer", c.referer)
}
if got := a.sameOrigin(r); got != c.want {
t.Errorf("origin=%q referer=%q: %v, esperado %v", c.origin, c.referer, got, c.want)
}
}
}
func TestClientIP(t *testing.T) {
a := &API{cfg: config.Config{Web: config.Web{TrustedProxies: []netip.Prefix{
netip.MustParsePrefix("172.16.0.0/12"),
netip.MustParsePrefix("192.168.18.10/32"), // NPM
}}}}
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.RemoteAddr = "172.18.0.5:41000" // container web
// Cliente forjou um IP à esquerda; o NPM acrescentou o IP real; o Next acrescentou o NPM.
r.Header.Set("X-Forwarded-For", "1.2.3.4, 192.168.18.50, 192.168.18.10")
if got := a.clientIP(r); got != "192.168.18.50" {
t.Errorf("clientIP = %s, esperado 192.168.18.50", got)
}
direct := httptest.NewRequest(http.MethodGet, "/", nil)
direct.RemoteAddr = "10.9.9.9:1234"
direct.Header.Set("X-Forwarded-For", "1.2.3.4")
if got := a.clientIP(direct); got != "10.9.9.9" {
t.Errorf("origem não confiável deveria ignorar X-Forwarded-For, veio %s", got)
}
}
func assertProblem(t *testing.T, rec *httptest.ResponseRecorder, code string) {
t.Helper()
if ct := rec.Header().Get("Content-Type"); ct != "application/problem+json; charset=utf-8" {
t.Errorf("Content-Type = %q", ct)
}
var body struct {
Status int `json:"status"`
Code string `json:"code"`
}
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatalf("corpo inválido: %v", err)
}
if body.Code != code || body.Status != rec.Code {
t.Errorf("problem = %+v, esperado code=%s status=%d", body, code, rec.Code)
}
}