visionA/visionA-backend/internal/api/models_download_test.go
jim800121chen 47a1d4d0ef feat(backend): 設備註冊 + 模型共享 backend(B 設備管理 + C 模型共享)
B 設備管理(feature-device-mgmt-tdd):
- POST /api/devices/:id/register + /unregister(owner 檢查 + representative 擋
  + 已註冊擋 + SetRegistered 單欄翻轉,不碰 unpair 軟刪)
- error codes ALREADY_REGISTERED / REPRESENTATIVE_DEVICE(409)
- 不需 migration(registered_at 欄/index/讀寫已在 0005)

C 模型共享(feature-model-sharing-tdd,security 深審 APPROVE):
- migration 0006:models.visibility enum DEFAULT 'private'(零行為改變)+ model_shares 表
- canAccessModel single source(owner ∪ share ∪ public ∪ tenant):profile + download 共用
- GET /library(cursor keyset)/ GET /:id/profile(404 防列舉、GetWithOwner join name 不洩 email)
  / PATCH /:id/visibility(owner-only)/ shares CRUD / download 放寬
- tenant 因 OIDC 無 org claim 留 stub(恆空、安全預設;補 org claim 需重送 security 深審)

reviewer 通過(B 三條紅線 / C security APPROVE 無 C/M)。130 dbtest 全綠、gosec 新檔 0。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-02 16:29:50 +08:00

311 lines
12 KiB
Go
Raw Permalink 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)
}
// TestModelsDownload_NotFoundWhenNoAccess 驗證模型共享後的行為改變TDD §5 download 放寬):
// 非 owner 且無任何可見性private model下載回 404不是 403——防 enumerationSEC-1
// 與 profile 的 canAccessModel 判斷一致single source of truth
func TestModelsDownload_NotFoundWhenNoAccess(t *testing.T) {
iss := &fakeIssuer{token: "fdt_x"}
// 登入 user = demo-user但 model owner = other-user且 model 為 private預設
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.StatusNotFound, w.Code, "private model 非 owner 應回 404防 enumeration")
assert.Contains(t, w.Body.String(), ErrCodeNotFound)
assert.Equal(t, 0, iss.calls, "should not issue token when no access")
}
// TestModelsDownload_PublicModelNonOwner 驗證 public model 非 owner 也能下載(共享放寬)。
func TestModelsDownload_PublicModelNonOwner(t *testing.T) {
iss := &fakeIssuer{token: "fdt_pub"}
r, repo := newDownloadFixture(t, iss, "https://faa.example.com:5081", "demo-user")
seedConvertedModel(t, repo, "m-pub", "other-user", "models/other-user/pub.nef")
// owner 把 model 設為 public。
m, err := repo.Get(context.Background(), "m-pub")
require.NoError(t, err)
m.Visibility = model.VisibilityPublic
require.NoError(t, repo.Save(context.Background(), m))
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/models/m-pub/download", nil)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code, "public model 非 owner 應可下載body=%s", w.Body.String())
assert.Equal(t, 1, iss.calls, "public model 應簽 download token")
}
// TestModelsDownload_SharedModelNonOwner 驗證被 restricted 分享的 grantee 也能下載。
func TestModelsDownload_SharedModelNonOwner(t *testing.T) {
iss := &fakeIssuer{token: "fdt_share"}
r, repo := newDownloadFixture(t, iss, "https://faa.example.com:5081", "demo-user")
seedConvertedModel(t, repo, "m-shared", "other-user", "models/other-user/shared.nef")
// owner 把 modelprivate分享給 demo-userviewer
require.NoError(t, repo.UpsertShare(context.Background(), &model.ModelShare{
ModelID: "m-shared",
GranteeUserID: "demo-user",
Role: "viewer",
GrantedBy: "other-user",
}))
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/models/m-shared/download", nil)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code, "被分享的 grantee 應可下載body=%s", w.Body.String())
assert.Equal(t, 1, iss.calls, "shared model 應簽 download token")
}
// TestModelsDownload_OwnerStillWorks 回歸:既有 owner 下載仍正常(不因放寬而退化)。
func TestModelsDownload_OwnerStillWorks(t *testing.T) {
iss := &fakeIssuer{token: "fdt_owner"}
r, repo := newDownloadFixture(t, iss, "https://faa.example.com:5081", "demo-user")
seedConvertedModel(t, repo, "m-mine", "demo-user", "models/demo-user/mine.nef")
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/models/m-mine/download", nil)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code, "owner 下載應仍正常body=%s", w.Body.String())
assert.Equal(t, 1, iss.calls)
}
// ==========================================================================
// 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")
}