package cabana_test import ( "encoding/json" "io/fs" "net/http" "testing" "testing/fstest" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/cabana" "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/party" "git.golem15.com/golem15/summercms/modules/surf" "git.golem15.com/golem15/summercms/modules/compass" "git.golem15.com/golem15/summercms/modules/lagoon" "os" "path/filepath" ) 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()) } }