Skip to content
Merged
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
4 changes: 2 additions & 2 deletions backend/prisma/src/middleware/auth.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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 {
Expand Down
45 changes: 43 additions & 2 deletions backend/src/app.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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);
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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;
Expand Down
12 changes: 12 additions & 0 deletions backend/src/config/jwt.ts
Original file line number Diff line number Diff line change
@@ -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() {

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getJwtSecret() can still return the insecure default in production if some code path imports it without having called validateJwtSecret() (validation currently only happens in app.ts). Consider running validation inside getJwtSecret() (or at module initialization in config/jwt.ts) so the production guardrail can’t be bypassed by alternative entrypoints.

Suggested change
export function getJwtSecret() {
export function getJwtSecret() {
validateJwtSecret();

Copilot uses AI. Check for mistakes.
return new TextEncoder().encode(process.env.JWT_SECRET || INSECURE_DEFAULT_JWT_SECRET);
Comment on lines +10 to +11

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getJwtSecret() allocates a new Uint8Array on every call (new TextEncoder().encode(...)). Since this runs per-request in requireAuth, consider memoizing the encoded secret (and optionally refreshing it only when JWT_SECRET changes) to avoid repeated allocations.

Copilot uses AI. Check for mistakes.
}
1 change: 1 addition & 0 deletions backend/src/controllers/stepController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions backend/src/middleware/auth.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions backend/src/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
31 changes: 21 additions & 10 deletions backend/src/services/equipmentService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, typeof activeDowntimeEvents[0]>();
for (const event of activeDowntimeEvents) {
if (!downtimeByEquipment.has(event.equipmentId)) {
downtimeByEquipment.set(event.equipmentId, event);
}
Comment on lines +83 to +87

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Map value type typeof activeDowntimeEvents[0] is hard to read and can behave unexpectedly with stricter TS options (e.g., noUncheckedIndexedAccess). Prefer a clearer element type like (typeof activeDowntimeEvents)[number] (or a Prisma DowntimeEvent type) for maintainability.

Copilot uses AI. Check for mistakes.
}

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,
Expand All @@ -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 } };
}
Expand Down
49 changes: 30 additions & 19 deletions backend/src/services/stepService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
15 changes: 15 additions & 0 deletions backend/tests/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -129,6 +131,19 @@ describe('POST /api/v1/auth/login', () => {
expect(res.body.user.role).toBe('Supervisor');
});

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)
.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' });
consoleErrorSpy.mockRestore();
});

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',
Expand Down
14 changes: 14 additions & 0 deletions backend/tests/completeStep.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }));
Expand All @@ -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 () => {
Expand All @@ -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 () => {
Expand Down
18 changes: 13 additions & 5 deletions backend/tests/equipment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 () => {
Expand Down
35 changes: 35 additions & 0 deletions backend/tests/jwtConfig.test.ts
Original file line number Diff line number Diff line change
@@ -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;
Comment on lines +4 to +11

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test reassigns process.env (not just individual keys). Replacing the process.env object can cause subtle cross-test interference in a shared Jest worker; consider saving/restoring only JWT_SECRET/NODE_ENV (or using a helper) instead of overwriting process.env entirely.

Suggested change
const originalEnv = process.env;
beforeEach(() => {
process.env = { ...originalEnv };
});
afterAll(() => {
process.env = originalEnv;
const originalJwtSecret = process.env.JWT_SECRET;
const originalNodeEnv = process.env.NODE_ENV;
beforeEach(() => {
if (originalJwtSecret === undefined) {
delete process.env.JWT_SECRET;
} else {
process.env.JWT_SECRET = originalJwtSecret;
}
if (originalNodeEnv === undefined) {
delete process.env.NODE_ENV;
} else {
process.env.NODE_ENV = originalNodeEnv;
}
});
afterAll(() => {
if (originalJwtSecret === undefined) {
delete process.env.JWT_SECRET;
} else {
process.env.JWT_SECRET = originalJwtSecret;
}
if (originalNodeEnv === undefined) {
delete process.env.NODE_ENV;
} else {
process.env.NODE_ENV = originalNodeEnv;
}

Copilot uses AI. Check for mistakes.
});

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);
});
});
Loading