package cabana_test import ( "bytes" "context" "encoding/json" "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" ) // tokenJSONApp is a command app on the package database with the demo // plugin's scopes and one administrator. func tokenJSONApp(t *testing.T, login string) *backpack.App { t.Helper() gdb := adminGorm(t) if err := gdb.AutoMigrate(&tokenDemoWidget{}); err != nil { t.Fatal(err) } app := commandApp(t, gdb) if err := app.Publish(party.Activated{tokenDemoPlugin{fsys: tokenDemoFS()}}); err != nil { t.Fatal(err) } insertAdmin(t, gdb, login, login+"@example.test", "secret-pass", true, false) return app } // callToken runs one admin:token command through bonfire.Call, so the // global options and flags are parsed as on the command line. func callToken(t *testing.T, app *backpack.App, name string, args ...string) string { t.Helper() var buf bytes.Buffer if err := bonfire.Call(context.Background(), cabana.RuntimeCommands(app), name, args, &buf); err != nil { t.Fatalf("%s %v: %v\n%s", name, args, err, buf.String()) } return buf.String() } // TestTokenCreateJSON: admin:token:create --json prints one JSON object // with id, name, token, scopes and expires_at, under -q too. func TestTokenCreateJSON(t *testing.T) { app := tokenJSONApp(t, "json-create") for _, extra := range [][]string{nil, {"-q"}} { args := append([]string{"json-create", "--name", "agent", "--scope", "demo", "--expires", "2027-01-01", "--json"}, extra...) out := callToken(t, app, "admin:token:create", args...) var got struct { ID uint `json:"id"` Name string `json:"name"` Token string `json:"token"` Scopes []string `json:"scopes"` ExpiresAt *string `json:"expires_at"` } dec := json.NewDecoder(strings.NewReader(out)) dec.DisallowUnknownFields() if err := dec.Decode(&got); err != nil { t.Fatalf("%v: not the JSON object: %v\n%s", extra, err, out) } if got.ID == 0 || got.Name != "agent" || !wellFormedPlain(got.Token) || strings.Join(got.Scopes, ",") != "demo" || got.ExpiresAt == nil || !strings.HasPrefix(*got.ExpiresAt, "2027-01-01") { t.Errorf("%v: created = %+v", extra, got) } } } // TestTokenListJSON: admin:token:list --json prints a JSON array of every // active token with its hint and never the token itself; --revoked adds // revoked ones; an empty list is []. func TestTokenListJSON(t *testing.T) { app := tokenJSONApp(t, "json-list") empty := callToken(t, app, "admin:token:list", "json-list", "--json") if strings.TrimSpace(empty) != "[]" { t.Fatalf("empty list = %q, want []", empty) } plain := strings.TrimSpace(callToken(t, app, "admin:token:create", "json-list", "--name", "one", "--scope", "demo")) second := strings.TrimSpace(callToken(t, app, "admin:token:create", "json-list", "--name", "two", "--scope", "demo")) out := callToken(t, app, "admin:token:list", "json-list", "--json") var list []map[string]any if err := json.Unmarshal([]byte(out), &list); err != nil { t.Fatalf("not a JSON array: %v\n%s", err, out) } if len(list) != 2 { t.Fatalf("list = %v", list) } for _, key := range []string{"id", "login", "name", "scopes", "hint", "created_at", "last_used_at", "expires_at", "revoked_at"} { if _, ok := list[0][key]; !ok { t.Errorf("token entry lacks %s: %v", key, list[0]) } } if list[0]["login"] != "json-list" || list[0]["name"] != "two" || list[0]["hint"] != "g15_…"+second[len(second)-4:] { t.Errorf("newest entry = %v", list[0]) } if strings.Contains(out, plain) || strings.Contains(out, second) { t.Fatalf("the list leaks a plaintext token:\n%s", out) } id := uint64(list[1]["id"].(float64)) callToken(t, app, "admin:token:revoke", jsonNumber(id)) if active := callToken(t, app, "admin:token:list", "json-list", "--json"); strings.Count(active, `"id":`) != 1 { t.Errorf("revoked token still listed:\n%s", active) } if all := callToken(t, app, "admin:token:list", "json-list", "--json", "--revoked", "-q"); strings.Count(all, `"id":`) != 2 || !strings.Contains(all, `"revoked_at": "`) { t.Errorf("--revoked -q list:\n%s", all) } } // TestTokenCreateQuietKeepsToken: under -q admin:token:create still prints // the token, and only the token (data, not decoration). func TestTokenCreateQuietKeepsToken(t *testing.T) { app := tokenJSONApp(t, "json-quiet") out := callToken(t, app, "admin:token:create", "json-quiet", "--name", "agent", "--scope", "demo", "-q") if strings.Count(out, "\n") != 1 || !wellFormedPlain(strings.TrimSpace(out)) { t.Fatalf("-q output = %q, want one token line", out) } var list []struct { ID uint64 `json:"id"` } if err := json.Unmarshal([]byte(callToken(t, app, "admin:token:list", "json-quiet", "--json")), &list); err != nil || len(list) != 1 { t.Fatalf("list: %v %v", list, err) } revoked := callToken(t, app, "admin:token:revoke", jsonNumber(list[0].ID), "-q") if revoked != "" { t.Errorf("-q revoke printed %q, want nothing", revoked) } } func jsonNumber(n uint64) string { b, _ := json.Marshal(n) return string(b) }