diff --git a/backend_link_test.go b/backend_link_test.go new file mode 100644 index 0000000..a0c05a0 --- /dev/null +++ b/backend_link_test.go @@ -0,0 +1,210 @@ +package user + +import ( + "context" + "encoding/json" + "net/http" + "slices" + "strconv" + "strings" + "sync" + "testing" + "time" + + "git.golem15.com/golem15/sm-user-plugin/classes" + "git.golem15.com/golem15/sm-user-plugin/controllers" + "git.golem15.com/golem15/sm-user-plugin/models" + "git.golem15.com/golem15/summercms/modules/lagoon" + "git.golem15.com/golem15/summercms/modules/pact" + "gorm.io/gorm" +) + +// requestCreatedAt reads when the administrator's open link request was +// created. +func (e *adminEnv) requestCreatedAt(t *testing.T, adminID uint) time.Time { + t.Helper() + var created time.Time + if err := e.gdb.Raw(`SELECT created_at FROM golem15_user_backend_link_requests WHERE backend_user_id = ? AND used_at IS NULL ORDER BY id DESC LIMIT 1`, adminID).Scan(&created).Error; err != nil || created.IsZero() { + t.Fatalf("link request created_at = %v, %v", created, err) + } + return created +} + +// TestBackendLink (D-03..D-08) extends the link smokes: the fresh sign-in +// rule at the second (an iat equal to the request's created_at confirms, +// one second earlier is stale_session); 1:1 on the frontend side too (a +// linked user cannot confirm another administrator's request); the show +// route exposes the administrator's login and name and the expiry only; +// SetLink records linked_by and fires BackendLinkedEvent and both subject +// events after commit, nothing on rollback; RemoveLink fires +// BackendUnlinkedEvent and both subject events; LinkedBackendUser answers +// false for a deactivated or soft-deleted administrator; deactivating the +// frontend user removes the link and fires deleted. Skips under -short. +func TestBackendLink(t *testing.T) { + env := newAdminEnv(t) + spy := env.spySubjects() + links, _ := env.app.Lookup[*classes.BackendLinks]() + resolver, _ := env.app.Lookup[pact.MCPLinkedBackendResolver]() + ctx := context.Background() + + t.Run("fresh sign-in at the second", func(t *testing.T) { + token := env.admin(t) + admin := adminID(t, token) + user := env.seedUser(t, "iat", nil) + sub := strconv.FormatUint(uint64(user.ID), 10) + code := env.startLink(t, token) + created := env.requestCreatedAt(t, admin).Truncate(time.Second) + early := craftToken(t, sub, "early-"+adminStamp(), created.Add(-time.Second), time.Now().Add(time.Hour)) + if status, body := env.confirmLink(t, early, code); status != http.StatusConflict || !strings.Contains(body, `"stale_session"`) { + t.Fatalf("iat one second early = %d %s", status, body) + } + exact := craftToken(t, sub, "exact-"+adminStamp(), created, time.Now().Add(time.Hour)) + if status, body := env.confirmLink(t, exact, code); status != http.StatusOK { + t.Fatalf("iat equal to created_at = %d %s", status, body) + } + }) + + t.Run("a linked frontend user cannot take a second administrator", func(t *testing.T) { + first, second := env.admin(t), env.admin(t) + user := env.seedUser(t, "one-to-one", nil) + code := env.startLink(t, first) + if status, body := env.confirmLink(t, freshUserToken(t, user), code); status != http.StatusOK { + t.Fatalf("first link = %d %s", status, body) + } + code = env.startLink(t, second) + if status, body := env.confirmLink(t, freshUserToken(t, user), code); status != http.StatusConflict || !strings.Contains(body, `"already_linked"`) { + t.Fatalf("second administrator = %d %s", status, body) + } + }) + + t.Run("show exposes login, name and expiry only", func(t *testing.T) { + token := env.admin(t) + code := env.startLink(t, token) + rec := env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/backend-link/"+code, "", nil) + var shown map[string]map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &shown); err != nil { + t.Fatal(err) + } + keys := func(m map[string]any) []string { + var out []string + for k := range m { + out = append(out, k) + } + slices.Sort(out) + return out + } + admin, _ := shown["data"]["admin"].(map[string]any) + if len(shown) != 1 || !slices.Equal(keys(shown["data"]), []string{"admin", "expires_at"}) || !slices.Equal(keys(admin), []string{"login", "name"}) { + t.Fatalf("show = %s", rec.Body.String()) + } + if strings.Contains(rec.Body.String(), "@") || strings.Contains(rec.Body.String(), code) { + t.Fatalf("show leaks an email or the code: %s", rec.Body.String()) + } + }) + + t.Run("SetLink and RemoveLink events", func(t *testing.T) { + var mu sync.Mutex + var linked, unlinked int + env.app.Events.Listen("test.link_events", func(context.Context, *classes.BackendLinkedEvent) error { + mu.Lock() + linked++ + mu.Unlock() + return nil + }) + env.app.Events.Listen("test.link_events", func(context.Context, *classes.BackendUnlinkedEvent) error { + mu.Lock() + unlinked++ + mu.Unlock() + return nil + }) + setter := adminID(t, env.admin(t)) + target := adminID(t, env.admin(t)) + user := env.seedUser(t, "set-link", nil) + + // A rolled-back SetLink fires no subject event. + spy.reset() + _ = lagoon.Transaction(ctx, env.gdb, func(ctx context.Context, tx *gorm.DB) error { + if _, err := links.SetLink(ctx, tx, user.ID, target, setter); err != nil { + t.Fatal(err) + } + return context.Canceled + }) + if spy.has("user", user.ID, pact.MCPSubjectLink) || spy.has("backend", target, pact.MCPSubjectLink) { + t.Fatalf("a rolled-back SetLink fired %+v", spy.events) + } + + var link *models.BackendLink + if err := lagoon.Transaction(ctx, env.gdb, func(ctx context.Context, tx *gorm.DB) error { + var err error + link, err = links.SetLink(ctx, tx, user.ID, target, setter) + return err + }); err != nil { + t.Fatal(err) + } + if link.LinkedBy == nil || *link.LinkedBy != setter { + t.Fatalf("linked_by = %v, want %d", link.LinkedBy, setter) + } + if !spy.has("user", user.ID, pact.MCPSubjectLink) || !spy.has("backend", target, pact.MCPSubjectLink) { + t.Fatalf("SetLink fired %+v", spy.events) + } + spy.reset() + if err := lagoon.Transaction(ctx, env.gdb, func(ctx context.Context, tx *gorm.DB) error { + return links.RemoveLink(ctx, tx, link) + }); err != nil { + t.Fatal(err) + } + if !spy.has("user", user.ID, pact.MCPSubjectLink) || !spy.has("backend", target, pact.MCPSubjectLink) { + t.Fatalf("RemoveLink fired %+v", spy.events) + } + mu.Lock() + defer mu.Unlock() + // The rolled-back SetLink fired BackendLinkedEvent inside its + // transaction too (listeners run with the transaction). + if linked != 2 || unlinked != 1 { + t.Fatalf("BackendLinkedEvent %d, BackendUnlinkedEvent %d", linked, unlinked) + } + }) + + t.Run("deactivated or deleted administrator", func(t *testing.T) { + token := env.admin(t) + admin := adminID(t, token) + user := env.seedUser(t, "admin-gone", nil) + if status, body := env.confirmLink(t, freshUserToken(t, user), env.startLink(t, token)); status != http.StatusOK { + t.Fatalf("link = %d %s", status, body) + } + check := func(want bool, why string) { + t.Helper() + if _, found, err := resolver.LinkedBackendUser(ctx, user.ID); err != nil || found != want { + t.Fatalf("%s: found = %v, %v", why, found, err) + } + } + check(true, "active") + env.gdb.Exec(`UPDATE backend_users SET is_activated = FALSE WHERE id = ?`, admin) + check(false, "deactivated administrator") + env.gdb.Exec(`UPDATE backend_users SET is_activated = TRUE, deleted_at = NOW() WHERE id = ?`, admin) + check(false, "soft-deleted administrator") + env.gdb.Exec(`UPDATE backend_users SET deleted_at = NULL WHERE id = ?`, admin) + check(true, "restored administrator") + }) + + t.Run("deactivating the frontend user removes the link", func(t *testing.T) { + token := env.admin(t) + admin := adminID(t, token) + user := env.seedUser(t, "deactivated", nil) + if status, body := env.confirmLink(t, freshUserToken(t, user), env.startLink(t, token)); status != http.StatusOK { + t.Fatalf("link = %d %s", status, body) + } + spy.reset() + if err := lagoon.Transaction(ctx, env.gdb, func(ctx context.Context, tx *gorm.DB) error { + return controllers.FireDeactivatingEvent(ctx, env.app, user, tx) + }); err != nil { + t.Fatal(err) + } + if link, err := links.LinkOf(ctx, user.ID); err != nil || link != nil { + t.Fatalf("link after deactivation = %+v, %v", link, err) + } + if !spy.has("user", user.ID, pact.MCPSubjectDeleted) || !spy.has("backend", admin, pact.MCPSubjectLink) { + t.Fatalf("deactivation fired %+v", spy.events) + } + }) +} diff --git a/frontend_permission_catalog_test.go b/frontend_permission_catalog_test.go new file mode 100644 index 0000000..1c8bd9a --- /dev/null +++ b/frontend_permission_catalog_test.go @@ -0,0 +1,95 @@ +package user + +import ( + "context" + "net/http" + "strings" + "testing" + + "git.golem15.com/golem15/sm-user-plugin/classes" + "git.golem15.com/golem15/sm-user-plugin/models" + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/pact" + "git.golem15.com/golem15/summercms/modules/party" +) + +const acmeShopOrders = "acme.shop.orders" + +// acmeShopPermissions declares a code of its own and acme.blog's manage code +// again, under another label: activated after acme.blog, its duplicate is +// dropped. +type acmeShopPermissions struct{} + +func (acmeShopPermissions) ID() string { return "acme.shop" } +func (acmeShopPermissions) Requires() []string { return nil } +func (acmeShopPermissions) Register(*backpack.App) error { return nil } +func (acmeShopPermissions) Boot(*backpack.App) error { return nil } +func (acmeShopPermissions) FrontendPermissions() []pact.FrontendPermission { + return []pact.FrontendPermission{ + {Code: acmeBlogManage, Label: "Shop's copy", Tab: "Shop"}, + {Code: acmeShopOrders, Label: "Manage orders", Tab: "Shop"}, + {Code: " ", Label: "Empty code", Tab: "Shop"}, + } +} + +func init() { party.Register(acmeShopPermissions{}) } + +// TestFrontendPermissionCatalog (D-09) extends the smoke: with two declaring +// plugins the editors offer the seeded rows, then the first plugin's codes, +// then the second's, a code the first already declared and an empty code +// left out; a group saved with a declared code keeps it, and the member's +// grants (what user.permission checks) include it. Skips under -short. +func TestFrontendPermissionCatalog(t *testing.T) { + env := newAdminEnvWith(t, []string{"acme.blog", "acme.shop"}) + if err := env.gdb.Exec(`INSERT INTO golem15_user_frontend_permissions (code, label, tab) VALUES (?, 'Seeded label', 'Blog') ON CONFLICT (code) DO NOTHING`, acmeBlogSeeded).Error; err != nil { + t.Fatal(err) + } + var seeded int64 + if err := env.gdb.Raw(`SELECT COUNT(*) FROM golem15_user_frontend_permissions`).Scan(&seeded).Error; err != nil { + t.Fatal(err) + } + token := env.admin(t, permAccessUsers, permAccessGroups) + + t.Run("second plugin's duplicate dropped", func(t *testing.T) { + for _, path := range []string{adminUsersPath, adminGroupsPath} { + options := env.permissionOptions(t, token, path) + declared := options[seeded:] + var codes []string + for _, o := range declared { + codes = append(codes, o.Code) + } + if len(declared) != 2 || declared[len(declared)-2].Code != acmeBlogManage || declared[len(declared)-2].Label != "Manage blog posts" || + declared[len(declared)-1] != (offeredPermission{Code: acmeShopOrders, Label: "Manage orders", Tab: "Shop"}) { + t.Fatalf("%s declared options = %+v", path, declared) + } + seen := map[string]int{} + for _, o := range options { + seen[o.Code]++ + if o.Code == "" || o.Code == " " { + t.Fatalf("%s offers an empty code", path) + } + } + if seen[acmeBlogManage] != 1 || seen[acmeShopOrders] != 1 { + t.Fatalf("%s codes offered %v (declared %v)", path, seen, codes) + } + } + }) + + t.Run("group saved with a declared code grants it", func(t *testing.T) { + stamp := adminStamp() + group := env.seedGroup(t, "Orders "+stamp, "orders-"+stamp) + member := env.seedUser(t, "orders", nil) + env.join(t, member, group) + env.expect(t, http.StatusOK, http.MethodPut, groupPath(group.ID), token, map[string]any{ + "name": group.Name, "code": *group.Code, "permissions": map[string]int{acmeShopOrders: 1}, + }) + var saved models.UserGroup + if err := env.gdb.First(&saved, group.ID).Error; err != nil || saved.Permissions == nil || !strings.Contains(*saved.Permissions, `"`+acmeShopOrders+`"`) { + t.Fatalf("saved permissions = %v, %v", saved.Permissions, err) + } + grants, err := classes.UserPermissionGrants(context.Background(), env.gdb, member.ID) + if err != nil || !grants[acmeShopOrders] { + t.Fatalf("member grants = %v, %v", grants, err) + } + }) +} diff --git a/mcp_guard_test.go b/mcp_guard_test.go new file mode 100644 index 0000000..ca46fbe --- /dev/null +++ b/mcp_guard_test.go @@ -0,0 +1,117 @@ +package user + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "git.golem15.com/golem15/sm-user-plugin/models" + "git.golem15.com/golem15/summercms/modules/bouncer" + "git.golem15.com/golem15/summercms/modules/pact" +) + +// TestUserMCPGuard (D-15, D-16, D-35) extends TestUserMCPGuardSmoke: the +// requirement parser refuses a user.* middleware it does not know and a +// scope or permission middleware without its name; a soft-deleted user is +// neither allowed nor dispatched and its personal tokens stop resolving, as +// do expired ones; and the in-memory credential MCPDispatch builds passes +// the route's own user.scope:read middleware when the subject was granted +// read and is refused (403) when it was not, through the plugin's real +// route chain in-process. Skips under -short. +func TestUserMCPGuard(t *testing.T) { + env := newAdminEnv(t) + ctx := context.Background() + guards, _ := env.app.Lookup[*pact.MCPGuards]() + guard, ok := guards.Lookup("user") + if !ok { + t.Fatal("no user guard") + } + resolver, _ := env.app.Lookup[pact.MCPPersonalTokenResolver]() + + t.Run("requirement parser refusals", func(t *testing.T) { + for _, mw := range [][]string{{"user.api_token", "user.unknown"}, {"user.api_token", "user.scope:"}, {"user.api_token", "user.permission:"}} { + if _, err := guard.MCPRequirements(mw); err == nil { + t.Fatalf("%v accepted", mw) + } + } + if req, err := guard.MCPRequirements([]string{"throttle:user-api", "user.api_token", "user.scope:read", "user.scope:read"}); err != nil || len(req.Scopes) != 1 { + t.Fatalf("duplicate scope = %+v, %v", req, err) + } + }) + + t.Run("soft-deleted user", func(t *testing.T) { + user := env.seedUser(t, "mcp-gone", nil) + secret, _ := env.mintUserToken(t, user, "read") + subject := pact.MCPSubject{Guard: "user", UserID: user.ID, Scopes: []string{"read"}} + if ok, err := guard.MCPAllows(ctx, subject, pact.MCPRequirements{}, nil); err != nil || !ok { + t.Fatalf("before deletion = %v, %v", ok, err) + } + if err := env.gdb.Exec(`UPDATE users SET deleted_at = NOW() WHERE id = ?`, user.ID).Error; err != nil { + t.Fatal(err) + } + if ok, err := guard.MCPAllows(ctx, subject, pact.MCPRequirements{}, nil); err != nil || ok { + t.Fatalf("after deletion Allows = %v, %v", ok, err) + } + if _, err := guard.MCPDispatch(ctx, subject, http.MethodGet); err == nil { + t.Fatal("a deleted user was dispatched") + } + if _, matched, err := resolver.MCPResolvePersonalToken(ctx, secret); !matched || !errors.Is(err, pact.ErrMCPTokenInvalid) { + t.Fatalf("a deleted user's token = %v, %v", matched, err) + } + }) + + t.Run("expired token", func(t *testing.T) { + user := env.seedUser(t, "mcp-expired", nil) + secret, token := env.mintUserToken(t, user, "read") + if err := env.gdb.Model(&models.APIToken{}).Where("id = ?", token.ID).Update("expires_at", time.Now().Add(-time.Minute)).Error; err != nil { + t.Fatal(err) + } + if _, matched, err := resolver.MCPResolvePersonalToken(ctx, secret); !matched || !errors.Is(err, pact.ErrMCPTokenInvalid) { + t.Fatalf("an expired token = %v, %v", matched, err) + } + }) + + t.Run("dispatch credential against user.scope", func(t *testing.T) { + user := env.seedUser(t, "mcp-scope", nil) + me := func(scopes ...string) *httptest.ResponseRecorder { + t.Helper() + d, err := guard.MCPDispatch(ctx, pact.MCPSubject{Guard: "user", UserID: user.ID, Scopes: scopes}, http.MethodGet) + if err != nil { + t.Fatal(err) + } + principal, _ := d.Principal.(*bouncer.Principal) + req := httptest.NewRequest(http.MethodGet, "/_user/api/v1/token/me", nil) + req = req.WithContext(bouncer.WithDispatch(req.Context(), &bouncer.Dispatch{Guards: d.Guards, Principal: principal, Credential: d.Credential})) + rec := httptest.NewRecorder() + env.h.ServeHTTP(rec, req) + return rec + } + if rec := me("read"); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"email":"`+user.Email+`"`) { + t.Fatalf("read subject = %d %s", rec.Code, rec.Body.String()) + } + if rec := me("write"); rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), "Missing required scope: read") { + t.Fatalf("write-only subject = %d %s", rec.Code, rec.Body.String()) + } + if _, isToken := mustDispatchCredential(t, guard, user.ID).(*models.APIToken); !isToken { + t.Fatal("the dispatch credential is not an in-memory API token") + } + }) +} + +// mustDispatchCredential returns the credential MCPDispatch builds for a +// read subject; it is never saved (ID 0). +func mustDispatchCredential(t *testing.T, guard pact.MCPGuard, userID uint) any { + t.Helper() + d, err := guard.MCPDispatch(context.Background(), pact.MCPSubject{Guard: "user", UserID: userID, Scopes: []string{"read"}}, http.MethodGet) + if err != nil { + t.Fatal(err) + } + if tok, ok := d.Credential.(*models.APIToken); !ok || tok.ID != 0 { + t.Fatalf("credential = %+v", d.Credential) + } + return d.Credential +}