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

296 lines
11 KiB
Go
Raw Permalink 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.

//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")
// 空 orgOIDC 現況)→ 不可見(安全預設,即使 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 驗證 filterowned / 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 nameMinor-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 }