Files
backspace/packages/server/test/helpers/twoInstanceHarness.ts
T

271 lines
9.0 KiB
TypeScript

import { spawn, type ChildProcess } from 'node:child_process';
import { mkdir, rm, readFile } from 'node:fs/promises';
import { createWriteStream } from 'node:fs';
import path from 'node:path';
import { createServer } from 'node:net';
import { fileURLToPath } from 'node:url';
import crypto from 'node:crypto';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export interface SpawnedInstance {
proc: ChildProcess;
port: number;
origin: string; // 'http://127.0.0.1:<port>'
domain: string; // 'home.test.local' / 'remote0.test.local' / ...
dbPath: string;
storagePath: string;
jwtSecret: string;
logPath: string;
}
export interface MultiRemoteHarness {
home: SpawnedInstance;
remotes: SpawnedInstance[];
runDir: string;
cleanup: () => Promise<void>;
}
// Convenience type alias for the common one-remote case.
export interface TwoInstanceHarness {
home: SpawnedInstance;
remote: SpawnedInstance;
remotes: SpawnedInstance[]; // for code that wants the array form
runDir: string;
cleanup: () => Promise<void>;
}
async function allocateEphemeralPort(): Promise<number> {
return new Promise((resolve, reject) => {
const srv = createServer();
srv.listen(0, '127.0.0.1', () => {
const addr = srv.address();
if (addr && typeof addr === 'object') {
const port = addr.port;
srv.close(() => resolve(port));
} else {
srv.close(() => reject(new Error('Could not allocate ephemeral port')));
}
});
srv.on('error', reject);
});
}
async function waitForReady(origin: string, proc: ChildProcess, logPath: string, timeoutMs = 20_000): Promise<void> {
const deadline = Date.now() + timeoutMs;
let exited = false;
let exitInfo: { code: number | null; signal: NodeJS.Signals | null } | null = null;
const onExit = (code: number | null, signal: NodeJS.Signals | null) => {
exited = true;
exitInfo = { code, signal };
};
proc.once('exit', onExit);
try {
while (Date.now() < deadline) {
if (exited) {
throw new Error(
`Instance ${origin} exited during boot (code=${exitInfo?.code}, signal=${exitInfo?.signal}). ` +
`See log at ${logPath}`,
);
}
try {
const res = await fetch(`${origin}/api/instance/info`);
if (res.ok) return;
} catch {
// not ready yet
}
await new Promise(r => setTimeout(r, 200));
}
throw new Error(`Instance ${origin} did not become ready within ${timeoutMs}ms. See log at ${logPath}`);
} finally {
proc.off('exit', onExit);
}
}
export async function spawnInstance(opts: {
domain: string;
port: number;
dbPath: string;
storagePath: string;
jwtSecret: string;
logPath: string;
disableRateLimits?: boolean;
/**
* When true, set `PUBLIC_ORIGIN=http://127.0.0.1:<port>` so this instance's
* `getOurOrigin()` returns its TRANSPORT url instead of the identity
* `https://<DOMAIN>`. Required by the real-handshake harness so a single real
* `/peer/initiate`→`/peer/accept` creates one working, reachable peer row per
* direction (keyed by the transport origin). Default off — existing callers
* are unaffected. See realHandshake.ts and federationAuth.ts:187.
*/
publicOriginAsTransport?: boolean;
}): Promise<SpawnedInstance> {
const origin = `http://127.0.0.1:${opts.port}`;
const env: Record<string, string> = {
...process.env,
NODE_ENV: 'test',
ENABLE_TEST_ROUTES: '1',
DISABLE_FEDERATION_WORKERS: '1',
PORT: String(opts.port),
HOST: '127.0.0.1',
DOMAIN: opts.domain,
DB_PATH: opts.dbPath,
STORAGE_PATH: opts.storagePath,
JWT_SECRET: opts.jwtSecret,
LIVEKIT_URL: '',
LIVEKIT_API_KEY: '',
LIVEKIT_API_SECRET: '',
};
// Default: bypass rate limits so unrelated tests don't exhaust the per-IP
// bucket on 127.0.0.1. Test #15 (rate-limit assertion) opts out via
// bootTwoInstancesWithRateLimits().
if (opts.disableRateLimits !== false) {
env.DISABLE_RATE_LIMITS = '1';
}
if (opts.publicOriginAsTransport) {
env.PUBLIC_ORIGIN = origin;
}
// From packages/server/test/helpers → packages/server is up two levels.
const serverDir = path.resolve(__dirname, '../../');
const proc = spawn('pnpm', ['exec', 'tsx', 'src/index.ts'], {
cwd: serverDir,
env,
stdio: ['ignore', 'pipe', 'pipe'],
});
const logStream = createWriteStream(opts.logPath);
proc.stdout!.pipe(logStream);
proc.stderr!.pipe(logStream);
await waitForReady(origin, proc, opts.logPath);
return {
proc,
port: opts.port,
origin,
domain: opts.domain,
dbPath: opts.dbPath,
storagePath: opts.storagePath,
jwtSecret: opts.jwtSecret,
logPath: opts.logPath,
};
}
export interface BootOptions {
/**
* If true, spawned instances do NOT receive `DISABLE_RATE_LIMITS=1`, so
* `@fastify/rate-limit` enforces real per-IP/per-user buckets. Used only by
* tests that assert rate-limit behaviour (Test #15). Default: false (bypass on).
*/
enableRateLimits?: boolean;
/**
* If true, every spawned instance (home + all remotes) sets
* `PUBLIC_ORIGIN=http://127.0.0.1:<its-port>` so `getOurOrigin()` returns the
* transport url. Required by the real-handshake harness so a single real
* handshake yields one reachable peer row per direction. Default: false.
*/
publicOriginAsTransport?: boolean;
}
/** Boot home + N remotes. All instances ready before the function returns. */
export async function bootHomePlusRemotes(
remoteCount: number,
options: BootOptions = {},
): Promise<MultiRemoteHarness> {
if (remoteCount < 1) throw new Error('remoteCount must be >= 1');
const disableRateLimits = !options.enableRateLimits;
const publicOriginAsTransport = options.publicOriginAsTransport ?? false;
const runId = crypto.randomBytes(4).toString('hex');
// From packages/server/test/helpers → repo root is up four levels: helpers → test → server → packages → repo-root.
const runDir = path.resolve(__dirname, `../../../../tests/.tmp/${runId}`);
await mkdir(`${runDir}/home-uploads`, { recursive: true });
const homePort = await allocateEphemeralPort();
const home = await spawnInstance({
domain: 'home.test.local',
port: homePort,
dbPath: `${runDir}/home.db`,
storagePath: `${runDir}/home-uploads`,
jwtSecret: crypto.randomBytes(32).toString('hex'),
logPath: `${runDir}/home.log`,
disableRateLimits,
publicOriginAsTransport,
});
const remotes: SpawnedInstance[] = [];
for (let i = 0; i < remoteCount; i++) {
await mkdir(`${runDir}/remote${i}-uploads`, { recursive: true });
const port = await allocateEphemeralPort();
const r = await spawnInstance({
domain: `remote${i}.test.local`,
port,
dbPath: `${runDir}/remote${i}.db`,
storagePath: `${runDir}/remote${i}-uploads`,
jwtSecret: crypto.randomBytes(32).toString('hex'),
logPath: `${runDir}/remote${i}.log`,
disableRateLimits,
publicOriginAsTransport,
});
remotes.push(r);
}
let cleanedUp = false;
const cleanup = async () => {
if (cleanedUp) return;
cleanedUp = true;
for (const inst of [home, ...remotes]) {
if (!inst.proc.killed) {
inst.proc.kill('SIGTERM');
await new Promise(r => setTimeout(r, 500));
if (!inst.proc.killed) inst.proc.kill('SIGKILL');
}
}
await rm(runDir, { recursive: true, force: true });
};
return { home, remotes, runDir, cleanup };
}
/** Convenience: 1 home + 1 remote. Exposes both `remote` (singular) and `remotes` (array). */
export async function bootTwoInstances(options: BootOptions = {}): Promise<TwoInstanceHarness> {
const m = await bootHomePlusRemotes(1, options);
return {
home: m.home,
remote: m.remotes[0],
remotes: m.remotes,
runDir: m.runDir,
cleanup: m.cleanup,
};
}
/**
* Variant of `bootTwoInstances` that does NOT set `DISABLE_RATE_LIMITS`, so the
* spawned instances enforce real `@fastify/rate-limit` buckets. Used by Test #15
* (rate-limit assertion) ONLY — every other test should use `bootTwoInstances` /
* `bootHomePlusRemotes` so unrelated tests don't exhaust the shared loopback IP
* bucket. Same shape as `bootTwoInstances`, just with a different env profile.
*/
export async function bootTwoInstancesWithRateLimits(): Promise<TwoInstanceHarness> {
return bootTwoInstances({ enableRateLimits: true });
}
/**
* Read the contents of an instance's log file. Used by tests that need to assert
* a specific request did or did not reach an instance (Task 14 / Test #1).
*/
export async function readInstanceLog(inst: SpawnedInstance): Promise<string> {
try {
return await readFile(inst.logPath, 'utf8');
} catch {
return '';
}
}
/**
* Wait `delayMs` (to let any in-flight request settle into the log file), then
* return whether `pattern` appears in `inst`'s log. Used to prove a request did
* NOT happen — assert the result is false.
*/
export async function logMatched(inst: SpawnedInstance, pattern: RegExp, delayMs = 1_000): Promise<boolean> {
await new Promise(r => setTimeout(r, delayMs));
const log = await readInstanceLog(inst);
return pattern.test(log);
}