test: cover server recovery scenarios
This commit is contained in:
203
tests/state-recovery.test.ts
Normal file
203
tests/state-recovery.test.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createYonexusServerRuntime } from "../plugin/core/runtime.js";
|
||||
import {
|
||||
createYonexusServerStore,
|
||||
loadServerStore,
|
||||
YonexusServerStoreCorruptionError
|
||||
} from "../plugin/core/store.js";
|
||||
import type { ClientConnection, ServerTransport } from "../plugin/core/transport.js";
|
||||
import {
|
||||
buildHello,
|
||||
decodeBuiltin,
|
||||
encodeBuiltin,
|
||||
type BuiltinEnvelope,
|
||||
type PairRequestPayload,
|
||||
YONEXUS_PROTOCOL_VERSION
|
||||
} from "../../Yonexus.Protocol/src/index.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
async function createTempServerStorePath(): Promise<string> {
|
||||
const dir = await mkdtemp(join(tmpdir(), "yonexus-server-recovery-"));
|
||||
tempDirs.push(dir);
|
||||
return join(dir, "server-store.json");
|
||||
}
|
||||
|
||||
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 createMockTransport() {
|
||||
const sentToConnection: Array<{ connection: ClientConnection; message: string }> = [];
|
||||
|
||||
const transport: ServerTransport = {
|
||||
isRunning: false,
|
||||
connections: new Map(),
|
||||
start: vi.fn(async () => undefined),
|
||||
stop: vi.fn(async () => undefined),
|
||||
send: vi.fn(() => true),
|
||||
sendToConnection: vi.fn((connection: ClientConnection, message: string) => {
|
||||
sentToConnection.push({ connection, message });
|
||||
return true;
|
||||
}),
|
||||
broadcast: vi.fn(),
|
||||
closeConnection: vi.fn(() => true),
|
||||
promoteToAuthenticated: vi.fn(() => true),
|
||||
removeTempConnection: vi.fn(),
|
||||
assignIdentifierToTemp: vi.fn()
|
||||
};
|
||||
|
||||
return {
|
||||
transport,
|
||||
sentToConnection
|
||||
};
|
||||
}
|
||||
|
||||
describe("YNX-1105e: Server state recovery", () => {
|
||||
it("SR-01: preserves pending pairing across restart and reuses the same pairing code", async () => {
|
||||
const storePath = await createTempServerStorePath();
|
||||
const store = createYonexusServerStore(storePath);
|
||||
let now = 1_710_000_000;
|
||||
|
||||
const firstTransport = createMockTransport();
|
||||
const firstRuntime = createYonexusServerRuntime({
|
||||
config: {
|
||||
followerIdentifiers: ["client-a"],
|
||||
notifyBotToken: "stub-token",
|
||||
adminUserId: "admin-user",
|
||||
listenHost: "127.0.0.1",
|
||||
listenPort: 8787
|
||||
},
|
||||
store,
|
||||
transport: firstTransport.transport,
|
||||
now: () => now
|
||||
});
|
||||
|
||||
await firstRuntime.start();
|
||||
|
||||
const firstConnection = createConnection();
|
||||
await firstRuntime.handleMessage(
|
||||
firstConnection,
|
||||
encodeBuiltin(
|
||||
buildHello(
|
||||
{
|
||||
identifier: "client-a",
|
||||
hasSecret: false,
|
||||
hasKeyPair: false,
|
||||
protocolVersion: YONEXUS_PROTOCOL_VERSION
|
||||
},
|
||||
{ requestId: "req-hello-1", timestamp: now }
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const initialRecord = firstRuntime.state.registry.clients.get("client-a");
|
||||
const initialPairingCode = initialRecord?.pairingCode;
|
||||
const initialExpiresAt = initialRecord?.pairingExpiresAt;
|
||||
|
||||
expect(initialRecord?.pairingStatus).toBe("pending");
|
||||
expect(initialPairingCode).toBeTypeOf("string");
|
||||
expect(initialExpiresAt).toBeTypeOf("number");
|
||||
|
||||
await firstRuntime.stop();
|
||||
|
||||
const persistedRaw = JSON.parse(await readFile(storePath, "utf8")) as {
|
||||
clients: Array<{ identifier: string; pairingStatus: string; pairingCode?: string }>;
|
||||
};
|
||||
expect(
|
||||
persistedRaw.clients.find((client) => client.identifier === "client-a")
|
||||
).toMatchObject({
|
||||
identifier: "client-a",
|
||||
pairingStatus: "pending",
|
||||
pairingCode: initialPairingCode
|
||||
});
|
||||
|
||||
now += 30;
|
||||
|
||||
const secondTransport = createMockTransport();
|
||||
const secondRuntime = createYonexusServerRuntime({
|
||||
config: {
|
||||
followerIdentifiers: ["client-a"],
|
||||
notifyBotToken: "stub-token",
|
||||
adminUserId: "admin-user",
|
||||
listenHost: "127.0.0.1",
|
||||
listenPort: 8787
|
||||
},
|
||||
store,
|
||||
transport: secondTransport.transport,
|
||||
now: () => now
|
||||
});
|
||||
|
||||
await secondRuntime.start();
|
||||
|
||||
const reloadedRecord = secondRuntime.state.registry.clients.get("client-a");
|
||||
expect(reloadedRecord?.pairingStatus).toBe("pending");
|
||||
expect(reloadedRecord?.pairingCode).toBe(initialPairingCode);
|
||||
expect(reloadedRecord?.pairingExpiresAt).toBe(initialExpiresAt);
|
||||
|
||||
const secondConnection = createConnection();
|
||||
await secondRuntime.handleMessage(
|
||||
secondConnection,
|
||||
encodeBuiltin(
|
||||
buildHello(
|
||||
{
|
||||
identifier: "client-a",
|
||||
hasSecret: false,
|
||||
hasKeyPair: false,
|
||||
protocolVersion: YONEXUS_PROTOCOL_VERSION
|
||||
},
|
||||
{ requestId: "req-hello-2", timestamp: now }
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const helloAck = decodeBuiltin(secondTransport.sentToConnection[0].message);
|
||||
expect(helloAck.type).toBe("hello_ack");
|
||||
expect(helloAck.payload).toMatchObject({
|
||||
identifier: "client-a",
|
||||
nextAction: "waiting_pair_confirm"
|
||||
});
|
||||
|
||||
const pairRequest = decodeBuiltin(secondTransport.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"
|
||||
});
|
||||
expect(pairRequest.payload?.expiresAt).toBe(initialExpiresAt);
|
||||
|
||||
await secondRuntime.stop();
|
||||
});
|
||||
|
||||
it("SR-05: corrupted server store raises YonexusServerStoreCorruptionError", async () => {
|
||||
const storePath = await createTempServerStorePath();
|
||||
await writeFile(storePath, '{"version":1,"clients":"oops"}\n', "utf8");
|
||||
|
||||
await expect(loadServerStore(storePath)).rejects.toBeInstanceOf(YonexusServerStoreCorruptionError);
|
||||
await expect(loadServerStore(storePath)).rejects.toThrow("invalid clients array");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user