Files
Crussell/backend/main_test.go
T
popertots 6d82535780 fix: adversarial review round — replay-rescue double-charge, discount credit, 2FA/per-IP limits, snapshot encryption, refund reconciliation, VAT, frontend parity, tests+docs
Addresses the adversarial fresh-eyes audit (findings A1-A20) plus review-round fixes:
- CRITICAL A1: replay-by-key rescue cross-checks replayed CreatedAt; ccof blind-fail leaves pending with CRITICAL + notification instead of clawing back
- A2/A3/A4: till idempotency key restored to unconditional hash; tip rejected in CreateBookingPayment; campaign discount now reduces the charged amount (deposit credit)
- A5: admin notifications on blind-fail, manual-refund re-arm, cap-stranded charge-group, webhook FAILED/REJECTED refunds
- A6/A10: BuyGiftCard idempotency user-scoped; gift-card slot scan advances past failed rows
- A7/A14/A15: 2FA user+IP limiter, SNAPSHOT_ENC_KEY startup validation, accurate pepper/log-delivery docs
- A8/A9: snapshot encryption on all write+reuse sites; MPV->SPV effective voucher type (single VAT point)
- A11/A12/A13/A16: amount-aware refund reconciliation; completed-booking refund re-validation; till retry dedup; PaymentWasRefunded on SquareClient interface
- A17/A18/A19/A20: CI runs npm test; confirm_overflow_tip frontend dialog; unknown-event admin notification; mock token redaction
- M7 ConfirmOverflowTip, M9 snapshot encryption, C1 discount ordering regression test
- Frontend vitest framework (41 tests), backend coverage for fixed functions, docs corrected (2,269 tests, SUPPORT_EMAIL tokens, resolution status)

All 25 backend packages pass; frontend 41/41; build + env-docs green.
2026-08-22 00:34:50 +01:00

265 lines
8.3 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)
}
}
// ============================================================
// isWeakJWTSecret — fail-closed JWT signing-key guard (batch-1 fix)
// ============================================================
// TestIsWeakJWTSecret_WeakSecrets verifies known placeholder/example values
// (publicly documented in .env.example, READMEs, or attack tooling) are always
// rejected even when they clear the 32-character minimum.
func TestIsWeakJWTSecret_WeakSecrets(t *testing.T) {
weak := []string{
"password",
"secret",
"changeme",
"change-me",
"changethis",
"CHANGE_ME",
"your-secret-key",
"your-secret",
"jwt-secret",
"jwt-secret-key",
"default-secret",
"my-secret",
"test-secret",
"test-secret-key",
"super-secret",
"a-very-secret-key-that-should-be-in-env", // 39 chars — length passes, list blocks
"test-secret-key-for-testing-only", // 32 chars — length passes, list blocks
}
for _, s := range weak {
if !isWeakJWTSecret(s) {
t.Errorf("expected known weak secret %q to be rejected", s)
}
}
}
// TestIsWeakJWTSecret_ShortSecrets verifies anything under 32 bytes is weak:
// HS256 keys must be at least 256 bits to be meaningful.
func TestIsWeakJWTSecret_ShortSecrets(t *testing.T) {
short := []string{
"",
"a",
"short",
"0123456789",
"0123456789012345678901234567890", // 31 chars < 32
}
for _, s := range short {
if !isWeakJWTSecret(s) {
t.Errorf("expected %q (%d chars) to be weak", s, len(s))
}
}
}
// TestIsWeakJWTSecret_StrongRandomSecret verifies a strong random 32+ byte
// secret (not a known placeholder) is accepted.
func TestIsWeakJWTSecret_StrongRandomSecret(t *testing.T) {
strong := "b8f0c2a1d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2"
if isWeakJWTSecret(strong) {
t.Errorf("expected a strong 50-char random secret to be accepted")
}
exactly32 := "9f2kL7mP4qR8sT1uV5wX3yZ6aB0cD2eG" // exactly 32 chars
if isWeakJWTSecret(exactly32) {
t.Errorf("expected an exactly-32-char random secret to be accepted")
}
}
// TestIsWeakJWTSecret_CaseAndWhitespaceInsensitive verifies the check lowercases
// and trims before comparing, so padded/case-varied placeholders cannot slip
// through.
func TestIsWeakJWTSecret_CaseAndWhitespaceInsensitive(t *testing.T) {
for _, s := range []string{"PASSWORD", " SeCrEt ", "\tchangeme\n", " super-secret "} {
if !isWeakJWTSecret(s) {
t.Errorf("expected %q to be weak after case/whitespace normalisation", s)
}
}
paddedStrong := " xY9Q2mR7vB4nK8wP1tL5sD3fG6hJ0cVnW4 "
if isWeakJWTSecret(paddedStrong) {
t.Errorf("expected a whitespace-padded strong secret to be accepted")
}
}