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>
245 lines
8.2 KiB
Go
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
|
|
}
|