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:
@@ -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<string, number[]>,
|
||||
caCertMap: Map<number, { id: number; certificatePem: string }>,
|
||||
issuedClientCertMap: Map<number, string[]>,
|
||||
cAsWithAnyIssuedCerts: Set<number>
|
||||
): Record<string, unknown> | null {
|
||||
const caCertIds = new Set<number>();
|
||||
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<string, unknown> = {
|
||||
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<string, number[]>
|
||||
): Map<string, string[]> {
|
||||
const groups = new Map<string, string[]>();
|
||||
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;
|
||||
}
|
||||
+23
-86
@@ -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<number, CertificateRow>) {
|
||||
const usage = new Map<number, CertificateUsage>();
|
||||
const autoManagedDomains = new Set<string>();
|
||||
@@ -1104,65 +1097,6 @@ async function buildProxyRoutes(
|
||||
return routes;
|
||||
}
|
||||
|
||||
function buildClientAuthentication(
|
||||
domains: string[],
|
||||
mTlsDomainMap: Map<string, number[]>,
|
||||
caCertMap: Map<number, { id: number; certificatePem: string }>,
|
||||
issuedClientCertMap: Map<number, string[]>,
|
||||
cAsWithAnyIssuedCerts: Set<number>
|
||||
): Record<string, unknown> | null {
|
||||
// Collect all CA cert IDs for any domain in this policy that has mTLS.
|
||||
const caCertIds = new Set<number>();
|
||||
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<string, unknown> = { mode: "require_and_verify", trusted_ca_certs: trustedCaCerts };
|
||||
if (trustedLeafCerts.length > 0) result.trusted_leaf_certs = trustedLeafCerts;
|
||||
return result;
|
||||
}
|
||||
|
||||
function buildTlsConnectionPolicies(
|
||||
usage: Map<number, CertificateUsage>,
|
||||
managedCertificatesWithAutomation: Set<number>,
|
||||
@@ -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 } });
|
||||
|
||||
+1
-1
@@ -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');
|
||||
|
||||
|
||||
@@ -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<typeof schema.proxyHosts.$inferInsert> = {}) {
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user