--- /dev/null
+---
+name: qmd
+description: Search markdown knowledge bases, notes, and documentation using QMD. Use when users ask to search notes, find documents, or look up information.
+license: MIT
+compatibility: Requires qmd CLI or MCP server. Install via `npm install -g @tobilu/qmd`.
+metadata:
+ author: tobi
+ version: '2.0.0'
+allowed-tools: Bash(qmd:*), mcp__qmd__*
+---
+
+# QMD - Quick Markdown Search
+
+Local search engine for markdown content.
+
+## Status
+
+!`qmd status 2>/dev/null || echo "Not installed: npm install -g @tobilu/qmd"`
+
+## MCP: `query`
+
+```json
+{
+ "searches": [
+ { "type": "lex", "query": "CAP theorem consistency" },
+ { "type": "vec", "query": "tradeoff between consistency and availability" }
+ ],
+ "collections": ["docs"],
+ "limit": 10
+}
+```
+
+### Query Types
+
+| Type | Method | Input |
+| ------ | ------ | ------------------------------------------- |
+| `lex` | BM25 | Keywords — exact terms, names, code |
+| `vec` | Vector | Question — natural language |
+| `hyde` | Vector | Answer — hypothetical result (50-100 words) |
+
+### Writing Good Queries
+
+**lex (keyword)**
+
+- 2-5 terms, no filler words
+- Exact phrase: `"connection pool"` (quoted)
+- Exclude terms: `performance -sports` (minus prefix)
+- Code identifiers work: `handleError async`
+
+**vec (semantic)**
+
+- Full natural language question
+- Be specific: `"how does the rate limiter handle burst traffic"`
+- Include context: `"in the payment service, how are refunds processed"`
+
+**hyde (hypothetical document)**
+
+- Write 50-100 words of what the _answer_ looks like
+- Use the vocabulary you expect in the result
+
+**expand (auto-expand)**
+
+- Use a single-line query (implicit) or `expand: question` on its own line
+- Lets the local LLM generate lex/vec/hyde variations
+- Do not mix `expand:` with other typed lines — it's either a standalone expand query or a full query document
+
+### Intent (Disambiguation)
+
+When a query term is ambiguous, add `intent` to steer results:
+
+```json
+{
+ "searches": [{ "type": "lex", "query": "performance" }],
+ "intent": "web page load times and Core Web Vitals"
+}
+```
+
+Intent affects expansion, reranking, chunk selection, and snippet extraction. It does not search on its own — it's a steering signal that disambiguates queries like "performance" (web-perf vs team health vs fitness).
+
+### Combining Types
+
+| Goal | Approach |
+| --------------------- | ----------------------------------------------------- |
+| Know exact terms | `lex` only |
+| Don't know vocabulary | Use a single-line query (implicit `expand:`) or `vec` |
+| Best recall | `lex` + `vec` |
+| Complex topic | `lex` + `vec` + `hyde` |
+| Ambiguous query | Add `intent` to any combination above |
+
+First query gets 2x weight in fusion — put your best guess first.
+
+### Lex Query Syntax
+
+| Syntax | Meaning | Example |
+| ---------- | ------------ | ---------------------------- |
+| `term` | Prefix match | `perf` matches "performance" |
+| `"phrase"` | Exact phrase | `"rate limiter"` |
+| `-term` | Exclude | `performance -sports` |
+
+Note: `-term` only works in lex queries, not vec/hyde.
+
+### Collection Filtering
+
+```json
+{ "collections": ["docs"] } // Single
+{ "collections": ["docs", "notes"] } // Multiple (OR)
+```
+
+Omit to search all collections.
+
+## Other MCP Tools
+
+| Tool | Use |
+| ----------- | -------------------------------- |
+| `get` | Retrieve doc by path or `#docid` |
+| `multi_get` | Retrieve multiple by glob/list |
+| `status` | Collections and health |
+
+## CLI
+
+```bash
+qmd query "question" # Auto-expand + rerank
+qmd query $'lex: X\nvec: Y' # Structured
+qmd query $'expand: question' # Explicit expand
+qmd query --json --explain "q" # Show score traces (RRF + rerank blend)
+qmd search "keywords" # BM25 only (no LLM)
+qmd get "#abc123" # By docid
+qmd multi-get "journals/2026-*.md" -l 40 # Batch pull snippets by glob
+qmd multi-get notes/foo.md,notes/bar.md # Comma-separated list, preserves order
+```
+
+## HTTP API
+
+```bash
+curl -X POST http://localhost:8181/query \
+ -H "Content-Type: application/json" \
+ -d '{"searches": [{"type": "lex", "query": "test"}]}'
+```
+
+## Setup
+
+```bash
+npm install -g @tobilu/qmd
+qmd collection add ~/notes --name notes
+qmd embed
+```
--- /dev/null
+# QMD MCP Server Setup
+
+## Install
+
+```bash
+npm install -g @tobilu/qmd
+qmd collection add ~/path/to/markdown --name myknowledge
+qmd embed
+```
+
+## Configure MCP Client
+
+**Claude Code** (`~/.claude/settings.json`):
+
+```json
+{
+ "mcpServers": {
+ "qmd": { "command": "qmd", "args": ["mcp"] }
+ }
+}
+```
+
+**Claude Desktop** (`~/Library/Application Support/Claude/claude_desktop_config.json`):
+
+```json
+{
+ "mcpServers": {
+ "qmd": { "command": "qmd", "args": ["mcp"] }
+ }
+}
+```
+
+**OpenClaw** (`~/.openclaw/openclaw.json`):
+
+```json
+{
+ "mcp": {
+ "servers": {
+ "qmd": { "command": "qmd", "args": ["mcp"] }
+ }
+ }
+}
+```
+
+## HTTP Mode
+
+```bash
+qmd mcp --http # Port 8181
+qmd mcp --http --daemon # Background
+qmd mcp stop # Stop daemon
+```
+
+## Tools
+
+### structured_search
+
+Search with pre-expanded queries.
+
+```json
+{
+ "searches": [
+ { "type": "lex", "query": "keyword phrases" },
+ { "type": "vec", "query": "natural language question" },
+ { "type": "hyde", "query": "hypothetical answer passage..." }
+ ],
+ "limit": 10,
+ "collection": "optional",
+ "minScore": 0.0
+}
+```
+
+| Type | Method | Input |
+| ------ | ------ | ----------------------------- |
+| `lex` | BM25 | Keywords (2-5 terms) |
+| `vec` | Vector | Question |
+| `hyde` | Vector | Answer passage (50-100 words) |
+
+### get
+
+Retrieve document by path or `#docid`.
+
+| Param | Type | Description |
+| ------------- | ------ | --------------------- |
+| `path` | string | File path or `#docid` |
+| `full` | bool? | Return full content |
+| `lineNumbers` | bool? | Add line numbers |
+
+### multi_get
+
+Retrieve multiple documents.
+
+| Param | Type | Description |
+| ---------- | ------- | ------------------------------- |
+| `pattern` | string | Glob or comma-separated list |
+| `maxBytes` | number? | Skip large files (default 10KB) |
+
+### status
+
+Index health and collections. No params.
+
+## Troubleshooting
+
+- **Not starting**: `which qmd`, `qmd mcp` manually
+- **No results**: `qmd collection list`, `qmd embed`
+- **Slow first search**: Normal, models loading (~3GB)
--- /dev/null
+/Users/I339261/SAPDevelop/e-mobility-charging-stations-simulator-git-worktrees/feat-auth-cache/.agents/skills/qmd
\ No newline at end of file
#### Local Auth List Management Profile
-- :x: GetLocalListVersion
-- :x: SendLocalList
+- :white_check_mark: GetLocalListVersion
+- :white_check_mark: SendLocalList
#### Reservation Profile
#### D. LocalAuthorizationListManagement
-- :x: GetLocalListVersion
-- :x: SendLocalList
+- :white_check_mark: GetLocalListVersion
+- :white_check_mark: SendLocalList
#### E. Transactions
#### Local Auth List Management Profile
- :white_check_mark: LocalAuthListEnabled (type: boolean) (units: -)
-- :x: LocalAuthListMaxLength (type: integer) (units: -)
-- :x: SendLocalListMaxLength (type: integer) (units: -)
+- :white_check_mark: LocalAuthListMaxLength (type: integer) (units: -)
+- :white_check_mark: SendLocalListMaxLength (type: integer) (units: -)
#### Reservation Profile
-import { type ConnectorStatusTransition, OCPP16ChargePointStatus } from '../../../types/index.js'
+import {
+ type ConnectorStatusTransition,
+ OCPP16ChargePointStatus,
+ type OCPP16GetLocalListVersionResponse,
+ type OCPP16SendLocalListResponse,
+ OCPP16UpdateStatus,
+} from '../../../types/index.js'
import { OCPPConstants } from '../OCPPConstants.js'
export class OCPP16Constants extends OCPPConstants {
// { from: OCPP16ChargePointStatus.Faulted, to: OCPP16ChargePointStatus.Faulted }
])
- static readonly DEFAULT_IDTAG = '00000000'
+ static readonly OCPP_DEFAULT_IDTAG = '00000000'
+
+ static readonly OCPP_GET_LOCAL_LIST_VERSION_RESPONSE_NOT_SUPPORTED: OCPP16GetLocalListVersionResponse =
+ Object.freeze({ listVersion: -1 })
+
+ static readonly OCPP_SEND_LOCAL_LIST_RESPONSE_ACCEPTED: OCPP16SendLocalListResponse =
+ Object.freeze({ status: OCPP16UpdateStatus.ACCEPTED })
+
+ static readonly OCPP_SEND_LOCAL_LIST_RESPONSE_FAILED: OCPP16SendLocalListResponse = Object.freeze(
+ { status: OCPP16UpdateStatus.FAILED }
+ )
+
+ static readonly OCPP_SEND_LOCAL_LIST_RESPONSE_NOT_SUPPORTED: OCPP16SendLocalListResponse =
+ Object.freeze({ status: OCPP16UpdateStatus.NOT_SUPPORTED })
+
+ static readonly OCPP_SEND_LOCAL_LIST_RESPONSE_VERSION_MISMATCH: OCPP16SendLocalListResponse =
+ Object.freeze({ status: OCPP16UpdateStatus.VERSION_MISMATCH })
}
type OCPP16FirmwareStatusNotificationResponse,
type OCPP16GetCompositeScheduleRequest,
type OCPP16GetCompositeScheduleResponse,
+ type OCPP16GetLocalListVersionResponse,
type OCPP16HeartbeatRequest,
type OCPP16HeartbeatResponse,
OCPP16IncomingRequestCommand,
OCPP16RequestCommand,
type OCPP16ReserveNowRequest,
type OCPP16ReserveNowResponse,
+ type OCPP16SendLocalListRequest,
+ type OCPP16SendLocalListResponse,
OCPP16StandardParametersKey,
type OCPP16StartTransactionRequest,
type OCPP16StartTransactionResponse,
OCPP16TriggerMessageStatus,
type OCPP16UpdateFirmwareRequest,
type OCPP16UpdateFirmwareResponse,
+ OCPP16UpdateType,
type OCPPConfigurationKey,
OCPPVersion,
type RemoteStartTransactionRequest,
Configuration,
convertToDate,
convertToInt,
+ convertToIntOrNaN,
ensureError,
formatDurationMilliSeconds,
handleIncomingRequestError,
sleep,
truncateId,
} from '../../../utils/index.js'
-import { AuthContext } from '../auth/index.js'
+import { AuthContext, type DifferentialAuthEntry, OCPPAuthServiceFactory } from '../auth/index.js'
import { sendAndSetConnectorStatus } from '../OCPPConnectorStatusOperations.js'
import { OCPPConstants } from '../OCPPConstants.js'
import { OCPPIncomingRequestService } from '../OCPPIncomingRequestService.js'
OCPP16IncomingRequestCommand.GET_DIAGNOSTICS,
this.toRequestHandler(this.handleRequestGetDiagnostics.bind(this)),
],
+ [
+ OCPP16IncomingRequestCommand.GET_LOCAL_LIST_VERSION,
+ this.toRequestHandler(this.handleRequestGetLocalListVersion.bind(this)),
+ ],
[
OCPP16IncomingRequestCommand.REMOTE_START_TRANSACTION,
this.toRequestHandler(this.handleRequestRemoteStartTransaction.bind(this)),
OCPP16IncomingRequestCommand.RESET,
this.toRequestHandler(this.handleRequestReset.bind(this)),
],
+ [
+ OCPP16IncomingRequestCommand.SEND_LOCAL_LIST,
+ this.toRequestHandler(this.handleRequestSendLocalList.bind(this)),
+ ],
[
OCPP16IncomingRequestCommand.SET_CHARGING_PROFILE,
this.toRequestHandler(this.handleRequestSetChargingProfile.bind(this)),
}
}
+ /**
+ * Handles OCPP 1.6 GetLocalListVersion request from central system.
+ * Returns the version number of the local authorization list.
+ * @param chargingStation - The charging station instance processing the request
+ * @returns GetLocalListVersionResponse with list version
+ */
+ private handleRequestGetLocalListVersion (
+ chargingStation: ChargingStation
+ ): OCPP16GetLocalListVersionResponse {
+ if (
+ !OCPP16ServiceUtils.checkFeatureProfile(
+ chargingStation,
+ OCPP16SupportedFeatureProfiles.LocalAuthListManagement,
+ OCPP16IncomingRequestCommand.GET_LOCAL_LIST_VERSION
+ )
+ ) {
+ return OCPP16Constants.OCPP_GET_LOCAL_LIST_VERSION_RESPONSE_NOT_SUPPORTED
+ }
+ try {
+ const authService = OCPPAuthServiceFactory.getInstance(chargingStation)
+ const manager = authService.getLocalAuthListManager()
+ if (manager == null) {
+ return OCPP16Constants.OCPP_GET_LOCAL_LIST_VERSION_RESPONSE_NOT_SUPPORTED
+ }
+ return { listVersion: manager.getVersion() }
+ } catch (error) {
+ logger.error(
+ `${chargingStation.logPrefix()} ${moduleName}.handleRequestGetLocalListVersion: Error getting version:`,
+ error
+ )
+ return OCPP16Constants.OCPP_GET_LOCAL_LIST_VERSION_RESPONSE_NOT_SUPPORTED
+ }
+ }
+
/**
* Handles OCPP 1.6 RemoteStartTransaction request from central system
* Initiates charging transaction on specified or available connector
return OCPP16Constants.OCPP_RESPONSE_ACCEPTED
}
+ private handleRequestSendLocalList (
+ chargingStation: ChargingStation,
+ commandPayload: OCPP16SendLocalListRequest
+ ): OCPP16SendLocalListResponse {
+ if (
+ !OCPP16ServiceUtils.checkFeatureProfile(
+ chargingStation,
+ OCPP16SupportedFeatureProfiles.LocalAuthListManagement,
+ OCPP16IncomingRequestCommand.SEND_LOCAL_LIST
+ )
+ ) {
+ return OCPP16Constants.OCPP_SEND_LOCAL_LIST_RESPONSE_NOT_SUPPORTED
+ }
+ try {
+ const authService = OCPPAuthServiceFactory.getInstance(chargingStation)
+ if (!chargingStation.getLocalAuthListEnabled()) {
+ return OCPP16Constants.OCPP_SEND_LOCAL_LIST_RESPONSE_NOT_SUPPORTED
+ }
+ const manager = authService.getLocalAuthListManager()
+ if (manager == null) {
+ return OCPP16Constants.OCPP_SEND_LOCAL_LIST_RESPONSE_NOT_SUPPORTED
+ }
+ if (commandPayload.listVersion <= 0) {
+ return OCPP16Constants.OCPP_SEND_LOCAL_LIST_RESPONSE_FAILED
+ }
+ const sendLocalListMaxLength = getConfigurationKey(
+ chargingStation,
+ OCPP16StandardParametersKey.SendLocalListMaxLength
+ )
+ if (sendLocalListMaxLength?.value != null) {
+ const maxLength = convertToIntOrNaN(sendLocalListMaxLength.value)
+ if (
+ Number.isInteger(maxLength) &&
+ maxLength > 0 &&
+ commandPayload.localAuthorizationList != null &&
+ commandPayload.localAuthorizationList.length > maxLength
+ ) {
+ return OCPP16Constants.OCPP_SEND_LOCAL_LIST_RESPONSE_FAILED
+ }
+ }
+ const { listVersion, localAuthorizationList, updateType } = commandPayload
+ if (updateType === OCPP16UpdateType.Full) {
+ const entries = (localAuthorizationList ?? []).map(item => ({
+ expiryDate:
+ item.idTagInfo?.expiryDate != null
+ ? convertToDate(item.idTagInfo.expiryDate)
+ : undefined,
+ identifier: item.idTag,
+ parentId: item.idTagInfo?.parentIdTag,
+ status: item.idTagInfo?.status ?? OCPP16AuthorizationStatus.INVALID,
+ }))
+ manager.setEntries(entries, listVersion)
+ } else {
+ // OCPP 1.6 §5.15: For differential updates, version must be greater than current
+ const currentVersion = manager.getVersion()
+ if (listVersion <= currentVersion) {
+ return OCPP16Constants.OCPP_SEND_LOCAL_LIST_RESPONSE_VERSION_MISMATCH
+ }
+ const diffEntries: DifferentialAuthEntry[] = (localAuthorizationList ?? []).map(item => ({
+ expiryDate:
+ item.idTagInfo?.expiryDate != null
+ ? convertToDate(item.idTagInfo.expiryDate)
+ : undefined,
+ identifier: item.idTag,
+ parentId: item.idTagInfo?.parentIdTag,
+ status: item.idTagInfo?.status,
+ }))
+ manager.applyDifferentialUpdate(diffEntries, listVersion)
+ }
+ logger.info(
+ `${chargingStation.logPrefix()} ${moduleName}.handleRequestSendLocalList: Local auth list updated (${updateType}), version=${String(listVersion)}`
+ )
+ return OCPP16Constants.OCPP_SEND_LOCAL_LIST_RESPONSE_ACCEPTED
+ } catch (error) {
+ logger.error(
+ `${chargingStation.logPrefix()} ${moduleName}.handleRequestSendLocalList: Error updating local auth list:`,
+ error
+ )
+ return OCPP16Constants.OCPP_SEND_LOCAL_LIST_RESPONSE_FAILED
+ }
+ }
+
private handleRequestSetChargingProfile (
chargingStation: ChargingStation,
commandPayload: SetChargingProfileRequest
switch (commandName) {
case OCPP16RequestCommand.AUTHORIZE:
return {
- idTag: OCPP16Constants.DEFAULT_IDTAG,
+ idTag: OCPP16Constants.OCPP_DEFAULT_IDTAG,
...commandParams,
} as unknown as Request
case OCPP16RequestCommand.BOOT_NOTIFICATION:
return OCPP16Constants.OCPP_REQUEST_EMPTY as unknown as Request
case OCPP16RequestCommand.START_TRANSACTION:
return {
- idTag: OCPP16Constants.DEFAULT_IDTAG,
+ idTag: OCPP16Constants.OCPP_DEFAULT_IDTAG,
meterStart: chargingStation.getEnergyActiveImportRegisterByConnectorId(
commandParams.connectorId as number,
true
[OCPP16IncomingRequestCommand.GET_COMPOSITE_SCHEDULE, 'GetCompositeSchedule'],
[OCPP16IncomingRequestCommand.GET_CONFIGURATION, 'GetConfiguration'],
[OCPP16IncomingRequestCommand.GET_DIAGNOSTICS, 'GetDiagnostics'],
+ [OCPP16IncomingRequestCommand.GET_LOCAL_LIST_VERSION, 'GetLocalListVersion'],
[OCPP16IncomingRequestCommand.REMOTE_START_TRANSACTION, 'RemoteStartTransaction'],
[OCPP16IncomingRequestCommand.REMOTE_STOP_TRANSACTION, 'RemoteStopTransaction'],
[OCPP16IncomingRequestCommand.RESERVE_NOW, 'ReserveNow'],
[OCPP16IncomingRequestCommand.RESET, 'Reset'],
+ [OCPP16IncomingRequestCommand.SEND_LOCAL_LIST, 'SendLocalList'],
[OCPP16IncomingRequestCommand.SET_CHARGING_PROFILE, 'SetChargingProfile'],
[OCPP16IncomingRequestCommand.TRIGGER_MESSAGE, 'TriggerMessage'],
[OCPP16IncomingRequestCommand.UNLOCK_CONNECTOR, 'UnlockConnector'],
OCPP16DataTransferResponse,
OCPP16GetCompositeScheduleRequest,
OCPP16GetCompositeScheduleResponse,
+ OCPP16GetLocalListVersionResponse,
OCPP16RequestCommand,
OCPP16ReserveNowRequest,
OCPP16ReserveNowResponse,
+ OCPP16SendLocalListRequest,
+ OCPP16SendLocalListResponse,
OCPP16TriggerMessageRequest,
OCPP16TriggerMessageResponse,
OCPP16UpdateFirmwareRequest,
commandPayload: GetDiagnosticsRequest
) => Promise<GetDiagnosticsResponse>
+ handleRequestGetLocalListVersion: (
+ chargingStation: ChargingStation
+ ) => OCPP16GetLocalListVersionResponse
+
/**
* Handles OCPP 1.6 RemoteStartTransaction request from central system.
* Initiates charging transaction on specified or available connector.
commandPayload: ResetRequest
) => GenericResponse
+ handleRequestSendLocalList: (
+ chargingStation: ChargingStation,
+ commandPayload: OCPP16SendLocalListRequest
+ ) => OCPP16SendLocalListResponse
+
/**
* Handles OCPP 1.6 SetChargingProfile request from central system.
* Sets or updates a charging profile on a connector.
handleRequestGetCompositeSchedule: serviceImpl.handleRequestGetCompositeSchedule.bind(service),
handleRequestGetConfiguration: serviceImpl.handleRequestGetConfiguration.bind(service),
handleRequestGetDiagnostics: serviceImpl.handleRequestGetDiagnostics.bind(service),
+ handleRequestGetLocalListVersion: serviceImpl.handleRequestGetLocalListVersion.bind(service),
handleRequestRemoteStartTransaction:
serviceImpl.handleRequestRemoteStartTransaction.bind(service),
handleRequestRemoteStopTransaction:
serviceImpl.handleRequestRemoteStopTransaction.bind(service),
handleRequestReserveNow: serviceImpl.handleRequestReserveNow.bind(service),
handleRequestReset: serviceImpl.handleRequestReset.bind(service),
+ handleRequestSendLocalList: serviceImpl.handleRequestSendLocalList.bind(service),
handleRequestSetChargingProfile: serviceImpl.handleRequestSetChargingProfile.bind(service),
handleRequestTriggerMessage: serviceImpl.handleRequestTriggerMessage.bind(service),
handleRequestUnlockConnector: serviceImpl.handleRequestUnlockConnector.bind(service),
import { mkdir, readdir, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises'
import { join, resolve, sep } from 'node:path'
-import type { ChargingStation } from '../../ChargingStation.js'
+import type { ChargingStation } from '../../index.js'
import { BaseError } from '../../../exception/index.js'
import {
type ConnectorStatusTransition,
MessageTriggerEnumType,
OCPP20ConnectorStatusEnumType,
+ type OCPP20SendLocalListResponse,
+ OCPP20SendLocalListStatusEnumType,
OCPP20TriggerReasonEnumType,
} from '../../../types/index.js'
import { OCPPConstants } from '../OCPPConstants.js'
static readonly MAX_VARIABLE_VALUE_LENGTH = 2500
+ static readonly OCPP_SEND_LOCAL_LIST_RESPONSE_ACCEPTED: OCPP20SendLocalListResponse =
+ Object.freeze({
+ status: OCPP20SendLocalListStatusEnumType.Accepted,
+ })
+
+ static readonly OCPP_SEND_LOCAL_LIST_RESPONSE_FAILED: OCPP20SendLocalListResponse = Object.freeze(
+ {
+ status: OCPP20SendLocalListStatusEnumType.Failed,
+ }
+ )
+
+ static readonly OCPP_SEND_LOCAL_LIST_RESPONSE_VERSION_MISMATCH: OCPP20SendLocalListResponse =
+ Object.freeze({
+ status: OCPP20SendLocalListStatusEnumType.VersionMismatch,
+ })
+
static readonly RESET_DELAY_MS = 1000
static readonly RESET_IDLE_MONITOR_INTERVAL_MS = 5000
type JsonType,
LogStatusEnumType,
MessageTriggerEnumType,
+ OCPP20AuthorizationStatusEnumType,
type OCPP20BootNotificationRequest,
type OCPP20BootNotificationResponse,
type OCPP20CertificateSignedRequest,
type OCPP20GetBaseReportResponse,
type OCPP20GetInstalledCertificateIdsRequest,
type OCPP20GetInstalledCertificateIdsResponse,
+ type OCPP20GetLocalListVersionResponse,
type OCPP20GetLogRequest,
type OCPP20GetLogResponse,
type OCPP20GetTransactionStatusRequest,
type OCPP20ResetResponse,
type OCPP20SecurityEventNotificationRequest,
type OCPP20SecurityEventNotificationResponse,
+ type OCPP20SendLocalListRequest,
+ type OCPP20SendLocalListResponse,
+ OCPP20SendLocalListStatusEnumType,
type OCPP20SetNetworkProfileRequest,
type OCPP20SetNetworkProfileResponse,
type OCPP20SetVariablesRequest,
OCPP20TriggerReasonEnumType,
type OCPP20UnlockConnectorRequest,
type OCPP20UnlockConnectorResponse,
+ OCPP20UpdateEnumType,
type OCPP20UpdateFirmwareRequest,
type OCPP20UpdateFirmwareResponse,
OCPP20VendorVariableName,
truncateId,
validateUUID,
} from '../../../utils/index.js'
-import { buildConfigKey, getConfigurationKey } from '../../ConfigurationKeyUtils.js'
import {
+ addConfigurationKey,
+ buildConfigKey,
+ getConfigurationKey,
hasPendingReservation,
hasPendingReservations,
resetConnectorStatus,
-} from '../../Helpers.js'
+} from '../../index.js'
import {
AuthContext,
AuthorizationStatus,
+ type DifferentialAuthEntry,
+ type LocalAuthEntry,
mapOCPP20TokenType,
OCPPAuthServiceFactory,
} from '../auth/index.js'
OCPP20IncomingRequestCommand.GET_INSTALLED_CERTIFICATE_IDS,
this.toRequestHandler(this.handleRequestGetInstalledCertificateIds.bind(this)),
],
+ [
+ OCPP20IncomingRequestCommand.GET_LOCAL_LIST_VERSION,
+ this.toRequestHandler(this.handleRequestGetLocalListVersion.bind(this)),
+ ],
[
OCPP20IncomingRequestCommand.GET_LOG,
this.toRequestHandler(this.handleRequestGetLog.bind(this)),
OCPP20IncomingRequestCommand.RESET,
this.toRequestHandler(this.handleRequestReset.bind(this)),
],
+ [
+ OCPP20IncomingRequestCommand.SEND_LOCAL_LIST,
+ this.toRequestHandler(this.handleRequestSendLocalList.bind(this)),
+ ],
[
OCPP20IncomingRequestCommand.SET_NETWORK_PROFILE,
this.toRequestHandler(this.handleRequestSetNetworkProfile.bind(this)),
}
}
+ /**
+ * Handles OCPP 2.0.1 GetLocalListVersion request.
+ * Returns the current version number of the local authorization list.
+ * Per D01.FR.03: Returns 0 when local auth list is not enabled or not available.
+ * @param chargingStation - The charging station instance
+ * @returns GetLocalListVersionResponse
+ */
+ protected handleRequestGetLocalListVersion (
+ chargingStation: ChargingStation
+ ): OCPP20GetLocalListVersionResponse {
+ try {
+ if (!chargingStation.getLocalAuthListEnabled()) {
+ return { versionNumber: 0 }
+ }
+ const authService = OCPPAuthServiceFactory.getInstance(chargingStation)
+ const manager = authService.getLocalAuthListManager()
+ if (manager == null) {
+ logger.info(
+ `${chargingStation.logPrefix()} ${moduleName}.handleRequestGetLocalListVersion: No local auth list manager, returning version 0`
+ )
+ return { versionNumber: 0 }
+ }
+ const version = manager.getVersion()
+ logger.debug(
+ `${chargingStation.logPrefix()} ${moduleName}.handleRequestGetLocalListVersion: Returning version ${version.toString()}`
+ )
+ return { versionNumber: version }
+ } catch (error) {
+ logger.error(
+ `${chargingStation.logPrefix()} ${moduleName}.handleRequestGetLocalListVersion: Error getting version:`,
+ error
+ )
+ return { versionNumber: 0 }
+ }
+ }
+
+ /**
+ * Handles OCPP 2.0.1 SendLocalList request.
+ * Applies full or differential updates to the local authorization list.
+ * Per D02.FR.01: Returns Failed if LocalAuthListCtrlr is not enabled.
+ * @param chargingStation - The charging station instance
+ * @param commandPayload - SendLocalList request payload
+ * @returns SendLocalListResponse
+ */
+ protected handleRequestSendLocalList (
+ chargingStation: ChargingStation,
+ commandPayload: OCPP20SendLocalListRequest
+ ): OCPP20SendLocalListResponse {
+ try {
+ const authService = OCPPAuthServiceFactory.getInstance(chargingStation)
+ if (!chargingStation.getLocalAuthListEnabled()) {
+ logger.info(
+ `${chargingStation.logPrefix()} ${moduleName}.handleRequestSendLocalList: Local auth list disabled, returning Failed`
+ )
+ return {
+ status: OCPP20SendLocalListStatusEnumType.Failed,
+ statusInfo: { reasonCode: ReasonCodeEnumType.NotEnabled },
+ }
+ }
+ const manager = authService.getLocalAuthListManager()
+ if (manager == null) {
+ logger.error(
+ `${chargingStation.logPrefix()} ${moduleName}.handleRequestSendLocalList: No local auth list manager available`
+ )
+ return {
+ status: OCPP20SendLocalListStatusEnumType.Failed,
+ statusInfo: {
+ additionalInfo: 'Local auth list manager unavailable',
+ reasonCode: ReasonCodeEnumType.InternalError,
+ },
+ }
+ }
+ if (commandPayload.versionNumber <= 0) {
+ return OCPP20Constants.OCPP_SEND_LOCAL_LIST_RESPONSE_FAILED
+ }
+
+ const { localAuthorizationList, updateType, versionNumber } = commandPayload
+
+ const itemsPerMessageKey = getConfigurationKey(
+ chargingStation,
+ buildConfigKey(
+ OCPP20ComponentName.LocalAuthListCtrlr,
+ OCPP20RequiredVariableName.ItemsPerMessage
+ )
+ )
+ if (itemsPerMessageKey?.value != null) {
+ const itemsPerMessage = convertToIntOrNaN(itemsPerMessageKey.value)
+ if (
+ Number.isInteger(itemsPerMessage) &&
+ itemsPerMessage > 0 &&
+ localAuthorizationList != null &&
+ localAuthorizationList.length > itemsPerMessage
+ ) {
+ return OCPP20Constants.OCPP_SEND_LOCAL_LIST_RESPONSE_FAILED
+ }
+ }
+
+ if (updateType === OCPP20UpdateEnumType.Full) {
+ const entries: LocalAuthEntry[] = (localAuthorizationList ?? []).map(item => ({
+ expiryDate:
+ item.idTokenInfo?.cacheExpiryDateTime != null
+ ? convertToDate(item.idTokenInfo.cacheExpiryDateTime)
+ : undefined,
+ identifier: item.idToken.idToken,
+ metadata: { idTokenType: item.idToken.type },
+ status: item.idTokenInfo?.status ?? OCPP20AuthorizationStatusEnumType.Invalid,
+ }))
+ manager.setEntries(entries, versionNumber)
+ } else {
+ // D02.FR.08: For differential updates, version must be greater than current
+ const currentVersion = manager.getVersion()
+ if (versionNumber <= currentVersion) {
+ return OCPP20Constants.OCPP_SEND_LOCAL_LIST_RESPONSE_VERSION_MISMATCH
+ }
+ const diffEntries: DifferentialAuthEntry[] = (localAuthorizationList ?? []).map(item => ({
+ expiryDate:
+ item.idTokenInfo?.cacheExpiryDateTime != null
+ ? convertToDate(item.idTokenInfo.cacheExpiryDateTime)
+ : undefined,
+ identifier: item.idToken.idToken,
+ metadata: { idTokenType: item.idToken.type },
+ status: item.idTokenInfo?.status,
+ }))
+ manager.applyDifferentialUpdate(diffEntries, versionNumber)
+ }
+
+ logger.info(
+ `${chargingStation.logPrefix()} ${moduleName}.handleRequestSendLocalList: Local auth list updated (${updateType}), version=${versionNumber.toString()}`
+ )
+ addConfigurationKey(
+ chargingStation,
+ buildConfigKey(OCPP20ComponentName.LocalAuthListCtrlr, OCPP20RequiredVariableName.Entries),
+ manager.getAllEntries().length.toString(),
+ { readonly: true },
+ { overwrite: true, save: false }
+ )
+ return OCPP20Constants.OCPP_SEND_LOCAL_LIST_RESPONSE_ACCEPTED
+ } catch (error) {
+ logger.error(
+ `${chargingStation.logPrefix()} ${moduleName}.handleRequestSendLocalList: Error updating local auth list:`,
+ error
+ )
+ return OCPP20Constants.OCPP_SEND_LOCAL_LIST_RESPONSE_FAILED
+ }
+ }
+
/**
* Check whether an incoming request command is supported by the charging station.
* @param chargingStation - Target charging station
logger,
validateIdentifierString,
} from '../../../utils/index.js'
-import { buildConfigKey, getConfigurationKey } from '../../ConfigurationKeyUtils.js'
+import { buildConfigKey, getConfigurationKey } from '../../index.js'
import {
mapOCPP20AuthorizationStatus,
mapOCPP20TokenType,
[OCPP20IncomingRequestCommand.DELETE_CERTIFICATE, 'DeleteCertificate'],
[OCPP20IncomingRequestCommand.GET_BASE_REPORT, 'GetBaseReport'],
[OCPP20IncomingRequestCommand.GET_INSTALLED_CERTIFICATE_IDS, 'GetInstalledCertificateIds'],
+ [OCPP20IncomingRequestCommand.GET_LOCAL_LIST_VERSION, 'GetLocalListVersion'],
[OCPP20IncomingRequestCommand.GET_LOG, 'GetLog'],
[OCPP20IncomingRequestCommand.GET_TRANSACTION_STATUS, 'GetTransactionStatus'],
[OCPP20IncomingRequestCommand.GET_VARIABLES, 'GetVariables'],
[OCPP20IncomingRequestCommand.REQUEST_START_TRANSACTION, 'RequestStartTransaction'],
[OCPP20IncomingRequestCommand.REQUEST_STOP_TRANSACTION, 'RequestStopTransaction'],
[OCPP20IncomingRequestCommand.RESET, 'Reset'],
+ [OCPP20IncomingRequestCommand.SEND_LOCAL_LIST, 'SendLocalList'],
[OCPP20IncomingRequestCommand.SET_NETWORK_PROFILE, 'SetNetworkProfile'],
[OCPP20IncomingRequestCommand.SET_VARIABLES, 'SetVariables'],
[OCPP20IncomingRequestCommand.TRIGGER_MESSAGE, 'TriggerMessage'],
type VariableType,
} from '../../../types/index.js'
import { Constants, convertToIntOrNaN, isEmpty, logger } from '../../../utils/index.js'
-import { type ChargingStation } from '../../ChargingStation.js'
import {
addConfigurationKey,
buildConfigKey,
+ type ChargingStation,
getConfigurationKey,
setConfigurationKeyValue,
-} from '../../ConfigurationKeyUtils.js'
+} from '../../index.js'
import { OCPP20Constants } from './OCPP20Constants.js'
import {
applyPostProcess,
import { millisecondsToSeconds } from 'date-fns'
-import type { ChargingStation } from '../../ChargingStation.js'
+import type { ChargingStation } from '../../index.js'
import {
AttributeEnumType,
supportedAttributes: [AttributeEnumType.Actual],
variable: 'DisablePostAuthorize',
},
- [buildRegistryKey(OCPP20ComponentName.LocalAuthListCtrlr, 'Entries')]: {
- component: OCPP20ComponentName.LocalAuthListCtrlr,
- dataType: DataEnumType.integer,
- defaultValue: '0',
- description: 'Amount of IdTokens currently in the Local Authorization List',
- min: 0,
- mutability: MutabilityEnumType.ReadOnly,
- persistence: PersistenceEnumType.Volatile,
- required: true,
- supportedAttributes: [AttributeEnumType.Actual],
- variable: 'Entries',
- },
[buildRegistryKey(OCPP20ComponentName.LocalAuthListCtrlr, 'Storage')]: {
characteristics: {
maxLimit: 1048576, // 1MB default
supportedAttributes: [AttributeEnumType.Actual],
variable: OCPP20RequiredVariableName.Enabled,
},
+ [buildRegistryKey(OCPP20ComponentName.LocalAuthListCtrlr, OCPP20RequiredVariableName.Entries)]: {
+ component: OCPP20ComponentName.LocalAuthListCtrlr,
+ dataType: DataEnumType.integer,
+ defaultValue: '0',
+ description: 'Amount of IdTokens currently in the Local Authorization List',
+ min: 0,
+ mutability: MutabilityEnumType.ReadOnly,
+ persistence: PersistenceEnumType.Volatile,
+ required: true,
+ supportedAttributes: [AttributeEnumType.Actual],
+ variable: OCPP20RequiredVariableName.Entries,
+ },
[buildRegistryKey(
OCPP20ComponentName.LocalAuthListCtrlr,
OCPP20RequiredVariableName.ItemsPerMessage
OCPP20GetBaseReportResponse,
OCPP20GetInstalledCertificateIdsRequest,
OCPP20GetInstalledCertificateIdsResponse,
+ OCPP20GetLocalListVersionResponse,
OCPP20GetLogRequest,
OCPP20GetLogResponse,
OCPP20GetTransactionStatusRequest,
OCPP20RequestStopTransactionResponse,
OCPP20ResetRequest,
OCPP20ResetResponse,
+ OCPP20SendLocalListRequest,
+ OCPP20SendLocalListResponse,
OCPP20SetNetworkProfileRequest,
OCPP20SetNetworkProfileResponse,
OCPP20SetVariablesRequest,
commandPayload: OCPP20GetInstalledCertificateIdsRequest
) => Promise<OCPP20GetInstalledCertificateIdsResponse>
+ handleRequestGetLocalListVersion: (
+ chargingStation: ChargingStation
+ ) => OCPP20GetLocalListVersionResponse
+
/**
* Handles OCPP 2.0.1 GetLog request from central system.
* Accepts log upload and simulates upload lifecycle.
commandPayload: OCPP20ResetRequest
) => Promise<OCPP20ResetResponse>
+ handleRequestSendLocalList: (
+ chargingStation: ChargingStation,
+ commandPayload: OCPP20SendLocalListRequest
+ ) => OCPP20SendLocalListResponse
+
/**
* Handles OCPP 2.0.1 SetNetworkProfile request from central system.
* Per TC_B_43_CS: CS must respond to SetNetworkProfile at minimum with Rejected.
handleRequestGetBaseReport: serviceImpl.handleRequestGetBaseReport.bind(service),
handleRequestGetInstalledCertificateIds:
serviceImpl.handleRequestGetInstalledCertificateIds.bind(service),
+ handleRequestGetLocalListVersion: serviceImpl.handleRequestGetLocalListVersion.bind(service),
handleRequestGetLog: serviceImpl.handleRequestGetLog.bind(service),
handleRequestGetTransactionStatus: serviceImpl.handleRequestGetTransactionStatus.bind(service),
handleRequestGetVariables: serviceImpl.handleRequestGetVariables.bind(service),
handleRequestInstallCertificate: serviceImpl.handleRequestInstallCertificate.bind(service),
handleRequestReset: serviceImpl.handleRequestReset.bind(service),
+ handleRequestSendLocalList: serviceImpl.handleRequestSendLocalList.bind(service),
handleRequestSetNetworkProfile: serviceImpl.handleRequestSetNetworkProfile.bind(service),
handleRequestSetVariables: serviceImpl.handleRequestSetVariables.bind(service),
handleRequestStartTransaction: serviceImpl.handleRequestStartTransaction.bind(service),
Identifier,
} from '../types/AuthTypes.js'
-import { getConfigurationKey } from '../../../../charging-station/ConfigurationKeyUtils.js'
+import { getConfigurationKey } from '../../../../charging-station/index.js'
import {
type OCPP16AuthorizeRequest,
type OCPP16AuthorizeResponse,
+ OCPP16StandardParametersKey,
OCPPVersion,
RequestCommand,
StandardParametersKey,
} from '../../../../types/index.js'
import {
convertToBoolean,
+ convertToIntOrNaN,
getErrorMessage,
isEmpty,
logger,
}
/**
- * Get OCPP 1.6 specific configuration schema
- * @returns JSON schema object describing valid OCPP 1.6 auth configuration properties
+ * @returns Configuration schema object for OCPP 1.6 authorization settings
*/
getConfigurationSchema (): JsonObject {
return {
}
}
+ /**
+ * Read maximum local auth list entries from OCPP 1.6 LocalAuthListMaxLength config key.
+ * @returns Maximum entries limit, or undefined if not configured
+ */
+ getMaxLocalAuthListEntries (): number | undefined {
+ const configKey = getConfigurationKey(
+ this.chargingStation,
+ OCPP16StandardParametersKey.LocalAuthListMaxLength
+ )
+ if (configKey?.value != null) {
+ const parsed = convertToIntOrNaN(configKey.value)
+ if (!Number.isNaN(parsed) && parsed > 0) {
+ return parsed
+ }
+ }
+ return undefined
+ }
+
/**
* Get adapter-specific status information
* @returns Status object with online state, auth settings, and station identifier
}
/**
- * Get OCPP 2.0 specific configuration schema
* @returns Configuration schema object for OCPP 2.0 authorization settings
*/
getConfigurationSchema (): JsonObject {
}
}
+ /**
+ * @returns Always undefined — OCPP 2.0 defines capacity via LocalAuthListCtrlr.Storage (bytes), not entry count
+ */
+ getMaxLocalAuthListEntries (): number | undefined {
+ return undefined
+ }
+
/**
* Get adapter-specific status information
* @returns Status object containing adapter state and capabilities
--- /dev/null
+/**
+ * @file InMemoryLocalAuthListManager
+ * @description In-memory implementation of the LocalAuthListManager interface for managing
+ * OCPP local authorization lists with O(1) lookups, optional capacity limits, and support
+ * for full and differential update operations.
+ */
+
+import type {
+ DifferentialAuthEntry,
+ LocalAuthEntry,
+ LocalAuthListManager,
+} from '../interfaces/OCPPAuthService.js'
+
+import { logger, truncateId } from '../../../../utils/index.js'
+
+const moduleName = 'InMemoryLocalAuthListManager'
+
+/**
+ * In-memory implementation of LocalAuthListManager.
+ *
+ * Uses a Map for O(1) identifier lookups. Supports an optional maximum
+ * entry limit and tracks a list version number for synchronization
+ * with the CSMS.
+ */
+export class InMemoryLocalAuthListManager implements LocalAuthListManager {
+ private readonly entries = new Map<string, LocalAuthEntry>()
+
+ private readonly maxEntries?: number
+
+ private version = 0
+
+ /**
+ * @param maxEntries - Optional maximum number of entries allowed in the list
+ */
+ constructor (maxEntries?: number) {
+ this.maxEntries = maxEntries
+
+ logger.info(
+ `${moduleName}: Initialized${maxEntries != null ? ` with maxEntries=${String(maxEntries)}` : ''}`
+ )
+ }
+
+ /**
+ * @param entry - Authorization list entry
+ * @throws {Error} if maxEntries is set and adding a new entry would exceed the limit
+ */
+ public addEntry (entry: LocalAuthEntry): void {
+ if (
+ this.maxEntries != null &&
+ !this.entries.has(entry.identifier) &&
+ this.entries.size >= this.maxEntries
+ ) {
+ throw new Error(
+ `${moduleName}: Cannot add entry '${truncateId(entry.identifier)}' — maximum capacity of ${String(this.maxEntries)} entries reached`
+ )
+ }
+
+ this.entries.set(entry.identifier, entry)
+ logger.debug(
+ `${moduleName}: Added/updated entry for identifier: '${truncateId(entry.identifier)}'`
+ )
+ }
+
+ /**
+ * @param entries - Differential entries to apply
+ * @param version - New list version number
+ * @throws {Error} if maxEntries is set and adding new entries would exceed the limit
+ */
+ public applyDifferentialUpdate (entries: DifferentialAuthEntry[], version: number): void {
+ if (this.maxEntries != null) {
+ const newIdentifiers = new Set<string>()
+ const removedIdentifiers = new Set<string>()
+ for (const entry of entries) {
+ if (entry.status != null && !this.entries.has(entry.identifier)) {
+ newIdentifiers.add(entry.identifier)
+ removedIdentifiers.delete(entry.identifier)
+ } else if (entry.status == null && this.entries.has(entry.identifier)) {
+ removedIdentifiers.add(entry.identifier)
+ newIdentifiers.delete(entry.identifier)
+ }
+ }
+ const netNew = newIdentifiers.size - removedIdentifiers.size
+ if (this.entries.size + netNew > this.maxEntries) {
+ throw new Error(
+ `${moduleName}: Cannot apply differential update — would exceed maximum capacity of ${String(this.maxEntries)} entries`
+ )
+ }
+ }
+
+ for (const entry of entries) {
+ if (entry.status != null) {
+ this.entries.set(entry.identifier, { ...entry, status: entry.status })
+ } else {
+ this.entries.delete(entry.identifier)
+ logger.debug(
+ `${moduleName}: Differential removal of identifier: '${truncateId(entry.identifier)}'`
+ )
+ }
+ }
+
+ this.version = version
+ logger.info(
+ `${moduleName}: Applied differential update — ${String(entries.length)} entries processed, version=${String(version)}`
+ )
+ }
+
+ public clearAll (): void {
+ const count = this.entries.size
+ this.entries.clear()
+
+ logger.info(`${moduleName}: Cleared ${String(count)} entries`)
+ }
+
+ public getAllEntries (): LocalAuthEntry[] {
+ return [...this.entries.values()]
+ }
+
+ public getEntry (identifier: string): LocalAuthEntry | undefined {
+ return this.entries.get(identifier)
+ }
+
+ public getVersion (): number {
+ return this.version
+ }
+
+ public removeEntry (identifier: string): void {
+ const deleted = this.entries.delete(identifier)
+
+ if (deleted) {
+ logger.debug(`${moduleName}: Removed entry for identifier: '${truncateId(identifier)}'`)
+ }
+ }
+
+ /**
+ * @param entries - New entries for the list
+ * @param version - New list version number
+ * @throws {Error} if maxEntries is set and the entries array exceeds the limit
+ */
+ public setEntries (entries: LocalAuthEntry[], version: number): void {
+ // Conservative check: uses array length rather than unique identifiers count,
+ // as OCPP messages should not contain duplicate entries per spec
+ if (this.maxEntries != null && entries.length > this.maxEntries) {
+ throw new Error(
+ `${moduleName}: Cannot set ${String(entries.length)} entries — maximum capacity of ${String(this.maxEntries)} entries exceeded`
+ )
+ }
+
+ this.entries.clear()
+
+ for (const entry of entries) {
+ this.entries.set(entry.identifier, entry)
+ }
+
+ this.version = version
+ logger.info(
+ `${moduleName}: Full update — ${String(entries.length)} entries set, version=${String(version)}`
+ )
+ }
+
+ public updateVersion (version: number): void {
+ this.version = version
+ logger.debug(`${moduleName}: Version updated to ${String(version)}`)
+ }
+}
import { OCPP16AuthAdapter } from '../adapters/OCPP16AuthAdapter.js'
import { OCPP20AuthAdapter } from '../adapters/OCPP20AuthAdapter.js'
import { InMemoryAuthCache } from '../cache/InMemoryAuthCache.js'
+import { InMemoryLocalAuthListManager } from '../cache/InMemoryLocalAuthListManager.js'
import { CertificateAuthStrategy } from '../strategies/CertificateAuthStrategy.js'
import { LocalAuthStrategy } from '../strategies/LocalAuthStrategy.js'
import { RemoteAuthStrategy } from '../strategies/RemoteAuthStrategy.js'
}
/**
- * Create local auth list manager (delegated to service implementation)
- *
- * TODO: Implement concrete LocalAuthListManager for OCPP 1.6 §3.5 and OCPP 2.0.1 §C13/C14
- * compliance. Until implemented, LocalAuthStrategy operates with cache and offline
- * fallback only. The OCPP 1.6 §3.5.3 guard in RemoteAuthStrategy (skip caching for
- * local-list identifiers) is inactive without a manager.
- * @param chargingStation - Charging station instance (unused, reserved for future use)
- * @param config - Authentication configuration (unused, reserved for future use)
- * @returns Always undefined as manager creation is not yet implemented
+ * Create local auth list manager
+ * @param config - Authentication configuration controlling local auth list behavior
+ * @returns In-memory local auth list manager if enabled, undefined otherwise
*/
- static createLocalAuthListManager (
- chargingStation: ChargingStation,
- config: AuthConfiguration
- ): undefined {
- // Manager creation is delegated to OCPPAuthServiceImpl
- // This method exists for API completeness
- return undefined
+ static createLocalAuthListManager (config: AuthConfiguration): LocalAuthListManager | undefined {
+ if (!config.localAuthListEnabled) {
+ return undefined
+ }
+
+ const maxEntries = config.maxLocalAuthListEntries
+ return new InMemoryLocalAuthListManager(maxEntries)
}
/**
* @module ocpp/auth
*/
+export { InMemoryLocalAuthListManager } from './cache/InMemoryLocalAuthListManager.js'
export type {
AuthCache,
AuthComponentFactory,
CacheStats,
CertificateAuthProvider,
CertificateInfo,
+ DifferentialAuthEntry,
LocalAuthEntry,
LocalAuthListManager,
OCPPAuthAdapter,
/**
* Create a local auth list manager
+ * @param config - Authentication configuration controlling local auth list behavior
+ * @returns In-memory local auth list manager if enabled, undefined otherwise
*/
- createLocalAuthListManager(): LocalAuthListManager
+ createLocalAuthListManager(config: AuthConfiguration): LocalAuthListManager | undefined
/**
* Create a strategy by name
* Authenticate using this strategy
* @param request - Authentication request
* @param config - Current configuration
- * @returns Promise resolving to authorization result, undefined if not handled
+ * @returns Authorization result, undefined if not handled
*/
authenticate(
request: AuthRequest,
config: AuthConfiguration
- ): Promise<AuthorizationResult | undefined>
+ ): AuthorizationResult | Promise<AuthorizationResult | undefined> | undefined
/**
* Check if this strategy can handle the given request
validTo: Date
}
+/**
+ * Entry used in differential update operations.
+ *
+ * When `status` is defined, the entry is added or updated in the list.
+ * When `status` is `undefined`, the entry is removed from the list.
+ * This models the OCPP behavior where absent idTagInfo (1.6) or
+ * absent idTokenInfo (2.0) signals removal.
+ */
+export interface DifferentialAuthEntry {
+ /** Optional expiry date */
+ expiryDate?: Date
+ /** Identifier value */
+ identifier: string
+ /** Entry metadata */
+ metadata?: Record<string, unknown>
+ /** Optional parent identifier */
+ parentId?: string
+ /** Authorization status — undefined signals removal */
+ status?: string
+}
+
/**
* Supporting types for interfaces
*/
* Add or update an entry in the local authorization list
* @param entry - Authorization list entry
*/
- addEntry(entry: LocalAuthEntry): Promise<void>
+ addEntry(entry: LocalAuthEntry): void
+
+ /**
+ * Apply a differential update to the list
+ * Entries with status are added/updated; entries without status are removed
+ * @param entries - Differential entries to apply
+ * @param version - New list version number
+ */
+ applyDifferentialUpdate(entries: DifferentialAuthEntry[], version: number): void
/**
* Clear all entries from the local authorization list
*/
- clearAll(): Promise<void>
+ clearAll(): void
/**
* Get all entries (for synchronization)
*/
- getAllEntries(): Promise<LocalAuthEntry[]>
+ getAllEntries(): LocalAuthEntry[]
/**
* Get an entry from the local authorization list
* @param identifier - Identifier to look up
* @returns Authorization entry or undefined if not found
*/
- getEntry(identifier: string): Promise<LocalAuthEntry | undefined>
+ getEntry(identifier: string): LocalAuthEntry | undefined
/**
* Get list version/update count
*/
- getVersion(): Promise<number>
+ getVersion(): number
/**
* Remove an entry from the local authorization list
* @param identifier - Identifier to remove
*/
- removeEntry(identifier: string): Promise<void>
+ removeEntry(identifier: string): void
+
+ /**
+ * Replace all entries with a new set (Full update)
+ * @param entries - New entries for the list
+ * @param version - New list version number
+ */
+ setEntries(entries: LocalAuthEntry[], version: number): void
/**
* Update list version
*/
- updateVersion(version: number): Promise<void>
+ updateVersion(version: number): void
}
/**
*/
getConfigurationSchema(): JsonObject
+ /**
+ * Get the maximum number of entries allowed in the local auth list.
+ * @returns Maximum entries from station configuration, or undefined if not configured
+ */
+ getMaxLocalAuthListEntries(): number | undefined
+
/**
* Check if remote authorization is available
*/
*/
getConfiguration(): AuthConfiguration
+ /**
+ * Get the local authorization list manager
+ * @returns Local authorization list manager or undefined if not enabled
+ */
+ getLocalAuthListManager(): LocalAuthListManager | undefined
+
/**
* Get authentication statistics
*/
type AuthCache,
type AuthStats,
type AuthStrategy,
+ type LocalAuthListManager,
type OCPPAuthService,
} from '../interfaces/OCPPAuthService.js'
import {
private authCache?: AuthCache
private readonly chargingStation: ChargingStation
private config: AuthConfiguration
+ private localAuthListManager?: LocalAuthListManager
private readonly metrics: {
cacheHits: number
cacheMisses: number
* @param request - Authorization request containing identifier, context, and options
* @returns Promise resolving to the authorization result with status and metadata
*/
- public async authorize (request: AuthRequest): Promise<AuthorizationResult> {
+ public authorize (request: AuthRequest): Promise<AuthorizationResult> {
return this.authenticate(request)
}
return { ...this.config }
}
+ /**
+ * Get the local authorization list manager
+ * @returns Local authorization list manager or undefined if not enabled
+ */
+ public getLocalAuthListManager (): LocalAuthListManager | undefined {
+ return this.localAuthListManager
+ }
+
/**
* Get authentication statistics
* @returns Authentication statistics including cache and rate limiting metrics
*/
public initialize (): void {
this.initializeAdapter()
+ if (this.adapter != null) {
+ this.config.maxLocalAuthListEntries ??= this.adapter.getMaxLocalAuthListEntries()
+ }
this.initializeStrategies()
}
}
this.authCache = AuthComponentFactory.createAuthCache(this.config)
+ this.localAuthListManager = AuthComponentFactory.createLocalAuthListManager(this.config)
const strategies = AuthComponentFactory.createStrategies(
this.chargingStation,
this.adapter,
- undefined, // manager - delegated to OCPPAuthServiceImpl
+ this.localAuthListManager,
this.authCache,
this.config
)
* @param config - Authentication configuration controlling local auth behavior
* @returns Authorization result from local list, cache, or offline fallback; undefined if not found locally
*/
- public async authenticate (
+ public authenticate (
request: AuthRequest,
config: AuthConfiguration
- ): Promise<AuthorizationResult | undefined> {
+ ): AuthorizationResult | undefined {
if (!this.isInitialized) {
throw new AuthenticationError(
'LocalAuthStrategy not initialized',
// 1. Try local authorization list first (highest priority)
if (config.localAuthListEnabled && this.localAuthListManager) {
- const localResult = await this.checkLocalAuthList(request, config)
+ const localResult = this.checkLocalAuthList(request, config)
if (localResult) {
logger.debug(`${moduleName}: Found in local auth list: ${localResult.status}`)
this.stats.localListHits++
* @param identifier - Unique identifier string to look up
* @returns True if the identifier exists in the local authorization list
*/
- public async isInLocalList (identifier: string): Promise<boolean> {
+ public isInLocalList (identifier: string): boolean {
if (!this.localAuthListManager) {
return false
}
try {
- const entry = await this.localAuthListManager.getEntry(identifier)
+ const entry = this.localAuthListManager.getEntry(identifier)
return !!entry
} catch (error) {
const errorMessage = getErrorMessage(error)
* @param config - Authentication configuration (unused in local list check)
* @returns Authorization result from local list if found; undefined otherwise
*/
- private async checkLocalAuthList (
+ private checkLocalAuthList (
request: AuthRequest,
config: AuthConfiguration
- ): Promise<AuthorizationResult | undefined> {
+ ): AuthorizationResult | undefined {
if (!this.localAuthListManager) {
return undefined
}
try {
- const entry = await this.localAuthListManager.getEntry(request.identifier.value)
+ const entry = this.localAuthListManager.getEntry(request.identifier.value)
if (!entry) {
return undefined
}
logger.debug(`${moduleName}: Remote authorization: ${result.status}`)
this.stats.successfulRemoteAuth++
- // Check if identifier is in Local Auth List — do not cache (OCPP 1.6 §3.5.3)
- // NOTE: This guard is inactive until LocalAuthListManager is implemented.
- // When localAuthListManager is undefined, all results are cached unconditionally.
+ // Skip caching for identifiers already in Local Auth List (OCPP 1.6 §3.5.3)
if (this.authCache && config.localAuthListEnabled && this.localAuthListManager) {
- const isInLocalList = await this.localAuthListManager.getEntry(request.identifier.value)
+ let isInLocalList = false
+ try {
+ isInLocalList = this.localAuthListManager.getEntry(request.identifier.value) != null
+ } catch (error) {
+ logger.warn(
+ `${moduleName}: Failed to check local auth list for '${truncateId(request.identifier.value)}': ${getErrorMessage(error)}`
+ )
+ }
if (isInLocalList) {
logger.debug(
`${moduleName}: Skipping cache for local list identifier: '${truncateId(request.identifier.value)}'`
/** Maximum cache entries */
maxCacheEntries?: number
+ /** Maximum local auth list entries */
+ maxLocalAuthListEntries?: number
+
/** OCPP protocol version configured on the charging station */
ocppVersion?: string
validateCacheConfig(config)
}
+ if (config.localAuthListEnabled) {
+ validateLocalAuthListConfig(config)
+ }
+
validateTimeout(config)
validateOfflineConfig(config)
checkAuthMethodsEnabled(config)
}
}
+/**
+ * Validate local auth list configuration values.
+ * @param config - Authentication configuration with local auth list settings
+ */
+function validateLocalAuthListConfig (config: AuthConfiguration): void {
+ if (config.maxLocalAuthListEntries !== undefined) {
+ if (!Number.isInteger(config.maxLocalAuthListEntries)) {
+ throw new AuthenticationError(
+ 'maxLocalAuthListEntries must be an integer',
+ AuthErrorCode.CONFIGURATION_ERROR
+ )
+ }
+
+ if (config.maxLocalAuthListEntries <= 0) {
+ throw new AuthenticationError(
+ `maxLocalAuthListEntries must be > 0, got ${String(config.maxLocalAuthListEntries)}`,
+ AuthErrorCode.CONFIGURATION_ERROR
+ )
+ }
+ }
+}
+
/**
* Validate offline authorization configuration consistency.
* @param config - Authentication configuration with offline settings
type ChangeConfigurationRequest,
type GetConfigurationRequest,
type GetDiagnosticsRequest,
+ type OCPP16AuthorizationData,
OCPP16AvailabilityType,
type OCPP16BootNotificationRequest,
type OCPP16CancelReservationRequest,
OCPP16FirmwareStatus,
type OCPP16FirmwareStatusNotificationRequest,
type OCPP16GetCompositeScheduleRequest,
+ type OCPP16GetLocalListVersionRequest,
type OCPP16HeartbeatRequest,
OCPP16IncomingRequestCommand,
OCPP16MessageTrigger,
OCPP16RequestCommand,
type OCPP16ReserveNowRequest,
+ type OCPP16SendLocalListRequest,
type OCPP16StatusNotificationRequest,
type OCPP16TriggerMessageRequest,
type OCPP16UpdateFirmwareRequest,
+ OCPP16UpdateType,
type RemoteStartTransactionRequest,
type RemoteStopTransactionRequest,
type ResetRequest,
type OCPP16DiagnosticsStatusNotificationResponse,
type OCPP16FirmwareStatusNotificationResponse,
type OCPP16GetCompositeScheduleResponse,
+ type OCPP16GetLocalListVersionResponse,
type OCPP16HeartbeatResponse,
OCPP16ReservationStatus,
type OCPP16ReserveNowResponse,
+ type OCPP16SendLocalListResponse,
type OCPP16StatusNotificationResponse,
type OCPP16TriggerMessageResponse,
OCPP16TriggerMessageStatus,
OCPP16UnlockStatus,
type OCPP16UpdateFirmwareResponse,
+ OCPP16UpdateStatus,
type SetChargingProfileResponse,
type UnlockConnectorResponse,
} from './ocpp/1.6/Responses.js'
type OCPP20UnitOfMeasure,
} from './ocpp/2.0/MeterValues.js'
export {
+ type OCPP20AuthorizationData,
type OCPP20AuthorizeRequest,
type OCPP20BootNotificationRequest,
type OCPP20CertificateSignedRequest,
type OCPP20GetBaseReportRequest,
type OCPP20GetCertificateStatusRequest,
type OCPP20GetInstalledCertificateIdsRequest,
+ type OCPP20GetLocalListVersionRequest,
type OCPP20GetLogRequest,
type OCPP20GetTransactionStatusRequest,
type OCPP20GetVariablesRequest,
type OCPP20RequestStopTransactionRequest,
type OCPP20ResetRequest,
type OCPP20SecurityEventNotificationRequest,
+ type OCPP20SendLocalListRequest,
type OCPP20SetNetworkProfileRequest,
type OCPP20SetVariablesRequest,
type OCPP20SignCertificateRequest,
type OCPP20StatusNotificationRequest,
type OCPP20TriggerMessageRequest,
type OCPP20UnlockConnectorRequest,
+ OCPP20UpdateEnumType,
type OCPP20UpdateFirmwareRequest,
} from './ocpp/2.0/Requests.js'
-export type {
- OCPP20AuthorizeResponse,
- OCPP20BootNotificationResponse,
- OCPP20CertificateSignedResponse,
- OCPP20ChangeAvailabilityResponse,
- OCPP20ClearCacheResponse,
- OCPP20CustomerInformationResponse,
- OCPP20DataTransferResponse,
- OCPP20DeleteCertificateResponse,
- OCPP20FirmwareStatusNotificationResponse,
- OCPP20Get15118EVCertificateResponse,
- OCPP20GetBaseReportResponse,
- OCPP20GetCertificateStatusResponse,
- OCPP20GetInstalledCertificateIdsResponse,
- OCPP20GetLogResponse,
- OCPP20GetTransactionStatusResponse,
- OCPP20GetVariablesResponse,
- OCPP20HeartbeatResponse,
- OCPP20InstallCertificateResponse,
- OCPP20LogStatusNotificationResponse,
- OCPP20NotifyCustomerInformationResponse,
- OCPP20NotifyReportResponse,
- OCPP20RequestStartTransactionResponse,
- OCPP20RequestStopTransactionResponse,
- OCPP20ResetResponse,
- OCPP20SecurityEventNotificationResponse,
- OCPP20SetNetworkProfileResponse,
- OCPP20SetVariablesResponse,
- OCPP20SignCertificateResponse,
- OCPP20StatusNotificationResponse,
- OCPP20TriggerMessageResponse,
- OCPP20UnlockConnectorResponse,
- OCPP20UpdateFirmwareResponse,
+export {
+ type OCPP20AuthorizeResponse,
+ type OCPP20BootNotificationResponse,
+ type OCPP20CertificateSignedResponse,
+ type OCPP20ChangeAvailabilityResponse,
+ type OCPP20ClearCacheResponse,
+ type OCPP20CustomerInformationResponse,
+ type OCPP20DataTransferResponse,
+ type OCPP20DeleteCertificateResponse,
+ type OCPP20FirmwareStatusNotificationResponse,
+ type OCPP20Get15118EVCertificateResponse,
+ type OCPP20GetBaseReportResponse,
+ type OCPP20GetCertificateStatusResponse,
+ type OCPP20GetInstalledCertificateIdsResponse,
+ type OCPP20GetLocalListVersionResponse,
+ type OCPP20GetLogResponse,
+ type OCPP20GetTransactionStatusResponse,
+ type OCPP20GetVariablesResponse,
+ type OCPP20HeartbeatResponse,
+ type OCPP20InstallCertificateResponse,
+ type OCPP20LogStatusNotificationResponse,
+ type OCPP20NotifyCustomerInformationResponse,
+ type OCPP20NotifyReportResponse,
+ type OCPP20RequestStartTransactionResponse,
+ type OCPP20RequestStopTransactionResponse,
+ type OCPP20ResetResponse,
+ type OCPP20SecurityEventNotificationResponse,
+ type OCPP20SendLocalListResponse,
+ OCPP20SendLocalListStatusEnumType,
+ type OCPP20SetNetworkProfileResponse,
+ type OCPP20SetVariablesResponse,
+ type OCPP20SignCertificateResponse,
+ type OCPP20StatusNotificationResponse,
+ type OCPP20TriggerMessageResponse,
+ type OCPP20UnlockConnectorResponse,
+ type OCPP20UpdateFirmwareResponse,
} from './ocpp/2.0/Responses.js'
export {
type AdditionalInfoType,
} from './ChargingProfile.js'
import type { OCPP16StandardParametersKey, OCPP16VendorParametersKey } from './Configuration.js'
import type { OCPP16DiagnosticsStatus } from './DiagnosticsStatus.js'
+import type { OCPP16IdTagInfo } from './Transaction.js'
export enum OCPP16AvailabilityType {
Inoperative = 'Inoperative',
GET_COMPOSITE_SCHEDULE = 'GetCompositeSchedule',
GET_CONFIGURATION = 'GetConfiguration',
GET_DIAGNOSTICS = 'GetDiagnostics',
+ GET_LOCAL_LIST_VERSION = 'GetLocalListVersion',
REMOTE_START_TRANSACTION = 'RemoteStartTransaction',
REMOTE_STOP_TRANSACTION = 'RemoteStopTransaction',
RESERVE_NOW = 'ReserveNow',
RESET = 'Reset',
+ SEND_LOCAL_LIST = 'SendLocalList',
SET_CHARGING_PROFILE = 'SetChargingProfile',
TRIGGER_MESSAGE = 'TriggerMessage',
UNLOCK_CONNECTOR = 'UnlockConnector',
STOP_TRANSACTION = 'StopTransaction',
}
+export enum OCPP16UpdateType {
+ Differential = 'Differential',
+ Full = 'Full',
+}
+
export enum ResetType {
HARD = 'Hard',
SOFT = 'Soft',
stopTime?: Date
}
+export interface OCPP16AuthorizationData extends JsonObject {
+ idTag: string
+ idTagInfo?: OCPP16IdTagInfo
+}
+
export interface OCPP16BootNotificationRequest extends JsonObject {
chargeBoxSerialNumber?: string
chargePointModel: string
duration: number
}
+export type OCPP16GetLocalListVersionRequest = EmptyObject
+
export type OCPP16HeartbeatRequest = EmptyObject
export interface OCPP16ReserveNowRequest extends JsonObject {
reservationId: number
}
+export interface OCPP16SendLocalListRequest extends JsonObject {
+ listVersion: number
+ localAuthorizationList?: OCPP16AuthorizationData[]
+ updateType: OCPP16UpdateType
+}
+
export interface OCPP16StatusNotificationRequest extends JsonObject {
connectorId: number
errorCode: OCPP16ChargePointErrorCode
UNLOCKED = 'Unlocked',
}
+export enum OCPP16UpdateStatus {
+ ACCEPTED = 'Accepted',
+ FAILED = 'Failed',
+ NOT_SUPPORTED = 'NotSupported',
+ VERSION_MISMATCH = 'VersionMismatch',
+}
+
export interface ChangeConfigurationResponse extends JsonObject {
status: OCPP16ConfigurationStatus
}
status: GenericStatus
}
+export interface OCPP16GetLocalListVersionResponse extends JsonObject {
+ listVersion: number
+}
+
export interface OCPP16HeartbeatResponse extends JsonObject {
currentTime: Date
}
status: OCPP16ReservationStatus
}
+export interface OCPP16SendLocalListResponse extends JsonObject {
+ status: OCPP16UpdateStatus
+}
+
export type OCPP16StatusNotificationResponse = EmptyObject
export interface OCPP16TriggerMessageResponse extends JsonObject {
OCPP20ChargingProfileType,
OCPP20ConnectorStatusEnumType,
OCPP20EVSEType,
+ OCPP20IdTokenInfoType,
OCPP20IdTokenType,
} from './Transaction.js'
import type {
DELETE_CERTIFICATE = 'DeleteCertificate',
GET_BASE_REPORT = 'GetBaseReport',
GET_INSTALLED_CERTIFICATE_IDS = 'GetInstalledCertificateIds',
+ GET_LOCAL_LIST_VERSION = 'GetLocalListVersion',
GET_LOG = 'GetLog',
GET_TRANSACTION_STATUS = 'GetTransactionStatus',
GET_VARIABLES = 'GetVariables',
REQUEST_START_TRANSACTION = 'RequestStartTransaction',
REQUEST_STOP_TRANSACTION = 'RequestStopTransaction',
RESET = 'Reset',
+ SEND_LOCAL_LIST = 'SendLocalList',
SET_NETWORK_PROFILE = 'SetNetworkProfile',
SET_VARIABLES = 'SetVariables',
TRIGGER_MESSAGE = 'TriggerMessage',
TRANSACTION_EVENT = 'TransactionEvent',
}
+export enum OCPP20UpdateEnumType {
+ Differential = 'Differential',
+ Full = 'Full',
+}
+
+export interface OCPP20AuthorizationData extends JsonObject {
+ customData?: CustomDataType
+ idToken: OCPP20IdTokenType
+ idTokenInfo?: OCPP20IdTokenInfoType
+}
+
export interface OCPP20AuthorizeRequest extends JsonObject {
customData?: CustomDataType
idToken: OCPP20IdTokenType
customData?: CustomDataType
}
+export type OCPP20GetLocalListVersionRequest = EmptyObject
+
export interface OCPP20GetLogRequest extends JsonObject {
customData?: CustomDataType
log: LogParametersType
type: string
}
+export interface OCPP20SendLocalListRequest extends JsonObject {
+ customData?: CustomDataType
+ localAuthorizationList?: OCPP20AuthorizationData[]
+ updateType: OCPP20UpdateEnumType
+ versionNumber: number
+}
+
export interface OCPP20SetNetworkProfileRequest extends JsonObject {
configurationSlot: number
connectionData: NetworkConnectionProfileType
import type { OCPP20IdTokenInfoType, RequestStartStopStatusEnumType } from './Transaction.js'
import type { OCPP20GetVariableResultType, OCPP20SetVariableResultType } from './Variables.js'
+export enum OCPP20SendLocalListStatusEnumType {
+ Accepted = 'Accepted',
+ Failed = 'Failed',
+ VersionMismatch = 'VersionMismatch',
+}
+
export interface OCPP20AuthorizeResponse extends JsonObject {
customData?: CustomDataType
idTokenInfo: OCPP20IdTokenInfoType
statusInfo?: StatusInfoType
}
+export interface OCPP20GetLocalListVersionResponse extends JsonObject {
+ customData?: CustomDataType
+ versionNumber: number
+}
+
export interface OCPP20GetLogResponse extends JsonObject {
customData?: CustomDataType
filename?: string
export type OCPP20SecurityEventNotificationResponse = EmptyObject
+export interface OCPP20SendLocalListResponse extends JsonObject {
+ customData?: CustomDataType
+ status: OCPP20SendLocalListStatusEnumType
+ statusInfo?: StatusInfoType
+}
+
export interface OCPP20SetNetworkProfileResponse extends JsonObject {
customData?: CustomDataType
status: SetNetworkProfileStatusEnumType
statusInfo?: StatusInfoType
}
+export type { OCPP20TransactionEventResponse } from './Transaction.js'
+
export interface OCPP20SetVariablesResponse extends JsonObject {
customData?: CustomDataType
setVariableResult: OCPP20SetVariableResultType[]
export type OCPP20StatusNotificationResponse = EmptyObject
-export type { OCPP20TransactionEventResponse } from './Transaction.js'
-
export interface OCPP20TriggerMessageResponse extends JsonObject {
customData?: CustomDataType
status: TriggerMessageStatusEnumType
CertificateEntries = 'CertificateEntries',
DateTime = 'DateTime',
Enabled = 'Enabled',
+ Entries = 'Entries',
EVConnectionTimeOut = 'EVConnectionTimeOut',
FileTransferProtocols = 'FileTransferProtocols',
ItemsPerMessage = 'ItemsPerMessage',
StopTransactionReason,
} from '../../../src/types/index.js'
+import { getConfigurationKey } from '../../../src/charging-station/index.js'
import {
AvailabilityType,
ConnectorStatusEnum,
CurrentType,
OCPPVersion,
RegistrationStatusEnumType,
+ StandardParametersKey,
} from '../../../src/types/index.js'
+import { convertToBoolean } from '../../../src/utils/index.js'
import {
TEST_CHARGING_STATION_BASE_NAME,
TEST_CHARGING_STATION_HASH_ID,
return heartbeatInterval * 1000 // Return in ms
},
getLocalAuthListEnabled (): boolean {
- return false // Default to false in mock
+ const key = getConfigurationKey(
+ this as unknown as ChargingStation,
+ StandardParametersKey.LocalAuthListEnabled
+ )
+ return key?.value != null ? convertToBoolean(key.value) : false
},
getNumberOfConnectors (): number {
return this.iterateConnectors(true).reduce(count => count + 1, 0)
--- /dev/null
+/**
+ * @file Tests for OCPP16IncomingRequestService LocalAuthList handlers
+ * @description Unit tests for OCPP 1.6 GetLocalListVersion and SendLocalList
+ * incoming request handlers
+ */
+
+import assert from 'node:assert/strict'
+import { afterEach, beforeEach, describe, it } from 'node:test'
+
+import type { ChargingStation } from '../../../../src/charging-station/index.js'
+import type {
+ LocalAuthListManager,
+ OCPPAuthService,
+} from '../../../../src/charging-station/ocpp/auth/interfaces/OCPPAuthService.js'
+import type { OCPP16SendLocalListRequest } from '../../../../src/types/index.js'
+
+import { InMemoryLocalAuthListManager } from '../../../../src/charging-station/ocpp/auth/cache/InMemoryLocalAuthListManager.js'
+import { OCPPAuthServiceFactory } from '../../../../src/charging-station/ocpp/auth/services/OCPPAuthServiceFactory.js'
+import {
+ OCPP16AuthorizationStatus,
+ OCPP16StandardParametersKey,
+ OCPP16UpdateStatus,
+ OCPP16UpdateType,
+} from '../../../../src/types/index.js'
+import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js'
+import {
+ createOCPP16IncomingRequestTestContext,
+ type OCPP16IncomingRequestTestContext,
+ upsertConfigurationKey,
+} from './OCPP16TestUtils.js'
+
+/**
+ * @param manager - LocalAuthListManager instance or undefined
+ * @returns Mock OCPPAuthService wired to the given manager
+ */
+function createMockAuthService (manager: LocalAuthListManager | undefined): OCPPAuthService {
+ return {
+ authorize: async () => Promise.resolve({ status: 'Accepted' }),
+ getAuthCache: () => undefined,
+ getLocalAuthListManager: () => manager,
+ getStats: () => ({
+ avgResponseTime: 0,
+ cacheHitRate: 0,
+ failedAuth: 0,
+ lastUpdatedDate: new Date(),
+ localUsageRate: 0,
+ remoteSuccessRate: 0,
+ successfulAuth: 0,
+ totalRequests: 0,
+ }),
+ initialize: () => undefined,
+ } as unknown as OCPPAuthService
+}
+
+/**
+ * @param context - Test context with station and service
+ */
+function enableLocalAuthListProfile (context: OCPP16IncomingRequestTestContext): void {
+ const { station } = context
+ upsertConfigurationKey(
+ station,
+ OCPP16StandardParametersKey.SupportedFeatureProfiles,
+ 'Core,LocalAuthListManagement'
+ )
+ upsertConfigurationKey(station, OCPP16StandardParametersKey.LocalAuthListEnabled, 'true')
+ upsertConfigurationKey(station, OCPP16StandardParametersKey.SendLocalListMaxLength, '20')
+}
+
+/**
+ * @param station - Charging station to configure mock for
+ * @param manager - LocalAuthListManager instance or undefined
+ */
+function setupMockAuthService (
+ station: ChargingStation,
+ manager: LocalAuthListManager | undefined
+): void {
+ const stationId = station.stationInfo?.chargingStationId ?? 'unknown'
+ OCPPAuthServiceFactory.setInstanceForTesting(stationId, createMockAuthService(manager))
+}
+
+await describe('OCPP16IncomingRequestService — LocalAuthList', async () => {
+ let context: OCPP16IncomingRequestTestContext
+
+ beforeEach(() => {
+ context = createOCPP16IncomingRequestTestContext()
+ })
+
+ afterEach(() => {
+ OCPPAuthServiceFactory.clearAllInstances()
+ standardCleanup()
+ })
+
+ // =========================================================================
+ // GetLocalListVersion
+ // =========================================================================
+
+ await describe('handleRequestGetLocalListVersion', async () => {
+ await it('should return 0 for empty list', () => {
+ const { station, testableService } = context
+ enableLocalAuthListProfile(context)
+ const manager = new InMemoryLocalAuthListManager()
+ setupMockAuthService(station, manager)
+
+ const response = testableService.handleRequestGetLocalListVersion(station)
+
+ assert.strictEqual(response.listVersion, 0)
+ })
+
+ await it('should return -1 when feature profile disabled', () => {
+ const { station, testableService } = context
+ upsertConfigurationKey(station, OCPP16StandardParametersKey.SupportedFeatureProfiles, 'Core')
+
+ const response = testableService.handleRequestGetLocalListVersion(station)
+
+ assert.strictEqual(response.listVersion, -1)
+ })
+
+ await it('should return -1 when manager undefined', () => {
+ const { station, testableService } = context
+ enableLocalAuthListProfile(context)
+ setupMockAuthService(station, undefined)
+
+ const response = testableService.handleRequestGetLocalListVersion(station)
+
+ assert.strictEqual(response.listVersion, -1)
+ })
+
+ await it('should return current version after update', () => {
+ const { station, testableService } = context
+ enableLocalAuthListProfile(context)
+ const manager = new InMemoryLocalAuthListManager()
+ manager.setEntries([{ identifier: 'TAG-001', status: 'Accepted' }], 5)
+ setupMockAuthService(station, manager)
+
+ const response = testableService.handleRequestGetLocalListVersion(station)
+
+ assert.strictEqual(response.listVersion, 5)
+ })
+ })
+
+ // =========================================================================
+ // SendLocalList
+ // =========================================================================
+
+ await describe('handleRequestSendLocalList', async () => {
+ await it('should accept Full update and replace list', () => {
+ const { station, testableService } = context
+ enableLocalAuthListProfile(context)
+ const manager = new InMemoryLocalAuthListManager()
+ setupMockAuthService(station, manager)
+
+ const request: OCPP16SendLocalListRequest = {
+ listVersion: 1,
+ localAuthorizationList: [
+ { idTag: 'TAG-001', idTagInfo: { status: OCPP16AuthorizationStatus.ACCEPTED } },
+ { idTag: 'TAG-002', idTagInfo: { status: OCPP16AuthorizationStatus.BLOCKED } },
+ ],
+ updateType: OCPP16UpdateType.Full,
+ }
+
+ const response = testableService.handleRequestSendLocalList(station, request)
+
+ assert.strictEqual(response.status, OCPP16UpdateStatus.ACCEPTED)
+ assert.strictEqual(manager.getVersion(), 1)
+ const entries = manager.getAllEntries()
+ assert.strictEqual(entries.length, 2)
+ })
+
+ await it('should accept Differential update with adds and removes', () => {
+ const { station, testableService } = context
+ enableLocalAuthListProfile(context)
+ const manager = new InMemoryLocalAuthListManager()
+ manager.setEntries(
+ [
+ { identifier: 'TAG-001', status: 'Accepted' },
+ { identifier: 'TAG-002', status: 'Accepted' },
+ ],
+ 1
+ )
+ setupMockAuthService(station, manager)
+
+ const request: OCPP16SendLocalListRequest = {
+ listVersion: 2,
+ localAuthorizationList: [
+ { idTag: 'TAG-003', idTagInfo: { status: OCPP16AuthorizationStatus.ACCEPTED } },
+ { idTag: 'TAG-001' },
+ ],
+ updateType: OCPP16UpdateType.Differential,
+ }
+
+ const response = testableService.handleRequestSendLocalList(station, request)
+
+ assert.strictEqual(response.status, OCPP16UpdateStatus.ACCEPTED)
+ assert.strictEqual(manager.getVersion(), 2)
+ const entry001 = manager.getEntry('TAG-001')
+ assert.strictEqual(entry001, undefined)
+ const entry003 = manager.getEntry('TAG-003')
+ assert.notStrictEqual(entry003, undefined)
+ assert.strictEqual(entry003?.status, 'Accepted')
+ })
+
+ await it('should return NotSupported when feature profile disabled', () => {
+ const { station, testableService } = context
+ upsertConfigurationKey(station, OCPP16StandardParametersKey.SupportedFeatureProfiles, 'Core')
+
+ const request: OCPP16SendLocalListRequest = {
+ listVersion: 1,
+ localAuthorizationList: [],
+ updateType: OCPP16UpdateType.Full,
+ }
+
+ const response = testableService.handleRequestSendLocalList(station, request)
+
+ assert.strictEqual(response.status, OCPP16UpdateStatus.NOT_SUPPORTED)
+ })
+
+ await it('should return Failed with listVersion=-1', () => {
+ const { station, testableService } = context
+ enableLocalAuthListProfile(context)
+ const manager = new InMemoryLocalAuthListManager()
+ setupMockAuthService(station, manager)
+
+ const request: OCPP16SendLocalListRequest = {
+ listVersion: -1,
+ localAuthorizationList: [],
+ updateType: OCPP16UpdateType.Full,
+ }
+
+ const response = testableService.handleRequestSendLocalList(station, request)
+
+ assert.strictEqual(response.status, OCPP16UpdateStatus.FAILED)
+ })
+
+ await it('should return Failed with listVersion=0', () => {
+ const { station, testableService } = context
+ enableLocalAuthListProfile(context)
+ const manager = new InMemoryLocalAuthListManager()
+ setupMockAuthService(station, manager)
+
+ const request: OCPP16SendLocalListRequest = {
+ listVersion: 0,
+ localAuthorizationList: [],
+ updateType: OCPP16UpdateType.Full,
+ }
+
+ const response = testableService.handleRequestSendLocalList(station, request)
+
+ assert.strictEqual(response.status, OCPP16UpdateStatus.FAILED)
+ })
+
+ await it('should return NotSupported when manager is undefined', () => {
+ const { station, testableService } = context
+ enableLocalAuthListProfile(context)
+ setupMockAuthService(station, undefined)
+
+ const request: OCPP16SendLocalListRequest = {
+ listVersion: 1,
+ localAuthorizationList: [],
+ updateType: OCPP16UpdateType.Full,
+ }
+
+ const response = testableService.handleRequestSendLocalList(station, request)
+
+ assert.strictEqual(response.status, OCPP16UpdateStatus.NOT_SUPPORTED)
+ })
+
+ await it('should accept Full update with empty list to clear all entries', () => {
+ const { station, testableService } = context
+ enableLocalAuthListProfile(context)
+ const manager = new InMemoryLocalAuthListManager()
+ manager.setEntries(
+ [
+ { identifier: 'TAG-001', status: 'Accepted' },
+ { identifier: 'TAG-002', status: 'Accepted' },
+ ],
+ 1
+ )
+ setupMockAuthService(station, manager)
+
+ const request: OCPP16SendLocalListRequest = {
+ listVersion: 2,
+ updateType: OCPP16UpdateType.Full,
+ }
+
+ const response = testableService.handleRequestSendLocalList(station, request)
+
+ assert.strictEqual(response.status, OCPP16UpdateStatus.ACCEPTED)
+ assert.strictEqual(manager.getVersion(), 2)
+ const entries = manager.getAllEntries()
+ assert.strictEqual(entries.length, 0)
+ })
+
+ await it('should return Failed when list exceeds SendLocalListMaxLength', () => {
+ const { station, testableService } = context
+ enableLocalAuthListProfile(context)
+ upsertConfigurationKey(station, OCPP16StandardParametersKey.SendLocalListMaxLength, '1')
+ const manager = new InMemoryLocalAuthListManager()
+ setupMockAuthService(station, manager)
+
+ const request: OCPP16SendLocalListRequest = {
+ listVersion: 1,
+ localAuthorizationList: [
+ { idTag: 'TAG-001', idTagInfo: { status: OCPP16AuthorizationStatus.ACCEPTED } },
+ { idTag: 'TAG-002', idTagInfo: { status: OCPP16AuthorizationStatus.ACCEPTED } },
+ ],
+ updateType: OCPP16UpdateType.Full,
+ }
+
+ const response = testableService.handleRequestSendLocalList(station, request)
+
+ assert.strictEqual(response.status, OCPP16UpdateStatus.FAILED)
+ })
+
+ await it('should return NotSupported when LocalAuthListEnabled is false', () => {
+ const { station, testableService } = context
+ enableLocalAuthListProfile(context)
+ upsertConfigurationKey(station, OCPP16StandardParametersKey.LocalAuthListEnabled, 'false')
+ const manager = new InMemoryLocalAuthListManager()
+ setupMockAuthService(station, manager)
+
+ const request: OCPP16SendLocalListRequest = {
+ listVersion: 1,
+ localAuthorizationList: [],
+ updateType: OCPP16UpdateType.Full,
+ }
+
+ const response = testableService.handleRequestSendLocalList(station, request)
+
+ assert.strictEqual(response.status, OCPP16UpdateStatus.NOT_SUPPORTED)
+ })
+
+ await it('should return VersionMismatch for differential update with version <= current', () => {
+ const { station, testableService } = context
+ enableLocalAuthListProfile(context)
+ const manager = new InMemoryLocalAuthListManager()
+ manager.setEntries([{ identifier: 'TAG-001', status: 'Accepted' }], 5)
+ setupMockAuthService(station, manager)
+
+ const request: OCPP16SendLocalListRequest = {
+ listVersion: 3,
+ localAuthorizationList: [
+ { idTag: 'TAG-002', idTagInfo: { status: OCPP16AuthorizationStatus.ACCEPTED } },
+ ],
+ updateType: OCPP16UpdateType.Differential,
+ }
+
+ const response = testableService.handleRequestSendLocalList(station, request)
+
+ assert.strictEqual(response.status, OCPP16UpdateStatus.VERSION_MISMATCH)
+ })
+
+ await it('should return VersionMismatch for differential update with version equal to current', () => {
+ const { station, testableService } = context
+ enableLocalAuthListProfile(context)
+ const manager = new InMemoryLocalAuthListManager()
+ manager.setEntries([{ identifier: 'TAG-001', status: 'Accepted' }], 5)
+ setupMockAuthService(station, manager)
+
+ const request: OCPP16SendLocalListRequest = {
+ listVersion: 5,
+ localAuthorizationList: [
+ { idTag: 'TAG-002', idTagInfo: { status: OCPP16AuthorizationStatus.ACCEPTED } },
+ ],
+ updateType: OCPP16UpdateType.Differential,
+ }
+
+ const response = testableService.handleRequestSendLocalList(station, request)
+
+ assert.strictEqual(response.status, OCPP16UpdateStatus.VERSION_MISMATCH)
+ })
+
+ await it('should accept Full update regardless of version (no VersionMismatch)', () => {
+ const { station, testableService } = context
+ enableLocalAuthListProfile(context)
+ const manager = new InMemoryLocalAuthListManager()
+ manager.setEntries([{ identifier: 'TAG-001', status: 'Accepted' }], 5)
+ setupMockAuthService(station, manager)
+
+ const request: OCPP16SendLocalListRequest = {
+ listVersion: 3,
+ localAuthorizationList: [
+ { idTag: 'TAG-002', idTagInfo: { status: OCPP16AuthorizationStatus.ACCEPTED } },
+ ],
+ updateType: OCPP16UpdateType.Full,
+ }
+
+ const response = testableService.handleRequestSendLocalList(station, request)
+
+ assert.strictEqual(response.status, OCPP16UpdateStatus.ACCEPTED)
+ })
+ })
+})
--- /dev/null
+/**
+ * @file Tests for OCPP20IncomingRequestService LocalAuthList handlers
+ * @description Unit tests for OCPP 2.0 GetLocalListVersion and SendLocalList command handling (D01/D02)
+ */
+
+import assert from 'node:assert/strict'
+import { afterEach, beforeEach, describe, it } from 'node:test'
+
+import type { ChargingStation } from '../../../../src/charging-station/index.js'
+
+import { buildConfigKey } from '../../../../src/charging-station/index.js'
+import { createTestableIncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/__testable__/index.js'
+import { OCPP20IncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.js'
+import {
+ InMemoryLocalAuthListManager,
+ OCPPAuthServiceFactory,
+} from '../../../../src/charging-station/ocpp/auth/index.js'
+import {
+ OCPP20AuthorizationStatusEnumType,
+ OCPP20ComponentName,
+ OCPP20IdTokenEnumType,
+ OCPP20RequiredVariableName,
+ OCPP20SendLocalListStatusEnumType,
+ OCPP20UpdateEnumType,
+ OCPPVersion,
+ ReasonCodeEnumType,
+} from '../../../../src/types/index.js'
+import { Constants } from '../../../../src/utils/index.js'
+import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js'
+import { TEST_CHARGING_STATION_BASE_NAME } from '../../ChargingStationTestConstants.js'
+import { createMockChargingStation } from '../../ChargingStationTestUtils.js'
+import { upsertConfigurationKey } from './OCPP20TestUtils.js'
+
+await describe('OCPP20IncomingRequestService — LocalAuthList', async () => {
+ let station: ChargingStation
+ let testableService: ReturnType<typeof createTestableIncomingRequestService>
+ let originalGetInstance: typeof OCPPAuthServiceFactory.getInstance
+
+ /**
+ * Toggle the LocalAuthListCtrlr.Enabled configuration key on the mock station.
+ * @param enabled - Whether to enable or disable the local auth list
+ */
+ function setLocalAuthListEnabled (enabled: boolean): void {
+ upsertConfigurationKey(
+ station,
+ buildConfigKey(OCPP20ComponentName.LocalAuthListCtrlr, OCPP20RequiredVariableName.Enabled),
+ String(enabled)
+ )
+ }
+
+ beforeEach(() => {
+ const { station: mockStation } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 3,
+ evseConfiguration: { evsesCount: 3 },
+ stationInfo: {
+ ocppStrictCompliance: false,
+ ocppVersion: OCPPVersion.VERSION_201,
+ },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ station = mockStation
+ const incomingRequestService = new OCPP20IncomingRequestService()
+ testableService = createTestableIncomingRequestService(incomingRequestService)
+ originalGetInstance = OCPPAuthServiceFactory.getInstance.bind(OCPPAuthServiceFactory)
+ setLocalAuthListEnabled(true)
+ })
+
+ afterEach(() => {
+ Object.assign(OCPPAuthServiceFactory, { getInstance: originalGetInstance })
+ standardCleanup()
+ })
+
+ // ============================================================================
+ // GetLocalListVersion
+ // ============================================================================
+
+ await describe('GetLocalListVersion', async () => {
+ await it('should return version 0 for empty list', () => {
+ const manager = new InMemoryLocalAuthListManager()
+ const mockAuthService = {
+ getConfiguration: () => ({ localAuthListEnabled: true }),
+ getLocalAuthListManager: () => manager,
+ }
+ Object.assign(OCPPAuthServiceFactory, {
+ getInstance: (): typeof mockAuthService => mockAuthService,
+ })
+
+ const response = testableService.handleRequestGetLocalListVersion(station)
+
+ assert.strictEqual(response.versionNumber, 0)
+ })
+
+ await it('should return version 0 when local auth list disabled', () => {
+ setLocalAuthListEnabled(false)
+ const mockAuthService = {
+ getConfiguration: () => ({ localAuthListEnabled: false }),
+ getLocalAuthListManager: () => undefined,
+ }
+ Object.assign(OCPPAuthServiceFactory, {
+ getInstance: (): typeof mockAuthService => mockAuthService,
+ })
+
+ const response = testableService.handleRequestGetLocalListVersion(station)
+
+ assert.strictEqual(response.versionNumber, 0)
+ })
+
+ await it('should return version 0 when manager is undefined', () => {
+ const mockAuthService = {
+ getConfiguration: () => ({ localAuthListEnabled: true }),
+ getLocalAuthListManager: () => undefined,
+ }
+ Object.assign(OCPPAuthServiceFactory, {
+ getInstance: (): typeof mockAuthService => mockAuthService,
+ })
+
+ const response = testableService.handleRequestGetLocalListVersion(station)
+
+ assert.strictEqual(response.versionNumber, 0)
+ })
+
+ await it('should return correct version after SendLocalList', () => {
+ const manager = new InMemoryLocalAuthListManager()
+ manager.setEntries([{ identifier: 'TOKEN_001', status: 'Accepted' }], 5)
+ const mockAuthService = {
+ getConfiguration: () => ({ localAuthListEnabled: true }),
+ getLocalAuthListManager: () => manager,
+ }
+ Object.assign(OCPPAuthServiceFactory, {
+ getInstance: (): typeof mockAuthService => mockAuthService,
+ })
+
+ const response = testableService.handleRequestGetLocalListVersion(station)
+
+ assert.strictEqual(response.versionNumber, 5)
+ })
+
+ await it('should return version 0 when auth service throws', () => {
+ Object.assign(OCPPAuthServiceFactory, {
+ getInstance: (): never => {
+ throw new Error('Auth service unavailable')
+ },
+ })
+
+ const response = testableService.handleRequestGetLocalListVersion(station)
+
+ assert.strictEqual(response.versionNumber, 0)
+ })
+ })
+
+ // ============================================================================
+ // SendLocalList
+ // ============================================================================
+
+ await describe('SendLocalList', async () => {
+ await it('should accept Full update and replace list', () => {
+ const manager = new InMemoryLocalAuthListManager()
+ const mockAuthService = {
+ getConfiguration: () => ({ localAuthListEnabled: true }),
+ getLocalAuthListManager: () => manager,
+ }
+ Object.assign(OCPPAuthServiceFactory, {
+ getInstance: (): typeof mockAuthService => mockAuthService,
+ })
+
+ const response = testableService.handleRequestSendLocalList(station, {
+ localAuthorizationList: [
+ {
+ idToken: { idToken: 'TOKEN_001', type: OCPP20IdTokenEnumType.ISO14443 },
+ idTokenInfo: { status: OCPP20AuthorizationStatusEnumType.Accepted },
+ },
+ {
+ idToken: { idToken: 'TOKEN_002', type: OCPP20IdTokenEnumType.eMAID },
+ idTokenInfo: { status: OCPP20AuthorizationStatusEnumType.Blocked },
+ },
+ ],
+ updateType: OCPP20UpdateEnumType.Full,
+ versionNumber: 3,
+ })
+
+ assert.strictEqual(response.status, OCPP20SendLocalListStatusEnumType.Accepted)
+
+ const entries = manager.getAllEntries()
+ assert.strictEqual(entries.length, 2)
+
+ const version = manager.getVersion()
+ assert.strictEqual(version, 3)
+ })
+
+ await it('should accept Differential update with complex IdToken types', () => {
+ const manager = new InMemoryLocalAuthListManager()
+ manager.setEntries([{ identifier: 'EXISTING_001', status: 'Accepted' }], 1)
+ const mockAuthService = {
+ getConfiguration: () => ({ localAuthListEnabled: true }),
+ getLocalAuthListManager: () => manager,
+ }
+ Object.assign(OCPPAuthServiceFactory, {
+ getInstance: (): typeof mockAuthService => mockAuthService,
+ })
+
+ const response = testableService.handleRequestSendLocalList(station, {
+ localAuthorizationList: [
+ {
+ idToken: { idToken: 'NEW_TOKEN', type: OCPP20IdTokenEnumType.ISO15693 },
+ idTokenInfo: { status: OCPP20AuthorizationStatusEnumType.Accepted },
+ },
+ ],
+ updateType: OCPP20UpdateEnumType.Differential,
+ versionNumber: 2,
+ })
+
+ assert.strictEqual(response.status, OCPP20SendLocalListStatusEnumType.Accepted)
+
+ const entries = manager.getAllEntries()
+ assert.strictEqual(entries.length, 2)
+
+ const version = manager.getVersion()
+ assert.strictEqual(version, 2)
+ })
+
+ await it('should return Failed when disabled (with statusInfo)', () => {
+ setLocalAuthListEnabled(false)
+ const mockAuthService = {
+ getConfiguration: () => ({ localAuthListEnabled: false }),
+ getLocalAuthListManager: () => undefined,
+ }
+ Object.assign(OCPPAuthServiceFactory, {
+ getInstance: (): typeof mockAuthService => mockAuthService,
+ })
+
+ const response = testableService.handleRequestSendLocalList(station, {
+ localAuthorizationList: [],
+ updateType: OCPP20UpdateEnumType.Full,
+ versionNumber: 1,
+ })
+
+ assert.strictEqual(response.status, OCPP20SendLocalListStatusEnumType.Failed)
+ assert.notStrictEqual(response.statusInfo, undefined)
+ assert.strictEqual(response.statusInfo?.reasonCode, ReasonCodeEnumType.NotEnabled)
+ })
+
+ await it('should return Failed when manager is undefined', () => {
+ const mockAuthService = {
+ getConfiguration: () => ({ localAuthListEnabled: true }),
+ getLocalAuthListManager: () => undefined,
+ }
+ Object.assign(OCPPAuthServiceFactory, {
+ getInstance: (): typeof mockAuthService => mockAuthService,
+ })
+
+ const response = testableService.handleRequestSendLocalList(station, {
+ localAuthorizationList: [],
+ updateType: OCPP20UpdateEnumType.Full,
+ versionNumber: 1,
+ })
+
+ assert.strictEqual(response.status, OCPP20SendLocalListStatusEnumType.Failed)
+ assert.notStrictEqual(response.statusInfo, undefined)
+ })
+
+ await it('should clear all entries with Full update and empty list', () => {
+ const manager = new InMemoryLocalAuthListManager()
+ manager.setEntries(
+ [
+ { identifier: 'TOKEN_A', status: 'Accepted' },
+ { identifier: 'TOKEN_B', status: 'Accepted' },
+ ],
+ 1
+ )
+ const mockAuthService = {
+ getConfiguration: () => ({ localAuthListEnabled: true }),
+ getLocalAuthListManager: () => manager,
+ }
+ Object.assign(OCPPAuthServiceFactory, {
+ getInstance: (): typeof mockAuthService => mockAuthService,
+ })
+
+ const response = testableService.handleRequestSendLocalList(station, {
+ localAuthorizationList: [],
+ updateType: OCPP20UpdateEnumType.Full,
+ versionNumber: 2,
+ })
+
+ assert.strictEqual(response.status, OCPP20SendLocalListStatusEnumType.Accepted)
+
+ const entries = manager.getAllEntries()
+ assert.strictEqual(entries.length, 0)
+
+ const version = manager.getVersion()
+ assert.strictEqual(version, 2)
+ })
+
+ await it('should return Failed when auth service throws', () => {
+ Object.assign(OCPPAuthServiceFactory, {
+ getInstance: (): never => {
+ throw new Error('Auth service unavailable')
+ },
+ })
+
+ const response = testableService.handleRequestSendLocalList(station, {
+ localAuthorizationList: [],
+ updateType: OCPP20UpdateEnumType.Full,
+ versionNumber: 1,
+ })
+
+ assert.strictEqual(response.status, OCPP20SendLocalListStatusEnumType.Failed)
+ })
+
+ await it('should handle Differential update removing entries (no idTokenInfo)', () => {
+ const manager = new InMemoryLocalAuthListManager()
+ manager.setEntries(
+ [
+ { identifier: 'REMOVE_ME', status: 'Accepted' },
+ { identifier: 'KEEP_ME', status: 'Accepted' },
+ ],
+ 1
+ )
+ const mockAuthService = {
+ getConfiguration: () => ({ localAuthListEnabled: true }),
+ getLocalAuthListManager: () => manager,
+ }
+ Object.assign(OCPPAuthServiceFactory, {
+ getInstance: (): typeof mockAuthService => mockAuthService,
+ })
+
+ const response = testableService.handleRequestSendLocalList(station, {
+ localAuthorizationList: [
+ {
+ idToken: { idToken: 'REMOVE_ME', type: OCPP20IdTokenEnumType.ISO14443 },
+ // No idTokenInfo → status will be undefined → removal in differential
+ },
+ ],
+ updateType: OCPP20UpdateEnumType.Differential,
+ versionNumber: 2,
+ })
+
+ assert.strictEqual(response.status, OCPP20SendLocalListStatusEnumType.Accepted)
+
+ const entries = manager.getAllEntries()
+ assert.strictEqual(entries.length, 1)
+ assert.strictEqual(entries[0].identifier, 'KEEP_ME')
+
+ const version = manager.getVersion()
+ assert.strictEqual(version, 2)
+ })
+
+ await it('should preserve idTokenType metadata in entries', () => {
+ const manager = new InMemoryLocalAuthListManager()
+ const mockAuthService = {
+ getConfiguration: () => ({ localAuthListEnabled: true }),
+ getLocalAuthListManager: () => manager,
+ }
+ Object.assign(OCPPAuthServiceFactory, {
+ getInstance: (): typeof mockAuthService => mockAuthService,
+ })
+
+ testableService.handleRequestSendLocalList(station, {
+ localAuthorizationList: [
+ {
+ idToken: { idToken: 'EMAID_TOKEN', type: OCPP20IdTokenEnumType.eMAID },
+ idTokenInfo: { status: OCPP20AuthorizationStatusEnumType.Accepted },
+ },
+ ],
+ updateType: OCPP20UpdateEnumType.Full,
+ versionNumber: 1,
+ })
+
+ const entry = manager.getEntry('EMAID_TOKEN')
+ assert.ok(entry != null)
+ assert.strictEqual(entry.status, 'Accepted')
+ assert.deepStrictEqual(entry.metadata, { idTokenType: OCPP20IdTokenEnumType.eMAID })
+ })
+
+ await it('should handle Full update with undefined localAuthorizationList', () => {
+ const manager = new InMemoryLocalAuthListManager()
+ manager.setEntries([{ identifier: 'OLD_TOKEN', status: 'Accepted' }], 1)
+ const mockAuthService = {
+ getConfiguration: () => ({ localAuthListEnabled: true }),
+ getLocalAuthListManager: () => manager,
+ }
+ Object.assign(OCPPAuthServiceFactory, {
+ getInstance: (): typeof mockAuthService => mockAuthService,
+ })
+
+ const response = testableService.handleRequestSendLocalList(station, {
+ updateType: OCPP20UpdateEnumType.Full,
+ versionNumber: 2,
+ })
+
+ assert.strictEqual(response.status, OCPP20SendLocalListStatusEnumType.Accepted)
+
+ const entries = manager.getAllEntries()
+ assert.strictEqual(entries.length, 0)
+
+ const version = manager.getVersion()
+ assert.strictEqual(version, 2)
+ })
+
+ await it('should convert cacheExpiryDateTime to Date', () => {
+ const manager = new InMemoryLocalAuthListManager()
+ const mockAuthService = {
+ getConfiguration: () => ({ localAuthListEnabled: true }),
+ getLocalAuthListManager: () => manager,
+ }
+ Object.assign(OCPPAuthServiceFactory, {
+ getInstance: (): typeof mockAuthService => mockAuthService,
+ })
+
+ const expiryDate = new Date('2027-01-01T00:00:00.000Z')
+
+ testableService.handleRequestSendLocalList(station, {
+ localAuthorizationList: [
+ {
+ idToken: { idToken: 'EXPIRY_TOKEN', type: OCPP20IdTokenEnumType.ISO14443 },
+ idTokenInfo: {
+ cacheExpiryDateTime: expiryDate,
+ status: OCPP20AuthorizationStatusEnumType.Accepted,
+ },
+ },
+ ],
+ updateType: OCPP20UpdateEnumType.Full,
+ versionNumber: 1,
+ })
+
+ const entry = manager.getEntry('EXPIRY_TOKEN')
+ assert.notStrictEqual(entry, undefined)
+ assert.ok(entry?.expiryDate instanceof Date)
+ })
+
+ await it('should return VersionMismatch for differential update with version < current', () => {
+ const manager = new InMemoryLocalAuthListManager()
+ manager.setEntries([{ identifier: 'TOKEN_001', status: 'Accepted' }], 5)
+ const mockAuthService = {
+ getConfiguration: () => ({ localAuthListEnabled: true }),
+ getLocalAuthListManager: () => manager,
+ }
+ Object.assign(OCPPAuthServiceFactory, {
+ getInstance: (): typeof mockAuthService => mockAuthService,
+ })
+
+ const response = testableService.handleRequestSendLocalList(station, {
+ localAuthorizationList: [
+ {
+ idToken: { idToken: 'NEW_TOKEN', type: OCPP20IdTokenEnumType.ISO14443 },
+ idTokenInfo: { status: OCPP20AuthorizationStatusEnumType.Accepted },
+ },
+ ],
+ updateType: OCPP20UpdateEnumType.Differential,
+ versionNumber: 3,
+ })
+
+ assert.strictEqual(response.status, OCPP20SendLocalListStatusEnumType.VersionMismatch)
+ })
+
+ await it('should return VersionMismatch for differential update with version equal to current', () => {
+ const manager = new InMemoryLocalAuthListManager()
+ manager.setEntries([{ identifier: 'TOKEN_001', status: 'Accepted' }], 5)
+ const mockAuthService = {
+ getConfiguration: () => ({ localAuthListEnabled: true }),
+ getLocalAuthListManager: () => manager,
+ }
+ Object.assign(OCPPAuthServiceFactory, {
+ getInstance: (): typeof mockAuthService => mockAuthService,
+ })
+
+ const response = testableService.handleRequestSendLocalList(station, {
+ localAuthorizationList: [
+ {
+ idToken: { idToken: 'NEW_TOKEN', type: OCPP20IdTokenEnumType.ISO14443 },
+ idTokenInfo: { status: OCPP20AuthorizationStatusEnumType.Accepted },
+ },
+ ],
+ updateType: OCPP20UpdateEnumType.Differential,
+ versionNumber: 5,
+ })
+
+ assert.strictEqual(response.status, OCPP20SendLocalListStatusEnumType.VersionMismatch)
+ })
+
+ await it('should return Failed with versionNumber <= 0', () => {
+ const manager = new InMemoryLocalAuthListManager()
+ const mockAuthService = {
+ getConfiguration: () => ({ localAuthListEnabled: true }),
+ getLocalAuthListManager: () => manager,
+ }
+ Object.assign(OCPPAuthServiceFactory, {
+ getInstance: (): typeof mockAuthService => mockAuthService,
+ })
+
+ const response = testableService.handleRequestSendLocalList(station, {
+ localAuthorizationList: [],
+ updateType: OCPP20UpdateEnumType.Full,
+ versionNumber: 0,
+ })
+
+ assert.strictEqual(response.status, OCPP20SendLocalListStatusEnumType.Failed)
+ })
+
+ await it('should return Failed with negative versionNumber', () => {
+ const manager = new InMemoryLocalAuthListManager()
+ const mockAuthService = {
+ getConfiguration: () => ({ localAuthListEnabled: true }),
+ getLocalAuthListManager: () => manager,
+ }
+ Object.assign(OCPPAuthServiceFactory, {
+ getInstance: (): typeof mockAuthService => mockAuthService,
+ })
+
+ const response = testableService.handleRequestSendLocalList(station, {
+ localAuthorizationList: [],
+ updateType: OCPP20UpdateEnumType.Full,
+ versionNumber: -1,
+ })
+
+ assert.strictEqual(response.status, OCPP20SendLocalListStatusEnumType.Failed)
+ })
+
+ await it('should accept Full update regardless of version (no VersionMismatch)', () => {
+ const manager = new InMemoryLocalAuthListManager()
+ manager.setEntries([{ identifier: 'TOKEN_001', status: 'Accepted' }], 5)
+ const mockAuthService = {
+ getConfiguration: () => ({ localAuthListEnabled: true }),
+ getLocalAuthListManager: () => manager,
+ }
+ Object.assign(OCPPAuthServiceFactory, {
+ getInstance: (): typeof mockAuthService => mockAuthService,
+ })
+
+ const response = testableService.handleRequestSendLocalList(station, {
+ localAuthorizationList: [
+ {
+ idToken: { idToken: 'NEW_TOKEN', type: OCPP20IdTokenEnumType.ISO14443 },
+ idTokenInfo: { status: OCPP20AuthorizationStatusEnumType.Accepted },
+ },
+ ],
+ updateType: OCPP20UpdateEnumType.Full,
+ versionNumber: 3,
+ })
+
+ assert.strictEqual(response.status, OCPP20SendLocalListStatusEnumType.Accepted)
+ })
+ })
+})
import { afterEach, beforeEach, describe, it } from 'node:test'
import type { ChargingStation } from '../../../../src/charging-station/index.js'
-import type { LocalAuthEntry } from '../../../../src/charging-station/ocpp/auth/interfaces/OCPPAuthService.js'
import { InMemoryAuthCache } from '../../../../src/charging-station/ocpp/auth/cache/InMemoryAuthCache.js'
import { OCPPAuthServiceImpl } from '../../../../src/charging-station/ocpp/auth/services/OCPPAuthServiceImpl.js'
})
// C13.FR.01.INT.01 - Local Auth List exclusion (R17)
- await it('C13.FR.01.INT.01: identifiers from local auth list are not cached', async () => {
+ await it('C13.FR.01.INT.01: identifiers from local auth list are not cached', () => {
const cache = new InMemoryAuthCache({ cleanupIntervalSeconds: 0 })
try {
const listManager = createMockLocalAuthListManager({
getEntry: (id: string) =>
- new Promise<LocalAuthEntry | undefined>(resolve => {
- resolve(id === 'LIST-ID' ? { identifier: 'LIST-ID', status: 'accepted' } : undefined)
- }),
+ id === 'LIST-ID' ? { identifier: 'LIST-ID', status: 'accepted' } : undefined,
})
const strategy = new LocalAuthStrategy(listManager, cache)
const request = createMockAuthRequest({
identifier: createMockIdentifier('LIST-ID'),
})
- const result = await strategy.authenticate(request, config)
+ const result = strategy.authenticate(request, config)
// Should be authorized from local list
assert.notStrictEqual(result, undefined)
--- /dev/null
+/**
+ * @file Tests for InMemoryLocalAuthListManager
+ * @description Unit tests for in-memory local authorization list management
+ */
+import assert from 'node:assert/strict'
+import { afterEach, beforeEach, describe, it } from 'node:test'
+
+import type { DifferentialAuthEntry } from '../../../../../src/charging-station/ocpp/auth/interfaces/OCPPAuthService.js'
+import type { LocalAuthEntry } from '../../../../../src/charging-station/ocpp/auth/interfaces/OCPPAuthService.js'
+
+import { InMemoryLocalAuthListManager } from '../../../../../src/charging-station/ocpp/auth/cache/InMemoryLocalAuthListManager.js'
+import { standardCleanup } from '../../../../helpers/TestLifecycleHelpers.js'
+
+const createEntry = (
+ identifier: string,
+ status = 'Accepted',
+ overrides?: Partial<LocalAuthEntry>
+): LocalAuthEntry => ({
+ identifier,
+ status,
+ ...overrides,
+})
+
+await describe('InMemoryLocalAuthListManager', async () => {
+ let manager: InMemoryLocalAuthListManager
+
+ beforeEach(() => {
+ manager = new InMemoryLocalAuthListManager()
+ })
+
+ afterEach(() => {
+ standardCleanup()
+ })
+
+ await describe('getVersion', async () => {
+ await it('should return 0 initially', () => {
+ const version = manager.getVersion()
+
+ assert.strictEqual(version, 0)
+ })
+ })
+
+ await describe('addEntry and getEntry', async () => {
+ await it('should add and retrieve an entry', () => {
+ const entry = createEntry('tag-001', 'Accepted', {
+ expiryDate: new Date('2030-01-01'),
+ parentId: 'parent-001',
+ })
+
+ manager.addEntry(entry)
+ const result = manager.getEntry('tag-001')
+
+ if (result == null) {
+ assert.fail('Expected entry to be defined')
+ }
+ assert.strictEqual(result.identifier, 'tag-001')
+ assert.strictEqual(result.status, 'Accepted')
+ assert.strictEqual(result.parentId, 'parent-001')
+ assert.deepStrictEqual(result.expiryDate, new Date('2030-01-01'))
+ })
+
+ await it('should update existing entry with same identifier', () => {
+ // Arrange
+ const original = createEntry('tag-001', 'Accepted')
+ const updated = createEntry('tag-001', 'Blocked')
+
+ // Act
+ manager.addEntry(original)
+ manager.addEntry(updated)
+ const result = manager.getEntry('tag-001')
+
+ // Assert
+ assert.strictEqual(result?.status, 'Blocked')
+ const all = manager.getAllEntries()
+ assert.strictEqual(all.length, 1)
+ })
+ })
+
+ await describe('removeEntry', async () => {
+ await it('should remove an existing entry', () => {
+ manager.addEntry(createEntry('tag-001'))
+
+ manager.removeEntry('tag-001')
+ const result = manager.getEntry('tag-001')
+
+ assert.strictEqual(result, undefined)
+ })
+
+ await it('should be a no-op for non-existent identifier', () => {
+ manager.addEntry(createEntry('tag-001'))
+
+ manager.removeEntry('non-existent')
+ const all = manager.getAllEntries()
+
+ assert.strictEqual(all.length, 1)
+ })
+ })
+
+ await describe('clearAll', async () => {
+ await it('should remove all entries but not reset version', () => {
+ // Arrange
+ manager.addEntry(createEntry('tag-001'))
+ manager.addEntry(createEntry('tag-002'))
+ manager.addEntry(createEntry('tag-003'))
+ manager.updateVersion(5)
+
+ // Act
+ manager.clearAll()
+
+ // Assert
+ const all = manager.getAllEntries()
+ assert.strictEqual(all.length, 0)
+ const version = manager.getVersion()
+ assert.strictEqual(version, 5)
+ })
+ })
+
+ await describe('updateVersion', async () => {
+ await it('should set version correctly', () => {
+ manager.updateVersion(42)
+ const version = manager.getVersion()
+
+ assert.strictEqual(version, 42)
+ })
+ })
+
+ await describe('getAllEntries', async () => {
+ await it('should return complete array of entries', () => {
+ // Arrange
+ const entries = [
+ createEntry('tag-001', 'Accepted'),
+ createEntry('tag-002', 'Blocked'),
+ createEntry('tag-003', 'Expired'),
+ ]
+
+ // Act
+ for (const entry of entries) {
+ manager.addEntry(entry)
+ }
+ const all = manager.getAllEntries()
+
+ // Assert
+ assert.strictEqual(all.length, 3)
+ const identifiers = all.map(e => e.identifier).sort()
+ assert.deepStrictEqual(identifiers, ['tag-001', 'tag-002', 'tag-003'])
+ })
+ })
+
+ await describe('setEntries (full update)', async () => {
+ await it('should clear old entries, set new ones, and set version', () => {
+ // Arrange
+ manager.addEntry(createEntry('old-001'))
+ manager.addEntry(createEntry('old-002'))
+ manager.updateVersion(1)
+ const newEntries = [createEntry('new-001', 'Accepted'), createEntry('new-002', 'Blocked')]
+
+ // Act
+ manager.setEntries(newEntries, 5)
+
+ // Assert
+ const all = manager.getAllEntries()
+ assert.strictEqual(all.length, 2)
+ const identifiers = all.map(e => e.identifier).sort()
+ assert.deepStrictEqual(identifiers, ['new-001', 'new-002'])
+ assert.strictEqual(manager.getEntry('old-001'), undefined)
+ assert.strictEqual(manager.getVersion(), 5)
+ })
+ })
+
+ await describe('applyDifferentialUpdate', async () => {
+ await it('should add a new entry when status is defined', () => {
+ const diffEntries: DifferentialAuthEntry[] = [{ identifier: 'new-tag', status: 'Accepted' }]
+
+ manager.applyDifferentialUpdate(diffEntries, 1)
+ const result = manager.getEntry('new-tag')
+
+ if (result == null) {
+ assert.fail('Expected entry to be defined')
+ }
+ assert.strictEqual(result.status, 'Accepted')
+ })
+
+ await it('should update an existing entry when status is defined', () => {
+ manager.addEntry(createEntry('tag-001', 'Accepted'))
+ const diffEntries: DifferentialAuthEntry[] = [{ identifier: 'tag-001', status: 'Blocked' }]
+
+ manager.applyDifferentialUpdate(diffEntries, 2)
+ const result = manager.getEntry('tag-001')
+
+ assert.strictEqual(result?.status, 'Blocked')
+ })
+
+ await it('should remove an entry when status is undefined', () => {
+ manager.addEntry(createEntry('tag-001', 'Accepted'))
+ const diffEntries: DifferentialAuthEntry[] = [{ identifier: 'tag-001' }]
+
+ manager.applyDifferentialUpdate(diffEntries, 2)
+ const result = manager.getEntry('tag-001')
+
+ assert.strictEqual(result, undefined)
+ })
+
+ await it('should handle mixed add, update, and remove in one call', () => {
+ // Arrange
+ manager.addEntry(createEntry('existing-update', 'Accepted'))
+ manager.addEntry(createEntry('existing-remove', 'Accepted'))
+ const diffEntries: DifferentialAuthEntry[] = [
+ { identifier: 'brand-new', status: 'Accepted' },
+ { identifier: 'existing-update', status: 'Blocked' },
+ { identifier: 'existing-remove' },
+ ]
+
+ // Act
+ manager.applyDifferentialUpdate(diffEntries, 10)
+
+ // Assert
+ const brandNew = manager.getEntry('brand-new')
+ if (brandNew == null) {
+ assert.fail('Expected brand-new entry to be defined')
+ }
+ assert.strictEqual(brandNew.status, 'Accepted')
+
+ const updated = manager.getEntry('existing-update')
+ assert.strictEqual(updated?.status, 'Blocked')
+
+ const removed = manager.getEntry('existing-remove')
+ assert.strictEqual(removed, undefined)
+
+ assert.strictEqual(manager.getVersion(), 10)
+ })
+ })
+
+ await describe('maxEntries limit', async () => {
+ await it('should throw when adding exceeds capacity', () => {
+ const limitedManager = new InMemoryLocalAuthListManager(2)
+ limitedManager.addEntry(createEntry('tag-001'))
+ limitedManager.addEntry(createEntry('tag-002'))
+
+ assert.throws(
+ () => {
+ limitedManager.addEntry(createEntry('tag-003'))
+ },
+ {
+ message: /maximum capacity of 2 entries reached/,
+ }
+ )
+ })
+
+ await it('should not block updates to existing entries', () => {
+ const limitedManager = new InMemoryLocalAuthListManager(2)
+ limitedManager.addEntry(createEntry('tag-001', 'Accepted'))
+ limitedManager.addEntry(createEntry('tag-002', 'Accepted'))
+
+ limitedManager.addEntry(createEntry('tag-001', 'Blocked'))
+ const result = limitedManager.getEntry('tag-001')
+
+ assert.strictEqual(result?.status, 'Blocked')
+ })
+
+ await it('should not partially mutate on capacity error in applyDifferentialUpdate', () => {
+ const limitedManager = new InMemoryLocalAuthListManager(3)
+ limitedManager.setEntries(
+ [createEntry('TAG001'), createEntry('TAG002'), createEntry('TAG003')],
+ 1
+ )
+
+ assert.throws(
+ () => {
+ limitedManager.applyDifferentialUpdate(
+ [
+ { identifier: 'TAG004', status: 'Accepted' },
+ { identifier: 'TAG005', status: 'Accepted' },
+ ],
+ 2
+ )
+ },
+ { message: /maximum capacity of 3 entries/ }
+ )
+
+ const entries = limitedManager.getAllEntries()
+ assert.strictEqual(entries.length, 3)
+ const identifiers = entries.map(e => e.identifier).sort()
+ assert.deepStrictEqual(identifiers, ['TAG001', 'TAG002', 'TAG003'])
+
+ const version = limitedManager.getVersion()
+ assert.strictEqual(version, 1)
+ })
+ })
+})
* @file Tests for AuthComponentFactory
* @description Unit tests for authentication component factory
*/
-/* eslint-disable @typescript-eslint/no-confusing-void-expression */
+
import assert from 'node:assert/strict'
import { afterEach, describe, it } from 'node:test'
})
await describe('createLocalAuthListManager', async () => {
- await it('should return undefined (delegated to service)', () => {
- const { station: chargingStation } = createMockChargingStation()
+ await it('should create local auth list manager when enabled', () => {
const config: AuthConfiguration = {
allowOfflineTxForUnknownId: false,
authorizationCacheEnabled: false,
offlineAuthorizationEnabled: false,
}
- const result = AuthComponentFactory.createLocalAuthListManager(chargingStation, config)
+ const result = AuthComponentFactory.createLocalAuthListManager(config)
+
+ assert.notStrictEqual(result, undefined)
+ })
+
+ await it('should return undefined when local auth list disabled', () => {
+ const config: AuthConfiguration = {
+ allowOfflineTxForUnknownId: false,
+ authorizationCacheEnabled: false,
+ authorizationTimeout: TEST_AUTHORIZATION_TIMEOUT_MS,
+ certificateAuthEnabled: false,
+ localAuthListEnabled: false,
+ localPreAuthorize: false,
+ offlineAuthorizationEnabled: false,
+ }
+
+ const result = AuthComponentFactory.createLocalAuthListManager(config)
assert.strictEqual(result, undefined)
})
import type { ChargingStation } from '../../../../../src/charging-station/index.js'
import type {
AuthCache,
- LocalAuthEntry,
LocalAuthListManager,
OCPPAuthAdapter,
OCPPAuthService,
/* empty */
},
getConfiguration: () => ({}) as AuthConfiguration,
+ getLocalAuthListManager: () => undefined,
getStats: () => ({
avgResponseTime: 0,
cacheHitRate: 0,
value: typeof identifier === 'string' ? identifier : identifier.idToken,
}),
getConfigurationSchema: () => ({}),
+ getMaxLocalAuthListEntries: () => undefined,
isRemoteAvailable: () => true,
ocppVersion,
validateConfiguration: (_config: AuthConfiguration) => true,
/**
* Create a mock LocalAuthListManager for testing.
* @param overrides - Partial LocalAuthListManager methods to override defaults
- * @returns Mock LocalAuthListManager with stubbed async methods
+ * @returns Mock LocalAuthListManager with stubbed methods
*/
export const createMockLocalAuthListManager = (
overrides?: Partial<LocalAuthListManager>
): LocalAuthListManager => ({
- addEntry: () =>
- new Promise<void>(resolve => {
- resolve()
- }),
- clearAll: () =>
- new Promise<void>(resolve => {
- resolve()
- }),
- getAllEntries: () =>
- new Promise<LocalAuthEntry[]>(resolve => {
- resolve([])
- }),
- getEntry: () =>
- new Promise<LocalAuthEntry | undefined>(resolve => {
- resolve(undefined)
- }),
- getVersion: () =>
- new Promise<number>(resolve => {
- resolve(1)
- }),
- removeEntry: () =>
- new Promise<void>(resolve => {
- resolve()
- }),
- updateVersion: () =>
- new Promise<void>(resolve => {
- resolve()
- }),
+ addEntry: () => {
+ /* empty */
+ },
+ applyDifferentialUpdate: () => {
+ /* empty */
+ },
+ clearAll: () => {
+ /* empty */
+ },
+ getAllEntries: () => [],
+ getEntry: () => undefined,
+ getVersion: () => 1,
+ removeEntry: () => {
+ /* empty */
+ },
+ setEntries: () => {
+ /* empty */
+ },
+ updateVersion: () => {
+ /* empty */
+ },
...overrides,
})
})
await describe('C10.FR.03 - cache post-authorize', async () => {
- await it('should accept non-Accepted cached token without re-auth when DisablePostAuthorize=true', async () => {
+ await it('should accept non-Accepted cached token without re-auth when DisablePostAuthorize=true', () => {
// Arrange
const blockedResult = createMockAuthorizationResult({
method: AuthenticationMethod.CACHE,
})
// Act
- const result = await strategy.authenticate(request, config)
+ const result = strategy.authenticate(request, config)
// Assert
assert.notStrictEqual(result, undefined)
assert.strictEqual(result.method, AuthenticationMethod.CACHE)
})
- await it('should trigger re-auth for non-Accepted cached token when DisablePostAuthorize=false', async () => {
+ await it('should trigger re-auth for non-Accepted cached token when DisablePostAuthorize=false', () => {
// Arrange
const blockedResult = createMockAuthorizationResult({
method: AuthenticationMethod.CACHE,
})
// Act
- const result = await strategy.authenticate(request, config)
+ const result = strategy.authenticate(request, config)
// Assert - undefined signals orchestrator should try remote strategy
assert.strictEqual(result, undefined)
})
await describe('C14.FR.03 - local list post-authorize', async () => {
- await it('should accept non-Accepted local list token without re-auth when DisablePostAuthorize=true', async () => {
+ await it('should accept non-Accepted local list token without re-auth when DisablePostAuthorize=true', () => {
const localListManager = createMockLocalAuthListManager({
- getEntry: () =>
- new Promise(resolve => {
- resolve({
- identifier: 'BLOCKED-TAG',
- status: 'Blocked',
- })
- }),
+ getEntry: () => ({
+ identifier: 'BLOCKED-TAG',
+ status: 'Blocked',
+ }),
})
strategy = new LocalAuthStrategy(localListManager, undefined)
const config = createTestAuthConfig({
identifier: createMockIdentifier('BLOCKED-TAG', IdentifierType.ISO14443),
})
- const result = await strategy.authenticate(request, config)
+ const result = strategy.authenticate(request, config)
assert.notStrictEqual(result, undefined)
assert.strictEqual(result?.status, AuthorizationStatus.BLOCKED)
})
await describe('default behavior', async () => {
- await it('should trigger re-auth when DisablePostAuthorize not configured (defaults to enabled)', async () => {
+ await it('should trigger re-auth when DisablePostAuthorize not configured (defaults to enabled)', () => {
// Arrange
const blockedResult = createMockAuthorizationResult({
method: AuthenticationMethod.CACHE,
})
// Act
- const result = await strategy.authenticate(request, config)
+ const result = strategy.authenticate(request, config)
// Assert - undefined signals orchestrator should try remote strategy (C10.FR.03)
assert.strictEqual(result, undefined)
import type {
AuthCache,
- LocalAuthEntry,
LocalAuthListManager,
} from '../../../../../src/charging-station/ocpp/auth/interfaces/OCPPAuthService.js'
strategy.initialize(config)
})
- await it('should authenticate using local auth list', async () => {
- mockLocalAuthListManager.getEntry = () =>
- new Promise<LocalAuthEntry | undefined>(resolve => {
- resolve({
- expiryDate: new Date(Date.now() + 86400000),
- identifier: 'LOCAL_TAG',
- metadata: { source: 'local' },
- status: 'accepted',
- })
- })
+ await it('should authenticate using local auth list', () => {
+ mockLocalAuthListManager.getEntry = () => ({
+ expiryDate: new Date(Date.now() + 86400000),
+ identifier: 'LOCAL_TAG',
+ metadata: { source: 'local' },
+ status: 'accepted',
+ })
const config = createTestAuthConfig({
authorizationCacheEnabled: true,
identifier: createMockIdentifier('LOCAL_TAG', IdentifierType.ID_TAG),
})
- const result = await strategy.authenticate(request, config)
+ const result = strategy.authenticate(request, config)
assert.notStrictEqual(result, undefined)
assert.strictEqual(result?.status, AuthorizationStatus.ACCEPTED)
assert.strictEqual(result.method, AuthenticationMethod.LOCAL_LIST)
})
- await it('should authenticate using cache', async () => {
+ await it('should authenticate using cache', () => {
mockAuthCache.get = () =>
createMockAuthorizationResult({
cacheTtl: 300,
identifier: createMockIdentifier('CACHED_TAG', IdentifierType.ID_TAG),
})
- const result = await strategy.authenticate(request, config)
+ const result = strategy.authenticate(request, config)
assert.notStrictEqual(result, undefined)
assert.strictEqual(result?.status, AuthorizationStatus.ACCEPTED)
assert.strictEqual(result.method, AuthenticationMethod.CACHE)
})
- await it('should use offline fallback for transaction stop', async () => {
+ await it('should use offline fallback for transaction stop', () => {
const config = createTestAuthConfig({ offlineAuthorizationEnabled: true })
const request = createMockAuthRequest({
allowOffline: true,
identifier: createMockIdentifier('UNKNOWN_TAG', IdentifierType.ID_TAG),
})
- const result = await strategy.authenticate(request, config)
+ const result = strategy.authenticate(request, config)
assert.notStrictEqual(result, undefined)
assert.strictEqual(result?.status, AuthorizationStatus.ACCEPTED)
assert.strictEqual(result.isOffline, true)
})
- await it('should return undefined when no local auth available', async () => {
+ await it('should return undefined when no local auth available', () => {
const config = createTestAuthConfig()
const request = createMockAuthRequest({
identifier: createMockIdentifier('UNKNOWN_TAG', IdentifierType.ID_TAG),
})
- const result = await strategy.authenticate(request, config)
+ const result = strategy.authenticate(request, config)
assert.strictEqual(result, undefined)
})
})
})
await describe('isInLocalList', async () => {
- await it('should return true when identifier is in local list', async () => {
- mockLocalAuthListManager.getEntry = () =>
- new Promise<LocalAuthEntry | undefined>(resolve => {
- resolve({
- identifier: 'LOCAL_TAG',
- status: 'accepted',
- })
- })
+ await it('should return true when identifier is in local list', () => {
+ mockLocalAuthListManager.getEntry = () => ({
+ identifier: 'LOCAL_TAG',
+ status: 'accepted',
+ })
- assert.strictEqual(await strategy.isInLocalList('LOCAL_TAG'), true)
+ assert.strictEqual(strategy.isInLocalList('LOCAL_TAG'), true)
})
- await it('should return false when identifier is not in local list', async () => {
- mockLocalAuthListManager.getEntry = () =>
- new Promise<LocalAuthEntry | undefined>(resolve => {
- resolve(undefined)
- })
+ await it('should return false when identifier is not in local list', () => {
+ mockLocalAuthListManager.getEntry = () => undefined
- assert.strictEqual(await strategy.isInLocalList('UNKNOWN_TAG'), false)
+ assert.strictEqual(strategy.isInLocalList('UNKNOWN_TAG'), false)
})
})
import type {
AuthCache,
- LocalAuthEntry,
LocalAuthListManager,
OCPPAuthAdapter,
} from '../../../../../src/charging-station/ocpp/auth/interfaces/OCPPAuthService.js'
}
mockLocalAuthListManager.getEntry = (identifier: string) =>
- new Promise<LocalAuthEntry | undefined>(resolve => {
- if (identifier === 'LOCAL_AUTH_TAG') {
- resolve({
- identifier: 'LOCAL_AUTH_TAG',
- status: 'Active',
- })
- } else {
- resolve(undefined)
- }
- })
+ identifier === 'LOCAL_AUTH_TAG'
+ ? { identifier: 'LOCAL_AUTH_TAG', status: 'Active' }
+ : undefined
const config = createTestAuthConfig({
authorizationCacheEnabled: true,
cachedKey = key
}
- mockLocalAuthListManager.getEntry = () =>
- new Promise<LocalAuthEntry | undefined>(resolve => {
- resolve(undefined)
- })
+ mockLocalAuthListManager.getEntry = () => undefined
const config = createTestAuthConfig({
authorizationCacheEnabled: true,
await strategy.authenticate(request, config)
assert.strictEqual(cachedKey, 'REMOTE_AUTH_TAG')
})
+
+ await it('should cache result when getEntry throws', async () => {
+ let cachedKey: string | undefined
+ mockAuthCache.set = (key: string) => {
+ cachedKey = key
+ }
+
+ mockLocalAuthListManager.getEntry = () => {
+ throw new Error('Local list lookup failed')
+ }
+
+ const config = createTestAuthConfig({
+ authorizationCacheEnabled: true,
+ authorizationCacheLifetime: 300,
+ localAuthListEnabled: true,
+ })
+ const request = createMockAuthRequest({
+ identifier: createMockIdentifier('FAILING_TAG', IdentifierType.ID_TAG),
+ })
+
+ await strategy.authenticate(request, config)
+ assert.strictEqual(cachedKey, 'FAILING_TAG')
+ })
})
await describe('adapter management', async () => {
- `Inoperative` — Connector unavailable
- `--set-variables <SPECS>`: SetVariables data as `Component.Variable=Value,...` (values must not contain commas)
- `--get-variables <SPECS>`: GetVariables data as `Component.Variable,...`
+- `--local-list-tokens TOKEN ...`: Tokens to include in SendLocalList (default: test token)
```shell
poetry run python server.py --command TriggerMessage --trigger-message BootNotification --delay 5
poetry run python server.py --command ChangeAvailability --availability-status Inoperative --delay 5
poetry run python server.py --command SetVariables --delay 5 \
--set-variables "OCPPCommCtrlr.HeartbeatInterval=30,TxCtrlr.EVConnectionTimeOut=60"
+poetry run python server.py --command GetLocalListVersion --delay 5
+poetry run python server.py --command SendLocalList --delay 5 --local-list-tokens token1 token2
```
## Supported OCPP 2.0.1 Messages
- `DeleteCertificate` — Delete a certificate on the charging station
- `GetBaseReport` — Request a full device model report
- `GetInstalledCertificateIds` — List installed certificate IDs
+- `GetLocalListVersion` — Get the version number of the local authorization list
- `GetLog` — Request log upload
- `GetTransactionStatus` — Get status of a transaction
- `GetVariables` — Get variable values
- `RequestStartTransaction` — Remote start a transaction
- `RequestStopTransaction` — Remote stop a transaction
- `Reset` — Reset the charging station
+- `SendLocalList` — Send a local authorization list update
- `SetNetworkProfile` — Set the network connection profile
- `SetVariables` — Set variable values
- `TriggerMessage` — Trigger a specific message from the station
ReportBaseEnumType,
ResetEnumType,
ResetStatusEnumType,
+ SendLocalListStatusEnumType,
SetNetworkProfileStatusEnumType,
TransactionEventEnumType,
TriggerMessageStatusEnumType,
UnlockStatusEnumType,
+ UpdateEnumType,
UpdateFirmwareStatusEnumType,
)
from websockets import ConnectionClosed
DEFAULT_VENDOR_ID = "TestVendor"
DEFAULT_FIRMWARE_URL = "https://example.com/firmware/v2.0.bin"
DEFAULT_LOG_URL = "https://example.com/logs"
+DEFAULT_LOCAL_LIST_VERSION = 1
DEFAULT_CUSTOMER_ID = "test_customer_001"
FALLBACK_TRANSACTION_ID = "test_transaction_123"
MAX_REQUEST_ID = 2**31 - 1
availability_status: OperationalStatusEnumType = OperationalStatusEnumType.operative
set_variables_data: list[dict] | None = None
get_variables_data: list[dict] | None = None
+ local_list_tokens: list[str] | None = None
class ChargePoint(ocpp.v201.ChargePoint):
_charge_points: set["ChargePoint"]
_set_variables_data: list[dict] | None
_get_variables_data: list[dict] | None
+ _local_list_tokens: list[str] | None
def __init__(
self,
charge_points: set["ChargePoint"] | None = None,
set_variables_data: list[dict] | None = None,
get_variables_data: list[dict] | None = None,
+ local_list_tokens: list[str] | None = None,
):
# Extract CP ID from last URL segment (OCPP 2.0.1 Part 4)
cp_id = connection.request.path.strip("/").split("/")[-1]
self._availability_status = availability_status
self._set_variables_data = set_variables_data
self._get_variables_data = get_variables_data
+ self._local_list_tokens = local_list_tokens
self._charge_points.add(self)
self._active_transactions: dict[str, int] = {}
if auth_config is None:
request = ocpp.v201.call.Reset(type=self._reset_type)
await self._call_and_log(request, Action.reset, ResetStatusEnumType.accepted)
+ async def _send_send_local_list(self):
+ tokens = self._local_list_tokens or [DEFAULT_TEST_TOKEN]
+ local_authorization_list = [
+ {
+ "id_token": {"id_token": token, "type": DEFAULT_TOKEN_TYPE},
+ "id_token_info": {
+ "status": AuthorizationStatusEnumType.accepted,
+ },
+ }
+ for token in tokens
+ ]
+ request = ocpp.v201.call.SendLocalList(
+ version_number=DEFAULT_LOCAL_LIST_VERSION,
+ update_type=UpdateEnumType.full,
+ local_authorization_list=local_authorization_list,
+ )
+ await self._call_and_log(
+ request, Action.send_local_list, SendLocalListStatusEnumType.accepted
+ )
+
async def _send_unlock_connector(self):
request = ocpp.v201.call.UnlockConnector(
evse_id=DEFAULT_EVSE_ID, connector_id=DEFAULT_CONNECTOR_ID
GetInstalledCertificateStatusEnumType.accepted,
)
+ async def _send_get_local_list_version(self):
+ request = ocpp.v201.call.GetLocalListVersion()
+ response = await self.call(request, suppress=False)
+ logger.info(
+ "%s response: version_number=%s",
+ Action.get_local_list_version,
+ response.version_number,
+ )
+
async def _send_get_log(self):
request = ocpp.v201.call.GetLog(
log={"remote_location": DEFAULT_LOG_URL},
Action.request_start_transaction: "_send_request_start_transaction",
Action.request_stop_transaction: "_send_request_stop_transaction",
Action.reset: "_send_reset",
+ Action.send_local_list: "_send_send_local_list",
Action.unlock_connector: "_send_unlock_connector",
Action.change_availability: "_send_change_availability",
Action.trigger_message: "_send_trigger_message",
Action.customer_information: "_send_customer_information",
Action.delete_certificate: "_send_delete_certificate",
Action.get_installed_certificate_ids: "_send_get_installed_certificate_ids",
+ Action.get_local_list_version: "_send_get_local_list_version",
Action.get_log: "_send_get_log",
Action.get_transaction_status: "_send_get_transaction_status",
Action.install_certificate: "_send_install_certificate",
charge_points=charge_points,
set_variables_data=config.set_variables_data,
get_variables_data=config.get_variables_data,
+ local_list_tokens=config.local_list_tokens,
)
if config.command_name:
await cp.send_command(config.command_name, config.delay, config.period)
'(e.g., "ChargingStation.AvailabilityState")'
),
)
+ parser.add_argument(
+ "--local-list-tokens",
+ nargs="*",
+ default=None,
+ help="Tokens to include in SendLocalList (default: test token)",
+ )
args, _ = parser.parse_known_args()
group.required = args.command is not None
availability_status=args.availability_status,
set_variables_data=parsed_set_variables,
get_variables_data=parsed_get_variables,
+ local_list_tokens=args.local_list_tokens,
)
logger.info(
RequestStartStopStatusEnumType,
ResetEnumType,
ResetStatusEnumType,
+ SendLocalListStatusEnumType,
SetNetworkProfileStatusEnumType,
TransactionEventEnumType,
TriggerMessageStatusEnumType,
UnlockStatusEnumType,
+ UpdateEnumType,
UpdateFirmwareStatusEnumType,
)
from server import (
DEFAULT_HEARTBEAT_INTERVAL_SECONDS,
+ DEFAULT_LOCAL_LIST_VERSION,
DEFAULT_TOTAL_COST,
FALLBACK_TRANSACTION_ID,
MAX_REQUEST_ID,
availability_status=OperationalStatusEnumType.operative,
set_variables=None,
get_variables=None,
+ local_list_tokens=None,
)
mock_serve_cm = AsyncMock()
mock_serve_cm.__aenter__ = AsyncMock(return_value=mock_server)
"_send_customer_information",
"_send_delete_certificate",
"_send_get_installed_certificate_ids",
+ "_send_get_local_list_version",
"_send_get_log",
"_send_get_transaction_status",
"_send_install_certificate",
+ "_send_send_local_list",
"_send_set_network_profile",
"_send_update_firmware",
]
assert isinstance(request, ocpp.v201.call.GetInstalledCertificateIds)
assert isinstance(request.certificate_type, list)
+ async def test_send_get_local_list_version(self, command_charge_point):
+ command_charge_point.call.return_value = (
+ ocpp.v201.call_result.GetLocalListVersion(version_number=5)
+ )
+ await command_charge_point._send_get_local_list_version()
+ command_charge_point.call.assert_called_once()
+ request = command_charge_point.call.call_args[0][0]
+ assert isinstance(request, ocpp.v201.call.GetLocalListVersion)
+
async def test_send_get_log(self, command_charge_point):
command_charge_point.call.return_value = ocpp.v201.call_result.GetLog(
status=LogStatusEnumType.accepted
assert request.configuration_slot == 1
assert isinstance(request.connection_data, dict)
+ async def test_send_send_local_list(self, command_charge_point):
+ command_charge_point.call.return_value = ocpp.v201.call_result.SendLocalList(
+ status=SendLocalListStatusEnumType.accepted
+ )
+ await command_charge_point._send_send_local_list()
+ command_charge_point.call.assert_called_once()
+ request = command_charge_point.call.call_args[0][0]
+ assert isinstance(request, ocpp.v201.call.SendLocalList)
+ assert request.version_number == DEFAULT_LOCAL_LIST_VERSION
+ assert request.update_type == UpdateEnumType.full
+ assert len(request.local_authorization_list) > 0
+
+ async def test_send_send_local_list_custom_tokens(self, command_charge_point):
+ command_charge_point._local_list_tokens = ["TOKEN_A", "TOKEN_B"]
+ command_charge_point.call.return_value = ocpp.v201.call_result.SendLocalList(
+ status=SendLocalListStatusEnumType.accepted
+ )
+ await command_charge_point._send_send_local_list()
+ request = command_charge_point.call.call_args[0][0]
+ assert len(request.local_authorization_list) == 2
+ tokens = [
+ entry["id_token"]["id_token"] for entry in request.local_authorization_list
+ ]
+ assert "TOKEN_A" in tokens
+ assert "TOKEN_B" in tokens
+
async def test_send_update_firmware(self, command_charge_point):
command_charge_point.call.return_value = ocpp.v201.call_result.UpdateFirmware(
status=UpdateFirmwareStatusEnumType.accepted
ocpp.v201.call_result.SetNetworkProfile,
SetNetworkProfileStatusEnumType.rejected,
),
+ (
+ "_send_send_local_list",
+ ocpp.v201.call_result.SendLocalList,
+ SendLocalListStatusEnumType.failed,
+ ),
(
"_send_update_firmware",
ocpp.v201.call_result.UpdateFirmware,