fix(config): report safe validation issues #25

Merged
eleith merged 1 commits from fix/config-diagnostics into main 2026-11-12 22:43:26 +00:00
12 changed files with 293 additions and 32 deletions

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
---
'@goto/web': patch
'@goto/worker': patch
'@goto/cli': patch
---
Report curated configuration issue paths and YAML locations in CLI and startup logs. Keep browser-facing errors generic and exclude configuration values, private dictionary keys, parser snippets and arbitrary error details.
+29 -2
View File
@@ -134,7 +134,29 @@ describe('goto-cli config validate', () => {
expect(await runCli(['config', 'validate'], output, { cwd, env: environment })).toBe(1);
expect(output.stdout).not.toHaveBeenCalled();
expect(output.stderr).toHaveBeenCalledExactlyOnceWith('Configuration validation failed.\n');
expect(output.stderr).toHaveBeenCalledExactlyOnceWith(
expect.stringMatching(/^Configuration validation failed\.\n/)
);
const diagnostic = output.stderr.mock.calls[0]?.[0];
for (const sensitive of [...Object.values(env), ...Object.values(environment)]) {
if (sensitive !== undefined) expect(diagnostic).not.toContain(sensitive);
}
expect(diagnostic).not.toContain(source);
expect(diagnostic).not.toContain(cwd);
expect(diagnostic).not.toContain('config/goto.yaml');
});
it('reports the site section for an unsupported site.url without printing its value', async () => {
const oldSiteUrl = 'https://old.example.test/private-do-not-print';
await writeConfiguration('config/goto.yaml', `${validYaml}site:\n url: ${oldSiteUrl}\n`);
const output = createOutput();
expect(await runCli(['config', 'validate'], output, { cwd, env })).toBe(1);
expect(output.stdout).not.toHaveBeenCalled();
expect(output.stderr).toHaveBeenCalledExactlyOnceWith(
expect.stringMatching(/^Configuration validation failed\.\n {2}\/site: \S[^\n]*\n$/)
);
expect(output.stderr.mock.calls[0]?.[0]).not.toContain(oldSiteUrl);
});
it('reports a missing file without disclosing the path', async () => {
@@ -144,7 +166,12 @@ describe('goto-cli config validate', () => {
await runCli(['config', 'validate', 'missing-private-path.yaml'], output, { cwd, env })
).toBe(1);
expect(output.stdout).not.toHaveBeenCalled();
expect(output.stderr).toHaveBeenCalledExactlyOnceWith('Configuration validation failed.\n');
expect(output.stderr).toHaveBeenCalledExactlyOnceWith(
'Configuration validation failed.\n /: Unable to read configuration file\n'
);
const diagnostic = output.stderr.mock.calls[0]?.[0];
expect(diagnostic).not.toContain('missing-private-path.yaml');
expect(diagnostic).not.toContain(cwd);
});
it.each([
+2 -2
View File
@@ -1,4 +1,4 @@
import { ConfigError, loadConfigFile, type ConfigOptions } from '@goto/config';
import { ConfigError, formatConfigError, loadConfigFile, type ConfigOptions } from '@goto/config';
import { cli, define, type CommandContext } from 'gunshi';
interface CliOutput {
@@ -57,7 +57,7 @@ export async function runCli(
} catch (error) {
const message =
error instanceof ConfigError
? 'Configuration validation failed.\n'
? `${formatConfigError(error, 'Configuration validation failed.')}\n`
: 'Command failed. Run goto-cli --help for usage.\n';
output.stderr(message);
return 1;
@@ -44,6 +44,7 @@ function prepared(
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.spyOn(console, 'error').mockImplementation(() => {});
vi.mocked(loadConfigFile).mockResolvedValue(prepared());
vi.mocked(createVisitRecorder).mockReturnValue({ recordVisit, stop: stopRecorder });
vi.mocked(startConfigReloading).mockImplementation(async (options) => {
@@ -157,6 +158,30 @@ test('invalid startup fails before registering a monitor', async () => {
vi.mocked(loadConfigFile).mockRejectedValue(new ConfigError());
await expect(createApplication(options)).rejects.toThrow('Invalid configuration');
expect(console.error).toHaveBeenCalledExactlyOnceWith('Invalid configuration');
expect(startConfigReloading).not.toHaveBeenCalled();
expect(createVisitRecorder).not.toHaveBeenCalled();
});
test('reports only curated configuration issues during startup', async () => {
const failure = new ConfigError([
{ path: '/url/app', message: 'Expected a canonical app origin' }
]);
failure.cause = new Error('private startup detail');
vi.mocked(loadConfigFile).mockRejectedValue(failure);
await expect(createApplication(options)).rejects.toBe(failure);
expect(console.error).toHaveBeenCalledExactlyOnceWith(
'Invalid configuration\n /url/app: Expected a canonical app origin'
);
expect(startConfigReloading).not.toHaveBeenCalled();
expect(createVisitRecorder).not.toHaveBeenCalled();
});
test('does not expose arbitrary startup errors as configuration diagnostics', async () => {
const failure = new Error('private connection detail');
vi.mocked(loadConfigFile).mockRejectedValue(failure);
await expect(createApplication(options)).rejects.toBe(failure);
expect(console.error).not.toHaveBeenCalled();
});
+6 -1
View File
@@ -2,6 +2,8 @@ import { isDeepStrictEqual } from 'node:util';
import { resolve } from 'node:path';
import type { NewVisit } from '@goto/db';
import {
ConfigError,
formatConfigError,
loadConfigFile,
resolveConfigPath,
startConfigReloading,
@@ -42,7 +44,10 @@ export async function createApplication(options: ConfigOptions = {}): Promise<Ap
cwd: options.cwd ?? process.cwd()
};
const path = resolveConfigPath(captured);
const initial = await loadConfigFile(path, captured);
const initial = await loadConfigFile(path, captured).catch((error: unknown) => {
if (error instanceof ConfigError) console.error(formatConfigError(error));
throw error;
});
const initialSettings = startupSettings(initial);
const startup = structuredClone(initialSettings);
let snapshot = prepareSnapshot(initial);
+7 -2
View File
@@ -1,4 +1,5 @@
import process from 'node:process';
import { ConfigError, formatConfigError } from '@goto/config';
import { startWorkerService, type WorkerService } from './services/startup.js';
import { logger } from './services/logger.js';
@@ -48,8 +49,12 @@ try {
});
}
}
} catch {
} catch (error) {
process.exitCode = 1;
logger.error('Unable to start worker service');
const message =
error instanceof ConfigError
? formatConfigError(error, 'Unable to start worker service')
: 'Unable to start worker service';
logger.error(message);
await shutdown();
}
+137
View File
@@ -0,0 +1,137 @@
import { describe, expect, it } from 'vitest';
import { configurationInput } from '../test/configuration.js';
import { ConfigError, formatConfigError, parseConfig, validateConfig } from './index.js';
const options = { env: {} };
const privateKey = 'private-authored-key-marker/~secret';
const privateValue = 'private-authored-value-marker';
function captureConfigError(action: () => unknown): ConfigError {
try {
action();
} catch (error) {
if (error instanceof ConfigError) return error;
throw error;
}
throw new Error('Expected configuration to fail');
}
describe('configuration diagnostics', () => {
it('keeps the error message generic and formats issues only when requested', () => {
const issues = [{ path: '/site', message: 'Expected object' }];
const error = new ConfigError(issues);
expect(error.message).toBe('Invalid configuration');
expect(String(error)).toBe('ConfigError: Invalid configuration');
expect(error.issues).toEqual(issues);
expect(error).not.toHaveProperty('cause');
expect(formatConfigError(error)).toBe('Invalid configuration\n /site: Expected object');
expect(formatConfigError(error, 'Configuration validation failed.')).toBe(
'Configuration validation failed.\n /site: Expected object'
);
expect(new ConfigError().issues).toEqual([]);
expect(formatConfigError(new ConfigError())).toBe('Invalid configuration');
expect(formatConfigError(new ConfigError(), 'Configuration validation failed.')).toBe(
'Configuration validation failed.'
);
});
it('identifies the site section and its allowed fields without echoing the old URL', () => {
const error = captureConfigError(() =>
validateConfig(
{
...configurationInput(),
site: { url: `https://${privateValue}.example.com` }
},
options
)
);
expect(error.message).toBe('Invalid configuration');
expect(error.issues).toEqual([
{
path: '/site',
message: 'Unexpected property. Allowed fields: title, logo_path, favicon_path'
}
]);
expect(JSON.stringify(error)).not.toContain(privateValue);
expect(formatConfigError(error)).not.toContain(privateValue);
});
it.each([
{ override: { [privateKey]: privateValue }, path: '/' },
{ override: { site: { [privateKey]: privateValue } }, path: '/site' }
])('omits unknown property names and values at $path', ({ override, path }) => {
const error = captureConfigError(() =>
validateConfig({ ...configurationInput(), ...override }, options)
);
expect(error.issues).toEqual([
{ path, message: expect.stringContaining('Unexpected property') }
]);
for (const output of [JSON.stringify(error), formatConfigError(error)]) {
expect(output).not.toContain('private-authored-key-marker');
expect(output).not.toContain(privateValue);
}
});
it('redacts domain and link keys and reports invalid resolved values without printing them', () => {
const error = captureConfigError(() =>
validateConfig(
{
...configurationInput(),
domains: {
'private-domain-marker.example.com': {
links: {
'/private-link-marker': {
url: '${DESTINATION}',
[privateKey]: privateValue
}
}
}
}
},
{ env: { DESTINATION: `https://user:${privateValue}@example.com` } }
)
);
expect(error.issues).toContainEqual({
path: '/domains/*/links/*/url',
message: "Expected string to match 'goto-link-url' format"
});
expect(error.issues).toContainEqual({
path: '/domains/*/links/*',
message: expect.stringContaining('Unexpected property. Allowed fields: url, title')
});
for (const output of [JSON.stringify(error), formatConfigError(error)]) {
for (const marker of [
'private-domain-marker',
'private-link-marker',
'private-authored-key-marker',
privateValue,
'${DESTINATION}'
]) {
expect(output).not.toContain(marker);
}
}
});
it('reports YAML codes and line positions without parser snippets or authored keys', () => {
const source = `${privateKey}: ${privateValue}\n${privateKey}: ${privateValue}`;
const error = captureConfigError(() => parseConfig(source, options));
expect(error.message).toBe('Invalid configuration');
expect(error.issues).toEqual([
{ path: '/', message: 'Invalid YAML (DUPLICATE_KEY) at line 2, column 1' }
]);
for (const output of [JSON.stringify(error), formatConfigError(error)]) {
expect(output).not.toContain('private-authored-key-marker');
expect(output).not.toContain(privateValue);
expect(output).not.toContain(source);
}
});
it('leaves errors without safe context generic', () => {
const error = captureConfigError(() =>
validateConfig({ ...configurationInput(), [privateKey]: '${MISSING}' }, options)
);
expect(error.issues).toEqual([]);
expect(formatConfigError(error)).toBe('Invalid configuration');
expect(JSON.stringify(error)).not.toContain('private-authored-key-marker');
});
});
+33 -1
View File
@@ -1,6 +1,38 @@
import { ValueErrorType, type ValueError } from '@sinclair/typebox/value';
export interface ConfigIssue {
path: string;
message: string;
}
export class ConfigError extends Error {
constructor() {
readonly issues: readonly ConfigIssue[];
constructor(issues: readonly ConfigIssue[] = []) {
super('Invalid configuration');
this.name = 'ConfigError';
this.issues = issues;
}
}
export function toConfigIssue(error: ValueError): ConfigIssue {
const segments = error.path.split('/').slice(1);
let message = error.message;
if (error.type === ValueErrorType.ObjectAdditionalProperties) {
segments.pop();
const allowedFields = Object.keys(error.schema.properties ?? {});
message =
allowedFields.length > 0
? `Unexpected property. Allowed fields: ${allowedFields.join(', ')}`
: 'Unexpected property';
}
if (segments[0] === 'domains') {
if (segments.length > 1) segments[1] = '*';
if (segments[2] === 'links' && segments.length > 3) segments[3] = '*';
}
return { path: `/${segments.join('/')}`, message };
}
export function formatConfigError(error: ConfigError, headline = error.message): string {
return [headline, ...error.issues.map((issue) => ` ${issue.path}: ${issue.message}`)].join('\n');
}
+1 -1
View File
@@ -1,4 +1,4 @@
export { ConfigError } from './errors.js';
export { ConfigError, formatConfigError, type ConfigIssue } from './errors.js';
export { loadConfigFile, parseConfig, validateConfig } from './load.js';
export { startConfigReloading } from './monitor.js';
export { isSelfRedirect } from './redirect.js';
+19 -8
View File
@@ -12,7 +12,7 @@ const yaml = 'auth: {credentials: {username: admin, password: test-password}}\nd
const authoredKeyMarker = 'dummy-authored-key-marker';
describe('configuration loading', () => {
it('parses YAML 1.2 and applies root defaults without mutating the input', () => {
it('parses YAML and applies root defaults without mutating the input', () => {
expect(parseConfig(`${yaml}\nsite: {title: yes}`, options).configuration.site.title).toBe(
'yes'
);
@@ -86,14 +86,18 @@ describe('configuration loading', () => {
expect(configuration.auth.secret).toBe(env.SECRET);
expect(() =>
validateConfig(input, { env: { ...env, WORKER_BASE: 'http://worker.example.com?secret' } })
).toThrowError(new ConfigError());
).toThrowError(
expect.objectContaining({ constructor: ConfigError, message: 'Invalid configuration' })
);
for (const destination of [
'https://private:secret@example.com',
'https://example.com/\r\nsecret'
]) {
expect(() =>
validateConfig(input, { env: { ...env, DESTINATION: destination } })
).toThrowError(new ConfigError());
).toThrowError(
expect.objectContaining({ constructor: ConfigError, message: 'Invalid configuration' })
);
}
expect(() =>
validateConfig({ ...input, auth: { ...input.auth, secret: '${MISSING}' } }, { env })
@@ -247,7 +251,6 @@ describe('configuration loading', () => {
`${yaml}\nversion: 1\nversion: 1`,
`${yaml}\n<<: {version: 1}`,
`${yaml}\n---\n${yaml}`,
`%YAML 1.1\n---\n${yaml}`,
`${yaml}\nsite: &cycle {title: *cycle}`
])('rejects non-strict YAML', (source) => {
expect(() => parseConfig(source, options)).toThrow(ConfigError);
@@ -263,9 +266,15 @@ describe('configuration loading', () => {
});
it('does not expose YAML parser snippets containing secrets', () => {
expect(() => parseConfig(`${yaml}\nauth: private-secret`, options)).toThrowError(
new ConfigError()
);
let error: unknown;
try {
parseConfig(`${yaml}\nauth: private-secret`, options);
} catch (caught) {
error = caught;
}
expect(error).toBeInstanceOf(ConfigError);
expect(error).toHaveProperty('message', 'Invalid configuration');
expect(JSON.stringify(error)).not.toContain('private-secret');
});
it('allows small aliases but bounds repeated expansion', () => {
@@ -350,7 +359,9 @@ describe('configuration loading', () => {
expect(explicit).toEqual(loaded);
await expect(
loadConfigFile('missing-private-file.yaml', { cwd: root, env: {} })
).rejects.toThrowError(new ConfigError());
).rejects.toThrowError(
expect.objectContaining({ constructor: ConfigError, message: 'Invalid configuration' })
);
} finally {
await rm(root, { recursive: true, force: true });
}
+24 -14
View File
@@ -1,8 +1,8 @@
import { readFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { Value } from '@sinclair/typebox/value';
import { parseAllDocuments } from 'yaml';
import { ConfigError } from './errors.js';
import { LineCounter, parseAllDocuments } from 'yaml';
import { ConfigError, toConfigIssue } from './errors.js';
import { assertArrayShapesPreserved, assertSafeInput } from './input.js';
import { interpolate } from './interpolate.js';
import { resolveConfigPath, resolveDeploymentRoot } from './paths.js';
@@ -20,7 +20,7 @@ export async function loadConfigFile(
try {
source = await readFile(configPath, 'utf8');
} catch {
throw new ConfigError();
throw new ConfigError([{ path: '/', message: 'Unable to read configuration file' }]);
}
const prepared = parseConfig(source, options);
const deploymentRoot = resolveDeploymentRoot(configPath);
@@ -33,8 +33,9 @@ export async function loadConfigFile(
export function parseConfig(source: string, options: ConfigOptions = {}): PreparedConfiguration {
let raw: unknown;
try {
const lineCounter = new LineCounter();
const documents = parseAllDocuments(source, {
version: '1.2',
lineCounter,
schema: 'core',
stringKeys: true,
uniqueKeys: true,
@@ -43,17 +44,23 @@ export function parseConfig(source: string, options: ConfigOptions = {}): Prepar
logLevel: 'silent'
});
const document = documents[0];
if (
documents.length !== 1 ||
!document ||
document.errors.length > 0 ||
document.warnings.length > 0 ||
document.directives?.yaml.version !== '1.2'
) {
throw new ConfigError();
if (documents.length !== 1 || !document) {
throw new ConfigError([{ path: '/', message: 'Expected one YAML document' }]);
}
const yamlErrors = [...document.errors, ...document.warnings];
if (yamlErrors.length > 0) {
const issues = yamlErrors.map((error) => {
const { line, col } = lineCounter.linePos(error.pos[0]);
return {
path: '/',
message: `Invalid YAML (${error.code}) at line ${line}, column ${col}`
};
});
throw new ConfigError(issues);
}
raw = document.toJS({ maxAliasCount: 100 });
} catch {
} catch (error) {
if (error instanceof ConfigError) throw error;
throw new ConfigError();
}
return validateConfig(raw, options);
@@ -69,7 +76,10 @@ export function validateConfig(raw: unknown, options: ConfigOptions = {}): Prepa
const defaulted = Value.Default(GotoConfigurationSchema, configurationReferences, expanded);
assertArrayShapesPreserved(raw, defaulted);
if (!Value.Check(GotoConfigurationSchema, configurationReferences, defaulted)) {
throw new ConfigError();
const issues = [
...Value.Errors(GotoConfigurationSchema, configurationReferences, defaulted)
].map(toConfigIssue);
throw new ConfigError(issues);
}
const configuration = defaulted;
const authoredDefaults =
+3 -1
View File
@@ -185,7 +185,9 @@ describe('effective domains', () => {
configurationInput({ '/docs': { url: 'https://example.com', [field]: instant } }),
options
)
).toThrowError(new ConfigError());
).toThrowError(
expect.objectContaining({ constructor: ConfigError, message: 'Invalid configuration' })
);
}
}
);