Files
sm-user-plugin/access_revocation_test.go
Jakub Zych ebe408f542 test: an organisation delete removes the blobs, Postgres tests skip under -short
TestOrganisationLogo asserted only that the system_files rows go; it now
also waits for the commit and asserts the logo's and avatar's originals and
thumbnails are gone from the bucket. The Postgres tests of the gap closure
call t.Skip under -short instead of returning, so -short runs report them
as skipped, not passed.
2026-10-23 21:14:37 +02:00

235 lines
9.8 KiB
Go

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, RC-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 (for ChangePassword the instant of the request, never the
// calling session's older iat, while the session itself survives), 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: skipped under -short.
func TestAccessRevocation(t *testing.T) {
if testing.Short() {
t.Skip("Postgres")
}
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 revokes as of the request and keeps the calling session", func(t *testing.T) {
user := env.seedUser(t, "rv-change", func(u *models.User) { u.HasSelfSetPassword = true })
// A session last refreshed twenty minutes ago: an OAuth grant its
// client rotated since then is newer than the session, and must
// still end (RC-CR-01).
iat := time.Now().Add(-20 * time.Minute)
session := craftToken(t, strconv.FormatUint(uint64(user.ID), 10), "rv-change-"+strconv.FormatInt(time.Now().UnixNano(), 10), iat, time.Now().Add(time.Hour))
probe.take()
spy.reset()
started := time.Now()
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)
if got.ev.Before.Before(started) || got.ev.Before.After(time.Now()) {
t.Fatalf("Before %s is not the instant of the request (started %s): grants refreshed after the session's iat %s would survive", got.ev.Before, started, iat)
}
stored := env.reload(t, user.ID).TokensValidAfter
if want := time.Unix(iat.Unix(), 0).Add(-time.Second); stored == nil || !stored.Equal(want) {
t.Fatalf("stored cutoff %v, want the session's iat second minus one (%s)", stored, want)
}
env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/fetch", session, nil)
})
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)
}
})
}