Files
summercms/modules/cabana/account_tokens_test.go
Jakub Zych 78124ab86f test(16.3-04): full Go coverage for tokens, guard, account API and CLI
Pin bouncer Scope serialization, party Activated order, lagoon migration,
compile/overlap/mint/expiry/validation, guard/debounce/resolver,
interactive-only, account token API and CLI on Postgres.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-11 13:32:11 +02:00

245 lines
8.2 KiB
Go

package cabana_test
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/cabana"
)
func TestAccountTokens(t *testing.T) {
gdb := adminGorm(t)
if err := gdb.AutoMigrate(&tokenDemoWidget{}); err != nil {
t.Fatal(err)
}
h := tokenAdminHandler(t)
user := insertAdmin(t, gdb, "acct-full", "acct-full@example.test", "secret-pass", true, false)
other := insertAdmin(t, gdb, "acct-other", "acct-other@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())
otherLogin := postJSON(t, h, adminAPI("/auth/login"), map[string]string{
"login": other.Login, "password": "secret-pass",
})
otherJWT := accessToken(t, otherLogin.Body.Bytes())
// Create two tokens so list ordering (newest first) is visible.
first := postAuth(t, h, http.MethodPost, adminAPI("/account/tokens"), jwt, mustJSON(t, map[string]any{
"name": "older", "expires": "90d", "scopes": []string{"demo"},
}))
if first.Code != http.StatusCreated {
t.Fatalf("first create: %d %s", first.Code, first.Body.String())
}
time.Sleep(10 * time.Millisecond)
second := postAuth(t, h, http.MethodPost, adminAPI("/account/tokens"), jwt, mustJSON(t, map[string]any{
"name": "newer", "expires": "90d", "scopes": []string{"*"},
}))
if second.Code != http.StatusCreated {
t.Fatalf("second create: %d %s", second.Code, second.Body.String())
}
var created struct {
Data cabana.AdminAPITokenCreated `json:"data"`
}
if err := json.Unmarshal(second.Body.Bytes(), &created); err != nil {
t.Fatal(err)
}
if !wellFormedPlain(created.Data.Token) {
t.Fatalf("plaintext %q", created.Data.Token)
}
plain := created.Data.Token
if strings.Contains(second.Body.String(), "token_hash") {
t.Fatal("201 leaked hash")
}
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":`) || strings.Contains(list.Body.String(), plain) {
t.Fatal("list leaked plaintext")
}
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) < 2 || listed.Data[0].Name != "newer" {
t.Fatalf("newest first = %#v", listed.Data)
}
// Scope entries: known demo, unknown known:false.
var olderID uint
if err := gdb.Raw(`SELECT id FROM backend_api_tokens WHERE name = 'older' AND backend_user_id = ?`, user.ID).Scan(&olderID).Error; err != nil || olderID == 0 {
t.Fatalf("older id: %d err=%v", olderID, err)
}
if err := gdb.Exec(`UPDATE backend_api_tokens SET scopes = ? WHERE id = ?`, `["demo","ghost"]`, olderID).Error; err != nil {
t.Fatal(err)
}
list = getAuth(t, h, adminAPI("/account/tokens"), jwt)
if err := json.Unmarshal(list.Body.Bytes(), &listed); err != nil {
t.Fatal(err)
}
var older *cabana.AdminAPIToken
for i := range listed.Data {
if listed.Data[i].Name == "older" {
older = &listed.Data[i]
break
}
}
if older == nil {
t.Fatal("older missing")
}
foundStar, foundDemo, foundGhost := false, false, false
for _, s := range older.Scopes {
switch s.Name {
case "demo":
foundDemo = true
if !s.Known || s.Label == "" {
t.Fatalf("demo scope entry %#v", s)
}
case "ghost":
foundGhost = true
if s.Known {
t.Fatalf("ghost known true: %#v", s)
}
case "*":
foundStar = true
}
}
_ = foundStar
if !foundDemo || !foundGhost {
t.Fatalf("scope entries %#v", older.Scopes)
}
for _, s := range listed.Data[0].Scopes {
if s.Name == "*" && (s.Label == "" || !s.Known) {
t.Fatalf("* entry %#v", s)
}
}
// Revoked filter.
revokedID := listed.Data[0].ID
path := adminAPI("/account/tokens/" + strconv.FormatUint(uint64(revokedID), 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())
}
active := getAuth(t, h, adminAPI("/account/tokens"), jwt)
var activeList struct {
Data []cabana.AdminAPIToken `json:"data"`
}
_ = json.Unmarshal(active.Body.Bytes(), &activeList)
for _, row := range activeList.Data {
if row.ID == revokedID {
t.Fatal("active list should omit revoked by default")
}
}
withRevoked := getAuth(t, h, adminAPI("/account/tokens")+"?revoked=1", jwt)
_ = json.Unmarshal(withRevoked.Body.Bytes(), &listed)
foundRevoked := false
for _, row := range listed.Data {
if row.RevokedAt != nil {
foundRevoked = true
}
}
if !foundRevoked {
t.Fatal("revoked=1 did not include revoked rows")
}
// 422 rules with server message text.
cases := []struct {
name string
body map[string]any
field string
msg string
}{
{"blank name", map[string]any{"name": "", "expires": "90d", "scopes": []string{"demo"}}, "name", "Enter a name"},
{"bad expires", map[string]any{"name": "x", "expires": "nope", "scopes": []string{"demo"}}, "expires", "valid expiry"},
{"empty scopes", map[string]any{"name": "x", "expires": "90d", "scopes": []string{}}, "scopes", "at least one"},
}
for _, tc := range cases {
rec := postAuth(t, h, http.MethodPost, adminAPI("/account/tokens"), jwt, mustJSON(t, tc.body))
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("%s: want 422, got %d %s", tc.name, rec.Code, rec.Body.String())
}
if !strings.Contains(strings.ToLower(rec.Body.String()), strings.ToLower(tc.msg)) {
t.Fatalf("%s: body %s missing %q", tc.name, rec.Body.String(), tc.msg)
}
}
// * normalization stored.
starCreate := postAuth(t, h, http.MethodPost, adminAPI("/account/tokens"), jwt, mustJSON(t, map[string]any{
"name": "full", "expires": "30d", "scopes": []string{"demo", "*"},
}))
if starCreate.Code != http.StatusCreated {
t.Fatalf("star create: %d %s", starCreate.Code, starCreate.Body.String())
}
var starRow cabana.BackendAPIToken
if err := gdb.Where("name = ? AND backend_user_id = ?", "full", user.ID).First(&starRow).Error; err != nil {
t.Fatal(err)
}
scopes := starRow.Scopes.Get()
if len(scopes) != 1 || scopes[0] != "*" {
t.Fatalf("stored scopes = %#v", scopes)
}
// Own-only soft revoke: other user's token → 404; already revoked → 404.
otherTok, err := cabana.IssueTestAPIToken(gdb, other.ID, []string{"demo"}, nil)
if err != nil {
t.Fatal(err)
}
_ = otherTok
var otherID uint
if err := gdb.Raw(`SELECT id FROM backend_api_tokens WHERE backend_user_id = ? ORDER BY id DESC LIMIT 1`, other.ID).Scan(&otherID).Error; err != nil {
t.Fatal(err)
}
otherPath := adminAPI("/account/tokens/" + strconv.FormatUint(uint64(otherID), 10))
if rec := postAuth(t, h, http.MethodDelete, otherPath, jwt, nil); rec.Code != http.StatusNotFound {
t.Fatalf("revoke other: want 404, got %d", rec.Code)
}
if rec := postAuth(t, h, http.MethodDelete, path, jwt, nil); rec.Code != http.StatusNotFound {
t.Fatalf("already revoked: want 404, got %d", rec.Code)
}
// Scopes picker: developer vs user without demo grants vs superuser.
scopesRec := getAuth(t, h, adminAPI("/account/tokens/scopes"), jwt)
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("developer scopes %#v", names)
}
super := insertAdmin(t, gdb, "acct-super", "acct-super@example.test", "secret-pass", true, false)
if err := gdb.Exec(`UPDATE backend_users SET is_superuser = true WHERE id = ?`, super.ID).Error; err != nil {
t.Fatal(err)
}
superLogin := postJSON(t, h, adminAPI("/auth/login"), map[string]string{
"login": super.Login, "password": "secret-pass",
})
superJWT := accessToken(t, superLogin.Body.Bytes())
superScopes := getAuth(t, h, adminAPI("/account/tokens/scopes"), superJWT)
_ = json.Unmarshal(superScopes.Body.Bytes(), &scopesBody)
superNames := map[string]bool{}
for _, s := range scopesBody.Data {
superNames[s.Name] = true
}
if !superNames["*"] || !superNames["admin:read"] {
t.Fatalf("superuser scopes %#v", superNames)
}
_ = otherJWT
}