From 34bbf3346d01f73e6de921bda734f05a029466dd Mon Sep 17 00:00:00 2001 From: =?utf8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Fri, 10 Apr 2026 17:58:43 +0200 Subject: [PATCH] feat(ocpp): implement Local Auth List Management Profile (GetLocalListVersion, SendLocalList) (#1782) MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit * feat(ocpp): add local auth list types for OCPP 1.6 and 2.0 * feat(ocpp): add InMemoryLocalAuthListManager implementation * feat(ocpp): export local auth list types from main types barrel * feat(ocpp): wire LocalAuthListManager into auth subsystem * feat(ocpp/2.0): implement GetLocalListVersion and SendLocalList handlers * docs: mark Local Auth List Management commands as implemented * style: apply formatting to local auth list test file * [autofix.ci] apply automated fixes * fix(ocpp): enforce VersionMismatch and size limits in SendLocalList handlers * fix(ocpp): address PR review feedback on local auth list implementation * fix(ocpp): improve robustness and consistency in local auth list handlers * fix(ocpp): fix config key, atomicity, and version validation in local auth list * fix(ocpp): harmonize validation ordering and improve type safety in local auth list * fix(ocpp): add error logging to OCPP 1.6 local auth list catch blocks * fix(ocpp): use enum values for auth status and fix OCPP 2.0 config key semantics * refactor(ocpp): rename DEFAULT_IDTAG to OCPP_DEFAULT_IDTAG for consistent naming * fix(test): use convertToBoolean in mock getLocalAuthListEnabled for production parity * fix(ocpp): align OCPP16UpdateStatus naming and add maxLocalAuthListEntries validation * fix(ocpp): gate maxLocalAuthListEntries validation on localAuthListEnabled, not cache * refactor(ocpp): convert LocalAuthListManager interface from async to sync * refactor(ocpp): remove redundant async from OCPPAuthServiceImpl.authorize() * fix(ocpp): remove stale LocalAuthListManager implementation note in RemoteAuthStrategy * refactor(ocpp): use barrel imports for cross-component boundaries Replace direct file imports with barrel (index.ts) imports when crossing component boundaries (ocpp/ → charging-station/, auth/ → ocpp/). Add Entries to OCPP20RequiredVariableName enum. Sync LocalAuthListCtrlr.Entries variable after SendLocalList. * fix(ocpp): update stale JSDoc in MockFactories and AuthComponentFactory * feat(ocpp-server): add GetLocalListVersion and SendLocalList command support * fix(test): reuse getConfigurationKey in mock getLocalAuthListEnabled * refactor(test): use upsertConfigurationKey in OCPP 2.0 local auth list tests * refactor(ocpp): move readMaxLocalAuthListEntries to AuthHelpers * refactor(ocpp): move maxLocalAuthListEntries reading to version adapters * docs: update agent memories with local auth list, QMD integration, and convention fixes * refactor(ocpp): widen AuthStrategy.authenticate to allow sync return and remove eslint-disable * fix(test): remove unnecessary async from LocalAuthStrategy test callbacks * fix(ocpp): address review feedback on volatile Entries, circular dep, and interface --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .agents/skills/qmd/SKILL.md | 146 +++++ .agents/skills/qmd/references/mcp-setup.md | 105 ++++ .opencode/skills/qmd | 1 + README.md | 12 +- .../ocpp/1.6/OCPP16Constants.ts | 26 +- .../ocpp/1.6/OCPP16IncomingRequestService.ts | 131 ++++- .../ocpp/1.6/OCPP16RequestService.ts | 4 +- .../ocpp/1.6/OCPP16ServiceUtils.ts | 2 + .../ocpp/1.6/__testable__/index.ts | 14 + .../ocpp/2.0/OCPP20CertificateManager.ts | 2 +- .../ocpp/2.0/OCPP20Constants.ts | 18 + .../ocpp/2.0/OCPP20IncomingRequestService.ts | 168 +++++- .../ocpp/2.0/OCPP20ServiceUtils.ts | 4 +- .../ocpp/2.0/OCPP20VariableManager.ts | 4 +- .../ocpp/2.0/OCPP20VariableRegistry.ts | 26 +- .../ocpp/2.0/__testable__/index.ts | 14 + .../ocpp/auth/adapters/OCPP16AuthAdapter.ts | 25 +- .../ocpp/auth/adapters/OCPP20AuthAdapter.ts | 8 +- .../cache/InMemoryLocalAuthListManager.ts | 164 ++++++ .../auth/factories/AuthComponentFactory.ts | 27 +- src/charging-station/ocpp/auth/index.ts | 2 + .../ocpp/auth/interfaces/OCPPAuthService.ts | 70 ++- .../ocpp/auth/services/OCPPAuthServiceImpl.ts | 18 +- .../ocpp/auth/strategies/LocalAuthStrategy.ts | 16 +- .../auth/strategies/RemoteAuthStrategy.ts | 13 +- .../ocpp/auth/types/AuthTypes.ts | 3 + .../ocpp/auth/utils/ConfigValidator.ts | 26 + src/types/index.ts | 80 +-- src/types/ocpp/1.6/Requests.ts | 21 + src/types/ocpp/1.6/Responses.ts | 15 + src/types/ocpp/2.0/Requests.ts | 23 + src/types/ocpp/2.0/Responses.ts | 21 +- src/types/ocpp/2.0/Variables.ts | 1 + .../helpers/StationHelpers.ts | 9 +- ...comingRequestService-LocalAuthList.test.ts | 392 +++++++++++++ ...comingRequestService-LocalAuthList.test.ts | 544 ++++++++++++++++++ .../ocpp/auth/OCPPAuthIntegration.test.ts | 9 +- .../InMemoryLocalAuthListManager.test.ts | 289 ++++++++++ .../factories/AuthComponentFactory.test.ts | 23 +- .../ocpp/auth/helpers/MockFactories.ts | 54 +- ...lAuthStrategy-DisablePostAuthorize.test.ts | 27 +- .../auth/strategies/LocalAuthStrategy.test.ts | 56 +- .../strategies/RemoteAuthStrategy.test.ts | 42 +- tests/ocpp-server/README.md | 5 + tests/ocpp-server/server.py | 46 ++ tests/ocpp-server/test_server.py | 46 ++ 46 files changed, 2539 insertions(+), 213 deletions(-) create mode 100644 .agents/skills/qmd/SKILL.md create mode 100644 .agents/skills/qmd/references/mcp-setup.md create mode 120000 .opencode/skills/qmd create mode 100644 src/charging-station/ocpp/auth/cache/InMemoryLocalAuthListManager.ts create mode 100644 tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-LocalAuthList.test.ts create mode 100644 tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-LocalAuthList.test.ts create mode 100644 tests/charging-station/ocpp/auth/cache/InMemoryLocalAuthListManager.test.ts diff --git a/.agents/skills/qmd/SKILL.md b/.agents/skills/qmd/SKILL.md new file mode 100644 index 00000000..1f5e3e68 --- /dev/null +++ b/.agents/skills/qmd/SKILL.md @@ -0,0 +1,146 @@ +--- +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 +``` diff --git a/.agents/skills/qmd/references/mcp-setup.md b/.agents/skills/qmd/references/mcp-setup.md new file mode 100644 index 00000000..ea98224f --- /dev/null +++ b/.agents/skills/qmd/references/mcp-setup.md @@ -0,0 +1,105 @@ +# 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) diff --git a/.opencode/skills/qmd b/.opencode/skills/qmd new file mode 120000 index 00000000..d12152c6 --- /dev/null +++ b/.opencode/skills/qmd @@ -0,0 +1 @@ +/Users/I339261/SAPDevelop/e-mobility-charging-stations-simulator-git-worktrees/feat-auth-cache/.agents/skills/qmd \ No newline at end of file diff --git a/README.md b/README.md index 45715636..9d601879 100644 --- a/README.md +++ b/README.md @@ -521,8 +521,8 @@ make SUBMODULES_INIT=true #### Local Auth List Management Profile -- :x: GetLocalListVersion -- :x: SendLocalList +- :white_check_mark: GetLocalListVersion +- :white_check_mark: SendLocalList #### Reservation Profile @@ -562,8 +562,8 @@ make SUBMODULES_INIT=true #### D. LocalAuthorizationListManagement -- :x: GetLocalListVersion -- :x: SendLocalList +- :white_check_mark: GetLocalListVersion +- :white_check_mark: SendLocalList #### E. Transactions @@ -654,8 +654,8 @@ All kind of OCPP parameters are supported in charging station configuration or c #### 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 diff --git a/src/charging-station/ocpp/1.6/OCPP16Constants.ts b/src/charging-station/ocpp/1.6/OCPP16Constants.ts index bd70fb8d..c81a025c 100644 --- a/src/charging-station/ocpp/1.6/OCPP16Constants.ts +++ b/src/charging-station/ocpp/1.6/OCPP16Constants.ts @@ -1,4 +1,10 @@ -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 { @@ -289,5 +295,21 @@ 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 }) } diff --git a/src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts b/src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts index 3718e774..bd3700b0 100644 --- a/src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts +++ b/src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts @@ -68,6 +68,7 @@ import { type OCPP16FirmwareStatusNotificationResponse, type OCPP16GetCompositeScheduleRequest, type OCPP16GetCompositeScheduleResponse, + type OCPP16GetLocalListVersionResponse, type OCPP16HeartbeatRequest, type OCPP16HeartbeatResponse, OCPP16IncomingRequestCommand, @@ -78,6 +79,8 @@ import { OCPP16RequestCommand, type OCPP16ReserveNowRequest, type OCPP16ReserveNowResponse, + type OCPP16SendLocalListRequest, + type OCPP16SendLocalListResponse, OCPP16StandardParametersKey, type OCPP16StartTransactionRequest, type OCPP16StartTransactionResponse, @@ -90,6 +93,7 @@ import { OCPP16TriggerMessageStatus, type OCPP16UpdateFirmwareRequest, type OCPP16UpdateFirmwareResponse, + OCPP16UpdateType, type OCPPConfigurationKey, OCPPVersion, type RemoteStartTransactionRequest, @@ -105,6 +109,7 @@ import { Configuration, convertToDate, convertToInt, + convertToIntOrNaN, ensureError, formatDurationMilliSeconds, handleIncomingRequestError, @@ -115,7 +120,7 @@ import { 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' @@ -221,6 +226,10 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService { 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)), @@ -237,6 +246,10 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService { 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)), @@ -1210,6 +1223,40 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService { } } + /** + * 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 @@ -1434,6 +1481,88 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService { 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 diff --git a/src/charging-station/ocpp/1.6/OCPP16RequestService.ts b/src/charging-station/ocpp/1.6/OCPP16RequestService.ts index f8bd81dd..dfe25789 100644 --- a/src/charging-station/ocpp/1.6/OCPP16RequestService.ts +++ b/src/charging-station/ocpp/1.6/OCPP16RequestService.ts @@ -176,7 +176,7 @@ export class OCPP16RequestService extends OCPPRequestService { switch (commandName) { case OCPP16RequestCommand.AUTHORIZE: return { - idTag: OCPP16Constants.DEFAULT_IDTAG, + idTag: OCPP16Constants.OCPP_DEFAULT_IDTAG, ...commandParams, } as unknown as Request case OCPP16RequestCommand.BOOT_NOTIFICATION: @@ -189,7 +189,7 @@ export class OCPP16RequestService extends OCPPRequestService { 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 diff --git a/src/charging-station/ocpp/1.6/OCPP16ServiceUtils.ts b/src/charging-station/ocpp/1.6/OCPP16ServiceUtils.ts index 09f195e1..f5d24fc2 100644 --- a/src/charging-station/ocpp/1.6/OCPP16ServiceUtils.ts +++ b/src/charging-station/ocpp/1.6/OCPP16ServiceUtils.ts @@ -97,10 +97,12 @@ export class OCPP16ServiceUtils { [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'], diff --git a/src/charging-station/ocpp/1.6/__testable__/index.ts b/src/charging-station/ocpp/1.6/__testable__/index.ts index c5b26950..40512276 100644 --- a/src/charging-station/ocpp/1.6/__testable__/index.ts +++ b/src/charging-station/ocpp/1.6/__testable__/index.ts @@ -34,9 +34,12 @@ import type { OCPP16DataTransferResponse, OCPP16GetCompositeScheduleRequest, OCPP16GetCompositeScheduleResponse, + OCPP16GetLocalListVersionResponse, OCPP16RequestCommand, OCPP16ReserveNowRequest, OCPP16ReserveNowResponse, + OCPP16SendLocalListRequest, + OCPP16SendLocalListResponse, OCPP16TriggerMessageRequest, OCPP16TriggerMessageResponse, OCPP16UpdateFirmwareRequest, @@ -135,6 +138,10 @@ export interface TestableOCPP16IncomingRequestService { commandPayload: GetDiagnosticsRequest ) => Promise + handleRequestGetLocalListVersion: ( + chargingStation: ChargingStation + ) => OCPP16GetLocalListVersionResponse + /** * Handles OCPP 1.6 RemoteStartTransaction request from central system. * Initiates charging transaction on specified or available connector. @@ -171,6 +178,11 @@ export interface TestableOCPP16IncomingRequestService { 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. @@ -259,12 +271,14 @@ export function createTestableIncomingRequestService ( 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), diff --git a/src/charging-station/ocpp/2.0/OCPP20CertificateManager.ts b/src/charging-station/ocpp/2.0/OCPP20CertificateManager.ts index 2e43be42..5f86dfba 100644 --- a/src/charging-station/ocpp/2.0/OCPP20CertificateManager.ts +++ b/src/charging-station/ocpp/2.0/OCPP20CertificateManager.ts @@ -4,7 +4,7 @@ import { hash, X509Certificate } from 'node:crypto' 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 { diff --git a/src/charging-station/ocpp/2.0/OCPP20Constants.ts b/src/charging-station/ocpp/2.0/OCPP20Constants.ts index b3219af5..48e8fed2 100644 --- a/src/charging-station/ocpp/2.0/OCPP20Constants.ts +++ b/src/charging-station/ocpp/2.0/OCPP20Constants.ts @@ -2,6 +2,8 @@ import { type ConnectorStatusTransition, MessageTriggerEnumType, OCPP20ConnectorStatusEnumType, + type OCPP20SendLocalListResponse, + OCPP20SendLocalListStatusEnumType, OCPP20TriggerReasonEnumType, } from '../../../types/index.js' import { OCPPConstants } from '../OCPPConstants.js' @@ -155,6 +157,22 @@ export class OCPP20Constants extends OCPPConstants { 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 diff --git a/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts b/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts index f6f57e76..c92b8460 100644 --- a/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts +++ b/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts @@ -34,6 +34,7 @@ import { type JsonType, LogStatusEnumType, MessageTriggerEnumType, + OCPP20AuthorizationStatusEnumType, type OCPP20BootNotificationRequest, type OCPP20BootNotificationResponse, type OCPP20CertificateSignedRequest, @@ -62,6 +63,7 @@ import { type OCPP20GetBaseReportResponse, type OCPP20GetInstalledCertificateIdsRequest, type OCPP20GetInstalledCertificateIdsResponse, + type OCPP20GetLocalListVersionResponse, type OCPP20GetLogRequest, type OCPP20GetLogResponse, type OCPP20GetTransactionStatusRequest, @@ -96,6 +98,9 @@ import { type OCPP20ResetResponse, type OCPP20SecurityEventNotificationRequest, type OCPP20SecurityEventNotificationResponse, + type OCPP20SendLocalListRequest, + type OCPP20SendLocalListResponse, + OCPP20SendLocalListStatusEnumType, type OCPP20SetNetworkProfileRequest, type OCPP20SetNetworkProfileResponse, type OCPP20SetVariablesRequest, @@ -108,6 +113,7 @@ import { OCPP20TriggerReasonEnumType, type OCPP20UnlockConnectorRequest, type OCPP20UnlockConnectorResponse, + OCPP20UpdateEnumType, type OCPP20UpdateFirmwareRequest, type OCPP20UpdateFirmwareResponse, OCPP20VendorVariableName, @@ -140,15 +146,19 @@ import { 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' @@ -283,6 +293,10 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { 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)), @@ -311,6 +325,10 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { 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)), @@ -803,6 +821,152 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { } } + /** + * 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 diff --git a/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts b/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts index 210eb7c3..5ad86705 100644 --- a/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts +++ b/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts @@ -51,7 +51,7 @@ import { logger, validateIdentifierString, } from '../../../utils/index.js' -import { buildConfigKey, getConfigurationKey } from '../../ConfigurationKeyUtils.js' +import { buildConfigKey, getConfigurationKey } from '../../index.js' import { mapOCPP20AuthorizationStatus, mapOCPP20TokenType, @@ -87,6 +87,7 @@ export class OCPP20ServiceUtils { [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'], @@ -94,6 +95,7 @@ export class OCPP20ServiceUtils { [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'], diff --git a/src/charging-station/ocpp/2.0/OCPP20VariableManager.ts b/src/charging-station/ocpp/2.0/OCPP20VariableManager.ts index e8093ed8..5a751eb0 100644 --- a/src/charging-station/ocpp/2.0/OCPP20VariableManager.ts +++ b/src/charging-station/ocpp/2.0/OCPP20VariableManager.ts @@ -20,13 +20,13 @@ import { 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, diff --git a/src/charging-station/ocpp/2.0/OCPP20VariableRegistry.ts b/src/charging-station/ocpp/2.0/OCPP20VariableRegistry.ts index 7348c1bc..cd700ca2 100644 --- a/src/charging-station/ocpp/2.0/OCPP20VariableRegistry.ts +++ b/src/charging-station/ocpp/2.0/OCPP20VariableRegistry.ts @@ -1,6 +1,6 @@ import { millisecondsToSeconds } from 'date-fns' -import type { ChargingStation } from '../../ChargingStation.js' +import type { ChargingStation } from '../../index.js' import { AttributeEnumType, @@ -1239,18 +1239,6 @@ export const VARIABLE_REGISTRY: Record = { 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 @@ -1293,6 +1281,18 @@ export const VARIABLE_REGISTRY: Record = { 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 diff --git a/src/charging-station/ocpp/2.0/__testable__/index.ts b/src/charging-station/ocpp/2.0/__testable__/index.ts index bb328154..c3bd89cb 100644 --- a/src/charging-station/ocpp/2.0/__testable__/index.ts +++ b/src/charging-station/ocpp/2.0/__testable__/index.ts @@ -31,6 +31,7 @@ import type { OCPP20GetBaseReportResponse, OCPP20GetInstalledCertificateIdsRequest, OCPP20GetInstalledCertificateIdsResponse, + OCPP20GetLocalListVersionResponse, OCPP20GetLogRequest, OCPP20GetLogResponse, OCPP20GetTransactionStatusRequest, @@ -46,6 +47,8 @@ import type { OCPP20RequestStopTransactionResponse, OCPP20ResetRequest, OCPP20ResetResponse, + OCPP20SendLocalListRequest, + OCPP20SendLocalListResponse, OCPP20SetNetworkProfileRequest, OCPP20SetNetworkProfileResponse, OCPP20SetVariablesRequest, @@ -145,6 +148,10 @@ export interface TestableOCPP20IncomingRequestService { commandPayload: OCPP20GetInstalledCertificateIdsRequest ) => Promise + handleRequestGetLocalListVersion: ( + chargingStation: ChargingStation + ) => OCPP20GetLocalListVersionResponse + /** * Handles OCPP 2.0.1 GetLog request from central system. * Accepts log upload and simulates upload lifecycle. @@ -190,6 +197,11 @@ export interface TestableOCPP20IncomingRequestService { commandPayload: OCPP20ResetRequest ) => Promise + 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. @@ -281,11 +293,13 @@ export function createTestableIncomingRequestService ( 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), diff --git a/src/charging-station/ocpp/auth/adapters/OCPP16AuthAdapter.ts b/src/charging-station/ocpp/auth/adapters/OCPP16AuthAdapter.ts index 1e2eb685..6dcb8df5 100644 --- a/src/charging-station/ocpp/auth/adapters/OCPP16AuthAdapter.ts +++ b/src/charging-station/ocpp/auth/adapters/OCPP16AuthAdapter.ts @@ -8,16 +8,18 @@ import type { 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, @@ -216,8 +218,7 @@ export class OCPP16AuthAdapter implements OCPPAuthAdapter { } /** - * 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 { @@ -258,6 +259,24 @@ export class OCPP16AuthAdapter implements OCPPAuthAdapter { } } + /** + * 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 diff --git a/src/charging-station/ocpp/auth/adapters/OCPP20AuthAdapter.ts b/src/charging-station/ocpp/auth/adapters/OCPP20AuthAdapter.ts index 84c07e9a..b983d457 100644 --- a/src/charging-station/ocpp/auth/adapters/OCPP20AuthAdapter.ts +++ b/src/charging-station/ocpp/auth/adapters/OCPP20AuthAdapter.ts @@ -295,7 +295,6 @@ export class OCPP20AuthAdapter implements OCPPAuthAdapter { } /** - * Get OCPP 2.0 specific configuration schema * @returns Configuration schema object for OCPP 2.0 authorization settings */ getConfigurationSchema (): JsonObject { @@ -337,6 +336,13 @@ export class OCPP20AuthAdapter implements OCPPAuthAdapter { } } + /** + * @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 diff --git a/src/charging-station/ocpp/auth/cache/InMemoryLocalAuthListManager.ts b/src/charging-station/ocpp/auth/cache/InMemoryLocalAuthListManager.ts new file mode 100644 index 00000000..26a315f2 --- /dev/null +++ b/src/charging-station/ocpp/auth/cache/InMemoryLocalAuthListManager.ts @@ -0,0 +1,164 @@ +/** + * @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() + + 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() + const removedIdentifiers = new Set() + 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)}`) + } +} diff --git a/src/charging-station/ocpp/auth/factories/AuthComponentFactory.ts b/src/charging-station/ocpp/auth/factories/AuthComponentFactory.ts index 81f57e16..3a1424c3 100644 --- a/src/charging-station/ocpp/auth/factories/AuthComponentFactory.ts +++ b/src/charging-station/ocpp/auth/factories/AuthComponentFactory.ts @@ -13,6 +13,7 @@ import { Constants } from '../../../../utils/index.js' 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' @@ -93,23 +94,17 @@ export class AuthComponentFactory { } /** - * 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) } /** diff --git a/src/charging-station/ocpp/auth/index.ts b/src/charging-station/ocpp/auth/index.ts index 32e2cdf0..a7c71129 100644 --- a/src/charging-station/ocpp/auth/index.ts +++ b/src/charging-station/ocpp/auth/index.ts @@ -8,6 +8,7 @@ * @module ocpp/auth */ +export { InMemoryLocalAuthListManager } from './cache/InMemoryLocalAuthListManager.js' export type { AuthCache, AuthComponentFactory, @@ -16,6 +17,7 @@ export type { CacheStats, CertificateAuthProvider, CertificateInfo, + DifferentialAuthEntry, LocalAuthEntry, LocalAuthListManager, OCPPAuthAdapter, diff --git a/src/charging-station/ocpp/auth/interfaces/OCPPAuthService.ts b/src/charging-station/ocpp/auth/interfaces/OCPPAuthService.ts index 4ce7adfa..5e184bbb 100644 --- a/src/charging-station/ocpp/auth/interfaces/OCPPAuthService.ts +++ b/src/charging-station/ocpp/auth/interfaces/OCPPAuthService.ts @@ -65,8 +65,10 @@ export interface AuthComponentFactory { /** * 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 @@ -123,12 +125,12 @@ export interface AuthStrategy { * 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 | Promise | undefined /** * Check if this strategy can handle the given request @@ -253,6 +255,27 @@ export interface CertificateInfo { 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 + /** Optional parent identifier */ + parentId?: string + /** Authorization status — undefined signals removal */ + status?: string +} + /** * Supporting types for interfaces */ @@ -281,40 +304,55 @@ export interface LocalAuthListManager { * Add or update an entry in the local authorization list * @param entry - Authorization list entry */ - addEntry(entry: LocalAuthEntry): Promise + 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 + clearAll(): void /** * Get all entries (for synchronization) */ - getAllEntries(): Promise + 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 + getEntry(identifier: string): LocalAuthEntry | undefined /** * Get list version/update count */ - getVersion(): Promise + getVersion(): number /** * Remove an entry from the local authorization list * @param identifier - Identifier to remove */ - removeEntry(identifier: string): Promise + 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 + updateVersion(version: number): void } /** @@ -357,6 +395,12 @@ export interface OCPPAuthAdapter { */ 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 */ @@ -397,6 +441,12 @@ export interface OCPPAuthService { */ getConfiguration(): AuthConfiguration + /** + * Get the local authorization list manager + * @returns Local authorization list manager or undefined if not enabled + */ + getLocalAuthListManager(): LocalAuthListManager | undefined + /** * Get authentication statistics */ diff --git a/src/charging-station/ocpp/auth/services/OCPPAuthServiceImpl.ts b/src/charging-station/ocpp/auth/services/OCPPAuthServiceImpl.ts index 33539295..b7db7d2c 100644 --- a/src/charging-station/ocpp/auth/services/OCPPAuthServiceImpl.ts +++ b/src/charging-station/ocpp/auth/services/OCPPAuthServiceImpl.ts @@ -20,6 +20,7 @@ import { type AuthCache, type AuthStats, type AuthStrategy, + type LocalAuthListManager, type OCPPAuthService, } from '../interfaces/OCPPAuthService.js' import { @@ -41,6 +42,7 @@ export class OCPPAuthServiceImpl implements OCPPAuthService { private authCache?: AuthCache private readonly chargingStation: ChargingStation private config: AuthConfiguration + private localAuthListManager?: LocalAuthListManager private readonly metrics: { cacheHits: number cacheMisses: number @@ -205,7 +207,7 @@ export class OCPPAuthServiceImpl implements OCPPAuthService { * @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 { + public authorize (request: AuthRequest): Promise { return this.authenticate(request) } @@ -296,6 +298,14 @@ export class OCPPAuthServiceImpl implements OCPPAuthService { 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 @@ -364,6 +374,9 @@ export class OCPPAuthServiceImpl implements OCPPAuthService { */ public initialize (): void { this.initializeAdapter() + if (this.adapter != null) { + this.config.maxLocalAuthListEntries ??= this.adapter.getMaxLocalAuthListEntries() + } this.initializeStrategies() } @@ -620,11 +633,12 @@ export class OCPPAuthServiceImpl implements OCPPAuthService { } 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 ) diff --git a/src/charging-station/ocpp/auth/strategies/LocalAuthStrategy.ts b/src/charging-station/ocpp/auth/strategies/LocalAuthStrategy.ts index 3b955099..669a572b 100644 --- a/src/charging-station/ocpp/auth/strategies/LocalAuthStrategy.ts +++ b/src/charging-station/ocpp/auth/strategies/LocalAuthStrategy.ts @@ -55,10 +55,10 @@ export class LocalAuthStrategy implements AuthStrategy { * @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 { if (!this.isInitialized) { throw new AuthenticationError( 'LocalAuthStrategy not initialized', @@ -77,7 +77,7 @@ export class LocalAuthStrategy implements AuthStrategy { // 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++ @@ -301,13 +301,13 @@ export class LocalAuthStrategy implements AuthStrategy { * @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 { + 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) @@ -374,16 +374,16 @@ export class LocalAuthStrategy implements AuthStrategy { * @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 { 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 } diff --git a/src/charging-station/ocpp/auth/strategies/RemoteAuthStrategy.ts b/src/charging-station/ocpp/auth/strategies/RemoteAuthStrategy.ts index 5374c102..1243e09c 100644 --- a/src/charging-station/ocpp/auth/strategies/RemoteAuthStrategy.ts +++ b/src/charging-station/ocpp/auth/strategies/RemoteAuthStrategy.ts @@ -114,11 +114,16 @@ export class RemoteAuthStrategy implements AuthStrategy { 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)}'` diff --git a/src/charging-station/ocpp/auth/types/AuthTypes.ts b/src/charging-station/ocpp/auth/types/AuthTypes.ts index 0bd2bf17..3a8f8833 100644 --- a/src/charging-station/ocpp/auth/types/AuthTypes.ts +++ b/src/charging-station/ocpp/auth/types/AuthTypes.ts @@ -141,6 +141,9 @@ export interface AuthConfiguration extends JsonObject { /** Maximum cache entries */ maxCacheEntries?: number + /** Maximum local auth list entries */ + maxLocalAuthListEntries?: number + /** OCPP protocol version configured on the charging station */ ocppVersion?: string diff --git a/src/charging-station/ocpp/auth/utils/ConfigValidator.ts b/src/charging-station/ocpp/auth/utils/ConfigValidator.ts index ed6df7a9..8370e4e7 100644 --- a/src/charging-station/ocpp/auth/utils/ConfigValidator.ts +++ b/src/charging-station/ocpp/auth/utils/ConfigValidator.ts @@ -42,6 +42,10 @@ function validate (config: AuthConfiguration): void { validateCacheConfig(config) } + if (config.localAuthListEnabled) { + validateLocalAuthListConfig(config) + } + validateTimeout(config) validateOfflineConfig(config) checkAuthMethodsEnabled(config) @@ -105,6 +109,28 @@ function validateCacheConfig (config: AuthConfiguration): void { } } +/** + * 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 diff --git a/src/types/index.ts b/src/types/index.ts index 4a7a17ec..039ba113 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -90,6 +90,7 @@ export { type ChangeConfigurationRequest, type GetConfigurationRequest, type GetDiagnosticsRequest, + type OCPP16AuthorizationData, OCPP16AvailabilityType, type OCPP16BootNotificationRequest, type OCPP16CancelReservationRequest, @@ -101,14 +102,17 @@ export { 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, @@ -132,14 +136,17 @@ export { 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' @@ -212,6 +219,7 @@ export { type OCPP20UnitOfMeasure, } from './ocpp/2.0/MeterValues.js' export { + type OCPP20AuthorizationData, type OCPP20AuthorizeRequest, type OCPP20BootNotificationRequest, type OCPP20CertificateSignedRequest, @@ -225,6 +233,7 @@ export { type OCPP20GetBaseReportRequest, type OCPP20GetCertificateStatusRequest, type OCPP20GetInstalledCertificateIdsRequest, + type OCPP20GetLocalListVersionRequest, type OCPP20GetLogRequest, type OCPP20GetTransactionStatusRequest, type OCPP20GetVariablesRequest, @@ -239,47 +248,52 @@ export { 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, diff --git a/src/types/ocpp/1.6/Requests.ts b/src/types/ocpp/1.6/Requests.ts index f31ce904..1b45679c 100644 --- a/src/types/ocpp/1.6/Requests.ts +++ b/src/types/ocpp/1.6/Requests.ts @@ -9,6 +9,7 @@ import type { } 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', @@ -35,10 +36,12 @@ export enum OCPP16IncomingRequestCommand { 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', @@ -67,6 +70,11 @@ export enum OCPP16RequestCommand { STOP_TRANSACTION = 'StopTransaction', } +export enum OCPP16UpdateType { + Differential = 'Differential', + Full = 'Full', +} + export enum ResetType { HARD = 'Hard', SOFT = 'Soft', @@ -89,6 +97,11 @@ export interface GetDiagnosticsRequest extends JsonObject { stopTime?: Date } +export interface OCPP16AuthorizationData extends JsonObject { + idTag: string + idTagInfo?: OCPP16IdTagInfo +} + export interface OCPP16BootNotificationRequest extends JsonObject { chargeBoxSerialNumber?: string chargePointModel: string @@ -139,6 +152,8 @@ export interface OCPP16GetCompositeScheduleRequest extends JsonObject { duration: number } +export type OCPP16GetLocalListVersionRequest = EmptyObject + export type OCPP16HeartbeatRequest = EmptyObject export interface OCPP16ReserveNowRequest extends JsonObject { @@ -149,6 +164,12 @@ 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 diff --git a/src/types/ocpp/1.6/Responses.ts b/src/types/ocpp/1.6/Responses.ts index 6dc03580..8274899c 100644 --- a/src/types/ocpp/1.6/Responses.ts +++ b/src/types/ocpp/1.6/Responses.ts @@ -56,6 +56,13 @@ export enum OCPP16UnlockStatus { UNLOCKED = 'Unlocked', } +export enum OCPP16UpdateStatus { + ACCEPTED = 'Accepted', + FAILED = 'Failed', + NOT_SUPPORTED = 'NotSupported', + VERSION_MISMATCH = 'VersionMismatch', +} + export interface ChangeConfigurationResponse extends JsonObject { status: OCPP16ConfigurationStatus } @@ -99,6 +106,10 @@ export interface OCPP16GetCompositeScheduleResponse extends JsonObject { status: GenericStatus } +export interface OCPP16GetLocalListVersionResponse extends JsonObject { + listVersion: number +} + export interface OCPP16HeartbeatResponse extends JsonObject { currentTime: Date } @@ -107,6 +118,10 @@ export interface OCPP16ReserveNowResponse extends JsonObject { status: OCPP16ReservationStatus } +export interface OCPP16SendLocalListResponse extends JsonObject { + status: OCPP16UpdateStatus +} + export type OCPP16StatusNotificationResponse = EmptyObject export interface OCPP16TriggerMessageResponse extends JsonObject { diff --git a/src/types/ocpp/2.0/Requests.ts b/src/types/ocpp/2.0/Requests.ts index 4476ec27..b4fd172c 100644 --- a/src/types/ocpp/2.0/Requests.ts +++ b/src/types/ocpp/2.0/Requests.ts @@ -26,6 +26,7 @@ import type { OCPP20ChargingProfileType, OCPP20ConnectorStatusEnumType, OCPP20EVSEType, + OCPP20IdTokenInfoType, OCPP20IdTokenType, } from './Transaction.js' import type { @@ -43,6 +44,7 @@ export enum OCPP20IncomingRequestCommand { 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', @@ -50,6 +52,7 @@ export enum OCPP20IncomingRequestCommand { REQUEST_START_TRANSACTION = 'RequestStartTransaction', REQUEST_STOP_TRANSACTION = 'RequestStopTransaction', RESET = 'Reset', + SEND_LOCAL_LIST = 'SendLocalList', SET_NETWORK_PROFILE = 'SetNetworkProfile', SET_VARIABLES = 'SetVariables', TRIGGER_MESSAGE = 'TriggerMessage', @@ -75,6 +78,17 @@ export enum OCPP20RequestCommand { 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 @@ -151,6 +165,8 @@ export interface OCPP20GetInstalledCertificateIdsRequest extends JsonObject { customData?: CustomDataType } +export type OCPP20GetLocalListVersionRequest = EmptyObject + export interface OCPP20GetLogRequest extends JsonObject { customData?: CustomDataType log: LogParametersType @@ -229,6 +245,13 @@ export interface OCPP20SecurityEventNotificationRequest extends JsonObject { type: string } +export interface OCPP20SendLocalListRequest extends JsonObject { + customData?: CustomDataType + localAuthorizationList?: OCPP20AuthorizationData[] + updateType: OCPP20UpdateEnumType + versionNumber: number +} + export interface OCPP20SetNetworkProfileRequest extends JsonObject { configurationSlot: number connectionData: NetworkConnectionProfileType diff --git a/src/types/ocpp/2.0/Responses.ts b/src/types/ocpp/2.0/Responses.ts index eb71f714..af58db72 100644 --- a/src/types/ocpp/2.0/Responses.ts +++ b/src/types/ocpp/2.0/Responses.ts @@ -27,6 +27,12 @@ import type { 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 @@ -106,6 +112,11 @@ export interface OCPP20GetInstalledCertificateIdsResponse extends JsonObject { statusInfo?: StatusInfoType } +export interface OCPP20GetLocalListVersionResponse extends JsonObject { + customData?: CustomDataType + versionNumber: number +} + export interface OCPP20GetLogResponse extends JsonObject { customData?: CustomDataType filename?: string @@ -162,12 +173,20 @@ export interface OCPP20ResetResponse extends JsonObject { 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[] @@ -181,8 +200,6 @@ export interface OCPP20SignCertificateResponse extends JsonObject { export type OCPP20StatusNotificationResponse = EmptyObject -export type { OCPP20TransactionEventResponse } from './Transaction.js' - export interface OCPP20TriggerMessageResponse extends JsonObject { customData?: CustomDataType status: TriggerMessageStatusEnumType diff --git a/src/types/ocpp/2.0/Variables.ts b/src/types/ocpp/2.0/Variables.ts index 0f12c9e9..2b226a7c 100644 --- a/src/types/ocpp/2.0/Variables.ts +++ b/src/types/ocpp/2.0/Variables.ts @@ -59,6 +59,7 @@ export enum OCPP20RequiredVariableName { CertificateEntries = 'CertificateEntries', DateTime = 'DateTime', Enabled = 'Enabled', + Entries = 'Entries', EVConnectionTimeOut = 'EVConnectionTimeOut', FileTransferProtocols = 'FileTransferProtocols', ItemsPerMessage = 'ItemsPerMessage', diff --git a/tests/charging-station/helpers/StationHelpers.ts b/tests/charging-station/helpers/StationHelpers.ts index d2ee2ae8..a0701607 100644 --- a/tests/charging-station/helpers/StationHelpers.ts +++ b/tests/charging-station/helpers/StationHelpers.ts @@ -18,13 +18,16 @@ import type { 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, @@ -510,7 +513,11 @@ export function createMockChargingStation ( 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) diff --git a/tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-LocalAuthList.test.ts b/tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-LocalAuthList.test.ts new file mode 100644 index 00000000..221b3915 --- /dev/null +++ b/tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-LocalAuthList.test.ts @@ -0,0 +1,392 @@ +/** + * @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) + }) + }) +}) diff --git a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-LocalAuthList.test.ts b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-LocalAuthList.test.ts new file mode 100644 index 00000000..a3b38184 --- /dev/null +++ b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-LocalAuthList.test.ts @@ -0,0 +1,544 @@ +/** + * @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 + 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) + }) + }) +}) diff --git a/tests/charging-station/ocpp/auth/OCPPAuthIntegration.test.ts b/tests/charging-station/ocpp/auth/OCPPAuthIntegration.test.ts index 744cd978..7cbcca27 100644 --- a/tests/charging-station/ocpp/auth/OCPPAuthIntegration.test.ts +++ b/tests/charging-station/ocpp/auth/OCPPAuthIntegration.test.ts @@ -7,7 +7,6 @@ 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 { 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' @@ -239,14 +238,12 @@ await describe('OCPP Authentication', async () => { }) // 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(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) @@ -259,7 +256,7 @@ await describe('OCPP Authentication', async () => { 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) diff --git a/tests/charging-station/ocpp/auth/cache/InMemoryLocalAuthListManager.test.ts b/tests/charging-station/ocpp/auth/cache/InMemoryLocalAuthListManager.test.ts new file mode 100644 index 00000000..68447a50 --- /dev/null +++ b/tests/charging-station/ocpp/auth/cache/InMemoryLocalAuthListManager.test.ts @@ -0,0 +1,289 @@ +/** + * @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 => ({ + 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) + }) + }) +}) diff --git a/tests/charging-station/ocpp/auth/factories/AuthComponentFactory.test.ts b/tests/charging-station/ocpp/auth/factories/AuthComponentFactory.test.ts index 9f339eff..8ed50c5f 100644 --- a/tests/charging-station/ocpp/auth/factories/AuthComponentFactory.test.ts +++ b/tests/charging-station/ocpp/auth/factories/AuthComponentFactory.test.ts @@ -2,7 +2,7 @@ * @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' @@ -93,8 +93,7 @@ await describe('AuthComponentFactory', async () => { }) 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, @@ -105,7 +104,23 @@ await describe('AuthComponentFactory', async () => { 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) }) diff --git a/tests/charging-station/ocpp/auth/helpers/MockFactories.ts b/tests/charging-station/ocpp/auth/helpers/MockFactories.ts index a1a17f17..829c419a 100644 --- a/tests/charging-station/ocpp/auth/helpers/MockFactories.ts +++ b/tests/charging-station/ocpp/auth/helpers/MockFactories.ts @@ -7,7 +7,6 @@ import assert from 'node:assert/strict' import type { ChargingStation } from '../../../../../src/charging-station/index.js' import type { AuthCache, - LocalAuthEntry, LocalAuthListManager, OCPPAuthAdapter, OCPPAuthService, @@ -127,6 +126,7 @@ export const createMockAuthService = (overrides?: Partial): OCP /* empty */ }, getConfiguration: () => ({}) as AuthConfiguration, + getLocalAuthListManager: () => undefined, getStats: () => ({ avgResponseTime: 0, cacheHitRate: 0, @@ -214,6 +214,7 @@ export const createMockOCPPAdapter = ( value: typeof identifier === 'string' ? identifier : identifier.idToken, }), getConfigurationSchema: () => ({}), + getMaxLocalAuthListEntries: () => undefined, isRemoteAvailable: () => true, ocppVersion, validateConfiguration: (_config: AuthConfiguration) => true, @@ -319,39 +320,32 @@ export const createMockAuthServiceTestStation = ( /** * 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 => ({ - addEntry: () => - new Promise(resolve => { - resolve() - }), - clearAll: () => - new Promise(resolve => { - resolve() - }), - getAllEntries: () => - new Promise(resolve => { - resolve([]) - }), - getEntry: () => - new Promise(resolve => { - resolve(undefined) - }), - getVersion: () => - new Promise(resolve => { - resolve(1) - }), - removeEntry: () => - new Promise(resolve => { - resolve() - }), - updateVersion: () => - new Promise(resolve => { - resolve() - }), + addEntry: () => { + /* empty */ + }, + applyDifferentialUpdate: () => { + /* empty */ + }, + clearAll: () => { + /* empty */ + }, + getAllEntries: () => [], + getEntry: () => undefined, + getVersion: () => 1, + removeEntry: () => { + /* empty */ + }, + setEntries: () => { + /* empty */ + }, + updateVersion: () => { + /* empty */ + }, ...overrides, }) diff --git a/tests/charging-station/ocpp/auth/strategies/LocalAuthStrategy-DisablePostAuthorize.test.ts b/tests/charging-station/ocpp/auth/strategies/LocalAuthStrategy-DisablePostAuthorize.test.ts index d942a091..4bf4134c 100644 --- a/tests/charging-station/ocpp/auth/strategies/LocalAuthStrategy-DisablePostAuthorize.test.ts +++ b/tests/charging-station/ocpp/auth/strategies/LocalAuthStrategy-DisablePostAuthorize.test.ts @@ -29,7 +29,7 @@ await describe('LocalAuthStrategy - DisablePostAuthorize', async () => { }) 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, @@ -49,7 +49,7 @@ await describe('LocalAuthStrategy - DisablePostAuthorize', async () => { }) // Act - const result = await strategy.authenticate(request, config) + const result = strategy.authenticate(request, config) // Assert assert.notStrictEqual(result, undefined) @@ -57,7 +57,7 @@ await describe('LocalAuthStrategy - DisablePostAuthorize', async () => { 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, @@ -77,7 +77,7 @@ await describe('LocalAuthStrategy - DisablePostAuthorize', async () => { }) // 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) @@ -85,15 +85,12 @@ await describe('LocalAuthStrategy - DisablePostAuthorize', async () => { }) 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({ @@ -105,7 +102,7 @@ await describe('LocalAuthStrategy - DisablePostAuthorize', async () => { 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) @@ -114,7 +111,7 @@ await describe('LocalAuthStrategy - DisablePostAuthorize', async () => { }) 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, @@ -133,7 +130,7 @@ await describe('LocalAuthStrategy - DisablePostAuthorize', async () => { }) // 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) diff --git a/tests/charging-station/ocpp/auth/strategies/LocalAuthStrategy.test.ts b/tests/charging-station/ocpp/auth/strategies/LocalAuthStrategy.test.ts index c0e64514..5d85ff4f 100644 --- a/tests/charging-station/ocpp/auth/strategies/LocalAuthStrategy.test.ts +++ b/tests/charging-station/ocpp/auth/strategies/LocalAuthStrategy.test.ts @@ -7,7 +7,6 @@ import { afterEach, beforeEach, describe, it } from 'node:test' import type { AuthCache, - LocalAuthEntry, LocalAuthListManager, } from '../../../../../src/charging-station/ocpp/auth/interfaces/OCPPAuthService.js' @@ -103,16 +102,13 @@ await describe('LocalAuthStrategy', async () => { strategy.initialize(config) }) - await it('should authenticate using local auth list', async () => { - mockLocalAuthListManager.getEntry = () => - new Promise(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, @@ -122,14 +118,14 @@ await describe('LocalAuthStrategy', async () => { 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, @@ -142,14 +138,14 @@ await describe('LocalAuthStrategy', async () => { 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, @@ -157,7 +153,7 @@ await describe('LocalAuthStrategy', async () => { 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) @@ -165,13 +161,13 @@ await describe('LocalAuthStrategy', async () => { 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) }) }) @@ -219,25 +215,19 @@ await describe('LocalAuthStrategy', async () => { }) await describe('isInLocalList', async () => { - await it('should return true when identifier is in local list', async () => { - mockLocalAuthListManager.getEntry = () => - new Promise(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(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) }) }) diff --git a/tests/charging-station/ocpp/auth/strategies/RemoteAuthStrategy.test.ts b/tests/charging-station/ocpp/auth/strategies/RemoteAuthStrategy.test.ts index cafb2446..6f6520b4 100644 --- a/tests/charging-station/ocpp/auth/strategies/RemoteAuthStrategy.test.ts +++ b/tests/charging-station/ocpp/auth/strategies/RemoteAuthStrategy.test.ts @@ -7,7 +7,6 @@ import { afterEach, beforeEach, describe, it } from 'node:test' import type { AuthCache, - LocalAuthEntry, LocalAuthListManager, OCPPAuthAdapter, } from '../../../../../src/charging-station/ocpp/auth/interfaces/OCPPAuthService.js' @@ -342,16 +341,9 @@ await describe('RemoteAuthStrategy', async () => { } mockLocalAuthListManager.getEntry = (identifier: string) => - new Promise(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, @@ -372,10 +364,7 @@ await describe('RemoteAuthStrategy', async () => { cachedKey = key } - mockLocalAuthListManager.getEntry = () => - new Promise(resolve => { - resolve(undefined) - }) + mockLocalAuthListManager.getEntry = () => undefined const config = createTestAuthConfig({ authorizationCacheEnabled: true, @@ -389,6 +378,29 @@ await describe('RemoteAuthStrategy', async () => { 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 () => { diff --git a/tests/ocpp-server/README.md b/tests/ocpp-server/README.md index 496c7431..5dd3c2aa 100644 --- a/tests/ocpp-server/README.md +++ b/tests/ocpp-server/README.md @@ -112,6 +112,7 @@ These flags customize the payload of specific commands: - `Inoperative` — Connector unavailable - `--set-variables `: SetVariables data as `Component.Variable=Value,...` (values must not contain commas) - `--get-variables `: 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 @@ -119,6 +120,8 @@ poetry run python server.py --command Reset --reset-type OnIdle --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 @@ -133,6 +136,7 @@ poetry run python server.py --command SetVariables --delay 5 \ - `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 @@ -140,6 +144,7 @@ poetry run python server.py --command SetVariables --delay 5 \ - `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 diff --git a/tests/ocpp-server/server.py b/tests/ocpp-server/server.py index 4d54b736..e19f271e 100644 --- a/tests/ocpp-server/server.py +++ b/tests/ocpp-server/server.py @@ -43,10 +43,12 @@ from ocpp.v201.enums import ( ReportBaseEnumType, ResetEnumType, ResetStatusEnumType, + SendLocalListStatusEnumType, SetNetworkProfileStatusEnumType, TransactionEventEnumType, TriggerMessageStatusEnumType, UnlockStatusEnumType, + UpdateEnumType, UpdateFirmwareStatusEnumType, ) from websockets import ConnectionClosed @@ -71,6 +73,7 @@ DEFAULT_TOKEN_TYPE = "ISO14443" # noqa: S105 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 @@ -144,6 +147,7 @@ class ServerConfig: 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): @@ -161,6 +165,7 @@ 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, @@ -181,6 +186,7 @@ class ChargePoint(ocpp.v201.ChargePoint): 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] @@ -202,6 +208,7 @@ class ChargePoint(ocpp.v201.ChargePoint): 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: @@ -500,6 +507,26 @@ class ChargePoint(ocpp.v201.ChargePoint): 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 @@ -587,6 +614,15 @@ class ChargePoint(ocpp.v201.ChargePoint): 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}, @@ -662,6 +698,7 @@ class ChargePoint(ocpp.v201.ChargePoint): 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", @@ -670,6 +707,7 @@ class ChargePoint(ocpp.v201.ChargePoint): 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", @@ -771,6 +809,7 @@ async def on_connect( 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) @@ -1004,6 +1043,12 @@ async def main(): '(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 @@ -1073,6 +1118,7 @@ async def main(): 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( diff --git a/tests/ocpp-server/test_server.py b/tests/ocpp-server/test_server.py index 38c06103..18a0d19a 100644 --- a/tests/ocpp-server/test_server.py +++ b/tests/ocpp-server/test_server.py @@ -35,15 +35,18 @@ from ocpp.v201.enums import ( 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, @@ -213,6 +216,7 @@ def _patch_main(mock_loop, mock_server, mock_event, extra_patches=None): 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) @@ -358,9 +362,11 @@ class TestHandlerCoverage: "_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", ] @@ -1221,6 +1227,15 @@ class TestOutgoingCommands: 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 @@ -1272,6 +1287,32 @@ class TestOutgoingCommands: 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 @@ -1356,6 +1397,11 @@ class TestOutgoingCommands: 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, -- 2.43.0