package api import ( "encoding/json" "errors" "io" "log/slog" "net/http" "net/http/httptest" "testing" "git.dcrubro.com/dcrubro/hammy-backend/internal/apikey" "git.dcrubro.com/dcrubro/hammy-backend/internal/ratelimit" ) func testRouter(t *testing.T, ready func() error) http.Handler { t.Helper() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) rt := &Router{ Auth: &Authenticator{ Keys: &fakeStore{rec: activeRecord()}, Limiter: ratelimit.NewMemory(), Logger: logger, }, Logger: logger, Version: "test", Ready: ready, } return rt.Handler() } func TestHealthzNeedsNoKey(t *testing.T) { h := testRouter(t, nil) w := httptest.NewRecorder() h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/healthz", nil)) if w.Code != http.StatusOK { t.Fatalf("status %d, want 200", w.Code) } if id := w.Header().Get("X-Request-Id"); id == "" { t.Error("no X-Request-Id on the response") } } // Liveness must not depend on Postgres. If it did, a database blip would get // the process killed by its supervisor and turn a wobble into an outage. func TestHealthzIgnoresDependencies(t *testing.T) { h := testRouter(t, func() error { return errors.New("postgres down") }) w := httptest.NewRecorder() h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/healthz", nil)) if w.Code != http.StatusOK { t.Errorf("healthz status %d with a failing dependency, want 200", w.Code) } } func TestReadyzReflectsDependencies(t *testing.T) { h := testRouter(t, func() error { return errors.New("postgres down") }) w := httptest.NewRecorder() h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/readyz", nil)) if w.Code != http.StatusServiceUnavailable { t.Errorf("readyz status %d, want 503", w.Code) } ok := testRouter(t, func() error { return nil }) w = httptest.NewRecorder() ok.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/readyz", nil)) if w.Code != http.StatusOK { t.Errorf("healthy readyz status %d, want 200", w.Code) } } func TestV1RequiresKey(t *testing.T) { h := testRouter(t, nil) w := httptest.NewRecorder() h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/v1/whoami", nil)) if w.Code != http.StatusUnauthorized { t.Fatalf("status %d, want 401", w.Code) } } func TestWhoamiEndToEnd(t *testing.T) { h := testRouter(t, nil) g, err := apikey.Generate(apikey.Live) if err != nil { t.Fatal(err) } r := httptest.NewRequest(http.MethodGet, "/v1/whoami", nil) r.Header.Set("Authorization", "Bearer "+g.Plaintext) w := httptest.NewRecorder() h.ServeHTTP(w, r) if w.Code != http.StatusOK { t.Fatalf("status %d: %s", w.Code, w.Body.String()) } var got struct { KeyID int64 `json:"key_id"` OwnerID int64 `json:"owner_id"` Scopes []string `json:"scopes"` } if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { t.Fatal(err) } if got.KeyID != 42 || got.OwnerID != 7 { t.Errorf("principal not propagated: %+v", got) } } func TestScopeEnforcedOnRoute(t *testing.T) { logger := slog.New(slog.NewTextHandler(io.Discard, nil)) rec := activeRecord() rec.Scopes = []string{ScopeLogbook} // deliberately not reference rt := &Router{ Auth: &Authenticator{ Keys: &fakeStore{rec: rec}, Limiter: ratelimit.NewMemory(), Logger: logger, }, Logger: logger, } g, _ := apikey.Generate(apikey.Live) r := httptest.NewRequest(http.MethodGet, "/v1/reference/ping", nil) r.Header.Set("Authorization", "Bearer "+g.Plaintext) w := httptest.NewRecorder() rt.Handler().ServeHTTP(w, r) if w.Code != http.StatusForbidden { t.Errorf("status %d, want 403", w.Code) } } func TestUnknownPathIsJSON404(t *testing.T) { h := testRouter(t, nil) w := httptest.NewRecorder() h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/nope", nil)) if w.Code != http.StatusNotFound { t.Fatalf("status %d, want 404", w.Code) } if ct := w.Header().Get("Content-Type"); ct[:16] != "application/json" { t.Errorf("Content-Type %q, want JSON so clients can parse errors uniformly", ct) } } // A wrong method on a real path must 405, not 404. func TestMethodMismatch(t *testing.T) { h := testRouter(t, nil) w := httptest.NewRecorder() h.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/healthz", nil)) if w.Code != http.StatusMethodNotAllowed { t.Errorf("status %d, want 405", w.Code) } } // A panicking handler must produce a 500, not kill the process. func TestRecoveryMiddleware(t *testing.T) { logger := slog.New(slog.NewTextHandler(io.Discard, nil)) boom := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { panic("kaboom") }) h := Chain(boom, WithRequestID, WithRecovery(logger)) w := httptest.NewRecorder() h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/x", nil)) if w.Code != http.StatusInternalServerError { t.Fatalf("status %d, want 500", w.Code) } if got := bodyCode(t, w); got != CodeInternal { t.Errorf("code %q, want %q", got, CodeInternal) } } func TestRequestIDHonoursInbound(t *testing.T) { h := Chain(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if got := RequestIDFrom(r.Context()); got != "abc123" { t.Errorf("request id %q, want abc123", got) } }), WithRequestID) r := httptest.NewRequest(http.MethodGet, "/", nil) r.Header.Set("X-Request-Id", "abc123") h.ServeHTTP(httptest.NewRecorder(), r) }