visionA/visionA-backend/internal/api/conversion_test.go
jim800121chen 6024c294d3 feat(visionA-backend): Phase 0.8b v0.6 T3 — 砍 faa_client + ErrFAA* + s-3/s-4/s-5
對齊 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>
2026-05-16 18:56:01 +08:00

807 lines
31 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)
// Phase 0.8b T4DownloadFn signature 從 (string, error) 改 (ReadCloser, *DownloadMetadata, error)
// — Service interface 從 DownloadRedirectURL 改 DownloadStreamAPI key 模式下沒有
// delegated token改 server-side stream proxyADR-015 §7 / conversion.md §4.1)。
DownloadFn func(ctx context.Context, userID, jobID string) (io.ReadCloser, *conversion.DownloadMetadata, 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) DownloadStream(ctx context.Context, userID, jobID string) (io.ReadCloser, *conversion.DownloadMetadata, error) {
s.mu.Lock()
s.lastUserID = userID
s.lastJobID = jobID
s.mu.Unlock()
if s.DownloadFn == nil {
return nil, nil, 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
// ==========================================================================
// Phase 0.8b T4handler 改成 server-side stream proxyAPI key 模式下沒有 delegated token
// 對應 ADR-015 §7 + conversion.md §4.1 + api-conversion.md §4 (Phase 0.8b 變更)。
//
// 成功 response 從「302 + Location」改為「200 + Content-Disposition: attachment + NEF binary stream」。
func TestConversion_Download_HappyPath_StreamProxy(t *testing.T) {
const nefPayload = "fake-nef-binary-bytes-stub-content-12345"
svc := &stubConversionService{
DownloadFn: func(ctx context.Context, userID, jobID string) (io.ReadCloser, *conversion.DownloadMetadata, error) {
require.Equal(t, "demo-user", userID)
require.Equal(t, "job-abc", jobID)
return io.NopCloser(strings.NewReader(nefPayload)), &conversion.DownloadMetadata{
Filename: "yolov5s_kl720.nef",
ContentType: "application/octet-stream",
ContentLength: int64(len(nefPayload)),
}, nil
},
}
r := newConversionFixture(t, svc)
req := httptest.NewRequest(http.MethodGet, "/api/conversion/job-abc/download", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
// 200 OK + NEF binary in bodyPhase 0.8b:不再 302 redirect
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "application/octet-stream", w.Header().Get("Content-Type"))
// Content-Disposition: attachment 觸發 browser download dialog
assert.Equal(t, `attachment; filename="yolov5s_kl720.nef"`, w.Header().Get("Content-Disposition"))
// Content-Length 與 stub stream 一致
assert.Equal(t, "40", w.Header().Get("Content-Length"))
// 防快取NEF 是 private 檔案)
assert.Contains(t, w.Header().Get("Cache-Control"), "no-store")
assert.Equal(t, "no-cache", w.Header().Get("Pragma"))
// Body bytes 與 stub stream 完全一致streaming proxy
assert.Equal(t, nefPayload, w.Body.String())
}
// TestConversion_Download_HappyPath_ChunkedTransferFAA 走 chunked transfer encoding 時
// ContentLength = -1handler 不應 set Content-Length header讓 net/http 用 chunked
func TestConversion_Download_HappyPath_ChunkedTransfer(t *testing.T) {
const nefPayload = "chunked-fake-nef-bytes"
svc := &stubConversionService{
DownloadFn: func(ctx context.Context, userID, jobID string) (io.ReadCloser, *conversion.DownloadMetadata, error) {
return io.NopCloser(strings.NewReader(nefPayload)), &conversion.DownloadMetadata{
Filename: "model_kl520.nef",
ContentType: "application/octet-stream",
ContentLength: -1, // chunked
}, 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.StatusOK, w.Code)
// chunked 模式下 handler 不設 Content-Lengthhttptest 不會自動補gin Status 後即 commit header
assert.Empty(t, w.Header().Get("Content-Length"),
"chunked transfer 時 handler 不應 set Content-LengthFAA 給 -1 → 讓 net/http 用 chunked")
assert.Equal(t, nefPayload, w.Body.String())
}
// TestConversion_Download_FilenameSanitizationService 給含特殊字元的 filename
// 也應被 handler sanitize防 HTTP header injection / path traversal
func TestConversion_Download_FilenameSanitization(t *testing.T) {
svc := &stubConversionService{
DownloadFn: func(ctx context.Context, userID, jobID string) (io.ReadCloser, *conversion.DownloadMetadata, error) {
return io.NopCloser(strings.NewReader("body")), &conversion.DownloadMetadata{
// 故意塞控制字元 + path sep + quote — 全部該被 sanitize 拔掉
Filename: "evil\r\n/foo/\"injected\".nef",
ContentType: "application/octet-stream",
ContentLength: 4,
}, nil
},
}
r := newConversionFixture(t, svc)
req := httptest.NewRequest(http.MethodGet, "/api/conversion/job-abc/download", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
cd := w.Header().Get("Content-Disposition")
// 控制字元 \r \n 不該出現
assert.NotContains(t, cd, "\r")
assert.NotContains(t, cd, "\n")
// path separator 不該出現
assert.NotContains(t, cd, "/")
// quote 不該破壞 filename="..." 結構
assert.Equal(t, `attachment; filename="evilfooinjected.nef"`, cd)
}
func TestConversion_Download_JobNotCompleted(t *testing.T) {
svc := &stubConversionService{
DownloadFn: func(ctx context.Context, userID, jobID string) (io.ReadCloser, *conversion.DownloadMetadata, error) {
return nil, nil, conversion.ErrJobNotCompleted
},
}
r := newConversionFixture(t, svc)
req := httptest.NewRequest(http.MethodGet, "/api/conversion/job-abc/download", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
// 錯誤情況回標準 JSON error200 還沒寫,可以 set status
assert.Equal(t, http.StatusConflict, w.Code)
assert.Contains(t, w.Body.String(), "job_not_completed")
assert.NotEqual(t, http.StatusOK, w.Code, "error case must not 200 stream")
assert.NotEqual(t, http.StatusFound, w.Code, "Phase 0.8b: 也不再 302 redirect")
}
func TestConversion_Download_NotFound(t *testing.T) {
svc := &stubConversionService{
DownloadFn: func(ctx context.Context, userID, jobID string) (io.ReadCloser, *conversion.DownloadMetadata, error) {
return nil, nil, 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)
}
// TestConversion_Download_FAAUnavailableFAA stream 失敗 → handler 回 502 + faa_unavailable。
//
// Phase 0.8b T4 補漏:取代原本的 TestConversion_Download_MCTokenUnavailable
// MC 認證鏈已取消ErrMCTokenUnavailable sentinel 已砍 — 對應 errors.go T3 砍除清單)。
// 保留 download 5xx 路徑覆蓋,改測 ErrFAAUnavailableAPI key 模式下最常見的 download 失敗)。
func TestConversion_Download_FAAUnavailable(t *testing.T) {
svc := &stubConversionService{
DownloadFn: func(ctx context.Context, userID, jobID string) (io.ReadCloser, *conversion.DownloadMetadata, error) {
return nil, nil, conversion.ErrFAAUnavailable
},
}
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(), "faa_unavailable")
}
// Phase 0.8b v0.6 T3TestConversion_Download_FAAAuthFailed 已整段移除
// visionA 端不再直接打 FAA、ErrFAAAuthFailed sentinel 已砍除ADR-016
// 對應的 converter API key 對外 mask 行為由 TestConversion_Download_ConverterAuthFailed 涵蓋
// v0.6 後 download path 改走 converterauth 失敗統一收斂到 ErrConverterAuthFailed
// TestConversion_Download_ConverterAuthFailed對稱測試 converter API key 不對齊。
// 對外 mask 成 converter_unavailable。
func TestConversion_Download_ConverterAuthFailed(t *testing.T) {
svc := &stubConversionService{
DownloadFn: func(ctx context.Context, userID, jobID string) (io.ReadCloser, *conversion.DownloadMetadata, error) {
return nil, nil, conversion.ErrConverterAuthFailed
},
}
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(), "converter_unavailable",
"ErrConverterAuthFailed 對外應 mask 成 converter_unavailable")
assert.NotContains(t, w.Body.String(), "auth_failed")
}
// TestConversion_Download_SizeCapEnforcedT5 補T4 Reviewer Minor #1
//
// 驗 io.Copy size cap 行為:當 Service 回的 stream 超過 conversion.MaxDownloadStreamBytes 時:
//
// 1. handler 仍回 200已 commit response header無法回頭改 status
// 2. body 被 truncate 到 cap不會把超大 stream 全 forward 給 client
// 3. cap 命中時 stream 被中斷infinite reader 不會被 read 完)
//
// 實作策略:
// - 用 infiniteByteReader 模擬「永遠可讀」的 stream攻擊情境的 abstract
// - 為避免測試實寫 1GB耗時 + 占記憶體),暫時 override
// conversion.MaxDownloadStreamBytes 為 1024 bytes —
// 此 var 設計就允許 test override見 conversion.go MaxDownloadStreamBytes godoc
// - 結束後 t.Cleanup 還原原值
//
// 為什麼不驗 log 內容log 行為是「副作用」,斷言 log 字串易脆log format / level 改了就失敗)。
// 直接驗「行為輸出」body 被 truncate已足以證明 size cap 起作用。
func TestConversion_Download_SizeCapEnforced(t *testing.T) {
// 暫時把 cap 從 1GB 改成 1KB方便測試
const testCapBytes int64 = 1024
prevCap := conversion.MaxDownloadStreamBytes
conversion.MaxDownloadStreamBytes = testCapBytes
t.Cleanup(func() { conversion.MaxDownloadStreamBytes = prevCap })
infinite := &infiniteByteReader{ch: 'X'}
svc := &stubConversionService{
DownloadFn: func(ctx context.Context, userID, jobID string) (io.ReadCloser, *conversion.DownloadMetadata, error) {
return io.NopCloser(infinite), &conversion.DownloadMetadata{
Filename: "huge.nef",
ContentType: "application/octet-stream",
ContentLength: -1, // chunked不 set Content-Length避免與 truncate 後的 body length 衝突)
}, nil
},
}
r := newConversionFixture(t, svc)
req := httptest.NewRequest(http.MethodGet, "/api/conversion/job-cap/download", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
// 1. status 仍 200cap 命中時 header 已 commit、無法回頭改 status
assert.Equal(t, http.StatusOK, w.Code,
"size cap 命中時 status 仍應 200header 已 commit")
// 2. body 被 truncate 到 cap — 不會把整個 infinite stream 寫給 client
assert.Equal(t, int(testCapBytes), w.Body.Len(),
"body 應被 truncate 到 cap%d bytesinfinite stream 未被全 read", testCapBytes)
// 內容應全是 'X'infiniteByteReader 寫的字元)
assert.True(t, strings.HasPrefix(w.Body.String(), "X"),
"body 應為 infinite reader 寫的 'X' 字元")
}
// infiniteByteReader 永遠 read 出固定 byte模擬無限大 stream 給 size cap 測試用。
//
// 用途:驗證 io.CopyN size cap 真的會中斷 stream不會把所有 bytes 寫給 client。
// 不實作 io.Closer — 由 io.NopCloser 包裝後傳給 handler。
type infiniteByteReader struct {
ch byte
}
func (r *infiniteByteReader) Read(p []byte) (int, error) {
for i := range p {
p[i] = r.ch
}
return len(p), nil
}
// ==========================================================================
// 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)
}