import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import { mkdtemp, mkdir, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import { gatewayConfig, serve } from '../src/serve.js';
const owner = 'did:plc:example';
const base = { MCP_ALLOWED_SUBJECT: owner, EXTERNAL_URL: 'https://mcp.example' };
test('hosted defaults keep owner and Google credential stores separate', () => {
const config = gatewayConfig({
...base,
DATA_PATH: '/private/state',
COLAB_CREDENTIALS: '/old/google.json',
});
assert.equal(config.OIDC_ALLOWED_USERS, owner);
assert.equal(config.OIDC_USER_ID_FIELD, '/sub');
assert.equal(config.COLAB_MANAGED_CREDENTIALS, '/private/state/colab-google.json');
assert.equal(config.COLAB_CREDENTIALS, '/old/google.json');
assert.equal(config.COLAB_LOGIN_URL, 'https://mcp.example/.auth/colab');
assert.equal(config.LISTEN, '127.0.0.1:3000');
assert.equal(config.NO_AUTO_TLS, 'true');
assert.deepEqual(config.MCP_REDIRECT_PREFIXES.split(','), [
'https://oauth-redirect.googleusercontent.com/',
'https://oauth-redirect-sandbox.googleusercontent.com/',
'https://oauth-redirect-test.googleusercontent.com/',
]);
});
test('hosted configuration rejects unsafe origins, a missing owner, and root state storage', () => {
for (const EXTERNAL_URL of [
'http://remote.example',
'https://user:secret@mcp.example',
'https://mcp.example/path',
'https://mcp.example?code=secret',
'https://mcp.example/#fragment',
]) {
assert.throws(() => gatewayConfig({ ...base, EXTERNAL_URL }), /EXTERNAL_URL/);
}
assert.throws(() => gatewayConfig({}), /MCP_ALLOWED_SUBJECT/);
assert.throws(() => gatewayConfig({ ...base, DATA_PATH: '/' }), /filesystem root/);
assert.equal(
gatewayConfig({ ...base, EXTERNAL_URL: 'http://127.0.0.1:4000/' }).EXTERNAL_URL,
'http://127.0.0.1:4000',
);
});
test('explicit gateway settings survive the wrapper', () => {
const config = gatewayConfig({
...base,
LISTEN: '127.0.0.1:7178',
NO_AUTO_TLS: 'false',
MCP_REDIRECT_PREFIXES: 'https://other-client.example/callback',
COLAB_MANAGED_CREDENTIALS: '/private/google.json',
});
assert.equal(config.LISTEN, '127.0.0.1:7178');
assert.equal(config.NO_AUTO_TLS, 'false');
assert.equal(config.MCP_REDIRECT_PREFIXES, 'https://other-client.example/callback');
assert.equal(config.COLAB_MANAGED_CREDENTIALS, '/private/google.json');
});
async function temporary(t) {
const dir = await mkdtemp(join(tmpdir(), 'colab-serve-'));
t.after(() => rm(dir, { recursive: true, force: true }));
return dir;
}
test('serve starts the packaged gateway with the stdio worker and cleans signal listeners', async (t) => {
const signals = new EventEmitter();
const DATA_PATH = await temporary(t);
let called;
const result = await serve({
env: { ...base, DATA_PATH },
signals,
spawnProcess(binary, args, options) {
called = { binary, args, options };
const child = new EventEmitter();
queueMicrotask(() => child.emit('exit', 7));
return child;
},
});
assert.equal(result, 7);
assert.match(called.binary, /dist[/\\]colab-mcp-gateway(?:\.exe)?$/);
assert.equal(called.args[0], '--');
assert.equal(called.args[1], process.execPath);
assert.match(called.args[2], /src[/\\]index\.js$/);
assert.equal(called.options.stdio, 'inherit');
assert.equal(called.options.env.MCP_ALLOWED_SUBJECT, owner);
assert.equal(signals.listenerCount('SIGTERM'), 0);
assert.equal(signals.listenerCount('SIGINT'), 0);
});
test('serve fails cleanly if the gateway is missing', async (t) => {
const signals = new EventEmitter();
await assert.rejects(
serve({
env: { ...base, DATA_PATH: await temporary(t) },
signals,
spawnProcess() {
const child = new EventEmitter();
queueMicrotask(() => child.emit('error', new Error('secret must not be echoed')));
return child;
},
}),
/^Error: cannot start OAuth gateway; run npm run build:gateway or set COLAB_GATEWAY_BIN$/,
);
assert.equal(signals.listenerCount('SIGTERM'), 0);
});
test('serve relays shutdown once and waits for the gateway', async (t) => {
const signals = new EventEmitter();
const sent = [];
const result = await serve({
env: { ...base, DATA_PATH: await temporary(t) },
signals,
spawnProcess() {
const child = new EventEmitter();
child.kill = (signal) => {
sent.push(signal);
queueMicrotask(() => child.emit('exit', 0));
};
queueMicrotask(() => {
signals.emit('SIGTERM');
signals.emit('SIGINT');
});
return child;
},
});
assert.equal(result, 0);
assert.deepEqual(sent, ['SIGTERM']);
assert.equal(signals.listenerCount('SIGTERM'), 0);
});
test('serve refuses existing public state directories without changing their permissions', async (t) => {
const DATA_PATH = join(await temporary(t), 'public');
await mkdir(DATA_PATH, { mode: 0o755 });
await assert.rejects(
serve({ env: { ...base, DATA_PATH }, spawnProcess: () => assert.fail('must not spawn') }),
/private/,
);
});