]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
fix(simulator): reject a performance storage URI that is not an absolute URL (#2169)
authorVitor Coelho <6161176+VitorCoelho@users.noreply.github.com>
Fri, 30 Oct 2026 16:23:20 +0000 (16:23 +0000)
committerGitHub <noreply@github.com>
Fri, 30 Oct 2026 16:23:20 +0000 (16:23 +0000)
* 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 <vitorc195@gmail.com>
* test(utils): cover absolute performance storage URIs

Signed-off-by: VitorCoelho <vitorc195@gmail.com>
* 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 <vitorc195@gmail.com>
* test(utils): cover performanceStorage.uri only where the simulator parses it

Signed-off-by: VitorCoelho <vitorc195@gmail.com>
* 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 <vitorc195@gmail.com>
* 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 <vitorc195@gmail.com>
* 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 <vitorc195@gmail.com>
---------

Signed-off-by: VitorCoelho <vitorc195@gmail.com>
Co-authored-by: Jérôme Benoit <jerome.benoit@piment-noir.org>
src/utils/Configuration.ts
src/utils/ConfigurationMigrations.ts
src/utils/ConfigurationSchema.ts
src/utils/Constants.ts
tests/utils/ConfigurationSchema.test.ts

index 6d909c8fb597c0ed49e35bb1b0fd94859222e78d..03a1a7e78348b7c5ac4bc02d908635bd210db30e 100644 (file)
@@ -66,7 +66,7 @@ const defaultUIServerConfiguration: UIServerConfiguration = {
 }
 
 const defaultStorageConfiguration: StorageConfiguration = {
-  enabled: true,
+  enabled: Constants.DEFAULT_PERFORMANCE_STORAGE_ENABLED,
   type: StorageType.NONE,
 }
 
index f2e36da4e7ee7350e99483c46abb47cb958e9c46..e43032270b483e3f8082c08baadabec92aa400fc 100644 (file)
@@ -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
index ec10e0ffa3bdfada20297d7ae017f3abe631c573..0df1b567bbd57aa4d19dcc4a888617cc8efad6db 100644 (file)
@@ -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
index 0a492ff3c936539a2404ee68112e355b13135811..c9c7c33fc62299b34a9779032ccc6ab0aa18ee50 100644 (file)
@@ -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
index 524338b157343221bec3309df16658d905668469..9a1831cfad473d4c59d5946335646a52571e93ad 100644 (file)
@@ -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 () => {