diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c7a24dd..4d55191 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,11 +7,13 @@ on: - 'main' tags: - 'v*' - pull_request: + pull_request_target: + types: [opened, synchronize, reopened] permissions: contents: read packages: write + # pull_request_target has secrets access by default; restrict everything concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -24,7 +26,11 @@ env: jobs: test: name: Test & Lint + if: github.event_name == 'push' || github.event.pull_request.author_association == 'OWNER' || github.event.pull_request.author_association == 'MEMBER' || github.event.pull_request.author_association == 'COLLABORATOR' runs-on: ubuntu-latest + permissions: + contents: read + # No secrets or write access for PRs steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0 @@ -33,6 +39,9 @@ jobs: - name: Checkout repository uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + # Prevent PRs from using credentials - mitigates RCE token exfiltration + persist-credentials: false - name: Setup Node.js uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 @@ -48,12 +57,17 @@ jobs: - name: Lint run: npm run lint + # TODO: duplicate build step; consider removing from here and only keeping in build-and-push + # once we figure a better way to prevent build errors from reaching build-and-push job. + - name: Build + run: npm run build + - name: Test run: npm run test build-and-push: name: Build & Push Docker Image - if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/') + if: (github.event_name == 'push' || github.event.pull_request.author_association == 'OWNER' || github.event.pull_request.author_association == 'MEMBER' || github.event.pull_request.author_association == 'COLLABORATOR') && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/')) needs: test runs-on: ubuntu-latest permissions: @@ -106,7 +120,7 @@ jobs: name: Create GitHub Release needs: build-and-push runs-on: ubuntu-latest - if: startsWith(github.ref, 'refs/tags/') + if: (github.event_name == 'push' || github.event.pull_request.author_association == 'OWNER' || github.event.pull_request.author_association == 'MEMBER' || github.event.pull_request.author_association == 'COLLABORATOR') && startsWith(github.ref, 'refs/tags/') steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0 diff --git a/src/api/middleware/errorHandler.ts b/src/api/middleware/errorHandler.ts index 8912991..545c833 100644 --- a/src/api/middleware/errorHandler.ts +++ b/src/api/middleware/errorHandler.ts @@ -31,26 +31,37 @@ export function errorHandler( ): void { const errorId = generateErrorId(); + // Extract error properties with safe type handling. + // We accept Error | unknown and need to safely access properties that may exist + // on Error objects (message), HTTP error objects (statusCode, status), or custom + // error objects (code). Type casting to any is necessary to access these + // arbitrary properties while maintaining runtime safety through optional chaining. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const errorObj = err as any; // Allow accessing arbitrary properties + const errorMessage = errorObj?.message ?? String(err); + const errorType = errorObj?.constructor?.name ?? 'Unknown'; + const statusCode = errorObj?.statusCode ?? errorObj?.status ?? 500; + const errorCode = errorObj?.code; + // Log the full error internally with correlation ID (safe location) log.error({ message: 'API error', data: { errorId, - type: err?.constructor?.name || 'Unknown', - message: err?.message || String(err) + type: errorType, + message: errorMessage } }); // Return safe error response to client with error ID for tracing - const statusCode = err?.statusCode || err?.status || 500; const errorResponse: ErrorResponse = { error: 'An error occurred processing your request', errorId }; // Add code if it's a validation error or known error type - if (err?.code) { - errorResponse.code = err.code; + if (errorCode) { + errorResponse.code = errorCode; } res.status(statusCode).json(errorResponse); diff --git a/src/api/middleware/logging.ts b/src/api/middleware/logging.ts index a453fb8..f18bbb5 100644 --- a/src/api/middleware/logging.ts +++ b/src/api/middleware/logging.ts @@ -11,9 +11,18 @@ export function requestLogging(req: Request, res: Response, next: NextFunction): const startTime = Date.now(); const clientIp = getClientIP(req); - // Override res.end to capture response - const originalEnd = res.end; - res.end = function (chunk?: Buffer | string, encoding?: string): Response { + // Override res.end to capture response timings and metadata. + // Express Response.end has multiple overloaded signatures: + // end(): Response + // end(callback: Function): Response + // end(data: Buffer | string): Response + // end(data: Buffer | string, callback: Function): Response + // end(data: Buffer | string, encoding: string, callback: Function): Response + // We accept variadic args to match all overloads while preserving the original + // function's ability to handle any combination of parameters. + const originalEnd = res.end.bind(res); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + res.end = function (...args: any[]): Response { const duration = Date.now() - startTime; log.debug({ @@ -27,8 +36,8 @@ export function requestLogging(req: Request, res: Response, next: NextFunction): } }); - return originalEnd.call(this, chunk, encoding); - }; + return originalEnd(...args); + } as typeof res.end; next(); } diff --git a/src/backends/traefik/templateParser.ts b/src/backends/traefik/templateParser.ts index 993ad45..d16a59d 100644 --- a/src/backends/traefik/templateParser.ts +++ b/src/backends/traefik/templateParser.ts @@ -36,10 +36,13 @@ function buildContext(appName: string, data: XMagicProxyData): Record { if (key in context) { return context[key]; } - // Unknown variable - leave as-is and warn - log.warn({ message: 'Unknown template variable', data: { appName, variable: key } }); - return `{{ ${key} }}`; + // Track unknown variable for error reporting + unknownVariables.push(key); + return _match; // Return original text }); + // If there were unknown variables, throw an error + if (unknownVariables.length > 0) { + const uniqueVars = [...new Set(unknownVariables)]; + const message = `Template contains unknown variables: ${uniqueVars.join(', ')}`; + log.error({ message, data: { appName, unknownVariables: uniqueVars } }); + throw new Error(message); + } + // Parse and re-dump for consistent YAML formatting try { const parsed = yaml.load(rendered); return yaml.dump(parsed, { noRefs: true, skipInvalid: true }); } catch (err) { - // Return raw rendered content if YAML parsing fails - log.warn({ + const message = err instanceof Error ? err.message : String(err); + log.error({ message: 'Template produced invalid YAML', - data: { appName, error: err instanceof Error ? err.message : String(err) } + data: { appName, error: message } }); - return rendered; + throw new Error(`Template produced invalid YAML: ${message}`); } } diff --git a/src/backends/traefik/traefik.ts b/src/backends/traefik/traefik.ts index dab3b94..da72420 100644 --- a/src/backends/traefik/traefik.ts +++ b/src/backends/traefik/traefik.ts @@ -41,8 +41,9 @@ async function loadTemplate(templatePath: string): Promise { /** * Creates a Traefik config fragment by rendering the appropriate template. + * Returns null if template rendering fails. */ -function makeAppConfig(appName: string, data: XMagicProxyData): TraefikConfigYamlFormat { +function makeAppConfig(appName: string, data: XMagicProxyData): TraefikConfigYamlFormat | null { lastUserData = data.userData ? JSON.stringify(data.userData) : null; const templateContent = templates.get(data.template); @@ -57,10 +58,18 @@ function makeAppConfig(appName: string, data: XMagicProxyData): TraefikConfigYam data: { appName, template: data.template, target: data.target, hostname: data.hostname } }); - const rendered = renderTemplate(templateContent, appName, data); - lastRendered = rendered; - - return yaml.load(rendered) as TraefikConfigYamlFormat; + try { + const rendered = renderTemplate(templateContent, appName, data); + lastRendered = rendered; + return yaml.load(rendered) as TraefikConfigYamlFormat; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + log.error({ + message: 'Failed to render template', + data: { appName, error: message } + }); + return null; + } } // ───────────────────────────────────────────────────────────────────────────── @@ -104,7 +113,7 @@ export async function initialize(config?: MagicProxyConfigFile): Promise { } log.info({ message: 'Initializing Traefik backend', data: { templateCount: templatePaths.length } }); - + // Load all templates concurrently const loadResults = await Promise.all( templatePaths.map(async (templatePath) => ({ @@ -138,6 +147,7 @@ export async function initialize(config?: MagicProxyConfigFile): Promise { /** * Add or update a proxied application. + * If template rendering fails, the host is skipped with an error log. */ export async function addProxiedApp(entry: HostEntry): Promise { const { containerName, xMagicProxy } = entry; @@ -146,7 +156,16 @@ export async function addProxiedApp(entry: HostEntry): Promise { data: { containerName, hostname: xMagicProxy.hostname, target: xMagicProxy.target, template: xMagicProxy.template } }); - manager.register(containerName, makeAppConfig(containerName, xMagicProxy)); + const config = makeAppConfig(containerName, xMagicProxy); + if (config === null) { + log.error({ + message: 'Skipping host due to template rendering failure', + data: { containerName, hostname: xMagicProxy.hostname } + }); + return; + } + + manager.register(containerName, config); await manager.flushToDisk(); } diff --git a/test/unit/providers/docker-provider.test.ts b/test/unit/providers/docker-provider.test.ts index 46c6e9e..8075f04 100644 --- a/test/unit/providers/docker-provider.test.ts +++ b/test/unit/providers/docker-provider.test.ts @@ -11,7 +11,8 @@ vi.mock('../../../src/providers/docker/compose', async () => { const actual = await vi.importActual('../../../src/providers/docker/compose'); return { ...actual, - groupContainersByComposeFile: vi.fn() + groupContainersByComposeFile: vi.fn(), + resolveHostPath: vi.fn((path) => path) }; }); vi.mock('../../../src/providers/docker/manifest', async () => { diff --git a/test/unit/traefik/template-error-handling.test.ts b/test/unit/traefik/template-error-handling.test.ts new file mode 100644 index 0000000..188f73d --- /dev/null +++ b/test/unit/traefik/template-error-handling.test.ts @@ -0,0 +1,314 @@ +import { describe, it, beforeEach, expect } from 'vitest'; +import { renderTemplate } from '../../../src/backends/traefik/templateParser'; +import * as traefik from '../../../src/backends/traefik/traefik'; +import { XMagicProxyData } from '../../../src/types/xmagic'; +import { HostEntry } from '../../../src/types/host'; +import { ComposeFileData } from '../../../src/types/docker'; + +describe('Template Error Handling', () => { + describe('renderTemplate', () => { + it('throws error when unknown template variable is encountered', () => { + const tmpl = 'Hello {{ app_name }} {{ unknown_var }} {{ target_url }}'; + const data: XMagicProxyData = { + template: 'default', + target: 'http://x', + hostname: 'h', + }; + + expect(() => renderTemplate(tmpl, 'app', data)).toThrow( + 'Template contains unknown variables: unknown_var' + ); + }); + + it('throws error with all unknown variables when multiple are missing', () => { + const tmpl = 'Host: {{ missing1 }} App: {{ app_name }} Var: {{ missing2 }}'; + const data: XMagicProxyData = { + template: 'default', + target: 'http://x', + hostname: 'h', + }; + + expect(() => renderTemplate(tmpl, 'app', data)).toThrow( + /Template contains unknown variables: (missing1, missing2|missing2, missing1)/ + ); + }); + + it('throws error when duplicate unknown variables are encountered', () => { + const tmpl = '{{ missing }} and {{ missing }} again'; + const data: XMagicProxyData = { + template: 'default', + target: 'http://x', + hostname: 'h', + }; + + expect(() => renderTemplate(tmpl, 'app', data)).toThrow( + 'Template contains unknown variables: missing' + ); + }); + + it('throws error on invalid YAML after variable replacement', () => { + const tmpl = ` +key: {{ app_name }} + bad_indentation: value + more_bad: stuff +`; + const data: XMagicProxyData = { + template: 'default', + target: 'http://x', + hostname: 'h', + }; + + expect(() => renderTemplate(tmpl, 'app', data)).toThrow( + /Template produced invalid YAML/ + ); + }); + + it('succeeds with valid template and all variables provided', () => { + const tmpl = ` +http: + routers: + app-{{ app_name }}: + rule: Host(\`{{ hostname }}\`) + service: {{ app_name }}-service + services: + {{ app_name }}-service: + loadBalancer: + servers: + - url: "{{ target_url }}" +`; + const data: XMagicProxyData = { + template: 'default', + target: 'http://backend:8080', + hostname: 'example.com', + }; + + const result = renderTemplate(tmpl, 'myapp', data); + expect(result).toContain('app-myapp'); + expect(result).toContain('example.com'); + expect(result).toContain('http://backend:8080'); + }); + + it('replaces core variables correctly', () => { + const tmpl = ` +app: {{ app_name }} +host: {{ hostname }} +target: {{ target_url }} +`; + const data: XMagicProxyData = { + template: 'default', + target: 'http://myservice:3000', + hostname: 'myhost.local', + }; + + const result = renderTemplate(tmpl, 'svc1', data); + expect(result).toContain('svc1'); + expect(result).toContain('myhost.local'); + expect(result).toContain('http://myservice:3000'); + }); + + it('replaces userData variables correctly', () => { + const tmpl = ` +config: + color: {{ color }} + size: {{ size }} + app: {{ app_name }} +`; + const data: XMagicProxyData = { + template: 'default', + target: 'http://x', + hostname: 'h', + userData: { color: 'blue', size: 'large' }, + }; + + const result = renderTemplate(tmpl, 'app', data); + expect(result).toContain('blue'); + expect(result).toContain('large'); + expect(result).toContain('app'); + }); + }); + + describe('addProxiedApp with error handling', () => { + beforeEach(() => { + traefik._resetForTesting(); + }); + + it('skips host when template has unknown variables', async () => { + traefik._setTemplateForTesting('default', ` +http: + routers: + app-{{ app_name }}: + rule: Host(\`{{ hostname }}\`) + service: {{ app_name }}-service + port: {{ port }} +`); + + const appData: XMagicProxyData = { + template: 'default', + target: 'http://backend:8080', + hostname: 'example.com', + // missing 'port' in userData + }; + + const entry: HostEntry = { + containerName: 'test-app', + xMagicProxy: appData, + composeFilePath: '', + composeData: {} as ComposeFileData, + lastChanged: Date.now(), + state: {}, + }; + + await traefik.addProxiedApp(entry); + const status = await traefik.getStatus(); + + // Host should not be registered + expect(status.registered).not.toContain('test-app'); + }); + + it('skips host when template produces invalid YAML', async () => { + traefik._setTemplateForTesting('invalid', ` +http: + routers: + app: {{ app_name }} + bad_indentation: value + nested: thing +`); + + const appData: XMagicProxyData = { + template: 'invalid', + target: 'http://backend:8080', + hostname: 'example.com', + }; + + const entry: HostEntry = { + containerName: 'bad-yaml-app', + xMagicProxy: appData, + composeFilePath: '', + composeData: {} as ComposeFileData, + lastChanged: Date.now(), + state: {}, + }; + + await traefik.addProxiedApp(entry); + const status = await traefik.getStatus(); + + // Host should not be registered + expect(status.registered).not.toContain('bad-yaml-app'); + }); + + it('registers host when template is valid and all variables are provided', async () => { + traefik._setTemplateForTesting('valid', ` +http: + routers: + app-{{ app_name }}: + rule: Host(\`{{ hostname }}\`) + service: {{ app_name }}-service + services: + {{ app_name }}-service: + loadBalancer: + servers: + - url: "{{ target_url }}" +`); + + const appData: XMagicProxyData = { + template: 'valid', + target: 'http://backend:8080', + hostname: 'example.com', + }; + + const entry: HostEntry = { + containerName: 'good-app', + xMagicProxy: appData, + composeFilePath: '', + composeData: {} as ComposeFileData, + lastChanged: Date.now(), + state: {}, + }; + + await traefik.addProxiedApp(entry); + const status = await traefik.getStatus(); + + // Host should be registered + expect(status.registered).toContain('good-app'); + }); + + it('continues processing when one host fails', async () => { + traefik._setTemplateForTesting('default', ` +http: + routers: + app-{{ app_name }}: + rule: Host(\`{{ hostname }}\`) + service: {{ app_name }}-service +`); + + // First app - will fail (missing required variable) + const badData: XMagicProxyData = { + template: 'default', + target: 'http://backend:8080', + hostname: 'example.com', + userData: { missing_var: 'value' }, + }; + + const badEntry: HostEntry = { + containerName: 'bad-app', + xMagicProxy: badData, + composeFilePath: '', + composeData: {} as ComposeFileData, + lastChanged: Date.now(), + state: {}, + }; + + // Update template to require a variable not in the bad entry + traefik._setTemplateForTesting('strict', ` +http: + routers: + app-{{ app_name }}: + rule: Host(\`{{ hostname }}\`) + service: {{ app_name }}-service + custom_port: {{ custom_port }} +`); + + const strictBadData: XMagicProxyData = { + template: 'strict', + target: 'http://backend:8080', + hostname: 'example.com', + // missing custom_port + }; + + const strictBadEntry: HostEntry = { + containerName: 'bad-app', + xMagicProxy: strictBadData, + composeFilePath: '', + composeData: {} as ComposeFileData, + lastChanged: Date.now(), + state: {}, + }; + + // Second app - will succeed + const goodData: XMagicProxyData = { + template: 'default', + target: 'http://good:8080', + hostname: 'example.com', + }; + + const goodEntry: HostEntry = { + containerName: 'good-app', + xMagicProxy: goodData, + composeFilePath: '', + composeData: {} as ComposeFileData, + lastChanged: Date.now(), + state: {}, + }; + + await traefik.addProxiedApp(strictBadEntry); + await traefik.addProxiedApp(goodEntry); + + const status = await traefik.getStatus(); + + // Bad app should not be registered + expect(status.registered).not.toContain('bad-app'); + // Good app should be registered + expect(status.registered).toContain('good-app'); + }); + }); +}); diff --git a/test/unit/traefik/template-validation.test.ts b/test/unit/traefik/template-validation.test.ts new file mode 100644 index 0000000..ed4208b --- /dev/null +++ b/test/unit/traefik/template-validation.test.ts @@ -0,0 +1,435 @@ +import { describe, it, expect } from 'vitest'; +import { validateGeneratedConfig } from '../../../src/backends/traefik/validators'; +import { renderTemplate } from '../../../src/backends/traefik/templateParser'; +import { XMagicProxyData } from '../../../src/types/xmagic'; + +describe('Template Validation', () => { + describe('validateGeneratedConfig', () => { + it('accepts valid http-only config', () => { + const yaml = ` +http: + routers: + my-app: + rule: Host(\`example.com\`) + service: my-service + services: + my-service: + loadBalancer: + servers: + - url: "http://backend:3000" +`; + const result = validateGeneratedConfig(yaml); + expect(result.valid).toBe(true); + }); + + it('accepts valid tcp-only config', () => { + const yaml = ` +tcp: + routers: + tcp-app: + entryPoints: + - tcp + service: tcp-service + services: + tcp-service: + loadBalancer: + servers: + - address: "backend:9000" +`; + const result = validateGeneratedConfig(yaml); + expect(result.valid).toBe(true); + }); + + it('accepts valid udp-only config', () => { + const yaml = ` +udp: + services: + udp-service: + loadBalancer: + servers: + - address: "backend:5353" +`; + const result = validateGeneratedConfig(yaml); + expect(result.valid).toBe(true); + }); + + it('accepts valid mixed http/tcp/udp config', () => { + const yaml = ` +http: + routers: + http-app: + rule: Host(\`example.com\`) + service: http-service + services: + http-service: + loadBalancer: + servers: + - url: "http://backend:3000" +tcp: + routers: + tcp-app: + entryPoints: + - tcp + service: tcp-service + services: + tcp-service: + loadBalancer: + servers: + - address: "backend:9000" +udp: + services: + udp-service: + loadBalancer: + servers: + - address: "backend:5353" +`; + const result = validateGeneratedConfig(yaml); + expect(result.valid).toBe(true); + }); + + it('accepts empty config', () => { + const yaml = ''; + const result = validateGeneratedConfig(yaml); + expect(result.valid).toBe(true); + }); + + it('rejects invalid YAML', () => { + const yaml = ` +http: + routers: + bad_indentation: value + nested: bad +`; + const result = validateGeneratedConfig(yaml); + expect(result.valid).toBe(false); + expect(result.valid === false && result.error).toContain('Invalid YAML'); + }); + + it('rejects non-object YAML', () => { + const yaml = 'just a string'; + const result = validateGeneratedConfig(yaml); + expect(result.valid).toBe(false); + }); + + it('rejects unexpected top-level keys', () => { + const yaml = ` +http: + routers: {} +invalid_section: + something: value +`; + const result = validateGeneratedConfig(yaml); + expect(result.valid).toBe(false); + expect(result.valid === false && result.error).toContain('Unexpected top-level key'); + }); + + it('rejects unexpected keys in http section', () => { + const yaml = ` +http: + routers: + app: {} + invalid_key: value +`; + const result = validateGeneratedConfig(yaml); + expect(result.valid).toBe(false); + expect(result.valid === false && result.error).toContain('Unexpected key under http'); + }); + + it('rejects unexpected keys in tcp section', () => { + const yaml = ` +tcp: + routers: + app: {} + middlewares: {} +`; + const result = validateGeneratedConfig(yaml); + expect(result.valid).toBe(false); + expect(result.valid === false && result.error).toContain('Unexpected key under tcp'); + }); + + it('rejects unexpected keys in udp section', () => { + const yaml = ` +udp: + services: {} + routers: {} +`; + const result = validateGeneratedConfig(yaml); + expect(result.valid).toBe(false); + expect(result.valid === false && result.error).toContain('Unexpected key under udp'); + }); + + it('rejects router/service names with whitespace', () => { + const yaml = ` +http: + routers: + "app with space": + rule: Host(\`example.com\`) + service: my-service + services: + my-service: {} +`; + const result = validateGeneratedConfig(yaml); + expect(result.valid).toBe(false); + expect(result.valid === false && result.error).toContain('Invalid name'); + }); + + it('rejects router/service names with newlines', () => { + // Note: YAML parsing will fail with invalid newline in key, so we expect YAML error + const yaml = ` +http: + routers: + app: {} + services: + "service + name": {} +`; + const result = validateGeneratedConfig(yaml); + expect(result.valid).toBe(false); + // YAML parsing fails before we can check name validation + expect(result.valid === false && result.error).toContain('Invalid YAML'); + }); + + it('rejects empty router/service names', () => { + const yaml = ` +http: + routers: + "": {} + services: + my-service: {} +`; + const result = validateGeneratedConfig(yaml); + expect(result.valid).toBe(false); + expect(result.valid === false && result.error).toContain('Invalid name'); + }); + + it('warns about unreplaced template variables', () => { + // Use plain text that contains template markers + const yaml = ` +http: + routers: + app: + rule: Host(\`app.example.com\`) + service: my-service + services: + my-service: {} + middlewares: + test: "contains {{ app_name }} variable" +`; + const result = validateGeneratedConfig(yaml); + expect(result.valid).toBe(true); + expect(result.valid === true && result.warnings?.length).toBeGreaterThan(0); + expect(result.valid === true && result.warnings?.[0]).toContain('unreplaced template'); + }); + + it('warns about unreplaced variables with }}', () => { + const yaml = ` +http: + routers: + app: + rule: Host(\`{{ hostname }}\`) + service: my-service + services: + my-service: {} +`; + const result = validateGeneratedConfig(yaml); + expect(result.valid).toBe(true); + expect(result.valid === true && result.warnings?.length).toBeGreaterThan(0); + }); + }); + + describe('Template rendering with validation', () => { + it('renders template and passes validation', () => { + const template = ` +http: + routers: + magic-proxy-{{ app_name }}: + rule: Host(\`{{ app_name }}.{{ hostname }}\`) + service: magic-proxy-{{ app_name }} + entryPoints: + - web + services: + magic-proxy-{{ app_name }}: + loadBalancer: + servers: + - url: "{{ target_url }}" +`; + const data: XMagicProxyData = { + template: 'default', + target: 'http://backend:3000', + hostname: 'example.com', + }; + + const rendered = renderTemplate(template, 'myapp', data); + const validation = validateGeneratedConfig(rendered); + + expect(validation.valid).toBe(true); + expect(rendered).toContain('myapp.example.com'); + expect(rendered).toContain('http://backend:3000'); + }); + + it('validates rendered template has no unreplaced variables', () => { + const template = ` +http: + routers: + magic-proxy-{{ app_name }}: + rule: Host(\`{{ app_name }}.{{ hostname }}\`) + service: magic-proxy-{{ app_name }} + services: + magic-proxy-{{ app_name }}: + loadBalancer: + servers: + - url: "{{ target_url }}" +`; + const data: XMagicProxyData = { + template: 'default', + target: 'http://backend:3000', + hostname: 'example.com', + }; + + const rendered = renderTemplate(template, 'app1', data); + const validation = validateGeneratedConfig(rendered); + + expect(validation.valid).toBe(true); + // Should not have warnings about unreplaced variables + if (validation.valid && validation.warnings) { + const unreplacedWarning = validation.warnings.find(w => + w.includes('unreplaced template') + ); + expect(unreplacedWarning).toBeUndefined(); + } + }); + + it('validates complex template with multiple apps', () => { + const template = ` +http: + routers: + magic-proxy-{{ app_name }}: + rule: Host(\`{{ hostname }}\`) + service: magic-proxy-{{ app_name }} + services: + magic-proxy-{{ app_name }}: + loadBalancer: + servers: + - url: "{{ target_url }}" +`; + + const renderAndValidate = (appName: string, target: string, hostname: string) => { + const data: XMagicProxyData = { + template: 'default', + target, + hostname, + }; + const rendered = renderTemplate(template, appName, data); + return validateGeneratedConfig(rendered); + }; + + const result1 = renderAndValidate('app1', 'http://backend1:3000', 'app1.local'); + const result2 = renderAndValidate('app2', 'http://backend2:4000', 'app2.local'); + + expect(result1.valid).toBe(true); + expect(result2.valid).toBe(true); + }); + + it('rejects rendered template with invalid structure', () => { + // This template has a structural issue after rendering + const template = ` +http: + routers: + app-{{ app_name }}: {{ bad_syntax }} +`; + const data: XMagicProxyData = { + template: 'default', + target: 'http://backend:3000', + hostname: 'example.com', + }; + + // renderTemplate will throw because of unknown variable + expect(() => renderTemplate(template, 'app', data)).toThrow(); + }); + }); + + describe('Template validation edge cases', () => { + it('accepts valid router with complex rules', () => { + const yaml = ` +http: + routers: + complex-router: + rule: Host(\`example.com\`) || Host(\`www.example.com\`) + service: my-service + middlewares: + - my-middleware + services: + my-service: + loadBalancer: + servers: + - url: "http://backend:3000" + middlewares: + my-middleware: + redirectScheme: + scheme: https +`; + const result = validateGeneratedConfig(yaml); + expect(result.valid).toBe(true); + }); + + it('accepts valid service with multiple servers', () => { + const yaml = ` +http: + services: + my-service: + loadBalancer: + servers: + - url: "http://backend1:3000" + - url: "http://backend2:3000" + - url: "http://backend3:3000" +`; + const result = validateGeneratedConfig(yaml); + expect(result.valid).toBe(true); + }); + + it('accepts valid middleware definitions', () => { + const yaml = ` +http: + middlewares: + auth: + basicAuth: + users: + - "admin:password" + cors: + headers: + accessControlAllowOriginList: + - "*" +`; + const result = validateGeneratedConfig(yaml); + expect(result.valid).toBe(true); + }); + + it('handles router/service names with hyphens and underscores', () => { + const yaml = ` +http: + routers: + my-app_router: + rule: Host(\`example.com\`) + service: my_app-service + services: + my_app-service: {} +`; + const result = validateGeneratedConfig(yaml); + expect(result.valid).toBe(true); + }); + + it('handles router/service names with numbers', () => { + const yaml = ` +http: + routers: + app123: + rule: Host(\`example.com\`) + service: service456 + services: + service456: {} +`; + const result = validateGeneratedConfig(yaml); + expect(result.valid).toBe(true); + }); + }); +}); diff --git a/test/unit/traefik/user-data-substitution.test.ts b/test/unit/traefik/user-data-substitution.test.ts new file mode 100644 index 0000000..5391149 --- /dev/null +++ b/test/unit/traefik/user-data-substitution.test.ts @@ -0,0 +1,485 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { renderTemplate } from '../../../src/backends/traefik/templateParser'; +import * as traefik from '../../../src/backends/traefik/traefik'; +import { XMagicProxyData } from '../../../src/types/xmagic'; +import { HostEntry } from '../../../src/types/host'; +import { ComposeFileData } from '../../../src/types/docker'; + +describe('User Data Template Substitution', () => { + describe('userData in template rendering', () => { + it('replaces single userData variable in template', () => { + const template = ` +config: + port: {{ port }} + app: {{ app_name }} +`; + const data: XMagicProxyData = { + template: 'test', + target: 'http://backend:3000', + hostname: 'example.com', + userData: { port: '8080' }, + }; + + const result = renderTemplate(template, 'myapp', data); + expect(result).toContain('8080'); + expect(result).toContain('myapp'); + }); + + it('replaces multiple userData variables in template', () => { + const template = ` +config: + port: {{ port }} + timeout: {{ timeout }} + retries: {{ retries }} + app: {{ app_name }} +`; + const data: XMagicProxyData = { + template: 'test', + target: 'http://backend:3000', + hostname: 'example.com', + userData: { + port: '8080', + timeout: '30', + retries: '3', + }, + }; + + const result = renderTemplate(template, 'myapp', data); + expect(result).toContain('8080'); + expect(result).toContain('30'); + expect(result).toContain('3'); + }); + + it('throws error when userData variable is missing', () => { + const template = ` +config: + port: {{ port }} + app: {{ app_name }} +`; + const data: XMagicProxyData = { + template: 'test', + target: 'http://backend:3000', + hostname: 'example.com', + // missing port in userData + }; + + expect(() => renderTemplate(template, 'myapp', data)).toThrow('Template contains unknown variables: port'); + }); + + it('handles userData with string values', () => { + const template = ` +config: + protocol: {{ protocol }} + environment: {{ env }} +`; + const data: XMagicProxyData = { + template: 'test', + target: 'http://backend:3000', + hostname: 'example.com', + userData: { + protocol: 'https', + env: 'production', + }, + }; + + const result = renderTemplate(template, 'app', data); + expect(result).toContain('https'); + expect(result).toContain('production'); + }); + + it('handles userData with numeric values', () => { + const template = ` +config: + port: {{ port }} + workers: {{ workers }} +`; + const data: XMagicProxyData = { + template: 'test', + target: 'http://backend:3000', + hostname: 'example.com', + userData: { + port: 8080, + workers: 4, + }, + }; + + const result = renderTemplate(template, 'app', data); + expect(result).toContain('8080'); + expect(result).toContain('4'); + }); + + it('handles userData with null values converted to empty strings', () => { + const template = ` +config: + optional_setting: "{{ optional }}" + app: {{ app_name }} +`; + const data: XMagicProxyData = { + template: 'test', + target: 'http://backend:3000', + hostname: 'example.com', + userData: { + optional: null, + }, + }; + + const result = renderTemplate(template, 'app', data); + // null should be converted to empty string, YAML output will have single quotes + expect(result).toContain("optional_setting: ''"); + }); + + it('core variables cannot be overwritten by userData', () => { + const template = ` +app: {{ app_name }} +host: {{ hostname }} +url: {{ target_url }} +`; + const data: XMagicProxyData = { + template: 'test', + target: 'http://backend:3000', + hostname: 'example.com', + userData: { + app_name: 'should-be-ignored', + hostname: 'should-be-ignored.com', + target_url: 'http://should-be-ignored:9999', + }, + }; + + const result = renderTemplate(template, 'myapp', data); + // Core variables should use actual values, not userData + expect(result).toContain('myapp'); + expect(result).toContain('example.com'); + expect(result).toContain('http://backend:3000'); + expect(result).not.toContain('should-be-ignored'); + }); + + it('rejects userData keys with invalid characters', () => { + const template = ` +config: + value: {{ invalid_key }} + other: {{ also_bad }} +`; + const data: XMagicProxyData = { + template: 'test', + target: 'http://backend:3000', + hostname: 'example.com', + userData: { + 'also-bad': 'value', + }, + }; + + // The userData key with hyphen won't match VALID_KEY_PATTERN (alphanumeric + underscore only) + // So the template variable won't be replaced and will error + expect(() => renderTemplate(template, 'app', data)).toThrow('Template contains unknown variables'); + }); + + it('accepts userData keys with underscores', () => { + const template = ` +config: + setting: {{ my_setting }} +`; + const data: XMagicProxyData = { + template: 'test', + target: 'http://backend:3000', + hostname: 'example.com', + userData: { + my_setting: 'value123', + }, + }; + + const result = renderTemplate(template, 'app', data); + expect(result).toContain('value123'); + }); + + it('accepts userData keys with numbers', () => { + const template = ` +config: + setting1: {{ setting1 }} + setting2: {{ setting2 }} + port3000: {{ port3000 }} +`; + const data: XMagicProxyData = { + template: 'test', + target: 'http://backend:3000', + hostname: 'example.com', + userData: { + setting1: 'val1', + setting2: 'val2', + port3000: '3000', + }, + }; + + const result = renderTemplate(template, 'app', data); + expect(result).toContain('val1'); + expect(result).toContain('val2'); + expect(result).toContain('3000'); + }); + }); + + describe('Complex template scenarios with userData', () => { + it('uses userData in Traefik router configuration', () => { + const template = ` +http: + routers: + app-{{ app_name }}: + rule: Host(\`{{ hostname }}\`) + service: app-{{ app_name }} + entryPoints: + - {{ entrypoint }} + middlewares: + - {{ middleware }} + services: + app-{{ app_name }}: + loadBalancer: + servers: + - url: "{{ target_url }}" +`; + const data: XMagicProxyData = { + template: 'custom', + target: 'http://backend:3000', + hostname: 'myapp.local', + userData: { + entrypoint: 'websecure', + middleware: 'auth', + }, + }; + + const result = renderTemplate(template, 'myapp', data); + expect(result).toContain('myapp.local'); + expect(result).toContain('http://backend:3000'); + expect(result).toContain('websecure'); + expect(result).toContain('auth'); + }); + + it('uses userData for service port configuration', () => { + const template = ` +http: + routers: + api: + rule: Host(\`api.example.com\`) + service: api-backend + services: + api-backend: + loadBalancer: + servers: + - url: "{{ target_url }}:{{ port }}" +`; + const data: XMagicProxyData = { + template: 'api', + target: 'http://backend', + hostname: 'api.example.com', + userData: { + port: '8080', + }, + }; + + const result = renderTemplate(template, 'api', data); + expect(result).toContain('http://backend:8080'); + }); + + it('uses userData for environment-specific configuration', () => { + const template = ` +http: + services: + app: + loadBalancer: + servers: + - url: "{{ target_url }}" + healthCheck: + path: {{ health_path }} + interval: {{ health_interval }} + timeout: {{ health_timeout }} +`; + const data: XMagicProxyData = { + template: 'health', + target: 'http://backend:3000', + hostname: 'example.com', + userData: { + health_path: '/health', + health_interval: '10s', + health_timeout: '5s', + }, + }; + + const result = renderTemplate(template, 'app', data); + expect(result).toContain('/health'); + expect(result).toContain('10s'); + expect(result).toContain('5s'); + }); + + it('empty userData allows templates with only core variables', () => { + const template = ` +http: + routers: + app-{{ app_name }}: + rule: Host(\`{{ hostname }}\`) + service: {{ app_name }} + services: + {{ app_name }}: + loadBalancer: + servers: + - url: "{{ target_url }}" +`; + const data: XMagicProxyData = { + template: 'basic', + target: 'http://backend:3000', + hostname: 'example.com', + // No userData + }; + + const result = renderTemplate(template, 'myapp', data); + expect(result).toContain('myapp'); + expect(result).toContain('example.com'); + }); + }); + + describe('Integration with addProxiedApp', () => { + beforeEach(() => { + traefik._resetForTesting(); + }); + + it('successfully adds app with userData substitution', async () => { + traefik._setTemplateForTesting('custom', ` +http: + routers: + app-{{ app_name }}: + rule: Host(\`{{ hostname }}\`) + service: {{ app_name }} + entryPoints: + - {{ entrypoint }} + services: + {{ app_name }}: + loadBalancer: + servers: + - url: "{{ target_url }}" +`); + + const appData: XMagicProxyData = { + template: 'custom', + target: 'http://backend:3000', + hostname: 'myapp.local', + userData: { + entrypoint: 'web', + }, + }; + + const entry: HostEntry = { + containerName: 'myapp', + xMagicProxy: appData, + composeFilePath: '', + composeData: {} as ComposeFileData, + lastChanged: Date.now(), + state: {}, + }; + + await traefik.addProxiedApp(entry); + const status = await traefik.getStatus(); + const config = await traefik.getConfig(); + + expect(status.registered).toContain('myapp'); + expect(config).toContain('myapp.local'); + expect(config).toContain('http://backend:3000'); + expect(config).toContain('web'); + }); + + it('skips app when userData variable is missing', async () => { + traefik._setTemplateForTesting('custom', ` +http: + routers: + app-{{ app_name }}: + rule: Host(\`{{ hostname }}\`) + service: {{ app_name }} + entryPoints: + - {{ entrypoint }} + services: + {{ app_name }}: {} +`); + + const appData: XMagicProxyData = { + template: 'custom', + target: 'http://backend:3000', + hostname: 'myapp.local', + // Missing required entrypoint + }; + + const entry: HostEntry = { + containerName: 'myapp', + xMagicProxy: appData, + composeFilePath: '', + composeData: {} as ComposeFileData, + lastChanged: Date.now(), + state: {}, + }; + + await traefik.addProxiedApp(entry); + const status = await traefik.getStatus(); + + // App should not be registered due to missing userData + expect(status.registered).not.toContain('myapp'); + }); + + it('multiple apps with different userData', async () => { + traefik._setTemplateForTesting('custom', ` +http: + routers: + app-{{ app_name }}: + rule: Host(\`{{ hostname }}\`) + service: {{ app_name }} + entryPoints: + - {{ entrypoint }} + services: + {{ app_name }}: + loadBalancer: + servers: + - url: "{{ target_url }}" +`); + + const app1: XMagicProxyData = { + template: 'custom', + target: 'http://backend1:3000', + hostname: 'app1.local', + userData: { entrypoint: 'web' }, + }; + + const app2: XMagicProxyData = { + template: 'custom', + target: 'http://backend2:4000', + hostname: 'app2.local', + userData: { entrypoint: 'websecure' }, + }; + + const entry1: HostEntry = { + containerName: 'app1', + xMagicProxy: app1, + composeFilePath: '', + composeData: {} as ComposeFileData, + lastChanged: Date.now(), + state: {}, + }; + + const entry2: HostEntry = { + containerName: 'app2', + xMagicProxy: app2, + composeFilePath: '', + composeData: {} as ComposeFileData, + lastChanged: Date.now(), + state: {}, + }; + + await traefik.addProxiedApp(entry1); + await traefik.addProxiedApp(entry2); + + const status = await traefik.getStatus(); + const config = await traefik.getConfig(); + + expect(status.registered).toContain('app1'); + expect(status.registered).toContain('app2'); + expect(config).toContain('app1.local'); + expect(config).toContain('app2.local'); + expect(config).toContain('http://backend1:3000'); + expect(config).toContain('http://backend2:4000'); + expect(config).toContain('web'); + expect(config).toContain('websecure'); + }); + }); +});