Skip to content

C3 User-Facing Safety Loop - Manual Test Plan โ€‹

Companion to 2026-06-28-admin-ban-wiring. That run covered the admin ban side; this one covers the C3 surfaces this branch adds: the Block -> Report flow in Chat, report intake (userReports), admin Cases off real data, the moderation notice composer, the user notice inbox, and FCM push delivery.

FieldValue
Branch / PRclaude/sealed-identity-stage-c-safety (no PR yet)
Environmentlocal dev: web :5173 + auth-api :8084 + admin :3001 (all hit LIVE lantern-app-dev Firebase). Stage B + prelude ON locally (.env.local).
Build flags / configVITE_FIREBASE_VAPID_KEY set (push enabled). No feature flag gates the C3 surfaces.
Build (commit)af838b30
TesterClaude (agent-driven, in-browser) + Mechelle
Date2026-07-14

Run outcome (2026-07-14): S1, S2, S3, S6, S8 PASS live; F1 found + FIXED; F2 resolved via an operator-authorized dev rules pre-deploy; S4/S5/S7/S9-S15 not run this session. The first live click-through of the block -> report flow (never done before) surfaced that the "report them too?" offer was unreachable in the live app (F1): blocking archived the connection, the Dashboard dropped it from active connections and closed the chat, and BlockReportFlow (rendered inside Chat.jsx) unmounted before it could show the report step. Staged with a seeded throwaway peer + connection (Onyx Ray, the sealed test account, as reporter); chat opened directly via #/?chat=<connectionId>.

UPDATE (2026-07-14, same session): F1 FIXED + verified live. Moved archiveConnection out of handleBlock into the flow's onComplete (Chat.jsx), so the connection stays active through the offer. Re-tested: Block -> the "report them too?" offer NOW appears -> Report opens the report modal (S6 category carry-over confirmed: no category radios, description-only) -> the connection archives at flow completion (status -> expired). The report write itself is blocked by F2 (below): the branch's userReports rules are not deployed to lantern-app-dev yet, so the live write returns "Missing or insufficient permissions." The write shape matches the branch rule (145 emulator rules tests pass), so this is a merge/deploy prerequisite, not a code bug.

Summary โ€‹

#ScenarioResultNotes
1Block step: feel-unsafe note is presence-gated (lit vs not lit)[x] pass [ ] fail [ ] blockednot-lit case: NO red venue-staff banner (correctly absent)
2Block -> decline report (block only, no report written)[x] pass [ ] fail [ ] blockedblock record written (users/{uid}/blocks/{peer}), connection archived (status->expired), 0 userReports
3Block -> Report happy path (description only)[x] pass [ ] fail [ ] blockedFULL PASS live after the F1 fix + a dev rules pre-deploy (F2): userReports doc written (category=harassment carried over from the block reason, status=open, connection + venue context present).
4Report with screenshot evidence (uploads to Storage)[ ] pass [ ] fail [x] blockedblocked by F1 (cannot reach the report modal)
5Report evidence partial failure (bad attachment)[ ] pass [ ] fail [x] blockedblocked by F1
6Block-reason -> report-category carry-over[x] pass [ ] fail [ ] blockedverified after the F1 fix: the report modal shows NO category radios (carried over from the block reason "harassment"), description-only, no "also block"
7Reporter: My Reports list + report detail (pending)[ ] pass [ ] fail [x] blockedblocked by F1 (no report exists to list)
8Admin: report surfaces in Cases off real data[x] pass [ ] fail [ ] blockedverified live with a REAL report (not seeded): Cases > Reports shows "C3 Test Peer" (1 report, Normal priority, Open / Unassigned, latest reason).
9Admin: Claim -> reviewing + denormalizes reporter status[ ] pass [ ] fail [x] blockedblocked by F1 (P4-verified separately)
10Admin: send notice to REPORTED user (warning/restriction/ban)[ ] pass [ ] fail [x] blockednot run (P4-verified separately)
11Admin: send notice to REPORTER (report-outcome)[ ] pass [ ] fail [x] blockednot run (P4-verified separately)
12User: notice inbox receive -> open -> mark read[ ] pass [ ] fail [x] blockednot run this session
13Push: permission grant -> FCM token registered[ ] pass [ ] fail [x] blockednot run this session (still the never-verified-live piece)
14Push: delivery end-to-end (pushed > 0 + notification)[ ] pass [ ] fail [x] blockednot run this session
15Regression: unblock from Settings blocked list[ ] pass [ ] fail [x] blockednot run this session

Findings โ€‹

F1 (BUG, blocks S3-S7): the "report them too?" offer is unreachable in the live app โ€‹

Severity: high, the entire user-facing report-intake path never fires.

Repro (2026-07-14, live, twice): as a logged-in user with an active connection, open the chat, tap Block, pick a reason, tap Block User. The block succeeds (a users/{uid}/blocks/{peer} record is written and the connection is archived, status -> expired), but the app goes straight to the dashboard. The "report them too?" offer never appears, so no report can be filed. userReports written = 0.

Root cause: Chat.jsx handleBlock calls archiveConnection (sets the connection status: 'expired'). Dashboard.jsx subscribes to ACTIVE connections (subscribeToActiveConnections, status == 'active'), so the archived connection drops out of activeConnections, and the Dashboard's activeChat effect closes the chat. BlockReportFlow is rendered inside Chat.jsx, so it unmounts with the chat before it can transition from the block step to the report-offer step. The flow works in Storybook only because there is no Dashboard subscription there to close the chat. The handleBlock comment ("the flow then offers 'report them too?' and closes the chat on completion (so we no longer close here)") describes the intended behavior, which archive-on-block breaks.

Fix options (pick one): (a) defer archiveConnection until the BlockReportFlow completes (move it out of handleBlock into the flow's completion), so the chat stays mounted through the offer; (b) render BlockReportFlow above the chat (Dashboard level) so it survives the chat unmount; (c) keep the connection active until the flow finishes, archiving as the last step. Option (a) is the smallest change.

Confirms the long-standing "the full block -> report user flow has never been clicked live" caveat: clicking it live is exactly what surfaced this.

FIX (applied 2026-07-14, #662, verified live): moved archiveConnection out of Chat.jsx handleBlock into the flow's onComplete callback. The block still happens immediately (safety-critical), but the connection stays active until the flow finishes, so BlockReportFlow stays mounted through the offer. Re-tested live: Block -> the offer appears -> Report opens the modal -> on completion (report submit OR "Not now" OR cancel) the connection archives (status -> expired, confirmed archived only after onComplete). eslint clean.

F2 (NOT a bug, merge prerequisite): the live report write needs the branch userReports rules deployed โ€‹

What: after the F1 fix, submitting the report returns "Missing or insufficient permissions" (a Firestore rules rejection). The write shape (reportedUserId, reportedUserName, reporterUserId, reporterLanternName, category, reason, status, createdAt + optional evidence/connectionId/venueId/venueName, with reporterUserId == auth.uid) matches the branch's userReports create rule exactly, and that rule passes the 145-test emulator rules suite. So the write is valid; it fails only because the branch's firestore.rules are not yet live on lantern-app-dev (the documented "local dev hits live dev Firebase, branch rules not deployed" gotcha). Resolves on merge (merging to dev deploys the rules). No code change needed.

RESOLVED (2026-07-14, operator-authorized pre-deploy): deployed just the rules to dev, firebase deploy --only firestore:rules --project lantern-app-dev. The diff vs origin/dev is purely additive (+145 lines, 0 deletions: adds appeals, reinstate_markers, userReports, moderationCases, notices, fcmTokens blocks), so it cannot break existing dev behavior. Re-tested: the report write now LANDS (userReports/qex6lQjk..., category=harassment, status=open), and it surfaces in admin Cases -> Reports as "C3 Test Peer" (1 report, Normal, Open). So S3 + S8 are now full-pass live. Dev carries the branch rules until the branch merges (which re-deploys them, idempotent).

Setup / preconditions โ€‹

  • Services running (all confirmed up on this worktree):
    • web: :5173 (Vite; proxies /api/auth -> :8084).
    • auth-api: :8084 (the admin notice/ban actions proxy here, NOT Cloud Run).
    • admin: :3001.
  • Accounts / connection:
    • Reporter = the app user you log into web (:5173) as.
    • Reported = a throwaway dev app-user you are already connected to (you need an active connection visible in the chat list, so the Block button is reachable). Use a throwaway you can afford to action, NOT a real user and NOT yourself.
    • Admin = an admin login for :3001.
    • Guardrail: Prelude test numbers only for any signup; throwaway app-users for the reported account. Do not report or notice a real subscriber.
  • Where to verify data (Firebase console, project lantern-app-dev):
    • userReports/{reportId} - reportedUserId, reporterUserId, reporterLanternName, category, reason, evidence[] (filenames only), status, connectionId/venueId/venueName, createdAt.
    • Storage reportEvidence/{reporterUid}/{reportId}/{i-name}.webp - the uploaded screenshots (admin + uploader readable only).
    • moderationCases/{reportedUserId} - status, assignee, escalated; subcollections activity and sentNotices.
    • notices/{id} - userId, type, message, read, caseRef, createdAt.
    • users/{uid}/fcmTokens/{t_<sha256>} - token, platform: "web", createdAt.
    • adminActions newest - action (sendNotice / claimCase / etc.), noticeType, recipientCount, pushed, performedBy.
    • Browser DevTools: Network tab for POST /auth/moderation/notice -> { sent, pushed }. NOTE the report write is a Firestore SDK write (websocket), NOT an HTTP POST, so confirm it via the Console devLog [reportService] report filed <id> + the Firestore doc, not the Network tab.
  • Evidence images: save screenshots into this run's folder, alongside this README, with scenario-keyed names (e.g. s14-push-delivered.png), and reference them relatively from the result block (e.g. ![push delivered](s14-push-delivered.png)). Each run is one self-contained folder (doc + its images), so runs/ never collapses into a pile of image-N.png and every shot ties to its scenario. Use throwaway/test data only in any screenshot.
  • Known caveat (rules drift): the live dev userReports rules were deployed at 18156b50 (P0); the ultra-review (410c30e3) hardened them afterward and that version is NOT on dev until merge. Happy-path writes pass both versions; the stricter constraints (hasOnly, name-length bounds) are already covered by the 148-test emulator rules suite. Do not read a local pass as validating the hardened rules.
  • Reaching a clean state: after the run, delete the test userReports, notices, and moderationCases/{reportedUserId} docs and the reportEvidence/{uid}/{reportId}/ files (Admin SDK or console), and unblock/unban any throwaway you actioned.

Scenarios โ€‹

1. Block step: feel-unsafe note is presence-gated (lit vs not lit) โ€‹

  • Goal: the bright-red "Find venue staff" note in the block modal shows ONLY when the user's lantern is lit at a venue now (litVenueName), not for any connection that merely carries a venue.
  • Preconditions: an active connection in the chat list.
  • Steps:
    1. With your lantern NOT lit at a venue, open a chat and tap Block.
    2. Read the block confirmation modal top.
    3. Cancel. Light your lantern at a venue (so litVenueName is set), open the same chat, tap Block again.
  • Expected: step 1 modal shows NO red "Feel unsafe or in danger? Find venue staff at {venue}" banner. Step 3 modal DOES show it, naming the lit venue, in present tense.
  • Verify (data/console): no console errors; the only difference between the two renders is the presence of the red venue-staff banner.
Result:    [ ] pass   [ ] fail   [ ] blocked
Actual:    ______________________________________________
Evidence:  ______________________________________________  (screenshot / console line / doc id)
Follow-up: ______________________________________________

2. Block -> decline report (block only, no report written) โ€‹

  • Goal: blocking is immediate and standalone; declining the "report them too?" offer writes no report.
  • Preconditions: an active connection.
  • Steps:
    1. Open the chat, tap Block, pick a reason, confirm the block.
    2. On the "Report them too?" prompt, tap Dismiss / No.
  • Expected: the user is blocked and the chat closes. No report modal. The connection leaves the active list (archived).
  • Verify (data/console): console [Chat] shows the block (and best-effort archive). NO new userReports doc is created for this attempt. The peer appears in Profile -> Settings -> Blocked users.
Result:    [ ] pass   [ ] fail   [ ] blocked
Actual:    ______________________________________________
Evidence:  ______________________________________________  (screenshot / console line / doc id)
Follow-up: ______________________________________________

3. Block -> Report happy path (description only) โ€‹

  • Goal: the consenting report follow-up writes a well-formed userReports doc.
  • Preconditions: an active connection (use a fresh throwaway connection if #2 archived the last one).
  • Steps:
    1. Open the chat, tap Block, pick a reason, confirm.
    2. On the offer, tap Report.
    3. In the report modal (already-blocked variant), type a description and submit. No screenshots.
  • Expected: "Report sent to our safety team." toast; the flow closes the chat.
  • Verify (data/console): console [reportService] report filed <reportId>. Firestore userReports/{reportId}: reporterUserId = your uid, reportedUserId = the peer, reporterLanternName set (<= 40 chars), reason = your text, evidence: [], status: "open", connectionId set, createdAt set.
Result:    [ ] pass   [ ] fail   [ ] blocked
Actual:    ______________________________________________
Evidence:  ______________________________________________  (screenshot / console line / doc id)
Follow-up: ______________________________________________

4. Report with screenshot evidence (uploads to Storage) โ€‹

  • Goal: attached screenshots compress to webp and upload to the locked-down evidence path, and the report pins their filenames.
  • Preconditions: an active connection; one or two image files handy.
  • Steps:
    1. Block -> Report as in #3.
    2. Attach 1-2 screenshots, type a description, submit.
  • Expected: "Report sent to our safety team." toast (no "could not be attached" warning).
  • Verify (data/console): Firestore userReports/{reportId}.evidence = ["0-<name>.webp", ...] (filenames only, not full paths). Storage reportEvidence/{yourUid}/{reportId}/0-<name>.webp exists. Confirm a non-admin cannot read another user's reportEvidence folder (path is scoped to your uid).
Result:    [ ] pass   [ ] fail   [ ] blocked
Actual:    ______________________________________________
Evidence:  ______________________________________________  (screenshot / console line / doc id)
Follow-up: ______________________________________________

5. Report evidence partial failure (bad attachment) โ€‹

  • Goal: an attachment that fails to upload never blocks the report; the user is told some were dropped.
  • Preconditions: an active connection; a file likely to fail compression/upload (e.g. an oversized or non-image masquerading as one), plus one good image.
  • Steps:
    1. Block -> Report; attach the bad file (and optionally one good one); submit.
  • Expected: the report still sends, with a toast like "Report sent. 1 screenshot could not be attached."
  • Verify (data/console): userReports/{reportId} is created (status: "open"); evidence contains only the successfully-uploaded filename(s); console [reportService] evidence upload skipped. No orphaned Storage objects for the failed file.
Result:    [ ] pass   [ ] fail   [ ] blocked
Actual:    ______________________________________________
Evidence:  ______________________________________________  (screenshot / console line / doc id)
Follow-up: ______________________________________________

6. Block-reason -> report-category carry-over โ€‹

  • Goal: the reason chosen at the block step preselects the report category, and the already-blocked report variant does not ask for category again.
  • Preconditions: an active connection.
  • Steps:
    1. Block with a specific reason (e.g. "harassment"), confirm.
    2. Tap Report on the offer.
    3. Observe the report modal: category selection and the "also block" option.
  • Expected: no category radios shown (carried over from the block reason); no "also block" option (already blocked). Only the description (+ optional evidence) is requested.
  • Verify (data/console): the resulting userReports/{reportId}.category is the normalized mapping of the block reason (a valid taxonomy value), not empty or a raw block reason like "uncomfortable".
Result:    [ ] pass   [ ] fail   [ ] blocked
Actual:    ______________________________________________
Evidence:  ______________________________________________  (screenshot / console line / doc id)
Follow-up: ______________________________________________

7. Reporter: My Reports list + report detail (pending) โ€‹

  • Goal: the reporter can follow up on their own report.
  • Preconditions: at least one report filed (from #3-#6).
  • Steps:
    1. As the reporter, go to Profile -> Settings -> Your reports.
    2. Open the most recent report row.
  • Expected: the list shows the report with the reported Lantern name, category, a "Submitted" status, and the date. Opening it shows the description, the evidence count, and a pending (not-yet-resolved) outcome.
  • Verify (data/console): the row reflects userReports.status: "open"; the detail evidence count matches evidence.length; no outcome block yet (status is not actioned/dismissed).
Result:    [ ] pass   [ ] fail   [ ] blocked
Actual:    ______________________________________________
Evidence:  ______________________________________________  (screenshot / console line / doc id)
Follow-up: ______________________________________________

8. Admin: report surfaces in Cases off real data โ€‹

  • Goal: a filed report appears in admin Moderation -> Cases (grouped by reported user), no longer mock.
  • Preconditions: at least one open report against the throwaway reported user; signed in to :3001 as admin.
  • Steps:
    1. Go to Moderation -> Cases.
    2. Find the case for the reported user; open the drawer.
  • Expected: a real case row for {reportedUserId} with a derived priority and an Open status (no "mock data" banner on this list). The drawer lists the actual report(s) with the reporter's Lantern name, category, and description.
  • Verify (data/console): the case maps to moderationCases/{reportedUserId} (or is derived from the userReports group); priority matches the rule (High if >= 3 reports or a severe category, Medium if 2, else Normal). No console errors.
Result:    [ ] pass   [ ] fail   [ ] blocked
Actual:    ______________________________________________
Evidence:  ______________________________________________  (screenshot / console line / doc id)
Follow-up: ______________________________________________

9. Admin: Claim -> reviewing + denormalizes reporter status โ€‹

  • Goal: claiming a case assigns it and propagates "in review" back to the reporter.
  • Preconditions: an open case (from #8); the reporter's My Reports view open on web in parallel if possible.
  • Steps:
    1. In the case drawer, click Claim.
    2. Check the reporter's Profile -> Your reports (refresh if needed).
  • Expected: the case status becomes reviewing, assignee shows "You". The reporter's report row flips to "In review".
  • Verify (data/console): moderationCases/{reportedUserId}.status: "reviewing", assignee set; the grouped userReports/{reportId}.status denormalized to reviewing; adminActions newest action: "claimCase" (or equivalent). Activity count increments.
Result:    [ ] pass   [ ] fail   [ ] blocked
Actual:    ______________________________________________
Evidence:  ______________________________________________  (screenshot / console line / doc id)
Follow-up: ______________________________________________

10. Admin: send notice to REPORTED user (warning / restriction / ban) โ€‹

  • Goal: the notice composer writes a recipient-readable notice to the reported user and logs it.
  • Preconditions: an open/reviewing case; the reported user is a throwaway you can notify.
  • Steps:
    1. In the case drawer, open the Notices sub-tab.
    2. Recipient = Reported user; pick a template (e.g. warning); optionally add a note; preview the recipient-facing copy; Send.
  • Expected: success; the sent notice appears in the drawer's Notices list; case Activity gains an entry. The reported user is never told who reported them.
  • Verify (data/console): Network POST /auth/moderation/notice -> { sent: 1, pushed: <n> }. Firestore: notices/{id} with userId = reported user, type: "warning", read: false, caseRef set. adminActions newest action: "sendNotice", noticeType: "warning", recipientCount: 1. moderationCases/{rid}/activity + moderationCases/{rid}/sentNotices each gain a doc.
Result:    [ ] pass   [ ] fail   [ ] blocked
Actual:    ______________________________________________
Evidence:  ______________________________________________  (screenshot / console line / doc id)
Follow-up: ______________________________________________

11. Admin: send notice to REPORTER (report-outcome) โ€‹

  • Goal: the reporter gets a report-outcome notice that acknowledges the review without leaking specifics.
  • Preconditions: a case with at least one reporter.
  • Steps:
    1. In the Notices sub-tab, recipient = Reporters; template = action taken (or no violation); Send.
  • Expected: success; the outcome notice is queued to the reporter(s). Copy acknowledges action was taken but withholds the specifics, with a "Your reports" follow-up path.
  • Verify (data/console): Network POST /auth/moderation/notice -> { sent, pushed }. notices/{id} for the reporter: type: "report-outcome", outcome set (action-taken / no-action), caseRef set. adminActions noticeType: "report-outcome".
Result:    [ ] pass   [ ] fail   [ ] blocked
Actual:    ______________________________________________
Evidence:  ______________________________________________  (screenshot / console line / doc id)
Follow-up: ______________________________________________

12. User: notice inbox receive -> open -> mark read โ€‹

  • Goal: a recipient sees the notice in their inbox, opens the correct templated modal, and can mark it read.
  • Preconditions: a notice sent to this user (from #10 or #11). Log in as that user on web.
  • Steps:
    1. Go to Profile -> Settings -> Notices and open the inbox.
    2. Observe the unread row; open it.
    3. Close it / mark read; reopen the inbox.
  • Expected: the inbox shows the notice with the correct type icon/accent, a one-line summary, and an unread dot. Opening renders the matching ModerationNoticeModal template (warning/restriction/ban -> appeals path on restriction+ban; report-outcome -> withholds specifics + "Your reports" button). After reading, the unread dot clears.
  • Verify (data/console): the inbox query reads only this user's notices; notices/{id}.read flips to true after open (or explicit mark-read).
Result:    [ ] pass   [ ] fail   [ ] blocked
Actual:    ______________________________________________
Evidence:  ______________________________________________  (screenshot / console line / doc id)
Follow-up: ______________________________________________

13. Push: permission grant -> FCM token registered โ€‹

  • Goal: opting into push registers an FCM token under the user.
  • Preconditions: logged in on web; browser notification permission currently "default" (not yet decided). Chrome.
  • Steps:
    1. Go to Profile -> Settings -> Notices and click View notices (this calls registerForNotices(true)).
    2. When Chrome prompts, click Allow.
  • Expected: permission becomes "granted"; no error toast.
  • Verify (data/console): console [notifications] registered for notices. Firestore users/{yourUid}/fcmTokens/{t_<sha256>} created with token, platform: "web", createdAt. (If VITE_FIREBASE_VAPID_KEY were unset you would instead see [notifications] ... push disabled.)
Result:    [ ] pass   [ ] fail   [ ] blocked
Actual:    ______________________________________________
Evidence:  ______________________________________________  (screenshot / console line / doc id)
Follow-up: ______________________________________________

14. Push: delivery end-to-end (pushed > 0 + notification) โ€‹

  • Goal: with a registered token, a sent notice actually pushes and appears.
  • Preconditions: #13 done (a token exists for the recipient). Have the admin tab ready to send.
  • Steps:
    1. From admin, send a notice to that user (as in #10/#11).
    2. Watch the recipient's browser (foreground) and the Network response.
  • Expected: the notice arrives; a system notification appears (or, if the tab is focused, the foreground handler logs it). This is the piece never verified live before (always pushed: 0).
  • Verify (data/console): Network POST /auth/moderation/notice -> { sent: 1, pushed: 1 } (pushed >= 1). adminActions newest sendNotice has pushed >= 1. Recipient console: foreground [notifications] foreground notice: <title> OR a background notification from the service worker.
Result:    [ ] pass   [ ] fail   [ ] blocked
Actual:    ______________________________________________
Evidence:  ______________________________________________  (screenshot / console line / doc id)
Follow-up: ______________________________________________

15. Regression: unblock from Settings blocked list โ€‹

  • Goal: the pre-existing block/unblock path still works after the flow rework.
  • Preconditions: at least one blocked user (from earlier scenarios).
  • Steps:
    1. Go to Profile -> Settings -> Blocked users.
    2. Unblock the test user.
  • Expected: the user leaves the blocked list; the unblock succeeds without error.
  • Verify (data/console): the block record is removed/reversed; no console errors. (Re-connecting is out of scope; this just confirms unblock was not broken by the BlockReportFlow change.)
Result:    [ ] pass   [ ] fail   [ ] blocked
Actual:    ______________________________________________
Evidence:  ______________________________________________  (screenshot / console line / doc id)
Follow-up: ______________________________________________

Built with VitePress