visionA/visionA-backend/internal/model/inmemory_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

153 lines
5.6 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 model
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// readyModel 建一個 readyUploadedAt 已設)的 model helper。
func readyModel(id, owner, visibility string) *Model {
now := time.Now().UTC()
return &Model{
ID: id, OwnerUserID: owner, Name: "m-" + id,
StorageKey: "k/" + id, FileSize: 1024,
Source: SourceUploaded, Visibility: visibility, UploadedAt: &now,
}
}
// TestInMemory_SaveDefaultsVisibility 驗證 Save 未設 visibility 時預設 private。
func TestInMemory_SaveDefaultsVisibility(t *testing.T) {
ctx := context.Background()
r := NewInMemoryRepository()
require.NoError(t, r.Save(ctx, &Model{ID: "m", OwnerUserID: "u", Name: "n", StorageKey: "k", Source: SourceUploaded}))
got, err := r.Get(ctx, "m")
require.NoError(t, err)
assert.Equal(t, VisibilityPrivate, got.Visibility, "未設 visibility 應預設 private")
}
// TestInMemory_ShareCRUD 驗證 share 的 Upsert / Get / List / Delete。
func TestInMemory_ShareCRUD(t *testing.T) {
ctx := context.Background()
r := NewInMemoryRepository()
require.NoError(t, r.UpsertShare(ctx, &ModelShare{ModelID: "m", GranteeUserID: "bob", Role: "viewer", GrantedBy: "owner"}))
got, err := r.GetShare(ctx, "m", "bob")
require.NoError(t, err)
assert.Equal(t, "viewer", got.Role)
// upsert 同 grantee → 更新 role。
require.NoError(t, r.UpsertShare(ctx, &ModelShare{ModelID: "m", GranteeUserID: "bob", Role: "editor", GrantedBy: "owner"}))
got, err = r.GetShare(ctx, "m", "bob")
require.NoError(t, err)
assert.Equal(t, "editor", got.Role, "重複 upsert 應更新 role")
// list
require.NoError(t, r.UpsertShare(ctx, &ModelShare{ModelID: "m", GranteeUserID: "alice", Role: "viewer", GrantedBy: "owner"}))
shares, err := r.ListShares(ctx, "m")
require.NoError(t, err)
assert.Len(t, shares, 2)
// delete
require.NoError(t, r.DeleteShare(ctx, "m", "bob"))
_, err = r.GetShare(ctx, "m", "bob")
assert.ErrorIs(t, err, ErrNotFound)
// delete 不存在 → ErrNotFound
assert.ErrorIs(t, r.DeleteShare(ctx, "m", "ghost"), ErrNotFound)
}
// TestInMemory_LibraryVisibility 驗證 Library predicate我的 public shared排除別人 private。
func TestInMemory_LibraryVisibility(t *testing.T) {
ctx := context.Background()
r := NewInMemoryRepository()
require.NoError(t, r.Save(ctx, readyModel("mine", "me", VisibilityPrivate)))
require.NoError(t, r.Save(ctx, readyModel("otherPriv", "other", VisibilityPrivate)))
require.NoError(t, r.Save(ctx, readyModel("otherPub", "other", VisibilityPublic)))
require.NoError(t, r.Save(ctx, readyModel("otherShared", "other", VisibilityPrivate)))
require.NoError(t, r.UpsertShare(ctx, &ModelShare{ModelID: "otherShared", GranteeUserID: "me", Role: "viewer", GrantedBy: "other"}))
items, _, err := r.Library(ctx, LibraryQuery{UserID: "me", Limit: 100})
require.NoError(t, err)
ids := map[string]*LibraryItem{}
for _, it := range items {
ids[it.Model.ID] = it
}
assert.Contains(t, ids, "mine")
assert.Contains(t, ids, "otherPub")
assert.Contains(t, ids, "otherShared")
assert.NotContains(t, ids, "otherPriv", "別人 private 不應可見")
assert.True(t, ids["otherShared"].SharedWithMe)
assert.Equal(t, AccessOwner, ids["mine"].MyAccess)
}
// TestInMemory_LibraryExcludesNotReady 驗證未 ready 的 model 不進 Library。
func TestInMemory_LibraryExcludesNotReady(t *testing.T) {
ctx := context.Background()
r := NewInMemoryRepository()
require.NoError(t, r.Save(ctx, &Model{ID: "pending", OwnerUserID: "me", Name: "p", StorageKey: "k", Source: SourceUploaded, Visibility: VisibilityPublic}))
items, _, err := r.Library(ctx, LibraryQuery{UserID: "me", Limit: 100})
require.NoError(t, err)
assert.Empty(t, items)
}
// TestInMemory_LibraryTenantStub 驗證 tenant 可見性:有 org 對應才命中in-memory 用 SetUserOrg 模擬)。
func TestInMemory_LibraryTenantStub(t *testing.T) {
ctx := context.Background()
r := NewInMemoryRepository()
r.SetUserOrg("owner", "org-1")
r.SetUserOrg("teammate", "org-1")
r.SetUserOrg("outsider", "org-2")
require.NoError(t, r.Save(ctx, readyModel("tenantModel", "owner", VisibilityTenant)))
// 同 org → 可見
items, _, err := r.Library(ctx, LibraryQuery{UserID: "teammate", UserOrgID: "org-1", Limit: 100})
require.NoError(t, err)
require.Len(t, items, 1)
assert.Equal(t, "tenantModel", items[0].Model.ID)
// 異 org → 不可見
items, _, err = r.Library(ctx, LibraryQuery{UserID: "outsider", UserOrgID: "org-2", Limit: 100})
require.NoError(t, err)
assert.Empty(t, items, "異 org 不應看到 tenant model")
// 無 orgOIDC 現況)→ 不可見(安全預設)
items, _, err = r.Library(ctx, LibraryQuery{UserID: "teammate", UserOrgID: "", Limit: 100})
require.NoError(t, err)
assert.Empty(t, items, "無 org 不應命中 tenant")
}
// TestInMemory_LibraryPaginationStable 驗證 cursor 分頁不重不漏。
func TestInMemory_LibraryPaginationStable(t *testing.T) {
ctx := context.Background()
r := NewInMemoryRepository()
for i := 0; i < 5; i++ {
require.NoError(t, r.Save(ctx, readyModel(string(rune('a'+i)), "me", VisibilityPrivate)))
}
seen := map[string]bool{}
var cursor *Cursor
for page := 0; page < 10; page++ {
items, hasMore, err := r.Library(ctx, LibraryQuery{
UserID: "me", Limit: 2, Sort: "name", Order: "asc", Cursor: cursor,
})
require.NoError(t, err)
for _, it := range items {
assert.False(t, seen[it.Model.ID], "分頁重複 %s", it.Model.ID)
seen[it.Model.ID] = true
}
if !hasMore {
break
}
last := items[len(items)-1].Model
cursor = &Cursor{ID: last.ID, SortValue: last.Name}
}
assert.Len(t, seen, 5, "所有 model 應被分頁走過一次")
}