- 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
167 lines
7.2 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|