package httpapi_test import ( "context" "encoding/json" "io" "net/http" "net/http/httptest" "strings" "testing" "github.com/urpi/go-bananas/internal/auth" "github.com/urpi/go-bananas/internal/config" "github.com/urpi/go-bananas/internal/domain" "github.com/urpi/go-bananas/internal/httpapi" "github.com/urpi/go-bananas/internal/logging" "github.com/urpi/go-bananas/internal/seed" "github.com/urpi/go-bananas/internal/store" ) // These tests cover the routing posture of the HTTP surface, which is the part // that cannot be verified by testing handlers in isolation. A handler that behaves // correctly but is mounted on the wrong side of the authentication middleware is // still a vulnerability, and no amount of handler testing would catch it. // // The split being asserted is: // - /rt/v1 and session discovery are reachable without a session; // - everything else under /api/v1 requires one; // - the two exceptions are the only exceptions. // testConfig builds a minimal development configuration. func testConfig(t *testing.T) config.Config { t.Helper() cfg := config.Config{ Environment: "test", AuthMode: config.AuthModeNone, PublicBaseURL: "http://localhost:8080", SessionSecret: "0123456789abcdef0123456789abcdef", SessionSameSite: "Lax", DatabasePath: ":memory:", DatabaseMaxConns: 4, RateLimitPerMinute: 600, } if err := cfg.Validate(); err != nil { t.Fatalf("test config is invalid: %v", err) } return cfg } // newTestRouter builds a router over an in-memory database with authentication // disabled. // // AUTH_MODE=none is not the same as "anonymous": it is a deliberate deployment // choice to grant every caller admin, so it is the right fixture for exercising // unauthenticated *routing* (probes, the runtime API, the 404 handler) but not // authentication itself. func newTestRouter(t *testing.T) http.Handler { t.Helper() cfg := testConfig(t) cfg.AuthMode = config.AuthModeNone return buildRouter(t, cfg, nil) } // newUnauthenticatedRouter builds a router in OIDC mode with no session. // // This is the fixture for the authentication tests: every request arrives // anonymous and must be rejected, which is exactly the state a signed-out browser // is in. The resolver is a stub so that no identity provider is contacted. func newUnauthenticatedRouter(t *testing.T) http.Handler { t.Helper() cfg := testConfig(t) cfg.AuthMode = config.AuthModeOIDC cfg.OIDCIssuer = "https://auth.lcl-urpi.be/realms/urpi" cfg.OIDCClientID = "go-bananas" cfg.OIDCClientSecret = "test-client-secret" cfg.SessionSecure = false return buildRouter(t, cfg, rejectingResolver{}) } // newRuntimeRouter builds a router whose store has the built-in themes seeded, which // is what the runtime endpoints need in order to have anything to serve. func newRuntimeRouter(t *testing.T) http.Handler { t.Helper() h, st := buildRouterWithStore(t, testConfig(t), nil) if _, _, err := st.Seed(context.Background(), seed.Themes()); err != nil { t.Fatalf("seed built-in themes: %v", err) } return h } // buildRouter wires a router for a configuration and an optional resolver. func buildRouter(t *testing.T, cfg config.Config, resolver auth.Resolver) http.Handler { t.Helper() h, _ := buildRouterWithStore(t, cfg, resolver) return h } // buildRouterWithStore wires a router and hands back its store, so a test can seed // or assert against the same database the handlers read. func buildRouterWithStore(t *testing.T, cfg config.Config, resolver auth.Resolver) (http.Handler, *store.Store) { t.Helper() st, err := store.Open(context.Background(), store.Options{Path: ":memory:"}) if err != nil { t.Fatalf("open store: %v", err) } t.Cleanup(func() { _ = st.Close() }) // The logger discards its output: these tests assert on HTTP status codes, and // a passing run would otherwise fill the test log with request lines. log := logging.New(logging.Options{Level: "error", Writer: io.Discard}) authenticator, err := auth.New(cfg, resolver, nil) if err != nil { t.Fatalf("new authenticator: %v", err) } return httpapi.NewRouter(httpapi.Deps{ Config: cfg, Store: st, Cache: httpapi.NewThemeCache(), Authenticator: authenticator, Log: log, Version: "test", RuntimeDefaults: func() (string, domain.Appearance) { return "", "" }, }), st } // get performs a request and returns the recorder. func get(t *testing.T, h http.Handler, path string) *httptest.ResponseRecorder { t.Helper() rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), http.MethodGet, path, nil)) return rec } // TestSessionEndpointsAreReachableWithoutASession is the regression test for the // mistake that made the admin UI unable to start a login: session introspection sat // inside the authenticated subtree, so the UI received 401 for the one request it // needs in order to discover that it is not logged in. func TestSessionEndpointsAreReachableWithoutASession(t *testing.T) { router := newUnauthenticatedRouter(t) for _, path := range []string{"/api/v1/auth/me", "/api/v1/auth/config"} { t.Run(path, func(t *testing.T) { rec := get(t, router, path) if rec.Code != http.StatusOK { t.Fatalf("got %d, want 200: %s", rec.Code, rec.Body.String()) } var body map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("response is not JSON: %v", err) } if _, ok := body["mode"]; !ok { t.Error("response does not report the auth mode") } }) } } // TestMeReportsAnonymityRatherThanFailing checks that an absent session is a 200 // with an explicit flag, not an error status. The UI treats this as the normal // first-load state. func TestMeReportsAnonymityRatherThanFailing(t *testing.T) { rec := get(t, newUnauthenticatedRouter(t), "/api/v1/auth/me") if rec.Code != http.StatusOK { t.Fatalf("got %d, want 200", rec.Code) } var body struct { Authenticated bool `json:"authenticated"` Mode string `json:"mode"` } if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("decode: %v", err) } if body.Authenticated { t.Error("anonymous request reported as authenticated") } if body.Mode == "" { t.Error("auth mode is empty") } } // TestAdminAPIRequiresASession is the other half of the contract: moving the two // session endpoints out of the guarded subtree must not have opened the rest of // /api/v1. Each route is checked individually, because a single unguarded route is // the whole vulnerability and testing the group as a whole would not localise it. func TestAdminAPIRequiresASession(t *testing.T) { router := newUnauthenticatedRouter(t) protected := []struct { method string path string }{ {http.MethodGet, "/api/v1/meta"}, {http.MethodGet, "/api/v1/stats"}, {http.MethodGet, "/api/v1/themes"}, {http.MethodGet, "/api/v1/themes/midnight"}, {http.MethodGet, "/api/v1/themes/midnight/export"}, {http.MethodGet, "/api/v1/themes/midnight/versions"}, {http.MethodGet, "/api/v1/themes/midnight/diff"}, {http.MethodPost, "/api/v1/themes"}, {http.MethodPost, "/api/v1/tokens/validate"}, {http.MethodGet, "/api/v1/tokens/schema"}, {http.MethodGet, "/api/v1/keys"}, {http.MethodGet, "/api/v1/admin/users"}, {http.MethodGet, "/api/v1/admin/audit"}, {http.MethodGet, "/api/v1/admin/settings"}, } for _, tc := range protected { t.Run(tc.method+" "+tc.path, func(t *testing.T) { rec := httptest.NewRecorder() router.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), tc.method, tc.path, nil)) if rec.Code != http.StatusUnauthorized { t.Errorf("got %d, want 401: %s", rec.Code, rec.Body.String()) } }) } } // TestRuntimeIsPublic checks that the runtime API is not behind authentication. A // theme is a public asset by design: the whole point is that a browser can link to // it with no credentials, which is also why it has its own rate limiter. // // The assertion is deliberately "not 401" rather than "200". With no published // theme these endpoints legitimately answer 400 or 404, and both are fine here — // what would not be fine is a 401, which would mean the request never reached the // handler. Checking for the absence of the failure mode rather than the presence of // a success keeps the test meaningful whether or not a theme exists. func TestRuntimeIsPublic(t *testing.T) { router := newTestRouter(t) for _, path := range []string{ "/rt/v1/manifest.json", "/rt/v1/theme.css", "/rt/v1/theme.json", "/rt/v1/embed", "/rt/v1/themes/midnight/theme.css", } { t.Run(path, func(t *testing.T) { rec := get(t, router, path) if rec.Code == http.StatusUnauthorized { t.Errorf("runtime endpoint demanded authentication: %s", rec.Body.String()) } if rec.Code == http.StatusNotFound && strings.Contains(rec.Body.String(), "endpoint") { t.Errorf("runtime route is not registered: %s", rec.Body.String()) } }) } } // TestRuntimeIsReadableCrossOrigin checks that a consuming application on another // host can read the styling contract. // // This is not a hypothetical: every sibling application is on its own host, so in a // real deployment all of them read this tree cross-origin, and two of those reads are // enforced by the platform rather than negotiated. An ES module