Files
summercms/modules/cabana/token_postgres_smoke_test.go

348 lines
11 KiB
Go

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
}