From 6f5edab1c81ce8d1dc468e3e5aa927c64d725cdf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Apr 2026 01:34:00 +0000 Subject: [PATCH 1/2] fix: harden step completion and backend error handling Agent-Logs-Url: https://github.com/OlympusLedgerOrg/TiM/sessions/9aecc93e-3ae2-4b8e-83d9-bb84283d81ab Co-authored-by: OlympusLedgerOrg <240737312+OlympusLedgerOrg@users.noreply.github.com> --- backend/prisma/src/middleware/auth.ts | 4 +- backend/src/app.ts | 45 ++++++++++++++++++++- backend/src/config/jwt.ts | 12 ++++++ backend/src/controllers/stepController.ts | 1 + backend/src/middleware/auth.ts | 4 +- backend/src/routes/auth.ts | 4 +- backend/src/services/equipmentService.ts | 31 +++++++++----- backend/src/services/stepService.ts | 49 ++++++++++++++--------- backend/tests/auth.test.ts | 11 +++++ backend/tests/completeStep.test.ts | 14 +++++++ backend/tests/equipment.test.ts | 18 ++++++--- backend/tests/jwtConfig.test.ts | 35 ++++++++++++++++ 12 files changed, 186 insertions(+), 42 deletions(-) create mode 100644 backend/src/config/jwt.ts create mode 100644 backend/tests/jwtConfig.test.ts diff --git a/backend/prisma/src/middleware/auth.ts b/backend/prisma/src/middleware/auth.ts index ee444300..6c3fff18 100644 --- a/backend/prisma/src/middleware/auth.ts +++ b/backend/prisma/src/middleware/auth.ts @@ -1,5 +1,6 @@ import { jwtVerify } from 'jose'; import { NextFunction, Request, Response } from 'express'; +import { getJwtSecret } from '../../../src/config/jwt.js'; export type Role = 'Tech' | 'Supervisor' | 'Admin'; @@ -17,8 +18,7 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio const token = auth.startsWith('Bearer ') ? auth.slice(7) : ''; if (!token) return res.status(401).json({ message: 'Missing token' }); - const secret = new TextEncoder().encode(process.env.JWT_SECRET || 'change-me'); - const { payload } = await jwtVerify(token, secret); + const { payload } = await jwtVerify(token, getJwtSecret()); req.user = { id: String(payload.sub), role: payload.role as Role }; return next(); } catch { diff --git a/backend/src/app.ts b/backend/src/app.ts index fb545fba..c2375788 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -1,5 +1,6 @@ import 'dotenv/config'; import express from 'express'; +import type { ErrorRequestHandler } from 'express'; import { Server } from 'socket.io'; import { createServer } from 'http'; import { jwtVerify } from 'jose'; @@ -25,6 +26,9 @@ import materialRoutes from './routes/materials.js'; import { globalLimiter, sapLimiter } from './middleware/rateLimiter.js'; import { httpsRedirect } from './middleware/httpsRedirect.js'; import type { Role } from './middleware/auth.js'; +import { getJwtSecret, validateJwtSecret } from './config/jwt.js'; + +validateJwtSecret(); const app = express(); const httpServer = createServer(app); @@ -75,8 +79,7 @@ io.use(async (socket, next) => { return next(new Error('Authentication token required')); } - const secret = new TextEncoder().encode(process.env.JWT_SECRET || 'change-me'); - const { payload } = await jwtVerify(token, secret); + const { payload } = await jwtVerify(token, getJwtSecret()); // Attach user info to socket for later use socket.data.user = { @@ -136,6 +139,44 @@ if (staticFilesPath) { console.log(`📦 Desktop mode: serving frontend from ${staticFilesPath}`); } +const errorHandler: ErrorRequestHandler = (err, req, res, next) => { + if (res.headersSent) { + return next(err); + } + + const isInvalidJson = + err instanceof SyntaxError && + 'status' in err && + req.is('application/json'); + const status = + isInvalidJson + ? 400 + : typeof err === 'object' && + err !== null && + 'status' in err && + typeof err.status === 'number' && + err.status >= 400 && + err.status < 600 + ? err.status + : 500; + const message = + status >= 500 + ? 'Internal server error' + : isInvalidJson + ? 'Invalid JSON body' + : err instanceof Error + ? err.message + : 'Unexpected error'; + + if (status >= 500) { + console.error(err); + } + + return res.status(status).json({ message }); +}; + +app.use(errorHandler); + // Socket.IO connection handling with tenant validation io.on('connection', (socket) => { const userTenantId = socket.data.user?.tenantId; diff --git a/backend/src/config/jwt.ts b/backend/src/config/jwt.ts new file mode 100644 index 00000000..7cc35eed --- /dev/null +++ b/backend/src/config/jwt.ts @@ -0,0 +1,12 @@ +const INSECURE_DEFAULT_JWT_SECRET = 'change-me'; + +export function validateJwtSecret() { + const secret = process.env.JWT_SECRET; + if (process.env.NODE_ENV === 'production' && (!secret || secret === INSECURE_DEFAULT_JWT_SECRET)) { + throw new Error('JWT_SECRET must be set to a non-default value in production'); + } +} + +export function getJwtSecret() { + return new TextEncoder().encode(process.env.JWT_SECRET || INSECURE_DEFAULT_JWT_SECRET); +} diff --git a/backend/src/controllers/stepController.ts b/backend/src/controllers/stepController.ts index 3c6288a5..c5e2ef91 100644 --- a/backend/src/controllers/stepController.ts +++ b/backend/src/controllers/stepController.ts @@ -9,6 +9,7 @@ export async function completeStepController(req: Request, res: Response) { if (!parse.success) return res.status(400).json({ message: 'Invalid body' }); const result = await completeStep({ + tenantId: req.user!.tenantId, workOrderId: req.params.workOrderId, stepId: req.params.stepId, notes: parse.data.notes, diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts index 5f0dbd0d..b3085f3a 100644 --- a/backend/src/middleware/auth.ts +++ b/backend/src/middleware/auth.ts @@ -1,5 +1,6 @@ import { jwtVerify } from 'jose'; import { NextFunction, Request, Response } from 'express'; +import { getJwtSecret } from '../config/jwt.js'; export type Role = 'Tech' | 'Supervisor' | 'Admin'; @@ -17,8 +18,7 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio const token = auth.startsWith('Bearer ') ? auth.slice(7) : ''; if (!token) return res.status(401).json({ message: 'Missing token' }); - const secret = new TextEncoder().encode(process.env.JWT_SECRET || 'change-me'); - const { payload } = await jwtVerify(token, secret); + const { payload } = await jwtVerify(token, getJwtSecret()); req.user = { id: String(payload.sub), role: payload.role as Role, diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 6e729394..ab817f2b 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -5,6 +5,7 @@ import bcrypt from 'bcryptjs'; import { prisma } from '../prisma/client.js'; import { authLimiter } from '../middleware/rateLimiter.js'; import type { Role } from '../middleware/auth.js'; +import { getJwtSecret } from '../config/jwt.js'; const router = Router(); @@ -50,7 +51,6 @@ router.post('/login', authLimiter, async (req, res) => { } // Generate JWT with sub, role, tenantId, and name claims - const secret = new TextEncoder().encode(process.env.JWT_SECRET || 'change-me'); const token = await new SignJWT({ sub: user.id, role: user.role as Role, @@ -60,7 +60,7 @@ router.post('/login', authLimiter, async (req, res) => { .setProtectedHeader({ alg: 'HS256' }) .setIssuedAt() .setExpirationTime('8h') // Shift-length token - .sign(secret); + .sign(getJwtSecret()); return res.status(200).json({ token, diff --git a/backend/src/services/equipmentService.ts b/backend/src/services/equipmentService.ts index 7ec3e1f7..9f97def6 100644 --- a/backend/src/services/equipmentService.ts +++ b/backend/src/services/equipmentService.ts @@ -70,15 +70,26 @@ export async function getEquipmentByWorkCenter(tenantId: string, workCenterCode: orderBy: { code: 'asc' }, }); - // Enrich with active downtime if any - const summaries: EquipmentSummary[] = []; - for (const eq of equipment) { - const activeDowntime = await prisma.downtimeEvent.findFirst({ - where: { equipmentId: eq.id, endedAt: null }, - orderBy: { startedAt: 'desc' }, - }); + const activeDowntimeEvents = equipment.length > 0 + ? await prisma.downtimeEvent.findMany({ + where: { + equipmentId: { in: equipment.map(eq => eq.id) }, + endedAt: null, + }, + orderBy: { startedAt: 'desc' }, + }) + : []; + + const downtimeByEquipment = new Map(); + for (const event of activeDowntimeEvents) { + if (!downtimeByEquipment.has(event.equipmentId)) { + downtimeByEquipment.set(event.equipmentId, event); + } + } - summaries.push({ + const summaries: EquipmentSummary[] = equipment.map((eq) => { + const activeDowntime = downtimeByEquipment.get(eq.id) || null; + return { id: eq.id, code: eq.code, name: eq.name, @@ -95,8 +106,8 @@ export async function getEquipmentByWorkCenter(tenantId: string, workCenterCode: startedAt: activeDowntime.startedAt.toISOString(), durationMin: (Date.now() - activeDowntime.startedAt.getTime()) / 60000, } : null, - }); - } + }; + }); return { status: 200, body: { equipment: summaries } }; } diff --git a/backend/src/services/stepService.ts b/backend/src/services/stepService.ts index fa015d78..86c3583e 100644 --- a/backend/src/services/stepService.ts +++ b/backend/src/services/stepService.ts @@ -2,32 +2,43 @@ import { prisma } from '../prisma/client.js'; import { emitStepCompleted } from '../sockets/workOrderSocket.js'; export async function completeStep(opts: { + tenantId: string; workOrderId: string; stepId: string; notes?: string; actorUserId: string; }) { - const step = await prisma.step.findFirst({ - where: { id: opts.stepId, workOrderId: opts.workOrderId } - }); - if (!step) return { status: 404, body: { message: 'Work order or step not found' } }; - if (step.status === 'COMPLETED') return { status: 409, body: { message: 'Step already completed' } }; + const result = await prisma.$transaction(async (tx) => { + const step = await tx.step.findFirst({ + where: { id: opts.stepId, workOrderId: opts.workOrderId, tenantId: opts.tenantId } + }); + if (!step) return { status: 404, body: { message: 'Work order or step not found' } }; + if (step.status === 'COMPLETED') return { status: 409, body: { message: 'Step already completed' } }; - const updated = await prisma.step.update({ - where: { id: step.id }, - data: { status: 'COMPLETED', notes: opts.notes ?? step.notes } - }); + const updated = await tx.step.update({ + where: { id: step.id }, + data: { status: 'COMPLETED', notes: opts.notes ?? step.notes } + }); + + await tx.auditLog.create({ + data: { + tenantId: opts.tenantId, + workOrderId: opts.workOrderId, + stepId: opts.stepId, + actorUserId: opts.actorUserId, + action: 'STEP_COMPLETED', + metadata: { notes: opts.notes ?? null } + } + }); - await prisma.auditLog.create({ - data: { - workOrderId: opts.workOrderId, - stepId: opts.stepId, - actorUserId: opts.actorUserId, - action: 'STEP_COMPLETED', - metadata: { notes: opts.notes ?? null } - } + return { + status: 200, + body: { step: { id: updated.id, status: updated.status, notes: updated.notes } } + }; }); - emitStepCompleted(opts.workOrderId, { stepId: opts.stepId, notes: opts.notes }); - return { status: 200, body: { step: { id: updated.id, status: updated.status, notes: updated.notes } } }; + if (result.status === 200) { + emitStepCompleted(opts.workOrderId, { stepId: opts.stepId, notes: opts.notes }); + } + return result; } diff --git a/backend/tests/auth.test.ts b/backend/tests/auth.test.ts index 8517ebfe..04605440 100644 --- a/backend/tests/auth.test.ts +++ b/backend/tests/auth.test.ts @@ -129,6 +129,17 @@ describe('POST /api/v1/auth/login', () => { expect(res.body.user.role).toBe('Supervisor'); }); + it('returns a sanitized 500 response for unexpected errors', async () => { + (prisma.user.findUnique as jest.Mock).mockRejectedValueOnce(new Error('database exploded')); + + const res = await request(app) + .post('/api/v1/auth/login') + .send({ email: 'super@test.com', password: testPassword }); + + expect(res.status).toBe(500); + expect(res.body).toEqual({ message: 'Internal server error' }); + }); + it('token contains correct claims (sub, role, tenantId, name)', async () => { (prisma.user.findUnique as jest.Mock).mockResolvedValueOnce({ id: 'user-1', email: 'admin@test.com', name: 'Admin User', diff --git a/backend/tests/completeStep.test.ts b/backend/tests/completeStep.test.ts index 030990b8..68b0ad0e 100644 --- a/backend/tests/completeStep.test.ts +++ b/backend/tests/completeStep.test.ts @@ -11,6 +11,7 @@ const m = { step: { findUnique: jest.fn(), findFirst: jest.fn(), update: jest.fn() }, workOrderStep: { findUnique: jest.fn(), findFirst: jest.fn(), update: jest.fn() }, auditLog: { create: jest.fn() }, + $transaction: jest.fn(), $queryRaw: jest.fn(), }; jest.mock('../src/prisma/client', () => ({ prisma: m })); @@ -36,6 +37,7 @@ const stepApi = () => m.step; describe('POST /complete', () => { beforeEach(() => { jest.clearAllMocks(); + m.$transaction.mockImplementation(async (callback: (tx: typeof m) => unknown) => callback(m)); }); afterAll(async () => { @@ -49,6 +51,18 @@ describe('POST /complete', () => { const token = await makeToken('Tech'); const res = await request(app).post(url()).set('Authorization', `Bearer ${token}`).send({ notes: 'done' }); expect(res.status).toBe(200); + expect(m.$transaction).toHaveBeenCalledTimes(1); + expect(stepApi().findFirst).toHaveBeenCalledWith({ + where: { id: 'st-1', workOrderId: 'wo-1', tenantId: 'default' }, + }); + expect(m.auditLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + tenantId: 'default', + workOrderId: 'wo-1', + stepId: 'st-1', + actorUserId: 'user-1', + }), + }); }); test('403 for Admin', async () => { diff --git a/backend/tests/equipment.test.ts b/backend/tests/equipment.test.ts index 5407d98c..e061bbdc 100644 --- a/backend/tests/equipment.test.ts +++ b/backend/tests/equipment.test.ts @@ -91,12 +91,12 @@ describe('Equipment API', () => { axxosEquipmentId: 'AX-4002', isActive: true, }, ]); - m.downtimeEvent.findFirst - .mockResolvedValueOnce(null) // eq-1: no downtime - .mockResolvedValueOnce({ // eq-2: active downtime + m.downtimeEvent.findMany.mockResolvedValue([ + { // eq-2: active downtime id: 'dt-1', category: 'UNPLANNED', reasonCode: 'BREAKDOWN-HYDRAULIC', - reasonText: 'Hydraulic line burst', startedAt: new Date('2026-04-13T14:30:00Z'), - }); + reasonText: 'Hydraulic line burst', startedAt: new Date('2026-04-13T14:30:00Z'), equipmentId: 'eq-2', + }, + ]); const token = await makeToken('Tech'); const res = await request(app) @@ -110,6 +110,14 @@ describe('Equipment API', () => { expect(res.body.equipment[1].status).toBe('DOWN'); expect(res.body.equipment[1].currentDowntime).toBeTruthy(); expect(res.body.equipment[1].currentDowntime.reasonCode).toBe('BREAKDOWN-HYDRAULIC'); + expect(m.downtimeEvent.findMany).toHaveBeenCalledWith({ + where: { + equipmentId: { in: ['eq-1', 'eq-2'] }, + endedAt: null, + }, + orderBy: { startedAt: 'desc' }, + }); + expect(m.downtimeEvent.findFirst).not.toHaveBeenCalled(); }); test('returns 401 without authentication', async () => { diff --git a/backend/tests/jwtConfig.test.ts b/backend/tests/jwtConfig.test.ts new file mode 100644 index 00000000..268e361f --- /dev/null +++ b/backend/tests/jwtConfig.test.ts @@ -0,0 +1,35 @@ +import { getJwtSecret, validateJwtSecret } from '../src/config/jwt'; + +describe('JWT configuration', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + it('throws in production when JWT_SECRET is missing', () => { + delete process.env.JWT_SECRET; + process.env.NODE_ENV = 'production'; + + expect(() => validateJwtSecret()).toThrow(/JWT_SECRET/); + }); + + it('throws in production when JWT_SECRET uses the insecure default', () => { + process.env.JWT_SECRET = 'change-me'; + process.env.NODE_ENV = 'production'; + + expect(() => validateJwtSecret()).toThrow(/JWT_SECRET/); + }); + + it('allows test environments to use the test secret', () => { + process.env.JWT_SECRET = 'test-secret'; + process.env.NODE_ENV = 'test'; + + expect(() => validateJwtSecret()).not.toThrow(); + expect(getJwtSecret()).toBeInstanceOf(Uint8Array); + }); +}); From 3484791a4ab7269365b9db537a067c8d2ded5baf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Apr 2026 01:36:39 +0000 Subject: [PATCH 2/2] test: stabilize auth rate limit coverage Agent-Logs-Url: https://github.com/OlympusLedgerOrg/TiM/sessions/9aecc93e-3ae2-4b8e-83d9-bb84283d81ab Co-authored-by: OlympusLedgerOrg <240737312+OlympusLedgerOrg@users.noreply.github.com> --- backend/tests/auth.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/tests/auth.test.ts b/backend/tests/auth.test.ts index 04605440..15193686 100644 --- a/backend/tests/auth.test.ts +++ b/backend/tests/auth.test.ts @@ -2,6 +2,8 @@ * Auth API Tests — Login for supervisors/managers (bcrypt password hashing) */ +process.env.RATE_LIMIT_AUTH = '100'; + // Mock Prisma before any imports jest.mock('../src/prisma/client', () => { const mockPrisma = { @@ -130,6 +132,7 @@ describe('POST /api/v1/auth/login', () => { }); it('returns a sanitized 500 response for unexpected errors', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); (prisma.user.findUnique as jest.Mock).mockRejectedValueOnce(new Error('database exploded')); const res = await request(app) @@ -138,6 +141,7 @@ describe('POST /api/v1/auth/login', () => { expect(res.status).toBe(500); expect(res.body).toEqual({ message: 'Internal server error' }); + consoleErrorSpy.mockRestore(); }); it('token contains correct claims (sub, role, tenantId, name)', async () => {