Files
sm-user-plugin/password_changed_smoke_test.go
Jakub Zych 7900f47ba6 feat: password-change hook and the reset-2FA admin slot
The D-07 password-change hook is the existing AccessRevokedEvent, fired
inside the write's transaction by a reset, the user's own change and an
administrator's password; (*AccessRevokedEvent).PasswordChanged reports
those three reasons, and the login rehash fires nothing.

The Users screen declares the record action reset_two_factor (danger)
behind the new permission golem15.users.reset_two_factor, which no role
has by default. It is offered only when a two-factor plugin publishes a
classes.TwoFactorAdmin and the user has a second factor; Run calls the
service's Reset in the action's transaction.
2026-10-26 10:03:59 +01:00

154 lines
5.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/bouncer"
)
// passwordProbe records the AccessRevokedEvents of an app (the D-07
// password-change hook) and can make the listener fail.
type passwordProbe struct {
mu sync.Mutex
seen []classes.AccessRevokedEvent
fail atomic.Bool
}
func (p *passwordProbe) take() []classes.AccessRevokedEvent {
p.mu.Lock()
defer p.mu.Unlock()
out := p.seen
p.seen = nil
return out
}
// onePasswordChange asserts exactly one event for id with reason, fired in
// a transaction and reporting PasswordChanged.
func onePasswordChange(t *testing.T, probe *passwordProbe, id uint, reason string) {
t.Helper()
seen := probe.take()
if len(seen) != 1 {
t.Fatalf("%s fired %d events, want 1: %+v", reason, len(seen), seen)
}
ev := seen[0]
if ev.Reason != reason || len(ev.UserIDs) != 1 || ev.UserIDs[0] != id || ev.Tx == nil || !ev.PasswordChanged() {
t.Fatalf("%s event = %+v (PasswordChanged %v)", reason, ev, ev.PasswordChanged())
}
}
// TestPasswordChangedEventSmoke pins the D-07 hook a two-factor plugin
// listens to: a password reset, the user's own change and an administrator
// setting a password each fire one AccessRevokedEvent that reports
// PasswordChanged, inside the write's transaction; the bcrypt rehash on
// login fires none; a failing listener rolls the change back. The event
// itself predates this test (TestAccessRevocation pins its other fields).
func TestPasswordChangedEventSmoke(t *testing.T) {
for reason, want := range map[string]bool{
classes.AccessRevokedPasswordReset: true,
classes.AccessRevokedPasswordChanged: true,
classes.AccessRevokedPasswordSetByAdmin: true,
classes.AccessRevokedBan: false,
"": false,
} {
if got := (&classes.AccessRevokedEvent{Reason: reason}).PasswordChanged(); got != want {
t.Fatalf("PasswordChanged(%q) = %v, want %v", reason, got, want)
}
}
var nilEvent *classes.AccessRevokedEvent
if nilEvent.PasswordChanged() {
t.Fatal("a nil event reports a password change")
}
if testing.Short() {
t.Skip("Postgres")
}
env := newAdminEnv(t)
probe := &passwordProbe{}
env.app.Events.Listen("test.password_changed", func(_ context.Context, ev *classes.AccessRevokedEvent) error {
if probe.fail.Load() {
return errors.New("listener refused")
}
probe.mu.Lock()
probe.seen = append(probe.seen, *ev)
probe.mu.Unlock()
return nil
})
admin := env.superAdmin(t)
ctx := context.Background()
session := func(user *models.User) string {
return craftToken(t, strconv.FormatUint(uint64(user.ID), 10), "pc-"+strconv.FormatInt(time.Now().UnixNano(), 10), time.Now().Add(-time.Minute), time.Now().Add(time.Hour))
}
t.Run("reset", func(t *testing.T) {
user := env.seedUser(t, "pc-reset", nil)
code, err := classes.IssueResetCode(ctx, env.gdb, user)
if err != nil {
t.Fatal(err)
}
probe.take()
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": "pc-new-password", "password_confirmation": "pc-new-password"})
onePasswordChange(t, probe, user.ID, classes.AccessRevokedPasswordReset)
})
t.Run("change", func(t *testing.T) {
user := env.seedUser(t, "pc-change", func(u *models.User) { u.HasSelfSetPassword = true })
probe.take()
env.expect(t, http.StatusOK, http.MethodPost, "/_user/api/v1/change-password", session(user), map[string]string{"current_password": "correct-horse", "password": "pc-changed-password", "password_confirmation": "pc-changed-password"})
onePasswordChange(t, probe, user.ID, classes.AccessRevokedPasswordChanged)
})
t.Run("admin_set", func(t *testing.T) {
user := env.seedUser(t, "pc-admin", nil)
probe.take()
const set = "pc-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})
onePasswordChange(t, probe, user.ID, classes.AccessRevokedPasswordSetByAdmin)
env.expect(t, http.StatusOK, http.MethodPut, userPath(user.ID, ""), admin, map[string]any{"name": user.Name, "email": user.Email})
if seen := probe.take(); len(seen) != 0 {
t.Fatalf("an update without a password fired %+v", seen)
}
})
t.Run("rehash_fires_none", func(t *testing.T) {
user := env.seedUser(t, "pc-rehash", nil)
if err := env.app.Config.Set("golem15.user.password.bcrypt_cost", 11); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = env.app.Config.Set("golem15.user.password.bcrypt_cost", 10) })
probe.take()
if status, _ := env.userLogin(t, user.Email, "correct-horse"); status != http.StatusOK {
t.Fatalf("login = %d", status)
}
stored := env.reload(t, user.ID).Password
if stored == user.Password || bouncer.NeedsRehash(stored, 11) {
t.Fatal("the login did not rehash the password at the higher cost")
}
if seen := probe.take(); len(seen) != 0 {
t.Fatalf("the rehash fired %+v", seen)
}
})
t.Run("listener_error_rolls_back", func(t *testing.T) {
user := env.seedUser(t, "pc-rollback", func(u *models.User) { u.HasSelfSetPassword = true })
probe.fail.Store(true)
rec := env.call(t, http.MethodPost, "/_user/api/v1/change-password", session(user), map[string]string{"current_password": "correct-horse", "password": "pc-never-set", "password_confirmation": "pc-never-set"})
probe.fail.Store(false)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("change with a failing listener = %d %s", rec.Code, rec.Body.String())
}
if stored := env.reload(t, user.ID).Password; stored != user.Password {
t.Fatal("the stored hash changed although the listener failed")
}
})
}