mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-03-01 15:03:57 +08:00
Add Chinese documentation for custom skills development and reference guide
- Created a new document for custom skills development (`custom.md`) detailing the structure, creation, implementation, and best practices for developing custom CCW skills. - Added an index document (`index.md`) summarizing all built-in skills, their categories, and usage examples. - Introduced a reference guide (`reference.md`) providing a quick reference for all 33 built-in CCW skills, including triggers and purposes.
This commit is contained in:
@@ -104,41 +104,131 @@ export interface ApiError {
|
||||
// ========== CSRF Token Handling ==========
|
||||
|
||||
/**
|
||||
* In-memory CSRF token storage
|
||||
* The token is obtained from X-CSRF-Token response header and stored here
|
||||
* because the XSRF-TOKEN cookie is HttpOnly and cannot be read by JavaScript
|
||||
* CSRF token pool for concurrent request support
|
||||
* The pool maintains multiple tokens to support parallel mutating requests
|
||||
*/
|
||||
let csrfToken: string | null = null;
|
||||
const MAX_CSRF_TOKEN_POOL_SIZE = 5;
|
||||
|
||||
// Token pool queue - FIFO for fair distribution
|
||||
let csrfTokenQueue: string[] = [];
|
||||
|
||||
/**
|
||||
* Get CSRF token from memory
|
||||
* Get a CSRF token from the pool
|
||||
* @returns Token string or undefined if pool is empty
|
||||
*/
|
||||
function getCsrfToken(): string | null {
|
||||
return csrfToken;
|
||||
function getCsrfTokenFromPool(): string | undefined {
|
||||
return csrfTokenQueue.shift();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set CSRF token from response header
|
||||
* Add a CSRF token to the pool with deduplication
|
||||
* @param token - Token to add
|
||||
*/
|
||||
function addCsrfTokenToPool(token: string): void {
|
||||
if (!token) return;
|
||||
// Deduplication: don't add if already in pool
|
||||
if (csrfTokenQueue.includes(token)) return;
|
||||
// Limit pool size
|
||||
if (csrfTokenQueue.length >= MAX_CSRF_TOKEN_POOL_SIZE) return;
|
||||
csrfTokenQueue.push(token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current pool size (for debugging)
|
||||
*/
|
||||
export function getCsrfPoolSize(): number {
|
||||
return csrfTokenQueue.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lock for deduplicating concurrent token fetch requests
|
||||
* Prevents multiple simultaneous calls to fetchTokenSynchronously
|
||||
*/
|
||||
let tokenFetchPromise: Promise<string> | null = null;
|
||||
|
||||
/**
|
||||
* Synchronously fetch a single token when pool is depleted
|
||||
* This blocks the request until a token is available
|
||||
* Uses lock mechanism to prevent concurrent fetch deduplication
|
||||
*/
|
||||
async function fetchTokenSynchronously(): Promise<string> {
|
||||
// If a fetch is already in progress, wait for it
|
||||
if (tokenFetchPromise) {
|
||||
return tokenFetchPromise;
|
||||
}
|
||||
|
||||
// Create new fetch promise and store as lock
|
||||
tokenFetchPromise = (async () => {
|
||||
try {
|
||||
const response = await fetch('/api/csrf-token', {
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch CSRF token');
|
||||
}
|
||||
const data = await response.json();
|
||||
const token = data.csrfToken;
|
||||
if (!token) {
|
||||
throw new Error('No CSRF token in response');
|
||||
}
|
||||
return token;
|
||||
} finally {
|
||||
// Release lock after completion (success or failure)
|
||||
tokenFetchPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return tokenFetchPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set CSRF token from response header (adds to pool)
|
||||
*/
|
||||
function updateCsrfToken(response: Response): void {
|
||||
const token = response.headers.get('X-CSRF-Token');
|
||||
if (token) {
|
||||
csrfToken = token;
|
||||
addCsrfTokenToPool(token);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize CSRF token by fetching from server
|
||||
* Initialize CSRF token pool by fetching multiple tokens from server
|
||||
* Should be called once on app initialization
|
||||
*/
|
||||
export async function initializeCsrfToken(): Promise<void> {
|
||||
try {
|
||||
const response = await fetch('/api/csrf-token', {
|
||||
// Prefetch 5 tokens for pool
|
||||
const response = await fetch(`/api/csrf-token?count=${MAX_CSRF_TOKEN_POOL_SIZE}`, {
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
updateCsrfToken(response);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to initialize CSRF token pool');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Handle both single token and batch response formats
|
||||
if (data.tokens && Array.isArray(data.tokens)) {
|
||||
// Batch response - add all tokens to pool
|
||||
for (const token of data.tokens) {
|
||||
addCsrfTokenToPool(token);
|
||||
}
|
||||
} else if (data.csrfToken) {
|
||||
// Single token response - add to pool
|
||||
addCsrfTokenToPool(data.csrfToken);
|
||||
}
|
||||
|
||||
console.log(`[CSRF] Token pool initialized with ${csrfTokenQueue.length} tokens`);
|
||||
} catch (error) {
|
||||
console.error('[CSRF] Failed to initialize CSRF token:', error);
|
||||
console.error('[CSRF] Failed to initialize CSRF token pool:', error);
|
||||
// Fallback: try to get at least one token
|
||||
try {
|
||||
const token = await fetchTokenSynchronously();
|
||||
addCsrfTokenToPool(token);
|
||||
} catch (fallbackError) {
|
||||
console.error('[CSRF] Fallback token fetch failed:', fallbackError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,7 +245,18 @@ async function fetchApi<T>(
|
||||
|
||||
// Add CSRF token for mutating requests
|
||||
if (options.method && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(options.method)) {
|
||||
const token = getCsrfToken();
|
||||
let token = getCsrfTokenFromPool();
|
||||
|
||||
// If pool is depleted, synchronously fetch a new token
|
||||
if (!token) {
|
||||
console.warn('[CSRF] Token pool depleted, fetching synchronously');
|
||||
try {
|
||||
token = await fetchTokenSynchronously();
|
||||
} catch (error) {
|
||||
throw new Error('Failed to acquire CSRF token for request');
|
||||
}
|
||||
}
|
||||
|
||||
if (token) {
|
||||
headers.set('X-CSRF-Token', token);
|
||||
}
|
||||
@@ -172,7 +273,7 @@ async function fetchApi<T>(
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
|
||||
// Update CSRF token from response header
|
||||
// Update CSRF token from response header (adds to pool)
|
||||
updateCsrfToken(response);
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -2963,6 +3064,18 @@ export interface ReviewDimension {
|
||||
findings: ReviewFinding[];
|
||||
}
|
||||
|
||||
export interface ReviewSummary {
|
||||
phase?: string;
|
||||
status?: string;
|
||||
severityDistribution?: {
|
||||
critical: number;
|
||||
high: number;
|
||||
medium: number;
|
||||
low: number;
|
||||
};
|
||||
criticalFiles?: string[];
|
||||
}
|
||||
|
||||
export interface ReviewSession {
|
||||
session_id: string;
|
||||
title?: string;
|
||||
@@ -2970,6 +3083,7 @@ export interface ReviewSession {
|
||||
type: 'review';
|
||||
phase?: string;
|
||||
reviewDimensions?: ReviewDimension[];
|
||||
reviewSummary?: ReviewSummary;
|
||||
_isActive?: boolean;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
@@ -2986,6 +3100,17 @@ export interface ReviewSessionsResponse {
|
||||
progress?: unknown;
|
||||
}>;
|
||||
};
|
||||
// New: Support activeSessions with review type
|
||||
activeSessions?: Array<{
|
||||
session_id: string;
|
||||
project?: string;
|
||||
type?: string;
|
||||
status?: string;
|
||||
created_at?: string;
|
||||
hasReview?: boolean;
|
||||
reviewSummary?: ReviewSummary;
|
||||
reviewDimensions?: ReviewDimension[];
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2994,12 +3119,34 @@ export interface ReviewSessionsResponse {
|
||||
export async function fetchReviewSessions(): Promise<ReviewSession[]> {
|
||||
const data = await fetchApi<ReviewSessionsResponse>('/api/data');
|
||||
|
||||
// If reviewSessions field exists (legacy format), use it
|
||||
// Priority 1: Use activeSessions with type='review' or hasReview=true
|
||||
if (data.activeSessions) {
|
||||
const reviewSessions = data.activeSessions.filter(
|
||||
session => session.type === 'review' || session.hasReview
|
||||
);
|
||||
if (reviewSessions.length > 0) {
|
||||
return reviewSessions.map(session => ({
|
||||
session_id: session.session_id,
|
||||
title: session.project || session.session_id,
|
||||
description: '',
|
||||
type: 'review' as const,
|
||||
phase: session.reviewSummary?.phase,
|
||||
reviewDimensions: session.reviewDimensions || [],
|
||||
reviewSummary: session.reviewSummary,
|
||||
_isActive: true,
|
||||
created_at: session.created_at,
|
||||
updated_at: undefined,
|
||||
status: session.status
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 2: Legacy reviewSessions field
|
||||
if (data.reviewSessions && data.reviewSessions.length > 0) {
|
||||
return data.reviewSessions;
|
||||
}
|
||||
|
||||
// Otherwise, transform reviewData.sessions into ReviewSession format
|
||||
// Priority 3: Legacy reviewData.sessions format
|
||||
if (data.reviewData?.sessions) {
|
||||
return data.reviewData.sessions.map(session => ({
|
||||
session_id: session.session_id,
|
||||
|
||||
@@ -96,6 +96,11 @@
|
||||
"message": "Try adjusting your filters or search query.",
|
||||
"noFixProgress": "No fix progress data available"
|
||||
},
|
||||
"notExecuted": {
|
||||
"title": "Review Not Yet Executed",
|
||||
"message": "This review session has been created but the review process has not been started yet. No findings have been generated.",
|
||||
"hint": "💡 Tip: Execute the review workflow to start analyzing code and generate findings."
|
||||
},
|
||||
"notFound": {
|
||||
"title": "Review Session Not Found",
|
||||
"message": "The requested review session could not be found."
|
||||
|
||||
@@ -96,6 +96,11 @@
|
||||
"message": "尝试调整筛选条件或搜索查询。",
|
||||
"noFixProgress": "无修复进度数据"
|
||||
},
|
||||
"notExecuted": {
|
||||
"title": "审查尚未执行",
|
||||
"message": "此审查会话已创建,但审查流程尚未启动。尚未生成任何发现结果。",
|
||||
"hint": "💡 提示:请执行审查工作流以开始分析代码并生成发现结果。"
|
||||
},
|
||||
"notFound": {
|
||||
"title": "未找到审查会话",
|
||||
"message": "无法找到请求的审查会话。"
|
||||
|
||||
@@ -765,13 +765,32 @@ export function ReviewSessionPage() {
|
||||
{filteredFindings.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="p-12 text-center">
|
||||
<FileText className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||
{formatMessage({ id: 'reviewSession.empty.title' })}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'reviewSession.empty.message' })}
|
||||
</p>
|
||||
{/* Check if review hasn't been executed yet */}
|
||||
{reviewSession?.reviewSummary?.status === 'in_progress' &&
|
||||
(!reviewSession?.reviewDimensions || reviewSession.reviewDimensions.length === 0) ? (
|
||||
<>
|
||||
<AlertTriangle className="h-12 w-12 text-amber-500 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||
{formatMessage({ id: 'reviewSession.notExecuted.title' })}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{formatMessage({ id: 'reviewSession.notExecuted.message' })}
|
||||
</p>
|
||||
<div className="text-xs text-muted-foreground bg-muted p-3 rounded-lg inline-block">
|
||||
{formatMessage({ id: 'reviewSession.notExecuted.hint' })}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FileText className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||
{formatMessage({ id: 'reviewSession.empty.title' })}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'reviewSession.empty.message' })}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
|
||||
@@ -3,6 +3,7 @@ import { randomBytes } from 'crypto';
|
||||
export interface CsrfTokenManagerOptions {
|
||||
tokenTtlMs?: number;
|
||||
cleanupIntervalMs?: number;
|
||||
maxTokensPerSession?: number;
|
||||
}
|
||||
|
||||
type CsrfTokenRecord = {
|
||||
@@ -13,14 +14,20 @@ type CsrfTokenRecord = {
|
||||
|
||||
const DEFAULT_TOKEN_TTL_MS = 15 * 60 * 1000; // 15 minutes
|
||||
const DEFAULT_CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
const DEFAULT_MAX_TOKENS_PER_SESSION = 5;
|
||||
|
||||
export class CsrfTokenManager {
|
||||
private readonly tokenTtlMs: number;
|
||||
private readonly records = new Map<string, CsrfTokenRecord>();
|
||||
private readonly maxTokensPerSession: number;
|
||||
// sessionId -> (token -> record) - supports multiple tokens per session
|
||||
private readonly sessionTokens = new Map<string, Map<string, CsrfTokenRecord>>();
|
||||
// Quick lookup: token -> sessionId for validation
|
||||
private readonly tokenToSession = new Map<string, string>();
|
||||
private readonly cleanupTimer: NodeJS.Timeout | null;
|
||||
|
||||
constructor(options: CsrfTokenManagerOptions = {}) {
|
||||
this.tokenTtlMs = options.tokenTtlMs ?? DEFAULT_TOKEN_TTL_MS;
|
||||
this.maxTokensPerSession = options.maxTokensPerSession ?? DEFAULT_MAX_TOKENS_PER_SESSION;
|
||||
|
||||
const cleanupIntervalMs = options.cleanupIntervalMs ?? DEFAULT_CLEANUP_INTERVAL_MS;
|
||||
if (cleanupIntervalMs > 0) {
|
||||
@@ -40,50 +47,137 @@ export class CsrfTokenManager {
|
||||
if (this.cleanupTimer) {
|
||||
clearInterval(this.cleanupTimer);
|
||||
}
|
||||
this.records.clear();
|
||||
this.sessionTokens.clear();
|
||||
this.tokenToSession.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a single CSRF token for a session
|
||||
*/
|
||||
generateToken(sessionId: string): string {
|
||||
const token = randomBytes(32).toString('hex');
|
||||
this.records.set(token, {
|
||||
sessionId,
|
||||
expiresAtMs: Date.now() + this.tokenTtlMs,
|
||||
used: false,
|
||||
});
|
||||
return token;
|
||||
const tokens = this.generateTokens(sessionId, 1);
|
||||
return tokens[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate multiple CSRF tokens for a session (pool pattern)
|
||||
* @param sessionId - Session identifier
|
||||
* @param count - Number of tokens to generate (max: maxTokensPerSession)
|
||||
* @returns Array of generated tokens
|
||||
*/
|
||||
generateTokens(sessionId: string, count: number): string[] {
|
||||
// Get or create session token map
|
||||
let sessionMap = this.sessionTokens.get(sessionId);
|
||||
if (!sessionMap) {
|
||||
sessionMap = new Map();
|
||||
this.sessionTokens.set(sessionId, sessionMap);
|
||||
}
|
||||
|
||||
// Limit count to max tokens per session
|
||||
const currentCount = sessionMap.size;
|
||||
const availableSlots = Math.max(0, this.maxTokensPerSession - currentCount);
|
||||
const tokensToGenerate = Math.min(count, availableSlots);
|
||||
|
||||
const tokens: string[] = [];
|
||||
const expiresAtMs = Date.now() + this.tokenTtlMs;
|
||||
|
||||
for (let i = 0; i < tokensToGenerate; i++) {
|
||||
const token = randomBytes(32).toString('hex');
|
||||
const record: CsrfTokenRecord = {
|
||||
sessionId,
|
||||
expiresAtMs,
|
||||
used: false,
|
||||
};
|
||||
sessionMap.set(token, record);
|
||||
this.tokenToSession.set(token, sessionId);
|
||||
tokens.push(token);
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a CSRF token against a session
|
||||
* Marks token as used (single-use) on successful validation
|
||||
*/
|
||||
validateToken(token: string, sessionId: string): boolean {
|
||||
const record = this.records.get(token);
|
||||
// Quick lookup: get session from token
|
||||
const tokenSessionId = this.tokenToSession.get(token);
|
||||
if (!tokenSessionId) return false;
|
||||
if (tokenSessionId !== sessionId) return false;
|
||||
|
||||
// Get session's token map
|
||||
const sessionMap = this.sessionTokens.get(sessionId);
|
||||
if (!sessionMap) return false;
|
||||
|
||||
// Get token record
|
||||
const record = sessionMap.get(token);
|
||||
if (!record) return false;
|
||||
if (record.used) return false;
|
||||
if (record.sessionId !== sessionId) return false;
|
||||
|
||||
// Check expiration
|
||||
if (Date.now() > record.expiresAtMs) {
|
||||
this.records.delete(token);
|
||||
this.removeToken(token, sessionId);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Mark as used (single-use enforcement)
|
||||
record.used = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a token from the pool
|
||||
*/
|
||||
private removeToken(token: string, sessionId: string): void {
|
||||
const sessionMap = this.sessionTokens.get(sessionId);
|
||||
if (sessionMap) {
|
||||
sessionMap.delete(token);
|
||||
// Clean up empty session maps
|
||||
if (sessionMap.size === 0) {
|
||||
this.sessionTokens.delete(sessionId);
|
||||
}
|
||||
}
|
||||
this.tokenToSession.delete(token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of active tokens for a session
|
||||
*/
|
||||
getTokenCount(sessionId: string): number {
|
||||
const sessionMap = this.sessionTokens.get(sessionId);
|
||||
return sessionMap ? sessionMap.size : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total number of active tokens across all sessions
|
||||
*/
|
||||
getActiveTokenCount(): number {
|
||||
return this.tokenToSession.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired and used tokens
|
||||
*/
|
||||
cleanupExpiredTokens(nowMs: number = Date.now()): number {
|
||||
let removed = 0;
|
||||
|
||||
for (const [token, record] of this.records.entries()) {
|
||||
if (record.used || nowMs > record.expiresAtMs) {
|
||||
this.records.delete(token);
|
||||
removed += 1;
|
||||
for (const [sessionId, sessionMap] of this.sessionTokens.entries()) {
|
||||
for (const [token, record] of sessionMap.entries()) {
|
||||
if (record.used || nowMs > record.expiresAtMs) {
|
||||
sessionMap.delete(token);
|
||||
this.tokenToSession.delete(token);
|
||||
removed += 1;
|
||||
}
|
||||
}
|
||||
// Clean up empty session maps
|
||||
if (sessionMap.size === 0) {
|
||||
this.sessionTokens.delete(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
getActiveTokenCount(): number {
|
||||
return this.records.size;
|
||||
}
|
||||
}
|
||||
|
||||
let csrfManagerInstance: CsrfTokenManager | null = null;
|
||||
|
||||
@@ -79,17 +79,37 @@ function setCsrfCookie(res: ServerResponse, token: string, maxAgeSeconds: number
|
||||
}
|
||||
|
||||
export async function handleAuthRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
const { pathname, req, res } = ctx;
|
||||
const { pathname, req, res, url } = ctx;
|
||||
|
||||
if (pathname === '/api/csrf-token' && req.method === 'GET') {
|
||||
const sessionId = getOrCreateSessionId(req, res);
|
||||
const tokenManager = getCsrfTokenManager();
|
||||
const csrfToken = tokenManager.generateToken(sessionId);
|
||||
|
||||
res.setHeader('X-CSRF-Token', csrfToken);
|
||||
setCsrfCookie(res, csrfToken, 15 * 60);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
||||
res.end(JSON.stringify({ csrfToken }));
|
||||
// Check for count parameter (pool pattern)
|
||||
const countParam = url.searchParams.get('count');
|
||||
const count = countParam ? Math.min(Math.max(1, parseInt(countParam, 10) || 1), 10) : 1;
|
||||
|
||||
if (count === 1) {
|
||||
// Single token response (existing behavior)
|
||||
const csrfToken = tokenManager.generateToken(sessionId);
|
||||
res.setHeader('X-CSRF-Token', csrfToken);
|
||||
setCsrfCookie(res, csrfToken, 15 * 60);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
||||
res.end(JSON.stringify({ csrfToken }));
|
||||
} else {
|
||||
// Batch token response (pool pattern)
|
||||
const tokens = tokenManager.generateTokens(sessionId, count);
|
||||
const firstToken = tokens[0];
|
||||
|
||||
// Set header and cookie with first token for compatibility
|
||||
res.setHeader('X-CSRF-Token', firstToken);
|
||||
setCsrfCookie(res, firstToken, 15 * 60);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
||||
res.end(JSON.stringify({
|
||||
tokens,
|
||||
expiresIn: 15 * 60, // seconds
|
||||
}));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -60,5 +60,91 @@ describe('CsrfTokenManager', async () => {
|
||||
assert.equal(manager.validateToken(token, 'session-1'), true);
|
||||
manager.dispose();
|
||||
});
|
||||
|
||||
// ========== Pool Pattern Tests ==========
|
||||
|
||||
it('generateTokens produces N unique tokens', () => {
|
||||
const manager = new mod.CsrfTokenManager({ cleanupIntervalMs: 0, maxTokensPerSession: 5 });
|
||||
const tokens = manager.generateTokens('session-1', 3);
|
||||
|
||||
assert.equal(tokens.length, 3);
|
||||
// All tokens should be unique
|
||||
assert.equal(new Set(tokens).size, 3);
|
||||
// All tokens should be valid hex
|
||||
for (const token of tokens) {
|
||||
assert.match(token, /^[a-f0-9]{64}$/);
|
||||
}
|
||||
manager.dispose();
|
||||
});
|
||||
|
||||
it('generateTokens respects maxTokensPerSession limit', () => {
|
||||
const manager = new mod.CsrfTokenManager({ cleanupIntervalMs: 0, maxTokensPerSession: 5 });
|
||||
// First batch of 5
|
||||
const tokens1 = manager.generateTokens('session-1', 5);
|
||||
assert.equal(tokens1.length, 5);
|
||||
|
||||
// Second batch should be empty (pool full)
|
||||
const tokens2 = manager.generateTokens('session-1', 3);
|
||||
assert.equal(tokens2.length, 0);
|
||||
|
||||
manager.dispose();
|
||||
});
|
||||
|
||||
it('getTokenCount returns correct count for session', () => {
|
||||
const manager = new mod.CsrfTokenManager({ cleanupIntervalMs: 0 });
|
||||
manager.generateTokens('session-1', 3);
|
||||
manager.generateTokens('session-2', 2);
|
||||
|
||||
assert.equal(manager.getTokenCount('session-1'), 3);
|
||||
assert.equal(manager.getTokenCount('session-2'), 2);
|
||||
assert.equal(manager.getTokenCount('session-3'), 0);
|
||||
manager.dispose();
|
||||
});
|
||||
|
||||
it('validateToken works with pool pattern (multiple tokens per session)', () => {
|
||||
const manager = new mod.CsrfTokenManager({ cleanupIntervalMs: 0 });
|
||||
const tokens = manager.generateTokens('session-1', 3);
|
||||
|
||||
// All tokens should be valid once
|
||||
assert.equal(manager.validateToken(tokens[0], 'session-1'), true);
|
||||
assert.equal(manager.validateToken(tokens[1], 'session-1'), true);
|
||||
assert.equal(manager.validateToken(tokens[2], 'session-1'), true);
|
||||
|
||||
// All tokens should now be invalid (used)
|
||||
assert.equal(manager.validateToken(tokens[0], 'session-1'), false);
|
||||
assert.equal(manager.validateToken(tokens[1], 'session-1'), false);
|
||||
assert.equal(manager.validateToken(tokens[2], 'session-1'), false);
|
||||
|
||||
manager.dispose();
|
||||
});
|
||||
|
||||
it('cleanupExpiredTokens handles multiple sessions', () => {
|
||||
const manager = new mod.CsrfTokenManager({ tokenTtlMs: 10, cleanupIntervalMs: 0 });
|
||||
manager.generateTokens('session-1', 3);
|
||||
manager.generateTokens('session-2', 2);
|
||||
|
||||
const removed = manager.cleanupExpiredTokens(Date.now() + 100);
|
||||
assert.equal(removed, 5);
|
||||
assert.equal(manager.getActiveTokenCount(), 0);
|
||||
assert.equal(manager.getTokenCount('session-1'), 0);
|
||||
assert.equal(manager.getTokenCount('session-2'), 0);
|
||||
manager.dispose();
|
||||
});
|
||||
|
||||
it('concurrent requests can use different tokens from pool', () => {
|
||||
const manager = new mod.CsrfTokenManager({ cleanupIntervalMs: 0 });
|
||||
const tokens = manager.generateTokens('session-1', 5);
|
||||
|
||||
// Simulate 5 concurrent requests using different tokens
|
||||
const results = tokens.map(token => manager.validateToken(token, 'session-1'));
|
||||
|
||||
// All should succeed
|
||||
assert.deepEqual(results, [true, true, true, true, true]);
|
||||
|
||||
// Token count should still be 5 (but all marked as used)
|
||||
assert.equal(manager.getTokenCount('session-1'), 5);
|
||||
|
||||
manager.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user