Files
Shade/packages/shade-vault/tests/scaffoldd-e2e.test.ts

154 lines
5.6 KiB
TypeScript
Raw Normal View History

feat(vault): server-side kryptert fillager (V4.13) Shade kunne flytte filer mellom peers (@shade/files) og lagre én liten profil-blob per konto, men hadde ingen alltid-på lagring av krypterte filer. Uten den kan ingen Shade-app tilby backup, og ingen klient lese data mens peeren som eier dem er avslått. Objekter er innholdsadresserte på hashen av CHIFFERTEKSTEN, så relayen kan lagre, deduplisere og verifisere uten nøkkel — den regner om hashen ved opplasting og avviser feilnavngitte objekter. Stier bor inne i det krypterte manifestet, aldri i objektnavn: relayen skal ikke lære hva filene heter. Loggen er append-only, så historikk og rollback følger av modellen. SqliteVaultStore har med vilje INGEN minne-fallback, i motsetning til blob-storen. Den fallbacken slettet Prisms profil ved en rutine-redeploy 2026-08-12 fordi den fungerte helt til containeren ble recreated, uten en eneste feilmelding. En backup som glemmer er verre enn ingen backup, så uten SHADE_VAULT_DB_PATH mountes rutene ikke — med en logglinje som sier hvorfor. Én feil fanget av testene: pubkeyen ble først lagt på UTENFOR signaturen, som både brøt verifyPayload og ville latt hvem som helst bytte identitet i transit på den TOFU-pinnende førsteskrivingen. 16 vault- + 7 store-tester, alle mot de ekte rutehåndtererne gjennom Honos fetch. Kjeden er dessuten kjørt mot en ekte HTTP-server med et ekte workspace: 491 filer / 7,7 MB, alle bit-identiske etter gjenoppretting, og andre push etter én endring sendte 0 KB. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 11:58:37 +02:00
/**
* 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);
});