feat(visionA-backend): Phase 0.8b v0.6 T3 — 砍 faa_client + ErrFAA* + s-3/s-4/s-5

對齊 ADR-016:visionA backend 不再直連 FAA、download 改走 converter GetResult。T3 砍除 v0.5 階段為 FAA delegated token 路線留的 faa_client.go 整檔 + 對應 sentinel + flow / e2e 殘留。

砍除:
- internal/conversion/faa_client.go(整檔)
- internal/conversion/faa_client_test.go(整檔)
- errors.go: ErrFAAFileNotFound + ErrFAAAuthFailed 2 sentinel(+ ErrorCode/HTTPStatus mapping)
- flow.go: faa FAAClient 欄位 + FlowOpts.FAA 必填 + a-h T3 預期清單 godoc
- flow_test.go: flowStubFAA struct + newFlowStubFAA helper + fixture.faa
- internal/api/conversion_test.go: TestConversion_Download_FAAAuthFailed
- cmd/api-server/main.go: NewFAAClient wire + FAA: faaAPIClient field

保留:
- ErrFAAUnavailable(converter promote 仍 PUT FAA、502 透傳路徑需要)
- hashObjectKey helper 搬到 util.go(ownership 仍用)
- e2e mockFAA 精簡為 regression-only(保留 negative assertion: FAA 0 命中)— reviewer 推薦雙層防護

新增(T3 必補,T1/T2 reviewer 累積):
- s-3 TestDownloadStream_ConverterValidationFailed_Propagation(converter 4xx fallback → ErrValidationFailed 透傳)
- s-4 TestPromoteToModels_StorageError_StreamClosed(instrumented stream wrapper 驗 fd leak 防護)
- s-5 TestParseFilenameFromContentDisposition 9 個 sub-case(3 RFC 5987 + 5 hostile-input + 1 empty quoted)
  發現:Go stdlib 自動 percent-decode RFC 5987 並寫入 params["filename"]、RFC 5987 優先於 ASCII filename

T3 review M-1 修補(commit 內含):
- internal/api/conversion.go:51,56 godoc + 501 user-facing message 從「FAA_BASE_URL」改為「VISIONA_CONVERTER_BASE_URL + VISIONA_CONVERTER_API_KEY」
- 對齊 ADR-016 visionA 端不再有 FAA 直連設計

驗證:
- B 層 verification 強制跑(reviewer 規定 T3 不接受暫緩):
  * 跨檔 grep: MC chain 0 / FAA functional refs 0 / TenantID 0
  * API contract test: TestConversionE2E_DownloadStream 6 斷言含 FAA negative
  * 安全 manual review: path traversal / unbounded read / secret in log / error mask 4 項
- go build ./... exit 0
- go test -race -count=3 ./... 17 packages 全綠
- Reviewer 5 軸(v0.6-t3-review)⚠️ 通過(M-1 已修)

a-h 8 條清單 100% 達成(逐條 grep 驗收);mockFAA 選方案 1(保留 + negative assertion)— 雙層防護。

下一步:
- T4 砍 ConversionConfig.FAAAPIKey/FAABaseURL + load.go env 讀取 + .env*.example + m-2 i18n dead case 一併
- T5 main.go startup log 整理 + e2e regression 防護

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
jim800121chen 2026-05-16 18:56:01 +08:00
parent ce6a657df4
commit 6024c294d3
12 changed files with 329 additions and 1327 deletions

View File

@ -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功能性影響 0mock 不會被 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 <FAA_API_KEY>)。
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 / FAAPhase 0.8b 後不再 wire mock MC — API key 模式)
// - mock converterAPI 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 keyADR-015mock MC / mcTokenClient
// 已從 fixture 移除download 走 server-side stream proxymockFAA 直接回 NEF binary。
// 已從 fixture 移除。
// Phase 0.8b v0.6 T3FAA 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 keyADR-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 T3visionA 端不再 wire FAA clientFAAClient 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 流程)")
// === 斷言 6v0.6 新增)visionA 端不再直接打 FAA ===
// === 斷言 6visionA 端不再直接打 FAAv0.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 interfacegetCallCount > 0 →
// e2e fail。涵蓋編譯期斷言無法抓的「raw HTTP 直連」regression
assert.Equal(t, int32(0), f.faa.getCallCount.Load(),
"v0.6visionA 端不應再直接打 FAAdownload path 改走 converter `/result`")
"v0.6 T3visionA 端不應再直接打 FAAdownload 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 應至少被打一次")

View File

@ -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 為 nil5 個 endpoint 自動回 501registerConversionRoutes 處理)。
//
// **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可選 =====

View File

@ -48,12 +48,14 @@ import (
//
// 由 NewRouter 在 apiGroupOIDC AuthMiddleware 已套)下呼叫;
// 若 deps.Conversion 為 nilPhase 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.6ADR-016visionA 端不再直連 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.8visionA → MC 換 delegated token → c.Redirect(302, FAA_URL_with_token)
// - Phase 0.8bvisionA backend 用 API key 直接拉 FAA → io.Copy(c.Writer, stream)
// 沒有 token 結構性流經 frontend不需 FAA CORSserver-side outbound HTTP
// - Phase 0.8b v0.4/v0.5visionA backend 用 API key 直接拉 FAA → io.Copy(c.Writer, stream)
// - **Phase 0.8b v0.6**ADR-016visionA backend 改走 `converter.GetResult` 從 converter
// MinIO 拉 NEF streamvisionA 端不再直接打 FAA、撤回 v0.5 設計缺口handler 端
// io.Copy(c.Writer, stream) 路徑不變、只是 stream 來源換成 converter
//
// 中途錯誤處理(已 200 / 已寫 part of body 後失敗):
// - 一旦 status 200 已寫,無法再改 status 給 clientHTTP 規範)
// - io.Copy 中斷只能 log 錯誤client 端 browser 會看到截斷檔
// - ctx cancelclient 斷線)由 FAAClient 內部 ctx-aware 透傳goroutine 自動結束
// - ctx cancelclient 斷線)由 ConverterClient 內部 ctx-aware 透傳goroutine 自動結束
func conversionDownloadHandler(deps Deps) gin.HandlerFunc {
return func(c *gin.Context) {
uc, ok := UserContextFrom(c)

View File

@ -657,30 +657,10 @@ func TestConversion_Download_FAAUnavailable(t *testing.T) {
assert.Contains(t, w.Body.String(), "faa_unavailable")
}
// TestConversion_Download_FAAAuthFailedAPI 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_unavailablemask— 不要洩漏 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 T3TestConversion_Download_FAAAuthFailed 已整段移除
// visionA 端不再直接打 FAA、ErrFAAAuthFailed sentinel 已砍除ADR-016
// 對應的 converter API key 對外 mask 行為由 TestConversion_Download_ConverterAuthFailed 涵蓋
// v0.6 後 download path 改走 converterauth 失敗統一收斂到 ErrConverterAuthFailed
// TestConversion_Download_ConverterAuthFailed對稱測試 converter API key 不對齊。
// 對外 mask 成 converter_unavailable。

View File

@ -1272,6 +1272,23 @@ func TestGetResult_EmptyJobID(t *testing.T) {
}
// TestParseFilenameFromContentDispositioncover 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-inputCRLF 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`<stem>_<chip>.nef`)、不會走到 RFC 5987 path
//
// Hostile-input 防護mime.ParseMediaType 對 malformed inputCRLF回 error → parser 回
// 空字串、不 panic、不洩漏 raw inputvisionA-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 1CRLF injectionHTTP response splitting 攻擊向量)
// `mime.ParseMediaType` 對含 CR/LF 的 header 回 error → parser 回空字串
{"hostile_crlf_injection", "attachment; filename=\"foo.nef\r\nSet-Cookie: evil=1\"", ""},
// Hostile-input 2path traversal — parser 端不做 sanitizeresponsibility 在 handler
// 此處只驗 parser 不 panic、不丟錯把 raw `../../etc/passwd` 字串原樣傳出caller 端
// 後續的 defaultDownloadFilename / handler sanitize 會處理)
{"hostile_path_traversal", `attachment; filename="../../etc/passwd"`, "../../etc/passwd"},
// Hostile-input 3null byte injection — `mime.ParseMediaType` 容忍 null byte
// 規範未明禁parser 把 raw 字串傳出(同上、由後續 layer sanitize
{"hostile_null_byte", "attachment; filename=\"foo\x00.nef\"", "foo\x00.nef"},
// Hostile-input 4extreme length4KB filename— parser 不限制長度、回 raw 字串
// 對 visionA 影響0response 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 5empty quoted string — 合法格式、回空字串
{"empty_quoted_filename", `attachment; filename=""`, ""},
}
for _, tc := range tests {
tc := tc

View File

@ -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.6T3後語意調整visionA 端不再直接呼叫 FAAADR-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 區分:
// - ErrConverterUnavailableconverter scheduler 本身不可達 / 5xxvisionA → converter 失敗)
// - ErrFAAUnavailableconverter 可達、但 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 細節)。
// callerflow.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}` 404visionA 端已無此 call path
// - ErrFAAAuthFailed — visionA → FAA API key 401/403visionA 端已無此 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 4xxclient_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 到 ErrConverterUnavailableconverter_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 自家 storagelocal 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.8bAPI 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 失敗)

View File

@ -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"},
// ErrFAAUnavailablev0.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 T3ErrFAAAuthFailed / ErrFAAFileNotFound 已砍visionA 端不再
// 直接打 FAAADR-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-1visionA 自身基礎設施失敗用獨立 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},
// ErrFAAUnavailablev0.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 T3ErrFAAAuthFailed 已砍visionA 端不再直接打 FAA
{"converter_auth_failed_502", ErrConverterAuthFailed, 502},
{"faa_auth_failed_502", ErrFAAAuthFailed, 502},
// Reviewer M-1visionA 自身基礎設施失敗 → 500不是 502 gateway
{"storage_unavailable_500", ErrStorageUnavailable, 500},
{"model_store_unavailable_500", ErrModelStoreUnavailable, 500},

View File

@ -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 用)。
// 其他 endpointPUT / DELETE / HEAD / metadata目前 visionA 不需要,未來再補。
//
// 設計要點:
// - **Phase 0.8b 認證**:直接帶 `Authorization: Bearer <VISIONA_FAA_API_KEY>`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 → 對應 sentinel401/403 → ErrFAAAuthFailed404 → ErrFAAFileNotFound
// 其他 4xx → ErrFAAUnavailable避免新增更多 sentinel
//
// 與 InitJob 的對比(為什麼 InitJob 不 retry 但 GetFile retry
// - InitJobmultipart **request body** 是 streamingio.Reader 來自上游 c.Body
// 一旦 http.Client.Do 開始送 request bodyio.Reader 已被消費retry 無法 rewind →
// 從第一次 attempt 起就「不可重試」。
// - GetFileGET 沒有 request bodyrequest 完全 idempotentretry window 涵蓋
// dial → 拿到 response headerPhase 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 stateapiKey 為 immutable 字串)。
type FAAClient interface {
// GetFile 從 FAA pull 一個 objectserver-to-serverPhase 0.8b 用 pre-shared API key
//
// 回傳 *FAAFile.Body 是 streaming bodyio.ReadCloser**caller 必須 Close**
// 不然底層 http.Response.Body 不會釋放、connection 也回不了 poolgoroutine + 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 Breturn *FAAFilebody 由 caller 自己讀;
// caller 在讀 body 時遇到網路中斷不再重試streaming response 不可 replay
//
// 錯誤映射(對齊 conversion.md §6 + errors.go
// - ctx cancel/deadline → 透傳 ctx.Err不包成 sentinel
// - 401 / 403 → ErrFAAAuthFailedPhase 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 bodycaller 用 io.Copy 等方式 streaming 消費。
Body io.ReadCloser
// ContentLength 對應 FAA response 的 Content-Length header。
// 若 FAA 走 chunked transfer 沒帶這個 header值為 -1net/http 慣例)。
ContentLength int64
// ContentType 對應 FAA response 的 Content-Type header如 "application/octet-stream")。
ContentType string
// ETag 對應 FAA response 的 ETag headerFAA 端取自 storage adapter
// 若 FAA 沒帶,為空字串。
ETag string
}
// FAAClientOpts 是 NewFAAClient 的依賴注入。
//
// HTTPClient / Now / Logger 為 optionalnil 自動填預設)— 方便 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 keyVISIONA_FAA_API_KEY
// 必填非空 — `NewFAAClient` 會在 APIKey 為空時 panicfail-fast
// 避免 server 在「未認證」狀態下啟動)。
//
// 值由 main.go 從 cfg.Conversion.FAAAPIKeyenv 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 為 optionalnil 用預設(含 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 + ResponseHeaderTimeout10s 各自)— 連線階段卡死才算 fail
// body streaming 階段交給 ctx.Done() 控制caller 用帶 deadline 的 ctx 即可)。
HTTPClient *http.Client
// Now 為 optionalnil 用 time.Now。測試會注入 fake clock。
Now func() time.Time
// Logger 為 optionalnil 用 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 validate10s 對小檔 metadata 階段夠寬鬆。
// 注意:這個 timeout **不涵蓋 body streaming 階段**body streaming 由 ctx 控制)。
faaResponseHeaderTimeout = 10 * time.Second
// faaMaxRetries 是 Phase A 5xx / network / timeout 的最大重試次數(不含第一次)。
// 對齊 conversion.md §9.1FAA GET /files/{key} max 2 retries1s, 2s
faaMaxRetries = 2
// faaRetryBaseDelay 是指數退避的 base1s, 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 structcaller 拿 interface讓未來換實作不影響 caller。
type faaClient struct {
baseURL string
apiKey string // Phase 0.8bpre-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 timeoutbody 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 retryPhase B 不 retry 的 streaming pull
// ==========================================================================
// GetFile 實作 FAAClient.GetFile。
//
// 流程Phase 0.8b
// 1. 組 URL + 建 request直接帶 c.apiKey 進 Authorization header不再透過 MCTokenClient
// 2. doWithRetrymax (1 + faaMaxRetries) attempts每 attempt 重新 c.http.Do
// - 拿到 200直接 return *FAAFile不 close body
// - 拿到 4xxclose body 後依 status mapping 對應 sentinel不 retry
// - 拿到 5xxclose 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 loopPhase A onlyapiKey 在 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 沒 bodyrequest 物件可重用,
// 但為了讓 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 → 分類結果。
//
// 回傳:
// - 成功2xxfile != nil含未 close 的 streaming body, classifiedErr=nil, retryable=false
// - 失敗file=nil, classifiedErr 為 sentinel-wrapped error, retryable 表示是否該重試
//
// 重要:成功時 callerdoWithRetry會直接把 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() 不會含 secrethttp.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 而不是真的存下來:
// - 不寫進 logFAA 錯誤 body 可能含 requestId / 路徑等內部資訊)
// - 只是讓 keep-alive 能 reuse connectionread-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 次 retryn 從 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: ...} 避免手動 escapenet/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 hex64-bit對 visionA 內部 job 數量來說碰撞機率極低,足以追蹤單一 request
func hashObjectKey(objectKey string) string {
sum := sha256.Sum256([]byte(objectKey))
return hex.EncodeToString(sum[:])[:objectKeyHashLen]
}

View File

@ -1,606 +0,0 @@
// FAA Client 單元測試。
//
// 測試策略:
// - 用 httptest.Server mock FAA 的 GET /files/{key} 端點
// - **Phase 0.8b**:直接用 string fake API keyfakeFAAAPIKey定義在 converter_client_test.go
// 不再注入 stub MCTokenClient
// - 用 atomic counter 驗 retry 行為Phase A retrymax 3 attempts = 1 + 2 retries
// - streaming 驗證用較大但合理大小10MB— 真 100MB 會拖慢 test runner 太多
//
// 測試範疇對應 conversion.md §9.1FAA 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_Successmock 回 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_Streamingmock 回 10MB bodyconfirm 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 的 clienttest 自己控)
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.Discardconfirm 全 download 完成。
written, copyErr := io.Copy(io.Discard, file.Body)
require.NoError(t, copyErr)
assert.Equal(t, totalSize, written, "streaming download 必須拿到完整 body")
}
// TestGetFile_AuthHeaderPhase 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_NoRetrymock 回 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 → ErrFAAFileNotFoundcaller 可精細處理)")
assert.Equal(t, int32(1), attempts.Load(),
"404 不應 retryobject 不存在 retry 也沒用)")
// 對外仍應 mask 成 faa_unavailable避免揭露 object_key 不存在)
assert.Equal(t, "faa_unavailable", ErrorCode(err))
assert.Equal(t, 502, HTTPStatus(err))
}
// TestGetFile_AuthFailed401Phase 0.8b — mock 回 401 → 不 retryreturn 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.8b401 必須 mapping 到新 sentinel ErrFAAAuthFailed")
// Phase 0.8b T3舊 sentinel ErrServiceClientUnauthorized 已移除,
// 改由 ErrFAAAuthFailed 接管 401/403 mapping。
assert.Equal(t, int32(1), attempts.Load(),
"401 不應 retryAPI 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_GenericErrorFAA 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 不應 retryvisionA 端的 bug")
}
// ==========================================================================
// Phase A retry 驗證5xx / network
// ==========================================================================
// TestGetFile_5xx_RetryThenSuccessmock 連續 500 兩次後回 200 → 共 3 次 attempt + 成功。
//
// 對齊 §9.1max 2 retries1s, 2s— 1 + 2 = 3 attempts第 3 次成功就 return。
// 注意test 用真實 backoff1s + 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 應 retrymax 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_Exhaustedmock 持續 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 retries1 + 2 = 3 attempts")
}
// TestGetFile_Network_RetryThenSuccess前 2 次 connection refused第 3 次成功。
//
// 用 dynamic listener swap 實作:先用一個 free port 不開 listenerdial 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 應 retrymax 2 retries → 3 attempts 後成功")
}
// TestGetFile_Network_Exhausteddial 失敗持續發生 → 用完 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")
// retry1 + 2 retries = 3 attempts2 次退避 = 1s + 2s = 3s 起跳
assert.GreaterOrEqual(t, duration, 2500*time.Millisecond,
"network retry 應走完 §9.1 退避序列")
}
// ==========================================================================
// Context cancel
// ==========================================================================
// TestGetFile_ContextCancelcaller cancel ctx → 立即 return ctx.Err()(不包成 sentinel
func TestGetFile_ContextCancel(t *testing.T) {
t.Parallel()
// mock serverhandler 故意 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_DuringRetryctx cancel 發生在 retry sleep 中 → 立即中斷。
//
// 流程:
// - mock server 持續 500觸發 retry
// - 在第 1 次 retry 退避1s的中間500mscancel 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_Emptyfail-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 為空時必須 panicfail-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 testlog 用 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 應產生同 hashlog 可追蹤同一 request")
assert.NotEqual(t, h1, h3, "不同 object_key hash 應不同")
assert.Len(t, h1, objectKeyHashLen, "hash 長度固定")
}

View File

@ -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.md5 個 endpoint 規格)
// - .autoflow/04-architecture/adr/adr-014-conversion-integration.md
@ -103,30 +107,21 @@ type Storage interface {
// flow 是 Service interface 的預設實作(不對外 exportcaller 拿 interface
//
// Phase 0.8b 變更ADR-015 §6 / conversion.md §3
// - 移除 mcToken服務間認證已改 pre-shared API keyAPI key 內含於 ConverterClient / FAAClient
// - 移除 mcToken服務間認證已改 pre-shared API key
// - 移除 tenantIDMC delegated download token 機制取消,不再需要 tenant 概念
// - 移除 faaBaseURLDownloadStream 走 faa.GetFileFAAClient 內含 baseURL不再自組 FAA URL
// - 移除 faaBaseURLvisionA 端不再自組 FAA URL
// - 移除 delegatedTTLSecondsdelegated 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 agentreviewer s-1 補):
// (a) 刪 internal/conversion/faa_client.go 整檔
// (b) 砍 FAAClient interfacetype 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 端不再直接打 FAAADR-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。其他 optionalnil/0 自動填合理預設)。
// 必填Converter / Ownership / ModelStore / Storage。其他 optionalnil/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 storeT3 / 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,

View File

@ -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 T2DownloadStream / 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 T3flowStubFAA + 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 T5mc_token_stub.go 整檔砍除MCTokenClient interface 已不存在。
// Phase 0.8b v0.6 T3flowStubFAA 整段砍除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 T4TenantID / FAABaseURL / MCToken 欄位已從 FlowOpts 砍除;
// 必填欄位降為 5 個Converter / FAA / Ownership / ModelStore / Storage
// Phase 0.8b T4TenantID / FAABaseURL / MCToken 欄位已從 FlowOpts 砍除。
// Phase 0.8b v0.6 T3FAA 欄位一併砍除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.GetResultv0.6 取代 faa.GetFile")
assert.Equal(t, int32(0), fix.faa.getCalls.Load(),
"v0.6visionA 端不再直接打 FAAfaa.GetFile 不該被呼叫")
// v0.6 T3FAAClient interface 已整檔砍除faa_client.go 不存在);
// 「visionA 端不再直接打 FAA」改由「型別已不存在」的編譯期保證 + e2e mockFAA 端 negative
// assertion 雙重防護conversion_e2e_test.go:TestConversionE2E_DownloadStream
}
// TestPromoteToModels_DefaultNamecaller 傳空 name 應走 fallback `<stem>_kl<chip>`。
@ -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.6visionA 端不再直接打 FAA
assert.Equal(t, int32(0), fix.faa.getCalls.Load())
// v0.6 T3FAAClient 已整檔砍除(編譯期保證 visionA 端不再直接打 FAA
// 不再有 fix.faa.getCalls assertion 需要驗
}
// TestPromoteToModels_ConverterGetResultExpired_Propagationv0.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) {
// - 不再依賴 MCTokenClientflowStubMCToken 已整段刪除)
// - 對外仍由同一個 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. SpecialCharsuser_id / job_id 含特殊字元時 buildTargetObjectKey 正確 + filename 安全
// 3. OwnershipMismatch→ ErrJobNotFound
// 4. JobNotCompleted→ ErrJobNotCompleted
// 5. PromoteError_Propagationpromote 5xx 透傳
// 6. FAAError_Propagation取代 MCErrorFAA pull 失敗透傳
// 2. FilenameFromConverterJobfilename 取自 cj.SourceFilename + Platform
// 3. DefaultsContentTypeconverter 沒給 Content-Type 時 fallback application/octet-stream
// 4. OwnershipMismatch→ ErrJobNotFound
// 5. JobNotCompleted→ ErrJobNotCompleted
// 6. PromoteError_Propagationpromote 5xx 透傳
// 7. ConverterGetResultError_Propagationv0.6 取代 FAAError_Propagation
// 8. ConverterAuthFailed_Propagationv0.6 取代 FAAAuthFailed_Propagation
// 9. ConverterResultExpired_Propagationv0.6 新增410 result_expired
// 10. ConverterValidationFailed_Propagationv0.6 T3 / s-3 新增)
// 11. StorageError_StreamClosedv0.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.6visionA 端不再直接打 FAA
assert.Equal(t, int32(0), fix.faa.getCalls.Load(),
"v0.6DownloadStream 不該呼叫 faa.GetFile")
// v0.6 T3FAAClient interface + faa_client.go 已整檔砍除 → 編譯期保證「visionA 端不再
// 直接打 FAA」e2e mockFAA 端的 negative assertion 提供 wire 層 regression 防護
}
// TestDownloadStream_FilenameFromConverterJobfilename 取自 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_Propagationv0.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 / 其他 4xxconverter_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 leakflow.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_StreamClosedv0.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 而不是 == 1Go defer 行為下 stream.Close 應該被呼叫恰好 1 次,但容忍多次
// closeio.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 leakflow.go:635 defer 保護)")
}
// ==========================================================================
// helper functions tests
// ==========================================================================

View File

@ -1,13 +1,20 @@
// Package conversion 內部 utility helpers。
//
// 此檔收容跨檔共用的小型 helperlog truncate 等),原本散落在
// mc_token_client.goPhase 0.8b T3 砍 mc_token_client 整個檔後搬到此獨立檔,
// 避免 truncate 隨 mc_token_client.go 一起被砍(仍被 converter_client.go /
// faa_client.go 的 log error 拼接使用)。
// 此檔收容跨檔共用的小型 helperlog truncate / object key hash 等),原本散落在
// mc_token_client.go / faa_client.goPhase 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 hex64-bit對 visionA 內部 job 數量來說碰撞機率極低,足以追蹤單一 request
//
// Phase 0.8b v0.6T3原本定義在 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]
}