Step-by-Step Guide — Follow each step exactly as written. All values are provided — do not improvise.

TC-GUEST-003: Guest Experience & Data Handling (Detailed)

Module: Guest Portal — Data Handling & Security

Primary Test Users: guest@demo.membervu.com / Guest123! (Guest) · admin@rcme.membervu.com / Admin123! (Admin) · events@demo.membervu.com / Events123! (Events Manager) · testmember@rcme.membervu.com / Member123! (Member, non-privileged)

Priority: P1

URLs for this test:
Frontend: https://stg-rcme.membervu.com/login
Admin guest roster: https://stg-rcme.membervu.com/admin/guests
API base: https://stg-api.membervu.com/api
Payment gateway is INERT on staging (Paynamics). Any invoice referenced by a guest's event registration in this TC is a record only — never a live charge.

Step 1: Guest profile card shows "Guest" type (GUEST3-01, #368)

StepAction (EXACT clicks/typing)Expected Result (EXACT text/behavior)Test Value
1Go to https://stg-rcme.membervu.com/login. Sign in.Guest dashboard loads (GuestHomePage) — a reduced view, not the full member dashboard.guest@demo.membervu.com / Guest123!
2Locate the Profile Card (avatar + info table, usually top-left or in a sidebar widget).Card renders NAME, EMAIL, and a TYPE row.
3Read the exact text in the TYPE row.TYPE reads exactly "Guest" — not empty, not "Member", not the raw enum GUEST.
Data assertion: the TYPE cell's rendered string must equal "Guest" exactly (case-sensitive). Code path: ProfileCard.tsx renders profile.memberType || (profile as any).member_type || (isGuest ? "Guest" : "Member") — since a guest record has no populated memberType, the fallback isGuest ? "Guest" : "Member" is what fires, and GuestHomePage.tsx passes isGuest={true}. If the TYPE row is blank or reads "Member", the guest fallback is broken — file as a regression of #368.
Companion check: the "Member Since" tenure footer at the bottom of the Profile Card must be absent for this guest (the same isGuest flag also gates that footer off) — confirm it does not render a fabricated join date.

Step 2: Admin exports the guest roster server-side (GUEST3-02, #340)

StepActionExpected ResultTest Value
1Log out. Go to https://stg-rcme.membervu.com/login, sign in as admin.Admin dashboard loads.admin@rcme.membervu.com / Admin123!
2Navigate to Members → Guests (/admin/guests).Guest roster list loads with search/filter controls.
3Open browser DevTools → Network tab. Filter by "export" or "csv". Click "Export CSV" on an event group.A network request fires: GET /api/admin/guests/export.csv (with query params for the active eventId/filters). Response headers include Content-Type: text/csv; charset=utf-8 and Content-Disposition: attachment; filename="guests-export-<date>.csv".
4Let the download complete. Open the downloaded .csv file in a plain text editor (not Excel yet).First line (headers) reads exactly: "Guest Name","Email","Registered Date","Invited By","Inviter Email","Status","Ticket Code","Statement #","Statement Amount","Statement Status","Statement Balance" — every field double-quoted.
Data assertion: confirm the CSV is NOT generated by client-side JS — inspect AdminGuestsPage.tsx's network call (exportAdminGuestsCSV in frontend/pwa-app/src/api/client.ts) fetches a blob from the server and triggers a browser download (a.download = "guests-<event>-<date>.csv") rather than assembling CSV rows in the browser and calling Blob([...]) on client-held state. The server route is eventsRouter.get("/admin/guests/export.csv", requirePermission("EVENTS:VIEW:registrations"), exportAdminGuestsCsvHandler).

Step 3: Formula-injection guard neutralizes a hostile cell (GUEST3-03, #340)

Security assertion — do not skip. Spreadsheet apps execute any cell beginning with =, +, -, or @ as a formula (CWE-1236). This step confirms the export neutralizes it rather than passing the raw value through.
StepActionExpected ResultTest Value
1Register a new guest for any public event using a display name that begins with an equals sign (via the public event registration form, or ask a dev to seed one if the form blocks it client-side).Guest registration succeeds; a new EventRegistration row exists with guestName starting with =.Guest name: =SUM(1+1)
2As admin, re-export the guest CSV for that event (Step 2, repeat).Export succeeds — 200, no error.
3Open the CSV in a plain text editor and locate the row for the injected guest.The "Guest Name" cell reads "'=SUM(1+1)" — a leading single-quote ' is prepended INSIDE the double-quoted cell. The raw form "=SUM(1+1)" (no leading ') must NOT appear.
4Open the same CSV file in Excel, Google Sheets, or LibreOffice Calc.The cell displays the literal text '=SUM(1+1) or =SUM(1+1) as a text string — it does NOT evaluate to 2. No formula bar shows an active formula for that cell.
Data assertion: code path is csvQuote()sanitizeCsvCell() in auth-service/src/utils/csvSanitize.ts: any cell value matching /^[=+\-@\t\r]/ gets a ' prefix before RFC-4180 quoting. This applies to every column derived from user input (Guest Name, Email, Invited By, Inviter Email, Ticket Code) — spot-check at least the Guest Name and Email columns. A normal guest name/email with no leading trigger character must render unmodified (no stray leading quote).

Step 4: Non-privileged roster view redacts PII (GUEST3-04 / GUEST3-05, #486)

Security assertion — do not skip. This step calls the API directly (via DevTools Network tab, or a REST client with the member's own bearer token) to inspect the raw JSON — the UI may not surface every field, so relying on visual inspection alone is insufficient.
StepActionExpected ResultTest Value
1Find (or create) an event where at least one OTHER member and at least one GUEST are registered, alongside the test member.Event has 2+ attendee rows visible to the test member.
2Log in as a plain member with no event-management role:
testmember@rcme.membervu.com.
Member dashboard loads.testmember@rcme.membervu.com / Member123!
3Navigate to the event's attendee/guest list in the UI (event detail page), OR open DevTools Network tab and trigger the roster load.A request fires: GET /api/events/:eventId/registrations/guests. Inspect the raw JSON response body.
4In the response's guests[] array, find a row that is NOT the test member's own registration (a different attendee, or the GUEST row).On that row: attendeeEmail, memberEmail, guestEmail, checkInCode, and displayTicketCode keys are absent (or explicitly undefined — check with the browser's JSON viewer, which typically omits undefined keys entirely). invoice is null.
5Find the test member's OWN row in the same response (matched by attendeeMemberId).The own row RETAINS attendeeEmail, memberEmail, checkInCode, displayTicketCode, and (if the member has an invoice for this event) a populated invoice object.
6On the GUEST row found in step 4, inspect the invitedBy object (present only if the guest was invited by a member).invitedBy retains id, firstName, lastName — but has NO email property.
Data assertion: code path is getEventGuestRegistrationsHandler (auth-service/src/handlers/eventRegistrationsHandler.ts): isPrivileged = await checkPermission(req, "EVENTS:VIEW:registrations"), isOwnRegistration matches on attendeeMemberId/legacy memberId, and showSensitive = isPrivileged || isOwnRegistration gates each sensitive field per-row. Non-sensitive fields (attendeeName, guestName, status, avatar, referral fields) remain visible on every row regardless of privilege — confirm the OTHER row's attendeeName/guestName is still populated (display name is not PII by this policy).

Step 5: Privileged caller sees the full roster (GUEST3-06, #486)

StepActionExpected ResultTest Value
1Log out. Log in as the Events Manager:
events@demo.membervu.com.
Admin/manager dashboard loads.events@demo.membervu.com / Events123!
2Repeat Step 4.3 for the SAME event — trigger GET /api/events/:eventId/registrations/guests and inspect the raw JSON.EVERY row — including the other attendee's and the guest's — has checkInCode, displayTicketCode, the relevant email field (attendeeEmail/memberEmail/guestEmail), and (if applicable) a populated invoice object.
3Inspect the GUEST row's invitedBy object.invitedBy.email IS present and populated (e.g. the inviting member's real email).
Data assertion: this confirms the gate is role-based (via EVENTS:VIEW:registrations), not a blanket lockdown — Events Manager and Admin roles should see identical (full) output to what the endpoint returned before #486 shipped. If a privileged role also sees redacted fields, that's a regression (over-redaction), not a security fix.

CLEANUP