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>
241 lines
8.5 KiB
Go
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())
|
|
}
|
|
}
|