153 lines
4.6 KiB
Go
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)
|
|
}
|
|
}
|