Files
sm-user-plugin/reset_two_factor_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

118 lines
3.6 KiB
Go

package user
import (
"context"
"encoding/json"
"net/http"
"slices"
"sync"
"testing"
"git.golem15.com/golem15/sm-user-plugin/classes"
"git.golem15.com/golem15/sm-user-plugin/models"
"gorm.io/gorm"
)
const permResetTwoFactor = "golem15.users.reset_two_factor"
// fakeTwoFactorAdmin stands in for a two-factor plugin's service.
type fakeTwoFactorAdmin struct {
mu sync.Mutex
has bool
resets []uint
txs []*gorm.DB
}
func (f *fakeTwoFactorAdmin) HasTwoFactor(_ context.Context, tx *gorm.DB, _ uint) (bool, error) {
if tx == nil {
return false, nil
}
f.mu.Lock()
defer f.mu.Unlock()
return f.has, nil
}
func (f *fakeTwoFactorAdmin) Reset(_ context.Context, tx *gorm.DB, user *models.User) error {
f.mu.Lock()
defer f.mu.Unlock()
f.resets = append(f.resets, user.ID)
f.txs = append(f.txs, tx)
f.has = false
return nil
}
// recordAction returns the offered record action named name from the show
// response of user id, or nil.
func (e *adminEnv) recordAction(t *testing.T, token string, id uint, name string) map[string]any {
t.Helper()
rec := e.expect(t, http.StatusOK, http.MethodGet, userPath(id, ""), token, nil)
var meta struct {
Actions []map[string]any `json:"actions"`
}
if err := json.Unmarshal(adminDecode(t, rec).Meta, &meta); err != nil {
t.Fatal(err)
}
for _, action := range meta.Actions {
if action["name"] == name {
return action
}
}
return nil
}
// TestResetTwoFactorSlotSmoke pins the D-15 slot: reset_two_factor is
// offered only when a classes.TwoFactorAdmin is published and the user has
// a second factor, only to an administrator holding
// golem15.users.reset_two_factor, with the danger confirmation, and running
// it calls Reset once inside the action's transaction.
func TestResetTwoFactorSlotSmoke(t *testing.T) {
if testing.Short() {
t.Skip("Postgres")
}
env := newAdminEnv(t)
token := env.admin(t, permAccessUsers, permResetTwoFactor)
plain := env.admin(t, permAccessUsers)
user := env.seedUser(t, "r2fa", nil)
actionPath := userPath(user.ID, "/actions/reset_two_factor")
// No two-factor plugin: never offered, never runs.
if slices.Contains(env.offered(t, token, user.ID), "reset_two_factor") {
t.Fatal("reset_two_factor offered without a published TwoFactorAdmin")
}
if rec := env.call(t, http.MethodPost, actionPath, token, map[string]any{}); rec.Code == http.StatusOK {
t.Fatalf("reset_two_factor ran without a TwoFactorAdmin: %s", rec.Body.String())
}
svc := &fakeTwoFactorAdmin{has: true}
if err := env.app.Publish[classes.TwoFactorAdmin](svc); err != nil {
t.Fatal(err)
}
action := env.recordAction(t, token, user.ID, "reset_two_factor")
if action == nil {
t.Fatalf("reset_two_factor not offered: %v", env.offered(t, token, user.ID))
}
if action["danger"] != true || action["label"] != "Reset 2FA" {
t.Fatalf("offered action = %v", action)
}
// An administrator with only access_users neither sees nor runs it.
if slices.Contains(env.offered(t, plain, user.ID), "reset_two_factor") {
t.Fatal("reset_two_factor offered without its permission")
}
env.expect(t, http.StatusForbidden, http.MethodPost, actionPath, plain, map[string]any{})
if len(svc.resets) != 0 {
t.Fatal("a refused administrator reached Reset")
}
env.expect(t, http.StatusOK, http.MethodPost, actionPath, token, map[string]any{})
if len(svc.resets) != 1 || svc.resets[0] != user.ID || svc.txs[0] == nil {
t.Fatalf("Reset calls = %v (tx %v)", svc.resets, svc.txs)
}
// Nothing left to reset: no longer offered.
if slices.Contains(env.offered(t, token, user.ID), "reset_two_factor") {
t.Fatal("reset_two_factor offered for a user without a second factor")
}
}