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>
239 lines
9.6 KiB
Go
239 lines
9.6 KiB
Go
//go:build dbtest
|
||
|
||
// Migration 0006(模型共享:visibility 欄 + model_shares 表 + index)的真 DB 整合測試。
|
||
//
|
||
// build tag `dbtest`:需要 Docker daemon / testcontainers。預設 `go test ./...` 不編譯本檔。
|
||
// 執行:
|
||
//
|
||
// go test -tags=dbtest ./internal/db/...
|
||
// # 無本機 Docker 時,在 130 補跑:
|
||
// DOCKER_HOST=tcp://192.168.0.130:2375 TESTCONTAINERS_RYUK_DISABLED=true \
|
||
// go test -tags=dbtest ./internal/db/...
|
||
//
|
||
// 對齊 migrations/0006_model_sharing.up.sql / .down.sql 與 feature-model-sharing-tdd.md §3:
|
||
// 1. apply:models 有 visibility 欄(NOT NULL DEFAULT 'private')、model_shares 表存在、
|
||
// idx_model_shares_grantee / idx_models_public_active 存在、CHECK constraint 生效。
|
||
// 2. 既有相容:apply 前既有 model → apply 後 visibility='private'(零行為改變)。
|
||
// 3. model_shares FK / PK / role CHECK 生效。
|
||
// 4. rollback 對稱:down 後 visibility 欄與 model_shares 表消失。
|
||
// 5. re-apply 冪等:up→down→up 不報錯、結果一致。
|
||
package db_test
|
||
|
||
import (
|
||
"context"
|
||
"testing"
|
||
|
||
"github.com/google/uuid"
|
||
"github.com/stretchr/testify/assert"
|
||
"github.com/stretchr/testify/require"
|
||
|
||
"visiona-backend/internal/db"
|
||
"visiona-backend/internal/db/testsupport"
|
||
)
|
||
|
||
// insertRawModel 直接寫入一筆 models(不經 repository),可控制 visibility(傳空用 DB DEFAULT)。
|
||
// 回傳 model id。
|
||
func insertRawModel(t *testing.T, tdb *testsupport.TestDB, ownerID, visibility string) string {
|
||
t.Helper()
|
||
id := uuid.NewString()
|
||
ctx := context.Background()
|
||
if visibility == "" {
|
||
// 不指定 visibility 欄,走 DB DEFAULT(驗既有相容)。
|
||
_, err := tdb.Pool.Exec(ctx,
|
||
`INSERT INTO models (id, owner_user_id, name, storage_key, file_size, source)
|
||
VALUES ($1, $2, 'raw-model', 'k', 1024, 'uploaded')`,
|
||
id, ownerID)
|
||
require.NoError(t, err, "insert raw model (default visibility)")
|
||
return id
|
||
}
|
||
_, err := tdb.Pool.Exec(ctx,
|
||
`INSERT INTO models (id, owner_user_id, name, storage_key, file_size, source, visibility)
|
||
VALUES ($1, $2, 'raw-model', 'k', 1024, 'uploaded', $3)`,
|
||
id, ownerID, visibility)
|
||
require.NoError(t, err, "insert raw model")
|
||
return id
|
||
}
|
||
|
||
// TestMigrate0006_Apply 驗證 up 後 schema 到位(§1)。
|
||
func TestMigrate0006_Apply(t *testing.T) {
|
||
tdb := testsupport.SetupTestDB(t) // 已 up 到最新(含 0006)
|
||
|
||
assert.True(t, colExists(t, tdb, "models", "visibility"), "models 應有 visibility 欄")
|
||
assert.True(t, tableExists(t, tdb, "model_shares"), "model_shares 表應存在")
|
||
|
||
for _, idx := range []string{"idx_model_shares_grantee", "idx_models_public_active"} {
|
||
assert.True(t, indexExists(t, tdb, idx), "index %s 應存在", idx)
|
||
}
|
||
|
||
// visibility 應 NOT NULL DEFAULT 'private'。
|
||
ctx := context.Background()
|
||
var isNullable, colDefault string
|
||
err := tdb.Pool.QueryRow(ctx,
|
||
`SELECT is_nullable, COALESCE(column_default, '') FROM information_schema.columns
|
||
WHERE table_name = 'models' AND column_name = 'visibility'`).Scan(&isNullable, &colDefault)
|
||
require.NoError(t, err)
|
||
assert.Equal(t, "NO", isNullable, "visibility 應 NOT NULL")
|
||
assert.Contains(t, colDefault, "private", "visibility 預設應為 'private'")
|
||
}
|
||
|
||
// TestMigrate0006_ExistingModelDefaultsPrivate 驗證既有 model 遷移後 visibility='private'(§2,關鍵相容性)。
|
||
func TestMigrate0006_ExistingModelDefaultsPrivate(t *testing.T) {
|
||
tdb := testsupport.SetupTestDB(t)
|
||
ctx := context.Background()
|
||
|
||
mg, err := db.NewMigrator(tdb.Cfg, discardLog())
|
||
require.NoError(t, err)
|
||
defer mg.Close()
|
||
|
||
// 回退 0006 → models 無 visibility 欄。
|
||
require.NoError(t, mg.Down(), "down 一步回到 0005")
|
||
require.False(t, colExists(t, tdb, "models", "visibility"), "down 後不應有 visibility 欄")
|
||
|
||
owner := tdb.InsertUser(t, "", "")
|
||
// 在無 visibility 欄的狀態下塞既有 model。
|
||
existingID := uuid.NewString()
|
||
_, err = tdb.Pool.Exec(ctx,
|
||
`INSERT INTO models (id, owner_user_id, name, storage_key, file_size, source)
|
||
VALUES ($1, $2, 'legacy', 'k', 1024, 'uploaded')`,
|
||
existingID, owner)
|
||
require.NoError(t, err)
|
||
|
||
// 重新 up 0006。
|
||
require.NoError(t, db.RunMigrations(tdb.Cfg, discardLog()), "re-up 0006")
|
||
|
||
var vis string
|
||
err = tdb.Pool.QueryRow(ctx, `SELECT visibility FROM models WHERE id = $1`, existingID).Scan(&vis)
|
||
require.NoError(t, err)
|
||
assert.Equal(t, "private", vis, "既有 model 遷移後 visibility 應為 private(零行為改變)")
|
||
}
|
||
|
||
// TestMigrate0006_VisibilityCheckConstraint 驗證非法 visibility 被 CHECK 擋下(§1)。
|
||
func TestMigrate0006_VisibilityCheckConstraint(t *testing.T) {
|
||
tdb := testsupport.SetupTestDB(t)
|
||
ctx := context.Background()
|
||
owner := tdb.InsertUser(t, "", "")
|
||
|
||
_, err := tdb.Pool.Exec(ctx,
|
||
`INSERT INTO models (id, owner_user_id, name, storage_key, file_size, source, visibility)
|
||
VALUES ($1, $2, 'bad', 'k', 1024, 'uploaded', 'world')`,
|
||
uuid.NewString(), owner)
|
||
assert.Error(t, err, "非法 visibility 'world' 應被 CHECK constraint 擋下")
|
||
|
||
// 合法值可寫入。
|
||
for _, v := range []string{"private", "tenant", "public"} {
|
||
_, err := tdb.Pool.Exec(ctx,
|
||
`INSERT INTO models (id, owner_user_id, name, storage_key, file_size, source, visibility)
|
||
VALUES ($1, $2, 'ok', 'k', 1024, 'uploaded', $3)`,
|
||
uuid.NewString(), owner, v)
|
||
assert.NoError(t, err, "合法 visibility %q 應可寫入", v)
|
||
}
|
||
}
|
||
|
||
// TestMigrate0006_ModelSharesConstraints 驗證 model_shares 的 PK / FK / role CHECK(§3)。
|
||
func TestMigrate0006_ModelSharesConstraints(t *testing.T) {
|
||
tdb := testsupport.SetupTestDB(t)
|
||
ctx := context.Background()
|
||
|
||
owner := tdb.InsertUser(t, "", "")
|
||
grantee := tdb.InsertUser(t, "", "")
|
||
modelID := insertRawModel(t, tdb, owner, "private")
|
||
|
||
// 合法 share。
|
||
_, err := tdb.Pool.Exec(ctx,
|
||
`INSERT INTO model_shares (model_id, grantee_user_id, role, granted_by)
|
||
VALUES ($1, $2, 'viewer', $3)`, modelID, grantee, owner)
|
||
require.NoError(t, err, "合法 share 應可寫入")
|
||
|
||
// PK 重複(同 model + 同 grantee)→ 衝突。
|
||
_, err = tdb.Pool.Exec(ctx,
|
||
`INSERT INTO model_shares (model_id, grantee_user_id, role, granted_by)
|
||
VALUES ($1, $2, 'editor', $3)`, modelID, grantee, owner)
|
||
assert.Error(t, err, "重複 (model_id, grantee_user_id) 應違反 PK")
|
||
|
||
// role CHECK:非法 role。
|
||
grantee2 := tdb.InsertUser(t, "", "")
|
||
_, err = tdb.Pool.Exec(ctx,
|
||
`INSERT INTO model_shares (model_id, grantee_user_id, role, granted_by)
|
||
VALUES ($1, $2, 'admin', $3)`, modelID, grantee2, owner)
|
||
assert.Error(t, err, "非法 role 'admin' 應被 CHECK 擋下")
|
||
|
||
// FK:不存在的 model_id。
|
||
_, err = tdb.Pool.Exec(ctx,
|
||
`INSERT INTO model_shares (model_id, grantee_user_id, role, granted_by)
|
||
VALUES ($1, $2, 'viewer', $3)`, uuid.NewString(), grantee2, owner)
|
||
assert.Error(t, err, "不存在的 model_id 應違反 FK")
|
||
|
||
// FK:不存在的 grantee_user_id。
|
||
_, err = tdb.Pool.Exec(ctx,
|
||
`INSERT INTO model_shares (model_id, grantee_user_id, role, granted_by)
|
||
VALUES ($1, $2, 'viewer', $3)`, modelID, uuid.NewString(), owner)
|
||
assert.Error(t, err, "不存在的 grantee_user_id 應違反 FK")
|
||
}
|
||
|
||
// TestMigrate0006_ModelSharesCascade 驗證 model 硬刪時連帶清 share(ON DELETE CASCADE)。
|
||
func TestMigrate0006_ModelSharesCascade(t *testing.T) {
|
||
tdb := testsupport.SetupTestDB(t)
|
||
ctx := context.Background()
|
||
|
||
owner := tdb.InsertUser(t, "", "")
|
||
grantee := tdb.InsertUser(t, "", "")
|
||
modelID := insertRawModel(t, tdb, owner, "private")
|
||
_, err := tdb.Pool.Exec(ctx,
|
||
`INSERT INTO model_shares (model_id, grantee_user_id, role, granted_by)
|
||
VALUES ($1, $2, 'viewer', $3)`, modelID, grantee, owner)
|
||
require.NoError(t, err)
|
||
|
||
// 硬刪 model(非軟刪)→ share 應連帶消失。
|
||
_, err = tdb.Pool.Exec(ctx, `DELETE FROM models WHERE id = $1`, modelID)
|
||
require.NoError(t, err)
|
||
|
||
var n int
|
||
err = tdb.Pool.QueryRow(ctx,
|
||
`SELECT count(*) FROM model_shares WHERE model_id = $1`, modelID).Scan(&n)
|
||
require.NoError(t, err)
|
||
assert.Equal(t, 0, n, "model 硬刪後 model_shares 應連帶清空(CASCADE)")
|
||
}
|
||
|
||
// TestMigrate0006_RollbackSymmetry 驗證 down 對稱:visibility 欄與 model_shares 表消失(§4)。
|
||
func TestMigrate0006_RollbackSymmetry(t *testing.T) {
|
||
tdb := testsupport.SetupTestDB(t)
|
||
|
||
mg, err := db.NewMigrator(tdb.Cfg, discardLog())
|
||
require.NoError(t, err)
|
||
defer mg.Close()
|
||
|
||
require.True(t, colExists(t, tdb, "models", "visibility"), "down 前 visibility 欄應存在")
|
||
require.True(t, tableExists(t, tdb, "model_shares"), "down 前 model_shares 表應存在")
|
||
|
||
require.NoError(t, mg.Down(), "down 一步(回退 0006)")
|
||
|
||
assert.False(t, colExists(t, tdb, "models", "visibility"), "down 後 visibility 欄應消失")
|
||
assert.False(t, tableExists(t, tdb, "model_shares"), "down 後 model_shares 表應消失")
|
||
for _, idx := range []string{"idx_model_shares_grantee", "idx_models_public_active"} {
|
||
assert.False(t, indexExists(t, tdb, idx), "down 後 index %s 應消失", idx)
|
||
}
|
||
}
|
||
|
||
// TestMigrate0006_ReApplyIdempotent 驗證 up→down→up 不報錯、結果一致(§5)。
|
||
func TestMigrate0006_ReApplyIdempotent(t *testing.T) {
|
||
tdb := testsupport.SetupTestDB(t)
|
||
|
||
mg, err := db.NewMigrator(tdb.Cfg, discardLog())
|
||
require.NoError(t, err)
|
||
defer mg.Close()
|
||
|
||
topVer, dirty, err := mg.Version()
|
||
require.NoError(t, err)
|
||
require.False(t, dirty)
|
||
|
||
require.NoError(t, mg.Down(), "down 一步")
|
||
require.NoError(t, db.RunMigrations(tdb.Cfg, discardLog()), "重新 up")
|
||
|
||
ver, dirty, err := mg.Version()
|
||
require.NoError(t, err)
|
||
assert.False(t, dirty, "up→down→up 後不應 dirty")
|
||
assert.Equal(t, topVer, ver, "up→down→up 後版本應回到最新")
|
||
assert.True(t, colExists(t, tdb, "models", "visibility"), "重新 up 後 visibility 欄應再次存在")
|
||
assert.True(t, tableExists(t, tdb, "model_shares"), "重新 up 後 model_shares 表應再次存在")
|
||
}
|