diff --git a/lib/online-backup-manager.js b/lib/online-backup-manager.js new file mode 100644 index 0000000..17723c9 --- /dev/null +++ b/lib/online-backup-manager.js @@ -0,0 +1,112 @@ +const { + createOnlineBackup, + deleteOnlineBackup, + uploadOnlineBackup +} = require('./online-backup'); + +const ERRORS = Object.freeze({ + list: 'Online-Sicherungen konnten nicht geladen werden', + create: 'Online-Sicherung konnte nicht erstellt werden', + copy: 'Online-Sicherungsschlüssel konnte nicht kopiert werden', + delete: 'Online-Sicherung konnte nicht gelöscht werden', + notFound: 'Online-Sicherungsschlüssel wurde nicht gefunden' +}); + +function sanitizeEntry(entry) { + return { + id: entry.id, + displayKey: entry.displayKey, + createdAt: entry.createdAt + }; +} + +function createOnlineBackupManager({ + keyring, + loadSettings, + appVersion, + createBackup = createOnlineBackup, + uploadBackup = uploadOnlineBackup, + deleteBackup = deleteOnlineBackup, + copyText +}) { + let mutation = Promise.resolve(); + + function serialize(operation) { + const next = mutation.catch(() => {}).then(operation); + mutation = next; + return next; + } + + async function listEntries() { + const entries = await keyring.list(); + return entries.map(sanitizeEntry); + } + + async function createTransaction() { + const createdAt = new Date().toISOString(); + const settings = await loadSettings(); + const created = createBackup(settings, appVersion(), createdAt); + const prepared = keyring.prepare(created.key, createdAt); + await uploadBackup(created.record); + try { + await keyring.commit(prepared); + } catch (error) { + try { + await deleteBackup(created.key); + } catch {} + throw error; + } + const entries = await listEntries(); + const entry = entries.find((current) => current.id === prepared.id); + if (!entry) throw new Error(ERRORS.create); + return { ok: true, entry }; + } + + async function deleteTransaction(id) { + const key = await keyring.getKey(id); + if (!key) return { ok: false, notFound: true, error: ERRORS.notFound }; + const outcome = await deleteBackup(key); + if (!outcome?.deleted && !outcome?.notFound) throw new Error(ERRORS.delete); + await keyring.remove(id); + return { ok: true, removedId: id, notFound: outcome.notFound }; + } + + async function listManaged() { + try { + return { ok: true, entries: await listEntries() }; + } catch { + return { ok: false, error: ERRORS.list }; + } + } + + async function createManaged() { + try { + return await serialize(createTransaction); + } catch { + return { ok: false, error: ERRORS.create }; + } + } + + async function copyManaged(id) { + try { + const key = await keyring.getKey(id); + if (!key) return { ok: false, notFound: true, error: ERRORS.notFound }; + await copyText(key); + return { ok: true }; + } catch { + return { ok: false, error: ERRORS.copy }; + } + } + + async function deleteManaged(id) { + try { + return await serialize(() => deleteTransaction(id)); + } catch { + return { ok: false, error: ERRORS.delete }; + } + } + + return Object.freeze({ listManaged, createManaged, copyManaged, deleteManaged }); +} + +module.exports = { createOnlineBackupManager }; diff --git a/tests/online-backup-manager.test.js b/tests/online-backup-manager.test.js new file mode 100644 index 0000000..ca6c52d --- /dev/null +++ b/tests/online-backup-manager.test.js @@ -0,0 +1,290 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); + +const { createOnlineBackupManager } = require('../lib/online-backup-manager'); + +const key = `MHU2-${'K'.repeat(70)}`; +const id = 'managed-backup-id'; +const record = { id, blob: 'encrypted-blob', deleteVerifier: 'delete-verifier' }; +const settings = { globalSettings: { alwaysOnTop: true } }; + +function createFixture(overrides = {}) { + const events = []; + const state = new Map(); + const entries = new Map(); + if (overrides.initialKey !== null) { + state.set(id, overrides.initialKey || key); + entries.set(id, { + id, + displayKey: `${key.slice(0, 9)}…${key.slice(-4)}`, + createdAt: '2026-08-22T10:00:00.000Z' + }); + } + let createdAt; + let createArguments; + const keyring = { + list: async () => { + events.push('list'); + return [...entries.values()]; + }, + prepare: (value, timestamp) => { + events.push('prepare'); + if (overrides.prepareError) throw overrides.prepareError; + createdAt = timestamp; + return { id, encryptedKey: 'encrypted-key', createdAt: timestamp }; + }, + commit: async (entry) => { + events.push('commit'); + if (overrides.commitError) throw overrides.commitError; + state.set(entry.id, key); + entries.set(entry.id, { + id: entry.id, + displayKey: `${key.slice(0, 9)}…${key.slice(-4)}`, + createdAt: entry.createdAt + }); + }, + remove: async (entryId) => { + events.push('remove'); + const existed = state.delete(entryId); + entries.delete(entryId); + return existed; + }, + getKey: async (entryId) => { + events.push('getKey'); + return state.get(entryId) || null; + } + }; + const manager = createOnlineBackupManager({ + keyring, + loadSettings: async () => settings, + appVersion: () => '2.1.31', + createBackup: (...args) => { + createArguments = args; + return { key, record }; + }, + uploadBackup: async (uploadedRecord) => { + events.push('upload'); + assert.equal(uploadedRecord, record); + if (overrides.uploadError) throw overrides.uploadError; + }, + deleteBackup: async (value) => { + events.push(`delete:${value}`); + if (overrides.deleteError) throw overrides.deleteError; + return overrides.deletionOutcome || { deleted: true, notFound: false }; + }, + copyText: (value) => events.push(`copy:${value}`) + }); + return { + events, + keyring, + manager, + state, + get createdAt() { + return createdAt; + }, + get createArguments() { + return createArguments; + } + }; +} + +describe('transactional online backup manager', () => { + it('creates in prepare, upload, commit order and returns only the sanitized entry', async () => { + const fixture = createFixture({ initialKey: null }); + + const result = await fixture.manager.createManaged(); + + assert.deepEqual(fixture.events, ['prepare', 'upload', 'commit', 'list']); + assert.deepEqual(fixture.createArguments, [settings, '2.1.31', fixture.createdAt]); + assert.match(fixture.createdAt, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); + assert.deepEqual(result, { + ok: true, + entry: { + id, + displayKey: `${key.slice(0, 9)}…${key.slice(-4)}`, + createdAt: fixture.createdAt + } + }); + assert.equal(JSON.stringify(result).includes(key), false); + }); + + it('does not upload when preparing encrypted local state fails', async () => { + const fixture = createFixture({ + initialKey: null, + prepareError: new Error(`prepare failed with ${key}`) + }); + + const result = await fixture.manager.createManaged(); + + assert.deepEqual(fixture.events, ['prepare']); + assert.deepEqual(result, { ok: false, error: 'Online-Sicherung konnte nicht erstellt werden' }); + assert.equal(JSON.stringify(result).includes(key), false); + }); + + it('does not commit when the upload fails', async () => { + const fixture = createFixture({ + initialKey: null, + uploadError: new Error(`upload failed with ${key}`) + }); + + const result = await fixture.manager.createManaged(); + + assert.deepEqual(fixture.events, ['prepare', 'upload']); + assert.deepEqual(result, { ok: false, error: 'Online-Sicherung konnte nicht erstellt werden' }); + assert.equal(JSON.stringify(result).includes(key), false); + }); + + it('rolls the remote backup back with the in-memory key when commit fails', async () => { + const fixture = createFixture({ + initialKey: null, + commitError: new Error(`commit failed with ${key}`) + }); + + const result = await fixture.manager.createManaged(); + + assert.deepEqual(fixture.events, ['prepare', 'upload', 'commit', `delete:${key}`]); + assert.deepEqual(result, { ok: false, error: 'Online-Sicherung konnte nicht erstellt werden' }); + assert.equal(JSON.stringify(result).includes(key), false); + }); + + it('keeps a commit failure sanitized when best-effort rollback also fails', async () => { + const fixture = createFixture({ + initialKey: null, + commitError: new Error(`commit failed with ${key}`), + deleteError: new Error(`rollback failed with ${key}`) + }); + + const result = await fixture.manager.createManaged(); + + assert.deepEqual(fixture.events, ['prepare', 'upload', 'commit', `delete:${key}`]); + assert.deepEqual(result, { ok: false, error: 'Online-Sicherung konnte nicht erstellt werden' }); + assert.equal(JSON.stringify(result).includes(key), false); + }); + + it('decrypts a managed key only inside copy and returns no key', async () => { + const fixture = createFixture(); + + const result = await fixture.manager.copyManaged(id); + + assert.deepEqual(fixture.events, ['getKey', `copy:${key}`]); + assert.deepEqual(result, { ok: true }); + assert.equal(JSON.stringify(result).includes(key), false); + }); + + it('removes the local entry after a successful remote deletion', async () => { + const fixture = createFixture(); + + const result = await fixture.manager.deleteManaged(id); + + assert.deepEqual(fixture.events, ['getKey', `delete:${key}`, 'remove']); + assert.deepEqual(result, { ok: true, removedId: id, notFound: false }); + assert.equal(fixture.state.has(id), false); + assert.equal(JSON.stringify(result).includes(key), false); + }); + + it('removes a stale local entry and reports a missing remote backup', async () => { + const fixture = createFixture({ deletionOutcome: { deleted: false, notFound: true } }); + + const result = await fixture.manager.deleteManaged(id); + + assert.deepEqual(fixture.events, ['getKey', `delete:${key}`, 'remove']); + assert.deepEqual(result, { ok: true, removedId: id, notFound: true }); + assert.equal(fixture.state.has(id), false); + }); + + it('keeps the local entry when remote deletion fails', async () => { + const fixture = createFixture({ deleteError: new Error(`network failed with ${key}`) }); + + const result = await fixture.manager.deleteManaged(id); + + assert.deepEqual(fixture.events, ['getKey', `delete:${key}`]); + assert.deepEqual(result, { ok: false, error: 'Online-Sicherung konnte nicht gelöscht werden' }); + assert.equal(fixture.state.has(id), true); + assert.equal(JSON.stringify(result).includes(key), false); + }); + + it('does not call copy or remote deletion dependencies for unknown IDs', async () => { + const fixture = createFixture({ initialKey: null }); + + const copied = await fixture.manager.copyManaged('unknown-id'); + const deleted = await fixture.manager.deleteManaged('unknown-id'); + + assert.deepEqual(fixture.events, ['getKey', 'getKey']); + assert.deepEqual(copied, { + ok: false, + notFound: true, + error: 'Online-Sicherungsschlüssel wurde nicht gefunden' + }); + assert.deepEqual(deleted, { + ok: false, + notFound: true, + error: 'Online-Sicherungsschlüssel wurde nicht gefunden' + }); + }); + + it('serializes concurrent create and delete mutations in request order', async () => { + const fixture = createFixture({ initialKey: null }); + let releaseUpload; + fixture.manager = createOnlineBackupManager({ + keyring: fixture.keyring, + loadSettings: async () => settings, + appVersion: () => '2.1.31', + createBackup: (_settings, _version, exportedAt) => { + fixture.createdTimestamp = exportedAt; + return { key, record }; + }, + uploadBackup: async () => { + fixture.events.push('upload:start'); + await new Promise((resolve) => { + releaseUpload = resolve; + }); + fixture.events.push('upload:end'); + }, + deleteBackup: async (value) => { + fixture.events.push(`delete:${value}`); + return { deleted: true, notFound: false }; + }, + copyText: () => {} + }); + + const creating = fixture.manager.createManaged(); + const deleting = fixture.manager.deleteManaged(id); + await new Promise((resolve) => setImmediate(resolve)); + + assert.deepEqual(fixture.events, ['prepare', 'upload:start']); + releaseUpload(); + const [created, deleted] = await Promise.all([creating, deleting]); + + assert.equal(created.ok, true); + assert.deepEqual(deleted, { ok: true, removedId: id, notFound: false }); + assert.deepEqual(fixture.events, [ + 'prepare', + 'upload:start', + 'upload:end', + 'commit', + 'list', + 'getKey', + `delete:${key}`, + 'remove' + ]); + }); + + it('lists sanitized entries and contains dependency failures at the public boundary', async () => { + const fixture = createFixture(); + + assert.deepEqual(await fixture.manager.listManaged(), { + ok: true, + entries: [{ + id, + displayKey: `${key.slice(0, 9)}…${key.slice(-4)}`, + createdAt: '2026-08-22T10:00:00.000Z' + }] + }); + fixture.keyring.list = async () => { + throw new Error(`list failed with ${key}`); + }; + const failed = await fixture.manager.listManaged(); + assert.deepEqual(failed, { ok: false, error: 'Online-Sicherungen konnten nicht geladen werden' }); + assert.equal(JSON.stringify(failed).includes(key), false); + }); +});