import assert from 'node:assert/strict'; import { afterEach, describe, it } from 'node:test'; import { loadConfig } from '../src/config.ts'; const SAVED = { ...process.env }; afterEach(() => { process.env = { ...SAVED }; }); describe('loadConfig', () => { it('requires the instance URL and token', () => { delete process.env.TSC_URL; process.env.TSC_JWT_COOKIE = 'x'; assert.throws(() => loadConfig(), /TSC_URL/); }); it('strips trailing slashes so paths concatenate cleanly', () => { process.env.TSC_URL = 'https://example.org///'; process.env.TSC_JWT_COOKIE = 'x'; assert.equal(loadConfig().baseUrl, 'https://example.org'); }); it('rejects a non-numeric port rather than silently defaulting', () => { process.env.TSC_URL = 'https://example.org'; process.env.TSC_JWT_COOKIE = 'x'; process.env.PORT = 'not-a-number'; assert.throws(() => loadConfig(), /PORT/); }); it('treats a blank auth token as absent', () => { process.env.TSC_URL = 'https://example.org'; process.env.TSC_JWT_COOKIE = 'x'; process.env.MCP_AUTH_TOKEN = ' '; assert.equal(loadConfig().authToken, undefined); }); }); describe('loadConfig: secret MCP path and state directory', () => { it('accepts a long URL-safe secret and leaves it off by default', () => { process.env.TSC_URL = 'https://example.org'; process.env.TSC_JWT_COOKIE = 'x'; assert.equal(loadConfig().mcpPathSecret, undefined); process.env.MCP_PATH_SECRET = '0123456789abcdef0123456789abcdef'; assert.equal(loadConfig().mcpPathSecret, '0123456789abcdef0123456789abcdef'); }); it('refuses a short or unsafe secret without echoing it', () => { process.env.TSC_URL = 'https://example.org'; process.env.TSC_JWT_COOKIE = 'x'; for (const secret of ['short-secret', 'has spaces in it but is long enough 1234', 'slash/in/the/middle/0123456789abcdefgh']) { process.env.MCP_PATH_SECRET = secret; assert.throws( () => loadConfig(), (error: Error) => /MCP_PATH_SECRET/.test(error.message) && !error.message.includes(secret), ); } }); it('resolves the state directory to an absolute path', () => { process.env.TSC_URL = 'https://example.org'; process.env.TSC_JWT_COOKIE = 'x'; process.env.STATE_DIR = 'tmp/state'; assert.match(loadConfig().stateDir ?? '', /^\/.*\/tmp\/state$/); }); }); describe('loadConfig: connector token', () => { const connector = 'connector-0123456789abcdef0123456789'; it('is off by default, and accepted alongside the main token', () => { process.env.TSC_URL = 'https://example.org'; process.env.TSC_JWT_COOKIE = 'x'; process.env.MCP_AUTH_TOKEN = 'main-token'; assert.equal(loadConfig().connectorToken, undefined); process.env.MCP_CONNECTOR_TOKEN = connector; assert.equal(loadConfig().connectorToken, connector); }); it('refuses a short or spaced token without echoing it', () => { process.env.TSC_URL = 'https://example.org'; process.env.TSC_JWT_COOKIE = 'x'; process.env.MCP_AUTH_TOKEN = 'main-token'; for (const token of ['too-short', 'long enough but with spaces in it 0123']) { process.env.MCP_CONNECTOR_TOKEN = token; assert.throws( () => loadConfig(), (error: Error) => /MCP_CONNECTOR_TOKEN/.test(error.message) && !error.message.includes(token), ); } }); it('refuses to leave /api open, or to be the main token under another name', () => { process.env.TSC_URL = 'https://example.org'; process.env.TSC_JWT_COOKIE = 'x'; process.env.MCP_CONNECTOR_TOKEN = connector; delete process.env.MCP_AUTH_TOKEN; assert.throws(() => loadConfig(), /needs MCP_AUTH_TOKEN/); process.env.MCP_AUTH_TOKEN = connector; assert.throws(() => loadConfig(), /must differ from MCP_AUTH_TOKEN/); }); }); describe('loadConfig: WebUntis', () => { const UNTIS = { UNTIS_SERVER: 'ags-erfurt.webuntis.com', UNTIS_SCHOOL: 'ags-erfurt', UNTIS_USER: 'fia24b-test', UNTIS_SECRET: 'JBSWY3DPEHPK3PXP', }; /** The four are read from the environment, so a stray real value must not leak in. */ function only(overrides: Partial = {}): void { process.env.TSC_URL = 'https://example.org'; process.env.TSC_JWT_COOKIE = 'x'; for (const name of Object.keys(UNTIS)) delete process.env[name]; for (const [name, value] of Object.entries(overrides)) process.env[name] = value; } it('is off when nothing is set', () => { only(); assert.equal(loadConfig().untis, undefined); }); it('reads all four values', () => { only(UNTIS); assert.deepEqual(loadConfig().untis, { server: 'ags-erfurt.webuntis.com', school: 'ags-erfurt', user: 'fia24b-test', secret: 'JBSWY3DPEHPK3PXP', }); }); it('names what is missing instead of half-configuring itself', () => { only({ UNTIS_SERVER: UNTIS.UNTIS_SERVER, UNTIS_SCHOOL: UNTIS.UNTIS_SCHOOL }); assert.throws(() => loadConfig(), /missing: UNTIS_USER, UNTIS_SECRET/); assert.throws(() => loadConfig(), /Profil/); }); it('refuses a URL where the bare host belongs', () => { only({ ...UNTIS, UNTIS_SERVER: 'https://ags-erfurt.webuntis.com/WebUntis/' }); assert.throws(() => loadConfig(), /UNTIS_SERVER must be the bare host/); }); it('refuses a key that is not base32, without echoing it', () => { const secret = 'not-a-base32-key!'; only({ ...UNTIS, UNTIS_SECRET: secret }); assert.throws( () => loadConfig(), (error: Error) => /UNTIS_SECRET/.test(error.message) && !error.message.includes(secret), ); }); });