test: extend yonexus integration coverage
This commit is contained in:
@@ -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<YonexusClientState>;
|
||||
initialServerClients?: ClientRecord[];
|
||||
} = {}
|
||||
): Promise<IntegrationTestContext> {
|
||||
const now = options.serverTime ?? 1_710_000_000;
|
||||
const initialNow = options.serverTime ?? 1_710_000_000;
|
||||
const identifier = options.clientIdentifier ?? "test-client";
|
||||
|
||||
const 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user