The till and admin payment modal 'Request a new code' buttons previously called
the session-scoped POST /api/user/2fa/code, which mints a code for the ADMIN's
session — a code that can never satisfy the card-owner gate and is delivered to
the admin's log line, not the customer.
- New POST /api/admin/users/{id}/2fa/code (AdminSendVerificationCodeHandler,
RequireAdmin + per-user limiter): mints/reuses a code for the TARGET user
(the card owner/customer), keyed to the CUSTOMER's userID so the [2FA]
delivery log carries the customer's ID — the customer, never the admin, is
the authentication subject for their card
- Shared useTwoFactorCodeForSavedCard composable gains an optional mint()
option; admin surfaces (PaymentModal, TillPurchases) pass the customer-scoped
mint, customer surfaces keep the session default
- Frontend: adminRequestNewTwoFactorCode(userID) in square.ts; PaymentModal
mints for booking.user_id, TillPurchases for selectedCustomer.id
- Tests: admin mint keys the code to the customer's userID (log line contains
customer ID, NOT the admin ID) + pending hash persisted for the customer;
unknown target user 404s
Backend 26/26 packages; frontend 72/72 + build clean.
248 lines
10 KiB
Go
248 lines
10 KiB
Go
//go:build test
|
|
|
|
package user
|
|
|
|
// Tests for admin 2FA management: the 2FA fields exposed by
|
|
// GET /api/admin/users/{id} (AdminUserDetail) and the admin-only recovery route
|
|
// POST /api/admin/users/{id}/2fa/remove (AdminRemoveUser2FAHandler), plus the
|
|
// two_factor_last_used_at stamping on successful verification. Sequential only
|
|
// (no t.Parallel): the enforced-mode test flips process-global env vars, and
|
|
// the package shares db.Conn state.
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"log"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"regexp"
|
|
"testing"
|
|
"time"
|
|
|
|
"crussell/clock"
|
|
"crussell/db"
|
|
"crussell/mw"
|
|
"crussell/testutils"
|
|
"crussell/testutils/fixtures"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// makeAdmin2FARequest builds a request with the given role in context (admin
|
|
// for the happy paths, verified_email for the RequireAdmin gate test) and the
|
|
// {id} route param parsed from the path (extractAdminUserID, from
|
|
// admin_handlers_test.go).
|
|
func makeAdmin2FARequest(handler http.Handler, method, path, role string, ctx context.Context) *httptest.ResponseRecorder {
|
|
req := httptest.NewRequest(method, path, nil)
|
|
rctx := chi.NewRouteContext()
|
|
if id, ok := extractAdminUserID(path); ok {
|
|
rctx.URLParams.Add("id", id)
|
|
}
|
|
ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx)
|
|
ctx = context.WithValue(ctx, mw.UserIDKey, "admin-test-id")
|
|
ctx = context.WithValue(ctx, mw.UserRoleKey, role)
|
|
req = req.WithContext(ctx)
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
return w
|
|
}
|
|
|
|
// seedUser2FATurnedOn enables 2FA for a user with a method, a pending code, and
|
|
// a last-used stamp so admin view/remove tests start from a fully-populated row.
|
|
func seedUser2FATurnedOn(t *testing.T, ctx context.Context, q db.Querier, userID string) {
|
|
t.Helper()
|
|
_, err := q.Exec(ctx, `
|
|
UPDATE users
|
|
SET two_factor_enabled = true,
|
|
two_factor_method = 'sms',
|
|
two_factor_pending_code_hash = 'abcdef',
|
|
two_factor_pending_code_expires = $2,
|
|
two_factor_last_used_at = $3
|
|
WHERE id = $1
|
|
`, userID, clock.Now().Add(5*time.Minute), clock.Now().Add(-24*time.Hour))
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// TestAdminUsers_Get_IncludesTwoFAState verifies GET /api/admin/users/{id}
|
|
// exposes two_factor_enabled, two_factor_method and two_factor_last_used_at.
|
|
func TestAdminUsers_Get_IncludesTwoFAState(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
require.NoError(t, err)
|
|
seedUser2FATurnedOn(t, ctx, tx, userID)
|
|
|
|
handler := http.HandlerFunc(GetAdminUserHandler)
|
|
w := makeAdmin2FARequest(handler, http.MethodGet, "/api/admin/users/"+userID, "admin", ctx)
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
|
|
var resp map[string]any
|
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
|
require.Equal(t, true, resp["twoFactorEnabled"])
|
|
require.Equal(t, "sms", resp["twoFactorMethod"])
|
|
lastUsed, ok := resp["twoFactorLastUsedAt"].(string)
|
|
require.True(t, ok, "twoFactorLastUsedAt should serialize as a string")
|
|
require.NotEmpty(t, lastUsed)
|
|
}
|
|
|
|
// TestAdminUsers_Get_TwoFADisabledIsFalse verifies the 2FA fields serialize
|
|
// sanely for a user who never enabled 2FA (false, nil method/last-used).
|
|
func TestAdminUsers_Get_TwoFADisabledIsFalse(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
require.NoError(t, err)
|
|
|
|
handler := http.HandlerFunc(GetAdminUserHandler)
|
|
w := makeAdmin2FARequest(handler, http.MethodGet, "/api/admin/users/"+userID, "admin", ctx)
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
|
|
var resp map[string]any
|
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
|
require.Equal(t, false, resp["twoFactorEnabled"])
|
|
_, hasMethod := resp["twoFactorMethod"]
|
|
require.False(t, hasMethod, "twoFactorMethod should be omitted when nil")
|
|
_, hasLastUsed := resp["twoFactorLastUsedAt"]
|
|
require.False(t, hasLastUsed, "twoFactorLastUsedAt should be omitted when nil")
|
|
}
|
|
|
|
// TestAdminUsers_Remove2FA_ClearsAllColumns verifies the admin recovery route
|
|
// clears the enabled flag, method, pending code fields and last-used stamp.
|
|
func TestAdminUsers_Remove2FA_ClearsAllColumns(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
require.NoError(t, err)
|
|
seedUser2FATurnedOn(t, ctx, tx, userID)
|
|
|
|
handler := http.HandlerFunc(AdminRemoveUser2FAHandler)
|
|
w := makeAdmin2FARequest(handler, http.MethodPost, "/api/admin/users/"+userID+"/2fa/remove", "admin", ctx)
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
|
|
var enabled bool
|
|
var method, pendingHash sql.NullString
|
|
var pendingExpires, lastUsed sql.NullTime
|
|
err = tx.QueryRow(ctx, `
|
|
SELECT two_factor_enabled, two_factor_method, two_factor_pending_code_hash,
|
|
two_factor_pending_code_expires, two_factor_last_used_at
|
|
FROM users WHERE id = $1
|
|
`, userID).Scan(&enabled, &method, &pendingHash, &pendingExpires, &lastUsed)
|
|
require.NoError(t, err)
|
|
require.False(t, enabled, "two_factor_enabled should be false after admin removal")
|
|
require.False(t, method.Valid, "two_factor_method should be NULL after admin removal")
|
|
require.False(t, pendingHash.Valid, "two_factor_pending_code_hash should be NULL after admin removal")
|
|
require.False(t, pendingExpires.Valid, "two_factor_pending_code_expires should be NULL after admin removal")
|
|
require.False(t, lastUsed.Valid, "two_factor_last_used_at should be NULL after admin removal")
|
|
}
|
|
|
|
// TestAdminUsers_Remove2FA_UnknownUser_NotFound verifies a valid-format ID that
|
|
// matches no user row returns 404.
|
|
func TestAdminUsers_Remove2FA_UnknownUser_NotFound(t *testing.T) {
|
|
ctx, _ := testutils.SetupTestTx(t)
|
|
|
|
handler := http.HandlerFunc(AdminRemoveUser2FAHandler)
|
|
w := makeAdmin2FARequest(handler, http.MethodPost, "/api/admin/users/000000000000/2fa/remove", "admin", ctx)
|
|
require.Equal(t, http.StatusNotFound, w.Code)
|
|
}
|
|
|
|
// TestAdminUsers_Remove2FA_InvalidID_NotFound verifies a malformed ID is
|
|
// rejected before any query runs.
|
|
func TestAdminUsers_Remove2FA_InvalidID_NotFound(t *testing.T) {
|
|
ctx, _ := testutils.SetupTestTx(t)
|
|
|
|
handler := http.HandlerFunc(AdminRemoveUser2FAHandler)
|
|
w := makeAdmin2FARequest(handler, http.MethodPost, "/api/admin/users/nothex/2fa/remove", "admin", ctx)
|
|
require.Equal(t, http.StatusNotFound, w.Code)
|
|
}
|
|
|
|
// TestAdminUsers_Remove2FA_NonAdmin_Forbidden verifies the route is admin-gated:
|
|
// a non-admin role gets 403 from RequireAdmin before the handler runs.
|
|
func TestAdminUsers_Remove2FA_NonAdmin_Forbidden(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
require.NoError(t, err)
|
|
seedUser2FATurnedOn(t, ctx, tx, userID)
|
|
|
|
handler := mw.RequireAdmin(http.HandlerFunc(AdminRemoveUser2FAHandler))
|
|
w := makeAdmin2FARequest(handler, http.MethodPost, "/api/admin/users/"+userID+"/2fa/remove", "verified_email", ctx)
|
|
require.Equal(t, http.StatusForbidden, w.Code)
|
|
|
|
// The user's 2FA must be untouched by the rejected request.
|
|
var enabled bool
|
|
err = tx.QueryRow(ctx, `SELECT two_factor_enabled FROM users WHERE id = $1`, userID).Scan(&enabled)
|
|
require.NoError(t, err)
|
|
require.True(t, enabled, "2FA should remain enabled after a 403")
|
|
}
|
|
|
|
// TestTwoFAVerify_Enforced_UpdatesLastUsedAt verifies a successful enforced-mode
|
|
// code check stamps two_factor_last_used_at.
|
|
func TestTwoFAVerify_Enforced_UpdatesLastUsedAt(t *testing.T) {
|
|
twofaEnvEnforced(t)
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
require.NoError(t, err)
|
|
|
|
seedPendingTwoFA(t, ctx, tx, userID, "123456")
|
|
|
|
w := performUser2FARequest(t, VerifyTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/verify", TwoFAVerifyRequest{Code: "123456"}, userID)
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
|
|
var enabled bool
|
|
var lastUsed sql.NullTime
|
|
err = tx.QueryRow(ctx, `SELECT two_factor_enabled, two_factor_last_used_at FROM users WHERE id = $1`, userID).Scan(&enabled, &lastUsed)
|
|
require.NoError(t, err)
|
|
require.True(t, enabled)
|
|
require.True(t, lastUsed.Valid, "two_factor_last_used_at should be set after a successful verify")
|
|
}
|
|
|
|
// TestAdminSendVerificationCode_MintsForCustomer verifies the admin-scoped mint
|
|
// keys the code to the TARGET user (the customer whose saved card is being
|
|
// charged), NOT the admin session. The [2FA] delivery log line must carry the
|
|
// customer's userID — so the code is delivered to the customer and can satisfy
|
|
// the card-owner gate — and must never carry the admin's ID.
|
|
func TestAdminSendVerificationCode_MintsForCustomer(t *testing.T) {
|
|
twofaEnvEnforced(t)
|
|
var buf bytes.Buffer
|
|
log.SetOutput(&buf)
|
|
t.Cleanup(func() { log.SetOutput(os.Stderr) })
|
|
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
customerID, err := fixtures.CreateTestUser(tx)
|
|
require.NoError(t, err)
|
|
_, err = tx.Exec(ctx, `UPDATE users SET two_factor_enabled = true, two_factor_method = 'email' WHERE id = $1`, customerID)
|
|
require.NoError(t, err)
|
|
|
|
// Admin session userID differs from the target customer.
|
|
w := makeAdmin2FARequest(http.HandlerFunc(AdminSendVerificationCodeHandler), http.MethodPost, "/api/admin/users/"+customerID+"/2fa/code", "admin", ctx)
|
|
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
|
|
|
var resp map[string]any
|
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
|
require.Equal(t, "Code sent", resp["message"])
|
|
_, hasCode := resp["code"]
|
|
require.False(t, hasCode, "enforced env must NOT return the code in the response")
|
|
|
|
logOut := buf.String()
|
|
require.Contains(t, logOut, customerID, "delivery log must key the code to the CUSTOMER's userID")
|
|
require.NotContains(t, logOut, "admin-test-id", "delivery log must NOT key the code to the admin session")
|
|
require.Regexp(t, regexp.MustCompile(`\[2FA\].*\d{6}`), logOut, "endpoint must log the code as the delivery channel")
|
|
|
|
// The customer now has a pending code that would satisfy the card-owner gate.
|
|
var pendingHash sql.NullString
|
|
require.NoError(t, tx.QueryRow(ctx, `SELECT two_factor_pending_code_hash FROM users WHERE id = $1`, customerID).Scan(&pendingHash))
|
|
require.True(t, pendingHash.Valid, "admin mint must persist a pending code for the customer")
|
|
}
|
|
|
|
// TestAdminSendVerificationCode_UnknownCustomer_NotFound verifies the admin
|
|
// mint 404s for a nonexistent target user (the route-level RequireAdmin
|
|
// middleware — applied in main.go — gates role access; the handler owns the
|
|
// target-user contract).
|
|
func TestAdminSendVerificationCode_UnknownCustomer_NotFound(t *testing.T) {
|
|
twofaEnvEnforced(t)
|
|
ctx, _ := testutils.SetupTestTx(t)
|
|
|
|
w := makeAdmin2FARequest(http.HandlerFunc(AdminSendVerificationCodeHandler), http.MethodPost, "/api/admin/users/no-such-user/2fa/code", "admin", ctx)
|
|
require.Equal(t, http.StatusNotFound, w.Code, "unknown target user must 404")
|
|
}
|