package api import ( "net/http" "net/http/httptest" "testing" "github.com/gin-gonic/gin" ) func init() { gin.SetMode(gin.TestMode) } // TestIsAllowedOrigin 驗證 CORS 白名單判斷邏輯(M8-8 / TDD §4.2)。 func TestIsAllowedOrigin(t *testing.T) { cases := []struct { origin string want bool }{ // 白名單合法情境 {"http://127.0.0.1:3721", true}, {"http://127.0.0.1", true}, {"http://localhost:3000", true}, {"http://localhost:8080", true}, {"http://localhost", true}, {"http://[::1]:3721", true}, {"http://LOCALHOST:9999", true}, // hostname 應大小寫不敏感 // scheme 不對 {"https://127.0.0.1:3721", false}, {"https://localhost:3000", false}, {"ws://127.0.0.1:3721", false}, // hostname 不在白名單 {"http://192.168.1.5:3721", false}, {"http://example.com", false}, {"http://malicious.local", false}, {"http://127.0.0.1.evil.com", false}, // suffix 攻擊 {"http://evil-127.0.0.1.com", false}, // 特殊情境 {"", false}, {"null", false}, {"http://", false}, {"not-a-url", false}, } for _, tc := range cases { got := isAllowedOrigin(tc.origin) if got != tc.want { t.Errorf("isAllowedOrigin(%q) = %v, want %v", tc.origin, got, tc.want) } } } // newTestRouter 建一台只掛 CORSMiddleware 的最小 router,用於測試 middleware 行為。 func newTestRouter() *gin.Engine { r := gin.New() r.Use(CORSMiddleware()) r.GET("/api/ping", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) }) r.POST("/api/do", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) }) return r } // TestCORSMiddleware_AllowedOriginGET:白名單 Origin 的 GET 應回 200 且帶 ACA header。 func TestCORSMiddleware_AllowedOriginGET(t *testing.T) { r := newTestRouter() req := httptest.NewRequest(http.MethodGet, "/api/ping", nil) req.Header.Set("Origin", "http://127.0.0.1:3000") w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("status = %d, want 200", w.Code) } if got := w.Header().Get("Access-Control-Allow-Origin"); got != "http://127.0.0.1:3000" { t.Errorf("ACA-Origin = %q, want http://127.0.0.1:3000", got) } if got := w.Header().Get("Access-Control-Allow-Credentials"); got != "true" { t.Errorf("ACA-Credentials = %q, want true", got) } if got := w.Header().Get("Vary"); got != "Origin" { t.Errorf("Vary = %q, want Origin", got) } } // TestCORSMiddleware_LocalhostAllowed:localhost 任意 port 都應放行。 func TestCORSMiddleware_LocalhostAllowed(t *testing.T) { r := newTestRouter() req := httptest.NewRequest(http.MethodGet, "/api/ping", nil) req.Header.Set("Origin", "http://localhost:8080") w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("status = %d, want 200", w.Code) } if got := w.Header().Get("Access-Control-Allow-Origin"); got != "http://localhost:8080" { t.Errorf("ACA-Origin = %q, want http://localhost:8080", got) } } // TestCORSMiddleware_DisallowedOriginPOST:非白名單 Origin 的 POST 必須 403。 func TestCORSMiddleware_DisallowedOriginPOST(t *testing.T) { r := newTestRouter() req := httptest.NewRequest(http.MethodPost, "/api/do", nil) req.Header.Set("Origin", "https://example.com") w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusForbidden { t.Fatalf("status = %d, want 403", w.Code) } if got := w.Header().Get("Access-Control-Allow-Origin"); got != "" { t.Errorf("非白名單不應回 ACA-Origin,got %q", got) } } // TestCORSMiddleware_DisallowedOriginGET:非白名單 GET 應該執行 handler 但不回 ACA。 func TestCORSMiddleware_DisallowedOriginGET(t *testing.T) { r := newTestRouter() req := httptest.NewRequest(http.MethodGet, "/api/ping", nil) req.Header.Set("Origin", "http://malicious.local") w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("status = %d, want 200 (handler 仍執行,瀏覽器層擋讀取)", w.Code) } if got := w.Header().Get("Access-Control-Allow-Origin"); got != "" { t.Errorf("非白名單不應回 ACA-Origin,got %q", got) } } // TestCORSMiddleware_PreflightAllowed:白名單 Origin 的 OPTIONS preflight 應回 204 + 完整 headers。 func TestCORSMiddleware_PreflightAllowed(t *testing.T) { r := newTestRouter() req := httptest.NewRequest(http.MethodOptions, "/api/do", nil) req.Header.Set("Origin", "http://127.0.0.1:9999") req.Header.Set("Access-Control-Request-Method", "POST") req.Header.Set("Access-Control-Request-Headers", "Content-Type") w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusNoContent { t.Fatalf("status = %d, want 204", w.Code) } if got := w.Header().Get("Access-Control-Allow-Origin"); got != "http://127.0.0.1:9999" { t.Errorf("ACA-Origin = %q, want http://127.0.0.1:9999", got) } if got := w.Header().Get("Access-Control-Allow-Methods"); got == "" { t.Errorf("ACA-Methods 不應為空") } if got := w.Header().Get("Access-Control-Allow-Headers"); got == "" { t.Errorf("ACA-Headers 不應為空") } } // TestCORSMiddleware_PreflightDisallowed:非白名單 OPTIONS preflight 應 403,不回 ACA。 func TestCORSMiddleware_PreflightDisallowed(t *testing.T) { r := newTestRouter() req := httptest.NewRequest(http.MethodOptions, "/api/do", nil) req.Header.Set("Origin", "http://evil.com") req.Header.Set("Access-Control-Request-Method", "POST") w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusForbidden { t.Fatalf("status = %d, want 403", w.Code) } if got := w.Header().Get("Access-Control-Allow-Origin"); got != "" { t.Errorf("非白名單不應回 ACA-Origin,got %q", got) } } // TestCORSMiddleware_SameOrigin:沒帶 Origin(same-origin)應放行。 func TestCORSMiddleware_SameOrigin(t *testing.T) { r := newTestRouter() req := httptest.NewRequest(http.MethodGet, "/api/ping", nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("status = %d, want 200", w.Code) } if got := w.Header().Get("Access-Control-Allow-Origin"); got != "" { t.Errorf("same-origin 不應回 ACA-Origin,got %q", got) } }