Files
Dune/tests/seat-handover-http.test.ts
Quintine 7dfd6b3048 Prototype voluntary seat handover with durable private retries
Let a current owner issue, cancel or replace a private 24-hour one-time offer.
Recipients accept the same faction and saved game while old browser sessions,
recovery keys and entry receipts lose authority. Preserve exact claim retries
across refresh, restart and later offers, with private tab-scoped continuation.

Fence every claim mutation to its winning transaction. Add durable receipts
and additive backfill for already-issued offers without changing game JSON or
existing seat authority. Block competing navigation during unresolved owner
operations; remove invalidated owner kits on confirmed recovery.

Record the pending CHOAM Nexus after-victory secrecy/window decision without
inventing the unresolved gameplay or opening mode/publication gates.

Validation: types/lint and 4,364 offline tests passed; production build passed;
43 HTTP tests passed. Added 19 offline and 3 HTTP regressions. Independent
privacy/persistence review passed, including deterministic CAS interleavings,
rollback, legacy migration and exact receipt restoration. Browser room 2EMYG4CY
retained private setup choices after handover/refresh and continued into Storm;
follow-up recovery removed its invalid offer with cards/forces/spice unchanged.
All 218 opening rooms preserved (291 after QA and both broad runs). The opening
456 seat, 49 recovery-key, 56 recovery-receipt and 106 entry-receipt rows were
also verified unchanged. Server reused; no restart or recurring automation.

Final private report: ~/.local/share/dune/checkpoints/20260913-seat-handover/
verification-final/report.json. Source fingerprint:
8949cdf58eb8bc8bd33f2657c6b21352b3ee5e17e2a556c684a89073015632f4.
Check/build/HTTP duration: 140565/6689/24835 ms. The earlier broad run passed;
a final recovery-UI review fix required the second source-bound run.
Goal aggregate counter 64,301,976 -> 65,031,751 before Git (729,775) for this
bounded capability and its concurrency/recovery review; full goal remains active.
2026-09-13 15:28:14 +10:00

201 lines
7.0 KiB
TypeScript

// @dune-suite integration
import test from 'node:test';
import assert from 'node:assert/strict';
import type { GameView } from '../game/engine';
import { createHandoverKit, createHandoverClaim } from '../lib/seat-handover';
const base = process.env.DUNE_TEST_URL ?? 'http://localhost:3000';
const secret = () =>
crypto.randomUUID().replaceAll('-', '') +
crypto.randomUUID().replaceAll('-', '');
async function request(
path: string,
body?: unknown,
cookie?: string,
origin?: string,
) {
const response = await fetch(base + path, {
method: body === undefined ? 'GET' : 'POST',
headers: {
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
...(cookie ? { cookie } : {}),
...(origin ? { origin } : {}),
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
const raw = await response.text();
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
parsed = { error: 'Non-JSON response' };
}
const data = parsed as GameView & {
view: GameView;
error?: string;
code: string;
replayed?: boolean;
transferred?: boolean;
expiresAt?: number;
revoked?: boolean;
};
return {
status: response.status,
data,
cookie: response.headers.get('set-cookie')?.split(';')[0],
cookieHeader: response.headers.get('set-cookie'),
cache: response.headers.get('cache-control'),
};
}
async function create() {
const result = await request('/api/rooms', {
name: 'Handover HTTP QA',
faction: 'atreides',
expansions: [],
advanced: false,
});
assert.equal(result.status, 201);
assert.ok(result.cookie);
return { ...result, path: `/api/rooms/${result.data.code}` };
}
const sameGame = (a: GameView, b: GameView) =>
assert.deepEqual({ ...a, version: 0 }, { ...b, version: 0 });
void test('HTTP owner handover preserves seat, invalidates old browser/recovery and confirms exact claim retry', async () => {
const a = await create(),
recoverySecret = secret();
const saved = await request(
a.path + '/control',
{ type: 'setRecoveryKey', version: a.data.version, recoverySecret },
a.cookie,
);
assert.equal(saved.status, 200);
const kit = createHandoverKit(a.data.code, a.data.me);
const body = {
type: 'createSeatHandover',
version: saved.data.view.version,
offerId: kit.offerId,
handoverSecret: kit.handoverSecret,
};
const offered = await request(a.path + '/control', body, a.cookie);
assert.equal(offered.status, 200);
assert.ok(offered.data.expiresAt! > Date.now());
sameGame(offered.data.view, saved.data.view);
const retriedOffer = await request(a.path + '/control', body, a.cookie);
assert.equal(retriedOffer.status, 200);
assert.equal(retriedOffer.data.replayed, true);
assert.equal(retriedOffer.data.view.version, offered.data.view.version);
const attempt = createHandoverClaim(kit).attempt;
const claimed = await request(a.path + '/control', attempt);
assert.equal(claimed.status, 200);
assert.equal(claimed.data.transferred, true);
assert.ok(claimed.cookieHeader?.includes('HttpOnly'));
assert.ok(claimed.cookieHeader?.includes('SameSite=Strict'));
assert.ok(claimed.cookieHeader?.includes(`Path=${a.path}`));
assert.equal(claimed.cache, 'no-store');
sameGame(claimed.data.view, offered.data.view);
assert.equal((await request(a.path, undefined, a.cookie)).status, 409);
assert.equal(
(
await request(a.path + '/control', {
type: 'recoverSeat',
playerId: a.data.me,
recoverySecret,
operationId: crypto.randomUUID(),
newSessionToken: secret(),
})
).status,
409,
);
const replay = await request(a.path + '/control', attempt);
assert.equal(replay.status, 200);
assert.equal(replay.cookie, claimed.cookie);
assert.equal(replay.data.replayed, true);
assert.equal(replay.data.view.version, claimed.data.view.version);
const read = await request(a.path, undefined, replay.cookie);
assert.equal(read.status, 200);
sameGame(read.data, claimed.data.view);
assert.equal(JSON.stringify(read.data).includes(kit.handoverSecret), false);
assert.equal(JSON.stringify(read.data).includes(kit.offerId), false);
assert.equal(
(await request(a.path + '/control', createHandoverClaim(kit).attempt))
.status,
409,
);
});
void test('HTTP handover rejects another active seat cookie, wrong secrets and cross-origin writes without changing ownership', async () => {
const a = await create();
const other = await request(a.path, {
type: 'join',
name: 'Other handover seat',
faction: 'harkonnen',
});
assert.equal(other.status, 200);
const kit = createHandoverKit(a.data.code, a.data.me);
const offer = {
type: 'createSeatHandover',
version: other.data.version,
offerId: kit.offerId,
handoverSecret: kit.handoverSecret,
};
assert.equal((await request(a.path + '/control', offer)).status, 409);
assert.equal(
(await request(a.path + '/control', offer, a.cookie, 'https://other.test'))
.status,
403,
);
const made = await request(a.path + '/control', offer, a.cookie);
assert.equal(made.status, 200);
const attempt = createHandoverClaim(kit).attempt;
const wrongSeat = await request(a.path + '/control', attempt, other.cookie);
assert.equal(wrongSeat.status, 409);
assert.equal(wrongSeat.data.code, 'ALREADY_SEATED');
const bad = await request(a.path + '/control', {
...attempt,
handoverSecret: secret(),
});
assert.equal(bad.status, 409);
assert.equal(bad.data.view, undefined);
assert.equal((await request(a.path, undefined, a.cookie)).status, 200);
const cancelled = await request(
a.path + '/control',
{
type: 'revokeSeatHandover',
version: made.data.view.version,
offerId: kit.offerId,
},
a.cookie,
);
assert.equal(cancelled.status, 200);
assert.equal(cancelled.data.revoked, true);
assert.equal((await request(a.path + '/control', attempt)).status, 409);
});
void test('HTTP simultaneous identical handover retries establish one credential and one ownership change', async () => {
const a = await create(),
kit = createHandoverKit(a.data.code, a.data.me);
const offered = await request(
a.path + '/control',
{
type: 'createSeatHandover',
version: a.data.version,
offerId: kit.offerId,
handoverSecret: kit.handoverSecret,
},
a.cookie,
);
assert.equal(offered.status, 200);
const attempt = createHandoverClaim(kit).attempt;
const results = await Promise.all([
request(a.path + '/control', attempt),
request(a.path + '/control', attempt),
]);
assert.ok(results.some((result) => result.status === 200));
const confirmed = await request(a.path + '/control', attempt);
assert.equal(confirmed.status, 200);
assert.equal(confirmed.data.view.version, offered.data.view.version + 1);
for (const result of results.filter((value) => value.status === 200))
assert.equal(result.cookie, confirmed.cookie);
assert.equal(
(await request(a.path, undefined, confirmed.cookie)).status,
200,
);
});