visionA/visionA-backend/internal/api/models_download_test.go
jim800121chen c63886a194 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>
2026-06-07 04:06:09 +08:00

224 lines
7.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 必須拿到正確的 userIDOIDC sub+ objectKeyFAAObjectKey非 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")
}