test: extend yonexus integration coverage

This commit is contained in:
nav
2026-04-09 01:13:49 +00:00
parent 477ccc8e5a
commit 25a59adb5d
4 changed files with 251 additions and 75 deletions

View File

@@ -1052,7 +1052,7 @@
### YNX-1104 编写 Server-Client 集成测试 ### YNX-1104 编写 Server-Client 集成测试
**状态** **状态**
- [x] 框架已完成2026-04-09 - [x] 已完成2026-04-09
**目标** **目标**
- 覆盖首次配对、正常重连、认证失败、心跳超时、re-pair - 覆盖首次配对、正常重连、认证失败、心跳超时、re-pair
@@ -1061,13 +1061,16 @@
- 已创建 `tests/integration/framework.test.ts` 集成测试框架 - 已创建 `tests/integration/framework.test.ts` 集成测试框架
- 提供 `MockTransportPair` 模拟 Server-Client 网络通信 - 提供 `MockTransportPair` 模拟 Server-Client 网络通信
- 提供 `createIntegrationTestContext()` 快速创建集成测试环境 - 提供 `createIntegrationTestContext()` 快速创建集成测试环境
- 实现首批集成测试用例: - 已修正集成测试框架中的时间推进问题,`advanceTime()` 现在会真实驱动 Server / Client runtime 的 `now()`
- 已实现以下集成测试用例:
- 首次配对完整流程hello → pair_request → pair_confirm → auth → heartbeat - 首次配对完整流程hello → pair_request → pair_confirm → auth → heartbeat
- 带凭证的重连流程(跳过配对直接认证) - 带凭证的重连流程(跳过配对直接认证)
- 心跳交换验证 - 认证后的心跳交换验证
- 心跳超时触发 `unstable` / `offline``disconnect_notice`
- nonce collision 触发 `re_pair_required` 后 client 回退到 `pair_required`
**待完成** **待完成**
- 更多边界场景心跳超时断线、re-pair 触发、并发连接 - 并发连接等剩余边界场景
- 真实 WebSocket 传输层集成测试(可选) - 真实 WebSocket 传输层集成测试(可选)
--- ---
@@ -1090,7 +1093,7 @@
### YNX-1105 编写失败路径测试矩阵 ### YNX-1105 编写失败路径测试矩阵
**状态** **状态**
- [x] 框架与 PF 测试已完成2026-04-09 - [x] 部分关键路径已完成,仍有少量尾项2026-04-09
**目标** **目标**
- 系统性覆盖 pairing/auth 失败路径 - 系统性覆盖 pairing/auth 失败路径
@@ -1130,14 +1133,18 @@
- Edge Cases: 并发配对、过期清理验证 - Edge Cases: 并发配对、过期清理验证
- 已新增 `Yonexus.Server/tests/auth-failures.test.ts` - 已新增 `Yonexus.Server/tests/auth-failures.test.ts`
- AF-07: nonce collision 触发 re_pair_required - AF-01 / AF-02unknown identifier、not_paired
- AF-08: rate limit 触发 re_pair_required - AF-03 / AF-09 / AF-11invalid signature、wrong public key、tampered proof
- AF-05 / AF-06stale / future timestamp
- AF-07 / AF-08nonce collision / rate limit 触发 re_pair_required
- AF-10malformed auth_request payload
- 覆盖 re_pair 后 secret 清理与 pairingStatus=revoked - 覆盖 re_pair 后 secret 清理与 pairingStatus=revoked
- 已同步更新 `tests/failure-path/MATRIX.md` 的 PF / AF / RP / HF 状态标记与当前备注
**待完成** **待完成**
- AFAuthentication Failures剩余场景stale/future timestamp、invalid signature 等) - AF-04当前实现未单独暴露 `invalid_secret` 分支,需先决定是否保留该错误码语义
- RPRe-pairing Triggers测试套件 - RP 其余触发场景
- CF/HF/SR 边界场景测试 - CF / HF / SR 其余边界场景测试
--- ---

View File

@@ -15,13 +15,13 @@ This document defines the systematic test coverage for pairing and authenticatio
| ID | Scenario | Trigger | Expected Behavior | Status | | ID | Scenario | Trigger | Expected Behavior | Status |
|----|----------|---------|-------------------|--------| |----|----------|---------|-------------------|--------|
| PF-01 | Invalid pairing code | Client submits wrong code | `pair_failed(invalid_code)`, allow retry | | | PF-01 | Invalid pairing code | Client submits wrong code | `pair_failed(invalid_code)`, allow retry | |
| PF-02 | Expired pairing code | Client submits after expiry | `pair_failed(expired)`, reset to `pair_required` | | | PF-02 | Expired pairing code | Client submits after expiry | `pair_failed(expired)`, reset to `pair_required` | |
| PF-03 | Identifier not in allowlist | Unknown client tries to pair | `pair_failed(identifier_not_allowed)`, close connection | | | PF-03 | Identifier not in allowlist | Unknown client tries to pair | `pair_failed(identifier_not_allowed)`, close connection | |
| PF-04 | Admin notification failed | Discord DM fails to send | `pair_failed(admin_notification_failed)`, abort pairing | | | PF-04 | Admin notification failed | Discord DM fails to send | `pair_failed(admin_notification_failed)`, abort pairing | |
| PF-05 | Empty pairing code | Client submits empty string | `pair_failed(invalid_code)` | | | PF-05 | Empty pairing code | Client submits empty string | `pair_failed(invalid_code)` | |
| PF-06 | Malformed pair_confirm payload | Missing required fields | Protocol error, no state change | | | PF-06 | Malformed pair_confirm payload | Missing required fields | Protocol error, no state change | |
| PF-07 | Double pairing attempt | Client calls pair_confirm twice | Second attempt rejected if already paired | | | PF-07 | Double pairing attempt | Client calls pair_confirm twice | Second attempt rejected if already paired | |
| PF-08 | Pairing during active session | Paired client tries to pair again | Reject, maintain existing trust | ⬜ | | PF-08 | Pairing during active session | Paired client tries to pair again | Reject, maintain existing trust | ⬜ |
| PF-09 | Server restart during pairing | Server restarts before confirm | Pairing state preserved, code still valid | ⬜ | | PF-09 | Server restart during pairing | Server restarts before confirm | Pairing state preserved, code still valid | ⬜ |
| PF-10 | Client restart during pairing | Client restarts before submit | Client must restart pairing flow | ⬜ | | PF-10 | Client restart during pairing | Client restarts before submit | Client must restart pairing flow | ⬜ |
@@ -32,17 +32,17 @@ This document defines the systematic test coverage for pairing and authenticatio
| ID | Scenario | Trigger | Expected Behavior | Status | | ID | Scenario | Trigger | Expected Behavior | Status |
|----|----------|---------|-------------------|--------| |----|----------|---------|-------------------|--------|
| AF-01 | Unknown identifier | Auth from unpaired client | `auth_failed(unknown_identifier)` | | | AF-01 | Unknown identifier | Auth from unpaired client | `auth_failed(unknown_identifier)` | |
| AF-02 | Not paired | Auth before pairing complete | `auth_failed(not_paired)` | | | AF-02 | Not paired | Auth before pairing complete | `auth_failed(not_paired)` | |
| AF-03 | Invalid signature | Wrong private key used | `auth_failed(invalid_signature)` | | | AF-03 | Invalid signature | Wrong private key used | `auth_failed(invalid_signature)` | |
| AF-04 | Wrong secret | Client has outdated secret | `auth_failed(invalid_secret)` | ⬜ | | AF-04 | Wrong secret | Client has outdated secret | `auth_failed(invalid_secret)` | ⬜ |
| AF-05 | Stale timestamp | Proof timestamp >10s old | `auth_failed(stale_timestamp)` | | | AF-05 | Stale timestamp | Proof timestamp >10s old | `auth_failed(stale_timestamp)` | |
| AF-06 | Future timestamp | Proof timestamp in future | `auth_failed(future_timestamp)` | | | AF-06 | Future timestamp | Proof timestamp in future | `auth_failed(future_timestamp)` | |
| AF-07 | Nonce collision | Reused nonce within window | `auth_failed(nonce_collision)``re_pair_required` 🔴 | ✅ | | AF-07 | Nonce collision | Reused nonce within window | `auth_failed(nonce_collision)``re_pair_required` 🔴 | ✅ |
| AF-08 | Rate limited | >10 attempts in 10s | `auth_failed(rate_limited)``re_pair_required` 🔴 | ✅ | | AF-08 | Rate limited | >10 attempts in 10s | `auth_failed(rate_limited)``re_pair_required` 🔴 | ✅ |
| AF-09 | Wrong public key | Key doesn't match stored | `auth_failed(invalid_signature)` | | | AF-09 | Wrong public key | Key doesn't match stored | `auth_failed(invalid_signature)` | |
| AF-10 | Malformed auth_request | Missing required fields | Protocol error | | | AF-10 | Malformed auth_request | Missing required fields | Protocol error | |
| AF-11 | Tampered proof | Modified signature | `auth_failed(invalid_signature)` | | | AF-11 | Tampered proof | Modified signature | `auth_failed(invalid_signature)` | |
--- ---
@@ -50,7 +50,7 @@ This document defines the systematic test coverage for pairing and authenticatio
| ID | Scenario | Cause | Server Action | Client Action | Status | | ID | Scenario | Cause | Server Action | Client Action | Status |
|----|----------|-------|---------------|---------------|--------| |----|----------|-------|---------------|---------------|--------|
| RP-01 | Nonce collision | Replay attack detected | Clear secret, reset state | Enter `pair_required` | | | RP-01 | Nonce collision | Replay attack detected | Clear secret, reset state | Enter `pair_required` | |
| RP-02 | Rate limit exceeded | Brute force detected | Clear secret, reset state | Enter `pair_required` | ⬜ | | RP-02 | Rate limit exceeded | Brute force detected | Clear secret, reset state | Enter `pair_required` | ⬜ |
| RP-03 | Admin-initiated | Manual revocation | Mark revoked, notify | Enter `pair_required` | ⬜ | | RP-03 | Admin-initiated | Manual revocation | Mark revoked, notify | Enter `pair_required` | ⬜ |
| RP-04 | Key rotation | Client sends new public key | Update key, keep secret | Continue with new key | ⬜ | | RP-04 | Key rotation | Client sends new public key | Update key, keep secret | Continue with new key | ⬜ |
@@ -75,8 +75,8 @@ This document defines the systematic test coverage for pairing and authenticatio
| ID | Scenario | Trigger | Expected Behavior | Status | | ID | Scenario | Trigger | Expected Behavior | Status |
|----|----------|---------|-------------------|--------| |----|----------|---------|-------------------|--------|
| HF-01 | 7-minute timeout | No heartbeat received | Status → `unstable`, notify | | | HF-01 | 7-minute timeout | No heartbeat received | Status → `unstable`, notify | |
| HF-02 | 11-minute timeout | Still no heartbeat | Status → `offline`, disconnect | | | HF-02 | 11-minute timeout | Still no heartbeat | Status → `offline`, disconnect | |
| HF-03 | Early heartbeat | Heartbeat before auth | Rejected/ignored | ⬜ | | HF-03 | Early heartbeat | Heartbeat before auth | Rejected/ignored | ⬜ |
| HF-04 | Heartbeat from unauthenticated | Wrong state | Error, possible disconnect | ⬜ | | HF-04 | Heartbeat from unauthenticated | Wrong state | Error, possible disconnect | ⬜ |
@@ -130,6 +130,11 @@ npm test -- connection-failures
npm test -- failure-paths npm test -- failure-paths
``` ```
### Current Notes
- AF-04 (`invalid_secret`) 仍未单独覆盖:现有实现把“错误 secret 导致的验签失败”统一落到 `invalid_signature`,是否拆分错误码仍待确认。
- 本轮已补齐 AF-01/02/03/05/06/09/10/11、RP-01、HF-01/02。
### Adding New Test Cases ### Adding New Test Cases
1. Add row to appropriate table above 1. Add row to appropriate table above

View File

@@ -1,10 +1,10 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import type { ClientConnection, ServerTransport } from "../Yonexus.Server/plugin/core/transport.js"; import type { ClientConnection, ServerTransport } from "../../Yonexus.Server/plugin/core/transport.js";
import type { ClientTransport } from "../Yonexus.Client/plugin/core/transport.js"; import type { ClientTransport } from "../../Yonexus.Client/plugin/core/transport.js";
import type { YonexusServerStore } from "../Yonexus.Server/plugin/core/store.js"; import type { YonexusServerStore } from "../../Yonexus.Server/plugin/core/store.js";
import type { YonexusClientStateStore } from "../Yonexus.Client/plugin/core/state.js"; import type { YonexusClientStateStore } from "../../Yonexus.Client/plugin/core/state.js";
import { createYonexusServerRuntime } from "../Yonexus.Server/plugin/core/runtime.js"; import { createYonexusServerRuntime } from "../../Yonexus.Server/plugin/core/runtime.js";
import { createYonexusClientRuntime } from "../Yonexus.Client/plugin/core/runtime.js"; import { createYonexusClientRuntime } from "../../Yonexus.Client/plugin/core/runtime.js";
import { import {
decodeBuiltin, decodeBuiltin,
encodeBuiltin, encodeBuiltin,
@@ -17,11 +17,12 @@ import {
buildAuthSuccess, buildAuthSuccess,
buildHeartbeat, buildHeartbeat,
buildHeartbeatAck, buildHeartbeatAck,
createAuthRequestSigningInput,
YONEXUS_PROTOCOL_VERSION YONEXUS_PROTOCOL_VERSION
} from "../Yonexus.Protocol/src/index.js"; } from "../../Yonexus.Protocol/src/index.js";
import { generateKeyPair } from "../Yonexus.Client/plugin/crypto/keypair.js"; import { generateKeyPair, signMessage } from "../../Yonexus.Client/plugin/crypto/keypair.js";
import type { ClientRecord } from "../Yonexus.Server/plugin/core/persistence.js"; import type { ClientRecord } from "../../Yonexus.Server/plugin/core/persistence.js";
import type { YonexusClientState } from "../Yonexus.Client/plugin/core/state.js"; import type { YonexusClientState } from "../../Yonexus.Client/plugin/core/state.js";
/** /**
* Yonexus Server-Client Integration Test Framework * Yonexus Server-Client Integration Test Framework
@@ -118,7 +119,7 @@ export function createMockTransportPair(): MockTransportPair {
}; };
// Client Transport Mock // Client Transport Mock
let clientState: import("../Yonexus.Client/plugin/core/transport.js").ClientConnectionState = "idle"; let clientState: import("../../Yonexus.Client/plugin/core/transport.js").ClientConnectionState = "idle";
const clientTransport: ClientTransport = { const clientTransport: ClientTransport = {
get state() { get state() {
@@ -244,18 +245,22 @@ export async function createIntegrationTestContext(
paired?: boolean; paired?: boolean;
authenticated?: boolean; authenticated?: boolean;
serverTime?: number; serverTime?: number;
initialClientState?: Partial<YonexusClientState>;
initialServerClients?: ClientRecord[];
} = {} } = {}
): Promise<IntegrationTestContext> { ): Promise<IntegrationTestContext> {
const now = options.serverTime ?? 1_710_000_000; const initialNow = options.serverTime ?? 1_710_000_000;
const identifier = options.clientIdentifier ?? "test-client"; const identifier = options.clientIdentifier ?? "test-client";
const transports = createMockTransportPair(); const transports = createMockTransportPair();
const serverStore = createMockServerStore(); const serverStore = createMockServerStore(options.initialServerClients ?? []);
const clientStore = createMockClientStore({ identifier }); const clientStore = createMockClientStore({ identifier, ...options.initialClientState });
// Generate keypair for client if needed // Generate keypair for client if needed
const keyPair = await generateKeyPair(); const keyPair = await generateKeyPair();
let currentTime = initialNow;
const serverRuntime = createYonexusServerRuntime({ const serverRuntime = createYonexusServerRuntime({
config: { config: {
followerIdentifiers: [identifier], followerIdentifiers: [identifier],
@@ -266,7 +271,7 @@ export async function createIntegrationTestContext(
}, },
store: serverStore, store: serverStore,
transport: transports.serverTransport, transport: transports.serverTransport,
now: () => now now: () => currentTime
}); });
const clientRuntime = createYonexusClientRuntime({ const clientRuntime = createYonexusClientRuntime({
@@ -278,12 +283,10 @@ export async function createIntegrationTestContext(
}, },
transport: transports.clientTransport, transport: transports.clientTransport,
stateStore: clientStore, stateStore: clientStore,
now: () => now now: () => currentTime
}); });
await serverRuntime.start(); await serverRuntime.start();
let currentTime = now;
const advanceTime = (seconds: number) => { const advanceTime = (seconds: number) => {
currentTime += seconds; currentTime += seconds;
}; };
@@ -398,26 +401,27 @@ describe("Yonexus Server-Client Integration", () => {
const ctx = await createIntegrationTestContext({ const ctx = await createIntegrationTestContext({
clientIdentifier: "reconnect-client", clientIdentifier: "reconnect-client",
paired: true, paired: true,
authenticated: false authenticated: false,
}); initialClientState: {
secret: "existing-secret",
// Pre-populate client with existing credentials publicKey: keyPair.publicKey.trim(),
ctx.clientRuntime.state.clientState.secret = "existing-secret"; privateKey: keyPair.privateKey,
ctx.clientRuntime.state.clientState.publicKey = keyPair.publicKey; pairedAt: now - 1000,
ctx.clientRuntime.state.clientState.privateKey = keyPair.privateKey; updatedAt: now - 1000
ctx.clientRuntime.state.clientState.pairedAt = now - 1000; },
initialServerClients: [
// Pre-populate server with client record {
ctx.serverRuntime.state.registry.clients.set("reconnect-client", { identifier: "reconnect-client",
identifier: "reconnect-client", pairingStatus: "paired",
pairingStatus: "paired", publicKey: keyPair.publicKey.trim(),
publicKey: keyPair.publicKey, secret: "existing-secret",
secret: "existing-secret", status: "offline",
status: "offline", recentNonces: [],
recentNonces: [], recentHandshakeAttempts: [],
recentHandshakeAttempts: [], createdAt: now - 2000,
createdAt: now - 2000, updatedAt: now - 1000
updatedAt: now - 1000 }
]
}); });
// Connect and send hello // Connect and send hello
@@ -442,25 +446,185 @@ describe("Yonexus Server-Client Integration", () => {
describe("Heartbeat Flow", () => { describe("Heartbeat Flow", () => {
it("exchanges heartbeats after authentication", async () => { it("exchanges heartbeats after authentication", async () => {
const now = 1_710_000_000;
const keyPair = await generateKeyPair();
const ctx = await createIntegrationTestContext({ const ctx = await createIntegrationTestContext({
clientIdentifier: "heartbeat-client", clientIdentifier: "heartbeat-client",
paired: true, serverTime: now,
authenticated: true initialClientState: {
secret: "existing-secret",
publicKey: keyPair.publicKey.trim(),
privateKey: keyPair.privateKey,
pairedAt: now - 1000,
updatedAt: now - 1000
},
initialServerClients: [
{
identifier: "heartbeat-client",
pairingStatus: "paired",
publicKey: keyPair.publicKey.trim(),
secret: "existing-secret",
status: "offline",
recentNonces: [],
recentHandshakeAttempts: [],
createdAt: now - 2000,
updatedAt: now - 1000
}
]
}); });
// Manually set authenticated state await ctx.clientRuntime.start();
ctx.clientRuntime.state.phase = "authenticated"; ctx.clientRuntime.handleTransportStateChange("connected");
await vi.advanceTimersByTimeAsync(100);
await ctx.processClientToServer();
await ctx.processServerToClient();
await vi.advanceTimersByTimeAsync(100);
await ctx.processClientToServer();
await ctx.processServerToClient();
expect(ctx.clientRuntime.state.phase).toBe("authenticated");
// Trigger heartbeat
await ctx.clientRuntime.handleMessage("heartbeat_tick"); await ctx.clientRuntime.handleMessage("heartbeat_tick");
await vi.advanceTimersByTimeAsync(100); await vi.advanceTimersByTimeAsync(100);
// Process heartbeat -> heartbeat_ack
await ctx.processClientToServer(); await ctx.processClientToServer();
// Verify server updated heartbeat timestamp
const record = ctx.serverRuntime.state.registry.clients.get("heartbeat-client"); const record = ctx.serverRuntime.state.registry.clients.get("heartbeat-client");
expect(record?.lastHeartbeatAt).toBeDefined(); expect(record?.lastHeartbeatAt).toBeDefined();
}); });
it("marks client unstable then offline after heartbeat timeout", async () => {
const now = 1_710_000_000;
const keyPair = await generateKeyPair();
const ctx = await createIntegrationTestContext({
clientIdentifier: "timed-out-client",
serverTime: now
});
ctx.serverRuntime.state.registry.clients.set("timed-out-client", {
identifier: "timed-out-client",
pairingStatus: "paired",
publicKey: keyPair.publicKey.trim(),
secret: "existing-secret",
status: "online",
recentNonces: [],
recentHandshakeAttempts: [],
lastAuthenticatedAt: now,
lastHeartbeatAt: now,
createdAt: now - 100,
updatedAt: now
});
ctx.serverRuntime.state.registry.sessions.set("timed-out-client", {
identifier: "timed-out-client",
socket: { close: vi.fn() } as unknown as WebSocket,
isAuthenticated: true,
connectedAt: now,
lastActivityAt: now,
publicKey: keyPair.publicKey.trim()
});
ctx.transports.serverTransport.connections.set("timed-out-client", {
identifier: "timed-out-client",
ws: { close: vi.fn() } as unknown as WebSocket,
connectedAt: now,
isAuthenticated: true
});
ctx.advanceTime(7 * 60);
await vi.advanceTimersByTimeAsync(30_100);
const unstableRecord = ctx.serverRuntime.state.registry.clients.get("timed-out-client");
expect(unstableRecord?.status).toBe("unstable");
expect(ctx.transports.channel.serverToClient.some((message) => {
const envelope = decodeBuiltin(message);
return envelope.type === "status_update";
})).toBe(true);
ctx.advanceTime(4 * 60);
await vi.advanceTimersByTimeAsync(30_100);
const offlineRecord = ctx.serverRuntime.state.registry.clients.get("timed-out-client");
expect(offlineRecord?.status).toBe("offline");
expect(ctx.serverRuntime.state.registry.sessions.has("timed-out-client")).toBe(false);
expect(ctx.transports.channel.serverToClient.some((message) => {
const envelope = decodeBuiltin(message);
return envelope.type === "disconnect_notice";
})).toBe(true);
});
});
describe("Re-pair Flow", () => {
it("forces client back to pair_required after nonce collision", async () => {
const now = 1_710_000_000;
const keyPair = await generateKeyPair();
const collisionNonce = "NONCE1234567890123456789";
const ctx = await createIntegrationTestContext({
clientIdentifier: "collision-client",
serverTime: now,
initialClientState: {
secret: "existing-secret",
publicKey: keyPair.publicKey.trim(),
privateKey: keyPair.privateKey,
pairedAt: now - 100,
updatedAt: now - 100
},
initialServerClients: [
{
identifier: "collision-client",
pairingStatus: "paired",
publicKey: keyPair.publicKey.trim(),
secret: "existing-secret",
status: "offline",
recentNonces: [{ nonce: collisionNonce, timestamp: now - 1 }],
recentHandshakeAttempts: [],
createdAt: now - 200,
updatedAt: now - 100
}
]
});
ctx.serverRuntime.state.registry.sessions.set("collision-client", {
identifier: "collision-client",
socket: { close: vi.fn() } as unknown as WebSocket,
isAuthenticated: false,
connectedAt: now,
lastActivityAt: now,
publicKey: keyPair.publicKey.trim()
});
const authRequest = buildAuthRequest(
{
identifier: "collision-client",
nonce: collisionNonce,
proofTimestamp: now,
signature: await signMessage(
keyPair.privateKey,
createAuthRequestSigningInput({
secret: "existing-secret",
nonce: collisionNonce,
proofTimestamp: now
})
),
publicKey: keyPair.publicKey.trim()
},
{ requestId: "req-collision", timestamp: now }
);
await ctx.serverRuntime.handleMessage(
{
identifier: "collision-client",
ws: { close: vi.fn() } as unknown as WebSocket,
connectedAt: now,
isAuthenticated: false
},
encodeBuiltin(authRequest)
);
const serverEnvelope = decodeBuiltin(ctx.transports.channel.serverToClient.at(-1) ?? "");
expect(serverEnvelope.type).toBe("re_pair_required");
await ctx.clientRuntime.handleMessage(ctx.transports.channel.serverToClient.at(-1)!);
expect(ctx.clientRuntime.state.phase).toBe("pair_required");
expect(ctx.clientRuntime.state.clientState.secret).toBeUndefined();
expect(ctx.clientRuntime.state.lastPairingFailure).toBe("re_pair_required");
});
}); });
}); });