fix: admin notification flood caps (C5) at every remaining insert site; gift-card expiry-sweep TOCTOU (M4)

- adminnotify: MaxUnacknowledgedCriticalLogs global cap exposed as
  CriticalLogsCapExceeded — a pre-check helper every insert site pairs with the
  atomic fold inside its INSERT (count-then-insert is atomic, closing the
  TOCTOU where concurrent inserts could both read a below-cap count).
- jobs/cleanup.go ScanCriticalPaymentLogs: capped at the shared cap, pre-check
  skips the scan and logs the suppression.
- scheduling: 1_week_no_pay, 1_month_no_pay, default_hours_changed,
  deposit_not_paid_by_deadline and the Square-erasure critical notification all
  flood-capped with pre-check + atomic fold (per-booking/per-user dedup kept).
- time-blockers.go CleanupExpiredGiftCards (M4): the expiry SELECT now runs
  under FOR UPDATE row locks so the read-expired-then-zero window is atomic —
  a concurrent top-up either commits before the SELECT (refreshed last_used_at
  drops the card out of the predicate) or blocks until the sweep's tx ends and
  revives the zeroed card via its own expiry refresh; the top-up value can
  never be destroyed by the sweep.
- flood-cap tests added for 1_week_no_pay; adminnotify unit coverage added.
This commit is contained in:
2026-08-22 00:34:50 +01:00
parent 16304bd295
commit 69a854d857
8 changed files with 553 additions and 30 deletions
@@ -0,0 +1,72 @@
//go:build test
package adminnotify
import (
"context"
"os"
"testing"
"crussell/db"
"crussell/testutils/testdb"
)
func TestMain(m *testing.M) {
pool := testdb.CreateTestDatabase("crussell_test_adminnotify")
db.Conn = db.NewPoolProxy(pool)
code := m.Run()
testdb.DestroyTestDatabase(pool, "crussell_test_adminnotify")
os.Exit(code)
}
// TestMaxUnacknowledgedCriticalLogs_Value pins the exported flood-cap constant
// that every capped insert site folds into its SQL (changing it re-scopes every
// site at once, so the value is part of the shared contract).
func TestMaxUnacknowledgedCriticalLogs_Value(t *testing.T) {
if MaxUnacknowledgedCriticalLogs != 100 {
t.Errorf("expected MaxUnacknowledgedCriticalLogs to be 100, got %d", MaxUnacknowledgedCriticalLogs)
}
}
// TestCriticalLogsCapExceeded verifies the exported pre-check that logs a
// suppression: below the cap it reports false, at the cap true, and
// acknowledging rows re-arms it.
func TestCriticalLogsCapExceeded(t *testing.T) {
ctx := context.Background()
t.Cleanup(func() {
_, _ = db.Conn.Exec(ctx, "DELETE FROM admin_notifications WHERE reason = 'critical_payment_log'")
})
if CriticalLogsCapExceeded(ctx, db.Conn, "critical_payment_log") {
t.Fatal("expected the empty queue to be below the cap")
}
for i := 0; i < MaxUnacknowledgedCriticalLogs-1; i++ {
if _, err := db.Conn.Exec(ctx, `
INSERT INTO admin_notifications (reason, created_at)
VALUES ('critical_payment_log', NOW())
`); err != nil {
t.Fatalf("failed to insert row %d: %v", i, err)
}
}
if CriticalLogsCapExceeded(ctx, db.Conn, "critical_payment_log") {
t.Fatalf("expected %d unacknowledged rows to stay below the cap", MaxUnacknowledgedCriticalLogs-1)
}
if _, err := db.Conn.Exec(ctx, `
INSERT INTO admin_notifications (reason, created_at)
VALUES ('critical_payment_log', NOW())
`); err != nil {
t.Fatalf("failed to insert the cap row: %v", err)
}
if !CriticalLogsCapExceeded(ctx, db.Conn, "critical_payment_log") {
t.Fatalf("expected %d unacknowledged rows to be at the cap", MaxUnacknowledgedCriticalLogs)
}
if _, err := db.Conn.Exec(ctx, "UPDATE admin_notifications SET acknowledged_at = NOW() WHERE reason = 'critical_payment_log'"); err != nil {
t.Fatalf("failed to acknowledge the queue: %v", err)
}
if CriticalLogsCapExceeded(ctx, db.Conn, "critical_payment_log") {
t.Fatal("expected acknowledging the queue to re-arm inserts")
}
}