go-bananas/internal/httpapi/router_test.go
Daniel Noulet 951dd8d26b
Some checks failed
ci / go (push) Has been cancelled
ci / admin-ui (push) Has been cancelled
ci / runtime-sdk (push) Has been cancelled
ci / helm (push) Has been cancelled
ci / openapi (push) Has been cancelled
ci / container (push) Has been cancelled
ci / release-config (push) Has been cancelled
ci / shared styling (push) Has been cancelled
ci / security (push) Has been cancelled
ci / all green (push) Has been cancelled
initial commit
2026-10-07 15:11:35 +02:00

421 lines
16 KiB
Go

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-dev.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 <script> is always
// fetched under CORS rules, and fetch() from another origin needs the header. The
// failure this guards against is silent and total: the control renders as an empty
// box and reports no palettes, while every same-origin test still passes.
func TestRuntimeIsReadableCrossOrigin(t *testing.T) {
router := newTestRouter(t)
for _, path := range []string{
"/rt/v1/contract.json",
"/rt/v1/contract.css",
"/rt/v1/components.js",
"/rt/v1/manifest.json",
} {
t.Run(path, func(t *testing.T) {
// The Origin header is what makes this a cross-origin read at all. Without
// it the middleware deliberately adds nothing, so a test that omits it is
// not testing the policy — it is testing the same-origin path under a
// misleading name.
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, path, nil)
req.Header.Set("Origin", "https://tell-dev.urpi.be")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "*" {
t.Errorf("Access-Control-Allow-Origin = %q, want \"*\" so another host can read it", got)
}
if !strings.Contains(rec.Header().Get("Vary"), "Origin") {
t.Error("Vary must include Origin, or a shared cache can serve one caller's entry to another")
}
if got := rec.Header().Get("Access-Control-Allow-Credentials"); got != "" {
// Browsers reject a credentialed wildcard, and the runtime needs no
// credentials: the SDK sends credentials:'omit'.
t.Errorf("Access-Control-Allow-Credentials = %q, want it absent alongside a wildcard", got)
}
})
}
}
// TestAdminAPIIsNotOpenedByTheRuntimePolicy is the counterpart to the test above.
//
// Opening the runtime tree to any origin is only safe because that tree is public
// styling. The admin API sits on the same router and is per-user and credentialed, so
// it must stay on the allow-list. A fix that simply sent "*" everywhere would make the
// runtime work and quietly expose the admin API, and the runtime tests alone would
// still pass.
//
// The router in this test has no configured origins, which is the default deployment
// shape, so the expectation is that nothing is allowed.
func TestAdminAPIIsNotOpenedByTheRuntimePolicy(t *testing.T) {
router := newTestRouter(t)
for _, path := range []string{"/api/v1/session", "/api/v1/themes"} {
t.Run(path, func(t *testing.T) {
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, path, nil)
req.Header.Set("Origin", "https://tell-dev.urpi.be")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("Access-Control-Allow-Origin = %q on %s, want none: the admin API is per-user and must stay restricted", got, path)
}
})
}
}
// TestAdminAPIAllowsAConfiguredOrigin checks that the restriction is an allow-list and
// not a blanket refusal: an operator who configures an origin for their own tooling
// should get it working, otherwise the safe default becomes permanent breakage with no
// way out.
func TestAdminAPIAllowsAConfiguredOrigin(t *testing.T) {
cfg := testConfig(t)
cfg.AuthMode = config.AuthModeNone
cfg.CORSAllowedOrigins = []string{"https://admin.example"}
router := buildRouter(t, cfg, nil)
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/v1/session", nil)
req.Header.Set("Origin", "https://admin.example")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "https://admin.example" {
t.Errorf("Access-Control-Allow-Origin = %q, want the configured origin echoed back", got)
}
if got := rec.Header().Get("Access-Control-Allow-Credentials"); got != "true" {
t.Errorf("Access-Control-Allow-Credentials = %q, want \"true\" for a credentialed allow-listed origin", got)
}
}
// TestRuntimeAnswersPreflight checks the OPTIONS branch. The runtime is read-only, so
// no consumer needs a preflight today; answering it is still correct, and refusing it
// would be a confusing way to express that.
//
// It goes through the router rather than the middleware directly because the router is
// what a real preflight arrives at, and the branch could be mounted wrongly without
// that ever showing up in a unit test of the middleware alone.
func TestRuntimeAnswersPreflight(t *testing.T) {
req := httptest.NewRequestWithContext(
context.Background(), http.MethodOptions, "/rt/v1/contract.json", nil)
req.Header.Set("Origin", "https://tell-dev.urpi.be")
// A real preflight names the method it wants to use. Without this header the
// request is not a preflight, and asserting 204 on it would be asserting on a
// case no browser ever sends — which is how this test passed against a 405.
req.Header.Set("Access-Control-Request-Method", "GET")
rec := httptest.NewRecorder()
newTestRouter(t).ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Errorf("status %d, want %d", rec.Code, http.StatusNoContent)
}
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "*" {
t.Errorf("Access-Control-Allow-Origin = %q on the preflight, want \"*\"", got)
}
}
// TestUnknownAPIEndpointIsJSONNotTheSPA checks that a typo under /api/v1 returns a
// machine-readable error. Falling through to the single-page app there would hand
// an HTML document to a fetch() call, and the client would report a JSON parse
// error instead of the 404 that actually happened.
func TestUnknownAPIEndpointIsJSONNotTheSPA(t *testing.T) {
rec := get(t, newTestRouter(t), "/api/v1/does-not-exist")
if rec.Code != http.StatusNotFound {
t.Fatalf("got %d, want 404", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); ct != "application/json; charset=utf-8" {
t.Errorf("content type %q, want JSON", ct)
}
}
// TestProbesArePublic checks the health endpoints, which a kubelet must be able to
// reach without credentials or they would restart a healthy pod forever.
func TestProbesArePublic(t *testing.T) {
router := newTestRouter(t)
for _, path := range []string{"/healthz", "/livez", "/readyz"} {
t.Run(path, func(t *testing.T) {
if rec := get(t, router, path); rec.Code != http.StatusOK {
t.Errorf("got %d, want 200: %s", rec.Code, rec.Body.String())
}
})
}
}
// rejectingResolver is an OIDC resolver that authenticates nobody.
//
// It exists so the router can be built in OIDC mode without contacting a real
// provider: New refuses to start in OIDC mode without a resolver, and the
// authentication tests need a mode where a missing session is actually rejected.
type rejectingResolver struct{}
func (rejectingResolver) Begin(context.Context, *http.Request, http.ResponseWriter, string) error {
return nil
}
func (rejectingResolver) Complete(context.Context, *http.Request) (*auth.Login, error) {
return nil, auth.ErrNoSession
}
func (rejectingResolver) End(context.Context, string) string { return "" }
func (rejectingResolver) Enabled() bool { return true }
func (rejectingResolver) ClearState(http.ResponseWriter) {}
func (rejectingResolver) LogoutURL(string) string { return "" }