對齊 ADR-015:visionA backend 從 OAuth client_credentials 改 pre-shared API key 服務間認證。Phase 0.8 stage e2e 撞 4 個 blocker(MC scope 沒註冊 / converter image 舊版 / converter 缺 env / FAA 不確定)後,使用者拍板 1:1 internal trust 用 OAuth 過度設計,改 API key。
實作(5 個增量 task,T1-T5 全綠 + Reviewer 5 輪 + final cross-task review):
T1 config + env:
- ConversionConfig 新增 ConverterAPIKey / FAAAPIKey 欄位
- Enabled() 改判定 4 欄位齊全(含兩個 API key)
- .env*.example 移除 OIDC service client / OIDC tenant / FAA delegated TTL env、新增 API key env
- TenantID / DelegatedTTLSeconds T1 暫留、T5 整批清
T2 client 改造:
- converter_client / faa_client 移除 MCTokenClient 依賴
- 直接讀 cfg.Conversion.{Converter,FAA}APIKey、set Authorization: Bearer <key>
- NewConverterClient / NewFAAClient APIKey 為空時 panic(fail-fast,對齊 ADR-015 §3.5.3 #1)
- 新增 ErrConverterAuthFailed / ErrFAAAuthFailed sentinel
- 對外 mask 成 converter_unavailable / faa_unavailable(不洩漏 401 細節,對齊 ADR-015 §3.5.3 #3)
T3 砍 mc_token_client:
- mc_token_client.go (624 行) + mc_token_client_test.go (864 行) 整檔砍
- 砍 5 個僅 mc_token_client 用的 sentinel(ErrServiceClientUnauthorized / ErrMCTokenUnavailable / ErrIDPMisconfigured / ErrIDPUnavailable / ErrDownloadTokenFailed)
- helper(truncate / silentLogger)搬到 util.go / testing_helpers_test.go
T4 flow + handler stream proxy:
- Service interface DownloadRedirectURL → DownloadStream(ctx) (io.ReadCloser, *DownloadMetadata, error)
- flow.DownloadStream 用 faa.GetFile 直接 stream NEF binary(取代 MC delegated token + 302)
- handler conversionDownloadHandler 改 io.Copy + Content-Type/Disposition/Cache-Control header
- 新增 sanitizeDownloadFilename helper 防 HTTP header injection
- 跨 package handler test (conversion_test.go) 改測 ErrFAAUnavailable + 補 *_AuthFailed 對稱 test
T5 wire 切換 + cleanup:
- main.go 砍 mcTokenClient wire、改 APIKey 注入、startup log 用 *_api_key_set boolean(不印 key)
- ConverterClient/FAAClient struct Tokens 欄位移除
- mc_token_stub.go (T4 過渡期) 整檔砍
- ConversionConfig TenantID / DelegatedTTLSeconds 欄位移除
- e2e_test.go TestConversionE2E_Download302Redirect 改寫為 TestConversionE2E_DownloadStream
- 補 MaxDownloadStreamBytes = 1 GiB size cap(io.CopyN,T4 reviewer Minor M-1)
- escapeObjectKeyPath Phase 1+ 預留 godoc + //nolint:unused(T4 reviewer Minor M-2)
- conversion.md §4.1 line 502 filename 來源描述歧義修訂(T4 reviewer Minor M-3,由 architect 處理)
- conversion_e2e_test.go 檔頭 docstring 更新(final reviewer Minor #1)
驗證:
- go build ./... exit 0
- go test -race -count=3 ./... 17 packages 全綠
- ADR-015 §6 砍除清單 100% cover(reviewer 獨立 grep 確認 MC chain / TenantID / DelegatedTTLSeconds 全清)
- ADR-015 §3.5.3 部署檢查清單 visionA 範圍 4/4 達成(fail-fast / mask / 不印 token / placeholder env)
不動:
- OIDCConfig.ServiceClientID/Secret 欄位保留(使用者拍板 backward compat)
- user login OIDC 完全不動
下一步:
- 步驟 4 — converter scheduler middleware 改 API key(jimchen 跨 repo,ADR-015 §3.5.1 Go snippet)
- 步驟 5 — FAA middleware 改 API key(warrenchen 跨 repo,ADR-015 §3.5.2 C# snippet)
- 步驟 6 — stage redeploy + e2e 完整測試
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1145 lines
41 KiB
Go
1145 lines
41 KiB
Go
// conversion_e2e_test.go — Phase 0.8 / Phase 0.8b conversion 整合 e2e 測試。
|
||
//
|
||
// 涵蓋 4 個必含場景(原始範圍對齊 .autoflow/05-implementation/phase-0.8-T8.md,
|
||
// Phase 0.8b T5 將場景 3 從「302 redirect」改為「server-side stream proxy」):
|
||
//
|
||
// 1. Streaming proxy 完整跑通
|
||
// —— 驗 InitJob 真的 streaming(不 buffer 整個 multipart body);
|
||
// 用 io.Pipe 對 visionA 送 ~10MB body(大小可控),驗 mock converter 收到 byte-perfect copy。
|
||
//
|
||
// 2. 重啟恢復 lazy rebuild
|
||
// —— 模擬 visionA backend 剛啟動 ownership 全空 + converter 端 user X 有 in_progress job;
|
||
// 驗 user 對 GET /active 觸發 lazy rebuild,且後續 GET /active 走 cache 不再打 ListInProgressJobs。
|
||
//
|
||
// 3. Download server-side stream proxy(Phase 0.8b 改造,原 302 redirect 已廢)
|
||
// —— 驗 visionA backend 用 Bearer <FAA_API_KEY> 拉 mock FAA、stream NEF binary 回 browser;
|
||
// 驗 response 200 + Content-Type: application/octet-stream + Content-Disposition: attachment +
|
||
// Cache-Control: no-store + body bytes 與 mock FAA 寫的 NEF binary byte-perfect 一致;
|
||
// 驗結構性無 302 / Location header(API key 模式不再走 delegated download)。
|
||
// 對齊 ADR-015 §7 + conversion.md §4.1 + api-conversion.md §4。
|
||
//
|
||
// 4. Active job 409 衝突
|
||
// —— 同 user 第一個 init 成功 → 第二個 init 撞 409 + body 帶 active_job 詳情。
|
||
//
|
||
// 為什麼自建 fixture 而非擴充 setupFixture:
|
||
//
|
||
// 既有 setupFixture(integration_test.go)是 B4/B5 的雛形(不含 conversion service);
|
||
// T7 main.go 在 wire 時才 build conversion service。本檔保持 T1-T7 既有 code 不動,
|
||
// 自己組一個 conversion 專用 fixture:fakeOIDC + apiServer + 2 個 mock servers
|
||
// (converter / FAA),完整模擬端到端。Phase 0.8b 取消 MC service token + delegated mock
|
||
// (API key 模式不依賴 MC OAuth),fixture 從 3 個 mock 收斂到 2 個。
|
||
//
|
||
// Phase 0.8 conversion e2e (見 docs/autoflow/04-architecture/conversion.md
|
||
// + adr/adr-015-server-to-server-api-key.md)
|
||
package main
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"crypto/rand"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"log/slog"
|
||
"mime"
|
||
"mime/multipart"
|
||
"net/http"
|
||
"net/http/cookiejar"
|
||
"net/http/httptest"
|
||
"net/url"
|
||
"os"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"sync/atomic"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"github.com/stretchr/testify/assert"
|
||
"github.com/stretchr/testify/require"
|
||
|
||
"visiona-backend/internal/api"
|
||
"visiona-backend/internal/auth"
|
||
"visiona-backend/internal/conversion"
|
||
"visiona-backend/internal/converter"
|
||
"visiona-backend/internal/device"
|
||
"visiona-backend/internal/model"
|
||
"visiona-backend/internal/oidc"
|
||
"visiona-backend/internal/oidctest"
|
||
"visiona-backend/internal/session"
|
||
"visiona-backend/internal/storage"
|
||
"visiona-backend/internal/usersession"
|
||
)
|
||
|
||
// ==========================================================================
|
||
// Mock servers
|
||
// ==========================================================================
|
||
|
||
// mockConverter 模擬 kneron_model_converter 的 task-scheduler。
|
||
//
|
||
// 對應 4 個 endpoint(converter_client.go 註解列表):
|
||
// - POST /api/v1/jobs — InitJob(multipart streaming)
|
||
// - GET /api/v1/jobs/{id} — GetJob
|
||
// - POST /api/v1/jobs/{id}/promote — Promote
|
||
// - GET /api/v1/jobs?user_id=&status=in_progress — ListInProgressJobs
|
||
//
|
||
// 透過 atomic.Int32 counter 計數每個 endpoint 被打幾次(給場景 #2 lazy rebuild 驗證用)。
|
||
type mockConverter struct {
|
||
srv *httptest.Server
|
||
|
||
mu sync.Mutex
|
||
// jobs:job_id → 當前狀態(給 GetJob / list 用)
|
||
jobs map[string]*conversion.ConverterJob
|
||
// userActive:user_id → []job_id(給 list endpoint 用)
|
||
userActive map[string][]string
|
||
|
||
// observed:紀錄關鍵事件以便驗證
|
||
initCallCount atomic.Int32
|
||
getJobCallCount atomic.Int32
|
||
promoteCallCount atomic.Int32
|
||
listJobsCallCount atomic.Int32
|
||
|
||
// initBodyBytes:場景 #1 驗 streaming forward 收到的真實 body(mock 端 ReadAll 後保留)
|
||
initBodyMu sync.Mutex
|
||
initBody []byte
|
||
initBodyCT string
|
||
initBodyLen int64
|
||
|
||
// nextInitBehavior:給場景 #4 用 — 若設為 conflictUserID,第二次 init 對該 user
|
||
// 直接回 409 user_has_active_job
|
||
nextInitConflict atomic.Int32 // 0=正常;>0=回 409 / 後續 decrement
|
||
}
|
||
|
||
// initBodyMust 把 mock 收到的 init body 取出(test caller 用)。
|
||
func (m *mockConverter) initBodySnapshot() ([]byte, string, int64) {
|
||
m.initBodyMu.Lock()
|
||
defer m.initBodyMu.Unlock()
|
||
return append([]byte(nil), m.initBody...), m.initBodyCT, m.initBodyLen
|
||
}
|
||
|
||
// addInProgressJob 預先在 mock 端註冊一個 user 的 in_progress job(給場景 #2)。
|
||
func (m *mockConverter) addInProgressJob(userID, jobID string, createdAt time.Time) {
|
||
m.mu.Lock()
|
||
defer m.mu.Unlock()
|
||
job := &conversion.ConverterJob{
|
||
JobID: jobID,
|
||
Status: "running",
|
||
Stage: "bie",
|
||
SourceFilename: "yolov5s.onnx",
|
||
Platform: "720",
|
||
CreatedAt: createdAt,
|
||
UpdatedAt: createdAt,
|
||
}
|
||
progress := 45
|
||
job.Progress = &progress
|
||
m.jobs[jobID] = job
|
||
m.userActive[userID] = append(m.userActive[userID], jobID)
|
||
}
|
||
|
||
// newMockConverter 建一個 mock converter server。
|
||
func newMockConverter(t *testing.T) *mockConverter {
|
||
t.Helper()
|
||
mc := &mockConverter{
|
||
jobs: make(map[string]*conversion.ConverterJob),
|
||
userActive: make(map[string][]string),
|
||
}
|
||
mux := http.NewServeMux()
|
||
|
||
// 解析 /api/v1/jobs 與 /api/v1/jobs/{id} / /promote — 依方法分流
|
||
mux.HandleFunc("/api/v1/jobs", mc.handleJobsRoot)
|
||
mux.HandleFunc("/api/v1/jobs/", mc.handleJobsByID)
|
||
|
||
mc.srv = httptest.NewServer(mux)
|
||
t.Cleanup(mc.srv.Close)
|
||
return mc
|
||
}
|
||
|
||
// handleJobsRoot 處理 POST /api/v1/jobs(InitJob)與 GET /api/v1/jobs?...(List)。
|
||
func (m *mockConverter) handleJobsRoot(w http.ResponseWriter, r *http.Request) {
|
||
switch r.Method {
|
||
case http.MethodPost:
|
||
m.handleInitJob(w, r)
|
||
case http.MethodGet:
|
||
m.handleListJobs(w, r)
|
||
default:
|
||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||
}
|
||
}
|
||
|
||
// handleInitJob 模擬 POST /api/v1/jobs。
|
||
//
|
||
// 行為:
|
||
// - 若 nextInitConflict > 0 → 回 409 user_has_active_job + body 帶 active_job 詳情(decrement)
|
||
// - 否則:streaming-read multipart body 全部(驗 visionA 真有 forward)→ 解出 user_id / model_id 等
|
||
// → 建一個 running job(status=created → 對齊 converter API)→ 回 201
|
||
func (m *mockConverter) handleInitJob(w http.ResponseWriter, r *http.Request) {
|
||
m.initCallCount.Add(1)
|
||
|
||
// 場景 #4:第二次 init 撞 409
|
||
if m.nextInitConflict.Load() > 0 {
|
||
m.nextInitConflict.Add(-1)
|
||
// 找該 user 第一個 active job 帶回 details
|
||
// (converter API 真實格式:見 conversion.go ActiveJobError 的 extractActiveJobFromDetails)
|
||
var firstJobID string
|
||
m.mu.Lock()
|
||
for _, ids := range m.userActive {
|
||
if len(ids) > 0 {
|
||
firstJobID = ids[0]
|
||
break
|
||
}
|
||
}
|
||
m.mu.Unlock()
|
||
writeJSON(w, http.StatusConflict, map[string]any{
|
||
"error": map[string]any{
|
||
"code": "user_has_active_job",
|
||
"message": "user already has active job",
|
||
"details": map[string]any{
|
||
"active_job": map[string]any{
|
||
"job_id": firstJobID,
|
||
"status": "running",
|
||
"stage": "bie",
|
||
},
|
||
},
|
||
},
|
||
})
|
||
return
|
||
}
|
||
|
||
// streaming-read 真實 body(驗 visionA 沒在記憶體 buffer)
|
||
contentType := r.Header.Get("Content-Type")
|
||
bodyBytes, err := io.ReadAll(r.Body)
|
||
if err != nil {
|
||
http.Error(w, "read body error: "+err.Error(), http.StatusBadRequest)
|
||
return
|
||
}
|
||
m.initBodyMu.Lock()
|
||
m.initBody = bodyBytes
|
||
m.initBodyCT = contentType
|
||
m.initBodyLen = int64(len(bodyBytes))
|
||
m.initBodyMu.Unlock()
|
||
|
||
// 從 multipart 取 user_id / model_id(驗 visionA 灌的 user_id 真有送到)
|
||
userID, ok := parseMultipartField(contentType, bodyBytes, "user_id")
|
||
if !ok {
|
||
http.Error(w, "user_id missing in multipart", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
jobID := fmt.Sprintf("job-%s-%d", userID, time.Now().UnixNano())
|
||
now := time.Now().UTC()
|
||
job := &conversion.ConverterJob{
|
||
JobID: jobID,
|
||
Status: "running",
|
||
Stage: "onnx",
|
||
SourceFilename: "yolov5s.onnx",
|
||
Platform: "720",
|
||
CreatedAt: now,
|
||
UpdatedAt: now,
|
||
}
|
||
zero := 0
|
||
job.Progress = &zero
|
||
|
||
m.mu.Lock()
|
||
m.jobs[jobID] = job
|
||
m.userActive[userID] = append(m.userActive[userID], jobID)
|
||
m.mu.Unlock()
|
||
|
||
writeJSON(w, http.StatusCreated, map[string]any{
|
||
"job_id": jobID,
|
||
"status": "running",
|
||
"stage": "onnx",
|
||
"progress": 0,
|
||
"stage_progress": 0,
|
||
"source_filename": "yolov5s.onnx",
|
||
"parameters": map[string]any{
|
||
"platform": "720",
|
||
},
|
||
"created_at": now.Format(time.RFC3339),
|
||
"updated_at": now.Format(time.RFC3339),
|
||
})
|
||
}
|
||
|
||
// handleListJobs 模擬 GET /api/v1/jobs?user_id=...&status=in_progress。
|
||
func (m *mockConverter) handleListJobs(w http.ResponseWriter, r *http.Request) {
|
||
m.listJobsCallCount.Add(1)
|
||
|
||
q := r.URL.Query()
|
||
userID := q.Get("user_id")
|
||
status := q.Get("status")
|
||
|
||
m.mu.Lock()
|
||
defer m.mu.Unlock()
|
||
|
||
jobs := make([]map[string]any, 0)
|
||
if status == "in_progress" {
|
||
for _, jobID := range m.userActive[userID] {
|
||
j := m.jobs[jobID]
|
||
if j == nil {
|
||
continue
|
||
}
|
||
if j.Status != "running" && j.Status != "created" {
|
||
continue
|
||
}
|
||
jobs = append(jobs, converterJobToMap(j))
|
||
}
|
||
}
|
||
|
||
writeJSON(w, http.StatusOK, map[string]any{
|
||
"jobs": jobs,
|
||
"total": len(jobs),
|
||
"page": 1,
|
||
"page_size": len(jobs),
|
||
"has_more": false,
|
||
})
|
||
}
|
||
|
||
// handleJobsByID 處理 /api/v1/jobs/{id} 與 /api/v1/jobs/{id}/promote。
|
||
func (m *mockConverter) handleJobsByID(w http.ResponseWriter, r *http.Request) {
|
||
// 路徑:/api/v1/jobs/{id} 或 /api/v1/jobs/{id}/promote
|
||
rest := strings.TrimPrefix(r.URL.Path, "/api/v1/jobs/")
|
||
if rest == "" {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
parts := strings.SplitN(rest, "/", 2)
|
||
jobID := parts[0]
|
||
|
||
if len(parts) == 1 {
|
||
// /api/v1/jobs/{id}
|
||
switch r.Method {
|
||
case http.MethodGet:
|
||
m.handleGetJob(w, r, jobID)
|
||
default:
|
||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||
}
|
||
return
|
||
}
|
||
|
||
// 帶 sub-path
|
||
switch parts[1] {
|
||
case "promote":
|
||
if r.Method != http.MethodPost {
|
||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||
return
|
||
}
|
||
m.handlePromote(w, r, jobID)
|
||
default:
|
||
http.NotFound(w, r)
|
||
}
|
||
}
|
||
|
||
// handleGetJob 模擬 GET /api/v1/jobs/{id}。
|
||
func (m *mockConverter) handleGetJob(w http.ResponseWriter, _ *http.Request, jobID string) {
|
||
m.getJobCallCount.Add(1)
|
||
|
||
m.mu.Lock()
|
||
j := m.jobs[jobID]
|
||
m.mu.Unlock()
|
||
if j == nil {
|
||
writeJSON(w, http.StatusNotFound, map[string]any{
|
||
"error": map[string]any{"code": "job_not_found", "message": "job not found"},
|
||
})
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, converterJobToMap(j))
|
||
}
|
||
|
||
// handlePromote 模擬 POST /api/v1/jobs/{id}/promote。
|
||
func (m *mockConverter) handlePromote(w http.ResponseWriter, r *http.Request, jobID string) {
|
||
m.promoteCallCount.Add(1)
|
||
|
||
var req struct {
|
||
Targets []struct {
|
||
Source string `json:"source"`
|
||
TargetObjectKey string `json:"target_object_key"`
|
||
} `json:"targets"`
|
||
}
|
||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||
|
||
m.mu.Lock()
|
||
j := m.jobs[jobID]
|
||
m.mu.Unlock()
|
||
if j == nil {
|
||
writeJSON(w, http.StatusNotFound, map[string]any{
|
||
"error": map[string]any{"code": "job_not_found"},
|
||
})
|
||
return
|
||
}
|
||
target := "models/promoted/" + jobID + ".nef"
|
||
if len(req.Targets) > 0 && req.Targets[0].TargetObjectKey != "" {
|
||
target = req.Targets[0].TargetObjectKey
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{
|
||
"job_id": jobID,
|
||
"promoted": []map[string]any{{
|
||
"source": "nef",
|
||
"target_object_key": target,
|
||
"size": int64(1024),
|
||
"file_access_agent_etag": "etag-mock",
|
||
}},
|
||
})
|
||
}
|
||
|
||
// markJobCompleted 把 mock 端 jobID 推進到 completed 狀態(給場景 #3 download 用)。
|
||
func (m *mockConverter) markJobCompleted(jobID string) {
|
||
m.mu.Lock()
|
||
defer m.mu.Unlock()
|
||
if j := m.jobs[jobID]; j != nil {
|
||
j.Status = "completed"
|
||
j.Stage = ""
|
||
hundred := 100
|
||
j.Progress = &hundred
|
||
j.UpdatedAt = time.Now().UTC()
|
||
}
|
||
}
|
||
|
||
// converterJobToMap 把 mock 內部結構序列化成 converter API response shape。
|
||
func converterJobToMap(j *conversion.ConverterJob) map[string]any {
|
||
progress := 0
|
||
if j.Progress != nil {
|
||
progress = *j.Progress
|
||
}
|
||
stageProgress := 0
|
||
if j.StageProgress != nil {
|
||
stageProgress = *j.StageProgress
|
||
}
|
||
out := map[string]any{
|
||
"job_id": j.JobID,
|
||
"status": j.Status,
|
||
"stage": j.Stage,
|
||
"progress": progress,
|
||
"stage_progress": stageProgress,
|
||
"created_at": j.CreatedAt.Format(time.RFC3339),
|
||
"updated_at": j.UpdatedAt.Format(time.RFC3339),
|
||
"input": map[string]any{
|
||
"filename": j.SourceFilename,
|
||
},
|
||
"parameters": map[string]any{
|
||
"platform": j.Platform,
|
||
},
|
||
}
|
||
if j.Stage == "" {
|
||
out["stage"] = nil
|
||
}
|
||
return out
|
||
}
|
||
|
||
// ==========================================================================
|
||
// Phase 0.8b T5:mockMC 已整段移除
|
||
// ==========================================================================
|
||
//
|
||
// 原 mockMC 提供 OAuth `client_credentials` service token 與 MC delegated download token;
|
||
// Phase 0.8b 改 pre-shared API key 後(ADR-015 §3 / §6 / §7),visionA 完全不再呼叫 MC,
|
||
// e2e fixture 不需要 mock MC server。
|
||
//
|
||
// 若未來 Phase 1+ 採 ADR-015 §7 選項 B(visionA 自簽 HMAC token + 302 redirect),
|
||
// 也只需 visionA 端有 HMAC_KEY;不需要 mock MC 端。
|
||
|
||
// ==========================================================================
|
||
// mockFAA — Phase 0.8b:模擬 FAA `GET /files/{key}` 回 NEF binary stream
|
||
// (配合 download e2e 從 302 redirect 改 server-side stream proxy 模式)。
|
||
// ==========================================================================
|
||
|
||
type mockFAA struct {
|
||
srv *httptest.Server
|
||
|
||
// 收到的 Authorization header(測試驗 visionA 真有帶 API key)
|
||
mu sync.Mutex
|
||
lastAuthHeader string
|
||
getCallCount atomic.Int32
|
||
|
||
// nefPayload 是模擬的 NEF binary(由測試 setNEFPayload 設定);
|
||
// nil → 預設一個小 marker payload。
|
||
nefPayload []byte
|
||
}
|
||
|
||
func newMockFAA(t *testing.T) *mockFAA {
|
||
t.Helper()
|
||
m := &mockFAA{}
|
||
m.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
// 只處理 GET /files/...(對齊 FAA API spec)
|
||
if r.Method != http.MethodGet || !strings.HasPrefix(r.URL.Path, "/files/") {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
m.getCallCount.Add(1)
|
||
m.mu.Lock()
|
||
m.lastAuthHeader = r.Header.Get("Authorization")
|
||
payload := m.nefPayload
|
||
m.mu.Unlock()
|
||
if payload == nil {
|
||
payload = []byte("mock-nef-default-payload")
|
||
}
|
||
// 模擬 FAA 回 NEF binary stream
|
||
w.Header().Set("Content-Type", "application/octet-stream")
|
||
w.Header().Set("Content-Length", strconv.Itoa(len(payload)))
|
||
w.Header().Set("ETag", "etag-mock-faa")
|
||
w.WriteHeader(http.StatusOK)
|
||
_, _ = w.Write(payload)
|
||
}))
|
||
t.Cleanup(m.srv.Close)
|
||
return m
|
||
}
|
||
|
||
// setNEFPayload 設定下一個(與後續所有)GET /files 回的 binary 內容;
|
||
// 測試端以此控制「user 下載到的 bytes」。
|
||
func (m *mockFAA) setNEFPayload(payload []byte) {
|
||
m.mu.Lock()
|
||
defer m.mu.Unlock()
|
||
m.nefPayload = payload
|
||
}
|
||
|
||
// getLastAuthHeader 取最後一次 GET /files 收到的 Authorization header
|
||
// (測試驗 visionA 帶上正確 Bearer <FAA_API_KEY>)。
|
||
func (m *mockFAA) getLastAuthHeader() string {
|
||
m.mu.Lock()
|
||
defer m.mu.Unlock()
|
||
return m.lastAuthHeader
|
||
}
|
||
|
||
// ==========================================================================
|
||
// conversionFixture — 把所有 server 拼起來並提供 OIDC 登入 helper
|
||
// ==========================================================================
|
||
|
||
type conversionFixture struct {
|
||
server *httptest.Server // visionA backend
|
||
fakeOIDC *oidctest.Server // 給 user 走 OIDC cookie session 登入用
|
||
conv *mockConverter
|
||
faa *mockFAA
|
||
|
||
// Phase 0.8b T5:mc *mockMC 已從 fixture 移除(服務間認證改 API key、不再依賴 MC)。
|
||
|
||
// 重啟模擬:場景 #2 需要在 instance A 不註冊 ownership 直接 instance B 起,
|
||
// 所以保留 lazy 把 conversion service rebuild 進新 router 的 hook。
|
||
router *gin.Engine
|
||
}
|
||
|
||
func (f *conversionFixture) Close() {
|
||
if f.server != nil {
|
||
f.server.Close()
|
||
}
|
||
// fakeOIDC / mocks 由 t.Cleanup 自動關
|
||
}
|
||
|
||
// setupConversionFixture 建立完整的 e2e 環境:
|
||
// - mock converter / FAA(Phase 0.8b 後不再 wire mock MC — API key 模式)
|
||
// - fake OIDC(給 user 走 cookie session 登入)
|
||
// - visionA-backend router(含 conversion service wired,仿 main.go wire 邏輯)
|
||
//
|
||
// **不影響 T1-T7 既有 code**:本 fixture 完全獨立,不重用 setupFixture(後者沒 wire conversion)。
|
||
//
|
||
// Phase 0.8b T5:服務間認證改 pre-shared API key(ADR-015);mock MC / mcTokenClient
|
||
// 已從 fixture 移除;download 走 server-side stream proxy,mockFAA 直接回 NEF binary。
|
||
func setupConversionFixture(t *testing.T) *conversionFixture {
|
||
t.Helper()
|
||
|
||
gin.SetMode(gin.TestMode)
|
||
|
||
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn}))
|
||
|
||
conv := newMockConverter(t)
|
||
faa := newMockFAA(t)
|
||
fakeOIDC := oidctest.NewServer(t,
|
||
oidctest.WithClientCredentials(fixtureOIDCClientID, fixtureOIDCClientSecret),
|
||
)
|
||
|
||
// 用 lazyHandler(既有 helper),因為 storage baseURL 需要 apiServer.URL,
|
||
// 而 storage 又是 router 的依賴 — 必須先 Start server 拿 URL。
|
||
lazy := &lazyHandler{}
|
||
apiTS := httptest.NewServer(lazy)
|
||
t.Cleanup(apiTS.Close)
|
||
|
||
storeDir := t.TempDir()
|
||
storeStore, err := storage.NewLocalFSStore(storeDir, apiTS.URL+"/storage", "test-secret")
|
||
require.NoError(t, err)
|
||
|
||
// OIDC provider(指向 fakeOIDC;user cookie session 登入用)
|
||
callbackURL := apiTS.URL + "/api/auth/callback"
|
||
oidcCtx, oidcCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||
oidcProvider, err := oidc.NewProvider(oidcCtx, oidc.ProviderConfig{
|
||
IssuerURL: fakeOIDC.URL,
|
||
ClientID: fakeOIDC.ClientID,
|
||
ClientSecret: fakeOIDC.ClientSecret,
|
||
RedirectURL: callbackURL,
|
||
})
|
||
oidcCancel()
|
||
require.NoError(t, err)
|
||
|
||
sessionMgr := usersession.NewManager(usersession.NewInMemoryStore(), usersession.CookieConfig{
|
||
Name: "visiona_session",
|
||
Path: "/",
|
||
HTTPOnly: true,
|
||
SameSite: http.SameSiteLaxMode,
|
||
MaxAge: 86400,
|
||
SigningKey: []byte(fixtureSessionSecret),
|
||
})
|
||
|
||
// === 組 conversion service(模擬 main.go wire 邏輯;mocks 替換真實 endpoint) ===
|
||
//
|
||
// Phase 0.8b T5:服務間認證改 pre-shared API key(ADR-015);
|
||
// - 不再 wire MCTokenClient / Tokens 欄位
|
||
// - converter / FAA client 各自帶 fixture 用的 API key
|
||
// - mock converter / FAA 端不驗 key(測試重點是 visionA 端的 wire 行為與 stream proxy)
|
||
//
|
||
// 注意:converter_client / faa_client 都用 5s timeout HTTPClient 避免測試卡死;
|
||
// 對 mock servers 來說連線秒回,timeout 不會觸發。
|
||
fastHTTP := &http.Client{Timeout: 5 * time.Second}
|
||
const fixtureConverterAPIKey = "fixture-converter-api-key-do-not-use-in-prod-aaaaaaaaaaaaaaaaaa"
|
||
const fixtureFAAAPIKey = "fixture-faa-api-key-do-not-use-in-prod-bbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||
converterAPIClient := conversion.NewConverterClient(conversion.ConverterClientOpts{
|
||
BaseURL: conv.srv.URL,
|
||
APIKey: fixtureConverterAPIKey,
|
||
HTTPClient: fastHTTP,
|
||
InitHTTPClient: &http.Client{Timeout: 60 * time.Second}, // 場景 #1 大 body 給寬一點
|
||
Logger: logger,
|
||
})
|
||
faaAPIClient := conversion.NewFAAClient(conversion.FAAClientOpts{
|
||
BaseURL: faa.srv.URL,
|
||
APIKey: fixtureFAAAPIKey,
|
||
HTTPClient: fastHTTP,
|
||
Logger: logger,
|
||
})
|
||
ownership := conversion.NewOwnership(converterAPIClient, logger)
|
||
|
||
modelRepo := model.NewInMemoryRepository()
|
||
modelStoreAdapter := newConversionModelStoreAdapter(modelRepo)
|
||
storageAdapter := newConversionStorageAdapter(storeStore)
|
||
|
||
conversionService, err := conversion.NewService(conversion.FlowOpts{
|
||
Converter: converterAPIClient,
|
||
FAA: faaAPIClient,
|
||
Ownership: ownership,
|
||
ModelStore: modelStoreAdapter,
|
||
Storage: storageAdapter,
|
||
Logger: logger,
|
||
})
|
||
require.NoError(t, err)
|
||
|
||
// === Build router(含 conversion) ===
|
||
pairingStore := auth.NewInMemoryPairingStore()
|
||
router := api.NewRouter(api.Deps{
|
||
Logger: logger,
|
||
PairingStore: pairingStore,
|
||
SessionTokenStore: auth.NewInMemorySessionTokenStore(),
|
||
// 不需要真實 SessionStore / Forwarder(conversion endpoint 不依賴);
|
||
// 但 NewRouter validate 需要某些非 nil 欄位 — 用 stub。
|
||
SessionStore: session.NewProxyClientStore(
|
||
session.NewHTTPProxyClient("http://127.0.0.1:1", logger),
|
||
session.NewForwarder("http://127.0.0.1:1", logger),
|
||
),
|
||
Forwarder: session.NewForwarder("http://127.0.0.1:1", logger),
|
||
DeviceRepo: device.NewInMemoryRepository(),
|
||
ModelRepo: modelRepo,
|
||
Storage: storeStore,
|
||
Converter: converter.NewStubClient(),
|
||
Conversion: conversionService,
|
||
MaxUploadSizeMB: 0,
|
||
OIDCProvider: oidcProvider,
|
||
SessionManager: sessionMgr,
|
||
OIDCPostLoginURL: apiTS.URL,
|
||
})
|
||
lazy.Set(router)
|
||
|
||
return &conversionFixture{
|
||
server: apiTS,
|
||
fakeOIDC: fakeOIDC,
|
||
conv: conv,
|
||
faa: faa,
|
||
router: router,
|
||
}
|
||
}
|
||
|
||
// AuthenticatedClient 走完整 OIDC login flow;複製自 oidc_test_helper_test.go 的 pattern
|
||
// 但綁本檔的 conversionFixture(fakeOIDC / apiServer)。
|
||
//
|
||
// 不直接 reuse testFixture.AuthenticatedClient,因為那個綁的是 setupFixture 的 testFixture
|
||
// 結構;我們的 conversionFixture 是獨立 type。
|
||
func (f *conversionFixture) AuthenticatedClient(t *testing.T, userID, email string) *http.Client {
|
||
t.Helper()
|
||
|
||
f.fakeOIDC.SetNextIDTokenClaims(map[string]any{
|
||
"sub": userID,
|
||
"email": email,
|
||
"name": userID,
|
||
})
|
||
|
||
jar, err := cookiejar.New(nil)
|
||
require.NoError(t, err)
|
||
flowClient := &http.Client{
|
||
Jar: jar,
|
||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||
return http.ErrUseLastResponse
|
||
},
|
||
Timeout: 10 * time.Second,
|
||
}
|
||
|
||
loc := getExpect302(t, flowClient, f.server.URL+"/api/auth/login")
|
||
require.True(t, strings.HasPrefix(loc, f.fakeOIDC.URL+"/authorize"),
|
||
"login 應 302 to fakeOIDC /authorize,得 %s", loc)
|
||
|
||
cb := f.fakeOIDC.SimulateAuthorizationFlow(t, loc)
|
||
_ = getExpect302(t, flowClient, cb)
|
||
|
||
u, err := url.Parse(f.server.URL)
|
||
require.NoError(t, err)
|
||
var sess *http.Cookie
|
||
for _, c := range jar.Cookies(u) {
|
||
if c.Name == "visiona_session" {
|
||
sess = c
|
||
break
|
||
}
|
||
}
|
||
require.NotNil(t, sess, "expected visiona_session cookie")
|
||
|
||
return &http.Client{Jar: jar, Timeout: 30 * time.Second}
|
||
}
|
||
|
||
// ==========================================================================
|
||
// E2E #1:Streaming proxy 完整跑通
|
||
// ==========================================================================
|
||
|
||
// TestConversionE2E_StreamingProxy 驗 visionA 的 InitJob 真的 streaming —
|
||
// 用 io.Pipe 對 visionA 送大量 multipart body(~10MB),驗:
|
||
//
|
||
// 1. mock converter 收到的 body 解析後能取出 visionA 灌的 user_id(OIDC sub)
|
||
// 2. mock converter 收到的 model file 內容與 client 端送的 byte-perfect 一致
|
||
// 3. response 201 + job_id
|
||
// 4. visionA backend 沒在記憶體 buffer 整個 body(透過 streaming 行為 + 沒 OOM 隱含驗證)
|
||
//
|
||
// 體積 10MB 而非 100MB:CI 上跑 race -count=3,每次都建 100MB buffer 太貴;
|
||
// 10MB 已能驗 streaming 行為(若 visionA 有 buffer 全 RAM,10MB 也會被測出來:
|
||
// io.Pipe 的 reader 卡住 → mock converter 永遠收不到完整 body → handler 200ms 內失敗)。
|
||
func TestConversionE2E_StreamingProxy(t *testing.T) {
|
||
f := setupConversionFixture(t)
|
||
defer f.Close()
|
||
|
||
const wantSub = "user-streaming-001"
|
||
client := f.AuthenticatedClient(t, wantSub, "stream@e2e.local")
|
||
|
||
// 產生 ~10MB 隨機 model file content(mock 收到後比對 byte-perfect)
|
||
modelBytes := make([]byte, 10*1024*1024)
|
||
_, err := rand.Read(modelBytes)
|
||
require.NoError(t, err)
|
||
|
||
// 用 io.Pipe + multipart.Writer 邊產 body 邊送(streaming;沒在記憶體組整個 body)
|
||
pr, pw := io.Pipe()
|
||
mw := multipart.NewWriter(pw)
|
||
contentType := mw.FormDataContentType()
|
||
|
||
go func() {
|
||
defer pw.Close()
|
||
defer mw.Close()
|
||
// 順序:先寫 form fields,再寫 file(converter multer 慣例)
|
||
_ = mw.WriteField("model_id", "12345")
|
||
_ = mw.WriteField("version", "v1.0.0")
|
||
_ = mw.WriteField("platform", "720")
|
||
// 嘗試塞 user_id(攻擊者場景)— 驗 visionA 黑名單
|
||
_ = mw.WriteField("user_id", "ATTACKER-OVERRIDE")
|
||
|
||
fw, _ := mw.CreateFormFile("model", "yolov5s.onnx")
|
||
// chunked write(每次寫 64KB;確保走 streaming 路徑)
|
||
for i := 0; i < len(modelBytes); i += 64 * 1024 {
|
||
end := i + 64*1024
|
||
if end > len(modelBytes) {
|
||
end = len(modelBytes)
|
||
}
|
||
if _, werr := fw.Write(modelBytes[i:end]); werr != nil {
|
||
return
|
||
}
|
||
}
|
||
}()
|
||
|
||
req, err := http.NewRequest(http.MethodPost, f.server.URL+"/api/conversion/init", pr)
|
||
require.NoError(t, err)
|
||
req.Header.Set("Content-Type", contentType)
|
||
|
||
resp, err := client.Do(req)
|
||
require.NoError(t, err)
|
||
defer resp.Body.Close()
|
||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||
require.Equal(t, http.StatusCreated, resp.StatusCode, "body=%s", string(bodyBytes))
|
||
|
||
// 驗 response shape
|
||
var apiResp map[string]any
|
||
require.NoError(t, json.Unmarshal(bodyBytes, &apiResp))
|
||
require.Equal(t, true, apiResp["success"])
|
||
data := apiResp["data"].(map[string]any)
|
||
jobID, _ := data["job_id"].(string)
|
||
require.NotEmpty(t, jobID)
|
||
require.Equal(t, "running", data["status"])
|
||
|
||
// 驗 mock converter 收到的 body 真的有解出 user_id(且為 visionA 灌的 OIDC sub)
|
||
gotBody, gotCT, gotLen := f.conv.initBodySnapshot()
|
||
require.NotZero(t, gotLen, "mock converter 應收到 non-empty body")
|
||
require.Greater(t, gotLen, int64(10*1024*1024), "body 至少 10MB(含 multipart overhead 應略大)")
|
||
|
||
gotUserID, ok := parseMultipartField(gotCT, gotBody, "user_id")
|
||
require.True(t, ok, "mock converter 收到的 multipart 應含 user_id field")
|
||
require.Equal(t, wantSub, gotUserID,
|
||
"visionA 必須注入 OIDC sub 為 user_id(不能採用 client 端塞的 ATTACKER-OVERRIDE)")
|
||
|
||
// 驗 model file 內容 byte-perfect(streaming forward 沒掉 byte / 沒亂改)
|
||
gotModel, ok := parseMultipartFile(gotCT, gotBody, "model")
|
||
require.True(t, ok, "mock converter 收到的 multipart 應含 model file")
|
||
require.Equal(t, len(modelBytes), len(gotModel), "model file 長度應 byte-perfect 一致")
|
||
require.True(t, bytes.Equal(modelBytes, gotModel), "model file content 應 byte-perfect 一致")
|
||
|
||
require.Equal(t, int32(1), f.conv.initCallCount.Load(), "converter init 應被打 1 次")
|
||
}
|
||
|
||
// ==========================================================================
|
||
// E2E #2:重啟恢復 lazy rebuild
|
||
// ==========================================================================
|
||
|
||
// TestConversionE2E_LazyRebuildAfterRestart 驗 visionA backend 重啟後(in-memory
|
||
// ownership 全空),user 對 GET /active 仍能拿到 in_progress job — 透過對 converter
|
||
// ListInProgressJobs 觸發 lazy rebuild。
|
||
//
|
||
// 流程:
|
||
// 1. 起 instance A、預先在 mock converter 端註冊 user X 有 1 個 in_progress job
|
||
// (模擬:user X 之前 init 過,但 visionA-backend 重啟導致 in-memory ownership 丟失)
|
||
// 2. user X 透過 instance A 對 /active 打第一次 → 回 has_active:true(lazy rebuild)
|
||
// 3. 驗 mock converter 的 ListInProgressJobs 被打 1 次
|
||
// 4. user X 對 instance A 對 /active 打第二次 → 仍回 has_active:true,但
|
||
// ListInProgressJobs **沒有**再被打(cache hit / rebuilt flag set)
|
||
//
|
||
// 注意:題目說「啟動 instance B(模擬重啟)— 沿用同一個 mock converter」;實作上
|
||
// 「instance B」就是「重新 setupConversionFixture 但共用 mock converter」。但 instance A
|
||
// 從來沒有 init 過任何 job(題目 #2 的前提就是 in-memory ownership 全空),所以 instance A
|
||
// 本身已等同「重啟後的乾淨 instance」— 不需要真的開兩個 server,這樣場景測得更乾淨。
|
||
func TestConversionE2E_LazyRebuildAfterRestart(t *testing.T) {
|
||
f := setupConversionFixture(t)
|
||
defer f.Close()
|
||
|
||
const wantSub = "user-rebuild-002"
|
||
|
||
// 預先在 mock converter 端註冊 user X 有 1 個 in_progress job(模擬 visionA 重啟前的狀態)
|
||
preexistingJobID := "job-preexisting-001"
|
||
createdAt := time.Now().Add(-1 * time.Hour).UTC()
|
||
f.conv.addInProgressJob(wantSub, preexistingJobID, createdAt)
|
||
|
||
client := f.AuthenticatedClient(t, wantSub, "rebuild@e2e.local")
|
||
|
||
// 第一次 /active → 觸發 lazy rebuild(visionA 對 converter 打 ListInProgressJobs)
|
||
resp1 := getJSONReq(t, client, f.server.URL+"/api/conversion/active")
|
||
require.Equal(t, http.StatusOK, resp1.status, "body=%v", resp1.body)
|
||
require.Equal(t, true, resp1.body["success"])
|
||
data1 := resp1.body["data"].(map[string]any)
|
||
assert.Equal(t, true, data1["has_active"],
|
||
"lazy rebuild 後應拿到 active job(visionA 從 converter 重建 ownership)")
|
||
job1 := data1["job"].(map[string]any)
|
||
assert.Equal(t, preexistingJobID, job1["job_id"],
|
||
"應拿回 mock 端那個預先註冊的 job_id")
|
||
|
||
// list 應被打 1 次
|
||
listCount1 := f.conv.listJobsCallCount.Load()
|
||
require.Equal(t, int32(1), listCount1, "lazy rebuild 應打 ListInProgressJobs 1 次")
|
||
|
||
// 第二次 /active → 走 cache(rebuilt flag set),不再打 list
|
||
resp2 := getJSONReq(t, client, f.server.URL+"/api/conversion/active")
|
||
require.Equal(t, http.StatusOK, resp2.status)
|
||
data2 := resp2.body["data"].(map[string]any)
|
||
assert.Equal(t, true, data2["has_active"], "第二次仍應有 active")
|
||
|
||
listCount2 := f.conv.listJobsCallCount.Load()
|
||
assert.Equal(t, listCount1, listCount2,
|
||
"第二次 /active 應走 cache,不再打 ListInProgressJobs(實際多了 %d 次)",
|
||
listCount2-listCount1)
|
||
}
|
||
|
||
// ==========================================================================
|
||
// E2E #3:Download server-side stream proxy(Phase 0.8b)
|
||
// ==========================================================================
|
||
|
||
// TestConversionE2E_DownloadStream 驗 Phase 0.8b 後 download 端對端行為:
|
||
//
|
||
// 1. user X 對 completed job 打 /download → status 200 OK
|
||
// 2. response header:
|
||
// - Content-Type: application/octet-stream
|
||
// - Content-Disposition: attachment; filename="..."(filename 經 sanitize)
|
||
// - Cache-Control: no-store, no-cache, must-revalidate, max-age=0
|
||
// 3. response body bytes 與 mock FAA 寫的 binary 一致(byte-perfect)
|
||
// 4. mock FAA 收到 visionA 帶的 Authorization: Bearer <fixture FAA API key>
|
||
// (驗 visionA 端真的用 API key wire 對下游發 request)
|
||
// 5. **沒有** 302 / Location header / token 結構性流經 frontend
|
||
// (Phase 0.8b 設計核心:server-side proxy 取代 delegated token redirect)
|
||
//
|
||
// 對齊 api-conversion.md §4 (Phase 0.8b) + conversion.md §4.1 + ADR-015 §7。
|
||
//
|
||
// 流程:
|
||
// - 起 fixture(mock FAA 預設回 small NEF binary marker)
|
||
// - user X init 一個 job → mock converter 自動建 running job
|
||
// - markJobCompleted(jobID) 把 mock job 推進 completed
|
||
// - 對 /download 打 GET — client 設 ErrUseLastResponse 防止意外 follow(雖預期非 302)
|
||
// - 驗以上 5 點
|
||
func TestConversionE2E_DownloadStream(t *testing.T) {
|
||
f := setupConversionFixture(t)
|
||
defer f.Close()
|
||
|
||
// 設定 mock FAA 回的 NEF binary(測試端控制 byte-perfect 比對)
|
||
const wantNEFContent = "PHASE-0.8b-MOCK-NEF-BINARY-PAYLOAD-FROM-FAA-STREAM-1234567890"
|
||
f.faa.setNEFPayload([]byte(wantNEFContent))
|
||
|
||
const wantSub = "user-download-003"
|
||
client := f.AuthenticatedClient(t, wantSub, "download@e2e.local")
|
||
|
||
// 1. 先 init 一個 job(讓 visionA 端寫 ownership + mock converter 建 running job)
|
||
jobID := initSimpleJob(t, client, f.server.URL)
|
||
|
||
// 2. 把 mock converter 端 job 推進到 completed(給 download 用)
|
||
f.conv.markJobCompleted(jobID)
|
||
|
||
// 3. 對 /download 打 GET — 設 ErrUseLastResponse 防意外 follow(預期非 302)
|
||
noRedirectClient := &http.Client{
|
||
Jar: client.Jar,
|
||
Timeout: 10 * time.Second,
|
||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||
return http.ErrUseLastResponse
|
||
},
|
||
}
|
||
|
||
resp, err := noRedirectClient.Get(f.server.URL + "/api/conversion/" + jobID + "/download")
|
||
require.NoError(t, err)
|
||
defer resp.Body.Close()
|
||
|
||
bodyBytes, err := io.ReadAll(resp.Body)
|
||
require.NoError(t, err)
|
||
|
||
// === 斷言 1:status 200 OK(Phase 0.8b 不再回 302)===
|
||
require.Equal(t, http.StatusOK, resp.StatusCode,
|
||
"Phase 0.8b 後 /download 回 200(server-side stream proxy),不再 302;body=%s",
|
||
string(bodyBytes))
|
||
|
||
// === 斷言 2:response header 對齊 api-conversion.md §4 ===
|
||
assert.Equal(t, "application/octet-stream", resp.Header.Get("Content-Type"),
|
||
"Content-Type 應為 application/octet-stream(觸發 browser download dialog)")
|
||
|
||
cd := resp.Header.Get("Content-Disposition")
|
||
assert.True(t, strings.HasPrefix(cd, "attachment; filename="),
|
||
"Content-Disposition 應為 attachment; filename=...,得 %s", cd)
|
||
// filename 應已被 sanitize(不含控制字元 / path sep / quote)
|
||
assert.NotContains(t, cd, "\r", "Content-Disposition 不應含 \\r(CRLF injection 防護)")
|
||
assert.NotContains(t, cd, "\n", "Content-Disposition 不應含 \\n(CRLF injection 防護)")
|
||
// filename 應對齊 wireframe §8.1:<source_filename_stem>_<chip_lower>.nef
|
||
// (mock converter 建的 job source_filename=yolov5s.onnx + platform=720 → yolov5s_kl720.nef)
|
||
assert.Contains(t, cd, ".nef",
|
||
"filename 應以 .nef 結尾(NEF 結果檔),得 %s", cd)
|
||
|
||
cc := resp.Header.Get("Cache-Control")
|
||
assert.Contains(t, cc, "no-store",
|
||
"Cache-Control 應含 no-store(避免 browser cache private NEF)")
|
||
assert.Contains(t, cc, "no-cache",
|
||
"Cache-Control 應含 no-cache,得 %s", cc)
|
||
assert.Contains(t, cc, "must-revalidate",
|
||
"Cache-Control 應含 must-revalidate,得 %s", cc)
|
||
|
||
// === 斷言 3:response body byte-perfect 對齊 mock FAA 寫的 binary ===
|
||
assert.Equal(t, wantNEFContent, string(bodyBytes),
|
||
"response body 應等於 mock FAA 寫的 NEF binary(byte-perfect stream proxy)")
|
||
|
||
// === 斷言 4:mock FAA 收到 visionA 帶的 Authorization Bearer <API key> ===
|
||
authHeader := f.faa.getLastAuthHeader()
|
||
assert.True(t, strings.HasPrefix(authHeader, "Bearer "),
|
||
"mock FAA 應收到 Bearer 開頭的 Authorization header,得 %q", authHeader)
|
||
assert.Contains(t, authHeader, "fixture-faa-api-key-do-not-use-in-prod",
|
||
"mock FAA 應收到 fixture FAA API key(驗 visionA 端 wire 正確)")
|
||
|
||
// === 斷言 5:沒有 302 / Location header(Phase 0.8b 結構性無 redirect)===
|
||
assert.NotEqual(t, http.StatusFound, resp.StatusCode,
|
||
"Phase 0.8b 不再回 302 Found(取消 delegated token 機制)")
|
||
assert.Empty(t, resp.Header.Get("Location"),
|
||
"Phase 0.8b 不應有 Location header(無 redirect 流程)")
|
||
|
||
// 驗 mock FAA 真的被打到(防 mock 路徑 wire 錯)
|
||
assert.GreaterOrEqual(t, int(f.faa.getCallCount.Load()), 1,
|
||
"mock FAA GET /files 應至少被打一次")
|
||
}
|
||
|
||
// ==========================================================================
|
||
// E2E #4:Active job 409 衝突
|
||
// ==========================================================================
|
||
|
||
// TestConversionE2E_ActiveJobConflict 驗:同一 user 在 visionA 端有 active job 時,
|
||
// 第二個 init 應回 409 + body 含 active_job 詳情。
|
||
//
|
||
// 流程:
|
||
// 1. user X 第一個 init → 200 + 取得 jobID1(visionA 寫 ownership)
|
||
// 2. user X 第二個 init → visionA pre-check 命中 ownership.ActiveJobOf(userID) 不為空
|
||
// → flow.checkActiveJob 對 mock converter GetJob jobID1 → status=running(active)
|
||
// → 回 *ActiveJobError,handler 包成 409 + extra.active_job
|
||
// 3. 驗 status 409 + body.error.code == "active_job_exists" + extra.active_job.job_id == jobID1
|
||
//
|
||
// 注意:題目原本要求「mock converter 第二次回 409 user_has_active_job」 —
|
||
// 但實際上 visionA pre-check 會在打 converter 之前就 short-circuit(§9.3 流程圖):
|
||
// 因此第二個 init 根本不會打到 converter init endpoint。這個行為更安全(少一次浪費 round-trip),
|
||
// 我們驗 visionA 自己的 pre-check 有效,並驗 active_job extra payload。
|
||
//
|
||
// 若要驗「converter 端也有同樣保護」由 internal/conversion/converter_client_test.go
|
||
// 的 ActiveJobError mapping test 涵蓋(T3 已驗)。
|
||
func TestConversionE2E_ActiveJobConflict(t *testing.T) {
|
||
f := setupConversionFixture(t)
|
||
defer f.Close()
|
||
|
||
const wantSub = "user-conflict-004"
|
||
client := f.AuthenticatedClient(t, wantSub, "conflict@e2e.local")
|
||
|
||
// 第一次 init → 應 201
|
||
jobID1 := initSimpleJob(t, client, f.server.URL)
|
||
require.NotEmpty(t, jobID1)
|
||
|
||
// 第二次 init → 應撞 409
|
||
resp2 := postSimpleInit(t, client, f.server.URL)
|
||
defer resp2.Body.Close()
|
||
body2, _ := io.ReadAll(resp2.Body)
|
||
require.Equal(t, http.StatusConflict, resp2.StatusCode,
|
||
"第二次 init 應 409;body=%s", string(body2))
|
||
|
||
var apiResp map[string]any
|
||
require.NoError(t, json.Unmarshal(body2, &apiResp))
|
||
require.Equal(t, false, apiResp["success"])
|
||
errObj := apiResp["error"].(map[string]any)
|
||
assert.Equal(t, "active_job_exists", errObj["code"])
|
||
|
||
// extra.active_job.job_id 應為 jobID1
|
||
extra, ok := errObj["extra"].(map[string]any)
|
||
require.True(t, ok, "error.extra 應存在(帶 active_job 詳情),實際 error=%v", errObj)
|
||
activeJob, ok := extra["active_job"].(map[string]any)
|
||
require.True(t, ok, "extra.active_job 應為 object")
|
||
assert.Equal(t, jobID1, activeJob["job_id"],
|
||
"active_job.job_id 應指向第一個 init 建立的 job")
|
||
|
||
// 驗第二次 init **沒有真的打到** mock converter init endpoint(visionA pre-check 短路)
|
||
assert.Equal(t, int32(1), f.conv.initCallCount.Load(),
|
||
"第二次 init 應被 visionA pre-check 短路;mock converter init 應只被打 1 次(實際 %d 次)",
|
||
f.conv.initCallCount.Load())
|
||
}
|
||
|
||
// ==========================================================================
|
||
// 共用 helper
|
||
// ==========================================================================
|
||
|
||
// initSimpleJob 對 visionA 送一個 minimal multipart init request,回 jobID。
|
||
func initSimpleJob(t *testing.T, client *http.Client, baseURL string) string {
|
||
t.Helper()
|
||
resp := postSimpleInit(t, client, baseURL)
|
||
defer resp.Body.Close()
|
||
body, _ := io.ReadAll(resp.Body)
|
||
require.Equal(t, http.StatusCreated, resp.StatusCode, "init body=%s", string(body))
|
||
var apiResp map[string]any
|
||
require.NoError(t, json.Unmarshal(body, &apiResp))
|
||
data := apiResp["data"].(map[string]any)
|
||
id, _ := data["job_id"].(string)
|
||
require.NotEmpty(t, id)
|
||
return id
|
||
}
|
||
|
||
// postSimpleInit 送一個 minimal multipart init;回 raw response(caller defer Close)。
|
||
func postSimpleInit(t *testing.T, client *http.Client, baseURL string) *http.Response {
|
||
t.Helper()
|
||
var buf bytes.Buffer
|
||
mw := multipart.NewWriter(&buf)
|
||
_ = mw.WriteField("model_id", "12345")
|
||
_ = mw.WriteField("version", "v1.0.0")
|
||
_ = mw.WriteField("platform", "720")
|
||
fw, _ := mw.CreateFormFile("model", "yolov5s.onnx")
|
||
_, _ = fw.Write([]byte("dummy-onnx-bytes"))
|
||
_ = mw.Close()
|
||
|
||
req, err := http.NewRequest(http.MethodPost, baseURL+"/api/conversion/init", &buf)
|
||
require.NoError(t, err)
|
||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||
resp, err := client.Do(req)
|
||
require.NoError(t, err)
|
||
return resp
|
||
}
|
||
|
||
// getJSONReq 對 client 打 GET 並 parse JSON。複製自 oidc_e2e_test 的 getJSON
|
||
// 但獨立命名避免 helper 命名衝突。
|
||
func getJSONReq(t *testing.T, client *http.Client, target string) jsonResp {
|
||
t.Helper()
|
||
resp, err := client.Get(target)
|
||
require.NoError(t, err)
|
||
defer resp.Body.Close()
|
||
body, _ := io.ReadAll(resp.Body)
|
||
out := jsonResp{status: resp.StatusCode, body: map[string]any{}}
|
||
if len(body) > 0 {
|
||
_ = json.Unmarshal(body, &out.body)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// parseMultipartField 從 raw multipart body 取 form field 的值。
|
||
func parseMultipartField(contentType string, body []byte, fieldName string) (string, bool) {
|
||
parts, ok := iterMultipart(contentType, body)
|
||
if !ok {
|
||
return "", false
|
||
}
|
||
for _, p := range parts {
|
||
if p.name == fieldName && p.filename == "" {
|
||
return string(p.body), true
|
||
}
|
||
}
|
||
return "", false
|
||
}
|
||
|
||
// parseMultipartFile 從 raw multipart body 取 file part 的內容。
|
||
func parseMultipartFile(contentType string, body []byte, fieldName string) ([]byte, bool) {
|
||
parts, ok := iterMultipart(contentType, body)
|
||
if !ok {
|
||
return nil, false
|
||
}
|
||
for _, p := range parts {
|
||
if p.name == fieldName && p.filename != "" {
|
||
return p.body, true
|
||
}
|
||
}
|
||
return nil, false
|
||
}
|
||
|
||
// multipartPart 是 iterMultipart 的中間結構。
|
||
type multipartPart struct {
|
||
name string
|
||
filename string
|
||
body []byte
|
||
}
|
||
|
||
// iterMultipart 解 raw multipart body → []multipartPart。
|
||
func iterMultipart(contentType string, body []byte) ([]multipartPart, bool) {
|
||
_, params, err := mime.ParseMediaType(contentType)
|
||
if err != nil {
|
||
return nil, false
|
||
}
|
||
boundary := params["boundary"]
|
||
if boundary == "" {
|
||
return nil, false
|
||
}
|
||
mr := multipart.NewReader(bytes.NewReader(body), boundary)
|
||
out := make([]multipartPart, 0, 4)
|
||
for {
|
||
part, err := mr.NextPart()
|
||
if errors.Is(err, io.EOF) {
|
||
return out, true
|
||
}
|
||
if err != nil {
|
||
return nil, false
|
||
}
|
||
raw, _ := io.ReadAll(part)
|
||
_ = part.Close()
|
||
out = append(out, multipartPart{
|
||
name: part.FormName(),
|
||
filename: part.FileName(),
|
||
body: raw,
|
||
})
|
||
}
|
||
}
|
||
|
||
// writeJSON 是 mock server handler 共用的 JSON 回應 helper。
|
||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(status)
|
||
_ = json.NewEncoder(w).Encode(v)
|
||
}
|
||
|