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")}} }