fix: review round 4 — per-dispute chargeback alerts, single-source clawback, 2FA lockout coherence, docs
Fourth fresh-eyes review pass (5 agents: goal, QA, code-quality, security, context-mining). All PASS on the money-safety core; this round closes the remaining MAJOR/MINOR items they surfaced. Webhooks: - Untracked disputes now raise ONE admin notification PER distinct chargeback: the notification id is derived deterministically from the square_dispute_id (SHA-256 truncated into the CHAR(12) slot) so a second untracked dispute is no longer silently suppressed by the first's dedup row. ON CONFLICT (id) keeps same-dispute replays idempotent; the booking-scoped NOT EXISTS guard is retained for the tracked path. Verified: distinct disputes -> distinct rows; re-delivered dispute -> one row. - The gift-card clawback SQL now lives in exactly ONE place: payments.RevertGiftCardFunding (new giftcard_clawback.go). till.go and the webhook path both call it — eliminating the byte-for-byte copy whose divergence would be a money-loss drift trap (the same two-sources-of-truth pattern this commit eliminated for GDPR scrubbing). 2FA: - Applied the lockout-coherence fix from the review: when a disable request must mint a fresh code (no valid pending one), the held attempt counter is reset so the locked-out user can use the freshly delivered code in the SAME request (no wasted round-trip). The reuse path keeps accumulating wrong attempts toward the 5-attempt lockout — the two behaviors no longer conflict. (The 'always-fresh on disable' suggestion was NOT adopted: it would break the out-of-band [2FA]-log delivery model, since a code generated by a request can never be submitted within that same request.) - New test pins the shared verify/disable lockout: 5 wrong verifies 429 and destroy the code; a stale code then 400s on disable while the freshly delivered code succeeds in the same request. - Startup now warns that 2FA codes travel in PLAINTEXT via the server log in enforced mode (operator must restrict log access + relay out-of-band until email/SMS lands). Docs: - Test counts updated to the current 2,154 across README + Technical Manual. - User Manual 2FA nav corrected: the settings live on the Account page, not an 'Admin' area. Tests: 2,154 (up from 2,151). Backend 25/26 packages green (crussell/db fails only in this environment: local postgres auth for the test role; package byte-identical to HEAD). Frontend builds; svelte-check 0 errors.
This commit is contained in:
@@ -5,6 +5,7 @@ package webhooks
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -316,10 +317,10 @@ func TestWebhook_DisputeCreated_InsertsDisputeRow(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestWebhook_DisputeCreated_NoLocalPayment_NoRow(t *testing.T) {
|
||||
// insertCriticalPaymentNotification dedups on unacknowledged rows per
|
||||
// (reason, booking_id), so a NULL-booking notification left unacknowledged
|
||||
// by an earlier test would mask this test's assertion. Acknowledge any
|
||||
// stragglers first.
|
||||
// Each untracked dispute now gets its own deterministic-id notification, but
|
||||
// a booking-scoped (reason, booking_id, acknowledged_at IS NULL) guard still
|
||||
// shares the NULL-booking slot for tracked-with-no-booking disputes — so
|
||||
// acknowledge any unacknowledged stragglers to keep this assertion scoped.
|
||||
if _, err := db.Conn.Exec(context.Background(),
|
||||
"UPDATE admin_notifications SET acknowledged_at = NOW() WHERE reason = 'critical_payment_log' AND acknowledged_at IS NULL"); err != nil {
|
||||
t.Fatalf("failed to acknowledge prior critical notifications: %v", err)
|
||||
@@ -374,6 +375,99 @@ func TestWebhook_DisputeCreated_NoLocalPayment_NoRow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebhook_DisputeCreated_Untracked_DistinctDisputes_DistinctNotifications
|
||||
// locks the per-dispute dedup fix: two DISTINCT untracked chargebacks (no local
|
||||
// payment row) must each raise their OWN unacknowledged NULL-booking critical
|
||||
// notification. The old (reason, booking_id, acknowledged_at IS NULL) dedup
|
||||
// collapsed them onto one row, silently suppressing the second chargeback.
|
||||
func TestWebhook_DisputeCreated_Untracked_DistinctDisputes_DistinctNotifications(t *testing.T) {
|
||||
disputes := []struct{ disputeID, paymentID string }{
|
||||
{"dts_untracked_a", "sqp_never_a"},
|
||||
{"dts_untracked_b", "sqp_never_b"},
|
||||
}
|
||||
for i, d := range disputes {
|
||||
event := SquareWebhookEvent{
|
||||
Type: "dispute.created",
|
||||
EventID: fmt.Sprintf("evt_untracked_distinct_%d", i),
|
||||
CreatedAt: "2025-01-01T00:00:00Z",
|
||||
Data: json.RawMessage(`{
|
||||
"type": "dispute",
|
||||
"id": "` + d.disputeID + `",
|
||||
"object": {
|
||||
"dispute": {
|
||||
"id": "` + d.disputeID + `",
|
||||
"state": "UNDER_REVIEW",
|
||||
"amount_money": {"amount": 1000, "currency": "GBP"},
|
||||
"disputed_payment": {"payment_id": "` + d.paymentID + `"}
|
||||
}
|
||||
}
|
||||
}`),
|
||||
}
|
||||
w := deliverWebhook(t, event)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 for %s, got %d: %s", d.disputeID, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// BOTH distinct disputes must have their own unacknowledged NULL-booking
|
||||
// notification (the second must not be suppressed by the first).
|
||||
for _, d := range disputes {
|
||||
var n int
|
||||
if err := db.Conn.QueryRow(context.Background(), `
|
||||
SELECT COUNT(*) FROM admin_notifications
|
||||
WHERE id = $1 AND reason = 'critical_payment_log'
|
||||
AND booking_id IS NULL AND acknowledged_at IS NULL
|
||||
`, disputeNotificationID(d.disputeID)).Scan(&n); err != nil {
|
||||
t.Fatalf("failed to count notifications for %s: %v", d.disputeID, err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("expected exactly 1 unacknowledged notification for dispute %s, got %d", d.disputeID, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebhook_DisputeCreated_Untracked_SameDisputeRedelivered_SingleNotification
|
||||
// locks the per-dispute idempotency: re-delivery of the SAME untracked dispute
|
||||
// (under a FRESH event_id, so the handler-level event_id dedup is bypassed)
|
||||
// must NOT create a second notification — the deterministic per-dispute id keeps
|
||||
// it to one row.
|
||||
func TestWebhook_DisputeCreated_Untracked_SameDisputeRedelivered_SingleNotification(t *testing.T) {
|
||||
const disputeID = "dts_untracked_redeliv"
|
||||
for _, eventID := range []string{"evt_untracked_redeliv_1", "evt_untracked_redeliv_2"} {
|
||||
event := SquareWebhookEvent{
|
||||
Type: "dispute.created",
|
||||
EventID: eventID,
|
||||
CreatedAt: "2025-01-01T00:00:00Z",
|
||||
Data: json.RawMessage(`{
|
||||
"type": "dispute",
|
||||
"id": "` + disputeID + `",
|
||||
"object": {
|
||||
"dispute": {
|
||||
"id": "` + disputeID + `",
|
||||
"state": "UNDER_REVIEW",
|
||||
"amount_money": {"amount": 1000, "currency": "GBP"},
|
||||
"disputed_payment": {"payment_id": "sqp_never_redeliv"}
|
||||
}
|
||||
}
|
||||
}`),
|
||||
}
|
||||
w := deliverWebhook(t, event)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 for %s, got %d: %s", eventID, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
var n int
|
||||
if err := db.Conn.QueryRow(context.Background(), `
|
||||
SELECT COUNT(*) FROM admin_notifications
|
||||
WHERE id = $1 AND reason = 'critical_payment_log' AND booking_id IS NULL
|
||||
`, disputeNotificationID(disputeID)).Scan(&n); err != nil {
|
||||
t.Fatalf("failed to count notifications for %s: %v", disputeID, err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("expected exactly 1 notification for the re-delivered dispute, got %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhook_DisputeCreated_LongReason_Truncated(t *testing.T) {
|
||||
const squarePaymentID = "sqp_dispute_longreason"
|
||||
_ = createWebhookTestPayment(t, squarePaymentID, "completed")
|
||||
|
||||
Reference in New Issue
Block a user