依 R5 五輪決策把 visionA-local 從「Wails 內嵌 Next.js」重構為「Wails
本機伺服器控制台 + 瀏覽器 Web UI」模式(類比 Docker Desktop / Ollama)。
程式碼變動
- M8-1 砍 yt-dlp 全套(後端 resolver / URL handler / 前端 URL tab /
Makefile vendor / installer / bootstrap / CI workflow,-555 行)
- M8-2 砍 Mock 模式全套(driver/mock、mock_camera、Settings runtimeMode、
VISIONA_MOCK 環境變數,-528 行)
- M8-3 ffmpeg 從 GPL 切換到 LGPL 混合方案:Windows/Linux 用 BtbN 現成
LGPL binary,macOS 自 build minimal decoder-only 進 git
(vendor/ffmpeg/macos/ffmpeg 5.7MB + ffprobe 5.6MB,比 GPL 版省 85% 空間)
- M8-4 Wails Server Controller:state machine、log ring buffer 2000 行、
preferences.json atomic write、boot-id、Gin SkipPaths、shutdown 7+1 秒、
notify_*.go 三平台 OS 通知、watchServer 改 Error state 不 os.Exit
- M8-4b 啟動階段管線 R5-E:6 階段進度 event、20s soft / 60s hard timeout、
stage 5/6 skip 規則、sentinel file、RestartStartupSequence 5 步驟
- M8-5 Wails 控制台 vanilla HTML/JS/CSS(9 檔 ~2012 行)取代 M7-B splash:
state 視覺、log panel、startup progress panel、Stage 6 manual CTA
pulse、shutdown modal、Settings、Dark Mode、i18n 中英雙語
- M8-6 上傳影片副檔名擴充(mp4/avi/mov/mpeg/mpg)
- M8-7 Web UI Server Offline Overlay(role=alertdialog + focus trap +
wsEverConnected 容錯 + Page Visibility)
- M8-8 CORS middleware(127.0.0.1/localhost only + suffix attack 防護)+
ws/origin.go 獨立 WebSocket CheckOrigin 避 package cycle
- MAJ-4 server:shutdown-imminent WebSocket broadcast 機制
(/ws/system endpoint + notifyShutdownImminent helper)
- M8-9 Boot-ID + 瀏覽器 tab 自動重連(sessionStorage loop guard)
品質
- ~105+ 新 unit test + race detector (-count=2) 全綠
- 10 個 milestone 全部通過 Reviewer 審查
- 三方 v2 + v2.1 文件(PRD / Design Spec / TDD)+ 交叉互審紀錄
收錄在 .autoflow/
交付前待處理(M8-10)
- 重跑 make payload-macos 把舊 GPL 77MB binary 換成新 LGPL
- 三平台 end-to-end build 驗證
Co-Authored-By: Claude Opus 4.6 (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)
|
||
}
|
||
}
|