test: cover server runtime flow
This commit is contained in:
390
tests/runtime-flow.test.ts
Normal file
390
tests/runtime-flow.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user