From 266d4fc3d92804b5e6eda456b3d302b9a94db9a9 Mon Sep 17 00:00:00 2001 From: oliver <54379826+ok@users.noreply.github.com> Date: Sat, 24 Oct 2026 17:50:44 +0200 Subject: [PATCH 1/2] [fix] Keep a left organization's folder out of own settings A backup that ended after the organization's folder went recorded its repository through the person's own settings, so leaving during a first backup kept backing up to the organization's share. A run's outcome is now recorded only while its folder is still in force. --- CHANGELOG.md | 8 ++++++++ src/worker/backup-service.js | 11 +++++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a643c233..04c8aff6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,14 @@ build-pipeline tweaks, and dependency bumps that don't change how the app behaves are intentionally omitted. Releases that contained only such changes do not appear here. +## Unreleased + +### Fixed + +- **Leaving an organization during its first backup no longer keeps backing + up to the organization's folder.** That folder could become your own + backup folder. + ## v1.13.0 ### 2026-10-24 diff --git a/src/worker/backup-service.js b/src/worker/backup-service.js index caaf78ad..26428ceb 100644 --- a/src/worker/backup-service.js +++ b/src/worker/backup-service.js @@ -262,7 +262,7 @@ export class Backup extends Subsystem { stopAt: () => this.cutoffAt, }) this.lastRunAt = Date.now() - if (this.config.folder !== folder) return + if (!this.stillNames(folder)) return this.failures = 0 if (result.repoId !== repoId) await this.remember({ folder, repoId: result.repoId }) await this.keepKeyInFolder(folder) @@ -280,13 +280,20 @@ export class Backup extends Subsystem { } catch (err) { this.lastRunAt = Date.now() this.dirty = mergeDirty(taken, this.dirty) - if (this.config.folder !== folder) return + if (!this.stillNames(folder)) return this.failures += 1 this.report({ state: 'error', lastError: err.code || CODES.UNKNOWN }) this.log.warn('backup', reason, 'failed:', err.code || err.message) } } + // A run's outcome is recorded only while its folder is still the one in force. The organization's + // folder can go mid-run, before reconsider() catches config up, and recording then would make it + // the person's own. + stillNames(folder) { + return this.config.folder === folder && this.effectiveConfig(this.state).folder === folder + } + // The key this device keeps goes back into the folder when the folder lacks it. A newer key of this // identity in the folder, or one on a device that keeps none (a restored one), is taken instead of // overwritten. Another identity's key is never replaced. From 56d5663085a17046eeb6d898503647c3bf968de3 Mon Sep 17 00:00:00 2001 From: oliver <54379826+ok@users.noreply.github.com> Date: Sat, 24 Oct 2026 17:50:44 +0200 Subject: [PATCH 2/2] [feat] Close backup setup before the first backup runs Setup waited for the whole first backup, so the dialog sat blocked for seconds with no sign of progress. It now closes once the key is written and checked; the first backup runs in the background and Settings shows it running. The button says it is working meanwhile. --- CHANGELOG.md | 6 +++ .../components/modals/BackupSetupModal.tsx | 4 +- src/worker/backup-service.js | 6 ++- test/frontend/scenarios/s160-backup-setup.mjs | 2 + test/integration/backup-protect.test.js | 38 +++++++++++++------ test/integration/managed-backup.test.js | 12 ++++++ 6 files changed, 52 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 04c8aff6..ed18037a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,12 @@ changes do not appear here. ## Unreleased +### Changed + +- **Setting up a backup no longer waits for the first backup.** The dialog + closes once your passphrase is checked, and the first backup runs in the + background while you keep working. Settings → Backup shows its progress. + ### Fixed - **Leaving an organization during its first backup no longer keeps backing diff --git a/src/renderer/components/modals/BackupSetupModal.tsx b/src/renderer/components/modals/BackupSetupModal.tsx index 311c3a94..d7f8087d 100644 --- a/src/renderer/components/modals/BackupSetupModal.tsx +++ b/src/renderer/components/modals/BackupSetupModal.tsx @@ -146,10 +146,10 @@ export default function BackupSetupModal({ isOpen, onClose }: BackupSetupModalPr onConfirmation={setConfirmation} /> {t('backupSetup.keepSafeBody')} - {busy &&

{t('backupSetup.working')}

} + {busy &&

{t('backupSetup.working')}

} - + )} diff --git a/src/worker/backup-service.js b/src/worker/backup-service.js index 26428ceb..5aed663f 100644 --- a/src/worker/backup-service.js +++ b/src/worker/backup-service.js @@ -331,7 +331,8 @@ export class Backup extends Subsystem { } // Set up in one go: the folder, then the recovery key sealed under the passphrase, written into the - // folder and opened back from there with the same passphrase, then the first backup. + // folder and opened back from there with the same passphrase. The first backup starts then and runs + // in the background like any other, so setup answers before it ends; its status reads as running. async setUp(folder, passphrase) { assertPassphrase(passphrase) this.assertFolder(folder) @@ -344,7 +345,8 @@ export class Backup extends Subsystem { const chosen = folderChosen(this.state, now) await this.persist(managed ? { ...chosen, managed: { repoId: null, ...this.state.managed, folder, setupAt: now } } : chosen) await this.configure(folder) - return this.run('setup') + this.runSoon('setup') + return this.status() } async newKey(passphrase) { diff --git a/test/frontend/scenarios/s160-backup-setup.mjs b/test/frontend/scenarios/s160-backup-setup.mjs index f4f71850..1dd23929 100644 --- a/test/frontend/scenarios/s160-backup-setup.mjs +++ b/test/frontend/scenarios/s160-backup-setup.mjs @@ -58,6 +58,8 @@ export default async function s160({ runDir, bootstrap }) { await r.ok('setup takes a folder and a passphrase in two steps', async () => { await A.click({ role: 'button', name: 'Set up backup' }) await setUpBackup(A, backupDir, PASS) + // The dialog closes once the passphrase is checked; the first backup runs on behind it. + await A.waitText('The first backup is running', 8000) await A.waitText("You're protected", 120000) }) await r.ok('the one screen holds the status, the folder and the passphrase', async () => { diff --git a/test/integration/backup-protect.test.js b/test/integration/backup-protect.test.js index 1f94599b..9498097c 100644 --- a/test/integration/backup-protect.test.js +++ b/test/integration/backup-protect.test.js @@ -59,6 +59,13 @@ async function start(t, d, { masterSecret, folder = null, repoId = null, profile return { root, fake } } +// Setup answers before its first backup ends; the tests that need that backup wait for it. +async function setUpBackup(fake, folder) { + await fake.call('backup:setup', { folder, passphrase: PASS }) + await waitFor(async () => (await fake.call('backup:status', {})).lastSuccessAt !== null, 20000, { label: 'the first backup' }) + return fake.call('backup:status', {}) +} + async function codeOf(promise) { try { await promise @@ -83,12 +90,19 @@ test('setup writes the folder, the key it can open, and the first backup', async const { fake } = await start(t, d, { masterSecret: crypto.randomBytes(32) }) t.is((await fake.call('backup:status', {})).verdict, 'at-risk', 'nothing backed up yet') - const status = await fake.call('backup:setup', { folder, passphrase: PASS }) + const answered = await fake.call('backup:setup', { folder, passphrase: PASS }) + t.is(answered.state, 'running', 'setup answers while the first backup runs') + t.is(answered.lastSuccessAt, null) + t.is(answered.verdictReason, 'first-backup') + t.ok(answered.key.checkedAt, 'the key was opened back before setup answered') + t.is(keyFiles(folder).length, 1) + + await waitFor(async () => (await fake.call('backup:status', {})).lastSuccessAt !== null, 20000, { label: 'the first backup' }) + const status = await fake.call('backup:status', {}) t.is(status.verdict, 'protected') t.ok(status.key.reminders) t.is(status.folder, folder) t.is(status.state, 'idle') - t.ok(status.lastSuccessAt, 'the first backup ran') t.ok(status.key.createdAt) t.ok(status.key.inFolder) t.ok(status.key.checkedAt, 'the key was opened back with the passphrase') @@ -112,7 +126,7 @@ test('the passphrase check and a new passphrase', async (t) => { const d = dirs(t, 'keys') const folder = backupFolder(t) const { fake } = await start(t, d, { masterSecret: crypto.randomBytes(32) }) - const first = await fake.call('backup:setup', { folder, passphrase: PASS }) + const first = await setUpBackup(fake, folder) t.is(await codeOf(fake.call('backup:check-key', { passphrase: 'not the passphrase at all' })), 'WRONG_PASSPHRASE') const checked = await fake.call('backup:check-key', { passphrase: PASS }) @@ -147,7 +161,7 @@ test('the last success and the key survive a restart, and a deleted key is put b const folder = backupFolder(t) const masterSecret = crypto.randomBytes(32) const first = await start(t, d, { masterSecret }) - const setUp = await first.fake.call('backup:setup', { folder, passphrase: PASS }) + const setUp = await setUpBackup(first.fake, folder) await first.root.close() fs.rmSync(path.join(folder, KEYS_DIR), { recursive: true, force: true }) @@ -167,7 +181,7 @@ test('REGRESSION (restored device asked for a passphrase): a device with no reco const folder = backupFolder(t) const masterSecret = crypto.randomBytes(32) const first = await start(t, d, { masterSecret }) - const setUp = await first.fake.call('backup:setup', { folder, passphrase: PASS }) + const setUp = await setUpBackup(first.fake, folder) await first.root.close() fs.rmSync(path.join(d.home, BACKUP_STATE_FILE)) @@ -184,7 +198,7 @@ test('a new device restores from the folder with only the passphrase', async (t) const folder = backupFolder(t) const source = dirs(t, 'source') const original = await start(t, source, { masterSecret: crypto.randomBytes(32) }) - const setUp = await original.fake.call('backup:setup', { folder, passphrase: PASS }) + const setUp = await setUpBackup(original.fake, folder) await original.root.close() const device = await start(t, dirs(t, 'device'), { masterSecret: crypto.randomBytes(32), profile: false }) @@ -209,7 +223,7 @@ test('a new device restores from the folder with only the passphrase', async (t) test('a folder that holds another identity\'s backup is refused, and its key is left alone', async (t) => { const folder = backupFolder(t) const other = await start(t, dirs(t, 'other'), { masterSecret: crypto.randomBytes(32) }) - await other.fake.call('backup:setup', { folder, passphrase: PASS }) + await setUpBackup(other.fake, folder) await other.root.close() const theirKey = keyFiles(folder) @@ -223,7 +237,7 @@ test('a key kept for another identity is dropped after an identity change', asyn const d = dirs(t, 'switch') const folder = backupFolder(t) const first = await start(t, d, { masterSecret: crypto.randomBytes(32) }) - const setUp = await first.fake.call('backup:setup', { folder, passphrase: PASS }) + const setUp = await setUpBackup(first.fake, folder) await first.root.close() const other = await start(t, d, { masterSecret: crypto.randomBytes(32), folder, repoId: setUp.repoId, profile: false }) @@ -235,7 +249,7 @@ test('a key kept for another identity is dropped after an identity change', asyn test('a folder keeping only another identity\'s key is refused, and the key stays', async (t) => { const theirs = backupFolder(t) const other = await start(t, dirs(t, 'keyonly-other'), { masterSecret: crypto.randomBytes(32) }) - await other.fake.call('backup:setup', { folder: theirs, passphrase: PASS }) + await setUpBackup(other.fake, theirs) await other.root.close() const folder = backupFolder(t) @@ -252,7 +266,7 @@ test('a newer key of this identity in the folder is taken, not overwritten', asy const d = dirs(t, 'newer') const folder = backupFolder(t) const { fake } = await start(t, d, { masterSecret: crypto.randomBytes(32) }) - await fake.call('backup:setup', { folder, passphrase: PASS }) + await setUpBackup(fake, folder) const createdAt = new Date(Date.now() + 60000).toISOString() const content = await sealRecoveryKey('a newer long passphrase', { createdAt }) @@ -279,7 +293,7 @@ test('the passphrase reminder can be turned off and on, and the verdict follows const folder = backupFolder(t) const masterSecret = crypto.randomBytes(32) const first = await start(t, d, { masterSecret }) - const setUp = await first.fake.call('backup:setup', { folder, passphrase: PASS }) + const setUp = await setUpBackup(first.fake, folder) t.absent((await first.fake.call('backup:reminders', { enabled: false })).key.reminders) t.ok((await first.fake.call('backup:reminders', { enabled: true })).key.reminders) await first.fake.call('backup:reminders', { enabled: false }) @@ -298,7 +312,7 @@ test('after setup the verdict says why it is at risk: a new folder, then a folde const d = dirs(t, 'risk') const folder = backupFolder(t) const { fake } = await start(t, d, { masterSecret: crypto.randomBytes(32) }) - await fake.call('backup:setup', { folder, passphrase: PASS }) + await setUpBackup(fake, folder) const other = backupFolder(t) const moved = await fake.call('backup:configure', { folder: other }) diff --git a/test/integration/managed-backup.test.js b/test/integration/managed-backup.test.js index f6798431..993cb22d 100644 --- a/test/integration/managed-backup.test.js +++ b/test/integration/managed-backup.test.js @@ -83,6 +83,17 @@ test('leaving the organization brings back the person\'s own (empty) backup sett t.ok(fs.existsSync(p.view.backupFolder), 'the backup on the share stays') }) +test('REGRESSION (FIX-5: left mid-run): leaving during the first backup never makes the organization\'s folder the person\'s own', async (t) => { + const share = tmpDir('managed-share-midrun', t) + const p = await setup(t, { share, required: false }) + const started = await p.fake.call('backup:setup', { folder: p.view.backupFolder, passphrase: PASS }) + t.is(started.state, 'running', 'the first backup is still running') + await p.fake.call('managed:leave', {}) + await waitFor(async () => (await p.fake.call('backup:status', {})).state !== 'running', 20000, { label: 'the first backup ended' }) + t.alike(p.ownSettings(), { folder: null, repoId: null }, 'the person\'s own setting stays empty') + t.is((await p.fake.call('backup:status', {})).folder, null) +}) + test('REGRESSION (FIX-4: inherited setup): a device backing up to its own folder is not set up on the organization\'s', async (t) => { const share = tmpDir('managed-share-own', t) const peer = await freshPeer(t) @@ -91,6 +102,7 @@ test('REGRESSION (FIX-4: inherited setup): a device backing up to its own folder setRuntimeConfig({ ...getRuntimeConfig(), managedTestSigningKey: SIGNER.publicHex, managedAllowHttpLoopback: true, managedPreviewTimeoutMs: 1500, managedHttpsPollMs: 60_000 }) await createSpace('Projects') await peer.fake.call('backup:setup', { folder: tmpDir('managed-own-folder', t), passphrase: PASS }) + await waitFor(async () => (await peer.fake.call('backup:status', {})).lastSuccessAt !== null, 20000, { label: 'a backup on the own folder' }) const api = await startManagedConsole(t) const org = organization({ apiUrl: api.apiUrl }) const settings = { [backupFolderKey(devicePlatform())]: `${share}/{user}/{install}` }