From 53331cc19014e7cfe376cfad4a3e86c5ba13441c Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Mon, 20 Apr 2026 20:12:24 +0100 Subject: [PATCH] fix(events): gate members-only events in calculateTicketPrice Legacy events (tickets.enabled=false) with membersOnly=true were returning a free guest ticket for unauthenticated callers, causing GET /api/events/[id]/tickets/available to report available:true. The UI then rendered the registration form and register.post.js 403'd on submit. Short-circuit early when membersOnly && !hasMemberAccess so the availability endpoint's existing null-ticketInfo branch surfaces the correct "members only" reason. --- server/utils/tickets.js | 8 ++++++ tests/server/utils/tickets.test.js | 46 ++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/server/utils/tickets.js b/server/utils/tickets.js index 4790ab5..799989e 100644 --- a/server/utils/tickets.js +++ b/server/utils/tickets.js @@ -19,6 +19,14 @@ export const calculateTicketPrice = (event, member = null) => { // Members without access (guest/suspended/cancelled) get public pricing only. const accessMember = hasMemberAccess(member) ? member : null; + // Members-only gate applies to both legacy and ticketed paths. Without this, + // a legacy event (tickets.enabled=false) with membersOnly=true returns a + // free guest ticket and the availability endpoint shows a registration form + // that the server then 403s on submit. + if (event.membersOnly && !accessMember) { + return null; + } + if (!event.tickets?.enabled) { // Legacy pricing model if (event.pricing?.paymentRequired && !event.pricing?.isFree) { diff --git a/tests/server/utils/tickets.test.js b/tests/server/utils/tickets.test.js index 8ba74a1..528208b 100644 --- a/tests/server/utils/tickets.test.js +++ b/tests/server/utils/tickets.test.js @@ -394,6 +394,52 @@ describe('calculateTicketPrice', () => { expect(result).toBeNull() }) }) + + describe('members-only gating', () => { + it('returns null for unauthenticated user on members-only legacy event', () => { + const event = legacyFreeEvent({ membersOnly: true }) + const result = calculateTicketPrice(event, null) + + expect(result).toBeNull() + }) + + it('returns null for unauthenticated user on members-only ticketed event', () => { + const event = ticketedEvent({ membersOnly: true }) + const result = calculateTicketPrice(event, null) + + expect(result).toBeNull() + }) + + it('returns null for guest-status member on members-only event', () => { + const event = ticketedEvent({ membersOnly: true }) + const result = calculateTicketPrice(event, baseMember({ status: 'guest' })) + + expect(result).toBeNull() + }) + + it('returns null for suspended member on members-only event', () => { + const event = ticketedEvent({ membersOnly: true }) + const result = calculateTicketPrice(event, baseMember({ status: 'suspended' })) + + expect(result).toBeNull() + }) + + it('returns member pricing for active member on members-only event', () => { + const event = ticketedEvent({ membersOnly: true }) + const result = calculateTicketPrice(event, baseMember()) + + expect(result).not.toBeNull() + expect(result.ticketType).toBe('member') + }) + + it('returns member pricing for pending_payment member on members-only event', () => { + const event = ticketedEvent({ membersOnly: true }) + const result = calculateTicketPrice(event, baseMember({ status: 'pending_payment' })) + + expect(result).not.toBeNull() + expect(result.ticketType).toBe('member') + }) + }) }) // ===========================================================================