]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
fix(config): tighten Zod validation on uiServer.options.port (#1874) (#1901)
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Mon, 15 Jun 2026 16:40:57 +0000 (18:40 +0200)
committerGitHub <noreply@github.com>
Mon, 15 Jun 2026 16:40:57 +0000 (18:40 +0200)
* fix(config): tighten Zod validation on uiServer.options.port (#1874)

Replace the permissive z.custom<ListenOptions> bridge under
uiServer.options with a typed object schema validating known primitive
fields, so bad transport-level values (e.g. port: "not-a-number")
fail at boot time in ConfigurationSchema.safeParse instead of later
inside node:net.Server.listen with ERR_SOCKET_BAD_PORT.

The existing accessPolicy refinement is preserved via .pipe(); unknown
keys are still passed through (.loose()) to keep the ListenOptions
extension surface (e.g. signal: AbortSignal) usable.

Validates:
- port: integer in [0, 65535]
- host: non-empty string
- backlog: non-negative integer
- path: non-empty string
- exclusive / ipv6Only / readableAll / writableAll: boolean

Tests: 10 new cases in tests/utils/ConfigurationSchema.test.ts and
1 integration assertion in tests/utils/ConfigurationValidation.test.ts
covering the structured Zod error path uiServer.options.port routed
through ConfigurationValidationError.

* docs(config): align JSDoc with composite uiServer.options schema

Address review comment on #1901: the JSDoc above
UIServerListenOptionsObjectSchema was named after the composite
UIServerListenOptionsSchema, leaving the helper described under the
wrong name and the composite undocumented. UIServerConfigurationSchema
also still referenced the now-removed permissive z.custom<ListenOptions>
bridge.

- UIServerListenOptionsObjectSchema gets its own JSDoc describing the
  typed object layer (.loose() + primitive constraints).
- UIServerListenOptionsSchema gets a JSDoc describing the full chain:
  object guard → accessPolicy refinement → pipe to typed object.
- UIServerConfigurationSchema JSDoc updated to reference the new
  composite schema instead of the obsolete custom bridge.

No behavior change, no test change.

src/utils/ConfigurationSchema.ts
tests/utils/ConfigurationSchema.test.ts
tests/utils/ConfigurationValidation.test.ts

index a9131e3be627955ce229a6ed12c5673cde44360a..cabf7ef6abd5e5ff73f334da0c1e1a86ed5afac0 100644 (file)
@@ -139,6 +139,32 @@ export const UIServerAccessPolicySchema = z
   })
   .strict()
 
+/**
+ * UIServerListenOptionsObjectSchema — typed object layer for `node:net`
+ * `ListenOptions`. Validates known primitive fields (port, host, backlog, ...)
+ * at boot time so that bad transport-level values (e.g. `port: "not-a-number"`)
+ * fail in `ConfigurationSchema.safeParse` rather than later in `Server.listen`.
+ * Unknown keys are passed through (`.loose()`) to preserve the `ListenOptions`
+ * extension surface (e.g. `signal: AbortSignal`).
+ */
+const UIServerListenOptionsObjectSchema = z
+  .object({
+    backlog: z.number().int().nonnegative().optional(),
+    exclusive: z.boolean().optional(),
+    host: z.string().min(1).optional(),
+    ipv6Only: z.boolean().optional(),
+    path: z.string().min(1).optional(),
+    port: z.number().int().min(0).max(65535).optional(),
+    readableAll: z.boolean().optional(),
+    writableAll: z.boolean().optional(),
+  })
+  .loose()
+
+/**
+ * UIServerListenOptionsSchema — composite schema for `uiServer.options`:
+ * non-array object guard → `accessPolicy` misplacement refinement → typed
+ * field validation via `UIServerListenOptionsObjectSchema`.
+ */
 const UIServerListenOptionsSchema = z
   .custom<ListenOptions>(
     value => value != null && typeof value === 'object' && !Array.isArray(value),
@@ -147,11 +173,13 @@ const UIServerListenOptionsSchema = z
   .refine(value => !Object.hasOwn(value as object, 'accessPolicy'), {
     message: "'accessPolicy' must be configured under 'uiServer', not 'uiServer.options'",
   })
+  .pipe(UIServerListenOptionsObjectSchema)
 
 /**
  * UIServerConfiguration — UI server configuration section.
- * `options` is structurally typed as `ListenOptions` from node:net; the schema
- * uses `z.custom<ListenOptions>()` to bridge the external surface.
+ * `options` is structurally typed as `ListenOptions` from node:net and
+ * validated by `UIServerListenOptionsSchema` (object guard → `accessPolicy`
+ * refinement → typed field validation).
  */
 export const UIServerConfigurationSchema = z
   .object({
index 6c0a4096029662b37a6b0ff46ec96dd2c8f198da..b73f9883280f4e09a9df6adbdd73f9cc6e68ac1f 100644 (file)
@@ -282,6 +282,126 @@ await describe('ConfigurationSchema', async () => {
       )
     })
 
+    await describe('uiServer.options.port', async () => {
+      await it('should reject non-numeric string port "not-a-number"', () => {
+        const result = ConfigurationSchema.safeParse(
+          buildMinimalConfiguration({
+            uiServer: { options: { host: 'localhost', port: 'not-a-number' } },
+          })
+        )
+        assert.ok(!result.success)
+        const paths = result.error.issues.map(i => i.path.join('.'))
+        assert.ok(
+          paths.includes('uiServer.options.port'),
+          `Expected error path 'uiServer.options.port' in ${JSON.stringify(paths)}`
+        )
+      })
+
+      await it('should reject negative port (-1)', () => {
+        const result = ConfigurationSchema.safeParse(
+          buildMinimalConfiguration({
+            uiServer: { options: { host: 'localhost', port: -1 } },
+          })
+        )
+        assert.ok(!result.success)
+        assert.ok(result.error.issues.some(i => i.path.join('.') === 'uiServer.options.port'))
+      })
+
+      await it('should reject port 65536 (out of range)', () => {
+        const result = ConfigurationSchema.safeParse(
+          buildMinimalConfiguration({
+            uiServer: { options: { host: 'localhost', port: 65536 } },
+          })
+        )
+        assert.ok(!result.success)
+        assert.ok(result.error.issues.some(i => i.path.join('.') === 'uiServer.options.port'))
+      })
+
+      await it('should reject non-integer port 3.14', () => {
+        const result = ConfigurationSchema.safeParse(
+          buildMinimalConfiguration({
+            uiServer: { options: { host: 'localhost', port: 3.14 } },
+          })
+        )
+        assert.ok(!result.success)
+        assert.ok(result.error.issues.some(i => i.path.join('.') === 'uiServer.options.port'))
+      })
+
+      await it('should accept port 8080', () => {
+        const result = ConfigurationSchema.safeParse(
+          buildMinimalConfiguration({
+            uiServer: { options: { host: 'localhost', port: 8080 } },
+          })
+        )
+        assert.ok(
+          result.success,
+          `Expected port 8080 to be accepted: ${result.success ? '' : JSON.stringify(result.error.issues)}`
+        )
+      })
+
+      await it('should accept port 0 (OS-picked port)', () => {
+        const result = ConfigurationSchema.safeParse(
+          buildMinimalConfiguration({
+            uiServer: { options: { host: 'localhost', port: 0 } },
+          })
+        )
+        assert.ok(
+          result.success,
+          `Expected port 0 to be accepted: ${result.success ? '' : JSON.stringify(result.error.issues)}`
+        )
+      })
+
+      await it('should accept port 65535 (max valid)', () => {
+        const result = ConfigurationSchema.safeParse(
+          buildMinimalConfiguration({
+            uiServer: { options: { host: 'localhost', port: 65535 } },
+          })
+        )
+        assert.ok(
+          result.success,
+          `Expected port 65535 to be accepted: ${result.success ? '' : JSON.stringify(result.error.issues)}`
+        )
+      })
+    })
+
+    await describe('uiServer.options.host', async () => {
+      await it('should reject empty host string', () => {
+        const result = ConfigurationSchema.safeParse(
+          buildMinimalConfiguration({
+            uiServer: { options: { host: '', port: 8080 } },
+          })
+        )
+        assert.ok(!result.success)
+        const paths = result.error.issues.map(i => i.path.join('.'))
+        assert.ok(
+          paths.includes('uiServer.options.host'),
+          `Expected error path 'uiServer.options.host' in ${JSON.stringify(paths)}`
+        )
+      })
+
+      await it('should accept host "localhost"', () => {
+        const result = ConfigurationSchema.safeParse(
+          buildMinimalConfiguration({
+            uiServer: { options: { host: 'localhost', port: 8080 } },
+          })
+        )
+        assert.ok(
+          result.success,
+          `Expected host 'localhost' to be accepted: ${result.success ? '' : JSON.stringify(result.error.issues)}`
+        )
+      })
+
+      await it('should reject non-string host', () => {
+        const result = ConfigurationSchema.safeParse(
+          buildMinimalConfiguration({
+            uiServer: { options: { host: 1234, port: 8080 } },
+          })
+        )
+        assert.ok(!result.success)
+        assert.ok(result.error.issues.some(i => i.path.join('.') === 'uiServer.options.host'))
+      })
+    })
+
     await it('should reject hostnames in trustedProxies', () => {
       const result = ConfigurationSchema.safeParse(
         buildMinimalConfiguration({
index 20b89fa3b40a0a174d02d7cc9d761bcf200dcb95..7f203756bfdbae5033420aa97800aa0049c0bdd5 100644 (file)
@@ -488,5 +488,30 @@ await describe('ConfigurationValidation', async () => {
         assert.strictEqual(error.message, EXPECTED_SNAPSHOT)
       }
     })
+
+    await it('should fail-fast with structured uiServer.options.port path on invalid port', () => {
+      const parsed = buildMinimalConfiguration({
+        uiServer: {
+          enabled: true,
+          options: { host: 'localhost', port: 'not-a-number' },
+          type: 'ws',
+        },
+      })
+      try {
+        validateConfiguration(parsed, 'bad-port.json')
+        assert.fail('Expected ConfigurationValidationError')
+      } catch (error) {
+        assert.ok(error instanceof ConfigurationValidationError)
+        assert.strictEqual(error.phase, 'schema')
+        const portErrors = error.fieldErrors.filter(e => e.path === 'uiServer.options.port')
+        assert.strictEqual(
+          portErrors.length,
+          1,
+          `Expected one fieldError on 'uiServer.options.port', got ${JSON.stringify(error.fieldErrors)}`
+        )
+        assert.match(error.message, /uiServer\.options\.port/)
+        assert.match(error.message, /\[schema\]/)
+      }
+    })
   })
 })