Files
caddy-proxy-manager/tests/unit/l4-port-manager-entrypoint.test.ts
akanealw 99819b70ff
Some checks failed
Build and Push Docker Images (Trusted) / build-and-push (., docker/caddy/Dockerfile, caddy) (push) Has been cancelled
Build and Push Docker Images (Trusted) / build-and-push (., docker/l4-port-manager/Dockerfile, l4-port-manager) (push) Has been cancelled
Build and Push Docker Images (Trusted) / build-and-push (., docker/web/Dockerfile, web) (push) Has been cancelled
Tests / test (push) Has been cancelled
added caddy-proxy-manager for testing
2026-04-21 22:49:08 +00:00

178 lines
7.7 KiB
TypeScript
Executable File

/**
* Unit tests for the L4 port manager sidecar entrypoint script.
*
* Tests critical invariants of the shell script:
* - Always applies the override on startup (not just on trigger change)
* - Only recreates the caddy service (never other services)
* - Uses --no-deps to prevent dependency cascades
* - Auto-detects compose project name from caddy container labels
* - Pre-loads LAST_TRIGGER to avoid double-applying on startup
* - Writes status files in valid JSON
* - Never includes test override files in production
* - Supports both named-volume and bind-mount deployments (COMPOSE_HOST_DIR)
*/
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
const SCRIPT_PATH = resolve(__dirname, '../../docker/l4-port-manager/entrypoint.sh');
const script = readFileSync(SCRIPT_PATH, 'utf-8');
const lines = script.split('\n');
describe('L4 port manager entrypoint.sh', () => {
it('applies override on startup (not only on trigger change)', () => {
// The script must call do_apply before entering the while loop.
// This ensures L4 ports are bound after any restart, because the main
// compose stack starts caddy without the L4 ports override file.
const firstApply = lines.findIndex(l => l.trim().startsWith('do_apply') || l.includes('do_apply'));
const whileLoop = lines.findIndex(l => l.includes('while true'));
expect(firstApply).toBeGreaterThan(-1);
expect(whileLoop).toBeGreaterThan(-1);
expect(firstApply).toBeLessThan(whileLoop);
});
it('pre-loads LAST_TRIGGER after startup apply to avoid double-apply', () => {
// After the startup apply, LAST_TRIGGER must be set from the current trigger
// file content so the poll loop doesn't re-apply the same trigger again.
const lastTriggerInit = lines.findIndex(l => l.includes('LAST_TRIGGER=') && l.includes('TRIGGER_FILE'));
const whileLoop = lines.findIndex(l => l.includes('while true'));
expect(lastTriggerInit).toBeGreaterThan(-1);
expect(lastTriggerInit).toBeLessThan(whileLoop);
});
it('only recreates the caddy service', () => {
// The docker compose command should target only "caddy" — never "web" or other services
const composeUpLines = lines.filter(line =>
line.includes('docker compose') && line.includes('up')
);
expect(composeUpLines.length).toBeGreaterThan(0);
for (const line of composeUpLines) {
expect(line).toContain('caddy');
expect(line).not.toMatch(/\bweb\b/);
}
});
it('uses --no-deps flag to prevent dependency cascades', () => {
const composeUpLines = lines.filter(line =>
line.includes('docker compose') && line.includes('up')
);
for (const line of composeUpLines) {
expect(line).toContain('--no-deps');
}
});
it('uses --force-recreate to ensure port changes take effect', () => {
const composeUpLines = lines.filter(line =>
line.includes('docker compose') && line.includes('up')
);
for (const line of composeUpLines) {
expect(line).toContain('--force-recreate');
}
});
it('specifies project name to target the correct compose stack', () => {
// Without -p, compose would infer the project from the mount directory name
// ("/compose") rather than the actual running stack name, causing it to
// create new containers instead of recreating the existing ones.
expect(script).toMatch(/COMPOSE_ARGS=.*-p \$COMPOSE_PROJECT/);
});
it('auto-detects project name from caddy container labels', () => {
expect(script).toContain('com.docker.compose.project');
expect(script).toContain('docker inspect');
expect(script).toContain('detect_project_name');
});
it('compares trigger content to avoid redundant restarts', () => {
expect(script).toContain('LAST_TRIGGER');
expect(script).toContain('CURRENT_TRIGGER');
expect(script).toContain('"$CURRENT_TRIGGER" = "$LAST_TRIGGER"');
});
it('uses --pull never to avoid registry pulls (only recreates)', () => {
const composeUpLines = lines.filter(line =>
line.includes('docker compose') && line.includes('up')
);
for (const line of composeUpLines) {
expect(line).toContain('--pull never');
expect(line).not.toContain('--build');
}
});
it('waits for caddy health check after recreation', () => {
expect(script).toContain('Health');
expect(script).toContain('healthy');
expect(script).toContain('HEALTH_TIMEOUT');
});
it('writes status for both success and failure cases', () => {
const statusWrites = lines.filter(l => l.trim().startsWith('write_status'));
// At least: startup idle/applying, applying, applied/success, failed
expect(statusWrites.length).toBeGreaterThanOrEqual(4);
});
it('does not include test override files in production', () => {
// Including docker-compose.test.yml would override web env vars (triggering
// web restart) and switch to test volume names.
expect(script).not.toContain('docker-compose.test.yml');
});
it('does not restart the web service or itself', () => {
const dangerousPatterns = [
/up.*\bweb\b/,
/restart.*\bweb\b/,
/up.*\bl4-port-manager\b/,
/restart.*\bl4-port-manager\b/,
];
for (const pattern of dangerousPatterns) {
expect(script).not.toMatch(pattern);
}
});
// ---------------------------------------------------------------------------
// Deployment scenario: COMPOSE_HOST_DIR (bind-mount / cloud override)
// ---------------------------------------------------------------------------
it('uses --project-directory $COMPOSE_HOST_DIR when COMPOSE_HOST_DIR is set', () => {
// Bind-mount deployments (docker-compose.override.yml replaces named volumes
// with ./data bind mounts). Relative paths like ./geoip-data in the override
// file must resolve against the HOST project directory, not the sidecar's
// /compose mount. --project-directory tells the Docker daemon where to look.
expect(script).toContain('--project-directory $COMPOSE_HOST_DIR');
// It must be conditional — only applied when COMPOSE_HOST_DIR is non-empty
expect(script).toMatch(/if \[ -n "\$COMPOSE_HOST_DIR" \]/);
});
it('does NOT unconditionally add --project-directory (named-volume deployments work without it)', () => {
// Standard deployments (no override file) use named volumes — no host path
// is needed. --project-directory must NOT be hardcoded outside the conditional.
const unconditional = lines.filter(l =>
l.includes('--project-directory') && !l.includes('COMPOSE_HOST_DIR') && !l.trim().startsWith('#')
);
expect(unconditional).toHaveLength(0);
});
it('uses --env-file from $COMPOSE_DIR (container-accessible path), not $COMPOSE_HOST_DIR', () => {
// When --project-directory points to the host path, Docker Compose looks for
// .env at $COMPOSE_HOST_DIR/.env which is NOT mounted inside the container.
// We must explicitly pass --env-file $COMPOSE_DIR/.env (the container mount).
expect(script).toContain('--env-file $COMPOSE_DIR/.env');
// Must NOT reference the host dir for the env file
expect(script).not.toContain('--env-file $COMPOSE_HOST_DIR');
});
it('always reads compose files from $COMPOSE_DIR regardless of COMPOSE_HOST_DIR', () => {
// The sidecar mounts the project at /compose (COMPOSE_DIR). Whether or not
// COMPOSE_HOST_DIR is set, all -f flags must reference container-accessible
// paths under $COMPOSE_DIR, never the host path.
const composeFileFlags = lines.filter(l =>
l.includes('-f ') && l.includes('docker-compose')
);
expect(composeFileFlags.length).toBeGreaterThan(0);
for (const line of composeFileFlags) {
expect(line).toContain('$COMPOSE_DIR');
expect(line).not.toContain('$COMPOSE_HOST_DIR');
}
});
});