Files
Multi-Hoster-Upload/tests/online-backup-manager.test.js
T

310 lines
10 KiB
JavaScript

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']);
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('keeps a committed create successful without a post-commit keyring read', async () => {
const fixture = createFixture({ initialKey: null });
fixture.keyring.list = async () => {
fixture.events.push('list');
throw new Error('list unavailable after commit');
};
const result = await fixture.manager.createManaged();
assert.deepEqual(fixture.events, ['prepare', 'upload', 'commit']);
assert.deepEqual(result, {
ok: true,
entry: {
id,
displayKey: `${key.slice(0, 9)}${key.slice(-4)}`,
createdAt: fixture.createdAt
}
});
});
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',
'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);
});
});