feat: coordinate managed online backup keys
This commit is contained in:
@@ -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 };
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user