diff --git a/visionA-backend/cmd/api-server/conversion_e2e_test.go b/visionA-backend/cmd/api-server/conversion_e2e_test.go index 72d3f91..918f3f1 100644 --- a/visionA-backend/cmd/api-server/conversion_e2e_test.go +++ b/visionA-backend/cmd/api-server/conversion_e2e_test.go @@ -513,67 +513,60 @@ func converterJobToMap(j *conversion.ConverterJob) map[string]any { // 也只需 visionA 端有 HMAC_KEY;不需要 mock MC 端。 // ========================================================================== -// mockFAA — Phase 0.8b:模擬 FAA `GET /files/{key}` 回 NEF binary stream -// (配合 download e2e 從 302 redirect 改 server-side stream proxy 模式)。 +// mockFAA — **Regression-only**(Phase 0.8b v0.6 T3 起)。 +// +// 用途已演進: +// - Phase 0.8b v0.4 / v0.5:模擬 FAA `GET /files/{key}` 回 NEF binary stream, +// visionA backend 端真的會打它(API key 認證) +// - **Phase 0.8b v0.6 T3 起(本 commit)**:visionA backend 端不再直接打 FAA +// (ADR-016 撤回;FAAClient interface + faa_client.go 整檔刪除)。mockFAA server +// **保留**作為「e2e regression 防護」—— 若未來某 agent 不小心把 FAA 直連加回 +// production code(例如「optimize: 直接打 FAA 跳一層」、或誤從 git history copy 舊 +// 程式碼),`getCallCount.Load() == 0` 的 negative assertion 會立即 fail +// (TestConversionE2E_DownloadStream:1037-1048)。 +// +// 為什麼**保留 mockFAA server**而不是純編譯期靜態斷言: +// - 純編譯期斷言(`var _ = (*conversion.FAAClient)(nil)`)已透過「FAAClient interface +// 不存在」自動成立 — production code 不可能再 import 不存在的 type +// - 但若有人**直接用 net/http 手寫**對 FAA 的 request(不透過 conversion package +// interface)、編譯期斷言抓不到。mockFAA server 在 e2e 層提供 wire-level 防護 +// (只要 visionA 端對 mockFAA URL 發任何 GET request、getCallCount > 0 → test fail) +// +// 為什麼選方案 1(保留 mockFAA + negative assertion)而非方案 2(純結構性保證): +// - 方案 1 額外維護成本:50 行 mock + 1 行 assertion;功能性影響 0(mock 不會被 visionA +// 端打到) +// - 方案 2 強度只到「FAAClient interface 不存在」、抓不到 raw net/http 手寫的 regression +// - 採方案 1 給未來 ADR-016 設計約束多一層 wire-level 防護、cost-benefit 划算 // ========================================================================== type mockFAA struct { srv *httptest.Server - // 收到的 Authorization header(測試驗 visionA 真有帶 API key) - mu sync.Mutex - lastAuthHeader string - getCallCount atomic.Int32 - - // nefPayload 是模擬的 NEF binary(由測試 setNEFPayload 設定); - // nil → 預設一個小 marker payload。 - nefPayload []byte + // **regression-only**:v0.6 T3 後 visionA 端不應再對 mockFAA 發任何 request; + // 此 counter > 0 → 設計約束被破壞、立即 fail e2e + getCallCount atomic.Int32 } func newMockFAA(t *testing.T) *mockFAA { t.Helper() m := &mockFAA{} m.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // 只處理 GET /files/...(對齊 FAA API spec) + // **v0.6 T3 起**:任何 request 進來都應視為設計違規;用 counter 記下,讓 e2e 斷言抓到 + m.getCallCount.Add(1) + // 仍照舊處理 GET /files/... 以避免 visionA 端因連線失敗發生 panic(讓 e2e 拿到具體 + // 「不該有的呼叫」而非「連線錯誤」)—— 但 production code 不應走到這條 path if r.Method != http.MethodGet || !strings.HasPrefix(r.URL.Path, "/files/") { http.NotFound(w, r) return } - m.getCallCount.Add(1) - m.mu.Lock() - m.lastAuthHeader = r.Header.Get("Authorization") - payload := m.nefPayload - m.mu.Unlock() - if payload == nil { - payload = []byte("mock-nef-default-payload") - } - // 模擬 FAA 回 NEF binary stream w.Header().Set("Content-Type", "application/octet-stream") - w.Header().Set("Content-Length", strconv.Itoa(len(payload))) - w.Header().Set("ETag", "etag-mock-faa") + w.Header().Set("Content-Length", "0") w.WriteHeader(http.StatusOK) - _, _ = w.Write(payload) })) t.Cleanup(m.srv.Close) return m } -// setNEFPayload 設定下一個(與後續所有)GET /files 回的 binary 內容; -// 測試端以此控制「user 下載到的 bytes」。 -func (m *mockFAA) setNEFPayload(payload []byte) { - m.mu.Lock() - defer m.mu.Unlock() - m.nefPayload = payload -} - -// getLastAuthHeader 取最後一次 GET /files 收到的 Authorization header -// (測試驗 visionA 帶上正確 Bearer )。 -func (m *mockFAA) getLastAuthHeader() string { - m.mu.Lock() - defer m.mu.Unlock() - return m.lastAuthHeader -} - // ========================================================================== // conversionFixture — 把所有 server 拼起來並提供 OIDC 登入 helper // ========================================================================== @@ -599,14 +592,19 @@ func (f *conversionFixture) Close() { } // setupConversionFixture 建立完整的 e2e 環境: -// - mock converter / FAA(Phase 0.8b 後不再 wire mock MC — API key 模式) +// - mock converter(API key 模式) +// - mock FAA(**Phase 0.8b v0.6 T3 起為 regression-only**;visionA 端不應再對它發 request; +// 若 getCallCount > 0 表示設計約束被破壞,由 e2e negative assertion 抓出) // - fake OIDC(給 user 走 cookie session 登入) // - visionA-backend router(含 conversion service wired,仿 main.go wire 邏輯) // // **不影響 T1-T7 既有 code**:本 fixture 完全獨立,不重用 setupFixture(後者沒 wire conversion)。 // // Phase 0.8b T5:服務間認證改 pre-shared API key(ADR-015);mock MC / mcTokenClient -// 已從 fixture 移除;download 走 server-side stream proxy,mockFAA 直接回 NEF binary。 +// 已從 fixture 移除。 +// Phase 0.8b v0.6 T3:FAA wire 從 conversion.FlowOpts 移除(FAAClient interface 已砍); +// mockFAA server 保留作為「visionA 端不再直接打 FAA」的 regression 防護 +// (TestConversionE2E_DownloadStream:1037-1048 的 negative assertion 仍生效)。 func setupConversionFixture(t *testing.T) *conversionFixture { t.Helper() @@ -655,14 +653,17 @@ func setupConversionFixture(t *testing.T) *conversionFixture { // // Phase 0.8b T5:服務間認證改 pre-shared API key(ADR-015); // - 不再 wire MCTokenClient / Tokens 欄位 - // - converter / FAA client 各自帶 fixture 用的 API key - // - mock converter / FAA 端不驗 key(測試重點是 visionA 端的 wire 行為與 stream proxy) + // - converter client 帶 fixture 用的 API key + // - mock converter 端不驗 key(測試重點是 visionA 端的 wire 行為與 stream proxy) // - // 注意:converter_client / faa_client 都用 5s timeout HTTPClient 避免測試卡死; + // Phase 0.8b v0.6 T3:visionA 端不再 wire FAA client(FAAClient interface 已砍); + // download / promote 都走 converter.GetResult。mockFAA server 仍 spin up 作為 + // regression-only 防護(見 newMockFAA godoc)。 + // + // 注意:converter_client 用 5s timeout HTTPClient 避免測試卡死; // 對 mock servers 來說連線秒回,timeout 不會觸發。 fastHTTP := &http.Client{Timeout: 5 * time.Second} const fixtureConverterAPIKey = "fixture-converter-api-key-do-not-use-in-prod-aaaaaaaaaaaaaaaaaa" - const fixtureFAAAPIKey = "fixture-faa-api-key-do-not-use-in-prod-bbbbbbbbbbbbbbbbbbbbbbbbbb" converterAPIClient := conversion.NewConverterClient(conversion.ConverterClientOpts{ BaseURL: conv.srv.URL, APIKey: fixtureConverterAPIKey, @@ -670,12 +671,6 @@ func setupConversionFixture(t *testing.T) *conversionFixture { InitHTTPClient: &http.Client{Timeout: 60 * time.Second}, // 場景 #1 大 body 給寬一點 Logger: logger, }) - faaAPIClient := conversion.NewFAAClient(conversion.FAAClientOpts{ - BaseURL: faa.srv.URL, - APIKey: fixtureFAAAPIKey, - HTTPClient: fastHTTP, - Logger: logger, - }) ownership := conversion.NewOwnership(converterAPIClient, logger) modelRepo := model.NewInMemoryRepository() @@ -684,7 +679,6 @@ func setupConversionFixture(t *testing.T) *conversionFixture { conversionService, err := conversion.NewService(conversion.FlowOpts{ Converter: converterAPIClient, - FAA: faaAPIClient, Ownership: ownership, ModelStore: modelStoreAdapter, Storage: storageAdapter, @@ -1034,18 +1028,23 @@ func TestConversionE2E_DownloadStream(t *testing.T) { assert.Empty(t, resp.Header.Get("Location"), "Phase 0.8b 不應有 Location header(無 redirect 流程)") - // === 斷言 6(v0.6 新增):visionA 端不再直接打 FAA === + // === 斷言 6:visionA 端不再直接打 FAA(v0.6 設計約束 + T3 雙層防護)=== // // 這條斷言是 **ADR-016 §1 設計約束的 regression 防護**:visionA 端不再直接呼叫 FAA, - // 整條 download path 改走 converter MinIO。萬一未來某 agent 不小心把 `f.faa.GetFile` - // 加回 production code(例如「optimize: 直接打 FAA 跳一層」),此 e2e 立即 fail。 - // 比依賴 reviewer 抓更可靠。 + // 整條 download path 改走 converter MinIO。萬一未來某 agent 不小心加回 FAA 直連 + // (例如「optimize: 直接打 FAA 跳一層」、或從 git history 誤 copy 舊程式碼),此 e2e + // 立即 fail。 // - // **T3 計畫**:T3 砍 faa_client.go 整檔後,mock FAA + `f.faa.getCallCount` 計數器都會 - // 一起砍;此 assertion 應改為「wire 點不存在 FAA dependency」的編譯期靜態斷言 - // (e.g. `var _ = (*conversion.FAAClient)(nil)` 不應 compile),維持同等強度的 regression 防護。 + // **T3 後的雙層防護**(reviewer 推薦方案 1:保留 mockFAA + negative assertion): + // 1. **編譯期保證**(v0.6 T3 新加):`FAAClient` interface + `faa_client.go` 整檔已砍除; + // production code 不可能再 import `conversion.FAAClient` 或 `conversion.NewFAAClient` + // (type 不存在 → 編譯 fail) + // 2. **執行期保證**(本斷言):mockFAA server 仍 spin up;若有人直接用 net/http 手寫 + // 對 FAA URL 的 request(繞過 conversion package interface),getCallCount > 0 → + // e2e fail。涵蓋編譯期斷言無法抓的「raw HTTP 直連」regression assert.Equal(t, int32(0), f.faa.getCallCount.Load(), - "v0.6:visionA 端不應再直接打 FAA(download path 改走 converter `/result`)") + "v0.6 T3:visionA 端不應再直接打 FAA(download path 改走 converter `/result`;"+ + "FAAClient interface 已整檔砍除作為第一層編譯期防護,此斷言為第二層執行期防護)") // 驗 mock converter `/result` 真的被打到(防 wire 路徑錯) assert.GreaterOrEqual(t, int(f.conv.getResultCallCount.Load()), 1, "mock converter GET /api/v1/jobs/{id}/result 應至少被打一次") diff --git a/visionA-backend/cmd/api-server/main.go b/visionA-backend/cmd/api-server/main.go index 2262d3d..f029ddc 100644 --- a/visionA-backend/cmd/api-server/main.go +++ b/visionA-backend/cmd/api-server/main.go @@ -138,15 +138,19 @@ func main() { converterClient := converter.NewStubClient() // ===== Phase 0.8 / 0.8b Conversion(轉檔功能整合) ===== - // 對齊 .autoflow/04-architecture/conversion.md、ADR-015。 + // 對齊 docs/autoflow/04-architecture/conversion.md、ADR-015、ADR-016。 // // 啟用條件:cfg.Conversion.Enabled() — - // ConverterBaseURL + FAABaseURL + ConverterAPIKey + FAAAPIKey 全部非空。 + // 由 ConverterBaseURL + ConverterAPIKey 決定(FAABaseURL / FAAAPIKey 由 T4 砍除 env 校驗)。 // 不啟用時 deps.Conversion 為 nil,5 個 endpoint 自動回 501(registerConversionRoutes 處理)。 // // **Phase 0.8b T5**:完全切換至 pre-shared API key 認證 — 不再 wire MCTokenClient、 // 不再讀 OIDCConfig.ServiceClientID/Secret、不再有 tenant_id / delegated_ttl_sec // 概念。參見 ADR-015 §6 變更影響清單。 + // + // **Phase 0.8b v0.6 T3**:撤回 visionA → FAA 直接呼叫(ADR-016 撤回 v0.5 設計缺口)。 + // faa_client.go / FAAClient interface / FlowOpts.FAA 全部砍除;download / promote 流程 + // 改走 converter.GetResult。FAABaseURL / FAAAPIKey env 仍保留在 config 直到 T4 一併砍。 var conversionService conversion.Service if cfg.Conversion.Enabled() { // 不再檢查 ServiceClientID/Secret —— Phase 0.8b 起 conversion 不依賴 OIDC service client。 @@ -157,11 +161,6 @@ func main() { APIKey: cfg.Conversion.ConverterAPIKey, Logger: log, }) - faaAPIClient := conversion.NewFAAClient(conversion.FAAClientOpts{ - BaseURL: cfg.Conversion.FAABaseURL, - APIKey: cfg.Conversion.FAAAPIKey, - Logger: log, - }) ownership := conversion.NewOwnership(converterAPIClient, log) // narrow adapter(避免 conversion 直接 import internal/model / internal/storage) @@ -171,7 +170,6 @@ func main() { var convErr error conversionService, convErr = conversion.NewService(conversion.FlowOpts{ Converter: converterAPIClient, - FAA: faaAPIClient, Ownership: ownership, ModelStore: modelStoreAdapter, Storage: storageAdapter, @@ -183,12 +181,10 @@ func main() { } log.Info("conversion service initialized", "converter_base_url", cfg.Conversion.ConverterBaseURL, - "faa_base_url", cfg.Conversion.FAABaseURL, // 安全:絕不印 key 全文 — 對齊 ADR-015 §3.5.3 部署檢查清單 #4 - "converter_api_key_set", cfg.Conversion.ConverterAPIKey != "", - "faa_api_key_set", cfg.Conversion.FAAAPIKey != "") + "converter_api_key_set", cfg.Conversion.ConverterAPIKey != "") } else { - log.Info("conversion service disabled (set VISIONA_CONVERTER_BASE_URL + VISIONA_FAA_BASE_URL + VISIONA_CONVERTER_API_KEY + VISIONA_FAA_API_KEY to enable)") + log.Info("conversion service disabled (set VISIONA_CONVERTER_BASE_URL + VISIONA_CONVERTER_API_KEY to enable)") } // ===== Seed demo data(可選) ===== diff --git a/visionA-backend/internal/api/conversion.go b/visionA-backend/internal/api/conversion.go index 4484028..70244eb 100644 --- a/visionA-backend/internal/api/conversion.go +++ b/visionA-backend/internal/api/conversion.go @@ -48,12 +48,14 @@ import ( // // 由 NewRouter 在 apiGroup(OIDC AuthMiddleware 已套)下呼叫; // 若 deps.Conversion 為 nil(Phase 0.8 conversion 未啟用,例如 dev 環境沒設 -// CONVERTER_BASE_URL / FAA_BASE_URL)→ 5 個 endpoint 一律回 501。 +// VISIONA_CONVERTER_BASE_URL + VISIONA_CONVERTER_API_KEY)→ 5 個 endpoint 一律回 501。 +// Phase 0.8b v0.6(ADR-016):visionA 端不再直連 FAA、download 改走 converter +// GET /api/v1/jobs/{id}/result,因此不再需要 FAA env。 func registerConversionRoutes(g *gin.RouterGroup, deps Deps) { if deps.Conversion == nil { // 未啟用 — 註冊 501 stub,避免 404(讓 frontend 拿到明確 NOT_IMPLEMENTED) notImpl := func(c *gin.Context) { - WriteNotImplemented(c, "conversion service is not configured (set VISIONA_CONVERTER_BASE_URL + VISIONA_FAA_BASE_URL)") + WriteNotImplemented(c, "conversion service is not configured (set VISIONA_CONVERTER_BASE_URL + VISIONA_CONVERTER_API_KEY)") } conv := g.Group("/conversion") conv.POST("/init", notImpl) @@ -270,15 +272,17 @@ func conversionPromoteHandler(deps Deps) gin.HandlerFunc { // - 失敗:不寫 200,依 sentinel 走 handleConversionError 回 JSON // - Cache-Control: no-store — 避免 browser 對私有檔案 cache // -// Phase 0.8 → 0.8b 差異: +// Phase 0.8 → 0.8b → v0.6 演進: // - Phase 0.8:visionA → MC 換 delegated token → c.Redirect(302, FAA_URL_with_token) -// - Phase 0.8b:visionA backend 用 API key 直接拉 FAA → io.Copy(c.Writer, stream) -// 沒有 token 結構性流經 frontend;不需 FAA CORS(server-side outbound HTTP) +// - Phase 0.8b v0.4/v0.5:visionA backend 用 API key 直接拉 FAA → io.Copy(c.Writer, stream) +// - **Phase 0.8b v0.6**(ADR-016):visionA backend 改走 `converter.GetResult` 從 converter +// MinIO 拉 NEF stream(visionA 端不再直接打 FAA、撤回 v0.5 設計缺口);handler 端 +// io.Copy(c.Writer, stream) 路徑不變、只是 stream 來源換成 converter // // 中途錯誤處理(已 200 / 已寫 part of body 後失敗): // - 一旦 status 200 已寫,無法再改 status 給 client(HTTP 規範) // - io.Copy 中斷只能 log 錯誤;client 端 browser 會看到截斷檔 -// - ctx cancel(client 斷線)由 FAAClient 內部 ctx-aware 透傳,goroutine 自動結束 +// - ctx cancel(client 斷線)由 ConverterClient 內部 ctx-aware 透傳,goroutine 自動結束 func conversionDownloadHandler(deps Deps) gin.HandlerFunc { return func(c *gin.Context) { uc, ok := UserContextFrom(c) diff --git a/visionA-backend/internal/api/conversion_test.go b/visionA-backend/internal/api/conversion_test.go index ba5b53b..c88cc0c 100644 --- a/visionA-backend/internal/api/conversion_test.go +++ b/visionA-backend/internal/api/conversion_test.go @@ -657,30 +657,10 @@ func TestConversion_Download_FAAUnavailable(t *testing.T) { 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 訊號") -} +// Phase 0.8b v0.6 T3:TestConversion_Download_FAAAuthFailed 已整段移除 +// (visionA 端不再直接打 FAA、ErrFAAAuthFailed sentinel 已砍除;ADR-016)。 +// 對應的 converter API key 對外 mask 行為由 TestConversion_Download_ConverterAuthFailed 涵蓋 +// (v0.6 後 download path 改走 converter,auth 失敗統一收斂到 ErrConverterAuthFailed)。 // TestConversion_Download_ConverterAuthFailed:對稱測試 converter API key 不對齊。 // 對外 mask 成 converter_unavailable。 diff --git a/visionA-backend/internal/conversion/converter_client_test.go b/visionA-backend/internal/conversion/converter_client_test.go index 8c8fd15..b181bf0 100644 --- a/visionA-backend/internal/conversion/converter_client_test.go +++ b/visionA-backend/internal/conversion/converter_client_test.go @@ -1272,6 +1272,23 @@ func TestGetResult_EmptyJobID(t *testing.T) { } // TestParseFilenameFromContentDisposition:cover parser 的 happy / empty / malformed case。 +// +// v0.6 T3 s-5 補強(reviewer T1 提的 RFC 5987 encoded form + hostile-input sub-case): +// - RFC 5987 encoded form(`filename*=UTF-8''...`)— 驗 Go stdlib `mime.ParseMediaType` +// 對 charset-encoded `filename*` 參數的 transparent 解碼行為 +// - Hostile-input:CRLF injection / path traversal / null byte / extreme length +// +// **重要發現**:Go stdlib `mime.ParseMediaType` 對 `filename*=UTF-8''...` 形式 +// **自動 percent-decode** 並寫入 `params["filename"]`(不需 caller 端額外讀 `filename*`)。 +// 即 parser 取回的字串會是 UTF-8 解碼後的值(如 `foo_✓.nef`),而非 raw URL-encoded +// (`foo_%E2%9C%93.nef`)。**且 RFC 5987 form 優先於 ASCII filename**(當兩者並存時)。 +// 此行為對 visionA 無影響: +// - flow.DownloadStream 對 filename 不依賴此 parser;用 defaultDownloadFilename(cj) 覆寫 +// - converter 端 ADR-016 §1.2 給的 filename 是 ASCII(`_.nef`)、不會走到 RFC 5987 path +// +// Hostile-input 防護:mime.ParseMediaType 對 malformed input(CRLF)回 error → parser 回 +// 空字串、不 panic、不洩漏 raw input;visionA-backend handler (api/conversion.go) 對 +// filename 另有 sanitize 層(CRLF / quote 移除),不依賴此 parser 做 sanitize。 func TestParseFilenameFromContentDisposition(t *testing.T) { t.Parallel() @@ -1286,6 +1303,31 @@ func TestParseFilenameFromContentDisposition(t *testing.T) { {"malformed_no_attachment", `;;;`, ""}, {"missing_filename_param", `attachment`, ""}, {"inline_disposition", `inline; filename="foo.bin"`, "foo.bin"}, + // === v0.6 T3 s-5 補強 === + // RFC 5987 encoded form — Go stdlib mime.ParseMediaType 自動 percent-decode 為 UTF-8 + // 並寫進 params["filename"](透明行為);對 ASCII-only 值結果就是原字串 + {"rfc5987_utf8_ascii_only", `attachment; filename*=UTF-8''yolov5s_kl720.nef`, "yolov5s_kl720.nef"}, + // RFC 5987 含 lang tag(`UTF-8'en'foo.nef`)— 同上、結果為解碼後 ASCII + {"rfc5987_with_lang", `attachment; filename*=UTF-8'en'foo.nef`, "foo.nef"}, + // RFC 5987 含 percent-encoded UTF-8(✓ 字元)— stdlib 解碼後回 UTF-8 字串 + // 且 RFC 5987 form **優先於** ASCII fallback(當兩者並存時,stdlib 取 `filename*` 值) + {"rfc5987_utf8_with_unicode", `attachment; filename="foo.nef"; filename*=UTF-8''foo_%E2%9C%93.nef`, "foo_✓.nef"}, + // Hostile-input 1:CRLF injection(HTTP response splitting 攻擊向量) + // `mime.ParseMediaType` 對含 CR/LF 的 header 回 error → parser 回空字串 + {"hostile_crlf_injection", "attachment; filename=\"foo.nef\r\nSet-Cookie: evil=1\"", ""}, + // Hostile-input 2:path traversal — parser 端不做 sanitize(responsibility 在 handler); + // 此處只驗 parser 不 panic、不丟錯,把 raw `../../etc/passwd` 字串原樣傳出(caller 端 + // 後續的 defaultDownloadFilename / handler sanitize 會處理) + {"hostile_path_traversal", `attachment; filename="../../etc/passwd"`, "../../etc/passwd"}, + // Hostile-input 3:null byte injection — `mime.ParseMediaType` 容忍 null byte + // (規範未明禁),parser 把 raw 字串傳出(同上、由後續 layer sanitize) + {"hostile_null_byte", "attachment; filename=\"foo\x00.nef\"", "foo\x00.nef"}, + // Hostile-input 4:extreme length(4KB filename)— parser 不限制長度、回 raw 字串 + // 對 visionA 影響:0(response header 4KB 在 Content-Disposition 內早就被 net/http + // 拒絕;此 case 純驗 parser 不 OOM / 不 panic) + {"hostile_extreme_length", `attachment; filename="` + strings.Repeat("A", 4096) + `"`, strings.Repeat("A", 4096)}, + // Hostile-input 5:empty quoted string — 合法格式、回空字串 + {"empty_quoted_filename", `attachment; filename=""`, ""}, } for _, tc := range tests { tc := tc diff --git a/visionA-backend/internal/conversion/errors.go b/visionA-backend/internal/conversion/errors.go index 69d10b9..2488d50 100644 --- a/visionA-backend/internal/conversion/errors.go +++ b/visionA-backend/internal/conversion/errors.go @@ -43,18 +43,26 @@ var ( // 對應 HTTP 502 / code "converter_unavailable"。 ErrConverterUnavailable = errors.New("conversion: converter unavailable") - // ErrFAAUnavailable — FAA 5xx / network 持續失敗。 + // ErrFAAUnavailable — converter 端對 FAA 推送 NEF 失敗(converter 回 502 file_gateway_unavailable)。 + // + // Phase 0.8b v0.6(T3)後語意調整:visionA 端不再直接呼叫 FAA(ADR-016 撤回), + // 此 sentinel 改由 `converter_client.go` 的 promote response mapping 使用 —— + // 當 converter promote 內部 PUT FAA 失敗時,converter 回 502 `file_gateway_unavailable`, + // visionA-backend 透傳成 `ErrFAAUnavailable` 給 handler 對外 502 + `faa_unavailable`。 + // + // 與 ErrConverterUnavailable 區分: + // - ErrConverterUnavailable:converter scheduler 本身不可達 / 5xx(visionA → converter 失敗) + // - ErrFAAUnavailable:converter 可達、但 converter → FAA push 失敗(運維告警打 FAA team) + // // 對應 HTTP 502 / code "faa_unavailable"。 ErrFAAUnavailable = errors.New("conversion: faa unavailable") - // ErrFAAFileNotFound — FAA 回 404(指定 object_key 不存在)。 - // 觸發情境:promote-to-models 流程 promoted 後 FAA pull 卻找不到檔(罕見: - // converter promote 才剛寫 FAA、應立即可見)— 可能 FAA 端 GC、或 object_key 命名邏輯有 bug。 - // 對應 HTTP 502 / code "faa_unavailable"(對外仍視為 FAA 不可用,避免揭露內部 object key 細節)。 - // caller(flow.go)可用 errors.Is(err, ErrFAAFileNotFound) 做精細處理(log / metric)。 - // - // Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §2.6 + §9.2) - ErrFAAFileNotFound = errors.New("conversion: faa file not found") + // Phase 0.8b v0.6 T3 移除(visionA 端不再直接打 FAA、相關 sentinel 不再被觸發): + // - ErrFAAFileNotFound — FAA `GET /files/{key}` 404(visionA 端已無此 call path) + // - ErrFAAAuthFailed — visionA → FAA API key 401/403(visionA 端已無此 call path) + // 取代:FAA 相關失敗模式收斂到 converter 端透傳(converter promote 失敗 → ErrFAAUnavailable)。 + // **不重用舊 sentinel name**(同 T3 ErrIDPUnavailable 規範;Phase 1+ 若未來再加 FAA 直連 + // 路徑,採新 sentinel name 避免閱讀 git log 時混淆語意)。 // ErrServiceBusy — converter 端回 503 service_busy。 // 對應 HTTP 503 / code "service_busy"。 @@ -84,8 +92,9 @@ var ( // - ErrIDPMisconfigured — MC token endpoint 4xx(client_credentials grant 設定錯誤) // - ErrIDPUnavailable — MC oauth/token 5xx / network 持續失敗 // - ErrServiceClientUnauthorized — visionA → MC 認證失敗(401/403) - // 取代:401/403 改 mapping 到 ErrConverterAuthFailed / ErrFAAAuthFailed(下方); + // 取代:401/403 改 mapping 到 ErrConverterAuthFailed(下方); // converter 端 503 改 mapping 到 ErrConverterUnavailable(converter_client.go mapPromoteError)。 + // (Phase 0.8b v0.6 T3 加註:原 v0.4/v0.5 引入的 ErrFAAAuthFailed 也已砍除,見上方說明。) // // **不重用舊 sentinel name**(Phase 1+ 注意):上述 5 個 sentinel name 已從 git history // 中砍除,未來若需要新增 sentinel 不應重用同名(如 `ErrIDPUnavailable`),以免閱讀 @@ -110,17 +119,7 @@ var ( // Phase 0.8b conversion (見 ADR-015 §6 / conversion.md §6 / api-conversion.md) ErrConverterAuthFailed = errors.New("conversion: converter api key auth failed") - // ErrFAAAuthFailed — visionA-backend → FAA 帶的 API key 不對齊(FAA middleware 401 / 403)。 - // - // 觸發情境(Phase 0.8b API key 路徑): - // - VISIONA_FAA_API_KEY 與 FAA 端 FAA_API_KEY 不同步(warrenchen 跨 repo 維護) - // - FAA middleware 上線前 visionA 過早部署 - // - // 設計選擇:與 ErrConverterAuthFailed 對稱、與 ErrFAAUnavailable 分開(同樣 mask 成 - // faa_unavailable / 502 對外、區分只在 server log)。 - // - // Phase 0.8b conversion (見 ADR-015 §6 / conversion.md §6 / api-conversion.md) - ErrFAAAuthFailed = errors.New("conversion: faa api key auth failed") + // Phase 0.8b v0.6 T3 移除:ErrFAAAuthFailed(同上方說明)。 // ErrStorageUnavailable — visionA 自家 storage(local FS / S3)寫入或讀取失敗。 // @@ -243,11 +242,10 @@ func ErrorCode(err error) string { return "payload_too_large" case errors.Is(err, ErrConverterUnavailable): return "converter_unavailable" - case errors.Is(err, ErrFAAFileNotFound): - // 對外仍視為 faa_unavailable,避免揭露 object_key 不存在的內部細節。 - // caller 想做精細處理用 errors.Is(err, ErrFAAFileNotFound) 直接判斷。 - return "faa_unavailable" case errors.Is(err, ErrFAAUnavailable): + // Phase 0.8b v0.6 T3 起:此 sentinel 改由 converter promote 502 file_gateway_unavailable + // 透傳(visionA 端不再直接打 FAA);對外 code 仍為 faa_unavailable,給 SRE 區分 + // converter 不可達 vs converter 端 push FAA 失敗。 return "faa_unavailable" case errors.Is(err, ErrServiceBusy): return "service_busy" @@ -260,9 +258,6 @@ func ErrorCode(err error) string { // Phase 0.8b:對外刻意 mask 成 converter_unavailable(不揭露「API key 不對」內部狀態); // caller 想做精細處理用 errors.Is(err, ErrConverterAuthFailed) 直接判斷(log / metric)。 return "converter_unavailable" - case errors.Is(err, ErrFAAAuthFailed): - // Phase 0.8b:對外刻意 mask 成 faa_unavailable,理由同上。 - return "faa_unavailable" case errors.Is(err, ErrStorageUnavailable): return "storage_unavailable" case errors.Is(err, ErrModelStoreUnavailable): @@ -289,11 +284,11 @@ func HTTPStatus(err error) int { return 413 case errors.Is(err, ErrConverterUnavailable), errors.Is(err, ErrFAAUnavailable), - errors.Is(err, ErrFAAFileNotFound), - errors.Is(err, ErrConverterAuthFailed), - errors.Is(err, ErrFAAAuthFailed): + errors.Is(err, ErrConverterAuthFailed): // Phase 0.8b:API key auth_failed 對外與「服務不可達」同層 502; // 內部 log / metric 才區分(auth_failed = SRE alarm;其他 = 自然 retry) + // v0.6 T3 後:ErrFAAFileNotFound / ErrFAAAuthFailed 已砍(visionA 端不再直接打 FAA); + // ErrFAAUnavailable 沿用、改由 converter promote 502 file_gateway_unavailable 透傳 return 502 case errors.Is(err, ErrStorageUnavailable), errors.Is(err, ErrModelStoreUnavailable): // visionA 自身基礎設施問題 → 500(不是 502 gateway,因為非 upstream 失敗) diff --git a/visionA-backend/internal/conversion/errors_test.go b/visionA-backend/internal/conversion/errors_test.go index 95e9b44..b40c8b6 100644 --- a/visionA-backend/internal/conversion/errors_test.go +++ b/visionA-backend/internal/conversion/errors_test.go @@ -26,14 +26,17 @@ func TestErrorCode(t *testing.T) { {"validation_failed", ErrValidationFailed, "validation_failed"}, {"payload_too_large", ErrPayloadTooLarge, "payload_too_large"}, {"converter_unavailable", ErrConverterUnavailable, "converter_unavailable"}, - {"faa_unavailable", ErrFAAUnavailable, "faa_unavailable"}, + // ErrFAAUnavailable:v0.6 T3 後改由 converter promote 502 file_gateway_unavailable 透傳 + // (visionA 端不再直接打 FAA、但 converter→FAA push 仍可能失敗;對外 code 仍 faa_unavailable) + {"faa_unavailable_from_converter_promote", ErrFAAUnavailable, "faa_unavailable"}, {"service_busy", ErrServiceBusy, "service_busy"}, // Phase 0.8b T3:以下 sentinel 已移除,不再對外暴露對應 error code // ErrDownloadTokenFailed / ErrMCTokenUnavailable / ErrIDPMisconfigured / // ErrIDPUnavailable / ErrServiceClientUnauthorized - // 取代:401/403 改 ErrConverterAuthFailed / ErrFAAAuthFailed(下方 wrapped) + // 取代:401/403 改 ErrConverterAuthFailed(下方 wrapped) + // Phase 0.8b v0.6 T3:ErrFAAAuthFailed / ErrFAAFileNotFound 已砍(visionA 端不再 + // 直接打 FAA;ADR-016 撤回 FAA 直連設計、faa_client.go 整檔刪除) {"converter_auth_failed_masked_as_converter_unavailable", ErrConverterAuthFailed, "converter_unavailable"}, - {"faa_auth_failed_masked_as_faa_unavailable", ErrFAAAuthFailed, "faa_unavailable"}, // Reviewer M-1:visionA 自身基礎設施失敗用獨立 code(與 FAA / converter 區分) {"storage_unavailable", ErrStorageUnavailable, "storage_unavailable"}, {"model_store_unavailable", ErrModelStoreUnavailable, "model_store_unavailable"}, @@ -67,14 +70,15 @@ func TestHTTPStatus(t *testing.T) { {"validation_400", ErrValidationFailed, 400}, {"payload_too_large_413", ErrPayloadTooLarge, 413}, {"converter_unavailable_502", ErrConverterUnavailable, 502}, - {"faa_unavailable_502", ErrFAAUnavailable, 502}, + // ErrFAAUnavailable:v0.6 T3 後改由 converter promote 502 file_gateway_unavailable 透傳 + {"faa_unavailable_502_from_converter_promote", ErrFAAUnavailable, 502}, {"service_busy_503", ErrServiceBusy, 503}, // Phase 0.8b T3:以下 sentinel 已移除,對外不再 mapping HTTP status // ErrDownloadTokenFailed / ErrMCTokenUnavailable / ErrIDPMisconfigured / // ErrIDPUnavailable / ErrServiceClientUnauthorized - // 取代:401/403 改 ErrConverterAuthFailed / ErrFAAAuthFailed (HTTP 502) + // 取代:401/403 改 ErrConverterAuthFailed (HTTP 502) + // Phase 0.8b v0.6 T3:ErrFAAAuthFailed 已砍(visionA 端不再直接打 FAA) {"converter_auth_failed_502", ErrConverterAuthFailed, 502}, - {"faa_auth_failed_502", ErrFAAAuthFailed, 502}, // Reviewer M-1:visionA 自身基礎設施失敗 → 500(不是 502 gateway) {"storage_unavailable_500", ErrStorageUnavailable, 500}, {"model_store_unavailable_500", ErrModelStoreUnavailable, 500}, diff --git a/visionA-backend/internal/conversion/faa_client.go b/visionA-backend/internal/conversion/faa_client.go deleted file mode 100644 index 149f142..0000000 --- a/visionA-backend/internal/conversion/faa_client.go +++ /dev/null @@ -1,478 +0,0 @@ -// FAA client — visionA-backend 對 File Access Agent 的 server-to-server HTTP client。 -// -// Phase 0.8 只用 GET /files/{object_key}(給 promote-to-models 流程從 FAA pull NEF 用)。 -// 其他 endpoint(PUT / DELETE / HEAD / metadata)目前 visionA 不需要,未來再補。 -// -// 設計要點: -// - **Phase 0.8b 認證**:直接帶 `Authorization: Bearer `(pre-shared -// API key),不再透過 MC OAuth client_credentials grant、不再依賴 MCTokenClient.ServiceToken()。 -// 詳見 ADR-015 §3 + conversion.md §3。 -// - **回 streaming body**(io.ReadCloser)— 不 io.ReadAll,避免 500MB NEF 全進 RAM -// - **Phase A retry**:dial → 拿到 response header 之間的 5xx / network / timeout 失敗 -// 依 §9.1 指數退避重試 max 2 次(1s, 2s)。一旦拿到 200 response(進 Phase B: -// streaming body 給 caller),這層責任就結束 — body 中斷由 caller 處理(不可 replay)。 -// 詳見下方 GetFile doc comment 的「Phase A vs Phase B retry」段。 -// - 4xx → 對應 sentinel(401/403 → ErrFAAAuthFailed;404 → ErrFAAFileNotFound; -// 其他 4xx → ErrFAAUnavailable,避免新增更多 sentinel) -// -// 與 InitJob 的對比(為什麼 InitJob 不 retry 但 GetFile retry): -// - InitJob:multipart **request body** 是 streaming(io.Reader 來自上游 c.Body); -// 一旦 http.Client.Do 開始送 request body,io.Reader 已被消費,retry 無法 rewind → -// 從第一次 attempt 起就「不可重試」。 -// - GetFile:GET 沒有 request body,request 完全 idempotent;retry window 涵蓋 -// dial → 拿到 response header(Phase A)。Phase A 結束後(200 已到),response body -// 才是「不可 replay」的 streaming,但那不在本層責任範圍 — 本層拿到 200 就 return *FAAFile。 -// -// 安全: -// - **絕不**寫 Authorization header / API key / response body 進 log -// - object_key 過長時截斷(避免 log 膨脹;FAA object_key 由 visionA 內部組,不含 user 敏感資訊) -// -// Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §2.3 / §2.6 / §9.1) -// Phase 0.8b API key 改造 (見 ADR-015 §3 + §6 + conversion.md §3) -package conversion - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "errors" - "fmt" - "io" - "log/slog" - "net" - "net/http" - "net/url" - "strings" - "time" -) - -// ========================================================================== -// 對外 type / interface -// ========================================================================== - -// FAAClient 對 File Access Agent 的 server-to-server client。 -// -// goroutine-safe:每次呼叫獨立 *http.Request;無內部 mutable state(apiKey 為 immutable 字串)。 -type FAAClient interface { - // GetFile 從 FAA pull 一個 object(server-to-server,Phase 0.8b 用 pre-shared API key)。 - // - // 回傳 *FAAFile.Body 是 streaming body(io.ReadCloser);**caller 必須 Close**, - // 不然底層 http.Response.Body 不會釋放、connection 也回不了 pool(goroutine + fd leak)。 - // 推薦 pattern: - // - // file, err := faa.GetFile(ctx, key) - // if err != nil { return err } - // defer file.Body.Close() - // _, err = io.Copy(dst, file.Body) // streaming 寫進 visionA storage - // - // 重試行為(Phase A retry only,對齊 §9.1): - // - dial / TLS / response header 階段的 5xx / network / timeout: - // 指數退避重試 max 2 次(1s, 2s)— GET 沒 request body 完全 idempotent,可放心 retry - // - 401 / 403 / 404 / 其他 4xx:不重試,立即 return 對應 sentinel - // - ctx cancel / deadline:立即 return ctx.Err()(即使在 retry sleep 中也立即中斷) - // - 一旦拿到 200 response(進 Phase B):return *FAAFile,body 由 caller 自己讀; - // caller 在讀 body 時遇到網路中斷不再重試(streaming response 不可 replay) - // - // 錯誤映射(對齊 conversion.md §6 + errors.go): - // - ctx cancel/deadline → 透傳 ctx.Err(不包成 sentinel) - // - 401 / 403 → ErrFAAAuthFailed(Phase 0.8b 新 sentinel;對外 mask 成 faa_unavailable/502) - // - 404 → ErrFAAFileNotFound(對外 faa_unavailable/502) - // - 其他 4xx / 5xx exhausted / network exhausted → ErrFAAUnavailable(對外 faa_unavailable/502) - GetFile(ctx context.Context, objectKey string) (*FAAFile, error) -} - -// FAAFile 是 GetFile 成功回傳的 streaming response。 -// -// **caller 必須 Body.Close()**(即使中途 error,也應 defer Close)。 -type FAAFile struct { - // Body 是 streaming response body;caller 用 io.Copy 等方式 streaming 消費。 - Body io.ReadCloser - - // ContentLength 對應 FAA response 的 Content-Length header。 - // 若 FAA 走 chunked transfer 沒帶這個 header,值為 -1(net/http 慣例)。 - ContentLength int64 - - // ContentType 對應 FAA response 的 Content-Type header(如 "application/octet-stream")。 - ContentType string - - // ETag 對應 FAA response 的 ETag header(FAA 端取自 storage adapter)。 - // 若 FAA 沒帶,為空字串。 - ETag string -} - -// FAAClientOpts 是 NewFAAClient 的依賴注入。 -// -// HTTPClient / Now / Logger 為 optional(nil 自動填預設)— 方便 unit test 注入 fake。 -type FAAClientOpts struct { - // BaseURL 是 FAA base URL(不帶結尾斜線)。 - // 範例:http://192.168.0.130:5081 - BaseURL string - - // APIKey 是 Phase 0.8b 引入的 pre-shared API key(VISIONA_FAA_API_KEY)。 - // 必填非空 — `NewFAAClient` 會在 APIKey 為空時 panic(fail-fast, - // 避免 server 在「未認證」狀態下啟動)。 - // - // 值由 main.go 從 cfg.Conversion.FAAAPIKey(env VISIONA_FAA_API_KEY)注入; - // 與 FAA middleware 端的 FAA_API_KEY 必須對齊(rotate 時雙方同步換;FAA 端由 warrenchen 維護)。 - // - // 安全:絕不 log 此值(即使前綴);Authorization header 也不 log。 - // - // Phase 0.8b API key 改造 (見 ADR-015 §3 + conversion.md §3) - APIKey string - - // HTTPClient 為 optional;nil 用預設(含 dial / response header timeout,但無整體 timeout)。 - // 測試會注入 httptest.Server.Client()。 - // - // 為什麼預設 client 不設 Timeout: - // 500MB NEF 在慢網路下 download 可能 5-10 分鐘;http.Client.Timeout 是「整體 timeout」 - // 涵蓋「dial + response header + body 讀完」三段,會在大檔下載中途斷線。 - // 改用 transport 層的 DialTimeout + ResponseHeaderTimeout(10s 各自)— 連線階段卡死才算 fail, - // body streaming 階段交給 ctx.Done() 控制(caller 用帶 deadline 的 ctx 即可)。 - HTTPClient *http.Client - - // Now 為 optional;nil 用 time.Now。測試會注入 fake clock。 - Now func() time.Time - - // Logger 為 optional;nil 用 slog.Default()。 - Logger *slog.Logger -} - -// ========================================================================== -// 內部固定常數 -// ========================================================================== - -const ( - // faaDialTimeout 是 dial 階段的 timeout(連 TCP / TLS 握手)。 - // 連線一直建不起來通常是路由問題,10s 已足夠;超過視為 FAA 不可達。 - faaDialTimeout = 10 * time.Second - - // faaResponseHeaderTimeout 是「送完 request → 收到 response status 行」的 timeout。 - // 這段是 server-side 處理時間(FAA 找檔、auth validate);10s 對小檔 metadata 階段夠寬鬆。 - // 注意:這個 timeout **不涵蓋 body streaming 階段**(body streaming 由 ctx 控制)。 - faaResponseHeaderTimeout = 10 * time.Second - - // faaMaxRetries 是 Phase A 5xx / network / timeout 的最大重試次數(不含第一次)。 - // 對齊 conversion.md §9.1:FAA GET /files/{key} max 2 retries(1s, 2s)。 - faaMaxRetries = 2 - - // faaRetryBaseDelay 是指數退避的 base(1s, 2s)。 - faaRetryBaseDelay = 1 * time.Second - - // objectKeyHashLen 是 log 中 object_key 的截短後 hash 長度(前 16 hex chars)。 - objectKeyHashLen = 16 - - // faaErrorBodyReadCap 是失敗 response 從 body 讀進 io.Discard 的最大量(4KB)。 - // 失敗時讀少量 body 主要是讓 keep-alive 能 reuse connection,避免空 body 留在 pipe。 - faaErrorBodyReadCap = 4 * 1024 -) - -// faaEndpointKind 是 log / 錯誤分類用的 endpoint 標記(目前只有一個)。 -const faaEndpointKind = "faa_get_file" - -// ErrFAAAPIKeyNotConfigured 啟動時 API key 為空 — 應在 NewFAAClient 立即 panic、 -// 不要等到第一個 request 才發現「未認證」狀態跑進 prod。 -// -// Phase 0.8b API key 改造 (見 ADR-015 §3.5.3 部署檢查清單 #1) -var ErrFAAAPIKeyNotConfigured = errors.New("conversion/faa_client: APIKey is required (set VISIONA_FAA_API_KEY)") - -// ========================================================================== -// 構造 + 內部實作 -// ========================================================================== - -// faaClient 是 FAAClient 的預設實作。 -// -// 套件內 unexported struct(caller 拿 interface),讓未來換實作不影響 caller。 -type faaClient struct { - baseURL string - apiKey string // Phase 0.8b:pre-shared API key,建構時 fail-fast 不允許空字串 - http *http.Client - now func() time.Time - logger *slog.Logger -} - -// NewFAAClient 建立一個 FAAClient 實例。 -// -// 必填:BaseURL / APIKey。其他 optional。 -// 注意:constructor 不會驗 BaseURL 連線,第一次 GetFile 才會打網路。 -// -// **Fail-fast**:若 opts.APIKey 為空字串,此函式 panic。理由是 Phase 0.8b 不允許 server 在 -// 「未認證」狀態下啟動 — 對齊 ADR-015 §3.5.3 部署檢查清單 #1。 -// -// `opts.Tokens` 是 Phase 0.8 廢棄欄位(見 FAAClientOpts.Tokens 註解),即使非 nil 也不被 -// 內部使用;T5 切換 wire 點後從 struct 移除。 -func NewFAAClient(opts FAAClientOpts) FAAClient { - if opts.APIKey == "" { - panic(ErrFAAAPIKeyNotConfigured) - } - httpClient := opts.HTTPClient - if httpClient == nil { - httpClient = newDefaultFAAHTTPClient() - } - now := opts.Now - if now == nil { - now = time.Now - } - logger := opts.Logger - if logger == nil { - logger = slog.Default() - } - return &faaClient{ - baseURL: strings.TrimRight(opts.BaseURL, "/"), - apiKey: opts.APIKey, - http: httpClient, - now: now, - logger: logger, - } -} - -// newDefaultFAAHTTPClient 建一個適合 streaming download 的預設 http.Client。 -// -// 為什麼自訂 transport: -// - http.Client.Timeout 不適用大檔下載(會中斷 body streaming) -// - 需要分別控制 dial / response header timeout,body streaming 不限制(由 ctx 控) -// -// transport 其餘參數沿用 net/http DefaultTransport 的合理預設(MaxIdleConns 等)。 -func newDefaultFAAHTTPClient() *http.Client { - transport := &http.Transport{ - DialContext: (&net.Dialer{ - Timeout: faaDialTimeout, - KeepAlive: 30 * time.Second, - }).DialContext, - ResponseHeaderTimeout: faaResponseHeaderTimeout, - // 沿用 DefaultTransport 的合理預設 - MaxIdleConns: 100, - MaxIdleConnsPerHost: 10, - IdleConnTimeout: 90 * time.Second, - TLSHandshakeTimeout: 10 * time.Second, - ExpectContinueTimeout: 1 * time.Second, - } - return &http.Client{ - Transport: transport, - // **不設 Timeout** — body streaming 階段由 ctx 控制 - } -} - -// ========================================================================== -// GetFile — Phase A retry,Phase B 不 retry 的 streaming pull -// ========================================================================== - -// GetFile 實作 FAAClient.GetFile。 -// -// 流程(Phase 0.8b): -// 1. 組 URL + 建 request(直接帶 c.apiKey 進 Authorization header;不再透過 MCTokenClient) -// 2. doWithRetry:max (1 + faaMaxRetries) attempts;每 attempt 重新 c.http.Do -// - 拿到 200:直接 return *FAAFile(不 close body) -// - 拿到 4xx:close body 後依 status mapping 對應 sentinel,不 retry -// - 拿到 5xx:close body,等 backoff 後 retry -// - network / dial / responseHeader timeout:等 backoff 後 retry -// - ctx cancel / deadline:立即 return ctx.Err() -func (c *faaClient) GetFile(ctx context.Context, objectKey string) (*FAAFile, error) { - if objectKey == "" { - return nil, fmt.Errorf("conversion/faa_client: object_key is required") - } - - keyHash := hashObjectKey(objectKey) - - // 1. 組 endpoint。注意 FAA 的 object_key 可能含路徑分隔符(如 "tenant/jobs/abc/output.nef")— - // 用 ResolveReference 處理;net/http 內部會做 path escape,避免 "../" 等問題。 - endpoint, err := c.buildFileURL(objectKey) - if err != nil { - return nil, fmt.Errorf("%w: build faa url: %v", ErrFAAUnavailable, err) - } - - // 2. 進 retry loop(Phase A only);apiKey 在 doWithRetry 內 set header - return c.doWithRetry(ctx, keyHash, endpoint) -} - -// doWithRetry 是 GetFile 的 Phase A retry 執行器。 -// -// Phase 0.8b 變更: -// - 不再接收 token 參數(API key 改造後 c.apiKey 直接 set header) -// -// 與 converter_client.doWithRetry 結構類似,差異: -// - 成功路徑回傳 *FAAFile(含未 close 的 streaming body),不是 []byte -// - 沒有「每次 attempt 重新建 request」需求 — GET 沒 body,request 物件可重用, -// 但為了讓 ctx-aware 行為一致(ctx cancel 後不重用舊 request),這裡每次都新建一個 -func (c *faaClient) doWithRetry( - ctx context.Context, - keyHash, endpoint string, -) (*FAAFile, error) { - var lastErr error - for attempt := 0; attempt <= faaMaxRetries; attempt++ { - // retry 前等待退避;ctx cancel 立即中斷 - if attempt > 0 { - select { - case <-ctx.Done(): - // ctx cancel/deadline → 立即 return(不 retry,不包成 sentinel) - return nil, ctx.Err() - case <-time.After(faaRetryBackoff(attempt)): - } - } - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) - if err != nil { - // 建 request 失敗(極罕見:URL parse 異常)— 不可 retry - return nil, fmt.Errorf("%w: build faa request: %v", ErrFAAUnavailable, err) - } - req.Header.Set("Accept", "application/octet-stream") - // Phase 0.8b:直接帶 pre-shared API key(不查 cache、不打 MC) - req.Header.Set("Authorization", "Bearer "+c.apiKey) - - file, classifiedErr, retryable := c.doOnce(req, keyHash, attempt) - if classifiedErr == nil { - // 成功 — file 含未 close 的 body,由 caller 接手 - return file, nil - } - lastErr = classifiedErr - if !retryable { - // 4xx / 401-403 / 404 / ctx cancel:直接 return,不再 retry - return nil, classifiedErr - } - // retryable 5xx / network / timeout:繼續下一輪 - } - // 用完 retry 額度 - c.logger.Warn("conversion.faa.retry_exhausted", - slog.String("endpoint", faaEndpointKind), - slog.String("object_key_hash", keyHash), - slog.Int("attempts", faaMaxRetries+1)) - return nil, lastErr -} - -// doOnce 執行一次 Phase A:發 request → 等 response header → 分類結果。 -// -// 回傳: -// - 成功(2xx):file != nil(含未 close 的 streaming body), classifiedErr=nil, retryable=false -// - 失敗:file=nil, classifiedErr 為 sentinel-wrapped error, retryable 表示是否該重試 -// -// 重要:成功時 caller(doWithRetry)會直接把 file 透傳出去 — 這層**不 close body**。 -// 失敗時這層**會 close body**(讀少量讓 keep-alive reuse connection)。 -func (c *faaClient) doOnce( - req *http.Request, - keyHash string, - attempt int, -) (file *FAAFile, err error, retryable bool) { - startedAt := c.now() - res, doErr := c.http.Do(req) - duration := c.now().Sub(startedAt) - if doErr != nil { - // network / dial / response header timeout / ctx cancel - if errors.Is(doErr, context.Canceled) || errors.Is(doErr, context.DeadlineExceeded) { - c.logger.Warn("conversion.faa.ctx_cancelled", - slog.String("endpoint", faaEndpointKind), - slog.String("object_key_hash", keyHash), - slog.Int("attempt", attempt+1), - slog.Duration("duration", duration)) - return nil, doErr, false - } - c.logger.Warn("conversion.faa.network_error", - slog.String("endpoint", faaEndpointKind), - slog.String("object_key_hash", keyHash), - slog.Int("attempt", attempt+1), - slog.Duration("duration", duration), - // err.Error() 不會含 secret(http.Client 錯誤訊息只有 URL + 連線層 errno), - // 但仍 truncate 防 log 爆量 - slog.String("err", truncate(doErr.Error(), 200))) - return nil, fmt.Errorf("%w: faa network error: %v", ErrFAAUnavailable, doErr), true - } - - // 成功(2xx):直接把 res.Body 透傳給 caller streaming 消費 — **不在這裡 close**! - // 注意:成功路徑沒 defer res.Body.Close() — body 的所有權交給 *FAAFile.Body。 - if res.StatusCode >= 200 && res.StatusCode < 300 { - c.logger.Info("conversion.faa.get_success", - slog.String("endpoint", faaEndpointKind), - slog.String("object_key_hash", keyHash), - slog.Int("status", res.StatusCode), - slog.Int("attempt", attempt+1), - slog.Int64("content_length", res.ContentLength), - slog.Duration("duration", duration)) - return &FAAFile{ - Body: res.Body, // caller 責任 Close - ContentLength: res.ContentLength, - ContentType: res.Header.Get("Content-Type"), - ETag: res.Header.Get("ETag"), - }, nil, false - } - - // 失敗(非 2xx):讀少量 body 做 log(避免 5xx 帶大 body 爆 log),然後 close - // 讀進 io.Discard 而不是真的存下來: - // - 不寫進 log(FAA 錯誤 body 可能含 requestId / 路徑等內部資訊) - // - 只是讓 keep-alive 能 reuse connection(read-to-EOF or close) - defer res.Body.Close() - _, _ = io.CopyN(io.Discard, res.Body, faaErrorBodyReadCap) - - c.logger.Warn("conversion.faa.endpoint_error", - slog.String("endpoint", faaEndpointKind), - slog.String("object_key_hash", keyHash), - slog.Int("status", res.StatusCode), - slog.Int("attempt", attempt+1), - slog.Duration("duration", duration)) - - mappedErr, isRetryable := c.mapGetFileError(res.StatusCode) - return nil, mappedErr, isRetryable -} - -// mapGetFileError 把 FAA `GET /files/{key}` 的非 2xx 對應到 sentinel + 是否 retryable。 -// -// Phase 0.8b 對齊 ADR-015 §3.5.2 FAA middleware: -// - 401 unauthorized → ErrFAAAuthFailed(不 retry — API key 不對齊;運維事件) -// - 403 forbidden → ErrFAAAuthFailed(不 retry) -// -// 其他 mapping(不變): -// - 404 file_not_found → ErrFAAFileNotFound(不 retry — object 不存在) -// - 400 invalid_object_key → ErrFAAUnavailable(不 retry — visionA 端 object_key 命名 bug) -// - 其他 4xx → ErrFAAUnavailable(不 retry) -// - 5xx → ErrFAAUnavailable(**可 retry**:FAA / 下游 storage 暫時失常) -func (c *faaClient) mapGetFileError(status int) (err error, retryable bool) { - switch { - case status == http.StatusUnauthorized || status == http.StatusForbidden: - return fmt.Errorf("%w: faa get file %d", ErrFAAAuthFailed, status), false - case status == http.StatusNotFound: - return fmt.Errorf("%w: faa get file %d", ErrFAAFileNotFound, status), false - case status >= 400 && status < 500: - // 400 / 其他 4xx:不可 retry - return fmt.Errorf("%w: faa get file %d", ErrFAAUnavailable, status), false - default: - // 5xx:可 retry - return fmt.Errorf("%w: faa get file %d", ErrFAAUnavailable, status), true - } -} - -// faaRetryBackoff 回傳第 n 次 retry(n 從 1 開始)的等待時間。 -// 1 → 1s, 2 → 2s(對齊 conversion.md §9.1) -// -// 不加 jitter — Phase 0.8 同時打 FAA 的 caller 數量有限(promote-to-models 流程是 -// 序列式 per-job 觸發),併發競爭機率低;jitter 的邊際效益低。 -func faaRetryBackoff(attempt int) time.Duration { - if attempt < 1 { - return faaRetryBaseDelay - } - return faaRetryBaseDelay * time.Duration(attempt) -} - -// buildFileURL 用 url.Parse + ResolveReference 組 GET /files/{objectKey} 的完整 URL。 -// -// 為什麼用 ResolveReference 而不是 string concat: -// - object_key 可能含路徑分隔符("tenant/jobs/abc/output.nef") -// - 直接 concat 容易踩 trailing-slash / encoding 雷 -// - net/url 會做必要的 percent-escape(保留 '/' 為 path separator) -func (c *faaClient) buildFileURL(objectKey string) (string, error) { - base, err := url.Parse(c.baseURL) - if err != nil { - return "", fmt.Errorf("parse base url: %w", err) - } - // 用 url.URL{Path: ...} 避免手動 escape;net/url 會處理 path encoding。 - // 注意:base.Path 可能為空或結尾帶 "/",ResolveReference 會處理。 - ref := &url.URL{Path: "/files/" + objectKey} - return base.ResolveReference(ref).String(), nil -} - -// hashObjectKey 把 object_key 算 SHA-256 後取前 16 hex chars,當 log 用的穩定 hash。 -// -// 為什麼不直接 log object_key: -// - object_key 可能含路徑("tenant/jobs/uuid/output.nef")— 過長 -// - 目前 visionA 的 object_key 不直接含 user 敏感資訊,但保險起見統一 hash -// - 16 chars hex(64-bit)對 visionA 內部 job 數量來說碰撞機率極低,足以追蹤單一 request -func hashObjectKey(objectKey string) string { - sum := sha256.Sum256([]byte(objectKey)) - return hex.EncodeToString(sum[:])[:objectKeyHashLen] -} diff --git a/visionA-backend/internal/conversion/faa_client_test.go b/visionA-backend/internal/conversion/faa_client_test.go deleted file mode 100644 index 9aba2f2..0000000 --- a/visionA-backend/internal/conversion/faa_client_test.go +++ /dev/null @@ -1,606 +0,0 @@ -// FAA Client 單元測試。 -// -// 測試策略: -// - 用 httptest.Server mock FAA 的 GET /files/{key} 端點 -// - **Phase 0.8b**:直接用 string fake API key(fakeFAAAPIKey;定義在 converter_client_test.go), -// 不再注入 stub MCTokenClient -// - 用 atomic counter 驗 retry 行為(Phase A retry:max 3 attempts = 1 + 2 retries) -// - streaming 驗證用較大但合理大小(10MB)— 真 100MB 會拖慢 test runner 太多 -// -// 測試範疇對應 conversion.md §9.1(FAA GET /files retry max 2 次, 1s/2s)+ ADR-015 §3 認證: -// - GetFile_Success / GetFile_Streaming / GetFile_AuthHeader -// - GetFile_404_NoRetry / GetFile_AuthFailed401 / GetFile_AuthFailed403 -// - GetFile_5xx_RetryThenSuccess / GetFile_5xx_Exhausted -// - GetFile_Network_RetryThenSuccess / GetFile_Network_Exhausted -// - GetFile_ContextCancel / GetFile_ContextCancel_DuringRetry -// - GetFile_EmptyObjectKey / GetFile_400_GenericError / HashObjectKey_StableAndLength -// - NewFAAClient_Panics_When_APIKey_Empty -// -// Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §2.3 + §9.1) -// Phase 0.8b API key 改造 (見 ADR-015 §3 + §6 + conversion.md §3) -package conversion - -import ( - "context" - "errors" - "io" - "net" - "net/http" - "net/http/httptest" - "sync/atomic" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// ========================================================================== -// FAA mock server helpers -// ========================================================================== - -// newFAAClientForTest 建立指向 mock server 的 FAAClient。 -// -// Phase 0.8b:直接傳 fakeFAAAPIKey(定義在 converter_client_test.go),不再透過 MCTokenClient。 -// 用較短 timeout 加速 test。注意 streaming test 不能用整體 Timeout,所以另外覆寫。 -func newFAAClientForTest(t *testing.T, baseURL string) FAAClient { - t.Helper() - return NewFAAClient(FAAClientOpts{ - BaseURL: baseURL, - APIKey: fakeFAAAPIKey, - HTTPClient: &http.Client{Timeout: 5 * time.Second}, - Logger: silentLogger(), - }) -} - -// ========================================================================== -// 成功路徑 -// ========================================================================== - -// TestGetFile_Success:mock 回 200 + binary stream,驗 ContentLength / ETag / ContentType 解析。 -func TestGetFile_Success(t *testing.T) { - t.Parallel() - - payload := []byte("binary payload here") - var receivedAuth string - - mux := http.NewServeMux() - mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) { - receivedAuth = r.Header.Get("Authorization") - require.Equal(t, http.MethodGet, r.Method) - w.Header().Set("Content-Type", "application/octet-stream") - w.Header().Set("ETag", "\"etag-abc-123\"") - w.Header().Set("Content-Length", "19") - w.WriteHeader(http.StatusOK) - _, _ = w.Write(payload) - }) - srv := httptest.NewServer(mux) - t.Cleanup(srv.Close) - - fc := newFAAClientForTest(t, srv.URL) - file, err := fc.GetFile(context.Background(), "tenant/jobs/abc/output.nef") - - require.NoError(t, err) - require.NotNil(t, file) - require.NotNil(t, file.Body) - t.Cleanup(func() { _ = file.Body.Close() }) - - assert.Equal(t, "application/octet-stream", file.ContentType) - assert.Equal(t, "\"etag-abc-123\"", file.ETag) - assert.Equal(t, int64(19), file.ContentLength) - - // caller 確實能 streaming 讀到完整 body - body, readErr := io.ReadAll(file.Body) - require.NoError(t, readErr) - assert.Equal(t, payload, body) - - assert.Equal(t, "Bearer "+fakeFAAAPIKey, receivedAuth, - "Phase 0.8b:必須直接帶 pre-shared API key(不經 MC token cache)") -} - -// TestGetFile_Streaming:mock 回 10MB body,confirm caller 能 streaming 讀(不 buffer 全 RAM)。 -// -// 與 InitJob streaming test 對稱:用 io.LimitReader + zerosReader,確認 reader 被多次 Read -// (而非一次性全讀)。但 net/http 端 download 的 streaming 由 res.Body 提供,這裡的關鍵是: -// - faa_client 必須**不 io.ReadAll** 把 body 提前讀完 -// - caller 用 io.Copy 慢慢讀時,server 端不需要先把全部 buffer 完成 -func TestGetFile_Streaming(t *testing.T) { - t.Parallel() - - const totalSize = int64(10 * 1024 * 1024) // 10MB - - mux := http.NewServeMux() - mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/octet-stream") - w.Header().Set("Content-Length", "10485760") - w.WriteHeader(http.StatusOK) - // streaming write — 用 io.Copy from zerosReader(避免一次配 10MB buffer) - _, _ = io.CopyN(w, zerosReader{}, totalSize) - }) - srv := httptest.NewServer(mux) - t.Cleanup(srv.Close) - - // streaming download 不能用 http.Client.Timeout(會中斷 body streaming) - fc := NewFAAClient(FAAClientOpts{ - BaseURL: srv.URL, - APIKey: fakeFAAAPIKey, - // 這裡用無 timeout 的 client(test 自己控) - HTTPClient: &http.Client{}, - Logger: silentLogger(), - }) - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - file, err := fc.GetFile(ctx, "big.nef") - - require.NoError(t, err) - require.NotNil(t, file) - t.Cleanup(func() { _ = file.Body.Close() }) - - assert.Equal(t, totalSize, file.ContentLength) - - // 用 countingReader 包 file.Body — 但 countingReader 是 io.Reader, - // 這裡換成 wrap 一下:直接 io.Copy 到 io.Discard,confirm 全 download 完成。 - written, copyErr := io.Copy(io.Discard, file.Body) - require.NoError(t, copyErr) - assert.Equal(t, totalSize, written, "streaming download 必須拿到完整 body") -} - -// TestGetFile_AuthHeader:Phase 0.8b — 驗 pre-shared API key 直接帶在 Authorization header。 -// -// 用客製 APIKey(與 fakeFAAAPIKey 不同的字串),確認 client 真的透傳「建構時拿到的 key」、 -// 而不是 hardcode 某個常數。 -func TestGetFile_AuthHeader(t *testing.T) { - t.Parallel() - - const customKey = "custom-faa-key-do-not-use-in-prod-ccccccccccccccccccccccccccccc" - - var receivedAuth string - var receivedAccept string - - mux := http.NewServeMux() - mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) { - receivedAuth = r.Header.Get("Authorization") - receivedAccept = r.Header.Get("Accept") - w.Header().Set("Content-Type", "application/octet-stream") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte("ok")) - }) - srv := httptest.NewServer(mux) - t.Cleanup(srv.Close) - - fc := NewFAAClient(FAAClientOpts{ - BaseURL: srv.URL, - APIKey: customKey, - HTTPClient: &http.Client{Timeout: 5 * time.Second}, - Logger: silentLogger(), - }) - file, err := fc.GetFile(context.Background(), "key") - require.NoError(t, err) - defer file.Body.Close() - _, _ = io.ReadAll(file.Body) - - assert.Equal(t, "Bearer "+customKey, receivedAuth, - "必須透傳建構時拿到的 API key,不可 hardcode 或從別處取") - assert.Equal(t, "application/octet-stream", receivedAccept) -} - -// ========================================================================== -// 失敗映射(不 retry 類) -// ========================================================================== - -// TestGetFile_404_NoRetry:mock 回 404 → 立即 return ErrFAAFileNotFound,不 retry。 -func TestGetFile_404_NoRetry(t *testing.T) { - t.Parallel() - - var attempts atomic.Int32 - mux := http.NewServeMux() - mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) { - attempts.Add(1) - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"error":{"code":"file_not_found","message":"File not found."}}`)) - }) - srv := httptest.NewServer(mux) - t.Cleanup(srv.Close) - - fc := newFAAClientForTest(t, srv.URL) - file, err := fc.GetFile(context.Background(), "missing.nef") - - require.Error(t, err) - require.Nil(t, file, "失敗時不應回 FAAFile(避免 caller 誤用 nil body)") - assert.True(t, errors.Is(err, ErrFAAFileNotFound), - "404 → ErrFAAFileNotFound(caller 可精細處理)") - assert.Equal(t, int32(1), attempts.Load(), - "404 不應 retry(object 不存在 retry 也沒用)") - // 對外仍應 mask 成 faa_unavailable(避免揭露 object_key 不存在) - assert.Equal(t, "faa_unavailable", ErrorCode(err)) - assert.Equal(t, 502, HTTPStatus(err)) -} - -// TestGetFile_AuthFailed401:Phase 0.8b — mock 回 401 → 不 retry,return ErrFAAAuthFailed。 -// -// 觸發情境:VISIONA_FAA_API_KEY 與 FAA 端 FAA_API_KEY 不對齊(rotate 未同步 / env 設錯)。 -// 對外仍 mask 成 faa_unavailable / 502,避免洩漏「API key 不對」內部運維狀態。 -func TestGetFile_AuthFailed401(t *testing.T) { - t.Parallel() - - var attempts atomic.Int32 - mux := http.NewServeMux() - mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) { - attempts.Add(1) - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusUnauthorized) - _, _ = w.Write([]byte(`{"error":"unauthorized"}`)) - }) - srv := httptest.NewServer(mux) - t.Cleanup(srv.Close) - - fc := newFAAClientForTest(t, srv.URL) - file, err := fc.GetFile(context.Background(), "k") - - require.Error(t, err) - require.Nil(t, file) - assert.True(t, errors.Is(err, ErrFAAAuthFailed), - "Phase 0.8b:401 必須 mapping 到新 sentinel ErrFAAAuthFailed") - // Phase 0.8b T3:舊 sentinel ErrServiceClientUnauthorized 已移除, - // 改由 ErrFAAAuthFailed 接管 401/403 mapping。 - assert.Equal(t, int32(1), attempts.Load(), - "401 不應 retry(API key 不對 retry 也是 401)") - // 對外仍 mask 成 faa_unavailable - assert.Equal(t, "faa_unavailable", ErrorCode(err)) - assert.Equal(t, 502, HTTPStatus(err)) -} - -// TestGetFile_AuthFailed403:對稱 — FAA 端 403 同樣 ErrFAAAuthFailed、不 retry。 -func TestGetFile_AuthFailed403(t *testing.T) { - t.Parallel() - - var attempts atomic.Int32 - mux := http.NewServeMux() - mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) { - attempts.Add(1) - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusForbidden) - _, _ = w.Write([]byte(`{"error":"unauthorized"}`)) - }) - srv := httptest.NewServer(mux) - t.Cleanup(srv.Close) - - fc := newFAAClientForTest(t, srv.URL) - _, err := fc.GetFile(context.Background(), "k") - - require.Error(t, err) - assert.True(t, errors.Is(err, ErrFAAAuthFailed)) - assert.Equal(t, int32(1), attempts.Load(), "403 不應 retry") -} - -// TestGetFile_400_GenericError:FAA 400(如 invalid_object_key)→ ErrFAAUnavailable,不 retry。 -func TestGetFile_400_GenericError(t *testing.T) { - t.Parallel() - - var attempts atomic.Int32 - mux := http.NewServeMux() - mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) { - attempts.Add(1) - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`{"error":{"code":"invalid_object_key"}}`)) - }) - srv := httptest.NewServer(mux) - t.Cleanup(srv.Close) - - fc := newFAAClientForTest(t, srv.URL) - _, err := fc.GetFile(context.Background(), "invalid//key") - - require.Error(t, err) - assert.True(t, errors.Is(err, ErrFAAUnavailable), - "400(非 401/403/404)→ ErrFAAUnavailable") - // 應該不會被 mis-classified 成 ErrFAAFileNotFound - assert.False(t, errors.Is(err, ErrFAAFileNotFound)) - assert.Equal(t, int32(1), attempts.Load(), "400 不應 retry(visionA 端的 bug)") -} - -// ========================================================================== -// Phase A retry 驗證(5xx / network) -// ========================================================================== - -// TestGetFile_5xx_RetryThenSuccess:mock 連續 500 兩次後回 200 → 共 3 次 attempt + 成功。 -// -// 對齊 §9.1:max 2 retries(1s, 2s)— 1 + 2 = 3 attempts;第 3 次成功就 return。 -// 注意:test 用真實 backoff(1s + 2s = 3s)— 為了驗 §9.1 退避時序,可接受。 -func TestGetFile_5xx_RetryThenSuccess(t *testing.T) { - t.Parallel() - - var attempts atomic.Int32 - payload := []byte("recovered after retry") - - mux := http.NewServeMux() - mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) { - n := attempts.Add(1) - if n < 3 { - w.WriteHeader(http.StatusInternalServerError) - _, _ = w.Write([]byte(`{"error":{"code":"internal_error"}}`)) - return - } - w.Header().Set("Content-Type", "application/octet-stream") - w.Header().Set("Content-Length", "21") - w.WriteHeader(http.StatusOK) - _, _ = w.Write(payload) - }) - srv := httptest.NewServer(mux) - t.Cleanup(srv.Close) - - fc := newFAAClientForTest(t, srv.URL) - - start := time.Now() - file, err := fc.GetFile(context.Background(), "k") - duration := time.Since(start) - - require.NoError(t, err) - require.NotNil(t, file) - t.Cleanup(func() { _ = file.Body.Close() }) - - got, _ := io.ReadAll(file.Body) - assert.Equal(t, payload, got, "第 3 次成功的 body 應正確透傳") - assert.Equal(t, int32(3), attempts.Load(), - "5xx 應 retry:max 2 retries → 3 attempts") - // 驗時序:兩次 retry 退避 1s + 2s,至少花 3s(容忍輕微誤差用 ≥2.5s) - assert.GreaterOrEqual(t, duration, 2500*time.Millisecond, - "§9.1 退避序列 1s + 2s 應至少耗 2.5s") -} - -// TestGetFile_5xx_Exhausted:mock 持續 500 → 用完 max retry 後 return ErrFAAUnavailable。 -func TestGetFile_5xx_Exhausted(t *testing.T) { - t.Parallel() - - var attempts atomic.Int32 - mux := http.NewServeMux() - mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) { - attempts.Add(1) - w.WriteHeader(http.StatusInternalServerError) - _, _ = w.Write([]byte(`{"error":{"code":"internal_error"}}`)) - }) - srv := httptest.NewServer(mux) - t.Cleanup(srv.Close) - - fc := newFAAClientForTest(t, srv.URL) - _, err := fc.GetFile(context.Background(), "k") - - require.Error(t, err) - assert.True(t, errors.Is(err, ErrFAAUnavailable), - "5xx exhausted → ErrFAAUnavailable") - assert.Equal(t, int32(faaMaxRetries+1), attempts.Load(), - "5xx 應跑滿 max retries:1 + 2 = 3 attempts") -} - -// TestGetFile_Network_RetryThenSuccess:前 2 次 connection refused,第 3 次成功。 -// -// 用 dynamic listener swap 實作:先用一個 free port 不開 listener(dial fail), -// 第 3 次 attempt 之前才 swap 到真的 mock server。實作上比較複雜 — 改用 -// proxy handler 在 mock server 內部對前 N 次「立刻 hijack 後 close」模擬 dial fail -// 不行(連線已建好);改用「server 端 force-close connection 不送任何 byte」 -// 來模擬 transport 層失敗。 -// -// 簡化版:用一個 proxy server,前 2 次直接 hijack + close 連線(client 看到 EOF), -// 第 3 次正常回 200。 -func TestGetFile_Network_RetryThenSuccess(t *testing.T) { - t.Parallel() - - var attempts atomic.Int32 - payload := []byte("recovered from net error") - - mux := http.NewServeMux() - mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) { - n := attempts.Add(1) - if n < 3 { - // hijack + close 模擬 connection 中斷(client 端會看到 unexpected EOF / read error) - hj, ok := w.(http.Hijacker) - if !ok { - t.Fatal("server does not support hijacking") - } - conn, _, err := hj.Hijack() - if err != nil { - t.Fatalf("hijack failed: %v", err) - } - _ = conn.Close() - return - } - w.Header().Set("Content-Type", "application/octet-stream") - w.WriteHeader(http.StatusOK) - _, _ = w.Write(payload) - }) - srv := httptest.NewServer(mux) - t.Cleanup(srv.Close) - - fc := newFAAClientForTest(t, srv.URL) - file, err := fc.GetFile(context.Background(), "k") - - require.NoError(t, err) - require.NotNil(t, file) - t.Cleanup(func() { _ = file.Body.Close() }) - - got, _ := io.ReadAll(file.Body) - assert.Equal(t, payload, got) - assert.Equal(t, int32(3), attempts.Load(), - "network error 應 retry:max 2 retries → 3 attempts 後成功") -} - -// TestGetFile_Network_Exhausted:dial 失敗持續發生 → 用完 max retry 後 ErrFAAUnavailable。 -// -// 用一個 listen 後立刻 close 的 socket 製造 connection refused(每次 attempt 都失敗)。 -func TestGetFile_Network_Exhausted(t *testing.T) { - t.Parallel() - - // 拿一個 free port 立刻關掉(dial 必失敗) - ln, err := net.Listen("tcp", "127.0.0.1:0") - require.NoError(t, err) - addr := ln.Addr().String() - require.NoError(t, ln.Close()) - - fc := NewFAAClient(FAAClientOpts{ - BaseURL: "http://" + addr, - APIKey: fakeFAAAPIKey, - // 用較短 timeout,但仍要大於 retry 退避總和(1s + 2s = 3s)— 設 10s 安全 - HTTPClient: &http.Client{Timeout: 10 * time.Second}, - Logger: silentLogger(), - }) - - start := time.Now() - _, err = fc.GetFile(context.Background(), "k") - duration := time.Since(start) - - require.Error(t, err) - assert.True(t, errors.Is(err, ErrFAAUnavailable), - "network exhausted → ErrFAAUnavailable") - // retry:1 + 2 retries = 3 attempts,2 次退避 = 1s + 2s = 3s 起跳 - assert.GreaterOrEqual(t, duration, 2500*time.Millisecond, - "network retry 應走完 §9.1 退避序列") -} - -// ========================================================================== -// Context cancel -// ========================================================================== - -// TestGetFile_ContextCancel:caller cancel ctx → 立即 return ctx.Err()(不包成 sentinel)。 -func TestGetFile_ContextCancel(t *testing.T) { - t.Parallel() - - - // mock server:handler 故意 sleep(讓 ctx cancel 在 server response 前發生) - mux := http.NewServeMux() - mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) { - select { - case <-r.Context().Done(): - case <-time.After(2 * time.Second): - } - w.WriteHeader(http.StatusOK) - }) - srv := httptest.NewServer(mux) - t.Cleanup(srv.Close) - - fc := newFAAClientForTest(t, srv.URL) - ctx, cancel := context.WithCancel(context.Background()) - go func() { - time.Sleep(50 * time.Millisecond) - cancel() - }() - - _, err := fc.GetFile(ctx, "k") - require.Error(t, err) - // ctx cancel → 透傳 ctx.Err()(不包成 ErrFAAUnavailable) - assert.True(t, - errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded), - "ctx cancel 應透傳,不應包成 ErrFAAUnavailable") -} - -// TestGetFile_ContextCancel_DuringRetry:ctx cancel 發生在 retry sleep 中 → 立即中斷。 -// -// 流程: -// - mock server 持續 500(觸發 retry) -// - 在第 1 次 retry 退避(1s)的中間(500ms)cancel ctx -// - 期望:GetFile 立即 return ctx.Err(),不等完 1s 退避也不繼續第 2 次 retry -func TestGetFile_ContextCancel_DuringRetry(t *testing.T) { - t.Parallel() - - var attempts atomic.Int32 - mux := http.NewServeMux() - mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) { - attempts.Add(1) - w.WriteHeader(http.StatusInternalServerError) - _, _ = w.Write([]byte(`{"error":{"code":"internal_error"}}`)) - }) - srv := httptest.NewServer(mux) - t.Cleanup(srv.Close) - - fc := newFAAClientForTest(t, srv.URL) - - ctx, cancel := context.WithCancel(context.Background()) - go func() { - // 等第 1 次 attempt 跑完 + 進 retry sleep 後再 cancel - // 第 1 次 attempt 約 < 100ms;第 1 次 retry 退避 1s,在 500ms cancel - time.Sleep(500 * time.Millisecond) - cancel() - }() - - start := time.Now() - _, err := fc.GetFile(ctx, "k") - duration := time.Since(start) - - require.Error(t, err) - assert.True(t, - errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded), - "retry sleep 中 cancel → 透傳 ctx.Err()") - // 應在 cancel 後立即中斷(< 1s 整體時間)— 不該等完 1s 退避或進入第 2 次 retry - assert.Less(t, duration, 900*time.Millisecond, - "ctx cancel 應立即中斷 retry sleep(不等完退避)") - // attempts 應為 1(第 1 次 attempt 後進 retry sleep 就被 cancel) - assert.Equal(t, int32(1), attempts.Load(), - "cancel 後不應再嘗試第 2 次 attempt") -} - -// ========================================================================== -// Constructor fail-fast -// ========================================================================== - -// TestNewFAAClient_Panics_When_APIKey_Empty:fail-fast 驗證 — Phase 0.8b -// 不允許 server 在「未認證」狀態下啟動,建構式必須立即 panic。 -// -// 對齊 ADR-015 §3.5.3 部署檢查清單 #1。 -// -// (Phase 0.8b 之前的 `TestGetFile_ServiceTokenFailure_Propagated` 已移除: -// API key 改造後 ServiceToken 不再被呼叫,「token 取不到」這個失敗路徑結構性消失, -// 原測試的前提不存在;對應的失敗模式變成「建構時 fail-fast」由本測試覆蓋。) -func TestNewFAAClient_Panics_When_APIKey_Empty(t *testing.T) { - t.Parallel() - - defer func() { - r := recover() - require.NotNil(t, r, "APIKey 為空時必須 panic(fail-fast)") - err, ok := r.(error) - require.True(t, ok, "panic value 應為 error 型別") - assert.True(t, errors.Is(err, ErrFAAAPIKeyNotConfigured), - "panic 應為 ErrFAAAPIKeyNotConfigured sentinel") - }() - - _ = NewFAAClient(FAAClientOpts{ - BaseURL: "http://example.com", - APIKey: "", // empty — 必須觸發 panic - Logger: silentLogger(), - }) -} - -// ========================================================================== -// 額外:empty object_key validation -// ========================================================================== - -// TestGetFile_EmptyObjectKey:保護性 validation — 空字串 object_key 應立即 fail。 -func TestGetFile_EmptyObjectKey(t *testing.T) { - t.Parallel() - - fc := NewFAAClient(FAAClientOpts{ - BaseURL: "http://invalid", - APIKey: fakeFAAAPIKey, - Logger: silentLogger(), - }) - - _, err := fc.GetFile(context.Background(), "") - require.Error(t, err) - assert.Contains(t, err.Error(), "object_key is required", - "empty object_key 應立即 fail(不打網路)") -} - -// ========================================================================== -// hashObjectKey unit test(log 用 hash 函式的穩定性) -// ========================================================================== - -// TestHashObjectKey_StableAndLength:同 input 應產生同 output;長度固定 16。 -func TestHashObjectKey_StableAndLength(t *testing.T) { - t.Parallel() - h1 := hashObjectKey("tenant/jobs/abc/output.nef") - h2 := hashObjectKey("tenant/jobs/abc/output.nef") - h3 := hashObjectKey("tenant/jobs/xyz/output.nef") - - assert.Equal(t, h1, h2, "同 object_key 應產生同 hash(log 可追蹤同一 request)") - assert.NotEqual(t, h1, h3, "不同 object_key hash 應不同") - assert.Len(t, h1, objectKeyHashLen, "hash 長度固定") -} diff --git a/visionA-backend/internal/conversion/flow.go b/visionA-backend/internal/conversion/flow.go index 85a3b16..899c79c 100644 --- a/visionA-backend/internal/conversion/flow.go +++ b/visionA-backend/internal/conversion/flow.go @@ -1,7 +1,11 @@ // Flow — Service interface 的具體實作(T6 整合層)。 // -// 整合 T2 (mc_token_client) / T3 (converter_client) / T4 (faa_client) / T5 (ownership) -// 成為對 handler 暴露的單一 Service。對齊: +// 整合 converter_client / ownership 成為對 handler 暴露的單一 Service。 +// 對齊: +// (Phase 0.8b v0.6 T3 起:原 T2 mc_token_client / T4 faa_client 已整檔砍除; +// 服務間 download / promote 改走 converter.GetResult,認證統一 visionA → converter API key。) +// +// 對齊: // - .autoflow/04-architecture/conversion.md §2.7 整體流程協調 + §4.3.1/§4.3.2 // - .autoflow/04-architecture/api/api-conversion.md(5 個 endpoint 規格) // - .autoflow/04-architecture/adr/adr-014-conversion-integration.md @@ -103,30 +107,21 @@ type Storage interface { // flow 是 Service interface 的預設實作(不對外 export,caller 拿 interface)。 // // Phase 0.8b 變更(ADR-015 §6 / conversion.md §3): -// - 移除 mcToken:服務間認證已改 pre-shared API key(API key 內含於 ConverterClient / FAAClient) +// - 移除 mcToken:服務間認證已改 pre-shared API key // - 移除 tenantID:MC delegated download token 機制取消,不再需要 tenant 概念 -// - 移除 faaBaseURL:DownloadStream 走 faa.GetFile(FAAClient 內含 baseURL),不再自組 FAA URL +// - 移除 faaBaseURL:visionA 端不再自組 FAA URL // - 移除 delegatedTTLSeconds:delegated download token 取消 // // Phase 0.8b v0.6 變更(ADR-016 / conversion.md §2.5 / §4.1): // - DownloadStream / PromoteToModels 改走 `converter.GetResult` 從 converter MinIO 拉 NEF stream; // visionA 端**不再直接呼叫 FAA**(撤回 v0.5 設計缺口) -// - faa 欄位仍保留(T3 才砍整檔 faa_client.go),但本 struct 的 method 內部不再使用 f.faa; -// `FlowOpts.FAA` 仍是必填以維持 wire 點向後相容(T3 / T5 再清) // -// **T3 預期清單**(給接手的 backend agent;reviewer s-1 補): -// (a) 刪 internal/conversion/faa_client.go 整檔 -// (b) 砍 FAAClient interface(type assertion / mock 都連帶刪) -// (c) 砍 FlowOpts.FAA 欄位 + NewService 對 FAA 的必填校驗 -// (d) 砍 flow.faa 欄位本身 -// (e) 砍 cmd/api-server/main.go wire 點對 FAAClient 的注入 -// (f) 砍 cmd/api-server/conversion_e2e_test.go mockFAA + setupConversionFixture 對 FAA 的 wire; -// 保留 e2e negative assertion 路徑、改驗「wire 點不存在 FAA dependency」(編譯期靜態斷言) -// (g) 砍 internal/conversion/flow_test.go flowStubFAA + flowFixture.faa 欄位(同步 T3) -// (h) 跑 grep 確認沒有殘留 `f\.faa\.` / `FAAClient` reference(含 godoc / 註解) +// Phase 0.8b v0.6 T3 變更(本 commit): +// - 砍 `faa` 欄位、`FAAClient` interface、`FlowOpts.FAA` 必填校驗(v0.5 設計缺口的最後痕跡) +// - `faa_client.go` / `faa_client_test.go` 整檔刪除 +// - e2e `mockFAA` 保留作為 regression 防護(驗 visionA 端不再直接打 FAA;ADR-016 §1 設計約束) type flow struct { converter ConverterClient - faa FAAClient // v0.6:保留欄位以維持 NewService 向後相容;method 內不再使用(T3 砍) ownership Ownership modelStore ModelStore @@ -140,14 +135,17 @@ type flow struct { // FlowOpts 是 NewService 的依賴注入。 // -// 必填:Converter / FAA / Ownership / ModelStore / Storage。其他 optional(nil/0 自動填合理預設)。 +// 必填:Converter / Ownership / ModelStore / Storage。其他 optional(nil/0 自動填合理預設)。 // // Phase 0.8b 變更(ADR-015 §6):移除 4 個欄位 — MCToken / TenantID / FAABaseURL / DelegatedTTLSeconds, // 因 API key 認證鏈不再依賴 MC,且 download 改 server-side stream proxy(不需自組 FAA URL)。 +// +// Phase 0.8b v0.6 T3 變更(本 commit):移除 `FAA FAAClient` 欄位 — ADR-016 撤回 visionA 直接 +// 呼叫 FAA 的設計後,FAAClient interface 與 faa_client.go 整檔砍除;download / promote 流程 +// 改走 `converter.GetResult`(含於 Converter 欄位內)。 type FlowOpts struct { - // 3 個 client + 1 個 ownership store(T3 / T4 / T5) + // 2 個 client + 1 個 ownership store Converter ConverterClient - FAA FAAClient Ownership Ownership // 既有 visionA 套件的 narrow adapter @@ -168,9 +166,6 @@ func NewService(opts FlowOpts) (Service, error) { if opts.Converter == nil { return nil, errors.New("conversion: FlowOpts.Converter is required") } - if opts.FAA == nil { - return nil, errors.New("conversion: FlowOpts.FAA is required") - } if opts.Ownership == nil { return nil, errors.New("conversion: FlowOpts.Ownership is required") } @@ -196,7 +191,6 @@ func NewService(opts FlowOpts) (Service, error) { return &flow{ converter: opts.Converter, - faa: opts.FAA, ownership: opts.Ownership, modelStore: opts.ModelStore, storage: opts.Storage, diff --git a/visionA-backend/internal/conversion/flow_test.go b/visionA-backend/internal/conversion/flow_test.go index 1b75e47..dbb115a 100644 --- a/visionA-backend/internal/conversion/flow_test.go +++ b/visionA-backend/internal/conversion/flow_test.go @@ -1,9 +1,9 @@ // flow_test.go — Service interface 整合層的單元測試。 // // 測試策略: -// - 各 client 用 in-package stub(不耦合 T3 / T4 / T5 真實邏輯,純驗 flow 整合行為) +// - 各 client 用 in-package stub(不耦合 ConverterClient / Ownership 真實邏輯,純驗 flow 整合行為) // - 沿用 ownership_test.go 的 stubConverterClient(補上 InitJob/GetJob/Promote 實作) -// - 用本檔案專屬的 stubFAAClient / stubModelStore / stubStorage +// - 用本檔案專屬的 stubModelStore / stubStorage // // 涵蓋 5 個 method × happy / ownership 失敗 / client 失敗 propagation + // task spec 額外要求: @@ -18,10 +18,8 @@ // (見 ADR-015 §6 + conversion.md §3 / §4.1) // Phase 0.8b v0.6 T2:DownloadStream / PromoteToModels 改走 converter.GetResult // (見 ADR-016 + conversion.md §2.5 / §4.1 / §6) -// - flowStubFAA struct **保留**(T3 才整檔砍 faa_client.go)但 test 不再透過它走 -// download / promote 路徑;改用 flowStubConverter.getResultFunc hook -// - fixture 自動安裝 default getResultFunc(回 defaultStubNEFBody)讓既有 happy-path -// test 不必個別設 hook;需 override 行為的 test 直接覆寫 fix.converter.getResultFunc +// Phase 0.8b v0.6 T3:flowStubFAA + flowFixture.faa 欄位整段砍除(ADR-016 撤回 FAA 直連、 +// faa_client.go 整檔刪除);FlowOpts.FAA 必填校驗一併移除。 package conversion import ( @@ -168,39 +166,10 @@ func (s *flowStubConverter) GetResult(ctx context.Context, jobID string) (io.Rea var _ ConverterClient = (*flowStubConverter)(nil) -// flowStubFAA 是 FAAClient stub。 -type flowStubFAA struct { - mu sync.Mutex - getFileFunc func(ctx context.Context, objectKey string) (*FAAFile, error) - getCalls atomic.Int32 - lastKey string -} - -func newFlowStubFAA() *flowStubFAA { - return &flowStubFAA{} -} - -func (s *flowStubFAA) GetFile(ctx context.Context, objectKey string) (*FAAFile, error) { - s.getCalls.Add(1) - s.mu.Lock() - s.lastKey = objectKey - s.mu.Unlock() - if s.getFileFunc != nil { - return s.getFileFunc(ctx, objectKey) - } - body := io.NopCloser(strings.NewReader("nef-bytes-stub")) - return &FAAFile{ - Body: body, - ContentLength: int64(len("nef-bytes-stub")), - ContentType: "application/octet-stream", - ETag: "stub-etag", - }, nil -} - -var _ FAAClient = (*flowStubFAA)(nil) - // Phase 0.8b T4:原 flowStubMCToken 已整段刪除(MC 認證鏈取消、flow 不再依賴 MCTokenClient)。 // Phase 0.8b T5:mc_token_stub.go 整檔砍除;MCTokenClient interface 已不存在。 +// Phase 0.8b v0.6 T3:flowStubFAA 整段砍除(ADR-016 撤回 FAA 直連;FAAClient interface +// + faa_client.go 整檔已刪);download / promote 改驗 converter.GetResult。 // flowStubModelStore 是 ModelStore stub。 type flowStubModelStore struct { @@ -299,7 +268,6 @@ var _ Storage = (*flowStubStorage)(nil) type flowFixture struct { svc Service converter *flowStubConverter - faa *flowStubFAA models *flowStubModelStore storage *flowStubStorage ownership Ownership @@ -313,15 +281,12 @@ type flowFixture struct { // 個別設 hook。需要 override 行為(specific Content-Length / error)的 test 直接覆寫 // `fix.converter.getResultFunc` 即可。 // -// **Phase 0.8b v0.6 (T2)** — FAA 欄位/stub 過渡狀態:FAA 欄位保留作為 T3 過渡(method 內無 caller、 -// godoc flow.go:111-118 明示「T3 砍」);`flowStubFAA` 仍保留並維持 wire 進 FlowOpts.FAA,以 -// 驗 e2e negative assertion「FAA 0 命中」(conversion_e2e_test.go:1037-1040)。T3 砍 -// faa_client.go 整檔時同步砍:(a) `flowStubFAA` type;(b) `flowFixture.faa` 欄位; -// (c) FlowOpts.FAA 必填;(d) e2e mockFAA + setupConversionFixture 對 FAA 的 wire。 +// **Phase 0.8b v0.6 T3**:FAA 欄位/stub 整段砍除(ADR-016 撤回 FAA 直連)。FlowOpts.FAA 必填 +// 校驗一併移除;e2e negative assertion 仍由 conversion_e2e_test.go 端 mockFAA + getCallCount +// 保留作為 regression 防護(驗 visionA 端不再直接打 FAA)。 func newFlowFixture(t *testing.T) *flowFixture { t.Helper() conv := newFlowStubConverter() - faa := newFlowStubFAA() models := newFlowStubModelStore() storage := newFlowStubStorage() own := NewOwnership(conv, newSilentLogger()) @@ -338,7 +303,6 @@ func newFlowFixture(t *testing.T) *flowFixture { svc, err := NewService(FlowOpts{ Converter: conv, - FAA: faa, Ownership: own, ModelStore: models, Storage: storage, @@ -351,7 +315,6 @@ func newFlowFixture(t *testing.T) *flowFixture { return &flowFixture{ svc: svc, converter: conv, - faa: faa, models: models, storage: storage, ownership: own, @@ -387,12 +350,12 @@ func makeMultipartBody(t *testing.T, clientUserID string) (body io.Reader, conte // Constructor — 缺欄位驗證 // ========================================================================== -// Phase 0.8b T4:TenantID / FAABaseURL / MCToken 欄位已從 FlowOpts 砍除; -// 必填欄位降為 5 個(Converter / FAA / Ownership / ModelStore / Storage)。 +// Phase 0.8b T4:TenantID / FAABaseURL / MCToken 欄位已從 FlowOpts 砍除。 +// Phase 0.8b v0.6 T3:FAA 欄位一併砍除(ADR-016);必填欄位降為 4 個 +// (Converter / Ownership / ModelStore / Storage)。 func TestNewService_RequiredFields(t *testing.T) { t.Parallel() conv := newFlowStubConverter() - faa := newFlowStubFAA() own := NewOwnership(conv, newSilentLogger()) mod := newFlowStubModelStore() st := newFlowStubStorage() @@ -401,11 +364,10 @@ func TestNewService_RequiredFields(t *testing.T) { name string opts FlowOpts }{ - {"missing converter", FlowOpts{FAA: faa, Ownership: own, ModelStore: mod, Storage: st}}, - {"missing faa", FlowOpts{Converter: conv, Ownership: own, ModelStore: mod, Storage: st}}, - {"missing ownership", FlowOpts{Converter: conv, FAA: faa, ModelStore: mod, Storage: st}}, - {"missing modelstore", FlowOpts{Converter: conv, FAA: faa, Ownership: own, Storage: st}}, - {"missing storage", FlowOpts{Converter: conv, FAA: faa, Ownership: own, ModelStore: mod}}, + {"missing converter", FlowOpts{Ownership: own, ModelStore: mod, Storage: st}}, + {"missing ownership", FlowOpts{Converter: conv, ModelStore: mod, Storage: st}}, + {"missing modelstore", FlowOpts{Converter: conv, Ownership: own, Storage: st}}, + {"missing storage", FlowOpts{Converter: conv, Ownership: own, ModelStore: mod}}, } for _, tt := range tests { tt := tt @@ -420,13 +382,12 @@ func TestNewService_RequiredFields(t *testing.T) { func TestNewService_DefaultsApplied(t *testing.T) { t.Parallel() conv := newFlowStubConverter() - faa := newFlowStubFAA() own := NewOwnership(conv, newSilentLogger()) mod := newFlowStubModelStore() st := newFlowStubStorage() svc, err := NewService(FlowOpts{ - Converter: conv, FAA: faa, Ownership: own, + Converter: conv, Ownership: own, ModelStore: mod, Storage: st, // DefaultJobExpiryDuration 留空 → 應 fallback 7d }) @@ -894,8 +855,9 @@ func TestPromoteToModels_HappyPath(t *testing.T) { assert.Equal(t, int32(1), fix.converter.promoteCalls.Load()) assert.Equal(t, int32(1), fix.converter.getResultCalls.Load(), "PromoteToModels 應呼叫 1 次 converter.GetResult(v0.6 取代 faa.GetFile)") - assert.Equal(t, int32(0), fix.faa.getCalls.Load(), - "v0.6:visionA 端不再直接打 FAA,faa.GetFile 不該被呼叫") + // v0.6 T3:FAAClient interface 已整檔砍除(faa_client.go 不存在); + // 「visionA 端不再直接打 FAA」改由「型別已不存在」的編譯期保證 + e2e mockFAA 端 negative + // assertion 雙重防護(conversion_e2e_test.go:TestConversionE2E_DownloadStream) } // TestPromoteToModels_DefaultName:caller 傳空 name 應走 fallback `_kl`。 @@ -1007,8 +969,8 @@ func TestPromoteToModels_ConverterGetResultError_Propagation(t *testing.T) { rec, _ := fix.models.FindBySourceJobID(context.Background(), "user-alice", "j1") assert.Nil(t, rec) - // v0.6:visionA 端不再直接打 FAA - assert.Equal(t, int32(0), fix.faa.getCalls.Load()) + // v0.6 T3:FAAClient 已整檔砍除(編譯期保證 visionA 端不再直接打 FAA); + // 不再有 fix.faa.getCalls assertion 需要驗 } // TestPromoteToModels_ConverterGetResultExpired_Propagation:v0.6 新增—— @@ -1060,11 +1022,10 @@ func TestPromoteToModels_StorageError(t *testing.T) { _, err := fix.svc.PromoteToModels(context.Background(), "user-alice", "j1", "x") require.Error(t, err) assert.True(t, errors.Is(err, ErrStorageUnavailable), - "storage.Put 失敗應歸類為 ErrStorageUnavailable,不是 ErrFAAUnavailable") + "storage.Put 失敗應歸類為 ErrStorageUnavailable,不是 ErrConverterUnavailable") // 確認沒被誤包成其他 sentinel - assert.False(t, errors.Is(err, ErrFAAUnavailable), - "storage 失敗不該被歸類為 FAA 問題(Reviewer M-1)") - assert.False(t, errors.Is(err, ErrConverterUnavailable)) + assert.False(t, errors.Is(err, ErrConverterUnavailable), + "storage 失敗不該被歸類為 converter 問題(Reviewer M-1)") // model record 不應被建(storage 失敗在 modelStore.Save 前) rec, _ := fix.models.FindBySourceJobID(context.Background(), "user-alice", "j1") @@ -1103,13 +1064,18 @@ func TestPromoteToModels_ModelStoreError(t *testing.T) { // - 不再依賴 MCTokenClient(flowStubMCToken 已整段刪除) // - 對外仍由同一個 GET /api/conversion/{job_id}/download endpoint 觸發(handler 層改 stream proxy) // -// 測試 case 對齊原 6 個 happy / ownership / state / error propagation 路徑: +// 測試 case 對齊 happy / ownership / state / error propagation 路徑: // 1. HappyPath:成功拉到 stream + metadata 正確 -// 2. SpecialChars:user_id / job_id 含特殊字元時 buildTargetObjectKey 正確 + filename 安全 -// 3. OwnershipMismatch:→ ErrJobNotFound -// 4. JobNotCompleted:→ ErrJobNotCompleted -// 5. PromoteError_Propagation:promote 5xx 透傳 -// 6. FAAError_Propagation(取代 MCError):FAA pull 失敗透傳 +// 2. FilenameFromConverterJob:filename 取自 cj.SourceFilename + Platform +// 3. DefaultsContentType:converter 沒給 Content-Type 時 fallback application/octet-stream +// 4. OwnershipMismatch:→ ErrJobNotFound +// 5. JobNotCompleted:→ ErrJobNotCompleted +// 6. PromoteError_Propagation:promote 5xx 透傳 +// 7. ConverterGetResultError_Propagation(v0.6 取代 FAAError_Propagation) +// 8. ConverterAuthFailed_Propagation(v0.6 取代 FAAAuthFailed_Propagation) +// 9. ConverterResultExpired_Propagation(v0.6 新增;410 result_expired) +// 10. ConverterValidationFailed_Propagation(v0.6 T3 / s-3 新增) +// 11. StorageError_StreamClosed(v0.6 T3 / s-4 新增;驗 fd leak 防護) // TestDownloadStream_HappyPath:成功 → 拿到 io.ReadCloser + DownloadMetadata 正確。 // @@ -1167,9 +1133,8 @@ func TestDownloadStream_HappyPath(t *testing.T) { assert.Equal(t, int32(1), fix.converter.getResultCalls.Load(), "DownloadStream 應呼叫 1 次 converter.GetResult") - // v0.6:visionA 端不再直接打 FAA - assert.Equal(t, int32(0), fix.faa.getCalls.Load(), - "v0.6:DownloadStream 不該呼叫 faa.GetFile") + // v0.6 T3:FAAClient interface + faa_client.go 已整檔砍除 → 編譯期保證「visionA 端不再 + // 直接打 FAA」;e2e mockFAA 端的 negative assertion 提供 wire 層 regression 防護 } // TestDownloadStream_FilenameFromConverterJob:filename 取自 cj.SourceFilename + Platform, @@ -1236,7 +1201,6 @@ func TestDownloadStream_OwnershipMismatch(t *testing.T) { assert.Nil(t, meta) // converter.GetResult 不該被打到(ownership 不符在 GetResult 之前) - // v0.6:取代原 faa.getCalls assert assert.Equal(t, int32(0), fix.converter.getResultCalls.Load(), "ownership 不符應在 converter.GetResult 之前短路") } @@ -1272,7 +1236,6 @@ func TestDownloadStream_PromoteError_Propagation(t *testing.T) { assert.True(t, errors.Is(err, ErrConverterUnavailable)) // converter.GetResult 不該被打到(promote 失敗在 GetResult 之前) - // v0.6:取代原 faa.getCalls assert assert.Equal(t, int32(0), fix.converter.getResultCalls.Load(), "promote 失敗應在 converter.GetResult 之前短路") } @@ -1348,6 +1311,93 @@ func TestDownloadStream_ConverterResultExpired_Propagation(t *testing.T) { assert.Equal(t, 410, HTTPStatus(err)) } +// TestDownloadStream_ConverterValidationFailed_Propagation:v0.6 T3 s-3 補強—— +// converter `GET /api/v1/jobs/{id}/result` 端回 4xx(非 401/403/404/409/410)→ +// converter_client.mapGetResultError mapping 到 ErrValidationFailed; +// flow.DownloadStream 透傳;handler 層對外 HTTP 400 + code `validation_failed`。 +// +// 設計動機(T1/T2 reviewer s-3 要求):mapGetResultError 內 `case status >= 400 && status < 500` +// 的 fallback 路徑沒測試覆蓋,補上避免 silent fallback regression。 +func TestDownloadStream_ConverterValidationFailed_Propagation(t *testing.T) { + t.Parallel() + fix := newFlowFixture(t) + + fix.converter.setJob(&ConverterJob{JobID: "j1", Status: "completed", CreatedAt: time.Now()}) + fix.ownership.Set("j1", "user-alice") + fix.converter.getResultFunc = func(ctx context.Context, jobID string) (io.ReadCloser, *DownloadMetadata, error) { + // 模擬 converter 端 422 / 其他 4xx(converter_client 會收斂到 ErrValidationFailed) + return nil, nil, fmt.Errorf("%w: get_result 422", ErrValidationFailed) + } + + stream, meta, err := fix.svc.DownloadStream(context.Background(), "user-alice", "j1") + require.Error(t, err) + assert.True(t, errors.Is(err, ErrValidationFailed), + "converter 端 4xx fallback 必須透傳 ErrValidationFailed 給 handler") + assert.Nil(t, stream) + assert.Nil(t, meta) + // 對外 ErrorCode / HTTPStatus 對齊 conversion.md §6 + api-conversion.md §錯誤碼總覽 + assert.Equal(t, "validation_failed", ErrorCode(err)) + assert.Equal(t, 400, HTTPStatus(err)) +} + +// instrumentedReadCloser 是 s-4 用的 io.ReadCloser wrapper,計數 Close 被呼叫次數。 +// +// 用於驗 flow.PromoteToModels 在 storage.Put 失敗時仍有 close converter.GetResult +// 回的 stream(避免 fd / goroutine leak;flow.go `defer stream.Close()` 已實作, +// 但缺乏 explicit test 驗證行為)。 +type instrumentedReadCloser struct { + io.Reader + closeCalls atomic.Int32 +} + +func (r *instrumentedReadCloser) Close() error { + r.closeCalls.Add(1) + return nil +} + +// TestPromoteToModels_StorageError_StreamClosed:v0.6 T3 s-4 補強—— +// PromoteToModels 第 5 步從 converter.GetResult 拿到 stream,第 6 步 storage.Put 失敗時, +// 必須仍 close 該 stream(避免 fd / goroutine leak)。 +// +// 設計動機(T2 reviewer s-4 要求):flow.go:635 `defer stream.Close()` 行為缺乏 explicit +// regression 防護;本 test 用 instrumented stream wrapper 計數 Close 呼叫次數,驗值 ≥ 1。 +// +// 為什麼用 ≥ 1 而不是 == 1:Go defer 行為下 stream.Close 應該被呼叫恰好 1 次,但容忍多次 +// close(io.NopCloser 等 wrapper 對重複 close 是安全的);用 ≥ 1 避免測試對 close 次數 +// 過度耦合(未來改寫成 defer + explicit close 的 cleanup pattern 也不破壞此 test)。 +func TestPromoteToModels_StorageError_StreamClosed(t *testing.T) { + t.Parallel() + fix := newFlowFixture(t) + + fix.converter.setJob(&ConverterJob{ + JobID: "j1", Status: "completed", CreatedAt: time.Now(), + SourceFilename: "x.onnx", Platform: "720", + }) + fix.ownership.Set("j1", "user-alice") + + // 注入 instrumented stream — 驗 storage.Put 失敗時仍會被 Close + instrumented := &instrumentedReadCloser{Reader: strings.NewReader("nef-bytes")} + fix.converter.getResultFunc = func(ctx context.Context, jobID string) (io.ReadCloser, *DownloadMetadata, error) { + return instrumented, &DownloadMetadata{ + Filename: "x.nef", + ContentType: "application/octet-stream", + ContentLength: int64(len("nef-bytes")), + }, nil + } + + // storage.Put 設為失敗 — 觸發 ErrStorageUnavailable 並驗 stream 仍被 close + fix.storage.putErr = errors.New("disk full") + + _, err := fix.svc.PromoteToModels(context.Background(), "user-alice", "j1", "x") + require.Error(t, err) + assert.True(t, errors.Is(err, ErrStorageUnavailable), + "storage.Put 失敗應歸類為 ErrStorageUnavailable") + + // **核心斷言**:即使 storage.Put 失敗,stream.Close 仍應被呼叫(flow.go defer 保護) + assert.GreaterOrEqual(t, int(instrumented.closeCalls.Load()), 1, + "storage.Put 失敗時,converter.GetResult 回的 stream 必須仍被 Close(避免 fd / goroutine leak;flow.go:635 defer 保護)") +} + // ========================================================================== // helper functions tests // ========================================================================== diff --git a/visionA-backend/internal/conversion/util.go b/visionA-backend/internal/conversion/util.go index 7e4e1c7..6188674 100644 --- a/visionA-backend/internal/conversion/util.go +++ b/visionA-backend/internal/conversion/util.go @@ -1,13 +1,20 @@ // Package conversion 內部 utility helpers。 // -// 此檔收容跨檔共用的小型 helper(log truncate 等),原本散落在 -// mc_token_client.go;Phase 0.8b T3 砍 mc_token_client 整個檔後搬到此獨立檔, -// 避免 truncate 隨 mc_token_client.go 一起被砍(仍被 converter_client.go / -// faa_client.go 的 log error 拼接使用)。 +// 此檔收容跨檔共用的小型 helper(log truncate / object key hash 等),原本散落在 +// mc_token_client.go / faa_client.go;Phase 0.8b T3 砍上述兩檔後搬到此獨立檔, +// 避免被連帶砍除(仍被 converter_client.go / flow.go 的 log 拼接使用)。 // -// Phase 0.8b conversion (見 ADR-015 §6 / .autoflow/04-architecture/conversion.md §2) +// Phase 0.8b v0.6 conversion (見 ADR-016 / docs/autoflow/04-architecture/conversion.md §2) package conversion +import ( + "crypto/sha256" + "encoding/hex" +) + +// objectKeyHashLen 是 log 中 object_key 的截短後 hash 長度(前 16 hex chars)。 +const objectKeyHashLen = 16 + // truncate 把字串截到 max 長度(避免 log 太長)。 func truncate(s string, max int) string { if len(s) <= max { @@ -15,3 +22,18 @@ func truncate(s string, max int) string { } return s[:max] + "...(truncated)" } + +// hashObjectKey 把 object_key 算 SHA-256 後取前 16 hex chars,當 log 用的穩定 hash。 +// +// 為什麼不直接 log object_key: +// - object_key 可能含路徑("tenant/jobs/uuid/output.nef")— 過長 +// - 目前 visionA 的 object_key 不直接含 user 敏感資訊,但保險起見統一 hash +// - 16 chars hex(64-bit)對 visionA 內部 job 數量來說碰撞機率極低,足以追蹤單一 request +// +// Phase 0.8b v0.6(T3):原本定義在 faa_client.go;砍 faa_client.go 時搬到 util.go +// (flow.go DownloadStream / PromoteToModels 仍用 hashObjectKey 為 log 加 target_object_key +// 標記,方便跨 service 追蹤同一個 NEF 物件)。 +func hashObjectKey(objectKey string) string { + sum := sha256.Sum256([]byte(objectKey)) + return hex.EncodeToString(sum[:])[:objectKeyHashLen] +}