visionA/local-agent/server/internal/api/token_store_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

214 lines
6.1 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 (
"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 應 ErrTokenInvalidgot %v", err)
}
}
// TestTokenStore_Consume_DeviceMismatchdeviceId 不符 → 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 不符應 ErrTokenInvalidgot %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 應 ErrTokenInvalidgot %v", err)
}
if err := s.Consume("", "dev-1"); !errors.Is(err, ErrTokenInvalid) {
t.Errorf("空 token 應 ErrTokenInvalidgot %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 應 ErrTokenInvalidgot %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("達上限應 ErrTokenLimitgot %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必須恰好 1single-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_StopWorksStartCleanup 回傳的 stop 可正常關閉 goroutine。
func TestTokenStore_Cleanup_StopWorks(t *testing.T) {
s := NewTokenStore()
stop := s.StartCleanup()
// 立即停止不應 panic / deadlock
stop()
}