From d78ebaf262295272835f3ea67e66fa4ce80e682d Mon Sep 17 00:00:00 2001 From: stonegray <7140974+stonegray@users.noreply.github.com> Date: Mon, 12 Jan 2026 03:49:47 -0500 Subject: [PATCH 1/8] Add API, harden build scripts (#19) * restart on config change * add secure API * add API key checking, timeouts, CORS, etc to API * Add tests for API * minor API updates, docs * bugfix: can't traverse host directory * ci: run on any PR * eslint: fix any types * bugfix: build types * build: force npm build in lint phase * eslint: add docs for overrides * resolve merges --------- Co-authored-by: Stone Gray --- .github/workflows/ci.yml | 5 +++++ src/api/middleware/errorHandler.ts | 21 ++++++++++++++++----- src/api/middleware/logging.ts | 19 ++++++++++++++----- 3 files changed, 35 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c7a24dd..80dbb40 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,6 +48,11 @@ 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 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(); } From e391c26b6b9e0ddc9b0af4c001b713b91e386e47 Mon Sep 17 00:00:00 2001 From: stonegray <7140974+stonegray@users.noreply.github.com> Date: Mon, 12 Jan 2026 05:36:26 -0500 Subject: [PATCH 2/8] Traefik: runtime checks for templating failures (#20) * restart on config change * add secure API * add API key checking, timeouts, CORS, etc to API * Add tests for API * minor API updates, docs * bugfix: can't traverse host directory * ci: run on any PR * eslint: fix any types * bugfix: build types * build: force npm build in lint phase * eslint: add docs for overrides * resolve merges * Add runtime checks to templates to prevent various test failures * Add test coverage for 3dc4bcd * Update test/unit/traefik/template-validation.test.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Stone Gray Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/backends/traefik/templateParser.ts | 28 +- src/backends/traefik/traefik.ts | 33 +- .../traefik/template-error-handling.test.ts | 314 ++++++++++++ test/unit/traefik/template-validation.test.ts | 435 ++++++++++++++++ .../traefik/user-data-substitution.test.ts | 485 ++++++++++++++++++ 5 files changed, 1281 insertions(+), 14 deletions(-) create mode 100644 test/unit/traefik/template-error-handling.test.ts create mode 100644 test/unit/traefik/template-validation.test.ts create mode 100644 test/unit/traefik/user-data-substitution.test.ts 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/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'); + }); + }); +}); From e96659d89030df20b3a43e42316c5a93179802a8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 14 Jan 2026 16:07:05 +0000 Subject: [PATCH 3/8] Initial plan From b2816e018e4184eca0e3661340557bb7590cb235 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 14 Jan 2026 16:09:45 +0000 Subject: [PATCH 4/8] Initial plan for addressing review comments Co-authored-by: stonegray <7140974+stonegray@users.noreply.github.com> --- package-lock.json | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8db7c2f..0b042b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -69,7 +69,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1917,7 +1916,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.6.tgz", "integrity": "sha512-NNu0sjyNxpoiW3YuVFfNz7mxSQ+S4X2G28uqg2s+CzoqoQjLPsWSbsFFyztIAqt2vb8kfEAsJNepMGPTxFDx3Q==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -2035,7 +2033,6 @@ "integrity": "sha512-iIACsx8pxRnguSYhHiMn2PvhvfpopO9FXHyn1mG5txZIsAaB6F0KwbFnUQN3KCiG3Jcuad/Cao2FAs1Wp7vAyg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.52.0", "@typescript-eslint/types": "8.52.0", @@ -2374,7 +2371,6 @@ "integrity": "sha512-rkoPH+RqWopVxDnCBE/ysIdfQ2A7j1eDmW8tCxxrR9nnFBa9jKf86VgsSAzxBd1x+ny0GC4JgiD3SNfRHv3pOg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/utils": "4.0.16", "fflate": "^0.8.2", @@ -2430,7 +2426,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2710,7 +2705,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2901,7 +2895,6 @@ "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", @@ -3389,7 +3382,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3632,7 +3624,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -5071,7 +5062,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -6094,7 +6084,6 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -6148,7 +6137,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6280,7 +6268,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -6356,7 +6343,6 @@ "integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.0.16", "@vitest/mocker": "4.0.16", From d8ba03af0164c64229e799f2f18b6783f6b35e12 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 14 Jan 2026 16:13:02 +0000 Subject: [PATCH 5/8] Address review comments: improve templateParser, add helpers tests, fix config types Co-authored-by: stonegray <7140974+stonegray@users.noreply.github.com> --- src/backends/traefik/templateParser.ts | 7 +- src/config.ts | 2 +- test/unit/traefik/helpers.test.ts | 128 +++++++++++++++++++++++++ 3 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 test/unit/traefik/helpers.test.ts diff --git a/src/backends/traefik/templateParser.ts b/src/backends/traefik/templateParser.ts index cd120fb..7e05ae4 100644 --- a/src/backends/traefik/templateParser.ts +++ b/src/backends/traefik/templateParser.ts @@ -77,6 +77,7 @@ export function renderTemplate(template: string, appName: string, data: XMagicPr /** * Get a value from context, supporting nested property access with dot notation. * e.g., "userData.foo" returns context.userData.foo + * Converts primitives (string, number, boolean) to strings for template substitution. */ function getContextValue(path: string): string | undefined { const parts = path.split('.'); @@ -89,7 +90,11 @@ export function renderTemplate(template: string, appName: string, data: XMagicPr value = (value as Record)[part]; } - return typeof value === 'string' ? value : undefined; + // Convert primitives to strings, reject objects/arrays/functions + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return String(value); + } + return undefined; } // Replace all {{ key }} occurrences diff --git a/src/config.ts b/src/config.ts index d727bd6..c175ee1 100644 --- a/src/config.ts +++ b/src/config.ts @@ -23,7 +23,7 @@ export function getDefaultConfigFile(): string { export const DEFAULT_CONFIG_FILE = getDefaultConfigFile(); /** Valid proxy backend names */ -const VALID_BACKENDS = ['traefik'] as const; +const VALID_BACKENDS: readonly string[] = ['traefik'] as const; /** * Load and validate a configuration file. diff --git a/test/unit/traefik/helpers.test.ts b/test/unit/traefik/helpers.test.ts new file mode 100644 index 0000000..74f7574 --- /dev/null +++ b/test/unit/traefik/helpers.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect } from 'vitest'; +import { getErrorMessage, detectCollisions } from '../../../src/backends/traefik/helpers'; + +describe('Traefik Helpers', () => { + describe('getErrorMessage', () => { + it('extracts message from Error object', () => { + const error = new Error('Something went wrong'); + expect(getErrorMessage(error)).toBe('Something went wrong'); + }); + + it('converts string to string', () => { + expect(getErrorMessage('plain string error')).toBe('plain string error'); + }); + + it('converts number to string', () => { + expect(getErrorMessage(42)).toBe('42'); + }); + + it('converts null to string', () => { + expect(getErrorMessage(null)).toBe('null'); + }); + + it('converts undefined to string', () => { + expect(getErrorMessage(undefined)).toBe('undefined'); + }); + + it('converts object to string', () => { + const obj = { code: 'ERR_UNKNOWN' }; + expect(getErrorMessage(obj)).toBe('[object Object]'); + }); + + it('handles TypeError', () => { + const error = new TypeError('Invalid type'); + expect(getErrorMessage(error)).toBe('Invalid type'); + }); + + it('handles custom Error subclass', () => { + class CustomError extends Error { + constructor(message: string) { + super(message); + this.name = 'CustomError'; + } + } + const error = new CustomError('Custom error message'); + expect(getErrorMessage(error)).toBe('Custom error message'); + }); + }); + + describe('detectCollisions', () => { + it('detects single collision', () => { + const target = { foo: 1, bar: 2 }; + const source = { foo: 3, baz: 4 }; + const collisions = detectCollisions(target, source); + expect(collisions).toEqual(['foo']); + }); + + it('detects multiple collisions', () => { + const target = { foo: 1, bar: 2, baz: 3 }; + const source = { foo: 10, bar: 20, qux: 40 }; + const collisions = detectCollisions(target, source); + expect(collisions).toContain('foo'); + expect(collisions).toContain('bar'); + expect(collisions).toHaveLength(2); + }); + + it('returns empty array when no collisions', () => { + const target = { foo: 1, bar: 2 }; + const source = { baz: 3, qux: 4 }; + const collisions = detectCollisions(target, source); + expect(collisions).toEqual([]); + }); + + it('handles empty target', () => { + const target = {}; + const source = { foo: 1, bar: 2 }; + const collisions = detectCollisions(target, source); + expect(collisions).toEqual([]); + }); + + it('handles empty source', () => { + const target = { foo: 1, bar: 2 }; + const source = {}; + const collisions = detectCollisions(target, source); + expect(collisions).toEqual([]); + }); + + it('handles both empty objects', () => { + const collisions = detectCollisions({}, {}); + expect(collisions).toEqual([]); + }); + + it('handles undefined target (default parameter)', () => { + const source = { foo: 1, bar: 2 }; + const collisions = detectCollisions(undefined, source); + expect(collisions).toEqual([]); + }); + + it('handles undefined source (default parameter)', () => { + const target = { foo: 1, bar: 2 }; + const collisions = detectCollisions(target, undefined); + expect(collisions).toEqual([]); + }); + + it('works with different value types', () => { + const target = { str: 'hello', num: 42, bool: true }; + const source = { str: 'world', obj: { nested: 'value' } }; + const collisions = detectCollisions(target, source); + expect(collisions).toEqual(['str']); + }); + + it('detects all keys when source is subset of target', () => { + const target = { a: 1, b: 2, c: 3, d: 4 }; + const source = { a: 10, c: 30 }; + const collisions = detectCollisions(target, source); + expect(collisions).toContain('a'); + expect(collisions).toContain('c'); + expect(collisions).toHaveLength(2); + }); + + it('preserves order of keys from source', () => { + const target = { a: 1, b: 2, c: 3 }; + const source = { c: 30, a: 10, b: 20 }; + const collisions = detectCollisions(target, source); + // Object.keys() order in modern JS follows insertion order + expect(collisions).toEqual(['c', 'a', 'b']); + }); + }); +}); From 66a5d193c7b7e3b4422bf0b2cb8d2259d9629242 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 14 Jan 2026 16:14:14 +0000 Subject: [PATCH 6/8] Improve VALID_BACKENDS type to maintain literal type safety Co-authored-by: stonegray <7140974+stonegray@users.noreply.github.com> --- src/config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config.ts b/src/config.ts index c175ee1..41ba613 100644 --- a/src/config.ts +++ b/src/config.ts @@ -23,7 +23,7 @@ export function getDefaultConfigFile(): string { export const DEFAULT_CONFIG_FILE = getDefaultConfigFile(); /** Valid proxy backend names */ -const VALID_BACKENDS: readonly string[] = ['traefik'] as const; +const VALID_BACKENDS: readonly ['traefik'] = ['traefik']; /** * Load and validate a configuration file. From c4cb6d8ea7e73d2f42876db0dd2fcc6c3117052f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 15 Jan 2026 18:28:45 +0000 Subject: [PATCH 7/8] Initial plan From c46463d21019dccabc175f727079c87269dd58f6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 15 Jan 2026 18:38:58 +0000 Subject: [PATCH 8/8] Address code review nitpicks Co-authored-by: stonegray <7140974+stonegray@users.noreply.github.com> --- src/config.ts | 2 +- test/unit/traefik/helpers.test.ts | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/config.ts b/src/config.ts index 41ba613..d727bd6 100644 --- a/src/config.ts +++ b/src/config.ts @@ -23,7 +23,7 @@ export function getDefaultConfigFile(): string { export const DEFAULT_CONFIG_FILE = getDefaultConfigFile(); /** Valid proxy backend names */ -const VALID_BACKENDS: readonly ['traefik'] = ['traefik']; +const VALID_BACKENDS = ['traefik'] as const; /** * Load and validate a configuration file. diff --git a/test/unit/traefik/helpers.test.ts b/test/unit/traefik/helpers.test.ts index 74f7574..59fead5 100644 --- a/test/unit/traefik/helpers.test.ts +++ b/test/unit/traefik/helpers.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; import { getErrorMessage, detectCollisions } from '../../../src/backends/traefik/helpers'; -describe('Traefik Helpers', () => { +describe('Traefik helpers', () => { describe('getErrorMessage', () => { it('extracts message from Error object', () => { const error = new Error('Something went wrong'); @@ -121,7 +121,6 @@ describe('Traefik Helpers', () => { const target = { a: 1, b: 2, c: 3 }; const source = { c: 30, a: 10, b: 20 }; const collisions = detectCollisions(target, source); - // Object.keys() order in modern JS follows insertion order expect(collisions).toEqual(['c', 'a', 'b']); }); });