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:
jim800121chen 2026-05-15 09:45:45 +08:00
parent b9c228df4f
commit 86b7175649
25 changed files with 1514 additions and 2402 deletions

View File

@ -30,6 +30,11 @@ MC_ADMIN_PASSWORD=Admin12345!
VISIONA_OIDC_CLIENT_ID=CHANGE_ME VISIONA_OIDC_CLIENT_ID=CHANGE_ME
VISIONA_OIDC_CLIENT_SECRET=CHANGE_ME VISIONA_OIDC_CLIENT_SECRET=CHANGE_ME
# Phase 0.8b 移除VISIONA_OIDC_SERVICE_CLIENT_ID / _SECRET
# 服務間認證visionA → converter / FAA改 pre-shared API keyADR-015
# 不再走 OAuth client_credentials grant所以 dev 也不需要 service client。
# 取代設定見下方 Phase 0.8 / 0.8b conversion 區塊。
# auth mode 切換static雛形預設/ oidc接 MC # auth mode 切換static雛形預設/ oidc接 MC
VISIONA_AUTH_TYPE=static VISIONA_AUTH_TYPE=static
@ -71,6 +76,20 @@ VISIONA_STORAGE_SIGNING_SECRET=dev-signing-secret-change-me-32-bytes
VISIONA_PAIRING_TOKEN= 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 keyopenssl 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 衝突時可改 # 進階port 衝突時可改
# ============================================================ # ============================================================

View File

@ -26,11 +26,11 @@ VISIONA_OIDC_CLIENT_ID=b8093fea1a504a5d8f0e04bee9f78f2e
# 留空 → backend 走 PKCE-only modeA1 後支援;見 ADR-013 # 留空 → backend 走 PKCE-only modeA1 後支援;見 ADR-013
VISIONA_OIDC_CLIENT_SECRET= VISIONA_OIDC_CLIENT_SECRET=
# Service-to-service clientclient_credentials grant # Phase 0.8b 移除VISIONA_OIDC_SERVICE_CLIENT_ID / _SECRET
# Phase 0.7 預留,不啟用;填入也不會被 main.go wire見 config.go ServiceClientID 註解) # 服務間認證從 OAuth client_credentials 改為 pre-shared API key見 ADR-015
# ⚠️ 兩個值都禁止寫死進 git tracked 檔;只在 stage host 的 .env.stage 才填入真值 # 兩個 service client env 不再讀取OIDCConfig.ServiceClientID/Secret struct 欄位
VISIONA_OIDC_SERVICE_CLIENT_ID= # 為了 backward compat 暫保留、但 conversion 模組不再依賴)。
VISIONA_OIDC_SERVICE_CLIENT_SECRET= # 已洩漏的 stage service client secret 自此作廢,無 rotate 需求。
# Callback URL — 必須與 MC 端 client 設定的 redirect_uri 完全一致 # Callback URL — 必須與 MC 端 client 設定的 redirect_uri 完全一致
VISIONA_OIDC_REDIRECT_URL=https://stage-9527.innovedus.com:9527/api/auth/callback VISIONA_OIDC_REDIRECT_URL=https://stage-9527.innovedus.com:9527/api/auth/callback
@ -115,12 +115,19 @@ VISIONA_SEED_DEMO_DATA=false
# 留註解作為 audit trailstage 部署不需設定 VISIONA_STATIC_USER_ID。 # 留註解作為 audit trailstage 部署不需設定 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 都非空時, # Phase 0.8b 變更:服務間認證從 OAuth client_credentials 改為 pre-shared API key。
# main.go 才會 wire conversion.Service任一缺 → 5 個 /api/conversion/* endpoint 回 501。 #
# 啟用判定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 URLstage 公司內網) # kneron_model_converter task-scheduler base URLstage 公司內網)
VISIONA_CONVERTER_BASE_URL=http://192.168.0.130:9501 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 # File Access Agent base URL
VISIONA_FAA_BASE_URL=http://192.168.0.130:5081 VISIONA_FAA_BASE_URL=http://192.168.0.130:5081
# 服務對服務 clientclient_credentials grantscope: converter:job.write/read + # Pre-shared API key — visionA → converter 服務間認證Phase 0.8b 新增ADR-015 §3
# files:download.read/delegate— stage 已配,不 rotate測試環境 # **必須換掉**openssl rand -hex 32 產生 64 字元 hex與 converter 端 CONVERTER_API_KEY 對齊
VISIONA_OIDC_SERVICE_CLIENT_ID= # 雙方獨立持有、不共用、嚴格分環境dev / stage / prod 各自獨立 key
VISIONA_OIDC_SERVICE_CLIENT_SECRET= # log 永遠不印此值全文;部署時用 AWS Secrets Manager / Vault 注入
VISIONA_CONVERTER_API_KEY=CHANGE_ME_OPENSSL_RAND_HEX_32
# Tenant ID給 MC delegated download token request 用) # Pre-shared API key — visionA → FAA 服務間認證Phase 0.8b 新增ADR-015 §3
VISIONA_CONVERSION_TENANT_ID=visionA # **必須換掉**openssl rand -hex 32與 FAA 端 FAA_API_KEY 對齊warrenchen 配置)
# 與 ConverterAPIKey **不共用**(每條 trust boundary 各自獨立,避免一處洩漏連坐)
VISIONA_FAA_API_KEY=CHANGE_ME_OPENSSL_RAND_HEX_32

View File

@ -498,7 +498,12 @@ func conversionDownloadHandler(deps Deps) gin.HandlerFunc {
if meta.SizeBytes > 0 { if meta.SizeBytes > 0 {
c.Header("Content-Length", strconv.FormatInt(meta.SizeBytes, 10)) c.Header("Content-Length", strconv.FormatInt(meta.SizeBytes, 10))
} }
// 鼓勵 browser 觸發 save dialogfilename 來自 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("Content-Disposition", `attachment; filename="`+sanitizeFilename(meta.Filename)+`"`)
c.Header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0") c.Header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
c.Status(http.StatusOK) c.Status(http.StatusOK)
@ -1009,3 +1014,4 @@ frontend 用 `<a href>` 觸發時,若失敗 browser 會把錯誤頁顯示在
| 2026-04-30 | 0.2 | Download flow 改為 server-side HTTP 302 redirectendpoint 從 `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.2 | Download flow 改為 server-side HTTP 302 redirectendpoint 從 `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」議題 #2A4 方案§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-04-30 | 0.3 | Phase 0.8 三方交叉審閱回饋整合§2.6.1 補「visionA-backend 重啟後 lazy rebuild ownership」議題 #2A4 方案§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 proxyService 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-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 proxyService 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 不友善」的對比說明。純文字釐清、無實作行為變更。 |

View File

@ -76,12 +76,11 @@ VISIONA_OIDC_REDIRECT_URL=http://localhost:3721/api/auth/callback
# prod: https://app.visiona.cloud # prod: https://app.visiona.cloud
VISIONA_FRONTEND_URL=http://localhost:3000 VISIONA_FRONTEND_URL=http://localhost:3000
# Service clientclient_credentials grant— A1 預留欄位,**目前不啟用**。 # Phase 0.8b 移除VISIONA_OIDC_SERVICE_CLIENT_ID / _SECRET
# 將來 visionA-backend 需以服務身份呼叫 MC API 時(例如查詢使用者組織、推送通知) # 服務間認證從 OAuth client_credentials 改為 pre-shared API key見 ADR-015、conversion.md §3
# 才會接這條路。留空代表「不啟用」main.go 不會 wire。 # 兩個 service client env 不再讀取OIDCConfig.ServiceClientID/Secret struct 欄位
# 對應 Stage 的 service client<see stage .env.stage> # 為了 backward compat 暫保留、但 conversion 模組不再依賴)。
VISIONA_OIDC_SERVICE_CLIENT_ID= # 取代設定見下方 Phase 0.8 / 0.8b 區塊的 VISIONA_CONVERTER_API_KEY / VISIONA_FAA_API_KEY。
VISIONA_OIDC_SERVICE_CLIENT_SECRET=
# Cookie HMAC 簽章 secret 至少 32 byte 隨機字串prod 用 openssl rand -hex 32 # Cookie HMAC 簽章 secret 至少 32 byte 隨機字串prod 用 openssl rand -hex 32
VISIONA_SESSION_SECRET=CHANGE_ME_TO_RANDOM_64_BYTES_in_production 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 都非空時, # Phase 0.8b 變更:服務間認證從 OAuth client_credentials 改為 pre-shared API key。
# main.go 才會 wire conversion.Service其中之一留空 → 5 個 /api/conversion/* endpoint 回 501。
# #
# 啟用時 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 / _SECRETOAuth client_credentials 機制取消)
# - VISIONA_OIDC_TENANT_ID取消 tenant 概念converter 端的 user_id 仍由 visionA 灌入)
# - VISIONA_FAA_DELEGATED_TTL_SECONDSdelegated download token 機制取消,改 server-side stream proxy
# kneron_model_converter task-scheduler base URL # kneron_model_converter task-scheduler base URL
# dev/stagehttp://192.168.0.130:9501 # dev/stagehttp://192.168.0.130:9501
@ -177,13 +181,16 @@ VISIONA_CONVERTER_BASE_URL=
# prodhttps://faa.innovedus.com # prodhttps://faa.innovedus.com
VISIONA_FAA_BASE_URL= VISIONA_FAA_BASE_URL=
# visionA 在 Member Center 的 tenant id單一 tenant # Pre-shared API key — visionA → converter 服務間認證Phase 0.8b 新增ADR-015 §3
# 跟 MC 換 delegated download token 時當 tenant_id 欄位用 # 產生openssl rand -hex 3264 字元 hex
VISIONA_OIDC_TENANT_ID= # 與 converter 端 CONVERTER_API_KEY env 對齊(雙方獨立持有,嚴格分環境 dev / stage / prod
# ⚠️ 不可 commitprod 用 Secrets Manager / Vaultlog 永遠不印此值全文
VISIONA_CONVERTER_API_KEY=
# Delegated download token TTL— FAA 直連下載用 # Pre-shared API key — visionA → FAA 服務間認證Phase 0.8b 新增ADR-015 §3
# 預設 3005 分鐘),可調整範圍 60-900 # 產生方式同上;與 FAA 端 FAA_API_KEY env 對齊warrenchen 配置)
VISIONA_FAA_DELEGATED_TTL_SECONDS=300 # 與 ConverterAPIKey **不共用**(每條 trust boundary 各自獨立,避免一處洩漏連坐)
VISIONA_FAA_API_KEY=
# 上傳模型檔大小上限MB— 與 converter 端 limit 對齊 # 上傳模型檔大小上限MB— 與 converter 端 limit 對齊
VISIONA_CONVERTER_MAX_MODEL_SIZE_MB=500 VISIONA_CONVERTER_MAX_MODEL_SIZE_MB=500

View File

@ -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 完整跑通 // 1. Streaming proxy 完整跑通
// —— 驗 InitJob 真的 streaming不 buffer 整個 multipart body // —— 驗 InitJob 真的 streaming不 buffer 整個 multipart body
@ -10,9 +11,12 @@
// —— 模擬 visionA backend 剛啟動 ownership 全空 + converter 端 user X 有 in_progress job // —— 模擬 visionA backend 剛啟動 ownership 全空 + converter 端 user X 有 in_progress job
// 驗 user 對 GET /active 觸發 lazy rebuild且後續 GET /active 走 cache 不再打 ListInProgressJobs。 // 驗 user 對 GET /active 觸發 lazy rebuild且後續 GET /active 走 cache 不再打 ListInProgressJobs。
// //
// 3. Download 302 redirect // 3. Download server-side stream proxyPhase 0.8b 改造,原 302 redirect 已廢)
// —— 驗 server-side 302 + Cache-Control: no-store + Location 帶 token // —— 驗 visionA backend 用 Bearer <FAA_API_KEY> 拉 mock FAA、stream NEF binary 回 browser
// 驗 response body 不含 token驗 redirect URL 指向 mock FAA。 // 驗 response 200 + Content-Type: application/octet-stream + Content-Disposition: attachment +
// Cache-Control: no-store + body bytes 與 mock FAA 寫的 NEF binary byte-perfect 一致;
// 驗結構性無 302 / Location headerAPI key 模式不再走 delegated download
// 對齊 ADR-015 §7 + conversion.md §4.1 + api-conversion.md §4。
// //
// 4. Active job 409 衝突 // 4. Active job 409 衝突
// —— 同 user 第一個 init 成功 → 第二個 init 撞 409 + body 帶 active_job 詳情。 // —— 同 user 第一個 init 成功 → 第二個 init 撞 409 + body 帶 active_job 詳情。
@ -21,10 +25,12 @@
// //
// 既有 setupFixtureintegration_test.go是 B4/B5 的雛形(不含 conversion service // 既有 setupFixtureintegration_test.go是 B4/B5 的雛形(不含 conversion service
// T7 main.go 在 wire 時才 build conversion service。本檔保持 T1-T7 既有 code 不動, // T7 main.go 在 wire 時才 build conversion service。本檔保持 T1-T7 既有 code 不動,
// 自己組一個 conversion 專用 fixturefakeOIDC + apiServer + 3 個 mock servers // 自己組一個 conversion 專用 fixturefakeOIDC + apiServer + 2 個 mock servers
// converter / MC service token + delegated / FAA完整模擬端到端。 // converter / FAA完整模擬端到端。Phase 0.8b 取消 MC service token + delegated mock
// API key 模式不依賴 MC OAuthfixture 從 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 package main
import ( import (
@ -43,6 +49,7 @@ import (
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
"os" "os"
"strconv"
"strings" "strings"
"sync" "sync"
"sync/atomic" "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 T5mockMC 已整段移除
// ========================================================================== // ==========================================================================
//
type mockMC struct { // 原 mockMC 提供 OAuth `client_credentials` service token 與 MC delegated download token
srv *httptest.Server // Phase 0.8b 改 pre-shared API key 後ADR-015 §3 / §6 / §7visionA 完全不再呼叫 MC
// e2e fixture 不需要 mock MC server。
serviceTokenCount atomic.Int32 //
delegatedTokenCount atomic.Int32 // 若未來 Phase 1+ 採 ADR-015 §7 選項 BvisionA 自簽 HMAC token + 302 redirect
// 也只需 visionA 端有 HMAC_KEY不需要 mock MC 端。
// 紀錄上一次發出的 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
}
// handleServiceTokenclient_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),
})
}
// ========================================================================== // ==========================================================================
// 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 { type mockFAA struct {
srv *httptest.Server 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 { func newMockFAA(t *testing.T) *mockFAA {
t.Helper() t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { m := &mockFAA{}
// e2e 不會真的 follow 到這test client 設 ErrUseLastResponse m.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 留 200 OK 當保險(避免假設外部 mock 必返錯誤)。 // 只處理 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.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("mock-nef-bytes")) _, _ = w.Write(payload)
})) }))
t.Cleanup(srv.Close) t.Cleanup(m.srv.Close)
return &mockFAA{srv: srv} 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 server *httptest.Server // visionA backend
fakeOIDC *oidctest.Server // 給 user 走 OIDC cookie session 登入用 fakeOIDC *oidctest.Server // 給 user 走 OIDC cookie session 登入用
conv *mockConverter conv *mockConverter
mc *mockMC
faa *mockFAA faa *mockFAA
// Phase 0.8b T5mc *mockMC 已從 fixture 移除(服務間認證改 API key、不再依賴 MC
// 重啟模擬:場景 #2 需要在 instance A 不註冊 ownership 直接 instance B 起, // 重啟模擬:場景 #2 需要在 instance A 不註冊 ownership 直接 instance B 起,
// 所以保留 lazy 把 conversion service rebuild 進新 router 的 hook。 // 所以保留 lazy 把 conversion service rebuild 進新 router 的 hook。
router *gin.Engine router *gin.Engine
@ -517,11 +524,14 @@ func (f *conversionFixture) Close() {
} }
// setupConversionFixture 建立完整的 e2e 環境: // setupConversionFixture 建立完整的 e2e 環境:
// - mock converter / MC service token + delegated / FAA // - mock converter / FAAPhase 0.8b 後不再 wire mock MC — API key 模式)
// - fake OIDC給 user 走 cookie session 登入) // - 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 // **不影響 T1-T7 既有 code**:本 fixture 完全獨立,不重用 setupFixture後者沒 wire conversion
//
// Phase 0.8b T5服務間認證改 pre-shared API keyADR-015mock MC / mcTokenClient
// 已從 fixture 移除download 走 server-side stream proxymockFAA 直接回 NEF binary。
func setupConversionFixture(t *testing.T) *conversionFixture { func setupConversionFixture(t *testing.T) *conversionFixture {
t.Helper() t.Helper()
@ -530,7 +540,6 @@ func setupConversionFixture(t *testing.T) *conversionFixture {
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn})) logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn}))
conv := newMockConverter(t) conv := newMockConverter(t)
mc := newMockMC(t)
faa := newMockFAA(t) faa := newMockFAA(t)
fakeOIDC := oidctest.NewServer(t, fakeOIDC := oidctest.NewServer(t,
oidctest.WithClientCredentials(fixtureOIDCClientID, fixtureOIDCClientSecret), oidctest.WithClientCredentials(fixtureOIDCClientID, fixtureOIDCClientSecret),
@ -567,28 +576,28 @@ func setupConversionFixture(t *testing.T) *conversionFixture {
SigningKey: []byte(fixtureSessionSecret), 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 // Phase 0.8b T5服務間認證改 pre-shared API keyADR-015
// 避免測試卡死;對 mock servers 來說連線秒回timeout 不會觸發。 // - 不再 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} fastHTTP := &http.Client{Timeout: 5 * time.Second}
mcTokenClient := conversion.NewMCTokenClient(conversion.MCTokenClientOpts{ const fixtureConverterAPIKey = "fixture-converter-api-key-do-not-use-in-prod-aaaaaaaaaaaaaaaaaa"
Issuer: mc.srv.URL, const fixtureFAAAPIKey = "fixture-faa-api-key-do-not-use-in-prod-bbbbbbbbbbbbbbbbbbbbbbbbbb"
ClientID: "visiona-service-client",
ClientSecret: "visiona-service-secret",
HTTPClient: fastHTTP,
Logger: logger,
})
converterAPIClient := conversion.NewConverterClient(conversion.ConverterClientOpts{ converterAPIClient := conversion.NewConverterClient(conversion.ConverterClientOpts{
BaseURL: conv.srv.URL, BaseURL: conv.srv.URL,
Tokens: mcTokenClient, APIKey: fixtureConverterAPIKey,
HTTPClient: fastHTTP, HTTPClient: fastHTTP,
InitHTTPClient: &http.Client{Timeout: 60 * time.Second}, // 場景 #1 大 body 給寬一點 InitHTTPClient: &http.Client{Timeout: 60 * time.Second}, // 場景 #1 大 body 給寬一點
Logger: logger, Logger: logger,
}) })
faaAPIClient := conversion.NewFAAClient(conversion.FAAClientOpts{ faaAPIClient := conversion.NewFAAClient(conversion.FAAClientOpts{
BaseURL: faa.srv.URL, BaseURL: faa.srv.URL,
Tokens: mcTokenClient, APIKey: fixtureFAAAPIKey,
HTTPClient: fastHTTP, HTTPClient: fastHTTP,
Logger: logger, Logger: logger,
}) })
@ -599,16 +608,12 @@ func setupConversionFixture(t *testing.T) *conversionFixture {
storageAdapter := newConversionStorageAdapter(storeStore) storageAdapter := newConversionStorageAdapter(storeStore)
conversionService, err := conversion.NewService(conversion.FlowOpts{ conversionService, err := conversion.NewService(conversion.FlowOpts{
Converter: converterAPIClient, Converter: converterAPIClient,
FAA: faaAPIClient, FAA: faaAPIClient,
MCToken: mcTokenClient, Ownership: ownership,
Ownership: ownership, ModelStore: modelStoreAdapter,
ModelStore: modelStoreAdapter, Storage: storageAdapter,
Storage: storageAdapter, Logger: logger,
TenantID: "tenant-visiona",
FAABaseURL: faa.srv.URL,
DelegatedTTLSeconds: 300,
Logger: logger,
}) })
require.NoError(t, err) require.NoError(t, err)
@ -641,7 +646,6 @@ func setupConversionFixture(t *testing.T) *conversionFixture {
server: apiTS, server: apiTS,
fakeOIDC: fakeOIDC, fakeOIDC: fakeOIDC,
conv: conv, conv: conv,
mc: mc,
faa: faa, faa: faa,
router: router, router: router,
} }
@ -846,36 +850,48 @@ func TestConversionE2E_LazyRebuildAfterRestart(t *testing.T) {
} }
// ========================================================================== // ==========================================================================
// E2E #3Download 302 redirect // E2E #3Download server-side stream proxyPhase 0.8b
// ========================================================================== // ==========================================================================
// TestConversionE2E_Download302Redirect 驗 // TestConversionE2E_DownloadStream 驗 Phase 0.8b 後 download 端對端行為
// //
// 1. user X 對 completed job 打 /download → status 302 Found // 1. user X 對 completed job 打 /download → status 200 OK
// 2. Location header 是 <faa-base-url>/files/<key>?access_token=<token> // 2. response header
// 3. Cache-Control: no-store, no-cache, ... // - Content-Type: application/octet-stream
// 4. response body 不含 token 字串grep response body 找不到 token // - Content-Disposition: attachment; filename="..."filename 經 sanitize
// 5. 不是 c.JSON 回 download_url純 redirect — content-type 不是 application/json // - 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 // - 起 fixturemock FAA 預設回 small NEF binary marker
// - user X 透過 init 建一個 jobmock 會自動建 running job // - user X init 一個 job → mock converter 自動建 running job
// - 把 mock converter 端 job 改成 completed // - markJobCompleted(jobID) 把 mock job 推進 completed
// - 對 /download 打 — client 設 ErrUseLastResponse 不 follow redirect // - 對 /download 打 GET — client 設 ErrUseLastResponse 防止意外 follow雖預期非 302
func TestConversionE2E_Download302Redirect(t *testing.T) { // - 驗以上 5 點
func TestConversionE2E_DownloadStream(t *testing.T) {
f := setupConversionFixture(t) f := setupConversionFixture(t)
defer f.Close() 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" const wantSub = "user-download-003"
client := f.AuthenticatedClient(t, wantSub, "download@e2e.local") 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) jobID := initSimpleJob(t, client, f.server.URL)
// 2. 把 mock converter 端 job 推進到 completed給 download 用) // 2. 把 mock converter 端 job 推進到 completed給 download 用)
f.conv.markJobCompleted(jobID) f.conv.markJobCompleted(jobID)
// 3. 用「不 follow redirect」的 client 對 /download 打 // 3. 對 /download 打 GET — 設 ErrUseLastResponse 防意外 follow預期非 302
noRedirectClient := &http.Client{ noRedirectClient := &http.Client{
Jar: client.Jar, Jar: client.Jar,
Timeout: 10 * time.Second, Timeout: 10 * time.Second,
@ -888,66 +904,57 @@ func TestConversionE2E_Download302Redirect(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
defer resp.Body.Close() defer resp.Body.Close()
bodyBytes, _ := io.ReadAll(resp.Body) bodyBytes, err := 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)
require.NoError(t, err) 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 // === 斷言 1status 200 OKPhase 0.8b 不再回 302===
require.Equal(t, http.StatusOK, resp.StatusCode,
"Phase 0.8b 後 /download 回 200server-side stream proxy不再 302body=%s",
string(bodyBytes))
// === 斷言 2response 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 不應含 \\rCRLF injection 防護)")
assert.NotContains(t, cd, "\n", "Content-Disposition 不應含 \\nCRLF 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") 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 // === 斷言 3response body byte-perfect 對齊 mock FAA 寫的 binary ===
// assert.Equal(t, wantNEFContent, string(bodyBytes),
// 行為說明(不要過度斷言「整個 body 不能含 token」 "response body 應等於 mock FAA 寫的 NEF binarybyte-perfect stream proxy")
// net/http.Redirect 的標準 fallback 會在 HTML body 塞一個 <a href="<url>">Found</a>
// 讓不支援 302 的 user-agent 還能手動點。**這是 net/http 的標準行為,不是 visionA // === 斷言 4mock FAA 收到 visionA 帶的 Authorization Bearer <API key> ===
// 把 token 寫進 JSON 給 frontend JS**。token 仍只活在 Location header 與 HTML authHeader := f.faa.getLastAuthHeader()
// anchor 中frontend JS 沒辦法用通常的 fetch().json() 讀到(需要 parse HTML assert.True(t, strings.HasPrefix(authHeader, "Bearer "),
// "mock FAA 應收到 Bearer 開頭的 Authorization header得 %q", authHeader)
// §10.4 安全聲明的精神是: assert.Contains(t, authHeader, "fixture-faa-api-key-do-not-use-in-prod",
// - 不用 c.JSON 回 download_url避免 JS 直接 .data.download_url 拿到 token "mock FAA 應收到 fixture FAA API key驗 visionA 端 wire 正確)")
// - Cache-Control: no-store 避免 browser 把 Location 寫 disk cache
// 兩者本檔都驗。 // === 斷言 5沒有 302 / Location headerPhase 0.8b 結構性無 redirect===
// assert.NotEqual(t, http.StatusFound, resp.StatusCode,
// 因此這裡的斷言改為: "Phase 0.8b 不再回 302 Found取消 delegated token 機制)")
// 1. content-type 不是 application/json沒用 c.JSON assert.Empty(t, resp.Header.Get("Location"),
// 2. body 不是 visionA 的 success envelope{success: true, data: {download_url: ...}} "Phase 0.8b 不應有 Location header無 redirect 流程)")
bodyStr := string(bodyBytes)
ct := resp.Header.Get("Content-Type") // 驗 mock FAA 真的被打到(防 mock 路徑 wire 錯)
if ct != "" { assert.GreaterOrEqual(t, int(f.faa.getCallCount.Load()), 1,
assert.NotContains(t, strings.ToLower(ct), "application/json", "mock FAA GET /files 應至少被打一次")
"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.JSONbody=%s",
bodyStr)
}
}
} }
// ========================================================================== // ==========================================================================
@ -1135,9 +1142,3 @@ func writeJSON(w http.ResponseWriter, status int, v any) {
_ = json.NewEncoder(w).Encode(v) _ = 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)
}

View File

@ -137,35 +137,29 @@ func main() {
// ===== ConverterstubPhase 2 才實作) ===== // ===== ConverterstubPhase 2 才實作) =====
converterClient := converter.NewStubClient() converterClient := converter.NewStubClient()
// ===== Phase 0.8 Conversion轉檔功能整合 ===== // ===== Phase 0.8 / 0.8b Conversion轉檔功能整合 =====
// 對齊 .autoflow/04-architecture/conversion.md // 對齊 .autoflow/04-architecture/conversion.md、ADR-015
// //
// 啟用條件cfg.Conversion.Enabled() — ConverterBaseURL + FAABaseURL 都非空。 // 啟用條件cfg.Conversion.Enabled() —
// 啟用時必須有 ServiceClientID/Secretclient_credentials grant 必要) // ConverterBaseURL + FAABaseURL + ConverterAPIKey + FAAAPIKey 全部非空
// 不啟用時 deps.Conversion 為 nil5 個 endpoint 自動回 501registerConversionRoutes 處理)。 // 不啟用時 deps.Conversion 為 nil5 個 endpoint 自動回 501registerConversionRoutes 處理)。
//
// **Phase 0.8b T5**:完全切換至 pre-shared API key 認證 — 不再 wire MCTokenClient、
// 不再讀 OIDCConfig.ServiceClientID/Secret、不再有 tenant_id / delegated_ttl_sec
// 概念。參見 ADR-015 §6 變更影響清單。
var conversionService conversion.Service var conversionService conversion.Service
if cfg.Conversion.Enabled() { if cfg.Conversion.Enabled() {
// service token 機制依賴 ServiceClientID/Secret — 沒設就 fatal避免半設定狀態 // 不再檢查 ServiceClientID/Secret —— Phase 0.8b 起 conversion 不依賴 OIDC service client。
if cfg.OIDC.ServiceClientID == "" || cfg.OIDC.ServiceClientSecret == "" { // OIDCConfig.ServiceClientID/Secret 兩欄位仍保留供 backward compat但非 conversion 必需。)
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)
}
mcTokenClient := conversion.NewMCTokenClient(conversion.MCTokenClientOpts{
Issuer: cfg.OIDC.IssuerURL,
ClientID: cfg.OIDC.ServiceClientID,
ClientSecret: cfg.OIDC.ServiceClientSecret,
Logger: log,
})
converterAPIClient := conversion.NewConverterClient(conversion.ConverterClientOpts{ converterAPIClient := conversion.NewConverterClient(conversion.ConverterClientOpts{
BaseURL: cfg.Conversion.ConverterBaseURL, BaseURL: cfg.Conversion.ConverterBaseURL,
Tokens: mcTokenClient, APIKey: cfg.Conversion.ConverterAPIKey,
Logger: log, Logger: log,
}) })
faaAPIClient := conversion.NewFAAClient(conversion.FAAClientOpts{ faaAPIClient := conversion.NewFAAClient(conversion.FAAClientOpts{
BaseURL: cfg.Conversion.FAABaseURL, BaseURL: cfg.Conversion.FAABaseURL,
Tokens: mcTokenClient, APIKey: cfg.Conversion.FAAAPIKey,
Logger: log, Logger: log,
}) })
ownership := conversion.NewOwnership(converterAPIClient, log) ownership := conversion.NewOwnership(converterAPIClient, log)
@ -176,16 +170,12 @@ func main() {
var convErr error var convErr error
conversionService, convErr = conversion.NewService(conversion.FlowOpts{ conversionService, convErr = conversion.NewService(conversion.FlowOpts{
Converter: converterAPIClient, Converter: converterAPIClient,
FAA: faaAPIClient, FAA: faaAPIClient,
MCToken: mcTokenClient, Ownership: ownership,
Ownership: ownership, ModelStore: modelStoreAdapter,
ModelStore: modelStoreAdapter, Storage: storageAdapter,
Storage: storageAdapter, Logger: log,
TenantID: cfg.Conversion.TenantID,
FAABaseURL: cfg.Conversion.FAABaseURL,
DelegatedTTLSeconds: cfg.Conversion.DelegatedTTLSeconds,
Logger: log,
}) })
if convErr != nil { if convErr != nil {
log.Error("failed to init conversion service", "error", convErr) log.Error("failed to init conversion service", "error", convErr)
@ -194,10 +184,11 @@ func main() {
log.Info("conversion service initialized", log.Info("conversion service initialized",
"converter_base_url", cfg.Conversion.ConverterBaseURL, "converter_base_url", cfg.Conversion.ConverterBaseURL,
"faa_base_url", cfg.Conversion.FAABaseURL, "faa_base_url", cfg.Conversion.FAABaseURL,
"tenant_id", cfg.Conversion.TenantID, // 安全:絕不印 key 全文 — 對齊 ADR-015 §3.5.3 部署檢查清單 #4
"delegated_ttl_sec", cfg.Conversion.DelegatedTTLSeconds) "converter_api_key_set", cfg.Conversion.ConverterAPIKey != "",
"faa_api_key_set", cfg.Conversion.FAAAPIKey != "")
} else { } 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可選 ===== // ===== Seed demo data可選 =====

View File

@ -11,16 +11,18 @@
// GET /api/conversion/active — 查當前 active job // GET /api/conversion/active — 查當前 active job
// GET /api/conversion/{job_id} — poll 狀態 // GET /api/conversion/{job_id} — poll 狀態
// POST /api/conversion/{job_id}/promote-to-models — 加到模型庫 // 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 proxyPhase 0.8b
// //
// 安全要點(對齊 conversion.md §7 / §10 // 安全要點(對齊 conversion.md §7 / §10
// - 全部 5 個 endpoint 都註冊在 apiGroupOIDC AuthMiddleware 之後) // - 全部 5 個 endpoint 都註冊在 apiGroupOIDC AuthMiddleware 之後)
// - userID 一律來自 UserContextFrom(c).UserID從 cookie session 解出 OIDC sub // - userID 一律來自 UserContextFrom(c).UserID從 cookie session 解出 OIDC sub
// - 任何 client 帶來的 user_idmultipart form / JSON / query一律忽略 // - 任何 client 帶來的 user_idmultipart form / JSON / query一律忽略
// - /init 不呼叫 c.MultipartForm() — 會 buffer 全 body 進 RAM / disk破壞 streaming // - /init 不呼叫 c.MultipartForm() — 會 buffer 全 body 進 RAM / disk破壞 streaming
// - /download 採 HTTP 302 Foundtoken 不出現在任何 JSON response§10.4 // - /download Phase 0.8b 改 server-side stream proxyvisionA backend 中轉 NEF stream
// 沒有 delegated token 結構性流經 frontendADR-015 §7 / conversion.md §4.1 / §10.4
// //
// Phase 0.8 conversion (見 .autoflow/04-architecture/api/api-conversion.md) // Phase 0.8 conversion (見 .autoflow/04-architecture/api/api-conversion.md)
// Phase 0.8b /download proxy 改造 (見 ADR-015 + conversion.md §4.1)
package api package api
@ -28,7 +30,9 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"io"
"net/http" "net/http"
"strconv"
"strings" "strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@ -259,14 +263,22 @@ func conversionPromoteHandler(deps Deps) gin.HandlerFunc {
// 5. GET /api/conversion/{job_id}/download // 5. GET /api/conversion/{job_id}/download
// ========================================================================== // ==========================================================================
// conversionDownloadHandler 處理「下載」請求 — server-side HTTP 302 redirect // conversionDownloadHandler 處理「下載」請求 — Phase 0.8bserver-side stream proxy
// //
// 對齊 api-conversion.md §4 + conversion.md §3.1 / §10.4 // 對齊 api-conversion.md §4 (Phase 0.8b 變更) + conversion.md §4.1 / §10.4 + ADR-015 §7
// - 成功:302 Found + Location: <FAA URL with access_token> // - 成功:200 OK + Content-Type/Length/Disposition + NEF binary streaming body
// - 失敗:不 redirect依 Accept header 回 JSON / HTML 錯誤 // - 失敗:不寫 200依 sentinel 走 handleConversionError 回 JSON
// - Cache-Control: no-store — token 不該被 browser cache即使是 302 Location // - Cache-Control: no-store — 避免 browser 對私有檔案 cache
// //
// 仿 FAA TestSite `DownloadFileDirect` patterntoken 永遠不過 frontend JS。 // Phase 0.8 → 0.8b 差異:
// - Phase 0.8visionA → MC 換 delegated token → c.Redirect(302, FAA_URL_with_token)
// - Phase 0.8bvisionA backend 用 API key 直接拉 FAA → io.Copy(c.Writer, stream)
// 沒有 token 結構性流經 frontend不需 FAA CORSserver-side outbound HTTP
//
// 中途錯誤處理(已 200 / 已寫 part of body 後失敗):
// - 一旦 status 200 已寫,無法再改 status 給 clientHTTP 規範)
// - io.Copy 中斷只能 log 錯誤client 端 browser 會看到截斷檔
// - ctx cancelclient 斷線)由 FAAClient 內部 ctx-aware 透傳goroutine 自動結束
func conversionDownloadHandler(deps Deps) gin.HandlerFunc { func conversionDownloadHandler(deps Deps) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
uc, ok := UserContextFrom(c) uc, ok := UserContextFrom(c)
@ -283,23 +295,106 @@ func conversionDownloadHandler(deps Deps) gin.HandlerFunc {
return 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 { if err != nil {
// 錯誤情況不 redirect — 依 Accept header 回 JSON / HTMLWriteError 寫 JSON // 200 還沒寫,可以正常回 JSON error依 Accept header
// 已能滿足主要 caseanchor tag 觸發時 browser 會直接顯示 JSON 也 OK
// Phase 0.8 不額外做 HTML 錯誤頁)
handleConversionError(c, err) handleConversionError(c, err)
return return
} }
// 必須 close — 否則底層 HTTP keep-alive connection 不會回 poolfd 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-LengthFAA 走 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 dialogfilename 由 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("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
c.Header("Pragma", "no-cache") c.Header("Pragma", "no-cache")
// 302 Found不用 301 — 301 可能被某些 browser 永久 cache c.Status(http.StatusOK)
c.Redirect(http.StatusFound, downloadURL)
// streaming proxy — 不 ReadAll、不暫存 disk
// 中斷錯誤只能 log已 200 + part of body無法回頭改 status
//
// Phase 0.8b T5T4 Reviewer Minor #1 修補):用 io.CopyN 上 size cap
// 防 buggy / malicious FAA 回傳超大 body 把 visionA backend 變 unbounded relay。
// 上限值 conversion.MaxDownloadStreamBytes1GB— 對 < 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無法回頭改 statusclient 會收到截斷檔
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 已給乾淨 filenamedefaultDownloadFilename 從 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 // 錯誤處理 helper
// ========================================================================== // ==========================================================================

View File

@ -47,7 +47,10 @@ type stubConversionService struct {
GetJobFn func(ctx context.Context, userID, jobID string) (*conversion.Job, error) GetJobFn func(ctx context.Context, userID, jobID string) (*conversion.Job, error)
ActiveJobFn func(ctx context.Context, userID 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) 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 T4DownloadFn signature 從 (string, error) 改 (ReadCloser, *DownloadMetadata, error)
// — Service interface 從 DownloadRedirectURL 改 DownloadStreamAPI key 模式下沒有
// delegated token改 server-side stream proxyADR-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) { 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) 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.mu.Lock()
s.lastUserID = userID s.lastUserID = userID
s.lastJobID = jobID s.lastJobID = jobID
s.mu.Unlock() s.mu.Unlock()
if s.DownloadFn == nil { 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) return s.DownloadFn(ctx, userID, jobID)
} }
@ -511,13 +514,21 @@ func TestConversion_Promote_JobNotCompleted(t *testing.T) {
// 5. GET /api/conversion/{job_id}/download // 5. GET /api/conversion/{job_id}/download
// ========================================================================== // ==========================================================================
func TestConversion_Download_HappyPath302(t *testing.T) { // Phase 0.8b T4handler 改成 server-side stream proxyAPI key 模式下沒有 delegated token
target := "http://192.168.0.130:5081/files/models/u/job.nef?access_token=opaque-xyz" // 對應 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{ 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, "demo-user", userID)
require.Equal(t, "job-abc", jobID) 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) r := newConversionFixture(t, svc)
@ -526,18 +537,77 @@ func TestConversion_Download_HappyPath302(t *testing.T) {
w := httptest.NewRecorder() w := httptest.NewRecorder()
r.ServeHTTP(w, req) r.ServeHTTP(w, req)
assert.Equal(t, http.StatusFound, w.Code) // 302 // 200 OK + NEF binary in bodyPhase 0.8b:不再 302 redirect
assert.Equal(t, target, w.Header().Get("Location")) assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "application/octet-stream", w.Header().Get("Content-Type"))
// 防快取 header — token 不該被 browser cache§10.4 // 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.Contains(t, w.Header().Get("Cache-Control"), "no-store")
assert.Equal(t, "no-cache", w.Header().Get("Pragma")) 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_ChunkedTransferFAA 走 chunked transfer encoding 時
// ContentLength = -1handler 不應 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-Lengthhttptest 不會自動補gin Status 後即 commit header
assert.Empty(t, w.Header().Get("Content-Length"),
"chunked transfer 時 handler 不應 set Content-LengthFAA 給 -1 → 讓 net/http 用 chunked")
assert.Equal(t, nefPayload, w.Body.String())
}
// TestConversion_Download_FilenameSanitizationService 給含特殊字元的 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) { func TestConversion_Download_JobNotCompleted(t *testing.T) {
svc := &stubConversionService{ 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) {
return "", conversion.ErrJobNotCompleted return nil, nil, conversion.ErrJobNotCompleted
}, },
} }
r := newConversionFixture(t, svc) r := newConversionFixture(t, svc)
@ -546,16 +616,17 @@ func TestConversion_Download_JobNotCompleted(t *testing.T) {
w := httptest.NewRecorder() w := httptest.NewRecorder()
r.ServeHTTP(w, req) r.ServeHTTP(w, req)
// 錯誤情況**不 redirect** — 回標準 JSON error // 錯誤情況回標準 JSON error200 還沒寫,可以 set status
assert.Equal(t, http.StatusConflict, w.Code) assert.Equal(t, http.StatusConflict, w.Code)
assert.Contains(t, w.Body.String(), "job_not_completed") 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) { func TestConversion_Download_NotFound(t *testing.T) {
svc := &stubConversionService{ 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) {
return "", conversion.ErrJobNotFound return nil, nil, conversion.ErrJobNotFound
}, },
} }
r := newConversionFixture(t, svc) r := newConversionFixture(t, svc)
@ -566,10 +637,15 @@ func TestConversion_Download_NotFound(t *testing.T) {
assert.Equal(t, http.StatusNotFound, w.Code) assert.Equal(t, http.StatusNotFound, w.Code)
} }
func TestConversion_Download_MCTokenUnavailable(t *testing.T) { // TestConversion_Download_FAAUnavailableFAA stream 失敗 → handler 回 502 + faa_unavailable。
//
// Phase 0.8b T4 補漏:取代原本的 TestConversion_Download_MCTokenUnavailable
// MC 認證鏈已取消ErrMCTokenUnavailable sentinel 已砍 — 對應 errors.go T3 砍除清單)。
// 保留 download 5xx 路徑覆蓋,改測 ErrFAAUnavailableAPI key 模式下最常見的 download 失敗)。
func TestConversion_Download_FAAUnavailable(t *testing.T) {
svc := &stubConversionService{ 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) {
return "", conversion.ErrMCTokenUnavailable return nil, nil, conversion.ErrFAAUnavailable
}, },
} }
r := newConversionFixture(t, svc) r := newConversionFixture(t, svc)
@ -578,7 +654,119 @@ func TestConversion_Download_MCTokenUnavailable(t *testing.T) {
w := httptest.NewRecorder() w := httptest.NewRecorder()
r.ServeHTTP(w, req) r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadGateway, w.Code) 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_FAAAuthFailedAPI key 不對齊(運維事件)
// → handler 回 502對外 mask 成 faa_unavailable不洩漏「API key 不對」)。
//
// 對齊 ADR-015 §3.5.3 #3「對外只回 unauthorized」原則 + conversion.md §6 mask 行為:
// SRE 從 server log 的 ErrFAAAuthFailed sentinel 排查 env但對 frontend 文字一致。
func TestConversion_Download_FAAAuthFailed(t *testing.T) {
svc := &stubConversionService{
DownloadFn: func(ctx context.Context, userID, jobID string) (io.ReadCloser, *conversion.DownloadMetadata, error) {
return nil, nil, conversion.ErrFAAAuthFailed
},
}
r := newConversionFixture(t, svc)
req := httptest.NewRequest(http.MethodGet, "/api/conversion/job/download", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
// 對外 502 + faa_unavailablemask— 不要洩漏 auth_failed 這個內部運維狀態
assert.Equal(t, http.StatusBadGateway, w.Code)
assert.Contains(t, w.Body.String(), "faa_unavailable",
"ErrFAAAuthFailed 對外應 mask 成 faa_unavailable不洩漏 API key 不對齊細節")
assert.NotContains(t, w.Body.String(), "auth_failed",
"對 frontend 不應暴露 auth_failed 這個內部 SRE 訊號")
}
// 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_SizeCapEnforcedT5 補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 仍 200cap 命中時 header 已 commit、無法回頭改 status
assert.Equal(t, http.StatusOK, w.Code,
"size cap 命中時 status 仍應 200header 已 commit")
// 2. body 被 truncate 到 cap — 不會把整個 infinite stream 寫給 client
assert.Equal(t, int(testCapBytes), w.Body.Len(),
"body 應被 truncate 到 cap%d bytesinfinite 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
} }
// ========================================================================== // ==========================================================================

View File

@ -180,15 +180,21 @@ type LoggerConfig struct {
Level string // VISIONA_LOG_LEVELdebug / info / warn / error預設 "info" Level string // VISIONA_LOG_LEVELdebug / 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 都非空時, // 啟用判定(由 Enabled() 給 main.go 用Phase 0.8b 起4 個欄位ConverterBaseURL /
// 才會 wire conversion.Service 進 api.Deps。其中之一為空 → 不啟用5 個 endpoint 回 501 // FAABaseURL / ConverterAPIKey / FAAAPIKey**全部非空**才視為啟用;任一缺即視為未啟用,
// 5 個 /api/conversion/* endpoint 不會 wiremain.go 在 wire 階段跳過、log warn
// //
// 進一步:啟用時 ServiceClientID/Secret 必須非空(轉檔依賴 service token 機制); // **Phase 0.8b 變更**:服務間認證從 OAuth client_credentials 改為 pre-shared API keyADR-015
// 不對齊時 main.go fatal log 退出(避免半設定狀態跑進生產)。 // `Enabled()` 加入兩個 API key 非空檢查。
//
// **Phase 0.8b T5 完成**(見 conversion.md §3.2 / ADR-015 §5 §7原暫留欄位
// TenantID / DelegatedTTLSeconds 已移除 — MC 認證鏈與 delegated download token 機制
// 都不存在了,兩個欄位連同對應 envVISIONA_OIDC_TENANT_ID /
// VISIONA_FAA_DELEGATED_TTL_SECONDS一併清除。
type ConversionConfig struct { type ConversionConfig struct {
// ConverterBaseURL 是 kneron_model_converter task-scheduler 服務的 base URL。 // ConverterBaseURL 是 kneron_model_converter task-scheduler 服務的 base URL。
// 例http://192.168.0.130:9501dev / stage / https://converter.visiona.cloudprod // 例http://192.168.0.130:9501dev / stage / https://converter.visiona.cloudprod
@ -200,15 +206,21 @@ type ConversionConfig struct {
// 對齊 VISIONA_FAA_BASE_URL留空 = 不啟用 Phase 0.8 轉檔功能。 // 對齊 VISIONA_FAA_BASE_URL留空 = 不啟用 Phase 0.8 轉檔功能。
FAABaseURL string FAABaseURL string
// TenantID 是 visionA 在 Member Center 註冊的 tenant id單一 tenant // ConverterAPIKey 是 visionA → converter 服務間認證的 pre-shared API keyPhase 0.8b 新增)。
// 在跟 MC 換 delegated download token 時當 request body 的 tenant_id 欄位用。 // 對齊 VISIONA_CONVERTER_API_KEY以 `Authorization: Bearer <key>` 形式帶上。
// 對齊 VISIONA_OIDC_TENANT_ID。 // 雙方獨立產生(`openssl rand -hex 32`visionA 端的值必須與 converter 端的
TenantID string // `CONVERTER_API_KEY` env 對齊;不對齊 → 下游 401visionA 端不重試,回 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 // FAAAPIKey 是 visionA → FAA 服務間認證的 pre-shared API keyPhase 0.8b 新增)。
// 預設 3005 分鐘);可調整範圍 60-900。對齊 VISIONA_FAA_DELEGATED_TTL_SECONDS。 // 對齊 VISIONA_FAA_API_KEY以 `Authorization: Bearer <key>` 形式帶上。
// 見 conversion.md §10.2 安全考量。 // 與 ConverterAPIKey **不共用**(每條 trust boundary 各自獨立,避免一處洩漏連坐 — ADR-015 §3
DelegatedTTLSeconds int // 對應 FAA 端的 `FAA_API_KEY` env由 warrenchen 配置(跨 repo 同步)。
FAAAPIKey string
// MaxModelSizeMB 是 visionA-backend 端對上傳模型檔的大小上限MB // MaxModelSizeMB 是 visionA-backend 端對上傳模型檔的大小上限MB
// 與 converter 端 limit 對齊converter 預設 500 MB // 與 converter 端 limit 對齊converter 預設 500 MB
@ -216,11 +228,16 @@ type ConversionConfig struct {
MaxModelSizeMB int 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.Service5 個 endpoint 回 501 / 不註冊)。
func (c ConversionConfig) Enabled() bool { func (c ConversionConfig) Enabled() bool {
return c.ConverterBaseURL != "" && c.FAABaseURL != "" return c.ConverterBaseURL != "" &&
c.FAABaseURL != "" &&
c.ConverterAPIKey != "" &&
c.FAAAPIKey != ""
} }
// CORSConfig 控制 api-server 對瀏覽器的 CORS 白名單。 // CORSConfig 控制 api-server 對瀏覽器的 CORS 白名單。

View File

@ -68,13 +68,16 @@ func Load() *Config {
CORS: CORSConfig{ CORS: CORSConfig{
AllowedOrigins: getEnvStringSlice("VISIONA_CORS_ALLOWED_ORIGINS", nil), 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{ Conversion: ConversionConfig{
ConverterBaseURL: getEnvString("VISIONA_CONVERTER_BASE_URL", ""), ConverterBaseURL: getEnvString("VISIONA_CONVERTER_BASE_URL", ""),
FAABaseURL: getEnvString("VISIONA_FAA_BASE_URL", ""), FAABaseURL: getEnvString("VISIONA_FAA_BASE_URL", ""),
TenantID: getEnvString("VISIONA_OIDC_TENANT_ID", ""), ConverterAPIKey: getEnvString("VISIONA_CONVERTER_API_KEY", ""),
DelegatedTTLSeconds: getEnvInt("VISIONA_FAA_DELEGATED_TTL_SECONDS", 300), FAAAPIKey: getEnvString("VISIONA_FAA_API_KEY", ""),
MaxModelSizeMB: getEnvInt("VISIONA_CONVERTER_MAX_MODEL_SIZE_MB", 500), MaxModelSizeMB: getEnvInt("VISIONA_CONVERTER_MAX_MODEL_SIZE_MB", 500),
}, },
} }
} }

View File

@ -266,14 +266,18 @@ func TestLoad_CORSAllowedOrigins(t *testing.T) {
assert.Nil(t, cfg.CORS.AllowedOrigins) 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 不會 wiremain.go 在 wire 階段會跳過)。 // 5 個 endpoint 不會 wiremain.go 在 wire 階段會跳過)。
//
// Phase 0.8b T5原暫留欄位 TenantID / DelegatedTTLSeconds 已從 ConversionConfig 移除
// MC 認證鏈與 delegated download token 機制不存在了);本 test 不再驗這兩欄位。
func TestLoad_ConversionDefaults(t *testing.T) { func TestLoad_ConversionDefaults(t *testing.T) {
for _, k := range []string{ for _, k := range []string{
"VISIONA_CONVERTER_BASE_URL", "VISIONA_FAA_BASE_URL", "VISIONA_OIDC_TENANT_ID", "VISIONA_CONVERTER_BASE_URL", "VISIONA_FAA_BASE_URL",
"VISIONA_FAA_DELEGATED_TTL_SECONDS", "VISIONA_CONVERTER_MAX_MODEL_SIZE_MB", "VISIONA_CONVERTER_API_KEY", "VISIONA_FAA_API_KEY",
"VISIONA_CONVERTER_MAX_MODEL_SIZE_MB",
} { } {
t.Setenv(k, "") t.Setenv(k, "")
} }
@ -281,50 +285,101 @@ func TestLoad_ConversionDefaults(t *testing.T) {
cfg := Load() cfg := Load()
assert.Empty(t, cfg.Conversion.ConverterBaseURL) assert.Empty(t, cfg.Conversion.ConverterBaseURL)
assert.Empty(t, cfg.Conversion.FAABaseURL) assert.Empty(t, cfg.Conversion.FAABaseURL)
assert.Empty(t, cfg.Conversion.TenantID) assert.Empty(t, cfg.Conversion.ConverterAPIKey, "Phase 0.8bAPI key 預設留空")
assert.Equal(t, 300, cfg.Conversion.DelegatedTTLSeconds, "預設 5 分鐘 TTL") assert.Empty(t, cfg.Conversion.FAAAPIKey, "Phase 0.8bAPI key 預設留空")
assert.Equal(t, 500, cfg.Conversion.MaxModelSizeMB, "預設 500 MB與 converter 對齊)") 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) { func TestLoad_ConversionEnabled(t *testing.T) {
cases := []struct { cases := []struct {
name string name string
converter string converterURL string
faa string faaURL string
wantEnabled bool converterKey string
faaKey string
wantEnabled bool
}{ }{
{"both_set_enables", "http://converter:9501", "http://faa:5081", true}, {"all_set_enables",
{"only_converter_disabled", "http://converter:9501", "", false}, "http://converter:9501", "http://faa:5081",
{"only_faa_disabled", "", "http://faa:5081", false}, "converter-key-32-bytes-hex-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
{"both_empty_disabled", "", "", false}, "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 { for _, tc := range cases {
tc := tc tc := tc
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
t.Setenv("VISIONA_CONVERTER_BASE_URL", tc.converter) t.Setenv("VISIONA_CONVERTER_BASE_URL", tc.converterURL)
t.Setenv("VISIONA_FAA_BASE_URL", tc.faa) 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() cfg := Load()
assert.Equal(t, tc.wantEnabled, cfg.Conversion.Enabled()) 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) { 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_CONVERTER_BASE_URL", "http://192.168.0.130:9501")
t.Setenv("VISIONA_FAA_BASE_URL", "http://192.168.0.130:5081") 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_CONVERTER_API_KEY", fakeConverterKey)
t.Setenv("VISIONA_FAA_DELEGATED_TTL_SECONDS", "600") t.Setenv("VISIONA_FAA_API_KEY", fakeFAAKey)
t.Setenv("VISIONA_CONVERTER_MAX_MODEL_SIZE_MB", "300") t.Setenv("VISIONA_CONVERTER_MAX_MODEL_SIZE_MB", "300")
cfg := Load() cfg := Load()
assert.Equal(t, "http://192.168.0.130:9501", cfg.Conversion.ConverterBaseURL) 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, "http://192.168.0.130:5081", cfg.Conversion.FAABaseURL)
assert.Equal(t, "fake-tenant-id-for-test", cfg.Conversion.TenantID) assert.Equal(t, fakeConverterKey, cfg.Conversion.ConverterAPIKey)
assert.Equal(t, 600, cfg.Conversion.DelegatedTTLSeconds) assert.Equal(t, fakeFAAKey, cfg.Conversion.FAAAPIKey)
assert.Equal(t, 300, cfg.Conversion.MaxModelSizeMB) assert.Equal(t, 300, cfg.Conversion.MaxModelSizeMB)
assert.True(t, cfg.Conversion.Enabled()) assert.True(t, cfg.Conversion.Enabled())
} }
// TestLoad_ConversionAPIKeysOnlyPhase 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 T54 個必要欄位齊全即 Enabled")
}

View File

@ -79,20 +79,31 @@ type Service interface {
// `name` 是 Design Phase 0.8 wireframe §7.1 的單一欄位(不含 description // `name` 是 Design Phase 0.8 wireframe §7.1 的單一欄位(不含 description
PromoteToModels(ctx context.Context, userID, jobID, name string) (*PromoteResult, error) PromoteToModels(ctx context.Context, userID, jobID, name string) (*PromoteResult, error)
// DownloadRedirectURL 產出「下載」的 server-side 302 redirect URL // DownloadStream 產出「下載」的 server-side stream proxyPhase 0.8b 變更,對應 ADR-015 §7
// //
// Handler 拿到後直接 c.Redirect(http.StatusFound, url)token 不出現在任何 JSON response // 流程(見 conversion.md §1 Stage 3b + §4.1
// 也不傳給 frontend JS見 conversion.md §10.4 安全分析)。 // 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 // Phase 0.8 → 0.8b 差異:
// 1. ownership 檢查 // - Phase 0.8visionA → MC 換 delegated token → 組 FAA URL → handler 回 302
// 2. ensurePromoted與 PromoteToModels 共用 cache // browser 直連 FAA。
// 3. 對 MC POST /file-access/download-tokens 換 delegated token // - Phase 0.8bMC 認證鏈取消ADR-015→ 沒有 delegated token → visionA backend
// scope=files:download.delegate, TTL 5 分鐘) // 用 API key 直接拉 FAA → 中轉 stream 給 browserserver-side proxy
// 4. 組 https://<faa>/files/<key>?access_token=<token>
// //
// 仿 FAA TestSite `DownloadFileDirect` pattern見 conversion.md §3.1)。 // 安全(見 conversion.md §10.4
DownloadRedirectURL(ctx context.Context, userID, jobID string) (string, error) // - 沒有 token 結構性存在於任何 frontend responseAPI key 永遠在 server side
// - object_key 不對 frontend 揭露filename 取自 promote 結果,由 visionA 命名)
// - 不需 FAA CORSvisionA → FAA 是 server-side outbound HTTP call不適用 CORS
//
// Callerhandler責任
// - 取得 stream 後**必須 defer stream.Close()**,否則 keep-alive connection 不會回 pool
// - 設好 response headerContent-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。 // ActiveJob 查 user 當前是否有 active job給 frontend `/conversion` 頁載入時 pre-check。
// //
@ -151,6 +162,53 @@ type Job struct {
ErrorMessage string `json:"error_message,omitempty"` 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 限 500MBVISIONA_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 headerContent-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 headerNEF binary 預設為 application/octet-stream。
// 若 FAA 沒給就用此預設值保險browser 收到 octet-stream 必觸發 download dialog
ContentType string
// ContentLength 對應 FAA response 的 Content-Length header。
// FAA 走 chunked transfer 時為 -1net/http 慣例handler 此時不要 set Content-Length header
// (讓 browser 用 chunked decoding
ContentLength int64
}
// PromoteResult 是 PromoteToModels 的 response shape對齊 api-conversion.md §3。 // PromoteResult 是 PromoteToModels 的 response shape對齊 api-conversion.md §3。
type PromoteResult struct { type PromoteResult struct {
ModelID string `json:"model_id"` ModelID string `json:"model_id"`

View File

@ -28,8 +28,10 @@ func (noopService) PromoteToModels(ctx context.Context, userID, jobID, name stri
return nil, nil return nil, nil
} }
func (noopService) DownloadRedirectURL(ctx context.Context, userID, jobID string) (string, error) { // Phase 0.8b T4DownloadRedirectURL 改 DownloadStreamAPI key 模式下沒有 delegated token
return "", nil // 改 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) { func (noopService) ActiveJob(ctx context.Context, userID string) (*Job, error) {

View File

@ -8,15 +8,20 @@
// //
// 設計重點: // 設計重點:
// - HTTP retry 矩陣對齊 conversion.md §9.1InitJob 例外:不 retry 5xx見下方 sendInitJob 註解) // - HTTP retry 矩陣對齊 conversion.md §9.1InitJob 例外:不 retry 5xx見下方 sendInitJob 註解)
// - service-to-service token 由注入的 MCTokenClient 提供per-scope cache // - **Phase 0.8b 認證**:直接帶 pre-shared API keyVISIONA_CONVERTER_API_KEY— 不再走 MC OAuth
// client_credentials grant、不再依賴 MCTokenClient.ServiceToken()。
// 詳見 ADR-015 §3 + conversion.md §3。
// - body 為 streamingInitJob 直接傳 caller 的 io.Reader不暫存 disk、不 buffer 全 RAM // - body 為 streamingInitJob 直接傳 caller 的 io.Reader不暫存 disk、不 buffer 全 RAM
// - 4xx 錯誤 mapping 對齊 §6 + api-conversion.md 錯誤碼總覽 // - 4xx 錯誤 mapping 對齊 §6 + api-conversion.md 錯誤碼總覽
// - 401/403 → ErrConverterAuthFailedPhase 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 // - 只 log job_id / status / endpoint / attempt / duration
// //
// Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §2.5 + §9.1) // 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 package conversion
import ( import (
@ -40,16 +45,15 @@ import (
// ConverterClient 對 task-scheduler 的 HTTP client。 // ConverterClient 對 task-scheduler 的 HTTP client。
// //
// 所有 method 都會自動: // 所有 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 / timeoutInitJob 例外) // - 依 conversion.md §9.1 retry 矩陣處理 5xx / network / timeoutInitJob 例外)
// - 把 4xx / 5xx 對應到 errors.go 的 sentinel // - 把 4xx / 5xx 對應到 errors.go 的 sentinel401/403 → ErrConverterAuthFailed不 retry
// //
// goroutine-safe每次呼叫獨立 *http.Request無內部 mutable statecache 由 MCTokenClient 管)。 // goroutine-safe每次呼叫獨立 *http.Request無內部 mutable stateapiKey 為 immutable 字串)。
type ConverterClient interface { type ConverterClient interface {
// InitJob 把 caller 的 multipart body streaming proxy 給 converter。 // InitJob 把 caller 的 multipart body streaming proxy 給 converter。
// //
// scope: converter:job.write
//
// 不 retry 5xxmultipart body 是 streamingio.Reader 一次性retry 會傳到一半的爛資料; // 不 retry 5xxmultipart body 是 streamingio.Reader 一次性retry 會傳到一半的爛資料;
// 直接 fail 由 callerflow.go依 §4.3.2 cleanup 鏈處理。 // 直接 fail 由 callerflow.go依 §4.3.2 cleanup 鏈處理。
// //
@ -58,13 +62,11 @@ type ConverterClient interface {
// GetJob 查單一 job 狀態。 // GetJob 查單一 job 狀態。
// //
// scope: converter:job.read
// retry: 5xx / network → max 3 attempts (0.5s, 1s, 2s 退避) // retry: 5xx / network → max 3 attempts (0.5s, 1s, 2s 退避)
GetJob(ctx context.Context, jobID string) (*ConverterJob, error) GetJob(ctx context.Context, jobID string) (*ConverterJob, error)
// Promote 把成功 job 的指定 stage 結果檔搬到 FAA。 // Promote 把成功 job 的指定 stage 結果檔搬到 FAA。
// //
// scope: converter:job.write
// retry: 5xx / network → max 2 attempts (1s, 2s 退避) // retry: 5xx / network → max 2 attempts (1s, 2s 退避)
// //
// 502 file_gateway_unavailable → ErrFAAUnavailableconverter 端 FAA 不可達) // 502 file_gateway_unavailable → ErrFAAUnavailableconverter 端 FAA 不可達)
@ -72,7 +74,6 @@ type ConverterClient interface {
// ListInProgressJobs 查指定 user 進行中的 job 清單(給 §2.6.1 lazy rebuild ownership 用)。 // ListInProgressJobs 查指定 user 進行中的 job 清單(給 §2.6.1 lazy rebuild ownership 用)。
// //
// scope: converter:job.read
// retry: 5xx / network → max 1 attempt (0.5s 退避,輕量;不期望常態打) // retry: 5xx / network → max 1 attempt (0.5s 退避,輕量;不期望常態打)
// //
// 預期 0 或 1 筆(同 user 同時只能 1 active job但回 slice 保留 future-proof。 // 預期 0 或 1 筆(同 user 同時只能 1 active job但回 slice 保留 future-proof。
@ -145,8 +146,17 @@ type ConverterClientOpts struct {
// 範例http://192.168.0.130:9501 // 範例http://192.168.0.130:9501
BaseURL string BaseURL string
// Tokens 是 MCTokenClient注入non-nil 必填)— 用來取 service token。 // APIKey 是 Phase 0.8b 引入的 pre-shared API keyVISIONA_CONVERTER_API_KEY
Tokens MCTokenClient // 必填非空 — `NewConverterClient` 會在 APIKey 為空時 panicfail-fast
// 避免 server 在「未認證」狀態下啟動)。
//
// 值由 main.go 從 cfg.Conversion.ConverterAPIKeyenv 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 為 optionalnil 用預設timeout 10s。GetJob / Promote / List 用。 // HTTPClient 為 optionalnil 用預設timeout 10s。GetJob / Promote / List 用。
HTTPClient *http.Client HTTPClient *http.Client
@ -167,10 +177,6 @@ type ConverterClientOpts struct {
// ========================================================================== // ==========================================================================
const ( const (
// converter scope對齊 task-scheduler openapi.yaml securitySchemes.OAuth2ClientCredentials.scopes
scopeConverterWrite = "converter:job.write"
scopeConverterRead = "converter:job.read"
// HTTP timeout // HTTP timeout
converterDefaultHTTPTimeout = 10 * time.Second converterDefaultHTTPTimeout = 10 * time.Second
converterInitHTTPTimeout = 30 * time.Minute // InitJob 大檔上傳 converterInitHTTPTimeout = 30 * time.Minute // InitJob 大檔上傳
@ -187,6 +193,12 @@ const (
promoteDefaultSource = "nef" 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 structcaller 拿 interface讓未來換實作不影響 caller。 // 套件內 unexported structcaller 拿 interface讓未來換實作不影響 caller。
type converterClient struct { type converterClient struct {
baseURL string baseURL string
tokens MCTokenClient apiKey string // Phase 0.8bpre-shared API key建構時 fail-fast 不允許空字串
http *http.Client http *http.Client
httpInit *http.Client httpInit *http.Client
now func() time.Time now func() time.Time
logger *slog.Logger logger *slog.Logger
} }
// NewConverterClient 建立一個 ConverterClient 實例。 // NewConverterClient 建立一個 ConverterClient 實例。
// //
// 必填BaseURL / Tokens。其他 optional。 // 必填BaseURL / APIKey。其他 optional。
// 注意constructor 不驗 BaseURL 連線;第一次呼叫 method 才會打網路。 // 注意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 { func NewConverterClient(opts ConverterClientOpts) ConverterClient {
if opts.APIKey == "" {
panic(ErrConverterAPIKeyNotConfigured)
}
httpClient := opts.HTTPClient httpClient := opts.HTTPClient
if httpClient == nil { if httpClient == nil {
httpClient = &http.Client{Timeout: converterDefaultHTTPTimeout} httpClient = &http.Client{Timeout: converterDefaultHTTPTimeout}
@ -226,7 +247,7 @@ func NewConverterClient(opts ConverterClientOpts) ConverterClient {
} }
return &converterClient{ return &converterClient{
baseURL: strings.TrimRight(opts.BaseURL, "/"), baseURL: strings.TrimRight(opts.BaseURL, "/"),
tokens: opts.Tokens, apiKey: opts.APIKey,
http: httpClient, http: httpClient,
httpInit: httpInit, httpInit: httpInit,
now: now, 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)") 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" endpoint := c.baseURL + "/api/v1/jobs"
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, req.Body) httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, req.Body)
if err != nil { if err != nil {
@ -259,7 +275,8 @@ func (c *converterClient) InitJob(ctx context.Context, req InitConverterJobReq)
// Content-Type 必須完整透傳(含 multipart boundary不能讓 net/http 自動推導 // Content-Type 必須完整透傳(含 multipart boundary不能讓 net/http 自動推導
httpReq.Header.Set("Content-Type", req.BodyContentType) httpReq.Header.Set("Content-Type", req.BodyContentType)
httpReq.Header.Set("Accept", "application/json") 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() startedAt := c.now()
res, err := c.httpInit.Do(httpReq) 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 { func (c *converterClient) mapInitError(status int, body []byte) error {
apiErr := parseAPIError(body) 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 { 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 // 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) endpoint := c.baseURL + "/api/v1/jobs/" + url.PathEscape(jobID)
body, err := c.doWithRetry(ctx, "get_job", jobID, scopeConverterRead, converterMaxRetriesGet, body, err := c.doWithRetry(ctx, "get_job", jobID, converterMaxRetriesGet,
func(token string) (*http.Request, error) { func() (*http.Request, error) {
req, rerr := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) req, rerr := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if rerr != nil { if rerr != nil {
return nil, rerr return nil, rerr
} }
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Authorization", "Bearer "+c.apiKey)
return req, nil return req, nil
}, },
c.mapGetJobError, 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 { func (c *converterClient) mapGetJobError(status int, body []byte) error {
apiErr := parseAPIError(body) apiErr := parseAPIError(body)
// Phase 0.8b401/403 → ErrConverterAuthFailedAPI key 不對齊)
if status == http.StatusUnauthorized || status == http.StatusForbidden { 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 { if status == http.StatusNotFound {
return fmt.Errorf("%w: get_job %d (%s)", ErrJobNotFound, status, apiErr.Code) 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) return nil, fmt.Errorf("%w: marshal promote request: %v", ErrConverterUnavailable, err)
} }
respBody, err := c.doWithRetry(ctx, "promote", jobID, scopeConverterWrite, converterMaxRetriesPromote, respBody, err := c.doWithRetry(ctx, "promote", jobID, converterMaxRetriesPromote,
func(token string) (*http.Request, error) { func() (*http.Request, error) {
r, rerr := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(bodyJSON)) r, rerr := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(bodyJSON))
if rerr != nil { if rerr != nil {
return nil, rerr return nil, rerr
} }
r.Header.Set("Content-Type", "application/json") r.Header.Set("Content-Type", "application/json")
r.Header.Set("Accept", "application/json") r.Header.Set("Accept", "application/json")
r.Header.Set("Authorization", "Bearer "+token) r.Header.Set("Authorization", "Bearer "+c.apiKey)
return r, nil return r, nil
}, },
c.mapPromoteError, c.mapPromoteError,
@ -446,13 +466,15 @@ func (c *converterClient) Promote(ctx context.Context, jobID string, req Promote
// //
// 特殊 mapping // 特殊 mapping
// - 502 file_gateway_unavailable → ErrFAAUnavailable // - 502 file_gateway_unavailable → ErrFAAUnavailable
// - 503 auth_service_unavailable → ErrIDPUnavailable // - 503 auth_service_unavailable → ErrConverterUnavailablePhase 0.8bMC 路徑取消,
// converter 端的 503 從 visionA 角度看是 converter 整體不可用)
// - 409 job_not_ready_for_promote / source_not_available → ErrJobNotCompleted // - 409 job_not_ready_for_promote / source_not_available → ErrJobNotCompleted
func (c *converterClient) mapPromoteError(status int, body []byte) error { func (c *converterClient) mapPromoteError(status int, body []byte) error {
apiErr := parseAPIError(body) apiErr := parseAPIError(body)
// Phase 0.8b401/403 → ErrConverterAuthFailedAPI key 不對齊)
if status == http.StatusUnauthorized || status == http.StatusForbidden { 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 { if status == http.StatusNotFound {
return fmt.Errorf("%w: promote %d (%s)", ErrJobNotFound, status, apiErr.Code) 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) return fmt.Errorf("%w: promote %d (%s)", ErrFAAUnavailable, status, apiErr.Code)
} }
if status == http.StatusServiceUnavailable { if status == http.StatusServiceUnavailable {
// converter 端 MC 簽 token 失敗 // Phase 0.8bMC token 路徑取消後converter 端 503 一律歸為 converter 整體不可用
return fmt.Errorf("%w: promote %d (%s)", ErrIDPUnavailable, status, apiErr.Code) // (從 visionA 角度看無法區分「converter 自己」vs「converter 上游 MC」 — 都是不可用)
return fmt.Errorf("%w: promote %d (%s)", ErrConverterUnavailable, status, apiErr.Code)
} }
if status == http.StatusBadRequest || status == http.StatusUnprocessableEntity { if status == http.StatusBadRequest || status == http.StatusUnprocessableEntity {
return &ConverterValidationError{ return &ConverterValidationError{
@ -495,14 +518,14 @@ func (c *converterClient) ListInProgressJobs(ctx context.Context, userID string)
q.Set("status", "in_progress") q.Set("status", "in_progress")
endpoint := c.baseURL + "/api/v1/jobs?" + q.Encode() endpoint := c.baseURL + "/api/v1/jobs?" + q.Encode()
body, err := c.doWithRetry(ctx, "list_jobs", userID, scopeConverterRead, converterMaxRetriesList, body, err := c.doWithRetry(ctx, "list_jobs", userID, converterMaxRetriesList,
func(token string) (*http.Request, error) { func() (*http.Request, error) {
r, rerr := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) r, rerr := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if rerr != nil { if rerr != nil {
return nil, rerr return nil, rerr
} }
r.Header.Set("Accept", "application/json") r.Header.Set("Accept", "application/json")
r.Header.Set("Authorization", "Bearer "+token) r.Header.Set("Authorization", "Bearer "+c.apiKey)
return r, nil return r, nil
}, },
c.mapListJobsError, c.mapListJobsError,
@ -520,8 +543,9 @@ func (c *converterClient) ListInProgressJobs(ctx context.Context, userID string)
func (c *converterClient) mapListJobsError(status int, body []byte) error { func (c *converterClient) mapListJobsError(status int, body []byte) error {
apiErr := parseAPIError(body) apiErr := parseAPIError(body)
// Phase 0.8b401/403 → ErrConverterAuthFailedAPI key 不對齊)
if status == http.StatusUnauthorized || status == http.StatusForbidden { 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 { if status >= 400 && status < 500 {
return fmt.Errorf("%w: list_jobs %d (%s)", ErrValidationFailed, status, apiErr.Code) 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 執行器。 // doWithRetry 是 GetJob / Promote / List 共用的 retry 執行器。
// //
// 與 mc_token_client.doWithRetry 結構類似但有以下差異: // Phase 0.8b 變更:
// - 每次 attempt 內呼叫 ServiceToken 取最新 token401 時 caller 不主動 invalidate cache — // - 移除 `scope` 參數與 `tokens.ServiceToken()` 呼叫API key 改造後不再 per-attempt 取 token
// 設計取捨:避免 cache 被惡意 401 attack 反覆清空;正常 401 = secret 設定錯retry 也沒用) // - reqBuilder closure 不再接 `token` 參數 — caller 直接讀 c.apiKey 自行 set header
// - retry 次數由 caller 傳入(不同 endpoint 不同上限) //
// 行為(不變):
// - 4xx / 401 / 403 不 retry5xx / network / timeout 可 retry // - 4xx / 401 / 403 不 retry5xx / network / timeout 可 retry
// - retry 次數由 caller 傳入(不同 endpoint 不同上限)
// - mapErr 由 caller 傳入,因為 GetJob / Promote / List 的 4xx mapping 細節不同 // - mapErr 由 caller 傳入,因為 GetJob / Promote / List 的 4xx mapping 細節不同
// //
// reqBuilder 是「每次 attempt 都重新建一個 *http.Request」的 closure // reqBuilder 是「每次 attempt 都重新建一個 *http.Request」的 closure
// — request body 可能在 retry 時已被讀完必須重建。caller 內部用 bytes.NewReader 等可重建的 body。 // — request body 可能在 retry 時已被讀完必須重建。caller 內部用 bytes.NewReader 等可重建的 body。
// — token 是 closure 參數,每次 attempt 都拿最新(也涵蓋 cache 過期 refresh 的場景)
func (c *converterClient) doWithRetry( func (c *converterClient) doWithRetry(
ctx context.Context, ctx context.Context,
endpointKind, label, scope string, endpointKind, label string,
maxRetries int, maxRetries int,
reqBuilder func(token string) (*http.Request, error), reqBuilder func() (*http.Request, error),
mapErr func(status int, body []byte) error, mapErr func(status int, body []byte) error,
) ([]byte, error) { ) ([]byte, error) {
var lastErr error var lastErr error
@ -563,14 +588,7 @@ func (c *converterClient) doWithRetry(
} }
} }
// 每次 attempt 都重新取 tokencache hit 情境下成本極低) req, err := reqBuilder()
token, err := c.tokens.ServiceToken(ctx, scope)
if err != nil {
// token 取不到 — 不可重試IdP 端問題,不在 converter 重試矩陣內)
return nil, c.wrapTokenErr(err)
}
req, err := reqBuilder(token)
if err != nil { if err != nil {
return nil, fmt.Errorf("%w: build %s request: %v", ErrConverterUnavailable, endpointKind, err) 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)) return converterRetryBase * (1 << (attempt - 1))
} }
// wrapTokenErr 把 MCTokenClient 取 token 時的錯誤包成 caller 已預期的 sentinel。 // Phase 0.8bwrapTokenErr 已移除API key 改造後不再透過 MCTokenClient 取 token
// // 因此沒有 token-取-不到 的失敗路徑需要 wrap
// 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
}
// 已是 sentinelErrServiceClientUnauthorized / ErrMCTokenUnavailable— 直接透傳
if errors.Is(err, ErrServiceClientUnauthorized) || errors.Is(err, ErrMCTokenUnavailable) {
return err
}
// 兜底:未預期的 token 錯誤包成 ErrMCTokenUnavailable
return fmt.Errorf("%w: %v", ErrMCTokenUnavailable, err)
}
// ========================================================================== // ==========================================================================
// Response 解析converter openapi.yaml shapes // Response 解析converter openapi.yaml shapes

View File

@ -2,28 +2,29 @@
// //
// 測試策略: // 測試策略:
// - 用 httptest.Server mock task-scheduler 的 4 個 endpoint // - 用 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 // - 用 atomic counter 驗 retry 行為attempts 數對齊 conversion.md §9.1
// - 大 body streaming 用 io.LimitReader不真的寫 100MB 進 RAM // - 大 body streaming 用 io.LimitReader不真的寫 100MB 進 RAM
// //
// 對應 task 規範必含 case // 對應 task 規範必含 case
// - InitJobSuccess / StreamingBody / ContentTypeHeader / Conflict409 / Validation400 / 5xx_NoRetry / AuthExpired // - InitJobSuccess / StreamingBody / ContentTypeHeader / Conflict409 / Validation400 / 5xx_NoRetry / AuthFailed401 / AuthFailed403
// - GetJobSuccess / NotFound / 5xx_RetryThenSuccess // - GetJobSuccess / NotFound / 5xx_RetryThenSuccess / AuthFailed401_NoRetry
// - PromoteSuccess / BadGateway // - PromoteSuccess / BadGateway / AuthFailed401_NoRetry
// - ListSuccess / Empty / 5xxRetry // - ListSuccess / Empty / 5xxRetry / AuthFailed401_NoRetry
// - ConstructorPanics_When_APIKey_Empty
// //
// Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §2.5 + §9.1) // 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 package conversion
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"io" "io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
"sync"
"sync/atomic" "sync/atomic"
"testing" "testing"
"time" "time"
@ -33,50 +34,16 @@ import (
) )
// ========================================================================== // ==========================================================================
// stub MCTokenClient — 解耦真實 mc_token_client 邏輯 // Phase 0.8bfake API key fixtures
// ========================================================================== // ==========================================================================
//
// stubTokenClient 是 test 用的 fake MCTokenClient。 // 取明顯 fake 字串、含 `do-not-use-in-prod` markergrepable避免被誤當真 key
type stubTokenClient struct { // 長度 64 hex chars 對齊 ADR-015 §3.4 production key 規格(`openssl rand -hex 32`)— 即使
mu sync.Mutex // 未來加 length validation 也不會 break 這個 fixture。
token string const (
tokenErr error fakeConverterAPIKey = "fake-converter-api-key-do-not-use-in-prod-aaaaaaaaaaaaaaaaaaaaaaaa"
callsByScope map[string]int fakeFAAAPIKey = "fake-faa-api-key-do-not-use-in-prod-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
} )
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]
}
// ========================================================================== // ==========================================================================
// converter mock server helpers // converter mock server helpers
@ -84,13 +51,15 @@ func (s *stubTokenClient) calls(scope string) int {
// newConverterClientForTest 建立指向 mock server 的 ConverterClient。 // newConverterClientForTest 建立指向 mock server 的 ConverterClient。
// //
// 使用較短的 init/http timeout 加速 testretry 退避保持原本converterRetryBackoff 1s 起跳 // Phase 0.8b:直接傳 fakeConverterAPIKey不再需要 MCTokenClient 注入。
//
// 使用較短的 init/http timeout 加速 testretry 退避保持原本converterRetryBackoff 0.5s 起跳
// 對 retry test 有點久但仍可接受 — 5xx retry test 的 max 2 retries = 0.5s + 1s = 1.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() t.Helper()
return NewConverterClient(ConverterClientOpts{ return NewConverterClient(ConverterClientOpts{
BaseURL: baseURL, BaseURL: baseURL,
Tokens: tokens, APIKey: fakeConverterAPIKey,
HTTPClient: &http.Client{Timeout: 5 * time.Second}, HTTPClient: &http.Client{Timeout: 5 * time.Second},
InitHTTPClient: &http.Client{Timeout: 5 * time.Second}, InitHTTPClient: &http.Client{Timeout: 5 * time.Second},
Logger: silentLogger(), Logger: silentLogger(),
@ -102,15 +71,18 @@ func newConverterClientForTest(t *testing.T, baseURL string, tokens MCTokenClien
// ========================================================================== // ==========================================================================
// TestInitJob_Successmock 接受 multipart回 201 + job spec。 // TestInitJob_Successmock 接受 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) { func TestInitJob_Success(t *testing.T) {
t.Parallel() t.Parallel()
tokens := newStubTokenClient("svc-tok")
var serverContentType string var serverContentType string
var serverAuth string
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/api/v1/jobs", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/v1/jobs", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method) 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") serverContentType = r.Header.Get("Content-Type")
// drain body 確認 streaming 完成 // drain body 確認 streaming 完成
@ -132,7 +104,7 @@ func TestInitJob_Success(t *testing.T) {
srv := httptest.NewServer(mux) srv := httptest.NewServer(mux)
t.Cleanup(srv.Close) t.Cleanup(srv.Close)
cc := newConverterClientForTest(t, srv.URL, tokens) cc := newConverterClientForTest(t, srv.URL)
job, err := cc.InitJob(context.Background(), InitConverterJobReq{ job, err := cc.InitJob(context.Background(), InitConverterJobReq{
UserID: "alice", UserID: "alice",
Platform: "520", Platform: "520",
@ -148,7 +120,8 @@ func TestInitJob_Success(t *testing.T) {
assert.Equal(t, "onnx", job.Stage) assert.Equal(t, "onnx", job.Stage)
assert.Equal(t, "multipart/form-data; boundary=xyz", serverContentType, assert.Equal(t, "multipart/form-data; boundary=xyz", serverContentType,
"InitJob 必須完整透傳 Content-Type 含 boundaryconverter multer 解析依賴此)") "InitJob 必須完整透傳 Content-Type 含 boundaryconverter 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_StreamingBodydriver 寫 100MB 假資料給 io.Readerconfirm streaming不全 buffer RAM // TestInitJob_StreamingBodydriver 寫 100MB 假資料給 io.Readerconfirm streaming不全 buffer RAM
@ -159,7 +132,6 @@ func TestInitJob_Success(t *testing.T) {
func TestInitJob_StreamingBody(t *testing.T) { func TestInitJob_StreamingBody(t *testing.T) {
t.Parallel() t.Parallel()
tokens := newStubTokenClient("svc-tok")
var serverBytesRead int64 var serverBytesRead int64
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/api/v1/jobs", func(w http.ResponseWriter, r *http.Request) { 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), R: io.LimitReader(zerosReader{}, totalSize),
} }
cc := newConverterClientForTest(t, srv.URL, tokens)
// 對 streaming test 加長 timeout // 對 streaming test 加長 timeout
cc = NewConverterClient(ConverterClientOpts{ cc := NewConverterClient(ConverterClientOpts{
BaseURL: srv.URL, BaseURL: srv.URL,
Tokens: tokens, APIKey: fakeConverterAPIKey,
HTTPClient: &http.Client{Timeout: 30 * time.Second}, HTTPClient: &http.Client{Timeout: 30 * time.Second},
InitHTTPClient: &http.Client{Timeout: 30 * time.Second}, InitHTTPClient: &http.Client{Timeout: 30 * time.Second},
Logger: silentLogger(), Logger: silentLogger(),
@ -215,7 +186,6 @@ func TestInitJob_StreamingBody(t *testing.T) {
func TestInitJob_ContentTypeHeader(t *testing.T) { func TestInitJob_ContentTypeHeader(t *testing.T) {
t.Parallel() t.Parallel()
tokens := newStubTokenClient("svc-tok")
var receivedCT string var receivedCT string
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/api/v1/jobs", func(w http.ResponseWriter, r *http.Request) { 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) t.Cleanup(srv.Close)
const customCT = "multipart/form-data; boundary=---xxx-very-specific-boundary-yyy---" 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{ _, err := cc.InitJob(context.Background(), InitConverterJobReq{
Body: strings.NewReader("body content"), Body: strings.NewReader("body content"),
BodyContentType: customCT, BodyContentType: customCT,
@ -247,7 +217,6 @@ func TestInitJob_ContentTypeHeader(t *testing.T) {
func TestInitJob_Conflict409_ActiveJobError(t *testing.T) { func TestInitJob_Conflict409_ActiveJobError(t *testing.T) {
t.Parallel() t.Parallel()
tokens := newStubTokenClient("svc-tok")
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/api/v1/jobs", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/v1/jobs", func(w http.ResponseWriter, r *http.Request) {
_, _ = io.Copy(io.Discard, r.Body) _, _ = io.Copy(io.Discard, r.Body)
@ -271,7 +240,7 @@ func TestInitJob_Conflict409_ActiveJobError(t *testing.T) {
srv := httptest.NewServer(mux) srv := httptest.NewServer(mux)
t.Cleanup(srv.Close) t.Cleanup(srv.Close)
cc := newConverterClientForTest(t, srv.URL, tokens) cc := newConverterClientForTest(t, srv.URL)
_, err := cc.InitJob(context.Background(), InitConverterJobReq{ _, err := cc.InitJob(context.Background(), InitConverterJobReq{
Body: strings.NewReader("x"), Body: strings.NewReader("x"),
BodyContentType: "multipart/form-data; boundary=xxx", BodyContentType: "multipart/form-data; boundary=xxx",
@ -294,7 +263,6 @@ func TestInitJob_Conflict409_ActiveJobError(t *testing.T) {
func TestInitJob_Validation400(t *testing.T) { func TestInitJob_Validation400(t *testing.T) {
t.Parallel() t.Parallel()
tokens := newStubTokenClient("svc-tok")
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/api/v1/jobs", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/v1/jobs", func(w http.ResponseWriter, r *http.Request) {
_, _ = io.Copy(io.Discard, r.Body) _, _ = io.Copy(io.Discard, r.Body)
@ -317,7 +285,7 @@ func TestInitJob_Validation400(t *testing.T) {
srv := httptest.NewServer(mux) srv := httptest.NewServer(mux)
t.Cleanup(srv.Close) t.Cleanup(srv.Close)
cc := newConverterClientForTest(t, srv.URL, tokens) cc := newConverterClientForTest(t, srv.URL)
_, err := cc.InitJob(context.Background(), InitConverterJobReq{ _, err := cc.InitJob(context.Background(), InitConverterJobReq{
Body: strings.NewReader("x"), Body: strings.NewReader("x"),
BodyContentType: "multipart/form-data; boundary=xxx", BodyContentType: "multipart/form-data; boundary=xxx",
@ -341,7 +309,6 @@ func TestInitJob_Validation400(t *testing.T) {
func TestInitJob_5xx_NoRetry(t *testing.T) { func TestInitJob_5xx_NoRetry(t *testing.T) {
t.Parallel() t.Parallel()
tokens := newStubTokenClient("svc-tok")
var counter atomic.Int32 var counter atomic.Int32
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/api/v1/jobs", func(w http.ResponseWriter, r *http.Request) { 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) srv := httptest.NewServer(mux)
t.Cleanup(srv.Close) t.Cleanup(srv.Close)
cc := newConverterClientForTest(t, srv.URL, tokens) cc := newConverterClientForTest(t, srv.URL)
_, err := cc.InitJob(context.Background(), InitConverterJobReq{ _, err := cc.InitJob(context.Background(), InitConverterJobReq{
Body: strings.NewReader("x"), Body: strings.NewReader("x"),
BodyContentType: "multipart/form-data; boundary=xxx", BodyContentType: "multipart/form-data; boundary=xxx",
@ -366,53 +333,71 @@ func TestInitJob_5xx_NoRetry(t *testing.T) {
"InitJob 不可 retry 5xxstreaming body 不可 replay") "InitJob 不可 retry 5xxstreaming body 不可 replay")
} }
// TestInitJob_AuthExpiredmock 回 401 → return ErrServiceClientUnauthorized。 // TestInitJob_AuthFailed401mock 回 401 → ErrConverterAuthFailedPhase 0.8b 新 sentinel
func TestInitJob_AuthExpired(t *testing.T) { // 對外 mask 成 converter_unavailable / 502避免洩漏「API key 不對齊」內部運維狀態)。
func TestInitJob_AuthFailed401(t *testing.T) {
t.Parallel() t.Parallel()
tokens := newStubTokenClient("expired-tok") var attempts atomic.Int32
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/api/v1/jobs", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/v1/jobs", func(w http.ResponseWriter, r *http.Request) {
attempts.Add(1)
_, _ = io.Copy(io.Discard, r.Body) _, _ = io.Copy(io.Discard, r.Body)
w.WriteHeader(http.StatusUnauthorized) w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":{"code":"invalid_token","message":"...","request_id":"r"}}`)) _, _ = w.Write([]byte(`{"error":"unauthorized"}`))
}) })
srv := httptest.NewServer(mux) srv := httptest.NewServer(mux)
t.Cleanup(srv.Close) t.Cleanup(srv.Close)
cc := newConverterClientForTest(t, srv.URL, tokens) cc := newConverterClientForTest(t, srv.URL)
_, err := cc.InitJob(context.Background(), InitConverterJobReq{ _, err := cc.InitJob(context.Background(), InitConverterJobReq{
Body: strings.NewReader("x"), Body: strings.NewReader("x"),
BodyContentType: "multipart/form-data; boundary=xxx", BodyContentType: "multipart/form-data; boundary=xxx",
}) })
require.Error(t, err) require.Error(t, err)
assert.True(t, errors.Is(err, ErrServiceClientUnauthorized)) assert.True(t, errors.Is(err, ErrConverterAuthFailed),
"Phase 0.8b401 必須 mapping 到新 sentinel ErrConverterAuthFailed")
// Phase 0.8b T3舊 sentinel ErrServiceClientUnauthorized 已移除,
// 改由 ErrConverterAuthFailed 接管 401/403 mapping。
assert.Equal(t, int32(1), attempts.Load(),
"401 不應 retryAPI key 不對 retry 也是 401")
// 對外 ErrorCode mask 成 converter_unavailable不洩漏「API key 不對」)
assert.Equal(t, "converter_unavailable", ErrorCode(err))
assert.Equal(t, 502, HTTPStatus(err))
} }
// TestInitJob_TokenFailure_PropagatedMCTokenClient 取 token 失敗時,錯誤透傳。 // TestInitJob_AuthFailed403對稱 — mock 回 403 → 同樣 ErrConverterAuthFailed、不 retry
func TestInitJob_TokenFailure_Propagated(t *testing.T) { func TestInitJob_AuthFailed403(t *testing.T) {
t.Parallel() t.Parallel()
tokens := newStubTokenClient("") var attempts atomic.Int32
tokens.setError(ErrServiceClientUnauthorized) 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{ _, err := cc.InitJob(context.Background(), InitConverterJobReq{
Body: strings.NewReader("x"), Body: strings.NewReader("x"),
BodyContentType: "multipart/form-data; boundary=xxx", BodyContentType: "multipart/form-data; boundary=xxx",
}) })
require.Error(t, err) 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本地參數驗證不打網路 // TestInitJob_RequiredFieldsValidation本地參數驗證不打網路
func TestInitJob_RequiredFieldsValidation(t *testing.T) { func TestInitJob_RequiredFieldsValidation(t *testing.T) {
t.Parallel() t.Parallel()
tokens := newStubTokenClient("svc-tok") cc := newConverterClientForTest(t, "http://unused")
cc := newConverterClientForTest(t, "http://unused", tokens)
// 缺 body // 缺 body
_, err := cc.InitJob(context.Background(), InitConverterJobReq{ _, 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") assert.Contains(t, err.Error(), "content type is required")
} }
// TestNewConverterClient_Panics_When_APIKey_Emptyfail-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 為空時必須 panicfail-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 // GetJob tests
// ========================================================================== // ==========================================================================
@ -437,11 +445,11 @@ func TestInitJob_RequiredFieldsValidation(t *testing.T) {
func TestGetJob_Success(t *testing.T) { func TestGetJob_Success(t *testing.T) {
t.Parallel() t.Parallel()
tokens := newStubTokenClient("svc-tok")
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/api/v1/jobs/", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/v1/jobs/", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method) 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} // path: /api/v1/jobs/{id}
assert.Contains(t, r.URL.Path, "550e8400") assert.Contains(t, r.URL.Path, "550e8400")
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@ -464,7 +472,7 @@ func TestGetJob_Success(t *testing.T) {
srv := httptest.NewServer(mux) srv := httptest.NewServer(mux)
t.Cleanup(srv.Close) 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") job, err := cc.GetJob(context.Background(), "550e8400-e29b-41d4-a716-446655440000")
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, job) require.NotNil(t, job)
@ -483,7 +491,6 @@ func TestGetJob_Success(t *testing.T) {
func TestGetJob_NotFound(t *testing.T) { func TestGetJob_NotFound(t *testing.T) {
t.Parallel() t.Parallel()
tokens := newStubTokenClient("svc-tok")
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/api/v1/jobs/", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/v1/jobs/", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
@ -492,7 +499,7 @@ func TestGetJob_NotFound(t *testing.T) {
srv := httptest.NewServer(mux) srv := httptest.NewServer(mux)
t.Cleanup(srv.Close) t.Cleanup(srv.Close)
cc := newConverterClientForTest(t, srv.URL, tokens) cc := newConverterClientForTest(t, srv.URL)
_, err := cc.GetJob(context.Background(), "missing-job") _, err := cc.GetJob(context.Background(), "missing-job")
require.Error(t, err) require.Error(t, err)
assert.True(t, errors.Is(err, ErrJobNotFound)) assert.True(t, errors.Is(err, ErrJobNotFound))
@ -502,7 +509,6 @@ func TestGetJob_NotFound(t *testing.T) {
func TestGetJob_5xx_RetryThenSuccess(t *testing.T) { func TestGetJob_5xx_RetryThenSuccess(t *testing.T) {
t.Parallel() t.Parallel()
tokens := newStubTokenClient("svc-tok")
var counter atomic.Int32 var counter atomic.Int32
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/api/v1/jobs/", func(w http.ResponseWriter, r *http.Request) { 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) srv := httptest.NewServer(mux)
t.Cleanup(srv.Close) t.Cleanup(srv.Close)
cc := newConverterClientForTest(t, srv.URL, tokens) cc := newConverterClientForTest(t, srv.URL)
job, err := cc.GetJob(context.Background(), "j1") job, err := cc.GetJob(context.Background(), "j1")
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, job) require.NotNil(t, job)
@ -536,7 +542,6 @@ func TestGetJob_5xx_RetryThenSuccess(t *testing.T) {
func TestGetJob_5xx_Exhausted(t *testing.T) { func TestGetJob_5xx_Exhausted(t *testing.T) {
t.Parallel() t.Parallel()
tokens := newStubTokenClient("svc-tok")
var counter atomic.Int32 var counter atomic.Int32
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/api/v1/jobs/", func(w http.ResponseWriter, r *http.Request) { 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) srv := httptest.NewServer(mux)
t.Cleanup(srv.Close) t.Cleanup(srv.Close)
cc := newConverterClientForTest(t, srv.URL, tokens) cc := newConverterClientForTest(t, srv.URL)
_, err := cc.GetJob(context.Background(), "j1") _, err := cc.GetJob(context.Background(), "j1")
require.Error(t, err) require.Error(t, err)
assert.True(t, errors.Is(err, ErrConverterUnavailable)) 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) { func TestGetJob_ContextCancel_NoRetry(t *testing.T) {
t.Parallel() t.Parallel()
tokens := newStubTokenClient("svc-tok")
var counter atomic.Int32 var counter atomic.Int32
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/api/v1/jobs/", func(w http.ResponseWriter, r *http.Request) { 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) srv := httptest.NewServer(mux)
t.Cleanup(srv.Close) t.Cleanup(srv.Close)
cc := newConverterClientForTest(t, srv.URL, tokens) cc := newConverterClientForTest(t, srv.URL)
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
// 第一次 attempt 完後 cancel第二次 retry 等待時應立即 return // 第一次 attempt 完後 cancel第二次 retry 等待時應立即 return
@ -586,6 +590,29 @@ func TestGetJob_ContextCancel_NoRetry(t *testing.T) {
"ctx cancel 應在第 1 次 attempt 後立即 return不再打 server") "ctx cancel 應在第 1 次 attempt 後立即 return不再打 server")
} }
// TestGetJob_AuthFailed401_NoRetry401 → ErrConverterAuthFailed、不 retryAPI 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.8bGetJob 401 必須 mapping 到 ErrConverterAuthFailed")
assert.Equal(t, int32(1), attempts.Load(),
"401 不應 retry — API key 不對 retry 100 次也不會自己變對")
}
// ========================================================================== // ==========================================================================
// Promote tests // Promote tests
// ========================================================================== // ==========================================================================
@ -594,7 +621,6 @@ func TestGetJob_ContextCancel_NoRetry(t *testing.T) {
func TestPromote_Success(t *testing.T) { func TestPromote_Success(t *testing.T) {
t.Parallel() t.Parallel()
tokens := newStubTokenClient("svc-tok")
var receivedBody string var receivedBody string
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/api/v1/jobs/", func(w http.ResponseWriter, r *http.Request) { 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) srv := httptest.NewServer(mux)
t.Cleanup(srv.Close) t.Cleanup(srv.Close)
cc := newConverterClientForTest(t, srv.URL, tokens) cc := newConverterClientForTest(t, srv.URL)
result, err := cc.Promote(context.Background(), "j1", PromoteReq{ result, err := cc.Promote(context.Background(), "j1", PromoteReq{
UserID: "alice", UserID: "alice",
Source: "nef", Source: "nef",
@ -641,7 +667,6 @@ func TestPromote_Success(t *testing.T) {
func TestPromote_DefaultSource(t *testing.T) { func TestPromote_DefaultSource(t *testing.T) {
t.Parallel() t.Parallel()
tokens := newStubTokenClient("svc-tok")
var receivedBody string var receivedBody string
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/api/v1/jobs/", func(w http.ResponseWriter, r *http.Request) { 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) srv := httptest.NewServer(mux)
t.Cleanup(srv.Close) t.Cleanup(srv.Close)
cc := newConverterClientForTest(t, srv.URL, tokens) cc := newConverterClientForTest(t, srv.URL)
_, err := cc.Promote(context.Background(), "j1", PromoteReq{ _, err := cc.Promote(context.Background(), "j1", PromoteReq{
UserID: "alice", UserID: "alice",
TargetObjectKey: "x", TargetObjectKey: "x",
@ -670,7 +695,6 @@ func TestPromote_DefaultSource(t *testing.T) {
func TestPromote_BadGateway(t *testing.T) { func TestPromote_BadGateway(t *testing.T) {
t.Parallel() t.Parallel()
tokens := newStubTokenClient("svc-tok")
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/api/v1/jobs/", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/v1/jobs/", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadGateway) w.WriteHeader(http.StatusBadGateway)
@ -679,7 +703,7 @@ func TestPromote_BadGateway(t *testing.T) {
srv := httptest.NewServer(mux) srv := httptest.NewServer(mux)
t.Cleanup(srv.Close) t.Cleanup(srv.Close)
cc := newConverterClientForTest(t, srv.URL, tokens) cc := newConverterClientForTest(t, srv.URL)
_, err := cc.Promote(context.Background(), "j1", PromoteReq{ _, err := cc.Promote(context.Background(), "j1", PromoteReq{
UserID: "alice", UserID: "alice",
TargetObjectKey: "x", TargetObjectKey: "x",
@ -693,7 +717,6 @@ func TestPromote_BadGateway(t *testing.T) {
func TestPromote_NotCompleted409(t *testing.T) { func TestPromote_NotCompleted409(t *testing.T) {
t.Parallel() t.Parallel()
tokens := newStubTokenClient("svc-tok")
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/api/v1/jobs/", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/v1/jobs/", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusConflict) w.WriteHeader(http.StatusConflict)
@ -702,7 +725,7 @@ func TestPromote_NotCompleted409(t *testing.T) {
srv := httptest.NewServer(mux) srv := httptest.NewServer(mux)
t.Cleanup(srv.Close) t.Cleanup(srv.Close)
cc := newConverterClientForTest(t, srv.URL, tokens) cc := newConverterClientForTest(t, srv.URL)
_, err := cc.Promote(context.Background(), "j1", PromoteReq{ _, err := cc.Promote(context.Background(), "j1", PromoteReq{
UserID: "alice", UserID: "alice",
TargetObjectKey: "x", TargetObjectKey: "x",
@ -715,7 +738,6 @@ func TestPromote_NotCompleted409(t *testing.T) {
func TestPromote_NotFound404(t *testing.T) { func TestPromote_NotFound404(t *testing.T) {
t.Parallel() t.Parallel()
tokens := newStubTokenClient("svc-tok")
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/api/v1/jobs/", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/v1/jobs/", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
@ -724,7 +746,7 @@ func TestPromote_NotFound404(t *testing.T) {
srv := httptest.NewServer(mux) srv := httptest.NewServer(mux)
t.Cleanup(srv.Close) t.Cleanup(srv.Close)
cc := newConverterClientForTest(t, srv.URL, tokens) cc := newConverterClientForTest(t, srv.URL)
_, err := cc.Promote(context.Background(), "j1", PromoteReq{ _, err := cc.Promote(context.Background(), "j1", PromoteReq{
UserID: "alice", UserID: "alice",
TargetObjectKey: "x", TargetObjectKey: "x",
@ -737,8 +759,7 @@ func TestPromote_NotFound404(t *testing.T) {
func TestPromote_RequiredFieldsValidation(t *testing.T) { func TestPromote_RequiredFieldsValidation(t *testing.T) {
t.Parallel() t.Parallel()
tokens := newStubTokenClient("svc-tok") cc := newConverterClientForTest(t, "http://unused")
cc := newConverterClientForTest(t, "http://unused", tokens)
_, err := cc.Promote(context.Background(), "", PromoteReq{TargetObjectKey: "x"}) _, err := cc.Promote(context.Background(), "", PromoteReq{TargetObjectKey: "x"})
require.Error(t, err) require.Error(t, err)
@ -749,6 +770,31 @@ func TestPromote_RequiredFieldsValidation(t *testing.T) {
assert.Contains(t, err.Error(), "target_object_key is required") assert.Contains(t, err.Error(), "target_object_key is required")
} }
// TestPromote_AuthFailed401_NoRetry401 → 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.8bPromote 401 必須 mapping 到 ErrConverterAuthFailed")
assert.Equal(t, int32(1), attempts.Load(), "401 不應 retry")
}
// ========================================================================== // ==========================================================================
// ListInProgressJobs tests // ListInProgressJobs tests
// ========================================================================== // ==========================================================================
@ -757,7 +803,6 @@ func TestPromote_RequiredFieldsValidation(t *testing.T) {
func TestListInProgressJobs_Success(t *testing.T) { func TestListInProgressJobs_Success(t *testing.T) {
t.Parallel() t.Parallel()
tokens := newStubTokenClient("svc-tok")
var receivedQuery string var receivedQuery string
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/api/v1/jobs", func(w http.ResponseWriter, r *http.Request) { 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) srv := httptest.NewServer(mux)
t.Cleanup(srv.Close) t.Cleanup(srv.Close)
cc := newConverterClientForTest(t, srv.URL, tokens) cc := newConverterClientForTest(t, srv.URL)
jobs, err := cc.ListInProgressJobs(context.Background(), "alice") jobs, err := cc.ListInProgressJobs(context.Background(), "alice")
require.NoError(t, err) require.NoError(t, err)
require.Len(t, jobs, 1) require.Len(t, jobs, 1)
@ -806,7 +851,6 @@ func TestListInProgressJobs_Success(t *testing.T) {
func TestListInProgressJobs_Empty(t *testing.T) { func TestListInProgressJobs_Empty(t *testing.T) {
t.Parallel() t.Parallel()
tokens := newStubTokenClient("svc-tok")
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/api/v1/jobs", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/v1/jobs", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@ -816,7 +860,7 @@ func TestListInProgressJobs_Empty(t *testing.T) {
srv := httptest.NewServer(mux) srv := httptest.NewServer(mux)
t.Cleanup(srv.Close) t.Cleanup(srv.Close)
cc := newConverterClientForTest(t, srv.URL, tokens) cc := newConverterClientForTest(t, srv.URL)
jobs, err := cc.ListInProgressJobs(context.Background(), "alice") jobs, err := cc.ListInProgressJobs(context.Background(), "alice")
require.NoError(t, err) require.NoError(t, err)
assert.Len(t, jobs, 0, "empty result 應回空 slice不是 nil 也不是 error") 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) { func TestListInProgressJobs_5xxRetry(t *testing.T) {
t.Parallel() t.Parallel()
tokens := newStubTokenClient("svc-tok")
var counter atomic.Int32 var counter atomic.Int32
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/api/v1/jobs", func(w http.ResponseWriter, r *http.Request) { 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) srv := httptest.NewServer(mux)
t.Cleanup(srv.Close) t.Cleanup(srv.Close)
cc := newConverterClientForTest(t, srv.URL, tokens) cc := newConverterClientForTest(t, srv.URL)
jobs, err := cc.ListInProgressJobs(context.Background(), "alice") jobs, err := cc.ListInProgressJobs(context.Background(), "alice")
require.NoError(t, err) require.NoError(t, err)
assert.Len(t, jobs, 0) assert.Len(t, jobs, 0)
@ -855,14 +898,35 @@ func TestListInProgressJobs_5xxRetry(t *testing.T) {
func TestListInProgressJobs_RequiredUserID(t *testing.T) { func TestListInProgressJobs_RequiredUserID(t *testing.T) {
t.Parallel() t.Parallel()
tokens := newStubTokenClient("svc-tok") cc := newConverterClientForTest(t, "http://unused")
cc := newConverterClientForTest(t, "http://unused", tokens)
_, err := cc.ListInProgressJobs(context.Background(), "") _, err := cc.ListInProgressJobs(context.Background(), "")
require.Error(t, err) require.Error(t, err)
assert.Contains(t, err.Error(), "userID is required") assert.Contains(t, err.Error(), "userID is required")
} }
// TestListInProgressJobs_AuthFailed401_NoRetry401 → 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.8bList 401 必須 mapping 到 ErrConverterAuthFailed")
assert.Equal(t, int32(1), attempts.Load(), "401 不應 retry")
}
// ========================================================================== // ==========================================================================
// 共用interface 契約 + helpers // 共用interface 契約 + helpers
// ========================================================================== // ==========================================================================
@ -870,9 +934,6 @@ func TestListInProgressJobs_RequiredUserID(t *testing.T) {
// 確保 converterClient 滿足 ConverterClient interfacecompile-time check // 確保 converterClient 滿足 ConverterClient interfacecompile-time check
var _ ConverterClient = (*converterClient)(nil) var _ ConverterClient = (*converterClient)(nil)
// 確保 stubTokenClient 滿足 MCTokenClient interfacecompile-time check
var _ MCTokenClient = (*stubTokenClient)(nil)
// zerosReader 是無限產生 0 byte 的 reader測 streaming 用)。 // zerosReader 是無限產生 0 byte 的 reader測 streaming 用)。
type zerosReader struct{} type zerosReader struct{}

View File

@ -56,39 +56,53 @@ var (
// Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §2.6 + §9.2) // Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §2.6 + §9.2)
ErrFAAFileNotFound = errors.New("conversion: faa file not found") 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 4xxclient_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。 // ErrServiceBusy — converter 端回 503 service_busy。
// 對應 HTTP 503 / code "service_busy"。 // 對應 HTTP 503 / code "service_busy"。
ErrServiceBusy = errors.New("conversion: service busy") ErrServiceBusy = errors.New("conversion: service busy")
// ErrServiceClientUnauthorized — visionA-backend 對 MC 認證失敗401 / 403 // Phase 0.8b T3 移除5 個僅 mc_token_client 用的 sentinelMC 路徑取消後不再有觸發點):
// - ErrDownloadTokenFailed — MC delegated token 4xx
// - ErrMCTokenUnavailable — MC 5xx / network 持續失敗
// - ErrIDPMisconfigured — MC token endpoint 4xxclient_credentials grant 設定錯誤)
// - ErrIDPUnavailable — MC oauth/token 5xx / network 持續失敗
// - ErrServiceClientUnauthorized — visionA → MC 認證失敗401/403
// 取代401/403 改 mapping 到 ErrConverterAuthFailed / ErrFAAAuthFailed下方
// converter 端 503 改 mapping 到 ErrConverterUnavailableconverter_client.go mapPromoteError
// //
// 觸發情境: // **不重用舊 sentinel name**Phase 1+ 注意):上述 5 個 sentinel name 已從 git history
// - VISIONA_OIDC_SERVICE_CLIENT_ID / SECRET 設定錯誤(典型) // 中砍除,未來若需要新增 sentinel 不應重用同名(如 `ErrIDPUnavailable`),以免閱讀
// - MC 端 client 被 revoke / 停用 // git log / blame / 既有 reference 時混淆語意(同名但對應不同層的失敗模式)。
// - client 沒有對應 scope 的權限 // 若未來 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 // 觸發情境Phase 0.8b API key 路徑):
// 可以做更精細的處理(例如 401 時主動 invalidate cache但對外 ErrorCode/HTTPStatus // - VISIONA_CONVERTER_API_KEY 與 converter 端 CONVERTER_API_KEY 不同步
// 都對應到 idp_misconfigured / 500fail-fast避免半設定狀態跑進 production // rotate 後一邊還沒換 env、stage / prod env 設錯)
// - converter middleware 上線前 visionA 過早部署converter 還沒驗 key
// //
// Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §5.2) // 設計選擇:對外仍 mask 成 converter_unavailable / 502 — 不洩漏「API key 對 / 不對」
ErrServiceClientUnauthorized = errors.New("conversion: service client unauthorized") // 這個內部運維狀態給 frontendSRE 從 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 自家 storagelocal FS / S3寫入或讀取失敗。 // ErrStorageUnavailable — visionA 自家 storagelocal FS / S3寫入或讀取失敗。
// //
@ -217,20 +231,15 @@ func ErrorCode(err error) string {
return "faa_unavailable" return "faa_unavailable"
case errors.Is(err, ErrFAAUnavailable): case errors.Is(err, ErrFAAUnavailable):
return "faa_unavailable" 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): case errors.Is(err, ErrServiceBusy):
return "service_busy" return "service_busy"
case errors.Is(err, ErrServiceClientUnauthorized): case errors.Is(err, ErrConverterAuthFailed):
// 對外仍透過 idp_misconfigured 呈現(避免 leak「我們的 client_secret 過期」這種內部狀態); // Phase 0.8b:對外刻意 mask 成 converter_unavailable不揭露「API key 不對」內部狀態);
// caller 想做精細處理用 errors.Is(err, ErrServiceClientUnauthorized) 直接判斷。 // caller 想做精細處理用 errors.Is(err, ErrConverterAuthFailed) 直接判斷log / metric
return "idp_misconfigured" return "converter_unavailable"
case errors.Is(err, ErrFAAAuthFailed):
// Phase 0.8b:對外刻意 mask 成 faa_unavailable理由同上。
return "faa_unavailable"
case errors.Is(err, ErrStorageUnavailable): case errors.Is(err, ErrStorageUnavailable):
return "storage_unavailable" return "storage_unavailable"
case errors.Is(err, ErrModelStoreUnavailable): case errors.Is(err, ErrModelStoreUnavailable):
@ -258,15 +267,15 @@ func HTTPStatus(err error) int {
case errors.Is(err, ErrConverterUnavailable), case errors.Is(err, ErrConverterUnavailable),
errors.Is(err, ErrFAAUnavailable), errors.Is(err, ErrFAAUnavailable),
errors.Is(err, ErrFAAFileNotFound), errors.Is(err, ErrFAAFileNotFound),
errors.Is(err, ErrDownloadTokenFailed), errors.Is(err, ErrConverterAuthFailed),
errors.Is(err, ErrMCTokenUnavailable): errors.Is(err, ErrFAAAuthFailed):
// Phase 0.8bAPI key auth_failed 對外與「服務不可達」同層 502
// 內部 log / metric 才區分auth_failed = SRE alarm其他 = 自然 retry
return 502 return 502
case errors.Is(err, ErrIDPMisconfigured), errors.Is(err, ErrServiceClientUnauthorized):
return 500
case errors.Is(err, ErrStorageUnavailable), errors.Is(err, ErrModelStoreUnavailable): case errors.Is(err, ErrStorageUnavailable), errors.Is(err, ErrModelStoreUnavailable):
// visionA 自身基礎設施問題 → 500不是 502 gateway因為非 upstream 失敗) // visionA 自身基礎設施問題 → 500不是 502 gateway因為非 upstream 失敗)
return 500 return 500
case errors.Is(err, ErrIDPUnavailable), errors.Is(err, ErrServiceBusy): case errors.Is(err, ErrServiceBusy):
return 503 return 503
default: default:
return 500 return 500

View File

@ -27,13 +27,13 @@ func TestErrorCode(t *testing.T) {
{"payload_too_large", ErrPayloadTooLarge, "payload_too_large"}, {"payload_too_large", ErrPayloadTooLarge, "payload_too_large"},
{"converter_unavailable", ErrConverterUnavailable, "converter_unavailable"}, {"converter_unavailable", ErrConverterUnavailable, "converter_unavailable"},
{"faa_unavailable", ErrFAAUnavailable, "faa_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"}, {"service_busy", ErrServiceBusy, "service_busy"},
// ErrServiceClientUnauthorized 對外刻意 mask 成 idp_misconfigured不 leak「visionA secret 過期」內部狀態) // Phase 0.8b T3以下 sentinel 已移除,不再對外暴露對應 error code
{"service_client_unauthorized_masked_as_idp_misconfig", ErrServiceClientUnauthorized, "idp_misconfigured"}, // 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-1visionA 自身基礎設施失敗用獨立 code與 FAA / converter 區分) // Reviewer M-1visionA 自身基礎設施失敗用獨立 code與 FAA / converter 區分)
{"storage_unavailable", ErrStorageUnavailable, "storage_unavailable"}, {"storage_unavailable", ErrStorageUnavailable, "storage_unavailable"},
{"model_store_unavailable", ErrModelStoreUnavailable, "model_store_unavailable"}, {"model_store_unavailable", ErrModelStoreUnavailable, "model_store_unavailable"},
@ -68,12 +68,13 @@ func TestHTTPStatus(t *testing.T) {
{"payload_too_large_413", ErrPayloadTooLarge, 413}, {"payload_too_large_413", ErrPayloadTooLarge, 413},
{"converter_unavailable_502", ErrConverterUnavailable, 502}, {"converter_unavailable_502", ErrConverterUnavailable, 502},
{"faa_unavailable_502", ErrFAAUnavailable, 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_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-1visionA 自身基礎設施失敗 → 500不是 502 gateway // Reviewer M-1visionA 自身基礎設施失敗 → 500不是 502 gateway
{"storage_unavailable_500", ErrStorageUnavailable, 500}, {"storage_unavailable_500", ErrStorageUnavailable, 500},
{"model_store_unavailable_500", ErrModelStoreUnavailable, 500}, {"model_store_unavailable_500", ErrModelStoreUnavailable, 500},

View File

@ -4,28 +4,31 @@
// 其他 endpointPUT / DELETE / HEAD / metadata目前 visionA 不需要,未來再補。 // 其他 endpointPUT / DELETE / HEAD / metadata目前 visionA 不需要,未來再補。
// //
// 設計要點: // 設計要點:
// - 走 service tokenscope=files:download.readtoken 由注入的 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 // - **回 streaming body**io.ReadCloser— 不 io.ReadAll避免 500MB NEF 全進 RAM
// - **Phase A retry**dial → 拿到 response header 之間的 5xx / network / timeout 失敗 // - **Phase A retry**dial → 拿到 response header 之間的 5xx / network / timeout 失敗
// 依 §9.1 指數退避重試 max 2 次1s, 2s。一旦拿到 200 response進 Phase B // 依 §9.1 指數退避重試 max 2 次1s, 2s。一旦拿到 200 response進 Phase B
// streaming body 給 caller這層責任就結束 — body 中斷由 caller 處理(不可 replay // streaming body 給 caller這層責任就結束 — body 中斷由 caller 處理(不可 replay
// 詳見下方 GetFile doc comment 的「Phase A vs Phase B retry」段。 // 詳見下方 GetFile doc comment 的「Phase A vs Phase B retry」段。
// - 4xx → 對應 sentinel401/403 → ErrServiceClientUnauthorized404 → ErrFAAFileNotFound // - 4xx → 對應 sentinel401/403 → ErrFAAAuthFailed404 → ErrFAAFileNotFound
// 其他 4xx → ErrFAAUnavailable避免新增更多 sentinel // 其他 4xx → ErrFAAUnavailable避免新增更多 sentinel
// //
// 與 T3 InitJob 的對比(為什麼 T3 不 retry 但 T4 GetFile retry // 與 InitJob 的對比(為什麼 InitJob 不 retry 但 GetFile retry
// - T3 InitJobmultipart **request body** 是 streamingio.Reader 來自上游 c.Body // - InitJobmultipart **request body** 是 streamingio.Reader 來自上游 c.Body
// 一旦 http.Client.Do 開始送 request bodyio.Reader 已被消費retry 無法 rewind → // 一旦 http.Client.Do 開始送 request bodyio.Reader 已被消費retry 無法 rewind →
// 從第一次 attempt 起就「不可重試」。 // 從第一次 attempt 起就「不可重試」。
// - T4 GetFileGET 沒有 request bodyrequest 完全 idempotentretry window 涵蓋 // - GetFileGET 沒有 request bodyrequest 完全 idempotentretry window 涵蓋
// dial → 拿到 response headerPhase A。Phase A 結束後200 已到response body // dial → 拿到 response headerPhase A。Phase A 結束後200 已到response body
// 才是「不可 replay」的 streaming但那不在本層責任範圍 — 本層拿到 200 就 return *FAAFile。 // 才是「不可 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 敏感資訊) // - 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.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 package conversion
import ( import (
@ -49,11 +52,9 @@ import (
// FAAClient 對 File Access Agent 的 server-to-server client。 // FAAClient 對 File Access Agent 的 server-to-server client。
// //
// goroutine-safe每次呼叫獨立 *http.Request無內部 mutable statecache 由注入的 MCTokenClient 管)。 // goroutine-safe每次呼叫獨立 *http.Request無內部 mutable stateapiKey 為 immutable 字串)。
type FAAClient interface { type FAAClient interface {
// GetFile 從 FAA pull 一個 objectserver-to-server用 service token // GetFile 從 FAA pull 一個 objectserver-to-serverPhase 0.8b 用 pre-shared API key
//
// scope: files:download.read
// //
// 回傳 *FAAFile.Body 是 streaming bodyio.ReadCloser**caller 必須 Close** // 回傳 *FAAFile.Body 是 streaming bodyio.ReadCloser**caller 必須 Close**
// 不然底層 http.Response.Body 不會釋放、connection 也回不了 poolgoroutine + fd leak // 不然底層 http.Response.Body 不會釋放、connection 也回不了 poolgoroutine + fd leak
@ -74,7 +75,7 @@ type FAAClient interface {
// //
// 錯誤映射(對齊 conversion.md §6 + errors.go // 錯誤映射(對齊 conversion.md §6 + errors.go
// - ctx cancel/deadline → 透傳 ctx.Err不包成 sentinel // - ctx cancel/deadline → 透傳 ctx.Err不包成 sentinel
// - 401 / 403 → ErrServiceClientUnauthorized對外 idp_misconfigured/500 // - 401 / 403 → ErrFAAAuthFailedPhase 0.8b 新 sentinel對外 mask 成 faa_unavailable/502
// - 404 → ErrFAAFileNotFound對外 faa_unavailable/502 // - 404 → ErrFAAFileNotFound對外 faa_unavailable/502
// - 其他 4xx / 5xx exhausted / network exhausted → ErrFAAUnavailable對外 faa_unavailable/502 // - 其他 4xx / 5xx exhausted / network exhausted → ErrFAAUnavailable對外 faa_unavailable/502
GetFile(ctx context.Context, objectKey string) (*FAAFile, error) GetFile(ctx context.Context, objectKey string) (*FAAFile, error)
@ -107,8 +108,17 @@ type FAAClientOpts struct {
// 範例http://192.168.0.130:5081 // 範例http://192.168.0.130:5081
BaseURL string BaseURL string
// Tokens 是 MCTokenClient注入non-nil 必填)— 用來取 service token。 // APIKey 是 Phase 0.8b 引入的 pre-shared API keyVISIONA_FAA_API_KEY
Tokens MCTokenClient // 必填非空 — `NewFAAClient` 會在 APIKey 為空時 panicfail-fast
// 避免 server 在「未認證」狀態下啟動)。
//
// 值由 main.go 從 cfg.Conversion.FAAAPIKeyenv VISIONA_FAA_API_KEY注入
// 與 FAA middleware 端的 FAA_API_KEY 必須對齊rotate 時雙方同步換FAA 端由 warrenchen 維護)。
//
// 安全:絕不 log 此值即使前綴Authorization header 也不 log。
//
// Phase 0.8b API key 改造 (見 ADR-015 §3 + conversion.md §3)
APIKey string
// HTTPClient 為 optionalnil 用預設(含 dial / response header timeout但無整體 timeout // HTTPClient 為 optionalnil 用預設(含 dial / response header timeout但無整體 timeout
// 測試會注入 httptest.Server.Client()。 // 測試會注入 httptest.Server.Client()。
@ -132,9 +142,6 @@ type FAAClientOpts struct {
// ========================================================================== // ==========================================================================
const ( const (
// scopeFAADownloadRead 對齊 FAA README §「初步 API 邊界」與 FileAccessScopes.DownloadRead。
scopeFAADownloadRead = "files:download.read"
// faaDialTimeout 是 dial 階段的 timeout連 TCP / TLS 握手)。 // faaDialTimeout 是 dial 階段的 timeout連 TCP / TLS 握手)。
// 連線一直建不起來通常是路由問題10s 已足夠;超過視為 FAA 不可達。 // 連線一直建不起來通常是路由問題10s 已足夠;超過視為 FAA 不可達。
faaDialTimeout = 10 * time.Second faaDialTimeout = 10 * time.Second
@ -162,6 +169,12 @@ const (
// faaEndpointKind 是 log / 錯誤分類用的 endpoint 標記(目前只有一個)。 // faaEndpointKind 是 log / 錯誤分類用的 endpoint 標記(目前只有一個)。
const faaEndpointKind = "faa_get_file" 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 structcaller 拿 interface讓未來換實作不影響 caller。 // 套件內 unexported structcaller 拿 interface讓未來換實作不影響 caller。
type faaClient struct { type faaClient struct {
baseURL string baseURL string
tokens MCTokenClient apiKey string // Phase 0.8bpre-shared API key建構時 fail-fast 不允許空字串
http *http.Client http *http.Client
now func() time.Time now func() time.Time
logger *slog.Logger logger *slog.Logger
@ -179,9 +192,18 @@ type faaClient struct {
// NewFAAClient 建立一個 FAAClient 實例。 // NewFAAClient 建立一個 FAAClient 實例。
// //
// 必填BaseURL / Tokens。其他 optional。 // 必填BaseURL / APIKey。其他 optional。
// 注意constructor 不會驗 BaseURL 連線,第一次 GetFile 才會打網路。 // 注意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 { func NewFAAClient(opts FAAClientOpts) FAAClient {
if opts.APIKey == "" {
panic(ErrFAAAPIKeyNotConfigured)
}
httpClient := opts.HTTPClient httpClient := opts.HTTPClient
if httpClient == nil { if httpClient == nil {
httpClient = newDefaultFAAHTTPClient() httpClient = newDefaultFAAHTTPClient()
@ -196,7 +218,7 @@ func NewFAAClient(opts FAAClientOpts) FAAClient {
} }
return &faaClient{ return &faaClient{
baseURL: strings.TrimRight(opts.BaseURL, "/"), baseURL: strings.TrimRight(opts.BaseURL, "/"),
tokens: opts.Tokens, apiKey: opts.APIKey,
http: httpClient, http: httpClient,
now: now, now: now,
logger: logger, logger: logger,
@ -236,10 +258,9 @@ func newDefaultFAAHTTPClient() *http.Client {
// GetFile 實作 FAAClient.GetFile。 // GetFile 實作 FAAClient.GetFile。
// //
// 流程: // 流程Phase 0.8b
// 1. 取 service token透過 MCTokenClient其錯誤透傳不重新分類 // 1. 組 URL + 建 request直接帶 c.apiKey 進 Authorization header不再透過 MCTokenClient
// 2. 組 URL + 建 request // 2. doWithRetrymax (1 + faaMaxRetries) attempts每 attempt 重新 c.http.Do
// 3. doWithRetrymax (1 + faaMaxRetries) attempts每 attempt 重新 c.http.Do
// - 拿到 200直接 return *FAAFile不 close body // - 拿到 200直接 return *FAAFile不 close body
// - 拿到 4xxclose body 後依 status mapping 對應 sentinel不 retry // - 拿到 4xxclose body 後依 status mapping 對應 sentinel不 retry
// - 拿到 5xxclose body等 backoff 後 retry // - 拿到 5xxclose body等 backoff 後 retry
@ -252,42 +273,29 @@ func (c *faaClient) GetFile(ctx context.Context, objectKey string) (*FAAFile, er
keyHash := hashObjectKey(objectKey) keyHash := hashObjectKey(objectKey)
// 1. 取 service token // 1. 組 endpoint。注意 FAA 的 object_key 可能含路徑分隔符(如 "tenant/jobs/abc/output.nef")—
// 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")—
// 用 ResolveReference 處理net/http 內部會做 path escape避免 "../" 等問題。 // 用 ResolveReference 處理net/http 內部會做 path escape避免 "../" 等問題。
endpoint, err := c.buildFileURL(objectKey) endpoint, err := c.buildFileURL(objectKey)
if err != nil { if err != nil {
return nil, fmt.Errorf("%w: build faa url: %v", ErrFAAUnavailable, err) return nil, fmt.Errorf("%w: build faa url: %v", ErrFAAUnavailable, err)
} }
// 3. 進 retry loopPhase A only // 2. 進 retry loopPhase A onlyapiKey 在 doWithRetry 內 set header
return c.doWithRetry(ctx, keyHash, endpoint, token) return c.doWithRetry(ctx, keyHash, endpoint)
} }
// doWithRetry 是 GetFile 的 Phase A retry 執行器。 // 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 // - 成功路徑回傳 *FAAFile含未 close 的 streaming body不是 []byte
// - 沒有「每次 attempt 重新建 request」需求 — GET 沒 bodyrequest 物件可重用, // - 沒有「每次 attempt 重新建 request」需求 — GET 沒 bodyrequest 物件可重用,
// 但為了讓 ctx-aware 行為一致ctx cancel 後不重用舊 request這裡每次都新建一個 // 但為了讓 ctx-aware 行為一致ctx cancel 後不重用舊 request這裡每次都新建一個
// - reqBuilder 不接 token 參數 — token 在 GetFile 取一次retry 期間沿用同一 token
// retry window 短max 1+2+3=6stoken 不會在這段期間過期)
//
// 為什麼 retry 期間不重新取 token
// - 簡化:避免 token 取失敗 vs HTTP 失敗 兩種錯誤交織的處理
// - 安全401 在這層被分類為「不可 retry」不會走到「token expired 中途要 refresh」場景
// - 效能cache hit 情境下成本低但仍多一次 mutex6s window 內 token 不會 expire
func (c *faaClient) doWithRetry( func (c *faaClient) doWithRetry(
ctx context.Context, ctx context.Context,
keyHash, endpoint, token string, keyHash, endpoint string,
) (*FAAFile, error) { ) (*FAAFile, error) {
var lastErr error var lastErr error
for attempt := 0; attempt <= faaMaxRetries; attempt++ { 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) return nil, fmt.Errorf("%w: build faa request: %v", ErrFAAUnavailable, err)
} }
req.Header.Set("Accept", "application/octet-stream") 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) file, classifiedErr, retryable := c.doOnce(req, keyHash, attempt)
if classifiedErr == nil { if classifiedErr == nil {
@ -404,9 +413,11 @@ func (c *faaClient) doOnce(
// mapGetFileError 把 FAA `GET /files/{key}` 的非 2xx 對應到 sentinel + 是否 retryable。 // mapGetFileError 把 FAA `GET /files/{key}` 的非 2xx 對應到 sentinel + 是否 retryable。
// //
// 對齊 FAA Program.cs MapGet("/files/{**objectKey}") 的失敗回應: // Phase 0.8b 對齊 ADR-015 §3.5.2 FAA middleware
// - 401 invalid_token / validation_unavailable → ErrServiceClientUnauthorized不 retry — secret 設定錯) // - 401 unauthorized → ErrFAAAuthFailed不 retry — API key 不對齊;運維事件)
// - 403 tenant_mismatch / object_key_mismatch / method_mismatch → ErrServiceClientUnauthorized不 retry // - 403 forbidden → ErrFAAAuthFailed不 retry
//
// 其他 mapping不變
// - 404 file_not_found → ErrFAAFileNotFound不 retry — object 不存在) // - 404 file_not_found → ErrFAAFileNotFound不 retry — object 不存在)
// - 400 invalid_object_key → ErrFAAUnavailable不 retry — visionA 端 object_key 命名 bug // - 400 invalid_object_key → ErrFAAUnavailable不 retry — visionA 端 object_key 命名 bug
// - 其他 4xx → ErrFAAUnavailable不 retry // - 其他 4xx → ErrFAAUnavailable不 retry
@ -414,7 +425,7 @@ func (c *faaClient) doOnce(
func (c *faaClient) mapGetFileError(status int) (err error, retryable bool) { func (c *faaClient) mapGetFileError(status int) (err error, retryable bool) {
switch { switch {
case status == http.StatusUnauthorized || status == http.StatusForbidden: 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: case status == http.StatusNotFound:
return fmt.Errorf("%w: faa get file %d", ErrFAAFileNotFound, status), false return fmt.Errorf("%w: faa get file %d", ErrFAAFileNotFound, status), false
case status >= 400 && status < 500: case status >= 400 && status < 500:

View File

@ -2,20 +2,22 @@
// //
// 測試策略: // 測試策略:
// - 用 httptest.Server mock FAA 的 GET /files/{key} 端點 // - 用 httptest.Server mock FAA 的 GET /files/{key} 端點
// - 用 stub MCTokenClient直接回 token / 注入錯誤),不耦合真實 mc_token_client 邏輯 // - **Phase 0.8b**:直接用 string fake API keyfakeFAAAPIKey定義在 converter_client_test.go
// 不再注入 stub MCTokenClient
// - 用 atomic counter 驗 retry 行為Phase A retrymax 3 attempts = 1 + 2 retries // - 用 atomic counter 驗 retry 行為Phase A retrymax 3 attempts = 1 + 2 retries
// - streaming 驗證用較大但合理大小10MB— 真 100MB 會拖慢 test runner 太多 // - streaming 驗證用較大但合理大小10MB— 真 100MB 會拖慢 test runner 太多
// //
// 測試範疇對應 conversion.md §9.1FAA GET /files retry max 2 次, 1s/2s // 測試範疇對應 conversion.md §9.1FAA GET /files retry max 2 次, 1s/2s+ ADR-015 §3 認證
// - GetFile_Success / GetFile_Streaming / GetFile_AuthHeader // - 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_5xx_RetryThenSuccess / GetFile_5xx_Exhausted
// - GetFile_Network_RetryThenSuccess / GetFile_Network_Exhausted // - GetFile_Network_RetryThenSuccess / GetFile_Network_Exhausted
// - GetFile_ContextCancel / GetFile_ContextCancel_DuringRetry // - GetFile_ContextCancel / GetFile_ContextCancel_DuringRetry
// - GetFile_ServiceTokenFailure_Propagated / GetFile_EmptyObjectKey // - GetFile_EmptyObjectKey / GetFile_400_GenericError / HashObjectKey_StableAndLength
// - 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.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 package conversion
import ( import (
@ -37,17 +39,15 @@ import (
// FAA mock server helpers // FAA mock server helpers
// ========================================================================== // ==========================================================================
// newFAAClientForTest 建立指向 mock server 的 FAAClient(使用快速 retry backoff 加速 test // newFAAClientForTest 建立指向 mock server 的 FAAClient
// //
// 注意:這個 helper 用較短 backoff10ms 起跳)讓 retry test 不會跑很久 // Phase 0.8b:直接傳 fakeFAAAPIKey定義在 converter_client_test.go不再透過 MCTokenClient
// 真實 production 走 §9.1 的 1s/2s在 NewFAAClient 預設) // 用較短 timeout 加速 test。注意 streaming test 不能用整體 Timeout所以另外覆寫
func newFAAClientForTest(t *testing.T, baseURL string, tokens MCTokenClient) FAAClient { func newFAAClientForTest(t *testing.T, baseURL string) FAAClient {
t.Helper() t.Helper()
return NewFAAClient(FAAClientOpts{ return NewFAAClient(FAAClientOpts{
BaseURL: baseURL, BaseURL: baseURL,
Tokens: tokens, APIKey: fakeFAAAPIKey,
// 用一個簡單的 http.Clienthttptest.Server.Client 也可以但這樣更貼近真實情境,
// 用較短 timeout 加速 test。注意 streaming test 不能用整體 Timeout所以另外覆寫。
HTTPClient: &http.Client{Timeout: 5 * time.Second}, HTTPClient: &http.Client{Timeout: 5 * time.Second},
Logger: silentLogger(), Logger: silentLogger(),
}) })
@ -61,7 +61,6 @@ func newFAAClientForTest(t *testing.T, baseURL string, tokens MCTokenClient) FAA
func TestGetFile_Success(t *testing.T) { func TestGetFile_Success(t *testing.T) {
t.Parallel() t.Parallel()
tokens := newStubTokenClient("svc-tok")
payload := []byte("binary payload here") payload := []byte("binary payload here")
var receivedAuth string var receivedAuth string
@ -78,7 +77,7 @@ func TestGetFile_Success(t *testing.T) {
srv := httptest.NewServer(mux) srv := httptest.NewServer(mux)
t.Cleanup(srv.Close) 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") file, err := fc.GetFile(context.Background(), "tenant/jobs/abc/output.nef")
require.NoError(t, err) require.NoError(t, err)
@ -95,8 +94,8 @@ func TestGetFile_Success(t *testing.T) {
require.NoError(t, readErr) require.NoError(t, readErr)
assert.Equal(t, payload, body) assert.Equal(t, payload, body)
assert.Equal(t, "Bearer svc-tok", receivedAuth, "Bearer service token 必須透傳") assert.Equal(t, "Bearer "+fakeFAAAPIKey, receivedAuth,
assert.Equal(t, 1, tokens.calls(scopeFAADownloadRead)) "Phase 0.8b:必須直接帶 pre-shared API key不經 MC token cache")
} }
// TestGetFile_Streamingmock 回 10MB bodyconfirm caller 能 streaming 讀(不 buffer 全 RAM // TestGetFile_Streamingmock 回 10MB bodyconfirm caller 能 streaming 讀(不 buffer 全 RAM
@ -108,7 +107,6 @@ func TestGetFile_Success(t *testing.T) {
func TestGetFile_Streaming(t *testing.T) { func TestGetFile_Streaming(t *testing.T) {
t.Parallel() t.Parallel()
tokens := newStubTokenClient("svc-tok")
const totalSize = int64(10 * 1024 * 1024) // 10MB const totalSize = int64(10 * 1024 * 1024) // 10MB
mux := http.NewServeMux() mux := http.NewServeMux()
@ -125,7 +123,7 @@ func TestGetFile_Streaming(t *testing.T) {
// streaming download 不能用 http.Client.Timeout會中斷 body streaming // streaming download 不能用 http.Client.Timeout會中斷 body streaming
fc := NewFAAClient(FAAClientOpts{ fc := NewFAAClient(FAAClientOpts{
BaseURL: srv.URL, BaseURL: srv.URL,
Tokens: tokens, APIKey: fakeFAAAPIKey,
// 這裡用無 timeout 的 clienttest 自己控) // 這裡用無 timeout 的 clienttest 自己控)
HTTPClient: &http.Client{}, HTTPClient: &http.Client{},
Logger: silentLogger(), Logger: silentLogger(),
@ -148,11 +146,15 @@ func TestGetFile_Streaming(t *testing.T) {
assert.Equal(t, totalSize, written, "streaming download 必須拿到完整 body") assert.Equal(t, totalSize, written, "streaming download 必須拿到完整 body")
} }
// TestGetFile_AuthHeader驗 Bearer token 透傳,且取 token scope 為 files:download.read。 // TestGetFile_AuthHeaderPhase 0.8b — 驗 pre-shared API key 直接帶在 Authorization header。
//
// 用客製 APIKey與 fakeFAAAPIKey 不同的字串),確認 client 真的透傳「建構時拿到的 key」、
// 而不是 hardcode 某個常數。
func TestGetFile_AuthHeader(t *testing.T) { func TestGetFile_AuthHeader(t *testing.T) {
t.Parallel() t.Parallel()
tokens := newStubTokenClient("specific-token-xyz") const customKey = "custom-faa-key-do-not-use-in-prod-ccccccccccccccccccccccccccccc"
var receivedAuth string var receivedAuth string
var receivedAccept string var receivedAccept string
@ -167,16 +169,20 @@ func TestGetFile_AuthHeader(t *testing.T) {
srv := httptest.NewServer(mux) srv := httptest.NewServer(mux)
t.Cleanup(srv.Close) 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") file, err := fc.GetFile(context.Background(), "key")
require.NoError(t, err) require.NoError(t, err)
defer file.Body.Close() defer file.Body.Close()
_, _ = io.ReadAll(file.Body) _, _ = 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, "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) { func TestGetFile_404_NoRetry(t *testing.T) {
t.Parallel() t.Parallel()
tokens := newStubTokenClient("svc-tok")
var attempts atomic.Int32 var attempts atomic.Int32
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) { 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) srv := httptest.NewServer(mux)
t.Cleanup(srv.Close) t.Cleanup(srv.Close)
fc := newFAAClientForTest(t, srv.URL, tokens) fc := newFAAClientForTest(t, srv.URL)
file, err := fc.GetFile(context.Background(), "missing.nef") file, err := fc.GetFile(context.Background(), "missing.nef")
require.Error(t, err) require.Error(t, err)
@ -213,54 +218,60 @@ func TestGetFile_404_NoRetry(t *testing.T) {
assert.Equal(t, 502, HTTPStatus(err)) assert.Equal(t, 502, HTTPStatus(err))
} }
// TestGetFile_401_Unauthorizedmock 回 401 → 不 retryreturn ErrServiceClientUnauthorized。 // TestGetFile_AuthFailed401Phase 0.8b — mock 回 401 → 不 retryreturn ErrFAAAuthFailed。
func TestGetFile_401_Unauthorized(t *testing.T) { //
// 觸發情境VISIONA_FAA_API_KEY 與 FAA 端 FAA_API_KEY 不對齊rotate 未同步 / env 設錯)。
// 對外仍 mask 成 faa_unavailable / 502避免洩漏「API key 不對」內部運維狀態。
func TestGetFile_AuthFailed401(t *testing.T) {
t.Parallel() t.Parallel()
tokens := newStubTokenClient("svc-tok")
var attempts atomic.Int32 var attempts atomic.Int32
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) {
attempts.Add(1) attempts.Add(1)
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized) w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":{"code":"invalid_token"}}`)) _, _ = w.Write([]byte(`{"error":"unauthorized"}`))
}) })
srv := httptest.NewServer(mux) srv := httptest.NewServer(mux)
t.Cleanup(srv.Close) t.Cleanup(srv.Close)
fc := newFAAClientForTest(t, srv.URL, tokens) fc := newFAAClientForTest(t, srv.URL)
file, err := fc.GetFile(context.Background(), "k") file, err := fc.GetFile(context.Background(), "k")
require.Error(t, err) require.Error(t, err)
require.Nil(t, file) require.Nil(t, file)
assert.True(t, errors.Is(err, ErrServiceClientUnauthorized), assert.True(t, errors.Is(err, ErrFAAAuthFailed),
"401 → ErrServiceClientUnauthorizedclient 認證設定錯)") "Phase 0.8b401 必須 mapping 到新 sentinel ErrFAAAuthFailed")
// Phase 0.8b T3舊 sentinel ErrServiceClientUnauthorized 已移除,
// 改由 ErrFAAAuthFailed 接管 401/403 mapping。
assert.Equal(t, int32(1), attempts.Load(), assert.Equal(t, int32(1), attempts.Load(),
"401 不應 retrysecret 設定錯retry 也是 401") "401 不應 retryAPI key 不對 retry 也是 401")
// 對外仍 mask 成 faa_unavailable
assert.Equal(t, "faa_unavailable", ErrorCode(err))
assert.Equal(t, 502, HTTPStatus(err))
} }
// TestGetFile_403_UnauthorizedFAA 端 tenant_mismatch / object_key_mismatch 等 403 都同類處理。 // TestGetFile_AuthFailed403對稱 — FAA 端 403 同樣 ErrFAAAuthFailed、不 retry
func TestGetFile_403_Unauthorized(t *testing.T) { func TestGetFile_AuthFailed403(t *testing.T) {
t.Parallel() t.Parallel()
tokens := newStubTokenClient("svc-tok")
var attempts atomic.Int32 var attempts atomic.Int32
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) {
attempts.Add(1) attempts.Add(1)
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden) w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`{"error":{"code":"tenant_mismatch"}}`)) _, _ = w.Write([]byte(`{"error":"unauthorized"}`))
}) })
srv := httptest.NewServer(mux) srv := httptest.NewServer(mux)
t.Cleanup(srv.Close) t.Cleanup(srv.Close)
fc := newFAAClientForTest(t, srv.URL, tokens) fc := newFAAClientForTest(t, srv.URL)
_, err := fc.GetFile(context.Background(), "k") _, err := fc.GetFile(context.Background(), "k")
require.Error(t, err) 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") 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) { func TestGetFile_400_GenericError(t *testing.T) {
t.Parallel() t.Parallel()
tokens := newStubTokenClient("svc-tok")
var attempts atomic.Int32 var attempts atomic.Int32
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) { 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) srv := httptest.NewServer(mux)
t.Cleanup(srv.Close) t.Cleanup(srv.Close)
fc := newFAAClientForTest(t, srv.URL, tokens) fc := newFAAClientForTest(t, srv.URL)
_, err := fc.GetFile(context.Background(), "invalid//key") _, err := fc.GetFile(context.Background(), "invalid//key")
require.Error(t, err) require.Error(t, err)
@ -301,7 +311,6 @@ func TestGetFile_400_GenericError(t *testing.T) {
func TestGetFile_5xx_RetryThenSuccess(t *testing.T) { func TestGetFile_5xx_RetryThenSuccess(t *testing.T) {
t.Parallel() t.Parallel()
tokens := newStubTokenClient("svc-tok")
var attempts atomic.Int32 var attempts atomic.Int32
payload := []byte("recovered after retry") payload := []byte("recovered after retry")
@ -321,7 +330,7 @@ func TestGetFile_5xx_RetryThenSuccess(t *testing.T) {
srv := httptest.NewServer(mux) srv := httptest.NewServer(mux)
t.Cleanup(srv.Close) t.Cleanup(srv.Close)
fc := newFAAClientForTest(t, srv.URL, tokens) fc := newFAAClientForTest(t, srv.URL)
start := time.Now() start := time.Now()
file, err := fc.GetFile(context.Background(), "k") 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) { func TestGetFile_5xx_Exhausted(t *testing.T) {
t.Parallel() t.Parallel()
tokens := newStubTokenClient("svc-tok")
var attempts atomic.Int32 var attempts atomic.Int32
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) { 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) srv := httptest.NewServer(mux)
t.Cleanup(srv.Close) t.Cleanup(srv.Close)
fc := newFAAClientForTest(t, srv.URL, tokens) fc := newFAAClientForTest(t, srv.URL)
_, err := fc.GetFile(context.Background(), "k") _, err := fc.GetFile(context.Background(), "k")
require.Error(t, err) require.Error(t, err)
@ -378,7 +386,6 @@ func TestGetFile_5xx_Exhausted(t *testing.T) {
func TestGetFile_Network_RetryThenSuccess(t *testing.T) { func TestGetFile_Network_RetryThenSuccess(t *testing.T) {
t.Parallel() t.Parallel()
tokens := newStubTokenClient("svc-tok")
var attempts atomic.Int32 var attempts atomic.Int32
payload := []byte("recovered from net error") payload := []byte("recovered from net error")
@ -405,7 +412,7 @@ func TestGetFile_Network_RetryThenSuccess(t *testing.T) {
srv := httptest.NewServer(mux) srv := httptest.NewServer(mux)
t.Cleanup(srv.Close) t.Cleanup(srv.Close)
fc := newFAAClientForTest(t, srv.URL, tokens) fc := newFAAClientForTest(t, srv.URL)
file, err := fc.GetFile(context.Background(), "k") file, err := fc.GetFile(context.Background(), "k")
require.NoError(t, err) require.NoError(t, err)
@ -424,7 +431,6 @@ func TestGetFile_Network_RetryThenSuccess(t *testing.T) {
func TestGetFile_Network_Exhausted(t *testing.T) { func TestGetFile_Network_Exhausted(t *testing.T) {
t.Parallel() t.Parallel()
tokens := newStubTokenClient("svc-tok")
// 拿一個 free port 立刻關掉dial 必失敗) // 拿一個 free port 立刻關掉dial 必失敗)
ln, err := net.Listen("tcp", "127.0.0.1:0") ln, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err) require.NoError(t, err)
@ -433,7 +439,7 @@ func TestGetFile_Network_Exhausted(t *testing.T) {
fc := NewFAAClient(FAAClientOpts{ fc := NewFAAClient(FAAClientOpts{
BaseURL: "http://" + addr, BaseURL: "http://" + addr,
Tokens: tokens, APIKey: fakeFAAAPIKey,
// 用較短 timeout但仍要大於 retry 退避總和1s + 2s = 3s— 設 10s 安全 // 用較短 timeout但仍要大於 retry 退避總和1s + 2s = 3s— 設 10s 安全
HTTPClient: &http.Client{Timeout: 10 * time.Second}, HTTPClient: &http.Client{Timeout: 10 * time.Second},
Logger: silentLogger(), Logger: silentLogger(),
@ -459,7 +465,6 @@ func TestGetFile_Network_Exhausted(t *testing.T) {
func TestGetFile_ContextCancel(t *testing.T) { func TestGetFile_ContextCancel(t *testing.T) {
t.Parallel() t.Parallel()
tokens := newStubTokenClient("svc-tok")
// mock serverhandler 故意 sleep讓 ctx cancel 在 server response 前發生) // mock serverhandler 故意 sleep讓 ctx cancel 在 server response 前發生)
mux := http.NewServeMux() mux := http.NewServeMux()
@ -473,7 +478,7 @@ func TestGetFile_ContextCancel(t *testing.T) {
srv := httptest.NewServer(mux) srv := httptest.NewServer(mux)
t.Cleanup(srv.Close) t.Cleanup(srv.Close)
fc := newFAAClientForTest(t, srv.URL, tokens) fc := newFAAClientForTest(t, srv.URL)
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
go func() { go func() {
time.Sleep(50 * time.Millisecond) time.Sleep(50 * time.Millisecond)
@ -497,7 +502,6 @@ func TestGetFile_ContextCancel(t *testing.T) {
func TestGetFile_ContextCancel_DuringRetry(t *testing.T) { func TestGetFile_ContextCancel_DuringRetry(t *testing.T) {
t.Parallel() t.Parallel()
tokens := newStubTokenClient("svc-tok")
var attempts atomic.Int32 var attempts atomic.Int32
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) { 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) srv := httptest.NewServer(mux)
t.Cleanup(srv.Close) t.Cleanup(srv.Close)
fc := newFAAClientForTest(t, srv.URL, tokens) fc := newFAAClientForTest(t, srv.URL)
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
go func() { go func() {
@ -535,52 +539,34 @@ func TestGetFile_ContextCancel_DuringRetry(t *testing.T) {
} }
// ========================================================================== // ==========================================================================
// Token 失敗透傳 // Constructor fail-fast
// ========================================================================== // ==========================================================================
// TestGetFile_ServiceTokenFailure_PropagatedMCTokenClient 失敗 → 透傳原 sentinel。 // TestNewFAAClient_Panics_When_APIKey_Emptyfail-fast 驗證 — Phase 0.8b
// 不允許 server 在「未認證」狀態下啟動,建構式必須立即 panic。
// //
// 對應 mc_token_client.go 的 ErrIDPMisconfigured / ErrServiceClientUnauthorized / ErrIDPUnavailable // 對齊 ADR-015 §3.5.3 部署檢查清單 #1。
// 不應被 faa_client 升級成 ErrFAAUnavailable會丟失 i18n 區分 idp_misconfig vs idp_down vs faa_down //
func TestGetFile_ServiceTokenFailure_Propagated(t *testing.T) { // Phase 0.8b 之前的 `TestGetFile_ServiceTokenFailure_Propagated` 已移除:
// API key 改造後 ServiceToken 不再被呼叫「token 取不到」這個失敗路徑結構性消失,
// 原測試的前提不存在;對應的失敗模式變成「建構時 fail-fast」由本測試覆蓋。
func TestNewFAAClient_Panics_When_APIKey_Empty(t *testing.T) {
t.Parallel() t.Parallel()
cases := []struct { defer func() {
name string r := recover()
tokenErr error require.NotNil(t, r, "APIKey 為空時必須 panicfail-fast")
}{ err, ok := r.(error)
{"idp_misconfigured", ErrIDPMisconfigured}, require.True(t, ok, "panic value 應為 error 型別")
{"service_client_unauthorized", ErrServiceClientUnauthorized}, assert.True(t, errors.Is(err, ErrFAAAPIKeyNotConfigured),
{"idp_unavailable", ErrIDPUnavailable}, "panic 應為 ErrFAAAPIKeyNotConfigured sentinel")
} }()
for _, tc := range cases { _ = NewFAAClient(FAAClientOpts{
tc := tc BaseURL: "http://example.com",
t.Run(tc.name, func(t *testing.T) { APIKey: "", // empty — 必須觸發 panic
t.Parallel() Logger: silentLogger(),
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")
})
}
} }
// ========================================================================== // ==========================================================================
@ -591,18 +577,16 @@ func TestGetFile_ServiceTokenFailure_Propagated(t *testing.T) {
func TestGetFile_EmptyObjectKey(t *testing.T) { func TestGetFile_EmptyObjectKey(t *testing.T) {
t.Parallel() t.Parallel()
tokens := newStubTokenClient("svc-tok")
fc := NewFAAClient(FAAClientOpts{ fc := NewFAAClient(FAAClientOpts{
BaseURL: "http://invalid", BaseURL: "http://invalid",
Tokens: tokens, APIKey: fakeFAAAPIKey,
Logger: silentLogger(), Logger: silentLogger(),
}) })
_, err := fc.GetFile(context.Background(), "") _, err := fc.GetFile(context.Background(), "")
require.Error(t, err) require.Error(t, err)
// 不需走網路就應該 failtoken 沒被呼叫) assert.Contains(t, err.Error(), "object_key is required",
assert.Equal(t, 0, tokens.calls(scopeFAADownloadRead), "empty object_key 應立即 fail不打網路")
"empty object_key 應立即 fail不該打 token endpoint")
} }
// ========================================================================== // ==========================================================================

View File

@ -101,19 +101,21 @@ type Storage interface {
// ========================================================================== // ==========================================================================
// flow 是 Service interface 的預設實作(不對外 exportcaller 拿 interface // flow 是 Service interface 的預設實作(不對外 exportcaller 拿 interface
//
// Phase 0.8b 變更ADR-015 §6 / conversion.md §3
// - 移除 mcToken服務間認證已改 pre-shared API keyAPI key 內含於 ConverterClient / FAAClient
// - 移除 tenantIDMC delegated download token 機制取消,不再需要 tenant 概念
// - 移除 faaBaseURLDownloadStream 走 faa.GetFileFAAClient 內含 baseURL不再自組 FAA URL
// - 移除 delegatedTTLSecondsdelegated download token 取消
type flow struct { type flow struct {
converter ConverterClient converter ConverterClient
faa FAAClient faa FAAClient
mcToken MCTokenClient
ownership Ownership ownership Ownership
modelStore ModelStore modelStore ModelStore
storage Storage storage Storage
tenantID string
faaBaseURL string
defaultJobExpiryDuration time.Duration defaultJobExpiryDuration time.Duration
delegatedTTLSeconds int
logger *slog.Logger logger *slog.Logger
now func() time.Time now func() time.Time
@ -121,33 +123,23 @@ type flow struct {
// FlowOpts 是 NewService 的依賴注入。 // FlowOpts 是 NewService 的依賴注入。
// //
// 必填Converter / FAA / MCToken / Ownership / ModelStore / Storage / TenantID / FAABaseURL。 // 必填Converter / FAA / Ownership / ModelStore / Storage。其他 optionalnil/0 自動填合理預設)。
// 其他 optionalnil/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 { type FlowOpts struct {
// 4 個 clientT2-T5 // 3 個 client + 1 個 ownership storeT3 / T4 / T5
Converter ConverterClient Converter ConverterClient
FAA FAAClient FAA FAAClient
MCToken MCTokenClient
Ownership Ownership Ownership Ownership
// 既有 visionA 套件的 narrow adapter // 既有 visionA 套件的 narrow adapter
ModelStore ModelStore ModelStore ModelStore
Storage Storage Storage Storage
// MC delegated download 用的 tenant idvisionA 在 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 天)。 // converter 沒回 expires_at 時自行推算的 fallback duration預設 7 天)。
DefaultJobExpiryDuration time.Duration DefaultJobExpiryDuration time.Duration
// MC delegated download token TTL。0 → 預設 3005 分鐘)。
// 對齊 conversion.md §10.2,建議範圍 60-900。
DelegatedTTLSeconds int
Logger *slog.Logger Logger *slog.Logger
Now func() time.Time Now func() time.Time
} }
@ -162,9 +154,6 @@ func NewService(opts FlowOpts) (Service, error) {
if opts.FAA == nil { if opts.FAA == nil {
return nil, errors.New("conversion: FlowOpts.FAA is required") 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 { if opts.Ownership == nil {
return nil, errors.New("conversion: FlowOpts.Ownership is required") return nil, errors.New("conversion: FlowOpts.Ownership is required")
} }
@ -174,21 +163,11 @@ func NewService(opts FlowOpts) (Service, error) {
if opts.Storage == nil { if opts.Storage == nil {
return nil, errors.New("conversion: FlowOpts.Storage is required") 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 expiry := opts.DefaultJobExpiryDuration
if expiry <= 0 { if expiry <= 0 {
expiry = 7 * 24 * time.Hour // 對齊 converter 7 天 GC§2.6.2 expiry = 7 * 24 * time.Hour // 對齊 converter 7 天 GC§2.6.2
} }
ttl := opts.DelegatedTTLSeconds
if ttl <= 0 {
ttl = 300
}
logger := opts.Logger logger := opts.Logger
if logger == nil { if logger == nil {
logger = slog.Default() logger = slog.Default()
@ -201,14 +180,10 @@ func NewService(opts FlowOpts) (Service, error) {
return &flow{ return &flow{
converter: opts.Converter, converter: opts.Converter,
faa: opts.FAA, faa: opts.FAA,
mcToken: opts.MCToken,
ownership: opts.Ownership, ownership: opts.Ownership,
modelStore: opts.ModelStore, modelStore: opts.ModelStore,
storage: opts.Storage, storage: opts.Storage,
tenantID: opts.TenantID,
faaBaseURL: strings.TrimRight(opts.FAABaseURL, "/"),
defaultJobExpiryDuration: expiry, defaultJobExpiryDuration: expiry,
delegatedTTLSeconds: ttl,
logger: logger, logger: logger,
now: nowFn, now: nowFn,
}, nil }, 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}/downloadPhase 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 // 1. ownership 驗(不符 → ErrJobNotFound§7.2 防枚舉
// 2. converter.GetJob — 確認 status=completed // 2. converter.GetJob — 確認 status=completed(否則 ErrJobNotCompleted
// 3. ensurePromoted — 自動觸發 promote若還沒 promote 過),拿到 target_object_key // 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 / download都拿同一個 target_object_key」+
// 「不會與 promote-to-models 衝突;兩者內部都會 ensurePromoted冪等」— // 「不會與 promote-to-models 衝突;兩者內部都會 ensurePromoted冪等」—
// 要求 user 先按 promote-to-models 才能下載會違背「下載」按鈕的直覺語意。 // 要求 user 先按 promote-to-models 才能下載會違背「下載」按鈕的直覺語意。
// 4. mcToken.IssueDelegatedDownload — 換 opaque token (TTL 5min 預設) // 4. faa.GetFile — 用 pre-shared API key 直接拉 NEF stream不再經 MC delegated token
// 5. 組 https://<faa>/files/<key>?access_token=<token> // 5. 回傳 (io.ReadCloser, *DownloadMetadata, nil)callerhandler負責 io.Copy 到 client + Close
// //
// 安全§10.4 // Phase 0.8b vs Phase 0.8 安全模型差異(見 conversion.md §10.4 + ADR-015 §10.4
// - token 不出現在任何 JSON responsecaller 走 server-side 302 redirect // - Phase 0.8MC simple delegated token + 302 redirect → browser 直連 FAAtoken 短暫流經 browser
// - object_key 不對 frontend 揭露 // - Phase 0.8bvisionA backend 中轉 stream → 沒有 token 結構性存在於任何 frontend response
func (f *flow) DownloadRedirectURL(ctx context.Context, userID, jobID string) (string, error) { //
// Callerhandler責任避免 fd / goroutine leak
// - **必須 defer stream.Close()**
// - 設好 response headerContent-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 == "" { if userID == "" {
return "", errors.New("conversion: DownloadRedirectURL requires userID") return nil, nil, errors.New("conversion: DownloadStream requires userID")
} }
if jobID == "" { if jobID == "" {
return "", ErrJobNotFound return nil, nil, ErrJobNotFound
} }
// 1. ownership 驗 // 1. ownership 驗
if err := f.ownership.EnsureRebuilt(ctx, userID); err != nil { if err := f.ownership.EnsureRebuilt(ctx, userID); err != nil {
// fail-softrebuild 失敗不直接擋cache 可能 stale 但仍可能有合法 entry
// 後面 Get / GetJob 還會把實際錯誤帶上來
f.logger.WarnContext(ctx, "conversion.flow.download_ownership_rebuild_failed", f.logger.WarnContext(ctx, "conversion.flow.download_ownership_rebuild_failed",
slog.String("user_hash", hashUserID(userID)), slog.String("user_hash", hashUserID(userID)),
slog.String("err", err.Error()), 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) owner, ok := f.ownership.Get(jobID)
if !ok || owner != userID { if !ok || owner != userID {
return "", ErrJobNotFound return nil, nil, ErrJobNotFound
} }
// 2. converter.GetJob 確認 completed // 2. converter.GetJob 確認 completed
cj, err := f.converter.GetJob(ctx, jobID) cj, err := f.converter.GetJob(ctx, jobID)
if err != nil { if err != nil {
return "", err return nil, nil, err
} }
if cj.Status != "completed" { 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 // 3. ensurePromoted — 自動觸發 promote 拿 target_object_keyconverter 端冪等)
// Phase 0.8 不 cache promoted_object_keyconverter 端 promote 是冪等的,
// 重複呼叫成本可接受 — 反正 download 路徑 user 主動觸發頻率不高)
targetObjectKey, err := f.ensurePromoted(ctx, userID, jobID, cj) targetObjectKey, err := f.ensurePromoted(ctx, userID, jobID, cj)
if err != nil { if err != nil {
return "", err return nil, nil, err
} }
// 4. mcToken 換 delegated download token // 4. faa.GetFile — 用 pre-shared API key streaming pull
delegated, err := f.mcToken.IssueDelegatedDownload(ctx, IssueDownloadReq{ file, err := f.faa.GetFile(ctx, targetObjectKey)
TenantID: f.tenantID,
UserID: userID,
ObjectKey: targetObjectKey,
ExpiresInSeconds: f.delegatedTTLSeconds,
})
if err != nil { if err != nil {
return "", err return nil, nil, err
} }
// 5. 組 URLFAA base + /files/<key>?access_token=<token> // 5. 組 metadatafilename 沿用 PromoteToModels 的命名規則(`<stem>_<chip>.nef`
// - object_key 用 url.PathEscape 處理(含路徑分隔符的 key 安全 escape contentType := file.ContentType
// - token 用 url.QueryEscape雖 opaque token 通常不含特殊字元,仍 escape 防呆) if contentType == "" {
downloadURL := fmt.Sprintf("%s/files/%s?access_token=%s", // FAA 未設 → 給安全預設octet-stream 必觸發 browser download dialog
f.faaBaseURL, contentType = "application/octet-stream"
escapeObjectKeyPath(targetObjectKey), }
url.QueryEscape(delegated.Token), 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("user_hash", hashUserID(userID)),
slog.String("job_id", jobID), slog.String("job_id", jobID),
slog.String("object_key_hash", hashObjectKey(targetObjectKey)), 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 // 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}` 來說 // url.PathEscape 會把 '/' 也 escape 成 %2F — 對 FAA `/files/{**objectKey}` 來說
// 應該保留 '/' 為路徑分隔符,所以拆段後逐段 escape 再合回。 // 應該保留 '/' 為路徑分隔符,所以拆段後逐段 escape 再合回。
//
// **Phase 0.8b 後 production code 無 caller**(僅 flow_test.go 引用)。
//
// 保留原因ADR-015 §7 選項 BPhase 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 { func escapeObjectKeyPath(objectKey string) string {
parts := strings.Split(objectKey, "/") parts := strings.Split(objectKey, "/")
for i := range parts { for i := range parts {

View File

@ -1,19 +1,21 @@
// flow_test.go — Service interface 整合層的單元測試。 // 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 實作) // - 沿用 ownership_test.go 的 stubConverterClient補上 InitJob/GetJob/Promote 實作)
// - 用本檔案專屬的 stubFAAClient / stubMCTokenClient / stubModelStore / stubStorage // - 用本檔案專屬的 stubFAAClient / stubModelStore / stubStorage
// //
// 涵蓋 5 個 method × happy / ownership 失敗 / client 失敗 propagation + // 涵蓋 5 個 method × happy / ownership 失敗 / client 失敗 propagation +
// task spec 額外要求: // task spec 額外要求:
// - InitJob 同 user 已有 active → ActiveJobError // - InitJob 同 user 已有 active → ActiveJobError
// - PromoteToModels 已 promote 過 → 回既有 model_ididempotent // - PromoteToModels 已 promote 過 → 回既有 model_ididempotent
// - PromoteToModels job 沒 succeeded → ErrJobNotCompleted // - PromoteToModels job 沒 succeeded → ErrJobNotCompleted
// - DownloadRedirectURL URL 組裝正確(含 url.PathEscape / url.QueryEscape // - DownloadStream 從 FAA stream 拉到正確 metadataPhase 0.8b:取代原 DownloadRedirectURL URL 組裝
// - ActiveJob converter 回 404 → ownership.Delete + (nil, nil) // - ActiveJob converter 回 404 → ownership.Delete + (nil, nil)
// //
// Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §2.7) // Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §2.7)
// Phase 0.8b T4DownloadRedirectURL → DownloadStream + 砍 flowStubMCToken
// (見 ADR-015 §6 + conversion.md §3 / §4.1)
package conversion package conversion
import ( import (
@ -171,42 +173,8 @@ func (s *flowStubFAA) GetFile(ctx context.Context, objectKey string) (*FAAFile,
var _ FAAClient = (*flowStubFAA)(nil) var _ FAAClient = (*flowStubFAA)(nil)
// flowStubMCToken 是 MCTokenClient stub。 // Phase 0.8b T4原 flowStubMCToken 已整段刪除MC 認證鏈取消、flow 不再依賴 MCTokenClient
type flowStubMCToken struct { // Phase 0.8b T5mc_token_stub.go 整檔砍除MCTokenClient interface 已不存在。
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)
// flowStubModelStore 是 ModelStore stub。 // flowStubModelStore 是 ModelStore stub。
type flowStubModelStore struct { type flowStubModelStore struct {
@ -306,17 +274,17 @@ type flowFixture struct {
svc Service svc Service
converter *flowStubConverter converter *flowStubConverter
faa *flowStubFAA faa *flowStubFAA
mcToken *flowStubMCToken
models *flowStubModelStore models *flowStubModelStore
storage *flowStubStorage storage *flowStubStorage
ownership Ownership ownership Ownership
} }
// Phase 0.8b T4mcToken 欄位已移除flow 不再依賴 MCTokenClientFlowOpts 也砍 4 個欄位
// MCToken / TenantID / FAABaseURL / DelegatedTTLSeconds
func newFlowFixture(t *testing.T) *flowFixture { func newFlowFixture(t *testing.T) *flowFixture {
t.Helper() t.Helper()
conv := newFlowStubConverter() conv := newFlowStubConverter()
faa := newFlowStubFAA() faa := newFlowStubFAA()
mcToken := newFlowStubMCToken()
models := newFlowStubModelStore() models := newFlowStubModelStore()
storage := newFlowStubStorage() storage := newFlowStubStorage()
own := NewOwnership(conv, newSilentLogger()) own := NewOwnership(conv, newSilentLogger())
@ -324,14 +292,10 @@ func newFlowFixture(t *testing.T) *flowFixture {
svc, err := NewService(FlowOpts{ svc, err := NewService(FlowOpts{
Converter: conv, Converter: conv,
FAA: faa, FAA: faa,
MCToken: mcToken,
Ownership: own, Ownership: own,
ModelStore: models, ModelStore: models,
Storage: storage, Storage: storage,
TenantID: "visiona-tenant",
FAABaseURL: "https://faa.example.com",
DefaultJobExpiryDuration: 7 * 24 * time.Hour, DefaultJobExpiryDuration: 7 * 24 * time.Hour,
DelegatedTTLSeconds: 300,
Logger: newSilentLogger(), Logger: newSilentLogger(),
Now: time.Now, Now: time.Now,
}) })
@ -341,7 +305,6 @@ func newFlowFixture(t *testing.T) *flowFixture {
svc: svc, svc: svc,
converter: conv, converter: conv,
faa: faa, faa: faa,
mcToken: mcToken,
models: models, models: models,
storage: storage, storage: storage,
ownership: own, ownership: own,
@ -373,11 +336,12 @@ func makeMultipartBody(t *testing.T, clientUserID string) (body io.Reader, conte
// Constructor — 缺欄位驗證 // Constructor — 缺欄位驗證
// ========================================================================== // ==========================================================================
// Phase 0.8b T4TenantID / FAABaseURL / MCToken 欄位已從 FlowOpts 砍除;
// 必填欄位降為 5 個Converter / FAA / Ownership / ModelStore / Storage
func TestNewService_RequiredFields(t *testing.T) { func TestNewService_RequiredFields(t *testing.T) {
t.Parallel() t.Parallel()
conv := newFlowStubConverter() conv := newFlowStubConverter()
faa := newFlowStubFAA() faa := newFlowStubFAA()
mc := newFlowStubMCToken()
own := NewOwnership(conv, newSilentLogger()) own := NewOwnership(conv, newSilentLogger())
mod := newFlowStubModelStore() mod := newFlowStubModelStore()
st := newFlowStubStorage() st := newFlowStubStorage()
@ -386,14 +350,11 @@ func TestNewService_RequiredFields(t *testing.T) {
name string name string
opts FlowOpts opts FlowOpts
}{ }{
{"missing converter", FlowOpts{FAA: faa, MCToken: mc, Ownership: own, ModelStore: mod, Storage: st, TenantID: "t", FAABaseURL: "https://x"}}, {"missing converter", FlowOpts{FAA: faa, Ownership: own, ModelStore: mod, Storage: st}},
{"missing faa", FlowOpts{Converter: conv, MCToken: mc, Ownership: own, ModelStore: mod, Storage: st, TenantID: "t", FAABaseURL: "https://x"}}, {"missing faa", FlowOpts{Converter: conv, Ownership: own, ModelStore: mod, Storage: st}},
{"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, ModelStore: mod, Storage: st}},
{"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, Ownership: own, Storage: st}},
{"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, Ownership: own, ModelStore: mod}},
{"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"}},
} }
for _, tt := range tests { for _, tt := range tests {
tt := tt tt := tt
@ -409,24 +370,20 @@ func TestNewService_DefaultsApplied(t *testing.T) {
t.Parallel() t.Parallel()
conv := newFlowStubConverter() conv := newFlowStubConverter()
faa := newFlowStubFAA() faa := newFlowStubFAA()
mc := newFlowStubMCToken()
own := NewOwnership(conv, newSilentLogger()) own := NewOwnership(conv, newSilentLogger())
mod := newFlowStubModelStore() mod := newFlowStubModelStore()
st := newFlowStubStorage() st := newFlowStubStorage()
svc, err := NewService(FlowOpts{ svc, err := NewService(FlowOpts{
Converter: conv, FAA: faa, MCToken: mc, Ownership: own, Converter: conv, FAA: faa, Ownership: own,
ModelStore: mod, Storage: st, ModelStore: mod, Storage: st,
TenantID: "visiona", FAABaseURL: "https://faa.example.com/", // DefaultJobExpiryDuration 留空 → 應 fallback 7d
// DefaultJobExpiryDuration / DelegatedTTLSeconds 留空 → 應 fallback
}) })
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, svc) require.NotNil(t, svc)
f := svc.(*flow) f := svc.(*flow)
assert.Equal(t, 7*24*time.Hour, f.defaultJobExpiryDuration) 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 // DownloadStreamPhase 0.8b:取代原 DownloadRedirectURL
// ========================================================================== // ==========================================================================
//
// Phase 0.8b 變更ADR-015 §7 + conversion.md §4.1
// - DownloadRedirectURL → DownloadStreamAPI key 模式下沒有 MC delegated token
// - 不再組「FAA URL + ?access_token=」;改成直接回 io.ReadCloser + DownloadMetadata
// - 不再依賴 MCTokenClientflowStubMCToken 已整段刪除)
// - 對外仍由同一個 GET /api/conversion/{job_id}/download endpoint 觸發handler 層改 stream proxy
//
// 測試 case 對齊原 6 個 happy / ownership / state / error propagation 路徑:
// 1. HappyPath成功拉到 stream + metadata 正確
// 2. SpecialCharsuser_id / job_id 含特殊字元時 buildTargetObjectKey 正確 + filename 安全
// 3. OwnershipMismatch→ ErrJobNotFound
// 4. JobNotCompleted→ ErrJobNotCompleted
// 5. PromoteError_Propagationpromote 5xx 透傳
// 6. FAAError_Propagation取代 MCErrorFAA pull 失敗透傳
// TestDownloadRedirectURL_HappyPathURL 組裝正確task spec 要求)。 // TestDownloadStream_HappyPath成功 → 拿到 io.ReadCloser + DownloadMetadata 正確
func TestDownloadRedirectURL_HappyPath(t *testing.T) { func TestDownloadStream_HappyPath(t *testing.T) {
t.Parallel() t.Parallel()
fix := newFlowFixture(t) fix := newFlowFixture(t)
fix.converter.setJob(&ConverterJob{ 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") 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.NoError(t, err)
require.NotNil(t, stream)
require.NotNil(t, meta)
defer stream.Close()
// FAA base + /files/<key>?access_token=<token> // metadata 對齊 stub FAA 的 default 行為faa_client_test.go newFlowStubFAA
// key = "models/user-alice/j1.nef"token = "opaque-stub-token-xyz" assert.Equal(t, "yolov5s_kl720.nef", meta.Filename,
assert.Equal(t, "filename = <stem>_<chip>.nef對齊 wireframe §8.1 + defaultDownloadFilename")
"https://faa.example.com/files/models/user-alice/j1.nef?access_token=opaque-stub-token-xyz", assert.Equal(t, "application/octet-stream", meta.ContentType)
url, assert.Equal(t, int64(len("nef-bytes-stub")), meta.ContentLength)
)
// 驗 IssueDelegatedDownload 帶到的參數 // stream 內容與 stub FAA 預設 body 一致streaming pull
fix.mcToken.mu.Lock() body, err := io.ReadAll(stream)
in := fix.mcToken.lastIssueInput require.NoError(t, err)
fix.mcToken.mu.Unlock() assert.Equal(t, "nef-bytes-stub", string(body))
require.NotNil(t, in)
assert.Equal(t, "visiona-tenant", in.TenantID) // 驗 faa.GetFile 帶到的 object_keybuildTargetObjectKey 規則models/<user>/<job>.nef
assert.Equal(t, "user-alice", in.UserID) fix.faa.mu.Lock()
assert.Equal(t, "models/user-alice/j1.nef", in.ObjectKey) gotKey := fix.faa.lastKey
assert.Equal(t, 300, in.ExpiresInSeconds) fix.faa.mu.Unlock()
assert.Equal(t, "models/user-alice/j1.nef", gotKey)
} }
// TestDownloadRedirectURL_EscapeSpecialChars特殊字元的 user_id / job_id 走 escape。 // TestDownloadStream_FilenameFromConverterJobfilename 取自 cj.SourceFilename + Platform
func TestDownloadRedirectURL_EscapeSpecialChars(t *testing.T) { // 而非從 FAA metadata 拿API key 模式下 FAA URL 不再含原檔名)。
func TestDownloadStream_FilenameFromConverterJob(t *testing.T) {
t.Parallel() t.Parallel()
fix := newFlowFixture(t) fix := newFlowFixture(t)
fix.mcToken.issueDelegatedDownloadFunc = func(ctx context.Context, in IssueDownloadReq) (*DelegatedDownloadToken, error) { fix.converter.setJob(&ConverterJob{
// 模擬 token 含特殊字元 JobID: "j1",
return &DelegatedDownloadToken{ Status: "completed",
Token: "abc def+/=", CreatedAt: time.Now(),
ExpiresAt: time.Now().Add(5 * time.Minute), SourceFilename: "/path/to/my_model.tflite", // 有 path prefix → 應只取 stem
}, nil Platform: "520",
} })
// 用合法但帶 special char 的 user_idOIDC sub 通常不會這樣,但要 defensive fix.ownership.Set("j1", "user-alice")
userID := "user with space"
fix.converter.setJob(&ConverterJob{JobID: "j1", Status: "completed", CreatedAt: time.Now()})
fix.ownership.Set("j1", userID)
url, err := fix.svc.DownloadRedirectURL(context.Background(), userID, "j1") _, meta, err := fix.svc.DownloadStream(context.Background(), "user-alice", "j1")
require.NoError(t, err) require.NoError(t, err)
// path 段 user_id 應 escape' ' → %20 require.NotNil(t, meta)
assert.Contains(t, url, "/files/models/user%20with%20space/j1.nef") assert.Equal(t, "my_model_kl520.nef", meta.Filename)
// token 段應 query escape'+' / '=' / '/' / ' '
assert.Contains(t, url, "?access_token=abc+def%2B%2F%3D")
} }
// TestDownloadRedirectURL_OwnershipMismatchnot_found。 // TestDownloadStream_DefaultsContentTypeFAA 沒給 Content-Type → 預設 octet-stream
func TestDownloadRedirectURL_OwnershipMismatch(t *testing.T) { // (確保 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() t.Parallel()
fix := newFlowFixture(t) fix := newFlowFixture(t)
fix.converter.setJob(&ConverterJob{JobID: "j1", Status: "completed", CreatedAt: time.Now()}) fix.converter.setJob(&ConverterJob{JobID: "j1", Status: "completed", CreatedAt: time.Now()})
fix.ownership.Set("j1", "user-bob") 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) require.Error(t, err)
assert.True(t, errors.Is(err, ErrJobNotFound)) 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_JobNotCompletedstill running → ErrJobNotCompleted。 // TestDownloadStream_JobNotCompletedstill running → ErrJobNotCompleted。
func TestDownloadRedirectURL_JobNotCompleted(t *testing.T) { func TestDownloadStream_JobNotCompleted(t *testing.T) {
t.Parallel() t.Parallel()
fix := newFlowFixture(t) fix := newFlowFixture(t)
fix.converter.setJob(&ConverterJob{JobID: "j1", Status: "running", CreatedAt: time.Now()}) fix.converter.setJob(&ConverterJob{JobID: "j1", Status: "running", CreatedAt: time.Now()})
fix.ownership.Set("j1", "user-alice") 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) require.Error(t, err)
assert.True(t, errors.Is(err, ErrJobNotCompleted)) assert.True(t, errors.Is(err, ErrJobNotCompleted))
assert.Nil(t, stream)
assert.Nil(t, meta)
} }
// TestDownloadRedirectURL_PromoteError_Propagationpromote 5xx 透傳。 // TestDownloadStream_PromoteError_Propagationpromote 5xx 透傳。
func TestDownloadRedirectURL_PromoteError_Propagation(t *testing.T) { func TestDownloadStream_PromoteError_Propagation(t *testing.T) {
t.Parallel() t.Parallel()
fix := newFlowFixture(t) fix := newFlowFixture(t)
@ -1136,25 +1143,54 @@ func TestDownloadRedirectURL_PromoteError_Propagation(t *testing.T) {
return nil, fmt.Errorf("%w: promote 502", ErrConverterUnavailable) 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) require.Error(t, err)
assert.True(t, errors.Is(err, ErrConverterUnavailable)) assert.True(t, errors.Is(err, ErrConverterUnavailable))
// FAA 不該被打到promote 失敗在 FAA call 之前)
assert.Equal(t, int32(0), fix.faa.getCalls.Load())
} }
// TestDownloadRedirectURL_MCError_PropagationMC delegated 5xx 透傳。 // TestDownloadStream_FAAError_PropagationFAA pull 5xx 透傳(取代原 MCError test
func TestDownloadRedirectURL_MCError_Propagation(t *testing.T) { //
// Phase 0.8b 後 download path 不再經 MCFAA stream 失敗是最常見的失敗模式
// API key 不對齊 → ErrFAAAuthFailedFAA 服務不可達 → ErrFAAUnavailable
func TestDownloadStream_FAAError_Propagation(t *testing.T) {
t.Parallel() t.Parallel()
fix := newFlowFixture(t) fix := newFlowFixture(t)
fix.converter.setJob(&ConverterJob{JobID: "j1", Status: "completed", CreatedAt: time.Now()}) fix.converter.setJob(&ConverterJob{JobID: "j1", Status: "completed", CreatedAt: time.Now()})
fix.ownership.Set("j1", "user-alice") fix.ownership.Set("j1", "user-alice")
fix.mcToken.issueDelegatedDownloadFunc = func(ctx context.Context, in IssueDownloadReq) (*DelegatedDownloadToken, error) { fix.faa.getFileFunc = func(ctx context.Context, objectKey string) (*FAAFile, error) {
return nil, fmt.Errorf("%w: mc 5xx", ErrMCTokenUnavailable) 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) 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_PropagationFAA 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 仍是 ErrFAAAuthFailedhandler 層才 mask 對外")
// 驗 sentinel 可被 errors.As 解出handler 用 conversion.HTTPStatus / ErrorCode 處理)
assert.Equal(t, "faa_unavailable", ErrorCode(err),
"ErrorCode helper 對 ErrFAAAuthFailed 應 mask 成 faa_unavailable對外不洩漏 auth_failed")
} }
// ========================================================================== // ==========================================================================

View File

@ -1,624 +0,0 @@
// MC token client — visionA-backend 對 Member Center 取兩種 token
// - service tokenclient_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.jsNode 版同模式,
// 已在 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 → ErrServiceClientUnauthorized500/idp_misconfigured 對外)
// 其他 4xx → ErrIDPMisconfigured500/idp_misconfiguredi18n=idp_misconfig
// 5xx / network 持續失敗 → ErrIDPUnavailable503/idp_unavailablei18n=idp_down
//
// - `IssueDelegatedDownload`(打 MC `/file-access/download-tokens`
// 401/403 → ErrServiceClientUnauthorized
// 其他 4xx → ErrDownloadTokenFailed502/download_token_failedi18n=token_failed
// 5xx / network 持續失敗 → ErrMCTokenUnavailable502/mc_token_unavailablei18n=token_failed
//
// 兩 endpoint 的 4xx / 5xx 用不同 sentinel — 因為 §6 的 i18n 訊息設計區分了
// 「IDP 設定錯誤」「IDP 暫時不可用」「下載授權失敗」「MC 不可達」四種不同的 user-facing 提示
// (前者引導使用者「聯絡支援」,後者引導「稍後再試」)。
//
// goroutine-safecache 用 sync.MutexDCL 確保併發 fetch 只發一次 request。
type MCTokenClient interface {
// ServiceToken 取一個 access tokenclient_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 tokenscope=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 預設 3005 分鐘),可在 caller 指定(範圍由 caller 自行檢查)
type IssueDownloadReq struct {
TenantID string
UserID string
ObjectKey string
ExpiresInSeconds int // <= 0 時自動套用預設 300
}
// DelegatedDownloadToken 是 MC 簽出來的 short-lived token。
//
// Token 是 opaqueFAA 收到後再對 MC validatevisionA-backend 不解碼。
type DelegatedDownloadToken struct {
Token string
ExpiresAt time.Time
}
// MCTokenClientOpts 是 NewMCTokenClient 的依賴注入。
//
// HTTPClient / Now / Logger 為 optionalnil 自動填預設)— 方便 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 為 optionalnil 用預設timeout 10s。測試會注入 httptest.Server.Client()。
HTTPClient *http.Client
// Now 為 optionalnil 用 time.Now。測試會注入 fake clock 控制 cache 過期。
Now func() time.Time
// Logger 為 optionalnil 用 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 timeoutdialer + response 整體)。
httpTimeout = 10 * time.Second
// maxRetries 是 5xx / network / timeout 的最大重試次數(不含第一次)。
// 對齊 conversion.md §9.1MC oauth/token 與 file-access/download-tokens 都 max 2 次。
maxRetries = 2
// retryBaseDelay 是指數退避的 base1s, 2s
retryBaseDelay = 1 * time.Second
// defaultDelegatedTTL 是 IssueDelegatedDownload 預設 TTLcaller 不傳就 300
defaultDelegatedTTL = 300
)
// cachedToken 是 ServiceToken cache 內部結構。
type cachedToken struct {
token string
expiresAt time.Time
}
// mcTokenClient 是 MCTokenClient 的預設實作。
//
// 套件內 unexported structcaller 拿 interface讓未來換實作不影響 caller。
type mcTokenClient struct {
issuer string
clientID string
clientSecret string
http *http.Client
now func() time.Time
logger *slog.Logger
// cache 由 mu 保護key=scopemulti-scope string 直接當 key
// 不做 normalize — caller 應傳穩定排序的 scope 字串)。
mu sync.Mutex // sync.Mutex 比 RWMutex 簡單fetch 路徑 IO boundRWMutex 沒有實質好處
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 後 returnfast 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 個 scopeper-scope 鎖的好處邊際
// - 簡單性 > 微優化;若未來 profiling 顯示瓶頸再改 sync.Map + per-scope mutex
//
// 為什麼不用 sync.Once
// - sync.Once 不能 resetcache 過期後要重 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 pathcache 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 shapeRFC 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 → ErrIDPMisconfiguredIDP 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 → ErrMCTokenUnavailableMC 不可達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 額外 contextscope 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 responseclient errorretry 沒用)
// - 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() 不會含 secrethttp.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 / 403client 認證失敗 — 不可重試(重試也會繼續 401
// 兩個 endpoint 都用同一個 sentinelcaller 可用 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 次 retryn 從 1 開始)的等待時間。
// 1 → 1s, 2 → 2s對齊 conversion.md §9.1
//
// 不加 jitter — Phase 0.8 預期同時 fetch 的 caller 已被 DCL 收斂到單一執行,
// 不會有大量併發打 MCjitter 邊際效益低。
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)"
}

View File

@ -1,864 +0,0 @@
// MC Token Client 單元測試。
//
// 測試策略:
// - 用 httptest.Server mock MCaccept 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 counteratomic可用來驗 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.Valueskey 是 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.Mapr.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 同時要 tokenDCL 確保只 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)
// §6MC 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)
// §6MC 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 應立即 returncontext.Canceled 或 ErrIDPUnavailable wrapgot %v", err)
// counter 可能是 1server 收到了但 client 在等回應時 cancel不應該 retry
assert.LessOrEqual(t, counter.Load(), int32(1),
"ctx cancel 不應 retrycounter <= 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)
// §6service_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)
// §6service_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)
// §6MC 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)
// §6MC 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 mapping401/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 mappingservice_token 4xx (非 401/403) → ErrIDPMisconfigured500/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")
}

View File

@ -0,0 +1,16 @@
// 測試共用 helperconverter_client_test / faa_client_test / 其他 conversion package
// 內 _test.go 共用),原本住在 mc_token_client_test.goPhase 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))
}

View File

@ -0,0 +1,17 @@
// Package conversion 內部 utility helpers。
//
// 此檔收容跨檔共用的小型 helperlog truncate 等),原本散落在
// mc_token_client.goPhase 0.8b T3 砍 mc_token_client 整個檔後搬到此獨立檔,
// 避免 truncate 隨 mc_token_client.go 一起被砍(仍被 converter_client.go /
// faa_client.go 的 log error 拼接使用)。
//
// 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)"
}