visionA/visionA-backend/internal/api/camera_ws_test.go
jim800121chen dc6ca211ae feat(ws): tunnel WS forward — 推論結果經 tunnel 推回雲端(後端塊2)
推論工作區後端塊2:實作 WS forward,讓 local agent 的推論結果 WS
(inference:<deviceId>)經 tunnel 推回雲端瀏覽器 canvas overlay。照
edge-ai-platform POC relay/server.go proxyWebSocket 移植(唯讀參考)。

- forwarder.go ForwardWebSocket:OpenStream→寫 upgrade→讀 101→WebSocketConn
  (新增 WebSocketConn/wsUpgradeError/AsWSUpgradeError)
- proxy.go newWebSocketProxyHandler:hijack browser→回寫 101→雙向 io.Copy
  cross-close(無 goroutine leak)+ copyWebSocketUpgradeHeaders(保留
  Sec-WebSocket-*、剝 Authorization/Origin)
- camera.go registerWebSocketRoutes:GET /ws/devices/:id/inference
- api.go wsAuthGroup(/ws + AuthMiddleware):same-origin cookie 認證,
  無 token-in-URL(security 定案)
- stubs.go:移除 WS inference 501 stub、更新過時 doc comment(Mi-1/2)

架構差異:POC 單 binary,visionA api-server(auth)+remote-proxy 雙 binary,
remote-proxy raw byte pipe 透明穿過 WS upgrade bytes。local agent 端未動。

認證/授權(security 定案 + S1/S2):same-origin cookie、剝 Auth/Origin、
WS 不套 300s timeout、走 pickActiveSessionToken(帶別人 deviceId 也只打到
自己 tunnel;多租戶嚴格綁定屬 Phase 1 M2 debt)。

Reviewer 0C/1M/3Mi 通過(修後)。Major-1 已修:收窄 all_endpoints_require_auth_test
的 /ws/ 白名單,讓 authed inference WS 納入「無 cookie 應 401」回歸檢查
(附守得住證明:移除 auth 測試即 FAIL)。+forwarder 5 test + camera_ws
端到端四層真連線雙向 pipe。build/vet/全回歸綠。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 04:56:54 +08:00

133 lines
4.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package api
import (
"bufio"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"visiona-backend/internal/session"
)
// newWSFixture 建立一個掛了 registerWebSocketRoutes 的 router含 injectStaticUserContext
// 模擬 AuthMiddleware 已放行。deps 由 caller 提供以便測不同情境。
func newWSFixture(deps Deps) *gin.Engine {
r := gin.New()
r.Use(RequestIDMiddleware())
r.Use(injectStaticUserContext("demo-user", ""))
ws := r.Group("/ws")
registerWebSocketRoutes(ws, deps)
return r
}
// TestWSInference_NoForwarder 驗證缺 Forwarder 時回 501走 recorder不需 hijack
func TestWSInference_NoForwarder(t *testing.T) {
r := newWSFixture(Deps{})
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/ws/devices/dev1/inference", nil))
assert.Equal(t, http.StatusNotImplemented, w.Code)
}
// TestWSInference_TunnelDisconnected 驗證無 active session 時回 502 TUNNEL_DISCONNECTED。
func TestWSInference_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/inference", nil))
assert.Equal(t, http.StatusBadGateway, w.Code)
assert.Contains(t, w.Body.String(), ErrCodeTunnelDisconnect)
}
// TestWSInference_HappyPath_BidirectionalPipe 是端到端的 WS forward 測試:
//
// browser(test client) ──► api-server handler(hijack) ──► Forwarder.OpenStream
// ──► fake remote-proxy(raw, hijack + 200 Connected) ──► fake local agent(101 + echo)
//
// 驗證:
// - handler 回 101 給 browser升級成功
// - 升級後 browser → agent 的 bytes 能雙向 pipe送 ping 收 echo
//
// 用真 httptest.NewServerrecorder 不支援 Hijack
func TestWSInference_HappyPath_BidirectionalPipe(t *testing.T) {
const clientMsg = "ping-from-browser"
// 1. 假 remote-proxyhijack → 200 Connected → 模擬 local agent讀 upgrade → 回 101 → echo
fakeProxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hj, ok := w.(http.Hijacker)
require.True(t, ok)
conn, _, err := hj.Hijack()
require.NoError(t, err)
defer conn.Close()
_, _ = conn.Write([]byte("HTTP/1.1 200 Connected\r\n\r\n"))
// 模擬 local agent讀 upgrade request → 回 101 → 之後把收到的 bytes echo 回去
br := bufio.NewReader(conn)
req, err := http.ReadRequest(br)
if err != nil {
return
}
assert.True(t, strings.EqualFold(req.Header.Get("Upgrade"), "websocket"))
_, _ = conn.Write([]byte(
"HTTP/1.1 101 Switching Protocols\r\n" +
"Upgrade: websocket\r\nConnection: Upgrade\r\n" +
"Sec-WebSocket-Accept: x\r\n\r\n",
))
// echo讀 browser 送來的 bytes透過 handler pipe→ 原樣寫回
buf := make([]byte, len(clientMsg))
n, _ := br.Read(buf)
if n > 0 {
_, _ = conn.Write(buf[:n])
}
}))
defer fakeProxy.Close()
// 2. api-server router有 active session token → pickActiveSessionToken 命中)
r := newWSFixture(Deps{
SessionStore: &fakeSessionStore{sessions: []*session.Summary{
{Token: "vAc_live", UserID: "demo-user"},
}},
Forwarder: session.NewForwarder(fakeProxy.URL, nil),
})
apiServer := httptest.NewServer(r)
defer apiServer.Close()
// 3. browser 端raw TCP dial + 送 WS upgrade request
u := strings.TrimPrefix(apiServer.URL, "http://")
cli, err := net.Dial("tcp", u)
require.NoError(t, err)
defer cli.Close()
upgrade := "GET /ws/devices/dev1/inference HTTP/1.1\r\n" +
"Host: " + u + "\r\n" +
"Upgrade: websocket\r\n" +
"Connection: Upgrade\r\n" +
"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n" +
"Sec-WebSocket-Version: 13\r\n\r\n"
_, err = cli.Write([]byte(upgrade))
require.NoError(t, err)
// 讀 101 response
cbr := bufio.NewReader(cli)
resp, err := http.ReadResponse(cbr, nil)
require.NoError(t, err)
assert.Equal(t, http.StatusSwitchingProtocols, resp.StatusCode,
"handler 應回 101 給 browser")
// 升級後:送一段 bytes期望 echo 回來(驗證雙向 pipe
_, err = cli.Write([]byte(clientMsg))
require.NoError(t, err)
echo := make([]byte, len(clientMsg))
_, err = cbr.Read(echo)
require.NoError(t, err)
assert.Equal(t, clientMsg, string(echo), "升級後 bytes 應雙向 pipe收到 echo")
}