105 lines
4.2 KiB
TypeScript
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);
|
|
});
|
|
});
|