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:
Admin guest roster:
API base:
Frontend:
https://stg-rcme.membervu.com/loginAdmin guest roster:
https://stg-rcme.membervu.com/admin/guestsAPI 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)
| Step | Action (EXACT clicks/typing) | Expected Result (EXACT text/behavior) | Test Value |
|---|---|---|---|
| 1 | Go 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! |
| 2 | Locate the Profile Card (avatar + info table, usually top-left or in a sidebar widget). | Card renders NAME, EMAIL, and a TYPE row. | — |
| 3 | Read 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)
| Step | Action | Expected Result | Test Value |
|---|---|---|---|
| 1 | Log out. Go to https://stg-rcme.membervu.com/login, sign in as admin. | Admin dashboard loads. | admin@rcme.membervu.com / Admin123! |
| 2 | Navigate to Members → Guests (/admin/guests). | Guest roster list loads with search/filter controls. | — |
| 3 | Open 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". | — |
| 4 | Let 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.
| Step | Action | Expected Result | Test Value |
|---|---|---|---|
| 1 | Register 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) |
| 2 | As admin, re-export the guest CSV for that event (Step 2, repeat). | Export succeeds — 200, no error. | — |
| 3 | Open 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. | — |
| 4 | Open 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.
| Step | Action | Expected Result | Test Value |
|---|---|---|---|
| 1 | Find (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. | — |
| 2 | Log in as a plain member with no event-management role: testmember@rcme.membervu.com. | Member dashboard loads. | testmember@rcme.membervu.com / Member123! |
| 3 | Navigate 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. | — |
| 4 | In 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. | — |
| 5 | Find 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. | — |
| 6 | On 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)
| Step | Action | Expected Result | Test Value |
|---|---|---|---|
| 1 | Log out. Log in as the Events Manager: events@demo.membervu.com. | Admin/manager dashboard loads. | events@demo.membervu.com / Events123! |
| 2 | Repeat 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. | — |
| 3 | Inspect 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
- If a throwaway guest registration with the
=SUM(1+1)name was created in Step 3, cancel it via Members → Guests (admin) or leave it and note the registration id in your test log — do not let it pollute event capacity counts for subsequent regression passes. - No other seed data is mutated by this TC — Steps 1, 2, 4, and 5 are all read-only against existing seed data.