From a8f2f1749ac36aa1421e178274f895746053250c Mon Sep 17 00:00:00 2001 From: Vitor Coelho <6161176+VitorCoelho@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:23:20 +0000 Subject: [PATCH] fix(simulator): reject a performance storage URI that is not an absolute URL (#2169) MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit * fix(simulator): reject a performance storage URI that is not an absolute URL performanceStorage.uri (and its deprecated URI alias) accepted any string, but the simulator parses it with new URL() while building the configuration at module load and in the Storage constructor. A value such as "not a url" or a relative path like "performance/records.json" therefore crashed the simulator with a bare "TypeError: Invalid URL" that did not name the field. Validate the value with URL.canParse() in the configuration schema so it is reported as a configuration error on performanceStorage.uri (or performanceStorage.URI) instead. The narrowing is folded into the unreleased configuration schema version 2. Closes #2142 Signed-off-by: VitorCoelho * test(utils): cover absolute performance storage URIs Signed-off-by: VitorCoelho * fix(simulator): validate performanceStorage.uri only where the simulator parses it The simulator parses the URI at load time for the jsonfile and sqlite storage types and when it opens an enabled storage. Values the simulator never reads, including the deprecated URI alias, are left unvalidated so existing configurations keep loading. Signed-off-by: VitorCoelho * test(utils): cover performanceStorage.uri only where the simulator parses it Signed-off-by: VitorCoelho * fix(simulator): validate performanceStorage.uri for remote backends with enabled omitted The mongodb, mysql and mariadb backends inherit 'enabled: true' from the default storage configuration, so the storage is opened and the URI parsed unless 'enabled' is false. Treat an omitted 'enabled' as enabled for them. Signed-off-by: VitorCoelho * test(utils): cover performanceStorage.uri validation with enabled omitted mongodb, mysql and mariadb sections without 'enabled' now reject a relative uri; sections with 'enabled: false' and a bare '{ enabled: true }' section are still accepted. Signed-off-by: VitorCoelho * refactor(simulator): share the performance storage enabled default with the schema The schema resolved an omitted 'performanceStorage.enabled' with 'enabled !== false', which re-encodes the 'enabled: true' default of defaultStorageConfiguration in Configuration.ts. Move that boolean to Constants.DEFAULT_PERFORMANCE_STORAGE_ENABLED, use it in the runtime default and resolve the schema fallback with 'value.enabled ?? default', so a change to the canonical default is followed by validation. No behaviour change. Signed-off-by: VitorCoelho --------- Signed-off-by: VitorCoelho Co-authored-by: Jérôme Benoit --- src/utils/Configuration.ts | 2 +- src/utils/ConfigurationMigrations.ts | 3 +- src/utils/ConfigurationSchema.ts | 25 ++++++++++ src/utils/Constants.ts | 8 +++ tests/utils/ConfigurationSchema.test.ts | 65 +++++++++++++++++++++++++ 5 files changed, 101 insertions(+), 2 deletions(-) diff --git a/src/utils/Configuration.ts b/src/utils/Configuration.ts index 6d909c8f..03a1a7e7 100644 --- a/src/utils/Configuration.ts +++ b/src/utils/Configuration.ts @@ -66,7 +66,7 @@ const defaultUIServerConfiguration: UIServerConfiguration = { } const defaultStorageConfiguration: StorageConfiguration = { - enabled: true, + enabled: Constants.DEFAULT_PERFORMANCE_STORAGE_ENABLED, type: StorageType.NONE, } diff --git a/src/utils/ConfigurationMigrations.ts b/src/utils/ConfigurationMigrations.ts index f2e36da4..e4303227 100644 --- a/src/utils/ConfigurationMigrations.ts +++ b/src/utils/ConfigurationMigrations.ts @@ -226,7 +226,8 @@ const migrateV0ToV1: MigrationFn = (config, _filePath) => clone(config) /** * v1 → v2: pure version-bump. v2 narrows `supervisionUrls` to URLs whose - * scheme the `ws` client can dial; values outside that set cannot be + * scheme the `ws` client can dial and `performanceStorage.uri` to absolute + * URLs where the simulator parses it; values outside those sets cannot be * rewritten automatically and are reported by schema validation. * `$schemaVersion` is stamped by `applyConfigurationMigration`. * @param config - source configuration object diff --git a/src/utils/ConfigurationSchema.ts b/src/utils/ConfigurationSchema.ts index ec10e0ff..0df1b567 100644 --- a/src/utils/ConfigurationSchema.ts +++ b/src/utils/ConfigurationSchema.ts @@ -13,6 +13,7 @@ import { } from '../types/index.js' import { WorkerProcessType } from '../worker/index.js' import { CURRENT_CONFIGURATION_SCHEMA_VERSION } from './ConfigurationMigrations.js' +import { Constants } from './Constants.js' import { isHostLiteralWithoutPort } from './HostUtils.js' import { has, isNotEmptyArray } from './Utils.js' @@ -70,6 +71,14 @@ export const WorkerConfigurationSchema = z /** * StorageConfiguration — performance storage configuration section. * Legacy `URI` (uppercase) is accepted but not auto-migrated; canonical key is `uri`. + * `uri` is parsed with `new URL()` when the configuration is loaded for the + * `jsonfile` and `sqlite` types, and when the storage is opened at startup: + * the `mongodb`, `mysql` and `mariadb` backends are opened when `enabled` is + * true or omitted (an omitted `enabled` resolves to + * `Constants.DEFAULT_PERFORMANCE_STORAGE_ENABLED`, the same default the runtime + * storage configuration uses). It must then be an absolute URL: a relative file path would + * otherwise throw a bare `TypeError` at startup. Values the simulator never + * parses are left as they are. */ export const StorageConfigurationSchema = z .object({ @@ -79,6 +88,22 @@ export const StorageConfigurationSchema = z URI: z.string().optional().describe("@deprecated: use 'uri' instead"), }) .strict() + .superRefine((value, ctx) => { + if (value.uri == null || URL.canParse(value.uri)) return + const parsedAtLoad = value.type === StorageType.JSON_FILE || value.type === StorageType.SQLITE + const parsedAtOpen = + (value.enabled ?? Constants.DEFAULT_PERFORMANCE_STORAGE_ENABLED) && + value.type != null && + value.type !== StorageType.NONE + if (parsedAtLoad || parsedAtOpen) { + ctx.addIssue({ + code: 'custom', + message: + 'must be an absolute URL (e.g. file:///path/to/records.json or mongodb://host:27017/db)', + path: ['uri'], + }) + } + }) /** * UIServerAuthentication — credentials for the UI server. `username` is a diff --git a/src/utils/Constants.ts b/src/utils/Constants.ts index 0a492ff3..c9c7c33f 100644 --- a/src/utils/Constants.ts +++ b/src/utils/Constants.ts @@ -71,6 +71,14 @@ export class Constants { static readonly DEFAULT_PERFORMANCE_RECORDS_DB_NAME = 'e-mobility-charging-stations-simulator' static readonly DEFAULT_PERFORMANCE_RECORDS_FILENAME = 'performanceRecords.json' + /** + * Whether the performance storage is enabled when `performanceStorage.enabled` + * is omitted from the configuration. Shared by the runtime default storage + * configuration and the configuration schema so both resolve an omitted + * `enabled` the same way. + */ + static readonly DEFAULT_PERFORMANCE_STORAGE_ENABLED = true + /** * Peak jitter fraction: consumers scale the base delay by a uniform draw in * `[0, jitterPercent)`. See `computeExponentialBackOffDelay` (uni-directional diff --git a/tests/utils/ConfigurationSchema.test.ts b/tests/utils/ConfigurationSchema.test.ts index 524338b1..9a1831cf 100644 --- a/tests/utils/ConfigurationSchema.test.ts +++ b/tests/utils/ConfigurationSchema.test.ts @@ -1149,6 +1149,71 @@ await describe('ConfigurationSchema', async () => { ) assert.ok(result.success) }) + + await it('should accept performanceStorage.uri values the storage backends can parse', () => { + for (const uri of [ + 'file:///tmp/perf.json', + 'file:///tmp/e-mobility-charging-stations-simulator.db', + 'mongodb://localhost:27017/e-mobility-charging-stations-simulator', + ]) { + const result = ConfigurationSchema.safeParse( + buildMinimalConfiguration({ performanceStorage: { type: 'jsonfile', uri } }) + ) + assert.ok(result.success, uri) + } + }) + + await it('should reject a performanceStorage.uri that is not an absolute URL where it is parsed', () => { + const parsedSections = [ + { type: 'jsonfile' }, + { type: 'sqlite' }, + { enabled: false, type: 'jsonfile' }, + { enabled: true, type: 'mongodb' }, + { enabled: true, type: 'mysql' }, + { enabled: true, type: 'mariadb' }, + // `enabled` omitted: inherited as true for the remote backends. + { type: 'mongodb' }, + { type: 'mysql' }, + { type: 'mariadb' }, + ] + for (const section of parsedSections) { + for (const uri of ['not a url', 'performance/records.json', '/tmp/perf.json', '']) { + assert.throws(() => new URL(uri), TypeError, uri) + const result = ConfigurationSchema.safeParse( + buildMinimalConfiguration({ performanceStorage: { ...section, uri } }) + ) + assert.ok(!result.success, `${JSON.stringify(section)} ${uri}`) + assert.ok( + result.error.issues.some(i => i.path.join('.') === 'performanceStorage.uri'), + `${JSON.stringify(section)} ${uri}` + ) + } + } + }) + + await it('should leave a performanceStorage.uri the simulator never parses unchanged', () => { + for (const section of [ + { type: 'none' }, + { enabled: true, type: 'none' }, + { enabled: false, type: 'mongodb' }, + { enabled: false, type: 'mysql' }, + { enabled: false, type: 'mariadb' }, + {}, + { enabled: true }, + ]) { + const result = ConfigurationSchema.safeParse( + buildMinimalConfiguration({ performanceStorage: { ...section, uri: 'not a url' } }) + ) + assert.ok(result.success, JSON.stringify(section)) + } + }) + + await it('should leave the unused performanceStorage.URI deprecated alias unchanged', () => { + const result = ConfigurationSchema.safeParse( + buildMinimalConfiguration({ performanceStorage: { type: 'jsonfile', URI: 'not a url' } }) + ) + assert.ok(result.success) + }) }) await describe('round-trip on real config-template.json', async () => { -- 2.53.0