conductor

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

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