From a68050ada31838f51ed33f6bf94b0a2f48300aa3 Mon Sep 17 00:00:00 2001 From: =?utf8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Thu, 5 Mar 2026 21:24:20 +0100 Subject: [PATCH] =?utf8?q?fix(ocpp):=20auth=20cache=20spec=20compliance=20?= =?utf8?q?(R0=E2=80=93R17)=20(#1699)?= MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit * 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. - 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 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> --- .../ocpp/2.0/OCPP20IncomingRequestService.ts | 2 +- .../ocpp/auth/adapters/OCPP16AuthAdapter.ts | 16 +- .../ocpp/auth/adapters/OCPP20AuthAdapter.ts | 37 +- .../ocpp/auth/cache/InMemoryAuthCache.ts | 242 ++++-- .../auth/factories/AuthComponentFactory.ts | 32 +- .../ocpp/auth/interfaces/OCPPAuthService.ts | 28 +- .../ocpp/auth/services/OCPPAuthServiceImpl.ts | 31 +- .../strategies/CertificateAuthStrategy.ts | 18 +- .../ocpp/auth/strategies/LocalAuthStrategy.ts | 81 +- .../auth/strategies/RemoteAuthStrategy.ts | 105 ++- .../ocpp/auth/types/AuthTypes.ts | 1 + .../ocpp/auth/utils/AuthHelpers.ts | 404 +++++----- .../ocpp/auth/utils/AuthValidators.ts | 334 ++++----- .../ocpp/auth/utils/ConfigValidator.ts | 278 ++++--- tests/TEST_STYLE_GUIDE.md | 1 + ...0IncomingRequestService-ClearCache.test.ts | 17 +- .../ocpp/auth/OCPPAuthIntegration.test.ts | 201 ++++- .../auth/adapters/OCPP16AuthAdapter.test.ts | 50 +- .../auth/adapters/OCPP20AuthAdapter.test.ts | 74 +- .../ocpp/auth/cache/InMemoryAuthCache.test.ts | 698 +++++++++++++----- .../ocpp/auth/helpers/MockFactories.ts | 147 ++-- .../auth/services/OCPPAuthServiceImpl.test.ts | 43 +- .../CertificateAuthStrategy.test.ts | 51 +- .../auth/strategies/LocalAuthStrategy.test.ts | 88 ++- .../strategies/RemoteAuthStrategy.test.ts | 229 +++++- .../ocpp/auth/utils/AuthHelpers.test.ts | 61 -- .../ocpp/auth/utils/AuthValidators.test.ts | 47 +- .../helpers}/OCPPAuthIntegrationTest.ts | 136 +--- tests/helpers/TestLifecycleHelpers.ts | 10 + 29 files changed, 2092 insertions(+), 1370 deletions(-) rename {src/charging-station/ocpp/auth/test => tests/helpers}/OCPPAuthIntegrationTest.ts (73%) diff --git a/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts b/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts index 671c212a..e01c50be 100644 --- a/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts +++ b/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts @@ -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` ) diff --git a/src/charging-station/ocpp/auth/adapters/OCPP16AuthAdapter.ts b/src/charging-station/ocpp/auth/adapters/OCPP16AuthAdapter.ts index f386aea3..b4c68f21 100644 --- a/src/charging-station/ocpp/auth/adapters/OCPP16AuthAdapter.ts +++ b/src/charging-station/ocpp/auth/adapters/OCPP16AuthAdapter.ts @@ -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 { + 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 { + 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 } } diff --git a/src/charging-station/ocpp/auth/adapters/OCPP20AuthAdapter.ts b/src/charging-station/ocpp/auth/adapters/OCPP20AuthAdapter.ts index bd4f18e4..ce3d3044 100644 --- a/src/charging-station/ocpp/auth/adapters/OCPP20AuthAdapter.ts +++ b/src/charging-station/ocpp/auth/adapters/OCPP20AuthAdapter.ts @@ -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 { + 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 { + 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 { 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) } } diff --git a/src/charging-station/ocpp/auth/cache/InMemoryAuthCache.ts b/src/charging-station/ocpp/auth/cache/InMemoryAuthCache.ts index 2e285459..7f76a18e 100644 --- a/src/charging-station/ocpp/auth/cache/InMemoryAuthCache.ts +++ b/src/charging-station/ocpp/auth/cache/InMemoryAuthCache.ts @@ -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() + private cleanupInterval?: ReturnType + /** Default TTL in seconds */ private readonly defaultTtl: number /** Access order for LRU eviction (identifier -> last access timestamp) */ private readonly lruOrder = new Map() + 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 { + 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 { + 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 { + 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 { + 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 { + 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)}...` } diff --git a/src/charging-station/ocpp/auth/factories/AuthComponentFactory.ts b/src/charging-station/ocpp/auth/factories/AuthComponentFactory.ts index 8c8e89f4..27128d71 100644 --- a/src/charging-station/ocpp/auth/factories/AuthComponentFactory.ts +++ b/src/charging-station/ocpp/auth/factories/AuthComponentFactory.ts @@ -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 { - 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 { 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) } diff --git a/src/charging-station/ocpp/auth/interfaces/OCPPAuthService.ts b/src/charging-station/ocpp/auth/interfaces/OCPPAuthService.ts index b18cd6a2..ffe608f1 100644 --- a/src/charging-station/ocpp/auth/interfaces/OCPPAuthService.ts +++ b/src/charging-station/ocpp/auth/interfaces/OCPPAuthService.ts @@ -13,25 +13,25 @@ export interface AuthCache { /** * Clear all cached entries */ - clear(): Promise + 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 + get(identifier: string): AuthorizationResult | undefined /** * Get cache statistics */ - getStats(): Promise + getStats(): CacheStats /** * Remove a cached entry * @param identifier - Identifier to remove */ - remove(identifier: string): Promise + 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 + set(identifier: string, result: AuthorizationResult, ttl?: number): void } /** @@ -139,7 +139,7 @@ export interface AuthStrategy { /** * Cleanup strategy resources */ - cleanup(): Promise + cleanup(): void /** * Optionally reconfigure the strategy at runtime @@ -153,13 +153,13 @@ export interface AuthStrategy { /** * Get strategy-specific statistics */ - getStats(): Promise> + getStats(): Promise> | Record /** * Initialize the strategy with configuration * @param config - Authentication configuration */ - initialize(config: AuthConfiguration): Promise + initialize(config: AuthConfiguration): Promise | void /** * Strategy name for identification @@ -361,7 +361,7 @@ export interface OCPPAuthAdapter { /** * Check if remote authorization is available */ - isRemoteAvailable(): Promise + isRemoteAvailable(): boolean | Promise /** * The OCPP version this adapter handles @@ -371,7 +371,7 @@ export interface OCPPAuthAdapter { /** * Validate adapter configuration */ - validateConfiguration(config: AuthConfiguration): Promise + validateConfiguration(config: AuthConfiguration): boolean } /** @@ -391,7 +391,7 @@ export interface OCPPAuthService { /** * Clear all cached authorizations */ - clearCache(): Promise + 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 + 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 + testConnectivity(): boolean /** * Update authentication configuration * @param config - New configuration to apply */ - updateConfiguration(config: Partial): Promise + updateConfiguration(config: Partial): void } diff --git a/src/charging-station/ocpp/auth/services/OCPPAuthServiceImpl.ts b/src/charging-station/ocpp/auth/services/OCPPAuthServiceImpl.ts index a7da8444..8f3533f2 100644 --- a/src/charging-station/ocpp/auth/services/OCPPAuthServiceImpl.ts +++ b/src/charging-station/ocpp/auth/services/OCPPAuthServiceImpl.ts @@ -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 { + 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 { + 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 { + 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): Promise { + public updateConfiguration (config: Partial): 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 ) diff --git a/src/charging-station/ocpp/auth/strategies/CertificateAuthStrategy.ts b/src/charging-station/ocpp/auth/strategies/CertificateAuthStrategy.ts index d9042aa0..f207acae 100644 --- a/src/charging-station/ocpp/auth/strategies/CertificateAuthStrategy.ts +++ b/src/charging-station/ocpp/auth/strategies/CertificateAuthStrategy.ts @@ -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 { + cleanup (): void { this.isInitialized = false logger.debug( `${this.chargingStation.logPrefix()} Certificate authentication strategy cleaned up` ) - return Promise.resolve() } - getStats (): Promise> { - return Promise.resolve({ + getStats (): Record { + return { ...this.stats, isInitialized: this.isInitialized, - }) + } } - initialize (config: AuthConfiguration): Promise { + 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 { // 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 diff --git a/src/charging-station/ocpp/auth/strategies/LocalAuthStrategy.ts b/src/charging-station/ocpp/auth/strategies/LocalAuthStrategy.ts index 1dc5da7f..200199b8 100644 --- a/src/charging-station/ocpp/auth/strategies/LocalAuthStrategy.ts +++ b/src/charging-station/ocpp/auth/strategies/LocalAuthStrategy.ts @@ -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 { + 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 { + 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> { - const cacheStats = this.authCache ? await this.authCache.getStats() : null + public getStats (): Record { + 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 { + 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 { + 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 { 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 { 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(), - }) + } } /** diff --git a/src/charging-station/ocpp/auth/strategies/RemoteAuthStrategy.ts b/src/charging-station/ocpp/auth/strategies/RemoteAuthStrategy.ts index 36637632..7a31eaa4 100644 --- a/src/charging-station/ocpp/auth/strategies/RemoteAuthStrategy.ts +++ b/src/charging-station/ocpp/auth/strategies/RemoteAuthStrategy.ts @@ -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() 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, authCache?: AuthCache) { + constructor ( + adapters?: Map, + 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 { + 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> { - const cacheStats = this.authCache ? await this.authCache.getStats() : null + const cacheStats = this.authCache ? this.authCache.getStats() : null const adapterStats = new Map() // 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 { + 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 { + 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 { 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 | undefined const result = await Promise.race([ - availabilityPromise, + Promise.resolve(adapter.isRemoteAvailable()), new Promise((_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 { const timeout = config.authorizationTimeout * 1000 + let timeoutHandle: ReturnType | undefined try { // Create the authorization promise @@ -427,7 +474,7 @@ export class RemoteAuthStrategy implements AuthStrategy { const result = await Promise.race([ authPromise, new Promise((_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 } diff --git a/src/charging-station/ocpp/auth/types/AuthTypes.ts b/src/charging-station/ocpp/auth/types/AuthTypes.ts index 1df07a92..ed7bc64f 100644 --- a/src/charging-station/ocpp/auth/types/AuthTypes.ts +++ b/src/charging-station/ocpp/auth/types/AuthTypes.ts @@ -26,6 +26,7 @@ export enum AuthenticationMethod { CACHE = 'Cache', CERTIFICATE_BASED = 'CertificateBased', LOCAL_LIST = 'LocalList', + NONE = 'None', OFFLINE_FALLBACK = 'OfflineFallback', REMOTE_AUTHORIZATION = 'RemoteAuthorization', } diff --git a/src/charging-station/ocpp/auth/utils/AuthHelpers.ts b/src/charging-station/ocpp/auth/utils/AuthHelpers.ts index d04e4c4d..c83d2887 100644 --- a/src/charging-station/ocpp/auth/utils/AuthHelpers.ts +++ b/src/charging-station/ocpp/auth/utils/AuthHelpers.ts @@ -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 - ): 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 +): 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 { - 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 { + 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, +} diff --git a/src/charging-station/ocpp/auth/utils/AuthValidators.ts b/src/charging-station/ocpp/auth/utils/AuthValidators.ts index 6f0a1aef..a8e18f10 100644 --- a/src/charging-station/ocpp/auth/utils/AuthValidators.ts +++ b/src/charging-station/ocpp/auth/utils/AuthValidators.ts @@ -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, +} diff --git a/src/charging-station/ocpp/auth/utils/ConfigValidator.ts b/src/charging-station/ocpp/auth/utils/ConfigValidator.ts index 1fcc1501..e1bec914 100644 --- a/src/charging-station/ocpp/auth/utils/ConfigValidator.ts +++ b/src/charging-station/ocpp/auth/utils/ConfigValidator.ts @@ -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, } diff --git a/tests/TEST_STYLE_GUIDE.md b/tests/TEST_STYLE_GUIDE.md index 55f9dd76..d3f8e820 100644 --- a/tests/TEST_STYLE_GUIDE.md +++ b/tests/TEST_STYLE_GUIDE.md @@ -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) | diff --git a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-ClearCache.test.ts b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-ClearCache.test.ts index 9d47f93b..426217da 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-ClearCache.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-ClearCache.test.ts @@ -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 => { + 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 => { + 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 => { - // 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 => { - 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 => { + clearCache: (): void => { clearCacheAttempted = true - return Promise.resolve() }, getConfiguration: () => ({ authorizationCacheEnabled: false, diff --git a/tests/charging-station/ocpp/auth/OCPPAuthIntegration.test.ts b/tests/charging-station/ocpp/auth/OCPPAuthIntegration.test.ts index 7d6246e3..c471f5f2 100644 --- a/tests/charging-station/ocpp/auth/OCPPAuthIntegration.test.ts +++ b/tests/charging-station/ocpp/auth/OCPPAuthIntegration.test.ts @@ -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(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() + } + }) + }) }) diff --git a/tests/charging-station/ocpp/auth/adapters/OCPP16AuthAdapter.test.ts b/tests/charging-station/ocpp/auth/adapters/OCPP16AuthAdapter.test.ts index bf5124f0..9b965b5c 100644 --- a/tests/charging-station/ocpp/auth/adapters/OCPP16AuthAdapter.test.ts +++ b/tests/charging-station/ocpp/auth/adapters/OCPP16AuthAdapter.test.ts @@ -35,15 +35,16 @@ await describe('OCPP16AuthAdapter', async () => { inAcceptedState: () => true, logPrefix: () => '[TEST-STATION]', ocppRequestService: { - requestHandler: (): Promise => { - return Promise.resolve({ - idTagInfo: { - expiryDate: new Date(Date.now() + 86400000), - parentIdTag: undefined, - status: OCPP16AuthorizationStatus.ACCEPTED, - }, - }) - }, + requestHandler: (): Promise => + new Promise(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 => { - return Promise.reject(new Error('Network error')) - } + mockStation.ocppRequestService.requestHandler = (): Promise => + new Promise((_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) }) }) diff --git a/tests/charging-station/ocpp/auth/adapters/OCPP20AuthAdapter.test.ts b/tests/charging-station/ocpp/auth/adapters/OCPP20AuthAdapter.test.ts index 2337205e..0f2f3fd2 100644 --- a/tests/charging-station/ocpp/auth/adapters/OCPP20AuthAdapter.test.ts +++ b/tests/charging-station/ocpp/auth/adapters/OCPP20AuthAdapter.test.ts @@ -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>(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 }, + 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 }, + 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) diff --git a/tests/charging-station/ocpp/auth/cache/InMemoryAuthCache.test.ts b/tests/charging-station/ocpp/auth/cache/InMemoryAuthCache.test.ts index 9d79a941..cdc4389d 100644 --- a/tests/charging-station/ocpp/auth/cache/InMemoryAuthCache.test.ts +++ b/tests/charging-station/ocpp/auth/cache/InMemoryAuthCache.test.ts @@ -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() + }) + }) }) diff --git a/tests/charging-station/ocpp/auth/helpers/MockFactories.ts b/tests/charging-station/ocpp/auth/helpers/MockFactories.ts index 60470de0..4f204b88 100644 --- a/tests/charging-station/ocpp/auth/helpers/MockFactories.ts +++ b/tests/charging-station/ocpp/auth/helpers/MockFactories.ts @@ -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 => ({ - 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(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>(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(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): 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 => ({ - 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 => ({ - authorizeRemote: async (_identifier: UnifiedIdentifier) => - Promise.resolve( - createMockAuthorizationResult({ - method: AuthenticationMethod.REMOTE_AUTHORIZATION, - }) - ), + authorizeRemote: (_identifier: UnifiedIdentifier) => + new Promise(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>(resolve => { + resolve({}) + }), stationInfo: { chargingStationId: `TEST-CS-${id}`, hashId: `test-hash-${id}`, @@ -304,12 +326,33 @@ export const createMockAuthServiceTestStation = ( export const createMockLocalAuthListManager = ( overrides?: Partial ): 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(resolve => { + resolve() + }), + clearAll: () => + new Promise(resolve => { + resolve() + }), + getAllEntries: () => + new Promise(resolve => { + resolve([]) + }), + getEntry: () => + new Promise(resolve => { + resolve(undefined) + }), + getVersion: () => + new Promise(resolve => { + resolve(1) + }), + removeEntry: () => + new Promise(resolve => { + resolve() + }), + updateVersion: () => + new Promise(resolve => { + resolve() + }), ...overrides, }) diff --git a/tests/charging-station/ocpp/auth/services/OCPPAuthServiceImpl.test.ts b/tests/charging-station/ocpp/auth/services/OCPPAuthServiceImpl.test.ts index 9553481d..7f437325 100644 --- a/tests/charging-station/ocpp/auth/services/OCPPAuthServiceImpl.test.ts +++ b/tests/charging-station/ocpp/auth/services/OCPPAuthServiceImpl.test.ts @@ -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() + }) + }) }) diff --git a/tests/charging-station/ocpp/auth/strategies/CertificateAuthStrategy.test.ts b/tests/charging-station/ocpp/auth/strategies/CertificateAuthStrategy.test.ts index 13df673b..c7189b5c 100644 --- a/tests/charging-station/ocpp/auth/strategies/CertificateAuthStrategy.test.ts +++ b/tests/charging-station/ocpp/auth/strategies/CertificateAuthStrategy.test.ts @@ -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(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) }) }) diff --git a/tests/charging-station/ocpp/auth/strategies/LocalAuthStrategy.test.ts b/tests/charging-station/ocpp/auth/strategies/LocalAuthStrategy.test.ts index 8d891f5d..9e5aa139 100644 --- a/tests/charging-station/ocpp/auth/strategies/LocalAuthStrategy.test.ts +++ b/tests/charging-station/ocpp/auth/strategies/LocalAuthStrategy.test.ts @@ -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(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(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(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) }) }) diff --git a/tests/charging-station/ocpp/auth/strategies/RemoteAuthStrategy.test.ts b/tests/charging-station/ocpp/auth/strategies/RemoteAuthStrategy.test.ts index d75964a4..f9c3204e 100644 --- a/tests/charging-station/ocpp/auth/strategies/RemoteAuthStrategy.test.ts +++ b/tests/charging-station/ocpp/auth/strategies/RemoteAuthStrategy.test.ts @@ -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(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(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(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(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(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) diff --git a/tests/charging-station/ocpp/auth/utils/AuthHelpers.test.ts b/tests/charging-station/ocpp/auth/utils/AuthHelpers.test.ts index c88a894b..2811dcdb 100644 --- a/tests/charging-station/ocpp/auth/utils/AuthHelpers.test.ts +++ b/tests/charging-station/ocpp/auth/utils/AuthHelpers.test.ts @@ -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 = { diff --git a/tests/charging-station/ocpp/auth/utils/AuthValidators.test.ts b/tests/charging-station/ocpp/auth/utils/AuthValidators.test.ts index 5279ea41..509830a8 100644 --- a/tests/charging-station/ocpp/auth/utils/AuthValidators.test.ts +++ b/tests/charging-station/ocpp/auth/utils/AuthValidators.test.ts @@ -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 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) diff --git a/src/charging-station/ocpp/auth/test/OCPPAuthIntegrationTest.ts b/tests/helpers/OCPPAuthIntegrationTest.ts similarity index 73% rename from src/charging-station/ocpp/auth/test/OCPPAuthIntegrationTest.ts rename to tests/helpers/OCPPAuthIntegrationTest.ts index 6e7c16dd..f05f97d5 100644 --- a/src/charging-station/ocpp/auth/test/OCPPAuthIntegrationTest.ts +++ b/tests/helpers/OCPPAuthIntegrationTest.ts @@ -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 { 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 { + private testConfigurationManagement (): void { const originalConfig = this.authService.getConfiguration() - // Test configuration update const updates: Partial = { 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 { - // 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 { - // 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 { - // 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 { - // 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 { - // 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 { + 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 diff --git a/tests/helpers/TestLifecycleHelpers.ts b/tests/helpers/TestLifecycleHelpers.ts index 0330d809..c2a6fcbe 100644 --- a/tests/helpers/TestLifecycleHelpers.ts +++ b/tests/helpers/TestLifecycleHelpers.ts @@ -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 => + new Promise(resolve => { + setTimeout(resolve, ms) + }) + /** * Execute a test function with mocked timers, ensuring cleanup on success or failure. * -- 2.53.0