fix mTLS cross-CA isolation bug, add instance-sync and mTLS tests

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>
This commit is contained in:
fuomag9
2026-03-07 18:32:52 +01:00
parent e5ba3e1ed9
commit fd847e7eb5
6 changed files with 1238 additions and 87 deletions

View File

@@ -0,0 +1,248 @@
/**
* 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);
});
});