mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
Since e14af08f (#709), the sidecar strips the browser Origin header but
immediately replaces it with `http://127.0.0.1:<port>` (line 1321 of
local-api-server.mjs). This ensures local handlers receive a valid
Origin for CORS while preventing browser-supplied origins from leaking
into server-to-server calls.
The test was written before that commit and still asserted
`originPresent: false`. Update the test to:
- Assert `originPresent: true` (a localhost origin IS present)
- Assert `originValue` equals `http://127.0.0.1:<port>` (verify it's
the replaced localhost origin, not the browser's)
- Rename the test to describe the actual behavior
npm run test:sidecar: 55/55 pass ✅
1389 lines
43 KiB
JavaScript
1389 lines
43 KiB
JavaScript
import { strict as assert } from 'node:assert';
|
|
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
|
|
import { createServer, request as httpRequest } from 'node:http';
|
|
import https from 'node:https';
|
|
import { EventEmitter } from 'node:events';
|
|
import { brotliDecompressSync, gunzipSync } from 'node:zlib';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import test from 'node:test';
|
|
import { createLocalApiServer } from './local-api-server.mjs';
|
|
|
|
async function listen(server, host = '127.0.0.1', port = 0) {
|
|
await new Promise((resolve, reject) => {
|
|
const onListening = () => {
|
|
server.off('error', onError);
|
|
resolve();
|
|
};
|
|
const onError = (error) => {
|
|
server.off('listening', onListening);
|
|
reject(error);
|
|
};
|
|
server.once('listening', onListening);
|
|
server.once('error', onError);
|
|
server.listen(port, host);
|
|
});
|
|
|
|
const address = server.address();
|
|
if (!address || typeof address === 'string') {
|
|
throw new Error('Failed to resolve server address');
|
|
}
|
|
return address.port;
|
|
}
|
|
|
|
async function postJsonViaHttp(url, payload) {
|
|
const target = new URL(url);
|
|
const body = JSON.stringify(payload);
|
|
return new Promise((resolve, reject) => {
|
|
const req = httpRequest({
|
|
hostname: target.hostname,
|
|
port: Number(target.port || 80),
|
|
path: `${target.pathname}${target.search}`,
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Content-Length': String(Buffer.byteLength(body)),
|
|
},
|
|
}, (res) => {
|
|
const chunks = [];
|
|
res.on('data', (chunk) => chunks.push(chunk));
|
|
res.on('end', () => {
|
|
const text = Buffer.concat(chunks).toString('utf8');
|
|
let json = null;
|
|
try { json = JSON.parse(text); } catch { /* non-json response */ }
|
|
resolve({ status: res.statusCode || 0, text, json });
|
|
});
|
|
});
|
|
req.on('error', reject);
|
|
req.write(body);
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
function mockHttpsRequestOnce({ statusCode, headers, body }) {
|
|
const original = https.request;
|
|
https.request = (_options, onResponse) => {
|
|
const req = new EventEmitter();
|
|
req.setTimeout = () => { };
|
|
req.write = () => { };
|
|
req.destroy = (error) => {
|
|
if (error) req.emit('error', error);
|
|
};
|
|
req.end = () => {
|
|
queueMicrotask(() => {
|
|
const res = new EventEmitter();
|
|
res.statusCode = statusCode;
|
|
res.statusMessage = '';
|
|
res.headers = headers;
|
|
onResponse(res);
|
|
if (body) res.emit('data', Buffer.from(body));
|
|
res.emit('end');
|
|
});
|
|
};
|
|
return req;
|
|
};
|
|
return () => {
|
|
https.request = original;
|
|
};
|
|
}
|
|
|
|
async function setupRemoteServer() {
|
|
const hits = [];
|
|
const origins = [];
|
|
const server = createServer((req, res) => {
|
|
const url = new URL(req.url || '/', 'http://127.0.0.1');
|
|
hits.push(url.pathname);
|
|
origins.push(req.headers.origin || null);
|
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
res.end(JSON.stringify({
|
|
source: 'remote',
|
|
path: url.pathname,
|
|
origin: req.headers.origin || null,
|
|
}));
|
|
});
|
|
|
|
const port = await listen(server);
|
|
return {
|
|
hits,
|
|
origins,
|
|
remoteBase: `http://127.0.0.1:${port}`,
|
|
async close() {
|
|
await new Promise((resolve, reject) => {
|
|
server.close((error) => (error ? reject(error) : resolve()));
|
|
});
|
|
},
|
|
};
|
|
}
|
|
|
|
async function setupApiDir(files) {
|
|
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'wm-sidecar-test-'));
|
|
const apiDir = path.join(tempRoot, 'api');
|
|
await mkdir(apiDir, { recursive: true });
|
|
|
|
await Promise.all(
|
|
Object.entries(files).map(async ([relativePath, source]) => {
|
|
const absolute = path.join(apiDir, relativePath);
|
|
await mkdir(path.dirname(absolute), { recursive: true });
|
|
await writeFile(absolute, source, 'utf8');
|
|
})
|
|
);
|
|
|
|
return {
|
|
apiDir,
|
|
async cleanup() {
|
|
await rm(tempRoot, { recursive: true, force: true });
|
|
},
|
|
};
|
|
}
|
|
|
|
async function setupResourceDirWithUpApi(files) {
|
|
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'wm-sidecar-resource-test-'));
|
|
const apiDir = path.join(tempRoot, '_up_', 'api');
|
|
await mkdir(apiDir, { recursive: true });
|
|
|
|
await Promise.all(
|
|
Object.entries(files).map(async ([relativePath, source]) => {
|
|
const absolute = path.join(apiDir, relativePath);
|
|
await mkdir(path.dirname(absolute), { recursive: true });
|
|
await writeFile(absolute, source, 'utf8');
|
|
})
|
|
);
|
|
|
|
return {
|
|
resourceDir: tempRoot,
|
|
apiDir,
|
|
async cleanup() {
|
|
await rm(tempRoot, { recursive: true, force: true });
|
|
},
|
|
};
|
|
}
|
|
|
|
test('returns local error directly when cloudFallback is off (default)', async () => {
|
|
const remote = await setupRemoteServer();
|
|
const localApi = await setupApiDir({
|
|
'fred-data.js': `
|
|
export default async function handler() {
|
|
return new Response(JSON.stringify({ source: 'local-error' }), {
|
|
status: 500,
|
|
headers: { 'content-type': 'application/json' }
|
|
});
|
|
}
|
|
`,
|
|
});
|
|
|
|
const app = await createLocalApiServer({
|
|
port: 0,
|
|
apiDir: localApi.apiDir,
|
|
remoteBase: remote.remoteBase,
|
|
logger: { log() { }, warn() { }, error() { } },
|
|
});
|
|
const { port } = await app.start();
|
|
|
|
try {
|
|
const response = await fetch(`http://127.0.0.1:${port}/api/fred-data`);
|
|
assert.equal(response.status, 500);
|
|
const body = await response.json();
|
|
assert.equal(body.source, 'local-error');
|
|
assert.equal(remote.hits.length, 0);
|
|
} finally {
|
|
await app.close();
|
|
await localApi.cleanup();
|
|
await remote.close();
|
|
}
|
|
});
|
|
|
|
test('falls back to cloud when cloudFallback is enabled and local handler returns 500', async () => {
|
|
const remote = await setupRemoteServer();
|
|
const localApi = await setupApiDir({
|
|
'fred-data.js': `
|
|
export default async function handler() {
|
|
return new Response(JSON.stringify({ source: 'local-error' }), {
|
|
status: 500,
|
|
headers: { 'content-type': 'application/json' }
|
|
});
|
|
}
|
|
`,
|
|
});
|
|
|
|
const app = await createLocalApiServer({
|
|
port: 0,
|
|
apiDir: localApi.apiDir,
|
|
remoteBase: remote.remoteBase,
|
|
cloudFallback: 'true',
|
|
logger: { log() { }, warn() { }, error() { } },
|
|
});
|
|
const { port } = await app.start();
|
|
|
|
try {
|
|
const response = await fetch(`http://127.0.0.1:${port}/api/fred-data`);
|
|
assert.equal(response.status, 200);
|
|
const body = await response.json();
|
|
assert.equal(body.source, 'remote');
|
|
assert.equal(remote.hits.includes('/api/fred-data'), true);
|
|
} finally {
|
|
await app.close();
|
|
await localApi.cleanup();
|
|
await remote.close();
|
|
}
|
|
});
|
|
|
|
test('preserves POST body when cloud fallback is triggered after local non-OK response', async () => {
|
|
const remoteBodies = [];
|
|
const remote = createServer((req, res) => {
|
|
const chunks = [];
|
|
req.on('data', (chunk) => chunks.push(chunk));
|
|
req.on('end', () => {
|
|
const body = Buffer.concat(chunks).toString('utf8');
|
|
remoteBodies.push(body);
|
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
res.end(JSON.stringify({ source: 'remote', body }));
|
|
});
|
|
});
|
|
const remotePort = await listen(remote);
|
|
|
|
const localApi = await setupApiDir({
|
|
'post-fail.js': `
|
|
export default async function handler(req) {
|
|
await req.text();
|
|
return new Response(JSON.stringify({ source: 'local-error' }), {
|
|
status: 500,
|
|
headers: { 'content-type': 'application/json' }
|
|
});
|
|
}
|
|
`,
|
|
});
|
|
|
|
const app = await createLocalApiServer({
|
|
port: 0,
|
|
apiDir: localApi.apiDir,
|
|
remoteBase: `http://127.0.0.1:${remotePort}`,
|
|
cloudFallback: 'true',
|
|
logger: { log() { }, warn() { }, error() { } },
|
|
});
|
|
const { port } = await app.start();
|
|
|
|
try {
|
|
const payload = JSON.stringify({ secret: 'keep-body' });
|
|
const response = await fetch(`http://127.0.0.1:${port}/api/post-fail`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: payload,
|
|
});
|
|
assert.equal(response.status, 200);
|
|
|
|
const body = await response.json();
|
|
assert.equal(body.source, 'remote');
|
|
assert.equal(body.body, payload);
|
|
assert.equal(remoteBodies[0], payload);
|
|
} finally {
|
|
await app.close();
|
|
await localApi.cleanup();
|
|
await new Promise((resolve, reject) => {
|
|
remote.close((error) => (error ? reject(error) : resolve()));
|
|
});
|
|
}
|
|
});
|
|
|
|
test('uses local handler response when local handler succeeds', async () => {
|
|
const remote = await setupRemoteServer();
|
|
const localApi = await setupApiDir({
|
|
'live.js': `
|
|
export default async function handler() {
|
|
return new Response(JSON.stringify({ source: 'local-ok' }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' }
|
|
});
|
|
}
|
|
`,
|
|
});
|
|
|
|
const app = await createLocalApiServer({
|
|
port: 0,
|
|
apiDir: localApi.apiDir,
|
|
remoteBase: remote.remoteBase,
|
|
logger: { log() { }, warn() { }, error() { } },
|
|
});
|
|
const { port } = await app.start();
|
|
|
|
try {
|
|
const response = await fetch(`http://127.0.0.1:${port}/api/live`);
|
|
assert.equal(response.status, 200);
|
|
const body = await response.json();
|
|
assert.equal(body.source, 'local-ok');
|
|
assert.equal(remote.hits.length, 0);
|
|
} finally {
|
|
await app.close();
|
|
await localApi.cleanup();
|
|
await remote.close();
|
|
}
|
|
});
|
|
|
|
test('returns 404 when local route does not exist and cloudFallback is off', async () => {
|
|
const remote = await setupRemoteServer();
|
|
const localApi = await setupApiDir({});
|
|
|
|
const app = await createLocalApiServer({
|
|
port: 0,
|
|
apiDir: localApi.apiDir,
|
|
remoteBase: remote.remoteBase,
|
|
logger: { log() { }, warn() { }, error() { } },
|
|
});
|
|
const { port } = await app.start();
|
|
|
|
try {
|
|
const response = await fetch(`http://127.0.0.1:${port}/api/not-found`);
|
|
assert.equal(response.status, 404);
|
|
const body = await response.json();
|
|
assert.equal(body.error, 'No local handler for this endpoint');
|
|
assert.equal(remote.hits.length, 0);
|
|
} finally {
|
|
await app.close();
|
|
await localApi.cleanup();
|
|
await remote.close();
|
|
}
|
|
});
|
|
|
|
test('replaces browser origin with localhost origin for local handlers', async () => {
|
|
const remote = await setupRemoteServer();
|
|
const localApi = await setupApiDir({
|
|
'origin-check.js': `
|
|
export default async function handler(req) {
|
|
const origin = req.headers.get('origin');
|
|
return new Response(JSON.stringify({
|
|
source: 'local',
|
|
originPresent: Boolean(origin),
|
|
originValue: origin || null,
|
|
}), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' }
|
|
});
|
|
}
|
|
`,
|
|
});
|
|
|
|
const app = await createLocalApiServer({
|
|
port: 0,
|
|
apiDir: localApi.apiDir,
|
|
remoteBase: remote.remoteBase,
|
|
logger: { log() { }, warn() { }, error() { } },
|
|
});
|
|
const { port } = await app.start();
|
|
|
|
try {
|
|
const response = await fetch(`http://127.0.0.1:${port}/api/origin-check`, {
|
|
headers: { Origin: 'https://tauri.localhost' },
|
|
});
|
|
assert.equal(response.status, 200);
|
|
const body = await response.json();
|
|
assert.equal(body.source, 'local');
|
|
// Since e14af08f (#709) the server strips the browser Origin but
|
|
// immediately replaces it with `http://127.0.0.1:<port>`, so the
|
|
// handler does receive an Origin header — just the localhost one.
|
|
assert.equal(body.originPresent, true);
|
|
assert.equal(body.originValue, `http://127.0.0.1:${port}`);
|
|
assert.equal(remote.hits.length, 0);
|
|
} finally {
|
|
await app.close();
|
|
await localApi.cleanup();
|
|
await remote.close();
|
|
}
|
|
});
|
|
|
|
test('preserves Request body when handler uses fetch(Request)', async () => {
|
|
let receivedBody = '';
|
|
|
|
const upstream = createServer((req, res) => {
|
|
const chunks = [];
|
|
req.on('data', (chunk) => chunks.push(chunk));
|
|
req.on('end', () => {
|
|
receivedBody = Buffer.concat(chunks).toString('utf8');
|
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
res.end(JSON.stringify({ receivedBody }));
|
|
});
|
|
});
|
|
const upstreamPort = await listen(upstream);
|
|
process.env.WM_TEST_UPSTREAM = `http://127.0.0.1:${upstreamPort}`;
|
|
|
|
const localApi = await setupApiDir({
|
|
'request-proxy.js': `
|
|
export default async function handler() {
|
|
const request = new Request(\`\${process.env.WM_TEST_UPSTREAM}/echo\`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ secret: 'keep-body' }),
|
|
});
|
|
const upstream = await fetch(request);
|
|
const payload = await upstream.text();
|
|
return new Response(payload, {
|
|
status: upstream.status,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
`,
|
|
});
|
|
|
|
const app = await createLocalApiServer({
|
|
port: 0,
|
|
apiDir: localApi.apiDir,
|
|
logger: { log() { }, warn() { }, error() { } },
|
|
});
|
|
const { port } = await app.start();
|
|
|
|
try {
|
|
const response = await fetch(`http://127.0.0.1:${port}/api/request-proxy`);
|
|
assert.equal(response.status, 200);
|
|
const body = await response.json();
|
|
assert.equal(body.receivedBody.includes('"secret":"keep-body"'), true);
|
|
assert.equal(receivedBody.includes('"secret":"keep-body"'), true);
|
|
} finally {
|
|
delete process.env.WM_TEST_UPSTREAM;
|
|
await app.close();
|
|
await localApi.cleanup();
|
|
await new Promise((resolve, reject) => {
|
|
upstream.close((error) => (error ? reject(error) : resolve()));
|
|
});
|
|
}
|
|
});
|
|
|
|
test('returns local handler error when fetch(Request) uses a consumed body', async () => {
|
|
let upstreamHits = 0;
|
|
|
|
const upstream = createServer((req, res) => {
|
|
upstreamHits += 1;
|
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
res.end(JSON.stringify({ ok: true }));
|
|
});
|
|
const upstreamPort = await listen(upstream);
|
|
process.env.WM_TEST_UPSTREAM = `http://127.0.0.1:${upstreamPort}`;
|
|
|
|
const localApi = await setupApiDir({
|
|
'request-consumed.js': `
|
|
export default async function handler() {
|
|
const request = new Request(\`\${process.env.WM_TEST_UPSTREAM}/echo\`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ secret: 'used-body' }),
|
|
});
|
|
await request.text();
|
|
await fetch(request);
|
|
return new Response(JSON.stringify({ ok: true }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
`,
|
|
});
|
|
|
|
const app = await createLocalApiServer({
|
|
port: 0,
|
|
apiDir: localApi.apiDir,
|
|
logger: { log() { }, warn() { }, error() { } },
|
|
});
|
|
const { port } = await app.start();
|
|
|
|
try {
|
|
const response = await fetch(`http://127.0.0.1:${port}/api/request-consumed`);
|
|
assert.equal(response.status, 502);
|
|
const body = await response.json();
|
|
assert.equal(body.error, 'Local handler error');
|
|
assert.equal(typeof body.reason, 'string');
|
|
assert.equal(body.reason.length > 0, true);
|
|
assert.equal(upstreamHits, 0);
|
|
} finally {
|
|
delete process.env.WM_TEST_UPSTREAM;
|
|
await app.close();
|
|
await localApi.cleanup();
|
|
await new Promise((resolve, reject) => {
|
|
upstream.close((error) => (error ? reject(error) : resolve()));
|
|
});
|
|
}
|
|
});
|
|
|
|
test('strips browser origin headers when proxying to cloud fallback (cloudFallback enabled)', async () => {
|
|
const remote = await setupRemoteServer();
|
|
const localApi = await setupApiDir({});
|
|
|
|
const app = await createLocalApiServer({
|
|
port: 0,
|
|
apiDir: localApi.apiDir,
|
|
remoteBase: remote.remoteBase,
|
|
cloudFallback: 'true',
|
|
logger: { log() { }, warn() { }, error() { } },
|
|
});
|
|
const { port } = await app.start();
|
|
|
|
try {
|
|
const response = await fetch(`http://127.0.0.1:${port}/api/no-local-handler`, {
|
|
headers: { Origin: 'https://tauri.localhost' },
|
|
});
|
|
assert.equal(response.status, 200);
|
|
const body = await response.json();
|
|
assert.equal(body.source, 'remote');
|
|
assert.equal(body.origin, null);
|
|
assert.equal(remote.origins[0], null);
|
|
} finally {
|
|
await app.close();
|
|
await localApi.cleanup();
|
|
await remote.close();
|
|
}
|
|
});
|
|
|
|
test('responds to OPTIONS preflight with CORS headers', async () => {
|
|
const localApi = await setupApiDir({
|
|
'data.js': `
|
|
export default async function handler() {
|
|
return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });
|
|
}
|
|
`,
|
|
});
|
|
|
|
const app = await createLocalApiServer({
|
|
port: 0,
|
|
apiDir: localApi.apiDir,
|
|
logger: { log() { }, warn() { }, error() { } },
|
|
});
|
|
const { port } = await app.start();
|
|
|
|
try {
|
|
const response = await fetch(`http://127.0.0.1:${port}/api/data`, { method: 'OPTIONS' });
|
|
assert.equal(response.status, 204);
|
|
assert.equal(response.headers.get('access-control-allow-methods'), 'GET, POST, PUT, DELETE, OPTIONS');
|
|
} finally {
|
|
await app.close();
|
|
await localApi.cleanup();
|
|
}
|
|
});
|
|
|
|
test('preserves Origin in Vary when gzip compression is applied', async () => {
|
|
const localApi = await setupApiDir({
|
|
'large.js': `
|
|
export default async function handler() {
|
|
return new Response(JSON.stringify({ payload: 'x'.repeat(4096) }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' }
|
|
});
|
|
}
|
|
`,
|
|
});
|
|
|
|
const app = await createLocalApiServer({
|
|
port: 0,
|
|
apiDir: localApi.apiDir,
|
|
logger: { log() { }, warn() { }, error() { } },
|
|
});
|
|
const { port } = await app.start();
|
|
|
|
try {
|
|
const response = await fetch(`http://127.0.0.1:${port}/api/large`, {
|
|
headers: {
|
|
Origin: 'https://tauri.localhost',
|
|
'Accept-Encoding': 'gzip',
|
|
},
|
|
});
|
|
|
|
assert.equal(response.status, 200);
|
|
assert.equal(response.headers.get('access-control-allow-origin'), 'https://tauri.localhost');
|
|
assert.equal(response.headers.get('content-encoding'), 'gzip');
|
|
|
|
const vary = (response.headers.get('vary') || '')
|
|
.split(',')
|
|
.map((part) => part.trim().toLowerCase())
|
|
.filter(Boolean);
|
|
|
|
assert.equal(vary.includes('origin'), true);
|
|
assert.equal(vary.includes('accept-encoding'), true);
|
|
} finally {
|
|
await app.close();
|
|
await localApi.cleanup();
|
|
}
|
|
});
|
|
|
|
test('resolves packaged tauri resource layout under _up_/api', async () => {
|
|
const remote = await setupRemoteServer();
|
|
const localResource = await setupResourceDirWithUpApi({
|
|
'live.js': `
|
|
export default async function handler() {
|
|
return new Response(JSON.stringify({ source: 'local-up' }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' }
|
|
});
|
|
}
|
|
`,
|
|
});
|
|
|
|
const app = await createLocalApiServer({
|
|
port: 0,
|
|
resourceDir: localResource.resourceDir,
|
|
remoteBase: remote.remoteBase,
|
|
logger: { log() { }, warn() { }, error() { } },
|
|
});
|
|
const { port } = await app.start();
|
|
|
|
try {
|
|
assert.equal(app.context.apiDir, localResource.apiDir);
|
|
assert.equal(app.routes.length, 1);
|
|
|
|
const response = await fetch(`http://127.0.0.1:${port}/api/live`);
|
|
assert.equal(response.status, 200);
|
|
const body = await response.json();
|
|
assert.equal(body.source, 'local-up');
|
|
assert.equal(remote.hits.length, 0);
|
|
} finally {
|
|
await app.close();
|
|
await localResource.cleanup();
|
|
await remote.close();
|
|
}
|
|
});
|
|
|
|
// ── Ollama env key allowlist + validation tests ──
|
|
|
|
test('accepts OLLAMA_API_URL via /api/local-env-update', async () => {
|
|
const localApi = await setupApiDir({});
|
|
|
|
const app = await createLocalApiServer({
|
|
port: 0,
|
|
apiDir: localApi.apiDir,
|
|
logger: { log() { }, warn() { }, error() { } },
|
|
});
|
|
const { port } = await app.start();
|
|
|
|
try {
|
|
const response = await fetch(`http://127.0.0.1:${port}/api/local-env-update`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ key: 'OLLAMA_API_URL', value: 'http://127.0.0.1:11434' }),
|
|
});
|
|
assert.equal(response.status, 200);
|
|
const body = await response.json();
|
|
assert.equal(body.ok, true);
|
|
assert.equal(body.key, 'OLLAMA_API_URL');
|
|
assert.equal(process.env.OLLAMA_API_URL, 'http://127.0.0.1:11434');
|
|
} finally {
|
|
delete process.env.OLLAMA_API_URL;
|
|
await app.close();
|
|
await localApi.cleanup();
|
|
}
|
|
});
|
|
|
|
test('accepts OLLAMA_MODEL via /api/local-env-update', async () => {
|
|
const localApi = await setupApiDir({});
|
|
|
|
const app = await createLocalApiServer({
|
|
port: 0,
|
|
apiDir: localApi.apiDir,
|
|
logger: { log() { }, warn() { }, error() { } },
|
|
});
|
|
const { port } = await app.start();
|
|
|
|
try {
|
|
const response = await fetch(`http://127.0.0.1:${port}/api/local-env-update`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ key: 'OLLAMA_MODEL', value: 'llama3.1:8b' }),
|
|
});
|
|
assert.equal(response.status, 200);
|
|
const body = await response.json();
|
|
assert.equal(body.ok, true);
|
|
assert.equal(body.key, 'OLLAMA_MODEL');
|
|
assert.equal(process.env.OLLAMA_MODEL, 'llama3.1:8b');
|
|
} finally {
|
|
delete process.env.OLLAMA_MODEL;
|
|
await app.close();
|
|
await localApi.cleanup();
|
|
}
|
|
});
|
|
|
|
test('rejects unknown key via /api/local-env-update', async () => {
|
|
const localApi = await setupApiDir({});
|
|
|
|
const app = await createLocalApiServer({
|
|
port: 0,
|
|
apiDir: localApi.apiDir,
|
|
logger: { log() { }, warn() { }, error() { } },
|
|
});
|
|
const { port } = await app.start();
|
|
|
|
try {
|
|
const response = await fetch(`http://127.0.0.1:${port}/api/local-env-update`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ key: 'NOT_ALLOWED_KEY', value: 'some-value' }),
|
|
});
|
|
assert.equal(response.status, 403);
|
|
const body = await response.json();
|
|
assert.equal(body.error, 'key not in allowlist');
|
|
} finally {
|
|
await app.close();
|
|
await localApi.cleanup();
|
|
}
|
|
});
|
|
|
|
test('validates OLLAMA_API_URL via /api/local-validate-secret (reachable endpoint)', async () => {
|
|
// Stand up a mock Ollama server that responds to /v1/models
|
|
const mockOllama = createServer((req, res) => {
|
|
if (req.url === '/v1/models') {
|
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
res.end(JSON.stringify({ data: [{ id: 'llama3.1:8b' }] }));
|
|
} else {
|
|
res.writeHead(404);
|
|
res.end('not found');
|
|
}
|
|
});
|
|
const ollamaPort = await listen(mockOllama);
|
|
|
|
const localApi = await setupApiDir({});
|
|
const app = await createLocalApiServer({
|
|
port: 0,
|
|
apiDir: localApi.apiDir,
|
|
logger: { log() { }, warn() { }, error() { } },
|
|
});
|
|
const { port } = await app.start();
|
|
|
|
try {
|
|
const response = await fetch(`http://127.0.0.1:${port}/api/local-validate-secret`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ key: 'OLLAMA_API_URL', value: `http://127.0.0.1:${ollamaPort}` }),
|
|
});
|
|
assert.equal(response.status, 200);
|
|
const body = await response.json();
|
|
assert.equal(body.valid, true);
|
|
assert.equal(body.message, 'Ollama endpoint verified');
|
|
} finally {
|
|
await app.close();
|
|
await localApi.cleanup();
|
|
await new Promise((resolve, reject) => {
|
|
mockOllama.close((err) => (err ? reject(err) : resolve()));
|
|
});
|
|
}
|
|
});
|
|
|
|
test('validates LM Studio style /v1 base URL via /api/local-validate-secret', async () => {
|
|
const mockOpenAiCompatible = createServer((req, res) => {
|
|
if (req.url === '/v1/models') {
|
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
res.end(JSON.stringify({ data: [{ id: 'qwen2.5-7b-instruct' }] }));
|
|
} else {
|
|
res.writeHead(404);
|
|
res.end('not found');
|
|
}
|
|
});
|
|
const providerPort = await listen(mockOpenAiCompatible);
|
|
|
|
const localApi = await setupApiDir({});
|
|
const app = await createLocalApiServer({
|
|
port: 0,
|
|
apiDir: localApi.apiDir,
|
|
logger: { log() { }, warn() { }, error() { } },
|
|
});
|
|
const { port } = await app.start();
|
|
|
|
try {
|
|
const response = await fetch(`http://127.0.0.1:${port}/api/local-validate-secret`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ key: 'OLLAMA_API_URL', value: `http://127.0.0.1:${providerPort}/v1` }),
|
|
});
|
|
assert.equal(response.status, 200);
|
|
const body = await response.json();
|
|
assert.equal(body.valid, true);
|
|
assert.equal(body.message, 'Ollama endpoint verified');
|
|
} finally {
|
|
await app.close();
|
|
await localApi.cleanup();
|
|
await new Promise((resolve, reject) => {
|
|
mockOpenAiCompatible.close((err) => (err ? reject(err) : resolve()));
|
|
});
|
|
}
|
|
});
|
|
|
|
test('validates OLLAMA_API_URL via native /api/tags fallback', async () => {
|
|
// Mock server that only responds to /api/tags (not /v1/models)
|
|
const mockOllama = createServer((req, res) => {
|
|
if (req.url === '/api/tags') {
|
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
res.end(JSON.stringify({ models: [{ name: 'llama3.1:8b' }] }));
|
|
} else {
|
|
res.writeHead(404);
|
|
res.end('not found');
|
|
}
|
|
});
|
|
const ollamaPort = await listen(mockOllama);
|
|
|
|
const localApi = await setupApiDir({});
|
|
const app = await createLocalApiServer({
|
|
port: 0,
|
|
apiDir: localApi.apiDir,
|
|
logger: { log() { }, warn() { }, error() { } },
|
|
});
|
|
const { port } = await app.start();
|
|
|
|
try {
|
|
const response = await fetch(`http://127.0.0.1:${port}/api/local-validate-secret`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ key: 'OLLAMA_API_URL', value: `http://127.0.0.1:${ollamaPort}` }),
|
|
});
|
|
assert.equal(response.status, 200);
|
|
const body = await response.json();
|
|
assert.equal(body.valid, true);
|
|
assert.equal(body.message, 'Ollama endpoint verified (native API)');
|
|
} finally {
|
|
await app.close();
|
|
await localApi.cleanup();
|
|
await new Promise((resolve, reject) => {
|
|
mockOllama.close((err) => (err ? reject(err) : resolve()));
|
|
});
|
|
}
|
|
});
|
|
|
|
test('validates OLLAMA_MODEL stores model name', async () => {
|
|
const localApi = await setupApiDir({});
|
|
const app = await createLocalApiServer({
|
|
port: 0,
|
|
apiDir: localApi.apiDir,
|
|
logger: { log() { }, warn() { }, error() { } },
|
|
});
|
|
const { port } = await app.start();
|
|
|
|
try {
|
|
const response = await fetch(`http://127.0.0.1:${port}/api/local-validate-secret`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ key: 'OLLAMA_MODEL', value: 'mistral:7b' }),
|
|
});
|
|
assert.equal(response.status, 200);
|
|
const body = await response.json();
|
|
assert.equal(body.valid, true);
|
|
assert.equal(body.message, 'Model name stored');
|
|
} finally {
|
|
await app.close();
|
|
await localApi.cleanup();
|
|
}
|
|
});
|
|
|
|
test('rejects OLLAMA_API_URL with non-http protocol', async () => {
|
|
const localApi = await setupApiDir({});
|
|
const app = await createLocalApiServer({
|
|
port: 0,
|
|
apiDir: localApi.apiDir,
|
|
logger: { log() { }, warn() { }, error() { } },
|
|
});
|
|
const { port } = await app.start();
|
|
|
|
try {
|
|
const response = await fetch(`http://127.0.0.1:${port}/api/local-validate-secret`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ key: 'OLLAMA_API_URL', value: 'ftp://127.0.0.1:11434' }),
|
|
});
|
|
assert.equal(response.status, 422);
|
|
const body = await response.json();
|
|
assert.equal(body.valid, false);
|
|
assert.equal(body.message, 'Must be an http(s) URL');
|
|
} finally {
|
|
await app.close();
|
|
await localApi.cleanup();
|
|
}
|
|
});
|
|
|
|
test('treats Cloudflare challenge 403 as soft-pass during secret validation', async () => {
|
|
const localApi = await setupApiDir({});
|
|
const restoreHttps = mockHttpsRequestOnce({
|
|
statusCode: 403,
|
|
headers: {
|
|
'content-type': 'text/html; charset=utf-8',
|
|
'cf-ray': 'abc123',
|
|
},
|
|
body: '<html><title>Attention Required</title><body>Cloudflare Ray ID: 123</body></html>',
|
|
});
|
|
|
|
const app = await createLocalApiServer({
|
|
port: 0,
|
|
apiDir: localApi.apiDir,
|
|
logger: { log() { }, warn() { }, error() { } },
|
|
});
|
|
const { port } = await app.start();
|
|
|
|
try {
|
|
const response = await postJsonViaHttp(`http://127.0.0.1:${port}/api/local-validate-secret`, {
|
|
key: 'GROQ_API_KEY',
|
|
value: 'dummy-key',
|
|
});
|
|
assert.equal(response.status, 200);
|
|
assert.equal(response.json?.valid, true);
|
|
assert.equal(response.json?.message, 'Groq key stored (Cloudflare blocked verification)');
|
|
} finally {
|
|
restoreHttps();
|
|
await app.close();
|
|
await localApi.cleanup();
|
|
}
|
|
});
|
|
|
|
test('does not soft-pass provider auth 403 JSON responses even with cf-ray header', async () => {
|
|
const localApi = await setupApiDir({});
|
|
const restoreHttps = mockHttpsRequestOnce({
|
|
statusCode: 403,
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
'cf-ray': 'abc123',
|
|
},
|
|
body: JSON.stringify({ error: 'invalid api key' }),
|
|
});
|
|
|
|
const app = await createLocalApiServer({
|
|
port: 0,
|
|
apiDir: localApi.apiDir,
|
|
logger: { log() { }, warn() { }, error() { } },
|
|
});
|
|
const { port } = await app.start();
|
|
|
|
try {
|
|
const response = await postJsonViaHttp(`http://127.0.0.1:${port}/api/local-validate-secret`, {
|
|
key: 'GROQ_API_KEY',
|
|
value: 'invalid-key',
|
|
});
|
|
assert.equal(response.status, 422);
|
|
assert.equal(response.json?.valid, false);
|
|
assert.equal(response.json?.message, 'Groq rejected this key');
|
|
} finally {
|
|
restoreHttps();
|
|
await app.close();
|
|
await localApi.cleanup();
|
|
}
|
|
});
|
|
|
|
test('auth-required behavior unchanged — rejects unauthenticated requests when token is set', async () => {
|
|
const localApi = await setupApiDir({});
|
|
const originalToken = process.env.LOCAL_API_TOKEN;
|
|
process.env.LOCAL_API_TOKEN = 'secret-token-123';
|
|
|
|
const app = await createLocalApiServer({
|
|
port: 0,
|
|
apiDir: localApi.apiDir,
|
|
logger: { log() { }, warn() { }, error() { } },
|
|
});
|
|
const { port } = await app.start();
|
|
|
|
try {
|
|
// Request without auth header should be rejected
|
|
const response = await fetch(`http://127.0.0.1:${port}/api/local-env-update`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ key: 'OLLAMA_API_URL', value: 'http://127.0.0.1:11434' }),
|
|
});
|
|
assert.equal(response.status, 401);
|
|
const body = await response.json();
|
|
assert.equal(body.error, 'Unauthorized');
|
|
|
|
// Request with correct auth header should succeed
|
|
const authedResponse = await fetch(`http://127.0.0.1:${port}/api/local-env-update`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': 'Bearer secret-token-123',
|
|
},
|
|
body: JSON.stringify({ key: 'OLLAMA_API_URL', value: 'http://127.0.0.1:11434' }),
|
|
});
|
|
assert.equal(authedResponse.status, 200);
|
|
} finally {
|
|
if (originalToken !== undefined) {
|
|
process.env.LOCAL_API_TOKEN = originalToken;
|
|
} else {
|
|
delete process.env.LOCAL_API_TOKEN;
|
|
}
|
|
delete process.env.OLLAMA_API_URL;
|
|
await app.close();
|
|
await localApi.cleanup();
|
|
}
|
|
});
|
|
|
|
|
|
test('prefers Brotli compression for payloads larger than 1KB when supported by the client', async () => {
|
|
const remote = await setupRemoteServer();
|
|
const localApi = await setupApiDir({
|
|
'compression-check.js': `
|
|
export default async function handler() {
|
|
const payload = { value: 'x'.repeat(3000) };
|
|
return new Response(JSON.stringify(payload), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' }
|
|
});
|
|
}
|
|
`,
|
|
});
|
|
|
|
const app = await createLocalApiServer({
|
|
port: 0,
|
|
apiDir: localApi.apiDir,
|
|
remoteBase: remote.remoteBase,
|
|
logger: { log() { }, warn() { }, error() { } },
|
|
});
|
|
const { port } = await app.start();
|
|
|
|
try {
|
|
const response = await fetch(`http://127.0.0.1:${port}/api/compression-check`, {
|
|
headers: { 'Accept-Encoding': 'gzip, br' },
|
|
});
|
|
assert.equal(response.status, 200);
|
|
assert.equal(response.headers.get('content-encoding'), 'br');
|
|
|
|
const compressed = Buffer.from(await response.arrayBuffer());
|
|
const decompressed = brotliDecompressSync(compressed).toString('utf8');
|
|
const body = JSON.parse(decompressed);
|
|
assert.equal(body.value.length, 3000);
|
|
assert.equal(remote.hits.length, 0);
|
|
} finally {
|
|
await app.close();
|
|
await localApi.cleanup();
|
|
await remote.close();
|
|
}
|
|
});
|
|
|
|
test('uses gzip compression when Brotli is unavailable but gzip is accepted', async () => {
|
|
const remote = await setupRemoteServer();
|
|
const localApi = await setupApiDir({
|
|
'compression-check.js': `
|
|
export default async function handler() {
|
|
const payload = { value: 'x'.repeat(3000) };
|
|
return new Response(JSON.stringify(payload), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' }
|
|
});
|
|
}
|
|
`,
|
|
});
|
|
|
|
const app = await createLocalApiServer({
|
|
port: 0,
|
|
apiDir: localApi.apiDir,
|
|
remoteBase: remote.remoteBase,
|
|
logger: { log() { }, warn() { }, error() { } },
|
|
});
|
|
const { port } = await app.start();
|
|
|
|
try {
|
|
const response = await fetch(`http://127.0.0.1:${port}/api/compression-check`, {
|
|
headers: { 'Accept-Encoding': 'gzip' },
|
|
});
|
|
assert.equal(response.status, 200);
|
|
assert.equal(response.headers.get('content-encoding'), 'gzip');
|
|
|
|
const compressed = Buffer.from(await response.arrayBuffer());
|
|
const decompressed = gunzipSync(compressed).toString('utf8');
|
|
const body = JSON.parse(decompressed);
|
|
assert.equal(body.value.length, 3000);
|
|
assert.equal(remote.hits.length, 0);
|
|
} finally {
|
|
await app.close();
|
|
await localApi.cleanup();
|
|
await remote.close();
|
|
}
|
|
});
|
|
|
|
// ── Security hardening tests ────────────────────────────────────────────
|
|
|
|
test('rejects unauthenticated requests to /api/local-status when token is set', async () => {
|
|
const localApi = await setupApiDir({});
|
|
const originalToken = process.env.LOCAL_API_TOKEN;
|
|
process.env.LOCAL_API_TOKEN = 'security-test-token';
|
|
|
|
const app = await createLocalApiServer({
|
|
port: 0,
|
|
apiDir: localApi.apiDir,
|
|
logger: { log() { }, warn() { }, error() { } },
|
|
});
|
|
const { port } = await app.start();
|
|
|
|
try {
|
|
const response = await fetch(`http://127.0.0.1:${port}/api/local-status`);
|
|
assert.equal(response.status, 401);
|
|
const body = await response.json();
|
|
assert.equal(body.error, 'Unauthorized');
|
|
|
|
// With token should succeed
|
|
const authed = await fetch(`http://127.0.0.1:${port}/api/local-status`, {
|
|
headers: { 'Authorization': 'Bearer security-test-token' },
|
|
});
|
|
assert.equal(authed.status, 200);
|
|
} finally {
|
|
if (originalToken !== undefined) {
|
|
process.env.LOCAL_API_TOKEN = originalToken;
|
|
} else {
|
|
delete process.env.LOCAL_API_TOKEN;
|
|
}
|
|
await app.close();
|
|
await localApi.cleanup();
|
|
}
|
|
});
|
|
|
|
test('rejects unauthenticated requests to /api/local-traffic-log when token is set', async () => {
|
|
const localApi = await setupApiDir({});
|
|
const originalToken = process.env.LOCAL_API_TOKEN;
|
|
process.env.LOCAL_API_TOKEN = 'security-test-token';
|
|
|
|
const app = await createLocalApiServer({
|
|
port: 0,
|
|
apiDir: localApi.apiDir,
|
|
logger: { log() { }, warn() { }, error() { } },
|
|
});
|
|
const { port } = await app.start();
|
|
|
|
try {
|
|
const response = await fetch(`http://127.0.0.1:${port}/api/local-traffic-log`);
|
|
assert.equal(response.status, 401);
|
|
} finally {
|
|
if (originalToken !== undefined) {
|
|
process.env.LOCAL_API_TOKEN = originalToken;
|
|
} else {
|
|
delete process.env.LOCAL_API_TOKEN;
|
|
}
|
|
await app.close();
|
|
await localApi.cleanup();
|
|
}
|
|
});
|
|
|
|
test('rejects unauthenticated requests to /api/local-debug-toggle when token is set', async () => {
|
|
const localApi = await setupApiDir({});
|
|
const originalToken = process.env.LOCAL_API_TOKEN;
|
|
process.env.LOCAL_API_TOKEN = 'security-test-token';
|
|
|
|
const app = await createLocalApiServer({
|
|
port: 0,
|
|
apiDir: localApi.apiDir,
|
|
logger: { log() { }, warn() { }, error() { } },
|
|
});
|
|
const { port } = await app.start();
|
|
|
|
try {
|
|
const response = await fetch(`http://127.0.0.1:${port}/api/local-debug-toggle`);
|
|
assert.equal(response.status, 401);
|
|
} finally {
|
|
if (originalToken !== undefined) {
|
|
process.env.LOCAL_API_TOKEN = originalToken;
|
|
} else {
|
|
delete process.env.LOCAL_API_TOKEN;
|
|
}
|
|
await app.close();
|
|
await localApi.cleanup();
|
|
}
|
|
});
|
|
|
|
test('rejects unauthenticated requests to /api/rss-proxy when token is set', async () => {
|
|
const localApi = await setupApiDir({});
|
|
const originalToken = process.env.LOCAL_API_TOKEN;
|
|
process.env.LOCAL_API_TOKEN = 'security-test-token';
|
|
|
|
const app = await createLocalApiServer({
|
|
port: 0,
|
|
apiDir: localApi.apiDir,
|
|
logger: { log() { }, warn() { }, error() { } },
|
|
});
|
|
const { port } = await app.start();
|
|
|
|
try {
|
|
const response = await fetch(`http://127.0.0.1:${port}/api/rss-proxy?url=https://example.com/rss`);
|
|
assert.equal(response.status, 401);
|
|
} finally {
|
|
if (originalToken !== undefined) {
|
|
process.env.LOCAL_API_TOKEN = originalToken;
|
|
} else {
|
|
delete process.env.LOCAL_API_TOKEN;
|
|
}
|
|
await app.close();
|
|
await localApi.cleanup();
|
|
}
|
|
});
|
|
|
|
test('allows unauthenticated requests to /api/service-status (health check exempt)', async () => {
|
|
const localApi = await setupApiDir({});
|
|
const originalToken = process.env.LOCAL_API_TOKEN;
|
|
process.env.LOCAL_API_TOKEN = 'security-test-token';
|
|
|
|
const app = await createLocalApiServer({
|
|
port: 0,
|
|
apiDir: localApi.apiDir,
|
|
logger: { log() { }, warn() { }, error() { } },
|
|
});
|
|
const { port } = await app.start();
|
|
|
|
try {
|
|
const response = await fetch(`http://127.0.0.1:${port}/api/service-status`);
|
|
assert.equal(response.status, 200);
|
|
const body = await response.json();
|
|
assert.equal(body.success, true);
|
|
} finally {
|
|
if (originalToken !== undefined) {
|
|
process.env.LOCAL_API_TOKEN = originalToken;
|
|
} else {
|
|
delete process.env.LOCAL_API_TOKEN;
|
|
}
|
|
await app.close();
|
|
await localApi.cleanup();
|
|
}
|
|
});
|
|
|
|
test('rss-proxy blocks requests to localhost (SSRF protection)', async () => {
|
|
const localApi = await setupApiDir({});
|
|
|
|
const app = await createLocalApiServer({
|
|
port: 0,
|
|
apiDir: localApi.apiDir,
|
|
logger: { log() { }, warn() { }, error() { } },
|
|
});
|
|
const { port } = await app.start();
|
|
|
|
try {
|
|
const response = await fetch(`http://127.0.0.1:${port}/api/rss-proxy?url=http://127.0.0.1:3000`);
|
|
assert.equal(response.status, 403);
|
|
const body = await response.json();
|
|
assert.ok(body.error.includes('private') || body.error.includes('localhost'));
|
|
} finally {
|
|
await app.close();
|
|
await localApi.cleanup();
|
|
}
|
|
});
|
|
|
|
test('rss-proxy blocks requests to private IP ranges (SSRF protection)', async () => {
|
|
const localApi = await setupApiDir({});
|
|
|
|
const app = await createLocalApiServer({
|
|
port: 0,
|
|
apiDir: localApi.apiDir,
|
|
logger: { log() { }, warn() { }, error() { } },
|
|
});
|
|
const { port } = await app.start();
|
|
|
|
try {
|
|
// Test 192.168.x.x range
|
|
const response1 = await fetch(`http://127.0.0.1:${port}/api/rss-proxy?url=http://192.168.1.1/`);
|
|
assert.equal(response1.status, 403);
|
|
|
|
// Test 10.x.x.x range
|
|
const response2 = await fetch(`http://127.0.0.1:${port}/api/rss-proxy?url=http://10.0.0.1/`);
|
|
assert.equal(response2.status, 403);
|
|
|
|
// Test 172.16-31.x.x range
|
|
const response3 = await fetch(`http://127.0.0.1:${port}/api/rss-proxy?url=http://172.16.0.1/`);
|
|
assert.equal(response3.status, 403);
|
|
} finally {
|
|
await app.close();
|
|
await localApi.cleanup();
|
|
}
|
|
});
|
|
|
|
test('rss-proxy blocks non-http protocols (SSRF protection)', async () => {
|
|
const localApi = await setupApiDir({});
|
|
|
|
const app = await createLocalApiServer({
|
|
port: 0,
|
|
apiDir: localApi.apiDir,
|
|
logger: { log() { }, warn() { }, error() { } },
|
|
});
|
|
const { port } = await app.start();
|
|
|
|
try {
|
|
const response = await fetch(`http://127.0.0.1:${port}/api/rss-proxy?url=file:///etc/passwd`);
|
|
assert.equal(response.status, 403);
|
|
const body = await response.json();
|
|
assert.ok(body.error.includes('http'));
|
|
} finally {
|
|
await app.close();
|
|
await localApi.cleanup();
|
|
}
|
|
});
|
|
|
|
test('rss-proxy blocks URLs with credentials (SSRF protection)', async () => {
|
|
const localApi = await setupApiDir({});
|
|
|
|
const app = await createLocalApiServer({
|
|
port: 0,
|
|
apiDir: localApi.apiDir,
|
|
logger: { log() { }, warn() { }, error() { } },
|
|
});
|
|
const { port } = await app.start();
|
|
|
|
try {
|
|
const response = await fetch(`http://127.0.0.1:${port}/api/rss-proxy?url=http://user:pass@example.com/rss`);
|
|
assert.equal(response.status, 403);
|
|
const body = await response.json();
|
|
assert.ok(body.error.includes('credentials'));
|
|
} finally {
|
|
await app.close();
|
|
await localApi.cleanup();
|
|
}
|
|
});
|
|
|
|
test('traffic log strips query strings from entries to protect privacy', async () => {
|
|
const localApi = await setupApiDir({
|
|
'test-endpoint.js': `
|
|
export default async function handler() {
|
|
return new Response(JSON.stringify({ ok: true }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' }
|
|
});
|
|
}
|
|
`,
|
|
});
|
|
|
|
const app = await createLocalApiServer({
|
|
port: 0,
|
|
apiDir: localApi.apiDir,
|
|
logger: { log() { }, warn() { }, error() { } },
|
|
});
|
|
const { port } = await app.start();
|
|
|
|
try {
|
|
// Make a request that will be recorded in the traffic log
|
|
await fetch(`http://127.0.0.1:${port}/api/test-endpoint?secret=value&key=data`);
|
|
|
|
// Retrieve the traffic log
|
|
const logResponse = await fetch(`http://127.0.0.1:${port}/api/local-traffic-log`);
|
|
assert.equal(logResponse.status, 200);
|
|
const logBody = await logResponse.json();
|
|
|
|
// Verify query strings are stripped
|
|
const entry = logBody.entries.find(e => e.path.includes('test-endpoint'));
|
|
assert.ok(entry, 'Traffic log should contain the test-endpoint entry');
|
|
assert.equal(entry.path, '/api/test-endpoint');
|
|
assert.ok(!entry.path.includes('secret='), 'Query string should be stripped from traffic log');
|
|
} finally {
|
|
await app.close();
|
|
await localApi.cleanup();
|
|
}
|
|
});
|
|
|
|
test('service-status reports bound fallback port after EADDRINUSE recovery', async () => {
|
|
const blocker = createServer((_req, res) => {
|
|
res.writeHead(200, { 'content-type': 'text/plain' });
|
|
res.end('occupied');
|
|
});
|
|
await listen(blocker, '127.0.0.1', 46123);
|
|
|
|
const localApi = await setupApiDir({});
|
|
const app = await createLocalApiServer({
|
|
port: 46123,
|
|
apiDir: localApi.apiDir,
|
|
logger: { log() { }, warn() { }, error() { } },
|
|
});
|
|
const { port } = await app.start();
|
|
|
|
try {
|
|
assert.notEqual(port, 46123);
|
|
|
|
const response = await fetch(`http://127.0.0.1:${port}/api/service-status`);
|
|
assert.equal(response.status, 200);
|
|
const body = await response.json();
|
|
|
|
assert.equal(body.local.port, port);
|
|
const localService = body.services.find((service) => service.id === 'local-api');
|
|
assert.equal(localService.description, `Running on 127.0.0.1:${port}`);
|
|
} finally {
|
|
await app.close();
|
|
await localApi.cleanup();
|
|
await new Promise((resolve, reject) => {
|
|
blocker.close((error) => (error ? reject(error) : resolve()));
|
|
});
|
|
}
|
|
});
|