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.
235 lines
9.8 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|