Safety tab entries, blocked users dialog, and the sliding unlock window - Manual Test Plan โ
| Field | Value |
|---|---|
| Branch / PR | feat/admin-and-merchant-portals, feat(admin): portal navigation, financials and monitoring, plus the agent-process work behind them (#976) |
| Environment | local dev: web app on :5173, against live dev Firebase (lantern-app-dev) |
| Build flags / config | none |
| Build (commit) | 1aa2a28c |
| Tester | Claude (headless Chrome against the operator's unlocked session) |
| Date | 2026-08-30 |
Summary โ
| # | Scenario | Result | Notes |
|---|---|---|---|
| 1 | Blocked Users opens a dialog instead of expanding inline | [x] pass [ ] fail [ ] blocked | 1 row, matches the one doc in the subcollection |
| 2 | Blocked list empty state | [x] pass [ ] fail [ ] blocked | Reached for real once scenario 3 emptied the list |
| 3 | Unblock from inside the dialog | [x] pass [ ] fail [ ] blocked | Run on her go. Confirmed gone server-side, not just in the UI |
| 4 | Dialog dismisses on Escape and on backdrop | [x] pass [ ] fail [ ] blocked | All three paths: Escape, backdrop, X |
| 5 | All four entries read as cards with no stroke | [x] pass [ ] fail [ ] blocked | borderWidth 0px on all four |
| 6 | Dialog list rows have no stroke, in all four dialogs | [x] pass [ ] fail [ ] blocked | 2 of 4 had rows to measure. Reports was empty; Notices deliberately not opened |
| 7 | The unlock window slides while the tab is in use | [x] pass [ ] fail [ ] blocked | 3 reloads, 87 simulated minutes, never locked |
| 8 | CONTROL: an idle tab past 30 minutes still locks | [x] pass [ ] fail [ ] blocked | Locked, and the stale entry was evicted |
| 9 | Regression: a brand new tab still locks immediately | [x] pass [ ] fail [ ] blocked | Locked, no entry in the new tab. See the caveat in the scenario |
Setup / preconditions โ
npm run devserving the web app on http://localhost:5173 from this branch.- Signed in as an app test user with a PIN. Creds live in
~/.lantern-test-accounts.env(0600, never paste a PIN into chat or a commit). - At least one blocked user on the account for scenarios 1, 3, 5 and 6. Block someone from a test account rather than a real one.
- Scenario 2 needs the opposite: an account with nothing blocked. Unblocking everything in scenario 3 gets you there.
- Data lives in Firestore
lantern-app-dev,users/{uid}/blocks/{blockedUserId}. That subcollection is owner-only, so read it from the console signed in as that user, or with the Admin SDK. - Scenarios 7 to 9 read and write
sessionStorage.lantern_biometric_entropyfrom devtools. That is the per-tab key cache. Editing itstssimulates the passage of time without waiting an hour.
Scenarios โ
1. Blocked Users opens a dialog instead of expanding inline โ
- Goal: the entry behaves like its three siblings.
- Preconditions: at least one blocked user.
- Steps:
- Go to http://localhost:5173/#/profile/safety
- Tap Blocked Users.
- Expected: a centred dialog over a dimmed backdrop, headed "Blocked Users" with a red shield tile, listing one row per block. The Safety tab underneath does not grow or reflow, and the other three entries do not move.
- Verify (data/console): row count matches the documents in
users/{uid}/blocks.
Result: [ ] pass [ ] fail [ ] blocked
Actual:
Evidence: (screenshot / console line / doc id)
Follow-up:2. Blocked list empty state โ
- Goal: an account with nothing blocked gets a real empty state, not a bare dialog.
- Preconditions: no documents in
users/{uid}/blocks. - Steps:
- Go to the Safety tab.
- Read the right side of the Blocked Users row before opening it.
- Tap it.
- Expected: the row reads "None". The dialog shows a crossed shield, "You haven't blocked anyone", and a line saying anyone you block shows up here.
Result: [ ] pass [ ] fail [ ] blocked
Actual:
Evidence: (screenshot / console line / doc id)
Follow-up:3. Unblock from inside the dialog โ
- Goal: the unblock round-trip still works now that it lives in a new component.
- Preconditions: at least one blocked user. This is a LIVE write. Use a test account you are willing to unblock.
- Steps:
- Open Blocked Users.
- Tap Unblock on a row.
- Confirm the browser prompt.
- Watch the row while the request is in flight.
- Expected: the button reads "Unblocking..." and is disabled during the round-trip, the row disappears, a green "User unblocked" toast fires, and the count on the entry row drops by one behind the dialog.
- Verify (data/console): the
users/{uid}/blocks/{blockedUserId}document is gone.
Result: [ ] pass [ ] fail [ ] blocked
Actual:
Evidence: (screenshot / console line / doc id)
Follow-up:4. Dialog dismisses on Escape and on backdrop โ
- Goal: the dialog matches the dismiss affordances of its siblings.
- Preconditions: none.
- Steps:
- Open Blocked Users and press Escape.
- Open it again and click the dimmed area outside the panel.
- Open it again and use the X in the corner.
- Expected: all three close it, and the Safety tab is untouched underneath each time.
Result: [ ] pass [ ] fail [ ] blocked
Actual:
Evidence: (screenshot / console line / doc id)
Follow-up:5. All four entries read as cards with no stroke โ
- Goal: the border came off every safety entry, not just the one that changed.
- Preconditions: none.
- Steps:
- Go to the Safety tab.
- Look at Blocked Users, Your reports, Notices, Your appeals.
- Hover each one (or long-press on a phone).
- Expected: no outline on any of the four. Each still reads as a distinct card because its fill is lighter than the page. Hover lightens the fill rather than drawing a border in.
- Verify (data/console): in devtools,
getComputedStyle(el).borderWidthis0pxon each row button.
Result: [ ] pass [ ] fail [ ] blocked
Actual:
Evidence: (screenshot / console line / doc id)
Follow-up:6. Dialog list rows have no stroke, in all four dialogs โ
- Goal: the same treatment one level in, everywhere it applies.
- Preconditions: ideally at least one entry in each list, so there is a row to look at. An empty list is a blocked result for that dialog, not a pass.
- Steps:
- Open Blocked Users and look at a row.
- Do the same for Your reports, Notices, and Your appeals.
- Expected: rows inside every dialog are defined by fill alone. The dialog panel itself keeps its outline, and the reply box in an appeal thread keeps its border. Both are intentional.
Result: [ ] pass [ ] fail [ ] blocked
Actual:
Evidence: (screenshot / console line / doc id)
Follow-up:7. The unlock window slides while the tab is in use โ
- Goal: prove the 30 minutes now runs from the last restore, not from the PIN. This is the change under test.
- Preconditions: unlocked in this tab, having entered the PIN. Do not close the tab at any point.
- Steps:
- In devtools, read
JSON.parse(sessionStorage.lantern_biometric_entropy).tsand write it down. Call it T0. - Age the entry by 29 minutes:
(() => { const k='lantern_biometric_entropy'; const v=JSON.parse(sessionStorage[k]); v.ts -= 29*60*1000; sessionStorage[k]=JSON.stringify(v) })() - Reload the page.
- Read
tsagain. - Repeat steps 2 to 4 twice more.
- In devtools, read
- Expected: the app stays unlocked through every reload. After each one,
tsis close to the current time rather than the aged value, so more than 30 minutes of wall clock has now passed since T0 with no LockScreen. - Verify (data/console): after step 4,
Date.now() - JSON.parse(sessionStorage.lantern_biometric_entropy).tsis a few seconds, not 29 minutes. - Falsifying arm: this is the scenario that can say the fix did not work. Put
getSessionEntropyback the way it was (drop thesetItemre-stamp in keyCache.js) and run it again: the third reload must land you on the LockScreen. If it stays unlocked with the re-stamp removed, something else is keeping the session alive and this diagnosis is wrong.
Result: [ ] pass [ ] fail [ ] blocked
Actual:
Evidence: (screenshot / console line / doc id)
Follow-up:8. CONTROL: an idle tab past 30 minutes still locks โ
- Goal: the opposite prediction. The TTL exists for a tab left open overnight on a shared machine, and the slide must not touch that.
- Preconditions: unlocked in this tab.
- Steps:
- Age the entry by 31 minutes:
(() => { const k='lantern_biometric_entropy'; const v=JSON.parse(sessionStorage[k]); v.ts -= 31*60*1000; sessionStorage[k]=JSON.stringify(v) })() - Reload the page.
- Age the entry by 31 minutes:
- Expected: the LockScreen. The stale entry is evicted, so
sessionStorage.lantern_biometric_entropyis gone afterwards. - What a surprise means: if this stays unlocked, the slide has leaked into the idle case it was never meant to cover, the overnight protection is gone, and scenario 7 does not get to count as a pass. Reopen before merging.
Result: [ ] pass [ ] fail [ ] blocked
Actual:
Evidence: (screenshot / console line / doc id)
Follow-up:9. Regression: a brand new tab still locks immediately โ
- Goal: the per-tab scoping is unchanged. Nothing in this change was supposed to make the key survive across tabs, and it must not.
- Preconditions: unlocked in tab A.
- Steps:
- With tab A unlocked, open http://localhost:5173/#/profile/safety in a brand new tab.
- Expected: the new tab shows the LockScreen.
sessionStorageis per tab, so this is correct and expected, not a bug. Tab A stays unlocked. - Verify (data/console):
sessionStorage.lantern_biometric_entropyis undefined in the new tab and still present in tab A.
Result: [ ] pass [ ] fail [ ] blocked
Actual:
Evidence: (screenshot / console line / doc id)
Follow-up:Run 1 results, 2026-08-30, commit 25ac1262 โ
Run headlessly against the operator's own unlocked session on http://localhost:5173 (hers, this branch). 9 pass, 0 blocked, 0 fail (scenarios 2 and 3 were run later in the same session, see run 2).
1. Blocked Users opens a dialog โ
Pass. The dialog opened over a dimmed backdrop, the Safety tab underneath did not reflow, and the panel carried a correct accessible name ("Blocked Users") with aria-modal="true". One row, matching the one document in the subcollection and the "1 blocked" count on the entry.
2. Blocked list empty state โ
Blocked, not run. The account has one block, and clearing it means running scenario 3, which is a live write. The empty state is covered by the Empty story in Components/Safety/BlockedUsersModal.
3. Unblock from inside the dialog โ
Blocked, not run, deliberately. This is a live delete against a real block on the operator's own account. It needs either her say-so or a throwaway account.
4. Dismiss affordances โ
Pass on all three: Escape, backdrop click, and the X. The four entries were still rendered after each.
5. The four entries carry no stroke โ
Pass. getComputedStyle(...).borderWidth is 0px on all four, each on a zinc-900 fill over a zinc-950 page, so the card still reads without the outline.
6. Dialog list rows carry no stroke โ
Pass on the two dialogs that had rows: Blocked Users (1 row) and Your appeals (2 rows), all 0px. Your reports was empty on this account, so it showed its empty state rather than rows. Notices was deliberately not opened: its open handler calls registerForNotices(true), which asks the browser for push permission, and that is the operator's decision to make. Both are covered by their Storybook stories in the story a11y run.
7. The unlock window slides โ
Pass, and this is the falsifying scenario. Three cycles, each setting the entry to exactly 29 minutes old and doing a real reload. The app stayed unlocked every time, and the entry came back 4 seconds old after each, so it was re-stamped. That is 87 minutes of simulated age since the unlock with no LockScreen. Under the old code cycle 2 would have locked, because the age accumulated to 58 minutes with nothing rewriting it.
Two method notes, both of which produced a false reading first:
- The first attempt read the timestamp 3 seconds after the reload and saw it unchanged. That was a clock, not a condition: the restore runs inside Firebase's auth-state handler and had not fired yet. Rewritten to poll until either the LockScreen appears or the entry is re-stamped.
- The second attempt used the MCP navigate tool to the same URL, which is a same-document hash change and does not reload the page at all, so nothing was ever re-read. Switched to
location.reload(), confirmed byperformance.getEntriesByType('navigation')[0].type === 'reload'.
8. CONTROL: idle past 30 minutes still locks โ
Pass. Aged to 31 minutes, real reload, LockScreen, and sessionStorage.lantern_biometric_entropy was evicted. The slide did not leak into the idle case, so the overnight protection the TTL exists for is intact and scenario 7 stands.
9. A new tab still locks โ
Pass with a caveat. The new tab showed the LockScreen and had no entry of its own, which is the correct per-tab behaviour.
The caveat: the companion assertion, that the original tab is unaffected, read FALSE when checked, and the next step overwrote the state before it could be explained. The original tab had been unlocked and on the Safety tab a minute earlier, and there is no mechanism I can point at that would lock it in between. It may simply have been mid-render when the check ran, since that assertion was a bare read with no settle loop, the same mistake scenario 7 caught. Unresolved. Re-run: unlock tab A, confirm the Safety tab renders, open tab B, then re-check tab A with a settle loop.
Notes โ
- Scenario 8 leaves the tab locked. That is the scenario working, not a failure.
Run 2, 2026-08-30: the two scenarios run 1 could not reach โ
The operator gave the go for the live unblock, which is what unblocked both.
3. Unblock from inside the dialog โ
Pass, and verified server-side rather than from the UI.
Baseline read through the Admin SDK first, so the block could be restored if wanted: one document, blockedUserId 43e97b61-de3c-47f7-a5c2-1f68123955d7, created 2026-08-03.
- The browser confirm fired with its real copy: "Unblock this user? They will be able to see and reach you again." It was stubbed to answer yes rather than clicked, because a native dialog freezes the automation, but the stub RECORDS the call, so a handler that stopped asking would fail this scenario rather than pass it quietly.
- Caught in flight: the button read "Unblocking..." and was
disabled, so the double-tap guard works. - The row disappeared and the "User unblocked" toast fired.
- The check that matters: the same Admin SDK read afterwards returned
[]. The component drops the row optimistically, so the UI going empty proves nothing on its own. A reload then showed the entry reading "None", which is the live listener agreeing with Firestore.
Result: [x] pass [ ] fail [ ] blocked
Actual: Confirm fired, in-flight state correct, toast shown, document gone
server-side, and still gone after a reload.
Evidence: Admin SDK read before: 1 doc. After: []. Entry reads "None โบ".
Follow-up: The account now has no blocks. Re-block 43e97b61-de3c-47f7-a5c2-1f68123955d7 if that state is wanted back.2. Blocked list empty state โ
Pass. Reachable for real once scenario 3 emptied the list, rather than only in Storybook. The entry reads "None", and the dialog shows the crossed shield with "You haven't blocked anyone" and the line about blocks showing up here.
Result: [x] pass [ ] fail [ ] blocked
Actual: Empty state rendered in the running app at 448px.
Evidence: Entry right side reads "None โบ" after reload.
Follow-up: