// 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 拉 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 )。 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 // (驗 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:_.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 === 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) }