- tip gate: CreateTipPayment saved-card 2FA gate now has scaTokenizedSavedCard skip matching every other charge surface (booking, terminal, gift-card); isSCATokenizeResultShape escape added to tip SAVE gate - webhook: align UPDATE clears VAT fields before re-apply (matches sweep rescue); 503 unknown-event tracking with 24h timeout notification via square_webhook_events table - cash-tip: cashChargeBasePence no longer restores campaign or subtracts loyalty — overcharge and tip shortfall fixed; 2FA dead code remnants removed from gift-card buy flow; TwoFactorCodeInput help text deconfused; refund pre-fill unit mismatch fixed (pounds vs pence); SCA buyer names split from full_name; passwordless delete UI accepts empty password - lockout: successful current-password clears shared failed_attempts/locked_until (victim can recover from login lockout via password change); passwordless delete condition changed to require 2FA only in enforced env - erasure: stale-guest batch erasure persists Square card/customer targets to durable outbox before NULLing them (crash-safe); S3 deletion retry capped at 10 attempts with admin notification; S3_PROFILE_PICS_BUCKET startup check added - env parsing: IsExplicitDevOrMockEnv and Square HTTP client base-URL switch now normalize (ToLower+TrimSpace) for consistency - auth: change-password/delete-account get per-user rate limiters (10/min); consume param dead code suppressed with TODO - frontend: 2FA/SCA dead code removed from gift-card buy flow, TwoFactorCodeInput help text fixed, refund pre-fill unit mismatch fixed, buyer names populated from full_name Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
536 lines
23 KiB
Go
536 lines
23 KiB
Go
//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")
|
|
}
|