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) } }) }