YNX-1104/1105: Add integration test framework and pairing failure path tests
- YNX-1104a: Create Server-Client integration test framework - MockTransportPair for simulating network communication - createIntegrationTestContext() for test environment setup - First-time pairing flow integration tests - Reconnection flow tests - YNX-1105a: Create failure path test matrix documentation - MATRIX.md with PF/AF/RP/CF/HF/SR categories - Priority marking for critical security paths - YNX-1105b: Implement pairing failure path tests - PF-01: Invalid pairing code with retry - PF-02: Expired pairing code cleanup - PF-03: Unknown identifier rejection - PF-04: Admin notification failure handling - PF-05: Empty/whitespace code rejection - PF-06: Malformed payload handling - PF-07: Double pairing protection - Edge cases: concurrent pairing, state cleanup - Update TASKLIST.md with completion status
This commit is contained in:
147
tests/failure-path/MATRIX.md
Normal file
147
tests/failure-path/MATRIX.md
Normal file
@@ -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/`
|
||||
616
tests/failure-path/pairing-failures.test.ts
Normal file
616
tests/failure-path/pairing-failures.test.ts
Normal file
@@ -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<ClientRecord>) => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user