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