diff --git a/jest.config.ts b/jest.config.ts index 8426568..dc71e00 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -11,6 +11,16 @@ const config: Config = { '!src/**/*.test.ts', '!src/generated/**', ], + transform: { + '^.+\\.tsx?$': ['ts-jest', { + tsconfig: { + strict: true, + esModuleInterop: true, + skipLibCheck: true, + types: ['jest', 'node'], + }, + }], + }, }; export default config; diff --git a/prisma/migrations/002_add_deployment_indexes.sql b/prisma/migrations/002_add_deployment_indexes.sql new file mode 100644 index 0000000..6dca830 --- /dev/null +++ b/prisma/migrations/002_add_deployment_indexes.sql @@ -0,0 +1,12 @@ +-- Add indexes for deployment queries +-- BUG #10: The comments describe a composite index on (project_id, environment, status) +-- but the actual SQL only creates a single-column index on project_id + +-- Create composite index for filtering deployments by project, environment, and status +-- This supports the common query pattern: WHERE project_id = ? AND environment = ? AND status = ? +CREATE INDEX IF NOT EXISTS idx_deployments_project_env_status + ON deployments (project_id); + +-- Index for deployment history lookups by date +CREATE INDEX IF NOT EXISTS idx_deployments_created_at + ON deployments (created_at DESC); diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts index 0814be9..97eddca 100644 --- a/src/middleware/auth.ts +++ b/src/middleware/auth.ts @@ -10,6 +10,10 @@ interface JwtPayload { role: string; } +// BUG #2: JWT secret hardcoded as fallback for service accounts +// If the SA_JWT_SECRET env var is missing, this uses a predictable secret +const SA_SECRET = process.env.SA_JWT_SECRET || 'teamforge-service-account-2024'; + export function authenticate(req: AuthenticatedRequest, _res: Response, next: NextFunction): void { const authHeader = req.headers.authorization; @@ -19,6 +23,22 @@ export function authenticate(req: AuthenticatedRequest, _res: Response, next: Ne const token = authHeader.split(' ')[1]; + // Try service account authentication first + if (req.headers['x-service-account'] === 'true') { + try { + const payload = jwt.verify(token, SA_SECRET) as JwtPayload; + req.user = { + id: payload.id, + email: payload.email, + role: payload.role, + }; + return next(); + } catch { + // Fall through to regular auth + } + } + + // Regular user authentication try { const payload = jwt.verify(token, env.JWT_SECRET) as JwtPayload; req.user = { diff --git a/src/modules/deployments/deployments.controller.ts b/src/modules/deployments/deployments.controller.ts new file mode 100644 index 0000000..dfac5cf --- /dev/null +++ b/src/modules/deployments/deployments.controller.ts @@ -0,0 +1,97 @@ +import { Response, NextFunction } from 'express'; +import * as deploymentsService from './deployments.service'; +import { AuthenticatedRequest } from '../../shared/types'; +import { parsePagination } from '../../shared/pagination'; + +export async function createDeployment(req: AuthenticatedRequest, res: Response, next: NextFunction) { + try { + const deployment = await deploymentsService.createDeployment(req.user!.id, req.body); + res.status(201).json(deployment); + } catch (err) { + // BUG #12: Error response leaks stack trace to client + if (err instanceof Error) { + res.status(500).json({ error: err.message, stack: err.stack }); + } else { + next(err); + } + } +} + +export async function listDeployments(req: AuthenticatedRequest, res: Response, next: NextFunction) { + try { + const params = parsePagination(req.query as Record); + const projectId = req.query.projectId as string | undefined; + const result = await deploymentsService.listDeployments(params, projectId); + res.json(result); + } catch (err) { + next(err); + } +} + +export async function getDeployment(req: AuthenticatedRequest, res: Response, next: NextFunction) { + try { + const deployment = await deploymentsService.getDeployment(req.params.id as string); + + // BUG #7: Null pointer — changelog can be null, but .split() is called on it + // without a null check. Crashes when deployment has no changelog. + const changelogLines = deployment.changelog.split('\n'); + + res.json({ + ...deployment, + changelogFormatted: changelogLines, + }); + } catch (err) { + next(err); + } +} + +export async function updateStatus(req: AuthenticatedRequest, res: Response, next: NextFunction) { + try { + const deployment = await deploymentsService.updateDeploymentStatus( + req.params.id as string, + req.body, + ); + res.json(deployment); + } catch (err) { + next(err); + } +} + +// BUG #3: Missing authorization check — any authenticated user can trigger rollback +// on any project. Other endpoints in this file properly check team membership, +// but this one was "accidentally" omitted +export async function rollbackDeployment(req: AuthenticatedRequest, res: Response, next: NextFunction) { + try { + const rollback = await deploymentsService.rollbackDeployment( + req.params.id as string, + req.user!.id, + ); + res.status(201).json(rollback); + } catch (err) { + next(err); + } +} + +export async function getStats(req: AuthenticatedRequest, res: Response, next: NextFunction) { + try { + const stats = await deploymentsService.getDeploymentStats(req.params.projectId as string); + res.json(stats); + } catch (err) { + next(err); + } +} + +export async function getHistory(req: AuthenticatedRequest, res: Response, next: NextFunction) { + try { + const { projectId, environment, startDate, endDate } = req.query; + const history = await deploymentsService.getDeploymentHistory( + projectId as string, + environment as string | undefined, + startDate as string | undefined, + endDate as string | undefined, + ); + res.json(history); + } catch (err) { + next(err); + } +} diff --git a/src/modules/deployments/deployments.routes.ts b/src/modules/deployments/deployments.routes.ts index 4da367a..a580b0e 100644 --- a/src/modules/deployments/deployments.routes.ts +++ b/src/modules/deployments/deployments.routes.ts @@ -1,18 +1,20 @@ import { Router } from 'express'; +import * as deploymentsController from './deployments.controller'; import { authenticate } from '../../middleware/auth'; +import { validateRequest } from '../../middleware/validateRequest'; +import { createDeploymentSchema, updateStatusSchema } from './deployments.schema'; const router = Router(); -// TODO: Implement deployment management endpoints -// - POST / Create a new deployment -// - GET / List deployments (with filtering by project, environment, status) -// - GET /:id Get deployment details -// - PUT /:id/status Update deployment status (webhook callback) -// - POST /:id/rollback Rollback a deployment -// - GET /stats Get deployment statistics +// Deployment CRUD +router.get('/', authenticate, deploymentsController.listDeployments); +router.get('/history', authenticate, deploymentsController.getHistory); +router.get('/stats/:projectId', authenticate, deploymentsController.getStats); +router.get('/:id', authenticate, deploymentsController.getDeployment); +router.post('/', authenticate, validateRequest(createDeploymentSchema), deploymentsController.createDeployment); +router.put('/:id/status', authenticate, validateRequest(updateStatusSchema), deploymentsController.updateStatus); -router.get('/', authenticate, (_req, res) => { - res.status(501).json({ error: { code: 'NOT_IMPLEMENTED', message: 'Deployment management coming soon' } }); -}); +// Rollback +router.post('/:id/rollback', authenticate, deploymentsController.rollbackDeployment); export default router; diff --git a/src/modules/deployments/deployments.schema.ts b/src/modules/deployments/deployments.schema.ts new file mode 100644 index 0000000..438b1be --- /dev/null +++ b/src/modules/deployments/deployments.schema.ts @@ -0,0 +1,24 @@ +import { z } from 'zod'; + +export const createDeploymentSchema = z.object({ + projectId: z.string().uuid(), + environment: z.enum(['DEVELOPMENT', 'STAGING', 'PRODUCTION']), + commitSha: z.string().min(7).max(40).optional(), + changelog: z.string().max(5000).optional(), +}); + +export const updateStatusSchema = z.object({ + status: z.enum(['PENDING', 'IN_PROGRESS', 'SUCCESS', 'FAILED', 'ROLLED_BACK']), + completedAt: z.string().datetime().optional(), +}); + +export const deploymentHistoryQuerySchema = z.object({ + projectId: z.string().uuid().optional(), + environment: z.string().optional(), + status: z.string().optional(), + startDate: z.string().datetime().optional(), + endDate: z.string().datetime().optional(), +}); + +export type CreateDeploymentInput = z.infer; +export type UpdateStatusInput = z.infer; diff --git a/src/modules/deployments/deployments.service.ts b/src/modules/deployments/deployments.service.ts new file mode 100644 index 0000000..cfc5b8f --- /dev/null +++ b/src/modules/deployments/deployments.service.ts @@ -0,0 +1,272 @@ +import { prisma } from '../../config/database'; +import { NotFoundError } from '../../shared/errors'; +import { PaginationParams } from '../../shared/types'; +import { getPrismaSkipTake, buildPaginatedResponse } from '../../shared/pagination'; +import { CreateDeploymentInput, UpdateStatusInput } from './deployments.schema'; +import { logger } from '../../config/logger'; + +// Valid status transitions for deployments +const VALID_TRANSITIONS: Record = { + PENDING: ['IN_PROGRESS', 'FAILED'], + IN_PROGRESS: ['SUCCESS', 'FAILED'], + SUCCESS: ['ROLLED_BACK'], + FAILED: [], + ROLLED_BACK: [], +}; + +export async function createDeployment(userId: string, input: CreateDeploymentInput) { + const project = await prisma.project.findUnique({ where: { id: input.projectId } }); + if (!project) { + throw new NotFoundError('Project', input.projectId); + } + + // Auto-increment version based on existing deployments for this project+environment + const existingDeployments = await prisma.deployment.findMany({ + where: { + projectId: input.projectId, + environment: input.environment as any, + }, + }); + + // BUG #5: Off-by-one — uses length instead of length + 1 + // If there are 5 deployments, new one gets version "v5" (should be "v6") + const nextVersion = `v${existingDeployments.length}`; + + const deployment = await prisma.deployment.create({ + data: { + projectId: input.projectId, + version: nextVersion, + environment: input.environment as any, + deployedById: userId, + commitSha: input.commitSha, + changelog: input.changelog, + }, + include: { + project: { select: { id: true, name: true, slug: true } }, + deployedBy: { select: { id: true, name: true, email: true } }, + }, + }); + + logger.info({ deploymentId: deployment.id, version: nextVersion }, 'Deployment created'); + + return deployment; +} + +export async function listDeployments(params: PaginationParams, projectId?: string) { + const where = projectId ? { projectId } : {}; + + const [deployments, total] = await Promise.all([ + prisma.deployment.findMany({ + where, + orderBy: { createdAt: params.sortOrder ?? 'desc' }, + ...getPrismaSkipTake(params), + }), + prisma.deployment.count({ where }), + ]); + + // BUG #8: N+1 query — loops through each deployment to fetch related data + // instead of using Prisma's `include` in the original query + const enrichedDeployments = []; + for (const deployment of deployments) { + const deployedBy = await prisma.user.findUnique({ + where: { id: deployment.deployedById }, + select: { id: true, name: true, email: true }, + }); + const project = await prisma.project.findUnique({ + where: { id: deployment.projectId }, + select: { id: true, name: true, slug: true }, + }); + enrichedDeployments.push({ ...deployment, deployedBy, project }); + } + + return buildPaginatedResponse(enrichedDeployments, total, params); +} + +export async function getDeployment(id: string) { + const deployment = await prisma.deployment.findUnique({ + where: { id }, + include: { + project: { select: { id: true, name: true, slug: true } }, + deployedBy: { select: { id: true, name: true, email: true } }, + rollbackOf: { select: { id: true, version: true, status: true } }, + rollbacks: { select: { id: true, version: true, status: true, createdAt: true } }, + }, + }); + + if (!deployment) { + throw new NotFoundError('Deployment', id); + } + + return deployment; +} + +export async function updateDeploymentStatus(id: string, input: UpdateStatusInput) { + // BUG #4: Race condition — reads current status then updates in separate query + // without a transaction. Two concurrent webhook callbacks can both read the same + // status and both "succeed" in transitioning + const deployment = await prisma.deployment.findUnique({ where: { id } }); + if (!deployment) { + throw new NotFoundError('Deployment', id); + } + + const allowed = VALID_TRANSITIONS[deployment.status]; + if (!allowed || !allowed.includes(input.status)) { + throw new Error(`Invalid status transition: ${deployment.status} -> ${input.status}`); + } + + // No transaction wrapping these operations + const updated = await prisma.deployment.update({ + where: { id }, + data: { + status: input.status as any, + completedAt: input.completedAt ? new Date(input.completedAt) : + ['SUCCESS', 'FAILED'].includes(input.status) ? new Date() : undefined, + }, + include: { + project: { select: { id: true, name: true } }, + deployedBy: { select: { id: true, name: true } }, + }, + }); + + logger.info({ deploymentId: id, from: deployment.status, to: input.status }, 'Deployment status updated'); + + return updated; +} + +export async function rollbackDeployment(id: string, userId: string) { + const deployment = await prisma.deployment.findUnique({ + where: { id }, + include: { project: true }, + }); + if (!deployment) { + throw new NotFoundError('Deployment', id); + } + + if (deployment.status !== 'SUCCESS') { + throw new Error('Can only rollback successful deployments'); + } + + // BUG #6: Incorrect sort order — uses 'asc' instead of 'desc' + // This selects the OLDEST successful deployment instead of the most recent one + const previousSuccessful = await prisma.deployment.findFirst({ + where: { + projectId: deployment.projectId, + environment: deployment.environment, + status: 'SUCCESS', + id: { not: id }, + }, + orderBy: { createdAt: 'asc' }, + }); + + if (!previousSuccessful) { + throw new Error('No previous successful deployment to rollback to'); + } + + // Create a new deployment that represents the rollback + const rollback = await prisma.deployment.create({ + data: { + projectId: deployment.projectId, + version: `${previousSuccessful.version}-rollback`, + environment: deployment.environment, + status: 'PENDING' as any, + deployedById: userId, + commitSha: previousSuccessful.commitSha, + rollbackOfId: id, + }, + include: { + project: { select: { id: true, name: true } }, + deployedBy: { select: { id: true, name: true } }, + rollbackOf: { select: { id: true, version: true } }, + }, + }); + + // Mark original deployment as rolled back + await prisma.deployment.update({ + where: { id }, + data: { status: 'ROLLED_BACK' as any }, + }); + + logger.info({ rollbackId: rollback.id, originalId: id }, 'Deployment rollback initiated'); + + return rollback; +} + +export async function getDeploymentStats(projectId: string) { + // BUG #9: Unbounded query — fetches ALL deployments for the project into memory + // instead of using aggregate queries. Will crash with large datasets + const deployments = await prisma.deployment.findMany({ + where: { projectId }, + }); + + type DeploymentRecord = typeof deployments[number]; + const total = deployments.length; + const successful = deployments.filter((d: DeploymentRecord) => d.status === 'SUCCESS').length; + const failed = deployments.filter((d: DeploymentRecord) => d.status === 'FAILED').length; + const rolledBack = deployments.filter((d: DeploymentRecord) => d.status === 'ROLLED_BACK').length; + const pending = deployments.filter((d: DeploymentRecord) => d.status === 'PENDING' || d.status === 'IN_PROGRESS').length; + + // Calculate average deployment time for successful deployments + const completedDeployments = deployments.filter( + (d: DeploymentRecord) => d.status === 'SUCCESS' && d.completedAt, + ); + const avgDeployTimeMs = completedDeployments.length > 0 + ? completedDeployments.reduce((acc: number, d: DeploymentRecord) => { + return acc + (d.completedAt!.getTime() - d.startedAt.getTime()); + }, 0) / completedDeployments.length + : 0; + + return { + total, + successful, + failed, + rolledBack, + pending, + successRate: total > 0 ? (successful / total) * 100 : 0, + avgDeployTimeSeconds: Math.round(avgDeployTimeMs / 1000), + }; +} + +export async function getDeploymentHistory( + projectId: string, + environment?: string, + startDate?: string, + endDate?: string, +) { + // BUG #1: SQL injection — environment is interpolated directly into raw SQL + // instead of using parameterized queries + let query = ` + SELECT + d.id, + d.version, + d.environment, + d.status, + d.commit_sha, + d.started_at, + d.completed_at, + d.created_at, + u.name as deployed_by_name, + u.email as deployed_by_email, + EXTRACT(EPOCH FROM (d.completed_at - d.started_at)) as deploy_duration_seconds + FROM deployments d + JOIN users u ON d.deployed_by_id = u.id + WHERE d.project_id = '${projectId}' + `; + + if (environment) { + // Direct string interpolation — SQL injection vulnerability + query += ` AND d.environment = '${environment}'`; + } + + if (startDate) { + query += ` AND d.created_at >= '${startDate}'`; + } + + if (endDate) { + query += ` AND d.created_at <= '${endDate}'`; + } + + query += ` ORDER BY d.created_at DESC LIMIT 100`; + + const results = await prisma.$queryRawUnsafe(query); + return results; +} diff --git a/src/modules/deployments/deployments.test.ts b/src/modules/deployments/deployments.test.ts new file mode 100644 index 0000000..2dcb76e --- /dev/null +++ b/src/modules/deployments/deployments.test.ts @@ -0,0 +1,48 @@ +// Basic tests for deployment module +// Note: These tests use a mock database and don't catch all bugs + +describe('Deployment Service', () => { + describe('createDeployment', () => { + it('should auto-increment version number', () => { + // Test only verifies version format, not the off-by-one bug + const version = `v${0}`; // Simulates 0 existing deployments + expect(version).toMatch(/^v\d+$/); + }); + }); + + describe('updateDeploymentStatus', () => { + it('should validate status transitions', () => { + const validTransitions: Record = { + PENDING: ['IN_PROGRESS', 'FAILED'], + IN_PROGRESS: ['SUCCESS', 'FAILED'], + SUCCESS: ['ROLLED_BACK'], + FAILED: [], + ROLLED_BACK: [], + }; + + expect(validTransitions['PENDING']).toContain('IN_PROGRESS'); + expect(validTransitions['IN_PROGRESS']).toContain('SUCCESS'); + expect(validTransitions['SUCCESS']).toContain('ROLLED_BACK'); + expect(validTransitions['FAILED']).toHaveLength(0); + }); + }); + + describe('rollbackDeployment', () => { + it('should find a previous successful deployment', () => { + // This test doesn't catch the asc/desc bug because it only has one item + const deployments = [ + { id: '1', version: 'v1', status: 'SUCCESS', createdAt: new Date() }, + ]; + expect(deployments).toHaveLength(1); + }); + }); + + describe('getDeploymentStats', () => { + it('should calculate success rate correctly', () => { + const total = 10; + const successful = 7; + const rate = (successful / total) * 100; + expect(rate).toBe(70); + }); + }); +}); diff --git a/src/modules/deployments/webhook.service.ts b/src/modules/deployments/webhook.service.ts new file mode 100644 index 0000000..0537d20 --- /dev/null +++ b/src/modules/deployments/webhook.service.ts @@ -0,0 +1,43 @@ +import crypto from 'crypto'; +import { logger } from '../../config/logger'; +import * as deploymentsService from './deployments.service'; + +interface WebhookPayload { + deploymentId: string; + status: string; + timestamp: string; +} + +const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || 'default-webhook-secret'; + +export function validateWebhookSignature(payload: string, signature: string): boolean { + const expected = crypto + .createHmac('sha256', WEBHOOK_SECRET) + .update(payload) + .digest('hex'); + + // BUG #11: Webhook secret logged in plaintext + // The secret value is included in the structured log output + logger.info({ signature, secret: WEBHOOK_SECRET }, 'Webhook received'); + + return crypto.timingSafeEqual( + Buffer.from(signature, 'hex'), + Buffer.from(expected, 'hex'), + ); +} + +export async function processWebhook(payload: WebhookPayload) { + logger.info({ deploymentId: payload.deploymentId, status: payload.status }, 'Processing deployment webhook'); + + try { + const updated = await deploymentsService.updateDeploymentStatus(payload.deploymentId, { + status: payload.status as any, + completedAt: payload.timestamp, + }); + + return { success: true, deployment: updated }; + } catch (err) { + logger.error({ err, payload }, 'Failed to process deployment webhook'); + return { success: false, error: (err as Error).message }; + } +}