test: cover server runtime flow

This commit is contained in:
nav
2026-04-09 00:42:32 +00:00
parent 35d787be04
commit 4f4c6bf993
2 changed files with 391 additions and 1 deletions

View File

@@ -261,7 +261,7 @@ export class YonexusServerRuntime {
isAuthenticated: false,
connectedAt: connection.connectedAt,
lastActivityAt: this.now(),
publicKey: payload.publicKey
publicKey: payload.publicKey?.trim() || undefined
});
const nextAction = this.determineNextAction(record);

390
tests/runtime-flow.test.ts Normal file
View File

@@ -0,0 +1,390 @@
import { describe, expect, it, vi } from "vitest";
import {
buildAuthRequest,
buildHeartbeat,
buildHello,
buildPairConfirm,
createAuthRequestSigningInput,
decodeBuiltin,
encodeBuiltin,
type AuthRequestPayload,
type BuiltinEnvelope,
type PairRequestPayload,
YONEXUS_PROTOCOL_VERSION
} from "../../Yonexus.Protocol/src/index.js";
import { createYonexusServerRuntime } from "../plugin/core/runtime.js";
import type { ClientRecord } from "../plugin/core/persistence.js";
import type { YonexusServerStore } from "../plugin/core/store.js";
import type { ClientConnection, ServerTransport } from "../plugin/core/transport.js";
import { generateKeyPair, signMessage, verifySignature } from "../../Yonexus.Client/plugin/crypto/keypair.js";
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((record) => [record.identifier, record]));
return {
filePath: "/tmp/yonexus-server-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 client of clients) {
persisted.set(client.identifier, client);
}
})
};
}
function createMockTransport() {
const sentToConnection: Array<{ connection: ClientConnection; message: string }> = [];
const sentByIdentifier: Array<{ identifier: string; message: string }> = [];
const assigned = new Map<object, string>();
const promoted: string[] = [];
const closed: Array<{ identifier: string; code?: number; reason?: string }> = [];
const transport: ServerTransport = {
isRunning: false,
connections: new Map(),
start: vi.fn(async () => undefined),
stop: vi.fn(async () => undefined),
send: vi.fn((identifier: string, message: string) => {
sentByIdentifier.push({ identifier, message });
return true;
}),
sendToConnection: vi.fn((connection: ClientConnection, message: string) => {
sentToConnection.push({ connection, message });
return true;
}),
broadcast: vi.fn(),
closeConnection: vi.fn((identifier: string, code?: number, reason?: string) => {
closed.push({ identifier, code, reason });
return true;
}),
promoteToAuthenticated: vi.fn((identifier: string, _ws) => {
promoted.push(identifier);
return true;
}),
removeTempConnection: vi.fn(),
assignIdentifierToTemp: vi.fn((ws, identifier: string) => {
assigned.set(ws as object, identifier);
})
};
return {
transport,
sentToConnection,
sentByIdentifier,
assigned,
promoted,
closed
};
}
describe("Yonexus.Server runtime flow", () => {
it("runs hello -> pair_request for an unpaired client", async () => {
const store = createMockStore();
const transportState = createMockTransport();
const runtime = createYonexusServerRuntime({
config: {
followerIdentifiers: ["client-a"],
notifyBotToken: "stub-token",
adminUserId: "admin-user",
listenHost: "127.0.0.1",
listenPort: 8787
},
store,
transport: transportState.transport,
now: () => 1_710_000_000
});
await runtime.start();
const connection = createConnection();
await runtime.handleMessage(
connection,
encodeBuiltin(
buildHello(
{
identifier: "client-a",
hasSecret: false,
hasKeyPair: false,
protocolVersion: YONEXUS_PROTOCOL_VERSION
},
{ requestId: "req-hello", timestamp: 1_710_000_000 }
)
)
);
expect(transportState.assigned.get(connection.ws as object)).toBe("client-a");
expect(transportState.sentToConnection).toHaveLength(2);
const helloAck = decodeBuiltin(transportState.sentToConnection[0].message);
expect(helloAck.type).toBe("hello_ack");
expect(helloAck.payload).toMatchObject({
identifier: "client-a",
nextAction: "pair_required"
});
const pairRequest = decodeBuiltin(transportState.sentToConnection[1].message) as BuiltinEnvelope<
"pair_request",
PairRequestPayload
>;
expect(pairRequest.type).toBe("pair_request");
expect(pairRequest.payload).toMatchObject({
identifier: "client-a",
adminNotification: "sent",
codeDelivery: "out_of_band"
});
const record = runtime.state.registry.clients.get("client-a");
expect(record?.pairingStatus).toBe("pending");
expect(record?.pairingCode).toBeTypeOf("string");
});
it("completes pair_confirm -> auth_request -> heartbeat for a client", async () => {
let now = 1_710_000_000;
const keyPair = await generateKeyPair();
const store = createMockStore();
const transportState = createMockTransport();
const runtime = createYonexusServerRuntime({
config: {
followerIdentifiers: ["client-a"],
notifyBotToken: "stub-token",
adminUserId: "admin-user",
listenHost: "127.0.0.1",
listenPort: 8787
},
store,
transport: transportState.transport,
now: () => now
});
await runtime.start();
const connection = createConnection();
await runtime.handleMessage(
connection,
encodeBuiltin(
buildHello(
{
identifier: "client-a",
hasSecret: false,
hasKeyPair: true,
publicKey: keyPair.publicKey,
protocolVersion: YONEXUS_PROTOCOL_VERSION
},
{ requestId: "req-hello", timestamp: now }
)
)
);
const pairRequest = decodeBuiltin(transportState.sentToConnection[1].message) as BuiltinEnvelope<
"pair_request",
PairRequestPayload
>;
const pairingCode = runtime.state.registry.clients.get("client-a")?.pairingCode;
expect(pairingCode).toBeTypeOf("string");
expect(pairRequest.payload?.identifier).toBe("client-a");
now += 2;
await runtime.handleMessage(
connection,
encodeBuiltin(
buildPairConfirm(
{
identifier: "client-a",
pairingCode: pairingCode!
},
{ requestId: "req-pair", timestamp: now }
)
)
);
const pairSuccess = decodeBuiltin(
transportState.sentToConnection[transportState.sentToConnection.length - 1].message
);
expect(pairSuccess.type).toBe("pair_success");
const recordAfterPair = runtime.state.registry.clients.get("client-a");
expect(recordAfterPair?.pairingStatus).toBe("paired");
expect(recordAfterPair?.publicKey).toBe(keyPair.publicKey.trim());
expect(recordAfterPair?.secret).toBeTypeOf("string");
now += 2;
const nonce = "AUTHNONCESTRING000000001";
const signingInput = createAuthRequestSigningInput({
secret: recordAfterPair!.secret!,
nonce,
proofTimestamp: now
});
const signature = await signMessage(keyPair.privateKey, signingInput);
await expect(verifySignature(keyPair.publicKey, signingInput, signature)).resolves.toBe(true);
await runtime.handleMessage(
connection,
encodeBuiltin(
buildAuthRequest(
{
identifier: "client-a",
nonce,
proofTimestamp: now,
signature,
publicKey: keyPair.publicKey
},
{ requestId: "req-auth", timestamp: now }
)
)
);
const authSuccess = decodeBuiltin(
transportState.sentToConnection[transportState.sentToConnection.length - 1].message
);
expect(authSuccess.type).toBe("auth_success");
expect(transportState.promoted).toContain("client-a");
const session = runtime.state.registry.sessions.get("client-a");
expect(session?.isAuthenticated).toBe(true);
now += 5;
await runtime.handleMessage(
{ ...connection, identifier: "client-a", isAuthenticated: true },
encodeBuiltin(
buildHeartbeat(
{
identifier: "client-a",
status: "alive"
},
{ requestId: "req-heartbeat", timestamp: now }
)
)
);
const heartbeatAck = decodeBuiltin(
transportState.sentToConnection[transportState.sentToConnection.length - 1].message
);
expect(heartbeatAck.type).toBe("heartbeat_ack");
const recordAfterHeartbeat = runtime.state.registry.clients.get("client-a");
expect(recordAfterHeartbeat?.status).toBe("online");
expect(recordAfterHeartbeat?.lastHeartbeatAt).toBe(now);
});
it("rejects unauthenticated rule messages by closing the connection", async () => {
const store = createMockStore();
const transportState = createMockTransport();
const runtime = createYonexusServerRuntime({
config: {
followerIdentifiers: ["client-a"],
notifyBotToken: "stub-token",
adminUserId: "admin-user",
listenHost: "127.0.0.1",
listenPort: 8787
},
store,
transport: transportState.transport,
now: () => 1_710_000_000
});
await runtime.start();
const connection = createConnection("client-a");
runtime.state.registry.sessions.set("client-a", {
identifier: "client-a",
socket: connection.ws,
isAuthenticated: false,
connectedAt: connection.connectedAt,
lastActivityAt: 1_710_000_000
});
await runtime.handleMessage(connection, 'chat_sync::{"body":"hello"}');
expect((connection.ws.close as ReturnType<typeof vi.fn>)).toHaveBeenCalledWith(
1008,
"Not authenticated"
);
});
it("marks stale authenticated clients unstable then offline during liveness sweep", async () => {
vi.useFakeTimers();
try {
let now = 1_710_000_000;
const store = createMockStore();
const transportState = createMockTransport();
const runtime = createYonexusServerRuntime({
config: {
followerIdentifiers: ["client-a"],
notifyBotToken: "stub-token",
adminUserId: "admin-user",
listenHost: "127.0.0.1",
listenPort: 8787
},
store,
transport: transportState.transport,
now: () => now,
sweepIntervalMs: 1000
});
await runtime.start();
runtime.state.registry.clients.set("client-a", {
identifier: "client-a",
pairingStatus: "paired",
publicKey: "pk",
secret: "secret",
status: "online",
recentNonces: [],
recentHandshakeAttempts: [],
createdAt: now,
updatedAt: now,
lastAuthenticatedAt: now,
lastHeartbeatAt: now
});
runtime.state.registry.sessions.set("client-a", {
identifier: "client-a",
socket: createMockSocket(),
isAuthenticated: true,
connectedAt: now,
lastActivityAt: now
});
now += 7 * 60;
await vi.advanceTimersByTimeAsync(1000);
expect(runtime.state.registry.clients.get("client-a")?.status).toBe("unstable");
const unstableNotice = transportState.sentByIdentifier.at(-1);
expect(unstableNotice?.identifier).toBe("client-a");
expect(decodeBuiltin(unstableNotice!.message).type).toBe("status_update");
now += 4 * 60;
await vi.advanceTimersByTimeAsync(1000);
expect(runtime.state.registry.clients.get("client-a")?.status).toBe("offline");
expect(transportState.closed).toContainEqual({
identifier: "client-a",
code: 1001,
reason: "Heartbeat timeout"
});
expect(runtime.state.registry.sessions.has("client-a")).toBe(false);
} finally {
vi.useRealTimers();
}
});
});