模型庫下載原本前端用 fetch + Authorization: Bearer 跨 origin 直連 FAA,
觸發 CORS preflight(OPTIONS);FAA 未設 CORS、OPTIONS 回 405 → 下載失敗。
FAA 設計本就支援「token 放 query string(access_token) + redirect 導航下載」,
故不需 FAA 設 CORS。改採 ADR-017 §11 v1.3 乙案:
- backend: download_url 組成含 ?access_token={url.QueryEscape(token)} 完整 FAA URL;
ModelDownloadResponse.Token 標 deprecated 保留(向下相容);token 不進 log
- frontend: 移除 downloadModelFile/deriveDownloadFilename(fetch+blob),
改 triggerNavDownload 用 <a download href> 導航;ModelDownloadGrant 移除 token 欄
- 整條鏈無 CORS:前端→backend 同 origin、前端→FAA 導航無 preflight
docs: ADR-017 增補 §11(v1.3) + TDD.md §9.5
tests: backend 8 PASS(含 token escape 邊界)、frontend 35 PASS;Reviewer 通過(0C/0M)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
254 lines
8.9 KiB
Go
254 lines
8.9 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)
|
||
|
||
// ADR-017 v1.3(§11.3 乙案):download_url 已含 ?access_token=(後端組好的完整 URL)。
|
||
assert.Equal(t,
|
||
"https://faa.example.com:5081/files/models/demo-user/job-1.nef?access_token=fdt_abc123",
|
||
data["download_url"])
|
||
// token 欄位仍回原始 fdt token(deprecated、向下相容,§11.3)。
|
||
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)
|
||
}
|
||
|
||
// TestModelsDownload_TokenURLEscaped 驗 token 內含 query-string 特殊字元時,
|
||
// download_url 以 url.QueryEscape 正確 escape(access_token 值不會破壞 URL 結構)。
|
||
func TestModelsDownload_TokenURLEscaped(t *testing.T) {
|
||
// 刻意用含 '+' / '/' / '=' / ' ' 的 token(fdt token 理論上是 opaque,需保證 escape 安全)。
|
||
iss := &fakeIssuer{token: "fdt_a+b/c=d e"}
|
||
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)
|
||
|
||
// url.QueryEscape:"+"→"%2B"、"/"→"%2F"、"="→"%3D"、" "→"+"。
|
||
assert.Equal(t,
|
||
"https://faa.example.com:5081/files/models/demo-user/job-1.nef?access_token=fdt_a%2Bb%2Fc%3Dd+e",
|
||
data["download_url"])
|
||
// token 欄位回原始未 escape 值(deprecated、向下相容)。
|
||
assert.Equal(t, "fdt_a+b/c=d e", data["token"])
|
||
}
|
||
|
||
// ==========================================================================
|
||
// 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")
|
||
}
|