Files
webmail/lib/shared-store/__tests__/import-data.test.ts
T
Linus Rath 8523289a4b feat: let an admin wipe a lost device, and clear an account the server refuses for good
Until now a lost laptop kept its offline mail for as long as anyone could
open it, and an account whose login the server had ended kept its copy too.

- `lib/devices`, `app/api/devices`: browsers with offline on and desktop
  apps register, in a durable collection with no expiry. A check-in goes
  by a per-device secret, so a revoked device still hears its wipe.
- `app/api/admin/devices` and the org devices page: wipe one device or all
  of a user's, with audit entries for each request and each confirmation.
- `lib/offline/device-wipe.ts`: a start finishes any wipe cut short, then
  waits up to a second for the instance before showing the local copy. A
  wipe takes the store, keys, plugin databases and handoffs, signs out,
  and reports back, again at the next contact if the report failed.
- `lib/jmap/client.ts`, `stores/auth-store.ts`: only `invalid_grant`, the
  session limit, or a 401 lasting past a re-auth count as refused. A 500,
  a proxy page or an outage never do.
- Settings: "Remove everything from this device", with a confirmation.

`lib/shared-store/__tests__/import-data.test.ts` changes its assertion
because it lists every collection the import copies, and `devices` is new.
2026-11-03 13:19:44 +01:00

109 lines
5.2 KiB
TypeScript

/**
* @vitest-environment node
*/
import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { setStoreForTests } from '../index';
import { resetLocalStoreForTests } from '../local';
import { separateDiskStore, type SeparateDiskStore } from '@/lib/__tests__/helpers/separate-disk-store';
import { steps } from '../import/data';
import { SETTINGS_COLLECTION } from '@/lib/settings-sync';
import { VAULT_COLLECTION } from '@/lib/plugin-secrets/vault';
import { ARCHIVE_COLLECTION } from '@/lib/account-archive-storage';
import { TELEMETRY_COLLECTION } from '@/lib/telemetry/state';
import { VERSION_CHECK_COLLECTION } from '@/lib/version-check/state';
import { DEVICES_COLLECTION } from '@/lib/devices/registry';
// An existing single instance's settings, plugin credentials, archives,
// telemetry and update-check state go into an empty shared store once, as
// they are; the local files stay untouched.
let data: string;
let store: SeparateDiskStore;
const hash = (c: string) => c.repeat(64);
function snapshot(dir: string): Record<string, string> {
const out: Record<string, string> = {};
const walk = (d: string) => {
for (const name of readdirSync(d)) {
const full = path.join(d, name);
if (statSync(full).isDirectory()) walk(full);
else out[path.relative(dir, full)] = readFileSync(full).toString('base64');
}
};
walk(dir);
return out;
}
beforeEach(() => {
data = mkdtempSync(path.join(tmpdir(), 'bw-import-data-'));
vi.stubEnv('SETTINGS_DATA_DIR', path.join(data, 'settings'));
vi.stubEnv('PLUGIN_SECRETS_DIR', path.join(data, 'plugin-secrets'));
vi.stubEnv('TELEMETRY_DATA_DIR', path.join(data, 'telemetry'));
vi.stubEnv('VERSION_CHECK_DATA_DIR', path.join(data, 'version-check'));
vi.stubEnv('ADMIN_STATE_DIR', path.join(data, 'admin-state'));
for (const dir of ['settings/account-archives', 'plugin-secrets', 'telemetry', 'version-check']) {
mkdirSync(path.join(data, dir), { recursive: true });
}
mkdirSync(path.join(data, 'admin-state', 'devices', hash('f')), { recursive: true });
writeFileSync(path.join(data, 'admin-state', 'devices', hash('f'), `${'0'.repeat(32)}.json`), '{"user":"a"}');
writeFileSync(path.join(data, 'settings', `${hash('a')}.enc`), Buffer.from([1, 2, 3, 4]));
writeFileSync(path.join(data, 'settings', `${hash('b')}.enc`), Buffer.from([5, 6]));
writeFileSync(path.join(data, 'settings', `${hash('c')}.enc.tmp`), 'half written');
writeFileSync(path.join(data, 'settings', 'account-archives', `${hash('d')}.json`), '{"version":1}');
writeFileSync(path.join(data, 'plugin-secrets', `${hash('e')}.enc`), 'c2VhbGVk');
writeFileSync(path.join(data, 'telemetry', 'state.json'), '{"consent":"on"}');
writeFileSync(path.join(data, 'telemetry', '.telemetry-id'), '0b9f1f3e-8b8e-4c4b-9a35-3c1b8a7a1e11');
writeFileSync(path.join(data, 'telemetry', 'logins.json'), '{"records":[]}');
writeFileSync(path.join(data, 'version-check', 'state.json'), '{"status":null}');
store = separateDiskStore();
setStoreForTests(store);
});
afterEach(() => {
setStoreForTests(null);
resetLocalStoreForTests();
vi.unstubAllEnvs();
store.cleanup();
rmSync(data, { recursive: true, force: true });
});
async function runAll(): Promise<Record<string, number>> {
const counts: Record<string, number> = {};
for (const step of steps) counts[step.name] = await step.run();
return counts;
}
describe('importing part data', () => {
it('copies every document once and leaves the local files alone', async () => {
const before = snapshot(data);
expect(await runAll()).toEqual({
settings: 2, 'plugin-secrets': 1, 'account-archives': 1, telemetry: 3, 'version-check': 1, devices: 1,
});
expect(await runAll()).toEqual({
settings: 0, 'plugin-secrets': 0, 'account-archives': 0, telemetry: 0, 'version-check': 0, devices: 0,
});
expect(snapshot(data)).toEqual(before);
const settings = store.collection('settings', SETTINGS_COLLECTION);
expect(await settings.list()).toEqual([hash('a'), hash('b')]);
expect((await settings.get(hash('a')))?.data).toEqual(Buffer.from([1, 2, 3, 4]));
expect((await store.collection('plugin-secrets', VAULT_COLLECTION).get(hash('e')))?.data.toString()).toBe('c2VhbGVk');
expect((await store.collection('account-archives', ARCHIVE_COLLECTION).get(hash('d')))?.data.toString()).toBe('{"version":1}');
expect((await store.collection('telemetry', TELEMETRY_COLLECTION).list()).sort()).toEqual(['.telemetry-id', 'logins.json', 'state.json']);
expect((await store.collection('version-check', VERSION_CHECK_COLLECTION).get('state.json'))?.data.toString()).toBe('{"status":null}');
expect((await store.collection('devices', DEVICES_COLLECTION).get(`${hash('f')}/${'0'.repeat(32)}`))?.data.toString()).toBe('{"user":"a"}');
});
it('keeps what the store already holds', async () => {
const settings = store.collection('settings', SETTINGS_COLLECTION);
await settings.put(hash('a'), Buffer.from([9]));
expect((await runAll()).settings).toBe(1);
expect((await settings.get(hash('a')))?.data).toEqual(Buffer.from([9]));
});
});