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:
@@ -14,17 +14,36 @@ process.env.BETTER_AUTH_SECRET = 'door-test-secret-0123456789abcdef';
|
|||||||
delete process.env.REDIS_URL;
|
delete process.env.REDIS_URL;
|
||||||
|
|
||||||
const STAFF = { id: 'staff-user-id', name: 'Door Staff', role: 'staff' };
|
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', () => ({
|
vi.mock('../lib/auth.js', () => ({
|
||||||
requireAuth: () => async (c: any, next: any) => {
|
requireAuth: (roles?: string[]) => async (c: any, next: any) => {
|
||||||
c.set('user', STAFF);
|
if (roles && !roles.includes(currentUser.role)) {
|
||||||
|
return c.json({ error: 'Forbidden' }, 403);
|
||||||
|
}
|
||||||
|
c.set('user', currentUser);
|
||||||
await next();
|
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.
|
// Walk-ins with an email trigger a confirmation send; keep it out of the test.
|
||||||
vi.mock('../lib/email.js', () => ({
|
vi.mock('../lib/email.js', () => ({
|
||||||
default: { sendBookingConfirmation: vi.fn(async () => ({ success: true })) },
|
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', () => {
|
describe('door-summary', () => {
|
||||||
it('totals door takings by tender and splits them from pre-sale', async () => {
|
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);
|
expect(status).toBe(200);
|
||||||
|
|
||||||
// Cash: tkt-unpaid + the 'Walk' and 'Overflow' walk-ins (the undone ones are
|
// 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
|
// POST /:eventId/door-checkin the single write endpoint — checks in, settles
|
||||||
// payment, or creates a walk-in, atomically
|
// payment, or creates a walk-in, atomically
|
||||||
// POST /:eventId/door-checkin/undo reverses exactly what one keyed action did
|
// 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
|
// 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
|
// 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 doorRouter = new Hono();
|
||||||
|
|
||||||
const STAFF_ROLES = ['admin', 'organizer', 'staff'] as const;
|
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';
|
const IDEMPOTENCY_SCOPE = 'door-checkin';
|
||||||
|
|
||||||
// ==================== Shared helpers ====================
|
// ==================== Shared helpers ====================
|
||||||
@@ -573,7 +579,7 @@ doorRouter.post(
|
|||||||
// End-of-night reconciliation: what was taken at the door, by tender, plus the
|
// End-of-night reconciliation: what was taken at the door, by tender, plus the
|
||||||
// pre-sale/door split the event dashboard shows.
|
// 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 eventId = c.req.param('eventId');
|
||||||
|
|
||||||
const event = await loadEvent(eventId);
|
const event = await loadEvent(eventId);
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ export function SessionSheet({
|
|||||||
entries,
|
entries,
|
||||||
summary,
|
summary,
|
||||||
summaryLoading,
|
summaryLoading,
|
||||||
|
showEventTotals,
|
||||||
currency,
|
currency,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -103,6 +104,8 @@ export function SessionSheet({
|
|||||||
entries: SessionEntry[];
|
entries: SessionEntry[];
|
||||||
summary: DoorSummary | null;
|
summary: DoorSummary | null;
|
||||||
summaryLoading: boolean;
|
summaryLoading: boolean;
|
||||||
|
/** Whole-event takings are admin/organizer only; door staff see their own shift. */
|
||||||
|
showEventTotals: boolean;
|
||||||
currency: string;
|
currency: string;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
onClose: () => 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>
|
<p className="text-xs text-gray-500">{liveEntries.length} checked in from this device</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
{showEventTotals && (
|
||||||
onClick={onRefresh}
|
<button
|
||||||
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"
|
onClick={onRefresh}
|
||||||
aria-label="Refresh totals"
|
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>
|
<ArrowPathIcon className={clsx('w-5 h-5', summaryLoading && 'animate-spin')} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
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"
|
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} />
|
<CashUpGrid totals={totals} currency={currency} />
|
||||||
</section>
|
</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">
|
<section className="space-y-2">
|
||||||
<div className="flex items-baseline justify-between">
|
<div className="flex items-baseline justify-between">
|
||||||
<h2 className="text-sm font-bold text-white uppercase tracking-wide">Door total, whole event</h2>
|
<h2 className="text-sm font-bold text-white uppercase tracking-wide">Door total, whole event</h2>
|
||||||
@@ -175,6 +182,7 @@ export function SessionSheet({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Feed */}
|
{/* Feed */}
|
||||||
<section className="space-y-2">
|
<section className="space-y-2">
|
||||||
|
|||||||
@@ -66,6 +66,10 @@ export default function AdminDoorPage() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const backHref = user?.role === 'staff' ? '/admin/events' : '/admin';
|
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 ──
|
// ── Events ──
|
||||||
const [events, setEvents] = useState<Event[]>([]);
|
const [events, setEvents] = useState<Event[]>([]);
|
||||||
@@ -531,7 +535,7 @@ export default function AdminDoorPage() {
|
|||||||
// ─── Session summary ─────────────────────────────────────────
|
// ─── Session summary ─────────────────────────────────────────
|
||||||
const loadSummary = useCallback(async () => {
|
const loadSummary = useCallback(async () => {
|
||||||
const eventId = selectedEventIdRef.current;
|
const eventId = selectedEventIdRef.current;
|
||||||
if (!eventId) return;
|
if (!eventId || !canSeeEventTotals) return;
|
||||||
setSummaryLoading(true);
|
setSummaryLoading(true);
|
||||||
try {
|
try {
|
||||||
setSummary(await doorApi.summary(eventId));
|
setSummary(await doorApi.summary(eventId));
|
||||||
@@ -540,7 +544,7 @@ export default function AdminDoorPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setSummaryLoading(false);
|
setSummaryLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [canSeeEventTotals]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (sessionOpen) loadSummary();
|
if (sessionOpen) loadSummary();
|
||||||
@@ -733,6 +737,7 @@ export default function AdminDoorPage() {
|
|||||||
entries={sessionEntries}
|
entries={sessionEntries}
|
||||||
summary={summary}
|
summary={summary}
|
||||||
summaryLoading={summaryLoading}
|
summaryLoading={summaryLoading}
|
||||||
|
showEventTotals={canSeeEventTotals}
|
||||||
currency={currency}
|
currency={currency}
|
||||||
onRefresh={loadSummary}
|
onRefresh={loadSummary}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user