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