Add owner-only operational counts dashboard
This commit is contained in:
1 parent
53a1ba77a1
commit
c6784468a1
10 files changed
+234
-1
No files matched your search
@@ -0,0 +1,16 @@
|
|||||||
|
.admin-operations-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-operations-grid > div {
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: #171c16;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-operations-grid dt { color: #b7bbaf; font-size: 0.9rem; }
|
||||||
|
.admin-operations-grid dd { margin: 0.35rem 0 0; font-size: 1.2rem; overflow-wrap: anywhere; }
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { ClientRequestError, requestJson } from '@/lib/client-request';
|
||||||
|
import type { AdminOperations } from '@/db/admin-operations';
|
||||||
|
import '../admin.css';
|
||||||
|
import './operations.css';
|
||||||
|
|
||||||
|
type Account = { id: string; role: 'owner' | 'operator' | 'viewer' };
|
||||||
|
type Snapshot = AdminOperations & { revision: string };
|
||||||
|
const message = (error: unknown) => error instanceof Error ? error.message : 'Operational status is unavailable.';
|
||||||
|
|
||||||
|
export default function OperationsPage() {
|
||||||
|
const [snapshot, setSnapshot] = useState<Snapshot | null>(null);
|
||||||
|
const [notice, setNotice] = useState('');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [refresh, setRefresh] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
let canceled = false;
|
||||||
|
requestJson<{ admin: Account }>('/api/admin/session', { cache: 'no-store' })
|
||||||
|
.then(async ({ admin }) => {
|
||||||
|
if (admin.role !== 'owner') throw new Error('Operations are available only to an owner.');
|
||||||
|
const result = await requestJson<Snapshot>('/api/admin/operations', {
|
||||||
|
cache: 'no-store', headers: { 'X-Dune-Admin-Id': admin.id },
|
||||||
|
});
|
||||||
|
if (!canceled) { setSnapshot(result); setNotice(''); }
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
if (canceled) return;
|
||||||
|
setSnapshot(null);
|
||||||
|
setNotice(error instanceof ClientRequestError && [401, 403].includes(error.status ?? 0)
|
||||||
|
? 'Owner access changed or expired. Sign in to administration again.' : message(error));
|
||||||
|
})
|
||||||
|
.finally(() => { if (!canceled) setLoading(false); });
|
||||||
|
return () => { canceled = true; };
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
return <main className="admin-shell">
|
||||||
|
<header className="admin-header"><div><a className="admin-return" href="/admin">Administration</a><h1>Operations</h1></div>
|
||||||
|
<Button variant="outline" disabled={loading} onClick={() => { setSnapshot(null); setLoading(true); setRefresh(value => value + 1); }}>Refresh status</Button></header>
|
||||||
|
<p>Owner-only, read-only counts from the current database and the revision baked into this server build. Refresh to take a new sample; no monitoring or automatic repair runs here.</p>
|
||||||
|
{notice && <p role="alert" className="admin-notice">{notice}</p>}
|
||||||
|
{loading ? <output>Checking owner access and database…</output> : snapshot ? <section aria-labelledby="operations-sample">
|
||||||
|
<h2 id="operations-sample">Sample at {new Date(snapshot.observedAt).toLocaleString()}</h2>
|
||||||
|
<dl className="admin-operations-grid">
|
||||||
|
<div><dt>Server build revision</dt><dd>{snapshot.revision}</dd></div>
|
||||||
|
<div><dt>Saved rooms (including removed/archived)</dt><dd>{snapshot.rooms}</dd></div>
|
||||||
|
<div><dt>Removed rooms</dt><dd>{snapshot.removedRooms}</dd></div>
|
||||||
|
<div><dt>Archived rooms</dt><dd>{snapshot.archivedRooms}</dd></div>
|
||||||
|
<div><dt>Paused rooms</dt><dd>{snapshot.pausedRooms}</dd></div>
|
||||||
|
<div><dt>Closed rooms</dt><dd>{snapshot.closedRooms}</dd></div>
|
||||||
|
<div><dt>Active seat credentials</dt><dd>{snapshot.activeSeats}</dd></div>
|
||||||
|
<div><dt>Invalid JSON room states</dt><dd>{snapshot.unreadableRooms}</dd></div>
|
||||||
|
<div><dt>Latest room write</dt><dd>{snapshot.lastRoomChange === null ? 'No rooms' : new Date(snapshot.lastRoomChange).toLocaleString()}</dd></div>
|
||||||
|
<div><dt>Room backup snapshots</dt><dd>{snapshot.backupSnapshots}</dd></div>
|
||||||
|
<div><dt>Backup snapshot payload bytes</dt><dd>{snapshot.backupBytes.toLocaleString()}</dd></div>
|
||||||
|
<div><dt>Recorded private exports</dt><dd>{snapshot.backupDownloads}</dd></div>
|
||||||
|
</dl>
|
||||||
|
<p>These counters do not check SQLite integrity, disk free space, backup recoverability, stalled decisions or server health outside this request. The invalid JSON count detects syntax only, not rule-level save validity; this page cannot repair a room.</p>
|
||||||
|
</section> : <p><a className="admin-room-link" href="/admin">Sign in to administration</a></p>}
|
||||||
|
</main>;
|
||||||
|
}
|
||||||
@@ -125,6 +125,7 @@ export default function Administration() {
|
|||||||
<div className="admin-section-heading"><div><h2 id="admin-rooms-title">Rooms</h2><p className="admin-secondary">Public room details. Cards, sealed choices and private discussion stay hidden.</p></div>
|
<div className="admin-section-heading"><div><h2 id="admin-rooms-title">Rooms</h2><p className="admin-secondary">Public room details. Cards, sealed choices and private discussion stay hidden.</p></div>
|
||||||
<div className="admin-room-actions">{account.role !== 'viewer' && <Button disabled={controlsBusy} onClick={() => { setSelectedDiscussion(null); setSelectedSeatAi(null); setSelectedRoom(null); setSelectedLobby(null); setSelectedRemoval(null); setSelectedClosure(null); setSelectedArchive(null); setCreatingRoom(true); }}>Create a room</Button>}
|
<div className="admin-room-actions">{account.role !== 'viewer' && <Button disabled={controlsBusy} onClick={() => { setSelectedDiscussion(null); setSelectedSeatAi(null); setSelectedRoom(null); setSelectedLobby(null); setSelectedRemoval(null); setSelectedClosure(null); setSelectedArchive(null); setCreatingRoom(true); }}>Create a room</Button>}
|
||||||
{account.role === 'owner' && <a className="admin-room-link" href="/admin/backups">Room backups</a>}
|
{account.role === 'owner' && <a className="admin-room-link" href="/admin/backups">Room backups</a>}
|
||||||
|
{account.role === 'owner' && <a className="admin-room-link" href="/admin/operations">Operations</a>}
|
||||||
<Button variant="outline" disabled={controlsBusy} onClick={() => void load(applied, directory?.page ?? 1)}><RefreshCw aria-hidden="true" /> Refresh</Button></div></div>
|
<Button variant="outline" disabled={controlsBusy} onClick={() => void load(applied, directory?.page ?? 1)}><RefreshCw aria-hidden="true" /> Refresh</Button></div></div>
|
||||||
{creatingRoom && account.role !== 'viewer' && <AdminRoomCreation key={account.id} accountId={account.id}
|
{creatingRoom && account.role !== 'viewer' && <AdminRoomCreation key={account.id} accountId={account.id}
|
||||||
onClose={() => setCreatingRoom(false)} onDenied={accessChanged} onBusyChange={setOperationBusy}
|
onClose={() => setCreatingRoom(false)} onDenied={accessChanged} onBusyChange={setOperationBusy}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { env } from 'cloudflare:workers';
|
||||||
|
import { AdminError, requireAdmin } from '@/db/admin-access';
|
||||||
|
import { readAdminOperations } from '@/db/admin-operations';
|
||||||
|
import { adminFailure, adminResponse, adminToken } from '@/lib/admin-http';
|
||||||
|
import { buildRevision } from '@/lib/build-revision';
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const identity = await requireAdmin(env.DB, adminToken(request), ['owner']);
|
||||||
|
const expected = request.headers.get('X-Dune-Admin-Id');
|
||||||
|
if (expected && expected !== identity.id)
|
||||||
|
throw new AdminError('The signed-in administrator changed. Reload operations.', 401);
|
||||||
|
const counts = await readAdminOperations(env.DB, identity);
|
||||||
|
return adminResponse({ revision: buildRevision, ...counts });
|
||||||
|
} catch (error) { return adminFailure(error); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { AdminError, type AdminIdentity } from './admin-access';
|
||||||
|
|
||||||
|
export type AdminOperations = {
|
||||||
|
observedAt: number;
|
||||||
|
rooms: number;
|
||||||
|
removedRooms: number;
|
||||||
|
archivedRooms: number;
|
||||||
|
pausedRooms: number;
|
||||||
|
closedRooms: number;
|
||||||
|
activeSeats: number;
|
||||||
|
unreadableRooms: number;
|
||||||
|
lastRoomChange: number | null;
|
||||||
|
backupSnapshots: number;
|
||||||
|
backupBytes: number;
|
||||||
|
backupDownloads: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Counts = Omit<AdminOperations, 'observedAt'>;
|
||||||
|
|
||||||
|
/** Aggregate only operational counters, never serialized game or private backup rows. */
|
||||||
|
export async function readAdminOperations(database: D1Database, identity: AdminIdentity, now = Date.now()): Promise<AdminOperations> {
|
||||||
|
const row = await database.prepare(`SELECT
|
||||||
|
(SELECT COUNT(*) FROM rooms) rooms,
|
||||||
|
(SELECT COUNT(*) FROM rooms r JOIN room_removals m ON m.room_code=r.code WHERE m.removed=1) removedRooms,
|
||||||
|
(SELECT COUNT(*) FROM rooms r JOIN room_archives a ON a.room_code=r.code WHERE a.archived=1) archivedRooms,
|
||||||
|
(SELECT COUNT(*) FROM rooms r JOIN room_controls c ON c.room_code=r.code WHERE c.paused=1) pausedRooms,
|
||||||
|
(SELECT COUNT(*) FROM rooms r JOIN room_closures c ON c.room_code=r.code WHERE c.closed=1) closedRooms,
|
||||||
|
(SELECT COUNT(*) FROM seats WHERE revoked=0) activeSeats,
|
||||||
|
(SELECT COUNT(*) FROM rooms WHERE json_valid(state)=0) unreadableRooms,
|
||||||
|
(SELECT MAX(updated_at) FROM rooms) lastRoomChange,
|
||||||
|
(SELECT COUNT(*) FROM admin_room_backups) backupSnapshots,
|
||||||
|
(SELECT COALESCE(SUM(size_bytes),0) FROM admin_room_backups) backupBytes,
|
||||||
|
(SELECT COUNT(*) FROM admin_room_backup_downloads) backupDownloads
|
||||||
|
WHERE EXISTS (SELECT 1 FROM admin_sessions s JOIN admin_accounts a ON a.id=s.admin_id
|
||||||
|
WHERE s.token_hash=? AND a.id=? AND a.enabled=1 AND a.role='owner'
|
||||||
|
AND s.revoked_at IS NULL AND s.expires_at>? AND s.generation=a.session_generation)`)
|
||||||
|
.bind(identity.sessionHash, identity.id, now).first<Counts>();
|
||||||
|
if (!row) throw new AdminError('Owner sign-in required.', 403);
|
||||||
|
return { observedAt: now, ...row };
|
||||||
|
}
|
||||||
+7
-1
@@ -15,7 +15,7 @@ recovery and persistence systems where suitable.
|
|||||||
| Lifecycle and removal | [Pause/resume and joining locks](ADMIN_ROOM_CONTROLS.md), [administrative close/reopen](ADMIN_ROOM_CLOSURE.md) and [recoverable removal/restoration](ADMIN_ROOM_REMOVAL.md) have controls, durable audit, exact retries and player/AI enforcement. [Archive/unarchive](ADMIN_ROOM_ARCHIVE.md) has separate directory filters and exact retries. Permanent deletion and bulk actions remain missing. | Prototyped, partial |
|
| Lifecycle and removal | [Pause/resume and joining locks](ADMIN_ROOM_CONTROLS.md), [administrative close/reopen](ADMIN_ROOM_CLOSURE.md) and [recoverable removal/restoration](ADMIN_ROOM_REMOVAL.md) have controls, durable audit, exact retries and player/AI enforcement. [Archive/unarchive](ADMIN_ROOM_ARCHIVE.md) has separate directory filters and exact retries. Permanent deletion and bulk actions remain missing. | Prototyped, partial |
|
||||||
| Participant support | [Participant AI](ADMIN_PARTICIPANT_AI.md) enables an existing difficulty on an eligible human seat in a paused game, retaining access/takeback and private custody. [Discussion controls](ADMIN_DISCUSSION.md) mute/unmute new sends while retaining history and gameplay. Participant removal, access revocation, assisted recovery and further replacement remain. Existing lobby controls can reassign the host. | Prototyped, partial |
|
| Participant support | [Participant AI](ADMIN_PARTICIPANT_AI.md) enables an existing difficulty on an eligible human seat in a paused game, retaining access/takeback and private custody. [Discussion controls](ADMIN_DISCUSSION.md) mute/unmute new sends while retaining history and gameplay. Participant removal, access revocation, assisted recovery and further replacement remain. Existing lobby controls can reassign the host. | Prototyped, partial |
|
||||||
| Saved-game operations | [Owner-only room backups](ADMIN_BACKUPS.md) capture/list/download a size-limited, version-fenced room snapshot without changing play or access; import validation, safe checkpoint restore and interrupted-work diagnosis/resume remain missing. | Prototyped, partial |
|
| Saved-game operations | [Owner-only room backups](ADMIN_BACKUPS.md) capture/list/download a size-limited, version-fenced room snapshot without changing play or access; import validation, safe checkpoint restore and interrupted-work diagnosis/resume remain missing. | Prototyped, partial |
|
||||||
| Operations and audit | Show server/build/storage health, room/player counts and actionable errors; provide maintenance controls and a searchable record of administrator actions. | Missing |
|
| Operations and audit | The owner-only [operations page](../app/admin/operations/page.tsx) samples build revision, room/seat flags, JSON syntax failures and backup/export counts without exposing game contents. Searchable action history, failed-attempt records, integrity/disk checks, stalled-decision diagnosis and maintenance controls remain. | Prototyped, partial |
|
||||||
|
|
||||||
Track each area through Missing, Prototyped, Integrated, Verified and Polished.
|
Track each area through Missing, Prototyped, Integrated, Verified and Polished.
|
||||||
As implementation begins, link its controls, server actions, persistence,
|
As implementation begins, link its controls, server actions, persistence,
|
||||||
@@ -64,6 +64,12 @@ state and related room-owned records without pausing or editing the room. The
|
|||||||
metadata directory omits private contents; downloaded JSON contains secret
|
metadata directory omits private contents; downloaded JSON contains secret
|
||||||
game state and hashed seat/recovery credentials. Import/restore are not available.
|
game state and hashed seat/recovery credentials. Import/restore are not available.
|
||||||
|
|
||||||
|
The owner-only [operations page](../app/admin/operations/page.tsx) provides
|
||||||
|
manual read-only samples of room lifecycle counts, active seat credentials,
|
||||||
|
invalid JSON saves, latest room write, backup payload usage and export receipts.
|
||||||
|
The database query repeats current owner authority. This is not an integrity
|
||||||
|
check, disk-capacity monitor, restore validation or automated repair.
|
||||||
|
|
||||||
The directory uses an explicit field allowlist, independent of any player view.
|
The directory uses an explicit field allowlist, independent of any player view.
|
||||||
It never returns hands, Traitors, predictions, plans, private messages, recovery
|
It never returns hands, Traitors, predictions, plans, private messages, recovery
|
||||||
records or raw saved state. Searches inspect only codes and public player names.
|
records or raw saved state. Searches inspect only codes and public player names.
|
||||||
|
|||||||
@@ -39,6 +39,13 @@ limits, missing import/restore and absent unified action-history entries keep
|
|||||||
the saved-game operations gate open. Local isolated capture/export and a
|
the saved-game operations gate open. Local isolated capture/export and a
|
||||||
signed-out browser denial passed; this is not deployed evidence.
|
signed-out browser denial passed; this is not deployed evidence.
|
||||||
|
|
||||||
|
The owner-only [read-only operations sample](ADMIN_PANEL.md) now exposes the
|
||||||
|
build revision, saved-room and active-seat counts, lifecycle flags, invalid
|
||||||
|
JSON room counts, latest room write and backup/export usage. Live SQL owner
|
||||||
|
authorization and desktop/mobile local UI checks passed. It does not inspect
|
||||||
|
SQLite integrity, disk space, stalled turns or restore safety; no live NAS
|
||||||
|
deployment or production administrator acceptance is claimed.
|
||||||
|
|
||||||
The bounded [Fremen Nexus Cunning](NEXUS_FREMEN_RULES.md#connected-native-cunning-path)
|
The bounded [Fremen Nexus Cunning](NEXUS_FREMEN_RULES.md#connected-native-cunning-path)
|
||||||
prototype connects an initially empty worm's public offer, one spent private
|
prototype connects an initially empty worm's public offer, one spent private
|
||||||
card, a Karama response, a typed remote ride after the Nexus, human controls
|
card, a Karama response, a typed remote ride after the Nexus, human controls
|
||||||
|
|||||||
@@ -1,5 +1,21 @@
|
|||||||
# Dune implementation status
|
# Dune implementation status
|
||||||
|
|
||||||
|
## 28 September 2026 — owner-only operational counts
|
||||||
|
|
||||||
|
`/admin/operations` now samples the build revision and live database aggregates:
|
||||||
|
saved/removed/archived/paused/closed rooms, active seat credentials, syntactically
|
||||||
|
invalid JSON saves, last room write, backup snapshot bytes/count and committed
|
||||||
|
private exports. Its read query repeats owner authority in SQL and returns no
|
||||||
|
saved state, seat token, message, backup payload or audit reason. The page is
|
||||||
|
read-only and refreshes only on request; it makes no integrity, disk-capacity,
|
||||||
|
repair or stalled-decision claim. Two isolated D1 tests pass for counts, live
|
||||||
|
demotion/revocation and unchanged saved games. The production HTTP suite
|
||||||
|
passed 54/54 with anonymous/forged-session denial, and the build includes the
|
||||||
|
new route/page. A real local signed-out browser denied access; desktop and
|
||||||
|
390-pixel samples rendered correctly using **mocked owner API responses** only,
|
||||||
|
not an authenticated production owner. Full administrator operations and
|
||||||
|
deployed acceptance remain gated; no production data was changed.
|
||||||
|
|
||||||
## 28 September 2026 — owner-only room backup capture
|
## 28 September 2026 — owner-only room backup capture
|
||||||
|
|
||||||
The new `/admin/backups` page lets an authenticated owner capture, list and
|
The new `/admin/backups` page lets an authenticated owner capture, list and
|
||||||
|
|||||||
@@ -39,3 +39,16 @@ void test('room-host and forged administrator credentials cannot list, create or
|
|||||||
assert.deepEqual(Object.keys(await response.json()), ['error']);
|
assert.deepEqual(Object.keys(await response.json()), ['error']);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void test('anonymous and forged administrator session cannot read operational counters', async () => {
|
||||||
|
const credentials: Record<string, string>[] = [
|
||||||
|
{},
|
||||||
|
{ cookie: 'dune_admin_session=' + 'a'.repeat(64), 'X-Dune-Admin-Id': backupId },
|
||||||
|
];
|
||||||
|
for (const headers of credentials) {
|
||||||
|
const response = await fetch(base + '/api/admin/operations', { headers, signal: AbortSignal.timeout(15_000) });
|
||||||
|
assert.equal(response.status, 401);
|
||||||
|
assert.equal(response.headers.get('cache-control'), 'no-store');
|
||||||
|
assert.deepEqual(Object.keys(await response.json()), ['error']);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { adminLogin, requireAdmin, AdminError } from '../db/admin-access';
|
||||||
|
import { readAdminOperations } from '../db/admin-operations';
|
||||||
|
import { adminStore } from './admin-access-fixture';
|
||||||
|
|
||||||
|
const now = 10_000;
|
||||||
|
|
||||||
|
void test('owner operations sample counts saved states and backup provenance without leaking contents or changing rooms', async () => {
|
||||||
|
const store = adminStore();
|
||||||
|
try {
|
||||||
|
const owner = store.provision('owner');
|
||||||
|
const login = await adminLogin(store.database, owner.key, now);
|
||||||
|
const identity = await requireAdmin(store.database, login.token, ['owner'], now);
|
||||||
|
store.sqlite.prepare('INSERT INTO rooms(code,state,version,updated_at) VALUES(?,?,?,?)').run('OPERAABC', '{"private":"sealed card"}', 12, 9000);
|
||||||
|
store.sqlite.prepare('INSERT INTO rooms(code,state,version,updated_at) VALUES(?,?,?,?)').run('OPERAABD', '{broken private state', 3, 9200);
|
||||||
|
store.sqlite.prepare('INSERT INTO seats(token_hash,room_code,player_id,revoked) VALUES(?,?,?,0)').run('private-session', 'OPERAABC', 'one');
|
||||||
|
store.sqlite.prepare('INSERT INTO seats(token_hash,room_code,player_id,revoked) VALUES(?,?,?,1)').run('revoked-session', 'OPERAABC', 'two');
|
||||||
|
store.sqlite.prepare('INSERT INTO room_removals(room_code,removed,revision,removed_at,updated_at) VALUES(?,1,1,?,?)').run('OPERAABD', now, now);
|
||||||
|
store.sqlite.prepare('INSERT INTO room_archives(room_code,archived,revision,archived_at,updated_at) VALUES(?,1,1,?,?)').run('OPERAABD', now, now);
|
||||||
|
store.sqlite.prepare('INSERT INTO room_controls(room_code,paused,join_locked,revision,updated_at) VALUES(?,1,0,1,?)').run('OPERAABC', now);
|
||||||
|
store.sqlite.prepare('INSERT INTO room_closures(room_code,closed,revision,closed_at,updated_at) VALUES(?,1,1,?,?)').run('OPERAABD', now, now);
|
||||||
|
const backupId = randomUUID();
|
||||||
|
store.sqlite.prepare(`INSERT INTO admin_room_backups
|
||||||
|
(operation_id,actor_admin_id,room_code,request_hash,room_version,created_at,size_bytes,digest,payload,reason)
|
||||||
|
VALUES(?,?,?,'request',12,?,14,'digest','secret payload','private reason')`).run(backupId, owner.id, 'OPERAABC', now);
|
||||||
|
store.sqlite.prepare('INSERT INTO admin_room_backup_downloads(download_id,operation_id,actor_admin_id,room_code,created_at) VALUES(?,?,?,?,?)').run(randomUUID(), backupId, owner.id, 'OPERAABC', now);
|
||||||
|
const before = store.sqlite.prepare('SELECT code,state,version FROM rooms ORDER BY code').all();
|
||||||
|
const result = await readAdminOperations(store.database, identity, now);
|
||||||
|
assert.deepEqual(result, {
|
||||||
|
observedAt: now, rooms: 2, removedRooms: 1, archivedRooms: 1,
|
||||||
|
pausedRooms: 1, closedRooms: 1, activeSeats: 1, unreadableRooms: 1,
|
||||||
|
lastRoomChange: 9200, backupSnapshots: 1, backupBytes: 14, backupDownloads: 1,
|
||||||
|
});
|
||||||
|
assert.equal(JSON.stringify(result).includes('secret'), false);
|
||||||
|
assert.deepEqual(store.sqlite.prepare('SELECT code,state,version FROM rooms ORDER BY code').all(), before);
|
||||||
|
} finally { store.sqlite.close(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('stale owner identity, demotion and revoked session cannot sample operational counts', async () => {
|
||||||
|
const store = adminStore();
|
||||||
|
try {
|
||||||
|
const owner = store.provision('owner');
|
||||||
|
store.provision('owner', 'Other owner');
|
||||||
|
const login = await adminLogin(store.database, owner.key, now);
|
||||||
|
const identity = await requireAdmin(store.database, login.token, ['owner'], now);
|
||||||
|
const denied = (error: unknown) => error instanceof AdminError && error.status === 403;
|
||||||
|
store.sqlite.prepare("UPDATE admin_accounts SET role='operator' WHERE id=?").run(owner.id);
|
||||||
|
await assert.rejects(readAdminOperations(store.database, identity, now), denied);
|
||||||
|
store.sqlite.prepare("UPDATE admin_accounts SET role='owner' WHERE id=?").run(owner.id);
|
||||||
|
store.sqlite.prepare('UPDATE admin_sessions SET revoked_at=? WHERE token_hash=?').run(now, identity.sessionHash);
|
||||||
|
await assert.rejects(readAdminOperations(store.database, identity, now), denied);
|
||||||
|
} finally { store.sqlite.close(); }
|
||||||
|
});
|
||||||
Reference in new issue
Block a user