Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
356 lines
12 KiB
Go
356 lines
12 KiB
Go
//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.StatusForbidden {
|
|
t.Errorf("expected preflight 403 for unlisted origin, 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])
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// CORS fix: OPTIONS from non-allowed origins returns 403
|
|
// ============================================================
|
|
|
|
// TestCORSPreflight_NonAllowedOrigin_Returns403 verifies the CORS fix: an
|
|
// OPTIONS preflight from a non-allowed origin must return 403 Forbidden
|
|
// (not 204 No Content) and must NOT include the Access-Control-Allow-Origin
|
|
// header, so a browser cannot be tricked into believing CORS is granted.
|
|
func TestCORSPreflight_NonAllowedOrigin_Returns403(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.com")
|
|
req.Header.Set("Access-Control-Request-Method", "POST")
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusForbidden {
|
|
t.Errorf("expected 403 Forbidden for non-allowed origin OPTIONS, got %d", rr.Code)
|
|
}
|
|
if got := rr.Header().Get("Access-Control-Allow-Origin"); got != "" {
|
|
t.Errorf("expected no Access-Control-Allow-Origin header for non-allowed origin, got %q", got)
|
|
}
|
|
// Also verify no CORS headers are leaked.
|
|
if got := rr.Header().Get("Access-Control-Allow-Methods"); got != "" {
|
|
t.Errorf("expected no Access-Control-Allow-Methods header for non-allowed origin, got %q", got)
|
|
}
|
|
}
|
|
|
|
// TestCORSPreflight_AllowedOrigin_Returns204 verifies that an OPTIONS preflight
|
|
// from a configured origin returns 204 No Content with proper CORS headers.
|
|
func TestCORSPreflight_AllowedOrigin_Returns204(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://app.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 204 No Content for allowed origin OPTIONS, got %d", rr.Code)
|
|
}
|
|
if got := rr.Header().Get("Access-Control-Allow-Origin"); got != "https://app.example.com" {
|
|
t.Errorf("expected Access-Control-Allow-Origin %q, got %q", "https://app.example.com", got)
|
|
}
|
|
if got := rr.Header().Get("Vary"); got != "Origin" {
|
|
t.Errorf("expected Vary: Origin header, got %q", got)
|
|
}
|
|
if got := rr.Header().Get("Access-Control-Allow-Methods"); got == "" {
|
|
t.Errorf("expected Access-Control-Allow-Methods header to be set")
|
|
}
|
|
if got := rr.Header().Get("Access-Control-Allow-Headers"); got == "" {
|
|
t.Errorf("expected Access-Control-Allow-Headers header to be set")
|
|
}
|
|
}
|