Files
Piik/tests/room-database.test.ts
T

438 lines
13 KiB
TypeScript

import { createHash } from "node:crypto";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { DatabaseSync } from "node:sqlite";
import { afterEach, describe, expect, it } from "vitest";
import { RoomDatabase } from "../src/server/room-database.ts";
import { RoomStore, RoomStoreError } from "../src/server/room-store.ts";
interface MutableClock {
nowMs: number;
}
const temporaryDirectories: string[] = [];
const stores: RoomStore[] = [];
afterEach(() => {
for (const store of stores.splice(0).reverse()) {
store.close();
}
for (const directory of temporaryDirectories.splice(0).reverse()) {
rmSync(directory, { recursive: true, force: true });
}
});
function databasePath(): string {
const directory = mkdtempSync(join(tmpdir(), "screener-room-db-"));
temporaryDirectories.push(directory);
return join(directory, "rooms.sqlite");
}
function stableStore(
path: string,
clock: MutableClock,
options: { maxRooms?: number; leaseMs?: number } = {},
): RoomStore {
const store = new RoomStore({
leaseMs: options.leaseMs ?? 1_000,
maxRooms: options.maxRooms ?? 8,
maxViewersPerRoom: 8,
now: () => clock.nowMs,
database: new RoomDatabase(path),
});
store.initialize();
stores.push(store);
return store;
}
function hostInput(
roomId: string,
hostToken: string,
sessionId = "host-session",
) {
return {
roomId,
role: "host" as const,
token: hostToken,
clientId: "host-client",
sessionId,
};
}
function viewerInput(
roomId: string,
sessionId: string,
viewerGrant?: string,
) {
return {
roomId,
role: "viewer" as const,
...(viewerGrant ? { viewerGrant } : {}),
clientId: `client-${sessionId}`,
sessionId,
};
}
function closeStore(store: RoomStore): void {
store.close();
const index = stores.indexOf(store);
if (index >= 0) {
stores.splice(index, 1);
}
}
describe("SQLite stable room authority", () => {
it("does not persist a room when password derivation is busy", async () => {
const path = databasePath();
const clock = { nowMs: 1_000 };
const first = stableStore(path, clock, { maxRooms: 1 });
const attempts = Array.from({ length: 18 }, (_, index) =>
first.connectViewerWithPassword(
{
roomId: "9999",
password: "gate-password",
clientId: `gate-client-${index}`,
sessionId: `gate-session-${index}`,
},
() => index < 2,
),
);
try {
await expect(
first.createRoom("private", "room-password", "4321"),
).rejects.toEqual(new RoomStoreError("ROOM_BUSY"));
} finally {
await Promise.allSettled(attempts);
}
expect(first.size).toBe(0);
closeStore(first);
const second = stableStore(path, clock, { maxRooms: 1 });
expect(second.size).toBe(0);
expect((await second.createRoom("private", null, "4321")).roomId).toBe(
"4321",
);
});
it("restores the exact authority aggregate without participants", async () => {
const path = databasePath();
const clock = { nowMs: 1_000 };
const first = stableStore(path, clock);
const room = await first.createRoom("open", null, "4321");
first.setCodeEntryPolicy(room.roomId, "private", room.hostToken);
await first.setViewerPassword(
room.roomId,
"room-password",
room.hostToken,
);
const rotated = first.setViewerGrant(
room.roomId,
"rotate",
room.hostToken,
);
const host = first.connectParticipant(
hostInput(room.roomId, room.hostToken),
);
const oldViewer = first.connectParticipant(
viewerInput(room.roomId, "old-viewer", rotated.viewerGrant!),
);
expect(first.getConnectedHost(room.roomId)).toMatchObject({
peerId: host.peerId,
});
expect(first.getConnectedViewer(room.roomId, oldViewer.peerId)).toBeDefined();
closeStore(first);
clock.nowMs = 2_000;
const second = stableStore(path, clock);
expect(second.size).toBe(1);
expect(second.getConnectedHost(room.roomId)).toBeUndefined();
expect(second.getConnectedViewers(room.roomId)).toEqual([]);
expect(() =>
second.connectParticipant(
viewerInput(room.roomId, "revoked-grant", room.viewerGrant!),
),
).toThrow(new RoomStoreError("INVALID_TOKEN"));
const granted = second.connectParticipant(
viewerInput(room.roomId, "current-grant", rotated.viewerGrant!),
);
expect(granted.expiresAt).toBe(new Date(3_000).toISOString());
expect(granted.viewerAuthorizationGeneration).toBe(
rotated.viewerAuthorizationGeneration,
);
await expect(
second.connectViewerWithPassword({
roomId: room.roomId,
password: "room-password",
clientId: "password-client",
sessionId: "password-session",
}),
).resolves.toMatchObject({ codeEntryPolicy: "private" });
expect(() =>
second.connectParticipant(hostInput(room.roomId, "wrong-token")),
).toThrow(new RoomStoreError("INVALID_TOKEN"));
expect(
second.connectParticipant(
hostInput(room.roomId, room.hostToken, "recovered-host"),
).expiresAt,
).toBeNull();
closeStore(second);
const raw = new DatabaseSync(path);
try {
const row = raw.prepare("SELECT * FROM rooms").get() as Record<
string,
unknown
>;
expect(Object.keys(row).sort()).toEqual(
[
"room_id",
"host_token_digest",
"viewer_grant_digest",
"viewer_authorization_generation",
"code_entry_policy",
"viewer_password_material",
"lease_expires_at_ms",
].sort(),
);
expect(Buffer.from(row.host_token_digest as Uint8Array)).toEqual(
createHash("sha256").update(room.hostToken).digest(),
);
expect(Buffer.from(row.viewer_grant_digest as Uint8Array)).toEqual(
createHash("sha256").update(rotated.viewerGrant!).digest(),
);
expect(row.viewer_password_material).toBeInstanceOf(Uint8Array);
expect(Buffer.from(row.viewer_password_material as Uint8Array)).toHaveLength(
48,
);
expect(JSON.stringify(row)).not.toContain(room.hostToken);
expect(JSON.stringify(row)).not.toContain(rotated.viewerGrant!);
expect(JSON.stringify(row)).not.toContain("room-password");
} finally {
raw.close();
}
});
it("converts an active marker once and preserves dormant deadlines", async () => {
const path = databasePath();
const clock = { nowMs: 100 };
const first = stableStore(path, clock);
const room = await first.createRoom("open", null, "5678");
first.connectParticipant(hostInput(room.roomId, room.hostToken));
closeStore(first);
clock.nowMs = 500;
const second = stableStore(path, clock);
const firstRestart = second.connectParticipant(
viewerInput(room.roomId, "first-restart", room.viewerGrant!),
);
expect(firstRestart.expiresAt).toBe(new Date(1_500).toISOString());
closeStore(second);
clock.nowMs = 700;
const third = stableStore(path, clock);
const secondRestart = third.connectParticipant(
viewerInput(room.roomId, "second-restart", room.viewerGrant!),
);
expect(secondRestart.expiresAt).toBe(new Date(1_500).toISOString());
closeStore(third);
clock.nowMs = 1_501;
const fourth = stableStore(path, clock);
expect(fourth.size).toBe(0);
const replacement = await fourth.createRoom("open", null, room.roomId);
expect(replacement.roomId).toBe(room.roomId);
expect(() =>
fourth.connectParticipant(hostInput(room.roomId, room.hostToken)),
).toThrow(new RoomStoreError("INVALID_TOKEN"));
expect(() =>
fourth.connectParticipant(
viewerInput(room.roomId, "stale-grant", room.viewerGrant!),
),
).toThrow(new RoomStoreError("INVALID_TOKEN"));
});
it("preserves the exact Host disconnect deadline", async () => {
const path = databasePath();
const clock = { nowMs: 100 };
const first = stableStore(path, clock);
const room = await first.createRoom("open", null, "5890");
const host = first.connectParticipant(
hostInput(room.roomId, room.hostToken),
);
clock.nowMs = 250;
first.disconnectParticipant(room.roomId, host.peerId, "host-session");
closeStore(first);
clock.nowMs = 500;
const second = stableStore(path, clock);
expect(
second.connectParticipant(
viewerInput(room.roomId, "dormant-viewer", room.viewerGrant!),
).expiresAt,
).toBe(new Date(1_250).toISOString());
});
it("persists explicit room deletion before recycling its code", async () => {
const path = databasePath();
const clock = { nowMs: 10 };
const first = stableStore(path, clock);
const room = await first.createRoom("open", null, "6789");
expect(first.abandonRoom(room.roomId)?.roomId).toBe(room.roomId);
closeStore(first);
const second = stableStore(path, clock);
expect(second.size).toBe(0);
const replacement = await second.createRoom("open", null, room.roomId);
expect(replacement.roomId).toBe(room.roomId);
expect(replacement.hostToken).not.toBe(room.hostToken);
expect(replacement.viewerGrant).not.toBe(room.viewerGrant);
});
it("rolls back a batch expiry when any exact room authority is stale", async () => {
const path = databasePath();
const clock = { nowMs: 10 };
const first = stableStore(path, clock);
await first.createRoom("open", null, "4321");
await first.createRoom("open", null, "5678");
closeStore(first);
const database = new RoomDatabase(path);
const restored = database.initialize(clock.nowMs, 1_010);
expect(restored).toHaveLength(2);
expect(() =>
database.deleteRooms([
{
roomId: restored[0]!.roomId,
hostTokenDigest: restored[0]!.hostTokenDigest,
},
{
roomId: restored[1]!.roomId,
hostTokenDigest: Buffer.alloc(32),
},
]),
).toThrow("Room database delete did not match current authority");
database.close();
const second = stableStore(path, clock);
expect(second.size).toBe(2);
});
it("persists room replacement as one authority transition", async () => {
const path = databasePath();
const clock = { nowMs: 10 };
const first = stableStore(path, clock);
const original = await first.createRoom("open", null, "4321");
const replacement = await first.replaceRoom(
original.roomId,
original.hostToken,
"private",
"new-password",
);
closeStore(first);
const second = stableStore(path, clock);
expect(second.size).toBe(1);
expect(() =>
second.connectParticipant(
hostInput(original.roomId, original.hostToken),
),
).toThrow(new RoomStoreError("INVALID_TOKEN"));
expect(
second.connectParticipant(
viewerInput(
replacement.created.roomId,
"replacement-viewer",
replacement.created.viewerGrant!,
),
).roomId,
).toBe(replacement.created.roomId);
await expect(
second.connectViewerWithPassword({
roomId: replacement.created.roomId,
password: "new-password",
clientId: "password-viewer",
sessionId: "password-session",
}),
).resolves.toMatchObject({ roomId: replacement.created.roomId });
});
it("rejects another owner of the same database", () => {
const path = databasePath();
const first = new RoomDatabase(path);
first.initialize(0, 1_000);
try {
const contender = new RoomDatabase(path);
expect(() => contender.initialize(0, 1_000)).toThrow(/locked/i);
contender.close();
} finally {
first.close();
}
const successor = new RoomDatabase(path);
expect(successor.initialize(0, 1_000)).toEqual([]);
successor.close();
});
it.each([
["application identity", "PRAGMA application_id = 1"],
["schema version", "PRAGMA user_version = 2"],
])("rejects an unknown %s", (_name, mutation) => {
const path = databasePath();
const initial = new RoomDatabase(path);
initial.initialize(0, 1_000);
initial.close();
const raw = new DatabaseSync(path);
raw.exec(mutation);
raw.close();
const reopened = new RoomDatabase(path);
expect(() => reopened.initialize(0, 1_000)).toThrow(/does not match/);
reopened.close();
});
it("rejects an altered current-version schema", () => {
const path = databasePath();
const initial = new RoomDatabase(path);
initial.initialize(0, 1_000);
initial.close();
const raw = new DatabaseSync(path);
raw.exec("ALTER TABLE rooms ADD COLUMN unexpected TEXT");
raw.close();
const reopened = new RoomDatabase(path);
expect(() => reopened.initialize(0, 1_000)).toThrow(
"Room database columns do not match the current schema",
);
reopened.close();
});
it("rejects corrupt authority rows instead of partially restoring them", async () => {
const path = databasePath();
const clock = { nowMs: 100 };
const initial = stableStore(path, clock);
await initial.createRoom("open", null, "7890");
closeStore(initial);
const raw = new DatabaseSync(path);
raw.exec("PRAGMA ignore_check_constraints = ON");
raw.prepare("UPDATE rooms SET host_token_digest = zeroblob(31)").run();
raw.close();
const reopened = new RoomStore({
leaseMs: 1_000,
maxRooms: 8,
maxViewersPerRoom: 8,
now: () => clock.nowMs,
database: new RoomDatabase(path),
});
expect(() => reopened.initialize()).toThrow(/integrity|Host token/i);
reopened.close();
});
});