Files
com.pearcal/test/resetPlan.test.js
7679dcbd94 feat: in-app "Reset app data" on every platform (#282)
* feat: in-app "Reset app data" on every platform

Wiping PearCal's local data meant deleting a directory by hand: buried
on desktop, a Settings -> Apps -> Storage trek on Android, and simply
impossible on iOS, where the only route was delete-and-reinstall. TODO
#118, proposal 2026-07-18-in-app-reset-data.

Two levels, both reachable from Profile -> Settings -> Reset:

  keep identity   clear the calendar DB and group store. Same user, same
                  device identity; rejoin a group with its invite link
                  and the calendar comes back from the other members.
  full            also delete the mnemonic, so the next boot mints a
                  fresh identity and this device is a new user. Gated
                  behind a typed RESET and a reveal-your-phrase step.

Local only. Nothing is broadcast: no group control message, no leave, no
writer revocation. To every peer it looks exactly like an uninstall,
which is why it needs no ownerGuard-style authentication - the
destructive ops that need authenticating are the ones that travel.

Order is load-bearing: quiesce, close every handle, delete, re-open.
Deleting cores under a live Hypercore/RocksDB handle is the not-fsynced
loss class, and on Windows an open handle fails the unlink outright.

Three things the proposal did not account for, each of which would have
made a "reset" not one:

- The PLATFORM mnemonic backup has to go too. hasMnemonic auto-restores
  from iCloud/Drive before reporting "no mnemonic", so deleting only the
  local secure-store entry hands the same identity back on next boot.
  Verified by read-back, since the two backends disagree about what a
  successful delete returns.
- Reminders are OS alarms, not database rows. A wipe leaves them armed
  and firing for events that no longer exist, so the shell cancels the
  scheduler's ID range.
- rebuildLocalDb stages through core.new / core.old. An aborted rebuild
  leaves a full readable copy of the old database beside the live one.

resetInMemoryState clears every module-level cache that outlives the DB.
Missing one is not cosmetic: a stale notifiedMemberJoins entry silences
real notifications for the new install, and a stale _writerProofs entry
has the new identity trusting a proof it never made.

Verified on the real worklet headless under Node, 20/20 checks: data
gone both ways, identity preserved on keep and genuinely CHANGED on
full, DB writable again afterwards, and repeatable back to back.

* fix(ui): read the recovery phrase off db, not sync

The reveal step before a full reset called sync.revealMnemonic, which
does not exist - revealMnemonic lives on the DB surface, alongside the
existing reveal in the Backup section. It threw straight into the error
line, so the one step standing between a user and an irreversible delete
silently showed them nothing.

Caught by driving the real UI bundle in a hidden Electron window rather
than by reading the diff; the surrounding checks all passed, which is
exactly why it would have shipped.

* chore: rebuild the UI bundle for the reset sheet

* fix: carry the profile across a keep-identity reset

"Clear calendar and groups" wiped the profile record along with
everything else, so getProfile() returned null, the UI decided this was
a first boot and marched the user through onboarding to pick a name they
already had. The identity did survive in the mnemonic, but nothing the
user could see said so - which flatly contradicts "You stay the same
person", the entire promise of that level. Reported on-device 2026-07-27.

Read the profile before the teardown, write it back after the re-open.
Name, avatar and settings all live in that one record, and the avatar is
inline rather than a hash into the wiped keyspace, so carrying the record
is enough.

Deliberately NOT carried on a full reset: that path is meant to produce a
stranger, and the name and avatar are exactly what would give the old
user away. The harness now asserts both directions.

* feat: leave every group on a full reset

A reset left the user sitting in the owner's member list forever, which
"start over as a new user" plainly should not (reported on-device
2026-07-27). Decided the same day: depart on the FULL reset only.

The two levels differ on purpose. Keep-identity stays local because its
whole promise is that you rejoin with the invite link and come back as
yourself; leaving would orphan your RSVPs and your entries in invitee
lists. A full reset destroys the identity, so a member record for it is
dead weight in every other member's list forever.

Owned groups are handed to the earliest-joined remaining member, using
array order as the proxy since members carry no joinedAt. A group owned
with nobody else in it is deleted outright rather than left ownerless,
which would otherwise strand it behind the 30-day claimOwnership path.

BEST-EFFORT, and the limit is structural rather than lazy: syncMemberLeft
records a durable pendingLeave: row so an offline peer hears on its next
connect, but a full reset deletes the very database holding that row. So
it reaches peers connected right now and no others. Time-bounded at 20s
for the same reason - a user who asked to wipe their device must not be
stuck on a spinner because one Autobase is wedged.

The counts come back in the return value rather than only in a log line,
so the outcome is observable and testable.

Copy corrected: the sheet used to promise "nobody else is told", which
is now true of one level and false of the other.

Harness 34/34, with all three branches asserted separately: transferred
an owned group to its remaining member, deleted the one owned alone, and
broadcast the departure - while a keep-identity reset departs nothing.

* chore: rebuild the iOS bare bundles for the departing reset

* feat(ui): disclose that leaving misses offline members

A full reset's departure only reaches peers with the app open at that
moment. Normally a leave writes a durable pendingLeave: row so an offline
peer hears on its next connect, but a full reset deletes the very
database holding it, so there is no second attempt.

That is structural, not a bug to polish away, which makes it something to
say out loud rather than something a member discovers by still seeing a
person who left. Disclosed on the confirmation step, before the typed
gate, with the practical alternative: leave your groups by hand first,
while the others are around.

UI harness asserts the wording is present, so it cannot quietly vanish in
a future copy edit.

* refactor: remove the recovery-phrase feature, keep the identity seed

The seed phrase was not a feature anybody could use. The reveal button,
the copy and export, the backup toggle and the backup status row were all
still in the code and none of them was rendered, on mobile or desktop.
There was no way to see, save or restore a phrase.

What WAS still running is the part nobody could see or switch off: backup
defaulted to ON with no reachable toggle, so every device that generated a
seed uploaded it to iCloud Keychain or Google Block Store, and hasMnemonic
read it back from there. A seed phrase sat in Apple's and Google's cloud
for a feature the user could not reach.

One live surface did leak through: OnboardingModal told new users
"Recovery phrase saved to iCloud Keychain" or "back up in Settings" -
pointing at a screen that does not exist.

Removed: the cloud mirroring and read-back, the reveal / copy / export
paths, the backup status and toggle, the onboarding toast, and the
matching IPC on both shells and both UI bridges. The full-reset flow drops
its "Show my recovery phrase first" step and now talks about the device
getting a new identity, which is what actually happens.

KEPT, deliberately:
- The mnemonic itself. It is the seed the identity derives from - profile.id,
  group.ownerId, writer proofs and multi-device pairing all come off it, and
  identityPublicKey has 96 references. Removing it is a redesign with a wire
  break, not a cleanup.
- restoreMnemonic. Unreachable from the UI, but it is the only re-entry path
  for an identity and TODO #86's owner recovery is built on it.
- The two native backup modules, for now, ONLY so scrubLegacyCloudBackup can
  delete seeds already uploaded. Dropping them today would strand exactly the
  data this removal is meant to remove: a Block Store entry survives until
  explicitly deleted and an iCloud Keychain item persists indefinitely. They
  can go in a later release once installs have rolled forward.

Added scrubLegacyCloudBackup: one-time, best-effort, idempotent, confirmed
by read-back since the two backends disagree about what a successful delete
returns. A failure leaves the flag unset so the next launch retries.

352 tests, worklet harness 34/34, desktop app over CDP 9/9, and the UI
harness now asserts the phrase surface is ABSENT so it cannot creep back.

* chore: rebuild bundles after the recovery-phrase removal

* fix(ios): remount the WebView after a reset instead of reloading it

A reset on iOS dropped the user on a black "Failed to start PearCal.
Could not connect to the server" screen. The app was fine underneath -
closing and reopening showed the data correctly cleared - but the reset
looked like it had broken the app.

The WebView's source is inline HTML with baseUrl 'https://localhost', and
WKWebView's reload() re-requests that baseUrl for real, so iOS went off
to fetch https://localhost and failed. Android re-renders the HTML
instead, which is why only iOS broke and the earlier TCL test passed.

Bump a key to remount instead. That drops the old React tree and loads
the same inline HTML fresh, which is what reloading was meant to achieve.
Routed through a _remountWebView indirection so the once-per-process
event handler reaches the live mount, matching _notifyReady.

Also fixes a real race in the departure, reported in the same session as
a full reset leaving the device in the owner's member list: syncMemberLeft
and the ownership append only buffer bytes, and shutdown() destroyed the
swarm milliseconds later, so the goodbye was composed and thrown away.
drainOutboundForReset now waits for the connections' write buffers to
empty, bounded at 8s with a 1.5s floor because a zero reading taken
immediately after an append means "not yet" rather than "done".

That race is real and was worth fixing on its own, but it is not proven to
be the cause of what was observed - the peer simply not being connected
would look identical. So the departure now reports how many channels and
connections existed at the time: "left: 1, channels: 0" means nobody was
listening, "left: 1, channels: 2" means it was sent. The next test says
which without another round of guessing.

* chore: rebuild bundles for the iOS remount fix

---------

Co-authored-by: Your Name <you@example.com>
2026-07-27 22:41:04 -05:00

51 lines
2.2 KiB
JavaScript

// TODO #118 — in-app "Reset app data". Pure decision extracted to
// src/lib/resetPlan.js. (feature/in-app-reset-data)
const test = require('node:test')
const assert = require('node:assert/strict')
const { resetPlan, DATA_SUBPATHS } = require('../src/lib/resetPlan.js')
test('the default level KEEPS the identity', () => {
assert.equal(resetPlan({}).keepIdentity, true)
assert.equal(resetPlan({}).deleteIdentity, false)
})
test('deleting the identity takes an explicit false, nothing less', () => {
assert.equal(resetPlan({ keepIdentity: false }).deleteIdentity, true)
// Everything else is the safe level. A caller that passes the wrong shape
// must not lose a user's recovery phrase over it.
for (const wrong of [undefined, null, {}, { keepIdentity: undefined }, { keepIdentity: 0 },
{ keepIdentity: '' }, { keepIdentity: 'false' }, { keepIdentity: 'no' }]) {
assert.equal(resetPlan(wrong).deleteIdentity, false,
'expected keep-identity for ' + JSON.stringify(wrong))
}
})
test('a keep-identity reset still wipes the data, it is not a no-op', () => {
const plan = resetPlan({ keepIdentity: true })
assert.ok(plan.subpaths.includes('core'))
assert.ok(plan.subpaths.includes('store'))
})
test("REBUILD LEFTOVERS: the plan covers rebuildLocalDb's staging dirs", () => {
// rebuildLocalDb renames core -> core.old and core.new -> core. An aborted
// run leaves a full readable copy of the old database in one of those, so a
// reset that removed only `core` would leave the "wiped" calendar on disk.
const plan = resetPlan({ keepIdentity: false })
assert.ok(plan.subpaths.includes('core.new'), 'core.new must be removed')
assert.ok(plan.subpaths.includes('core.old'), 'core.old must be removed')
})
test('the path list is a copy, so a caller cannot mutate the shared constant', () => {
const plan = resetPlan({})
plan.subpaths.push('etc')
assert.equal(DATA_SUBPATHS.includes('etc'), false)
assert.equal(resetPlan({}).subpaths.includes('etc'), false)
})
test('every path is relative, so a reset can never escape the data directory', () => {
for (const p of DATA_SUBPATHS) {
assert.equal(p.startsWith('/'), false, p + ' must not be absolute')
assert.equal(p.includes('..'), false, p + ' must not traverse upward')
}
})