Restrict whole-event door takings to admin and organizer.
The door session sheet showed every staff member what the event had taken overall, by tender and against pre-sale. That is management information, not door information, and it matches the convention already applied to the other revenue aggregates (admin/analytics, admin/export/financial are both admin-only). Door staff keep their own shift cash-up: the "This session" totals are computed on the device from its own action log, so nothing they need to reconcile at the end of the night is lost. The gate is on GET /api/events/:eventId/door-summary, not only on the section that renders it -- hiding the panel while the endpoint still returned the figures would leave them one network response away. The client skips the request entirely for staff rather than provoking a 403. The test auth mock previously waved every role through, so it could not have caught a wrong gate; it now honours the role list, which also puts several already-written assertions onto real code paths. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
02a12ee9e0
commit
745af4184f
@@ -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,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">
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
Reference in New Issue
Block a user