Files
caddy-proxy-manager/tests/integration/instance-sync.test.ts
fuomag9 fd847e7eb5 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>
2026-03-07 18:32:52 +01:00

387 lines
13 KiB
TypeScript

/**
* 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');
});
});