Rejected administrator authentication and authorization attempts are now recorded durably in a new additive admin_attempts table with a fixed reason (invalid_key_format, unknown_or_disabled_key, missing_session, unknown_or_expired_session, role_denied), the attempt kind, the attempted role and an already-known account id. No key, key hash, session token, session hash, request body, origin, address or user agent is stored, and the unknown/disabled distinction is deliberately collapsed so the lookup cannot become an account-enumeration oracle. GET /api/admin/operations?attempts=1 returns seven-day per-reason counters plus the twenty most recent attempts, newest first, to a live owner only, with no-store and the same query guard as the other reads. The operations page adds "List failed attempts" with the counters and the bounded sample, truncating each account id to eight characters. Recording is best-effort inside the rejection path, so a rejected request keeps its own answer even when the table is unavailable, and the history never locks out, disables or revokes anything by itself. Evidence: tests/admin-attempts.test.ts asserts every recorded reason, the kind/role/account fields, that successful sign-ins and privileged reads record nothing, that no credential text appears anywhere in the page, the reporting window, the twenty-row bound and the owner-only recheck after demotion, and that a dropped attempts table leaves the original rejection answer unchanged. The anonymous HTTP boundary denies ?attempts=1, and the panel was verified in the real browser against a genuine recorded rejection. The shared AdminError moved to db/admin-error.ts so the recorder and the access module no longer form a value-import cycle. Offline suite 7,801/7,801 with typecheck and lint clean. No game, seat, saved state or deployment changes.
129 lines
8.5 KiB
TypeScript
129 lines
8.5 KiB
TypeScript
// @dune-suite integration
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
const base = process.env.DUNE_TEST_URL ?? 'http://localhost:3000';
|
|
|
|
void test('admin HTTP denies anonymous, forged admin, room-host and spoofed identity-header access', async () => {
|
|
const created = await fetch(base + '/api/rooms', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json', origin: base },
|
|
body: JSON.stringify({ name: 'Admin boundary QA', faction: 'atreides', advanced: false, expansions: [] }),
|
|
});
|
|
assert.equal(created.status, 201);
|
|
const cookie = created.headers.get('set-cookie')?.split(';')[0];
|
|
assert.ok(cookie);
|
|
const room = (await created.json() as { code: string }).code;
|
|
for (const headers of [{}, { cookie }, { cookie: 'dune_admin_session=' + 'a'.repeat(64) }, { 'oai-authenticated-user-id': 'owner', 'oai-authenticated-user-email': 'owner@example.test' }] as Record<string, string>[]) {
|
|
for (const path of ['/api/admin/session', '/api/admin/accounts', '/api/admin/audit', '/api/admin/operations?integrity=1', '/api/admin/operations?stalled=1', '/api/admin/operations?attempts=1', '/api/admin/rooms', `/api/admin/rooms/${room}/control`, `/api/admin/rooms/${room}/lobby`, `/api/admin/rooms/${room}/removal`, `/api/admin/rooms/${room}/closure`, `/api/admin/rooms/${room}/archive`, `/api/admin/rooms/${room}/seat-ai`, `/api/admin/rooms/${room}/discussion`]) {
|
|
const result = await fetch(base + path, { headers, signal: AbortSignal.timeout(15000) });
|
|
assert.equal(result.status, 401, path);
|
|
assert.equal(result.headers.get('cache-control'), 'no-store');
|
|
assert.deepEqual(Object.keys(await result.json()), ['error']);
|
|
}
|
|
const account = await fetch(base + '/api/admin/accounts', {
|
|
method: 'POST', headers: { ...headers, origin: base, 'Content-Type': 'application/json', 'X-Dune-Admin-Id': 'forged-owner' },
|
|
body: JSON.stringify({ action: 'provision', operationId: crypto.randomUUID(), id: crypto.randomUUID(), key: 'forged', name: 'Forbidden', role: 'owner', reason: 'Denied boundary request' }),
|
|
signal: AbortSignal.timeout(15000),
|
|
});
|
|
assert.equal(account.status, 401);
|
|
assert.equal(account.headers.get('set-cookie'), null);
|
|
assert.deepEqual(Object.keys(await account.json()), ['error']);
|
|
const target=crypto.randomUUID();
|
|
const rotate=await fetch(base + '/api/admin/accounts', {
|
|
method: 'POST',headers:{ ...headers,origin:base,'Content-Type':'application/json' },
|
|
body:JSON.stringify({ action:'rotate',operationId:crypto.randomUUID(),target,expectedUpdatedAt:1000,
|
|
key:`dune-admin.${target}.${'a'.repeat(64)}`,reason:'Denied rotation request' }),
|
|
signal:AbortSignal.timeout(15000),
|
|
});
|
|
assert.equal(rotate.status,401);
|
|
assert.equal(rotate.headers.get('set-cookie'),null);
|
|
assert.deepEqual(Object.keys(await rotate.json()),['error']);
|
|
const write = await fetch(`${base}/api/admin/rooms/${room}/control`, {
|
|
method: 'POST', headers: { ...headers, origin: base, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ operationId: crypto.randomUUID(), expectedRevision: 0, paused: true, joinLocked: true, reason: 'Denied QA request' }),
|
|
signal: AbortSignal.timeout(15000),
|
|
});
|
|
assert.equal(write.status, 401);
|
|
assert.deepEqual(Object.keys(await write.json()), ['error']);
|
|
const lobby = await fetch(`${base}/api/admin/rooms/${room}/lobby`, {
|
|
method: 'POST', headers: { ...headers, origin: base, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ operationId: crypto.randomUUID(), expectedVersion: 0, action: { type: 'rules', advanced: true }, reason: 'Denied lobby QA' }),
|
|
signal: AbortSignal.timeout(15000),
|
|
});
|
|
assert.equal(lobby.status, 401);
|
|
assert.equal(lobby.headers.get('set-cookie'), null);
|
|
assert.deepEqual(Object.keys(await lobby.json()), ['error']);
|
|
const removal = await fetch(`${base}/api/admin/rooms/${room}/removal`, {
|
|
method: 'POST', headers: { ...headers, origin: base, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ operationId: crypto.randomUUID(), expectedVersion: 0, expectedRevision: 0, removed: true, reason: 'Denied removal QA' }),
|
|
signal: AbortSignal.timeout(15000),
|
|
});
|
|
assert.equal(removal.status, 401);
|
|
assert.equal(removal.headers.get('set-cookie'), null);
|
|
assert.deepEqual(Object.keys(await removal.json()), ['error']);
|
|
const closure = await fetch(`${base}/api/admin/rooms/${room}/closure`, {
|
|
method: 'POST', headers: { ...headers, origin: base, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ operationId: crypto.randomUUID(), expectedVersion: 0, expectedRevision: 0, closed: true, reason: 'Denied closure QA' }),
|
|
signal: AbortSignal.timeout(15000),
|
|
});
|
|
assert.equal(closure.status, 401);
|
|
assert.equal(closure.headers.get('set-cookie'), null);
|
|
assert.deepEqual(Object.keys(await closure.json()), ['error']);
|
|
const archive = await fetch(`${base}/api/admin/rooms/${room}/archive`, {
|
|
method: 'POST', headers: { ...headers, origin: base, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ operationId: crypto.randomUUID(), expectedVersion: 0, expectedRevision: 0, archived: true, reason: 'Denied archive QA' }),
|
|
});
|
|
assert.equal(archive.status, 401);
|
|
assert.equal(archive.headers.get('set-cookie'), null);
|
|
assert.deepEqual(Object.keys(await archive.json()), ['error']);
|
|
const participant = await fetch(`${base}/api/admin/rooms/${room}/seat-ai`, {
|
|
method: 'POST', headers: { ...headers, origin: base, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ operationId: crypto.randomUUID(), expectedVersion: 0, expectedControlRevision: 0, target: 'owner', difficulty: 'Easy', reason: 'Denied participant QA' }),
|
|
});
|
|
assert.equal(participant.status, 401);
|
|
assert.equal(participant.headers.get('set-cookie'), null);
|
|
assert.deepEqual(Object.keys(await participant.json()), ['error']);
|
|
const discussion = await fetch(`${base}/api/admin/rooms/${room}/discussion`, {
|
|
method: 'POST', headers: { ...headers, origin: base, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ operationId: crypto.randomUUID(), expectedVersion: 0, expectedRevision: 0, target: 'owner', muted: true, reason: 'Denied moderation QA' }),
|
|
});
|
|
assert.equal(discussion.status, 401);
|
|
assert.equal(discussion.headers.get('set-cookie'), null);
|
|
assert.deepEqual(Object.keys(await discussion.json()), ['error']);
|
|
const create = await fetch(base + '/api/admin/rooms', {
|
|
method: 'POST', headers: { ...headers, origin: base, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ operationId: crypto.randomUUID(), sessionToken: 'b'.repeat(64), name: 'Denied creation QA', faction: 'atreides', advanced: false, techTokens: false, strongholdCards: false, bots: [], reason: 'Must not create a room' }),
|
|
signal: AbortSignal.timeout(15000),
|
|
});
|
|
assert.equal(create.status, 401);
|
|
assert.equal(create.headers.get('set-cookie'), null);
|
|
assert.deepEqual(Object.keys(await create.json()), ['error']);
|
|
}
|
|
});
|
|
void test('admin HTTP rejects login CSRF, oversized bodies and invalid credentials without setting sessions', async () => {
|
|
for (const [origin, body, expected] of [
|
|
['', '{}', 403], ['https://foreign.invalid', '{}', 403],
|
|
[base, ' '.repeat(2049), 413], [base, JSON.stringify({ action: 'login', key: 'invalid' }), 401],
|
|
] as const) {
|
|
const result = await fetch(base + '/api/admin/session', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json', ...(origin ? { origin } : {}) }, body,
|
|
signal: AbortSignal.timeout(15000),
|
|
});
|
|
assert.equal(result.status, expected);
|
|
assert.equal(result.headers.get('set-cookie'), null);
|
|
if (expected !== 403) assert.equal(result.headers.get('cache-control'), 'no-store');
|
|
await result.text();
|
|
}
|
|
});
|
|
void test('account writes reject foreign origins before any credential or account lookup', async () => {
|
|
for (const origin of ['', 'https://foreign.invalid']) {
|
|
const result = await fetch(base + '/api/admin/accounts', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json', ...(origin ? { origin } : {}), 'X-Dune-Admin-Id': 'forged-owner' },
|
|
body: JSON.stringify({ action:'disable',operationId:crypto.randomUUID(),target:crypto.randomUUID(),expectedUpdatedAt:1,reason:'Forbidden' }),
|
|
signal: AbortSignal.timeout(15000),
|
|
});
|
|
assert.equal(result.status,403);
|
|
assert.equal(result.headers.get('set-cookie'),null);
|
|
await result.text();
|
|
}
|
|
});
|