//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 ( "context" "database/sql" "encoding/json" "net/http" "net/http/httptest" "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") }