實作 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>
205 lines
8.0 KiB
Go
205 lines
8.0 KiB
Go
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_DISCONNECTED(R-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)
|
||
}
|