visionA/visionA-backend/internal/api/models_download_test.go
jim800121chen 3e45532f55 fix(model-download): 改 redirect/query-string token 下載,根除 CORS preflight 405
模型庫下載原本前端用 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>
2026-06-27 06:18:05 +08:00

254 lines
8.9 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)
// 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 tokendeprecated、向下相容§11.3)。
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)
}
// TestModelsDownload_TokenURLEscaped 驗 token 內含 query-string 特殊字元時,
// download_url 以 url.QueryEscape 正確 escapeaccess_token 值不會破壞 URL 結構)。
func TestModelsDownload_TokenURLEscaped(t *testing.T) {
// 刻意用含 '+' / '/' / '=' / ' ' 的 tokenfdt 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")
}