422 lines
16 KiB
Go
422 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 "" }
|