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