- local-agent/server:detector 保留 kn_number(parseScanDevices 可測化、 合成 ID 語意不動)+ DeviceInfo.SerialNumber + Manager serialToLocalID 反查表 + GetDevice 雙查(sessions 先、serial 後,純加法) - visiona-agent:pairing exchange 帶本地裝置清單(DeviceLister 失敗不中斷 配對、timeout 2s、omitempty 舊版相容) - visionA-backend:exchange 收 devices —— R1 取第一顆可用序號、R2 假序號 0x00000000 寫 NULL、R4 同 owner 同序號復用既有 device_id(防 23505)、 pg+mem 兩實作對齊 - 五個 proxy 操作(flash/inference/camera/connect/disconnect)收斂於 GetDevice 單一入口,serial 路由一處涵蓋 - docs:api-spec.md §2 增補 POST /api/pairing/exchange(schema + R1/R2/R4) - 測試:行為級四環節鏈 + dbtest 130 實跑 6/6 + DBOn 回歸 4/4; 三 module build/vet/test 全綠 - review:通過 0C/0M/6Mi/5Sug(.autoflow/05-implementation/review/ wp0-serial-routing-review.md);Minor #1 Rescan stale session 掛 WP-C 前置 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
187 lines
8.2 KiB
Go
187 lines
8.2 KiB
Go
//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")
|
||
}
|