Files
Crussell/backend/main_test.go
T
popertots 5e3dc9b428 fix: comprehensive payment system hardening (4 review passes)
CRITICAL fixes:
- C1: JWT exp claim now validated via jwtauth.VerifyToken (was Decode)
- C2: OverrideAmount validated post-substitution (prevents negative money minting)
- C3: Terminal gift-card payments store gift_card_id; refund credits user balance
- C4: Refund dedup returns stored amount, not req.Amount (prevents admin mislead)
- C5: Booking recheck uses FOR UPDATE (prevents TOCTOU with cancellation)
- C6: processChargeGroup idempotency key stable (charge-only, prevents double-refund)

MAJOR fixes:
- M2: Gift-card refund UPDATE checks RowsAffected; 0 rows -> failed
- M3: ProcessCancellationRefund returns commit error (was swallowed)
- M5: Dispute webhook handling (created + state.updated + disputes table)

MEDIUM fixes:
- ME1: CORS restricted to FRONTEND_ORIGIN env var (was reflect-any)
- ME2: anonymize_user() scrubs users.notes, bookings.notes, name_history, refresh_tokens
- ME3: Webhook handlers now mutate state (payment.updated, refund.updated)

Frontend fixes:
- Same-key retry on 503 (ambiguous failure) wired to all 8 payment flows
- CHARGE_AND_STORE intent for save-card flows (SCA compliance)
- Nonce staleness check verified across all flows

Additional fixes from adversarial re-review:
- F1: Till-sale completed dedup echoes stored amount (C4-class)
- F2: Cash/giftcard terminal path uses FOR UPDATE (C5-class)
- F3: Square-success UPDATE checks RowsAffected (till sales)
- F4: Dispute reason truncated to 192 chars (prevents INSERT failure)
- F5: Booking-user lookup failure marks refund failed (prevents silent money loss)
- F6: Saved-card/tip rechecks wrapped in transaction (C5 residual)

Tests:
- 15 adversarial attack tests (negative override, zero override, terminal gift card,
  refund dedup, TOCTOU, deleted gift card, advisory lock, overcharge, zero/negative/huge
  amount, raw PAN, missing auth, gift card balance, concurrent refunds)
- 14 webhook state tests (dispute created/state, payment/refund updated)
- 3 CORS tests, 3 GDPR tests, 1 HTTP timeout test
- Full suite passes with -race (25 packages, 0 failures)

25 files changed, +1532/-275 lines
2026-08-22 00:34:49 +01:00

186 lines
5.7 KiB
Go

//go:build test
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"crussell/db"
)
func TestHealthCheck_OK(t *testing.T) {
// Create request and recorder
req := httptest.NewRequest(http.MethodGet, "/api/health", nil)
w := httptest.NewRecorder()
// Call handler directly
healthCheckHandler(w, req)
// Assert 200 OK
if w.Code != http.StatusOK {
t.Errorf("expected status %d, got %d. body: %s", http.StatusOK, w.Code, w.Body.String())
}
// Parse JSON response
var response map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("failed to parse JSON response: %v", err)
}
// Assert status == "ok"
status, ok := response["status"].(string)
if !ok || status != "ok" {
t.Errorf("expected status 'ok', got '%v'", response["status"])
}
// Assert services
services, ok := response["services"].(map[string]interface{})
if !ok {
t.Fatalf("services not found in response")
}
// Assert services.backend == "ok"
backend, ok := services["backend"].(string)
if !ok || backend != "ok" {
t.Errorf("expected services.backend 'ok', got '%v'", services["backend"])
}
// Assert services.database == "ok"
database, ok := services["database"].(string)
if !ok || database != "ok" {
t.Errorf("expected services.database 'ok', got '%v'", services["database"])
}
}
func TestHealthCheck_Degraded(t *testing.T) {
// Set db.Conn to nil to simulate degraded state
originalDB := db.Conn
db.Conn = nil
// Create request and recorder
req := httptest.NewRequest(http.MethodGet, "/api/health", nil)
w := httptest.NewRecorder()
// Call handler directly
healthCheckHandler(w, req)
// Assert 503 Service Unavailable
if w.Code != http.StatusServiceUnavailable {
t.Errorf("expected status %d, got %d. body: %s", http.StatusServiceUnavailable, w.Code, w.Body.String())
}
// Parse JSON response
var response map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("failed to parse JSON response: %v", err)
}
// Assert status == "degraded"
status, ok := response["status"].(string)
if !ok || status != "degraded" {
t.Errorf("expected status 'degraded', got '%v'", response["status"])
}
// Assert services
services, ok := response["services"].(map[string]interface{})
if !ok {
t.Fatalf("services not found in response")
}
// Assert services.database == "error"
database, ok := services["database"].(string)
if !ok || database != "error" {
t.Errorf("expected services.database 'error', got '%v'", services["database"])
}
// Restore original db.Conn
db.Conn = originalDB
}
func TestCORS_OnlyAllowedOrigins(t *testing.T) {
t.Setenv("FRONTEND_ORIGIN", "https://app.example.com, http://localhost:5173")
handler := corsMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
tests := []struct {
name string
origin string
expectAllowed bool
}{
{name: "first configured origin is allowed", origin: "https://app.example.com", expectAllowed: true},
{name: "second configured origin is allowed", origin: "http://localhost:5173", expectAllowed: true},
{name: "unlisted origin is rejected", origin: "https://evil.example.com", expectAllowed: false},
{name: "prefix-confusion origin is rejected", origin: "https://app.example.com.evil.test", expectAllowed: false},
{name: "suffix-attack origin is rejected", origin: "https://app.example.com/evil", expectAllowed: false},
{name: "no origin header is not echoed", origin: "", expectAllowed: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/health", nil)
if tt.origin != "" {
req.Header.Set("Origin", tt.origin)
}
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
got := rr.Header().Get("Access-Control-Allow-Origin")
if tt.expectAllowed && got != tt.origin {
t.Errorf("expected Access-Control-Allow-Origin %q, got %q", tt.origin, got)
}
if !tt.expectAllowed && got != "" {
t.Errorf("expected no Access-Control-Allow-Origin header, got %q", got)
}
})
}
}
func TestCORS_DefaultOriginWhenEnvUnset(t *testing.T) {
t.Setenv("FRONTEND_ORIGIN", "")
handler := corsMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
allowed := httptest.NewRequest(http.MethodGet, "/", nil)
allowed.Header.Set("Origin", "http://localhost:5173")
aw := httptest.NewRecorder()
handler.ServeHTTP(aw, allowed)
if got := aw.Header().Get("Access-Control-Allow-Origin"); got != "http://localhost:5173" {
t.Errorf("expected default dev origin to be allowed, got %q", got)
}
evil := httptest.NewRequest(http.MethodGet, "/", nil)
evil.Header.Set("Origin", "https://evil.example.com")
ew := httptest.NewRecorder()
handler.ServeHTTP(ew, evil)
if got := ew.Header().Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("expected unlisted origin to be rejected with default config, got %q", got)
}
}
func TestCORSPreflight_RejectsUnlistedOrigin(t *testing.T) {
t.Setenv("FRONTEND_ORIGIN", "https://app.example.com")
handler := corsMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodOptions, "/api/bookings", nil)
req.Header.Set("Origin", "https://evil.example.com")
req.Header.Set("Access-Control-Request-Method", "POST")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusNoContent {
t.Errorf("expected preflight 204, got %d", rr.Code)
}
if got := rr.Header().Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("expected no Access-Control-Allow-Origin on preflight for unlisted origin, got %q", got)
}
}