package cabana import ( "context" "encoding/json" "errors" "net/http" "net/http/httptest" "strings" "sync" "testing" "time" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/bouncer" "git.golem15.com/golem15/summercms/modules/lagoon" ) type memTokenStore struct { mu sync.Mutex byHash map[string]*BackendAPIToken stampN int stampIDs []uint } func (s *memTokenStore) FindUsableByHash(_ context.Context, hash string, now time.Time) (*BackendAPIToken, error) { s.mu.Lock() defer s.mu.Unlock() row := s.byHash[hash] if row == nil { return nil, nil } if row.RevokedAt != nil { return nil, nil } if row.ExpiresAt != nil && !row.ExpiresAt.After(now) { return nil, nil } cp := *row return &cp, nil } func (s *memTokenStore) StampLastUsed(_ context.Context, id uint, _ time.Time) error { s.mu.Lock() defer s.mu.Unlock() s.stampN++ s.stampIDs = append(s.stampIDs, id) return nil } func (s *memTokenStore) put(row *BackendAPIToken) { s.mu.Lock() defer s.mu.Unlock() if s.byHash == nil { s.byHash = map[string]*BackendAPIToken{} } s.byHash[row.TokenHash] = row } type stubUsers struct { byID map[uint]*bouncer.Principal } func (s stubUsers) FindByID(_ context.Context, id uint) (*bouncer.Principal, error) { if s.byID == nil { return nil, nil } p := s.byID[id] if p == nil { return nil, nil } cp := *p if p.PermissionGrants != nil { cp.PermissionGrants = map[string]bool{} for k, v := range p.PermissionGrants { cp.PermissionGrants[k] = v } } return &cp, nil } type stubJWTGuard struct { called bool } func (g *stubJWTGuard) Authenticate(r *http.Request) (*bouncer.Principal, error) { g.called = true return &bouncer.Principal{ID: 99, Backend: true, IsSuperuser: true}, nil } func tracerCatalog() *tokenScopeCatalog { cat := &tokenScopeCatalog{byName: map[string]*compiledTokenScope{}} all := map[string]bool{"GET": true, "HEAD": true, "POST": true, "PUT": true, "PATCH": true, "DELETE": true} cat.ordered = []*compiledTokenScope{ {Name: scopeAll, Methods: all, Full: true}, {Name: scopeAdminRead, Methods: map[string]bool{"GET": true, "HEAD": true}, Full: true}, { Name: "demo", Permissions: []string{"acme.demo.*"}, Methods: all, PluginID: "acme.demo", }, { Name: "demo:reports", Permissions: []string{"acme.demo.reports"}, Methods: map[string]bool{"GET": true, "HEAD": true}, PluginID: "acme.demo", }, } for _, s := range cat.ordered { cat.byName[s.Name] = s } return cat } func TestTokenTracerSmoke(t *testing.T) { 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, }, } super := &bouncer.Principal{ID: 2, Backend: true, IsSuperuser: true} users := stubUsers{byID: map[uint]*bouncer.Principal{1: dev, 2: super}} jwt := &stubJWTGuard{} guard := newCompositeGuard(jwt, store, users, reg) mint := func(owner uint, scopes []string) string { plain, hash, hint, err := mintAPIToken() if err != nil { t.Fatal(err) } store.put(&BackendAPIToken{ ID: uint(len(store.byHash) + 1), BackendUserID: owner, Name: "t", TokenHash: hash, TokenHint: hint, Scopes: lagoon.Jsonable[[]string]{Data: scopes, Valid: true}, }) return plain } guards := bouncer.NewRegistry() if err := guards.Register("summercms.cabana", "backend", guard); err != nil { t.Fatal(err) } mw, err := guards.Middleware("backend") if err != nil { t.Fatal(err) } h := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { p, _ := bouncer.User(r.Context()) if Allows(p, []string{"acme.demo.access"}) { w.WriteHeader(http.StatusOK) return } WriteError(w, http.StatusForbidden, "forbidden", msgForbidden) })) req := func(method, token string) *httptest.ResponseRecorder { r := httptest.NewRequest(method, "/api/v1/widgets", nil) if token != "" { r.Header.Set("Authorization", "Bearer "+token) } rec := httptest.NewRecorder() h.ServeHTTP(rec, r) return rec } demoTok := mint(1, []string{"demo"}) if rec := req(http.MethodGet, demoTok); rec.Code != http.StatusOK { t.Fatalf("demo GET: %d %s", rec.Code, rec.Body.String()) } reportsTok := mint(1, []string{"demo:reports"}) if rec := req(http.MethodGet, reportsTok); rec.Code != http.StatusForbidden { t.Fatalf("demo:reports GET: want 403, got %d %s", rec.Code, rec.Body.String()) } if rec := req(http.MethodPost, reportsTok); rec.Code != http.StatusForbidden { t.Fatalf("demo:reports POST: want 403, got %d", rec.Code) } else { var body struct { Error struct { Details map[string]any `json:"details"` } `json:"error"` } if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatal(err) } if body.Error.Details["reason"] != "token_scope" { t.Fatalf("reason = %v", body.Error.Details["reason"]) } } superTok := mint(2, []string{"demo:reports"}) if rec := req(http.MethodGet, superTok); rec.Code != http.StatusForbidden { t.Fatalf("superuser demo:reports: want 403, got %d", rec.Code) } if rec := req(http.MethodGet, "g15_x"); rec.Code != http.StatusUnauthorized { t.Fatalf("malformed: want 401, got %d", rec.Code) } jwt.called = false if rec := req(http.MethodGet, "eyJhbGciOiJIUzI1NiJ9.e30.sig"); rec.Code != http.StatusOK { t.Fatalf("jwt path: want 200 from stub, got %d", rec.Code) } if !jwt.called { t.Fatal("jwt guard was not called for non-g15_ bearer") } if !strings.HasPrefix(demoTok, APITokenPrefix) || len(demoTok) != 44 { t.Fatalf("mint shape %q", demoTok) } } func TestTokenGatesSmoke(t *testing.T) { 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(owner uint, scopes []string, id uint) string { plain, hash, hint, err := mintAPIToken() if err != nil { t.Fatal(err) } store.put(&BackendAPIToken{ ID: id, BackendUserID: owner, Name: "t", TokenHash: hash, TokenHint: hint, Scopes: lagoon.Jsonable[[]string]{Data: scopes, Valid: true}, }) return plain } starTok := mint(1, []string{"*"}, 100) demoTok := mint(1, []string{"demo"}, 101) reportsTok := mint(1, []string{"demo:reports"}, 102) adminReadTok := mint(1, []string{"admin:read"}, 103) // interactiveOnly on PUT /account and refresh with raw g15_ Bearer. accountH := interactiveOnly(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPut, "/account", nil) req = req.WithContext(bouncer.WithCredential(req.Context(), &BackendAPIToken{ID: 100})) accountH(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("account with token cred: %d", rec.Code) } var body struct { Error struct { Details map[string]any `json:"details"` } `json:"error"` } if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Error.Details["reason"] != "interactive_only" { t.Fatalf("account reason: %v %s", err, rec.Body.String()) } refreshH := interactiveOnly(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) rec = httptest.NewRecorder() req = httptest.NewRequest(http.MethodPost, "/auth/refresh", nil) req.Header.Set("Authorization", "Bearer "+starTok) refreshH(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("refresh with g15_: %d", rec.Code) } // fieldTypesList gate svc := &service{reg: reg} runFieldTypes := func(token string) *httptest.ResponseRecorder { r := httptest.NewRequest(http.MethodGet, "/field-types", nil) r.Header.Set("Authorization", "Bearer "+token) p, cred, err := guard.AuthenticateCredential(r) if err != nil || p == nil { t.Fatalf("auth: %v", err) } ctx := bouncer.WithUser(r.Context(), p) if cred != nil { ctx = bouncer.WithCredential(ctx, cred) } rec := httptest.NewRecorder() svc.fieldTypesList(rec, r.WithContext(ctx)) return rec } if rec := runFieldTypes(demoTok); rec.Code != http.StatusForbidden { t.Fatalf("demo field-types: want 403, got %d", rec.Code) } else if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Error.Details["reason"] != "token_scope" { t.Fatalf("field-types reason: %v %s", err, rec.Body.String()) } if rec := runFieldTypes(adminReadTok); rec.Code != http.StatusOK { t.Fatalf("admin:read field-types: want 200, got %d %s", rec.Code, rec.Body.String()) } // ResolveBackend app := backpack.New(nil) if err := app.Publish(&backendResolver{guard: guard}); err != nil { t.Fatal(err) } r := httptest.NewRequest(http.MethodPost, "/plugin", nil) r.Header.Set("Authorization", "Bearer "+reportsTok) if _, _, err := ResolveBackend(app, r, "acme.demo.reports"); !errors.Is(err, ErrTokenScope) { t.Fatalf("reports POST cover: want ErrTokenScope, got %v", err) } r = httptest.NewRequest(http.MethodGet, "/plugin", nil) r.Header.Set("Authorization", "Bearer "+demoTok) if _, _, err := ResolveBackend(app, r, "acme.other.*"); !errors.Is(err, ErrTokenScope) { t.Fatalf("demo cover other: want ErrTokenScope, got %v", err) } r = httptest.NewRequest(http.MethodGet, "/plugin", nil) if _, _, err := ResolveBackend(app, r); !errors.Is(err, ErrUnauthenticated) { t.Fatalf("no bearer: want ErrUnauthenticated, got %v", err) } r = httptest.NewRequest(http.MethodGet, "/plugin", nil) r.Header.Set("Authorization", "Bearer g15_x") if _, _, err := ResolveBackend(app, r); !errors.Is(err, ErrUnauthenticated) { t.Fatalf("malformed: want ErrUnauthenticated, got %v", err) } r = httptest.NewRequest(http.MethodGet, "/plugin", nil) r.Header.Set("Authorization", "Bearer "+demoTok) out, p, err := ResolveBackend(app, r, "acme.demo.*") if err != nil || p == nil || out == nil { t.Fatalf("demo GET cover: %v", err) } if _, ok := bouncer.Credential(out.Context()); !ok { t.Fatal("credential missing on resolved request") } // Stamp debounce: two auths within a minute → one store stamp. guard.stamps.last.Delete(uint(101)) store.stampN = 0 r = httptest.NewRequest(http.MethodGet, "/x", nil) r.Header.Set("Authorization", "Bearer "+demoTok) if _, _, err := guard.AuthenticateCredential(r); err != nil { t.Fatal(err) } if _, _, err := guard.AuthenticateCredential(r); err != nil { t.Fatal(err) } if store.stampN != 1 { t.Fatalf("stamp calls = %d, want 1", store.stampN) } }