實作 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>
270 lines
8.9 KiB
Go
270 lines
8.9 KiB
Go
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 的 router,handler 記錄是否被呼叫並回讀 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_ConsumedOrExpiredToken:Consume 回 ErrTokenInvalid(已用/過期/deviceId不符)
|
||
// → 401 LOCAL_TOKEN_INVALID,handler 不被呼叫。
|
||
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_TokenCheckedBeforeHandler:token 驗證發生在 handler(FormFile 讀檔)之前。
|
||
// 用「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_TooLarge:body 超過 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 掉那個 token(size 檢查在 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 上限時不應呼叫 Consume(size 檢查在 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_INVALID,got %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 是 sentinel(errors.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 不應相等")
|
||
}
|
||
}
|