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>
311 lines
12 KiB
Go
311 lines
12 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)
|
||
}
|
||
|
||
// TestModelsDownload_NotFoundWhenNoAccess 驗證模型共享後的行為改變(TDD §5 download 放寬):
|
||
// 非 owner 且無任何可見性(private model)下載,回 404(不是 403)——防 enumeration(SEC-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 把 model(private)分享給 demo-user(viewer)。
|
||
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")
|
||
}
|