visionA/visionA-backend/internal/db/migrate_0006_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

239 lines
9.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.

//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. applymodels 有 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 硬刪時連帶清 shareON 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 表應再次存在")
}