electron/company-backup-schedule.node-test.mjs
Rama main · réplica de lectura del repositorio original.
import assert from "node:assert/strict";
import { test } from "node:test";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { createCompanyBackupSchedule } from "./company-backup-schedule.mjs";
import { createManagedDesktopStore } from "./managed-desktop.mjs";
const DAY = 24 * 60 * 60_000, HOUR = DAY / 24;
const ENABLE = { enabled: true, confirmation: "BACK UP THIS WORKSPACE DAILY" };
const REMOVED = "The saved daily backup schedule couldn't be read, so it was turned off. Turn daily backups on again.";
const WAITING = "The saved daily backup schedule can't be read right now. OpenMausBot tries again every minute.";
const RESUME_DELAY = 15 * 60_000;
const deferred = () => { let resolve; const promise = new Promise(yes => { resolve = yes; }); return { promise, resolve }; };
const turn = () => new Promise(resolve => setImmediate(resolve));
function fixture(saved = null) {
const f = { now: 1_800_000_000_000, scope: { key: "fixture-portal:org:email:device:workspace", generation: 1 },
saved, calls: [], states: [], writes: [], timers: new Map(), failWrite: false, blockWrite: null,
run: async () => {}, read: async () => f.saved };
let nextTimer = 0, tail = Promise.resolve();
f.scheduler = createCompanyBackupSchedule({
store: { read: () => f.read(), write: value => {
const copy = structuredClone(value), block = f.blockWrite; f.blockWrite = null;
const operation = tail.catch(() => {}).then(async () => {
if (block) await block.promise;
if (f.failWrite) throw new Error("fixture secret should never escape");
f.saved = copy; f.writes.push(copy);
});
tail = operation; return operation;
} },
scope: () => f.scope, now: () => f.now,
run: (...args) => { f.calls.push(args); return f.run(...args); },
onState: state => f.states.push(state),
setTimer: (callback, delay) => { const id = ++nextTimer; f.timers.set(id, { callback, delay }); return id; },
clearTimer: id => f.timers.delete(id),
});
f.fire = async (advance = 0) => {
f.now += advance;
assert.equal(f.timers.size, 1, "one scheduled wakeup only");
const [id, timer] = [...f.timers][0]; f.timers.delete(id);
await timer.callback();
};
return f;
}
test("fresh/personal startup is off, reads only, and never creates a timer or backup", async () => {
const f = fixture(); f.scope = null;
assert.deepEqual(await f.scheduler.start(), { enabled: false, status: "off" });
assert.equal(f.writes.length, 0); assert.equal(f.timers.size, 0); assert.equal(f.calls.length, 0);
});
test("exact consent is required and first backup waits a full day", async () => {
const f = fixture(); await f.scheduler.start();
for (const input of [{ enabled: true }, { ...ENABLE, confirmation: "yes" }, { ...ENABLE, password: "short" }, { ...ENABLE, interval: 1 }]) {
await assert.rejects(f.scheduler.configure(input));
}
const state = await f.scheduler.configure(ENABLE);
assert.equal(state.nextBackupAt, f.now + DAY); assert.equal(f.calls.length, 0);
assert.equal(Object.hasOwn(f.saved, "password"), false);
await f.fire(DAY - 1); assert.equal(f.calls.length, 0);
await f.fire(1); assert.equal(f.calls.length, 1);
assert.equal(f.scheduler.state().lastBackupAt, f.now);
assert.equal(f.saved.nextBackupAt, f.now + DAY);
});
test("restart catches up one missed daily snapshot, not every missed day", async () => {
const original = fixture(); await original.scheduler.configure(ENABLE);
const f = fixture(original.saved); f.now += 10 * DAY;
await f.scheduler.start();
assert.equal(f.calls.length, 1); assert.equal(f.saved.nextBackupAt, f.now + DAY);
await f.fire(); assert.equal(f.calls.length, 1);
});
test("a failed transfer persists an hourly retry before dispatch; refresh cannot accelerate it", async () => {
const f = fixture(); await f.scheduler.configure(ENABLE);
f.run = async () => { assert.equal(f.saved.nextBackupAt, f.now + HOUR); throw new Error("fixture private failure"); };
await f.fire(DAY);
assert.equal(f.scheduler.state().status, "error");
assert(!JSON.stringify(f.states).includes("fixture private failure"));
for (let i = 0; i < 5; i++) { f.scheduler.reconcile(); await f.fire(); }
assert.equal(f.calls.length, 1);
const restarted = fixture(f.saved); restarted.now = f.now;
await restarted.scheduler.start(); assert.equal(restarted.calls.length, 0);
});
test("busy workspace defers rather than marking success", async () => {
const f = fixture(); await f.scheduler.configure(ENABLE);
f.run = async () => { throw Object.assign(new Error("busy"), { code: "workspace_busy" }); };
await f.fire(DAY);
assert.equal(f.scheduler.state().status, "paused"); assert.equal(f.saved.lastBackupAt, undefined);
assert.equal(f.saved.nextBackupAt, f.now + HOUR);
});
test("missing connection pauses and account/workspace changes forget the encrypted secret", async () => {
const f = fixture(); await f.scheduler.configure(ENABLE);
const scope = f.scope; f.scope = null;
await f.fire(DAY); assert.equal(f.calls.length, 0); assert.equal(f.scheduler.state().status, "paused");
f.scope = { ...scope, key: "another-account-or-workspace" };
f.scheduler.reconcile(); await turn();
assert.equal(f.saved, null); assert.equal(f.scheduler.state().enabled, false); assert.equal(f.timers.size, 0);
});
test("disable cancels its in-flight transfer and late completion cannot restore secret or success", async () => {
const f = fixture(), completion = deferred(), started = deferred();
f.run = (signal) => { started.resolve(signal); return completion.promise; };
await f.scheduler.configure(ENABLE); const operation = f.fire(DAY); const signal = await started.promise;
await f.scheduler.configure({ enabled: false }); assert.equal(signal.aborted, true);
completion.resolve(); await operation;
assert.equal(f.saved, null); assert.deepEqual(f.scheduler.state(), { enabled: false, status: "off" }); assert.equal(f.timers.size, 0);
});
test("disable while enabling is pending wins the serialized durable write", async () => {
const f = fixture(), blocked = deferred(); f.blockWrite = blocked;
const enabling = f.scheduler.configure(ENABLE);
const disabling = f.scheduler.configure({ enabled: false });
blocked.resolve(); await Promise.all([enabling, disabling]);
assert.equal(f.saved, null); assert.equal(f.scheduler.state().enabled, false); assert.equal(f.timers.size, 0);
});
test("disable while the pre-transfer reservation is pending prevents all transfer work", async () => {
const f = fixture(), blocked = deferred(); await f.scheduler.configure(ENABLE); f.blockWrite = blocked;
const firing = f.fire(DAY); const disabling = f.scheduler.configure({ enabled: false });
blocked.resolve(); await Promise.all([firing, disabling]);
assert.equal(f.saved, null); assert.equal(f.calls.length, 0);
});
test("connection generation changes during password-store await prevent old transfer", async () => {
const f = fixture(), blocked = deferred(); await f.scheduler.configure(ENABLE); f.blockWrite = blocked;
const firing = f.fire(DAY); f.scope.generation++; blocked.resolve(); await firing;
assert.equal(f.calls.length, 0);
});
test("late startup read cannot resurrect a disabled record", async () => {
const f = fixture(), loading = deferred(); await f.scheduler.configure(ENABLE); const saved = f.saved;
const next = fixture(saved); next.read = () => loading.promise;
const starting = next.scheduler.start(); await next.scheduler.configure({ enabled: false });
loading.resolve(saved); await starting;
assert.equal(next.saved, null); assert.equal(next.scheduler.state().enabled, false); assert.equal(next.timers.size, 0);
});
test("locked or malformed storage is fail-closed and secret-free", async () => {
for (const saved of [{ version: 1 }, { version: 1, scope: "scope", password: "synthetic old password", nextBackupAt: -1 }]) {
const f = fixture(saved); await f.scheduler.start();
assert.deepEqual(f.scheduler.state(), { enabled: false, status: "off", message: REMOVED });
assert.equal(f.saved, null); assert.equal(f.calls.length, 0); assert.equal(f.timers.size, 0);
}
const f = fixture(); f.failWrite = true;
await assert.rejects(f.scheduler.configure(ENABLE));
assert.equal(f.scheduler.state().enabled, false); assert.equal(f.timers.size, 0);
});
test("a saved schedule this computer can no longer decrypt is removed, and turning backups on again is the one next step", async t => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "omb-schedule-unreadable-")); t.after(() => fs.rm(root, { recursive: true, force: true }));
const file = path.join(root, "company-backup-schedule.bin");
const encryption = { available: async () => true, encrypt: async text => Buffer.from(text).map(byte => byte ^ 0x33),
decrypt: async () => { throw new Error("Error while decrypting the ciphertext provided to safeStorage.decryptString."); } };
const saved = { version: 2, scope: "fixture-portal:org:email:device:workspace", nextBackupAt: 1_800_000_000_000 + DAY };
await createManagedDesktopStore({ file, encryption }).write(saved);
const f = fixture(saved); const real = createManagedDesktopStore({ file, encryption }); f.read = () => real.read();
await f.scheduler.start();
assert.deepEqual(f.scheduler.state(), { enabled: false, status: "off", message: REMOVED });
assert.deepEqual(f.writes, [null]); assert.equal(f.timers.size, 0); assert.equal(f.calls.length, 0);
await f.scheduler.configure(ENABLE); assert.equal(f.scheduler.state().status, "waiting");
});
test("a locked keychain never removes the saved schedule: it stays on, is read again sooner then each minute, until it opens", async () => {
const original = fixture(); await original.scheduler.configure(ENABLE);
const f = fixture(original.saved), stored = f.saved; let locked = true, reads = 0;
f.read = async () => { reads++; if (locked) throw new Error("Your company connection could not be read. Unlock your system keychain and try again."); return stored; };
await f.scheduler.start();
assert.deepEqual(f.scheduler.state(), { enabled: true, status: "paused", message: WAITING });
assert.equal(f.saved, stored); assert.deepEqual(f.writes, []);
for (const delay of [15_000, 30_000, 60_000, 60_000]) {
assert.equal([...f.timers.values()][0].delay, delay);
const before = reads; await f.fire(delay); assert.equal(reads, before + 1);
assert.equal(f.scheduler.state().enabled, true);
}
f.scheduler.reconcile(); assert.equal(f.timers.size, 1, "a connection change does not cancel the next read");
assert.equal(f.states.filter(state => state.message === WAITING).length, 1, "said once, not on every try");
assert.deepEqual(f.writes, [], "a locked schedule is never removed");
locked = false; await f.fire(60_000);
assert.equal(f.scheduler.state().status, "waiting"); assert.equal(f.scheduler.state().enabled, true);
assert.equal(f.scheduler.state().nextBackupAt, stored.nextBackupAt);
});
test("turning daily backups off while the saved schedule is locked removes it, and nothing reads it again", async () => {
const original = fixture(); await original.scheduler.configure(ENABLE);
const f = fixture(original.saved);
f.read = async () => { throw new Error("Your company connection could not be read. Unlock your system keychain and try again."); };
await f.scheduler.start();
assert.equal(f.scheduler.state().enabled, true);
assert.deepEqual(await f.scheduler.configure({ enabled: false }), { enabled: false, status: "off" });
assert.deepEqual(f.writes, [null]); assert.equal(f.saved, null);
assert.equal(f.timers.size, 0, "no read is left to bring it back"); assert.equal(f.calls.length, 0);
});
test("a locked schedule that opens later never uploads an overdue backup at once: it shows first, then waits", async () => {
const original = fixture(); await original.scheduler.configure(ENABLE);
const f = fixture(original.saved), stored = f.saved; let locked = true;
f.read = async () => { if (locked) throw new Error("Your company connection could not be read. Unlock your system keychain and try again."); return stored; };
await f.scheduler.start();
f.now += 2 * DAY; locked = false; await f.fire();
assert.equal(f.calls.length, 0, "nothing uploads the moment the schedule opens");
assert.equal(f.scheduler.state().status, "waiting"); assert.equal(f.scheduler.state().enabled, true);
const [, timer] = [...f.timers][0];
assert.equal(timer.delay, RESUME_DELAY);
await f.fire(RESUME_DELAY); assert.equal(f.calls.length, 1, "then the one overdue backup runs");
});
test("existing daily consent migrates without retaining the old archive password", async () => {
const f = fixture(); await f.scheduler.configure(ENABLE);
const next = fixture({ ...f.saved, version: 1, password: "old synthetic secret" });
await next.scheduler.start();
assert.equal(next.scheduler.state().enabled, true);
assert.equal(next.saved.version, 2);
assert.equal(Object.hasOwn(next.saved, "password"), false);
assert.equal(next.calls.length, 0);
});
test("failed disable reports failure instead of claiming the persisted secret was forgotten", async () => {
const f = fixture(); await f.scheduler.configure(ENABLE); f.failWrite = true;
await assert.rejects(f.scheduler.configure({ enabled: false }));
assert.equal(f.scheduler.state().status, "error"); assert.match(f.scheduler.state().message, /could not be cleared/);
assert.equal(f.timers.size, 0);
});
test("shutdown cancels transfer/timers and prevents late writes", async () => {
const f = fixture(), completion = deferred(), started = deferred();
f.run = (signal) => { started.resolve(signal); return completion.promise; };
await f.scheduler.configure(ENABLE); const firing = f.fire(DAY); const signal = await started.promise;
const writes = f.writes.length; f.scheduler.close(); completion.resolve(); await firing;
assert.equal(signal.aborted, true); assert.equal(f.writes.length, writes); assert.equal(f.timers.size, 0);
await assert.rejects(f.scheduler.configure(ENABLE));
});
test("clock rollback never causes an early snapshot or an unbounded timer", async () => {
const original = fixture(); await original.scheduler.configure(ENABLE);
const f = fixture(original.saved); f.now -= DAY * 10; await f.scheduler.start();
assert.equal(f.calls.length, 0);
assert([...f.timers.values()].every(timer => timer.delay <= DAY && timer.delay >= 1000));
});
// Mirrors main.mjs companyBackupScope: any deviceId, same person, organisation and folder.
const scopeFor = (org = "org", generation = 1) => ({ key: JSON.stringify(["portal", org, "email", "workspace"]), generation,
adopts: saved => { try { const v = JSON.parse(saved); return Array.isArray(v) && v.length === 5 && v[0] === "portal" && v[1] === org && v[2] === "email" && v[4] === "workspace"; } catch { return false; } } });
const legacyKey = device => JSON.stringify(["portal", "org", "email", device, "workspace"]);
test("a schedule saved under an old device-scoped key survives the upgrade, even for an enrollment that has since expired", async () => {
// Saved for device D1; D1 then expired and this computer re-enrolled as D2.
const f = fixture({ version: 2, scope: legacyKey("device-1"), nextBackupAt: 1_800_000_000_000 + HOUR });
f.scope = scopeFor();
const state = await f.scheduler.start();
assert.equal(state.enabled, true); assert.equal(state.status, "waiting");
assert.equal(f.saved.scope, scopeFor().key, "the saved key is rewritten, not forgotten");
f.scope = null; f.scheduler.reconcile();
assert.equal(f.scheduler.state().enabled, true);
f.scope = scopeFor("other-org", 3);
f.scheduler.reconcile(); await turn(); await turn();
assert.equal(f.saved, null); assert.equal(f.scheduler.state().enabled, false);
});
test("reconcile adopts an old key of the same person through the write queue", async () => {
const f = fixture(); f.scope = { key: legacyKey("device-1"), generation: 1 };
await f.scheduler.start(); await f.scheduler.configure(ENABLE);
assert.equal(f.saved.scope, legacyKey("device-1"));
f.scope = scopeFor("org", 2);
f.scheduler.reconcile(); await turn(); await turn();
assert.equal(f.saved.scope, scopeFor().key); assert.equal(f.scheduler.state().enabled, true);
});
test("an overdue backup waits after the company connection returns instead of uploading at once", async () => {
const f = fixture({ version: 2, scope: scopeFor().key, nextBackupAt: 1_800_000_000_000 - DAY });
f.scope = null;
await f.scheduler.start();
assert.equal(f.scheduler.state().status, "paused");
f.scope = scopeFor(); f.scheduler.reconcile();
const [, timer] = [...f.timers][0];
assert.ok(timer.delay >= 15 * 60_000, `delay ${timer.delay}`);
assert.equal(f.calls.length, 0);
});