From e3c164fdf0edd61ec87a6907e74e8e0bdf6c8d54 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Fri, 23 Oct 2026 18:36:39 +0200 Subject: [PATCH] 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 --- README.md | 2 + access_revocation_test.go | 226 ++++++++++++++++++++++++++ api_token_events_test.go | 37 +++++ backend_link_auth_time_test.go | 114 ++++++++++++++ backend_link_show_test.go | 110 +++++++++++++ mcp_guard_client_ip_test.go | 61 +++++++ member_token_scopes_test.go | 280 +++++++++++++++++++++++++++++++++ organisation_logo_test.go | 150 ++++++++++++++++++ 8 files changed, 980 insertions(+) create mode 100644 access_revocation_test.go create mode 100644 api_token_events_test.go create mode 100644 backend_link_auth_time_test.go create mode 100644 backend_link_show_test.go create mode 100644 mcp_guard_client_ip_test.go create mode 100644 member_token_scopes_test.go create mode 100644 organisation_logo_test.go diff --git a/README.md b/README.md index cce06ff..40626cc 100644 --- a/README.md +++ b/README.md @@ -325,4 +325,6 @@ go test ./... # full run, starts Postgres with testcontainers (needs D `TestBackendLinkSmoke` runs the account link end to end: My account offers `link`, the action answers the site URL with a 64-hex code, the show route answers the administrator's login and expiry, a session older than the request gets `stale_session`, a fresh one links (the published resolver answers the administrator), the code is one-shot, a second frontend user gets `already_linked`, an expired code is 404, unlink and deleting the frontend user remove the link, and the subject events fire. `TestBackendLinkAuthTimeSmoke` checks the `auth_time` proof: a session signed in before the request and refreshed after it gets `stale_session`, a sign-in after the request links, `POST /backend-link/show` answers for the code in the body, and My account's URL carries the code in the fragment. `TestBackendLinkAdminSmoke` covers the Administrators screen actions: offered only as they apply and only with `golem15.users.manage_backend_links`, the case-insensitive email match, `linked_by`, `BackendLinkedEvent`, the 422 refusals and the unlink. `TestUserMCPGuardSmoke` covers the `user` guard provider (requirements, the scope and group-permission checks, the dispatch credential, personal token resolution), `GET /token/me` and the revoke, group and user subject events. `TestFrontendPermissionCatalogSmoke` boots a test plugin that declares frontend permission codes and checks both editors' options. `TestMemberTokenScopesSmoke` checks the member token scope catalog (duplicate, built-in and bad names fail) and, with Docker, minting, the scope check, the list, `GET /tokens/scopes` and a second application refusing the scope. The others need Docker. +`TestMemberTokenScopesBoot` (no Docker) checks that the scope Boot step fails for a name two plugins declare (naming both), for a redeclared `read`, `write` or `ai`, for a bad name, an empty label and a missing phrase key. With Docker: `TestMemberTokenScopes` walks the member token scope acceptance list item by item (mint and `middleware.Scope`, the 403 message, the 422 for an undeclared scope, the list, discovery in en and pl, the per-application catalog and the legacy fallback); `TestBackendLinkAuthTime` checks the link proof against login, request, refresh and re-login orders, a token without `auth_time` and another user's session token; `TestBackendLinkShowPost` checks that `POST /backend-link/show` answers like the GET route with `Cache-Control: no-store, private`, the fragment URL and `backend_links.page_path`; `TestAccessRevocation` checks `AccessRevokedEvent` from the ban action, the password reset, the password change and an administrator setting a password (ids, `Before`, reason, the transaction, the subject event after commit, the rollback on a failing listener) and that a banned user loses the backend link until unbanned; `TestUserMCPGuardClientIP` checks `last_used_ip` on the MCP personal-token path; `TestAPITokenRevokeEvent` checks the token-specific revoked event of `DELETE /tokens/{id}`; `TestOrganisationLogo` checks the logo field, the upload, `OrganisationLogoFields` with and without a logo, and that deleting an organisation removes its avatar and logo attachments. + The admin tests (`TestAdmin*`) boot the plugin with the admin API mounted on a database of their own and sign in backend administrators with a chosen permission set; the helpers are in `admin_harness_test.go`. diff --git a/access_revocation_test.go b/access_revocation_test.go new file mode 100644 index 0000000..b87b635 --- /dev/null +++ b/access_revocation_test.go @@ -0,0 +1,226 @@ +package user + +import ( + "context" + "errors" + "net/http" + "strconv" + "sync" + "sync/atomic" + "testing" + "time" + + "git.golem15.com/golem15/sm-user-plugin/classes" + "git.golem15.com/golem15/sm-user-plugin/models" + "git.golem15.com/golem15/summercms/modules/pact" +) + +// revokedSeen is one AccessRevokedEvent as a listener saw it, with what was +// already visible at that moment. +type revokedSeen struct { + ev classes.AccessRevokedEvent + // stored is tokens_valid_after of the first user read through ev.Tx. + stored *time.Time + // subjectEarly reports whether a revoked subject event for the first + // user had already fired (it must wait for the commit). + subjectEarly bool +} + +// revocationProbe records every AccessRevokedEvent of an app and can make +// the listener fail. +type revocationProbe struct { + mu sync.Mutex + seen []revokedSeen + fail atomic.Bool +} + +func (p *revocationProbe) take() []revokedSeen { + p.mu.Lock() + defer p.mu.Unlock() + out := p.seen + p.seen = nil + return out +} + +func listenRevocations(env *adminEnv, spy *subjectSpy) *revocationProbe { + probe := &revocationProbe{} + env.app.Events.Listen("test.access_revoked", func(ctx context.Context, ev *classes.AccessRevokedEvent) error { + if probe.fail.Load() { + return errors.New("listener refused") + } + seen := revokedSeen{ev: *ev} + if ev.Tx != nil && len(ev.UserIDs) > 0 { + var row struct{ TokensValidAfter *time.Time } + if err := ev.Tx.WithContext(ctx).Raw(`SELECT tokens_valid_after FROM users WHERE id = ?`, ev.UserIDs[0]).Scan(&row).Error; err == nil { + seen.stored = row.TokensValidAfter + } + seen.subjectEarly = spy.has(classes.SubjectGuardUser, ev.UserIDs[0], pact.MCPSubjectRevoked) + } + probe.mu.Lock() + probe.seen = append(probe.seen, seen) + probe.mu.Unlock() + return nil + }) + return probe +} + +// one asserts exactly one event naming id for reason, fired in a +// transaction before the user's revoked subject event. +func one(t *testing.T, probe *revocationProbe, spy *subjectSpy, id uint, reason string) revokedSeen { + t.Helper() + seen := probe.take() + if len(seen) != 1 { + t.Fatalf("%s fired %d access-revoked events, want 1: %+v", reason, len(seen), seen) + } + got := seen[0] + if got.ev.Reason != reason || len(got.ev.UserIDs) != 1 || got.ev.UserIDs[0] != id || got.ev.Tx == nil { + t.Fatalf("%s event = %+v, want user %d in a transaction", reason, got.ev, id) + } + if got.subjectEarly { + t.Fatalf("%s: the revoked subject event fired before the commit", reason) + } + if !spy.has(classes.SubjectGuardUser, id, pact.MCPSubjectRevoked) { + t.Fatalf("%s: no revoked subject event for user %d after the commit", reason, id) + } + return got +} + +// wholeSecondsUntruncated checks the stored cutoff has whole seconds and +// Before is the untruncated instant of the same second. +func wholeSecondsUntruncated(t *testing.T, env *adminEnv, id uint, before time.Time) { + t.Helper() + stored := env.reload(t, id).TokensValidAfter + if stored == nil || stored.Nanosecond() != 0 { + t.Fatalf("stored tokens_valid_after = %v, want whole seconds", stored) + } + if before.Before(*stored) || !before.Truncate(time.Second).Equal(stored.Truncate(time.Second)) { + t.Fatalf("Before %s is not the untruncated instant of the stored cutoff %s", before, stored) + } + if before.Nanosecond() == 0 { + t.Logf("the clock answered a whole second (%s); the sub-second part is not observable this run", before) + } +} + +// TestAccessRevocation pins plan 08's revocation chain on the user side +// (C-CR-01, P-35): the ban bulk action, ResetPassword, ChangePassword and an +// administrator setting a password each fire one AccessRevokedEvent in the +// write's transaction with the right ids, Before and Reason, then one +// revoked subject event per user after the commit; the ban fires only for +// newly banned users; a failing listener rolls a password reset back; and +// a banned linked user loses the backend link until unbanned. Postgres +// only: under -short nothing runs. +func TestAccessRevocation(t *testing.T) { + if testing.Short() { + return + } + env := newAdminEnv(t) + spy := env.spySubjects() + probe := listenRevocations(env, spy) + admin := env.superAdmin(t) + ctx := context.Background() + + t.Run("the ban bulk action fires for newly banned users only", func(t *testing.T) { + already := env.seedUser(t, "rv-already", nil) + env.seedThrottle(t, already, "203.0.113.20", true, nil) + fresh := env.seedUser(t, "rv-ban", nil) + probe.take() + spy.reset() + started := time.Now() + env.expect(t, http.StatusOK, http.MethodPost, adminAPI(adminUsersPath+"/bulk/ban"), admin, map[string]any{"ids": []uint{already.ID, fresh.ID}}) + got := one(t, probe, spy, fresh.ID, classes.AccessRevokedBan) + if got.ev.Before.Before(started) || got.ev.Before.After(time.Now()) { + t.Fatalf("ban Before %s outside the request", got.ev.Before) + } + if spy.has(classes.SubjectGuardUser, already.ID, pact.MCPSubjectRevoked) { + t.Fatal("the already banned user got a revoked event") + } + }) + + t.Run("ResetPassword fires with the untruncated instant", func(t *testing.T) { + user := env.seedUser(t, "rv-reset", nil) + code, err := classes.IssueResetCode(ctx, env.gdb, user) + if err != nil { + t.Fatal(err) + } + probe.take() + spy.reset() + packed := strconv.FormatUint(uint64(user.ID), 10) + "!" + code + env.expect(t, http.StatusOK, http.MethodPost, "/_user/api/v1/reset-password", "", map[string]string{"code": packed, "password": "rv-new-password", "password_confirmation": "rv-new-password"}) + got := one(t, probe, spy, user.ID, classes.AccessRevokedPasswordReset) + if got.stored == nil { + t.Fatal("the listener did not see the new cutoff inside the reset's transaction") + } + wholeSecondsUntruncated(t, env, user.ID, got.ev.Before) + }) + + t.Run("ChangePassword fires with the stored cutoff plus one second", func(t *testing.T) { + user := env.seedUser(t, "rv-change", func(u *models.User) { u.HasSelfSetPassword = true }) + status, session := env.userLogin(t, user.Email, "correct-horse") + if status != http.StatusOK { + t.Fatalf("login = %d", status) + } + probe.take() + spy.reset() + env.expect(t, http.StatusOK, http.MethodPost, "/_user/api/v1/change-password", session, map[string]string{"current_password": "correct-horse", "password": "rv-changed-password", "password_confirmation": "rv-changed-password"}) + got := one(t, probe, spy, user.ID, classes.AccessRevokedPasswordChanged) + stored := env.reload(t, user.ID).TokensValidAfter + if stored == nil || !got.ev.Before.Equal(stored.Add(time.Second)) { + t.Fatalf("Before %s, stored cutoff %v: want the cutoff plus one second", got.ev.Before, stored) + } + }) + + t.Run("an administrator setting a password fires with the untruncated instant", func(t *testing.T) { + user := env.seedUser(t, "rv-admin-set", nil) + probe.take() + spy.reset() + const set = "rv-admin-password" + env.expect(t, http.StatusOK, http.MethodPut, userPath(user.ID, ""), admin, map[string]any{"name": user.Name, "email": user.Email, "password": set, "password_confirmation": set}) + got := one(t, probe, spy, user.ID, classes.AccessRevokedPasswordSetByAdmin) + wholeSecondsUntruncated(t, env, user.ID, got.ev.Before) + }) + + t.Run("a failing listener rolls the password reset back", func(t *testing.T) { + user := env.seedUser(t, "rv-rollback", nil) + code, err := classes.IssueResetCode(ctx, env.gdb, user) + if err != nil { + t.Fatal(err) + } + spy.reset() + probe.fail.Store(true) + packed := strconv.FormatUint(uint64(user.ID), 10) + "!" + code + rec := env.call(t, http.MethodPost, "/_user/api/v1/reset-password", "", map[string]string{"code": packed, "password": "rv-never-set", "password_confirmation": "rv-never-set"}) + probe.fail.Store(false) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("reset with a failing listener = %d %s", rec.Code, rec.Body.String()) + } + if status, _ := env.userLogin(t, user.Email, "correct-horse"); status != http.StatusOK { + t.Fatalf("the old password stopped working (%d): the reset was not rolled back", status) + } + if status, _ := env.userLogin(t, user.Email, "rv-never-set"); status == http.StatusOK { + t.Fatal("the new password works: the reset was not rolled back") + } + if spy.has(classes.SubjectGuardUser, user.ID, pact.MCPSubjectRevoked) { + t.Fatal("a rolled-back reset fired a revoked subject event") + } + }) + + t.Run("a banned linked user loses the backend link until unbanned", func(t *testing.T) { + user := env.seedUser(t, "rv-linked", nil) + code := env.startLink(t, env.admin(t)) + if status, body := env.confirmLink(t, freshUserToken(t, user), code); status != http.StatusOK { + t.Fatalf("link = %d %s", status, body) + } + links, _ := env.app.Lookup[*classes.BackendLinks]() + if _, ok, err := links.LinkedBackendUser(ctx, user.ID); err != nil || !ok { + t.Fatalf("linked before the ban = %v, %v", ok, err) + } + env.expect(t, http.StatusOK, http.MethodPost, adminAPI(adminUsersPath+"/bulk/ban"), admin, map[string]any{"ids": []uint{user.ID}}) + if _, ok, err := links.LinkedBackendUser(ctx, user.ID); err != nil || ok { + t.Fatalf("linked while banned = %v, %v", ok, err) + } + env.expect(t, http.StatusOK, http.MethodPost, adminAPI(adminUsersPath+"/bulk/unban"), admin, map[string]any{"ids": []uint{user.ID}}) + if _, ok, err := links.LinkedBackendUser(ctx, user.ID); err != nil || !ok { + t.Fatalf("linked after the unban = %v, %v", ok, err) + } + }) +} diff --git a/api_token_events_test.go b/api_token_events_test.go new file mode 100644 index 0000000..8440764 --- /dev/null +++ b/api_token_events_test.go @@ -0,0 +1,37 @@ +package user + +import ( + "net/http" + "strconv" + "testing" + + "git.golem15.com/golem15/sm-user-plugin/classes" + "git.golem15.com/golem15/summercms/modules/pact" +) + +// TestAPITokenRevokeEvent pins plan 08's token-specific MCP revocation +// (C-IN-02, user side): DELETE /tokens/{id} fires exactly one revoked +// subject event for the user carrying that TokenID, so only the sessions of +// that token end. Postgres only: under -short nothing runs. +func TestAPITokenRevokeEvent(t *testing.T) { + if testing.Short() { + return + } + env := newAdminEnv(t) + spy := env.spySubjects() + user := env.seedUser(t, "token-revoke", nil) + _, kept := env.mintUserToken(t, user, "read") + _, gone := env.mintUserToken(t, user, "read") + spy.reset() + env.expect(t, http.StatusOK, http.MethodDelete, "/_user/api/v1/tokens/"+strconv.FormatUint(uint64(gone.ID), 10), freshUserToken(t, user), nil) + spy.mu.Lock() + events := append([]pact.MCPSubjectChanged(nil), spy.events...) + spy.mu.Unlock() + want := pact.MCPSubjectChanged{Guard: classes.SubjectGuardUser, UserID: user.ID, Kind: pact.MCPSubjectRevoked, TokenID: gone.ID} + if len(events) != 1 || events[0] != want { + t.Fatalf("events = %+v, want exactly %+v", events, want) + } + if spy.hasEvent(pact.MCPSubjectChanged{Guard: classes.SubjectGuardUser, UserID: user.ID, Kind: pact.MCPSubjectRevoked, TokenID: kept.ID}) { + t.Fatal("the kept token was revoked too") + } +} diff --git a/backend_link_auth_time_test.go b/backend_link_auth_time_test.go new file mode 100644 index 0000000..ab337a0 --- /dev/null +++ b/backend_link_auth_time_test.go @@ -0,0 +1,114 @@ +package user + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" + + "git.golem15.com/golem15/sm-user-plugin/controllers" + "git.golem15.com/golem15/sm-user-plugin/models" + "git.golem15.com/golem15/summercms/modules/bouncer" +) + +// TestBackendLinkAuthTime pins the account link proof of plan 08 (C-CR-02, +// P-34): confirming a link needs a sign-in at or after the request, read +// from the session's auth_time, which refresh copies and never renews. A +// session signed in before the request stays stale through a refresh, a +// sign-in after the request confirms (also after a refresh), a token +// without auth_time is stale, and a session token of another user than the +// authenticated one is 401. Postgres only: under -short nothing runs. +func TestBackendLinkAuthTime(t *testing.T) { + if testing.Short() { + return + } + env := newAdminEnv(t) + login := func(t *testing.T, user *models.User) string { + t.Helper() + status, token := env.userLogin(t, user.Email, "correct-horse") + if status != http.StatusOK || token == "" { + t.Fatalf("login = %d", status) + } + return token + } + refresh := func(t *testing.T, token string) string { + t.Helper() + rec := env.refresh(t, token) + var body struct { + Token string `json:"token"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); rec.Code != http.StatusOK || err != nil || body.Token == "" { + t.Fatalf("refresh = %d %s", rec.Code, rec.Body.String()) + } + return body.Token + } + // signInEarlier waits past the second of the sign-in, so a request + // created next is strictly later (auth_time has whole seconds). + signInEarlier := func() { time.Sleep(1100 * time.Millisecond) } + expect := func(t *testing.T, token, code string, status int, contains string) { + t.Helper() + got, body := env.confirmLink(t, token, code) + if got != status || !strings.Contains(body, contains) { + t.Fatalf("confirm = %d %s, want %d with %q", got, body, status, contains) + } + } + + t.Run("login, request, refresh, confirm is stale", func(t *testing.T) { + user := env.seedUser(t, "at-refresh", nil) + session := login(t, user) + signInEarlier() + code := env.startLink(t, env.admin(t)) + expect(t, refresh(t, session), code, http.StatusConflict, `"stale_session"`) + }) + + t.Run("request, login, confirm links", func(t *testing.T) { + user := env.seedUser(t, "at-after", nil) + code := env.startLink(t, env.admin(t)) + expect(t, login(t, user), code, http.StatusOK, `"backend_user"`) + }) + + t.Run("login, request, login again, confirm links", func(t *testing.T) { + user := env.seedUser(t, "at-again", nil) + first := login(t, user) + signInEarlier() + code := env.startLink(t, env.admin(t)) + expect(t, first, code, http.StatusConflict, `"stale_session"`) + expect(t, login(t, user), code, http.StatusOK, `"backend_user"`) + }) + + t.Run("a hand-made token without auth_time is stale", func(t *testing.T) { + user := env.seedUser(t, "at-legacy", nil) + code := env.startLink(t, env.admin(t)) + legacy := craftToken(t, strconv.FormatUint(uint64(user.ID), 10), "at-legacy-"+adminStamp(), time.Now(), time.Now().Add(time.Hour)) + expect(t, legacy, code, http.StatusConflict, `"stale_session"`) + }) + + t.Run("a refreshed session of a post-request sign-in keeps working", func(t *testing.T) { + user := env.seedUser(t, "at-carried", nil) + code := env.startLink(t, env.admin(t)) + session := login(t, user) + expect(t, refresh(t, refresh(t, session)), code, http.StatusOK, `"backend_user"`) + }) + + t.Run("another user's session token is 401", func(t *testing.T) { + user := env.seedUser(t, "at-owner", nil) + other := env.seedUser(t, "at-other", nil) + code := env.startLink(t, env.admin(t)) + // jwt.auth authenticated user, but the session token the handler + // reads again for its sign-in time belongs to other. + req := httptest.NewRequest(http.MethodPost, "/_user/api/v1/backend-link/confirm", strings.NewReader(`{"code":"`+code+`"}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+login(t, other)) + req = req.WithContext(bouncer.WithUser(req.Context(), &bouncer.Principal{ID: user.ID})) + rec := httptest.NewRecorder() + controllers.BackendLinkConfirm(env.app)(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("mismatched session token = %d %s", rec.Code, rec.Body.String()) + } + // The code is still usable by its rightful user. + expect(t, login(t, user), code, http.StatusOK, `"backend_user"`) + }) +} diff --git a/backend_link_show_test.go b/backend_link_show_test.go new file mode 100644 index 0000000..b9dd107 --- /dev/null +++ b/backend_link_show_test.go @@ -0,0 +1,110 @@ +package user + +import ( + "bytes" + "net/http" + "regexp" + "strings" + "testing" + "time" + + "git.golem15.com/golem15/sm-user-plugin/models" + "git.golem15.com/golem15/summercms/modules/compass" +) + +// TestBackendLinkShowPost pins plan 08's link code transport (C-WR-01, +// C-IN-06): POST /backend-link/show answers exactly like the GET route (the +// data for a live code, 404 for an unknown, a used or an expired one), both +// with Cache-Control no-store, private; My account answers the link as +// /link-admin#code=<64 hex> with no query string; +// golem15.user.backend_links.page_path changes the path, and a value +// without a leading slash gets one. Postgres only: under -short nothing +// runs. +func TestBackendLinkShowPost(t *testing.T) { + if testing.Short() { + return + } + env := newAdminEnv(t) + show := func(t *testing.T, code string) (get, post *struct { + status int + body string + cache string + }) { + t.Helper() + g := env.call(t, http.MethodGet, "/_user/api/v1/backend-link/"+code, "", nil) + p := env.call(t, http.MethodPost, "/_user/api/v1/backend-link/show", "", map[string]string{"code": code}) + return &struct { + status int + body string + cache string + }{g.Code, g.Body.String(), g.Header().Get("Cache-Control")}, &struct { + status int + body string + cache string + }{p.Code, p.Body.String(), p.Header().Get("Cache-Control")} + } + same := func(t *testing.T, code string, status int) { + t.Helper() + get, post := show(t, code) + if get.status != status || post.status != status { + t.Fatalf("show %s: GET %d, POST %d, want %d", code[:8], get.status, post.status, status) + } + if !bytes.Equal([]byte(get.body), []byte(post.body)) { + t.Fatalf("POST show differs from GET:\nGET %s\nPOST %s", get.body, post.body) + } + if get.cache != "no-store, private" || post.cache != "no-store, private" { + t.Fatalf("Cache-Control: GET %q, POST %q", get.cache, post.cache) + } + } + + t.Run("POST show answers like GET", func(t *testing.T) { + token := env.admin(t) + live := env.startLink(t, token) + same(t, live, http.StatusOK) + if _, post := show(t, live); !strings.Contains(post.body, `"admin"`) || !strings.Contains(post.body, `"expires_at"`) { + t.Fatalf("POST show data = %s", post.body) + } + same(t, strings.Repeat("a", 64), http.StatusNotFound) + + used := env.startLink(t, env.admin(t)) + if status, body := env.confirmLink(t, freshUserToken(t, env.seedUser(t, "show-used", nil)), used); status != http.StatusOK { + t.Fatalf("confirm = %d %s", status, body) + } + same(t, used, http.StatusNotFound) + + expiring := env.admin(t) + expired := env.startLink(t, expiring) + if err := env.gdb.Model(&models.BackendLinkRequest{}).Where("backend_user_id = ?", adminID(t, expiring)). + Update("expires_at", time.Now().Add(-time.Minute)).Error; err != nil { + t.Fatal(err) + } + same(t, expired, http.StatusNotFound) + }) + + t.Run("the My account URL carries the code in the fragment only", func(t *testing.T) { + var out struct { + URL string `json:"url"` + } + adminInto(t, env.expect(t, http.StatusOK, http.MethodPost, adminAPI("/account/contributions/frontend_account/actions/link"), env.admin(t), map[string]any{}), &out) + if !regexp.MustCompile(`^https://app\.example\.test/link-admin#code=[a-f0-9]{64}$`).MatchString(out.URL) || strings.Contains(out.URL, "?") { + t.Fatalf("link URL = %q", out.URL) + } + }) + + for _, c := range []struct{ value, want string }{{"/pl/polacz", "/pl/polacz"}, {"pl/polacz", "/pl/polacz"}} { + t.Run("page_path "+c.value, func(t *testing.T) { + custom := newAdminEnv(t, func(cfg *compass.Config) { + if err := cfg.Set("golem15.user.backend_links.page_path", c.value); err != nil { + t.Fatal(err) + } + }) + var out struct { + URL string `json:"url"` + } + adminInto(t, custom.expect(t, http.StatusOK, http.MethodPost, adminAPI("/account/contributions/frontend_account/actions/link"), custom.admin(t), map[string]any{}), &out) + if !regexp.MustCompile(`^https://app\.example\.test` + regexp.QuoteMeta(c.want) + `#code=[a-f0-9]{64}$`).MatchString(out.URL) { + t.Fatalf("link URL with page_path %q = %q", c.value, out.URL) + } + }) + } +} diff --git a/mcp_guard_client_ip_test.go b/mcp_guard_client_ip_test.go new file mode 100644 index 0000000..910df72 --- /dev/null +++ b/mcp_guard_client_ip_test.go @@ -0,0 +1,61 @@ +package user + +import ( + "context" + "testing" + + "git.golem15.com/golem15/sm-user-plugin/models" + "git.golem15.com/golem15/summercms/modules/pact" + "git.golem15.com/golem15/summercms/modules/towel" +) + +// TestUserMCPGuardClientIP pins plan 08's client address on the MCP +// personal-token path (C-IN-04): MCPResolvePersonalToken stamps +// last_used_ip from towel.ClientIP, as the api_token guard does, and leaves +// the column as it was when the context carries no address. Postgres only: +// under -short nothing runs. +func TestUserMCPGuardClientIP(t *testing.T) { + if testing.Short() { + return + } + env := newAdminEnv(t) + resolver, ok := env.app.Lookup[pact.MCPPersonalTokenResolver]() + if !ok { + t.Fatal("no personal token resolver published") + } + user := env.seedUser(t, "mcp-ip", nil) + secret, token := env.mintUserToken(t, user, "read") + lastIP := func(t *testing.T) *string { + t.Helper() + var row models.APIToken + if err := env.gdb.Where("id = ?", token.ID).First(&row).Error; err != nil { + t.Fatal(err) + } + return row.LastUsedIP + } + + t.Run("an address on the context is stamped", func(t *testing.T) { + subject, matched, err := resolver.MCPResolvePersonalToken(towel.WithClientIP(context.Background(), "203.0.113.44"), secret) + if err != nil || !matched || subject.TokenID != token.ID { + t.Fatalf("resolve = %+v, %v, %v", subject, matched, err) + } + if ip := lastIP(t); ip == nil || *ip != "203.0.113.44" { + t.Fatalf("last_used_ip = %v, want 203.0.113.44", ip) + } + }) + + t.Run("no address leaves the column as it was", func(t *testing.T) { + if _, matched, err := resolver.MCPResolvePersonalToken(context.Background(), secret); err != nil || !matched { + t.Fatalf("resolve = %v, %v", matched, err) + } + if ip := lastIP(t); ip == nil || *ip != "203.0.113.44" { + t.Fatalf("last_used_ip after a resolve without an address = %v, want it unchanged", ip) + } + if _, _, err := resolver.MCPResolvePersonalToken(towel.WithClientIP(context.Background(), " "), secret); err != nil { + t.Fatal(err) + } + if ip := lastIP(t); ip == nil || *ip != "203.0.113.44" { + t.Fatalf("last_used_ip after a blank address = %v, want it unchanged", ip) + } + }) +} diff --git a/member_token_scopes_test.go b/member_token_scopes_test.go new file mode 100644 index 0000000..d8944f5 --- /dev/null +++ b/member_token_scopes_test.go @@ -0,0 +1,280 @@ +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")}} +} diff --git a/organisation_logo_test.go b/organisation_logo_test.go new file mode 100644 index 0000000..0b3180f --- /dev/null +++ b/organisation_logo_test.go @@ -0,0 +1,150 @@ +package user + +import ( + "bytes" + "context" + "encoding/json" + "mime/multipart" + "net/http" + "strconv" + "strings" + "testing" + + "git.golem15.com/golem15/sm-user-plugin/classes" + "git.golem15.com/golem15/sm-user-plugin/models" + "git.golem15.com/golem15/summercms/modules/cabana" +) + +// uploadOrganisationFile posts an image to an organisation form's file +// field under a form session key. +func (e *adminEnv) uploadOrganisationFile(t *testing.T, token string, id uint, field, name, sessionKey string) { + t.Helper() + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + part, err := mw.CreateFormFile("file_data", name) + if err != nil { + t.Fatal(err) + } + if _, err := part.Write(jpegBytes(t)); err != nil { + t.Fatal(err) + } + if err := mw.Close(); err != nil { + t.Fatal(err) + } + if up := e.send(t, http.MethodPost, organisationPath(id, "/files/"+field), token, buf.Bytes(), mw.FormDataContentType(), map[string]string{cabana.SessionKeyHeader: sessionKey}); up.Code != http.StatusCreated && up.Code != http.StatusOK { + t.Fatalf("%s upload = %d %s", field, up.Code, up.Body.String()) + } +} + +// organisationFiles counts an organisation's system_files rows of field. +func (e *adminEnv) organisationFiles(t *testing.T, id uint, field string) int64 { + t.Helper() + var n int64 + if err := e.gdb.Raw(`SELECT COUNT(*) FROM system_files WHERE attachment_type = ? AND attachment_id = ? AND field = ?`, + (models.Organisation{}).MorphName(), strconv.FormatUint(uint64(id), 10), field).Scan(&n).Error; err != nil { + t.Fatal(err) + } + return n +} + +// TestOrganisationLogo pins E-1 (P-38): the Organisations form has a logo +// fileupload right after the avatar; a logo uploaded through the admin form +// API is stored as a system_files row with field logo; OrganisationLogoFields +// answers has_logo, the disk name and a URL, or false and nils without a +// logo; the avatar keeps working beside it; and deleting the organisation +// removes the logo attachment as it does the avatar. Postgres only: under +// -short nothing runs. +func TestOrganisationLogo(t *testing.T) { + if testing.Short() { + return + } + env := newAdminEnv(t) + token := env.admin(t, permAccessUsers) + ctx := context.Background() + + t.Run("the form has a logo fileupload next to the avatar", func(t *testing.T) { + rec := env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminOrganisationsPath+"/schema/form"), token, nil) + var schema struct { + Data struct { + Fields []struct { + Name string `json:"name"` + Type string `json:"type"` + } `json:"fields"` + } `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &schema); err != nil { + t.Fatal(err) + } + at := -1 + for i, f := range schema.Data.Fields { + if f.Name == "avatar" { + at = i + } + } + if at < 0 || at+1 >= len(schema.Data.Fields) || schema.Data.Fields[at+1].Name != models.OrganisationLogoField || schema.Data.Fields[at+1].Type != "fileupload" { + t.Fatalf("form fields = %+v, want logo:fileupload right after avatar", schema.Data.Fields) + } + }) + + t.Run("without a logo the fields answer false and nils", func(t *testing.T) { + org := env.seedOrganisation(t, "No Logo "+adminStamp()) + fields, err := classes.OrganisationLogoFields(ctx, env.app, org) + if err != nil { + t.Fatal(err) + } + if fields["has_logo"] != false || fields["logo"] != nil || fields["logo_url"] != nil { + t.Fatalf("fields = %+v", fields) + } + if _, ok, err := classes.OrganisationLogoURL(ctx, env.app, org, 64); err != nil || ok { + t.Fatalf("OrganisationLogoURL = %v, %v", ok, err) + } + }) + + t.Run("upload, payload fields, the avatar beside it and the delete", func(t *testing.T) { + name := "Logo Org " + adminStamp() + org := env.seedOrganisation(t, name) + key := adminSessionKey(t) + env.uploadOrganisationFile(t, token, org.ID, "avatar", "avatar.jpg", key) + env.uploadOrganisationFile(t, token, org.ID, models.OrganisationLogoField, "logo.jpg", key) + body, err := json.Marshal(map[string]any{"name": name, "slug": org.Slug}) + if err != nil { + t.Fatal(err) + } + if saved := env.send(t, http.MethodPut, organisationPath(org.ID, ""), token, body, "application/json", map[string]string{cabana.SessionKeyHeader: key}); saved.Code != http.StatusOK { + t.Fatalf("save = %d %s", saved.Code, saved.Body.String()) + } + if logos, avatars := env.organisationFiles(t, org.ID, "logo"), env.organisationFiles(t, org.ID, "avatar"); logos != 1 || avatars != 1 { + t.Fatalf("system_files rows: logo %d, avatar %d, want 1 and 1", logos, avatars) + } + + fields, err := classes.OrganisationLogoFields(ctx, env.app, env.organisation(t, org.ID)) + if err != nil { + t.Fatal(err) + } + disk, _ := fields["logo"].(string) + url, _ := fields["logo_url"].(string) + if fields["has_logo"] != true || disk == "" || url == "" { + t.Fatalf("fields = %+v", fields) + } + var logoDisk, avatarDisk string + if err := env.gdb.Raw(`SELECT disk_name FROM system_files WHERE attachment_type = ? AND attachment_id = ? AND field = 'logo'`, + (models.Organisation{}).MorphName(), strconv.FormatUint(uint64(org.ID), 10)).Scan(&logoDisk).Error; err != nil { + t.Fatal(err) + } + if err := env.gdb.Raw(`SELECT disk_name FROM system_files WHERE attachment_type = ? AND attachment_id = ? AND field = 'avatar'`, + (models.Organisation{}).MorphName(), strconv.FormatUint(uint64(org.ID), 10)).Scan(&avatarDisk).Error; err != nil { + t.Fatal(err) + } + if disk != logoDisk || disk == avatarDisk { + t.Fatalf("logo disk name %q, stored logo %q, avatar %q", disk, logoDisk, avatarDisk) + } + if strings.Contains(url, avatarDisk) { + t.Fatalf("logo URL %q points at the avatar", url) + } + + env.expect(t, http.StatusOK, http.MethodDelete, organisationPath(org.ID, ""), token, nil) + if logos, avatars := env.organisationFiles(t, org.ID, "logo"), env.organisationFiles(t, org.ID, "avatar"); logos != 0 || avatars != 0 { + t.Fatalf("after the delete: logo rows %d, avatar rows %d, want none", logos, avatars) + } + }) +}