Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions src/server.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// Keep the module-level side effects in server.ts (DB manager construction,
// heavy imports) from doing anything real when the module is imported here.
jest.mock('./db/mongodb');
jest.mock('./db/couchdb');
jest.mock('./api', () => ({
__esModule: true,
default: jest.fn()
}));

import { validateRequiredEnv } from './server';

describe('validateRequiredEnv (startup env validation)', () => {
const ORIGINAL_ENV = process.env;
let exitSpy: jest.SpyInstance;
let errorSpy: jest.SpyInstance;

beforeEach(() => {
// Start each test from a clean, valid environment.
process.env = { ...ORIGINAL_ENV };
process.env.SMB_ADDRESS = 'http://localhost:8080';
process.env.CORS_ORIGIN = 'http://localhost:3000';

// process.exit must be mocked so the test runner is not torn down and so we
// can assert on the exit behaviour. Throw so control flow stops like the
// real exit would, letting us assert the exit code.
exitSpy = jest
.spyOn(process, 'exit')
.mockImplementation((code?: string | number | null | undefined) => {
throw new Error(`process.exit:${code}`);
});
errorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
});

afterEach(() => {
process.env = ORIGINAL_ENV;
exitSpy.mockRestore();
errorSpy.mockRestore();
});

it('exits(1) when SMB_ADDRESS is missing', () => {
delete process.env.SMB_ADDRESS;
expect(() => validateRequiredEnv()).toThrow('process.exit:1');
expect(exitSpy).toHaveBeenCalledWith(1);
});

it('exits(1) when SMB_ADDRESS is empty', () => {
process.env.SMB_ADDRESS = '';
expect(() => validateRequiredEnv()).toThrow('process.exit:1');
expect(exitSpy).toHaveBeenCalledWith(1);
});

it('exits(1) when CORS_ORIGIN is missing', () => {
delete process.env.CORS_ORIGIN;
expect(() => validateRequiredEnv()).toThrow('process.exit:1');
expect(exitSpy).toHaveBeenCalledWith(1);
});

it('exits(1) when CORS_ORIGIN is empty', () => {
process.env.CORS_ORIGIN = '';
expect(() => validateRequiredEnv()).toThrow('process.exit:1');
expect(exitSpy).toHaveBeenCalledWith(1);
});

it('does not exit when both SMB_ADDRESS and CORS_ORIGIN are set', () => {
expect(() => validateRequiredEnv()).not.toThrow();
expect(exitSpy).not.toHaveBeenCalled();
});
});
60 changes: 43 additions & 17 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,17 @@ import { CoreFunctions } from './api_productions_core_functions';
import { ConnectionQueue } from './connection_queue';
import { DbManagerCouchDb } from './db/couchdb';
import { DbManagerMongoDb } from './db/mongodb';
import { DbManager } from './db/interface';
import { IngestManager } from './ingest_manager';
import { Log } from './log';
import { ProductionManager } from './production_manager';

const SMB_ADDRESS: string = process.env.SMB_ADDRESS ?? 'http://localhost:8080';
// SMB_ADDRESS is required (validated in validateRequiredEnv); no default is
// provided so that a missing value is caught by startup validation rather than
// silently falling back to localhost.
const SMB_ADDRESS: string = process.env.SMB_ADDRESS ?? '';
const PUBLIC_HOST: string = process.env.PUBLIC_HOST ?? 'http://localhost:8000';

if (!process.env.SMB_ADDRESS) {
Log().warn('SMB_ADDRESS environment variable not set, using defaults');
}

if (!process.env.PUBLIC_HOST) {
Log().warn('PUBLIC_HOST is not set — falling back to localhost default');
}
Expand All @@ -27,16 +27,18 @@ if (
);
}

try {
const smbUrl = new URL(SMB_ADDRESS);
const localHosts = ['localhost', '127.0.0.1', '::1'];
if (smbUrl.protocol === 'http:' && !localHosts.includes(smbUrl.hostname)) {
Log().warn(
`SMB_ADDRESS uses plaintext http:// to a remote host (${smbUrl.hostname}); SDP/ICE data will be sent unencrypted. Use https:// in production.`
);
if (SMB_ADDRESS) {
try {
const smbUrl = new URL(SMB_ADDRESS);
const localHosts = ['localhost', '127.0.0.1', '::1'];
if (smbUrl.protocol === 'http:' && !localHosts.includes(smbUrl.hostname)) {
Log().warn(
`SMB_ADDRESS uses plaintext http:// to a remote host (${smbUrl.hostname}); SDP/ICE data will be sent unencrypted. Use https:// in production.`
);
}
} catch (err) {
Log().warn(`SMB_ADDRESS could not be parsed as a URL: ${SMB_ADDRESS}`);
}
} catch (err) {
Log().warn(`SMB_ADDRESS could not be parsed as a URL: ${SMB_ADDRESS}`);
}

const ENDPOINT_IDLE_TIMEOUT_S: string =
Expand All @@ -48,7 +50,7 @@ const DB_CONNECTION_STRING: string =
process.env.DB_CONNECTION_STRING ??
process.env.MONGODB_CONNECTION_STRING ??
'mongodb://localhost:27017/intercom-manager';
let dbManager;
let dbManager: DbManager;
const dbUrl = new URL(DB_CONNECTION_STRING);
if (dbUrl.protocol === 'mongodb:' || dbUrl.protocol === 'mongodb+srv:') {
dbManager = new DbManagerMongoDb(dbUrl);
Expand All @@ -58,7 +60,25 @@ if (dbUrl.protocol === 'mongodb:' || dbUrl.protocol === 'mongodb+srv:') {
throw new Error('Unsupported database protocol');
}

(async function startServer() {
const REQUIRED_ENV = ['SMB_ADDRESS', 'CORS_ORIGIN'] as const;

/**
* Validate that all required environment variables are set and non-empty.
* Exits the process with code 1 on the first missing/empty variable so that
* the server never starts in a misconfigured state.
*/
export function validateRequiredEnv(): void {
for (const key of REQUIRED_ENV) {
if (!process.env[key]) {
Log().error(`Missing required environment variable: ${key}`);
process.exit(1);
}
}
}

async function startServer() {
validateRequiredEnv();

await dbManager.connect();
const productionManager = new ProductionManager(dbManager);
await productionManager.load();
Expand Down Expand Up @@ -106,4 +126,10 @@ if (dbUrl.protocol === 'mongodb:' || dbUrl.protocol === 'mongodb+srv:') {
Log().error('Uncaught exception:', err);
process.exit(1);
});
})();
}

// Only start the server when this module is executed directly (e.g. via
// `ts-node src/server.ts`), not when it is imported (e.g. by unit tests).
if (require.main === module) {
startServer();
}
Loading