對齊 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>
224 lines
7.5 KiB
Go
224 lines
7.5 KiB
Go
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")
|
||
}
|