From 25a59adb5db63ddb9ddc1be84b11fc271aad06b1 Mon Sep 17 00:00:00 2001 From: nav Date: Thu, 9 Apr 2026 01:13:49 +0000 Subject: [PATCH] test: extend yonexus integration coverage --- TASKLIST.md | 27 +-- Yonexus.Server | 2 +- tests/failure-path/MATRIX.md | 41 +++-- tests/integration/framework.test.ts | 256 +++++++++++++++++++++++----- 4 files changed, 251 insertions(+), 75 deletions(-) diff --git a/TASKLIST.md b/TASKLIST.md index 4372f89..bc2d9d1 100644 --- a/TASKLIST.md +++ b/TASKLIST.md @@ -1052,7 +1052,7 @@ ### YNX-1104 编写 Server-Client 集成测试 **状态** -- [x] 框架已完成(2026-04-09) +- [x] 已完成(2026-04-09) **目标** - 覆盖首次配对、正常重连、认证失败、心跳超时、re-pair @@ -1061,13 +1061,16 @@ - 已创建 `tests/integration/framework.test.ts` 集成测试框架 - 提供 `MockTransportPair` 模拟 Server-Client 网络通信 - 提供 `createIntegrationTestContext()` 快速创建集成测试环境 -- 实现首批集成测试用例: +- 已修正集成测试框架中的时间推进问题,`advanceTime()` 现在会真实驱动 Server / Client runtime 的 `now()` +- 已实现以下集成测试用例: - 首次配对完整流程(hello → pair_request → pair_confirm → auth → heartbeat) - 带凭证的重连流程(跳过配对直接认证) - - 心跳交换验证 + - 认证后的心跳交换验证 + - 心跳超时触发 `unstable` / `offline` 与 `disconnect_notice` + - nonce collision 触发 `re_pair_required` 后 client 回退到 `pair_required` **待完成** -- 更多边界场景:心跳超时断线、re-pair 触发、并发连接 +- 并发连接等剩余边界场景 - 真实 WebSocket 传输层集成测试(可选) --- @@ -1090,7 +1093,7 @@ ### YNX-1105 编写失败路径测试矩阵 **状态** -- [x] 框架与 PF 测试已完成(2026-04-09) +- [x] 部分关键路径已完成,仍有少量尾项(2026-04-09) **目标** - 系统性覆盖 pairing/auth 失败路径 @@ -1130,14 +1133,18 @@ - Edge Cases: 并发配对、过期清理验证 - 已新增 `Yonexus.Server/tests/auth-failures.test.ts` - - AF-07: nonce collision 触发 re_pair_required - - AF-08: rate limit 触发 re_pair_required + - AF-01 / AF-02:unknown identifier、not_paired + - AF-03 / AF-09 / AF-11:invalid signature、wrong public key、tampered proof + - AF-05 / AF-06:stale / future timestamp + - AF-07 / AF-08:nonce collision / rate limit 触发 re_pair_required + - AF-10:malformed auth_request payload - 覆盖 re_pair 后 secret 清理与 pairingStatus=revoked +- 已同步更新 `tests/failure-path/MATRIX.md` 的 PF / AF / RP / HF 状态标记与当前备注 **待完成** -- AF(Authentication Failures)剩余场景(stale/future timestamp、invalid signature 等) -- RP(Re-pairing Triggers)测试套件 -- CF/HF/SR 边界场景测试 +- AF-04:当前实现未单独暴露 `invalid_secret` 分支,需先决定是否保留该错误码语义 +- RP 其余触发场景 +- CF / HF / SR 其余边界场景测试 --- diff --git a/Yonexus.Server b/Yonexus.Server index 3597298..0717b20 160000 --- a/Yonexus.Server +++ b/Yonexus.Server @@ -1 +1 @@ -Subproject commit 35972981d3f809740a662c9866ab6b18181d82ee +Subproject commit 0717b204f129fefdade87cc2b70e4e1442091b9b diff --git a/tests/failure-path/MATRIX.md b/tests/failure-path/MATRIX.md index 83ea18e..b16e757 100644 --- a/tests/failure-path/MATRIX.md +++ b/tests/failure-path/MATRIX.md @@ -15,13 +15,13 @@ This document defines the systematic test coverage for pairing and authenticatio | 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-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 | ⬜ | @@ -32,17 +32,17 @@ This document defines the systematic test coverage for pairing and authenticatio | 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-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-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)` | ⬜ | +| 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)` | ✅ | --- @@ -50,7 +50,7 @@ This document defines the systematic test coverage for pairing and authenticatio | 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-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 | ⬜ | @@ -75,8 +75,8 @@ This document defines the systematic test coverage for pairing and authenticatio | 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-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 | ⬜ | @@ -130,6 +130,11 @@ npm test -- connection-failures 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 1. Add row to appropriate table above diff --git a/tests/integration/framework.test.ts b/tests/integration/framework.test.ts index 42df91a..35f2834 100644 --- a/tests/integration/framework.test.ts +++ b/tests/integration/framework.test.ts @@ -1,10 +1,10 @@ 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 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, @@ -17,11 +17,12 @@ import { buildAuthSuccess, buildHeartbeat, buildHeartbeatAck, + createAuthRequestSigningInput, 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"; +} from "../../Yonexus.Protocol/src/index.js"; +import { generateKeyPair, signMessage } 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 @@ -118,7 +119,7 @@ export function createMockTransportPair(): MockTransportPair { }; // 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 = { get state() { @@ -244,18 +245,22 @@ export async function createIntegrationTestContext( paired?: boolean; authenticated?: boolean; serverTime?: number; + initialClientState?: Partial; + initialServerClients?: ClientRecord[]; } = {} ): Promise { - const now = options.serverTime ?? 1_710_000_000; + const initialNow = options.serverTime ?? 1_710_000_000; const identifier = options.clientIdentifier ?? "test-client"; const transports = createMockTransportPair(); - const serverStore = createMockServerStore(); - const clientStore = createMockClientStore({ identifier }); + const serverStore = createMockServerStore(options.initialServerClients ?? []); + const clientStore = createMockClientStore({ identifier, ...options.initialClientState }); // Generate keypair for client if needed const keyPair = await generateKeyPair(); + let currentTime = initialNow; + const serverRuntime = createYonexusServerRuntime({ config: { followerIdentifiers: [identifier], @@ -266,7 +271,7 @@ export async function createIntegrationTestContext( }, store: serverStore, transport: transports.serverTransport, - now: () => now + now: () => currentTime }); const clientRuntime = createYonexusClientRuntime({ @@ -278,12 +283,10 @@ export async function createIntegrationTestContext( }, transport: transports.clientTransport, stateStore: clientStore, - now: () => now + now: () => currentTime }); await serverRuntime.start(); - - let currentTime = now; const advanceTime = (seconds: number) => { currentTime += seconds; }; @@ -398,26 +401,27 @@ describe("Yonexus Server-Client Integration", () => { 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 + authenticated: false, + initialClientState: { + secret: "existing-secret", + publicKey: keyPair.publicKey.trim(), + privateKey: keyPair.privateKey, + pairedAt: now - 1000, + updatedAt: now - 1000 + }, + initialServerClients: [ + { + identifier: "reconnect-client", + pairingStatus: "paired", + publicKey: keyPair.publicKey.trim(), + secret: "existing-secret", + status: "offline", + recentNonces: [], + recentHandshakeAttempts: [], + createdAt: now - 2000, + updatedAt: now - 1000 + } + ] }); // Connect and send hello @@ -442,25 +446,185 @@ describe("Yonexus Server-Client Integration", () => { describe("Heartbeat Flow", () => { it("exchanges heartbeats after authentication", async () => { + const now = 1_710_000_000; + const keyPair = await generateKeyPair(); const ctx = await createIntegrationTestContext({ clientIdentifier: "heartbeat-client", - paired: true, - authenticated: true + serverTime: now, + 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 - ctx.clientRuntime.state.phase = "authenticated"; + await ctx.clientRuntime.start(); + 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 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(); }); + + 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"); + }); }); });