- Add list/create/revoke/scopes handlers, validation, and soft revoke - Regenerate admin OpenAPI and TS types; inventory and conformance cases - Add server-side api_tokens phrase keys (en/pl) Co-authored-by: Cursor <cursoragent@cursor.com>
272 lines
8.8 KiB
Go
272 lines
8.8 KiB
Go
package cabana_test
|
|
|
|
import (
|
|
"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/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 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
|
|
}
|