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
272 lines
8.3 KiB
TypeScript
Executable File
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);
|
|
});
|
|
});
|