)
return OCPP20Constants.OCPP_RESPONSE_REJECTED
}
- await authService.clearCache()
+ authService.clearCache()
logger.info(
`${chargingStation.logPrefix()} ${moduleName}.handleRequestClearCache: Authorization cache cleared`
)
* Check if remote authorization is available
* @returns True if remote authorization is enabled and station is online
*/
- isRemoteAvailable (): Promise<boolean> {
+ isRemoteAvailable (): boolean {
try {
// Check if station supports remote authorization
const remoteAuthEnabled = this.chargingStation.stationInfo?.remoteAuthorization === true
// 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
}
}
* @param config - Auth configuration to validate
* @returns True if configuration has valid auth methods and timeout values
*/
- validateConfiguration (config: AuthConfiguration): Promise<boolean> {
+ validateConfiguration (config: AuthConfiguration): boolean {
try {
// Check that at least one authorization method is enabled
const hasLocalAuth = config.localAuthListEnabled
logger.warn(
`${this.chargingStation.logPrefix()} OCPP 1.6 adapter: No authorization methods enabled`
)
- return Promise.resolve(false)
+ return false
}
// Validate timeout values
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
}
}
)
// Check if remote authorization is configured
- const isRemoteAuth = await this.isRemoteAvailable()
+ const isRemoteAuth = this.isRemoteAvailable()
if (!isRemoteAuth) {
return {
additionalInfo: {
* Check if remote authorization is available for OCPP 2.0
* @returns True if remote authorization is available and enabled
*/
- async isRemoteAvailable (): Promise<boolean> {
+ isRemoteAvailable (): boolean {
try {
// Check if station supports remote authorization via variables
// OCPP 2.0 uses variables instead of configuration keys
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
IdentifierType.KEY_CODE,
IdentifierType.E_MAID,
IdentifierType.MAC_ADDRESS,
+ IdentifierType.NO_AUTHORIZATION,
]
return validTypes.includes(identifier.type)
* @param config - Authentication configuration to validate
* @returns Promise resolving to true if configuration is valid for OCPP 2.0 operations
*/
- validateConfiguration (config: AuthConfiguration): Promise<boolean> {
+ validateConfiguration (config: AuthConfiguration): boolean {
try {
// Check that at least one authorization method is enabled
- const hasRemoteAuth = config.authorizeRemoteStart === true
- const hasLocalAuth = config.localAuthorizeOffline === true
- const hasCertAuth = config.certificateValidation === true
+ const hasRemoteAuth = config.remoteAuthorization === true
+ const hasLocalAuth = config.offlineAuthorizationEnabled
+ const hasCertAuth = config.certificateAuthEnabled
if (!hasRemoteAuth && !hasLocalAuth && !hasCertAuth) {
logger.warn(
`${this.chargingStation.logPrefix()} OCPP 2.0 adapter: No authorization methods enabled`
)
- return Promise.resolve(false)
+ return false
}
// Validate timeout values
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
}
}
component: string,
variable: string,
useDefaultFallback = true
- ): Promise<string | undefined> {
+ ): string | undefined {
try {
const variableManager = OCPP20VariableManager.getInstance()
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]
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)
}
}
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
}
* - 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
*
* - Rate limiting prevents DoS attacks on auth endpoints
* - Cache expiration ensures stale auth data doesn't persist
* - Memory limits prevent memory exhaustion attacks
+ * - Bounded rate limits map prevents unbounded memory growth
*/
export class InMemoryAuthCache implements AuthCache {
+ // Implementation-specific safety limit (not mandated by OCPP spec)
+ private static readonly DEFAULT_MAX_ABSOLUTE_LIFETIME_MS = 86_400_000 // 24 hours
+
/** Cache storage: identifier -> entry */
private readonly cache = new Map<string, CacheEntry>()
+ private cleanupInterval?: ReturnType<typeof setInterval>
+
/** Default TTL in seconds */
private readonly defaultTtl: number
/** Access order for LRU eviction (identifier -> last access timestamp) */
private readonly lruOrder = new Map<string, number>()
+ private readonly maxAbsoluteLifetimeMs: number
+
/** Maximum number of entries allowed in cache */
private readonly maxEntries: number
/**
* 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'}`
)
/**
* Clear all cached entries and rate limits
- * @returns Promise that resolves when cache is cleared
*/
- public async clear (): Promise<void> {
+ public clear (): void {
const entriesCleared = this.cache.size
this.cache.clear()
this.lruOrder.clear()
this.rateLimits.clear()
- this.resetStats()
logger.info(`InMemoryAuthCache: Cleared ${String(entriesCleared)} entries`)
- return Promise.resolve()
+ }
+
+ public dispose (): void {
+ if (this.cleanupInterval) {
+ clearInterval(this.cleanupInterval)
+ this.cleanupInterval = undefined
+ }
}
/**
* @param identifier - Identifier to look up
* @returns Cached result or undefined if not found/expired/rate-limited
*/
- public async get (identifier: string): Promise<AuthorizationResult | undefined> {
+ public get (identifier: string): AuthorizationResult | undefined {
// Check rate limiting first
if (!this.checkRateLimit(identifier)) {
this.stats.rateLimitBlocked++
- logger.warn(`InMemoryAuthCache: Rate limit exceeded for identifier: ${identifier}`)
- return Promise.resolve(undefined)
+ logger.warn(
+ `InMemoryAuthCache: Rate limit exceeded for identifier: ${this.truncateId(identifier)}`
+ )
+ return undefined
}
const entry = this.cache.get(identifier)
// Cache miss
if (!entry) {
this.stats.misses++
- return Promise.resolve(undefined)
+ return undefined
}
// Check expiration
const now = Date.now()
if (now >= entry.expiresAt) {
this.stats.expired++
- this.stats.misses++
- this.cache.delete(identifier)
- this.lruOrder.delete(identifier)
- logger.debug(`InMemoryAuthCache: Expired entry for identifier: ${identifier}`)
- return Promise.resolve(undefined)
+ // Transition to EXPIRED status instead of deleting (R10)
+ entry.result = { ...entry.result, status: AuthorizationStatus.EXPIRED }
+ // Apply absolute lifetime cap to expired-transition TTL refresh (default-TTL entries only)
+ if (!entry.hasExplicitTtl) {
+ const absoluteDeadline = entry.createdAt + this.maxAbsoluteLifetimeMs
+ if (absoluteDeadline > now) {
+ entry.expiresAt = Math.min(now + this.defaultTtl * 1000, absoluteDeadline)
+ }
+ }
+ this.lruOrder.set(identifier, now)
+ logger.debug(
+ `InMemoryAuthCache: Expired entry transitioned to EXPIRED for identifier: ${this.truncateId(identifier)}`
+ )
+ return entry.result
}
- // Cache hit - update LRU order
+ // Cache hit - update LRU order and reset TTL (R16, R5)
this.stats.hits++
this.lruOrder.set(identifier, now)
- logger.debug(`InMemoryAuthCache: Cache hit for identifier: ${identifier}`)
- return Promise.resolve(entry.result)
+ // Reset TTL on access for default-TTL entries only; explicit TTL entries (e.g. CSMS
+ // cacheExpiryDateTime) keep their original expiration per OCPP spec.
+ if (!entry.hasExplicitTtl && entry.createdAt + this.maxAbsoluteLifetimeMs > now) {
+ entry.expiresAt = now + this.defaultTtl * 1000
+ }
+
+ logger.debug(`InMemoryAuthCache: Cache hit for identifier: ${this.truncateId(identifier)}`)
+ return entry.result
}
/**
* Get cache statistics including rate limiting stats
* @returns Cache statistics with rate limiting metrics
*/
- public async getStats (): Promise<CacheStats & { rateLimit: RateLimitStats }> {
+ public getStats (): CacheStats & { rateLimit: RateLimitStats } {
const totalAccess = this.stats.hits + this.stats.misses
const hitRate = totalAccess > 0 ? (this.stats.hits / totalAccess) * 100 : 0
// 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,
totalChecks: this.stats.rateLimitChecks,
},
totalEntries: this.cache.size,
- })
+ }
+ }
+
+ public hasCleanupInterval (): boolean {
+ return this.cleanupInterval !== undefined
}
/**
* Remove a cached entry
* @param identifier - Identifier to remove
- * @returns Promise that resolves when entry is removed
*/
- public async remove (identifier: string): Promise<void> {
+ public remove (identifier: string): void {
const deleted = this.cache.delete(identifier)
this.lruOrder.delete(identifier)
if (deleted) {
- logger.debug(`InMemoryAuthCache: Removed entry for identifier: ${identifier}`)
+ logger.debug(
+ `InMemoryAuthCache: Removed entry for identifier: ${this.truncateId(identifier)}`
+ )
+ }
+ }
+
+ /**
+ * Reset statistics counters
+ */
+ public resetStats (): void {
+ this.stats = {
+ evictions: 0,
+ expired: 0,
+ hits: 0,
+ misses: 0,
+ rateLimitBlocked: 0,
+ rateLimitChecks: 0,
+ sets: 0,
+ }
+ }
+
+ public runCleanup (): void {
+ const now = Date.now()
+ for (const [key, entry] of this.cache.entries()) {
+ if (now >= entry.expiresAt) {
+ if (entry.result.status === AuthorizationStatus.EXPIRED) {
+ // Already transitioned by get() — delete on second expiration cycle
+ this.cache.delete(key)
+ this.lruOrder.delete(key)
+ } else {
+ // First expiration — transition to EXPIRED status (consistent with get() R10 semantics)
+ entry.result = { ...entry.result, status: AuthorizationStatus.EXPIRED }
+ if (!entry.hasExplicitTtl) {
+ const absoluteDeadline = entry.createdAt + this.maxAbsoluteLifetimeMs
+ if (absoluteDeadline > now) {
+ entry.expiresAt = Math.min(now + this.defaultTtl * 1000, absoluteDeadline)
+ }
+ }
+ this.stats.expired++
+ }
+ }
}
- return Promise.resolve()
+ this.cleanupExpiredRateLimits()
}
/**
* @param identifier - Identifier to cache
* @param result - Authorization result to cache
* @param ttl - Optional TTL override in seconds
- * @returns Promise that resolves when entry is cached
*/
- public async set (identifier: string, result: AuthorizationResult, ttl?: number): Promise<void> {
+ public set (identifier: string, result: AuthorizationResult, ttl?: number): void {
// Check rate limiting
if (!this.checkRateLimit(identifier)) {
this.stats.rateLimitBlocked++
- logger.warn(`InMemoryAuthCache: Rate limit exceeded, not caching identifier: ${identifier}`)
- return Promise.resolve()
+ logger.warn(
+ `InMemoryAuthCache: Rate limit exceeded, not caching identifier: ${this.truncateId(identifier)}`
+ )
+ return
}
// Evict LRU entry if cache is full
}
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)
+ }
}
/**
// No existing entry - create one
if (!entry) {
this.rateLimits.set(identifier, { count: 1, windowStart: now })
+ this.boundRateLimitsMap()
return true
}
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)}...`
}
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
- },
})
}
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,
cache: AuthCache | undefined,
config: AuthConfiguration
): Promise<AuthStrategy | undefined> {
- if (!config.localAuthListEnabled) {
+ if (
+ !config.localAuthListEnabled &&
+ !config.authorizationCacheEnabled &&
+ !config.offlineAuthorizationEnabled
+ ) {
return undefined
}
// Use static import - circular dependency is acceptable here
const { LocalAuthStrategy } = await import('../strategies/LocalAuthStrategy.js')
const strategy = new LocalAuthStrategy(manager, cache)
- await strategy.initialize(config)
+ strategy.initialize(config)
return strategy
}
* @param adapters.ocpp20Adapter - Optional OCPP 2.0.x protocol adapter
* @param cache - Authorization cache for storing remote auth results
* @param config - Authentication configuration controlling remote auth behavior
+ * @param localAuthListManager - Optional local auth list manager for R17 cache exclusion
* @returns Remote strategy instance or undefined if remote auth disabled
*/
static async createRemoteStrategy (
adapters: { ocpp16Adapter?: OCPP16AuthAdapter; ocpp20Adapter?: OCPP20AuthAdapter },
cache: AuthCache | undefined,
- config: AuthConfiguration
+ config: AuthConfiguration,
+ localAuthListManager?: LocalAuthListManager
): Promise<AuthStrategy | undefined> {
if (!config.remoteAuthorization) {
return undefined
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
}
}
// 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)
}
/**
* Clear all cached entries
*/
- clear(): Promise<void>
+ clear(): void
/**
* Get cached authorization result
* @param identifier - Identifier to look up
* @returns Cached result or undefined if not found/expired
*/
- get(identifier: string): Promise<AuthorizationResult | undefined>
+ get(identifier: string): AuthorizationResult | undefined
/**
* Get cache statistics
*/
- getStats(): Promise<CacheStats>
+ getStats(): CacheStats
/**
* Remove a cached entry
* @param identifier - Identifier to remove
*/
- remove(identifier: string): Promise<void>
+ remove(identifier: string): void
/**
* Cache an authorization result
* @param result - Authorization result to cache
* @param ttl - Optional TTL override in seconds
*/
- set(identifier: string, result: AuthorizationResult, ttl?: number): Promise<void>
+ set(identifier: string, result: AuthorizationResult, ttl?: number): void
}
/**
/**
* Cleanup strategy resources
*/
- cleanup(): Promise<void>
+ cleanup(): void
/**
* Optionally reconfigure the strategy at runtime
/**
* Get strategy-specific statistics
*/
- getStats(): Promise<Record<string, unknown>>
+ getStats(): Promise<Record<string, unknown>> | Record<string, unknown>
/**
* Initialize the strategy with configuration
* @param config - Authentication configuration
*/
- initialize(config: AuthConfiguration): Promise<void>
+ initialize(config: AuthConfiguration): Promise<void> | void
/**
* Strategy name for identification
/**
* Check if remote authorization is available
*/
- isRemoteAvailable(): Promise<boolean>
+ isRemoteAvailable(): boolean | Promise<boolean>
/**
* The OCPP version this adapter handles
/**
* Validate adapter configuration
*/
- validateConfiguration(config: AuthConfiguration): Promise<boolean>
+ validateConfiguration(config: AuthConfiguration): boolean
}
/**
/**
* Clear all cached authorizations
*/
- clearCache(): Promise<void>
+ clearCache(): void
/**
* Get current authentication configuration
* Invalidate cached authorization for an identifier
* @param identifier - Identifier to invalidate
*/
- invalidateCache(identifier: UnifiedIdentifier): Promise<void>
+ invalidateCache(identifier: UnifiedIdentifier): void
/**
* Check if an identifier is locally authorized (cache/local list)
/**
* Test connectivity to remote authorization service
*/
- testConnectivity(): Promise<boolean>
+ testConnectivity(): boolean
/**
* Update authentication configuration
* @param config - New configuration to apply
*/
- updateConfiguration(config: Partial<AuthConfiguration>): Promise<void>
+ updateConfiguration(config: Partial<AuthConfiguration>): void
}
strategyUsed: 'none',
},
isOffline: false,
- method: AuthenticationMethod.LOCAL_LIST,
+ method: AuthenticationMethod.NONE,
status: AuthorizationStatus.INVALID,
timestamp: new Date(),
}
/**
* Clear all cached authorizations
*/
- public async clearCache (): Promise<void> {
+ public clearCache (): void {
logger.debug(`${this.chargingStation.logPrefix()} Clearing all cached authorizations`)
// Clear cache in local strategy
const localStrategy = this.strategies.get('local') as LocalAuthStrategy | undefined
- if (localStrategy?.authCache) {
- await localStrategy.authCache.clear()
+ const localAuthCache = localStrategy?.getAuthCache()
+ if (localAuthCache) {
+ localAuthCache.clear()
logger.info(`${this.chargingStation.logPrefix()} Authorization cache cleared`)
} else {
logger.debug(`${this.chargingStation.logPrefix()} No authorization cache available to clear`)
* Invalidate cached authorization for an identifier
* @param identifier - Unified identifier whose cached authorization should be invalidated
*/
- public async invalidateCache (identifier: UnifiedIdentifier): Promise<void> {
+ public invalidateCache (identifier: UnifiedIdentifier): void {
logger.debug(
`${this.chargingStation.logPrefix()} Invalidating cache for identifier: ${identifier.value}`
)
// 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}`
)
* Test connectivity to remote authorization service
* @returns True if remote authorization service is reachable
*/
- public testConnectivity (): Promise<boolean> {
+ public testConnectivity (): boolean {
const remoteStrategy = this.strategies.get('remote')
if (!remoteStrategy) {
- return Promise.resolve(false)
+ return false
}
// For now return true - real implementation would test remote connectivity
- return Promise.resolve(true)
+ return true
}
/**
* Update authentication configuration
* @param config - Partial configuration object with values to update
- * @returns Promise that resolves when configuration is updated
* @throws {OCPPError} If configuration validation fails
*/
- public updateConfiguration (config: Partial<AuthConfiguration>): Promise<void> {
+ public updateConfiguration (config: Partial<AuthConfiguration>): void {
// Merge new config with existing
const newConfig = { ...this.config, ...config }
this.config = newConfig
logger.info(`${this.chargingStation.logPrefix()} Authentication configuration updated`)
- return Promise.resolve()
}
/**
localPreAuthorize: false,
maxCacheEntries: 1000,
offlineAuthorizationEnabled: true,
+ remoteAuthorization: true,
unknownIdAuthorization: AuthorizationStatus.INVALID,
}
}
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
)
} 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'
return hasAdapter && certAuthEnabled && hasCertificateData && this.isInitialized
}
- cleanup (): Promise<void> {
+ cleanup (): void {
this.isInitialized = false
logger.debug(
`${this.chargingStation.logPrefix()} Certificate authentication strategy cleaned up`
)
- return Promise.resolve()
}
- getStats (): Promise<Record<string, unknown>> {
- return Promise.resolve({
+ getStats (): Record<string, unknown> {
+ return {
...this.stats,
isInitialized: this.isInitialized,
- })
+ }
}
- initialize (config: AuthConfiguration): Promise<void> {
+ initialize (config: AuthConfiguration): void {
if (!config.certificateAuthEnabled) {
logger.info(`${this.chargingStation.logPrefix()} Certificate authentication disabled`)
- return Promise.resolve()
+ return
}
logger.info(
`${this.chargingStation.logPrefix()} Certificate authentication strategy initialized`
)
this.isInitialized = true
- return Promise.resolve()
}
/**
config: AuthConfiguration
): Promise<boolean> {
// Simulate validation delay
- await new Promise(resolve => setTimeout(resolve, 100))
+ await sleep(100)
// In a real implementation, this would:
// 1. Load trusted CA certificates from configuration
* 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 = {
// 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++
// 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++
* @param result - Authorization result to store in cache
* @param ttl - Optional time-to-live in seconds for cache entry
*/
- public async cacheResult (
- identifier: string,
- result: AuthorizationResult,
- ttl?: number
- ): Promise<void> {
+ public cacheResult (identifier: string, result: AuthorizationResult, ttl?: number): void {
if (!this.authCache) {
return
}
try {
- await this.authCache.set(identifier, result, ttl)
+ this.authCache.set(identifier, result, ttl)
logger.debug(`LocalAuthStrategy: Cached result for ${identifier}`)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
/**
* Cleanup strategy resources
- * @returns Promise that resolves when cleanup is complete
*/
- public cleanup (): Promise<void> {
+ public cleanup (): void {
logger.info('LocalAuthStrategy: Cleaning up...')
// Reset internal state
}
logger.info('LocalAuthStrategy: Cleanup completed')
- return Promise.resolve()
+ }
+
+ /**
+ * Get the authorization cache
+ * @returns The authorization cache or undefined if not available
+ */
+ public getAuthCache (): AuthCache | undefined {
+ return this.authCache
}
/**
* Get strategy statistics
* @returns Strategy statistics including hit rates, request counts, and cache status
*/
- public async getStats (): Promise<Record<string, unknown>> {
- const cacheStats = this.authCache ? await this.authCache.getStats() : null
+ public getStats (): Record<string, unknown> {
+ const cacheStats = this.authCache ? this.authCache.getStats() : null
return {
...this.stats,
/**
* Initialize strategy with configuration and dependencies
* @param config - Authentication configuration for strategy setup
- * @returns Promise that resolves when initialization completes
*/
- public initialize (config: AuthConfiguration): Promise<void> {
+ public initialize (config: AuthConfiguration): void {
try {
logger.info('LocalAuthStrategy: Initializing...')
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)) }
)
}
}
* Invalidate cached result for identifier
* @param identifier - Unique identifier string to remove from cache
*/
- public async invalidateCache (identifier: string): Promise<void> {
+ public invalidateCache (identifier: string): void {
if (!this.authCache) {
return
}
try {
- await this.authCache.remove(identifier)
+ this.authCache.remove(identifier)
logger.debug(`LocalAuthStrategy: Invalidated cache for ${identifier}`)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
* @param config - Authentication configuration (unused but required by interface)
* @returns Cached authorization result if found and not expired; undefined otherwise
*/
- private async checkAuthCache (
+ private checkAuthCache (
request: AuthRequest,
config: AuthConfiguration
- ): Promise<AuthorizationResult | undefined> {
+ ): AuthorizationResult | undefined {
if (!this.authCache) {
return undefined
}
try {
- const cachedResult = await this.authCache.get(request.identifier.value)
+ const cachedResult = this.authCache.get(request.identifier.value)
if (!cachedResult) {
return undefined
}
- // Check if cached result is still valid based on timestamp and TTL
- if (cachedResult.cacheTtl) {
- const expiry = new Date(cachedResult.timestamp.getTime() + cachedResult.cacheTtl * 1000)
- if (expiry < new Date()) {
- logger.debug(`LocalAuthStrategy: Cached entry ${request.identifier.value} expired`)
- // Remove expired entry
- await this.authCache.remove(request.identifier.value)
- return undefined
- }
- }
-
logger.debug(`LocalAuthStrategy: Cache hit for ${request.identifier.value}`)
return cachedResult
} catch (error) {
private handleOfflineFallback (
request: AuthRequest,
config: AuthConfiguration
- ): Promise<AuthorizationResult | undefined> {
+ ): AuthorizationResult | undefined {
logger.debug(`LocalAuthStrategy: Applying offline fallback for ${request.identifier.value}`)
// For transaction stops, always allow (safety requirement)
if (request.context === AuthContext.TRANSACTION_STOP) {
- return Promise.resolve({
+ return {
additionalInfo: { reason: 'Transaction stop - offline mode' },
isOffline: true,
method: AuthenticationMethod.OFFLINE_FALLBACK,
status: AuthorizationStatus.ACCEPTED,
timestamp: new Date(),
- })
+ }
}
// For unknown IDs, check configuration
if (config.allowOfflineTxForUnknownId) {
const status = config.unknownIdAuthorization ?? AuthorizationStatus.ACCEPTED
- return Promise.resolve({
+ return {
additionalInfo: { reason: 'Unknown ID allowed in offline mode' },
isOffline: true,
method: AuthenticationMethod.OFFLINE_FALLBACK,
status,
timestamp: new Date(),
- })
+ }
}
// Default offline behavior - reject unknown identifiers
- return Promise.resolve({
+ return {
additionalInfo: { reason: 'Unknown ID not allowed in offline mode' },
isOffline: true,
method: AuthenticationMethod.OFFLINE_FALLBACK,
status: AuthorizationStatus.INVALID,
timestamp: new Date(),
- })
+ }
}
/**
-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'
AuthenticationError,
AuthenticationMethod,
AuthErrorCode,
- AuthorizationStatus,
+ IdentifierType,
} from '../types/AuthTypes.js'
/**
private adapters = new Map<OCPPVersion, OCPPAuthAdapter>()
private authCache?: AuthCache
private isInitialized = false
+ private localAuthListManager?: LocalAuthListManager
private stats = {
avgResponseTimeMs: 0,
failedRemoteAuth: 0,
totalResponseTimeMs: 0,
}
- constructor (adapters?: Map<OCPPVersion, OCPPAuthAdapter>, authCache?: AuthCache) {
+ constructor (
+ adapters?: Map<OCPPVersion, OCPPAuthAdapter>,
+ authCache?: AuthCache,
+ localAuthListManager?: LocalAuthListManager
+ ) {
if (adapters) {
this.adapters = adapters
}
this.authCache = authCache
+ this.localAuthListManager = localAuthListManager
}
/**
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
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
)
}
}
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) {
// Can handle if we have an adapter for the identifier's OCPP version
const hasAdapter = this.adapters.has(request.identifier.ocppVersion)
- // Remote authorization must be enabled (not using local-only mode)
- const remoteEnabled = !config.localPreAuthorize
+ // Remote authorization must be enabled via configuration
+ const remoteEnabled = config.remoteAuthorization !== false
return hasAdapter && remoteEnabled
}
/**
* Cleanup strategy resources
- * @returns Promise that resolves when cleanup is complete
*/
- public cleanup (): Promise<void> {
+ public cleanup (): void {
logger.info('RemoteAuthStrategy: Cleaning up...')
// Reset internal state
}
logger.info('RemoteAuthStrategy: Cleanup completed')
- return Promise.resolve()
}
/**
* @returns Strategy statistics including success rates, response times, and error counts
*/
public async getStats (): Promise<Record<string, unknown>> {
- const cacheStats = this.authCache ? await this.authCache.getStats() : null
+ const cacheStats = this.authCache ? this.authCache.getStats() : null
const adapterStats = new Map<string, unknown>()
// Collect adapter availability status
* Initialize strategy with configuration and adapters
* @param config - Authentication configuration for adapter validation
*/
- public async initialize (config: AuthConfiguration): Promise<void> {
+ public initialize (config: AuthConfiguration): void {
try {
logger.info('RemoteAuthStrategy: Initializing...')
// 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 {
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
* @param identifier - Unique identifier string to use as cache key
* @param result - Authorization result to store in cache
* @param ttl - Optional time-to-live in seconds for cache entry
+ * @param identifierType - Identifier type to filter non-cacheable types (C02.FR.03, C03.FR.02)
*/
- private async cacheResult (
+ private cacheResult (
identifier: string,
result: AuthorizationResult,
- ttl?: number
- ): Promise<void> {
+ ttl?: number,
+ identifierType?: IdentifierType
+ ): void {
if (!this.authCache) {
return
}
+ // Per OCPP 2.0.1 C02.FR.03/C03.FR.02: NoAuthorization and Central tokens
+ // should not be cached as they represent system-level auth bypasses
+ if (
+ identifierType === IdentifierType.NO_AUTHORIZATION ||
+ identifierType === IdentifierType.CENTRAL
+ ) {
+ logger.debug(`RemoteAuthStrategy: Skipping cache for ${identifierType} identifier type`)
+ return
+ }
+
try {
// Use provided TTL or default cache lifetime
const cacheTtl = ttl ?? result.cacheTtl ?? 300 // Default 5 minutes
- await this.authCache.set(identifier, result, cacheTtl)
+ this.authCache.set(identifier, result, cacheTtl)
logger.debug(
- `RemoteAuthStrategy: Cached result for ${identifier} (TTL: ${String(cacheTtl)}s)`
+ `RemoteAuthStrategy: Cached result for ${identifier.substring(0, 8)}... (TTL: ${String(cacheTtl)}s)`
)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
config: AuthConfiguration
): Promise<boolean> {
try {
- // Use adapter's built-in availability check with timeout
- const timeout = (config.authorizationTimeout * 1000) / 2 // Use half timeout for availability check
- const availabilityPromise = adapter.isRemoteAvailable()
+ const timeout = (config.authorizationTimeout * 1000) / 2
+ let timeoutHandle: ReturnType<typeof setTimeout> | undefined
const result = await Promise.race([
- availabilityPromise,
+ Promise.resolve(adapter.isRemoteAvailable()),
new Promise<boolean>((_resolve, reject) => {
- setTimeout(() => {
+ timeoutHandle = setTimeout(() => {
reject(new AuthenticationError('Availability check timeout', AuthErrorCode.TIMEOUT))
}, timeout)
}),
])
+ clearTimeout(timeoutHandle)
return result
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
startTime: number
): Promise<AuthorizationResult | undefined> {
const timeout = config.authorizationTimeout * 1000
+ let timeoutHandle: ReturnType<typeof setTimeout> | undefined
try {
// Create the authorization promise
const result = await Promise.race([
authPromise,
new Promise<never>((_resolve, reject) => {
- setTimeout(() => {
+ timeoutHandle = setTimeout(() => {
reject(
new AuthenticationError(
`Remote authorization timeout after ${String(config.authorizationTimeout)}s`,
}),
])
+ 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
}
CACHE = 'Cache',
CERTIFICATE_BASED = 'CertificateBased',
LOCAL_LIST = 'LocalList',
+ NONE = 'None',
OFFLINE_FALLBACK = 'OfflineFallback',
REMOTE_AUTHORIZATION = 'RemoteAuthorization',
}
import { AuthContext, AuthenticationMethod, AuthorizationStatus } from '../types/AuthTypes.js'
/**
- * Authentication helper functions
- *
- * Provides utility functions for common authentication operations
- * such as creating requests, merging results, and formatting errors.
+ * Compute remaining TTL in seconds from an expiry date.
+ * @param expiryDate - Expiry timestamp to compute TTL from
+ * @returns TTL in seconds, or undefined if already expired or no date provided
*/
-// eslint-disable-next-line @typescript-eslint/no-extraneous-class
-export class AuthHelpers {
- /**
- * Calculate TTL from expiry date
- * @param expiryDate - The expiry date
- * @returns TTL in seconds, or undefined if no valid expiry
- */
- static calculateTTL (expiryDate?: Date): number | undefined {
- if (!expiryDate) {
- return undefined
- }
-
- const now = new Date()
- const ttlMs = expiryDate.getTime() - now.getTime()
+function calculateTTL (expiryDate?: Date): number | undefined {
+ if (!expiryDate) {
+ return undefined
+ }
- // Return undefined if already expired or invalid
- if (ttlMs <= 0) {
- return undefined
- }
+ const now = new Date()
+ const ttlMs = expiryDate.getTime() - now.getTime()
- // Convert to seconds and round down
- return Math.floor(ttlMs / 1000)
+ if (ttlMs <= 0) {
+ return undefined
}
- /**
- * Create a standard authentication request
- * @param identifier - The unified identifier to authenticate
- * @param context - The authentication context
- * @param connectorId - Optional connector ID
- * @param metadata - Optional additional metadata
- * @returns A properly formatted AuthRequest
- */
- static createAuthRequest (
- identifier: UnifiedIdentifier,
- context: AuthContext,
- connectorId?: number,
- metadata?: Record<string, unknown>
- ): AuthRequest {
- return {
- allowOffline: true, // Default to allowing offline if remote fails
- connectorId,
- context,
- identifier,
- metadata,
- timestamp: new Date(),
- }
- }
+ return Math.floor(ttlMs / 1000)
+}
- /**
- * Create a rejected authorization result
- * @param status - The rejection status
- * @param method - The authentication method that rejected
- * @param reason - Optional reason for rejection
- * @returns A rejected AuthorizationResult
- */
- static createRejectedResult (
- status: AuthorizationStatus,
- method: AuthenticationMethod,
- reason?: string
- ): AuthorizationResult {
- return {
- additionalInfo: reason ? { reason } : undefined,
- isOffline: false,
- method,
- status,
- timestamp: new Date(),
- }
+/**
+ * Build an AuthRequest with sensible defaults.
+ * @param identifier - Unified identifier for the request
+ * @param context - Authentication context
+ * @param connectorId - Optional connector ID
+ * @param metadata - Optional additional metadata
+ * @returns Fully populated AuthRequest
+ */
+function createAuthRequest (
+ identifier: UnifiedIdentifier,
+ context: AuthContext,
+ connectorId?: number,
+ metadata?: Record<string, unknown>
+): AuthRequest {
+ return {
+ allowOffline: true,
+ connectorId,
+ context,
+ identifier,
+ metadata,
+ timestamp: new Date(),
}
+}
- /**
- * Format authentication error message
- * @param error - The error to format
- * @param identifier - The identifier that failed authentication
- * @returns A user-friendly error message
- */
- static formatAuthError (error: Error, identifier: UnifiedIdentifier): string {
- const identifierValue = identifier.value.substring(0, 8) + '...'
- return `Authentication failed for identifier ${identifierValue} (${identifier.type}): ${error.message}`
+/**
+ * Build a rejected AuthorizationResult.
+ * @param status - Authorization status to assign
+ * @param method - Authentication method that produced the result
+ * @param reason - Optional human-readable rejection reason
+ * @returns AuthorizationResult with isOffline=false
+ */
+function createRejectedResult (
+ status: AuthorizationStatus,
+ method: AuthenticationMethod,
+ reason?: string
+): AuthorizationResult {
+ return {
+ additionalInfo: reason ? { reason } : undefined,
+ isOffline: false,
+ method,
+ status,
+ timestamp: new Date(),
}
+}
- /**
- * Get user-friendly status message
- * @param status - The authorization status
- * @returns A human-readable status message
- */
- static getStatusMessage (status: AuthorizationStatus): string {
- switch (status) {
- case AuthorizationStatus.ACCEPTED:
- return 'Authorization accepted'
- case AuthorizationStatus.BLOCKED:
- return 'Identifier is blocked'
- case AuthorizationStatus.CONCURRENT_TX:
- return 'Concurrent transaction in progress'
- case AuthorizationStatus.EXPIRED:
- return 'Authorization has expired'
- case AuthorizationStatus.INVALID:
- return 'Invalid identifier'
- case AuthorizationStatus.NOT_AT_THIS_LOCATION:
- return 'Not authorized at this location'
- case AuthorizationStatus.NOT_AT_THIS_TIME:
- return 'Not authorized at this time'
- case AuthorizationStatus.PENDING:
- return 'Authorization pending'
- case AuthorizationStatus.UNKNOWN:
- return 'Unknown authorization status'
- default:
- return 'Authorization failed'
- }
+/**
+ * Format an authentication error for logging.
+ * @param error - Error that occurred during authentication
+ * @param identifier - Identifier involved in the failed auth attempt
+ * @returns Formatted error string with truncated identifier
+ */
+function formatAuthError (error: Error, identifier: UnifiedIdentifier): string {
+ const identifierValue = identifier.value.substring(0, 8) + '...'
+ return `Authentication failed for identifier ${identifierValue} (${identifier.type}): ${error.message}`
+}
+
+/**
+ * Map an authorization status to a human-readable message.
+ * @param status - Authorization status to describe
+ * @returns Descriptive message for the status
+ */
+function getStatusMessage (status: AuthorizationStatus): string {
+ switch (status) {
+ case AuthorizationStatus.ACCEPTED:
+ return 'Authorization accepted'
+ case AuthorizationStatus.BLOCKED:
+ return 'Identifier is blocked'
+ case AuthorizationStatus.CONCURRENT_TX:
+ return 'Concurrent transaction in progress'
+ case AuthorizationStatus.EXPIRED:
+ return 'Authorization has expired'
+ case AuthorizationStatus.INVALID:
+ return 'Invalid identifier'
+ case AuthorizationStatus.NOT_AT_THIS_LOCATION:
+ return 'Not authorized at this location'
+ case AuthorizationStatus.NOT_AT_THIS_TIME:
+ return 'Not authorized at this time'
+ case AuthorizationStatus.PENDING:
+ return 'Authorization pending'
+ case AuthorizationStatus.UNKNOWN:
+ return 'Unknown authorization status'
+ default:
+ return 'Authorization failed'
}
+}
- /**
- * Check if an authorization result is cacheable
- *
- * Only Accepted results with reasonable expiry dates should be cached.
- * @param result - The authorization result to check
- * @returns True if the result should be cached, false otherwise
- */
- static isCacheable (result: AuthorizationResult): boolean {
- if (result.status !== AuthorizationStatus.ACCEPTED) {
- return false
- }
+/**
+ * Check whether an authorization result represents a permanent failure.
+ * @param result - Authorization result to evaluate
+ * @returns True if BLOCKED, EXPIRED, or INVALID
+ */
+function isPermanentFailure (result: AuthorizationResult): boolean {
+ return [
+ AuthorizationStatus.BLOCKED,
+ AuthorizationStatus.EXPIRED,
+ AuthorizationStatus.INVALID,
+ ].includes(result.status)
+}
- // Don't cache if no expiry date or already expired
- if (!result.expiryDate) {
- return false
- }
+/**
+ * Check whether an authorization result is still valid (ACCEPTED and not expired).
+ * @param result - Authorization result to evaluate
+ * @returns True if ACCEPTED and expiry date has not passed
+ */
+function isResultValid (result: AuthorizationResult): boolean {
+ if (result.status !== AuthorizationStatus.ACCEPTED) {
+ return false
+ }
- const now = new Date()
- if (result.expiryDate <= now) {
- return false
- }
+ if (!result.expiryDate) {
+ return true
+ }
- // Don't cache if expiry is too far in the future (> 1 year)
- const oneYearFromNow = new Date(now.getTime() + 365 * 24 * 60 * 60 * 1000)
- if (result.expiryDate > oneYearFromNow) {
- return false
- }
+ const now = new Date()
+ return result.expiryDate > now
+}
+/**
+ * Check whether an authorization result represents a temporary failure.
+ * @param result - Authorization result to evaluate
+ * @returns True if PENDING or UNKNOWN
+ */
+function isTemporaryFailure (result: AuthorizationResult): boolean {
+ if (result.status === AuthorizationStatus.PENDING) {
return true
}
- /**
- * Check if result indicates a permanent failure (should not retry)
- * @param result - The authorization result to check
- * @returns True if this is a permanent failure
- */
- static isPermanentFailure (result: AuthorizationResult): boolean {
- return [
- AuthorizationStatus.BLOCKED,
- AuthorizationStatus.EXPIRED,
- AuthorizationStatus.INVALID,
- ].includes(result.status)
+ if (result.status === AuthorizationStatus.UNKNOWN) {
+ return true
}
- /**
- * Check if authorization result is still valid (not expired)
- * @param result - The authorization result to check
- * @returns True if valid, false if expired or invalid
- */
- static isResultValid (result: AuthorizationResult): boolean {
- if (result.status !== AuthorizationStatus.ACCEPTED) {
- return false
- }
-
- // If no expiry date, consider valid
- if (!result.expiryDate) {
- return true
- }
+ return false
+}
- // Check if not expired
- const now = new Date()
- return result.expiryDate > now
+/**
+ * Merge multiple authorization results, preferring ACCEPTED.
+ * @param results - Array of results to merge
+ * @returns The first ACCEPTED result, or the first result with merged metadata
+ */
+function mergeAuthResults (results: AuthorizationResult[]): AuthorizationResult | undefined {
+ if (results.length === 0) {
+ return undefined
}
- /**
- * Check if result indicates a temporary failure (should retry)
- * @param result - The authorization result to check
- * @returns True if this is a temporary failure that could be retried
- */
- static isTemporaryFailure (result: AuthorizationResult): boolean {
- // Pending status indicates temporary state
- if (result.status === AuthorizationStatus.PENDING) {
- return true
- }
-
- // Unknown status might be temporary
- if (result.status === AuthorizationStatus.UNKNOWN) {
- return true
- }
-
- return false
+ const acceptedResult = results.find(r => r.status === AuthorizationStatus.ACCEPTED)
+ if (acceptedResult) {
+ return acceptedResult
}
- /**
- * Merge multiple authorization results (for fallback chains)
- *
- * Takes the first Accepted result, or merges error information
- * if all results are rejections.
- * @param results - Array of authorization results to merge
- * @returns The merged authorization result
- */
- static mergeAuthResults (results: AuthorizationResult[]): AuthorizationResult | undefined {
- if (results.length === 0) {
- return undefined
- }
-
- // Return first Accepted result
- const acceptedResult = results.find(r => r.status === AuthorizationStatus.ACCEPTED)
- if (acceptedResult) {
- return acceptedResult
- }
-
- // If no accepted results, merge information from all attempts
- const firstResult = results[0]
- const allMethods = results.map(r => r.method).join(', ')
-
- return {
- additionalInfo: {
- attemptedMethods: allMethods,
- totalAttempts: results.length,
- },
- isOffline: results.some(r => r.isOffline),
- method: firstResult.method,
- status: firstResult.status,
- timestamp: firstResult.timestamp,
- }
+ const firstResult = results[0]
+ const allMethods = results.map(r => r.method).join(', ')
+
+ return {
+ additionalInfo: {
+ attemptedMethods: allMethods,
+ totalAttempts: results.length,
+ },
+ isOffline: results.some(r => r.isOffline),
+ method: firstResult.method,
+ status: firstResult.status,
+ timestamp: firstResult.timestamp,
}
+}
- /**
- * Sanitize authorization result for logging
- *
- * Removes sensitive information before logging
- * @param result - The authorization result to sanitize
- * @returns Sanitized result safe for logging
- */
- static sanitizeForLogging (result: AuthorizationResult): Record<string, unknown> {
- return {
- hasExpiryDate: !!result.expiryDate,
- hasGroupId: !!result.groupId,
- hasPersonalMessage: !!result.personalMessage,
- isOffline: result.isOffline,
- method: result.method,
- status: result.status,
- timestamp: result.timestamp.toISOString(),
- }
+/**
+ * Strip sensitive data from an authorization result for safe logging.
+ * @param result - Authorization result to sanitize
+ * @returns Object with only safe-to-log fields
+ */
+function sanitizeForLogging (result: AuthorizationResult): Record<string, unknown> {
+ return {
+ hasExpiryDate: !!result.expiryDate,
+ hasGroupId: !!result.groupId,
+ hasPersonalMessage: !!result.personalMessage,
+ isOffline: result.isOffline,
+ method: result.method,
+ status: result.status,
+ timestamp: result.timestamp.toISOString(),
}
}
+
+export const AuthHelpers = {
+ calculateTTL,
+ createAuthRequest,
+ createRejectedResult,
+ formatAuthError,
+ getStatusMessage,
+ isPermanentFailure,
+ isResultValid,
+ isTemporaryFailure,
+ mergeAuthResults,
+ sanitizeForLogging,
+}
import type { AuthConfiguration, UnifiedIdentifier } from '../types/AuthTypes.js'
-import { AuthenticationMethod, IdentifierType } from '../types/AuthTypes.js'
+import { IdentifierType } from '../types/AuthTypes.js'
/**
* Authentication validation utilities
* 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,
+}
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,
}
| 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) |
// Create a mock auth service to verify clearCache is called
let clearCacheCalled = false
const mockAuthService = {
- clearCache: (): Promise<void> => {
+ clearCache: (): void => {
clearCacheCalled = true
- return Promise.resolve()
},
getConfiguration: () => ({
authorizationCacheEnabled: true,
await it('should return Rejected when AuthCacheEnabled is false', async () => {
// Create a mock auth service with cache disabled
const mockAuthService = {
- clearCache: (): Promise<void> => {
+ clearCache: (): void => {
throw new Error('clearCache should not be called when cache is disabled')
},
getConfiguration: () => ({
await it('should return Accepted when AuthCacheEnabled is true and clear succeeds', async () => {
// Create a mock auth service with cache enabled
const mockAuthService = {
- clearCache: (): Promise<void> => {
- // Successful clear
- return Promise.resolve()
+ clearCache: (): void => {
+ /* empty */
},
getConfiguration: () => ({
authorizationCacheEnabled: true,
await it('should return Rejected when clearCache throws an error', async () => {
// Create a mock auth service that throws on clearCache
const mockAuthService = {
- clearCache: (): Promise<void> => {
- return Promise.reject(new Error('Cache clear failed'))
+ clearCache: (): void => {
+ throw new Error('Cache clear failed')
},
getConfiguration: () => ({
authorizationCacheEnabled: true,
await it('should not attempt to clear cache when AuthCacheEnabled is false', async () => {
let clearCacheAttempted = false
const mockAuthService = {
- clearCache: (): Promise<void> => {
+ clearCache: (): void => {
clearCacheAttempted = true
- return Promise.resolve()
},
getConfiguration: () => ({
authorizationCacheEnabled: false,
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
}
})
})
+
+ await describe('Cache Spec Compliance Integration', async () => {
+ // G04.INT.01 - Cache wiring regression (T2)
+ await it('G04.INT.01: OCPPAuthServiceImpl wires auth cache into local strategy', async () => {
+ const result16 = createMockChargingStation({
+ baseName: 'TEST_CACHE_WIRING',
+ connectorsCount: 1,
+ stationInfo: {
+ chargingStationId: 'TEST_CACHE_WIRING',
+ ocppVersion: OCPPVersion.VERSION_16,
+ templateName: 'test-auth-template',
+ },
+ })
+ const service = new OCPPAuthServiceImpl(result16.station)
+ await service.initialize()
+
+ const localStrategy = service.getStrategy('local') as LocalAuthStrategy | undefined
+ expect(localStrategy).toBeDefined()
+
+ const authCache = localStrategy?.getAuthCache()
+ expect(authCache).toBeDefined()
+ })
+
+ // G04.INT.02 - All-status caching (T4)
+ await it('G04.INT.02: cache stores and retrieves all authorization statuses', () => {
+ const cache = new InMemoryAuthCache({ cleanupIntervalSeconds: 0 })
+ try {
+ const blockedResult = createMockAuthorizationResult({
+ status: AuthorizationStatus.BLOCKED,
+ })
+
+ cache.set('BLOCKED-ID', blockedResult)
+ const retrieved = cache.get('BLOCKED-ID')
+
+ expect(retrieved).toBeDefined()
+ expect(retrieved?.status).toBe(AuthorizationStatus.BLOCKED)
+ } finally {
+ cache.dispose()
+ }
+ })
+
+ // G04.INT.03 - Status-aware eviction (T5)
+ await it('G04.INT.03: eviction prefers ACCEPTED entries over non-ACCEPTED', () => {
+ const cache = new InMemoryAuthCache({ cleanupIntervalSeconds: 0, maxEntries: 3 })
+ try {
+ // Add 3 ACCEPTED entries
+ for (let i = 0; i < 3; i++) {
+ cache.set(
+ `ACCEPTED-${String(i)}`,
+ createMockAuthorizationResult({ status: AuthorizationStatus.ACCEPTED })
+ )
+ }
+
+ // Insert a BLOCKED entry — triggers eviction of one ACCEPTED entry
+ cache.set(
+ 'BLOCKED-ENTRY',
+ createMockAuthorizationResult({ status: AuthorizationStatus.BLOCKED })
+ )
+
+ const stats = cache.getStats()
+ expect(stats.totalEntries).toBe(3)
+
+ // BLOCKED entry must still exist
+ const blocked = cache.get('BLOCKED-ENTRY')
+ expect(blocked).toBeDefined()
+ expect(blocked?.status).toBe(AuthorizationStatus.BLOCKED)
+ } finally {
+ cache.dispose()
+ }
+ })
+
+ // G04.INT.04 - TTL sliding window (T6/R5/R16)
+ await it('G04.INT.04: cache hit resets TTL sliding window', async () => {
+ const cache = new InMemoryAuthCache({ cleanupIntervalSeconds: 0, defaultTtl: 1 })
+ try {
+ cache.set(
+ 'SLIDING-ID',
+ createMockAuthorizationResult({ status: AuthorizationStatus.ACCEPTED })
+ )
+
+ // Wait 500ms, then access to reset TTL
+ await sleep(500)
+ const midResult = cache.get('SLIDING-ID')
+ expect(midResult).toBeDefined()
+ expect(midResult?.status).toBe(AuthorizationStatus.ACCEPTED)
+
+ // Wait another 700ms (total 1200ms from initial set, but only 700ms from last access)
+ await sleep(700)
+ const lateResult = cache.get('SLIDING-ID')
+
+ // Entry should still be valid because TTL was reset at the 500ms access
+ expect(lateResult).toBeDefined()
+ expect(lateResult?.status).toBe(AuthorizationStatus.ACCEPTED)
+ } finally {
+ cache.dispose()
+ }
+ })
+
+ // G04.INT.05 - Expired entry transition (T7/R10)
+ await it('G04.INT.05: expired entries transition to EXPIRED status instead of being deleted', async () => {
+ const cache = new InMemoryAuthCache({ cleanupIntervalSeconds: 0, defaultTtl: 1 })
+ try {
+ cache.set(
+ 'EXPIRE-ID',
+ createMockAuthorizationResult({ status: AuthorizationStatus.ACCEPTED })
+ )
+
+ // Wait for TTL to expire
+ await sleep(1100)
+ const result = cache.get('EXPIRE-ID')
+
+ expect(result).toBeDefined()
+ expect(result?.status).toBe(AuthorizationStatus.EXPIRED)
+ } finally {
+ cache.dispose()
+ }
+ })
+
+ // G04.INT.06 - Local Auth List exclusion (T8/R17)
+ await it('G04.INT.06: identifiers from local auth list are not cached', async () => {
+ const cache = new InMemoryAuthCache({ cleanupIntervalSeconds: 0 })
+ try {
+ const listManager = createMockLocalAuthListManager({
+ getEntry: (id: string) =>
+ new Promise<LocalAuthEntry | undefined>(resolve => {
+ resolve(id === 'LIST-ID' ? { identifier: 'LIST-ID', status: 'accepted' } : undefined)
+ }),
+ })
+
+ const strategy = new LocalAuthStrategy(listManager, cache)
+ const config = createTestAuthConfig({
+ authorizationCacheEnabled: true,
+ localAuthListEnabled: true,
+ })
+ strategy.initialize(config)
+
+ const request = createMockAuthRequest({
+ identifier: createMockIdentifier(OCPPVersion.VERSION_16, 'LIST-ID'),
+ })
+ const result = await strategy.authenticate(request, config)
+
+ // Should be authorized from local list
+ expect(result).toBeDefined()
+ expect(result?.method).toBe(AuthenticationMethod.LOCAL_LIST)
+
+ // Verify cache does NOT contain the identifier (R17)
+ const cached = cache.get('LIST-ID')
+ expect(cached).toBeUndefined()
+ } finally {
+ cache.dispose()
+ }
+ })
+
+ // G04.INT.07 - Cache lifecycle with stats preservation (T11)
+ await it('G04.INT.07: clear preserves stats, resetStats zeroes them', () => {
+ const cache = new InMemoryAuthCache({ cleanupIntervalSeconds: 0 })
+ try {
+ // Perform some operations to generate stats
+ cache.set(
+ 'STATS-ID',
+ createMockAuthorizationResult({ status: AuthorizationStatus.ACCEPTED })
+ )
+ cache.get('STATS-ID')
+ cache.get('NONEXISTENT')
+
+ const statsBefore = cache.getStats()
+ expect(statsBefore.hits).toBeGreaterThan(0)
+ expect(statsBefore.misses).toBeGreaterThan(0)
+
+ // Clear entries — stats should be preserved
+ cache.clear()
+ const statsAfterClear = cache.getStats()
+ expect(statsAfterClear.totalEntries).toBe(0)
+ expect(statsAfterClear.hits).toBe(statsBefore.hits)
+ expect(statsAfterClear.misses).toBe(statsBefore.misses)
+
+ // Reset stats — counters should be zeroed
+ cache.resetStats()
+ const statsAfterReset = cache.getStats()
+ expect(statsAfterReset.hits).toBe(0)
+ expect(statsAfterReset.misses).toBe(0)
+ expect(statsAfterReset.evictions).toBe(0)
+ } finally {
+ cache.dispose()
+ }
+ })
+ })
})
inAcceptedState: () => true,
logPrefix: () => '[TEST-STATION]',
ocppRequestService: {
- requestHandler: (): Promise<OCPP16AuthorizeResponse> => {
- return Promise.resolve({
- idTagInfo: {
- expiryDate: new Date(Date.now() + 86400000),
- parentIdTag: undefined,
- status: OCPP16AuthorizationStatus.ACCEPTED,
- },
- })
- },
+ requestHandler: (): Promise<OCPP16AuthorizeResponse> =>
+ new Promise<OCPP16AuthorizeResponse>(resolve => {
+ resolve({
+ idTagInfo: {
+ expiryDate: new Date(Date.now() + 86400000),
+ parentIdTag: undefined,
+ status: OCPP16AuthorizationStatus.ACCEPTED,
+ },
+ })
+ }),
},
stationInfo: {
chargingStationId: 'TEST-001',
await it('should handle authorization failure gracefully', async () => {
// Override mock to simulate failure
- mockStation.ocppRequestService.requestHandler = (): Promise<never> => {
- return Promise.reject(new Error('Network error'))
- }
+ mockStation.ocppRequestService.requestHandler = (): Promise<never> =>
+ new Promise<never>((_resolve, reject) => {
+ reject(new Error('Network error'))
+ })
const identifier = createMockIdentifier(OCPPVersion.VERSION_16, 'TEST_TAG')
})
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,
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,
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,
remoteAuthorization: true,
}
- const isValid = await adapter.validateConfiguration(config)
+ const isValid = adapter.validateConfiguration(config)
expect(isValid).toBe(false)
})
})
await describe('authorizeRemote', async () => {
await it('should perform remote authorization successfully', async t => {
// Mock isRemoteAvailable to return true (avoids OCPP20VariableManager singleton issues)
- t.mock.method(adapter, 'isRemoteAvailable', () => Promise.resolve(true))
+ t.mock.method(adapter, 'isRemoteAvailable', () => true)
// Mock sendTransactionEvent to return accepted authorization
- t.mock.method(OCPP20ServiceUtils, 'sendTransactionEvent', () =>
- Promise.resolve({
- idTokenInfo: {
- status: OCPP20AuthorizationStatusEnumType.Accepted,
- },
- })
+ t.mock.method(
+ OCPP20ServiceUtils,
+ 'sendTransactionEvent',
+ () =>
+ new Promise<Record<string, unknown>>(resolve => {
+ resolve({
+ idTokenInfo: {
+ status: OCPP20AuthorizationStatusEnumType.Accepted,
+ },
+ })
+ })
)
const identifier = createMockIdentifier(
})
await describe('isRemoteAvailable', async () => {
- await it('should return true when station is online and remote start enabled', async t => {
+ await it('should return true when station is online and remote start enabled', t => {
t.mock.method(
- adapter as unknown as { getVariableValue: () => Promise<string | undefined> },
+ adapter as unknown as { getVariableValue: () => string | undefined },
'getVariableValue',
- () => Promise.resolve('true')
+ () => 'true'
)
- const isAvailable = await adapter.isRemoteAvailable()
+ const isAvailable = adapter.isRemoteAvailable()
expect(isAvailable).toBe(true)
})
- await it('should return false when station is offline', async t => {
+ await it('should return false when station is offline', t => {
mockStation.inAcceptedState = () => false
t.mock.method(
- adapter as unknown as { getVariableValue: () => Promise<string | undefined> },
+ adapter as unknown as { getVariableValue: () => string | undefined },
'getVariableValue',
- () => Promise.resolve('true')
+ () => 'true'
)
- const isAvailable = await adapter.isRemoteAvailable()
+ const isAvailable = adapter.isRemoteAvailable()
expect(isAvailable).toBe(false)
})
})
await describe('validateConfiguration', async () => {
- await it('should validate configuration with at least one auth method', async () => {
+ await it('should validate configuration with at least one auth method', () => {
const config: AuthConfiguration = {
allowOfflineTxForUnknownId: false,
authorizationCacheEnabled: false,
authorizationTimeout: 30,
- authorizeRemoteStart: true,
certificateAuthEnabled: false,
localAuthListEnabled: false,
- localAuthorizeOffline: false,
localPreAuthorize: false,
offlineAuthorizationEnabled: false,
+ remoteAuthorization: true,
}
- const isValid = await adapter.validateConfiguration(config)
+ const isValid = adapter.validateConfiguration(config)
expect(isValid).toBe(true)
})
- await it('should reject configuration with no auth methods', async () => {
+ await it('should reject configuration with no auth methods', () => {
const config: AuthConfiguration = {
allowOfflineTxForUnknownId: false,
authorizationCacheEnabled: false,
authorizationTimeout: 30,
- authorizeRemoteStart: false,
certificateAuthEnabled: false,
localAuthListEnabled: false,
- localAuthorizeOffline: false,
localPreAuthorize: false,
offlineAuthorizationEnabled: false,
+ remoteAuthorization: false,
}
- const isValid = await adapter.validateConfiguration(config)
+ const isValid = adapter.validateConfiguration(config)
expect(isValid).toBe(false)
})
- await it('should reject configuration with invalid timeout', async () => {
+ await it('should reject configuration with invalid timeout', () => {
const config: AuthConfiguration = {
allowOfflineTxForUnknownId: false,
authorizationCacheEnabled: false,
authorizationTimeout: 0,
- authorizeRemoteStart: true,
certificateAuthEnabled: false,
localAuthListEnabled: false,
- localAuthorizeOffline: true,
localPreAuthorize: false,
- offlineAuthorizationEnabled: false,
+ offlineAuthorizationEnabled: true,
+ remoteAuthorization: true,
}
- const isValid = await adapter.validateConfiguration(config)
+ const isValid = adapter.validateConfiguration(config)
expect(isValid).toBe(false)
})
})
})
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)
})
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)
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'
/**
})
})
- 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
})
// 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()
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%
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)
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)
})
})
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)
})
})
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)
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)
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)
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',
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()
+ })
+ })
})
import type { ChargingStation } from '../../../../../src/charging-station/ChargingStation.js'
import type {
AuthCache,
+ LocalAuthEntry,
LocalAuthListManager,
OCPPAuthAdapter,
OCPPAuthService,
*/
export const createMockAuthService = (overrides?: Partial<OCPPAuthService>): OCPPAuthService =>
({
- authorize: () =>
- Promise.resolve({
- expiresAt: new Date(Date.now() + 3600000),
- isOffline: false,
- method: AuthenticationMethod.LOCAL_LIST,
- status: AuthorizationStatus.ACCEPTED,
- timestamp: new Date(),
+ authorize: (_request: AuthRequest) =>
+ new Promise<AuthorizationResult>(resolve => {
+ resolve(
+ createMockAuthorizationResult({
+ method: AuthenticationMethod.LOCAL_LIST,
+ })
+ )
}),
- clearCache: () => Promise.resolve(),
+ clearCache: () => {
+ /* empty */
+ },
getConfiguration: () => ({}) as AuthConfiguration,
getStats: () =>
- Promise.resolve({
- avgResponseTime: 0,
- cacheHitRate: 0,
- failedAuth: 0,
- lastUpdated: new Date(),
- localUsageRate: 1,
- remoteSuccessRate: 0,
- successfulAuth: 0,
- totalRequests: 0,
+ new Promise<Record<string, unknown>>(resolve => {
+ resolve({
+ avgResponseTime: 0,
+ cacheHitRate: 0,
+ failedAuth: 0,
+ lastUpdated: new Date(),
+ localUsageRate: 1,
+ remoteSuccessRate: 0,
+ successfulAuth: 0,
+ totalRequests: 0,
+ })
+ }),
+ invalidateCache: () => {
+ /* empty */
+ },
+ isLocallyAuthorized: (_identifier: UnifiedIdentifier, _connectorId?: number) =>
+ new Promise<AuthorizationResult | undefined>(resolve => {
+ resolve(undefined)
}),
- invalidateCache: () => Promise.resolve(),
- isLocallyAuthorized: () => Promise.resolve(undefined),
- testConnectivity: () => Promise.resolve(true),
- updateConfiguration: () => Promise.resolve(),
+ testConnectivity: () => true,
+ updateConfiguration: () => {
+ /* empty */
+ },
...overrides,
}) as OCPPAuthService
/**
* Create a mock AuthCache for testing.
* @param overrides - Partial AuthCache methods to override defaults
- * @returns Mock AuthCache with stubbed async methods
+ * @returns Mock AuthCache with stubbed methods
*/
export const createMockAuthCache = (overrides?: Partial<AuthCache>): AuthCache => ({
- clear: async () => Promise.resolve(),
- get: async (_key: string) => Promise.resolve(undefined),
- getStats: async () =>
- Promise.resolve({
- evictions: 0,
- expiredEntries: 0,
- hitRate: 0,
- hits: 0,
- memoryUsage: 0,
- misses: 0,
- totalEntries: 0,
- }),
- remove: async (_key: string) => Promise.resolve(),
- set: async (_key: string, _value: unknown, _ttl?: number) => Promise.resolve(),
+ clear: () => {
+ /* empty */
+ },
+ get: (_key: string) => undefined,
+ getStats: () => ({
+ evictions: 0,
+ expiredEntries: 0,
+ hitRate: 0,
+ hits: 0,
+ memoryUsage: 0,
+ misses: 0,
+ totalEntries: 0,
+ }),
+ remove: (_key: string) => {
+ /* empty */
+ },
+ set: (_key: string, _value: unknown, _ttl?: number) => {
+ /* empty */
+ },
...overrides,
})
ocppVersion: OCPPVersion,
overrides?: Partial<OCPPAuthAdapter>
): OCPPAuthAdapter => ({
- authorizeRemote: async (_identifier: UnifiedIdentifier) =>
- Promise.resolve(
- createMockAuthorizationResult({
- method: AuthenticationMethod.REMOTE_AUTHORIZATION,
- })
- ),
+ authorizeRemote: (_identifier: UnifiedIdentifier) =>
+ new Promise<AuthorizationResult>(resolve => {
+ resolve(
+ createMockAuthorizationResult({
+ method: AuthenticationMethod.REMOTE_AUTHORIZATION,
+ })
+ )
+ }),
convertFromUnifiedIdentifier: (identifier: UnifiedIdentifier) =>
ocppVersion === OCPPVersion.VERSION_16
? identifier.value
: ((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,
})
// ============================================================================
idTagLocalAuthorized: () => false,
isConnected: () => true,
logPrefix: () => `[TEST-CS-${id}]`,
- sendRequest: () => Promise.resolve({}),
+ sendRequest: () =>
+ new Promise<Record<string, never>>(resolve => {
+ resolve({})
+ }),
stationInfo: {
chargingStationId: `TEST-CS-${id}`,
hashId: `test-hash-${id}`,
export const createMockLocalAuthListManager = (
overrides?: Partial<LocalAuthListManager>
): LocalAuthListManager => ({
- addEntry: async () => Promise.resolve(),
- clearAll: async () => Promise.resolve(),
- getAllEntries: async () => Promise.resolve([]),
- getEntry: async () => Promise.resolve(undefined),
- getVersion: async () => Promise.resolve(1),
- removeEntry: async () => Promise.resolve(),
- updateVersion: async () => Promise.resolve(),
+ addEntry: () =>
+ new Promise<void>(resolve => {
+ resolve()
+ }),
+ clearAll: () =>
+ new Promise<void>(resolve => {
+ resolve()
+ }),
+ getAllEntries: () =>
+ new Promise<LocalAuthEntry[]>(resolve => {
+ resolve([])
+ }),
+ getEntry: () =>
+ new Promise<LocalAuthEntry | undefined>(resolve => {
+ resolve(undefined)
+ }),
+ getVersion: () =>
+ new Promise<number>(resolve => {
+ resolve(1)
+ }),
+ removeEntry: () =>
+ new Promise<void>(resolve => {
+ resolve()
+ }),
+ updateVersion: () =>
+ new Promise<void>(resolve => {
+ resolve()
+ }),
...overrides,
})
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,
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,
})
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')
})
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()
})
})
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 = {
value: 'TAG_TO_INVALIDATE',
}
- await expect(authService.invalidateCache(identifier)).resolves.toBeUndefined()
+ expect(() => {
+ authService.invalidateCache(identifier)
+ }).not.toThrow()
})
})
})
expect(result.status).toBe(AuthorizationStatus.INVALID)
- expect(result.method).toBe(AuthenticationMethod.LOCAL_LIST)
+ expect(result.method).toBe(AuthenticationMethod.NONE)
})
})
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()
+ })
+ })
})
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'
} as unknown as ChargingStation
mockOCPP20Adapter = createMockOCPPAdapter(OCPPVersion.VERSION_20, {
- authorizeRemote: async () =>
- Promise.resolve(
- createMockAuthorizationResult({
- method: AuthenticationMethod.CERTIFICATE_BASED,
- })
- ),
+ authorizeRemote: () =>
+ new Promise<AuthorizationResult>(resolve => {
+ resolve(
+ createMockAuthorizationResult({
+ method: AuthenticationMethod.CERTIFICATE_BASED,
+ })
+ )
+ }),
convertToUnifiedIdentifier: identifier => ({
ocppVersion: OCPPVersion.VERSION_20,
type: IdentifierType.CERTIFICATE,
})
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', () => {
})
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 () => {
})
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)
})
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({
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)
})
})
import type {
AuthCache,
+ LocalAuthEntry,
LocalAuthListManager,
} from '../../../../../src/charging-station/ocpp/auth/interfaces/OCPPAuthService.js'
})
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()
})
})
})
await describe('authenticate', async () => {
- beforeEach(async () => {
+ beforeEach(() => {
const config = createTestAuthConfig({
authorizationCacheEnabled: true,
localAuthListEnabled: true,
offlineAuthorizationEnabled: true,
})
- await strategy.initialize(config)
+ strategy.initialize(config)
})
await it('should authenticate using local auth list', async () => {
- mockLocalAuthListManager.getEntry = async () =>
- await Promise.resolve({
- expiryDate: new Date(Date.now() + 86400000),
- identifier: 'LOCAL_TAG',
- metadata: { source: 'local' },
- status: 'accepted',
+ mockLocalAuthListManager.getEntry = () =>
+ new Promise<LocalAuthEntry | undefined>(resolve => {
+ resolve({
+ expiryDate: new Date(Date.now() + 86400000),
+ identifier: 'LOCAL_TAG',
+ metadata: { source: 'local' },
+ status: 'accepted',
+ })
})
const config = createTestAuthConfig({
})
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({
})
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 }
}
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')
}
method: AuthenticationMethod.REMOTE_AUTHORIZATION,
})
- await expect(strategy.cacheResult('TEST_TAG', result)).resolves.toBeUndefined()
+ expect(() => {
+ strategy.cacheResult('TEST_TAG', result)
+ }).not.toThrow()
})
})
await describe('invalidateCache', async () => {
- await it('should remove entry from cache', async () => {
+ await it('should remove entry from cache', () => {
let removedKey: string | undefined
- // eslint-disable-next-line @typescript-eslint/require-await
- mockAuthCache.remove = async (key: string) => {
+ mockAuthCache.remove = (key: string) => {
removedKey = key
}
- await strategy.invalidateCache('TEST_TAG')
+ strategy.invalidateCache('TEST_TAG')
expect(removedKey).toBe('TEST_TAG')
})
})
await describe('isInLocalList', async () => {
await it('should return true when identifier is in local list', async () => {
- mockLocalAuthListManager.getEntry = async () =>
- await Promise.resolve({
- identifier: 'LOCAL_TAG',
- status: 'accepted',
+ mockLocalAuthListManager.getEntry = () =>
+ new Promise<LocalAuthEntry | undefined>(resolve => {
+ resolve({
+ identifier: 'LOCAL_TAG',
+ status: 'accepted',
+ })
})
await expect(strategy.isInLocalList('LOCAL_TAG')).resolves.toBe(true)
})
await it('should return false when identifier is not in local list', async () => {
- // eslint-disable-next-line @typescript-eslint/require-await
- mockLocalAuthListManager.getEntry = async () => undefined
+ mockLocalAuthListManager.getEntry = () =>
+ new Promise<LocalAuthEntry | undefined>(resolve => {
+ resolve(undefined)
+ })
await expect(strategy.isInLocalList('UNKNOWN_TAG')).resolves.toBe(false)
})
})
await describe('getStats', async () => {
- await it('should return strategy statistics', async () => {
- const stats = await strategy.getStats()
+ await it('should return strategy statistics', () => {
+ const stats = strategy.getStats()
expect(stats.totalRequests).toBe(0)
expect(stats.cacheHits).toBe(0)
})
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)
})
})
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'
import { standardCleanup } from '../../../../helpers/TestLifecycleHelpers.js'
import {
createMockAuthCache,
+ createMockAuthorizationResult,
createMockAuthRequest,
createMockIdentifier,
+ createMockLocalAuthListManager,
createMockOCPPAdapter,
createTestAuthConfig,
} from '../helpers/MockFactories.js'
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)
adapters.set(OCPPVersion.VERSION_16, mockOCPP16Adapter)
adapters.set(OCPPVersion.VERSION_20, mockOCPP20Adapter)
- strategy = new RemoteAuthStrategy(adapters, mockAuthCache)
+ strategy = new RemoteAuthStrategy(adapters, mockAuthCache, mockLocalAuthListManager)
})
afterEach(() => {
})
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()
})
})
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(
})
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 () => {
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({
expect(cachedKey).toBe('CACHE_TAG')
})
+ await it('G03.FR.01.T4.01 - should cache BLOCKED authorization status', async () => {
+ let cachedKey: string | undefined
+ mockAuthCache.set = (key: string) => {
+ cachedKey = key
+ }
+ mockOCPP16Adapter.authorizeRemote = () =>
+ new Promise<AuthorizationResult>(resolve => {
+ resolve(
+ createMockAuthorizationResult({
+ method: AuthenticationMethod.REMOTE_AUTHORIZATION,
+ status: AuthorizationStatus.BLOCKED,
+ })
+ )
+ })
+
+ const config = createTestAuthConfig({
+ authorizationCacheEnabled: true,
+ authorizationCacheLifetime: 300,
+ })
+ const request = createMockAuthRequest({
+ identifier: createMockIdentifier(
+ OCPPVersion.VERSION_16,
+ 'BLOCKED_TAG',
+ IdentifierType.ID_TAG
+ ),
+ })
+
+ await strategy.authenticate(request, config)
+ expect(cachedKey).toBe('BLOCKED_TAG')
+ })
+
+ await it('G03.FR.01.T4.02 - should cache EXPIRED authorization status', async () => {
+ let cachedKey: string | undefined
+ mockAuthCache.set = (key: string) => {
+ cachedKey = key
+ }
+ mockOCPP16Adapter.authorizeRemote = () =>
+ new Promise<AuthorizationResult>(resolve => {
+ resolve(
+ createMockAuthorizationResult({
+ method: AuthenticationMethod.REMOTE_AUTHORIZATION,
+ status: AuthorizationStatus.EXPIRED,
+ })
+ )
+ })
+
+ const config = createTestAuthConfig({
+ authorizationCacheEnabled: true,
+ authorizationCacheLifetime: 300,
+ })
+ const request = createMockAuthRequest({
+ identifier: createMockIdentifier(
+ OCPPVersion.VERSION_16,
+ 'EXPIRED_TAG',
+ IdentifierType.ID_TAG
+ ),
+ })
+
+ await strategy.authenticate(request, config)
+ expect(cachedKey).toBe('EXPIRED_TAG')
+ })
+
+ await it('G03.FR.01.T4.03 - should cache INVALID authorization status', async () => {
+ let cachedKey: string | undefined
+ mockAuthCache.set = (key: string) => {
+ cachedKey = key
+ }
+ mockOCPP16Adapter.authorizeRemote = () =>
+ new Promise<AuthorizationResult>(resolve => {
+ resolve(
+ createMockAuthorizationResult({
+ method: AuthenticationMethod.REMOTE_AUTHORIZATION,
+ status: AuthorizationStatus.INVALID,
+ })
+ )
+ })
+
+ const config = createTestAuthConfig({
+ authorizationCacheEnabled: true,
+ authorizationCacheLifetime: 300,
+ })
+ const request = createMockAuthRequest({
+ identifier: createMockIdentifier(
+ OCPPVersion.VERSION_16,
+ 'INVALID_TAG',
+ IdentifierType.ID_TAG
+ ),
+ })
+
+ await strategy.authenticate(request, config)
+ expect(cachedKey).toBe('INVALID_TAG')
+ })
+
+ await it('G03.FR.01.T4.04 - should still cache ACCEPTED authorization status (regression)', async () => {
+ let cachedKey: string | undefined
+ mockAuthCache.set = (key: string) => {
+ cachedKey = key
+ }
+
+ const config = createTestAuthConfig({
+ authorizationCacheEnabled: true,
+ authorizationCacheLifetime: 300,
+ })
+ const request = createMockAuthRequest({
+ identifier: createMockIdentifier(
+ OCPPVersion.VERSION_16,
+ 'ACCEPTED_TAG',
+ IdentifierType.ID_TAG
+ ),
+ })
+
+ await strategy.authenticate(request, config)
+ expect(cachedKey).toBe('ACCEPTED_TAG')
+ })
+
await it('should return undefined when remote is unavailable', async () => {
- mockOCPP16Adapter.isRemoteAvailable = async () => Promise.resolve(false)
+ mockOCPP16Adapter.isRemoteAvailable = () => false
const config = createTestAuthConfig()
const request = createMockAuthRequest({
const result = await strategy.authenticate(request, config)
expect(result).toBeUndefined()
})
+
+ await it('G03.FR.01.T8.01 - should not cache identifier that is in local auth list', async () => {
+ let cachedKey: string | undefined
+ mockAuthCache.set = (key: string) => {
+ cachedKey = key
+ }
+
+ mockLocalAuthListManager.getEntry = (identifier: string) =>
+ new Promise<LocalAuthEntry | undefined>(resolve => {
+ if (identifier === 'LOCAL_AUTH_TAG') {
+ resolve({
+ identifier: 'LOCAL_AUTH_TAG',
+ status: 'Active',
+ })
+ } else {
+ resolve(undefined)
+ }
+ })
+
+ const config = createTestAuthConfig({
+ authorizationCacheEnabled: true,
+ authorizationCacheLifetime: 300,
+ localAuthListEnabled: true,
+ })
+ const request = createMockAuthRequest({
+ identifier: createMockIdentifier(
+ OCPPVersion.VERSION_16,
+ 'LOCAL_AUTH_TAG',
+ IdentifierType.ID_TAG
+ ),
+ })
+
+ await strategy.authenticate(request, config)
+ expect(cachedKey).toBeUndefined()
+ })
+
+ await it('G03.FR.01.T8.02 - should cache identifier that is not in local auth list', async () => {
+ let cachedKey: string | undefined
+ mockAuthCache.set = (key: string) => {
+ cachedKey = key
+ }
+
+ mockLocalAuthListManager.getEntry = () =>
+ new Promise<LocalAuthEntry | undefined>(resolve => {
+ resolve(undefined)
+ })
+
+ const config = createTestAuthConfig({
+ authorizationCacheEnabled: true,
+ authorizationCacheLifetime: 300,
+ localAuthListEnabled: true,
+ })
+ const request = createMockAuthRequest({
+ identifier: createMockIdentifier(
+ OCPPVersion.VERSION_16,
+ 'REMOTE_AUTH_TAG',
+ IdentifierType.ID_TAG
+ ),
+ })
+
+ await strategy.authenticate(request, config)
+ expect(cachedKey).toBe('REMOTE_AUTH_TAG')
+ })
})
await describe('adapter management', async () => {
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)
})
})
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)
})
})
await it('should include adapter statistics', async () => {
- await strategy.initialize(createTestAuthConfig())
+ strategy.initialize(createTestAuthConfig())
const stats = await strategy.getStats()
expect(stats.adapterStats).toBeDefined()
})
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)
})
})
- 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 = {
import {
type AuthConfiguration,
- AuthenticationMethod,
IdentifierType,
type UnifiedIdentifier,
} from '../../../../../src/charging-station/ocpp/auth/types/AuthTypes.js'
authorizationCacheLifetime: 3600,
authorizationTimeout: 30,
certificateAuthEnabled: false,
- enabledStrategies: [AuthenticationMethod.LOCAL_LIST],
localAuthListEnabled: true,
localPreAuthorize: true,
offlineAuthorizationEnabled: false,
expect(AuthValidators.validateAuthConfiguration(undefined as any)).toBe(false)
})
- await it('should return false for empty enabled strategies', () => {
- const config: AuthConfiguration = {
- allowOfflineTxForUnknownId: false,
- authorizationCacheEnabled: true,
- authorizationTimeout: 30,
- certificateAuthEnabled: false,
- enabledStrategies: [],
- localAuthListEnabled: true,
- localPreAuthorize: true,
- offlineAuthorizationEnabled: false,
- }
-
- expect(AuthValidators.validateAuthConfiguration(config)).toBe(false)
- })
-
- await it('should return false for missing enabled strategies', () => {
+ await it('should return false for missing required boolean fields', () => {
const config = {
allowOfflineTxForUnknownId: false,
authorizationCacheEnabled: true,
authorizationTimeout: 30,
- certificateAuthEnabled: false,
localAuthListEnabled: true,
localPreAuthorize: true,
offlineAuthorizationEnabled: false,
+ // certificateAuthEnabled missing
} as Partial<AuthConfiguration>
expect(AuthValidators.validateAuthConfiguration(config)).toBe(false)
})
- await it('should return false for invalid remote auth timeout', () => {
+ await it('should return false for non-positive authorization timeout', () => {
const config: AuthConfiguration = {
allowOfflineTxForUnknownId: false,
authorizationCacheEnabled: true,
- authorizationTimeout: 30,
+ authorizationTimeout: 0,
certificateAuthEnabled: false,
- enabledStrategies: [AuthenticationMethod.LOCAL_LIST],
localAuthListEnabled: true,
localPreAuthorize: true,
offlineAuthorizationEnabled: false,
- remoteAuthTimeout: -1,
}
expect(AuthValidators.validateAuthConfiguration(config)).toBe(false)
})
- await it('should return false for invalid local auth cache TTL', () => {
+ await it('should return false for negative cache lifetime', () => {
const config: AuthConfiguration = {
allowOfflineTxForUnknownId: false,
authorizationCacheEnabled: true,
+ authorizationCacheLifetime: -100,
authorizationTimeout: 30,
certificateAuthEnabled: false,
- enabledStrategies: [AuthenticationMethod.LOCAL_LIST],
- localAuthCacheTTL: -100,
localAuthListEnabled: true,
localPreAuthorize: true,
offlineAuthorizationEnabled: false,
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)
-/**
- * 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
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
`${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) {
failed++
}
- // Test 2: Configuration Management
try {
- await this.testConfigurationManagement()
+ this.testConfigurationManagement()
results.push('✅ Configuration Management - PASSED')
passed++
} catch (error) {
failed++
}
- // Test 3: Strategy Selection Logic
try {
- await this.testStrategySelection()
+ this.testStrategySelection()
results.push('✅ Strategy Selection Logic - PASSED')
passed++
} catch (error) {
failed++
}
- // Test 4: OCPP 1.6 Authentication Flow
try {
await this.testOCPP16AuthFlow()
results.push('✅ OCPP 1.6 Authentication Flow - PASSED')
failed++
}
- // Test 5: OCPP 2.0 Authentication Flow
try {
await this.testOCPP20AuthFlow()
results.push('✅ OCPP 2.0 Authentication Flow - PASSED')
failed++
}
- // Test 6: Error Handling
try {
await this.testErrorHandling()
results.push('✅ Error Handling - PASSED')
failed++
}
- // Test 7: Cache Operations
try {
await this.testCacheOperations()
results.push('✅ Cache Operations - PASSED')
failed++
}
- // Test 8: Performance and Statistics
try {
await this.testPerformanceAndStats()
results.push('✅ Performance and Statistics - PASSED')
return { failed, passed, results }
}
- /**
- * Test 7: Cache Operations
- */
private async testCacheOperations (): Promise<void> {
const testIdentifier: UnifiedIdentifier = {
ocppVersion:
value: 'CACHE_TEST_ID',
}
- // Test cache invalidation (should not throw)
- await this.authService.invalidateCache(testIdentifier)
-
- // Test cache clearing (should not throw)
- await this.authService.clearCache()
-
- // Test local authorization check after cache operations
+ this.authService.invalidateCache(testIdentifier)
+ this.authService.clearCache()
await this.authService.isLocallyAuthorized(testIdentifier)
- // Result can be undefined, which is valid
logger.debug(`${this.chargingStation.logPrefix()} Cache operations tested`)
}
- /**
- * Test 2: Configuration Management
- */
- private async testConfigurationManagement (): Promise<void> {
+ private testConfigurationManagement (): void {
const originalConfig = this.authService.getConfiguration()
- // Test configuration update
const updates: Partial<AuthConfiguration> = {
authorizationTimeout: 60,
localAuthListEnabled: false,
maxCacheEntries: 2000,
}
- await this.authService.updateConfiguration(updates)
+ this.authService.updateConfiguration(updates)
const updatedConfig = this.authService.getConfiguration()
- // Verify updates applied
if (updatedConfig.authorizationTimeout !== 60) {
throw new Error('Configuration update failed: authorizationTimeout')
}
throw new Error('Configuration update failed: maxCacheEntries')
}
- // Restore original configuration
- await this.authService.updateConfiguration(originalConfig)
+ this.authService.updateConfiguration(originalConfig)
logger.debug(`${this.chargingStation.logPrefix()} Configuration management test completed`)
}
- /**
- * Test 6: Error Handling
- */
private async testErrorHandling (): Promise<void> {
- // Test with invalid identifier
const invalidIdentifier: UnifiedIdentifier = {
ocppVersion: OCPPVersion.VERSION_16,
type: IdentifierType.ISO14443,
const invalidRequest: AuthRequest = {
allowOffline: false,
- connectorId: 999, // Invalid connector
+ connectorId: 999,
context: AuthContext.TRANSACTION_START,
identifier: invalidIdentifier,
timestamp: new Date(),
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')
}
logger.debug(`${this.chargingStation.logPrefix()} Error handling verified`)
}
- /**
- * Test 4: OCPP 1.6 Authentication Flow
- */
private async testOCPP16AuthFlow (): Promise<void> {
- // Create test request for OCPP 1.6
const identifier: UnifiedIdentifier = {
ocppVersion: OCPPVersion.VERSION_16,
type: IdentifierType.ISO14443,
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)
logger.debug(`${this.chargingStation.logPrefix()} OCPP 1.6 authentication flow tested`)
}
- /**
- * Test 5: OCPP 2.0 Authentication Flow
- */
private async testOCPP20AuthFlow (): Promise<void> {
- // Create test request for OCPP 2.0
const identifier: UnifiedIdentifier = {
ocppVersion: OCPPVersion.VERSION_20,
type: IdentifierType.ISO15693,
timestamp: new Date(),
}
- // Test authentication with different contexts
const contexts = [
AuthContext.TRANSACTION_START,
AuthContext.TRANSACTION_STOP,
logger.debug(`${this.chargingStation.logPrefix()} OCPP 2.0 authentication flow tested`)
}
- /**
- * Test 8: Performance and Statistics
- */
private async testPerformanceAndStats (): Promise<void> {
- // Test connectivity check
- const connectivity = await this.authService.testConnectivity()
+ const connectivity = this.authService.testConnectivity()
if (typeof connectivity !== 'boolean') {
throw new Error('Invalid connectivity test result')
}
- // Test statistics retrieval
const stats = await this.authService.getStats()
if (typeof stats.totalRequests !== 'number') {
throw new Error('Invalid statistics object')
}
- // Test authentication statistics
const authStats = this.authService.getAuthenticationStats()
if (!Array.isArray(authStats.availableStrategies)) {
throw new Error('Invalid authentication statistics')
}
- // Performance test - multiple rapid authentication requests
const identifier: UnifiedIdentifier = {
ocppVersion:
this.chargingStation.stationInfo?.ocppVersion === OCPPVersion.VERSION_16
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`)
}
)
}
- /**
- * Test 1: Service Initialization
- * @returns Promise that resolves when test passes
- */
- private testServiceInitialization (): Promise<void> {
- // Service is always initialized in constructor, no need to check
-
- // Check available strategies
+ private testServiceInitialization (): void {
const strategies = this.authService.getAvailableStrategies()
if (strategies.length === 0) {
throw new Error('No authentication strategies available')
}
- // Check configuration
const config = this.authService.getConfiguration()
if (typeof config !== 'object') {
throw new Error('Invalid configuration object')
}
- // Check stats
const stats = this.authService.getAuthenticationStats()
if (!stats.ocppVersion) {
throw new Error('Invalid authentication statistics')
logger.debug(
`${this.chargingStation.logPrefix()} Service initialized with ${String(strategies.length)} strategies`
)
-
- return Promise.resolve()
}
- /**
- * Test 3: Strategy Selection Logic
- * @returns Promise that resolves when test passes
- */
- private testStrategySelection (): Promise<void> {
+ private testStrategySelection (): void {
const strategies = this.authService.getAvailableStrategies()
- // Test each strategy individually
for (const strategyName of strategies) {
const strategy = this.authService.getStrategy(strategyName)
if (!strategy) {
}
}
- // Test identifier support detection
const testIdentifier: UnifiedIdentifier = {
ocppVersion:
this.chargingStation.stationInfo?.ocppVersion === OCPPVersion.VERSION_16
}
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')
}
/**
- * 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
MockIdTagsCache.resetInstance()
}
+/**
+ * Suspends execution for the specified number of milliseconds.
+ * @param ms - Duration to sleep in milliseconds.
+ * @returns A promise that resolves after the specified delay.
+ */
+export const sleep = (ms: number): Promise<void> =>
+ new Promise(resolve => {
+ setTimeout(resolve, ms)
+ })
+
/**
* Execute a test function with mocked timers, ensuring cleanup on success or failure.
*