feat(device): WP-B repository 接 agents 模型 + exchange 重塑(A' 走向第二階段 Go 層)
- 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>
This commit is contained in:
parent
8369cab85c
commit
59c57fa481
@ -44,7 +44,12 @@ func TestDBOnFix_OIDCCallbackProvisionsUser(t *testing.T) {
|
||||
assert.Equal(t, "alice@example.com", got.Email)
|
||||
}
|
||||
|
||||
// TestDBOnFix_ExchangeProvisionsDevice 驗證問題 #2:pairing exchange 後自建 device(owner 對齊)。
|
||||
// TestDBOnFix_ExchangeProvisionsDevice 驗證問題 #2 + A' 重塑(WP-B B3/B4):pairing exchange
|
||||
// 後建 agent 的 representative device 供 session token 綁定。
|
||||
//
|
||||
// A' 語意變更:exchange 無 USB 上報時只建 representative device(is_representative=true),
|
||||
// 而 List(B4)會 filter 掉 representative(只列真 USB)——故 exchange 前後 List 都為空。
|
||||
// 「device 有被建」改由「representative device 存在且綁到 session」驗證。
|
||||
func TestDBOnFix_ExchangeProvisionsDevice(t *testing.T) {
|
||||
f := setupFixture(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
@ -54,7 +59,7 @@ func TestDBOnFix_ExchangeProvisionsDevice(t *testing.T) {
|
||||
const sub = "bob-sub"
|
||||
client := f.AuthenticatedClient(t, sub, "bob@example.com")
|
||||
|
||||
// 登入後但 exchange 前:該 user 名下無 device
|
||||
// 登入後但 exchange 前:該 user 名下無(真 USB)device
|
||||
before, err := f.deviceRepo.List(context.Background(), sub)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, before, "exchange 前不應有 device")
|
||||
@ -68,7 +73,7 @@ func TestDBOnFix_ExchangeProvisionsDevice(t *testing.T) {
|
||||
require.NoError(t, json.NewDecoder(tokResp.Body).Decode(&tokBody))
|
||||
pairingTok := tokBody["data"].(map[string]any)["token"].(string)
|
||||
|
||||
// 2) exchange(不走 AuthMiddleware)→ 應自建 device + 建 session token
|
||||
// 2) exchange(不走 AuthMiddleware)→ 建 agent + representative device + session token
|
||||
reqBody, _ := json.Marshal(api.PairingExchangeRequest{PairingToken: pairingTok})
|
||||
exchResp, err := http.Post(f.apiServer.URL+"/api/pairing/exchange",
|
||||
"application/json", bytes.NewReader(reqBody))
|
||||
@ -80,10 +85,18 @@ func TestDBOnFix_ExchangeProvisionsDevice(t *testing.T) {
|
||||
sessionTok := exchBody["data"].(map[string]any)["session_token"].(string)
|
||||
require.True(t, auth.IsValidSessionToken(sessionTok))
|
||||
|
||||
// 3) exchange 後:該 user 名下多了一筆自建 device
|
||||
// 3) exchange 後:List(真 USB)仍為空——A' 下無 USB 上報只建 representative(B4 filter)。
|
||||
after, err := f.deviceRepo.List(context.Background(), sub)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, after, 1, "exchange 應自建一筆 device")
|
||||
assert.Equal(t, sub, after[0].OwnerUserID, "device owner 應為登入 user")
|
||||
assert.NotNil(t, after[0].PairedAt, "自建 device 應設 paired_at")
|
||||
require.Empty(t, after, "無 USB 上報 → List(真 USB)仍為空(representative 被 filter)")
|
||||
|
||||
// 4) session token 綁到一顆存在的 representative device(is_representative=true、owner 對齊)。
|
||||
tok, err := f.sessionTokenStore.Get(context.Background(), sessionTok)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, tok.DeviceID, "session token 應綁一個 device")
|
||||
rep, err := f.deviceRepo.Get(context.Background(), tok.DeviceID)
|
||||
require.NoError(t, err, "session 綁的 representative device 應存在")
|
||||
assert.True(t, rep.IsRepresentative, "exchange 建的應為 representative device")
|
||||
assert.Equal(t, sub, rep.OwnerUserID, "device owner 應為登入 user")
|
||||
assert.NotNil(t, rep.PairedAt, "representative device 應設 paired_at")
|
||||
}
|
||||
|
||||
@ -41,6 +41,7 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"visiona-backend/internal/agent"
|
||||
"visiona-backend/internal/api"
|
||||
"visiona-backend/internal/auth"
|
||||
"visiona-backend/internal/converter"
|
||||
@ -114,6 +115,10 @@ type testFixture struct {
|
||||
userStore *user.InMemoryStore
|
||||
deviceRepo *device.InMemoryRepository
|
||||
|
||||
// sessionTokenStore 暴露給 A' 重塑 test(WP-B):驗證 exchange 建的 session token
|
||||
// 綁到 representative device(List 已 filter 掉 representative)。
|
||||
sessionTokenStore *auth.InMemorySessionTokenStore
|
||||
|
||||
// router 暴露 *gin.Engine 給需要列出所有 route 的 test
|
||||
// (目前用於 all_endpoints_require_auth_test.go — Phase 0.7 security regression)。
|
||||
router *gin.Engine
|
||||
@ -239,7 +244,7 @@ func setupFixtureWithMaxUpload(t *testing.T, localHandler http.Handler, maxUploa
|
||||
|
||||
// DB-on FK 收尾:in-memory user store(問題 #1)+ in-memory pairing exchanger(問題 #2)
|
||||
UserStore: userStore,
|
||||
PairingExchanger: api.NewInMemoryPairingExchanger(deviceRepo, sessionTokenStore),
|
||||
PairingExchanger: api.NewInMemoryPairingExchanger(agent.NewInMemoryRepository(), deviceRepo, sessionTokenStore),
|
||||
|
||||
// OIDC wiring(OB5)
|
||||
OIDCProvider: oidcProvider,
|
||||
@ -260,6 +265,7 @@ func setupFixtureWithMaxUpload(t *testing.T, localHandler http.Handler, maxUploa
|
||||
sessionMgr: sessionMgr,
|
||||
userStore: userStore,
|
||||
deviceRepo: deviceRepo,
|
||||
sessionTokenStore: sessionTokenStore,
|
||||
router: router,
|
||||
}
|
||||
}
|
||||
|
||||
@ -24,6 +24,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"visiona-backend/internal/agent"
|
||||
"visiona-backend/internal/api"
|
||||
"visiona-backend/internal/auth"
|
||||
"visiona-backend/internal/config"
|
||||
@ -231,12 +232,14 @@ func main() {
|
||||
// (local dev fallback,雛形行為不變)。Repository interface 不變,handler 一行都不需改。
|
||||
var deviceRepo device.Repository
|
||||
var pgDeviceRepo *device.PostgresRepository // 塊 5.2 cascade:DeleteTx 需 concrete type
|
||||
var memDeviceRepo *device.InMemoryRepository // WP-B B3:mem exchanger 需 concrete(representative concrete method)
|
||||
if dbPool != nil {
|
||||
pgDeviceRepo = device.NewPostgresRepository(dbPool.Pool())
|
||||
deviceRepo = pgDeviceRepo
|
||||
log.Info("device repository initialized", "backend", "postgres")
|
||||
} else {
|
||||
deviceRepo = device.NewInMemoryRepository()
|
||||
memDeviceRepo = device.NewInMemoryRepository()
|
||||
deviceRepo = memDeviceRepo
|
||||
log.Info("device repository initialized", "backend", "in-memory")
|
||||
}
|
||||
|
||||
@ -280,18 +283,31 @@ func main() {
|
||||
log.Info("user store initialized", "backend", "in-memory")
|
||||
}
|
||||
|
||||
// pairing exchanger:DB-on FK 收尾(問題 #2)— exchange 時自建 device + 建 session token。
|
||||
// - Postgres:db.WithTx 把「建 device + 建 session token」包成單一交易,整筆原子。
|
||||
// agent repository:A' 模型(ADR-018 / migration 0005,WP-B B2)— exchange 建/復用 agent 用。
|
||||
// dbPool != nil 時 Postgres;否則 in-memory(local-dev fallback)。
|
||||
var pgAgentRepo *agent.PostgresRepository
|
||||
var memAgentRepo *agent.InMemoryRepository
|
||||
if dbPool != nil {
|
||||
pgAgentRepo = agent.NewPostgresRepository(dbPool.Pool())
|
||||
log.Info("agent repository initialized", "backend", "postgres")
|
||||
} else {
|
||||
memAgentRepo = agent.NewInMemoryRepository()
|
||||
log.Info("agent repository initialized", "backend", "in-memory")
|
||||
}
|
||||
|
||||
// pairing exchanger:A' 模型 exchange 重塑(ADR-018,WP-B B3)—
|
||||
// 建/復用 agent + representative device(綁 session token)+ N 顆真 USB device。
|
||||
// - Postgres:db.WithTx 把整串包成單一交易,整筆原子。
|
||||
// - in-memory:依序執行(無交易),行為一致。
|
||||
// 注入 Deps.PairingExchanger;exchange handler 偵測非 nil 即走「自建 device」路徑。
|
||||
// 注入 Deps.PairingExchanger;exchange handler 偵測非 nil 即走 A' 路徑。
|
||||
var pairingExchanger api.PairingExchanger
|
||||
if dbPool != nil {
|
||||
pairingExchanger = api.NewPostgresPairingExchanger(
|
||||
dbPool.Pool(), pgDeviceRepo, pgSessionTokenStore, log)
|
||||
dbPool.Pool(), pgAgentRepo, pgDeviceRepo, pgSessionTokenStore, log)
|
||||
log.Info("pairing exchanger initialized", "backend", "postgres-tx")
|
||||
} else {
|
||||
pairingExchanger = api.NewInMemoryPairingExchanger(
|
||||
deviceRepo, memSessionTokenStore)
|
||||
memAgentRepo, memDeviceRepo, memSessionTokenStore)
|
||||
log.Info("pairing exchanger initialized", "backend", "in-memory")
|
||||
}
|
||||
|
||||
|
||||
167
visionA-backend/internal/agent/agent.go
Normal file
167
visionA-backend/internal/agent/agent.go
Normal file
@ -0,0 +1,167 @@
|
||||
// Package agent 定義 Agent domain model 與 Repository 介面。
|
||||
//
|
||||
// 背景(ADR-018 走向 A' / migration 0005,WP-B B2):
|
||||
//
|
||||
// 一個 agent = 一條已配對的 tunnel 連線(一台跑 local-agent 的機器)。一個 agent 底下可掛
|
||||
// 多顆實體 USB device(agents 1 ─ N devices)。exchange 時建/復用該 owner 的 agent,再建
|
||||
// representative device(綁 session_tokens)+ N 顆真 USB device(皆 agent_id=此 agent)。
|
||||
//
|
||||
// 對齊 migrations/0005_create_agents.up.sql 的 agents 表 schema:
|
||||
// - id UUID PK(DEFAULT gen_random_uuid())
|
||||
// - owner_user_id UUID NOT NULL REFERENCES users(id)
|
||||
// - name TEXT NOT NULL DEFAULT 'local-agent'
|
||||
// - platform / agent_version TEXT(nullable,agent 上報,可空)
|
||||
// - last_paired_at TIMESTAMPTZ(nullable)
|
||||
// - created_at / updated_at NOT NULL DEFAULT now()
|
||||
// - deleted_at TIMESTAMPTZ(nullable,soft delete)
|
||||
//
|
||||
// 兩個實作對齊(沿用專案慣例,比照 user / device package):
|
||||
// - InMemoryRepository:local-dev fallback / 單元測試(不檢查 FK)。
|
||||
// - PostgresRepository:DB-on(postgres_repository.go)。
|
||||
//
|
||||
// main.go 依 dbPool 是否非 nil 擇一注入。
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"visiona-backend/internal/db"
|
||||
)
|
||||
|
||||
// newAgentID 產生一個新 agent id(in-memory 用;PG 版由 DB gen_random_uuid() 產)。
|
||||
func newAgentID() string { return uuid.NewString() }
|
||||
|
||||
// ErrNotFound 表示指定條件的 Agent 不存在(或已軟刪除)。
|
||||
var ErrNotFound = errors.New("agent: not found")
|
||||
|
||||
// Agent 對應 migrations/0005 的 agents 表。
|
||||
type Agent struct {
|
||||
ID string `json:"id"`
|
||||
OwnerUserID string `json:"ownerUserId"`
|
||||
Name string `json:"name"`
|
||||
Platform string `json:"platform,omitempty"`
|
||||
AgentVersion string `json:"agentVersion,omitempty"`
|
||||
LastPairedAt *time.Time `json:"lastPairedAt,omitempty"`
|
||||
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
DeletedAt *time.Time `json:"deletedAt,omitempty"`
|
||||
}
|
||||
|
||||
// Repository 是 Agent 持久層介面。
|
||||
//
|
||||
// 所有查詢方法必須略過 deleted_at IS NOT NULL 的紀錄(soft delete)。
|
||||
type Repository interface {
|
||||
// GetByOwnerTx 取得該 owner 的(第一個未刪除)agent;不存在回 ErrNotFound。
|
||||
//
|
||||
// 現階段語意「一 owner 一 agent」(一台機器一條 tunnel):exchange 用它判斷是否已有
|
||||
// agent 可復用。若未來支援「一 owner 多機器多 agent」,此方法需擴充識別鍵(如 machine id)。
|
||||
GetByOwnerTx(ctx context.Context, q db.Querier, ownerUserID string) (*Agent, error)
|
||||
|
||||
// GetOrCreateAgentTx 取得該 owner 的 agent,不存在則建立一筆。
|
||||
//
|
||||
// 在傳入的 Querier(pool 或 tx)上執行,供 exchange 與 device / session token 建立在
|
||||
// 同一交易內(整筆原子)。回傳的 Agent 一定非 nil(復用既有或新建);並更新 last_paired_at。
|
||||
//
|
||||
// name / platform / agentVersion 為 agent 上報值(可空);新建時填入,復用時更新非空值 +
|
||||
// last_paired_at(tx 內只更新這幾欄,避免全欄覆寫造成 lost-update)。
|
||||
GetOrCreateAgentTx(ctx context.Context, q db.Querier, ownerUserID, name, platform, agentVersion string, pairedAt time.Time) (*Agent, error)
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// InMemoryRepository
|
||||
// ==========================================================================
|
||||
|
||||
// InMemoryRepository 是 local-dev fallback / 單元測試用的記憶體實作。
|
||||
type InMemoryRepository struct {
|
||||
mu sync.RWMutex
|
||||
agents map[string]*Agent // keyed by id
|
||||
}
|
||||
|
||||
// NewInMemoryRepository 建立一個空的記憶體 Repository。
|
||||
func NewInMemoryRepository() *InMemoryRepository {
|
||||
return &InMemoryRepository{agents: make(map[string]*Agent)}
|
||||
}
|
||||
|
||||
// GetByOwnerTx 找該 owner 的第一個未刪除 agent(in-memory 忽略 q)。
|
||||
func (r *InMemoryRepository) GetByOwnerTx(_ context.Context, _ db.Querier, ownerUserID string) (*Agent, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
if a := r.findActiveByOwnerLocked(ownerUserID); a != nil {
|
||||
cp := *a
|
||||
return &cp, nil
|
||||
}
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
// GetOrCreateAgentTx 復用或新建該 owner 的 agent(in-memory 忽略 q,無交易需求)。
|
||||
//
|
||||
// 語意對齊 Postgres 版:復用時只更新 last_paired_at + 非空的 name/platform/agentVersion,
|
||||
// 不覆寫其他欄位(避免 lost-update)。
|
||||
func (r *InMemoryRepository) GetOrCreateAgentTx(
|
||||
_ context.Context, _ db.Querier, ownerUserID, name, platform, agentVersion string, pairedAt time.Time,
|
||||
) (*Agent, error) {
|
||||
if ownerUserID == "" {
|
||||
return nil, errors.New("agent: GetOrCreateAgentTx requires ownerUserID")
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
now := time.Now().UTC()
|
||||
paired := pairedAt.UTC()
|
||||
|
||||
if a := r.findActiveByOwnerLocked(ownerUserID); a != nil {
|
||||
// 復用:只更新 last_paired_at + 非空上報欄位(對齊 PG tx 內局部更新)。
|
||||
a.LastPairedAt = &paired
|
||||
a.UpdatedAt = now
|
||||
if name != "" {
|
||||
a.Name = name
|
||||
}
|
||||
if platform != "" {
|
||||
a.Platform = platform
|
||||
}
|
||||
if agentVersion != "" {
|
||||
a.AgentVersion = agentVersion
|
||||
}
|
||||
cp := *a
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
// 新建。
|
||||
if name == "" {
|
||||
name = "local-agent" // 對齊 agents.name DEFAULT
|
||||
}
|
||||
a := &Agent{
|
||||
ID: newAgentID(),
|
||||
OwnerUserID: ownerUserID,
|
||||
Name: name,
|
||||
Platform: platform,
|
||||
AgentVersion: agentVersion,
|
||||
LastPairedAt: &paired,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
r.agents[a.ID] = a
|
||||
cp := *a
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
// findActiveByOwnerLocked 找該 owner 的第一個未刪除 agent(呼叫端須持鎖)。
|
||||
//
|
||||
// map 迭代順序不定,但「一 owner 一 agent」下最多一筆 active,故無歧義。
|
||||
func (r *InMemoryRepository) findActiveByOwnerLocked(ownerUserID string) *Agent {
|
||||
for _, a := range r.agents {
|
||||
if a.DeletedAt == nil && a.OwnerUserID == ownerUserID {
|
||||
return a
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 編譯時檢查:確保 InMemoryRepository 實作 Repository。
|
||||
var _ Repository = (*InMemoryRepository)(nil)
|
||||
98
visionA-backend/internal/agent/inmemory_repository_test.go
Normal file
98
visionA-backend/internal/agent/inmemory_repository_test.go
Normal file
@ -0,0 +1,98 @@
|
||||
// InMemoryRepository(agent)的單元測試(WP-B B2)。
|
||||
//
|
||||
// 無 build tag:預設 `go test ./...` 即涵蓋(不需 Docker)。PG 側對齊測試見
|
||||
// postgres_repository_db_test.go(-tags=dbtest)。
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestInMemory_GetOrCreate_CreatesWhenAbsent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r := NewInMemoryRepository()
|
||||
owner := "owner-1"
|
||||
paired := time.Now().UTC()
|
||||
|
||||
a, err := r.GetOrCreateAgentTx(ctx, nil, owner, "", "", "", paired)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, a)
|
||||
assert.NotEmpty(t, a.ID)
|
||||
assert.Equal(t, owner, a.OwnerUserID)
|
||||
assert.Equal(t, "local-agent", a.Name, "name 空時應用預設 local-agent")
|
||||
require.NotNil(t, a.LastPairedAt)
|
||||
assert.True(t, paired.Equal(*a.LastPairedAt))
|
||||
}
|
||||
|
||||
func TestInMemory_GetOrCreate_ReusesExisting(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r := NewInMemoryRepository()
|
||||
owner := "owner-2"
|
||||
|
||||
first, err := r.GetOrCreateAgentTx(ctx, nil, owner, "local-agent", "darwin", "1.0", time.Now().UTC())
|
||||
require.NoError(t, err)
|
||||
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
later := time.Now().UTC()
|
||||
second, err := r.GetOrCreateAgentTx(ctx, nil, owner, "", "", "", later)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, first.ID, second.ID, "同 owner 應復用同一 agent")
|
||||
assert.Equal(t, "darwin", second.Platform, "空上報值不應覆寫既有 platform")
|
||||
assert.Equal(t, "1.0", second.AgentVersion, "空上報值不應覆寫既有 agent_version")
|
||||
require.NotNil(t, second.LastPairedAt)
|
||||
assert.True(t, later.Equal(*second.LastPairedAt), "last_paired_at 應更新")
|
||||
}
|
||||
|
||||
func TestInMemory_GetOrCreate_UpdatesNonEmptyFieldsOnReuse(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r := NewInMemoryRepository()
|
||||
owner := "owner-3"
|
||||
|
||||
_, err := r.GetOrCreateAgentTx(ctx, nil, owner, "local-agent", "darwin", "1.0", time.Now().UTC())
|
||||
require.NoError(t, err)
|
||||
|
||||
updated, err := r.GetOrCreateAgentTx(ctx, nil, owner, "renamed", "linux", "2.0", time.Now().UTC())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "renamed", updated.Name)
|
||||
assert.Equal(t, "linux", updated.Platform)
|
||||
assert.Equal(t, "2.0", updated.AgentVersion)
|
||||
}
|
||||
|
||||
func TestInMemory_GetOrCreate_DistinctOwners(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r := NewInMemoryRepository()
|
||||
|
||||
a1, err := r.GetOrCreateAgentTx(ctx, nil, "owner-A", "", "", "", time.Now().UTC())
|
||||
require.NoError(t, err)
|
||||
a2, err := r.GetOrCreateAgentTx(ctx, nil, "owner-B", "", "", "", time.Now().UTC())
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, a1.ID, a2.ID, "不同 owner 應各自建 agent")
|
||||
}
|
||||
|
||||
func TestInMemory_GetOrCreate_RequiresOwner(t *testing.T) {
|
||||
r := NewInMemoryRepository()
|
||||
_, err := r.GetOrCreateAgentTx(context.Background(), nil, "", "", "", "", time.Now().UTC())
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestInMemory_GetByOwner(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r := NewInMemoryRepository()
|
||||
owner := "owner-4"
|
||||
|
||||
_, err := r.GetByOwnerTx(ctx, nil, owner)
|
||||
assert.ErrorIs(t, err, ErrNotFound, "無 agent 時應回 ErrNotFound")
|
||||
|
||||
created, err := r.GetOrCreateAgentTx(ctx, nil, owner, "", "", "", time.Now().UTC())
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := r.GetByOwnerTx(ctx, nil, owner)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, created.ID, got.ID)
|
||||
}
|
||||
194
visionA-backend/internal/agent/postgres_repository.go
Normal file
194
visionA-backend/internal/agent/postgres_repository.go
Normal file
@ -0,0 +1,194 @@
|
||||
// Package agent 的 Postgres 持久層實作(migration 0005,WP-B B2)。
|
||||
//
|
||||
// PostgresRepository 實作與 InMemoryRepository 相同的 Repository interface。
|
||||
//
|
||||
// 對齊 migrations/0005_create_agents.up.sql 的 agents 表;語意對齊 in-memory(agent.go):
|
||||
// - GetByOwnerTx / GetOrCreateAgentTx 略過 deleted_at IS NOT NULL 的紀錄。
|
||||
// - GetOrCreateAgentTx 復用路徑「tx 內局部 UPDATE」(只改 last_paired_at + 非空上報欄),
|
||||
// 不全欄覆寫,避免 lost-update(WP-0 Mi#2 同精神)。
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"visiona-backend/internal/db"
|
||||
)
|
||||
|
||||
// PostgresRepository 是 Agent 的 PostgreSQL 持久層實作。
|
||||
type PostgresRepository struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewPostgresRepository 建立一個以 pgxpool 為後端的 Repository。
|
||||
func NewPostgresRepository(pool *pgxpool.Pool) *PostgresRepository {
|
||||
return &PostgresRepository{pool: pool}
|
||||
}
|
||||
|
||||
// 編譯時檢查:確保 PostgresRepository 實作 Repository。
|
||||
var _ Repository = (*PostgresRepository)(nil)
|
||||
|
||||
// agentColumns 是 SELECT 共用欄位清單(順序須與 scanAgent 對齊)。
|
||||
const agentColumns = `id, owner_user_id, name, platform, agent_version,
|
||||
last_paired_at, created_at, updated_at, deleted_at`
|
||||
|
||||
// GetByOwnerTx 取得該 owner 的第一個未刪除 agent;不存在回 ErrNotFound。
|
||||
//
|
||||
// 一 owner 一 agent 語意下最多一筆 active;仍加 ORDER BY created_at + LIMIT 1 確保
|
||||
// 多筆殘留時取最早那筆為決定性結果(避免非決定性 row)。
|
||||
func (r *PostgresRepository) GetByOwnerTx(ctx context.Context, q db.Querier, ownerUserID string) (*Agent, error) {
|
||||
const sql = `SELECT ` + agentColumns + `
|
||||
FROM agents
|
||||
WHERE owner_user_id = $1 AND deleted_at IS NULL
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1`
|
||||
|
||||
row := q.QueryRow(ctx, sql, ownerUserID)
|
||||
a, err := scanAgent(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("agent: pg GetByOwner: %w", err)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// GetOrCreateAgentTx 復用或新建該 owner 的 agent(在傳入 Querier / tx 上執行)。
|
||||
//
|
||||
// 復用路徑:局部 UPDATE last_paired_at + 非空上報欄(COALESCE 保留既有值),不全欄覆寫
|
||||
// ——避免 lost-update。新建路徑:INSERT,id 由 DB gen_random_uuid() 產、created_at/updated_at
|
||||
// 用 DEFAULT。
|
||||
//
|
||||
// 併發正確性(無 owner unique 約束下防 insert race):
|
||||
//
|
||||
// agents 表沒有 owner_user_id 的 unique 約束(migration 0005 只有 id PK),故「空表併發
|
||||
// get-or-create」若只靠 SELECT FOR UPDATE 會失效——FOR UPDATE 只能鎖「已存在的列」,空表
|
||||
// 時多個並發 tx 都讀到 no rows、全走 INSERT,建出多筆 agent。
|
||||
// 因此改用 transaction-scoped advisory lock(pg_advisory_xact_lock)以 (ownerUserID) 為鍵
|
||||
// 序列化同 owner 的 get-or-create:同一時間只有一個 tx 能進入「查→建」臨界區,交易結束自動
|
||||
// 釋放。此法不需動 schema(守 WP-B「不碰 migration」邊界)。
|
||||
// ⚠️ advisory_xact_lock 需在交易內才會自動釋放——exchange 一律以 db.WithTx 包住
|
||||
// (見 pairing_exchange.go)。若 q 為 pool(非 tx),lock 在該單一語句結束即釋放、
|
||||
// 無法涵蓋整個「查→建」,故呼叫端務必在 tx 內使用(測試亦如是)。
|
||||
func (r *PostgresRepository) GetOrCreateAgentTx(
|
||||
ctx context.Context, q db.Querier, ownerUserID, name, platform, agentVersion string, pairedAt time.Time,
|
||||
) (*Agent, error) {
|
||||
if ownerUserID == "" {
|
||||
return nil, errors.New("agent: GetOrCreateAgentTx requires ownerUserID")
|
||||
}
|
||||
|
||||
// 0) advisory lock 序列化同 owner 的 get-or-create(tx-scoped,交易結束自動釋放)。
|
||||
// 以 owner UUID 文字的 hashtext 當鎖鍵(碰撞僅造成不同 owner 偶爾序列化,不影響正確性)。
|
||||
if _, lErr := q.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtext($1))`, ownerUserID); lErr != nil {
|
||||
return nil, fmt.Errorf("agent: pg GetOrCreate advisory lock: %w", lErr)
|
||||
}
|
||||
|
||||
// 1) 嘗試取既有 active agent(advisory lock 已序列化,此處 FOR UPDATE 進一步鎖既有列)。
|
||||
const selForUpdate = `SELECT ` + agentColumns + `
|
||||
FROM agents
|
||||
WHERE owner_user_id = $1 AND deleted_at IS NULL
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1
|
||||
FOR UPDATE`
|
||||
|
||||
existing, err := scanAgent(q.QueryRow(ctx, selForUpdate, ownerUserID))
|
||||
switch {
|
||||
case err == nil:
|
||||
// 2a) 復用:tx 內局部更新(只改 last_paired_at + 非空上報欄;空值保留既有)。
|
||||
const upd = `UPDATE agents SET
|
||||
last_paired_at = $2,
|
||||
name = COALESCE(NULLIF($3, ''), name),
|
||||
platform = COALESCE(NULLIF($4, ''), platform),
|
||||
agent_version = COALESCE(NULLIF($5, ''), agent_version),
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING ` + agentColumns
|
||||
|
||||
updated, uErr := scanAgent(q.QueryRow(ctx, upd,
|
||||
existing.ID, pairedAt.UTC(), name, platform, agentVersion))
|
||||
if uErr != nil {
|
||||
return nil, fmt.Errorf("agent: pg GetOrCreate update: %w", uErr)
|
||||
}
|
||||
return updated, nil
|
||||
|
||||
case errors.Is(err, pgx.ErrNoRows):
|
||||
// 2b) 新建:id / created_at / updated_at 由 DB 產;name 空時走 agents.name DEFAULT。
|
||||
const ins = `INSERT INTO agents
|
||||
(owner_user_id, name, platform, agent_version, last_paired_at)
|
||||
VALUES ($1, COALESCE(NULLIF($2, ''), 'local-agent'), NULLIF($3, ''), NULLIF($4, ''), $5)
|
||||
RETURNING ` + agentColumns
|
||||
|
||||
created, iErr := scanAgent(q.QueryRow(ctx, ins,
|
||||
ownerUserID, name, platform, agentVersion, pairedAt.UTC()))
|
||||
if iErr != nil {
|
||||
return nil, fmt.Errorf("agent: pg GetOrCreate insert: %w", iErr)
|
||||
}
|
||||
return created, nil
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("agent: pg GetOrCreate select: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// scan helper
|
||||
// ==========================================================================
|
||||
|
||||
type rowScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
// scanAgent 從一列掃出 *Agent。欄位順序須與 agentColumns 對齊。
|
||||
//
|
||||
// nullable TEXT(platform / agent_version)NULL → 空字串(對齊 in-memory zero value);
|
||||
// nullable TIMESTAMPTZ(last_paired_at / deleted_at)以 *time.Time 接,NULL → nil。
|
||||
func scanAgent(row rowScanner) (*Agent, error) {
|
||||
var (
|
||||
a Agent
|
||||
platform *string
|
||||
agentVersion *string
|
||||
)
|
||||
err := row.Scan(
|
||||
&a.ID,
|
||||
&a.OwnerUserID,
|
||||
&a.Name,
|
||||
&platform,
|
||||
&agentVersion,
|
||||
&a.LastPairedAt,
|
||||
&a.CreatedAt,
|
||||
&a.UpdatedAt,
|
||||
&a.DeletedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
a.Platform = derefString(platform)
|
||||
a.AgentVersion = derefString(agentVersion)
|
||||
|
||||
a.CreatedAt = a.CreatedAt.UTC()
|
||||
a.UpdatedAt = a.UpdatedAt.UTC()
|
||||
if a.LastPairedAt != nil {
|
||||
t := a.LastPairedAt.UTC()
|
||||
a.LastPairedAt = &t
|
||||
}
|
||||
if a.DeletedAt != nil {
|
||||
t := a.DeletedAt.UTC()
|
||||
a.DeletedAt = &t
|
||||
}
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
// derefString 解指標字串,nil 視為空字串(對齊 in-memory zero value)。
|
||||
func derefString(s *string) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return *s
|
||||
}
|
||||
179
visionA-backend/internal/agent/postgres_repository_db_test.go
Normal file
179
visionA-backend/internal/agent/postgres_repository_db_test.go
Normal file
@ -0,0 +1,179 @@
|
||||
//go:build dbtest
|
||||
|
||||
// PostgresRepository(agent)的真 DB 整合測試(WP-B B2)。
|
||||
//
|
||||
// build tag `dbtest`:只在帶 `-tags=dbtest` 時編譯/執行(需 Docker / testcontainers)。
|
||||
//
|
||||
// 執行:
|
||||
//
|
||||
// go test -tags=dbtest ./internal/agent/...
|
||||
// # 無本機 Docker 時在 130 補跑:
|
||||
// DOCKER_HOST=tcp://192.168.0.130:2375 TESTCONTAINERS_RYUK_DISABLED=true \
|
||||
// go test -tags=dbtest ./internal/agent/...
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"visiona-backend/internal/db"
|
||||
"visiona-backend/internal/db/testsupport"
|
||||
)
|
||||
|
||||
// newPGRepo 啟動測試 DB、truncate、確保 demo user,回傳 repo + owner。
|
||||
func newPGRepo(t *testing.T) (*PostgresRepository, *testsupport.TestDB, string) {
|
||||
t.Helper()
|
||||
tdb := testsupport.SetupTestDB(t)
|
||||
tdb.Truncate(t, "devices", "agents", "users")
|
||||
owner := tdb.EnsureDemoUser(t)
|
||||
return NewPostgresRepository(tdb.Pool), tdb, owner
|
||||
}
|
||||
|
||||
// GetOrCreate 新建:DB 中先無 agent → 建一筆、欄位正確。
|
||||
func TestPG_GetOrCreate_CreatesWhenAbsent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r, tdb, owner := newPGRepo(t)
|
||||
paired := time.Now().Add(-1 * time.Minute).UTC().Truncate(time.Microsecond)
|
||||
|
||||
a, err := r.GetOrCreateAgentTx(ctx, tdb.Pool, owner, "", "darwin", "1.2.3", paired)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, a)
|
||||
assert.NotEmpty(t, a.ID)
|
||||
assert.Equal(t, owner, a.OwnerUserID)
|
||||
assert.Equal(t, "local-agent", a.Name, "空 name 走 DEFAULT")
|
||||
assert.Equal(t, "darwin", a.Platform)
|
||||
assert.Equal(t, "1.2.3", a.AgentVersion)
|
||||
require.NotNil(t, a.LastPairedAt)
|
||||
assert.True(t, paired.Equal(*a.LastPairedAt))
|
||||
assert.Equal(t, 1, tdb.CountRows(t, "agents"))
|
||||
}
|
||||
|
||||
// GetOrCreate 復用:同 owner 第二次呼叫復用同 agent、不新建。
|
||||
func TestPG_GetOrCreate_ReusesExisting(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r, tdb, owner := newPGRepo(t)
|
||||
|
||||
first, err := r.GetOrCreateAgentTx(ctx, tdb.Pool, owner, "local-agent", "darwin", "1.0", time.Now().UTC())
|
||||
require.NoError(t, err)
|
||||
|
||||
later := time.Now().Add(1 * time.Minute).UTC().Truncate(time.Microsecond)
|
||||
second, err := r.GetOrCreateAgentTx(ctx, tdb.Pool, owner, "", "", "", later)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, first.ID, second.ID, "同 owner 應復用")
|
||||
assert.Equal(t, 1, tdb.CountRows(t, "agents"), "復用不應多建 agent")
|
||||
// 空上報值不覆寫既有欄位(COALESCE NULLIF 保留)。
|
||||
assert.Equal(t, "darwin", second.Platform)
|
||||
assert.Equal(t, "1.0", second.AgentVersion)
|
||||
require.NotNil(t, second.LastPairedAt)
|
||||
assert.True(t, later.Equal(*second.LastPairedAt), "last_paired_at 應更新")
|
||||
}
|
||||
|
||||
// GetOrCreate 復用時非空上報值更新既有欄位。
|
||||
func TestPG_GetOrCreate_UpdatesNonEmptyOnReuse(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r, tdb, owner := newPGRepo(t)
|
||||
|
||||
_, err := r.GetOrCreateAgentTx(ctx, tdb.Pool, owner, "local-agent", "darwin", "1.0", time.Now().UTC())
|
||||
require.NoError(t, err)
|
||||
|
||||
upd, err := r.GetOrCreateAgentTx(ctx, tdb.Pool, owner, "renamed", "linux", "2.0", time.Now().UTC())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "renamed", upd.Name)
|
||||
assert.Equal(t, "linux", upd.Platform)
|
||||
assert.Equal(t, "2.0", upd.AgentVersion)
|
||||
}
|
||||
|
||||
// GetOrCreate 在 WithTx 內:與其他寫入同一交易,成功 commit。
|
||||
func TestPG_GetOrCreate_WithinTx(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r, tdb, owner := newPGRepo(t)
|
||||
|
||||
var agentID string
|
||||
err := db.WithTx(ctx, tdb.Pool, func(q db.Querier) error {
|
||||
a, e := r.GetOrCreateAgentTx(ctx, q, owner, "", "", "", time.Now().UTC())
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
agentID = a.ID
|
||||
return nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, agentID)
|
||||
assert.Equal(t, 1, tdb.CountRows(t, "agents"))
|
||||
|
||||
// commit 後 GetByOwner 讀得到。
|
||||
got, err := r.GetByOwnerTx(ctx, tdb.Pool, owner)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, agentID, got.ID)
|
||||
}
|
||||
|
||||
// GetOrCreate 在 WithTx rollback:交易失敗時 agent 不殘留。
|
||||
func TestPG_GetOrCreate_RollbackOnTxError(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r, tdb, owner := newPGRepo(t)
|
||||
|
||||
sentinel := assertErr("forced rollback")
|
||||
err := db.WithTx(ctx, tdb.Pool, func(q db.Querier) error {
|
||||
if _, e := r.GetOrCreateAgentTx(ctx, q, owner, "", "", "", time.Now().UTC()); e != nil {
|
||||
return e
|
||||
}
|
||||
return sentinel // 強制 rollback
|
||||
})
|
||||
require.ErrorIs(t, err, sentinel)
|
||||
assert.Equal(t, 0, tdb.CountRows(t, "agents"), "rollback 後 agent 不應殘留")
|
||||
}
|
||||
|
||||
// GetByOwner 不存在回 ErrNotFound。
|
||||
func TestPG_GetByOwner_NotFound(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r, tdb, owner := newPGRepo(t)
|
||||
_, err := r.GetByOwnerTx(ctx, tdb.Pool, owner)
|
||||
assert.ErrorIs(t, err, ErrNotFound)
|
||||
}
|
||||
|
||||
// 併發 GetOrCreate 同 owner(各自 WithTx):FOR UPDATE 序列化,最終恰一筆 agent。
|
||||
func TestPG_GetOrCreate_ConcurrentSameOwner(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r, tdb, owner := newPGRepo(t)
|
||||
|
||||
const n = 10
|
||||
var wg sync.WaitGroup
|
||||
ids := make([]string, n)
|
||||
errs := make([]error, n)
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
errs[i] = db.WithTx(ctx, tdb.Pool, func(q db.Querier) error {
|
||||
a, e := r.GetOrCreateAgentTx(ctx, q, owner, "", "", "", time.Now().UTC())
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
ids[i] = a.ID
|
||||
return nil
|
||||
})
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for i, e := range errs {
|
||||
require.NoError(t, e, "併發 GetOrCreate #%d 不應報錯", i)
|
||||
}
|
||||
// 所有 goroutine 應拿到同一個 agent id。
|
||||
first := ids[0]
|
||||
for i, id := range ids {
|
||||
assert.Equal(t, first, id, "併發應復用同一 agent(#%d)", i)
|
||||
}
|
||||
assert.Equal(t, 1, tdb.CountRows(t, "agents"), "併發同 owner 最終恰一筆 agent")
|
||||
}
|
||||
|
||||
// assertErr 是測試用 sentinel error。
|
||||
type assertErr string
|
||||
|
||||
func (e assertErr) Error() string { return string(e) }
|
||||
@ -51,6 +51,12 @@ type DeviceListItem struct {
|
||||
DeviceType string `json:"device_type"`
|
||||
SerialNumber string `json:"serial_number,omitempty"`
|
||||
|
||||
// A' 模型(WP-B B4):供前端三色(連線軸 × 註冊軸)與分組用。
|
||||
// - AgentID:所屬 agent(同一 agent 下的 USB 共用一條 tunnel)。
|
||||
// - RegisteredAt:註冊軸(nil=未註冊)。前端用「未註冊 + 在線 = 黃」算第三態(WP-F)。
|
||||
AgentID string `json:"agent_id,omitempty"`
|
||||
RegisteredAt *time.Time `json:"registered_at,omitempty"`
|
||||
|
||||
// 狀態
|
||||
RemoteStatus string `json:"remote_status"`
|
||||
LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
|
||||
@ -107,6 +113,8 @@ func devicesListHandler(deps Deps) gin.HandlerFunc {
|
||||
Name: d.Name,
|
||||
DeviceType: d.DeviceType,
|
||||
SerialNumber: d.SerialNumber,
|
||||
AgentID: d.AgentID,
|
||||
RegisteredAt: d.RegisteredAt,
|
||||
RemoteStatus: d.RemoteStatus,
|
||||
LastSeenAt: d.LastSeenAt,
|
||||
LastConnectedAt: d.LastConnectedAt,
|
||||
@ -179,6 +187,8 @@ func devicesGetHandler(deps Deps) gin.HandlerFunc {
|
||||
Name: d.Name,
|
||||
DeviceType: d.DeviceType,
|
||||
SerialNumber: d.SerialNumber,
|
||||
AgentID: d.AgentID,
|
||||
RegisteredAt: d.RegisteredAt,
|
||||
RemoteStatus: d.RemoteStatus,
|
||||
LastSeenAt: d.LastSeenAt,
|
||||
LastConnectedAt: d.LastConnectedAt,
|
||||
|
||||
@ -1,31 +1,32 @@
|
||||
// pairing_exchange.go — pairing exchange 自建 device 的協調者(DB-on FK 收尾,問題 #2)。
|
||||
// pairing_exchange.go — pairing exchange 建 agent + representative device + N 顆真 USB device
|
||||
// 的協調者(A' 模型;ADR-018 / migration 0005,WP-B B3)。
|
||||
//
|
||||
// 背景:
|
||||
// 背景(DB-on FK 收尾 #2 → A' 重塑):
|
||||
//
|
||||
// session_tokens.device_id 是 NOT NULL FK → devices(id),但雛形 pairing 流程從頭到尾沒有任何
|
||||
// production 路徑會建 device(grep 確認 device.Save 只在 seed / test 被呼叫)。exchange 時
|
||||
// info.DeviceID 必為空 → DB-on 下 session token INSERT 因 device_id 空字串 cast UUID 失敗。
|
||||
// in-memory 模式因為不檢查 FK 而藏住此問題。
|
||||
//
|
||||
// 修法(使用者拍板:exchange 時雲端自建 device,不動 local-tool):
|
||||
//
|
||||
// exchange 驗完 pairing token 後、建 session token 之前,雲端自建一筆 device 代表「這台配對
|
||||
// 進來的 local agent」(owner = pairing token 綁的 user,這個 user 已透過 OIDC callback
|
||||
// provision 進 users 表 —— 見問題 #1)。然後用這個 device_id 建 session token。
|
||||
// session_tokens.device_id 是 NOT NULL FK → devices(id)。exchange 驗完 pairing token 後需建
|
||||
// 一筆 device 供 session token 綁定。原雛形是「每次 exchange 自建一筆佔位 device」;A' 模型
|
||||
// (ADR-018 走向 A')把這件事重塑為:
|
||||
// 1. 建/復用該 owner 的 agent(一 agent = 一條 tunnel 連線;agents 表,migration 0005)。
|
||||
// 2. 建/復用該 agent 的 representative device(is_representative=true、serial=NULL),
|
||||
// session_tokens.device_id 綁它(維持現行「一條 tunnel 綁一個 device_id」的物理語意,
|
||||
// 但改綁「agent 代表 device」而非隨機佔位——session_tokens schema 一字不動)。
|
||||
// 3. 對 agent 上報清單的每顆可用序號 USB,建/復用一筆真 USB device(is_representative=false、
|
||||
// agent_id=同一 agent、填 serial)——R1 完整 N 顆(非只第一顆)。
|
||||
//
|
||||
// 為什麼抽成 coordinator(比照 unpair.go 的 DeviceUnpairer):
|
||||
// - 讓 handler(pairing.go 的 exchange)維持薄。
|
||||
// - Postgres 後端用 db.WithTx 把「建 device + 建 session token」包成單一交易——任一步失敗
|
||||
// 整筆 rollback,杜絕「device 建了但 session token 沒建成」的中間態(database.md §6 一致性精神)。
|
||||
// - Postgres 後端用 db.WithTx 把「建 agent + representative + N USB + session token」包成
|
||||
// 單一交易——任一步失敗整筆 rollback,杜絕中間態(database.md §6 一致性精神)。
|
||||
// - in-memory 後端依序執行(無交易),行為一致。
|
||||
// - main.go 依 dbPool 是否非 nil 擇一注入 Deps.PairingExchanger。為 nil 時 exchange handler
|
||||
// fallback 到「不自建 device、直接用 info.DeviceID(可能為空)建 session token」的舊行為
|
||||
// (與 DB-off 雛形相容;in-memory store 不檢查 FK,空 deviceID 可接受)。
|
||||
//
|
||||
// 冪等:pairing token 是一次性(MarkUsed 後 Validate 回 ErrTokenUsed),故同一 token 不會被
|
||||
// exchange 兩次成功。每次成功 exchange 自建一筆新 device(新 UUID)是正確語意——不同次配對
|
||||
// 視為不同 agent 連線。重試(exchange 後 MarkUsed 失敗被 abort)時 session token 已 revoke、
|
||||
// device 已建但無 token 指向它(孤兒 device,無安全風險,僅一筆閒置紀錄;雛形可接受)。
|
||||
// exchange 兩次成功。同 owner 多次配對復用同一 agent + 同一 representative device(A' 語意
|
||||
// 「一 agent 一 representative」),各建一個新 session token 綁該 representative。真 USB device
|
||||
// 依 serial 去重復用(GetBySerial)。重試(exchange 後 MarkUsed 失敗被 abort)時 session token
|
||||
// 已 revoke、agent/device 已建但無新 token 指向它(無安全風險,僅閒置紀錄;雛形可接受)。
|
||||
package api
|
||||
|
||||
import (
|
||||
@ -33,39 +34,50 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"visiona-backend/internal/agent"
|
||||
"visiona-backend/internal/auth"
|
||||
"visiona-backend/internal/db"
|
||||
"visiona-backend/internal/device"
|
||||
)
|
||||
|
||||
// defaultPairedDeviceName / defaultPairedDeviceType 是 exchange 自建 device 的預設值。
|
||||
// representativeDeviceName / representativeDeviceType 是 agent 的 representative device 預設值。
|
||||
//
|
||||
// 雛形:agent 端 exchange request 只傳 pairing_token、不帶裝置資訊(不動 local-tool),
|
||||
// 故 Name / DeviceType 在雲端用預設值。Phase 1 若 agent 帶上 serial / device_type 可改填真值。
|
||||
// representative device 代表「這條 tunnel 連線」本身(非任何一顆實體 USB),綁 session_tokens。
|
||||
// 真 USB device 的 Name 由 DeviceType/serial 衍生(見 deriveDeviceName,S-2),不再共用此值。
|
||||
const (
|
||||
defaultPairedDeviceName = "local-tool (paired)"
|
||||
defaultPairedDeviceType = "local-agent"
|
||||
representativeDeviceName = "local-agent (paired)"
|
||||
representativeDeviceType = "local-agent"
|
||||
)
|
||||
|
||||
// ExchangeProvisionResult 回報 exchange 自建 device + 建 session token 的結果。
|
||||
// ExchangeProvisionResult 回報 exchange 建 agent + representative + N USB + session token 的結果。
|
||||
type ExchangeProvisionResult struct {
|
||||
DeviceID string // 本次自建(或依 serial 復用)的 device id
|
||||
// DeviceID 是 session token 綁定的 device id——A' 下為該 agent 的 representative device
|
||||
// (is_representative=true)。維持 session_tokens.device_id 綁一個 device 的物理語意。
|
||||
DeviceID string
|
||||
AgentID string // 本次建/復用的 agent id
|
||||
USBDeviceIDs []string // 本次建/復用的真 USB device ids(is_representative=false,可為空)
|
||||
SessionPlaintext string // 新 session token 原文(caller 只此一次能拿到)
|
||||
SessionInfo *auth.SessionToken // session token 儲存層表示(含 ExpiresAt)
|
||||
}
|
||||
|
||||
// ExchangeDeviceInput 是 exchange payload 中 agent 上報的單顆實體 USB 裝置
|
||||
// (WP-0 / ADR-018 序號地基,對齊 agent 端 tunnel.exchangeDevice 的 JSON)。
|
||||
//
|
||||
// Firmware(S-1 forward-compat):agent 上報韌體版本字串,目前 devices 表無 firmware 欄
|
||||
// (migration 0005 未建),故 WP-B 尚未消費——收下但不落 DB。未來若要顯示/記錄韌體版本,
|
||||
// 需另開 migration 加 devices.firmware 欄 + 在此 struct → Device 的映射補上(超出 WP-B 範圍)。
|
||||
// 保留此欄位讓 agent 端 payload 契約穩定、未來加欄時 agent 不需改。
|
||||
type ExchangeDeviceInput struct {
|
||||
SerialNumber string `json:"serial_number"`
|
||||
DeviceType string `json:"device_type,omitempty"`
|
||||
Firmware string `json:"firmware,omitempty"`
|
||||
Firmware string `json:"firmware,omitempty"` // forward-compat:WP-B 尚未消費(見上方說明)
|
||||
}
|
||||
|
||||
// fakeSerialNumber 是 agent 端 pyusb fallback(無 Kneron SDK,如 macOS 缺 dylib)
|
||||
@ -73,52 +85,103 @@ type ExchangeDeviceInput struct {
|
||||
// 視同「無序號」寫 NULL(ADR-018 §2.2 / task-1 mapping R2)。
|
||||
const fakeSerialNumber = "0x00000000"
|
||||
|
||||
// firstUsableSerialDevice 從 agent 上報清單挑第一顆「序號可用」的裝置。
|
||||
// serialPattern 是 Kneron kn_number 的合法格式白名單(Mi#5):0x 前綴 + 8 位十六進位。
|
||||
//
|
||||
// WP-0 最小落地(task-1 mapping R1):exchange 仍只落一筆 device,多顆 USB 的
|
||||
// 完整模型(一 agent N device)是 WP-B / migration 0005 的範疇。這裡取第一顆
|
||||
// 有效序號填入;空序號與假序號(0x00000000)跳過。
|
||||
func firstUsableSerialDevice(devices []ExchangeDeviceInput) (ExchangeDeviceInput, bool) {
|
||||
for _, d := range devices {
|
||||
serial := strings.TrimSpace(d.SerialNumber)
|
||||
if serial == "" || strings.EqualFold(serial, fakeSerialNumber) {
|
||||
continue
|
||||
// 為什麼加白名單:serial 是跨層路由鍵(雲端 device ↔ local agent sessions),亂格式序號
|
||||
// 會污染路由與去重。不符此格式者視同「無序號」(寫 NULL、不進去重、不路由),與假序號同處理。
|
||||
// 攻擊面受限(需持有效一次性 pairing token 才能觸發 exchange),reviewer 已判不升級 Security。
|
||||
var serialPattern = regexp.MustCompile(`^0x[0-9A-Fa-f]{8}$`)
|
||||
|
||||
// normalizeSerial 對上報序號做 trim + 白名單驗證。回傳 (正規化序號, 是否可用)。
|
||||
//
|
||||
// 不可用(空 / 假序號 / 不符白名單)→ 回 ("", false),呼叫端視同無序號。
|
||||
func normalizeSerial(raw string) (string, bool) {
|
||||
s := strings.TrimSpace(raw)
|
||||
if s == "" || strings.EqualFold(s, fakeSerialNumber) {
|
||||
return "", false
|
||||
}
|
||||
d.SerialNumber = serial
|
||||
return d, true
|
||||
if !serialPattern.MatchString(s) {
|
||||
return "", false
|
||||
}
|
||||
return ExchangeDeviceInput{}, false
|
||||
return s, true
|
||||
}
|
||||
|
||||
// PairingExchanger 把「自建 device + 建 session token」包成一個原子(Postgres tx)或
|
||||
// 一致(in-memory 依序)操作。
|
||||
// usableSerialDevices 從 agent 上報清單挑出所有「序號可用」的裝置(R1:完整 N 顆)。
|
||||
//
|
||||
// A' 模型(WP-B B3):一 agent 底下 N 顆實體 USB 各建一筆真 device。此函式過濾出可用序號者、
|
||||
// 正規化序號、並對同一序號去重(同一次 exchange 上報重複序號只取第一顆,避免同 tx 內撞
|
||||
// partial unique)。空序號 / 假序號 / 不符白名單者跳過。
|
||||
func usableSerialDevices(devices []ExchangeDeviceInput) []ExchangeDeviceInput {
|
||||
out := make([]ExchangeDeviceInput, 0, len(devices))
|
||||
seen := make(map[string]struct{}, len(devices))
|
||||
for _, d := range devices {
|
||||
serial, ok := normalizeSerial(d.SerialNumber)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(serial) // 去重用大小寫不敏感(對齊 GetBySerial 的實體語意)
|
||||
if _, dup := seen[key]; dup {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
d.SerialNumber = serial
|
||||
out = append(out, d)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// deriveDeviceName 由 agent 上報的 device type + serial 衍生真 USB device 的顯示名稱(S-2)。
|
||||
//
|
||||
// 取代舊的固定 "local-tool (paired)":真 USB device 應能從名稱看出是哪顆晶片/序號,
|
||||
// 如 "kneron_kl520 (0x1A2B3C4D)"。deviceType 空時退回 "USB device"。
|
||||
func deriveDeviceName(deviceType, serial string) string {
|
||||
dt := strings.TrimSpace(deviceType)
|
||||
if dt == "" {
|
||||
dt = "USB device"
|
||||
}
|
||||
if serial != "" {
|
||||
return fmt.Sprintf("%s (%s)", dt, serial)
|
||||
}
|
||||
return dt
|
||||
}
|
||||
|
||||
// PairingExchanger 把「建 agent + representative device + N 顆真 USB device + 建 session token」
|
||||
// 包成一個原子(Postgres tx)或一致(in-memory 依序)操作(A' 模型,WP-B B3)。
|
||||
//
|
||||
// Provision 語意:成功回 ExchangeProvisionResult;任一步失敗回 error(handler 經 errors.go
|
||||
// 映射成 5xx,不洩漏 raw error)。
|
||||
type PairingExchanger interface {
|
||||
// Provision 自建一筆 device(owner = userID)並建一筆綁該 device 的 session token。
|
||||
// Provision 建/復用該 owner 的 agent + 該 agent 的 representative device,並建一筆綁
|
||||
// representative device 的 session token;再對 agent 上報清單的每顆可用序號 USB 建/復用
|
||||
// 一筆真 USB device(掛同一 agent)。
|
||||
//
|
||||
// parentTokenHash 為來源 pairing token 的 hash(稽核鏈,寫進 session_tokens.parent_token_hash)。
|
||||
//
|
||||
// devices 為 agent 上報的實體 USB 清單(WP-0 序號地基,可為 nil = 舊 agent /
|
||||
// 撈不到清單,行為與現行完全一致)。序號可用時:
|
||||
// - 同 owner 已有同 serial 的未刪除 device → 復用既有 device_id(防
|
||||
// uq_devices_owner_serial_active 23505 炸裂;「同序號重配 = 復用」,R4)。
|
||||
// - 否則新建 device 並填 serial_number。
|
||||
// 撈不到清單,此時只建 agent + representative + session token,不建真 USB device)。
|
||||
// 可用序號者(通過白名單):同 owner 已有同 serial 的未刪除 device → 復用(防
|
||||
// uq_devices_owner_serial_active 23505;「同序號重配 = 復用」,R4);否則新建並填 serial。
|
||||
Provision(ctx context.Context, userID, parentTokenHash string, ttl time.Duration, devices []ExchangeDeviceInput) (ExchangeProvisionResult, error)
|
||||
}
|
||||
|
||||
// ── Postgres 後端 ─────────────────────────────────────────────────────────────
|
||||
|
||||
// pgDeviceSaver 是 device 在 tx 內 upsert + 依 serial 查詢的能力
|
||||
// (由 device.PostgresRepository 滿足)。
|
||||
// pgDeviceSaver 是 device 在 tx 內 upsert / 查詢的能力(由 device.PostgresRepository 滿足)。
|
||||
//
|
||||
// GetBySerial 用於 WP-0 序號防炸:serial 有值時 exchange 先查同 owner 是否已有
|
||||
// 同 serial 的未刪除 device,有則復用、不再自建(避免撞 partial unique
|
||||
// uq_devices_owner_serial_active → 23505 → exchange 500)。
|
||||
// 全部走 tx 版(Querier),讓「查既有 → 復用/建」與 session token 在同一交易內序列化
|
||||
// (Mi#2 lost-update 收斂):
|
||||
// - GetBySerialTx:查同 owner 同 serial 的未刪除真 USB device(去重復用,R4)。
|
||||
// - GetRepresentativeByAgentTx:查該 agent 既有 representative device(一 agent 一 representative)。
|
||||
// - SaveTx:upsert device(representative / 真 USB 共用)。
|
||||
type pgDeviceSaver interface {
|
||||
SaveTx(ctx context.Context, q db.Querier, d *device.Device) error
|
||||
GetBySerial(ctx context.Context, ownerUserID, serial string) (*device.Device, error)
|
||||
GetBySerialTx(ctx context.Context, q db.Querier, ownerUserID, serial string) (*device.Device, error)
|
||||
GetRepresentativeByAgentTx(ctx context.Context, q db.Querier, agentID string) (*device.Device, error)
|
||||
}
|
||||
|
||||
// pgAgentProvisioner 是「在 tx 內建/復用 agent」的能力(由 agent.PostgresRepository 滿足)。
|
||||
type pgAgentProvisioner interface {
|
||||
GetOrCreateAgentTx(ctx context.Context, q db.Querier, ownerUserID, name, platform, agentVersion string, pairedAt time.Time) (*agent.Agent, error)
|
||||
}
|
||||
|
||||
// pgSessionTokenCreator 是「在 tx 內建 session token」的能力(由 auth.PostgresSessionTokenStore 滿足)。
|
||||
@ -126,9 +189,10 @@ type pgSessionTokenCreator interface {
|
||||
CreateTx(ctx context.Context, q db.Querier, userID, deviceID, parentTokenHash string, ttl time.Duration) (string, *auth.SessionToken, error)
|
||||
}
|
||||
|
||||
// pgPairingExchanger 用單一 pgx 交易完成「自建 device + 建 session token」。
|
||||
// pgPairingExchanger 用單一 pgx 交易完成 A' 的 exchange 重塑。
|
||||
type pgPairingExchanger struct {
|
||||
pool *pgxpool.Pool
|
||||
agents pgAgentProvisioner
|
||||
devices pgDeviceSaver
|
||||
sessionToken pgSessionTokenCreator
|
||||
log *slog.Logger
|
||||
@ -137,80 +201,72 @@ type pgPairingExchanger struct {
|
||||
// NewPostgresPairingExchanger 建立 Postgres 後端的 exchange 協調者。
|
||||
func NewPostgresPairingExchanger(
|
||||
pool *pgxpool.Pool,
|
||||
agents pgAgentProvisioner,
|
||||
devices pgDeviceSaver,
|
||||
sessionToken pgSessionTokenCreator,
|
||||
log *slog.Logger,
|
||||
) PairingExchanger {
|
||||
return &pgPairingExchanger{
|
||||
pool: pool,
|
||||
agents: agents,
|
||||
devices: devices,
|
||||
sessionToken: sessionToken,
|
||||
log: logOrDefault(log),
|
||||
}
|
||||
}
|
||||
|
||||
// Provision 在單一交易內:自建(或依 serial 復用)device → 建綁該 device 的 session token。
|
||||
// Provision 在單一交易內完成 A' exchange 重塑:
|
||||
//
|
||||
// 任一步失敗整筆 rollback(device 不會「已建但沒 token」殘留在 DB)。
|
||||
// 1. 建/復用該 owner 的 agent(GetOrCreateAgentTx,advisory lock 序列化同 owner)。
|
||||
// 2. 建/復用該 agent 的 representative device(is_representative=true、serial=NULL)。
|
||||
// 3. 建綁 representative device 的 session token(session_tokens.device_id = representative.id)。
|
||||
// 4. 對每顆可用序號 USB,建/復用一筆真 USB device(is_representative=false、掛同一 agent)。
|
||||
//
|
||||
// WP-0 序號地基(ADR-018):agent 上報清單有可用序號時,先 GetBySerial 查同
|
||||
// owner 是否已有同 serial 的未刪除 device——有則復用既有 device_id(同序號重配
|
||||
// = 復用,R4),沒有才新建並填 serial_number。已知限制:GetBySerial 走 pool
|
||||
// (非 tx 內),與並發 exchange 之間有極小 race window;撞到時 SaveTx 會被
|
||||
// partial unique index 擋下(整筆 rollback、不產生重複 serial),同一顆 agent
|
||||
// 的配對操作實務上是序列的,可接受。
|
||||
// 任一步失敗整筆 rollback(不留半建的 agent / device / token)。
|
||||
//
|
||||
// Mi#2 lost-update 收斂:所有查詢(GetRepresentativeByAgentTx / GetBySerialTx)都在 tx 內、
|
||||
// 復用路徑只更新必要欄位(PairedAt / serial / agent 關聯),不全欄覆寫。
|
||||
func (e *pgPairingExchanger) Provision(
|
||||
ctx context.Context, userID, parentTokenHash string, ttl time.Duration, devices []ExchangeDeviceInput,
|
||||
) (ExchangeProvisionResult, error) {
|
||||
var res ExchangeProvisionResult
|
||||
now := time.Now().UTC()
|
||||
|
||||
dev := &device.Device{
|
||||
ID: uuid.NewString(),
|
||||
OwnerUserID: userID,
|
||||
Name: defaultPairedDeviceName,
|
||||
DeviceType: defaultPairedDeviceType,
|
||||
// serial_number 預設留空(agent 未帶):SaveTx 把空 serial 寫成 SQL NULL,
|
||||
// 故同 owner 多次 exchange 各建一筆 serial=NULL 的 distinct device,不撞
|
||||
// partial unique uq_devices_owner_serial_active(每個 NULL 互不相等)。
|
||||
RemoteStatus: device.RemoteStatusOffline,
|
||||
Status: device.USBStatusUnknown,
|
||||
PairedAt: &now,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
if input, ok := firstUsableSerialDevice(devices); ok {
|
||||
existing, gErr := e.devices.GetBySerial(ctx, userID, input.SerialNumber)
|
||||
switch {
|
||||
case gErr == nil:
|
||||
// 復用既有 device:保留既有欄位,只更新配對時間。
|
||||
dev = existing
|
||||
dev.PairedAt = &now
|
||||
dev.UpdatedAt = now
|
||||
case errors.Is(gErr, device.ErrNotFound):
|
||||
// 新建 device,填入序號(+ agent 上報的 device type 若有)。
|
||||
dev.SerialNumber = input.SerialNumber
|
||||
if input.DeviceType != "" {
|
||||
dev.DeviceType = input.DeviceType
|
||||
}
|
||||
default:
|
||||
return ExchangeProvisionResult{}, fmt.Errorf("exchange: get device by serial: %w", gErr)
|
||||
}
|
||||
}
|
||||
usable := usableSerialDevices(devices)
|
||||
|
||||
err := db.WithTx(ctx, e.pool, func(q db.Querier) error {
|
||||
if saveErr := e.devices.SaveTx(ctx, q, dev); saveErr != nil {
|
||||
return fmt.Errorf("exchange: save device: %w", saveErr)
|
||||
// 1) 建/復用 agent。platform / agentVersion 目前 agent 上報 payload 未帶(forward-compat),
|
||||
// 先傳空——GetOrCreateAgentTx 空值不覆寫既有。
|
||||
ag, agErr := e.agents.GetOrCreateAgentTx(ctx, q, userID, "", "", "", now)
|
||||
if agErr != nil {
|
||||
return fmt.Errorf("exchange: get-or-create agent: %w", agErr)
|
||||
}
|
||||
res.AgentID = ag.ID
|
||||
|
||||
plaintext, info, createErr := e.sessionToken.CreateTx(ctx, q, userID, dev.ID, parentTokenHash, ttl)
|
||||
// 2) 建/復用該 agent 的 representative device(一 agent 一 representative)。
|
||||
rep, repErr := e.representativeForAgentTx(ctx, q, userID, ag.ID, now)
|
||||
if repErr != nil {
|
||||
return fmt.Errorf("exchange: representative device: %w", repErr)
|
||||
}
|
||||
res.DeviceID = rep.ID
|
||||
|
||||
// 3) 建綁 representative device 的 session token。
|
||||
plaintext, info, createErr := e.sessionToken.CreateTx(ctx, q, userID, rep.ID, parentTokenHash, ttl)
|
||||
if createErr != nil {
|
||||
return fmt.Errorf("exchange: create session token: %w", createErr)
|
||||
}
|
||||
res.DeviceID = dev.ID
|
||||
res.SessionPlaintext = plaintext
|
||||
res.SessionInfo = info
|
||||
|
||||
// 4) 每顆可用序號 USB:建/復用真 USB device(掛同一 agent)。
|
||||
usbIDs := make([]string, 0, len(usable))
|
||||
for _, in := range usable {
|
||||
usbDev, uErr := e.upsertUSBDeviceTx(ctx, q, userID, ag.ID, in, now)
|
||||
if uErr != nil {
|
||||
return fmt.Errorf("exchange: upsert usb device (serial=%s): %w", in.SerialNumber, uErr)
|
||||
}
|
||||
usbIDs = append(usbIDs, usbDev.ID)
|
||||
}
|
||||
res.USBDeviceIDs = usbIDs
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
@ -219,6 +275,94 @@ func (e *pgPairingExchanger) Provision(
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// representativeForAgentTx 建/復用某 agent 的 representative device(tx 內)。
|
||||
//
|
||||
// 復用(GetRepresentativeByAgentTx 命中):只更新 PairedAt(不全欄覆寫,Mi#2)。
|
||||
// 新建:is_representative=true、serial=NULL、掛 agent_id。
|
||||
func (e *pgPairingExchanger) representativeForAgentTx(
|
||||
ctx context.Context, q db.Querier, userID, agentID string, now time.Time,
|
||||
) (*device.Device, error) {
|
||||
existing, gErr := e.devices.GetRepresentativeByAgentTx(ctx, q, agentID)
|
||||
switch {
|
||||
case gErr == nil:
|
||||
// 復用:只更新配對時間(保留既有欄位)。
|
||||
existing.PairedAt = &now
|
||||
existing.UpdatedAt = now
|
||||
if saveErr := e.devices.SaveTx(ctx, q, existing); saveErr != nil {
|
||||
return nil, fmt.Errorf("save representative: %w", saveErr)
|
||||
}
|
||||
return existing, nil
|
||||
case errors.Is(gErr, device.ErrNotFound):
|
||||
rep := &device.Device{
|
||||
ID: uuid.NewString(),
|
||||
OwnerUserID: userID,
|
||||
Name: representativeDeviceName,
|
||||
DeviceType: representativeDeviceType,
|
||||
AgentID: agentID,
|
||||
IsRepresentative: true,
|
||||
// serial_number 留空 → NULL(representative 非真 USB,不佔 partial unique)。
|
||||
RemoteStatus: device.RemoteStatusOffline,
|
||||
Status: device.USBStatusUnknown,
|
||||
PairedAt: &now,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if saveErr := e.devices.SaveTx(ctx, q, rep); saveErr != nil {
|
||||
return nil, fmt.Errorf("save representative: %w", saveErr)
|
||||
}
|
||||
return rep, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("get representative: %w", gErr)
|
||||
}
|
||||
}
|
||||
|
||||
// upsertUSBDeviceTx 建/復用一顆真 USB device(tx 內,掛指定 agent)。
|
||||
//
|
||||
// 復用(GetBySerialTx 命中同 owner 同 serial 的未刪除 device):只更新 PairedAt + agent 關聯
|
||||
// + device type/name(不全欄覆寫,Mi#2;R4 同序號重配 = 復用)。新建:is_representative=false、
|
||||
// 填 serial、Name 由 DeviceType 衍生(S-2)。
|
||||
func (e *pgPairingExchanger) upsertUSBDeviceTx(
|
||||
ctx context.Context, q db.Querier, userID, agentID string, in ExchangeDeviceInput, now time.Time,
|
||||
) (*device.Device, error) {
|
||||
existing, gErr := e.devices.GetBySerialTx(ctx, q, userID, in.SerialNumber)
|
||||
switch {
|
||||
case gErr == nil:
|
||||
// 復用:更新配對時間 + agent 關聯 + 上報的 device type(非空時),Name 隨 type 衍生。
|
||||
existing.PairedAt = &now
|
||||
existing.UpdatedAt = now
|
||||
existing.AgentID = agentID
|
||||
if in.DeviceType != "" {
|
||||
existing.DeviceType = in.DeviceType
|
||||
existing.Name = deriveDeviceName(in.DeviceType, existing.SerialNumber)
|
||||
}
|
||||
if saveErr := e.devices.SaveTx(ctx, q, existing); saveErr != nil {
|
||||
return nil, fmt.Errorf("save usb device: %w", saveErr)
|
||||
}
|
||||
return existing, nil
|
||||
case errors.Is(gErr, device.ErrNotFound):
|
||||
usbDev := &device.Device{
|
||||
ID: uuid.NewString(),
|
||||
OwnerUserID: userID,
|
||||
Name: deriveDeviceName(in.DeviceType, in.SerialNumber),
|
||||
DeviceType: in.DeviceType,
|
||||
SerialNumber: in.SerialNumber,
|
||||
AgentID: agentID,
|
||||
IsRepresentative: false,
|
||||
RemoteStatus: device.RemoteStatusOffline,
|
||||
Status: device.USBStatusUnknown,
|
||||
PairedAt: &now,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if saveErr := e.devices.SaveTx(ctx, q, usbDev); saveErr != nil {
|
||||
return nil, fmt.Errorf("save usb device: %w", saveErr)
|
||||
}
|
||||
return usbDev, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("get usb device by serial: %w", gErr)
|
||||
}
|
||||
}
|
||||
|
||||
// ── in-memory 後端 ────────────────────────────────────────────────────────────
|
||||
|
||||
// memSessionTokenCreator 是 in-memory store「建 session token」的能力
|
||||
@ -227,72 +371,163 @@ type memSessionTokenCreator interface {
|
||||
Create(ctx context.Context, userID, deviceID, parentTokenHash string, ttl time.Duration) (string, *auth.SessionToken, error)
|
||||
}
|
||||
|
||||
// memPairingExchanger 依序(非交易)完成自建 device + 建 session token。
|
||||
// memDeviceRepo 是 in-memory device store 供 exchange 用的能力(由 *device.InMemoryRepository 滿足)。
|
||||
//
|
||||
// 除 Repository 的 Save/GetBySerial 外,額外需要 GetRepresentativeByAgentTx(concrete method)。
|
||||
// 傳 nil Querier(in-memory 無交易)。
|
||||
type memDeviceRepo interface {
|
||||
Save(ctx context.Context, d *device.Device) error
|
||||
GetBySerial(ctx context.Context, ownerUserID, serial string) (*device.Device, error)
|
||||
GetRepresentativeByAgentTx(ctx context.Context, q db.Querier, agentID string) (*device.Device, error)
|
||||
}
|
||||
|
||||
// memAgentRepo 是 in-memory agent store 供 exchange 用的能力(由 *agent.InMemoryRepository 滿足)。
|
||||
type memAgentRepo interface {
|
||||
GetOrCreateAgentTx(ctx context.Context, q db.Querier, ownerUserID, name, platform, agentVersion string, pairedAt time.Time) (*agent.Agent, error)
|
||||
}
|
||||
|
||||
// memPairingExchanger 依序(非交易)完成 A' exchange 重塑。
|
||||
//
|
||||
// in-memory 為單機 local-dev fallback,無跨 store 交易需求;依序執行已能保證行為一致。
|
||||
// 行為與 pgPairingExchanger 對齊:建/復用 agent → representative device(綁 session token)
|
||||
// → N 顆真 USB device。
|
||||
type memPairingExchanger struct {
|
||||
devices device.Repository
|
||||
agents memAgentRepo
|
||||
devices memDeviceRepo
|
||||
sessionToken memSessionTokenCreator
|
||||
}
|
||||
|
||||
// NewInMemoryPairingExchanger 建立 in-memory 後端的 exchange 協調者。
|
||||
func NewInMemoryPairingExchanger(
|
||||
devices device.Repository,
|
||||
agents memAgentRepo,
|
||||
devices memDeviceRepo,
|
||||
sessionToken memSessionTokenCreator,
|
||||
) PairingExchanger {
|
||||
return &memPairingExchanger{
|
||||
agents: agents,
|
||||
devices: devices,
|
||||
sessionToken: sessionToken,
|
||||
}
|
||||
}
|
||||
|
||||
// Provision 自建(或依 serial 復用)device 後建綁該 device 的 session token
|
||||
// (依序,非交易)。serial 處理邏輯與 pgPairingExchanger 對齊(WP-0)。
|
||||
// Provision(in-memory):建/復用 agent → representative device(綁 session token)→ N USB。
|
||||
// 行為與 pgPairingExchanger.Provision 對齊(無交易,依序執行)。
|
||||
func (e *memPairingExchanger) Provision(
|
||||
ctx context.Context, userID, parentTokenHash string, ttl time.Duration, devices []ExchangeDeviceInput,
|
||||
) (ExchangeProvisionResult, error) {
|
||||
var res ExchangeProvisionResult
|
||||
now := time.Now().UTC()
|
||||
usable := usableSerialDevices(devices)
|
||||
|
||||
dev := &device.Device{
|
||||
// 1) 建/復用 agent。
|
||||
ag, agErr := e.agents.GetOrCreateAgentTx(ctx, nil, userID, "", "", "", now)
|
||||
if agErr != nil {
|
||||
return ExchangeProvisionResult{}, fmt.Errorf("exchange: get-or-create agent: %w", agErr)
|
||||
}
|
||||
res.AgentID = ag.ID
|
||||
|
||||
// 2) 建/復用 representative device。
|
||||
rep, repErr := e.representativeForAgent(ctx, userID, ag.ID, now)
|
||||
if repErr != nil {
|
||||
return ExchangeProvisionResult{}, fmt.Errorf("exchange: representative device: %w", repErr)
|
||||
}
|
||||
res.DeviceID = rep.ID
|
||||
|
||||
// 3) 建綁 representative device 的 session token。
|
||||
plaintext, info, err := e.sessionToken.Create(ctx, userID, rep.ID, parentTokenHash, ttl)
|
||||
if err != nil {
|
||||
return ExchangeProvisionResult{}, fmt.Errorf("exchange: create session token: %w", err)
|
||||
}
|
||||
res.SessionPlaintext = plaintext
|
||||
res.SessionInfo = info
|
||||
|
||||
// 4) 每顆可用序號 USB。
|
||||
usbIDs := make([]string, 0, len(usable))
|
||||
for _, in := range usable {
|
||||
usbDev, uErr := e.upsertUSBDevice(ctx, userID, ag.ID, in, now)
|
||||
if uErr != nil {
|
||||
return ExchangeProvisionResult{}, fmt.Errorf("exchange: upsert usb device (serial=%s): %w", in.SerialNumber, uErr)
|
||||
}
|
||||
usbIDs = append(usbIDs, usbDev.ID)
|
||||
}
|
||||
res.USBDeviceIDs = usbIDs
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// representativeForAgent 建/復用某 agent 的 representative device(in-memory,對齊 pg 版)。
|
||||
func (e *memPairingExchanger) representativeForAgent(
|
||||
ctx context.Context, userID, agentID string, now time.Time,
|
||||
) (*device.Device, error) {
|
||||
existing, gErr := e.devices.GetRepresentativeByAgentTx(ctx, nil, agentID)
|
||||
switch {
|
||||
case gErr == nil:
|
||||
existing.PairedAt = &now
|
||||
existing.UpdatedAt = now
|
||||
if saveErr := e.devices.Save(ctx, existing); saveErr != nil {
|
||||
return nil, fmt.Errorf("save representative: %w", saveErr)
|
||||
}
|
||||
return existing, nil
|
||||
case errors.Is(gErr, device.ErrNotFound):
|
||||
rep := &device.Device{
|
||||
ID: uuid.NewString(),
|
||||
OwnerUserID: userID,
|
||||
Name: defaultPairedDeviceName,
|
||||
DeviceType: defaultPairedDeviceType,
|
||||
Name: representativeDeviceName,
|
||||
DeviceType: representativeDeviceType,
|
||||
AgentID: agentID,
|
||||
IsRepresentative: true,
|
||||
RemoteStatus: device.RemoteStatusOffline,
|
||||
Status: device.USBStatusUnknown,
|
||||
PairedAt: &now,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if saveErr := e.devices.Save(ctx, rep); saveErr != nil {
|
||||
return nil, fmt.Errorf("save representative: %w", saveErr)
|
||||
}
|
||||
return rep, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("get representative: %w", gErr)
|
||||
}
|
||||
}
|
||||
|
||||
if input, ok := firstUsableSerialDevice(devices); ok {
|
||||
existing, gErr := e.devices.GetBySerial(ctx, userID, input.SerialNumber)
|
||||
// upsertUSBDevice 建/復用一顆真 USB device(in-memory,對齊 pg 版)。
|
||||
func (e *memPairingExchanger) upsertUSBDevice(
|
||||
ctx context.Context, userID, agentID string, in ExchangeDeviceInput, now time.Time,
|
||||
) (*device.Device, error) {
|
||||
existing, gErr := e.devices.GetBySerial(ctx, userID, in.SerialNumber)
|
||||
switch {
|
||||
case gErr == nil:
|
||||
dev = existing
|
||||
dev.PairedAt = &now
|
||||
dev.UpdatedAt = now
|
||||
existing.PairedAt = &now
|
||||
existing.UpdatedAt = now
|
||||
existing.AgentID = agentID
|
||||
if in.DeviceType != "" {
|
||||
existing.DeviceType = in.DeviceType
|
||||
existing.Name = deriveDeviceName(in.DeviceType, existing.SerialNumber)
|
||||
}
|
||||
if saveErr := e.devices.Save(ctx, existing); saveErr != nil {
|
||||
return nil, fmt.Errorf("save usb device: %w", saveErr)
|
||||
}
|
||||
return existing, nil
|
||||
case errors.Is(gErr, device.ErrNotFound):
|
||||
dev.SerialNumber = input.SerialNumber
|
||||
if input.DeviceType != "" {
|
||||
dev.DeviceType = input.DeviceType
|
||||
usbDev := &device.Device{
|
||||
ID: uuid.NewString(),
|
||||
OwnerUserID: userID,
|
||||
Name: deriveDeviceName(in.DeviceType, in.SerialNumber),
|
||||
DeviceType: in.DeviceType,
|
||||
SerialNumber: in.SerialNumber,
|
||||
AgentID: agentID,
|
||||
IsRepresentative: false,
|
||||
RemoteStatus: device.RemoteStatusOffline,
|
||||
Status: device.USBStatusUnknown,
|
||||
PairedAt: &now,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if saveErr := e.devices.Save(ctx, usbDev); saveErr != nil {
|
||||
return nil, fmt.Errorf("save usb device: %w", saveErr)
|
||||
}
|
||||
return usbDev, nil
|
||||
default:
|
||||
return ExchangeProvisionResult{}, fmt.Errorf("exchange: get device by serial: %w", gErr)
|
||||
return nil, fmt.Errorf("get usb device by serial: %w", gErr)
|
||||
}
|
||||
}
|
||||
|
||||
if err := e.devices.Save(ctx, dev); err != nil {
|
||||
return ExchangeProvisionResult{}, fmt.Errorf("exchange: save device: %w", err)
|
||||
}
|
||||
|
||||
plaintext, info, err := e.sessionToken.Create(ctx, userID, dev.ID, parentTokenHash, ttl)
|
||||
if err != nil {
|
||||
return ExchangeProvisionResult{}, fmt.Errorf("exchange: create session token: %w", err)
|
||||
}
|
||||
return ExchangeProvisionResult{
|
||||
DeviceID: dev.ID,
|
||||
SessionPlaintext: plaintext,
|
||||
SessionInfo: info,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@ -1,22 +1,18 @@
|
||||
//go:build dbtest
|
||||
|
||||
// Postgres pairing exchange 自建 device 的真 DB 整合測試(DB-on FK 收尾,問題 #2)。
|
||||
// Postgres pairing exchange A' 模型重塑的真 DB 整合測試(WP-B B3)。
|
||||
//
|
||||
// build tag `dbtest`:只在帶 `-tags=dbtest`(需要 Docker / testcontainers)時編譯/執行。
|
||||
// 預設 `go test ./...`(無 Docker)不觸碰本檔,維持綠燈。
|
||||
//
|
||||
// 執行:
|
||||
//
|
||||
// go test -tags=dbtest ./internal/api/...
|
||||
// # 無本機 Docker 時,Orchestrator 在 130 補跑:
|
||||
// # 無本機 Docker 時在 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 不會殘留。
|
||||
// 涵蓋 A' 重塑:建/復用 agent + representative device(綁 session token)+ N 顆真 USB device;
|
||||
// Mi#2(tx 內查詢/局部更新)、Mi#5(serial 白名單)、S-2(Name 衍生)、R1(完整 N 顆)、R4(同序號復用)。
|
||||
package api
|
||||
|
||||
import (
|
||||
@ -27,160 +23,216 @@ import (
|
||||
"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(無 device、無 token)。
|
||||
// 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", "users")
|
||||
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, devRepo, sessions, nil)
|
||||
exchanger = NewPostgresPairingExchanger(tdb.Pool, agentRepo, 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) {
|
||||
// Provision 建 agent + representative device + session token(無 USB 上報)。
|
||||
func TestPGExchange_ProvisionCreatesAgentRepresentativeAndSession(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tdb, exchanger, devRepo, sessions, owner := pgExchangeFixture(t)
|
||||
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)
|
||||
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) device 真的進 DB、owner 對齊
|
||||
dev, err := devRepo.Get(ctx, res.DeviceID)
|
||||
// 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.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")
|
||||
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)
|
||||
|
||||
// 2) session token 真的進 DB、device_id 綁到新建 device(非空、FK 滿足)
|
||||
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, "session token 的 device_id 應綁到自建 device")
|
||||
assert.Equal(t, res.DeviceID, tok.DeviceID)
|
||||
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")
|
||||
assert.Equal(t, parentHash, tok.ParentTokenHash)
|
||||
}
|
||||
|
||||
// TestPGExchange_Provision_RollbackOnBadOwner 驗證原子性:owner 不存在於 users(FK violation)
|
||||
// → device INSERT 撞 owner_user_id FK → 整筆 rollback,device 不殘留。
|
||||
// 原子性:owner 不存在(FK violation)→ 整筆 rollback,agent / device / token 都不殘留。
|
||||
func TestPGExchange_Provision_RollbackOnBadOwner(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tdb, exchanger, _, _, _ := pgExchangeFixture(t)
|
||||
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")
|
||||
require.Error(t, err, "owner 不存在 → agent.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 也不應建立")
|
||||
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 殘留")
|
||||
}
|
||||
|
||||
// TestPGExchange_Provision_MultipleCreatesDistinctDevices 驗證多次 exchange 各自建新 device
|
||||
// (不同 UUID)——對齊「不同次配對視為不同 agent 連線」的冪等語意。
|
||||
func TestPGExchange_Provision_MultipleCreatesDistinctDevices(t *testing.T) {
|
||||
// 同 owner 多次 exchange:復用同一 agent + 同一 representative device,各建獨立 session token。
|
||||
func TestPGExchange_Provision_ReusesAgentAndRepresentative(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
_, exchanger, _, _, owner := pgExchangeFixture(t)
|
||||
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.NotEqual(t, res1.DeviceID, res2.DeviceID, "兩次 exchange 應各自建不同 device")
|
||||
assert.NotEqual(t, res1.SessionPlaintext, res2.SessionPlaintext, "兩次 session token 應不同")
|
||||
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")
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// WP-0(ADR-018 序號地基):exchange 收 agent 上報序號(真 DB)
|
||||
// ==========================================================================
|
||||
|
||||
// TestPGExchange_Provision_FillsSerialNumber 驗證序號真的寫進 devices.serial_number
|
||||
// (非 NULL),且 device_type 取 agent 上報值。
|
||||
func TestPGExchange_Provision_FillsSerialNumber(t *testing.T) {
|
||||
// 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)
|
||||
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)
|
||||
|
||||
dev, err := devRepo.Get(ctx, res.DeviceID)
|
||||
d, err := devRepo.Get(ctx, res.USBDeviceIDs[0])
|
||||
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")
|
||||
assert.Equal(t, "kneron_kl520 (0x1A2B3C4D)", d.Name)
|
||||
assert.Equal(t, "kneron_kl520", d.DeviceType)
|
||||
}
|
||||
|
||||
// TestPGExchange_Provision_SameSerialReusesDevice 驗證同序號重複 exchange 不撞
|
||||
// partial unique uq_devices_owner_serial_active(23505)——復用既有 device、
|
||||
// 不 500、devices 表不長出第二筆。
|
||||
func TestPGExchange_Provision_SameSerialReusesDevice(t *testing.T) {
|
||||
// R4:同序號重配復用同一真 USB device,不撞 partial unique(23505)、不多建。
|
||||
func TestPGExchange_Provision_SameSerialReusesUSBDevice(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tdb, exchanger, _, sessions, owner := pgExchangeFixture(t)
|
||||
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.NoError(t, err, "同序號重配不可撞 23505 炸 500")
|
||||
require.Len(t, res2.USBDeviceIDs, 1)
|
||||
|
||||
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)
|
||||
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"))
|
||||
}
|
||||
|
||||
// TestPGExchange_Provision_FakeSerialWritesNull 驗證假序號 0x00000000 視同無序號
|
||||
// → serial_number 寫 NULL、不進復用分支(NULL 互不相等,各建 distinct device)。
|
||||
func TestPGExchange_Provision_FakeSerialWritesNull(t *testing.T) {
|
||||
// serial 真的寫進 DB(非 NULL)+ 復用 USB device 掛到 agent。
|
||||
func TestPGExchange_Provision_FillsSerialNumber(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tdb, exchanger, _, _, owner := pgExchangeFixture(t)
|
||||
input := []ExchangeDeviceInput{{SerialNumber: "0x00000000"}}
|
||||
tdb, exchanger, devRepo, _, _, owner := pgExchangeFixture(t)
|
||||
|
||||
res1, err := exchanger.Provision(ctx, owner, "", auth.SessionTokenTTL, input)
|
||||
res, err := exchanger.Provision(ctx, owner, "", auth.SessionTokenTTL,
|
||||
[]ExchangeDeviceInput{{SerialNumber: "0x1A2B3C4D", DeviceType: "kneron_kl520"}})
|
||||
require.NoError(t, err)
|
||||
res2, err := exchanger.Provision(ctx, owner, "", auth.SessionTokenTTL, input)
|
||||
require.NoError(t, err, "假序號寫 NULL、NULL 互不相等 → 不撞 unique")
|
||||
require.Len(t, res.USBDeviceIDs, 1)
|
||||
|
||||
assert.NotEqual(t, res1.DeviceID, res2.DeviceID)
|
||||
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`, res1.DeviceID).Scan(&serialIsNull))
|
||||
assert.True(t, serialIsNull, "假序號應寫 NULL")
|
||||
`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)
|
||||
}
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
// memPairingExchanger 的單元測試(DB-on FK 收尾,問題 #2)。
|
||||
// memPairingExchanger 的單元測試(A' 模型 exchange 重塑,WP-B B3)。
|
||||
//
|
||||
// 不帶 build tag:屬於預設 `go test ./...` 範圍(無需 Docker)。
|
||||
// 驗證 in-memory exchanger 自建 device + 建 session token、且 session token 綁到該 device,
|
||||
// 與 pairing_exchange_db_test.go 的 Postgres dbtest 對齊行為。
|
||||
// 驗證 in-memory exchanger 建/復用 agent + representative device(綁 session token)+ N 顆真
|
||||
// USB device,與 pairing_exchange_db_test.go 的 Postgres dbtest 對齊行為。
|
||||
package api
|
||||
|
||||
import (
|
||||
@ -12,133 +12,210 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"visiona-backend/internal/agent"
|
||||
"visiona-backend/internal/auth"
|
||||
"visiona-backend/internal/device"
|
||||
)
|
||||
|
||||
func TestMemExchange_ProvisionCreatesDeviceAndSession(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
// memFixture 建一組 in-memory exchanger + 底層 repo,供斷言直接查 device / agent。
|
||||
func memFixture() (
|
||||
PairingExchanger,
|
||||
*device.InMemoryRepository,
|
||||
*agent.InMemoryRepository,
|
||||
*auth.InMemorySessionTokenStore,
|
||||
) {
|
||||
devRepo := device.NewInMemoryRepository()
|
||||
agentRepo := agent.NewInMemoryRepository()
|
||||
sessions := auth.NewInMemorySessionTokenStore()
|
||||
exchanger := NewInMemoryPairingExchanger(devRepo, sessions)
|
||||
ex := NewInMemoryPairingExchanger(agentRepo, devRepo, sessions)
|
||||
return ex, devRepo, agentRepo, sessions
|
||||
}
|
||||
|
||||
res, err := exchanger.Provision(ctx, "owner-1", "parent-hash", auth.SessionTokenTTL, nil)
|
||||
// Provision 建 agent + representative device + session token(無 USB 上報時)。
|
||||
// session token 綁 representative device(is_representative=true)。
|
||||
func TestMemExchange_ProvisionCreatesAgentRepresentativeAndSession(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ex, devRepo, _, sessions := memFixture()
|
||||
|
||||
res, err := ex.Provision(ctx, "owner-1", "parent-hash", auth.SessionTokenTTL, nil)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, res.DeviceID)
|
||||
require.NotEmpty(t, res.DeviceID, "應建 representative device")
|
||||
require.NotEmpty(t, res.AgentID, "應建 agent")
|
||||
require.NotEmpty(t, res.SessionPlaintext)
|
||||
require.NotNil(t, res.SessionInfo)
|
||||
assert.Empty(t, res.USBDeviceIDs, "無 USB 上報 → 不建真 USB device")
|
||||
|
||||
// device 自建、owner 對齊
|
||||
dev, err := devRepo.Get(ctx, res.DeviceID)
|
||||
// DeviceID 指向的是 representative device(is_representative=true、綁該 agent)。
|
||||
rep, err := devRepo.Get(ctx, res.DeviceID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "owner-1", dev.OwnerUserID)
|
||||
assert.Equal(t, defaultPairedDeviceName, dev.Name)
|
||||
assert.Equal(t, defaultPairedDeviceType, dev.DeviceType)
|
||||
assert.NotNil(t, dev.PairedAt)
|
||||
assert.True(t, rep.IsRepresentative, "session 綁的應為 representative device")
|
||||
assert.Equal(t, res.AgentID, rep.AgentID)
|
||||
assert.Equal(t, representativeDeviceName, rep.Name)
|
||||
assert.Empty(t, rep.SerialNumber, "representative device serial 應為空(NULL)")
|
||||
assert.NotNil(t, rep.PairedAt)
|
||||
|
||||
// session token 綁到自建 device
|
||||
// session token 綁 representative device。
|
||||
tok, err := sessions.Get(ctx, res.SessionPlaintext)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, res.DeviceID, tok.DeviceID, "session token 應綁到自建 device")
|
||||
assert.Equal(t, res.DeviceID, tok.DeviceID)
|
||||
assert.Equal(t, "owner-1", tok.UserID)
|
||||
assert.Equal(t, "parent-hash", tok.ParentTokenHash)
|
||||
}
|
||||
|
||||
func TestMemExchange_Provision_DistinctDevices(t *testing.T) {
|
||||
// 同 owner 多次 exchange:復用同一 agent + 同一 representative device(A' 語意),
|
||||
// 但各建獨立 session token。(取代舊 DistinctDevices 測試——A' 下 representative 不再各建。)
|
||||
func TestMemExchange_Provision_ReusesAgentAndRepresentative(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
exchanger := NewInMemoryPairingExchanger(
|
||||
device.NewInMemoryRepository(), auth.NewInMemorySessionTokenStore())
|
||||
ex, devRepo, _, _ := memFixture()
|
||||
|
||||
res1, err := exchanger.Provision(ctx, "owner-1", "", auth.SessionTokenTTL, nil)
|
||||
res1, err := ex.Provision(ctx, "owner-1", "", auth.SessionTokenTTL, nil)
|
||||
require.NoError(t, err)
|
||||
res2, err := exchanger.Provision(ctx, "owner-1", "", auth.SessionTokenTTL, nil)
|
||||
res2, err := ex.Provision(ctx, "owner-1", "", auth.SessionTokenTTL, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotEqual(t, res1.DeviceID, res2.DeviceID)
|
||||
assert.NotEqual(t, res1.SessionPlaintext, res2.SessionPlaintext)
|
||||
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, "session token 各自獨立")
|
||||
|
||||
// 只有一顆 representative device(未長出第二顆)。List filter 掉 representative(B4),
|
||||
// 故用 GetRepresentativeByAgentTx 直接查該 agent 的 representative 確認唯一。
|
||||
rep, err := devRepo.GetRepresentativeByAgentTx(ctx, nil, res1.AgentID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, res1.DeviceID, rep.ID, "representative 應復用同一顆")
|
||||
// List(真 USB)為空(無 USB 上報)。
|
||||
list, err := devRepo.List(ctx, "owner-1")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, list, "無 USB 上報 → List(真 USB)為空")
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// WP-0(ADR-018 序號地基):exchange 收 agent 上報序號
|
||||
// ==========================================================================
|
||||
|
||||
// TestMemExchange_Provision_FillsSerialNumber 驗證 agent 上報序號時,自建 device
|
||||
// 的 serial_number 有值(+ device_type 若有上報)。
|
||||
func TestMemExchange_Provision_FillsSerialNumber(t *testing.T) {
|
||||
// R1:多顆可用序號 USB → 各建一筆真 USB device(掛同一 agent、is_representative=false)。
|
||||
func TestMemExchange_Provision_CreatesNUSBDevices(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
devRepo := device.NewInMemoryRepository()
|
||||
exchanger := NewInMemoryPairingExchanger(devRepo, auth.NewInMemorySessionTokenStore())
|
||||
ex, devRepo, _, _ := memFixture()
|
||||
|
||||
res, err := exchanger.Provision(ctx, "owner-1", "", auth.SessionTokenTTL,
|
||||
res, err := ex.Provision(ctx, "owner-1", "", auth.SessionTokenTTL,
|
||||
[]ExchangeDeviceInput{
|
||||
{SerialNumber: "0x1A2B3C4D", DeviceType: "kneron_kl520"},
|
||||
{SerialNumber: "0x0E5F6071", DeviceType: "kneron_kl720"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, res.USBDeviceIDs, 2, "兩顆可用序號 → 兩筆真 USB device")
|
||||
|
||||
// 每顆真 USB device:is_representative=false、掛同一 agent、有 serial、Name 由 type 衍生。
|
||||
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)
|
||||
assert.Contains(t, d.Name, "kneron_kl", "Name 應由 device type 衍生")
|
||||
assert.Contains(t, d.Name, d.SerialNumber, "Name 應含 serial")
|
||||
}
|
||||
}
|
||||
|
||||
// serial 填入 + device_type 衍生 Name(S-2)。
|
||||
func TestMemExchange_Provision_FillsSerialAndDerivedName(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ex, devRepo, _, _ := memFixture()
|
||||
|
||||
res, err := ex.Provision(ctx, "owner-1", "", auth.SessionTokenTTL,
|
||||
[]ExchangeDeviceInput{{SerialNumber: "0x1A2B3C4D", DeviceType: "kneron_kl520", Firmware: "KDP"}})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, res.USBDeviceIDs, 1)
|
||||
|
||||
dev, err := devRepo.Get(ctx, res.DeviceID)
|
||||
d, err := devRepo.Get(ctx, res.USBDeviceIDs[0])
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "0x1A2B3C4D", dev.SerialNumber, "serial_number 應填入 agent 上報值")
|
||||
assert.Equal(t, "kneron_kl520", dev.DeviceType, "device_type 有上報時應覆蓋預設")
|
||||
assert.Equal(t, "0x1A2B3C4D", d.SerialNumber)
|
||||
assert.Equal(t, "kneron_kl520", d.DeviceType)
|
||||
assert.Equal(t, "kneron_kl520 (0x1A2B3C4D)", d.Name, "S-2:Name 由 type + serial 衍生")
|
||||
}
|
||||
|
||||
// TestMemExchange_Provision_SameSerialReusesDevice 驗證同序號重複 exchange
|
||||
// 復用既有 device(防唯一約束炸裂;「同序號重配 = 復用」,R4)——不新建、
|
||||
// 不報錯、session token 各自獨立。
|
||||
func TestMemExchange_Provision_SameSerialReusesDevice(t *testing.T) {
|
||||
// 同序號重配 = 復用真 USB device(R4;防 unique 炸裂)。
|
||||
func TestMemExchange_Provision_SameSerialReusesUSBDevice(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
devRepo := device.NewInMemoryRepository()
|
||||
exchanger := NewInMemoryPairingExchanger(devRepo, auth.NewInMemorySessionTokenStore())
|
||||
ex, devRepo, _, _ := memFixture()
|
||||
input := []ExchangeDeviceInput{{SerialNumber: "0x1A2B3C4D"}}
|
||||
|
||||
res1, err := exchanger.Provision(ctx, "owner-1", "", auth.SessionTokenTTL, input)
|
||||
res1, err := ex.Provision(ctx, "owner-1", "", auth.SessionTokenTTL, input)
|
||||
require.NoError(t, err)
|
||||
res2, err := exchanger.Provision(ctx, "owner-1", "", auth.SessionTokenTTL, input)
|
||||
require.NoError(t, err, "同序號重配不可失敗(防 23505 的行為對齊)")
|
||||
|
||||
assert.Equal(t, res1.DeviceID, res2.DeviceID, "同序號重配應復用既有 device")
|
||||
assert.NotEqual(t, res1.SessionPlaintext, res2.SessionPlaintext, "session token 應各自獨立")
|
||||
|
||||
devices, err := devRepo.List(ctx, "owner-1")
|
||||
require.Len(t, res1.USBDeviceIDs, 1)
|
||||
res2, err := ex.Provision(ctx, "owner-1", "", auth.SessionTokenTTL, input)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, devices, 1, "同序號重配不應多建 device")
|
||||
require.Len(t, res2.USBDeviceIDs, 1)
|
||||
|
||||
assert.Equal(t, res1.USBDeviceIDs[0], res2.USBDeviceIDs[0], "同序號重配應復用同一 USB device")
|
||||
|
||||
// List(filter 掉 representative,B4)只回真 USB:一顆(同序號不重複建)。
|
||||
list, err := devRepo.List(ctx, "owner-1")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, list, 1, "List 只回真 USB(同序號復用,不重複建)")
|
||||
assert.Equal(t, res1.USBDeviceIDs[0], list[0].ID)
|
||||
}
|
||||
|
||||
// TestMemExchange_Provision_FakeSerialTreatedAsEmpty 驗證假序號 0x00000000
|
||||
// (macOS 無 SDK 的 pyusb fallback)視同無序號:serial 留空、行為與不帶序號一致
|
||||
// (每次 exchange 各建一筆 distinct device)。
|
||||
func TestMemExchange_Provision_FakeSerialTreatedAsEmpty(t *testing.T) {
|
||||
// 假序號 0x00000000 視同無序號:不建真 USB device(Mi#5 白名單前的既有規則保留)。
|
||||
func TestMemExchange_Provision_FakeSerialSkipped(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
devRepo := device.NewInMemoryRepository()
|
||||
exchanger := NewInMemoryPairingExchanger(devRepo, auth.NewInMemorySessionTokenStore())
|
||||
input := []ExchangeDeviceInput{{SerialNumber: "0x00000000"}}
|
||||
ex, _, _, _ := memFixture()
|
||||
|
||||
res1, err := exchanger.Provision(ctx, "owner-1", "", auth.SessionTokenTTL, input)
|
||||
res, err := ex.Provision(ctx, "owner-1", "", auth.SessionTokenTTL,
|
||||
[]ExchangeDeviceInput{{SerialNumber: "0x00000000"}})
|
||||
require.NoError(t, err)
|
||||
res2, err := exchanger.Provision(ctx, "owner-1", "", auth.SessionTokenTTL, input)
|
||||
require.NoError(t, err)
|
||||
|
||||
dev1, err := devRepo.Get(ctx, res1.DeviceID)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, dev1.SerialNumber, "假序號應視同無序號(寫 NULL)")
|
||||
assert.NotEqual(t, res1.DeviceID, res2.DeviceID, "假序號不進復用分支,各建 distinct device")
|
||||
assert.Empty(t, res.USBDeviceIDs, "假序號視同無序號 → 不建真 USB device")
|
||||
}
|
||||
|
||||
// TestMemExchange_Provision_PicksFirstUsableSerial 驗證多顆上報時取第一顆
|
||||
// 可用序號(空序號 / 假序號跳過)——WP-0 最小落地(R1:多顆完整模型是 WP-B)。
|
||||
func TestMemExchange_Provision_PicksFirstUsableSerial(t *testing.T) {
|
||||
// Mi#5 serial 白名單:不符 ^0x[0-9A-Fa-f]{8}$ 的序號視同無序號、不建真 USB device。
|
||||
func TestMemExchange_Provision_InvalidSerialFormatSkipped(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
devRepo := device.NewInMemoryRepository()
|
||||
exchanger := NewInMemoryPairingExchanger(devRepo, auth.NewInMemorySessionTokenStore())
|
||||
ex, _, _, _ := memFixture()
|
||||
|
||||
res, err := exchanger.Provision(ctx, "owner-1", "", auth.SessionTokenTTL,
|
||||
res, err := ex.Provision(ctx, "owner-1", "", auth.SessionTokenTTL,
|
||||
[]ExchangeDeviceInput{
|
||||
{SerialNumber: ""}, // 空序號跳過
|
||||
{SerialNumber: "0x00000000"}, // 假序號跳過
|
||||
{SerialNumber: " 0x0E5F6071 ", DeviceType: "kneron_kl720"},
|
||||
{SerialNumber: "not-a-serial"}, // 無 0x 前綴
|
||||
{SerialNumber: "0x12"}, // 位數不足
|
||||
{SerialNumber: "0x1234567890AB"}, // 位數過多
|
||||
{SerialNumber: "0xZZZZZZZZ"}, // 非 hex
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, res.USBDeviceIDs, "格式不符白名單的序號應全部跳過")
|
||||
}
|
||||
|
||||
// usableSerialDevices 去重:同一次 exchange 上報重複序號只建一顆。
|
||||
func TestMemExchange_Provision_DedupsRepeatedSerial(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ex, _, _, _ := memFixture()
|
||||
|
||||
res, err := ex.Provision(ctx, "owner-1", "", auth.SessionTokenTTL,
|
||||
[]ExchangeDeviceInput{
|
||||
{SerialNumber: "0x1A2B3C4D"},
|
||||
{SerialNumber: "0x1a2b3c4d"}, // 同序號(大小寫不同)→ 去重
|
||||
{SerialNumber: "0xFFFF0001"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
dev, err := devRepo.Get(ctx, res.DeviceID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "0x0E5F6071", dev.SerialNumber, "應取第一顆可用序號(trim 空白)")
|
||||
assert.Equal(t, "kneron_kl720", dev.DeviceType)
|
||||
assert.Len(t, res.USBDeviceIDs, 2, "重複序號應去重(大小寫不敏感)")
|
||||
}
|
||||
|
||||
// 空序號 / 假序號混雜時,仍取所有可用序號(不再只取第一顆——R1)。
|
||||
func TestMemExchange_Provision_PicksAllUsableSerials(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ex, devRepo, _, _ := memFixture()
|
||||
|
||||
res, err := ex.Provision(ctx, "owner-1", "", auth.SessionTokenTTL,
|
||||
[]ExchangeDeviceInput{
|
||||
{SerialNumber: ""}, // 空 → 跳過
|
||||
{SerialNumber: "0x00000000"}, // 假 → 跳過
|
||||
{SerialNumber: " 0x0E5F6071 ", DeviceType: "a"}, // trim 後可用
|
||||
{SerialNumber: "0xFFFF0001"}, // 可用
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, res.USBDeviceIDs, 2, "R1:取所有可用序號(非只第一顆)")
|
||||
|
||||
// 確認 trim 生效。
|
||||
serials := map[string]bool{}
|
||||
for _, id := range res.USBDeviceIDs {
|
||||
d, err := devRepo.Get(ctx, id)
|
||||
require.NoError(t, err)
|
||||
serials[d.SerialNumber] = true
|
||||
}
|
||||
assert.True(t, serials["0x0E5F6071"], "trim 後序號應寫入")
|
||||
assert.True(t, serials["0xFFFF0001"])
|
||||
}
|
||||
|
||||
@ -319,3 +319,112 @@ func TestMigrate0005_ExistingDeviceReadWrite(t *testing.T) {
|
||||
"99999999-9999-9999-9999-999999999999", "bad-owner")
|
||||
assert.Error(t, err, "devices.owner_user_id FK 應仍生效")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// B5 收尾:0005-Mi#1(有 serial 未刪除 device 遷移)+ Q4 矛盾態驗證
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestMigrate0005_Mi1_ActiveDeviceWithSerialMigration 驗證「有 serial 的未刪除 device」遷移行為
|
||||
// (0005-Mi#1;WP-B 落地後才有此真場景)。
|
||||
//
|
||||
// 場景:WP-0(序號地基)已 commit,exchange 會落「有 serial 的真 USB device」。若這些 device
|
||||
// 在 apply 0005 前就已存在於 DB,0005 的 data migration(對所有未刪除 device 一律標
|
||||
// is_representative=true)會如何處理它們?本測試 down 回 0004、塞有 serial 的未刪除 device、
|
||||
// 再 up 觀察遷移結果——驗證「行為」本身(不論結果是否為預期,先如實記錄)。
|
||||
func TestMigrate0005_Mi1_ActiveDeviceWithSerialMigration(t *testing.T) {
|
||||
tdb := testsupport.SetupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
mg, err := db.NewMigrator(tdb.Cfg, discardLog())
|
||||
require.NoError(t, err)
|
||||
defer mg.Close()
|
||||
|
||||
require.NoError(t, mg.Down(), "down 回 0004")
|
||||
require.False(t, colExists(t, tdb, "devices", "agent_id"), "down 後不應有 agent_id 欄")
|
||||
|
||||
owner := tdb.InsertUser(t, "", "")
|
||||
|
||||
// 塞「有 serial 的未刪除 device」(模擬 WP-0 產生的真 USB)+ 「無 serial 的未刪除 device」(連線佔位)。
|
||||
serialID := insertRawDevice(t, tdb, "", owner, "0x1A2B3C4D", false)
|
||||
nullSerialID := insertRawDevice(t, tdb, "", owner, "", false)
|
||||
|
||||
require.NoError(t, db.RunMigrations(tdb.Cfg, discardLog()), "re-up 0005")
|
||||
|
||||
// 0005 data migration 對「所有未刪除 device」一律建 agent(agent_id=自己 id)+ 標 representative。
|
||||
// 故有 serial 的 device 也會被遷移——如實記錄其遷移後狀態。
|
||||
var (
|
||||
serialAgentID string
|
||||
serialIsRep bool
|
||||
serialNumberKept string
|
||||
)
|
||||
err = tdb.Pool.QueryRow(ctx,
|
||||
`SELECT agent_id, is_representative, COALESCE(serial_number, '') FROM devices WHERE id = $1`,
|
||||
serialID).Scan(&serialAgentID, &serialIsRep, &serialNumberKept)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 遷移行為斷言(如實反映 0005 現況):
|
||||
assert.Equal(t, serialID, serialAgentID, "有 serial 的未刪除 device 也建 agent(agent_id=自己 id)")
|
||||
assert.True(t, serialIsRep, "0005 對所有未刪除 device 一律標 is_representative=true(含有 serial 者)")
|
||||
assert.Equal(t, "0x1A2B3C4D", serialNumberKept, "serial_number 未被 0005 清除(保留原值)")
|
||||
|
||||
// 無 serial 的未刪除 device:正常遷移為 representative。
|
||||
var nullIsRep bool
|
||||
err = tdb.Pool.QueryRow(ctx,
|
||||
`SELECT is_representative FROM devices WHERE id = $1`, nullSerialID).Scan(&nullIsRep)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, nullIsRep, "無 serial 未刪除 device 標 representative=true")
|
||||
|
||||
// agents 表恰 2 筆(兩筆未刪除 device 各建一 agent)。
|
||||
assert.Equal(t, 2, tdb.CountRows(t, "agents"))
|
||||
}
|
||||
|
||||
// TestMigrate0005_Q4_ContradictoryStateDetection 專驗 Q4 矛盾態:apply 0005 前若 DB 已有
|
||||
// WP-0 產生的「有 serial device」,0005 data migration 是否製造「is_representative=true 但
|
||||
// serial_number≠NULL」的矛盾態,以及實際發生幾筆。
|
||||
//
|
||||
// ⚠️ 本測試只**驗證並量化**矛盾態是否發生(B5 任務要求),不擅自改 0005 或寫 0006——
|
||||
// 補償與否由 architect / 使用者依本結果裁決(見 wp-b-scope §5 Q4)。
|
||||
func TestMigrate0005_Q4_ContradictoryStateDetection(t *testing.T) {
|
||||
tdb := testsupport.SetupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
mg, err := db.NewMigrator(tdb.Cfg, discardLog())
|
||||
require.NoError(t, err)
|
||||
defer mg.Close()
|
||||
|
||||
require.NoError(t, mg.Down(), "down 回 0004(apply 0005 前狀態)")
|
||||
|
||||
owner := tdb.InsertUser(t, "", "")
|
||||
|
||||
// 模擬 apply 0005 前 DB 已有的 device 分佈:
|
||||
// - 2 筆有 serial 的未刪除 device(WP-0 exchange 落的真 USB)。
|
||||
// - 1 筆無 serial 的未刪除 device(舊連線佔位)。
|
||||
// - 1 筆有 serial 的 soft-deleted device(不遷移)。
|
||||
insertRawDevice(t, tdb, "", owner, "0x1A2B3C4D", false)
|
||||
insertRawDevice(t, tdb, "", owner, "0x0E5F6071", false)
|
||||
insertRawDevice(t, tdb, "", owner, "", false)
|
||||
insertRawDevice(t, tdb, "", owner, "0xDEADBEEF", true) // soft-deleted
|
||||
|
||||
require.NoError(t, db.RunMigrations(tdb.Cfg, discardLog()), "apply 0005")
|
||||
|
||||
// 量化「矛盾態」:is_representative=true 且 serial_number IS NOT NULL 且未刪除。
|
||||
var contradictoryCount int
|
||||
err = tdb.Pool.QueryRow(ctx,
|
||||
`SELECT count(*) FROM devices
|
||||
WHERE is_representative = true AND serial_number IS NOT NULL AND deleted_at IS NULL`).Scan(&contradictoryCount)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 依 0005 現行 data migration 邏輯(對所有未刪除 device 一律標 representative=true),
|
||||
// 上述 2 筆有 serial 的未刪除 device 會落入矛盾態。本測試釘住此事實供 architect 裁決。
|
||||
// 若未來 0006 補償(如 WHERE serial_number IS NULL 排除有 serial 者),此斷言需同步更新。
|
||||
assert.Equal(t, 2, contradictoryCount,
|
||||
"Q4 矛盾態:apply 0005 前已有的『有 serial 未刪除 device』被標為 representative=true(2 筆)")
|
||||
|
||||
// soft-deleted 的有 serial device 不遷移(不計入矛盾態)。
|
||||
var deletedRepCount int
|
||||
err = tdb.Pool.QueryRow(ctx,
|
||||
`SELECT count(*) FROM devices
|
||||
WHERE is_representative = true AND deleted_at IS NOT NULL`).Scan(&deletedRepCount)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, deletedRepCount, "soft-deleted device 不應被標 representative")
|
||||
}
|
||||
|
||||
@ -9,6 +9,8 @@ import (
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"visiona-backend/internal/db"
|
||||
)
|
||||
|
||||
// ==========================================================================
|
||||
@ -61,6 +63,13 @@ const (
|
||||
// - RemoteStatus(tunnel-level):雲端觀察到的 tunnel 連線狀態
|
||||
//
|
||||
// 前端優先顯示 RemoteStatus,次要顯示 Status(見 TDD §10.5.1)。
|
||||
//
|
||||
// A' 模型欄位(ADR-018 / migration 0005,WP-B B1 加入):
|
||||
// - AgentID:所屬 agent(一條 tunnel 連線)。NULL=遷移前舊資料 / 尚未歸入 agent。
|
||||
// - AgentLocalDeviceID:local agent 端合成 id(如 kl520-0),路由/除錯輔助。
|
||||
// - RegisteredAt:註冊軸(NULL=未註冊)。連線軸 × 註冊軸 → 前端三色(WP-F)。
|
||||
// - IsRepresentative:true=agent 佔位/代表 device(非真 USB,綁 session_tokens);
|
||||
// false=真實 USB device。List/Get 只列 false 者(WP-B B4)。
|
||||
type Device struct {
|
||||
ID string `json:"id"`
|
||||
OwnerUserID string `json:"ownerUserId"`
|
||||
@ -68,6 +77,12 @@ type Device struct {
|
||||
DeviceType string `json:"deviceType"`
|
||||
SerialNumber string `json:"serialNumber,omitempty"`
|
||||
|
||||
// A' 模型(agents 掛載 + 註冊軸 + representative 區分;migration 0005)
|
||||
AgentID string `json:"agentId,omitempty"`
|
||||
AgentLocalDeviceID string `json:"agentLocalDeviceId,omitempty"`
|
||||
RegisteredAt *time.Time `json:"registeredAt,omitempty"`
|
||||
IsRepresentative bool `json:"isRepresentative"`
|
||||
|
||||
// tunnel-level 狀態
|
||||
RemoteStatus RemoteStatus `json:"remoteStatus"`
|
||||
LastSeenAt *time.Time `json:"lastSeenAt,omitempty"`
|
||||
@ -139,7 +154,14 @@ func (r *InMemoryRepository) Get(ctx context.Context, id string) (*Device, error
|
||||
}
|
||||
|
||||
// GetBySerial 以 (owner, serial) 查詢。
|
||||
//
|
||||
// 空 serial 分支(WP-0 Mi#4,對齊 PostgresRepository.GetBySerialTx):以序號查詢對「空序號」
|
||||
// 無業務意義,直接回 ErrNotFound(不再用空字串比對撈到 representative / 佔位 device)。
|
||||
func (r *InMemoryRepository) GetBySerial(ctx context.Context, ownerUserID, serial string) (*Device, error) {
|
||||
if serial == "" {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
@ -155,14 +177,17 @@ func (r *InMemoryRepository) GetBySerial(ctx context.Context, ownerUserID, seria
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
// List 列出某 user 的所有未刪除 device。
|
||||
// List 列出某 user 的所有未刪除、非 representative 的 device(WP-B B4)。
|
||||
//
|
||||
// is_representative=false filter 對齊 PostgresRepository.List:representative device
|
||||
// (agent 連線佔位)不出現在使用者裝置清單,只列真實 USB device。
|
||||
func (r *InMemoryRepository) List(ctx context.Context, ownerUserID string) ([]*Device, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
out := make([]*Device, 0)
|
||||
for _, d := range r.devices {
|
||||
if d.DeletedAt != nil {
|
||||
if d.DeletedAt != nil || d.IsRepresentative {
|
||||
continue
|
||||
}
|
||||
if d.OwnerUserID == ownerUserID {
|
||||
@ -206,6 +231,31 @@ func (r *InMemoryRepository) Save(ctx context.Context, d *Device) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetRepresentativeByAgentTx 取得某 agent 的 representative device(is_representative=true、
|
||||
// 未刪除);不存在回 ErrNotFound。in-memory 忽略 q(無交易需求)。
|
||||
//
|
||||
// 語意對齊 PostgresRepository:一 agent 一顆 representative;多筆殘留時取 CreatedAt 最早者
|
||||
// 保決定性(map 迭代順序不定,故顯式挑最早)。
|
||||
func (r *InMemoryRepository) GetRepresentativeByAgentTx(_ context.Context, _ db.Querier, agentID string) (*Device, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
var found *Device
|
||||
for _, d := range r.devices {
|
||||
if d.DeletedAt != nil || !d.IsRepresentative || d.AgentID != agentID {
|
||||
continue
|
||||
}
|
||||
if found == nil || d.CreatedAt.Before(found.CreatedAt) {
|
||||
found = d
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
cp := *found
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
// Delete 標記 device 為軟刪除。
|
||||
func (r *InMemoryRepository) Delete(ctx context.Context, id string) error {
|
||||
r.mu.Lock()
|
||||
|
||||
@ -118,3 +118,59 @@ func TestInMemoryRepository_Save_PreservesCreatedAt(t *testing.T) {
|
||||
assert.Equal(t, createdAt, got.CreatedAt, "CreatedAt 應保留原值")
|
||||
assert.True(t, got.UpdatedAt.After(createdAt) || got.UpdatedAt.Equal(createdAt))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// B4(List filter representative + Mi#4 空 serial + representative 查詢)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// List filter:representative device 不出現在清單(對齊 PG 版)。
|
||||
func TestInMemoryRepository_List_FiltersRepresentative(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r := NewInMemoryRepository()
|
||||
owner := "owner-1"
|
||||
|
||||
require.NoError(t, r.Save(ctx, &Device{ID: "rep", OwnerUserID: owner, Name: "rep", AgentID: "ag", IsRepresentative: true}))
|
||||
require.NoError(t, r.Save(ctx, &Device{ID: "usb1", OwnerUserID: owner, Name: "usb1", SerialNumber: "0x11111111", AgentID: "ag"}))
|
||||
require.NoError(t, r.Save(ctx, &Device{ID: "usb2", OwnerUserID: owner, Name: "usb2", SerialNumber: "0x22222222", AgentID: "ag"}))
|
||||
|
||||
list, err := r.List(ctx, owner)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, list, 2, "List 只回真 USB(排除 representative)")
|
||||
for _, d := range list {
|
||||
assert.False(t, d.IsRepresentative)
|
||||
}
|
||||
}
|
||||
|
||||
// Mi#4:in-memory GetBySerial 空 serial → ErrNotFound(對齊 PG GetBySerialTx)。
|
||||
func TestInMemoryRepository_GetBySerial_EmptyReturnsNotFound(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r := NewInMemoryRepository()
|
||||
owner := "owner-1"
|
||||
|
||||
require.NoError(t, r.Save(ctx, &Device{ID: "null1", OwnerUserID: owner, Name: "null1"})) // serial 空
|
||||
_, err := r.GetBySerial(ctx, owner, "")
|
||||
assert.ErrorIs(t, err, ErrNotFound, "空 serial 查詢應回 ErrNotFound")
|
||||
}
|
||||
|
||||
// GetRepresentativeByAgentTx:找該 agent 的 representative device。
|
||||
func TestInMemoryRepository_GetRepresentativeByAgent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r := NewInMemoryRepository()
|
||||
owner := "owner-1"
|
||||
|
||||
// 無 representative 時回 ErrNotFound。
|
||||
_, err := r.GetRepresentativeByAgentTx(ctx, nil, "ag")
|
||||
assert.ErrorIs(t, err, ErrNotFound)
|
||||
|
||||
require.NoError(t, r.Save(ctx, &Device{ID: "rep", OwnerUserID: owner, Name: "rep", AgentID: "ag", IsRepresentative: true}))
|
||||
require.NoError(t, r.Save(ctx, &Device{ID: "usb", OwnerUserID: owner, Name: "usb", SerialNumber: "0x11111111", AgentID: "ag"}))
|
||||
|
||||
got, err := r.GetRepresentativeByAgentTx(ctx, nil, "ag")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "rep", got.ID, "應回 representative device(非真 USB)")
|
||||
assert.True(t, got.IsRepresentative)
|
||||
|
||||
// 不同 agent 查不到。
|
||||
_, err = r.GetRepresentativeByAgentTx(ctx, nil, "other-agent")
|
||||
assert.ErrorIs(t, err, ErrNotFound)
|
||||
}
|
||||
|
||||
@ -54,9 +54,13 @@ func NewPostgresRepository(pool *pgxpool.Pool) *PostgresRepository {
|
||||
var _ Repository = (*PostgresRepository)(nil)
|
||||
|
||||
// deviceColumns 是 SELECT 共用的欄位清單(順序必須與 scanDevice 對齊)。
|
||||
//
|
||||
// A' 模型(migration 0005)新增 4 欄,接在原 13 欄之後(agent_id / agent_local_device_id /
|
||||
// registered_at / is_representative)。順序改動時 scanDevice 的 Scan 目標順序須同步(WP-B B1)。
|
||||
const deviceColumns = `id, owner_user_id, name, device_type, serial_number,
|
||||
remote_status, last_seen_at, last_connected_at, status,
|
||||
created_at, updated_at, paired_at, deleted_at`
|
||||
created_at, updated_at, paired_at, deleted_at,
|
||||
agent_id, agent_local_device_id, registered_at, is_representative`
|
||||
|
||||
// Get 取得單一 device;不存在或已軟刪除回 ErrNotFound。
|
||||
func (r *PostgresRepository) Get(ctx context.Context, id string) (*Device, error) {
|
||||
@ -83,27 +87,30 @@ func (r *PostgresRepository) Get(ctx context.Context, id string) (*Device, error
|
||||
// serial_number IS NULL 比對(等號比較永不命中 NULL)。非空 serial 走參數化 = $2。
|
||||
// 此分支讓 PG 與 in-memory(d.SerialNumber == serial,空查空)語意一致。
|
||||
func (r *PostgresRepository) GetBySerial(ctx context.Context, ownerUserID, serial string) (*Device, error) {
|
||||
if serial == "" {
|
||||
const qNull = `SELECT ` + deviceColumns + `
|
||||
FROM devices
|
||||
WHERE owner_user_id = $1 AND serial_number IS NULL AND deleted_at IS NULL`
|
||||
return r.GetBySerialTx(ctx, r.pool, ownerUserID, serial)
|
||||
}
|
||||
|
||||
row := r.pool.QueryRow(ctx, qNull, ownerUserID)
|
||||
d, err := scanDevice(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
// GetBySerialTx 與 GetBySerial 相同語意,但在傳入的 Querier(pool 或 tx)上執行。
|
||||
//
|
||||
// WP-B B3(Mi#2 lost-update 收斂):exchange 復用真 USB device 時,把「查 serial → 復用/建」
|
||||
// 都放進同一交易(先前 GetBySerial 走 pool、與 SaveTx 之間有 race window)。tx 內查詢讓
|
||||
// 「查到既有 → 復用」與後續寫入在同一快照下序列化,撞 partial unique 的機率降到只剩跨 tx
|
||||
// 的並發配對(實務上同一 agent 序列配對,可接受),且撞到時整筆 rollback 不產生重複。
|
||||
//
|
||||
// 空 serial 分支(WP-0 Mi#4 收斂):以序號查詢對「空序號」無業務意義(呼叫端一律帶已正規化
|
||||
// 的非空 serial)。空 serial 直接回 ErrNotFound——不再掃「serial IS NULL」的多筆 representative
|
||||
// / 佔位 device(那些不是「以序號識別的實體 USB」,撈出來也無意義且無 ORDER BY 為非決定性)。
|
||||
func (r *PostgresRepository) GetBySerialTx(ctx context.Context, q db.Querier, ownerUserID, serial string) (*Device, error) {
|
||||
if serial == "" {
|
||||
// 空 serial 無法以序號識別實體 USB(Mi#4):直接回 ErrNotFound,避免非決定性多筆掃描。
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("device: pg GetBySerial: %w", err)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
const q = `SELECT ` + deviceColumns + `
|
||||
const sql = `SELECT ` + deviceColumns + `
|
||||
FROM devices
|
||||
WHERE owner_user_id = $1 AND serial_number = $2 AND deleted_at IS NULL`
|
||||
|
||||
row := r.pool.QueryRow(ctx, q, ownerUserID, serial)
|
||||
row := q.QueryRow(ctx, sql, ownerUserID, serial)
|
||||
d, err := scanDevice(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
@ -114,11 +121,20 @@ func (r *PostgresRepository) GetBySerial(ctx context.Context, ownerUserID, seria
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// List 列出某 owner 的所有未刪除 device,以 created_at DESC 排序(最新在前)。
|
||||
// List 列出某 owner 的所有未刪除、**非 representative** 的 device(WP-B B4),以 created_at
|
||||
// DESC 排序(最新在前)。
|
||||
//
|
||||
// is_representative=false filter(A' 模型,ADR-018 §2.5 / migration-0005-spec §4.3):
|
||||
//
|
||||
// representative device 是「agent 連線佔位」(綁 session_tokens),不是真實 USB,不該出現在
|
||||
// 使用者的裝置清單。遷移後所有舊 device 都被標為 representative(0005 data migration),若不
|
||||
// filter,UI 會把這些佔位 device 當 USB 顯示。故 List 只回真 USB device(is_representative=false)。
|
||||
// 精確查詢(Get / GetBySerialTx / GetRepresentativeByAgentTx)不受此 filter 影響——它們是
|
||||
// by-id / by-serial / by-agent 的通用查詢,用途包含查 representative 本身。
|
||||
func (r *PostgresRepository) List(ctx context.Context, ownerUserID string) ([]*Device, error) {
|
||||
const q = `SELECT ` + deviceColumns + `
|
||||
FROM devices
|
||||
WHERE owner_user_id = $1 AND deleted_at IS NULL
|
||||
WHERE owner_user_id = $1 AND deleted_at IS NULL AND is_representative = false
|
||||
ORDER BY created_at DESC`
|
||||
|
||||
rows, err := r.pool.Query(ctx, q, ownerUserID)
|
||||
@ -203,15 +219,32 @@ func (r *PostgresRepository) SaveTx(ctx context.Context, q db.Querier, d *Device
|
||||
serialNumber = d.SerialNumber
|
||||
} // else: 留 nil → 寫入 SQL NULL
|
||||
|
||||
// A' 新欄(migration 0005,WP-B B2):
|
||||
// - agent_id:空字串寫 SQL NULL(agent_id 是 nullable UUID FK→agents;空字串無法 cast
|
||||
// UUID 會炸,且「未掛 agent」的語意正是 NULL——遷移前舊資料 / 尚未歸入 agent 的 device)。
|
||||
// - agent_local_device_id:空字串寫 NULL(nullable TEXT,對齊 serial_number 慣例)。
|
||||
// - registered_at:*time.Time nil → SQL NULL(pgx 直接處理),非 nil 寫值(註冊軸)。
|
||||
// - is_representative:NOT NULL BOOLEAN,bool 直接寫(zero value false = 真 USB / 未指定)。
|
||||
var agentID any
|
||||
if d.AgentID != "" {
|
||||
agentID = d.AgentID
|
||||
} // else: 留 nil → SQL NULL
|
||||
var agentLocalDeviceID any
|
||||
if d.AgentLocalDeviceID != "" {
|
||||
agentLocalDeviceID = d.AgentLocalDeviceID
|
||||
} // else: 留 nil → SQL NULL
|
||||
|
||||
const sql = `
|
||||
INSERT INTO devices (
|
||||
id, owner_user_id, name, device_type, serial_number,
|
||||
remote_status, last_seen_at, last_connected_at, status,
|
||||
created_at, updated_at, paired_at, deleted_at
|
||||
created_at, updated_at, paired_at, deleted_at,
|
||||
agent_id, agent_local_device_id, registered_at, is_representative
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5,
|
||||
$6, $7, $8, $9,
|
||||
COALESCE($10, now()), now(), $11, $12
|
||||
COALESCE($10, now()), now(), $11, $12,
|
||||
$13, $14, $15, $16
|
||||
)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
owner_user_id = EXCLUDED.owner_user_id,
|
||||
@ -229,7 +262,11 @@ func (r *PostgresRepository) SaveTx(ctx context.Context, q db.Querier, d *Device
|
||||
END,
|
||||
updated_at = now(),
|
||||
paired_at = EXCLUDED.paired_at,
|
||||
deleted_at = EXCLUDED.deleted_at`
|
||||
deleted_at = EXCLUDED.deleted_at,
|
||||
agent_id = EXCLUDED.agent_id,
|
||||
agent_local_device_id = EXCLUDED.agent_local_device_id,
|
||||
registered_at = EXCLUDED.registered_at,
|
||||
is_representative = EXCLUDED.is_representative`
|
||||
|
||||
_, err := q.Exec(ctx, sql,
|
||||
d.ID, // $1
|
||||
@ -244,6 +281,10 @@ func (r *PostgresRepository) SaveTx(ctx context.Context, q db.Querier, d *Device
|
||||
createdAt, // $10
|
||||
d.PairedAt, // $11
|
||||
d.DeletedAt, // $12
|
||||
agentID, // $13
|
||||
agentLocalDeviceID, // $14
|
||||
d.RegisteredAt, // $15
|
||||
d.IsRepresentative, // $16
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("device: pg Save upsert: %w", err)
|
||||
@ -251,6 +292,30 @@ func (r *PostgresRepository) SaveTx(ctx context.Context, q db.Querier, d *Device
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetRepresentativeByAgentTx 取得某 agent 的 representative device(is_representative=true、
|
||||
// 未刪除);不存在回 ErrNotFound(在傳入 Querier / tx 上執行)。
|
||||
//
|
||||
// A' 語意(ADR-018 §2.4):一 agent 一顆 representative device,綁 session_tokens。exchange
|
||||
// 復用 agent 時用它找既有 representative 復用(避免每次配對長出一顆新 representative)。
|
||||
// 一 agent 最多一顆 representative;仍加 ORDER BY created_at + LIMIT 1 保決定性。
|
||||
func (r *PostgresRepository) GetRepresentativeByAgentTx(ctx context.Context, q db.Querier, agentID string) (*Device, error) {
|
||||
const sql = `SELECT ` + deviceColumns + `
|
||||
FROM devices
|
||||
WHERE agent_id = $1 AND is_representative = true AND deleted_at IS NULL
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1`
|
||||
|
||||
row := q.QueryRow(ctx, sql, agentID)
|
||||
d, err := scanDevice(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("device: pg GetRepresentativeByAgent: %w", err)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// Delete 軟刪除:寫 deleted_at = now()。已刪除或不存在回 ErrNotFound。
|
||||
//
|
||||
// 直接在 pool 上跑(自動 commit)。若需與 token cascade 撤銷在同一交易內,請改用 DeleteTx。
|
||||
@ -291,14 +356,20 @@ type rowScanner interface {
|
||||
|
||||
// scanDevice 從一列掃出 *Device。欄位順序必須與 deviceColumns 對齊。
|
||||
//
|
||||
// nullable TEXT 欄位(device_type / serial_number)在 DB 為 NULL 時掃進空字串(對齊
|
||||
// in-memory zero value);nullable TIMESTAMPTZ(last_seen_at / last_connected_at /
|
||||
// paired_at / deleted_at)以 *time.Time 接,NULL → nil。
|
||||
// nullable TEXT 欄位(device_type / serial_number / agent_id / agent_local_device_id)在 DB
|
||||
// 為 NULL 時掃進空字串(對齊 in-memory zero value);nullable TIMESTAMPTZ(last_seen_at /
|
||||
// last_connected_at / paired_at / deleted_at / registered_at)以 *time.Time 接,NULL → nil。
|
||||
// is_representative 為 NOT NULL BOOLEAN(DEFAULT false),直接掃進 bool。
|
||||
//
|
||||
// A' 新欄(migration 0005,WP-B B1):agent_id / agent_local_device_id 以 nullable *string
|
||||
// 接(遷移前舊資料 / 真 USB 未掛 agent 時為 NULL),registered_at 以 *time.Time 接(NULL=未註冊)。
|
||||
func scanDevice(row rowScanner) (*Device, error) {
|
||||
var (
|
||||
d Device
|
||||
deviceType *string
|
||||
serialNumber *string
|
||||
agentID *string
|
||||
agentLocalDeviceID *string
|
||||
)
|
||||
|
||||
err := row.Scan(
|
||||
@ -315,6 +386,10 @@ func scanDevice(row rowScanner) (*Device, error) {
|
||||
&d.UpdatedAt,
|
||||
&d.PairedAt,
|
||||
&d.DeletedAt,
|
||||
&agentID,
|
||||
&agentLocalDeviceID,
|
||||
&d.RegisteredAt,
|
||||
&d.IsRepresentative,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -322,6 +397,8 @@ func scanDevice(row rowScanner) (*Device, error) {
|
||||
|
||||
d.DeviceType = derefString(deviceType)
|
||||
d.SerialNumber = derefString(serialNumber)
|
||||
d.AgentID = derefString(agentID)
|
||||
d.AgentLocalDeviceID = derefString(agentLocalDeviceID)
|
||||
|
||||
// 正規化時間為 UTC,對齊 in-memory(time.Now().UTC())。
|
||||
d.CreatedAt = d.CreatedAt.UTC()
|
||||
@ -342,6 +419,10 @@ func scanDevice(row rowScanner) (*Device, error) {
|
||||
t := d.DeletedAt.UTC()
|
||||
d.DeletedAt = &t
|
||||
}
|
||||
if d.RegisteredAt != nil {
|
||||
t := d.RegisteredAt.UTC()
|
||||
d.RegisteredAt = &t
|
||||
}
|
||||
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
@ -409,6 +409,321 @@ func TestPG_ConcurrentSaveSameID(t *testing.T) {
|
||||
assert.Equal(t, 1, tdb.CountRows(t, "devices"), "併發 upsert 同 id 應只有一列")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// B1(A' 模型 scan 讀新欄;migration 0005)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// scanDevice 讀 17 欄回歸:既有 SaveTx 只寫舊 13 欄,新 4 欄(agent_id /
|
||||
// agent_local_device_id / registered_at / is_representative)走 DB DEFAULT / nullable。
|
||||
// B1 只擴充 scan(不寫新欄),此測驗證 Save→Get round-trip 後新欄讀回預設空值:
|
||||
// - agent_id / agent_local_device_id → NULL → 空字串(derefString)
|
||||
// - registered_at → NULL → nil
|
||||
// - is_representative → DEFAULT false
|
||||
func TestPG_ScanNewColumns_DefaultsAfterSave(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r, _, owner := newPGRepo(t)
|
||||
|
||||
id := uuid.NewString()
|
||||
require.NoError(t, r.Save(ctx, &Device{ID: id, OwnerUserID: owner, Name: "b1", SerialNumber: "SN-B1"}))
|
||||
|
||||
got, err := r.Get(ctx, id)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, got.AgentID, "既有 Save 不帶 agent_id → 讀回空字串(NULL)")
|
||||
assert.Empty(t, got.AgentLocalDeviceID, "既有 Save 不帶 agent_local_device_id → 讀回空字串(NULL)")
|
||||
assert.Nil(t, got.RegisteredAt, "既有 Save 不帶 registered_at → 讀回 nil(NULL)")
|
||||
assert.False(t, got.IsRepresentative, "is_representative 應為 DEFAULT false")
|
||||
}
|
||||
|
||||
// scanDevice 讀 17 欄:直接以 SQL 塞入帶新欄值的 device(模擬 migration 0005 data migration
|
||||
// 產生的 representative device / 未來 B2 寫入的真 USB),驗證 scan 正確讀回四個新欄。
|
||||
// 用 SQL 直塞而非 repo.Save,因 B1 的 Save 尚未寫新欄(B2 才寫)。
|
||||
func TestPG_ScanNewColumns_ReadsPopulatedValues(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r, tdb, owner := newPGRepo(t)
|
||||
|
||||
// 先建一個 agent(供 devices.agent_id FK 參照)。
|
||||
agentID := uuid.NewString()
|
||||
_, err := tdb.Pool.Exec(ctx,
|
||||
`INSERT INTO agents (id, owner_user_id, name) VALUES ($1, $2, 'local-agent')`,
|
||||
agentID, owner)
|
||||
require.NoError(t, err)
|
||||
|
||||
registeredAt := time.Now().Add(-2 * time.Hour).UTC().Truncate(time.Microsecond)
|
||||
devID := uuid.NewString()
|
||||
_, err = tdb.Pool.Exec(ctx,
|
||||
`INSERT INTO devices
|
||||
(id, owner_user_id, name, agent_id, agent_local_device_id, registered_at, is_representative)
|
||||
VALUES ($1, $2, 'usb-0', $3, 'kl520-0', $4, false)`,
|
||||
devID, owner, agentID, registeredAt)
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := r.Get(ctx, devID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, agentID, got.AgentID, "agent_id 應讀回")
|
||||
assert.Equal(t, "kl520-0", got.AgentLocalDeviceID, "agent_local_device_id 應讀回")
|
||||
require.NotNil(t, got.RegisteredAt, "registered_at 應非 nil")
|
||||
assert.True(t, registeredAt.Equal(*got.RegisteredAt), "registered_at round-trip")
|
||||
assert.False(t, got.IsRepresentative, "is_representative=false 應讀回")
|
||||
|
||||
// 另塞一筆 representative device(is_representative=true),驗 bool 讀回。
|
||||
repID := uuid.NewString()
|
||||
_, err = tdb.Pool.Exec(ctx,
|
||||
`INSERT INTO devices (id, owner_user_id, name, agent_id, is_representative)
|
||||
VALUES ($1, $2, 'rep', $3, true)`,
|
||||
repID, owner, agentID)
|
||||
require.NoError(t, err)
|
||||
|
||||
rep, err := r.Get(ctx, repID)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, rep.IsRepresentative, "representative device 的 is_representative=true 應讀回")
|
||||
assert.Nil(t, rep.RegisteredAt, "未設 registered_at → nil")
|
||||
}
|
||||
|
||||
// 0005-Mi#2:is_representative 為 NOT NULL 約束——直接 INSERT is_representative=NULL 應失敗。
|
||||
// migration 0005 的 ALTER ... ADD COLUMN is_representative BOOLEAN NOT NULL DEFAULT false,
|
||||
// 顯式寫 NULL 覆蓋 DEFAULT 時應被 NOT NULL 約束擋下(23502 not_null_violation)。
|
||||
func TestPG_IsRepresentative_NotNullConstraint(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
_, tdb, owner := newPGRepo(t)
|
||||
|
||||
_, err := tdb.Pool.Exec(ctx,
|
||||
`INSERT INTO devices (id, owner_user_id, name, is_representative)
|
||||
VALUES ($1, $2, 'null-rep', NULL)`,
|
||||
uuid.NewString(), owner)
|
||||
require.Error(t, err, "is_representative=NULL 應違反 NOT NULL 約束")
|
||||
|
||||
var pgErr *pgconn.PgError
|
||||
require.ErrorAs(t, err, &pgErr)
|
||||
assert.Equal(t, "23502", pgErr.Code, "應為 not_null_violation")
|
||||
assert.Equal(t, "is_representative", pgErr.ColumnName)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// B2(SaveTx 寫新欄;migration 0005)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// SaveTx 寫入 A' 新 4 欄:帶 agent_id / agent_local_device_id / registered_at /
|
||||
// is_representative 的 device,Save→Get round-trip 後四欄都正確寫入 + 讀回。
|
||||
func TestPG_SaveTx_WritesNewColumns(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r, tdb, owner := newPGRepo(t)
|
||||
|
||||
// 先建 agent 供 FK。
|
||||
agentID := uuid.NewString()
|
||||
_, err := tdb.Pool.Exec(ctx,
|
||||
`INSERT INTO agents (id, owner_user_id, name) VALUES ($1, $2, 'local-agent')`,
|
||||
agentID, owner)
|
||||
require.NoError(t, err)
|
||||
|
||||
registeredAt := time.Now().Add(-30 * time.Minute).UTC().Truncate(time.Microsecond)
|
||||
id := uuid.NewString()
|
||||
require.NoError(t, r.Save(ctx, &Device{
|
||||
ID: id,
|
||||
OwnerUserID: owner,
|
||||
Name: "usb-real",
|
||||
DeviceType: "kl520",
|
||||
SerialNumber: "0x1A2B3C4D",
|
||||
AgentID: agentID,
|
||||
AgentLocalDeviceID: "kl520-0",
|
||||
RegisteredAt: ®isteredAt,
|
||||
IsRepresentative: false,
|
||||
}))
|
||||
|
||||
got, err := r.Get(ctx, id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, agentID, got.AgentID)
|
||||
assert.Equal(t, "kl520-0", got.AgentLocalDeviceID)
|
||||
require.NotNil(t, got.RegisteredAt)
|
||||
assert.True(t, registeredAt.Equal(*got.RegisteredAt), "registered_at round-trip")
|
||||
assert.False(t, got.IsRepresentative)
|
||||
}
|
||||
|
||||
// SaveTx 寫 representative device(is_representative=true、serial=NULL、掛 agent)。
|
||||
func TestPG_SaveTx_WritesRepresentative(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r, tdb, owner := newPGRepo(t)
|
||||
|
||||
agentID := uuid.NewString()
|
||||
_, err := tdb.Pool.Exec(ctx,
|
||||
`INSERT INTO agents (id, owner_user_id, name) VALUES ($1, $2, 'local-agent')`,
|
||||
agentID, owner)
|
||||
require.NoError(t, err)
|
||||
|
||||
id := uuid.NewString()
|
||||
require.NoError(t, r.Save(ctx, &Device{
|
||||
ID: id,
|
||||
OwnerUserID: owner,
|
||||
Name: "local-agent (paired)",
|
||||
DeviceType: "local-agent",
|
||||
AgentID: agentID,
|
||||
IsRepresentative: true,
|
||||
// SerialNumber 留空 → NULL;RegisteredAt 留 nil → NULL
|
||||
}))
|
||||
|
||||
got, err := r.Get(ctx, id)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, got.IsRepresentative)
|
||||
assert.Empty(t, got.SerialNumber)
|
||||
assert.Nil(t, got.RegisteredAt)
|
||||
assert.Equal(t, agentID, got.AgentID)
|
||||
|
||||
// 直接查 DB 確認 serial_number 為 NULL(representative 不佔 unique)。
|
||||
var serialIsNull bool
|
||||
require.NoError(t, tdb.Pool.QueryRow(ctx,
|
||||
`SELECT serial_number IS NULL FROM devices WHERE id = $1`, id).Scan(&serialIsNull))
|
||||
assert.True(t, serialIsNull, "representative device serial 應為 NULL")
|
||||
}
|
||||
|
||||
// SaveTx upsert 更新新欄:第二次 Save 同 id 改 registered_at / is_representative,應更新。
|
||||
// 這是註冊軸的關鍵路徑(WP-D 註冊會 UPDATE registered_at);B2 先確保 upsert 能改這些欄。
|
||||
func TestPG_SaveTx_UpsertUpdatesNewColumns(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r, tdb, owner := newPGRepo(t)
|
||||
|
||||
agentID := uuid.NewString()
|
||||
_, err := tdb.Pool.Exec(ctx,
|
||||
`INSERT INTO agents (id, owner_user_id, name) VALUES ($1, $2, 'local-agent')`,
|
||||
agentID, owner)
|
||||
require.NoError(t, err)
|
||||
|
||||
id := uuid.NewString()
|
||||
// 初次:未註冊(registered_at nil)。
|
||||
require.NoError(t, r.Save(ctx, &Device{
|
||||
ID: id, OwnerUserID: owner, Name: "usb", SerialNumber: "0xAABBCCDD",
|
||||
AgentID: agentID, IsRepresentative: false,
|
||||
}))
|
||||
first, err := r.Get(ctx, id)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, first.RegisteredAt)
|
||||
|
||||
// 二次:設 registered_at(模擬註冊)。
|
||||
registeredAt := time.Now().UTC().Truncate(time.Microsecond)
|
||||
require.NoError(t, r.Save(ctx, &Device{
|
||||
ID: id, OwnerUserID: owner, Name: "usb", SerialNumber: "0xAABBCCDD",
|
||||
AgentID: agentID, IsRepresentative: false, RegisteredAt: ®isteredAt,
|
||||
}))
|
||||
second, err := r.Get(ctx, id)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, second.RegisteredAt, "upsert 應更新 registered_at")
|
||||
assert.True(t, registeredAt.Equal(*second.RegisteredAt))
|
||||
_ = tdb
|
||||
}
|
||||
|
||||
// SaveTx 帶不存在的 agent_id → FK violation(23503)。守住 agent_id FK 完整性。
|
||||
func TestPG_SaveTx_BadAgentIDFKViolation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r, _, owner := newPGRepo(t)
|
||||
|
||||
err := r.Save(ctx, &Device{
|
||||
ID: uuid.NewString(), OwnerUserID: owner, Name: "orphan",
|
||||
SerialNumber: "0x11223344", AgentID: uuid.NewString(), // 不存在的 agent
|
||||
})
|
||||
require.Error(t, err, "agent_id 指向不存在的 agent 應違反 FK")
|
||||
|
||||
var pgErr *pgconn.PgError
|
||||
require.ErrorAs(t, err, &pgErr)
|
||||
assert.Equal(t, "23503", pgErr.Code, "應為 foreign_key_violation")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// B4(List filter representative + Mi#4 空 serial)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// List filter:representative device 不出現在清單,只列真 USB device(is_representative=false)。
|
||||
func TestPG_List_FiltersRepresentative(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r, tdb, owner := newPGRepo(t)
|
||||
|
||||
agentID := uuid.NewString()
|
||||
_, err := tdb.Pool.Exec(ctx,
|
||||
`INSERT INTO agents (id, owner_user_id, name) VALUES ($1, $2, 'local-agent')`,
|
||||
agentID, owner)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 1 representative + 2 真 USB。
|
||||
repID := uuid.NewString()
|
||||
require.NoError(t, r.Save(ctx, &Device{
|
||||
ID: repID, OwnerUserID: owner, Name: "rep", AgentID: agentID, IsRepresentative: true,
|
||||
}))
|
||||
usb1 := uuid.NewString()
|
||||
require.NoError(t, r.Save(ctx, &Device{
|
||||
ID: usb1, OwnerUserID: owner, Name: "usb1", SerialNumber: "0x11111111", AgentID: agentID,
|
||||
}))
|
||||
usb2 := uuid.NewString()
|
||||
require.NoError(t, r.Save(ctx, &Device{
|
||||
ID: usb2, OwnerUserID: owner, Name: "usb2", SerialNumber: "0x22222222", AgentID: agentID,
|
||||
}))
|
||||
|
||||
list, err := r.List(ctx, owner)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, list, 2, "List 應只回 2 顆真 USB(排除 representative)")
|
||||
for _, d := range list {
|
||||
assert.False(t, d.IsRepresentative, "List 不應含 representative device")
|
||||
}
|
||||
// DB 實際有 3 筆(含 representative),只是 List filter 掉。
|
||||
assert.Equal(t, 3, tdb.CountRows(t, "devices"))
|
||||
}
|
||||
|
||||
// List 回傳 registered_at / agent_id(供前端三色 / 分組)。
|
||||
func TestPG_List_ReturnsAgentIDAndRegisteredAt(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r, tdb, owner := newPGRepo(t)
|
||||
|
||||
agentID := uuid.NewString()
|
||||
_, err := tdb.Pool.Exec(ctx,
|
||||
`INSERT INTO agents (id, owner_user_id, name) VALUES ($1, $2, 'local-agent')`,
|
||||
agentID, owner)
|
||||
require.NoError(t, err)
|
||||
|
||||
registeredAt := time.Now().Add(-1 * time.Hour).UTC().Truncate(time.Microsecond)
|
||||
require.NoError(t, r.Save(ctx, &Device{
|
||||
ID: uuid.NewString(), OwnerUserID: owner, Name: "registered-usb",
|
||||
SerialNumber: "0x33333333", AgentID: agentID, RegisteredAt: ®isteredAt,
|
||||
}))
|
||||
require.NoError(t, r.Save(ctx, &Device{
|
||||
ID: uuid.NewString(), OwnerUserID: owner, Name: "unregistered-usb",
|
||||
SerialNumber: "0x44444444", AgentID: agentID, // registered_at nil
|
||||
}))
|
||||
|
||||
list, err := r.List(ctx, owner)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, list, 2)
|
||||
|
||||
byName := map[string]*Device{}
|
||||
for _, d := range list {
|
||||
byName[d.Name] = d
|
||||
}
|
||||
require.Contains(t, byName, "registered-usb")
|
||||
require.Contains(t, byName, "unregistered-usb")
|
||||
|
||||
assert.Equal(t, agentID, byName["registered-usb"].AgentID)
|
||||
require.NotNil(t, byName["registered-usb"].RegisteredAt)
|
||||
assert.True(t, registeredAt.Equal(*byName["registered-usb"].RegisteredAt))
|
||||
|
||||
assert.Equal(t, agentID, byName["unregistered-usb"].AgentID)
|
||||
assert.Nil(t, byName["unregistered-usb"].RegisteredAt, "未註冊 device registered_at 應為 nil")
|
||||
}
|
||||
|
||||
// Mi#4:GetBySerialTx 空 serial → 直接回 ErrNotFound(不掃 serial IS NULL 的多筆)。
|
||||
func TestPG_GetBySerialTx_EmptySerialReturnsNotFound(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r, tdb, owner := newPGRepo(t)
|
||||
|
||||
// 塞兩筆 serial=NULL 的 device(representative / 佔位)。
|
||||
require.NoError(t, r.Save(ctx, &Device{ID: uuid.NewString(), OwnerUserID: owner, Name: "null1"}))
|
||||
require.NoError(t, r.Save(ctx, &Device{ID: uuid.NewString(), OwnerUserID: owner, Name: "null2"}))
|
||||
|
||||
// 空 serial 查詢應直接回 ErrNotFound(Mi#4:不因多筆 NULL 而非決定性 / 誤命中)。
|
||||
_, err := r.GetBySerialTx(ctx, tdb.Pool, owner, "")
|
||||
assert.ErrorIs(t, err, ErrNotFound, "空 serial 查詢應回 ErrNotFound")
|
||||
|
||||
// 對照:非空 serial 正常命中。
|
||||
require.NoError(t, r.Save(ctx, &Device{ID: uuid.NewString(), OwnerUserID: owner, Name: "real", SerialNumber: "0x55555555"}))
|
||||
got, err := r.GetBySerialTx(ctx, tdb.Pool, owner, "0x55555555")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "real", got.Name)
|
||||
}
|
||||
|
||||
// context cancel:已取消的 ctx 應讓操作回 error(不 hang、不 panic)。
|
||||
func TestPG_ContextCancel(t *testing.T) {
|
||||
r, _, owner := newPGRepo(t)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user