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

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

241 lines
8.5 KiB
Go

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())
}
}