Files
hammy-backend/internal/api/router_test.go
T
2026-09-06 19:14:59 +02:00

215 lines
5.2 KiB
Go

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