Merge pull request 'Restrict whole-event door takings to admin and organizer.' (#35) from dev into main

Reviewed-on: #35
This commit was merged in pull request #35.
This commit is contained in:
2026-08-23 06:38:57 +00:00
4 changed files with 92 additions and 18 deletions
+61 -6
View File
@@ -14,17 +14,36 @@ process.env.BETTER_AUTH_SECRET = 'door-test-secret-0123456789abcdef';
delete process.env.REDIS_URL;
const STAFF = { id: 'staff-user-id', name: 'Door Staff', role: 'staff' };
const ADMIN = { id: 'admin-user-id', name: 'The Admin', role: 'admin' };
const ORGANIZER = { id: 'organizer-user-id', name: 'The Organizer', role: 'organizer' };
// Who the next request is from. Session auth itself is Better Auth's concern and
// has its own integration suite; this mock keeps the *role* check real so the
// tests can prove which endpoints door staff may reach.
let currentUser: { id: string; name: string; role: string } = STAFF;
// The door endpoints are behind staff auth; the flows under test are the writes,
// not Better Auth, which has its own integration suite.
vi.mock('../lib/auth.js', () => ({
requireAuth: () => async (c: any, next: any) => {
c.set('user', STAFF);
requireAuth: (roles?: string[]) => async (c: any, next: any) => {
if (roles && !roles.includes(currentUser.role)) {
return c.json({ error: 'Forbidden' }, 403);
}
c.set('user', currentUser);
await next();
},
getAuthUser: async () => STAFF,
getAuthUser: async () => currentUser,
}));
/** Run one request as a given role, always restoring the default afterwards. */
async function as<T>(user: typeof STAFF, fn: () => Promise<T>): Promise<T> {
const previous = currentUser;
currentUser = user;
try {
return await fn();
} finally {
currentUser = previous;
}
}
// Walk-ins with an email trigger a confirmation send; keep it out of the test.
vi.mock('../lib/email.js', () => ({
default: { sendBookingConfirmation: vi.fn(async () => ({ success: true })) },
@@ -362,9 +381,45 @@ describe('undo', () => {
});
});
describe('door-summary access', () => {
it('is hidden from door staff — whole-event takings are not door information', async () => {
const { status, body } = await get(`/api/events/${EVENT_ID}/door-summary`);
expect(status).toBe(403);
// The numbers must not leak in the body either: hiding the section in the UI
// alone would still expose them to anyone reading the network response.
expect(body).not.toHaveProperty('door');
expect(body).not.toHaveProperty('presale');
});
it('is available to admin and organizer', async () => {
for (const role of [ADMIN, ORGANIZER]) {
const { status } = await as(role, () => get(`/api/events/${EVENT_ID}/door-summary`));
expect(status, `${role.role} should see door takings`).toBe(200);
}
});
it('still lets door staff do their job — list, check in and undo', async () => {
expect((await get(`/api/events/${EVENT_ID}/door-attendees`)).status).toBe(200);
// Comp, so this ticket stays out of the revenue totals asserted below and
// the two tests cannot drift into each other through the shared database.
seedTicket({ id: 'tkt-role', first: 'Role', last: 'Check', status: 'confirmed', paymentStatus: 'comp' });
const checkin = await post(`/api/events/${EVENT_ID}/door-checkin`, {
ticketId: 'tkt-role',
idempotencyKey: 'key-role-check',
});
expect(checkin.status).toBe(201);
const undo = await post(`/api/events/${EVENT_ID}/door-checkin/undo`, {
idempotencyKey: 'key-role-check',
});
expect(undo.status).toBe(200);
});
});
describe('door-summary', () => {
it('totals door takings by tender and splits them from pre-sale', async () => {
const { status, body } = await get(`/api/events/${EVENT_ID}/door-summary`);
const { status, body } = await as(ADMIN, () => get(`/api/events/${EVENT_ID}/door-summary`));
expect(status).toBe(200);
// Cash: tkt-unpaid + the 'Walk' and 'Overflow' walk-ins (the undone ones are
+8 -2
View File
@@ -8,7 +8,8 @@
// POST /:eventId/door-checkin the single write endpoint — checks in, settles
// payment, or creates a walk-in, atomically
// POST /:eventId/door-checkin/undo reverses exactly what one keyed action did
// GET /:eventId/door-summary end-of-night cash-up + pre-sale/door revenue split
// GET /:eventId/door-summary end-of-night cash-up + pre-sale/door revenue
// split (admin/organizer only)
//
// Every write carries a client-generated idempotencyKey. The key is inserted in
// the same transaction as the writes, so a double tap or a retry after a timeout
@@ -35,6 +36,11 @@ import emailService from '../lib/email.js';
const doorRouter = new Hono();
const STAFF_ROLES = ['admin', 'organizer', 'staff'] as const;
// Whole-event money is management information, not door information: door staff
// reconcile their own shift from the session feed the client keeps locally, and
// never see what the event took overall. Matches the existing convention for
// revenue aggregates (admin/export/financial, admin/analytics).
const REVENUE_ROLES = ['admin', 'organizer'] as const;
const IDEMPOTENCY_SCOPE = 'door-checkin';
// ==================== Shared helpers ====================
@@ -573,7 +579,7 @@ doorRouter.post(
// End-of-night reconciliation: what was taken at the door, by tender, plus the
// pre-sale/door split the event dashboard shows.
doorRouter.get('/:eventId/door-summary', requireAuth([...STAFF_ROLES]), async (c) => {
doorRouter.get('/:eventId/door-summary', requireAuth([...REVENUE_ROLES]), async (c) => {
const eventId = c.req.param('eventId');
const event = await loadEvent(eventId);
@@ -96,6 +96,7 @@ export function SessionSheet({
entries,
summary,
summaryLoading,
showEventTotals,
currency,
onRefresh,
onClose,
@@ -103,6 +104,8 @@ export function SessionSheet({
entries: SessionEntry[];
summary: DoorSummary | null;
summaryLoading: boolean;
/** Whole-event takings are admin/organizer only; door staff see their own shift. */
showEventTotals: boolean;
currency: string;
onRefresh: () => void;
onClose: () => void;
@@ -118,13 +121,15 @@ export function SessionSheet({
<p className="text-xs text-gray-500">{liveEntries.length} checked in from this device</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={onRefresh}
className="min-w-[48px] min-h-[48px] flex items-center justify-center rounded-full text-gray-400 active:text-white active:scale-95 transition-all"
aria-label="Refresh totals"
>
<ArrowPathIcon className={clsx('w-5 h-5', summaryLoading && 'animate-spin')} />
</button>
{showEventTotals && (
<button
onClick={onRefresh}
className="min-w-[48px] min-h-[48px] flex items-center justify-center rounded-full text-gray-400 active:text-white active:scale-95 transition-all"
aria-label="Refresh totals"
>
<ArrowPathIcon className={clsx('w-5 h-5', summaryLoading && 'animate-spin')} />
</button>
)}
<button
onClick={onClose}
className="min-w-[48px] min-h-[48px] flex items-center justify-center rounded-full text-gray-400 active:text-white active:scale-95 transition-all"
@@ -145,7 +150,9 @@ export function SessionSheet({
<CashUpGrid totals={totals} currency={currency} />
</section>
{/* Whole event, from the server — the number to reconcile the cash box against */}
{/* Whole event, from the server — the number to reconcile the cash box
against. Admin/organizer only; the API enforces the same split. */}
{showEventTotals && (
<section className="space-y-2">
<div className="flex items-baseline justify-between">
<h2 className="text-sm font-bold text-white uppercase tracking-wide">Door total, whole event</h2>
@@ -175,6 +182,7 @@ export function SessionSheet({
</p>
)}
</section>
)}
{/* Feed */}
<section className="space-y-2">
+7 -2
View File
@@ -66,6 +66,10 @@ export default function AdminDoorPage() {
const router = useRouter();
const { user } = useAuth();
const backHref = user?.role === 'staff' ? '/admin/events' : '/admin';
// Whole-event takings are management information. Door staff reconcile their
// own shift from the session feed below, which is local to this device; the
// API enforces the same split (see REVENUE_ROLES in routes/door.ts).
const canSeeEventTotals = user?.role === 'admin' || user?.role === 'organizer';
// ── Events ──
const [events, setEvents] = useState<Event[]>([]);
@@ -531,7 +535,7 @@ export default function AdminDoorPage() {
// ─── Session summary ─────────────────────────────────────────
const loadSummary = useCallback(async () => {
const eventId = selectedEventIdRef.current;
if (!eventId) return;
if (!eventId || !canSeeEventTotals) return;
setSummaryLoading(true);
try {
setSummary(await doorApi.summary(eventId));
@@ -540,7 +544,7 @@ export default function AdminDoorPage() {
} finally {
setSummaryLoading(false);
}
}, []);
}, [canSeeEventTotals]);
useEffect(() => {
if (sessionOpen) loadSummary();
@@ -733,6 +737,7 @@ export default function AdminDoorPage() {
entries={sessionEntries}
summary={summary}
summaryLoading={summaryLoading}
showEventTotals={canSeeEventTotals}
currency={currency}
onRefresh={loadSummary}
onClose={() => {