Files
Charon/tests/fixtures/token-refresh-validation.spec.ts
akanealw eec8c28fb3
Some checks are pending
Go Benchmark / Performance Regression Check (push) Waiting to run
Cerberus Integration / Cerberus Security Stack Integration (push) Waiting to run
Upload Coverage to Codecov / Backend Codecov Upload (push) Waiting to run
Upload Coverage to Codecov / Frontend Codecov Upload (push) Waiting to run
CodeQL - Analyze / CodeQL analysis (go) (push) Waiting to run
CodeQL - Analyze / CodeQL analysis (javascript-typescript) (push) Waiting to run
CrowdSec Integration / CrowdSec Bouncer Integration (push) Waiting to run
Docker Build, Publish & Test / build-and-push (push) Waiting to run
Docker Build, Publish & Test / Security Scan PR Image (push) Blocked by required conditions
Quality Checks / Auth Route Protection Contract (push) Waiting to run
Quality Checks / Codecov Trigger/Comment Parity Guard (push) Waiting to run
Quality Checks / Backend (Go) (push) Waiting to run
Quality Checks / Frontend (React) (push) Waiting to run
Rate Limit integration / Rate Limiting Integration (push) Waiting to run
Security Scan (PR) / Trivy Binary Scan (push) Waiting to run
Supply Chain Verification (PR) / Verify Supply Chain (push) Waiting to run
WAF integration / Coraza WAF Integration (push) Waiting to run
changed perms
2026-04-22 18:19:14 +00:00

272 lines
8.3 KiB
TypeScript
Executable File

import {
test,
expect,
refreshTokenIfNeeded,
resetTokenRefreshStateForTests,
} from './auth-fixtures';
import { readdirSync } from 'fs';
import { tmpdir } from 'os';
function toBase64Url(value: string): string {
return Buffer.from(value)
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '');
}
function createJwt(expiresInSeconds: number): string {
const header = toBase64Url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
const payload = toBase64Url(
JSON.stringify({
exp: Math.floor(Date.now() / 1000) + expiresInSeconds,
sub: 'test-user',
})
);
return `${header}.${payload}.signature`;
}
function createJsonResponse(body: Record<string, unknown>, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: {
'Content-Type': 'application/json',
},
});
}
/**
* Token Refresh Validation Tests
*
* Validates that the token refresh mechanism works correctly for long-running E2E sessions.
* These tests verify:
* - Token cache creation and reading
* - JWT expiry extraction
* - Token refresh endpoint integration
* - Concurrent access safety (in-memory serialization)
*/
function getTokenCacheDirCount(): number {
return readdirSync(tmpdir(), { withFileTypes: true }).filter(
(entry) => entry.isDirectory() && entry.name.startsWith('charon-test-token-cache-')
).length;
}
test.describe('Token Refresh for Long-Running Sessions', () => {
test('New token should be cached with expiry', async ({ adminUser, page }) => {
const baseURL = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:8080';
// Get initial token
let token = adminUser.token;
// refresh should either return the same token or a new one
const refreshedToken = await refreshTokenIfNeeded(baseURL, token);
expect(refreshedToken).toBeTruthy();
expect(refreshedToken).toMatch(/^[A-Za-z0-9\-_=]+\.[A-Za-z0-9\-_.]+\.[A-Za-z0-9\-_=]*$/);
});
test('Token refresh should work for 60-minute session simulation', async ({
adminUser,
page,
}) => {
const baseURL = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:8080';
let token = adminUser.token;
let refreshCount = 0;
// Simulate 6 checkpoints over 60 minutes (10-min intervals in test)
// In production, these would be actual 10-minute intervals
for (let i = 0; i < 6; i++) {
const oldToken = token;
// Attempt refresh (should be no-op if not expired)
token = await refreshTokenIfNeeded(baseURL, token);
if (token !== oldToken) {
refreshCount++;
}
// Verify token is still valid by making a request
const response = await page.request.get('/api/v1/auth/status', {
headers: {
'Authorization': `Bearer ${token}`,
},
});
expect(response.status()).toBeLessThan(400);
// In a real 60-min test, this would wait 10 minutes
// For validation, we skip the wait
// await page.waitForTimeout(10*60*1000);
}
// Token should be valid after the session
expect(token).toBeTruthy();
expect(token).toMatch(/^[A-Za-z0-9\-_=]+\.[A-Za-z0-9\-_.]+\.[A-Za-z0-9\-_=]*$/);
});
test('Token should remain valid across page navigation', async ({ adminUser, page }) => {
const baseURL = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:8080';
let token = adminUser.token;
// Refresh token
token = await refreshTokenIfNeeded(baseURL, token);
// Set header for next request
await page.setExtraHTTPHeaders({
'Authorization': `Bearer ${token}`,
});
// Navigate to dashboard
const response = await page.goto('/');
expect(response?.status()).toBeLessThan(400);
});
test('Concurrent token access should not corrupt cache', async ({ adminUser }) => {
const baseURL = 'http://localhost:8080';
const token = adminUser.token;
const cacheDirCountBefore = getTokenCacheDirCount();
// Simulate concurrent refresh calls (would happen in parallel tests)
const promises = Array.from({ length: 5 }, () =>
refreshTokenIfNeeded(baseURL, token)
);
const results = await Promise.all(promises);
// All should return valid tokens
results.forEach((result) => {
expect(result).toBeTruthy();
expect(result).toMatch(/^[A-Za-z0-9\-_=]+\.[A-Za-z0-9\-_.]+\.[A-Za-z0-9\-_=]*$/);
});
// In-memory cache should not depend on temp-directory artifacts
const cacheDirCountAfter = getTokenCacheDirCount();
expect(cacheDirCountAfter).toBe(cacheDirCountBefore);
});
});
test.describe('refreshTokenIfNeeded branch and concurrency regression', () => {
let originalFetch: typeof globalThis.fetch | undefined;
test.beforeEach(async () => {
originalFetch = globalThis.fetch;
resetTokenRefreshStateForTests();
});
test.afterEach(async () => {
if (originalFetch) {
globalThis.fetch = originalFetch;
}
resetTokenRefreshStateForTests();
});
test('coalesces N concurrent callers to one refresh request with consistent token', async () => {
const currentToken = createJwt(60);
const refreshedToken = createJwt(3600);
const baseURL = 'http://localhost:8080';
const concurrentCallers = 20;
let refreshInvocationCount = 0;
let releaseRefreshResponse!: () => void;
const refreshResponseGate = new Promise<void>((resolve) => {
releaseRefreshResponse = resolve;
});
let markFirstRefreshStarted!: () => void;
const firstRefreshStarted = new Promise<void>((resolve) => {
markFirstRefreshStarted = resolve;
});
globalThis.fetch = (async () => {
refreshInvocationCount += 1;
if (refreshInvocationCount === 1) {
markFirstRefreshStarted();
}
await refreshResponseGate;
return createJsonResponse({ token: refreshedToken });
}) as typeof globalThis.fetch;
const resultsPromise = Promise.all(
Array.from({ length: concurrentCallers }, () =>
refreshTokenIfNeeded(baseURL, currentToken)
)
);
await firstRefreshStarted;
expect(refreshInvocationCount).toBe(1);
releaseRefreshResponse();
const results = await resultsPromise;
expect(refreshInvocationCount).toBe(1);
expect(results).toHaveLength(concurrentCallers);
results.forEach((result) => {
expect(result).toBe(refreshedToken);
});
});
test('returns currentToken when refresh response is non-OK', async () => {
const currentToken = createJwt(60);
const baseURL = 'http://localhost:8080';
let refreshInvocationCount = 0;
globalThis.fetch = (async () => {
refreshInvocationCount += 1;
return createJsonResponse({ error: 'unauthorized' }, 401);
}) as typeof globalThis.fetch;
const result = await refreshTokenIfNeeded(baseURL, currentToken);
expect(refreshInvocationCount).toBe(1);
expect(result).toBe(currentToken);
});
test('returns currentToken when refresh response omits token field', async () => {
const currentToken = createJwt(60);
const baseURL = 'http://localhost:8080';
let refreshInvocationCount = 0;
globalThis.fetch = (async () => {
refreshInvocationCount += 1;
return createJsonResponse({});
}) as typeof globalThis.fetch;
const result = await refreshTokenIfNeeded(baseURL, currentToken);
expect(refreshInvocationCount).toBe(1);
expect(result).toBe(currentToken);
});
test('returns currentToken when fetch throws network error', async () => {
const currentToken = createJwt(60);
const baseURL = 'http://localhost:8080';
let refreshInvocationCount = 0;
globalThis.fetch = (async () => {
refreshInvocationCount += 1;
throw new Error('network down');
}) as typeof globalThis.fetch;
const result = await refreshTokenIfNeeded(baseURL, currentToken);
expect(refreshInvocationCount).toBe(1);
expect(result).toBe(currentToken);
});
test('returns currentToken and skips fetch when baseURL is undefined', async () => {
const currentToken = createJwt(60);
let refreshInvocationCount = 0;
globalThis.fetch = (async () => {
refreshInvocationCount += 1;
return createJsonResponse({ token: createJwt(3600) });
}) as typeof globalThis.fetch;
const result = await refreshTokenIfNeeded(undefined, currentToken);
expect(refreshInvocationCount).toBe(0);
expect(result).toBe(currentToken);
});
});