This commit is contained in:
278
packages/server/tests/data-erase-helpers.test.ts
Normal file
278
packages/server/tests/data-erase-helpers.test.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* Pure-helper tests for the data-erase flow.
|
||||
*
|
||||
* These cover the safety-critical primitives that the route + boot wipe
|
||||
* sit on top of:
|
||||
* - confirmation gate (header + exact phrase)
|
||||
* - snapshot accuracy (file count + bytes)
|
||||
* - safe-to-wipe assertion (refuses non-Waggle dirs, refuses cwd, refuses root)
|
||||
* - path-escape resistance during the wipe walk (symlinks, ..)
|
||||
* - marker round-trip (write then read)
|
||||
* - performWipe receipt correctness
|
||||
*
|
||||
* These are pure-function tests on a tmp dir — they do NOT touch any
|
||||
* server, DB, or network. The integration with the route lives in
|
||||
* data-erase.test.ts; the integration with service startup lives
|
||||
* implicitly in startService (smoke-tested when first-run.test.ts runs).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
validateEraseConfirmation,
|
||||
snapshotDataDir,
|
||||
assertDataDirIsSafeToWipe,
|
||||
writeEraseMarker,
|
||||
readEraseMarker,
|
||||
performWipe,
|
||||
writeWipeReceipt,
|
||||
ERASE_CONFIRMATION_PHRASE,
|
||||
ERASE_CONFIRMATION_HEADER_VALUE,
|
||||
ERASE_MARKER_FILENAME,
|
||||
type EraseMarker,
|
||||
} from '../src/local/data-erase-helpers.js';
|
||||
|
||||
function mkTmp(name: string): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), `waggle-${name}-`));
|
||||
}
|
||||
|
||||
describe('validateEraseConfirmation', () => {
|
||||
it('accepts the exact header + body phrase', () => {
|
||||
const r = validateEraseConfirmation(
|
||||
{ 'x-confirm-erase': ERASE_CONFIRMATION_HEADER_VALUE },
|
||||
{ confirmation: ERASE_CONFIRMATION_PHRASE },
|
||||
);
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects missing header', () => {
|
||||
const r = validateEraseConfirmation({}, { confirmation: ERASE_CONFIRMATION_PHRASE });
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.error).toMatch(/X-Confirm-Erase/);
|
||||
});
|
||||
|
||||
it('rejects header with wrong value', () => {
|
||||
const r = validateEraseConfirmation(
|
||||
{ 'x-confirm-erase': 'YES' }, // wrong case
|
||||
{ confirmation: ERASE_CONFIRMATION_PHRASE },
|
||||
);
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.error).toMatch(/X-Confirm-Erase/);
|
||||
});
|
||||
|
||||
it('rejects body with wrong phrase', () => {
|
||||
const r = validateEraseConfirmation(
|
||||
{ 'x-confirm-erase': ERASE_CONFIRMATION_HEADER_VALUE },
|
||||
{ confirmation: 'i understand this is permanent' }, // wrong case
|
||||
);
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.error).toMatch(/exact phrase/);
|
||||
});
|
||||
|
||||
it('rejects body that is not a JSON object', () => {
|
||||
const r = validateEraseConfirmation(
|
||||
{ 'x-confirm-erase': ERASE_CONFIRMATION_HEADER_VALUE },
|
||||
'I UNDERSTAND THIS IS PERMANENT',
|
||||
);
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.error).toMatch(/JSON object/);
|
||||
});
|
||||
|
||||
it('rejects body with non-string confirmation field', () => {
|
||||
const r = validateEraseConfirmation(
|
||||
{ 'x-confirm-erase': ERASE_CONFIRMATION_HEADER_VALUE },
|
||||
{ confirmation: true },
|
||||
);
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.error).toMatch(/must be a string/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('snapshotDataDir', () => {
|
||||
let dir: string;
|
||||
beforeEach(() => { dir = mkTmp('erase-snapshot'); });
|
||||
afterEach(() => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* */ } });
|
||||
|
||||
it('returns zeroes for a non-existent dir', () => {
|
||||
const snap = snapshotDataDir(path.join(dir, 'does-not-exist'));
|
||||
expect(snap.fileCount).toBe(0);
|
||||
expect(snap.totalBytes).toBe(0);
|
||||
expect(snap.topLevelEntries).toEqual([]);
|
||||
});
|
||||
|
||||
it('counts top-level files + recursive subdir bytes correctly', () => {
|
||||
fs.writeFileSync(path.join(dir, 'a.txt'), 'aaaaa'); // 5 bytes
|
||||
fs.writeFileSync(path.join(dir, 'b.txt'), 'bb'); // 2 bytes
|
||||
fs.mkdirSync(path.join(dir, 'sub'));
|
||||
fs.writeFileSync(path.join(dir, 'sub', 'c.txt'), 'ccc'); // 3 bytes
|
||||
fs.writeFileSync(path.join(dir, 'sub', 'd.txt'), 'dddd'); // 4 bytes
|
||||
|
||||
const snap = snapshotDataDir(dir);
|
||||
expect(snap.fileCount).toBe(4);
|
||||
expect(snap.totalBytes).toBe(14);
|
||||
expect(snap.topLevelEntries).toHaveLength(3);
|
||||
const sub = snap.topLevelEntries.find(e => e.name === 'sub');
|
||||
expect(sub?.isDirectory).toBe(true);
|
||||
expect(sub?.bytes).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertDataDirIsSafeToWipe', () => {
|
||||
it('passes for a path with "waggle" in the basename', () => {
|
||||
const dir = mkTmp('waggle-safe-name');
|
||||
expect(assertDataDirIsSafeToWipe(dir)).toBeNull();
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('passes for a non-waggle-named dir if it contains a Waggle artifact', () => {
|
||||
const dir = mkTmp('foreign-name'); // mkdtemp prefix gets "waggle-" — work around
|
||||
// Rename into a foreign-shape path so the basename heuristic fails.
|
||||
const renamed = path.join(path.dirname(dir), `foreign-${Date.now()}`);
|
||||
fs.renameSync(dir, renamed);
|
||||
fs.writeFileSync(path.join(renamed, 'personal.mind'), '');
|
||||
expect(assertDataDirIsSafeToWipe(renamed)).toBeNull();
|
||||
fs.rmSync(renamed, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('refuses a foreign-named, artifact-free dir', () => {
|
||||
const dir = mkTmp('safe-suffix');
|
||||
const renamed = path.join(path.dirname(dir), `notrelated-${Date.now()}`);
|
||||
fs.renameSync(dir, renamed);
|
||||
const err = assertDataDirIsSafeToWipe(renamed);
|
||||
expect(err).toMatch(/does not look like a Waggle data dir/);
|
||||
fs.rmSync(renamed, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('refuses cwd', () => {
|
||||
const err = assertDataDirIsSafeToWipe(process.cwd());
|
||||
expect(err).toMatch(/forbidden top-level path/);
|
||||
});
|
||||
|
||||
it('refuses an empty / non-string path', () => {
|
||||
expect(assertDataDirIsSafeToWipe('')).toMatch(/empty/);
|
||||
// @ts-expect-error — testing the runtime guard
|
||||
expect(assertDataDirIsSafeToWipe(null)).toMatch(/empty/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('writeEraseMarker / readEraseMarker round-trip', () => {
|
||||
let dir: string;
|
||||
beforeEach(() => { dir = mkTmp('erase-marker'); });
|
||||
afterEach(() => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* */ } });
|
||||
|
||||
it('writes a JSON marker that readEraseMarker parses back', () => {
|
||||
const marker: EraseMarker = {
|
||||
schemaVersion: 1,
|
||||
requestedAt: '2026-05-08T10:00:00.000Z',
|
||||
snapshot: { fileCount: 3, totalBytes: 100, topLevelEntries: [] },
|
||||
};
|
||||
const p = writeEraseMarker(dir, marker);
|
||||
expect(p).toBe(path.join(dir, ERASE_MARKER_FILENAME));
|
||||
const round = readEraseMarker(dir);
|
||||
expect(round).toEqual(marker);
|
||||
});
|
||||
|
||||
it('returns null when no marker file exists', () => {
|
||||
expect(readEraseMarker(dir)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when marker JSON has wrong schemaVersion', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(dir, ERASE_MARKER_FILENAME),
|
||||
JSON.stringify({ schemaVersion: 2, requestedAt: 'x', snapshot: {} }),
|
||||
);
|
||||
expect(readEraseMarker(dir)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when marker JSON is malformed', () => {
|
||||
fs.writeFileSync(path.join(dir, ERASE_MARKER_FILENAME), 'not json');
|
||||
expect(readEraseMarker(dir)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('performWipe', () => {
|
||||
let dir: string;
|
||||
beforeEach(() => { dir = mkTmp('erase-wipe'); });
|
||||
afterEach(() => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* */ } });
|
||||
|
||||
function fakeMarker(): EraseMarker {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
requestedAt: new Date().toISOString(),
|
||||
snapshot: { fileCount: 0, totalBytes: 0, topLevelEntries: [] },
|
||||
};
|
||||
}
|
||||
|
||||
it('removes every file inside the data dir and returns them in filesRemoved', () => {
|
||||
fs.writeFileSync(path.join(dir, 'a.txt'), 'a');
|
||||
fs.writeFileSync(path.join(dir, 'b.mind'), 'b');
|
||||
fs.mkdirSync(path.join(dir, 'sub'));
|
||||
fs.writeFileSync(path.join(dir, 'sub', 'c.txt'), 'c');
|
||||
// The marker itself
|
||||
writeEraseMarker(dir, fakeMarker());
|
||||
|
||||
const r = performWipe(dir, fakeMarker());
|
||||
expect(r.filesSkipped).toEqual([]);
|
||||
// All four files + the marker should be removed.
|
||||
const removed = new Set(r.filesRemoved);
|
||||
expect(removed.has('a.txt')).toBe(true);
|
||||
expect(removed.has('b.mind')).toBe(true);
|
||||
expect(removed.has(path.join('sub', 'c.txt'))).toBe(true);
|
||||
expect(removed.has(ERASE_MARKER_FILENAME)).toBe(true);
|
||||
// Data dir still exists, but is empty.
|
||||
expect(fs.existsSync(dir)).toBe(true);
|
||||
expect(fs.readdirSync(dir)).toEqual([]);
|
||||
});
|
||||
|
||||
it('refuses to wipe a foreign-named, artifact-free dir and returns refusal in filesSkipped', () => {
|
||||
// Build a foreign-shape dir so assertDataDirIsSafeToWipe refuses.
|
||||
const renamed = path.join(path.dirname(dir), `notrelated-${Date.now()}`);
|
||||
fs.renameSync(dir, renamed);
|
||||
fs.writeFileSync(path.join(renamed, 'a.txt'), 'a');
|
||||
|
||||
const r = performWipe(renamed, fakeMarker());
|
||||
expect(r.filesRemoved).toEqual([]);
|
||||
expect(r.filesSkipped).toHaveLength(1);
|
||||
expect(r.filesSkipped[0].reason).toMatch(/does not look like a Waggle data dir/);
|
||||
// File untouched.
|
||||
expect(fs.existsSync(path.join(renamed, 'a.txt'))).toBe(true);
|
||||
|
||||
fs.rmSync(renamed, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('writeWipeReceipt', () => {
|
||||
let dir: string;
|
||||
beforeEach(() => { dir = mkTmp('erase-receipt'); });
|
||||
afterEach(() => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* */ } });
|
||||
|
||||
it('writes a JSON receipt to a unique timestamped filename', () => {
|
||||
const receipt = {
|
||||
requestedAt: '2026-05-08T10:00:00.000Z',
|
||||
wipedAt: '2026-05-08T10:01:00.000Z',
|
||||
snapshot: { fileCount: 0, totalBytes: 0, topLevelEntries: [] },
|
||||
filesRemoved: ['a.txt'],
|
||||
filesSkipped: [],
|
||||
};
|
||||
const p = writeWipeReceipt(dir, receipt);
|
||||
expect(path.basename(p)).toMatch(/^audit-receipt-2026-05-08T10-01-00-000Z\.json$/);
|
||||
const back = JSON.parse(fs.readFileSync(p, 'utf-8'));
|
||||
expect(back).toEqual(receipt);
|
||||
});
|
||||
|
||||
it('creates the dir if it has been wiped to non-existence', () => {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
expect(fs.existsSync(dir)).toBe(false);
|
||||
const receipt = {
|
||||
requestedAt: '2026-05-08T10:00:00.000Z',
|
||||
wipedAt: '2026-05-08T10:01:00.000Z',
|
||||
snapshot: { fileCount: 0, totalBytes: 0, topLevelEntries: [] },
|
||||
filesRemoved: [],
|
||||
filesSkipped: [],
|
||||
};
|
||||
const p = writeWipeReceipt(dir, receipt);
|
||||
expect(fs.existsSync(p)).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user