randomayzer/tests/token-refresh-concurrency.test.ts
2026-08-18 14:37:09 +07:00

105 lines
4.2 KiB
TypeScript

import { describe, it, expect, beforeEach } from 'vitest';
import { TokenRefresher } from '../src/lib/auth/token-refresher';
import { MemoryUserRepository } from '../src/lib/repository/user-repository';
import { AesGcmTokenVault } from '../src/lib/auth/token-vault';
import { MockVkOAuthClient } from '../src/integrations/vk/mock-oauth-client';
import { VkReauthenticationRequiredError } from '../src/integrations/vk/vk-errors';
describe('Phase 2.3 Token Refresh & Single-Flight Concurrency Gate', () => {
let userRepo: MemoryUserRepository;
let tokenVault: AesGcmTokenVault;
let oauthClient: MockVkOAuthClient;
let tokenRefresher: TokenRefresher;
let organizerId: string;
const expiredAccessToken = 'vk1.a.expired_old_access_token';
const initialRefreshToken = 'vk1.a.initial_refresh_token_valid';
beforeEach(async () => {
userRepo = new MemoryUserRepository();
tokenVault = new AesGcmTokenVault('test-master-token-encryption-key-32b!');
oauthClient = new MockVkOAuthClient();
tokenRefresher = new TokenRefresher(userRepo, tokenVault, oauthClient);
const encryptedAccessToken = await tokenVault.encrypt(expiredAccessToken);
const encryptedRefreshToken = await tokenVault.encrypt(initialRefreshToken);
// Save expired credential (expired 10 seconds ago)
const user = await userRepo.upsertUserWithTokens({
vkUserId: '12345678',
firstName: 'Bob',
lastName: 'Refresher',
encryptedAccessToken,
encryptedRefreshToken,
expiresIn: -10, // Expired in the past
});
organizerId = user.id;
});
it('20 concurrent requests for an expired token trigger exactly 1 refresh operation (single-flight mutex)', async () => {
let refreshCallsCount = 0;
const originalRefreshToken = oauthClient.refreshToken.bind(oauthClient);
oauthClient.refreshToken = async (params) => {
refreshCallsCount++;
// Artificial delay to allow all 20 concurrent requests to pile in
await new Promise(r => setTimeout(r, 50));
return originalRefreshToken(params);
};
// Launch 20 concurrent requests
const promises = Array.from({ length: 20 }, () =>
tokenRefresher.getOrRefreshUserToken(organizerId)
);
const tokens = await Promise.all(promises);
// 1. Single-Flight guarantee: Exactly 1 network refresh call was made
expect(refreshCallsCount).toBe(1);
// 2. All 20 callers received the same valid refreshed access token
expect(tokens).toHaveLength(20);
const firstToken = tokens[0];
expect(firstToken).toMatch(/mock_refreshed_access_token_/);
expect(tokens.every(t => t === firstToken)).toBe(true);
// 3. Database credential record was updated with the new token
const updatedCred = await userRepo.getUserCredentials(organizerId);
expect(updatedCred?.expiresAt).toBeDefined();
expect(updatedCred?.expiresAt!.getTime()).toBeGreaterThan(Date.now());
});
it('rotates refresh_token when provided by VK ID response', async () => {
const refreshedToken = await tokenRefresher.getOrRefreshUserToken(organizerId);
expect(refreshedToken).toBeDefined();
const updatedCred = await userRepo.getUserCredentials(organizerId);
const decryptedNewRefresh = await tokenVault.decrypt(updatedCred!.encryptedRefreshToken!);
expect(decryptedNewRefresh).toMatch(/mock_new_refresh_token_/);
expect(decryptedNewRefresh).not.toBe(initialRefreshToken);
});
it('throws VkReauthenticationRequiredError when refresh fails on VK side', async () => {
oauthClient.shouldFailRefresh = true;
await expect(
tokenRefresher.getOrRefreshUserToken(organizerId)
).rejects.toThrow(VkReauthenticationRequiredError);
});
it('throws VkReauthenticationRequiredError when expired token has no refresh token', async () => {
// Create user without refresh token
const encryptedAccessToken = await tokenVault.encrypt(expiredAccessToken);
const userNoRefresh = await userRepo.upsertUserWithTokens({
vkUserId: '55555555',
firstName: 'No',
lastName: 'Refresh',
encryptedAccessToken,
expiresIn: -10, // Expired
});
await expect(
tokenRefresher.getOrRefreshUserToken(userNoRefresh.id)
).rejects.toThrow(VkReauthenticationRequiredError);
});
});