//go:build test package main import ( "encoding/json" "net/http" "net/http/httptest" "strings" "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") } } // ============================================================ // isWeakJWTSecret entropy gate (B15) // ============================================================ // TestIsWeakJWTSecret_EntropyGate verifies the B15 fix: secrets that clear the // length and blacklist checks but lack entropy are rejected. An all-same-char // and an all-zero 32+ char secret each have a 1-byte alphabet — trivially // brute-forceable despite meeting the length requirement — while a strong // random secret with many distinct bytes is accepted. func TestIsWeakJWTSecret_EntropyGate(t *testing.T) { weak := []string{ strings.Repeat("a", 32), // all-same-char: 1 distinct byte strings.Repeat("0", 32), // all-zero: 1 distinct byte strings.Repeat("ab", 16), // 2 distinct bytes strings.Repeat("abcd", 8), // 4 distinct bytes "abcdefghijklmnopqrstuvwxyz123456", // many distinct, strong — accepted } for i := 0; i < len(weak)-1; i++ { if !isWeakJWTSecret(weak[i]) { t.Errorf("expected low-entropy secret %q to be rejected", weak[i]) } } if isWeakJWTSecret(weak[len(weak)-1]) { t.Errorf("expected high-entropy secret %q to be accepted", weak[len(weak)-1]) } }