jim800121chen fac07c39d0 fix(local-agent): 配對前自動 rescan USB,解序號偵測時序問題
問題:配對時 exchange 撈 GET /api/devices(ListDevices=讀快取、不重新
偵測)。若 agent 啟動時 USB 尚未插入/未偵測到,快取為空 → 配對撈不到
序號 → payload 不帶 devices → 雲端裝置「尚未回報序號」,即便之後插上
USB 也不會自動重偵測。

修法(方案 A、最小侵入):
- NewLocalDeviceLister 改打 POST /api/devices/scan(ScanDevices →
  Manager.Rescan() 重新偵測 USB),配對前強制重掃一次
- localDeviceListTimeout 2s → localDeviceScanTimeout 8s(真 SDK scan
  kp.core.scan_devices 較慢、給餘裕,逾時走 fallback 不卡配對)
- app.go DeviceLister 注入註解同步更新(Reviewer Mi-1)

不變量保留(Reviewer 獨立驗證):
- rescan 失敗/逾時 → lister return nil(不 error)→ payload omit devices
  → 配對照常(序號是加值資訊,不中斷配對)
- Rescan 對序號身分未變的 session 保持連線(serialIdentity 相等 continue)、
  只斷被拔除的 → 不誤斷在線 tunnel/inference session
- Pair 由 lifecycleMu 序列化、Rescan write lock 全量重建 → 重試冪等安全

Reviewer 通過(0C/0M/1Mi/2Sug)。兩 module build/vet/test/race 全綠、
8 個新 tunnel 行為測試 PASS(端到端快取空→rescan→序號進 payload +
逾時 fallback)、gitleaks 0。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 15:02:31 +08:00

815 lines
31 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// pairing_test.go — ValidatePairingToken / HTTPPairingExchanger 測試AB5 範圍)。
package tunnel
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
"time"
)
func TestValidatePairingToken(t *testing.T) {
cases := []struct {
in string
wantErr bool
}{
{"vAc_0123456789abcdef0123456789abcdef", false},
{"vAc_ffffffffffffffffffffffffffffffff", false},
{"vAs_0123456789abcdef0123456789abcdef", true}, // wrong prefix
{"vAc_0123456789abcdef0123456789abcde", true}, // 31 hex
{"vAc_0123456789abcdef0123456789abcdef0", true}, // 33 hex
{"vAc_ABCDEF0123456789ABCDEF0123456789", true}, // uppercase
{"vAc_ghijklmnopqrstuvwxyz0123456789ab", true}, // non-hex
{"", true},
{"bogus", true},
}
for _, tc := range cases {
err := ValidatePairingToken(tc.in)
if (err != nil) != tc.wantErr {
t.Errorf("ValidatePairingToken(%q) err = %v, wantErr = %v", tc.in, err, tc.wantErr)
}
}
}
func TestExchangeMockMode(t *testing.T) {
ex := &HTTPPairingExchanger{
CloudAPIURL: "http://unused",
MockMode: true,
}
result, err := ex.Exchange("vAc_0123456789abcdef0123456789abcdef")
if err != nil {
t.Fatalf("Exchange: %v", err)
}
if !strings.HasPrefix(result.SessionToken, "vAs_") {
t.Errorf("SessionToken %q should start with vAs_", result.SessionToken)
}
// vAs_ + 64 hex = 68 chars
if len(result.SessionToken) != 68 {
t.Errorf("SessionToken length = %d, want 68", len(result.SessionToken))
}
if result.Account != "demo@visionA.local" {
t.Errorf("Account = %q, want demo@visionA.local", result.Account)
}
}
func TestExchangeMockModeCustomAccount(t *testing.T) {
ex := &HTTPPairingExchanger{
MockMode: true,
MockAccount: "override@x",
MockRelayURL: "wss://mock-relay/tunnel/connect",
}
r, err := ex.Exchange("vAc_00000000000000000000000000000000")
if err != nil {
t.Fatalf("Exchange: %v", err)
}
if r.Account != "override@x" {
t.Errorf("Account = %q, want override@x", r.Account)
}
if r.RelayURL != "wss://mock-relay/tunnel/connect" {
t.Errorf("RelayURL = %q", r.RelayURL)
}
}
func TestExchangeInvalidFormat(t *testing.T) {
ex := &HTTPPairingExchanger{MockMode: true}
_, err := ex.Exchange("not-a-token")
if !errors.Is(err, ErrInvalidTokenFormat) {
t.Errorf("err = %v, want ErrInvalidTokenFormat", err)
}
}
func TestExchangeRealSuccess(t *testing.T) {
// fake visionA-backend 接 /api/pairing/exchange 回成功
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/pairing/exchange" {
w.WriteHeader(404)
return
}
if r.Method != http.MethodPost {
w.WriteHeader(405)
return
}
var req exchangeRequest
_ = json.NewDecoder(r.Body).Decode(&req)
if req.PairingToken == "" {
w.WriteHeader(400)
return
}
w.Header().Set("Content-Type", "application/json")
// 雲端真實格式:{success:true, data:{...}}api/errors.go WriteSuccess envelope
_ = json.NewEncoder(w).Encode(exchangeResponse{
Success: true,
Data: exchangeResponseData{
SessionToken: "vAs_" + strings.Repeat("a", 60) + "beef",
Account: "real@visionA.cloud",
RelayURL: "wss://relay.visionA.cloud/tunnel/connect",
},
})
}))
defer srv.Close()
ex := NewHTTPPairingExchanger(srv.URL)
r, err := ex.Exchange("vAc_0123456789abcdef0123456789abcdef")
if err != nil {
t.Fatalf("Exchange: %v", err)
}
if !strings.HasPrefix(r.SessionToken, "vAs_") {
t.Errorf("SessionToken = %q", r.SessionToken)
}
if r.Account != "real@visionA.cloud" {
t.Errorf("Account = %q", r.Account)
}
if r.RelayURL != "wss://relay.visionA.cloud/tunnel/connect" {
t.Errorf("RelayURL = %q", r.RelayURL)
}
}
// TestExchangeRealRawEnvelope 用手寫 JSON 字串(複製 visionA-backend WriteSuccess 的
// 真實輸出形狀)驗證 agent 能正確從 data. 那層解出欄位,不依賴 agent 自己的 struct
// 編碼(避免 struct 改錯時測試跟著錯、形成假綠)。
func TestExchangeRealRawEnvelope(t *testing.T) {
const rawBody = `{"success":true,"data":{"session_token":"vAs_deadbeef","account":"raw@visionA.local","relay_url":"wss://raw.relay/tunnel/connect","expires_at":"2026-07-21T00:00:00Z"}}`
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(rawBody))
}))
defer srv.Close()
ex := NewHTTPPairingExchanger(srv.URL)
r, err := ex.Exchange("vAc_0123456789abcdef0123456789abcdef")
if err != nil {
t.Fatalf("Exchange against raw cloud envelope: %v", err)
}
if r.SessionToken != "vAs_deadbeef" {
t.Errorf("SessionToken = %q, want vAs_deadbeef (from data.session_token)", r.SessionToken)
}
if r.Account != "raw@visionA.local" {
t.Errorf("Account = %q, want raw@visionA.local (from data.account)", r.Account)
}
if r.RelayURL != "wss://raw.relay/tunnel/connect" {
t.Errorf("RelayURL = %q, want from data.relay_url", r.RelayURL)
}
}
// TestExchangeRealTopLevelSessionTokenRejected 是回歸防護:確認 agent 不會誤接
// 「session_token 在頂層」的舊格式contract drift 前的假設)。雲端不再回這種格式,
// 若 agent 又退回解頂層,這個測試會抓到。
//
// legacyBody 既無 `success:true` 也無 `data.session_token`,故現行實作在 200 分支
// 先被 Mi-4 的 success 檢查攔下success=false即使未來調整攔截順序、也必然落到
// missing session_token。兩種訊息都代表「舊頂層格式被拒」核心防護意圖不變——只要
// exchange 失敗且非退回解頂層即可。
func TestExchangeRealTopLevelSessionTokenRejected(t *testing.T) {
const legacyBody = `{"session_token":"vAs_toplevel","account":"old@x"}`
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(legacyBody))
}))
defer srv.Close()
ex := NewHTTPPairingExchanger(srv.URL)
res, err := ex.Exchange("vAc_0123456789abcdef0123456789abcdef")
if err == nil {
t.Fatal("expected error when session_token is only at top level (legacy format no longer accepted)")
}
// 關鍵回歸斷言:絕不能誤接頂層的 "vAs_toplevel"。
if res.SessionToken != "" {
t.Errorf("SessionToken = %q, want empty (top-level session_token must not be accepted)", res.SessionToken)
}
if !strings.Contains(err.Error(), "missing session_token") && !strings.Contains(err.Error(), "success=false") {
t.Errorf("err = %v, want 'missing session_token' or 'success=false'", err)
}
}
// TestExchangeReal401Codes 驗證 agent 能從雲端錯誤 envelope{success:false, error:{code}}
// 解出大寫 error codeINVALID_PAIRING_TOKEN 等,對齊 api.ErrCode* 常數)→ 映射成
// 對應 sentinel error。
func TestExchangeReal401Codes(t *testing.T) {
cases := []struct {
code string // 雲端大寫常數api.ErrCodeInvalidPairingToken 等)
wantErr error
}{
{"INVALID_PAIRING_TOKEN", ErrTokenInvalid},
{"PAIRING_TOKEN_EXPIRED", ErrTokenExpired},
{"PAIRING_TOKEN_USED", ErrTokenUsed},
{"PAIRING_TOKEN_REVOKED", ErrTokenRevoked},
}
for _, tc := range cases {
t.Run(tc.code, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(401)
// 雲端真實格式:{success:false, error:{code, message, request_id}}。
_ = json.NewEncoder(w).Encode(exchangeErrorResponse{
Success: false,
Error: exchangeErrorDetail{
Code: tc.code,
Message: "pairing token rejected",
},
})
}))
defer srv.Close()
ex := NewHTTPPairingExchanger(srv.URL)
_, err := ex.Exchange("vAc_0123456789abcdef0123456789abcdef")
if !errors.Is(err, tc.wantErr) {
t.Errorf("err = %v, want %v", err, tc.wantErr)
}
})
}
}
// TestExchangeReal401RawEnvelope 用手寫 JSON複製 WriteError 真實輸出)驗證 401
// envelope 解析,不依賴 agent struct 編碼。
func TestExchangeReal401RawEnvelope(t *testing.T) {
const rawErr = `{"success":false,"error":{"code":"PAIRING_TOKEN_EXPIRED","message":"pairing token expired","request_id":"req_123"}}`
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(401)
_, _ = w.Write([]byte(rawErr))
}))
defer srv.Close()
ex := NewHTTPPairingExchanger(srv.URL)
_, err := ex.Exchange("vAc_0123456789abcdef0123456789abcdef")
if !errors.Is(err, ErrTokenExpired) {
t.Errorf("err = %v, want ErrTokenExpired (from error.code=PAIRING_TOKEN_EXPIRED)", err)
}
}
// TestExchangeReal401TopLevelCodeRejected 回歸防護:舊格式(頂層小寫 code不應再被
// 接受。雲端不回這種格式;若 agent 退回解頂層 token_invalid會落到 default 分支
// (仍是 ErrTokenInvalid但帶 code=""),用 error 訊息含空 code 確認沒解到。
func TestExchangeReal401TopLevelCodeRejected(t *testing.T) {
const legacyErr = `{"code":"token_expired"}`
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(401)
_, _ = w.Write([]byte(legacyErr))
}))
defer srv.Close()
ex := NewHTTPPairingExchanger(srv.URL)
_, err := ex.Exchange("vAc_0123456789abcdef0123456789abcdef")
// 頂層 code 解不到 error.code → 落 defaultfmt 包 ErrTokenInvalid + code="")。
// 重點:不應被誤判成 ErrTokenExpired。
if errors.Is(err, ErrTokenExpired) {
t.Errorf("legacy top-level code should NOT map to ErrTokenExpired; got %v", err)
}
if !errors.Is(err, ErrTokenInvalid) {
t.Errorf("unmatched code should fall back to ErrTokenInvalid; got %v", err)
}
}
// TestIsPairingMockOptIn 驗證 Fix-A3mock 模式必須明確 opt-in。
//
// 預設規則(任何不是明確 "true" 的值)一律走真實 exchange避免「使用者忘設環境
// 變數,看起來能跑但其實沒打 backend」這個隱形災難。
func TestIsPairingMockOptIn(t *testing.T) {
cases := []struct {
env string
want bool
desc string
}{
{"", false, "unset → real (production-safe default)"},
{"true", true, "明確 true → mock"},
{"True", true, "大小寫不敏感"},
{"TRUE", true, "全大寫"},
{"false", false, "明確 false → real"},
{"False", false, "false 大小寫不敏感"},
{"1", false, "1 不是 true → real避免拼錯字啟用 mock"},
{"yes", false, "yes 不是 true → real"},
{"on", false, "on 不是 true → real"},
{" true", false, "前導空白 → 不識別為 true規則嚴格"},
{"truthy", false, "包含 true 的字串不算"},
}
for _, tc := range cases {
t.Run(tc.desc, func(t *testing.T) {
got := IsPairingMockOptIn(tc.env)
if got != tc.want {
t.Errorf("IsPairingMockOptIn(%q) = %v, want %v", tc.env, got, tc.want)
}
})
}
}
// TestIsInsecureSkipTLSVerify 驗證跳過 TLS 驗證的 env 解析(必須明確 opt-in
//
// 預設規則(任何不是明確 "true" 的值)一律維持安全的 TLS 驗證,避免使用者拼錯字
// 或殘留環境變數誤入不安全模式。
func TestIsInsecureSkipTLSVerify(t *testing.T) {
cases := []struct {
env string
want bool
desc string
}{
{"", false, "unset → 安全驗證production-safe default"},
{"true", true, "明確 true → skip"},
{"True", true, "大小寫不敏感"},
{"TRUE", true, "全大寫"},
{"false", false, "明確 false → 安全驗證"},
{"False", false, "false 大小寫不敏感"},
{"1", false, "1 不是 true → 安全驗證(避免拼錯字啟用)"},
{"yes", false, "yes 不是 true → 安全驗證"},
{"on", false, "on 不是 true → 安全驗證"},
{" true", false, "前導空白 → 不識別為 true規則嚴格"},
{"truthy", false, "包含 true 的字串不算"},
}
for _, tc := range cases {
t.Run(tc.desc, func(t *testing.T) {
got := IsInsecureSkipTLSVerify(tc.env)
if got != tc.want {
t.Errorf("IsInsecureSkipTLSVerify(%q) = %v, want %v", tc.env, got, tc.want)
}
})
}
}
// TestExchangerInsecureSkipTLSVerifyAppliesToClient 驗證開關開時,預設建構的
// http.Client 的 Transport.TLSClientConfig.InsecureSkipVerify == true。
func TestExchangerInsecureSkipTLSVerifyAppliesToClient(t *testing.T) {
// 開關開:用一個會拒絕未知 CA 的 TLS server確認沒有 x509 錯誤即代表已跳過驗證。
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
// 雲端真實格式:{success:true, data:{...}}。
_ = json.NewEncoder(w).Encode(exchangeResponse{
Success: true,
Data: exchangeResponseData{
SessionToken: "vAs_" + strings.Repeat("a", 64),
Account: "skip@visionA.local",
},
})
}))
defer srv.Close()
// httptest TLS server 用自簽憑證,預設 client 會 x509 失敗;開關開應成功。
ex := NewHTTPPairingExchanger(srv.URL)
ex.InsecureSkipTLSVerify = true
r, err := ex.Exchange("vAc_0123456789abcdef0123456789abcdef")
if err != nil {
t.Fatalf("Exchange with InsecureSkipTLSVerify=true should succeed against self-signed server, got: %v", err)
}
if r.Account != "skip@visionA.local" {
t.Errorf("Account = %q", r.Account)
}
}
// TestExchangerSecureByDefaultRejectsSelfSigned 驗證開關關(預設)時,連自簽憑證
// server 會因 TLS 驗證失敗而報 network error不會跳過驗證
func TestExchangerSecureByDefaultRejectsSelfSigned(t *testing.T) {
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
}))
defer srv.Close()
ex := NewHTTPPairingExchanger(srv.URL) // InsecureSkipTLSVerify 預設 false
_, err := ex.Exchange("vAc_0123456789abcdef0123456789abcdef")
if err == nil {
t.Fatal("expected TLS verification error against self-signed server when skip disabled")
}
if !errors.Is(err, ErrExchangeNetwork) {
t.Errorf("err = %v, want wrapped ErrExchangeNetwork (TLS x509 failure)", err)
}
}
func TestExchangeReal404HintsMockMode(t *testing.T) {
// 模擬 AB11 尚未上線時 endpoint 不存在的情境
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(404)
}))
defer srv.Close()
ex := NewHTTPPairingExchanger(srv.URL)
_, err := ex.Exchange("vAc_0123456789abcdef0123456789abcdef")
if err == nil {
t.Fatal("expected error on 404")
}
if !strings.Contains(err.Error(), "mock_mode") && !strings.Contains(err.Error(), "AB11") {
t.Errorf("err = %v — should hint mock_mode or AB11", err)
}
}
// ==========================================================================
// WP-0ADR-018 序號地基exchange payload 帶本地 USB 裝置清單
// ==========================================================================
// TestExchangeReal_PayloadIncludesDevices 驗證 DeviceLister 有清單時,
// exchange request body 帶 devices含 serial_number序號得以上雲。
func TestExchangeReal_PayloadIncludesDevices(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("a", 64)},
})
}))
defer srv.Close()
ex := NewHTTPPairingExchanger(srv.URL)
ex.DeviceLister = func() ([]LocalDevice, error) {
return []LocalDevice{
{SerialNumber: "0x1A2B3C4D", DeviceType: "kneron_kl520", Firmware: "KDP"},
{SerialNumber: " 0x0E5F6071 ", 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) != 2 {
t.Fatalf("devices 長度 = %d, want 2", 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"])
}
if d0["device_type"] != "kneron_kl520" {
t.Errorf("devices[0].device_type = %v, want kneron_kl520", d0["device_type"])
}
d1 := devices[1].(map[string]interface{})
if d1["serial_number"] != "0x0E5F6071" {
t.Errorf("devices[1].serial_number = %v應 trim 空白), want 0x0E5F6071", d1["serial_number"])
}
}
// TestExchangeReal_DeviceListerFails_ExchangeStillSucceeds 驗證不弄壞守則:
// 撈本地清單失敗時 exchange 照常成功payload 無 devices且失敗有留 log。
func TestExchangeReal_DeviceListerFails_ExchangeStillSucceeds(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("b", 64)},
})
}))
defer srv.Close()
var logged []string
ex := NewHTTPPairingExchanger(srv.URL)
ex.DeviceLister = func() ([]LocalDevice, error) {
return nil, errors.New("local server unreachable")
}
ex.Logf = func(format string, args ...interface{}) {
logged = append(logged, format)
}
if _, err := ex.Exchange("vAc_0123456789abcdef0123456789abcdef"); err != nil {
t.Fatalf("Exchange 不應因撈清單失敗而失敗:%v", err)
}
if _, has := gotBody["devices"]; has {
t.Errorf("撈清單失敗時 payload 不應帶 devices%v", gotBody["devices"])
}
if len(logged) == 0 {
t.Error("撈清單失敗應留 logno silent failures")
}
}
// TestExchangeReal_NoDeviceLister_NoDevicesField 驗證未注入 DeviceLister舊行為
// 時 payload 完全不帶 devices 欄位omitempty 相容性)。
func TestExchangeReal_NoDeviceLister_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("c", 64)},
})
}))
defer srv.Close()
ex := NewHTTPPairingExchanger(srv.URL)
if _, err := ex.Exchange("vAc_0123456789abcdef0123456789abcdef"); err != nil {
t.Fatalf("Exchange: %v", err)
}
if strings.Contains(string(rawBody), "devices") {
t.Errorf("無 DeviceLister 時 payload 不應含 devices 欄位:%s", rawBody)
}
}
// TestExchangeReal_AllEmptySerials_NoDevicesField 驗證 S-3DeviceLister 回的
// 裝置全部 serial 皆空時payload 省略 devices 欄位(與無 DeviceLister 舊行為一致),
// 不送出對雲端無意義的空 serial 陣列。
func TestExchangeReal_AllEmptySerials_NoDevicesField(t *testing.T) {
var rawBody []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
buf := new(strings.Builder)
_, _ = io.Copy(buf, r.Body)
rawBody = []byte(buf.String())
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(exchangeResponse{
Success: true,
Data: exchangeResponseData{SessionToken: "vAs_" + strings.Repeat("d", 64)},
})
}))
defer srv.Close()
ex := NewHTTPPairingExchanger(srv.URL)
ex.DeviceLister = func() ([]LocalDevice, error) {
// 兩顆都無序號no-SDK demo / 撈到但序號空白)。
return []LocalDevice{
{SerialNumber: "", DeviceType: "kneron_kl520"},
{SerialNumber: " ", DeviceType: "kneron_kl720"},
}, nil
}
if _, err := ex.Exchange("vAc_0123456789abcdef0123456789abcdef"); err != nil {
t.Fatalf("Exchange: %v", err)
}
if strings.Contains(string(rawBody), "devices") {
t.Errorf("全部 serial 皆空時 payload 不應含 devices 欄位:%s", rawBody)
}
}
// TestExchangeReal_PartialEmptySerials_OnlyKeepsNonEmpty 驗證 S-3混合清單中
// 空 serial 被濾掉、只留有序號的裝置。
func TestExchangeReal_PartialEmptySerials_OnlyKeepsNonEmpty(t *testing.T) {
var gotBody map[string]interface{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewDecoder(r.Body).Decode(&gotBody)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(exchangeResponse{
Success: true,
Data: exchangeResponseData{SessionToken: "vAs_" + strings.Repeat("e", 64)},
})
}))
defer srv.Close()
ex := NewHTTPPairingExchanger(srv.URL)
ex.DeviceLister = func() ([]LocalDevice, error) {
return []LocalDevice{
{SerialNumber: "", DeviceType: "kneron_kl520"},
{SerialNumber: "0x1A2B3C4D", DeviceType: "kneron_kl720"},
}, nil
}
if _, err := ex.Exchange("vAc_0123456789abcdef0123456789abcdef"); err != nil {
t.Fatalf("Exchange: %v", err)
}
devices, ok := gotBody["devices"].([]interface{})
if !ok {
t.Fatalf("request body 缺 devices 欄位:%v", gotBody)
}
if len(devices) != 1 {
t.Fatalf("devices 長度 = %d, want 1空 serial 應被濾除)", len(devices))
}
d0 := devices[0].(map[string]interface{})
if d0["serial_number"] != "0x1A2B3C4D" {
t.Errorf("devices[0].serial_number = %v, want 0x1A2B3C4D", d0["serial_number"])
}
}
// TestExchangeReal_SuccessFalse_ReturnsClearError 驗證 Mi-4HTTP 200 但
// envelope success=false 時回明確錯誤,而非誤導性的 "missing session_token"。
func TestExchangeReal_SuccessFalse_ReturnsClearError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
// 200 + success:false異常組合契約允許表達
_ = json.NewEncoder(w).Encode(exchangeResponse{Success: false})
}))
defer srv.Close()
ex := NewHTTPPairingExchanger(srv.URL)
_, err := ex.Exchange("vAc_0123456789abcdef0123456789abcdef")
if err == nil {
t.Fatal("expected error on 200 + success=false")
}
if !strings.Contains(err.Error(), "success=false") {
t.Errorf("err = %v, want to mention success=false非誤導性 missing session_token", err)
}
if strings.Contains(err.Error(), "missing session_token") {
t.Errorf("err = %v — 不應落到誤導性的 missing session_token 訊息", err)
}
}
// TestExchange_EndToEnd_ScanSerialReachesPayload 是端到端驗證:用真實的
// NewLocalDeviceLister打 POST /api/devices/scan串起「配對前 rescan → 撈到序號
// → 序號進 exchange payload」的完整鏈路。模擬時序 bug 修復後的正確流程agent
// 啟動時快取空,配對觸發 rescan 才偵測到剛插上的實體 KL520。
func TestExchange_EndToEnd_ScanSerialReachesPayload(t *testing.T) {
// 本地 serverGET /api/devices 回空(快取,模擬啟動時 USB 沒插好),
// POST /api/devices/scan 回剛偵測到的實體裝置。
localSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/devices":
_, _ = w.Write([]byte(`{"success":true,"data":{"devices":[]}}`))
case r.Method == http.MethodPost && r.URL.Path == "/api/devices/scan":
_, _ = w.Write([]byte(`{"success":true,"data":{"devices":[
{"id":"kl520-0","type":"kneron_kl520","serialNumber":"0xB906162C","firmwareVersion":"KDP","status":"detected"}
]}}`))
default:
w.WriteHeader(404)
}
}))
defer localSrv.Close()
// 雲端 exchange server記下 payload 的 devices。
var gotBody map[string]interface{}
cloudSrv := 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("f", 64)},
})
}))
defer cloudSrv.Close()
localPort := portFromTestServerURL(t, localSrv.URL)
ex := NewHTTPPairingExchanger(cloudSrv.URL)
ex.DeviceLister = NewLocalDeviceLister(localPort) // 真實 lister打 scan 端點)
if _, err := ex.Exchange("vAc_0123456789abcdef0123456789abcdef"); err != nil {
t.Fatalf("Exchange: %v", err)
}
devices, ok := gotBody["devices"].([]interface{})
if !ok {
t.Fatalf("配對前 rescan 應撈到序號並帶進 payload但 devices 欄位缺失:%v", gotBody)
}
if len(devices) != 1 {
t.Fatalf("devices 長度 = %d, want 1", len(devices))
}
d0 := devices[0].(map[string]interface{})
if d0["serial_number"] != "0xB906162C" {
t.Errorf("payload serial_number = %v, want 0xB906162Crescan 撈到的序號)", d0["serial_number"])
}
}
// TestExchange_ScanTimeout_DoesNotBreakPairing 驗證核心安全守則:配對前 rescan
// 若逾時(真 SDK scan 卡住不可中斷配對——DeviceLister 逾時回 error →
// collectLocalDevices fallback 不帶 devices → exchange 照常成功。
//
// 用一個「回應慢於 lister timeout」的 scan 端點模擬 SDK 卡住。lister 的
// localDeviceScanTimeout 是 8s測試不想真的等 8s改注入一個 timeout 極短的
// 自訂 lister等價語意scan 回應慢於 client timeout → context deadline
func TestExchange_ScanTimeout_DoesNotBreakPairing(t *testing.T) {
// scan 端點故意 sleep超過下方短 timeout client。
slowScan := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(200 * time.Millisecond)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":true,"data":{"devices":[{"id":"kl520-0","serialNumber":"0xB906162C"}]}}`))
}))
defer slowScan.Close()
scanPort := portFromTestServerURL(t, slowScan.URL)
var gotBody map[string]interface{}
cloudSrv := 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("a", 64)},
})
}))
defer cloudSrv.Close()
// 短 timeout client 打 slow scan → 逾時 error等價於 8s SDK scan 卡住)。
shortClient := &http.Client{Timeout: 50 * time.Millisecond}
scanURL := fmt.Sprintf("http://127.0.0.1:%d/api/devices/scan", scanPort)
timingOutLister := func() ([]LocalDevice, error) {
resp, err := shortClient.Post(scanURL, "application/json", nil)
if err != nil {
return nil, err // 逾時走這裡
}
defer resp.Body.Close()
return nil, nil
}
var logged int
ex := NewHTTPPairingExchanger(cloudSrv.URL)
ex.DeviceLister = timingOutLister
ex.Logf = func(format string, args ...interface{}) { logged++ }
if _, err := ex.Exchange("vAc_0123456789abcdef0123456789abcdef"); err != nil {
t.Fatalf("rescan 逾時不應中斷配對,但 Exchange 失敗:%v", err)
}
if _, has := gotBody["devices"]; has {
t.Errorf("rescan 逾時 fallback 時 payload 不應帶 devices%v", gotBody["devices"])
}
if logged == 0 {
t.Error("rescan 逾時應留 logno silent failures")
}
}
// TestNewLocalDeviceLister_TriggersScan 驗證配對前的 DeviceLister 打的是
// `POST /api/devices/scan`Rescan、重新偵測 USB而非 `GET /api/devices`
// (快取),並能解析回傳的 envelopesuccess + data.devices
//
// 這是時序修正的核心行為agent 啟動時 USB 未插好 → 快取空 → 配對前必須先
// rescan 才撈得到剛插上的實體序號。
func TestNewLocalDeviceLister_TriggersScan(t *testing.T) {
var gotMethod, gotPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotMethod = r.Method
gotPath = r.URL.Path
if r.Method != http.MethodPost || r.URL.Path != "/api/devices/scan" {
w.WriteHeader(404)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"success": true,
"data": {
"devices": [
{"id":"kl520-0","name":"Kneron KL520 #1","type":"kneron_kl520","serialNumber":"0x1A2B3C4D","firmwareVersion":"KDP","status":"detected"},
{"id":"kl720-0","name":"Kneron KL720 #1","type":"kneron_kl720","status":"detected"}
]
}
}`))
}))
defer srv.Close()
port := portFromTestServerURL(t, srv.URL)
lister := NewLocalDeviceLister(port)
devs, err := lister()
if err != nil {
t.Fatalf("lister: %v", err)
}
if gotMethod != http.MethodPost || gotPath != "/api/devices/scan" {
t.Fatalf("DeviceLister 應打 POST /api/devices/scan觸發 rescan實際 %s %s", gotMethod, gotPath)
}
if len(devs) != 2 {
t.Fatalf("devices = %d, want 2", len(devs))
}
if devs[0].SerialNumber != "0x1A2B3C4D" || devs[0].DeviceType != "kneron_kl520" || devs[0].Firmware != "KDP" {
t.Errorf("devs[0] = %+v", devs[0])
}
if devs[1].SerialNumber != "" {
t.Errorf("devs[1].SerialNumber = %q, want emptyserialNumber omitempty", devs[1].SerialNumber)
}
}
// TestNewLocalDeviceLister_ScanFindsFreshlyPluggedDevice 模擬時序 bug 的場景:
// agent 啟動時快取空GET /api/devices 回 0 顆),插上 USB 後配對觸發 rescan
// POST /api/devices/scan才偵測到裝置。驗證 DeviceLister 走的是 scan 路徑、
// 因此能撈到剛插上的序號。
func TestNewLocalDeviceLister_ScanFindsFreshlyPluggedDevice(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/devices":
// 快取:啟動時 USB 沒插好 → 0 顆(若 lister 誤打這裡就撈不到序號)。
_, _ = w.Write([]byte(`{"success":true,"data":{"devices":[]}}`))
case r.Method == http.MethodPost && r.URL.Path == "/api/devices/scan":
// rescan重新偵測到剛插上的實體 KL520。
_, _ = w.Write([]byte(`{"success":true,"data":{"devices":[
{"id":"kl520-0","type":"kneron_kl520","serialNumber":"0xB906162C","firmwareVersion":"KDP","status":"detected"}
]}}`))
default:
w.WriteHeader(404)
}
}))
defer srv.Close()
port := portFromTestServerURL(t, srv.URL)
lister := NewLocalDeviceLister(port)
devs, err := lister()
if err != nil {
t.Fatalf("lister: %v", err)
}
if len(devs) != 1 || devs[0].SerialNumber != "0xB906162C" {
t.Fatalf("rescan 應撈到剛插上的序號 0xB906162C實際 %+v", devs)
}
}
// TestNewLocalDeviceLister_Unreachable 驗證 local server 不可達時回 error
// (由 collectLocalDevices 轉為「不帶 devices、exchange 照常」)。
func TestNewLocalDeviceLister_Unreachable(t *testing.T) {
// 先開再關,拿一個幾乎必然沒人聽的 port。
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
port := portFromTestServerURL(t, srv.URL)
srv.Close()
lister := NewLocalDeviceLister(port)
if _, err := lister(); err == nil {
t.Error("lister 對不可達的 local server 應回 error")
}
}
// portFromTestServerURL 從 httptest server URLhttp://127.0.0.1:PORT解出 port。
func portFromTestServerURL(t *testing.T, rawURL string) int {
t.Helper()
u, err := url.Parse(rawURL)
if err != nil {
t.Fatalf("parse url %q: %v", rawURL, err)
}
port, err := strconv.Atoi(u.Port())
if err != nil {
t.Fatalf("parse port from %q: %v", rawURL, err)
}
return port
}