From 59c57fa48167cdb2b4299181a3553aff77ee797f Mon Sep 17 00:00:00 2001 From: jim800121chen Date: Thu, 16 Jul 2026 11:40:17 +0800 Subject: [PATCH] =?UTF-8?q?feat(device):=20WP-B=20repository=20=E6=8E=A5?= =?UTF-8?q?=20agents=20=E6=A8=A1=E5=9E=8B=20+=20exchange=20=E9=87=8D?= =?UTF-8?q?=E5=A1=91=EF=BC=88A'=20=E8=B5=B0=E5=90=91=E7=AC=AC=E4=BA=8C?= =?UTF-8?q?=E9=9A=8E=E6=AE=B5=20Go=20=E5=B1=A4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .../cmd/api-server/dbon_fk_fix_test.go | 27 +- .../cmd/api-server/integration_test.go | 32 +- visionA-backend/cmd/api-server/main.go | 30 +- visionA-backend/internal/agent/agent.go | 167 ++++++ .../agent/inmemory_repository_test.go | 98 ++++ .../internal/agent/postgres_repository.go | 194 +++++++ .../agent/postgres_repository_db_test.go | 179 ++++++ visionA-backend/internal/api/devices.go | 10 + .../internal/api/pairing_exchange.go | 511 +++++++++++++----- .../internal/api/pairing_exchange_db_test.go | 228 +++++--- .../internal/api/pairing_exchange_test.go | 237 +++++--- .../internal/db/migrate_0005_db_test.go | 109 ++++ visionA-backend/internal/device/device.go | 54 +- .../device/inmemory_repository_test.go | 56 ++ .../internal/device/postgres_repository.go | 159 ++++-- .../device/postgres_repository_db_test.go | 315 +++++++++++ 16 files changed, 2032 insertions(+), 374 deletions(-) create mode 100644 visionA-backend/internal/agent/agent.go create mode 100644 visionA-backend/internal/agent/inmemory_repository_test.go create mode 100644 visionA-backend/internal/agent/postgres_repository.go create mode 100644 visionA-backend/internal/agent/postgres_repository_db_test.go diff --git a/visionA-backend/cmd/api-server/dbon_fk_fix_test.go b/visionA-backend/cmd/api-server/dbon_fk_fix_test.go index c119d9a..2036988 100644 --- a/visionA-backend/cmd/api-server/dbon_fk_fix_test.go +++ b/visionA-backend/cmd/api-server/dbon_fk_fix_test.go @@ -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") } diff --git a/visionA-backend/cmd/api-server/integration_test.go b/visionA-backend/cmd/api-server/integration_test.go index 0680e2f..5f0ebc5 100644 --- a/visionA-backend/cmd/api-server/integration_test.go +++ b/visionA-backend/cmd/api-server/integration_test.go @@ -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, @@ -249,18 +254,19 @@ func setupFixtureWithMaxUpload(t *testing.T, localHandler http.Handler, maxUploa lazy.Set(router) return &testFixture{ - apiServer: apiTS, - internalSrv: internalTS, - tunnelSrv: tunnelTS, - localBackend: localBackend, - store: store, - forwarder: forwarder, - fakeOIDC: fakeOIDC, - pairingStore: pairingStore, - sessionMgr: sessionMgr, - userStore: userStore, - deviceRepo: deviceRepo, - router: router, + apiServer: apiTS, + internalSrv: internalTS, + tunnelSrv: tunnelTS, + localBackend: localBackend, + store: store, + forwarder: forwarder, + fakeOIDC: fakeOIDC, + pairingStore: pairingStore, + sessionMgr: sessionMgr, + userStore: userStore, + deviceRepo: deviceRepo, + sessionTokenStore: sessionTokenStore, + router: router, } } diff --git a/visionA-backend/cmd/api-server/main.go b/visionA-backend/cmd/api-server/main.go index bccd1a6..ce48ff7 100644 --- a/visionA-backend/cmd/api-server/main.go +++ b/visionA-backend/cmd/api-server/main.go @@ -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" @@ -230,13 +231,15 @@ func main() { // device:DB 接入塊 2 — dbPool != nil 時切到 PostgresRepository;否則維持 in-memory // (local dev fallback,雛形行為不變)。Repository interface 不變,handler 一行都不需改。 var deviceRepo device.Repository - var pgDeviceRepo *device.PostgresRepository // 塊 5.2 cascade:DeleteTx 需 concrete type + 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") } diff --git a/visionA-backend/internal/agent/agent.go b/visionA-backend/internal/agent/agent.go new file mode 100644 index 0000000..04f5dc2 --- /dev/null +++ b/visionA-backend/internal/agent/agent.go @@ -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) diff --git a/visionA-backend/internal/agent/inmemory_repository_test.go b/visionA-backend/internal/agent/inmemory_repository_test.go new file mode 100644 index 0000000..8c92657 --- /dev/null +++ b/visionA-backend/internal/agent/inmemory_repository_test.go @@ -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) +} diff --git a/visionA-backend/internal/agent/postgres_repository.go b/visionA-backend/internal/agent/postgres_repository.go new file mode 100644 index 0000000..53df5af --- /dev/null +++ b/visionA-backend/internal/agent/postgres_repository.go @@ -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 +} diff --git a/visionA-backend/internal/agent/postgres_repository_db_test.go b/visionA-backend/internal/agent/postgres_repository_db_test.go new file mode 100644 index 0000000..a2b5653 --- /dev/null +++ b/visionA-backend/internal/agent/postgres_repository_db_test.go @@ -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) } diff --git a/visionA-backend/internal/api/devices.go b/visionA-backend/internal/api/devices.go index 3ab4e11..1935483 100644 --- a/visionA-backend/internal/api/devices.go +++ b/visionA-backend/internal/api/devices.go @@ -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, diff --git a/visionA-backend/internal/api/pairing_exchange.go b/visionA-backend/internal/api/pairing_exchange.go index 29841b9..abafaa0 100644 --- a/visionA-backend/internal/api/pairing_exchange.go +++ b/visionA-backend/internal/api/pairing_exchange.go @@ -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 - } - d.SerialNumber = serial - return d, true +// 為什麼加白名單: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 } - return ExchangeDeviceInput{}, false + if !serialPattern.MatchString(s) { + return "", 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{ - ID: uuid.NewString(), - OwnerUserID: userID, - Name: defaultPairedDeviceName, - DeviceType: defaultPairedDeviceType, - RemoteStatus: device.RemoteStatusOffline, - Status: device.USBStatusUnknown, - PairedAt: &now, - CreatedAt: now, - UpdatedAt: now, + // 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 - if input, ok := firstUsableSerialDevice(devices); ok { - existing, gErr := e.devices.GetBySerial(ctx, userID, input.SerialNumber) - switch { - case gErr == nil: - dev = existing - dev.PairedAt = &now - dev.UpdatedAt = now - case errors.Is(gErr, device.ErrNotFound): - dev.SerialNumber = input.SerialNumber - if input.DeviceType != "" { - dev.DeviceType = input.DeviceType - } - default: - return ExchangeProvisionResult{}, fmt.Errorf("exchange: get device by serial: %w", gErr) - } + // 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 - 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) + // 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) } - return ExchangeProvisionResult{ - DeviceID: dev.ID, - SessionPlaintext: plaintext, - SessionInfo: info, - }, nil + 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: 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) + } +} + +// 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: + 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): + 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 nil, fmt.Errorf("get usb device by serial: %w", gErr) + } } diff --git a/visionA-backend/internal/api/pairing_exchange_db_test.go b/visionA-backend/internal/api/pairing_exchange_db_test.go index a86bea6..9a1f53f 100644 --- a/visionA-backend/internal/api/pairing_exchange_db_test.go +++ b/visionA-backend/internal/api/pairing_exchange_db_test.go @@ -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) } diff --git a/visionA-backend/internal/api/pairing_exchange_test.go b/visionA-backend/internal/api/pairing_exchange_test.go index 5c04c10..4ece17a 100644 --- a/visionA-backend/internal/api/pairing_exchange_test.go +++ b/visionA-backend/internal/api/pairing_exchange_test.go @@ -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"]) } diff --git a/visionA-backend/internal/db/migrate_0005_db_test.go b/visionA-backend/internal/db/migrate_0005_db_test.go index 97a9a64..2c21157 100644 --- a/visionA-backend/internal/db/migrate_0005_db_test.go +++ b/visionA-backend/internal/db/migrate_0005_db_test.go @@ -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") +} diff --git a/visionA-backend/internal/device/device.go b/visionA-backend/internal/device/device.go index b04b9d0..5686a55 100644 --- a/visionA-backend/internal/device/device.go +++ b/visionA-backend/internal/device/device.go @@ -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() diff --git a/visionA-backend/internal/device/inmemory_repository_test.go b/visionA-backend/internal/device/inmemory_repository_test.go index d0003d3..42246f1 100644 --- a/visionA-backend/internal/device/inmemory_repository_test.go +++ b/visionA-backend/internal/device/inmemory_repository_test.go @@ -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) +} diff --git a/visionA-backend/internal/device/postgres_repository.go b/visionA-backend/internal/device/postgres_repository.go index ffda73d..3c90338 100644 --- a/visionA-backend/internal/device/postgres_repository.go +++ b/visionA-backend/internal/device/postgres_repository.go @@ -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) { - return nil, ErrNotFound - } - if err != nil { - return nil, fmt.Errorf("device: pg GetBySerial: %w", err) - } - return d, nil +// 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 } - 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,21 +262,29 @@ 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 - d.OwnerUserID, // $2 - d.Name, // $3 - d.DeviceType, // $4 - serialNumber, // $5 - remoteStatus, // $6 - d.LastSeenAt, // $7 - d.LastConnectedAt, // $8 - status, // $9 - createdAt, // $10 - d.PairedAt, // $11 - d.DeletedAt, // $12 + d.ID, // $1 + d.OwnerUserID, // $2 + d.Name, // $3 + d.DeviceType, // $4 + serialNumber, // $5 + remoteStatus, // $6 + d.LastSeenAt, // $7 + d.LastConnectedAt, // $8 + status, // $9 + 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 + 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 } diff --git a/visionA-backend/internal/device/postgres_repository_db_test.go b/visionA-backend/internal/device/postgres_repository_db_test.go index d7464ff..64d0c69 100644 --- a/visionA-backend/internal/device/postgres_repository_db_test.go +++ b/visionA-backend/internal/device/postgres_repository_db_test.go @@ -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)