對齊 ADR-016:visionA backend 不再直連 FAA、download 改走 converter GetResult。T3 砍除 v0.5 階段為 FAA delegated token 路線留的 faa_client.go 整檔 + 對應 sentinel + flow / e2e 殘留。 砍除: - internal/conversion/faa_client.go(整檔) - internal/conversion/faa_client_test.go(整檔) - errors.go: ErrFAAFileNotFound + ErrFAAAuthFailed 2 sentinel(+ ErrorCode/HTTPStatus mapping) - flow.go: faa FAAClient 欄位 + FlowOpts.FAA 必填 + a-h T3 預期清單 godoc - flow_test.go: flowStubFAA struct + newFlowStubFAA helper + fixture.faa - internal/api/conversion_test.go: TestConversion_Download_FAAAuthFailed - cmd/api-server/main.go: NewFAAClient wire + FAA: faaAPIClient field 保留: - ErrFAAUnavailable(converter promote 仍 PUT FAA、502 透傳路徑需要) - hashObjectKey helper 搬到 util.go(ownership 仍用) - e2e mockFAA 精簡為 regression-only(保留 negative assertion: FAA 0 命中)— reviewer 推薦雙層防護 新增(T3 必補,T1/T2 reviewer 累積): - s-3 TestDownloadStream_ConverterValidationFailed_Propagation(converter 4xx fallback → ErrValidationFailed 透傳) - s-4 TestPromoteToModels_StorageError_StreamClosed(instrumented stream wrapper 驗 fd leak 防護) - s-5 TestParseFilenameFromContentDisposition 9 個 sub-case(3 RFC 5987 + 5 hostile-input + 1 empty quoted) 發現:Go stdlib 自動 percent-decode RFC 5987 並寫入 params["filename"]、RFC 5987 優先於 ASCII filename T3 review M-1 修補(commit 內含): - internal/api/conversion.go:51,56 godoc + 501 user-facing message 從「FAA_BASE_URL」改為「VISIONA_CONVERTER_BASE_URL + VISIONA_CONVERTER_API_KEY」 - 對齊 ADR-016 visionA 端不再有 FAA 直連設計 驗證: - B 層 verification 強制跑(reviewer 規定 T3 不接受暫緩): * 跨檔 grep: MC chain 0 / FAA functional refs 0 / TenantID 0 * API contract test: TestConversionE2E_DownloadStream 6 斷言含 FAA negative * 安全 manual review: path traversal / unbounded read / secret in log / error mask 4 項 - go build ./... exit 0 - go test -race -count=3 ./... 17 packages 全綠 - Reviewer 5 軸(v0.6-t3-review)⚠️→ ✅ 通過(M-1 已修) a-h 8 條清單 100% 達成(逐條 grep 驗收);mockFAA 選方案 1(保留 + negative assertion)— 雙層防護。 下一步: - T4 砍 ConversionConfig.FAAAPIKey/FAABaseURL + load.go env 讀取 + .env*.example + m-2 i18n dead case 一併 - T5 main.go startup log 整理 + e2e regression 防護 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1238 lines
47 KiB
Go
1238 lines
47 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。
|
||
//
|
||
// 對應 5 個 endpoint(converter_client.go 註解列表;Phase 0.8b v0.6 新增 GetResult):
|
||
// - POST /api/v1/jobs — InitJob(multipart streaming)
|
||
// - GET /api/v1/jobs/{id} — GetJob
|
||
// - POST /api/v1/jobs/{id}/promote — Promote
|
||
// - GET /api/v1/jobs/{id}/result — GetResult(v0.6 新增;ADR-016 §1)
|
||
// - 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
|
||
getResultCallCount atomic.Int32 // v0.6(ADR-016 §1)
|
||
|
||
// 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
|
||
|
||
// v0.6:getResult endpoint 用的 NEF binary payload 與 last auth header(取代原 mock FAA)
|
||
resultMu sync.Mutex
|
||
resultPayload []byte // nil → 預設一個小 marker payload
|
||
resultLastAuthHeader string
|
||
}
|
||
|
||
// 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)
|
||
case "result":
|
||
// v0.6(ADR-016 §1):GET /api/v1/jobs/{id}/result — visionA download path 改走此 endpoint
|
||
if r.Method != http.MethodGet {
|
||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||
return
|
||
}
|
||
m.handleGetResult(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",
|
||
}},
|
||
})
|
||
}
|
||
|
||
// handleGetResult 模擬 GET /api/v1/jobs/{id}/result(v0.6 / ADR-016 §1)。
|
||
//
|
||
// 行為:
|
||
// - 紀錄收到的 Authorization header(給 E2E 驗證 visionA 帶上 converter API key)
|
||
// - job 不存在 → 404 `job_not_found`
|
||
// - job 存在但 status != completed → 409 `job_not_completed`
|
||
// - 否則 200 + Content-Type/Length/Disposition + body
|
||
// (payload 由 setResultPayload 控制;nil 用 default marker)
|
||
func (m *mockConverter) handleGetResult(w http.ResponseWriter, r *http.Request, jobID string) {
|
||
m.getResultCallCount.Add(1)
|
||
|
||
m.resultMu.Lock()
|
||
m.resultLastAuthHeader = r.Header.Get("Authorization")
|
||
payload := m.resultPayload
|
||
m.resultMu.Unlock()
|
||
if payload == nil {
|
||
payload = []byte("mock-converter-result-default-payload")
|
||
}
|
||
|
||
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
|
||
}
|
||
if j.Status != "completed" {
|
||
writeJSON(w, http.StatusConflict, map[string]any{
|
||
"error": map[string]any{"code": "job_not_completed"},
|
||
"status": j.Status,
|
||
})
|
||
return
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/octet-stream")
|
||
w.Header().Set("Content-Length", strconv.Itoa(len(payload)))
|
||
// converter 給的 filename 是 object_key 派生(對 user 不直觀);visionA backend 端會用
|
||
// defaultDownloadFilename(cj) 覆寫成 <stem>_<chip>.nef 對齊 wireframe
|
||
w.Header().Set("Content-Disposition", `attachment; filename="`+jobID+`.nef"`)
|
||
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
|
||
w.WriteHeader(http.StatusOK)
|
||
_, _ = w.Write(payload)
|
||
}
|
||
|
||
// setResultPayload 設定 GET /result 回的 binary 內容(測試端控制 byte-perfect 比對)。
|
||
func (m *mockConverter) setResultPayload(payload []byte) {
|
||
m.resultMu.Lock()
|
||
defer m.resultMu.Unlock()
|
||
m.resultPayload = payload
|
||
}
|
||
|
||
// getResultLastAuthHeader 取最後一次 GET /result 收到的 Authorization header
|
||
// (測試驗 visionA 帶上正確 Bearer <CONVERTER_API_KEY>)。
|
||
func (m *mockConverter) getResultLastAuthHeader() string {
|
||
m.resultMu.Lock()
|
||
defer m.resultMu.Unlock()
|
||
return m.resultLastAuthHeader
|
||
}
|
||
|
||
// 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 — **Regression-only**(Phase 0.8b v0.6 T3 起)。
|
||
//
|
||
// 用途已演進:
|
||
// - Phase 0.8b v0.4 / v0.5:模擬 FAA `GET /files/{key}` 回 NEF binary stream,
|
||
// visionA backend 端真的會打它(API key 認證)
|
||
// - **Phase 0.8b v0.6 T3 起(本 commit)**:visionA backend 端不再直接打 FAA
|
||
// (ADR-016 撤回;FAAClient interface + faa_client.go 整檔刪除)。mockFAA server
|
||
// **保留**作為「e2e regression 防護」—— 若未來某 agent 不小心把 FAA 直連加回
|
||
// production code(例如「optimize: 直接打 FAA 跳一層」、或誤從 git history copy 舊
|
||
// 程式碼),`getCallCount.Load() == 0` 的 negative assertion 會立即 fail
|
||
// (TestConversionE2E_DownloadStream:1037-1048)。
|
||
//
|
||
// 為什麼**保留 mockFAA server**而不是純編譯期靜態斷言:
|
||
// - 純編譯期斷言(`var _ = (*conversion.FAAClient)(nil)`)已透過「FAAClient interface
|
||
// 不存在」自動成立 — production code 不可能再 import 不存在的 type
|
||
// - 但若有人**直接用 net/http 手寫**對 FAA 的 request(不透過 conversion package
|
||
// interface)、編譯期斷言抓不到。mockFAA server 在 e2e 層提供 wire-level 防護
|
||
// (只要 visionA 端對 mockFAA URL 發任何 GET request、getCallCount > 0 → test fail)
|
||
//
|
||
// 為什麼選方案 1(保留 mockFAA + negative assertion)而非方案 2(純結構性保證):
|
||
// - 方案 1 額外維護成本:50 行 mock + 1 行 assertion;功能性影響 0(mock 不會被 visionA
|
||
// 端打到)
|
||
// - 方案 2 強度只到「FAAClient interface 不存在」、抓不到 raw net/http 手寫的 regression
|
||
// - 採方案 1 給未來 ADR-016 設計約束多一層 wire-level 防護、cost-benefit 划算
|
||
// ==========================================================================
|
||
|
||
type mockFAA struct {
|
||
srv *httptest.Server
|
||
|
||
// **regression-only**:v0.6 T3 後 visionA 端不應再對 mockFAA 發任何 request;
|
||
// 此 counter > 0 → 設計約束被破壞、立即 fail e2e
|
||
getCallCount atomic.Int32
|
||
}
|
||
|
||
func newMockFAA(t *testing.T) *mockFAA {
|
||
t.Helper()
|
||
m := &mockFAA{}
|
||
m.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
// **v0.6 T3 起**:任何 request 進來都應視為設計違規;用 counter 記下,讓 e2e 斷言抓到
|
||
m.getCallCount.Add(1)
|
||
// 仍照舊處理 GET /files/... 以避免 visionA 端因連線失敗發生 panic(讓 e2e 拿到具體
|
||
// 「不該有的呼叫」而非「連線錯誤」)—— 但 production code 不應走到這條 path
|
||
if r.Method != http.MethodGet || !strings.HasPrefix(r.URL.Path, "/files/") {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
w.Header().Set("Content-Type", "application/octet-stream")
|
||
w.Header().Set("Content-Length", "0")
|
||
w.WriteHeader(http.StatusOK)
|
||
}))
|
||
t.Cleanup(m.srv.Close)
|
||
return m
|
||
}
|
||
|
||
// ==========================================================================
|
||
// 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(API key 模式)
|
||
// - mock FAA(**Phase 0.8b v0.6 T3 起為 regression-only**;visionA 端不應再對它發 request;
|
||
// 若 getCallCount > 0 表示設計約束被破壞,由 e2e negative assertion 抓出)
|
||
// - 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 移除。
|
||
// Phase 0.8b v0.6 T3:FAA wire 從 conversion.FlowOpts 移除(FAAClient interface 已砍);
|
||
// mockFAA server 保留作為「visionA 端不再直接打 FAA」的 regression 防護
|
||
// (TestConversionE2E_DownloadStream:1037-1048 的 negative assertion 仍生效)。
|
||
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 client 帶 fixture 用的 API key
|
||
// - mock converter 端不驗 key(測試重點是 visionA 端的 wire 行為與 stream proxy)
|
||
//
|
||
// Phase 0.8b v0.6 T3:visionA 端不再 wire FAA client(FAAClient interface 已砍);
|
||
// download / promote 都走 converter.GetResult。mockFAA server 仍 spin up 作為
|
||
// regression-only 防護(見 newMockFAA godoc)。
|
||
//
|
||
// 注意:converter_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"
|
||
converterAPIClient := conversion.NewConverterClient(conversion.ConverterClientOpts{
|
||
BaseURL: conv.srv.URL,
|
||
APIKey: fixtureConverterAPIKey,
|
||
HTTPClient: fastHTTP,
|
||
InitHTTPClient: &http.Client{Timeout: 60 * time.Second}, // 場景 #1 大 body 給寬一點
|
||
Logger: logger,
|
||
})
|
||
ownership := conversion.NewOwnership(converterAPIClient, logger)
|
||
|
||
modelRepo := model.NewInMemoryRepository()
|
||
modelStoreAdapter := newConversionModelStoreAdapter(modelRepo)
|
||
storageAdapter := newConversionStorageAdapter(storeStore)
|
||
|
||
conversionService, err := conversion.NewService(conversion.FlowOpts{
|
||
Converter: converterAPIClient,
|
||
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 v0.6 後 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 converter `/result` 寫的 binary 一致(byte-perfect)
|
||
// 4. mock converter 端 `/api/v1/jobs/{id}/result` 收到 visionA 帶的 Authorization:
|
||
// Bearer <fixture converter API key>(驗 visionA 端真的用 API key wire 對 converter 發 request)
|
||
// 5. **沒有** 302 / Location header / token 結構性流經 frontend
|
||
// (Phase 0.8b 設計核心:server-side proxy 取代 delegated token redirect)
|
||
// 6. **v0.6 新增**:visionA 端**不再直接打 FAA**(mock FAA `/files` 的 getCallCount 應為 0)
|
||
// —— 對應 ADR-016 撤回 visionA → FAA 直接呼叫的設計
|
||
//
|
||
// 對齊 api-conversion.md §4 (Phase 0.8b v0.6) + conversion.md §4.1 + ADR-016 §1。
|
||
//
|
||
// 流程:
|
||
// - 起 fixture(mock converter `/result` 預設回 small NEF binary marker)
|
||
// - user X init 一個 job → mock converter 自動建 running job
|
||
// - markJobCompleted(jobID) 把 mock job 推進 completed
|
||
// - 對 /download 打 GET — client 設 ErrUseLastResponse 防止意外 follow(雖預期非 302)
|
||
// - 驗以上 6 點
|
||
func TestConversionE2E_DownloadStream(t *testing.T) {
|
||
f := setupConversionFixture(t)
|
||
defer f.Close()
|
||
|
||
// v0.6:設定 mock converter `/result` 回的 NEF binary(取代原 mock FAA setNEFPayload)
|
||
const wantNEFContent = "PHASE-0.8b-v0.6-MOCK-NEF-BINARY-PAYLOAD-FROM-CONVERTER-RESULT-12345"
|
||
f.conv.setResultPayload([]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)
|
||
// — v0.6:mock converter `/result` 給的 filename 對 user 不直觀,visionA backend 用
|
||
// defaultDownloadFilename(cj) 覆寫成 <stem>_<chip>.nef
|
||
assert.Contains(t, cd, ".nef",
|
||
"filename 應以 .nef 結尾(NEF 結果檔),得 %s", cd)
|
||
assert.Contains(t, cd, "yolov5s_kl720.nef",
|
||
"v0.6:filename 應由 visionA defaultDownloadFilename(cj) 覆寫成 <stem>_<chip>.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 converter `/result` 寫的 binary ===
|
||
assert.Equal(t, wantNEFContent, string(bodyBytes),
|
||
"response body 應等於 mock converter `/result` 寫的 NEF binary(byte-perfect stream proxy)")
|
||
|
||
// === 斷言 4:mock converter `/result` 收到 visionA 帶的 Authorization Bearer <CONVERTER API key> ===
|
||
// v0.6:取代原本驗 mock FAA 收到 FAA API key 的斷言
|
||
authHeader := f.conv.getResultLastAuthHeader()
|
||
assert.True(t, strings.HasPrefix(authHeader, "Bearer "),
|
||
"mock converter `/result` 應收到 Bearer 開頭的 Authorization header,得 %q", authHeader)
|
||
assert.Contains(t, authHeader, "fixture-converter-api-key-do-not-use-in-prod",
|
||
"mock converter `/result` 應收到 fixture converter 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 流程)")
|
||
|
||
// === 斷言 6:visionA 端不再直接打 FAA(v0.6 設計約束 + T3 雙層防護)===
|
||
//
|
||
// 這條斷言是 **ADR-016 §1 設計約束的 regression 防護**:visionA 端不再直接呼叫 FAA,
|
||
// 整條 download path 改走 converter MinIO。萬一未來某 agent 不小心加回 FAA 直連
|
||
// (例如「optimize: 直接打 FAA 跳一層」、或從 git history 誤 copy 舊程式碼),此 e2e
|
||
// 立即 fail。
|
||
//
|
||
// **T3 後的雙層防護**(reviewer 推薦方案 1:保留 mockFAA + negative assertion):
|
||
// 1. **編譯期保證**(v0.6 T3 新加):`FAAClient` interface + `faa_client.go` 整檔已砍除;
|
||
// production code 不可能再 import `conversion.FAAClient` 或 `conversion.NewFAAClient`
|
||
// (type 不存在 → 編譯 fail)
|
||
// 2. **執行期保證**(本斷言):mockFAA server 仍 spin up;若有人直接用 net/http 手寫
|
||
// 對 FAA URL 的 request(繞過 conversion package interface),getCallCount > 0 →
|
||
// e2e fail。涵蓋編譯期斷言無法抓的「raw HTTP 直連」regression
|
||
assert.Equal(t, int32(0), f.faa.getCallCount.Load(),
|
||
"v0.6 T3:visionA 端不應再直接打 FAA(download path 改走 converter `/result`;"+
|
||
"FAAClient interface 已整檔砍除作為第一層編譯期防護,此斷言為第二層執行期防護)")
|
||
// 驗 mock converter `/result` 真的被打到(防 wire 路徑錯)
|
||
assert.GreaterOrEqual(t, int(f.conv.getResultCallCount.Load()), 1,
|
||
"mock converter GET /api/v1/jobs/{id}/result 應至少被打一次")
|
||
}
|
||
|
||
// ==========================================================================
|
||
// 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)
|
||
}
|
||
|