- lagoon.OrderBy takes variadic lagoon.OrderOption values; lagoon.Collate(name) emits a validated, double-quoted COLLATE clause (e.g. "pl-x-icu") - remove the exported CheckLocale and the ICU pl-PL check from Open and Use - framework test containers and per-test databases are plain PostgreSQL - lagoon README, root README and docs pages drop the locale requirement; queries-and-pagination gains a "Sorting with a collation" section backed by ExampleCollate
556 lines
18 KiB
Go
556 lines
18 KiB
Go
package cabana_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"database/sql"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"io/fs"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"testing/fstest"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
|
"git.golem15.com/golem15/summercms/modules/compass"
|
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
"git.golem15.com/golem15/summercms/modules/party"
|
|
"git.golem15.com/golem15/summercms/modules/surf"
|
|
_ "github.com/jackc/pgx/v5/stdlib"
|
|
"github.com/testcontainers/testcontainers-go/modules/postgres"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
const (
|
|
adminTestSecret = "summercms-test-only-admin-hs256-secret"
|
|
adminTestPassword = "lifecycle-secret"
|
|
)
|
|
|
|
func TestAdminAuthLifecycle(t *testing.T) {
|
|
gdb := adminGorm(t)
|
|
h := adminHandler(t, gdb, nil)
|
|
user := insertAdmin(t, gdb, "life", "Life@Example.Test", adminTestPassword, true, false)
|
|
login := postJSON(t, h, adminAPI("/auth/login"), map[string]string{
|
|
"login": "life",
|
|
"password": adminTestPassword,
|
|
})
|
|
if login.Code != http.StatusOK {
|
|
t.Fatalf("login status=%d body=%s", login.Code, login.Body.String())
|
|
}
|
|
token := accessToken(t, login.Body.Bytes())
|
|
if aud := jwtAudience(t, token); aud != "backend" {
|
|
t.Fatalf("aud=%q", aud)
|
|
}
|
|
if strings.Contains(login.Body.String(), adminTestPassword) {
|
|
t.Fatal("login body contains the password")
|
|
}
|
|
var stamped sql.NullTime
|
|
if err := gdb.Raw(`SELECT last_login FROM backend_users WHERE id = ?`, user.ID).Scan(&stamped).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !stamped.Valid {
|
|
t.Fatal("successful login did not stamp last_login")
|
|
}
|
|
byEmail := postJSON(t, h, adminAPI("/auth/login"), map[string]string{
|
|
"email": "life@example.test",
|
|
"password": adminTestPassword,
|
|
})
|
|
if byEmail.Code != http.StatusOK {
|
|
t.Fatalf("email login status=%d body=%s", byEmail.Code, byEmail.Body.String())
|
|
}
|
|
emailToken := accessToken(t, byEmail.Body.Bytes())
|
|
me := getAuth(t, h, adminAPI("/auth/me"), emailToken)
|
|
if me.Code != http.StatusOK {
|
|
t.Fatalf("me status=%d body=%s", me.Code, me.Body.String())
|
|
}
|
|
assertSafeProfile(t, me.Body.Bytes(), user)
|
|
refreshed := postAuth(t, h, http.MethodPost, adminAPI("/auth/refresh"), emailToken, nil)
|
|
if refreshed.Code != http.StatusOK {
|
|
t.Fatalf("refresh status=%d body=%s", refreshed.Code, refreshed.Body.String())
|
|
}
|
|
next := accessToken(t, refreshed.Body.Bytes())
|
|
if next == emailToken {
|
|
t.Fatal("refresh returned the same token")
|
|
}
|
|
if jwtAudience(t, next) != "backend" {
|
|
t.Fatal("refreshed token lost the backend audience")
|
|
}
|
|
oldMe := getAuth(t, h, adminAPI("/auth/me"), emailToken)
|
|
if oldMe.Code != http.StatusUnauthorized {
|
|
t.Fatalf("previous token after refresh status=%d body=%s", oldMe.Code, oldMe.Body.String())
|
|
}
|
|
var blacklisted int
|
|
if err := gdb.Raw(`SELECT COUNT(*) FROM backend_jwt_blacklist WHERE jti = ?`, jwtID(t, emailToken)).Scan(&blacklisted).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if blacklisted != 1 {
|
|
t.Fatalf("previous jti blacklist rows=%d", blacklisted)
|
|
}
|
|
out := postAuth(t, h, http.MethodPost, adminAPI("/auth/logout"), next, nil)
|
|
if out.Code != http.StatusOK {
|
|
t.Fatalf("logout status=%d body=%s", out.Code, out.Body.String())
|
|
}
|
|
if strings.Contains(out.Body.String(), next) {
|
|
t.Fatal("logout body contains the token")
|
|
}
|
|
after := getAuth(t, h, adminAPI("/auth/me"), next)
|
|
if after.Code != http.StatusUnauthorized {
|
|
t.Fatalf("me after logout status=%d", after.Code)
|
|
}
|
|
cutoff := time.Now().Add(time.Minute)
|
|
if err := gdb.Model(&cabana.BackendUser{}).Where("id = ?", user.ID).Update("tokens_valid_after", cutoff).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fresh := accessToken(t, postJSON(t, h, adminAPI("/auth/login"), map[string]string{
|
|
"login": "life", "password": adminTestPassword,
|
|
}).Body.Bytes())
|
|
// Login mints after the cutoff, so this token is current. Move the cutoff
|
|
// past it to prove a stale principal fails closed.
|
|
if err := gdb.Model(&cabana.BackendUser{}).Where("id = ?", user.ID).Update("tokens_valid_after", time.Now().Add(time.Hour)).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
stale := getAuth(t, h, adminAPI("/auth/me"), fresh)
|
|
if stale.Code != http.StatusUnauthorized {
|
|
t.Fatalf("stale principal status=%d body=%s", stale.Code, stale.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestAdminInactive(t *testing.T) {
|
|
gdb := adminGorm(t)
|
|
h := adminHandler(t, gdb, nil)
|
|
insertAdmin(t, gdb, "inactive", "inactive@example.test", adminTestPassword, false, false)
|
|
unknown := postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": "nobody", "password": adminTestPassword})
|
|
wrong := postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": "inactive", "password": "wrong-password"})
|
|
right := postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": "inactive", "password": adminTestPassword})
|
|
assertSameOpaque(t, unknown, wrong, right)
|
|
var stamped sql.NullTime
|
|
if err := gdb.Raw(`SELECT last_login FROM backend_users WHERE login = 'inactive'`).Scan(&stamped).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if stamped.Valid {
|
|
t.Fatal("inactive login stamped last_login")
|
|
}
|
|
}
|
|
|
|
func TestAdminDeleted(t *testing.T) {
|
|
gdb := adminGorm(t)
|
|
h := adminHandler(t, gdb, nil)
|
|
insertAdmin(t, gdb, "deleted", "deleted@example.test", adminTestPassword, true, true)
|
|
unknown := postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": "nobody-else", "password": adminTestPassword})
|
|
deleted := postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": "deleted", "password": adminTestPassword})
|
|
assertSameOpaque(t, unknown, deleted)
|
|
if strings.Contains(strings.ToLower(deleted.Body.String()), "delet") {
|
|
t.Fatalf("deleted login disclosed the account: %s", deleted.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestAdminBlacklist(t *testing.T) {
|
|
gdb := adminGorm(t)
|
|
h := adminHandler(t, gdb, nil)
|
|
insertAdmin(t, gdb, "revoke", "revoke@example.test", adminTestPassword, true, false)
|
|
token := accessToken(t, postJSON(t, h, adminAPI("/auth/login"), map[string]string{
|
|
"login": "revoke", "password": adminTestPassword,
|
|
}).Body.Bytes())
|
|
out := postAuth(t, h, http.MethodPost, adminAPI("/auth/logout"), token, nil)
|
|
if out.Code != http.StatusOK {
|
|
t.Fatalf("logout status=%d body=%s", out.Code, out.Body.String())
|
|
}
|
|
again := postAuth(t, h, http.MethodPost, adminAPI("/auth/refresh"), token, nil)
|
|
if again.Code != http.StatusUnauthorized {
|
|
t.Fatalf("refresh after logout status=%d body=%s", again.Code, again.Body.String())
|
|
}
|
|
var n int
|
|
if err := gdb.Raw(`SELECT COUNT(*) FROM backend_jwt_blacklist WHERE jti = ?`, jwtID(t, token)).Scan(&n).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if n != 1 {
|
|
t.Fatalf("admin blacklist rows=%d, want the revoked jti in backend_jwt_blacklist", n)
|
|
}
|
|
}
|
|
|
|
func TestAdminLoginThrottle(t *testing.T) {
|
|
gdb := adminGorm(t)
|
|
h := adminHandler(t, gdb, func(cfg *compass.Config) {
|
|
if err := cfg.Set("admin.login.max_attempts", 2); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := cfg.Set("admin.login.decay_minutes", 1); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
})
|
|
var last *httptest.ResponseRecorder
|
|
for i := 0; i < 3; i++ {
|
|
last = postJSON(t, h, adminAPI("/auth/login"), map[string]string{
|
|
"login": "throttle-user", "password": adminTestPassword,
|
|
})
|
|
}
|
|
if last.Code != http.StatusTooManyRequests {
|
|
t.Fatalf("third login status=%d body=%s, want 429 from the fixed-window limiter", last.Code, last.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestAdminAuthLogging(t *testing.T) {
|
|
gdb := adminGorm(t)
|
|
h := adminHandler(t, gdb, nil)
|
|
user := insertAdmin(t, gdb, "logged", "logged@example.test", adminTestPassword, true, false)
|
|
var publisherID uint
|
|
if err := gdb.Raw(`SELECT id FROM backend_user_roles WHERE code = 'publisher'`).Scan(&publisherID).Error; err != nil || publisherID == 0 {
|
|
t.Fatalf("publisher role: id=%d err=%v", publisherID, err)
|
|
}
|
|
if err := gdb.Model(&cabana.BackendUser{}).Where("id = ?", user.ID).Update("role_id", publisherID).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var buf bytes.Buffer
|
|
prev := slog.Default()
|
|
slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, nil)))
|
|
t.Cleanup(func() { slog.SetDefault(prev) })
|
|
|
|
ok := postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": "logged", "password": adminTestPassword})
|
|
token := accessToken(t, ok.Body.Bytes())
|
|
assertLog(t, &buf, "success", user.ID, adminTestPassword, token)
|
|
buf.Reset()
|
|
|
|
postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": "logged", "password": "not-the-password"})
|
|
assertLog(t, &buf, "failed", user.ID, "not-the-password", "")
|
|
buf.Reset()
|
|
|
|
postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": "missing-logged", "password": "not-the-password"})
|
|
failedUnknown := buf.String()
|
|
if !strings.Contains(failedUnknown, `"outcome":"failed"`) {
|
|
t.Fatalf("unknown login log = %s", failedUnknown)
|
|
}
|
|
if strings.Contains(failedUnknown, `"admin_id"`) || strings.Contains(failedUnknown, "not-the-password") {
|
|
t.Fatalf("unknown login log disclosed an id or password: %s", failedUnknown)
|
|
}
|
|
buf.Reset()
|
|
|
|
denied := getAuth(t, h, adminAPI("/acme/demo/widgets"), token)
|
|
if denied.Code != http.StatusForbidden {
|
|
t.Fatalf("denied status=%d body=%s", denied.Code, denied.Body.String())
|
|
}
|
|
assertLog(t, &buf, "denied", user.ID, adminTestPassword, token)
|
|
}
|
|
|
|
func assertLog(t *testing.T, buf *bytes.Buffer, outcome string, id uint, secret, token string) {
|
|
t.Helper()
|
|
text := buf.String()
|
|
if !strings.Contains(text, `"outcome":"`+outcome+`"`) || !strings.Contains(text, `"admin_id":`+itoa(id)) {
|
|
t.Fatalf("log %q missing outcome %s admin %d", text, outcome, id)
|
|
}
|
|
if secret != "" && strings.Contains(text, secret) {
|
|
t.Fatalf("log contains a credential: %s", text)
|
|
}
|
|
if token != "" && strings.Contains(text, token) {
|
|
t.Fatalf("log contains a bearer token: %s", text)
|
|
}
|
|
if strings.Contains(text, "$2a$") || strings.Contains(text, adminTestSecret) {
|
|
t.Fatalf("log contains a hash or signing secret: %s", text)
|
|
}
|
|
}
|
|
|
|
func assertSameOpaque(t *testing.T, recs ...*httptest.ResponseRecorder) {
|
|
t.Helper()
|
|
if len(recs) == 0 {
|
|
t.Fatal("no responses")
|
|
}
|
|
want := recs[0].Body.String()
|
|
for _, rec := range recs {
|
|
if rec.Code != http.StatusUnauthorized || rec.Body.String() != want {
|
|
t.Fatalf("opaque mismatch %d %s vs %s", rec.Code, rec.Body.String(), want)
|
|
}
|
|
var body struct {
|
|
Error struct {
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
} `json:"error"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if body.Error.Code != "unauthenticated" || body.Error.Message == "" {
|
|
t.Fatalf("error = %+v", body.Error)
|
|
}
|
|
}
|
|
}
|
|
|
|
func assertSafeProfile(t *testing.T, raw []byte, user cabana.BackendUser) {
|
|
t.Helper()
|
|
if strings.Contains(string(raw), adminTestPassword) || strings.Contains(string(raw), "permissions") || strings.Contains(string(raw), "$2") {
|
|
t.Fatalf("profile leaked sensitive data: %s", raw)
|
|
}
|
|
var body struct {
|
|
Data struct {
|
|
ID uint `json:"id"`
|
|
Login string `json:"login"`
|
|
Email string `json:"email"`
|
|
FirstName string `json:"first_name"`
|
|
LastName string `json:"last_name"`
|
|
IsSuperuser bool `json:"is_superuser"`
|
|
Role struct {
|
|
Code string `json:"code"`
|
|
Name string `json:"name"`
|
|
} `json:"role"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(raw, &body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if body.Data.ID != user.ID || body.Data.Login != user.Login || body.Data.Email != user.Email || body.Data.FirstName != "Ada" || body.Data.LastName != "Admin" || body.Data.IsSuperuser || body.Data.Role.Code != "developer" || body.Data.Role.Name != "Developer" {
|
|
t.Fatalf("profile = %+v", body.Data)
|
|
}
|
|
}
|
|
|
|
var (
|
|
adminOnce sync.Once
|
|
adminSQL *sql.DB
|
|
adminGDB *gorm.DB
|
|
adminErr error
|
|
)
|
|
|
|
func adminGorm(t *testing.T) *gorm.DB {
|
|
t.Helper()
|
|
if testing.Short() {
|
|
t.Skip("requires testcontainers postgres")
|
|
}
|
|
adminOnce.Do(func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
|
defer cancel()
|
|
ctr, err := postgres.Run(ctx, "postgres:16-alpine",
|
|
postgres.WithDatabase("cabana"),
|
|
postgres.WithUsername("cabana"),
|
|
postgres.WithPassword("cabana"),
|
|
postgres.BasicWaitStrategies(),
|
|
)
|
|
if err != nil {
|
|
adminErr = err
|
|
return
|
|
}
|
|
dsn, err := ctr.ConnectionString(ctx, "sslmode=disable")
|
|
if err != nil {
|
|
adminErr = err
|
|
return
|
|
}
|
|
db, err := sql.Open("pgx", dsn)
|
|
if err != nil {
|
|
adminErr = err
|
|
return
|
|
}
|
|
gdb, err := lagoon.Use(ctx, db)
|
|
if err != nil {
|
|
adminErr = err
|
|
return
|
|
}
|
|
if err := lagoon.Migrate(gdb, nil); err != nil {
|
|
adminErr = err
|
|
return
|
|
}
|
|
adminSQL = db
|
|
adminGDB = gdb
|
|
})
|
|
if adminErr != nil {
|
|
t.Fatalf("postgres: %v", adminErr)
|
|
}
|
|
return adminGDB
|
|
}
|
|
|
|
func adminHandler(t *testing.T, gdb *gorm.DB, mutate func(*compass.Config)) http.Handler {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: cabana-auth\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg, err := compass.Open(compass.Options{
|
|
Dir: dir,
|
|
Environ: []string{
|
|
"SUMMER_ENV=development",
|
|
"SUMMER_ADMIN__JWT__SECRET=" + adminTestSecret,
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := cfg.Set("http.body_limits.default_bytes", 1048576); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := cfg.Set("http.body_limits.upload_bytes", 1048576); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if mutate != nil {
|
|
mutate(cfg)
|
|
}
|
|
app := backpack.New(cfg)
|
|
if err := lagoon.Publish(app, adminSQL, gdb); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
h, err := surf.Assemble(app, []party.Plugin{demoPlugin{fsys: demoFS()}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return h
|
|
}
|
|
|
|
func insertAdmin(t *testing.T, gdb *gorm.DB, login, email, password string, activated, deleted bool) cabana.BackendUser {
|
|
t.Helper()
|
|
hash, err := bouncer.HashPassword(10, password)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var roleID uint
|
|
if err := gdb.Raw(`SELECT id FROM backend_user_roles WHERE code = 'developer'`).Scan(&roleID).Error; err != nil || roleID == 0 {
|
|
t.Fatalf("developer role: %v id=%d", err, roleID)
|
|
}
|
|
user := cabana.BackendUser{
|
|
FirstName: "Ada",
|
|
LastName: "Admin",
|
|
Login: login,
|
|
Email: email,
|
|
Password: hash,
|
|
IsActivated: activated,
|
|
RoleID: &roleID,
|
|
}
|
|
if err := gdb.Create(&user).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if deleted {
|
|
if err := gdb.Delete(&user).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
return user
|
|
}
|
|
|
|
type demoPlugin struct{ fsys fs.FS }
|
|
|
|
func (p demoPlugin) ID() string { return "acme.demo" }
|
|
func (p demoPlugin) Requires() []string { return nil }
|
|
func (p demoPlugin) Register(*backpack.App) error { return nil }
|
|
func (p demoPlugin) Boot(*backpack.App) error { return nil }
|
|
func (p demoPlugin) AdminControllers() []pact.AdminController {
|
|
return []pact.AdminController{demoController{}}
|
|
}
|
|
func (p demoPlugin) Permissions() []pact.Permission {
|
|
return []pact.Permission{{Code: "acme.demo.access", Roles: []string{"developer"}}}
|
|
}
|
|
func (p demoPlugin) AdminFS() fs.FS { return p.fsys }
|
|
|
|
type demoController struct{}
|
|
|
|
func (demoController) ID() string { return "acme.demo.widgets" }
|
|
func (demoController) ModelName() string { return "Widget" }
|
|
func (demoController) ConfigDir() string { return "controllers/widgets" }
|
|
func (demoController) RequiredPermissions() []string { return []string{"acme.demo.access"} }
|
|
|
|
func demoFS() fs.FS {
|
|
return fstest.MapFS{
|
|
"controllers/widgets/config_list.yaml": &fstest.MapFile{Data: []byte("list: ~/plugins/acme/demo/models/widget/columns.yaml\nmodelClass: Widget\nrecordsPerPage: 20\nshowSearch: true\n")},
|
|
"models/widget/columns.yaml": &fstest.MapFile{Data: []byte("columns:\n name:\n label: Name\n searchable: true\n")},
|
|
}
|
|
}
|
|
|
|
func postJSON(t *testing.T, h http.Handler, path string, body any) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
raw, err := json.Marshal(body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return postAuth(t, h, http.MethodPost, path, "", raw)
|
|
}
|
|
|
|
func postAuth(t *testing.T, h http.Handler, method, path, token string, body []byte) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
req := httptest.NewRequest(method, path, bytes.NewReader(body))
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
if token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
return rec
|
|
}
|
|
|
|
func getAuth(t *testing.T, h http.Handler, path, token string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
return postAuth(t, h, http.MethodGet, path, token, nil)
|
|
}
|
|
|
|
func accessToken(t *testing.T, raw []byte) string {
|
|
t.Helper()
|
|
var body struct {
|
|
Data struct {
|
|
AccessToken string `json:"access_token"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(raw, &body); err != nil {
|
|
t.Fatalf("token json: %v body=%s", err, raw)
|
|
}
|
|
if body.Data.AccessToken == "" {
|
|
t.Fatalf("missing access_token: %s", raw)
|
|
}
|
|
return body.Data.AccessToken
|
|
}
|
|
|
|
func jwtAudience(t *testing.T, token string) string {
|
|
t.Helper()
|
|
claims := jwtClaims(t, token)
|
|
switch v := claims["aud"].(type) {
|
|
case string:
|
|
return v
|
|
case []any:
|
|
if len(v) == 1 {
|
|
s, _ := v[0].(string)
|
|
return s
|
|
}
|
|
}
|
|
t.Fatalf("aud=%v", claims["aud"])
|
|
return ""
|
|
}
|
|
|
|
func jwtID(t *testing.T, token string) string {
|
|
t.Helper()
|
|
id, _ := jwtClaims(t, token)["jti"].(string)
|
|
if id == "" {
|
|
t.Fatal("token has no jti")
|
|
}
|
|
return id
|
|
}
|
|
|
|
func jwtClaims(t *testing.T, token string) map[string]any {
|
|
t.Helper()
|
|
parts := strings.Split(token, ".")
|
|
if len(parts) != 3 {
|
|
t.Fatalf("token segments=%d", len(parts))
|
|
}
|
|
raw, err := base64.RawURLEncoding.DecodeString(parts[1])
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var claims map[string]any
|
|
if err := json.Unmarshal(raw, &claims); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return claims
|
|
}
|
|
|
|
func itoa(id uint) string {
|
|
return strconv.FormatUint(uint64(id), 10)
|
|
}
|
|
|
|
// adminAPI mirrors the internal helper in admin_paths_test.go for this
|
|
// external test package: a full admin API path under the default prefix.
|
|
func adminAPI(rel string) string {
|
|
return cabana.DefaultAdminPrefix + "/api/v1" + rel
|
|
}
|