config.test.js (6178B)
1 // test/config.test.js - configuration loading, precedence and validation 2 3 import test from 'node:test'; 4 import assert from 'node:assert/strict'; 5 import fs from 'node:fs/promises'; 6 import os from 'node:os'; 7 import path from 'node:path'; 8 import { loadConfig, stateDirs, parseKey } from '../src/lib/config.js'; 9 10 // loadConfig reads process.env, so every case jobs with a clean slate. 11 async function withEnv(env, fn) { 12 const saved = {}; 13 for (const key of Object.keys(process.env)) { 14 if (key.startsWith('CONDUCTOR_')) { 15 saved[key] = process.env[key]; 16 delete process.env[key]; 17 } 18 } 19 Object.assign(process.env, env); 20 try { 21 return await fn(); 22 } finally { 23 for (const key of Object.keys(process.env)) { 24 if (key.startsWith('CONDUCTOR_')) delete process.env[key]; 25 } 26 Object.assign(process.env, saved); 27 } 28 } 29 30 async function tempDir() { 31 return fs.mkdtemp(path.join(os.tmpdir(), 'conductor-cfg-')); 32 } 33 34 test('defaults select sqlite, local storage and local auth', async () => { 35 await withEnv({}, () => { 36 const cfg = loadConfig(); 37 assert.equal(cfg.database.dialect, 'sqlite'); 38 assert.equal(cfg.storage.driver, 'local'); 39 assert.equal(cfg.auth.mode, 'local'); 40 assert.equal(cfg.server.port, 8080); 41 }); 42 }); 43 44 test('configuring an s3 bucket switches the storage driver', async () => { 45 await withEnv({ 46 CONDUCTOR_S3_ENDPOINT: 'http://127.0.0.1:3900', 47 CONDUCTOR_S3_BUCKET: 'ci', 48 CONDUCTOR_S3_ACCESS_KEY_ID: 'k', 49 CONDUCTOR_S3_SECRET_ACCESS_KEY: 's', 50 }, () => { 51 assert.equal(loadConfig().storage.driver, 's3'); 52 }); 53 }); 54 55 test('a partial s3 configuration is rejected rather than half applied', async () => { 56 await withEnv({ CONDUCTOR_S3_BUCKET: 'ci' }, () => { 57 assert.throws(() => loadConfig(), /partially configured/); 58 }); 59 }); 60 61 test('configuring an oidc discovery url switches the auth mode', async () => { 62 await withEnv({ 63 CONDUCTOR_OIDC_DISCOVERY_URL: 'https://idp.example.com/realms/ci/.well-known/openid-configuration', 64 CONDUCTOR_OIDC_CLIENT_ID: 'conductor', 65 }, () => { 66 assert.equal(loadConfig().auth.mode, 'oidc'); 67 }); 68 }); 69 70 test('an oidc discovery url without a client id is rejected', async () => { 71 await withEnv({ 72 CONDUCTOR_OIDC_DISCOVERY_URL: 'https://idp.example.com/realms/ci/.well-known/openid-configuration', 73 }, () => { 74 assert.throws(() => loadConfig(), /client_id is required/); 75 }); 76 }); 77 78 test('a database url selects its dialect', async () => { 79 await withEnv({ CONDUCTOR_DATABASE_URL: 'postgres://u:p@h:5432/ci' }, () => { 80 assert.equal(loadConfig().database.dialect, 'postgres'); 81 }); 82 await withEnv({ CONDUCTOR_DATABASE_URL: 'mysql://u:p@h:3306/ci' }, () => { 83 assert.equal(loadConfig().database.dialect, 'mysql'); 84 }); 85 }); 86 87 test('an unsupported database scheme is reported clearly', async () => { 88 await withEnv({ CONDUCTOR_DATABASE_URL: 'mongodb://h/ci' }, () => { 89 assert.throws(() => loadConfig(), /unsupported scheme/); 90 }); 91 }); 92 93 test('a sqlite url collapses into database.path', async () => { 94 await withEnv({ CONDUCTOR_DATABASE_URL: 'sqlite:///var/lib/conductor/c.db' }, () => { 95 const cfg = loadConfig(); 96 assert.equal(cfg.database.dialect, 'sqlite'); 97 assert.equal(cfg.database.url, null); 98 assert.equal(cfg.database.path, '/var/lib/conductor/c.db'); 99 }); 100 }); 101 102 test('environment variables override the config file', async () => { 103 const dir = await tempDir(); 104 const file = path.join(dir, 'conductor.yaml'); 105 await fs.writeFile(file, 'server:\n port: 9999\n public_url: https://ci.example.com\n'); 106 107 await withEnv({ CONDUCTOR_CONFIG: file, CONDUCTOR_PORT: '7777' }, () => { 108 const cfg = loadConfig(); 109 assert.equal(cfg.server.port, 7777); 110 assert.equal(cfg.server.public_url, 'https://ci.example.com'); 111 }); 112 await fs.rm(dir, { recursive: true, force: true }); 113 }); 114 115 test('relative paths resolve against the config file directory', async () => { 116 const dir = await tempDir(); 117 const file = path.join(dir, 'conductor.yaml'); 118 await fs.writeFile(file, 'database:\n path: ./state/c.db\n'); 119 120 await withEnv({ CONDUCTOR_CONFIG: file }, () => { 121 assert.equal(loadConfig().database.path, path.join(dir, 'state/c.db')); 122 }); 123 await fs.rm(dir, { recursive: true, force: true }); 124 }); 125 126 test('a missing explicit config file is an error, a missing default is not', async () => { 127 await withEnv({ CONDUCTOR_CONFIG: '/nonexistent/conductor.yaml' }, () => { 128 assert.throws(() => loadConfig(), /config file not found/); 129 }); 130 await withEnv({}, () => { 131 assert.doesNotThrow(() => loadConfig()); 132 }); 133 }); 134 135 test('a heartbeat timeout below the reap interval is rejected', async () => { 136 const dir = await tempDir(); 137 const file = path.join(dir, 'conductor.yaml'); 138 await fs.writeFile(file, 'scheduler:\n heartbeat_timeout: 10\n reap_interval: 30\n'); 139 140 await withEnv({ CONDUCTOR_CONFIG: file }, () => { 141 assert.throws(() => loadConfig(), /must be greater than/); 142 }); 143 await fs.rm(dir, { recursive: true, force: true }); 144 }); 145 146 test('a non-integer port is rejected with the variable named', async () => { 147 await withEnv({ CONDUCTOR_PORT: 'http' }, () => { 148 assert.throws(() => loadConfig(), /CONDUCTOR_PORT/); 149 }); 150 }); 151 152 test('parseKey accepts hex and base64, and rejects the wrong length', () => { 153 assert.equal(parseKey('a'.repeat(64), 'k').length, 32); 154 assert.equal(parseKey(Buffer.alloc(32).toString('base64'), 'k').length, 32); 155 assert.throws(() => parseKey('abcd', 'secrets.encryption_key'), /must decode to 32 bytes/); 156 }); 157 158 test('stateDirs omits the storage directory when s3 is in use', async () => { 159 await withEnv({}, () => { 160 assert.ok(stateDirs(loadConfig()).some((d) => d.endsWith('storage'))); 161 }); 162 await withEnv({ 163 CONDUCTOR_S3_ENDPOINT: 'http://127.0.0.1:3900', 164 CONDUCTOR_S3_BUCKET: 'ci', 165 CONDUCTOR_S3_ACCESS_KEY_ID: 'k', 166 CONDUCTOR_S3_SECRET_ACCESS_KEY: 's', 167 }, () => { 168 assert.ok(!stateDirs(loadConfig()).some((d) => d.endsWith('storage'))); 169 }); 170 }); 171 172 test('the loaded config is frozen', async () => { 173 await withEnv({}, () => { 174 const cfg = loadConfig(); 175 assert.throws(() => { cfg.server.port = 1; }, TypeError); 176 }); 177 });