visionA/local-agent/server/internal/api/local_middleware_test.go
jim800121chen 9031153553 feat(adr-019): 影片/圖片/批次上傳走同機 localhost 直連 local-agent
實作 ADR-019 混合路徑:影片/圖片/批次的檔案上傳改由瀏覽器同機直連
local-agent localhost endpoint(繞過雲端 tunnel),控制面 + MJPEG 結果 +
推論 WS 仍走 tunnel。解決大檔頻寬雙倍 + nginx 100M + 300s timeout。

三條 stream(全數過 reviewer + security code-level 複審 APPROVED):

local-agent(Go):
- CORS 雲端 origin 完整精確比對 + Allow-Credentials:false + HostGuard(loopback)
  + PNA header(middleware.go)
- 新 route /api/local/media/upload/*(一律要 token、不看 Origin,關 C1 後門)
- one-time token store(crypto/rand、TTL 120s、綁 deviceId、single-flight consume、
  上限 32→429;200 goroutine -race 綠)
- GET /api/local/hello(回 salted SHA-256 serialHashes、最小揭露)
  + POST /api/local/issue-token(Host-based)
- LocalUploadGuard(token+size 驗證放 FormFile 前);video≤500MB / batch 合計 80MB
  → 413;stopActivePipeline + batch 生命週期 temp 檔清理

cloud(visionA-backend):
- POST /api/devices/:serial/local-upload-ticket(OIDC + 裝置歸屬 + 經 tunnel
  轉發 issue-token;IDOR-safe、錯誤不洩漏)

frontend(visionA-frontend):
- lib/local-agent.ts(port 探測 3721-3740 並發+快取、Web Crypto serial hash 比對
  同機判定、uploadToLocalAgent 通用函式)
- validateBatchFiles 合計大小檢查(MAX_BATCH_TOTAL_BYTES=80MB,消 50×19MB 撞 413 地雷)

回歸:ADR-019 相關 270 測試全綠、既有 tunnel 路徑未被打斷、無 regression。
既有 tunnel(無 Origin)不要求 token(C1 route 分離相容性保證)。

Refs: ADR-019。WP-0(PNA 實機)/WP-4(影片分頁接線)下一批。

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

270 lines
8.9 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 (
"bytes"
"encoding/json"
"errors"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
// fakeConsumer 是 tokenConsumer 的測試替身,記錄 Consume 被呼叫的參數,
// 並可設定回傳的錯誤(模擬有效 / 已消費 / 過期 / deviceId 不符)。
type fakeConsumer struct {
called bool
gotToken string
gotDeviceID string
returnErr error
}
func (f *fakeConsumer) Consume(token, deviceID string) error {
f.called = true
f.gotToken = token
f.gotDeviceID = deviceID
return f.returnErr
}
// buildMultipart 建一個含 deviceId + file 欄位的 multipart body回傳 body 與 content-type。
func buildMultipart(t *testing.T, deviceID string, fileContent []byte) (*bytes.Buffer, string) {
t.Helper()
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
if err := w.WriteField("deviceId", deviceID); err != nil {
t.Fatal(err)
}
fw, err := w.CreateFormFile("file", "test.mp4")
if err != nil {
t.Fatal(err)
}
if _, err := fw.Write(fileContent); err != nil {
t.Fatal(err)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
return &buf, w.FormDataContentType()
}
// newGuardRouter 建一台掛 LocalUploadGuard 的 routerhandler 記錄是否被呼叫並回讀 file。
func newGuardRouter(store tokenConsumer, maxBytes int64, handlerCalled *bool) *gin.Engine {
r := gin.New()
r.POST("/api/local/media/upload/video",
LocalUploadGuard(store, maxBytes),
func(c *gin.Context) {
*handlerCalled = true
// 模擬既有 handler 讀 file驗證 middleware 解析後 handler 仍可 FormFile
_, _, err := c.Request.FormFile("file")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"formfile_err": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
})
return r
}
// decodeErrCode 從 response body 取出 error.code。
func decodeErrCode(t *testing.T, body []byte) string {
t.Helper()
var resp struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
if err := json.Unmarshal(body, &resp); err != nil {
t.Fatalf("decode body %q: %v", string(body), err)
}
return resp.Error.Code
}
// TestLocalUploadGuard_MissingToken無 X-Visiona-Local-Token → 401 LOCAL_TOKEN_INVALID
// 且 store.Consume 不被呼叫、handler 不被呼叫。
func TestLocalUploadGuard_MissingToken(t *testing.T) {
store := &fakeConsumer{}
var handlerCalled bool
r := newGuardRouter(store, maxVideoUploadBytes, &handlerCalled)
body, ct := buildMultipart(t, "dev-1", []byte("small"))
req := httptest.NewRequest(http.MethodPost, "/api/local/media/upload/video", body)
req.Header.Set("Content-Type", ct)
// 刻意不帶 token
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", w.Code)
}
if code := decodeErrCode(t, w.Body.Bytes()); code != "LOCAL_TOKEN_INVALID" {
t.Errorf("error code = %q, want LOCAL_TOKEN_INVALID", code)
}
if store.called {
t.Error("無 token 不應呼叫 Consume")
}
if handlerCalled {
t.Error("無 token 不應進入 handler")
}
}
// TestLocalUploadGuard_ValidToken有效 token → 放行、Consume 被呼叫且帶正確 token+deviceId、
// handler 被呼叫。
func TestLocalUploadGuard_ValidToken(t *testing.T) {
store := &fakeConsumer{returnErr: nil} // Consume 成功
var handlerCalled bool
r := newGuardRouter(store, maxVideoUploadBytes, &handlerCalled)
body, ct := buildMultipart(t, "dev-42", []byte("video-bytes"))
req := httptest.NewRequest(http.MethodPost, "/api/local/media/upload/video", body)
req.Header.Set("Content-Type", ct)
req.Header.Set("X-Visiona-Local-Token", "tok-abc")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", w.Code, w.Body.String())
}
if !store.called {
t.Fatal("有效 token 應呼叫 Consume")
}
if store.gotToken != "tok-abc" {
t.Errorf("Consume token = %q, want tok-abc", store.gotToken)
}
if store.gotDeviceID != "dev-42" {
t.Errorf("Consume deviceID = %q, want dev-42取自表單", store.gotDeviceID)
}
if !handlerCalled {
t.Error("有效 token 應進入 handler")
}
}
// TestLocalUploadGuard_ConsumedOrExpiredTokenConsume 回 ErrTokenInvalid已用/過期/deviceId不符
// → 401 LOCAL_TOKEN_INVALIDhandler 不被呼叫。
func TestLocalUploadGuard_ConsumedOrExpiredToken(t *testing.T) {
store := &fakeConsumer{returnErr: ErrTokenInvalid}
var handlerCalled bool
r := newGuardRouter(store, maxVideoUploadBytes, &handlerCalled)
body, ct := buildMultipart(t, "dev-1", []byte("x"))
req := httptest.NewRequest(http.MethodPost, "/api/local/media/upload/video", body)
req.Header.Set("Content-Type", ct)
req.Header.Set("X-Visiona-Local-Token", "stale-tok")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", w.Code)
}
if code := decodeErrCode(t, w.Body.Bytes()); code != "LOCAL_TOKEN_INVALID" {
t.Errorf("error code = %q, want LOCAL_TOKEN_INVALID", code)
}
if handlerCalled {
t.Error("無效 token 不應進入 handler")
}
}
// TestLocalUploadGuard_TokenCheckedBeforeHandlertoken 驗證發生在 handlerFormFile 讀檔)之前。
// 用「Consume 失敗時 handler 不被呼叫」+「Consume 成功時才進 handler」共同證明順序
// 若 handler 先跑,無效 token 情境下 handlerCalled 會是 true。
func TestLocalUploadGuard_TokenCheckedBeforeHandler(t *testing.T) {
store := &fakeConsumer{returnErr: ErrTokenInvalid}
var handlerCalled bool
r := newGuardRouter(store, maxVideoUploadBytes, &handlerCalled)
body, ct := buildMultipart(t, "dev-1", bytes.Repeat([]byte("A"), 1024))
req := httptest.NewRequest(http.MethodPost, "/api/local/media/upload/video", body)
req.Header.Set("Content-Type", ct)
req.Header.Set("X-Visiona-Local-Token", "bad")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if handlerCalled {
t.Error("token 驗證失敗時 handler 不得被呼叫(證明 token 檢查在 handler 前)")
}
if !store.called {
t.Error("Consume 應在進 handler 前被呼叫")
}
}
// TestLocalUploadGuard_TooLargebody 超過 size 上限 → 413 LOCAL_UPLOAD_TOO_LARGE
// handler 不被呼叫。用很小的 maxBytes 觸發。
func TestLocalUploadGuard_TooLarge(t *testing.T) {
const tinyMax = 64 // 64 bytes遠小於下方 body
store := &fakeConsumer{returnErr: nil}
var handlerCalled bool
r := newGuardRouter(store, tinyMax, &handlerCalled)
body, ct := buildMultipart(t, "dev-1", bytes.Repeat([]byte("A"), 4096))
req := httptest.NewRequest(http.MethodPost, "/api/local/media/upload/video", body)
req.Header.Set("Content-Type", ct)
req.Header.Set("X-Visiona-Local-Token", "tok")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("status = %d, want 413 (body=%s)", w.Code, w.Body.String())
}
if code := decodeErrCode(t, w.Body.Bytes()); code != "LOCAL_UPLOAD_TOO_LARGE" {
t.Errorf("error code = %q, want LOCAL_UPLOAD_TOO_LARGE", code)
}
if handlerCalled {
t.Error("超過 size 上限不應進入 handler")
}
}
// TestLocalUploadGuard_TooLarge_BeforeTokenConsumed超過上限時即使帶了看似有效的 token
// 也不應 consume 掉那個 tokensize 檢查在 consume 之前,避免大檔攻擊順手燒掉 token
func TestLocalUploadGuard_TooLarge_BeforeTokenConsumed(t *testing.T) {
const tinyMax = 64
store := &fakeConsumer{returnErr: nil}
var handlerCalled bool
r := newGuardRouter(store, tinyMax, &handlerCalled)
body, ct := buildMultipart(t, "dev-1", bytes.Repeat([]byte("A"), 4096))
req := httptest.NewRequest(http.MethodPost, "/api/local/media/upload/video", body)
req.Header.Set("Content-Type", ct)
req.Header.Set("X-Visiona-Local-Token", "tok")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if store.called {
t.Error("超過 size 上限時不應呼叫 Consumesize 檢查在 consume 前)")
}
}
// TestLocalUploadGuard_ErrorCodeMatchesSpec確保錯誤碼字串與 api-spec §6.5 完全一致。
func TestLocalUploadGuard_ErrorCodeMatchesSpec(t *testing.T) {
// 直接驗證 respondTokenInvalid 的輸出格式。
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
respondTokenInvalid(c)
if w.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", w.Code)
}
if !strings.Contains(w.Body.String(), "LOCAL_TOKEN_INVALID") {
t.Errorf("body 應含 LOCAL_TOKEN_INVALIDgot %s", w.Body.String())
}
var resp struct {
Success bool `json:"success"`
}
_ = json.Unmarshal(w.Body.Bytes(), &resp)
if resp.Success {
t.Error("錯誤回應 success 應為 false")
}
}
// 確認 ErrTokenInvalid / ErrTokenLimit 是 sentinelerrors.Is 可比對),供 handler/middleware 對應錯誤碼。
func TestSentinelErrors(t *testing.T) {
if !errors.Is(ErrTokenInvalid, ErrTokenInvalid) {
t.Error("ErrTokenInvalid 應可自比對")
}
if errors.Is(ErrTokenInvalid, ErrTokenLimit) {
t.Error("ErrTokenInvalid 與 ErrTokenLimit 不應相等")
}
}