Files
summercms/modules/cabana/token_guard_test.go
Jakub Zych 78124ab86f test(16.3-04): full Go coverage for tokens, guard, account API and CLI
Pin bouncer Scope serialization, party Activated order, lagoon migration,
compile/overlap/mint/expiry/validation, guard/debounce/resolver,
interactive-only, account token API and CLI on Postgres.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-11 13:32:11 +02:00

285 lines
10 KiB
Go

package cabana
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/lagoon"
)
type errStampStore struct {
memTokenStore
err error
}
func (s *errStampStore) StampLastUsed(ctx context.Context, id uint, now time.Time) error {
if s.err != nil {
return s.err
}
return s.memTokenStore.StampLastUsed(ctx, id, now)
}
func TestBackendTokenGuard(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}}
jwt := &stubJWTGuard{}
guard := newCompositeGuard(jwt, store, users, reg)
mint := func(id uint, scopes []string, mutate func(*BackendAPIToken)) string {
plain, hash, hint, err := mintAPIToken()
if err != nil {
t.Fatal(err)
}
row := &BackendAPIToken{
ID: id, BackendUserID: 1, Name: "t", TokenHash: hash, TokenHint: hint,
Scopes: lagoon.Jsonable[[]string]{Data: scopes, Valid: true},
}
if mutate != nil {
mutate(row)
}
store.put(row)
return plain
}
auth := func(method, token string) (*bouncer.Principal, any, error) {
r := httptest.NewRequest(method, "/x", nil)
if token != "" {
r.Header.Set("Authorization", "Bearer "+token)
}
return guard.AuthenticateCredential(r)
}
// Well-formedness (prefix must be exact g15_; upper-case hex refused).
for _, bad := range []string{
"g15_short",
"g15_" + strings.Repeat("0", 39),
"g15_" + strings.Repeat("0", 41),
"g15_" + strings.Repeat("G", 40),
"g15_" + strings.Repeat("0", 39) + "Z",
} {
if _, _, err := auth(http.MethodGet, bad); !errors.Is(err, ErrUnauthenticated) {
t.Fatalf("malformed %q: %v", bad, err)
}
}
// Upper-case prefix is not a personal token — routes to the JWT guard.
jwt.called = false
if _, _, err := auth(http.MethodGet, "G15_"+strings.Repeat("0", 40)); err != nil || !jwt.called {
t.Fatalf("upper-case prefix should hit JWT: err=%v called=%v", err, jwt.called)
}
// Prefix routing: JWT untouched; g15_ in cookie/query never authenticates.
jwt.called = false
if _, _, err := auth(http.MethodGet, "eyJhbGciOiJIUzI1NiJ9.e30.sig"); err != nil || !jwt.called {
t.Fatalf("jwt path: err=%v called=%v", err, jwt.called)
}
// g15_ in a cookie or query string never authenticates (Bearer only).
noJWT := newCompositeGuard(nil, store, users, reg)
r := httptest.NewRequest(http.MethodGet, "/x?token=g15_"+strings.Repeat("a", 40), nil)
r.AddCookie(&http.Cookie{Name: "summer_admin", Value: "g15_" + strings.Repeat("b", 40)})
if _, cred, err := noJWT.AuthenticateCredential(r); !errors.Is(err, ErrUnauthenticated) || cred != nil {
t.Fatalf("cookie/query g15_ must not authenticate as a token: err=%v cred=%v", err, cred)
}
plain := mint(10, []string{"demo"}, nil)
if p, cred, err := auth(http.MethodGet, plain); err != nil || p == nil || cred == nil {
t.Fatalf("ok: %v", err)
} else if _, ok := cred.(*BackendAPIToken); !ok {
t.Fatalf("credential type %T", cred)
}
// Unknown hash
if _, _, err := auth(http.MethodGet, "g15_"+strings.Repeat("c", 40)); !errors.Is(err, ErrUnauthenticated) {
t.Fatalf("unknown: %v", err)
}
// Expired / revoked
past := time.Now().UTC().Add(-time.Hour)
expired := mint(11, []string{"demo"}, func(row *BackendAPIToken) { row.ExpiresAt = &past })
if _, _, err := auth(http.MethodGet, expired); !errors.Is(err, ErrUnauthenticated) {
t.Fatalf("expired: %v", err)
}
revoked := mint(12, []string{"demo"}, func(row *BackendAPIToken) { row.RevokedAt = &past })
if _, _, err := auth(http.MethodGet, revoked); !errors.Is(err, ErrUnauthenticated) {
t.Fatalf("revoked: %v", err)
}
// Deactivated / soft-deleted owner
users.byID[1] = nil
alive := mint(13, []string{"demo"}, nil)
if _, _, err := auth(http.MethodGet, alive); !errors.Is(err, ErrUnauthenticated) {
t.Fatalf("deactivated: %v", err)
}
users.byID[1] = dev
// All-unknown scopes → 401
unknown := mint(14, []string{"nope"}, nil)
if _, _, err := auth(http.MethodGet, unknown); !errors.Is(err, ErrUnauthenticated) {
t.Fatalf("unknown scopes: %v", err)
}
// Method refusal → ErrTokenScope (403 via WriteUnauthorized)
reports := mint(15, []string{"demo:reports"}, nil)
if _, _, err := auth(http.MethodPost, reports); !errors.Is(err, ErrTokenScope) {
t.Fatalf("method refusal: %v", err)
}
rec := httptest.NewRecorder()
guard.WriteUnauthorized(rec, newScopeError())
if rec.Code != http.StatusForbidden {
t.Fatalf("WriteUnauthorized code %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"] != "token_scope" {
t.Fatalf("reason body=%s err=%v", rec.Body.String(), err)
}
// Stamp failure does not fail auth
errStore := &errStampStore{memTokenStore: memTokenStore{byHash: map[string]*BackendAPIToken{}}, err: errors.New("stamp boom")}
plain2, hash2, hint2, err := mintAPIToken()
if err != nil {
t.Fatal(err)
}
errStore.put(&BackendAPIToken{
ID: 99, BackendUserID: 1, Name: "t", TokenHash: hash2, TokenHint: hint2,
Scopes: lagoon.Jsonable[[]string]{Data: []string{"demo"}, Valid: true},
})
lastStamp.Delete(uint(99))
g2 := newCompositeGuard(jwt, errStore, users, reg)
r = httptest.NewRequest(http.MethodGet, "/x", nil)
r.Header.Set("Authorization", "Bearer "+plain2)
if p, _, err := g2.AuthenticateCredential(r); err != nil || p == nil {
t.Fatalf("stamp error path: %v", err)
}
}
func TestTokenLastUsedDebounce(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}}
guard := newCompositeGuard(&stubJWTGuard{}, store, stubUsers{byID: map[uint]*bouncer.Principal{1: dev}}, reg)
plain, hash, hint, err := mintAPIToken()
if err != nil {
t.Fatal(err)
}
store.put(&BackendAPIToken{
ID: 42, BackendUserID: 1, Name: "t", TokenHash: hash, TokenHint: hint,
Scopes: lagoon.Jsonable[[]string]{Data: []string{"demo"}, Valid: true},
})
lastStamp.Delete(uint(42))
store.stampN = 0
r := httptest.NewRequest(http.MethodGet, "/x", nil)
r.Header.Set("Authorization", "Bearer "+plain)
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("within minute stamps = %d", store.stampN)
}
lastStamp.Store(uint(42), time.Now().UTC().Add(-2*time.Minute))
if _, _, err := guard.AuthenticateCredential(r); err != nil {
t.Fatal(err)
}
if store.stampN != 2 {
t.Fatalf("after minute stamps = %d", store.stampN)
}
}
func TestResolveBackend(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,
}}
guard := newCompositeGuard(&stubJWTGuard{}, store, stubUsers{byID: map[uint]*bouncer.Principal{1: dev}}, 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
}
star := mint(1, []string{"*"})
demo := mint(2, []string{"demo"})
reports := mint(3, []string{"demo:reports"})
if _, _, err := ResolveBackend(nil, httptest.NewRequest(http.MethodGet, "/", nil)); !errors.Is(err, ErrUnauthenticated) {
t.Fatalf("no app: %v", err)
}
app := backpack.New(nil)
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.Header.Set("Authorization", "Bearer "+demo)
if _, _, err := ResolveBackend(app, r); !errors.Is(err, ErrUnauthenticated) {
t.Fatalf("no resolver: %v", err)
}
if err := app.Publish(&backendResolver{guard: guard}); err != nil {
t.Fatal(err)
}
r = httptest.NewRequest(http.MethodGet, "/", nil)
if _, _, err := ResolveBackend(app, r); !errors.Is(err, ErrUnauthenticated) {
t.Fatalf("no bearer: %v", err)
}
r = httptest.NewRequest(http.MethodGet, "/", nil)
r.AddCookie(&http.Cookie{Name: "summer_admin", Value: "eyJhbGciOiJIUzI1NiJ9.e30.sig"})
if _, _, err := ResolveBackend(app, r); !errors.Is(err, ErrUnauthenticated) {
t.Fatalf("cookie-only: %v", err)
}
r = httptest.NewRequest(http.MethodGet, "/", nil)
r.Header.Set("Authorization", "Bearer eyJhbGciOiJIUzI1NiJ9.e30.sig")
out, p, err := ResolveBackend(app, r, "acme.other.*")
if err != nil || p == nil || out == nil {
t.Fatalf("jwt cover ignored: %v", err)
}
if _, ok := bouncer.Credential(out.Context()); ok {
t.Fatal("jwt must not set token credential")
}
r = httptest.NewRequest(http.MethodGet, "/", nil)
r.Header.Set("Authorization", "Bearer "+star)
if out, p, err = ResolveBackend(app, r, "anything"); err != nil || p == nil || out == nil {
t.Fatalf("full scope: %v", err)
}
r = httptest.NewRequest(http.MethodGet, "/", nil)
r.Header.Set("Authorization", "Bearer "+demo)
if out, p, err = ResolveBackend(app, r, "acme.demo.*"); err != nil || p == nil {
t.Fatalf("matching cover: %v", err)
}
if _, ok := bouncer.Credential(out.Context()); !ok {
t.Fatal("token credential missing")
}
r = httptest.NewRequest(http.MethodGet, "/", nil)
r.Header.Set("Authorization", "Bearer "+demo)
if _, _, err = ResolveBackend(app, r, "acme.other.*"); !errors.Is(err, ErrTokenScope) {
t.Fatalf("non-matching cover: %v", err)
}
r = httptest.NewRequest(http.MethodPost, "/", nil)
r.Header.Set("Authorization", "Bearer "+reports)
if _, _, err = ResolveBackend(app, r, "acme.demo.reports"); !errors.Is(err, ErrTokenScope) {
t.Fatalf("method refusal: %v", err)
}
}