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_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
VISIONA_AUTH_TYPE=static
@ -71,6 +76,20 @@ VISIONA_STORAGE_SIGNING_SECRET=dev-signing-secret-change-me-32-bytes
VISIONA_PAIRING_TOKEN=
# ============================================================
# Phase 0.8 / 0.8b — 轉檔功能整合dev 通常不啟用,留空即可)
# ============================================================
# 對齊 .autoflow/04-architecture/conversion.md §3 + ADR-015。
#
# 4 個欄位全部非空才會啟用 conversion 模組dev 全空 = sidebar tab 顯示但 endpoint 不註冊。
# 若要在 dev 連 stage 的 converter / FAA 測整合,依 .env.stage.example 模板填入。
#
# 產生 API 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 衝突時可改
# ============================================================

View File

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

View File

@ -498,7 +498,12 @@ func conversionDownloadHandler(deps Deps) gin.HandlerFunc {
if meta.SizeBytes > 0 {
c.Header("Content-Length", strconv.FormatInt(meta.SizeBytes, 10))
}
// 鼓勵 browser 觸發 save 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("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
c.Status(http.StatusOK)
@ -1009,3 +1014,4 @@ frontend 用 `<a href>` 觸發時,若失敗 browser 會把錯誤頁顯示在
| 2026-04-30 | 0.2 | Download flow 改為 server-side HTTP 302 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-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
VISIONA_FRONTEND_URL=http://localhost:3000
# Service clientclient_credentials grant— A1 預留欄位,**目前不啟用**。
# 將來 visionA-backend 需以服務身份呼叫 MC API 時(例如查詢使用者組織、推送通知)
# 才會接這條路。留空代表「不啟用」main.go 不會 wire。
# 對應 Stage 的 service client<see stage .env.stage>
VISIONA_OIDC_SERVICE_CLIENT_ID=
VISIONA_OIDC_SERVICE_CLIENT_SECRET=
# Phase 0.8b 移除VISIONA_OIDC_SERVICE_CLIENT_ID / _SECRET
# 服務間認證從 OAuth client_credentials 改為 pre-shared API key見 ADR-015、conversion.md §3
# 兩個 service client env 不再讀取OIDCConfig.ServiceClientID/Secret struct 欄位
# 為了 backward compat 暫保留、但 conversion 模組不再依賴)。
# 取代設定見下方 Phase 0.8 / 0.8b 區塊的 VISIONA_CONVERTER_API_KEY / VISIONA_FAA_API_KEY。
# Cookie HMAC 簽章 secret 至少 32 byte 隨機字串prod 用 openssl rand -hex 32
VISIONA_SESSION_SECRET=CHANGE_ME_TO_RANDOM_64_BYTES_in_production
@ -158,14 +157,19 @@ VISIONA_PAIRING_TOKEN=
# ============================================================
# Phase 0.8 — 轉檔功能整合converter / FAA / Member Center service token
# Phase 0.8 / 0.8b — 轉檔功能整合converter / FAA pre-shared API key
# ============================================================
# 對齊 .autoflow/04-architecture/conversion.md §5.3
# 對齊 .autoflow/04-architecture/conversion.md §3 + ADR-015。
#
# 啟用判定:當 VISIONA_CONVERTER_BASE_URL 與 VISIONA_FAA_BASE_URL 都非空時,
# main.go 才會 wire conversion.Service其中之一留空 → 5 個 /api/conversion/* endpoint 回 501。
# Phase 0.8b 變更:服務間認證從 OAuth client_credentials 改為 pre-shared API key。
#
# 啟用時 VISIONA_OIDC_SERVICE_CLIENT_ID/SECRET 必須非空(轉檔依賴 service token 機制)。
# 啟用判定4 個欄位ConverterBaseURL / FAABaseURL / ConverterAPIKey / FAAAPIKey
# **全部非空**才視為啟用;任一缺 → 5 個 /api/conversion/* endpoint 不註冊 / 回 501。
#
# Phase 0.8b 移除(不再讀取):
# - VISIONA_OIDC_SERVICE_CLIENT_ID / _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
# dev/stagehttp://192.168.0.130:9501
@ -177,13 +181,16 @@ VISIONA_CONVERTER_BASE_URL=
# prodhttps://faa.innovedus.com
VISIONA_FAA_BASE_URL=
# visionA 在 Member Center 的 tenant id單一 tenant
# 跟 MC 換 delegated download token 時當 tenant_id 欄位用
VISIONA_OIDC_TENANT_ID=
# Pre-shared API key — visionA → converter 服務間認證Phase 0.8b 新增ADR-015 §3
# 產生openssl rand -hex 3264 字元 hex
# 與 converter 端 CONVERTER_API_KEY env 對齊(雙方獨立持有,嚴格分環境 dev / stage / prod
# ⚠️ 不可 commitprod 用 Secrets Manager / Vaultlog 永遠不印此值全文
VISIONA_CONVERTER_API_KEY=
# Delegated download token TTL— FAA 直連下載用
# 預設 3005 分鐘),可調整範圍 60-900
VISIONA_FAA_DELEGATED_TTL_SECONDS=300
# Pre-shared API key — visionA → FAA 服務間認證Phase 0.8b 新增ADR-015 §3
# 產生方式同上;與 FAA 端 FAA_API_KEY env 對齊warrenchen 配置)
# 與 ConverterAPIKey **不共用**(每條 trust boundary 各自獨立,避免一處洩漏連坐)
VISIONA_FAA_API_KEY=
# 上傳模型檔大小上限MB— 與 converter 端 limit 對齊
VISIONA_CONVERTER_MAX_MODEL_SIZE_MB=500

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

View File

@ -137,35 +137,29 @@ func main() {
// ===== ConverterstubPhase 2 才實作) =====
converterClient := converter.NewStubClient()
// ===== Phase 0.8 Conversion轉檔功能整合 =====
// 對齊 .autoflow/04-architecture/conversion.md
// ===== Phase 0.8 / 0.8b Conversion轉檔功能整合 =====
// 對齊 .autoflow/04-architecture/conversion.md、ADR-015
//
// 啟用條件cfg.Conversion.Enabled() — ConverterBaseURL + FAABaseURL 都非空。
// 啟用時必須有 ServiceClientID/Secretclient_credentials grant 必要)
// 啟用條件cfg.Conversion.Enabled() —
// ConverterBaseURL + FAABaseURL + ConverterAPIKey + FAAAPIKey 全部非空
// 不啟用時 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
if cfg.Conversion.Enabled() {
// service token 機制依賴 ServiceClientID/Secret — 沒設就 fatal避免半設定狀態
if cfg.OIDC.ServiceClientID == "" || cfg.OIDC.ServiceClientSecret == "" {
log.Error("conversion enabled but service client credentials missing",
"hint", "set VISIONA_OIDC_SERVICE_CLIENT_ID + VISIONA_OIDC_SERVICE_CLIENT_SECRET, or unset CONVERTER/FAA base URL to disable")
os.Exit(1)
}
// 不再檢查 ServiceClientID/Secret —— Phase 0.8b 起 conversion 不依賴 OIDC service client。
// OIDCConfig.ServiceClientID/Secret 兩欄位仍保留供 backward compat但非 conversion 必需。)
mcTokenClient := conversion.NewMCTokenClient(conversion.MCTokenClientOpts{
Issuer: cfg.OIDC.IssuerURL,
ClientID: cfg.OIDC.ServiceClientID,
ClientSecret: cfg.OIDC.ServiceClientSecret,
Logger: log,
})
converterAPIClient := conversion.NewConverterClient(conversion.ConverterClientOpts{
BaseURL: cfg.Conversion.ConverterBaseURL,
Tokens: mcTokenClient,
APIKey: cfg.Conversion.ConverterAPIKey,
Logger: log,
})
faaAPIClient := conversion.NewFAAClient(conversion.FAAClientOpts{
BaseURL: cfg.Conversion.FAABaseURL,
Tokens: mcTokenClient,
APIKey: cfg.Conversion.FAAAPIKey,
Logger: log,
})
ownership := conversion.NewOwnership(converterAPIClient, log)
@ -176,16 +170,12 @@ func main() {
var convErr error
conversionService, convErr = conversion.NewService(conversion.FlowOpts{
Converter: converterAPIClient,
FAA: faaAPIClient,
MCToken: mcTokenClient,
Ownership: ownership,
ModelStore: modelStoreAdapter,
Storage: storageAdapter,
TenantID: cfg.Conversion.TenantID,
FAABaseURL: cfg.Conversion.FAABaseURL,
DelegatedTTLSeconds: cfg.Conversion.DelegatedTTLSeconds,
Logger: log,
Converter: converterAPIClient,
FAA: faaAPIClient,
Ownership: ownership,
ModelStore: modelStoreAdapter,
Storage: storageAdapter,
Logger: log,
})
if convErr != nil {
log.Error("failed to init conversion service", "error", convErr)
@ -194,10 +184,11 @@ func main() {
log.Info("conversion service initialized",
"converter_base_url", cfg.Conversion.ConverterBaseURL,
"faa_base_url", cfg.Conversion.FAABaseURL,
"tenant_id", cfg.Conversion.TenantID,
"delegated_ttl_sec", cfg.Conversion.DelegatedTTLSeconds)
// 安全:絕不印 key 全文 — 對齊 ADR-015 §3.5.3 部署檢查清單 #4
"converter_api_key_set", cfg.Conversion.ConverterAPIKey != "",
"faa_api_key_set", cfg.Conversion.FAAAPIKey != "")
} else {
log.Info("conversion service disabled (set VISIONA_CONVERTER_BASE_URL + VISIONA_FAA_BASE_URL to enable)")
log.Info("conversion service disabled (set VISIONA_CONVERTER_BASE_URL + VISIONA_FAA_BASE_URL + VISIONA_CONVERTER_API_KEY + VISIONA_FAA_API_KEY to enable)")
}
// ===== Seed demo data可選 =====

View File

@ -11,16 +11,18 @@
// GET /api/conversion/active — 查當前 active job
// GET /api/conversion/{job_id} — poll 狀態
// POST /api/conversion/{job_id}/promote-to-models — 加到模型庫
// GET /api/conversion/{job_id}/download — server-side 302 redirect → FAA
// GET /api/conversion/{job_id}/download — server-side stream proxyPhase 0.8b
//
// 安全要點(對齊 conversion.md §7 / §10
// - 全部 5 個 endpoint 都註冊在 apiGroupOIDC AuthMiddleware 之後)
// - userID 一律來自 UserContextFrom(c).UserID從 cookie session 解出 OIDC sub
// - 任何 client 帶來的 user_idmultipart form / JSON / query一律忽略
// - /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.8b /download proxy 改造 (見 ADR-015 + conversion.md §4.1)
package api
@ -28,7 +30,9 @@ import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
@ -259,14 +263,22 @@ func conversionPromoteHandler(deps Deps) gin.HandlerFunc {
// 5. GET /api/conversion/{job_id}/download
// ==========================================================================
// conversionDownloadHandler 處理「下載」請求 — server-side HTTP 302 redirect
// conversionDownloadHandler 處理「下載」請求 — Phase 0.8bserver-side stream proxy
//
// 對齊 api-conversion.md §4 + conversion.md §3.1 / §10.4
// - 成功:302 Found + Location: <FAA URL with access_token>
// - 失敗:不 redirect依 Accept header 回 JSON / HTML 錯誤
// - Cache-Control: no-store — token 不該被 browser cache即使是 302 Location
// 對齊 api-conversion.md §4 (Phase 0.8b 變更) + conversion.md §4.1 / §10.4 + ADR-015 §7
// - 成功:200 OK + Content-Type/Length/Disposition + NEF binary streaming body
// - 失敗:不寫 200依 sentinel 走 handleConversionError 回 JSON
// - Cache-Control: no-store — 避免 browser 對私有檔案 cache
//
// 仿 FAA TestSite `DownloadFileDirect` 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 {
return func(c *gin.Context) {
uc, ok := UserContextFrom(c)
@ -283,23 +295,106 @@ func conversionDownloadHandler(deps Deps) gin.HandlerFunc {
return
}
downloadURL, err := deps.Conversion.DownloadRedirectURL(c.Request.Context(), uc.UserID, jobID)
stream, meta, err := deps.Conversion.DownloadStream(c.Request.Context(), uc.UserID, jobID)
if err != nil {
// 錯誤情況不 redirect — 依 Accept header 回 JSON / HTMLWriteError 寫 JSON
// 已能滿足主要 caseanchor tag 觸發時 browser 會直接顯示 JSON 也 OK
// Phase 0.8 不額外做 HTML 錯誤頁)
// 200 還沒寫,可以正常回 JSON error依 Accept header
handleConversionError(c, err)
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("Pragma", "no-cache")
// 302 Found不用 301 — 301 可能被某些 browser 永久 cache
c.Redirect(http.StatusFound, downloadURL)
c.Status(http.StatusOK)
// streaming proxy — 不 ReadAll、不暫存 disk
// 中斷錯誤只能 log已 200 + part of body無法回頭改 status
//
// Phase 0.8b 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
// ==========================================================================

View File

@ -47,7 +47,10 @@ type stubConversionService struct {
GetJobFn func(ctx context.Context, userID, jobID string) (*conversion.Job, error)
ActiveJobFn func(ctx context.Context, userID string) (*conversion.Job, error)
PromoteFn func(ctx context.Context, userID, jobID, name string) (*conversion.PromoteResult, error)
DownloadFn func(ctx context.Context, userID, jobID string) (string, error)
// Phase 0.8b 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) {
@ -92,13 +95,13 @@ func (s *stubConversionService) PromoteToModels(ctx context.Context, userID, job
return s.PromoteFn(ctx, userID, jobID, name)
}
func (s *stubConversionService) DownloadRedirectURL(ctx context.Context, userID, jobID string) (string, error) {
func (s *stubConversionService) DownloadStream(ctx context.Context, userID, jobID string) (io.ReadCloser, *conversion.DownloadMetadata, error) {
s.mu.Lock()
s.lastUserID = userID
s.lastJobID = jobID
s.mu.Unlock()
if s.DownloadFn == nil {
return "", errors.New("stub: DownloadFn not set")
return nil, nil, errors.New("stub: DownloadFn not set")
}
return s.DownloadFn(ctx, userID, jobID)
}
@ -511,13 +514,21 @@ func TestConversion_Promote_JobNotCompleted(t *testing.T) {
// 5. GET /api/conversion/{job_id}/download
// ==========================================================================
func TestConversion_Download_HappyPath302(t *testing.T) {
target := "http://192.168.0.130:5081/files/models/u/job.nef?access_token=opaque-xyz"
// Phase 0.8b T4handler 改成 server-side stream proxyAPI key 模式下沒有 delegated token
// 對應 ADR-015 §7 + conversion.md §4.1 + api-conversion.md §4 (Phase 0.8b 變更)。
//
// 成功 response 從「302 + Location」改為「200 + Content-Disposition: attachment + NEF binary stream」。
func TestConversion_Download_HappyPath_StreamProxy(t *testing.T) {
const nefPayload = "fake-nef-binary-bytes-stub-content-12345"
svc := &stubConversionService{
DownloadFn: func(ctx context.Context, userID, jobID string) (string, error) {
DownloadFn: func(ctx context.Context, userID, jobID string) (io.ReadCloser, *conversion.DownloadMetadata, error) {
require.Equal(t, "demo-user", userID)
require.Equal(t, "job-abc", jobID)
return target, nil
return io.NopCloser(strings.NewReader(nefPayload)), &conversion.DownloadMetadata{
Filename: "yolov5s_kl720.nef",
ContentType: "application/octet-stream",
ContentLength: int64(len(nefPayload)),
}, nil
},
}
r := newConversionFixture(t, svc)
@ -526,18 +537,77 @@ func TestConversion_Download_HappyPath302(t *testing.T) {
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusFound, w.Code) // 302
assert.Equal(t, target, w.Header().Get("Location"))
// 防快取 header — token 不該被 browser cache§10.4
// 200 OK + NEF binary in bodyPhase 0.8b:不再 302 redirect
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "application/octet-stream", w.Header().Get("Content-Type"))
// Content-Disposition: attachment 觸發 browser download dialog
assert.Equal(t, `attachment; filename="yolov5s_kl720.nef"`, w.Header().Get("Content-Disposition"))
// Content-Length 與 stub stream 一致
assert.Equal(t, "40", w.Header().Get("Content-Length"))
// 防快取NEF 是 private 檔案)
assert.Contains(t, w.Header().Get("Cache-Control"), "no-store")
assert.Equal(t, "no-cache", w.Header().Get("Pragma"))
// Body bytes 與 stub stream 完全一致streaming proxy
assert.Equal(t, nefPayload, w.Body.String())
}
// TestConversion_Download_HappyPath_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) {
svc := &stubConversionService{
DownloadFn: func(ctx context.Context, userID, jobID string) (string, error) {
return "", conversion.ErrJobNotCompleted
DownloadFn: func(ctx context.Context, userID, jobID string) (io.ReadCloser, *conversion.DownloadMetadata, error) {
return nil, nil, conversion.ErrJobNotCompleted
},
}
r := newConversionFixture(t, svc)
@ -546,16 +616,17 @@ func TestConversion_Download_JobNotCompleted(t *testing.T) {
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
// 錯誤情況**不 redirect** — 回標準 JSON error
// 錯誤情況回標準 JSON error200 還沒寫,可以 set status
assert.Equal(t, http.StatusConflict, w.Code)
assert.Contains(t, w.Body.String(), "job_not_completed")
assert.NotEqual(t, http.StatusFound, w.Code, "error case must not 302 redirect")
assert.NotEqual(t, http.StatusOK, w.Code, "error case must not 200 stream")
assert.NotEqual(t, http.StatusFound, w.Code, "Phase 0.8b: 也不再 302 redirect")
}
func TestConversion_Download_NotFound(t *testing.T) {
svc := &stubConversionService{
DownloadFn: func(ctx context.Context, userID, jobID string) (string, error) {
return "", conversion.ErrJobNotFound
DownloadFn: func(ctx context.Context, userID, jobID string) (io.ReadCloser, *conversion.DownloadMetadata, error) {
return nil, nil, conversion.ErrJobNotFound
},
}
r := newConversionFixture(t, svc)
@ -566,10 +637,15 @@ func TestConversion_Download_NotFound(t *testing.T) {
assert.Equal(t, http.StatusNotFound, w.Code)
}
func TestConversion_Download_MCTokenUnavailable(t *testing.T) {
// TestConversion_Download_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{
DownloadFn: func(ctx context.Context, userID, jobID string) (string, error) {
return "", conversion.ErrMCTokenUnavailable
DownloadFn: func(ctx context.Context, userID, jobID string) (io.ReadCloser, *conversion.DownloadMetadata, error) {
return nil, nil, conversion.ErrFAAUnavailable
},
}
r := newConversionFixture(t, svc)
@ -578,7 +654,119 @@ func TestConversion_Download_MCTokenUnavailable(t *testing.T) {
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadGateway, w.Code)
assert.Contains(t, w.Body.String(), "mc_token_unavailable")
assert.Contains(t, w.Body.String(), "faa_unavailable")
}
// TestConversion_Download_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"
}
// ConversionConfig 控制 Phase 0.8 轉檔功能整合。
// ConversionConfig 控制 Phase 0.8 / 0.8b 轉檔功能整合。
//
// 對齊 .autoflow/04-architecture/conversion.md §5.3
// 對齊 .autoflow/04-architecture/conversion.md §3、`adr/adr-015-server-to-server-api-key.md`
//
// 啟用判定(由 main.go 在 wire 階段檢查):當 ConverterBaseURL 與 FAABaseURL 都非空時,
// 才會 wire conversion.Service 進 api.Deps。其中之一為空 → 不啟用5 個 endpoint 回 501
// 啟用判定(由 Enabled() 給 main.go 用Phase 0.8b 起4 個欄位ConverterBaseURL /
// FAABaseURL / ConverterAPIKey / FAAAPIKey**全部非空**才視為啟用;任一缺即視為未啟用,
// 5 個 /api/conversion/* endpoint 不會 wiremain.go 在 wire 階段跳過、log warn
//
// 進一步:啟用時 ServiceClientID/Secret 必須非空(轉檔依賴 service token 機制);
// 不對齊時 main.go fatal log 退出(避免半設定狀態跑進生產)。
// **Phase 0.8b 變更**:服務間認證從 OAuth client_credentials 改為 pre-shared API keyADR-015
// `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 {
// ConverterBaseURL 是 kneron_model_converter task-scheduler 服務的 base URL。
// 例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 轉檔功能。
FAABaseURL string
// TenantID 是 visionA 在 Member Center 註冊的 tenant id單一 tenant
// 在跟 MC 換 delegated download token 時當 request body 的 tenant_id 欄位用。
// 對齊 VISIONA_OIDC_TENANT_ID。
TenantID string
// ConverterAPIKey 是 visionA → converter 服務間認證的 pre-shared API keyPhase 0.8b 新增)。
// 對齊 VISIONA_CONVERTER_API_KEY以 `Authorization: Bearer <key>` 形式帶上。
// 雙方獨立產生(`openssl rand -hex 32`visionA 端的值必須與 converter 端的
// `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
// 預設 3005 分鐘);可調整範圍 60-900。對齊 VISIONA_FAA_DELEGATED_TTL_SECONDS。
// 見 conversion.md §10.2 安全考量。
DelegatedTTLSeconds int
// FAAAPIKey 是 visionA → FAA 服務間認證的 pre-shared API keyPhase 0.8b 新增)。
// 對齊 VISIONA_FAA_API_KEY以 `Authorization: Bearer <key>` 形式帶上。
// 與 ConverterAPIKey **不共用**(每條 trust boundary 各自獨立,避免一處洩漏連坐 — ADR-015 §3
// 對應 FAA 端的 `FAA_API_KEY` env由 warrenchen 配置(跨 repo 同步)。
FAAAPIKey string
// MaxModelSizeMB 是 visionA-backend 端對上傳模型檔的大小上限MB
// 與 converter 端 limit 對齊converter 預設 500 MB
@ -216,11 +228,16 @@ type ConversionConfig struct {
MaxModelSizeMB int
}
// Enabled 回傳 Phase 0.8 conversion 是否啟用。
// Enabled 回傳 Phase 0.8 / 0.8b conversion 是否啟用。
//
// main.go 在 wire 時用此判斷是否要 init conversion.Service。
// **Phase 0.8b 變更**ADR-015 §6除既有的 ConverterBaseURL / FAABaseURL 外,
// 加入 ConverterAPIKey / FAAAPIKey 非空檢查4 個欄位皆非空才算啟用。
// 任一缺 → 視為未啟用main.go 不會 wire conversion.Service5 個 endpoint 回 501 / 不註冊)。
func (c ConversionConfig) Enabled() bool {
return c.ConverterBaseURL != "" && c.FAABaseURL != ""
return c.ConverterBaseURL != "" &&
c.FAABaseURL != "" &&
c.ConverterAPIKey != "" &&
c.FAAAPIKey != ""
}
// CORSConfig 控制 api-server 對瀏覽器的 CORS 白名單。

View File

@ -68,13 +68,16 @@ func Load() *Config {
CORS: CORSConfig{
AllowedOrigins: getEnvStringSlice("VISIONA_CORS_ALLOWED_ORIGINS", nil),
},
// Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §5.3)
// Phase 0.8 / 0.8b conversion (見 .autoflow/04-architecture/conversion.md §3、ADR-015)
// Phase 0.8b T5原暫留欄位 TenantID / DelegatedTTLSeconds 與對應 env
// VISIONA_OIDC_TENANT_ID / VISIONA_FAA_DELEGATED_TTL_SECONDS已移除 —
// MC 認證鏈與 delegated download token 機制不存在了。
Conversion: ConversionConfig{
ConverterBaseURL: getEnvString("VISIONA_CONVERTER_BASE_URL", ""),
FAABaseURL: getEnvString("VISIONA_FAA_BASE_URL", ""),
TenantID: getEnvString("VISIONA_OIDC_TENANT_ID", ""),
DelegatedTTLSeconds: getEnvInt("VISIONA_FAA_DELEGATED_TTL_SECONDS", 300),
MaxModelSizeMB: getEnvInt("VISIONA_CONVERTER_MAX_MODEL_SIZE_MB", 500),
ConverterBaseURL: getEnvString("VISIONA_CONVERTER_BASE_URL", ""),
FAABaseURL: getEnvString("VISIONA_FAA_BASE_URL", ""),
ConverterAPIKey: getEnvString("VISIONA_CONVERTER_API_KEY", ""),
FAAAPIKey: getEnvString("VISIONA_FAA_API_KEY", ""),
MaxModelSizeMB: getEnvInt("VISIONA_CONVERTER_MAX_MODEL_SIZE_MB", 500),
},
}
}

View File

@ -266,14 +266,18 @@ func TestLoad_CORSAllowedOrigins(t *testing.T) {
assert.Nil(t, cfg.CORS.AllowedOrigins)
}
// TestLoad_ConversionDefaults 驗證 Phase 0.8 conversion 欄位的預設行為。
// TestLoad_ConversionDefaults 驗證 Phase 0.8 / 0.8b conversion 欄位的預設行為。
//
// 對齊 .autoflow/04-architecture/conversion.md §5.3留空時 Enabled() 為 false
// 對齊 .autoflow/04-architecture/conversion.md §3 + ADR-015:留空時 Enabled() 為 false
// 5 個 endpoint 不會 wiremain.go 在 wire 階段會跳過)。
//
// Phase 0.8b T5原暫留欄位 TenantID / DelegatedTTLSeconds 已從 ConversionConfig 移除
// MC 認證鏈與 delegated download token 機制不存在了);本 test 不再驗這兩欄位。
func TestLoad_ConversionDefaults(t *testing.T) {
for _, k := range []string{
"VISIONA_CONVERTER_BASE_URL", "VISIONA_FAA_BASE_URL", "VISIONA_OIDC_TENANT_ID",
"VISIONA_FAA_DELEGATED_TTL_SECONDS", "VISIONA_CONVERTER_MAX_MODEL_SIZE_MB",
"VISIONA_CONVERTER_BASE_URL", "VISIONA_FAA_BASE_URL",
"VISIONA_CONVERTER_API_KEY", "VISIONA_FAA_API_KEY",
"VISIONA_CONVERTER_MAX_MODEL_SIZE_MB",
} {
t.Setenv(k, "")
}
@ -281,50 +285,101 @@ func TestLoad_ConversionDefaults(t *testing.T) {
cfg := Load()
assert.Empty(t, cfg.Conversion.ConverterBaseURL)
assert.Empty(t, cfg.Conversion.FAABaseURL)
assert.Empty(t, cfg.Conversion.TenantID)
assert.Equal(t, 300, cfg.Conversion.DelegatedTTLSeconds, "預設 5 分鐘 TTL")
assert.Empty(t, cfg.Conversion.ConverterAPIKey, "Phase 0.8bAPI key 預設留空")
assert.Empty(t, cfg.Conversion.FAAAPIKey, "Phase 0.8bAPI key 預設留空")
assert.Equal(t, 500, cfg.Conversion.MaxModelSizeMB, "預設 500 MB與 converter 對齊)")
assert.False(t, cfg.Conversion.Enabled(), "URL 全空 → 不啟用")
assert.False(t, cfg.Conversion.Enabled(), "全空 → 不啟用")
}
// TestLoad_ConversionEnabled 驗證 Conversion.Enabled() 的判定邏輯。
// TestLoad_ConversionEnabled 驗證 Conversion.Enabled() 的判定邏輯Phase 0.8b 修訂)。
//
// Phase 0.8b 變更4 個欄位Converter URL / FAA URL / Converter API key / FAA API key
// 全部非空才視為啟用;任一缺即 disable。
func TestLoad_ConversionEnabled(t *testing.T) {
cases := []struct {
name string
converter string
faa string
wantEnabled bool
name string
converterURL string
faaURL string
converterKey string
faaKey string
wantEnabled bool
}{
{"both_set_enables", "http://converter:9501", "http://faa:5081", true},
{"only_converter_disabled", "http://converter:9501", "", false},
{"only_faa_disabled", "", "http://faa:5081", false},
{"both_empty_disabled", "", "", false},
{"all_set_enables",
"http://converter:9501", "http://faa:5081",
"converter-key-32-bytes-hex-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"faa-key-32-bytes-hex-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
true},
{"missing_converter_url_disabled",
"", "http://faa:5081",
"converter-key", "faa-key",
false},
{"missing_faa_url_disabled",
"http://converter:9501", "",
"converter-key", "faa-key",
false},
{"missing_converter_key_disabled",
"http://converter:9501", "http://faa:5081",
"", "faa-key",
false},
{"missing_faa_key_disabled",
"http://converter:9501", "http://faa:5081",
"converter-key", "",
false},
{"all_empty_disabled",
"", "", "", "",
false},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Setenv("VISIONA_CONVERTER_BASE_URL", tc.converter)
t.Setenv("VISIONA_FAA_BASE_URL", tc.faa)
t.Setenv("VISIONA_CONVERTER_BASE_URL", tc.converterURL)
t.Setenv("VISIONA_FAA_BASE_URL", tc.faaURL)
t.Setenv("VISIONA_CONVERTER_API_KEY", tc.converterKey)
t.Setenv("VISIONA_FAA_API_KEY", tc.faaKey)
cfg := Load()
assert.Equal(t, tc.wantEnabled, cfg.Conversion.Enabled())
})
}
}
// TestLoad_ConversionAllSet 驗證所有欄位設定後正確讀取。
// TestLoad_ConversionAllSet 驗證 Phase 0.8b 所有欄位設定後正確讀取。
//
// Phase 0.8b T5原暫留欄位 TenantID / DelegatedTTLSeconds 已移除,本 test
// 不再驗這兩欄位(對應 env 也不再讀取)。
func TestLoad_ConversionAllSet(t *testing.T) {
const fakeConverterKey = "fake-converter-api-key-for-test-do-not-use-in-prod"
const fakeFAAKey = "fake-faa-api-key-for-test-do-not-use-in-prod"
t.Setenv("VISIONA_CONVERTER_BASE_URL", "http://192.168.0.130:9501")
t.Setenv("VISIONA_FAA_BASE_URL", "http://192.168.0.130:5081")
t.Setenv("VISIONA_OIDC_TENANT_ID", "fake-tenant-id-for-test")
t.Setenv("VISIONA_FAA_DELEGATED_TTL_SECONDS", "600")
t.Setenv("VISIONA_CONVERTER_API_KEY", fakeConverterKey)
t.Setenv("VISIONA_FAA_API_KEY", fakeFAAKey)
t.Setenv("VISIONA_CONVERTER_MAX_MODEL_SIZE_MB", "300")
cfg := Load()
assert.Equal(t, "http://192.168.0.130:9501", cfg.Conversion.ConverterBaseURL)
assert.Equal(t, "http://192.168.0.130:5081", cfg.Conversion.FAABaseURL)
assert.Equal(t, "fake-tenant-id-for-test", cfg.Conversion.TenantID)
assert.Equal(t, 600, cfg.Conversion.DelegatedTTLSeconds)
assert.Equal(t, fakeConverterKey, cfg.Conversion.ConverterAPIKey)
assert.Equal(t, fakeFAAKey, cfg.Conversion.FAAAPIKey)
assert.Equal(t, 300, cfg.Conversion.MaxModelSizeMB)
assert.True(t, cfg.Conversion.Enabled())
}
// TestLoad_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
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
// 也不傳給 frontend JS見 conversion.md §10.4 安全分析)。
// 流程(見 conversion.md §1 Stage 3b + §4.1
// 1. ownership 檢查(不符 → ErrJobNotFound§7.2 防枚舉)
// 2. converter.GetJob 確認 status=completed否則 ErrJobNotCompleted
// 3. ensurePromoted與 PromoteToModels 共用同一個 converter promote endpoint冪等
// 4. faa.GetFile(targetObjectKey) — 用 pre-shared API key 直接拉 NEF stream
//
// 步驟(見 conversion.md §1 Stage 3b
// 1. ownership 檢查
// 2. ensurePromoted與 PromoteToModels 共用 cache
// 3. 對 MC POST /file-access/download-tokens 換 delegated token
// scope=files:download.delegate, TTL 5 分鐘)
// 4. 組 https://<faa>/files/<key>?access_token=<token>
// Phase 0.8 → 0.8b 差異:
// - Phase 0.8visionA → MC 換 delegated token → 組 FAA URL → handler 回 302
// browser 直連 FAA。
// - Phase 0.8bMC 認證鏈取消ADR-015→ 沒有 delegated token → visionA backend
// 用 API key 直接拉 FAA → 中轉 stream 給 browserserver-side proxy
//
// 仿 FAA TestSite `DownloadFileDirect` pattern見 conversion.md §3.1)。
DownloadRedirectURL(ctx context.Context, userID, jobID string) (string, error)
// 安全(見 conversion.md §10.4
// - 沒有 token 結構性存在於任何 frontend 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。
//
@ -151,6 +162,53 @@ type Job struct {
ErrorMessage string `json:"error_message,omitempty"`
}
// MaxDownloadStreamBytes 是 Service.DownloadStream → handler io.Copy 的 sanity cap。
//
// 用途:對 buggy / malicious FAA 回傳超大 body 的防禦性深化T4 Reviewer Minor #1
// io.Copy 本身是 streaming不 buffer 全 RAM每次 32KB但無上限會
// - 浪費 visionA → browser 的 egress bandwidth
// - goroutine 持續開著slow loris-like 行為)
//
// 1GB 的選擇邏輯:
// - ADR-015 §7「單檔 NEF 通常 < 50MB」是現況觀測但 NEF 沒有結構性上限
// - visionA model upload 限 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。
type PromoteResult struct {
ModelID string `json:"model_id"`

View File

@ -28,8 +28,10 @@ func (noopService) PromoteToModels(ctx context.Context, userID, jobID, name stri
return nil, nil
}
func (noopService) DownloadRedirectURL(ctx context.Context, userID, jobID string) (string, error) {
return "", nil
// Phase 0.8b T4DownloadRedirectURL 改 DownloadStreamAPI key 模式下沒有 delegated token
// 改 server-side stream proxy對應 ADR-015 §7 / conversion.md §4.1
func (noopService) DownloadStream(ctx context.Context, userID, jobID string) (io.ReadCloser, *DownloadMetadata, error) {
return nil, nil, nil
}
func (noopService) ActiveJob(ctx context.Context, userID string) (*Job, error) {

View File

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

View File

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

View File

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

View File

@ -27,13 +27,13 @@ func TestErrorCode(t *testing.T) {
{"payload_too_large", ErrPayloadTooLarge, "payload_too_large"},
{"converter_unavailable", ErrConverterUnavailable, "converter_unavailable"},
{"faa_unavailable", ErrFAAUnavailable, "faa_unavailable"},
{"download_token_failed", ErrDownloadTokenFailed, "download_token_failed"},
{"mc_token_unavailable", ErrMCTokenUnavailable, "mc_token_unavailable"},
{"idp_misconfigured", ErrIDPMisconfigured, "idp_misconfigured"},
{"idp_unavailable", ErrIDPUnavailable, "idp_unavailable"},
{"service_busy", ErrServiceBusy, "service_busy"},
// ErrServiceClientUnauthorized 對外刻意 mask 成 idp_misconfigured不 leak「visionA secret 過期」內部狀態)
{"service_client_unauthorized_masked_as_idp_misconfig", ErrServiceClientUnauthorized, "idp_misconfigured"},
// Phase 0.8b T3以下 sentinel 已移除,不再對外暴露對應 error code
// ErrDownloadTokenFailed / ErrMCTokenUnavailable / ErrIDPMisconfigured /
// ErrIDPUnavailable / ErrServiceClientUnauthorized
// 取代401/403 改 ErrConverterAuthFailed / ErrFAAAuthFailed下方 wrapped
{"converter_auth_failed_masked_as_converter_unavailable", ErrConverterAuthFailed, "converter_unavailable"},
{"faa_auth_failed_masked_as_faa_unavailable", ErrFAAAuthFailed, "faa_unavailable"},
// Reviewer M-1visionA 自身基礎設施失敗用獨立 code與 FAA / converter 區分)
{"storage_unavailable", ErrStorageUnavailable, "storage_unavailable"},
{"model_store_unavailable", ErrModelStoreUnavailable, "model_store_unavailable"},
@ -68,12 +68,13 @@ func TestHTTPStatus(t *testing.T) {
{"payload_too_large_413", ErrPayloadTooLarge, 413},
{"converter_unavailable_502", ErrConverterUnavailable, 502},
{"faa_unavailable_502", ErrFAAUnavailable, 502},
{"download_token_failed_502", ErrDownloadTokenFailed, 502},
{"mc_token_unavailable_502", ErrMCTokenUnavailable, 502},
{"idp_misconfigured_500", ErrIDPMisconfigured, 500},
{"idp_unavailable_503", ErrIDPUnavailable, 503},
{"service_busy_503", ErrServiceBusy, 503},
{"service_client_unauthorized_500", ErrServiceClientUnauthorized, 500},
// Phase 0.8b T3以下 sentinel 已移除,對外不再 mapping HTTP status
// ErrDownloadTokenFailed / ErrMCTokenUnavailable / ErrIDPMisconfigured /
// ErrIDPUnavailable / ErrServiceClientUnauthorized
// 取代401/403 改 ErrConverterAuthFailed / ErrFAAAuthFailed (HTTP 502)
{"converter_auth_failed_502", ErrConverterAuthFailed, 502},
{"faa_auth_failed_502", ErrFAAAuthFailed, 502},
// Reviewer M-1visionA 自身基礎設施失敗 → 500不是 502 gateway
{"storage_unavailable_500", ErrStorageUnavailable, 500},
{"model_store_unavailable_500", ErrModelStoreUnavailable, 500},

View File

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

View File

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

View File

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

View File

@ -1,19 +1,21 @@
// flow_test.go — Service interface 整合層的單元測試。
//
// 測試策略:
// - 各 client 用 in-package stub不耦合 T2-T5 真實邏輯,純驗 flow 整合行為)
// - 各 client 用 in-package stub不耦合 T3 / T4 / T5 真實邏輯,純驗 flow 整合行為)
// - 沿用 ownership_test.go 的 stubConverterClient補上 InitJob/GetJob/Promote 實作)
// - 用本檔案專屬的 stubFAAClient / stubMCTokenClient / stubModelStore / stubStorage
// - 用本檔案專屬的 stubFAAClient / stubModelStore / stubStorage
//
// 涵蓋 5 個 method × happy / ownership 失敗 / client 失敗 propagation +
// task spec 額外要求:
// - InitJob 同 user 已有 active → ActiveJobError
// - PromoteToModels 已 promote 過 → 回既有 model_ididempotent
// - 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)
//
// 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
import (
@ -171,42 +173,8 @@ func (s *flowStubFAA) GetFile(ctx context.Context, objectKey string) (*FAAFile,
var _ FAAClient = (*flowStubFAA)(nil)
// flowStubMCToken 是 MCTokenClient stub。
type flowStubMCToken struct {
serviceTokenFunc func(ctx context.Context, scope string) (string, error)
issueDelegatedDownloadFunc func(ctx context.Context, in IssueDownloadReq) (*DelegatedDownloadToken, error)
// 紀錄最後一次 IssueDelegatedDownload 收到的 input
mu sync.Mutex
lastIssueInput *IssueDownloadReq
}
func newFlowStubMCToken() *flowStubMCToken {
return &flowStubMCToken{}
}
func (s *flowStubMCToken) ServiceToken(ctx context.Context, scope string) (string, error) {
if s.serviceTokenFunc != nil {
return s.serviceTokenFunc(ctx, scope)
}
return "stub-service-token", nil
}
func (s *flowStubMCToken) IssueDelegatedDownload(ctx context.Context, in IssueDownloadReq) (*DelegatedDownloadToken, error) {
s.mu.Lock()
cp := in
s.lastIssueInput = &cp
s.mu.Unlock()
if s.issueDelegatedDownloadFunc != nil {
return s.issueDelegatedDownloadFunc(ctx, in)
}
return &DelegatedDownloadToken{
Token: "opaque-stub-token-xyz",
ExpiresAt: time.Now().Add(5 * time.Minute),
}, nil
}
var _ MCTokenClient = (*flowStubMCToken)(nil)
// Phase 0.8b T4原 flowStubMCToken 已整段刪除MC 認證鏈取消、flow 不再依賴 MCTokenClient
// Phase 0.8b T5mc_token_stub.go 整檔砍除MCTokenClient interface 已不存在。
// flowStubModelStore 是 ModelStore stub。
type flowStubModelStore struct {
@ -306,17 +274,17 @@ type flowFixture struct {
svc Service
converter *flowStubConverter
faa *flowStubFAA
mcToken *flowStubMCToken
models *flowStubModelStore
storage *flowStubStorage
ownership Ownership
}
// Phase 0.8b T4mcToken 欄位已移除flow 不再依賴 MCTokenClientFlowOpts 也砍 4 個欄位
// MCToken / TenantID / FAABaseURL / DelegatedTTLSeconds
func newFlowFixture(t *testing.T) *flowFixture {
t.Helper()
conv := newFlowStubConverter()
faa := newFlowStubFAA()
mcToken := newFlowStubMCToken()
models := newFlowStubModelStore()
storage := newFlowStubStorage()
own := NewOwnership(conv, newSilentLogger())
@ -324,14 +292,10 @@ func newFlowFixture(t *testing.T) *flowFixture {
svc, err := NewService(FlowOpts{
Converter: conv,
FAA: faa,
MCToken: mcToken,
Ownership: own,
ModelStore: models,
Storage: storage,
TenantID: "visiona-tenant",
FAABaseURL: "https://faa.example.com",
DefaultJobExpiryDuration: 7 * 24 * time.Hour,
DelegatedTTLSeconds: 300,
Logger: newSilentLogger(),
Now: time.Now,
})
@ -341,7 +305,6 @@ func newFlowFixture(t *testing.T) *flowFixture {
svc: svc,
converter: conv,
faa: faa,
mcToken: mcToken,
models: models,
storage: storage,
ownership: own,
@ -373,11 +336,12 @@ func makeMultipartBody(t *testing.T, clientUserID string) (body io.Reader, conte
// Constructor — 缺欄位驗證
// ==========================================================================
// Phase 0.8b T4TenantID / FAABaseURL / MCToken 欄位已從 FlowOpts 砍除;
// 必填欄位降為 5 個Converter / FAA / Ownership / ModelStore / Storage
func TestNewService_RequiredFields(t *testing.T) {
t.Parallel()
conv := newFlowStubConverter()
faa := newFlowStubFAA()
mc := newFlowStubMCToken()
own := NewOwnership(conv, newSilentLogger())
mod := newFlowStubModelStore()
st := newFlowStubStorage()
@ -386,14 +350,11 @@ func TestNewService_RequiredFields(t *testing.T) {
name string
opts FlowOpts
}{
{"missing converter", FlowOpts{FAA: faa, MCToken: mc, Ownership: own, ModelStore: mod, Storage: st, TenantID: "t", FAABaseURL: "https://x"}},
{"missing faa", FlowOpts{Converter: conv, MCToken: mc, Ownership: own, ModelStore: mod, Storage: st, TenantID: "t", FAABaseURL: "https://x"}},
{"missing mc", FlowOpts{Converter: conv, FAA: faa, Ownership: own, ModelStore: mod, Storage: st, TenantID: "t", FAABaseURL: "https://x"}},
{"missing ownership", FlowOpts{Converter: conv, FAA: faa, MCToken: mc, ModelStore: mod, Storage: st, TenantID: "t", FAABaseURL: "https://x"}},
{"missing modelstore", FlowOpts{Converter: conv, FAA: faa, MCToken: mc, Ownership: own, Storage: st, TenantID: "t", FAABaseURL: "https://x"}},
{"missing storage", FlowOpts{Converter: conv, FAA: faa, MCToken: mc, Ownership: own, ModelStore: mod, TenantID: "t", FAABaseURL: "https://x"}},
{"missing tenant", FlowOpts{Converter: conv, FAA: faa, MCToken: mc, Ownership: own, ModelStore: mod, Storage: st, FAABaseURL: "https://x"}},
{"missing faaurl", FlowOpts{Converter: conv, FAA: faa, MCToken: mc, Ownership: own, ModelStore: mod, Storage: st, TenantID: "t"}},
{"missing converter", FlowOpts{FAA: faa, Ownership: own, ModelStore: mod, Storage: st}},
{"missing faa", FlowOpts{Converter: conv, Ownership: own, ModelStore: mod, Storage: st}},
{"missing ownership", FlowOpts{Converter: conv, FAA: faa, ModelStore: mod, Storage: st}},
{"missing modelstore", FlowOpts{Converter: conv, FAA: faa, Ownership: own, Storage: st}},
{"missing storage", FlowOpts{Converter: conv, FAA: faa, Ownership: own, ModelStore: mod}},
}
for _, tt := range tests {
tt := tt
@ -409,24 +370,20 @@ func TestNewService_DefaultsApplied(t *testing.T) {
t.Parallel()
conv := newFlowStubConverter()
faa := newFlowStubFAA()
mc := newFlowStubMCToken()
own := NewOwnership(conv, newSilentLogger())
mod := newFlowStubModelStore()
st := newFlowStubStorage()
svc, err := NewService(FlowOpts{
Converter: conv, FAA: faa, MCToken: mc, Ownership: own,
Converter: conv, FAA: faa, Ownership: own,
ModelStore: mod, Storage: st,
TenantID: "visiona", FAABaseURL: "https://faa.example.com/",
// DefaultJobExpiryDuration / DelegatedTTLSeconds 留空 → 應 fallback
// DefaultJobExpiryDuration 留空 → 應 fallback 7d
})
require.NoError(t, err)
require.NotNil(t, svc)
f := svc.(*flow)
assert.Equal(t, 7*24*time.Hour, f.defaultJobExpiryDuration)
assert.Equal(t, 300, f.delegatedTTLSeconds)
assert.Equal(t, "https://faa.example.com", f.faaBaseURL, "trailing slash 應被 trim")
}
// ==========================================================================
@ -1040,93 +997,143 @@ func TestPromoteToModels_ModelStoreError(t *testing.T) {
}
// ==========================================================================
// DownloadRedirectURL
// 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 要求)。
func TestDownloadRedirectURL_HappyPath(t *testing.T) {
// TestDownloadStream_HappyPath成功 → 拿到 io.ReadCloser + DownloadMetadata 正確
func TestDownloadStream_HappyPath(t *testing.T) {
t.Parallel()
fix := newFlowFixture(t)
fix.converter.setJob(&ConverterJob{
JobID: "j1", Status: "completed", CreatedAt: time.Now(),
JobID: "j1",
Status: "completed",
CreatedAt: time.Now(),
SourceFilename: "yolov5s.onnx",
Platform: "720",
})
fix.ownership.Set("j1", "user-alice")
url, err := fix.svc.DownloadRedirectURL(context.Background(), "user-alice", "j1")
stream, meta, err := fix.svc.DownloadStream(context.Background(), "user-alice", "j1")
require.NoError(t, err)
require.NotNil(t, stream)
require.NotNil(t, meta)
defer stream.Close()
// FAA base + /files/<key>?access_token=<token>
// key = "models/user-alice/j1.nef"token = "opaque-stub-token-xyz"
assert.Equal(t,
"https://faa.example.com/files/models/user-alice/j1.nef?access_token=opaque-stub-token-xyz",
url,
)
// metadata 對齊 stub FAA 的 default 行為faa_client_test.go newFlowStubFAA
assert.Equal(t, "yolov5s_kl720.nef", meta.Filename,
"filename = <stem>_<chip>.nef對齊 wireframe §8.1 + defaultDownloadFilename")
assert.Equal(t, "application/octet-stream", meta.ContentType)
assert.Equal(t, int64(len("nef-bytes-stub")), meta.ContentLength)
// 驗 IssueDelegatedDownload 帶到的參數
fix.mcToken.mu.Lock()
in := fix.mcToken.lastIssueInput
fix.mcToken.mu.Unlock()
require.NotNil(t, in)
assert.Equal(t, "visiona-tenant", in.TenantID)
assert.Equal(t, "user-alice", in.UserID)
assert.Equal(t, "models/user-alice/j1.nef", in.ObjectKey)
assert.Equal(t, 300, in.ExpiresInSeconds)
// stream 內容與 stub FAA 預設 body 一致streaming pull
body, err := io.ReadAll(stream)
require.NoError(t, err)
assert.Equal(t, "nef-bytes-stub", string(body))
// 驗 faa.GetFile 帶到的 object_keybuildTargetObjectKey 規則models/<user>/<job>.nef
fix.faa.mu.Lock()
gotKey := fix.faa.lastKey
fix.faa.mu.Unlock()
assert.Equal(t, "models/user-alice/j1.nef", gotKey)
}
// TestDownloadRedirectURL_EscapeSpecialChars特殊字元的 user_id / job_id 走 escape。
func TestDownloadRedirectURL_EscapeSpecialChars(t *testing.T) {
// TestDownloadStream_FilenameFromConverterJobfilename 取自 cj.SourceFilename + Platform
// 而非從 FAA metadata 拿API key 模式下 FAA URL 不再含原檔名)。
func TestDownloadStream_FilenameFromConverterJob(t *testing.T) {
t.Parallel()
fix := newFlowFixture(t)
fix.mcToken.issueDelegatedDownloadFunc = func(ctx context.Context, in IssueDownloadReq) (*DelegatedDownloadToken, error) {
// 模擬 token 含特殊字元
return &DelegatedDownloadToken{
Token: "abc def+/=",
ExpiresAt: time.Now().Add(5 * time.Minute),
}, nil
}
// 用合法但帶 special char 的 user_idOIDC sub 通常不會這樣,但要 defensive
userID := "user with space"
fix.converter.setJob(&ConverterJob{JobID: "j1", Status: "completed", CreatedAt: time.Now()})
fix.ownership.Set("j1", userID)
fix.converter.setJob(&ConverterJob{
JobID: "j1",
Status: "completed",
CreatedAt: time.Now(),
SourceFilename: "/path/to/my_model.tflite", // 有 path prefix → 應只取 stem
Platform: "520",
})
fix.ownership.Set("j1", "user-alice")
url, err := fix.svc.DownloadRedirectURL(context.Background(), userID, "j1")
_, meta, err := fix.svc.DownloadStream(context.Background(), "user-alice", "j1")
require.NoError(t, err)
// path 段 user_id 應 escape' ' → %20
assert.Contains(t, url, "/files/models/user%20with%20space/j1.nef")
// token 段應 query escape'+' / '=' / '/' / ' '
assert.Contains(t, url, "?access_token=abc+def%2B%2F%3D")
require.NotNil(t, meta)
assert.Equal(t, "my_model_kl520.nef", meta.Filename)
}
// TestDownloadRedirectURL_OwnershipMismatchnot_found。
func TestDownloadRedirectURL_OwnershipMismatch(t *testing.T) {
// TestDownloadStream_DefaultsContentTypeFAA 沒給 Content-Type → 預設 octet-stream
// (確保 browser download dialog 仍會觸發)。
func TestDownloadStream_DefaultsContentType(t *testing.T) {
t.Parallel()
fix := newFlowFixture(t)
fix.faa.getFileFunc = func(ctx context.Context, objectKey string) (*FAAFile, error) {
return &FAAFile{
Body: io.NopCloser(strings.NewReader("nef")),
ContentLength: 3,
ContentType: "", // 故意空白
ETag: "etag",
}, nil
}
fix.converter.setJob(&ConverterJob{
JobID: "j1", Status: "completed", CreatedAt: time.Now(),
SourceFilename: "x.onnx", Platform: "720",
})
fix.ownership.Set("j1", "user-alice")
_, meta, err := fix.svc.DownloadStream(context.Background(), "user-alice", "j1")
require.NoError(t, err)
assert.Equal(t, "application/octet-stream", meta.ContentType,
"FAA 沒給 Content-Type 時應 fallback 為 application/octet-stream")
}
// TestDownloadStream_OwnershipMismatch別 user 的 job → ErrJobNotFound防枚舉
func TestDownloadStream_OwnershipMismatch(t *testing.T) {
t.Parallel()
fix := newFlowFixture(t)
fix.converter.setJob(&ConverterJob{JobID: "j1", Status: "completed", CreatedAt: time.Now()})
fix.ownership.Set("j1", "user-bob")
_, err := fix.svc.DownloadRedirectURL(context.Background(), "user-alice", "j1")
stream, meta, err := fix.svc.DownloadStream(context.Background(), "user-alice", "j1")
require.Error(t, err)
assert.True(t, errors.Is(err, ErrJobNotFound))
assert.Nil(t, stream)
assert.Nil(t, meta)
// FAA 不該被打到ownership 不符在 FAA call 之前)
assert.Equal(t, int32(0), fix.faa.getCalls.Load())
}
// TestDownloadRedirectURL_JobNotCompletedstill running → ErrJobNotCompleted。
func TestDownloadRedirectURL_JobNotCompleted(t *testing.T) {
// TestDownloadStream_JobNotCompletedstill running → ErrJobNotCompleted。
func TestDownloadStream_JobNotCompleted(t *testing.T) {
t.Parallel()
fix := newFlowFixture(t)
fix.converter.setJob(&ConverterJob{JobID: "j1", Status: "running", CreatedAt: time.Now()})
fix.ownership.Set("j1", "user-alice")
_, err := fix.svc.DownloadRedirectURL(context.Background(), "user-alice", "j1")
stream, meta, err := fix.svc.DownloadStream(context.Background(), "user-alice", "j1")
require.Error(t, err)
assert.True(t, errors.Is(err, ErrJobNotCompleted))
assert.Nil(t, stream)
assert.Nil(t, meta)
}
// TestDownloadRedirectURL_PromoteError_Propagationpromote 5xx 透傳。
func TestDownloadRedirectURL_PromoteError_Propagation(t *testing.T) {
// TestDownloadStream_PromoteError_Propagationpromote 5xx 透傳。
func TestDownloadStream_PromoteError_Propagation(t *testing.T) {
t.Parallel()
fix := newFlowFixture(t)
@ -1136,25 +1143,54 @@ func TestDownloadRedirectURL_PromoteError_Propagation(t *testing.T) {
return nil, fmt.Errorf("%w: promote 502", ErrConverterUnavailable)
}
_, err := fix.svc.DownloadRedirectURL(context.Background(), "user-alice", "j1")
_, _, err := fix.svc.DownloadStream(context.Background(), "user-alice", "j1")
require.Error(t, err)
assert.True(t, errors.Is(err, ErrConverterUnavailable))
// FAA 不該被打到promote 失敗在 FAA call 之前)
assert.Equal(t, int32(0), fix.faa.getCalls.Load())
}
// TestDownloadRedirectURL_MCError_PropagationMC delegated 5xx 透傳。
func TestDownloadRedirectURL_MCError_Propagation(t *testing.T) {
// TestDownloadStream_FAAError_PropagationFAA pull 5xx 透傳(取代原 MCError test
//
// Phase 0.8b 後 download path 不再經 MCFAA stream 失敗是最常見的失敗模式
// API key 不對齊 → ErrFAAAuthFailedFAA 服務不可達 → ErrFAAUnavailable
func TestDownloadStream_FAAError_Propagation(t *testing.T) {
t.Parallel()
fix := newFlowFixture(t)
fix.converter.setJob(&ConverterJob{JobID: "j1", Status: "completed", CreatedAt: time.Now()})
fix.ownership.Set("j1", "user-alice")
fix.mcToken.issueDelegatedDownloadFunc = func(ctx context.Context, in IssueDownloadReq) (*DelegatedDownloadToken, error) {
return nil, fmt.Errorf("%w: mc 5xx", ErrMCTokenUnavailable)
fix.faa.getFileFunc = func(ctx context.Context, objectKey string) (*FAAFile, error) {
return nil, fmt.Errorf("%w: faa 502", ErrFAAUnavailable)
}
_, err := fix.svc.DownloadRedirectURL(context.Background(), "user-alice", "j1")
stream, meta, err := fix.svc.DownloadStream(context.Background(), "user-alice", "j1")
require.Error(t, err)
assert.True(t, errors.Is(err, ErrMCTokenUnavailable))
assert.True(t, errors.Is(err, ErrFAAUnavailable))
assert.Nil(t, stream)
assert.Nil(t, meta)
}
// TestDownloadStream_FAAAuthFailed_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)"
}