visionA/visionA-backend/internal/api/local_upload_ticket_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

205 lines
8.0 KiB
Go
Raw 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 (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"visiona-backend/internal/device"
"visiona-backend/internal/session"
)
// stubLocalTokenIssuer 是 localTokenIssuer 的測試替身。
//
// 可設定:回傳的 ticket / error並記錄呼叫參數用來驗證 handler 是否有嘗試取 token、
// 以及傳的 serial / userID 正確。
type stubLocalTokenIssuer struct {
ticket LocalUploadTicket
err error
called bool
gotUserID string
gotSerial string
}
func (s *stubLocalTokenIssuer) IssueToken(_ context.Context, userID, serial string) (LocalUploadTicket, error) {
s.called = true
s.gotUserID = userID
s.gotSerial = serial
return s.ticket, s.err
}
// newLocalTicketFixture 建 router + 塞一顆 device可注入自訂 Deps 欄位issuer
// loginUserID 是「已登入 user」AuthMiddleware 塞的 UserContext可與 device owner 不同以驗 IDOR。
func newLocalTicketFixture(t *testing.T, d *device.Device, loginUserID string, mutate func(*Deps)) *gin.Engine {
t.Helper()
repo := device.NewInMemoryRepository()
require.NoError(t, repo.Save(context.Background(), d))
r := gin.New()
r.Use(RequestIDMiddleware())
r.Use(injectStaticUserContext(loginUserID, ""))
g := r.Group("/api")
deps := Deps{
DeviceRepo: repo,
SessionStore: &fakeSessionStore{},
}
if mutate != nil {
mutate(&deps)
}
registerDeviceRoutes(g, deps)
return r
}
// postLocalTicket 打 POST /api/devices/:serial/local-upload-ticket 並回 (status, 解出的 body)。
func postLocalTicket(t *testing.T, r *gin.Engine, serial string) (int, map[string]any) {
t.Helper()
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/devices/"+serial+"/local-upload-ticket", strings.NewReader("{}"))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
var body map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body), "body=%s", w.Body.String())
return w.Code, body
}
func ownedDevice() *device.Device {
return &device.Device{
ID: "dev1", OwnerUserID: "demo-user", Name: "KL520", DeviceType: "kl520",
SerialNumber: "0xB906162C",
RemoteStatus: device.RemoteStatusOnline,
Status: device.USBStatusOnline,
CreatedAt: time.Now().UTC(),
}
}
// TestLocalTicket_Success 驗證:裝置歸屬 + tunnel 正常時,透傳 local-agent 產的 token。
func TestLocalTicket_Success(t *testing.T) {
issuer := &stubLocalTokenIssuer{ticket: LocalUploadTicket{
Token: "tok_abc123", ExpiresAt: 1700000000000, TTLSeconds: 120,
}}
r := newLocalTicketFixture(t, ownedDevice(), "demo-user",
func(d *Deps) { d.LocalTokenIssuer = issuer })
code, body := postLocalTicket(t, r, "0xB906162C")
require.Equal(t, http.StatusOK, code, "body=%v", body)
require.Equal(t, true, body["success"])
data, ok := body["data"].(map[string]any)
require.True(t, ok, "data 應為物件body=%v", body)
assert.Equal(t, "tok_abc123", data["token"])
assert.Equal(t, float64(1700000000000), data["expiresAt"])
assert.Equal(t, float64(120), data["ttlSeconds"])
assert.True(t, issuer.called, "應嘗試打 local agent issue-token")
assert.Equal(t, "0xB906162C", issuer.gotSerial, "應以 device 記錄的序號打 local agent")
assert.Equal(t, "demo-user", issuer.gotUserID, "應帶當前登入 user")
}
// TestLocalTicket_DeviceNotOwned_404 驗證serial 不屬於當前登入 user 時回 404
// 且**完全不打 local agent**(歸屬檢查先於 issue-token。這是 IDOR 防護的核心路徑。
func TestLocalTicket_DeviceNotOwned_404(t *testing.T) {
issuer := &stubLocalTokenIssuer{ticket: LocalUploadTicket{Token: "should_not_be_returned"}}
// device owner = demo-user但登入者是 attacker → GetBySerial(attacker, serial) 查不到。
r := newLocalTicketFixture(t, ownedDevice(), "attacker",
func(d *Deps) { d.LocalTokenIssuer = issuer })
code, body := postLocalTicket(t, r, "0xB906162C")
require.Equal(t, http.StatusNotFound, code, "非 owner 應回 404")
errObj, ok := body["error"].(map[string]any)
require.True(t, ok, "應有 error 物件body=%v", body)
assert.Equal(t, ErrCodeNotFound, errObj["code"])
assert.False(t, issuer.called, "非 owner 不該打 local agent歸屬檢查先擋")
}
// TestLocalTicket_UnknownSerial_404 驗證:序號不存在(連 owner 自己都沒這顆)→ 404。
func TestLocalTicket_UnknownSerial_404(t *testing.T) {
issuer := &stubLocalTokenIssuer{}
r := newLocalTicketFixture(t, ownedDevice(), "demo-user",
func(d *Deps) { d.LocalTokenIssuer = issuer })
code, body := postLocalTicket(t, r, "0xDEADBEEF") // owner 有 dev1(0xB906162C) 但無此序號
require.Equal(t, http.StatusNotFound, code)
errObj, _ := body["error"].(map[string]any)
assert.Equal(t, ErrCodeNotFound, errObj["code"])
assert.False(t, issuer.called)
}
// TestLocalTicket_TokenLimit_429 驗證local-agent 回 token 上限errLocalTokenLimit
// 透傳 429 RATE_LIMITED不當成 500 / 502。
func TestLocalTicket_TokenLimit_429(t *testing.T) {
issuer := &stubLocalTokenIssuer{err: errLocalTokenLimit}
r := newLocalTicketFixture(t, ownedDevice(), "demo-user",
func(d *Deps) { d.LocalTokenIssuer = issuer })
code, body := postLocalTicket(t, r, "0xB906162C")
require.Equal(t, http.StatusTooManyRequests, code, "token 上限應透傳 429")
errObj, _ := body["error"].(map[string]any)
assert.Equal(t, ErrCodeRateLimited, errObj["code"])
assert.True(t, issuer.called)
}
// TestLocalTicket_TunnelDisconnected_502 驗證tunnel 離線session.ErrSessionNotFound
// 502 TUNNEL_DISCONNECTEDR-3裝置未連線無法取 token前端據此 disable 上傳)。
func TestLocalTicket_TunnelDisconnected_502(t *testing.T) {
issuer := &stubLocalTokenIssuer{err: session.ErrSessionNotFound}
r := newLocalTicketFixture(t, ownedDevice(), "demo-user",
func(d *Deps) { d.LocalTokenIssuer = issuer })
code, body := postLocalTicket(t, r, "0xB906162C")
require.Equal(t, http.StatusBadGateway, code)
errObj, _ := body["error"].(map[string]any)
assert.Equal(t, ErrCodeTunnelDisconnect, errObj["code"])
}
// TestLocalTicket_TunnelError_502 驗證local-agent 不可達 / 非預期回應errLocalTokenUnavailable
// → 502 TUNNEL_ERROR。
func TestLocalTicket_TunnelError_502(t *testing.T) {
issuer := &stubLocalTokenIssuer{err: errLocalTokenUnavailable}
r := newLocalTicketFixture(t, ownedDevice(), "demo-user",
func(d *Deps) { d.LocalTokenIssuer = issuer })
code, body := postLocalTicket(t, r, "0xB906162C")
require.Equal(t, http.StatusBadGateway, code)
errObj, _ := body["error"].(map[string]any)
assert.Equal(t, ErrCodeTunnelError, errObj["code"])
}
// TestLocalTicket_NoIssuer_501 驗證Forwarder/SessionStore 未配置resolveLocalTokenIssuer
// 回 nil→ 501 NOT_IMPLEMENTED而非 panic / 500。
func TestLocalTicket_NoIssuer_501(t *testing.T) {
// 不注入 LocalTokenIssuer且 Deps.Forwarder 為 nil → newForwarderLocalTokenIssuer 回 nil。
// fixture 預設有 SessionStore 但無 Forwarder故 default issuer 為 nil。
r := newLocalTicketFixture(t, ownedDevice(), "demo-user", nil)
code, body := postLocalTicket(t, r, "0xB906162C")
require.Equal(t, http.StatusNotImplemented, code)
errObj, _ := body["error"].(map[string]any)
assert.Equal(t, ErrCodeNotImplemented, errObj["code"])
}
// TestResolveLocalTokenIssuer_NilWhenNoForwarder 驗證Forwarder 為 nil 時 default issuer 為 nil。
func TestResolveLocalTokenIssuer_NilWhenNoForwarder(t *testing.T) {
assert.Nil(t, resolveLocalTokenIssuer(Deps{SessionStore: &fakeSessionStore{}}),
"Forwarder 為 nil 應回 nil issuer")
assert.Nil(t, resolveLocalTokenIssuer(Deps{}),
"Forwarder + SessionStore 皆 nil 應回 nil issuer")
}
// TestResolveLocalTokenIssuer_InjectedWins 驗證Deps.LocalTokenIssuer 非 nil 時優先用注入的 stub。
func TestResolveLocalTokenIssuer_InjectedWins(t *testing.T) {
stub := &stubLocalTokenIssuer{}
got := resolveLocalTokenIssuer(Deps{LocalTokenIssuer: stub})
assert.Same(t, stub, got)
}