conductor

CI task system
git clone git://git.finwo.net/app/conductor
Log | Files | Refs | README | LICENSE

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 });