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

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

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

660 lines
24 KiB
Go
Raw 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"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
)
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)
}
}
// TestNewLocalDeviceLister_ParsesEnvelope 驗證 NewLocalDeviceLister 能解析
// local server GET /api/devices 的 envelopesuccess + data.devices
func TestNewLocalDeviceLister_ParsesEnvelope(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/devices" {
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 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_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
}