diff --git a/CHANGELOG.md b/CHANGELOG.md index a643c233..ed18037a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,20 @@ 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 + +### 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 + up to the organization's folder.** That folder could become your own + backup folder. + ## v1.13.0 ### 2026-10-24 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 caaf78ad..5aed663f 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. @@ -324,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) @@ -337,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}` }