Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/renderer/components/modals/BackupSetupModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -146,10 +146,10 @@ export default function BackupSetupModal({ isOpen, onClose }: BackupSetupModalPr
onConfirmation={setConfirmation}
/>
<Callout tone="warning" title={t('backupSetup.keepSafeTitle')}>{t('backupSetup.keepSafeBody')}</Callout>
{busy && <p role="status" className="text-sm text-on-surface-variant">{t('backupSetup.working')}</p>}
{busy && <p role="status" className="sr-only">{t('backupSetup.working')}</p>}
<ModalFooter>
<Button variant="secondary" size="lg" onClick={() => { setStep(1); setError(null) }} disabled={busy}>{t('actions.back')}</Button>
<Button size="lg" icon="shield" onClick={() => void turnOn()} disabled={verdict !== 'ok'} ariaDisabled={busy}>{t('backupSetup.turnOn')}</Button>
<Button size="lg" icon="shield" onClick={() => void turnOn()} disabled={verdict !== 'ok'} ariaDisabled={busy}>{t(busy ? 'backupSetup.working' : 'backupSetup.turnOn')}</Button>
</ModalFooter>
</>
)}
Expand Down
17 changes: 13 additions & 4 deletions src/worker/backup-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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.
Expand Down Expand Up @@ -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)
Expand All @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions test/frontend/scenarios/s160-backup-setup.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
38 changes: 26 additions & 12 deletions test/integration/backup-protect.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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')
Expand All @@ -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 })
Expand Down Expand Up @@ -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 })
Expand All @@ -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))
Expand All @@ -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 })
Expand All @@ -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)

Expand All @@ -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 })
Expand All @@ -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)
Expand All @@ -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 })
Expand All @@ -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 })
Expand All @@ -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 })
Expand Down
12 changes: 12 additions & 0 deletions test/integration/managed-backup.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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}` }
Expand Down
Loading