visionA/visionA-backend/internal/api/pairing_exchange_db_test.go
jim800121chen b9ee184586 feat(device): WP-0 序號串通 + serial 路由(ADR-018 走向 A' 第一階段)
- 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>
2026-07-10 09:31:51 +08:00

187 lines
8.2 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
// 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 成功:自建一筆 deviceowner 對齊)+ 建綁該 device 的 session tokensession
// token 的 device_id 不再為空、且確實指向新建的 deviceFK 滿足)。
// - parent_token_hash 寫入(稽核鏈)。
// - 原子性device owner 不存在FK violation→ 整筆 rollbackdevice 不會殘留。
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 不存在於 usersFK violation
// → device INSERT 撞 owner_user_id FK → 整筆 rollbackdevice 不殘留。
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-0ADR-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_active23505——復用既有 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")
}