]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
fix(ocpp): auth cache spec compliance (R0–R17) (#1699)
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Thu, 5 Mar 2026 20:24:20 +0000 (21:24 +0100)
committerGitHub <noreply@github.com>
Thu, 5 Mar 2026 20:24:20 +0000 (21:24 +0100)
* fix(ocpp): wire auth cache into strategies and fix default configuration

* fix(ocpp): add isCacheable dead code note and cache wiring test

* fix(ocpp): cache all authorization statuses per OCPP spec (R1)

- Remove ACCEPTED-only guard in RemoteAuthStrategy: now caches ALL
  authorization statuses (BLOCKED, EXPIRED, INVALID) per OCPP 1.6 §3.5.1
  and OCPP 2.0.1 AuthCacheCtrlr
- Simplify isCacheable() to always return true — all statuses cacheable
- Update AuthHelpers tests to reflect all-statuses policy (T4.05-T4.09)
- Add RemoteAuthStrategy tests for BLOCKED/EXPIRED/INVALID caching (T4.01-T4.04)

* fix(ocpp): status-aware eviction, TTL reset, expired lifecycle (R2, R5, R10, R16)

* fix(ocpp): exclude local auth list identifiers from cache (R17)

* fix(ocpp): periodic cache cleanup and bounded rate limits (R8, R9)

* fix(ocpp): make resetStats public and preserve stats across clear (R14, R15)

* test(ocpp): integration tests for auth cache spec compliance (T12)

* [autofix.ci] apply automated fixes

* fix(ocpp): address PR #1699 review feedback (F1-F9)

- F1: increment stats.hits for expired cache entries in get()
- F2: use captured 'now' timestamp in set() LRU update
- F3: two-phase expiration in runCleanup() (transition then delete)
- F4: guard cleanupIntervalSeconds with > 0 (NaN protection)
- F5: forward localAuthListManager to RemoteAuthStrategy
- F6: apply absolute 24h lifetime cap in expired-transition branch
- F7: guard local auth list query with config.localAuthListEnabled
- F8: mask identifier in debug logs (first 8 chars only)
- F9: remove dead isCacheable() method and its tests

* refactor(test): replace inline setTimeout with shared sleep helper

* [autofix.ci] apply automated fixes

* fix(ocpp): replace redundant Promise.resolve returns in async methods

* fix(auth): make AuthCache interface synchronous and remove all Promise wrappers

BREAKING CHANGE: AuthCache interface methods (get, set, remove, clear,
getStats) are now synchronous. OCPPAuthService.clearCache() and
invalidateCache() return void instead of Promise<void>.

- Remove async/Promise wrappers from AuthCache interface and InMemoryAuthCache
- Make OCPPAuthService clearCache/invalidateCache return void
- Update all strategies (Local/Remote) to use sync cache operations
- Remove await from all sync cache calls in source and test files
- Fix mock factories to use sync signatures with /* empty */ bodies
- Widen TTL timing margins in InMemoryAuthCache tests to reduce flakiness
- Fix JSDoc annotations in TestLifecycleHelpers and InMemoryAuthCache

* refactor(auth): eliminate all Promise.resolve() from auth module

Replace Promise.resolve() wrappers with proper sync/async signatures:
- Update AuthStrategy interface: getStats() returns sync Record, initialize() returns sync void
- Update OCPPAuthService interface: getStats() returns sync AuthStats
- Convert strategy getStats()/initialize()/cleanup() implementations to sync
- Convert adapter sync methods (convertToUnifiedIdentifier, convertFromUnifiedIdentifier,
  getConfigurationSchema, validateConfiguration) from async to sync
- Update OCPPAuthServiceImpl to match sync interface signatures
- Update OCPPAuthIntegrationTest to match sync interface
- Align all test mocks with exact interface signatures (sync mocks for sync
  interfaces, async mocks for async interfaces)
- Remove all eslint-disable require-await comments from test files

* fix(auth): resolve all lint errors for CI compliance

- Reorder union types to satisfy perfectionist/sort-union-types
- Remove @returns JSDoc from void functions (jsdoc/require-returns-check)
- Make OCPP16/20 adapter sync methods non-async (require-await)
- Fix all mock signatures to match interface contracts exactly
- Replace async mock lambdas with new Promise<T> pattern
- Fix no-confusing-void-expression in test assertions
- Sort named imports alphabetically (perfectionist/sort-named-imports)
- Add missing await for floating promise in test (no-floating-promises)

* [autofix.ci] apply automated fixes

* fix(auth): remove eslint-disable added by us, use expect().not.toThrow() pattern

* ci: trigger CI run

* refactor(auth): apply code review corrections (fixes 1-10)

Fix validators, convert static classes to modules,
improve cache configurability, fix test configs,
replace sleep with mock timers, fix Promise leak,
move integration test helper to tests/helpers.

* fix(auth): resolve all 35 JSDoc lint warnings for clean lint gate

* fix(auth): address cross-validated audit findings (C1-C3, M1-M6, minor fixes)

- C1: fix timer leak in performRemoteAuthorization (clearTimeout on success/catch)
- C2: document LocalAuthListManager stub with TODO, mark §3.5.3 guard inactive
- C3: expand factory gate to check all three auth feature flags
- M1: change RemoteAuthStrategy.canHandle to use remoteAuthorization config flag
- M2: add AuthenticationMethod.NONE enum for all-strategies-fail fallback
- M4: add NO_AUTHORIZATION to OCPP20AuthAdapter validTypes
- M3: wire hasExplicitTtl into CacheEntry for CSMS-provided TTL preservation
- M6: filter NoAuthorization/Central identifiers from cache (C02.FR.03, C03.FR.02)
- m3: fix boundRateLimitsMap to evict in loop until within threshold
- m4: stop counting expired entries as cache hits
- m7: clamp TTL to [0, maxAbsoluteLifetime] range
- m8: enforce maxEntries >= 1 in constructor

* refactor(auth): use sleep() utility instead of raw Promise setTimeout

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
29 files changed:
src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts
src/charging-station/ocpp/auth/adapters/OCPP16AuthAdapter.ts
src/charging-station/ocpp/auth/adapters/OCPP20AuthAdapter.ts
src/charging-station/ocpp/auth/cache/InMemoryAuthCache.ts
src/charging-station/ocpp/auth/factories/AuthComponentFactory.ts
src/charging-station/ocpp/auth/interfaces/OCPPAuthService.ts
src/charging-station/ocpp/auth/services/OCPPAuthServiceImpl.ts
src/charging-station/ocpp/auth/strategies/CertificateAuthStrategy.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/AuthHelpers.ts
src/charging-station/ocpp/auth/utils/AuthValidators.ts
src/charging-station/ocpp/auth/utils/ConfigValidator.ts
tests/TEST_STYLE_GUIDE.md
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-ClearCache.test.ts
tests/charging-station/ocpp/auth/OCPPAuthIntegration.test.ts
tests/charging-station/ocpp/auth/adapters/OCPP16AuthAdapter.test.ts
tests/charging-station/ocpp/auth/adapters/OCPP20AuthAdapter.test.ts
tests/charging-station/ocpp/auth/cache/InMemoryAuthCache.test.ts
tests/charging-station/ocpp/auth/helpers/MockFactories.ts
tests/charging-station/ocpp/auth/services/OCPPAuthServiceImpl.test.ts
tests/charging-station/ocpp/auth/strategies/CertificateAuthStrategy.test.ts
tests/charging-station/ocpp/auth/strategies/LocalAuthStrategy.test.ts
tests/charging-station/ocpp/auth/strategies/RemoteAuthStrategy.test.ts
tests/charging-station/ocpp/auth/utils/AuthHelpers.test.ts
tests/charging-station/ocpp/auth/utils/AuthValidators.test.ts
tests/helpers/OCPPAuthIntegrationTest.ts [moved from src/charging-station/ocpp/auth/test/OCPPAuthIntegrationTest.ts with 73% similarity]
tests/helpers/TestLifecycleHelpers.ts

index 671c212ad6b97fe9e25c6662a0d29f09fbe84096..e01c50bea41522ee47795ccfc0114274f37e416f 100644 (file)
@@ -472,7 +472,7 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
         )
         return OCPP20Constants.OCPP_RESPONSE_REJECTED
       }
-      await authService.clearCache()
+      authService.clearCache()
       logger.info(
         `${chargingStation.logPrefix()} ${moduleName}.handleRequestClearCache: Authorization cache cleared`
       )
index f386aea33470ddaae2baecd37ca2cdd93a1bf92f..b4c68f21caf7e46481354b7bdd73766747895f1d 100644 (file)
@@ -273,7 +273,7 @@ export class OCPP16AuthAdapter implements OCPPAuthAdapter {
    * Check if remote authorization is available
    * @returns True if remote authorization is enabled and station is online
    */
-  isRemoteAvailable (): Promise<boolean> {
+  isRemoteAvailable (): boolean {
     try {
       // Check if station supports remote authorization
       const remoteAuthEnabled = this.chargingStation.stationInfo?.remoteAuthorization === true
@@ -281,13 +281,13 @@ export class OCPP16AuthAdapter implements OCPPAuthAdapter {
       // Check if station is online and can communicate
       const isOnline = this.chargingStation.inAcceptedState()
 
-      return Promise.resolve(remoteAuthEnabled && isOnline)
+      return remoteAuthEnabled && isOnline
     } catch (error) {
       logger.warn(
         `${this.chargingStation.logPrefix()} Error checking remote authorization availability`,
         error
       )
-      return Promise.resolve(false)
+      return false
     }
   }
 
@@ -323,7 +323,7 @@ export class OCPP16AuthAdapter implements OCPPAuthAdapter {
    * @param config - Auth configuration to validate
    * @returns True if configuration has valid auth methods and timeout values
    */
-  validateConfiguration (config: AuthConfiguration): Promise<boolean> {
+  validateConfiguration (config: AuthConfiguration): boolean {
     try {
       // Check that at least one authorization method is enabled
       const hasLocalAuth = config.localAuthListEnabled
@@ -333,7 +333,7 @@ export class OCPP16AuthAdapter implements OCPPAuthAdapter {
         logger.warn(
           `${this.chargingStation.logPrefix()} OCPP 1.6 adapter: No authorization methods enabled`
         )
-        return Promise.resolve(false)
+        return false
       }
 
       // Validate timeout values
@@ -341,16 +341,16 @@ export class OCPP16AuthAdapter implements OCPPAuthAdapter {
         logger.warn(
           `${this.chargingStation.logPrefix()} OCPP 1.6 adapter: Invalid authorization timeout`
         )
-        return Promise.resolve(false)
+        return false
       }
 
-      return Promise.resolve(true)
+      return true
     } catch (error) {
       logger.error(
         `${this.chargingStation.logPrefix()} OCPP 1.6 adapter configuration validation failed`,
         error
       )
-      return Promise.resolve(false)
+      return false
     }
   }
 
index bd4f18e401c99acc7426d137efbf86d349162546..ce3d3044399b00cd9c0fee1d7ae5a318642d7fe9 100644 (file)
@@ -71,7 +71,7 @@ export class OCPP20AuthAdapter implements OCPPAuthAdapter {
       )
 
       // Check if remote authorization is configured
-      const isRemoteAuth = await this.isRemoteAvailable()
+      const isRemoteAuth = this.isRemoteAvailable()
       if (!isRemoteAuth) {
         return {
           additionalInfo: {
@@ -441,7 +441,7 @@ export class OCPP20AuthAdapter implements OCPPAuthAdapter {
    * Check if remote authorization is available for OCPP 2.0
    * @returns True if remote authorization is available and enabled
    */
-  async isRemoteAvailable (): Promise<boolean> {
+  isRemoteAvailable (): boolean {
     try {
       // Check if station supports remote authorization via variables
       // OCPP 2.0 uses variables instead of configuration keys
@@ -450,7 +450,7 @@ export class OCPP20AuthAdapter implements OCPPAuthAdapter {
       const isOnline = this.chargingStation.inAcceptedState()
 
       // Check AuthorizeRemoteStart variable (with type validation)
-      const remoteStartValue = await this.getVariableValue('AuthCtrlr', 'AuthorizeRemoteStart')
+      const remoteStartValue = this.getVariableValue('AuthCtrlr', 'AuthorizeRemoteStart')
       const remoteStartEnabled = this.parseBooleanVariable(remoteStartValue, true)
 
       return isOnline && remoteStartEnabled
@@ -489,6 +489,7 @@ export class OCPP20AuthAdapter implements OCPPAuthAdapter {
       IdentifierType.KEY_CODE,
       IdentifierType.E_MAID,
       IdentifierType.MAC_ADDRESS,
+      IdentifierType.NO_AUTHORIZATION,
     ]
 
     return validTypes.includes(identifier.type)
@@ -499,18 +500,18 @@ export class OCPP20AuthAdapter implements OCPPAuthAdapter {
    * @param config - Authentication configuration to validate
    * @returns Promise resolving to true if configuration is valid for OCPP 2.0 operations
    */
-  validateConfiguration (config: AuthConfiguration): Promise<boolean> {
+  validateConfiguration (config: AuthConfiguration): boolean {
     try {
       // Check that at least one authorization method is enabled
-      const hasRemoteAuth = config.authorizeRemoteStart === true
-      const hasLocalAuth = config.localAuthorizeOffline === true
-      const hasCertAuth = config.certificateValidation === true
+      const hasRemoteAuth = config.remoteAuthorization === true
+      const hasLocalAuth = config.offlineAuthorizationEnabled
+      const hasCertAuth = config.certificateAuthEnabled
 
       if (!hasRemoteAuth && !hasLocalAuth && !hasCertAuth) {
         logger.warn(
           `${this.chargingStation.logPrefix()} OCPP 2.0 adapter: No authorization methods enabled`
         )
-        return Promise.resolve(false)
+        return false
       }
 
       // Validate timeout values
@@ -518,16 +519,16 @@ export class OCPP20AuthAdapter implements OCPPAuthAdapter {
         logger.warn(
           `${this.chargingStation.logPrefix()} OCPP 2.0 adapter: Invalid authorization timeout`
         )
-        return Promise.resolve(false)
+        return false
       }
 
-      return Promise.resolve(true)
+      return true
     } catch (error) {
       logger.error(
         `${this.chargingStation.logPrefix()} OCPP 2.0 adapter configuration validation failed`,
         error
       )
-      return Promise.resolve(false)
+      return false
     }
   }
 
@@ -597,7 +598,7 @@ export class OCPP20AuthAdapter implements OCPPAuthAdapter {
     component: string,
     variable: string,
     useDefaultFallback = true
-  ): Promise<string | undefined> {
+  ): string | undefined {
     try {
       const variableManager = OCPP20VariableManager.getInstance()
 
@@ -613,9 +614,7 @@ export class OCPP20AuthAdapter implements OCPPAuthAdapter {
         logger.debug(
           `${this.chargingStation.logPrefix()} Variable ${component}.${variable} not found in registry`
         )
-        return Promise.resolve(
-          this.getDefaultVariableValue(component, variable, useDefaultFallback)
-        )
+        return this.getDefaultVariableValue(component, variable, useDefaultFallback)
       }
 
       const result = results[0]
@@ -628,18 +627,16 @@ export class OCPP20AuthAdapter implements OCPPAuthAdapter {
         logger.debug(
           `${this.chargingStation.logPrefix()} Variable ${component}.${variable} not available: ${result.attributeStatus}`
         )
-        return Promise.resolve(
-          this.getDefaultVariableValue(component, variable, useDefaultFallback)
-        )
+        return this.getDefaultVariableValue(component, variable, useDefaultFallback)
       }
 
-      return Promise.resolve(result.attributeValue)
+      return result.attributeValue
     } catch (error) {
       logger.warn(
         `${this.chargingStation.logPrefix()} Error getting variable ${component}.${variable}`,
         error
       )
-      return Promise.resolve(this.getDefaultVariableValue(component, variable, useDefaultFallback))
+      return this.getDefaultVariableValue(component, variable, useDefaultFallback)
     }
   }
 
index 2e285459c11f35e67f1cb980faaeccc4d0921f19..7f76a18e3f82e79d2291db8e2c3d4cdbab913751 100644 (file)
@@ -2,13 +2,18 @@ import type { AuthCache, CacheStats } from '../interfaces/OCPPAuthService.js'
 import type { AuthorizationResult } from '../types/AuthTypes.js'
 
 import { logger } from '../../../../utils/Logger.js'
+import { AuthorizationStatus } from '../types/AuthTypes.js'
 
 /**
  * Cached authorization entry with expiration
  */
 interface CacheEntry {
+  /** Timestamp when entry was originally created (milliseconds since epoch) */
+  createdAt: number
   /** Timestamp when entry expires (milliseconds since epoch) */
   expiresAt: number
+  /** Whether TTL was explicitly provided (e.g. from CSMS cacheExpiryDateTime) */
+  hasExplicitTtl: boolean
   /** Cached authorization result */
   result: AuthorizationResult
 }
@@ -42,6 +47,7 @@ interface RateLimitStats {
  * - LRU eviction when maxEntries is reached
  * - Automatic expiration of cache entries based on TTL
  * - Rate limiting per identifier (requests per time window)
+ * - Periodic cleanup of expired entries
  * - Memory usage tracking
  * - Comprehensive statistics
  *
@@ -49,17 +55,25 @@ interface RateLimitStats {
  * - Rate limiting prevents DoS attacks on auth endpoints
  * - Cache expiration ensures stale auth data doesn't persist
  * - Memory limits prevent memory exhaustion attacks
+ * - Bounded rate limits map prevents unbounded memory growth
  */
 export class InMemoryAuthCache implements AuthCache {
+  // Implementation-specific safety limit (not mandated by OCPP spec)
+  private static readonly DEFAULT_MAX_ABSOLUTE_LIFETIME_MS = 86_400_000 // 24 hours
+
   /** Cache storage: identifier -> entry */
   private readonly cache = new Map<string, CacheEntry>()
 
+  private cleanupInterval?: ReturnType<typeof setInterval>
+
   /** Default TTL in seconds */
   private readonly defaultTtl: number
 
   /** Access order for LRU eviction (identifier -> last access timestamp) */
   private readonly lruOrder = new Map<string, number>()
 
+  private readonly maxAbsoluteLifetimeMs: number
+
   /** Maximum number of entries allowed in cache */
   private readonly maxEntries: number
 
@@ -87,26 +101,41 @@ export class InMemoryAuthCache implements AuthCache {
   /**
    * Create an in-memory auth cache
    * @param options - Cache configuration options
+   * @param options.cleanupIntervalSeconds - Periodic cleanup interval in seconds (default: 300, 0 to disable)
    * @param options.defaultTtl - Default TTL in seconds (default: 3600)
+   * @param options.maxAbsoluteLifetimeMs - Absolute lifetime cap in milliseconds (default: 86400000)
    * @param options.maxEntries - Maximum number of cache entries (default: 1000)
    * @param options.rateLimit - Rate limiting configuration
-   * @param options.rateLimit.enabled - Enable rate limiting (default: true)
+   * @param options.rateLimit.enabled - Enable rate limiting (default: false)
    * @param options.rateLimit.maxRequests - Max requests per window (default: 10)
    * @param options.rateLimit.windowMs - Time window in milliseconds (default: 60000)
    */
   constructor (options?: {
+    cleanupIntervalSeconds?: number
     defaultTtl?: number
+    maxAbsoluteLifetimeMs?: number
     maxEntries?: number
     rateLimit?: { enabled?: boolean; maxRequests?: number; windowMs?: number }
   }) {
     this.defaultTtl = options?.defaultTtl ?? 3600 // 1 hour default
-    this.maxEntries = options?.maxEntries ?? 1000
+    this.maxAbsoluteLifetimeMs =
+      options?.maxAbsoluteLifetimeMs ?? InMemoryAuthCache.DEFAULT_MAX_ABSOLUTE_LIFETIME_MS
+    this.maxEntries = Math.max(1, options?.maxEntries ?? 1000)
     this.rateLimit = {
-      enabled: options?.rateLimit?.enabled ?? true,
+      enabled: options?.rateLimit?.enabled ?? false,
       maxRequests: options?.rateLimit?.maxRequests ?? 10, // 10 requests per window
       windowMs: options?.rateLimit?.windowMs ?? 60000, // 1 minute window
     }
 
+    const cleanupSeconds = options?.cleanupIntervalSeconds ?? 300
+    if (cleanupSeconds > 0) {
+      const intervalMs = cleanupSeconds * 1000
+      this.cleanupInterval = setInterval(() => {
+        this.runCleanup()
+      }, intervalMs)
+      this.cleanupInterval.unref()
+    }
+
     logger.info(
       `InMemoryAuthCache: Initialized with maxEntries=${String(this.maxEntries)}, defaultTtl=${String(this.defaultTtl)}s, rateLimit=${this.rateLimit.enabled ? `${String(this.rateLimit.maxRequests)} req/${String(this.rateLimit.windowMs)}ms` : 'disabled'}`
     )
@@ -114,17 +143,21 @@ export class InMemoryAuthCache implements AuthCache {
 
   /**
    * Clear all cached entries and rate limits
-   * @returns Promise that resolves when cache is cleared
    */
-  public async clear (): Promise<void> {
+  public clear (): void {
     const entriesCleared = this.cache.size
     this.cache.clear()
     this.lruOrder.clear()
     this.rateLimits.clear()
-    this.resetStats()
 
     logger.info(`InMemoryAuthCache: Cleared ${String(entriesCleared)} entries`)
-    return Promise.resolve()
+  }
+
+  public dispose (): void {
+    if (this.cleanupInterval) {
+      clearInterval(this.cleanupInterval)
+      this.cleanupInterval = undefined
+    }
   }
 
   /**
@@ -132,12 +165,14 @@ export class InMemoryAuthCache implements AuthCache {
    * @param identifier - Identifier to look up
    * @returns Cached result or undefined if not found/expired/rate-limited
    */
-  public async get (identifier: string): Promise<AuthorizationResult | undefined> {
+  public get (identifier: string): AuthorizationResult | undefined {
     // Check rate limiting first
     if (!this.checkRateLimit(identifier)) {
       this.stats.rateLimitBlocked++
-      logger.warn(`InMemoryAuthCache: Rate limit exceeded for identifier: ${identifier}`)
-      return Promise.resolve(undefined)
+      logger.warn(
+        `InMemoryAuthCache: Rate limit exceeded for identifier: ${this.truncateId(identifier)}`
+      )
+      return undefined
     }
 
     const entry = this.cache.get(identifier)
@@ -145,33 +180,48 @@ export class InMemoryAuthCache implements AuthCache {
     // Cache miss
     if (!entry) {
       this.stats.misses++
-      return Promise.resolve(undefined)
+      return undefined
     }
 
     // Check expiration
     const now = Date.now()
     if (now >= entry.expiresAt) {
       this.stats.expired++
-      this.stats.misses++
-      this.cache.delete(identifier)
-      this.lruOrder.delete(identifier)
-      logger.debug(`InMemoryAuthCache: Expired entry for identifier: ${identifier}`)
-      return Promise.resolve(undefined)
+      // Transition to EXPIRED status instead of deleting (R10)
+      entry.result = { ...entry.result, status: AuthorizationStatus.EXPIRED }
+      // Apply absolute lifetime cap to expired-transition TTL refresh (default-TTL entries only)
+      if (!entry.hasExplicitTtl) {
+        const absoluteDeadline = entry.createdAt + this.maxAbsoluteLifetimeMs
+        if (absoluteDeadline > now) {
+          entry.expiresAt = Math.min(now + this.defaultTtl * 1000, absoluteDeadline)
+        }
+      }
+      this.lruOrder.set(identifier, now)
+      logger.debug(
+        `InMemoryAuthCache: Expired entry transitioned to EXPIRED for identifier: ${this.truncateId(identifier)}`
+      )
+      return entry.result
     }
 
-    // Cache hit - update LRU order
+    // Cache hit - update LRU order and reset TTL (R16, R5)
     this.stats.hits++
     this.lruOrder.set(identifier, now)
 
-    logger.debug(`InMemoryAuthCache: Cache hit for identifier: ${identifier}`)
-    return Promise.resolve(entry.result)
+    // Reset TTL on access for default-TTL entries only; explicit TTL entries (e.g. CSMS
+    // cacheExpiryDateTime) keep their original expiration per OCPP spec.
+    if (!entry.hasExplicitTtl && entry.createdAt + this.maxAbsoluteLifetimeMs > now) {
+      entry.expiresAt = now + this.defaultTtl * 1000
+    }
+
+    logger.debug(`InMemoryAuthCache: Cache hit for identifier: ${this.truncateId(identifier)}`)
+    return entry.result
   }
 
   /**
    * Get cache statistics including rate limiting stats
    * @returns Cache statistics with rate limiting metrics
    */
-  public async getStats (): Promise<CacheStats & { rateLimit: RateLimitStats }> {
+  public getStats (): CacheStats & { rateLimit: RateLimitStats } {
     const totalAccess = this.stats.hits + this.stats.misses
     const hitRate = totalAccess > 0 ? (this.stats.hits / totalAccess) * 100 : 0
 
@@ -182,7 +232,7 @@ export class InMemoryAuthCache implements AuthCache {
     // Clean expired rate limit entries
     this.cleanupExpiredRateLimits()
 
-    return Promise.resolve({
+    return {
       evictions: this.stats.evictions,
       expiredEntries: this.stats.expired,
       hitRate: Math.round(hitRate * 100) / 100,
@@ -195,22 +245,65 @@ export class InMemoryAuthCache implements AuthCache {
         totalChecks: this.stats.rateLimitChecks,
       },
       totalEntries: this.cache.size,
-    })
+    }
+  }
+
+  public hasCleanupInterval (): boolean {
+    return this.cleanupInterval !== undefined
   }
 
   /**
    * Remove a cached entry
    * @param identifier - Identifier to remove
-   * @returns Promise that resolves when entry is removed
    */
-  public async remove (identifier: string): Promise<void> {
+  public remove (identifier: string): void {
     const deleted = this.cache.delete(identifier)
     this.lruOrder.delete(identifier)
 
     if (deleted) {
-      logger.debug(`InMemoryAuthCache: Removed entry for identifier: ${identifier}`)
+      logger.debug(
+        `InMemoryAuthCache: Removed entry for identifier: ${this.truncateId(identifier)}`
+      )
+    }
+  }
+
+  /**
+   * Reset statistics counters
+   */
+  public resetStats (): void {
+    this.stats = {
+      evictions: 0,
+      expired: 0,
+      hits: 0,
+      misses: 0,
+      rateLimitBlocked: 0,
+      rateLimitChecks: 0,
+      sets: 0,
+    }
+  }
+
+  public runCleanup (): void {
+    const now = Date.now()
+    for (const [key, entry] of this.cache.entries()) {
+      if (now >= entry.expiresAt) {
+        if (entry.result.status === AuthorizationStatus.EXPIRED) {
+          // Already transitioned by get() — delete on second expiration cycle
+          this.cache.delete(key)
+          this.lruOrder.delete(key)
+        } else {
+          // First expiration — transition to EXPIRED status (consistent with get() R10 semantics)
+          entry.result = { ...entry.result, status: AuthorizationStatus.EXPIRED }
+          if (!entry.hasExplicitTtl) {
+            const absoluteDeadline = entry.createdAt + this.maxAbsoluteLifetimeMs
+            if (absoluteDeadline > now) {
+              entry.expiresAt = Math.min(now + this.defaultTtl * 1000, absoluteDeadline)
+            }
+          }
+          this.stats.expired++
+        }
+      }
     }
-    return Promise.resolve()
+    this.cleanupExpiredRateLimits()
   }
 
   /**
@@ -218,14 +311,15 @@ export class InMemoryAuthCache implements AuthCache {
    * @param identifier - Identifier to cache
    * @param result - Authorization result to cache
    * @param ttl - Optional TTL override in seconds
-   * @returns Promise that resolves when entry is cached
    */
-  public async set (identifier: string, result: AuthorizationResult, ttl?: number): Promise<void> {
+  public set (identifier: string, result: AuthorizationResult, ttl?: number): void {
     // Check rate limiting
     if (!this.checkRateLimit(identifier)) {
       this.stats.rateLimitBlocked++
-      logger.warn(`InMemoryAuthCache: Rate limit exceeded, not caching identifier: ${identifier}`)
-      return Promise.resolve()
+      logger.warn(
+        `InMemoryAuthCache: Rate limit exceeded, not caching identifier: ${this.truncateId(identifier)}`
+      )
+      return
     }
 
     // Evict LRU entry if cache is full
@@ -234,16 +328,34 @@ export class InMemoryAuthCache implements AuthCache {
     }
 
     const ttlSeconds = ttl ?? this.defaultTtl
-    const expiresAt = Date.now() + ttlSeconds * 1000
+    const maxTtlSeconds = this.maxAbsoluteLifetimeMs / 1000
+    const clampedTtl = Math.min(Math.max(0, ttlSeconds), maxTtlSeconds)
+    const now = Date.now()
+    const expiresAt = now + clampedTtl * 1000
 
-    this.cache.set(identifier, { expiresAt, result })
-    this.lruOrder.set(identifier, Date.now())
+    this.cache.set(identifier, {
+      createdAt: now,
+      expiresAt,
+      hasExplicitTtl: ttl !== undefined,
+      result,
+    })
+    this.lruOrder.set(identifier, now)
     this.stats.sets++
 
     logger.debug(
-      `InMemoryAuthCache: Cached result for identifier: ${identifier}, ttl=${String(ttlSeconds)}s, entries=${String(this.cache.size)}/${String(this.maxEntries)}`
+      `InMemoryAuthCache: Cached result for identifier: ${this.truncateId(identifier)}, ttl=${String(clampedTtl)}s, entries=${String(this.cache.size)}/${String(this.maxEntries)}`
     )
-    return Promise.resolve()
+  }
+
+  private boundRateLimitsMap (): void {
+    const threshold = this.maxEntries * 2
+    while (this.rateLimits.size > threshold) {
+      const firstKey = this.rateLimits.keys().next().value
+      if (firstKey === undefined) {
+        break
+      }
+      this.rateLimits.delete(firstKey)
+    }
   }
 
   /**
@@ -264,6 +376,7 @@ export class InMemoryAuthCache implements AuthCache {
     // No existing entry - create one
     if (!entry) {
       this.rateLimits.set(identifier, { count: 1, windowStart: now })
+      this.boundRateLimitsMap()
       return true
     }
 
@@ -309,37 +422,50 @@ export class InMemoryAuthCache implements AuthCache {
       return
     }
 
-    // Find entry with oldest access time
-    let oldestIdentifier: string | undefined
-    let oldestTime = Number.POSITIVE_INFINITY
+    // Phase 1 (R2): prefer evicting non-valid (status != ACCEPTED) entries
+    let candidateIdentifier: string | undefined
+    let candidateTime = Number.POSITIVE_INFINITY
 
     for (const [identifier, accessTime] of this.lruOrder.entries()) {
-      if (accessTime < oldestTime) {
-        oldestTime = accessTime
-        oldestIdentifier = identifier
+      const entry = this.cache.get(identifier)
+      if (entry?.result.status !== AuthorizationStatus.ACCEPTED && accessTime < candidateTime) {
+        candidateTime = accessTime
+        candidateIdentifier = identifier
       }
     }
 
-    if (oldestIdentifier) {
-      this.cache.delete(oldestIdentifier)
-      this.lruOrder.delete(oldestIdentifier)
+    // Phase 2: fall back to pure LRU if all entries are valid
+    if (candidateIdentifier == null) {
+      for (const [identifier, accessTime] of this.lruOrder.entries()) {
+        if (accessTime < candidateTime) {
+          candidateTime = accessTime
+          candidateIdentifier = identifier
+        }
+      }
+    }
+
+    if (candidateIdentifier != null) {
+      this.cache.delete(candidateIdentifier)
+      this.lruOrder.delete(candidateIdentifier)
       this.stats.evictions++
-      logger.debug(`InMemoryAuthCache: Evicted LRU entry: ${oldestIdentifier}`)
+      logger.debug(`InMemoryAuthCache: Evicted LRU entry: ${this.truncateId(candidateIdentifier)}`)
     }
   }
 
-  /**
-   * Reset statistics counters
-   */
-  private resetStats (): void {
-    this.stats = {
-      evictions: 0,
-      expired: 0,
-      hits: 0,
-      misses: 0,
-      rateLimitBlocked: 0,
-      rateLimitChecks: 0,
-      sets: 0,
-    }
+  private truncateId (identifier: string, maxLen = 8): string {
+    return truncateId(identifier, maxLen)
+  }
+}
+
+/**
+ * Truncate identifier for safe log output.
+ * @param identifier - Full identifier string
+ * @param maxLen - Maximum length before truncation (default: 8)
+ * @returns Truncated identifier with '...' suffix, or original if within limit
+ */
+export function truncateId (identifier: string, maxLen = 8): string {
+  if (identifier.length <= maxLen) {
+    return identifier
   }
+  return `${identifier.slice(0, maxLen)}...`
 }
index 8c8e89f49880df809595a76c7b42ddbdef7c590b..27128d716a9c240fd6c1b380c6969a9f5a39c667 100644 (file)
@@ -77,11 +77,6 @@ export class AuthComponentFactory {
     return new InMemoryAuthCache({
       defaultTtl: config.authorizationCacheLifetime ?? 3600,
       maxEntries: config.maxCacheEntries ?? 1000,
-      rateLimit: {
-        enabled: true,
-        maxRequests: 10, // 10 requests per minute per identifier
-        windowMs: 60000, // 1 minute window
-      },
     })
   }
 
@@ -110,15 +105,20 @@ export class AuthComponentFactory {
       adapterMap.set(OCPPVersion.VERSION_201, adapters.ocpp20Adapter)
     }
     const strategy = new CertificateAuthStrategy(chargingStation, adapterMap)
-    await strategy.initialize(config)
+    strategy.initialize(config)
     return strategy
   }
 
   /**
    * 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 delegated to service
+   * @returns Always undefined as manager creation is not yet implemented
    */
   static createLocalAuthListManager (
     chargingStation: ChargingStation,
@@ -141,14 +141,18 @@ export class AuthComponentFactory {
     cache: AuthCache | undefined,
     config: AuthConfiguration
   ): Promise<AuthStrategy | undefined> {
-    if (!config.localAuthListEnabled) {
+    if (
+      !config.localAuthListEnabled &&
+      !config.authorizationCacheEnabled &&
+      !config.offlineAuthorizationEnabled
+    ) {
       return undefined
     }
 
     // Use static import - circular dependency is acceptable here
     const { LocalAuthStrategy } = await import('../strategies/LocalAuthStrategy.js')
     const strategy = new LocalAuthStrategy(manager, cache)
-    await strategy.initialize(config)
+    strategy.initialize(config)
     return strategy
   }
 
@@ -159,12 +163,14 @@ export class AuthComponentFactory {
    * @param adapters.ocpp20Adapter - Optional OCPP 2.0.x protocol adapter
    * @param cache - Authorization cache for storing remote auth results
    * @param config - Authentication configuration controlling remote auth behavior
+   * @param localAuthListManager - Optional local auth list manager for R17 cache exclusion
    * @returns Remote strategy instance or undefined if remote auth disabled
    */
   static async createRemoteStrategy (
     adapters: { ocpp16Adapter?: OCPP16AuthAdapter; ocpp20Adapter?: OCPP20AuthAdapter },
     cache: AuthCache | undefined,
-    config: AuthConfiguration
+    config: AuthConfiguration,
+    localAuthListManager?: LocalAuthListManager
   ): Promise<AuthStrategy | undefined> {
     if (!config.remoteAuthorization) {
       return undefined
@@ -180,8 +186,8 @@ export class AuthComponentFactory {
       adapterMap.set(OCPPVersion.VERSION_20, adapters.ocpp20Adapter)
       adapterMap.set(OCPPVersion.VERSION_201, adapters.ocpp20Adapter)
     }
-    const strategy = new RemoteAuthStrategy(adapterMap, cache)
-    await strategy.initialize(config)
+    const strategy = new RemoteAuthStrategy(adapterMap, cache, localAuthListManager)
+    strategy.initialize(config)
     return strategy
   }
 
@@ -212,7 +218,7 @@ export class AuthComponentFactory {
     }
 
     // Add remote strategy if enabled
-    const remoteStrategy = await this.createRemoteStrategy(adapters, cache, config)
+    const remoteStrategy = await this.createRemoteStrategy(adapters, cache, config, manager)
     if (remoteStrategy) {
       strategies.push(remoteStrategy)
     }
index b18cd6a2afee6b560d7135b833ca6e2466ba0809..ffe608f1c25108a2d130ff9cab085237e2c42b8f 100644 (file)
@@ -13,25 +13,25 @@ export interface AuthCache {
   /**
    * Clear all cached entries
    */
-  clear(): Promise<void>
+  clear(): void
 
   /**
    * Get cached authorization result
    * @param identifier - Identifier to look up
    * @returns Cached result or undefined if not found/expired
    */
-  get(identifier: string): Promise<AuthorizationResult | undefined>
+  get(identifier: string): AuthorizationResult | undefined
 
   /**
    * Get cache statistics
    */
-  getStats(): Promise<CacheStats>
+  getStats(): CacheStats
 
   /**
    * Remove a cached entry
    * @param identifier - Identifier to remove
    */
-  remove(identifier: string): Promise<void>
+  remove(identifier: string): void
 
   /**
    * Cache an authorization result
@@ -39,7 +39,7 @@ export interface AuthCache {
    * @param result - Authorization result to cache
    * @param ttl - Optional TTL override in seconds
    */
-  set(identifier: string, result: AuthorizationResult, ttl?: number): Promise<void>
+  set(identifier: string, result: AuthorizationResult, ttl?: number): void
 }
 
 /**
@@ -139,7 +139,7 @@ export interface AuthStrategy {
   /**
    * Cleanup strategy resources
    */
-  cleanup(): Promise<void>
+  cleanup(): void
 
   /**
    * Optionally reconfigure the strategy at runtime
@@ -153,13 +153,13 @@ export interface AuthStrategy {
   /**
    * Get strategy-specific statistics
    */
-  getStats(): Promise<Record<string, unknown>>
+  getStats(): Promise<Record<string, unknown>> | Record<string, unknown>
 
   /**
    * Initialize the strategy with configuration
    * @param config - Authentication configuration
    */
-  initialize(config: AuthConfiguration): Promise<void>
+  initialize(config: AuthConfiguration): Promise<void> | void
 
   /**
    * Strategy name for identification
@@ -361,7 +361,7 @@ export interface OCPPAuthAdapter {
   /**
    * Check if remote authorization is available
    */
-  isRemoteAvailable(): Promise<boolean>
+  isRemoteAvailable(): boolean | Promise<boolean>
 
   /**
    * The OCPP version this adapter handles
@@ -371,7 +371,7 @@ export interface OCPPAuthAdapter {
   /**
    * Validate adapter configuration
    */
-  validateConfiguration(config: AuthConfiguration): Promise<boolean>
+  validateConfiguration(config: AuthConfiguration): boolean
 }
 
 /**
@@ -391,7 +391,7 @@ export interface OCPPAuthService {
   /**
    * Clear all cached authorizations
    */
-  clearCache(): Promise<void>
+  clearCache(): void
 
   /**
    * Get current authentication configuration
@@ -407,7 +407,7 @@ export interface OCPPAuthService {
    * Invalidate cached authorization for an identifier
    * @param identifier - Identifier to invalidate
    */
-  invalidateCache(identifier: UnifiedIdentifier): Promise<void>
+  invalidateCache(identifier: UnifiedIdentifier): void
 
   /**
    * Check if an identifier is locally authorized (cache/local list)
@@ -423,11 +423,11 @@ export interface OCPPAuthService {
   /**
    * Test connectivity to remote authorization service
    */
-  testConnectivity(): Promise<boolean>
+  testConnectivity(): boolean
 
   /**
    * Update authentication configuration
    * @param config - New configuration to apply
    */
-  updateConfiguration(config: Partial<AuthConfiguration>): Promise<void>
+  updateConfiguration(config: Partial<AuthConfiguration>): void
 }
index a7da8444924916b625cc32f307db7583b8e11414..8f3533f2772a950f4663345553ea6a358a0118e0 100644 (file)
@@ -183,7 +183,7 @@ export class OCPPAuthServiceImpl implements OCPPAuthService {
         strategyUsed: 'none',
       },
       isOffline: false,
-      method: AuthenticationMethod.LOCAL_LIST,
+      method: AuthenticationMethod.NONE,
       status: AuthorizationStatus.INVALID,
       timestamp: new Date(),
     }
@@ -267,13 +267,14 @@ export class OCPPAuthServiceImpl implements OCPPAuthService {
   /**
    * Clear all cached authorizations
    */
-  public async clearCache (): Promise<void> {
+  public clearCache (): void {
     logger.debug(`${this.chargingStation.logPrefix()} Clearing all cached authorizations`)
 
     // Clear cache in local strategy
     const localStrategy = this.strategies.get('local') as LocalAuthStrategy | undefined
-    if (localStrategy?.authCache) {
-      await localStrategy.authCache.clear()
+    const localAuthCache = localStrategy?.getAuthCache()
+    if (localAuthCache) {
+      localAuthCache.clear()
       logger.info(`${this.chargingStation.logPrefix()} Authorization cache cleared`)
     } else {
       logger.debug(`${this.chargingStation.logPrefix()} No authorization cache available to clear`)
@@ -412,7 +413,7 @@ export class OCPPAuthServiceImpl implements OCPPAuthService {
    * Invalidate cached authorization for an identifier
    * @param identifier - Unified identifier whose cached authorization should be invalidated
    */
-  public async invalidateCache (identifier: UnifiedIdentifier): Promise<void> {
+  public invalidateCache (identifier: UnifiedIdentifier): void {
     logger.debug(
       `${this.chargingStation.logPrefix()} Invalidating cache for identifier: ${identifier.value}`
     )
@@ -420,7 +421,7 @@ export class OCPPAuthServiceImpl implements OCPPAuthService {
     // Invalidate in local strategy
     const localStrategy = this.strategies.get('local') as LocalAuthStrategy | undefined
     if (localStrategy) {
-      await localStrategy.invalidateCache(identifier.value)
+      localStrategy.invalidateCache(identifier.value)
       logger.info(
         `${this.chargingStation.logPrefix()} Cache invalidated for identifier: ${identifier.value}`
       )
@@ -493,23 +494,22 @@ export class OCPPAuthServiceImpl implements OCPPAuthService {
    * Test connectivity to remote authorization service
    * @returns True if remote authorization service is reachable
    */
-  public testConnectivity (): Promise<boolean> {
+  public testConnectivity (): boolean {
     const remoteStrategy = this.strategies.get('remote')
     if (!remoteStrategy) {
-      return Promise.resolve(false)
+      return false
     }
 
     // For now return true - real implementation would test remote connectivity
-    return Promise.resolve(true)
+    return true
   }
 
   /**
    * Update authentication configuration
    * @param config - Partial configuration object with values to update
-   * @returns Promise that resolves when configuration is updated
    * @throws {OCPPError} If configuration validation fails
    */
-  public updateConfiguration (config: Partial<AuthConfiguration>): Promise<void> {
+  public updateConfiguration (config: Partial<AuthConfiguration>): void {
     // Merge new config with existing
     const newConfig = { ...this.config, ...config }
 
@@ -520,7 +520,6 @@ export class OCPPAuthServiceImpl implements OCPPAuthService {
     this.config = newConfig
 
     logger.info(`${this.chargingStation.logPrefix()} Authentication configuration updated`)
-    return Promise.resolve()
   }
 
   /**
@@ -579,6 +578,7 @@ export class OCPPAuthServiceImpl implements OCPPAuthService {
       localPreAuthorize: false,
       maxCacheEntries: 1000,
       offlineAuthorizationEnabled: true,
+      remoteAuthorization: true,
       unknownIdAuthorization: AuthorizationStatus.INVALID,
     }
   }
@@ -609,12 +609,15 @@ export class OCPPAuthServiceImpl implements OCPPAuthService {
     const ocpp16Adapter = this.adapters.get(OCPPVersion.VERSION_16) as OCPP16AuthAdapter | undefined
     const ocpp20Adapter = this.adapters.get(OCPPVersion.VERSION_20) as OCPP20AuthAdapter | undefined
 
+    // Create auth cache for strategy injection
+    const authCache = AuthComponentFactory.createAuthCache(this.config)
+
     // Create strategies using factory
     const strategies = await AuthComponentFactory.createStrategies(
       this.chargingStation,
       { ocpp16Adapter, ocpp20Adapter },
-      undefined, // manager
-      undefined, // cache
+      undefined, // manager - delegated to OCPPAuthServiceImpl
+      authCache,
       this.config
     )
 
index d9042aa0c6718a383380a7dd2a3520b30ea56870..f207acaef0ad045aa1f937f99cbda44b9d5c774a 100644 (file)
@@ -8,7 +8,7 @@ import type {
 } from '../types/AuthTypes.js'
 
 import { OCPPVersion } from '../../../../types/index.js'
-import { isNotEmptyString } from '../../../../utils/index.js'
+import { isNotEmptyString, sleep } from '../../../../utils/index.js'
 import { logger } from '../../../../utils/Logger.js'
 import { AuthenticationMethod, AuthorizationStatus, IdentifierType } from '../types/AuthTypes.js'
 
@@ -135,32 +135,30 @@ export class CertificateAuthStrategy implements AuthStrategy {
     return hasAdapter && certAuthEnabled && hasCertificateData && this.isInitialized
   }
 
-  cleanup (): Promise<void> {
+  cleanup (): void {
     this.isInitialized = false
     logger.debug(
       `${this.chargingStation.logPrefix()} Certificate authentication strategy cleaned up`
     )
-    return Promise.resolve()
   }
 
-  getStats (): Promise<Record<string, unknown>> {
-    return Promise.resolve({
+  getStats (): Record<string, unknown> {
+    return {
       ...this.stats,
       isInitialized: this.isInitialized,
-    })
+    }
   }
 
-  initialize (config: AuthConfiguration): Promise<void> {
+  initialize (config: AuthConfiguration): void {
     if (!config.certificateAuthEnabled) {
       logger.info(`${this.chargingStation.logPrefix()} Certificate authentication disabled`)
-      return Promise.resolve()
+      return
     }
 
     logger.info(
       `${this.chargingStation.logPrefix()} Certificate authentication strategy initialized`
     )
     this.isInitialized = true
-    return Promise.resolve()
   }
 
   /**
@@ -243,7 +241,7 @@ export class CertificateAuthStrategy implements AuthStrategy {
     config: AuthConfiguration
   ): Promise<boolean> {
     // Simulate validation delay
-    await new Promise(resolve => setTimeout(resolve, 100))
+    await sleep(100)
 
     // In a real implementation, this would:
     // 1. Load trusted CA certificates from configuration
index 1dc5da7f86212a8c10c0f0208ebb9a0bb4fe300f..200199b83114bc41b6d14c6c3ff1e6e5fc7f5c9c 100644 (file)
@@ -26,10 +26,10 @@ import {
  * and offline capability when remote services are unavailable.
  */
 export class LocalAuthStrategy implements AuthStrategy {
-  public authCache?: AuthCache
   public readonly name = 'LocalAuthStrategy'
-
   public readonly priority = 1 // High priority - try local first
+
+  private authCache?: AuthCache
   private isInitialized = false
   private localAuthListManager?: LocalAuthListManager
   private stats = {
@@ -83,7 +83,7 @@ export class LocalAuthStrategy implements AuthStrategy {
 
       // 2. Try authorization cache
       if (config.authorizationCacheEnabled && this.authCache) {
-        const cacheResult = await this.checkAuthCache(request, config)
+        const cacheResult = this.checkAuthCache(request, config)
         if (cacheResult) {
           logger.debug(`LocalAuthStrategy: Found in cache: ${cacheResult.status}`)
           this.stats.cacheHits++
@@ -93,7 +93,7 @@ export class LocalAuthStrategy implements AuthStrategy {
 
       // 3. Apply offline fallback behavior
       if (config.offlineAuthorizationEnabled && request.allowOffline) {
-        const offlineResult = await this.handleOfflineFallback(request, config)
+        const offlineResult = this.handleOfflineFallback(request, config)
         if (offlineResult) {
           logger.debug(`LocalAuthStrategy: Offline fallback: ${offlineResult.status}`)
           this.stats.offlineDecisions++
@@ -128,17 +128,13 @@ export class LocalAuthStrategy implements AuthStrategy {
    * @param result - Authorization result to store in cache
    * @param ttl - Optional time-to-live in seconds for cache entry
    */
-  public async cacheResult (
-    identifier: string,
-    result: AuthorizationResult,
-    ttl?: number
-  ): Promise<void> {
+  public cacheResult (identifier: string, result: AuthorizationResult, ttl?: number): void {
     if (!this.authCache) {
       return
     }
 
     try {
-      await this.authCache.set(identifier, result, ttl)
+      this.authCache.set(identifier, result, ttl)
       logger.debug(`LocalAuthStrategy: Cached result for ${identifier}`)
     } catch (error) {
       const errorMessage = error instanceof Error ? error.message : String(error)
@@ -164,9 +160,8 @@ export class LocalAuthStrategy implements AuthStrategy {
 
   /**
    * Cleanup strategy resources
-   * @returns Promise that resolves when cleanup is complete
    */
-  public cleanup (): Promise<void> {
+  public cleanup (): void {
     logger.info('LocalAuthStrategy: Cleaning up...')
 
     // Reset internal state
@@ -180,15 +175,22 @@ export class LocalAuthStrategy implements AuthStrategy {
     }
 
     logger.info('LocalAuthStrategy: Cleanup completed')
-    return Promise.resolve()
+  }
+
+  /**
+   * Get the authorization cache
+   * @returns The authorization cache or undefined if not available
+   */
+  public getAuthCache (): AuthCache | undefined {
+    return this.authCache
   }
 
   /**
    * Get strategy statistics
    * @returns Strategy statistics including hit rates, request counts, and cache status
    */
-  public async getStats (): Promise<Record<string, unknown>> {
-    const cacheStats = this.authCache ? await this.authCache.getStats() : null
+  public getStats (): Record<string, unknown> {
+    const cacheStats = this.authCache ? this.authCache.getStats() : null
 
     return {
       ...this.stats,
@@ -212,9 +214,8 @@ export class LocalAuthStrategy implements AuthStrategy {
   /**
    * Initialize strategy with configuration and dependencies
    * @param config - Authentication configuration for strategy setup
-   * @returns Promise that resolves when initialization completes
    */
-  public initialize (config: AuthConfiguration): Promise<void> {
+  public initialize (config: AuthConfiguration): void {
     try {
       logger.info('LocalAuthStrategy: Initializing...')
 
@@ -237,16 +238,13 @@ export class LocalAuthStrategy implements AuthStrategy {
 
       this.isInitialized = true
       logger.info('LocalAuthStrategy: Initialized successfully')
-      return Promise.resolve()
     } catch (error) {
       const errorMessage = error instanceof Error ? error.message : String(error)
       logger.error(`LocalAuthStrategy: Initialization failed: ${errorMessage}`)
-      return Promise.reject(
-        new AuthenticationError(
-          `Local auth strategy initialization failed: ${errorMessage}`,
-          AuthErrorCode.CONFIGURATION_ERROR,
-          { cause: error instanceof Error ? error : new Error(String(error)) }
-        )
+      throw new AuthenticationError(
+        `Local auth strategy initialization failed: ${errorMessage}`,
+        AuthErrorCode.CONFIGURATION_ERROR,
+        { cause: error instanceof Error ? error : new Error(String(error)) }
       )
     }
   }
@@ -255,13 +253,13 @@ export class LocalAuthStrategy implements AuthStrategy {
    * Invalidate cached result for identifier
    * @param identifier - Unique identifier string to remove from cache
    */
-  public async invalidateCache (identifier: string): Promise<void> {
+  public invalidateCache (identifier: string): void {
     if (!this.authCache) {
       return
     }
 
     try {
-      await this.authCache.remove(identifier)
+      this.authCache.remove(identifier)
       logger.debug(`LocalAuthStrategy: Invalidated cache for ${identifier}`)
     } catch (error) {
       const errorMessage = error instanceof Error ? error.message : String(error)
@@ -312,31 +310,20 @@ export class LocalAuthStrategy implements AuthStrategy {
    * @param config - Authentication configuration (unused but required by interface)
    * @returns Cached authorization result if found and not expired; undefined otherwise
    */
-  private async checkAuthCache (
+  private checkAuthCache (
     request: AuthRequest,
     config: AuthConfiguration
-  ): Promise<AuthorizationResult | undefined> {
+  ): AuthorizationResult | undefined {
     if (!this.authCache) {
       return undefined
     }
 
     try {
-      const cachedResult = await this.authCache.get(request.identifier.value)
+      const cachedResult = this.authCache.get(request.identifier.value)
       if (!cachedResult) {
         return undefined
       }
 
-      // Check if cached result is still valid based on timestamp and TTL
-      if (cachedResult.cacheTtl) {
-        const expiry = new Date(cachedResult.timestamp.getTime() + cachedResult.cacheTtl * 1000)
-        if (expiry < new Date()) {
-          logger.debug(`LocalAuthStrategy: Cached entry ${request.identifier.value} expired`)
-          // Remove expired entry
-          await this.authCache.remove(request.identifier.value)
-          return undefined
-        }
-      }
-
       logger.debug(`LocalAuthStrategy: Cache hit for ${request.identifier.value}`)
       return cachedResult
     } catch (error) {
@@ -446,41 +433,41 @@ export class LocalAuthStrategy implements AuthStrategy {
   private handleOfflineFallback (
     request: AuthRequest,
     config: AuthConfiguration
-  ): Promise<AuthorizationResult | undefined> {
+  ): AuthorizationResult | undefined {
     logger.debug(`LocalAuthStrategy: Applying offline fallback for ${request.identifier.value}`)
 
     // For transaction stops, always allow (safety requirement)
     if (request.context === AuthContext.TRANSACTION_STOP) {
-      return Promise.resolve({
+      return {
         additionalInfo: { reason: 'Transaction stop - offline mode' },
         isOffline: true,
         method: AuthenticationMethod.OFFLINE_FALLBACK,
         status: AuthorizationStatus.ACCEPTED,
         timestamp: new Date(),
-      })
+      }
     }
 
     // For unknown IDs, check configuration
     if (config.allowOfflineTxForUnknownId) {
       const status = config.unknownIdAuthorization ?? AuthorizationStatus.ACCEPTED
 
-      return Promise.resolve({
+      return {
         additionalInfo: { reason: 'Unknown ID allowed in offline mode' },
         isOffline: true,
         method: AuthenticationMethod.OFFLINE_FALLBACK,
         status,
         timestamp: new Date(),
-      })
+      }
     }
 
     // Default offline behavior - reject unknown identifiers
-    return Promise.resolve({
+    return {
       additionalInfo: { reason: 'Unknown ID not allowed in offline mode' },
       isOffline: true,
       method: AuthenticationMethod.OFFLINE_FALLBACK,
       status: AuthorizationStatus.INVALID,
       timestamp: new Date(),
-    })
+    }
   }
 
   /**
index 36637632aeb67e83fcb982fd773378b1c02fecac..7a31eaa4dd773ff4ff305df6cbb5c97baa2691b6 100644 (file)
@@ -1,4 +1,9 @@
-import type { AuthCache, AuthStrategy, OCPPAuthAdapter } from '../interfaces/OCPPAuthService.js'
+import type {
+  AuthCache,
+  AuthStrategy,
+  LocalAuthListManager,
+  OCPPAuthAdapter,
+} from '../interfaces/OCPPAuthService.js'
 import type { AuthConfiguration, AuthorizationResult, AuthRequest } from '../types/AuthTypes.js'
 
 import { OCPPVersion } from '../../../../types/ocpp/OCPPVersion.js'
@@ -7,7 +12,7 @@ import {
   AuthenticationError,
   AuthenticationMethod,
   AuthErrorCode,
-  AuthorizationStatus,
+  IdentifierType,
 } from '../types/AuthTypes.js'
 
 /**
@@ -29,6 +34,7 @@ export class RemoteAuthStrategy implements AuthStrategy {
   private adapters = new Map<OCPPVersion, OCPPAuthAdapter>()
   private authCache?: AuthCache
   private isInitialized = false
+  private localAuthListManager?: LocalAuthListManager
   private stats = {
     avgResponseTimeMs: 0,
     failedRemoteAuth: 0,
@@ -40,11 +46,16 @@ export class RemoteAuthStrategy implements AuthStrategy {
     totalResponseTimeMs: 0,
   }
 
-  constructor (adapters?: Map<OCPPVersion, OCPPAuthAdapter>, authCache?: AuthCache) {
+  constructor (
+    adapters?: Map<OCPPVersion, OCPPAuthAdapter>,
+    authCache?: AuthCache,
+    localAuthListManager?: LocalAuthListManager
+  ) {
     if (adapters) {
       this.adapters = adapters
     }
     this.authCache = authCache
+    this.localAuthListManager = localAuthListManager
   }
 
   /**
@@ -80,7 +91,7 @@ export class RemoteAuthStrategy implements AuthStrategy {
 
     try {
       logger.debug(
-        `RemoteAuthStrategy: Authenticating ${request.identifier.value} via CSMS for ${request.context}`
+        `RemoteAuthStrategy: Authenticating ${request.identifier.value.substring(0, 8)}... via CSMS for ${request.context}`
       )
 
       // Get appropriate adapter for OCPP version
@@ -106,12 +117,29 @@ export class RemoteAuthStrategy implements AuthStrategy {
         logger.debug(`RemoteAuthStrategy: Remote authorization: ${result.status}`)
         this.stats.successfulRemoteAuth++
 
-        // Cache successful results for performance
-        if (this.authCache && result.status === AuthorizationStatus.ACCEPTED) {
-          await this.cacheResult(
+        // 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.
+        if (this.authCache && config.localAuthListEnabled && this.localAuthListManager) {
+          const isInLocalList = await this.localAuthListManager.getEntry(request.identifier.value)
+          if (isInLocalList) {
+            logger.debug(
+              `RemoteAuthStrategy: Skipping cache for local list identifier: ${request.identifier.value.substring(0, 8)}...`
+            )
+          } else {
+            this.cacheResult(
+              request.identifier.value,
+              result,
+              config.authorizationCacheLifetime,
+              request.identifier.type
+            )
+          }
+        } else if (this.authCache) {
+          this.cacheResult(
             request.identifier.value,
             result,
-            config.authorizationCacheLifetime
+            config.authorizationCacheLifetime,
+            request.identifier.type
           )
         }
 
@@ -119,7 +147,7 @@ export class RemoteAuthStrategy implements AuthStrategy {
       }
 
       logger.debug(
-        `RemoteAuthStrategy: No remote authorization result for ${request.identifier.value}`
+        `RemoteAuthStrategy: No remote authorization result for ${request.identifier.value.substring(0, 8)}...`
       )
       return undefined
     } catch (error) {
@@ -155,17 +183,16 @@ export class RemoteAuthStrategy implements AuthStrategy {
     // Can handle if we have an adapter for the identifier's OCPP version
     const hasAdapter = this.adapters.has(request.identifier.ocppVersion)
 
-    // Remote authorization must be enabled (not using local-only mode)
-    const remoteEnabled = !config.localPreAuthorize
+    // Remote authorization must be enabled via configuration
+    const remoteEnabled = config.remoteAuthorization !== false
 
     return hasAdapter && remoteEnabled
   }
 
   /**
    * Cleanup strategy resources
-   * @returns Promise that resolves when cleanup is complete
    */
-  public cleanup (): Promise<void> {
+  public cleanup (): void {
     logger.info('RemoteAuthStrategy: Cleaning up...')
 
     // Reset internal state
@@ -182,7 +209,6 @@ export class RemoteAuthStrategy implements AuthStrategy {
     }
 
     logger.info('RemoteAuthStrategy: Cleanup completed')
-    return Promise.resolve()
   }
 
   /**
@@ -190,7 +216,7 @@ export class RemoteAuthStrategy implements AuthStrategy {
    * @returns Strategy statistics including success rates, response times, and error counts
    */
   public async getStats (): Promise<Record<string, unknown>> {
-    const cacheStats = this.authCache ? await this.authCache.getStats() : null
+    const cacheStats = this.authCache ? this.authCache.getStats() : null
     const adapterStats = new Map<string, unknown>()
 
     // Collect adapter availability status
@@ -229,7 +255,7 @@ export class RemoteAuthStrategy implements AuthStrategy {
    * Initialize strategy with configuration and adapters
    * @param config - Authentication configuration for adapter validation
    */
-  public async initialize (config: AuthConfiguration): Promise<void> {
+  public initialize (config: AuthConfiguration): void {
     try {
       logger.info('RemoteAuthStrategy: Initializing...')
 
@@ -241,7 +267,7 @@ export class RemoteAuthStrategy implements AuthStrategy {
       // Validate adapter configurations
       for (const [version, adapter] of this.adapters) {
         try {
-          const isValid = await adapter.validateConfiguration(config)
+          const isValid = adapter.validateConfiguration(config)
           if (!isValid) {
             logger.warn(`RemoteAuthStrategy: Invalid configuration for OCPP ${version}`)
           } else {
@@ -293,6 +319,14 @@ export class RemoteAuthStrategy implements AuthStrategy {
     this.authCache = cache
   }
 
+  /**
+   * Set local auth list manager (for dependency injection)
+   * @param manager - LocalAuthListManager instance for checking if identifier is in local list
+   */
+  public setLocalAuthListManager (manager: LocalAuthListManager): void {
+    this.localAuthListManager = manager
+  }
+
   /**
    * Test connectivity to remote authorization service
    * @returns True if at least one OCPP adapter can reach its remote service
@@ -322,22 +356,34 @@ export class RemoteAuthStrategy implements AuthStrategy {
    * @param identifier - Unique identifier string to use as cache key
    * @param result - Authorization result to store in cache
    * @param ttl - Optional time-to-live in seconds for cache entry
+   * @param identifierType - Identifier type to filter non-cacheable types (C02.FR.03, C03.FR.02)
    */
-  private async cacheResult (
+  private cacheResult (
     identifier: string,
     result: AuthorizationResult,
-    ttl?: number
-  ): Promise<void> {
+    ttl?: number,
+    identifierType?: IdentifierType
+  ): void {
     if (!this.authCache) {
       return
     }
 
+    // Per OCPP 2.0.1 C02.FR.03/C03.FR.02: NoAuthorization and Central tokens
+    // should not be cached as they represent system-level auth bypasses
+    if (
+      identifierType === IdentifierType.NO_AUTHORIZATION ||
+      identifierType === IdentifierType.CENTRAL
+    ) {
+      logger.debug(`RemoteAuthStrategy: Skipping cache for ${identifierType} identifier type`)
+      return
+    }
+
     try {
       // Use provided TTL or default cache lifetime
       const cacheTtl = ttl ?? result.cacheTtl ?? 300 // Default 5 minutes
-      await this.authCache.set(identifier, result, cacheTtl)
+      this.authCache.set(identifier, result, cacheTtl)
       logger.debug(
-        `RemoteAuthStrategy: Cached result for ${identifier} (TTL: ${String(cacheTtl)}s)`
+        `RemoteAuthStrategy: Cached result for ${identifier.substring(0, 8)}... (TTL: ${String(cacheTtl)}s)`
       )
     } catch (error) {
       const errorMessage = error instanceof Error ? error.message : String(error)
@@ -357,19 +403,19 @@ export class RemoteAuthStrategy implements AuthStrategy {
     config: AuthConfiguration
   ): Promise<boolean> {
     try {
-      // Use adapter's built-in availability check with timeout
-      const timeout = (config.authorizationTimeout * 1000) / 2 // Use half timeout for availability check
-      const availabilityPromise = adapter.isRemoteAvailable()
+      const timeout = (config.authorizationTimeout * 1000) / 2
+      let timeoutHandle: ReturnType<typeof setTimeout> | undefined
 
       const result = await Promise.race([
-        availabilityPromise,
+        Promise.resolve(adapter.isRemoteAvailable()),
         new Promise<boolean>((_resolve, reject) => {
-          setTimeout(() => {
+          timeoutHandle = setTimeout(() => {
             reject(new AuthenticationError('Availability check timeout', AuthErrorCode.TIMEOUT))
           }, timeout)
         }),
       ])
 
+      clearTimeout(timeoutHandle)
       return result
     } catch (error) {
       const errorMessage = error instanceof Error ? error.message : String(error)
@@ -414,6 +460,7 @@ export class RemoteAuthStrategy implements AuthStrategy {
     startTime: number
   ): Promise<AuthorizationResult | undefined> {
     const timeout = config.authorizationTimeout * 1000
+    let timeoutHandle: ReturnType<typeof setTimeout> | undefined
 
     try {
       // Create the authorization promise
@@ -427,7 +474,7 @@ export class RemoteAuthStrategy implements AuthStrategy {
       const result = await Promise.race([
         authPromise,
         new Promise<never>((_resolve, reject) => {
-          setTimeout(() => {
+          timeoutHandle = setTimeout(() => {
             reject(
               new AuthenticationError(
                 `Remote authorization timeout after ${String(config.authorizationTimeout)}s`,
@@ -442,11 +489,13 @@ export class RemoteAuthStrategy implements AuthStrategy {
         }),
       ])
 
+      clearTimeout(timeoutHandle)
       logger.debug(
         `RemoteAuthStrategy: Remote authorization completed in ${String(Date.now() - startTime)}ms`
       )
       return result
     } catch (error) {
+      clearTimeout(timeoutHandle)
       if (error instanceof AuthenticationError) {
         throw error // Re-throw authentication errors as-is
       }
index 1df07a92ffea103a8e7cfe0953edb00ff1544b07..ed7bc64fa809ab9bdd1a757871529ca4c64d1867 100644 (file)
@@ -26,6 +26,7 @@ export enum AuthenticationMethod {
   CACHE = 'Cache',
   CERTIFICATE_BASED = 'CertificateBased',
   LOCAL_LIST = 'LocalList',
+  NONE = 'None',
   OFFLINE_FALLBACK = 'OfflineFallback',
   REMOTE_AUTHORIZATION = 'RemoteAuthorization',
 }
index d04e4c4d9deed8a1bd5d35420936eb68c41a6bb4..c83d28877cda5b2acb8e4e7c50765886aa484811 100644 (file)
@@ -3,255 +3,215 @@ import type { AuthorizationResult, AuthRequest, UnifiedIdentifier } from '../typ
 import { AuthContext, AuthenticationMethod, AuthorizationStatus } from '../types/AuthTypes.js'
 
 /**
- * Authentication helper functions
- *
- * Provides utility functions for common authentication operations
- * such as creating requests, merging results, and formatting errors.
+ * Compute remaining TTL in seconds from an expiry date.
+ * @param expiryDate - Expiry timestamp to compute TTL from
+ * @returns TTL in seconds, or undefined if already expired or no date provided
  */
-// eslint-disable-next-line @typescript-eslint/no-extraneous-class
-export class AuthHelpers {
-  /**
-   * Calculate TTL from expiry date
-   * @param expiryDate - The expiry date
-   * @returns TTL in seconds, or undefined if no valid expiry
-   */
-  static calculateTTL (expiryDate?: Date): number | undefined {
-    if (!expiryDate) {
-      return undefined
-    }
-
-    const now = new Date()
-    const ttlMs = expiryDate.getTime() - now.getTime()
+function calculateTTL (expiryDate?: Date): number | undefined {
+  if (!expiryDate) {
+    return undefined
+  }
 
-    // Return undefined if already expired or invalid
-    if (ttlMs <= 0) {
-      return undefined
-    }
+  const now = new Date()
+  const ttlMs = expiryDate.getTime() - now.getTime()
 
-    // Convert to seconds and round down
-    return Math.floor(ttlMs / 1000)
+  if (ttlMs <= 0) {
+    return undefined
   }
 
-  /**
-   * Create a standard authentication request
-   * @param identifier - The unified identifier to authenticate
-   * @param context - The authentication context
-   * @param connectorId - Optional connector ID
-   * @param metadata - Optional additional metadata
-   * @returns A properly formatted AuthRequest
-   */
-  static createAuthRequest (
-    identifier: UnifiedIdentifier,
-    context: AuthContext,
-    connectorId?: number,
-    metadata?: Record<string, unknown>
-  ): AuthRequest {
-    return {
-      allowOffline: true, // Default to allowing offline if remote fails
-      connectorId,
-      context,
-      identifier,
-      metadata,
-      timestamp: new Date(),
-    }
-  }
+  return Math.floor(ttlMs / 1000)
+}
 
-  /**
-   * Create a rejected authorization result
-   * @param status - The rejection status
-   * @param method - The authentication method that rejected
-   * @param reason - Optional reason for rejection
-   * @returns A rejected AuthorizationResult
-   */
-  static createRejectedResult (
-    status: AuthorizationStatus,
-    method: AuthenticationMethod,
-    reason?: string
-  ): AuthorizationResult {
-    return {
-      additionalInfo: reason ? { reason } : undefined,
-      isOffline: false,
-      method,
-      status,
-      timestamp: new Date(),
-    }
+/**
+ * Build an AuthRequest with sensible defaults.
+ * @param identifier - Unified identifier for the request
+ * @param context - Authentication context
+ * @param connectorId - Optional connector ID
+ * @param metadata - Optional additional metadata
+ * @returns Fully populated AuthRequest
+ */
+function createAuthRequest (
+  identifier: UnifiedIdentifier,
+  context: AuthContext,
+  connectorId?: number,
+  metadata?: Record<string, unknown>
+): AuthRequest {
+  return {
+    allowOffline: true,
+    connectorId,
+    context,
+    identifier,
+    metadata,
+    timestamp: new Date(),
   }
+}
 
-  /**
-   * Format authentication error message
-   * @param error - The error to format
-   * @param identifier - The identifier that failed authentication
-   * @returns A user-friendly error message
-   */
-  static formatAuthError (error: Error, identifier: UnifiedIdentifier): string {
-    const identifierValue = identifier.value.substring(0, 8) + '...'
-    return `Authentication failed for identifier ${identifierValue} (${identifier.type}): ${error.message}`
+/**
+ * Build a rejected AuthorizationResult.
+ * @param status - Authorization status to assign
+ * @param method - Authentication method that produced the result
+ * @param reason - Optional human-readable rejection reason
+ * @returns AuthorizationResult with isOffline=false
+ */
+function createRejectedResult (
+  status: AuthorizationStatus,
+  method: AuthenticationMethod,
+  reason?: string
+): AuthorizationResult {
+  return {
+    additionalInfo: reason ? { reason } : undefined,
+    isOffline: false,
+    method,
+    status,
+    timestamp: new Date(),
   }
+}
 
-  /**
-   * Get user-friendly status message
-   * @param status - The authorization status
-   * @returns A human-readable status message
-   */
-  static getStatusMessage (status: AuthorizationStatus): string {
-    switch (status) {
-      case AuthorizationStatus.ACCEPTED:
-        return 'Authorization accepted'
-      case AuthorizationStatus.BLOCKED:
-        return 'Identifier is blocked'
-      case AuthorizationStatus.CONCURRENT_TX:
-        return 'Concurrent transaction in progress'
-      case AuthorizationStatus.EXPIRED:
-        return 'Authorization has expired'
-      case AuthorizationStatus.INVALID:
-        return 'Invalid identifier'
-      case AuthorizationStatus.NOT_AT_THIS_LOCATION:
-        return 'Not authorized at this location'
-      case AuthorizationStatus.NOT_AT_THIS_TIME:
-        return 'Not authorized at this time'
-      case AuthorizationStatus.PENDING:
-        return 'Authorization pending'
-      case AuthorizationStatus.UNKNOWN:
-        return 'Unknown authorization status'
-      default:
-        return 'Authorization failed'
-    }
+/**
+ * Format an authentication error for logging.
+ * @param error - Error that occurred during authentication
+ * @param identifier - Identifier involved in the failed auth attempt
+ * @returns Formatted error string with truncated identifier
+ */
+function formatAuthError (error: Error, identifier: UnifiedIdentifier): string {
+  const identifierValue = identifier.value.substring(0, 8) + '...'
+  return `Authentication failed for identifier ${identifierValue} (${identifier.type}): ${error.message}`
+}
+
+/**
+ * Map an authorization status to a human-readable message.
+ * @param status - Authorization status to describe
+ * @returns Descriptive message for the status
+ */
+function getStatusMessage (status: AuthorizationStatus): string {
+  switch (status) {
+    case AuthorizationStatus.ACCEPTED:
+      return 'Authorization accepted'
+    case AuthorizationStatus.BLOCKED:
+      return 'Identifier is blocked'
+    case AuthorizationStatus.CONCURRENT_TX:
+      return 'Concurrent transaction in progress'
+    case AuthorizationStatus.EXPIRED:
+      return 'Authorization has expired'
+    case AuthorizationStatus.INVALID:
+      return 'Invalid identifier'
+    case AuthorizationStatus.NOT_AT_THIS_LOCATION:
+      return 'Not authorized at this location'
+    case AuthorizationStatus.NOT_AT_THIS_TIME:
+      return 'Not authorized at this time'
+    case AuthorizationStatus.PENDING:
+      return 'Authorization pending'
+    case AuthorizationStatus.UNKNOWN:
+      return 'Unknown authorization status'
+    default:
+      return 'Authorization failed'
   }
+}
 
-  /**
-   * Check if an authorization result is cacheable
-   *
-   * Only Accepted results with reasonable expiry dates should be cached.
-   * @param result - The authorization result to check
-   * @returns True if the result should be cached, false otherwise
-   */
-  static isCacheable (result: AuthorizationResult): boolean {
-    if (result.status !== AuthorizationStatus.ACCEPTED) {
-      return false
-    }
+/**
+ * Check whether an authorization result represents a permanent failure.
+ * @param result - Authorization result to evaluate
+ * @returns True if BLOCKED, EXPIRED, or INVALID
+ */
+function isPermanentFailure (result: AuthorizationResult): boolean {
+  return [
+    AuthorizationStatus.BLOCKED,
+    AuthorizationStatus.EXPIRED,
+    AuthorizationStatus.INVALID,
+  ].includes(result.status)
+}
 
-    // Don't cache if no expiry date or already expired
-    if (!result.expiryDate) {
-      return false
-    }
+/**
+ * Check whether an authorization result is still valid (ACCEPTED and not expired).
+ * @param result - Authorization result to evaluate
+ * @returns True if ACCEPTED and expiry date has not passed
+ */
+function isResultValid (result: AuthorizationResult): boolean {
+  if (result.status !== AuthorizationStatus.ACCEPTED) {
+    return false
+  }
 
-    const now = new Date()
-    if (result.expiryDate <= now) {
-      return false
-    }
+  if (!result.expiryDate) {
+    return true
+  }
 
-    // Don't cache if expiry is too far in the future (> 1 year)
-    const oneYearFromNow = new Date(now.getTime() + 365 * 24 * 60 * 60 * 1000)
-    if (result.expiryDate > oneYearFromNow) {
-      return false
-    }
+  const now = new Date()
+  return result.expiryDate > now
+}
 
+/**
+ * Check whether an authorization result represents a temporary failure.
+ * @param result - Authorization result to evaluate
+ * @returns True if PENDING or UNKNOWN
+ */
+function isTemporaryFailure (result: AuthorizationResult): boolean {
+  if (result.status === AuthorizationStatus.PENDING) {
     return true
   }
 
-  /**
-   * Check if result indicates a permanent failure (should not retry)
-   * @param result - The authorization result to check
-   * @returns True if this is a permanent failure
-   */
-  static isPermanentFailure (result: AuthorizationResult): boolean {
-    return [
-      AuthorizationStatus.BLOCKED,
-      AuthorizationStatus.EXPIRED,
-      AuthorizationStatus.INVALID,
-    ].includes(result.status)
+  if (result.status === AuthorizationStatus.UNKNOWN) {
+    return true
   }
 
-  /**
-   * Check if authorization result is still valid (not expired)
-   * @param result - The authorization result to check
-   * @returns True if valid, false if expired or invalid
-   */
-  static isResultValid (result: AuthorizationResult): boolean {
-    if (result.status !== AuthorizationStatus.ACCEPTED) {
-      return false
-    }
-
-    // If no expiry date, consider valid
-    if (!result.expiryDate) {
-      return true
-    }
+  return false
+}
 
-    // Check if not expired
-    const now = new Date()
-    return result.expiryDate > now
+/**
+ * Merge multiple authorization results, preferring ACCEPTED.
+ * @param results - Array of results to merge
+ * @returns The first ACCEPTED result, or the first result with merged metadata
+ */
+function mergeAuthResults (results: AuthorizationResult[]): AuthorizationResult | undefined {
+  if (results.length === 0) {
+    return undefined
   }
 
-  /**
-   * Check if result indicates a temporary failure (should retry)
-   * @param result - The authorization result to check
-   * @returns True if this is a temporary failure that could be retried
-   */
-  static isTemporaryFailure (result: AuthorizationResult): boolean {
-    // Pending status indicates temporary state
-    if (result.status === AuthorizationStatus.PENDING) {
-      return true
-    }
-
-    // Unknown status might be temporary
-    if (result.status === AuthorizationStatus.UNKNOWN) {
-      return true
-    }
-
-    return false
+  const acceptedResult = results.find(r => r.status === AuthorizationStatus.ACCEPTED)
+  if (acceptedResult) {
+    return acceptedResult
   }
 
-  /**
-   * Merge multiple authorization results (for fallback chains)
-   *
-   * Takes the first Accepted result, or merges error information
-   * if all results are rejections.
-   * @param results - Array of authorization results to merge
-   * @returns The merged authorization result
-   */
-  static mergeAuthResults (results: AuthorizationResult[]): AuthorizationResult | undefined {
-    if (results.length === 0) {
-      return undefined
-    }
-
-    // Return first Accepted result
-    const acceptedResult = results.find(r => r.status === AuthorizationStatus.ACCEPTED)
-    if (acceptedResult) {
-      return acceptedResult
-    }
-
-    // If no accepted results, merge information from all attempts
-    const firstResult = results[0]
-    const allMethods = results.map(r => r.method).join(', ')
-
-    return {
-      additionalInfo: {
-        attemptedMethods: allMethods,
-        totalAttempts: results.length,
-      },
-      isOffline: results.some(r => r.isOffline),
-      method: firstResult.method,
-      status: firstResult.status,
-      timestamp: firstResult.timestamp,
-    }
+  const firstResult = results[0]
+  const allMethods = results.map(r => r.method).join(', ')
+
+  return {
+    additionalInfo: {
+      attemptedMethods: allMethods,
+      totalAttempts: results.length,
+    },
+    isOffline: results.some(r => r.isOffline),
+    method: firstResult.method,
+    status: firstResult.status,
+    timestamp: firstResult.timestamp,
   }
+}
 
-  /**
-   * Sanitize authorization result for logging
-   *
-   * Removes sensitive information before logging
-   * @param result - The authorization result to sanitize
-   * @returns Sanitized result safe for logging
-   */
-  static sanitizeForLogging (result: AuthorizationResult): Record<string, unknown> {
-    return {
-      hasExpiryDate: !!result.expiryDate,
-      hasGroupId: !!result.groupId,
-      hasPersonalMessage: !!result.personalMessage,
-      isOffline: result.isOffline,
-      method: result.method,
-      status: result.status,
-      timestamp: result.timestamp.toISOString(),
-    }
+/**
+ * Strip sensitive data from an authorization result for safe logging.
+ * @param result - Authorization result to sanitize
+ * @returns Object with only safe-to-log fields
+ */
+function sanitizeForLogging (result: AuthorizationResult): Record<string, unknown> {
+  return {
+    hasExpiryDate: !!result.expiryDate,
+    hasGroupId: !!result.groupId,
+    hasPersonalMessage: !!result.personalMessage,
+    isOffline: result.isOffline,
+    method: result.method,
+    status: result.status,
+    timestamp: result.timestamp.toISOString(),
   }
 }
+
+export const AuthHelpers = {
+  calculateTTL,
+  createAuthRequest,
+  createRejectedResult,
+  formatAuthError,
+  getStatusMessage,
+  isPermanentFailure,
+  isResultValid,
+  isTemporaryFailure,
+  mergeAuthResults,
+  sanitizeForLogging,
+}
index 6f0a1aef094fad9ee950b23d77c97024971dae73..a8e18f10a3ea52d9b318c295c2d3afc55517c304 100644 (file)
@@ -1,6 +1,6 @@
 import type { AuthConfiguration, UnifiedIdentifier } from '../types/AuthTypes.js'
 
-import { AuthenticationMethod, IdentifierType } from '../types/AuthTypes.js'
+import { IdentifierType } from '../types/AuthTypes.js'
 
 /**
  * Authentication validation utilities
@@ -8,189 +8,193 @@ import { AuthenticationMethod, IdentifierType } from '../types/AuthTypes.js'
  * Provides validation functions for authentication-related data structures
  * ensuring data integrity and OCPP protocol compliance.
  */
-// eslint-disable-next-line @typescript-eslint/no-extraneous-class
-export class AuthValidators {
-  /**
-   * Maximum length for OCPP 1.6 idTag
-   */
-  public static readonly MAX_IDTAG_LENGTH = 20
-
-  /**
-   * Maximum length for OCPP 2.0 IdToken
-   */
-  public static readonly MAX_IDTOKEN_LENGTH = 36
-
-  /**
-   * Validate cache TTL value
-   * @param ttl - Cache time-to-live duration in seconds, or undefined for optional parameter
-   * @returns True if the TTL is undefined or a valid non-negative finite number, false otherwise
-   */
-  static isValidCacheTTL (ttl: number | undefined): boolean {
-    if (ttl === undefined) {
-      return true // Optional parameter
-    }
-
-    return typeof ttl === 'number' && ttl >= 0 && Number.isFinite(ttl)
+
+/**
+ * Maximum length for OCPP 1.6 idTag
+ */
+const MAX_IDTAG_LENGTH = 20
+
+/**
+ * Maximum length for OCPP 2.0 IdToken
+ */
+const MAX_IDTOKEN_LENGTH = 36
+
+/**
+ * Validate cache TTL value
+ * @param ttl - Cache time-to-live duration in seconds, or undefined for optional parameter
+ * @returns True if the TTL is undefined or a valid non-negative finite number, false otherwise
+ */
+function isValidCacheTTL (ttl: number | undefined): boolean {
+  if (ttl === undefined) {
+    return true // Optional parameter
   }
 
-  /**
-   * Validate connector ID
-   * @param connectorId - Charging connector identifier (0 or positive integer), or undefined for optional parameter
-   * @returns True if the connector ID is undefined or a valid non-negative integer, false otherwise
-   */
-  static isValidConnectorId (connectorId: number | undefined): boolean {
-    if (connectorId === undefined) {
-      return true // Optional parameter
-    }
-
-    return typeof connectorId === 'number' && connectorId >= 0 && Number.isInteger(connectorId)
+  return typeof ttl === 'number' && ttl >= 0 && Number.isFinite(ttl)
+}
+
+/**
+ * Validate connector ID
+ * @param connectorId - Charging connector identifier (0 or positive integer), or undefined for optional parameter
+ * @returns True if the connector ID is undefined or a valid non-negative integer, false otherwise
+ */
+function isValidConnectorId (connectorId: number | undefined): boolean {
+  if (connectorId === undefined) {
+    return true // Optional parameter
   }
 
-  /**
-   * Validate that a string is a valid identifier value
-   * @param value - Authentication identifier string to validate (idTag or IdToken value)
-   * @returns True if the value is a non-empty string with at least one non-whitespace character, false otherwise
-   */
-  static isValidIdentifierValue (value: string): boolean {
-    if (typeof value !== 'string' || value.length === 0) {
-      return false
-    }
+  return typeof connectorId === 'number' && connectorId >= 0 && Number.isInteger(connectorId)
+}
 
-    // Must contain at least one non-whitespace character
-    return value.trim().length > 0
+/**
+ * Validate that a string is a valid identifier value
+ * @param value - Authentication identifier string to validate (idTag or IdToken value)
+ * @returns True if the value is a non-empty string with at least one non-whitespace character, false otherwise
+ */
+function isValidIdentifierValue (value: string): boolean {
+  if (typeof value !== 'string' || value.length === 0) {
+    return false
   }
 
-  /**
-   * Sanitize idTag for OCPP 1.6 (max 20 characters)
-   * @param idTag - Raw idTag input to sanitize (may be any type)
-   * @returns Trimmed and truncated idTag string conforming to OCPP 1.6 length limit, or empty string for non-string input
-   */
-  static sanitizeIdTag (idTag: unknown): string {
-    // Return empty string for non-string input
-    if (typeof idTag !== 'string') {
-      return ''
-    }
-
-    // Trim whitespace and truncate to max length
-    const trimmed = idTag.trim()
-    return trimmed.length > this.MAX_IDTAG_LENGTH
-      ? trimmed.substring(0, this.MAX_IDTAG_LENGTH)
-      : trimmed
+  // Must contain at least one non-whitespace character
+  return value.trim().length > 0
+}
+
+/**
+ * Sanitize idTag for OCPP 1.6 (max 20 characters)
+ * @param idTag - Raw idTag input to sanitize (may be any type)
+ * @returns Trimmed and truncated idTag string conforming to OCPP 1.6 length limit, or empty string for non-string input
+ */
+function sanitizeIdTag (idTag: unknown): string {
+  // Return empty string for non-string input
+  if (typeof idTag !== 'string') {
+    return ''
   }
 
-  /**
-   * Sanitize IdToken for OCPP 2.0 (max 36 characters)
-   * @param idToken - Raw IdToken input to sanitize (may be any type)
-   * @returns Trimmed and truncated IdToken string conforming to OCPP 2.0 length limit, or empty string for non-string input
-   */
-  static sanitizeIdToken (idToken: unknown): string {
-    // Return empty string for non-string input
-    if (typeof idToken !== 'string') {
-      return ''
-    }
-
-    // Trim whitespace and truncate to max length
-    const trimmed = idToken.trim()
-    return trimmed.length > this.MAX_IDTOKEN_LENGTH
-      ? trimmed.substring(0, this.MAX_IDTOKEN_LENGTH)
-      : trimmed
+  // Trim whitespace and truncate to max length
+  const trimmed = idTag.trim()
+  return trimmed.length > MAX_IDTAG_LENGTH ? trimmed.substring(0, MAX_IDTAG_LENGTH) : trimmed
+}
+
+/**
+ * Sanitize IdToken for OCPP 2.0 (max 36 characters)
+ * @param idToken - Raw IdToken input to sanitize (may be any type)
+ * @returns Trimmed and truncated IdToken string conforming to OCPP 2.0 length limit, or empty string for non-string input
+ */
+function sanitizeIdToken (idToken: unknown): string {
+  // Return empty string for non-string input
+  if (typeof idToken !== 'string') {
+    return ''
   }
 
-  /**
-   * Validate authentication configuration
-   * @param config - Authentication configuration object to validate (may be any type)
-   * @returns True if the configuration has valid enabled strategies, timeouts, and priority order, false otherwise
-   */
-  static validateAuthConfiguration (config: unknown): boolean {
-    if (!config || typeof config !== 'object') {
-      return false
-    }
+  // Trim whitespace and truncate to max length
+  const trimmed = idToken.trim()
+  return trimmed.length > MAX_IDTOKEN_LENGTH ? trimmed.substring(0, MAX_IDTOKEN_LENGTH) : trimmed
+}
 
-    const authConfig = config as AuthConfiguration
+/**
+ * Validate authentication configuration
+ * @param config - Authentication configuration object to validate (may be any type)
+ * @returns True if the configuration has valid required fields and constraints, false otherwise
+ */
+function validateAuthConfiguration (config: unknown): boolean {
+  if (!config || typeof config !== 'object') {
+    return false
+  }
 
-    // Validate enabled strategies
-    if (
-      !authConfig.enabledStrategies ||
-      !Array.isArray(authConfig.enabledStrategies) ||
-      authConfig.enabledStrategies.length === 0
-    ) {
-      return false
-    }
+  const authConfig = config as AuthConfiguration
+
+  // Validate required boolean fields exist
+  if (
+    typeof authConfig.authorizationCacheEnabled !== 'boolean' ||
+    typeof authConfig.localAuthListEnabled !== 'boolean' ||
+    typeof authConfig.offlineAuthorizationEnabled !== 'boolean' ||
+    typeof authConfig.allowOfflineTxForUnknownId !== 'boolean' ||
+    typeof authConfig.localPreAuthorize !== 'boolean' ||
+    typeof authConfig.certificateAuthEnabled !== 'boolean'
+  ) {
+    return false
+  }
 
-    // Validate timeouts
-    if (typeof authConfig.remoteAuthTimeout === 'number' && authConfig.remoteAuthTimeout <= 0) {
-      return false
-    }
+  // Validate authorization timeout (required, must be positive)
+  if (typeof authConfig.authorizationTimeout !== 'number' || authConfig.authorizationTimeout <= 0) {
+    return false
+  }
 
-    if (
-      authConfig.localAuthCacheTTL !== undefined &&
-      (typeof authConfig.localAuthCacheTTL !== 'number' || authConfig.localAuthCacheTTL < 0)
-    ) {
-      return false
-    }
-
-    // Validate priority order if specified
-    if (authConfig.strategyPriorityOrder) {
-      if (!Array.isArray(authConfig.strategyPriorityOrder)) {
-        return false
-      }
-
-      // Check that priority order contains valid authentication methods
-      const validMethods = Object.values(AuthenticationMethod)
-      for (const method of authConfig.strategyPriorityOrder) {
-        if (typeof method === 'string' && !validMethods.includes(method as AuthenticationMethod)) {
-          return false
-        }
-      }
-    }
-
-    return true
+  // Validate optional cache lifetime if provided
+  if (
+    authConfig.authorizationCacheLifetime !== undefined &&
+    (typeof authConfig.authorizationCacheLifetime !== 'number' ||
+      authConfig.authorizationCacheLifetime < 0)
+  ) {
+    return false
   }
 
-  /**
-   * Validate unified identifier format and constraints
-   * @param identifier - Unified identifier object to validate (may be any type)
-   * @returns True if the identifier has a valid type and value within OCPP length constraints, false otherwise
-   */
-  static validateIdentifier (identifier: unknown): boolean {
-    // Check if identifier itself is valid
-    if (!identifier || typeof identifier !== 'object') {
-      return false
-    }
+  // Validate optional max cache entries if provided
+  if (
+    authConfig.maxCacheEntries !== undefined &&
+    (typeof authConfig.maxCacheEntries !== 'number' ||
+      authConfig.maxCacheEntries < 1 ||
+      !Number.isInteger(authConfig.maxCacheEntries))
+  ) {
+    return false
+  }
+
+  return true
+}
 
-    const unifiedIdentifier = identifier as UnifiedIdentifier
+/**
+ * Validate unified identifier format and constraints
+ * @param identifier - Unified identifier object to validate (may be any type)
+ * @returns True if the identifier has a valid type and value within OCPP length constraints, false otherwise
+ */
+function validateIdentifier (identifier: unknown): boolean {
+  // Check if identifier itself is valid
+  if (!identifier || typeof identifier !== 'object') {
+    return false
+  }
 
-    if (!unifiedIdentifier.value) {
+  const unifiedIdentifier = identifier as UnifiedIdentifier
+
+  if (!unifiedIdentifier.value) {
+    return false
+  }
+
+  // Check length constraints based on identifier type
+  switch (unifiedIdentifier.type) {
+    case IdentifierType.BIOMETRIC:
+    // Fallthrough intentional: all these OCPP 2.0 types share the same validation
+    case IdentifierType.CENTRAL:
+    case IdentifierType.CERTIFICATE:
+    case IdentifierType.E_MAID:
+    case IdentifierType.ISO14443:
+    case IdentifierType.ISO15693:
+    case IdentifierType.KEY_CODE:
+    case IdentifierType.LOCAL:
+    case IdentifierType.MAC_ADDRESS:
+    case IdentifierType.MOBILE_APP:
+    case IdentifierType.NO_AUTHORIZATION:
+      // OCPP 2.0 types - use IdToken max length
+      return (
+        unifiedIdentifier.value.length > 0 && unifiedIdentifier.value.length <= MAX_IDTOKEN_LENGTH
+      )
+    case IdentifierType.ID_TAG:
+      return (
+        unifiedIdentifier.value.length > 0 && unifiedIdentifier.value.length <= MAX_IDTAG_LENGTH
+      )
+
+    default:
       return false
-    }
-
-    // Check length constraints based on identifier type
-    switch (unifiedIdentifier.type) {
-      case IdentifierType.BIOMETRIC:
-      // Fallthrough intentional: all these OCPP 2.0 types share the same validation
-      case IdentifierType.CENTRAL:
-      case IdentifierType.CERTIFICATE:
-      case IdentifierType.E_MAID:
-      case IdentifierType.ISO14443:
-      case IdentifierType.ISO15693:
-      case IdentifierType.KEY_CODE:
-      case IdentifierType.LOCAL:
-      case IdentifierType.MAC_ADDRESS:
-      case IdentifierType.MOBILE_APP:
-      case IdentifierType.NO_AUTHORIZATION:
-        // OCPP 2.0 types - use IdToken max length
-        return (
-          unifiedIdentifier.value.length > 0 &&
-          unifiedIdentifier.value.length <= this.MAX_IDTOKEN_LENGTH
-        )
-      case IdentifierType.ID_TAG:
-        return (
-          unifiedIdentifier.value.length > 0 &&
-          unifiedIdentifier.value.length <= this.MAX_IDTAG_LENGTH
-        )
-
-      default:
-        return false
-    }
   }
 }
+
+export const AuthValidators = {
+  isValidCacheTTL,
+  isValidConnectorId,
+  isValidIdentifierValue,
+  MAX_IDTAG_LENGTH,
+  MAX_IDTOKEN_LENGTH,
+  sanitizeIdTag,
+  sanitizeIdToken,
+  validateAuthConfiguration,
+  validateIdentifier,
+}
index 1fcc15017a5c14e143b0d9761b671feb734c2443..e1bec914181f8a603cd910e19334f90fdc7dd601 100644 (file)
@@ -2,193 +2,163 @@ import { logger } from '../../../../utils/Logger.js'
 import { type AuthConfiguration, AuthenticationError, AuthErrorCode } from '../types/AuthTypes.js'
 
 /**
- * Validator for authentication configuration
- *
- * Ensures that authentication configuration values are valid and consistent
- * before being applied to the authentication service.
+ * Warn if no authentication method is enabled in the configuration.
+ * @param config - Authentication configuration to check
  */
-// eslint-disable-next-line @typescript-eslint/no-extraneous-class
-export class AuthConfigValidator {
-  /**
-   * Validate authentication configuration
-   * @param config - Configuration to validate
-   * @throws {AuthenticationError} If configuration is invalid
-   * @example
-   * ```typescript
-   * const config: AuthConfiguration = {
-   *   authorizationCacheEnabled: true,
-   *   authorizationCacheLifetime: 3600,
-   *   maxCacheEntries: 1000,
-   *   // ... other config
-   * }
-   *
-   * AuthConfigValidator.validate(config) // Throws if invalid
-   * ```
-   */
-  static validate (config: AuthConfiguration): void {
-    // Validate cache configuration
-    if (config.authorizationCacheEnabled) {
-      this.validateCacheConfig(config)
-    }
+function checkAuthMethodsEnabled (config: AuthConfiguration): void {
+  const hasLocalList = config.localAuthListEnabled
+  const hasCache = config.authorizationCacheEnabled
+  const hasRemote = config.remoteAuthorization ?? false
+  const hasCertificate = config.certificateAuthEnabled
+  const hasOffline = config.offlineAuthorizationEnabled
+
+  if (!hasLocalList && !hasCache && !hasRemote && !hasCertificate && !hasOffline) {
+    logger.warn(
+      'AuthConfigValidator: No authentication method is enabled. All authorization requests will fail unless at least one method is enabled.'
+    )
+  }
 
-    // Validate timeout
-    this.validateTimeout(config)
+  const enabledMethods: string[] = []
+  if (hasLocalList) enabledMethods.push('local list')
+  if (hasCache) enabledMethods.push('cache')
+  if (hasRemote) enabledMethods.push('remote')
+  if (hasCertificate) enabledMethods.push('certificate')
+  if (hasOffline) enabledMethods.push('offline')
+
+  if (enabledMethods.length > 0) {
+    logger.debug(
+      `AuthConfigValidator: Enabled authentication methods: ${enabledMethods.join(', ')}`
+    )
+  }
+}
 
-    // Validate offline configuration
-    this.validateOfflineConfig(config)
+/**
+ * Validate authentication configuration.
+ * @param config - Configuration to validate
+ * @throws {AuthenticationError} If configuration is invalid
+ */
+function validate (config: AuthConfiguration): void {
+  if (config.authorizationCacheEnabled) {
+    validateCacheConfig(config)
+  }
 
-    // Warn if no auth method is enabled
-    this.checkAuthMethodsEnabled(config)
+  validateTimeout(config)
+  validateOfflineConfig(config)
+  checkAuthMethodsEnabled(config)
 
-    logger.debug('AuthConfigValidator: Configuration validated successfully')
-  }
+  logger.debug('AuthConfigValidator: Configuration validated successfully')
+}
 
-  /**
-   * Check if at least one authentication method is enabled
-   * @param config - Authentication configuration to check for enabled methods
-   */
-  private static checkAuthMethodsEnabled (config: AuthConfiguration): void {
-    const hasLocalList = config.localAuthListEnabled
-    const hasCache = config.authorizationCacheEnabled
-    const hasRemote = config.remoteAuthorization ?? false
-    const hasCertificate = config.certificateAuthEnabled
-    const hasOffline = config.offlineAuthorizationEnabled
-
-    if (!hasLocalList && !hasCache && !hasRemote && !hasCertificate && !hasOffline) {
-      logger.warn(
-        'AuthConfigValidator: No authentication method is enabled. All authorization requests will fail unless at least one method is enabled.'
+/**
+ * Validate cache-related configuration values.
+ * @param config - Authentication configuration with cache settings
+ */
+function validateCacheConfig (config: AuthConfiguration): void {
+  if (config.authorizationCacheLifetime !== undefined) {
+    if (!Number.isInteger(config.authorizationCacheLifetime)) {
+      throw new AuthenticationError(
+        'authorizationCacheLifetime must be an integer',
+        AuthErrorCode.CONFIGURATION_ERROR
       )
     }
 
-    // Log enabled methods for debugging
-    const enabledMethods: string[] = []
-    if (hasLocalList) enabledMethods.push('local list')
-    if (hasCache) enabledMethods.push('cache')
-    if (hasRemote) enabledMethods.push('remote')
-    if (hasCertificate) enabledMethods.push('certificate')
-    if (hasOffline) enabledMethods.push('offline')
-
-    if (enabledMethods.length > 0) {
-      logger.debug(
-        `AuthConfigValidator: Enabled authentication methods: ${enabledMethods.join(', ')}`
+    if (config.authorizationCacheLifetime <= 0) {
+      throw new AuthenticationError(
+        `authorizationCacheLifetime must be > 0, got ${String(config.authorizationCacheLifetime)}`,
+        AuthErrorCode.CONFIGURATION_ERROR
       )
     }
-  }
 
-  /**
-   * Validate cache-related configuration
-   * @param config - Authentication configuration containing cache settings to validate
-   */
-  private static validateCacheConfig (config: AuthConfiguration): void {
-    if (config.authorizationCacheLifetime !== undefined) {
-      if (!Number.isInteger(config.authorizationCacheLifetime)) {
-        throw new AuthenticationError(
-          'authorizationCacheLifetime must be an integer',
-          AuthErrorCode.CONFIGURATION_ERROR
-        )
-      }
-
-      if (config.authorizationCacheLifetime <= 0) {
-        throw new AuthenticationError(
-          `authorizationCacheLifetime must be > 0, got ${String(config.authorizationCacheLifetime)}`,
-          AuthErrorCode.CONFIGURATION_ERROR
-        )
-      }
-
-      // Warn if lifetime is very short (< 60s)
-      if (config.authorizationCacheLifetime < 60) {
-        logger.warn(
-          `AuthConfigValidator: authorizationCacheLifetime is very short (${String(config.authorizationCacheLifetime)}s). Consider using at least 60s for efficiency.`
-        )
-      }
-
-      // Warn if lifetime is very long (> 24h)
-      if (config.authorizationCacheLifetime > 86400) {
-        logger.warn(
-          `AuthConfigValidator: authorizationCacheLifetime is very long (${String(config.authorizationCacheLifetime)}s). This may lead to stale authorizations.`
-        )
-      }
-    }
-
-    if (config.maxCacheEntries !== undefined) {
-      if (!Number.isInteger(config.maxCacheEntries)) {
-        throw new AuthenticationError(
-          'maxCacheEntries must be an integer',
-          AuthErrorCode.CONFIGURATION_ERROR
-        )
-      }
-
-      if (config.maxCacheEntries <= 0) {
-        throw new AuthenticationError(
-          `maxCacheEntries must be > 0, got ${String(config.maxCacheEntries)}`,
-          AuthErrorCode.CONFIGURATION_ERROR
-        )
-      }
-
-      // Warn if cache is very small (< 10 entries)
-      if (config.maxCacheEntries < 10) {
-        logger.warn(
-          `AuthConfigValidator: maxCacheEntries is very small (${String(config.maxCacheEntries)}). Cache may be ineffective with frequent evictions.`
-        )
-      }
-    }
-  }
-
-  /**
-   * Validate offline-related configuration
-   * @param config - Authentication configuration containing offline settings to validate
-   */
-  private static validateOfflineConfig (config: AuthConfiguration): void {
-    // If offline transactions are allowed for unknown IDs, offline mode should be enabled
-    if (config.allowOfflineTxForUnknownId && !config.offlineAuthorizationEnabled) {
+    if (config.authorizationCacheLifetime < 60) {
       logger.warn(
-        'AuthConfigValidator: allowOfflineTxForUnknownId is true but offlineAuthorizationEnabled is false. Unknown IDs will not be authorized.'
+        `AuthConfigValidator: authorizationCacheLifetime is very short (${String(config.authorizationCacheLifetime)}s). Consider using at least 60s for efficiency.`
       )
     }
 
-    // Check consistency between offline mode and unknown ID authorization
-    if (
-      config.offlineAuthorizationEnabled &&
-      config.allowOfflineTxForUnknownId &&
-      config.unknownIdAuthorization
-    ) {
-      logger.debug(
-        `AuthConfigValidator: Offline mode enabled with unknownIdAuthorization=${config.unknownIdAuthorization}`
+    if (config.authorizationCacheLifetime > 86400) {
+      logger.warn(
+        `AuthConfigValidator: authorizationCacheLifetime is very long (${String(config.authorizationCacheLifetime)}s). This may lead to stale authorizations.`
       )
     }
   }
 
-  /**
-   * Validate timeout configuration
-   * @param config - Authentication configuration containing timeout value to validate
-   */
-  private static validateTimeout (config: AuthConfiguration): void {
-    if (!Number.isInteger(config.authorizationTimeout)) {
+  if (config.maxCacheEntries !== undefined) {
+    if (!Number.isInteger(config.maxCacheEntries)) {
       throw new AuthenticationError(
-        'authorizationTimeout must be an integer',
+        'maxCacheEntries must be an integer',
         AuthErrorCode.CONFIGURATION_ERROR
       )
     }
 
-    if (config.authorizationTimeout <= 0) {
+    if (config.maxCacheEntries <= 0) {
       throw new AuthenticationError(
-        `authorizationTimeout must be > 0, got ${String(config.authorizationTimeout)}`,
+        `maxCacheEntries must be > 0, got ${String(config.maxCacheEntries)}`,
         AuthErrorCode.CONFIGURATION_ERROR
       )
     }
 
-    // Warn if timeout is very short (< 5s)
-    if (config.authorizationTimeout < 5) {
+    if (config.maxCacheEntries < 10) {
       logger.warn(
-        `AuthConfigValidator: authorizationTimeout is very short (${String(config.authorizationTimeout)}s). This may cause premature timeouts.`
+        `AuthConfigValidator: maxCacheEntries is very small (${String(config.maxCacheEntries)}). Cache may be ineffective with frequent evictions.`
       )
     }
+  }
+}
 
-    // Warn if timeout is very long (> 60s)
-    if (config.authorizationTimeout > 60) {
-      logger.warn(
-        `AuthConfigValidator: authorizationTimeout is very long (${String(config.authorizationTimeout)}s). Users may experience long waits.`
-      )
-    }
+/**
+ * Validate offline authorization configuration consistency.
+ * @param config - Authentication configuration with offline settings
+ */
+function validateOfflineConfig (config: AuthConfiguration): void {
+  if (config.allowOfflineTxForUnknownId && !config.offlineAuthorizationEnabled) {
+    logger.warn(
+      'AuthConfigValidator: allowOfflineTxForUnknownId is true but offlineAuthorizationEnabled is false. Unknown IDs will not be authorized.'
+    )
+  }
+
+  if (
+    config.offlineAuthorizationEnabled &&
+    config.allowOfflineTxForUnknownId &&
+    config.unknownIdAuthorization
+  ) {
+    logger.debug(
+      `AuthConfigValidator: Offline mode enabled with unknownIdAuthorization=${config.unknownIdAuthorization}`
+    )
+  }
+}
+
+/**
+ * Validate authorization timeout value.
+ * @param config - Authentication configuration with timeout setting
+ */
+function validateTimeout (config: AuthConfiguration): void {
+  if (!Number.isInteger(config.authorizationTimeout)) {
+    throw new AuthenticationError(
+      'authorizationTimeout must be an integer',
+      AuthErrorCode.CONFIGURATION_ERROR
+    )
   }
+
+  if (config.authorizationTimeout <= 0) {
+    throw new AuthenticationError(
+      `authorizationTimeout must be > 0, got ${String(config.authorizationTimeout)}`,
+      AuthErrorCode.CONFIGURATION_ERROR
+    )
+  }
+
+  if (config.authorizationTimeout < 5) {
+    logger.warn(
+      `AuthConfigValidator: authorizationTimeout is very short (${String(config.authorizationTimeout)}s). This may cause premature timeouts.`
+    )
+  }
+
+  if (config.authorizationTimeout > 60) {
+    logger.warn(
+      `AuthConfigValidator: authorizationTimeout is very long (${String(config.authorizationTimeout)}s). Users may experience long waits.`
+    )
+  }
+}
+
+export const AuthConfigValidator = {
+  validate,
 }
index 55f9dd76e40074b64038c49400b00c926bfdb55b..d3f8e8200469fd52acf796f2682bec8368dbc5b5 100644 (file)
@@ -300,6 +300,7 @@ expect(mocks.webSocket.sentMessages).toContain(expectedMessage)
 | Utility                           | Purpose                                  |
 | --------------------------------- | ---------------------------------------- |
 | `standardCleanup()`               | **MANDATORY** afterEach cleanup          |
+| `sleep(ms)`                       | Real-time delay                          |
 | `withMockTimers()`                | Execute test with timer mocking          |
 | `createTimerScope()`              | Manual timer control                     |
 | `createLoggerMocks()`             | Create logger spies (error, warn)        |
index 9d47f93b46c31055fac5f4daaa74fbe569ef43b4..426217da4c4e0f86e36154be6f9f30216d4beef0 100644 (file)
@@ -70,9 +70,8 @@ await describe('C11 - Clear Authorization Data in Authorization Cache', async ()
       // Create a mock auth service to verify clearCache is called
       let clearCacheCalled = false
       const mockAuthService = {
-        clearCache: (): Promise<void> => {
+        clearCache: (): void => {
           clearCacheCalled = true
-          return Promise.resolve()
         },
         getConfiguration: () => ({
           authorizationCacheEnabled: true,
@@ -122,7 +121,7 @@ await describe('C11 - Clear Authorization Data in Authorization Cache', async ()
     await it('should return Rejected when AuthCacheEnabled is false', async () => {
       // Create a mock auth service with cache disabled
       const mockAuthService = {
-        clearCache: (): Promise<void> => {
+        clearCache: (): void => {
           throw new Error('clearCache should not be called when cache is disabled')
         },
         getConfiguration: () => ({
@@ -149,9 +148,8 @@ await describe('C11 - Clear Authorization Data in Authorization Cache', async ()
     await it('should return Accepted when AuthCacheEnabled is true and clear succeeds', async () => {
       // Create a mock auth service with cache enabled
       const mockAuthService = {
-        clearCache: (): Promise<void> => {
-          // Successful clear
-          return Promise.resolve()
+        clearCache: (): void => {
+          /* empty */
         },
         getConfiguration: () => ({
           authorizationCacheEnabled: true,
@@ -177,8 +175,8 @@ await describe('C11 - Clear Authorization Data in Authorization Cache', async ()
     await it('should return Rejected when clearCache throws an error', async () => {
       // Create a mock auth service that throws on clearCache
       const mockAuthService = {
-        clearCache: (): Promise<void> => {
-          return Promise.reject(new Error('Cache clear failed'))
+        clearCache: (): void => {
+          throw new Error('Cache clear failed')
         },
         getConfiguration: () => ({
           authorizationCacheEnabled: true,
@@ -204,9 +202,8 @@ await describe('C11 - Clear Authorization Data in Authorization Cache', async ()
     await it('should not attempt to clear cache when AuthCacheEnabled is false', async () => {
       let clearCacheAttempted = false
       const mockAuthService = {
-        clearCache: (): Promise<void> => {
+        clearCache: (): void => {
           clearCacheAttempted = true
-          return Promise.resolve()
         },
         getConfiguration: () => ({
           authorizationCacheEnabled: false,
index 7d6246e3a2c3d241935fe0ceca3aacddd6170c18..c471f5f2b3e62f36df19861a5642f65c50a829b9 100644 (file)
@@ -7,17 +7,27 @@ import { expect } from '@std/expect'
 import { afterEach, beforeEach, describe, it } from 'node:test'
 
 import type { ChargingStation } from '../../../../src/charging-station/ChargingStation.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'
+import { LocalAuthStrategy } from '../../../../src/charging-station/ocpp/auth/strategies/LocalAuthStrategy.js'
 import {
   AuthContext,
+  AuthenticationMethod,
   AuthorizationStatus,
   IdentifierType,
 } from '../../../../src/charging-station/ocpp/auth/types/AuthTypes.js'
 import { OCPPVersion } from '../../../../src/types/ocpp/OCPPVersion.js'
-import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js'
+import { sleep, standardCleanup } from '../../../helpers/TestLifecycleHelpers.js'
 import { createMockChargingStation } from '../../ChargingStationTestUtils.js'
-import { createMockAuthRequest, createMockIdentifier } from './helpers/MockFactories.js'
+import {
+  createMockAuthorizationResult,
+  createMockAuthRequest,
+  createMockIdentifier,
+  createMockLocalAuthListManager,
+  createTestAuthConfig,
+} from './helpers/MockFactories.js'
 
 await describe('OCPP Authentication', async () => {
   let mockStation16: ChargingStation
@@ -209,4 +219,191 @@ await describe('OCPP Authentication', async () => {
       }
     })
   })
+
+  await describe('Cache Spec Compliance Integration', async () => {
+    // G04.INT.01 - Cache wiring regression (T2)
+    await it('G04.INT.01: OCPPAuthServiceImpl wires auth cache into local strategy', async () => {
+      const result16 = createMockChargingStation({
+        baseName: 'TEST_CACHE_WIRING',
+        connectorsCount: 1,
+        stationInfo: {
+          chargingStationId: 'TEST_CACHE_WIRING',
+          ocppVersion: OCPPVersion.VERSION_16,
+          templateName: 'test-auth-template',
+        },
+      })
+      const service = new OCPPAuthServiceImpl(result16.station)
+      await service.initialize()
+
+      const localStrategy = service.getStrategy('local') as LocalAuthStrategy | undefined
+      expect(localStrategy).toBeDefined()
+
+      const authCache = localStrategy?.getAuthCache()
+      expect(authCache).toBeDefined()
+    })
+
+    // G04.INT.02 - All-status caching (T4)
+    await it('G04.INT.02: cache stores and retrieves all authorization statuses', () => {
+      const cache = new InMemoryAuthCache({ cleanupIntervalSeconds: 0 })
+      try {
+        const blockedResult = createMockAuthorizationResult({
+          status: AuthorizationStatus.BLOCKED,
+        })
+
+        cache.set('BLOCKED-ID', blockedResult)
+        const retrieved = cache.get('BLOCKED-ID')
+
+        expect(retrieved).toBeDefined()
+        expect(retrieved?.status).toBe(AuthorizationStatus.BLOCKED)
+      } finally {
+        cache.dispose()
+      }
+    })
+
+    // G04.INT.03 - Status-aware eviction (T5)
+    await it('G04.INT.03: eviction prefers ACCEPTED entries over non-ACCEPTED', () => {
+      const cache = new InMemoryAuthCache({ cleanupIntervalSeconds: 0, maxEntries: 3 })
+      try {
+        // Add 3 ACCEPTED entries
+        for (let i = 0; i < 3; i++) {
+          cache.set(
+            `ACCEPTED-${String(i)}`,
+            createMockAuthorizationResult({ status: AuthorizationStatus.ACCEPTED })
+          )
+        }
+
+        // Insert a BLOCKED entry — triggers eviction of one ACCEPTED entry
+        cache.set(
+          'BLOCKED-ENTRY',
+          createMockAuthorizationResult({ status: AuthorizationStatus.BLOCKED })
+        )
+
+        const stats = cache.getStats()
+        expect(stats.totalEntries).toBe(3)
+
+        // BLOCKED entry must still exist
+        const blocked = cache.get('BLOCKED-ENTRY')
+        expect(blocked).toBeDefined()
+        expect(blocked?.status).toBe(AuthorizationStatus.BLOCKED)
+      } finally {
+        cache.dispose()
+      }
+    })
+
+    // G04.INT.04 - TTL sliding window (T6/R5/R16)
+    await it('G04.INT.04: cache hit resets TTL sliding window', async () => {
+      const cache = new InMemoryAuthCache({ cleanupIntervalSeconds: 0, defaultTtl: 1 })
+      try {
+        cache.set(
+          'SLIDING-ID',
+          createMockAuthorizationResult({ status: AuthorizationStatus.ACCEPTED })
+        )
+
+        // Wait 500ms, then access to reset TTL
+        await sleep(500)
+        const midResult = cache.get('SLIDING-ID')
+        expect(midResult).toBeDefined()
+        expect(midResult?.status).toBe(AuthorizationStatus.ACCEPTED)
+
+        // Wait another 700ms (total 1200ms from initial set, but only 700ms from last access)
+        await sleep(700)
+        const lateResult = cache.get('SLIDING-ID')
+
+        // Entry should still be valid because TTL was reset at the 500ms access
+        expect(lateResult).toBeDefined()
+        expect(lateResult?.status).toBe(AuthorizationStatus.ACCEPTED)
+      } finally {
+        cache.dispose()
+      }
+    })
+
+    // G04.INT.05 - Expired entry transition (T7/R10)
+    await it('G04.INT.05: expired entries transition to EXPIRED status instead of being deleted', async () => {
+      const cache = new InMemoryAuthCache({ cleanupIntervalSeconds: 0, defaultTtl: 1 })
+      try {
+        cache.set(
+          'EXPIRE-ID',
+          createMockAuthorizationResult({ status: AuthorizationStatus.ACCEPTED })
+        )
+
+        // Wait for TTL to expire
+        await sleep(1100)
+        const result = cache.get('EXPIRE-ID')
+
+        expect(result).toBeDefined()
+        expect(result?.status).toBe(AuthorizationStatus.EXPIRED)
+      } finally {
+        cache.dispose()
+      }
+    })
+
+    // G04.INT.06 - Local Auth List exclusion (T8/R17)
+    await it('G04.INT.06: identifiers from local auth list are not cached', async () => {
+      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)
+            }),
+        })
+
+        const strategy = new LocalAuthStrategy(listManager, cache)
+        const config = createTestAuthConfig({
+          authorizationCacheEnabled: true,
+          localAuthListEnabled: true,
+        })
+        strategy.initialize(config)
+
+        const request = createMockAuthRequest({
+          identifier: createMockIdentifier(OCPPVersion.VERSION_16, 'LIST-ID'),
+        })
+        const result = await strategy.authenticate(request, config)
+
+        // Should be authorized from local list
+        expect(result).toBeDefined()
+        expect(result?.method).toBe(AuthenticationMethod.LOCAL_LIST)
+
+        // Verify cache does NOT contain the identifier (R17)
+        const cached = cache.get('LIST-ID')
+        expect(cached).toBeUndefined()
+      } finally {
+        cache.dispose()
+      }
+    })
+
+    // G04.INT.07 - Cache lifecycle with stats preservation (T11)
+    await it('G04.INT.07: clear preserves stats, resetStats zeroes them', () => {
+      const cache = new InMemoryAuthCache({ cleanupIntervalSeconds: 0 })
+      try {
+        // Perform some operations to generate stats
+        cache.set(
+          'STATS-ID',
+          createMockAuthorizationResult({ status: AuthorizationStatus.ACCEPTED })
+        )
+        cache.get('STATS-ID')
+        cache.get('NONEXISTENT')
+
+        const statsBefore = cache.getStats()
+        expect(statsBefore.hits).toBeGreaterThan(0)
+        expect(statsBefore.misses).toBeGreaterThan(0)
+
+        // Clear entries — stats should be preserved
+        cache.clear()
+        const statsAfterClear = cache.getStats()
+        expect(statsAfterClear.totalEntries).toBe(0)
+        expect(statsAfterClear.hits).toBe(statsBefore.hits)
+        expect(statsAfterClear.misses).toBe(statsBefore.misses)
+
+        // Reset stats — counters should be zeroed
+        cache.resetStats()
+        const statsAfterReset = cache.getStats()
+        expect(statsAfterReset.hits).toBe(0)
+        expect(statsAfterReset.misses).toBe(0)
+        expect(statsAfterReset.evictions).toBe(0)
+      } finally {
+        cache.dispose()
+      }
+    })
+  })
 })
index bf5124f08cc161e0597ad48f5eabf017e74d2cfe..9b965b5cf45f57a75513d7037f4e382ccd939e48 100644 (file)
@@ -35,15 +35,16 @@ await describe('OCPP16AuthAdapter', async () => {
       inAcceptedState: () => true,
       logPrefix: () => '[TEST-STATION]',
       ocppRequestService: {
-        requestHandler: (): Promise<OCPP16AuthorizeResponse> => {
-          return Promise.resolve({
-            idTagInfo: {
-              expiryDate: new Date(Date.now() + 86400000),
-              parentIdTag: undefined,
-              status: OCPP16AuthorizationStatus.ACCEPTED,
-            },
-          })
-        },
+        requestHandler: (): Promise<OCPP16AuthorizeResponse> =>
+          new Promise<OCPP16AuthorizeResponse>(resolve => {
+            resolve({
+              idTagInfo: {
+                expiryDate: new Date(Date.now() + 86400000),
+                parentIdTag: undefined,
+                status: OCPP16AuthorizationStatus.ACCEPTED,
+              },
+            })
+          }),
       },
       stationInfo: {
         chargingStationId: 'TEST-001',
@@ -169,9 +170,10 @@ await describe('OCPP16AuthAdapter', async () => {
 
     await it('should handle authorization failure gracefully', async () => {
       // Override mock to simulate failure
-      mockStation.ocppRequestService.requestHandler = (): Promise<never> => {
-        return Promise.reject(new Error('Network error'))
-      }
+      mockStation.ocppRequestService.requestHandler = (): Promise<never> =>
+        new Promise<never>((_resolve, reject) => {
+          reject(new Error('Network error'))
+        })
 
       const identifier = createMockIdentifier(OCPPVersion.VERSION_16, 'TEST_TAG')
 
@@ -183,30 +185,30 @@ await describe('OCPP16AuthAdapter', async () => {
   })
 
   await describe('isRemoteAvailable', async () => {
-    await it('should return true when remote authorization is enabled and online', async () => {
-      const isAvailable = await adapter.isRemoteAvailable()
+    await it('should return true when remote authorization is enabled and online', () => {
+      const isAvailable = adapter.isRemoteAvailable()
       expect(isAvailable).toBe(true)
     })
 
-    await it('should return false when station is offline', async () => {
+    await it('should return false when station is offline', () => {
       mockStation.inAcceptedState = () => false
 
-      const isAvailable = await adapter.isRemoteAvailable()
+      const isAvailable = adapter.isRemoteAvailable()
       expect(isAvailable).toBe(false)
     })
 
-    await it('should return false when remote authorization is disabled', async () => {
+    await it('should return false when remote authorization is disabled', () => {
       if (mockStation.stationInfo) {
         mockStation.stationInfo.remoteAuthorization = false
       }
 
-      const isAvailable = await adapter.isRemoteAvailable()
+      const isAvailable = adapter.isRemoteAvailable()
       expect(isAvailable).toBe(false)
     })
   })
 
   await describe('validateConfiguration', async () => {
-    await it('should validate configuration with at least one auth method', async () => {
+    await it('should validate configuration with at least one auth method', () => {
       const config: AuthConfiguration = {
         allowOfflineTxForUnknownId: false,
         authorizationCacheEnabled: false,
@@ -218,11 +220,11 @@ await describe('OCPP16AuthAdapter', async () => {
         remoteAuthorization: false,
       }
 
-      const isValid = await adapter.validateConfiguration(config)
+      const isValid = adapter.validateConfiguration(config)
       expect(isValid).toBe(true)
     })
 
-    await it('should reject configuration with no auth methods', async () => {
+    await it('should reject configuration with no auth methods', () => {
       const config: AuthConfiguration = {
         allowOfflineTxForUnknownId: false,
         authorizationCacheEnabled: false,
@@ -234,11 +236,11 @@ await describe('OCPP16AuthAdapter', async () => {
         remoteAuthorization: false,
       }
 
-      const isValid = await adapter.validateConfiguration(config)
+      const isValid = adapter.validateConfiguration(config)
       expect(isValid).toBe(false)
     })
 
-    await it('should reject configuration with invalid timeout', async () => {
+    await it('should reject configuration with invalid timeout', () => {
       const config: AuthConfiguration = {
         allowOfflineTxForUnknownId: false,
         authorizationCacheEnabled: false,
@@ -250,7 +252,7 @@ await describe('OCPP16AuthAdapter', async () => {
         remoteAuthorization: true,
       }
 
-      const isValid = await adapter.validateConfiguration(config)
+      const isValid = adapter.validateConfiguration(config)
       expect(isValid).toBe(false)
     })
   })
index 2337205e5595a6a1d67080e28b2291b282cf25b6..0f2f3fd213e18a4fcb1053116f796da17a16b6b6 100644 (file)
@@ -221,15 +221,20 @@ await describe('OCPP20AuthAdapter', async () => {
   await describe('authorizeRemote', async () => {
     await it('should perform remote authorization successfully', async t => {
       // Mock isRemoteAvailable to return true (avoids OCPP20VariableManager singleton issues)
-      t.mock.method(adapter, 'isRemoteAvailable', () => Promise.resolve(true))
+      t.mock.method(adapter, 'isRemoteAvailable', () => true)
 
       // Mock sendTransactionEvent to return accepted authorization
-      t.mock.method(OCPP20ServiceUtils, 'sendTransactionEvent', () =>
-        Promise.resolve({
-          idTokenInfo: {
-            status: OCPP20AuthorizationStatusEnumType.Accepted,
-          },
-        })
+      t.mock.method(
+        OCPP20ServiceUtils,
+        'sendTransactionEvent',
+        () =>
+          new Promise<Record<string, unknown>>(resolve => {
+            resolve({
+              idTokenInfo: {
+                status: OCPP20AuthorizationStatusEnumType.Accepted,
+              },
+            })
+          })
       )
 
       const identifier = createMockIdentifier(
@@ -257,79 +262,76 @@ await describe('OCPP20AuthAdapter', async () => {
   })
 
   await describe('isRemoteAvailable', async () => {
-    await it('should return true when station is online and remote start enabled', async t => {
+    await it('should return true when station is online and remote start enabled', t => {
       t.mock.method(
-        adapter as unknown as { getVariableValue: () => Promise<string | undefined> },
+        adapter as unknown as { getVariableValue: () => string | undefined },
         'getVariableValue',
-        () => Promise.resolve('true')
+        () => 'true'
       )
 
-      const isAvailable = await adapter.isRemoteAvailable()
+      const isAvailable = adapter.isRemoteAvailable()
       expect(isAvailable).toBe(true)
     })
 
-    await it('should return false when station is offline', async t => {
+    await it('should return false when station is offline', t => {
       mockStation.inAcceptedState = () => false
       t.mock.method(
-        adapter as unknown as { getVariableValue: () => Promise<string | undefined> },
+        adapter as unknown as { getVariableValue: () => string | undefined },
         'getVariableValue',
-        () => Promise.resolve('true')
+        () => 'true'
       )
 
-      const isAvailable = await adapter.isRemoteAvailable()
+      const isAvailable = adapter.isRemoteAvailable()
       expect(isAvailable).toBe(false)
     })
   })
 
   await describe('validateConfiguration', async () => {
-    await it('should validate configuration with at least one auth method', async () => {
+    await it('should validate configuration with at least one auth method', () => {
       const config: AuthConfiguration = {
         allowOfflineTxForUnknownId: false,
         authorizationCacheEnabled: false,
         authorizationTimeout: 30,
-        authorizeRemoteStart: true,
         certificateAuthEnabled: false,
         localAuthListEnabled: false,
-        localAuthorizeOffline: false,
         localPreAuthorize: false,
         offlineAuthorizationEnabled: false,
+        remoteAuthorization: true,
       }
 
-      const isValid = await adapter.validateConfiguration(config)
+      const isValid = adapter.validateConfiguration(config)
       expect(isValid).toBe(true)
     })
 
-    await it('should reject configuration with no auth methods', async () => {
+    await it('should reject configuration with no auth methods', () => {
       const config: AuthConfiguration = {
         allowOfflineTxForUnknownId: false,
         authorizationCacheEnabled: false,
         authorizationTimeout: 30,
-        authorizeRemoteStart: false,
         certificateAuthEnabled: false,
         localAuthListEnabled: false,
-        localAuthorizeOffline: false,
         localPreAuthorize: false,
         offlineAuthorizationEnabled: false,
+        remoteAuthorization: false,
       }
 
-      const isValid = await adapter.validateConfiguration(config)
+      const isValid = adapter.validateConfiguration(config)
       expect(isValid).toBe(false)
     })
 
-    await it('should reject configuration with invalid timeout', async () => {
+    await it('should reject configuration with invalid timeout', () => {
       const config: AuthConfiguration = {
         allowOfflineTxForUnknownId: false,
         authorizationCacheEnabled: false,
         authorizationTimeout: 0,
-        authorizeRemoteStart: true,
         certificateAuthEnabled: false,
         localAuthListEnabled: false,
-        localAuthorizeOffline: true,
         localPreAuthorize: false,
-        offlineAuthorizationEnabled: false,
+        offlineAuthorizationEnabled: true,
+        remoteAuthorization: true,
       }
 
-      const isValid = await adapter.validateConfiguration(config)
+      const isValid = adapter.validateConfiguration(config)
       expect(isValid).toBe(false)
     })
   })
@@ -410,23 +412,23 @@ await describe('OCPP20AuthAdapter', async () => {
     })
 
     await describe('G03.FR.02.001 - Offline detection', async () => {
-      await it('should detect station is offline when not in accepted state', async () => {
+      await it('should detect station is offline when not in accepted state', () => {
         // Given: Station is offline (not in accepted state)
         offlineMockChargingStation.inAcceptedState = () => false
 
         // When: Check if remote authorization is available
-        const isAvailable = await offlineAdapter.isRemoteAvailable()
+        const isAvailable = offlineAdapter.isRemoteAvailable()
 
         // Then: Remote should not be available
         expect(isAvailable).toBe(false)
       })
 
-      await it('should detect station is online when in accepted state', async () => {
+      await it('should detect station is online when in accepted state', () => {
         // Given: Station is online (in accepted state)
         offlineMockChargingStation.inAcceptedState = () => true
 
         // When: Check if remote authorization is available
-        const isAvailable = await offlineAdapter.isRemoteAvailable()
+        const isAvailable = offlineAdapter.isRemoteAvailable()
 
         // Then: Remote should be available (assuming AuthorizeRemoteStart is enabled by default)
         expect(isAvailable).toBe(true)
@@ -439,25 +441,25 @@ await describe('OCPP20AuthAdapter', async () => {
     })
 
     await describe('G03.FR.02.002 - Remote availability check', async () => {
-      await it('should return false when offline even with valid configuration', async () => {
+      await it('should return false when offline even with valid configuration', () => {
         // Given: Station is offline
         offlineMockChargingStation.inAcceptedState = () => false
 
         // When: Check remote availability
-        const isAvailable = await offlineAdapter.isRemoteAvailable()
+        const isAvailable = offlineAdapter.isRemoteAvailable()
 
         // Then: Should not be available
         expect(isAvailable).toBe(false)
       })
 
-      await it('should handle errors gracefully when checking availability', async () => {
+      await it('should handle errors gracefully when checking availability', () => {
         // Given: inAcceptedState throws an error
         offlineMockChargingStation.inAcceptedState = () => {
           throw new Error('Connection error')
         }
 
         // When: Check remote availability
-        const isAvailable = await offlineAdapter.isRemoteAvailable()
+        const isAvailable = offlineAdapter.isRemoteAvailable()
 
         // Then: Should safely return false
         expect(isAvailable).toBe(false)
index 9d79a94159899c6263f928737283363e1506836c..cdc4389d6e9efb4022f96dd4a3b10ef9f8723c61 100644 (file)
@@ -7,12 +7,15 @@ import { afterEach, beforeEach, describe, it } from 'node:test'
 
 import type { AuthorizationResult } from '../../../../../src/charging-station/ocpp/auth/types/AuthTypes.js'
 
-import { InMemoryAuthCache } from '../../../../../src/charging-station/ocpp/auth/cache/InMemoryAuthCache.js'
+import {
+  InMemoryAuthCache,
+  truncateId,
+} from '../../../../../src/charging-station/ocpp/auth/cache/InMemoryAuthCache.js'
 import {
   AuthenticationMethod,
   AuthorizationStatus,
 } from '../../../../../src/charging-station/ocpp/auth/types/AuthTypes.js'
-import { standardCleanup } from '../../../../helpers/TestLifecycleHelpers.js'
+import { standardCleanup, withMockTimers } from '../../../../helpers/TestLifecycleHelpers.js'
 import { createMockAuthorizationResult } from '../helpers/MockFactories.js'
 
 /**
@@ -54,34 +57,34 @@ await describe('InMemoryAuthCache - G03.FR.01 Conformance', async () => {
       })
     })
 
-    await it('should return cached result on cache hit', async () => {
+    await it('should return cached result on cache hit', () => {
       const identifier = 'test-token-001'
 
       // Cache the result
-      await cache.set(identifier, mockResult, 60)
+      cache.set(identifier, mockResult, 60)
 
       // Retrieve from cache
-      const cachedResult = await cache.get(identifier)
+      const cachedResult = cache.get(identifier)
 
       expect(cachedResult).toBeDefined()
       expect(cachedResult?.status).toBe(AuthorizationStatus.ACCEPTED)
       expect(cachedResult?.timestamp).toStrictEqual(mockResult.timestamp)
     })
 
-    await it('should track cache hits in statistics', async () => {
+    await it('should track cache hits in statistics', () => {
       const identifier = 'test-token-002'
 
-      await cache.set(identifier, mockResult)
-      await cache.get(identifier)
-      await cache.get(identifier)
+      cache.set(identifier, mockResult)
+      cache.get(identifier)
+      cache.get(identifier)
 
-      const stats = await cache.getStats()
+      const stats = cache.getStats()
       expect(stats.hits).toBe(2)
       expect(stats.misses).toBe(0)
       expect(stats.hitRate).toBe(100)
     })
 
-    await it('should update LRU order on cache hit', async () => {
+    await it('should update LRU order on cache hit', () => {
       // Use cache without rate limiting for this test
       const lruCache = new InMemoryAuthCache({
         defaultTtl: 3600, // 1 hour to prevent expiration during test
@@ -90,21 +93,21 @@ await describe('InMemoryAuthCache - G03.FR.01 Conformance', async () => {
       })
 
       // Fill cache to capacity
-      await lruCache.set('token-1', mockResult)
-      await lruCache.set('token-2', mockResult)
-      await lruCache.set('token-3', mockResult)
+      lruCache.set('token-1', mockResult)
+      lruCache.set('token-2', mockResult)
+      lruCache.set('token-3', mockResult)
 
       // Access token-3 to make it most recently used
-      const access3 = await lruCache.get('token-3')
+      const access3 = lruCache.get('token-3')
       expect(access3).toBeDefined() // Verify it's accessible before eviction test
 
       // Add new entry to trigger eviction
-      await lruCache.set('token-4', mockResult)
+      lruCache.set('token-4', mockResult)
 
       // token-1 should be evicted (oldest), token-3 and token-4 should still exist
-      const token1 = await lruCache.get('token-1')
-      const token3 = await lruCache.get('token-3')
-      const token4 = await lruCache.get('token-4')
+      const token1 = lruCache.get('token-1')
+      const token3 = lruCache.get('token-3')
+      const token4 = lruCache.get('token-4')
 
       expect(token1).toBeUndefined()
       expect(token3).toBeDefined()
@@ -119,38 +122,38 @@ await describe('InMemoryAuthCache - G03.FR.01 Conformance', async () => {
       mockResult = createMockAuthorizationResult()
     })
 
-    await it('should return undefined on cache miss', async () => {
-      const result = await cache.get('non-existent-token')
+    await it('should return undefined on cache miss', () => {
+      const result = cache.get('non-existent-token')
 
       expect(result).toBeUndefined()
     })
 
-    await it('should track cache misses in statistics', async () => {
-      await cache.get('miss-1')
-      await cache.get('miss-2')
-      await cache.get('miss-3')
+    await it('should track cache misses in statistics', () => {
+      cache.get('miss-1')
+      cache.get('miss-2')
+      cache.get('miss-3')
 
-      const stats = await cache.getStats()
+      const stats = cache.getStats()
       expect(stats.misses).toBe(3)
       expect(stats.hits).toBe(0)
       expect(stats.hitRate).toBe(0)
     })
 
-    await it('should calculate hit rate correctly with mixed hits/misses', async () => {
+    await it('should calculate hit rate correctly with mixed hits/misses', () => {
       // 2 sets
-      await cache.set('token-1', mockResult)
-      await cache.set('token-2', mockResult)
+      cache.set('token-1', mockResult)
+      cache.set('token-2', mockResult)
 
       // 2 hits
-      await cache.get('token-1')
-      await cache.get('token-2')
+      cache.get('token-1')
+      cache.get('token-2')
 
       // 3 misses
-      await cache.get('miss-1')
-      await cache.get('miss-2')
-      await cache.get('miss-3')
+      cache.get('miss-1')
+      cache.get('miss-2')
+      cache.get('miss-3')
 
-      const stats = await cache.getStats()
+      const stats = cache.getStats()
       expect(stats.hits).toBe(2)
       expect(stats.misses).toBe(3)
       expect(stats.hitRate).toBe(40) // 2/(2+3) * 100 = 40%
@@ -164,58 +167,63 @@ await describe('InMemoryAuthCache - G03.FR.01 Conformance', async () => {
       mockResult = createMockAuthorizationResult()
     })
 
-    await it('should expire entries after TTL', async () => {
+    await it('should transition expired entries to EXPIRED status', async t => {
       const identifier = 'expiring-token'
 
-      // Set with 1ms TTL (will expire immediately)
-      await cache.set(identifier, mockResult, 0.001)
+      await withMockTimers(t, ['Date'], () => {
+        cache.set(identifier, mockResult, 0.001)
 
-      // Wait for expiration
-      await new Promise(resolve => setTimeout(resolve, 10))
+        t.mock.timers.tick(10)
 
-      const result = await cache.get(identifier)
+        const result = cache.get(identifier)
 
-      expect(result).toBeUndefined()
+        expect(result).toBeDefined()
+        expect(result?.status).toBe(AuthorizationStatus.EXPIRED)
+      })
     })
 
-    await it('should track expired entries in statistics', async () => {
-      // Set with very short TTL
-      await cache.set('token-1', mockResult, 0.001)
-      await cache.set('token-2', mockResult, 0.001)
+    await it('should track expired entries in statistics', async t => {
+      await withMockTimers(t, ['Date'], () => {
+        // Set with very short TTL
+        cache.set('token-1', mockResult, 0.001)
+        cache.set('token-2', mockResult, 0.001)
 
-      // Wait for expiration
-      await new Promise(resolve => setTimeout(resolve, 10))
+        // Advance past expiration
+        t.mock.timers.tick(10)
 
-      // Access expired entries
-      await cache.get('token-1')
-      await cache.get('token-2')
+        // Access expired entries
+        cache.get('token-1')
+        cache.get('token-2')
 
-      const stats = await cache.getStats()
-      expect(stats.expiredEntries).toBeGreaterThanOrEqual(2)
+        const stats = cache.getStats()
+        expect(stats.expiredEntries).toBeGreaterThanOrEqual(2)
+      })
     })
 
-    await it('should use default TTL when not specified', async () => {
-      const cacheWithShortTTL = new InMemoryAuthCache({
-        defaultTtl: 0.001, // 1ms default
-      })
+    await it('should use default TTL when not specified', async t => {
+      await withMockTimers(t, ['Date'], () => {
+        const cacheWithShortTTL = new InMemoryAuthCache({
+          defaultTtl: 0.001, // 1ms default
+        })
 
-      await cacheWithShortTTL.set('token', mockResult) // No TTL specified
+        cacheWithShortTTL.set('token', mockResult)
 
-      // Wait for expiration
-      await new Promise(resolve => setTimeout(resolve, 10))
+        t.mock.timers.tick(10)
 
-      const result = await cacheWithShortTTL.get('token')
-      expect(result).toBeUndefined()
+        const result = cacheWithShortTTL.get('token')
+        expect(result).toBeDefined()
+        expect(result?.status).toBe(AuthorizationStatus.EXPIRED)
+      })
     })
 
-    await it('should not expire entries before TTL', async () => {
+    await it('should not expire entries before TTL', () => {
       const identifier = 'long-lived-token'
 
       // Set with 60 second TTL
-      await cache.set(identifier, mockResult, 60)
+      cache.set(identifier, mockResult, 60)
 
       // Immediately retrieve
-      const result = await cache.get(identifier)
+      const result = cache.get(identifier)
 
       expect(result).toBeDefined()
       expect(result?.status).toBe(mockResult.status)
@@ -229,50 +237,52 @@ await describe('InMemoryAuthCache - G03.FR.01 Conformance', async () => {
       mockResult = createMockAuthorizationResult()
     })
 
-    await it('should remove entry on invalidation', async () => {
+    await it('should remove entry on invalidation', () => {
       const identifier = 'token-to-remove'
 
-      await cache.set(identifier, mockResult)
+      cache.set(identifier, mockResult)
 
       // Verify it exists
-      let result = await cache.get(identifier)
+      let result = cache.get(identifier)
       expect(result).toBeDefined()
 
       // Remove it
-      await cache.remove(identifier)
+      cache.remove(identifier)
 
       // Verify it's gone
-      result = await cache.get(identifier)
+      result = cache.get(identifier)
       expect(result).toBeUndefined()
     })
 
-    await it('should clear all entries', async () => {
-      await cache.set('token-1', mockResult)
-      await cache.set('token-2', mockResult)
-      await cache.set('token-3', mockResult)
+    await it('should clear all entries', () => {
+      cache.set('token-1', mockResult)
+      cache.set('token-2', mockResult)
+      cache.set('token-3', mockResult)
 
-      const statsBefore = await cache.getStats()
+      const statsBefore = cache.getStats()
       expect(statsBefore.totalEntries).toBe(3)
 
-      await cache.clear()
+      cache.clear()
 
-      const statsAfter = await cache.getStats()
+      const statsAfter = cache.getStats()
       expect(statsAfter.totalEntries).toBe(0)
     })
 
-    await it('should reset statistics on clear', async () => {
-      await cache.set('token', mockResult)
-      await cache.get('token')
-      await cache.get('miss')
+    await it('should preserve statistics on clear', () => {
+      cache.set('token', mockResult)
+      cache.get('token')
+      cache.get('miss')
 
-      const statsBefore = await cache.getStats()
+      const statsBefore = cache.getStats()
       expect(statsBefore.hits).toBeGreaterThan(0)
+      expect(statsBefore.misses).toBeGreaterThan(0)
 
-      await cache.clear()
+      cache.clear()
 
-      const statsAfter = await cache.getStats()
-      expect(statsAfter.hits).toBe(0)
-      expect(statsAfter.misses).toBe(0)
+      const statsAfter = cache.getStats()
+      expect(statsAfter.hits).toBe(statsBefore.hits)
+      expect(statsAfter.misses).toBe(statsBefore.misses)
+      expect(statsAfter.totalEntries).toBe(0)
     })
   })
 
@@ -283,79 +293,78 @@ await describe('InMemoryAuthCache - G03.FR.01 Conformance', async () => {
       mockResult = createMockAuthorizationResult()
     })
 
-    await it('should block requests exceeding rate limit', async () => {
+    await it('should block requests exceeding rate limit', () => {
       const identifier = 'rate-limited-token'
 
       // Make 3 requests (at limit)
-      await cache.set(identifier, mockResult)
-      await cache.get(identifier)
-      await cache.get(identifier)
+      cache.set(identifier, mockResult)
+      cache.get(identifier)
+      cache.get(identifier)
 
       // 4th request should be rate limited
-      const result = await cache.get(identifier)
+      const result = cache.get(identifier)
       expect(result).toBeUndefined()
 
-      const stats = await cache.getStats()
+      const stats = cache.getStats()
       expect(stats.rateLimit.blockedRequests).toBeGreaterThan(0)
     })
 
-    await it('should track rate limit statistics', async () => {
+    await it('should track rate limit statistics', () => {
       const identifier = 'token'
 
       // Exceed rate limit
-      await cache.set(identifier, mockResult)
-      await cache.set(identifier, mockResult)
-      await cache.set(identifier, mockResult)
-      await cache.set(identifier, mockResult) // Should be blocked
+      cache.set(identifier, mockResult)
+      cache.set(identifier, mockResult)
+      cache.set(identifier, mockResult)
+      cache.set(identifier, mockResult) // Should be blocked
 
-      const stats = await cache.getStats()
+      const stats = cache.getStats()
       expect(stats.rateLimit.totalChecks).toBeGreaterThan(0)
       expect(stats.rateLimit.blockedRequests).toBeGreaterThan(0)
     })
 
-    await it('should reset rate limit after window expires', async () => {
+    await it('should reset rate limit after window expires', async t => {
       const identifier = 'windowed-token'
 
-      // Fill rate limit
-      await cache.set(identifier, mockResult)
-      await cache.get(identifier)
-      await cache.get(identifier)
+      await withMockTimers(t, ['Date'], () => {
+        cache.set(identifier, mockResult)
+        cache.get(identifier)
+        cache.get(identifier)
 
-      // Wait for window to expire
-      await new Promise(resolve => setTimeout(resolve, 1100))
+        t.mock.timers.tick(1100)
 
-      // Should allow new requests
-      const result = await cache.get(identifier)
-      expect(result).toBeDefined()
+        const result = cache.get(identifier)
+        expect(result).toBeDefined()
+      })
     })
 
-    await it('should rate limit per identifier independently', async () => {
+    await it('should rate limit per identifier independently', () => {
       // Fill rate limit for token-1
-      await cache.set('token-1', mockResult)
-      await cache.get('token-1')
-      await cache.get('token-1')
-      await cache.get('token-1') // Blocked
+      cache.set('token-1', mockResult)
+      cache.get('token-1')
+      cache.get('token-1')
+      cache.get('token-1') // Blocked
 
       // token-2 should still work
-      await cache.set('token-2', mockResult)
-      const result = await cache.get('token-2')
+      cache.set('token-2', mockResult)
+      const result = cache.get('token-2')
       expect(result).toBeDefined()
     })
 
-    await it('should allow disabling rate limiting', async () => {
+    await it('should allow disabling rate limiting', () => {
       const unratedCache = new InMemoryAuthCache({
         rateLimit: { enabled: false },
       })
 
       // Make many requests without blocking
       for (let i = 0; i < 20; i++) {
-        await unratedCache.set('token', mockResult)
+        unratedCache.set('token', mockResult)
       }
 
-      const result = await unratedCache.get('token')
+      const result = unratedCache.get('token')
       expect(result).toBeDefined()
 
-      const stats = await unratedCache.getStats()
+      const stats = unratedCache.getStats()
       expect(stats.rateLimit.blockedRequests).toBe(0)
     })
   })
@@ -367,36 +376,36 @@ await describe('InMemoryAuthCache - G03.FR.01 Conformance', async () => {
       mockResult = createMockAuthorizationResult()
     })
 
-    await it('should evict least recently used entry when full', async () => {
+    await it('should evict least recently used entry when full', () => {
       // Fill cache to capacity (5 entries)
-      await cache.set('token-1', mockResult)
-      await cache.set('token-2', mockResult)
-      await cache.set('token-3', mockResult)
-      await cache.set('token-4', mockResult)
-      await cache.set('token-5', mockResult)
+      cache.set('token-1', mockResult)
+      cache.set('token-2', mockResult)
+      cache.set('token-3', mockResult)
+      cache.set('token-4', mockResult)
+      cache.set('token-5', mockResult)
 
       // Add 6th entry - should evict token-1 (oldest)
-      await cache.set('token-6', mockResult)
+      cache.set('token-6', mockResult)
 
-      const stats = await cache.getStats()
+      const stats = cache.getStats()
       expect(stats.totalEntries).toBe(5)
 
       // token-1 should be evicted
-      const token1 = await cache.get('token-1')
+      const token1 = cache.get('token-1')
       expect(token1).toBeUndefined()
 
       // token-6 should exist
-      const token6 = await cache.get('token-6')
+      const token6 = cache.get('token-6')
       expect(token6).toBeDefined()
     })
 
-    await it('should track eviction count in statistics', async () => {
+    await it('should track eviction count in statistics', () => {
       // Trigger multiple evictions
       for (let i = 1; i <= 10; i++) {
-        await cache.set(`token-${String(i)}`, mockResult)
+        cache.set(`token-${String(i)}`, mockResult)
       }
 
-      const stats = await cache.getStats()
+      const stats = cache.getStats()
       expect(stats.totalEntries).toBe(5)
       // Should have 5 evictions (10 sets - 5 capacity = 5 evictions)
       expect(stats.evictions).toBe(5)
@@ -410,13 +419,13 @@ await describe('InMemoryAuthCache - G03.FR.01 Conformance', async () => {
       mockResult = createMockAuthorizationResult()
     })
 
-    await it('should provide accurate cache statistics', async () => {
-      await cache.set('token-1', mockResult)
-      await cache.set('token-2', mockResult)
-      await cache.get('token-1') // hit
-      await cache.get('miss-1') // miss
+    await it('should provide accurate cache statistics', () => {
+      cache.set('token-1', mockResult)
+      cache.set('token-2', mockResult)
+      cache.get('token-1') // hit
+      cache.get('miss-1') // miss
 
-      const stats = await cache.getStats()
+      const stats = cache.getStats()
 
       expect(stats.totalEntries).toBe(2)
       expect(stats.hits).toBe(1)
@@ -425,29 +434,29 @@ await describe('InMemoryAuthCache - G03.FR.01 Conformance', async () => {
       expect(stats.memoryUsage).toBeGreaterThan(0)
     })
 
-    await it('should track memory usage estimate', async () => {
-      const statsBefore = await cache.getStats()
+    await it('should track memory usage estimate', () => {
+      const statsBefore = cache.getStats()
       const memoryBefore = statsBefore.memoryUsage
 
       // Add entries
-      await cache.set('token-1', mockResult)
-      await cache.set('token-2', mockResult)
-      await cache.set('token-3', mockResult)
+      cache.set('token-1', mockResult)
+      cache.set('token-2', mockResult)
+      cache.set('token-3', mockResult)
 
-      const statsAfter = await cache.getStats()
+      const statsAfter = cache.getStats()
       const memoryAfter = statsAfter.memoryUsage
 
       expect(memoryAfter).toBeGreaterThan(memoryBefore)
     })
 
-    await it('should provide rate limit statistics', async () => {
+    await it('should provide rate limit statistics', () => {
       // Make some rate-limited requests
-      await cache.set('token', mockResult)
-      await cache.set('token', mockResult)
-      await cache.set('token', mockResult)
-      await cache.set('token', mockResult) // Blocked
+      cache.set('token', mockResult)
+      cache.set('token', mockResult)
+      cache.set('token', mockResult)
+      cache.set('token', mockResult) // Blocked
 
-      const stats = await cache.getStats()
+      const stats = cache.getStats()
 
       expect(stats.rateLimit).toBeDefined()
       expect(stats.rateLimit.totalChecks).toBeGreaterThan(0)
@@ -463,85 +472,79 @@ await describe('InMemoryAuthCache - G03.FR.01 Conformance', async () => {
       mockResult = createMockAuthorizationResult()
     })
 
-    await it('should handle empty identifier gracefully', async () => {
-      await cache.set('', mockResult)
-      const result = await cache.get('')
+    await it('should handle empty identifier gracefully', () => {
+      cache.set('', mockResult)
+      const result = cache.get('')
 
       expect(result).toBeDefined()
     })
 
-    await it('should handle very long identifier strings', async () => {
+    await it('should handle very long identifier strings', () => {
       const longIdentifier = 'x'.repeat(1000)
 
-      await cache.set(longIdentifier, mockResult)
-      const result = await cache.get(longIdentifier)
+      cache.set(longIdentifier, mockResult)
+      const result = cache.get(longIdentifier)
 
       expect(result).toBeDefined()
     })
 
-    await it('should handle concurrent operations', async () => {
+    await it('should handle concurrent operations', () => {
       // Concurrent sets
-      await Promise.all([
-        cache.set('token-1', mockResult),
-        cache.set('token-2', mockResult),
-        cache.set('token-3', mockResult),
-      ])
+      cache.set('token-1', mockResult)
+      cache.set('token-2', mockResult)
+      cache.set('token-3', mockResult)
 
       // Concurrent gets
-      const results = await Promise.all([
-        cache.get('token-1'),
-        cache.get('token-2'),
-        cache.get('token-3'),
-      ])
+      const results = [cache.get('token-1'), cache.get('token-2'), cache.get('token-3')]
 
       expect(results[0]).toBeDefined()
       expect(results[1]).toBeDefined()
       expect(results[2]).toBeDefined()
     })
 
-    await it('should handle zero TTL (immediate expiration)', async () => {
-      await cache.set('token', mockResult, 0)
+    await it('should handle zero TTL (immediate expiration)', () => {
+      cache.set('token', mockResult, 0)
 
-      // Should be immediately expired
-      const result = await cache.get('token')
-      expect(result).toBeUndefined()
+      const result = cache.get('token')
+      expect(result).toBeDefined()
+      expect(result?.status).toBe(AuthorizationStatus.EXPIRED)
     })
 
-    await it('should handle very large TTL values', async () => {
+    await it('should handle very large TTL values', () => {
       // 1 year TTL
-      await cache.set('token', mockResult, 31536000)
+      cache.set('token', mockResult, 31536000)
 
-      const result = await cache.get('token')
+      const result = cache.get('token')
       expect(result).toBeDefined()
     })
   })
 
   await describe('G03.FR.01.009 - Integration with Auth System', async () => {
-    await it('should cache ACCEPTED authorization results', async () => {
+    await it('should cache ACCEPTED authorization results', () => {
       const mockResult = createMockAuthorizationResult({
         method: AuthenticationMethod.REMOTE_AUTHORIZATION,
         status: AuthorizationStatus.ACCEPTED,
       })
 
-      await cache.set('valid-token', mockResult)
-      const result = await cache.get('valid-token')
+      cache.set('valid-token', mockResult)
+      const result = cache.get('valid-token')
 
       expect(result?.status).toBe(AuthorizationStatus.ACCEPTED)
       expect(result?.method).toBe(AuthenticationMethod.REMOTE_AUTHORIZATION)
     })
 
-    await it('should handle BLOCKED authorization results', async () => {
+    await it('should handle BLOCKED authorization results', () => {
       const mockResult = createMockAuthorizationResult({
         status: AuthorizationStatus.BLOCKED,
       })
 
-      await cache.set('blocked-token', mockResult)
-      const result = await cache.get('blocked-token')
+      cache.set('blocked-token', mockResult)
+      const result = cache.get('blocked-token')
 
       expect(result?.status).toBe(AuthorizationStatus.BLOCKED)
     })
 
-    await it('should preserve authorization result metadata', async () => {
+    await it('should preserve authorization result metadata', () => {
       const mockResult = createMockAuthorizationResult({
         additionalInfo: {
           customField: 'test-value',
@@ -550,25 +553,346 @@ await describe('InMemoryAuthCache - G03.FR.01 Conformance', async () => {
         status: AuthorizationStatus.ACCEPTED,
       })
 
-      await cache.set('token', mockResult)
-      const result = await cache.get('token')
+      cache.set('token', mockResult)
+      const result = cache.get('token')
 
       expect(result?.additionalInfo?.customField).toBe('test-value')
       expect(result?.additionalInfo?.reason).toBe('test-reason')
     })
 
-    await it('should handle offline authorization results', async () => {
+    await it('should handle offline authorization results', () => {
       const mockResult = createMockAuthorizationResult({
         isOffline: true,
         method: AuthenticationMethod.OFFLINE_FALLBACK,
         status: AuthorizationStatus.ACCEPTED,
       })
 
-      await cache.set('offline-token', mockResult)
-      const result = await cache.get('offline-token')
+      cache.set('offline-token', mockResult)
+      const result = cache.get('offline-token')
 
       expect(result?.isOffline).toBe(true)
       expect(result?.method).toBe(AuthenticationMethod.OFFLINE_FALLBACK)
     })
   })
+
+  await describe('G03.FR.01.T5 - Status-aware Eviction (R2)', async () => {
+    await it('G03.FR.01.T5.01 - should evict non-valid entry before valid one', () => {
+      const lruCache = new InMemoryAuthCache({
+        defaultTtl: 3600,
+        maxEntries: 2,
+        rateLimit: { enabled: false },
+      })
+
+      const accepted = createMockAuthorizationResult({ status: AuthorizationStatus.ACCEPTED })
+      const blocked = createMockAuthorizationResult({ status: AuthorizationStatus.BLOCKED })
+
+      lruCache.set('valid-token', accepted)
+      lruCache.set('blocked-token', blocked)
+
+      // Access valid-token to make it most recently used
+      lruCache.get('valid-token')
+
+      // Trigger eviction — blocked-token should be evicted (non-valid, even though valid-token is older in set order)
+      lruCache.set('new-token', accepted)
+
+      const validResult = lruCache.get('valid-token')
+      const blockedResult = lruCache.get('blocked-token')
+      const newResult = lruCache.get('new-token')
+
+      expect(validResult).toBeDefined()
+      expect(validResult?.status).toBe(AuthorizationStatus.ACCEPTED)
+      expect(blockedResult).toBeUndefined()
+      expect(newResult).toBeDefined()
+    })
+
+    await it('G03.FR.01.T5.02 - should fall back to LRU when all entries are ACCEPTED', () => {
+      const lruCache = new InMemoryAuthCache({
+        defaultTtl: 3600,
+        maxEntries: 2,
+        rateLimit: { enabled: false },
+      })
+
+      const accepted = createMockAuthorizationResult({ status: AuthorizationStatus.ACCEPTED })
+
+      lruCache.set('token-a', accepted)
+      lruCache.set('token-b', accepted)
+
+      // Access token-b to make it most recently used
+      lruCache.get('token-b')
+
+      // Trigger eviction — token-a should be evicted (LRU)
+      lruCache.set('token-c', accepted)
+
+      const resultA = lruCache.get('token-a')
+      const resultB = lruCache.get('token-b')
+      const resultC = lruCache.get('token-c')
+
+      expect(resultA).toBeUndefined()
+      expect(resultB).toBeDefined()
+      expect(resultC).toBeDefined()
+    })
+  })
+
+  await describe('G03.FR.01.T6 - TTL Reset on Access (R16, R5)', async () => {
+    await it('G03.FR.01.T6.01 - should reset TTL on cache hit', async t => {
+      await withMockTimers(t, ['Date'], () => {
+        const shortCache = new InMemoryAuthCache({
+          defaultTtl: 0.15, // 150ms
+          maxEntries: 10,
+          rateLimit: { enabled: false },
+        })
+
+        const accepted = createMockAuthorizationResult({ status: AuthorizationStatus.ACCEPTED })
+        shortCache.set('token', accepted)
+
+        t.mock.timers.tick(50)
+        const midResult = shortCache.get('token')
+        expect(midResult).toBeDefined()
+        expect(midResult?.status).toBe(AuthorizationStatus.ACCEPTED)
+
+        t.mock.timers.tick(50)
+        const lateResult = shortCache.get('token')
+        expect(lateResult).toBeDefined()
+        expect(lateResult?.status).toBe(AuthorizationStatus.ACCEPTED)
+      })
+    })
+
+    await it('G03.FR.01.T6.02 - should not reset TTL when max absolute lifetime exceeded', async t => {
+      await withMockTimers(t, ['Date'], () => {
+        const shortCache = new InMemoryAuthCache({
+          defaultTtl: 0.15, // 150ms
+          maxEntries: 10,
+          rateLimit: { enabled: false },
+        })
+
+        const accepted = createMockAuthorizationResult({ status: AuthorizationStatus.ACCEPTED })
+        shortCache.set('token', accepted)
+
+        t.mock.timers.tick(200)
+        const result = shortCache.get('token')
+        expect(result).toBeDefined()
+        expect(result?.status).toBe(AuthorizationStatus.EXPIRED)
+      })
+    })
+  })
+
+  await describe('G03.FR.01.T7 - Expired Entry Lifecycle (R10)', async () => {
+    await it('G03.FR.01.T7.01 - should return EXPIRED status instead of undefined', async t => {
+      await withMockTimers(t, ['Date'], () => {
+        const shortCache = new InMemoryAuthCache({
+          defaultTtl: 0.001,
+          maxEntries: 10,
+          rateLimit: { enabled: false },
+        })
+
+        const accepted = createMockAuthorizationResult({ status: AuthorizationStatus.ACCEPTED })
+        shortCache.set('token', accepted)
+
+        t.mock.timers.tick(10)
+
+        const result = shortCache.get('token')
+        expect(result).toBeDefined()
+        expect(result?.status).toBe(AuthorizationStatus.EXPIRED)
+      })
+    })
+
+    await it('G03.FR.01.T7.02 - should keep expired entry in cache after first access', async t => {
+      await withMockTimers(t, ['Date'], () => {
+        const shortCache = new InMemoryAuthCache({
+          defaultTtl: 0.001,
+          maxEntries: 10,
+          rateLimit: { enabled: false },
+        })
+
+        const accepted = createMockAuthorizationResult({ status: AuthorizationStatus.ACCEPTED })
+        shortCache.set('token', accepted)
+
+        t.mock.timers.tick(10)
+
+        // First access transitions to EXPIRED
+        const first = shortCache.get('token')
+        expect(first?.status).toBe(AuthorizationStatus.EXPIRED)
+
+        // Second access should still return the entry (now with refreshed TTL as EXPIRED)
+        const second = shortCache.get('token')
+        expect(second).toBeDefined()
+        expect(second?.status).toBe(AuthorizationStatus.EXPIRED)
+
+        const stats = shortCache.getStats()
+        expect(stats.totalEntries).toBe(1)
+      })
+    })
+  })
+
+  await describe('Helper - truncateId', async () => {
+    await it('should return identifier unchanged when short', () => {
+      const result = truncateId('ABCD')
+      expect(result).toBe('ABCD')
+    })
+
+    await it('should truncate long identifier with ellipsis', () => {
+      const result = truncateId('ABCDEFGHIJKLMNOP')
+      expect(result).toBe('ABCDEFGH...')
+    })
+  })
+
+  await describe('G03.FR.01.T10 - Periodic Cleanup and Rate Limit Bounds (R8, R9)', async () => {
+    await it('G03.FR.01.T10.01 - dispose() stops the cleanup interval and is safe to call twice', () => {
+      const cleanupCache = new InMemoryAuthCache({
+        cleanupIntervalSeconds: 1,
+        defaultTtl: 3600,
+        maxEntries: 10,
+        rateLimit: { enabled: false },
+      })
+      expect(() => {
+        cleanupCache.dispose()
+      }).not.toThrow()
+      expect(() => {
+        cleanupCache.dispose()
+      }).not.toThrow()
+    })
+
+    await it('G03.FR.01.T10.02 - cleanup interval is not started when cleanupIntervalSeconds is 0', () => {
+      const noCleanupCache = new InMemoryAuthCache({
+        cleanupIntervalSeconds: 0,
+        defaultTtl: 3600,
+        maxEntries: 10,
+        rateLimit: { enabled: false },
+      })
+      expect(noCleanupCache.hasCleanupInterval()).toBe(false)
+      noCleanupCache.dispose()
+    })
+
+    await it('G03.FR.01.T10.03 - runCleanup removes expired entries (two-phase)', async t => {
+      await withMockTimers(t, ['Date'], () => {
+        const cleanupCache = new InMemoryAuthCache({
+          cleanupIntervalSeconds: 0,
+          defaultTtl: 1,
+          maxEntries: 10,
+          rateLimit: { enabled: false },
+        })
+
+        cleanupCache.set('id-1', createMockAuthorizationResult())
+        cleanupCache.set('id-2', createMockAuthorizationResult())
+
+        const statsBefore = cleanupCache.getStats()
+        expect(statsBefore.totalEntries).toBe(2)
+
+        t.mock.timers.tick(1100)
+
+        cleanupCache.runCleanup()
+
+        const statsAfterFirst = cleanupCache.getStats()
+        expect(statsAfterFirst.totalEntries).toBe(2)
+        expect(statsAfterFirst.expiredEntries).toBe(2)
+
+        t.mock.timers.tick(1100)
+
+        cleanupCache.runCleanup()
+
+        const statsAfterSecond = cleanupCache.getStats()
+        expect(statsAfterSecond.totalEntries).toBe(0)
+        expect(statsAfterSecond.expiredEntries).toBe(2)
+        cleanupCache.dispose()
+      })
+    })
+
+    await it('G03.FR.01.T10.04 - rateLimits map is bounded to maxEntries * 2', () => {
+      const boundedCache = new InMemoryAuthCache({
+        cleanupIntervalSeconds: 0,
+        defaultTtl: 3600,
+        maxEntries: 2,
+        rateLimit: { enabled: true, maxRequests: 100 },
+      })
+
+      for (let i = 0; i < 10; i++) {
+        boundedCache.get(`identifier-${String(i)}`)
+      }
+
+      const rateLimitsSize = boundedCache.getStats().rateLimit.rateLimitedIdentifiers
+      expect(rateLimitsSize).toBeLessThanOrEqual(4)
+      boundedCache.dispose()
+    })
+  })
+
+  await describe('G03.FR.01.T11 - Stats preservation and resetStats() (R14, R15)', async () => {
+    await it('G03.FR.01.T11.01 - clear() preserves hits, misses, evictions', () => {
+      const statsCache = new InMemoryAuthCache({
+        cleanupIntervalSeconds: 0,
+        defaultTtl: 3600,
+        maxEntries: 2,
+        rateLimit: { enabled: false },
+      })
+      const result = createMockAuthorizationResult()
+      statsCache.set('id-a', result)
+      statsCache.set('id-b', result)
+      statsCache.set('id-c', result) // triggers eviction
+      statsCache.get('id-b') // hit
+      statsCache.get('id-miss') // miss
+
+      const before = statsCache.getStats()
+      expect(before.evictions).toBeGreaterThan(0)
+      expect(before.hits).toBeGreaterThan(0)
+      expect(before.misses).toBeGreaterThan(0)
+
+      statsCache.clear()
+
+      const after = statsCache.getStats()
+      expect(after.evictions).toBe(before.evictions)
+      expect(after.hits).toBe(before.hits)
+      expect(after.misses).toBe(before.misses)
+      expect(after.totalEntries).toBe(0)
+      statsCache.dispose()
+    })
+
+    await it('G03.FR.01.T11.02 - resetStats() zeroes all stat fields', () => {
+      const statsCache = new InMemoryAuthCache({
+        cleanupIntervalSeconds: 0,
+        defaultTtl: 3600,
+        maxEntries: 2,
+        rateLimit: { enabled: false },
+      })
+      const result = createMockAuthorizationResult()
+      statsCache.set('id-a', result)
+      statsCache.get('id-a') // hit
+      statsCache.get('id-miss') // miss
+
+      const before = statsCache.getStats()
+      expect(before.hits).toBeGreaterThan(0)
+      expect(before.misses).toBeGreaterThan(0)
+
+      statsCache.resetStats()
+
+      const after = statsCache.getStats()
+      expect(after.hits).toBe(0)
+      expect(after.misses).toBe(0)
+      expect(after.evictions).toBe(0)
+      statsCache.dispose()
+    })
+
+    await it('G03.FR.01.T11.03 - resetStats() after clear() zeroes stats correctly', () => {
+      const statsCache = new InMemoryAuthCache({
+        cleanupIntervalSeconds: 0,
+        defaultTtl: 3600,
+        maxEntries: 10,
+        rateLimit: { enabled: false },
+      })
+      const result = createMockAuthorizationResult()
+      statsCache.set('id-a', result)
+      statsCache.get('id-a') // hit
+
+      statsCache.clear() // clears entries but preserves stats
+
+      const afterClear = statsCache.getStats()
+      expect(afterClear.hits).toBeGreaterThan(0) // stats preserved
+      expect(afterClear.totalEntries).toBe(0) // entries gone
+
+      statsCache.resetStats() // now zero out
+
+      const afterReset = statsCache.getStats()
+      expect(afterReset.hits).toBe(0)
+      expect(afterReset.misses).toBe(0)
+      statsCache.dispose()
+    })
+  })
 })
index 60470de0a9dfce2b3ecaca1aa19a6c6d0cb7d62d..4f204b88753628d88a1f7adf0b6dbfc1b71a669e 100644 (file)
@@ -7,6 +7,7 @@ import { expect } from '@std/expect'
 import type { ChargingStation } from '../../../../../src/charging-station/ChargingStation.js'
 import type {
   AuthCache,
+  LocalAuthEntry,
   LocalAuthListManager,
   OCPPAuthAdapter,
   OCPPAuthService,
@@ -110,31 +111,42 @@ export const createMockAuthorizationResult = (
  */
 export const createMockAuthService = (overrides?: Partial<OCPPAuthService>): OCPPAuthService =>
   ({
-    authorize: () =>
-      Promise.resolve({
-        expiresAt: new Date(Date.now() + 3600000),
-        isOffline: false,
-        method: AuthenticationMethod.LOCAL_LIST,
-        status: AuthorizationStatus.ACCEPTED,
-        timestamp: new Date(),
+    authorize: (_request: AuthRequest) =>
+      new Promise<AuthorizationResult>(resolve => {
+        resolve(
+          createMockAuthorizationResult({
+            method: AuthenticationMethod.LOCAL_LIST,
+          })
+        )
       }),
-    clearCache: () => Promise.resolve(),
+    clearCache: () => {
+      /* empty */
+    },
     getConfiguration: () => ({}) as AuthConfiguration,
     getStats: () =>
-      Promise.resolve({
-        avgResponseTime: 0,
-        cacheHitRate: 0,
-        failedAuth: 0,
-        lastUpdated: new Date(),
-        localUsageRate: 1,
-        remoteSuccessRate: 0,
-        successfulAuth: 0,
-        totalRequests: 0,
+      new Promise<Record<string, unknown>>(resolve => {
+        resolve({
+          avgResponseTime: 0,
+          cacheHitRate: 0,
+          failedAuth: 0,
+          lastUpdated: new Date(),
+          localUsageRate: 1,
+          remoteSuccessRate: 0,
+          successfulAuth: 0,
+          totalRequests: 0,
+        })
+      }),
+    invalidateCache: () => {
+      /* empty */
+    },
+    isLocallyAuthorized: (_identifier: UnifiedIdentifier, _connectorId?: number) =>
+      new Promise<AuthorizationResult | undefined>(resolve => {
+        resolve(undefined)
       }),
-    invalidateCache: () => Promise.resolve(),
-    isLocallyAuthorized: () => Promise.resolve(undefined),
-    testConnectivity: () => Promise.resolve(true),
-    updateConfiguration: () => Promise.resolve(),
+    testConnectivity: () => true,
+    updateConfiguration: () => {
+      /* empty */
+    },
     ...overrides,
   }) as OCPPAuthService
 
@@ -145,23 +157,28 @@ export const createMockAuthService = (overrides?: Partial<OCPPAuthService>): OCP
 /**
  * Create a mock AuthCache for testing.
  * @param overrides - Partial AuthCache methods to override defaults
- * @returns Mock AuthCache with stubbed async methods
+ * @returns Mock AuthCache with stubbed methods
  */
 export const createMockAuthCache = (overrides?: Partial<AuthCache>): AuthCache => ({
-  clear: async () => Promise.resolve(),
-  get: async (_key: string) => Promise.resolve(undefined),
-  getStats: async () =>
-    Promise.resolve({
-      evictions: 0,
-      expiredEntries: 0,
-      hitRate: 0,
-      hits: 0,
-      memoryUsage: 0,
-      misses: 0,
-      totalEntries: 0,
-    }),
-  remove: async (_key: string) => Promise.resolve(),
-  set: async (_key: string, _value: unknown, _ttl?: number) => Promise.resolve(),
+  clear: () => {
+    /* empty */
+  },
+  get: (_key: string) => undefined,
+  getStats: () => ({
+    evictions: 0,
+    expiredEntries: 0,
+    hitRate: 0,
+    hits: 0,
+    memoryUsage: 0,
+    misses: 0,
+    totalEntries: 0,
+  }),
+  remove: (_key: string) => {
+    /* empty */
+  },
+  set: (_key: string, _value: unknown, _ttl?: number) => {
+    /* empty */
+  },
   ...overrides,
 })
 
@@ -179,12 +196,14 @@ export const createMockOCPPAdapter = (
   ocppVersion: OCPPVersion,
   overrides?: Partial<OCPPAuthAdapter>
 ): OCPPAuthAdapter => ({
-  authorizeRemote: async (_identifier: UnifiedIdentifier) =>
-    Promise.resolve(
-      createMockAuthorizationResult({
-        method: AuthenticationMethod.REMOTE_AUTHORIZATION,
-      })
-    ),
+  authorizeRemote: (_identifier: UnifiedIdentifier) =>
+    new Promise<AuthorizationResult>(resolve => {
+      resolve(
+        createMockAuthorizationResult({
+          method: AuthenticationMethod.REMOTE_AUTHORIZATION,
+        })
+      )
+    }),
   convertFromUnifiedIdentifier: (identifier: UnifiedIdentifier) =>
     ocppVersion === OCPPVersion.VERSION_16
       ? identifier.value
@@ -198,9 +217,9 @@ export const createMockOCPPAdapter = (
         : ((identifier as { idToken?: string }).idToken ?? 'unknown'),
   }),
   getConfigurationSchema: () => ({}),
-  isRemoteAvailable: async () => Promise.resolve(true),
+  isRemoteAvailable: () => true,
   ocppVersion,
-  validateConfiguration: async (_config: AuthConfiguration) => Promise.resolve(true),
+  validateConfiguration: (_config: AuthConfiguration) => true,
   ...overrides,
 })
 // ============================================================================
@@ -284,7 +303,10 @@ export const createMockAuthServiceTestStation = (
     idTagLocalAuthorized: () => false,
     isConnected: () => true,
     logPrefix: () => `[TEST-CS-${id}]`,
-    sendRequest: () => Promise.resolve({}),
+    sendRequest: () =>
+      new Promise<Record<string, never>>(resolve => {
+        resolve({})
+      }),
     stationInfo: {
       chargingStationId: `TEST-CS-${id}`,
       hashId: `test-hash-${id}`,
@@ -304,12 +326,33 @@ export const createMockAuthServiceTestStation = (
 export const createMockLocalAuthListManager = (
   overrides?: Partial<LocalAuthListManager>
 ): LocalAuthListManager => ({
-  addEntry: async () => Promise.resolve(),
-  clearAll: async () => Promise.resolve(),
-  getAllEntries: async () => Promise.resolve([]),
-  getEntry: async () => Promise.resolve(undefined),
-  getVersion: async () => Promise.resolve(1),
-  removeEntry: async () => Promise.resolve(),
-  updateVersion: async () => Promise.resolve(),
+  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()
+    }),
   ...overrides,
 })
index 9553481de545d6d39bac3b79647f670c50e57b63..7f437325e759beeafe8717c70e22eb42c771c6eb 100644 (file)
@@ -9,6 +9,7 @@ import type { ChargingStation } from '../../../../../src/charging-station/Chargi
 import type { OCPPAuthService } from '../../../../../src/charging-station/ocpp/auth/interfaces/OCPPAuthService.js'
 
 import { OCPPAuthServiceImpl } from '../../../../../src/charging-station/ocpp/auth/services/OCPPAuthServiceImpl.js'
+import { LocalAuthStrategy } from '../../../../../src/charging-station/ocpp/auth/strategies/LocalAuthStrategy.js'
 import {
   AuthContext,
   AuthenticationMethod,
@@ -74,10 +75,10 @@ await describe('OCPPAuthServiceImpl', async () => {
       mockStation = createMockAuthServiceTestStation('updateConfig')
     })
 
-    await it('should update configuration', async () => {
+    await it('should update configuration', () => {
       const authService = new OCPPAuthServiceImpl(mockStation)
 
-      await authService.updateConfiguration({
+      authService.updateConfiguration({
         authorizationTimeout: 60,
         localAuthListEnabled: false,
       })
@@ -131,9 +132,9 @@ await describe('OCPPAuthServiceImpl', async () => {
       mockStation = createMockAuthServiceTestStation('connectivity')
     })
 
-    await it('should test remote connectivity', async () => {
+    await it('should test remote connectivity', () => {
       const authService = new OCPPAuthServiceImpl(mockStation)
-      const isConnected = await authService.testConnectivity()
+      const isConnected = authService.testConnectivity()
 
       expect(typeof isConnected).toBe('boolean')
     })
@@ -146,10 +147,12 @@ await describe('OCPPAuthServiceImpl', async () => {
       mockStation = createMockAuthServiceTestStation('clearCache')
     })
 
-    await it('should clear authorization cache', async () => {
+    await it('should clear authorization cache', () => {
       const authService = new OCPPAuthServiceImpl(mockStation)
 
-      await expect(authService.clearCache()).resolves.toBeUndefined()
+      expect(() => {
+        authService.clearCache()
+      }).not.toThrow()
     })
   })
 
@@ -160,7 +163,7 @@ await describe('OCPPAuthServiceImpl', async () => {
       mockStation = createMockAuthServiceTestStation('invalidateCache')
     })
 
-    await it('should invalidate cache for specific identifier', async () => {
+    await it('should invalidate cache for specific identifier', () => {
       const authService = new OCPPAuthServiceImpl(mockStation)
 
       const identifier: UnifiedIdentifier = {
@@ -169,7 +172,9 @@ await describe('OCPPAuthServiceImpl', async () => {
         value: 'TAG_TO_INVALIDATE',
       }
 
-      await expect(authService.invalidateCache(identifier)).resolves.toBeUndefined()
+      expect(() => {
+        authService.invalidateCache(identifier)
+      }).not.toThrow()
     })
   })
 
@@ -239,7 +244,7 @@ await describe('OCPPAuthServiceImpl', async () => {
       })
 
       expect(result.status).toBe(AuthorizationStatus.INVALID)
-      expect(result.method).toBe(AuthenticationMethod.LOCAL_LIST)
+      expect(result.method).toBe(AuthenticationMethod.NONE)
     })
   })
 
@@ -415,4 +420,24 @@ await describe('OCPPAuthServiceImpl', async () => {
       expect(result).toBeDefined()
     })
   })
+
+  await describe('G03.FR.01.100 - Cache Wiring', async () => {
+    let mockStation: ChargingStation
+
+    beforeEach(() => {
+      mockStation = createMockAuthServiceTestStation('cache-wiring')
+    })
+
+    await it('G03.FR.01.101 - local strategy has a defined authCache after initialization', async () => {
+      const authService = new OCPPAuthServiceImpl(mockStation)
+      await authService.initialize()
+
+      const localStrategy = authService.getStrategy('local')
+      expect(localStrategy).toBeDefined()
+      expect(localStrategy).toBeInstanceOf(LocalAuthStrategy)
+
+      const local = localStrategy as LocalAuthStrategy
+      expect(local.getAuthCache()).toBeDefined()
+    })
+  })
 })
index 13df673b64ab3d2058bda97cc543045dc229ad06..c7189b5ca9f264a50b9f6dad2de9d4039b8227d0 100644 (file)
@@ -11,6 +11,7 @@ import type { OCPPAuthAdapter } from '../../../../../src/charging-station/ocpp/a
 import { CertificateAuthStrategy } from '../../../../../src/charging-station/ocpp/auth/strategies/CertificateAuthStrategy.js'
 import {
   AuthenticationMethod,
+  type AuthorizationResult,
   AuthorizationStatus,
   IdentifierType,
 } from '../../../../../src/charging-station/ocpp/auth/types/AuthTypes.js'
@@ -38,12 +39,14 @@ await describe('CertificateAuthStrategy', async () => {
     } as unknown as ChargingStation
 
     mockOCPP20Adapter = createMockOCPPAdapter(OCPPVersion.VERSION_20, {
-      authorizeRemote: async () =>
-        Promise.resolve(
-          createMockAuthorizationResult({
-            method: AuthenticationMethod.CERTIFICATE_BASED,
-          })
-        ),
+      authorizeRemote: () =>
+        new Promise<AuthorizationResult>(resolve => {
+          resolve(
+            createMockAuthorizationResult({
+              method: AuthenticationMethod.CERTIFICATE_BASED,
+            })
+          )
+        }),
       convertToUnifiedIdentifier: identifier => ({
         ocppVersion: OCPPVersion.VERSION_20,
         type: IdentifierType.CERTIFICATE,
@@ -69,21 +72,25 @@ await describe('CertificateAuthStrategy', async () => {
   })
 
   await describe('initialize', async () => {
-    await it('should initialize successfully when certificate auth is enabled', async () => {
+    await it('should initialize successfully when certificate auth is enabled', () => {
       const config = createTestAuthConfig({ certificateAuthEnabled: true })
-      await expect(strategy.initialize(config)).resolves.toBeUndefined()
+      expect(() => {
+        strategy.initialize(config)
+      }).not.toThrow()
     })
 
-    await it('should handle disabled certificate auth gracefully', async () => {
+    await it('should handle disabled certificate auth gracefully', () => {
       const config = createTestAuthConfig({ certificateAuthEnabled: false })
-      await expect(strategy.initialize(config)).resolves.toBeUndefined()
+      expect(() => {
+        strategy.initialize(config)
+      }).not.toThrow()
     })
   })
 
   await describe('canHandle', async () => {
-    beforeEach(async () => {
+    beforeEach(() => {
       const config = createTestAuthConfig({ certificateAuthEnabled: true })
-      await strategy.initialize(config)
+      strategy.initialize(config)
     })
 
     await it('should return true for certificate identifiers with OCPP 2.0', () => {
@@ -165,9 +172,9 @@ await describe('CertificateAuthStrategy', async () => {
   })
 
   await describe('authenticate', async () => {
-    beforeEach(async () => {
+    beforeEach(() => {
       const config = createTestAuthConfig({ certificateAuthEnabled: true })
-      await strategy.initialize(config)
+      strategy.initialize(config)
     })
 
     await it('should authenticate valid test certificate', async () => {
@@ -299,8 +306,8 @@ await describe('CertificateAuthStrategy', async () => {
   })
 
   await describe('getStats', async () => {
-    await it('should return strategy statistics', async () => {
-      const stats = await strategy.getStats()
+    await it('should return strategy statistics', () => {
+      const stats = strategy.getStats()
 
       expect(stats.isInitialized).toBe(false)
       expect(stats.totalRequests).toBe(0)
@@ -309,7 +316,7 @@ await describe('CertificateAuthStrategy', async () => {
     })
 
     await it('should update stats after authentication', async () => {
-      await strategy.initialize(createTestAuthConfig({ certificateAuthEnabled: true }))
+      strategy.initialize(createTestAuthConfig({ certificateAuthEnabled: true }))
 
       const config = createTestAuthConfig({ certificateAuthEnabled: true })
       const request = createMockAuthRequest({
@@ -328,18 +335,18 @@ await describe('CertificateAuthStrategy', async () => {
 
       await strategy.authenticate(request, config)
 
-      const stats = await strategy.getStats()
+      const stats = strategy.getStats()
       expect(stats.totalRequests).toBe(1)
       expect(stats.successfulAuths).toBe(1)
     })
   })
 
   await describe('cleanup', async () => {
-    await it('should reset strategy state', async () => {
-      await strategy.initialize(createTestAuthConfig({ certificateAuthEnabled: true }))
+    await it('should reset strategy state', () => {
+      strategy.initialize(createTestAuthConfig({ certificateAuthEnabled: true }))
 
-      await strategy.cleanup()
-      const stats = await strategy.getStats()
+      strategy.cleanup()
+      const stats = strategy.getStats()
       expect(stats.isInitialized).toBe(false)
     })
   })
index 8d891f5d097779cf06a807fdc3e5990726533f90..9e5aa1398f3a246fa0947a4779827ca4e157019a 100644 (file)
@@ -7,6 +7,7 @@ import { afterEach, beforeEach, describe, it } from 'node:test'
 
 import type {
   AuthCache,
+  LocalAuthEntry,
   LocalAuthListManager,
 } from '../../../../../src/charging-station/ocpp/auth/interfaces/OCPPAuthService.js'
 
@@ -56,12 +57,14 @@ await describe('LocalAuthStrategy', async () => {
   })
 
   await describe('initialize', async () => {
-    await it('should initialize successfully with valid config', async () => {
+    await it('should initialize successfully with valid config', () => {
       const config = createTestAuthConfig({
         authorizationCacheEnabled: true,
         localAuthListEnabled: true,
       })
-      await expect(strategy.initialize(config)).resolves.toBeUndefined()
+      expect(() => {
+        strategy.initialize(config)
+      }).not.toThrow()
     })
   })
 
@@ -92,22 +95,24 @@ await describe('LocalAuthStrategy', async () => {
   })
 
   await describe('authenticate', async () => {
-    beforeEach(async () => {
+    beforeEach(() => {
       const config = createTestAuthConfig({
         authorizationCacheEnabled: true,
         localAuthListEnabled: true,
         offlineAuthorizationEnabled: true,
       })
-      await strategy.initialize(config)
+      strategy.initialize(config)
     })
 
     await it('should authenticate using local auth list', async () => {
-      mockLocalAuthListManager.getEntry = async () =>
-        await Promise.resolve({
-          expiryDate: new Date(Date.now() + 86400000),
-          identifier: 'LOCAL_TAG',
-          metadata: { source: 'local' },
-          status: 'accepted',
+      mockLocalAuthListManager.getEntry = () =>
+        new Promise<LocalAuthEntry | undefined>(resolve => {
+          resolve({
+            expiryDate: new Date(Date.now() + 86400000),
+            identifier: 'LOCAL_TAG',
+            metadata: { source: 'local' },
+            status: 'accepted',
+          })
         })
 
       const config = createTestAuthConfig({
@@ -130,14 +135,12 @@ await describe('LocalAuthStrategy', async () => {
     })
 
     await it('should authenticate using cache', async () => {
-      mockAuthCache.get = async () =>
-        await Promise.resolve(
-          createMockAuthorizationResult({
-            cacheTtl: 300,
-            method: AuthenticationMethod.CACHE,
-            timestamp: new Date(Date.now() - 60000),
-          })
-        )
+      mockAuthCache.get = () =>
+        createMockAuthorizationResult({
+          cacheTtl: 300,
+          method: AuthenticationMethod.CACHE,
+          timestamp: new Date(Date.now() - 60000),
+        })
 
       const config = createTestAuthConfig({ authorizationCacheEnabled: true })
       const request = createMockAuthRequest({
@@ -191,10 +194,9 @@ await describe('LocalAuthStrategy', async () => {
   })
 
   await describe('cacheResult', async () => {
-    await it('should cache authorization result', async () => {
+    await it('should cache authorization result', () => {
       let cachedValue: undefined | { key: string; ttl?: number; value: unknown }
-      // eslint-disable-next-line @typescript-eslint/require-await
-      mockAuthCache.set = async (key: string, value, ttl?: number) => {
+      mockAuthCache.set = (key: string, value, ttl?: number) => {
         cachedValue = { key, ttl, value }
       }
 
@@ -202,13 +204,12 @@ await describe('LocalAuthStrategy', async () => {
         method: AuthenticationMethod.REMOTE_AUTHORIZATION,
       })
 
-      await strategy.cacheResult('TEST_TAG', result, 300)
+      strategy.cacheResult('TEST_TAG', result, 300)
       expect(cachedValue).toBeDefined()
     })
 
-    await it('should handle cache errors gracefully', async () => {
-      // eslint-disable-next-line @typescript-eslint/require-await
-      mockAuthCache.set = async () => {
+    await it('should handle cache errors gracefully', () => {
+      mockAuthCache.set = () => {
         throw new Error('Cache error')
       }
 
@@ -216,45 +217,50 @@ await describe('LocalAuthStrategy', async () => {
         method: AuthenticationMethod.REMOTE_AUTHORIZATION,
       })
 
-      await expect(strategy.cacheResult('TEST_TAG', result)).resolves.toBeUndefined()
+      expect(() => {
+        strategy.cacheResult('TEST_TAG', result)
+      }).not.toThrow()
     })
   })
 
   await describe('invalidateCache', async () => {
-    await it('should remove entry from cache', async () => {
+    await it('should remove entry from cache', () => {
       let removedKey: string | undefined
-      // eslint-disable-next-line @typescript-eslint/require-await
-      mockAuthCache.remove = async (key: string) => {
+      mockAuthCache.remove = (key: string) => {
         removedKey = key
       }
 
-      await strategy.invalidateCache('TEST_TAG')
+      strategy.invalidateCache('TEST_TAG')
       expect(removedKey).toBe('TEST_TAG')
     })
   })
 
   await describe('isInLocalList', async () => {
     await it('should return true when identifier is in local list', async () => {
-      mockLocalAuthListManager.getEntry = async () =>
-        await Promise.resolve({
-          identifier: 'LOCAL_TAG',
-          status: 'accepted',
+      mockLocalAuthListManager.getEntry = () =>
+        new Promise<LocalAuthEntry | undefined>(resolve => {
+          resolve({
+            identifier: 'LOCAL_TAG',
+            status: 'accepted',
+          })
         })
 
       await expect(strategy.isInLocalList('LOCAL_TAG')).resolves.toBe(true)
     })
 
     await it('should return false when identifier is not in local list', async () => {
-      // eslint-disable-next-line @typescript-eslint/require-await
-      mockLocalAuthListManager.getEntry = async () => undefined
+      mockLocalAuthListManager.getEntry = () =>
+        new Promise<LocalAuthEntry | undefined>(resolve => {
+          resolve(undefined)
+        })
 
       await expect(strategy.isInLocalList('UNKNOWN_TAG')).resolves.toBe(false)
     })
   })
 
   await describe('getStats', async () => {
-    await it('should return strategy statistics', async () => {
-      const stats = await strategy.getStats()
+    await it('should return strategy statistics', () => {
+      const stats = strategy.getStats()
 
       expect(stats.totalRequests).toBe(0)
       expect(stats.cacheHits).toBe(0)
@@ -266,9 +272,9 @@ await describe('LocalAuthStrategy', async () => {
   })
 
   await describe('cleanup', async () => {
-    await it('should reset strategy state', async () => {
-      await strategy.cleanup()
-      const stats = await strategy.getStats()
+    await it('should reset strategy state', () => {
+      strategy.cleanup()
+      const stats = strategy.getStats()
       expect(stats.isInitialized).toBe(false)
     })
   })
index d75964a4bcde3c2ddf106b754315872a7ebd5aa6..f9c3204e3e337d86236e9f62132f5dec46a10e81 100644 (file)
@@ -7,12 +7,15 @@ import { afterEach, beforeEach, describe, it } from 'node:test'
 
 import type {
   AuthCache,
+  LocalAuthEntry,
+  LocalAuthListManager,
   OCPPAuthAdapter,
 } from '../../../../../src/charging-station/ocpp/auth/interfaces/OCPPAuthService.js'
 
 import { RemoteAuthStrategy } from '../../../../../src/charging-station/ocpp/auth/strategies/RemoteAuthStrategy.js'
 import {
   AuthenticationMethod,
+  type AuthorizationResult,
   AuthorizationStatus,
   IdentifierType,
 } from '../../../../../src/charging-station/ocpp/auth/types/AuthTypes.js'
@@ -20,8 +23,10 @@ import { OCPPVersion } from '../../../../../src/types/ocpp/OCPPVersion.js'
 import { standardCleanup } from '../../../../helpers/TestLifecycleHelpers.js'
 import {
   createMockAuthCache,
+  createMockAuthorizationResult,
   createMockAuthRequest,
   createMockIdentifier,
+  createMockLocalAuthListManager,
   createMockOCPPAdapter,
   createTestAuthConfig,
 } from '../helpers/MockFactories.js'
@@ -29,11 +34,13 @@ import {
 await describe('RemoteAuthStrategy', async () => {
   let strategy: RemoteAuthStrategy
   let mockAuthCache: AuthCache
+  let mockLocalAuthListManager: LocalAuthListManager
   let mockOCPP16Adapter: OCPPAuthAdapter
   let mockOCPP20Adapter: OCPPAuthAdapter
 
   beforeEach(() => {
     mockAuthCache = createMockAuthCache()
+    mockLocalAuthListManager = createMockLocalAuthListManager()
     mockOCPP16Adapter = createMockOCPPAdapter(OCPPVersion.VERSION_16)
     mockOCPP20Adapter = createMockOCPPAdapter(OCPPVersion.VERSION_20)
 
@@ -41,7 +48,7 @@ await describe('RemoteAuthStrategy', async () => {
     adapters.set(OCPPVersion.VERSION_16, mockOCPP16Adapter)
     adapters.set(OCPPVersion.VERSION_20, mockOCPP20Adapter)
 
-    strategy = new RemoteAuthStrategy(adapters, mockAuthCache)
+    strategy = new RemoteAuthStrategy(adapters, mockAuthCache, mockLocalAuthListManager)
   })
 
   afterEach(() => {
@@ -62,16 +69,20 @@ await describe('RemoteAuthStrategy', async () => {
   })
 
   await describe('initialize', async () => {
-    await it('should initialize successfully with adapters', async () => {
+    await it('should initialize successfully with adapters', () => {
       const config = createTestAuthConfig({ authorizationCacheEnabled: true })
-      await expect(strategy.initialize(config)).resolves.toBeUndefined()
+      expect(() => {
+        strategy.initialize(config)
+      }).not.toThrow()
     })
 
-    await it('should validate adapter configurations', async () => {
-      mockOCPP16Adapter.validateConfiguration = async () => Promise.resolve(true)
-      mockOCPP20Adapter.validateConfiguration = async () => Promise.resolve(true)
+    await it('should validate adapter configurations', () => {
+      mockOCPP16Adapter.validateConfiguration = () => true
+      mockOCPP20Adapter.validateConfiguration = () => true
       const config = createTestAuthConfig()
-      await expect(strategy.initialize(config)).resolves.toBeUndefined()
+      expect(() => {
+        strategy.initialize(config)
+      }).not.toThrow()
     })
   })
 
@@ -88,10 +99,9 @@ await describe('RemoteAuthStrategy', async () => {
       expect(strategy.canHandle(request, config)).toBe(true)
     })
 
-    await it('should return false when localPreAuthorize is enabled', () => {
+    await it('should return false when remote authorization is explicitly disabled', () => {
       const config = createTestAuthConfig({
-        localAuthListEnabled: true,
-        localPreAuthorize: true,
+        remoteAuthorization: false,
       })
       const request = createMockAuthRequest({
         identifier: createMockIdentifier(
@@ -118,9 +128,9 @@ await describe('RemoteAuthStrategy', async () => {
   })
 
   await describe('authenticate', async () => {
-    beforeEach(async () => {
+    beforeEach(() => {
       const config = createTestAuthConfig({ authorizationCacheEnabled: true })
-      await strategy.initialize(config)
+      strategy.initialize(config)
     })
 
     await it('should authenticate using OCPP 1.6 adapter', async () => {
@@ -159,9 +169,8 @@ await describe('RemoteAuthStrategy', async () => {
 
     await it('should cache successful authorization results', async () => {
       let cachedKey: string | undefined
-      mockAuthCache.set = async (key: string) => {
+      mockAuthCache.set = (key: string) => {
         cachedKey = key
-        return Promise.resolve()
       }
 
       const config = createTestAuthConfig({
@@ -180,8 +189,123 @@ await describe('RemoteAuthStrategy', async () => {
       expect(cachedKey).toBe('CACHE_TAG')
     })
 
+    await it('G03.FR.01.T4.01 - should cache BLOCKED authorization status', async () => {
+      let cachedKey: string | undefined
+      mockAuthCache.set = (key: string) => {
+        cachedKey = key
+      }
+      mockOCPP16Adapter.authorizeRemote = () =>
+        new Promise<AuthorizationResult>(resolve => {
+          resolve(
+            createMockAuthorizationResult({
+              method: AuthenticationMethod.REMOTE_AUTHORIZATION,
+              status: AuthorizationStatus.BLOCKED,
+            })
+          )
+        })
+
+      const config = createTestAuthConfig({
+        authorizationCacheEnabled: true,
+        authorizationCacheLifetime: 300,
+      })
+      const request = createMockAuthRequest({
+        identifier: createMockIdentifier(
+          OCPPVersion.VERSION_16,
+          'BLOCKED_TAG',
+          IdentifierType.ID_TAG
+        ),
+      })
+
+      await strategy.authenticate(request, config)
+      expect(cachedKey).toBe('BLOCKED_TAG')
+    })
+
+    await it('G03.FR.01.T4.02 - should cache EXPIRED authorization status', async () => {
+      let cachedKey: string | undefined
+      mockAuthCache.set = (key: string) => {
+        cachedKey = key
+      }
+      mockOCPP16Adapter.authorizeRemote = () =>
+        new Promise<AuthorizationResult>(resolve => {
+          resolve(
+            createMockAuthorizationResult({
+              method: AuthenticationMethod.REMOTE_AUTHORIZATION,
+              status: AuthorizationStatus.EXPIRED,
+            })
+          )
+        })
+
+      const config = createTestAuthConfig({
+        authorizationCacheEnabled: true,
+        authorizationCacheLifetime: 300,
+      })
+      const request = createMockAuthRequest({
+        identifier: createMockIdentifier(
+          OCPPVersion.VERSION_16,
+          'EXPIRED_TAG',
+          IdentifierType.ID_TAG
+        ),
+      })
+
+      await strategy.authenticate(request, config)
+      expect(cachedKey).toBe('EXPIRED_TAG')
+    })
+
+    await it('G03.FR.01.T4.03 - should cache INVALID authorization status', async () => {
+      let cachedKey: string | undefined
+      mockAuthCache.set = (key: string) => {
+        cachedKey = key
+      }
+      mockOCPP16Adapter.authorizeRemote = () =>
+        new Promise<AuthorizationResult>(resolve => {
+          resolve(
+            createMockAuthorizationResult({
+              method: AuthenticationMethod.REMOTE_AUTHORIZATION,
+              status: AuthorizationStatus.INVALID,
+            })
+          )
+        })
+
+      const config = createTestAuthConfig({
+        authorizationCacheEnabled: true,
+        authorizationCacheLifetime: 300,
+      })
+      const request = createMockAuthRequest({
+        identifier: createMockIdentifier(
+          OCPPVersion.VERSION_16,
+          'INVALID_TAG',
+          IdentifierType.ID_TAG
+        ),
+      })
+
+      await strategy.authenticate(request, config)
+      expect(cachedKey).toBe('INVALID_TAG')
+    })
+
+    await it('G03.FR.01.T4.04 - should still cache ACCEPTED authorization status (regression)', async () => {
+      let cachedKey: string | undefined
+      mockAuthCache.set = (key: string) => {
+        cachedKey = key
+      }
+
+      const config = createTestAuthConfig({
+        authorizationCacheEnabled: true,
+        authorizationCacheLifetime: 300,
+      })
+      const request = createMockAuthRequest({
+        identifier: createMockIdentifier(
+          OCPPVersion.VERSION_16,
+          'ACCEPTED_TAG',
+          IdentifierType.ID_TAG
+        ),
+      })
+
+      await strategy.authenticate(request, config)
+      expect(cachedKey).toBe('ACCEPTED_TAG')
+    })
+
     await it('should return undefined when remote is unavailable', async () => {
-      mockOCPP16Adapter.isRemoteAvailable = async () => Promise.resolve(false)
+      mockOCPP16Adapter.isRemoteAvailable = () => false
 
       const config = createTestAuthConfig()
       const request = createMockAuthRequest({
@@ -227,6 +351,69 @@ await describe('RemoteAuthStrategy', async () => {
       const result = await strategy.authenticate(request, config)
       expect(result).toBeUndefined()
     })
+
+    await it('G03.FR.01.T8.01 - should not cache identifier that is in local auth list', async () => {
+      let cachedKey: string | undefined
+      mockAuthCache.set = (key: string) => {
+        cachedKey = key
+      }
+
+      mockLocalAuthListManager.getEntry = (identifier: string) =>
+        new Promise<LocalAuthEntry | undefined>(resolve => {
+          if (identifier === 'LOCAL_AUTH_TAG') {
+            resolve({
+              identifier: 'LOCAL_AUTH_TAG',
+              status: 'Active',
+            })
+          } else {
+            resolve(undefined)
+          }
+        })
+
+      const config = createTestAuthConfig({
+        authorizationCacheEnabled: true,
+        authorizationCacheLifetime: 300,
+        localAuthListEnabled: true,
+      })
+      const request = createMockAuthRequest({
+        identifier: createMockIdentifier(
+          OCPPVersion.VERSION_16,
+          'LOCAL_AUTH_TAG',
+          IdentifierType.ID_TAG
+        ),
+      })
+
+      await strategy.authenticate(request, config)
+      expect(cachedKey).toBeUndefined()
+    })
+
+    await it('G03.FR.01.T8.02 - should cache identifier that is not in local auth list', async () => {
+      let cachedKey: string | undefined
+      mockAuthCache.set = (key: string) => {
+        cachedKey = key
+      }
+
+      mockLocalAuthListManager.getEntry = () =>
+        new Promise<LocalAuthEntry | undefined>(resolve => {
+          resolve(undefined)
+        })
+
+      const config = createTestAuthConfig({
+        authorizationCacheEnabled: true,
+        authorizationCacheLifetime: 300,
+        localAuthListEnabled: true,
+      })
+      const request = createMockAuthRequest({
+        identifier: createMockIdentifier(
+          OCPPVersion.VERSION_16,
+          'REMOTE_AUTH_TAG',
+          IdentifierType.ID_TAG
+        ),
+      })
+
+      await strategy.authenticate(request, config)
+      expect(cachedKey).toBe('REMOTE_AUTH_TAG')
+    })
   })
 
   await describe('adapter management', async () => {
@@ -256,7 +443,7 @@ await describe('RemoteAuthStrategy', async () => {
 
   await describe('testConnectivity', async () => {
     await it('should test connectivity successfully', async () => {
-      await strategy.initialize(createTestAuthConfig())
+      strategy.initialize(createTestAuthConfig())
       const result = await strategy.testConnectivity()
       expect(result).toBe(true)
     })
@@ -268,10 +455,10 @@ await describe('RemoteAuthStrategy', async () => {
     })
 
     await it('should return false when all adapters unavailable', async () => {
-      mockOCPP16Adapter.isRemoteAvailable = async () => Promise.resolve(false)
-      mockOCPP20Adapter.isRemoteAvailable = async () => Promise.resolve(false)
+      mockOCPP16Adapter.isRemoteAvailable = () => false
+      mockOCPP20Adapter.isRemoteAvailable = () => false
 
-      await strategy.initialize(createTestAuthConfig())
+      strategy.initialize(createTestAuthConfig())
       const result = await strategy.testConnectivity()
       expect(result).toBe(false)
     })
@@ -290,7 +477,7 @@ await describe('RemoteAuthStrategy', async () => {
     })
 
     await it('should include adapter statistics', async () => {
-      await strategy.initialize(createTestAuthConfig())
+      strategy.initialize(createTestAuthConfig())
       const stats = await strategy.getStats()
       expect(stats.adapterStats).toBeDefined()
     })
@@ -298,7 +485,7 @@ await describe('RemoteAuthStrategy', async () => {
 
   await describe('cleanup', async () => {
     await it('should reset strategy state', async () => {
-      await strategy.cleanup()
+      strategy.cleanup()
       const stats = await strategy.getStats()
       expect(stats.isInitialized).toBe(false)
       expect(stats.totalRequests).toBe(0)
index c88a894bdba777a8af727c0e88a35dc9d948f66c..2811dcdb7d62531e4a531082dd18f8d6a0f179c1 100644 (file)
@@ -216,67 +216,6 @@ await describe('AuthHelpers', async () => {
     })
   })
 
-  await describe('isCacheable', async () => {
-    await it('should return false for non-ACCEPTED status', () => {
-      const result: AuthorizationResult = {
-        isOffline: false,
-        method: AuthenticationMethod.LOCAL_LIST,
-        status: AuthorizationStatus.BLOCKED,
-        timestamp: new Date(),
-      }
-
-      expect(AuthHelpers.isCacheable(result)).toBe(false)
-    })
-
-    await it('should return false for ACCEPTED without expiry date', () => {
-      const result: AuthorizationResult = {
-        isOffline: false,
-        method: AuthenticationMethod.LOCAL_LIST,
-        status: AuthorizationStatus.ACCEPTED,
-        timestamp: new Date(),
-      }
-
-      expect(AuthHelpers.isCacheable(result)).toBe(false)
-    })
-
-    await it('should return false for already expired result', () => {
-      const result: AuthorizationResult = {
-        expiryDate: new Date(Date.now() - 1000),
-        isOffline: false,
-        method: AuthenticationMethod.LOCAL_LIST,
-        status: AuthorizationStatus.ACCEPTED,
-        timestamp: new Date(),
-      }
-
-      expect(AuthHelpers.isCacheable(result)).toBe(false)
-    })
-
-    await it('should return false for expiry too far in future (>1 year)', () => {
-      const oneYearPlusOne = new Date(Date.now() + 366 * 24 * 60 * 60 * 1000)
-      const result: AuthorizationResult = {
-        expiryDate: oneYearPlusOne,
-        isOffline: false,
-        method: AuthenticationMethod.LOCAL_LIST,
-        status: AuthorizationStatus.ACCEPTED,
-        timestamp: new Date(),
-      }
-
-      expect(AuthHelpers.isCacheable(result)).toBe(false)
-    })
-
-    await it('should return true for valid ACCEPTED result with reasonable expiry', () => {
-      const result: AuthorizationResult = {
-        expiryDate: new Date(Date.now() + 24 * 60 * 60 * 1000),
-        isOffline: false,
-        method: AuthenticationMethod.LOCAL_LIST,
-        status: AuthorizationStatus.ACCEPTED,
-        timestamp: new Date(),
-      }
-
-      expect(AuthHelpers.isCacheable(result)).toBe(true)
-    })
-  })
-
   await describe('isPermanentFailure', async () => {
     await it('should return true for BLOCKED status', () => {
       const result: AuthorizationResult = {
index 5279ea41bbf9687c2732db5c41c93a87afd10f43..509830a8636853633fe51456a4e7da56bd3c9f28 100644 (file)
@@ -7,7 +7,6 @@ import { afterEach, describe, it } from 'node:test'
 
 import {
   type AuthConfiguration,
-  AuthenticationMethod,
   IdentifierType,
   type UnifiedIdentifier,
 } from '../../../../../src/charging-station/ocpp/auth/types/AuthTypes.js'
@@ -151,7 +150,6 @@ await describe('AuthValidators', async () => {
         authorizationCacheLifetime: 3600,
         authorizationTimeout: 30,
         certificateAuthEnabled: false,
-        enabledStrategies: [AuthenticationMethod.LOCAL_LIST],
         localAuthListEnabled: true,
         localPreAuthorize: true,
         offlineAuthorizationEnabled: false,
@@ -170,59 +168,41 @@ await describe('AuthValidators', async () => {
       expect(AuthValidators.validateAuthConfiguration(undefined as any)).toBe(false)
     })
 
-    await it('should return false for empty enabled strategies', () => {
-      const config: AuthConfiguration = {
-        allowOfflineTxForUnknownId: false,
-        authorizationCacheEnabled: true,
-        authorizationTimeout: 30,
-        certificateAuthEnabled: false,
-        enabledStrategies: [],
-        localAuthListEnabled: true,
-        localPreAuthorize: true,
-        offlineAuthorizationEnabled: false,
-      }
-
-      expect(AuthValidators.validateAuthConfiguration(config)).toBe(false)
-    })
-
-    await it('should return false for missing enabled strategies', () => {
+    await it('should return false for missing required boolean fields', () => {
       const config = {
         allowOfflineTxForUnknownId: false,
         authorizationCacheEnabled: true,
         authorizationTimeout: 30,
-        certificateAuthEnabled: false,
         localAuthListEnabled: true,
         localPreAuthorize: true,
         offlineAuthorizationEnabled: false,
+        // certificateAuthEnabled missing
       } as Partial<AuthConfiguration>
 
       expect(AuthValidators.validateAuthConfiguration(config)).toBe(false)
     })
 
-    await it('should return false for invalid remote auth timeout', () => {
+    await it('should return false for non-positive authorization timeout', () => {
       const config: AuthConfiguration = {
         allowOfflineTxForUnknownId: false,
         authorizationCacheEnabled: true,
-        authorizationTimeout: 30,
+        authorizationTimeout: 0,
         certificateAuthEnabled: false,
-        enabledStrategies: [AuthenticationMethod.LOCAL_LIST],
         localAuthListEnabled: true,
         localPreAuthorize: true,
         offlineAuthorizationEnabled: false,
-        remoteAuthTimeout: -1,
       }
 
       expect(AuthValidators.validateAuthConfiguration(config)).toBe(false)
     })
 
-    await it('should return false for invalid local auth cache TTL', () => {
+    await it('should return false for negative cache lifetime', () => {
       const config: AuthConfiguration = {
         allowOfflineTxForUnknownId: false,
         authorizationCacheEnabled: true,
+        authorizationCacheLifetime: -100,
         authorizationTimeout: 30,
         certificateAuthEnabled: false,
-        enabledStrategies: [AuthenticationMethod.LOCAL_LIST],
-        localAuthCacheTTL: -100,
         localAuthListEnabled: true,
         localPreAuthorize: true,
         offlineAuthorizationEnabled: false,
@@ -231,37 +211,32 @@ await describe('AuthValidators', async () => {
       expect(AuthValidators.validateAuthConfiguration(config)).toBe(false)
     })
 
-    await it('should return false for invalid strategy priority order', () => {
+    await it('should return false for non-integer max cache entries', () => {
       const config: AuthConfiguration = {
         allowOfflineTxForUnknownId: false,
         authorizationCacheEnabled: true,
         authorizationTimeout: 30,
         certificateAuthEnabled: false,
-        enabledStrategies: [AuthenticationMethod.LOCAL_LIST],
         localAuthListEnabled: true,
         localPreAuthorize: true,
+        maxCacheEntries: 1.5,
         offlineAuthorizationEnabled: false,
-        // eslint-disable-next-line @typescript-eslint/no-explicit-any -- testing invalid enum value
-        strategyPriorityOrder: ['InvalidMethod' as any],
       }
 
       expect(AuthValidators.validateAuthConfiguration(config)).toBe(false)
     })
 
-    await it('should return true for valid strategy priority order', () => {
+    await it('should return true for valid configuration with optional fields', () => {
       const config: AuthConfiguration = {
         allowOfflineTxForUnknownId: false,
         authorizationCacheEnabled: true,
+        authorizationCacheLifetime: 3600,
         authorizationTimeout: 30,
         certificateAuthEnabled: false,
-        enabledStrategies: [AuthenticationMethod.LOCAL_LIST],
         localAuthListEnabled: true,
         localPreAuthorize: true,
+        maxCacheEntries: 500,
         offlineAuthorizationEnabled: false,
-        strategyPriorityOrder: [
-          AuthenticationMethod.LOCAL_LIST,
-          AuthenticationMethod.REMOTE_AUTHORIZATION,
-        ],
       }
 
       expect(AuthValidators.validateAuthConfiguration(config)).toBe(true)
similarity index 73%
rename from src/charging-station/ocpp/auth/test/OCPPAuthIntegrationTest.ts
rename to tests/helpers/OCPPAuthIntegrationTest.ts
index 6e7c16dd7124e876dcdc3aa4b75ed9212366d50d..f05f97d58ec55a4bcb9c063d94cf9d2b181ed350 100644 (file)
@@ -1,29 +1,21 @@
-/**
- * Integration Test for OCPP Authentication Service
- * Tests the complete authentication flow end-to-end
- */
-
 import type {
   AuthConfiguration,
   AuthorizationResult,
   AuthRequest,
   UnifiedIdentifier,
-} from '../types/AuthTypes.js'
+} from '../../src/charging-station/ocpp/auth/types/AuthTypes.js'
 
-import { OCPPVersion } from '../../../../types/ocpp/OCPPVersion.js'
-import { logger } from '../../../../utils/Logger.js'
-import { ChargingStation } from '../../../ChargingStation.js'
-import { OCPPAuthServiceImpl } from '../services/OCPPAuthServiceImpl.js'
+import { ChargingStation } from '../../src/charging-station/ChargingStation.js'
+import { OCPPAuthServiceImpl } from '../../src/charging-station/ocpp/auth/services/OCPPAuthServiceImpl.js'
 import {
   AuthContext,
   AuthenticationMethod,
   AuthorizationStatus,
   IdentifierType,
-} from '../types/AuthTypes.js'
+} from '../../src/charging-station/ocpp/auth/types/AuthTypes.js'
+import { OCPPVersion } from '../../src/types/ocpp/OCPPVersion.js'
+import { logger } from '../../src/utils/Logger.js'
 
-/**
- * Integration test class for OCPP Authentication
- */
 export class OCPPAuthIntegrationTest {
   private authService: OCPPAuthServiceImpl
   private chargingStation: ChargingStation
@@ -33,10 +25,6 @@ export class OCPPAuthIntegrationTest {
     this.authService = new OCPPAuthServiceImpl(chargingStation)
   }
 
-  /**
-   * Run comprehensive integration test suite
-   * @returns Test results with passed/failed counts and result messages
-   */
   public async runTests (): Promise<{ failed: number; passed: number; results: string[] }> {
     const results: string[] = []
     let passed = 0
@@ -46,9 +34,8 @@ export class OCPPAuthIntegrationTest {
       `${this.chargingStation.logPrefix()} Starting OCPP Authentication Integration Tests`
     )
 
-    // Test 1: Service Initialization
     try {
-      await this.testServiceInitialization()
+      this.testServiceInitialization()
       results.push('✅ Service Initialization - PASSED')
       passed++
     } catch (error) {
@@ -56,9 +43,8 @@ export class OCPPAuthIntegrationTest {
       failed++
     }
 
-    // Test 2: Configuration Management
     try {
-      await this.testConfigurationManagement()
+      this.testConfigurationManagement()
       results.push('✅ Configuration Management - PASSED')
       passed++
     } catch (error) {
@@ -66,9 +52,8 @@ export class OCPPAuthIntegrationTest {
       failed++
     }
 
-    // Test 3: Strategy Selection Logic
     try {
-      await this.testStrategySelection()
+      this.testStrategySelection()
       results.push('✅ Strategy Selection Logic - PASSED')
       passed++
     } catch (error) {
@@ -76,7 +61,6 @@ export class OCPPAuthIntegrationTest {
       failed++
     }
 
-    // Test 4: OCPP 1.6 Authentication Flow
     try {
       await this.testOCPP16AuthFlow()
       results.push('✅ OCPP 1.6 Authentication Flow - PASSED')
@@ -86,7 +70,6 @@ export class OCPPAuthIntegrationTest {
       failed++
     }
 
-    // Test 5: OCPP 2.0 Authentication Flow
     try {
       await this.testOCPP20AuthFlow()
       results.push('✅ OCPP 2.0 Authentication Flow - PASSED')
@@ -96,7 +79,6 @@ export class OCPPAuthIntegrationTest {
       failed++
     }
 
-    // Test 6: Error Handling
     try {
       await this.testErrorHandling()
       results.push('✅ Error Handling - PASSED')
@@ -106,7 +88,6 @@ export class OCPPAuthIntegrationTest {
       failed++
     }
 
-    // Test 7: Cache Operations
     try {
       await this.testCacheOperations()
       results.push('✅ Cache Operations - PASSED')
@@ -116,7 +97,6 @@ export class OCPPAuthIntegrationTest {
       failed++
     }
 
-    // Test 8: Performance and Statistics
     try {
       await this.testPerformanceAndStats()
       results.push('✅ Performance and Statistics - PASSED')
@@ -133,9 +113,6 @@ export class OCPPAuthIntegrationTest {
     return { failed, passed, results }
   }
 
-  /**
-   * Test 7: Cache Operations
-   */
   private async testCacheOperations (): Promise<void> {
     const testIdentifier: UnifiedIdentifier = {
       ocppVersion:
@@ -146,37 +123,26 @@ export class OCPPAuthIntegrationTest {
       value: 'CACHE_TEST_ID',
     }
 
-    // Test cache invalidation (should not throw)
-    await this.authService.invalidateCache(testIdentifier)
-
-    // Test cache clearing (should not throw)
-    await this.authService.clearCache()
-
-    // Test local authorization check after cache operations
+    this.authService.invalidateCache(testIdentifier)
+    this.authService.clearCache()
     await this.authService.isLocallyAuthorized(testIdentifier)
-    // Result can be undefined, which is valid
 
     logger.debug(`${this.chargingStation.logPrefix()} Cache operations tested`)
   }
 
-  /**
-   * Test 2: Configuration Management
-   */
-  private async testConfigurationManagement (): Promise<void> {
+  private testConfigurationManagement (): void {
     const originalConfig = this.authService.getConfiguration()
 
-    // Test configuration update
     const updates: Partial<AuthConfiguration> = {
       authorizationTimeout: 60,
       localAuthListEnabled: false,
       maxCacheEntries: 2000,
     }
 
-    await this.authService.updateConfiguration(updates)
+    this.authService.updateConfiguration(updates)
 
     const updatedConfig = this.authService.getConfiguration()
 
-    // Verify updates applied
     if (updatedConfig.authorizationTimeout !== 60) {
       throw new Error('Configuration update failed: authorizationTimeout')
     }
@@ -189,17 +155,12 @@ export class OCPPAuthIntegrationTest {
       throw new Error('Configuration update failed: maxCacheEntries')
     }
 
-    // Restore original configuration
-    await this.authService.updateConfiguration(originalConfig)
+    this.authService.updateConfiguration(originalConfig)
 
     logger.debug(`${this.chargingStation.logPrefix()} Configuration management test completed`)
   }
 
-  /**
-   * Test 6: Error Handling
-   */
   private async testErrorHandling (): Promise<void> {
-    // Test with invalid identifier
     const invalidIdentifier: UnifiedIdentifier = {
       ocppVersion: OCPPVersion.VERSION_16,
       type: IdentifierType.ISO14443,
@@ -208,7 +169,7 @@ export class OCPPAuthIntegrationTest {
 
     const invalidRequest: AuthRequest = {
       allowOffline: false,
-      connectorId: 999, // Invalid connector
+      connectorId: 999,
       context: AuthContext.TRANSACTION_START,
       identifier: invalidIdentifier,
       timestamp: new Date(),
@@ -216,17 +177,14 @@ export class OCPPAuthIntegrationTest {
 
     const result = await this.authService.authenticate(invalidRequest)
 
-    // Should get INVALID status for invalid request
     if (result.status === AuthorizationStatus.ACCEPTED) {
       throw new Error('Expected INVALID status for invalid identifier, got ACCEPTED')
     }
 
-    // Test strategy-specific authorization with non-existent strategy
     try {
       await this.authService.authorizeWithStrategy('non-existent', invalidRequest)
       throw new Error('Expected error for non-existent strategy')
     } catch (error) {
-      // Expected behavior - should throw error
       if (!(error as Error).message.includes('not found')) {
         throw new Error('Unexpected error message for non-existent strategy')
       }
@@ -235,11 +193,7 @@ export class OCPPAuthIntegrationTest {
     logger.debug(`${this.chargingStation.logPrefix()} Error handling verified`)
   }
 
-  /**
-   * Test 4: OCPP 1.6 Authentication Flow
-   */
   private async testOCPP16AuthFlow (): Promise<void> {
-    // Create test request for OCPP 1.6
     const identifier: UnifiedIdentifier = {
       ocppVersion: OCPPVersion.VERSION_16,
       type: IdentifierType.ISO14443,
@@ -254,15 +208,12 @@ export class OCPPAuthIntegrationTest {
       timestamp: new Date(),
     }
 
-    // Test main authentication method
     const result = await this.authService.authenticate(request)
     this.validateAuthenticationResult(result)
 
-    // Test direct authorization method
     const authResult = await this.authService.authorize(request)
     this.validateAuthenticationResult(authResult)
 
-    // Test local authorization check
     const localResult = await this.authService.isLocallyAuthorized(identifier, 1)
     if (localResult) {
       this.validateAuthenticationResult(localResult)
@@ -271,11 +222,7 @@ export class OCPPAuthIntegrationTest {
     logger.debug(`${this.chargingStation.logPrefix()} OCPP 1.6 authentication flow tested`)
   }
 
-  /**
-   * Test 5: OCPP 2.0 Authentication Flow
-   */
   private async testOCPP20AuthFlow (): Promise<void> {
-    // Create test request for OCPP 2.0
     const identifier: UnifiedIdentifier = {
       ocppVersion: OCPPVersion.VERSION_20,
       type: IdentifierType.ISO15693,
@@ -290,7 +237,6 @@ export class OCPPAuthIntegrationTest {
       timestamp: new Date(),
     }
 
-    // Test authentication with different contexts
     const contexts = [
       AuthContext.TRANSACTION_START,
       AuthContext.TRANSACTION_STOP,
@@ -307,29 +253,22 @@ export class OCPPAuthIntegrationTest {
     logger.debug(`${this.chargingStation.logPrefix()} OCPP 2.0 authentication flow tested`)
   }
 
-  /**
-   * Test 8: Performance and Statistics
-   */
   private async testPerformanceAndStats (): Promise<void> {
-    // Test connectivity check
-    const connectivity = await this.authService.testConnectivity()
+    const connectivity = this.authService.testConnectivity()
     if (typeof connectivity !== 'boolean') {
       throw new Error('Invalid connectivity test result')
     }
 
-    // Test statistics retrieval
     const stats = await this.authService.getStats()
     if (typeof stats.totalRequests !== 'number') {
       throw new Error('Invalid statistics object')
     }
 
-    // Test authentication statistics
     const authStats = this.authService.getAuthenticationStats()
     if (!Array.isArray(authStats.availableStrategies)) {
       throw new Error('Invalid authentication statistics')
     }
 
-    // Performance test - multiple rapid authentication requests
     const identifier: UnifiedIdentifier = {
       ocppVersion:
         this.chargingStation.stationInfo?.ocppVersion === OCPPVersion.VERSION_16
@@ -356,12 +295,10 @@ export class OCPPAuthIntegrationTest {
     const results = await Promise.all(promises)
     const duration = Date.now() - startTime
 
-    // Verify all requests completed
     if (results.length !== 10) {
       throw new Error('Not all performance test requests completed')
     }
 
-    // Check reasonable performance (less than 5 seconds for 10 requests)
     if (duration > 5000) {
       throw new Error(`Performance test too slow: ${String(duration)}ms for 10 requests`)
     }
@@ -371,26 +308,17 @@ export class OCPPAuthIntegrationTest {
     )
   }
 
-  /**
-   * Test 1: Service Initialization
-   * @returns Promise that resolves when test passes
-   */
-  private testServiceInitialization (): Promise<void> {
-    // Service is always initialized in constructor, no need to check
-
-    // Check available strategies
+  private testServiceInitialization (): void {
     const strategies = this.authService.getAvailableStrategies()
     if (strategies.length === 0) {
       throw new Error('No authentication strategies available')
     }
 
-    // Check configuration
     const config = this.authService.getConfiguration()
     if (typeof config !== 'object') {
       throw new Error('Invalid configuration object')
     }
 
-    // Check stats
     const stats = this.authService.getAuthenticationStats()
     if (!stats.ocppVersion) {
       throw new Error('Invalid authentication statistics')
@@ -399,18 +327,11 @@ export class OCPPAuthIntegrationTest {
     logger.debug(
       `${this.chargingStation.logPrefix()} Service initialized with ${String(strategies.length)} strategies`
     )
-
-    return Promise.resolve()
   }
 
-  /**
-   * Test 3: Strategy Selection Logic
-   * @returns Promise that resolves when test passes
-   */
-  private testStrategySelection (): Promise<void> {
+  private testStrategySelection (): void {
     const strategies = this.authService.getAvailableStrategies()
 
-    // Test each strategy individually
     for (const strategyName of strategies) {
       const strategy = this.authService.getStrategy(strategyName)
       if (!strategy) {
@@ -418,7 +339,6 @@ export class OCPPAuthIntegrationTest {
       }
     }
 
-    // Test identifier support detection
     const testIdentifier: UnifiedIdentifier = {
       ocppVersion:
         this.chargingStation.stationInfo?.ocppVersion === OCPPVersion.VERSION_16
@@ -434,43 +354,29 @@ export class OCPPAuthIntegrationTest {
     }
 
     logger.debug(`${this.chargingStation.logPrefix()} Strategy selection logic verified`)
-
-    return Promise.resolve()
   }
 
-  /**
-   * Validate authentication result structure
-   * @param result - Authorization result to validate for required fields and valid enum values
-   */
   private validateAuthenticationResult (result: AuthorizationResult): void {
-    // Note: status, method, and timestamp are required by the AuthorizationResult interface
-    // so no null checks are needed - they are guaranteed by TypeScript
-
     if (typeof result.isOffline !== 'boolean') {
       throw new Error('Authentication result missing or invalid isOffline flag')
     }
 
-    // Validate status is valid enum value
     const validStatuses = Object.values(AuthorizationStatus)
     if (!validStatuses.includes(result.status)) {
       throw new Error(`Invalid authorization status: ${result.status}`)
     }
 
-    // Validate method is valid enum value
     const validMethods = Object.values(AuthenticationMethod)
     if (!validMethods.includes(result.method)) {
       throw new Error(`Invalid authentication method: ${result.method}`)
     }
 
-    // Check timestamp is recent (within last minute)
     const now = new Date()
     const diff = now.getTime() - result.timestamp.getTime()
     if (diff > 60000) {
-      // 60 seconds
       throw new Error(`Authentication timestamp too old: ${String(diff)}ms`)
     }
 
-    // Check additional info structure if present
     if (result.additionalInfo) {
       if (typeof result.additionalInfo !== 'object') {
         throw new Error('Invalid additionalInfo structure')
@@ -480,9 +386,9 @@ export class OCPPAuthIntegrationTest {
 }
 
 /**
- * Factory function to create and run integration tests
- * @param chargingStation - Charging station instance to run authentication integration tests against
- * @returns Test results with pass/fail counts and individual test outcome messages
+ * Create and run integration tests for a charging station.
+ * @param chargingStation - Charging station instance to test
+ * @returns Test results with pass/fail counts and outcome messages
  */
 export async function runOCPPAuthIntegrationTests (chargingStation: ChargingStation): Promise<{
   failed: number
index 0330d809a12fb01e771f7f8d8247665c8eb7edf5..c2a6fcbe0d20f757bfbd45a2ed4a8f6f338ffcdf 100644 (file)
@@ -300,6 +300,16 @@ export function standardCleanup (): void {
   MockIdTagsCache.resetInstance()
 }
 
+/**
+ * Suspends execution for the specified number of milliseconds.
+ * @param ms - Duration to sleep in milliseconds.
+ * @returns A promise that resolves after the specified delay.
+ */
+export const sleep = (ms: number): Promise<void> =>
+  new Promise(resolve => {
+    setTimeout(resolve, ms)
+  })
+
 /**
  * Execute a test function with mocked timers, ensuring cleanup on success or failure.
  *