diff --git a/backend/src/__tests__/README.md b/backend/src/__tests__/README.md new file mode 100644 index 0000000..9866a06 --- /dev/null +++ b/backend/src/__tests__/README.md @@ -0,0 +1,173 @@ +# Test Suite Documentation + +## Overview +This directory contains the comprehensive test suite for the OPS-Agent-Desktop backend. Tests are organized by module and include unit tests, integration tests, and security tests. + +## Test Files + +### Authentication Tests (`auth/authService.test.ts`) +- **Coverage**: Password hashing, JWT generation/verification, login/registration flows +- **Security Focus**: + - Password hashing with bcrypt (12 rounds) + - JWT token validation + - Invalid credential handling + - OAuth user management +- **Test Count**: 15+ tests + +### Security Middleware Tests (`middleware/securityMiddleware.test.ts`) +- **Coverage**: Input sanitization, XSS protection, request validation +- **Security Focus**: + - XSS attack vector prevention + - Content-Type validation + - Request size limits + - Query parameter sanitization +- **Test Count**: 25+ tests + +### Auth Middleware Tests (`middleware/authMiddleware.test.ts`) +- **Coverage**: Authentication and authorization middleware +- **Security Focus**: + - Bearer token validation + - Role-based access control (RBAC) + - Ownership verification + - Authorization error handling +- **Test Count**: 20+ tests + +### API Routes Tests (`api/routes.test.ts`) +- **Coverage**: End-to-end API endpoint testing +- **Security Focus**: + - Authentication requirements + - Authorization checks + - User data scoping + - Admin privilege escalation +- **Test Count**: 20+ tests + +## Running Tests + +### All Tests +```bash +npm test +``` + +### Watch Mode (for development) +```bash +npm run test:watch +``` + +### With Coverage Report +```bash +npm run test:coverage +``` + +### With UI +```bash +npm run test:ui +``` + +## Prerequisites + +Before running tests, ensure dependencies are installed: +```bash +npm install +``` + +## Test Configuration + +Tests are configured via `vitest.config.ts` with the following settings: +- **Environment**: Node.js +- **Coverage Provider**: v8 +- **Coverage Threshold**: 60% for auth/, middleware/, api/ +- **Test Pattern**: `**/*.test.ts` + +## Continuous Integration + +Tests run automatically on: +- Every pull request +- Commits to `main` branch +- Pre-merge validation + +CI pipeline requirements: +- ✅ All tests must pass +- ✅ No linting errors +- ✅ Coverage thresholds met + +## Coverage Goals + +| Module | Current | Target | +|--------|---------|--------| +| auth/ | - | 80%+ | +| middleware/ | - | 75%+ | +| api/ | - | 70%+ | +| Overall | - | 60%+ | + +## Security Test Priorities + +Tests are prioritized for security-critical code: +1. **High Priority**: Authentication, authorization, password handling +2. **Medium Priority**: Input validation, rate limiting, CORS +3. **Standard**: Business logic, data transformations + +## Writing New Tests + +### Test Structure +```typescript +describe('Module Name', () => { + beforeEach(() => { + // Setup + }); + + describe('Feature Name', () => { + it('should do something specific', () => { + // Arrange + // Act + // Assert + }); + }); +}); +``` + +### Mocking Dependencies +Use vitest mocks for external dependencies: +```typescript +vi.mock('../db/client', () => ({ + prisma: { + user: { + findUnique: vi.fn(), + }, + }, +})); +``` + +### Security Test Best Practices +1. Test both positive and negative cases +2. Verify error messages don't leak sensitive data +3. Test authorization at boundaries (owner/non-owner, admin/user) +4. Test input sanitization with known attack vectors +5. Verify proper JWT handling (expired, invalid, malformed) + +## Troubleshooting + +### Tests not running +- Ensure `vitest` is installed: `npm install` +- Check Node version: `node -v` (requires v18+) + +### Coverage not generating +- Install coverage provider: `npm install -D @vitest/coverage-v8` + +### Mocks not working +- Clear mock cache: `vi.clearAllMocks()` in `beforeEach` +- Check mock path matches import path + +## Future Enhancements + +- [ ] Add frontend component tests +- [ ] Add E2E tests with Playwright +- [ ] Add performance/load tests +- [ ] Integrate mutation testing +- [ ] Add visual regression tests for UI + +## References + +- [Vitest Documentation](https://vitest.dev/) +- [Supertest Documentation](https://github.com/ladjs/supertest) +- [Testing Best Practices](https://testingjavascript.com/) + diff --git a/backend/src/api/routes.test.ts b/backend/src/api/routes.test.ts new file mode 100644 index 0000000..04508dd --- /dev/null +++ b/backend/src/api/routes.test.ts @@ -0,0 +1,439 @@ +/** + * API Routes Integration Tests + * Tests for API endpoint behavior and authorization + */ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import request from 'supertest'; +import express from 'express'; +import routes from './routes'; +import jwt from 'jsonwebtoken'; +import { config } from '../config'; + +// Mock dependencies +vi.mock('../repositories/missionRepository', () => ({ + missionRepository: { + create: vi.fn(), + findById: vi.fn(), + findOne: vi.fn(), + list: vi.fn(), + delete: vi.fn(), + getStats: vi.fn(), + }, +})); + +vi.mock('../browser/browserAgent', () => ({ + browserAgent: { + executeMission: vi.fn().mockResolvedValue(undefined), + }, +})); + +vi.mock('../observability/logger', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +vi.mock('../middleware/securityMiddleware', () => ({ + missionRateLimiter: (req: any, res: any, next: any) => next(), + authRateLimiter: (req: any, res: any, next: any) => next(), +})); + +import { missionRepository } from '../repositories/missionRepository'; + +// Create test Express app +const createApp = () => { + const app = express(); + app.use(express.json()); + app.use('/api', routes); + return app; +}; + +// Helper to generate test JWT token +const generateTestToken = (payload: any) => { + return jwt.sign(payload, config.jwtSecret, { expiresIn: '1h' }); +}; + +describe('API Routes', () => { + let app: express.Application; + + beforeEach(() => { + app = createApp(); + vi.clearAllMocks(); + }); + + describe('POST /api/missions', () => { + it('should reject unauthenticated requests', async () => { + const response = await request(app) + .post('/api/missions') + .send({ prompt: 'Check dashboard' }); + + expect(response.status).toBe(401); + expect(response.body.error).toBeDefined(); + }); + + it('should reject VIEWER role', async () => { + const token = generateTestToken({ + userId: 'user-123', + email: 'viewer@example.com', + role: 'VIEWER', + }); + + const response = await request(app) + .post('/api/missions') + .set('Authorization', `Bearer ${token}`) + .send({ prompt: 'Check dashboard' }); + + expect(response.status).toBe(403); + expect(response.body.error).toContain('permissions'); + }); + + it('should allow OPERATOR role to create mission', async () => { + const token = generateTestToken({ + userId: 'user-123', + email: 'operator@example.com', + role: 'OPERATOR', + }); + + const mockMission = { + id: 'mission-123', + userId: 'user-123', + prompt: 'Check dashboard', + status: 'PENDING', + createdAt: new Date(), + }; + + (missionRepository.create as any).mockResolvedValue(mockMission); + + const response = await request(app) + .post('/api/missions') + .set('Authorization', `Bearer ${token}`) + .send({ prompt: 'Check dashboard' }); + + expect(response.status).toBe(201); + expect(response.body.missionId).toBe('mission-123'); + expect(missionRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: 'Check dashboard', + userId: 'user-123', + }) + ); + }); + + it('should allow ADMIN role to create mission', async () => { + const token = generateTestToken({ + userId: 'admin-123', + email: 'admin@example.com', + role: 'ADMIN', + }); + + const mockMission = { + id: 'mission-456', + userId: 'admin-123', + prompt: 'Admin mission', + status: 'PENDING', + createdAt: new Date(), + }; + + (missionRepository.create as any).mockResolvedValue(mockMission); + + const response = await request(app) + .post('/api/missions') + .set('Authorization', `Bearer ${token}`) + .send({ prompt: 'Admin mission' }); + + expect(response.status).toBe(201); + expect(response.body.missionId).toBeDefined(); + }); + + it('should reject empty prompt', async () => { + const token = generateTestToken({ + userId: 'user-123', + email: 'operator@example.com', + role: 'OPERATOR', + }); + + const response = await request(app) + .post('/api/missions') + .set('Authorization', `Bearer ${token}`) + .send({ prompt: '' }); + + expect(response.status).toBe(400); + expect(response.body.error).toContain('prompt'); + }); + + it('should reject missing prompt', async () => { + const token = generateTestToken({ + userId: 'user-123', + email: 'operator@example.com', + role: 'OPERATOR', + }); + + const response = await request(app) + .post('/api/missions') + .set('Authorization', `Bearer ${token}`) + .send({}); + + expect(response.status).toBe(400); + }); + }); + + describe('GET /api/missions/:id', () => { + it('should reject unauthenticated requests', async () => { + const response = await request(app).get('/api/missions/mission-123'); + + expect(response.status).toBe(401); + }); + + it('should allow owner to access their mission', async () => { + const token = generateTestToken({ + userId: 'user-123', + email: 'user@example.com', + role: 'OPERATOR', + }); + + const mockMission = { + id: 'mission-123', + userId: 'user-123', + prompt: 'Check dashboard', + status: 'COMPLETED', + steps: [], + }; + + (missionRepository.findById as any).mockResolvedValue(mockMission); + + const response = await request(app) + .get('/api/missions/mission-123') + .set('Authorization', `Bearer ${token}`); + + expect(response.status).toBe(200); + expect(response.body.mission.id).toBe('mission-123'); + }); + + it('should prevent user from accessing another users mission', async () => { + const token = generateTestToken({ + userId: 'user-123', + email: 'user@example.com', + role: 'OPERATOR', + }); + + const mockMission = { + id: 'mission-456', + userId: 'user-456', // Different user + prompt: 'Other users mission', + status: 'RUNNING', + }; + + (missionRepository.findById as any).mockResolvedValue(mockMission); + + const response = await request(app) + .get('/api/missions/mission-456') + .set('Authorization', `Bearer ${token}`); + + expect(response.status).toBe(403); + expect(response.body.error).toContain('Access denied'); + }); + + it('should allow admin to access any mission', async () => { + const token = generateTestToken({ + userId: 'admin-123', + email: 'admin@example.com', + role: 'ADMIN', + }); + + const mockMission = { + id: 'mission-789', + userId: 'user-456', // Different user + prompt: 'Some mission', + status: 'COMPLETED', + steps: [], + }; + + (missionRepository.findById as any).mockResolvedValue(mockMission); + + const response = await request(app) + .get('/api/missions/mission-789') + .set('Authorization', `Bearer ${token}`); + + expect(response.status).toBe(200); + expect(response.body.mission.id).toBe('mission-789'); + }); + + it('should return 404 for non-existent mission', async () => { + const token = generateTestToken({ + userId: 'user-123', + email: 'user@example.com', + role: 'OPERATOR', + }); + + (missionRepository.findById as any).mockResolvedValue(null); + + const response = await request(app) + .get('/api/missions/nonexistent') + .set('Authorization', `Bearer ${token}`); + + expect(response.status).toBe(404); + expect(response.body.error).toContain('not found'); + }); + }); + + describe('GET /api/missions', () => { + it('should list missions scoped to user', async () => { + const token = generateTestToken({ + userId: 'user-123', + email: 'user@example.com', + role: 'OPERATOR', + }); + + const mockResult = { + missions: [ + { id: 'mission-1', userId: 'user-123', prompt: 'Mission 1' }, + { id: 'mission-2', userId: 'user-123', prompt: 'Mission 2' }, + ], + pagination: { + page: 1, + limit: 20, + total: 2, + totalPages: 1, + }, + }; + + (missionRepository.list as any).mockResolvedValue(mockResult); + + const response = await request(app) + .get('/api/missions') + .set('Authorization', `Bearer ${token}`); + + expect(response.status).toBe(200); + expect(response.body.missions).toHaveLength(2); + expect(missionRepository.list).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user-123', + }) + ); + }); + + it('should support pagination parameters', async () => { + const token = generateTestToken({ + userId: 'user-123', + email: 'user@example.com', + role: 'OPERATOR', + }); + + (missionRepository.list as any).mockResolvedValue({ + missions: [], + pagination: { page: 2, limit: 10, total: 0, totalPages: 0 }, + }); + + await request(app) + .get('/api/missions?page=2&limit=10') + .set('Authorization', `Bearer ${token}`); + + expect(missionRepository.list).toHaveBeenCalledWith( + expect.objectContaining({ + page: 2, + limit: 10, + }) + ); + }); + }); + + describe('DELETE /api/missions/:id', () => { + it('should allow owner to delete their mission', async () => { + const token = generateTestToken({ + userId: 'user-123', + email: 'user@example.com', + role: 'OPERATOR', + }); + + const mockMission = { + id: 'mission-123', + userId: 'user-123', + }; + + (missionRepository.findOne as any).mockResolvedValue(mockMission); + (missionRepository.delete as any).mockResolvedValue(undefined); + + const response = await request(app) + .delete('/api/missions/mission-123') + .set('Authorization', `Bearer ${token}`); + + expect(response.status).toBe(200); + expect(missionRepository.delete).toHaveBeenCalledWith('mission-123'); + }); + + it('should prevent user from deleting another users mission', async () => { + const token = generateTestToken({ + userId: 'user-123', + email: 'user@example.com', + role: 'OPERATOR', + }); + + const mockMission = { + id: 'mission-456', + userId: 'user-456', // Different user + }; + + (missionRepository.findOne as any).mockResolvedValue(mockMission); + + const response = await request(app) + .delete('/api/missions/mission-456') + .set('Authorization', `Bearer ${token}`); + + expect(response.status).toBe(403); + expect(missionRepository.delete).not.toHaveBeenCalled(); + }); + + it('should allow admin to delete any mission', async () => { + const token = generateTestToken({ + userId: 'admin-123', + email: 'admin@example.com', + role: 'ADMIN', + }); + + const mockMission = { + id: 'mission-789', + userId: 'user-456', + }; + + (missionRepository.findOne as any).mockResolvedValue(mockMission); + (missionRepository.delete as any).mockResolvedValue(undefined); + + const response = await request(app) + .delete('/api/missions/mission-789') + .set('Authorization', `Bearer ${token}`); + + expect(response.status).toBe(200); + expect(missionRepository.delete).toHaveBeenCalledWith('mission-789'); + }); + }); + + describe('Authorization Headers', () => { + it('should reject malformed Authorization header', async () => { + const response = await request(app) + .get('/api/missions') + .set('Authorization', 'InvalidFormat token'); + + expect(response.status).toBe(401); + }); + + it('should reject missing Bearer prefix', async () => { + const response = await request(app) + .get('/api/missions') + .set('Authorization', 'some-token'); + + expect(response.status).toBe(401); + }); + + it('should reject invalid JWT token', async () => { + const response = await request(app) + .get('/api/missions') + .set('Authorization', 'Bearer invalid.jwt.token'); + + expect(response.status).toBe(401); + }); + }); +}); + diff --git a/backend/src/api/routes.ts b/backend/src/api/routes.ts index feb043f..c3663ab 100644 --- a/backend/src/api/routes.ts +++ b/backend/src/api/routes.ts @@ -2,6 +2,8 @@ import { Router, Request, Response } from 'express'; import { missionService } from '../missions/missionService'; import { browserAgent } from '../browser/browserAgent'; import { CreateMissionRequest, CreateMissionResponse } from '../types/mission'; +import path from 'path'; +import fs from 'fs'; const router = Router(); @@ -59,7 +61,7 @@ router.get('/missions/:id/stream', (req: Request, res: Response) => { res.json({ mission, latestScreenshot: latestScreenshot - ? `/screenshots/${latestScreenshot}` + ? `/api/screenshots/${id}/${latestScreenshot}` : undefined, }); } catch (error) { @@ -102,4 +104,50 @@ router.get('/missions/:id', (req: Request, res: Response) => { } }); +/** + * GET /api/screenshots/:missionId/:filename + * Get a screenshot for a specific mission. + * Protected endpoint - validates mission exists before serving screenshot. + */ +router.get('/screenshots/:missionId/:filename', (req: Request, res: Response) => { + try { + const { missionId, filename } = req.params; + + // Validate mission exists + const mission = missionService.getMission(missionId); + if (!mission) { + return res.status(404).json({ error: 'Mission not found' }); + } + + // Validate filename belongs to this mission + const stepWithScreenshot = mission.steps.find( + (step) => step.screenshotPath === filename + ); + + if (!stepWithScreenshot) { + return res.status(403).json({ error: 'Screenshot not found for this mission' }); + } + + // Construct safe file path + const screenshotsDir = path.join(__dirname, '../../screenshots'); + const filepath = path.join(screenshotsDir, filename); + + // Prevent directory traversal attacks + if (!filepath.startsWith(screenshotsDir)) { + return res.status(403).json({ error: 'Invalid file path' }); + } + + // Check if file exists + if (!fs.existsSync(filepath)) { + return res.status(404).json({ error: 'Screenshot file not found' }); + } + + // Serve the file + res.sendFile(filepath); + } catch (error) { + console.error('Error serving screenshot:', error); + res.status(500).json({ error: 'Failed to serve screenshot' }); + } +}); + export default router; diff --git a/backend/src/auth/authService.test.ts b/backend/src/auth/authService.test.ts new file mode 100644 index 0000000..1ebf8c9 --- /dev/null +++ b/backend/src/auth/authService.test.ts @@ -0,0 +1,360 @@ +/** + * AuthService Tests + * Tests for password hashing, JWT generation, and authentication flows + */ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { authService } from './authService'; +import bcrypt from 'bcrypt'; +import jwt from 'jsonwebtoken'; +import { config } from '../config'; + +// Mock dependencies +vi.mock('../db/client', () => ({ + prisma: { + user: { + findUnique: vi.fn(), + create: vi.fn(), + update: vi.fn(), + }, + refreshToken: { + findUnique: vi.fn(), + create: vi.fn(), + update: vi.fn(), + }, + }, +})); + +vi.mock('../observability/logger', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +import { prisma } from '../db/client'; + +describe('AuthService', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('Password Hashing', () => { + it('should hash passwords securely with bcrypt', async () => { + const password = 'MySecurePassword123!'; + const hashedPassword = await bcrypt.hash(password, 12); + + expect(hashedPassword).toBeDefined(); + expect(hashedPassword).not.toBe(password); + expect(hashedPassword.length).toBeGreaterThan(50); + expect(hashedPassword).toMatch(/^\$2[aby]\$/); // bcrypt format + }); + + it('should verify correct password', async () => { + const password = 'MySecurePassword123!'; + const hashedPassword = await bcrypt.hash(password, 12); + + const isValid = await bcrypt.compare(password, hashedPassword); + expect(isValid).toBe(true); + }); + + it('should reject incorrect password', async () => { + const password = 'MySecurePassword123!'; + const hashedPassword = await bcrypt.hash(password, 12); + + const isValid = await bcrypt.compare('WrongPassword', hashedPassword); + expect(isValid).toBe(false); + }); + + it('should generate different hashes for same password', async () => { + const password = 'MySecurePassword123!'; + const hash1 = await bcrypt.hash(password, 12); + const hash2 = await bcrypt.hash(password, 12); + + expect(hash1).not.toBe(hash2); + + // Both should still verify correctly + expect(await bcrypt.compare(password, hash1)).toBe(true); + expect(await bcrypt.compare(password, hash2)).toBe(true); + }); + }); + + describe('JWT Token Generation', () => { + it('should generate valid access token', () => { + const payload = { + userId: 'user-123', + email: 'test@example.com', + role: 'OPERATOR' as const, + }; + + const token = jwt.sign(payload, config.jwtSecret, { + expiresIn: config.jwtExpiration, + }); + + expect(token).toBeDefined(); + expect(token.split('.')).toHaveLength(3); // JWT has 3 parts + + const decoded = jwt.verify(token, config.jwtSecret) as any; + expect(decoded.userId).toBe(payload.userId); + expect(decoded.email).toBe(payload.email); + expect(decoded.role).toBe(payload.role); + }); + + it('should reject token with invalid signature', () => { + const payload = { + userId: 'user-123', + email: 'test@example.com', + role: 'OPERATOR' as const, + }; + + const token = jwt.sign(payload, 'wrong-secret'); + + expect(() => { + jwt.verify(token, config.jwtSecret); + }).toThrow(); + }); + + it('should reject expired token', () => { + const payload = { + userId: 'user-123', + email: 'test@example.com', + role: 'OPERATOR' as const, + }; + + const token = jwt.sign(payload, config.jwtSecret, { + expiresIn: '-1s', // Expired 1 second ago + }); + + expect(() => { + jwt.verify(token, config.jwtSecret); + }).toThrow(/expired/); + }); + }); + + describe('verifyAccessToken', () => { + it('should verify valid access token', () => { + const payload = { + userId: 'user-123', + email: 'test@example.com', + role: 'OPERATOR' as const, + }; + + const token = jwt.sign(payload, config.jwtSecret, { + expiresIn: '1h', + }); + + const result = authService.verifyAccessToken(token); + + expect(result.userId).toBe(payload.userId); + expect(result.email).toBe(payload.email); + expect(result.role).toBe(payload.role); + }); + + it('should throw error for invalid token', () => { + expect(() => { + authService.verifyAccessToken('invalid-token'); + }).toThrow(/Invalid or expired access token/); + }); + + it('should throw error for expired token', () => { + const payload = { + userId: 'user-123', + email: 'test@example.com', + role: 'OPERATOR' as const, + }; + + const token = jwt.sign(payload, config.jwtSecret, { + expiresIn: '-1s', + }); + + expect(() => { + authService.verifyAccessToken(token); + }).toThrow(/Invalid or expired access token/); + }); + }); + + describe('register', () => { + it('should create new user with hashed password', async () => { + const mockUser = { + id: 'user-123', + email: 'newuser@example.com', + passwordHash: 'hashed-password', + name: 'New User', + role: 'OPERATOR', + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + lastLoginAt: null, + oauthProvider: null, + oauthId: null, + }; + + (prisma.user.findUnique as any).mockResolvedValue(null); + (prisma.user.create as any).mockResolvedValue(mockUser); + + const result = await authService.register( + 'newuser@example.com', + 'SecurePass123!', + 'New User' + ); + + expect(result).toEqual(mockUser); + expect(prisma.user.findUnique).toHaveBeenCalledWith({ + where: { email: 'newuser@example.com' }, + }); + expect(prisma.user.create).toHaveBeenCalled(); + }); + + it('should throw error if user already exists', async () => { + const existingUser = { + id: 'user-123', + email: 'existing@example.com', + role: 'OPERATOR', + }; + + (prisma.user.findUnique as any).mockResolvedValue(existingUser); + + await expect( + authService.register('existing@example.com', 'password', 'User') + ).rejects.toThrow('User with this email already exists'); + }); + }); + + describe('login', () => { + it('should authenticate valid credentials', async () => { + const password = 'SecurePass123!'; + const hashedPassword = await bcrypt.hash(password, 12); + + const mockUser = { + id: 'user-123', + email: 'user@example.com', + passwordHash: hashedPassword, + name: 'Test User', + role: 'OPERATOR', + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + lastLoginAt: null, + oauthProvider: null, + oauthId: null, + }; + + (prisma.user.findUnique as any).mockResolvedValue(mockUser); + (prisma.user.update as any).mockResolvedValue(mockUser); + (prisma.refreshToken.create as any).mockResolvedValue({}); + + const tokens = await authService.login('user@example.com', password); + + expect(tokens).toHaveProperty('accessToken'); + expect(tokens).toHaveProperty('refreshToken'); + expect(tokens.accessToken).toBeDefined(); + expect(tokens.refreshToken).toBeDefined(); + }); + + it('should reject invalid password', async () => { + const password = 'SecurePass123!'; + const hashedPassword = await bcrypt.hash(password, 12); + + const mockUser = { + id: 'user-123', + email: 'user@example.com', + passwordHash: hashedPassword, + isActive: true, + oauthProvider: null, + }; + + (prisma.user.findUnique as any).mockResolvedValue(mockUser); + + await expect( + authService.login('user@example.com', 'WrongPassword') + ).rejects.toThrow('Invalid credentials'); + }); + + it('should reject non-existent user', async () => { + (prisma.user.findUnique as any).mockResolvedValue(null); + + await expect( + authService.login('nonexistent@example.com', 'password') + ).rejects.toThrow('Invalid credentials'); + }); + + it('should reject inactive user', async () => { + const mockUser = { + id: 'user-123', + email: 'inactive@example.com', + passwordHash: 'hash', + isActive: false, + }; + + (prisma.user.findUnique as any).mockResolvedValue(mockUser); + + await expect( + authService.login('inactive@example.com', 'password') + ).rejects.toThrow('Invalid credentials'); + }); + + it('should reject OAuth user attempting password login', async () => { + const mockUser = { + id: 'user-123', + email: 'oauth@example.com', + passwordHash: null, // OAuth user has no password + isActive: true, + oauthProvider: 'google', + }; + + (prisma.user.findUnique as any).mockResolvedValue(mockUser); + + await expect( + authService.login('oauth@example.com', 'password') + ).rejects.toThrow('Please login with your OAuth provider'); + }); + }); + + describe('changePassword', () => { + it('should change password successfully', async () => { + const oldPassword = 'OldPass123!'; + const newPassword = 'NewPass456!'; + const oldHash = await bcrypt.hash(oldPassword, 12); + + const mockUser = { + id: 'user-123', + passwordHash: oldHash, + }; + + (prisma.user.findUnique as any).mockResolvedValue(mockUser); + (prisma.user.update as any).mockResolvedValue({}); + + await expect( + authService.changePassword('user-123', oldPassword, newPassword) + ).resolves.not.toThrow(); + + expect(prisma.user.update).toHaveBeenCalled(); + }); + + it('should reject incorrect old password', async () => { + const oldPassword = 'OldPass123!'; + const oldHash = await bcrypt.hash(oldPassword, 12); + + const mockUser = { + id: 'user-123', + passwordHash: oldHash, + }; + + (prisma.user.findUnique as any).mockResolvedValue(mockUser); + + await expect( + authService.changePassword('user-123', 'WrongOldPass', 'NewPass456!') + ).rejects.toThrow('Invalid old password'); + }); + + it('should reject if user not found', async () => { + (prisma.user.findUnique as any).mockResolvedValue(null); + + await expect( + authService.changePassword('nonexistent', 'old', 'new') + ).rejects.toThrow('User not found'); + }); + }); +}); + diff --git a/backend/src/index.ts b/backend/src/index.ts index 83bbbf0..4ace8ab 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -12,10 +12,7 @@ app.use(cors()); app.use(express.json()); app.use(morgan('dev')); -// Serve screenshots as static files -app.use('/screenshots', express.static(path.join(__dirname, '../screenshots'))); - -// API routes +// API routes (includes protected screenshot endpoint) app.use('/api', apiRoutes); // Health check @@ -26,7 +23,6 @@ app.get('/health', (req, res) => { // Start server app.listen(PORT, () => { console.log(`🚀 Ops-Agent-Desktop Backend running on http://localhost:${PORT}`); - console.log(`📸 Screenshots available at http://localhost:${PORT}/screenshots`); console.log(`🔍 API docs: http://localhost:${PORT}/api`); }); diff --git a/backend/src/middleware/authMiddleware.test.ts b/backend/src/middleware/authMiddleware.test.ts new file mode 100644 index 0000000..6158234 --- /dev/null +++ b/backend/src/middleware/authMiddleware.test.ts @@ -0,0 +1,378 @@ +/** + * Auth Middleware Tests + * Tests for authentication and authorization middleware + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Request, Response, NextFunction } from 'express'; +import { requireAuth, requireRole, isAdmin, isOwner } from './authMiddleware'; +import jwt from 'jsonwebtoken'; +import { config } from '../config'; +import { Role } from '@prisma/client'; + +// Mock dependencies +vi.mock('../auth/authService', () => ({ + authService: { + verifyAccessToken: vi.fn(), + }, +})); + +vi.mock('../observability/logger', () => ({ + logger: { + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +import { authService } from '../auth/authService'; + +describe('Auth Middleware', () => { + let mockReq: Partial; + let mockRes: Partial; + let nextFn: NextFunction; + + beforeEach(() => { + mockReq = { + headers: {}, + user: undefined, + }; + + mockRes = { + status: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis(), + }; + + nextFn = vi.fn(); + vi.clearAllMocks(); + }); + + describe('requireAuth', () => { + it('should reject request without Authorization header', () => { + mockReq.headers = {}; + + requireAuth(mockReq as Request, mockRes as Response, nextFn); + + expect(mockRes.status).toHaveBeenCalledWith(401); + expect(mockRes.json).toHaveBeenCalledWith({ + error: 'Missing or invalid authorization header', + }); + expect(nextFn).not.toHaveBeenCalled(); + }); + + it('should reject Authorization header without Bearer prefix', () => { + mockReq.headers = { authorization: 'InvalidToken' }; + + requireAuth(mockReq as Request, mockRes as Response, nextFn); + + expect(mockRes.status).toHaveBeenCalledWith(401); + expect(mockRes.json).toHaveBeenCalledWith({ + error: 'Missing or invalid authorization header', + }); + expect(nextFn).not.toHaveBeenCalled(); + }); + + it('should accept valid Bearer token', () => { + const mockPayload = { + userId: 'user-123', + email: 'user@example.com', + role: 'OPERATOR' as Role, + }; + + mockReq.headers = { authorization: 'Bearer valid-token' }; + (authService.verifyAccessToken as any).mockReturnValue(mockPayload); + + requireAuth(mockReq as Request, mockRes as Response, nextFn); + + expect(authService.verifyAccessToken).toHaveBeenCalledWith('valid-token'); + expect(mockReq.user).toEqual(mockPayload); + expect(nextFn).toHaveBeenCalled(); + expect(mockRes.status).not.toHaveBeenCalled(); + }); + + it('should reject invalid token', () => { + mockReq.headers = { authorization: 'Bearer invalid-token' }; + (authService.verifyAccessToken as any).mockImplementation(() => { + throw new Error('Invalid token'); + }); + + requireAuth(mockReq as Request, mockRes as Response, nextFn); + + expect(mockRes.status).toHaveBeenCalledWith(401); + expect(mockRes.json).toHaveBeenCalledWith({ + error: 'Invalid or expired token', + }); + expect(nextFn).not.toHaveBeenCalled(); + }); + + it('should reject expired token', () => { + mockReq.headers = { authorization: 'Bearer expired-token' }; + (authService.verifyAccessToken as any).mockImplementation(() => { + throw new Error('Token expired'); + }); + + requireAuth(mockReq as Request, mockRes as Response, nextFn); + + expect(mockRes.status).toHaveBeenCalledWith(401); + expect(mockRes.json).toHaveBeenCalledWith({ + error: 'Invalid or expired token', + }); + }); + + it('should handle token with correct format', () => { + const payload = { + userId: 'user-456', + email: 'admin@example.com', + role: 'ADMIN' as Role, + }; + + const token = jwt.sign(payload, config.jwtSecret, { expiresIn: '1h' }); + mockReq.headers = { authorization: `Bearer ${token}` }; + + (authService.verifyAccessToken as any).mockReturnValue(payload); + + requireAuth(mockReq as Request, mockRes as Response, nextFn); + + expect(mockReq.user?.userId).toBe('user-456'); + expect(mockReq.user?.role).toBe('ADMIN'); + expect(nextFn).toHaveBeenCalled(); + }); + }); + + describe('requireRole', () => { + it('should reject request without authenticated user', () => { + mockReq.user = undefined; + + const middleware = requireRole('ADMIN' as Role); + middleware(mockReq as Request, mockRes as Response, nextFn); + + expect(mockRes.status).toHaveBeenCalledWith(401); + expect(mockRes.json).toHaveBeenCalledWith({ + error: 'Authentication required', + }); + expect(nextFn).not.toHaveBeenCalled(); + }); + + it('should allow user with correct role', () => { + mockReq.user = { + userId: 'user-123', + email: 'admin@example.com', + role: 'ADMIN' as Role, + }; + + const middleware = requireRole('ADMIN' as Role); + middleware(mockReq as Request, mockRes as Response, nextFn); + + expect(nextFn).toHaveBeenCalled(); + expect(mockRes.status).not.toHaveBeenCalled(); + }); + + it('should reject user with incorrect role', () => { + mockReq.user = { + userId: 'user-123', + email: 'user@example.com', + role: 'VIEWER' as Role, + }; + + const middleware = requireRole('ADMIN' as Role); + middleware(mockReq as Request, mockRes as Response, nextFn); + + expect(mockRes.status).toHaveBeenCalledWith(403); + expect(mockRes.json).toHaveBeenCalledWith({ + error: 'Insufficient permissions', + }); + expect(nextFn).not.toHaveBeenCalled(); + }); + + it('should allow user with one of multiple allowed roles', () => { + mockReq.user = { + userId: 'user-123', + email: 'operator@example.com', + role: 'OPERATOR' as Role, + }; + + const middleware = requireRole('OPERATOR' as Role, 'ADMIN' as Role); + middleware(mockReq as Request, mockRes as Response, nextFn); + + expect(nextFn).toHaveBeenCalled(); + expect(mockRes.status).not.toHaveBeenCalled(); + }); + + it('should reject user not in multiple allowed roles', () => { + mockReq.user = { + userId: 'user-123', + email: 'viewer@example.com', + role: 'VIEWER' as Role, + }; + + const middleware = requireRole('OPERATOR' as Role, 'ADMIN' as Role); + middleware(mockReq as Request, mockRes as Response, nextFn); + + expect(mockRes.status).toHaveBeenCalledWith(403); + expect(mockRes.json).toHaveBeenCalledWith({ + error: 'Insufficient permissions', + }); + }); + }); + + describe('isAdmin', () => { + it('should return true for ADMIN role', () => { + mockReq.user = { + userId: 'admin-123', + email: 'admin@example.com', + role: 'ADMIN' as Role, + }; + + const result = isAdmin(mockReq as Request); + + expect(result).toBe(true); + }); + + it('should return false for non-ADMIN roles', () => { + const roles: Role[] = ['OPERATOR', 'VIEWER']; + + roles.forEach((role) => { + mockReq.user = { + userId: 'user-123', + email: 'user@example.com', + role, + }; + + const result = isAdmin(mockReq as Request); + expect(result).toBe(false); + }); + }); + + it('should return false when user is undefined', () => { + mockReq.user = undefined; + + const result = isAdmin(mockReq as Request); + + expect(result).toBe(false); + }); + }); + + describe('isOwner', () => { + it('should return true when userId matches resource userId', () => { + mockReq.user = { + userId: 'user-123', + email: 'user@example.com', + role: 'OPERATOR' as Role, + }; + + const result = isOwner(mockReq as Request, 'user-123'); + + expect(result).toBe(true); + }); + + it('should return false when userId does not match', () => { + mockReq.user = { + userId: 'user-123', + email: 'user@example.com', + role: 'OPERATOR' as Role, + }; + + const result = isOwner(mockReq as Request, 'user-456'); + + expect(result).toBe(false); + }); + + it('should return false when user is undefined', () => { + mockReq.user = undefined; + + const result = isOwner(mockReq as Request, 'user-123'); + + expect(result).toBe(false); + }); + }); + + describe('Authorization Scenarios', () => { + it('should properly chain requireAuth and requireRole', () => { + const mockPayload = { + userId: 'admin-123', + email: 'admin@example.com', + role: 'ADMIN' as Role, + }; + + mockReq.headers = { authorization: 'Bearer valid-token' }; + (authService.verifyAccessToken as any).mockReturnValue(mockPayload); + + // First requireAuth + requireAuth(mockReq as Request, mockRes as Response, nextFn); + expect(mockReq.user).toEqual(mockPayload); + + // Then requireRole + const roleMiddleware = requireRole('ADMIN' as Role); + roleMiddleware(mockReq as Request, mockRes as Response, nextFn); + + expect(nextFn).toHaveBeenCalledTimes(2); + }); + + it('should prevent role check without authentication', () => { + // Skip requireAuth + mockReq.user = undefined; + + const roleMiddleware = requireRole('ADMIN' as Role); + roleMiddleware(mockReq as Request, mockRes as Response, nextFn); + + expect(mockRes.status).toHaveBeenCalledWith(401); + expect(nextFn).not.toHaveBeenCalled(); + }); + + it('should handle case-sensitive role comparison', () => { + mockReq.user = { + userId: 'user-123', + email: 'user@example.com', + role: 'ADMIN' as Role, + }; + + // Role must match exactly + const middleware = requireRole('ADMIN' as Role); + middleware(mockReq as Request, mockRes as Response, nextFn); + + expect(nextFn).toHaveBeenCalled(); + }); + }); + + describe('Security Edge Cases', () => { + it('should handle malformed Bearer token', () => { + mockReq.headers = { authorization: 'Bearer' }; // No token after Bearer + + requireAuth(mockReq as Request, mockRes as Response, nextFn); + + expect(mockRes.status).toHaveBeenCalledWith(401); + }); + + it('should handle multiple spaces in Authorization header', () => { + mockReq.headers = { authorization: 'Bearer token-with-spaces' }; + + (authService.verifyAccessToken as any).mockImplementation(() => { + throw new Error('Invalid token'); + }); + + requireAuth(mockReq as Request, mockRes as Response, nextFn); + + expect(mockRes.status).toHaveBeenCalledWith(401); + }); + + it('should not leak user information in error messages', () => { + mockReq.user = { + userId: 'user-123', + email: 'user@example.com', + role: 'VIEWER' as Role, + }; + + const middleware = requireRole('ADMIN' as Role); + middleware(mockReq as Request, mockRes as Response, nextFn); + + expect(mockRes.json).toHaveBeenCalledWith({ + error: 'Insufficient permissions', + }); + + // Should not include role information in response + const jsonCall = (mockRes.json as any).mock.calls[0][0]; + expect(JSON.stringify(jsonCall)).not.toContain('VIEWER'); + expect(JSON.stringify(jsonCall)).not.toContain('user@example.com'); + }); + }); +}); + diff --git a/backend/src/middleware/securityMiddleware.test.ts b/backend/src/middleware/securityMiddleware.test.ts new file mode 100644 index 0000000..52ca519 --- /dev/null +++ b/backend/src/middleware/securityMiddleware.test.ts @@ -0,0 +1,320 @@ +/** + * Security Middleware Tests + * Tests for input sanitization, rate limiting, and security headers + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Request, Response, NextFunction } from 'express'; +import { + sanitizeInput, + validateContentType, + validateRequestSize, + sanitizeQueryParams, +} from './securityMiddleware'; + +describe('Security Middleware', () => { + let mockReq: Partial; + let mockRes: Partial; + let nextFn: NextFunction; + + beforeEach(() => { + mockReq = { + method: 'POST', + headers: {}, + body: {}, + query: {}, + }; + + mockRes = { + status: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis(), + }; + + nextFn = vi.fn(); + }); + + describe('sanitizeInput', () => { + it('should remove script tags from body strings', () => { + mockReq.body = { + message: 'Hello World', + }; + + sanitizeInput(mockReq as Request, mockRes as Response, nextFn); + + expect(mockReq.body.message).toBe('Hello World'); + expect(mockReq.body.message).not.toContain('', + bio: 'Developer', + }, + }; + + sanitizeInput(mockReq as Request, mockRes as Response, nextFn); + + expect(mockReq.body.user.name).toBe('John'); + expect(mockReq.body.user.bio).toBe('Developer'); + expect(nextFn).toHaveBeenCalled(); + }); + + it('should handle arrays', () => { + mockReq.body = { + tags: [ + 'tag1', + 'tag2', + 'tag3', + ], + }; + + sanitizeInput(mockReq as Request, mockRes as Response, nextFn); + + expect(mockReq.body.tags[0]).toBe('tag1'); + expect(mockReq.body.tags[1]).toBe('tag2'); + expect(mockReq.body.tags[2]).toBe('tag3'); + expect(nextFn).toHaveBeenCalled(); + }); + + it('should not modify non-string values', () => { + mockReq.body = { + count: 42, + active: true, + ratio: 3.14, + nothing: null, + }; + + sanitizeInput(mockReq as Request, mockRes as Response, nextFn); + + expect(mockReq.body.count).toBe(42); + expect(mockReq.body.active).toBe(true); + expect(mockReq.body.ratio).toBe(3.14); + expect(mockReq.body.nothing).toBe(null); + expect(nextFn).toHaveBeenCalled(); + }); + + it('should handle empty body', () => { + mockReq.body = {}; + + sanitizeInput(mockReq as Request, mockRes as Response, nextFn); + + expect(mockReq.body).toEqual({}); + expect(nextFn).toHaveBeenCalled(); + }); + + it('should remove multiple script tags', () => { + mockReq.body = { + text: 'SafeContent', + }; + + sanitizeInput(mockReq as Request, mockRes as Response, nextFn); + + expect(mockReq.body.text).toBe('SafeContent'); + expect(nextFn).toHaveBeenCalled(); + }); + }); + + describe('validateContentType', () => { + it('should allow GET requests without Content-Type', () => { + mockReq.method = 'GET'; + + validateContentType(mockReq as Request, mockRes as Response, nextFn); + + expect(nextFn).toHaveBeenCalled(); + expect(mockRes.status).not.toHaveBeenCalled(); + }); + + it('should require JSON Content-Type for POST', () => { + mockReq.method = 'POST'; + mockReq.headers = {}; + + validateContentType(mockReq as Request, mockRes as Response, nextFn); + + expect(mockRes.status).toHaveBeenCalledWith(415); + expect(mockRes.json).toHaveBeenCalledWith({ + error: 'Content-Type must be application/json', + }); + expect(nextFn).not.toHaveBeenCalled(); + }); + + it('should accept valid JSON Content-Type for POST', () => { + mockReq.method = 'POST'; + mockReq.headers = { 'content-type': 'application/json' }; + + validateContentType(mockReq as Request, mockRes as Response, nextFn); + + expect(nextFn).toHaveBeenCalled(); + expect(mockRes.status).not.toHaveBeenCalled(); + }); + + it('should accept JSON with charset for POST', () => { + mockReq.method = 'POST'; + mockReq.headers = { 'content-type': 'application/json; charset=utf-8' }; + + validateContentType(mockReq as Request, mockRes as Response, nextFn); + + expect(nextFn).toHaveBeenCalled(); + }); + + it('should require JSON Content-Type for PUT', () => { + mockReq.method = 'PUT'; + mockReq.headers = {}; + + validateContentType(mockReq as Request, mockRes as Response, nextFn); + + expect(mockRes.status).toHaveBeenCalledWith(415); + }); + + it('should require JSON Content-Type for PATCH', () => { + mockReq.method = 'PATCH'; + mockReq.headers = {}; + + validateContentType(mockReq as Request, mockRes as Response, nextFn); + + expect(mockRes.status).toHaveBeenCalledWith(415); + }); + }); + + describe('validateRequestSize', () => { + it('should accept requests within size limit', () => { + const maxSize = 1024 * 1024; // 1MB + mockReq.headers = { 'content-length': '1000' }; + + const middleware = validateRequestSize(maxSize); + middleware(mockReq as Request, mockRes as Response, nextFn); + + expect(nextFn).toHaveBeenCalled(); + expect(mockRes.status).not.toHaveBeenCalled(); + }); + + it('should reject requests exceeding size limit', () => { + const maxSize = 1024; // 1KB + mockReq.headers = { 'content-length': '2048' }; // 2KB + + const middleware = validateRequestSize(maxSize); + middleware(mockReq as Request, mockRes as Response, nextFn); + + expect(mockRes.status).toHaveBeenCalledWith(413); + expect(mockRes.json).toHaveBeenCalledWith({ + error: 'Request entity too large', + }); + expect(nextFn).not.toHaveBeenCalled(); + }); + + it('should allow requests without Content-Length', () => { + const maxSize = 1024; + mockReq.headers = {}; + + const middleware = validateRequestSize(maxSize); + middleware(mockReq as Request, mockRes as Response, nextFn); + + expect(nextFn).toHaveBeenCalled(); + }); + + it('should handle exact size limit', () => { + const maxSize = 1000; + mockReq.headers = { 'content-length': '1000' }; + + const middleware = validateRequestSize(maxSize); + middleware(mockReq as Request, mockRes as Response, nextFn); + + expect(nextFn).toHaveBeenCalled(); + }); + }); + + describe('sanitizeQueryParams', () => { + it('should convert array query params to single values', () => { + mockReq.query = { + id: ['123', '456', '789'], + name: 'John', + }; + + sanitizeQueryParams(mockReq as Request, mockRes as Response, nextFn); + + expect(mockReq.query.id).toBe('123'); // Takes first value + expect(mockReq.query.name).toBe('John'); + expect(nextFn).toHaveBeenCalled(); + }); + + it('should handle empty query', () => { + mockReq.query = {}; + + sanitizeQueryParams(mockReq as Request, mockRes as Response, nextFn); + + expect(mockReq.query).toEqual({}); + expect(nextFn).toHaveBeenCalled(); + }); + + it('should not modify single value params', () => { + mockReq.query = { + page: '1', + limit: '20', + status: 'active', + }; + + sanitizeQueryParams(mockReq as Request, mockRes as Response, nextFn); + + expect(mockReq.query.page).toBe('1'); + expect(mockReq.query.limit).toBe('20'); + expect(mockReq.query.status).toBe('active'); + expect(nextFn).toHaveBeenCalled(); + }); + + it('should handle mixed array and single params', () => { + mockReq.query = { + tags: ['tag1', 'tag2'], + category: 'news', + filter: ['all', 'active'], + }; + + sanitizeQueryParams(mockReq as Request, mockRes as Response, nextFn); + + expect(mockReq.query.tags).toBe('tag1'); + expect(mockReq.query.category).toBe('news'); + expect(mockReq.query.filter).toBe('all'); + expect(nextFn).toHaveBeenCalled(); + }); + }); + + describe('XSS Attack Vectors', () => { + it('should prevent common XSS attack patterns', () => { + const xssVectors = [ + '', + '', + '', + '', + '', + '', + ]; + + xssVectors.forEach((vector) => { + mockReq.body = { input: vector }; + sanitizeInput(mockReq as Request, mockRes as Response, nextFn); + + // Script tags should be removed + expect(mockReq.body.input).not.toContain(''); + }); + }); + + it('should handle script tag variations', () => { + const vectors = [ + '', + '', + 'alert(1)', + 'alert(1)', + ]; + + vectors.forEach((vector) => { + mockReq.body = { input: vector }; + sanitizeInput(mockReq as Request, mockRes as Response, nextFn); + + // All variations should be handled (case-insensitive regex) + expect(mockReq.body.input.toLowerCase()).not.toContain('alert'); + }); + }); + }); +}); + diff --git a/backend/vitest.config.ts b/backend/vitest.config.ts new file mode 100644 index 0000000..d6630b9 --- /dev/null +++ b/backend/vitest.config.ts @@ -0,0 +1,29 @@ +import { defineConfig } from 'vitest/config'; +import path from 'path'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + exclude: [ + 'node_modules/**', + 'dist/**', + '**/*.config.ts', + '**/*.d.ts', + '**/types/**', + 'src/db/seed.ts', + ], + }, + include: ['src/**/*.test.ts'], + exclude: ['node_modules', 'dist'], + }, + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, +}); + diff --git a/frontend/src/components/ConnectionStatus.css b/frontend/src/components/ConnectionStatus.css new file mode 100644 index 0000000..981e2c5 --- /dev/null +++ b/frontend/src/components/ConnectionStatus.css @@ -0,0 +1,92 @@ +.connection-status { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + border-radius: 4px; + font-size: 0.875rem; + background-color: rgba(0, 0, 0, 0.1); + user-select: none; +} + +.status-indicator { + width: 8px; + height: 8px; + border-radius: 50%; + animation: pulse 2s infinite; +} + +.status-label { + font-weight: 500; +} + +/* Status-specific styles */ +.status-connected { + color: #10b981; +} + +.status-connected .status-indicator { + background-color: #10b981; + box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.2); +} + +.status-connecting, +.status-reconnecting { + color: #f59e0b; +} + +.status-connecting .status-indicator, +.status-reconnecting .status-indicator { + background-color: #f59e0b; + box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.2); + animation: pulse 1s infinite; +} + +.status-disconnected { + color: #6b7280; +} + +.status-disconnected .status-indicator { + background-color: #6b7280; + box-shadow: 0 0 0 2px rgba(107, 114, 128, 0.2); + animation: none; +} + +.status-error { + color: #ef4444; +} + +.status-error .status-indicator { + background-color: #ef4444; + box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.2); + animation: pulse 0.5s infinite; +} + +.status-polling { + color: #3b82f6; +} + +.status-polling .status-indicator { + background-color: #3b82f6; + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2); + animation: pulse 1.5s infinite; +} + +/* Pulse animation */ +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +/* Dark mode adjustments */ +@media (prefers-color-scheme: dark) { + .connection-status { + background-color: rgba(255, 255, 255, 0.05); + } +} + diff --git a/frontend/src/components/ConnectionStatus.tsx b/frontend/src/components/ConnectionStatus.tsx new file mode 100644 index 0000000..95c4741 --- /dev/null +++ b/frontend/src/components/ConnectionStatus.tsx @@ -0,0 +1,72 @@ +/** + * ConnectionStatus Component + * Displays real-time WebSocket connection status + */ +import { ConnectionStatus as Status } from '../hooks/useMission'; +import './ConnectionStatus.css'; + +interface ConnectionStatusProps { + status: Status; + isUsingPolling: boolean; +} + +export const ConnectionStatus = ({ status, isUsingPolling }: ConnectionStatusProps) => { + const getStatusInfo = () => { + if (isUsingPolling) { + return { + className: 'status-polling', + label: 'Polling', + description: 'Using HTTP polling (fallback mode)', + }; + } + + switch (status) { + case Status.CONNECTED: + return { + className: 'status-connected', + label: 'Connected', + description: 'Real-time WebSocket connection active', + }; + case Status.CONNECTING: + return { + className: 'status-connecting', + label: 'Connecting', + description: 'Establishing WebSocket connection...', + }; + case Status.RECONNECTING: + return { + className: 'status-reconnecting', + label: 'Reconnecting', + description: 'Reconnecting to WebSocket server...', + }; + case Status.DISCONNECTED: + return { + className: 'status-disconnected', + label: 'Disconnected', + description: 'WebSocket connection closed', + }; + case Status.ERROR: + return { + className: 'status-error', + label: 'Connection Error', + description: 'Failed to establish WebSocket connection', + }; + default: + return { + className: 'status-unknown', + label: 'Unknown', + description: 'Connection status unknown', + }; + } + }; + + const { className, label, description } = getStatusInfo(); + + return ( +
+
+ {label} +
+ ); +}; +