diff --git a/src/lib/caddy-mtls.ts b/src/lib/caddy-mtls.ts new file mode 100644 index 00000000..c5fab29b --- /dev/null +++ b/src/lib/caddy-mtls.ts @@ -0,0 +1,110 @@ +/** + * mTLS helper functions for building Caddy TLS connection policies. + * + * Extracted from caddy.ts so they can be unit-tested independently. + */ + +/** + * Converts a PEM certificate to base64-encoded DER format expected by Caddy's + * `trusted_ca_certs` and `trusted_leaf_certs` fields. + */ +export function pemToBase64Der(pem: string): string { + return pem + .replace(/-----BEGIN CERTIFICATE-----/, "") + .replace(/-----END CERTIFICATE-----/, "") + .replace(/\s+/g, ""); +} + +/** + * Builds a Caddy `client_authentication` block for the given list of domains. + * + * All CA cert IDs referenced by those domains are unioned into one set, which is + * intentional when every domain in the list shares the same CA configuration. + * Callers must ensure that `domains` is pre-grouped so domains with different CA + * sets are passed in separate calls — see `groupMtlsDomainsByCaSet`. + * + * Strategy per CA: + * - Unmanaged CA (no tracked issued certs): trust any cert signed by that CA. + * - Managed CA with active certs: CA in `trusted_ca_certs` + active leaf certs + * in `trusted_leaf_certs` (revocation enforcement). + * - Managed CA with ALL certs revoked: excluded entirely (chain validation fails). + * + * Returns null if there are no CA certs to trust (all excluded or none configured). + */ +export function buildClientAuthentication( + domains: string[], + mTlsDomainMap: Map, + caCertMap: Map, + issuedClientCertMap: Map, + cAsWithAnyIssuedCerts: Set +): Record | null { + const caCertIds = new Set(); + for (const domain of domains) { + const ids = mTlsDomainMap.get(domain.toLowerCase()); + if (ids) { + for (const id of ids) caCertIds.add(id); + } + } + if (caCertIds.size === 0) return null; + + const trustedCaCerts: string[] = []; + const trustedLeafCerts: string[] = []; + + for (const id of caCertIds) { + const ca = caCertMap.get(id); + if (!ca) continue; + + if (cAsWithAnyIssuedCerts.has(id)) { + // Managed CA: enforce revocation via leaf pinning + const activeLeafCerts = issuedClientCertMap.get(id) ?? []; + if (activeLeafCerts.length === 0) { + // All certs revoked: exclude CA so chain validation fails for its certs + continue; + } + trustedCaCerts.push(pemToBase64Der(ca.certificatePem)); + for (const certPem of activeLeafCerts) { + trustedLeafCerts.push(pemToBase64Der(certPem)); + } + } else { + // Unmanaged CA: trust any cert in the chain + trustedCaCerts.push(pemToBase64Der(ca.certificatePem)); + } + } + + if (trustedCaCerts.length === 0) return null; + + const result: Record = { + mode: "require_and_verify", + trusted_ca_certs: trustedCaCerts, + }; + if (trustedLeafCerts.length > 0) result.trusted_leaf_certs = trustedLeafCerts; + return result; +} + +/** + * Groups mTLS domains by their sorted CA ID fingerprint so that each group can + * get its own TLS connection policy with the correct, isolated set of trusted CAs. + * + * Domains with the same set of CA IDs (regardless of order) are placed in the + * same group. Domains with different CA sets end up in separate groups, ensuring + * a client certificate from CA_B cannot authenticate against a host that only + * configured CA_A. + * + * @param domains - List of domain names that have mTLS configured. + * @param mTlsDomainMap - Map from lowercased domain to its list of CA cert IDs. + * @returns Map from CA-set fingerprint string to the list of domains sharing it. + */ +export function groupMtlsDomainsByCaSet( + domains: string[], + mTlsDomainMap: Map +): Map { + const groups = new Map(); + for (const domain of domains) { + const ids = mTlsDomainMap.get(domain.toLowerCase()) ?? []; + const key = [...ids].sort((a, b) => a - b).join(","); + const group = groups.get(key) ?? []; + group.push(domain); + groups.set(key, group); + } + return groups; +} diff --git a/src/lib/caddy.ts b/src/lib/caddy.ts index bb27147b..76266c30 100644 --- a/src/lib/caddy.ts +++ b/src/lib/caddy.ts @@ -47,6 +47,7 @@ import { proxyHosts } from "./db/schema"; import { type GeoBlockMode, type WafHostConfig, type MtlsConfig } from "./models/proxy-hosts"; +import { pemToBase64Der, buildClientAuthentication, groupMtlsDomainsByCaSet } from "./caddy-mtls"; const CERTS_DIR = process.env.CERTS_DIRECTORY || join(process.cwd(), "data", "certs"); mkdirSync(CERTS_DIR, { recursive: true, mode: 0o700 }); @@ -467,14 +468,6 @@ async function resolveUpstreamDials( }; } -function pemToBase64Der(pem: string): string { - // Strip PEM headers/footers and whitespace — what remains is the base64-encoded DER - return pem - .replace(/-----BEGIN CERTIFICATE-----/, "") - .replace(/-----END CERTIFICATE-----/, "") - .replace(/\s+/g, ""); -} - function collectCertificateUsage(rows: ProxyHostRow[], certificates: Map) { const usage = new Map(); const autoManagedDomains = new Set(); @@ -1104,65 +1097,6 @@ async function buildProxyRoutes( return routes; } -function buildClientAuthentication( - domains: string[], - mTlsDomainMap: Map, - caCertMap: Map, - issuedClientCertMap: Map, - cAsWithAnyIssuedCerts: Set -): Record | null { - // Collect all CA cert IDs for any domain in this policy that has mTLS. - const caCertIds = new Set(); - for (const domain of domains) { - const ids = mTlsDomainMap.get(domain.toLowerCase()); - if (ids) { - for (const id of ids) caCertIds.add(id); - } - } - if (caCertIds.size === 0) return null; - - // trusted_leaf_certs is an ADDITIONAL check on top of CA chain validation - // (Caddy rejects without trusted_ca_certs even when trusted_leaf_certs is set). - // - // Strategy: - // - Unmanaged CA (no tracked issued certs): trust any cert signed by that CA - // → CA cert in trusted_ca_certs only. - // - Managed CA with active certs: trust only those specific leaf certs - // → CA cert in trusted_ca_certs (for chain validation) + - // active leaf certs in trusted_leaf_certs (revocation enforcement). - // - Managed CA with ALL certs revoked: exclude it from trusted_ca_certs so - // chain validation fails — no cert from it will be accepted. - const trustedCaCerts: string[] = []; - const trustedLeafCerts: string[] = []; - - for (const id of caCertIds) { - const ca = caCertMap.get(id); - if (!ca) continue; - - if (cAsWithAnyIssuedCerts.has(id)) { - // Managed CA: enforce revocation via leaf pinning - const activeLeafCerts = issuedClientCertMap.get(id) ?? []; - if (activeLeafCerts.length === 0) { - // All certs revoked: exclude CA so chain validation fails for its certs - continue; - } - trustedCaCerts.push(pemToBase64Der(ca.certificatePem)); - for (const certPem of activeLeafCerts) { - trustedLeafCerts.push(pemToBase64Der(certPem)); - } - } else { - // Unmanaged CA: trust any cert in the chain - trustedCaCerts.push(pemToBase64Der(ca.certificatePem)); - } - } - - if (trustedCaCerts.length === 0) return null; - - const result: Record = { mode: "require_and_verify", trusted_ca_certs: trustedCaCerts }; - if (trustedLeafCerts.length > 0) result.trusted_leaf_certs = trustedLeafCerts; - return result; -} - function buildTlsConnectionPolicies( usage: Map, managedCertificatesWithAutomation: Set, @@ -1179,6 +1113,25 @@ function buildTlsConnectionPolicies( const buildAuth = (domains: string[]) => buildClientAuthentication(domains, mTlsDomainMap, caCertMap, issuedClientCertMap, cAsWithAnyIssuedCerts); + /** + * Pushes one TLS policy per unique CA set found in `mTlsDomains`. + * Domains that share the same CA configuration are grouped into one policy; + * domains with different CAs get separate policies so a cert from CA_B cannot + * authenticate against a host that only trusts CA_A. + */ + const pushMtlsPolicies = (mTlsDomains: string[]) => { + const groups = groupMtlsDomainsByCaSet(mTlsDomains, mTlsDomainMap); + for (const domainGroup of groups.values()) { + const mTlsAuth = buildAuth(domainGroup); + if (mTlsAuth) { + policies.push({ match: { sni: domainGroup }, client_authentication: mTlsAuth }); + } else { + // All CAs have all certs revoked — drop connections rather than allow through without mTLS + policies.push({ match: { sni: domainGroup }, drop: true }); + } + } + }; + // Add policy for auto-managed domains (certificate_id = null) if (autoManagedDomains.size > 0) { const domains = Array.from(autoManagedDomains); @@ -1187,13 +1140,7 @@ function buildTlsConnectionPolicies( const nonMTlsDomains = domains.filter(d => !mTlsDomainMap.has(d)); if (mTlsDomains.length > 0) { - const mTlsAuth = buildAuth(mTlsDomains); - if (mTlsAuth) { - policies.push({ match: { sni: mTlsDomains }, client_authentication: mTlsAuth }); - } else { - // All CAs have all certs revoked — drop connections rather than allow through without mTLS - policies.push({ match: { sni: mTlsDomains }, drop: true }); - } + pushMtlsPolicies(mTlsDomains); } if (nonMTlsDomains.length > 0) { policies.push({ match: { sni: nonMTlsDomains } }); @@ -1221,12 +1168,7 @@ function buildTlsConnectionPolicies( const nonMTlsDomains = domains.filter(d => !mTlsDomainMap.has(d)); if (mTlsDomains.length > 0) { - const mTlsAuth = buildAuth(mTlsDomains); - if (mTlsAuth) { - policies.push({ match: { sni: mTlsDomains }, client_authentication: mTlsAuth }); - } else { - policies.push({ match: { sni: mTlsDomains }, drop: true }); - } + pushMtlsPolicies(mTlsDomains); } if (nonMTlsDomains.length > 0) { policies.push({ match: { sni: nonMTlsDomains } }); @@ -1245,12 +1187,7 @@ function buildTlsConnectionPolicies( const nonMTlsDomains = domains.filter(d => !mTlsDomainMap.has(d)); if (mTlsDomains.length > 0) { - const mTlsAuth = buildAuth(mTlsDomains); - if (mTlsAuth) { - policies.push({ match: { sni: mTlsDomains }, client_authentication: mTlsAuth }); - } else { - policies.push({ match: { sni: mTlsDomains }, drop: true }); - } + pushMtlsPolicies(mTlsDomains); } if (nonMTlsDomains.length > 0) { policies.push({ match: { sni: nonMTlsDomains } }); diff --git a/tests/helpers/db.ts b/tests/helpers/db.ts index 718c135f..5bd5b87b 100644 --- a/tests/helpers/db.ts +++ b/tests/helpers/db.ts @@ -2,7 +2,7 @@ import Database from 'better-sqlite3'; import { drizzle } from 'drizzle-orm/better-sqlite3'; import { migrate } from 'drizzle-orm/better-sqlite3/migrator'; import { resolve } from 'node:path'; -import * as schema from '@/src/lib/db/schema'; +import * as schema from '../../src/lib/db/schema'; const migrationsFolder = resolve(process.cwd(), 'drizzle'); diff --git a/tests/integration/instance-sync.test.ts b/tests/integration/instance-sync.test.ts new file mode 100644 index 00000000..5abf4f8c --- /dev/null +++ b/tests/integration/instance-sync.test.ts @@ -0,0 +1,386 @@ +/** + * Integration tests for buildSyncPayload and applySyncPayload + * in src/lib/instance-sync.ts. + * + * We mock src/lib/db.ts to inject a fresh migrated in-memory SQLite + * database, giving full control over table content without affecting + * any real db file. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { TestDb } from '../helpers/db'; + +// --------------------------------------------------------------------------- +// Mock src/lib/db — must be declared before any import that uses the db. +// vi.hoisted() creates the mutable container at hoist time so the vi.mock +// factory (which also runs during hoisting) can populate it safely. +// --------------------------------------------------------------------------- + +const ctx = vi.hoisted(() => ({ db: null as unknown as TestDb })); + +vi.mock('../../src/lib/db', async () => { + const { createTestDb } = await import('../helpers/db'); + const schemaModule = await import('../../src/lib/db/schema'); + ctx.db = createTestDb(); + return { + default: ctx.db, + schema: schemaModule, + nowIso: () => new Date().toISOString(), + toIso: (value: string | Date | null | undefined): string | null => { + if (!value) return null; + return value instanceof Date ? value.toISOString() : new Date(value).toISOString(); + }, + }; +}); + +// These imports must come AFTER vi.mock to pick up the mocked module. +import { buildSyncPayload, applySyncPayload } from '../../src/lib/instance-sync'; +import * as schema from '../../src/lib/db/schema'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function nowIso() { + return new Date().toISOString(); +} + +/** A minimal proxy host record that satisfies the schema. */ +function makeProxyHost(overrides: Partial = {}) { + const now = nowIso(); + return { + name: 'Test Host', + domains: JSON.stringify(['test.example.com']), + upstreams: JSON.stringify(['backend:8080']), + certificateId: null, + accessListId: null, + ownerUserId: null, + sslForced: false, + hstsEnabled: false, + hstsSubdomains: false, + allowWebsocket: false, + preserveHostHeader: false, + skipHttpsHostnameValidation: false, + meta: null, + enabled: true, + createdAt: now, + updatedAt: now, + ...overrides, + } satisfies typeof schema.proxyHosts.$inferInsert; +} + +/** Clean all relevant tables between tests to avoid cross-test contamination. */ +async function clearTables() { + await ctx.db.delete(schema.proxyHosts); + await ctx.db.delete(schema.accessListEntries); + await ctx.db.delete(schema.accessLists); + await ctx.db.delete(schema.issuedClientCertificates); + await ctx.db.delete(schema.certificates); + await ctx.db.delete(schema.caCertificates); + await ctx.db.delete(schema.settings); +} + +beforeEach(async () => { + await clearTables(); +}); + +// --------------------------------------------------------------------------- +// buildSyncPayload +// --------------------------------------------------------------------------- + +describe('buildSyncPayload', () => { + it('returns empty arrays when db has no rows', async () => { + const payload = await buildSyncPayload(); + expect(payload.data.proxyHosts).toEqual([]); + expect(payload.data.certificates).toEqual([]); + expect(payload.data.caCertificates).toEqual([]); + expect(payload.data.issuedClientCertificates).toEqual([]); + expect(payload.data.accessLists).toEqual([]); + expect(payload.data.accessListEntries).toEqual([]); + }); + + it('returns null settings when no settings are stored', async () => { + const payload = await buildSyncPayload(); + expect(payload.settings.general).toBeNull(); + expect(payload.settings.cloudflare).toBeNull(); + expect(payload.settings.authentik).toBeNull(); + expect(payload.settings.dns).toBeNull(); + expect(payload.settings.waf).toBeNull(); + expect(payload.settings.geoblock).toBeNull(); + }); + + it('includes generated_at as an ISO date string', async () => { + const before = Date.now(); + const payload = await buildSyncPayload(); + const after = Date.now(); + const ts = new Date(payload.generated_at).getTime(); + expect(ts).toBeGreaterThanOrEqual(before); + expect(ts).toBeLessThanOrEqual(after); + }); + + it('sanitizes proxy host ownerUserId to null', async () => { + // buildSyncPayload always spreads ...row then sets ownerUserId: null + await ctx.db.insert(schema.proxyHosts).values(makeProxyHost()); + const payload = await buildSyncPayload(); + expect(payload.data.proxyHosts).toHaveLength(1); + expect(payload.data.proxyHosts[0].ownerUserId).toBeNull(); + }); + + it('includes proxy host data fields correctly', async () => { + await ctx.db.insert(schema.proxyHosts).values( + makeProxyHost({ name: 'My Host', domains: JSON.stringify(['myhost.example.com']) }) + ); + const payload = await buildSyncPayload(); + expect(payload.data.proxyHosts[0].name).toBe('My Host'); + expect(JSON.parse(payload.data.proxyHosts[0].domains)).toEqual(['myhost.example.com']); + }); + + it('sanitizes certificate createdBy to null', async () => { + const now = nowIso(); + await ctx.db.insert(schema.certificates).values({ + name: 'Test Cert', + type: 'managed', + domainNames: JSON.stringify(['cert.example.com']), + autoRenew: true, + providerOptions: null, + certificatePem: null, + privateKeyPem: null, + createdBy: null, + createdAt: now, + updatedAt: now, + }); + const payload = await buildSyncPayload(); + expect(payload.data.certificates).toHaveLength(1); + expect(payload.data.certificates[0].createdBy).toBeNull(); + expect(payload.data.certificates[0].name).toBe('Test Cert'); + }); + + it('sanitizes access list createdBy to null', async () => { + const now = nowIso(); + await ctx.db.insert(schema.accessLists).values({ + name: 'My List', + description: null, + createdBy: null, + createdAt: now, + updatedAt: now, + }); + const payload = await buildSyncPayload(); + expect(payload.data.accessLists).toHaveLength(1); + expect(payload.data.accessLists[0].createdBy).toBeNull(); + expect(payload.data.accessLists[0].name).toBe('My List'); + }); + + it('includes access list entries unchanged', async () => { + const now = nowIso(); + const [list] = await ctx.db.insert(schema.accessLists).values({ + name: 'List A', + description: null, + createdBy: null, + createdAt: now, + updatedAt: now, + }).returning(); + await ctx.db.insert(schema.accessListEntries).values({ + accessListId: list.id, + username: 'user1', + passwordHash: '$2b$10$fakehashhhhh', + createdAt: now, + updatedAt: now, + }); + const payload = await buildSyncPayload(); + expect(payload.data.accessListEntries).toHaveLength(1); + expect(payload.data.accessListEntries[0].username).toBe('user1'); + }); +}); + +// --------------------------------------------------------------------------- +// applySyncPayload +// --------------------------------------------------------------------------- + +describe('applySyncPayload', () => { + /** Build a minimal valid payload (all data empty, all settings null). */ + function emptyPayload() { + return { + generated_at: nowIso(), + settings: { + general: null, + cloudflare: null, + authentik: null, + metrics: null, + logging: null, + dns: null, + upstream_dns_resolution: null, + waf: null, + geoblock: null, + }, + data: { + certificates: [] as any[], + caCertificates: [] as any[], + issuedClientCertificates: [] as any[], + accessLists: [] as any[], + accessListEntries: [] as any[], + proxyHosts: [] as any[], + }, + }; + } + + it('runs without error on an empty payload', async () => { + await expect(applySyncPayload(emptyPayload() as any)).resolves.toBeUndefined(); + }); + + it('clears existing proxy hosts when payload has empty array', async () => { + await ctx.db.insert(schema.proxyHosts).values(makeProxyHost({ name: 'Old Host' })); + const before = await ctx.db.select().from(schema.proxyHosts); + expect(before).toHaveLength(1); + + await applySyncPayload(emptyPayload() as any); + + const after = await ctx.db.select().from(schema.proxyHosts); + expect(after).toHaveLength(0); + }); + + it('inserts proxy hosts from payload', async () => { + const now = nowIso(); + const payload = emptyPayload(); + payload.data.proxyHosts = [ + { + id: 1, + name: 'Synced Host', + domains: JSON.stringify(['synced.example.com']), + upstreams: JSON.stringify(['backend:8080']), + certificateId: null, + accessListId: null, + ownerUserId: null, + sslForced: false, + hstsEnabled: false, + hstsSubdomains: false, + allowWebsocket: false, + preserveHostHeader: false, + skipHttpsHostnameValidation: false, + meta: null, + enabled: true, + createdAt: now, + updatedAt: now, + }, + ]; + + await applySyncPayload(payload as any); + + const rows = await ctx.db.select().from(schema.proxyHosts); + expect(rows).toHaveLength(1); + expect(rows[0].name).toBe('Synced Host'); + expect(JSON.parse(rows[0].domains)).toEqual(['synced.example.com']); + }); + + it('replaces existing proxy hosts with payload contents (full override)', async () => { + await ctx.db.insert(schema.proxyHosts).values(makeProxyHost({ name: 'Old Host' })); + + const now = nowIso(); + const payload = emptyPayload(); + payload.data.proxyHosts = [ + { + id: 99, + name: 'New Host', + domains: JSON.stringify(['new.example.com']), + upstreams: JSON.stringify(['new-backend:9090']), + certificateId: null, + accessListId: null, + ownerUserId: null, + sslForced: false, + hstsEnabled: false, + hstsSubdomains: false, + allowWebsocket: false, + preserveHostHeader: false, + skipHttpsHostnameValidation: false, + meta: null, + enabled: true, + createdAt: now, + updatedAt: now, + }, + ]; + + await applySyncPayload(payload as any); + + const rows = await ctx.db.select().from(schema.proxyHosts); + expect(rows).toHaveLength(1); + expect(rows[0].name).toBe('New Host'); + }); + + it('is idempotent: applying the same payload twice gives the same result', async () => { + const now = nowIso(); + const payload = emptyPayload(); + payload.data.proxyHosts = [ + { + id: 1, + name: 'Idempotent Host', + domains: JSON.stringify(['idempotent.example.com']), + upstreams: JSON.stringify(['backend:8080']), + certificateId: null, + accessListId: null, + ownerUserId: null, + sslForced: false, + hstsEnabled: false, + hstsSubdomains: false, + allowWebsocket: false, + preserveHostHeader: false, + skipHttpsHostnameValidation: false, + meta: null, + enabled: true, + createdAt: now, + updatedAt: now, + }, + ]; + + await applySyncPayload(payload as any); + await applySyncPayload(payload as any); + + const rows = await ctx.db.select().from(schema.proxyHosts); + expect(rows).toHaveLength(1); + expect(rows[0].name).toBe('Idempotent Host'); + }); + + it('stores settings with synced: prefix', async () => { + const payload = emptyPayload(); + payload.settings.general = { primaryDomain: 'example.com' }; + + await applySyncPayload(payload as any); + + const row = await ctx.db.query.settings.findFirst({ + where: (t, { eq }) => eq(t.key, 'synced:general'), + }); + expect(row).toBeDefined(); + expect(JSON.parse(row!.value)).toEqual({ primaryDomain: 'example.com' }); + }); + + it('stores null settings as JSON null value', async () => { + const payload = emptyPayload(); + payload.settings.cloudflare = null; + + await applySyncPayload(payload as any); + + const row = await ctx.db.query.settings.findFirst({ + where: (t, { eq }) => eq(t.key, 'synced:cloudflare'), + }); + expect(row).toBeDefined(); + expect(JSON.parse(row!.value)).toBeNull(); + }); + + it('inserts access lists and entries from payload', async () => { + const now = nowIso(); + const payload = emptyPayload(); + payload.data.accessLists = [ + { id: 1, name: 'Synced List', description: null, createdBy: null, createdAt: now, updatedAt: now }, + ]; + payload.data.accessListEntries = [ + { id: 1, accessListId: 1, username: 'synceduser', passwordHash: '$2b$10$fakehash', createdAt: now, updatedAt: now }, + ]; + + await applySyncPayload(payload as any); + + const lists = await ctx.db.select().from(schema.accessLists); + expect(lists).toHaveLength(1); + expect(lists[0].name).toBe('Synced List'); + + const entries = await ctx.db.select().from(schema.accessListEntries); + expect(entries).toHaveLength(1); + expect(entries[0].username).toBe('synceduser'); + }); +}); diff --git a/tests/unit/caddy-mtls.test.ts b/tests/unit/caddy-mtls.test.ts new file mode 100644 index 00000000..57f05336 --- /dev/null +++ b/tests/unit/caddy-mtls.test.ts @@ -0,0 +1,470 @@ +/** + * Unit tests for src/lib/caddy-mtls.ts + * + * Covers: + * - pemToBase64Der: PEM stripping + * - buildClientAuthentication: CA trust configuration per domain set + * - groupMtlsDomainsByCaSet: isolation of CA sets per TLS policy + * + * The key bug these tests document and verify the fix for: + * If two proxy hosts (app.example.com → CA_A, api.example.com → CA_B) share an + * auto-managed TLS certificate, their mTLS domains must NOT be grouped into a + * single policy — otherwise a client cert signed by CA_B can authenticate against + * app.example.com (which only trusts CA_A) and vice-versa. + */ +import { describe, it, expect } from 'vitest'; +import { + pemToBase64Der, + buildClientAuthentication, + groupMtlsDomainsByCaSet, +} from '../../src/lib/caddy-mtls'; + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +function makeCaPem(label: string): string { + return `-----BEGIN CERTIFICATE-----\n${label}\n-----END CERTIFICATE-----`; +} + +function makeCaCertMap(...entries: [number, string][]) { + return new Map(entries.map(([id, label]) => [id, { id, certificatePem: makeCaPem(label) }])); +} + +// --------------------------------------------------------------------------- +// pemToBase64Der +// --------------------------------------------------------------------------- + +describe('pemToBase64Der', () => { + it('strips PEM header and footer', () => { + const pem = '-----BEGIN CERTIFICATE-----\nABCDEFGH\n-----END CERTIFICATE-----'; + expect(pemToBase64Der(pem)).toBe('ABCDEFGH'); + }); + + it('strips all whitespace including newlines and spaces', () => { + const pem = '-----BEGIN CERTIFICATE-----\n ABCD \n EFGH \n-----END CERTIFICATE-----'; + expect(pemToBase64Der(pem)).toBe('ABCDEFGH'); + }); + + it('handles multi-line base64 content', () => { + const pem = '-----BEGIN CERTIFICATE-----\nAAAA\nBBBB\nCCCC\n-----END CERTIFICATE-----'; + expect(pemToBase64Der(pem)).toBe('AAAABBBBCCCC'); + }); + + it('returns only the base64 body without any whitespace', () => { + const result = pemToBase64Der(makeCaPem('CA_A')); + expect(result).toBe('CA_A'); + expect(result).not.toMatch(/\s/); + expect(result).not.toContain('BEGIN'); + expect(result).not.toContain('END'); + }); +}); + +// --------------------------------------------------------------------------- +// buildClientAuthentication +// --------------------------------------------------------------------------- + +describe('buildClientAuthentication', () => { + it('returns null when no domains have mTLS config', () => { + const result = buildClientAuthentication( + ['app.example.com'], + new Map(), + new Map(), + new Map(), + new Set() + ); + expect(result).toBeNull(); + }); + + it('returns null when domain references CA IDs that do not exist in caCertMap', () => { + const mTlsDomainMap = new Map([['app.example.com', [99]]]); + const result = buildClientAuthentication( + ['app.example.com'], + mTlsDomainMap, + new Map(), // CA 99 not in map + new Map(), + new Set() + ); + expect(result).toBeNull(); + }); + + it('returns mode=require_and_verify and trusted_ca_certs for unmanaged CA', () => { + const mTlsDomainMap = new Map([['app.example.com', [1]]]); + const caCertMap = makeCaCertMap([1, 'CA_A']); + + const result = buildClientAuthentication( + ['app.example.com'], + mTlsDomainMap, + caCertMap, + new Map(), + new Set() // CA 1 is not managed + ); + + expect(result).not.toBeNull(); + expect(result!.mode).toBe('require_and_verify'); + expect(result!.trusted_ca_certs).toEqual(['CA_A']); + expect(result!.trusted_leaf_certs).toBeUndefined(); + }); + + it('excludes managed CA when all its issued certs are revoked (no active certs)', () => { + const mTlsDomainMap = new Map([['app.example.com', [1]]]); + const caCertMap = makeCaCertMap([1, 'CA_A']); + const issuedClientCertMap = new Map([[1, []]]); // CA 1 managed, zero active certs + const cAsWithAnyIssuedCerts = new Set([1]); + + const result = buildClientAuthentication( + ['app.example.com'], + mTlsDomainMap, + caCertMap, + issuedClientCertMap, + cAsWithAnyIssuedCerts + ); + + expect(result).toBeNull(); // No CA trusted → null + }); + + it('includes CA cert and active leaf certs for managed CA with active certs', () => { + const mTlsDomainMap = new Map([['app.example.com', [1]]]); + const caCertMap = makeCaCertMap([1, 'CA_A']); + const leafPem = makeCaPem('LEAF_1'); + const issuedClientCertMap = new Map([[1, [leafPem]]]); + const cAsWithAnyIssuedCerts = new Set([1]); + + const result = buildClientAuthentication( + ['app.example.com'], + mTlsDomainMap, + caCertMap, + issuedClientCertMap, + cAsWithAnyIssuedCerts + ); + + expect(result).not.toBeNull(); + expect(result!.trusted_ca_certs).toEqual(['CA_A']); + expect(result!.trusted_leaf_certs).toEqual(['LEAF_1']); + }); + + it('includes multiple active leaf certs for managed CA', () => { + const mTlsDomainMap = new Map([['app.example.com', [1]]]); + const caCertMap = makeCaCertMap([1, 'CA_A']); + const leafPems = [makeCaPem('LEAF_1'), makeCaPem('LEAF_2'), makeCaPem('LEAF_3')]; + const issuedClientCertMap = new Map([[1, leafPems]]); + const cAsWithAnyIssuedCerts = new Set([1]); + + const result = buildClientAuthentication( + ['app.example.com'], + mTlsDomainMap, + caCertMap, + issuedClientCertMap, + cAsWithAnyIssuedCerts + ); + + expect(result!.trusted_leaf_certs).toEqual(['LEAF_1', 'LEAF_2', 'LEAF_3']); + }); + + it('mixes unmanaged CA (no leaves) and managed CA (with active leaves) correctly', () => { + const mTlsDomainMap = new Map([['app.example.com', [1, 2]]]); + const caCertMap = makeCaCertMap([1, 'CA_A'], [2, 'CA_B']); + const leafPem = makeCaPem('LEAF_B1'); + // CA 1 is unmanaged; CA 2 is managed with one active cert + const issuedClientCertMap = new Map([[2, [leafPem]]]); + const cAsWithAnyIssuedCerts = new Set([2]); + + const result = buildClientAuthentication( + ['app.example.com'], + mTlsDomainMap, + caCertMap, + issuedClientCertMap, + cAsWithAnyIssuedCerts + ); + + expect(result!.trusted_ca_certs).toContain('CA_A'); + expect(result!.trusted_ca_certs).toContain('CA_B'); + expect(result!.trusted_leaf_certs).toEqual(['LEAF_B1']); + }); + + it('returns null when the only configured CA is managed with all certs revoked', () => { + const mTlsDomainMap = new Map([['app.example.com', [1]]]); + const caCertMap = makeCaCertMap([1, 'CA_A']); + const issuedClientCertMap = new Map([[1, []]]); + const cAsWithAnyIssuedCerts = new Set([1]); + + const result = buildClientAuthentication( + ['app.example.com'], + mTlsDomainMap, + caCertMap, + issuedClientCertMap, + cAsWithAnyIssuedCerts + ); + expect(result).toBeNull(); + }); + + it('domain lookup is case-insensitive', () => { + const mTlsDomainMap = new Map([['app.example.com', [1]]]); // lowercase key + const caCertMap = makeCaCertMap([1, 'CA_A']); + + const result = buildClientAuthentication( + ['APP.EXAMPLE.COM'], // uppercase input + mTlsDomainMap, + caCertMap, + new Map(), + new Set() + ); + + expect(result).not.toBeNull(); + expect(result!.trusted_ca_certs).toEqual(['CA_A']); + }); +}); + +// --------------------------------------------------------------------------- +// groupMtlsDomainsByCaSet +// --------------------------------------------------------------------------- + +describe('groupMtlsDomainsByCaSet', () => { + it('returns empty map for empty input', () => { + expect(groupMtlsDomainsByCaSet([], new Map()).size).toBe(0); + }); + + it('puts a single domain in its own group', () => { + const mTlsDomainMap = new Map([['app.example.com', [1]]]); + const groups = groupMtlsDomainsByCaSet(['app.example.com'], mTlsDomainMap); + expect(groups.size).toBe(1); + const [group] = groups.values(); + expect(group).toEqual(['app.example.com']); + }); + + it('groups two domains with the same single CA into one group', () => { + const mTlsDomainMap = new Map([ + ['app.example.com', [1]], + ['app2.example.com', [1]], + ]); + const groups = groupMtlsDomainsByCaSet( + ['app.example.com', 'app2.example.com'], + mTlsDomainMap + ); + expect(groups.size).toBe(1); + const [group] = groups.values(); + expect(group).toHaveLength(2); + expect(group).toContain('app.example.com'); + expect(group).toContain('app2.example.com'); + }); + + it('separates domains with different CA sets — the cross-CA isolation test', () => { + // This is the core bug scenario: two hosts with different CAs must each get + // their own TLS policy so CA_B certs cannot authenticate against the CA_A host. + const mTlsDomainMap = new Map([ + ['app.example.com', [1]], // trusts CA_A only + ['api.example.com', [2]], // trusts CA_B only + ]); + const groups = groupMtlsDomainsByCaSet( + ['app.example.com', 'api.example.com'], + mTlsDomainMap + ); + expect(groups.size).toBe(2); + + const groupValues = Array.from(groups.values()); + const appGroup = groupValues.find(g => g.includes('app.example.com')); + const apiGroup = groupValues.find(g => g.includes('api.example.com')); + + expect(appGroup).toEqual(['app.example.com']); + expect(apiGroup).toEqual(['api.example.com']); + }); + + it('groups domains with the same multi-CA set together regardless of CA order', () => { + const mTlsDomainMap = new Map([ + ['app.example.com', [1, 2]], + ['app2.example.com', [2, 1]], // same CAs, different order + ]); + const groups = groupMtlsDomainsByCaSet( + ['app.example.com', 'app2.example.com'], + mTlsDomainMap + ); + expect(groups.size).toBe(1); + const [group] = groups.values(); + expect(group).toHaveLength(2); + }); + + it('separates domains with subset vs superset CAs', () => { + const mTlsDomainMap = new Map([ + ['app.example.com', [1]], // trusts CA_A only + ['api.example.com', [1, 2]], // trusts CA_A + CA_B + ]); + const groups = groupMtlsDomainsByCaSet( + ['app.example.com', 'api.example.com'], + mTlsDomainMap + ); + expect(groups.size).toBe(2); + }); + + it('creates three groups for three different CA sets', () => { + const mTlsDomainMap = new Map([ + ['a.example.com', [1]], + ['b.example.com', [2]], + ['c.example.com', [3]], + ]); + const groups = groupMtlsDomainsByCaSet( + ['a.example.com', 'b.example.com', 'c.example.com'], + mTlsDomainMap + ); + expect(groups.size).toBe(3); + }); + + it('correctly handles a mix: two shared, one unique', () => { + const mTlsDomainMap = new Map([ + ['shared1.example.com', [1]], + ['shared2.example.com', [1]], + ['unique.example.com', [2]], + ]); + const groups = groupMtlsDomainsByCaSet( + ['shared1.example.com', 'shared2.example.com', 'unique.example.com'], + mTlsDomainMap + ); + expect(groups.size).toBe(2); + + const groupValues = Array.from(groups.values()); + const sharedGroup = groupValues.find(g => g.length === 2); + const uniqueGroup = groupValues.find(g => g.length === 1); + + expect(sharedGroup).toContain('shared1.example.com'); + expect(sharedGroup).toContain('shared2.example.com'); + expect(uniqueGroup).toContain('unique.example.com'); + }); + + it('domain with empty CA list gets its own group (key="")', () => { + // Edge case: domain in mTlsDomainMap but with an empty CA ID list + const mTlsDomainMap = new Map([ + ['app.example.com', [1]], + ['noca.example.com', []], + ]); + const groups = groupMtlsDomainsByCaSet( + ['app.example.com', 'noca.example.com'], + mTlsDomainMap + ); + // Two distinct groups: key="1" and key="" + expect(groups.size).toBe(2); + }); +}); + +// --------------------------------------------------------------------------- +// Cross-CA isolation integration: groupMtlsDomainsByCaSet + buildClientAuthentication +// --------------------------------------------------------------------------- + +describe('mTLS per-host CA isolation (regression test for cross-CA bug)', () => { + const caCertMap = makeCaCertMap([1, 'CA_A'], [2, 'CA_B']); + + it('before the fix (union): calling buildClientAuthentication with both domains together gives both CAs', () => { + // This documents the OLD behavior — the caller should NOT do this. + const mTlsDomainMap = new Map([ + ['app.example.com', [1]], + ['api.example.com', [2]], + ]); + const result = buildClientAuthentication( + ['app.example.com', 'api.example.com'], // both domains in one call — wrong + mTlsDomainMap, + caCertMap, + new Map(), + new Set() + ); + // Both CAs end up trusted — this is the unsafe behavior + expect(result!.trusted_ca_certs).toContain('CA_A'); + expect(result!.trusted_ca_certs).toContain('CA_B'); + }); + + it('after the fix (grouping): each domain gets a policy with only its own CA', () => { + const mTlsDomainMap = new Map([ + ['app.example.com', [1]], // trusts CA_A only + ['api.example.com', [2]], // trusts CA_B only + ]); + const allMtlsDomains = ['app.example.com', 'api.example.com']; + const groups = groupMtlsDomainsByCaSet(allMtlsDomains, mTlsDomainMap); + + const policies: { sni: string[]; trusted_ca_certs: unknown[] }[] = []; + for (const domainGroup of groups.values()) { + const auth = buildClientAuthentication( + domainGroup, + mTlsDomainMap, + caCertMap, + new Map(), + new Set() + ); + if (auth) { + policies.push({ + sni: domainGroup, + trusted_ca_certs: auth.trusted_ca_certs as unknown[], + }); + } + } + + expect(policies).toHaveLength(2); + + const appPolicy = policies.find(p => p.sni.includes('app.example.com'))!; + const apiPolicy = policies.find(p => p.sni.includes('api.example.com'))!; + + // app.example.com policy must ONLY trust CA_A + expect(appPolicy.trusted_ca_certs).toContain('CA_A'); + expect(appPolicy.trusted_ca_certs).not.toContain('CA_B'); + + // api.example.com policy must ONLY trust CA_B + expect(apiPolicy.trusted_ca_certs).toContain('CA_B'); + expect(apiPolicy.trusted_ca_certs).not.toContain('CA_A'); + }); + + it('three hosts each with different CAs get three separate policies', () => { + const caCertMapExtended = makeCaCertMap([1, 'CA_A'], [2, 'CA_B'], [3, 'CA_C']); + const mTlsDomainMap = new Map([ + ['a.example.com', [1]], + ['b.example.com', [2]], + ['c.example.com', [3]], + ]); + const allMtlsDomains = ['a.example.com', 'b.example.com', 'c.example.com']; + const groups = groupMtlsDomainsByCaSet(allMtlsDomains, mTlsDomainMap); + + expect(groups.size).toBe(3); + + const policies: { sni: string[]; trusted_ca_certs: unknown[] }[] = []; + for (const domainGroup of groups.values()) { + const auth = buildClientAuthentication( + domainGroup, + mTlsDomainMap, + caCertMapExtended, + new Map(), + new Set() + ); + if (auth) { + policies.push({ sni: domainGroup, trusted_ca_certs: auth.trusted_ca_certs as unknown[] }); + } + } + + expect(policies).toHaveLength(3); + + const aPolicy = policies.find(p => p.sni.includes('a.example.com'))!; + expect(aPolicy.trusted_ca_certs).toEqual(['CA_A']); + expect(aPolicy.trusted_ca_certs).not.toContain('CA_B'); + expect(aPolicy.trusted_ca_certs).not.toContain('CA_C'); + }); + + it('two hosts sharing the same CA are correctly grouped into one policy', () => { + const mTlsDomainMap = new Map([ + ['app.example.com', [1]], + ['app2.example.com', [1]], // same CA + ]); + const allMtlsDomains = ['app.example.com', 'app2.example.com']; + const groups = groupMtlsDomainsByCaSet(allMtlsDomains, mTlsDomainMap); + + expect(groups.size).toBe(1); + + const [domainGroup] = groups.values(); + const auth = buildClientAuthentication( + domainGroup, + mTlsDomainMap, + caCertMap, + new Map(), + new Set() + ); + + expect(auth).not.toBeNull(); + expect(auth!.trusted_ca_certs).toEqual(['CA_A']); + // Both domains in the same policy + expect(domainGroup).toContain('app.example.com'); + expect(domainGroup).toContain('app2.example.com'); + }); +}); diff --git a/tests/unit/instance-sync-env.test.ts b/tests/unit/instance-sync-env.test.ts new file mode 100644 index 00000000..60fc62e1 --- /dev/null +++ b/tests/unit/instance-sync-env.test.ts @@ -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); + }); +});