feat(visionA-backend): Phase 0.9 模型庫存取 — FAA delegated download token(B1+B2)
對齊 ADR-017 v1.2:模型庫下載走 visionA 簽 MC delegated token → Client 直連 FAA。
B2 — MC download token client(internal/fileaccess):
- DownloadTokenIssuer: GetServiceToken(打 MC /oauth/token,client_credentials +
scope files:download.delegate,含 token cache)+ IssueDownloadToken(打 MC Issue 簽 fdt_)
- secret / service token / fdt token 三層全程用 hashShort 遮罩不 log
- FileAccessConfig + VISIONA_FILE_ACCESS_* env + main.go wire(Enabled() 才接)
B1 — object_key 斷層:
- model.Model 加 FAAObjectKey(json:"-" 不揭露前端)
- PromoteToModels 寫入(用 promote response TargetObjectKey = models/{userID}/{jobID}.nef)
- 三方對映天然一致(visionA Issue / FAA path / MC validate)
- 第一階段框死只 Source=converted 類 model,上傳類 download 回 501
download endpoint:
- GET /api/models/:id/download(owner-only)→ {download_url, token, expires_at}
- 前端帶 Authorization: Bearer 直連 FAA(不經 visionA、不經 AWS)
- 401/403/404/501/502 分明,502 對外 mask 不洩漏 MC 內部狀態
測試: 13 + 8 unit test(mock MC + fake issuer,httptest 驗真 HTTP);go build/vet/test 全綠。
Reviewer: 0 Critical / 0 Major / 3 Minor / 4 Suggestion,通過。
技術債(正式上線前): 第一階段 PoC 共用 FAA service client,MC 規範禁止 client 混用
usage、secret 不共用,須 MC 配發 visionA 專屬 usage=file_api client。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
88a8ddbd82
commit
c63886a194
@ -189,3 +189,46 @@ VISIONA_CONVERTER_API_KEY=
|
||||
|
||||
# 上傳模型檔大小上限(MB)— 與 converter 端 limit 對齊
|
||||
VISIONA_CONVERTER_MAX_MODEL_SIZE_MB=500
|
||||
|
||||
# ============================================================
|
||||
# Phase 0.9 — 模型庫 model 直連 FAA 下載(ADR-017 (a))
|
||||
# ============================================================
|
||||
# 對齊 docs/autoflow/04-architecture/adr/adr-017-model-library-access.md §10(stage e2e 實測藍本)。
|
||||
#
|
||||
# 下載鏈:visionA 用 service client 打 MC /oauth/token(scope files:download.delegate)
|
||||
# → 打 MC POST /file-access/download-tokens(Issue)簽 opaque fdt_ token
|
||||
# → 回給 Client「FAA 下載 URL + fdt token」,Client 帶 Authorization: Bearer fdt_...
|
||||
# 直接 GET {FAA}/files/{object_key}(不經 visionA、不經 AWS)。
|
||||
#
|
||||
# 啟用判定:MC_BASE_URL / SERVICE_CLIENT_ID / SERVICE_CLIENT_SECRET / TENANT_ID / FAA_BASE_URL
|
||||
# 全部非空才啟用;任一缺 → GET /api/models/:id/download 回 501。
|
||||
#
|
||||
# ⚠️⚠️ 技術債(ADR-017 §7 R1 / Q10):第一階段 PoC 短期**共用 FAA 的 service client**
|
||||
# (stage 用 4242ba63...,實測可拿 files:download.delegate token)。MC 規範明訂
|
||||
# 「OAuth client 禁止混用 usage、secret 不共用」——這份 secret 同時被 FAA 與 visionA 持有,
|
||||
# 任一邊洩漏會波及兩個服務。**正式上線前須請 MC 配發 visionA 專屬 usage=file_api client**
|
||||
# 換掉此共用 client,把 secret 邊界收回 visionA。
|
||||
|
||||
# Member Center API base URL(不帶結尾斜線)
|
||||
# stage:https://stage-9527.innovedus.com:7850
|
||||
VISIONA_FILE_ACCESS_MC_BASE_URL=
|
||||
|
||||
# Service client(client_credentials grant,打 MC /oauth/token + Issue download token)
|
||||
# ⚠️ 技術債:第一階段 PoC 共用 FAA service client(stage:4242ba63099d4f318dd3f143d27ef4c5)
|
||||
VISIONA_FILE_ACCESS_SERVICE_CLIENT_ID=
|
||||
|
||||
# Service client secret
|
||||
# ⚠️ 不可 commit;prod 用 Secrets Manager / Vault;log 永遠不印此值全文
|
||||
# ⚠️ 技術債:第一階段 PoC 共用 FAA service client secret(正式上線前換 visionA 專屬 client)
|
||||
VISIONA_FILE_ACCESS_SERVICE_CLIENT_SECRET=
|
||||
|
||||
# 簽 download token 時帶給 MC 的 tenant_id(須與 FAA validate 的 tenant 一致)
|
||||
# stage:732270c0-449c-489c-bfad-321e9bf89b3d
|
||||
VISIONA_FILE_ACCESS_TENANT_ID=
|
||||
|
||||
# File Access Agent 對外 base URL(不帶結尾斜線)— 組回給 Client 的 download_url 用
|
||||
# stage:https://stage-9527.innovedus.com:5081
|
||||
VISIONA_FILE_ACCESS_FAA_BASE_URL=
|
||||
|
||||
# download token 有效期(秒)— ADR-017 Q2 區間 60–300s,預設 120
|
||||
VISIONA_FILE_ACCESS_DOWNLOAD_TOKEN_TTL_SECONDS=120
|
||||
|
||||
2
visionA-backend/.gitignore
vendored
2
visionA-backend/.gitignore
vendored
@ -20,6 +20,8 @@ go.work.sum
|
||||
bin/
|
||||
dist/
|
||||
build/
|
||||
# go build 產生的 api-server 二進位(根目錄錨定,避免誤排除其他同名路徑)
|
||||
/api-server
|
||||
|
||||
# ---- 環境變數 / 密鑰 -----------------------------------------------------
|
||||
.env
|
||||
|
||||
@ -67,6 +67,7 @@ func (a *conversionModelStoreAdapter) Save(ctx context.Context, rec *conversion.
|
||||
TargetChip: rec.TargetChip,
|
||||
Source: rec.Source, // 應為 "converted"
|
||||
SourceJobID: rec.SourceJobID,
|
||||
FAAObjectKey: rec.FAAObjectKey, // ADR-017 (a) B1:promote 寫入的 FAA object key
|
||||
CreatedAt: rec.CreatedAt,
|
||||
UpdatedAt: rec.UpdatedAt,
|
||||
UploadedAt: &uploadedAt, // promote 完即 ready(對齊 toModelResponse)
|
||||
@ -120,6 +121,7 @@ func modelToRecord(m *model.Model) *conversion.ModelRecord {
|
||||
TargetChip: m.TargetChip,
|
||||
Source: m.Source,
|
||||
SourceJobID: m.SourceJobID,
|
||||
FAAObjectKey: m.FAAObjectKey, // ADR-017 (a) B1
|
||||
CreatedAt: m.CreatedAt,
|
||||
UpdatedAt: m.UpdatedAt,
|
||||
}
|
||||
|
||||
@ -30,6 +30,7 @@ import (
|
||||
"visiona-backend/internal/conversion"
|
||||
"visiona-backend/internal/converter"
|
||||
"visiona-backend/internal/device"
|
||||
"visiona-backend/internal/fileaccess"
|
||||
"visiona-backend/internal/logger"
|
||||
"visiona-backend/internal/model"
|
||||
"visiona-backend/internal/oidc"
|
||||
@ -188,6 +189,41 @@ func main() {
|
||||
log.Info("conversion service disabled (set VISIONA_CONVERTER_BASE_URL + VISIONA_CONVERTER_API_KEY to enable)")
|
||||
}
|
||||
|
||||
// ===== Phase 0.9 模型庫 model 直連 FAA 下載(ADR-017 (a)) =====
|
||||
// 對齊 docs/autoflow/04-architecture/adr/adr-017-model-library-access.md §10。
|
||||
//
|
||||
// 啟用條件:cfg.FileAccess.Enabled() — 由 MC base / service client id+secret / tenant /
|
||||
// FAA base 五欄位全非空決定。不啟用時 fileAccessIssuer 為 nil,
|
||||
// GET /api/models/:id/download 自動回 501(modelsDownloadHandler 處理)。
|
||||
//
|
||||
// ⚠️ 技術債(ADR-017 §7 R1 / Q10):第一階段 PoC 共用 FAA 的 service client;
|
||||
// 正式上線前須換 visionA 專屬 usage=file_api client(見 internal/fileaccess 套件註解 + .env.example)。
|
||||
var fileAccessIssuer fileaccess.DownloadTokenIssuer
|
||||
if cfg.FileAccess.Enabled() {
|
||||
issuer, faErr := fileaccess.NewClient(fileaccess.Opts{
|
||||
MCBaseURL: cfg.FileAccess.MCBaseURL,
|
||||
ServiceClientID: cfg.FileAccess.ServiceClientID,
|
||||
ServiceClientSecret: cfg.FileAccess.ServiceClientSecret,
|
||||
TenantID: cfg.FileAccess.TenantID,
|
||||
DownloadTokenTTLSeconds: cfg.FileAccess.DownloadTokenTTLSeconds,
|
||||
Logger: log,
|
||||
})
|
||||
if faErr != nil {
|
||||
log.Error("failed to init file access client", "error", faErr)
|
||||
os.Exit(1)
|
||||
}
|
||||
fileAccessIssuer = issuer
|
||||
log.Info("file access (FAA download) initialized",
|
||||
"mc_base_url", cfg.FileAccess.MCBaseURL,
|
||||
"faa_base_url", cfg.FileAccess.FAABaseURL,
|
||||
"tenant_id", cfg.FileAccess.TenantID,
|
||||
// 安全:絕不印 client secret 全文
|
||||
"service_client_secret_set", cfg.FileAccess.ServiceClientSecret != "",
|
||||
"download_token_ttl_sec", cfg.FileAccess.DownloadTokenTTLSeconds)
|
||||
} else {
|
||||
log.Info("file access (FAA download) disabled (set VISIONA_FILE_ACCESS_* to enable)")
|
||||
}
|
||||
|
||||
// ===== Seed demo data(可選) =====
|
||||
if cfg.Server.SeedDemoData {
|
||||
if err := seedDemoData(deviceRepo, modelRepo, pairingStore, cfg.Auth.StaticUserID, log); err != nil {
|
||||
@ -210,6 +246,8 @@ func main() {
|
||||
Storage: storageStore,
|
||||
Converter: converterClient,
|
||||
Conversion: conversionService, // Phase 0.8(nil 時 /api/conversion/* 回 501)
|
||||
FileAccessIssuer: fileAccessIssuer, // Phase 0.9(nil 時 /api/models/:id/download 回 501)
|
||||
FAABaseURL: cfg.FileAccess.FAABaseURL,
|
||||
MaxUploadSizeMB: cfg.Model.MaxSizeMB,
|
||||
CORSAllowedOrigins: cfg.CORS.AllowedOrigins,
|
||||
RelayPublicURL: cfg.Server.RelayPublicURL,
|
||||
|
||||
@ -26,6 +26,7 @@ import (
|
||||
"visiona-backend/internal/conversion"
|
||||
"visiona-backend/internal/converter"
|
||||
"visiona-backend/internal/device"
|
||||
"visiona-backend/internal/fileaccess"
|
||||
"visiona-backend/internal/model"
|
||||
"visiona-backend/internal/oidc"
|
||||
"visiona-backend/internal/session"
|
||||
@ -77,6 +78,17 @@ type Deps struct {
|
||||
// 設計選擇:用 conversion.Service interface 而非 concrete type — 方便 unit test 注入 stub。
|
||||
Conversion conversion.Service
|
||||
|
||||
// FileAccessIssuer 是 Phase 0.9「模型庫 model 直連 FAA 下載」的 download token 簽發者
|
||||
// (ADR-017 (a))。為 nil 時 GET /api/models/:id/download 回 501 NOT_IMPLEMENTED
|
||||
// (main.go 在 cfg.FileAccess.Enabled() 為 false 時不 wire)。
|
||||
// 用 interface 方便 unit test 注入 fake。
|
||||
FileAccessIssuer fileaccess.DownloadTokenIssuer
|
||||
|
||||
// FAABaseURL 是 File Access Agent 對外 base URL(不帶結尾斜線),用來組回給 Client
|
||||
// 的 download_url(`{FAABaseURL}/files/{object_key}`)。
|
||||
// 由 cfg.FileAccess.FAABaseURL 注入;FileAccessIssuer 非 nil 時必非空(main.go 確保)。
|
||||
FAABaseURL string
|
||||
|
||||
// CORSAllowedOrigins 是允許的瀏覽器 Origin 白名單;空 slice 預設放行
|
||||
// http://localhost:3000(前端 dev server)。
|
||||
CORSAllowedOrigins []string
|
||||
|
||||
@ -20,6 +20,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@ -41,6 +42,9 @@ func registerModelRoutes(g *gin.RouterGroup, deps Deps) {
|
||||
g.POST("/models/:id/finalize", modelsFinalizeHandler(deps))
|
||||
g.DELETE("/models/:id", modelsDeleteHandler(deps))
|
||||
|
||||
// Phase 0.9 模型庫 model 直連 FAA 下載(ADR-017 (a))。
|
||||
g.GET("/models/:id/download", modelsDownloadHandler(deps))
|
||||
|
||||
// load-to-device 雛形先 stub(完整實作需要 presigned GET + 透過 tunnel 送指令給 local agent)
|
||||
g.POST("/models/:id/load-to-device", func(c *gin.Context) {
|
||||
WriteNotImplemented(c, "models.load-to-device — pending Phase 1")
|
||||
@ -431,3 +435,128 @@ func modelsDeleteHandler(deps Deps) gin.HandlerFunc {
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
// ModelDownloadResponse 是 GET /api/models/:id/download 的 response data(ADR-017 (a) 決策 2)。
|
||||
//
|
||||
// Client(local-tool / browser)拿到後,帶 `Authorization: Bearer {Token}` 直接
|
||||
// GET DownloadURL(= {FAA}/files/{object_key})下載——不經 visionA、不經 AWS。
|
||||
type ModelDownloadResponse struct {
|
||||
// DownloadURL 是 FAA 下載 URL(`{FAABaseURL}/files/{object_key}`)。
|
||||
DownloadURL string `json:"download_url"`
|
||||
// Token 是 MC 簽的 opaque download token(fdt_);放 Authorization: Bearer。
|
||||
Token string `json:"token"`
|
||||
// ExpiresAt 是 token 到期時間(RFC3339 / UTC);MC 沒回填時為零值(前端不應依賴)。
|
||||
ExpiresAt time.Time `json:"expires_at,omitempty"`
|
||||
}
|
||||
|
||||
// modelsDownloadHandler 實作 GET /api/models/:id/download(ADR-017 (a) 模型庫直連 FAA 下載)。
|
||||
//
|
||||
// 流程(對齊 adr-017 §10.3 e2e 藍本 + 決策 2):
|
||||
// 1. ownership 驗(第一階段 owner-only;B 分享是後續階段)
|
||||
// 2. model 必須有 FAAObjectKey(= 轉檔→promote 類);上傳類(空 key)回 501(Q7 範圍框死)
|
||||
// 3. FileAccessIssuer 簽 MC download token(fdt_)
|
||||
// 4. 回 {download_url, token, expires_at} 給 Client 直連 FAA
|
||||
//
|
||||
// 錯誤:
|
||||
// - issuer / FAABaseURL 未配置(deps.FileAccessIssuer == nil)→ 501 NOT_IMPLEMENTED
|
||||
// - model 不存在 / 非 owner → 404 / 403
|
||||
// - 上傳類 model(無 FAAObjectKey)→ 501 NOT_IMPLEMENTED(明確訊息)
|
||||
// - 簽 token 失敗(MC 不可用)→ 502 INTERNAL_ERROR(對外 mask,不洩漏 MC 內部狀態)
|
||||
func modelsDownloadHandler(deps Deps) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if deps.ModelRepo == nil {
|
||||
WriteError(c, http.StatusNotFound, ErrCodeNotFound, "model not found", nil)
|
||||
return
|
||||
}
|
||||
// FAA 直連下載未啟用(cfg.FileAccess.Enabled() == false → main.go 不 wire)
|
||||
if deps.FileAccessIssuer == nil || deps.FAABaseURL == "" {
|
||||
WriteNotImplemented(c, "model FAA download not configured (set VISIONA_FILE_ACCESS_* env)")
|
||||
return
|
||||
}
|
||||
|
||||
id := c.Param("id")
|
||||
if id == "" {
|
||||
WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "model id required", nil)
|
||||
return
|
||||
}
|
||||
// Phase 0.7 security fix C1 (見 .autoflow/05-implementation/review/phase-0.7-security-audit.md)
|
||||
uc, ok := UserContextFrom(c)
|
||||
if !ok || uc.UserID == "" {
|
||||
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
|
||||
"missing user context (auth middleware misconfigured?)", nil)
|
||||
return
|
||||
}
|
||||
userID := uc.UserID
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
m, err := deps.ModelRepo.Get(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
WriteError(c, http.StatusNotFound, ErrCodeNotFound, "model not found", nil)
|
||||
return
|
||||
}
|
||||
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
|
||||
"get model failed: "+err.Error(), nil)
|
||||
return
|
||||
}
|
||||
// 第一階段 owner-only(B 分享後續階段);非 owner 回 403。
|
||||
if m.OwnerUserID != userID {
|
||||
WriteError(c, http.StatusForbidden, ErrCodeForbidden, "not owner", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// 第一階段只支援「轉檔→promote 進 FAA」類 model(有 FAAObjectKey)。
|
||||
// 上傳類 model 只在 visionA 自己 storage、不在 FAA,無法直連(ADR-017 Q7 範圍框死)。
|
||||
if m.FAAObjectKey == "" {
|
||||
WriteNotImplemented(c,
|
||||
"uploaded models do not support FAA direct download in this phase (only converted models)")
|
||||
return
|
||||
}
|
||||
|
||||
// 簽 MC download token(fdt_)。userID = OIDC sub(MC 真實 user);
|
||||
// objectKey 必須與 FAA GET path key 一致(= 簽 token 時的 object_key)。
|
||||
issued, err := deps.FileAccessIssuer.IssueDownloadToken(ctx, userID, m.FAAObjectKey)
|
||||
if err != nil {
|
||||
// 對外 mask 成 502(不洩漏「MC 不可用 / token 簽發細節」這類內部運維狀態);
|
||||
// SRE 從 server log 的 error 看 fileaccess sentinel 分類。
|
||||
logOrDefault(deps.Logger).Warn("models: download token issue failed",
|
||||
"model_id", m.ID,
|
||||
"user_id", userID,
|
||||
"err", err.Error(),
|
||||
"request_id", RequestIDFrom(c))
|
||||
WriteError(c, http.StatusBadGateway, ErrCodeInternalError,
|
||||
"download token service unavailable", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// 組對外 download_url:{FAABaseURL}/files/{object_key}。
|
||||
// object_key 內含 '/'(models/{userID}/{jobID}.nef),需逐段 escape 但保留 '/'。
|
||||
downloadURL := strings.TrimRight(deps.FAABaseURL, "/") + "/files/" + escapeFAAObjectKey(m.FAAObjectKey)
|
||||
|
||||
logOrDefault(deps.Logger).Info("models: download token issued",
|
||||
"model_id", m.ID,
|
||||
"user_id", userID,
|
||||
"request_id", RequestIDFrom(c))
|
||||
|
||||
WriteSuccess(c, http.StatusOK, ModelDownloadResponse{
|
||||
DownloadURL: downloadURL,
|
||||
Token: issued.Token,
|
||||
ExpiresAt: issued.ExpiresAt,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// escapeFAAObjectKey 對 FAA object_key 逐段 path-escape,保留 '/' 為 path separator。
|
||||
//
|
||||
// object_key 形如 models/{userID}/{jobID}.nef;userID(OIDC sub) / jobID(UUID) 都是
|
||||
// 安全字元,但仍逐段 url.PathEscape 防禦(避免任何特殊字元破壞 URL 結構)。
|
||||
// 不整體 PathEscape 是因為那會把 '/' 變 %2F,破壞 FAA `/files/{**objectKey}` 的 path 比對。
|
||||
func escapeFAAObjectKey(objectKey string) string {
|
||||
segs := strings.Split(objectKey, "/")
|
||||
for i, s := range segs {
|
||||
segs[i] = url.PathEscape(s)
|
||||
}
|
||||
return strings.Join(segs, "/")
|
||||
}
|
||||
|
||||
223
visionA-backend/internal/api/models_download_test.go
Normal file
223
visionA-backend/internal/api/models_download_test.go
Normal file
@ -0,0 +1,223 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"visiona-backend/internal/fileaccess"
|
||||
"visiona-backend/internal/model"
|
||||
)
|
||||
|
||||
// ==========================================================================
|
||||
// fake DownloadTokenIssuer
|
||||
// ==========================================================================
|
||||
|
||||
type fakeIssuer struct {
|
||||
token string
|
||||
expiresAt time.Time
|
||||
err error
|
||||
|
||||
// 記錄被呼叫的參數,驗 handler 傳對 userID / objectKey。
|
||||
gotUserID string
|
||||
gotObjectKey string
|
||||
calls int
|
||||
}
|
||||
|
||||
func (f *fakeIssuer) IssueDownloadToken(ctx context.Context, userID, objectKey string) (*fileaccess.IssuedDownloadToken, error) {
|
||||
f.calls++
|
||||
f.gotUserID = userID
|
||||
f.gotObjectKey = objectKey
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return &fileaccess.IssuedDownloadToken{
|
||||
Token: f.token,
|
||||
TokenType: "file_download",
|
||||
ExpiresAt: f.expiresAt,
|
||||
Scope: "files:download.read",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// newDownloadFixture 建一個帶 fake issuer 的 models route fixture。
|
||||
//
|
||||
// issuer 為 nil 時模擬「FAA download 未啟用」(main.go 不 wire)。
|
||||
// faaBaseURL 空時也視為未配置。
|
||||
func newDownloadFixture(t *testing.T, issuer fileaccess.DownloadTokenIssuer, faaBaseURL, userID string) (*gin.Engine, *model.InMemoryRepository) {
|
||||
t.Helper()
|
||||
repo := model.NewInMemoryRepository()
|
||||
|
||||
r := gin.New()
|
||||
r.Use(RequestIDMiddleware())
|
||||
r.Use(injectStaticUserContext(userID, ""))
|
||||
g := r.Group("/api")
|
||||
registerModelRoutes(g, Deps{
|
||||
ModelRepo: repo,
|
||||
MaxUploadSizeMB: 10,
|
||||
FileAccessIssuer: issuer,
|
||||
FAABaseURL: faaBaseURL,
|
||||
})
|
||||
return r, repo
|
||||
}
|
||||
|
||||
// seedConvertedModel 直接塞一個「轉檔→promote」類 model(有 FAAObjectKey)。
|
||||
func seedConvertedModel(t *testing.T, repo *model.InMemoryRepository, id, owner, faaKey string) {
|
||||
t.Helper()
|
||||
now := time.Now().UTC()
|
||||
require.NoError(t, repo.Save(context.Background(), &model.Model{
|
||||
ID: id,
|
||||
OwnerUserID: owner,
|
||||
Name: "converted-model",
|
||||
StorageKey: "models/" + owner + "/" + id + ".nef",
|
||||
FAAObjectKey: faaKey,
|
||||
FileSize: 1024,
|
||||
Source: model.SourceConverted,
|
||||
UploadedAt: &now,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}))
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// happy path
|
||||
// ==========================================================================
|
||||
|
||||
func TestModelsDownload_OK(t *testing.T) {
|
||||
exp := time.Date(2026, 6, 7, 12, 2, 0, 0, time.UTC)
|
||||
iss := &fakeIssuer{token: "fdt_abc123", expiresAt: exp}
|
||||
r, repo := newDownloadFixture(t, iss, "https://faa.example.com:5081", "demo-user")
|
||||
seedConvertedModel(t, repo, "m-conv-1", "demo-user", "models/demo-user/job-1.nef")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/models/m-conv-1/download", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String())
|
||||
|
||||
var sb SuccessBody
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &sb))
|
||||
data := sb.Data.(map[string]any)
|
||||
|
||||
assert.Equal(t, "https://faa.example.com:5081/files/models/demo-user/job-1.nef", data["download_url"])
|
||||
assert.Equal(t, "fdt_abc123", data["token"])
|
||||
assert.Contains(t, data["expires_at"], "2026-06-07")
|
||||
|
||||
// issuer 必須拿到正確的 userID(OIDC sub)+ objectKey(FAAObjectKey,非 StorageKey)
|
||||
assert.Equal(t, "demo-user", iss.gotUserID)
|
||||
assert.Equal(t, "models/demo-user/job-1.nef", iss.gotObjectKey)
|
||||
assert.Equal(t, 1, iss.calls)
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// 501 — 未配置 / 上傳類
|
||||
// ==========================================================================
|
||||
|
||||
func TestModelsDownload_NotConfiguredWhenIssuerNil(t *testing.T) {
|
||||
r, repo := newDownloadFixture(t, nil, "", "demo-user")
|
||||
seedConvertedModel(t, repo, "m1", "demo-user", "models/demo-user/job.nef")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/models/m1/download", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusNotImplemented, w.Code)
|
||||
assert.Contains(t, w.Body.String(), ErrCodeNotImplemented)
|
||||
}
|
||||
|
||||
func TestModelsDownload_NotConfiguredWhenFAABaseURLEmpty(t *testing.T) {
|
||||
iss := &fakeIssuer{token: "fdt_x"}
|
||||
// issuer 有,但 FAABaseURL 空 → 仍視為未配置
|
||||
r, repo := newDownloadFixture(t, iss, "", "demo-user")
|
||||
seedConvertedModel(t, repo, "m1", "demo-user", "models/demo-user/job.nef")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/models/m1/download", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusNotImplemented, w.Code)
|
||||
assert.Equal(t, 0, iss.calls, "should not call issuer when not configured")
|
||||
}
|
||||
|
||||
func TestModelsDownload_UploadedModelReturns501(t *testing.T) {
|
||||
iss := &fakeIssuer{token: "fdt_x"}
|
||||
r, repo := newDownloadFixture(t, iss, "https://faa.example.com:5081", "demo-user")
|
||||
|
||||
// 上傳類 model:無 FAAObjectKey
|
||||
now := time.Now().UTC()
|
||||
require.NoError(t, repo.Save(context.Background(), &model.Model{
|
||||
ID: "m-upload",
|
||||
OwnerUserID: "demo-user",
|
||||
Name: "uploaded",
|
||||
StorageKey: "models/demo-user/m-upload.nef",
|
||||
Source: model.SourceUploaded,
|
||||
UploadedAt: &now,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/models/m-upload/download", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusNotImplemented, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "uploaded models")
|
||||
assert.Equal(t, 0, iss.calls, "should not issue token for uploaded model")
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// 404 / 403 — ownership
|
||||
// ==========================================================================
|
||||
|
||||
func TestModelsDownload_NotFound(t *testing.T) {
|
||||
iss := &fakeIssuer{token: "fdt_x"}
|
||||
r, _ := newDownloadFixture(t, iss, "https://faa.example.com:5081", "demo-user")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/models/does-not-exist/download", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
assert.Contains(t, w.Body.String(), ErrCodeNotFound)
|
||||
}
|
||||
|
||||
func TestModelsDownload_ForbiddenWhenNotOwner(t *testing.T) {
|
||||
iss := &fakeIssuer{token: "fdt_x"}
|
||||
// 登入 user = demo-user,但 model owner = other-user
|
||||
r, repo := newDownloadFixture(t, iss, "https://faa.example.com:5081", "demo-user")
|
||||
seedConvertedModel(t, repo, "m-other", "other-user", "models/other-user/job.nef")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/models/m-other/download", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusForbidden, w.Code)
|
||||
assert.Contains(t, w.Body.String(), ErrCodeForbidden)
|
||||
assert.Equal(t, 0, iss.calls, "should not issue token for non-owner")
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// 502 — issuer 失敗
|
||||
// ==========================================================================
|
||||
|
||||
func TestModelsDownload_IssuerFailureReturns502(t *testing.T) {
|
||||
iss := &fakeIssuer{err: errors.New("mc unavailable")}
|
||||
r, repo := newDownloadFixture(t, iss, "https://faa.example.com:5081", "demo-user")
|
||||
seedConvertedModel(t, repo, "m1", "demo-user", "models/demo-user/job.nef")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/models/m1/download", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadGateway, w.Code)
|
||||
assert.Contains(t, w.Body.String(), ErrCodeInternalError)
|
||||
// 對外 mask:不應洩漏 "mc unavailable" 內部細節
|
||||
assert.NotContains(t, w.Body.String(), "mc unavailable")
|
||||
}
|
||||
@ -24,6 +24,8 @@ type Config struct {
|
||||
// Conversion 控制 Phase 0.8 轉檔功能整合(converter / FAA / MC service token)。
|
||||
// 對齊 .autoflow/04-architecture/conversion.md §5.3。
|
||||
Conversion ConversionConfig
|
||||
// FileAccess 控制 Phase 0.9 模型庫 model 直連 FAA 下載鏈(ADR-017 (a))。
|
||||
FileAccess FileAccessConfig
|
||||
}
|
||||
|
||||
// ServerConfig 控制 HTTP listener 的位址與埠號。
|
||||
@ -223,6 +225,69 @@ type ConversionConfig struct {
|
||||
MaxModelSizeMB int
|
||||
}
|
||||
|
||||
// FileAccessConfig 控制「模型庫 model 直連 FAA 下載」鏈路(ADR-017 (a))。
|
||||
//
|
||||
// 對齊 adr/adr-017-model-library-access.md §10(stage e2e 實測藍本):
|
||||
//
|
||||
// visionA 用 service client 打 MC `/oauth/token`(scope files:download.delegate)
|
||||
// → 打 MC `POST /file-access/download-tokens`(Issue)簽 opaque `fdt_` token
|
||||
// → 回給 Client「FAA 下載 URL + fdt token」,Client 帶 `Authorization: Bearer fdt_...`
|
||||
// 直接 GET `{FAA}/files/{object_key}`。
|
||||
//
|
||||
// 啟用判定(由 Enabled() 給 main.go 用):4 個必要欄位
|
||||
// (MCBaseURL / ServiceClientID / ServiceClientSecret / TenantID)全部非空才視為啟用;
|
||||
// 任一缺即不 wire download token issuer,model download endpoint 回 501。
|
||||
// FAABaseURL 是「組對外 download_url」用,留空時 endpoint 也回 501(無從組 URL)。
|
||||
//
|
||||
// ⚠️ 技術債(ADR-017 §7 R1 / Q10):第一階段 PoC 短期**共用 FAA 的 service client**
|
||||
// (`4242ba63...`,stage 實測可拿 files:download.delegate token)。MC 規範明訂「OAuth client
|
||||
// 禁止混用 usage、secret 不共用」,故正式上線前須請 MC 配發 visionA 專屬 usage=file_api
|
||||
// client 換掉此共用 client,把 secret 邊界收回 visionA。詳見 .env.example 對應註解。
|
||||
type FileAccessConfig struct {
|
||||
// MCBaseURL 是 Member Center API base URL(不帶結尾斜線)。
|
||||
// stage:https://stage-9527.innovedus.com:7850
|
||||
// 對齊 VISIONA_FILE_ACCESS_MC_BASE_URL。
|
||||
MCBaseURL string
|
||||
|
||||
// ServiceClientID 是打 MC `/oauth/token`(client_credentials)的 client id。
|
||||
// ⚠️ 技術債:第一階段 PoC 共用 FAA service client(`4242ba63...`)。
|
||||
// 對齊 VISIONA_FILE_ACCESS_SERVICE_CLIENT_ID。
|
||||
ServiceClientID string
|
||||
|
||||
// ServiceClientSecret 是 service client 的 secret。
|
||||
// **禁止 commit 進 repo**;對齊 VISIONA_FILE_ACCESS_SERVICE_CLIENT_SECRET。
|
||||
// 安全:log 永遠不印此值全文。
|
||||
ServiceClientSecret string
|
||||
|
||||
// TenantID 是簽 download token 時帶給 MC 的 tenant_id(須與 FAA validate 的 tenant 一致)。
|
||||
// stage:732270c0-449c-489c-bfad-321e9bf89b3d
|
||||
// 對齊 VISIONA_FILE_ACCESS_TENANT_ID。
|
||||
TenantID string
|
||||
|
||||
// FAABaseURL 是 File Access Agent 對外 base URL(不帶結尾斜線),用來組回給 Client 的
|
||||
// download_url(`{FAABaseURL}/files/{object_key}`)。
|
||||
// stage:https://stage-9527.innovedus.com:5081
|
||||
// 對齊 VISIONA_FILE_ACCESS_FAA_BASE_URL。
|
||||
FAABaseURL string
|
||||
|
||||
// DownloadTokenTTLSeconds 是簽 download token 時帶給 MC 的 expires_in_seconds。
|
||||
// ADR-017 Q2 區間 60–300s,預設 120s。對齊 VISIONA_FILE_ACCESS_DOWNLOAD_TOKEN_TTL_SECONDS。
|
||||
DownloadTokenTTLSeconds int
|
||||
}
|
||||
|
||||
// Enabled 回傳「模型庫 FAA 直連下載」是否啟用。
|
||||
//
|
||||
// 4 個必要欄位(MCBaseURL / ServiceClientID / ServiceClientSecret / TenantID)+ FAABaseURL
|
||||
// 全部非空才視為啟用;任一缺 → main.go 不 wire download token issuer,
|
||||
// GET /api/models/:id/download 回 501。
|
||||
func (c FileAccessConfig) Enabled() bool {
|
||||
return c.MCBaseURL != "" &&
|
||||
c.ServiceClientID != "" &&
|
||||
c.ServiceClientSecret != "" &&
|
||||
c.TenantID != "" &&
|
||||
c.FAABaseURL != ""
|
||||
}
|
||||
|
||||
// Enabled 回傳 Phase 0.8 / 0.8b conversion 是否啟用。
|
||||
//
|
||||
// **Phase 0.8b v0.6 T4 簡化**(ADR-016 §2 / conversion.md v0.6.1 §3.1):visionA 端撤回
|
||||
|
||||
@ -81,6 +81,17 @@ func Load() *Config {
|
||||
ConverterAPIKey: getEnvString("VISIONA_CONVERTER_API_KEY", ""),
|
||||
MaxModelSizeMB: getEnvInt("VISIONA_CONVERTER_MAX_MODEL_SIZE_MB", 500),
|
||||
},
|
||||
// Phase 0.9 模型庫 FAA 直連下載(ADR-017 (a),見 adr-017 §10 stage e2e 藍本)。
|
||||
// ⚠️ 技術債:ServiceClientID/Secret 第一階段 PoC 共用 FAA 的 service client;
|
||||
// 正式上線前須換 visionA 專屬 usage=file_api client(ADR-017 §7 R1 / Q10)。
|
||||
FileAccess: FileAccessConfig{
|
||||
MCBaseURL: getEnvString("VISIONA_FILE_ACCESS_MC_BASE_URL", ""),
|
||||
ServiceClientID: getEnvString("VISIONA_FILE_ACCESS_SERVICE_CLIENT_ID", ""),
|
||||
ServiceClientSecret: getEnvString("VISIONA_FILE_ACCESS_SERVICE_CLIENT_SECRET", ""),
|
||||
TenantID: getEnvString("VISIONA_FILE_ACCESS_TENANT_ID", ""),
|
||||
FAABaseURL: getEnvString("VISIONA_FILE_ACCESS_FAA_BASE_URL", ""),
|
||||
DownloadTokenTTLSeconds: getEnvInt("VISIONA_FILE_ACCESS_DOWNLOAD_TOKEN_TTL_SECONDS", 120),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -84,6 +84,10 @@ type ModelRecord struct {
|
||||
TargetChip string
|
||||
Source string // 永遠 "converted"
|
||||
SourceJobID string
|
||||
// FAAObjectKey 是該 model 在 FAA 上的 object key(ADR-017 (a) B1)。
|
||||
// = converter promote 的 target_object_key(buildTargetObjectKey:models/{userID}/{jobID}.nef)。
|
||||
// PromoteToModels 寫入;adapter 對映到 model.Model.FAAObjectKey。
|
||||
FAAObjectKey string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
@ -650,6 +654,16 @@ func (f *flow) PromoteToModels(ctx context.Context, userID, jobID, name string)
|
||||
}
|
||||
|
||||
// 7. modelStore.Save
|
||||
//
|
||||
// FAAObjectKey(ADR-017 (a) B1):promote 把 NEF 推進 FAA 用的 object_key。
|
||||
// 以 converter promote response 回傳的 TargetObjectKey 為權威值(converter 可能對 key
|
||||
// 正規化);promote response 萬一沒回(理論上不會,parseConverterPromoteResult 已要求非空)
|
||||
// 則 fallback 回 visionA 端組的 targetObjectKey。download endpoint 用此 key Issue MC token。
|
||||
faaObjectKey := promoteRes.TargetObjectKey
|
||||
if faaObjectKey == "" {
|
||||
faaObjectKey = targetObjectKey
|
||||
}
|
||||
|
||||
now := f.now().UTC()
|
||||
rec := &ModelRecord{
|
||||
ID: modelID,
|
||||
@ -661,6 +675,7 @@ func (f *flow) PromoteToModels(ctx context.Context, userID, jobID, name string)
|
||||
TargetChip: normalizeTargetChip(cj.Platform),
|
||||
Source: "converted",
|
||||
SourceJobID: jobID,
|
||||
FAAObjectKey: faaObjectKey,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
427
visionA-backend/internal/fileaccess/client.go
Normal file
427
visionA-backend/internal/fileaccess/client.go
Normal file
@ -0,0 +1,427 @@
|
||||
// Package fileaccess 實作「模型庫 model 直連 FAA 下載」的 MC 認證鏈(ADR-017 (a))。
|
||||
//
|
||||
// 對齊 docs/autoflow/04-architecture/adr/adr-017-model-library-access.md §10
|
||||
// (stage 真實環境 e2e 實測藍本)。本套件只負責「跟 MC 拿 service token + 簽
|
||||
// download token(fdt_)」,不負責真的打 FAA 下載——下載由 Client(local-tool /
|
||||
// browser)帶 fdt token 直接 GET {FAA}/files/{object_key}。
|
||||
//
|
||||
// 與 internal/conversion 的關係:
|
||||
// - conversion 走「visionA → converter pre-shared API key」(ADR-015/016),是另一條路徑。
|
||||
// - fileaccess 走「visionA → MC OAuth client_credentials → 簽 fdt token」(ADR-017 (a)),
|
||||
// 是模型庫持久資產的下載路徑。兩者並存、用途不同(ADR-017 決策 4.5)。
|
||||
//
|
||||
// ⚠️ 技術債(ADR-017 §7 R1 / Q10):第一階段 PoC 短期共用 FAA 的 service client
|
||||
// (stage `4242ba63...`)。MC 規範明訂「OAuth client 禁止混用 usage、secret 不共用」,
|
||||
// 正式上線前須請 MC 配發 visionA 專屬 usage=file_api client 換掉,把 secret 邊界收回 visionA。
|
||||
//
|
||||
// 安全:
|
||||
// - 絕不把 client secret / access token / fdt token 全文寫進 log(連前綴也不印)。
|
||||
// - 只 log object_key hash / user hash / 結果狀態。
|
||||
package fileaccess
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ==========================================================================
|
||||
// Errors(sentinel)
|
||||
// ==========================================================================
|
||||
|
||||
var (
|
||||
// ErrServiceTokenFailed 表示打 MC /oauth/token 拿 service token 失敗
|
||||
// (網路 / 4xx / 5xx / 解析)。caller(handler)對外 mask 成 download_unavailable / 502。
|
||||
ErrServiceTokenFailed = errors.New("fileaccess: service token request failed")
|
||||
|
||||
// ErrIssueTokenFailed 表示打 MC Issue(POST /file-access/download-tokens)失敗。
|
||||
// caller 對外 mask 成 download_unavailable / 502。
|
||||
ErrIssueTokenFailed = errors.New("fileaccess: issue download token failed")
|
||||
|
||||
// ErrConfigIncomplete 表示 client 建構參數不完整(缺 MCBaseURL / client / tenant)。
|
||||
ErrConfigIncomplete = errors.New("fileaccess: required config is missing")
|
||||
)
|
||||
|
||||
// ==========================================================================
|
||||
// 對外 type / interface
|
||||
// ==========================================================================
|
||||
|
||||
// IssuedDownloadToken 是 IssueDownloadToken 的結果。
|
||||
//
|
||||
// Token 是 MC 簽的 opaque `fdt_<base64url>`(不是 JWT),caller 直接回給 Client
|
||||
// 當 `Authorization: Bearer {Token}` 打 FAA。ExpiresAt 由 MC 回填(UTC)。
|
||||
type IssuedDownloadToken struct {
|
||||
Token string // opaque fdt_ token(敏感,勿 log 全文)
|
||||
TokenType string // MC 回 "file_download"
|
||||
ExpiresAt time.Time // MC 回填的到期時間(UTC)
|
||||
Scope string // MC 回 "files:download.read"
|
||||
}
|
||||
|
||||
// DownloadTokenIssuer 是「簽 model download token」的最小對外介面。
|
||||
//
|
||||
// handler 依賴此 interface(而非具體 client),方便 unit test 注入 fake、
|
||||
// 也方便未來換成 visionA 專屬 client 時不動 handler。
|
||||
type DownloadTokenIssuer interface {
|
||||
// IssueDownloadToken 對指定 (userID, objectKey) 簽一個 MC download token。
|
||||
//
|
||||
// - userID 必須是 MC 真實 user(OIDC sub);visionA 登入走 MC OIDC,
|
||||
// UserContext.UserID 即 OIDC sub,直接傳入即可(ADR-017 §10 契約細節)。
|
||||
// - objectKey 必須與 FAA GET 的 path key 完全一致(FAA 從 URL path 取 objectKey
|
||||
// 去 MC validate boundary,不一致 FAA 回 object_key_mismatch)。
|
||||
//
|
||||
// 失敗回 ErrServiceTokenFailed / ErrIssueTokenFailed(已包進細節)。
|
||||
IssueDownloadToken(ctx context.Context, userID, objectKey string) (*IssuedDownloadToken, error)
|
||||
}
|
||||
|
||||
// Opts 是 NewClient 的依賴注入。
|
||||
//
|
||||
// 必填:MCBaseURL / ServiceClientID / ServiceClientSecret / TenantID。
|
||||
// HTTPClient / Now / Logger 為 optional(nil 自動填預設)— 方便 unit test 注入 fake。
|
||||
type Opts struct {
|
||||
// MCBaseURL 是 Member Center API base URL(不帶結尾斜線)。
|
||||
MCBaseURL string
|
||||
|
||||
// ServiceClientID / ServiceClientSecret 打 MC /oauth/token 用(client_credentials)。
|
||||
// ⚠️ 技術債:第一階段 PoC 共用 FAA service client(見套件註解)。
|
||||
ServiceClientID string
|
||||
ServiceClientSecret string
|
||||
|
||||
// TenantID 簽 download token 時帶給 MC(須與 FAA validate 的 tenant 一致)。
|
||||
TenantID string
|
||||
|
||||
// DownloadTokenTTLSeconds 是簽 token 時帶給 MC 的 expires_in_seconds。
|
||||
// 0 → 用 defaultDownloadTokenTTLSeconds(120,ADR-017 Q2)。
|
||||
DownloadTokenTTLSeconds int
|
||||
|
||||
// HTTPClient 為 optional;nil 用預設(timeout 10s)。MC call 是輕量 JSON POST。
|
||||
HTTPClient *http.Client
|
||||
|
||||
// Now 為 optional;nil 用 time.Now(service token cache 過期判定用)。
|
||||
Now func() time.Time
|
||||
|
||||
// Logger 為 optional;nil 用 slog.Default()。
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// 內部常數
|
||||
// ==========================================================================
|
||||
|
||||
const (
|
||||
// downloadDelegateScope 是打 MC /oauth/token 時要的 scope(ADR-017 §10.2/§10.3)。
|
||||
downloadDelegateScope = "files:download.delegate"
|
||||
|
||||
// oauthTokenPath / issuePath 是 MC 的 endpoint path(ADR-017 §10)。
|
||||
oauthTokenPath = "/oauth/token"
|
||||
issuePath = "/file-access/download-tokens"
|
||||
|
||||
// defaultDownloadTokenTTLSeconds 是 download token 預設有效期(秒)—— ADR-017 Q2(傾向 120s)。
|
||||
defaultDownloadTokenTTLSeconds = 120
|
||||
|
||||
// defaultHTTPTimeout 是 MC call 的整體 timeout(OAuth / Issue 都是輕量 JSON)。
|
||||
defaultHTTPTimeout = 10 * time.Second
|
||||
|
||||
// serviceTokenRefreshSkew 是 service token cache 提前刷新的安全邊際——
|
||||
// token 還有少於這個秒數就視為過期、重拿,避免「拿了正好過期的 token」。
|
||||
serviceTokenRefreshSkew = 30 * time.Second
|
||||
|
||||
// issueMethod 是簽 download token 時帶給 MC 的 method(FAA download 是 GET)。
|
||||
issueMethod = "GET"
|
||||
)
|
||||
|
||||
// ==========================================================================
|
||||
// 構造 + 內部 struct
|
||||
// ==========================================================================
|
||||
|
||||
// client 是 DownloadTokenIssuer 的預設實作。
|
||||
type client struct {
|
||||
mcBaseURL string
|
||||
clientID string
|
||||
clientSecret string
|
||||
tenantID string
|
||||
ttlSeconds int
|
||||
|
||||
http *http.Client
|
||||
now func() time.Time
|
||||
logger *slog.Logger
|
||||
|
||||
// service token cache(client_credentials token 可重用到過期前)。
|
||||
mu sync.Mutex
|
||||
cachedToken string
|
||||
cachedTokenExp time.Time // UTC
|
||||
}
|
||||
|
||||
// 編譯時檢查:確保 client 實作 DownloadTokenIssuer。
|
||||
var _ DownloadTokenIssuer = (*client)(nil)
|
||||
|
||||
// NewClient 建立一個 fileaccess client。
|
||||
//
|
||||
// 必填欄位任一為空 → 回 ErrConfigIncomplete(讓 main.go fail-fast,不在「設定不全」
|
||||
// 狀態下把 endpoint 接起來)。
|
||||
func NewClient(opts Opts) (DownloadTokenIssuer, error) {
|
||||
if opts.MCBaseURL == "" || opts.ServiceClientID == "" ||
|
||||
opts.ServiceClientSecret == "" || opts.TenantID == "" {
|
||||
return nil, ErrConfigIncomplete
|
||||
}
|
||||
ttl := opts.DownloadTokenTTLSeconds
|
||||
if ttl <= 0 {
|
||||
ttl = defaultDownloadTokenTTLSeconds
|
||||
}
|
||||
httpClient := opts.HTTPClient
|
||||
if httpClient == nil {
|
||||
httpClient = &http.Client{Timeout: defaultHTTPTimeout}
|
||||
}
|
||||
now := opts.Now
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
logger := opts.Logger
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &client{
|
||||
mcBaseURL: strings.TrimRight(opts.MCBaseURL, "/"),
|
||||
clientID: opts.ServiceClientID,
|
||||
clientSecret: opts.ServiceClientSecret,
|
||||
tenantID: opts.TenantID,
|
||||
ttlSeconds: ttl,
|
||||
http: httpClient,
|
||||
now: now,
|
||||
logger: logger,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// GetServiceToken — 打 MC /oauth/token(client_credentials, scope files:download.delegate)
|
||||
// ==========================================================================
|
||||
|
||||
// oauthTokenResponse 是 MC /oauth/token 的 response shape(標準 OAuth2 token response)。
|
||||
type oauthTokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"` // 秒
|
||||
Scope string `json:"scope"`
|
||||
}
|
||||
|
||||
// GetServiceToken 取得(或從 cache 重用)MC service access token。
|
||||
//
|
||||
// 帶簡單 cache:token 未過期(含 serviceTokenRefreshSkew 安全邊際)直接回 cache,
|
||||
// 否則打 MC /oauth/token 重拿。goroutine-safe(mutex 保護 cache)。
|
||||
//
|
||||
// 失敗回 ErrServiceTokenFailed(已包細節)。
|
||||
func (c *client) GetServiceToken(ctx context.Context) (string, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
// cache 命中(還有效)→ 直接回
|
||||
if c.cachedToken != "" && c.now().UTC().Add(serviceTokenRefreshSkew).Before(c.cachedTokenExp) {
|
||||
return c.cachedToken, nil
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("grant_type", "client_credentials")
|
||||
form.Set("client_id", c.clientID)
|
||||
form.Set("client_secret", c.clientSecret)
|
||||
form.Set("scope", downloadDelegateScope)
|
||||
|
||||
endpoint := c.mcBaseURL + oauthTokenPath
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint,
|
||||
strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: build oauth request: %v", ErrServiceTokenFailed, err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
// 網路 / timeout / ctx cancel 都歸 service token 失敗
|
||||
return "", fmt.Errorf("%w: do oauth request: %v", ErrServiceTokenFailed, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, readErr := io.ReadAll(io.LimitReader(resp.Body, errorBodyReadCap))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
// 安全:不 log secret;body 可能含 error_description(不含 secret),可帶上 status
|
||||
return "", fmt.Errorf("%w: oauth status=%d body=%s", ErrServiceTokenFailed,
|
||||
resp.StatusCode, truncate(string(body)))
|
||||
}
|
||||
if readErr != nil {
|
||||
return "", fmt.Errorf("%w: read oauth body: %v", ErrServiceTokenFailed, readErr)
|
||||
}
|
||||
|
||||
var parsed oauthTokenResponse
|
||||
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||
return "", fmt.Errorf("%w: parse oauth body: %v", ErrServiceTokenFailed, err)
|
||||
}
|
||||
if parsed.AccessToken == "" {
|
||||
return "", fmt.Errorf("%w: oauth response missing access_token", ErrServiceTokenFailed)
|
||||
}
|
||||
|
||||
// 更新 cache。expires_in 缺省(0)時保守用 serviceTokenRefreshSkew * 2 當下限,
|
||||
// 避免把過期時間設成「現在」導致每次都 miss。
|
||||
expiresIn := time.Duration(parsed.ExpiresIn) * time.Second
|
||||
if expiresIn <= serviceTokenRefreshSkew {
|
||||
expiresIn = 2 * serviceTokenRefreshSkew
|
||||
}
|
||||
c.cachedToken = parsed.AccessToken
|
||||
c.cachedTokenExp = c.now().UTC().Add(expiresIn)
|
||||
|
||||
c.logger.DebugContext(ctx, "fileaccess.service_token.refreshed",
|
||||
slog.String("scope", parsed.Scope),
|
||||
slog.Int("expires_in_sec", parsed.ExpiresIn),
|
||||
)
|
||||
return parsed.AccessToken, nil
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// IssueDownloadToken — 打 MC POST /file-access/download-tokens(Issue)簽 fdt_ token
|
||||
// ==========================================================================
|
||||
|
||||
// issueRequest 是 MC Issue 的 request body(ADR-017 §10 實測契約)。
|
||||
type issueRequest struct {
|
||||
TenantID string `json:"tenant_id"`
|
||||
UserID string `json:"user_id"`
|
||||
ObjectKey string `json:"object_key"`
|
||||
Method string `json:"method"`
|
||||
ExpiresInSeconds int `json:"expires_in_seconds"`
|
||||
}
|
||||
|
||||
// issueResponse 是 MC Issue 的 response shape(ADR-017 §10.3 實測)。
|
||||
type issueResponse struct {
|
||||
Token string `json:"token"` // fdt_<base64url>
|
||||
TokenType string `json:"token_type"` // "file_download"
|
||||
ExpiresAt string `json:"expires_at"` // RFC3339
|
||||
Scope string `json:"scope"` // "files:download.read"
|
||||
}
|
||||
|
||||
// IssueDownloadToken 對指定 (userID, objectKey) 簽一個 MC download token(fdt_)。
|
||||
//
|
||||
// 流程(ADR-017 §10.3 e2e 藍本):
|
||||
// 1. GetServiceToken — 拿(或重用)MC service token
|
||||
// 2. 帶該 token + tenant/user/object_key/method/expires_in 打 MC Issue
|
||||
// 3. 回 IssuedDownloadToken(Token / ExpiresAt / Scope)
|
||||
func (c *client) IssueDownloadToken(ctx context.Context, userID, objectKey string) (*IssuedDownloadToken, error) {
|
||||
if userID == "" {
|
||||
return nil, fmt.Errorf("%w: userID is required", ErrIssueTokenFailed)
|
||||
}
|
||||
if objectKey == "" {
|
||||
return nil, fmt.Errorf("%w: objectKey is required", ErrIssueTokenFailed)
|
||||
}
|
||||
|
||||
serviceToken, err := c.GetServiceToken(ctx)
|
||||
if err != nil {
|
||||
return nil, err // 已是 ErrServiceTokenFailed
|
||||
}
|
||||
|
||||
bodyJSON, err := json.Marshal(issueRequest{
|
||||
TenantID: c.tenantID,
|
||||
UserID: userID,
|
||||
ObjectKey: objectKey,
|
||||
Method: issueMethod,
|
||||
ExpiresInSeconds: c.ttlSeconds,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: marshal issue request: %v", ErrIssueTokenFailed, err)
|
||||
}
|
||||
|
||||
endpoint := c.mcBaseURL + issuePath
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(bodyJSON))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: build issue request: %v", ErrIssueTokenFailed, err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+serviceToken)
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: do issue request: %v", ErrIssueTokenFailed, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, readErr := io.ReadAll(io.LimitReader(resp.Body, errorBodyReadCap))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("%w: issue status=%d body=%s", ErrIssueTokenFailed,
|
||||
resp.StatusCode, truncate(string(body)))
|
||||
}
|
||||
if readErr != nil {
|
||||
return nil, fmt.Errorf("%w: read issue body: %v", ErrIssueTokenFailed, readErr)
|
||||
}
|
||||
|
||||
var parsed issueResponse
|
||||
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||
return nil, fmt.Errorf("%w: parse issue body: %v", ErrIssueTokenFailed, err)
|
||||
}
|
||||
if parsed.Token == "" {
|
||||
return nil, fmt.Errorf("%w: issue response missing token", ErrIssueTokenFailed)
|
||||
}
|
||||
|
||||
// expires_at 解析失敗不 hard fail(token 本身可用,TTL 只是給 client 參考);
|
||||
// 解析成功則回填 UTC。
|
||||
var expiresAt time.Time
|
||||
if parsed.ExpiresAt != "" {
|
||||
if t, perr := time.Parse(time.RFC3339, parsed.ExpiresAt); perr == nil {
|
||||
expiresAt = t.UTC()
|
||||
} else {
|
||||
c.logger.WarnContext(ctx, "fileaccess.issue.expires_at_parse_failed",
|
||||
slog.String("raw", parsed.ExpiresAt),
|
||||
slog.String("err", perr.Error()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
c.logger.InfoContext(ctx, "fileaccess.issue.success",
|
||||
slog.String("user_hash", hashShort(userID)),
|
||||
slog.String("object_key_hash", hashShort(objectKey)),
|
||||
slog.String("scope", parsed.Scope),
|
||||
slog.Int("ttl_sec", c.ttlSeconds),
|
||||
)
|
||||
|
||||
return &IssuedDownloadToken{
|
||||
Token: parsed.Token,
|
||||
TokenType: parsed.TokenType,
|
||||
ExpiresAt: expiresAt,
|
||||
Scope: parsed.Scope,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// helpers
|
||||
// ==========================================================================
|
||||
|
||||
// errorBodyReadCap 是失敗 response 從 body 讀進記憶體的最大量(4KB)—— 避免惡意大 body。
|
||||
const errorBodyReadCap = 4 * 1024
|
||||
|
||||
// truncate 把 error body 截短(log / error message 用),避免超長 / 換行污染 log。
|
||||
func truncate(s string) string {
|
||||
s = strings.ReplaceAll(strings.TrimSpace(s), "\n", " ")
|
||||
const max = 256
|
||||
if len(s) > max {
|
||||
return s[:max] + "...(truncated)"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// hashShort 對輸入做 SHA-256 取前 8 hex char,給 log 用(PII / object_key 保護)。
|
||||
//
|
||||
// 不存原始 user_id / object_key 進 log,避免 log file 洩漏 OIDC sub 或 storage 路徑。
|
||||
// 對齊 internal/conversion.hashUserID 的遮罩慣例。
|
||||
func hashShort(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
sum := sha256.Sum256([]byte(s))
|
||||
return hex.EncodeToString(sum[:])[:8]
|
||||
}
|
||||
360
visionA-backend/internal/fileaccess/client_test.go
Normal file
360
visionA-backend/internal/fileaccess/client_test.go
Normal file
@ -0,0 +1,360 @@
|
||||
package fileaccess
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ==========================================================================
|
||||
// 測試輔助
|
||||
// ==========================================================================
|
||||
|
||||
// newTestClient 用 httptest server base URL 建一個 *client(拿具體型別,方便測 cache)。
|
||||
func newTestClient(t *testing.T, baseURL string, ttlSec int) *client {
|
||||
t.Helper()
|
||||
iss, err := NewClient(Opts{
|
||||
MCBaseURL: baseURL,
|
||||
ServiceClientID: "test-client-id",
|
||||
ServiceClientSecret: "test-secret",
|
||||
TenantID: "test-tenant",
|
||||
DownloadTokenTTLSeconds: ttlSec,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient: %v", err)
|
||||
}
|
||||
c, ok := iss.(*client)
|
||||
if !ok {
|
||||
t.Fatalf("NewClient did not return *client, got %T", iss)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// NewClient
|
||||
// ==========================================================================
|
||||
|
||||
func TestNewClient_returnsErrWhenConfigIncomplete(t *testing.T) {
|
||||
cases := map[string]Opts{
|
||||
"missing MCBaseURL": {ServiceClientID: "c", ServiceClientSecret: "s", TenantID: "t"},
|
||||
"missing clientID": {MCBaseURL: "http://mc", ServiceClientSecret: "s", TenantID: "t"},
|
||||
"missing clientSecret": {MCBaseURL: "http://mc", ServiceClientID: "c", TenantID: "t"},
|
||||
"missing tenant": {MCBaseURL: "http://mc", ServiceClientID: "c", ServiceClientSecret: "s"},
|
||||
}
|
||||
for name, opts := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, err := NewClient(opts)
|
||||
if !errors.Is(err, ErrConfigIncomplete) {
|
||||
t.Fatalf("want ErrConfigIncomplete, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClient_defaultsTTLWhenNonPositive(t *testing.T) {
|
||||
iss, err := NewClient(Opts{
|
||||
MCBaseURL: "http://mc", ServiceClientID: "c", ServiceClientSecret: "s", TenantID: "t",
|
||||
DownloadTokenTTLSeconds: 0,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient: %v", err)
|
||||
}
|
||||
if got := iss.(*client).ttlSeconds; got != defaultDownloadTokenTTLSeconds {
|
||||
t.Fatalf("ttl default: want %d, got %d", defaultDownloadTokenTTLSeconds, got)
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// GetServiceToken
|
||||
// ==========================================================================
|
||||
|
||||
func TestGetServiceToken_successAndSendsClientCredentials(t *testing.T) {
|
||||
var gotGrant, gotScope, gotClientID, gotSecret string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != oauthTokenPath {
|
||||
t.Errorf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
if ct := r.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/x-www-form-urlencoded") {
|
||||
t.Errorf("want form content-type, got %s", ct)
|
||||
}
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
form, _ := url.ParseQuery(string(body))
|
||||
gotGrant = form.Get("grant_type")
|
||||
gotScope = form.Get("scope")
|
||||
gotClientID = form.Get("client_id")
|
||||
gotSecret = form.Get("client_secret")
|
||||
writeJSON(w, http.StatusOK, oauthTokenResponse{
|
||||
AccessToken: "svc-access-token", TokenType: "Bearer", ExpiresIn: 3600,
|
||||
Scope: downloadDelegateScope,
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := newTestClient(t, srv.URL, 120)
|
||||
tok, err := c.GetServiceToken(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("GetServiceToken: %v", err)
|
||||
}
|
||||
if tok != "svc-access-token" {
|
||||
t.Fatalf("token: want svc-access-token, got %s", tok)
|
||||
}
|
||||
if gotGrant != "client_credentials" {
|
||||
t.Errorf("grant_type: want client_credentials, got %s", gotGrant)
|
||||
}
|
||||
if gotScope != downloadDelegateScope {
|
||||
t.Errorf("scope: want %s, got %s", downloadDelegateScope, gotScope)
|
||||
}
|
||||
if gotClientID != "test-client-id" || gotSecret != "test-secret" {
|
||||
t.Errorf("client creds not sent: id=%s secret=%s", gotClientID, gotSecret)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetServiceToken_cachesTokenAcrossCalls(t *testing.T) {
|
||||
var hits int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&hits, 1)
|
||||
writeJSON(w, http.StatusOK, oauthTokenResponse{
|
||||
AccessToken: "svc-access-token", TokenType: "Bearer", ExpiresIn: 3600,
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := newTestClient(t, srv.URL, 120)
|
||||
for i := 0; i < 3; i++ {
|
||||
if _, err := c.GetServiceToken(context.Background()); err != nil {
|
||||
t.Fatalf("call %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
if got := atomic.LoadInt32(&hits); got != 1 {
|
||||
t.Fatalf("want 1 oauth hit (cached), got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetServiceToken_refetchesWhenExpired(t *testing.T) {
|
||||
var hits int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&hits, 1)
|
||||
// expires_in 短到一定要重拿
|
||||
writeJSON(w, http.StatusOK, oauthTokenResponse{
|
||||
AccessToken: "svc-access-token", TokenType: "Bearer", ExpiresIn: 10,
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := newTestClient(t, srv.URL, 120)
|
||||
// 用可控時鐘:第一次 now=t0;第二次 now=t0+1h(必然過期)
|
||||
base := time.Date(2026, 6, 7, 0, 0, 0, 0, time.UTC)
|
||||
var cur time.Time = base
|
||||
c.now = func() time.Time { return cur }
|
||||
|
||||
if _, err := c.GetServiceToken(context.Background()); err != nil {
|
||||
t.Fatalf("first call: %v", err)
|
||||
}
|
||||
cur = base.Add(time.Hour)
|
||||
if _, err := c.GetServiceToken(context.Background()); err != nil {
|
||||
t.Fatalf("second call: %v", err)
|
||||
}
|
||||
if got := atomic.LoadInt32(&hits); got != 2 {
|
||||
t.Fatalf("want 2 oauth hits (refetch after expiry), got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetServiceToken_failsOnNon200(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`{"error":"invalid_client"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := newTestClient(t, srv.URL, 120)
|
||||
_, err := c.GetServiceToken(context.Background())
|
||||
if !errors.Is(err, ErrServiceTokenFailed) {
|
||||
t.Fatalf("want ErrServiceTokenFailed, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetServiceToken_failsOnMissingAccessToken(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, oauthTokenResponse{TokenType: "Bearer", ExpiresIn: 3600})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := newTestClient(t, srv.URL, 120)
|
||||
_, err := c.GetServiceToken(context.Background())
|
||||
if !errors.Is(err, ErrServiceTokenFailed) {
|
||||
t.Fatalf("want ErrServiceTokenFailed, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// IssueDownloadToken
|
||||
// ==========================================================================
|
||||
|
||||
func TestIssueDownloadToken_successFullChain(t *testing.T) {
|
||||
var issueAuth string
|
||||
var issueBody issueRequest
|
||||
expiresAt := time.Date(2026, 6, 7, 12, 2, 0, 0, time.UTC)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case oauthTokenPath:
|
||||
writeJSON(w, http.StatusOK, oauthTokenResponse{
|
||||
AccessToken: "svc-access-token", TokenType: "Bearer", ExpiresIn: 3600,
|
||||
})
|
||||
case issuePath:
|
||||
issueAuth = r.Header.Get("Authorization")
|
||||
_ = json.NewDecoder(r.Body).Decode(&issueBody)
|
||||
writeJSON(w, http.StatusOK, issueResponse{
|
||||
Token: "fdt_abc123", TokenType: "file_download",
|
||||
ExpiresAt: expiresAt.Format(time.RFC3339), Scope: "files:download.read",
|
||||
})
|
||||
default:
|
||||
t.Errorf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := newTestClient(t, srv.URL, 120)
|
||||
res, err := c.IssueDownloadToken(context.Background(), "user-oidc-sub", "models/u/j.nef")
|
||||
if err != nil {
|
||||
t.Fatalf("IssueDownloadToken: %v", err)
|
||||
}
|
||||
|
||||
if res.Token != "fdt_abc123" {
|
||||
t.Errorf("token: want fdt_abc123, got %s", res.Token)
|
||||
}
|
||||
if res.TokenType != "file_download" {
|
||||
t.Errorf("token_type: want file_download, got %s", res.TokenType)
|
||||
}
|
||||
if res.Scope != "files:download.read" {
|
||||
t.Errorf("scope: want files:download.read, got %s", res.Scope)
|
||||
}
|
||||
if !res.ExpiresAt.Equal(expiresAt) {
|
||||
t.Errorf("expires_at: want %v, got %v", expiresAt, res.ExpiresAt)
|
||||
}
|
||||
// Issue 必須帶 service token 當 Bearer
|
||||
if issueAuth != "Bearer svc-access-token" {
|
||||
t.Errorf("issue auth header: want 'Bearer svc-access-token', got %q", issueAuth)
|
||||
}
|
||||
// Issue body 契約(ADR-017 §10)
|
||||
if issueBody.TenantID != "test-tenant" {
|
||||
t.Errorf("tenant_id: want test-tenant, got %s", issueBody.TenantID)
|
||||
}
|
||||
if issueBody.UserID != "user-oidc-sub" {
|
||||
t.Errorf("user_id: want user-oidc-sub, got %s", issueBody.UserID)
|
||||
}
|
||||
if issueBody.ObjectKey != "models/u/j.nef" {
|
||||
t.Errorf("object_key: want models/u/j.nef, got %s", issueBody.ObjectKey)
|
||||
}
|
||||
if issueBody.Method != "GET" {
|
||||
t.Errorf("method: want GET, got %s", issueBody.Method)
|
||||
}
|
||||
if issueBody.ExpiresInSeconds != 120 {
|
||||
t.Errorf("expires_in_seconds: want 120, got %d", issueBody.ExpiresInSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueDownloadToken_validatesArgs(t *testing.T) {
|
||||
c := newTestClient(t, "http://unused", 120)
|
||||
if _, err := c.IssueDownloadToken(context.Background(), "", "key"); !errors.Is(err, ErrIssueTokenFailed) {
|
||||
t.Errorf("empty userID: want ErrIssueTokenFailed, got %v", err)
|
||||
}
|
||||
if _, err := c.IssueDownloadToken(context.Background(), "user", ""); !errors.Is(err, ErrIssueTokenFailed) {
|
||||
t.Errorf("empty objectKey: want ErrIssueTokenFailed, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueDownloadToken_failsWhenServiceTokenFails(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// oauth 永遠 500
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := newTestClient(t, srv.URL, 120)
|
||||
_, err := c.IssueDownloadToken(context.Background(), "user", "key")
|
||||
if !errors.Is(err, ErrServiceTokenFailed) {
|
||||
t.Fatalf("want ErrServiceTokenFailed (propagated), got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueDownloadToken_failsOnIssueNon200(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case oauthTokenPath:
|
||||
writeJSON(w, http.StatusOK, oauthTokenResponse{AccessToken: "tok", ExpiresIn: 3600})
|
||||
case issuePath:
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
_, _ = w.Write([]byte(`{"error":"forbidden"}`))
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := newTestClient(t, srv.URL, 120)
|
||||
_, err := c.IssueDownloadToken(context.Background(), "user", "key")
|
||||
if !errors.Is(err, ErrIssueTokenFailed) {
|
||||
t.Fatalf("want ErrIssueTokenFailed, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueDownloadToken_failsOnMissingToken(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case oauthTokenPath:
|
||||
writeJSON(w, http.StatusOK, oauthTokenResponse{AccessToken: "tok", ExpiresIn: 3600})
|
||||
case issuePath:
|
||||
writeJSON(w, http.StatusOK, issueResponse{TokenType: "file_download"}) // 無 token
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := newTestClient(t, srv.URL, 120)
|
||||
_, err := c.IssueDownloadToken(context.Background(), "user", "key")
|
||||
if !errors.Is(err, ErrIssueTokenFailed) {
|
||||
t.Fatalf("want ErrIssueTokenFailed, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueDownloadToken_toleratesBadExpiresAt(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case oauthTokenPath:
|
||||
writeJSON(w, http.StatusOK, oauthTokenResponse{AccessToken: "tok", ExpiresIn: 3600})
|
||||
case issuePath:
|
||||
writeJSON(w, http.StatusOK, issueResponse{
|
||||
Token: "fdt_x", TokenType: "file_download", ExpiresAt: "not-a-date",
|
||||
})
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := newTestClient(t, srv.URL, 120)
|
||||
res, err := c.IssueDownloadToken(context.Background(), "user", "key")
|
||||
if err != nil {
|
||||
t.Fatalf("should tolerate bad expires_at, got err %v", err)
|
||||
}
|
||||
if res.Token != "fdt_x" {
|
||||
t.Errorf("token: want fdt_x, got %s", res.Token)
|
||||
}
|
||||
if !res.ExpiresAt.IsZero() {
|
||||
t.Errorf("bad expires_at should leave zero time, got %v", res.ExpiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// helpers(test-local)
|
||||
// ==========================================================================
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
@ -57,6 +57,18 @@ type Model struct {
|
||||
FileSize int64 `json:"fileSize"`
|
||||
FileChecksum string `json:"fileChecksum,omitempty"` // sha256 hex
|
||||
|
||||
// FAAObjectKey 是該 model 在 File Access Agent 上的 object key(ADR-017 (a) B1)。
|
||||
//
|
||||
// 只有「轉檔→promote 進 FAA」類 model(Source=converted)有值——promote 時由
|
||||
// PromoteToModels 寫入(= converter promote 的 target_object_key,命名 models/{userID}/{jobID}.nef)。
|
||||
// 上傳類 model(Source=uploaded)只在 visionA 自己 storage、不在 FAA,此欄位留空。
|
||||
//
|
||||
// model download endpoint(GET /api/models/:id/download)用此欄位(非 StorageKey)去 MC
|
||||
// Issue download token + 組 FAA URL;留空時回 501(第一階段不支援上傳類 FAA 直連)。
|
||||
//
|
||||
// nullable:DB 為 NULL(database.md §2.3 待補欄位,見回報);JSON `-` 完全不序列化到 API 回應,不向前端揭露 FAA 內部 object key。
|
||||
FAAObjectKey string `json:"-"` // 不對前端揭露(內部 storage key,ADR-017 決策 2 防曝露)
|
||||
|
||||
// 模型 metadata(可選)
|
||||
TargetChip string `json:"targetChip,omitempty"`
|
||||
InputShape []int `json:"inputShape,omitempty"`
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user