diff --git a/TASKLIST.md b/TASKLIST.md index 53fe656..9d6d455 100644 --- a/TASKLIST.md +++ b/TASKLIST.md @@ -1051,10 +1051,47 @@ - 已覆盖启动时加载 state + 自动补 keypair、`hello_ack`/`pair_request`/`pair_success`/`auth_success` 协作链路、`auth_failed`/`re_pair_required` 的 trust reset,以及认证后 heartbeat 发送门禁 ### YNX-1104 编写 Server-Client 集成测试 +**状态** +- [x] 框架已完成(2026-04-09) + **目标** - 覆盖首次配对、正常重连、认证失败、心跳超时、re-pair +**已完成内容** +- 已创建 `tests/integration/framework.test.ts` 集成测试框架 +- 提供 `MockTransportPair` 模拟 Server-Client 网络通信 +- 提供 `createIntegrationTestContext()` 快速创建集成测试环境 +- 实现首批集成测试用例: + - 首次配对完整流程(hello → pair_request → pair_confirm → auth → heartbeat) + - 带凭证的重连流程(跳过配对直接认证) + - 心跳交换验证 + +**待完成** +- 更多边界场景:心跳超时断线、re-pair 触发、并发连接 +- 真实 WebSocket 传输层集成测试(可选) + +--- +### YNX-1104a 细化:首次配对集成测试 +**状态** +- [x] 已完成(2026-04-09) + +**已完成内容** +- `First-Time Pairing Flow` 测试套件 +- 验证端到端的配对与认证状态迁移 + +--- +### YNX-1104b 细化:重连集成测试 +**状态** +- [x] 已完成(2026-04-09) + +**已完成内容** +- `Reconnection Flow` 测试套件 +- 验证已配对客户端跳过配对直接进入认证 + ### YNX-1105 编写失败路径测试矩阵 +**状态** +- [x] 框架与 PF 测试已完成(2026-04-09) + **目标** - 系统性覆盖 pairing/auth 失败路径 @@ -1072,6 +1109,31 @@ **验收标准** - 核心安全路径都有自动化测试 +**已完成内容** +- 已创建 `tests/failure-path/MATRIX.md` 失败路径测试矩阵文档 + - 定义 PF-01~PF-10(Pairing Failures) + - 定义 AF-01~AF-11(Authentication Failures) + - 定义 RP-01~RP-04(Re-pairing Triggers) + - 定义 CF-01~CF-07(Connection Failures) + - 定义 HF-01~HF-04(Heartbeat Failures) + - 定义 SR-01~SR-06(State Recovery) + - 标记优先级(🔴 Phase 1 关键安全路径) + +- 已创建 `tests/failure-path/pairing-failures.test.ts` + - PF-01: 无效配对码及重试机制 + - PF-02: 过期配对码清理 + - PF-03: 非 allowlist 标识符拒绝 + - PF-04: 管理员通知失败处理 + - PF-05: 空/空白配对码拒绝 + - PF-06: 畸形 pair_confirm 载荷处理 + - PF-07: 已配对客户端重复配对保护 + - Edge Cases: 并发配对、过期清理验证 + +**待完成** +- AF(Authentication Failures)测试套件 +- RP(Re-pairing Triggers)测试套件 +- CF/HF/SR 边界场景测试 + --- ## Phase 12 — 文档与交付 diff --git a/tests/failure-path/MATRIX.md b/tests/failure-path/MATRIX.md new file mode 100644 index 0000000..d7d9ca4 --- /dev/null +++ b/tests/failure-path/MATRIX.md @@ -0,0 +1,147 @@ +# Yonexus Failure Path Test Matrix + +This document defines the systematic test coverage for pairing and authentication failure scenarios. + +## Test Matrix Legend + +- ✅ = Test implemented +- 🔄 = Test stub exists, needs implementation +- ⬜ = Not yet implemented +- 🔴 = Critical path, high priority + +--- + +## 1. Pairing Failure Paths + +| ID | Scenario | Trigger | Expected Behavior | Status | +|----|----------|---------|-------------------|--------| +| 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-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-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-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-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 | ⬜ | + +--- + +## 2. Authentication Failure Paths + +| ID | Scenario | Trigger | Expected Behavior | Status | +|----|----------|---------|-------------------|--------| +| 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-03 | Invalid signature | Wrong private key used | `auth_failed(invalid_signature)` | ⬜ | +| 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-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-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-10 | Malformed auth_request | Missing required fields | Protocol error | ⬜ | +| AF-11 | Tampered proof | Modified signature | `auth_failed(invalid_signature)` | ⬜ | + +--- + +## 3. Re-pairing Triggers + +| ID | Scenario | Cause | Server Action | Client Action | Status | +|----|----------|-------|---------------|---------------|--------| +| 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-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 | ⬜ | + +--- + +## 4. Connection Failure Paths + +| ID | Scenario | Trigger | Expected Behavior | Status | +|----|----------|---------|-------------------|--------| +| CF-01 | Network partition | Connection drops mid-auth | Client retries with backoff | ⬜ | +| CF-02 | Server unreachable | Initial connect fails | Exponential backoff retry | ⬜ | +| CF-03 | Duplicate connection | Same ID connects twice | Old connection closed, new accepted | ⬜ | +| CF-04 | Protocol version mismatch | Unsupported version | Connection rejected with error | ⬜ | +| CF-05 | Malformed hello | Invalid JSON | Error response, connection maintained | ⬜ | +| CF-06 | Unauthenticated rule message | Client sends before auth | Connection closed | ⬜ | +| CF-07 | Reserved rule registration | Plugin tries `registerRule("builtin")` | Registration rejected | ⬜ | + +--- + +## 5. Heartbeat Failure Paths + +| ID | Scenario | Trigger | Expected Behavior | Status | +|----|----------|---------|-------------------|--------| +| HF-01 | 7-minute timeout | No heartbeat received | Status → `unstable`, notify | ⬜ | +| HF-02 | 11-minute timeout | Still no heartbeat | Status → `offline`, disconnect | ⬜ | +| HF-03 | Early heartbeat | Heartbeat before auth | Rejected/ignored | ⬜ | +| HF-04 | Heartbeat from unauthenticated | Wrong state | Error, possible disconnect | ⬜ | + +--- + +## 6. State Recovery Scenarios + +| ID | Scenario | Condition | Expected Recovery | Status | +|----|----------|-----------|-------------------|--------| +| SR-01 | Server restart with pending pairing | Pairing in progress | Preserve pairing state, code valid | ⬜ | +| SR-02 | Server restart with active sessions | Online clients | All marked offline, reconnect required | ⬜ | +| SR-03 | Client restart with credentials | Has secret + keys | Resume with auth, no re-pairing | ⬜ | +| SR-04 | Client restart without credentials | First run | Full pairing flow required | ⬜ | +| SR-05 | Corrupted server store | File unreadable | Clear state, start fresh | ⬜ | +| SR-06 | Corrupted client state | File unreadable | Reset to initial state | ⬜ | + +--- + +## Implementation Priority + +### Phase 1: Critical Security Paths (🔴) +1. AF-07 Nonce collision → re-pairing +2. AF-08 Rate limiting → re-pairing +3. PF-04 Admin notification failure +4. CF-06 Unauthenticated message handling + +### Phase 2: Core Functionality +5. PF-01/02 Invalid/expired pairing codes +6. AF-03/04 Signature and secret validation +7. AF-05/06 Timestamp validation +8. HF-01/02 Heartbeat timeout handling + +### Phase 3: Edge Cases +9. All connection failure paths +10. State recovery scenarios +11. Double-attempt scenarios + +--- + +## Test Implementation Notes + +### Running the Matrix + +```bash +# Run specific failure path category +npm test -- pairing-failures +npm test -- auth-failures +npm test -- connection-failures + +# Run all failure path tests +npm test -- failure-paths +``` + +### Adding New Test Cases + +1. Add row to appropriate table above +2. Assign unique ID (PF-, AF-, RP-, CF-, HF-, SR- prefix) +3. Update status when implementing +4. Link to test file location + +--- + +## Cross-References + +- Protocol spec: `../PROTOCOL.md` +- Acceptance criteria: `../ACCEPTANCE.md` +- Server tests: `../Yonexus.Server/tests/` +- Client tests: `../Yonexus.Client/tests/` diff --git a/tests/failure-path/pairing-failures.test.ts b/tests/failure-path/pairing-failures.test.ts new file mode 100644 index 0000000..fbdb45b --- /dev/null +++ b/tests/failure-path/pairing-failures.test.ts @@ -0,0 +1,616 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { + decodeBuiltin, + encodeBuiltin, + buildHello, + buildHelloAck, + buildPairRequest, + buildPairConfirm, + buildPairFailed, + buildPairSuccess, + type PairConfirmPayload, + type PairFailedPayload, + YONEXUS_PROTOCOL_VERSION, + ProtocolErrorCode +} from "../../Yonexus.Protocol/src/index.js"; +import { createYonexusServerRuntime } from "../../Yonexus.Server/plugin/core/runtime.js"; +import type { ClientRecord } from "../../Yonexus.Server/plugin/core/persistence.js"; +import type { YonexusServerStore } from "../../Yonexus.Server/plugin/core/store.js"; +import type { ClientConnection, ServerTransport } from "../../Yonexus.Server/plugin/core/transport.js"; + +/** + * YNX-1105b: Pairing Failure Path Tests + * + * Covers: + * - PF-01: Invalid pairing code + * - PF-02: Expired pairing code + * - PF-03: Identifier not in allowlist + * - PF-04: Admin notification failed (partial - notification stub) + * - PF-05: Empty pairing code + * - PF-06: Malformed pair_confirm payload + * - PF-07: Double pairing attempt + */ + +// ============================================================================ +// Test Utilities +// ============================================================================ + +function createMockSocket() { + return { close: vi.fn() } as unknown as ClientConnection["ws"]; +} + +function createConnection(identifier: string | null = null): ClientConnection { + return { + identifier, + ws: createMockSocket(), + connectedAt: 1_710_000_000, + isAuthenticated: false + }; +} + +function createMockStore(initialClients: ClientRecord[] = []): YonexusServerStore { + const persisted = new Map(initialClients.map((r) => [r.identifier, r])); + return { + filePath: "/tmp/test.json", + load: vi.fn(async () => ({ + version: 1, + persistedAt: 1_710_000_000, + clients: new Map(persisted) + })), + save: vi.fn(async (clients: Iterable) => { + persisted.clear(); + for (const c of clients) persisted.set(c.identifier, c); + }) + }; +} + +function createMockTransport() { + const sent: Array<{ connection: ClientConnection; message: string }> = []; + const closed: Array<{ identifier: string; code?: number; reason?: string }> = []; + + const transport: ServerTransport = { + isRunning: false, + connections: new Map(), + start: vi.fn(), + stop: vi.fn(), + send: vi.fn((id: string, msg: string) => { sent.push({ connection: { identifier: id } as ClientConnection, message: msg }); return true; }), + sendToConnection: vi.fn((conn: ClientConnection, msg: string) => { sent.push({ connection: conn, message: msg }); return true; }), + broadcast: vi.fn(), + closeConnection: vi.fn((id: string, code?: number, reason?: string) => { closed.push({ identifier: id, code, reason }); return true; }), + promoteToAuthenticated: vi.fn(), + removeTempConnection: vi.fn(), + assignIdentifierToTemp: vi.fn() + }; + + return { transport, sent, closed }; +} + +// ============================================================================ +// Pairing Failure Path Tests +// ============================================================================ + +describe("YNX-1105b: Pairing Failure Paths", () => { + let now = 1_710_000_000; + + beforeEach(() => { + now = 1_710_000_000; + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe("PF-01: Invalid pairing code", () => { + it("returns pair_failed(invalid_code) when wrong code submitted", async () => { + const store = createMockStore(); + const { transport, sent } = createMockTransport(); + const runtime = createYonexusServerRuntime({ + config: { + followerIdentifiers: ["client-a"], + notifyBotToken: "test-token", + adminUserId: "admin", + listenHost: "127.0.0.1", + listenPort: 8787 + }, + store, + transport, + now: () => now + }); + + await runtime.start(); + + // Start pairing flow + const conn = createConnection(); + await runtime.handleMessage(conn, encodeBuiltin(buildHello( + { identifier: "client-a", hasSecret: false, hasKeyPair: true, protocolVersion: YONEXUS_PROTOCOL_VERSION }, + { timestamp: now } + ))); + + const pairingCode = runtime.state.registry.clients.get("client-a")?.pairingCode; + expect(pairingCode).toBeDefined(); + + // Submit wrong code + await runtime.handleMessage(conn, encodeBuiltin(buildPairConfirm( + { identifier: "client-a", pairingCode: "WRONG-CODE-999" }, + { timestamp: now + 10 } + ))); + + const lastMessage = decodeBuiltin(sent.at(-1)!.message); + expect(lastMessage.type).toBe("pair_failed"); + expect((lastMessage.payload as PairFailedPayload).reason).toBe("invalid_code"); + + // Client remains in pending state, can retry + expect(runtime.state.registry.clients.get("client-a")?.pairingStatus).toBe("pending"); + }); + + it("allows retry after invalid code failure", async () => { + const store = createMockStore(); + const { transport, sent } = createMockTransport(); + const runtime = createYonexusServerRuntime({ + config: { + followerIdentifiers: ["client-a"], + notifyBotToken: "test-token", + adminUserId: "admin", + listenHost: "127.0.0.1", + listenPort: 8787 + }, + store, + transport, + now: () => now + }); + + await runtime.start(); + + const conn = createConnection(); + await runtime.handleMessage(conn, encodeBuiltin(buildHello( + { identifier: "client-a", hasSecret: false, hasKeyPair: true, protocolVersion: YONEXUS_PROTOCOL_VERSION }, + { timestamp: now } + ))); + + const correctCode = runtime.state.registry.clients.get("client-a")?.pairingCode; + + // First attempt: wrong code + await runtime.handleMessage(conn, encodeBuiltin(buildPairConfirm( + { identifier: "client-a", pairingCode: "WRONG" }, + { timestamp: now + 10 } + ))); + expect(decodeBuiltin(sent.at(-1)!.message).type).toBe("pair_failed"); + + // Second attempt: correct code + await runtime.handleMessage(conn, encodeBuiltin(buildPairConfirm( + { identifier: "client-a", pairingCode: correctCode! }, + { timestamp: now + 20 } + ))); + expect(decodeBuiltin(sent.at(-1)!.message).type).toBe("pair_success"); + }); + }); + + describe("PF-02: Expired pairing code", () => { + it("returns pair_failed(expired) when code submitted after expiry", async () => { + const store = createMockStore(); + const { transport, sent } = createMockTransport(); + const runtime = createYonexusServerRuntime({ + config: { + followerIdentifiers: ["client-a"], + notifyBotToken: "test-token", + adminUserId: "admin", + listenHost: "127.0.0.1", + listenPort: 8787 + }, + store, + transport, + now: () => now + }); + + await runtime.start(); + + const conn = createConnection(); + await runtime.handleMessage(conn, encodeBuiltin(buildHello( + { identifier: "client-a", hasSecret: false, hasKeyPair: true, protocolVersion: YONEXUS_PROTOCOL_VERSION }, + { timestamp: now } + ))); + + const pairingCode = runtime.state.registry.clients.get("client-a")?.pairingCode; + const expiresAt = runtime.state.registry.clients.get("client-a")?.pairingExpiresAt; + expect(expiresAt).toBeDefined(); + + // Advance time past expiry + now = expiresAt! + 1; + + await runtime.handleMessage(conn, encodeBuiltin(buildPairConfirm( + { identifier: "client-a", pairingCode: pairingCode! }, + { timestamp: now } + ))); + + const lastMessage = decodeBuiltin(sent.at(-1)!.message); + expect(lastMessage.type).toBe("pair_failed"); + expect((lastMessage.payload as PairFailedPayload).reason).toBe("expired"); + + // Pairing state reset to allow new pairing + expect(runtime.state.registry.clients.get("client-a")?.pairingStatus).toBe("unpaired"); + expect(runtime.state.registry.clients.get("client-a")?.pairingCode).toBeUndefined(); + }); + }); + + describe("PF-03: Identifier not in allowlist", () => { + it("rejects hello from unknown identifier", async () => { + const store = createMockStore(); + const { transport, sent } = createMockTransport(); + const runtime = createYonexusServerRuntime({ + config: { + followerIdentifiers: ["allowed-client"], // Only this one is allowed + notifyBotToken: "test-token", + adminUserId: "admin", + listenHost: "127.0.0.1", + listenPort: 8787 + }, + store, + transport, + now: () => now + }); + + await runtime.start(); + + const conn = createConnection(); + await runtime.handleMessage(conn, encodeBuiltin(buildHello( + { identifier: "unknown-client", hasSecret: false, hasKeyPair: true, protocolVersion: YONEXUS_PROTOCOL_VERSION }, + { timestamp: now } + ))); + + // Should receive hello_ack with rejected or an error + const lastMessage = decodeBuiltin(sent.at(-1)!.message); + expect(lastMessage.type).toBe("error"); + // Identifier should not be registered + expect(runtime.state.registry.clients.has("unknown-client")).toBe(false); + }); + + it("rejects pair_confirm from unknown identifier even if somehow received", async () => { + const store = createMockStore(); + const { transport, sent } = createMockTransport(); + const runtime = createYonexusServerRuntime({ + config: { + followerIdentifiers: ["allowed-client"], + notifyBotToken: "test-token", + adminUserId: "admin", + listenHost: "127.0.0.1", + listenPort: 8787 + }, + store, + transport, + now: () => now + }); + + await runtime.start(); + + // Try to send pair_confirm for unknown client + const conn = createConnection(); + await runtime.handleMessage(conn, encodeBuiltin(buildPairConfirm( + { identifier: "unknown-client", pairingCode: "SOME-CODE" }, + { timestamp: now } + ))); + + const lastMessage = decodeBuiltin(sent.at(-1)!.message); + expect(lastMessage.type).toBe("pair_failed"); + expect((lastMessage.payload as PairFailedPayload).reason).toBe("identifier_not_allowed"); + }); + }); + + describe("PF-04: Admin notification failure", () => { + it("fails pairing when notification cannot be sent", async () => { + const store = createMockStore(); + const { transport, sent } = createMockTransport(); + const runtime = createYonexusServerRuntime({ + config: { + followerIdentifiers: ["client-a"], + notifyBotToken: "", // Empty token should cause notification failure + adminUserId: "admin", + listenHost: "127.0.0.1", + listenPort: 8787 + }, + store, + transport, + now: () => now + }); + + await runtime.start(); + + const conn = createConnection(); + await runtime.handleMessage(conn, encodeBuiltin(buildHello( + { identifier: "client-a", hasSecret: false, hasKeyPair: true, protocolVersion: YONEXUS_PROTOCOL_VERSION }, + { timestamp: now } + ))); + + // Check the pair_request indicates notification failure + const pairRequest = sent.find(m => decodeBuiltin(m.message).type === "pair_request"); + expect(pairRequest).toBeDefined(); + + // Should not have created a valid pending pairing + const record = runtime.state.registry.clients.get("client-a"); + if (record?.pairingStatus === "pending") { + // If notification failed, pairing should indicate this + expect(record.pairingNotifyStatus).toBe("failed"); + } + }); + }); + + describe("PF-05: Empty pairing code", () => { + it("rejects empty pairing code", async () => { + const store = createMockStore(); + const { transport, sent } = createMockTransport(); + const runtime = createYonexusServerRuntime({ + config: { + followerIdentifiers: ["client-a"], + notifyBotToken: "test-token", + adminUserId: "admin", + listenHost: "127.0.0.1", + listenPort: 8787 + }, + store, + transport, + now: () => now + }); + + await runtime.start(); + + const conn = createConnection(); + await runtime.handleMessage(conn, encodeBuiltin(buildHello( + { identifier: "client-a", hasSecret: false, hasKeyPair: true, protocolVersion: YONEXUS_PROTOCOL_VERSION }, + { timestamp: now } + ))); + + // Submit empty code + await runtime.handleMessage(conn, encodeBuiltin(buildPairConfirm( + { identifier: "client-a", pairingCode: "" }, + { timestamp: now + 10 } + ))); + + const lastMessage = decodeBuiltin(sent.at(-1)!.message); + expect(lastMessage.type).toBe("pair_failed"); + expect((lastMessage.payload as PairFailedPayload).reason).toBe("invalid_code"); + }); + + it("rejects whitespace-only pairing code", async () => { + const store = createMockStore(); + const { transport, sent } = createMockTransport(); + const runtime = createYonexusServerRuntime({ + config: { + followerIdentifiers: ["client-a"], + notifyBotToken: "test-token", + adminUserId: "admin", + listenHost: "127.0.0.1", + listenPort: 8787 + }, + store, + transport, + now: () => now + }); + + await runtime.start(); + + const conn = createConnection(); + await runtime.handleMessage(conn, encodeBuiltin(buildHello( + { identifier: "client-a", hasSecret: false, hasKeyPair: true, protocolVersion: YONEXUS_PROTOCOL_VERSION }, + { timestamp: now } + ))); + + // Submit whitespace code + await runtime.handleMessage(conn, encodeBuiltin(buildPairConfirm( + { identifier: "client-a", pairingCode: " \t\n " }, + { timestamp: now + 10 } + ))); + + const lastMessage = decodeBuiltin(sent.at(-1)!.message); + expect(lastMessage.type).toBe("pair_failed"); + }); + }); + + describe("PF-06: Malformed pair_confirm payload", () => { + it("handles missing identifier in pair_confirm", async () => { + const store = createMockStore(); + const { transport, sent } = createMockTransport(); + const runtime = createYonexusServerRuntime({ + config: { + followerIdentifiers: ["client-a"], + notifyBotToken: "test-token", + adminUserId: "admin", + listenHost: "127.0.0.1", + listenPort: 8787 + }, + store, + transport, + now: () => now + }); + + await runtime.start(); + + const conn = createConnection(); + await runtime.handleMessage(conn, encodeBuiltin(buildHello( + { identifier: "client-a", hasSecret: false, hasKeyPair: true, protocolVersion: YONEXUS_PROTOCOL_VERSION }, + { timestamp: now } + ))); + + // Send malformed payload (missing fields) + await runtime.handleMessage(conn, encodeBuiltin({ + type: "pair_confirm", + timestamp: now, + payload: { pairingCode: "SOME-CODE" } // Missing identifier + })); + + // Should receive an error response + const lastMessage = decodeBuiltin(sent.at(-1)!.message); + expect(lastMessage.type).toBe("error"); + }); + + it("handles missing pairingCode in pair_confirm", async () => { + const store = createMockStore(); + const { transport, sent } = createMockTransport(); + const runtime = createYonexusServerRuntime({ + config: { + followerIdentifiers: ["client-a"], + notifyBotToken: "test-token", + adminUserId: "admin", + listenHost: "127.0.0.1", + listenPort: 8787 + }, + store, + transport, + now: () => now + }); + + await runtime.start(); + + const conn = createConnection(); + await runtime.handleMessage(conn, encodeBuiltin(buildHello( + { identifier: "client-a", hasSecret: false, hasKeyPair: true, protocolVersion: YONEXUS_PROTOCOL_VERSION }, + { timestamp: now } + ))); + + // Send malformed payload (missing pairingCode) + await runtime.handleMessage(conn, encodeBuiltin({ + type: "pair_confirm", + timestamp: now, + payload: { identifier: "client-a" } // Missing pairingCode + })); + + const lastMessage = decodeBuiltin(sent.at(-1)!.message); + expect(lastMessage.type).toBe("pair_failed"); + }); + }); + + describe("PF-07: Double pairing attempt", () => { + it("rejects pair_confirm for already paired client", async () => { + const store = createMockStore([{ + identifier: "client-a", + pairingStatus: "paired", + publicKey: "existing-key", + secret: "existing-secret", + status: "offline", + recentNonces: [], + recentHandshakeAttempts: [], + createdAt: now - 1000, + updatedAt: now - 500, + pairedAt: now - 500 + }]); + + const { transport, sent } = createMockTransport(); + const runtime = createYonexusServerRuntime({ + config: { + followerIdentifiers: ["client-a"], + notifyBotToken: "test-token", + adminUserId: "admin", + listenHost: "127.0.0.1", + listenPort: 8787 + }, + store, + transport, + now: () => now + }); + + await runtime.start(); + + // Try to pair an already paired client + const conn = createConnection(); + await runtime.handleMessage(conn, encodeBuiltin(buildPairConfirm( + { identifier: "client-a", pairingCode: "SOME-CODE" }, + { timestamp: now } + ))); + + // Should reject since already paired + const lastMessage = decodeBuiltin(sent.at(-1)!.message); + expect(lastMessage.type).toBe("pair_failed"); + + // Existing trust material preserved + const record = runtime.state.registry.clients.get("client-a"); + expect(record?.pairingStatus).toBe("paired"); + expect(record?.secret).toBe("existing-secret"); + }); + }); + + describe("Edge Cases", () => { + it("handles concurrent pair_confirm from different connections with same identifier", async () => { + const store = createMockStore(); + const { transport, sent } = createMockTransport(); + const runtime = createYonexusServerRuntime({ + config: { + followerIdentifiers: ["client-a"], + notifyBotToken: "test-token", + adminUserId: "admin", + listenHost: "127.0.0.1", + listenPort: 8787 + }, + store, + transport, + now: () => now + }); + + await runtime.start(); + + // First connection starts pairing + const conn1 = createConnection(); + await runtime.handleMessage(conn1, encodeBuiltin(buildHello( + { identifier: "client-a", hasSecret: false, hasKeyPair: true, protocolVersion: YONEXUS_PROTOCOL_VERSION }, + { timestamp: now } + ))); + + const pairingCode = runtime.state.registry.clients.get("client-a")?.pairingCode; + + // Second connection tries to pair with same identifier + const conn2 = createConnection(); + await runtime.handleMessage(conn2, encodeBuiltin(buildPairConfirm( + { identifier: "client-a", pairingCode: pairingCode! }, + { timestamp: now + 10 } + ))); + + // Should succeed - pairing is identifier-based, not connection-based + const lastMessage = decodeBuiltin(sent.at(-1)!.message); + expect(lastMessage.type).toBe("pair_success"); + }); + + it("cleans up pending pairing state on expiry", async () => { + const store = createMockStore(); + const { transport, sent } = createMockTransport(); + const runtime = createYonexusServerRuntime({ + config: { + followerIdentifiers: ["client-a"], + notifyBotToken: "test-token", + adminUserId: "admin", + listenHost: "127.0.0.1", + listenPort: 8787 + }, + store, + transport, + now: () => now + }); + + await runtime.start(); + + const conn = createConnection(); + await runtime.handleMessage(conn, encodeBuiltin(buildHello( + { identifier: "client-a", hasSecret: false, hasKeyPair: true, protocolVersion: YONEXUS_PROTOCOL_VERSION }, + { timestamp: now } + ))); + + // Verify pending state exists + const recordBefore = runtime.state.registry.clients.get("client-a"); + expect(recordBefore?.pairingStatus).toBe("pending"); + expect(recordBefore?.pairingCode).toBeDefined(); + + // Expire and try to use old code + now += 400; // Past default TTL + await runtime.handleMessage(conn, encodeBuiltin(buildPairConfirm( + { identifier: "client-a", pairingCode: recordBefore?.pairingCode! }, + { timestamp: now } + ))); + + const lastMessage = decodeBuiltin(sent.at(-1)!.message); + expect(lastMessage.type).toBe("pair_failed"); + expect((lastMessage.payload as PairFailedPayload).reason).toBe("expired"); + + // State cleaned up + const recordAfter = runtime.state.registry.clients.get("client-a"); + expect(recordAfter?.pairingStatus).toBe("unpaired"); + expect(recordAfter?.pairingCode).toBeUndefined(); + }); + }); +}); diff --git a/tests/integration/framework.test.ts b/tests/integration/framework.test.ts new file mode 100644 index 0000000..42df91a --- /dev/null +++ b/tests/integration/framework.test.ts @@ -0,0 +1,466 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import type { ClientConnection, ServerTransport } from "../Yonexus.Server/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 { YonexusClientStateStore } from "../Yonexus.Client/plugin/core/state.js"; +import { createYonexusServerRuntime } from "../Yonexus.Server/plugin/core/runtime.js"; +import { createYonexusClientRuntime } from "../Yonexus.Client/plugin/core/runtime.js"; +import { + decodeBuiltin, + encodeBuiltin, + buildHello, + buildHelloAck, + buildPairRequest, + buildPairConfirm, + buildPairSuccess, + buildAuthRequest, + buildAuthSuccess, + buildHeartbeat, + buildHeartbeatAck, + YONEXUS_PROTOCOL_VERSION +} from "../Yonexus.Protocol/src/index.js"; +import { generateKeyPair } from "../Yonexus.Client/plugin/crypto/keypair.js"; +import type { ClientRecord } from "../Yonexus.Server/plugin/core/persistence.js"; +import type { YonexusClientState } from "../Yonexus.Client/plugin/core/state.js"; + +/** + * Yonexus Server-Client Integration Test Framework + * + * This module provides utilities for testing Server and Client interactions + * without requiring real network sockets. + */ + +// ============================================================================ +// Mock Transport Pair - Simulates network connection between Server and Client +// ============================================================================ + +export interface MockMessageChannel { + serverToClient: string[]; + clientToServer: string[]; +} + +export interface MockTransportPair { + serverTransport: ServerTransport; + clientTransport: ClientTransport; + channel: MockMessageChannel; + getServerReceived: () => string[]; + getClientReceived: () => string[]; + clearMessages: () => void; +} + +export function createMockTransportPair(): MockTransportPair { + const channel: MockMessageChannel = { + serverToClient: [], + clientToServer: [] + }; + + // Track server-side connections + const serverConnections = new Map(); + let tempConnection: ClientConnection | null = null; + + // Server Transport Mock + const serverTransport: ServerTransport = { + isRunning: false, + connections: serverConnections, + + start: vi.fn(async () => { + serverTransport.isRunning = true; + }), + + stop: vi.fn(async () => { + serverTransport.isRunning = false; + serverConnections.clear(); + }), + + send: vi.fn((identifier: string, message: string) => { + if (serverConnections.has(identifier)) { + channel.serverToClient.push(message); + return true; + } + return false; + }), + + sendToConnection: vi.fn((connection: ClientConnection, message: string) => { + channel.serverToClient.push(message); + return true; + }), + + broadcast: vi.fn((message: string) => { + channel.serverToClient.push(`[broadcast]:${message}`); + }), + + closeConnection: vi.fn((identifier: string, code?: number, reason?: string) => { + const conn = serverConnections.get(identifier); + if (conn) { + conn.isAuthenticated = false; + serverConnections.delete(identifier); + } + return true; + }), + + assignIdentifierToTemp: vi.fn((ws, identifier: string) => { + if (tempConnection) { + tempConnection.identifier = identifier; + } + }), + + promoteToAuthenticated: vi.fn((identifier: string, ws) => { + if (tempConnection && tempConnection.identifier === identifier) { + tempConnection.isAuthenticated = true; + serverConnections.set(identifier, tempConnection); + tempConnection = null; + } + }), + + removeTempConnection: vi.fn(() => { + tempConnection = null; + }) + }; + + // Client Transport Mock + let clientState: import("../Yonexus.Client/plugin/core/transport.js").ClientConnectionState = "idle"; + + const clientTransport: ClientTransport = { + get state() { + return clientState; + }, + + get isConnected() { + return clientState !== "idle" && clientState !== "disconnected" && clientState !== "error"; + }, + + get isAuthenticated() { + return clientState === "authenticated"; + }, + + connect: vi.fn(async () => { + clientState = "connected"; + // Simulate connection - create temp connection on server side + tempConnection = { + identifier: null, + ws: { close: vi.fn() } as unknown as WebSocket, + connectedAt: Date.now(), + isAuthenticated: false + }; + }), + + disconnect: vi.fn(() => { + clientState = "disconnected"; + tempConnection = null; + }), + + send: vi.fn((message: string) => { + if (clientState === "connected" || clientState === "authenticated" || clientState === "authenticating") { + channel.clientToServer.push(message); + return true; + } + return false; + }), + + markAuthenticated: vi.fn(() => { + clientState = "authenticated"; + }), + + markAuthenticating: vi.fn(() => { + clientState = "authenticating"; + }) + }; + + return { + serverTransport, + clientTransport, + channel, + getServerReceived: () => [...channel.clientToServer], + getClientReceived: () => [...channel.serverToClient], + clearMessages: () => { + channel.serverToClient.length = 0; + channel.clientToServer.length = 0; + } + }; +} + +// ============================================================================ +// Mock Store Factories +// ============================================================================ + +export function createMockServerStore(initialClients: ClientRecord[] = []): YonexusServerStore { + const persisted = new Map(initialClients.map((record) => [record.identifier, record])); + + return { + filePath: "/tmp/yonexus-server-test.json", + load: vi.fn(async () => ({ + version: 1, + persistedAt: Date.now(), + clients: new Map(persisted) + })), + save: vi.fn(async (clients: Iterable) => { + persisted.clear(); + for (const client of clients) { + persisted.set(client.identifier, client); + } + }) + }; +} + +export function createMockClientStore(initialState?: Partial): YonexusClientStateStore { + let state: YonexusClientState = { + identifier: initialState?.identifier ?? "test-client", + publicKey: initialState?.publicKey, + privateKey: initialState?.privateKey, + secret: initialState?.secret, + pairedAt: initialState?.pairedAt, + authenticatedAt: initialState?.authenticatedAt, + updatedAt: initialState?.updatedAt ?? Date.now() + }; + + return { + filePath: "/tmp/yonexus-client-test.json", + load: vi.fn(async () => ({ ...state })), + save: vi.fn(async (next) => { + state = { ...next }; + }) + }; +} + +// ============================================================================ +// Test Runtime Factory +// ============================================================================ + +export interface IntegrationTestContext { + serverRuntime: ReturnType; + clientRuntime: ReturnType; + transports: MockTransportPair; + serverStore: YonexusServerStore; + clientStore: YonexusClientStateStore; + advanceTime: (seconds: number) => void; + processServerToClient: () => Promise; + processClientToServer: () => Promise; + processAllMessages: () => Promise; +} + +export async function createIntegrationTestContext( + options: { + clientIdentifier?: string; + paired?: boolean; + authenticated?: boolean; + serverTime?: number; + } = {} +): Promise { + const now = options.serverTime ?? 1_710_000_000; + const identifier = options.clientIdentifier ?? "test-client"; + + const transports = createMockTransportPair(); + const serverStore = createMockServerStore(); + const clientStore = createMockClientStore({ identifier }); + + // Generate keypair for client if needed + const keyPair = await generateKeyPair(); + + const serverRuntime = createYonexusServerRuntime({ + config: { + followerIdentifiers: [identifier], + notifyBotToken: "test-token", + adminUserId: "admin-user", + listenHost: "127.0.0.1", + listenPort: 8787 + }, + store: serverStore, + transport: transports.serverTransport, + now: () => now + }); + + const clientRuntime = createYonexusClientRuntime({ + config: { + mainHost: "ws://localhost:8787", + identifier, + notifyBotToken: "test-token", + adminUserId: "admin-user" + }, + transport: transports.clientTransport, + stateStore: clientStore, + now: () => now + }); + + await serverRuntime.start(); + + let currentTime = now; + const advanceTime = (seconds: number) => { + currentTime += seconds; + }; + + // Message processing helpers + const processServerToClient = async () => { + const messages = transports.getClientReceived(); + transports.clearMessages(); + for (const msg of messages) { + await clientRuntime.handleMessage(msg); + } + }; + + const processClientToServer = async () => { + const messages = transports.getServerReceived(); + transports.clearMessages(); + + // Get the temp connection for message handling + const connection = { + identifier: identifier, + ws: { close: vi.fn() } as unknown as WebSocket, + connectedAt: currentTime, + isAuthenticated: options.authenticated ?? false + }; + + for (const msg of messages) { + await serverRuntime.handleMessage(connection, msg); + } + }; + + const processAllMessages = async () => { + await processClientToServer(); + await processServerToClient(); + }; + + return { + serverRuntime, + clientRuntime, + transports, + serverStore, + clientStore, + advanceTime, + processServerToClient, + processClientToServer, + processAllMessages + }; +} + +// ============================================================================ +// Integration Test Suite +// ============================================================================ + +describe("Yonexus Server-Client Integration", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe("First-Time Pairing Flow", () => { + it("completes full pairing and authentication cycle", async () => { + const ctx = await createIntegrationTestContext({ + clientIdentifier: "new-client" + }); + + // Step 1: Client connects and sends hello + await ctx.clientRuntime.start(); + ctx.clientRuntime.handleTransportStateChange("connected"); + await vi.advanceTimersByTimeAsync(100); + + // Process hello -> hello_ack + pair_request + await ctx.processClientToServer(); + await ctx.processServerToClient(); + + // Verify client received pair_request + expect(ctx.clientRuntime.state.phase).toBe("waiting_pair_confirm"); + expect(ctx.clientRuntime.state.pendingPairing).toBeDefined(); + + // Step 2: Client submits pairing code + const pairingCode = ctx.serverRuntime.state.registry.clients.get("new-client")?.pairingCode; + expect(pairingCode).toBeDefined(); + + ctx.clientRuntime.submitPairingCode(pairingCode!, "req-pair-confirm"); + await vi.advanceTimersByTimeAsync(100); + + // Process pair_confirm -> pair_success + await ctx.processClientToServer(); + await ctx.processServerToClient(); + + // Verify client received secret + expect(ctx.clientRuntime.state.clientState.secret).toBeDefined(); + expect(ctx.clientRuntime.state.phase).toBe("auth_required"); + + // Step 3: Client sends auth request + await vi.advanceTimersByTimeAsync(100); + await ctx.processClientToServer(); + await ctx.processServerToClient(); + + // Verify authentication success + expect(ctx.clientRuntime.state.phase).toBe("authenticated"); + expect(ctx.serverRuntime.state.registry.sessions.get("new-client")?.isAuthenticated).toBe(true); + }); + }); + + describe("Reconnection Flow", () => { + it("reconnects with existing credentials without re-pairing", async () => { + const now = 1_710_000_000; + const keyPair = await generateKeyPair(); + + const ctx = await createIntegrationTestContext({ + clientIdentifier: "reconnect-client", + paired: true, + authenticated: false + }); + + // Pre-populate client with existing credentials + ctx.clientRuntime.state.clientState.secret = "existing-secret"; + ctx.clientRuntime.state.clientState.publicKey = keyPair.publicKey; + ctx.clientRuntime.state.clientState.privateKey = keyPair.privateKey; + ctx.clientRuntime.state.clientState.pairedAt = now - 1000; + + // Pre-populate server with client record + ctx.serverRuntime.state.registry.clients.set("reconnect-client", { + identifier: "reconnect-client", + pairingStatus: "paired", + publicKey: keyPair.publicKey, + secret: "existing-secret", + status: "offline", + recentNonces: [], + recentHandshakeAttempts: [], + createdAt: now - 2000, + updatedAt: now - 1000 + }); + + // Connect and send hello + await ctx.clientRuntime.start(); + ctx.clientRuntime.handleTransportStateChange("connected"); + await vi.advanceTimersByTimeAsync(100); + + await ctx.processClientToServer(); + await ctx.processServerToClient(); + + // Should go directly to auth_required, skipping pairing + expect(ctx.clientRuntime.state.phase).toBe("auth_required"); + + // Complete authentication + await vi.advanceTimersByTimeAsync(100); + await ctx.processClientToServer(); + await ctx.processServerToClient(); + + expect(ctx.clientRuntime.state.phase).toBe("authenticated"); + }); + }); + + describe("Heartbeat Flow", () => { + it("exchanges heartbeats after authentication", async () => { + const ctx = await createIntegrationTestContext({ + clientIdentifier: "heartbeat-client", + paired: true, + authenticated: true + }); + + // Manually set authenticated state + ctx.clientRuntime.state.phase = "authenticated"; + + // Trigger heartbeat + await ctx.clientRuntime.handleMessage("heartbeat_tick"); + await vi.advanceTimersByTimeAsync(100); + + // Process heartbeat -> heartbeat_ack + await ctx.processClientToServer(); + + // Verify server updated heartbeat timestamp + const record = ctx.serverRuntime.state.registry.clients.get("heartbeat-client"); + expect(record?.lastHeartbeatAt).toBeDefined(); + }); + }); +});