]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
feat(ocpp): implement Local Auth List Management Profile (GetLocalListVersion, SendLo...
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Fri, 10 Apr 2026 15:58:43 +0000 (17:58 +0200)
committerGitHub <noreply@github.com>
Fri, 10 Apr 2026 15:58:43 +0000 (17:58 +0200)
* 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>
46 files changed:
.agents/skills/qmd/SKILL.md [new file with mode: 0644]
.agents/skills/qmd/references/mcp-setup.md [new file with mode: 0644]
.opencode/skills/qmd [new symlink]
README.md
src/charging-station/ocpp/1.6/OCPP16Constants.ts
src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts
src/charging-station/ocpp/1.6/OCPP16RequestService.ts
src/charging-station/ocpp/1.6/OCPP16ServiceUtils.ts
src/charging-station/ocpp/1.6/__testable__/index.ts
src/charging-station/ocpp/2.0/OCPP20CertificateManager.ts
src/charging-station/ocpp/2.0/OCPP20Constants.ts
src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts
src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts
src/charging-station/ocpp/2.0/OCPP20VariableManager.ts
src/charging-station/ocpp/2.0/OCPP20VariableRegistry.ts
src/charging-station/ocpp/2.0/__testable__/index.ts
src/charging-station/ocpp/auth/adapters/OCPP16AuthAdapter.ts
src/charging-station/ocpp/auth/adapters/OCPP20AuthAdapter.ts
src/charging-station/ocpp/auth/cache/InMemoryLocalAuthListManager.ts [new file with mode: 0644]
src/charging-station/ocpp/auth/factories/AuthComponentFactory.ts
src/charging-station/ocpp/auth/index.ts
src/charging-station/ocpp/auth/interfaces/OCPPAuthService.ts
src/charging-station/ocpp/auth/services/OCPPAuthServiceImpl.ts
src/charging-station/ocpp/auth/strategies/LocalAuthStrategy.ts
src/charging-station/ocpp/auth/strategies/RemoteAuthStrategy.ts
src/charging-station/ocpp/auth/types/AuthTypes.ts
src/charging-station/ocpp/auth/utils/ConfigValidator.ts
src/types/index.ts
src/types/ocpp/1.6/Requests.ts
src/types/ocpp/1.6/Responses.ts
src/types/ocpp/2.0/Requests.ts
src/types/ocpp/2.0/Responses.ts
src/types/ocpp/2.0/Variables.ts
tests/charging-station/helpers/StationHelpers.ts
tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-LocalAuthList.test.ts [new file with mode: 0644]
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-LocalAuthList.test.ts [new file with mode: 0644]
tests/charging-station/ocpp/auth/OCPPAuthIntegration.test.ts
tests/charging-station/ocpp/auth/cache/InMemoryLocalAuthListManager.test.ts [new file with mode: 0644]
tests/charging-station/ocpp/auth/factories/AuthComponentFactory.test.ts
tests/charging-station/ocpp/auth/helpers/MockFactories.ts
tests/charging-station/ocpp/auth/strategies/LocalAuthStrategy-DisablePostAuthorize.test.ts
tests/charging-station/ocpp/auth/strategies/LocalAuthStrategy.test.ts
tests/charging-station/ocpp/auth/strategies/RemoteAuthStrategy.test.ts
tests/ocpp-server/README.md
tests/ocpp-server/server.py
tests/ocpp-server/test_server.py

diff --git a/.agents/skills/qmd/SKILL.md b/.agents/skills/qmd/SKILL.md
new file mode 100644 (file)
index 0000000..1f5e3e6
--- /dev/null
@@ -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 (file)
index 0000000..ea98224
--- /dev/null
@@ -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 (symlink)
index 0000000..d12152c
--- /dev/null
@@ -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
index 457156360a663217486d620be2f83511b5bb1553..9d601879e8da37414fb5f92eef2f0b09698af1bf 100644 (file)
--- 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
 
index bd70fb8d0723d39c9f59c01cb2a1ceb1f58e6bbe..c81a025c079ed71df86549bd831a502222cf3035 100644 (file)
@@ -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 })
 }
index 3718e774e007988beaa629b157572ccf88f310db..bd3700b00f0705a9fffa4b800cf06f6856313c7f 100644 (file)
@@ -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
index f8bd81dda415470be5059c51e8f35f0089e79acd..dfe2578988a03dce91554793435cde1e452c7a82 100644 (file)
@@ -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
index 09f195e13c9941005863680ad1981fcfffbf6b93..f5d24fc2396050dd59c480e58f1ab3ffc093bf7a 100644 (file)
@@ -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'],
index c5b26950d657021e8b1dbeb2b8e5b0282ec0217c..40512276c0a0c7734cc52d75035dc1ba2bd28f96 100644 (file)
@@ -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<GetDiagnosticsResponse>
 
+  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),
index 2e43be421d6fb12bac32239137c81b836620fecd..5f86dfba97908eff4c5be89a2857bb9f6279cee4 100644 (file)
@@ -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 {
index b3219af5845699d0e47e2dafb715c4d525ad479d..48e8fed2359fb984dc85fe82aea9d3b740ae4c6d 100644 (file)
@@ -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
 
index f6f57e763465060dbefc574ffae05db5fa904811..c92b8460d6188b63347801d0c20c6821755ffa0b 100644 (file)
@@ -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
index 210eb7c340fb88d3dc4a80dec5cca88cb9bf2a6a..5ad867057a44b0357418abeab98342cc8d46afad 100644 (file)
@@ -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'],
index e8093ed801445a5de4881d95cd51abc69c9be22d..5a751eb07b5cba16612f38e98c8a8ab9cebf9e58 100644 (file)
@@ -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,
index 7348c1bc9b91f8f702e8091bcd2dab12a5d3fe51..cd700ca224860434265074aea64bf8e3985114be 100644 (file)
@@ -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<string, VariableMetadata> = {
     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<string, VariableMetadata> = {
     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
index bb328154e89e43a42c9e2f7b87c951b9b5946cc2..c3bd89cb6e294f32d5083ef00a0db1b0e7426c8b 100644 (file)
@@ -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<OCPP20GetInstalledCertificateIdsResponse>
 
+  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<OCPP20ResetResponse>
 
+  handleRequestSendLocalList: (
+    chargingStation: ChargingStation,
+    commandPayload: OCPP20SendLocalListRequest
+  ) => OCPP20SendLocalListResponse
+
   /**
    * Handles OCPP 2.0.1 SetNetworkProfile request from central system.
    * Per TC_B_43_CS: CS must respond to SetNetworkProfile at minimum with Rejected.
@@ -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),
index 1e2eb685a6c3cdda6caec9ad113eaebb9565d02d..6dcb8df5ba3f6132b8b28383dcf2f4aced7e9f61 100644 (file)
@@ -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<string> {
   }
 
   /**
-   * 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<string> {
     }
   }
 
+  /**
+   * 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
index 84c07e9a05edab9a520243cd9a1105100c04d07a..b983d45738ab7d9457efa79ba428232349280fd2 100644 (file)
@@ -295,7 +295,6 @@ export class OCPP20AuthAdapter implements OCPPAuthAdapter<OCPP20IdTokenType> {
   }
 
   /**
-   * 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<OCPP20IdTokenType> {
     }
   }
 
+  /**
+   * @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 (file)
index 0000000..26a315f
--- /dev/null
@@ -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<string, LocalAuthEntry>()
+
+  private readonly maxEntries?: number
+
+  private version = 0
+
+  /**
+   * @param maxEntries - Optional maximum number of entries allowed in the list
+   */
+  constructor (maxEntries?: number) {
+    this.maxEntries = maxEntries
+
+    logger.info(
+      `${moduleName}: Initialized${maxEntries != null ? ` with maxEntries=${String(maxEntries)}` : ''}`
+    )
+  }
+
+  /**
+   * @param entry - Authorization list entry
+   * @throws {Error} if maxEntries is set and adding a new entry would exceed the limit
+   */
+  public addEntry (entry: LocalAuthEntry): void {
+    if (
+      this.maxEntries != null &&
+      !this.entries.has(entry.identifier) &&
+      this.entries.size >= this.maxEntries
+    ) {
+      throw new Error(
+        `${moduleName}: Cannot add entry '${truncateId(entry.identifier)}' — maximum capacity of ${String(this.maxEntries)} entries reached`
+      )
+    }
+
+    this.entries.set(entry.identifier, entry)
+    logger.debug(
+      `${moduleName}: Added/updated entry for identifier: '${truncateId(entry.identifier)}'`
+    )
+  }
+
+  /**
+   * @param entries - Differential entries to apply
+   * @param version - New list version number
+   * @throws {Error} if maxEntries is set and adding new entries would exceed the limit
+   */
+  public applyDifferentialUpdate (entries: DifferentialAuthEntry[], version: number): void {
+    if (this.maxEntries != null) {
+      const newIdentifiers = new Set<string>()
+      const removedIdentifiers = new Set<string>()
+      for (const entry of entries) {
+        if (entry.status != null && !this.entries.has(entry.identifier)) {
+          newIdentifiers.add(entry.identifier)
+          removedIdentifiers.delete(entry.identifier)
+        } else if (entry.status == null && this.entries.has(entry.identifier)) {
+          removedIdentifiers.add(entry.identifier)
+          newIdentifiers.delete(entry.identifier)
+        }
+      }
+      const netNew = newIdentifiers.size - removedIdentifiers.size
+      if (this.entries.size + netNew > this.maxEntries) {
+        throw new Error(
+          `${moduleName}: Cannot apply differential update — would exceed maximum capacity of ${String(this.maxEntries)} entries`
+        )
+      }
+    }
+
+    for (const entry of entries) {
+      if (entry.status != null) {
+        this.entries.set(entry.identifier, { ...entry, status: entry.status })
+      } else {
+        this.entries.delete(entry.identifier)
+        logger.debug(
+          `${moduleName}: Differential removal of identifier: '${truncateId(entry.identifier)}'`
+        )
+      }
+    }
+
+    this.version = version
+    logger.info(
+      `${moduleName}: Applied differential update — ${String(entries.length)} entries processed, version=${String(version)}`
+    )
+  }
+
+  public clearAll (): void {
+    const count = this.entries.size
+    this.entries.clear()
+
+    logger.info(`${moduleName}: Cleared ${String(count)} entries`)
+  }
+
+  public getAllEntries (): LocalAuthEntry[] {
+    return [...this.entries.values()]
+  }
+
+  public getEntry (identifier: string): LocalAuthEntry | undefined {
+    return this.entries.get(identifier)
+  }
+
+  public getVersion (): number {
+    return this.version
+  }
+
+  public removeEntry (identifier: string): void {
+    const deleted = this.entries.delete(identifier)
+
+    if (deleted) {
+      logger.debug(`${moduleName}: Removed entry for identifier: '${truncateId(identifier)}'`)
+    }
+  }
+
+  /**
+   * @param entries - New entries for the list
+   * @param version - New list version number
+   * @throws {Error} if maxEntries is set and the entries array exceeds the limit
+   */
+  public setEntries (entries: LocalAuthEntry[], version: number): void {
+    // Conservative check: uses array length rather than unique identifiers count,
+    // as OCPP messages should not contain duplicate entries per spec
+    if (this.maxEntries != null && entries.length > this.maxEntries) {
+      throw new Error(
+        `${moduleName}: Cannot set ${String(entries.length)} entries — maximum capacity of ${String(this.maxEntries)} entries exceeded`
+      )
+    }
+
+    this.entries.clear()
+
+    for (const entry of entries) {
+      this.entries.set(entry.identifier, entry)
+    }
+
+    this.version = version
+    logger.info(
+      `${moduleName}: Full update — ${String(entries.length)} entries set, version=${String(version)}`
+    )
+  }
+
+  public updateVersion (version: number): void {
+    this.version = version
+    logger.debug(`${moduleName}: Version updated to ${String(version)}`)
+  }
+}
index 81f57e1656d33ada13b29d10bec5703568da0cb4..3a1424c3a107a47fd25a1f9b25c75b662d970718 100644 (file)
@@ -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)
   }
 
   /**
index 32e2cdf0c1cede4c86cfa19695a8b8b45972f221..a7c71129b754600c432232500590b64ada73a4b9 100644 (file)
@@ -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,
index 4ce7adfa031281804b8d9a39b8cb22005620a9fa..5e184bbb130ae9cec5f7d7ad71d9887e55b04be4 100644 (file)
@@ -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 | undefined>
+  ): AuthorizationResult | Promise<AuthorizationResult | undefined> | 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<string, unknown>
+  /** 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<void>
+  addEntry(entry: LocalAuthEntry): void
+
+  /**
+   * Apply a differential update to the list
+   * Entries with status are added/updated; entries without status are removed
+   * @param entries - Differential entries to apply
+   * @param version - New list version number
+   */
+  applyDifferentialUpdate(entries: DifferentialAuthEntry[], version: number): void
 
   /**
    * Clear all entries from the local authorization list
    */
-  clearAll(): Promise<void>
+  clearAll(): void
 
   /**
    * Get all entries (for synchronization)
    */
-  getAllEntries(): Promise<LocalAuthEntry[]>
+  getAllEntries(): LocalAuthEntry[]
 
   /**
    * Get an entry from the local authorization list
    * @param identifier - Identifier to look up
    * @returns Authorization entry or undefined if not found
    */
-  getEntry(identifier: string): Promise<LocalAuthEntry | undefined>
+  getEntry(identifier: string): LocalAuthEntry | undefined
 
   /**
    * Get list version/update count
    */
-  getVersion(): Promise<number>
+  getVersion(): number
 
   /**
    * Remove an entry from the local authorization list
    * @param identifier - Identifier to remove
    */
-  removeEntry(identifier: string): Promise<void>
+  removeEntry(identifier: string): void
+
+  /**
+   * Replace all entries with a new set (Full update)
+   * @param entries - New entries for the list
+   * @param version - New list version number
+   */
+  setEntries(entries: LocalAuthEntry[], version: number): void
 
   /**
    * Update list version
    */
-  updateVersion(version: number): Promise<void>
+  updateVersion(version: number): void
 }
 
 /**
@@ -357,6 +395,12 @@ export interface OCPPAuthAdapter<TVersionId = unknown> {
    */
   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
    */
index 33539295c5c72e0c11832c2e8f48bfeb692effb8..b7db7d2c7f1c594ad2f697ca1031c03957a025c2 100644 (file)
@@ -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<AuthorizationResult> {
+  public authorize (request: AuthRequest): Promise<AuthorizationResult> {
     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
     )
index 3b9550992733a57a4b7e2eaee42d094761237f78..669a572b550ef701b7d9154ba460dab771bdb04d 100644 (file)
@@ -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> {
+  ): 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<boolean> {
+  public isInLocalList (identifier: string): boolean {
     if (!this.localAuthListManager) {
       return false
     }
 
     try {
-      const entry = await this.localAuthListManager.getEntry(identifier)
+      const entry = this.localAuthListManager.getEntry(identifier)
       return !!entry
     } catch (error) {
       const errorMessage = getErrorMessage(error)
@@ -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> {
+  ): 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
       }
index 5374c102a4fe71b5cd20f9de58392add40ac80de..1243e09c689ec5512ac3e97c3c1e7328f58ec09b 100644 (file)
@@ -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)}'`
index 0bd2bf1797e05010632f37b78aa86e24474408c8..3a8f883396c5b5a8b9a24c9aa0faa81eed08aa58 100644 (file)
@@ -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
 
index ed6df7a98b8a588ae19504a0938008306701f8e1..8370e4e77305bf4613ef517977d5598ad42d7a93 100644 (file)
@@ -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
index 4a7a17ecada7cf5d346008bb8baec16421227f12..039ba113a8905f23d275d64f034a0b72c5ae5f0e 100644 (file)
@@ -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,
index f31ce904dcc1e175bc1ff094a072513ffe08f697..1b45679ccb1daf410f72af5939deb7a1351103ab 100644 (file)
@@ -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
index 6dc0358065370752ea7f59edcb021ac15043336a..8274899c521bc41b644c16b250e10961a260dd58 100644 (file)
@@ -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 {
index 4476ec271a90924dfc43bf034a8f464a2408e854..b4fd172cf2914d2c758186f96dbd6fb4b9d3578c 100644 (file)
@@ -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
index eb71f714ab06383b77d797dd471ce9d2f203017d..af58db72e90934bd54ccd1fac7d09d86076871d9 100644 (file)
@@ -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
index 0f12c9e9f0c680072f62a4e8d6138bb9ca9cb4af..2b226a7cc5a03f7074c29be9e391cbcec13ffc36 100644 (file)
@@ -59,6 +59,7 @@ export enum OCPP20RequiredVariableName {
   CertificateEntries = 'CertificateEntries',
   DateTime = 'DateTime',
   Enabled = 'Enabled',
+  Entries = 'Entries',
   EVConnectionTimeOut = 'EVConnectionTimeOut',
   FileTransferProtocols = 'FileTransferProtocols',
   ItemsPerMessage = 'ItemsPerMessage',
index d2ee2ae87d908f4a575a40902fa61b64fbeaba90..a07016072ee9312e10523f7c4178bb810244b7a9 100644 (file)
@@ -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 (file)
index 0000000..221b391
--- /dev/null
@@ -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 (file)
index 0000000..a3b3818
--- /dev/null
@@ -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<typeof createTestableIncomingRequestService>
+  let originalGetInstance: typeof OCPPAuthServiceFactory.getInstance
+
+  /**
+   * Toggle the LocalAuthListCtrlr.Enabled configuration key on the mock station.
+   * @param enabled - Whether to enable or disable the local auth list
+   */
+  function setLocalAuthListEnabled (enabled: boolean): void {
+    upsertConfigurationKey(
+      station,
+      buildConfigKey(OCPP20ComponentName.LocalAuthListCtrlr, OCPP20RequiredVariableName.Enabled),
+      String(enabled)
+    )
+  }
+
+  beforeEach(() => {
+    const { station: mockStation } = createMockChargingStation({
+      baseName: TEST_CHARGING_STATION_BASE_NAME,
+      connectorsCount: 3,
+      evseConfiguration: { evsesCount: 3 },
+      stationInfo: {
+        ocppStrictCompliance: false,
+        ocppVersion: OCPPVersion.VERSION_201,
+      },
+      websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+    })
+    station = mockStation
+    const incomingRequestService = new OCPP20IncomingRequestService()
+    testableService = createTestableIncomingRequestService(incomingRequestService)
+    originalGetInstance = OCPPAuthServiceFactory.getInstance.bind(OCPPAuthServiceFactory)
+    setLocalAuthListEnabled(true)
+  })
+
+  afterEach(() => {
+    Object.assign(OCPPAuthServiceFactory, { getInstance: originalGetInstance })
+    standardCleanup()
+  })
+
+  // ============================================================================
+  // GetLocalListVersion
+  // ============================================================================
+
+  await describe('GetLocalListVersion', async () => {
+    await it('should return version 0 for empty list', () => {
+      const manager = new InMemoryLocalAuthListManager()
+      const mockAuthService = {
+        getConfiguration: () => ({ localAuthListEnabled: true }),
+        getLocalAuthListManager: () => manager,
+      }
+      Object.assign(OCPPAuthServiceFactory, {
+        getInstance: (): typeof mockAuthService => mockAuthService,
+      })
+
+      const response = testableService.handleRequestGetLocalListVersion(station)
+
+      assert.strictEqual(response.versionNumber, 0)
+    })
+
+    await it('should return version 0 when local auth list disabled', () => {
+      setLocalAuthListEnabled(false)
+      const mockAuthService = {
+        getConfiguration: () => ({ localAuthListEnabled: false }),
+        getLocalAuthListManager: () => undefined,
+      }
+      Object.assign(OCPPAuthServiceFactory, {
+        getInstance: (): typeof mockAuthService => mockAuthService,
+      })
+
+      const response = testableService.handleRequestGetLocalListVersion(station)
+
+      assert.strictEqual(response.versionNumber, 0)
+    })
+
+    await it('should return version 0 when manager is undefined', () => {
+      const mockAuthService = {
+        getConfiguration: () => ({ localAuthListEnabled: true }),
+        getLocalAuthListManager: () => undefined,
+      }
+      Object.assign(OCPPAuthServiceFactory, {
+        getInstance: (): typeof mockAuthService => mockAuthService,
+      })
+
+      const response = testableService.handleRequestGetLocalListVersion(station)
+
+      assert.strictEqual(response.versionNumber, 0)
+    })
+
+    await it('should return correct version after SendLocalList', () => {
+      const manager = new InMemoryLocalAuthListManager()
+      manager.setEntries([{ identifier: 'TOKEN_001', status: 'Accepted' }], 5)
+      const mockAuthService = {
+        getConfiguration: () => ({ localAuthListEnabled: true }),
+        getLocalAuthListManager: () => manager,
+      }
+      Object.assign(OCPPAuthServiceFactory, {
+        getInstance: (): typeof mockAuthService => mockAuthService,
+      })
+
+      const response = testableService.handleRequestGetLocalListVersion(station)
+
+      assert.strictEqual(response.versionNumber, 5)
+    })
+
+    await it('should return version 0 when auth service throws', () => {
+      Object.assign(OCPPAuthServiceFactory, {
+        getInstance: (): never => {
+          throw new Error('Auth service unavailable')
+        },
+      })
+
+      const response = testableService.handleRequestGetLocalListVersion(station)
+
+      assert.strictEqual(response.versionNumber, 0)
+    })
+  })
+
+  // ============================================================================
+  // SendLocalList
+  // ============================================================================
+
+  await describe('SendLocalList', async () => {
+    await it('should accept Full update and replace list', () => {
+      const manager = new InMemoryLocalAuthListManager()
+      const mockAuthService = {
+        getConfiguration: () => ({ localAuthListEnabled: true }),
+        getLocalAuthListManager: () => manager,
+      }
+      Object.assign(OCPPAuthServiceFactory, {
+        getInstance: (): typeof mockAuthService => mockAuthService,
+      })
+
+      const response = testableService.handleRequestSendLocalList(station, {
+        localAuthorizationList: [
+          {
+            idToken: { idToken: 'TOKEN_001', type: OCPP20IdTokenEnumType.ISO14443 },
+            idTokenInfo: { status: OCPP20AuthorizationStatusEnumType.Accepted },
+          },
+          {
+            idToken: { idToken: 'TOKEN_002', type: OCPP20IdTokenEnumType.eMAID },
+            idTokenInfo: { status: OCPP20AuthorizationStatusEnumType.Blocked },
+          },
+        ],
+        updateType: OCPP20UpdateEnumType.Full,
+        versionNumber: 3,
+      })
+
+      assert.strictEqual(response.status, OCPP20SendLocalListStatusEnumType.Accepted)
+
+      const entries = manager.getAllEntries()
+      assert.strictEqual(entries.length, 2)
+
+      const version = manager.getVersion()
+      assert.strictEqual(version, 3)
+    })
+
+    await it('should accept Differential update with complex IdToken types', () => {
+      const manager = new InMemoryLocalAuthListManager()
+      manager.setEntries([{ identifier: 'EXISTING_001', status: 'Accepted' }], 1)
+      const mockAuthService = {
+        getConfiguration: () => ({ localAuthListEnabled: true }),
+        getLocalAuthListManager: () => manager,
+      }
+      Object.assign(OCPPAuthServiceFactory, {
+        getInstance: (): typeof mockAuthService => mockAuthService,
+      })
+
+      const response = testableService.handleRequestSendLocalList(station, {
+        localAuthorizationList: [
+          {
+            idToken: { idToken: 'NEW_TOKEN', type: OCPP20IdTokenEnumType.ISO15693 },
+            idTokenInfo: { status: OCPP20AuthorizationStatusEnumType.Accepted },
+          },
+        ],
+        updateType: OCPP20UpdateEnumType.Differential,
+        versionNumber: 2,
+      })
+
+      assert.strictEqual(response.status, OCPP20SendLocalListStatusEnumType.Accepted)
+
+      const entries = manager.getAllEntries()
+      assert.strictEqual(entries.length, 2)
+
+      const version = manager.getVersion()
+      assert.strictEqual(version, 2)
+    })
+
+    await it('should return Failed when disabled (with statusInfo)', () => {
+      setLocalAuthListEnabled(false)
+      const mockAuthService = {
+        getConfiguration: () => ({ localAuthListEnabled: false }),
+        getLocalAuthListManager: () => undefined,
+      }
+      Object.assign(OCPPAuthServiceFactory, {
+        getInstance: (): typeof mockAuthService => mockAuthService,
+      })
+
+      const response = testableService.handleRequestSendLocalList(station, {
+        localAuthorizationList: [],
+        updateType: OCPP20UpdateEnumType.Full,
+        versionNumber: 1,
+      })
+
+      assert.strictEqual(response.status, OCPP20SendLocalListStatusEnumType.Failed)
+      assert.notStrictEqual(response.statusInfo, undefined)
+      assert.strictEqual(response.statusInfo?.reasonCode, ReasonCodeEnumType.NotEnabled)
+    })
+
+    await it('should return Failed when manager is undefined', () => {
+      const mockAuthService = {
+        getConfiguration: () => ({ localAuthListEnabled: true }),
+        getLocalAuthListManager: () => undefined,
+      }
+      Object.assign(OCPPAuthServiceFactory, {
+        getInstance: (): typeof mockAuthService => mockAuthService,
+      })
+
+      const response = testableService.handleRequestSendLocalList(station, {
+        localAuthorizationList: [],
+        updateType: OCPP20UpdateEnumType.Full,
+        versionNumber: 1,
+      })
+
+      assert.strictEqual(response.status, OCPP20SendLocalListStatusEnumType.Failed)
+      assert.notStrictEqual(response.statusInfo, undefined)
+    })
+
+    await it('should clear all entries with Full update and empty list', () => {
+      const manager = new InMemoryLocalAuthListManager()
+      manager.setEntries(
+        [
+          { identifier: 'TOKEN_A', status: 'Accepted' },
+          { identifier: 'TOKEN_B', status: 'Accepted' },
+        ],
+        1
+      )
+      const mockAuthService = {
+        getConfiguration: () => ({ localAuthListEnabled: true }),
+        getLocalAuthListManager: () => manager,
+      }
+      Object.assign(OCPPAuthServiceFactory, {
+        getInstance: (): typeof mockAuthService => mockAuthService,
+      })
+
+      const response = testableService.handleRequestSendLocalList(station, {
+        localAuthorizationList: [],
+        updateType: OCPP20UpdateEnumType.Full,
+        versionNumber: 2,
+      })
+
+      assert.strictEqual(response.status, OCPP20SendLocalListStatusEnumType.Accepted)
+
+      const entries = manager.getAllEntries()
+      assert.strictEqual(entries.length, 0)
+
+      const version = manager.getVersion()
+      assert.strictEqual(version, 2)
+    })
+
+    await it('should return Failed when auth service throws', () => {
+      Object.assign(OCPPAuthServiceFactory, {
+        getInstance: (): never => {
+          throw new Error('Auth service unavailable')
+        },
+      })
+
+      const response = testableService.handleRequestSendLocalList(station, {
+        localAuthorizationList: [],
+        updateType: OCPP20UpdateEnumType.Full,
+        versionNumber: 1,
+      })
+
+      assert.strictEqual(response.status, OCPP20SendLocalListStatusEnumType.Failed)
+    })
+
+    await it('should handle Differential update removing entries (no idTokenInfo)', () => {
+      const manager = new InMemoryLocalAuthListManager()
+      manager.setEntries(
+        [
+          { identifier: 'REMOVE_ME', status: 'Accepted' },
+          { identifier: 'KEEP_ME', status: 'Accepted' },
+        ],
+        1
+      )
+      const mockAuthService = {
+        getConfiguration: () => ({ localAuthListEnabled: true }),
+        getLocalAuthListManager: () => manager,
+      }
+      Object.assign(OCPPAuthServiceFactory, {
+        getInstance: (): typeof mockAuthService => mockAuthService,
+      })
+
+      const response = testableService.handleRequestSendLocalList(station, {
+        localAuthorizationList: [
+          {
+            idToken: { idToken: 'REMOVE_ME', type: OCPP20IdTokenEnumType.ISO14443 },
+            // No idTokenInfo → status will be undefined → removal in differential
+          },
+        ],
+        updateType: OCPP20UpdateEnumType.Differential,
+        versionNumber: 2,
+      })
+
+      assert.strictEqual(response.status, OCPP20SendLocalListStatusEnumType.Accepted)
+
+      const entries = manager.getAllEntries()
+      assert.strictEqual(entries.length, 1)
+      assert.strictEqual(entries[0].identifier, 'KEEP_ME')
+
+      const version = manager.getVersion()
+      assert.strictEqual(version, 2)
+    })
+
+    await it('should preserve idTokenType metadata in entries', () => {
+      const manager = new InMemoryLocalAuthListManager()
+      const mockAuthService = {
+        getConfiguration: () => ({ localAuthListEnabled: true }),
+        getLocalAuthListManager: () => manager,
+      }
+      Object.assign(OCPPAuthServiceFactory, {
+        getInstance: (): typeof mockAuthService => mockAuthService,
+      })
+
+      testableService.handleRequestSendLocalList(station, {
+        localAuthorizationList: [
+          {
+            idToken: { idToken: 'EMAID_TOKEN', type: OCPP20IdTokenEnumType.eMAID },
+            idTokenInfo: { status: OCPP20AuthorizationStatusEnumType.Accepted },
+          },
+        ],
+        updateType: OCPP20UpdateEnumType.Full,
+        versionNumber: 1,
+      })
+
+      const entry = manager.getEntry('EMAID_TOKEN')
+      assert.ok(entry != null)
+      assert.strictEqual(entry.status, 'Accepted')
+      assert.deepStrictEqual(entry.metadata, { idTokenType: OCPP20IdTokenEnumType.eMAID })
+    })
+
+    await it('should handle Full update with undefined localAuthorizationList', () => {
+      const manager = new InMemoryLocalAuthListManager()
+      manager.setEntries([{ identifier: 'OLD_TOKEN', status: 'Accepted' }], 1)
+      const mockAuthService = {
+        getConfiguration: () => ({ localAuthListEnabled: true }),
+        getLocalAuthListManager: () => manager,
+      }
+      Object.assign(OCPPAuthServiceFactory, {
+        getInstance: (): typeof mockAuthService => mockAuthService,
+      })
+
+      const response = testableService.handleRequestSendLocalList(station, {
+        updateType: OCPP20UpdateEnumType.Full,
+        versionNumber: 2,
+      })
+
+      assert.strictEqual(response.status, OCPP20SendLocalListStatusEnumType.Accepted)
+
+      const entries = manager.getAllEntries()
+      assert.strictEqual(entries.length, 0)
+
+      const version = manager.getVersion()
+      assert.strictEqual(version, 2)
+    })
+
+    await it('should convert cacheExpiryDateTime to Date', () => {
+      const manager = new InMemoryLocalAuthListManager()
+      const mockAuthService = {
+        getConfiguration: () => ({ localAuthListEnabled: true }),
+        getLocalAuthListManager: () => manager,
+      }
+      Object.assign(OCPPAuthServiceFactory, {
+        getInstance: (): typeof mockAuthService => mockAuthService,
+      })
+
+      const expiryDate = new Date('2027-01-01T00:00:00.000Z')
+
+      testableService.handleRequestSendLocalList(station, {
+        localAuthorizationList: [
+          {
+            idToken: { idToken: 'EXPIRY_TOKEN', type: OCPP20IdTokenEnumType.ISO14443 },
+            idTokenInfo: {
+              cacheExpiryDateTime: expiryDate,
+              status: OCPP20AuthorizationStatusEnumType.Accepted,
+            },
+          },
+        ],
+        updateType: OCPP20UpdateEnumType.Full,
+        versionNumber: 1,
+      })
+
+      const entry = manager.getEntry('EXPIRY_TOKEN')
+      assert.notStrictEqual(entry, undefined)
+      assert.ok(entry?.expiryDate instanceof Date)
+    })
+
+    await it('should return VersionMismatch for differential update with version < current', () => {
+      const manager = new InMemoryLocalAuthListManager()
+      manager.setEntries([{ identifier: 'TOKEN_001', status: 'Accepted' }], 5)
+      const mockAuthService = {
+        getConfiguration: () => ({ localAuthListEnabled: true }),
+        getLocalAuthListManager: () => manager,
+      }
+      Object.assign(OCPPAuthServiceFactory, {
+        getInstance: (): typeof mockAuthService => mockAuthService,
+      })
+
+      const response = testableService.handleRequestSendLocalList(station, {
+        localAuthorizationList: [
+          {
+            idToken: { idToken: 'NEW_TOKEN', type: OCPP20IdTokenEnumType.ISO14443 },
+            idTokenInfo: { status: OCPP20AuthorizationStatusEnumType.Accepted },
+          },
+        ],
+        updateType: OCPP20UpdateEnumType.Differential,
+        versionNumber: 3,
+      })
+
+      assert.strictEqual(response.status, OCPP20SendLocalListStatusEnumType.VersionMismatch)
+    })
+
+    await it('should return VersionMismatch for differential update with version equal to current', () => {
+      const manager = new InMemoryLocalAuthListManager()
+      manager.setEntries([{ identifier: 'TOKEN_001', status: 'Accepted' }], 5)
+      const mockAuthService = {
+        getConfiguration: () => ({ localAuthListEnabled: true }),
+        getLocalAuthListManager: () => manager,
+      }
+      Object.assign(OCPPAuthServiceFactory, {
+        getInstance: (): typeof mockAuthService => mockAuthService,
+      })
+
+      const response = testableService.handleRequestSendLocalList(station, {
+        localAuthorizationList: [
+          {
+            idToken: { idToken: 'NEW_TOKEN', type: OCPP20IdTokenEnumType.ISO14443 },
+            idTokenInfo: { status: OCPP20AuthorizationStatusEnumType.Accepted },
+          },
+        ],
+        updateType: OCPP20UpdateEnumType.Differential,
+        versionNumber: 5,
+      })
+
+      assert.strictEqual(response.status, OCPP20SendLocalListStatusEnumType.VersionMismatch)
+    })
+
+    await it('should return Failed with versionNumber <= 0', () => {
+      const manager = new InMemoryLocalAuthListManager()
+      const mockAuthService = {
+        getConfiguration: () => ({ localAuthListEnabled: true }),
+        getLocalAuthListManager: () => manager,
+      }
+      Object.assign(OCPPAuthServiceFactory, {
+        getInstance: (): typeof mockAuthService => mockAuthService,
+      })
+
+      const response = testableService.handleRequestSendLocalList(station, {
+        localAuthorizationList: [],
+        updateType: OCPP20UpdateEnumType.Full,
+        versionNumber: 0,
+      })
+
+      assert.strictEqual(response.status, OCPP20SendLocalListStatusEnumType.Failed)
+    })
+
+    await it('should return Failed with negative versionNumber', () => {
+      const manager = new InMemoryLocalAuthListManager()
+      const mockAuthService = {
+        getConfiguration: () => ({ localAuthListEnabled: true }),
+        getLocalAuthListManager: () => manager,
+      }
+      Object.assign(OCPPAuthServiceFactory, {
+        getInstance: (): typeof mockAuthService => mockAuthService,
+      })
+
+      const response = testableService.handleRequestSendLocalList(station, {
+        localAuthorizationList: [],
+        updateType: OCPP20UpdateEnumType.Full,
+        versionNumber: -1,
+      })
+
+      assert.strictEqual(response.status, OCPP20SendLocalListStatusEnumType.Failed)
+    })
+
+    await it('should accept Full update regardless of version (no VersionMismatch)', () => {
+      const manager = new InMemoryLocalAuthListManager()
+      manager.setEntries([{ identifier: 'TOKEN_001', status: 'Accepted' }], 5)
+      const mockAuthService = {
+        getConfiguration: () => ({ localAuthListEnabled: true }),
+        getLocalAuthListManager: () => manager,
+      }
+      Object.assign(OCPPAuthServiceFactory, {
+        getInstance: (): typeof mockAuthService => mockAuthService,
+      })
+
+      const response = testableService.handleRequestSendLocalList(station, {
+        localAuthorizationList: [
+          {
+            idToken: { idToken: 'NEW_TOKEN', type: OCPP20IdTokenEnumType.ISO14443 },
+            idTokenInfo: { status: OCPP20AuthorizationStatusEnumType.Accepted },
+          },
+        ],
+        updateType: OCPP20UpdateEnumType.Full,
+        versionNumber: 3,
+      })
+
+      assert.strictEqual(response.status, OCPP20SendLocalListStatusEnumType.Accepted)
+    })
+  })
+})
index 744cd9782c80a0f82235d0151e27e8705d0a15d8..7cbcca277d06c4cca06f171fbce966f95ce55df9 100644 (file)
@@ -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<LocalAuthEntry | undefined>(resolve => {
-              resolve(id === 'LIST-ID' ? { identifier: 'LIST-ID', status: 'accepted' } : undefined)
-            }),
+            id === 'LIST-ID' ? { identifier: 'LIST-ID', status: 'accepted' } : undefined,
         })
 
         const strategy = new LocalAuthStrategy(listManager, cache)
@@ -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 (file)
index 0000000..68447a5
--- /dev/null
@@ -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>
+): 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)
+    })
+  })
+})
index 9f339effb8d3c6d877418743272276f53ac34cfc..8ed50c5fed7c4287b25882b73e2e962bb6fa6e13 100644 (file)
@@ -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)
     })
index a1a17f178841c1a97b4d32d7c91c0799003a2be0..829c419af0be49817bce51a8e3169b8133d05dc4 100644 (file)
@@ -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<OCPPAuthService>): 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>
 ): LocalAuthListManager => ({
-  addEntry: () =>
-    new Promise<void>(resolve => {
-      resolve()
-    }),
-  clearAll: () =>
-    new Promise<void>(resolve => {
-      resolve()
-    }),
-  getAllEntries: () =>
-    new Promise<LocalAuthEntry[]>(resolve => {
-      resolve([])
-    }),
-  getEntry: () =>
-    new Promise<LocalAuthEntry | undefined>(resolve => {
-      resolve(undefined)
-    }),
-  getVersion: () =>
-    new Promise<number>(resolve => {
-      resolve(1)
-    }),
-  removeEntry: () =>
-    new Promise<void>(resolve => {
-      resolve()
-    }),
-  updateVersion: () =>
-    new Promise<void>(resolve => {
-      resolve()
-    }),
+  addEntry: () => {
+    /* empty */
+  },
+  applyDifferentialUpdate: () => {
+    /* empty */
+  },
+  clearAll: () => {
+    /* empty */
+  },
+  getAllEntries: () => [],
+  getEntry: () => undefined,
+  getVersion: () => 1,
+  removeEntry: () => {
+    /* empty */
+  },
+  setEntries: () => {
+    /* empty */
+  },
+  updateVersion: () => {
+    /* empty */
+  },
   ...overrides,
 })
 
index d942a091541c5fd942aee40fdf16630b6b59880f..4bf4134ca46752ad6523b37a16657f9f1a5de8fc 100644 (file)
@@ -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)
index c0e6451437f6571f98f4aeaf5fb04bc3d147f26a..5d85ff4f0ce2abc40e851ce4c9a1f1ab2c1172db 100644 (file)
@@ -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<LocalAuthEntry | undefined>(resolve => {
-          resolve({
-            expiryDate: new Date(Date.now() + 86400000),
-            identifier: 'LOCAL_TAG',
-            metadata: { source: 'local' },
-            status: 'accepted',
-          })
-        })
+    await it('should authenticate using local auth list', () => {
+      mockLocalAuthListManager.getEntry = () => ({
+        expiryDate: new Date(Date.now() + 86400000),
+        identifier: 'LOCAL_TAG',
+        metadata: { source: 'local' },
+        status: 'accepted',
+      })
 
       const config = createTestAuthConfig({
         authorizationCacheEnabled: true,
@@ -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<LocalAuthEntry | undefined>(resolve => {
-          resolve({
-            identifier: 'LOCAL_TAG',
-            status: 'accepted',
-          })
-        })
+    await it('should return true when identifier is in local list', () => {
+      mockLocalAuthListManager.getEntry = () => ({
+        identifier: 'LOCAL_TAG',
+        status: 'accepted',
+      })
 
-      assert.strictEqual(await strategy.isInLocalList('LOCAL_TAG'), true)
+      assert.strictEqual(strategy.isInLocalList('LOCAL_TAG'), true)
     })
 
-    await it('should return false when identifier is not in local list', async () => {
-      mockLocalAuthListManager.getEntry = () =>
-        new Promise<LocalAuthEntry | undefined>(resolve => {
-          resolve(undefined)
-        })
+    await it('should return false when identifier is not in local list', () => {
+      mockLocalAuthListManager.getEntry = () => undefined
 
-      assert.strictEqual(await strategy.isInLocalList('UNKNOWN_TAG'), false)
+      assert.strictEqual(strategy.isInLocalList('UNKNOWN_TAG'), false)
     })
   })
 
index cafb24464496aa37e426bcfc14c01efb24906cde..6f6520b44406d48f1c8b0c69b110bee6eff928aa 100644 (file)
@@ -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<LocalAuthEntry | undefined>(resolve => {
-          if (identifier === 'LOCAL_AUTH_TAG') {
-            resolve({
-              identifier: 'LOCAL_AUTH_TAG',
-              status: 'Active',
-            })
-          } else {
-            resolve(undefined)
-          }
-        })
+        identifier === 'LOCAL_AUTH_TAG'
+          ? { identifier: 'LOCAL_AUTH_TAG', status: 'Active' }
+          : undefined
 
       const config = createTestAuthConfig({
         authorizationCacheEnabled: true,
@@ -372,10 +364,7 @@ await describe('RemoteAuthStrategy', async () => {
         cachedKey = key
       }
 
-      mockLocalAuthListManager.getEntry = () =>
-        new Promise<LocalAuthEntry | undefined>(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 () => {
index 496c7431d2ffc731aa5994486f8d3aa5f825456c..5dd3c2aa4402b476c0fd89d7dedd0c81424726f4 100644 (file)
@@ -112,6 +112,7 @@ These flags customize the payload of specific commands:
   - `Inoperative` — Connector unavailable
 - `--set-variables <SPECS>`: SetVariables data as `Component.Variable=Value,...` (values must not contain commas)
 - `--get-variables <SPECS>`: GetVariables data as `Component.Variable,...`
+- `--local-list-tokens TOKEN ...`: Tokens to include in SendLocalList (default: test token)
 
 ```shell
 poetry run python server.py --command TriggerMessage --trigger-message BootNotification --delay 5
@@ -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
index 4d54b736b2a404d3a85fffc91f4edd04bf13f234..e19f271e165e631699bfa4888faf6e0275997e8b 100644 (file)
@@ -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(
index 38c061035daf0a7556c24682f6a3318f3b750431..18a0d19a45de2543bdcb2cfa078ff929eee58197 100644 (file)
@@ -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,