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>
296 lines
11 KiB
Go
296 lines
11 KiB
Go
//go:build dbtest
|
||
|
||
// PostgresRepository 模型共享方法(Library 查詢 + model_shares CRUD)的真 DB 整合測試。
|
||
//
|
||
// build tag `dbtest`:只在帶 `-tags=dbtest` 時編譯/執行(需要 Docker / testcontainers)。
|
||
// 執行:
|
||
//
|
||
// go test -tags=dbtest ./internal/model/...
|
||
// DOCKER_HOST=tcp://192.168.0.130:2375 TESTCONTAINERS_RYUK_DISABLED=true \
|
||
// go test -tags=dbtest ./internal/model/...
|
||
//
|
||
// 涵蓋:可見性 predicate(我的 ∪ public ∪ tenant同org ∪ shared)、enumeration 排除、
|
||
// filter / 搜尋 / cursor 分頁、share CRUD、tenant 邊界(空 org 不落 tenant)。
|
||
package model
|
||
|
||
import (
|
||
"context"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/google/uuid"
|
||
"github.com/stretchr/testify/assert"
|
||
"github.com/stretchr/testify/require"
|
||
|
||
"visiona-backend/internal/db/testsupport"
|
||
)
|
||
|
||
// insertUserWithOrg 寫入一筆帶 org_id 的 user,回傳 user id。org 為空時 org_id=NULL。
|
||
func insertUserWithOrg(t *testing.T, tdb *testsupport.TestDB, org string) string {
|
||
t.Helper()
|
||
id := uuid.NewString()
|
||
ctx := context.Background()
|
||
if org == "" {
|
||
_, err := tdb.Pool.Exec(ctx,
|
||
`INSERT INTO users (id, email) VALUES ($1, $2)`, id, id+"@t.local")
|
||
require.NoError(t, err)
|
||
return id
|
||
}
|
||
_, err := tdb.Pool.Exec(ctx,
|
||
`INSERT INTO users (id, email, org_id) VALUES ($1, $2, $3)`, id, id+"@t.local", org)
|
||
require.NoError(t, err)
|
||
return id
|
||
}
|
||
|
||
// saveReady 存一個 ready model(指定 owner + visibility),回傳其 id。
|
||
func saveReady(t *testing.T, r *PostgresRepository, owner, visibility, name string) string {
|
||
t.Helper()
|
||
now := time.Now().UTC()
|
||
id := uuid.NewString()
|
||
require.NoError(t, r.Save(context.Background(), &Model{
|
||
ID: id, OwnerUserID: owner, Name: name,
|
||
StorageKey: "models/" + owner + "/" + id + ".nef", FileSize: 1024,
|
||
Source: SourceUploaded, Visibility: visibility, UploadedAt: &now,
|
||
}))
|
||
return id
|
||
}
|
||
|
||
// TestPGShare_LibraryVisibility 驗證 Library predicate(我的 ∪ public ∪ shared,排除別人 private)。
|
||
func TestPGShare_LibraryVisibility(t *testing.T) {
|
||
tdb := testsupport.SetupTestDB(t)
|
||
tdb.Truncate(t, "model_shares", "models", "users")
|
||
r := NewPostgresRepository(tdb.Pool)
|
||
ctx := context.Background()
|
||
|
||
me := insertUserWithOrg(t, tdb, "")
|
||
other := insertUserWithOrg(t, tdb, "")
|
||
|
||
mine := saveReady(t, r, me, VisibilityPrivate, "mine")
|
||
saveReady(t, r, other, VisibilityPrivate, "otherPriv")
|
||
pub := saveReady(t, r, other, VisibilityPublic, "otherPub")
|
||
shared := saveReady(t, r, other, VisibilityPrivate, "otherShared")
|
||
require.NoError(t, r.UpsertShare(ctx, &ModelShare{ModelID: shared, GranteeUserID: me, Role: "viewer", GrantedBy: other}))
|
||
|
||
items, _, err := r.Library(ctx, LibraryQuery{UserID: me, Limit: 100})
|
||
require.NoError(t, err)
|
||
|
||
got := map[string]*LibraryItem{}
|
||
for _, it := range items {
|
||
got[it.Model.ID] = it
|
||
}
|
||
assert.Contains(t, got, mine)
|
||
assert.Contains(t, got, pub)
|
||
assert.Contains(t, got, shared)
|
||
assert.Len(t, got, 3, "別人的 private 不應出現")
|
||
assert.True(t, got[shared].SharedWithMe)
|
||
assert.Equal(t, "viewer", got[shared].MyAccess)
|
||
}
|
||
|
||
// TestPGShare_LibraryTenantBoundary 驗證 tenant 可見性:同 org 命中、異 org / 空 org 不命中(SEC-4)。
|
||
func TestPGShare_LibraryTenantBoundary(t *testing.T) {
|
||
tdb := testsupport.SetupTestDB(t)
|
||
tdb.Truncate(t, "model_shares", "models", "users")
|
||
r := NewPostgresRepository(tdb.Pool)
|
||
ctx := context.Background()
|
||
|
||
// org_id 是 UUID 欄,用真 UUID(不是 'org-1' 這種字面)。
|
||
org1 := uuid.NewString()
|
||
org2 := uuid.NewString()
|
||
orgOwner := insertUserWithOrg(t, tdb, org1)
|
||
teammate := insertUserWithOrg(t, tdb, org1)
|
||
outsider := insertUserWithOrg(t, tdb, org2)
|
||
noOrg := insertUserWithOrg(t, tdb, "")
|
||
|
||
tenantModel := saveReady(t, r, orgOwner, VisibilityTenant, "tenant")
|
||
|
||
// 同 org → 可見。UserOrgID 傳 org_id 的 text 形式(對齊 UserContext.OrgID 為字串)。
|
||
items, _, err := r.Library(ctx, LibraryQuery{UserID: teammate, UserOrgID: org1, 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: org2, Limit: 100})
|
||
require.NoError(t, err)
|
||
assert.Empty(t, items, "異 org 不應看到 tenant model")
|
||
|
||
// 空 org(OIDC 現況)→ 不可見(安全預設,即使 model 是 tenant)。
|
||
items, _, err = r.Library(ctx, LibraryQuery{UserID: noOrg, UserOrgID: "", Limit: 100})
|
||
require.NoError(t, err)
|
||
assert.Empty(t, items, "空 org 不應落 tenant 可見(SEC-4)")
|
||
}
|
||
|
||
// TestPGShare_LibraryFilters 驗證 filter(owned / target_chip / source / visibility / q)。
|
||
func TestPGShare_LibraryFilters(t *testing.T) {
|
||
tdb := testsupport.SetupTestDB(t)
|
||
tdb.Truncate(t, "model_shares", "models", "users")
|
||
r := NewPostgresRepository(tdb.Pool)
|
||
ctx := context.Background()
|
||
|
||
me := insertUserWithOrg(t, tdb, "")
|
||
other := insertUserWithOrg(t, tdb, "")
|
||
mine := saveReady(t, r, me, VisibilityPrivate, "yolo-mine")
|
||
pub := saveReady(t, r, other, VisibilityPublic, "resnet-pub")
|
||
|
||
// owned=true → 只我的。
|
||
items, _, err := r.Library(ctx, LibraryQuery{UserID: me, Owned: boolPtr(true), Limit: 100})
|
||
require.NoError(t, err)
|
||
require.Len(t, items, 1)
|
||
assert.Equal(t, mine, items[0].Model.ID)
|
||
|
||
// owned=false → 只別人。
|
||
items, _, err = r.Library(ctx, LibraryQuery{UserID: me, Owned: boolPtr(false), Limit: 100})
|
||
require.NoError(t, err)
|
||
require.Len(t, items, 1)
|
||
assert.Equal(t, pub, items[0].Model.ID)
|
||
|
||
// visibility=public。
|
||
items, _, err = r.Library(ctx, LibraryQuery{UserID: me, Visibility: VisibilityPublic, Limit: 100})
|
||
require.NoError(t, err)
|
||
require.Len(t, items, 1)
|
||
assert.Equal(t, pub, items[0].Model.ID)
|
||
|
||
// q=yolo(搜尋 name)。
|
||
items, _, err = r.Library(ctx, LibraryQuery{UserID: me, Q: "yolo", Limit: 100})
|
||
require.NoError(t, err)
|
||
require.Len(t, items, 1)
|
||
assert.Equal(t, mine, items[0].Model.ID)
|
||
|
||
// q 含 LIKE 萬用字元應被 escape(不 match 全部)。
|
||
items, _, err = r.Library(ctx, LibraryQuery{UserID: me, Q: "%", Limit: 100})
|
||
require.NoError(t, err)
|
||
assert.Empty(t, items, "字面 '%' 不應 match 任何 model(萬用字元已 escape)")
|
||
}
|
||
|
||
// TestPGShare_LibraryCursorPagination 驗證 cursor 分頁不重不漏(真 DB keyset)。
|
||
func TestPGShare_LibraryCursorPagination(t *testing.T) {
|
||
tdb := testsupport.SetupTestDB(t)
|
||
tdb.Truncate(t, "model_shares", "models", "users")
|
||
r := NewPostgresRepository(tdb.Pool)
|
||
ctx := context.Background()
|
||
|
||
me := insertUserWithOrg(t, tdb, "")
|
||
for i := 0; i < 7; i++ {
|
||
saveReady(t, r, me, VisibilityPrivate, "m"+string(rune('a'+i)))
|
||
}
|
||
|
||
seen := map[string]bool{}
|
||
var cursor *Cursor
|
||
for page := 0; page < 20; page++ {
|
||
items, hasMore, err := r.Library(ctx, LibraryQuery{
|
||
UserID: me, Limit: 3, 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
|
||
}
|
||
require.NotEmpty(t, items)
|
||
last := items[len(items)-1].Model
|
||
cursor = &Cursor{ID: last.ID, SortValue: last.Name}
|
||
}
|
||
assert.Len(t, seen, 7, "所有 model 應被分頁完整走過一次")
|
||
}
|
||
|
||
// TestPGShare_ShareCRUD 驗證 share Upsert / Get / List / Delete(真 DB)。
|
||
func TestPGShare_ShareCRUD(t *testing.T) {
|
||
tdb := testsupport.SetupTestDB(t)
|
||
tdb.Truncate(t, "model_shares", "models", "users")
|
||
r := NewPostgresRepository(tdb.Pool)
|
||
ctx := context.Background()
|
||
|
||
owner := insertUserWithOrg(t, tdb, "")
|
||
bob := insertUserWithOrg(t, tdb, "")
|
||
alice := insertUserWithOrg(t, tdb, "")
|
||
m := saveReady(t, r, owner, VisibilityPrivate, "m")
|
||
|
||
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 更新 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)
|
||
|
||
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)
|
||
|
||
require.NoError(t, r.DeleteShare(ctx, m, bob))
|
||
_, err = r.GetShare(ctx, m, bob)
|
||
assert.ErrorIs(t, err, ErrNotFound)
|
||
assert.ErrorIs(t, r.DeleteShare(ctx, m, uuid.NewString()), ErrNotFound)
|
||
}
|
||
|
||
// TestPGShare_LibraryExcludesSoftDeletedAndPending 驗證軟刪 / 未 ready 的 model 不進 Library。
|
||
func TestPGShare_LibraryExcludesSoftDeletedAndPending(t *testing.T) {
|
||
tdb := testsupport.SetupTestDB(t)
|
||
tdb.Truncate(t, "model_shares", "models", "users")
|
||
r := NewPostgresRepository(tdb.Pool)
|
||
ctx := context.Background()
|
||
|
||
me := insertUserWithOrg(t, tdb, "")
|
||
// pending(無 UploadedAt)。
|
||
pendingID := uuid.NewString()
|
||
require.NoError(t, r.Save(ctx, &Model{
|
||
ID: pendingID, OwnerUserID: me, Name: "pending", StorageKey: "k",
|
||
FileSize: 1, Source: SourceUploaded, Visibility: VisibilityPublic,
|
||
}))
|
||
// ready 然後軟刪。
|
||
deleted := saveReady(t, r, me, VisibilityPublic, "deleted")
|
||
require.NoError(t, r.Delete(ctx, deleted))
|
||
// 正常 ready。
|
||
ok := saveReady(t, r, me, VisibilityPrivate, "ok")
|
||
|
||
items, _, err := r.Library(ctx, LibraryQuery{UserID: me, Limit: 100})
|
||
require.NoError(t, err)
|
||
require.Len(t, items, 1, "只應列正常 ready 的 model")
|
||
assert.Equal(t, ok, items[0].Model.ID)
|
||
}
|
||
|
||
// TestPGShare_GetWithOwner 驗證 GetWithOwner join 出 owner name(Minor-1,真 DB)。
|
||
func TestPGShare_GetWithOwner(t *testing.T) {
|
||
tdb := testsupport.SetupTestDB(t)
|
||
tdb.Truncate(t, "model_shares", "models", "users")
|
||
r := NewPostgresRepository(tdb.Pool)
|
||
ctx := context.Background()
|
||
|
||
// 建帶 name 的 owner。
|
||
ownerID := uuid.NewString()
|
||
_, err := tdb.Pool.Exec(ctx,
|
||
`INSERT INTO users (id, email, name) VALUES ($1, $2, $3)`,
|
||
ownerID, ownerID+"@t.local", "Alice")
|
||
require.NoError(t, err)
|
||
|
||
modelID := saveReady(t, r, ownerID, VisibilityPublic, "m")
|
||
|
||
m, ownerName, err := r.GetWithOwner(ctx, modelID)
|
||
require.NoError(t, err)
|
||
assert.Equal(t, modelID, m.ID)
|
||
assert.Equal(t, VisibilityPublic, m.Visibility)
|
||
assert.Equal(t, "Alice", ownerName, "GetWithOwner 應 join 出 owner name")
|
||
|
||
// owner 無 name → 空字串(COALESCE)。
|
||
noNameOwner := insertUserWithOrg(t, tdb, "")
|
||
m2 := saveReady(t, r, noNameOwner, VisibilityPrivate, "m2")
|
||
_, ownerName2, err := r.GetWithOwner(ctx, m2)
|
||
require.NoError(t, err)
|
||
assert.Equal(t, "", ownerName2, "owner 無 name 時 owner_name 應為空")
|
||
|
||
// 不存在 / 已軟刪 → ErrNotFound。
|
||
_, _, err = r.GetWithOwner(ctx, uuid.NewString())
|
||
assert.ErrorIs(t, err, ErrNotFound)
|
||
require.NoError(t, r.Delete(ctx, modelID))
|
||
_, _, err = r.GetWithOwner(ctx, modelID)
|
||
assert.ErrorIs(t, err, ErrNotFound, "已軟刪應回 ErrNotFound")
|
||
}
|
||
|
||
func boolPtr(b bool) *bool { return &b }
|