對齊 ADR-015:visionA backend 從 OAuth client_credentials 改 pre-shared API key 服務間認證。Phase 0.8 stage e2e 撞 4 個 blocker(MC scope 沒註冊 / converter image 舊版 / converter 缺 env / FAA 不確定)後,使用者拍板 1:1 internal trust 用 OAuth 過度設計,改 API key。
實作(5 個增量 task,T1-T5 全綠 + Reviewer 5 輪 + final cross-task review):
T1 config + env:
- ConversionConfig 新增 ConverterAPIKey / FAAAPIKey 欄位
- Enabled() 改判定 4 欄位齊全(含兩個 API key)
- .env*.example 移除 OIDC service client / OIDC tenant / FAA delegated TTL env、新增 API key env
- TenantID / DelegatedTTLSeconds T1 暫留、T5 整批清
T2 client 改造:
- converter_client / faa_client 移除 MCTokenClient 依賴
- 直接讀 cfg.Conversion.{Converter,FAA}APIKey、set Authorization: Bearer <key>
- NewConverterClient / NewFAAClient APIKey 為空時 panic(fail-fast,對齊 ADR-015 §3.5.3 #1)
- 新增 ErrConverterAuthFailed / ErrFAAAuthFailed sentinel
- 對外 mask 成 converter_unavailable / faa_unavailable(不洩漏 401 細節,對齊 ADR-015 §3.5.3 #3)
T3 砍 mc_token_client:
- mc_token_client.go (624 行) + mc_token_client_test.go (864 行) 整檔砍
- 砍 5 個僅 mc_token_client 用的 sentinel(ErrServiceClientUnauthorized / ErrMCTokenUnavailable / ErrIDPMisconfigured / ErrIDPUnavailable / ErrDownloadTokenFailed)
- helper(truncate / silentLogger)搬到 util.go / testing_helpers_test.go
T4 flow + handler stream proxy:
- Service interface DownloadRedirectURL → DownloadStream(ctx) (io.ReadCloser, *DownloadMetadata, error)
- flow.DownloadStream 用 faa.GetFile 直接 stream NEF binary(取代 MC delegated token + 302)
- handler conversionDownloadHandler 改 io.Copy + Content-Type/Disposition/Cache-Control header
- 新增 sanitizeDownloadFilename helper 防 HTTP header injection
- 跨 package handler test (conversion_test.go) 改測 ErrFAAUnavailable + 補 *_AuthFailed 對稱 test
T5 wire 切換 + cleanup:
- main.go 砍 mcTokenClient wire、改 APIKey 注入、startup log 用 *_api_key_set boolean(不印 key)
- ConverterClient/FAAClient struct Tokens 欄位移除
- mc_token_stub.go (T4 過渡期) 整檔砍
- ConversionConfig TenantID / DelegatedTTLSeconds 欄位移除
- e2e_test.go TestConversionE2E_Download302Redirect 改寫為 TestConversionE2E_DownloadStream
- 補 MaxDownloadStreamBytes = 1 GiB size cap(io.CopyN,T4 reviewer Minor M-1)
- escapeObjectKeyPath Phase 1+ 預留 godoc + //nolint:unused(T4 reviewer Minor M-2)
- conversion.md §4.1 line 502 filename 來源描述歧義修訂(T4 reviewer Minor M-3,由 architect 處理)
- conversion_e2e_test.go 檔頭 docstring 更新(final reviewer Minor #1)
驗證:
- go build ./... exit 0
- go test -race -count=3 ./... 17 packages 全綠
- ADR-015 §6 砍除清單 100% cover(reviewer 獨立 grep 確認 MC chain / TenantID / DelegatedTTLSeconds 全清)
- ADR-015 §3.5.3 部署檢查清單 visionA 範圍 4/4 達成(fail-fast / mask / 不印 token / placeholder env)
不動:
- OIDCConfig.ServiceClientID/Secret 欄位保留(使用者拍板 backward compat)
- user login OIDC 完全不動
下一步:
- 步驟 4 — converter scheduler middleware 改 API key(jimchen 跨 repo,ADR-015 §3.5.1 Go snippet)
- 步驟 5 — FAA middleware 改 API key(warrenchen 跨 repo,ADR-015 §3.5.2 C# snippet)
- 步驟 6 — stage redeploy + e2e 完整測試
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
827 lines
32 KiB
Go
827 lines
32 KiB
Go
// 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 T4:DownloadFn signature 從 (string, error) 改 (ReadCloser, *DownloadMetadata, error)
|
||
// — Service interface 從 DownloadRedirectURL 改 DownloadStream(API key 模式下沒有
|
||
// delegated token,改 server-side stream proxy;ADR-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.go:cfg.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 T4:handler 改成 server-side stream proxy(API 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 body(Phase 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_ChunkedTransfer:FAA 走 chunked transfer encoding 時
|
||
// (ContentLength = -1),handler 不應 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-Length;httptest 不會自動補(gin Status 後即 commit header)
|
||
assert.Empty(t, w.Header().Get("Content-Length"),
|
||
"chunked transfer 時 handler 不應 set Content-Length(FAA 給 -1 → 讓 net/http 用 chunked)")
|
||
assert.Equal(t, nefPayload, w.Body.String())
|
||
}
|
||
|
||
// TestConversion_Download_FilenameSanitization:Service 給含特殊字元的 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 error(200 還沒寫,可以 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_FAAUnavailable:FAA stream 失敗 → handler 回 502 + faa_unavailable。
|
||
//
|
||
// Phase 0.8b T4 補漏:取代原本的 TestConversion_Download_MCTokenUnavailable
|
||
// (MC 認證鏈已取消,ErrMCTokenUnavailable sentinel 已砍 — 對應 errors.go T3 砍除清單)。
|
||
// 保留 download 5xx 路徑覆蓋,改測 ErrFAAUnavailable(API 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")
|
||
}
|
||
|
||
// TestConversion_Download_FAAAuthFailed:API key 不對齊(運維事件)
|
||
// → handler 回 502,對外 mask 成 faa_unavailable(不洩漏「API key 不對」)。
|
||
//
|
||
// 對齊 ADR-015 §3.5.3 #3「對外只回 unauthorized」原則 + conversion.md §6 mask 行為:
|
||
// SRE 從 server log 的 ErrFAAAuthFailed sentinel 排查 env,但對 frontend 文字一致。
|
||
func TestConversion_Download_FAAAuthFailed(t *testing.T) {
|
||
svc := &stubConversionService{
|
||
DownloadFn: func(ctx context.Context, userID, jobID string) (io.ReadCloser, *conversion.DownloadMetadata, error) {
|
||
return nil, nil, conversion.ErrFAAAuthFailed
|
||
},
|
||
}
|
||
r := newConversionFixture(t, svc)
|
||
|
||
req := httptest.NewRequest(http.MethodGet, "/api/conversion/job/download", nil)
|
||
w := httptest.NewRecorder()
|
||
r.ServeHTTP(w, req)
|
||
|
||
// 對外 502 + faa_unavailable(mask)— 不要洩漏 auth_failed 這個內部運維狀態
|
||
assert.Equal(t, http.StatusBadGateway, w.Code)
|
||
assert.Contains(t, w.Body.String(), "faa_unavailable",
|
||
"ErrFAAAuthFailed 對外應 mask 成 faa_unavailable,不洩漏 API key 不對齊細節")
|
||
assert.NotContains(t, w.Body.String(), "auth_failed",
|
||
"對 frontend 不應暴露 auth_failed 這個內部 SRE 訊號")
|
||
}
|
||
|
||
// 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_SizeCapEnforced:T5 補(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 仍 200(cap 命中時 header 已 commit、無法回頭改 status)
|
||
assert.Equal(t, http.StatusOK, w.Code,
|
||
"size cap 命中時 status 仍應 200(header 已 commit)")
|
||
|
||
// 2. body 被 truncate 到 cap — 不會把整個 infinite stream 寫給 client
|
||
assert.Equal(t, int(testCapBytes), w.Body.Len(),
|
||
"body 應被 truncate 到 cap(%d bytes),infinite 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_id,handler
|
||
// 仍只把 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=attacker,handler 給 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)
|
||
}
|