jim800121chen 4f50ad7350 feat(ws): flash-progress WS tunnel proxy(後端,載入模型進度回顯)
flash「載入模型到裝置」後端缺口:flash-progress WS 從 501 stub 換真
tunnel proxy,複用 inference WS 的 path-agnostic handler。

- camera.go registerWebSocketRoutes:加 /ws/devices/:id/flash-progress
  (複用 newWebSocketProxyHandler、掛 wsAuthGroup same-origin cookie)
- stubs.go:刪 flash-progress 501 stub(成對防 radix panic)+ 更新註解
- api.go:更新 wsAuthGroup 註解(現含 inference + flash-progress 兩條裝置級 WS)
- camera_ws_test.go:+3 test(NoForwarder/TunnelDisconnected/CoexistsWithInference)
- all_endpoints_require_auth_test.go:收窄白名單,flash-progress 從 skipped→
  covered 納入「無 cookie 應 401」回歸檢查(守得住證明:移除 auth 即 FAIL)

契約:WS /ws/devices/:id/flash-progress、payload raw {percent,stage,message?,error?}
透明轉發。Reviewer 0C/0M/1Mi 通過(Mi 註解已修)。build/vet/全回歸綠。

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

178 lines
6.7 KiB
Go
Raw Permalink 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)
}
// TestWSFlashProgress_NoForwarder 驗證 flash-progress route 已掛上(非 404/501-stub
// 且與 inference 共用同一個 path-agnostic handler缺 Forwarder 時回 501handler 內部
// 的「forwarder/session store not configured」非舊的 registerWebSocketStubs 501
//
// 這條與 TestWSInference_NoForwarder 對照,證明 flash-progress 走的是
// registerWebSocketRouteswsAuthGroup而非 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/inferenceparam vs
// /ws/devices/:id/flash-progressparam三者掛在同一 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而非 404route 沒掛上)。
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
// ──► 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")
}