package cabana import ( "encoding/json" "errors" "net/http" "net/http/httptest" "strings" "testing" "git.golem15.com/golem15/summercms/modules/bouncer" "git.golem15.com/golem15/summercms/modules/lagoon" ) // tokenRouteClass is the D-05/D-06 inventory class for a phase09Routes entry. type tokenRouteClass string const ( classPublic tokenRouteClass = "public" classOpenAnyToken tokenRouteClass = "open_any_token" classInteractive tokenRouteClass = "interactive_only" classExplicitGate tokenRouteClass = "explicit_gate" classAllowsProtect tokenRouteClass = "allows_protect" classAllowsNav tokenRouteClass = "allows_navigation" classAllowsSettings tokenRouteClass = "allows_settings" ) // tokenRouteClasses maps every phase09Routes key to its personal-token class. // Adding a route without a class fails TestTokenRouteGates. var tokenRouteClasses = map[string]tokenRouteClass{ "POST /auth/login": classPublic, "POST /auth/refresh": classInteractive, // public to JWT, but interactive-only for tokens "GET /lang": classPublic, "POST /auth/logout": classInteractive, "GET /auth/me": classOpenAnyToken, "PUT /account": classInteractive, "POST /account/avatar": classInteractive, "DELETE /account/avatar": classInteractive, "GET /account/tokens": classInteractive, "POST /account/tokens": classInteractive, "GET /account/tokens/scopes": classInteractive, "DELETE /account/tokens/{id}": classInteractive, "GET /navigation": classAllowsNav, "GET /field-types": classExplicitGate, "GET /settings": classAllowsSettings, "GET /settings/{code}/schema": classAllowsSettings, "GET /settings/{code}": classAllowsSettings, "PUT /settings/{code}": classAllowsSettings, "POST /markdown/preview": classExplicitGate, "GET /{vendor}/{plugin}/{controller}/schema/list": classAllowsProtect, "GET /{vendor}/{plugin}/{controller}/schema/form": classAllowsProtect, "GET /{vendor}/{plugin}/{controller}/schema/relation/{name}": classAllowsProtect, "GET /{vendor}/{plugin}/{controller}/partials/{name}": classAllowsProtect, "GET /{vendor}/{plugin}/{controller}/fields/{field}/options": classAllowsProtect, "GET /{vendor}/{plugin}/{controller}/fields/{field}/dependent-options": classAllowsProtect, "GET /{vendor}/{plugin}/{controller}/filters/{scope}/options": classAllowsProtect, "GET /{vendor}/{plugin}/{controller}": classAllowsProtect, "POST /{vendor}/{plugin}/{controller}": classAllowsProtect, "POST /{vendor}/{plugin}/{controller}/bulk-delete": classAllowsProtect, "GET /{vendor}/{plugin}/{controller}/reorder": classAllowsProtect, "POST /{vendor}/{plugin}/{controller}/reorder": classAllowsProtect, "GET /{vendor}/{plugin}/{controller}/singleton": classAllowsProtect, "POST /{vendor}/{plugin}/{controller}/bulk/{action}": classAllowsProtect, "POST /{vendor}/{plugin}/{controller}/widgets/{field}": classAllowsProtect, "POST /{vendor}/{plugin}/{controller}/toolbar/{action}": classAllowsProtect, "GET /{vendor}/{plugin}/{controller}/{id}": classAllowsProtect, "PUT /{vendor}/{plugin}/{controller}/{id}": classAllowsProtect, "DELETE /{vendor}/{plugin}/{controller}/{id}": classAllowsProtect, "POST /{vendor}/{plugin}/{controller}/{id}/actions/{action}": classAllowsProtect, "GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}": classAllowsProtect, "GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/candidates": classAllowsProtect, "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/link": classAllowsProtect, "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink": classAllowsProtect, "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records": classAllowsProtect, "GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}": classAllowsProtect, "PUT /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}": classAllowsProtect, "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/delete": classAllowsProtect, "GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/pivot/{child}": classAllowsProtect, "PUT /{vendor}/{plugin}/{controller}/{id}/relations/{name}/pivot/{child}": classAllowsProtect, "GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}": classAllowsProtect, "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}": classAllowsProtect, "POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/reorder": classAllowsProtect, "PUT /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}": classAllowsProtect, "DELETE /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}": classAllowsProtect, "GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}/download": classAllowsProtect, "GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}/thumb": classAllowsProtect, "GET /{vendor}/{plugin}/{controller}/{id}/files/{field}": classAllowsProtect, "POST /{vendor}/{plugin}/{controller}/{id}/files/{field}": classAllowsProtect, "POST /{vendor}/{plugin}/{controller}/{id}/files/{field}/reorder": classAllowsProtect, "PUT /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}": classAllowsProtect, "DELETE /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}": classAllowsProtect, "GET /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}/download": classAllowsProtect, "GET /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}/thumb": classAllowsProtect, "POST /{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload": classAllowsProtect, "GET /assets/{vendor}/{plugin}/{file...}": classPublic, "GET ": classPublic, "GET /{path...}": classPublic, } func TestTokenRouteGates(t *testing.T) { for _, route := range phase09Routes { if _, ok := tokenRouteClasses[route.key]; !ok { t.Errorf("phase09Routes key %q has no token class", route.key) } } for key := range tokenRouteClasses { found := false for _, route := range phase09Routes { if route.key == key { found = true break } } if !found { t.Errorf("token class map has unknown key %q not in phase09Routes", key) } } if t.Failed() { t.Fatal("token route classification incomplete") } cat := tracerCatalog() reg := &Registry{tokenScopes: cat} store := &memTokenStore{byHash: map[string]*BackendAPIToken{}} dev := &bouncer.Principal{ ID: 1, Backend: true, PermissionGrants: map[string]bool{ "acme.demo.access": true, "acme.demo.reports": true, }, } users := stubUsers{byID: map[uint]*bouncer.Principal{1: dev}} guard := newCompositeGuard(&stubJWTGuard{}, store, users, reg) mint := func(id uint, scopes []string) string { plain, hash, hint, err := mintAPIToken() if err != nil { t.Fatal(err) } store.put(&BackendAPIToken{ ID: id, BackendUserID: 1, Name: "t", TokenHash: hash, TokenHint: hint, Scopes: lagoon.Jsonable[[]string]{Data: scopes, Valid: true}, }) return plain } starTok := mint(1, []string{"*"}) adminReadTok := mint(2, []string{"admin:read"}) demoTok := mint(3, []string{"demo"}) authPrincipal := func(r *http.Request, token string) (*bouncer.Principal, any, error) { r.Header.Set("Authorization", "Bearer "+token) return guard.AuthenticateCredential(r) } // open_any_token: GET /auth/me — any valid token passes the guard. for name, tok := range map[string]string{"*": starTok, "admin:read": adminReadTok, "demo": demoTok} { r := httptest.NewRequest(http.MethodGet, "/auth/me", nil) p, _, err := authPrincipal(r, tok) if err != nil || p == nil { t.Fatalf("open_any_token %s: auth err=%v", name, err) } } // interactive_only: credential or raw g15_ Bearer → 403 interactive_only. interactivePaths := []string{ "PUT /account", "POST /account/avatar", "DELETE /account/avatar", "GET /account/tokens", "POST /account/tokens", "GET /account/tokens/scopes", "DELETE /account/tokens/{id}", "POST /auth/refresh", "POST /auth/logout", } for _, key := range interactivePaths { if tokenRouteClasses[key] != classInteractive { t.Fatalf("%s class = %s, want interactive_only", key, tokenRouteClasses[key]) } method, path, _ := strings.Cut(key, " ") path = strings.ReplaceAll(path, "{id}", "1") h := interactiveOnly(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) tokens := map[string]string{"*": starTok, "demo": demoTok} if method == http.MethodGet || method == http.MethodHead { tokens["admin:read"] = adminReadTok } for name, tok := range tokens { rec := httptest.NewRecorder() req := httptest.NewRequest(method, path, nil) req.Header.Set("Authorization", "Bearer "+tok) p, cred, err := guard.AuthenticateCredential(req) if err != nil { t.Fatalf("%s %s auth: %v", key, name, err) } ctx := bouncer.WithUser(req.Context(), p) if cred != nil { ctx = bouncer.WithCredential(ctx, cred) } h(rec, req.WithContext(ctx)) if rec.Code != http.StatusForbidden { t.Fatalf("%s %s: want 403, got %d", key, name, rec.Code) } assertReason(t, rec, "interactive_only") } // admin:read cannot authenticate on non-GET interactive routes (method filter). if method != http.MethodGet && method != http.MethodHead { req := httptest.NewRequest(method, path, nil) req.Header.Set("Authorization", "Bearer "+adminReadTok) if _, _, err := guard.AuthenticateCredential(req); !errors.Is(err, ErrTokenScope) { t.Fatalf("%s admin:read: want ErrTokenScope, got %v", key, err) } } } // explicit_gate: only Full scopes pass (admin:read on GET). svc := &service{reg: reg} runGate := func(method, path, tok string, handler http.HandlerFunc) *httptest.ResponseRecorder { r := httptest.NewRequest(method, path, nil) r.Header.Set("Authorization", "Bearer "+tok) p, cred, err := guard.AuthenticateCredential(r) if err != nil || p == nil { t.Fatalf("auth %s: %v", tok[:8], err) } ctx := bouncer.WithUser(r.Context(), p) if cred != nil { ctx = bouncer.WithCredential(ctx, cred) } rec := httptest.NewRecorder() handler(rec, r.WithContext(ctx)) return rec } if rec := runGate(http.MethodGet, "/field-types", demoTok, svc.fieldTypesList); rec.Code != http.StatusForbidden { t.Fatalf("demo field-types: want 403, got %d", rec.Code) } else { assertReason(t, rec, "token_scope") } if rec := runGate(http.MethodGet, "/field-types", adminReadTok, svc.fieldTypesList); rec.Code != http.StatusOK { t.Fatalf("admin:read field-types: want 200, got %d %s", rec.Code, rec.Body.String()) } if rec := runGate(http.MethodGet, "/field-types", starTok, svc.fieldTypesList); rec.Code != http.StatusOK { t.Fatalf("* field-types: want 200, got %d", rec.Code) } if rec := runGate(http.MethodPost, "/markdown/preview", demoTok, svc.markdownPreview); rec.Code != http.StatusForbidden { t.Fatalf("demo markdown: want 403, got %d", rec.Code) } else { assertReason(t, rec, "token_scope") } // admin:read is GET/HEAD only → POST method refusal before handler. r := httptest.NewRequest(http.MethodPost, "/markdown/preview", nil) r.Header.Set("Authorization", "Bearer "+adminReadTok) if _, _, err := guard.AuthenticateCredential(r); !errors.Is(err, ErrTokenScope) { t.Fatalf("admin:read POST markdown: want ErrTokenScope, got %v", err) } r = httptest.NewRequest(http.MethodPost, "/markdown/preview", strings.NewReader(`{"markdown":"hi"}`)) r.Header.Set("Content-Type", "application/json") r.Header.Set("Authorization", "Bearer "+starTok) pStar, _, err := guard.AuthenticateCredential(r) if err != nil { t.Fatal(err) } recMD := httptest.NewRecorder() svc.markdownPreview(recMD, r.WithContext(bouncer.WithUser(r.Context(), pStar))) if recMD.Code == http.StatusForbidden { var body struct { Error struct { Details map[string]any `json:"details"` } `json:"error"` } _ = json.Unmarshal(recMD.Body.Bytes(), &body) if body.Error.Details["reason"] == "token_scope" { t.Fatalf("* markdown refused with token_scope: %s", recMD.Body.String()) } } // allows_protect: scoped demo token passes Allows for acme.demo.access; reports scope fails. protectH := func(w http.ResponseWriter, r *http.Request) { p, _ := bouncer.User(r.Context()) if !Allows(p, []string{"acme.demo.access"}) { WriteError(w, http.StatusForbidden, "forbidden", msgForbidden) return } w.WriteHeader(http.StatusOK) } for name, tok := range map[string]string{"*": starTok, "demo": demoTok} { r := httptest.NewRequest(http.MethodGet, "/acme/demo/widgets", nil) r.Header.Set("Authorization", "Bearer "+tok) p, cred, err := guard.AuthenticateCredential(r) if err != nil { t.Fatalf("protect %s: %v", name, err) } ctx := bouncer.WithUser(r.Context(), p) if cred != nil { ctx = bouncer.WithCredential(ctx, cred) } rec := httptest.NewRecorder() protectH(rec, r.WithContext(ctx)) if rec.Code != http.StatusOK { t.Fatalf("protect %s: want 200, got %d", name, rec.Code) } } r = httptest.NewRequest(http.MethodGet, "/acme/demo/widgets", nil) r.Header.Set("Authorization", "Bearer "+adminReadTok) p, _, err := guard.AuthenticateCredential(r) if err != nil { t.Fatal(err) } rec := httptest.NewRecorder() protectH(rec, r.WithContext(bouncer.WithUser(r.Context(), p))) // admin:read is Full → Allows uses owner grants → 200 for developer. if rec.Code != http.StatusOK { t.Fatalf("protect admin:read: want 200, got %d", rec.Code) } // Class expectations summary for *, admin:read (GET), demo. type expect struct { star, adminRead, demo string // "ok", "interactive_only", "token_scope", "public" } byClass := map[tokenRouteClass]expect{ classPublic: {star: "public", adminRead: "public", demo: "public"}, classOpenAnyToken: {star: "ok", adminRead: "ok", demo: "ok"}, classInteractive: {star: "interactive_only", adminRead: "interactive_only", demo: "interactive_only"}, classExplicitGate: {star: "ok", adminRead: "ok", demo: "token_scope"}, classAllowsProtect: {star: "ok", adminRead: "ok", demo: "ok"}, classAllowsNav: {star: "ok", adminRead: "ok", demo: "ok"}, classAllowsSettings: {star: "ok", adminRead: "ok", demo: "ok"}, } for key, class := range tokenRouteClasses { exp, ok := byClass[class] if !ok { t.Fatalf("no expect for class %s on %s", class, key) } _ = exp // exercised via class representatives above; map completeness is the gate if class == "" { t.Fatalf("empty class for %s", key) } } } func assertReason(t *testing.T, rec *httptest.ResponseRecorder, reason string) { t.Helper() var body struct { Error struct { Details map[string]any `json:"details"` } `json:"error"` } if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("json: %v body=%s", err, rec.Body.String()) } if body.Error.Details["reason"] != reason { t.Fatalf("reason = %v, want %s body=%s", body.Error.Details["reason"], reason, rec.Body.String()) } } func TestInteractiveOnly(t *testing.T) { h := interactiveOnly(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) // Token credential refused. rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPut, "/account", nil) req = req.WithContext(bouncer.WithCredential(req.Context(), &BackendAPIToken{ID: 1})) h(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("cred: %d", rec.Code) } assertReason(t, rec, "interactive_only") // Raw g15_ Bearer refused even without credential. plain, _, _, err := mintAPIToken() if err != nil { t.Fatal(err) } for _, path := range []struct { method, path string }{ {http.MethodPut, "/account"}, {http.MethodPost, "/account/avatar"}, {http.MethodDelete, "/account/avatar"}, {http.MethodGet, "/account/tokens"}, {http.MethodPost, "/account/tokens"}, {http.MethodGet, "/account/tokens/scopes"}, {http.MethodDelete, "/account/tokens/1"}, {http.MethodPost, "/auth/refresh"}, {http.MethodPost, "/auth/logout"}, } { rec = httptest.NewRecorder() req = httptest.NewRequest(path.method, path.path, nil) req.Header.Set("Authorization", "Bearer "+plain) h(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("%s %s: %d", path.method, path.path, rec.Code) } assertReason(t, rec, "interactive_only") } // JWT session (non-g15_ bearer, no token credential) passes. rec = httptest.NewRecorder() req = httptest.NewRequest(http.MethodPut, "/account", nil) req.Header.Set("Authorization", "Bearer eyJhbGciOiJIUzI1NiJ9.e30.sig") req = req.WithContext(bouncer.WithUser(req.Context(), &bouncer.Principal{ID: 1, Backend: true})) h(rec, req) if rec.Code != http.StatusOK { t.Fatalf("jwt session: %d", rec.Code) } }