//go:build dbtest // Postgres pairing exchange 自建 device 的真 DB 整合測試(DB-on FK 收尾,問題 #2)。 // // build tag `dbtest`:只在帶 `-tags=dbtest`(需要 Docker / testcontainers)時編譯/執行。 // 預設 `go test ./...`(無 Docker)不觸碰本檔,維持綠燈。 // // 執行: // // go test -tags=dbtest ./internal/api/... // # 無本機 Docker 時,Orchestrator 在 130 補跑: // DOCKER_HOST=tcp://192.168.0.130:2375 TESTCONTAINERS_RYUK_DISABLED=true \ // go test -tags=dbtest ./internal/api/... // // 涵蓋: // - Provision 成功:自建一筆 device(owner 對齊)+ 建綁該 device 的 session token,session // token 的 device_id 不再為空、且確實指向新建的 device(FK 滿足)。 // - parent_token_hash 寫入(稽核鏈)。 // - 原子性:device owner 不存在(FK violation)→ 整筆 rollback,device 不會殘留。 package api import ( "context" "testing" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "visiona-backend/internal/auth" "visiona-backend/internal/db/testsupport" "visiona-backend/internal/device" ) // pgExchangeFixture 建一個已就緒的 Postgres 環境:一個合法 owner user(無 device、無 token)。 func pgExchangeFixture(t *testing.T) ( tdb *testsupport.TestDB, exchanger PairingExchanger, devRepo *device.PostgresRepository, sessions *auth.PostgresSessionTokenStore, owner string, ) { t.Helper() tdb = testsupport.SetupTestDB(t) tdb.Truncate(t, "pairing_tokens", "session_tokens", "devices", "users") owner = tdb.EnsureDemoUser(t) devRepo = device.NewPostgresRepository(tdb.Pool) sessions = auth.NewPostgresSessionTokenStore(tdb.Pool) exchanger = NewPostgresPairingExchanger(tdb.Pool, devRepo, sessions, nil) return } // TestPGExchange_ProvisionCreatesDeviceAndSession 驗證 exchange 自建 device + session token, // 且 session token 的 device_id 綁定到新建的 device(之前 DB-on 會因 device_id 空字串失敗)。 func TestPGExchange_ProvisionCreatesDeviceAndSession(t *testing.T) { ctx := context.Background() tdb, exchanger, devRepo, sessions, owner := pgExchangeFixture(t) parentHash := auth.HashToken("vAc_" + uuid.NewString()[:32]) res, err := exchanger.Provision(ctx, owner, parentHash, auth.SessionTokenTTL, nil) require.NoError(t, err) require.NotEmpty(t, res.DeviceID, "應自建一筆 device") require.NotEmpty(t, res.SessionPlaintext, "應建一個 session token") require.NotNil(t, res.SessionInfo) // 1) device 真的進 DB、owner 對齊 dev, err := devRepo.Get(ctx, res.DeviceID) require.NoError(t, err) assert.Equal(t, owner, dev.OwnerUserID) assert.Equal(t, defaultPairedDeviceName, dev.Name) assert.Equal(t, defaultPairedDeviceType, dev.DeviceType) assert.NotNil(t, dev.PairedAt, "自建 device 應設 paired_at") // 2) session token 真的進 DB、device_id 綁到新建 device(非空、FK 滿足) tok, err := sessions.Get(ctx, res.SessionPlaintext) require.NoError(t, err) assert.Equal(t, res.DeviceID, tok.DeviceID, "session token 的 device_id 應綁到自建 device") assert.Equal(t, owner, tok.UserID) assert.Equal(t, parentHash, tok.ParentTokenHash, "parent_token_hash 應為來源 pairing token hash(稽核鏈)") // 3) 直接查 DB 確認 session_tokens.device_id 非 NULL var deviceIDIsNull bool require.NoError(t, tdb.Pool.QueryRow(ctx, `SELECT device_id IS NULL FROM session_tokens WHERE token_hash = $1`, auth.HashToken(res.SessionPlaintext)).Scan(&deviceIDIsNull)) assert.False(t, deviceIDIsNull, "session_tokens.device_id 不應為 NULL") } // TestPGExchange_Provision_RollbackOnBadOwner 驗證原子性:owner 不存在於 users(FK violation) // → device INSERT 撞 owner_user_id FK → 整筆 rollback,device 不殘留。 func TestPGExchange_Provision_RollbackOnBadOwner(t *testing.T) { ctx := context.Background() tdb, exchanger, _, _, _ := pgExchangeFixture(t) badOwner := uuid.NewString() // 不在 users 表 _, err := exchanger.Provision(ctx, badOwner, "", auth.SessionTokenTTL, nil) require.Error(t, err, "owner 不存在 → device.owner_user_id FK violation") // device 不應殘留(整筆交易 rollback) assert.Equal(t, 0, tdb.CountRows(t, "devices"), "FK 失敗應 rollback,無 device 殘留") assert.Equal(t, 0, tdb.CountRows(t, "session_tokens"), "session token 也不應建立") } // TestPGExchange_Provision_MultipleCreatesDistinctDevices 驗證多次 exchange 各自建新 device // (不同 UUID)——對齊「不同次配對視為不同 agent 連線」的冪等語意。 func TestPGExchange_Provision_MultipleCreatesDistinctDevices(t *testing.T) { ctx := context.Background() _, exchanger, _, _, owner := pgExchangeFixture(t) res1, err := exchanger.Provision(ctx, owner, "", auth.SessionTokenTTL, nil) require.NoError(t, err) res2, err := exchanger.Provision(ctx, owner, "", auth.SessionTokenTTL, nil) require.NoError(t, err) assert.NotEqual(t, res1.DeviceID, res2.DeviceID, "兩次 exchange 應各自建不同 device") assert.NotEqual(t, res1.SessionPlaintext, res2.SessionPlaintext, "兩次 session token 應不同") } // ========================================================================== // WP-0(ADR-018 序號地基):exchange 收 agent 上報序號(真 DB) // ========================================================================== // TestPGExchange_Provision_FillsSerialNumber 驗證序號真的寫進 devices.serial_number // (非 NULL),且 device_type 取 agent 上報值。 func TestPGExchange_Provision_FillsSerialNumber(t *testing.T) { ctx := context.Background() tdb, exchanger, devRepo, _, owner := pgExchangeFixture(t) res, err := exchanger.Provision(ctx, owner, "", auth.SessionTokenTTL, []ExchangeDeviceInput{{SerialNumber: "0x1A2B3C4D", DeviceType: "kneron_kl520"}}) require.NoError(t, err) dev, err := devRepo.Get(ctx, res.DeviceID) require.NoError(t, err) assert.Equal(t, "0x1A2B3C4D", dev.SerialNumber) assert.Equal(t, "kneron_kl520", dev.DeviceType) // 直接查 DB 確認 serial_number 非 NULL(不是空字串寫入) var serialIsNull bool require.NoError(t, tdb.Pool.QueryRow(ctx, `SELECT serial_number IS NULL FROM devices WHERE id = $1`, res.DeviceID).Scan(&serialIsNull)) assert.False(t, serialIsNull, "devices.serial_number 不應為 NULL") } // TestPGExchange_Provision_SameSerialReusesDevice 驗證同序號重複 exchange 不撞 // partial unique uq_devices_owner_serial_active(23505)——復用既有 device、 // 不 500、devices 表不長出第二筆。 func TestPGExchange_Provision_SameSerialReusesDevice(t *testing.T) { ctx := context.Background() tdb, exchanger, _, sessions, owner := pgExchangeFixture(t) input := []ExchangeDeviceInput{{SerialNumber: "0x1A2B3C4D"}} res1, err := exchanger.Provision(ctx, owner, "", auth.SessionTokenTTL, input) require.NoError(t, err) res2, err := exchanger.Provision(ctx, owner, "", auth.SessionTokenTTL, input) require.NoError(t, err, "同序號重配不可撞 23505 炸 500(防炸守則)") assert.Equal(t, res1.DeviceID, res2.DeviceID, "同序號重配應復用既有 device") assert.Equal(t, 1, tdb.CountRows(t, "devices"), "同序號重配不應多建 device") // 兩個 session token 都存在、都綁同一顆 device tok2, err := sessions.Get(ctx, res2.SessionPlaintext) require.NoError(t, err) assert.Equal(t, res1.DeviceID, tok2.DeviceID) } // TestPGExchange_Provision_FakeSerialWritesNull 驗證假序號 0x00000000 視同無序號 // → serial_number 寫 NULL、不進復用分支(NULL 互不相等,各建 distinct device)。 func TestPGExchange_Provision_FakeSerialWritesNull(t *testing.T) { ctx := context.Background() tdb, exchanger, _, _, owner := pgExchangeFixture(t) input := []ExchangeDeviceInput{{SerialNumber: "0x00000000"}} res1, err := exchanger.Provision(ctx, owner, "", auth.SessionTokenTTL, input) require.NoError(t, err) res2, err := exchanger.Provision(ctx, owner, "", auth.SessionTokenTTL, input) require.NoError(t, err, "假序號寫 NULL、NULL 互不相等 → 不撞 unique") assert.NotEqual(t, res1.DeviceID, res2.DeviceID) var serialIsNull bool require.NoError(t, tdb.Pool.QueryRow(ctx, `SELECT serial_number IS NULL FROM devices WHERE id = $1`, res1.DeviceID).Scan(&serialIsNull)) assert.True(t, serialIsNull, "假序號應寫 NULL") }