test: bind the port-sharing test servers to ephemeral ports; guard new fixed ports

Four ports were shared by two files each — 3162 (qr-auth / auth-security), 3170
(multiuser-auth / routes/ws-routes), 3230 and 3231 (cod54-hook-event-auth /
routes/voice-routes). Files run serially (`fileParallelism: false`), so the pairs
never met inside one run; they collide between two runs on one host, or with
anything else holding the port. All six files now bind port 0 and read the
number back (`boundPort` for WebServer, `server.address()` after each listen for
the raw Fastify / ws servers).

test/test-ports-guard.test.ts fails on a WebServer built under test/ whose port
argument is not the literal 0 — `new WebServer(…)`, a subclass, or a destructured
alias (`{ WebServer: T }`, as quick-start.test.ts does) — outside a legacy list of
the 43 files that construct one with a non-zero port today; the follow-up sweep
converts them. A converted file cannot stay listed. What it does not cover (helper
parameters, `import { WebServer as X }`, raw listen sites) is written down in it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Randalix
2026-10-08 14:56:42 +02:00
co-authored by Claude Opus 5.5
parent 03629c966e
commit bb4e7943c5
7 changed files with 187 additions and 52 deletions
+13 -18
View File
@@ -8,7 +8,7 @@
* 6. Logout endpoint invalidates session
* 7. Settings schema rejects unknown fields
*
* Port: 3160 (auth tests), 3161 (loopback no-auth tests), 3162 (network override tests)
* Port: ephemeral (`new WebServer(0, …)`, read back through `boundPort`)
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest';
import { WebServer } from '../src/web/server.js';
@@ -16,11 +16,6 @@ import { TmuxManager } from '../src/tmux-manager.js';
import { SettingsUpdateSchema } from '../src/web/schemas.js';
import { getHookSecret, HOOK_SECRET_HEADER } from '../src/config/hook-secret.js';
const AUTH_PORT = 3160;
const NOAUTH_PORT = 3161;
const NETWORK_OVERRIDE_PORT = 3162;
const AUTH_RATE_LIMIT_PORT = 3220;
const NOAUTH_NETWORK_PORT = 3221;
const TEST_USER = 'admin';
const TEST_PASS = 'test-password-12345';
@@ -30,12 +25,12 @@ function basicAuthHeader(user: string, pass: string): string {
return 'Basic ' + Buffer.from(`${user}:${pass}`).toString('base64');
}
async function startAuthServer(port: number): Promise<{ server: WebServer; baseUrl: string }> {
async function startAuthServer(): Promise<{ server: WebServer; baseUrl: string }> {
process.env.CODEMAN_PASSWORD = TEST_PASS;
process.env.CODEMAN_USERNAME = TEST_USER;
const server = new WebServer(port, false, true);
const server = new WebServer(0, false, true);
await server.start();
return { server, baseUrl: `http://localhost:${port}` };
return { server, baseUrl: `http://localhost:${server.boundPort}` };
}
async function getSessionCookie(baseUrl: string): Promise<string> {
@@ -66,9 +61,9 @@ describe('Auth Security', () => {
beforeAll(async () => {
process.env.CODEMAN_PASSWORD = TEST_PASS;
process.env.CODEMAN_USERNAME = TEST_USER;
server = new WebServer(AUTH_PORT, false, true);
server = new WebServer(0, false, true);
await server.start();
baseUrl = `http://localhost:${AUTH_PORT}`;
baseUrl = `http://localhost:${server.boundPort}`;
});
afterAll(async () => {
@@ -194,7 +189,7 @@ describe('Auth Security', () => {
let rateBaseUrl: string;
beforeEach(async () => {
({ server: rateServer, baseUrl: rateBaseUrl } = await startAuthServer(AUTH_RATE_LIMIT_PORT));
({ server: rateServer, baseUrl: rateBaseUrl } = await startAuthServer());
});
afterEach(async () => {
@@ -347,7 +342,7 @@ describe('No-Auth Server Startup Policy', () => {
delete process.env.CODEMAN_PASSWORD;
delete process.env.CODEMAN_USERNAME;
delete process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK;
server = new WebServer(NOAUTH_PORT, false, true, '127.0.0.1');
server = new WebServer(0, false, true, '127.0.0.1');
await server.start();
});
@@ -359,7 +354,7 @@ describe('No-Auth Server Startup Policy', () => {
});
it('allows loopback requests without auth when no password is configured', async () => {
const res = await fetch(`http://localhost:${NOAUTH_PORT}/api/status`);
const res = await fetch(`http://localhost:${server.boundPort}/api/status`);
expect(res.status).toBe(200);
});
@@ -368,10 +363,10 @@ describe('No-Auth Server Startup Policy', () => {
// bind without a password no longer refuses to start — it starts and warns,
// pointing at how to secure it. See docs/security-architecture.md.
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const networkServer = new WebServer(NOAUTH_NETWORK_PORT, false, true, '0.0.0.0');
const networkServer = new WebServer(0, false, true, '0.0.0.0');
await expect(networkServer.start()).resolves.toBeUndefined();
const res = await fetch(`http://localhost:${NOAUTH_NETWORK_PORT}/api/status`);
const res = await fetch(`http://localhost:${networkServer.boundPort}/api/status`);
expect(res.status).toBe(200);
const warned = warnSpy.mock.calls.flat().join('\n');
@@ -393,10 +388,10 @@ describe('No-Auth Server Startup Policy', () => {
});
it('allows non-loopback startup with the explicit unauthenticated-network override', async () => {
const networkServer = new WebServer(NETWORK_OVERRIDE_PORT, false, true, '0.0.0.0', undefined, true);
const networkServer = new WebServer(0, false, true, '0.0.0.0', undefined, true);
await networkServer.start();
const res = await fetch(`http://localhost:${NETWORK_OVERRIDE_PORT}/api/status`);
const res = await fetch(`http://localhost:${networkServer.boundPort}/api/status`);
expect(res.status).toBe(200);
await networkServer.stop();
});