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) } }