//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") } // TestAdminSendVerificationCode_WritesAuditTrail verifies finding 2a: an // admin-scoped 2FA mint writes an admin_audit_log row (action_type // '2fa_code_mint') keyed to the ADMIN, with details recording whether the code // was FRESH or REUSED and the remaining lifetime. Uses a real admin user so the // admin_id FK is satisfied (the synthetic "admin-test-id" elsewhere would make // the best-effort audit write a no-op). func TestAdminSendVerificationCode_WritesAuditTrail(t *testing.T) { twofaEnvEnforced(t) ctx, tx := testutils.SetupTestTx(t) adminID, err := fixtures.CreateTestAdminUser(tx) require.NoError(t, err) 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) // Fresh mint: audit row must record reused=false + the full 10-minute life. req := httptest.NewRequest(http.MethodPost, "/api/admin/users/"+customerID+"/2fa/code", nil) rctx := chi.NewRouteContext() rctx.URLParams.Add("id", customerID) req = req.WithContext(context.WithValue(ctx, chi.RouteCtxKey, rctx)) req = req.WithContext(context.WithValue(req.Context(), mw.UserIDKey, adminID)) w := httptest.NewRecorder() AdminSendVerificationCodeHandler(w, req) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) var auditCount int var actionType string var targetUserID sql.NullString var details sql.NullString err = tx.QueryRow(ctx, ` SELECT COUNT(*), MAX(action_type), MAX(target_user_id), MAX(details::text) FROM admin_audit_log WHERE admin_id = $1 AND action_type = '2fa_code_mint' `, adminID).Scan(&auditCount, &actionType, &targetUserID, &details) require.NoError(t, err) require.Equal(t, 1, auditCount, "a fresh admin mint must write exactly one audit row") require.Equal(t, "2fa_code_mint", actionType) require.True(t, targetUserID.Valid && targetUserID.String == customerID, "audit must target the customer, not the admin") var freshDetails map[string]any require.NoError(t, json.Unmarshal([]byte(details.String), &freshDetails), "audit details must be parseable JSON") require.Equal(t, false, freshDetails["reused"], "a fresh mint must be audited as fresh") require.Equal(t, float64(600), freshDetails["remaining_seconds"], "a fresh mint reports the full 10-minute lifetime") // Second request reuses the still-valid code: audit must record reuse. (Both // rows share the transaction's NOW(), so select by the distinguishing detail // rather than created_at.) w2 := httptest.NewRecorder() AdminSendVerificationCodeHandler(w2, req) require.Equal(t, http.StatusOK, w2.Code, w2.Body.String()) err = tx.QueryRow(ctx, ` SELECT details::text FROM admin_audit_log WHERE admin_id = $1 AND action_type = '2fa_code_mint' AND (details->>'reused')::boolean = true LIMIT 1 `, adminID).Scan(&details) require.NoError(t, err) var reuseDetails map[string]any require.NoError(t, json.Unmarshal([]byte(details.String), &reuseDetails)) require.Equal(t, true, reuseDetails["reused"], "a reused code must be audited as reused") }