diff --git a/admin/tests/account/ApiTokenCreateDialog.test.ts b/admin/tests/account/ApiTokenCreateDialog.test.ts new file mode 100644 index 0000000..ac16d83 --- /dev/null +++ b/admin/tests/account/ApiTokenCreateDialog.test.ts @@ -0,0 +1,190 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { enableAutoUnmount, flushPromises } from '@vue/test-utils' +import { toasts } from '../../src/state/useToasts' +import { API, keydown, mountApp, requestsTo, resetState, type Route } from '../helpers' + +const TOKENS = `${API}/account/tokens` +const SCOPES = `${API}/account/tokens/scopes` + +const envelope = (data: unknown, status = 200) => ({ status, body: { data, meta: {} } }) + +const PLAIN = 'g15_0123456789abcdef0123456789abcdef01234567' + +const createdToken = { + id: 12, + name: 'CI runner', + hint: 'c3d4', + scopes: [ + { + name: 'admin:read', + label: 'Read-only, whole admin', + description: 'View everything', + read_only: true, + known: true, + }, + ], + created_at: '2026-01-10T12:00:00Z', + last_used_at: '', + expires_at: '2026-04-10T12:00:00Z', + revoked_at: '', + expired: false, + token: PLAIN, +} + +const scopes = [ + { + name: '*', + label: 'Full access', + description: 'Everything', + read_only: false, + known: true, + }, + { + name: 'admin:read', + label: 'Read-only, whole admin', + description: 'View everything', + read_only: true, + known: true, + }, +] + +function tokenRoutes(overrides: Record = {}): Record { + return { + [`GET ${TOKENS}`]: envelope([]), + [`GET ${SCOPES}`]: envelope(scopes), + [`POST ${TOKENS}`]: envelope(createdToken, 201), + ...overrides, + } +} + +const panel = () => document.body.querySelector('[data-api-tokens]') +const dialog = () => document.body.querySelector('[data-token-dialog]') +const confirmDialog = () => document.body.querySelector('[data-confirm-dialog]') + +async function click(selector: string, root: ParentNode = document.body): Promise { + const el = root.querySelector(selector) + el?.click() + await flushPromises() +} + +async function openCreateAtStep2(): Promise { + await mountApp('/account', tokenRoutes(), { attach: true }) + await flushPromises() + await click('[data-action="create-token"]', panel()!) + await flushPromises() + const nameInput = dialog()!.querySelector('#api-token-name')! + nameInput.value = 'Agent' + nameInput.dispatchEvent(new Event('input', { bubbles: true })) + const scope = dialog()!.querySelector('[data-scope="admin:read"] input')! + scope.checked = true + scope.dispatchEvent(new Event('change', { bubbles: true })) + await flushPromises() + await click('[data-action="create-token-submit"]', dialog()!) + expect(dialog()!.querySelector('[data-token-plaintext]')!.value).toBe(PLAIN) +} + +function plaintextRetained(): boolean { + if (document.body.innerHTML.includes(PLAIN)) return true + if (document.body.textContent?.includes(PLAIN)) return true + for (const toast of toasts.value) { + if (toast.text.includes(PLAIN)) return true + } + return false +} + +beforeEach(() => { + resetState() +}) + +afterEach(() => { + document.body.innerHTML = '' + vi.unstubAllGlobals() +}) + +enableAutoUnmount(afterEach) + +describe('ApiTokenCreateDialog plaintext lifetime', () => { + it('clears plaintext from the DOM and toasts after Done (hide)', async () => { + await openCreateAtStep2() + await click('[data-action="hide-token"]', dialog()!) + expect(dialog()).toBeNull() + expect(plaintextRetained()).toBe(false) + }) + + it('clears plaintext after Esc-confirm close and reopening starts at step 1', async () => { + await openCreateAtStep2() + keydown(dialog()!, 'Escape') + await flushPromises() + if (!confirmDialog()) { + await click('[data-action="close"]', dialog()!) + } + expect(confirmDialog()).toBeTruthy() + await click('[data-action="confirm"]', confirmDialog()!) + await flushPromises() + expect(dialog()).toBeNull() + expect(plaintextRetained()).toBe(false) + + await click('[data-action="create-token"]', panel()!) + await flushPromises() + expect(dialog()).toBeTruthy() + expect(dialog()!.querySelector('[data-token-plaintext]')).toBeNull() + expect(dialog()!.querySelector('#api-token-name')).toBeTruthy() + expect(plaintextRetained()).toBe(false) + }) + + it('copy success announces; clipboard failure selects and alerts', async () => { + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }) + await openCreateAtStep2() + await click('[data-action="copy-token"]', dialog()!) + expect(writeText).toHaveBeenCalledWith(PLAIN) + expect(dialog()!.querySelector('[aria-live="polite"]')!.textContent).toContain( + 'Skopiowano token do schowka.', + ) + + writeText.mockRejectedValueOnce(new Error('denied')) + await click('[data-action="copy-token"]', dialog()!) + expect(dialog()!.querySelector('[role="alert"]')!.textContent).toContain( + 'Nie udało się skopiować automatycznie', + ) + expect(document.activeElement).toBe(dialog()!.querySelector('[data-token-plaintext]')) + }) + + it('Esc before copying asks and cancel keeps step 2', async () => { + await openCreateAtStep2() + keydown(dialog()!, 'Escape') + await flushPromises() + if (!confirmDialog()) { + await click('[data-action="close"]', dialog()!) + } + expect(confirmDialog()).toBeTruthy() + await click('[data-action="cancel"]', confirmDialog()!) + expect(dialog()!.querySelector('[data-token-plaintext]')!.value).toBe(PLAIN) + }) +}) + +describe('ApiTokenCreateDialog step 1 smoke from dedicated file', () => { + it('posts selected scopes and never leaves plaintext after Done', async () => { + const { calls } = await mountApp('/account', tokenRoutes(), { attach: true }) + await click('[data-action="create-token"]', panel()!) + const nameInput = dialog()!.querySelector('#api-token-name')! + nameInput.value = 'CI' + nameInput.dispatchEvent(new Event('input', { bubbles: true })) + const scope = dialog()!.querySelector('[data-scope="admin:read"] input')! + scope.checked = true + scope.dispatchEvent(new Event('change', { bubbles: true })) + await flushPromises() + await click('[data-action="create-token-submit"]', dialog()!) + const [post] = requestsTo(calls, 'POST', TOKENS) + expect(await post!.clone().json()).toEqual({ + name: 'CI', + expires: '90d', + scopes: ['admin:read'], + }) + await click('[data-action="hide-token"]', dialog()!) + expect(plaintextRetained()).toBe(false) + }) +}) diff --git a/modules/cabana/token_postgres_test.go b/modules/cabana/token_postgres_test.go new file mode 100644 index 0000000..af7a8e2 --- /dev/null +++ b/modules/cabana/token_postgres_test.go @@ -0,0 +1,131 @@ +package cabana_test + +import ( + "bytes" + "context" + "net/http" + "testing" + "time" + + "git.golem15.com/golem15/summercms/modules/bonfire" + "git.golem15.com/golem15/summercms/modules/cabana" +) + +func TestBackendTokenPostgres(t *testing.T) { + gdb := adminGorm(t) + if err := gdb.AutoMigrate(&tokenDemoWidget{}); err != nil { + t.Fatal(err) + } + h := tokenAdminHandler(t) + user := insertAdmin(t, gdb, "tok-life", "tok-life@example.test", "secret-pass", true, false) + listPath := adminAPI("/acme/demo/widgets") + + plain, err := cabana.IssueTestAPIToken(gdb, user.ID, []string{"demo"}, nil) + if err != nil { + t.Fatal(err) + } + if rec := getAuth(t, h, listPath, plain); rec.Code != http.StatusOK { + t.Fatalf("initial: %d %s", rec.Code, rec.Body.String()) + } + + // D-10: deactivate → 401, reactivate → 200. + if err := gdb.Exec(`UPDATE backend_users SET is_activated = false WHERE id = ?`, user.ID).Error; err != nil { + t.Fatal(err) + } + if rec := getAuth(t, h, listPath, plain); rec.Code != http.StatusUnauthorized { + t.Fatalf("deactivated: want 401, got %d %s", rec.Code, rec.Body.String()) + } + if err := gdb.Exec(`UPDATE backend_users SET is_activated = true WHERE id = ?`, user.ID).Error; err != nil { + t.Fatal(err) + } + if rec := getAuth(t, h, listPath, plain); rec.Code != http.StatusOK { + t.Fatalf("reactivated: want 200, got %d %s", rec.Code, rec.Body.String()) + } + + // Password reset / tokens_valid_after leave the personal API token working. + reset := commandByName(t, cabana.RuntimeCommands(commandApp(t, gdb)), "admin:reset-password") + var buf bytes.Buffer + out := bonfire.NewOutput(nil, &buf, &buf) + if err := reset.Run(context.Background(), flagInput{ + args: []string{user.Login}, + flags: map[string]string{"password": "new-secret-pass"}, + }, out); err != nil { + t.Fatalf("admin:reset-password: %v output=%s", err, buf.String()) + } + var afterReset cabana.BackendUser + if err := gdb.First(&afterReset, user.ID).Error; err != nil { + t.Fatal(err) + } + if afterReset.TokensValidAfter == nil { + t.Fatal("reset did not set tokens_valid_after") + } + if rec := getAuth(t, h, listPath, plain); rec.Code != http.StatusOK { + t.Fatalf("after password reset: want 200, got %d %s", rec.Code, rec.Body.String()) + } + cutoff := time.Now().UTC().Add(time.Hour) + if err := gdb.Model(&cabana.BackendUser{}).Where("id = ?", user.ID).Update("tokens_valid_after", cutoff).Error; err != nil { + t.Fatal(err) + } + if rec := getAuth(t, h, listPath, plain); rec.Code != http.StatusOK { + t.Fatalf("after tokens_valid_after cutoff: want 200, got %d %s", rec.Code, rec.Body.String()) + } + + // Revoked → 401. + now := time.Now().UTC() + if err := gdb.Exec(`UPDATE backend_api_tokens SET revoked_at = ? WHERE backend_user_id = ?`, now, user.ID).Error; err != nil { + t.Fatal(err) + } + if rec := getAuth(t, h, listPath, plain); rec.Code != http.StatusUnauthorized { + t.Fatalf("revoked: want 401, got %d %s", rec.Code, rec.Body.String()) + } + + // Fresh token, expired → 401. + expired := now.Add(-time.Hour) + plain2, err := cabana.IssueTestAPIToken(gdb, user.ID, []string{"demo"}, &expired) + if err != nil { + t.Fatal(err) + } + if rec := getAuth(t, h, listPath, plain2); rec.Code != http.StatusUnauthorized { + t.Fatalf("expired: want 401, got %d %s", rec.Code, rec.Body.String()) + } + + // Soft-delete owner → 401. + plain3, err := cabana.IssueTestAPIToken(gdb, user.ID, []string{"demo"}, nil) + if err != nil { + t.Fatal(err) + } + if err := gdb.Delete(&user).Error; err != nil { + t.Fatal(err) + } + if rec := getAuth(t, h, listPath, plain3); rec.Code != http.StatusUnauthorized { + t.Fatalf("soft-deleted owner: want 401, got %d %s", rec.Code, rec.Body.String()) + } + + // last_used_at stamped once within a minute for two requests. + user2 := insertAdmin(t, gdb, "tok-stamp", "tok-stamp@example.test", "secret-pass", true, false) + plain4, err := cabana.IssueTestAPIToken(gdb, user2.ID, []string{"demo"}, nil) + if err != nil { + t.Fatal(err) + } + var tokenID uint + if err := gdb.Raw(`SELECT id FROM backend_api_tokens WHERE backend_user_id = ? ORDER BY id DESC LIMIT 1`, user2.ID).Scan(&tokenID).Error; err != nil || tokenID == 0 { + t.Fatalf("token id: %d err=%v", tokenID, err) + } + if rec := getAuth(t, h, listPath, plain4); rec.Code != http.StatusOK { + t.Fatalf("stamp first: %d %s", rec.Code, rec.Body.String()) + } + if rec := getAuth(t, h, listPath, plain4); rec.Code != http.StatusOK { + t.Fatalf("stamp second: %d %s", rec.Code, rec.Body.String()) + } + var row cabana.BackendAPIToken + if err := gdb.First(&row, tokenID).Error; err != nil { + t.Fatal(err) + } + if row.LastUsedAt == nil { + t.Fatal("last_used_at not set") + } + // Ensure the stamp path does not write plaintext scopes oddly. + if !row.Scopes.Valid || len(row.Scopes.Get()) == 0 { + t.Fatalf("scopes = %#v", row.Scopes) + } +} diff --git a/modules/cabana/token_routes_test.go b/modules/cabana/token_routes_test.go new file mode 100644 index 0000000..dbe5814 --- /dev/null +++ b/modules/cabana/token_routes_test.go @@ -0,0 +1,348 @@ +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}/filters/{scope}/options": classAllowsProtect, + "GET /{vendor}/{plugin}/{controller}": classAllowsProtect, + "POST /{vendor}/{plugin}/{controller}": classAllowsProtect, + "POST /{vendor}/{plugin}/{controller}/bulk-delete": 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()) + } +} diff --git a/modules/cabana/token_scopes_test.go b/modules/cabana/token_scopes_test.go new file mode 100644 index 0000000..d14872a --- /dev/null +++ b/modules/cabana/token_scopes_test.go @@ -0,0 +1,170 @@ +package cabana + +import ( + "testing" + + "git.golem15.com/golem15/summercms/modules/bouncer" +) + +func TestScopeMatch(t *testing.T) { + cases := []struct { + name string + pattern string + required string + want int + }{ + {"exact full same", "a.b.c", "a.b.c", matchFull}, + {"exact full via a.b.*", "a.b.*", "a.b.c", matchFull}, + {"exact full via a.*", "a.*", "a.b.c", matchFull}, + {"exact none a.x.*", "a.x.*", "a.b.c", matchNone}, + {"exact none a.b.d", "a.b.d", "a.b.c", matchNone}, + {"wild full same", "a.b.*", "a.b.*", matchFull}, + {"wild full broader a.*", "a.*", "a.b.*", matchFull}, + {"wild partial narrower pattern", "a.b.*", "a.*", matchPartial}, + {"wild partial exact narrower", "a.b.c", "a.*", matchPartial}, + {"wild none a.x.*", "a.x.*", "a.b.*", matchNone}, + {"wild none x.*", "x.*", "a.b.*", matchNone}, + {"leading partial exact", "*.manage", "a.manage", matchPartial}, + {"leading none wild required", "*.manage", "a.manage.*", matchNone}, + {"leading none wild pattern required", "*.manage", "*.manage", matchNone}, + {"empty pattern", "", "a.b.c", matchNone}, + {"empty required", "a.b.c", "", matchNone}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := scopeMatch(tc.pattern, tc.required); got != tc.want { + t.Fatalf("scopeMatch(%q, %q) = %d, want %d", tc.pattern, tc.required, got, tc.want) + } + }) + } +} + +func TestAllowsScope(t *testing.T) { + dev := &bouncer.Principal{ + ID: 1, + Backend: true, + PermissionGrants: map[string]bool{ + "acme.demo.access": true, + "acme.demo.reports": true, + }, + } + super := &bouncer.Principal{ID: 2, Backend: true, IsSuperuser: true} + + cases := []struct { + name string + p *bouncer.Principal + scope *bouncer.ScopeLimit + required []string + want bool + }{ + { + name: "JWT nil scope keeps empty-list shortcut", + p: dev, scope: nil, required: nil, want: true, + }, + { + name: "JWT nil scope permission grant", + p: dev, scope: nil, required: []string{"acme.demo.access"}, want: true, + }, + { + name: "JWT nil scope missing grant", + p: dev, scope: nil, required: []string{"acme.other.x"}, want: false, + }, + { + name: "JWT superuser empty list", + p: super, scope: nil, required: nil, want: true, + }, + { + name: "full * every method empty list", + p: dev, scope: &bouncer.ScopeLimit{Full: true}, required: nil, want: true, + }, + { + name: "full * permission as owner", + p: dev, scope: &bouncer.ScopeLimit{Full: true}, required: []string{"acme.demo.access"}, want: true, + }, + { + name: "full admin:read empty list", + p: dev, scope: &bouncer.ScopeLimit{Full: true}, required: nil, want: true, + }, + { + name: "plugin scope with owner grant", + p: dev, + scope: &bouncer.ScopeLimit{Scopes: [][]string{{"acme.demo.*"}}}, + required: []string{"acme.demo.access"}, want: true, + }, + { + name: "plugin scope without owner grant", + p: &bouncer.Principal{ + ID: 3, Backend: true, + PermissionGrants: map[string]bool{"acme.other.x": true}, + }, + scope: &bouncer.ScopeLimit{Scopes: [][]string{{"acme.demo.*"}}}, + required: []string{"acme.demo.access"}, want: false, + }, + { + name: "superuser with scoped token limited", + p: super, + scope: &bouncer.ScopeLimit{Scopes: [][]string{{"acme.demo.reports"}}}, + required: []string{"acme.demo.access"}, want: false, + }, + { + name: "superuser scoped token matching scope code", + p: super, + scope: &bouncer.ScopeLimit{Scopes: [][]string{{"acme.demo.reports"}}}, + required: []string{"acme.demo.reports"}, want: true, + }, + { + name: "empty requirement list refused unless full", + p: dev, + scope: &bouncer.ScopeLimit{Scopes: [][]string{{"acme.demo.*"}}}, + required: nil, want: false, + }, + { + name: "multi-code literal: one scope must match every code", + p: dev, + scope: &bouncer.ScopeLimit{Scopes: [][]string{{"acme.demo.*"}}}, + required: []string{"acme.demo.access", "acme.demo.reports"}, want: true, + }, + { + name: "multi-code literal: two scopes each matching part fails", + p: dev, + scope: &bouncer.ScopeLimit{Scopes: [][]string{ + {"acme.demo.access"}, + {"acme.demo.reports"}, + }}, + required: []string{"acme.demo.access", "acme.demo.reports"}, want: false, + }, + { + name: "wildcard requirement broader pattern partial via ownerHas(pattern)", + p: &bouncer.Principal{ + ID: 4, Backend: true, + PermissionGrants: map[string]bool{"acme.demo.*": true}, + }, + scope: &bouncer.ScopeLimit{Scopes: [][]string{{"acme.demo.access"}}}, + required: []string{"acme.demo.*"}, want: true, + }, + { + name: "wildcard requirement equal pattern full", + p: dev, + scope: &bouncer.ScopeLimit{Scopes: [][]string{{"acme.demo.*"}}}, + required: []string{"acme.demo.*"}, want: true, + }, + { + name: "wildcard requirement narrower pattern none for Allows owner side", + p: &bouncer.Principal{ + ID: 5, Backend: true, + PermissionGrants: map[string]bool{"acme.demo.access": true}, + }, + scope: &bouncer.ScopeLimit{Scopes: [][]string{{"acme.demo.access.extra"}}}, + required: []string{"acme.demo.*"}, want: false, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + p := *tc.p + p.Scope = tc.scope + if got := Allows(&p, tc.required); got != tc.want { + t.Fatalf("Allows = %v, want %v", got, tc.want) + } + }) + } +}