Let a human owner authorize another human in the started room to start a fixed difficulty once within 24 hours. The owner retains the seat, cookie, private hand, recovery setup and takeback control. Consent is explicit even while online; neither host status nor inactivity authorizes a takeover. Activation enters the existing paced private-view AI path without strategic changes. Add additive migration 0006, both exact session fences, owner/delegate-only metadata in the authenticated room snapshot, atomic CAS activation and consumption, expiry and terminal retry receipts. Replacement hides older consent; own AI changes invalidate unused consent. Recovery or handover of either bound seat invalidates the grant. Future-version guards prevent stale computed state from overwriting a racing action. Exact browser retry records retain the original operation/version across refresh and cannot rearm old consent. Shared HTTP continuation preserves coalescing across ordinary and control routes. Independent review found no material remaining issue. Revoke messaging accurately handles activation winning the race. Browser QA exercised grant/revoke, preserved consent after refresh, reciprocal fixed-profile activation, consumed receipts, keyboard takeback and private ownership. The panel now remains expanded after success and action buttons are at least 44px. Responsive screenshot acceptance remains open because the browser viewport override targeted a failed secondary-origin tab; desktop controls and no-overflow width were verified. The QA room is stopped at a genuine human setup decision with both seats taken back. Types, lint, all 4906 offline tests, production build and all 45 HTTP tests pass on the stable source fingerprint. Seven new offline cases cover client consent/retry validation and real SQLite privacy, restart, one-use races, future versions, takeback, expiry and both-seat recovery/handover; two new HTTP cases cover authentication, injection, fixed-profile continuation and takeback. The first broad run exposed 68 legacy VM fixtures missing the new production dependency; their mappings were updated and the real shared continuation remains exercised. The historical 0005 migration test verifies backfill before applying 0006 for current code. All 1174 original rooms, 2456 seats and every prior recovery/entry/handover/migration row remain unchanged. SQLite integrity and foreign keys pass; migration and QA added 88 rooms and 188 seats. No reset, server restart, usage check or automation was performed. The current Sites integration has no supported durable Cron/DO provisioning path. Document this platform blocker rather than adding an inert scheduled export; unattended resumption and broader network-failure acceptance remain unfinished. Full rules, component, AI strength and publication gates remain closed. Verified source tree: 2d86fc611e9ef4f5a10db10f96687a31db1ec238908ecf1e9333c50fac9ccaa8
799 lines
21 KiB
TypeScript
799 lines
21 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { createHash } from 'node:crypto';
|
|
import { readFileSync } from 'node:fs';
|
|
import type { DatabaseSync } from 'node:sqlite';
|
|
import { baseDeck } from '../game/cards';
|
|
import { createGame, newPlayer } from '../game/engine';
|
|
import { unitStore } from './fixture-nexus-room-store';
|
|
|
|
const UUID = {
|
|
entry: '10000000-0000-4000-8000-000000000001',
|
|
recovery: '10000000-0000-4000-8000-000000000002',
|
|
offer: '10000000-0000-4000-8000-000000000003',
|
|
replacement: '10000000-0000-4000-8000-000000000004',
|
|
claim: '10000000-0000-4000-8000-000000000005',
|
|
rival: '10000000-0000-4000-8000-000000000006',
|
|
};
|
|
const secret = (pair: string) => pair.repeat(32);
|
|
const digest = (value: string) =>
|
|
createHash('sha256').update(value).digest('hex');
|
|
const clock = (now: number) => ({ now: () => now });
|
|
|
|
async function fixture(
|
|
withRecoveryRotation = false,
|
|
migrationThrough?: string,
|
|
) {
|
|
const store = unitStore(undefined, migrationThrough);
|
|
const entry = {
|
|
operationId: UUID.entry,
|
|
sessionToken: secret('10'),
|
|
};
|
|
const created = await store.rooms.createRoom(
|
|
'Original owner',
|
|
'atreides',
|
|
false,
|
|
[],
|
|
entry,
|
|
);
|
|
const code = created.view.code;
|
|
let auth = await store.rooms.authenticate(code, created.token);
|
|
const game = await store.rooms.readRoom(code);
|
|
game.players[0].hand = [baseDeck()[0]];
|
|
game.players[0].traitors = ['emperor-0'];
|
|
store.sqlite
|
|
.prepare('UPDATE rooms SET state = ? WHERE code = ?')
|
|
.run(JSON.stringify(game), code);
|
|
let view = await store.rooms.readSeatView(code, auth);
|
|
const recoverySecret = secret('20');
|
|
let recoveryInput:
|
|
| {
|
|
playerId: string;
|
|
recoverySecret: string;
|
|
operationId: string;
|
|
newSessionToken: string;
|
|
}
|
|
| undefined;
|
|
if (withRecoveryRotation) {
|
|
view = (
|
|
await store.rooms.setRecoveryKey(
|
|
code,
|
|
auth,
|
|
view.version,
|
|
recoverySecret,
|
|
)
|
|
).view;
|
|
recoveryInput = {
|
|
playerId: created.view.me,
|
|
recoverySecret,
|
|
operationId: UUID.recovery,
|
|
newSessionToken: secret('30'),
|
|
};
|
|
const recovered = await store.rooms.recoverSeat(code, recoveryInput);
|
|
view = recovered.view;
|
|
auth = await store.rooms.authenticate(code, recoveryInput.newSessionToken);
|
|
}
|
|
return {
|
|
...store,
|
|
code,
|
|
created,
|
|
entry,
|
|
auth,
|
|
view,
|
|
recoverySecret,
|
|
recoveryInput,
|
|
};
|
|
}
|
|
|
|
const offer = (id = UUID.offer, handoverSecret = secret('40')) => ({
|
|
offerId: id,
|
|
handoverSecret,
|
|
});
|
|
const claim = (
|
|
playerId: string,
|
|
operationId = UUID.claim,
|
|
newSessionToken = secret('50'),
|
|
handover = offer(),
|
|
) => ({
|
|
playerId,
|
|
...handover,
|
|
operationId,
|
|
newSessionToken,
|
|
});
|
|
|
|
function custody(sqlite: DatabaseSync) {
|
|
return JSON.stringify(
|
|
[
|
|
'rooms',
|
|
'seats',
|
|
'seat_recovery_keys',
|
|
'seat_recovery_receipts',
|
|
'room_entry_receipts',
|
|
'seat_handover_offers',
|
|
'seat_handover_claim_receipts',
|
|
].map((table) =>
|
|
sqlite.prepare(`SELECT * FROM ${table} ORDER BY rowid`).all(),
|
|
),
|
|
);
|
|
}
|
|
|
|
function batchBarrier(hooks: { beforeBatch?: () => Promise<void> }) {
|
|
let entered!: () => void;
|
|
let release!: () => void;
|
|
const waiting = new Promise<void>((resolve) => {
|
|
entered = resolve;
|
|
});
|
|
const gate = new Promise<void>((resolve) => {
|
|
release = resolve;
|
|
});
|
|
hooks.beforeBatch = async () => {
|
|
delete hooks.beforeBatch;
|
|
entered();
|
|
await gate;
|
|
};
|
|
return { waiting, release };
|
|
}
|
|
|
|
void test('voluntary claim preserves exact game state and private seat while revoking every old authority', async () => {
|
|
const f = await fixture(true);
|
|
try {
|
|
const handover = offer();
|
|
const created = await f.rooms.createSeatHandover(
|
|
f.code,
|
|
f.auth,
|
|
f.view.version,
|
|
handover,
|
|
clock(1000),
|
|
);
|
|
assert.equal(created.expiresAt, 1000 + f.rooms.SEAT_HANDOVER_TTL_MS);
|
|
assert.equal(created.replayed, false);
|
|
const state = f.sqlite
|
|
.prepare('SELECT state FROM rooms WHERE code = ?')
|
|
.get(f.code)!.state;
|
|
const body = claim(f.created.view.me);
|
|
const transferred = await f.rooms.claimSeatHandover(
|
|
f.code,
|
|
body,
|
|
clock(2000),
|
|
);
|
|
assert.equal(transferred.transferred, true);
|
|
assert.equal(transferred.replayed, false);
|
|
assert.equal(transferred.view.me, f.created.view.me);
|
|
assert.equal(transferred.view.players[0].faction, 'atreides');
|
|
assert.equal(transferred.view.players[0].hand![0].id, baseDeck()[0].id);
|
|
assert.equal(
|
|
f.sqlite.prepare('SELECT state FROM rooms WHERE code = ?').get(f.code)!
|
|
.state,
|
|
state,
|
|
);
|
|
await assert.rejects(f.rooms.readSeatView(f.code, f.auth), /verified/);
|
|
await assert.rejects(
|
|
f.rooms.recoverSeat(f.code, f.recoveryInput!),
|
|
/could not be verified|no longer active/,
|
|
);
|
|
await assert.rejects(
|
|
f.rooms.createRoom(
|
|
'Original owner',
|
|
'atreides',
|
|
false,
|
|
[],
|
|
f.entry,
|
|
),
|
|
/no longer active/,
|
|
);
|
|
assert.equal(
|
|
f.sqlite.prepare('SELECT count(*) AS n FROM seat_recovery_keys').get()!
|
|
.n,
|
|
0,
|
|
);
|
|
assert.equal(
|
|
f.sqlite
|
|
.prepare('SELECT count(*) AS n FROM seat_recovery_receipts')
|
|
.get()!.n,
|
|
0,
|
|
);
|
|
const recipientAuth = await f.rooms.authenticate(
|
|
f.code,
|
|
body.newSessionToken,
|
|
);
|
|
const acted = await f.rooms.act(
|
|
f.code,
|
|
recipientAuth,
|
|
transferred.view.version,
|
|
{ type: 'ready' },
|
|
);
|
|
assert.equal(acted.players[0].ready, true);
|
|
const restarted = f.restart();
|
|
const replay = await restarted.claimSeatHandover(
|
|
f.code,
|
|
body,
|
|
clock(1000 + f.rooms.SEAT_HANDOVER_TTL_MS + 1),
|
|
);
|
|
assert.equal(replay.replayed, true);
|
|
assert.equal(replay.token, body.newSessionToken);
|
|
assert.equal(replay.view.version, acted.version);
|
|
} finally {
|
|
f.sqlite.close();
|
|
}
|
|
});
|
|
|
|
void test('recovering the issuer invalidates its still-unclaimed handover offer', async () => {
|
|
const f = await fixture();
|
|
try {
|
|
const recoverySecret = secret('21');
|
|
const saved = await f.rooms.setRecoveryKey(
|
|
f.code,
|
|
f.auth,
|
|
f.view.version,
|
|
recoverySecret,
|
|
);
|
|
const handover = offer();
|
|
const offered = await f.rooms.createSeatHandover(
|
|
f.code,
|
|
f.auth,
|
|
saved.view.version,
|
|
handover,
|
|
clock(1000),
|
|
);
|
|
const recovery = {
|
|
playerId: f.created.view.me,
|
|
recoverySecret,
|
|
operationId: UUID.recovery,
|
|
newSessionToken: secret('31'),
|
|
};
|
|
const recovered = await f.rooms.recoverSeat(f.code, recovery);
|
|
await assert.rejects(
|
|
f.rooms.claimSeatHandover(
|
|
f.code,
|
|
claim(f.created.view.me),
|
|
clock(2000),
|
|
),
|
|
/could not be verified/,
|
|
);
|
|
const newAuth = await f.rooms.authenticate(
|
|
f.code,
|
|
recovery.newSessionToken,
|
|
);
|
|
assert.deepEqual(
|
|
await f.rooms.readSeatView(f.code, newAuth),
|
|
recovered.view,
|
|
);
|
|
assert.equal(
|
|
(await f.rooms.readRoom(f.code)).version,
|
|
offered.view.version + 1,
|
|
);
|
|
} finally {
|
|
f.sqlite.close();
|
|
}
|
|
});
|
|
|
|
void test('claim invalidates the issuer entry receipt that still controlled the seat', async () => {
|
|
const f = await fixture();
|
|
try {
|
|
const offered = await f.rooms.createSeatHandover(
|
|
f.code,
|
|
f.auth,
|
|
f.view.version,
|
|
offer(),
|
|
clock(1000),
|
|
);
|
|
await f.rooms.claimSeatHandover(
|
|
f.code,
|
|
claim(f.created.view.me),
|
|
clock(2000),
|
|
);
|
|
await assert.rejects(
|
|
f.rooms.createRoom(
|
|
'Original owner',
|
|
'atreides',
|
|
false,
|
|
[],
|
|
f.entry,
|
|
),
|
|
/no longer active/,
|
|
);
|
|
assert.equal(
|
|
(await f.rooms.readRoom(f.code)).version,
|
|
offered.view.version + 1,
|
|
);
|
|
} finally {
|
|
f.sqlite.close();
|
|
}
|
|
});
|
|
|
|
void test('0005 backfills an active 0004 claim for exact replay after upgrade', async () => {
|
|
const f = unitStore(undefined, '0004_flippant_archangel.sql');
|
|
try {
|
|
const code = 'LEGACY05';
|
|
const playerId = UUID.rival;
|
|
const oldSessionHash = digest(secret('10'));
|
|
const handover = offer();
|
|
const body = claim(playerId);
|
|
const game = createGame(
|
|
code,
|
|
newPlayer(playerId, 'Original owner', 'atreides'),
|
|
false,
|
|
[],
|
|
);
|
|
game.players[0].hand = [baseDeck()[0]];
|
|
game.players[0].traitors = ['emperor-0'];
|
|
f.sqlite
|
|
.prepare(
|
|
'INSERT INTO rooms(code,state,version,updated_at) VALUES(?,?,1,?)',
|
|
)
|
|
.run(code, JSON.stringify(game), 1000);
|
|
f.sqlite
|
|
.prepare(
|
|
'INSERT INTO seats(token_hash,room_code,player_id) VALUES(?,?,?)',
|
|
)
|
|
.run(oldSessionHash, code, playerId);
|
|
f.sqlite
|
|
.prepare(
|
|
'INSERT INTO seat_handover_offers(room_code,player_id,offer_hash,secret_hash,issuer_session_hash,expires_at,claim_operation_hash,session_hash,claimed_at) VALUES(?,?,?,?,?,?,NULL,NULL,NULL)',
|
|
)
|
|
.run(
|
|
code,
|
|
playerId,
|
|
digest(handover.offerId),
|
|
digest(handover.handoverSecret),
|
|
oldSessionHash,
|
|
301000,
|
|
);
|
|
const operationHash = digest(body.operationId);
|
|
const sessionHash = digest(body.newSessionToken);
|
|
f.sqlite.exec('BEGIN');
|
|
try {
|
|
f.sqlite
|
|
.prepare(
|
|
'UPDATE rooms SET version = version + 1, updated_at = ? WHERE code = ? AND version = ?',
|
|
)
|
|
.run(2000, code, 1);
|
|
f.sqlite
|
|
.prepare(
|
|
'UPDATE seat_handover_offers SET claim_operation_hash = ?, session_hash = ?, claimed_at = ? WHERE room_code = ? AND player_id = ?',
|
|
)
|
|
.run(operationHash, sessionHash, 2000, code, playerId);
|
|
f.sqlite
|
|
.prepare(
|
|
'UPDATE seats SET revoked = 1 WHERE room_code = ? AND player_id = ? AND revoked = 0',
|
|
)
|
|
.run(code, playerId);
|
|
f.sqlite
|
|
.prepare(
|
|
'INSERT INTO seats(token_hash,room_code,player_id) VALUES (?,?,?)',
|
|
)
|
|
.run(sessionHash, code, playerId);
|
|
f.sqlite.exec('COMMIT');
|
|
} catch (error) {
|
|
f.sqlite.exec('ROLLBACK');
|
|
throw error;
|
|
}
|
|
const stateBeforeUpgrade = f.sqlite
|
|
.prepare('SELECT state FROM rooms WHERE code = ?')
|
|
.get(code)!.state;
|
|
f.sqlite.exec(
|
|
readFileSync(
|
|
new URL('../drizzle/0005_salty_alice.sql', import.meta.url),
|
|
'utf8',
|
|
),
|
|
);
|
|
const receipt = f.sqlite
|
|
.prepare(
|
|
'SELECT operation_hash,session_hash,claim_fence FROM seat_handover_claim_receipts WHERE room_code = ? AND player_id = ?',
|
|
)
|
|
.get(code, playerId)!;
|
|
assert.equal(receipt.operation_hash, operationHash);
|
|
assert.equal(receipt.session_hash, sessionHash);
|
|
assert.match(receipt.claim_fence as string, /^legacy:/);
|
|
assert.equal(
|
|
(receipt.claim_fence as string).includes(body.newSessionToken),
|
|
false,
|
|
);
|
|
f.sqlite.exec(
|
|
readFileSync(
|
|
new URL('../drizzle/0006_thick_imperial_guard.sql', import.meta.url),
|
|
'utf8',
|
|
),
|
|
);
|
|
const replay = await f.restart().claimSeatHandover(code, body, clock(3000));
|
|
assert.equal(replay.replayed, true);
|
|
assert.equal(replay.token, body.newSessionToken);
|
|
assert.equal(replay.view.me, playerId);
|
|
assert.equal(replay.view.players[0].hand![0].id, baseDeck()[0].id);
|
|
assert.equal(
|
|
f.sqlite.prepare('SELECT state FROM rooms WHERE code = ?').get(code)!
|
|
.state,
|
|
stateBeforeUpgrade,
|
|
);
|
|
} finally {
|
|
f.sqlite.close();
|
|
}
|
|
});
|
|
|
|
void test('exact claim replay survives a later offer created by the recipient', async () => {
|
|
const f = await fixture();
|
|
try {
|
|
const body = claim(f.created.view.me);
|
|
const offered = await f.rooms.createSeatHandover(
|
|
f.code,
|
|
f.auth,
|
|
f.view.version,
|
|
offer(),
|
|
clock(1000),
|
|
);
|
|
const transferred = await f.rooms.claimSeatHandover(
|
|
f.code,
|
|
body,
|
|
clock(2000),
|
|
);
|
|
const recipientAuth = await f.rooms.authenticate(
|
|
f.code,
|
|
body.newSessionToken,
|
|
);
|
|
const later = await f.rooms.createSeatHandover(
|
|
f.code,
|
|
recipientAuth,
|
|
transferred.view.version,
|
|
offer(UUID.replacement, secret('41')),
|
|
clock(3000),
|
|
);
|
|
const replay = await f.rooms.claimSeatHandover(
|
|
f.code,
|
|
body,
|
|
clock(4000),
|
|
);
|
|
assert.equal(replay.replayed, true);
|
|
assert.equal(replay.token, body.newSessionToken);
|
|
assert.equal(replay.view.version, later.view.version);
|
|
assert.equal(offered.view.version + 2, later.view.version);
|
|
} finally {
|
|
f.sqlite.close();
|
|
}
|
|
});
|
|
|
|
void test('losing exact claim cannot erase recovery configured after the winner', async () => {
|
|
const f = await fixture();
|
|
try {
|
|
const body = claim(f.created.view.me);
|
|
await f.rooms.createSeatHandover(
|
|
f.code,
|
|
f.auth,
|
|
f.view.version,
|
|
offer(),
|
|
clock(1000),
|
|
);
|
|
const barrier = batchBarrier(f.hooks);
|
|
const loser = f.rooms.claimSeatHandover(f.code, body, clock(2000));
|
|
await barrier.waiting;
|
|
const winner = await f.rooms.claimSeatHandover(
|
|
f.code,
|
|
body,
|
|
clock(2000),
|
|
);
|
|
const recipientAuth = await f.rooms.authenticate(
|
|
f.code,
|
|
body.newSessionToken,
|
|
);
|
|
const recoverySecret = secret('22');
|
|
const configured = await f.rooms.setRecoveryKey(
|
|
f.code,
|
|
recipientAuth,
|
|
winner.view.version,
|
|
recoverySecret,
|
|
);
|
|
barrier.release();
|
|
const replay = await loser;
|
|
assert.equal(replay.replayed, true);
|
|
assert.equal(replay.view.version, configured.view.version);
|
|
assert.equal(
|
|
f.sqlite.prepare('SELECT count(*) AS n FROM seat_recovery_keys').get()!
|
|
.n,
|
|
1,
|
|
);
|
|
const recovered = await f.rooms.recoverSeat(f.code, {
|
|
playerId: f.created.view.me,
|
|
recoverySecret,
|
|
operationId: UUID.recovery,
|
|
newSessionToken: secret('32'),
|
|
});
|
|
assert.equal(recovered.recovered, true);
|
|
} finally {
|
|
f.sqlite.close();
|
|
}
|
|
});
|
|
|
|
void test('a concurrent game action wins cleanly over a paused claim', async () => {
|
|
const f = await fixture();
|
|
try {
|
|
const offered = await f.rooms.createSeatHandover(
|
|
f.code,
|
|
f.auth,
|
|
f.view.version,
|
|
offer(),
|
|
clock(1000),
|
|
);
|
|
const barrier = batchBarrier(f.hooks);
|
|
const pending = f.rooms.claimSeatHandover(
|
|
f.code,
|
|
claim(f.created.view.me),
|
|
clock(2000),
|
|
);
|
|
await barrier.waiting;
|
|
const acted = await f.rooms.act(f.code, f.auth, offered.view.version, {
|
|
type: 'ready',
|
|
});
|
|
barrier.release();
|
|
await assert.rejects(pending, /changed/);
|
|
assert.equal(acted.players[0].ready, true);
|
|
await f.rooms.readSeatView(f.code, f.auth);
|
|
assert.equal(
|
|
f.sqlite
|
|
.prepare('SELECT count(*) AS n FROM seat_handover_claim_receipts')
|
|
.get()!.n,
|
|
0,
|
|
);
|
|
} finally {
|
|
f.sqlite.close();
|
|
}
|
|
});
|
|
|
|
void test('issuer recovery wins cleanly over a paused claim', async () => {
|
|
const f = await fixture();
|
|
try {
|
|
const recoverySecret = secret('23');
|
|
const saved = await f.rooms.setRecoveryKey(
|
|
f.code,
|
|
f.auth,
|
|
f.view.version,
|
|
recoverySecret,
|
|
);
|
|
await f.rooms.createSeatHandover(
|
|
f.code,
|
|
f.auth,
|
|
saved.view.version,
|
|
offer(),
|
|
clock(1000),
|
|
);
|
|
const barrier = batchBarrier(f.hooks);
|
|
const pending = f.rooms.claimSeatHandover(
|
|
f.code,
|
|
claim(f.created.view.me),
|
|
clock(2000),
|
|
);
|
|
await barrier.waiting;
|
|
const recovery = {
|
|
playerId: f.created.view.me,
|
|
recoverySecret,
|
|
operationId: UUID.recovery,
|
|
newSessionToken: secret('33'),
|
|
};
|
|
const recovered = await f.rooms.recoverSeat(f.code, recovery);
|
|
barrier.release();
|
|
await assert.rejects(pending, /changed/);
|
|
const recoveredAuth = await f.rooms.authenticate(
|
|
f.code,
|
|
recovery.newSessionToken,
|
|
);
|
|
assert.deepEqual(
|
|
await f.rooms.readSeatView(f.code, recoveredAuth),
|
|
recovered.view,
|
|
);
|
|
assert.equal(
|
|
f.sqlite
|
|
.prepare('SELECT count(*) AS n FROM seat_handover_claim_receipts')
|
|
.get()!.n,
|
|
0,
|
|
);
|
|
} finally {
|
|
f.sqlite.close();
|
|
}
|
|
});
|
|
|
|
void test('owner revocation wins cleanly over a paused claim', async () => {
|
|
const f = await fixture();
|
|
try {
|
|
const offered = await f.rooms.createSeatHandover(
|
|
f.code,
|
|
f.auth,
|
|
f.view.version,
|
|
offer(),
|
|
clock(1000),
|
|
);
|
|
const barrier = batchBarrier(f.hooks);
|
|
const pending = f.rooms.claimSeatHandover(
|
|
f.code,
|
|
claim(f.created.view.me),
|
|
clock(2000),
|
|
);
|
|
await barrier.waiting;
|
|
const revoked = await f.rooms.revokeSeatHandover(
|
|
f.code,
|
|
f.auth,
|
|
offered.view.version,
|
|
UUID.offer,
|
|
clock(2000),
|
|
);
|
|
barrier.release();
|
|
await assert.rejects(pending, /changed/);
|
|
assert.equal(revoked.revoked, true);
|
|
await f.rooms.readSeatView(f.code, f.auth);
|
|
assert.equal(
|
|
f.sqlite
|
|
.prepare('SELECT count(*) AS n FROM seat_handover_claim_receipts')
|
|
.get()!.n,
|
|
0,
|
|
);
|
|
} finally {
|
|
f.sqlite.close();
|
|
}
|
|
});
|
|
|
|
void test('exact concurrent claims replay one rotation while different claims have one winner', async () => {
|
|
for (const same of [true, false]) {
|
|
const f = await fixture();
|
|
try {
|
|
const offered = await f.rooms.createSeatHandover(
|
|
f.code,
|
|
f.auth,
|
|
f.view.version,
|
|
offer(),
|
|
clock(1000),
|
|
);
|
|
const first = claim(f.created.view.me);
|
|
const second = same
|
|
? first
|
|
: claim(f.created.view.me, UUID.rival, secret('60'));
|
|
const results = await Promise.allSettled([
|
|
f.rooms.claimSeatHandover(f.code, first, clock(2000)),
|
|
f.rooms.claimSeatHandover(f.code, second, clock(2000)),
|
|
]);
|
|
assert.equal(
|
|
results.filter((result) => result.status === 'fulfilled').length,
|
|
same ? 2 : 1,
|
|
);
|
|
assert.equal(
|
|
(await f.rooms.readRoom(f.code)).version,
|
|
offered.view.version + 1,
|
|
);
|
|
assert.equal(
|
|
f.sqlite
|
|
.prepare('SELECT count(*) AS n FROM seats WHERE revoked = 0')
|
|
.get()!.n,
|
|
1,
|
|
);
|
|
if (same)
|
|
assert.ok(
|
|
results.every(
|
|
(result) =>
|
|
result.status === 'fulfilled' &&
|
|
result.value.token === first.newSessionToken,
|
|
),
|
|
);
|
|
else
|
|
await assert.rejects(
|
|
f.rooms.claimSeatHandover(f.code, second, clock(2000)),
|
|
/already been claimed/,
|
|
);
|
|
} finally {
|
|
f.sqlite.close();
|
|
}
|
|
}
|
|
});
|
|
|
|
void test('offer replacement, revocation and expiry are version-fenced and retry safe', async () => {
|
|
const f = await fixture();
|
|
try {
|
|
const first = await f.rooms.createSeatHandover(
|
|
f.code,
|
|
f.auth,
|
|
f.view.version,
|
|
offer(),
|
|
clock(1000),
|
|
);
|
|
const replay = await f.rooms.createSeatHandover(
|
|
f.code,
|
|
f.auth,
|
|
f.view.version,
|
|
offer(),
|
|
clock(1001),
|
|
);
|
|
assert.equal(replay.replayed, true);
|
|
assert.equal(replay.view.version, first.view.version);
|
|
const replacement = offer(UUID.replacement, secret('41'));
|
|
const replaced = await f.rooms.createSeatHandover(
|
|
f.code,
|
|
f.auth,
|
|
first.view.version,
|
|
replacement,
|
|
clock(2000),
|
|
);
|
|
await assert.rejects(
|
|
f.rooms.claimSeatHandover(
|
|
f.code,
|
|
claim(f.created.view.me),
|
|
clock(3000),
|
|
),
|
|
/could not be verified/,
|
|
);
|
|
await assert.rejects(
|
|
f.rooms.revokeSeatHandover(
|
|
f.code,
|
|
f.auth,
|
|
replaced.view.version,
|
|
UUID.offer,
|
|
),
|
|
/different handover offer/,
|
|
);
|
|
const revoked = await f.rooms.revokeSeatHandover(
|
|
f.code,
|
|
f.auth,
|
|
replaced.view.version,
|
|
UUID.replacement,
|
|
clock(3000),
|
|
);
|
|
assert.equal(revoked.replayed, false);
|
|
const retried = await f.rooms.revokeSeatHandover(
|
|
f.code,
|
|
f.auth,
|
|
replaced.view.version,
|
|
UUID.replacement,
|
|
clock(3001),
|
|
);
|
|
assert.equal(retried.replayed, true);
|
|
|
|
const expiring = await f.rooms.createSeatHandover(
|
|
f.code,
|
|
f.auth,
|
|
retried.view.version,
|
|
offer(),
|
|
clock(4000),
|
|
);
|
|
await assert.rejects(
|
|
f.rooms.claimSeatHandover(
|
|
f.code,
|
|
claim(f.created.view.me),
|
|
clock(expiring.expiresAt),
|
|
),
|
|
/expired/,
|
|
);
|
|
} finally {
|
|
f.sqlite.close();
|
|
}
|
|
});
|
|
|
|
void test('failed recipient credential insertion rolls back claim custody atomically', async () => {
|
|
const f = await fixture(true);
|
|
try {
|
|
await f.rooms.createSeatHandover(
|
|
f.code,
|
|
f.auth,
|
|
f.view.version,
|
|
offer(),
|
|
clock(1000),
|
|
);
|
|
const before = custody(f.sqlite);
|
|
f.sqlite.exec(
|
|
"CREATE TRIGGER reject_handover_session BEFORE INSERT ON seats BEGIN SELECT RAISE(ABORT, 'injected handover insertion failure'); END;",
|
|
);
|
|
await assert.rejects(
|
|
f.rooms.claimSeatHandover(
|
|
f.code,
|
|
claim(f.created.view.me),
|
|
clock(2000),
|
|
),
|
|
/injected handover insertion failure/,
|
|
);
|
|
assert.equal(custody(f.sqlite), before);
|
|
await f.rooms.readSeatView(f.code, f.auth);
|
|
f.sqlite.exec('DROP TRIGGER reject_handover_session');
|
|
const done = await f.rooms.claimSeatHandover(
|
|
f.code,
|
|
claim(f.created.view.me),
|
|
clock(2000),
|
|
);
|
|
assert.equal(done.transferred, true);
|
|
} finally {
|
|
f.sqlite.close();
|
|
}
|
|
});
|