Phase 0.8 把 kneron_model_converter 的轉檔功能整合進 visionA Cloud。
visionA backend 當 streaming proxy(upload)+ delegated download token broker(download)+
ownership trust boundary,converter / FAA / MC 三方零修改。
新增 internal/conversion/ 套件(8 個檔,~10,000 行 prod+test,117+ test cases,race -count=3 全綠):
- conversion.go:Service interface 5 method、Job/PromoteResult/InitJobInput types
- errors.go:13+ sentinel errors + ErrorCode/HTTPStatus mapping,對齊 conversion.md §6
- mc_token_client.go:service-to-service token (client_credentials grant) + DCL cache
(exp - 15s 重取,per-scope cache),IssueDelegatedDownload(MC delegated download token)
錯誤分 idp_misconfigured (4xx) / idp_unavailable (5xx) / download_token_failed / mc_token_unavailable
- converter_client.go:對 converter scheduler 4 method(InitJob multipart streaming /
GetJob / Promote / ListInProgressJobs),InitJob 不 retry 5xx(streaming body 無法 replay)
- faa_client.go:對 FAA GET /files/{key} server-to-server pull,Phase A retry(GET 無 body
可 replay)對齊 §9.1 retry 矩陣,streaming io.ReadCloser 透傳避 OOM
- ownership.go:in-memory job_id → user_id map + per-user mutex 防 thundering herd lazy rebuild
(不同 user 平行 fetch,同 user 100 caller 收斂成 1 次),visionA 重啟靠 converter
ListInProgressJobs(user) 重建
- flow.go:Service interface 整合層(5 method 串接 converter/FAA/MC/ownership)
- InitJob 用 io.Pipe + multipart.Reader/Writer 重組 streaming proxy(黑名單 client user_id
+ 灌入 OIDC sub)
- DownloadRedirectURL 自動觸發 promote(spec §1 Stage 3b),用 ensurePromoted helper
- PromoteToModels 冪等(modelStore.FindBySourceJobID 為 source-of-truth)
- OwnershipMismatch → ErrJobNotFound 不 forbidden(§7.2 防枚舉)
- storage / modelStore 失敗包 ErrStorageUnavailable / ErrModelStoreUnavailable
(視為 visionA 自身 500 而非 502 gateway,SRE alarm 才打對 team)
新增 internal/api/conversion.go(5 endpoint handler + main.go wire):
- POST /api/conversion/init(multipart streaming proxy,不呼叫 c.MultipartForm())
- GET /api/conversion/active(lazy rebuild ownership)
- GET /api/conversion/{job_id}(poll status)
- POST /api/conversion/{job_id}/promote-to-models(FAA pull → models 三段式)
- GET /api/conversion/{job_id}/download(server-side HTTP 302 → FAA,token 不過 frontend
JS,仿 FAA TestSite DownloadFileDirect pattern;Cache-Control: no-store)
5 個 endpoint 全部走 OIDC AuthMiddleware;user_id 從 cookie session 灌(trust boundary),
從不接受 client multipart form / JSON / query 的 user_id。
TestAllAPIEndpointsRequire401WithoutCookie 自動覆蓋新 5 endpoint regression 防呆。
新增 cmd/api-server/conversion_e2e_test.go(4 個 e2e 場景):
- TestConversionE2E_StreamingProxy(10MB body + trust boundary regression)
- TestConversionE2E_LazyRebuildAfterRestart(visionA 重啟仍能 /active)
- TestConversionE2E_Download302Redirect(驗 302 + Location header + token 不在 body)
- TestConversionE2E_ActiveJobConflict(409 + active_job 詳情)
修改 internal/config/{config,load}.go:新增 ConversionConfig 5 欄位
(ConverterBaseURL / FAABaseURL / TenantID / ServiceClientID / ServiceClientSecret)+
Enabled() helper(雙非空判定)。
修改 cmd/api-server/main.go:條件 wire(cfg.Conversion.Enabled() 為 true 才建 client + Service;
否則 Deps.Conversion=nil,handler 自動回 501)。
修改 .env.example:新增 Phase 0.8 區塊註解。
新增 cmd/api-server/conversion_adapters.go:narrow interface adapter(接既有
internal/model.Repository / internal/storage.Store → conversion.ModelStore / Storage,避免 import cycle)。
驗證:go test -race -count=3 ./... 17 packages 全綠 / go vet 0 warning / go build 成功。
對齊文件:
- .autoflow/04-architecture/adr/adr-014-conversion-integration.md
- .autoflow/04-architecture/conversion.md (TDD)
- .autoflow/04-architecture/api/api-conversion.md
- .autoflow/02-prd/features/feature-converter-integration.md
- .autoflow/03-design/wireframes/wireframe-conversion.md
- .autoflow/03-design/flows/flow-conversion.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1144 lines
39 KiB
Go
1144 lines
39 KiB
Go
// conversion_e2e_test.go — Phase 0.8 conversion 整合 e2e 測試。
|
||
//
|
||
// 涵蓋 4 個必含場景(對齊 .autoflow/05-implementation/phase-0.8-T8.md 範圍):
|
||
//
|
||
// 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 302 redirect
|
||
// —— 驗 server-side 302 + Cache-Control: no-store + Location 帶 token;
|
||
// 驗 response body 不含 token;驗 redirect URL 指向 mock FAA。
|
||
//
|
||
// 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 + 3 個 mock servers
|
||
// (converter / MC service token + delegated / FAA),完整模擬端到端。
|
||
//
|
||
// Phase 0.8 conversion e2e (見 .autoflow/04-architecture/conversion.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"
|
||
"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
|
||
}
|
||
|
||
// ==========================================================================
|
||
// mockMC — 服務 service token (/oauth/token) + delegated download (/file-access/download-tokens)
|
||
// ==========================================================================
|
||
|
||
type mockMC struct {
|
||
srv *httptest.Server
|
||
|
||
serviceTokenCount atomic.Int32
|
||
delegatedTokenCount atomic.Int32
|
||
|
||
// 紀錄上一次發出的 delegated token(給場景 #3 驗 location 帶到)
|
||
lastDelegatedToken string
|
||
mu sync.Mutex
|
||
}
|
||
|
||
func newMockMC(t *testing.T) *mockMC {
|
||
t.Helper()
|
||
mc := &mockMC{}
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/oauth/token", mc.handleServiceToken)
|
||
mux.HandleFunc("/file-access/download-tokens", mc.handleDelegated)
|
||
mc.srv = httptest.NewServer(mux)
|
||
t.Cleanup(mc.srv.Close)
|
||
return mc
|
||
}
|
||
|
||
// handleServiceToken:client_credentials grant 永遠回 200 + access_token + expires_in。
|
||
func (m *mockMC) handleServiceToken(w http.ResponseWriter, r *http.Request) {
|
||
m.serviceTokenCount.Add(1)
|
||
if r.Method != http.MethodPost {
|
||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{
|
||
"access_token": "mock-service-token-" + randHex(8),
|
||
"token_type": "Bearer",
|
||
"expires_in": 3600,
|
||
"scope": r.FormValue("scope"),
|
||
})
|
||
}
|
||
|
||
// handleDelegated:簽 opaque token + 預設 5 分鐘過期。
|
||
func (m *mockMC) handleDelegated(w http.ResponseWriter, r *http.Request) {
|
||
m.delegatedTokenCount.Add(1)
|
||
tok := "delegated-" + randHex(16)
|
||
m.mu.Lock()
|
||
m.lastDelegatedToken = tok
|
||
m.mu.Unlock()
|
||
writeJSON(w, http.StatusOK, map[string]any{
|
||
"token": tok,
|
||
"expires_at": time.Now().Add(5 * time.Minute).UTC().Format(time.RFC3339),
|
||
})
|
||
}
|
||
|
||
// ==========================================================================
|
||
// mockFAA — 純 placeholder,本檔測 download 用 CheckRedirect 卡住 302、不 follow 到 FAA。
|
||
// ==========================================================================
|
||
|
||
type mockFAA struct {
|
||
srv *httptest.Server
|
||
}
|
||
|
||
func newMockFAA(t *testing.T) *mockFAA {
|
||
t.Helper()
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||
// e2e 不會真的 follow 到這(test client 設 ErrUseLastResponse),
|
||
// 留 200 OK 當保險(避免假設外部 mock 必返錯誤)。
|
||
w.WriteHeader(http.StatusOK)
|
||
_, _ = w.Write([]byte("mock-nef-bytes"))
|
||
}))
|
||
t.Cleanup(srv.Close)
|
||
return &mockFAA{srv: srv}
|
||
}
|
||
|
||
// ==========================================================================
|
||
// conversionFixture — 把所有 server 拼起來並提供 OIDC 登入 helper
|
||
// ==========================================================================
|
||
|
||
type conversionFixture struct {
|
||
server *httptest.Server // visionA backend
|
||
fakeOIDC *oidctest.Server // 給 user 走 OIDC cookie session 登入用
|
||
conv *mockConverter
|
||
mc *mockMC
|
||
faa *mockFAA
|
||
|
||
// 重啟模擬:場景 #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 / MC service token + delegated / FAA
|
||
// - fake OIDC(給 user 走 cookie session 登入)
|
||
// - visionA-backend router(含 conversion service wired,仿 T7 main.go wire 邏輯)
|
||
//
|
||
// **不影響 T1-T7 既有 code**:本 fixture 完全獨立,不重用 setupFixture(後者沒 wire conversion)。
|
||
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)
|
||
mc := newMockMC(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 T7 wire 邏輯;mocks 替換真實 endpoint) ===
|
||
//
|
||
// 注意:mc_token_client / converter_client / faa_client 都用 100ms timeout HTTPClient
|
||
// 避免測試卡死;對 mock servers 來說連線秒回,timeout 不會觸發。
|
||
fastHTTP := &http.Client{Timeout: 5 * time.Second}
|
||
mcTokenClient := conversion.NewMCTokenClient(conversion.MCTokenClientOpts{
|
||
Issuer: mc.srv.URL,
|
||
ClientID: "visiona-service-client",
|
||
ClientSecret: "visiona-service-secret",
|
||
HTTPClient: fastHTTP,
|
||
Logger: logger,
|
||
})
|
||
converterAPIClient := conversion.NewConverterClient(conversion.ConverterClientOpts{
|
||
BaseURL: conv.srv.URL,
|
||
Tokens: mcTokenClient,
|
||
HTTPClient: fastHTTP,
|
||
InitHTTPClient: &http.Client{Timeout: 60 * time.Second}, // 場景 #1 大 body 給寬一點
|
||
Logger: logger,
|
||
})
|
||
faaAPIClient := conversion.NewFAAClient(conversion.FAAClientOpts{
|
||
BaseURL: faa.srv.URL,
|
||
Tokens: mcTokenClient,
|
||
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,
|
||
MCToken: mcTokenClient,
|
||
Ownership: ownership,
|
||
ModelStore: modelStoreAdapter,
|
||
Storage: storageAdapter,
|
||
TenantID: "tenant-visiona",
|
||
FAABaseURL: faa.srv.URL,
|
||
DelegatedTTLSeconds: 300,
|
||
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,
|
||
mc: mc,
|
||
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 302 redirect
|
||
// ==========================================================================
|
||
|
||
// TestConversionE2E_Download302Redirect 驗:
|
||
//
|
||
// 1. user X 對 completed job 打 /download → status 302 Found
|
||
// 2. Location header 是 <faa-base-url>/files/<key>?access_token=<token>
|
||
// 3. Cache-Control: no-store, no-cache, ...
|
||
// 4. response body 不含 token 字串(grep response body 找不到 token)
|
||
// 5. 不是 c.JSON 回 download_url(純 redirect — content-type 不是 application/json)
|
||
//
|
||
// 流程:
|
||
// - 起 fixture
|
||
// - user X 透過 init 建一個 job(mock 會自動建 running job)
|
||
// - 把 mock converter 端 job 改成 completed
|
||
// - 對 /download 打 — client 設 ErrUseLastResponse 不 follow redirect
|
||
func TestConversionE2E_Download302Redirect(t *testing.T) {
|
||
f := setupConversionFixture(t)
|
||
defer f.Close()
|
||
|
||
const wantSub = "user-download-003"
|
||
client := f.AuthenticatedClient(t, wantSub, "download@e2e.local")
|
||
|
||
// 1. 先 init 一個 job(讓 visionA 端寫 ownership)
|
||
jobID := initSimpleJob(t, client, f.server.URL)
|
||
|
||
// 2. 把 mock converter 端 job 推進到 completed(給 download 用)
|
||
f.conv.markJobCompleted(jobID)
|
||
|
||
// 3. 用「不 follow redirect」的 client 對 /download 打
|
||
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, _ := io.ReadAll(resp.Body)
|
||
|
||
// 驗 status 302
|
||
require.Equal(t, http.StatusFound, resp.StatusCode, "body=%s", string(bodyBytes))
|
||
|
||
// 驗 Location 指向 mock FAA + 帶 access_token
|
||
location := resp.Header.Get("Location")
|
||
require.NotEmpty(t, location)
|
||
require.True(t, strings.HasPrefix(location, f.faa.srv.URL+"/files/"),
|
||
"Location 應指向 mock FAA /files/,得 %s", location)
|
||
require.Contains(t, location, "access_token=", "Location 應帶 access_token query")
|
||
|
||
// 驗 token 真的在 query 中且 == mock 端發出的 token
|
||
parsed, err := url.Parse(location)
|
||
require.NoError(t, err)
|
||
gotToken := parsed.Query().Get("access_token")
|
||
require.NotEmpty(t, gotToken)
|
||
f.mc.mu.Lock()
|
||
wantToken := f.mc.lastDelegatedToken
|
||
f.mc.mu.Unlock()
|
||
require.Equal(t, wantToken, gotToken,
|
||
"Location 帶的 access_token 應 == mock MC 簽發的 token")
|
||
|
||
// 驗 Cache-Control: no-store
|
||
cc := resp.Header.Get("Cache-Control")
|
||
require.Contains(t, cc, "no-store", "Cache-Control 應含 no-store,得 %s", cc)
|
||
|
||
// 驗 response 不是用 c.JSON 回(純 server-side 302,§10.4 token 不過 frontend JS)
|
||
//
|
||
// 行為說明(不要過度斷言「整個 body 不能含 token」):
|
||
// net/http.Redirect 的標準 fallback 會在 HTML body 塞一個 <a href="<url>">Found</a>
|
||
// 讓不支援 302 的 user-agent 還能手動點。**這是 net/http 的標準行為,不是 visionA
|
||
// 把 token 寫進 JSON 給 frontend JS**。token 仍只活在 Location header 與 HTML
|
||
// anchor 中,frontend JS 沒辦法用通常的 fetch().json() 讀到(需要 parse HTML)。
|
||
//
|
||
// §10.4 安全聲明的精神是:
|
||
// - 不用 c.JSON 回 download_url(避免 JS 直接 .data.download_url 拿到 token)
|
||
// - Cache-Control: no-store 避免 browser 把 Location 寫 disk cache
|
||
// 兩者本檔都驗。
|
||
//
|
||
// 因此這裡的斷言改為:
|
||
// 1. content-type 不是 application/json(沒用 c.JSON)
|
||
// 2. body 不是 visionA 的 success envelope({success: true, data: {download_url: ...}})
|
||
bodyStr := string(bodyBytes)
|
||
ct := resp.Header.Get("Content-Type")
|
||
if ct != "" {
|
||
assert.NotContains(t, strings.ToLower(ct), "application/json",
|
||
"302 response 不應為 JSON(用了 c.JSON 而非 c.Redirect);得 content-type=%s", ct)
|
||
}
|
||
// 確認 body 不是「visionA 包好的 JSON envelope 帶 download_url」
|
||
// (這正是 §10.4 不要的形式)
|
||
var maybeEnvelope map[string]any
|
||
if json.Unmarshal(bodyBytes, &maybeEnvelope) == nil {
|
||
if data, ok := maybeEnvelope["data"].(map[string]any); ok {
|
||
_, hasURL := data["download_url"]
|
||
assert.False(t, hasURL,
|
||
"response body 不應為 {success:..., data:{download_url:...}} 形式(用了 c.JSON);body=%s",
|
||
bodyStr)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ==========================================================================
|
||
// 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)
|
||
}
|
||
|
||
// randHex 產 n bytes random hex,給 mock token 用。
|
||
func randHex(n int) string {
|
||
b := make([]byte, n)
|
||
_, _ = rand.Read(b)
|
||
return fmt.Sprintf("%x", b)
|
||
}
|