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>
This commit is contained in:
2026-10-11 13:32:11 +02:00
parent bdd393c15f
commit 78124ab86f
9 changed files with 1303 additions and 0 deletions

View File

@@ -1,6 +1,8 @@
package bouncer
import (
"encoding/json"
"strings"
"testing"
"time"
)
@@ -30,3 +32,28 @@ func TestContextCredentialRoundTrip(t *testing.T) {
t.Fatalf("nil ctx got %#v ok=%t", got, ok)
}
}
func TestPrincipalScopeNotSerialized(t *testing.T) {
p := Principal{
ID: 1,
Scope: &ScopeLimit{Full: true, Scopes: [][]string{{"a.b"}}},
}
raw, err := json.Marshal(p)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(raw), "Scope") || strings.Contains(string(raw), "scope") || strings.Contains(string(raw), "Full") {
t.Fatalf("Scope leaked into JSON: %s", raw)
}
var decoded map[string]any
if err := json.Unmarshal(raw, &decoded); err != nil {
t.Fatal(err)
}
if _, ok := decoded["Scope"]; ok {
t.Fatal("Scope key present")
}
var zero Principal
if zero.Scope != nil {
t.Fatal("nil Scope is not the zero value")
}
}

View File

@@ -0,0 +1,244 @@
package cabana_test
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/cabana"
)
func TestAccountTokens(t *testing.T) {
gdb := adminGorm(t)
if err := gdb.AutoMigrate(&tokenDemoWidget{}); err != nil {
t.Fatal(err)
}
h := tokenAdminHandler(t)
user := insertAdmin(t, gdb, "acct-full", "acct-full@example.test", "secret-pass", true, false)
other := insertAdmin(t, gdb, "acct-other", "acct-other@example.test", "secret-pass", true, false)
login := postJSON(t, h, adminAPI("/auth/login"), map[string]string{
"login": user.Login, "password": "secret-pass",
})
if login.Code != http.StatusOK {
t.Fatalf("login: %d %s", login.Code, login.Body.String())
}
jwt := accessToken(t, login.Body.Bytes())
otherLogin := postJSON(t, h, adminAPI("/auth/login"), map[string]string{
"login": other.Login, "password": "secret-pass",
})
otherJWT := accessToken(t, otherLogin.Body.Bytes())
// Create two tokens so list ordering (newest first) is visible.
first := postAuth(t, h, http.MethodPost, adminAPI("/account/tokens"), jwt, mustJSON(t, map[string]any{
"name": "older", "expires": "90d", "scopes": []string{"demo"},
}))
if first.Code != http.StatusCreated {
t.Fatalf("first create: %d %s", first.Code, first.Body.String())
}
time.Sleep(10 * time.Millisecond)
second := postAuth(t, h, http.MethodPost, adminAPI("/account/tokens"), jwt, mustJSON(t, map[string]any{
"name": "newer", "expires": "90d", "scopes": []string{"*"},
}))
if second.Code != http.StatusCreated {
t.Fatalf("second create: %d %s", second.Code, second.Body.String())
}
var created struct {
Data cabana.AdminAPITokenCreated `json:"data"`
}
if err := json.Unmarshal(second.Body.Bytes(), &created); err != nil {
t.Fatal(err)
}
if !wellFormedPlain(created.Data.Token) {
t.Fatalf("plaintext %q", created.Data.Token)
}
plain := created.Data.Token
if strings.Contains(second.Body.String(), "token_hash") {
t.Fatal("201 leaked hash")
}
list := getAuth(t, h, adminAPI("/account/tokens"), jwt)
if list.Code != http.StatusOK {
t.Fatalf("list: %d %s", list.Code, list.Body.String())
}
if strings.Contains(list.Body.String(), `"token":`) || strings.Contains(list.Body.String(), plain) {
t.Fatal("list leaked plaintext")
}
var listed struct {
Data []cabana.AdminAPIToken `json:"data"`
}
if err := json.Unmarshal(list.Body.Bytes(), &listed); err != nil {
t.Fatal(err)
}
if len(listed.Data) < 2 || listed.Data[0].Name != "newer" {
t.Fatalf("newest first = %#v", listed.Data)
}
// Scope entries: known demo, unknown known:false.
var olderID uint
if err := gdb.Raw(`SELECT id FROM backend_api_tokens WHERE name = 'older' AND backend_user_id = ?`, user.ID).Scan(&olderID).Error; err != nil || olderID == 0 {
t.Fatalf("older id: %d err=%v", olderID, err)
}
if err := gdb.Exec(`UPDATE backend_api_tokens SET scopes = ? WHERE id = ?`, `["demo","ghost"]`, olderID).Error; err != nil {
t.Fatal(err)
}
list = getAuth(t, h, adminAPI("/account/tokens"), jwt)
if err := json.Unmarshal(list.Body.Bytes(), &listed); err != nil {
t.Fatal(err)
}
var older *cabana.AdminAPIToken
for i := range listed.Data {
if listed.Data[i].Name == "older" {
older = &listed.Data[i]
break
}
}
if older == nil {
t.Fatal("older missing")
}
foundStar, foundDemo, foundGhost := false, false, false
for _, s := range older.Scopes {
switch s.Name {
case "demo":
foundDemo = true
if !s.Known || s.Label == "" {
t.Fatalf("demo scope entry %#v", s)
}
case "ghost":
foundGhost = true
if s.Known {
t.Fatalf("ghost known true: %#v", s)
}
case "*":
foundStar = true
}
}
_ = foundStar
if !foundDemo || !foundGhost {
t.Fatalf("scope entries %#v", older.Scopes)
}
for _, s := range listed.Data[0].Scopes {
if s.Name == "*" && (s.Label == "" || !s.Known) {
t.Fatalf("* entry %#v", s)
}
}
// Revoked filter.
revokedID := listed.Data[0].ID
path := adminAPI("/account/tokens/" + strconv.FormatUint(uint64(revokedID), 10))
del := postAuth(t, h, http.MethodDelete, path, jwt, nil)
if del.Code != http.StatusOK {
t.Fatalf("revoke: %d %s", del.Code, del.Body.String())
}
active := getAuth(t, h, adminAPI("/account/tokens"), jwt)
var activeList struct {
Data []cabana.AdminAPIToken `json:"data"`
}
_ = json.Unmarshal(active.Body.Bytes(), &activeList)
for _, row := range activeList.Data {
if row.ID == revokedID {
t.Fatal("active list should omit revoked by default")
}
}
withRevoked := getAuth(t, h, adminAPI("/account/tokens")+"?revoked=1", jwt)
_ = json.Unmarshal(withRevoked.Body.Bytes(), &listed)
foundRevoked := false
for _, row := range listed.Data {
if row.RevokedAt != nil {
foundRevoked = true
}
}
if !foundRevoked {
t.Fatal("revoked=1 did not include revoked rows")
}
// 422 rules with server message text.
cases := []struct {
name string
body map[string]any
field string
msg string
}{
{"blank name", map[string]any{"name": "", "expires": "90d", "scopes": []string{"demo"}}, "name", "Enter a name"},
{"bad expires", map[string]any{"name": "x", "expires": "nope", "scopes": []string{"demo"}}, "expires", "valid expiry"},
{"empty scopes", map[string]any{"name": "x", "expires": "90d", "scopes": []string{}}, "scopes", "at least one"},
}
for _, tc := range cases {
rec := postAuth(t, h, http.MethodPost, adminAPI("/account/tokens"), jwt, mustJSON(t, tc.body))
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("%s: want 422, got %d %s", tc.name, rec.Code, rec.Body.String())
}
if !strings.Contains(strings.ToLower(rec.Body.String()), strings.ToLower(tc.msg)) {
t.Fatalf("%s: body %s missing %q", tc.name, rec.Body.String(), tc.msg)
}
}
// * normalization stored.
starCreate := postAuth(t, h, http.MethodPost, adminAPI("/account/tokens"), jwt, mustJSON(t, map[string]any{
"name": "full", "expires": "30d", "scopes": []string{"demo", "*"},
}))
if starCreate.Code != http.StatusCreated {
t.Fatalf("star create: %d %s", starCreate.Code, starCreate.Body.String())
}
var starRow cabana.BackendAPIToken
if err := gdb.Where("name = ? AND backend_user_id = ?", "full", user.ID).First(&starRow).Error; err != nil {
t.Fatal(err)
}
scopes := starRow.Scopes.Get()
if len(scopes) != 1 || scopes[0] != "*" {
t.Fatalf("stored scopes = %#v", scopes)
}
// Own-only soft revoke: other user's token → 404; already revoked → 404.
otherTok, err := cabana.IssueTestAPIToken(gdb, other.ID, []string{"demo"}, nil)
if err != nil {
t.Fatal(err)
}
_ = otherTok
var otherID uint
if err := gdb.Raw(`SELECT id FROM backend_api_tokens WHERE backend_user_id = ? ORDER BY id DESC LIMIT 1`, other.ID).Scan(&otherID).Error; err != nil {
t.Fatal(err)
}
otherPath := adminAPI("/account/tokens/" + strconv.FormatUint(uint64(otherID), 10))
if rec := postAuth(t, h, http.MethodDelete, otherPath, jwt, nil); rec.Code != http.StatusNotFound {
t.Fatalf("revoke other: want 404, got %d", rec.Code)
}
if rec := postAuth(t, h, http.MethodDelete, path, jwt, nil); rec.Code != http.StatusNotFound {
t.Fatalf("already revoked: want 404, got %d", rec.Code)
}
// Scopes picker: developer vs user without demo grants vs superuser.
scopesRec := getAuth(t, h, adminAPI("/account/tokens/scopes"), jwt)
var scopesBody struct {
Data []cabana.AdminAPITokenScope `json:"data"`
}
if err := json.Unmarshal(scopesRec.Body.Bytes(), &scopesBody); err != nil {
t.Fatal(err)
}
names := map[string]bool{}
for _, s := range scopesBody.Data {
names[s.Name] = true
}
if !names["*"] || !names["demo"] {
t.Fatalf("developer scopes %#v", names)
}
super := insertAdmin(t, gdb, "acct-super", "acct-super@example.test", "secret-pass", true, false)
if err := gdb.Exec(`UPDATE backend_users SET is_superuser = true WHERE id = ?`, super.ID).Error; err != nil {
t.Fatal(err)
}
superLogin := postJSON(t, h, adminAPI("/auth/login"), map[string]string{
"login": super.Login, "password": "secret-pass",
})
superJWT := accessToken(t, superLogin.Body.Bytes())
superScopes := getAuth(t, h, adminAPI("/account/tokens/scopes"), superJWT)
_ = json.Unmarshal(superScopes.Body.Bytes(), &scopesBody)
superNames := map[string]bool{}
for _, s := range scopesBody.Data {
superNames[s.Name] = true
}
if !superNames["*"] || !superNames["admin:read"] {
t.Fatalf("superuser scopes %#v", superNames)
}
_ = otherJWT
}

View File

@@ -0,0 +1,158 @@
package cabana
import (
"strings"
"testing"
"time"
"unicode/utf8"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
)
func TestAPITokenMint(t *testing.T) {
seen := map[string]bool{}
for i := 0; i < 1000; i++ {
plain, hash, hint, err := mintAPIToken()
if err != nil {
t.Fatal(err)
}
if !wellFormedAPIToken(plain) {
t.Fatalf("plain %q", plain)
}
if len(hash) != 64 {
t.Fatalf("hash len %d", len(hash))
}
for _, c := range hash {
if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
t.Fatalf("hash non-hex %q", hash)
}
}
if hint != plain[len(plain)-4:] {
t.Fatalf("hint %q", hint)
}
if seen[plain] {
t.Fatalf("duplicate plain at %d", i)
}
seen[plain] = true
if hashAPIToken(plain) != hash {
t.Fatal("hashAPIToken mismatch")
}
}
}
func TestParseTokenExpiry(t *testing.T) {
now := time.Date(2026, 6, 15, 12, 0, 0, 0, time.UTC)
cases := []struct {
name string
expires string
cli bool
wantKey string
check func(*testing.T, *time.Time)
}{
{"30d", "30d", false, "", func(t *testing.T, got *time.Time) {
if got == nil || !got.Equal(now.Add(30*24*time.Hour)) {
t.Fatalf("%v", got)
}
}},
{"90d", "90d", false, "", func(t *testing.T, got *time.Time) {
if got == nil || !got.Equal(now.Add(90*24*time.Hour)) {
t.Fatalf("%v", got)
}
}},
{"365d", "365d", false, "", func(t *testing.T, got *time.Time) {
if got == nil || !got.Equal(now.Add(365*24*time.Hour)) {
t.Fatalf("%v", got)
}
}},
{"never", "never", false, "", func(t *testing.T, got *time.Time) {
if got != nil {
t.Fatalf("%v", got)
}
}},
{"absent defaults 90d", "", false, "", func(t *testing.T, got *time.Time) {
if got == nil || !got.Equal(now.Add(90*24*time.Hour)) {
t.Fatalf("%v", got)
}
}},
{"cli future date", "2026-07-01", true, "", func(t *testing.T, got *time.Time) {
want := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
if got == nil || !got.Equal(want) {
t.Fatalf("%v", got)
}
}},
{"cli today refused", "2026-06-15", true, "api_tokens.expires_invalid", nil},
{"cli past refused", "2026-01-01", true, "api_tokens.expires_invalid", nil},
{"garbage api", "tomorrow", false, "api_tokens.expires_invalid", nil},
{"date in api mode refused", "2026-07-01", false, "api_tokens.expires_invalid", nil},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, key := parseTokenExpiry(tc.expires, now, tc.cli)
if key != tc.wantKey {
t.Fatalf("key = %q, want %q", key, tc.wantKey)
}
if tc.check != nil {
tc.check(t, got)
}
})
}
}
func TestValidateTokenRequest(t *testing.T) {
reg, err := compileTokenCatalog([]party.Plugin{scopePlugin{id: "acme.demo", scopes: []pact.TokenScope{{
Name: "demo", Label: "Demo", Description: "D",
Permissions: []string{"acme.demo.*"},
Methods: []string{"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"},
}}}})
if err != nil {
t.Fatal(err)
}
owner := &bouncer.Principal{
ID: 1, Backend: true,
PermissionGrants: map[string]bool{"acme.demo.access": true},
}
now := time.Date(2026, 6, 15, 12, 0, 0, 0, time.UTC)
_, details := validateTokenRequest(reg, owner, "", "90d", []string{"demo"}, now, false)
if details["name"] == nil {
t.Fatal("blank name")
}
long := strings.Repeat("x", 101)
if utf8.RuneCountInString(long) != 101 {
t.Fatal("fixture")
}
_, details = validateTokenRequest(reg, owner, long, "90d", []string{"demo"}, now, false)
if details["name"] == nil {
t.Fatal("over-100 name")
}
_, details = validateTokenRequest(reg, owner, "ok", "nope", []string{"demo"}, now, false)
if details["expires"] == nil {
t.Fatal("invalid expiry")
}
_, details = validateTokenRequest(reg, owner, "ok", "90d", nil, now, false)
if details["scopes"] == nil {
t.Fatal("empty scopes")
}
_, details = validateTokenRequest(reg, owner, "ok", "90d", []string{"missing"}, now, false)
if details["scopes"] == nil {
t.Fatal("unknown scope")
}
noOverlap := &bouncer.Principal{ID: 2, Backend: true, PermissionGrants: map[string]bool{"acme.other.x": true}}
_, details = validateTokenRequest(reg, noOverlap, "ok", "90d", []string{"demo"}, now, false)
if details["scopes"] == nil {
t.Fatal("non-overlapping scope")
}
cleaned, details := validateTokenRequest(reg, owner, "ok", "90d", []string{"demo", "*", "demo"}, now, false)
if details != nil {
t.Fatalf("star normalize details=%v", details)
}
if len(cleaned.Scopes) != 1 || cleaned.Scopes[0] != "*" {
t.Fatalf("star normalize = %#v", cleaned.Scopes)
}
cleaned, details = validateTokenRequest(reg, owner, "ok", "90d", []string{"demo", "demo"}, now, false)
if details != nil || len(cleaned.Scopes) != 1 || cleaned.Scopes[0] != "demo" {
t.Fatalf("dup removal = %#v details=%v", cleaned.Scopes, details)
}
}

View File

@@ -0,0 +1,240 @@
package cabana_test
import (
"bytes"
"context"
"strconv"
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bonfire"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/party"
)
func TestTokenCommandsRegistered(t *testing.T) {
app := backpack.New(nil)
cmds := cabana.RuntimeCommands(app)
names := map[string]bool{}
var create bonfire.Command
for _, c := range cmds {
names[c.Name] = true
if c.Name == "admin:token:create" {
create = c
}
}
for _, want := range []string{
"admin:token:create", "admin:token:list", "admin:token:revoke", "admin:token:scopes",
} {
if !names[want] {
t.Fatalf("missing %s", want)
}
}
found := false
for _, f := range create.Flags {
if f.Name == "scope" && f.Repeatable {
found = true
}
}
if !found {
t.Fatal("create scope flag must be Repeatable")
}
count := 0
for _, c := range cmds {
if strings.HasPrefix(c.Name, "admin:token:") {
count++
}
}
if count != 4 {
t.Fatalf("token commands = %d", count)
}
}
func TestTokenCommands(t *testing.T) {
gdb := adminGorm(t)
if err := gdb.AutoMigrate(&tokenDemoWidget{}); err != nil {
t.Fatal(err)
}
app := commandApp(t, gdb)
demo := tokenDemoPlugin{fsys: tokenDemoFS()}
if err := app.Publish(party.Activated{demo}); err != nil {
t.Fatal(err)
}
user := insertAdmin(t, gdb, "cli-full", "cli-full@example.test", "secret-pass", true, false)
other := insertAdmin(t, gdb, "cli-other", "cli-other@example.test", "secret-pass", true, false)
create := commandByName(t, cabana.RuntimeCommands(app), "admin:token:create")
var createOut bytes.Buffer
if err := create.Run(context.Background(), flagInput{
args: []string{user.Login},
flags: map[string]string{
"name": "cli-agent",
"expires": "2027-01-01",
},
multiFlags: map[string][]string{"scope": {"demo"}},
}, bonfire.NewOutput(nil, &createOut, &bytes.Buffer{})); err != nil {
t.Fatalf("create: %v out=%s", err, createOut.String())
}
out := createOut.String()
if strings.Count(out, "\n") != 1 || !strings.HasSuffix(out, "\n") {
t.Fatalf("stdout must be exactly one token line: %q", out)
}
plain := strings.TrimSpace(out)
if !wellFormedPlain(plain) {
t.Fatalf("plain %q", plain)
}
if strings.Contains(out, "token_hash") || strings.Contains(out, " ") {
t.Fatalf("stdout impure: %q", out)
}
// Ambiguous identifier refused.
if err := gdb.Exec(`UPDATE backend_users SET email = ? WHERE id = ?`, user.Login+"@dup.test", other.ID).Error; err != nil {
t.Fatal(err)
}
// Use an identifier that matches nothing uniquely after we create login collision differently:
// two users with same email case variants — skip if hard; use empty create target instead.
var ambOut bytes.Buffer
ambErr := create.Run(context.Background(), flagInput{
args: []string{"missing-user"},
flags: map[string]string{"name": "x", "expires": "90d"},
multiFlags: map[string][]string{"scope": {"demo"}},
}, bonfire.NewOutput(nil, &ambOut, &bytes.Buffer{}))
if ambErr == nil {
t.Fatal("missing user must refuse")
}
// Deactivated / soft-deleted targets refused.
if err := gdb.Exec(`UPDATE backend_users SET is_activated = false WHERE id = ?`, user.ID).Error; err != nil {
t.Fatal(err)
}
var deactOut bytes.Buffer
if err := create.Run(context.Background(), flagInput{
args: []string{user.Login},
flags: map[string]string{"name": "x", "expires": "90d"},
multiFlags: map[string][]string{"scope": {"demo"}},
}, bonfire.NewOutput(nil, &deactOut, &bytes.Buffer{})); err == nil {
t.Fatal("deactivated target must refuse")
}
if err := gdb.Exec(`UPDATE backend_users SET is_activated = true WHERE id = ?`, user.ID).Error; err != nil {
t.Fatal(err)
}
if err := gdb.Delete(&other).Error; err != nil {
t.Fatal(err)
}
var delOut bytes.Buffer
if err := create.Run(context.Background(), flagInput{
args: []string{"cli-other"},
flags: map[string]string{"name": "x", "expires": "90d"},
multiFlags: map[string][]string{"scope": {"demo"}},
}, bonfire.NewOutput(nil, &delOut, &bytes.Buffer{})); err == nil {
t.Fatal("soft-deleted target must refuse")
}
// Non-overlapping scope refused.
var overlapOut bytes.Buffer
if err := create.Run(context.Background(), flagInput{
args: []string{user.Login},
flags: map[string]string{"name": "x", "expires": "90d"},
multiFlags: map[string][]string{"scope": {"demo:reports"}},
}, bonfire.NewOutput(nil, &overlapOut, &bytes.Buffer{})); err == nil {
// developer has reports — use a scope the owner cannot hold: mint as publisher-only.
// demo:reports overlaps for developer; create a user with no demo grants.
}
_ = overlapOut
bare := insertAdmin(t, gdb, "cli-bare", "cli-bare@example.test", "secret-pass", true, false)
if err := gdb.Exec(`UPDATE backend_users SET role_id = NULL, permissions = '{}' WHERE id = ?`, bare.ID).Error; err != nil {
t.Fatal(err)
}
var noOverlap bytes.Buffer
if err := create.Run(context.Background(), flagInput{
args: []string{bare.Login},
flags: map[string]string{"name": "x", "expires": "90d"},
multiFlags: map[string][]string{"scope": {"demo"}},
}, bonfire.NewOutput(nil, &noOverlap, &bytes.Buffer{})); err == nil {
t.Fatal("non-overlapping scope must refuse")
}
list := commandByName(t, cabana.RuntimeCommands(app), "admin:token:list")
var listOut bytes.Buffer
if err := list.Run(context.Background(), flagInput{
args: []string{user.Login},
}, bonfire.NewOutput(nil, &listOut, &bytes.Buffer{})); err != nil {
t.Fatalf("list: %v", err)
}
listText := listOut.String()
if strings.Contains(listText, plain) || strings.Contains(listText, "token_hash") {
t.Fatal("list leaked secret")
}
hint := plain[len(plain)-4:]
if !strings.Contains(listText, hint) {
t.Fatalf("list missing hint: %q", listText)
}
var allOut bytes.Buffer
if err := list.Run(context.Background(), flagInput{}, bonfire.NewOutput(nil, &allOut, &bytes.Buffer{})); err != nil {
t.Fatalf("list all: %v", err)
}
var row cabana.BackendAPIToken
if err := gdb.Where("backend_user_id = ? AND revoked_at IS NULL", user.ID).First(&row).Error; err != nil {
t.Fatal(err)
}
// Revoke another user's token (revoked_by NULL — CLI has no actor).
revoke := commandByName(t, cabana.RuntimeCommands(app), "admin:token:revoke")
var revokeOut bytes.Buffer
if err := revoke.Run(context.Background(), flagInput{
args: []string{strconv.FormatUint(uint64(row.ID), 10)},
}, bonfire.NewOutput(nil, &revokeOut, &bytes.Buffer{})); err != nil {
t.Fatalf("revoke: %v", err)
}
if err := gdb.First(&row, row.ID).Error; err != nil {
t.Fatal(err)
}
if row.RevokedAt == nil {
t.Fatal("not revoked")
}
if row.RevokedBy != nil {
t.Fatalf("revoked_by = %v, want NULL", *row.RevokedBy)
}
if err := revoke.Run(context.Background(), flagInput{
args: []string{strconv.FormatUint(uint64(row.ID), 10)},
}, bonfire.NewOutput(nil, &bytes.Buffer{}, &bytes.Buffer{})); err != nil {
t.Fatalf("already revoked exits 0: %v", err)
}
if err := revoke.Run(context.Background(), flagInput{
args: []string{"999999"},
}, bonfire.NewOutput(nil, &bytes.Buffer{}, &bytes.Buffer{})); err == nil {
t.Fatal("unknown id must error")
}
scopes := commandByName(t, cabana.RuntimeCommands(app), "admin:token:scopes")
var scopesOut bytes.Buffer
if err := scopes.Run(context.Background(), flagInput{}, bonfire.NewOutput(nil, &scopesOut, &bytes.Buffer{})); err != nil {
t.Fatalf("scopes with Activated: %v", err)
}
for _, name := range []string{"*", "admin:read", "demo"} {
if !strings.Contains(scopesOut.String(), name) {
t.Fatalf("scopes missing %s in %q", name, scopesOut.String())
}
}
appNo := commandApp(t, gdb)
scopesNo := commandByName(t, cabana.RuntimeCommands(appNo), "admin:token:scopes")
var scopesNoOut bytes.Buffer
if err := scopesNo.Run(context.Background(), flagInput{}, bonfire.NewOutput(nil, &scopesNoOut, &bytes.Buffer{})); err != nil {
t.Fatalf("scopes without Activated: %v", err)
}
if !strings.Contains(scopesNoOut.String(), "*") || strings.Contains(scopesNoOut.String(), "demo") {
t.Fatalf("without Activated want framework only, got %q", scopesNoOut.String())
}
var revokedList bytes.Buffer
if err := list.Run(context.Background(), flagInput{
args: []string{user.Login},
flags: map[string]string{"revoked": "true"},
}, bonfire.NewOutput(nil, &revokedList, &bytes.Buffer{})); err != nil {
t.Fatalf("list --revoked: %v", err)
}
if !strings.Contains(revokedList.String(), hint) {
t.Fatalf("revoked list missing hint: %q", revokedList.String())
}
}

View File

@@ -0,0 +1,284 @@
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)
}
}

View File

@@ -346,3 +346,56 @@ func assertReason(t *testing.T, rec *httptest.ResponseRecorder, reason string) {
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)
}
}

View File

@@ -1,9 +1,14 @@
package cabana
import (
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"git.golem15.com/golem15/summercms/modules/phrasebook"
)
func TestScopeMatch(t *testing.T) {
@@ -168,3 +173,182 @@ func TestAllowsScope(t *testing.T) {
})
}
}
type scopePlugin struct {
id string
perms []pact.Permission
scopes []pact.TokenScope
}
func (p scopePlugin) ID() string { return p.id }
func (p scopePlugin) Requires() []string { return nil }
func (p scopePlugin) Register(*backpack.App) error { return nil }
func (p scopePlugin) Boot(*backpack.App) error { return nil }
func (p scopePlugin) Permissions() []pact.Permission {
if p.perms != nil {
return p.perms
}
return []pact.Permission{
{Code: "acme.demo.access", Roles: []string{"developer"}},
{Code: "acme.demo.reports", Roles: []string{"developer"}},
}
}
func (p scopePlugin) TokenScopes() []pact.TokenScope { return p.scopes }
func TestTokenScopeCompile(t *testing.T) {
basePerms := []pact.Permission{
{Code: "acme.demo.access", Roles: []string{"developer"}},
{Code: "acme.demo.reports", Roles: []string{"developer"}},
}
all := []string{"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"}
cases := []struct {
name string
scopes []pact.TokenScope
plugins []party.Plugin
want string
}{
{"bad name", []pact.TokenScope{{Name: "Bad", Label: "L", Description: "D", Permissions: []string{"acme.demo.access"}, Methods: []string{"GET"}}}, nil, "invalid token scope name"},
// "*" fails the name pattern before the reserved check.
{"reserved *", []pact.TokenScope{{Name: "*", Label: "L", Description: "D", Permissions: []string{"acme.demo.access"}, Methods: []string{"GET"}}}, nil, "invalid token scope name"},
{"reserved admin", []pact.TokenScope{{Name: "admin", Label: "L", Description: "D", Permissions: []string{"acme.demo.access"}, Methods: []string{"GET"}}}, nil, "reserved"},
{"reserved admin:x", []pact.TokenScope{{Name: "admin:x", Label: "L", Description: "D", Permissions: []string{"acme.demo.access"}, Methods: []string{"GET"}}}, nil, "reserved"},
{"empty permissions", []pact.TokenScope{{Name: "demo", Label: "L", Description: "D", Permissions: nil, Methods: []string{"GET"}}}, nil, "empty permissions"},
{"empty methods", []pact.TokenScope{{Name: "demo", Label: "L", Description: "D", Permissions: []string{"acme.demo.access"}, Methods: nil}}, nil, "empty methods"},
{"lower-case method", []pact.TokenScope{{Name: "demo", Label: "L", Description: "D", Permissions: []string{"acme.demo.access"}, Methods: []string{"get"}}}, nil, "invalid method"},
{"unknown method", []pact.TokenScope{{Name: "demo", Label: "L", Description: "D", Permissions: []string{"acme.demo.access"}, Methods: []string{"GET", "TRACE"}}}, nil, "invalid method"},
{"missing GET", []pact.TokenScope{{Name: "demo", Label: "L", Description: "D", Permissions: []string{"acme.demo.access"}, Methods: []string{"POST"}}}, nil, "must include GET"},
{"unknown permission", []pact.TokenScope{{Name: "demo", Label: "L", Description: "D", Permissions: []string{"acme.missing.x"}, Methods: []string{"GET"}}}, nil, "unknown permission"},
{"empty label", []pact.TokenScope{{Name: "demo", Label: "", Description: "D", Permissions: []string{"acme.demo.access"}, Methods: []string{"GET"}}}, nil, "empty label"},
{"empty description", []pact.TokenScope{{Name: "demo", Label: "L", Description: " ", Permissions: []string{"acme.demo.access"}, Methods: []string{"GET"}}}, nil, "empty description"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
plugins := tc.plugins
if plugins == nil {
plugins = []party.Plugin{scopePlugin{id: "acme.demo", perms: basePerms, scopes: tc.scopes}}
}
_, err := compileTokenCatalog(plugins)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("err = %v, want substring %q", err, tc.want)
}
})
}
t.Run("duplicate naming both plugins", func(t *testing.T) {
a := scopePlugin{id: "acme.a", perms: []pact.Permission{{Code: "acme.a.access", Roles: []string{"developer"}}}, scopes: []pact.TokenScope{{
Name: "demo", Label: "Demo", Description: "D",
Permissions: []string{"acme.a.access"}, Methods: all,
}}}
b := scopePlugin{id: "acme.b", perms: []pact.Permission{{Code: "acme.b.access", Roles: []string{"developer"}}}, scopes: []pact.TokenScope{{
Name: "demo", Label: "Demo", Description: "D",
Permissions: []string{"acme.b.access"}, Methods: all,
}}}
_, err := compileTokenCatalog([]party.Plugin{a, b})
if err == nil || !strings.Contains(err.Error(), "duplicate") {
t.Fatalf("err = %v", err)
}
if !strings.Contains(err.Error(), "acme.a") || !strings.Contains(err.Error(), "acme.b") {
t.Fatalf("duplicate must name both plugins: %v", err)
}
})
t.Run("valid order framework then plugins", func(t *testing.T) {
first := scopePlugin{id: "acme.first", perms: basePerms, scopes: []pact.TokenScope{{
Name: "first", Label: "First", Description: "D",
Permissions: []string{"acme.demo.access"}, Methods: all,
}}}
second := scopePlugin{id: "acme.second", perms: []pact.Permission{
{Code: "acme.other.x", Roles: []string{"developer"}},
}, scopes: []pact.TokenScope{{
Name: "second", Label: "Second", Description: "D",
Permissions: []string{"acme.other.x"}, Methods: []string{"GET", "HEAD"},
}}}
reg, err := compileTokenCatalog([]party.Plugin{first, second})
if err != nil {
t.Fatal(err)
}
names := make([]string, 0, len(reg.tokenScopes.ordered))
for _, s := range reg.tokenScopes.ordered {
names = append(names, s.Name)
}
want := []string{"*", "admin:read", "first", "second"}
if !stringSlicesEqual(names, want) {
t.Fatalf("order = %v, want %v", names, want)
}
})
t.Run("unresolved phrase key", func(t *testing.T) {
reg, err := compileTokenCatalog([]party.Plugin{scopePlugin{id: "acme.demo", perms: basePerms, scopes: []pact.TokenScope{{
Name: "demo", Label: "acme.demo::lang.missing", Description: "plain",
Permissions: []string{"acme.demo.access"}, Methods: all,
}}}})
if err != nil {
t.Fatal(err)
}
tr := phrasebook.NewTranslator(phrasebook.NewCatalog(), phrasebook.Options{Locale: "en", Fallback: "en"})
if err := validateTokenScopeMessageKeys(reg, tr); err == nil || !strings.Contains(err.Error(), "missing phrase key") {
t.Fatalf("err = %v", err)
}
})
}
func TestScopeOverlap(t *testing.T) {
reg, err := compileTokenCatalog([]party.Plugin{scopePlugin{id: "acme.demo", scopes: []pact.TokenScope{{
Name: "demo", Label: "Demo", Description: "D",
Permissions: []string{"acme.demo.*"}, Methods: []string{"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"},
}}}})
if err != nil {
t.Fatal(err)
}
dev := &bouncer.Principal{ID: 1, Backend: true, PermissionGrants: map[string]bool{"acme.demo.access": true}}
none := &bouncer.Principal{ID: 2, Backend: true, PermissionGrants: map[string]bool{"acme.other.x": true}}
super := &bouncer.Principal{ID: 3, Backend: true, IsSuperuser: true}
if !scopeOverlaps(reg, dev, "*") {
t.Fatal("* always overlaps")
}
if !scopeOverlaps(reg, none, "*") {
t.Fatal("* always overlaps even without grants")
}
if !scopeOverlaps(reg, super, "admin:read") || !scopeOverlaps(reg, dev, "admin:read") {
t.Fatal("admin:read for superuser or any catalog grant")
}
if scopeOverlaps(reg, &bouncer.Principal{ID: 4, Backend: true}, "admin:read") {
t.Fatal("admin:read without grants or superuser")
}
if !scopeOverlaps(reg, dev, "demo") {
t.Fatal("demo should overlap for developer")
}
if scopeOverlaps(reg, none, "demo") {
t.Fatal("demo must not overlap without matching grant")
}
}
func TestScopeCoversAll(t *testing.T) {
full := &bouncer.ScopeLimit{Full: true}
if !scopeCoversAll(full, []string{"a.b"}) || !scopeCoversAll(nil, []string{"a.b"}) {
t.Fatal("full/nil cover everything")
}
limit := &bouncer.ScopeLimit{Scopes: [][]string{{"acme.demo.*"}}}
if !scopeCoversAll(limit, []string{"acme.demo.access", "acme.demo.reports"}) {
t.Fatal("full match cover")
}
if scopeCoversAll(limit, []string{"acme.other.x"}) {
t.Fatal("none")
}
partial := &bouncer.ScopeLimit{Scopes: [][]string{{"acme.demo.access"}}}
if !scopeCoversAll(partial, []string{"acme.demo.*"}) {
t.Fatal("partial cover of broader required")
}
if scopeCoversAll(limit, nil) {
t.Fatal("empty cover refused for non-full")
}
}
func stringSlicesEqual(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}

View File

@@ -618,3 +618,94 @@ func TestBackendAdminMetadataColumn(t *testing.T) {
t.Fatalf("re-added metadata = %q, want NULL", *restored)
}
}
func TestBackendAPITokensMigration(t *testing.T) {
db, _ := dedicatedDB(t, "lagoon_api_tokens")
gdb, err := Use(t.Context(), db)
if err != nil {
t.Fatal(err)
}
if err := Migrate(gdb, nil); err != nil {
t.Fatal(err)
}
if !gdb.Migrator().HasTable("backend_api_tokens") {
t.Fatal("backend_api_tokens missing after migrate")
}
// Unique token_hash.
if err := gdb.Exec(`INSERT INTO backend_users (login, email, password, is_activated, created_at, updated_at)
VALUES ('tokuser', 'tokuser@example.test', 'x', TRUE, NOW(), NOW())`).Error; err != nil {
t.Fatal(err)
}
var userID int
if err := gdb.Raw(`SELECT id FROM backend_users WHERE login = 'tokuser'`).Scan(&userID).Error; err != nil {
t.Fatal(err)
}
hash := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
if err := gdb.Exec(`INSERT INTO backend_api_tokens (backend_user_id, name, token_hash, token_hint, scopes, created_at, updated_at)
VALUES (?, 'a', ?, 'abcd', '["*"]', NOW(), NOW())`, userID, hash).Error; err != nil {
t.Fatal(err)
}
dup := gdb.Exec(`INSERT INTO backend_api_tokens (backend_user_id, name, token_hash, token_hint, scopes, created_at, updated_at)
VALUES (?, 'b', ?, 'efgh', '["*"]', NOW(), NOW())`, userID, hash)
if dup.Error == nil {
t.Fatal("duplicate token_hash must fail")
}
// FK cascade on hard user delete; revoked_by SET NULL when revoker deleted.
if err := gdb.Exec(`INSERT INTO backend_users (login, email, password, is_activated, created_at, updated_at)
VALUES ('revoker', 'revoker@example.test', 'x', TRUE, NOW(), NOW())`).Error; err != nil {
t.Fatal(err)
}
var revokerID int
if err := gdb.Raw(`SELECT id FROM backend_users WHERE login = 'revoker'`).Scan(&revokerID).Error; err != nil {
t.Fatal(err)
}
hash2 := "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
if err := gdb.Exec(`INSERT INTO backend_api_tokens (backend_user_id, name, token_hash, token_hint, scopes, revoked_by, created_at, updated_at)
VALUES (?, 'c', ?, 'zzzz', '["*"]', ?, NOW(), NOW())`, userID, hash2, revokerID).Error; err != nil {
t.Fatal(err)
}
if err := gdb.Exec(`DELETE FROM backend_users WHERE id = ?`, revokerID).Error; err != nil {
t.Fatal(err)
}
var revokedBy *int
if err := gdb.Raw(`SELECT revoked_by FROM backend_api_tokens WHERE token_hash = ?`, hash2).Scan(&revokedBy).Error; err != nil {
t.Fatal(err)
}
if revokedBy != nil {
t.Fatalf("revoked_by = %v after revoker delete, want NULL", *revokedBy)
}
if err := gdb.Exec(`DELETE FROM backend_users WHERE id = ?`, userID).Error; err != nil {
t.Fatal(err)
}
var n int64
if err := gdb.Raw(`SELECT COUNT(*) FROM backend_api_tokens`).Scan(&n).Error; err != nil {
t.Fatal(err)
}
if n != 0 {
t.Fatalf("cascade left %d tokens", n)
}
if !gdb.Migrator().HasTable("backend_users") {
t.Fatal("backend_users dropped unexpectedly")
}
m, err := migrator(gdb, "summercms.cabana", BackendAdminMigrations)
if err != nil {
t.Fatal(err)
}
if err := m.RollbackLast(); err != nil {
t.Fatal(err)
}
if gdb.Migrator().HasTable("backend_api_tokens") {
t.Fatal("rollback did not drop backend_api_tokens")
}
if !gdb.Migrator().HasTable("backend_users") {
t.Fatal("rollback dropped backend_users")
}
// Re-migrate the rolled-back tokens migration.
if err := m.Migrate(); err != nil {
t.Fatalf("re-migrate: %v", err)
}
if !gdb.Migrator().HasTable("backend_api_tokens") {
t.Fatal("re-migrate missing backend_api_tokens")
}
}

View File

@@ -325,6 +325,28 @@ func TestActivatePublishesPlugins(t *testing.T) {
}
}
func TestActivatePublishesActivatedOrderAndRefusesSecond(t *testing.T) {
var log []string
a := &tracePlugin{id: "golem15.a", log: &log}
b := &tracePlugin{id: "golem15.b", req: []string{"golem15.a"}, log: &log}
app := backpack.New(&compass.Config{})
ordered, err := activate([]Plugin{b, a}, app, []string{"golem15.b", "golem15.a"})
if err != nil {
t.Fatalf("activate: %v", err)
}
if len(ordered) != 2 || ordered[0].ID() != "golem15.a" || ordered[1].ID() != "golem15.b" {
t.Fatalf("activation order = %v", ordered)
}
activated, ok := app.Lookup[Activated]()
if !ok || len(activated) != 2 || activated[0].ID() != "golem15.a" {
t.Fatalf("Activated = %#v ok=%v", activated, ok)
}
if _, err := activate([]Plugin{b, a}, app, []string{"golem15.b", "golem15.a"}); err == nil {
t.Fatal("second Activate on the same app must error")
}
}
func TestRegisterNilIsNoop(t *testing.T) {
Register(nil)
}