visionA/visionA-backend/internal/api/conversion_test.go
jim800121chen 1231bf0ed2 feat(visionA-backend): Phase 0.8 conversion package — 5 endpoint + 8 個內部模組
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>
2026-05-04 13:56:07 +08:00

639 lines
22 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// conversion_test.go — handler-level unit tests for /api/conversion/*。
//
// 用 in-package stub 實作 conversion.Service測 handler 層轉接、路由註冊、
// 錯誤對應的正確性。實際 Service 行為multipart 重組、ownership rebuild、
// promote → FAA pull → finalize由 internal/conversion/*_test.go 覆蓋。
//
// Phase 0.8 conversion (見 .autoflow/04-architecture/api/api-conversion.md)
package api
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"visiona-backend/internal/conversion"
)
// ==========================================================================
// Service stub
// ==========================================================================
// stubConversionService 是 conversion.Service 的測試 stub。
//
// 每個 method 都有對應的 InitJobFn / GetJobFn / ... 欄位,由 test case 注入想要的行為。
// 沒注入的 method 預設回 (nil, nil) — 對應 method 不被呼叫的 case。
//
// goroutine-safe所有欄位由 test setup 階段一次性寫入handler 呼叫時只讀。
type stubConversionService struct {
mu sync.Mutex
// 紀錄上一次呼叫的參數,給 test 驗 user_id 注入正確trust boundary
lastUserID string
lastJobID string
InitJobFn func(ctx context.Context, in conversion.InitJobInput) (*conversion.Job, error)
GetJobFn func(ctx context.Context, userID, jobID string) (*conversion.Job, error)
ActiveJobFn func(ctx context.Context, userID string) (*conversion.Job, error)
PromoteFn func(ctx context.Context, userID, jobID, name string) (*conversion.PromoteResult, error)
DownloadFn func(ctx context.Context, userID, jobID string) (string, error)
}
func (s *stubConversionService) InitJob(ctx context.Context, in conversion.InitJobInput) (*conversion.Job, error) {
s.mu.Lock()
s.lastUserID = in.UserID
s.mu.Unlock()
if s.InitJobFn == nil {
return nil, errors.New("stub: InitJobFn not set")
}
return s.InitJobFn(ctx, in)
}
func (s *stubConversionService) GetJob(ctx context.Context, userID, jobID string) (*conversion.Job, error) {
s.mu.Lock()
s.lastUserID = userID
s.lastJobID = jobID
s.mu.Unlock()
if s.GetJobFn == nil {
return nil, errors.New("stub: GetJobFn not set")
}
return s.GetJobFn(ctx, userID, jobID)
}
func (s *stubConversionService) ActiveJob(ctx context.Context, userID string) (*conversion.Job, error) {
s.mu.Lock()
s.lastUserID = userID
s.mu.Unlock()
if s.ActiveJobFn == nil {
return nil, errors.New("stub: ActiveJobFn not set")
}
return s.ActiveJobFn(ctx, userID)
}
func (s *stubConversionService) PromoteToModels(ctx context.Context, userID, jobID, name string) (*conversion.PromoteResult, error) {
s.mu.Lock()
s.lastUserID = userID
s.lastJobID = jobID
s.mu.Unlock()
if s.PromoteFn == nil {
return nil, errors.New("stub: PromoteFn not set")
}
return s.PromoteFn(ctx, userID, jobID, name)
}
func (s *stubConversionService) DownloadRedirectURL(ctx context.Context, userID, jobID string) (string, error) {
s.mu.Lock()
s.lastUserID = userID
s.lastJobID = jobID
s.mu.Unlock()
if s.DownloadFn == nil {
return "", errors.New("stub: DownloadFn not set")
}
return s.DownloadFn(ctx, userID, jobID)
}
// ==========================================================================
// Fixture
// ==========================================================================
// newConversionFixture 建一個只裝 conversion routes 的 gin engine。
//
// 所有 handler 都跑在 injectStaticUserContext("demo-user", ...) 之後 —
// 模擬「user 已登入」場景;驗 AuthMiddleware 行為由 oidc_auth_test 負責。
func newConversionFixture(t *testing.T, svc conversion.Service) *gin.Engine {
t.Helper()
r := gin.New()
r.Use(RequestIDMiddleware())
r.Use(injectStaticUserContext("demo-user", "demo@example.com"))
g := r.Group("/api")
registerConversionRoutes(g, Deps{Conversion: svc})
return r
}
// sampleJob 是一個典型的成功 job — 給 happy path 用。
func sampleJob() *conversion.Job {
now := time.Date(2026, 4, 30, 12, 0, 0, 0, time.UTC)
return &conversion.Job{
JobID: "job-abc-123",
Status: "running",
Stage: "onnx",
Progress: 0,
StageProgress: 0,
CreatedAt: now,
UpdatedAt: now,
ExpiresAt: now.Add(7 * 24 * time.Hour),
SourceFilename: "yolov5s.onnx",
TargetChip: "720",
}
}
// ==========================================================================
// 0. 共通:未啟用時 5 個 endpoint 全 501
// ==========================================================================
// TestConversion_Disabled_All501 — 當 deps.Conversion = nil 時5 個 endpoint 全回 501。
//
// 對齊 main.gocfg.Conversion.Enabled() == false 時 deps.Conversion 為 nil。
func TestConversion_Disabled_All501(t *testing.T) {
r := gin.New()
r.Use(RequestIDMiddleware())
r.Use(injectStaticUserContext("demo-user", ""))
g := r.Group("/api")
registerConversionRoutes(g, Deps{Conversion: nil}) // 未啟用
cases := []struct {
method string
path string
}{
{http.MethodPost, "/api/conversion/init"},
{http.MethodGet, "/api/conversion/active"},
{http.MethodGet, "/api/conversion/job-1"},
{http.MethodPost, "/api/conversion/job-1/promote-to-models"},
{http.MethodGet, "/api/conversion/job-1/download"},
}
for _, c := range cases {
t.Run(c.method+" "+c.path, func(t *testing.T) {
req := httptest.NewRequest(c.method, c.path, nil)
if c.method == http.MethodPost {
req.Header.Set("Content-Type", "application/json")
}
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotImplemented, w.Code,
"%s %s should be 501 when Conversion=nil; body=%s", c.method, c.path, w.Body.String())
})
}
}
// ==========================================================================
// 1. POST /api/conversion/init
// ==========================================================================
// TestConversion_Init_HappyPath — 成功 init 回 201 + Job。
func TestConversion_Init_HappyPath(t *testing.T) {
job := sampleJob()
svc := &stubConversionService{
InitJobFn: func(ctx context.Context, in conversion.InitJobInput) (*conversion.Job, error) {
// 驗 user_id 正確注入trust boundary
require.Equal(t, "demo-user", in.UserID)
require.NotEmpty(t, in.ContentType)
require.NotNil(t, in.Body)
// 驗 body 有內容streaming reader 還沒被讀)
b, err := io.ReadAll(in.Body)
require.NoError(t, err)
require.Contains(t, string(b), "fake-multipart")
return job, nil
},
}
r := newConversionFixture(t, svc)
body := strings.NewReader("--xyz\r\nContent-Disposition: form-data; name=\"fake-multipart\"\r\n\r\ndata\r\n--xyz--\r\n")
req := httptest.NewRequest(http.MethodPost, "/api/conversion/init", body)
req.Header.Set("Content-Type", "multipart/form-data; boundary=xyz")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, http.StatusCreated, w.Code, "body=%s", w.Body.String())
var sb SuccessBody
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &sb))
data := sb.Data.(map[string]any)
assert.Equal(t, "job-abc-123", data["job_id"])
assert.Equal(t, "running", data["status"])
assert.Equal(t, "yolov5s.onnx", data["source_filename"])
assert.Equal(t, "720", data["target_chip"])
}
// TestConversion_Init_BadContentType — Content-Type 非 multipart/form-data 回 400。
//
// 這擋下 client 傳 JSON 等錯誤格式(避免 Service 層白白讀完 body 才發現格式錯)。
func TestConversion_Init_BadContentType(t *testing.T) {
svc := &stubConversionService{} // 不應該被呼叫
r := newConversionFixture(t, svc)
req := httptest.NewRequest(http.MethodPost, "/api/conversion/init",
strings.NewReader(`{"foo":"bar"}`))
req.Header.Set("Content-Type", "application/json") // 錯誤
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), ErrCodeValidationFailed)
assert.Contains(t, w.Body.String(), "multipart/form-data")
}
// TestConversion_Init_ActiveJobError — ActiveJobError 回 409 + extra.active_job。
//
// 這個 case 驗 handleConversionError 對 errors.As(*ActiveJobError) 的特殊處理。
func TestConversion_Init_ActiveJobError(t *testing.T) {
existingJob := &conversion.Job{
JobID: "job-existing-456",
Status: "running",
Stage: "bie",
Progress: 45,
}
svc := &stubConversionService{
InitJobFn: func(ctx context.Context, in conversion.InitJobInput) (*conversion.Job, error) {
return nil, &conversion.ActiveJobError{Job: existingJob}
},
}
r := newConversionFixture(t, svc)
req := httptest.NewRequest(http.MethodPost, "/api/conversion/init",
strings.NewReader("--xyz\r\n--xyz--\r\n"))
req.Header.Set("Content-Type", "multipart/form-data; boundary=xyz")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, http.StatusConflict, w.Code, "body=%s", w.Body.String())
var eb ErrorBody
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &eb))
require.NotNil(t, eb.Error)
assert.Equal(t, "active_job_exists", eb.Error.Code)
require.NotNil(t, eb.Error.Extra)
activeJob, ok := eb.Error.Extra["active_job"].(map[string]any)
require.True(t, ok, "extra.active_job should be object; got %v", eb.Error.Extra)
assert.Equal(t, "job-existing-456", activeJob["job_id"])
}
// TestConversion_Init_ValidationError — ConverterValidationError 回 400 + details.fields。
func TestConversion_Init_ValidationError(t *testing.T) {
svc := &stubConversionService{
InitJobFn: func(ctx context.Context, in conversion.InitJobInput) (*conversion.Job, error) {
return nil, &conversion.ConverterValidationError{
Fields: []conversion.ValidationFieldError{
{Field: "model_id", Message: "must be 1-65535"},
},
Message: "validation failed",
}
},
}
r := newConversionFixture(t, svc)
req := httptest.NewRequest(http.MethodPost, "/api/conversion/init",
strings.NewReader("--xyz\r\n--xyz--\r\n"))
req.Header.Set("Content-Type", "multipart/form-data; boundary=xyz")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, http.StatusBadRequest, w.Code, "body=%s", w.Body.String())
var eb ErrorBody
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &eb))
assert.Equal(t, "validation_failed", eb.Error.Code)
require.Len(t, eb.Error.Details, 1)
assert.Equal(t, "model_id", eb.Error.Details[0].Field)
}
// TestConversion_Init_ConverterUnavailable — 502 mapping。
func TestConversion_Init_ConverterUnavailable(t *testing.T) {
svc := &stubConversionService{
InitJobFn: func(ctx context.Context, in conversion.InitJobInput) (*conversion.Job, error) {
return nil, conversion.ErrConverterUnavailable
},
}
r := newConversionFixture(t, svc)
req := httptest.NewRequest(http.MethodPost, "/api/conversion/init",
strings.NewReader("--xyz\r\n--xyz--\r\n"))
req.Header.Set("Content-Type", "multipart/form-data; boundary=xyz")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadGateway, w.Code)
assert.Contains(t, w.Body.String(), "converter_unavailable")
}
// ==========================================================================
// 2. GET /api/conversion/active
// ==========================================================================
func TestConversion_Active_HasActive(t *testing.T) {
job := sampleJob()
svc := &stubConversionService{
ActiveJobFn: func(ctx context.Context, userID string) (*conversion.Job, error) {
require.Equal(t, "demo-user", userID)
return job, nil
},
}
r := newConversionFixture(t, svc)
req := httptest.NewRequest(http.MethodGet, "/api/conversion/active", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String())
var sb SuccessBody
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &sb))
data := sb.Data.(map[string]any)
assert.Equal(t, true, data["has_active"])
jobMap, ok := data["job"].(map[string]any)
require.True(t, ok)
assert.Equal(t, "job-abc-123", jobMap["job_id"])
}
func TestConversion_Active_NoActive(t *testing.T) {
svc := &stubConversionService{
ActiveJobFn: func(ctx context.Context, userID string) (*conversion.Job, error) {
return nil, nil
},
}
r := newConversionFixture(t, svc)
req := httptest.NewRequest(http.MethodGet, "/api/conversion/active", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
var sb SuccessBody
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &sb))
data := sb.Data.(map[string]any)
assert.Equal(t, false, data["has_active"])
assert.Nil(t, data["job"])
}
func TestConversion_Active_ConverterUnavailable(t *testing.T) {
svc := &stubConversionService{
ActiveJobFn: func(ctx context.Context, userID string) (*conversion.Job, error) {
return nil, conversion.ErrConverterUnavailable
},
}
r := newConversionFixture(t, svc)
req := httptest.NewRequest(http.MethodGet, "/api/conversion/active", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadGateway, w.Code)
}
// ==========================================================================
// 3. GET /api/conversion/{job_id}
// ==========================================================================
func TestConversion_Get_HappyPath(t *testing.T) {
job := sampleJob()
svc := &stubConversionService{
GetJobFn: func(ctx context.Context, userID, jobID string) (*conversion.Job, error) {
require.Equal(t, "demo-user", userID)
require.Equal(t, "job-abc-123", jobID)
return job, nil
},
}
r := newConversionFixture(t, svc)
req := httptest.NewRequest(http.MethodGet, "/api/conversion/job-abc-123", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String())
var sb SuccessBody
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &sb))
data := sb.Data.(map[string]any)
assert.Equal(t, "job-abc-123", data["job_id"])
assert.Equal(t, "running", data["status"])
}
func TestConversion_Get_NotFound(t *testing.T) {
svc := &stubConversionService{
GetJobFn: func(ctx context.Context, userID, jobID string) (*conversion.Job, error) {
return nil, conversion.ErrJobNotFound
},
}
r := newConversionFixture(t, svc)
req := httptest.NewRequest(http.MethodGet, "/api/conversion/missing-job", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
assert.Contains(t, w.Body.String(), "not_found")
}
// ==========================================================================
// 4. POST /api/conversion/{job_id}/promote-to-models
// ==========================================================================
func TestConversion_Promote_HappyPath(t *testing.T) {
now := time.Date(2026, 4, 30, 12, 30, 0, 0, time.UTC)
res := &conversion.PromoteResult{
ModelID: "model-xyz",
Source: "converted",
SourceJobID: "job-abc-123",
Name: "yolo_kl720",
TargetChip: "kl720",
FileSize: 12345,
Status: "ready",
CreatedAt: now,
}
svc := &stubConversionService{
PromoteFn: func(ctx context.Context, userID, jobID, name string) (*conversion.PromoteResult, error) {
require.Equal(t, "demo-user", userID)
require.Equal(t, "job-abc-123", jobID)
require.Equal(t, "yolo_kl720", name)
return res, nil
},
}
r := newConversionFixture(t, svc)
req := httptest.NewRequest(http.MethodPost, "/api/conversion/job-abc-123/promote-to-models",
strings.NewReader(`{"name":"yolo_kl720"}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, http.StatusCreated, w.Code, "body=%s", w.Body.String())
var sb SuccessBody
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &sb))
data := sb.Data.(map[string]any)
assert.Equal(t, "model-xyz", data["model_id"])
assert.Equal(t, "converted", data["source"])
assert.Equal(t, "ready", data["status"])
}
// TestConversion_Promote_NoBody — 沒帶 body 也應該成功name 可為空)。
func TestConversion_Promote_NoBody(t *testing.T) {
svc := &stubConversionService{
PromoteFn: func(ctx context.Context, userID, jobID, name string) (*conversion.PromoteResult, error) {
require.Equal(t, "", name) // body 沒帶 → name 為空,由 Service fallback
return &conversion.PromoteResult{ModelID: "m1", Source: "converted", Status: "ready"}, nil
},
}
r := newConversionFixture(t, svc)
req := httptest.NewRequest(http.MethodPost, "/api/conversion/job-abc/promote-to-models", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusCreated, w.Code, "body=%s", w.Body.String())
}
func TestConversion_Promote_BadJSON(t *testing.T) {
svc := &stubConversionService{} // 不該被呼叫
r := newConversionFixture(t, svc)
req := httptest.NewRequest(http.MethodPost, "/api/conversion/job/promote-to-models",
strings.NewReader(`{not valid json`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), ErrCodeValidationFailed)
}
func TestConversion_Promote_JobNotCompleted(t *testing.T) {
svc := &stubConversionService{
PromoteFn: func(ctx context.Context, userID, jobID, name string) (*conversion.PromoteResult, error) {
return nil, conversion.ErrJobNotCompleted
},
}
r := newConversionFixture(t, svc)
req := httptest.NewRequest(http.MethodPost, "/api/conversion/job-abc/promote-to-models",
strings.NewReader(`{}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusConflict, w.Code)
assert.Contains(t, w.Body.String(), "job_not_completed")
}
// ==========================================================================
// 5. GET /api/conversion/{job_id}/download
// ==========================================================================
func TestConversion_Download_HappyPath302(t *testing.T) {
target := "http://192.168.0.130:5081/files/models/u/job.nef?access_token=opaque-xyz"
svc := &stubConversionService{
DownloadFn: func(ctx context.Context, userID, jobID string) (string, error) {
require.Equal(t, "demo-user", userID)
require.Equal(t, "job-abc", jobID)
return target, nil
},
}
r := newConversionFixture(t, svc)
req := httptest.NewRequest(http.MethodGet, "/api/conversion/job-abc/download", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusFound, w.Code) // 302
assert.Equal(t, target, w.Header().Get("Location"))
// 防快取 header — token 不該被 browser cache§10.4
assert.Contains(t, w.Header().Get("Cache-Control"), "no-store")
assert.Equal(t, "no-cache", w.Header().Get("Pragma"))
}
func TestConversion_Download_JobNotCompleted(t *testing.T) {
svc := &stubConversionService{
DownloadFn: func(ctx context.Context, userID, jobID string) (string, error) {
return "", conversion.ErrJobNotCompleted
},
}
r := newConversionFixture(t, svc)
req := httptest.NewRequest(http.MethodGet, "/api/conversion/job-abc/download", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
// 錯誤情況**不 redirect** — 回標準 JSON error
assert.Equal(t, http.StatusConflict, w.Code)
assert.Contains(t, w.Body.String(), "job_not_completed")
assert.NotEqual(t, http.StatusFound, w.Code, "error case must not 302 redirect")
}
func TestConversion_Download_NotFound(t *testing.T) {
svc := &stubConversionService{
DownloadFn: func(ctx context.Context, userID, jobID string) (string, error) {
return "", conversion.ErrJobNotFound
},
}
r := newConversionFixture(t, svc)
req := httptest.NewRequest(http.MethodGet, "/api/conversion/missing/download", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
}
func TestConversion_Download_MCTokenUnavailable(t *testing.T) {
svc := &stubConversionService{
DownloadFn: func(ctx context.Context, userID, jobID string) (string, error) {
return "", conversion.ErrMCTokenUnavailable
},
}
r := newConversionFixture(t, svc)
req := httptest.NewRequest(http.MethodGet, "/api/conversion/job/download", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadGateway, w.Code)
assert.Contains(t, w.Body.String(), "mc_token_unavailable")
}
// ==========================================================================
// User_id trust boundary
// ==========================================================================
// TestConversion_Init_IgnoresClientUserID — 即使 multipart form 帶 user_idhandler
// 仍只把 cookie session 的 UserID 傳給 Service。
//
// 這是 trust boundary 的回歸測試conversion.md §7。實際 multipart 重組 / 黑名單
// 邏輯在 Service 層做flow.go rebuildMultipart但 handler 必須確保傳給 Service 的
// InitJobInput.UserID 永遠是 UserContext 的,不是 client 提供的。
func TestConversion_Init_IgnoresClientUserID(t *testing.T) {
svc := &stubConversionService{
InitJobFn: func(ctx context.Context, in conversion.InitJobInput) (*conversion.Job, error) {
// 即使 client 在 multipart 內塞了 user_id=attackerhandler 給 Service 的 UserID
// 必須是 demo-user從 UserContext 拿)
require.Equal(t, "demo-user", in.UserID)
return sampleJob(), nil
},
}
r := newConversionFixture(t, svc)
// 一個包含 user_id=attacker 的 multipart body — 應被忽略
body := strings.NewReader(
"--xyz\r\n" +
"Content-Disposition: form-data; name=\"user_id\"\r\n\r\n" +
"attacker\r\n" +
"--xyz\r\n" +
"Content-Disposition: form-data; name=\"model\"\r\n\r\n" +
"data\r\n" +
"--xyz--\r\n",
)
req := httptest.NewRequest(http.MethodPost, "/api/conversion/init", body)
req.Header.Set("Content-Type", "multipart/form-data; boundary=xyz")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, http.StatusCreated, w.Code, "body=%s", w.Body.String())
}
// TestConversion_GetJob_IgnoresQueryUserID — query 帶 user_id 不影響 handler
// 傳給 Service 的 userID仍是 UserContext 拿到的)。
func TestConversion_GetJob_IgnoresQueryUserID(t *testing.T) {
svc := &stubConversionService{
GetJobFn: func(ctx context.Context, userID, jobID string) (*conversion.Job, error) {
require.Equal(t, "demo-user", userID, "user_id from query must be ignored")
return sampleJob(), nil
},
}
r := newConversionFixture(t, svc)
req := httptest.NewRequest(http.MethodGet, "/api/conversion/job-abc?user_id=attacker", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
}