package cabana_test import ( "bytes" "context" "encoding/json" "io/fs" "net/http" "os" "path/filepath" "strconv" "strings" "testing" "testing/fstest" "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/compass" "git.golem15.com/golem15/summercms/modules/lagoon" "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/party" "git.golem15.com/golem15/summercms/modules/surf" ) type tokenDemoWidget struct { ID uint `gorm:"column:id;primaryKey"` Name string `gorm:"column:name"` } func (tokenDemoWidget) TableName() string { return "cabana_token_demo_widgets" } func (tokenDemoWidget) Fillable() []string { return []string{"name"} } func (tokenDemoWidget) Rules() map[string]string { return map[string]string{} } type tokenDemoPlugin struct{ fsys fs.FS } func (p tokenDemoPlugin) ID() string { return "acme.demo" } func (p tokenDemoPlugin) Requires() []string { return nil } func (p tokenDemoPlugin) Register(*backpack.App) error { return nil } func (p tokenDemoPlugin) Boot(*backpack.App) error { return nil } func (p tokenDemoPlugin) AdminControllers() []pact.AdminController { return []pact.AdminController{tokenDemoController{}} } func (p tokenDemoPlugin) Models() []any { return []any{&tokenDemoWidget{}} } func (p tokenDemoPlugin) Permissions() []pact.Permission { return []pact.Permission{ {Code: "acme.demo.access", Roles: []string{"developer"}}, {Code: "acme.demo.reports", Roles: []string{"developer"}}, } } func (p tokenDemoPlugin) TokenScopes() []pact.TokenScope { all := []string{"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"} return []pact.TokenScope{ { Name: "demo", Label: "Demo", Description: "Full demo access", Permissions: []string{"acme.demo.*"}, Methods: all, }, { Name: "demo:reports", Label: "Demo reports", Description: "Reports only", Permissions: []string{"acme.demo.reports"}, Methods: []string{"GET", "HEAD"}, }, } } func (p tokenDemoPlugin) AdminFS() fs.FS { return p.fsys } type tokenDemoController struct{} func (tokenDemoController) ID() string { return "acme.demo.widgets" } func (tokenDemoController) ModelName() string { return "Widget" } func (tokenDemoController) ConfigDir() string { return "controllers/widgets" } func (tokenDemoController) NewRecord() any { return &tokenDemoWidget{} } func (tokenDemoController) RequiredPermissions() []string { return []string{"acme.demo.access"} } func tokenDemoFS() fs.FS { return fstest.MapFS{ "controllers/widgets/config_list.yaml": &fstest.MapFile{Data: []byte("list: ~/plugins/acme/demo/models/widget/columns.yaml\nmodelClass: Widget\nrecordsPerPage: 20\nshowSearch: true\n")}, "models/widget/columns.yaml": &fstest.MapFile{Data: []byte("columns:\n name:\n label: Name\n searchable: true\n")}, } } func tokenAdminHandler(t *testing.T) http.Handler { t.Helper() gdb := adminGorm(t) dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: cabana-token\n"), 0o644); err != nil { t.Fatal(err) } cfg, err := compass.Open(compass.Options{ Dir: dir, Environ: []string{ "SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=" + adminTestSecret, }, }) if err != nil { t.Fatal(err) } if err := cfg.Set("http.body_limits.default_bytes", 1048576); err != nil { t.Fatal(err) } if err := cfg.Set("http.body_limits.upload_bytes", 1048576); err != nil { t.Fatal(err) } app := backpack.New(cfg) if err := lagoon.Publish(app, adminSQL, gdb); err != nil { t.Fatal(err) } h, err := surf.Assemble(app, []party.Plugin{tokenDemoPlugin{fsys: tokenDemoFS()}}) if err != nil { t.Fatal(err) } return h } func TestTokenTracerPostgres(t *testing.T) { gdb := adminGorm(t) if err := gdb.AutoMigrate(&tokenDemoWidget{}); err != nil { t.Fatal(err) } h := tokenAdminHandler(t) user := insertAdmin(t, gdb, "tok-dev", "tok-dev@example.test", "secret-pass", true, false) demoPlain, err := cabana.IssueTestAPIToken(gdb, user.ID, []string{"demo"}, nil) if err != nil { t.Fatal(err) } reportsPlain, err := cabana.IssueTestAPIToken(gdb, user.ID, []string{"demo:reports"}, nil) if err != nil { t.Fatal(err) } listPath := adminAPI("/acme/demo/widgets") if rec := getAuth(t, h, listPath, demoPlain); rec.Code != http.StatusOK { t.Fatalf("demo list: %d %s", rec.Code, rec.Body.String()) } if rec := getAuth(t, h, listPath, reportsPlain); rec.Code != http.StatusForbidden { t.Fatalf("reports list: want 403, got %d %s", rec.Code, rec.Body.String()) } if rec := postAuth(t, h, http.MethodPost, listPath, reportsPlain, []byte(`{}`)); rec.Code != http.StatusForbidden { t.Fatalf("reports POST: want 403, got %d %s", rec.Code, rec.Body.String()) } else { var body struct { Error struct { Details map[string]any `json:"details"` } `json:"error"` } if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatal(err) } if body.Error.Details["reason"] != "token_scope" { t.Fatalf("reason = %v body=%s", body.Error.Details["reason"], rec.Body.String()) } } if err := gdb.Delete(&user).Error; err != nil { t.Fatal(err) } if rec := getAuth(t, h, listPath, demoPlain); rec.Code != http.StatusUnauthorized { t.Fatalf("soft-deleted owner: want 401, got %d %s", rec.Code, rec.Body.String()) } } func TestAccountTokensSmoke(t *testing.T) { gdb := adminGorm(t) if err := gdb.AutoMigrate(&tokenDemoWidget{}); err != nil { t.Fatal(err) } h := tokenAdminHandler(t) user := insertAdmin(t, gdb, "tok-acct", "tok-acct@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()) scopesRec := getAuth(t, h, adminAPI("/account/tokens/scopes"), jwt) if scopesRec.Code != http.StatusOK { t.Fatalf("scopes: %d %s", scopesRec.Code, scopesRec.Body.String()) } 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("scopes = %#v, want * and demo", names) } create := postAuth(t, h, http.MethodPost, adminAPI("/account/tokens"), jwt, mustJSON(t, map[string]any{ "name": "agent", "expires": "90d", "scopes": []string{"demo"}, })) if create.Code != http.StatusCreated { t.Fatalf("create: %d %s", create.Code, create.Body.String()) } var created struct { Data cabana.AdminAPITokenCreated `json:"data"` } if err := json.Unmarshal(create.Body.Bytes(), &created); err != nil { t.Fatal(err) } if !wellFormedPlain(created.Data.Token) { t.Fatalf("token shape %q", created.Data.Token) } if strings.Contains(create.Body.String(), "token_hash") { t.Fatal("create body leaked token_hash") } plain := created.Data.Token hint := created.Data.Hint if hint != plain[len(plain)-4:] { t.Fatalf("hint %q != last4 of %q", hint, plain) } 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":`) { t.Fatal("list body leaked plaintext token") } 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) == 0 || listed.Data[0].Hint != hint { t.Fatalf("list = %#v", listed.Data) } if rec := getAuth(t, h, adminAPI("/account/tokens"), plain); rec.Code != http.StatusForbidden { t.Fatalf("token on token route: want 403, got %d %s", rec.Code, rec.Body.String()) } path := adminAPI("/account/tokens/" + strconv.FormatUint(uint64(created.Data.ID), 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()) } del2 := postAuth(t, h, http.MethodDelete, path, jwt, nil) if del2.Code != http.StatusNotFound { t.Fatalf("second revoke: want 404, got %d", del2.Code) } if rec := getAuth(t, h, adminAPI("/acme/demo/widgets"), plain); rec.Code != http.StatusUnauthorized { t.Fatalf("revoked token: want 401, got %d %s", rec.Code, rec.Body.String()) } } func TestTokenCommandsSmoke(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, "tok-cli", "tok-cli@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": "90d", }, multiFlags: map[string][]string{"scope": {"demo"}}, }, bonfire.NewOutput(nil, &createOut, &bytes.Buffer{})); err != nil { t.Fatalf("admin:token:create: %v", err) } plain := strings.TrimSpace(createOut.String()) if !wellFormedPlain(plain) { t.Fatalf("create stdout %q", createOut.String()) } if strings.Count(createOut.String(), "\n") != 1 || !strings.HasSuffix(createOut.String(), "\n") { t.Fatalf("create must print exactly one token line, got %q", createOut.String()) } 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("admin:token:list: %v", err) } listText := listOut.String() if strings.Contains(listText, plain) { t.Fatal("list leaked plaintext token") } hint := plain[len(plain)-4:] if !strings.Contains(listText, "g15_…"+hint) { t.Fatalf("list missing hint, got %q", listText) } 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 := 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("admin:token:revoke: %v", err) } 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("admin:token:scopes: %v", err) } scopesText := scopesOut.String() for _, name := range []string{"*", "admin:read", "demo"} { if !strings.Contains(scopesText, name) { t.Fatalf("scopes missing %q in %q", name, scopesText) } } } func mustJSON(t *testing.T, v any) []byte { t.Helper() b, err := json.Marshal(v) if err != nil { t.Fatal(err) } return b } func wellFormedPlain(v string) bool { if len(v) != 44 || !strings.HasPrefix(v, "g15_") { return false } for _, c := range v[4:] { if (c < '0' || c > '9') && (c < 'a' || c > 'f') { return false } } return true }