從 local-tool 複製出獨立的「visionA Agent」桌面應用(A3 純橋樑: tunnel client + 配對 UI + 設定,不開 HTTP port、不做本機裝置/推論 UI)。 Bundle ID 與 local-tool 不同(com.innovedus.visiona-agent vs visiona-local), 雙 app 可共存。fork 後不主動 sync,需要時手動 cherry-pick。 Backend / Wails Go(AB1-AB13): - internal/tunnel:6 狀態機(Idle/Connecting/Connected/Reconnecting/Failed/Stopped) + Pair/Unpair/Reconnect/Disconnect binding + ClientHooks event - internal/auth:encrypted file token store(AES-GCM + scrypt + machineID fallback salt + 13 tests) - internal/config:YAML validation + atomic write + 11 tests - internal/log:ring buffer + ExportLog 升級 zip - visionA-backend /api/pairing/exchange:SessionTokenStore + 17 new tests - 三平台 build 驗證(macOS DMG 160 MB / Windows EXE / Linux AppImage) - end-to-end 5 milestone 全綠(pairing → tunnel → forward → reuse 防護 → tunnel drop failover) Frontend / Next.js(AF1-AF7,沿用 visionA-frontend 基礎): - AppShell + Header + TabNav(StatusView / PairView / SettingsView 三 tab) - ConnectionStatusBadge 5 種狀態 - TokenInput regex 驗證 + 7 種錯誤 + 0.5s auto-switch 到狀態頁 - 設定頁 4 區塊(含重新配對 AlertDialog) - agent-api.ts 封裝 Wails bindings(mock/real 雙實作)+ 90 tests Phase 0.7 review-driven fix(Round 2): - A1 Session fixation 防護(RotateSessionID) - A3 mock pairing 預設改 false(必須明確 opt-in)+ startup log - A4 Pair 失敗後 state 清理矩陣(exchange/Save/Start fail 各自終態) - A5 Pair/Unpair/Reconnect lifecycleMu + 50 goroutine race test - F1 重新配對次按鈕 / F2 PairView Esc cancel / F3 Wails BrowserOpenURL / F4 Settings draft 持久 + 未儲存 badge 驗證:agent backend go test -race -count=3 ./... 4 packages 全綠 / agent frontend pnpm test 119 tests 全綠 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
202 lines
6.0 KiB
Go
202 lines
6.0 KiB
Go
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)
|
||
}
|
||
}
|