From 86b7175649dfdb62cb660c818ba08cf9fe91254c Mon Sep 17 00:00:00 2001 From: jim800121chen Date: Fri, 15 May 2026 09:45:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(visionA-backend):=20Phase=200.8b=20?= =?UTF-8?q?=E6=AD=A5=E9=A9=9F=202=20=E2=80=94=20visionA=20=E2=86=92=20conv?= =?UTF-8?q?erter=20/=20FAA=20=E6=94=B9=20API=20key=20=E8=AA=8D=E8=AD=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 對齊 ADR-015:visionA backend 從 OAuth client_credentials 改 pre-shared API key 服務間認證。Phase 0.8 stage e2e 撞 4 個 blocker(MC scope 沒註冊 / converter image 舊版 / converter 缺 env / FAA 不確定)後,使用者拍板 1:1 internal trust 用 OAuth 過度設計,改 API key。 實作(5 個增量 task,T1-T5 全綠 + Reviewer 5 輪 + final cross-task review): T1 config + env: - ConversionConfig 新增 ConverterAPIKey / FAAAPIKey 欄位 - Enabled() 改判定 4 欄位齊全(含兩個 API key) - .env*.example 移除 OIDC service client / OIDC tenant / FAA delegated TTL env、新增 API key env - TenantID / DelegatedTTLSeconds T1 暫留、T5 整批清 T2 client 改造: - converter_client / faa_client 移除 MCTokenClient 依賴 - 直接讀 cfg.Conversion.{Converter,FAA}APIKey、set Authorization: Bearer - NewConverterClient / NewFAAClient APIKey 為空時 panic(fail-fast,對齊 ADR-015 §3.5.3 #1) - 新增 ErrConverterAuthFailed / ErrFAAAuthFailed sentinel - 對外 mask 成 converter_unavailable / faa_unavailable(不洩漏 401 細節,對齊 ADR-015 §3.5.3 #3) T3 砍 mc_token_client: - mc_token_client.go (624 行) + mc_token_client_test.go (864 行) 整檔砍 - 砍 5 個僅 mc_token_client 用的 sentinel(ErrServiceClientUnauthorized / ErrMCTokenUnavailable / ErrIDPMisconfigured / ErrIDPUnavailable / ErrDownloadTokenFailed) - helper(truncate / silentLogger)搬到 util.go / testing_helpers_test.go T4 flow + handler stream proxy: - Service interface DownloadRedirectURL → DownloadStream(ctx) (io.ReadCloser, *DownloadMetadata, error) - flow.DownloadStream 用 faa.GetFile 直接 stream NEF binary(取代 MC delegated token + 302) - handler conversionDownloadHandler 改 io.Copy + Content-Type/Disposition/Cache-Control header - 新增 sanitizeDownloadFilename helper 防 HTTP header injection - 跨 package handler test (conversion_test.go) 改測 ErrFAAUnavailable + 補 *_AuthFailed 對稱 test T5 wire 切換 + cleanup: - main.go 砍 mcTokenClient wire、改 APIKey 注入、startup log 用 *_api_key_set boolean(不印 key) - ConverterClient/FAAClient struct Tokens 欄位移除 - mc_token_stub.go (T4 過渡期) 整檔砍 - ConversionConfig TenantID / DelegatedTTLSeconds 欄位移除 - e2e_test.go TestConversionE2E_Download302Redirect 改寫為 TestConversionE2E_DownloadStream - 補 MaxDownloadStreamBytes = 1 GiB size cap(io.CopyN,T4 reviewer Minor M-1) - escapeObjectKeyPath Phase 1+ 預留 godoc + //nolint:unused(T4 reviewer Minor M-2) - conversion.md §4.1 line 502 filename 來源描述歧義修訂(T4 reviewer Minor M-3,由 architect 處理) - conversion_e2e_test.go 檔頭 docstring 更新(final reviewer Minor #1) 驗證: - go build ./... exit 0 - go test -race -count=3 ./... 17 packages 全綠 - ADR-015 §6 砍除清單 100% cover(reviewer 獨立 grep 確認 MC chain / TenantID / DelegatedTTLSeconds 全清) - ADR-015 §3.5.3 部署檢查清單 visionA 範圍 4/4 達成(fail-fast / mask / 不印 token / placeholder env) 不動: - OIDCConfig.ServiceClientID/Secret 欄位保留(使用者拍板 backward compat) - user login OIDC 完全不動 下一步: - 步驟 4 — converter scheduler middleware 改 API key(jimchen 跨 repo,ADR-015 §3.5.1 Go snippet) - 步驟 5 — FAA middleware 改 API key(warrenchen 跨 repo,ADR-015 §3.5.2 C# snippet) - 步驟 6 — stage redeploy + e2e 完整測試 Co-Authored-By: Claude Opus 4.7 (1M context) --- .env.dev.example | 19 + .env.stage.example | 40 +- docs/autoflow/04-architecture/conversion.md | 8 +- visionA-backend/.env.example | 41 +- .../cmd/api-server/conversion_e2e_test.go | 339 +++---- visionA-backend/cmd/api-server/main.go | 53 +- visionA-backend/internal/api/conversion.go | 125 ++- .../internal/api/conversion_test.go | 230 ++++- visionA-backend/internal/config/config.go | 51 +- visionA-backend/internal/config/load.go | 15 +- visionA-backend/internal/config/load_test.go | 101 +- .../internal/conversion/conversion.go | 80 +- .../internal/conversion/conversion_test.go | 6 +- .../internal/conversion/converter_client.go | 159 ++-- .../conversion/converter_client_test.go | 295 +++--- visionA-backend/internal/conversion/errors.go | 95 +- .../internal/conversion/errors_test.go | 23 +- .../internal/conversion/faa_client.go | 109 ++- .../internal/conversion/faa_client_test.go | 184 ++-- visionA-backend/internal/conversion/flow.go | 158 ++-- .../internal/conversion/flow_test.go | 264 +++--- .../internal/conversion/mc_token_client.go | 624 ------------- .../conversion/mc_token_client_test.go | 864 ------------------ .../conversion/testing_helpers_test.go | 16 + visionA-backend/internal/conversion/util.go | 17 + 25 files changed, 1514 insertions(+), 2402 deletions(-) delete mode 100644 visionA-backend/internal/conversion/mc_token_client.go delete mode 100644 visionA-backend/internal/conversion/mc_token_client_test.go create mode 100644 visionA-backend/internal/conversion/testing_helpers_test.go create mode 100644 visionA-backend/internal/conversion/util.go diff --git a/.env.dev.example b/.env.dev.example index 911f26e..fe15287 100644 --- a/.env.dev.example +++ b/.env.dev.example @@ -30,6 +30,11 @@ MC_ADMIN_PASSWORD=Admin12345! VISIONA_OIDC_CLIENT_ID=CHANGE_ME VISIONA_OIDC_CLIENT_SECRET=CHANGE_ME +# Phase 0.8b 移除:VISIONA_OIDC_SERVICE_CLIENT_ID / _SECRET +# 服務間認證(visionA → converter / FAA)改 pre-shared API key(ADR-015); +# 不再走 OAuth client_credentials grant,所以 dev 也不需要 service client。 +# 取代設定見下方 Phase 0.8 / 0.8b conversion 區塊。 + # auth mode 切換:static(雛形預設)/ oidc(接 MC) VISIONA_AUTH_TYPE=static @@ -71,6 +76,20 @@ VISIONA_STORAGE_SIGNING_SECRET=dev-signing-secret-change-me-32-bytes VISIONA_PAIRING_TOKEN= +# ============================================================ +# Phase 0.8 / 0.8b — 轉檔功能整合(dev 通常不啟用,留空即可) +# ============================================================ +# 對齊 .autoflow/04-architecture/conversion.md §3 + ADR-015。 +# +# 4 個欄位全部非空才會啟用 conversion 模組;dev 全空 = sidebar tab 顯示但 endpoint 不註冊。 +# 若要在 dev 連 stage 的 converter / FAA 測整合,依 .env.stage.example 模板填入。 +# +# 產生 API key:openssl rand -hex 32 +# VISIONA_CONVERTER_BASE_URL=http://192.168.0.130:9501 +# VISIONA_FAA_BASE_URL=http://192.168.0.130:5081 +# VISIONA_CONVERTER_API_KEY= +# VISIONA_FAA_API_KEY= + # ============================================================ # 進階:port 衝突時可改 # ============================================================ diff --git a/.env.stage.example b/.env.stage.example index 16aa1e5..6d04085 100644 --- a/.env.stage.example +++ b/.env.stage.example @@ -26,11 +26,11 @@ VISIONA_OIDC_CLIENT_ID=b8093fea1a504a5d8f0e04bee9f78f2e # 留空 → backend 走 PKCE-only mode(A1 後支援;見 ADR-013) VISIONA_OIDC_CLIENT_SECRET= -# Service-to-service client(client_credentials grant) -# Phase 0.7 預留,不啟用;填入也不會被 main.go wire(見 config.go ServiceClientID 註解) -# ⚠️ 兩個值都禁止寫死進 git tracked 檔;只在 stage host 的 .env.stage 才填入真值 -VISIONA_OIDC_SERVICE_CLIENT_ID= -VISIONA_OIDC_SERVICE_CLIENT_SECRET= +# Phase 0.8b 移除:VISIONA_OIDC_SERVICE_CLIENT_ID / _SECRET +# 服務間認證從 OAuth client_credentials 改為 pre-shared API key(見 ADR-015); +# 兩個 service client env 不再讀取(OIDCConfig.ServiceClientID/Secret struct 欄位 +# 為了 backward compat 暫保留、但 conversion 模組不再依賴)。 +# 已洩漏的 stage service client secret 自此作廢,無 rotate 需求。 # Callback URL — 必須與 MC 端 client 設定的 redirect_uri 完全一致 VISIONA_OIDC_REDIRECT_URL=https://stage-9527.innovedus.com:9527/api/auth/callback @@ -115,12 +115,19 @@ VISIONA_SEED_DEMO_DATA=false # 留註解作為 audit trail;stage 部署不需設定 VISIONA_STATIC_USER_ID。 # ============================================================ -# Phase 0.8 — 轉檔功能整合(converter / FAA / MC service token) +# Phase 0.8 / 0.8b — 轉檔功能整合(converter / FAA pre-shared API key) # ============================================================ -# 對齊 .autoflow/04-architecture/conversion.md §5.3 +# 對齊 .autoflow/04-architecture/conversion.md §3 + ADR-015。 # -# 啟用判定:當 ConverterBaseURL 與 FAABaseURL 都非空,且 ServiceClientID/Secret 都非空時, -# main.go 才會 wire conversion.Service;任一缺 → 5 個 /api/conversion/* endpoint 回 501。 +# Phase 0.8b 變更:服務間認證從 OAuth client_credentials 改為 pre-shared API key。 +# +# 啟用判定:4 個欄位(ConverterBaseURL / FAABaseURL / ConverterAPIKey / FAAAPIKey) +# **全部非空**才視為啟用;任一缺 → 5 個 /api/conversion/* endpoint 不註冊。 +# +# Phase 0.8b 移除(不再讀取,也別再放進 .env.stage): +# - VISIONA_OIDC_SERVICE_CLIENT_ID / _SECRET(服務間認證取消 MC client_credentials) +# - VISIONA_CONVERSION_TENANT_ID / VISIONA_OIDC_TENANT_ID(取消 tenant 概念) +# - VISIONA_FAA_DELEGATED_TTL_SECONDS(取消 delegated download token 機制;改 server-side stream proxy) # kneron_model_converter task-scheduler base URL(stage 公司內網) VISIONA_CONVERTER_BASE_URL=http://192.168.0.130:9501 @@ -128,10 +135,13 @@ VISIONA_CONVERTER_BASE_URL=http://192.168.0.130:9501 # File Access Agent base URL VISIONA_FAA_BASE_URL=http://192.168.0.130:5081 -# 服務對服務 client(client_credentials grant,scope: converter:job.write/read + -# files:download.read/delegate)— stage 已配,不 rotate(測試環境) -VISIONA_OIDC_SERVICE_CLIENT_ID= -VISIONA_OIDC_SERVICE_CLIENT_SECRET= +# Pre-shared API key — visionA → converter 服務間認證(Phase 0.8b 新增;ADR-015 §3) +# **必須換掉**:openssl rand -hex 32 產生 64 字元 hex;與 converter 端 CONVERTER_API_KEY 對齊 +# 雙方獨立持有、不共用、嚴格分環境(dev / stage / prod 各自獨立 key) +# log 永遠不印此值全文;部署時用 AWS Secrets Manager / Vault 注入 +VISIONA_CONVERTER_API_KEY=CHANGE_ME_OPENSSL_RAND_HEX_32 -# Tenant ID(給 MC delegated download token request 用) -VISIONA_CONVERSION_TENANT_ID=visionA +# Pre-shared API key — visionA → FAA 服務間認證(Phase 0.8b 新增;ADR-015 §3) +# **必須換掉**:openssl rand -hex 32;與 FAA 端 FAA_API_KEY 對齊(warrenchen 配置) +# 與 ConverterAPIKey **不共用**(每條 trust boundary 各自獨立,避免一處洩漏連坐) +VISIONA_FAA_API_KEY=CHANGE_ME_OPENSSL_RAND_HEX_32 diff --git a/docs/autoflow/04-architecture/conversion.md b/docs/autoflow/04-architecture/conversion.md index 37ddd3e..ebf2b3f 100644 --- a/docs/autoflow/04-architecture/conversion.md +++ b/docs/autoflow/04-architecture/conversion.md @@ -498,7 +498,12 @@ func conversionDownloadHandler(deps Deps) gin.HandlerFunc { if meta.SizeBytes > 0 { c.Header("Content-Length", strconv.FormatInt(meta.SizeBytes, 10)) } - // 鼓勵 browser 觸發 save dialog(filename 來自 promote 結果) + // 鼓勵 browser 觸發 save dialog + // 注意:meta.Filename **不是** FAA metadata 直接給的(FAA 端的 object_key 是 + // `models//.nef` 對 user 不友善),而是 visionA backend 在 service 層 + // 由 `defaultDownloadFilename(cj)` 從 conversion job metadata 構造,規則: + // `_.nef`(例:`yolov5s_kl720.nef`), + // 對齊 wireframe success card 顯示範例(`yolov5s.onnx → yolov5s_kl720.nef`)。 c.Header("Content-Disposition", `attachment; filename="`+sanitizeFilename(meta.Filename)+`"`) c.Header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0") c.Status(http.StatusOK) @@ -1009,3 +1014,4 @@ frontend 用 `` 觸發時,若失敗 browser 會把錯誤頁顯示在 | 2026-04-30 | 0.2 | Download flow 改為 server-side HTTP 302 redirect:endpoint 從 `POST /{job}/download-token` 改為 `GET /{job}/download`、Service interface `DownloadToken` → `DownloadRedirectURL`、`DownloadGrant` 改為 mc_token_client 內部 struct(不對外 JSON)、補 §3.1 handler 範例、補 §10.4 token 不過 frontend JS 的安全分析、§6 補 `/download` 錯誤回應策略 | | 2026-04-30 | 0.3 | Phase 0.8 三方交叉審閱回饋整合:§2.6.1 補「visionA-backend 重啟後 lazy rebuild ownership」(議題 #2,A4 方案);§2.6.2 補 expires_at 來源(議題 #7);§4.3.1 streaming proxy 進度語意明確化(議題 #6,採選項 A:等 converter 201 才回 200);§4.3.2 補 cancel cleanup 鏈與 best-effort cancel converter(議題 #5) | | 2026-05-11 | 0.4 | **Phase 0.8b**:服務間認證從 OAuth `client_credentials` 改為 pre-shared API key(對應 [ADR-015](./adr/adr-015-server-to-server-api-key.md))。主要變更:(1) §1 端對端 sequence 拿掉 MC node;(2) §2 砍 `mc_token_client.go` 整個檔;(3) §3 新增「服務間認證(API key)」章節(原 §5 OAuth 章節整段刪除,章節編號 4→5);(4) §4.1 `/download` handler 從 `c.Redirect(302)` 改 server-side stream proxy(Service interface `DownloadRedirectURL` → `DownloadStream`);(5) §6 錯誤碼 mapping 移除 MC 4 個 code、新增 `converter_auth_failed` / `faa_auth_failed`;(6) §9.1 retry 矩陣移除 MC 2 row、所有下游 401/403 不重試;(7) §10.2 刪除 delegated token TTL、§10.3 改為 pre-shared API key 保護、§10.4 改為 server-side stream proxy 安全模型;(8) 變更影響清單列出 backend agent 後續實作要動的 .go 檔。OIDC user login 完全不動。 | +| 2026-05-15 | 0.4.1 | 修 §4.1 `/download` handler `Content-Disposition` filename 來源描述歧義(T4 Reviewer M-3)— 原註釋「filename 來自 promote 結果」可被誤讀為「FAA promote response 直接給 filename」;改為明確標示「visionA backend 在 service 層由 `defaultDownloadFilename(cj)` 從 conversion job metadata 構造(規則 `_.nef`),對齊 wireframe success card 顯示範例」、並補充「FAA 端的 object_key 是 `models//.nef` 對 user 不友善」的對比說明。純文字釐清、無實作行為變更。 | diff --git a/visionA-backend/.env.example b/visionA-backend/.env.example index 8ff7420..9195dec 100644 --- a/visionA-backend/.env.example +++ b/visionA-backend/.env.example @@ -76,12 +76,11 @@ VISIONA_OIDC_REDIRECT_URL=http://localhost:3721/api/auth/callback # prod: https://app.visiona.cloud VISIONA_FRONTEND_URL=http://localhost:3000 -# Service client(client_credentials grant)— A1 預留欄位,**目前不啟用**。 -# 將來 visionA-backend 需以服務身份呼叫 MC API 時(例如查詢使用者組織、推送通知) -# 才會接這條路。留空代表「不啟用」,main.go 不會 wire。 -# 對應 Stage 的 service client: -VISIONA_OIDC_SERVICE_CLIENT_ID= -VISIONA_OIDC_SERVICE_CLIENT_SECRET= +# Phase 0.8b 移除:VISIONA_OIDC_SERVICE_CLIENT_ID / _SECRET +# 服務間認證從 OAuth client_credentials 改為 pre-shared API key(見 ADR-015、conversion.md §3)。 +# 兩個 service client env 不再讀取(OIDCConfig.ServiceClientID/Secret struct 欄位 +# 為了 backward compat 暫保留、但 conversion 模組不再依賴)。 +# 取代設定見下方 Phase 0.8 / 0.8b 區塊的 VISIONA_CONVERTER_API_KEY / VISIONA_FAA_API_KEY。 # Cookie HMAC 簽章 secret(⚠️ 至少 32 byte 隨機字串;prod 用 openssl rand -hex 32) VISIONA_SESSION_SECRET=CHANGE_ME_TO_RANDOM_64_BYTES_in_production @@ -158,14 +157,19 @@ VISIONA_PAIRING_TOKEN= # ============================================================ -# Phase 0.8 — 轉檔功能整合(converter / FAA / Member Center service token) +# Phase 0.8 / 0.8b — 轉檔功能整合(converter / FAA pre-shared API key) # ============================================================ -# 對齊 .autoflow/04-architecture/conversion.md §5.3 +# 對齊 .autoflow/04-architecture/conversion.md §3 + ADR-015。 # -# 啟用判定:當 VISIONA_CONVERTER_BASE_URL 與 VISIONA_FAA_BASE_URL 都非空時, -# main.go 才會 wire conversion.Service;其中之一留空 → 5 個 /api/conversion/* endpoint 回 501。 +# Phase 0.8b 變更:服務間認證從 OAuth client_credentials 改為 pre-shared API key。 # -# 啟用時 VISIONA_OIDC_SERVICE_CLIENT_ID/SECRET 必須非空(轉檔依賴 service token 機制)。 +# 啟用判定:4 個欄位(ConverterBaseURL / FAABaseURL / ConverterAPIKey / FAAAPIKey) +# **全部非空**才視為啟用;任一缺 → 5 個 /api/conversion/* endpoint 不註冊 / 回 501。 +# +# Phase 0.8b 移除(不再讀取): +# - VISIONA_OIDC_SERVICE_CLIENT_ID / _SECRET(OAuth client_credentials 機制取消) +# - VISIONA_OIDC_TENANT_ID(取消 tenant 概念,converter 端的 user_id 仍由 visionA 灌入) +# - VISIONA_FAA_DELEGATED_TTL_SECONDS(delegated download token 機制取消,改 server-side stream proxy) # kneron_model_converter task-scheduler base URL # dev/stage:http://192.168.0.130:9501 @@ -177,13 +181,16 @@ VISIONA_CONVERTER_BASE_URL= # prod:https://faa.innovedus.com VISIONA_FAA_BASE_URL= -# visionA 在 Member Center 的 tenant id(單一 tenant) -# 跟 MC 換 delegated download token 時當 tenant_id 欄位用 -VISIONA_OIDC_TENANT_ID= +# Pre-shared API key — visionA → converter 服務間認證(Phase 0.8b 新增;ADR-015 §3) +# 產生:openssl rand -hex 32(64 字元 hex) +# 與 converter 端 CONVERTER_API_KEY env 對齊(雙方獨立持有,嚴格分環境 dev / stage / prod) +# ⚠️ 不可 commit;prod 用 Secrets Manager / Vault;log 永遠不印此值全文 +VISIONA_CONVERTER_API_KEY= -# Delegated download token TTL(秒)— FAA 直連下載用 -# 預設 300(5 分鐘),可調整範圍 60-900 -VISIONA_FAA_DELEGATED_TTL_SECONDS=300 +# Pre-shared API key — visionA → FAA 服務間認證(Phase 0.8b 新增;ADR-015 §3) +# 產生方式同上;與 FAA 端 FAA_API_KEY env 對齊(warrenchen 配置) +# 與 ConverterAPIKey **不共用**(每條 trust boundary 各自獨立,避免一處洩漏連坐) +VISIONA_FAA_API_KEY= # 上傳模型檔大小上限(MB)— 與 converter 端 limit 對齊 VISIONA_CONVERTER_MAX_MODEL_SIZE_MB=500 diff --git a/visionA-backend/cmd/api-server/conversion_e2e_test.go b/visionA-backend/cmd/api-server/conversion_e2e_test.go index 507eb37..f985178 100644 --- a/visionA-backend/cmd/api-server/conversion_e2e_test.go +++ b/visionA-backend/cmd/api-server/conversion_e2e_test.go @@ -1,6 +1,7 @@ -// conversion_e2e_test.go — Phase 0.8 conversion 整合 e2e 測試。 +// conversion_e2e_test.go — Phase 0.8 / Phase 0.8b conversion 整合 e2e 測試。 // -// 涵蓋 4 個必含場景(對齊 .autoflow/05-implementation/phase-0.8-T8.md 範圍): +// 涵蓋 4 個必含場景(原始範圍對齊 .autoflow/05-implementation/phase-0.8-T8.md, +// Phase 0.8b T5 將場景 3 從「302 redirect」改為「server-side stream proxy」): // // 1. Streaming proxy 完整跑通 // —— 驗 InitJob 真的 streaming(不 buffer 整個 multipart body); @@ -10,9 +11,12 @@ // —— 模擬 visionA backend 剛啟動 ownership 全空 + converter 端 user X 有 in_progress job; // 驗 user 對 GET /active 觸發 lazy rebuild,且後續 GET /active 走 cache 不再打 ListInProgressJobs。 // -// 3. Download 302 redirect -// —— 驗 server-side 302 + Cache-Control: no-store + Location 帶 token; -// 驗 response body 不含 token;驗 redirect URL 指向 mock FAA。 +// 3. Download server-side stream proxy(Phase 0.8b 改造,原 302 redirect 已廢) +// —— 驗 visionA backend 用 Bearer 拉 mock FAA、stream NEF binary 回 browser; +// 驗 response 200 + Content-Type: application/octet-stream + Content-Disposition: attachment + +// Cache-Control: no-store + body bytes 與 mock FAA 寫的 NEF binary byte-perfect 一致; +// 驗結構性無 302 / Location header(API key 模式不再走 delegated download)。 +// 對齊 ADR-015 §7 + conversion.md §4.1 + api-conversion.md §4。 // // 4. Active job 409 衝突 // —— 同 user 第一個 init 成功 → 第二個 init 撞 409 + body 帶 active_job 詳情。 @@ -21,10 +25,12 @@ // // 既有 setupFixture(integration_test.go)是 B4/B5 的雛形(不含 conversion service); // T7 main.go 在 wire 時才 build conversion service。本檔保持 T1-T7 既有 code 不動, -// 自己組一個 conversion 專用 fixture:fakeOIDC + apiServer + 3 個 mock servers -// (converter / MC service token + delegated / FAA),完整模擬端到端。 +// 自己組一個 conversion 專用 fixture:fakeOIDC + apiServer + 2 個 mock servers +// (converter / FAA),完整模擬端到端。Phase 0.8b 取消 MC service token + delegated mock +// (API key 模式不依賴 MC OAuth),fixture 從 3 個 mock 收斂到 2 個。 // -// Phase 0.8 conversion e2e (見 .autoflow/04-architecture/conversion.md) +// Phase 0.8 conversion e2e (見 docs/autoflow/04-architecture/conversion.md +// + adr/adr-015-server-to-server-api-key.md) package main import ( @@ -43,6 +49,7 @@ import ( "net/http/httptest" "net/url" "os" + "strconv" "strings" "sync" "sync/atomic" @@ -420,77 +427,76 @@ func converterJobToMap(j *conversion.ConverterJob) map[string]any { } // ========================================================================== -// mockMC — 服務 service token (/oauth/token) + delegated download (/file-access/download-tokens) +// Phase 0.8b T5:mockMC 已整段移除 // ========================================================================== - -type mockMC struct { - srv *httptest.Server - - serviceTokenCount atomic.Int32 - delegatedTokenCount atomic.Int32 - - // 紀錄上一次發出的 delegated token(給場景 #3 驗 location 帶到) - lastDelegatedToken string - mu sync.Mutex -} - -func newMockMC(t *testing.T) *mockMC { - t.Helper() - mc := &mockMC{} - mux := http.NewServeMux() - mux.HandleFunc("/oauth/token", mc.handleServiceToken) - mux.HandleFunc("/file-access/download-tokens", mc.handleDelegated) - mc.srv = httptest.NewServer(mux) - t.Cleanup(mc.srv.Close) - return mc -} - -// handleServiceToken:client_credentials grant 永遠回 200 + access_token + expires_in。 -func (m *mockMC) handleServiceToken(w http.ResponseWriter, r *http.Request) { - m.serviceTokenCount.Add(1) - if r.Method != http.MethodPost { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - writeJSON(w, http.StatusOK, map[string]any{ - "access_token": "mock-service-token-" + randHex(8), - "token_type": "Bearer", - "expires_in": 3600, - "scope": r.FormValue("scope"), - }) -} - -// handleDelegated:簽 opaque token + 預設 5 分鐘過期。 -func (m *mockMC) handleDelegated(w http.ResponseWriter, r *http.Request) { - m.delegatedTokenCount.Add(1) - tok := "delegated-" + randHex(16) - m.mu.Lock() - m.lastDelegatedToken = tok - m.mu.Unlock() - writeJSON(w, http.StatusOK, map[string]any{ - "token": tok, - "expires_at": time.Now().Add(5 * time.Minute).UTC().Format(time.RFC3339), - }) -} +// +// 原 mockMC 提供 OAuth `client_credentials` service token 與 MC delegated download token; +// Phase 0.8b 改 pre-shared API key 後(ADR-015 §3 / §6 / §7),visionA 完全不再呼叫 MC, +// e2e fixture 不需要 mock MC server。 +// +// 若未來 Phase 1+ 採 ADR-015 §7 選項 B(visionA 自簽 HMAC token + 302 redirect), +// 也只需 visionA 端有 HMAC_KEY;不需要 mock MC 端。 // ========================================================================== -// mockFAA — 純 placeholder,本檔測 download 用 CheckRedirect 卡住 302、不 follow 到 FAA。 +// mockFAA — Phase 0.8b:模擬 FAA `GET /files/{key}` 回 NEF binary stream +// (配合 download e2e 從 302 redirect 改 server-side stream proxy 模式)。 // ========================================================================== 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 } func newMockFAA(t *testing.T) *mockFAA { t.Helper() - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - // e2e 不會真的 follow 到這(test client 設 ErrUseLastResponse), - // 留 200 OK 當保險(避免假設外部 mock 必返錯誤)。 + m := &mockFAA{} + m.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // 只處理 GET /files/...(對齊 FAA API spec) + 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.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte("mock-nef-bytes")) + _, _ = w.Write(payload) })) - t.Cleanup(srv.Close) - return &mockFAA{srv: srv} + 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 } // ========================================================================== @@ -501,9 +507,10 @@ type conversionFixture struct { server *httptest.Server // visionA backend fakeOIDC *oidctest.Server // 給 user 走 OIDC cookie session 登入用 conv *mockConverter - mc *mockMC faa *mockFAA + // Phase 0.8b T5:mc *mockMC 已從 fixture 移除(服務間認證改 API key、不再依賴 MC)。 + // 重啟模擬:場景 #2 需要在 instance A 不註冊 ownership 直接 instance B 起, // 所以保留 lazy 把 conversion service rebuild 進新 router 的 hook。 router *gin.Engine @@ -517,11 +524,14 @@ func (f *conversionFixture) Close() { } // setupConversionFixture 建立完整的 e2e 環境: -// - mock converter / MC service token + delegated / FAA +// - mock converter / FAA(Phase 0.8b 後不再 wire mock MC — API key 模式) // - fake OIDC(給 user 走 cookie session 登入) -// - visionA-backend router(含 conversion service wired,仿 T7 main.go wire 邏輯) +// - 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。 func setupConversionFixture(t *testing.T) *conversionFixture { t.Helper() @@ -530,7 +540,6 @@ func setupConversionFixture(t *testing.T) *conversionFixture { logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn})) conv := newMockConverter(t) - mc := newMockMC(t) faa := newMockFAA(t) fakeOIDC := oidctest.NewServer(t, oidctest.WithClientCredentials(fixtureOIDCClientID, fixtureOIDCClientSecret), @@ -567,28 +576,28 @@ func setupConversionFixture(t *testing.T) *conversionFixture { SigningKey: []byte(fixtureSessionSecret), }) - // === 組 conversion service(模擬 main.go T7 wire 邏輯;mocks 替換真實 endpoint) === + // === 組 conversion service(模擬 main.go wire 邏輯;mocks 替換真實 endpoint) === // - // 注意:mc_token_client / converter_client / faa_client 都用 100ms timeout HTTPClient - // 避免測試卡死;對 mock servers 來說連線秒回,timeout 不會觸發。 + // 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 / faa_client 都用 5s timeout HTTPClient 避免測試卡死; + // 對 mock servers 來說連線秒回,timeout 不會觸發。 fastHTTP := &http.Client{Timeout: 5 * time.Second} - mcTokenClient := conversion.NewMCTokenClient(conversion.MCTokenClientOpts{ - Issuer: mc.srv.URL, - ClientID: "visiona-service-client", - ClientSecret: "visiona-service-secret", - HTTPClient: fastHTTP, - Logger: logger, - }) + 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, - Tokens: mcTokenClient, + APIKey: fixtureConverterAPIKey, HTTPClient: fastHTTP, InitHTTPClient: &http.Client{Timeout: 60 * time.Second}, // 場景 #1 大 body 給寬一點 Logger: logger, }) faaAPIClient := conversion.NewFAAClient(conversion.FAAClientOpts{ BaseURL: faa.srv.URL, - Tokens: mcTokenClient, + APIKey: fixtureFAAAPIKey, HTTPClient: fastHTTP, Logger: logger, }) @@ -599,16 +608,12 @@ func setupConversionFixture(t *testing.T) *conversionFixture { storageAdapter := newConversionStorageAdapter(storeStore) conversionService, err := conversion.NewService(conversion.FlowOpts{ - Converter: converterAPIClient, - FAA: faaAPIClient, - MCToken: mcTokenClient, - Ownership: ownership, - ModelStore: modelStoreAdapter, - Storage: storageAdapter, - TenantID: "tenant-visiona", - FAABaseURL: faa.srv.URL, - DelegatedTTLSeconds: 300, - Logger: logger, + Converter: converterAPIClient, + FAA: faaAPIClient, + Ownership: ownership, + ModelStore: modelStoreAdapter, + Storage: storageAdapter, + Logger: logger, }) require.NoError(t, err) @@ -641,7 +646,6 @@ func setupConversionFixture(t *testing.T) *conversionFixture { server: apiTS, fakeOIDC: fakeOIDC, conv: conv, - mc: mc, faa: faa, router: router, } @@ -846,36 +850,48 @@ func TestConversionE2E_LazyRebuildAfterRestart(t *testing.T) { } // ========================================================================== -// E2E #3:Download 302 redirect +// E2E #3:Download server-side stream proxy(Phase 0.8b) // ========================================================================== -// TestConversionE2E_Download302Redirect 驗: +// TestConversionE2E_DownloadStream 驗 Phase 0.8b 後 download 端對端行為: // -// 1. user X 對 completed job 打 /download → status 302 Found -// 2. Location header 是 /files/?access_token= -// 3. Cache-Control: no-store, no-cache, ... -// 4. response body 不含 token 字串(grep response body 找不到 token) -// 5. 不是 c.JSON 回 download_url(純 redirect — content-type 不是 application/json) +// 1. user X 對 completed job 打 /download → status 200 OK +// 2. response header: +// - Content-Type: application/octet-stream +// - Content-Disposition: attachment; filename="..."(filename 經 sanitize) +// - Cache-Control: no-store, no-cache, must-revalidate, max-age=0 +// 3. response body bytes 與 mock FAA 寫的 binary 一致(byte-perfect) +// 4. mock FAA 收到 visionA 帶的 Authorization: Bearer +// (驗 visionA 端真的用 API key wire 對下游發 request) +// 5. **沒有** 302 / Location header / token 結構性流經 frontend +// (Phase 0.8b 設計核心:server-side proxy 取代 delegated token redirect) +// +// 對齊 api-conversion.md §4 (Phase 0.8b) + conversion.md §4.1 + ADR-015 §7。 // // 流程: -// - 起 fixture -// - user X 透過 init 建一個 job(mock 會自動建 running job) -// - 把 mock converter 端 job 改成 completed -// - 對 /download 打 — client 設 ErrUseLastResponse 不 follow redirect -func TestConversionE2E_Download302Redirect(t *testing.T) { +// - 起 fixture(mock FAA 預設回 small NEF binary marker) +// - user X init 一個 job → mock converter 自動建 running job +// - markJobCompleted(jobID) 把 mock job 推進 completed +// - 對 /download 打 GET — client 設 ErrUseLastResponse 防止意外 follow(雖預期非 302) +// - 驗以上 5 點 +func TestConversionE2E_DownloadStream(t *testing.T) { f := setupConversionFixture(t) defer f.Close() + // 設定 mock FAA 回的 NEF binary(測試端控制 byte-perfect 比對) + const wantNEFContent = "PHASE-0.8b-MOCK-NEF-BINARY-PAYLOAD-FROM-FAA-STREAM-1234567890" + f.faa.setNEFPayload([]byte(wantNEFContent)) + const wantSub = "user-download-003" client := f.AuthenticatedClient(t, wantSub, "download@e2e.local") - // 1. 先 init 一個 job(讓 visionA 端寫 ownership) + // 1. 先 init 一個 job(讓 visionA 端寫 ownership + mock converter 建 running job) jobID := initSimpleJob(t, client, f.server.URL) // 2. 把 mock converter 端 job 推進到 completed(給 download 用) f.conv.markJobCompleted(jobID) - // 3. 用「不 follow redirect」的 client 對 /download 打 + // 3. 對 /download 打 GET — 設 ErrUseLastResponse 防意外 follow(預期非 302) noRedirectClient := &http.Client{ Jar: client.Jar, Timeout: 10 * time.Second, @@ -888,66 +904,57 @@ func TestConversionE2E_Download302Redirect(t *testing.T) { require.NoError(t, err) defer resp.Body.Close() - bodyBytes, _ := io.ReadAll(resp.Body) - - // 驗 status 302 - require.Equal(t, http.StatusFound, resp.StatusCode, "body=%s", string(bodyBytes)) - - // 驗 Location 指向 mock FAA + 帶 access_token - location := resp.Header.Get("Location") - require.NotEmpty(t, location) - require.True(t, strings.HasPrefix(location, f.faa.srv.URL+"/files/"), - "Location 應指向 mock FAA /files/,得 %s", location) - require.Contains(t, location, "access_token=", "Location 應帶 access_token query") - - // 驗 token 真的在 query 中且 == mock 端發出的 token - parsed, err := url.Parse(location) + bodyBytes, err := io.ReadAll(resp.Body) require.NoError(t, err) - gotToken := parsed.Query().Get("access_token") - require.NotEmpty(t, gotToken) - f.mc.mu.Lock() - wantToken := f.mc.lastDelegatedToken - f.mc.mu.Unlock() - require.Equal(t, wantToken, gotToken, - "Location 帶的 access_token 應 == mock MC 簽發的 token") - // 驗 Cache-Control: no-store + // === 斷言 1:status 200 OK(Phase 0.8b 不再回 302)=== + require.Equal(t, http.StatusOK, resp.StatusCode, + "Phase 0.8b 後 /download 回 200(server-side stream proxy),不再 302;body=%s", + string(bodyBytes)) + + // === 斷言 2:response header 對齊 api-conversion.md §4 === + assert.Equal(t, "application/octet-stream", resp.Header.Get("Content-Type"), + "Content-Type 應為 application/octet-stream(觸發 browser download dialog)") + + cd := resp.Header.Get("Content-Disposition") + assert.True(t, strings.HasPrefix(cd, "attachment; filename="), + "Content-Disposition 應為 attachment; filename=...,得 %s", cd) + // filename 應已被 sanitize(不含控制字元 / path sep / quote) + assert.NotContains(t, cd, "\r", "Content-Disposition 不應含 \\r(CRLF injection 防護)") + assert.NotContains(t, cd, "\n", "Content-Disposition 不應含 \\n(CRLF injection 防護)") + // filename 應對齊 wireframe §8.1:_.nef + // (mock converter 建的 job source_filename=yolov5s.onnx + platform=720 → yolov5s_kl720.nef) + assert.Contains(t, cd, ".nef", + "filename 應以 .nef 結尾(NEF 結果檔),得 %s", cd) + cc := resp.Header.Get("Cache-Control") - require.Contains(t, cc, "no-store", "Cache-Control 應含 no-store,得 %s", cc) + assert.Contains(t, cc, "no-store", + "Cache-Control 應含 no-store(避免 browser cache private NEF)") + assert.Contains(t, cc, "no-cache", + "Cache-Control 應含 no-cache,得 %s", cc) + assert.Contains(t, cc, "must-revalidate", + "Cache-Control 應含 must-revalidate,得 %s", cc) - // 驗 response 不是用 c.JSON 回(純 server-side 302,§10.4 token 不過 frontend JS) - // - // 行為說明(不要過度斷言「整個 body 不能含 token」): - // net/http.Redirect 的標準 fallback 會在 HTML body 塞一個 Found - // 讓不支援 302 的 user-agent 還能手動點。**這是 net/http 的標準行為,不是 visionA - // 把 token 寫進 JSON 給 frontend JS**。token 仍只活在 Location header 與 HTML - // anchor 中,frontend JS 沒辦法用通常的 fetch().json() 讀到(需要 parse HTML)。 - // - // §10.4 安全聲明的精神是: - // - 不用 c.JSON 回 download_url(避免 JS 直接 .data.download_url 拿到 token) - // - Cache-Control: no-store 避免 browser 把 Location 寫 disk cache - // 兩者本檔都驗。 - // - // 因此這裡的斷言改為: - // 1. content-type 不是 application/json(沒用 c.JSON) - // 2. body 不是 visionA 的 success envelope({success: true, data: {download_url: ...}}) - bodyStr := string(bodyBytes) - ct := resp.Header.Get("Content-Type") - if ct != "" { - assert.NotContains(t, strings.ToLower(ct), "application/json", - "302 response 不應為 JSON(用了 c.JSON 而非 c.Redirect);得 content-type=%s", ct) - } - // 確認 body 不是「visionA 包好的 JSON envelope 帶 download_url」 - // (這正是 §10.4 不要的形式) - var maybeEnvelope map[string]any - if json.Unmarshal(bodyBytes, &maybeEnvelope) == nil { - if data, ok := maybeEnvelope["data"].(map[string]any); ok { - _, hasURL := data["download_url"] - assert.False(t, hasURL, - "response body 不應為 {success:..., data:{download_url:...}} 形式(用了 c.JSON);body=%s", - bodyStr) - } - } + // === 斷言 3:response body byte-perfect 對齊 mock FAA 寫的 binary === + assert.Equal(t, wantNEFContent, string(bodyBytes), + "response body 應等於 mock FAA 寫的 NEF binary(byte-perfect stream proxy)") + + // === 斷言 4:mock FAA 收到 visionA 帶的 Authorization Bearer === + authHeader := f.faa.getLastAuthHeader() + assert.True(t, strings.HasPrefix(authHeader, "Bearer "), + "mock FAA 應收到 Bearer 開頭的 Authorization header,得 %q", authHeader) + assert.Contains(t, authHeader, "fixture-faa-api-key-do-not-use-in-prod", + "mock FAA 應收到 fixture FAA API key(驗 visionA 端 wire 正確)") + + // === 斷言 5:沒有 302 / Location header(Phase 0.8b 結構性無 redirect)=== + assert.NotEqual(t, http.StatusFound, resp.StatusCode, + "Phase 0.8b 不再回 302 Found(取消 delegated token 機制)") + assert.Empty(t, resp.Header.Get("Location"), + "Phase 0.8b 不應有 Location header(無 redirect 流程)") + + // 驗 mock FAA 真的被打到(防 mock 路徑 wire 錯) + assert.GreaterOrEqual(t, int(f.faa.getCallCount.Load()), 1, + "mock FAA GET /files 應至少被打一次") } // ========================================================================== @@ -1135,9 +1142,3 @@ func writeJSON(w http.ResponseWriter, status int, v any) { _ = json.NewEncoder(w).Encode(v) } -// randHex 產 n bytes random hex,給 mock token 用。 -func randHex(n int) string { - b := make([]byte, n) - _, _ = rand.Read(b) - return fmt.Sprintf("%x", b) -} diff --git a/visionA-backend/cmd/api-server/main.go b/visionA-backend/cmd/api-server/main.go index 0c616f2..2262d3d 100644 --- a/visionA-backend/cmd/api-server/main.go +++ b/visionA-backend/cmd/api-server/main.go @@ -137,35 +137,29 @@ func main() { // ===== Converter(stub,Phase 2 才實作) ===== converterClient := converter.NewStubClient() - // ===== Phase 0.8 Conversion(轉檔功能整合) ===== - // 對齊 .autoflow/04-architecture/conversion.md。 + // ===== Phase 0.8 / 0.8b Conversion(轉檔功能整合) ===== + // 對齊 .autoflow/04-architecture/conversion.md、ADR-015。 // - // 啟用條件:cfg.Conversion.Enabled() — ConverterBaseURL + FAABaseURL 都非空。 - // 啟用時必須有 ServiceClientID/Secret(client_credentials grant 必要)。 + // 啟用條件:cfg.Conversion.Enabled() — + // ConverterBaseURL + FAABaseURL + ConverterAPIKey + FAAAPIKey 全部非空。 // 不啟用時 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 變更影響清單。 var conversionService conversion.Service if cfg.Conversion.Enabled() { - // service token 機制依賴 ServiceClientID/Secret — 沒設就 fatal,避免半設定狀態 - if cfg.OIDC.ServiceClientID == "" || cfg.OIDC.ServiceClientSecret == "" { - log.Error("conversion enabled but service client credentials missing", - "hint", "set VISIONA_OIDC_SERVICE_CLIENT_ID + VISIONA_OIDC_SERVICE_CLIENT_SECRET, or unset CONVERTER/FAA base URL to disable") - os.Exit(1) - } + // 不再檢查 ServiceClientID/Secret —— Phase 0.8b 起 conversion 不依賴 OIDC service client。 + // (OIDCConfig.ServiceClientID/Secret 兩欄位仍保留供 backward compat,但非 conversion 必需。) - mcTokenClient := conversion.NewMCTokenClient(conversion.MCTokenClientOpts{ - Issuer: cfg.OIDC.IssuerURL, - ClientID: cfg.OIDC.ServiceClientID, - ClientSecret: cfg.OIDC.ServiceClientSecret, - Logger: log, - }) converterAPIClient := conversion.NewConverterClient(conversion.ConverterClientOpts{ BaseURL: cfg.Conversion.ConverterBaseURL, - Tokens: mcTokenClient, + APIKey: cfg.Conversion.ConverterAPIKey, Logger: log, }) faaAPIClient := conversion.NewFAAClient(conversion.FAAClientOpts{ BaseURL: cfg.Conversion.FAABaseURL, - Tokens: mcTokenClient, + APIKey: cfg.Conversion.FAAAPIKey, Logger: log, }) ownership := conversion.NewOwnership(converterAPIClient, log) @@ -176,16 +170,12 @@ func main() { var convErr error conversionService, convErr = conversion.NewService(conversion.FlowOpts{ - Converter: converterAPIClient, - FAA: faaAPIClient, - MCToken: mcTokenClient, - Ownership: ownership, - ModelStore: modelStoreAdapter, - Storage: storageAdapter, - TenantID: cfg.Conversion.TenantID, - FAABaseURL: cfg.Conversion.FAABaseURL, - DelegatedTTLSeconds: cfg.Conversion.DelegatedTTLSeconds, - Logger: log, + Converter: converterAPIClient, + FAA: faaAPIClient, + Ownership: ownership, + ModelStore: modelStoreAdapter, + Storage: storageAdapter, + Logger: log, }) if convErr != nil { log.Error("failed to init conversion service", "error", convErr) @@ -194,10 +184,11 @@ func main() { log.Info("conversion service initialized", "converter_base_url", cfg.Conversion.ConverterBaseURL, "faa_base_url", cfg.Conversion.FAABaseURL, - "tenant_id", cfg.Conversion.TenantID, - "delegated_ttl_sec", cfg.Conversion.DelegatedTTLSeconds) + // 安全:絕不印 key 全文 — 對齊 ADR-015 §3.5.3 部署檢查清單 #4 + "converter_api_key_set", cfg.Conversion.ConverterAPIKey != "", + "faa_api_key_set", cfg.Conversion.FAAAPIKey != "") } else { - log.Info("conversion service disabled (set VISIONA_CONVERTER_BASE_URL + VISIONA_FAA_BASE_URL to enable)") + log.Info("conversion service disabled (set VISIONA_CONVERTER_BASE_URL + VISIONA_FAA_BASE_URL + VISIONA_CONVERTER_API_KEY + VISIONA_FAA_API_KEY to enable)") } // ===== Seed demo data(可選) ===== diff --git a/visionA-backend/internal/api/conversion.go b/visionA-backend/internal/api/conversion.go index d7221ff..4484028 100644 --- a/visionA-backend/internal/api/conversion.go +++ b/visionA-backend/internal/api/conversion.go @@ -11,16 +11,18 @@ // GET /api/conversion/active — 查當前 active job // GET /api/conversion/{job_id} — poll 狀態 // POST /api/conversion/{job_id}/promote-to-models — 加到模型庫 -// GET /api/conversion/{job_id}/download — server-side 302 redirect → FAA +// GET /api/conversion/{job_id}/download — server-side stream proxy(Phase 0.8b) // // 安全要點(對齊 conversion.md §7 / §10): // - 全部 5 個 endpoint 都註冊在 apiGroup(OIDC AuthMiddleware 之後) // - userID 一律來自 UserContextFrom(c).UserID(從 cookie session 解出 OIDC sub) // - 任何 client 帶來的 user_id(multipart form / JSON / query)一律忽略 // - /init 不呼叫 c.MultipartForm() — 會 buffer 全 body 進 RAM / disk,破壞 streaming -// - /download 採 HTTP 302 Found;token 不出現在任何 JSON response(§10.4) +// - /download Phase 0.8b 改 server-side stream proxy(visionA backend 中轉 NEF stream); +// 沒有 delegated token 結構性流經 frontend(ADR-015 §7 / conversion.md §4.1 / §10.4) // // Phase 0.8 conversion (見 .autoflow/04-architecture/api/api-conversion.md) +// Phase 0.8b /download proxy 改造 (見 ADR-015 + conversion.md §4.1) package api @@ -28,7 +30,9 @@ import ( "context" "encoding/json" "errors" + "io" "net/http" + "strconv" "strings" "github.com/gin-gonic/gin" @@ -259,14 +263,22 @@ func conversionPromoteHandler(deps Deps) gin.HandlerFunc { // 5. GET /api/conversion/{job_id}/download // ========================================================================== -// conversionDownloadHandler 處理「下載」請求 — server-side HTTP 302 redirect。 +// conversionDownloadHandler 處理「下載」請求 — Phase 0.8b:server-side stream proxy。 // -// 對齊 api-conversion.md §4 + conversion.md §3.1 / §10.4: -// - 成功:302 Found + Location: -// - 失敗:不 redirect,依 Accept header 回 JSON / HTML 錯誤 -// - Cache-Control: no-store — token 不該被 browser cache(即使是 302 Location) +// 對齊 api-conversion.md §4 (Phase 0.8b 變更) + conversion.md §4.1 / §10.4 + ADR-015 §7: +// - 成功:200 OK + Content-Type/Length/Disposition + NEF binary streaming body +// - 失敗:不寫 200,依 sentinel 走 handleConversionError 回 JSON +// - Cache-Control: no-store — 避免 browser 對私有檔案 cache // -// 仿 FAA TestSite `DownloadFileDirect` pattern:token 永遠不過 frontend JS。 +// Phase 0.8 → 0.8b 差異: +// - 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) +// +// 中途錯誤處理(已 200 / 已寫 part of body 後失敗): +// - 一旦 status 200 已寫,無法再改 status 給 client(HTTP 規範) +// - io.Copy 中斷只能 log 錯誤;client 端 browser 會看到截斷檔 +// - ctx cancel(client 斷線)由 FAAClient 內部 ctx-aware 透傳,goroutine 自動結束 func conversionDownloadHandler(deps Deps) gin.HandlerFunc { return func(c *gin.Context) { uc, ok := UserContextFrom(c) @@ -283,23 +295,106 @@ func conversionDownloadHandler(deps Deps) gin.HandlerFunc { return } - downloadURL, err := deps.Conversion.DownloadRedirectURL(c.Request.Context(), uc.UserID, jobID) + stream, meta, err := deps.Conversion.DownloadStream(c.Request.Context(), uc.UserID, jobID) if err != nil { - // 錯誤情況不 redirect — 依 Accept header 回 JSON / HTML(WriteError 寫 JSON - // 已能滿足主要 case;anchor tag 觸發時 browser 會直接顯示 JSON 也 OK, - // Phase 0.8 不額外做 HTML 錯誤頁) + // 200 還沒寫,可以正常回 JSON error(依 Accept header) handleConversionError(c, err) return } + // 必須 close — 否則底層 HTTP keep-alive connection 不會回 pool(fd leak) + defer stream.Close() - // 防快取:避免 browser 把 302 + Location 寫入 history / disk cache(§10.4) + // 設 response header 後才能 io.Copy;一旦 io.Copy 開始就無法再改 status + // (Phase 0.8b: 對齊 api-conversion.md §4 「Response 200(成功 — Phase 0.8b 變更)」) + c.Header("Content-Type", meta.ContentType) + // Content-Length:FAA 走 chunked 時 ContentLength = -1,此時不要 set header + // (讓 net/http 用 chunked transfer encoding,避免 browser 依 -1 解析錯誤) + if meta.ContentLength > 0 { + c.Header("Content-Length", strconv.FormatInt(meta.ContentLength, 10)) + } + // 對 browser 觸發 download dialog;filename 由 Service 命名(已 stem + chip + .nef 規則化) + // sanitizeDownloadFilename 額外擋特殊字元(即使 Service 已給乾淨值也防呆) + c.Header("Content-Disposition", `attachment; filename="`+sanitizeDownloadFilename(meta.Filename)+`"`) + // 防快取:private NEF 不該被 browser cache(§10.4) c.Header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0") c.Header("Pragma", "no-cache") - // 302 Found(不用 301 — 301 可能被某些 browser 永久 cache) - c.Redirect(http.StatusFound, downloadURL) + c.Status(http.StatusOK) + + // streaming proxy — 不 ReadAll、不暫存 disk + // 中斷錯誤只能 log(已 200 + part of body,無法回頭改 status) + // + // Phase 0.8b T5(T4 Reviewer Minor #1 修補):用 io.CopyN 上 size cap + // 防 buggy / malicious FAA 回傳超大 body 把 visionA backend 變 unbounded relay。 + // 上限值 conversion.MaxDownloadStreamBytes(1GB)— 對 < 50MB 正常 NEF 零影響、 + // 對 > 1GB 視為異常並中斷 stream。 + written, copyErr := io.CopyN(c.Writer, stream, conversion.MaxDownloadStreamBytes) + switch { + case errors.Is(copyErr, io.EOF): + // 正常 EOF — stream 在 cap 之內結束;written < cap,無事 + case copyErr == nil && written == conversion.MaxDownloadStreamBytes: + // 命中 cap — 中斷 stream(接下來的 bytes 不再 copy 給 client) + // 已 200 + 部分 body,無法回頭改 status,client 會收到截斷檔 + if deps.Logger != nil { + deps.Logger.Warn("conversion.download.size_cap_exceeded", + "user_id", uc.UserID, + "job_id", jobID, + "written_bytes", written, + "cap_bytes", conversion.MaxDownloadStreamBytes, + "hint", "FAA returned body >= cap; truncated to protect visionA bandwidth", + ) + } + case copyErr != nil: + // 其他錯誤(client 斷線 / 網路中斷 / FAA stream error 等) + // 此時 client 端可能已收到部分 bytes 但 connection 中斷; + // 用 deps.Logger 記下、由 SRE alarm 看「download_stream_copy_failed」率 + if deps.Logger != nil { + deps.Logger.Warn("conversion.download.stream_copy_failed", + "user_id", uc.UserID, + "job_id", jobID, + "written_bytes", written, + "err", copyErr.Error(), + ) + } + } } } +// sanitizeDownloadFilename 對 Content-Disposition 的 filename 做最低限度的安全處理。 +// +// 規則: +// - 移除控制字元(包含 \r \n \t)— 防 HTTP header injection +// - 移除 path separator(/ 與 \)— 防 directory traversal 暗示 +// - 移除 quote / backslash — 避免破壞 `filename="..."` 結構 +// - 空字串兜底為 "download.nef" +// +// 注意:Service 已給乾淨 filename(defaultDownloadFilename 從 stem + chip 組), +// 這個 sanitize 只是防呆 — 即使 Service 漏字元也擋一次。 +func sanitizeDownloadFilename(name string) string { + if name == "" { + return "download.nef" + } + // 黑名單:控制字元 + path sep + quote + backslash + var sb strings.Builder + sb.Grow(len(name)) + for _, r := range name { + switch { + case r < 0x20: // 控制字元(含 \r \n \t) + continue + case r == '/' || r == '\\': + continue + case r == '"': + continue + default: + sb.WriteRune(r) + } + } + out := sb.String() + if out == "" { + return "download.nef" + } + return out +} + // ========================================================================== // 錯誤處理 helper // ========================================================================== diff --git a/visionA-backend/internal/api/conversion_test.go b/visionA-backend/internal/api/conversion_test.go index d9a1eb7..ba5b53b 100644 --- a/visionA-backend/internal/api/conversion_test.go +++ b/visionA-backend/internal/api/conversion_test.go @@ -47,7 +47,10 @@ type stubConversionService struct { GetJobFn func(ctx context.Context, userID, jobID string) (*conversion.Job, error) ActiveJobFn func(ctx context.Context, userID string) (*conversion.Job, error) PromoteFn func(ctx context.Context, userID, jobID, name string) (*conversion.PromoteResult, error) - DownloadFn func(ctx context.Context, userID, jobID string) (string, error) + // Phase 0.8b T4:DownloadFn signature 從 (string, error) 改 (ReadCloser, *DownloadMetadata, error) + // — Service interface 從 DownloadRedirectURL 改 DownloadStream(API key 模式下沒有 + // delegated token,改 server-side stream proxy;ADR-015 §7 / conversion.md §4.1)。 + DownloadFn func(ctx context.Context, userID, jobID string) (io.ReadCloser, *conversion.DownloadMetadata, error) } func (s *stubConversionService) InitJob(ctx context.Context, in conversion.InitJobInput) (*conversion.Job, error) { @@ -92,13 +95,13 @@ func (s *stubConversionService) PromoteToModels(ctx context.Context, userID, job return s.PromoteFn(ctx, userID, jobID, name) } -func (s *stubConversionService) DownloadRedirectURL(ctx context.Context, userID, jobID string) (string, error) { +func (s *stubConversionService) DownloadStream(ctx context.Context, userID, jobID string) (io.ReadCloser, *conversion.DownloadMetadata, error) { s.mu.Lock() s.lastUserID = userID s.lastJobID = jobID s.mu.Unlock() if s.DownloadFn == nil { - return "", errors.New("stub: DownloadFn not set") + return nil, nil, errors.New("stub: DownloadFn not set") } return s.DownloadFn(ctx, userID, jobID) } @@ -511,13 +514,21 @@ func TestConversion_Promote_JobNotCompleted(t *testing.T) { // 5. GET /api/conversion/{job_id}/download // ========================================================================== -func TestConversion_Download_HappyPath302(t *testing.T) { - target := "http://192.168.0.130:5081/files/models/u/job.nef?access_token=opaque-xyz" +// Phase 0.8b T4:handler 改成 server-side stream proxy(API key 模式下沒有 delegated token)。 +// 對應 ADR-015 §7 + conversion.md §4.1 + api-conversion.md §4 (Phase 0.8b 變更)。 +// +// 成功 response 從「302 + Location」改為「200 + Content-Disposition: attachment + NEF binary stream」。 +func TestConversion_Download_HappyPath_StreamProxy(t *testing.T) { + const nefPayload = "fake-nef-binary-bytes-stub-content-12345" svc := &stubConversionService{ - DownloadFn: func(ctx context.Context, userID, jobID string) (string, error) { + DownloadFn: func(ctx context.Context, userID, jobID string) (io.ReadCloser, *conversion.DownloadMetadata, error) { require.Equal(t, "demo-user", userID) require.Equal(t, "job-abc", jobID) - return target, nil + return io.NopCloser(strings.NewReader(nefPayload)), &conversion.DownloadMetadata{ + Filename: "yolov5s_kl720.nef", + ContentType: "application/octet-stream", + ContentLength: int64(len(nefPayload)), + }, nil }, } r := newConversionFixture(t, svc) @@ -526,18 +537,77 @@ func TestConversion_Download_HappyPath302(t *testing.T) { w := httptest.NewRecorder() r.ServeHTTP(w, req) - assert.Equal(t, http.StatusFound, w.Code) // 302 - assert.Equal(t, target, w.Header().Get("Location")) - - // 防快取 header — token 不該被 browser cache(§10.4) + // 200 OK + NEF binary in body(Phase 0.8b:不再 302 redirect) + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "application/octet-stream", w.Header().Get("Content-Type")) + // Content-Disposition: attachment 觸發 browser download dialog + assert.Equal(t, `attachment; filename="yolov5s_kl720.nef"`, w.Header().Get("Content-Disposition")) + // Content-Length 與 stub stream 一致 + assert.Equal(t, "40", w.Header().Get("Content-Length")) + // 防快取(NEF 是 private 檔案) assert.Contains(t, w.Header().Get("Cache-Control"), "no-store") assert.Equal(t, "no-cache", w.Header().Get("Pragma")) + // Body bytes 與 stub stream 完全一致(streaming proxy) + assert.Equal(t, nefPayload, w.Body.String()) +} + +// TestConversion_Download_HappyPath_ChunkedTransfer:FAA 走 chunked transfer encoding 時 +// (ContentLength = -1),handler 不應 set Content-Length header(讓 net/http 用 chunked)。 +func TestConversion_Download_HappyPath_ChunkedTransfer(t *testing.T) { + const nefPayload = "chunked-fake-nef-bytes" + svc := &stubConversionService{ + DownloadFn: func(ctx context.Context, userID, jobID string) (io.ReadCloser, *conversion.DownloadMetadata, error) { + return io.NopCloser(strings.NewReader(nefPayload)), &conversion.DownloadMetadata{ + Filename: "model_kl520.nef", + ContentType: "application/octet-stream", + ContentLength: -1, // chunked + }, nil + }, + } + r := newConversionFixture(t, svc) + + req := httptest.NewRequest(http.MethodGet, "/api/conversion/job-abc/download", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + // chunked 模式下 handler 不設 Content-Length;httptest 不會自動補(gin Status 後即 commit header) + assert.Empty(t, w.Header().Get("Content-Length"), + "chunked transfer 時 handler 不應 set Content-Length(FAA 給 -1 → 讓 net/http 用 chunked)") + assert.Equal(t, nefPayload, w.Body.String()) +} + +// TestConversion_Download_FilenameSanitization:Service 給含特殊字元的 filename +// 也應被 handler sanitize(防 HTTP header injection / path traversal)。 +func TestConversion_Download_FilenameSanitization(t *testing.T) { + svc := &stubConversionService{ + DownloadFn: func(ctx context.Context, userID, jobID string) (io.ReadCloser, *conversion.DownloadMetadata, error) { + return io.NopCloser(strings.NewReader("body")), &conversion.DownloadMetadata{ + // 故意塞控制字元 + path sep + quote — 全部該被 sanitize 拔掉 + Filename: "evil\r\n/foo/\"injected\".nef", + ContentType: "application/octet-stream", + ContentLength: 4, + }, nil + }, + } + r := newConversionFixture(t, svc) + + req := httptest.NewRequest(http.MethodGet, "/api/conversion/job-abc/download", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + cd := w.Header().Get("Content-Disposition") + // 控制字元 \r \n 不該出現 + assert.NotContains(t, cd, "\r") + assert.NotContains(t, cd, "\n") + // path separator 不該出現 + assert.NotContains(t, cd, "/") + // quote 不該破壞 filename="..." 結構 + assert.Equal(t, `attachment; filename="evilfooinjected.nef"`, cd) } func TestConversion_Download_JobNotCompleted(t *testing.T) { svc := &stubConversionService{ - DownloadFn: func(ctx context.Context, userID, jobID string) (string, error) { - return "", conversion.ErrJobNotCompleted + DownloadFn: func(ctx context.Context, userID, jobID string) (io.ReadCloser, *conversion.DownloadMetadata, error) { + return nil, nil, conversion.ErrJobNotCompleted }, } r := newConversionFixture(t, svc) @@ -546,16 +616,17 @@ func TestConversion_Download_JobNotCompleted(t *testing.T) { w := httptest.NewRecorder() r.ServeHTTP(w, req) - // 錯誤情況**不 redirect** — 回標準 JSON error + // 錯誤情況回標準 JSON error(200 還沒寫,可以 set status) assert.Equal(t, http.StatusConflict, w.Code) assert.Contains(t, w.Body.String(), "job_not_completed") - assert.NotEqual(t, http.StatusFound, w.Code, "error case must not 302 redirect") + assert.NotEqual(t, http.StatusOK, w.Code, "error case must not 200 stream") + assert.NotEqual(t, http.StatusFound, w.Code, "Phase 0.8b: 也不再 302 redirect") } func TestConversion_Download_NotFound(t *testing.T) { svc := &stubConversionService{ - DownloadFn: func(ctx context.Context, userID, jobID string) (string, error) { - return "", conversion.ErrJobNotFound + DownloadFn: func(ctx context.Context, userID, jobID string) (io.ReadCloser, *conversion.DownloadMetadata, error) { + return nil, nil, conversion.ErrJobNotFound }, } r := newConversionFixture(t, svc) @@ -566,10 +637,15 @@ func TestConversion_Download_NotFound(t *testing.T) { assert.Equal(t, http.StatusNotFound, w.Code) } -func TestConversion_Download_MCTokenUnavailable(t *testing.T) { +// TestConversion_Download_FAAUnavailable:FAA stream 失敗 → handler 回 502 + faa_unavailable。 +// +// Phase 0.8b T4 補漏:取代原本的 TestConversion_Download_MCTokenUnavailable +// (MC 認證鏈已取消,ErrMCTokenUnavailable sentinel 已砍 — 對應 errors.go T3 砍除清單)。 +// 保留 download 5xx 路徑覆蓋,改測 ErrFAAUnavailable(API key 模式下最常見的 download 失敗)。 +func TestConversion_Download_FAAUnavailable(t *testing.T) { svc := &stubConversionService{ - DownloadFn: func(ctx context.Context, userID, jobID string) (string, error) { - return "", conversion.ErrMCTokenUnavailable + DownloadFn: func(ctx context.Context, userID, jobID string) (io.ReadCloser, *conversion.DownloadMetadata, error) { + return nil, nil, conversion.ErrFAAUnavailable }, } r := newConversionFixture(t, svc) @@ -578,7 +654,119 @@ func TestConversion_Download_MCTokenUnavailable(t *testing.T) { w := httptest.NewRecorder() r.ServeHTTP(w, req) assert.Equal(t, http.StatusBadGateway, w.Code) - assert.Contains(t, w.Body.String(), "mc_token_unavailable") + assert.Contains(t, w.Body.String(), "faa_unavailable") +} + +// TestConversion_Download_FAAAuthFailed:API key 不對齊(運維事件) +// → handler 回 502,對外 mask 成 faa_unavailable(不洩漏「API key 不對」)。 +// +// 對齊 ADR-015 §3.5.3 #3「對外只回 unauthorized」原則 + conversion.md §6 mask 行為: +// SRE 從 server log 的 ErrFAAAuthFailed sentinel 排查 env,但對 frontend 文字一致。 +func TestConversion_Download_FAAAuthFailed(t *testing.T) { + svc := &stubConversionService{ + DownloadFn: func(ctx context.Context, userID, jobID string) (io.ReadCloser, *conversion.DownloadMetadata, error) { + return nil, nil, conversion.ErrFAAAuthFailed + }, + } + r := newConversionFixture(t, svc) + + req := httptest.NewRequest(http.MethodGet, "/api/conversion/job/download", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + // 對外 502 + faa_unavailable(mask)— 不要洩漏 auth_failed 這個內部運維狀態 + assert.Equal(t, http.StatusBadGateway, w.Code) + assert.Contains(t, w.Body.String(), "faa_unavailable", + "ErrFAAAuthFailed 對外應 mask 成 faa_unavailable,不洩漏 API key 不對齊細節") + assert.NotContains(t, w.Body.String(), "auth_failed", + "對 frontend 不應暴露 auth_failed 這個內部 SRE 訊號") +} + +// TestConversion_Download_ConverterAuthFailed:對稱測試 converter API key 不對齊。 +// 對外 mask 成 converter_unavailable。 +func TestConversion_Download_ConverterAuthFailed(t *testing.T) { + svc := &stubConversionService{ + DownloadFn: func(ctx context.Context, userID, jobID string) (io.ReadCloser, *conversion.DownloadMetadata, error) { + return nil, nil, conversion.ErrConverterAuthFailed + }, + } + r := newConversionFixture(t, svc) + + req := httptest.NewRequest(http.MethodGet, "/api/conversion/job/download", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusBadGateway, w.Code) + assert.Contains(t, w.Body.String(), "converter_unavailable", + "ErrConverterAuthFailed 對外應 mask 成 converter_unavailable") + assert.NotContains(t, w.Body.String(), "auth_failed") +} + +// TestConversion_Download_SizeCapEnforced:T5 補(T4 Reviewer Minor #1) +// +// 驗 io.Copy size cap 行為:當 Service 回的 stream 超過 conversion.MaxDownloadStreamBytes 時: +// +// 1. handler 仍回 200(已 commit response header,無法回頭改 status) +// 2. body 被 truncate 到 cap(不會把超大 stream 全 forward 給 client) +// 3. cap 命中時 stream 被中斷(infinite reader 不會被 read 完) +// +// 實作策略: +// - 用 infiniteByteReader 模擬「永遠可讀」的 stream(攻擊情境的 abstract) +// - 為避免測試實寫 1GB(耗時 + 占記憶體),暫時 override +// conversion.MaxDownloadStreamBytes 為 1024 bytes — +// 此 var 設計就允許 test override(見 conversion.go MaxDownloadStreamBytes godoc) +// - 結束後 t.Cleanup 還原原值 +// +// 為什麼不驗 log 內容:log 行為是「副作用」,斷言 log 字串易脆(log format / level 改了就失敗)。 +// 直接驗「行為輸出」(body 被 truncate)已足以證明 size cap 起作用。 +func TestConversion_Download_SizeCapEnforced(t *testing.T) { + // 暫時把 cap 從 1GB 改成 1KB,方便測試 + const testCapBytes int64 = 1024 + prevCap := conversion.MaxDownloadStreamBytes + conversion.MaxDownloadStreamBytes = testCapBytes + t.Cleanup(func() { conversion.MaxDownloadStreamBytes = prevCap }) + + infinite := &infiniteByteReader{ch: 'X'} + + svc := &stubConversionService{ + DownloadFn: func(ctx context.Context, userID, jobID string) (io.ReadCloser, *conversion.DownloadMetadata, error) { + return io.NopCloser(infinite), &conversion.DownloadMetadata{ + Filename: "huge.nef", + ContentType: "application/octet-stream", + ContentLength: -1, // chunked,不 set Content-Length(避免與 truncate 後的 body length 衝突) + }, nil + }, + } + r := newConversionFixture(t, svc) + + req := httptest.NewRequest(http.MethodGet, "/api/conversion/job-cap/download", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + // 1. status 仍 200(cap 命中時 header 已 commit、無法回頭改 status) + assert.Equal(t, http.StatusOK, w.Code, + "size cap 命中時 status 仍應 200(header 已 commit)") + + // 2. body 被 truncate 到 cap — 不會把整個 infinite stream 寫給 client + assert.Equal(t, int(testCapBytes), w.Body.Len(), + "body 應被 truncate 到 cap(%d bytes),infinite stream 未被全 read", testCapBytes) + // 內容應全是 'X'(infiniteByteReader 寫的字元) + assert.True(t, strings.HasPrefix(w.Body.String(), "X"), + "body 應為 infinite reader 寫的 'X' 字元") +} + +// infiniteByteReader 永遠 read 出固定 byte,模擬無限大 stream 給 size cap 測試用。 +// +// 用途:驗證 io.CopyN size cap 真的會中斷 stream,不會把所有 bytes 寫給 client。 +// 不實作 io.Closer — 由 io.NopCloser 包裝後傳給 handler。 +type infiniteByteReader struct { + ch byte +} + +func (r *infiniteByteReader) Read(p []byte) (int, error) { + for i := range p { + p[i] = r.ch + } + return len(p), nil } // ========================================================================== diff --git a/visionA-backend/internal/config/config.go b/visionA-backend/internal/config/config.go index f5b0e58..bea2f8b 100644 --- a/visionA-backend/internal/config/config.go +++ b/visionA-backend/internal/config/config.go @@ -180,15 +180,21 @@ type LoggerConfig struct { Level string // VISIONA_LOG_LEVEL:debug / info / warn / error,預設 "info" } -// ConversionConfig 控制 Phase 0.8 轉檔功能整合。 +// ConversionConfig 控制 Phase 0.8 / 0.8b 轉檔功能整合。 // -// 對齊 .autoflow/04-architecture/conversion.md §5.3。 +// 對齊 .autoflow/04-architecture/conversion.md §3、`adr/adr-015-server-to-server-api-key.md`。 // -// 啟用判定(由 main.go 在 wire 階段檢查):當 ConverterBaseURL 與 FAABaseURL 都非空時, -// 才會 wire conversion.Service 進 api.Deps。其中之一為空 → 不啟用(5 個 endpoint 回 501)。 +// 啟用判定(由 Enabled() 給 main.go 用):Phase 0.8b 起,4 個欄位(ConverterBaseURL / +// FAABaseURL / ConverterAPIKey / FAAAPIKey)**全部非空**才視為啟用;任一缺即視為未啟用, +// 5 個 /api/conversion/* endpoint 不會 wire(main.go 在 wire 階段跳過、log warn)。 // -// 進一步:啟用時 ServiceClientID/Secret 必須非空(轉檔依賴 service token 機制); -// 不對齊時 main.go fatal log 退出(避免半設定狀態跑進生產)。 +// **Phase 0.8b 變更**:服務間認證從 OAuth client_credentials 改為 pre-shared API key(ADR-015); +// `Enabled()` 加入兩個 API key 非空檢查。 +// +// **Phase 0.8b T5 完成**(見 conversion.md §3.2 / ADR-015 §5 §7):原暫留欄位 +// TenantID / DelegatedTTLSeconds 已移除 — MC 認證鏈與 delegated download token 機制 +// 都不存在了,兩個欄位連同對應 env(VISIONA_OIDC_TENANT_ID / +// VISIONA_FAA_DELEGATED_TTL_SECONDS)一併清除。 type ConversionConfig struct { // ConverterBaseURL 是 kneron_model_converter task-scheduler 服務的 base URL。 // 例:http://192.168.0.130:9501(dev / stage) / https://converter.visiona.cloud(prod) @@ -200,15 +206,21 @@ type ConversionConfig struct { // 對齊 VISIONA_FAA_BASE_URL;留空 = 不啟用 Phase 0.8 轉檔功能。 FAABaseURL string - // TenantID 是 visionA 在 Member Center 註冊的 tenant id(單一 tenant)。 - // 在跟 MC 換 delegated download token 時當 request body 的 tenant_id 欄位用。 - // 對齊 VISIONA_OIDC_TENANT_ID。 - TenantID string + // ConverterAPIKey 是 visionA → converter 服務間認證的 pre-shared API key(Phase 0.8b 新增)。 + // 對齊 VISIONA_CONVERTER_API_KEY;以 `Authorization: Bearer ` 形式帶上。 + // 雙方獨立產生(`openssl rand -hex 32`),visionA 端的值必須與 converter 端的 + // `CONVERTER_API_KEY` env 對齊;不對齊 → 下游 401(visionA 端不重試,回 502 converter_auth_failed)。 + // 對應 ADR-015 §3。 + // + // 安全:log 永遠不印此值全文(可印 `api_key_set=true/false` 或前 8 字元 prefix); + // 部署用 AWS Secrets Manager / Vault;嚴格分環境(dev / stage / prod 各自獨立 key)。 + ConverterAPIKey string - // DelegatedTTLSeconds 是 MC 簽 delegated download token 的 TTL(秒)。 - // 預設 300(5 分鐘);可調整範圍 60-900。對齊 VISIONA_FAA_DELEGATED_TTL_SECONDS。 - // 見 conversion.md §10.2 安全考量。 - DelegatedTTLSeconds int + // FAAAPIKey 是 visionA → FAA 服務間認證的 pre-shared API key(Phase 0.8b 新增)。 + // 對齊 VISIONA_FAA_API_KEY;以 `Authorization: Bearer ` 形式帶上。 + // 與 ConverterAPIKey **不共用**(每條 trust boundary 各自獨立,避免一處洩漏連坐 — ADR-015 §3); + // 對應 FAA 端的 `FAA_API_KEY` env,由 warrenchen 配置(跨 repo 同步)。 + FAAAPIKey string // MaxModelSizeMB 是 visionA-backend 端對上傳模型檔的大小上限(MB)。 // 與 converter 端 limit 對齊(converter 預設 500 MB)。 @@ -216,11 +228,16 @@ type ConversionConfig struct { MaxModelSizeMB int } -// Enabled 回傳 Phase 0.8 conversion 是否啟用。 +// Enabled 回傳 Phase 0.8 / 0.8b conversion 是否啟用。 // -// main.go 在 wire 時用此判斷是否要 init conversion.Service。 +// **Phase 0.8b 變更**(ADR-015 §6):除既有的 ConverterBaseURL / FAABaseURL 外, +// 加入 ConverterAPIKey / FAAAPIKey 非空檢查;4 個欄位皆非空才算啟用。 +// 任一缺 → 視為未啟用,main.go 不會 wire conversion.Service(5 個 endpoint 回 501 / 不註冊)。 func (c ConversionConfig) Enabled() bool { - return c.ConverterBaseURL != "" && c.FAABaseURL != "" + return c.ConverterBaseURL != "" && + c.FAABaseURL != "" && + c.ConverterAPIKey != "" && + c.FAAAPIKey != "" } // CORSConfig 控制 api-server 對瀏覽器的 CORS 白名單。 diff --git a/visionA-backend/internal/config/load.go b/visionA-backend/internal/config/load.go index 85edc32..9a68160 100644 --- a/visionA-backend/internal/config/load.go +++ b/visionA-backend/internal/config/load.go @@ -68,13 +68,16 @@ func Load() *Config { CORS: CORSConfig{ AllowedOrigins: getEnvStringSlice("VISIONA_CORS_ALLOWED_ORIGINS", nil), }, - // Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §5.3) + // Phase 0.8 / 0.8b conversion (見 .autoflow/04-architecture/conversion.md §3、ADR-015) + // Phase 0.8b T5:原暫留欄位 TenantID / DelegatedTTLSeconds 與對應 env + // (VISIONA_OIDC_TENANT_ID / VISIONA_FAA_DELEGATED_TTL_SECONDS)已移除 — + // MC 認證鏈與 delegated download token 機制不存在了。 Conversion: ConversionConfig{ - ConverterBaseURL: getEnvString("VISIONA_CONVERTER_BASE_URL", ""), - FAABaseURL: getEnvString("VISIONA_FAA_BASE_URL", ""), - TenantID: getEnvString("VISIONA_OIDC_TENANT_ID", ""), - DelegatedTTLSeconds: getEnvInt("VISIONA_FAA_DELEGATED_TTL_SECONDS", 300), - MaxModelSizeMB: getEnvInt("VISIONA_CONVERTER_MAX_MODEL_SIZE_MB", 500), + ConverterBaseURL: getEnvString("VISIONA_CONVERTER_BASE_URL", ""), + FAABaseURL: getEnvString("VISIONA_FAA_BASE_URL", ""), + ConverterAPIKey: getEnvString("VISIONA_CONVERTER_API_KEY", ""), + FAAAPIKey: getEnvString("VISIONA_FAA_API_KEY", ""), + MaxModelSizeMB: getEnvInt("VISIONA_CONVERTER_MAX_MODEL_SIZE_MB", 500), }, } } diff --git a/visionA-backend/internal/config/load_test.go b/visionA-backend/internal/config/load_test.go index fc257ea..69a0f4d 100644 --- a/visionA-backend/internal/config/load_test.go +++ b/visionA-backend/internal/config/load_test.go @@ -266,14 +266,18 @@ func TestLoad_CORSAllowedOrigins(t *testing.T) { assert.Nil(t, cfg.CORS.AllowedOrigins) } -// TestLoad_ConversionDefaults 驗證 Phase 0.8 conversion 欄位的預設行為。 +// TestLoad_ConversionDefaults 驗證 Phase 0.8 / 0.8b conversion 欄位的預設行為。 // -// 對齊 .autoflow/04-architecture/conversion.md §5.3:留空時 Enabled() 為 false, +// 對齊 .autoflow/04-architecture/conversion.md §3 + ADR-015:留空時 Enabled() 為 false, // 5 個 endpoint 不會 wire(main.go 在 wire 階段會跳過)。 +// +// Phase 0.8b T5:原暫留欄位 TenantID / DelegatedTTLSeconds 已從 ConversionConfig 移除 +// (MC 認證鏈與 delegated download token 機制不存在了);本 test 不再驗這兩欄位。 func TestLoad_ConversionDefaults(t *testing.T) { for _, k := range []string{ - "VISIONA_CONVERTER_BASE_URL", "VISIONA_FAA_BASE_URL", "VISIONA_OIDC_TENANT_ID", - "VISIONA_FAA_DELEGATED_TTL_SECONDS", "VISIONA_CONVERTER_MAX_MODEL_SIZE_MB", + "VISIONA_CONVERTER_BASE_URL", "VISIONA_FAA_BASE_URL", + "VISIONA_CONVERTER_API_KEY", "VISIONA_FAA_API_KEY", + "VISIONA_CONVERTER_MAX_MODEL_SIZE_MB", } { t.Setenv(k, "") } @@ -281,50 +285,101 @@ func TestLoad_ConversionDefaults(t *testing.T) { cfg := Load() assert.Empty(t, cfg.Conversion.ConverterBaseURL) assert.Empty(t, cfg.Conversion.FAABaseURL) - assert.Empty(t, cfg.Conversion.TenantID) - assert.Equal(t, 300, cfg.Conversion.DelegatedTTLSeconds, "預設 5 分鐘 TTL") + assert.Empty(t, cfg.Conversion.ConverterAPIKey, "Phase 0.8b:API key 預設留空") + assert.Empty(t, cfg.Conversion.FAAAPIKey, "Phase 0.8b:API key 預設留空") assert.Equal(t, 500, cfg.Conversion.MaxModelSizeMB, "預設 500 MB(與 converter 對齊)") - assert.False(t, cfg.Conversion.Enabled(), "URL 全空 → 不啟用") + assert.False(t, cfg.Conversion.Enabled(), "全空 → 不啟用") } -// TestLoad_ConversionEnabled 驗證 Conversion.Enabled() 的判定邏輯。 +// TestLoad_ConversionEnabled 驗證 Conversion.Enabled() 的判定邏輯(Phase 0.8b 修訂)。 +// +// Phase 0.8b 變更:4 個欄位(Converter URL / FAA URL / Converter API key / FAA API key) +// 全部非空才視為啟用;任一缺即 disable。 func TestLoad_ConversionEnabled(t *testing.T) { cases := []struct { - name string - converter string - faa string - wantEnabled bool + name string + converterURL string + faaURL string + converterKey string + faaKey string + wantEnabled bool }{ - {"both_set_enables", "http://converter:9501", "http://faa:5081", true}, - {"only_converter_disabled", "http://converter:9501", "", false}, - {"only_faa_disabled", "", "http://faa:5081", false}, - {"both_empty_disabled", "", "", false}, + {"all_set_enables", + "http://converter:9501", "http://faa:5081", + "converter-key-32-bytes-hex-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "faa-key-32-bytes-hex-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + true}, + {"missing_converter_url_disabled", + "", "http://faa:5081", + "converter-key", "faa-key", + false}, + {"missing_faa_url_disabled", + "http://converter:9501", "", + "converter-key", "faa-key", + false}, + {"missing_converter_key_disabled", + "http://converter:9501", "http://faa:5081", + "", "faa-key", + false}, + {"missing_faa_key_disabled", + "http://converter:9501", "http://faa:5081", + "converter-key", "", + false}, + {"all_empty_disabled", + "", "", "", "", + false}, } for _, tc := range cases { tc := tc t.Run(tc.name, func(t *testing.T) { - t.Setenv("VISIONA_CONVERTER_BASE_URL", tc.converter) - t.Setenv("VISIONA_FAA_BASE_URL", tc.faa) + t.Setenv("VISIONA_CONVERTER_BASE_URL", tc.converterURL) + t.Setenv("VISIONA_FAA_BASE_URL", tc.faaURL) + t.Setenv("VISIONA_CONVERTER_API_KEY", tc.converterKey) + t.Setenv("VISIONA_FAA_API_KEY", tc.faaKey) cfg := Load() assert.Equal(t, tc.wantEnabled, cfg.Conversion.Enabled()) }) } } -// TestLoad_ConversionAllSet 驗證所有欄位設定後正確讀取。 +// TestLoad_ConversionAllSet 驗證 Phase 0.8b 所有欄位設定後正確讀取。 +// +// Phase 0.8b T5:原暫留欄位 TenantID / DelegatedTTLSeconds 已移除,本 test +// 不再驗這兩欄位(對應 env 也不再讀取)。 func TestLoad_ConversionAllSet(t *testing.T) { + const fakeConverterKey = "fake-converter-api-key-for-test-do-not-use-in-prod" + const fakeFAAKey = "fake-faa-api-key-for-test-do-not-use-in-prod" + t.Setenv("VISIONA_CONVERTER_BASE_URL", "http://192.168.0.130:9501") t.Setenv("VISIONA_FAA_BASE_URL", "http://192.168.0.130:5081") - t.Setenv("VISIONA_OIDC_TENANT_ID", "fake-tenant-id-for-test") - t.Setenv("VISIONA_FAA_DELEGATED_TTL_SECONDS", "600") + t.Setenv("VISIONA_CONVERTER_API_KEY", fakeConverterKey) + t.Setenv("VISIONA_FAA_API_KEY", fakeFAAKey) t.Setenv("VISIONA_CONVERTER_MAX_MODEL_SIZE_MB", "300") cfg := Load() assert.Equal(t, "http://192.168.0.130:9501", cfg.Conversion.ConverterBaseURL) assert.Equal(t, "http://192.168.0.130:5081", cfg.Conversion.FAABaseURL) - assert.Equal(t, "fake-tenant-id-for-test", cfg.Conversion.TenantID) - assert.Equal(t, 600, cfg.Conversion.DelegatedTTLSeconds) + assert.Equal(t, fakeConverterKey, cfg.Conversion.ConverterAPIKey) + assert.Equal(t, fakeFAAKey, cfg.Conversion.FAAAPIKey) assert.Equal(t, 300, cfg.Conversion.MaxModelSizeMB) assert.True(t, cfg.Conversion.Enabled()) } + +// TestLoad_ConversionAPIKeysOnly:Phase 0.8b T5 — 4 個必要欄位齊全即 Enabled。 +// +// 此 test 在 T1-T4 期間驗證「廢棄 env 不設也能 Enabled」;T5 完成後該邏輯 +// 由本 test 與 TestLoad_ConversionAllSet 共同覆蓋(因為廢棄 env 已徹底移除)。 +func TestLoad_ConversionAPIKeysOnly(t *testing.T) { + const fakeConverterKey = "fake-converter-api-key-only-test" + const fakeFAAKey = "fake-faa-api-key-only-test" + + t.Setenv("VISIONA_CONVERTER_BASE_URL", "http://192.168.0.130:9501") + t.Setenv("VISIONA_FAA_BASE_URL", "http://192.168.0.130:5081") + t.Setenv("VISIONA_CONVERTER_API_KEY", fakeConverterKey) + t.Setenv("VISIONA_FAA_API_KEY", fakeFAAKey) + + cfg := Load() + assert.True(t, cfg.Conversion.Enabled(), + "Phase 0.8b T5:4 個必要欄位齊全即 Enabled") +} diff --git a/visionA-backend/internal/conversion/conversion.go b/visionA-backend/internal/conversion/conversion.go index 70209f7..2796d9b 100644 --- a/visionA-backend/internal/conversion/conversion.go +++ b/visionA-backend/internal/conversion/conversion.go @@ -79,20 +79,31 @@ type Service interface { // `name` 是 Design Phase 0.8 wireframe §7.1 的單一欄位(不含 description)。 PromoteToModels(ctx context.Context, userID, jobID, name string) (*PromoteResult, error) - // DownloadRedirectURL 產出「下載」的 server-side 302 redirect URL。 + // DownloadStream 產出「下載」的 server-side stream proxy(Phase 0.8b 變更,對應 ADR-015 §7)。 // - // Handler 拿到後直接 c.Redirect(http.StatusFound, url);token 不出現在任何 JSON response, - // 也不傳給 frontend JS(見 conversion.md §10.4 安全分析)。 + // 流程(見 conversion.md §1 Stage 3b + §4.1): + // 1. ownership 檢查(不符 → ErrJobNotFound,§7.2 防枚舉) + // 2. converter.GetJob 確認 status=completed(否則 ErrJobNotCompleted) + // 3. ensurePromoted(與 PromoteToModels 共用同一個 converter promote endpoint,冪等) + // 4. faa.GetFile(targetObjectKey) — 用 pre-shared API key 直接拉 NEF stream // - // 步驟(見 conversion.md §1 Stage 3b): - // 1. ownership 檢查 - // 2. ensurePromoted(與 PromoteToModels 共用 cache) - // 3. 對 MC POST /file-access/download-tokens 換 delegated token - // (scope=files:download.delegate, TTL 5 分鐘) - // 4. 組 https:///files/?access_token= + // Phase 0.8 → 0.8b 差異: + // - Phase 0.8:visionA → MC 換 delegated token → 組 FAA URL → handler 回 302, + // browser 直連 FAA。 + // - Phase 0.8b:MC 認證鏈取消(ADR-015)→ 沒有 delegated token → visionA backend + // 用 API key 直接拉 FAA → 中轉 stream 給 browser(server-side proxy)。 // - // 仿 FAA TestSite `DownloadFileDirect` pattern(見 conversion.md §3.1)。 - DownloadRedirectURL(ctx context.Context, userID, jobID string) (string, error) + // 安全(見 conversion.md §10.4): + // - 沒有 token 結構性存在於任何 frontend response(API key 永遠在 server side) + // - object_key 不對 frontend 揭露(filename 取自 promote 結果,由 visionA 命名) + // - 不需 FAA CORS(visionA → FAA 是 server-side outbound HTTP call,不適用 CORS) + // + // Caller(handler)責任: + // - 取得 stream 後**必須 defer stream.Close()**,否則 keep-alive connection 不會回 pool + // - 設好 response header(Content-Type / Content-Disposition / Cache-Control / Content-Length), + // 用 io.Copy(w, stream) streaming 寫到 client + // - 中途錯誤無法再改 status(已 200 + part of body),由 ctx 控制 caller 端 cleanup + DownloadStream(ctx context.Context, userID, jobID string) (stream io.ReadCloser, meta *DownloadMetadata, err error) // ActiveJob 查 user 當前是否有 active job,給 frontend `/conversion` 頁載入時 pre-check。 // @@ -151,6 +162,53 @@ type Job struct { ErrorMessage string `json:"error_message,omitempty"` } +// MaxDownloadStreamBytes 是 Service.DownloadStream → handler io.Copy 的 sanity cap。 +// +// 用途:對 buggy / malicious FAA 回傳超大 body 的防禦性深化(T4 Reviewer Minor #1)。 +// io.Copy 本身是 streaming(不 buffer 全 RAM,每次 32KB),但無上限會: +// - 浪費 visionA → browser 的 egress bandwidth +// - goroutine 持續開著(slow loris-like 行為) +// +// 1GB 的選擇邏輯: +// - ADR-015 §7「單檔 NEF 通常 < 50MB」是現況觀測,但 NEF 沒有結構性上限 +// - visionA model upload 限 500MB(VISIONA_CONVERTER_MAX_MODEL_SIZE_MB 預設) +// - 1GB = 「正常 NEF × 20」的 sanity cap,足以涵蓋極端但合理的轉檔結果 +// - 超過此值幾乎必為 FAA bug 或攻擊;中斷比繼續 stream 安全 +// +// 對 < 50MB 的正常 NEF 零影響;超過 1GB 時 handler log warn + 中斷 stream +// (已 200 + 部分 body,無法回頭改 status — 對齊 conversion.go:325-336 既有錯誤分支處理)。 +// +// Phase 1 量大評估升級時(ADR-015 §7 選項 B),可一併重新評估此值或改 per-user quota。 +// +// **設計選擇:var 而非 const**:handler 端讀取此值;測試需要 override 為小數值 +// (e.g. 1024 bytes)以驗 cap 行為而不需 read 真實 1GB stream。Production 程式碼 +// **絕不**修改此值(runtime mutation 會造成 race);只有 test 在初始化階段覆寫。 +var MaxDownloadStreamBytes int64 = 1 * 1024 * 1024 * 1024 // 1 GiB + +// DownloadMetadata 是 Service.DownloadStream 回傳的中介資料(Phase 0.8b 新增)。 +// +// 對應 api-conversion.md §4 Phase 0.8b response 規格 — handler 把這些值寫進對 browser 的 +// HTTP response header(Content-Type / Content-Length / Content-Disposition)。 +// +// 設計選擇:與 faa_client.FAAFile.ContentLength / ContentType 對齊;多一個 Filename 是 +// 因為 download 走 `Content-Disposition: attachment; filename=...`,需要 visionA 自行命名 +// (API key 模式下沒有 FAA delegated URL 含原檔名了)。 +type DownloadMetadata struct { + // Filename 對應 `Content-Disposition: attachment; filename=...` 的 value。 + // 規則:`_.nef`,對齊 wireframe §8.1 success card 顯示 + // (例:`yolov5s_kl720.nef`);handler 應對此值再做一次 sanitize(去除控制字元 / 路徑分隔符)。 + Filename string + + // ContentType 對應 FAA response 的 Content-Type header;NEF binary 預設為 application/octet-stream。 + // 若 FAA 沒給就用此預設值(保險:browser 收到 octet-stream 必觸發 download dialog)。 + ContentType string + + // ContentLength 對應 FAA response 的 Content-Length header。 + // FAA 走 chunked transfer 時為 -1(net/http 慣例),handler 此時不要 set Content-Length header + // (讓 browser 用 chunked decoding)。 + ContentLength int64 +} + // PromoteResult 是 PromoteToModels 的 response shape,對齊 api-conversion.md §3。 type PromoteResult struct { ModelID string `json:"model_id"` diff --git a/visionA-backend/internal/conversion/conversion_test.go b/visionA-backend/internal/conversion/conversion_test.go index 45a7656..3745102 100644 --- a/visionA-backend/internal/conversion/conversion_test.go +++ b/visionA-backend/internal/conversion/conversion_test.go @@ -28,8 +28,10 @@ func (noopService) PromoteToModels(ctx context.Context, userID, jobID, name stri return nil, nil } -func (noopService) DownloadRedirectURL(ctx context.Context, userID, jobID string) (string, error) { - return "", nil +// Phase 0.8b T4:DownloadRedirectURL 改 DownloadStream(API key 模式下沒有 delegated token, +// 改 server-side stream proxy;對應 ADR-015 §7 / conversion.md §4.1) +func (noopService) DownloadStream(ctx context.Context, userID, jobID string) (io.ReadCloser, *DownloadMetadata, error) { + return nil, nil, nil } func (noopService) ActiveJob(ctx context.Context, userID string) (*Job, error) { diff --git a/visionA-backend/internal/conversion/converter_client.go b/visionA-backend/internal/conversion/converter_client.go index 8b1bde2..294be56 100644 --- a/visionA-backend/internal/conversion/converter_client.go +++ b/visionA-backend/internal/conversion/converter_client.go @@ -8,15 +8,20 @@ // // 設計重點: // - HTTP retry 矩陣對齊 conversion.md §9.1(InitJob 例外:不 retry 5xx,見下方 sendInitJob 註解) -// - service-to-service token 由注入的 MCTokenClient 提供(per-scope cache) +// - **Phase 0.8b 認證**:直接帶 pre-shared API key(VISIONA_CONVERTER_API_KEY)— 不再走 MC OAuth +// client_credentials grant、不再依賴 MCTokenClient.ServiceToken()。 +// 詳見 ADR-015 §3 + conversion.md §3。 // - body 為 streaming:InitJob 直接傳 caller 的 io.Reader;不暫存 disk、不 buffer 全 RAM // - 4xx 錯誤 mapping 對齊 §6 + api-conversion.md 錯誤碼總覽 +// - 401/403 → ErrConverterAuthFailed(Phase 0.8b 新 sentinel;對外仍 mask 成 converter_unavailable +// 避免洩漏「API key 不對」這個內部運維狀態,SRE 從 server log 看 auth_failed 計數) // // 安全: -// - **絕不**把 Authorization header / access_token 寫進 log +// - **絕不**把 Authorization header / API key 寫進 log(即使是部分前綴) // - 只 log job_id / status / endpoint / attempt / duration // // Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §2.5 + §9.1) +// Phase 0.8b API key 改造 (見 ADR-015 §3 + §6 + conversion.md §3) package conversion import ( @@ -40,16 +45,15 @@ import ( // ConverterClient 對 task-scheduler 的 HTTP client。 // // 所有 method 都會自動: -// - 透過 MCTokenClient 取對應 scope 的 service token,放進 Authorization header +// - Phase 0.8b:直接帶 `Authorization: Bearer ` — 不查 cache、 +// 不打 MC、不重簽(見 ADR-015 §3 + conversion.md §3.1) // - 依 conversion.md §9.1 retry 矩陣處理 5xx / network / timeout(InitJob 例外) -// - 把 4xx / 5xx 對應到 errors.go 的 sentinel +// - 把 4xx / 5xx 對應到 errors.go 的 sentinel;401/403 → ErrConverterAuthFailed(不 retry) // -// goroutine-safe:每次呼叫獨立 *http.Request,無內部 mutable state(cache 由 MCTokenClient 管)。 +// goroutine-safe:每次呼叫獨立 *http.Request,無內部 mutable state(apiKey 為 immutable 字串)。 type ConverterClient interface { // InitJob 把 caller 的 multipart body streaming proxy 給 converter。 // - // scope: converter:job.write - // // 不 retry 5xx:multipart body 是 streaming(io.Reader 一次性),retry 會傳到一半的爛資料; // 直接 fail 由 caller(flow.go)依 §4.3.2 cleanup 鏈處理。 // @@ -58,13 +62,11 @@ type ConverterClient interface { // GetJob 查單一 job 狀態。 // - // scope: converter:job.read // retry: 5xx / network → max 3 attempts (0.5s, 1s, 2s 退避) GetJob(ctx context.Context, jobID string) (*ConverterJob, error) // Promote 把成功 job 的指定 stage 結果檔搬到 FAA。 // - // scope: converter:job.write // retry: 5xx / network → max 2 attempts (1s, 2s 退避) // // 502 file_gateway_unavailable → ErrFAAUnavailable(converter 端 FAA 不可達) @@ -72,7 +74,6 @@ type ConverterClient interface { // ListInProgressJobs 查指定 user 進行中的 job 清單(給 §2.6.1 lazy rebuild ownership 用)。 // - // scope: converter:job.read // retry: 5xx / network → max 1 attempt (0.5s 退避,輕量;不期望常態打) // // 預期 0 或 1 筆(同 user 同時只能 1 active job),但回 slice 保留 future-proof。 @@ -145,8 +146,17 @@ type ConverterClientOpts struct { // 範例:http://192.168.0.130:9501 BaseURL string - // Tokens 是 MCTokenClient(注入,non-nil 必填)— 用來取 service token。 - Tokens MCTokenClient + // APIKey 是 Phase 0.8b 引入的 pre-shared API key(VISIONA_CONVERTER_API_KEY)。 + // 必填非空 — `NewConverterClient` 會在 APIKey 為空時 panic(fail-fast, + // 避免 server 在「未認證」狀態下啟動)。 + // + // 值由 main.go 從 cfg.Conversion.ConverterAPIKey(env VISIONA_CONVERTER_API_KEY)注入; + // 與 converter middleware 端的 CONVERTER_API_KEY 必須對齊(rotate 時雙方同步換)。 + // + // 安全:絕不 log 此值(即使前綴);Authorization header 也不 log。 + // + // Phase 0.8b API key 改造 (見 ADR-015 §3 + conversion.md §3) + APIKey string // HTTPClient 為 optional;nil 用預設(timeout 10s)。GetJob / Promote / List 用。 HTTPClient *http.Client @@ -167,10 +177,6 @@ type ConverterClientOpts struct { // ========================================================================== const ( - // converter scope(對齊 task-scheduler openapi.yaml securitySchemes.OAuth2ClientCredentials.scopes) - scopeConverterWrite = "converter:job.write" - scopeConverterRead = "converter:job.read" - // HTTP timeout converterDefaultHTTPTimeout = 10 * time.Second converterInitHTTPTimeout = 30 * time.Minute // InitJob 大檔上傳 @@ -187,6 +193,12 @@ const ( promoteDefaultSource = "nef" ) +// ErrConverterAPIKeyNotConfigured 啟動時 API key 為空 — 應在 NewConverterClient 立即 panic、 +// 不要等到第一個 request 才發現「未認證」狀態跑進 prod。 +// +// Phase 0.8b API key 改造 (見 ADR-015 §3.5.3 部署檢查清單 #1) +var ErrConverterAPIKeyNotConfigured = errors.New("conversion/converter_client: APIKey is required (set VISIONA_CONVERTER_API_KEY)") + // ========================================================================== // 構造 + 內部實作 // ========================================================================== @@ -195,19 +207,28 @@ const ( // // 套件內 unexported struct(caller 拿 interface),讓未來換實作不影響 caller。 type converterClient struct { - baseURL string - tokens MCTokenClient - http *http.Client - httpInit *http.Client - now func() time.Time - logger *slog.Logger + baseURL string + apiKey string // Phase 0.8b:pre-shared API key,建構時 fail-fast 不允許空字串 + http *http.Client + httpInit *http.Client + now func() time.Time + logger *slog.Logger } // NewConverterClient 建立一個 ConverterClient 實例。 // -// 必填:BaseURL / Tokens。其他 optional。 +// 必填:BaseURL / APIKey。其他 optional。 // 注意:constructor 不驗 BaseURL 連線;第一次呼叫 method 才會打網路。 +// +// **Fail-fast**:若 opts.APIKey 為空字串,此函式 panic。理由是 Phase 0.8b 不允許 server 在 +// 「未認證」狀態下啟動 — 對齊 ADR-015 §3.5.3 部署檢查清單 #1。 +// +// `opts.Tokens` 是 Phase 0.8 廢棄欄位(見 ConverterClientOpts.Tokens 註解),即使非 nil 也不被 +// 內部使用;T5 切換 wire 點後從 struct 移除。 func NewConverterClient(opts ConverterClientOpts) ConverterClient { + if opts.APIKey == "" { + panic(ErrConverterAPIKeyNotConfigured) + } httpClient := opts.HTTPClient if httpClient == nil { httpClient = &http.Client{Timeout: converterDefaultHTTPTimeout} @@ -226,7 +247,7 @@ func NewConverterClient(opts ConverterClientOpts) ConverterClient { } return &converterClient{ baseURL: strings.TrimRight(opts.BaseURL, "/"), - tokens: opts.Tokens, + apiKey: opts.APIKey, http: httpClient, httpInit: httpInit, now: now, @@ -246,11 +267,6 @@ func (c *converterClient) InitJob(ctx context.Context, req InitConverterJobReq) return nil, fmt.Errorf("conversion/converter_client: InitJob body content type is required (must contain multipart boundary)") } - token, err := c.tokens.ServiceToken(ctx, scopeConverterWrite) - if err != nil { - return nil, c.wrapTokenErr(err) - } - endpoint := c.baseURL + "/api/v1/jobs" httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, req.Body) if err != nil { @@ -259,7 +275,8 @@ func (c *converterClient) InitJob(ctx context.Context, req InitConverterJobReq) // Content-Type 必須完整透傳(含 multipart boundary),不能讓 net/http 自動推導 httpReq.Header.Set("Content-Type", req.BodyContentType) httpReq.Header.Set("Accept", "application/json") - httpReq.Header.Set("Authorization", "Bearer "+token) + // Phase 0.8b:直接帶 pre-shared API key(不查 cache、不打 MC、不重簽) + httpReq.Header.Set("Authorization", "Bearer "+c.apiKey) startedAt := c.now() res, err := c.httpInit.Do(httpReq) @@ -310,9 +327,11 @@ func (c *converterClient) InitJob(ctx context.Context, req InitConverterJobReq) func (c *converterClient) mapInitError(status int, body []byte) error { apiErr := parseAPIError(body) - // 認證失敗(visionA service client 設定錯) + // Phase 0.8b:認證失敗 = visionA 端 VISIONA_CONVERTER_API_KEY 與 converter 端 CONVERTER_API_KEY + // 不對齊(rotate 未同步 / env 設錯 / converter middleware 未上線)。 + // 不 retry — API key 不對 retry 100 次也不會自己變對;對外 mask 成 converter_unavailable。 if status == http.StatusUnauthorized || status == http.StatusForbidden { - return fmt.Errorf("%w: init job %d", ErrServiceClientUnauthorized, status) + return fmt.Errorf("%w: init job %d", ErrConverterAuthFailed, status) } // 409 user_has_active_job — wrap 成 ActiveJobError @@ -357,14 +376,14 @@ func (c *converterClient) GetJob(ctx context.Context, jobID string) (*ConverterJ endpoint := c.baseURL + "/api/v1/jobs/" + url.PathEscape(jobID) - body, err := c.doWithRetry(ctx, "get_job", jobID, scopeConverterRead, converterMaxRetriesGet, - func(token string) (*http.Request, error) { + body, err := c.doWithRetry(ctx, "get_job", jobID, converterMaxRetriesGet, + func() (*http.Request, error) { req, rerr := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if rerr != nil { return nil, rerr } req.Header.Set("Accept", "application/json") - req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Authorization", "Bearer "+c.apiKey) return req, nil }, c.mapGetJobError, @@ -380,8 +399,9 @@ func (c *converterClient) GetJob(ctx context.Context, jobID string) (*ConverterJ func (c *converterClient) mapGetJobError(status int, body []byte) error { apiErr := parseAPIError(body) + // Phase 0.8b:401/403 → ErrConverterAuthFailed(API key 不對齊) if status == http.StatusUnauthorized || status == http.StatusForbidden { - return fmt.Errorf("%w: get_job %d", ErrServiceClientUnauthorized, status) + return fmt.Errorf("%w: get_job %d", ErrConverterAuthFailed, status) } if status == http.StatusNotFound { return fmt.Errorf("%w: get_job %d (%s)", ErrJobNotFound, status, apiErr.Code) @@ -422,15 +442,15 @@ func (c *converterClient) Promote(ctx context.Context, jobID string, req Promote return nil, fmt.Errorf("%w: marshal promote request: %v", ErrConverterUnavailable, err) } - respBody, err := c.doWithRetry(ctx, "promote", jobID, scopeConverterWrite, converterMaxRetriesPromote, - func(token string) (*http.Request, error) { + respBody, err := c.doWithRetry(ctx, "promote", jobID, converterMaxRetriesPromote, + func() (*http.Request, error) { r, rerr := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(bodyJSON)) if rerr != nil { return nil, rerr } r.Header.Set("Content-Type", "application/json") r.Header.Set("Accept", "application/json") - r.Header.Set("Authorization", "Bearer "+token) + r.Header.Set("Authorization", "Bearer "+c.apiKey) return r, nil }, c.mapPromoteError, @@ -446,13 +466,15 @@ func (c *converterClient) Promote(ctx context.Context, jobID string, req Promote // // 特殊 mapping: // - 502 file_gateway_unavailable → ErrFAAUnavailable -// - 503 auth_service_unavailable → ErrIDPUnavailable +// - 503 auth_service_unavailable → ErrConverterUnavailable(Phase 0.8b:MC 路徑取消, +// converter 端的 503 從 visionA 角度看是 converter 整體不可用) // - 409 job_not_ready_for_promote / source_not_available → ErrJobNotCompleted func (c *converterClient) mapPromoteError(status int, body []byte) error { apiErr := parseAPIError(body) + // Phase 0.8b:401/403 → ErrConverterAuthFailed(API key 不對齊) if status == http.StatusUnauthorized || status == http.StatusForbidden { - return fmt.Errorf("%w: promote %d", ErrServiceClientUnauthorized, status) + return fmt.Errorf("%w: promote %d", ErrConverterAuthFailed, status) } if status == http.StatusNotFound { return fmt.Errorf("%w: promote %d (%s)", ErrJobNotFound, status, apiErr.Code) @@ -466,8 +488,9 @@ func (c *converterClient) mapPromoteError(status int, body []byte) error { return fmt.Errorf("%w: promote %d (%s)", ErrFAAUnavailable, status, apiErr.Code) } if status == http.StatusServiceUnavailable { - // converter 端 MC 簽 token 失敗 - return fmt.Errorf("%w: promote %d (%s)", ErrIDPUnavailable, status, apiErr.Code) + // Phase 0.8b:MC token 路徑取消後,converter 端 503 一律歸為 converter 整體不可用 + // (從 visionA 角度看,無法區分「converter 自己」vs「converter 上游 MC」 — 都是不可用) + return fmt.Errorf("%w: promote %d (%s)", ErrConverterUnavailable, status, apiErr.Code) } if status == http.StatusBadRequest || status == http.StatusUnprocessableEntity { return &ConverterValidationError{ @@ -495,14 +518,14 @@ func (c *converterClient) ListInProgressJobs(ctx context.Context, userID string) q.Set("status", "in_progress") endpoint := c.baseURL + "/api/v1/jobs?" + q.Encode() - body, err := c.doWithRetry(ctx, "list_jobs", userID, scopeConverterRead, converterMaxRetriesList, - func(token string) (*http.Request, error) { + body, err := c.doWithRetry(ctx, "list_jobs", userID, converterMaxRetriesList, + func() (*http.Request, error) { r, rerr := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if rerr != nil { return nil, rerr } r.Header.Set("Accept", "application/json") - r.Header.Set("Authorization", "Bearer "+token) + r.Header.Set("Authorization", "Bearer "+c.apiKey) return r, nil }, c.mapListJobsError, @@ -520,8 +543,9 @@ func (c *converterClient) ListInProgressJobs(ctx context.Context, userID string) func (c *converterClient) mapListJobsError(status int, body []byte) error { apiErr := parseAPIError(body) + // Phase 0.8b:401/403 → ErrConverterAuthFailed(API key 不對齊) if status == http.StatusUnauthorized || status == http.StatusForbidden { - return fmt.Errorf("%w: list_jobs %d", ErrServiceClientUnauthorized, status) + return fmt.Errorf("%w: list_jobs %d", ErrConverterAuthFailed, status) } if status >= 400 && status < 500 { return fmt.Errorf("%w: list_jobs %d (%s)", ErrValidationFailed, status, apiErr.Code) @@ -535,21 +559,22 @@ func (c *converterClient) mapListJobsError(status int, body []byte) error { // doWithRetry 是 GetJob / Promote / List 共用的 retry 執行器。 // -// 與 mc_token_client.doWithRetry 結構類似但有以下差異: -// - 每次 attempt 內呼叫 ServiceToken 取最新 token(401 時 caller 不主動 invalidate cache — -// 設計取捨:避免 cache 被惡意 401 attack 反覆清空;正常 401 = secret 設定錯,retry 也沒用) -// - retry 次數由 caller 傳入(不同 endpoint 不同上限) +// Phase 0.8b 變更: +// - 移除 `scope` 參數與 `tokens.ServiceToken()` 呼叫(API key 改造後不再 per-attempt 取 token) +// - reqBuilder closure 不再接 `token` 參數 — caller 直接讀 c.apiKey 自行 set header +// +// 行為(不變): // - 4xx / 401 / 403 不 retry;5xx / network / timeout 可 retry +// - retry 次數由 caller 傳入(不同 endpoint 不同上限) // - mapErr 由 caller 傳入,因為 GetJob / Promote / List 的 4xx mapping 細節不同 // // reqBuilder 是「每次 attempt 都重新建一個 *http.Request」的 closure // — request body 可能在 retry 時已被讀完,必須重建。caller 內部用 bytes.NewReader 等可重建的 body。 -// — token 是 closure 參數,每次 attempt 都拿最新(也涵蓋 cache 過期 refresh 的場景) func (c *converterClient) doWithRetry( ctx context.Context, - endpointKind, label, scope string, + endpointKind, label string, maxRetries int, - reqBuilder func(token string) (*http.Request, error), + reqBuilder func() (*http.Request, error), mapErr func(status int, body []byte) error, ) ([]byte, error) { var lastErr error @@ -563,14 +588,7 @@ func (c *converterClient) doWithRetry( } } - // 每次 attempt 都重新取 token(cache hit 情境下成本極低) - token, err := c.tokens.ServiceToken(ctx, scope) - if err != nil { - // token 取不到 — 不可重試(IdP 端問題,不在 converter 重試矩陣內) - return nil, c.wrapTokenErr(err) - } - - req, err := reqBuilder(token) + req, err := reqBuilder() if err != nil { return nil, fmt.Errorf("%w: build %s request: %v", ErrConverterUnavailable, endpointKind, err) } @@ -677,21 +695,8 @@ func converterRetryBackoff(attempt int) time.Duration { return converterRetryBase * (1 << (attempt - 1)) } -// wrapTokenErr 把 MCTokenClient 取 token 時的錯誤包成 caller 已預期的 sentinel。 -// -// MCTokenClient 已經把錯誤分類成 ErrServiceClientUnauthorized / ErrMCTokenUnavailable / ctx.Err, -// 我們不在 converter_client 層改動分類,純粹透傳(讓上層用 errors.Is 比對)。 -func (c *converterClient) wrapTokenErr(err error) error { - if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { - return err - } - // 已是 sentinel(ErrServiceClientUnauthorized / ErrMCTokenUnavailable)— 直接透傳 - if errors.Is(err, ErrServiceClientUnauthorized) || errors.Is(err, ErrMCTokenUnavailable) { - return err - } - // 兜底:未預期的 token 錯誤包成 ErrMCTokenUnavailable - return fmt.Errorf("%w: %v", ErrMCTokenUnavailable, err) -} +// Phase 0.8b:wrapTokenErr 已移除(API key 改造後不再透過 MCTokenClient 取 token, +// 因此沒有 token-取-不到 的失敗路徑需要 wrap)。 // ========================================================================== // Response 解析(converter openapi.yaml shapes) diff --git a/visionA-backend/internal/conversion/converter_client_test.go b/visionA-backend/internal/conversion/converter_client_test.go index 5c77f66..1d337a9 100644 --- a/visionA-backend/internal/conversion/converter_client_test.go +++ b/visionA-backend/internal/conversion/converter_client_test.go @@ -2,28 +2,29 @@ // // 測試策略: // - 用 httptest.Server mock task-scheduler 的 4 個 endpoint -// - 用 stub MCTokenClient(直接回 token / 注入錯誤),不耦合真實 mc_token_client 邏輯 +// - **Phase 0.8b**:直接用 string fake API key(不再注入 stub MCTokenClient)— 與 ADR-015 +// pre-shared key 模式一致;驗 server 端確實收到 `Authorization: Bearer ` // - 用 atomic counter 驗 retry 行為(attempts 數對齊 conversion.md §9.1) // - 大 body streaming 用 io.LimitReader(不真的寫 100MB 進 RAM) // // 對應 task 規範必含 case: -// - InitJob:Success / StreamingBody / ContentTypeHeader / Conflict409 / Validation400 / 5xx_NoRetry / AuthExpired -// - GetJob:Success / NotFound / 5xx_RetryThenSuccess -// - Promote:Success / BadGateway -// - List:Success / Empty / 5xxRetry +// - InitJob:Success / StreamingBody / ContentTypeHeader / Conflict409 / Validation400 / 5xx_NoRetry / AuthFailed401 / AuthFailed403 +// - GetJob:Success / NotFound / 5xx_RetryThenSuccess / AuthFailed401_NoRetry +// - Promote:Success / BadGateway / AuthFailed401_NoRetry +// - List:Success / Empty / 5xxRetry / AuthFailed401_NoRetry +// - Constructor:Panics_When_APIKey_Empty // // Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §2.5 + §9.1) +// Phase 0.8b API key 改造 (見 ADR-015 §3 + §6 + conversion.md §3) package conversion import ( "context" "errors" - "fmt" "io" "net/http" "net/http/httptest" "strings" - "sync" "sync/atomic" "testing" "time" @@ -33,50 +34,16 @@ import ( ) // ========================================================================== -// stub MCTokenClient — 解耦真實 mc_token_client 邏輯 +// Phase 0.8b:fake API key fixtures // ========================================================================== - -// stubTokenClient 是 test 用的 fake MCTokenClient。 -type stubTokenClient struct { - mu sync.Mutex - token string - tokenErr error - callsByScope map[string]int -} - -func newStubTokenClient(token string) *stubTokenClient { - return &stubTokenClient{ - token: token, - callsByScope: make(map[string]int), - } -} - -func (s *stubTokenClient) ServiceToken(ctx context.Context, scope string) (string, error) { - s.mu.Lock() - defer s.mu.Unlock() - s.callsByScope[scope]++ - if s.tokenErr != nil { - return "", s.tokenErr - } - return s.token, nil -} - -func (s *stubTokenClient) IssueDelegatedDownload(ctx context.Context, in IssueDownloadReq) (*DelegatedDownloadToken, error) { - // converter_client 不會呼叫;此處只是滿足 interface - return nil, fmt.Errorf("stubTokenClient.IssueDelegatedDownload should not be called from converter_client tests") -} - -func (s *stubTokenClient) setError(err error) { - s.mu.Lock() - defer s.mu.Unlock() - s.tokenErr = err -} - -func (s *stubTokenClient) calls(scope string) int { - s.mu.Lock() - defer s.mu.Unlock() - return s.callsByScope[scope] -} +// +// 取明顯 fake 字串、含 `do-not-use-in-prod` marker(grepable,避免被誤當真 key)。 +// 長度 64 hex chars 對齊 ADR-015 §3.4 production key 規格(`openssl rand -hex 32`)— 即使 +// 未來加 length validation 也不會 break 這個 fixture。 +const ( + fakeConverterAPIKey = "fake-converter-api-key-do-not-use-in-prod-aaaaaaaaaaaaaaaaaaaaaaaa" + fakeFAAAPIKey = "fake-faa-api-key-do-not-use-in-prod-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" +) // ========================================================================== // converter mock server helpers @@ -84,13 +51,15 @@ func (s *stubTokenClient) calls(scope string) int { // newConverterClientForTest 建立指向 mock server 的 ConverterClient。 // -// 使用較短的 init/http timeout 加速 test;retry 退避保持原本(converterRetryBackoff 1s 起跳 +// Phase 0.8b:直接傳 fakeConverterAPIKey;不再需要 MCTokenClient 注入。 +// +// 使用較短的 init/http timeout 加速 test;retry 退避保持原本(converterRetryBackoff 0.5s 起跳 // 對 retry test 有點久但仍可接受 — 5xx retry test 的 max 2 retries = 0.5s + 1s = 1.5s)。 -func newConverterClientForTest(t *testing.T, baseURL string, tokens MCTokenClient) ConverterClient { +func newConverterClientForTest(t *testing.T, baseURL string) ConverterClient { t.Helper() return NewConverterClient(ConverterClientOpts{ BaseURL: baseURL, - Tokens: tokens, + APIKey: fakeConverterAPIKey, HTTPClient: &http.Client{Timeout: 5 * time.Second}, InitHTTPClient: &http.Client{Timeout: 5 * time.Second}, Logger: silentLogger(), @@ -102,15 +71,18 @@ func newConverterClientForTest(t *testing.T, baseURL string, tokens MCTokenClien // ========================================================================== // TestInitJob_Success:mock 接受 multipart,回 201 + job spec。 +// +// Phase 0.8b:驗 server 端確實收到 `Authorization: Bearer `(pre-shared +// API key 直接 set header;不再透過 MCTokenClient 取 token)。 func TestInitJob_Success(t *testing.T) { t.Parallel() - tokens := newStubTokenClient("svc-tok") var serverContentType string + var serverAuth string mux := http.NewServeMux() mux.HandleFunc("/api/v1/jobs", func(w http.ResponseWriter, r *http.Request) { require.Equal(t, http.MethodPost, r.Method) - require.Equal(t, "Bearer svc-tok", r.Header.Get("Authorization")) + serverAuth = r.Header.Get("Authorization") serverContentType = r.Header.Get("Content-Type") // drain body 確認 streaming 完成 @@ -132,7 +104,7 @@ func TestInitJob_Success(t *testing.T) { srv := httptest.NewServer(mux) t.Cleanup(srv.Close) - cc := newConverterClientForTest(t, srv.URL, tokens) + cc := newConverterClientForTest(t, srv.URL) job, err := cc.InitJob(context.Background(), InitConverterJobReq{ UserID: "alice", Platform: "520", @@ -148,7 +120,8 @@ func TestInitJob_Success(t *testing.T) { assert.Equal(t, "onnx", job.Stage) assert.Equal(t, "multipart/form-data; boundary=xyz", serverContentType, "InitJob 必須完整透傳 Content-Type 含 boundary(converter multer 解析依賴此)") - assert.Equal(t, 1, tokens.calls(scopeConverterWrite)) + assert.Equal(t, "Bearer "+fakeConverterAPIKey, serverAuth, + "Phase 0.8b:必須直接帶 pre-shared API key(不經 MC token cache)") } // TestInitJob_StreamingBody:driver 寫 100MB 假資料給 io.Reader,confirm streaming(不全 buffer RAM)。 @@ -159,7 +132,6 @@ func TestInitJob_Success(t *testing.T) { func TestInitJob_StreamingBody(t *testing.T) { t.Parallel() - tokens := newStubTokenClient("svc-tok") var serverBytesRead int64 mux := http.NewServeMux() mux.HandleFunc("/api/v1/jobs", func(w http.ResponseWriter, r *http.Request) { @@ -184,11 +156,10 @@ func TestInitJob_StreamingBody(t *testing.T) { R: io.LimitReader(zerosReader{}, totalSize), } - cc := newConverterClientForTest(t, srv.URL, tokens) // 對 streaming test 加長 timeout - cc = NewConverterClient(ConverterClientOpts{ + cc := NewConverterClient(ConverterClientOpts{ BaseURL: srv.URL, - Tokens: tokens, + APIKey: fakeConverterAPIKey, HTTPClient: &http.Client{Timeout: 30 * time.Second}, InitHTTPClient: &http.Client{Timeout: 30 * time.Second}, Logger: silentLogger(), @@ -215,7 +186,6 @@ func TestInitJob_StreamingBody(t *testing.T) { func TestInitJob_ContentTypeHeader(t *testing.T) { t.Parallel() - tokens := newStubTokenClient("svc-tok") var receivedCT string mux := http.NewServeMux() mux.HandleFunc("/api/v1/jobs", func(w http.ResponseWriter, r *http.Request) { @@ -234,7 +204,7 @@ func TestInitJob_ContentTypeHeader(t *testing.T) { t.Cleanup(srv.Close) const customCT = "multipart/form-data; boundary=---xxx-very-specific-boundary-yyy---" - cc := newConverterClientForTest(t, srv.URL, tokens) + cc := newConverterClientForTest(t, srv.URL) _, err := cc.InitJob(context.Background(), InitConverterJobReq{ Body: strings.NewReader("body content"), BodyContentType: customCT, @@ -247,7 +217,6 @@ func TestInitJob_ContentTypeHeader(t *testing.T) { func TestInitJob_Conflict409_ActiveJobError(t *testing.T) { t.Parallel() - tokens := newStubTokenClient("svc-tok") mux := http.NewServeMux() mux.HandleFunc("/api/v1/jobs", func(w http.ResponseWriter, r *http.Request) { _, _ = io.Copy(io.Discard, r.Body) @@ -271,7 +240,7 @@ func TestInitJob_Conflict409_ActiveJobError(t *testing.T) { srv := httptest.NewServer(mux) t.Cleanup(srv.Close) - cc := newConverterClientForTest(t, srv.URL, tokens) + cc := newConverterClientForTest(t, srv.URL) _, err := cc.InitJob(context.Background(), InitConverterJobReq{ Body: strings.NewReader("x"), BodyContentType: "multipart/form-data; boundary=xxx", @@ -294,7 +263,6 @@ func TestInitJob_Conflict409_ActiveJobError(t *testing.T) { func TestInitJob_Validation400(t *testing.T) { t.Parallel() - tokens := newStubTokenClient("svc-tok") mux := http.NewServeMux() mux.HandleFunc("/api/v1/jobs", func(w http.ResponseWriter, r *http.Request) { _, _ = io.Copy(io.Discard, r.Body) @@ -317,7 +285,7 @@ func TestInitJob_Validation400(t *testing.T) { srv := httptest.NewServer(mux) t.Cleanup(srv.Close) - cc := newConverterClientForTest(t, srv.URL, tokens) + cc := newConverterClientForTest(t, srv.URL) _, err := cc.InitJob(context.Background(), InitConverterJobReq{ Body: strings.NewReader("x"), BodyContentType: "multipart/form-data; boundary=xxx", @@ -341,7 +309,6 @@ func TestInitJob_Validation400(t *testing.T) { func TestInitJob_5xx_NoRetry(t *testing.T) { t.Parallel() - tokens := newStubTokenClient("svc-tok") var counter atomic.Int32 mux := http.NewServeMux() mux.HandleFunc("/api/v1/jobs", func(w http.ResponseWriter, r *http.Request) { @@ -354,7 +321,7 @@ func TestInitJob_5xx_NoRetry(t *testing.T) { srv := httptest.NewServer(mux) t.Cleanup(srv.Close) - cc := newConverterClientForTest(t, srv.URL, tokens) + cc := newConverterClientForTest(t, srv.URL) _, err := cc.InitJob(context.Background(), InitConverterJobReq{ Body: strings.NewReader("x"), BodyContentType: "multipart/form-data; boundary=xxx", @@ -366,53 +333,71 @@ func TestInitJob_5xx_NoRetry(t *testing.T) { "InitJob 不可 retry 5xx(streaming body 不可 replay)") } -// TestInitJob_AuthExpired:mock 回 401 → return ErrServiceClientUnauthorized。 -func TestInitJob_AuthExpired(t *testing.T) { +// TestInitJob_AuthFailed401:mock 回 401 → ErrConverterAuthFailed(Phase 0.8b 新 sentinel; +// 對外 mask 成 converter_unavailable / 502,避免洩漏「API key 不對齊」內部運維狀態)。 +func TestInitJob_AuthFailed401(t *testing.T) { t.Parallel() - tokens := newStubTokenClient("expired-tok") + var attempts atomic.Int32 mux := http.NewServeMux() mux.HandleFunc("/api/v1/jobs", func(w http.ResponseWriter, r *http.Request) { + attempts.Add(1) _, _ = io.Copy(io.Discard, r.Body) w.WriteHeader(http.StatusUnauthorized) - _, _ = w.Write([]byte(`{"error":{"code":"invalid_token","message":"...","request_id":"r"}}`)) + _, _ = w.Write([]byte(`{"error":"unauthorized"}`)) }) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) - cc := newConverterClientForTest(t, srv.URL, tokens) + cc := newConverterClientForTest(t, srv.URL) _, err := cc.InitJob(context.Background(), InitConverterJobReq{ Body: strings.NewReader("x"), BodyContentType: "multipart/form-data; boundary=xxx", }) require.Error(t, err) - assert.True(t, errors.Is(err, ErrServiceClientUnauthorized)) + assert.True(t, errors.Is(err, ErrConverterAuthFailed), + "Phase 0.8b:401 必須 mapping 到新 sentinel ErrConverterAuthFailed") + // Phase 0.8b T3:舊 sentinel ErrServiceClientUnauthorized 已移除, + // 改由 ErrConverterAuthFailed 接管 401/403 mapping。 + assert.Equal(t, int32(1), attempts.Load(), + "401 不應 retry(API key 不對 retry 也是 401)") + // 對外 ErrorCode mask 成 converter_unavailable(不洩漏「API key 不對」) + assert.Equal(t, "converter_unavailable", ErrorCode(err)) + assert.Equal(t, 502, HTTPStatus(err)) } -// TestInitJob_TokenFailure_Propagated:MCTokenClient 取 token 失敗時,錯誤透傳。 -func TestInitJob_TokenFailure_Propagated(t *testing.T) { +// TestInitJob_AuthFailed403:對稱 — mock 回 403 → 同樣 ErrConverterAuthFailed、不 retry。 +func TestInitJob_AuthFailed403(t *testing.T) { t.Parallel() - tokens := newStubTokenClient("") - tokens.setError(ErrServiceClientUnauthorized) + var attempts atomic.Int32 + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/jobs", func(w http.ResponseWriter, r *http.Request) { + attempts.Add(1) + _, _ = io.Copy(io.Discard, r.Body) + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"error":"unauthorized"}`)) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) - cc := newConverterClientForTest(t, "http://unused", tokens) + cc := newConverterClientForTest(t, srv.URL) _, err := cc.InitJob(context.Background(), InitConverterJobReq{ Body: strings.NewReader("x"), BodyContentType: "multipart/form-data; boundary=xxx", }) require.Error(t, err) - assert.True(t, errors.Is(err, ErrServiceClientUnauthorized)) + assert.True(t, errors.Is(err, ErrConverterAuthFailed)) + assert.Equal(t, int32(1), attempts.Load()) } // TestInitJob_RequiredFieldsValidation:本地參數驗證(不打網路)。 func TestInitJob_RequiredFieldsValidation(t *testing.T) { t.Parallel() - tokens := newStubTokenClient("svc-tok") - cc := newConverterClientForTest(t, "http://unused", tokens) + cc := newConverterClientForTest(t, "http://unused") // 缺 body _, err := cc.InitJob(context.Background(), InitConverterJobReq{ @@ -429,6 +414,29 @@ func TestInitJob_RequiredFieldsValidation(t *testing.T) { assert.Contains(t, err.Error(), "content type is required") } +// TestNewConverterClient_Panics_When_APIKey_Empty:fail-fast 驗證 — Phase 0.8b +// 不允許 server 在「未認證」狀態下啟動,建構式必須立即 panic。 +// +// 對齊 ADR-015 §3.5.3 部署檢查清單 #1。 +func TestNewConverterClient_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, ErrConverterAPIKeyNotConfigured), + "panic 應為 ErrConverterAPIKeyNotConfigured sentinel") + }() + + _ = NewConverterClient(ConverterClientOpts{ + BaseURL: "http://example.com", + APIKey: "", // empty — 必須觸發 panic + Logger: silentLogger(), + }) +} + // ========================================================================== // GetJob tests // ========================================================================== @@ -437,11 +445,11 @@ func TestInitJob_RequiredFieldsValidation(t *testing.T) { func TestGetJob_Success(t *testing.T) { t.Parallel() - tokens := newStubTokenClient("svc-tok") mux := http.NewServeMux() mux.HandleFunc("/api/v1/jobs/", func(w http.ResponseWriter, r *http.Request) { require.Equal(t, http.MethodGet, r.Method) - require.Equal(t, "Bearer svc-tok", r.Header.Get("Authorization")) + require.Equal(t, "Bearer "+fakeConverterAPIKey, r.Header.Get("Authorization"), + "Phase 0.8b:每個 GET 也要直接帶 pre-shared API key") // path: /api/v1/jobs/{id} assert.Contains(t, r.URL.Path, "550e8400") w.Header().Set("Content-Type", "application/json") @@ -464,7 +472,7 @@ func TestGetJob_Success(t *testing.T) { srv := httptest.NewServer(mux) t.Cleanup(srv.Close) - cc := newConverterClientForTest(t, srv.URL, tokens) + cc := newConverterClientForTest(t, srv.URL) job, err := cc.GetJob(context.Background(), "550e8400-e29b-41d4-a716-446655440000") require.NoError(t, err) require.NotNil(t, job) @@ -483,7 +491,6 @@ func TestGetJob_Success(t *testing.T) { func TestGetJob_NotFound(t *testing.T) { t.Parallel() - tokens := newStubTokenClient("svc-tok") mux := http.NewServeMux() mux.HandleFunc("/api/v1/jobs/", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) @@ -492,7 +499,7 @@ func TestGetJob_NotFound(t *testing.T) { srv := httptest.NewServer(mux) t.Cleanup(srv.Close) - cc := newConverterClientForTest(t, srv.URL, tokens) + cc := newConverterClientForTest(t, srv.URL) _, err := cc.GetJob(context.Background(), "missing-job") require.Error(t, err) assert.True(t, errors.Is(err, ErrJobNotFound)) @@ -502,7 +509,6 @@ func TestGetJob_NotFound(t *testing.T) { func TestGetJob_5xx_RetryThenSuccess(t *testing.T) { t.Parallel() - tokens := newStubTokenClient("svc-tok") var counter atomic.Int32 mux := http.NewServeMux() mux.HandleFunc("/api/v1/jobs/", func(w http.ResponseWriter, r *http.Request) { @@ -524,7 +530,7 @@ func TestGetJob_5xx_RetryThenSuccess(t *testing.T) { srv := httptest.NewServer(mux) t.Cleanup(srv.Close) - cc := newConverterClientForTest(t, srv.URL, tokens) + cc := newConverterClientForTest(t, srv.URL) job, err := cc.GetJob(context.Background(), "j1") require.NoError(t, err) require.NotNil(t, job) @@ -536,7 +542,6 @@ func TestGetJob_5xx_RetryThenSuccess(t *testing.T) { func TestGetJob_5xx_Exhausted(t *testing.T) { t.Parallel() - tokens := newStubTokenClient("svc-tok") var counter atomic.Int32 mux := http.NewServeMux() mux.HandleFunc("/api/v1/jobs/", func(w http.ResponseWriter, r *http.Request) { @@ -547,7 +552,7 @@ func TestGetJob_5xx_Exhausted(t *testing.T) { srv := httptest.NewServer(mux) t.Cleanup(srv.Close) - cc := newConverterClientForTest(t, srv.URL, tokens) + cc := newConverterClientForTest(t, srv.URL) _, err := cc.GetJob(context.Background(), "j1") require.Error(t, err) assert.True(t, errors.Is(err, ErrConverterUnavailable)) @@ -558,7 +563,6 @@ func TestGetJob_5xx_Exhausted(t *testing.T) { func TestGetJob_ContextCancel_NoRetry(t *testing.T) { t.Parallel() - tokens := newStubTokenClient("svc-tok") var counter atomic.Int32 mux := http.NewServeMux() mux.HandleFunc("/api/v1/jobs/", func(w http.ResponseWriter, r *http.Request) { @@ -569,7 +573,7 @@ func TestGetJob_ContextCancel_NoRetry(t *testing.T) { srv := httptest.NewServer(mux) t.Cleanup(srv.Close) - cc := newConverterClientForTest(t, srv.URL, tokens) + cc := newConverterClientForTest(t, srv.URL) ctx, cancel := context.WithCancel(context.Background()) // 第一次 attempt 完後 cancel;第二次 retry 等待時應立即 return @@ -586,6 +590,29 @@ func TestGetJob_ContextCancel_NoRetry(t *testing.T) { "ctx cancel 應在第 1 次 attempt 後立即 return,不再打 server") } +// TestGetJob_AuthFailed401_NoRetry:401 → ErrConverterAuthFailed、不 retry(API key 不對齊)。 +func TestGetJob_AuthFailed401_NoRetry(t *testing.T) { + t.Parallel() + + var attempts atomic.Int32 + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/jobs/", func(w http.ResponseWriter, r *http.Request) { + attempts.Add(1) + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"unauthorized"}`)) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + cc := newConverterClientForTest(t, srv.URL) + _, err := cc.GetJob(context.Background(), "j1") + require.Error(t, err) + assert.True(t, errors.Is(err, ErrConverterAuthFailed), + "Phase 0.8b:GetJob 401 必須 mapping 到 ErrConverterAuthFailed") + assert.Equal(t, int32(1), attempts.Load(), + "401 不應 retry — API key 不對 retry 100 次也不會自己變對") +} + // ========================================================================== // Promote tests // ========================================================================== @@ -594,7 +621,6 @@ func TestGetJob_ContextCancel_NoRetry(t *testing.T) { func TestPromote_Success(t *testing.T) { t.Parallel() - tokens := newStubTokenClient("svc-tok") var receivedBody string mux := http.NewServeMux() mux.HandleFunc("/api/v1/jobs/", func(w http.ResponseWriter, r *http.Request) { @@ -621,7 +647,7 @@ func TestPromote_Success(t *testing.T) { srv := httptest.NewServer(mux) t.Cleanup(srv.Close) - cc := newConverterClientForTest(t, srv.URL, tokens) + cc := newConverterClientForTest(t, srv.URL) result, err := cc.Promote(context.Background(), "j1", PromoteReq{ UserID: "alice", Source: "nef", @@ -641,7 +667,6 @@ func TestPromote_Success(t *testing.T) { func TestPromote_DefaultSource(t *testing.T) { t.Parallel() - tokens := newStubTokenClient("svc-tok") var receivedBody string mux := http.NewServeMux() mux.HandleFunc("/api/v1/jobs/", func(w http.ResponseWriter, r *http.Request) { @@ -657,7 +682,7 @@ func TestPromote_DefaultSource(t *testing.T) { srv := httptest.NewServer(mux) t.Cleanup(srv.Close) - cc := newConverterClientForTest(t, srv.URL, tokens) + cc := newConverterClientForTest(t, srv.URL) _, err := cc.Promote(context.Background(), "j1", PromoteReq{ UserID: "alice", TargetObjectKey: "x", @@ -670,7 +695,6 @@ func TestPromote_DefaultSource(t *testing.T) { func TestPromote_BadGateway(t *testing.T) { t.Parallel() - tokens := newStubTokenClient("svc-tok") mux := http.NewServeMux() mux.HandleFunc("/api/v1/jobs/", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusBadGateway) @@ -679,7 +703,7 @@ func TestPromote_BadGateway(t *testing.T) { srv := httptest.NewServer(mux) t.Cleanup(srv.Close) - cc := newConverterClientForTest(t, srv.URL, tokens) + cc := newConverterClientForTest(t, srv.URL) _, err := cc.Promote(context.Background(), "j1", PromoteReq{ UserID: "alice", TargetObjectKey: "x", @@ -693,7 +717,6 @@ func TestPromote_BadGateway(t *testing.T) { func TestPromote_NotCompleted409(t *testing.T) { t.Parallel() - tokens := newStubTokenClient("svc-tok") mux := http.NewServeMux() mux.HandleFunc("/api/v1/jobs/", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusConflict) @@ -702,7 +725,7 @@ func TestPromote_NotCompleted409(t *testing.T) { srv := httptest.NewServer(mux) t.Cleanup(srv.Close) - cc := newConverterClientForTest(t, srv.URL, tokens) + cc := newConverterClientForTest(t, srv.URL) _, err := cc.Promote(context.Background(), "j1", PromoteReq{ UserID: "alice", TargetObjectKey: "x", @@ -715,7 +738,6 @@ func TestPromote_NotCompleted409(t *testing.T) { func TestPromote_NotFound404(t *testing.T) { t.Parallel() - tokens := newStubTokenClient("svc-tok") mux := http.NewServeMux() mux.HandleFunc("/api/v1/jobs/", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) @@ -724,7 +746,7 @@ func TestPromote_NotFound404(t *testing.T) { srv := httptest.NewServer(mux) t.Cleanup(srv.Close) - cc := newConverterClientForTest(t, srv.URL, tokens) + cc := newConverterClientForTest(t, srv.URL) _, err := cc.Promote(context.Background(), "j1", PromoteReq{ UserID: "alice", TargetObjectKey: "x", @@ -737,8 +759,7 @@ func TestPromote_NotFound404(t *testing.T) { func TestPromote_RequiredFieldsValidation(t *testing.T) { t.Parallel() - tokens := newStubTokenClient("svc-tok") - cc := newConverterClientForTest(t, "http://unused", tokens) + cc := newConverterClientForTest(t, "http://unused") _, err := cc.Promote(context.Background(), "", PromoteReq{TargetObjectKey: "x"}) require.Error(t, err) @@ -749,6 +770,31 @@ func TestPromote_RequiredFieldsValidation(t *testing.T) { assert.Contains(t, err.Error(), "target_object_key is required") } +// TestPromote_AuthFailed401_NoRetry:401 → ErrConverterAuthFailed、不 retry。 +func TestPromote_AuthFailed401_NoRetry(t *testing.T) { + t.Parallel() + + var attempts atomic.Int32 + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/jobs/", func(w http.ResponseWriter, r *http.Request) { + attempts.Add(1) + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"unauthorized"}`)) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + cc := newConverterClientForTest(t, srv.URL) + _, err := cc.Promote(context.Background(), "j1", PromoteReq{ + UserID: "alice", + TargetObjectKey: "x", + }) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrConverterAuthFailed), + "Phase 0.8b:Promote 401 必須 mapping 到 ErrConverterAuthFailed") + assert.Equal(t, int32(1), attempts.Load(), "401 不應 retry") +} + // ========================================================================== // ListInProgressJobs tests // ========================================================================== @@ -757,7 +803,6 @@ func TestPromote_RequiredFieldsValidation(t *testing.T) { func TestListInProgressJobs_Success(t *testing.T) { t.Parallel() - tokens := newStubTokenClient("svc-tok") var receivedQuery string mux := http.NewServeMux() mux.HandleFunc("/api/v1/jobs", func(w http.ResponseWriter, r *http.Request) { @@ -789,7 +834,7 @@ func TestListInProgressJobs_Success(t *testing.T) { srv := httptest.NewServer(mux) t.Cleanup(srv.Close) - cc := newConverterClientForTest(t, srv.URL, tokens) + cc := newConverterClientForTest(t, srv.URL) jobs, err := cc.ListInProgressJobs(context.Background(), "alice") require.NoError(t, err) require.Len(t, jobs, 1) @@ -806,7 +851,6 @@ func TestListInProgressJobs_Success(t *testing.T) { func TestListInProgressJobs_Empty(t *testing.T) { t.Parallel() - tokens := newStubTokenClient("svc-tok") mux := http.NewServeMux() mux.HandleFunc("/api/v1/jobs", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") @@ -816,7 +860,7 @@ func TestListInProgressJobs_Empty(t *testing.T) { srv := httptest.NewServer(mux) t.Cleanup(srv.Close) - cc := newConverterClientForTest(t, srv.URL, tokens) + cc := newConverterClientForTest(t, srv.URL) jobs, err := cc.ListInProgressJobs(context.Background(), "alice") require.NoError(t, err) assert.Len(t, jobs, 0, "empty result 應回空 slice,不是 nil 也不是 error") @@ -827,7 +871,6 @@ func TestListInProgressJobs_Empty(t *testing.T) { func TestListInProgressJobs_5xxRetry(t *testing.T) { t.Parallel() - tokens := newStubTokenClient("svc-tok") var counter atomic.Int32 mux := http.NewServeMux() mux.HandleFunc("/api/v1/jobs", func(w http.ResponseWriter, r *http.Request) { @@ -844,7 +887,7 @@ func TestListInProgressJobs_5xxRetry(t *testing.T) { srv := httptest.NewServer(mux) t.Cleanup(srv.Close) - cc := newConverterClientForTest(t, srv.URL, tokens) + cc := newConverterClientForTest(t, srv.URL) jobs, err := cc.ListInProgressJobs(context.Background(), "alice") require.NoError(t, err) assert.Len(t, jobs, 0) @@ -855,14 +898,35 @@ func TestListInProgressJobs_5xxRetry(t *testing.T) { func TestListInProgressJobs_RequiredUserID(t *testing.T) { t.Parallel() - tokens := newStubTokenClient("svc-tok") - cc := newConverterClientForTest(t, "http://unused", tokens) + cc := newConverterClientForTest(t, "http://unused") _, err := cc.ListInProgressJobs(context.Background(), "") require.Error(t, err) assert.Contains(t, err.Error(), "userID is required") } +// TestListInProgressJobs_AuthFailed401_NoRetry:401 → ErrConverterAuthFailed、不 retry。 +func TestListInProgressJobs_AuthFailed401_NoRetry(t *testing.T) { + t.Parallel() + + var attempts atomic.Int32 + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/jobs", func(w http.ResponseWriter, r *http.Request) { + attempts.Add(1) + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"unauthorized"}`)) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + cc := newConverterClientForTest(t, srv.URL) + _, err := cc.ListInProgressJobs(context.Background(), "alice") + require.Error(t, err) + assert.True(t, errors.Is(err, ErrConverterAuthFailed), + "Phase 0.8b:List 401 必須 mapping 到 ErrConverterAuthFailed") + assert.Equal(t, int32(1), attempts.Load(), "401 不應 retry") +} + // ========================================================================== // 共用:interface 契約 + helpers // ========================================================================== @@ -870,9 +934,6 @@ func TestListInProgressJobs_RequiredUserID(t *testing.T) { // 確保 converterClient 滿足 ConverterClient interface(compile-time check)。 var _ ConverterClient = (*converterClient)(nil) -// 確保 stubTokenClient 滿足 MCTokenClient interface(compile-time check)。 -var _ MCTokenClient = (*stubTokenClient)(nil) - // zerosReader 是無限產生 0 byte 的 reader(測 streaming 用)。 type zerosReader struct{} diff --git a/visionA-backend/internal/conversion/errors.go b/visionA-backend/internal/conversion/errors.go index 08b7deb..e4950c1 100644 --- a/visionA-backend/internal/conversion/errors.go +++ b/visionA-backend/internal/conversion/errors.go @@ -56,39 +56,53 @@ var ( // Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §2.6 + §9.2) ErrFAAFileNotFound = errors.New("conversion: faa file not found") - // ErrDownloadTokenFailed — MC 換 delegated token 4xx 失敗(設定問題)。 - // 對應 HTTP 502 / code "download_token_failed"。 - ErrDownloadTokenFailed = errors.New("conversion: download token failed") - - // ErrMCTokenUnavailable — MC 5xx / network 持續失敗。 - // 對應 HTTP 502 / code "mc_token_unavailable"。 - ErrMCTokenUnavailable = errors.New("conversion: mc token unavailable") - - // ErrIDPMisconfigured — MC token endpoint 4xx(client_credentials grant 設定錯誤)。 - // 對應 HTTP 500 / code "idp_misconfigured"。 - ErrIDPMisconfigured = errors.New("conversion: idp misconfigured") - - // ErrIDPUnavailable — MC oauth/token 5xx / network 持續失敗。 - // 對應 HTTP 503 / code "idp_unavailable"。 - ErrIDPUnavailable = errors.New("conversion: idp unavailable") - // ErrServiceBusy — converter 端回 503 service_busy。 // 對應 HTTP 503 / code "service_busy"。 ErrServiceBusy = errors.New("conversion: service busy") - // ErrServiceClientUnauthorized — visionA-backend 對 MC 認證失敗(401 / 403)。 + // Phase 0.8b T3 移除(5 個僅 mc_token_client 用的 sentinel,MC 路徑取消後不再有觸發點): + // - ErrDownloadTokenFailed — MC delegated token 4xx + // - ErrMCTokenUnavailable — MC 5xx / network 持續失敗 + // - ErrIDPMisconfigured — MC token endpoint 4xx(client_credentials grant 設定錯誤) + // - ErrIDPUnavailable — MC oauth/token 5xx / network 持續失敗 + // - ErrServiceClientUnauthorized — visionA → MC 認證失敗(401/403) + // 取代:401/403 改 mapping 到 ErrConverterAuthFailed / ErrFAAAuthFailed(下方); + // converter 端 503 改 mapping 到 ErrConverterUnavailable(converter_client.go mapPromoteError)。 // - // 觸發情境: - // - VISIONA_OIDC_SERVICE_CLIENT_ID / SECRET 設定錯誤(典型) - // - MC 端 client 被 revoke / 停用 - // - client 沒有對應 scope 的權限 + // **不重用舊 sentinel name**(Phase 1+ 注意):上述 5 個 sentinel name 已從 git history + // 中砍除,未來若需要新增 sentinel 不應重用同名(如 `ErrIDPUnavailable`),以免閱讀 + // git log / blame / 既有 reference 時混淆語意(同名但對應不同層的失敗模式)。 + // 若未來 MC 認證鏈以新樣態回來(例如 ADR-015 §7 選項 B 的「visionA 自簽 HMAC token」), + // 採用新 sentinel name(例:`ErrHMACTokenUnavailable` / `ErrHMACSigningFailed`)。 + // 對應 T3 Reviewer Minor #M-3 / T4 補註說明(.autoflow/05-implementation/backend/logs/t4-final-*.log) + + // ErrConverterAuthFailed — visionA-backend → converter 帶的 API key 不對齊 + // (converter middleware constant-time compare 失敗 → 401 / 403)。 // - // 設計選擇:與 ErrIDPMisconfigured 分開的 sentinel,給 mc_token_client 內部 caller - // 可以做更精細的處理(例如 401 時主動 invalidate cache),但對外 ErrorCode/HTTPStatus - // 都對應到 idp_misconfigured / 500(fail-fast,避免半設定狀態跑進 production)。 + // 觸發情境(Phase 0.8b API key 路徑): + // - VISIONA_CONVERTER_API_KEY 與 converter 端 CONVERTER_API_KEY 不同步 + // (rotate 後一邊還沒換 env、stage / prod env 設錯) + // - converter middleware 上線前 visionA 過早部署(converter 還沒驗 key) // - // Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §5.2) - ErrServiceClientUnauthorized = errors.New("conversion: service client unauthorized") + // 設計選擇:對外仍 mask 成 converter_unavailable / 502 — 不洩漏「API key 對 / 不對」 + // 這個內部運維狀態給 frontend;SRE 從 server log 看到 auth_failed 計數異常 → 檢查 env。 + // 與 ErrConverterUnavailable 分開的 sentinel 是為了 log / metric 分桶(運維事件 vs 上游不可達), + // 對外的 user-facing message 仍然一樣(避免 social engineering 利用 401 訊號做攻擊)。 + // + // 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") // ErrStorageUnavailable — visionA 自家 storage(local FS / S3)寫入或讀取失敗。 // @@ -217,20 +231,15 @@ func ErrorCode(err error) string { return "faa_unavailable" case errors.Is(err, ErrFAAUnavailable): return "faa_unavailable" - case errors.Is(err, ErrDownloadTokenFailed): - return "download_token_failed" - case errors.Is(err, ErrMCTokenUnavailable): - return "mc_token_unavailable" - case errors.Is(err, ErrIDPMisconfigured): - return "idp_misconfigured" - case errors.Is(err, ErrIDPUnavailable): - return "idp_unavailable" case errors.Is(err, ErrServiceBusy): return "service_busy" - case errors.Is(err, ErrServiceClientUnauthorized): - // 對外仍透過 idp_misconfigured 呈現(避免 leak「我們的 client_secret 過期」這種內部狀態); - // caller 想做精細處理用 errors.Is(err, ErrServiceClientUnauthorized) 直接判斷。 - return "idp_misconfigured" + case errors.Is(err, ErrConverterAuthFailed): + // 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): @@ -258,15 +267,15 @@ func HTTPStatus(err error) int { case errors.Is(err, ErrConverterUnavailable), errors.Is(err, ErrFAAUnavailable), errors.Is(err, ErrFAAFileNotFound), - errors.Is(err, ErrDownloadTokenFailed), - errors.Is(err, ErrMCTokenUnavailable): + errors.Is(err, ErrConverterAuthFailed), + errors.Is(err, ErrFAAAuthFailed): + // Phase 0.8b:API key auth_failed 對外與「服務不可達」同層 502; + // 內部 log / metric 才區分(auth_failed = SRE alarm;其他 = 自然 retry) return 502 - case errors.Is(err, ErrIDPMisconfigured), errors.Is(err, ErrServiceClientUnauthorized): - return 500 case errors.Is(err, ErrStorageUnavailable), errors.Is(err, ErrModelStoreUnavailable): // visionA 自身基礎設施問題 → 500(不是 502 gateway,因為非 upstream 失敗) return 500 - case errors.Is(err, ErrIDPUnavailable), errors.Is(err, ErrServiceBusy): + case errors.Is(err, ErrServiceBusy): return 503 default: return 500 diff --git a/visionA-backend/internal/conversion/errors_test.go b/visionA-backend/internal/conversion/errors_test.go index 4513cd5..95e9b44 100644 --- a/visionA-backend/internal/conversion/errors_test.go +++ b/visionA-backend/internal/conversion/errors_test.go @@ -27,13 +27,13 @@ func TestErrorCode(t *testing.T) { {"payload_too_large", ErrPayloadTooLarge, "payload_too_large"}, {"converter_unavailable", ErrConverterUnavailable, "converter_unavailable"}, {"faa_unavailable", ErrFAAUnavailable, "faa_unavailable"}, - {"download_token_failed", ErrDownloadTokenFailed, "download_token_failed"}, - {"mc_token_unavailable", ErrMCTokenUnavailable, "mc_token_unavailable"}, - {"idp_misconfigured", ErrIDPMisconfigured, "idp_misconfigured"}, - {"idp_unavailable", ErrIDPUnavailable, "idp_unavailable"}, {"service_busy", ErrServiceBusy, "service_busy"}, - // ErrServiceClientUnauthorized 對外刻意 mask 成 idp_misconfigured(不 leak「visionA secret 過期」內部狀態) - {"service_client_unauthorized_masked_as_idp_misconfig", ErrServiceClientUnauthorized, "idp_misconfigured"}, + // Phase 0.8b T3:以下 sentinel 已移除,不再對外暴露對應 error code + // ErrDownloadTokenFailed / ErrMCTokenUnavailable / ErrIDPMisconfigured / + // ErrIDPUnavailable / ErrServiceClientUnauthorized + // 取代:401/403 改 ErrConverterAuthFailed / ErrFAAAuthFailed(下方 wrapped) + {"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"}, @@ -68,12 +68,13 @@ func TestHTTPStatus(t *testing.T) { {"payload_too_large_413", ErrPayloadTooLarge, 413}, {"converter_unavailable_502", ErrConverterUnavailable, 502}, {"faa_unavailable_502", ErrFAAUnavailable, 502}, - {"download_token_failed_502", ErrDownloadTokenFailed, 502}, - {"mc_token_unavailable_502", ErrMCTokenUnavailable, 502}, - {"idp_misconfigured_500", ErrIDPMisconfigured, 500}, - {"idp_unavailable_503", ErrIDPUnavailable, 503}, {"service_busy_503", ErrServiceBusy, 503}, - {"service_client_unauthorized_500", ErrServiceClientUnauthorized, 500}, + // Phase 0.8b T3:以下 sentinel 已移除,對外不再 mapping HTTP status + // ErrDownloadTokenFailed / ErrMCTokenUnavailable / ErrIDPMisconfigured / + // ErrIDPUnavailable / ErrServiceClientUnauthorized + // 取代:401/403 改 ErrConverterAuthFailed / ErrFAAAuthFailed (HTTP 502) + {"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 index ef9697b..149f142 100644 --- a/visionA-backend/internal/conversion/faa_client.go +++ b/visionA-backend/internal/conversion/faa_client.go @@ -4,28 +4,31 @@ // 其他 endpoint(PUT / DELETE / HEAD / metadata)目前 visionA 不需要,未來再補。 // // 設計要點: -// - 走 service token(scope=files:download.read);token 由注入的 MCTokenClient 提供 +// - **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 → ErrServiceClientUnauthorized;404 → ErrFAAFileNotFound; +// - 4xx → 對應 sentinel(401/403 → ErrFAAAuthFailed;404 → ErrFAAFileNotFound; // 其他 4xx → ErrFAAUnavailable,避免新增更多 sentinel) // -// 與 T3 InitJob 的對比(為什麼 T3 不 retry 但 T4 GetFile retry): -// - T3 InitJob:multipart **request body** 是 streaming(io.Reader 來自上游 c.Body); +// 與 InitJob 的對比(為什麼 InitJob 不 retry 但 GetFile retry): +// - InitJob:multipart **request body** 是 streaming(io.Reader 來自上游 c.Body); // 一旦 http.Client.Do 開始送 request body,io.Reader 已被消費,retry 無法 rewind → // 從第一次 attempt 起就「不可重試」。 -// - T4 GetFile:GET 沒有 request body,request 完全 idempotent;retry window 涵蓋 +// - 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 / service token / response body 進 log +// - **絕不**寫 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 ( @@ -49,11 +52,9 @@ import ( // FAAClient 對 File Access Agent 的 server-to-server client。 // -// goroutine-safe:每次呼叫獨立 *http.Request;無內部 mutable state(cache 由注入的 MCTokenClient 管)。 +// goroutine-safe:每次呼叫獨立 *http.Request;無內部 mutable state(apiKey 為 immutable 字串)。 type FAAClient interface { - // GetFile 從 FAA pull 一個 object(server-to-server,用 service token)。 - // - // scope: files:download.read + // 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)。 @@ -74,7 +75,7 @@ type FAAClient interface { // // 錯誤映射(對齊 conversion.md §6 + errors.go): // - ctx cancel/deadline → 透傳 ctx.Err(不包成 sentinel) - // - 401 / 403 → ErrServiceClientUnauthorized(對外 idp_misconfigured/500) + // - 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) @@ -107,8 +108,17 @@ type FAAClientOpts struct { // 範例:http://192.168.0.130:5081 BaseURL string - // Tokens 是 MCTokenClient(注入,non-nil 必填)— 用來取 service token。 - Tokens MCTokenClient + // 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()。 @@ -132,9 +142,6 @@ type FAAClientOpts struct { // ========================================================================== const ( - // scopeFAADownloadRead 對齊 FAA README §「初步 API 邊界」與 FileAccessScopes.DownloadRead。 - scopeFAADownloadRead = "files:download.read" - // faaDialTimeout 是 dial 階段的 timeout(連 TCP / TLS 握手)。 // 連線一直建不起來通常是路由問題,10s 已足夠;超過視為 FAA 不可達。 faaDialTimeout = 10 * time.Second @@ -162,6 +169,12 @@ const ( // 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)") + // ========================================================================== // 構造 + 內部實作 // ========================================================================== @@ -171,7 +184,7 @@ const faaEndpointKind = "faa_get_file" // 套件內 unexported struct(caller 拿 interface),讓未來換實作不影響 caller。 type faaClient struct { baseURL string - tokens MCTokenClient + apiKey string // Phase 0.8b:pre-shared API key,建構時 fail-fast 不允許空字串 http *http.Client now func() time.Time logger *slog.Logger @@ -179,9 +192,18 @@ type faaClient struct { // NewFAAClient 建立一個 FAAClient 實例。 // -// 必填:BaseURL / Tokens。其他 optional。 +// 必填: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() @@ -196,7 +218,7 @@ func NewFAAClient(opts FAAClientOpts) FAAClient { } return &faaClient{ baseURL: strings.TrimRight(opts.BaseURL, "/"), - tokens: opts.Tokens, + apiKey: opts.APIKey, http: httpClient, now: now, logger: logger, @@ -236,10 +258,9 @@ func newDefaultFAAHTTPClient() *http.Client { // GetFile 實作 FAAClient.GetFile。 // -// 流程: -// 1. 取 service token(透過 MCTokenClient;其錯誤透傳,不重新分類) -// 2. 組 URL + 建 request -// 3. doWithRetry:max (1 + faaMaxRetries) attempts;每 attempt 重新 c.http.Do +// 流程(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 @@ -252,42 +273,29 @@ func (c *faaClient) GetFile(ctx context.Context, objectKey string) (*FAAFile, er keyHash := hashObjectKey(objectKey) - // 1. 取 service token - // ServiceToken 內部已依 §6 mapping 失敗(ErrServiceClientUnauthorized / ErrIDPMisconfigured / - // ErrIDPUnavailable)— 這裡用 fmt.Errorf("%w") 透傳,不再二次包裝(避免錯誤碼被「升級」 - // 成 ErrFAAUnavailable 而失去原本的 i18n 區分 idp_misconfig vs idp_down)。 - token, err := c.tokens.ServiceToken(ctx, scopeFAADownloadRead) - if err != nil { - return nil, fmt.Errorf("conversion: get service token for faa download: %w", err) - } - - // 2. 組 endpoint。注意 FAA 的 object_key 可能含路徑分隔符(如 "tenant/jobs/abc/output.nef")— + // 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) } - // 3. 進 retry loop(Phase A only) - return c.doWithRetry(ctx, keyHash, endpoint, token) + // 2. 進 retry loop(Phase A only);apiKey 在 doWithRetry 內 set header + return c.doWithRetry(ctx, keyHash, endpoint) } // doWithRetry 是 GetFile 的 Phase A retry 執行器。 // -// 與 mc_token_client.doWithRetry / converter_client.doWithRetry 結構類似,但有以下差異: +// 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),這裡每次都新建一個 -// - reqBuilder 不接 token 參數 — token 在 GetFile 取一次,retry 期間沿用同一 token -// (retry window 短:max 1+2+3=6s,token 不會在這段期間過期) -// -// 為什麼 retry 期間不重新取 token: -// - 簡化:避免 token 取失敗 vs HTTP 失敗 兩種錯誤交織的處理 -// - 安全:401 在這層被分類為「不可 retry」,不會走到「token expired 中途要 refresh」場景 -// - 效能:cache hit 情境下成本低但仍多一次 mutex;6s window 內 token 不會 expire func (c *faaClient) doWithRetry( ctx context.Context, - keyHash, endpoint, token string, + keyHash, endpoint string, ) (*FAAFile, error) { var lastErr error for attempt := 0; attempt <= faaMaxRetries; attempt++ { @@ -307,7 +315,8 @@ func (c *faaClient) doWithRetry( return nil, fmt.Errorf("%w: build faa request: %v", ErrFAAUnavailable, err) } req.Header.Set("Accept", "application/octet-stream") - req.Header.Set("Authorization", "Bearer "+token) + // 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 { @@ -404,9 +413,11 @@ func (c *faaClient) doOnce( // mapGetFileError 把 FAA `GET /files/{key}` 的非 2xx 對應到 sentinel + 是否 retryable。 // -// 對齊 FAA Program.cs MapGet("/files/{**objectKey}") 的失敗回應: -// - 401 invalid_token / validation_unavailable → ErrServiceClientUnauthorized(不 retry — secret 設定錯) -// - 403 tenant_mismatch / object_key_mismatch / method_mismatch → ErrServiceClientUnauthorized(不 retry) +// 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) @@ -414,7 +425,7 @@ func (c *faaClient) doOnce( 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", ErrServiceClientUnauthorized, status), false + 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: diff --git a/visionA-backend/internal/conversion/faa_client_test.go b/visionA-backend/internal/conversion/faa_client_test.go index 08140dc..9aba2f2 100644 --- a/visionA-backend/internal/conversion/faa_client_test.go +++ b/visionA-backend/internal/conversion/faa_client_test.go @@ -2,20 +2,22 @@ // // 測試策略: // - 用 httptest.Server mock FAA 的 GET /files/{key} 端點 -// - 用 stub MCTokenClient(直接回 token / 注入錯誤),不耦合真實 mc_token_client 邏輯 +// - **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): +// 測試範疇對應 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_401_Unauthorized / GetFile_403_Unauthorized +// - 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_ServiceTokenFailure_Propagated / GetFile_EmptyObjectKey -// - GetFile_400_GenericError / HashObjectKey_StableAndLength +// - 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 ( @@ -37,17 +39,15 @@ import ( // FAA mock server helpers // ========================================================================== -// newFAAClientForTest 建立指向 mock server 的 FAAClient(使用快速 retry backoff 加速 test)。 +// newFAAClientForTest 建立指向 mock server 的 FAAClient。 // -// 注意:這個 helper 用較短 backoff(10ms 起跳)讓 retry test 不會跑很久。 -// 真實 production 走 §9.1 的 1s/2s(在 NewFAAClient 預設)。 -func newFAAClientForTest(t *testing.T, baseURL string, tokens MCTokenClient) 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, - Tokens: tokens, - // 用一個簡單的 http.Client;httptest.Server.Client 也可以但這樣更貼近真實情境, - // 用較短 timeout 加速 test。注意 streaming test 不能用整體 Timeout,所以另外覆寫。 + BaseURL: baseURL, + APIKey: fakeFAAAPIKey, HTTPClient: &http.Client{Timeout: 5 * time.Second}, Logger: silentLogger(), }) @@ -61,7 +61,6 @@ func newFAAClientForTest(t *testing.T, baseURL string, tokens MCTokenClient) FAA func TestGetFile_Success(t *testing.T) { t.Parallel() - tokens := newStubTokenClient("svc-tok") payload := []byte("binary payload here") var receivedAuth string @@ -78,7 +77,7 @@ func TestGetFile_Success(t *testing.T) { srv := httptest.NewServer(mux) t.Cleanup(srv.Close) - fc := newFAAClientForTest(t, srv.URL, tokens) + fc := newFAAClientForTest(t, srv.URL) file, err := fc.GetFile(context.Background(), "tenant/jobs/abc/output.nef") require.NoError(t, err) @@ -95,8 +94,8 @@ func TestGetFile_Success(t *testing.T) { require.NoError(t, readErr) assert.Equal(t, payload, body) - assert.Equal(t, "Bearer svc-tok", receivedAuth, "Bearer service token 必須透傳") - assert.Equal(t, 1, tokens.calls(scopeFAADownloadRead)) + 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)。 @@ -108,7 +107,6 @@ func TestGetFile_Success(t *testing.T) { func TestGetFile_Streaming(t *testing.T) { t.Parallel() - tokens := newStubTokenClient("svc-tok") const totalSize = int64(10 * 1024 * 1024) // 10MB mux := http.NewServeMux() @@ -125,7 +123,7 @@ func TestGetFile_Streaming(t *testing.T) { // streaming download 不能用 http.Client.Timeout(會中斷 body streaming) fc := NewFAAClient(FAAClientOpts{ BaseURL: srv.URL, - Tokens: tokens, + APIKey: fakeFAAAPIKey, // 這裡用無 timeout 的 client(test 自己控) HTTPClient: &http.Client{}, Logger: silentLogger(), @@ -148,11 +146,15 @@ func TestGetFile_Streaming(t *testing.T) { assert.Equal(t, totalSize, written, "streaming download 必須拿到完整 body") } -// TestGetFile_AuthHeader:驗 Bearer token 透傳,且取 token scope 為 files:download.read。 +// TestGetFile_AuthHeader:Phase 0.8b — 驗 pre-shared API key 直接帶在 Authorization header。 +// +// 用客製 APIKey(與 fakeFAAAPIKey 不同的字串),確認 client 真的透傳「建構時拿到的 key」、 +// 而不是 hardcode 某個常數。 func TestGetFile_AuthHeader(t *testing.T) { t.Parallel() - tokens := newStubTokenClient("specific-token-xyz") + const customKey = "custom-faa-key-do-not-use-in-prod-ccccccccccccccccccccccccccccc" + var receivedAuth string var receivedAccept string @@ -167,16 +169,20 @@ func TestGetFile_AuthHeader(t *testing.T) { srv := httptest.NewServer(mux) t.Cleanup(srv.Close) - fc := newFAAClientForTest(t, srv.URL, tokens) + 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 specific-token-xyz", receivedAuth) + assert.Equal(t, "Bearer "+customKey, receivedAuth, + "必須透傳建構時拿到的 API key,不可 hardcode 或從別處取") assert.Equal(t, "application/octet-stream", receivedAccept) - assert.Equal(t, 1, tokens.calls(scopeFAADownloadRead), - "必須用 files:download.read scope 取 service token") } // ========================================================================== @@ -187,7 +193,6 @@ func TestGetFile_AuthHeader(t *testing.T) { func TestGetFile_404_NoRetry(t *testing.T) { t.Parallel() - tokens := newStubTokenClient("svc-tok") var attempts atomic.Int32 mux := http.NewServeMux() mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) { @@ -199,7 +204,7 @@ func TestGetFile_404_NoRetry(t *testing.T) { srv := httptest.NewServer(mux) t.Cleanup(srv.Close) - fc := newFAAClientForTest(t, srv.URL, tokens) + fc := newFAAClientForTest(t, srv.URL) file, err := fc.GetFile(context.Background(), "missing.nef") require.Error(t, err) @@ -213,54 +218,60 @@ func TestGetFile_404_NoRetry(t *testing.T) { assert.Equal(t, 502, HTTPStatus(err)) } -// TestGetFile_401_Unauthorized:mock 回 401 → 不 retry,return ErrServiceClientUnauthorized。 -func TestGetFile_401_Unauthorized(t *testing.T) { +// 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() - tokens := newStubTokenClient("svc-tok") 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":{"code":"invalid_token"}}`)) + _, _ = w.Write([]byte(`{"error":"unauthorized"}`)) }) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) - fc := newFAAClientForTest(t, srv.URL, tokens) + 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, ErrServiceClientUnauthorized), - "401 → ErrServiceClientUnauthorized(client 認證設定錯)") + 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(secret 設定錯,retry 也是 401)") + "401 不應 retry(API key 不對 retry 也是 401)") + // 對外仍 mask 成 faa_unavailable + assert.Equal(t, "faa_unavailable", ErrorCode(err)) + assert.Equal(t, 502, HTTPStatus(err)) } -// TestGetFile_403_Unauthorized:FAA 端 tenant_mismatch / object_key_mismatch 等 403 都同類處理。 -func TestGetFile_403_Unauthorized(t *testing.T) { +// TestGetFile_AuthFailed403:對稱 — FAA 端 403 同樣 ErrFAAAuthFailed、不 retry。 +func TestGetFile_AuthFailed403(t *testing.T) { t.Parallel() - tokens := newStubTokenClient("svc-tok") 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":{"code":"tenant_mismatch"}}`)) + _, _ = w.Write([]byte(`{"error":"unauthorized"}`)) }) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) - fc := newFAAClientForTest(t, srv.URL, tokens) + fc := newFAAClientForTest(t, srv.URL) _, err := fc.GetFile(context.Background(), "k") require.Error(t, err) - assert.True(t, errors.Is(err, ErrServiceClientUnauthorized)) + assert.True(t, errors.Is(err, ErrFAAAuthFailed)) assert.Equal(t, int32(1), attempts.Load(), "403 不應 retry") } @@ -268,7 +279,6 @@ func TestGetFile_403_Unauthorized(t *testing.T) { func TestGetFile_400_GenericError(t *testing.T) { t.Parallel() - tokens := newStubTokenClient("svc-tok") var attempts atomic.Int32 mux := http.NewServeMux() mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) { @@ -279,7 +289,7 @@ func TestGetFile_400_GenericError(t *testing.T) { srv := httptest.NewServer(mux) t.Cleanup(srv.Close) - fc := newFAAClientForTest(t, srv.URL, tokens) + fc := newFAAClientForTest(t, srv.URL) _, err := fc.GetFile(context.Background(), "invalid//key") require.Error(t, err) @@ -301,7 +311,6 @@ func TestGetFile_400_GenericError(t *testing.T) { func TestGetFile_5xx_RetryThenSuccess(t *testing.T) { t.Parallel() - tokens := newStubTokenClient("svc-tok") var attempts atomic.Int32 payload := []byte("recovered after retry") @@ -321,7 +330,7 @@ func TestGetFile_5xx_RetryThenSuccess(t *testing.T) { srv := httptest.NewServer(mux) t.Cleanup(srv.Close) - fc := newFAAClientForTest(t, srv.URL, tokens) + fc := newFAAClientForTest(t, srv.URL) start := time.Now() file, err := fc.GetFile(context.Background(), "k") @@ -344,7 +353,6 @@ func TestGetFile_5xx_RetryThenSuccess(t *testing.T) { func TestGetFile_5xx_Exhausted(t *testing.T) { t.Parallel() - tokens := newStubTokenClient("svc-tok") var attempts atomic.Int32 mux := http.NewServeMux() mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) { @@ -355,7 +363,7 @@ func TestGetFile_5xx_Exhausted(t *testing.T) { srv := httptest.NewServer(mux) t.Cleanup(srv.Close) - fc := newFAAClientForTest(t, srv.URL, tokens) + fc := newFAAClientForTest(t, srv.URL) _, err := fc.GetFile(context.Background(), "k") require.Error(t, err) @@ -378,7 +386,6 @@ func TestGetFile_5xx_Exhausted(t *testing.T) { func TestGetFile_Network_RetryThenSuccess(t *testing.T) { t.Parallel() - tokens := newStubTokenClient("svc-tok") var attempts atomic.Int32 payload := []byte("recovered from net error") @@ -405,7 +412,7 @@ func TestGetFile_Network_RetryThenSuccess(t *testing.T) { srv := httptest.NewServer(mux) t.Cleanup(srv.Close) - fc := newFAAClientForTest(t, srv.URL, tokens) + fc := newFAAClientForTest(t, srv.URL) file, err := fc.GetFile(context.Background(), "k") require.NoError(t, err) @@ -424,7 +431,6 @@ func TestGetFile_Network_RetryThenSuccess(t *testing.T) { func TestGetFile_Network_Exhausted(t *testing.T) { t.Parallel() - tokens := newStubTokenClient("svc-tok") // 拿一個 free port 立刻關掉(dial 必失敗) ln, err := net.Listen("tcp", "127.0.0.1:0") require.NoError(t, err) @@ -433,7 +439,7 @@ func TestGetFile_Network_Exhausted(t *testing.T) { fc := NewFAAClient(FAAClientOpts{ BaseURL: "http://" + addr, - Tokens: tokens, + APIKey: fakeFAAAPIKey, // 用較短 timeout,但仍要大於 retry 退避總和(1s + 2s = 3s)— 設 10s 安全 HTTPClient: &http.Client{Timeout: 10 * time.Second}, Logger: silentLogger(), @@ -459,7 +465,6 @@ func TestGetFile_Network_Exhausted(t *testing.T) { func TestGetFile_ContextCancel(t *testing.T) { t.Parallel() - tokens := newStubTokenClient("svc-tok") // mock server:handler 故意 sleep(讓 ctx cancel 在 server response 前發生) mux := http.NewServeMux() @@ -473,7 +478,7 @@ func TestGetFile_ContextCancel(t *testing.T) { srv := httptest.NewServer(mux) t.Cleanup(srv.Close) - fc := newFAAClientForTest(t, srv.URL, tokens) + fc := newFAAClientForTest(t, srv.URL) ctx, cancel := context.WithCancel(context.Background()) go func() { time.Sleep(50 * time.Millisecond) @@ -497,7 +502,6 @@ func TestGetFile_ContextCancel(t *testing.T) { func TestGetFile_ContextCancel_DuringRetry(t *testing.T) { t.Parallel() - tokens := newStubTokenClient("svc-tok") var attempts atomic.Int32 mux := http.NewServeMux() mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) { @@ -508,7 +512,7 @@ func TestGetFile_ContextCancel_DuringRetry(t *testing.T) { srv := httptest.NewServer(mux) t.Cleanup(srv.Close) - fc := newFAAClientForTest(t, srv.URL, tokens) + fc := newFAAClientForTest(t, srv.URL) ctx, cancel := context.WithCancel(context.Background()) go func() { @@ -535,52 +539,34 @@ func TestGetFile_ContextCancel_DuringRetry(t *testing.T) { } // ========================================================================== -// Token 失敗透傳 +// Constructor fail-fast // ========================================================================== -// TestGetFile_ServiceTokenFailure_Propagated:MCTokenClient 失敗 → 透傳原 sentinel。 +// TestNewFAAClient_Panics_When_APIKey_Empty:fail-fast 驗證 — Phase 0.8b +// 不允許 server 在「未認證」狀態下啟動,建構式必須立即 panic。 // -// 對應 mc_token_client.go 的 ErrIDPMisconfigured / ErrServiceClientUnauthorized / ErrIDPUnavailable, -// 不應被 faa_client 升級成 ErrFAAUnavailable(會丟失 i18n 區分 idp_misconfig vs idp_down vs faa_down)。 -func TestGetFile_ServiceTokenFailure_Propagated(t *testing.T) { +// 對齊 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() - cases := []struct { - name string - tokenErr error - }{ - {"idp_misconfigured", ErrIDPMisconfigured}, - {"service_client_unauthorized", ErrServiceClientUnauthorized}, - {"idp_unavailable", ErrIDPUnavailable}, - } + 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") + }() - for _, tc := range cases { - tc := tc - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - tokens := newStubTokenClient("") - tokens.setError(tc.tokenErr) - - // server 不應被打(token 取不到就 fail) - var serverHit atomic.Int32 - mux := http.NewServeMux() - mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) { - serverHit.Add(1) - w.WriteHeader(http.StatusOK) - }) - srv := httptest.NewServer(mux) - t.Cleanup(srv.Close) - - fc := newFAAClientForTest(t, srv.URL, tokens) - _, err := fc.GetFile(context.Background(), "k") - - require.Error(t, err) - assert.True(t, errors.Is(err, tc.tokenErr), - "token 錯誤應透傳;不應包成 ErrFAAUnavailable") - assert.Equal(t, int32(0), serverHit.Load(), - "token 取不到時不應打 FAA") - }) - } + _ = NewFAAClient(FAAClientOpts{ + BaseURL: "http://example.com", + APIKey: "", // empty — 必須觸發 panic + Logger: silentLogger(), + }) } // ========================================================================== @@ -591,18 +577,16 @@ func TestGetFile_ServiceTokenFailure_Propagated(t *testing.T) { func TestGetFile_EmptyObjectKey(t *testing.T) { t.Parallel() - tokens := newStubTokenClient("svc-tok") fc := NewFAAClient(FAAClientOpts{ BaseURL: "http://invalid", - Tokens: tokens, + APIKey: fakeFAAAPIKey, Logger: silentLogger(), }) _, err := fc.GetFile(context.Background(), "") require.Error(t, err) - // 不需走網路就應該 fail(token 沒被呼叫) - assert.Equal(t, 0, tokens.calls(scopeFAADownloadRead), - "empty object_key 應立即 fail,不該打 token endpoint") + assert.Contains(t, err.Error(), "object_key is required", + "empty object_key 應立即 fail(不打網路)") } // ========================================================================== diff --git a/visionA-backend/internal/conversion/flow.go b/visionA-backend/internal/conversion/flow.go index 4ed9784..1b8b2e7 100644 --- a/visionA-backend/internal/conversion/flow.go +++ b/visionA-backend/internal/conversion/flow.go @@ -101,19 +101,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) +// - 移除 tenantID:MC delegated download token 機制取消,不再需要 tenant 概念 +// - 移除 faaBaseURL:DownloadStream 走 faa.GetFile(FAAClient 內含 baseURL),不再自組 FAA URL +// - 移除 delegatedTTLSeconds:delegated download token 取消 type flow struct { converter ConverterClient faa FAAClient - mcToken MCTokenClient ownership Ownership modelStore ModelStore storage Storage - tenantID string - faaBaseURL string defaultJobExpiryDuration time.Duration - delegatedTTLSeconds int logger *slog.Logger now func() time.Time @@ -121,33 +123,23 @@ type flow struct { // FlowOpts 是 NewService 的依賴注入。 // -// 必填:Converter / FAA / MCToken / Ownership / ModelStore / Storage / TenantID / FAABaseURL。 -// 其他 optional(nil/0 自動填合理預設)。 +// 必填:Converter / FAA / 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)。 type FlowOpts struct { - // 4 個 client(T2-T5) + // 3 個 client + 1 個 ownership store(T3 / T4 / T5) Converter ConverterClient FAA FAAClient - MCToken MCTokenClient Ownership Ownership // 既有 visionA 套件的 narrow adapter ModelStore ModelStore Storage Storage - // MC delegated download 用的 tenant id(visionA 在 MC 的 tenant 識別) - TenantID string - - // FAA base URL;組 download URL 用(http://192.168.0.130:5081 等)。 - // 不帶結尾斜線,constructor 自動 trim。 - FAABaseURL string - // converter 沒回 expires_at 時自行推算的 fallback duration(預設 7 天)。 DefaultJobExpiryDuration time.Duration - // MC delegated download token TTL(秒)。0 → 預設 300(5 分鐘)。 - // 對齊 conversion.md §10.2,建議範圍 60-900。 - DelegatedTTLSeconds int - Logger *slog.Logger Now func() time.Time } @@ -162,9 +154,6 @@ func NewService(opts FlowOpts) (Service, error) { if opts.FAA == nil { return nil, errors.New("conversion: FlowOpts.FAA is required") } - if opts.MCToken == nil { - return nil, errors.New("conversion: FlowOpts.MCToken is required") - } if opts.Ownership == nil { return nil, errors.New("conversion: FlowOpts.Ownership is required") } @@ -174,21 +163,11 @@ func NewService(opts FlowOpts) (Service, error) { if opts.Storage == nil { return nil, errors.New("conversion: FlowOpts.Storage is required") } - if opts.TenantID == "" { - return nil, errors.New("conversion: FlowOpts.TenantID is required") - } - if opts.FAABaseURL == "" { - return nil, errors.New("conversion: FlowOpts.FAABaseURL is required") - } expiry := opts.DefaultJobExpiryDuration if expiry <= 0 { expiry = 7 * 24 * time.Hour // 對齊 converter 7 天 GC(§2.6.2) } - ttl := opts.DelegatedTTLSeconds - if ttl <= 0 { - ttl = 300 - } logger := opts.Logger if logger == nil { logger = slog.Default() @@ -201,14 +180,10 @@ func NewService(opts FlowOpts) (Service, error) { return &flow{ converter: opts.Converter, faa: opts.FAA, - mcToken: opts.MCToken, ownership: opts.Ownership, modelStore: opts.ModelStore, storage: opts.Storage, - tenantID: opts.TenantID, - faaBaseURL: strings.TrimRight(opts.FAABaseURL, "/"), defaultJobExpiryDuration: expiry, - delegatedTTLSeconds: ttl, logger: logger, now: nowFn, }, nil @@ -688,35 +663,42 @@ func (f *flow) PromoteToModels(ctx context.Context, userID, jobID, name string) } // ========================================================================== -// DownloadRedirectURL — 對應 GET /api/conversion/{job_id}/download +// DownloadStream — 對應 GET /api/conversion/{job_id}/download(Phase 0.8b server-side proxy) // ========================================================================== -// DownloadRedirectURL 對齊 conversion.md §1 Stage 3b + §3.1 + api-conversion.md §4。 +// DownloadStream 對齊 conversion.md §1 Stage 3b + §4.1 + api-conversion.md §4 + ADR-015 §7。 // // 流程: -// 1. ownership 驗(不符 → ErrJobNotFound) -// 2. converter.GetJob — 確認 status=completed +// 1. ownership 驗(不符 → ErrJobNotFound,§7.2 防枚舉) +// 2. converter.GetJob — 確認 status=completed(否則 ErrJobNotCompleted) // 3. ensurePromoted — 自動觸發 promote(若還沒 promote 過),拿到 target_object_key -// - 設計選擇(task spec 詢問點):自動觸發。理由:api-conversion.md §4 註解說 +// - 設計選擇(沿用 Phase 0.8):自動觸發。理由:api-conversion.md §4 註解說 // 「兩條路徑(promote-to-models / download)都拿同一個 target_object_key」+ // 「不會與 promote-to-models 衝突;兩者內部都會 ensurePromoted(冪等)」— // 要求 user 先按 promote-to-models 才能下載會違背「下載」按鈕的直覺語意。 -// 4. mcToken.IssueDelegatedDownload — 換 opaque token (TTL 5min 預設) -// 5. 組 https:///files/?access_token= +// 4. faa.GetFile — 用 pre-shared API key 直接拉 NEF stream(不再經 MC delegated token) +// 5. 回傳 (io.ReadCloser, *DownloadMetadata, nil);caller(handler)負責 io.Copy 到 client + Close // -// 安全(§10.4): -// - token 不出現在任何 JSON response(caller 走 server-side 302 redirect) -// - object_key 不對 frontend 揭露 -func (f *flow) DownloadRedirectURL(ctx context.Context, userID, jobID string) (string, error) { +// Phase 0.8b vs Phase 0.8 安全模型差異(見 conversion.md §10.4 + ADR-015 §10.4): +// - Phase 0.8:MC simple delegated token + 302 redirect → browser 直連 FAA(token 短暫流經 browser) +// - Phase 0.8b:visionA backend 中轉 stream → 沒有 token 結構性存在於任何 frontend response +// +// Caller(handler)責任(避免 fd / goroutine leak): +// - **必須 defer stream.Close()** +// - 設好 response header(Content-Type / Content-Length / Content-Disposition / Cache-Control: no-store) +// - 用 io.Copy(w, stream) streaming 寫;不要 ReadAll 進 RAM(單檔 NEF 可達 50MB+) +func (f *flow) DownloadStream(ctx context.Context, userID, jobID string) (io.ReadCloser, *DownloadMetadata, error) { if userID == "" { - return "", errors.New("conversion: DownloadRedirectURL requires userID") + return nil, nil, errors.New("conversion: DownloadStream requires userID") } if jobID == "" { - return "", ErrJobNotFound + return nil, nil, ErrJobNotFound } // 1. ownership 驗 if err := f.ownership.EnsureRebuilt(ctx, userID); err != nil { + // fail-soft:rebuild 失敗不直接擋(cache 可能 stale 但仍可能有合法 entry); + // 後面 Get / GetJob 還會把實際錯誤帶上來 f.logger.WarnContext(ctx, "conversion.flow.download_ownership_rebuild_failed", slog.String("user_hash", hashUserID(userID)), slog.String("err", err.Error()), @@ -724,54 +706,69 @@ func (f *flow) DownloadRedirectURL(ctx context.Context, userID, jobID string) (s } owner, ok := f.ownership.Get(jobID) if !ok || owner != userID { - return "", ErrJobNotFound + return nil, nil, ErrJobNotFound } // 2. converter.GetJob 確認 completed cj, err := f.converter.GetJob(ctx, jobID) if err != nil { - return "", err + return nil, nil, err } if cj.Status != "completed" { - return "", fmt.Errorf("%w: status=%s", ErrJobNotCompleted, cj.Status) + return nil, nil, fmt.Errorf("%w: status=%s", ErrJobNotCompleted, cj.Status) } - // 3. ensurePromoted — 自動觸發 promote 拿 target_object_key - // Phase 0.8 不 cache promoted_object_key(converter 端 promote 是冪等的, - // 重複呼叫成本可接受 — 反正 download 路徑 user 主動觸發頻率不高) + // 3. ensurePromoted — 自動觸發 promote 拿 target_object_key(converter 端冪等) targetObjectKey, err := f.ensurePromoted(ctx, userID, jobID, cj) if err != nil { - return "", err + return nil, nil, err } - // 4. mcToken 換 delegated download token - delegated, err := f.mcToken.IssueDelegatedDownload(ctx, IssueDownloadReq{ - TenantID: f.tenantID, - UserID: userID, - ObjectKey: targetObjectKey, - ExpiresInSeconds: f.delegatedTTLSeconds, - }) + // 4. faa.GetFile — 用 pre-shared API key streaming pull + file, err := f.faa.GetFile(ctx, targetObjectKey) if err != nil { - return "", err + return nil, nil, err } - // 5. 組 URL:FAA base + /files/?access_token= - // - object_key 用 url.PathEscape 處理(含路徑分隔符的 key 安全 escape) - // - token 用 url.QueryEscape(雖 opaque token 通常不含特殊字元,仍 escape 防呆) - downloadURL := fmt.Sprintf("%s/files/%s?access_token=%s", - f.faaBaseURL, - escapeObjectKeyPath(targetObjectKey), - url.QueryEscape(delegated.Token), - ) + // 5. 組 metadata;filename 沿用 PromoteToModels 的命名規則(`_.nef`) + contentType := file.ContentType + if contentType == "" { + // FAA 未設 → 給安全預設(octet-stream 必觸發 browser download dialog) + contentType = "application/octet-stream" + } + meta := &DownloadMetadata{ + Filename: defaultDownloadFilename(cj), + ContentType: contentType, + ContentLength: file.ContentLength, + } - f.logger.InfoContext(ctx, "conversion.flow.download_url_issued", + f.logger.InfoContext(ctx, "conversion.flow.download_stream_opened", slog.String("user_hash", hashUserID(userID)), slog.String("job_id", jobID), slog.String("object_key_hash", hashObjectKey(targetObjectKey)), - slog.Int("ttl_sec", f.delegatedTTLSeconds), + slog.Int64("content_length", file.ContentLength), + slog.String("filename", meta.Filename), ) - return downloadURL, nil + // caller 拿 io.ReadCloser 後**必須 defer Close**;本層不負責 close(透傳給 handler) + return file.Body, meta, nil +} + +// defaultDownloadFilename 產 DownloadStream 的對外 filename。 +// +// 規則:`_.nef`,對齊: +// - wireframe §8.1 success card 顯示「yolov5s.onnx → yolov5s_kl720.nef」 +// - PromoteToModels 的 defaultModelName fallback 規則 +// +// 兜底:若 cj 缺 stem / chip → 用 timestamp 或 generic name。 +func defaultDownloadFilename(cj *ConverterJob) string { + // 重用 defaultModelName 的命名邏輯(已有 stem / chip / 兜底處理), + // 然後補 .nef 副檔名給 download 用 + name := defaultModelName(cj) + if !strings.HasSuffix(strings.ToLower(name), ".nef") { + name += ".nef" + } + return name } // ensurePromoted 取 target_object_key — 若已 promote 過(model record 已存在)用 cache, @@ -875,6 +872,17 @@ func buildStorageKey(userID, modelID string) string { // // url.PathEscape 會把 '/' 也 escape 成 %2F — 對 FAA `/files/{**objectKey}` 來說 // 應該保留 '/' 為路徑分隔符,所以拆段後逐段 escape 再合回。 +// +// **Phase 0.8b 後 production code 無 caller**(僅 flow_test.go 引用)。 +// +// 保留原因:ADR-015 §7 選項 B(Phase 1+ visionA 自簽 short-TTL HMAC token + 302 redirect) +// 需要重新組合「FAA URL + ?access_token=」回 browser,會用到此 helper。 +// 砍掉後 Phase 1 還要再寫一次(含 url.PathEscape 對 path segment 的細節),維護成本極低 +// (函式 12 行 + test 10 行),保留更划算。 +// +// 若 Phase 1 確定不採選項 B(例如直接擴展 stream proxy 容量),可一併砍除函式 + test。 +// +//nolint:unused // Phase 1+ ADR-015 §7 選項 B 預留 func escapeObjectKeyPath(objectKey string) string { parts := strings.Split(objectKey, "/") for i := range parts { diff --git a/visionA-backend/internal/conversion/flow_test.go b/visionA-backend/internal/conversion/flow_test.go index 73ed99b..46135d6 100644 --- a/visionA-backend/internal/conversion/flow_test.go +++ b/visionA-backend/internal/conversion/flow_test.go @@ -1,19 +1,21 @@ // flow_test.go — Service interface 整合層的單元測試。 // // 測試策略: -// - 各 client 用 in-package stub(不耦合 T2-T5 真實邏輯,純驗 flow 整合行為) +// - 各 client 用 in-package stub(不耦合 T3 / T4 / T5 真實邏輯,純驗 flow 整合行為) // - 沿用 ownership_test.go 的 stubConverterClient(補上 InitJob/GetJob/Promote 實作) -// - 用本檔案專屬的 stubFAAClient / stubMCTokenClient / stubModelStore / stubStorage +// - 用本檔案專屬的 stubFAAClient / stubModelStore / stubStorage // // 涵蓋 5 個 method × happy / ownership 失敗 / client 失敗 propagation + // task spec 額外要求: // - InitJob 同 user 已有 active → ActiveJobError // - PromoteToModels 已 promote 過 → 回既有 model_id(idempotent) // - PromoteToModels job 沒 succeeded → ErrJobNotCompleted -// - DownloadRedirectURL URL 組裝正確(含 url.PathEscape / url.QueryEscape) +// - DownloadStream 從 FAA stream 拉到正確 metadata(Phase 0.8b:取代原 DownloadRedirectURL URL 組裝) // - ActiveJob converter 回 404 → ownership.Delete + (nil, nil) // // Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §2.7) +// Phase 0.8b T4:DownloadRedirectURL → DownloadStream + 砍 flowStubMCToken +// (見 ADR-015 §6 + conversion.md §3 / §4.1) package conversion import ( @@ -171,42 +173,8 @@ func (s *flowStubFAA) GetFile(ctx context.Context, objectKey string) (*FAAFile, var _ FAAClient = (*flowStubFAA)(nil) -// flowStubMCToken 是 MCTokenClient stub。 -type flowStubMCToken struct { - serviceTokenFunc func(ctx context.Context, scope string) (string, error) - issueDelegatedDownloadFunc func(ctx context.Context, in IssueDownloadReq) (*DelegatedDownloadToken, error) - - // 紀錄最後一次 IssueDelegatedDownload 收到的 input - mu sync.Mutex - lastIssueInput *IssueDownloadReq -} - -func newFlowStubMCToken() *flowStubMCToken { - return &flowStubMCToken{} -} - -func (s *flowStubMCToken) ServiceToken(ctx context.Context, scope string) (string, error) { - if s.serviceTokenFunc != nil { - return s.serviceTokenFunc(ctx, scope) - } - return "stub-service-token", nil -} - -func (s *flowStubMCToken) IssueDelegatedDownload(ctx context.Context, in IssueDownloadReq) (*DelegatedDownloadToken, error) { - s.mu.Lock() - cp := in - s.lastIssueInput = &cp - s.mu.Unlock() - if s.issueDelegatedDownloadFunc != nil { - return s.issueDelegatedDownloadFunc(ctx, in) - } - return &DelegatedDownloadToken{ - Token: "opaque-stub-token-xyz", - ExpiresAt: time.Now().Add(5 * time.Minute), - }, nil -} - -var _ MCTokenClient = (*flowStubMCToken)(nil) +// Phase 0.8b T4:原 flowStubMCToken 已整段刪除(MC 認證鏈取消、flow 不再依賴 MCTokenClient)。 +// Phase 0.8b T5:mc_token_stub.go 整檔砍除;MCTokenClient interface 已不存在。 // flowStubModelStore 是 ModelStore stub。 type flowStubModelStore struct { @@ -306,17 +274,17 @@ type flowFixture struct { svc Service converter *flowStubConverter faa *flowStubFAA - mcToken *flowStubMCToken models *flowStubModelStore storage *flowStubStorage ownership Ownership } +// Phase 0.8b T4:mcToken 欄位已移除(flow 不再依賴 MCTokenClient);FlowOpts 也砍 4 個欄位 +// (MCToken / TenantID / FAABaseURL / DelegatedTTLSeconds)。 func newFlowFixture(t *testing.T) *flowFixture { t.Helper() conv := newFlowStubConverter() faa := newFlowStubFAA() - mcToken := newFlowStubMCToken() models := newFlowStubModelStore() storage := newFlowStubStorage() own := NewOwnership(conv, newSilentLogger()) @@ -324,14 +292,10 @@ func newFlowFixture(t *testing.T) *flowFixture { svc, err := NewService(FlowOpts{ Converter: conv, FAA: faa, - MCToken: mcToken, Ownership: own, ModelStore: models, Storage: storage, - TenantID: "visiona-tenant", - FAABaseURL: "https://faa.example.com", DefaultJobExpiryDuration: 7 * 24 * time.Hour, - DelegatedTTLSeconds: 300, Logger: newSilentLogger(), Now: time.Now, }) @@ -341,7 +305,6 @@ func newFlowFixture(t *testing.T) *flowFixture { svc: svc, converter: conv, faa: faa, - mcToken: mcToken, models: models, storage: storage, ownership: own, @@ -373,11 +336,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)。 func TestNewService_RequiredFields(t *testing.T) { t.Parallel() conv := newFlowStubConverter() faa := newFlowStubFAA() - mc := newFlowStubMCToken() own := NewOwnership(conv, newSilentLogger()) mod := newFlowStubModelStore() st := newFlowStubStorage() @@ -386,14 +350,11 @@ func TestNewService_RequiredFields(t *testing.T) { name string opts FlowOpts }{ - {"missing converter", FlowOpts{FAA: faa, MCToken: mc, Ownership: own, ModelStore: mod, Storage: st, TenantID: "t", FAABaseURL: "https://x"}}, - {"missing faa", FlowOpts{Converter: conv, MCToken: mc, Ownership: own, ModelStore: mod, Storage: st, TenantID: "t", FAABaseURL: "https://x"}}, - {"missing mc", FlowOpts{Converter: conv, FAA: faa, Ownership: own, ModelStore: mod, Storage: st, TenantID: "t", FAABaseURL: "https://x"}}, - {"missing ownership", FlowOpts{Converter: conv, FAA: faa, MCToken: mc, ModelStore: mod, Storage: st, TenantID: "t", FAABaseURL: "https://x"}}, - {"missing modelstore", FlowOpts{Converter: conv, FAA: faa, MCToken: mc, Ownership: own, Storage: st, TenantID: "t", FAABaseURL: "https://x"}}, - {"missing storage", FlowOpts{Converter: conv, FAA: faa, MCToken: mc, Ownership: own, ModelStore: mod, TenantID: "t", FAABaseURL: "https://x"}}, - {"missing tenant", FlowOpts{Converter: conv, FAA: faa, MCToken: mc, Ownership: own, ModelStore: mod, Storage: st, FAABaseURL: "https://x"}}, - {"missing faaurl", FlowOpts{Converter: conv, FAA: faa, MCToken: mc, Ownership: own, ModelStore: mod, Storage: st, TenantID: "t"}}, + {"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}}, } for _, tt := range tests { tt := tt @@ -409,24 +370,20 @@ func TestNewService_DefaultsApplied(t *testing.T) { t.Parallel() conv := newFlowStubConverter() faa := newFlowStubFAA() - mc := newFlowStubMCToken() own := NewOwnership(conv, newSilentLogger()) mod := newFlowStubModelStore() st := newFlowStubStorage() svc, err := NewService(FlowOpts{ - Converter: conv, FAA: faa, MCToken: mc, Ownership: own, + Converter: conv, FAA: faa, Ownership: own, ModelStore: mod, Storage: st, - TenantID: "visiona", FAABaseURL: "https://faa.example.com/", - // DefaultJobExpiryDuration / DelegatedTTLSeconds 留空 → 應 fallback + // DefaultJobExpiryDuration 留空 → 應 fallback 7d }) require.NoError(t, err) require.NotNil(t, svc) f := svc.(*flow) assert.Equal(t, 7*24*time.Hour, f.defaultJobExpiryDuration) - assert.Equal(t, 300, f.delegatedTTLSeconds) - assert.Equal(t, "https://faa.example.com", f.faaBaseURL, "trailing slash 應被 trim") } // ========================================================================== @@ -1040,93 +997,143 @@ func TestPromoteToModels_ModelStoreError(t *testing.T) { } // ========================================================================== -// DownloadRedirectURL +// DownloadStream(Phase 0.8b:取代原 DownloadRedirectURL) // ========================================================================== +// +// Phase 0.8b 變更(ADR-015 §7 + conversion.md §4.1): +// - DownloadRedirectURL → DownloadStream(API key 模式下沒有 MC delegated token) +// - 不再組「FAA URL + ?access_token=」;改成直接回 io.ReadCloser + DownloadMetadata +// - 不再依賴 MCTokenClient(flowStubMCToken 已整段刪除) +// - 對外仍由同一個 GET /api/conversion/{job_id}/download endpoint 觸發(handler 層改 stream proxy) +// +// 測試 case 對齊原 6 個 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 失敗透傳 -// TestDownloadRedirectURL_HappyPath:URL 組裝正確(task spec 要求)。 -func TestDownloadRedirectURL_HappyPath(t *testing.T) { +// TestDownloadStream_HappyPath:成功 → 拿到 io.ReadCloser + DownloadMetadata 正確。 +func TestDownloadStream_HappyPath(t *testing.T) { t.Parallel() fix := newFlowFixture(t) fix.converter.setJob(&ConverterJob{ - JobID: "j1", Status: "completed", CreatedAt: time.Now(), + JobID: "j1", + Status: "completed", + CreatedAt: time.Now(), + SourceFilename: "yolov5s.onnx", + Platform: "720", }) fix.ownership.Set("j1", "user-alice") - url, err := fix.svc.DownloadRedirectURL(context.Background(), "user-alice", "j1") + stream, meta, err := fix.svc.DownloadStream(context.Background(), "user-alice", "j1") require.NoError(t, err) + require.NotNil(t, stream) + require.NotNil(t, meta) + defer stream.Close() - // FAA base + /files/?access_token= - // key = "models/user-alice/j1.nef",token = "opaque-stub-token-xyz" - assert.Equal(t, - "https://faa.example.com/files/models/user-alice/j1.nef?access_token=opaque-stub-token-xyz", - url, - ) + // metadata 對齊 stub FAA 的 default 行為(faa_client_test.go newFlowStubFAA) + assert.Equal(t, "yolov5s_kl720.nef", meta.Filename, + "filename = _.nef,對齊 wireframe §8.1 + defaultDownloadFilename") + assert.Equal(t, "application/octet-stream", meta.ContentType) + assert.Equal(t, int64(len("nef-bytes-stub")), meta.ContentLength) - // 驗 IssueDelegatedDownload 帶到的參數 - fix.mcToken.mu.Lock() - in := fix.mcToken.lastIssueInput - fix.mcToken.mu.Unlock() - require.NotNil(t, in) - assert.Equal(t, "visiona-tenant", in.TenantID) - assert.Equal(t, "user-alice", in.UserID) - assert.Equal(t, "models/user-alice/j1.nef", in.ObjectKey) - assert.Equal(t, 300, in.ExpiresInSeconds) + // stream 內容與 stub FAA 預設 body 一致(streaming pull) + body, err := io.ReadAll(stream) + require.NoError(t, err) + assert.Equal(t, "nef-bytes-stub", string(body)) + + // 驗 faa.GetFile 帶到的 object_key(buildTargetObjectKey 規則:models//.nef) + fix.faa.mu.Lock() + gotKey := fix.faa.lastKey + fix.faa.mu.Unlock() + assert.Equal(t, "models/user-alice/j1.nef", gotKey) } -// TestDownloadRedirectURL_EscapeSpecialChars:特殊字元的 user_id / job_id 走 escape。 -func TestDownloadRedirectURL_EscapeSpecialChars(t *testing.T) { +// TestDownloadStream_FilenameFromConverterJob:filename 取自 cj.SourceFilename + Platform, +// 而非從 FAA metadata 拿(API key 模式下 FAA URL 不再含原檔名)。 +func TestDownloadStream_FilenameFromConverterJob(t *testing.T) { t.Parallel() fix := newFlowFixture(t) - fix.mcToken.issueDelegatedDownloadFunc = func(ctx context.Context, in IssueDownloadReq) (*DelegatedDownloadToken, error) { - // 模擬 token 含特殊字元 - return &DelegatedDownloadToken{ - Token: "abc def+/=", - ExpiresAt: time.Now().Add(5 * time.Minute), - }, nil - } - // 用合法但帶 special char 的 user_id(OIDC sub 通常不會這樣,但要 defensive) - userID := "user with space" - fix.converter.setJob(&ConverterJob{JobID: "j1", Status: "completed", CreatedAt: time.Now()}) - fix.ownership.Set("j1", userID) + fix.converter.setJob(&ConverterJob{ + JobID: "j1", + Status: "completed", + CreatedAt: time.Now(), + SourceFilename: "/path/to/my_model.tflite", // 有 path prefix → 應只取 stem + Platform: "520", + }) + fix.ownership.Set("j1", "user-alice") - url, err := fix.svc.DownloadRedirectURL(context.Background(), userID, "j1") + _, meta, err := fix.svc.DownloadStream(context.Background(), "user-alice", "j1") require.NoError(t, err) - // path 段 user_id 應 escape(' ' → %20) - assert.Contains(t, url, "/files/models/user%20with%20space/j1.nef") - // token 段應 query escape('+' / '=' / '/' / ' ') - assert.Contains(t, url, "?access_token=abc+def%2B%2F%3D") + require.NotNil(t, meta) + assert.Equal(t, "my_model_kl520.nef", meta.Filename) } -// TestDownloadRedirectURL_OwnershipMismatch:not_found。 -func TestDownloadRedirectURL_OwnershipMismatch(t *testing.T) { +// TestDownloadStream_DefaultsContentType:FAA 沒給 Content-Type → 預設 octet-stream +// (確保 browser download dialog 仍會觸發)。 +func TestDownloadStream_DefaultsContentType(t *testing.T) { + t.Parallel() + fix := newFlowFixture(t) + + fix.faa.getFileFunc = func(ctx context.Context, objectKey string) (*FAAFile, error) { + return &FAAFile{ + Body: io.NopCloser(strings.NewReader("nef")), + ContentLength: 3, + ContentType: "", // 故意空白 + ETag: "etag", + }, nil + } + fix.converter.setJob(&ConverterJob{ + JobID: "j1", Status: "completed", CreatedAt: time.Now(), + SourceFilename: "x.onnx", Platform: "720", + }) + fix.ownership.Set("j1", "user-alice") + + _, meta, err := fix.svc.DownloadStream(context.Background(), "user-alice", "j1") + require.NoError(t, err) + assert.Equal(t, "application/octet-stream", meta.ContentType, + "FAA 沒給 Content-Type 時應 fallback 為 application/octet-stream") +} + +// TestDownloadStream_OwnershipMismatch:別 user 的 job → ErrJobNotFound(防枚舉)。 +func TestDownloadStream_OwnershipMismatch(t *testing.T) { t.Parallel() fix := newFlowFixture(t) fix.converter.setJob(&ConverterJob{JobID: "j1", Status: "completed", CreatedAt: time.Now()}) fix.ownership.Set("j1", "user-bob") - _, err := fix.svc.DownloadRedirectURL(context.Background(), "user-alice", "j1") + stream, meta, err := fix.svc.DownloadStream(context.Background(), "user-alice", "j1") require.Error(t, err) assert.True(t, errors.Is(err, ErrJobNotFound)) + assert.Nil(t, stream) + assert.Nil(t, meta) + + // FAA 不該被打到(ownership 不符在 FAA call 之前) + assert.Equal(t, int32(0), fix.faa.getCalls.Load()) } -// TestDownloadRedirectURL_JobNotCompleted:still running → ErrJobNotCompleted。 -func TestDownloadRedirectURL_JobNotCompleted(t *testing.T) { +// TestDownloadStream_JobNotCompleted:still running → ErrJobNotCompleted。 +func TestDownloadStream_JobNotCompleted(t *testing.T) { t.Parallel() fix := newFlowFixture(t) fix.converter.setJob(&ConverterJob{JobID: "j1", Status: "running", CreatedAt: time.Now()}) fix.ownership.Set("j1", "user-alice") - _, err := fix.svc.DownloadRedirectURL(context.Background(), "user-alice", "j1") + stream, meta, err := fix.svc.DownloadStream(context.Background(), "user-alice", "j1") require.Error(t, err) assert.True(t, errors.Is(err, ErrJobNotCompleted)) + assert.Nil(t, stream) + assert.Nil(t, meta) } -// TestDownloadRedirectURL_PromoteError_Propagation:promote 5xx 透傳。 -func TestDownloadRedirectURL_PromoteError_Propagation(t *testing.T) { +// TestDownloadStream_PromoteError_Propagation:promote 5xx 透傳。 +func TestDownloadStream_PromoteError_Propagation(t *testing.T) { t.Parallel() fix := newFlowFixture(t) @@ -1136,25 +1143,54 @@ func TestDownloadRedirectURL_PromoteError_Propagation(t *testing.T) { return nil, fmt.Errorf("%w: promote 502", ErrConverterUnavailable) } - _, err := fix.svc.DownloadRedirectURL(context.Background(), "user-alice", "j1") + _, _, err := fix.svc.DownloadStream(context.Background(), "user-alice", "j1") require.Error(t, err) assert.True(t, errors.Is(err, ErrConverterUnavailable)) + + // FAA 不該被打到(promote 失敗在 FAA call 之前) + assert.Equal(t, int32(0), fix.faa.getCalls.Load()) } -// TestDownloadRedirectURL_MCError_Propagation:MC delegated 5xx 透傳。 -func TestDownloadRedirectURL_MCError_Propagation(t *testing.T) { +// TestDownloadStream_FAAError_Propagation:FAA pull 5xx 透傳(取代原 MCError test)。 +// +// Phase 0.8b 後 download path 不再經 MC,FAA stream 失敗是最常見的失敗模式 +// (API key 不對齊 → ErrFAAAuthFailed;FAA 服務不可達 → ErrFAAUnavailable)。 +func TestDownloadStream_FAAError_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.mcToken.issueDelegatedDownloadFunc = func(ctx context.Context, in IssueDownloadReq) (*DelegatedDownloadToken, error) { - return nil, fmt.Errorf("%w: mc 5xx", ErrMCTokenUnavailable) + fix.faa.getFileFunc = func(ctx context.Context, objectKey string) (*FAAFile, error) { + return nil, fmt.Errorf("%w: faa 502", ErrFAAUnavailable) } - _, err := fix.svc.DownloadRedirectURL(context.Background(), "user-alice", "j1") + stream, meta, err := fix.svc.DownloadStream(context.Background(), "user-alice", "j1") require.Error(t, err) - assert.True(t, errors.Is(err, ErrMCTokenUnavailable)) + assert.True(t, errors.Is(err, ErrFAAUnavailable)) + assert.Nil(t, stream) + assert.Nil(t, meta) +} + +// TestDownloadStream_FAAAuthFailed_Propagation:FAA API key 不對齊 → ErrFAAAuthFailed +// 透傳(handler 層會 mask 成 faa_unavailable 對外)。 +func TestDownloadStream_FAAAuthFailed_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.faa.getFileFunc = func(ctx context.Context, objectKey string) (*FAAFile, error) { + return nil, fmt.Errorf("%w: faa 401", ErrFAAAuthFailed) + } + + _, _, err := fix.svc.DownloadStream(context.Background(), "user-alice", "j1") + require.Error(t, err) + assert.True(t, errors.Is(err, ErrFAAAuthFailed), + "flow 層 sentinel 仍是 ErrFAAAuthFailed;handler 層才 mask 對外") + // 驗 sentinel 可被 errors.As 解出(handler 用 conversion.HTTPStatus / ErrorCode 處理) + assert.Equal(t, "faa_unavailable", ErrorCode(err), + "ErrorCode helper 對 ErrFAAAuthFailed 應 mask 成 faa_unavailable(對外不洩漏 auth_failed)") } // ========================================================================== diff --git a/visionA-backend/internal/conversion/mc_token_client.go b/visionA-backend/internal/conversion/mc_token_client.go deleted file mode 100644 index 6f5e966..0000000 --- a/visionA-backend/internal/conversion/mc_token_client.go +++ /dev/null @@ -1,624 +0,0 @@ -// MC token client — visionA-backend 對 Member Center 取兩種 token: -// - service token(client_credentials grant):自己呼叫 converter / FAA 用,per-scope cache -// - delegated download token:給 user 換 short-lived FAA download URL(不 cache,每次新簽) -// -// 設計參考: -// - kneron_model_converter/apps/task-scheduler/src/auth/oauthClient.js(Node 版同模式, -// 已在 production 跑過;這裡 Go 版改用 sync.Mutex + DCL,不用 promise dedup) -// - 本檔案搭配 .autoflow/04-architecture/conversion.md §2.4 / §5 / §9.1 retry 矩陣 -// -// 安全: -// - **絕不**把 client_secret / access_token / Authorization header 內容寫進 log -// - 錯誤訊息只揭露 status + 是否 retry,不揭露 server 端細節 -// -// Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §2.4 / §5) -package conversion - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "io" - "log/slog" - "net/http" - "net/url" - "strings" - "sync" - "time" -) - -// ========================================================================== -// 對外 type / interface -// ========================================================================== - -// MCTokenClient 對 Member Center 取兩種 token。 -// -// 兩個 method 的錯誤處理策略對齊 conversion.md §6: -// -// - `ServiceToken`(打 MC `/oauth/token`,client_credentials grant): -// 401/403 → ErrServiceClientUnauthorized(500/idp_misconfigured 對外) -// 其他 4xx → ErrIDPMisconfigured(500/idp_misconfigured,i18n=idp_misconfig) -// 5xx / network 持續失敗 → ErrIDPUnavailable(503/idp_unavailable,i18n=idp_down) -// -// - `IssueDelegatedDownload`(打 MC `/file-access/download-tokens`): -// 401/403 → ErrServiceClientUnauthorized -// 其他 4xx → ErrDownloadTokenFailed(502/download_token_failed,i18n=token_failed) -// 5xx / network 持續失敗 → ErrMCTokenUnavailable(502/mc_token_unavailable,i18n=token_failed) -// -// 兩 endpoint 的 4xx / 5xx 用不同 sentinel — 因為 §6 的 i18n 訊息設計區分了 -// 「IDP 設定錯誤」「IDP 暫時不可用」「下載授權失敗」「MC 不可達」四種不同的 user-facing 提示 -// (前者引導使用者「聯絡支援」,後者引導「稍後再試」)。 -// -// goroutine-safe:cache 用 sync.Mutex,DCL 確保併發 fetch 只發一次 request。 -type MCTokenClient interface { - // ServiceToken 取一個 access token(client_credentials grant),可 cache 重用。 - // - // scope 範例: - // "converter:job.write converter:job.read files:download.read files:download.delegate" - // (多 scope 用空白分隔,依 RFC 6749 §3.3) - // - // cache 行為(見 §5.2): - // - per-scope cache(不同 scope 各自獨立) - // - 過期判斷:now() >= exp - 15s(提前 15 秒 refresh 避免邊界 race) - // - 失敗不 cache,下一次呼叫會重試 - // - DCL 防併發爆量(100 個 caller 同時要 token,只 fetch 一次) - ServiceToken(ctx context.Context, scope string) (string, error) - - // IssueDelegatedDownload 跟 MC 換 browser 直連 FAA 用的 opaque token。 - // - // 流程: - // 1. 先取 service token(scope=files:download.delegate)— 內部呼 ServiceToken - // 2. POST {issuer}/file-access/download-tokens - // 3. 回 opaque token + 過期時間 - // - // caller 通常是 flow.DownloadRedirectURL,拿到後組 - // https:///files/?access_token= - // 走 server-side 302 redirect 給 browser(見 conversion.md §10.4)。 - IssueDelegatedDownload(ctx context.Context, in IssueDownloadReq) (*DelegatedDownloadToken, error) -} - -// IssueDownloadReq 是 IssueDelegatedDownload 的輸入。 -// -// 欄位來源(trust boundary 見 conversion.md §7): -// - TenantID / UserID / ObjectKey 由 visionA-backend 內部產生(OIDC sub + promote 結果), -// 不接受 client 傳入 -// - ExpiresInSeconds 預設 300(5 分鐘),可在 caller 指定(範圍由 caller 自行檢查) -type IssueDownloadReq struct { - TenantID string - UserID string - ObjectKey string - ExpiresInSeconds int // <= 0 時自動套用預設 300 -} - -// DelegatedDownloadToken 是 MC 簽出來的 short-lived token。 -// -// Token 是 opaque(FAA 收到後再對 MC validate),visionA-backend 不解碼。 -type DelegatedDownloadToken struct { - Token string - ExpiresAt time.Time -} - -// MCTokenClientOpts 是 NewMCTokenClient 的依賴注入。 -// -// HTTPClient / Now / Logger 為 optional(nil 自動填預設)— 方便 unit test 注入 fake。 -type MCTokenClientOpts struct { - // Issuer 是 MC issuer URL(不帶結尾斜線)。 - // 會打: - // POST {Issuer}/oauth/token - // POST {Issuer}/file-access/download-tokens - Issuer string - - // ClientID / ClientSecret 是 visionA service client 在 MC 的註冊資訊。 - // **禁止 commit 進 repo**;由 main.go 從 env var 讀進 config 後注入。 - ClientID string - ClientSecret string - - // HTTPClient 為 optional;nil 用預設(timeout 10s)。測試會注入 httptest.Server.Client()。 - HTTPClient *http.Client - - // Now 為 optional;nil 用 time.Now。測試會注入 fake clock 控制 cache 過期。 - Now func() time.Time - - // Logger 為 optional;nil 用 slog.Default()。 - Logger *slog.Logger -} - -// ========================================================================== -// 內部實作 -// ========================================================================== - -// 內部固定常數(不對外,避免 caller hardcode)。 -const ( - // tokenRefreshSkew 是 cache 過期判斷的緩衝;now() >= exp - skew 視為過期。 - // 15s 對齊 conversion.md §2.4 / §5.2。 - tokenRefreshSkew = 15 * time.Second - - // httpTimeout 是預設 HTTP client timeout(dialer + response 整體)。 - httpTimeout = 10 * time.Second - - // maxRetries 是 5xx / network / timeout 的最大重試次數(不含第一次)。 - // 對齊 conversion.md §9.1:MC oauth/token 與 file-access/download-tokens 都 max 2 次。 - maxRetries = 2 - - // retryBaseDelay 是指數退避的 base(1s, 2s)。 - retryBaseDelay = 1 * time.Second - - // defaultDelegatedTTL 是 IssueDelegatedDownload 預設 TTL(caller 不傳就 300)。 - defaultDelegatedTTL = 300 -) - -// cachedToken 是 ServiceToken cache 內部結構。 -type cachedToken struct { - token string - expiresAt time.Time -} - -// mcTokenClient 是 MCTokenClient 的預設實作。 -// -// 套件內 unexported struct(caller 拿 interface),讓未來換實作不影響 caller。 -type mcTokenClient struct { - issuer string - clientID string - clientSecret string - http *http.Client - now func() time.Time - logger *slog.Logger - - // cache 由 mu 保護;key=scope(multi-scope string 直接當 key, - // 不做 normalize — caller 應傳穩定排序的 scope 字串)。 - mu sync.Mutex // sync.Mutex 比 RWMutex 簡單;fetch 路徑 IO bound,RWMutex 沒有實質好處 - cache map[string]cachedToken -} - -// NewMCTokenClient 建立一個 MCTokenClient 實例。 -// -// 必填:Issuer / ClientID / ClientSecret。其他 optional。 -// 注意:constructor 不會驗 Issuer 連線,第一次 ServiceToken 呼叫才會打網路。 -func NewMCTokenClient(opts MCTokenClientOpts) MCTokenClient { - httpClient := opts.HTTPClient - if httpClient == nil { - httpClient = &http.Client{Timeout: httpTimeout} - } - now := opts.Now - if now == nil { - now = time.Now - } - logger := opts.Logger - if logger == nil { - logger = slog.Default() - } - return &mcTokenClient{ - issuer: strings.TrimRight(opts.Issuer, "/"), - clientID: opts.ClientID, - clientSecret: opts.ClientSecret, - http: httpClient, - now: now, - logger: logger, - cache: make(map[string]cachedToken), - } -} - -// ========================================================================== -// ServiceToken 實作(含 DCL cache) -// ========================================================================== - -// ServiceToken 實作 MCTokenClient.ServiceToken。 -// -// DCL 流程: -// 1. 拿鎖 → 看 cache → 還新鮮就 unlock 後 return(fast path) -// 2. cache 過期 → 持鎖直接 fetch(在鎖內執行 HTTP request) -// -// 鎖內 fetch 的取捨: -// - 優點:實作極簡,無 in-flight Promise / sync.Once dance;併發 100 個 caller 全部 -// 在同一個 mutex 上排隊,第一個 fetch 完寫 cache 後,後續 caller 走 fast path -// - 缺點:fetch 期間(最多 10s timeout + 2 retries = 最壞 ~13s)所有同 scope 的 -// caller 全部 block;不同 scope 因為共用同一個 mu,也會 block(比 per-scope 鎖差) -// -// 為什麼不用 per-scope 鎖: -// - Phase 0.8 同時只用 1-2 個 scope,per-scope 鎖的好處邊際 -// - 簡單性 > 微優化;若未來 profiling 顯示瓶頸再改 sync.Map + per-scope mutex -// -// 為什麼不用 sync.Once: -// - sync.Once 不能 reset(cache 過期後要重 fetch)— 不適用 -func (c *mcTokenClient) ServiceToken(ctx context.Context, scope string) (string, error) { - if scope == "" { - return "", fmt.Errorf("conversion/mc_token_client: scope is required") - } - - c.mu.Lock() - defer c.mu.Unlock() - - // fast path:cache hit 且仍新鮮 - if entry, ok := c.cache[scope]; ok && c.isStillFresh(entry) { - return entry.token, nil - } - - // cache miss / 過期 → fetch(在鎖內執行) - token, exp, err := c.fetchServiceToken(ctx, scope) - if err != nil { - // 失敗不寫 cache;下次重試 - return "", err - } - - c.cache[scope] = cachedToken{ - token: token, - expiresAt: exp, - } - return token, nil -} - -// isStillFresh 判斷 cache entry 是否還能用。 -// 真正的過期時間是 expiresAt - tokenRefreshSkew(提前 15s 視為過期)。 -func (c *mcTokenClient) isStillFresh(entry cachedToken) bool { - if entry.token == "" { - return false - } - return c.now().Before(entry.expiresAt.Add(-tokenRefreshSkew)) -} - -// fetchServiceToken 真正打 MC oauth/token endpoint 取 token。 -// 已 retry 過所有可重試錯誤;回傳 error 時 caller 應視為 fatal(這次取不到)。 -func (c *mcTokenClient) fetchServiceToken(ctx context.Context, scope string) (string, time.Time, error) { - tokenURL := c.issuer + "/oauth/token" - - form := url.Values{} - form.Set("grant_type", "client_credentials") - form.Set("scope", scope) - - body, err := c.doWithRetry(ctx, endpointKindServiceToken, scope, func() (*http.Request, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, - strings.NewReader(form.Encode())) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("Accept", "application/json") - // RFC 6749 §2.3.1 推薦:client credentials 走 Basic auth header(比 body 安全) - req.SetBasicAuth(c.clientID, c.clientSecret) - return req, nil - }) - if err != nil { - return "", time.Time{}, err - } - - // 解析 token endpoint response shape(RFC 6749 §5.1) - var resp struct { - AccessToken string `json:"access_token"` - TokenType string `json:"token_type"` - ExpiresIn int `json:"expires_in"` - Scope string `json:"scope,omitempty"` - } - if err := json.Unmarshal(body, &resp); err != nil { - c.logger.Warn("conversion.mc_token.parse_failed", - slog.String("endpoint", endpointKindServiceToken), - slog.String("scope", scope), - // 不 log body(可能含 access_token),只 log 錯誤訊息 - slog.String("err", truncate(err.Error(), 100))) - // IdP 回了 200 但 body 不是合法 JSON — 視為服務暫時失常(503/idp_unavailable) - return "", time.Time{}, fmt.Errorf("%w: parse service token response: %v", - ErrIDPUnavailable, err) - } - if resp.AccessToken == "" || resp.ExpiresIn <= 0 { - c.logger.Warn("conversion.mc_token.invalid_shape", - slog.String("endpoint", endpointKindServiceToken), - slog.String("scope", scope), - slog.Int("access_token_length", len(resp.AccessToken)), - slog.Int("expires_in", resp.ExpiresIn)) - // IdP 回了 200 但 shape 不對 — 同上視為 503/idp_unavailable - return "", time.Time{}, fmt.Errorf("%w: invalid service token response shape", - ErrIDPUnavailable) - } - - expiresAt := c.now().Add(time.Duration(resp.ExpiresIn) * time.Second) - - // 不 log token 本身;只 log 長度 + 過期時間(給除錯用) - c.logger.Info("conversion.mc_token.obtained", - slog.String("endpoint", endpointKindServiceToken), - slog.String("scope", scope), - slog.Int("expires_in_sec", resp.ExpiresIn), - slog.Int("token_len", len(resp.AccessToken))) - - return resp.AccessToken, expiresAt, nil -} - -// ========================================================================== -// IssueDelegatedDownload 實作 -// ========================================================================== - -// IssueDelegatedDownload 實作 MCTokenClient.IssueDelegatedDownload。 -// -// 流程: -// 1. ServiceToken(ctx, "files:download.delegate") 取 service token -// 2. POST {issuer}/file-access/download-tokens (Bearer) -// 3. 回 opaque token + 過期時間 -// -// 不 cache(每次都新簽)— delegated token TTL 短(5 分鐘預設),cache 沒意義。 -func (c *mcTokenClient) IssueDelegatedDownload(ctx context.Context, in IssueDownloadReq) (*DelegatedDownloadToken, error) { - if in.TenantID == "" || in.UserID == "" || in.ObjectKey == "" { - return nil, fmt.Errorf("conversion/mc_token_client: tenant_id / user_id / object_key required") - } - ttl := in.ExpiresInSeconds - if ttl <= 0 { - ttl = defaultDelegatedTTL - } - - // 1. 取 service token(注意:這個呼叫本身可能 fetch,會走 cache fast path 或 fetch + retry) - // ServiceToken 內部已依 §6 mapping 失敗(ErrServiceClientUnauthorized / ErrIDPMisconfigured / - // ErrIDPUnavailable)— 這裡用 fmt.Errorf("%w") 透傳,不再二次包裝,避免錯誤碼被「升級」成 - // ErrMCTokenUnavailable 而失去原本的 i18n 區分(idp_misconfig vs idp_down)。 - serviceToken, err := c.ServiceToken(ctx, "files:download.delegate") - if err != nil { - return nil, fmt.Errorf("conversion: get service token for delegated download: %w", err) - } - - endpoint := c.issuer + "/file-access/download-tokens" - - reqBody, err := json.Marshal(map[string]any{ - "tenant_id": in.TenantID, - "user_id": in.UserID, - "object_key": in.ObjectKey, - "method": "GET", - "expires_in_seconds": ttl, - }) - if err != nil { - // 本地 marshal 失敗(理論不會發生)— 視為 MC 不可達(502/mc_token_unavailable) - return nil, fmt.Errorf("%w: marshal delegated download request: %v", - ErrMCTokenUnavailable, err) - } - - body, err := c.doWithRetry(ctx, endpointKindDelegatedDownload, in.ObjectKey, func() (*http.Request, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, - strings.NewReader(string(reqBody))) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") - req.Header.Set("Authorization", "Bearer "+serviceToken) - return req, nil - }) - if err != nil { - return nil, err - } - - // MC delegated download token response shape: - // {"token": "", "expires_at": ""} - // 若 MC 改用 expires_in_seconds,這裡 fallback 處理。 - var resp struct { - Token string `json:"token"` - ExpiresAt time.Time `json:"expires_at"` - ExpiresInSeconds int `json:"expires_in_seconds,omitempty"` - } - if err := json.Unmarshal(body, &resp); err != nil { - c.logger.Warn("conversion.mc_token.parse_failed", - slog.String("endpoint", endpointKindDelegatedDownload), - slog.String("err", truncate(err.Error(), 100))) - // MC 回 200 但 body 不是合法 JSON — 視為 MC 不可達(502/mc_token_unavailable) - return nil, fmt.Errorf("%w: parse delegated download response: %v", - ErrMCTokenUnavailable, err) - } - if resp.Token == "" { - c.logger.Warn("conversion.mc_token.invalid_shape", - slog.String("endpoint", endpointKindDelegatedDownload)) - // 同上:shape 不對視為 502/mc_token_unavailable - return nil, fmt.Errorf("%w: invalid delegated download response shape", - ErrMCTokenUnavailable) - } - expiresAt := resp.ExpiresAt - if expiresAt.IsZero() && resp.ExpiresInSeconds > 0 { - expiresAt = c.now().Add(time.Duration(resp.ExpiresInSeconds) * time.Second) - } - if expiresAt.IsZero() { - // 都沒有 → 用 caller 傳入 ttl 推算(best-effort) - expiresAt = c.now().Add(time.Duration(ttl) * time.Second) - } - - c.logger.Info("conversion.mc_token.delegated_obtained", - slog.String("endpoint", endpointKindDelegatedDownload), - slog.Int("ttl_sec", ttl), - slog.Int("token_len", len(resp.Token))) - - return &DelegatedDownloadToken{ - Token: resp.Token, - ExpiresAt: expiresAt, - }, nil -} - -// ========================================================================== -// HTTP 共用:retry / 錯誤分類 -// ========================================================================== - -// endpointKind 常數 — doWithRetry / doOnce 用來區分 4xx/5xx 該映射到哪個 sentinel。 -// -// Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §6) -const ( - endpointKindServiceToken = "service_token" // MC /oauth/token - endpointKindDelegatedDownload = "delegated_download" // MC /file-access/download-tokens -) - -// errClient4xx 取得「其他 4xx(非 401/403)」對應的 sentinel error。 -// service_token endpoint → ErrIDPMisconfigured(IDP grant 設定錯誤) -// delegated_download endpoint → ErrDownloadTokenFailed(換下載 token 失敗) -func errClient4xx(endpointKind string) error { - if endpointKind == endpointKindServiceToken { - return ErrIDPMisconfigured - } - return ErrDownloadTokenFailed -} - -// errServer5xxOrNetwork 取得「5xx / network / timeout」對應的 sentinel error。 -// service_token endpoint → ErrIDPUnavailable(認證服務暫時不可用,503) -// delegated_download endpoint → ErrMCTokenUnavailable(MC 不可達,502) -func errServer5xxOrNetwork(endpointKind string) error { - if endpointKind == endpointKindServiceToken { - return ErrIDPUnavailable - } - return ErrMCTokenUnavailable -} - -// doWithRetry 執行一次 HTTP request;遇到 5xx / network / timeout 時依 -// conversion.md §9.1 退避重試。每次 retry 之間檢查 ctx.Done()。 -// -// reqBuilder 是「每次 attempt 都重新建一個 *http.Request」的 closure -// — 因為 request body 可能在 retry 時已被讀完,必須重建。caller 內部用 -// strings.NewReader 等可重建的 body source。 -// -// 4xx 不 retry,直接 mapping 後 return。 -// -// endpointKind 是 log 用的標記("service_token" / "delegated_download")。 -// label 給 log 額外 context(scope or object_key)。 -func (c *mcTokenClient) doWithRetry( - ctx context.Context, - endpointKind, label string, - reqBuilder func() (*http.Request, error), -) ([]byte, error) { - var lastErr error - for attempt := 0; attempt <= maxRetries; attempt++ { - // retry 前檢查 ctx - if attempt > 0 { - select { - case <-ctx.Done(): - // ctx cancel/deadline → 立即 return(不 retry,不包成 ErrMCTokenUnavailable) - return nil, ctx.Err() - case <-time.After(retryBackoff(attempt)): - } - } - - req, err := reqBuilder() - if err != nil { - // 建 request 失敗(例如 URL parse error)— 視為「打不出去」的網路類問題, - // 依 endpoint 種類映射到對應 sentinel。 - return nil, fmt.Errorf("%w: build request: %v", - errServer5xxOrNetwork(endpointKind), err) - } - - body, classifiedErr, retryable := c.doOnce(req, endpointKind, label, attempt) - if classifiedErr == nil { - return body, nil - } - lastErr = classifiedErr - if !retryable { - // 4xx / 401-403 / ctx cancel:直接 return,不再 retry - return nil, classifiedErr - } - // retryable 5xx / network / timeout:繼續下一輪 - } - // 用完 retry 額度 - c.logger.Warn("conversion.mc_token.retry_exhausted", - slog.String("endpoint", endpointKind), - slog.String("label", label), - slog.Int("attempts", maxRetries+1)) - return nil, lastErr -} - -// doOnce 執行一次 HTTP request,回傳 body(成功時)+ 分類好的 error + 是否可重試。 -// -// 回傳 retryable=false 表示 caller 不應 retry: -// - ctx 已 cancel -// - 4xx response(client error,retry 沒用) -// - JSON parse 失敗只在 caller 處理,不在這裡分類 -func (c *mcTokenClient) doOnce( - req *http.Request, - endpointKind, label string, - attempt int, -) (body []byte, err error, retryable bool) { - startedAt := c.now() - res, err := c.http.Do(req) - duration := c.now().Sub(startedAt) - if err != nil { - // network / timeout / context cancel - if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { - c.logger.Warn("conversion.mc_token.ctx_cancelled", - slog.String("endpoint", endpointKind), - slog.String("label", label), - slog.Int("attempt", attempt+1), - slog.Duration("duration", duration)) - return nil, err, false - } - c.logger.Warn("conversion.mc_token.network_error", - slog.String("endpoint", endpointKind), - slog.String("label", label), - slog.Int("attempt", attempt+1), - slog.Duration("duration", duration), - // err.Error() 不會含 secret(http.Client 錯誤訊息只有 URL + 連線層 errno), - // 但仍 truncate 防 log 爆量 - slog.String("err", truncate(err.Error(), 200))) - return nil, fmt.Errorf("%w: %s network error: %v", - errServer5xxOrNetwork(endpointKind), endpointKind, err), true - } - defer res.Body.Close() - - bodyBytes, readErr := io.ReadAll(res.Body) - if readErr != nil { - c.logger.Warn("conversion.mc_token.body_read_failed", - slog.String("endpoint", endpointKind), - slog.String("label", label), - slog.Int("status", res.StatusCode), - slog.String("err", truncate(readErr.Error(), 200))) - // body read 失敗視為網路問題,可重試(依 endpoint 映射) - return nil, fmt.Errorf("%w: read response body: %v", - errServer5xxOrNetwork(endpointKind), readErr), true - } - - // 成功 2xx - if res.StatusCode >= 200 && res.StatusCode < 300 { - c.logger.Debug("conversion.mc_token.success", - slog.String("endpoint", endpointKind), - slog.String("label", label), - slog.Int("status", res.StatusCode), - slog.Int("attempt", attempt+1), - slog.Duration("duration", duration)) - return bodyBytes, nil, false - } - - // 錯誤分類(不寫 body 進 log — error_description 可能含 client_id / requestId) - c.logger.Warn("conversion.mc_token.endpoint_error", - slog.String("endpoint", endpointKind), - slog.String("label", label), - slog.Int("status", res.StatusCode), - slog.Int("attempt", attempt+1), - slog.Duration("duration", duration)) - - // 401 / 403:client 認證失敗 — 不可重試(重試也會繼續 401) - // 兩個 endpoint 都用同一個 sentinel(caller 可用 errors.Is 做精細處理, - // 例如 cache invalidate;對外仍透過 ErrorCode mask 成 idp_misconfigured/500) - if res.StatusCode == http.StatusUnauthorized || res.StatusCode == http.StatusForbidden { - return nil, fmt.Errorf("%w: %s endpoint returned %d", - ErrServiceClientUnauthorized, endpointKind, res.StatusCode), false - } - - // 其他 4xx:不可重試 — 依 endpoint 種類映射到對應 sentinel: - // service_token → ErrIDPMisconfigured (500/idp_misconfigured) - // delegated_download → ErrDownloadTokenFailed (502/download_token_failed) - if res.StatusCode >= 400 && res.StatusCode < 500 { - return nil, fmt.Errorf("%w: %s endpoint returned %d", - errClient4xx(endpointKind), endpointKind, res.StatusCode), false - } - - // 5xx:可重試 — 依 endpoint 種類映射到對應 sentinel: - // service_token → ErrIDPUnavailable (503/idp_unavailable) - // delegated_download → ErrMCTokenUnavailable (502/mc_token_unavailable) - return nil, fmt.Errorf("%w: %s endpoint returned %d", - errServer5xxOrNetwork(endpointKind), endpointKind, res.StatusCode), true -} - -// retryBackoff 回傳第 n 次 retry(n 從 1 開始)的等待時間。 -// 1 → 1s, 2 → 2s(對齊 conversion.md §9.1) -// -// 不加 jitter — Phase 0.8 預期同時 fetch 的 caller 已被 DCL 收斂到單一執行, -// 不會有大量併發打 MC,jitter 邊際效益低。 -func retryBackoff(attempt int) time.Duration { - if attempt < 1 { - return retryBaseDelay - } - return retryBaseDelay * time.Duration(attempt) -} - -// truncate 把字串截到 max 長度(避免 log 太長)。 -func truncate(s string, max int) string { - if len(s) <= max { - return s - } - return s[:max] + "...(truncated)" -} diff --git a/visionA-backend/internal/conversion/mc_token_client_test.go b/visionA-backend/internal/conversion/mc_token_client_test.go deleted file mode 100644 index 282deb7..0000000 --- a/visionA-backend/internal/conversion/mc_token_client_test.go +++ /dev/null @@ -1,864 +0,0 @@ -// MC Token Client 單元測試。 -// -// 測試策略: -// - 用 httptest.Server mock MC,accept counter / atomic 驗 retry / cache 行為 -// - 用 fake clock 控制時間(測 cache 過期) -// - 用 silent logger 避免 test 輸出污染(assert 過程仍可 inspect) -// -// 對應 task 規範必含 11 個 case;本檔每個都有對應 test func。 -// -// Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §2.4 / §5) -package conversion - -import ( - "context" - "errors" - "fmt" - "io" - "log/slog" - "net/http" - "net/http/httptest" - "net/url" - "strings" - "sync" - "sync/atomic" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// silentLogger 是 test 用的 no-op logger,避免 test 輸出污染。 -func silentLogger() *slog.Logger { - return slog.New(slog.NewTextHandler(io.Discard, nil)) -} - -// fakeClock 提供可控的時間源;用 atomic 操作 nano 確保 race-free。 -type fakeClock struct { - nano atomic.Int64 // unix nano -} - -func newFakeClock(t time.Time) *fakeClock { - c := &fakeClock{} - c.nano.Store(t.UnixNano()) - return c -} - -func (c *fakeClock) now() time.Time { - return time.Unix(0, c.nano.Load()) -} - -func (c *fakeClock) advance(d time.Duration) { - c.nano.Add(int64(d)) -} - -// ========================================================================== -// mock helpers — 模擬 MC oauth/token + file-access/download-tokens 兩個 endpoint -// ========================================================================== - -// tokenServerOpts 控制 mock server 行為。 -type tokenServerOpts struct { - // expiresIn 是回給 caller 的 expires_in(秒);預設 3600 - expiresIn int - - // statusFn 控制每次 request 的 HTTP status;預設 200 - statusFn func(callIdx int) int - - // tokenFn 控制每次 request 的 access_token 內容;預設 "tok-{idx}" - tokenFn func(callIdx int) string - - // delay 是 server 回應前的等待(測 timeout / cancel 用) - delay time.Duration - - // invalidJSON 為 true 時回非 JSON body(測 parse error) - invalidJSON bool - - // emptyToken 為 true 時回 access_token=""(測 invalid shape) - emptyToken bool -} - -// newTokenServer 建立一個 mock MC server,提供 /oauth/token endpoint。 -// -// 回傳:server URL、call counter(atomic,可用來驗 fetch 次數)、收到的 last form values。 -func newTokenServer(t *testing.T, opts tokenServerOpts) (*httptest.Server, *atomic.Int32, *sync.Map) { - t.Helper() - var counter atomic.Int32 - lastForm := &sync.Map{} // map[int]url.Values,key 是 call idx - - if opts.expiresIn == 0 { - opts.expiresIn = 3600 - } - if opts.statusFn == nil { - opts.statusFn = func(int) int { return 200 } - } - if opts.tokenFn == nil { - opts.tokenFn = func(idx int) string { return fmt.Sprintf("tok-%d", idx) } - } - - mux := http.NewServeMux() - mux.HandleFunc("/oauth/token", func(w http.ResponseWriter, r *http.Request) { - idx := int(counter.Add(1)) - 1 - - // 驗 Basic auth + Content-Type 都對 - if _, _, ok := r.BasicAuth(); !ok { - t.Errorf("oauth/token expected Basic auth header, got none") - } - if !strings.HasPrefix(r.Header.Get("Content-Type"), "application/x-www-form-urlencoded") { - t.Errorf("oauth/token expected form content-type, got %q", r.Header.Get("Content-Type")) - } - - // 解 body 存起來給 test 檢查 - _ = r.ParseForm() - // 拷一份 r.Form 進 sync.Map(r.Form 之後可能被 server 覆寫) - form := url.Values{} - for k, v := range r.Form { - form[k] = append([]string(nil), v...) - } - lastForm.Store(idx, form) - - if opts.delay > 0 { - select { - case <-time.After(opts.delay): - case <-r.Context().Done(): - return - } - } - - status := opts.statusFn(idx) - if status != 200 { - w.WriteHeader(status) - _, _ = w.Write([]byte(`{"error":"server_error"}`)) - return - } - - if opts.invalidJSON { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(``)) - return - } - token := opts.tokenFn(idx) - if opts.emptyToken { - token = "" - } - w.Header().Set("Content-Type", "application/json") - _, _ = fmt.Fprintf(w, `{"access_token":"%s","token_type":"Bearer","expires_in":%d}`, - token, opts.expiresIn) - }) - - srv := httptest.NewServer(mux) - t.Cleanup(srv.Close) - return srv, &counter, lastForm -} - -// downloadServerOpts 控制 download-tokens mock 行為。 -type downloadServerOpts struct { - tokenStatusFn func(callIdx int) int // /oauth/token 端的 status;預設 200 - downloadStatusFn func(callIdx int) int // /file-access/download-tokens 的 status;預設 200 - - respBody string // /file-access/download-tokens 的回應 body;預設 happy path -} - -// newDownloadServer 同時 mock /oauth/token + /file-access/download-tokens。 -// -// 回傳:server URL、download endpoint call counter、收到的 last download body(解 JSON 後)。 -func newDownloadServer(t *testing.T, opts downloadServerOpts) ( - srv *httptest.Server, - tokenCounter, downloadCounter *atomic.Int32, - lastDownloadBody *string, -) { - t.Helper() - var tCounter, dCounter atomic.Int32 - var bodyMu sync.Mutex - var lastBody string - - if opts.tokenStatusFn == nil { - opts.tokenStatusFn = func(int) int { return 200 } - } - if opts.downloadStatusFn == nil { - opts.downloadStatusFn = func(int) int { return 200 } - } - - mux := http.NewServeMux() - mux.HandleFunc("/oauth/token", func(w http.ResponseWriter, r *http.Request) { - idx := int(tCounter.Add(1)) - 1 - status := opts.tokenStatusFn(idx) - if status != 200 { - w.WriteHeader(status) - return - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"access_token":"svc-tok","token_type":"Bearer","expires_in":3600}`)) - }) - mux.HandleFunc("/file-access/download-tokens", func(w http.ResponseWriter, r *http.Request) { - idx := int(dCounter.Add(1)) - 1 - - // 把收到的 body 存起來給 test 驗 shape - body, _ := io.ReadAll(r.Body) - bodyMu.Lock() - lastBody = string(body) - bodyMu.Unlock() - - // 驗 Bearer token 有送 - auth := r.Header.Get("Authorization") - if !strings.HasPrefix(auth, "Bearer ") { - t.Errorf("download endpoint expected Bearer auth, got %q", auth) - } - - status := opts.downloadStatusFn(idx) - if status != 200 { - w.WriteHeader(status) - return - } - body2 := opts.respBody - if body2 == "" { - // happy path: 回一個 future expires_at - body2 = fmt.Sprintf(`{"token":"opaque-tok-%d","expires_at":"%s"}`, - idx, time.Now().UTC().Add(5*time.Minute).Format(time.RFC3339)) - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(body2)) - }) - - srv = httptest.NewServer(mux) - t.Cleanup(srv.Close) - - return srv, &tCounter, &dCounter, func() *string { - bodyMu.Lock() - defer bodyMu.Unlock() - s := lastBody - return &s - }() -} - -// newClient 建一個測試用的 mcTokenClient,注入 fake clock 與 silent logger。 -func newClient(srv *httptest.Server, clock *fakeClock) MCTokenClient { - opts := MCTokenClientOpts{ - Issuer: srv.URL, - ClientID: "visiona-svc-id", - ClientSecret: "visiona-svc-secret", - HTTPClient: srv.Client(), - Logger: silentLogger(), - } - if clock != nil { - opts.Now = clock.now - } - return NewMCTokenClient(opts) -} - -// ========================================================================== -// ServiceToken — cache / fetch / retry 系列 -// ========================================================================== - -func TestServiceToken_FirstCall_Fetches(t *testing.T) { - t.Parallel() - - srv, counter, lastForm := newTokenServer(t, tokenServerOpts{}) - c := newClient(srv, nil) - - tok, err := c.ServiceToken(context.Background(), "converter:job.write") - require.NoError(t, err) - assert.Equal(t, "tok-0", tok) - assert.Equal(t, int32(1), counter.Load(), "第一次呼叫應該真的打 MC") - - // 驗 form values 對齊 RFC 6749 §4.4 - if v, ok := lastForm.Load(0); ok { - form := v.(url.Values) - assert.Equal(t, "client_credentials", form.Get("grant_type")) - assert.Equal(t, "converter:job.write", form.Get("scope")) - } else { - t.Fatal("server did not record form") - } -} - -func TestServiceToken_CacheHit(t *testing.T) { - t.Parallel() - - srv, counter, _ := newTokenServer(t, tokenServerOpts{expiresIn: 3600}) - c := newClient(srv, nil) - - scope := "converter:job.write" - tok1, err := c.ServiceToken(context.Background(), scope) - require.NoError(t, err) - tok2, err := c.ServiceToken(context.Background(), scope) - require.NoError(t, err) - tok3, err := c.ServiceToken(context.Background(), scope) - require.NoError(t, err) - - assert.Equal(t, tok1, tok2) - assert.Equal(t, tok2, tok3) - assert.Equal(t, int32(1), counter.Load(), "後續呼叫應走 cache,不打 MC") -} - -func TestServiceToken_Expired_Refetch(t *testing.T) { - t.Parallel() - - clock := newFakeClock(time.Date(2026, 4, 30, 12, 0, 0, 0, time.UTC)) - srv, counter, _ := newTokenServer(t, tokenServerOpts{expiresIn: 60}) // 60s TTL - c := newClient(srv, clock) - - scope := "converter:job.write" - tok1, err := c.ServiceToken(context.Background(), scope) - require.NoError(t, err) - assert.Equal(t, int32(1), counter.Load()) - - // 推進到 exp - skew 之後(60s - 15s = 45s),應視為過期 - clock.advance(46 * time.Second) - tok2, err := c.ServiceToken(context.Background(), scope) - require.NoError(t, err) - assert.NotEqual(t, tok1, tok2, "過期後應拿到新 token") - assert.Equal(t, int32(2), counter.Load(), "過期後應重 fetch") -} - -func TestServiceToken_DifferentScope_DifferentCache(t *testing.T) { - t.Parallel() - - srv, counter, _ := newTokenServer(t, tokenServerOpts{expiresIn: 3600}) - c := newClient(srv, nil) - - tokA1, err := c.ServiceToken(context.Background(), "scope-a") - require.NoError(t, err) - tokB1, err := c.ServiceToken(context.Background(), "scope-b") - require.NoError(t, err) - tokA2, err := c.ServiceToken(context.Background(), "scope-a") - require.NoError(t, err) - tokB2, err := c.ServiceToken(context.Background(), "scope-b") - require.NoError(t, err) - - assert.Equal(t, tokA1, tokA2, "同 scope 應走 cache") - assert.Equal(t, tokB1, tokB2) - assert.NotEqual(t, tokA1, tokB1, "不同 scope 應有不同 token") - assert.Equal(t, int32(2), counter.Load(), "兩個 scope 各 fetch 一次") -} - -// TestServiceToken_Concurrent_OnlyOneFetch — 100 個 goroutine 同時要 token,DCL 確保只 fetch 一次。 -// -// 實作細節:mock server 回應有 50ms delay,確保第一個 fetch 還沒回前所有 caller 都已進來; -// DCL 應讓他們全部 block 在 mu.Lock(),第一個 fetch 完寫 cache 後,後續 caller 走 fast path。 -func TestServiceToken_Concurrent_OnlyOneFetch(t *testing.T) { - t.Parallel() - - srv, counter, _ := newTokenServer(t, tokenServerOpts{ - expiresIn: 3600, - delay: 50 * time.Millisecond, - }) - c := newClient(srv, nil) - - const N = 100 - var wg sync.WaitGroup - wg.Add(N) - tokens := make([]string, N) - errs := make([]error, N) - start := make(chan struct{}) - - for i := 0; i < N; i++ { - go func(idx int) { - defer wg.Done() - <-start - tok, err := c.ServiceToken(context.Background(), "converter:job.write") - tokens[idx] = tok - errs[idx] = err - }(i) - } - close(start) - wg.Wait() - - for _, e := range errs { - require.NoError(t, e) - } - for i := 1; i < N; i++ { - assert.Equal(t, tokens[0], tokens[i], "所有 goroutine 應拿到同一個 token") - } - assert.Equal(t, int32(1), counter.Load(), "DCL 應確保 100 個 caller 只打一次 MC") -} - -func TestServiceToken_Server4xx_NoRetry(t *testing.T) { - t.Parallel() - - srv, counter, _ := newTokenServer(t, tokenServerOpts{ - statusFn: func(int) int { return 401 }, - }) - c := newClient(srv, nil) - - _, err := c.ServiceToken(context.Background(), "converter:job.write") - require.Error(t, err) - assert.True(t, errors.Is(err, ErrServiceClientUnauthorized), - "401 應 mapping 到 ErrServiceClientUnauthorized, got %v", err) - assert.False(t, errors.Is(err, ErrMCTokenUnavailable), - "401 不應同時掛 ErrMCTokenUnavailable") - assert.Equal(t, int32(1), counter.Load(), "401 不應 retry") -} - -func TestServiceToken_Server403_NoRetry(t *testing.T) { - t.Parallel() - - srv, counter, _ := newTokenServer(t, tokenServerOpts{ - statusFn: func(int) int { return 403 }, - }) - c := newClient(srv, nil) - - _, err := c.ServiceToken(context.Background(), "converter:job.write") - require.Error(t, err) - assert.True(t, errors.Is(err, ErrServiceClientUnauthorized)) - assert.Equal(t, int32(1), counter.Load(), "403 不應 retry") -} - -func TestServiceToken_Server400_NoRetry(t *testing.T) { - t.Parallel() - - srv, counter, _ := newTokenServer(t, tokenServerOpts{ - statusFn: func(int) int { return 400 }, - }) - c := newClient(srv, nil) - - _, err := c.ServiceToken(context.Background(), "converter:job.write") - require.Error(t, err) - // §6:MC token endpoint 4xx (非 401/403) → idp_misconfigured / 500 - assert.True(t, errors.Is(err, ErrIDPMisconfigured), - "service_token 4xx 應 mapping 到 ErrIDPMisconfigured(§6), got %v", err) - assert.False(t, errors.Is(err, ErrServiceClientUnauthorized), - "400 不應掛 ErrServiceClientUnauthorized(限 401/403)") - assert.False(t, errors.Is(err, ErrMCTokenUnavailable), - "service_token 4xx 不應掛 ErrMCTokenUnavailable(§6 該 sentinel 限 delegated 5xx 用)") - assert.Equal(t, int32(1), counter.Load(), "400 不應 retry") -} - -func TestServiceToken_Server5xx_Retry(t *testing.T) { - t.Parallel() - - // 前兩次 500、第三次 200 - srv, counter, _ := newTokenServer(t, tokenServerOpts{ - statusFn: func(idx int) int { - if idx < 2 { - return 500 - } - return 200 - }, - }) - - // 把 retryBaseDelay 暫時縮短,避免 test 等太久(用環境變數無法 — 改用 dial-down opts) - // 這裡選擇接受真實 1s + 2s = 3s 的等待(test 內可接受) - c := newClient(srv, nil) - - tok, err := c.ServiceToken(context.Background(), "converter:job.write") - require.NoError(t, err) - assert.Equal(t, "tok-2", tok, "第三次成功的 token") - assert.Equal(t, int32(3), counter.Load(), "5xx 應 retry 兩次後第三次成功") -} - -func TestServiceToken_Server5xx_Exhausted(t *testing.T) { - t.Parallel() - - srv, counter, _ := newTokenServer(t, tokenServerOpts{ - statusFn: func(int) int { return 500 }, - }) - c := newClient(srv, nil) - - _, err := c.ServiceToken(context.Background(), "converter:job.write") - require.Error(t, err) - // §6:MC token endpoint 5xx / network 持續失敗 → idp_unavailable / 503 - assert.True(t, errors.Is(err, ErrIDPUnavailable), - "service_token 連續 5xx 應 mapping 到 ErrIDPUnavailable(§6), got %v", err) - assert.False(t, errors.Is(err, ErrMCTokenUnavailable), - "service_token 5xx 不應掛 ErrMCTokenUnavailable(§6 該 sentinel 限 delegated 5xx 用)") - // 第一次 + 2 次 retry = 3 次 attempt - assert.Equal(t, int32(3), counter.Load(), "5xx 應 attempt 3 次") -} - -func TestServiceToken_ContextCancel_NoRetry(t *testing.T) { - t.Parallel() - - // server 回應有 500ms delay,給我們時間 cancel - srv, counter, _ := newTokenServer(t, tokenServerOpts{ - delay: 500 * time.Millisecond, - }) - c := newClient(srv, nil) - - ctx, cancel := context.WithCancel(context.Background()) - // 50ms 後 cancel(在 server response 之前) - go func() { - time.Sleep(50 * time.Millisecond) - cancel() - }() - - _, err := c.ServiceToken(ctx, "converter:job.write") - require.Error(t, err) - // ctx cancel 在 service_token endpoint: - // - http.Client 端攔到 ctx cancel → 透傳 context.Canceled(不包 sentinel) - // - 透過 fmt.Errorf("%w") 包過 → ErrIDPUnavailable(§6 service_token network 失敗映射) - // 兩者擇一即為合法 - assert.True(t, - errors.Is(err, context.Canceled) || errors.Is(err, ErrIDPUnavailable), - "ctx cancel 應立即 return(context.Canceled 或 ErrIDPUnavailable wrap),got %v", err) - // counter 可能是 1(server 收到了但 client 在等回應時 cancel);不應該 retry - assert.LessOrEqual(t, counter.Load(), int32(1), - "ctx cancel 不應 retry,counter <= 1") -} - -func TestServiceToken_InvalidJSON_TreatedAsError(t *testing.T) { - t.Parallel() - - srv, _, _ := newTokenServer(t, tokenServerOpts{invalidJSON: true}) - c := newClient(srv, nil) - - _, err := c.ServiceToken(context.Background(), "converter:job.write") - require.Error(t, err) - // §6:service_token endpoint 回 200 但 body 不合法 — 視為 IDP 暫時不可用(503/idp_unavailable) - assert.True(t, errors.Is(err, ErrIDPUnavailable), - "service_token JSON parse error 應 mapping 到 ErrIDPUnavailable(§6), got %v", err) -} - -func TestServiceToken_EmptyTokenInResponse_TreatedAsError(t *testing.T) { - t.Parallel() - - srv, _, _ := newTokenServer(t, tokenServerOpts{emptyToken: true}) - c := newClient(srv, nil) - - _, err := c.ServiceToken(context.Background(), "converter:job.write") - require.Error(t, err) - // §6:service_token endpoint shape 不對 — 同 IdP 失常(503/idp_unavailable) - assert.True(t, errors.Is(err, ErrIDPUnavailable), - "空 access_token 應 mapping 到 ErrIDPUnavailable(§6), got %v", err) -} - -func TestServiceToken_FailureNotCached(t *testing.T) { - t.Parallel() - - // 第一次 500 (+2 retry 都 500),第四次(即第二次 ServiceToken 呼叫的第一個 attempt)成功 - var phase atomic.Int32 - srv, counter, _ := newTokenServer(t, tokenServerOpts{ - statusFn: func(idx int) int { - if phase.Load() == 0 { - return 500 - } - return 200 - }, - }) - c := newClient(srv, nil) - - _, err := c.ServiceToken(context.Background(), "converter:job.write") - require.Error(t, err, "第一次預期失敗") - assert.Equal(t, int32(3), counter.Load()) - - // 切換到 success phase - phase.Store(1) - tok, err := c.ServiceToken(context.Background(), "converter:job.write") - require.NoError(t, err, "第二次應成功(之前的失敗不應 cache)") - assert.NotEmpty(t, tok) - assert.Equal(t, int32(4), counter.Load(), "第二次 ServiceToken 應重新打 MC") -} - -// ========================================================================== -// IssueDelegatedDownload 系列 -// ========================================================================== - -func TestIssueDelegatedDownload_Success(t *testing.T) { - t.Parallel() - - srv, _, dCounter, _ := newDownloadServer(t, downloadServerOpts{}) - c := newClient(srv, nil) - - dl, err := c.IssueDelegatedDownload(context.Background(), IssueDownloadReq{ - TenantID: "tenant-x", - UserID: "user-y", - ObjectKey: "promoted/job-1.nef", - ExpiresInSeconds: 600, - }) - require.NoError(t, err) - require.NotNil(t, dl) - assert.Contains(t, dl.Token, "opaque-tok-") - assert.True(t, dl.ExpiresAt.After(time.Now()), "expires_at 應在未來") - assert.Equal(t, int32(1), dCounter.Load()) -} - -// TestIssueDelegatedDownload_RequestBodyShape 驗 POST /file-access/download-tokens 的 body shape -// 對齊 conversion.md §1 + §2.4。 -func TestIssueDelegatedDownload_RequestBodyShape(t *testing.T) { - t.Parallel() - - // 自訂 server 收 body 後驗 shape - var lastBody string - mux := http.NewServeMux() - mux.HandleFunc("/oauth/token", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"access_token":"svc-tok","token_type":"Bearer","expires_in":3600}`)) - }) - mux.HandleFunc("/file-access/download-tokens", func(w http.ResponseWriter, r *http.Request) { - body, _ := io.ReadAll(r.Body) - lastBody = string(body) - assert.Equal(t, "application/json", r.Header.Get("Content-Type")) - assert.True(t, strings.HasPrefix(r.Header.Get("Authorization"), "Bearer svc-tok"), - "應帶 service token 為 Bearer auth") - w.Header().Set("Content-Type", "application/json") - _, _ = fmt.Fprintf(w, `{"token":"opaque","expires_at":"%s"}`, - time.Now().UTC().Add(5*time.Minute).Format(time.RFC3339)) - }) - srv := httptest.NewServer(mux) - defer srv.Close() - - c := NewMCTokenClient(MCTokenClientOpts{ - Issuer: srv.URL, - ClientID: "id", - ClientSecret: "sec", - HTTPClient: srv.Client(), - Logger: silentLogger(), - }) - - _, err := c.IssueDelegatedDownload(context.Background(), IssueDownloadReq{ - TenantID: "tenant-z", - UserID: "user-a", - ObjectKey: "a/b/c.nef", - ExpiresInSeconds: 300, - }) - require.NoError(t, err) - - // 驗 body shape — JSON 含必要欄位 - assert.Contains(t, lastBody, `"tenant_id":"tenant-z"`) - assert.Contains(t, lastBody, `"user_id":"user-a"`) - assert.Contains(t, lastBody, `"object_key":"a/b/c.nef"`) - assert.Contains(t, lastBody, `"method":"GET"`) - assert.Contains(t, lastBody, `"expires_in_seconds":300`) -} - -func TestIssueDelegatedDownload_DefaultTTL(t *testing.T) { - t.Parallel() - - var lastBody string - mux := http.NewServeMux() - mux.HandleFunc("/oauth/token", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"access_token":"svc-tok","token_type":"Bearer","expires_in":3600}`)) - }) - mux.HandleFunc("/file-access/download-tokens", func(w http.ResponseWriter, r *http.Request) { - body, _ := io.ReadAll(r.Body) - lastBody = string(body) - w.Header().Set("Content-Type", "application/json") - _, _ = fmt.Fprintf(w, `{"token":"opaque","expires_at":"%s"}`, - time.Now().UTC().Add(5*time.Minute).Format(time.RFC3339)) - }) - srv := httptest.NewServer(mux) - defer srv.Close() - - c := NewMCTokenClient(MCTokenClientOpts{ - Issuer: srv.URL, - ClientID: "id", - ClientSecret: "sec", - HTTPClient: srv.Client(), - Logger: silentLogger(), - }) - - // 不傳 ExpiresInSeconds(=0),應自動套 default 300 - _, err := c.IssueDelegatedDownload(context.Background(), IssueDownloadReq{ - TenantID: "t", - UserID: "u", - ObjectKey: "k", - }) - require.NoError(t, err) - assert.Contains(t, lastBody, `"expires_in_seconds":300`, - "ExpiresInSeconds 為 0 時應 fallback 到 default 300") -} - -func TestIssueDelegatedDownload_Server4xx_PropagateError(t *testing.T) { - t.Parallel() - - srv, _, dCounter, _ := newDownloadServer(t, downloadServerOpts{ - downloadStatusFn: func(int) int { return 400 }, - }) - c := newClient(srv, nil) - - _, err := c.IssueDelegatedDownload(context.Background(), IssueDownloadReq{ - TenantID: "t", - UserID: "u", - ObjectKey: "k", - }) - require.Error(t, err) - // §6:MC delegated download 4xx → download_token_failed / 502 - assert.True(t, errors.Is(err, ErrDownloadTokenFailed), - "delegated 4xx 應 mapping 到 ErrDownloadTokenFailed(§6), got %v", err) - assert.False(t, errors.Is(err, ErrMCTokenUnavailable), - "delegated 4xx 不應掛 ErrMCTokenUnavailable(§6 該 sentinel 限 5xx 用)") - assert.Equal(t, int32(1), dCounter.Load(), "4xx 不應 retry") -} - -func TestIssueDelegatedDownload_Server5xx_RetryThenFail(t *testing.T) { - t.Parallel() - - srv, _, dCounter, _ := newDownloadServer(t, downloadServerOpts{ - downloadStatusFn: func(int) int { return 500 }, - }) - c := newClient(srv, nil) - - _, err := c.IssueDelegatedDownload(context.Background(), IssueDownloadReq{ - TenantID: "t", - UserID: "u", - ObjectKey: "k", - }) - require.Error(t, err) - // §6:MC delegated download 5xx / network 持續失敗 → mc_token_unavailable / 502(不變) - assert.True(t, errors.Is(err, ErrMCTokenUnavailable), - "delegated 5xx 應 mapping 到 ErrMCTokenUnavailable(§6), got %v", err) - assert.False(t, errors.Is(err, ErrDownloadTokenFailed), - "delegated 5xx 不應掛 ErrDownloadTokenFailed(§6 該 sentinel 限 4xx 用)") - assert.Equal(t, int32(3), dCounter.Load(), "5xx 應 attempt 3 次") -} - -func TestIssueDelegatedDownload_Server401_PropagateUnauthorized(t *testing.T) { - t.Parallel() - - srv, _, dCounter, _ := newDownloadServer(t, downloadServerOpts{ - downloadStatusFn: func(int) int { return 401 }, - }) - c := newClient(srv, nil) - - _, err := c.IssueDelegatedDownload(context.Background(), IssueDownloadReq{ - TenantID: "t", - UserID: "u", - ObjectKey: "k", - }) - require.Error(t, err) - assert.True(t, errors.Is(err, ErrServiceClientUnauthorized), - "download 401 應 mapping 到 ErrServiceClientUnauthorized, got %v", err) - assert.Equal(t, int32(1), dCounter.Load(), "401 不應 retry") -} - -func TestIssueDelegatedDownload_ServiceTokenFailure_Propagated(t *testing.T) { - t.Parallel() - - srv, tCounter, dCounter, _ := newDownloadServer(t, downloadServerOpts{ - tokenStatusFn: func(int) int { return 500 }, // service token 完全取不到 - }) - c := newClient(srv, nil) - - _, err := c.IssueDelegatedDownload(context.Background(), IssueDownloadReq{ - TenantID: "t", - UserID: "u", - ObjectKey: "k", - }) - require.Error(t, err) - // §6:失敗源頭是 service_token endpoint 5xx → ErrIDPUnavailable - // IssueDelegatedDownload 用 fmt.Errorf("%w") 透傳,不會升級成 ErrMCTokenUnavailable, - // 確保前端 i18n 能正確顯示「認證服務暫時無法使用」而非「無法取得下載授權」。 - assert.True(t, errors.Is(err, ErrIDPUnavailable), - "service token 5xx 透傳 → ErrIDPUnavailable(§6), got %v", err) - assert.False(t, errors.Is(err, ErrMCTokenUnavailable), - "不應被升級成 ErrMCTokenUnavailable,否則 i18n 訊息會錯") - assert.Equal(t, int32(3), tCounter.Load(), "service token 5xx 應 attempt 3 次") - assert.Equal(t, int32(0), dCounter.Load(), "service token 失敗時不應打 download endpoint") -} - -// TestIssueDelegatedDownload_ServiceTokenAuthFailure_Propagated — service_token 401/403 透傳。 -// -// §6 mapping:401/403 用 ErrServiceClientUnauthorized(對外仍 mask 成 idp_misconfigured/500)。 -// 確認 IssueDelegatedDownload 用 fmt.Errorf("%w") 透傳後,errors.Is 仍能命中。 -func TestIssueDelegatedDownload_ServiceTokenAuthFailure_Propagated(t *testing.T) { - t.Parallel() - - srv, tCounter, dCounter, _ := newDownloadServer(t, downloadServerOpts{ - tokenStatusFn: func(int) int { return 401 }, - }) - c := newClient(srv, nil) - - _, err := c.IssueDelegatedDownload(context.Background(), IssueDownloadReq{ - TenantID: "t", - UserID: "u", - ObjectKey: "k", - }) - require.Error(t, err) - assert.True(t, errors.Is(err, ErrServiceClientUnauthorized), - "service token 401 透傳 → ErrServiceClientUnauthorized(§5.2), got %v", err) - assert.Equal(t, int32(1), tCounter.Load(), "401 不應 retry") - assert.Equal(t, int32(0), dCounter.Load(), "service token 401 時不應打 download endpoint") -} - -// TestIssueDelegatedDownload_ServiceToken4xxNonAuth_Propagated — service_token 400 透傳成 IDP 設定錯誤。 -// -// §6 mapping:service_token 4xx (非 401/403) → ErrIDPMisconfigured(500/idp_misconfigured)。 -// 這是「IDP grant 設定錯」而非「下載授權失敗」— 區分 i18n 訊息。 -func TestIssueDelegatedDownload_ServiceToken4xxNonAuth_Propagated(t *testing.T) { - t.Parallel() - - srv, tCounter, dCounter, _ := newDownloadServer(t, downloadServerOpts{ - tokenStatusFn: func(int) int { return 400 }, - }) - c := newClient(srv, nil) - - _, err := c.IssueDelegatedDownload(context.Background(), IssueDownloadReq{ - TenantID: "t", - UserID: "u", - ObjectKey: "k", - }) - require.Error(t, err) - assert.True(t, errors.Is(err, ErrIDPMisconfigured), - "service token 400 透傳 → ErrIDPMisconfigured(§6), got %v", err) - assert.False(t, errors.Is(err, ErrDownloadTokenFailed), - "不應掛 ErrDownloadTokenFailed(那是 delegated endpoint 4xx 的錯誤碼)") - assert.Equal(t, int32(1), tCounter.Load(), "400 不應 retry") - assert.Equal(t, int32(0), dCounter.Load(), "service token 4xx 時不應打 download endpoint") -} - -func TestIssueDelegatedDownload_RequiredFieldsValidation(t *testing.T) { - t.Parallel() - - c := NewMCTokenClient(MCTokenClientOpts{ - Issuer: "http://localhost:9999", // 不會真的打到 - ClientID: "id", - ClientSecret: "sec", - Logger: silentLogger(), - }) - - cases := []struct { - name string - in IssueDownloadReq - }{ - {"empty_tenant", IssueDownloadReq{UserID: "u", ObjectKey: "k"}}, - {"empty_user", IssueDownloadReq{TenantID: "t", ObjectKey: "k"}}, - {"empty_object_key", IssueDownloadReq{TenantID: "t", UserID: "u"}}, - } - for _, tc := range cases { - tc := tc - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - _, err := c.IssueDelegatedDownload(context.Background(), tc.in) - require.Error(t, err, "缺必填欄位應 fail-fast") - }) - } -} - -// ========================================================================== -// Constructor / 邊界 -// ========================================================================== - -func TestNewMCTokenClient_NilOptsDefaults(t *testing.T) { - t.Parallel() - - c := NewMCTokenClient(MCTokenClientOpts{ - Issuer: "http://example.com/", - ClientID: "id", - ClientSecret: "sec", - }) - require.NotNil(t, c) - - // 透過 type assertion 檢查預設值有套用(這是內部檢查; - // 平常 caller 不該 assert 內部 struct,但 test 可以) - impl, ok := c.(*mcTokenClient) - require.True(t, ok) - assert.NotNil(t, impl.http, "HTTPClient nil 時應有預設") - assert.NotNil(t, impl.now, "Now nil 時應有預設") - assert.NotNil(t, impl.logger, "Logger nil 時應有預設") - assert.Equal(t, "http://example.com", impl.issuer, "issuer 結尾斜線應被移除") -} - -func TestServiceToken_EmptyScope_ReturnsError(t *testing.T) { - t.Parallel() - - c := NewMCTokenClient(MCTokenClientOpts{ - Issuer: "http://localhost:9999", - ClientID: "id", - ClientSecret: "sec", - Logger: silentLogger(), - }) - - _, err := c.ServiceToken(context.Background(), "") - require.Error(t, err) - assert.Contains(t, err.Error(), "scope is required") -} diff --git a/visionA-backend/internal/conversion/testing_helpers_test.go b/visionA-backend/internal/conversion/testing_helpers_test.go new file mode 100644 index 0000000..c298641 --- /dev/null +++ b/visionA-backend/internal/conversion/testing_helpers_test.go @@ -0,0 +1,16 @@ +// 測試共用 helper(converter_client_test / faa_client_test / 其他 conversion package +// 內 _test.go 共用),原本住在 mc_token_client_test.go;Phase 0.8b T3 砍 mc_token_client +// 整個檔後搬到此獨立檔,避免 silentLogger 隨 mc_token_client_test.go 一起被砍。 +// +// Phase 0.8b conversion (見 ADR-015 §6 / .autoflow/04-architecture/conversion.md §2.4) +package conversion + +import ( + "io" + "log/slog" +) + +// silentLogger 是 test 用的 no-op logger,避免 test 輸出污染。 +func silentLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} diff --git a/visionA-backend/internal/conversion/util.go b/visionA-backend/internal/conversion/util.go new file mode 100644 index 0000000..7e4e1c7 --- /dev/null +++ b/visionA-backend/internal/conversion/util.go @@ -0,0 +1,17 @@ +// 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 拼接使用)。 +// +// Phase 0.8b conversion (見 ADR-015 §6 / .autoflow/04-architecture/conversion.md §2) +package conversion + +// truncate 把字串截到 max 長度(避免 log 太長)。 +func truncate(s string, max int) string { + if len(s) <= max { + return s + } + return s[:max] + "...(truncated)" +}