config.test.js (8931B)
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('the built-in provider is on by default and answers at public_url', async () => { 79 await withEnv({ CONDUCTOR_PUBLIC_URL: 'https://ci.example.com/' }, () => { 80 const cfg = loadConfig(); 81 assert.equal(cfg.auth.local.enabled, true); 82 assert.deepEqual(cfg.auth.local.redirect_uris, ['https://ci.example.com']); 83 }); 84 }); 85 86 test('configuring a provider turns the built-in one off', async () => { 87 await withEnv({ 88 CONDUCTOR_OIDC_DISCOVERY_URL: 'https://idp.example.com/realms/ci/.well-known/openid-configuration', 89 CONDUCTOR_OIDC_CLIENT_ID: 'conductor', 90 }, () => { 91 assert.equal(loadConfig().auth.local.enabled, false); 92 }); 93 }); 94 95 // Only one provider can be advertised, so asking for the built-in one wins. 96 test('enabling the built-in provider overrides a configured one', async () => { 97 await withEnv({ 98 CONDUCTOR_OIDC_DISCOVERY_URL: 'https://idp.example.com/realms/ci/.well-known/openid-configuration', 99 CONDUCTOR_OIDC_CLIENT_ID: 'conductor', 100 CONDUCTOR_LOCAL_AUTH: 'true', 101 }, () => { 102 const cfg = loadConfig(); 103 assert.equal(cfg.auth.mode, 'local'); 104 assert.equal(cfg.auth.local.enabled, true); 105 }); 106 }); 107 108 test('disabling the built-in provider with no other one is rejected', async () => { 109 await withEnv({ CONDUCTOR_LOCAL_AUTH: 'false' }, () => { 110 assert.throws(() => loadConfig(), /nobody could sign in/); 111 }); 112 }); 113 114 test('redirect uris are split on commas and lose trailing slashes', async () => { 115 await withEnv({ 116 CONDUCTOR_LOCAL_REDIRECT_URIS: 'https://app.example.com/ , https://alt.example.com', 117 }, () => { 118 assert.deepEqual(loadConfig().auth.local.redirect_uris, [ 119 'https://app.example.com', 120 'https://alt.example.com', 121 ]); 122 }); 123 }); 124 125 test('a star allows any origin, and absorbs anything listed beside it', async () => { 126 await withEnv({ CONDUCTOR_LOCAL_REDIRECT_URIS: 'https://app.example.com,*' }, () => { 127 assert.deepEqual(loadConfig().auth.local.redirect_uris, ['*']); 128 }); 129 }); 130 131 // A bare host:port parses as a URL with a nonsense scheme, so it would 132 // silently match nothing rather than failing. 133 test('a redirect uri without a scheme is rejected at startup', async () => { 134 await withEnv({ CONDUCTOR_LOCAL_REDIRECT_URIS: '127.0.0.1:18080,localhost:18080' }, () => { 135 assert.throws(() => loadConfig(), /must be an absolute http or https URL/); 136 }); 137 }); 138 139 test('token lifetimes are read from the environment as integers', async () => { 140 await withEnv({ 141 CONDUCTOR_ACCESS_TTL: '300', 142 CONDUCTOR_REFRESH_TTL: '86400', 143 CONDUCTOR_REFRESH_SLIDING: 'off', 144 }, () => { 145 const cfg = loadConfig(); 146 assert.equal(cfg.auth.access_ttl, 300); 147 assert.equal(cfg.auth.refresh_ttl, 86400); 148 assert.equal(cfg.auth.refresh_sliding, false); 149 }); 150 }); 151 152 test('a database url selects its dialect', async () => { 153 await withEnv({ CONDUCTOR_DATABASE_URL: 'postgres://u:p@h:5432/ci' }, () => { 154 assert.equal(loadConfig().database.dialect, 'postgres'); 155 }); 156 await withEnv({ CONDUCTOR_DATABASE_URL: 'mysql://u:p@h:3306/ci' }, () => { 157 assert.equal(loadConfig().database.dialect, 'mysql'); 158 }); 159 }); 160 161 test('an unsupported database scheme is reported clearly', async () => { 162 await withEnv({ CONDUCTOR_DATABASE_URL: 'mongodb://h/ci' }, () => { 163 assert.throws(() => loadConfig(), /unsupported scheme/); 164 }); 165 }); 166 167 test('a sqlite url collapses into database.path', async () => { 168 await withEnv({ CONDUCTOR_DATABASE_URL: 'sqlite:///var/lib/conductor/c.db' }, () => { 169 const cfg = loadConfig(); 170 assert.equal(cfg.database.dialect, 'sqlite'); 171 assert.equal(cfg.database.url, null); 172 assert.equal(cfg.database.path, '/var/lib/conductor/c.db'); 173 }); 174 }); 175 176 test('environment variables override the config file', async () => { 177 const dir = await tempDir(); 178 const file = path.join(dir, 'conductor.yaml'); 179 await fs.writeFile(file, 'server:\n port: 9999\n public_url: https://ci.example.com\n'); 180 181 await withEnv({ CONDUCTOR_CONFIG: file, CONDUCTOR_PORT: '7777' }, () => { 182 const cfg = loadConfig(); 183 assert.equal(cfg.server.port, 7777); 184 assert.equal(cfg.server.public_url, 'https://ci.example.com'); 185 }); 186 await fs.rm(dir, { recursive: true, force: true }); 187 }); 188 189 test('relative paths resolve against the config file directory', async () => { 190 const dir = await tempDir(); 191 const file = path.join(dir, 'conductor.yaml'); 192 await fs.writeFile(file, 'database:\n path: ./state/c.db\n'); 193 194 await withEnv({ CONDUCTOR_CONFIG: file }, () => { 195 assert.equal(loadConfig().database.path, path.join(dir, 'state/c.db')); 196 }); 197 await fs.rm(dir, { recursive: true, force: true }); 198 }); 199 200 test('a missing explicit config file is an error, a missing default is not', async () => { 201 await withEnv({ CONDUCTOR_CONFIG: '/nonexistent/conductor.yaml' }, () => { 202 assert.throws(() => loadConfig(), /config file not found/); 203 }); 204 await withEnv({}, () => { 205 assert.doesNotThrow(() => loadConfig()); 206 }); 207 }); 208 209 test('a heartbeat timeout below the reap interval is rejected', async () => { 210 const dir = await tempDir(); 211 const file = path.join(dir, 'conductor.yaml'); 212 await fs.writeFile(file, 'scheduler:\n heartbeat_timeout: 10\n reap_interval: 30\n'); 213 214 await withEnv({ CONDUCTOR_CONFIG: file }, () => { 215 assert.throws(() => loadConfig(), /must be greater than/); 216 }); 217 await fs.rm(dir, { recursive: true, force: true }); 218 }); 219 220 test('a non-integer port is rejected with the variable named', async () => { 221 await withEnv({ CONDUCTOR_PORT: 'http' }, () => { 222 assert.throws(() => loadConfig(), /CONDUCTOR_PORT/); 223 }); 224 }); 225 226 test('parseKey accepts hex and base64, and rejects the wrong length', () => { 227 assert.equal(parseKey('a'.repeat(64), 'k').length, 32); 228 assert.equal(parseKey(Buffer.alloc(32).toString('base64'), 'k').length, 32); 229 assert.throws(() => parseKey('abcd', 'secrets.encryption_key'), /must decode to 32 bytes/); 230 }); 231 232 test('stateDirs omits the storage directory when s3 is in use', async () => { 233 await withEnv({}, () => { 234 assert.ok(stateDirs(loadConfig()).some((d) => d.endsWith('storage'))); 235 }); 236 await withEnv({ 237 CONDUCTOR_S3_ENDPOINT: 'http://127.0.0.1:3900', 238 CONDUCTOR_S3_BUCKET: 'ci', 239 CONDUCTOR_S3_ACCESS_KEY_ID: 'k', 240 CONDUCTOR_S3_SECRET_ACCESS_KEY: 's', 241 }, () => { 242 assert.ok(!stateDirs(loadConfig()).some((d) => d.endsWith('storage'))); 243 }); 244 }); 245 246 test('the loaded config is frozen', async () => { 247 await withEnv({}, () => { 248 const cfg = loadConfig(); 249 assert.throws(() => { cfg.server.port = 1; }, TypeError); 250 }); 251 });