- Device struct 加 4 欄(agent_id/agent_local_device_id/registered_at/
is_representative)+ deviceColumns 13→17 + scanDevice/SaveTx 讀寫新欄
- 新增 internal/agent package(domain + interface + in-memory + PG repo):
GetOrCreateAgentTx/GetByOwnerTx,advisory lock 序列化同 owner get-or-create
- exchange 重塑:建/復用 agent → representative device(綁 session_tokens、
serial=NULL)→ loop 建 N 顆真 USB device(R1 完整 N 顆非只第一顆)
- List filter is_representative=false + DeviceListItem 回傳 agent_id/registered_at
- 併入 WP-0/0005 follow-up Minor:Mi#2 lost-update 收斂(tx 內查詢+局部更新)
/ Mi#3 過時註解 / Mi#4 空 serial 回 ErrNotFound / Mi#5 serial 白名單
^0x[0-9A-Fa-f]{8}$ + 去重 / S-1 firmware forward-compat / S-2 device Name 衍生
守 ADR-018 A'(一 owner N agents、session_tokens FK 物理不動、不加 owner
unique 為多機器留路)。Reviewer 通過(0C/0M)。5 套件 dbtest 130 全綠
(db 19/device 37/agent 13/api 172/cmd 60)、build/vet/test 綠。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
239 lines
9.7 KiB
Go
239 lines
9.7 KiB
Go
//go:build dbtest
|
||
|
||
// Postgres pairing exchange A' 模型重塑的真 DB 整合測試(WP-B B3)。
|
||
//
|
||
// build tag `dbtest`:只在帶 `-tags=dbtest`(需要 Docker / testcontainers)時編譯/執行。
|
||
//
|
||
// 執行:
|
||
//
|
||
// go test -tags=dbtest ./internal/api/...
|
||
// # 無本機 Docker 時在 130 補跑:
|
||
// DOCKER_HOST=tcp://192.168.0.130:2375 TESTCONTAINERS_RYUK_DISABLED=true \
|
||
// go test -tags=dbtest ./internal/api/...
|
||
//
|
||
// 涵蓋 A' 重塑:建/復用 agent + representative device(綁 session token)+ N 顆真 USB device;
|
||
// Mi#2(tx 內查詢/局部更新)、Mi#5(serial 白名單)、S-2(Name 衍生)、R1(完整 N 顆)、R4(同序號復用)。
|
||
package api
|
||
|
||
import (
|
||
"context"
|
||
"testing"
|
||
|
||
"github.com/google/uuid"
|
||
"github.com/stretchr/testify/assert"
|
||
"github.com/stretchr/testify/require"
|
||
|
||
"visiona-backend/internal/agent"
|
||
"visiona-backend/internal/auth"
|
||
"visiona-backend/internal/db/testsupport"
|
||
"visiona-backend/internal/device"
|
||
)
|
||
|
||
// pgExchangeFixture 建一個已就緒的 Postgres 環境:一個合法 owner user(無 agent / device / token)。
|
||
func pgExchangeFixture(t *testing.T) (
|
||
tdb *testsupport.TestDB,
|
||
exchanger PairingExchanger,
|
||
devRepo *device.PostgresRepository,
|
||
agentRepo *agent.PostgresRepository,
|
||
sessions *auth.PostgresSessionTokenStore,
|
||
owner string,
|
||
) {
|
||
t.Helper()
|
||
tdb = testsupport.SetupTestDB(t)
|
||
tdb.Truncate(t, "pairing_tokens", "session_tokens", "devices", "agents", "users")
|
||
owner = tdb.EnsureDemoUser(t)
|
||
|
||
devRepo = device.NewPostgresRepository(tdb.Pool)
|
||
agentRepo = agent.NewPostgresRepository(tdb.Pool)
|
||
sessions = auth.NewPostgresSessionTokenStore(tdb.Pool)
|
||
exchanger = NewPostgresPairingExchanger(tdb.Pool, agentRepo, devRepo, sessions, nil)
|
||
return
|
||
}
|
||
|
||
// Provision 建 agent + representative device + session token(無 USB 上報)。
|
||
func TestPGExchange_ProvisionCreatesAgentRepresentativeAndSession(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, "應建 representative device")
|
||
require.NotEmpty(t, res.AgentID, "應建 agent")
|
||
require.NotEmpty(t, res.SessionPlaintext)
|
||
assert.Empty(t, res.USBDeviceIDs, "無 USB 上報 → 不建真 USB device")
|
||
|
||
// 1) agent 真的進 DB。
|
||
assert.Equal(t, 1, tdb.CountRows(t, "agents"))
|
||
|
||
// 2) representative device:is_representative=true、掛該 agent、serial=NULL。
|
||
rep, err := devRepo.Get(ctx, res.DeviceID)
|
||
require.NoError(t, err)
|
||
assert.True(t, rep.IsRepresentative)
|
||
assert.Equal(t, res.AgentID, rep.AgentID)
|
||
assert.Equal(t, owner, rep.OwnerUserID)
|
||
assert.Empty(t, rep.SerialNumber)
|
||
assert.NotNil(t, rep.PairedAt)
|
||
|
||
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.True(t, serialIsNull, "representative device serial 應為 NULL")
|
||
|
||
// 3) session token 綁 representative device(device_id 非 NULL、FK 滿足)。
|
||
tok, err := sessions.Get(ctx, res.SessionPlaintext)
|
||
require.NoError(t, err)
|
||
assert.Equal(t, res.DeviceID, tok.DeviceID)
|
||
assert.Equal(t, owner, tok.UserID)
|
||
assert.Equal(t, parentHash, tok.ParentTokenHash)
|
||
}
|
||
|
||
// 原子性:owner 不存在(FK violation)→ 整筆 rollback,agent / device / token 都不殘留。
|
||
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 不存在 → agent.owner_user_id FK violation")
|
||
|
||
assert.Equal(t, 0, tdb.CountRows(t, "agents"), "FK 失敗應 rollback,無 agent 殘留")
|
||
assert.Equal(t, 0, tdb.CountRows(t, "devices"), "無 device 殘留")
|
||
assert.Equal(t, 0, tdb.CountRows(t, "session_tokens"), "無 session token 殘留")
|
||
}
|
||
|
||
// 同 owner 多次 exchange:復用同一 agent + 同一 representative device,各建獨立 session token。
|
||
func TestPGExchange_Provision_ReusesAgentAndRepresentative(t *testing.T) {
|
||
ctx := context.Background()
|
||
tdb, 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.Equal(t, res1.AgentID, res2.AgentID, "同 owner 應復用同一 agent")
|
||
assert.Equal(t, res1.DeviceID, res2.DeviceID, "同 owner 應復用同一 representative device")
|
||
assert.NotEqual(t, res1.SessionPlaintext, res2.SessionPlaintext)
|
||
|
||
assert.Equal(t, 1, tdb.CountRows(t, "agents"), "復用不多建 agent")
|
||
assert.Equal(t, 1, tdb.CountRows(t, "devices"), "復用不多建 representative device")
|
||
assert.Equal(t, 2, tdb.CountRows(t, "session_tokens"), "兩次各建一個 session token")
|
||
}
|
||
|
||
// R1:N 顆可用序號 USB → 建 N 筆真 USB device(掛同一 agent、is_representative=false)。
|
||
func TestPGExchange_Provision_CreatesNUSBDevices(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"},
|
||
{SerialNumber: "0x0E5F6071", DeviceType: "kneron_kl720"},
|
||
{SerialNumber: "0xFFFF0001"},
|
||
})
|
||
require.NoError(t, err)
|
||
require.Len(t, res.USBDeviceIDs, 3, "三顆可用序號 → 三筆真 USB device")
|
||
|
||
// devices 共 4 筆:1 representative + 3 USB。
|
||
assert.Equal(t, 4, tdb.CountRows(t, "devices"))
|
||
|
||
for _, id := range res.USBDeviceIDs {
|
||
d, err := devRepo.Get(ctx, id)
|
||
require.NoError(t, err)
|
||
assert.False(t, d.IsRepresentative)
|
||
assert.Equal(t, res.AgentID, d.AgentID, "真 USB device 掛同一 agent")
|
||
assert.NotEmpty(t, d.SerialNumber)
|
||
}
|
||
}
|
||
|
||
// S-2:真 USB device Name 由 device type + serial 衍生(非固定字串)。
|
||
func TestPGExchange_Provision_DerivesUSBDeviceName(t *testing.T) {
|
||
ctx := context.Background()
|
||
_, exchanger, devRepo, _, _, owner := pgExchangeFixture(t)
|
||
|
||
res, err := exchanger.Provision(ctx, owner, "", auth.SessionTokenTTL,
|
||
[]ExchangeDeviceInput{{SerialNumber: "0x1A2B3C4D", DeviceType: "kneron_kl520"}})
|
||
require.NoError(t, err)
|
||
require.Len(t, res.USBDeviceIDs, 1)
|
||
|
||
d, err := devRepo.Get(ctx, res.USBDeviceIDs[0])
|
||
require.NoError(t, err)
|
||
assert.Equal(t, "kneron_kl520 (0x1A2B3C4D)", d.Name)
|
||
assert.Equal(t, "kneron_kl520", d.DeviceType)
|
||
}
|
||
|
||
// R4:同序號重配復用同一真 USB device,不撞 partial unique(23505)、不多建。
|
||
func TestPGExchange_Provision_SameSerialReusesUSBDevice(t *testing.T) {
|
||
ctx := context.Background()
|
||
tdb, exchanger, _, _, _, owner := pgExchangeFixture(t)
|
||
input := []ExchangeDeviceInput{{SerialNumber: "0x1A2B3C4D"}}
|
||
|
||
res1, err := exchanger.Provision(ctx, owner, "", auth.SessionTokenTTL, input)
|
||
require.NoError(t, err)
|
||
require.Len(t, res1.USBDeviceIDs, 1)
|
||
res2, err := exchanger.Provision(ctx, owner, "", auth.SessionTokenTTL, input)
|
||
require.NoError(t, err, "同序號重配不可撞 23505 炸 500")
|
||
require.Len(t, res2.USBDeviceIDs, 1)
|
||
|
||
assert.Equal(t, res1.USBDeviceIDs[0], res2.USBDeviceIDs[0], "同序號應復用同一 USB device")
|
||
// devices:1 representative + 1 USB = 2(同序號不多建)。
|
||
assert.Equal(t, 2, tdb.CountRows(t, "devices"))
|
||
}
|
||
|
||
// serial 真的寫進 DB(非 NULL)+ 復用 USB device 掛到 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)
|
||
require.Len(t, res.USBDeviceIDs, 1)
|
||
|
||
d, err := devRepo.Get(ctx, res.USBDeviceIDs[0])
|
||
require.NoError(t, err)
|
||
assert.Equal(t, "0x1A2B3C4D", d.SerialNumber)
|
||
assert.Equal(t, res.AgentID, d.AgentID)
|
||
|
||
var serialIsNull bool
|
||
require.NoError(t, tdb.Pool.QueryRow(ctx,
|
||
`SELECT serial_number IS NULL FROM devices WHERE id = $1`, res.USBDeviceIDs[0]).Scan(&serialIsNull))
|
||
assert.False(t, serialIsNull, "真 USB device serial_number 不應為 NULL")
|
||
}
|
||
|
||
// 假序號 0x00000000 + Mi#5 白名單:不符格式的序號視同無序號,不建真 USB device。
|
||
func TestPGExchange_Provision_InvalidSerialsSkipped(t *testing.T) {
|
||
ctx := context.Background()
|
||
tdb, exchanger, _, _, _, owner := pgExchangeFixture(t)
|
||
|
||
res, err := exchanger.Provision(ctx, owner, "", auth.SessionTokenTTL,
|
||
[]ExchangeDeviceInput{
|
||
{SerialNumber: "0x00000000"}, // 假序號
|
||
{SerialNumber: "bad"}, // 無 0x
|
||
{SerialNumber: "0x123"}, // 位數不足
|
||
{SerialNumber: "0xGGGGGGGG"}, // 非 hex
|
||
})
|
||
require.NoError(t, err)
|
||
assert.Empty(t, res.USBDeviceIDs, "無合法序號 → 不建真 USB device")
|
||
// 只有 representative device。
|
||
assert.Equal(t, 1, tdb.CountRows(t, "devices"))
|
||
}
|
||
|
||
// session_tokens 綁 representative:兩次 exchange 綁同一 representative device_id(Q5 語意驗證)。
|
||
func TestPGExchange_Provision_SessionsBindSameRepresentative(t *testing.T) {
|
||
ctx := context.Background()
|
||
tdb, 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)
|
||
|
||
var distinctDeviceIDs int
|
||
require.NoError(t, tdb.Pool.QueryRow(ctx,
|
||
`SELECT count(DISTINCT device_id) FROM session_tokens`).Scan(&distinctDeviceIDs))
|
||
assert.Equal(t, 1, distinctDeviceIDs, "兩個 session token 應綁同一 representative device")
|
||
assert.Equal(t, res1.DeviceID, res2.DeviceID)
|
||
}
|