feat(16.3-01): account token API with OpenAPI and phrase keys
- 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>
This commit is contained in:
@@ -4,18 +4,20 @@ 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"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
type tokenDemoWidget struct {
|
||||
@@ -154,3 +156,116 @@ func TestTokenTracerPostgres(t *testing.T) {
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user