From daad8a9d812aa4484ebdb5e50d0ec09daa6fbf3c Mon Sep 17 00:00:00 2001 From: =?utf8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Sun, 5 Jul 2026 22:21:38 +0200 Subject: [PATCH] =?utf8?q?refactor:=20complete=20barrel-gap=20scope=20?= =?utf8?q?=E2=80=94=20extract=20host-parsing=20utilities=20and=20expose=20?= =?utf8?q?AbstractUIServer=20(#1954)?= MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit refactor: complete barrel-gap scope — extract host-parsing utilities and expose AbstractUIServer Complete the scope that #1952 and #1953 left incomplete. Three coupled concerns addressed atomically to avoid further PR fragmentation: R1. Extract host-parsing utilities from ui-server internals to src/utils/HostUtils.ts: The `ui-server/UIServerNet.ts` file previously mixed pure host/IP parsing helpers (`LOOPBACK_HOSTNAME`, `isLoopback`, `normalizeIPAddress`, `normalizeHost`, `isHostLiteralWithoutPort`) with HTTP-header parsing helpers (`splitHeaderList`, `splitQuoted`). The host-parsing family is a pure utility, not UI-server-specific, and was consumed cross-layer by `src/utils/ConfigurationSchema.ts` (a Zod validator) — an inverted dependency (`utils/` importing from `charging-station/ui-server/`) that required a workaround comment documenting the TDZ (Temporal Dead Zone) cycle risk at UIServerNet.ts:3. Move the 5 host-parsing exports plus their private helpers (`HOSTNAME_PATTERN`, `isValidPort`, `parseIPv4MappedAddress`, `expandIPv6`, `isIPv6Group`) to `src/utils/HostUtils.ts`. The `ui-server/UIServerNet.ts` file now contains only the HTTP-header parsing helpers (`splitHeaderList`, `splitQuoted`) that legitimately belong to the ui-server access-policy layer. Callers updated: - ChargingStation ui-server internals (`AbstractUIServer.ts`, `UIServerAccessPolicy.ts`, `UIServerFactory.ts`) now import from `../../utils/index.js` (the utils barrel), each in a single alphabetically-sorted specifier block merged with the pre-existing utils imports from that file (no duplicate import statements). - `src/utils/ConfigurationSchema.ts` imports from `./HostUtils.js` directly (same directory, avoids barrel TDZ risk). - The utils barrel (`src/utils/index.ts`) re-exports the 5 host helpers alphabetically. Priority-3 anchor comments preserved verbatim in the moved private helpers (radix-16 rationale for `Number.parseInt` in `parseIPv4MappedAddress` and `expandIPv6`). R2. Expose AbstractUIServer as a type-only re-export from the ui-server barrel: Bootstrap.ts holds a `readonly uiServer: AbstractUIServer` field without subclassing the abstract base — a legitimate external type-reference consumer. Previously deep-imported via `./ui-server/AbstractUIServer.js`. Now available through the barrel via `export type { AbstractUIServer } from './AbstractUIServer.js'`. Bootstrap merges its two ui-server imports into a single barrel import. The ui-server barrel `@file` header updates: - Move `AbstractUIServer` from the "Deliberately not re-exported" list to a positive mention: "re-exported as a type-only symbol for external consumers (Bootstrap) that hold a reference to the selected concrete server without subclassing it". - Retitle the `UIServerNet` bullet: the host-parsing helpers no longer live here; the barrel now cross-references `src/utils/HostUtils.ts` so a reader chasing them does not wonder where they went. - Complete the "Deliberately not re-exported" enumeration to also name the omitted internal symbols from `UIServerSecurity` (`DEFAULT_MAX_PAYLOAD_SIZE_BYTES`, `PayloadTooLargeError`, `readLimitedBody`) and `UIServerUtils` (`isProtocolAndVersionSupported`) so the list is exhaustive. R3. Adjacent-sub-component cycle already resolved in #1953: the `broadcast-channel ↔ ui-server/ui-services` value-import back-edge remains a direct import (not via the barrel) per the codebase's established convention (`meter-values/index.ts`: "Internal helpers are intentionally not re-exported; tests and internal callers should import them directly from the owning sub-module"). No code change required for R3 in this PR. Test surface reorganized to match the code surface: - `tests/utils/HostUtils.test.ts` (new) — 9 `isLoopback` cases and 12 `normalizeHost` cases moved from the previous `tests/charging-station/ui-server/UIServerNet.test.ts`. Uses a direct module import (`../../src/utils/HostUtils.js`) rather than the utils barrel: the barrel load pulls Configuration singleton + Logger side effects into a pure-function test surface, which under the Node test runner produces "Promise resolution is still pending but the event loop has already resolved" hangs. Peer utility tests that need those side effects use the barrel; a pure host-parsing unit test does not. - `tests/charging-station/ui-server/UIServerNet.test.ts` retains only the 4 `splitHeaderList` cases. Quality gates: typecheck clean, lint clean, targeted tests pass (utils + ui-server + broadcast-channel suites: 705/705 pass, 0 fail). --- src/charging-station/Bootstrap.ts | 3 +- .../ui-server/AbstractUIServer.ts | 10 +- .../ui-server/UIServerAccessPolicy.ts | 9 +- .../ui-server/UIServerFactory.ts | 3 +- src/charging-station/ui-server/UIServerNet.ts | 141 ------------------ src/charging-station/ui-server/index.ts | 23 ++- src/utils/ConfigurationSchema.ts | 2 +- src/utils/HostUtils.ts | 139 +++++++++++++++++ src/utils/index.ts | 7 + .../ui-server/UIServerNet.test.ts | 107 +------------ tests/utils/HostUtils.test.ts | 115 ++++++++++++++ 11 files changed, 297 insertions(+), 262 deletions(-) create mode 100644 src/utils/HostUtils.ts create mode 100644 tests/utils/HostUtils.test.ts diff --git a/src/charging-station/Bootstrap.ts b/src/charging-station/Bootstrap.ts index c8828b38..40a00eb7 100644 --- a/src/charging-station/Bootstrap.ts +++ b/src/charging-station/Bootstrap.ts @@ -10,7 +10,6 @@ import { isMainThread } from 'node:worker_threads' import { availableParallelism, type MessageHandler } from 'poolifier' import type { IBootstrap } from './IBootstrap.js' -import type { AbstractUIServer } from './ui-server/AbstractUIServer.js' import packageJson from '../../package.json' with { type: 'json' } import { BaseError, TimeoutError } from '../exception/index.js' @@ -58,7 +57,7 @@ import { } from '../worker/index.js' import { readStateFile, reconstructTemplateIndexes, writeStateFile } from './BootstrapStateUtils.js' import { buildTemplateName, waitChargingStationEvents } from './Helpers.js' -import { UIServerFactory } from './ui-server/index.js' +import { type AbstractUIServer, UIServerFactory } from './ui-server/index.js' const moduleName = 'Bootstrap' diff --git a/src/charging-station/ui-server/AbstractUIServer.ts b/src/charging-station/ui-server/AbstractUIServer.ts index be1a7703..76f25639 100644 --- a/src/charging-station/ui-server/AbstractUIServer.ts +++ b/src/charging-station/ui-server/AbstractUIServer.ts @@ -26,7 +26,14 @@ import { type UIServerConfiguration, type UUIDv4, } from '../../types/index.js' -import { isEmpty, isNotEmptyArray, isNotEmptyString, logger, logPrefix } from '../../utils/index.js' +import { + isEmpty, + isLoopback, + isNotEmptyArray, + isNotEmptyString, + logger, + logPrefix, +} from '../../utils/index.js' import { UIServiceFactory } from './ui-services/UIServiceFactory.js' import { createUIServerAccessCache, @@ -34,7 +41,6 @@ import { type UIServerAccessCache, type UIServerAccessDecision, } from './UIServerAccessPolicy.js' -import { isLoopback } from './UIServerNet.js' import { createRateLimiter, DEFAULT_RATE_LIMIT, diff --git a/src/charging-station/ui-server/UIServerAccessPolicy.ts b/src/charging-station/ui-server/UIServerAccessPolicy.ts index 3c3670f1..c504af81 100644 --- a/src/charging-station/ui-server/UIServerAccessPolicy.ts +++ b/src/charging-station/ui-server/UIServerAccessPolicy.ts @@ -3,14 +3,15 @@ import type { IncomingMessage } from 'node:http' import type { UIServerConfiguration } from '../../types/index.js' import { UI_SERVER_ACCESS_POLICY_DEFAULTS } from '../../utils/ConfigurationSchema.js' -import { has, isEmpty, isNotEmptyArray } from '../../utils/index.js' import { + has, + isEmpty, isLoopback, + isNotEmptyArray, normalizeHost, normalizeIPAddress, - splitHeaderList, - splitQuoted, -} from './UIServerNet.js' +} from '../../utils/index.js' +import { splitHeaderList, splitQuoted } from './UIServerNet.js' const FORWARDED_HEADER_NAMES = [ 'forwarded', diff --git a/src/charging-station/ui-server/UIServerFactory.ts b/src/charging-station/ui-server/UIServerFactory.ts index 2a9d686c..015a1887 100644 --- a/src/charging-station/ui-server/UIServerFactory.ts +++ b/src/charging-station/ui-server/UIServerFactory.ts @@ -9,10 +9,9 @@ import { ConfigurationSection, type UIServerConfiguration, } from '../../types/index.js' -import { logger, logPrefix } from '../../utils/index.js' +import { isLoopback, logger, logPrefix } from '../../utils/index.js' import { UIHttpServer } from './UIHttpServer.js' import { UIMCPServer } from './UIMCPServer.js' -import { isLoopback } from './UIServerNet.js' import { UIWebSocketServer } from './UIWebSocketServer.js' const moduleName = 'UIServerFactory' diff --git a/src/charging-station/ui-server/UIServerNet.ts b/src/charging-station/ui-server/UIServerNet.ts index d493c014..a93ce187 100644 --- a/src/charging-station/ui-server/UIServerNet.ts +++ b/src/charging-station/ui-server/UIServerNet.ts @@ -1,106 +1,3 @@ -import { isIP } from 'node:net' - -// Direct path: the `utils/index.js` barrel re-exports ConfigurationSchema.ts which imports from this module, causing a TDZ cycle. -import { convertToInt } from '../../utils/Utils.js' - -export const LOOPBACK_HOSTNAME = 'localhost' - -export const isLoopback = (address: string): boolean => { - if (address.trim().toLowerCase() === LOOPBACK_HOSTNAME) { - return true - } - const normalizedAddress = normalizeIPAddress(address) - if (normalizedAddress == null) { - return false - } - if (normalizedAddress.family === 'ipv4') { - return normalizedAddress.value.split('.')[0] === '127' - } - const groups = normalizedAddress.value.split(':') - return groups.slice(0, -1).every(group => group === '0') && groups.at(-1) === '1' -} - -/** - * Parse an IP literal (IPv4, IPv6, or IPv4-mapped IPv6) into a normalized - * `family:value` pair. - * @param address The address to normalize. - * @returns The normalized IP literal, or `undefined` when not a valid IP. - */ -export const normalizeIPAddress = ( - address: string -): undefined | { family: 'ipv4' | 'ipv6'; value: string } => { - const host = normalizeHost(address) - if (host == null) { - return undefined - } - if (host === LOOPBACK_HOSTNAME) { - return { family: 'ipv4', value: '127.0.0.1' } - } - const ipv4MappedAddress = parseIPv4MappedAddress(host) - if (ipv4MappedAddress != null) { - return { family: 'ipv4', value: ipv4MappedAddress } - } - if (isIP(host) === 4) { - return { family: 'ipv4', value: host } - } - if (isIP(host) === 6) { - const groups = expandIPv6(host) - return groups != null ? { family: 'ipv6', value: groups.join(':') } : undefined - } - return undefined -} - -/** - * Strip a bracketed IPv6 wrapper and a trailing `:port` suffix, lowercase - * the result, and drop a single trailing dot. - * @param host The raw `Host` header or address value. - * @returns The bare host, or `undefined` when the input is malformed. - */ -export const normalizeHost = (host: string): string | undefined => { - const trimmedHost = host.trim().toLowerCase() - if (trimmedHost === '') { - return undefined - } - const bracketMatch = /^\[([^\]]+)](?::(\d+))?$/.exec(trimmedHost) - if (bracketMatch != null) { - return isValidPort(bracketMatch[2]) ? bracketMatch[1] : undefined - } - if (isIP(trimmedHost) === 6) { - return trimmedHost - } - const parts = trimmedHost.split(':') - if (parts.length > 2 || (parts.length === 2 && !isValidPort(parts[1]))) { - return undefined - } - const hostPart = parts[0].replace(/\.$/, '') - if (hostPart === '') { - return undefined - } - return HOSTNAME_PATTERN.test(hostPart) ? hostPart : undefined -} - -export const isHostLiteralWithoutPort = (value: string): boolean => { - const normalized = normalizeHost(value) - if (normalized == null) { - return false - } - const stripped = value.trim().toLowerCase().replace(/\.$/, '') - return stripped === normalized || stripped === `[${normalized}]` -} - -const HOSTNAME_PATTERN = /^[a-z0-9._-]+$/ - -const isValidPort = (port: string | undefined): boolean => { - if (port == null) { - return true - } - if (!/^\d+$/.test(port)) { - return false - } - const parsedPort = convertToInt(port) - return parsedPort >= 1 && parsedPort <= 65535 -} - /** * Split a comma-separated header list while honoring RFC 7239 / RFC 7230 * double-quoted values. Commas inside `"…"` are preserved. @@ -142,41 +39,3 @@ export const splitQuoted = (value: string, delimiter: string): string[] => { } return entries } - -const parseIPv4MappedAddress = (address: string): string | undefined => { - const dottedMatch = /^::ffff:(.+)$/i.exec(address) - if (dottedMatch != null && isIP(dottedMatch[1]) === 4) { - return dottedMatch[1] - } - const hexMatch = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(address) - if (hexMatch == null) { - return undefined - } - // radix-16 required for IPv4-mapped IPv6 hex octet parsing; convertToInt does not accept a radix - const high = Number.parseInt(hexMatch[1], 16) - const low = Number.parseInt(hexMatch[2], 16) - return [high >> 8, high & 0xff, low >> 8, low & 0xff].join('.') -} - -const expandIPv6 = (address: string): string[] | undefined => { - const sections = address.toLowerCase().split('::') - if (sections.length > 2) { - return undefined - } - const head = sections[0] === '' ? [] : sections[0].split(':') - const tail = sections.length === 1 || sections[1] === '' ? [] : sections[1].split(':') - const missingGroups = 8 - head.length - tail.length - if ((sections.length === 1 && missingGroups !== 0) || missingGroups < 0) { - return undefined - } - const groups = [...head, ...Array(missingGroups).fill('0'), ...tail] - if (groups.length !== 8) { - return undefined - } - // radix-16 required for IPv6 hex group normalization; convertToInt does not accept a radix - return groups.every(isIPv6Group) - ? groups.map(group => Number.parseInt(group, 16).toString(16)) - : undefined -} - -const isIPv6Group = (group: string): boolean => /^[0-9a-f]{1,4}$/i.test(group) diff --git a/src/charging-station/ui-server/index.ts b/src/charging-station/ui-server/index.ts index 015ae8bb..2d240f8d 100644 --- a/src/charging-station/ui-server/index.ts +++ b/src/charging-station/ui-server/index.ts @@ -5,19 +5,32 @@ * `HttpMethod` enum, the `DEFAULT_COMPRESSION_THRESHOLD_BYTES` canonical default, * plus the `AbstractUIService` base class and its `BroadcastChannelResponseLogContext` * type which are consumed as extension points across the broadcast-channel boundary. + * `AbstractUIServer` is re-exported as a type-only symbol for external consumers + * (`Bootstrap`) that hold a reference to the selected concrete server without + * subclassing it. * * Deliberately not re-exported: - * - `AbstractUIServer` — internal extension point subclassed only by the concrete - * servers within this sub-component; no external extenders exist. * - `UIHttpServer` — `@deprecated` pending removal; re-exporting through a fresh * barrel would surface the deprecation on every consumer via * `@typescript-eslint/no-deprecated`. - * - `UIServerAccessPolicy` / `UIServerNet` / `UIServerSecurity` helpers beyond - * `DEFAULT_COMPRESSION_THRESHOLD_BYTES` — internal to `UIServerFactory` and - * the concrete servers. + * - `UIServerAccessPolicy` — internal to the concrete servers. + * - `UIServerNet` (`splitHeaderList` / `splitQuoted`) — HTTP-header parsing + * internals used by `UIServerAccessPolicy` only. Host-parsing helpers that + * were previously here (`isLoopback`, `normalizeHost`, `normalizeIPAddress`, + * `isHostLiteralWithoutPort`, `LOOPBACK_HOSTNAME`) now live in + * `src/utils/HostUtils.ts` and are re-exported through `src/utils/index.ts`. + * - `UIServerSecurity` (`DEFAULT_MAX_PAYLOAD_SIZE_BYTES`, `PayloadTooLargeError`, + * `readLimitedBody`) — request-body handling internals consumed by the + * concrete servers only; `DEFAULT_COMPRESSION_THRESHOLD_BYTES` is the sole + * canonical default surfaced through this barrel. + * - `UIServerUtils` (`isProtocolAndVersionSupported`, protocol-negotiation + * helpers) — WebSocket protocol handshake internals consumed by + * `UIWebSocketServer` and `UIHttpServer` only; `HttpMethod` is the sole + * transport-agnostic enum surfaced through this barrel. * - `ui-services/UIService001` / `ui-services/UIServiceFactory` concrete * implementations — internal to `AbstractUIService`. */ +export type { AbstractUIServer } from './AbstractUIServer.js' export { AbstractUIService, type BroadcastChannelResponseLogContext, diff --git a/src/utils/ConfigurationSchema.ts b/src/utils/ConfigurationSchema.ts index 25d4b09f..e26aec4f 100644 --- a/src/utils/ConfigurationSchema.ts +++ b/src/utils/ConfigurationSchema.ts @@ -4,7 +4,6 @@ import type { ResourceLimits } from 'node:worker_threads' import { isIP } from 'node:net' import { z } from 'zod' -import { isHostLiteralWithoutPort } from '../charging-station/ui-server/UIServerNet.js' import { ApplicationProtocol, ApplicationProtocolVersion, @@ -14,6 +13,7 @@ import { } from '../types/index.js' import { WorkerProcessType } from '../worker/index.js' import { CURRENT_CONFIGURATION_SCHEMA_VERSION } from './ConfigurationMigrations.js' +import { isHostLiteralWithoutPort } from './HostUtils.js' import { has } from './Utils.js' // --------------------------------------------------------------- diff --git a/src/utils/HostUtils.ts b/src/utils/HostUtils.ts new file mode 100644 index 00000000..7b04f6e3 --- /dev/null +++ b/src/utils/HostUtils.ts @@ -0,0 +1,139 @@ +import { isIP } from 'node:net' + +import { convertToInt } from './Utils.js' + +export const LOOPBACK_HOSTNAME = 'localhost' + +export const isLoopback = (address: string): boolean => { + if (address.trim().toLowerCase() === LOOPBACK_HOSTNAME) { + return true + } + const normalizedAddress = normalizeIPAddress(address) + if (normalizedAddress == null) { + return false + } + if (normalizedAddress.family === 'ipv4') { + return normalizedAddress.value.split('.')[0] === '127' + } + const groups = normalizedAddress.value.split(':') + return groups.slice(0, -1).every(group => group === '0') && groups.at(-1) === '1' +} + +/** + * Parse an IP literal (IPv4, IPv6, or IPv4-mapped IPv6) into a normalized + * `family:value` pair. + * @param address The address to normalize. + * @returns The normalized IP literal, or `undefined` when not a valid IP. + */ +export const normalizeIPAddress = ( + address: string +): undefined | { family: 'ipv4' | 'ipv6'; value: string } => { + const host = normalizeHost(address) + if (host == null) { + return undefined + } + if (host === LOOPBACK_HOSTNAME) { + return { family: 'ipv4', value: '127.0.0.1' } + } + const ipv4MappedAddress = parseIPv4MappedAddress(host) + if (ipv4MappedAddress != null) { + return { family: 'ipv4', value: ipv4MappedAddress } + } + if (isIP(host) === 4) { + return { family: 'ipv4', value: host } + } + if (isIP(host) === 6) { + const groups = expandIPv6(host) + return groups != null ? { family: 'ipv6', value: groups.join(':') } : undefined + } + return undefined +} + +/** + * Strip a bracketed IPv6 wrapper and a trailing `:port` suffix, lowercase + * the result, and drop a single trailing dot. + * @param host The raw `Host` header or address value. + * @returns The bare host, or `undefined` when the input is malformed. + */ +export const normalizeHost = (host: string): string | undefined => { + const trimmedHost = host.trim().toLowerCase() + if (trimmedHost === '') { + return undefined + } + const bracketMatch = /^\[([^\]]+)](?::(\d+))?$/.exec(trimmedHost) + if (bracketMatch != null) { + return isValidPort(bracketMatch[2]) ? bracketMatch[1] : undefined + } + if (isIP(trimmedHost) === 6) { + return trimmedHost + } + const parts = trimmedHost.split(':') + if (parts.length > 2 || (parts.length === 2 && !isValidPort(parts[1]))) { + return undefined + } + const hostPart = parts[0].replace(/\.$/, '') + if (hostPart === '') { + return undefined + } + return HOSTNAME_PATTERN.test(hostPart) ? hostPart : undefined +} + +export const isHostLiteralWithoutPort = (value: string): boolean => { + const normalized = normalizeHost(value) + if (normalized == null) { + return false + } + const stripped = value.trim().toLowerCase().replace(/\.$/, '') + return stripped === normalized || stripped === `[${normalized}]` +} + +const HOSTNAME_PATTERN = /^[a-z0-9._-]+$/ + +const isValidPort = (port: string | undefined): boolean => { + if (port == null) { + return true + } + if (!/^\d+$/.test(port)) { + return false + } + const parsedPort = convertToInt(port) + return parsedPort >= 1 && parsedPort <= 65535 +} + +const parseIPv4MappedAddress = (address: string): string | undefined => { + const dottedMatch = /^::ffff:(.+)$/i.exec(address) + if (dottedMatch != null && isIP(dottedMatch[1]) === 4) { + return dottedMatch[1] + } + const hexMatch = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(address) + if (hexMatch == null) { + return undefined + } + // radix-16 required for IPv4-mapped IPv6 hex octet parsing; convertToInt does not accept a radix + const high = Number.parseInt(hexMatch[1], 16) + const low = Number.parseInt(hexMatch[2], 16) + return [high >> 8, high & 0xff, low >> 8, low & 0xff].join('.') +} + +const expandIPv6 = (address: string): string[] | undefined => { + const sections = address.toLowerCase().split('::') + if (sections.length > 2) { + return undefined + } + const head = sections[0] === '' ? [] : sections[0].split(':') + const tail = sections.length === 1 || sections[1] === '' ? [] : sections[1].split(':') + const missingGroups = 8 - head.length - tail.length + if ((sections.length === 1 && missingGroups !== 0) || missingGroups < 0) { + return undefined + } + const groups = [...head, ...Array(missingGroups).fill('0'), ...tail] + if (groups.length !== 8) { + return undefined + } + // radix-16 required for IPv6 hex group normalization; convertToInt does not accept a radix + return groups.every(isIPv6Group) + ? groups.map(group => Number.parseInt(group, 16).toString(16)) + : undefined +} + +const isIPv6Group = (group: string): boolean => /^[0-9a-f]{1,4}$/i.test(group) diff --git a/src/utils/index.ts b/src/utils/index.ts index c1db125d..435a341f 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -51,6 +51,13 @@ export { type AtomicWriteOptions, watchJsonFile, } from './FileUtils.js' +export { + isHostLiteralWithoutPort, + isLoopback, + LOOPBACK_HOSTNAME, + normalizeHost, + normalizeIPAddress, +} from './HostUtils.js' export { logger } from './Logger.js' export { buildAddedMessage, diff --git a/tests/charging-station/ui-server/UIServerNet.test.ts b/tests/charging-station/ui-server/UIServerNet.test.ts index 10b6e42e..03fac6c2 100644 --- a/tests/charging-station/ui-server/UIServerNet.test.ts +++ b/tests/charging-station/ui-server/UIServerNet.test.ts @@ -1,17 +1,12 @@ /** * @file Tests for UIServerNet - * @description Unit tests for IP literal normalization, loopback - * classification, host parsing, and quote-aware header tokenization. + * @description Unit tests for quote-aware header tokenization. */ import assert from 'node:assert/strict' import { afterEach, describe, it } from 'node:test' -import { - isLoopback, - normalizeHost, - splitHeaderList, -} from '../../../src/charging-station/ui-server/UIServerNet.js' +import { splitHeaderList } from '../../../src/charging-station/ui-server/UIServerNet.js' import { standardCleanup } from '../../helpers/TestLifecycleHelpers.js' await describe('UIServerNet', async () => { @@ -19,46 +14,6 @@ await describe('UIServerNet', async () => { standardCleanup() }) - await describe('isLoopback', async () => { - await it('should return true for localhost', () => { - assert.strictEqual(isLoopback('localhost'), true) - }) - - await it('should return true for 127.0.0.1', () => { - assert.strictEqual(isLoopback('127.0.0.1'), true) - }) - - await it('should return true for IPv4-mapped loopback addresses', () => { - assert.strictEqual(isLoopback('::ffff:127.0.0.1'), true) - assert.strictEqual(isLoopback('::ffff:7f00:1'), true) - }) - - await it('should return true for IPv6 loopback ::1', () => { - assert.strictEqual(isLoopback('::1'), true) - }) - - await it('should return true for full IPv6 loopback', () => { - assert.strictEqual(isLoopback('0000:0000:0000:0000:0000:0000:0000:0001'), true) - }) - - await it('should return false for external IPv4 address', () => { - assert.strictEqual(isLoopback('192.168.1.1'), false) - }) - - await it('should return false for invalid addresses', () => { - assert.strictEqual(isLoopback('127.999.999.999'), false) - assert.strictEqual(isLoopback('1'), false) - }) - - await it('should return true for bracketed IPv6 loopback with port', () => { - assert.strictEqual(isLoopback('[::1]:8080'), true) - }) - - await it('should return false for empty string', () => { - assert.strictEqual(isLoopback(''), false) - }) - }) - await describe('splitHeaderList', async () => { await it('should split unquoted comma-separated values', () => { assert.deepStrictEqual(splitHeaderList('a, b ,c'), ['a', 'b', 'c']) @@ -81,62 +36,4 @@ await describe('UIServerNet', async () => { ]) }) }) - - await describe('normalizeHost', async () => { - await it('should reject inputs with too many colons', () => { - assert.strictEqual(normalizeHost('a:b:c'), undefined) - }) - - await it('should reject inputs with non-numeric port', () => { - assert.strictEqual(normalizeHost('localhost:bad'), undefined) - }) - - await it('should reject bracketed inputs with non-numeric port', () => { - assert.strictEqual(normalizeHost('[::1]:abc'), undefined) - }) - - await it('should reject inputs with port out of range', () => { - assert.strictEqual(normalizeHost('[::1]:99999'), undefined) - }) - - await it('should reject inputs with port 0 (RFC 6335 reserved)', () => { - assert.strictEqual(normalizeHost('localhost:0'), undefined) - assert.strictEqual(normalizeHost('[::1]:0'), undefined) - }) - - await it('should reject inputs with characters outside hostname charset', () => { - assert.strictEqual(normalizeHost('a.example.com, b.example.com'), undefined) - assert.strictEqual(normalizeHost('foo bar'), undefined) - assert.strictEqual(normalizeHost('[bad'), undefined) - }) - - await it('should reject empty input', () => { - assert.strictEqual(normalizeHost(''), undefined) - assert.strictEqual(normalizeHost(' '), undefined) - }) - - await it('should accept hostname with optional port', () => { - assert.strictEqual(normalizeHost('gateway.example.com'), 'gateway.example.com') - assert.strictEqual(normalizeHost('gateway.example.com:8080'), 'gateway.example.com') - }) - - await it('should accept IPv4 literal with optional port', () => { - assert.strictEqual(normalizeHost('127.0.0.1'), '127.0.0.1') - assert.strictEqual(normalizeHost('127.0.0.1:8080'), '127.0.0.1') - }) - - await it('should accept bracketed IPv6 literal with optional port', () => { - assert.strictEqual(normalizeHost('[::1]'), '::1') - assert.strictEqual(normalizeHost('[::1]:8080'), '::1') - }) - - await it('should drop a single trailing dot', () => { - assert.strictEqual(normalizeHost('gateway.example.com.'), 'gateway.example.com') - assert.strictEqual(normalizeHost('localhost.:80'), 'localhost') - }) - - await it('should lowercase the result', () => { - assert.strictEqual(normalizeHost('Gateway.Example.COM'), 'gateway.example.com') - }) - }) }) diff --git a/tests/utils/HostUtils.test.ts b/tests/utils/HostUtils.test.ts new file mode 100644 index 00000000..560ed4d3 --- /dev/null +++ b/tests/utils/HostUtils.test.ts @@ -0,0 +1,115 @@ +/** + * @file Tests for HostUtils + * @description Unit tests for IP literal normalization, loopback classification, + * and host-parsing helpers. + */ + +import assert from 'node:assert/strict' +import { afterEach, describe, it } from 'node:test' + +import { isLoopback, normalizeHost } from '../../src/utils/HostUtils.js' +import { standardCleanup } from '../helpers/TestLifecycleHelpers.js' + +await describe('HostUtils', async () => { + afterEach(() => { + standardCleanup() + }) + + await describe('isLoopback', async () => { + await it('should return true for localhost', () => { + assert.strictEqual(isLoopback('localhost'), true) + }) + + await it('should return true for 127.0.0.1', () => { + assert.strictEqual(isLoopback('127.0.0.1'), true) + }) + + await it('should return true for IPv4-mapped loopback addresses', () => { + assert.strictEqual(isLoopback('::ffff:127.0.0.1'), true) + assert.strictEqual(isLoopback('::ffff:7f00:1'), true) + }) + + await it('should return true for IPv6 loopback ::1', () => { + assert.strictEqual(isLoopback('::1'), true) + }) + + await it('should return true for full IPv6 loopback', () => { + assert.strictEqual(isLoopback('0000:0000:0000:0000:0000:0000:0000:0001'), true) + }) + + await it('should return false for external IPv4 address', () => { + assert.strictEqual(isLoopback('192.168.1.1'), false) + }) + + await it('should return false for invalid addresses', () => { + assert.strictEqual(isLoopback('127.999.999.999'), false) + assert.strictEqual(isLoopback('1'), false) + }) + + await it('should return true for bracketed IPv6 loopback with port', () => { + assert.strictEqual(isLoopback('[::1]:8080'), true) + }) + + await it('should return false for empty string', () => { + assert.strictEqual(isLoopback(''), false) + }) + }) + + await describe('normalizeHost', async () => { + await it('should reject inputs with too many colons', () => { + assert.strictEqual(normalizeHost('a:b:c'), undefined) + }) + + await it('should reject inputs with non-numeric port', () => { + assert.strictEqual(normalizeHost('localhost:bad'), undefined) + }) + + await it('should reject bracketed inputs with non-numeric port', () => { + assert.strictEqual(normalizeHost('[::1]:abc'), undefined) + }) + + await it('should reject inputs with port out of range', () => { + assert.strictEqual(normalizeHost('[::1]:99999'), undefined) + }) + + await it('should reject inputs with port 0 (RFC 6335 reserved)', () => { + assert.strictEqual(normalizeHost('localhost:0'), undefined) + assert.strictEqual(normalizeHost('[::1]:0'), undefined) + }) + + await it('should reject inputs with characters outside hostname charset', () => { + assert.strictEqual(normalizeHost('a.example.com, b.example.com'), undefined) + assert.strictEqual(normalizeHost('foo bar'), undefined) + assert.strictEqual(normalizeHost('[bad'), undefined) + }) + + await it('should reject empty input', () => { + assert.strictEqual(normalizeHost(''), undefined) + assert.strictEqual(normalizeHost(' '), undefined) + }) + + await it('should accept hostname with optional port', () => { + assert.strictEqual(normalizeHost('gateway.example.com'), 'gateway.example.com') + assert.strictEqual(normalizeHost('gateway.example.com:8080'), 'gateway.example.com') + }) + + await it('should accept IPv4 literal with optional port', () => { + assert.strictEqual(normalizeHost('127.0.0.1'), '127.0.0.1') + assert.strictEqual(normalizeHost('127.0.0.1:8080'), '127.0.0.1') + }) + + await it('should accept bracketed IPv6 literal with optional port', () => { + assert.strictEqual(normalizeHost('[::1]'), '::1') + assert.strictEqual(normalizeHost('[::1]:8080'), '::1') + }) + + await it('should drop a single trailing dot', () => { + assert.strictEqual(normalizeHost('gateway.example.com.'), 'gateway.example.com') + assert.strictEqual(normalizeHost('localhost.:80'), 'localhost') + }) + + await it('should lowercase the result', () => { + assert.strictEqual(normalizeHost('Gateway.Example.COM'), 'gateway.example.com') + }) + }) +}) -- 2.53.0