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
This commit is contained in:
@@ -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`.
|
||||
|
||||
226
access_revocation_test.go
Normal file
226
access_revocation_test.go
Normal file
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
37
api_token_events_test.go
Normal file
37
api_token_events_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
114
backend_link_auth_time_test.go
Normal file
114
backend_link_auth_time_test.go
Normal file
@@ -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"`)
|
||||
})
|
||||
}
|
||||
110
backend_link_show_test.go
Normal file
110
backend_link_show_test.go
Normal file
@@ -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
|
||||
// <frontend_url>/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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
61
mcp_guard_client_ip_test.go
Normal file
61
mcp_guard_client_ip_test.go
Normal file
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
280
member_token_scopes_test.go
Normal file
280
member_token_scopes_test.go
Normal file
@@ -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")}}
|
||||
}
|
||||
150
organisation_logo_test.go
Normal file
150
organisation_logo_test.go
Normal file
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user