Files
kovi/tests/server.test.mjs
T
mdaleo404 23e1527b82
Build, Scan & Publish Image / build-scan-push (release) Successful in 12m30s
Add opt-in trusted-proxy client resolution, update docs
2026-10-09 07:29:53 +01:00

131 lines
5.2 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { mkdtempSync, rmSync, readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import net from 'node:net';
import os from 'node:os';
import path from 'node:path';
const SERVER = fileURLToPath(new URL('../server.mjs', import.meta.url));
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function freePort() {
return new Promise((resolve, reject) => {
const probe = net.createServer();
probe.on('error', reject);
probe.listen(0, '127.0.0.1', () => {
const { port } = probe.address();
probe.close(() => resolve(port));
});
});
}
async function startServer(extraEnv = {}) {
const dir = mkdtempSync(path.join(os.tmpdir(), 'kovi-server-'));
const port = await freePort();
const child = spawn(process.execPath, [SERVER], {
cwd: path.dirname(SERVER),
env: { ...process.env, PORT: String(port), HOST: '127.0.0.1', DATA_PATH: dir, CALIBRE_COVER_RESOLVER: 'false', ...extraEnv },
stdio: ['ignore', 'pipe', 'pipe'],
});
let output = '';
child.stdout.on('data', (d) => (output += d));
child.stderr.on('data', (d) => (output += d));
const base = `http://127.0.0.1:${port}`;
let healthy = false;
const deadline = Date.now() + 10_000;
while (Date.now() < deadline && !healthy) {
try { healthy = (await fetch(`${base}/api/health`)).ok; } catch {}
if (!healthy) await sleep(100);
}
if (!healthy) {
child.kill('SIGKILL');
rmSync(dir, { recursive: true, force: true });
throw new Error(`kovi did not start:\n${output}`);
}
return {
base,
logs: () => output,
async stop() {
if (child.exitCode === null && child.signalCode === null) {
const exited = new Promise((r) => child.once('exit', r));
child.kill('SIGKILL');
await exited;
}
rmSync(dir, { recursive: true, force: true });
},
};
}
const post = (base, pathname, body, headers = {}) => fetch(`${base}${pathname}`, {
method: 'POST',
headers: { 'content-type': 'application/json', ...headers },
body: JSON.stringify(body),
});
test('pairing attempts are rate limited per source and ignore X-Forwarded-For unless trusted', async () => {
const server = await startServer();
try {
for (let i = 0; i < 30; i++) {
const res = await post(server.base, '/api/plugin/pair', { code: 'invalid-code' }, { 'x-forwarded-for': `203.0.113.${i + 1}` });
assert.equal(res.status, 401);
}
const blocked = await post(server.base, '/api/plugin/pair', { code: 'invalid-code' }, { 'x-forwarded-for': '203.0.113.250' });
assert.equal(blocked.status, 429);
assert.match((await blocked.json()).error, /Too many pairing attempts/);
} finally {
await server.stop();
}
});
test('TRUST_PROXY keys the rate limit on the forwarded client, validates hops, and logs it', async () => {
const server = await startServer({ TRUST_PROXY: '1' });
try {
for (let i = 0; i < 30; i++) {
assert.equal((await post(server.base, '/api/plugin/pair', { code: 'invalid-code' }, { 'x-forwarded-for': '203.0.113.10' })).status, 401);
}
// A different forwarded client still has its own budget.
assert.equal((await post(server.base, '/api/plugin/pair', { code: 'invalid-code' }, { 'x-forwarded-for': '203.0.113.11' })).status, 401);
// The exhausted client is now blocked.
assert.equal((await post(server.base, '/api/plugin/pair', { code: 'invalid-code' }, { 'x-forwarded-for': '203.0.113.10' })).status, 429);
// A non-IP forwarded value is rejected and falls back to the direct peer.
assert.equal((await post(server.base, '/api/plugin/pair', { code: 'invalid-code' }, { 'x-forwarded-for': 'not-an-ip' })).status, 401);
await sleep(100);
assert.match(server.logs(), /"remote":"203\.0\.113\.10"/);
assert.match(server.logs(), /"remote":"127\.0\.0\.1"/);
} finally {
await server.stop();
}
});
test('plugin ping keeps responding but logs at most once per interval', async () => {
const server = await startServer();
try {
assert.equal((await fetch(`${server.base}/api/plugin/ping`)).status, 200);
assert.equal((await fetch(`${server.base}/api/plugin/ping`)).status, 200);
await sleep(100);
assert.equal((server.logs().match(/plugin\.ping/g) || []).length, 1);
} finally {
await server.stop();
}
});
test('concurrent backup requests are serialized with 429', async () => {
const server = await startServer();
try {
const statuses = await Promise.all(Array.from({ length: 5 }, () => fetch(`${server.base}/api/backup`).then((r) => r.status)));
assert.ok(statuses.includes(200), `expected a successful backup, got ${statuses}`);
assert.ok(statuses.includes(429), `expected a rejected concurrent backup, got ${statuses}`);
} finally {
await server.stop();
}
});
test('server pins explicit HTTP timeouts instead of relying on runtime defaults', () => {
const source = readFileSync(SERVER, 'utf8');
assert.match(source, /server\.headersTimeout\s*=\s*HEADERS_TIMEOUT_MS/);
assert.match(source, /server\.requestTimeout\s*=\s*REQUEST_TIMEOUT_MS/);
assert.match(source, /server\.keepAliveTimeout\s*=\s*KEEP_ALIVE_TIMEOUT_MS/);
});