實作 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>
214 lines
6.1 KiB
Go
214 lines
6.1 KiB
Go
package api
|
||
|
||
import (
|
||
"errors"
|
||
"sync"
|
||
"sync/atomic"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// TestTokenStore_IssueConsume_HappyPath:發放後可消費一次,第二次消費失敗(one-time)。
|
||
func TestTokenStore_IssueConsume_HappyPath(t *testing.T) {
|
||
s := NewTokenStore()
|
||
|
||
token, expiresAt, err := s.Issue("dev-1")
|
||
if err != nil {
|
||
t.Fatalf("Issue error: %v", err)
|
||
}
|
||
if token == "" {
|
||
t.Fatal("token 不應為空")
|
||
}
|
||
if !expiresAt.After(time.Now()) {
|
||
t.Errorf("expiresAt %v 應在未來", expiresAt)
|
||
}
|
||
|
||
// 第一次消費成功
|
||
if err := s.Consume(token, "dev-1"); err != nil {
|
||
t.Fatalf("第一次 Consume 應成功,got %v", err)
|
||
}
|
||
// 第二次消費必失敗(one-time)
|
||
if err := s.Consume(token, "dev-1"); !errors.Is(err, ErrTokenInvalid) {
|
||
t.Errorf("第二次 Consume 應 ErrTokenInvalid,got %v", err)
|
||
}
|
||
}
|
||
|
||
// TestTokenStore_Consume_DeviceMismatch:deviceId 不符 → ErrTokenInvalid,且 token 已被消費。
|
||
func TestTokenStore_Consume_DeviceMismatch(t *testing.T) {
|
||
s := NewTokenStore()
|
||
token, _, _ := s.Issue("dev-1")
|
||
|
||
// deviceId 不符 → 失敗
|
||
if err := s.Consume(token, "dev-2"); !errors.Is(err, ErrTokenInvalid) {
|
||
t.Errorf("deviceId 不符應 ErrTokenInvalid,got %v", err)
|
||
}
|
||
// 即使不符,token 也應已被移除(命中即刪,防以正確 deviceId 重試)
|
||
if err := s.Consume(token, "dev-1"); !errors.Is(err, ErrTokenInvalid) {
|
||
t.Errorf("不符後 token 應已被消費,got %v", err)
|
||
}
|
||
}
|
||
|
||
// TestTokenStore_Consume_MissingAndEmpty:不存在 / 空字串 token → ErrTokenInvalid。
|
||
func TestTokenStore_Consume_MissingAndEmpty(t *testing.T) {
|
||
s := NewTokenStore()
|
||
if err := s.Consume("nonexistent", "dev-1"); !errors.Is(err, ErrTokenInvalid) {
|
||
t.Errorf("不存在 token 應 ErrTokenInvalid,got %v", err)
|
||
}
|
||
if err := s.Consume("", "dev-1"); !errors.Is(err, ErrTokenInvalid) {
|
||
t.Errorf("空 token 應 ErrTokenInvalid,got %v", err)
|
||
}
|
||
}
|
||
|
||
// TestTokenStore_Expiry:過期 token 消費失敗。用可注入的 now 模擬時間流逝。
|
||
func TestTokenStore_Expiry(t *testing.T) {
|
||
s := NewTokenStore()
|
||
base := time.Now()
|
||
current := base
|
||
s.now = func() time.Time { return current }
|
||
|
||
token, _, _ := s.Issue("dev-1")
|
||
|
||
// 前進超過 TTL
|
||
current = base.Add(tokenTTL + time.Second)
|
||
|
||
if err := s.Consume(token, "dev-1"); !errors.Is(err, ErrTokenInvalid) {
|
||
t.Errorf("過期 token 應 ErrTokenInvalid,got %v", err)
|
||
}
|
||
}
|
||
|
||
// TestTokenStore_Limit:未使用 token 達 32 上限 → ErrTokenLimit;消費一個後可再發。
|
||
func TestTokenStore_Limit(t *testing.T) {
|
||
s := NewTokenStore()
|
||
|
||
tokens := make([]string, 0, maxUnusedTokens)
|
||
for i := 0; i < maxUnusedTokens; i++ {
|
||
tok, _, err := s.Issue("dev-1")
|
||
if err != nil {
|
||
t.Fatalf("第 %d 個 Issue 不應失敗,got %v", i, err)
|
||
}
|
||
tokens = append(tokens, tok)
|
||
}
|
||
|
||
// 第 33 個應被拒
|
||
if _, _, err := s.Issue("dev-1"); !errors.Is(err, ErrTokenLimit) {
|
||
t.Errorf("達上限應 ErrTokenLimit,got %v", err)
|
||
}
|
||
if !s.IsLimitErr(ErrTokenLimit) {
|
||
t.Error("IsLimitErr(ErrTokenLimit) 應為 true")
|
||
}
|
||
|
||
// 消費一個後釋放名額,可再發
|
||
if err := s.Consume(tokens[0], "dev-1"); err != nil {
|
||
t.Fatalf("Consume 應成功,got %v", err)
|
||
}
|
||
if _, _, err := s.Issue("dev-1"); err != nil {
|
||
t.Errorf("釋放名額後 Issue 應成功,got %v", err)
|
||
}
|
||
}
|
||
|
||
// TestTokenStore_Limit_ExpiredFreesSlot:過期 token 在 Issue 時被惰性清理,釋放上限名額。
|
||
func TestTokenStore_Limit_ExpiredFreesSlot(t *testing.T) {
|
||
s := NewTokenStore()
|
||
base := time.Now()
|
||
current := base
|
||
s.now = func() time.Time { return current }
|
||
|
||
for i := 0; i < maxUnusedTokens; i++ {
|
||
if _, _, err := s.Issue("dev-1"); err != nil {
|
||
t.Fatalf("第 %d 個 Issue 失敗: %v", i, err)
|
||
}
|
||
}
|
||
// 全部過期
|
||
current = base.Add(tokenTTL + time.Second)
|
||
|
||
// 再 Issue 應觸發惰性清理、成功
|
||
if _, _, err := s.Issue("dev-1"); err != nil {
|
||
t.Errorf("過期清理後 Issue 應成功,got %v", err)
|
||
}
|
||
}
|
||
|
||
// TestTokenStore_ConcurrentConsume_SingleFlight 是 security m2 的關鍵測試:
|
||
// 多個 goroutine 同時消費同一 token,必須「恰好一個成功」(防 one-time 失效 / 雙重消費)。
|
||
func TestTokenStore_ConcurrentConsume_SingleFlight(t *testing.T) {
|
||
const goroutines = 200
|
||
|
||
// 跑多輪,提高抓到 race 的機率。
|
||
for round := 0; round < 50; round++ {
|
||
s := NewTokenStore()
|
||
token, _, _ := s.Issue("dev-1")
|
||
|
||
var successCount int32
|
||
var wg sync.WaitGroup
|
||
start := make(chan struct{})
|
||
wg.Add(goroutines)
|
||
for i := 0; i < goroutines; i++ {
|
||
go func() {
|
||
defer wg.Done()
|
||
<-start // 同時起跑,最大化競爭
|
||
if err := s.Consume(token, "dev-1"); err == nil {
|
||
atomic.AddInt32(&successCount, 1)
|
||
}
|
||
}()
|
||
}
|
||
close(start)
|
||
wg.Wait()
|
||
|
||
if successCount != 1 {
|
||
t.Fatalf("round %d: 併發消費同一 token 成功數 = %d,必須恰好 1(single-flight 失效)",
|
||
round, successCount)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestTokenStore_ConcurrentIssue_LimitHeld 是 security m2 的另一半:
|
||
// 併發 Issue 時,未使用 token 數不得突破 32 上限。
|
||
func TestTokenStore_ConcurrentIssue_LimitHeld(t *testing.T) {
|
||
const goroutines = 200
|
||
|
||
for round := 0; round < 30; round++ {
|
||
s := NewTokenStore()
|
||
|
||
var wg sync.WaitGroup
|
||
start := make(chan struct{})
|
||
wg.Add(goroutines)
|
||
for i := 0; i < goroutines; i++ {
|
||
go func() {
|
||
defer wg.Done()
|
||
<-start
|
||
_, _, _ = s.Issue("dev-1")
|
||
}()
|
||
}
|
||
close(start)
|
||
wg.Wait()
|
||
|
||
if got := s.len(); got > maxUnusedTokens {
|
||
t.Fatalf("round %d: 併發 Issue 後 token 數 = %d,不得超過上限 %d(上限檢查非 atomic)",
|
||
round, got, maxUnusedTokens)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestTokenStore_TokenUniqueness:連續發放的 token 值不重複。
|
||
func TestTokenStore_TokenUniqueness(t *testing.T) {
|
||
s := NewTokenStore()
|
||
seen := make(map[string]bool)
|
||
for i := 0; i < maxUnusedTokens; i++ {
|
||
tok, _, err := s.Issue("dev-1")
|
||
if err != nil {
|
||
t.Fatalf("Issue 失敗: %v", err)
|
||
}
|
||
if seen[tok] {
|
||
t.Fatalf("token 重複: %q", tok)
|
||
}
|
||
seen[tok] = true
|
||
}
|
||
}
|
||
|
||
// TestTokenStore_Cleanup_StopWorks:StartCleanup 回傳的 stop 可正常關閉 goroutine。
|
||
func TestTokenStore_Cleanup_StopWorks(t *testing.T) {
|
||
s := NewTokenStore()
|
||
stop := s.StartCleanup()
|
||
// 立即停止不應 panic / deadlock
|
||
stop()
|
||
}
|