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>
153 lines
5.6 KiB
Go
153 lines
5.6 KiB
Go
package model
|
||
|
||
import (
|
||
"context"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/stretchr/testify/assert"
|
||
"github.com/stretchr/testify/require"
|
||
)
|
||
|
||
// readyModel 建一個 ready(UploadedAt 已設)的 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")
|
||
|
||
// 無 org(OIDC 現況)→ 不可見(安全預設)
|
||
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 應被分頁走過一次")
|
||
}
|