From 4c658551409ebb976bc3bdc7f8afcb5190cc9ca0 Mon Sep 17 00:00:00 2001 From: Test User Date: Mon, 29 Dec 2025 19:35:09 -0500 Subject: [PATCH] feat: enhance authentication and session management tests - Added comprehensive unit tests for authentication middleware, including session token validation, API key authentication, and cookie-based authentication. - Implemented tests for session management functions such as creating, updating, archiving, and deleting sessions. - Improved test coverage for queue management in session handling, ensuring robust error handling and validation. - Introduced checks for session metadata and working directory validation to ensure proper session creation. --- apps/server/tests/unit/lib/auth.test.ts | 322 +++++++++++++++ .../tests/unit/services/agent-service.test.ts | 382 ++++++++++++++++++ 2 files changed, 704 insertions(+) diff --git a/apps/server/tests/unit/lib/auth.test.ts b/apps/server/tests/unit/lib/auth.test.ts index 89cc801e..70f50def 100644 --- a/apps/server/tests/unit/lib/auth.test.ts +++ b/apps/server/tests/unit/lib/auth.test.ts @@ -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,6 +10,9 @@ 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', () => { @@ -54,6 +59,323 @@ 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', () => { diff --git a/apps/server/tests/unit/services/agent-service.test.ts b/apps/server/tests/unit/services/agent-service.test.ts index 15abbcdc..586a737b 100644 --- a/apps/server/tests/unit/services/agent-service.test.ts +++ b/apps/server/tests/unit/services/agent-service.test.ts @@ -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'); + }); + }); });