//go:build test package user import ( "bytes" "context" "database/sql" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "sync" "testing" "time" "crussell/clock" "crussell/db" "crussell/internal/s3" "crussell/mw" "crussell/testutils" "crussell/testutils/fixtures" "github.com/stretchr/testify/require" "golang.org/x/crypto/bcrypt" ) // ============================================================================ // FIX 2 — DeleteAccountHandler current-password lockout // ============================================================================ // TestDeleteAccount_CurrentPasswordLockout verifies the FIX 2 budget: after 5 // consecutive wrong current passwords the account is locked (even the CORRECT // password is rejected with 429), and a cleared lockout lets the correct // password through. Sequential (no t.Parallel): the handler reads the // process-global s3.Client / payments.SquareClient. // // FIX 1 (round-9): a locked-out user who provides the CORRECT current password // clears the lockout and succeeds — the test verifies that the 6th attempt // with the correct password now succeeds (the lockout is lifted on success). func TestDeleteAccount_CurrentPasswordLockout(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) // Five wrong current passwords: each rejected with 401 and counted toward // the shared failed_attempts budget. for i := 0; i < 5; i++ { req := deleteAccountRequest(t, ctx, userID, "not-the-password", "") rr := httptest.NewRecorder() DeleteAccountHandler(rr, req) require.Equal(t, http.StatusUnauthorized, rr.Code, "wrong current password must be rejected (attempt %d)", i+1) } // FIX 1: the correct password now clears the lockout and succeeds (the // locked-out user can self-recover). req := deleteAccountRequest(t, ctx, userID, "testpassword123", "") rr := httptest.NewRecorder() DeleteAccountHandler(rr, req) require.Equal(t, http.StatusNoContent, rr.Code, "a locked-out user with the correct current password must be able to recover (FIX 1)") // The lockout was cleared on success. var failedAttempts int var lockedUntil *time.Time require.NoError(t, tx.QueryRow(ctx, `SELECT failed_attempts, locked_until FROM users WHERE id = $1`, userID).Scan(&failedAttempts, &lockedUntil)) require.Zero(t, failedAttempts, "failed_attempts must be reset to 0 after a successful recovery") require.Nil(t, lockedUntil, "locked_until must be NULL after a successful recovery") } // ============================================================================ // FIX 3 — ChangePasswordHandler current-password lockout // ============================================================================ // changePasswordRequest builds the PUT /api/user/change-password request with // the given current/new password and an authenticated context. func changePasswordRequest(t *testing.T, ctx context.Context, userID, currentPassword, newPassword string) *http.Request { t.Helper() body, err := json.Marshal(ChangePasswordRequest{ CurrentPassword: currentPassword, NewPassword: newPassword, }) require.NoError(t, err) req := httptest.NewRequest(http.MethodPut, "/api/user/change-password", bytes.NewReader(body)) req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID)) req.Header.Set("Content-Type", "application/json") return req } // TestPasswordChange_CurrentPasswordLockout verifies the FIX 3 budget shares // the same failed-attempt/lockout columns as delete-account: 5 wrong current // passwords lock the change-password flow (correct password → 429), and a // cleared lockout lets it through. // // FIX 1 (round-9): a locked-out user who provides the CORRECT current password // clears the lockout and succeeds. func TestPasswordChange_CurrentPasswordLockout(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) for i := 0; i < 5; i++ { req := changePasswordRequest(t, ctx, userID, "not-the-password", "newpassword456") rr := httptest.NewRecorder() ChangePasswordHandler(rr, req) require.Equal(t, http.StatusUnauthorized, rr.Code, "wrong current password must be rejected (attempt %d)", i+1) } // FIX 1: the correct password now clears the lockout and succeeds. req := changePasswordRequest(t, ctx, userID, "testpassword123", "newpassword456") rr := httptest.NewRecorder() ChangePasswordHandler(rr, req) require.Equal(t, http.StatusOK, rr.Code, "a locked-out user with the correct current password must be able to recover (FIX 1)") // The lockout was cleared on success. var failedAttempts int var lockedUntil *time.Time require.NoError(t, tx.QueryRow(ctx, `SELECT failed_attempts, locked_until FROM users WHERE id = $1`, userID).Scan(&failedAttempts, &lockedUntil)) require.Zero(t, failedAttempts, "failed_attempts must be reset to 0 after a successful recovery") require.Nil(t, lockedUntil, "locked_until must be NULL after a successful recovery") } // ============================================================================ // FIX 1 — Durable S3/R2 profile-picture deletion outbox // ============================================================================ // recordingUploader records S3/R2 Delete calls so the account-deletion outbox // tests can assert what was (and wasn't) deleted. Every Uploader method is // implemented explicitly (no embedded nil) so parallel profile-picture tests // that read the swapped global s3.Client never panic. type recordingUploader struct { mu sync.Mutex deleted []string // "bucket/key" } func (r *recordingUploader) Upload(context.Context, string, string, io.Reader, string) error { return nil } func (r *recordingUploader) Download(context.Context, string, string, io.Writer) error { return nil } func (r *recordingUploader) GetURL(context.Context, string, string) (string, error) { return "https://cdn.example.com/x/y", nil } func (r *recordingUploader) HealthCheck(context.Context) error { return nil } func (r *recordingUploader) Delete(_ context.Context, bucket, key string) error { r.mu.Lock() defer r.mu.Unlock() r.deleted = append(r.deleted, bucket+"/"+key) return nil } func (r *recordingUploader) Deleted() []string { r.mu.Lock() defer r.mu.Unlock() return append([]string(nil), r.deleted...) } // waitFor polls cond until it holds or the timeout elapses, failing the test. func waitFor(t *testing.T, timeout time.Duration, cond func() bool) { t.Helper() deadline := clock.Now().Add(timeout) for clock.Now().Before(deadline) { if cond() { return } time.Sleep(5 * time.Millisecond) } t.Fatal("timed out waiting for condition") } // TestDeleteAccount_S3OutboxPersisted_AndDrained verifies the FIX 1 durable // pattern: with a configured bucket the erasure transaction persists a // pending_s3_deletions outbox row, and the async goroutine drains it after // deleting the object. Sequential: swaps the process-global s3.Client and env. func TestDeleteAccount_S3OutboxPersisted_AndDrained(t *testing.T) { savedClient := s3.Client rec := &recordingUploader{} s3.Client = rec t.Cleanup(func() { s3.Client = savedClient }) t.Setenv("S3_PROFILE_PICS_BUCKET", "test-profile-pics-bucket") ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) _, err = tx.Exec(ctx, `UPDATE users SET profile_pic_url = 'https://cdn.example.com/pics/old.jpg' WHERE id = $1`, userID) require.NoError(t, err) req := deleteAccountRequest(t, ctx, userID, "testpassword123", "") rr := httptest.NewRecorder() DeleteAccountHandler(rr, req) require.Equal(t, http.StatusNoContent, rr.Code, rr.Body.String()) // The durable outbox row was written inside the erasure transaction. var outboxID, bucket, objectKey string err = tx.QueryRow(ctx, `SELECT id, bucket, object_key FROM pending_s3_deletions WHERE user_id = $1`, userID). Scan(&outboxID, &bucket, &objectKey) require.NoError(t, err, "pending_s3_deletions outbox row must be persisted in the erasure transaction") require.NotEmpty(t, outboxID) require.Equal(t, "test-profile-pics-bucket", bucket) require.Equal(t, "profiles/"+userID+".jpg", objectKey) // The async goroutine (primary drain) deletes the object from the store. waitFor(t, 2*time.Second, func() bool { return len(rec.Deleted()) > 0 }) require.Equal(t, []string{"test-profile-pics-bucket/profiles/" + userID + ".jpg"}, rec.Deleted()) } // TestDeleteAccount_S3Outbox_FailClosedOnEmptyBucket verifies the FIX 4 // fail-closed behavior: with S3_PROFILE_PICS_BUCKET unset the handler must // NOT guess the dev bucket — no outbox row is written and no deletion is // attempted, even though a profile picture exists. func TestDeleteAccount_S3Outbox_FailClosedOnEmptyBucket(t *testing.T) { savedClient := s3.Client rec := &recordingUploader{} s3.Client = rec t.Cleanup(func() { s3.Client = savedClient }) t.Setenv("S3_PROFILE_PICS_BUCKET", "") ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) _, err = tx.Exec(ctx, `UPDATE users SET profile_pic_url = 'https://cdn.example.com/pics/old.jpg' WHERE id = $1`, userID) require.NoError(t, err) req := deleteAccountRequest(t, ctx, userID, "testpassword123", "") rr := httptest.NewRecorder() DeleteAccountHandler(rr, req) require.Equal(t, http.StatusNoContent, rr.Code, rr.Body.String()) // Fail-closed: no outbox row, no deletion attempt against a guessed bucket. var count int require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM pending_s3_deletions WHERE user_id = $1`, userID).Scan(&count)) require.Zero(t, count, "no outbox row may be written when the bucket is unset") require.Empty(t, rec.Deleted(), "no S3 deletion may be attempted against a guessed bucket") } // ============================================================================ // FIX 3 — atomic current-password failure record (no lost updates) // ============================================================================ // TestDeleteAccount_ConcurrentWrongPassword_NoLostUpdates verifies the FIX 3 // atomic failure record: 20 concurrent wrong-current-password requests each // increment the shared failed-attempt budget exactly once (no lost updates — // the final count is 20, not a smaller racy subset) and the escalating lockout // is armed. Runs against the real pool because a per-test pgx.Tx cannot serve // concurrent queries; the requests carry no test-tx context. func TestDeleteAccount_ConcurrentWrongPassword_NoLostUpdates(t *testing.T) { savedClient := s3.Client s3.Client = nil t.Cleanup(func() { s3.Client = savedClient }) hash, err := bcrypt.GenerateFromPassword([]byte("testpassword123"), bcrypt.DefaultCost) require.NoError(t, err) ctx := context.Background() var userID string require.NoError(t, db.Conn.QueryRow(ctx, ` INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type) VALUES ('Concurrent', 'User', $1, '+447123456789', '1990-01-01', $2, 'verified_email', 'email') RETURNING id `, fmt.Sprintf("concurrent.%d@test.com", time.Now().UnixNano()), string(hash)).Scan(&userID)) t.Cleanup(func() { _, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID) }) const n = 20 var wg sync.WaitGroup for i := 0; i < n; i++ { wg.Add(1) go func() { defer wg.Done() req := httptest.NewRequest(http.MethodDelete, "/api/user/account", bytes.NewBufferString(`{"current_password":"not-the-password"}`)) req = req.WithContext(context.WithValue(context.Background(), mw.UserIDKey, userID)) rr := httptest.NewRecorder() DeleteAccountHandler(rr, req) }() } wg.Wait() var failedAttempts int var lockedUntil *time.Time require.NoError(t, db.Conn.QueryRow(ctx, `SELECT failed_attempts, locked_until FROM users WHERE id = $1`, userID).Scan(&failedAttempts, &lockedUntil)) require.Equal(t, n, failedAttempts, "every concurrent wrong password must be counted — no lost updates") require.NotNil(t, lockedUntil, "the lockout must be armed after the burst") require.True(t, lockedUntil.After(clock.Now()), "locked_until must be in the future") } // ============================================================================ // FIX 5 — passwordless (NULL password_hash) accounts // ============================================================================ // TestPasswordChange_NullHash_NoPasswordToChange verifies FIX 5b: changing the // password on a passwordless (NULL hash) account is a clear 400 with an // actionable message — not the old 500 from scanning NULL into a plain string. func TestPasswordChange_NullHash_NoPasswordToChange(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) _, err = tx.Exec(ctx, `UPDATE users SET password_hash = NULL WHERE id = $1`, userID) require.NoError(t, err) req := changePasswordRequest(t, ctx, userID, "whatever", "newpassword456") rr := httptest.NewRecorder() ChangePasswordHandler(rr, req) require.Equal(t, http.StatusBadRequest, rr.Code, rr.Body.String()) require.Contains(t, rr.Body.String(), "this account has no password to change") // The password must be untouched. var stored sql.NullString require.NoError(t, tx.QueryRow(ctx, `SELECT password_hash FROM users WHERE id = $1`, userID).Scan(&stored)) require.False(t, stored.Valid, "a passwordless account must remain passwordless after a rejected change") } // ============================================================================ // FIX 1a — CardDAV vCard deleted inside the erasure transaction // ============================================================================ // TestDeleteAccount_DavCardDeletedInErasureTx verifies FIX 1a: the dav_cards // row (full name/email/phone/DOB/photo URL PII) is deleted INSIDE the erasure // transaction — the old fire-and-forget dav.Service.DeleteContact goroutine is // gone, so the contact PII can never be stranded by a crash or a log-only // failure. func TestDeleteAccount_DavCardDeletedInErasureTx(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) uri := userID + ".vcf" _, err = tx.Exec(ctx, ` INSERT INTO dav_cards (addressbookid, uri, carddata, lastmodified, etag, size) VALUES (1, $1, 'BEGIN:VCARD', 0, '0', 0) `, uri) require.NoError(t, err) var countBefore int require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM dav_cards WHERE uri = $1`, uri).Scan(&countBefore)) require.Equal(t, 1, countBefore) req := deleteAccountRequest(t, ctx, userID, "testpassword123", "") rr := httptest.NewRecorder() DeleteAccountHandler(rr, req) require.Equal(t, http.StatusNoContent, rr.Code, rr.Body.String()) var countAfter int require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM dav_cards WHERE uri = $1`, uri).Scan(&countAfter)) require.Zero(t, countAfter, "the dav_cards row must be deleted inside the erasure transaction") } // ============================================================================ // FIX 1 (round-9) — current-password clears login lockout on success // ============================================================================ // TestPasswordChange_LockedOutUserCanRecover verifies FIX 1: a user with // locked_until set (locked out by LOGIN attacks) can still change their // password by providing the CORRECT current password. The handler runs the // bcrypt compare even when locked out, and on success clears the shared // lockout (failed_attempts = 0, locked_until = NULL). func TestPasswordChange_LockedOutUserCanRecover(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) // Simulate a login lockout: set locked_until in the future. future := clock.Now().Add(30 * time.Minute) _, err = tx.Exec(ctx, `UPDATE users SET failed_attempts = 5, locked_until = $2 WHERE id = $1`, userID, future) require.NoError(t, err) // The user is locked out but provides the CORRECT current password. req := changePasswordRequest(t, ctx, userID, "testpassword123", "newpassword456") rr := httptest.NewRecorder() ChangePasswordHandler(rr, req) require.Equal(t, http.StatusOK, rr.Code, "a locked-out user with the correct current password must be able to change their password") // The lockout was cleared on success. var failedAttempts int var lockedUntil *time.Time require.NoError(t, tx.QueryRow(ctx, `SELECT failed_attempts, locked_until FROM users WHERE id = $1`, userID).Scan(&failedAttempts, &lockedUntil)) require.Zero(t, failedAttempts, "failed_attempts must be reset to 0 after a successful password change") require.Nil(t, lockedUntil, "locked_until must be NULL after a successful password change") } // TestPasswordChange_LockedOutUserWrongPassword verifies FIX 1: a locked-out // user who provides a WRONG current password is rejected without incrementing // the counter further (the lockout stands). func TestPasswordChange_LockedOutUserWrongPassword(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) future := clock.Now().Add(30 * time.Minute) _, err = tx.Exec(ctx, `UPDATE users SET failed_attempts = 5, locked_until = $2 WHERE id = $1`, userID, future) require.NoError(t, err) req := changePasswordRequest(t, ctx, userID, "wrong-password", "newpassword456") rr := httptest.NewRecorder() ChangePasswordHandler(rr, req) require.Equal(t, http.StatusUnauthorized, rr.Code, "a locked-out user with a wrong password must be rejected") require.Contains(t, rr.Body.String(), "too many failed attempts") // The counter was NOT incremented (still 5). var failedAttempts int require.NoError(t, tx.QueryRow(ctx, `SELECT failed_attempts FROM users WHERE id = $1`, userID).Scan(&failedAttempts)) require.Equal(t, 5, failedAttempts, "failed_attempts must NOT be incremented when already locked out") } // TestDeleteAccount_LockedOutUserCanRecover verifies FIX 1: a user with // locked_until set (locked out by LOGIN attacks) can still delete their // account by providing the CORRECT current password. The lockout is cleared // on success. func TestDeleteAccount_LockedOutUserCanRecover(t *testing.T) { savedClient := s3.Client s3.Client = nil t.Cleanup(func() { s3.Client = savedClient }) ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) // Simulate a login lockout. future := clock.Now().Add(30 * time.Minute) _, err = tx.Exec(ctx, `UPDATE users SET failed_attempts = 5, locked_until = $2 WHERE id = $1`, userID, future) require.NoError(t, err) // The user is locked out but provides the CORRECT current password. req := deleteAccountRequest(t, ctx, userID, "testpassword123", "") rr := httptest.NewRecorder() DeleteAccountHandler(rr, req) require.Equal(t, http.StatusNoContent, rr.Code, "a locked-out user with the correct current password must be able to delete their account") // The lockout was cleared on success. var failedAttempts int var lockedUntil *time.Time require.NoError(t, tx.QueryRow(ctx, `SELECT failed_attempts, locked_until FROM users WHERE id = $1`, userID).Scan(&failedAttempts, &lockedUntil)) require.Zero(t, failedAttempts, "failed_attempts must be reset to 0 after a successful delete") require.Nil(t, lockedUntil, "locked_until must be NULL after a successful delete") } // TestDeleteAccount_LockedOutUserWrongPassword verifies FIX 1: a locked-out // user who provides a WRONG current password is rejected without incrementing // the counter further. func TestDeleteAccount_LockedOutUserWrongPassword(t *testing.T) { savedClient := s3.Client s3.Client = nil t.Cleanup(func() { s3.Client = savedClient }) ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) future := clock.Now().Add(30 * time.Minute) _, err = tx.Exec(ctx, `UPDATE users SET failed_attempts = 5, locked_until = $2 WHERE id = $1`, userID, future) require.NoError(t, err) req := deleteAccountRequest(t, ctx, userID, "wrong-password", "") rr := httptest.NewRecorder() DeleteAccountHandler(rr, req) require.Equal(t, http.StatusUnauthorized, rr.Code, "a locked-out user with a wrong password must be rejected") require.Contains(t, rr.Body.String(), "too many failed attempts") // The counter was NOT incremented (still 5). var failedAttempts int require.NoError(t, tx.QueryRow(ctx, `SELECT failed_attempts FROM users WHERE id = $1`, userID).Scan(&failedAttempts)) require.Equal(t, 5, failedAttempts, "failed_attempts must NOT be incremented when already locked out") } // ============================================================================ // FIX 2 (round-9) — passwordless delete-account 2FA condition // ============================================================================ // TestDeleteAccount_Passwordless_Requires2FAInEnforcedEnv verifies FIX 2: a // NULL-password-hash (social-only) account requires a 2FA code ONLY when 2FA // enforcement is active. In enforced env, the code gate protects against a // stolen session token erasing the account with zero credential proof. func TestDeleteAccount_Passwordless_Requires2FAInEnforcedEnv(t *testing.T) { twofaEnvEnforced(t) savedClient := s3.Client s3.Client = nil t.Cleanup(func() { s3.Client = savedClient }) ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) _, err = tx.Exec(ctx, `UPDATE users SET password_hash = NULL WHERE id = $1`, userID) require.NoError(t, err) // No code → rejected with the 2FA-required message. req := deleteAccountRequest(t, ctx, userID, "", "") rr := httptest.NewRecorder() DeleteAccountHandler(rr, req) require.Equal(t, http.StatusBadRequest, rr.Code, rr.Body.String()) require.Contains(t, rr.Body.String(), "a two-factor verification code is required to delete the account") // A correct fresh code deletes the account. seedPendingTwoFA(t, ctx, tx, userID, "424242") req = deleteAccountRequest(t, ctx, userID, "", "424242") rr = httptest.NewRecorder() DeleteAccountHandler(rr, req) require.Equal(t, http.StatusNoContent, rr.Code, rr.Body.String()) } // TestDeleteAccount_Passwordless_No2FARequiredInUnenforcedEnv verifies FIX 2: // in an unenforced environment (dev/test), a passwordless account can delete // without a 2FA code — the code cannot be minted (no delivery channel), and // the passwordless property plus the authenticated session are the protection. func TestDeleteAccount_Passwordless_No2FARequiredInUnenforcedEnv(t *testing.T) { twofaEnvUnenforced(t) savedClient := s3.Client s3.Client = nil t.Cleanup(func() { s3.Client = savedClient }) ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) _, err = tx.Exec(ctx, `UPDATE users SET password_hash = NULL WHERE id = $1`, userID) require.NoError(t, err) // No code required — the delete succeeds with just the authenticated session. req := deleteAccountRequest(t, ctx, userID, "", "") rr := httptest.NewRecorder() DeleteAccountHandler(rr, req) require.Equal(t, http.StatusNoContent, rr.Code, rr.Body.String()) // The account was anonymized (anonymize_user() sets name to 'Deleted'). var firstName string require.NoError(t, tx.QueryRow(ctx, `SELECT n_first_name FROM users WHERE id = $1`, userID).Scan(&firstName)) require.Equal(t, "Deleted", firstName, "the passwordless account must be anonymized") }