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

235 lines
7.1 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 handlers
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
)
func init() {
gin.SetMode(gin.TestMode)
}
// fakeDeviceLister 是 deviceLister 的測試替身。
type fakeDeviceLister struct {
serials []string
}
func (f fakeDeviceLister) ListDevices() []deviceInfoView {
out := make([]deviceInfoView, 0, len(f.serials))
for _, s := range f.serials {
out = append(out, deviceInfoView{SerialNumber: s})
}
return out
}
// fakeStore 是 localTokenStore 的測試替身。
type fakeStore struct {
token string
expiresAt time.Time
err error
isLimit bool
gotDevice string
}
func (f *fakeStore) Issue(deviceID string) (string, time.Time, error) {
f.gotDevice = deviceID
return f.token, f.expiresAt, f.err
}
func (f *fakeStore) IsLimitErr(err error) bool { return f.isLimit && err != nil }
// expectedHash 用測試獨立的實作重算 SHA-256(salt||serial) hex
// 避免直接呼叫被測函式(防同一個 bug 同時存在於實作與預期)。
func expectedHash(serial string) string {
sum := sha256.Sum256([]byte("visiona-local-v1" + serial))
return hex.EncodeToString(sum[:])
}
// TestHello_SerialHasheshello 回 serialHashes正確 hex+ supportsLocalUpload
// 跳過空 / fake 序號,且不回 agentVersion / 完整 serial。
func TestHello_SerialHashes(t *testing.T) {
h := &LocalHandler{
devices: fakeDeviceLister{serials: []string{
"0x1A2B3C4D",
"", // 空 → 跳過
"0x00000000", // fake placeholder → 跳過
"0xDEADBEEF",
}},
}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "/api/local/hello", nil)
h.Hello(c)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
var resp struct {
Success bool `json:"success"`
Data struct {
SerialHashes []string `json:"serialHashes"`
SupportsLocalUpload bool `json:"supportsLocalUpload"`
} `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if !resp.Success {
t.Error("success 應為 true")
}
if !resp.Data.SupportsLocalUpload {
t.Error("supportsLocalUpload 應為 true")
}
// 只應有兩個真實序號的 hash
if len(resp.Data.SerialHashes) != 2 {
t.Fatalf("serialHashes 數量 = %d, want 2空與 fake 應被跳過)", len(resp.Data.SerialHashes))
}
wantSet := map[string]bool{
expectedHash("0x1A2B3C4D"): true,
expectedHash("0xDEADBEEF"): true,
}
for _, got := range resp.Data.SerialHashes {
if !wantSet[got] {
t.Errorf("非預期的 hash: %q", got)
}
// hex 必須是 lowercase、長度 64SHA-256 = 32 bytes → 64 hex chars
if len(got) != 64 {
t.Errorf("hash 長度 = %d, want 64", len(got))
}
if got != strings.ToLower(got) {
t.Errorf("hash 必須 lowercase hexgot %q", got)
}
}
// 最小揭露:不得出現 agentVersion / 完整 serial 明文
bodyStr := w.Body.String()
if strings.Contains(bodyStr, "agentVersion") {
t.Error("hello 不應回 agentVersion")
}
if strings.Contains(bodyStr, "0x1A2B3C4D") || strings.Contains(bodyStr, "0xDEADBEEF") {
t.Error("hello 不應回完整 serial 明文")
}
}
// TestHello_EmptyDevices無裝置 → serialHashes 為空陣列(非 null
func TestHello_EmptyDevices(t *testing.T) {
h := &LocalHandler{devices: fakeDeviceLister{serials: nil}}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "/api/local/hello", nil)
h.Hello(c)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
if !strings.Contains(w.Body.String(), `"serialHashes":[]`) {
t.Errorf("空裝置應回 serialHashes:[]got %s", w.Body.String())
}
}
// TestHashSerial_Contract 直接驗證被測 hashSerial 的字串拼接 / 編碼 / hex 大小寫
// 與前端逐 byte 一致性複核需要的契約SHA-256("visiona-local-v1"||serial) lowercase hex。
func TestHashSerial_Contract(t *testing.T) {
serial := "0x1A2B3C4D"
got := hashSerial(serial)
want := expectedHash(serial)
if got != want {
t.Errorf("hashSerial(%q) = %q, want %q", serial, got, want)
}
// 明確固定一個已知向量供前端對照salt+serial 直接字串相接、UTF-8、SHA-256、lowercase hex
// echo -n "visiona-local-v10x1A2B3C4D" | shasum -a 256
if len(got) != 64 || got != strings.ToLower(got) {
t.Errorf("hex 格式不符len=%d lower=%v", len(got), got == strings.ToLower(got))
}
if LocalSerialSalt != "visiona-local-v1" {
t.Errorf("LocalSerialSalt = %q, want visiona-local-v1前後端共用常數", LocalSerialSalt)
}
}
// TestIssueToken_Success正常發放 → 200 + token/expiresAt/ttlSecondsdeviceId 綁 serial。
func TestIssueToken_Success(t *testing.T) {
exp := time.UnixMilli(1_700_000_000_000)
store := &fakeStore{token: "tok-xyz", expiresAt: exp}
h := &LocalHandler{store: store}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "/api/local/issue-token",
strings.NewReader(`{"serial":"0xAAAA0001"}`))
c.Request.Header.Set("Content-Type", "application/json")
h.IssueToken(c)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", w.Code, w.Body.String())
}
var resp struct {
Data struct {
Token string `json:"token"`
ExpiresAt int64 `json:"expiresAt"`
TTLSeconds int `json:"ttlSeconds"`
} `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Data.Token != "tok-xyz" {
t.Errorf("token = %q, want tok-xyz", resp.Data.Token)
}
if resp.Data.ExpiresAt != exp.UnixMilli() {
t.Errorf("expiresAt = %d, want %d", resp.Data.ExpiresAt, exp.UnixMilli())
}
if resp.Data.TTLSeconds != 120 {
t.Errorf("ttlSeconds = %d, want 120", resp.Data.TTLSeconds)
}
if store.gotDevice != "0xAAAA0001" {
t.Errorf("Issue deviceID = %q, want 0xAAAA0001token 綁 serial", store.gotDevice)
}
}
// TestIssueToken_Limit達上限 → 429 LOCAL_TOKEN_LIMIT。
func TestIssueToken_Limit(t *testing.T) {
store := &fakeStore{err: errors.New("limit"), isLimit: true}
h := &LocalHandler{store: store}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "/api/local/issue-token",
strings.NewReader(`{"serial":"0xAAAA0001"}`))
c.Request.Header.Set("Content-Type", "application/json")
h.IssueToken(c)
if w.Code != http.StatusTooManyRequests {
t.Fatalf("status = %d, want 429", w.Code)
}
if !strings.Contains(w.Body.String(), "LOCAL_TOKEN_LIMIT") {
t.Errorf("body 應含 LOCAL_TOKEN_LIMITgot %s", w.Body.String())
}
}
// TestIssueToken_MissingSerial缺 serial → 400。
func TestIssueToken_MissingSerial(t *testing.T) {
h := &LocalHandler{store: &fakeStore{}}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "/api/local/issue-token",
strings.NewReader(`{}`))
c.Request.Header.Set("Content-Type", "application/json")
h.IssueToken(c)
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", w.Code)
}
}