AtlatestRepositoryfolio

folio / tree / testtest-shared-http.mjs

1// Runner-only integration and load coverage. Every store is synthetic.
2import assert from 'node:assert/strict';
3import { spawn } from 'node:child_process';
4import { mkdtemp, mkdir, writeFile, readFile, rm, stat, chmod, utimes, rename, unlink } from 'node:fs/promises';
5import { tmpdir, networkInterfaces } from 'node:os';
6import { resolve, join } from 'node:path';
7import { createServer } from 'node:net';
8import { once } from 'node:events';
9import test from 'node:test';
11const binary = resolve(process.env.FOLIO_TEST_BINARY || 'build/dev/bin/folio');
12const token = 'folio-test-only-0123456789abcdef0123456789abcdef';
13const pause = ms => new Promise(resolve => setTimeout(resolve, ms));
14const meta = {
15 'io.modelcontextprotocol/protocolVersion': '2026-07-28',
16 'io.modelcontextprotocol/clientCapabilities': {},
17};
19function boundedFetch(url, options = {}) {
20 return fetch(url, { signal: AbortSignal.timeout(15000), ...options });
23async function unusedPort() {
24 const socket = createServer();
25 socket.listen(0, '127.0.0.1');
26 await once(socket, 'listening');
27 const port = socket.address().port;
28 await new Promise(resolve => socket.close(resolve));
29 return port;
32async function eventually(check, label, timeout = 25000) {
33 const deadline = performance.now() + timeout;
34 let last;
35 while (performance.now() < deadline) {
36 try { if (await check()) return; } catch (error) { last = error; }
37 await pause(150);
38 }
39 throw new Error(`Timed out: ${label}`, { cause: last });
42async function stop(child) {
43 if (child.exitCode !== null || child.signalCode !== null) return;
44 const exited = once(child, 'exit');
45 child.kill('SIGTERM');
46 const timer = setTimeout(() => child.kill('SIGKILL'), 3000);
47 await exited;
48 clearTimeout(timer);
51async function cpuSeconds(pid) {
52 const text = await readFile(`/proc/${pid}/stat`, 'utf8');
53 const fields = text.slice(text.lastIndexOf(')') + 2).split(' ');
54 return (Number(fields[11]) + Number(fields[12])) / 100;
57test('stdio initialize still works and HTTP startup refuses invalid token configuration',
58 { timeout: 20000 }, async t => {
59 const root = await mkdtemp(join(tmpdir(), 'folio-stdio-test-'));
60 t.after(() => rm(root, { recursive: true, force: true }));
61 const child = spawn(binary, ['serve', '--stdio'], { env: { ...process.env, FOLIO_ROOT: root } });
62 t.after(() => stop(child));
63 let received = '';
64 let diagnostic = '';
65 child.stdout.on('data', chunk => { received += chunk; });
66 child.stderr.on('data', chunk => { diagnostic += chunk; });
67 child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 42, method: 'initialize',
68 params: { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 'stdio-test', version: '1' } } }) + '\n');
69 await eventually(() => received.split('\n').some(line => {
70 try { return JSON.parse(line).id === 42 && JSON.parse(line).result.serverInfo; }
71 catch { return false; }
72 }), `stdio initialize: ${diagnostic}`, 10000);
73 await stop(child);
74 const insecureToken = join(root, 'insecure-token');
75 await writeFile(insecureToken, token + '\n', { mode: 0o644 });
76 await chmod(insecureToken, 0o644);
77 for (const args of [
78 ['serve', '--http', '18765', '--token-file', insecureToken],
79 ['serve', '--http', '18765', '--http', '18766', '--token-file', insecureToken],
80 ['serve', '--http', '18765'],
81 ['serve', '--http', '18765', '--stdio'],
82 ['serve', '--token-file', '/missing/folio-token'],
83 ]) {
84 const invalid = spawn(binary, args, { env: { ...process.env, FOLIO_ROOT: root } });
85 invalid.stdout.resume();
86 invalid.stderr.resume();
87 const timer = setTimeout(() => invalid.kill('SIGKILL'), 4000);
88 const [code, signal] = await once(invalid, 'exit');
89 clearTimeout(timer);
90 assert.equal(signal, null, `invalid startup hung: ${args.join(' ')}`);
91 assert.notEqual(code, 0, `invalid startup accepted: ${args.join(' ')}`);
92 }
93 });
95test('deprecated viewer flags warn once, never bind, and preserve stdio EOF', { timeout: 20000 }, async t => {
96 const root = await mkdtemp(join(tmpdir(), 'folio-stdio-web-'));
97 t.after(() => rm(root, { recursive: true, force: true }));
98 await writeFile(join(root, 'inbox.md'), '# Inbox\n');
99 const occupied = createServer();
100 occupied.listen(0, '0.0.0.0');
101 await once(occupied, 'listening');
102 t.after(() => new Promise(resolve => occupied.close(resolve)));
103 const port = occupied.address().port;
104 for (const flags of [['--web-port', String(port)], ['--web']]) {
105 const child = spawn(binary, ['serve', '--stdio', ...flags],
106 { env: { ...process.env, FOLIO_ROOT: root } });
107 t.after(() => stop(child));
108 let output = '';
109 let diagnostic = '';
110 child.stdout.on('data', chunk => { output += chunk; });
111 child.stderr.on('data', chunk => { diagnostic += chunk; });
112 child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 44, method: 'initialize',
113 params: { protocolVersion: '2025-11-25', capabilities: {} } }) + '\n');
114 await eventually(() => output.includes('serverInfo'), 'deprecated flag stdio initialize', 10000);
115 assert.equal(diagnostic.split('\n').filter(line => line.includes('deprecated and ignored')).length, 1);
116 assert.ok(!diagnostic.includes('Folio web UI'));
117 const sockets = await readFile('/proc/net/tcp', 'utf8');
118 const suffix = ':' + port.toString(16).toUpperCase().padStart(4, '0');
119 const listeners = sockets.split('\n').filter(line => {
120 const fields = line.trim().split(/\s+/);
121 return fields[1]?.endsWith(suffix) && fields[3] === '0A';
122 });
123 assert.equal(listeners.length, 1, 'deprecated flag created a second listener');
124 assert.equal(listeners[0].trim().split(/\s+/)[1], '00000000' + suffix);
125 const exited = once(child, 'exit');
126 child.stdin.end();
127 const timer = setTimeout(() => child.kill('SIGKILL'), 4000);
128 const [code, signal] = await exited;
129 clearTimeout(timer);
130 assert.equal(signal, null, 'deprecated viewer flag prevented stdin EOF exit');
131 assert.equal(code, 0);
132 }
133});
135test('shared HTTP authentication, serialization, external edits and 20 clients',
136 { timeout: 180000 }, async t => {
137 const root = await mkdtemp(join(tmpdir(), 'folio-http-test-'));
138 let child;
139 let logs = '';
140 t.after(async () => {
141 const exitBeforeCleanup = child?.exitCode;
142 const signalBeforeCleanup = child?.signalCode;
143 if (child) await stop(child);
144 console.log(JSON.stringify({ exitBeforeCleanup, signalBeforeCleanup, serverExit: child?.exitCode, serverSignal: child?.signalCode, serverLog: logs }));
145 await rm(root, { recursive: true, force: true });
146 });
147 for (const dir of ['notes', 'projects', 'archive/notes', 'archive/projects']) {
148 await mkdir(join(root, dir), { recursive: true });
149 }
150 await writeFile(join(root, 'inbox.md'), '# Inbox\n');
151 await writeFile(join(root, 'notes/storm.md'), '# Storm\n');
152 await writeFile(join(root, 'notes/external.md'), '# External\noldneedle\n');
153 await writeFile(join(root, 'notes/large.md'), '# Unicode\n' + 'λ → résumé\n'.repeat(30000));
154 for (let i = 0; i < 1000; i++) {
155 await writeFile(join(root, `notes/fixture-${i}.md`), `# Fixture ${i}\nsearchable content\n`);
156 }
157 const tokenFile = join(root, 'bearer');
158 await writeFile(tokenFile, token + '\n', { mode: 0o600 });
159 const port = await unusedPort();
160 const url = `http://127.0.0.1:${port}/mcp`;
161 child = spawn(binary, ['serve', '--http', String(port), '--token-file', tokenFile,
162 '--web-port', String(port)], { env: { ...process.env, FOLIO_ROOT: root } });
163 child.stdout.on('data', chunk => { logs = (logs + chunk).slice(-16000); });
164 child.stderr.on('data', chunk => { logs = (logs + chunk).slice(-16000); });
165 let id = 0;
166 async function rpc(method, params = {}, headers = {}) {
167 const response = await boundedFetch(url, {
168 method: 'POST', signal: AbortSignal.timeout(15000),
169 headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json',
170 'MCP-Protocol-Version': '2026-07-28', 'Mcp-Method': method,
171 ...(params.name ? { 'Mcp-Name': params.name } : {}), ...headers },
172 body: JSON.stringify({ jsonrpc: '2.0', id: ++id, method,
173 params: { ...params, _meta: meta } }),
174 });
175 assert.equal(response.status, 200, await response.clone().text());
176 const body = await response.json();
177 assert.ok(!body.error, JSON.stringify(body));
178 assert.ok(!body.result?.isError, JSON.stringify(body));
179 return body.result;
180 }
181 const call = (name, args) => rpc('tools/call', { name, arguments: args });
182 const text = result => result.content.map(item => item.text || '').join('\n');
183 await eventually(async () => {
184 assert.equal(child.exitCode, null, logs);
185 const response = await boundedFetch(url, { signal: AbortSignal.timeout(1000) });
186 await response.text();
187 return response.status === 401;
188 }, 'server ready', 45000);
190 await t.test('bearer required before protocol parsing; legacy initialize works', async () => {
191 for (const authorization of ['', 'Bearer wrong', 'Basic wrong', 'Bearer wrongé']) {
192 console.log(JSON.stringify({ authenticationProbe: authorization ? (authorization.includes('é') ? 'non-ASCII' : authorization.split(' ')[0]) : 'empty' }));
193 const response = await boundedFetch(url, { method: 'POST', headers: { Authorization: authorization }, body: '{' });
194 assert.equal(response.status, 401);
195 assert.equal(response.headers.get('www-authenticate'), 'Bearer');
196 await response.text();
197 }
198 const response = await boundedFetch(url, { method: 'POST',
199 headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
200 body: JSON.stringify({ jsonrpc: '2.0', id: 0, method: 'initialize',
201 params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'test', version: '1' } } }) });
202 assert.equal(response.status, 200);
203 assert.ok((await response.json()).result.serverInfo);
204 const legacy = await boundedFetch(url, { method: 'POST',
205 headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json',
206 'MCP-Protocol-Version': '2025-11-25' },
207 body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }) });
208 assert.equal(legacy.status, 200, await legacy.clone().text());
209 const legacyResult = (await legacy.json()).result;
210 assert.ok(legacyResult.tools.some(tool => tool.name === 'folio/note-read'));
211 assert.equal(legacyResult.resultType, undefined);
212 assert.equal(legacy.headers.get('mcp-session-id'), null);
213 const invalidModern = await boundedFetch(url, { method: 'POST',
214 headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json',
215 'MCP-Protocol-Version': '2026-07-28' },
216 body: JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }) });
217 assert.equal(invalidModern.status, 400);
218 const forbidden = await boundedFetch(url, { method: 'POST',
219 headers: { Authorization: `Bearer ${token}`, Origin: 'https://example.com' }, body: '{}' });
220 assert.equal(forbidden.status, 403);
221 });
223 await t.test('HTTP rebuild is refused immediately and invalid crosslinks stay usable', async () => {
224 const started = performance.now();
225 const response = await boundedFetch(url, { method: 'POST',
226 headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json',
227 'MCP-Protocol-Version': '2026-07-28', 'Mcp-Method': 'tools/call',
228 'Mcp-Name': 'folio/rebuild-index' },
229 body: JSON.stringify({ jsonrpc: '2.0', id: ++id, method: 'tools/call',
230 params: { name: 'folio/rebuild-index', arguments: {}, _meta: meta } }) });
231 assert.equal(response.status, 200);
232 const result = await response.json();
233 assert.match(JSON.stringify(result), /rebuild-index is unavailable over HTTP/);
234 assert.ok(result.error || result.result?.isError);
235 assert.ok(performance.now() - started < 2000, 'HTTP rebuild blocked the listener');
236 await call('folio/note-edit', { name: 'storm', append: '\n[[../x]] and [[/tmp/x]]\n' });
237 const links = text(await call('folio/note-links', { name: 'storm' }));
238 assert.match(links, /\.\.\/x/);
239 await call('folio/note-read', { name: 'storm' });
240 });
242 await t.test('actual listener is IPv4 loopback only with ignored viewer port', async () => {
243 const sockets = await readFile('/proc/net/tcp', 'utf8');
244 const suffix = ':' + port.toString(16).toUpperCase().padStart(4, '0');
245 const listeners = sockets.split('\n').filter(line => {
246 const fields = line.trim().split(/\s+/);
247 return fields[1]?.endsWith(suffix) && fields[3] === '0A';
248 });
249 assert.equal(listeners.length, 1);
250 assert.equal(listeners[0].trim().split(/\s+/)[1], '0100007F' + suffix);
251 for (const addresses of Object.values(networkInterfaces())) {
252 for (const address of addresses || []) {
253 if (address.family === 'IPv4' && !address.internal) {
254 await assert.rejects(boundedFetch(`http://${address.address}:${port}/mcp`, { signal: AbortSignal.timeout(1000) }));
255 }
256 }
257 }
258 assert.equal(logs.split('\n').filter(line => line.includes('deprecated and ignored')).length, 1);
259 });
261 await t.test('20 concurrent appenders preserve every unique append exactly once', async () => {
262 await Promise.all(Array.from({ length: 20 }, async (_, client) => {
263 for (let sequence = 0; sequence < 4; sequence++) {
264 await call('folio/note-edit', { name: 'storm', append: `\nappend-${client}-${sequence}-end\n` });
265 }
266 }));
267 const disk = await readFile(join(root, 'notes/storm.md'), 'utf8');
268 for (let client = 0; client < 20; client++) {
269 for (let sequence = 0; sequence < 4; sequence++) {
270 assert.equal(disk.split(`append-${client}-${sequence}-end`).length - 1, 1);
271 }
272 }
273 assert.match(text(await call('folio/notes', { query: 'append-19-3-end' })), /storm/);
274 });
276 await t.test('same-size external write with restored mtime, additions and removals converge', async () => {
277 const external = join(root, 'notes/external.md');
278 const before = await stat(external);
279 await writeFile(external, '# External\nnewneedle\n');
280 await utimes(external, before.atime, before.mtime);
281 await eventually(async () => /external/.test(text(await call('folio/notes', { query: 'newneedle' }))), 'external edit');
282 assert.doesNotMatch(text(await call('folio/notes', { query: 'oldneedle' })), /external/);
283 await mkdir(join(root, 'notes/nested'));
284 await rename(external, join(root, 'notes/nested/moved.md'));
285 await writeFile(join(root, 'notes/added.md'), '# Added\naddedneedle\n');
286 await unlink(join(root, 'notes/fixture-0.md'));
287 await eventually(async () => {
288 const names = text(await call('folio/notes', {}));
289 return names.includes('nested/moved') && names.includes('added') && !names.includes(' external') && !names.includes(' fixture-0\n');
290 }, 'external rename, add and delete');
291 });
293 await t.test('large Unicode response and 20-client load have bounded CPU and latency', async () => {
294 const largeStart = performance.now();
295 assert.match(text(await call('folio/note-read', { name: 'large' })), /λ → résumé/);
296 const largeMs = performance.now() - largeStart;
297 assert.ok(largeMs < 12000, `large Unicode read took ${largeMs} ms`);
298 const cpuStart = await cpuSeconds(child.pid);
299 const started = performance.now();
300 const latencies = [];
301 await Promise.all(Array.from({ length: 20 }, async (_, client) => {
302 for (let iteration = 0; iteration < 8; iteration++) {
303 const start = performance.now();
304 if (iteration % 4 === 0) {
305 await call('folio/note-edit', { name: 'storm', append: `\nload-${client}-${iteration}\n` });
306 } else {
307 await call('folio/note-read', { name: 'fixture-1' });
308 }
309 latencies.push(performance.now() - start);
310 }
311 }));
312 const elapsedMs = performance.now() - started;
313 const cpu = (await cpuSeconds(child.pid)) - cpuStart;
314 latencies.sort((a, b) => a - b);
315 const p95 = latencies[Math.floor(latencies.length * 0.95)];
316 console.log(JSON.stringify({ clients: 20, requests: latencies.length, elapsedMs, cpuSeconds: cpu, p95Ms: p95, maxMs: latencies.at(-1), largeUnicodeMs: largeMs }));
317 assert.ok(elapsedMs < 30000, `load elapsed ${elapsedMs} ms`);
318 assert.ok(cpu < 15, `load consumed ${cpu} CPU seconds`);
319 assert.ok(p95 < 5000, `p95 ${p95} ms`);
320 const idleCpu = await cpuSeconds(child.pid);
321 await pause(16000); // includes one metadata census
322 const idleSeconds = (await cpuSeconds(child.pid)) - idleCpu;
323 console.log(JSON.stringify({ idleWallSeconds: 16, idleCpuSeconds: idleSeconds }));
324 assert.ok(idleSeconds < 1, `idle census consumed ${idleSeconds} CPU seconds`);
325 });
326 });