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 }