feat: instant banner refresh on L4 mutations + master-slave L4 sync

Banner (L4PortsApplyBanner):
- Accept refreshSignal prop; re-fetch /api/l4-ports when it changes
- Signal fires immediately after create/edit/delete/toggle in L4ProxyHostsClient
  without waiting for a page reload

Master-slave replication (instance-sync):
- Add l4ProxyHosts to SyncPayload.data (optional for backward compat
  with older master instances that don't include it)
- buildSyncPayload: query and include l4ProxyHosts, sanitize ownerUserId
- applySyncPayload: clear and re-insert l4ProxyHosts in transaction;
  call applyL4Ports() if port diff requires it so the slave's sidecar
  recreates caddy with the correct ports
- Sync route: add isL4ProxyHost validator; backfill missing field from
  old masters; validate array when present

Tests (25 new tests):
- instance-sync.test.ts: buildSyncPayload includes L4 data, sanitizes ownerUserId;
  applySyncPayload replaces L4 hosts, handles missing field, writes trigger
  when ports differ, skips trigger when ports already match
- l4-ports-apply-banner.test.ts: banner refreshSignal contract + client
  increments counter on all mutation paths

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
fuomag9
2026-03-22 00:22:44 +01:00
parent 3a4a4d51cf
commit 00c9bff8b4
6 changed files with 378 additions and 11 deletions

View File

@@ -7,6 +7,8 @@
* any real db file.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { rmSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import type { TestDb } from '../helpers/db';
// ---------------------------------------------------------------------------
@@ -15,7 +17,15 @@ import type { TestDb } from '../helpers/db';
// factory (which also runs during hoisting) can populate it safely.
// ---------------------------------------------------------------------------
const ctx = vi.hoisted(() => ({ db: null as unknown as TestDb }));
const ctx = vi.hoisted(() => {
const { mkdirSync } = require('node:fs');
const { join } = require('node:path');
const { tmpdir } = require('node:os');
const dir = join(tmpdir(), `instance-sync-test-${Date.now()}`);
mkdirSync(dir, { recursive: true });
process.env.L4_PORTS_DIR = dir;
return { db: null as unknown as TestDb, tmpDir: dir };
});
vi.mock('../../src/lib/db', async () => {
const { createTestDb } = await import('../helpers/db');
@@ -70,6 +80,7 @@ function makeProxyHost(overrides: Partial<typeof schema.proxyHosts.$inferInsert>
/** Clean all relevant tables between tests to avoid cross-test contamination. */
async function clearTables() {
await ctx.db.delete(schema.l4ProxyHosts);
await ctx.db.delete(schema.proxyHosts);
await ctx.db.delete(schema.accessListEntries);
await ctx.db.delete(schema.accessLists);
@@ -79,8 +90,37 @@ async function clearTables() {
await ctx.db.delete(schema.settings);
}
function cleanTmpDir() {
for (const file of ['docker-compose.l4-ports.yml', 'l4-ports.trigger', 'l4-ports.status']) {
const path = join(ctx.tmpDir, file);
if (existsSync(path)) rmSync(path);
}
}
function makeL4Host(overrides: Partial<typeof schema.l4ProxyHosts.$inferInsert> = {}) {
const now = nowIso();
return {
name: 'Test L4 Host',
protocol: 'tcp',
listenAddress: ':5432',
upstreams: JSON.stringify(['10.0.0.1:5432']),
matcherType: 'none',
matcherValue: null,
tlsTermination: false,
proxyProtocolVersion: null,
proxyProtocolReceive: false,
ownerUserId: null,
meta: null,
enabled: true,
createdAt: now,
updatedAt: now,
...overrides,
} satisfies typeof schema.l4ProxyHosts.$inferInsert;
}
beforeEach(async () => {
await clearTables();
cleanTmpDir();
});
// ---------------------------------------------------------------------------
@@ -96,6 +136,29 @@ describe('buildSyncPayload', () => {
expect(payload.data.issuedClientCertificates).toEqual([]);
expect(payload.data.accessLists).toEqual([]);
expect(payload.data.accessListEntries).toEqual([]);
expect(payload.data.l4ProxyHosts).toEqual([]);
});
it('includes L4 proxy hosts in payload', async () => {
await ctx.db.insert(schema.l4ProxyHosts).values(makeL4Host({ listenAddress: ':5432' }));
const payload = await buildSyncPayload();
expect(payload.data.l4ProxyHosts).toHaveLength(1);
expect(payload.data.l4ProxyHosts![0].listenAddress).toBe(':5432');
});
it('sanitizes L4 proxy host ownerUserId to null', async () => {
await ctx.db.insert(schema.l4ProxyHosts).values(makeL4Host({ ownerUserId: null }));
const payload = await buildSyncPayload();
expect(payload.data.l4ProxyHosts![0].ownerUserId).toBeNull();
});
it('includes multiple L4 proxy hosts', async () => {
await ctx.db.insert(schema.l4ProxyHosts).values(makeL4Host({ name: 'PG', listenAddress: ':5432' }));
await ctx.db.insert(schema.l4ProxyHosts).values(makeL4Host({ name: 'MySQL', listenAddress: ':3306' }));
const payload = await buildSyncPayload();
expect(payload.data.l4ProxyHosts).toHaveLength(2);
const addresses = payload.data.l4ProxyHosts!.map(h => h.listenAddress).sort();
expect(addresses).toEqual([':3306', ':5432']);
});
it('returns null settings when no settings are stored', async () => {
@@ -383,4 +446,163 @@ describe('applySyncPayload', () => {
expect(entries).toHaveLength(1);
expect(entries[0].username).toBe('synceduser');
});
// ---------------------------------------------------------------------------
// L4 proxy host replication
// ---------------------------------------------------------------------------
it('clears existing L4 proxy hosts when payload has empty array', async () => {
await ctx.db.insert(schema.l4ProxyHosts).values(makeL4Host({ name: 'Old L4 Host' }));
const before = await ctx.db.select().from(schema.l4ProxyHosts);
expect(before).toHaveLength(1);
await applySyncPayload(emptyPayload());
const after = await ctx.db.select().from(schema.l4ProxyHosts);
expect(after).toHaveLength(0);
});
it('inserts L4 proxy hosts from payload', async () => {
const now = nowIso();
const payload = emptyPayload();
payload.data.l4ProxyHosts = [
{
id: 1,
name: 'Synced PG',
protocol: 'tcp',
listenAddress: ':5432',
upstreams: JSON.stringify(['db:5432']),
matcherType: 'none',
matcherValue: null,
tlsTermination: false,
proxyProtocolVersion: null,
proxyProtocolReceive: false,
ownerUserId: null,
meta: null,
enabled: true,
createdAt: now,
updatedAt: now,
},
];
await applySyncPayload(payload);
const rows = await ctx.db.select().from(schema.l4ProxyHosts);
expect(rows).toHaveLength(1);
expect(rows[0].name).toBe('Synced PG');
expect(rows[0].listenAddress).toBe(':5432');
});
it('replaces existing L4 proxy hosts with payload contents', async () => {
await ctx.db.insert(schema.l4ProxyHosts).values(makeL4Host({ name: 'Old L4', listenAddress: ':9999' }));
const now = nowIso();
const payload = emptyPayload();
payload.data.l4ProxyHosts = [
{
id: 99,
name: 'New L4',
protocol: 'tcp',
listenAddress: ':5432',
upstreams: JSON.stringify(['db:5432']),
matcherType: 'none',
matcherValue: null,
tlsTermination: false,
proxyProtocolVersion: null,
proxyProtocolReceive: false,
ownerUserId: null,
meta: null,
enabled: true,
createdAt: now,
updatedAt: now,
},
];
await applySyncPayload(payload);
const rows = await ctx.db.select().from(schema.l4ProxyHosts);
expect(rows).toHaveLength(1);
expect(rows[0].name).toBe('New L4');
expect(rows[0].listenAddress).toBe(':5432');
});
it('works with payload missing l4ProxyHosts (backward compat with old master)', async () => {
// Old master instances don't include l4ProxyHosts in their payload.
// The slave should still sync successfully and not crash.
await ctx.db.insert(schema.l4ProxyHosts).values(makeL4Host({ name: 'Existing L4' }));
const payload = emptyPayload();
// Explicitly remove l4ProxyHosts to simulate old master payload
delete (payload.data as Record<string, unknown>).l4ProxyHosts;
await expect(applySyncPayload(payload)).resolves.toBeUndefined();
// Existing L4 hosts are cleared (the DELETE always runs)
const rows = await ctx.db.select().from(schema.l4ProxyHosts);
expect(rows).toHaveLength(0);
});
it('writes trigger file when L4 port diff requires apply after sync', async () => {
const now = nowIso();
const payload = emptyPayload();
payload.data.l4ProxyHosts = [
{
id: 1,
name: 'PG Sync',
protocol: 'tcp',
listenAddress: ':5432',
upstreams: JSON.stringify(['db:5432']),
matcherType: 'none',
matcherValue: null,
tlsTermination: false,
proxyProtocolVersion: null,
proxyProtocolReceive: false,
ownerUserId: null,
meta: null,
enabled: true,
createdAt: now,
updatedAt: now,
},
];
// No override file exists yet → diff will show needsApply=true
await applySyncPayload(payload);
const triggerPath = join(ctx.tmpDir, 'l4-ports.trigger');
expect(existsSync(triggerPath)).toBe(true);
});
it('does not write trigger file when L4 ports already match after sync', async () => {
const { writeFileSync } = await import('node:fs');
// Pre-write override file matching the incoming payload port
writeFileSync(join(ctx.tmpDir, 'docker-compose.l4-ports.yml'), `services:\n caddy:\n ports:\n - "5432:5432"\n`);
const now = nowIso();
const payload = emptyPayload();
payload.data.l4ProxyHosts = [
{
id: 1,
name: 'PG Sync',
protocol: 'tcp',
listenAddress: ':5432',
upstreams: JSON.stringify(['db:5432']),
matcherType: 'none',
matcherValue: null,
tlsTermination: false,
proxyProtocolVersion: null,
proxyProtocolReceive: false,
ownerUserId: null,
meta: null,
enabled: true,
createdAt: now,
updatedAt: now,
},
];
await applySyncPayload(payload);
// Ports already match → no trigger needed
const triggerPath = join(ctx.tmpDir, 'l4-ports.trigger');
expect(existsSync(triggerPath)).toBe(false);
});
});

View File

@@ -0,0 +1,73 @@
/**
* Unit tests for the L4PortsApplyBanner refresh-signal contract.
*
* Verifies that the banner component re-fetches port status whenever
* refreshSignal changes — so changes are reflected immediately after
* create/edit/delete/toggle without a page reload.
*
* These tests inspect the component source rather than rendering it,
* to avoid the cost of a jsdom environment.
*/
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
const BANNER_PATH = resolve(__dirname, '../../src/components/l4-proxy-hosts/L4PortsApplyBanner.tsx');
const banner = readFileSync(BANNER_PATH, 'utf-8');
const CLIENT_PATH = resolve(__dirname, '../../app/(dashboard)/l4-proxy-hosts/L4ProxyHostsClient.tsx');
const client = readFileSync(CLIENT_PATH, 'utf-8');
describe('L4PortsApplyBanner', () => {
it('accepts a refreshSignal prop', () => {
expect(banner).toContain('refreshSignal');
});
it('re-fetches when refreshSignal changes via useEffect', () => {
// Must have a useEffect that depends on refreshSignal
expect(banner).toMatch(/useEffect\s*\(\s*\(\s*\)\s*=>/);
expect(banner).toContain('refreshSignal');
// The effect must call fetchStatus
expect(banner).toContain('fetchStatus');
});
it('skips fetch when refreshSignal is falsy (avoids double-fetch on mount)', () => {
// The effect should guard against firing on initial 0/undefined value
// so the mount effect and the signal effect don't both fire on load.
expect(banner).toMatch(/if\s*\(!\s*refreshSignal\s*\)/);
});
});
describe('L4ProxyHostsClient banner integration', () => {
it('tracks bannerRefresh state', () => {
expect(client).toContain('bannerRefresh');
expect(client).toContain('setBannerRefresh');
});
it('passes refreshSignal to L4PortsApplyBanner', () => {
expect(client).toContain('refreshSignal={bannerRefresh}');
});
it('increments bannerRefresh after toggle', () => {
// The toggle handler must signal the banner after the action completes
expect(client).toMatch(/toggleL4ProxyHostAction[\s\S]{0,200}signalBannerRefresh/);
});
it('increments bannerRefresh when create dialog closes', () => {
expect(client).toMatch(/CreateL4HostDialog[\s\S]{0,400}signalBannerRefresh/);
});
it('increments bannerRefresh when edit dialog closes', () => {
expect(client).toMatch(/EditL4HostDialog[\s\S]{0,400}signalBannerRefresh/);
});
it('increments bannerRefresh when delete dialog closes', () => {
expect(client).toMatch(/DeleteL4HostDialog[\s\S]{0,400}signalBannerRefresh/);
});
it('defines signalBannerRefresh as a function that increments the counter', () => {
// Must use functional update form to avoid stale closure
expect(client).toMatch(/signalBannerRefresh\s*=\s*\(\s*\)\s*=>\s*setBannerRefresh\s*\(/);
expect(client).toContain('n + 1');
});
});