- 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
281 lines
12 KiB
Go
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")}}
|
|
}
|