- 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>
660 lines
24 KiB
Go
660 lines
24 KiB
Go
// 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 code(INVALID_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 → 落 default(fmt 包 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-A3:mock 模式必須明確 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-0(ADR-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("撈清單失敗應留 log(no 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-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 能解析
|
||
// local server GET /api/devices 的 envelope(success + 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 empty(serialNumber 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 URL(http://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
|
||
}
|