Prototype owner-only administrator account management
This commit is contained in:
1 parent
ac8742c829
commit
882d006b3c
24 files changed
+3438
-15
No files matched your search
@@ -0,0 +1,98 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { register } from 'node:module';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { clearAdminAccountRecord, completeAdminAccountRequest, newAdminAccountRequest, readAdminAccountRecord, saveAdminAccountRequest, validAdminAccountResult } from '../lib/admin-accounts-client';
|
||||
import type { AdminAccountRow } from '../lib/admin-accounts';
|
||||
|
||||
function storage() {
|
||||
const values = new Map<string, string>();
|
||||
return { values, getItem: (key: string) => values.get(key) ?? null, setItem: (key: string, value: string) => { values.set(key, value); }, removeItem: (key: string) => { values.delete(key); } };
|
||||
}
|
||||
|
||||
const account = (id: string, role: AdminAccountRow['role'] = 'viewer'): AdminAccountRow =>
|
||||
({ id, name: 'QA only', role, enabled: true, createdAt: 1700000000000, updatedAt: 1700000000000 });
|
||||
|
||||
void test('SSR exposes no account directory or private key before owner session verification', async () => {
|
||||
// Node's test loader does not handle the page's stylesheet imports; the browser build does.
|
||||
register('data:text/javascript,export async function load(url,context,nextLoad){if(url.endsWith(%22.css%22))return {format:%22module%22,source:%22export {}%22,shortCircuit:true};return nextLoad(url,context)}', import.meta.url);
|
||||
const { default: AccountsPage } = await import('../app/admin/accounts/page');
|
||||
const html = renderToStaticMarkup(createElement(AccountsPage));
|
||||
assert.match(html, /Checking owner access and account directory/);
|
||||
assert.doesNotMatch(html, /One-time account access key|Change role ·|Disable ·|dune-admin\./);
|
||||
});
|
||||
|
||||
void test('provisioned key is generated only in the browser, saved exactly before sending, and recoverable from its original tab', () => {
|
||||
const owner = crypto.randomUUID();
|
||||
const tab = storage();
|
||||
const input = newAdminAccountRequest({ action: 'provision', name: ' QA colleague ', role: 'operator', reason: ' QA access ' });
|
||||
assert.equal(input.action, 'provision');
|
||||
if (input.action !== 'provision') return;
|
||||
assert.equal(new RegExp(`^dune-admin\\.${input.id}\\.[0-9a-f]{64}$`).test(input.key), true);
|
||||
assert.equal(input.name, 'QA colleague'); assert.equal(input.reason, 'QA access');
|
||||
saveAdminAccountRequest(tab, owner, input);
|
||||
const pending = readAdminAccountRecord(tab, owner);
|
||||
assert.ok(pending?.kind === 'pending' && pending.input.action === 'provision' &&
|
||||
pending.input.operationId === input.operationId && pending.input.key === input.key);
|
||||
assert.equal(readAdminAccountRecord(tab, crypto.randomUUID()), null);
|
||||
assert.throws(() => saveAdminAccountRequest(tab, owner, newAdminAccountRequest({ action: 'provision', name: 'Other', role: 'viewer', reason: 'QA' })));
|
||||
assert.equal(JSON.stringify(readAdminAccountRecord(tab, owner)) === JSON.stringify(pending), true);
|
||||
saveAdminAccountRequest(tab, owner, input);
|
||||
const receipt = { operationId: input.operationId, replayed: true, account: { ...account(input.id, 'operator'), name: input.name } };
|
||||
assert.equal(validAdminAccountResult({ ...receipt, account: { ...receipt.account, role: 'viewer' } }, input), false);
|
||||
completeAdminAccountRequest(tab, owner, input, receipt);
|
||||
const completed = readAdminAccountRecord(tab, owner);
|
||||
assert.ok(completed?.kind === 'completed' && completed.input.key === input.key && completed.account.id === input.id);
|
||||
assert.throws(() => saveAdminAccountRequest(tab, owner, input));
|
||||
assert.equal(JSON.stringify(readAdminAccountRecord(tab, owner)) === JSON.stringify(completed), true);
|
||||
const savedKey = [...tab.values.keys()][0], savedRecord = tab.values.get(savedKey)!;
|
||||
tab.values.set(savedKey, JSON.stringify({ kind: 'completed', input, account: { ...receipt.account, role: 'viewer' } }));
|
||||
assert.throws(() => readAdminAccountRecord(tab, owner));
|
||||
tab.values.set(savedKey, savedRecord);
|
||||
clearAdminAccountRecord(tab, owner);
|
||||
assert.equal(readAdminAccountRecord(tab, owner), null);
|
||||
});
|
||||
|
||||
void test('role and disable requests retain exact target version and reason until a matching receipt is persisted', () => {
|
||||
const owner = crypto.randomUUID(), target = crypto.randomUUID();
|
||||
for (const input of [
|
||||
newAdminAccountRequest({ action: 'role', target, expectedUpdatedAt: 1700000000000, role: 'owner', reason: 'QA rotation' }),
|
||||
newAdminAccountRequest({ action: 'disable', target, expectedUpdatedAt: 1700000000000, reason: 'QA departure' }),
|
||||
]) {
|
||||
if (input.action === 'provision') throw new Error('This fixture must change an existing account.');
|
||||
const tab = storage();
|
||||
saveAdminAccountRequest(tab, owner, input);
|
||||
assert.deepEqual(readAdminAccountRecord(tab, owner), { kind: 'pending', input });
|
||||
assert.throws(() => saveAdminAccountRequest(tab, owner, { ...input, expectedUpdatedAt: 1700000000001 }));
|
||||
assert.deepEqual(readAdminAccountRecord(tab, owner), { kind: 'pending', input });
|
||||
assert.equal(validAdminAccountResult({ operationId: crypto.randomUUID(), replayed: true, account: account(target) }, input), false);
|
||||
assert.equal(validAdminAccountResult({ operationId: input.operationId, replayed: true, account: { ...account(target), key: 'private' } }, input), false);
|
||||
const next = { ...account(target, input.action === 'role' ? 'owner' : 'viewer'),
|
||||
enabled: input.action === 'role', updatedAt: 1700000000001 };
|
||||
assert.equal(validAdminAccountResult({ operationId: input.operationId, replayed: true,
|
||||
account: { ...next, enabled: !next.enabled } }, input), false);
|
||||
const receipt = { operationId: input.operationId, replayed: true, account: next };
|
||||
completeAdminAccountRequest(tab, owner, input, receipt);
|
||||
assert.equal(readAdminAccountRecord(tab, owner), null);
|
||||
assert.equal([...tab.values.values()].join('').includes(input.reason), false);
|
||||
}
|
||||
});
|
||||
|
||||
void test('unavailable, corrupt or changed storage fails closed and never replaces an uncertain credential', () => {
|
||||
const tab = storage(), owner = crypto.randomUUID();
|
||||
const input = newAdminAccountRequest({ action: 'provision', name: 'QA colleague', role: 'viewer', reason: 'QA' });
|
||||
assert.throws(() => saveAdminAccountRequest({ ...tab, setItem() {} }, owner, input));
|
||||
assert.equal(tab.values.size, 0);
|
||||
saveAdminAccountRequest(tab, owner, input);
|
||||
const key = [...tab.values.keys()][0];
|
||||
tab.values.set(key, '{broken');
|
||||
assert.throws(() => readAdminAccountRecord(tab, owner));
|
||||
assert.throws(() => saveAdminAccountRequest(tab, owner, input));
|
||||
assert.equal(tab.values.get(key), '{broken');
|
||||
tab.values.set(key, JSON.stringify({ kind: 'pending', input: { ...input, operationId: crypto.randomUUID() } }));
|
||||
const unchanged = readAdminAccountRecord(tab, owner);
|
||||
assert.throws(() => completeAdminAccountRequest(tab, owner, input, { operationId: input.operationId, replayed: false, account: account(input.action === 'provision' ? input.id : '') }));
|
||||
assert.equal(JSON.stringify(readAdminAccountRecord(tab, owner)) === JSON.stringify(unchanged), true);
|
||||
assert.throws(() => newAdminAccountRequest({ action: 'provision', name: 'QA', role: 'viewer', reason: 'dune-admin.secret' }));
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomBytes, randomUUID } from 'node:crypto';
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { adminStore, sha256 } from './admin-access-fixture';
|
||||
import { adminLogin, adminLogoutAll, requireAdmin, AdminError } from '../db/admin-access';
|
||||
import { applyAdminAccount, readAdminAccounts } from '../db/admin-accounts';
|
||||
import { readAdminAudit } from '../db/admin-audit';
|
||||
import { validAdminAccountInput, type AdminAccountInput } from '../lib/admin-accounts';
|
||||
|
||||
const now = Date.parse('2026-09-29T12:00:00Z');
|
||||
const denied = (status: number) => (error: unknown) => error instanceof AdminError && error.status === status;
|
||||
async function fixture() {
|
||||
const store = adminStore(), owner = store.provision('owner'), target = store.provision('viewer');
|
||||
const session = await adminLogin(store.database,owner.key,now);
|
||||
const identity = await requireAdmin(store.database,session.token,['owner'],now);
|
||||
return { ...store,owner,target,session,identity };
|
||||
}
|
||||
function newProvision(role: 'owner'|'operator'|'viewer' = 'viewer'): Extract<AdminAccountInput,{action:'provision'}> {
|
||||
const id = randomUUID();
|
||||
return { action:'provision',operationId:randomUUID(),id,key:`dune-admin.${id}.${randomBytes(32).toString('hex')}`,name:'New administrator',role,reason:'Staff rotation' };
|
||||
}
|
||||
|
||||
void test('account receipt migration adds metadata without changing existing games or credentials',() => {
|
||||
const sqlite=new DatabaseSync(':memory:');
|
||||
try {
|
||||
for(const file of readdirSync(new URL('../drizzle/',import.meta.url))
|
||||
.filter(file=>file.endsWith('.sql') && file<'0019')
|
||||
.sort((a,b)=>a.localeCompare(b)))
|
||||
sqlite.exec(readFileSync(new URL('../drizzle/'+file,import.meta.url),'utf8'));
|
||||
const id=randomUUID(),keyHash=sha256('private-old-key'),sessionHash=sha256('private-old-session');
|
||||
sqlite.prepare('INSERT INTO rooms(code,state,version,updated_at) VALUES(?,?,?,?)')
|
||||
.run('SAVE0001','{"sealed":"original"}',7,1000);
|
||||
sqlite.prepare('INSERT INTO seats(token_hash,revoked,room_code,player_id) VALUES(?,?,?,?)')
|
||||
.run('d'.repeat(64),0,'SAVE0001','player');
|
||||
sqlite.prepare('INSERT INTO admin_accounts(id,name,role,key_hash,created_at,updated_at) VALUES(?,?,?,?,?,?)')
|
||||
.run(id,'Original owner','owner',keyHash,1000,1000);
|
||||
sqlite.prepare('INSERT INTO admin_sessions(token_hash,id,admin_id,generation,created_at,expires_at) VALUES(?,?,?,?,?,?)')
|
||||
.run(sessionHash,randomUUID(),id,0,1000,100000);
|
||||
const saved=()=>JSON.stringify([
|
||||
sqlite.prepare('SELECT * FROM rooms').all(),sqlite.prepare('SELECT * FROM seats').all(),
|
||||
sqlite.prepare('SELECT * FROM admin_accounts').all(),sqlite.prepare('SELECT * FROM admin_sessions').all(),
|
||||
sqlite.prepare('SELECT id,actor_admin_id,target_admin_id,action,created_at,detail FROM admin_audit').all(),
|
||||
]);
|
||||
const before=saved();
|
||||
sqlite.exec(readFileSync(new URL('../drizzle/0019_admin_accounts.sql',import.meta.url),'utf8'));
|
||||
assert.equal(saved(),before);
|
||||
assert.equal(sqlite.prepare('SELECT reason FROM admin_audit WHERE target_admin_id=?').get(id)!.reason,null);
|
||||
assert.equal(sqlite.prepare('SELECT COUNT(*) n FROM admin_account_operations').get()!.n,0);
|
||||
assert.throws(()=>sqlite.prepare('UPDATE admin_accounts SET enabled=0 WHERE id=?').run(id));
|
||||
assert.equal(saved(),before);
|
||||
} finally {sqlite.close();}
|
||||
});
|
||||
|
||||
void test('owner directory pages 25 safe accounts and rejects stale/forged authority',async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
for(let i=0;i<27;i++)f.provision('viewer','Directory '+i);
|
||||
const first=await readAdminAccounts(f.database,f.identity,new URLSearchParams(),now);
|
||||
const second=await readAdminAccounts(f.database,f.identity,new URLSearchParams('page=2'),now);
|
||||
assert.equal(first.total,29);assert.equal(first.accounts.length,25);assert.equal(second.accounts.length,4);
|
||||
assert.equal(new Set([...first.accounts,...second.accounts].map(a=>a.id)).size,29);
|
||||
assert.doesNotMatch(JSON.stringify(first),/key_hash|session_hash|dune-admin\.|[0-9a-f]{64}/);
|
||||
for(const query of ['page=0','page=1&page=2','role=owner'])await assert.rejects(readAdminAccounts(f.database,f.identity,new URLSearchParams(query),now),denied(400));
|
||||
f.provision('owner');
|
||||
f.sqlite.prepare("UPDATE admin_accounts SET role='operator' WHERE id=?").run(f.owner.id);
|
||||
await assert.rejects(readAdminAccounts(f.database,f.identity,new URLSearchParams(),now),denied(401));
|
||||
} finally {f.sqlite.close();}
|
||||
});
|
||||
|
||||
void test('provision exact retry returns the committed snapshot without doubling audit',async () => {
|
||||
const f=await fixture();
|
||||
try {
|
||||
const input=newProvision('operator');
|
||||
assert.equal(validAdminAccountInput(input),true);
|
||||
const initial=await applyAdminAccount(f.database,f.identity,input,now);
|
||||
assert.equal(initial.replayed,false);assert.equal(initial.account.id,input.id);
|
||||
assert.deepEqual(await applyAdminAccount(f.database,f.identity,input,now),{...initial,replayed:true});
|
||||
assert.equal(f.sqlite.prepare('SELECT COUNT(*) n FROM admin_audit WHERE target_admin_id=?').get(input.id)!.n,1);
|
||||
} finally {f.sqlite.close();}
|
||||
});
|
||||
|
||||
void test('an unchanged role request cannot advance the account version or fabricate an audit change',async () => {
|
||||
const f=await fixture();
|
||||
try {
|
||||
const before=JSON.stringify([
|
||||
f.sqlite.prepare('SELECT * FROM admin_accounts WHERE id=?').get(f.target.id),
|
||||
f.sqlite.prepare('SELECT * FROM admin_audit ORDER BY rowid').all(),
|
||||
]);
|
||||
const input:AdminAccountInput={action:'role',operationId:randomUUID(),target:f.target.id,
|
||||
expectedUpdatedAt:1000,role:'viewer',reason:'No change'};
|
||||
await assert.rejects(applyAdminAccount(f.database,f.identity,input,now),denied(409));
|
||||
assert.equal(JSON.stringify([
|
||||
f.sqlite.prepare('SELECT * FROM admin_accounts WHERE id=?').get(f.target.id),
|
||||
f.sqlite.prepare('SELECT * FROM admin_audit ORDER BY rowid').all(),
|
||||
]),before);
|
||||
} finally {f.sqlite.close();}
|
||||
});
|
||||
|
||||
void test('provision, role and permanent disable retain one audited operation each and leave games/seats untouched',async () => {
|
||||
const f=await fixture();
|
||||
try {
|
||||
f.sqlite.prepare('INSERT INTO rooms(code,state,version,updated_at) VALUES(?,?,?,?)').run('SAVEDQAA','{"secret":"sealed"}',4,1000);
|
||||
f.sqlite.prepare('INSERT INTO seats(token_hash,revoked,room_code,player_id) VALUES(?,?,?,?)').run('f'.repeat(64),0,'SAVEDQAA','player');
|
||||
const before=JSON.stringify([f.sqlite.prepare('SELECT * FROM rooms').all(),f.sqlite.prepare('SELECT * FROM seats').all()]);
|
||||
const provision=newProvision('viewer');
|
||||
const created=await applyAdminAccount(f.database,f.identity,provision,now);
|
||||
assert.equal(created.account.role,'viewer');
|
||||
assert.deepEqual(await applyAdminAccount(f.database,f.identity,provision,now),{...created,replayed:true});
|
||||
const role: AdminAccountInput={action:'role',operationId:randomUUID(),target:provision.id,expectedUpdatedAt:created.account.updatedAt,role:'operator',reason:'Assign duty'};
|
||||
const promoted=await applyAdminAccount(f.database,f.identity,role,now);
|
||||
assert.equal(promoted.account.role,'operator');assert.ok(promoted.account.updatedAt>created.account.updatedAt);
|
||||
assert.deepEqual(await applyAdminAccount(f.database,f.identity,role,now),{...promoted,replayed:true});
|
||||
await assert.rejects(applyAdminAccount(f.database,f.identity,{...role,operationId:randomUUID()},now),denied(409));
|
||||
const login=await adminLogin(f.database,provision.key,now);
|
||||
const disable: AdminAccountInput={action:'disable',operationId:randomUUID(),target:provision.id,expectedUpdatedAt:promoted.account.updatedAt,reason:'Access no longer needed'};
|
||||
const disabled=await applyAdminAccount(f.database,f.identity,disable,now);
|
||||
assert.equal(disabled.account.enabled,false);
|
||||
assert.deepEqual(await applyAdminAccount(f.database,f.identity,disable,now),{...disabled,replayed:true});
|
||||
await assert.rejects(adminLogin(f.database,provision.key,now),denied(401));
|
||||
await assert.rejects(requireAdmin(f.database,login.token,undefined,now),denied(401));
|
||||
assert.ok(f.sqlite.prepare('SELECT revoked_at FROM admin_sessions WHERE admin_id=?').get(provision.id)!.revoked_at);
|
||||
const audits=f.sqlite.prepare('SELECT actor_admin_id,action,reason,detail FROM admin_audit WHERE target_admin_id=?').all(provision.id);
|
||||
assert.equal(audits.length,4); // Login receipt plus three account operations.
|
||||
assert.deepEqual(audits.filter(a=>a.action!=='login').map(a=>a.action).sort((a,b)=>String(a).localeCompare(String(b))),['provision','revoke','role']);
|
||||
assert.ok(audits.filter(a=>a.action!=='login').every(a=>a.actor_admin_id===f.owner.id && typeof a.reason==='string'));
|
||||
const history=await readAdminAudit(f.database,f.identity,new URLSearchParams('category=account'),now);
|
||||
const roleEvent=history.events.find(e=>e.targetAdminId===provision.id && e.action==='role');
|
||||
assert.deepEqual(roleEvent?.changes,[{field:'Role',before:'viewer',after:'operator'}]);
|
||||
assert.equal(roleEvent?.reason,'Assign duty');
|
||||
assert.equal(f.sqlite.prepare('SELECT key_hash FROM admin_accounts WHERE id=?').get(provision.id)!.key_hash,sha256(provision.key));
|
||||
const persisted=JSON.stringify([f.sqlite.prepare('SELECT * FROM admin_account_operations').all(),f.sqlite.prepare('SELECT * FROM admin_audit').all(),history]);
|
||||
assert.equal(persisted.includes(provision.key),false);
|
||||
assert.equal(persisted.includes(sha256(provision.key)),false);
|
||||
assert.equal(JSON.stringify([f.sqlite.prepare('SELECT * FROM rooms').all(),f.sqlite.prepare('SELECT * FROM seats').all()]),before);
|
||||
} finally {f.sqlite.close();}
|
||||
});
|
||||
|
||||
void test('conflicting replay, actor change and invalid payloads cannot write',async () => {
|
||||
const f=await fixture();
|
||||
try {
|
||||
const input=newProvision();await applyAdminAccount(f.database,f.identity,input,now);
|
||||
const before=Number(f.sqlite.prepare('SELECT COUNT(*) n FROM admin_audit').get()!.n);
|
||||
await assert.rejects(applyAdminAccount(f.database,f.identity,{...input,reason:'Different reason'},now),denied(409));
|
||||
await assert.rejects(applyAdminAccount(f.database,f.identity,{...input,reason:' '+input.reason},now),denied(409));
|
||||
const other=f.provision('owner');const session=await adminLogin(f.database,other.key,now);
|
||||
const identity=await requireAdmin(f.database,session.token,['owner'],now);
|
||||
await assert.rejects(applyAdminAccount(f.database,identity,input,now),denied(409));
|
||||
assert.equal(f.sqlite.prepare('SELECT COUNT(*) n FROM admin_audit').get()!.n,before+2); // Other owner provision and login only.
|
||||
for(const bad of [{...input,key:input.key+'\n'},{...input,name:'Bad\nname'},{...input,reason:'contains '+input.key},{...input,unknown:true},{...input,role:'superuser'},
|
||||
{...input,id:randomUUID()},{...input,operationId:'not-a-uuid'}])assert.equal(validAdminAccountInput(bad),false);
|
||||
} finally {f.sqlite.close();}
|
||||
});
|
||||
|
||||
void test('write-time revocation, concurrent target change, final-owner trigger and self mutation fail closed',async () => {
|
||||
const f=await fixture();
|
||||
try {
|
||||
assert.throws(()=>f.sqlite.prepare('UPDATE admin_accounts SET enabled=0 WHERE id=?').run(f.owner.id));
|
||||
assert.equal(f.sqlite.prepare('SELECT enabled FROM admin_accounts WHERE id=?').get(f.owner.id)!.enabled,1);
|
||||
const self: AdminAccountInput={action:'disable',operationId:randomUUID(),target:f.owner.id,expectedUpdatedAt:1000,reason:'Self'};
|
||||
await assert.rejects(applyAdminAccount(f.database,f.identity,self,now),denied(409));
|
||||
await assert.rejects(applyAdminAccount(f.database,f.identity,{
|
||||
action:'role',operationId:randomUUID(),target:f.owner.id,expectedUpdatedAt:1000,role:'viewer',reason:'Self demotion',
|
||||
},now),denied(409));
|
||||
const input=newProvision();
|
||||
f.hooks.beforeBatch=async()=>{f.hooks.beforeBatch=async()=>{delete f.hooks.beforeBatch;f.sqlite.prepare('UPDATE admin_sessions SET revoked_at=1 WHERE admin_id=?').run(f.owner.id);};};
|
||||
await assert.rejects(applyAdminAccount(f.database,f.identity,input,now),denied(401));
|
||||
assert.equal(f.sqlite.prepare('SELECT COUNT(*) n FROM admin_accounts WHERE id=?').get(input.id)!.n,0);
|
||||
f.sqlite.prepare('UPDATE admin_sessions SET revoked_at=NULL WHERE admin_id=?').run(f.owner.id);
|
||||
const target=f.provision('viewer');
|
||||
const change: AdminAccountInput={action:'role',operationId:randomUUID(),target:target.id,expectedUpdatedAt:1000,role:'operator',reason:'Race'};
|
||||
f.hooks.beforeBatch=async()=>{f.hooks.beforeBatch=async()=>{delete f.hooks.beforeBatch;f.sqlite.prepare('UPDATE admin_accounts SET updated_at=2000 WHERE id=?').run(target.id);};};
|
||||
await assert.rejects(applyAdminAccount(f.database,f.identity,change,now),denied(409));
|
||||
assert.equal(f.sqlite.prepare('SELECT role FROM admin_accounts WHERE id=?').get(target.id)!.role,'viewer');
|
||||
assert.equal(f.sqlite.prepare('SELECT COUNT(*) n FROM admin_account_operations WHERE operation_id=?').get(change.operationId)!.n,0);
|
||||
} finally {f.sqlite.close();}
|
||||
});
|
||||
|
||||
void test('logout-all cannot roll back an account version and admit a stale disable',async () => {
|
||||
const f=await fixture();
|
||||
try {
|
||||
const promote:AdminAccountInput={action:'role',operationId:randomUUID(),target:f.target.id,
|
||||
expectedUpdatedAt:1000,role:'operator',reason:'Grant duty'};
|
||||
const changed=await applyAdminAccount(f.database,f.identity,promote,1000);
|
||||
const login=await adminLogin(f.database,f.target.key,1000);
|
||||
const target=await requireAdmin(f.database,login.token,undefined,1000);
|
||||
await adminLogoutAll(f.database,target,1000);
|
||||
const updated=Number(f.sqlite.prepare('SELECT updated_at FROM admin_accounts WHERE id=?').get(f.target.id)!.updated_at);
|
||||
assert.ok(updated>changed.account.updatedAt);
|
||||
const stale:AdminAccountInput={action:'disable',operationId:randomUUID(),target:f.target.id,
|
||||
expectedUpdatedAt:1000,reason:'Stale version'};
|
||||
await assert.rejects(applyAdminAccount(f.database,f.identity,stale,1000),denied(409));
|
||||
assert.equal(f.sqlite.prepare('SELECT enabled FROM admin_accounts WHERE id=?').get(f.target.id)!.enabled,1);
|
||||
} finally {f.sqlite.close();}
|
||||
});
|
||||
|
||||
void test('workerd D1 applies provision, exact retry and revocation in transactional batches',async () => {
|
||||
const { Miniflare }=await import('miniflare');
|
||||
const f=await fixture();
|
||||
const worker=new Miniflare({modules:true,script:'export default {fetch(){return new Response("ok")}}',compatibilityDate:'2026-05-15',d1Databases:['DB']});
|
||||
try {
|
||||
const db=await worker.getD1Database('DB');
|
||||
const schema=f.sqlite.prepare("SELECT type,name,sql FROM sqlite_master WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%' ORDER BY CASE type WHEN 'table' THEN 0 WHEN 'index' THEN 1 ELSE 2 END").all() as {type:string;name:string;sql:string}[];
|
||||
for(const entry of schema.filter(e=>e.type!=='trigger'))await db.prepare(entry.sql).run();
|
||||
for(const entry of schema.filter(e=>e.type==='table')) {
|
||||
for(const row of f.sqlite.prepare(`SELECT * FROM "${entry.name}"`).all()) {
|
||||
const columns=Object.keys(row);
|
||||
await db.prepare(`INSERT INTO "${entry.name}" (${columns.map(column=>'"'+column+'"').join(',')}) VALUES (${columns.map(()=>'?').join(',')})`).bind(...Object.values(row)).run();
|
||||
}
|
||||
}
|
||||
for(const entry of schema.filter(e=>e.type==='trigger'))await db.prepare(entry.sql).run();
|
||||
const database=db as unknown as D1Database, input=newProvision('owner');
|
||||
const created=await applyAdminAccount(database,f.identity,input,now);
|
||||
const simultaneous=newProvision('viewer');
|
||||
const races=await Promise.all([
|
||||
applyAdminAccount(database,f.identity,simultaneous,now),
|
||||
applyAdminAccount(database,f.identity,simultaneous,now),
|
||||
]);
|
||||
assert.deepEqual(races.map(result=>result.replayed).sort((a,b)=>Number(a)-Number(b)),[false,true],JSON.stringify(races));
|
||||
assert.equal((await db.prepare('SELECT COUNT(*) n FROM admin_audit WHERE target_admin_id=?').bind(simultaneous.id).first<{n:number}>())?.n,1);
|
||||
assert.deepEqual(await applyAdminAccount(database,f.identity,input,now),{...created,replayed:true});
|
||||
const listed=await readAdminAccounts(database,f.identity,new URLSearchParams(),now);
|
||||
assert.equal(listed.total,4);
|
||||
const disable:AdminAccountInput={action:'disable',operationId:randomUUID(),target:input.id,expectedUpdatedAt:created.account.updatedAt,reason:'Remove access'};
|
||||
const result=await applyAdminAccount(database,f.identity,disable,now);
|
||||
assert.equal(result.account.enabled,false);
|
||||
assert.equal((await db.prepare('SELECT COUNT(*) n FROM admin_audit WHERE target_admin_id=?').bind(input.id).first<{n:number}>())?.n,2);
|
||||
await assert.rejects(adminLogin(database,input.key,now),denied(401));
|
||||
} finally {await worker.dispose();f.sqlite.close();}
|
||||
});
|
||||
@@ -13,12 +13,20 @@ void test('admin HTTP denies anonymous, forged admin, room-host and spoofed iden
|
||||
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/audit', '/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`]) {
|
||||
for (const path of ['/api/admin/session', '/api/admin/accounts', '/api/admin/audit', '/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 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' }),
|
||||
@@ -96,3 +104,15 @@ void test('admin HTTP rejects login CSRF, oversized bodies and invalid credentia
|
||||
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();
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user