Preserve room JSON, seats, credentials and messages while removed rooms deny ordinary reads and writes. Add deliberate room-scoped controls, removed-directory filters, persistent player notices and live-authorized atomic version/revision audit receipts. Retain uncertain room/seat requests and hidden recovery drafts through removal; restore the prior pause/join settings without reviving revoked credentials. Fix AI readiness after a joining human or human faction change so real lobby starts remain legal. Verified types, lint, 5314 offline tests, 52 HTTP tests and build. Fourteen authenticated HTTP groups and independent privacy/persistence review passed. Desktop/mobile browser QA confirmed a saved removal after a lost response and refresh, one audit on exact retry, persistent notices and recovery draft continuity. All 1,945 baseline rooms, 4,121 seats and related records preserved. Positive authenticated production admin acceptance remains pending authorized QA operator access; full admin and rules gates remain open.
390 lines
12 KiB
TypeScript
390 lines
12 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
ROOM_ENTRY_STORAGE_KEY,
|
|
clearRoomEntry,
|
|
createRoomEntry,
|
|
parseRoomEntry,
|
|
roomEntryConfirmed,
|
|
roomEntryRejectedBeforeCommit,
|
|
roomEntryUrl,
|
|
saveRoomEntry,
|
|
} from '../lib/room-entry';
|
|
import {
|
|
ClientRequestError,
|
|
requestJson,
|
|
requestMayHaveCompleted,
|
|
} from '../lib/client-request';
|
|
|
|
const fields = {
|
|
name: ' Jessica ',
|
|
faction: 'atreides',
|
|
expansions: ['ecaz', 'ix'],
|
|
advanced: false,
|
|
};
|
|
const playerId = '93c66e7c-2d3b-4845-aedf-5d1f06cf3371';
|
|
|
|
void test('temporary removal cannot clear a first creation or join proof that may already have committed', () => {
|
|
const removed = new ClientRequestError('Room removed', 'http', 410, 'ROOM_REMOVED');
|
|
assert.equal(roomEntryRejectedBeforeCommit(true, removed), false);
|
|
assert.equal(roomEntryRejectedBeforeCommit(false, removed), false);
|
|
assert.equal(roomEntryRejectedBeforeCommit(true, new ClientRequestError('Invalid input', 'http', 400)), true);
|
|
});
|
|
const memoryStorage = () => {
|
|
const data = new Map<string, string>();
|
|
return {
|
|
getItem: (key: string) => data.get(key) ?? null,
|
|
setItem: (key: string, value: string) => {
|
|
data.set(key, value);
|
|
},
|
|
removeItem: (key: string) => {
|
|
data.delete(key);
|
|
},
|
|
};
|
|
};
|
|
const view = {
|
|
code: 'ABCD2345',
|
|
me: playerId,
|
|
players: [{ id: playerId }],
|
|
version: 1,
|
|
};
|
|
|
|
void test('each new explicit attempt has secure credentials; hydration preserves exact raw fields and body bytes', () => {
|
|
const first = createRoomEntry(fields);
|
|
const originalBody = first.bodyText;
|
|
const body = JSON.parse(originalBody);
|
|
assert.equal(body.name, fields.name);
|
|
assert.deepEqual(body.expansions, ['ecaz', 'ix']);
|
|
assert.match(body.entry.sessionToken, /^[0-9a-f]{64}$/);
|
|
assert.match(
|
|
body.entry.operationId,
|
|
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
|
|
);
|
|
const storage = memoryStorage();
|
|
saveRoomEntry(storage, first);
|
|
const restored = parseRoomEntry(storage.getItem(ROOM_ENTRY_STORAGE_KEY)!);
|
|
assert.equal(restored.bodyText, originalBody);
|
|
assert.equal(roomEntryUrl(restored), '/api/rooms');
|
|
assert.ok(Object.isFrozen(restored));
|
|
assert.throws(() => Object.assign(restored, { bodyText: '{}' }), TypeError);
|
|
const second = JSON.parse(createRoomEntry(fields).bodyText);
|
|
assert.notEqual(body.entry.operationId, second.entry.operationId);
|
|
assert.notEqual(body.entry.sessionToken, second.entry.sessionToken);
|
|
});
|
|
|
|
void test('join endpoint is derived only from a validated room, and join preserves its exact small payload', () => {
|
|
const attempt = createRoomEntry(fields, 'ABCD2345');
|
|
assert.equal(roomEntryUrl(attempt), '/api/rooms/ABCD2345');
|
|
assert.deepEqual(Object.keys(JSON.parse(attempt.bodyText)).sort(), [
|
|
'entry',
|
|
'faction',
|
|
'name',
|
|
'type',
|
|
]);
|
|
for (const code of [
|
|
'../rooms',
|
|
'abcd2345',
|
|
'ABCD2340',
|
|
'https://elsewhere.test',
|
|
])
|
|
assert.throws(() => createRoomEntry(fields, code));
|
|
});
|
|
|
|
void test('stored records reject unsupported schemas, malformed proofs, unexpected body fields and altered endpoints without exposing secrets', () => {
|
|
const attempt = createRoomEntry(fields);
|
|
const body = JSON.parse(attempt.bodyText);
|
|
const corruptBodies = [
|
|
{ ...body, entry: { ...body.entry, sessionToken: 'private-secret' } },
|
|
{ ...body, entry: { ...body.entry, operationId: '../request' } },
|
|
{ ...body, entry: { ...body.entry, extra: true } },
|
|
{ ...body, faction: 'invented' },
|
|
{ ...body, name: ' ' },
|
|
{ ...body, name: 'x'.repeat(33) },
|
|
{ ...body, advanced: 'false' },
|
|
{ ...body, expansions: ['unsupported'] },
|
|
{ ...body, action: { type: 'advanceBots' } },
|
|
];
|
|
const bad = [
|
|
'',
|
|
'{',
|
|
'null',
|
|
'[]',
|
|
'x'.repeat(8193),
|
|
JSON.stringify({ ...attempt, schemaVersion: 2 }),
|
|
JSON.stringify({ ...attempt, roomCode: 'ABCD2345' }),
|
|
JSON.stringify({ ...attempt, createdAt: -1 }),
|
|
JSON.stringify({ ...attempt, url: 'https://elsewhere.test' }),
|
|
JSON.stringify({
|
|
...attempt,
|
|
bodyText: '{"name":"duplicate",' + attempt.bodyText.slice(1),
|
|
}),
|
|
...corruptBodies.map((changed) =>
|
|
JSON.stringify({ ...attempt, bodyText: JSON.stringify(changed) }),
|
|
),
|
|
];
|
|
for (const text of bad)
|
|
assert.throws(
|
|
() => parseRoomEntry(text),
|
|
(error: unknown) =>
|
|
error instanceof Error && !error.message.includes('private-secret'),
|
|
);
|
|
});
|
|
|
|
void test('old records do not expire or lose their proof; parsing is read-only even on a future schema', () => {
|
|
const storage = memoryStorage();
|
|
const old = parseRoomEntry(
|
|
JSON.stringify({ ...createRoomEntry(fields), createdAt: 0 }),
|
|
);
|
|
saveRoomEntry(storage, old);
|
|
assert.equal(
|
|
parseRoomEntry(storage.getItem(ROOM_ENTRY_STORAGE_KEY)!).createdAt,
|
|
0,
|
|
);
|
|
const future = JSON.stringify({ ...old, schemaVersion: 99 });
|
|
storage.setItem(ROOM_ENTRY_STORAGE_KEY, future);
|
|
assert.throws(() => parseRoomEntry(storage.getItem(ROOM_ENTRY_STORAGE_KEY)!));
|
|
assert.equal(storage.getItem(ROOM_ENTRY_STORAGE_KEY), future);
|
|
});
|
|
|
|
void test('only the matching receipt, room and seated player confirm a saved request; a cookie GET never does', () => {
|
|
const attempt = createRoomEntry(fields, 'ABCD2345');
|
|
const { operationId } = JSON.parse(attempt.bodyText).entry;
|
|
const response = { ...view, entryReceipt: { operationId, replayed: true } };
|
|
assert.equal(roomEntryConfirmed(attempt, response), true);
|
|
assert.equal(roomEntryConfirmed(attempt, view), false);
|
|
assert.equal(
|
|
roomEntryConfirmed(attempt, { ...response, code: 'EFGH6789' }),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
roomEntryConfirmed(attempt, { ...response, me: 'not-a-seat' }),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
roomEntryConfirmed(attempt, { ...response, players: [] }),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
roomEntryConfirmed(attempt, {
|
|
...response,
|
|
entryReceipt: { operationId: crypto.randomUUID(), replayed: true },
|
|
}),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
roomEntryConfirmed(attempt, { ...view, alreadySeated: true }),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
roomEntryConfirmed(attempt, { ...view, alreadySeated: true }, true),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
roomEntryConfirmed(
|
|
createRoomEntry(fields),
|
|
{ ...view, alreadySeated: true },
|
|
true,
|
|
),
|
|
false,
|
|
);
|
|
});
|
|
|
|
void test('storage denial or a failed write is detected before a caller can dispatch; clearing removes only the scoped proof', () => {
|
|
const attempt = createRoomEntry(fields);
|
|
assert.throws(() =>
|
|
saveRoomEntry(
|
|
{
|
|
getItem: () => null,
|
|
setItem: () => {
|
|
throw new Error('denied');
|
|
},
|
|
},
|
|
attempt,
|
|
),
|
|
);
|
|
assert.throws(() =>
|
|
saveRoomEntry({ getItem: () => null, setItem: () => {} }, attempt),
|
|
);
|
|
const storage = memoryStorage();
|
|
storage.setItem('other-setting', 'keep');
|
|
saveRoomEntry(storage, attempt);
|
|
clearRoomEntry(storage);
|
|
assert.equal(storage.getItem(ROOM_ENTRY_STORAGE_KEY), null);
|
|
assert.equal(storage.getItem('other-setting'), 'keep');
|
|
assert.throws(() =>
|
|
clearRoomEntry({ getItem: () => 'still-present', removeItem: () => {} }),
|
|
);
|
|
});
|
|
|
|
void test('a gateway failure and page refresh retain the exact request for explicit replay; a matching receipt permits cleanup', async () => {
|
|
const storage = memoryStorage();
|
|
const attempt = createRoomEntry(fields, 'ABCD2345');
|
|
saveRoomEntry(storage, attempt);
|
|
const sent: string[] = [];
|
|
const failedFetch: typeof fetch = async (_url, init) => {
|
|
sent.push(init?.body as string);
|
|
return new Response(JSON.stringify({ error: 'Gateway unavailable' }), {
|
|
status: 503,
|
|
});
|
|
};
|
|
await assert.rejects(
|
|
requestJson(
|
|
roomEntryUrl(attempt),
|
|
{ method: 'POST', body: attempt.bodyText },
|
|
{ fetcher: failedFetch },
|
|
),
|
|
(error) => requestMayHaveCompleted(error),
|
|
);
|
|
assert.equal(sent.length, 1, 'there is no automatic retry');
|
|
const restored = parseRoomEntry(storage.getItem(ROOM_ENTRY_STORAGE_KEY)!);
|
|
const { operationId } = JSON.parse(restored.bodyText).entry;
|
|
const recovered = await requestJson(
|
|
roomEntryUrl(restored),
|
|
{ method: 'POST', body: restored.bodyText },
|
|
{
|
|
fetcher: async (_url, init) => {
|
|
sent.push(init?.body as string);
|
|
return new Response(
|
|
JSON.stringify({
|
|
...view,
|
|
entryReceipt: { operationId, replayed: true },
|
|
}),
|
|
);
|
|
},
|
|
},
|
|
);
|
|
assert.deepEqual(sent, [attempt.bodyText, attempt.bodyText]);
|
|
assert.equal(roomEntryConfirmed(restored, recovered), true);
|
|
clearRoomEntry(storage);
|
|
assert.equal(storage.getItem(ROOM_ENTRY_STORAGE_KEY), null);
|
|
});
|
|
|
|
void test('storage read/write/readback/remove exceptions are surfaced, and an existing proof is never overwritten', () => {
|
|
const attempt = createRoomEntry(fields);
|
|
assert.throws(() =>
|
|
saveRoomEntry(
|
|
{
|
|
getItem: () => {
|
|
throw new Error('read denied');
|
|
},
|
|
setItem: () =>
|
|
assert.fail('must not write after a failed initial read'),
|
|
},
|
|
attempt,
|
|
),
|
|
);
|
|
let reads = 0;
|
|
assert.throws(() =>
|
|
saveRoomEntry(
|
|
{
|
|
getItem: () => {
|
|
if (++reads === 1) return null;
|
|
throw new Error('readback denied');
|
|
},
|
|
setItem: () => {},
|
|
},
|
|
attempt,
|
|
),
|
|
);
|
|
assert.throws(() =>
|
|
clearRoomEntry({
|
|
getItem: () => null,
|
|
removeItem: () => {
|
|
throw new Error('remove denied');
|
|
},
|
|
}),
|
|
);
|
|
assert.throws(() =>
|
|
clearRoomEntry({
|
|
getItem: () => {
|
|
throw new Error('readback denied');
|
|
},
|
|
removeItem: () => {},
|
|
}),
|
|
);
|
|
const storage = memoryStorage();
|
|
saveRoomEntry(storage, attempt);
|
|
assert.throws(() => saveRoomEntry(storage, createRoomEntry(fields)));
|
|
assert.equal(
|
|
storage.getItem(ROOM_ENTRY_STORAGE_KEY),
|
|
JSON.stringify(attempt),
|
|
);
|
|
saveRoomEntry(storage, attempt);
|
|
assert.equal(
|
|
storage.getItem(ROOM_ENTRY_STORAGE_KEY),
|
|
JSON.stringify(attempt),
|
|
);
|
|
});
|
|
|
|
void test('a body timeout retains the pending proof; a malformed receipt cannot fall back to alreadySeated', async () => {
|
|
const attempt = createRoomEntry(fields, 'ABCD2345');
|
|
const storage = memoryStorage();
|
|
saveRoomEntry(storage, attempt);
|
|
let aborted = false;
|
|
await assert.rejects(
|
|
requestJson(
|
|
roomEntryUrl(attempt),
|
|
{ method: 'POST', body: attempt.bodyText },
|
|
{
|
|
timeoutMs: 5,
|
|
fetcher: async (_url, init) => {
|
|
init?.signal?.addEventListener('abort', () => {
|
|
aborted = true;
|
|
});
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
json: () => new Promise(() => {}),
|
|
} as Response;
|
|
},
|
|
},
|
|
),
|
|
(error) => requestMayHaveCompleted(error),
|
|
);
|
|
assert.equal(aborted, true);
|
|
assert.equal(
|
|
parseRoomEntry(storage.getItem(ROOM_ENTRY_STORAGE_KEY)!).bodyText,
|
|
attempt.bodyText,
|
|
);
|
|
for (const entryReceipt of [
|
|
null,
|
|
[],
|
|
{ replayed: true },
|
|
{
|
|
operationId: JSON.parse(attempt.bodyText).entry.operationId,
|
|
replayed: 'yes',
|
|
},
|
|
]) {
|
|
assert.equal(
|
|
roomEntryConfirmed(
|
|
attempt,
|
|
{ ...view, alreadySeated: true, entryReceipt },
|
|
true,
|
|
),
|
|
false,
|
|
);
|
|
assert.notEqual(storage.getItem(ROOM_ENTRY_STORAGE_KEY), null);
|
|
}
|
|
});
|
|
|
|
void test('only a first definitive HTTP rejection allows correcting entry details; uncertain or retried outcomes retain their proof', () => {
|
|
for (const status of [400, 401, 403, 404, 409, 413, 429]) {
|
|
const error = new ClientRequestError('Rejected', 'http', status);
|
|
assert.equal(roomEntryRejectedBeforeCommit(true, error), true);
|
|
assert.equal(roomEntryRejectedBeforeCommit(false, error), false);
|
|
}
|
|
for (const error of [
|
|
new ClientRequestError('Timeout', 'timeout'),
|
|
new ClientRequestError('Aborted', 'aborted'),
|
|
new ClientRequestError('Network', 'network'),
|
|
new ClientRequestError('Invalid response', 'invalid-response'),
|
|
new ClientRequestError('HTTP timeout', 'http', 408),
|
|
new ClientRequestError('Server', 'http', 500),
|
|
new ClientRequestError('Gateway', 'http', 502),
|
|
new ClientRequestError('Unavailable', 'http', 503),
|
|
new ClientRequestError('Gateway timeout', 'http', 504),
|
|
new Error('Unclassified'),
|
|
])
|
|
assert.equal(roomEntryRejectedBeforeCommit(true, error), false);
|
|
});
|