// 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 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) } } // TestExchange_EndToEnd_ScanSerialReachesPayload 是端到端驗證:用真實的 // NewLocalDeviceLister(打 POST /api/devices/scan)串起「配對前 rescan → 撈到序號 // → 序號進 exchange payload」的完整鏈路。模擬時序 bug 修復後的正確流程:agent // 啟動時快取空,配對觸發 rescan 才偵測到剛插上的實體 KL520。 func TestExchange_EndToEnd_ScanSerialReachesPayload(t *testing.T) { // 本地 server:GET /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 0xB906162C(rescan 撈到的序號)", 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 逾時應留 log(no silent failures)") } } // TestNewLocalDeviceLister_TriggersScan 驗證配對前的 DeviceLister 打的是 // `POST /api/devices/scan`(Rescan、重新偵測 USB)而非 `GET /api/devices` // (快取),並能解析回傳的 envelope(success + 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 empty(serialNumber 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 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 }