Block Sever (#144 Phase 1 + 2) - Manual + Emulator Test Plan โ
| Field | Value |
|---|---|
| Branch / PR | feat/144-block-enforcement / #776 |
| Environment | Firestore emulator (rules + Admin-SDK integration) for the safety layers; local full stack (web :5173 + lanterns-api :8083 + emulators) for the UI E2E |
| Build flags / config | none (behavior is default-on) |
| Build (commit) | see PR head |
| Tester | agent (emulator scenarios) + operator (browser E2E, post-deploy) |
| Date | 2026-08-02 |
What this feature does โ
Blocking someone now SEVERS the relationship instead of hiding it: the chat connection is closed and its messages are destroyed server-side, pending waves and beacon invites between the two are deleted, and the security rules refuse any further read/send across a block in either direction. Before the chat is destroyed, the blocker can save a local copy (capture-then-sever). Unblock stays possible but does not restore the destroyed thread.
Who verifies what (honest split) โ
- Agent, via the Firestore emulator (automated, recorded here): the security-rule gate (real rules, real emulator) and the server sever/purge (real Admin SDK against the emulator, real deletes). These are the safety-critical layers.
- Operator, via the browser (manual): the UI flow (block button -> save-a-copy download -> chat closes). Left manual because it needs two real accounts with an established E2EE chat; not worth standing up headlessly for marginal gain over the layer tests.
- Post-deploy pass: re-confirm on the deployed dev build that the behavior is live (the code is default-on, so this is a smoke check, not an activation-flag check).
Setup / preconditions โ
- Emulator scenarios:
npm run test:rules(Firestore emulator, rules) andnpx vitest run services/api/lanterns/test/block.sever.integration.test.js(Admin SDK against afirebase emulators:execFirestore). No live project touched. - Browser E2E: two dev test accounts (see
~/.lantern-agent-probe.envfor the admin probe; a second phone+PIN test account per the browser-testing memory). Run the app against emulators (npm run emulators+npm run dev), or against deployed dev AFTER this PR merges. Data to verify lives in thelantern-app-devFirestore (or the emulator):connections/{id}(blockClosed),connections/{id}/messages(gone),waves,beaconInvites,users/{uid}/blocks/{blockedUid}. - Automated browser E2E (headless, deployed dev):
npm run e2e:block-severdrives the full two-account flow (light โ wave โ accept โ message โ block+save-copy) and asserts the sever in Firestore. Setup, test-account creation, and gotchas are documented indocs/engineering/testing/HEADLESS_E2E.md. - Guardrail: never run the destructive block against a real account you care about; use throwaway/test accounts. The sever really deletes messages.
Summary โ
| # | Scenario | Layer | Result | Notes |
|---|---|---|---|---|
| 1 | Blocked party cannot SEND into the connection (rule) | emulator/rules | [x] pass | npm run test:rules |
| 2 | Blocked party cannot READ the thread (rule) | emulator/rules | [x] pass | npm run test:rules |
| 3 | Reverse-direction block also denies (rule) | emulator/rules | [x] pass | npm run test:rules |
| 4 | blockClosed connection denies read+send even with no block doc (rule) | emulator/rules | [x] pass | npm run test:rules |
| 5 | blockClosed is server-write-only; participant cannot set/clear (rule) | emulator/rules | [x] pass | npm run test:rules |
| 6 | beaconInvite create is denied across a block (rule) | emulator/rules | [x] pass | npm run test:rules |
| 7 | severBetween closes connection + deletes messages (Admin SDK) | emulator/integration | [x] pass | npm run test:sever:emulator |
| 8 | severBetween purges pending waves both directions (Admin SDK) | emulator/integration | [x] pass | npm run test:sever:emulator |
| 9 | severBetween purges beacon invites both directions (Admin SDK) | emulator/integration | [x] pass | npm run test:sever:emulator |
| 10 | Unrelated connections/waves/invites untouched (Admin SDK) | emulator/integration | [x] pass | npm run test:sever:emulator |
| 11 | UI: block from a live chat closes it; save-a-copy downloads | browser | [ ] pass [ ] fail [ ] blocked | operator |
| 12 | UI: warning states permanent deletion; unblock does not restore | browser | [ ] pass [ ] fail [ ] blocked | operator |
| 13 | Post-deploy: behavior live on dev build | deployed | [ ] pass [ ] fail [ ] blocked | after merge |
| 14 | UI: a blocked peer disappears from the Archived connections list (Phase 3) | browser | [ ] pass [ ] fail [ ] blocked | operator; unit-covered in waveService.test.js |
Deploy verified 2026-08-02 (agent): PR #776 merged to dev (squash
cb4fc137) and deployed green. dev.ourlantern.app = 200; lanterns-api /health = 200; the DEPLOYED lanterns-api openapi carries/lanterns/block/severwith responses[200, 400, 401, 429], confirming the exact merged code (incl. the review fixes) shipped. Scenarios 11/12/14 (browser) could NOT be automated headlessly: deployed dev enforces App Check (reCAPTCHA Enterprise), so a headless browser can't sign in (Missing X-Firebase-AppCheck header). These are operator-run on a real browser/device (or a local run with a registered App Check debug token). See the browser-testing memory.
Emulator run 2026-08-02 (agent): scenarios 1-10 PASS.
npm run test:rules= 206 rules tests green (incl. the 10 Phase 1 + 3 Phase 2 block cases);npm run test:sever:emulator= 5 integration tests green against a real Firestore emulator. Scenarios 11-12 (browser E2E) and 13 (post-deploy) remain for the operator, since they need two real accounts with an established E2EE chat.
Scenarios โ
1. Blocked party cannot SEND into the connection โ
- Goal: A block in either direction refuses a message create.
- Steps:
- Seed a connection (ALICE, BOB) and a block
users/ALICE/blocks/BOB. - As BOB, attempt to create a message under the connection.
- Seed a connection (ALICE, BOB) and a block
- Expected: Permission denied.
- Verify: rules test
after ALICE blocks BOB, BOB (the blocked party) CANNOT send.
Result: [ ] pass [ ] fail [ ] blocked
Actual:
Evidence:
Follow-up:2. Blocked party cannot READ the thread โ
- Goal: The blocked party's message read is denied.
- Steps:
- Seed connection (ALICE, BOB), a message, and block
users/ALICE/blocks/BOB. - As BOB, query the messages subcollection.
- Seed connection (ALICE, BOB), a message, and block
- Expected: Permission denied.
- Verify: rules test
the blocked party CANNOT read the thread.
Result: [ ] pass [ ] fail [ ] blocked
Actual:
Evidence:
Follow-up:3. Reverse-direction block also denies โ
- Goal: BOB blocking ALICE denies ALICE just as ALICE blocking BOB does.
- Steps: Seed block
users/BOB/blocks/ALICE; as ALICE, attempt a message send. - Expected: Permission denied.
- Verify: rules test
a reverse-direction block (BOB blocks ALICE) also denies ALICE sending.
Result: [ ] pass [ ] fail [ ] blocked
Actual:
Evidence:
Follow-up:4. blockClosed connection denies read+send even with no block doc โ
- Goal: After the server severs (sets blockClosed, deletes the thread, and the block doc may later be removed on unblock), the connection stays closed.
- Steps: Seed connection with
blockClosed: trueand NO block doc; as a participant, attempt read and send. - Expected: Both denied.
- Verify: rules tests
a blockClosed connection denies reads...and...denies sends.
Result: [ ] pass [ ] fail [ ] blocked
Actual:
Evidence:
Follow-up:5. blockClosed is server-write-only โ
- Goal: A participant cannot forge a sever or clear one to reopen.
- Steps: As a participant, attempt
update {blockClosed:true}on an open connection, andupdate {blockClosed:false}on a severed one. - Expected: Both denied.
- Verify: rules tests
a participant CANNOT set blockClosed.../...CANNOT clear blockClosed....
Result: [ ] pass [ ] fail [ ] blocked
Actual:
Evidence:
Follow-up:6. beaconInvite create is denied across a block โ
- Goal: A blocked user cannot create a location-leaking beacon invite (Phase 2 rule gate).
- Steps: Seed block
users/BOB/blocks/ALICE; as ALICE, attempt to create a beaconInvite withrecipientId: BOB. - Expected: Permission denied.
- Verify: rules test (Phase 2)
beaconInvite create denied across a block.
Result: [ ] pass [ ] fail [ ] blocked
Actual:
Evidence:
Follow-up:7. severBetween closes connection + deletes messages (Admin SDK) โ
- Goal: The server sever actually closes and destroys against a real Firestore emulator.
- Steps: Seed a connection + messages in the emulator; call
severBetween(A, B). - Expected: connection
blockClosed == true; messages subcollection empty; counts returned. - Verify: integration test asserts emulator state after the call.
Result: [ ] pass [ ] fail [ ] blocked
Actual:
Evidence:
Follow-up:8. severBetween purges pending waves both directions โ
- Goal: Pre-block waves (the location leak) are destroyed, both orderings.
- Steps: Seed waves (A->B and B->A) in the emulator; call
severBetween(A, B). - Expected: both wave docs gone;
wavesDeleted == 2. - Verify: integration test.
Result: [ ] pass [ ] fail [ ] blocked
Actual:
Evidence:
Follow-up:9. severBetween purges beacon invites both directions โ
- Goal: Pre-block beacon invites (venue + time leak) are destroyed, both orderings.
- Steps: Seed beaconInvites (A->B and B->A); call
severBetween(A, B). - Expected: both invite docs gone;
beaconInvitesDeleted == 2. - Verify: integration test.
Result: [ ] pass [ ] fail [ ] blocked
Actual:
Evidence:
Follow-up:10. Unrelated connections/waves/invites untouched โ
- Goal: The sever is precisely scoped to the pair.
- Steps: Seed an unrelated connection (A, C), wave (A->C), invite (A->C); call
severBetween(A, B). - Expected: none of the A-C docs are closed or deleted.
- Verify: integration test.
Result: [ ] pass [ ] fail [ ] blocked
Actual:
Evidence:
Follow-up:11. UI: block from a live chat closes it; save-a-copy downloads (operator) โ
- Goal: End-to-end user flow.
- Preconditions: Two accounts with an active connection and a few exchanged messages.
- Steps:
- Open the chat as account A. Tap Block.
- In the modal, check "Save a copy of this conversation before blocking".
- Confirm the block.
- Expected: a
.txttranscript downloads; the chat closes; A no longer sees the conversation. - Verify (data):
connections/{id}.blockClosed == true;connections/{id}/messagesempty;users/A/blocks/Bexists. As account B, the chat can no longer be read or sent to.
Result: [ ] pass [ ] fail [ ] blocked
Actual:
Evidence:
Follow-up:12. UI: warning copy + unblock does not restore (operator) โ
- Goal: The user is warned the deletion is permanent, and unblock confirms it.
- Steps:
- Open the block modal from a chat; read the warning.
- Block, then unblock the same user in Settings.
- Start a new connection with them (new wave/accept) if possible.
- Expected: warning states the chat is permanently deleted and unrecoverable even on unblock; after unblock the old messages do NOT reappear; a fresh conversation starts empty.
- Verify: old
connections/{id}/messagesstill empty; any new conversation is a new connection doc.
Result: [ ] pass [ ] fail [ ] blocked
Actual:
Evidence:
Follow-up:14. UI: a blocked peer disappears from the Archived connections list (operator) โ
- Goal: Phase 3 - blocked peers vanish from the archived list too (the active list already filtered them).
- Steps: Have an archived (met/expired) connection with a peer, then block that peer; open the Archived connections view.
- Expected: the blocked peer's archived connection is not shown.
- Verify: unit-covered in
apps/web/src/lib/__tests__/waveService.test.js(getArchivedConnections+subscribeToArchivedConnectionsblock cases); this is the browser confirmation.
Result: [ ] pass [ ] fail [ ] blocked
Actual:
Evidence:
Follow-up:13. Post-deploy: behavior live on dev build โ
- Goal: Confirm the shipped dev build severs (no activation flag; this is a smoke check).
- Steps: After the PR merges and dev deploys, repeat scenario 11 on dev.ourlantern.app with test accounts.
- Expected: same as scenario 11 on the deployed build.
- Verify: dev Firestore shows the sever; note the deploy run id + commit.
Result: [ ] pass [ ] fail [ ] blocked
Actual:
Evidence:
Follow-up:Notes โ
(Agent and operator both edit this file over the run. Agent records emulator scenario results as they run; operator fills the browser + post-deploy scenarios.)