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.
118 lines
3.6 KiB
Go
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")
|
|
}
|
|
}
|