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:
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user