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