isValidRandomIntBounds,
logger,
sleep,
+ SupervisionUrlSchema,
truncateId,
} from '../../../utils/index.js'
import { AuthContext, type DifferentialAuthEntry, OCPPAuthServiceFactory } from '../auth/index.js'
const moduleName = 'OCPP16IncomingRequestService'
+/**
+ * Whether a value is a URL the supervision WebSocket client can dial once the
+ * charging station identity is appended to it. The endpoint policy (schemes,
+ * fragment and query delimiters, `ws+unix:` socket and request path) is the
+ * shared `SupervisionUrlSchema`. The OCPP key stores the value as sent, so
+ * whitespace and control characters are rejected here first: `z.url()` strips
+ * them from its output, but the raw value would still be dialed with them.
+ * @param value - Candidate supervision URL.
+ * @returns `true` when the raw value has no whitespace or control characters
+ * and satisfies `SupervisionUrlSchema`.
+ */
+const isSupportedSupervisionUrl = (value: string): boolean =>
+ // eslint-disable-next-line no-control-regex -- control characters are rejected on purpose
+ !/[\s\u0000-\u001F\u007F]/.test(value) && SupervisionUrlSchema.safeParse(value).success
+
const requireOCPP16ChargePointStatus = (status: string | undefined): OCPP16ChargePointStatus => {
const ocpp16Status = getEnumStringValue(OCPP16ChargePointStatus, status)
if (ocpp16Status == null) {
return OCPP16Constants.OCPP_CONFIGURATION_RESPONSE_REJECTED
}
}
+ // The supervision URL key is dialed by the WebSocket client on reconnect;
+ // reject a value it cannot dial instead of persisting it.
+ if (
+ chargingStation.stationInfo?.supervisionUrlOcppConfiguration === true &&
+ keyToChange.key === chargingStation.stationInfo.supervisionUrlOcppKey &&
+ !isSupportedSupervisionUrl(value)
+ ) {
+ return OCPP16Constants.OCPP_CONFIGURATION_RESPONSE_REJECTED
+ }
let valueChanged = false
if (keyToChange.value !== value) {
setConfigurationKeyValue(chargingStation, key, value, true)
assert.deepStrictEqual(restartedConnectorIds, [1, 3])
})
+ await describe('supervision URL OCPP configuration key', async () => {
+ const supervisionUrlOcppKey = 'ConnectionUrl'
+ const supervisionUrl = 'ws://localhost:9999/'
+
+ beforeEach(() => {
+ const { station } = testContext
+ if (station.stationInfo != null) {
+ station.stationInfo.supervisionUrlOcppConfiguration = true
+ station.stationInfo.supervisionUrlOcppKey = supervisionUrlOcppKey
+ }
+ upsertConfigurationKey(station, supervisionUrlOcppKey, supervisionUrl)
+ })
+
+ await it('should reject a non-URL value without mutating the key', () => {
+ // Arrange
+ const { station, testableService } = testContext
+ const request: ChangeConfigurationRequest = {
+ key: supervisionUrlOcppKey,
+ value: 'not a url',
+ }
+
+ // Act
+ const response = testableService.handleRequestChangeConfiguration(station, request)
+
+ // Assert
+ assert.strictEqual(response.status, OCPP16ConfigurationStatus.REJECTED)
+ assert.strictEqual(getConfigurationKey(station, supervisionUrlOcppKey)?.value, supervisionUrl)
+ })
+
+ await it('should reject a non-URL value via the changeConfiguration seam', () => {
+ // Arrange
+ const { incomingRequestService, station } = testContext
+
+ // Act
+ const status = incomingRequestService.changeConfiguration(
+ station,
+ supervisionUrlOcppKey,
+ 'not a url'
+ )
+
+ // Assert
+ assert.strictEqual(status, OCPP16ConfigurationStatus.REJECTED)
+ assert.strictEqual(getConfigurationKey(station, supervisionUrlOcppKey)?.value, supervisionUrl)
+ })
+
+ await it('should accept a valid URL value', () => {
+ // Arrange
+ const { station, testableService } = testContext
+ const request: ChangeConfigurationRequest = {
+ key: supervisionUrlOcppKey,
+ value: 'ws://localhost:8888/',
+ }
+
+ // Act
+ const response = testableService.handleRequestChangeConfiguration(station, request)
+
+ // Assert
+ assert.strictEqual(response.status, OCPP16ConfigurationStatus.ACCEPTED)
+ assert.strictEqual(
+ getConfigurationKey(station, supervisionUrlOcppKey)?.value,
+ 'ws://localhost:8888/'
+ )
+ })
+
+ await it('should reject a URL whose scheme the WebSocket client cannot dial', () => {
+ // Arrange
+ const { station, testableService } = testContext
+ const request: ChangeConfigurationRequest = {
+ key: supervisionUrlOcppKey,
+ value: 'ftp://localhost:8888/',
+ }
+
+ // Act
+ const response = testableService.handleRequestChangeConfiguration(station, request)
+
+ // Assert
+ assert.strictEqual(response.status, OCPP16ConfigurationStatus.REJECTED)
+ assert.strictEqual(getConfigurationKey(station, supervisionUrlOcppKey)?.value, supervisionUrl)
+ })
+
+ await it('should reject a URL the WebSocket client cannot dial despite a supported scheme', () => {
+ // Arrange
+ const { station, testableService } = testContext
+ const urls = [
+ 'ws://localhost:8888/#fragment',
+ 'ws://localhost:8888/#',
+ 'ws://localhost:8888/OCPP16?token=secret',
+ 'ws://localhost:8888/OCPP16?',
+ 'ws://localhost:8888 ',
+ ' ws://localhost:8888/',
+ 'ws://localhost:8888/OC\tPP16',
+ 'ws+unix://',
+ 'ws+unix:',
+ 'ws+unix:///tmp/simulator.sock',
+ 'ws+unix:///:/OCPP16',
+ 'ws+unix:///tmp/simulator.sock:/OC:PP16',
+ 'ws+unix:///tmp/simulator.sock:OCPP16',
+ 'ws+unix:s:OCPP16',
+ ]
+
+ for (const value of urls) {
+ // Act
+ const response = testableService.handleRequestChangeConfiguration(station, {
+ key: supervisionUrlOcppKey,
+ value,
+ })
+
+ // Assert
+ assert.strictEqual(response.status, OCPP16ConfigurationStatus.REJECTED, value)
+ assert.strictEqual(
+ getConfigurationKey(station, supervisionUrlOcppKey)?.value,
+ supervisionUrl,
+ value
+ )
+ }
+ })
+
+ await it('should accept every scheme the WebSocket client can dial', () => {
+ // Arrange
+ const { station, testableService } = testContext
+ const urls = [
+ 'ws://localhost:8888/',
+ 'wss://localhost:8888/',
+ 'http://localhost:8888/',
+ 'https://localhost:8888/',
+ 'ws+unix:///tmp/simulator.sock:/OCPP16',
+ 'ws+unix:///tmp/simulator.sock:/',
+ 'ws+unix:///tmp/simulator.sock:',
+ 'ws+unix:s:/OCPP16',
+ 'ws+unix:./s:/OCPP16',
+ ]
+
+ for (const value of urls) {
+ // Act
+ const response = testableService.handleRequestChangeConfiguration(station, {
+ key: supervisionUrlOcppKey,
+ value,
+ })
+
+ // Assert
+ assert.strictEqual(response.status, OCPP16ConfigurationStatus.ACCEPTED, value)
+ assert.strictEqual(getConfigurationKey(station, supervisionUrlOcppKey)?.value, value)
+ }
+ })
+ })
+
// ---------------------------------------------------------------------------
// changeConfiguration (local Web UI seam over §5.4)
// ---------------------------------------------------------------------------