mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
fix(server): restore gitnexus serve startup under Express 5 (#1749)
* fix(server): restore gitnexus serve startup under Express 5
Express 5 rejects app.options('*'), which broke CI e2e when the backend
failed to start. Move PNA middleware before cors so preflight responses
include Access-Control-Allow-Private-Network, and add regression tests.
Co-authored-by: Cursor <cursoragent@cursor.com>
* test(server): address PR review — prettier, ephemeral port, cleanup
- Format integration and rate-limit test files for CI quality/format
- Use OS-assigned port instead of random 47xxx range
- Remove per-test GITNEXUS_HOME temp dir in afterEach
- Use regex for PNA-before-cors structural guard (indent-agnostic)
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
1b5c6e5b6a
commit
8db51184ab
4 changed files with 261 additions and 16 deletions
|
|
@ -692,6 +692,14 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
|||
// local-bound default).
|
||||
app.set('trust proxy', 'loopback, linklocal, uniquelocal');
|
||||
|
||||
// Chromium Private Network Access (required since Chrome 130+). Must run before
|
||||
// cors: the cors middleware ends OPTIONS preflight responses, so this header
|
||||
// has to be set on res before cors writes the preflight reply.
|
||||
app.use((_req, res, next) => {
|
||||
res.setHeader('Access-Control-Allow-Private-Network', 'true');
|
||||
next();
|
||||
});
|
||||
|
||||
// CORS: allow localhost, private/LAN networks, and the deployed site.
|
||||
// Non-browser requests (curl, server-to-server) have no origin and are allowed.
|
||||
// Disallowed origins get the response without Access-Control-Allow-Origin,
|
||||
|
|
@ -706,22 +714,6 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
|||
);
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
|
||||
// Support Chromium Private Network Access (required since Chrome 130+).
|
||||
// Without this header, Chrome/Edge/Brave/Arc block public->loopback requests
|
||||
// which breaks bridge mode entirely.
|
||||
app.use((_req, res, next) => {
|
||||
res.setHeader('Access-Control-Allow-Private-Network', 'true');
|
||||
next();
|
||||
});
|
||||
|
||||
// Handle PNA preflight: Chromium sends Access-Control-Request-Private-Network
|
||||
// on OPTIONS requests and expects the allow header in the response.
|
||||
// Note: the actual Allow-Private-Network header is already set by the global
|
||||
// middleware above, so we just need to call next() here.
|
||||
app.options('*', (_req, res, next) => {
|
||||
next();
|
||||
});
|
||||
|
||||
// Initialize MCP backend (multi-repo, shared across all MCP sessions)
|
||||
const backend = new LocalBackend();
|
||||
await backend.init();
|
||||
|
|
|
|||
141
gitnexus/test/integration/server-http-startup.test.ts
Normal file
141
gitnexus/test/integration/server-http-startup.test.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
/**
|
||||
* HTTP serve startup — proves createServer() boots under Express 5.
|
||||
*
|
||||
* Spawns the built CLI (`gitnexus serve`) and probes GET /api/health.
|
||||
* Catches regressions like invalid route patterns that throw at registration
|
||||
* time before LadybugDB or MCP initialize.
|
||||
*/
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
||||
const DIST_CLI = path.join(REPO_ROOT, 'dist', 'cli', 'index.js');
|
||||
|
||||
const STARTUP_BUDGET_MS = process.env.CI ? 30_000 : 15_000;
|
||||
|
||||
const allocateFreePort = (): Promise<number> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const probe = http.createServer();
|
||||
probe.once('error', reject);
|
||||
probe.listen(0, '127.0.0.1', () => {
|
||||
const addr = probe.address();
|
||||
if (typeof addr !== 'object' || !addr) {
|
||||
probe.close();
|
||||
reject(new Error('could not allocate ephemeral port'));
|
||||
return;
|
||||
}
|
||||
const port = addr.port;
|
||||
probe.close((err) => (err ? reject(err) : resolve(port)));
|
||||
});
|
||||
});
|
||||
|
||||
const probeHealth = (port: number): Promise<{ status: number; body: string }> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const req = http.get(`http://127.0.0.1:${port}/api/health`, (res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (c) => chunks.push(c));
|
||||
res.on('end', () => {
|
||||
resolve({
|
||||
status: res.statusCode ?? 0,
|
||||
body: Buffer.concat(chunks).toString('utf8'),
|
||||
});
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.setTimeout(5_000, () => {
|
||||
req.destroy();
|
||||
reject(new Error('health probe timed out'));
|
||||
});
|
||||
});
|
||||
|
||||
// Child-process serve + health probe is reliable on Linux CI (where e2e failed).
|
||||
// On Windows, spawned `serve` can print "running" before the listen socket is
|
||||
// reachable from the parent; unit tests in server-cors-stack.test.ts cover the
|
||||
// Express 5 registration path on all platforms.
|
||||
const describeServeStartup = process.platform === 'win32' ? describe.skip : describe;
|
||||
|
||||
describeServeStartup('gitnexus serve HTTP startup (Express 5)', () => {
|
||||
let proc: ChildProcessWithoutNullStreams | undefined;
|
||||
let homeDir: string | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
if (proc && !proc.killed) {
|
||||
proc.kill('SIGTERM');
|
||||
await new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
proc?.kill('SIGKILL');
|
||||
resolve();
|
||||
}, 3_000);
|
||||
proc?.on('exit', () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
proc = undefined;
|
||||
|
||||
if (homeDir) {
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
homeDir = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
it('serve boots and GET /api/health returns ok', async () => {
|
||||
if (!fs.existsSync(DIST_CLI)) {
|
||||
throw new Error(`Missing ${DIST_CLI} — run npm run build before integration tests`);
|
||||
}
|
||||
|
||||
const port = await allocateFreePort();
|
||||
homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-serve-home-'));
|
||||
|
||||
proc = spawn(
|
||||
process.execPath,
|
||||
[DIST_CLI, 'serve', '--port', String(port), '--host', '127.0.0.1'],
|
||||
{
|
||||
cwd: REPO_ROOT,
|
||||
env: { ...process.env, GITNEXUS_HOME: homeDir, NODE_OPTIONS: '' },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
},
|
||||
);
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
proc.stdout.on('data', (buf) => {
|
||||
stdout += buf.toString();
|
||||
});
|
||||
proc.stderr.on('data', (buf) => {
|
||||
stderr += buf.toString();
|
||||
});
|
||||
|
||||
const startedAt = Date.now();
|
||||
let status = 0;
|
||||
let body = '';
|
||||
|
||||
while (Date.now() - startedAt < STARTUP_BUDGET_MS) {
|
||||
if (proc.exitCode !== null) {
|
||||
throw new Error(
|
||||
`serve exited ${proc.exitCode} before ready.\nstdout:\n${stdout}\nstderr:\n${stderr}`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
({ status, body } = await probeHealth(port));
|
||||
if (status === 200) {
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// Server still starting — retry until budget expires.
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toContain('"status":"ok"');
|
||||
}, 60_000);
|
||||
});
|
||||
|
|
@ -262,6 +262,17 @@ describe('production routes — rate-limit middleware wiring', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('does not register Express-4-only app.options("*") (Express 5 path-to-regexp)', () => {
|
||||
expect(apiSource).not.toMatch(/app\.options\(\s*'\*'/);
|
||||
expect(apiSource).not.toMatch(/app\.options\(\s*'\/\*'/);
|
||||
});
|
||||
|
||||
it('sets PNA header middleware before cors (preflight must include Allow-Private-Network)', () => {
|
||||
expect(apiSource).toMatch(
|
||||
/Access-Control-Allow-Private-Network[\s\S]*?app\.use\(\s*\n?\s*cors\(/,
|
||||
);
|
||||
});
|
||||
|
||||
it('embed route flushes WAL via flushWAL, not inline executeQuery (#1376)', () => {
|
||||
// The embed handler must call the consolidated helper, not hand-roll
|
||||
// its own try/catch around executeQuery('CHECKPOINT').
|
||||
|
|
|
|||
101
gitnexus/test/unit/server-cors-stack.test.ts
Normal file
101
gitnexus/test/unit/server-cors-stack.test.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
/**
|
||||
* Regression tests for createServer() CORS + PNA middleware (Express 5).
|
||||
*
|
||||
* Express 5 / path-to-regexp v8 rejects bare `app.options('*')`, which broke
|
||||
* `gitnexus serve` in CI after #872. These tests mirror the registration order
|
||||
* in createServer() without booting LadybugDB or MCP.
|
||||
*/
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import http from 'node:http';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { isAllowedOrigin } from '../../src/server/api.js';
|
||||
|
||||
/** Mirrors createServer() trust proxy + PNA + cors + json stack. */
|
||||
const buildCreateServerCorsStack = (): express.Express => {
|
||||
const app = express();
|
||||
app.disable('x-powered-by');
|
||||
app.set('trust proxy', 'loopback, linklocal, uniquelocal');
|
||||
app.use((_req, res, next) => {
|
||||
res.setHeader('Access-Control-Allow-Private-Network', 'true');
|
||||
next();
|
||||
});
|
||||
app.use(
|
||||
cors({
|
||||
origin: (origin, callback) => {
|
||||
callback(null, isAllowedOrigin(origin));
|
||||
},
|
||||
}),
|
||||
);
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
return app;
|
||||
};
|
||||
|
||||
describe('createServer CORS/PNA stack — Express 5 registration', () => {
|
||||
it('registers without path-to-regexp wildcard errors', () => {
|
||||
expect(() => buildCreateServerCorsStack()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createServer CORS/PNA stack — OPTIONS preflight', () => {
|
||||
let server: http.Server | undefined;
|
||||
let baseUrl = '';
|
||||
|
||||
afterEach(
|
||||
() =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
if (!server) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
server.close((err) => (err ? reject(err) : resolve()));
|
||||
}),
|
||||
);
|
||||
|
||||
const start = (app: express.Express): Promise<void> =>
|
||||
new Promise((resolve) => {
|
||||
server = app.listen(0, '127.0.0.1', () => {
|
||||
const addr = server!.address();
|
||||
if (typeof addr === 'object' && addr) {
|
||||
baseUrl = `http://127.0.0.1:${addr.port}`;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('OPTIONS /api/repos includes PNA and ACAO for allowed origin', async () => {
|
||||
const app = buildCreateServerCorsStack();
|
||||
await start(app);
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/repos`, {
|
||||
method: 'OPTIONS',
|
||||
headers: {
|
||||
Origin: 'https://gitnexus.vercel.app',
|
||||
'Access-Control-Request-Method': 'GET',
|
||||
'Access-Control-Request-Private-Network': 'true',
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(204);
|
||||
expect(res.headers.get('access-control-allow-origin')).toBe('https://gitnexus.vercel.app');
|
||||
expect(res.headers.get('access-control-allow-private-network')).toBe('true');
|
||||
});
|
||||
|
||||
it('OPTIONS / includes PNA header for localhost bridge', async () => {
|
||||
const app = buildCreateServerCorsStack();
|
||||
await start(app);
|
||||
|
||||
const res = await fetch(`${baseUrl}/`, {
|
||||
method: 'OPTIONS',
|
||||
headers: {
|
||||
Origin: 'http://localhost:5173',
|
||||
'Access-Control-Request-Method': 'GET',
|
||||
'Access-Control-Request-Private-Network': 'true',
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(204);
|
||||
expect(res.headers.get('access-control-allow-origin')).toBe('http://localhost:5173');
|
||||
expect(res.headers.get('access-control-allow-private-network')).toBe('true');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue