Merge pull request #321 from AutoMaker-Org/protect-api-with-api-key

adding more security to api endpoints to require api token for all ac…
This commit is contained in:
Web Dev Cody
2025-12-30 00:42:46 -05:00
committed by GitHub
51 changed files with 2952 additions and 249 deletions

View File

@@ -9,6 +9,7 @@
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"dev:test": "tsx src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"lint": "eslint src/",
@@ -29,6 +30,7 @@
"@automaker/types": "^1.0.0",
"@automaker/utils": "^1.0.0",
"@modelcontextprotocol/sdk": "^1.25.1",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^5.2.1",
@@ -37,6 +39,8 @@
"ws": "^8.18.3"
},
"devDependencies": {
"@types/cookie": "^0.6.0",
"@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/morgan": "^1.9.10",

View File

@@ -9,15 +9,19 @@
import express from 'express';
import cors from 'cors';
import morgan from 'morgan';
import cookieParser from 'cookie-parser';
import cookie from 'cookie';
import { WebSocketServer, WebSocket } from 'ws';
import { createServer } from 'http';
import dotenv from 'dotenv';
import { createEventEmitter, type EventEmitter } from './lib/events.js';
import { initAllowedPaths } from '@automaker/platform';
import { authMiddleware, getAuthStatus } from './lib/auth.js';
import { authMiddleware, validateWsConnectionToken, checkRawAuthentication } from './lib/auth.js';
import { requireJsonContentType } from './middleware/require-json-content-type.js';
import { createAuthRoutes } from './routes/auth/index.js';
import { createFsRoutes } from './routes/fs/index.js';
import { createHealthRoutes } from './routes/health/index.js';
import { createHealthRoutes, createDetailedHandler } from './routes/health/index.js';
import { createAgentRoutes } from './routes/agent/index.js';
import { createSessionsRoutes } from './routes/sessions/index.js';
import { createFeaturesRoutes } from './routes/features/index.js';
@@ -91,7 +95,7 @@ const app = express();
// Middleware
// Custom colored logger showing only endpoint and status code (configurable via ENABLE_REQUEST_LOGGING env var)
if (ENABLE_REQUEST_LOGGING) {
morgan.token('status-colored', (req, res) => {
morgan.token('status-colored', (_req, res) => {
const status = res.statusCode;
if (status >= 500) return `\x1b[31m${status}\x1b[0m`; // Red for server errors
if (status >= 400) return `\x1b[33m${status}\x1b[0m`; // Yellow for client errors
@@ -105,17 +109,43 @@ if (ENABLE_REQUEST_LOGGING) {
})
);
}
// SECURITY: Restrict CORS to localhost UI origins to prevent drive-by attacks
// from malicious websites. MCP server endpoints can execute arbitrary commands,
// so allowing any origin would enable RCE from any website visited while Automaker runs.
const DEFAULT_CORS_ORIGINS = ['http://localhost:3007', 'http://127.0.0.1:3007'];
// CORS configuration
// When using credentials (cookies), origin cannot be '*'
// We dynamically allow the requesting origin for local development
app.use(
cors({
origin: process.env.CORS_ORIGIN || DEFAULT_CORS_ORIGINS,
origin: (origin, callback) => {
// Allow requests with no origin (like mobile apps, curl, Electron)
if (!origin) {
callback(null, true);
return;
}
// If CORS_ORIGIN is set, use it (can be comma-separated list)
const allowedOrigins = process.env.CORS_ORIGIN?.split(',').map((o) => o.trim());
if (allowedOrigins && allowedOrigins.length > 0 && allowedOrigins[0] !== '*') {
if (allowedOrigins.includes(origin)) {
callback(null, origin);
} else {
callback(new Error('Not allowed by CORS'));
}
return;
}
// For local development, allow localhost origins
if (origin.startsWith('http://localhost:') || origin.startsWith('http://127.0.0.1:')) {
callback(null, origin);
return;
}
// Reject other origins by default for security
callback(new Error('Not allowed by CORS'));
},
credentials: true,
})
);
app.use(express.json({ limit: '50mb' }));
app.use(cookieParser());
// Create shared event emitter for streaming
const events: EventEmitter = createEventEmitter();
@@ -144,12 +174,20 @@ setInterval(() => {
}
}, VALIDATION_CLEANUP_INTERVAL_MS);
// Mount API routes - health is unauthenticated for monitoring
// Require Content-Type: application/json for all API POST/PUT/PATCH requests
// This helps prevent CSRF and content-type confusion attacks
app.use('/api', requireJsonContentType);
// Mount API routes - health and auth are unauthenticated
app.use('/api/health', createHealthRoutes());
app.use('/api/auth', createAuthRoutes());
// Apply authentication to all other routes
app.use('/api', authMiddleware);
// Protected health endpoint with detailed info
app.get('/api/health/detailed', createDetailedHandler());
app.use('/api/fs', createFsRoutes(events));
app.use('/api/agent', createAgentRoutes(agentService, events));
app.use('/api/sessions', createSessionsRoutes(agentService));
@@ -182,10 +220,55 @@ const wss = new WebSocketServer({ noServer: true });
const terminalWss = new WebSocketServer({ noServer: true });
const terminalService = getTerminalService();
/**
* Authenticate WebSocket upgrade requests
* Checks for API key in header/query, session token in header/query, OR valid session cookie
*/
function authenticateWebSocket(request: import('http').IncomingMessage): boolean {
const url = new URL(request.url || '', `http://${request.headers.host}`);
// Convert URL search params to query object
const query: Record<string, string | undefined> = {};
url.searchParams.forEach((value, key) => {
query[key] = value;
});
// Parse cookies from header
const cookieHeader = request.headers.cookie;
const cookies = cookieHeader ? cookie.parse(cookieHeader) : {};
// Use shared authentication logic for standard auth methods
if (
checkRawAuthentication(
request.headers as Record<string, string | string[] | undefined>,
query,
cookies
)
) {
return true;
}
// Additionally check for short-lived WebSocket connection token (WebSocket-specific)
const wsToken = url.searchParams.get('wsToken');
if (wsToken && validateWsConnectionToken(wsToken)) {
return true;
}
return false;
}
// Handle HTTP upgrade requests manually to route to correct WebSocket server
server.on('upgrade', (request, socket, head) => {
const { pathname } = new URL(request.url || '', `http://${request.headers.host}`);
// Authenticate all WebSocket connections
if (!authenticateWebSocket(request)) {
console.log('[WebSocket] Authentication failed, rejecting connection');
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
if (pathname === '/api/events') {
wss.handleUpgrade(request, socket, head, (ws) => {
wss.emit('connection', ws, request);

View File

@@ -1,54 +1,378 @@
/**
* Authentication middleware for API security
*
* Supports API key authentication via header or environment variable.
* Supports two authentication methods:
* 1. Header-based (X-API-Key) - Used by Electron mode
* 2. Cookie-based (HTTP-only session cookie) - Used by web mode
*
* Auto-generates an API key on first run if none is configured.
*/
import type { Request, Response, NextFunction } from 'express';
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
// API key from environment (optional - if not set, auth is disabled)
const API_KEY = process.env.AUTOMAKER_API_KEY;
const DATA_DIR = process.env.DATA_DIR || './data';
const API_KEY_FILE = path.join(DATA_DIR, '.api-key');
const SESSIONS_FILE = path.join(DATA_DIR, '.sessions');
const SESSION_COOKIE_NAME = 'automaker_session';
const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
const WS_TOKEN_MAX_AGE_MS = 5 * 60 * 1000; // 5 minutes for WebSocket connection tokens
// Session store - persisted to file for survival across server restarts
const validSessions = new Map<string, { createdAt: number; expiresAt: number }>();
// Short-lived WebSocket connection tokens (in-memory only, not persisted)
const wsConnectionTokens = new Map<string, { createdAt: number; expiresAt: number }>();
// Clean up expired WebSocket tokens periodically
setInterval(() => {
const now = Date.now();
wsConnectionTokens.forEach((data, token) => {
if (data.expiresAt <= now) {
wsConnectionTokens.delete(token);
}
});
}, 60 * 1000); // Clean up every minute
/**
* Load sessions from file on startup
*/
function loadSessions(): void {
try {
if (fs.existsSync(SESSIONS_FILE)) {
const data = fs.readFileSync(SESSIONS_FILE, 'utf-8');
const sessions = JSON.parse(data) as Array<
[string, { createdAt: number; expiresAt: number }]
>;
const now = Date.now();
let loadedCount = 0;
let expiredCount = 0;
for (const [token, session] of sessions) {
// Only load non-expired sessions
if (session.expiresAt > now) {
validSessions.set(token, session);
loadedCount++;
} else {
expiredCount++;
}
}
if (loadedCount > 0 || expiredCount > 0) {
console.log(`[Auth] Loaded ${loadedCount} sessions (${expiredCount} expired)`);
}
}
} catch (error) {
console.warn('[Auth] Error loading sessions:', error);
}
}
/**
* Save sessions to file (async)
*/
async function saveSessions(): Promise<void> {
try {
await fs.promises.mkdir(path.dirname(SESSIONS_FILE), { recursive: true });
const sessions = Array.from(validSessions.entries());
await fs.promises.writeFile(SESSIONS_FILE, JSON.stringify(sessions), {
encoding: 'utf-8',
mode: 0o600,
});
} catch (error) {
console.error('[Auth] Failed to save sessions:', error);
}
}
// Load existing sessions on startup
loadSessions();
/**
* Ensure an API key exists - either from env var, file, or generate new one.
* This provides CSRF protection by requiring a secret key for all API requests.
*/
function ensureApiKey(): string {
// First check environment variable (Electron passes it this way)
if (process.env.AUTOMAKER_API_KEY) {
console.log('[Auth] Using API key from environment variable');
return process.env.AUTOMAKER_API_KEY;
}
// Try to read from file
try {
if (fs.existsSync(API_KEY_FILE)) {
const key = fs.readFileSync(API_KEY_FILE, 'utf-8').trim();
if (key) {
console.log('[Auth] Loaded API key from file');
return key;
}
}
} catch (error) {
console.warn('[Auth] Error reading API key file:', error);
}
// Generate new key
const newKey = crypto.randomUUID();
try {
fs.mkdirSync(path.dirname(API_KEY_FILE), { recursive: true });
fs.writeFileSync(API_KEY_FILE, newKey, { encoding: 'utf-8', mode: 0o600 });
console.log('[Auth] Generated new API key');
} catch (error) {
console.error('[Auth] Failed to save API key:', error);
}
return newKey;
}
// API key - always generated/loaded on startup for CSRF protection
const API_KEY = ensureApiKey();
// Print API key to console for web mode users (unless suppressed for production logging)
if (process.env.AUTOMAKER_HIDE_API_KEY !== 'true') {
console.log(`
╔═══════════════════════════════════════════════════════════════════════╗
║ 🔐 API Key for Web Mode Authentication ║
╠═══════════════════════════════════════════════════════════════════════╣
║ ║
║ When accessing via browser, you'll be prompted to enter this key: ║
║ ║
${API_KEY}
║ ║
║ In Electron mode, authentication is handled automatically. ║
╚═══════════════════════════════════════════════════════════════════════╝
`);
} else {
console.log('[Auth] API key banner hidden (AUTOMAKER_HIDE_API_KEY=true)');
}
/**
* Generate a cryptographically secure session token
*/
function generateSessionToken(): string {
return crypto.randomBytes(32).toString('hex');
}
/**
* Create a new session and return the token
*/
export async function createSession(): Promise<string> {
const token = generateSessionToken();
const now = Date.now();
validSessions.set(token, {
createdAt: now,
expiresAt: now + SESSION_MAX_AGE_MS,
});
await saveSessions(); // Persist to file
return token;
}
/**
* Validate a session token
* Note: This returns synchronously but triggers async persistence if session expired
*/
export function validateSession(token: string): boolean {
const session = validSessions.get(token);
if (!session) return false;
if (Date.now() > session.expiresAt) {
validSessions.delete(token);
// Fire-and-forget: persist removal asynchronously
saveSessions().catch((err) => console.error('[Auth] Error saving sessions:', err));
return false;
}
return true;
}
/**
* Invalidate a session token
*/
export async function invalidateSession(token: string): Promise<void> {
validSessions.delete(token);
await saveSessions(); // Persist removal
}
/**
* Create a short-lived WebSocket connection token
* Used for initial WebSocket handshake authentication
*/
export function createWsConnectionToken(): string {
const token = generateSessionToken();
const now = Date.now();
wsConnectionTokens.set(token, {
createdAt: now,
expiresAt: now + WS_TOKEN_MAX_AGE_MS,
});
return token;
}
/**
* Validate a WebSocket connection token
* These tokens are single-use and short-lived (5 minutes)
* Token is invalidated immediately after first successful use
*/
export function validateWsConnectionToken(token: string): boolean {
const tokenData = wsConnectionTokens.get(token);
if (!tokenData) return false;
// Always delete the token (single-use)
wsConnectionTokens.delete(token);
// Check if expired
if (Date.now() > tokenData.expiresAt) {
return false;
}
return true;
}
/**
* Validate the API key using timing-safe comparison
* Prevents timing attacks that could leak information about the key
*/
export function validateApiKey(key: string): boolean {
if (!key || typeof key !== 'string') return false;
// Both buffers must be the same length for timingSafeEqual
const keyBuffer = Buffer.from(key);
const apiKeyBuffer = Buffer.from(API_KEY);
// If lengths differ, compare against a dummy to maintain constant time
if (keyBuffer.length !== apiKeyBuffer.length) {
crypto.timingSafeEqual(apiKeyBuffer, apiKeyBuffer);
return false;
}
return crypto.timingSafeEqual(keyBuffer, apiKeyBuffer);
}
/**
* Get session cookie options
*/
export function getSessionCookieOptions(): {
httpOnly: boolean;
secure: boolean;
sameSite: 'strict' | 'lax' | 'none';
maxAge: number;
path: string;
} {
return {
httpOnly: true, // JavaScript cannot access this cookie
secure: process.env.NODE_ENV === 'production', // HTTPS only in production
sameSite: 'strict', // Only sent for same-site requests (CSRF protection)
maxAge: SESSION_MAX_AGE_MS,
path: '/',
};
}
/**
* Get the session cookie name
*/
export function getSessionCookieName(): string {
return SESSION_COOKIE_NAME;
}
/**
* Authentication result type
*/
type AuthResult =
| { authenticated: true }
| { authenticated: false; errorType: 'invalid_api_key' | 'invalid_session' | 'no_auth' };
/**
* Core authentication check - shared between middleware and status check
* Extracts auth credentials from various sources and validates them
*/
function checkAuthentication(
headers: Record<string, string | string[] | undefined>,
query: Record<string, string | undefined>,
cookies: Record<string, string | undefined>
): AuthResult {
// Check for API key in header (Electron mode)
const headerKey = headers['x-api-key'] as string | undefined;
if (headerKey) {
if (validateApiKey(headerKey)) {
return { authenticated: true };
}
return { authenticated: false, errorType: 'invalid_api_key' };
}
// Check for session token in header (web mode with explicit token)
const sessionTokenHeader = headers['x-session-token'] as string | undefined;
if (sessionTokenHeader) {
if (validateSession(sessionTokenHeader)) {
return { authenticated: true };
}
return { authenticated: false, errorType: 'invalid_session' };
}
// Check for API key in query parameter (fallback)
const queryKey = query.apiKey;
if (queryKey) {
if (validateApiKey(queryKey)) {
return { authenticated: true };
}
return { authenticated: false, errorType: 'invalid_api_key' };
}
// Check for session cookie (web mode)
const sessionToken = cookies[SESSION_COOKIE_NAME];
if (sessionToken && validateSession(sessionToken)) {
return { authenticated: true };
}
return { authenticated: false, errorType: 'no_auth' };
}
/**
* Authentication middleware
*
* If AUTOMAKER_API_KEY is set, requires matching key in X-API-Key header.
* If not set, allows all requests (development mode).
* Accepts either:
* 1. X-API-Key header (for Electron mode)
* 2. X-Session-Token header (for web mode with explicit token)
* 3. apiKey query parameter (fallback for cases where headers can't be set)
* 4. Session cookie (for web mode)
*/
export function authMiddleware(req: Request, res: Response, next: NextFunction): void {
// If no API key is configured, allow all requests
if (!API_KEY) {
const result = checkAuthentication(
req.headers as Record<string, string | string[] | undefined>,
req.query as Record<string, string | undefined>,
(req.cookies || {}) as Record<string, string | undefined>
);
if (result.authenticated) {
next();
return;
}
// Check for API key in header
const providedKey = req.headers['x-api-key'] as string | undefined;
if (!providedKey) {
res.status(401).json({
success: false,
error: 'Authentication required. Provide X-API-Key header.',
});
return;
// Return appropriate error based on what failed
switch (result.errorType) {
case 'invalid_api_key':
res.status(403).json({
success: false,
error: 'Invalid API key.',
});
break;
case 'invalid_session':
res.status(403).json({
success: false,
error: 'Invalid or expired session token.',
});
break;
case 'no_auth':
default:
res.status(401).json({
success: false,
error: 'Authentication required.',
});
}
if (providedKey !== API_KEY) {
res.status(403).json({
success: false,
error: 'Invalid API key.',
});
return;
}
next();
}
/**
* Check if authentication is enabled
* Check if authentication is enabled (always true now)
*/
export function isAuthEnabled(): boolean {
return !!API_KEY;
return true;
}
/**
@@ -56,7 +380,31 @@ export function isAuthEnabled(): boolean {
*/
export function getAuthStatus(): { enabled: boolean; method: string } {
return {
enabled: !!API_KEY,
method: API_KEY ? 'api_key' : 'none',
enabled: true,
method: 'api_key_or_session',
};
}
/**
* Check if a request is authenticated (for status endpoint)
*/
export function isRequestAuthenticated(req: Request): boolean {
const result = checkAuthentication(
req.headers as Record<string, string | string[] | undefined>,
req.query as Record<string, string | undefined>,
(req.cookies || {}) as Record<string, string | undefined>
);
return result.authenticated;
}
/**
* Check if raw credentials are authenticated
* Used for WebSocket authentication where we don't have Express request objects
*/
export function checkRawAuthentication(
headers: Record<string, string | string[] | undefined>,
query: Record<string, string | undefined>,
cookies: Record<string, string | undefined>
): boolean {
return checkAuthentication(headers, query, cookies).authenticated;
}

View File

@@ -0,0 +1,50 @@
/**
* Middleware to enforce Content-Type: application/json for request bodies
*
* This security middleware prevents malicious requests by requiring proper
* Content-Type headers for all POST, PUT, and PATCH requests.
*
* Rejecting requests without proper Content-Type helps prevent:
* - CSRF attacks via form submissions (which use application/x-www-form-urlencoded)
* - Content-type confusion attacks
* - Malformed request exploitation
*/
import type { Request, Response, NextFunction } from 'express';
// HTTP methods that typically include request bodies
const METHODS_REQUIRING_JSON = ['POST', 'PUT', 'PATCH'];
/**
* Middleware that requires Content-Type: application/json for POST/PUT/PATCH requests
*
* Returns 415 Unsupported Media Type if:
* - The request method is POST, PUT, or PATCH
* - AND the Content-Type header is missing or not application/json
*
* Allows requests to pass through if:
* - The request method is GET, DELETE, OPTIONS, HEAD, etc.
* - OR the Content-Type is properly set to application/json (with optional charset)
*/
export function requireJsonContentType(req: Request, res: Response, next: NextFunction): void {
// Skip validation for methods that don't require a body
if (!METHODS_REQUIRING_JSON.includes(req.method)) {
next();
return;
}
const contentType = req.headers['content-type'];
// Check if Content-Type header exists and contains application/json
// Allows for charset parameter: "application/json; charset=utf-8"
if (!contentType || !contentType.toLowerCase().includes('application/json')) {
res.status(415).json({
success: false,
error: 'Unsupported Media Type',
message: 'Content-Type header must be application/json',
});
return;
}
next();
}

View File

@@ -0,0 +1,247 @@
/**
* Auth routes - Login, logout, and status endpoints
*
* Security model:
* - Web mode: User enters API key (shown on server console) to get HTTP-only session cookie
* - Electron mode: Uses X-API-Key header (handled automatically via IPC)
*
* The session cookie is:
* - HTTP-only: JavaScript cannot read it (protects against XSS)
* - SameSite=Strict: Only sent for same-site requests (protects against CSRF)
*
* Mounted at /api/auth in the main server (BEFORE auth middleware).
*/
import { Router } from 'express';
import type { Request } from 'express';
import {
validateApiKey,
createSession,
invalidateSession,
getSessionCookieOptions,
getSessionCookieName,
isRequestAuthenticated,
createWsConnectionToken,
} from '../../lib/auth.js';
// Rate limiting configuration
const RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute window
const RATE_LIMIT_MAX_ATTEMPTS = 5; // Max 5 attempts per window
// Check if we're in test mode - disable rate limiting for E2E tests
const isTestMode = process.env.AUTOMAKER_MOCK_AGENT === 'true';
// In-memory rate limit tracking (resets on server restart)
const loginAttempts = new Map<string, { count: number; windowStart: number }>();
// Clean up old rate limit entries periodically (every 5 minutes)
setInterval(
() => {
const now = Date.now();
loginAttempts.forEach((data, ip) => {
if (now - data.windowStart > RATE_LIMIT_WINDOW_MS * 2) {
loginAttempts.delete(ip);
}
});
},
5 * 60 * 1000
);
/**
* Get client IP address from request
* Handles X-Forwarded-For header for reverse proxy setups
*/
function getClientIp(req: Request): string {
const forwarded = req.headers['x-forwarded-for'];
if (forwarded) {
// X-Forwarded-For can be a comma-separated list; take the first (original client)
const forwardedIp = Array.isArray(forwarded) ? forwarded[0] : forwarded.split(',')[0];
return forwardedIp.trim();
}
return req.ip || req.socket.remoteAddress || 'unknown';
}
/**
* Check if an IP is rate limited
* Returns { limited: boolean, retryAfter?: number }
*/
function checkRateLimit(ip: string): { limited: boolean; retryAfter?: number } {
const now = Date.now();
const attempt = loginAttempts.get(ip);
if (!attempt) {
return { limited: false };
}
// Check if window has expired
if (now - attempt.windowStart > RATE_LIMIT_WINDOW_MS) {
loginAttempts.delete(ip);
return { limited: false };
}
// Check if over limit
if (attempt.count >= RATE_LIMIT_MAX_ATTEMPTS) {
const retryAfter = Math.ceil((RATE_LIMIT_WINDOW_MS - (now - attempt.windowStart)) / 1000);
return { limited: true, retryAfter };
}
return { limited: false };
}
/**
* Record a login attempt for rate limiting
*/
function recordLoginAttempt(ip: string): void {
const now = Date.now();
const attempt = loginAttempts.get(ip);
if (!attempt || now - attempt.windowStart > RATE_LIMIT_WINDOW_MS) {
// Start new window
loginAttempts.set(ip, { count: 1, windowStart: now });
} else {
// Increment existing window
attempt.count++;
}
}
/**
* Create auth routes
*
* @returns Express Router with auth endpoints
*/
export function createAuthRoutes(): Router {
const router = Router();
/**
* GET /api/auth/status
*
* Returns whether the current request is authenticated.
* Used by the UI to determine if login is needed.
*/
router.get('/status', (req, res) => {
const authenticated = isRequestAuthenticated(req);
res.json({
success: true,
authenticated,
required: true,
});
});
/**
* POST /api/auth/login
*
* Validates the API key and sets a session cookie.
* Body: { apiKey: string }
*
* Rate limited to 5 attempts per minute per IP to prevent brute force attacks.
*/
router.post('/login', async (req, res) => {
const clientIp = getClientIp(req);
// Skip rate limiting in test mode to allow parallel E2E tests
if (!isTestMode) {
// Check rate limit before processing
const rateLimit = checkRateLimit(clientIp);
if (rateLimit.limited) {
res.status(429).json({
success: false,
error: 'Too many login attempts. Please try again later.',
retryAfter: rateLimit.retryAfter,
});
return;
}
}
const { apiKey } = req.body as { apiKey?: string };
if (!apiKey) {
res.status(400).json({
success: false,
error: 'API key is required.',
});
return;
}
// Record this attempt (only for actual API key validation attempts, skip in test mode)
if (!isTestMode) {
recordLoginAttempt(clientIp);
}
if (!validateApiKey(apiKey)) {
res.status(401).json({
success: false,
error: 'Invalid API key.',
});
return;
}
// Create session and set cookie
const sessionToken = await createSession();
const cookieOptions = getSessionCookieOptions();
const cookieName = getSessionCookieName();
res.cookie(cookieName, sessionToken, cookieOptions);
res.json({
success: true,
message: 'Logged in successfully.',
// Return token for explicit header-based auth (works around cross-origin cookie issues)
token: sessionToken,
});
});
/**
* GET /api/auth/token
*
* Generates a short-lived WebSocket connection token if the user has a valid session.
* This token is used for initial WebSocket handshake authentication and expires in 5 minutes.
* The token is NOT the session cookie value - it's a separate, short-lived token.
*/
router.get('/token', (req, res) => {
// Validate the session is still valid (via cookie, API key, or session token header)
if (!isRequestAuthenticated(req)) {
res.status(401).json({
success: false,
error: 'Authentication required.',
});
return;
}
// Generate a new short-lived WebSocket connection token
const wsToken = createWsConnectionToken();
res.json({
success: true,
token: wsToken,
expiresIn: 300, // 5 minutes in seconds
});
});
/**
* POST /api/auth/logout
*
* Clears the session cookie and invalidates the session.
*/
router.post('/logout', async (req, res) => {
const cookieName = getSessionCookieName();
const sessionToken = req.cookies?.[cookieName] as string | undefined;
if (sessionToken) {
await invalidateSession(sessionToken);
}
// Clear the cookie
res.clearCookie(cookieName, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
path: '/',
});
res.json({
success: true,
message: 'Logged out successfully.',
});
});
return router;
}

View File

@@ -1,16 +1,25 @@
/**
* Health check routes
*
* NOTE: Only the basic health check (/) is unauthenticated.
* The /detailed endpoint requires authentication.
*/
import { Router } from 'express';
import { createIndexHandler } from './routes/index.js';
import { createDetailedHandler } from './routes/detailed.js';
/**
* Create unauthenticated health routes (basic check only)
* Used by load balancers and container orchestration
*/
export function createHealthRoutes(): Router {
const router = Router();
// Basic health check - no sensitive info
router.get('/', createIndexHandler());
router.get('/detailed', createDetailedHandler());
return router;
}
// Re-export detailed handler for use in authenticated routes
export { createDetailedHandler } from './routes/detailed.js';

View File

@@ -12,12 +12,13 @@ import { ClaudeUsage } from '../routes/claude/types.js';
*
* Platform-specific implementations:
* - macOS: Uses 'expect' command for PTY
* - Windows: Uses node-pty for PTY
* - Windows/Linux: Uses node-pty for PTY
*/
export class ClaudeUsageService {
private claudeBinary = 'claude';
private timeout = 30000; // 30 second timeout
private isWindows = os.platform() === 'win32';
private isLinux = os.platform() === 'linux';
/**
* Check if Claude CLI is available on the system
@@ -48,8 +49,8 @@ export class ClaudeUsageService {
* Uses platform-specific PTY implementation
*/
private executeClaudeUsageCommand(): Promise<string> {
if (this.isWindows) {
return this.executeClaudeUsageCommandWindows();
if (this.isWindows || this.isLinux) {
return this.executeClaudeUsageCommandPty();
}
return this.executeClaudeUsageCommandMac();
}
@@ -147,17 +148,23 @@ export class ClaudeUsageService {
}
/**
* Windows implementation using node-pty
* Windows/Linux implementation using node-pty
*/
private executeClaudeUsageCommandWindows(): Promise<string> {
private executeClaudeUsageCommandPty(): Promise<string> {
return new Promise((resolve, reject) => {
let output = '';
let settled = false;
let hasSeenUsageData = false;
const workingDirectory = process.env.USERPROFILE || os.homedir() || 'C:\\';
const workingDirectory = this.isWindows
? process.env.USERPROFILE || os.homedir() || 'C:\\'
: process.env.HOME || os.homedir() || '/tmp';
const ptyProcess = pty.spawn('cmd.exe', ['/c', 'claude', '/usage'], {
// Use platform-appropriate shell and command
const shell = this.isWindows ? 'cmd.exe' : '/bin/sh';
const args = this.isWindows ? ['/c', 'claude', '/usage'] : ['-c', 'claude /usage'];
const ptyProcess = pty.spawn(shell, args, {
name: 'xterm-256color',
cols: 120,
rows: 30,

View File

@@ -1,5 +1,7 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { createMockExpressContext } from '../../utils/mocks.js';
import fs from 'fs';
import path from 'path';
/**
* Note: auth.ts reads AUTOMAKER_API_KEY at module load time.
@@ -8,26 +10,13 @@ import { createMockExpressContext } from '../../utils/mocks.js';
describe('auth.ts', () => {
beforeEach(() => {
vi.resetModules();
delete process.env.AUTOMAKER_API_KEY;
delete process.env.AUTOMAKER_HIDE_API_KEY;
delete process.env.NODE_ENV;
});
describe('authMiddleware - no API key', () => {
it('should call next() when no API key is set', async () => {
delete process.env.AUTOMAKER_API_KEY;
const { authMiddleware } = await import('@/lib/auth.js');
const { req, res, next } = createMockExpressContext();
authMiddleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
});
describe('authMiddleware - with API key', () => {
it('should reject request without API key header', async () => {
process.env.AUTOMAKER_API_KEY = 'test-secret-key';
describe('authMiddleware', () => {
it('should reject request without any authentication', async () => {
const { authMiddleware } = await import('@/lib/auth.js');
const { req, res, next } = createMockExpressContext();
@@ -36,7 +25,7 @@ describe('auth.ts', () => {
expect(res.status).toHaveBeenCalledWith(401);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'Authentication required. Provide X-API-Key header.',
error: 'Authentication required.',
});
expect(next).not.toHaveBeenCalled();
});
@@ -70,46 +59,340 @@ describe('auth.ts', () => {
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('should authenticate with session token in header', async () => {
const { authMiddleware, createSession } = await import('@/lib/auth.js');
const token = await createSession();
const { req, res, next } = createMockExpressContext();
req.headers['x-session-token'] = token;
authMiddleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('should reject invalid session token in header', async () => {
const { authMiddleware } = await import('@/lib/auth.js');
const { req, res, next } = createMockExpressContext();
req.headers['x-session-token'] = 'invalid-token';
authMiddleware(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'Invalid or expired session token.',
});
expect(next).not.toHaveBeenCalled();
});
it('should authenticate with API key in query parameter', async () => {
process.env.AUTOMAKER_API_KEY = 'test-secret-key';
const { authMiddleware } = await import('@/lib/auth.js');
const { req, res, next } = createMockExpressContext();
req.query.apiKey = 'test-secret-key';
authMiddleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('should authenticate with session cookie', async () => {
const { authMiddleware, createSession, getSessionCookieName } = await import('@/lib/auth.js');
const token = await createSession();
const cookieName = getSessionCookieName();
const { req, res, next } = createMockExpressContext();
req.cookies = { [cookieName]: token };
authMiddleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
});
describe('createSession', () => {
it('should create a new session and return token', async () => {
const { createSession } = await import('@/lib/auth.js');
const token = await createSession();
expect(token).toBeDefined();
expect(typeof token).toBe('string');
expect(token.length).toBeGreaterThan(0);
});
it('should create unique tokens for each session', async () => {
const { createSession } = await import('@/lib/auth.js');
const token1 = await createSession();
const token2 = await createSession();
expect(token1).not.toBe(token2);
});
});
describe('validateSession', () => {
it('should validate a valid session token', async () => {
const { createSession, validateSession } = await import('@/lib/auth.js');
const token = await createSession();
expect(validateSession(token)).toBe(true);
});
it('should reject invalid session token', async () => {
const { validateSession } = await import('@/lib/auth.js');
expect(validateSession('invalid-token')).toBe(false);
});
it('should reject expired session token', async () => {
vi.useFakeTimers();
const { createSession, validateSession } = await import('@/lib/auth.js');
const token = await createSession();
// Advance time past session expiration (30 days)
vi.advanceTimersByTime(31 * 24 * 60 * 60 * 1000);
expect(validateSession(token)).toBe(false);
vi.useRealTimers();
});
});
describe('invalidateSession', () => {
it('should invalidate a session token', async () => {
const { createSession, validateSession, invalidateSession } = await import('@/lib/auth.js');
const token = await createSession();
expect(validateSession(token)).toBe(true);
await invalidateSession(token);
expect(validateSession(token)).toBe(false);
});
});
describe('createWsConnectionToken', () => {
it('should create a WebSocket connection token', async () => {
const { createWsConnectionToken } = await import('@/lib/auth.js');
const token = createWsConnectionToken();
expect(token).toBeDefined();
expect(typeof token).toBe('string');
expect(token.length).toBeGreaterThan(0);
});
it('should create unique tokens', async () => {
const { createWsConnectionToken } = await import('@/lib/auth.js');
const token1 = createWsConnectionToken();
const token2 = createWsConnectionToken();
expect(token1).not.toBe(token2);
});
});
describe('validateWsConnectionToken', () => {
it('should validate a valid WebSocket token', async () => {
const { createWsConnectionToken, validateWsConnectionToken } = await import('@/lib/auth.js');
const token = createWsConnectionToken();
expect(validateWsConnectionToken(token)).toBe(true);
});
it('should reject invalid WebSocket token', async () => {
const { validateWsConnectionToken } = await import('@/lib/auth.js');
expect(validateWsConnectionToken('invalid-token')).toBe(false);
});
it('should reject expired WebSocket token', async () => {
vi.useFakeTimers();
const { createWsConnectionToken, validateWsConnectionToken } = await import('@/lib/auth.js');
const token = createWsConnectionToken();
// Advance time past token expiration (5 minutes)
vi.advanceTimersByTime(6 * 60 * 1000);
expect(validateWsConnectionToken(token)).toBe(false);
vi.useRealTimers();
});
it('should invalidate token after first use (single-use)', async () => {
const { createWsConnectionToken, validateWsConnectionToken } = await import('@/lib/auth.js');
const token = createWsConnectionToken();
expect(validateWsConnectionToken(token)).toBe(true);
// Token should be deleted after first use
expect(validateWsConnectionToken(token)).toBe(false);
});
});
describe('validateApiKey', () => {
it('should validate correct API key', async () => {
process.env.AUTOMAKER_API_KEY = 'test-secret-key';
const { validateApiKey } = await import('@/lib/auth.js');
expect(validateApiKey('test-secret-key')).toBe(true);
});
it('should reject incorrect API key', async () => {
process.env.AUTOMAKER_API_KEY = 'test-secret-key';
const { validateApiKey } = await import('@/lib/auth.js');
expect(validateApiKey('wrong-key')).toBe(false);
});
it('should reject empty string', async () => {
process.env.AUTOMAKER_API_KEY = 'test-secret-key';
const { validateApiKey } = await import('@/lib/auth.js');
expect(validateApiKey('')).toBe(false);
});
it('should reject null/undefined', async () => {
process.env.AUTOMAKER_API_KEY = 'test-secret-key';
const { validateApiKey } = await import('@/lib/auth.js');
expect(validateApiKey(null as any)).toBe(false);
expect(validateApiKey(undefined as any)).toBe(false);
});
it('should use timing-safe comparison for different lengths', async () => {
process.env.AUTOMAKER_API_KEY = 'test-secret-key';
const { validateApiKey } = await import('@/lib/auth.js');
// Key with different length should be rejected without timing leak
expect(validateApiKey('short')).toBe(false);
expect(validateApiKey('very-long-key-that-does-not-match')).toBe(false);
});
});
describe('getSessionCookieOptions', () => {
it('should return cookie options with httpOnly true', async () => {
const { getSessionCookieOptions } = await import('@/lib/auth.js');
const options = getSessionCookieOptions();
expect(options.httpOnly).toBe(true);
expect(options.sameSite).toBe('strict');
expect(options.path).toBe('/');
expect(options.maxAge).toBeGreaterThan(0);
});
it('should set secure to true in production', async () => {
process.env.NODE_ENV = 'production';
const { getSessionCookieOptions } = await import('@/lib/auth.js');
const options = getSessionCookieOptions();
expect(options.secure).toBe(true);
});
it('should set secure to false in non-production', async () => {
process.env.NODE_ENV = 'development';
const { getSessionCookieOptions } = await import('@/lib/auth.js');
const options = getSessionCookieOptions();
expect(options.secure).toBe(false);
});
});
describe('getSessionCookieName', () => {
it('should return the session cookie name', async () => {
const { getSessionCookieName } = await import('@/lib/auth.js');
const name = getSessionCookieName();
expect(name).toBe('automaker_session');
});
});
describe('isRequestAuthenticated', () => {
it('should return true for authenticated request with API key', async () => {
process.env.AUTOMAKER_API_KEY = 'test-secret-key';
const { isRequestAuthenticated } = await import('@/lib/auth.js');
const { req } = createMockExpressContext();
req.headers['x-api-key'] = 'test-secret-key';
expect(isRequestAuthenticated(req)).toBe(true);
});
it('should return false for unauthenticated request', async () => {
const { isRequestAuthenticated } = await import('@/lib/auth.js');
const { req } = createMockExpressContext();
expect(isRequestAuthenticated(req)).toBe(false);
});
it('should return true for authenticated request with session token', async () => {
const { isRequestAuthenticated, createSession } = await import('@/lib/auth.js');
const token = await createSession();
const { req } = createMockExpressContext();
req.headers['x-session-token'] = token;
expect(isRequestAuthenticated(req)).toBe(true);
});
});
describe('checkRawAuthentication', () => {
it('should return true for valid API key in headers', async () => {
process.env.AUTOMAKER_API_KEY = 'test-secret-key';
const { checkRawAuthentication } = await import('@/lib/auth.js');
expect(checkRawAuthentication({ 'x-api-key': 'test-secret-key' }, {}, {})).toBe(true);
});
it('should return true for valid session token in headers', async () => {
const { checkRawAuthentication, createSession } = await import('@/lib/auth.js');
const token = await createSession();
expect(checkRawAuthentication({ 'x-session-token': token }, {}, {})).toBe(true);
});
it('should return true for valid API key in query', async () => {
process.env.AUTOMAKER_API_KEY = 'test-secret-key';
const { checkRawAuthentication } = await import('@/lib/auth.js');
expect(checkRawAuthentication({}, { apiKey: 'test-secret-key' }, {})).toBe(true);
});
it('should return true for valid session cookie', async () => {
const { checkRawAuthentication, createSession, getSessionCookieName } =
await import('@/lib/auth.js');
const token = await createSession();
const cookieName = getSessionCookieName();
expect(checkRawAuthentication({}, {}, { [cookieName]: token })).toBe(true);
});
it('should return false for invalid credentials', async () => {
const { checkRawAuthentication } = await import('@/lib/auth.js');
expect(checkRawAuthentication({}, {}, {})).toBe(false);
});
});
describe('isAuthEnabled', () => {
it('should return false when no API key is set', async () => {
delete process.env.AUTOMAKER_API_KEY;
const { isAuthEnabled } = await import('@/lib/auth.js');
expect(isAuthEnabled()).toBe(false);
});
it('should return true when API key is set', async () => {
process.env.AUTOMAKER_API_KEY = 'test-key';
it('should always return true (auth is always required)', async () => {
const { isAuthEnabled } = await import('@/lib/auth.js');
expect(isAuthEnabled()).toBe(true);
});
});
describe('getAuthStatus', () => {
it('should return disabled status when no API key', async () => {
delete process.env.AUTOMAKER_API_KEY;
const { getAuthStatus } = await import('@/lib/auth.js');
const status = getAuthStatus();
expect(status).toEqual({
enabled: false,
method: 'none',
});
});
it('should return enabled status when API key is set', async () => {
process.env.AUTOMAKER_API_KEY = 'test-key';
it('should return enabled status with api_key_or_session method', async () => {
const { getAuthStatus } = await import('@/lib/auth.js');
const status = getAuthStatus();
expect(status).toEqual({
enabled: true,
method: 'api_key',
method: 'api_key_or_session',
});
});
});

View File

@@ -347,4 +347,386 @@ describe('agent-service.ts', () => {
expect(fs.writeFile).toHaveBeenCalled();
});
});
describe('createSession', () => {
beforeEach(() => {
vi.mocked(fs.readFile).mockResolvedValue('{}');
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
});
it('should create a new session with metadata', async () => {
const session = await service.createSession('Test Session', '/test/project', '/test/dir');
expect(session.id).toBeDefined();
expect(session.name).toBe('Test Session');
expect(session.projectPath).toBe('/test/project');
expect(session.workingDirectory).toBeDefined();
expect(session.createdAt).toBeDefined();
expect(session.updatedAt).toBeDefined();
});
it('should use process.cwd() if no working directory provided', async () => {
const session = await service.createSession('Test Session');
expect(session.workingDirectory).toBeDefined();
});
it('should validate working directory', async () => {
// Set ALLOWED_ROOT_DIRECTORY to restrict paths
const originalAllowedRoot = process.env.ALLOWED_ROOT_DIRECTORY;
process.env.ALLOWED_ROOT_DIRECTORY = '/allowed/projects';
// Re-import platform to initialize with new env var
vi.resetModules();
const { initAllowedPaths } = await import('@automaker/platform');
initAllowedPaths();
const { AgentService } = await import('@/services/agent-service.js');
const testService = new AgentService('/test/data', mockEvents as any);
vi.mocked(fs.readFile).mockResolvedValue('{}');
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
await expect(
testService.createSession('Test Session', undefined, '/invalid/path')
).rejects.toThrow();
// Restore original value
if (originalAllowedRoot) {
process.env.ALLOWED_ROOT_DIRECTORY = originalAllowedRoot;
} else {
delete process.env.ALLOWED_ROOT_DIRECTORY;
}
vi.resetModules();
const { initAllowedPaths: reinit } = await import('@automaker/platform');
reinit();
});
});
describe('setSessionModel', () => {
beforeEach(async () => {
const error: any = new Error('ENOENT');
error.code = 'ENOENT';
vi.mocked(fs.readFile).mockRejectedValue(error);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
await service.startConversation({
sessionId: 'session-1',
});
});
it('should set model for existing session', async () => {
vi.mocked(fs.readFile).mockResolvedValue('{"session-1": {}}');
const result = await service.setSessionModel('session-1', 'claude-sonnet-4-20250514');
expect(result).toBe(true);
});
it('should return false for non-existent session', async () => {
const result = await service.setSessionModel('nonexistent', 'claude-sonnet-4-20250514');
expect(result).toBe(false);
});
});
describe('updateSession', () => {
beforeEach(() => {
vi.mocked(fs.readFile).mockResolvedValue(
JSON.stringify({
'session-1': {
id: 'session-1',
name: 'Test Session',
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
},
})
);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
});
it('should update session metadata', async () => {
const result = await service.updateSession('session-1', { name: 'Updated Name' });
expect(result).not.toBeNull();
expect(result?.name).toBe('Updated Name');
expect(result?.updatedAt).not.toBe('2024-01-01T00:00:00Z');
});
it('should return null for non-existent session', async () => {
const result = await service.updateSession('nonexistent', { name: 'Updated Name' });
expect(result).toBeNull();
});
});
describe('archiveSession', () => {
beforeEach(() => {
vi.mocked(fs.readFile).mockResolvedValue(
JSON.stringify({
'session-1': {
id: 'session-1',
name: 'Test Session',
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
},
})
);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
});
it('should archive a session', async () => {
const result = await service.archiveSession('session-1');
expect(result).toBe(true);
});
it('should return false for non-existent session', async () => {
const result = await service.archiveSession('nonexistent');
expect(result).toBe(false);
});
});
describe('unarchiveSession', () => {
beforeEach(() => {
vi.mocked(fs.readFile).mockResolvedValue(
JSON.stringify({
'session-1': {
id: 'session-1',
name: 'Test Session',
archived: true,
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
},
})
);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
});
it('should unarchive a session', async () => {
const result = await service.unarchiveSession('session-1');
expect(result).toBe(true);
});
it('should return false for non-existent session', async () => {
const result = await service.unarchiveSession('nonexistent');
expect(result).toBe(false);
});
});
describe('deleteSession', () => {
beforeEach(() => {
vi.mocked(fs.readFile).mockResolvedValue(
JSON.stringify({
'session-1': {
id: 'session-1',
name: 'Test Session',
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
},
})
);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
vi.mocked(fs.unlink).mockResolvedValue(undefined);
});
it('should delete a session', async () => {
const result = await service.deleteSession('session-1');
expect(result).toBe(true);
expect(fs.writeFile).toHaveBeenCalled();
});
it('should return false for non-existent session', async () => {
const result = await service.deleteSession('nonexistent');
expect(result).toBe(false);
});
});
describe('listSessions', () => {
beforeEach(() => {
vi.mocked(fs.readFile).mockResolvedValue(
JSON.stringify({
'session-1': {
id: 'session-1',
name: 'Test Session 1',
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-02T00:00:00Z',
archived: false,
},
'session-2': {
id: 'session-2',
name: 'Test Session 2',
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-03T00:00:00Z',
archived: true,
},
})
);
});
it('should list non-archived sessions by default', async () => {
const sessions = await service.listSessions();
expect(sessions.length).toBe(1);
expect(sessions[0].id).toBe('session-1');
});
it('should include archived sessions when requested', async () => {
const sessions = await service.listSessions(true);
expect(sessions.length).toBe(2);
});
it('should sort sessions by updatedAt descending', async () => {
const sessions = await service.listSessions(true);
expect(sessions[0].id).toBe('session-2');
expect(sessions[1].id).toBe('session-1');
});
});
describe('addToQueue', () => {
beforeEach(async () => {
const error: any = new Error('ENOENT');
error.code = 'ENOENT';
vi.mocked(fs.readFile).mockRejectedValue(error);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
await service.startConversation({
sessionId: 'session-1',
});
});
it('should add prompt to queue', async () => {
const result = await service.addToQueue('session-1', {
message: 'Test prompt',
imagePaths: ['/test/image.png'],
model: 'claude-sonnet-4-20250514',
});
expect(result.success).toBe(true);
expect(result.queuedPrompt).toBeDefined();
expect(result.queuedPrompt?.message).toBe('Test prompt');
expect(mockEvents.emit).toHaveBeenCalled();
});
it('should return error for non-existent session', async () => {
const result = await service.addToQueue('nonexistent', {
message: 'Test prompt',
});
expect(result.success).toBe(false);
expect(result.error).toBe('Session not found');
});
});
describe('getQueue', () => {
beforeEach(async () => {
const error: any = new Error('ENOENT');
error.code = 'ENOENT';
vi.mocked(fs.readFile).mockRejectedValue(error);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
await service.startConversation({
sessionId: 'session-1',
});
});
it('should return queue for session', async () => {
await service.addToQueue('session-1', { message: 'Test prompt' });
const result = service.getQueue('session-1');
expect(result.success).toBe(true);
expect(result.queue).toBeDefined();
expect(result.queue?.length).toBe(1);
});
it('should return error for non-existent session', () => {
const result = service.getQueue('nonexistent');
expect(result.success).toBe(false);
expect(result.error).toBe('Session not found');
});
});
describe('removeFromQueue', () => {
beforeEach(async () => {
const error: any = new Error('ENOENT');
error.code = 'ENOENT';
vi.mocked(fs.readFile).mockRejectedValue(error);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
await service.startConversation({
sessionId: 'session-1',
});
const addResult = await service.addToQueue('session-1', { message: 'Test prompt' });
vi.clearAllMocks();
});
it('should remove prompt from queue', async () => {
const queueResult = service.getQueue('session-1');
const promptId = queueResult.queue![0].id;
const result = await service.removeFromQueue('session-1', promptId);
expect(result.success).toBe(true);
expect(mockEvents.emit).toHaveBeenCalled();
});
it('should return error for non-existent session', async () => {
const result = await service.removeFromQueue('nonexistent', 'prompt-id');
expect(result.success).toBe(false);
expect(result.error).toBe('Session not found');
});
it('should return error for non-existent prompt', async () => {
const result = await service.removeFromQueue('session-1', 'nonexistent-prompt-id');
expect(result.success).toBe(false);
expect(result.error).toBe('Prompt not found in queue');
});
});
describe('clearQueue', () => {
beforeEach(async () => {
const error: any = new Error('ENOENT');
error.code = 'ENOENT';
vi.mocked(fs.readFile).mockRejectedValue(error);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
await service.startConversation({
sessionId: 'session-1',
});
await service.addToQueue('session-1', { message: 'Test prompt 1' });
await service.addToQueue('session-1', { message: 'Test prompt 2' });
vi.clearAllMocks();
});
it('should clear all prompts from queue', async () => {
const result = await service.clearQueue('session-1');
expect(result.success).toBe(true);
const queueResult = service.getQueue('session-1');
expect(queueResult.queue?.length).toBe(0);
expect(mockEvents.emit).toHaveBeenCalled();
});
it('should return error for non-existent session', async () => {
const result = await service.clearQueue('nonexistent');
expect(result.success).toBe(false);
expect(result.error).toBe('Session not found');
});
});
});