實作 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>
235 lines
7.1 KiB
Go
235 lines
7.1 KiB
Go
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_SerialHashes:hello 回 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、長度 64(SHA-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 hex,got %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/ttlSeconds,deviceId 綁 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 0xAAAA0001(token 綁 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_LIMIT,got %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)
|
||
}
|
||
}
|