154 lines
5.6 KiB
TypeScript
154 lines
5.6 KiB
TypeScript
|
|
/**
|
||
|
|
* The whole backup chain, against real data.
|
||
|
|
*
|
||
|
|
* Talks to a running `scaffoldd` over its unix socket, asks what the backup
|
||
|
|
* set is, reads those files off disk, pushes them through the vault, and pulls
|
||
|
|
* them back — then compares byte-for-byte.
|
||
|
|
*
|
||
|
|
* This is the test that would catch the things unit tests cannot: a path that
|
||
|
|
* survives the round trip wrong, a 600 KB log that trips a size ceiling, a
|
||
|
|
* workspace whose file count makes the manifest too large to commit. It is
|
||
|
|
* skipped when no daemon is listening, so it never blocks an ordinary run.
|
||
|
|
*
|
||
|
|
* scaffoldd --socket /tmp/scaffoldd-e2e.sock &
|
||
|
|
* SCAFFOLDD_SOCKET=/tmp/scaffoldd-e2e.sock bun test scaffoldd-e2e
|
||
|
|
*/
|
||
|
|
import { describe, test, expect } from 'bun:test';
|
||
|
|
import { connect } from 'node:net';
|
||
|
|
import { readFile } from 'node:fs/promises';
|
||
|
|
import { SubtleCryptoProvider } from '@shade/crypto-web';
|
||
|
|
import { createVaultRoutes } from '../src/server.js';
|
||
|
|
import { MemoryVaultStore } from '../src/store.js';
|
||
|
|
import { HttpVaultTransport } from '../src/http-transport.js';
|
||
|
|
import { VaultClient, deriveVaultKeys } from '../src/client.js';
|
||
|
|
import type { VaultFile } from '../src/client.js';
|
||
|
|
|
||
|
|
const SOCKET = process.env.SCAFFOLDD_SOCKET;
|
||
|
|
const crypto = new SubtleCryptoProvider();
|
||
|
|
|
||
|
|
interface BackupFile {
|
||
|
|
path: string;
|
||
|
|
source: string;
|
||
|
|
size: number;
|
||
|
|
}
|
||
|
|
|
||
|
|
function ask(socketPath: string, request: object): Promise<any> {
|
||
|
|
return new Promise((resolve, reject) => {
|
||
|
|
const sock = connect(socketPath);
|
||
|
|
let buf = '';
|
||
|
|
const timer = setTimeout(() => {
|
||
|
|
sock.destroy();
|
||
|
|
reject(new Error('scaffoldd svarte ikke innen 20 s'));
|
||
|
|
}, 20_000);
|
||
|
|
// Deliberately no `end()` after writing: Bun's socket closes both
|
||
|
|
// directions, so the daemon's reply is discarded before it arrives. The
|
||
|
|
// protocol is line-delimited, so read until the first complete line and
|
||
|
|
// close from here instead.
|
||
|
|
sock.on('connect', () => {
|
||
|
|
sock.write(`${JSON.stringify(request)}\n`);
|
||
|
|
});
|
||
|
|
sock.on('data', (c) => {
|
||
|
|
buf += c.toString();
|
||
|
|
const nl = buf.indexOf('\n');
|
||
|
|
if (nl === -1) return;
|
||
|
|
clearTimeout(timer);
|
||
|
|
sock.destroy();
|
||
|
|
const res = JSON.parse(buf.slice(0, nl));
|
||
|
|
res.ok ? resolve(res.result) : reject(new Error(`${res.code}: ${res.error}`));
|
||
|
|
});
|
||
|
|
sock.on('end', () => {
|
||
|
|
clearTimeout(timer);
|
||
|
|
if (!buf.includes('\n')) reject(new Error('tomt svar'));
|
||
|
|
});
|
||
|
|
sock.on('error', (e) => {
|
||
|
|
clearTimeout(timer);
|
||
|
|
reject(e);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
async function harness() {
|
||
|
|
const store = new MemoryVaultStore();
|
||
|
|
const routes = createVaultRoutes(store, crypto);
|
||
|
|
const transport = new HttpVaultTransport('http://vault.test', (i, init) =>
|
||
|
|
routes.fetch(new Request(i, init)),
|
||
|
|
);
|
||
|
|
const keys = await deriveVaultKeys(new Uint8Array(32).fill(42), 'scaffold-e2e');
|
||
|
|
return { store, client: new VaultClient(crypto, keys, transport) };
|
||
|
|
}
|
||
|
|
|
||
|
|
describe.skipIf(!SOCKET)('scaffoldd → vault, real workspace', () => {
|
||
|
|
test('the whole backup set round-trips byte-for-byte', async () => {
|
||
|
|
const set = (await ask(SOCKET!, { op: 'backupSet' })) as {
|
||
|
|
files: BackupFile[];
|
||
|
|
count: number;
|
||
|
|
bytes: number;
|
||
|
|
};
|
||
|
|
expect(set.count).toBeGreaterThan(50);
|
||
|
|
|
||
|
|
const files: VaultFile[] = [];
|
||
|
|
for (const f of set.files) {
|
||
|
|
try {
|
||
|
|
files.push({ path: f.path, bytes: new Uint8Array(await readFile(f.source)) });
|
||
|
|
} catch {
|
||
|
|
// Matches the bridge: one unreadable file is skipped, not fatal.
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const { client } = await harness();
|
||
|
|
const push = await client.push(files, 1_786_600_000_000, 'e2e');
|
||
|
|
expect(push.seq).toBe(1);
|
||
|
|
expect(push.uploaded).toBe(files.length);
|
||
|
|
|
||
|
|
const { files: back } = await client.pull();
|
||
|
|
expect(back.size).toBe(files.length);
|
||
|
|
|
||
|
|
// Every single file, not a sample: a backup that restores 99% of a
|
||
|
|
// workspace is not a backup.
|
||
|
|
for (const f of files) {
|
||
|
|
const restored = back.get(f.path);
|
||
|
|
expect(restored).toBeDefined();
|
||
|
|
expect(restored!.length).toBe(f.bytes.length);
|
||
|
|
expect(Buffer.from(restored!).equals(Buffer.from(f.bytes))).toBe(true);
|
||
|
|
}
|
||
|
|
}, 120_000);
|
||
|
|
|
||
|
|
test('a second push after one edit sends almost nothing', async () => {
|
||
|
|
// The property that decides whether hourly backup is affordable.
|
||
|
|
const set = (await ask(SOCKET!, { op: 'backupSet' })) as { files: BackupFile[] };
|
||
|
|
const files: VaultFile[] = [];
|
||
|
|
for (const f of set.files.slice(0, 120)) {
|
||
|
|
try {
|
||
|
|
files.push({ path: f.path, bytes: new Uint8Array(await readFile(f.source)) });
|
||
|
|
} catch {
|
||
|
|
/* skipped */
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const { client } = await harness();
|
||
|
|
const first = await client.push(files, 1_786_600_000_000);
|
||
|
|
|
||
|
|
const edited = files.map((f, i) =>
|
||
|
|
i === 0 ? { path: f.path, bytes: new TextEncoder().encode('endret én linje') } : f,
|
||
|
|
);
|
||
|
|
const second = await client.push(edited, 1_786_600_001_000);
|
||
|
|
|
||
|
|
expect(first.uploaded).toBe(files.length);
|
||
|
|
expect(second.uploaded).toBe(1);
|
||
|
|
expect(second.reused).toBe(files.length - 1);
|
||
|
|
}, 120_000);
|
||
|
|
|
||
|
|
test('the largest file in the workspace survives the round trip', async () => {
|
||
|
|
// Terra's log.md is ~617 KB. Nothing in the chain may quietly cap it.
|
||
|
|
const set = (await ask(SOCKET!, { op: 'backupSet' })) as { files: BackupFile[] };
|
||
|
|
const biggest = set.files.reduce((a, b) => (a.size > b.size ? a : b));
|
||
|
|
expect(biggest.size).toBeGreaterThan(100_000);
|
||
|
|
|
||
|
|
const bytes = new Uint8Array(await readFile(biggest.source));
|
||
|
|
const { client } = await harness();
|
||
|
|
await client.push([{ path: biggest.path, bytes }], 1_786_600_000_000);
|
||
|
|
const { files } = await client.pull();
|
||
|
|
expect(files.get(biggest.path)!.length).toBe(bytes.length);
|
||
|
|
}, 120_000);
|
||
|
|
});
|