Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
12 changes: 12 additions & 0 deletions prisma/migrations/002_add_deployment_indexes.sql
Original file line number Diff line number Diff line change
@@ -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);
20 changes: 20 additions & 0 deletions src/middleware/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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 = {
Expand Down
97 changes: 97 additions & 0 deletions src/modules/deployments/deployments.controller.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>);
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);
}
}

Comment on lines +44 to +47
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) {
Comment on lines +75 to +79
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);
}
}
22 changes: 12 additions & 10 deletions src/modules/deployments/deployments.routes.ts
Original file line number Diff line number Diff line change
@@ -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);
Comment on lines +9 to +10
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;
24 changes: 24 additions & 0 deletions src/modules/deployments/deployments.schema.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createDeploymentSchema>;
export type UpdateStatusInput = z.infer<typeof updateStatusSchema>;
Loading