feat(visionA-backend): Phase 0.8b 步驟 2 — visionA → converter / FAA 改 API key 認證
對齊 ADR-015:visionA backend 從 OAuth client_credentials 改 pre-shared API key 服務間認證。Phase 0.8 stage e2e 撞 4 個 blocker(MC scope 沒註冊 / converter image 舊版 / converter 缺 env / FAA 不確定)後,使用者拍板 1:1 internal trust 用 OAuth 過度設計,改 API key。
實作(5 個增量 task,T1-T5 全綠 + Reviewer 5 輪 + final cross-task review):
T1 config + env:
- ConversionConfig 新增 ConverterAPIKey / FAAAPIKey 欄位
- Enabled() 改判定 4 欄位齊全(含兩個 API key)
- .env*.example 移除 OIDC service client / OIDC tenant / FAA delegated TTL env、新增 API key env
- TenantID / DelegatedTTLSeconds T1 暫留、T5 整批清
T2 client 改造:
- converter_client / faa_client 移除 MCTokenClient 依賴
- 直接讀 cfg.Conversion.{Converter,FAA}APIKey、set Authorization: Bearer <key>
- NewConverterClient / NewFAAClient APIKey 為空時 panic(fail-fast,對齊 ADR-015 §3.5.3 #1)
- 新增 ErrConverterAuthFailed / ErrFAAAuthFailed sentinel
- 對外 mask 成 converter_unavailable / faa_unavailable(不洩漏 401 細節,對齊 ADR-015 §3.5.3 #3)
T3 砍 mc_token_client:
- mc_token_client.go (624 行) + mc_token_client_test.go (864 行) 整檔砍
- 砍 5 個僅 mc_token_client 用的 sentinel(ErrServiceClientUnauthorized / ErrMCTokenUnavailable / ErrIDPMisconfigured / ErrIDPUnavailable / ErrDownloadTokenFailed)
- helper(truncate / silentLogger)搬到 util.go / testing_helpers_test.go
T4 flow + handler stream proxy:
- Service interface DownloadRedirectURL → DownloadStream(ctx) (io.ReadCloser, *DownloadMetadata, error)
- flow.DownloadStream 用 faa.GetFile 直接 stream NEF binary(取代 MC delegated token + 302)
- handler conversionDownloadHandler 改 io.Copy + Content-Type/Disposition/Cache-Control header
- 新增 sanitizeDownloadFilename helper 防 HTTP header injection
- 跨 package handler test (conversion_test.go) 改測 ErrFAAUnavailable + 補 *_AuthFailed 對稱 test
T5 wire 切換 + cleanup:
- main.go 砍 mcTokenClient wire、改 APIKey 注入、startup log 用 *_api_key_set boolean(不印 key)
- ConverterClient/FAAClient struct Tokens 欄位移除
- mc_token_stub.go (T4 過渡期) 整檔砍
- ConversionConfig TenantID / DelegatedTTLSeconds 欄位移除
- e2e_test.go TestConversionE2E_Download302Redirect 改寫為 TestConversionE2E_DownloadStream
- 補 MaxDownloadStreamBytes = 1 GiB size cap(io.CopyN,T4 reviewer Minor M-1)
- escapeObjectKeyPath Phase 1+ 預留 godoc + //nolint:unused(T4 reviewer Minor M-2)
- conversion.md §4.1 line 502 filename 來源描述歧義修訂(T4 reviewer Minor M-3,由 architect 處理)
- conversion_e2e_test.go 檔頭 docstring 更新(final reviewer Minor #1)
驗證:
- go build ./... exit 0
- go test -race -count=3 ./... 17 packages 全綠
- ADR-015 §6 砍除清單 100% cover(reviewer 獨立 grep 確認 MC chain / TenantID / DelegatedTTLSeconds 全清)
- ADR-015 §3.5.3 部署檢查清單 visionA 範圍 4/4 達成(fail-fast / mask / 不印 token / placeholder env)
不動:
- OIDCConfig.ServiceClientID/Secret 欄位保留(使用者拍板 backward compat)
- user login OIDC 完全不動
下一步:
- 步驟 4 — converter scheduler middleware 改 API key(jimchen 跨 repo,ADR-015 §3.5.1 Go snippet)
- 步驟 5 — FAA middleware 改 API key(warrenchen 跨 repo,ADR-015 §3.5.2 C# snippet)
- 步驟 6 — stage redeploy + e2e 完整測試
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b9c228df4f
commit
86b7175649
@ -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 衝突時可改
|
||||
# ============================================================
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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/<user>/<job>.nef` 對 user 不友善),而是 visionA backend 在 service 層
|
||||
// 由 `defaultDownloadFilename(cj)` 從 conversion job metadata 構造,規則:
|
||||
// `<source_filename_stem>_<target_chip_lower>.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 用 `<a href>` 觸發時,若失敗 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 構造(規則 `<source_filename_stem>_<target_chip_lower>.nef`),對齊 wireframe success card 顯示範例」、並補充「FAA 端的 object_key 是 `models/<user>/<job>.nef` 對 user 不友善」的對比說明。純文字釐清、無實作行為變更。 |
|
||||
|
||||
@ -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:<see stage .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、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
|
||||
|
||||
@ -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 <FAA_API_KEY> 拉 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 <FAA_API_KEY>)。
|
||||
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 是 <faa-base-url>/files/<key>?access_token=<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 <fixture FAA API key>
|
||||
// (驗 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:<source_filename_stem>_<chip_lower>.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 塞一個 <a href="<url>">Found</a>
|
||||
// 讓不支援 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 <API key> ===
|
||||
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)
|
||||
}
|
||||
|
||||
@ -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(可選) =====
|
||||
|
||||
@ -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: <FAA URL with access_token>
|
||||
// - 失敗:不 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
|
||||
// ==========================================================================
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
|
||||
@ -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 <key>` 形式帶上。
|
||||
// 雙方獨立產生(`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 <key>` 形式帶上。
|
||||
// 與 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 白名單。
|
||||
|
||||
@ -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),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
@ -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://<faa>/files/<key>?access_token=<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。
|
||||
// 規則:`<source_filename_stem>_<target_chip_lower>.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"`
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 <VISIONA_CONVERTER_API_KEY>` — 不查 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)
|
||||
|
||||
@ -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 <fakeAPIKey>`
|
||||
// - 用 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 <fakeConverterAPIKey>`(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{}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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},
|
||||
|
||||
@ -4,28 +4,31 @@
|
||||
// 其他 endpoint(PUT / DELETE / HEAD / metadata)目前 visionA 不需要,未來再補。
|
||||
//
|
||||
// 設計要點:
|
||||
// - 走 service token(scope=files:download.read);token 由注入的 MCTokenClient 提供
|
||||
// - **Phase 0.8b 認證**:直接帶 `Authorization: Bearer <VISIONA_FAA_API_KEY>`(pre-shared
|
||||
// API key),不再透過 MC OAuth client_credentials grant、不再依賴 MCTokenClient.ServiceToken()。
|
||||
// 詳見 ADR-015 §3 + conversion.md §3。
|
||||
// - **回 streaming body**(io.ReadCloser)— 不 io.ReadAll,避免 500MB NEF 全進 RAM
|
||||
// - **Phase A retry**:dial → 拿到 response header 之間的 5xx / network / timeout 失敗
|
||||
// 依 §9.1 指數退避重試 max 2 次(1s, 2s)。一旦拿到 200 response(進 Phase B:
|
||||
// streaming body 給 caller),這層責任就結束 — body 中斷由 caller 處理(不可 replay)。
|
||||
// 詳見下方 GetFile doc comment 的「Phase A vs Phase B retry」段。
|
||||
// - 4xx → 對應 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:
|
||||
|
||||
@ -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(不打網路)")
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
|
||||
@ -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://<faa>/files/<key>?access_token=<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/<key>?access_token=<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 的命名規則(`<stem>_<chip>.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。
|
||||
//
|
||||
// 規則:`<source_filename_stem>_<target_chip_lower>.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=<visionA-signed-hmac>」回 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 {
|
||||
|
||||
@ -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/<key>?access_token=<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 = <stem>_<chip>.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/<user>/<job>.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)")
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
|
||||
@ -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://<faa>/files/<key>?access_token=<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": "<opaque>", "expires_at": "<ISO8601>"}
|
||||
// 若 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)"
|
||||
}
|
||||
@ -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(`<not json>`))
|
||||
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")
|
||||
}
|
||||
16
visionA-backend/internal/conversion/testing_helpers_test.go
Normal file
16
visionA-backend/internal/conversion/testing_helpers_test.go
Normal file
@ -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))
|
||||
}
|
||||
17
visionA-backend/internal/conversion/util.go
Normal file
17
visionA-backend/internal/conversion/util.go
Normal file
@ -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)"
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user