Files
sm-user-plugin/member_token_scopes_test.go
Jakub Zych e3c164fdf0 test: named tests for the account link proof, revocation, member token scopes and the organisation logo
- TestMemberTokenScopes walks E-2's acceptance list item by item, plus the
  per-application catalog and the legacy MintAPIToken fallback;
  TestMemberTokenScopesBoot (no Docker) the boot refusals
- TestBackendLinkAuthTime (C-CR-02), TestBackendLinkShowPost (C-WR-01,
  C-IN-06), TestAccessRevocation (C-CR-01), TestUserMCPGuardClientIP
  (C-IN-04), TestAPITokenRevokeEvent (C-IN-02), TestOrganisationLogo (E-1)
- README Testing section names them
2026-10-23 18:36:39 +02:00

281 lines
12 KiB
Go

package user
import (
"encoding/json"
"io/fs"
"net/http"
"strings"
"testing"
"testing/fstest"
"git.golem15.com/golem15/sm-user-plugin/classes"
usermiddleware "git.golem15.com/golem15/sm-user-plugin/middleware"
"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/phrasebook"
"git.golem15.com/golem15/summercms/modules/wire"
)
// acmeInventory declares the member token scopes servers:read and
// servers:write (E-2's acceptance values) and serves the agent's report
// route behind the api token guard and middleware.Scope("servers:write").
// It is registered for the test binary and activated only by
// TestMemberTokenScopes.
type acmeInventory struct{}
func (acmeInventory) ID() string { return "acme.inventory" }
func (acmeInventory) Requires() []string { return []string{"golem15.user"} }
func (acmeInventory) Register(*backpack.App) error { return nil }
func (acmeInventory) Boot(*backpack.App) error { return nil }
func (acmeInventory) FrontendTokenScopes() []pact.FrontendTokenScope {
return []pact.FrontendTokenScope{
{Name: "servers:read", Label: "Read servers", Description: "Look servers up by host and port."},
{Name: "servers:write", Label: "Report servers", Description: "Report the components of a server."},
}
}
func (acmeInventory) Routes(r pact.Router) error {
report := usermiddleware.Scope("servers:write")(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
wire.WriteJSON(w, http.StatusOK, map[string]any{"server": req.PathValue("id"), "reported": true})
}))
r.Put("/_test/servers/{id}/components/report", report.ServeHTTP, "user.api_token")
return nil
}
func init() { party.Register(acmeInventory{}) }
// scopeList is the GET /_user/api/v1/tokens/scopes answer.
type scopeList struct {
Data []struct {
Name string `json:"name"`
Label string `json:"label"`
Description string `json:"description"`
BuiltIn bool `json:"built_in"`
} `json:"data"`
}
// TestMemberTokenScopes pins E-2's acceptance list item by item (each
// subtest quotes it) with a plugin declaring servers:read and servers:write,
// plus the per-application catalog and the legacy MintAPIToken fallback.
// Postgres only: under -short nothing runs.
func TestMemberTokenScopes(t *testing.T) {
if testing.Short() {
return
}
t.Cleanup(classes.ResetDeclaredAPITokenScopes)
env := newAdminEnvWith(t, []string{"acme.inventory"})
member := env.seedUser(t, "e2-member", nil)
session := freshUserToken(t, member)
mint := func(t *testing.T, scopes ...string) *http.Response {
t.Helper()
return env.call(t, http.MethodPost, "/_user/api/v1/tokens", session, map[string]any{"name": "agent " + adminStamp(), "scopes": scopes}).Result()
}
secretOf := func(t *testing.T, 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 || !strings.HasPrefix(body.Token, "usr_") {
t.Fatalf("mint = %d, token %q", res.StatusCode, body.Token)
}
return body.Token
}
const report = "/_test/servers/1/components/report"
t.Run(`A plugin that declares servers:write can mint a usr_ token with that scope through POST /tokens, and a route with middleware.Scope("servers:write") accepts it`, func(t *testing.T) {
agent := secretOf(t, mint(t, "servers:write"))
rec := env.expect(t, http.StatusOK, http.MethodPut, report, agent, map[string]any{"components": []string{"cpu"}})
if !strings.Contains(rec.Body.String(), `"reported":true`) {
t.Fatalf("report = %s", rec.Body.String())
}
})
t.Run(`A token without the scope gets 403 "Missing required scope: servers:write"`, func(t *testing.T) {
reader := secretOf(t, mint(t, "read"))
rec := env.expect(t, http.StatusForbidden, http.MethodPut, report, reader, map[string]any{})
var body struct {
Message string `json:"message"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Message != "Missing required scope: servers:write" {
t.Fatalf("403 body = %s", rec.Body.String())
}
})
t.Run("An undeclared scope still answers 422 at mint time", func(t *testing.T) {
rec := env.call(t, http.MethodPost, "/_user/api/v1/tokens", session, map[string]any{"name": "nope", "scopes": []string{"servers:delete"}})
var body struct {
Errors map[string][]string `json:"errors"`
}
if rec.Code != http.StatusUnprocessableEntity || json.Unmarshal(rec.Body.Bytes(), &body) != nil {
t.Fatalf("undeclared scope = %d %s", rec.Code, rec.Body.String())
}
if got := body.Errors["scopes"]; len(got) != 1 || got[0] != "The selected scope is invalid." {
t.Fatalf("scopes errors = %v (%s)", got, rec.Body.String())
}
})
t.Run("Listing returns them in scopes", func(t *testing.T) {
name := "listed " + adminStamp()
rec := env.call(t, http.MethodPost, "/_user/api/v1/tokens", session, map[string]any{"name": name, "scopes": []string{"servers:write"}})
if rec.Code != http.StatusCreated {
t.Fatalf("mint = %d %s", rec.Code, rec.Body.String())
}
rec = env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/tokens", session, nil)
var listed struct {
Data []struct {
Name string `json:"name"`
Scopes []string `json:"scopes"`
} `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &listed); err != nil {
t.Fatalf("list = %s (%v)", rec.Body.String(), err)
}
found := false
for _, tok := range listed.Data {
if tok.Name == name {
found = true
if len(tok.Scopes) != 1 || tok.Scopes[0] != "servers:write" {
t.Fatalf("listed scopes = %v", tok.Scopes)
}
}
}
if !found {
t.Fatalf("token %q not listed: %s", name, rec.Body.String())
}
})
t.Run("The discovery answer lists the built-ins and the declared scopes with labels", func(t *testing.T) {
for locale, builtIn := range map[string][3]string{"en": {"Read", "Write", "AI"}, "pl": {"Odczyt", "Zapis", "AI"}} {
rec := env.send(t, http.MethodGet, "/_user/api/v1/tokens/scopes", session, nil, "application/json", map[string]string{"Accept-Language": locale})
var got scopeList
if rec.Code != http.StatusOK || json.Unmarshal(rec.Body.Bytes(), &got) != nil || len(got.Data) != 5 {
t.Fatalf("%s: scopes = %d %s", locale, rec.Code, rec.Body.String())
}
want := []struct {
name, label string
builtIn bool
}{
{"read", builtIn[0], true}, {"write", builtIn[1], true}, {"ai", builtIn[2], true},
{"servers:read", "Read servers", false}, {"servers:write", "Report servers", false},
}
for i, w := range want {
s := got.Data[i]
if s.Name != w.name || s.Label != w.label || s.BuiltIn != w.builtIn || s.Description == "" || strings.Contains(s.Description, "::") {
t.Fatalf("%s: scope %d = %+v, want %+v", locale, i, s, w)
}
}
}
env.expect(t, http.StatusUnauthorized, http.MethodGet, "/_user/api/v1/tokens/scopes", "", nil)
})
t.Run("Scopes are scoped to the application", func(t *testing.T) {
appB := newAdminEnv(t)
rec := appB.call(t, http.MethodPost, "/_user/api/v1/tokens", session, map[string]any{"name": "app b", "scopes": []string{"servers:write"}})
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "The selected scope is invalid.") {
t.Fatalf("app B POST /tokens = %d %s", rec.Code, rec.Body.String())
}
if _, _, err := classes.MintAPITokenFor(appB.app, "", member.ID, "app b", []string{"servers:write"}, nil, nil); err == nil {
t.Fatal("MintAPITokenFor(appB) accepted a scope only app A declares")
}
if _, _, err := classes.MintAPITokenFor(env.app, "", member.ID, "app a", []string{"servers:write"}, nil, nil); err != nil {
t.Fatalf("MintAPITokenFor(appA): %v", err)
}
})
t.Run("The legacy MintAPIToken uses the process-wide fallback", func(t *testing.T) {
if _, _, err := classes.MintAPIToken("", member.ID, "legacy", []string{"servers:write"}, nil, nil); err != nil {
t.Fatalf("MintAPIToken(servers:write) after a boot declared it: %v", err)
}
if _, _, err := classes.MintAPIToken("", member.ID, "legacy", []string{"servers:never"}, nil, nil); err == nil {
t.Fatal("MintAPIToken accepted a never-declared scope")
}
classes.ResetDeclaredAPITokenScopes()
if _, _, err := classes.MintAPIToken("", member.ID, "legacy", []string{"servers:write"}, nil, nil); err == nil {
t.Fatal("MintAPIToken accepted servers:write after ResetDeclaredAPITokenScopes")
}
})
}
// scopeBootApp is an application with the user plugin's phrase catalog and
// the given plugins as the activated list, for the scope Boot step alone.
func scopeBootApp(t *testing.T, plugins ...party.Plugin) *backpack.App {
t.Helper()
app := backpack.New(nil)
all := append([]party.Plugin{&Plugin{}}, plugins...)
if err := phrasebook.Activate(app, all); err != nil {
t.Fatal(err)
}
if err := app.Publish(party.Activated(all)); err != nil {
t.Fatal(err)
}
return app
}
// TestMemberTokenScopesBoot pins the boot refusals of E-2 without a
// database: the scope Boot step (bootAPITokenScopes, which Plugin.Boot
// runs) fails for a name two plugins declare, naming both, for a plugin
// redeclaring a built-in, for a bad name or an empty label, and for a
// label naming a missing phrase key; a good declaration publishes the
// catalog.
func TestMemberTokenScopesBoot(t *testing.T) {
t.Cleanup(classes.ResetDeclaredAPITokenScopes)
scope := func(name string) []pact.FrontendTokenScope {
return []pact.FrontendTokenScope{{Name: name, Label: "Label", Description: "Description"}}
}
boot := func(plugins ...party.Plugin) error {
return (&Plugin{}).bootAPITokenScopes(scopeBootApp(t, plugins...))
}
t.Run("Two plugins declaring the same scope name fails boot", func(t *testing.T) {
err := boot(scopeDeclarer{"acme.first", scope("servers:write")}, scopeDeclarer{"acme.second", scope("servers:write")})
if err == nil || !strings.Contains(err.Error(), "acme.first") || !strings.Contains(err.Error(), "acme.second") || !strings.Contains(err.Error(), "servers:write") {
t.Fatalf("err = %v, want both plugin ids and the name", err)
}
})
for _, builtIn := range []string{"read", "write", "ai"} {
t.Run("a plugin redeclaring "+builtIn+" fails boot", func(t *testing.T) {
err := boot(scopeDeclarer{"acme.redeclare", scope(builtIn)})
if err == nil || !strings.Contains(err.Error(), "built-in") || !strings.Contains(err.Error(), `"`+builtIn+`"`) {
t.Fatalf("err = %v", err)
}
})
}
t.Run("a bad name fails boot", func(t *testing.T) {
for _, name := range []string{"Servers", "servers:", "a:b:c", "with space", ""} {
if err := boot(scopeDeclarer{"acme.bad", scope(name)}); err == nil {
t.Fatalf("%q booted", name)
}
}
})
t.Run("an empty label fails boot", func(t *testing.T) {
err := boot(scopeDeclarer{"acme.bad", []pact.FrontendTokenScope{{Name: "servers:write", Label: " ", Description: "Description"}}})
if err == nil || !strings.Contains(err.Error(), "empty label") {
t.Fatalf("err = %v", err)
}
})
t.Run("a label naming a missing phrase key fails at Boot", func(t *testing.T) {
err := boot(scopeDeclarer{"acme.keys", []pact.FrontendTokenScope{{Name: "servers:write", Label: "acme.keys::lang.missing", Description: "Description"}}})
if err == nil || !strings.Contains(err.Error(), "missing phrase key acme.keys::lang.missing") {
t.Fatalf("err = %v", err)
}
})
t.Run("a declared phrase key and a good declaration boot", func(t *testing.T) {
app := scopeBootApp(t, langDeclarer{scopeDeclarer{"acme.keys", []pact.FrontendTokenScope{{Name: "servers:write", Label: "acme.keys::lang.servers_write", Description: "Description"}}}})
if err := (&Plugin{}).bootAPITokenScopes(app); err != nil {
t.Fatal(err)
}
if !classes.APITokenScopesFor(app).Allowed("servers:write") {
t.Fatal("the published catalog does not allow the declared scope")
}
})
}
// langDeclarer is a scopeDeclarer that also ships a phrase catalog.
type langDeclarer struct{ scopeDeclarer }
func (langDeclarer) LangFS() fs.FS {
return fstest.MapFS{"lang/en/lang.yaml": {Data: []byte("servers_write: Report servers\n")}}
}