visionA/visionA-backend/internal/api/models_sharing_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

515 lines
20 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"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"visiona-backend/internal/auth"
"visiona-backend/internal/model"
)
// canAccessModelForTest 以 userID無 org包一層 canAccessModel方便單元測試。
func canAccessModelForTest(ctx context.Context, userID string, m *model.Model,
shareLookup func(context.Context, string, string) (*model.ModelShare, error),
) model.AccessLevel {
return canAccessModel(ctx, &auth.UserContext{UserID: userID}, m, shareLookup)
}
// ==========================================================================
// fixture
// ==========================================================================
// newSharingFixture 建一個「以 userID 身份登入」的模型共享 route fixture。
func newSharingFixture(t *testing.T, 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,
})
return r, repo
}
// seedReadyModel 塞一個 ready已 finalize的 model指定 owner + visibility。
func seedReadyModel(t *testing.T, repo *model.InMemoryRepository, id, owner, visibility string) *model.Model {
t.Helper()
now := time.Now().UTC()
m := &model.Model{
ID: id,
OwnerUserID: owner,
Name: "model-" + id,
StorageKey: "models/" + owner + "/" + id + ".nef",
FileSize: 1024,
Source: model.SourceUploaded,
Visibility: visibility,
UploadedAt: &now, // ready
}
require.NoError(t, repo.Save(context.Background(), m))
return m
}
// decodeData 解 envelope 的 data 到 target。
func decodeData(t *testing.T, body []byte, target any) {
t.Helper()
var sb SuccessBody
require.NoError(t, json.Unmarshal(body, &sb))
raw, err := json.Marshal(sb.Data)
require.NoError(t, err)
require.NoError(t, json.Unmarshal(raw, target))
}
// ==========================================================================
// GET /api/models/library — 可見性 matrix
// ==========================================================================
// TestLibrary_VisibilityMatrix 驗證共享庫只列可見 model我的 + public + shared
// 不含別人的 privateTDD §4.1 predicate
func TestLibrary_VisibilityMatrix(t *testing.T) {
r, repo := newSharingFixture(t, "me")
seedReadyModel(t, repo, "mine-priv", "me", model.VisibilityPrivate) // 我的 private → 可見
seedReadyModel(t, repo, "other-priv", "other", model.VisibilityPrivate) // 別人 private → 不可見
seedReadyModel(t, repo, "other-pub", "other", model.VisibilityPublic) // 別人 public → 可見
shared := seedReadyModel(t, repo, "other-shared", "other", model.VisibilityPrivate)
require.NoError(t, repo.UpsertShare(context.Background(), &model.ModelShare{
ModelID: shared.ID, GranteeUserID: "me", Role: "viewer", GrantedBy: "other",
}))
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/models/library?limit=100", nil)
r.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String())
var resp LibraryResponse
decodeData(t, w.Body.Bytes(), &resp)
got := map[string]LibraryItemResponse{}
for _, it := range resp.Items {
got[it.ID] = it
}
assert.Contains(t, got, "mine-priv", "我的 private 應可見")
assert.Contains(t, got, "other-pub", "別人 public 應可見")
assert.Contains(t, got, "other-shared", "分享給我的應可見")
assert.NotContains(t, got, "other-priv", "別人 private 不應可見")
// my_access / is_me / shared_with_me 正確。
assert.Equal(t, model.AccessOwner, got["mine-priv"].MyAccess)
assert.True(t, got["mine-priv"].Owner.IsMe)
assert.Equal(t, model.AccessViewer, got["other-pub"].MyAccess)
assert.False(t, got["other-pub"].Owner.IsMe)
assert.True(t, got["other-shared"].SharedWithMe, "分享給我的應標 shared_with_me")
}
// TestLibrary_ExcludesNotReady 驗證未 finalizepending的 model 不進共享庫。
func TestLibrary_ExcludesNotReady(t *testing.T) {
r, repo := newSharingFixture(t, "me")
// pending modelUploadedAt=nil
require.NoError(t, repo.Save(context.Background(), &model.Model{
ID: "pending", OwnerUserID: "me", Name: "p", StorageKey: "k",
FileSize: 1, Source: model.SourceUploaded, Visibility: model.VisibilityPublic,
}))
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/models/library", nil)
r.ServeHTTP(w, req)
var resp LibraryResponse
decodeData(t, w.Body.Bytes(), &resp)
assert.Empty(t, resp.Items, "pending model 不應進共享庫")
}
// TestLibrary_OwnedFilter 驗證 owned=true 只回我的、owned=false 只回別人分享/公開的。
func TestLibrary_OwnedFilter(t *testing.T) {
r, repo := newSharingFixture(t, "me")
seedReadyModel(t, repo, "mine", "me", model.VisibilityPrivate)
seedReadyModel(t, repo, "pub", "other", model.VisibilityPublic)
// owned=true
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models/library?owned=true", nil))
var mineOnly LibraryResponse
decodeData(t, w.Body.Bytes(), &mineOnly)
require.Len(t, mineOnly.Items, 1)
assert.Equal(t, "mine", mineOnly.Items[0].ID)
// owned=false
w = httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models/library?owned=false", nil))
var othersOnly LibraryResponse
decodeData(t, w.Body.Bytes(), &othersOnly)
require.Len(t, othersOnly.Items, 1)
assert.Equal(t, "pub", othersOnly.Items[0].ID)
}
// TestLibrary_SearchQ 驗證 q 搜尋 name。
func TestLibrary_SearchQ(t *testing.T) {
r, repo := newSharingFixture(t, "me")
m1 := seedReadyModel(t, repo, "a", "me", model.VisibilityPrivate)
m1.Name = "yolov5-detect"
require.NoError(t, repo.Save(context.Background(), m1))
m2 := seedReadyModel(t, repo, "b", "me", model.VisibilityPrivate)
m2.Name = "resnet-classify"
require.NoError(t, repo.Save(context.Background(), m2))
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models/library?q=yolo", nil))
var resp LibraryResponse
decodeData(t, w.Body.Bytes(), &resp)
require.Len(t, resp.Items, 1)
assert.Equal(t, "a", resp.Items[0].ID)
}
// TestLibrary_CursorPagination 驗證 cursor 分頁不重複、不遺漏。
func TestLibrary_CursorPagination(t *testing.T) {
r, repo := newSharingFixture(t, "me")
for i := 0; i < 5; i++ {
m := seedReadyModel(t, repo, string(rune('a'+i)), "me", model.VisibilityPrivate)
// 讓 created_at 有序sort=name 更穩定,用 name 分頁)。
_ = m
}
seen := map[string]bool{}
cursor := ""
pages := 0
for {
url := "/api/models/library?limit=2&sort=name&order=asc"
if cursor != "" {
url += "&cursor=" + cursor
}
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, url, nil))
require.Equal(t, http.StatusOK, w.Code)
var resp LibraryResponse
decodeData(t, w.Body.Bytes(), &resp)
for _, it := range resp.Items {
assert.False(t, seen[it.ID], "id %s 重複出現於分頁", it.ID)
seen[it.ID] = true
}
pages++
require.Less(t, pages, 10, "分頁不應無限迴圈")
if !resp.HasMore {
break
}
cursor = resp.NextCursor
require.NotEmpty(t, cursor, "has_more=true 時應有 next_cursor")
}
assert.Len(t, seen, 5, "所有 model 應被分頁完整走過一次")
}
// TestLibrary_InvalidParams 驗證非法 sort / limit / cursor 回 400。
func TestLibrary_InvalidParams(t *testing.T) {
r, _ := newSharingFixture(t, "me")
for _, url := range []string{
"/api/models/library?sort=bogus",
"/api/models/library?limit=abc",
"/api/models/library?order=sideways",
"/api/models/library?cursor=!!!notbase64!!!",
"/api/models/library?owned=maybe",
} {
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, url, nil))
assert.Equal(t, http.StatusBadRequest, w.Code, "url=%s should be 400, body=%s", url, w.Body.String())
assert.Contains(t, w.Body.String(), ErrCodeValidationFailed)
}
}
// ==========================================================================
// GET /api/models/:id/profile
// ==========================================================================
// TestProfile_PublicVisibleToNonOwner 驗證 public model 非 owner 可看 profile
// 且 owner.name 有帶出Minor-1profile join owner name對齊 api §2
func TestProfile_PublicVisibleToNonOwner(t *testing.T) {
r, repo := newSharingFixture(t, "me")
seedReadyModel(t, repo, "pub", "other", model.VisibilityPublic)
repo.SetUserName("other", "Alice") // owner 顯示名
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models/pub/profile", nil))
require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String())
var p ProfileResponse
decodeData(t, w.Body.Bytes(), &p)
assert.Equal(t, model.AccessViewer, p.MyAccess)
assert.True(t, p.CanDownload)
assert.False(t, p.Owner.IsMe)
assert.Equal(t, "other", p.Owner.ID)
assert.Equal(t, "Alice", p.Owner.Name, "profile 應帶出 owner nameMinor-1")
}
// TestProfile_PrivateHiddenReturns404 驗證別人 private model → profile 回 404防 enumeration
func TestProfile_PrivateHiddenReturns404(t *testing.T) {
r, repo := newSharingFixture(t, "me")
seedReadyModel(t, repo, "secret", "other", model.VisibilityPrivate)
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models/secret/profile", nil))
assert.Equal(t, http.StatusNotFound, w.Code, "無權限應回 404不是 403")
assert.Contains(t, w.Body.String(), ErrCodeNotFound)
}
// TestProfile_NonExistentReturns404Same 驗證不存在的 id 與無權限的 id 回相同 404enumeration 防護)。
func TestProfile_NonExistentReturns404Same(t *testing.T) {
r, repo := newSharingFixture(t, "me")
seedReadyModel(t, repo, "secret", "other", model.VisibilityPrivate)
wHidden := httptest.NewRecorder()
r.ServeHTTP(wHidden, httptest.NewRequest(http.MethodGet, "/api/models/secret/profile", nil))
wMissing := httptest.NewRecorder()
r.ServeHTTP(wMissing, httptest.NewRequest(http.MethodGet, "/api/models/does-not-exist/profile", nil))
assert.Equal(t, wMissing.Code, wHidden.Code, "無權限與不存在應回相同 status")
// body 除了 request_id 外結構一致(都是 NOT_FOUND / model not found
assert.Contains(t, wHidden.Body.String(), "model not found")
assert.Contains(t, wMissing.Body.String(), "model not found")
}
// TestProfile_NoLeakInternalKeys 驗證 profile 不洩漏 storage_key / faa_object_key / owner emailSEC-3
func TestProfile_NoLeakInternalKeys(t *testing.T) {
r, repo := newSharingFixture(t, "me")
m := seedReadyModel(t, repo, "pub", "other", model.VisibilityPublic)
m.FAAObjectKey = "models/other/secret-object-key.nef"
m.FileChecksum = "sha256-secret"
require.NoError(t, repo.Save(context.Background(), m))
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models/pub/profile", nil))
body := w.Body.String()
assert.NotContains(t, body, "secret-object-key", "不應洩漏 faa_object_key")
assert.NotContains(t, body, "storage_key", "不應輸出 storage_key 欄")
assert.NotContains(t, body, m.StorageKey, "不應洩漏 storage_key 值")
assert.NotContains(t, body, "sha256-secret", "不應洩漏 file_checksum")
assert.NotContains(t, body, "email", "不應輸出 owner email 欄")
}
// TestProfile_Preset 驗證 preset profile 任何登入 user 可看。
func TestProfile_Preset(t *testing.T) {
r, _ := newSharingFixture(t, "me")
presets := model.PresetModels()
require.NotEmpty(t, presets)
presetID := presets[0].ID
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models/"+presetID+"/profile", nil))
require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String())
var p ProfileResponse
decodeData(t, w.Body.Bytes(), &p)
assert.Equal(t, model.VisibilityPublic, p.Visibility)
assert.True(t, p.CanDownload)
}
// ==========================================================================
// PATCH /api/models/:id/visibility
// ==========================================================================
// TestSetVisibility_OwnerOK 驗證 owner 可改 visibility。
func TestSetVisibility_OwnerOK(t *testing.T) {
r, repo := newSharingFixture(t, "me")
seedReadyModel(t, repo, "m", "me", model.VisibilityPrivate)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPatch, "/api/models/m/visibility",
strings.NewReader(`{"visibility":"public"}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String())
m, err := repo.Get(context.Background(), "m")
require.NoError(t, err)
assert.Equal(t, model.VisibilityPublic, m.Visibility)
}
// TestSetVisibility_NonOwnerForbidden 驗證非 owner 改 visibility 回 403。
func TestSetVisibility_NonOwnerForbidden(t *testing.T) {
r, repo := newSharingFixture(t, "me")
seedReadyModel(t, repo, "m", "other", model.VisibilityPrivate)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPatch, "/api/models/m/visibility",
strings.NewReader(`{"visibility":"public"}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusForbidden, w.Code)
assert.Contains(t, w.Body.String(), ErrCodeForbidden)
}
// TestSetVisibility_Invalid 驗證非法 visibility 值回 400。
func TestSetVisibility_Invalid(t *testing.T) {
r, repo := newSharingFixture(t, "me")
seedReadyModel(t, repo, "m", "me", model.VisibilityPrivate)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPatch, "/api/models/m/visibility",
strings.NewReader(`{"visibility":"world"}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
// TestSetVisibility_TenantWithoutOrg 驗證 user 無 org 設 tenant 回 400。
func TestSetVisibility_TenantWithoutOrg(t *testing.T) {
r, repo := newSharingFixture(t, "me") // injectStaticUserContext 不設 OrgID
seedReadyModel(t, repo, "m", "me", model.VisibilityPrivate)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPatch, "/api/models/m/visibility",
strings.NewReader(`{"visibility":"tenant"}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code, "無 org 設 tenant 應回 400")
}
// TestSetVisibility_NotReadyConflict 驗證未 finalize 的 model 設公開回 409。
func TestSetVisibility_NotReadyConflict(t *testing.T) {
r, repo := newSharingFixture(t, "me")
// pending modelUploadedAt=nil
require.NoError(t, repo.Save(context.Background(), &model.Model{
ID: "pending", OwnerUserID: "me", Name: "p", StorageKey: "k",
FileSize: 1, Source: model.SourceUploaded,
}))
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPatch, "/api/models/pending/visibility",
strings.NewReader(`{"visibility":"public"}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusConflict, w.Code, "未 ready 設公開應回 409")
}
// ==========================================================================
// shares CRUDrestricted 授權管理)
// ==========================================================================
// TestShares_PutListDelete 驗證 owner 加/列/移除授權完整流程。
func TestShares_PutListDelete(t *testing.T) {
r, repo := newSharingFixture(t, "me")
seedReadyModel(t, repo, "m", "me", model.VisibilityPrivate)
// PUT 加授權。
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPut, "/api/models/m/shares",
strings.NewReader(`{"grantee_user_id":"bob","role":"viewer"}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String())
// GET 列授權。
w = httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models/m/shares", nil))
require.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), "bob")
// DELETE 移除授權。
w = httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodDelete, "/api/models/m/shares/bob", nil))
require.Equal(t, http.StatusNoContent, w.Code)
// 再列應為空。
shares, err := repo.ListShares(context.Background(), "m")
require.NoError(t, err)
assert.Empty(t, shares)
}
// TestShares_NonOwnerForbidden 驗證非 owner 不能管理授權。
func TestShares_NonOwnerForbidden(t *testing.T) {
r, repo := newSharingFixture(t, "me")
seedReadyModel(t, repo, "m", "other", model.VisibilityPrivate)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPut, "/api/models/m/shares",
strings.NewReader(`{"grantee_user_id":"bob"}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusForbidden, w.Code)
}
// TestShares_CannotShareToSelf 驗證不能分享給自己。
func TestShares_CannotShareToSelf(t *testing.T) {
r, repo := newSharingFixture(t, "me")
seedReadyModel(t, repo, "m", "me", model.VisibilityPrivate)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPut, "/api/models/m/shares",
strings.NewReader(`{"grantee_user_id":"me"}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
// TestShares_InvalidRole 驗證非法 role 回 400。
func TestShares_InvalidRole(t *testing.T) {
r, repo := newSharingFixture(t, "me")
seedReadyModel(t, repo, "m", "me", model.VisibilityPrivate)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPut, "/api/models/m/shares",
strings.NewReader(`{"grantee_user_id":"bob","role":"admin"}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
// TestShares_DeleteNonExistent 驗證移除不存在的授權回 404。
func TestShares_DeleteNonExistent(t *testing.T) {
r, repo := newSharingFixture(t, "me")
seedReadyModel(t, repo, "m", "me", model.VisibilityPrivate)
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodDelete, "/api/models/m/shares/ghost", nil))
assert.Equal(t, http.StatusNotFound, w.Code)
}
// ==========================================================================
// canAccessModel 單元測試single source of truth
// ==========================================================================
// TestCanAccessModel_Levels 直接驗 canAccessModel 各級判斷。
func TestCanAccessModel_Levels(t *testing.T) {
ctx := context.Background()
noShare := func(context.Context, string, string) (*model.ModelShare, error) {
return nil, model.ErrNotFound
}
mPrivate := &model.Model{ID: "m", OwnerUserID: "owner", Visibility: model.VisibilityPrivate}
mPublic := &model.Model{ID: "m", OwnerUserID: "owner", Visibility: model.VisibilityPublic}
// owner
assert.Equal(t, model.AccessOwner,
canAccessModelForTest(ctx, "owner", mPrivate, noShare))
// 無關 user + private → none
assert.Equal(t, model.AccessNone,
canAccessModelForTest(ctx, "stranger", mPrivate, noShare))
// public → viewer
assert.Equal(t, model.AccessViewer,
canAccessModelForTest(ctx, "stranger", mPublic, noShare))
// share viewer
shareViewer := func(context.Context, string, string) (*model.ModelShare, error) {
return &model.ModelShare{Role: "viewer"}, nil
}
assert.Equal(t, model.AccessViewer,
canAccessModelForTest(ctx, "grantee", mPrivate, shareViewer))
// share editor
shareEditor := func(context.Context, string, string) (*model.ModelShare, error) {
return &model.ModelShare{Role: "editor"}, nil
}
assert.Equal(t, model.AccessEditor,
canAccessModelForTest(ctx, "grantee", mPrivate, shareEditor))
// tenant 但 user 無 orgOIDC 現況)→ none安全預設stub
mTenant := &model.Model{ID: "m", OwnerUserID: "owner", Visibility: model.VisibilityTenant}
assert.Equal(t, model.AccessNone,
canAccessModelForTest(ctx, "stranger", mTenant, noShare),
"tenant + 無 org → nonetenant stub")
}