Files
sm-user-plugin/member_token_scopes_smoke_test.go
Jakub Zych ee5c096d0d feat: plugin-declared member token scopes (pact.HasFrontendTokenScopes)
- CompileAPITokenScopes builds the catalog from the built-ins and the
  activated plugins' declarations; a bad name, a redeclared built-in, a
  duplicate, an empty label or a missing phrase key fails boot
- POST /tokens, user:token:create and MintAPITokenFor check the
  application's own catalog (422 text unchanged); MintAPIToken keeps its
  signature with a process-wide fallback for declared scopes
- GET /_user/api/v1/tokens/scopes lists the built-ins and the declared
  scopes with translated labels
- built-in scope labels in en and pl; TestMemberTokenScopesSmoke
2026-10-23 17:00:12 +02:00

167 lines
7.2 KiB
Go

package user
import (
"encoding/json"
"net/http"
"slices"
"strings"
"testing"
"git.golem15.com/golem15/sm-user-plugin/classes"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"git.golem15.com/golem15/summercms/modules/wire"
)
// scopeDeclarer is a plugin value that only declares member token scopes,
// for CompileAPITokenScopes; it is never registered.
type scopeDeclarer struct {
id string
scopes []pact.FrontendTokenScope
}
func (d scopeDeclarer) ID() string { return d.id }
func (d scopeDeclarer) Requires() []string { return nil }
func (d scopeDeclarer) Register(*backpack.App) error { return nil }
func (d scopeDeclarer) Boot(*backpack.App) error { return nil }
func (d scopeDeclarer) FrontendTokenScopes() []pact.FrontendTokenScope { return d.scopes }
// acmeServers declares the member token scope servers:write and serves a
// route behind user.api_token and user.scope:servers:write. It is
// registered for the test binary and activated only by the tests that name
// it.
type acmeServers struct{}
func (acmeServers) ID() string { return "acme.servers" }
func (acmeServers) Requires() []string { return []string{"golem15.user"} }
func (acmeServers) Register(*backpack.App) error { return nil }
func (acmeServers) Boot(*backpack.App) error { return nil }
func (acmeServers) FrontendTokenScopes() []pact.FrontendTokenScope {
return []pact.FrontendTokenScope{{Name: "servers:write", Label: "Write servers", Description: "Report server components."}}
}
func (acmeServers) Routes(r pact.Router) error {
r.Get("/_servers/api/v1/ping", func(w http.ResponseWriter, _ *http.Request) {
wire.WriteJSON(w, http.StatusOK, map[string]any{"ok": true})
}, "user.api_token", "user.scope:servers:write")
return nil
}
func init() { party.Register(acmeServers{}) }
// TestMemberTokenScopesSmoke (E-2): a duplicate, a redeclared built-in and a
// bad name fail the catalog; with a plugin declaring servers:write, POST
// /tokens mints it, a route behind user.scope:servers:write accepts it and
// refuses a read token, an undeclared scope answers 422, GET /tokens/scopes
// lists the built-ins and the declared scope with labels, and a second
// application without the plugin refuses the scope over HTTP and through
// MintAPITokenFor. The Postgres part skips under -short.
func TestMemberTokenScopesSmoke(t *testing.T) {
t.Cleanup(classes.ResetDeclaredAPITokenScopes)
t.Run("catalog validation", func(t *testing.T) {
scope := func(name string) []pact.FrontendTokenScope {
return []pact.FrontendTokenScope{{Name: name, Label: "Label", Description: "Description"}}
}
for name, plugins := range map[string][]party.Plugin{
"duplicate": {scopeDeclarer{"acme.a", scope("acme:dup")}, scopeDeclarer{"acme.b", scope("acme:dup")}},
"built-in": {scopeDeclarer{"acme.a", scope("write")}},
"bad name": {scopeDeclarer{"acme.a", scope("Bad Name")}},
"empty label": {scopeDeclarer{"acme.a", []pact.FrontendTokenScope{{Name: "acme:x", Description: "Description"}}}},
} {
if _, err := classes.CompileAPITokenScopes(plugins); err == nil {
t.Fatalf("%s: CompileAPITokenScopes succeeded", name)
}
}
catalog, err := classes.CompileAPITokenScopes([]party.Plugin{scopeDeclarer{"acme.a", scope("acme:ok")}})
if err != nil {
t.Fatal(err)
}
var names []string
for _, s := range catalog.Scopes() {
names = append(names, s.Name)
}
if !slices.Equal(names, []string{"read", "write", "ai", "acme:ok"}) || !catalog.Allowed("acme:ok") || catalog.Allowed("acme:dup") {
t.Fatalf("catalog = %v", names)
}
})
t.Run("mint, check, list and discovery", func(t *testing.T) {
env := newAdminEnvWith(t, []string{"acme.servers"})
user := env.seedUser(t, "member-scopes", nil)
session := freshUserToken(t, user)
mint := func(e *adminEnv, scopes ...string) *http.Response {
t.Helper()
rec := e.call(t, http.MethodPost, "/_user/api/v1/tokens", session, map[string]any{"name": "agent " + adminStamp(), "scopes": scopes})
return rec.Result()
}
secretOf := func(res *http.Response) string {
t.Helper()
var body struct {
Token string `json:"token"`
}
if res.StatusCode != http.StatusCreated || json.NewDecoder(res.Body).Decode(&body) != nil || body.Token == "" {
t.Fatalf("mint = %d", res.StatusCode)
}
return body.Token
}
agent := secretOf(mint(env, "servers:write"))
env.expect(t, http.StatusOK, http.MethodGet, "/_servers/api/v1/ping", agent, nil)
reader := secretOf(mint(env, "read"))
rec := env.expect(t, http.StatusForbidden, http.MethodGet, "/_servers/api/v1/ping", reader, nil)
if !strings.Contains(rec.Body.String(), "Missing required scope: servers:write") {
t.Fatalf("read token at the servers route = %s", rec.Body.String())
}
rec = env.call(t, http.MethodPost, "/_user/api/v1/tokens", session, map[string]any{"name": "nope", "scopes": []string{"servers:nope"}})
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "The selected scope is invalid.") {
t.Fatalf("undeclared scope = %d %s", rec.Code, rec.Body.String())
}
rec = env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/tokens", session, nil)
if !strings.Contains(rec.Body.String(), `"servers:write"`) {
t.Fatalf("token list = %s", rec.Body.String())
}
rec = env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/tokens/scopes", session, nil)
var listed struct {
Data []struct {
Name string `json:"name"`
Label string `json:"label"`
Description string `json:"description"`
BuiltIn bool `json:"built_in"`
} `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &listed); err != nil || len(listed.Data) != 4 {
t.Fatalf("scopes = %s (%v)", rec.Body.String(), err)
}
want := []struct {
name, label string
builtIn bool
}{{"read", "Read", true}, {"write", "Write", true}, {"ai", "AI", true}, {"servers:write", "Write servers", false}}
for i, w := range want {
got := listed.Data[i]
if got.Name != w.name || got.Label != w.label || got.BuiltIn != w.builtIn || got.Description == "" || strings.Contains(got.Description, "::") {
t.Fatalf("scope %d = %+v, want %+v", i, got, w)
}
}
env.expect(t, http.StatusUnauthorized, http.MethodGet, "/_user/api/v1/tokens/scopes", "", nil)
// A second application without the declaring plugin refuses it.
other := newAdminEnv(t)
rec = other.call(t, http.MethodPost, "/_user/api/v1/tokens", session, map[string]any{"name": "other", "scopes": []string{"servers:write"}})
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "The selected scope is invalid.") {
t.Fatalf("second application = %d %s", rec.Code, rec.Body.String())
}
if _, _, err := classes.MintAPITokenFor(other.app, "", user.ID, "other", []string{"servers:write"}, nil, nil); err == nil {
t.Fatal("MintAPITokenFor accepted another application's scope")
}
if _, _, err := classes.MintAPITokenFor(env.app, "", user.ID, "declaring", []string{"servers:write"}, nil, nil); err != nil {
t.Fatalf("MintAPITokenFor in the declaring application: %v", err)
}
})
}