import { strict as assert } from 'node:assert'; import { Token } from './token'; import { IDPError, RetryPolicy, TokenManager, TokenManagerConfig, TokenStreamListener } from './token-manager'; import { IdentityProvider, TokenResponse } from './identity-provider'; import { setTimeout } from 'timers/promises'; describe('TokenManager', () => { /** * Helper function to delay execution for a given number of milliseconds. * @param ms */ const delay = (ms: number) => { return setTimeout(ms); } /** * IdentityProvider that returns a fixed test token for testing and doesn't handle TTL. */ class TestIdentityProvider implements IdentityProvider { requestToken(): Promise> { return Promise.resolve({ token: 'test-token 1', ttlMs: 1000 }); } } /** * Helper function to create a test token with a given TTL . * @param ttlMs Time-to-live in milliseconds */ const createToken = (ttlMs: number): Token => { return new Token('test-token', ttlMs, 0); }; /** * Listener that records received tokens and errors for testing. */ class TestListener implements TokenStreamListener { public readonly receivedTokens: Token[] = []; public readonly errors: IDPError[] = []; onNext(token: Token): void { this.receivedTokens.push(token); } onError(error: IDPError): void { this.errors.push(error); } } /** * IdentityProvider that returns a sequence of tokens with a fixed delay simulating network latency. * Used for testing token refresh scenarios. */ class ControlledIdentityProvider implements IdentityProvider { private tokenIndex = 0; private readonly delayMs: number; private readonly ttlMs: number; constructor( private readonly tokens: string[], delayMs: number = 0, tokenTTlMs: number = 100 ) { this.delayMs = delayMs; this.ttlMs = tokenTTlMs; } async requestToken(): Promise> { if (this.tokenIndex >= this.tokens.length) { throw new Error('No more test tokens available'); } if (this.delayMs > 0) { await setTimeout(this.delayMs); } return { token: this.tokens[this.tokenIndex++], ttlMs: this.ttlMs }; } } /** * IdentityProvider that simulates various error scenarios with configurable behavior */ class ErrorSimulatingProvider implements IdentityProvider { private requestCount = 0; constructor( private readonly errorSequence: Array, private readonly delayMs: number = 0, private readonly ttlMs: number = 100 ) {} async requestToken(): Promise> { if (this.delayMs > 0) { await delay(this.delayMs); } const result = this.errorSequence[this.requestCount]; this.requestCount++; if (result instanceof Error) { throw result; } else if (typeof result === 'string') { return { token: result, ttlMs: this.ttlMs }; } else { throw new Error('No more responses configured'); } } getRequestCount(): number { return this.requestCount; } } describe('constructor validation', () => { it('should throw error if ratio is greater than 1', () => { const config: TokenManagerConfig = { expirationRefreshRatio: 1.1 }; assert.throws( () => new TokenManager(new TestIdentityProvider(), config), /expirationRefreshRatio must be less than or equal to 1/ ); }); it('should throw error if ratio is negative', () => { const config: TokenManagerConfig = { expirationRefreshRatio: -0.1 }; assert.throws( () => new TokenManager(new TestIdentityProvider(), config), /expirationRefreshRatio must be greater or equal to 0/ ); }); it('should accept ratio of 1', () => { const config: TokenManagerConfig = { expirationRefreshRatio: 1 }; assert.doesNotThrow( () => new TokenManager(new TestIdentityProvider(), config) ); }); it('should accept ratio of 0', () => { const config: TokenManagerConfig = { expirationRefreshRatio: 0 }; assert.doesNotThrow( () => new TokenManager(new TestIdentityProvider(), config) ); }); }); describe('calculateRefreshTime', () => { it('should calculate correct refresh time with 0.8 ratio', () => { const config: TokenManagerConfig = { expirationRefreshRatio: 0.8 }; const manager = new TokenManager(new TestIdentityProvider(), config); const token = createToken(1000); const refreshTime = manager.calculateRefreshTime(token, 0); // With 1000s TTL and 0.8 ratio, should refresh at 800s assert.equal(refreshTime, 800); }); it('should return 0 for ratio of 0', () => { const config: TokenManagerConfig = { expirationRefreshRatio: 0 }; const manager = new TokenManager(new TestIdentityProvider(), config); const token = createToken(1000); const refreshTime = manager.calculateRefreshTime(token, 0); assert.equal(refreshTime, 0); }); it('should refresh at expiration time with ratio of 1', () => { const config: TokenManagerConfig = { expirationRefreshRatio: 1 }; const manager = new TokenManager(new TestIdentityProvider(), config); const token = createToken(1000); const refreshTime = manager.calculateRefreshTime(token, 0); assert.equal(refreshTime, 1000); }); it('should handle short TTL tokens', () => { const config: TokenManagerConfig = { expirationRefreshRatio: 0.8 }; const manager = new TokenManager(new TestIdentityProvider(), config); const token = createToken(5); const refreshTime = manager.calculateRefreshTime(token, 0); assert.equal(refreshTime, 4); }); it('should handle expired tokens', () => { const config: TokenManagerConfig = { expirationRefreshRatio: 0.8 }; const manager = new TokenManager(new TestIdentityProvider(), config); // Create token that expired 100s ago const token = createToken(-100); const refreshTime = manager.calculateRefreshTime(token, 0); // Should return refresh time of 0 for expired tokens assert.equal(refreshTime, 0); }); describe('token refresh scenarios', () => { describe('token refresh', () => { it('should handle token refresh', async () => { const networkDelay = 20; const tokenTtl = 100; const config: TokenManagerConfig = { expirationRefreshRatio: 0.8 }; const identityProvider = new ControlledIdentityProvider(['token1', 'token2', 'token3'], networkDelay, tokenTtl); const manager = new TokenManager(identityProvider, config); const listener = new TestListener(); const disposable = manager.start(listener); assert.equal(manager.getCurrentToken(), null, 'Should not have token yet'); // Wait for the first token request to complete ( it should be immediate, and we should wait only for the network delay) await delay(networkDelay) assert.equal(listener.receivedTokens.length, 1, 'Should receive initial token'); assert.equal(listener.receivedTokens[0].value, 'token1', 'Should have correct token value'); assert.equal(listener.receivedTokens[0].expiresAtMs - listener.receivedTokens[0].receivedAtMs, tokenTtl, 'Should have correct TTL'); assert.equal(listener.errors.length, 0, 'Should not have any errors: ' + listener.errors); assert.equal(manager.getCurrentToken().value, 'token1', 'Should have current token'); await delay(80); assert.equal(listener.receivedTokens.length, 1, 'Should not receive new token yet'); assert.equal(listener.errors.length, 0, 'Should not have any errors'); await delay(networkDelay); assert.equal(listener.receivedTokens.length, 2, 'Should receive second token'); assert.equal(listener.receivedTokens[1].value, 'token2', 'Should have correct token value'); assert.equal(listener.receivedTokens[1].expiresAtMs - listener.receivedTokens[1].receivedAtMs, tokenTtl, 'Should have correct TTL'); assert.equal(listener.errors.length, 0, 'Should not have any errors'); assert.equal(manager.getCurrentToken().value, 'token2', 'Should have current token'); await delay(80); assert.equal(listener.receivedTokens.length, 2, 'Should not receive new token yet'); assert.equal(listener.errors.length, 0, 'Should not have any errors'); await delay(networkDelay); assert.equal(listener.receivedTokens.length, 3, 'Should receive third token'); assert.equal(listener.receivedTokens[2].value, 'token3', 'Should have correct token value'); assert.equal(listener.receivedTokens[2].expiresAtMs - listener.receivedTokens[2].receivedAtMs, tokenTtl, 'Should have correct TTL'); assert.equal(listener.errors.length, 0, 'Should not have any errors'); assert.equal(manager.getCurrentToken().value, 'token3', 'Should have current token'); disposable?.dispose(); }); }); }); }); describe('TokenManager error handling', () => { describe('error scenarios', () => { it('should not recover if retries are not enabled', async () => { const networkDelay = 20; const tokenTtl = 100; const config: TokenManagerConfig = { expirationRefreshRatio: 0.8 }; const identityProvider = new ErrorSimulatingProvider( [ 'token1', new Error('Fatal error'), 'token3' ], networkDelay, tokenTtl ); const manager = new TokenManager(identityProvider, config); const listener = new TestListener(); const disposable = manager.start(listener); await delay(networkDelay); assert.equal(listener.receivedTokens.length, 1, 'Should receive initial token'); assert.equal(listener.receivedTokens[0].value, 'token1', 'Should have correct initial token'); assert.equal(listener.receivedTokens[0].expiresAtMs - listener.receivedTokens[0].receivedAtMs, tokenTtl, 'Should have correct TTL'); assert.equal(listener.errors.length, 0, 'Should not have errors yet'); await delay(80); assert.equal(listener.receivedTokens.length, 1, 'Should not receive new token yet'); assert.equal(listener.errors.length, 0, 'Should not have any errors'); await delay(networkDelay); assert.equal(listener.receivedTokens.length, 1, 'Should not receive new token after failure'); assert.equal(listener.errors.length, 1, 'Should receive error'); assert.equal(listener.errors[0].message, 'Fatal error', 'Should have correct error message'); assert.equal(listener.errors[0].isRetryable, false, 'Should be a fatal error'); // verify that the token manager is stopped and no more requests are made after the error and expected refresh time await delay(80); assert.equal(identityProvider.getRequestCount(), 2, 'Should not make more requests after error'); assert.equal(listener.receivedTokens.length, 1, 'Should not receive new token after error'); assert.equal(listener.errors.length, 1, 'Should not receive more errors after error'); assert.equal(manager.isRunning(), false, 'Should stop token manager after error'); disposable?.dispose(); }); it('should handle retries with exponential backoff', async () => { const networkDelay = 20; const tokenTtl = 100; const config: TokenManagerConfig = { expirationRefreshRatio: 0.8, retry: { maxAttempts: 3, initialDelayMs: 100, maxDelayMs: 1000, backoffMultiplier: 2, isRetryable: (error: unknown) => error instanceof Error && error.message === 'Temporary failure' } }; const identityProvider = new ErrorSimulatingProvider( [ 'initial-token', new Error('Temporary failure'), // First attempt fails new Error('Temporary failure'), // First retry fails 'recovery-token' // Second retry succeeds ], networkDelay, tokenTtl ); const manager = new TokenManager(identityProvider, config); const listener = new TestListener(); const disposable = manager.start(listener); // Wait for initial token await delay(networkDelay); assert.equal(listener.receivedTokens.length, 1, 'Should receive initial token'); assert.equal(listener.receivedTokens[0].value, 'initial-token', 'Should have correct initial token'); assert.equal(listener.receivedTokens[0].expiresAtMs - listener.receivedTokens[0].receivedAtMs, tokenTtl, 'Should have correct TTL'); assert.equal(listener.errors.length, 0, 'Should not have errors yet'); await delay(80); assert.equal(listener.receivedTokens.length, 1, 'Should not receive new token yet'); assert.equal(listener.errors.length, 0, 'Should not have any errors'); await delay(networkDelay); // Should have first error but not stop due to retry config assert.equal(listener.errors.length, 1, 'Should have first error'); assert.ok(listener.errors[0].message.includes('attempt 1'), 'Error should indicate first attempt'); assert.equal(listener.errors[0].isRetryable, true, 'Should not be a fatal error'); assert.equal(manager.isRunning(), true, 'Should continue running during retries'); // Advance past first retry (delay: 100ms due to backoff) await delay(100); assert.equal(listener.errors.length, 1, 'Should not have the second error yet'); await delay(networkDelay); assert.equal(listener.errors.length, 2, 'Should have second error'); assert.ok(listener.errors[1].message.includes('attempt 2'), 'Error should indicate second attempt'); assert.equal(listener.errors[0].isRetryable, true, 'Should not be a fatal error'); assert.equal(manager.isRunning(), true, 'Should continue running during retries'); // Advance past second retry (delay: 200ms due to backoff) await delay(200); assert.equal(listener.errors.length, 2, 'Should not have another error'); assert.equal(listener.receivedTokens.length, 1, 'Should not receive new token yet'); await delay(networkDelay); // Should have recovered assert.equal(listener.receivedTokens.length, 2, 'Should receive recovery token'); assert.equal(listener.receivedTokens[1].value, 'recovery-token', 'Should have correct recovery token'); assert.equal(listener.receivedTokens[1].expiresAtMs - listener.receivedTokens[1].receivedAtMs, tokenTtl, 'Should have correct TTL'); assert.equal(manager.isRunning(), true, 'Should continue running after recovery'); assert.equal(identityProvider.getRequestCount(), 4, 'Should have made exactly 4 requests'); disposable?.dispose(); }); it('should stop after max retries exceeded', async () => { const networkDelay = 20; const tokenTtl = 100; const config: TokenManagerConfig = { expirationRefreshRatio: 0.8, retry: { maxAttempts: 2, // Only allow 2 retries initialDelayMs: 100, maxDelayMs: 1000, backoffMultiplier: 2, jitterPercentage: 0, isRetryable: (error: unknown) => error instanceof Error && error.message === 'Temporary failure' } }; // All attempts must fail const identityProvider = new ErrorSimulatingProvider( [ 'initial-token', new Error('Temporary failure'), new Error('Temporary failure'), new Error('Temporary failure') ], networkDelay, tokenTtl ); const manager = new TokenManager(identityProvider, config); const listener = new TestListener(); const disposable = manager.start(listener); // Wait for initial token await delay(networkDelay); assert.equal(listener.receivedTokens.length, 1, 'Should receive initial token'); await delay(80); assert.equal(listener.receivedTokens.length, 1, 'Should not receive new token yet'); assert.equal(listener.errors.length, 0, 'Should not have any errors'); //wait for the "network call" to complete await delay(networkDelay); // First error assert.equal(listener.errors.length, 1, 'Should have first error'); assert.equal(manager.isRunning(), true, 'Should continue running after first error'); assert.equal(listener.errors[0].isRetryable, true, 'Should not be a fatal error'); // Advance past first retry await delay(100); assert.equal(listener.errors.length, 1, 'Should not have second error yet'); //wait for the "network call" to complete await delay(networkDelay); // Second error assert.equal(listener.errors.length, 2, 'Should have second error'); assert.equal(manager.isRunning(), true, 'Should continue running after second error'); assert.equal(listener.errors[1].isRetryable, true, 'Should not be a fatal error'); // Advance past second retry await delay(200); assert.equal(listener.errors.length, 2, 'Should not have third error yet'); //wait for the "network call" to complete await delay(networkDelay); // Should stop after max retries assert.equal(listener.errors.length, 3, 'Should have final error'); assert.equal(listener.errors[2].isRetryable, false, 'Should be a fatal error'); assert.equal(manager.isRunning(), false, 'Should stop after max retries exceeded'); assert.equal(identityProvider.getRequestCount(), 4, 'Should have made exactly 4 requests'); disposable?.dispose(); }); }); }); describe('TokenManager retry delay calculations', () => { const createManager = (retryConfig: Partial) => { const config: TokenManagerConfig = { expirationRefreshRatio: 0.8, retry: { maxAttempts: 3, initialDelayMs: 100, maxDelayMs: 1000, backoffMultiplier: 2, ...retryConfig } }; return new TokenManager(new TestIdentityProvider(), config); }; describe('calculateRetryDelay', () => { it('should apply exponential backoff', () => { const manager = createManager({ initialDelayMs: 100, backoffMultiplier: 2, jitterPercentage: 0 }); // Test multiple retry attempts const expectedDelays = [ [1, 100], // First attempt: initialDelay * (2^0) = 100 [2, 200], // Second attempt: initialDelay * (2^1) = 200 [3, 400], // Third attempt: initialDelay * (2^2) = 400 [4, 800], // Fourth attempt: initialDelay * (2^3) = 800 [5, 1000] // Fifth attempt: would be 1600, but capped at maxDelay (1000) ]; for (const [attempt, expectedDelay] of expectedDelays) { manager['retryAttempt'] = attempt; assert.equal( manager.calculateRetryDelay(), expectedDelay, `Incorrect delay for attempt ${attempt}` ); } }); it('should respect maxDelayMs', () => { const manager = createManager({ initialDelayMs: 100, maxDelayMs: 300, backoffMultiplier: 2, jitterPercentage: 0 }); // Test that delays are capped at maxDelayMs const expectedDelays = [ [1, 100], // First attempt: 100 [2, 200], // Second attempt: 200 [3, 300], // Third attempt: would be 400, capped at 300 [4, 300], // Fourth attempt: would be 800, capped at 300 [5, 300] // Fifth attempt: would be 1600, capped at 300 ]; for (const [attempt, expectedDelay] of expectedDelays) { manager['retryAttempt'] = attempt; assert.equal( manager.calculateRetryDelay(), expectedDelay, `Incorrect delay for attempt ${attempt}` ); } }); it('should return 0 when no retry config is present', () => { const manager = new TokenManager(new TestIdentityProvider(), { expirationRefreshRatio: 0.8 }); manager['retryAttempt'] = 1; assert.equal(manager.calculateRetryDelay(), 0); }); }); }); });