215 lines
5.2 KiB
Go
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)
|
|
}
|