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:
@@ -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 {
|
||||
|
||||
@@ -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(¬ifCount); 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user