Extract pemToBase64Der and buildClientAuthentication from caddy.ts into a new caddy-mtls.ts module, adding groupMtlsDomainsByCaSet to group mTLS domains by their CA fingerprint before building TLS connection policies. Previously all mTLS domains sharing a cert type (auto-managed, imported, or managed) were grouped into a single policy, causing CA union: a client cert from CA_B could authenticate against a host that only trusted CA_A. The fix creates one policy per unique CA set, ensuring strict per-host CA isolation across all three TLS policy code paths. Also adds: - tests/unit/caddy-mtls.test.ts (26 tests) covering pemToBase64Der, buildClientAuthentication, groupMtlsDomainsByCaSet, and cross-CA isolation regression tests - tests/unit/instance-sync-env.test.ts (33 tests) for the five pure env-reading functions in instance-sync.ts - tests/integration/instance-sync.test.ts (16 tests) for buildSyncPayload and applySyncPayload using an in-memory SQLite db - Fix tests/helpers/db.ts to use a relative import for db/schema so it works inside vi.mock factory dynamic imports Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
249 lines
7.6 KiB
TypeScript
249 lines
7.6 KiB
TypeScript
/**
|
|
* Unit tests for the pure environment-variable-reading functions
|
|
* exported by src/lib/instance-sync.ts.
|
|
*
|
|
* These functions have no DB or network dependency — they only read
|
|
* from process.env and do simple parsing/validation.
|
|
*/
|
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import {
|
|
getEnvSlaveInstances,
|
|
getSyncIntervalMs,
|
|
isHttpSyncAllowed,
|
|
isInstanceModeFromEnv,
|
|
isSyncTokenFromEnv,
|
|
} from '../../src/lib/instance-sync';
|
|
|
|
const KEYS = [
|
|
'INSTANCE_SLAVES',
|
|
'INSTANCE_SYNC_INTERVAL',
|
|
'INSTANCE_SYNC_ALLOW_HTTP',
|
|
'INSTANCE_MODE',
|
|
'INSTANCE_SYNC_TOKEN',
|
|
] as const;
|
|
|
|
beforeEach(() => {
|
|
for (const k of KEYS) delete process.env[k];
|
|
});
|
|
|
|
afterEach(() => {
|
|
for (const k of KEYS) delete process.env[k];
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// getEnvSlaveInstances
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('getEnvSlaveInstances', () => {
|
|
it('returns empty array when env var is not set', () => {
|
|
expect(getEnvSlaveInstances()).toEqual([]);
|
|
});
|
|
|
|
it('returns empty array for empty string', () => {
|
|
process.env.INSTANCE_SLAVES = '';
|
|
expect(getEnvSlaveInstances()).toEqual([]);
|
|
});
|
|
|
|
it('parses a valid single slave entry', () => {
|
|
process.env.INSTANCE_SLAVES = JSON.stringify([
|
|
{ name: 'slave1', url: 'https://slave.example.com', token: 'secret123' },
|
|
]);
|
|
const result = getEnvSlaveInstances();
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0]).toEqual({
|
|
name: 'slave1',
|
|
url: 'https://slave.example.com',
|
|
token: 'secret123',
|
|
});
|
|
});
|
|
|
|
it('parses multiple slave entries', () => {
|
|
process.env.INSTANCE_SLAVES = JSON.stringify([
|
|
{ name: 'slave1', url: 'https://slave1.example.com', token: 'tok1' },
|
|
{ name: 'slave2', url: 'https://slave2.example.com', token: 'tok2' },
|
|
]);
|
|
expect(getEnvSlaveInstances()).toHaveLength(2);
|
|
});
|
|
|
|
it('returns empty array for non-array JSON', () => {
|
|
process.env.INSTANCE_SLAVES = '{"name":"slave1"}'; // object, not array
|
|
expect(getEnvSlaveInstances()).toEqual([]);
|
|
});
|
|
|
|
it('returns empty array for malformed JSON', () => {
|
|
process.env.INSTANCE_SLAVES = '{bad json';
|
|
expect(getEnvSlaveInstances()).toEqual([]);
|
|
});
|
|
|
|
it('filters out entries missing required fields', () => {
|
|
process.env.INSTANCE_SLAVES = JSON.stringify([
|
|
{ name: 'slave1', url: 'https://slave1.example.com', token: 'tok1' }, // valid
|
|
{ name: 'slave2', url: 'https://slave2.example.com' }, // missing token
|
|
{ name: 'slave3', token: 'tok3' }, // missing url
|
|
{ url: 'https://slave4.example.com', token: 'tok4' }, // missing name
|
|
]);
|
|
const result = getEnvSlaveInstances();
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].name).toBe('slave1');
|
|
});
|
|
|
|
it('filters out entries with empty string fields', () => {
|
|
process.env.INSTANCE_SLAVES = JSON.stringify([
|
|
{ name: '', url: 'https://slave.example.com', token: 'tok' }, // empty name
|
|
]);
|
|
expect(getEnvSlaveInstances()).toEqual([]);
|
|
});
|
|
|
|
it('filters out non-object entries', () => {
|
|
process.env.INSTANCE_SLAVES = JSON.stringify([
|
|
42,
|
|
null,
|
|
'string',
|
|
{ name: 'ok', url: 'https://ok.com', token: 'tok' },
|
|
]);
|
|
const result = getEnvSlaveInstances();
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].name).toBe('ok');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// getSyncIntervalMs
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('getSyncIntervalMs', () => {
|
|
it('returns 0 when env var is not set (disabled)', () => {
|
|
expect(getSyncIntervalMs()).toBe(0);
|
|
});
|
|
|
|
it('converts seconds to milliseconds', () => {
|
|
process.env.INSTANCE_SYNC_INTERVAL = '60';
|
|
expect(getSyncIntervalMs()).toBe(60_000);
|
|
});
|
|
|
|
it('enforces minimum of 30 seconds', () => {
|
|
process.env.INSTANCE_SYNC_INTERVAL = '10';
|
|
expect(getSyncIntervalMs()).toBe(30_000); // clamped to 30s
|
|
});
|
|
|
|
it('exactly 30 seconds is allowed', () => {
|
|
process.env.INSTANCE_SYNC_INTERVAL = '30';
|
|
expect(getSyncIntervalMs()).toBe(30_000);
|
|
});
|
|
|
|
it('returns 0 for "0"', () => {
|
|
process.env.INSTANCE_SYNC_INTERVAL = '0';
|
|
expect(getSyncIntervalMs()).toBe(0);
|
|
});
|
|
|
|
it('returns 0 for negative value', () => {
|
|
process.env.INSTANCE_SYNC_INTERVAL = '-60';
|
|
expect(getSyncIntervalMs()).toBe(0);
|
|
});
|
|
|
|
it('returns 0 for non-numeric string', () => {
|
|
process.env.INSTANCE_SYNC_INTERVAL = 'abc';
|
|
expect(getSyncIntervalMs()).toBe(0);
|
|
});
|
|
|
|
it('handles large interval correctly', () => {
|
|
process.env.INSTANCE_SYNC_INTERVAL = '3600'; // 1 hour
|
|
expect(getSyncIntervalMs()).toBe(3_600_000);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// isHttpSyncAllowed
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('isHttpSyncAllowed', () => {
|
|
it('returns false when env var is not set', () => {
|
|
expect(isHttpSyncAllowed()).toBe(false);
|
|
});
|
|
|
|
it('returns true for "true"', () => {
|
|
process.env.INSTANCE_SYNC_ALLOW_HTTP = 'true';
|
|
expect(isHttpSyncAllowed()).toBe(true);
|
|
});
|
|
|
|
it('returns true for "1"', () => {
|
|
process.env.INSTANCE_SYNC_ALLOW_HTTP = '1';
|
|
expect(isHttpSyncAllowed()).toBe(true);
|
|
});
|
|
|
|
it('returns false for "false"', () => {
|
|
process.env.INSTANCE_SYNC_ALLOW_HTTP = 'false';
|
|
expect(isHttpSyncAllowed()).toBe(false);
|
|
});
|
|
|
|
it('returns false for "yes"', () => {
|
|
process.env.INSTANCE_SYNC_ALLOW_HTTP = 'yes';
|
|
expect(isHttpSyncAllowed()).toBe(false);
|
|
});
|
|
|
|
it('returns false for empty string', () => {
|
|
process.env.INSTANCE_SYNC_ALLOW_HTTP = '';
|
|
expect(isHttpSyncAllowed()).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// isInstanceModeFromEnv
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('isInstanceModeFromEnv', () => {
|
|
it('returns false when env var is not set', () => {
|
|
expect(isInstanceModeFromEnv()).toBe(false);
|
|
});
|
|
|
|
it('returns true for "master"', () => {
|
|
process.env.INSTANCE_MODE = 'master';
|
|
expect(isInstanceModeFromEnv()).toBe(true);
|
|
});
|
|
|
|
it('returns true for "slave"', () => {
|
|
process.env.INSTANCE_MODE = 'slave';
|
|
expect(isInstanceModeFromEnv()).toBe(true);
|
|
});
|
|
|
|
it('returns true for "standalone"', () => {
|
|
process.env.INSTANCE_MODE = 'standalone';
|
|
expect(isInstanceModeFromEnv()).toBe(true);
|
|
});
|
|
|
|
it('returns false for invalid mode', () => {
|
|
process.env.INSTANCE_MODE = 'invalid';
|
|
expect(isInstanceModeFromEnv()).toBe(false);
|
|
});
|
|
|
|
it('returns false for empty string', () => {
|
|
process.env.INSTANCE_MODE = '';
|
|
expect(isInstanceModeFromEnv()).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// isSyncTokenFromEnv
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('isSyncTokenFromEnv', () => {
|
|
it('returns false when env var is not set', () => {
|
|
expect(isSyncTokenFromEnv()).toBe(false);
|
|
});
|
|
|
|
it('returns true when token is set to a non-empty string', () => {
|
|
process.env.INSTANCE_SYNC_TOKEN = 'my-secret-token';
|
|
expect(isSyncTokenFromEnv()).toBe(true);
|
|
});
|
|
|
|
it('returns false for empty string token', () => {
|
|
process.env.INSTANCE_SYNC_TOKEN = '';
|
|
expect(isSyncTokenFromEnv()).toBe(false);
|
|
});
|
|
|
|
it('returns true for any non-empty value', () => {
|
|
process.env.INSTANCE_SYNC_TOKEN = ' '; // whitespace counts as non-empty
|
|
expect(isSyncTokenFromEnv()).toBe(true);
|
|
});
|
|
});
|