diff --git a/visionA-backend/cmd/api-server/all_endpoints_require_auth_test.go b/visionA-backend/cmd/api-server/all_endpoints_require_auth_test.go index 976f37b..136fa5c 100644 --- a/visionA-backend/cmd/api-server/all_endpoints_require_auth_test.go +++ b/visionA-backend/cmd/api-server/all_endpoints_require_auth_test.go @@ -45,8 +45,9 @@ import ( // // /ws/* stub endpoints(仍是 501、尚未實作 WebSocket proxy、註冊在 r 而非 wsAuthGroup, // 故不走 AuthMiddleware)也逐條列在這裡。**逐條精確列出**、而非用 /ws/ prefix 一次放行, -// 是為了讓「已升級成 authed 的 WS route」(如 GET /ws/devices/:id/inference,掛在 -// wsAuthGroup、走 same-origin cookie AuthMiddleware)自動落入主測試的「必須 401」檢查。 +// 是為了讓「已升級成 authed 的 WS route」(如 GET /ws/devices/:id/inference 與 +// GET /ws/devices/:id/flash-progress,皆掛在 wsAuthGroup、走 same-origin cookie +// AuthMiddleware)自動落入主測試的「必須 401」檢查。 // 未來任一條 stub 補實作並套 auth 後,把它從這份清單移除即可納入回歸檢查。 // // 任何往這份清單裡新加 endpoint 的 PR 都該特別 review — 你正在繞過 OIDC 保護。 @@ -57,10 +58,10 @@ var publicPaths = map[string]bool{ "POST /api/pairing/exchange": true, // /ws/* 仍為 501 stub 的 endpoint(見 internal/api/stubs.go registerWebSocketStubs)。 - // 注意:GET /ws/devices/:id/inference 已升級為 authed(wsAuthGroup),**刻意不在此清單**, + // 注意:GET /ws/devices/:id/inference 與 GET /ws/devices/:id/flash-progress 已升級為 + // authed(掛 wsAuthGroup,走 same-origin cookie AuthMiddleware),**刻意不在此清單**, // 因此會被主測試納入「無 cookie 應回 401」的檢查。 "GET /ws/devices/events": true, - "GET /ws/devices/:id/flash-progress": true, "GET /ws/server-logs": true, "GET /ws/system": true, "GET /ws/clusters/:id/inference": true, diff --git a/visionA-backend/internal/api/api.go b/visionA-backend/internal/api/api.go index 84f0e76..6ec8a2d 100644 --- a/visionA-backend/internal/api/api.go +++ b/visionA-backend/internal/api/api.go @@ -233,12 +233,13 @@ func NewRouter(deps Deps) *gin.Engine { // Pairing Token 本身就是這個 endpoint 的憑證。詳見 security.md §1.2。 registerPairingPublicRoutes(r, deps) - // /ws/* 雛形大多仍 501;已實作的 WS tunnel proxy(/ws/devices/:id/inference) - // 改掛在下方 wsAuthGroup(AuthMiddleware group)。 + // /ws/* 雛形大多仍 501;已實作的 WS tunnel proxy(/ws/devices/:id/inference + // 與 /ws/devices/:id/flash-progress)改掛在下方 wsAuthGroup(AuthMiddleware group)。 registerWebSocketStubs(r) // WS tunnel proxy group:走 same-origin cookie AuthMiddleware(security 定案, - // 不放 token 到 URL)。目前只有推論結果 WS(/ws/devices/:id/inference)。 + // 不放 token 到 URL)。目前有兩條裝置級 WS:推論結果(/ws/devices/:id/inference) + // 與 flash 進度回顯(/ws/devices/:id/flash-progress),兩者都走透明 tunnel proxy。 // 刻意獨立成 group 而非掛 /api:WS endpoint 對外路徑就是 /ws/*(對齊前端與 // api-spec);但認證邏輯與 /api 共用 AuthMiddleware。 wsAuthGroup := r.Group("/ws") diff --git a/visionA-backend/internal/api/camera.go b/visionA-backend/internal/api/camera.go index 993c8a9..acff6a6 100644 --- a/visionA-backend/internal/api/camera.go +++ b/visionA-backend/internal/api/camera.go @@ -66,14 +66,25 @@ func registerCameraRoutes(g *gin.RouterGroup, deps Deps) { // registerWebSocketRoutes 註冊需要 WS tunnel proxy 的 /ws/* endpoint。 // -// 目前只有 /ws/devices/:id/inference(推論結果即時推播 — camera overlay + media 結果 -// 顯示的共用資料通道)。掛在 wsGroup(已套 AuthMiddleware)下,走 same-origin cookie -// 認證,不放 token 到 URL(security 定案)。 +// 目前有兩條: +// - /ws/devices/:id/inference — 推論結果即時推播(camera overlay + media 結果 +// 顯示的共用資料通道)。 +// - /ws/devices/:id/flash-progress — flash(載入模型到裝置)進度回顯(percent / +// stage / message / error,raw JSON 透明轉發)。 // -// 其餘 /ws/* 仍是 registerWebSocketStubs 的 501(events / flash-progress / server-logs / -// system / clusters / pairing),非本次範圍。 +// 兩條共用同一個 newWebSocketProxyHandler:該 handler 是 path-agnostic 的 +// (outPath 取自 c.Request.URL.Path,原樣轉發到 local agent,不綁定任何 path), +// 所以 inference 與 flash-progress 只差路由掛載,handler 零改動。local agent 端 +// 兩條 path 都已實作(router.go /ws/devices/:id/flash-progress → flash_ws.go)。 +// +// 掛在 wsGroup(已套 AuthMiddleware)下,走 same-origin cookie 認證,不放 token 到 +// URL(security 定案)。 +// +// 其餘 /ws/* 仍是 registerWebSocketStubs 的 501(events / server-logs / system / +// clusters / pairing),非本次範圍。 // // wsGroup 必須是「path 前綴為 /ws 且套了 AuthMiddleware」的 group(見 api.go NewRouter)。 func registerWebSocketRoutes(wsGroup *gin.RouterGroup, deps Deps) { wsGroup.GET("/devices/:id/inference", newWebSocketProxyHandler(deps)) + wsGroup.GET("/devices/:id/flash-progress", newWebSocketProxyHandler(deps)) } diff --git a/visionA-backend/internal/api/camera_ws_test.go b/visionA-backend/internal/api/camera_ws_test.go index 3e619b8..3048810 100644 --- a/visionA-backend/internal/api/camera_ws_test.go +++ b/visionA-backend/internal/api/camera_ws_test.go @@ -46,6 +46,51 @@ func TestWSInference_TunnelDisconnected(t *testing.T) { assert.Contains(t, w.Body.String(), ErrCodeTunnelDisconnect) } +// TestWSFlashProgress_NoForwarder 驗證 flash-progress route 已掛上(非 404/501-stub), +// 且與 inference 共用同一個 path-agnostic handler:缺 Forwarder 時回 501(handler 內部 +// 的「forwarder/session store not configured」,非舊的 registerWebSocketStubs 501)。 +// +// 這條與 TestWSInference_NoForwarder 對照,證明 flash-progress 走的是 +// registerWebSocketRoutes(wsAuthGroup)而非 registerWebSocketStubs。 +func TestWSFlashProgress_NoForwarder(t *testing.T) { + r := newWSFixture(Deps{}) + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/ws/devices/dev1/flash-progress", nil)) + assert.Equal(t, http.StatusNotImplemented, w.Code) +} + +// TestWSFlashProgress_TunnelDisconnected 驗證 flash-progress 無 active session 時回 +// 502 TUNNEL_DISCONNECTED(與 inference 同一套 pickActiveSessionToken 路徑)。 +func TestWSFlashProgress_TunnelDisconnected(t *testing.T) { + r := newWSFixture(Deps{ + SessionStore: &fakeSessionStore{}, // List 回空 + Forwarder: session.NewForwarder("http://localhost:0", nil), + }) + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/ws/devices/dev1/flash-progress", nil)) + assert.Equal(t, http.StatusBadGateway, w.Code) + assert.Contains(t, w.Body.String(), ErrCodeTunnelDisconnect) +} + +// TestWSFlashProgress_CoexistsWithInference 驗證 radix tree 共存: +// /ws/devices/events(靜態) vs /ws/devices/:id/inference(param) vs +// /ws/devices/:id/flash-progress(param)三者掛在同一 group 不 panic,且 flash-progress +// 與 inference 都能各自路由到 handler(而非彼此蓋掉)。newWSFixture 建構本身若 panic +// 就會 fail;這裡再各打一發確認兩條 param route 都 match 得到(回 501 = 命中 handler)。 +func TestWSFlashProgress_CoexistsWithInference(t *testing.T) { + r := newWSFixture(Deps{}) // 建構不 panic 即代表 radix tree 共存 OK + + for _, path := range []string{ + "/ws/devices/dev1/inference", + "/ws/devices/dev1/flash-progress", + } { + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil)) + // 命中 handler(缺 Forwarder → 501)而非 404(route 沒掛上)。 + assert.Equal(t, http.StatusNotImplemented, w.Code, "path %s 應命中 WS proxy handler", path) + } +} + // TestWSInference_HappyPath_BidirectionalPipe 是端到端的 WS forward 測試: // // browser(test client) ──► api-server handler(hijack) ──► Forwarder.OpenStream diff --git a/visionA-backend/internal/api/stubs.go b/visionA-backend/internal/api/stubs.go index f2d66df..e829639 100644 --- a/visionA-backend/internal/api/stubs.go +++ b/visionA-backend/internal/api/stubs.go @@ -57,9 +57,9 @@ func registerStubRoutes(g *gin.RouterGroup, _ Deps) { // // WS tunnel proxy 的基礎設施(Forwarder.ForwardWebSocket、api-server 端 Hijack + 雙向 // io.Copy)已實作(見 forwarder.go §ForwardWebSocket、proxy.go newWebSocketProxyHandler), -// 且 /ws/devices/:id/inference 已由 registerWebSocketRoutes 換成真正的 WS tunnel proxy。 -// 這裡只剩尚未接上 proxy 的其餘 /ws/* 端點(events / flash-progress / server-logs / -// system / clusters / pairing)。 +// 且 /ws/devices/:id/inference 與 /ws/devices/:id/flash-progress 已由 +// registerWebSocketRoutes 換成真正的 WS tunnel proxy。這裡只剩尚未接上 proxy 的其餘 +// /ws/* 端點(events / server-logs / system / clusters / pairing)。 // // 注意:ws endpoint 在 /ws 而非 /api/ws,所以由 NewRouter 直接註冊而非 apiGroup。 func registerWebSocketStubs(r *gin.Engine) { @@ -70,10 +70,9 @@ func registerWebSocketStubs(r *gin.Engine) { } // 用 GET(WebSocket upgrade 的初始 HTTP request) r.GET("/ws/devices/events", stub("ws.devices.events — pending B7")) - r.GET("/ws/devices/:id/flash-progress", stub("ws.flash-progress — pending B7")) - // /ws/devices/:id/inference 已由 registerWebSocketRoutes 換成真正的 WS tunnel proxy - // (掛在 AuthMiddleware group 內,走 same-origin cookie 認證)。不在此註冊 stub, - // 避免 gin radix tree 同路徑重複註冊 panic。 + // /ws/devices/:id/inference 與 /ws/devices/:id/flash-progress 已由 + // registerWebSocketRoutes 換成真正的 WS tunnel proxy(掛在 AuthMiddleware group 內, + // 走 same-origin cookie 認證)。不在此註冊 stub,避免 gin radix tree 同路徑重複註冊 panic。 r.GET("/ws/server-logs", stub("ws.server-logs — pending B7")) r.GET("/ws/system", stub("ws.system — pending B7")) r.GET("/ws/clusters/:id/inference", stub("ws.clusters.inference — pending B7"))