fix: round-3 — tip gate asymmetry, webhook VAT align + 503 notifications, cash-tip campaign overcharge, lockout DoS, erasure durability, S3 retry cap, env parsing, per-user rate limiters, consume dead code, frontend 2FA remnants

- 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)
This commit is contained in:
2026-08-22 00:34:51 +01:00
co-authored by Sisyphus
parent fba00a10ad
commit 9a12a2d886
27 changed files with 1206 additions and 226 deletions
+32 -10
View File
@@ -593,6 +593,12 @@ func RetryPendingSquareErasures(ctx context.Context) (int, error) {
return len(drained), nil
}
// maxS3DeletionRetries is the cap on retry attempts for a pending S3 deletion
// outbox row. After this many consecutive failures the row is marked as
// final-failed and a critical admin notification is raised — the operator must
// investigate and delete the object manually.
const maxS3DeletionRetries = 10
// RetryPendingS3Deletions is the durable safety net for the account-deletion
// S3/R2 profile-picture outbox (Fault A2). DeleteAccountHandler persists the
// deletion target (bucket + object key) inside its anonymization transaction,
@@ -601,8 +607,10 @@ func RetryPendingSquareErasures(ctx context.Context) (int, error) {
// the object store indefinitely — this job finds the pending rows and retries
// the deletion so the erasure is eventually complete. On success it deletes
// the outbox row; on failure it leaves the row in place (bumping attempts and
// recording the error) so the next run retries again. Returns the number of
// outbox rows drained.
// recording the error) so the next run retries again. After maxS3DeletionRetries
// consecutive failures the row is marked as final-failed and a critical admin
// notification is raised — the operator must investigate and delete the object
// manually. Returns the number of outbox rows drained.
func RetryPendingS3Deletions(ctx context.Context) (int, error) {
if s3.Client == nil {
// No object store configured: no deletion is possible, and the handler
@@ -612,24 +620,26 @@ func RetryPendingS3Deletions(ctx context.Context) (int, error) {
client := s3.Client
rows, err := db.Conn.Query(ctx, `
SELECT id, bucket, object_key
SELECT id, bucket, object_key, attempts
FROM pending_s3_deletions
WHERE attempts < $1
ORDER BY created_at
`)
`, maxS3DeletionRetries)
if err != nil {
return 0, fmt.Errorf("failed to query pending S3 deletions: %w", err)
}
defer rows.Close()
type pendingDelete struct {
id string
bucket string
key string
id string
bucket string
key string
attempts int
}
var pending []pendingDelete
for rows.Next() {
var p pendingDelete
if err := rows.Scan(&p.id, &p.bucket, &p.key); err != nil {
if err := rows.Scan(&p.id, &p.bucket, &p.key, &p.attempts); err != nil {
return 0, fmt.Errorf("failed to scan pending S3 deletion: %w", err)
}
pending = append(pending, p)
@@ -647,13 +657,25 @@ func RetryPendingS3Deletions(ctx context.Context) (int, error) {
err := client.Delete(actx, p.bucket, p.key)
cancel()
if err != nil {
// Truncate the error to 200 chars to prevent unbounded growth.
errMsg := err.Error()
if len(errMsg) > 200 {
errMsg = errMsg[:200]
}
newAttempts := p.attempts + 1
if _, uerr := db.Conn.Exec(ctx, `
UPDATE pending_s3_deletions SET attempts = attempts + 1, last_error = $2
UPDATE pending_s3_deletions SET attempts = $2, last_error = $3
WHERE id = $1
`, p.id, err.Error()); uerr != nil {
`, p.id, newAttempts, errMsg); uerr != nil {
return drained, fmt.Errorf("failed to record S3 deletion retry failure %s: %w", p.id, uerr)
}
log.Printf("Warning: retry-s3-deletions failed to delete profile picture %s (outbox %s): %v", p.key, p.id, err)
// After maxS3DeletionRetries consecutive failures, raise a critical
// admin notification and stop retrying — the operator must investigate.
if newAttempts >= maxS3DeletionRetries {
user.InsertSquareErasureCriticalNotification(ctx, "s3:"+p.id)
log.Printf("CRITICAL: retry-s3-deletions exhausted %d attempts for outbox %s (key %s) — raising admin notification; operator must delete the object manually", maxS3DeletionRetries, p.id, p.key)
}
continue
}
if _, err := db.Conn.Exec(ctx, `DELETE FROM pending_s3_deletions WHERE id = $1`, p.id); err != nil {
+137
View File
@@ -7,6 +7,7 @@ import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"sync"
"testing"
@@ -14,6 +15,7 @@ import (
"crussell/db"
"crussell/handlers/payments"
"crussell/internal/adminnotify"
"crussell/internal/s3"
"crussell/internal/square"
"crussell/testutils"
"crussell/testutils/testdb"
@@ -680,3 +682,138 @@ func TestRetryPendingSquareErasures_KeepsSharedCustomerReferencedByActiveCard(t
t.Errorf("expected active row to keep its customer reference, got %v", id)
}
}
// ============================================================
// RetryPendingS3Deletions — S3/R2 outbox job (FIX 2)
// ============================================================
// failingS3Uploader always fails Delete with an over-long error so tests can
// verify the retry cap and last_error truncation.
type failingS3Uploader struct{}
func (failingS3Uploader) Upload(ctx context.Context, bucket, key string, body io.Reader, contentType string) error {
return nil
}
func (failingS3Uploader) Download(ctx context.Context, bucket, key string, w io.Writer) error {
return nil
}
func (failingS3Uploader) Delete(ctx context.Context, bucket, key string) error {
return fmt.Errorf("simulated persistent S3 deletion failure: this error message is deliberately much longer than two hundred characters so the truncation bound in RetryPendingS3Deletions must cut it off; otherwise the last_error column grows without bound across every hourly retry run")
}
func (failingS3Uploader) GetURL(ctx context.Context, bucket, key string) (string, error) {
return "", nil
}
func (failingS3Uploader) HealthCheck(ctx context.Context) error {
return nil
}
// TestRetryPendingS3Deletions_AttemptCapRaisesNotification verifies FIX 2: a
// pending S3 deletion outbox row that fails maxS3DeletionRetries consecutive
// times stops being retried (the job's query filters attempts < cap), bumps
// attempts to the cap, truncates last_error to 200 chars, and raises the
// deduped critical admin notification so the operator investigates.
func TestRetryPendingS3Deletions_AttemptCapRaisesNotification(t *testing.T) {
ctx := context.Background()
var rowID string
if err := db.Conn.QueryRow(ctx, `
INSERT INTO pending_s3_deletions (user_id, bucket, object_key, attempts, last_error)
VALUES (NULL, 'test-bucket', 'profiles/test.jpg', 9, 'previous error')
RETURNING id
`).Scan(&rowID); err != nil {
t.Fatalf("failed to seed pending S3 deletion: %v", err)
}
t.Cleanup(func() {
_, _ = db.Conn.Exec(ctx, "DELETE FROM pending_s3_deletions WHERE id = $1", rowID)
_, _ = db.Conn.Exec(ctx, "DELETE FROM admin_notifications WHERE reason = 'critical_payment_log'")
})
origClient := s3.Client
s3.Client = failingS3Uploader{}
t.Cleanup(func() { s3.Client = origClient })
n, err := RetryPendingS3Deletions(ctx)
if err != nil {
t.Fatalf("RetryPendingS3Deletions failed: %v", err)
}
if n != 0 {
t.Errorf("expected 0 drained rows on persistent failure, got %d", n)
}
var attempts int
var lastError string
if err := db.Conn.QueryRow(ctx, `
SELECT attempts, last_error FROM pending_s3_deletions WHERE id = $1
`, rowID).Scan(&attempts, &lastError); err != nil {
t.Fatalf("failed to query outbox row: %v", err)
}
if attempts != maxS3DeletionRetries {
t.Errorf("expected attempts capped at %d, got %d", maxS3DeletionRetries, attempts)
}
if len(lastError) > 200 {
t.Errorf("expected last_error truncated to 200 chars, got %d", len(lastError))
}
// The critical notification must have been raised (deduped by id).
var notifCount int
if err := db.Conn.QueryRow(ctx, `
SELECT COUNT(*) FROM admin_notifications WHERE reason = 'critical_payment_log'
`).Scan(&notifCount); err != nil {
t.Fatalf("failed to count critical notifications: %v", err)
}
if notifCount != 1 {
t.Errorf("expected 1 critical notification after reaching the cap, got %d", notifCount)
}
// No more retries: a second run must not pick the row up (attempts < cap
// filters it out), so attempts stays at the cap.
n, err = RetryPendingS3Deletions(ctx)
if err != nil {
t.Fatalf("second RetryPendingS3Deletions failed: %v", err)
}
if n != 0 {
t.Errorf("expected 0 rows retried once at the cap, got %d", n)
}
var after int
if err := db.Conn.QueryRow(ctx, `SELECT attempts FROM pending_s3_deletions WHERE id = $1`, rowID).Scan(&after); err != nil {
t.Fatalf("failed to query attempts after second run: %v", err)
}
if after != maxS3DeletionRetries {
t.Errorf("expected attempts unchanged at %d after the cap, got %d", maxS3DeletionRetries, after)
}
}
// TestRetryPendingS3Deletions_NoOpWithoutClient verifies the job is a no-op when
// no object store client is configured — no outbox rows are read or modified.
func TestRetryPendingS3Deletions_NoOpWithoutClient(t *testing.T) {
ctx := context.Background()
var rowID string
if err := db.Conn.QueryRow(ctx, `
INSERT INTO pending_s3_deletions (user_id, bucket, object_key)
VALUES (NULL, 'test-bucket', 'profiles/test.jpg')
RETURNING id
`).Scan(&rowID); err != nil {
t.Fatalf("failed to seed pending S3 deletion: %v", err)
}
t.Cleanup(func() {
_, _ = db.Conn.Exec(ctx, "DELETE FROM pending_s3_deletions WHERE id = $1", rowID)
})
origClient := s3.Client
s3.Client = nil
t.Cleanup(func() { s3.Client = origClient })
n, err := RetryPendingS3Deletions(ctx)
if err != nil {
t.Fatalf("RetryPendingS3Deletions failed: %v", err)
}
if n != 0 {
t.Errorf("expected 0 drained rows without a client, got %d", n)
}
var attempts int
if err := db.Conn.QueryRow(ctx, `SELECT attempts FROM pending_s3_deletions WHERE id = $1`, rowID).Scan(&attempts); err != nil {
t.Fatalf("failed to query outbox row: %v", err)
}
if attempts != 0 {
t.Errorf("expected outbox row untouched without a client, got attempts=%d", attempts)
}
}
+7
View File
@@ -17,6 +17,13 @@ var Client Uploader
// uses the in-memory fallback client.
var FallbackToInMemory bool
// ClientIsStub reports whether the active client is the production stub that
// cannot perform real S3 operations. It is always true in the !dev build:
// every operation (Upload, Download, Delete) returns "not implemented" because
// the AWS SDK v2 is not compiled in. main.go uses it to warn at startup so the
// operator knows that profile-picture deletion will never succeed in this build.
var ClientIsStub = true
type Uploader interface {
Upload(ctx context.Context, bucket, key string, body io.Reader, contentType string) error
Download(ctx context.Context, bucket, key string, w io.Writer) error
+5
View File
@@ -26,6 +26,11 @@ var Client Uploader
// main.go can surface it from the health endpoint in both build variants.
var FallbackToInMemory bool
// ClientIsStub mirrors the prod-build flag so main.go can reference it in dev
// builds. It is always false here: the dev build compiles the real AWS SDK
// client and is never the "not implemented" stub.
var ClientIsStub = false
type Uploader interface {
Upload(ctx context.Context, bucket, key string, body io.Reader, contentType string) error
Download(ctx context.Context, bucket, key string, w io.Writer) error
+34
View File
@@ -0,0 +1,34 @@
//go:build !dev
package s3
import (
"context"
"strings"
"testing"
)
// TestProdClientIsStub verifies the production (!dev) build's S3 client is
// flagged as the stub: main.go uses ClientIsStub to warn at startup that
// object-store operations (including profile-picture deletion) cannot perform
// real work in this build.
func TestProdClientIsStub(t *testing.T) {
if !ClientIsStub {
t.Error("expected ClientIsStub to be true in the !dev build (the client is the 'not implemented' stub)")
}
}
// TestProdStubDeleteNotImplemented verifies the prod stub's Delete always fails
// with a "not implemented" error — the retry-s3-deletions job cannot drain any
// outbox row in this build, so the attempt cap + critical notification is the
// only signal the operator gets.
func TestProdStubDeleteNotImplemented(t *testing.T) {
c := &S3Client{bucket: "b", endpoint: "http://localhost:9000"}
err := c.Delete(context.Background(), "test-bucket", "profiles/x.jpg")
if err == nil {
t.Fatal("expected the prod stub Delete to return an error")
}
if !strings.Contains(err.Error(), "not implemented") {
t.Errorf("expected the prod stub Delete error to say 'not implemented', got: %v", err)
}
}
@@ -92,7 +92,7 @@ func SquareLocationID() string {
}
func newHTTPClient() *httpClient {
env := SquareEnvironment()
env := strings.ToLower(strings.TrimSpace(SquareEnvironment()))
baseURL := squareSandboxURL
// Empty/unknown SQUARE_ENVIRONMENT is production-ENFORCED, mirroring
// payments.IsExplicitDevOrMockEnv()'s fail-closed interpretation (an
@@ -2170,6 +2170,11 @@ func TestNewHTTPClient_BaseURLSelection(t *testing.T) {
{name: "sandbox_env", env: "sandbox", want: squareSandboxURL},
{name: "mock_env", env: "mock", want: squareSandboxURL},
{name: "dev_env", env: "dev", want: squareSandboxURL},
// FIX 5: env normalization — lowercased + trimmed before comparison.
{name: "uppercase_production_is_production", env: "PRODUCTION", want: squareProductionURL},
{name: "trailing_space_production_is_production", env: "Production ", want: squareProductionURL},
{name: "uppercase_sandbox_is_sandbox", env: "SANDBOX", want: squareSandboxURL},
{name: "surrounded_space_mock_is_sandbox", env: " Mock ", want: squareSandboxURL},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {