From 78124ab86f783de2e263f91fc7dd00056f64541b Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Sun, 11 Oct 2026 13:32:11 +0200 Subject: [PATCH] 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 --- modules/bouncer/context_test.go | 27 ++ modules/cabana/account_tokens_test.go | 244 +++++++++++++++ modules/cabana/api_tokens_test.go | 158 ++++++++++ modules/cabana/token_commands_test.go | 240 +++++++++++++++ modules/cabana/token_guard_test.go | 284 ++++++++++++++++++ modules/cabana/token_routes_test.go | 53 ++++ modules/cabana/token_scopes_test.go | 184 ++++++++++++ .../lagoon/backend_admin_migrations_test.go | 91 ++++++ modules/party/registry_test.go | 22 ++ 9 files changed, 1303 insertions(+) create mode 100644 modules/cabana/account_tokens_test.go create mode 100644 modules/cabana/api_tokens_test.go create mode 100644 modules/cabana/token_commands_test.go create mode 100644 modules/cabana/token_guard_test.go diff --git a/modules/bouncer/context_test.go b/modules/bouncer/context_test.go index f7c62a7..d17fc4a 100644 --- a/modules/bouncer/context_test.go +++ b/modules/bouncer/context_test.go @@ -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") + } +} diff --git a/modules/cabana/account_tokens_test.go b/modules/cabana/account_tokens_test.go new file mode 100644 index 0000000..db4d037 --- /dev/null +++ b/modules/cabana/account_tokens_test.go @@ -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 +} diff --git a/modules/cabana/api_tokens_test.go b/modules/cabana/api_tokens_test.go new file mode 100644 index 0000000..51e1aad --- /dev/null +++ b/modules/cabana/api_tokens_test.go @@ -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) + } +} diff --git a/modules/cabana/token_commands_test.go b/modules/cabana/token_commands_test.go new file mode 100644 index 0000000..9ffbeb2 --- /dev/null +++ b/modules/cabana/token_commands_test.go @@ -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()) + } +} diff --git a/modules/cabana/token_guard_test.go b/modules/cabana/token_guard_test.go new file mode 100644 index 0000000..c16cea7 --- /dev/null +++ b/modules/cabana/token_guard_test.go @@ -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) + } +} diff --git a/modules/cabana/token_routes_test.go b/modules/cabana/token_routes_test.go index dbe5814..ae52fb5 100644 --- a/modules/cabana/token_routes_test.go +++ b/modules/cabana/token_routes_test.go @@ -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) + } +} diff --git a/modules/cabana/token_scopes_test.go b/modules/cabana/token_scopes_test.go index d14872a..fda3f06 100644 --- a/modules/cabana/token_scopes_test.go +++ b/modules/cabana/token_scopes_test.go @@ -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 +} diff --git a/modules/lagoon/backend_admin_migrations_test.go b/modules/lagoon/backend_admin_migrations_test.go index 84146a7..a60f6cb 100644 --- a/modules/lagoon/backend_admin_migrations_test.go +++ b/modules/lagoon/backend_admin_migrations_test.go @@ -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") + } +} diff --git a/modules/party/registry_test.go b/modules/party/registry_test.go index dd78717..db71b15 100644 --- a/modules/party/registry_test.go +++ b/modules/party/registry_test.go @@ -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) }