Compare commits

...

3 Commits

Author SHA1 Message Date
51dfd0f2d8 fix(frontend): normalizeDevice serialNumber 加 trim(WP-C Minor #1)
純空白字串(" ")原判 truthy → 會以空白 serial 組出必失敗的請求路由。
改為先 trim 再判定:serial !== "" ? serial : null。順帶消除字面 "0"
被 falsy 誤判為 null 的隱性行為。補純空白 → null 邊界測試。

Reviewer 通過(0C/0M/0Mi)。tsc/eslint 0、18 test 綠。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 11:40:35 +08:00
26b433eb10 fix(local-agent): review follow-up 小批(TLS Mi-3/4 + WP-0 S-3/4)
- Mi-3: insecure Transport 改用 DefaultTransport.Clone() 只覆寫 TLSConfig
  (保留 proxy/timeout,消除「開 skip 順便改掉 proxy 行為」副作用)
- Mi-4: exchange 200 分支檢查 Success 欄位(避免 200+success:false 落到
  誤導性的 missing session_token;既有回歸測試改用直接斷言防護不減反增)
- S-3: collectLocalDevices 全空 serial 濾掉不送 devices 陣列(payload 對稱)
- S-4: 假序號比對統一用 EqualFold(防未來 bridge 輸出 casing 變化)

Reviewer 通過(0C/0M/1Mi/3Sug)。兩 module build/vet/test + -race 綠、
gitleaks 0、TLS 行為級測試全 PASS。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 11:40:35 +08:00
59c57fa481 feat(device): WP-B repository 接 agents 模型 + exchange 重塑(A' 走向第二階段 Go 層)
- Device struct 加 4 欄(agent_id/agent_local_device_id/registered_at/
  is_representative)+ deviceColumns 13→17 + scanDevice/SaveTx 讀寫新欄
- 新增 internal/agent package(domain + interface + in-memory + PG repo):
  GetOrCreateAgentTx/GetByOwnerTx,advisory lock 序列化同 owner get-or-create
- exchange 重塑:建/復用 agent → representative device(綁 session_tokens、
  serial=NULL)→ loop 建 N 顆真 USB device(R1 完整 N 顆非只第一顆)
- List filter is_representative=false + DeviceListItem 回傳 agent_id/registered_at
- 併入 WP-0/0005 follow-up Minor:Mi#2 lost-update 收斂(tx 內查詢+局部更新)
  / Mi#3 過時註解 / Mi#4 空 serial 回 ErrNotFound / Mi#5 serial 白名單
  ^0x[0-9A-Fa-f]{8}$ + 去重 / S-1 firmware forward-compat / S-2 device Name 衍生

守 ADR-018 A'(一 owner N agents、session_tokens FK 物理不動、不加 owner
unique 為多機器留路)。Reviewer 通過(0C/0M)。5 套件 dbtest 130 全綠
(db 19/device 37/agent 13/api 172/cmd 60)、build/vet/test 綠。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 11:40:17 +08:00
22 changed files with 2214 additions and 388 deletions

View File

@ -3,6 +3,7 @@ package device
import (
"fmt"
"log"
"strings"
"sync"
"visiona-agent/server/internal/driver"
@ -102,11 +103,21 @@ func (m *Manager) Start() {
m.rebuildSerialIndexLocked()
}
// isFakeSerial reports whether a serial is the pyusb-fallback placeholder.
// Uses EqualFold to match the backend's comparison (visionA-backend
// pairing_exchange.go), so both layers treat the fake serial identically even
// if the Python bridge ever emits a different hex casing (e.g. "0X00000000").
// The current value is all-zero (no casing difference), so this is defensive
// alignment for future bridge output changes (WP-0 review S-4).
func isFakeSerial(serial string) bool {
return strings.EqualFold(serial, fakeSerialNumber)
}
// serialIdentity normalizes a serial for identity comparison: empty and the
// fake pyusb-fallback serial both mean "no identity" (ADR-018 R2), so they
// compare equal to each other and never equal to a real kn_number.
func serialIdentity(serial string) string {
if serial == fakeSerialNumber {
if isFakeSerial(serial) {
return ""
}
return serial
@ -203,7 +214,7 @@ func (m *Manager) rebuildSerialIndexLocked() {
idx := make(map[string]string, len(m.sessions))
for id, s := range m.sessions {
serial := s.Driver.Info().SerialNumber
if serial == "" || serial == fakeSerialNumber {
if serial == "" || isFakeSerial(serial) {
continue
}
if prev, dup := idx[serial]; dup {

View File

@ -199,6 +199,31 @@ func TestManager_SerialIndex_SkipsEmptyAndFakeSerial(t *testing.T) {
}
}
// TestManager_SerialIndex_FakeSerialCaseInsensitive verifies S-4: the fake
// serial is matched case-insensitively (EqualFold), aligned with the backend.
// A hex-casing variant like "0X00000000" must still be treated as "no serial"
// and never routable, guarding against future bridge output casing changes.
func TestManager_SerialIndex_FakeSerialCaseInsensitive(t *testing.T) {
const fakeUpper = "0X00000000" // same value, different hex casing
mgr := NewManager(NewRegistry(), "")
mgr.mu.Lock()
mgr.sessions["kl520-0"] = NewSession(&testDriver{info: driver.DeviceInfo{ID: "kl520-0", SerialNumber: fakeUpper}})
mgr.rebuildSerialIndexLocked()
mgr.mu.Unlock()
if _, err := mgr.GetDevice(fakeUpper); err == nil {
t.Errorf("GetDevice(%s) expected error (fake serial variant must not be routable)", fakeUpper)
}
// serialIdentity should also treat the variant as no-identity.
if got := serialIdentity(fakeUpper); got != "" {
t.Errorf("serialIdentity(%s) = %q, want \"\" (fake serial variant = no identity)", fakeUpper, got)
}
// The device remains reachable by its local id.
if _, err := mgr.GetDevice("kl520-0"); err != nil {
t.Errorf("GetDevice(kl520-0) error = %v", err)
}
}
// ==========================================================================
// Rescan identity check (WP-0 review Minor #1): positional synthetic IDs vs
// serial identity — the serial index must reflect current detection.

View File

@ -293,10 +293,15 @@ func (e *HTTPPairingExchanger) exchangeReal(pairingToken string) (ExchangeResult
// 注意:只在 client.Transport == nil含 NewHTTPPairingExchanger 的預設 client
// 時覆寫,避免踩掉呼叫者注入的自訂 Transport且不直接改 e.Client用區域複本
// 不影響傳入的共享 client。
//
// Clone DefaultTransport 而非新建 zero-value &http.Transport{}:只覆寫
// TLSClientConfig跳過憑證驗證保留預設的 Proxy: http.ProxyFromEnvironment
// 與 dial / TLS handshake timeout。避免「開 skip 順便改掉 proxy 行為」的隱性
// 副作用(例如經 corp proxy 的 dev 環境會被迫改走直連)。
tr := http.DefaultTransport.(*http.Transport).Clone()
tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec // dev-only, gated by VISIONA_INSECURE_SKIP_TLS_VERIFY
c := *client
c.Transport = &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // dev-only, gated by VISIONA_INSECURE_SKIP_TLS_VERIFY
}
c.Transport = tr
client = &c
}
@ -329,6 +334,11 @@ func (e *HTTPPairingExchanger) exchangeReal(pairingToken string) (ExchangeResult
if err := json.Unmarshal(respBody, &ok); err != nil {
return ExchangeResult{}, fmt.Errorf("decode exchange response: %w", err)
}
// HTTP 200 + success:false 是 envelope 契約允許表達、但正常不該發生的異常組合。
// 先明確攔截避免落到下方「missing session_token」這個誤導性訊息。
if !ok.Success {
return ExchangeResult{}, fmt.Errorf("exchange returned success=false: %s", truncate(string(respBody), 256))
}
// session_token 在 envelope 的 data. 底下(見 exchangeResponse 註解)。
if ok.Data.SessionToken == "" {
return ExchangeResult{}, errors.New("exchange response missing session_token")
@ -370,12 +380,24 @@ func (e *HTTPPairingExchanger) collectLocalDevices() []exchangeDevice {
}
out := make([]exchangeDevice, 0, len(devs))
for _, d := range devs {
// serial 是 devices payload 的唯一用途(雲端據此填 serial_number 做 serial
// 路由)。空 serial 的裝置對雲端無意義、雲端本來就會濾掉——agent 端先濾,
// 減少 payload 面積、也讓語意對稱WP-0 review S-3
serial := strings.TrimSpace(d.SerialNumber)
if serial == "" {
continue
}
out = append(out, exchangeDevice{
SerialNumber: strings.TrimSpace(d.SerialNumber),
SerialNumber: serial,
DeviceType: d.DeviceType,
Firmware: d.Firmware,
})
}
// 全部 serial 皆空 → 回 nil讓 payload 省略 devices 欄位omitempty
// 與「無 DeviceLister」的舊行為一致。
if len(out) == 0 {
return nil
}
return out
}

View File

@ -158,7 +158,12 @@ func TestExchangeRealRawEnvelope(t *testing.T) {
// TestExchangeRealTopLevelSessionTokenRejected 是回歸防護:確認 agent 不會誤接
// 「session_token 在頂層」的舊格式contract drift 前的假設)。雲端不再回這種格式,
// 若 agent 又退回解頂層,這個測試會抓到(會解不到 data.session_token → 報 missing
// 若 agent 又退回解頂層,這個測試會抓到。
//
// legacyBody 既無 `success:true` 也無 `data.session_token`,故現行實作在 200 分支
// 先被 Mi-4 的 success 檢查攔下success=false即使未來調整攔截順序、也必然落到
// missing session_token。兩種訊息都代表「舊頂層格式被拒」核心防護意圖不變——只要
// exchange 失敗且非退回解頂層即可。
func TestExchangeRealTopLevelSessionTokenRejected(t *testing.T) {
const legacyBody = `{"session_token":"vAs_toplevel","account":"old@x"}`
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@ -168,12 +173,16 @@ func TestExchangeRealTopLevelSessionTokenRejected(t *testing.T) {
defer srv.Close()
ex := NewHTTPPairingExchanger(srv.URL)
_, err := ex.Exchange("vAc_0123456789abcdef0123456789abcdef")
res, err := ex.Exchange("vAc_0123456789abcdef0123456789abcdef")
if err == nil {
t.Fatal("expected error when session_token is only at top level (legacy format no longer accepted)")
}
if !strings.Contains(err.Error(), "missing session_token") {
t.Errorf("err = %v, want 'missing session_token'", err)
// 關鍵回歸斷言:絕不能誤接頂層的 "vAs_toplevel"。
if res.SessionToken != "" {
t.Errorf("SessionToken = %q, want empty (top-level session_token must not be accepted)", res.SessionToken)
}
if !strings.Contains(err.Error(), "missing session_token") && !strings.Contains(err.Error(), "success=false") {
t.Errorf("err = %v, want 'missing session_token' or 'success=false'", err)
}
}
@ -487,6 +496,102 @@ func TestExchangeReal_NoDeviceLister_NoDevicesField(t *testing.T) {
}
}
// TestExchangeReal_AllEmptySerials_NoDevicesField 驗證 S-3DeviceLister 回的
// 裝置全部 serial 皆空時payload 省略 devices 欄位(與無 DeviceLister 舊行為一致),
// 不送出對雲端無意義的空 serial 陣列。
func TestExchangeReal_AllEmptySerials_NoDevicesField(t *testing.T) {
var rawBody []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
buf := new(strings.Builder)
_, _ = io.Copy(buf, r.Body)
rawBody = []byte(buf.String())
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(exchangeResponse{
Success: true,
Data: exchangeResponseData{SessionToken: "vAs_" + strings.Repeat("d", 64)},
})
}))
defer srv.Close()
ex := NewHTTPPairingExchanger(srv.URL)
ex.DeviceLister = func() ([]LocalDevice, error) {
// 兩顆都無序號no-SDK demo / 撈到但序號空白)。
return []LocalDevice{
{SerialNumber: "", DeviceType: "kneron_kl520"},
{SerialNumber: " ", DeviceType: "kneron_kl720"},
}, nil
}
if _, err := ex.Exchange("vAc_0123456789abcdef0123456789abcdef"); err != nil {
t.Fatalf("Exchange: %v", err)
}
if strings.Contains(string(rawBody), "devices") {
t.Errorf("全部 serial 皆空時 payload 不應含 devices 欄位:%s", rawBody)
}
}
// TestExchangeReal_PartialEmptySerials_OnlyKeepsNonEmpty 驗證 S-3混合清單中
// 空 serial 被濾掉、只留有序號的裝置。
func TestExchangeReal_PartialEmptySerials_OnlyKeepsNonEmpty(t *testing.T) {
var gotBody map[string]interface{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewDecoder(r.Body).Decode(&gotBody)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(exchangeResponse{
Success: true,
Data: exchangeResponseData{SessionToken: "vAs_" + strings.Repeat("e", 64)},
})
}))
defer srv.Close()
ex := NewHTTPPairingExchanger(srv.URL)
ex.DeviceLister = func() ([]LocalDevice, error) {
return []LocalDevice{
{SerialNumber: "", DeviceType: "kneron_kl520"},
{SerialNumber: "0x1A2B3C4D", DeviceType: "kneron_kl720"},
}, nil
}
if _, err := ex.Exchange("vAc_0123456789abcdef0123456789abcdef"); err != nil {
t.Fatalf("Exchange: %v", err)
}
devices, ok := gotBody["devices"].([]interface{})
if !ok {
t.Fatalf("request body 缺 devices 欄位:%v", gotBody)
}
if len(devices) != 1 {
t.Fatalf("devices 長度 = %d, want 1空 serial 應被濾除)", len(devices))
}
d0 := devices[0].(map[string]interface{})
if d0["serial_number"] != "0x1A2B3C4D" {
t.Errorf("devices[0].serial_number = %v, want 0x1A2B3C4D", d0["serial_number"])
}
}
// TestExchangeReal_SuccessFalse_ReturnsClearError 驗證 Mi-4HTTP 200 但
// envelope success=false 時回明確錯誤,而非誤導性的 "missing session_token"。
func TestExchangeReal_SuccessFalse_ReturnsClearError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
// 200 + success:false異常組合契約允許表達
_ = json.NewEncoder(w).Encode(exchangeResponse{Success: false})
}))
defer srv.Close()
ex := NewHTTPPairingExchanger(srv.URL)
_, err := ex.Exchange("vAc_0123456789abcdef0123456789abcdef")
if err == nil {
t.Fatal("expected error on 200 + success=false")
}
if !strings.Contains(err.Error(), "success=false") {
t.Errorf("err = %v, want to mention success=false非誤導性 missing session_token", err)
}
if strings.Contains(err.Error(), "missing session_token") {
t.Errorf("err = %v — 不應落到誤導性的 missing session_token 訊息", err)
}
}
// TestNewLocalDeviceLister_ParsesEnvelope 驗證 NewLocalDeviceLister 能解析
// local server GET /api/devices 的 envelopesuccess + data.devices
func TestNewLocalDeviceLister_ParsesEnvelope(t *testing.T) {

View File

@ -44,7 +44,12 @@ func TestDBOnFix_OIDCCallbackProvisionsUser(t *testing.T) {
assert.Equal(t, "alice@example.com", got.Email)
}
// TestDBOnFix_ExchangeProvisionsDevice 驗證問題 #2pairing exchange 後自建 deviceowner 對齊)。
// TestDBOnFix_ExchangeProvisionsDevice 驗證問題 #2 + A' 重塑WP-B B3/B4pairing exchange
// 後建 agent 的 representative device 供 session token 綁定。
//
// A' 語意變更exchange 無 USB 上報時只建 representative deviceis_representative=true
// 而 ListB4會 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 名下無(真 USBdevice
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不走 AuthMiddlewareagent + 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 上報只建 representativeB4 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 deviceis_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")
}

View File

@ -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' 重塑 testWP-B驗證 exchange 建的 session token
// 綁到 representative deviceList 已 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 wiringOB5
OIDCProvider: oidcProvider,
@ -260,6 +265,7 @@ func setupFixtureWithMaxUpload(t *testing.T, localHandler http.Handler, maxUploa
sessionMgr: sessionMgr,
userStore: userStore,
deviceRepo: deviceRepo,
sessionTokenStore: sessionTokenStore,
router: router,
}
}

View File

@ -24,6 +24,7 @@ import (
"github.com/gin-gonic/gin"
"visiona-backend/internal/agent"
"visiona-backend/internal/api"
"visiona-backend/internal/auth"
"visiona-backend/internal/config"
@ -231,12 +232,14 @@ func main() {
// local dev fallback雛形行為不變。Repository interface 不變handler 一行都不需改。
var deviceRepo device.Repository
var pgDeviceRepo *device.PostgresRepository // 塊 5.2 cascadeDeleteTx 需 concrete type
var memDeviceRepo *device.InMemoryRepository // WP-B B3mem exchanger 需 concreterepresentative 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 exchangerDB-on FK 收尾(問題 #2— exchange 時自建 device + 建 session token。
// - Postgresdb.WithTx 把「建 device + 建 session token」包成單一交易整筆原子。
// agent repositoryA' 模型ADR-018 / migration 0005WP-B B2— exchange 建/復用 agent 用。
// dbPool != nil 時 Postgres否則 in-memorylocal-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 exchangerA' 模型 exchange 重塑ADR-018WP-B B3
// 建/復用 agent + representative device綁 session token+ N 顆真 USB device。
// - Postgresdb.WithTx 把整串包成單一交易,整筆原子。
// - in-memory依序執行無交易行為一致。
// 注入 Deps.PairingExchangerexchange handler 偵測非 nil 即走「自建 device」路徑。
// 注入 Deps.PairingExchangerexchange 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")
}

View File

@ -0,0 +1,167 @@
// Package agent 定義 Agent domain model 與 Repository 介面。
//
// 背景ADR-018 走向 A' / migration 0005WP-B B2
//
// 一個 agent = 一條已配對的 tunnel 連線(一台跑 local-agent 的機器)。一個 agent 底下可掛
// 多顆實體 USB deviceagents 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 PKDEFAULT gen_random_uuid()
// - owner_user_id UUID NOT NULL REFERENCES users(id)
// - name TEXT NOT NULL DEFAULT 'local-agent'
// - platform / agent_version TEXTnullableagent 上報,可空)
// - last_paired_at TIMESTAMPTZnullable
// - created_at / updated_at NOT NULL DEFAULT now()
// - deleted_at TIMESTAMPTZnullablesoft delete
//
// 兩個實作對齊(沿用專案慣例,比照 user / device package
// - InMemoryRepositorylocal-dev fallback / 單元測試(不檢查 FK
// - PostgresRepositoryDB-onpostgres_repository.go
//
// main.go 依 dbPool 是否非 nil 擇一注入。
package agent
import (
"context"
"errors"
"sync"
"time"
"github.com/google/uuid"
"visiona-backend/internal/db"
)
// newAgentID 產生一個新 agent idin-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」一台機器一條 tunnelexchange 用它判斷是否已有
// agent 可復用。若未來支援「一 owner 多機器多 agent」此方法需擴充識別鍵如 machine id
GetByOwnerTx(ctx context.Context, q db.Querier, ownerUserID string) (*Agent, error)
// GetOrCreateAgentTx 取得該 owner 的 agent不存在則建立一筆。
//
// 在傳入的 Querierpool 或 tx上執行供 exchange 與 device / session token 建立在
// 同一交易內(整筆原子)。回傳的 Agent 一定非 nil復用既有或新建並更新 last_paired_at。
//
// name / platform / agentVersion 為 agent 上報值(可空);新建時填入,復用時更新非空值 +
// last_paired_attx 內只更新這幾欄,避免全欄覆寫造成 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 的第一個未刪除 agentin-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 的 agentin-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)

View File

@ -0,0 +1,98 @@
// InMemoryRepositoryagent的單元測試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)
}

View File

@ -0,0 +1,194 @@
// Package agent 的 Postgres 持久層實作migration 0005WP-B B2
//
// PostgresRepository 實作與 InMemoryRepository 相同的 Repository interface。
//
// 對齊 migrations/0005_create_agents.up.sql 的 agents 表;語意對齊 in-memoryagent.go
// - GetByOwnerTx / GetOrCreateAgentTx 略過 deleted_at IS NOT NULL 的紀錄。
// - GetOrCreateAgentTx 復用路徑「tx 內局部 UPDATE」只改 last_paired_at + 非空上報欄),
// 不全欄覆寫,避免 lost-updateWP-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。新建路徑INSERTid 由 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 lockpg_advisory_xact_lock以 (ownerUserID) 為鍵
// 序列化同 owner 的 get-or-create同一時間只有一個 tx 能進入「查→建」臨界區,交易結束自動
// 釋放。此法不需動 schema守 WP-B「不碰 migration」邊界
// ⚠️ advisory_xact_lock 需在交易內才會自動釋放——exchange 一律以 db.WithTx 包住
// (見 pairing_exchange.go。若 q 為 pool非 txlock 在該單一語句結束即釋放、
// 無法涵蓋整個「查→建」,故呼叫端務必在 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-createtx-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 agentadvisory 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 TEXTplatform / agent_versionNULL → 空字串(對齊 in-memory zero value
// nullable TIMESTAMPTZlast_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
}

View File

@ -0,0 +1,179 @@
//go:build dbtest
// PostgresRepositoryagent的真 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各自 WithTxFOR 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) }

View File

@ -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,

View File

@ -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 0005WP-B B3
//
// 背景
// 背景DB-on FK 收尾 #2 → A' 重塑)
//
// session_tokens.device_id 是 NOT NULL FK → devices(id),但雛形 pairing 流程從頭到尾沒有任何
// production 路徑會建 devicegrep 確認 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 deviceis_representative=true、serial=NULL
// session_tokens.device_id 綁它(維持現行「一條 tunnel 綁一個 device_id」的物理語意
// 但改綁「agent 代表 device」而非隨機佔位——session_tokens schema 一字不動)。
// 3. 對 agent 上報清單的每顆可用序號 USB建/復用一筆真 USB deviceis_representative=false、
// agent_id=同一 agent、填 serial——R1 完整 N 顆(非只第一顆)。
//
// 為什麼抽成 coordinator比照 unpair.go 的 DeviceUnpairer
// - 讓 handlerpairing.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 deviceA' 語意
// 「一 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 衍生(見 deriveDeviceNameS-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 idsis_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
//
// FirmwareS-1 forward-compatagent 上報韌體版本字串,目前 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-compatWP-B 尚未消費(見上方說明)
}
// fakeSerialNumber 是 agent 端 pyusb fallback無 Kneron SDK如 macOS 缺 dylib
@ -73,52 +85,103 @@ type ExchangeDeviceInput struct {
// 視同「無序號」寫 NULLADR-018 §2.2 / task-1 mapping R2
const fakeSerialNumber = "0x00000000"
// firstUsableSerialDevice 從 agent 上報清單挑第一顆「序號可用」的裝置
// serialPattern 是 Kneron kn_number 的合法格式白名單Mi#50x 前綴 + 8 位十六進位
//
// WP-0 最小落地task-1 mapping R1exchange 仍只落一筆 device多顆 USB 的
// 完整模型(一 agent N device是 WP-B / migration 0005 的範疇。這裡取第一顆
// 有效序號填入空序號與假序號0x00000000跳過。
func firstUsableSerialDevice(devices []ExchangeDeviceInput) (ExchangeDeviceInput, bool) {
for _, d := range devices {
serial := strings.TrimSpace(d.SerialNumber)
if serial == "" || strings.EqualFold(serial, fakeSerialNumber) {
continue
// 為什麼加白名單serial 是跨層路由鍵(雲端 device ↔ local agent sessions亂格式序號
// 會污染路由與去重。不符此格式者視同「無序號」(寫 NULL、不進去重、不路由與假序號同處理。
// 攻擊面受限(需持有效一次性 pairing token 才能觸發 exchangereviewer 已判不升級 Security。
var serialPattern = regexp.MustCompile(`^0x[0-9A-Fa-f]{8}$`)
// normalizeSerial 對上報序號做 trim + 白名單驗證。回傳 (正規化序號, 是否可用)。
//
// 不可用(空 / 假序號 / 不符白名單)→ 回 ("", false),呼叫端視同無序號。
func normalizeSerial(raw string) (string, bool) {
s := strings.TrimSpace(raw)
if s == "" || strings.EqualFold(s, fakeSerialNumber) {
return "", false
}
d.SerialNumber = serial
return d, true
if !serialPattern.MatchString(s) {
return "", false
}
return ExchangeDeviceInput{}, false
return s, true
}
// PairingExchanger 把「自建 device + 建 session token」包成一個原子Postgres tx
// 一致in-memory 依序)操作。
// usableSerialDevices 從 agent 上報清單挑出所有「序號可用」的裝置R1完整 N 顆)。
//
// A' 模型WP-B B3一 agent 底下 N 顆實體 USB 各建一筆真 device。此函式過濾出可用序號者、
// 正規化序號、並對同一序號去重(同一次 exchange 上報重複序號只取第一顆,避免同 tx 內撞
// partial unique。空序號 / 假序號 / 不符白名單者跳過。
func usableSerialDevices(devices []ExchangeDeviceInput) []ExchangeDeviceInput {
out := make([]ExchangeDeviceInput, 0, len(devices))
seen := make(map[string]struct{}, len(devices))
for _, d := range devices {
serial, ok := normalizeSerial(d.SerialNumber)
if !ok {
continue
}
key := strings.ToLower(serial) // 去重用大小寫不敏感(對齊 GetBySerial 的實體語意)
if _, dup := seen[key]; dup {
continue
}
seen[key] = struct{}{}
d.SerialNumber = serial
out = append(out, d)
}
return out
}
// deriveDeviceName 由 agent 上報的 device type + serial 衍生真 USB device 的顯示名稱S-2
//
// 取代舊的固定 "local-tool (paired)":真 USB device 應能從名稱看出是哪顆晶片/序號,
// 如 "kneron_kl520 (0x1A2B3C4D)"。deviceType 空時退回 "USB device"。
func deriveDeviceName(deviceType, serial string) string {
dt := strings.TrimSpace(deviceType)
if dt == "" {
dt = "USB device"
}
if serial != "" {
return fmt.Sprintf("%s (%s)", dt, serial)
}
return dt
}
// PairingExchanger 把「建 agent + representative device + N 顆真 USB device + 建 session token」
// 包成一個原子Postgres tx或一致in-memory 依序操作A' 模型WP-B B3
//
// Provision 語意:成功回 ExchangeProvisionResult任一步失敗回 errorhandler 經 errors.go
// 映射成 5xx不洩漏 raw error
type PairingExchanger interface {
// Provision 自建一筆 deviceowner = 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
// - SaveTxupsert devicerepresentative / 真 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 重塑:
//
// 任一步失敗整筆 rollbackdevice 不會「已建但沒 token」殘留在 DB
// 1. 建/復用該 owner 的 agentGetOrCreateAgentTxadvisory lock 序列化同 owner
// 2. 建/復用該 agent 的 representative deviceis_representative=true、serial=NULL
// 3. 建綁 representative device 的 session tokensession_tokens.device_id = representative.id
// 4. 對每顆可用序號 USB建/復用一筆真 USB deviceis_representative=false、掛同一 agent
//
// WP-0 序號地基ADR-018agent 上報清單有可用序號時,先 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 devicetx 內)。
//
// 復用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 留空 → NULLrepresentative 非真 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 devicetx 內,掛指定 agent
//
// 復用GetBySerialTx 命中同 owner 同 serial 的未刪除 device只更新 PairedAt + agent 關聯
// + device type/name不全欄覆寫Mi#2R4 同序號重配 = 復用。新建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 外,額外需要 GetRepresentativeByAgentTxconcrete method
// 傳 nil Querierin-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)。
// Provisionin-memory建/復用 agent → representative device綁 session token→ N USB。
// 行為與 pgPairingExchanger.Provision 對齊(無交易,依序執行)。
func (e *memPairingExchanger) Provision(
ctx context.Context, userID, parentTokenHash string, ttl time.Duration, devices []ExchangeDeviceInput,
) (ExchangeProvisionResult, error) {
var res ExchangeProvisionResult
now := time.Now().UTC()
usable := usableSerialDevices(devices)
dev := &device.Device{
// 1) 建/復用 agent。
ag, agErr := e.agents.GetOrCreateAgentTx(ctx, nil, userID, "", "", "", now)
if agErr != nil {
return ExchangeProvisionResult{}, fmt.Errorf("exchange: get-or-create agent: %w", agErr)
}
res.AgentID = ag.ID
// 2) 建/復用 representative device。
rep, repErr := e.representativeForAgent(ctx, userID, ag.ID, now)
if repErr != nil {
return ExchangeProvisionResult{}, fmt.Errorf("exchange: representative device: %w", repErr)
}
res.DeviceID = rep.ID
// 3) 建綁 representative device 的 session token。
plaintext, info, err := e.sessionToken.Create(ctx, userID, rep.ID, parentTokenHash, ttl)
if err != nil {
return ExchangeProvisionResult{}, fmt.Errorf("exchange: create session token: %w", err)
}
res.SessionPlaintext = plaintext
res.SessionInfo = info
// 4) 每顆可用序號 USB。
usbIDs := make([]string, 0, len(usable))
for _, in := range usable {
usbDev, uErr := e.upsertUSBDevice(ctx, userID, ag.ID, in, now)
if uErr != nil {
return ExchangeProvisionResult{}, fmt.Errorf("exchange: upsert usb device (serial=%s): %w", in.SerialNumber, uErr)
}
usbIDs = append(usbIDs, usbDev.ID)
}
res.USBDeviceIDs = usbIDs
return res, nil
}
// representativeForAgent 建/復用某 agent 的 representative devicein-memory對齊 pg 版)。
func (e *memPairingExchanger) representativeForAgent(
ctx context.Context, userID, agentID string, now time.Time,
) (*device.Device, error) {
existing, gErr := e.devices.GetRepresentativeByAgentTx(ctx, nil, agentID)
switch {
case gErr == nil:
existing.PairedAt = &now
existing.UpdatedAt = now
if saveErr := e.devices.Save(ctx, existing); saveErr != nil {
return nil, fmt.Errorf("save representative: %w", saveErr)
}
return existing, nil
case errors.Is(gErr, device.ErrNotFound):
rep := &device.Device{
ID: uuid.NewString(),
OwnerUserID: userID,
Name: defaultPairedDeviceName,
DeviceType: defaultPairedDeviceType,
Name: representativeDeviceName,
DeviceType: representativeDeviceType,
AgentID: agentID,
IsRepresentative: true,
RemoteStatus: device.RemoteStatusOffline,
Status: device.USBStatusUnknown,
PairedAt: &now,
CreatedAt: now,
UpdatedAt: now,
}
if saveErr := e.devices.Save(ctx, rep); saveErr != nil {
return nil, fmt.Errorf("save representative: %w", saveErr)
}
return rep, nil
default:
return nil, fmt.Errorf("get representative: %w", gErr)
}
}
if input, ok := firstUsableSerialDevice(devices); ok {
existing, gErr := e.devices.GetBySerial(ctx, userID, input.SerialNumber)
// upsertUSBDevice 建/復用一顆真 USB devicein-memory對齊 pg 版)。
func (e *memPairingExchanger) upsertUSBDevice(
ctx context.Context, userID, agentID string, in ExchangeDeviceInput, now time.Time,
) (*device.Device, error) {
existing, gErr := e.devices.GetBySerial(ctx, userID, in.SerialNumber)
switch {
case gErr == nil:
dev = existing
dev.PairedAt = &now
dev.UpdatedAt = now
existing.PairedAt = &now
existing.UpdatedAt = now
existing.AgentID = agentID
if in.DeviceType != "" {
existing.DeviceType = in.DeviceType
existing.Name = deriveDeviceName(in.DeviceType, existing.SerialNumber)
}
if saveErr := e.devices.Save(ctx, existing); saveErr != nil {
return nil, fmt.Errorf("save usb device: %w", saveErr)
}
return existing, nil
case errors.Is(gErr, device.ErrNotFound):
dev.SerialNumber = input.SerialNumber
if input.DeviceType != "" {
dev.DeviceType = input.DeviceType
usbDev := &device.Device{
ID: uuid.NewString(),
OwnerUserID: userID,
Name: deriveDeviceName(in.DeviceType, in.SerialNumber),
DeviceType: in.DeviceType,
SerialNumber: in.SerialNumber,
AgentID: agentID,
IsRepresentative: false,
RemoteStatus: device.RemoteStatusOffline,
Status: device.USBStatusUnknown,
PairedAt: &now,
CreatedAt: now,
UpdatedAt: now,
}
if saveErr := e.devices.Save(ctx, usbDev); saveErr != nil {
return nil, fmt.Errorf("save usb device: %w", saveErr)
}
return usbDev, nil
default:
return ExchangeProvisionResult{}, fmt.Errorf("exchange: get device by serial: %w", gErr)
return nil, fmt.Errorf("get usb device by serial: %w", gErr)
}
}
if err := e.devices.Save(ctx, dev); err != nil {
return ExchangeProvisionResult{}, fmt.Errorf("exchange: save device: %w", err)
}
plaintext, info, err := e.sessionToken.Create(ctx, userID, dev.ID, parentTokenHash, ttl)
if err != nil {
return ExchangeProvisionResult{}, fmt.Errorf("exchange: create session token: %w", err)
}
return ExchangeProvisionResult{
DeviceID: dev.ID,
SessionPlaintext: plaintext,
SessionInfo: info,
}, nil
}

View File

@ -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 成功:自建一筆 deviceowner 對齊)+ 建綁該 device 的 session tokensession
// token 的 device_id 不再為空、且確實指向新建的 deviceFK 滿足)。
// - parent_token_hash 寫入(稽核鏈)。
// - 原子性device owner 不存在FK violation→ 整筆 rollbackdevice 不會殘留。
// 涵蓋 A' 重塑:建/復用 agent + representative device綁 session token+ N 顆真 USB device
// Mi#2tx 內查詢/局部更新、Mi#5serial 白名單、S-2Name 衍生、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 userdevice、無 token
// pgExchangeFixture 建一個已就緒的 Postgres 環境:一個合法 owner useragent / 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 deviceis_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 devicedevice_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 不存在於 usersFK violation
// → device INSERT 撞 owner_user_id FK → 整筆 rollbackdevice 不殘留。
// 原子性owner 不存在FK violation→ 整筆 rollbackagent / 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-0ADR-018 序號地基exchange 收 agent 上報序號(真 DB
// ==========================================================================
// TestPGExchange_Provision_FillsSerialNumber 驗證序號真的寫進 devices.serial_number
// (非 NULL且 device_type 取 agent 上報值。
func TestPGExchange_Provision_FillsSerialNumber(t *testing.T) {
// R1N 顆可用序號 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_active23505——復用既有 device、
// 不 500、devices 表不長出第二筆。
func TestPGExchange_Provision_SameSerialReusesDevice(t *testing.T) {
// R4同序號重配復用同一真 USB device不撞 partial unique23505、不多建。
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")
// devices1 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_idQ5 語意驗證)。
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)
}

View File

@ -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 deviceis_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 deviceis_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 deviceA' 語意),
// 但各建獨立 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 掉 representativeB4
// 故用 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-0ADR-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 deviceis_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 衍生 NameS-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-2Name 由 type + serial 衍生")
}
// TestMemExchange_Provision_SameSerialReusesDevice 驗證同序號重複 exchange
// 復用既有 device防唯一約束炸裂「同序號重配 = 復用」R4——不新建、
// 不報錯、session token 各自獨立。
func TestMemExchange_Provision_SameSerialReusesDevice(t *testing.T) {
// 同序號重配 = 復用真 USB deviceR4防 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")
// Listfilter 掉 representativeB4只回真 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 deviceMi#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"])
}

View File

@ -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#1WP-B 落地後才有此真場景)。
//
// 場景WP-0序號地基已 commitexchange 會落「有 serial 的真 USB device」。若這些 device
// 在 apply 0005 前就已存在於 DB0005 的 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」一律建 agentagent_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 也建 agentagent_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 回 0004apply 0005 前狀態)")
owner := tdb.InsertUser(t, "", "")
// 模擬 apply 0005 前 DB 已有的 device 分佈:
// - 2 筆有 serial 的未刪除 deviceWP-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=true2 筆)")
// 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")
}

View File

@ -9,6 +9,8 @@ import (
"errors"
"sync"
"time"
"visiona-backend/internal/db"
)
// ==========================================================================
@ -61,6 +63,13 @@ const (
// - RemoteStatustunnel-level雲端觀察到的 tunnel 連線狀態
//
// 前端優先顯示 RemoteStatus次要顯示 Status見 TDD §10.5.1)。
//
// A' 模型欄位ADR-018 / migration 0005WP-B B1 加入):
// - AgentID所屬 agent一條 tunnel 連線。NULL=遷移前舊資料 / 尚未歸入 agent。
// - AgentLocalDeviceIDlocal agent 端合成 id如 kl520-0路由/除錯輔助。
// - RegisteredAt註冊軸NULL=未註冊)。連線軸 × 註冊軸 → 前端三色WP-F
// - IsRepresentativetrue=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 的 deviceWP-B B4
//
// is_representative=false filter 對齊 PostgresRepository.Listrepresentative 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 deviceis_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()

View File

@ -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))
}
// ---------------------------------------------------------------------------
// B4List filter representative + Mi#4 空 serial + representative 查詢)
// ---------------------------------------------------------------------------
// List filterrepresentative 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#4in-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)
}

View File

@ -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-memoryd.SerialNumber == serial空查空語意一致。
func (r *PostgresRepository) GetBySerial(ctx context.Context, ownerUserID, serial string) (*Device, error) {
if serial == "" {
const qNull = `SELECT ` + deviceColumns + `
FROM devices
WHERE owner_user_id = $1 AND serial_number IS NULL AND deleted_at IS NULL`
return r.GetBySerialTx(ctx, r.pool, ownerUserID, serial)
}
row := r.pool.QueryRow(ctx, qNull, ownerUserID)
d, err := scanDevice(row)
if errors.Is(err, pgx.ErrNoRows) {
// GetBySerialTx 與 GetBySerial 相同語意,但在傳入的 Querierpool 或 tx上執行。
//
// WP-B B3Mi#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 無法以序號識別實體 USBMi#4直接回 ErrNotFound避免非決定性多筆掃描。
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("device: pg GetBySerial: %w", err)
}
return d, nil
}
const q = `SELECT ` + deviceColumns + `
const sql = `SELECT ` + deviceColumns + `
FROM devices
WHERE owner_user_id = $1 AND serial_number = $2 AND deleted_at IS NULL`
row := r.pool.QueryRow(ctx, q, ownerUserID, serial)
row := q.QueryRow(ctx, sql, ownerUserID, serial)
d, err := scanDevice(row)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
@ -114,11 +121,20 @@ func (r *PostgresRepository) GetBySerial(ctx context.Context, ownerUserID, seria
return d, nil
}
// List 列出某 owner 的所有未刪除 device以 created_at DESC 排序(最新在前)。
// List 列出某 owner 的所有未刪除、**非 representative** 的 deviceWP-B B4以 created_at
// DESC 排序(最新在前)。
//
// is_representative=false filterA' 模型ADR-018 §2.5 / migration-0005-spec §4.3
//
// representative device 是「agent 連線佔位」(綁 session_tokens不是真實 USB不該出現在
// 使用者的裝置清單。遷移後所有舊 device 都被標為 representative0005 data migration若不
// filterUI 會把這些佔位 device 當 USB 顯示。故 List 只回真 USB deviceis_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 0005WP-B B2
// - agent_id空字串寫 SQL NULLagent_id 是 nullable UUID FK→agents空字串無法 cast
// UUID 會炸,且「未掛 agent」的語意正是 NULL——遷移前舊資料 / 尚未歸入 agent 的 device
// - agent_local_device_id空字串寫 NULLnullable TEXT對齊 serial_number 慣例)。
// - registered_at*time.Time nil → SQL NULLpgx 直接處理),非 nil 寫值(註冊軸)。
// - is_representativeNOT NULL BOOLEANbool 直接寫zero value false = 真 USB / 未指定)。
var agentID any
if d.AgentID != "" {
agentID = d.AgentID
} // else: 留 nil → SQL NULL
var agentLocalDeviceID any
if d.AgentLocalDeviceID != "" {
agentLocalDeviceID = d.AgentLocalDeviceID
} // else: 留 nil → SQL NULL
const sql = `
INSERT INTO devices (
id, owner_user_id, name, device_type, serial_number,
remote_status, last_seen_at, last_connected_at, status,
created_at, updated_at, paired_at, deleted_at
created_at, updated_at, paired_at, deleted_at,
agent_id, agent_local_device_id, registered_at, is_representative
) VALUES (
$1, $2, $3, $4, $5,
$6, $7, $8, $9,
COALESCE($10, now()), now(), $11, $12
COALESCE($10, now()), now(), $11, $12,
$13, $14, $15, $16
)
ON CONFLICT (id) DO UPDATE SET
owner_user_id = EXCLUDED.owner_user_id,
@ -229,7 +262,11 @@ func (r *PostgresRepository) SaveTx(ctx context.Context, q db.Querier, d *Device
END,
updated_at = now(),
paired_at = EXCLUDED.paired_at,
deleted_at = EXCLUDED.deleted_at`
deleted_at = EXCLUDED.deleted_at,
agent_id = EXCLUDED.agent_id,
agent_local_device_id = EXCLUDED.agent_local_device_id,
registered_at = EXCLUDED.registered_at,
is_representative = EXCLUDED.is_representative`
_, err := q.Exec(ctx, sql,
d.ID, // $1
@ -244,6 +281,10 @@ func (r *PostgresRepository) SaveTx(ctx context.Context, q db.Querier, d *Device
createdAt, // $10
d.PairedAt, // $11
d.DeletedAt, // $12
agentID, // $13
agentLocalDeviceID, // $14
d.RegisteredAt, // $15
d.IsRepresentative, // $16
)
if err != nil {
return fmt.Errorf("device: pg Save upsert: %w", err)
@ -251,6 +292,30 @@ func (r *PostgresRepository) SaveTx(ctx context.Context, q db.Querier, d *Device
return nil
}
// GetRepresentativeByAgentTx 取得某 agent 的 representative deviceis_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 valuenullable TIMESTAMPTZlast_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 valuenullable TIMESTAMPTZlast_seen_at /
// last_connected_at / paired_at / deleted_at / registered_at以 *time.Time 接NULL → nil。
// is_representative 為 NOT NULL BOOLEANDEFAULT false直接掃進 bool。
//
// A' 新欄migration 0005WP-B B1agent_id / agent_local_device_id 以 nullable *string
// 接(遷移前舊資料 / 真 USB 未掛 agent 時為 NULLregistered_at 以 *time.Time 接NULL=未註冊)。
func scanDevice(row rowScanner) (*Device, error) {
var (
d Device
deviceType *string
serialNumber *string
agentID *string
agentLocalDeviceID *string
)
err := row.Scan(
@ -315,6 +386,10 @@ func scanDevice(row rowScanner) (*Device, error) {
&d.UpdatedAt,
&d.PairedAt,
&d.DeletedAt,
&agentID,
&agentLocalDeviceID,
&d.RegisteredAt,
&d.IsRepresentative,
)
if err != nil {
return nil, err
@ -322,6 +397,8 @@ func scanDevice(row rowScanner) (*Device, error) {
d.DeviceType = derefString(deviceType)
d.SerialNumber = derefString(serialNumber)
d.AgentID = derefString(agentID)
d.AgentLocalDeviceID = derefString(agentLocalDeviceID)
// 正規化時間為 UTC對齊 in-memorytime.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
}

View File

@ -409,6 +409,321 @@ func TestPG_ConcurrentSaveSameID(t *testing.T) {
assert.Equal(t, 1, tdb.CountRows(t, "devices"), "併發 upsert 同 id 應只有一列")
}
// ---------------------------------------------------------------------------
// B1A' 模型 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 → 讀回 nilNULL")
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 deviceis_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#2is_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)
}
// ---------------------------------------------------------------------------
// B2SaveTx 寫新欄migration 0005
// ---------------------------------------------------------------------------
// SaveTx 寫入 A' 新 4 欄:帶 agent_id / agent_local_device_id / registered_at /
// is_representative 的 deviceSave→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: &registeredAt,
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 deviceis_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 留空 → NULLRegisteredAt 留 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 為 NULLrepresentative 不佔 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_atB2 先確保 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: &registeredAt,
}))
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 violation23503。守住 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")
}
// ---------------------------------------------------------------------------
// B4List filter representative + Mi#4 空 serial
// ---------------------------------------------------------------------------
// List filterrepresentative device 不出現在清單,只列真 USB deviceis_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: &registeredAt,
}))
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#4GetBySerialTx 空 serial → 直接回 ErrNotFound不掃 serial IS NULL 的多筆)。
func TestPG_GetBySerialTx_EmptySerialReturnsNotFound(t *testing.T) {
ctx := context.Background()
r, tdb, owner := newPGRepo(t)
// 塞兩筆 serial=NULL 的 devicerepresentative / 佔位)。
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 查詢應直接回 ErrNotFoundMi#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)

View File

@ -175,7 +175,7 @@ describe("useDeviceStore", () => {
});
});
it("serial_number 正規化snake_case / camelCase 都吃、缺欄位與空字串 → nullWP-C", async () => {
it("serial_number 正規化snake_case / camelCase 都吃、缺欄位與空字串/純空白 → nullWP-C", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
jsonResponse({
success: true,
@ -188,6 +188,8 @@ describe("useDeviceStore", () => {
{ id: "dev-3", name: "C", type: "kl520", status: "connected" },
// 空字串(防禦性;無法路由)→ null
{ id: "dev-4", name: "D", type: "kl520", status: "connected", serial_number: "" },
// 純空白防禦性trim 後為空、無法路由)→ null
{ id: "dev-5", name: "E", type: "kl520", status: "connected", serial_number: " " },
],
}),
);
@ -198,6 +200,7 @@ describe("useDeviceStore", () => {
expect(devices[1]?.serialNumber).toBe("KN99887766");
expect(devices[2]?.serialNumber).toBeNull();
expect(devices[3]?.serialNumber).toBeNull();
expect(devices[4]?.serialNumber).toBeNull();
});
it("fetchDevices 遇到 501 NOT_IMPLEMENTED 時視為空 list不記錯誤", async () => {

View File

@ -112,14 +112,16 @@ function normalizeDevice(raw: unknown): Device {
const rawRemoteStatus = pick<string>("remote_status", "remoteStatus") as
| RemoteStatus
| undefined;
// serial_number 為 omitempty缺欄位 → null防禦性把空字串也視為 null
// (空字串無法路由,語意上等同「未回報序號」)。
// serial_number 為 omitempty缺欄位 → null防禦性把空字串純空白也視為 null
// (空字串與空白皆無法路由,語意上等同「未回報序號」)。先 trim 再判定,
// 避免純空白(" ")誤判為 truthy 而以空白 serial 組出必失敗的請求路由。
const rawSerial = pick<string>("serial_number", "serialNumber");
const serial = rawSerial != null ? String(rawSerial).trim() : "";
return {
id: String(pick<string>("id") ?? ""),
name: String(pick<string>("name") ?? pick<string>("device_name") ?? ""),
alias: pick<string>("alias") ?? undefined,
serialNumber: rawSerial ? String(rawSerial) : null,
serialNumber: serial !== "" ? serial : null,
type: String(pick<string>("type", "device_type") ?? ""),
status: (pick<string>("status") as DeviceHardwareStatus) ?? "disconnected",
remoteStatus: