fix: frontend API mapping, security hardening, test suite

Frontend:
- Fix all API response unwrapping (backend wraps in {data:...})
- Fix workflow publish/archive to use PATCH /status endpoint
- Fix step reorder payload format
- Add tenantId to workflow creation
- Fix step count display using _count

Security (CRITICAL/HIGH):
- Add JWT algorithm restriction (HS256 only)
- Replace weak default JWT secrets
- Fix token blacklist memory leak
- Fix step reorder IDOR vulnerability
- Strengthen password policy (8 chars, mixed case + digit)

Tests:
- 68 tests: auth (17), workflows (26), tenants (25)
- Complete Prisma mock setup with test fixtures
This commit is contained in:
root
2026-03-20 23:15:07 +00:00
parent 4adcb42bb5
commit 093fe85573
18 changed files with 1982 additions and 74 deletions
+303
View File
@@ -0,0 +1,303 @@
import request from 'supertest';
import jwt from 'jsonwebtoken';
import {
seedTestData,
clearTestData,
generateTestToken,
generateTestRefreshToken,
TEST_SYSTEM_ADMIN,
TEST_CUSTOMER_USER_A,
TEST_INACTIVE_USER,
} from './setup';
// The mock must be set up before importing app
import app from '../index';
describe('Auth Routes', () => {
beforeEach(() => {
seedTestData();
jest.clearAllMocks();
});
afterEach(() => {
clearTestData();
});
// ============================================================
// POST /api/auth/login
// ============================================================
describe('POST /api/auth/login', () => {
it('should return 200 and tokens with valid credentials', async () => {
const res = await request(app)
.post('/api/auth/login')
.send({ email: TEST_CUSTOMER_USER_A.email, password: 'password123' });
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('accessToken');
expect(res.body).toHaveProperty('refreshToken');
expect(res.body).toHaveProperty('user');
expect(res.body.user.email).toBe(TEST_CUSTOMER_USER_A.email);
expect(res.body.user.firstName).toBe(TEST_CUSTOMER_USER_A.firstName);
expect(res.body.user.lastName).toBe(TEST_CUSTOMER_USER_A.lastName);
expect(res.body.user.role).toBe(TEST_CUSTOMER_USER_A.role);
expect(res.body.user.tenantId).toBe(TEST_CUSTOMER_USER_A.tenantId);
// Ensure password hash is not leaked
expect(res.body.user).not.toHaveProperty('passwordHash');
});
it('should return 200 with tenant info when user has a tenant', async () => {
const res = await request(app)
.post('/api/auth/login')
.send({ email: TEST_CUSTOMER_USER_A.email, password: 'password123' });
expect(res.status).toBe(200);
expect(res.body.user.tenant).toBeTruthy();
expect(res.body.user.tenant.id).toBe('tenant-a-id');
expect(res.body.user.tenant.name).toBe('Tenant A');
expect(res.body.user.tenant.slug).toBe('tenant-a');
});
it('should return tokens that are valid JWTs', async () => {
const res = await request(app)
.post('/api/auth/login')
.send({ email: TEST_CUSTOMER_USER_A.email, password: 'password123' });
expect(res.status).toBe(200);
const accessDecoded = jwt.verify(res.body.accessToken, process.env.JWT_SECRET!) as any;
expect(accessDecoded.id).toBe(TEST_CUSTOMER_USER_A.id);
expect(accessDecoded.email).toBe(TEST_CUSTOMER_USER_A.email);
expect(accessDecoded.role).toBe(TEST_CUSTOMER_USER_A.role);
const refreshDecoded = jwt.verify(res.body.refreshToken, process.env.JWT_REFRESH_SECRET!) as any;
expect(refreshDecoded.id).toBe(TEST_CUSTOMER_USER_A.id);
expect(refreshDecoded.type).toBe('refresh');
});
it('should return 401 with invalid password', async () => {
const res = await request(app)
.post('/api/auth/login')
.send({ email: TEST_CUSTOMER_USER_A.email, password: 'wrong-password' });
expect(res.status).toBe(401);
expect(res.body).toHaveProperty('error');
expect(res.body.error).toBe('Invalid credentials');
expect(res.body).not.toHaveProperty('accessToken');
expect(res.body).not.toHaveProperty('refreshToken');
});
it('should return 401 with non-existent email', async () => {
const res = await request(app)
.post('/api/auth/login')
.send({ email: 'nobody@nowhere.test', password: 'password123' });
expect(res.status).toBe(401);
expect(res.body.error).toBe('Invalid credentials');
});
it('should return 401 for inactive user', async () => {
const res = await request(app)
.post('/api/auth/login')
.send({ email: TEST_INACTIVE_USER.email, password: 'password123' });
expect(res.status).toBe(401);
expect(res.body.error).toBe('Invalid credentials');
});
it('should return 400 with missing email', async () => {
const res = await request(app)
.post('/api/auth/login')
.send({ password: 'password123' });
expect(res.status).toBe(400);
expect(res.body).toHaveProperty('error');
});
it('should return 400 with invalid email format', async () => {
const res = await request(app)
.post('/api/auth/login')
.send({ email: 'not-an-email', password: 'password123' });
expect(res.status).toBe(400);
expect(res.body).toHaveProperty('error');
});
it('should return 400 with missing password', async () => {
const res = await request(app)
.post('/api/auth/login')
.send({ email: TEST_CUSTOMER_USER_A.email });
expect(res.status).toBe(400);
expect(res.body).toHaveProperty('error');
});
});
// ============================================================
// GET /api/auth/me
// ============================================================
describe('GET /api/auth/me', () => {
it('should return 200 and user data with valid token', async () => {
const token = generateTestToken(TEST_CUSTOMER_USER_A);
const res = await request(app)
.get('/api/auth/me')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.id).toBe(TEST_CUSTOMER_USER_A.id);
expect(res.body.email).toBe(TEST_CUSTOMER_USER_A.email);
expect(res.body.firstName).toBe(TEST_CUSTOMER_USER_A.firstName);
expect(res.body.lastName).toBe(TEST_CUSTOMER_USER_A.lastName);
expect(res.body.role).toBe(TEST_CUSTOMER_USER_A.role);
expect(res.body.tenantId).toBe(TEST_CUSTOMER_USER_A.tenantId);
expect(res.body).not.toHaveProperty('passwordHash');
});
it('should include tenant details in response', async () => {
const token = generateTestToken(TEST_CUSTOMER_USER_A);
const res = await request(app)
.get('/api/auth/me')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.tenant).toBeTruthy();
expect(res.body.tenant.id).toBe('tenant-a-id');
expect(res.body.tenant.name).toBe('Tenant A');
});
it('should return 401 without token', async () => {
const res = await request(app).get('/api/auth/me');
expect(res.status).toBe(401);
expect(res.body).toHaveProperty('error');
});
it('should return 401 with invalid token', async () => {
const res = await request(app)
.get('/api/auth/me')
.set('Authorization', 'Bearer invalid-token-string');
expect(res.status).toBe(401);
expect(res.body).toHaveProperty('error');
});
it('should return 401 with expired token', async () => {
const expiredToken = jwt.sign(
{
id: TEST_CUSTOMER_USER_A.id,
email: TEST_CUSTOMER_USER_A.email,
role: TEST_CUSTOMER_USER_A.role,
tenantId: TEST_CUSTOMER_USER_A.tenantId,
firstName: TEST_CUSTOMER_USER_A.firstName,
lastName: TEST_CUSTOMER_USER_A.lastName,
},
process.env.JWT_SECRET!,
{ expiresIn: '0s' }
);
// Small delay to ensure token is expired
await new Promise((resolve) => setTimeout(resolve, 10));
const res = await request(app)
.get('/api/auth/me')
.set('Authorization', `Bearer ${expiredToken}`);
expect(res.status).toBe(401);
});
it('should return 401 with malformed Authorization header', async () => {
const res = await request(app)
.get('/api/auth/me')
.set('Authorization', 'NotBearer some-token');
expect(res.status).toBe(401);
});
it('should work for SYSTEM_ADMIN with null tenant', async () => {
const token = generateTestToken(TEST_SYSTEM_ADMIN);
const res = await request(app)
.get('/api/auth/me')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.role).toBe('SYSTEM_ADMIN');
expect(res.body.tenant).toBeNull();
});
});
// ============================================================
// POST /api/auth/refresh
// ============================================================
describe('POST /api/auth/refresh', () => {
it('should return new tokens with valid refresh token', async () => {
const refreshToken = generateTestRefreshToken(TEST_CUSTOMER_USER_A.id);
const res = await request(app)
.post('/api/auth/refresh')
.send({ refreshToken });
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('accessToken');
expect(res.body).toHaveProperty('refreshToken');
// New refresh token should be different from the old one
expect(res.body.refreshToken).not.toBe(refreshToken);
});
it('should invalidate the old refresh token after use', async () => {
const refreshToken = generateTestRefreshToken(TEST_CUSTOMER_USER_A.id);
// First use - should succeed
const res1 = await request(app)
.post('/api/auth/refresh')
.send({ refreshToken });
expect(res1.status).toBe(200);
// Second use of the same token - should fail (token invalidated)
const res2 = await request(app)
.post('/api/auth/refresh')
.send({ refreshToken });
expect(res2.status).toBe(401);
});
it('should return 401 with invalid refresh token', async () => {
const res = await request(app)
.post('/api/auth/refresh')
.send({ refreshToken: 'not-a-valid-jwt' });
expect(res.status).toBe(401);
expect(res.body).toHaveProperty('error');
});
it('should return 401 when using an access token as refresh token', async () => {
const accessToken = generateTestToken(TEST_CUSTOMER_USER_A);
const res = await request(app)
.post('/api/auth/refresh')
.send({ refreshToken: accessToken });
// The access token is signed with a different secret, so verification fails
expect(res.status).toBe(401);
});
it('should return 400 when refreshToken is missing', async () => {
const res = await request(app)
.post('/api/auth/refresh')
.send({});
expect(res.status).toBe(400);
expect(res.body).toHaveProperty('error');
});
it('should return 401 for inactive user refresh', async () => {
const refreshToken = generateTestRefreshToken(TEST_INACTIVE_USER.id);
const res = await request(app)
.post('/api/auth/refresh')
.send({ refreshToken });
expect(res.status).toBe(401);
});
});
});
+509
View File
@@ -0,0 +1,509 @@
import jwt from 'jsonwebtoken';
import bcrypt from 'bcryptjs';
// Test environment variables - set before any imports that use them
process.env.NODE_ENV = 'test';
process.env.JWT_SECRET = 'test-jwt-secret-key';
process.env.JWT_REFRESH_SECRET = 'test-jwt-refresh-secret-key';
process.env.PORT = '0'; // Random port for tests
process.env.CORS_ORIGIN = 'http://localhost:3000';
// ============================================================
// Prisma Mock
// ============================================================
// In-memory data stores for the mock
const mockTenants: any[] = [];
const mockUsers: any[] = [];
const mockWorkflows: any[] = [];
const mockWorkflowSteps: any[] = [];
const mockCategories: any[] = [];
// Helper to find by unique constraints
function findInArray(arr: any[], where: any): any {
return arr.find((item) => {
return Object.entries(where).every(([key, value]) => item[key] === value);
});
}
function filterArray(arr: any[], where: any): any[] {
if (!where || Object.keys(where).length === 0) return [...arr];
return arr.filter((item) => {
return Object.entries(where).every(([key, value]: [string, any]) => {
if (key === 'OR') {
return (value as any[]).some((condition: any) =>
Object.entries(condition).every(([k, v]: [string, any]) => {
if (typeof v === 'object' && v.contains) {
return item[k]?.toLowerCase().includes(v.contains.toLowerCase());
}
return item[k] === v;
})
);
}
if (key === 'tags' && typeof value === 'object' && value.hasSome) {
return value.hasSome.some((tag: string) => item.tags?.includes(tag));
}
return item[key] === value;
});
});
}
function addIncludes(item: any, include: any): any {
if (!include || !item) return item;
const result = { ...item };
if (include.tenant) {
result.tenant = mockTenants.find((t) => t.id === item.tenantId) || null;
if (result.tenant && typeof include.tenant === 'object' && include.tenant.select) {
const selected: any = {};
for (const key of Object.keys(include.tenant.select)) {
selected[key] = result.tenant[key];
}
result.tenant = selected;
}
}
if (include.createdBy) {
const user = mockUsers.find((u) => u.id === item.createdById);
if (user && typeof include.createdBy === 'object' && include.createdBy.select) {
const selected: any = {};
for (const key of Object.keys(include.createdBy.select)) {
selected[key] = user[key];
}
result.createdBy = selected;
} else {
result.createdBy = user || null;
}
}
if (include.updatedBy) {
const user = mockUsers.find((u) => u.id === item.updatedById);
if (user && typeof include.updatedBy === 'object' && include.updatedBy.select) {
const selected: any = {};
for (const key of Object.keys(include.updatedBy.select)) {
selected[key] = user[key];
}
result.updatedBy = selected;
} else {
result.updatedBy = user || null;
}
}
if (include.category) {
const cat = mockCategories.find((c) => c.id === item.categoryId);
if (cat && typeof include.category === 'object' && include.category.select) {
const selected: any = {};
for (const key of Object.keys(include.category.select)) {
selected[key] = cat[key];
}
result.category = selected;
} else {
result.category = cat || null;
}
}
if (include.steps) {
result.steps = mockWorkflowSteps
.filter((s) => s.workflowId === item.id)
.sort((a, b) => a.order - b.order);
}
if (include._count) {
result._count = {};
if (include._count.select?.users) {
result._count.users = mockUsers.filter((u) => u.tenantId === item.id).length;
}
if (include._count.select?.workflows) {
result._count.workflows = mockWorkflows.filter((w) => w.tenantId === item.id).length;
}
if (include._count.select?.steps) {
result._count.steps = mockWorkflowSteps.filter((s) => s.workflowId === item.id).length;
}
}
return result;
}
// Build mock Prisma client
const mockPrismaClient = {
user: {
findUnique: jest.fn(async ({ where, include }: any) => {
const user = findInArray(mockUsers, where);
return user ? addIncludes(user, include) : null;
}),
findMany: jest.fn(async ({ where, include }: any = {}) => {
return filterArray(mockUsers, where).map((u) => addIncludes(u, include));
}),
create: jest.fn(async ({ data, include }: any) => {
const user = { ...data, createdAt: new Date(), updatedAt: new Date() };
mockUsers.push(user);
return addIncludes(user, include);
}),
update: jest.fn(async ({ where, data, include }: any) => {
const idx = mockUsers.findIndex((u) => u.id === where.id);
if (idx === -1) throw { code: 'P2025' };
mockUsers[idx] = { ...mockUsers[idx], ...data, updatedAt: new Date() };
return addIncludes(mockUsers[idx], include);
}),
count: jest.fn(async ({ where }: any = {}) => filterArray(mockUsers, where).length),
},
tenant: {
findUnique: jest.fn(async ({ where, include }: any) => {
const tenant = findInArray(mockTenants, where);
return tenant ? addIncludes(tenant, include) : null;
}),
findMany: jest.fn(async ({ where, include, orderBy }: any = {}) => {
return filterArray(mockTenants, where).map((t) => addIncludes(t, include));
}),
create: jest.fn(async ({ data }: any) => {
const tenant = { ...data, createdAt: new Date(), updatedAt: new Date() };
mockTenants.push(tenant);
return tenant;
}),
update: jest.fn(async ({ where, data, include }: any) => {
const idx = mockTenants.findIndex((t) => t.id === where.id);
if (idx === -1) throw { code: 'P2025' };
mockTenants[idx] = { ...mockTenants[idx], ...data, updatedAt: new Date() };
return addIncludes(mockTenants[idx], include);
}),
delete: jest.fn(async ({ where }: any) => {
const idx = mockTenants.findIndex((t) => t.id === where.id);
if (idx === -1) throw { code: 'P2025' };
return mockTenants.splice(idx, 1)[0];
}),
count: jest.fn(async ({ where }: any = {}) => filterArray(mockTenants, where).length),
},
workflow: {
findUnique: jest.fn(async ({ where, include }: any) => {
const wf = findInArray(mockWorkflows, where);
return wf ? addIncludes(wf, include) : null;
}),
findMany: jest.fn(async ({ where, include, orderBy, skip, take }: any = {}) => {
let results = filterArray(mockWorkflows, where);
if (skip) results = results.slice(skip);
if (take) results = results.slice(0, take);
return results.map((w) => addIncludes(w, include));
}),
create: jest.fn(async ({ data, include }: any) => {
const { steps: stepsData, ...workflowData } = data;
const workflow = {
...workflowData,
id: workflowData.id || `wf-${Date.now()}-${Math.random().toString(36).slice(2)}`,
status: workflowData.status || 'DRAFT',
version: workflowData.version || 1,
isTemplate: workflowData.isTemplate || false,
tags: workflowData.tags || [],
createdAt: new Date(),
updatedAt: new Date(),
};
mockWorkflows.push(workflow);
if (stepsData?.create) {
const stepsToCreate = Array.isArray(stepsData.create) ? stepsData.create : [stepsData.create];
for (const stepData of stepsToCreate) {
const step = {
...stepData,
id: `step-${Date.now()}-${Math.random().toString(36).slice(2)}`,
workflowId: workflow.id,
createdAt: new Date(),
updatedAt: new Date(),
};
mockWorkflowSteps.push(step);
}
}
return addIncludes(workflow, include);
}),
update: jest.fn(async ({ where, data, include }: any) => {
const idx = mockWorkflows.findIndex((w) => w.id === where.id);
if (idx === -1) throw { code: 'P2025' };
const updateData = { ...data };
if (updateData.version?.increment) {
updateData.version = mockWorkflows[idx].version + updateData.version.increment;
}
mockWorkflows[idx] = { ...mockWorkflows[idx], ...updateData, updatedAt: new Date() };
return addIncludes(mockWorkflows[idx], include);
}),
delete: jest.fn(async ({ where }: any) => {
const idx = mockWorkflows.findIndex((w) => w.id === where.id);
if (idx === -1) throw { code: 'P2025' };
// Also delete associated steps
const wfId = mockWorkflows[idx].id;
for (let i = mockWorkflowSteps.length - 1; i >= 0; i--) {
if (mockWorkflowSteps[i].workflowId === wfId) {
mockWorkflowSteps.splice(i, 1);
}
}
return mockWorkflows.splice(idx, 1)[0];
}),
count: jest.fn(async ({ where }: any = {}) => filterArray(mockWorkflows, where).length),
},
workflowStep: {
findFirst: jest.fn(async ({ where }: any) => {
return mockWorkflowSteps.find((s) =>
Object.entries(where).every(([k, v]) => s[k] === v)
) || null;
}),
findMany: jest.fn(async ({ where, orderBy }: any = {}) => {
let results = filterArray(mockWorkflowSteps, where);
if (orderBy?.order === 'asc') results.sort((a, b) => a.order - b.order);
return results;
}),
create: jest.fn(async ({ data }: any) => {
const step = {
...data,
id: `step-${Date.now()}-${Math.random().toString(36).slice(2)}`,
createdAt: new Date(),
updatedAt: new Date(),
};
mockWorkflowSteps.push(step);
return step;
}),
update: jest.fn(async ({ where, data }: any) => {
const idx = mockWorkflowSteps.findIndex((s) => s.id === where.id);
if (idx === -1) throw { code: 'P2025' };
mockWorkflowSteps[idx] = { ...mockWorkflowSteps[idx], ...data, updatedAt: new Date() };
return mockWorkflowSteps[idx];
}),
delete: jest.fn(async ({ where }: any) => {
const idx = mockWorkflowSteps.findIndex((s) => s.id === where.id);
if (idx === -1) throw { code: 'P2025' };
return mockWorkflowSteps.splice(idx, 1)[0];
}),
},
category: {
findUnique: jest.fn(async ({ where }: any) => findInArray(mockCategories, where)),
findMany: jest.fn(async ({ where }: any = {}) => filterArray(mockCategories, where)),
create: jest.fn(async ({ data }: any) => {
const cat = { ...data, id: data.id || `cat-${Date.now()}`, createdAt: new Date() };
mockCategories.push(cat);
return cat;
}),
},
$transaction: jest.fn(async (operations: any) => {
if (Array.isArray(operations)) {
return Promise.all(operations);
}
return operations(mockPrismaClient);
}),
$disconnect: jest.fn(),
};
// Mock the prisma module
jest.mock('../lib/prisma', () => ({
__esModule: true,
default: mockPrismaClient,
}));
// ============================================================
// Test Data Helpers
// ============================================================
export const TEST_TENANT_A = {
id: 'tenant-a-id',
name: 'Tenant A',
slug: 'tenant-a',
logo: null,
active: true,
createdAt: new Date('2025-01-01'),
updatedAt: new Date('2025-01-01'),
};
export const TEST_TENANT_B = {
id: 'tenant-b-id',
name: 'Tenant B',
slug: 'tenant-b',
logo: null,
active: true,
createdAt: new Date('2025-01-01'),
updatedAt: new Date('2025-01-01'),
};
const passwordHash = bcrypt.hashSync('password123', 10);
export const TEST_SYSTEM_ADMIN = {
id: 'system-admin-id',
email: 'admin@system.test',
passwordHash,
firstName: 'System',
lastName: 'Admin',
role: 'SYSTEM_ADMIN' as const,
tenantId: null,
active: true,
createdAt: new Date('2025-01-01'),
updatedAt: new Date('2025-01-01'),
};
export const TEST_TECHNICIAN = {
id: 'technician-id',
email: 'tech@system.test',
passwordHash,
firstName: 'Tech',
lastName: 'Nician',
role: 'TECHNICIAN' as const,
tenantId: null,
active: true,
createdAt: new Date('2025-01-01'),
updatedAt: new Date('2025-01-01'),
};
export const TEST_CUSTOMER_ADMIN_A = {
id: 'customer-admin-a-id',
email: 'admin@tenanta.test',
passwordHash,
firstName: 'Customer',
lastName: 'AdminA',
role: 'CUSTOMER_ADMIN' as const,
tenantId: 'tenant-a-id',
active: true,
createdAt: new Date('2025-01-01'),
updatedAt: new Date('2025-01-01'),
};
export const TEST_CUSTOMER_USER_A = {
id: 'customer-user-a-id',
email: 'user@tenanta.test',
passwordHash,
firstName: 'Customer',
lastName: 'UserA',
role: 'CUSTOMER_USER' as const,
tenantId: 'tenant-a-id',
active: true,
createdAt: new Date('2025-01-01'),
updatedAt: new Date('2025-01-01'),
};
export const TEST_CUSTOMER_USER_B = {
id: 'customer-user-b-id',
email: 'user@tenantb.test',
passwordHash,
firstName: 'Customer',
lastName: 'UserB',
role: 'CUSTOMER_USER' as const,
tenantId: 'tenant-b-id',
active: true,
createdAt: new Date('2025-01-01'),
updatedAt: new Date('2025-01-01'),
};
export const TEST_INACTIVE_USER = {
id: 'inactive-user-id',
email: 'inactive@test.com',
passwordHash,
firstName: 'Inactive',
lastName: 'User',
role: 'CUSTOMER_USER' as const,
tenantId: 'tenant-a-id',
active: false,
createdAt: new Date('2025-01-01'),
updatedAt: new Date('2025-01-01'),
};
/**
* Generate a JWT access token for testing.
*/
export function generateTestToken(user: {
id: string;
email: string;
role: string;
tenantId: string | null;
firstName: string;
lastName: string;
}): string {
return jwt.sign(
{
id: user.id,
email: user.email,
role: user.role,
tenantId: user.tenantId,
firstName: user.firstName,
lastName: user.lastName,
},
process.env.JWT_SECRET!,
{ expiresIn: '15m' }
);
}
/**
* Generate a JWT refresh token for testing.
*/
export function generateTestRefreshToken(userId: string): string {
return jwt.sign(
{ id: userId, type: 'refresh' },
process.env.JWT_REFRESH_SECRET!,
{ expiresIn: '7d' }
);
}
/**
* Seed the mock data stores with test data.
* Call this in beforeEach to have a clean state.
*/
export function seedTestData(): void {
// Clear all arrays
mockTenants.length = 0;
mockUsers.length = 0;
mockWorkflows.length = 0;
mockWorkflowSteps.length = 0;
mockCategories.length = 0;
// Seed tenants
mockTenants.push({ ...TEST_TENANT_A }, { ...TEST_TENANT_B });
// Seed users
mockUsers.push(
{ ...TEST_SYSTEM_ADMIN },
{ ...TEST_TECHNICIAN },
{ ...TEST_CUSTOMER_ADMIN_A },
{ ...TEST_CUSTOMER_USER_A },
{ ...TEST_CUSTOMER_USER_B },
{ ...TEST_INACTIVE_USER }
);
// Seed a workflow for tenant A
mockWorkflows.push({
id: 'workflow-a1-id',
title: 'Workflow A1',
description: 'Test workflow for tenant A',
tenantId: 'tenant-a-id',
categoryId: null,
status: 'DRAFT',
version: 1,
isTemplate: false,
tags: ['test'],
createdById: 'customer-admin-a-id',
updatedById: null,
createdAt: new Date('2025-01-01'),
updatedAt: new Date('2025-01-01'),
});
// Seed a workflow for tenant B
mockWorkflows.push({
id: 'workflow-b1-id',
title: 'Workflow B1',
description: 'Test workflow for tenant B',
tenantId: 'tenant-b-id',
categoryId: null,
status: 'PUBLISHED',
version: 1,
isTemplate: false,
tags: ['production'],
createdById: 'customer-user-b-id',
updatedById: null,
createdAt: new Date('2025-01-02'),
updatedAt: new Date('2025-01-02'),
});
}
/**
* Clear all mock data stores.
*/
export function clearTestData(): void {
mockTenants.length = 0;
mockUsers.length = 0;
mockWorkflows.length = 0;
mockWorkflowSteps.length = 0;
mockCategories.length = 0;
}
export { mockPrismaClient };
+399
View File
@@ -0,0 +1,399 @@
import request from 'supertest';
import {
seedTestData,
clearTestData,
generateTestToken,
TEST_SYSTEM_ADMIN,
TEST_TECHNICIAN,
TEST_CUSTOMER_ADMIN_A,
TEST_CUSTOMER_USER_A,
TEST_CUSTOMER_USER_B,
TEST_TENANT_A,
TEST_TENANT_B,
} from './setup';
import app from '../index';
describe('Tenant Routes', () => {
let systemAdminToken: string;
let technicianToken: string;
let customerAdminAToken: string;
let customerUserAToken: string;
let customerUserBToken: string;
beforeEach(() => {
seedTestData();
jest.clearAllMocks();
systemAdminToken = generateTestToken(TEST_SYSTEM_ADMIN);
technicianToken = generateTestToken(TEST_TECHNICIAN);
customerAdminAToken = generateTestToken(TEST_CUSTOMER_ADMIN_A);
customerUserAToken = generateTestToken(TEST_CUSTOMER_USER_A);
customerUserBToken = generateTestToken(TEST_CUSTOMER_USER_B);
});
afterEach(() => {
clearTestData();
});
// ============================================================
// POST /api/tenants (Create)
// ============================================================
describe('POST /api/tenants', () => {
const newTenant = {
name: 'New Tenant',
slug: 'new-tenant',
};
it('should allow SYSTEM_ADMIN to create a tenant', async () => {
const res = await request(app)
.post('/api/tenants')
.set('Authorization', `Bearer ${systemAdminToken}`)
.send(newTenant);
expect(res.status).toBe(201);
expect(res.body).toHaveProperty('data');
expect(res.body.data.name).toBe(newTenant.name);
expect(res.body.data.slug).toBe(newTenant.slug);
});
it('should reject TECHNICIAN from creating a tenant', async () => {
const res = await request(app)
.post('/api/tenants')
.set('Authorization', `Bearer ${technicianToken}`)
.send(newTenant);
expect(res.status).toBe(403);
});
it('should reject CUSTOMER_ADMIN from creating a tenant', async () => {
const res = await request(app)
.post('/api/tenants')
.set('Authorization', `Bearer ${customerAdminAToken}`)
.send(newTenant);
expect(res.status).toBe(403);
});
it('should reject CUSTOMER_USER from creating a tenant', async () => {
const res = await request(app)
.post('/api/tenants')
.set('Authorization', `Bearer ${customerUserAToken}`)
.send(newTenant);
expect(res.status).toBe(403);
});
it('should reject unauthenticated tenant creation', async () => {
const res = await request(app)
.post('/api/tenants')
.send(newTenant);
expect(res.status).toBe(401);
});
it('should return 400 with missing name', async () => {
const res = await request(app)
.post('/api/tenants')
.set('Authorization', `Bearer ${systemAdminToken}`)
.send({ slug: 'no-name' });
expect(res.status).toBe(400);
expect(res.body).toHaveProperty('error');
});
it('should return 400 with missing slug', async () => {
const res = await request(app)
.post('/api/tenants')
.set('Authorization', `Bearer ${systemAdminToken}`)
.send({ name: 'No Slug' });
expect(res.status).toBe(400);
});
it('should return 400 with invalid slug format', async () => {
const res = await request(app)
.post('/api/tenants')
.set('Authorization', `Bearer ${systemAdminToken}`)
.send({ name: 'Bad Slug', slug: 'BAD SLUG!' });
expect(res.status).toBe(400);
});
it('should return 409 when slug already exists', async () => {
const res = await request(app)
.post('/api/tenants')
.set('Authorization', `Bearer ${systemAdminToken}`)
.send({ name: 'Duplicate', slug: 'tenant-a' });
expect(res.status).toBe(409);
expect(res.body.error).toBe('Conflict');
});
});
// ============================================================
// GET /api/tenants (List)
// ============================================================
describe('GET /api/tenants', () => {
it('should return all tenants for SYSTEM_ADMIN', async () => {
const res = await request(app)
.get('/api/tenants')
.set('Authorization', `Bearer ${systemAdminToken}`);
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('data');
expect(Array.isArray(res.body.data)).toBe(true);
expect(res.body.data.length).toBe(2);
});
it('should return all tenants for TECHNICIAN', async () => {
const res = await request(app)
.get('/api/tenants')
.set('Authorization', `Bearer ${technicianToken}`);
expect(res.status).toBe(200);
expect(res.body.data.length).toBe(2);
});
it('should return only own tenant for CUSTOMER_USER', async () => {
const res = await request(app)
.get('/api/tenants')
.set('Authorization', `Bearer ${customerUserAToken}`);
expect(res.status).toBe(200);
expect(res.body.data.length).toBe(1);
expect(res.body.data[0].id).toBe(TEST_TENANT_A.id);
});
it('should return only own tenant for CUSTOMER_ADMIN', async () => {
const res = await request(app)
.get('/api/tenants')
.set('Authorization', `Bearer ${customerAdminAToken}`);
expect(res.status).toBe(200);
expect(res.body.data.length).toBe(1);
expect(res.body.data[0].id).toBe(TEST_TENANT_A.id);
});
it('tenant B user should only see tenant B', async () => {
const res = await request(app)
.get('/api/tenants')
.set('Authorization', `Bearer ${customerUserBToken}`);
expect(res.status).toBe(200);
expect(res.body.data.length).toBe(1);
expect(res.body.data[0].id).toBe(TEST_TENANT_B.id);
});
it('should return 401 without authentication', async () => {
const res = await request(app).get('/api/tenants');
expect(res.status).toBe(401);
});
it('should include user and workflow counts', async () => {
const res = await request(app)
.get('/api/tenants')
.set('Authorization', `Bearer ${systemAdminToken}`);
expect(res.status).toBe(200);
for (const tenant of res.body.data) {
expect(tenant).toHaveProperty('_count');
expect(tenant._count).toHaveProperty('users');
expect(tenant._count).toHaveProperty('workflows');
}
});
});
// ============================================================
// GET /api/tenants/:id (Get Single)
// ============================================================
describe('GET /api/tenants/:id', () => {
it('should return tenant details for SYSTEM_ADMIN', async () => {
const res = await request(app)
.get(`/api/tenants/${TEST_TENANT_A.id}`)
.set('Authorization', `Bearer ${systemAdminToken}`);
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('data');
expect(res.body.data.id).toBe(TEST_TENANT_A.id);
expect(res.body.data.name).toBe(TEST_TENANT_A.name);
expect(res.body.data.slug).toBe(TEST_TENANT_A.slug);
});
it('should allow TECHNICIAN to view any tenant', async () => {
const res = await request(app)
.get(`/api/tenants/${TEST_TENANT_B.id}`)
.set('Authorization', `Bearer ${technicianToken}`);
expect(res.status).toBe(200);
expect(res.body.data.id).toBe(TEST_TENANT_B.id);
});
it('should allow CUSTOMER_USER to view own tenant', async () => {
const res = await request(app)
.get(`/api/tenants/${TEST_TENANT_A.id}`)
.set('Authorization', `Bearer ${customerUserAToken}`);
expect(res.status).toBe(200);
expect(res.body.data.id).toBe(TEST_TENANT_A.id);
});
it('should deny CUSTOMER_USER access to other tenant', async () => {
const res = await request(app)
.get(`/api/tenants/${TEST_TENANT_B.id}`)
.set('Authorization', `Bearer ${customerUserAToken}`);
expect(res.status).toBe(403);
});
it('should deny CUSTOMER_ADMIN access to other tenant', async () => {
const res = await request(app)
.get(`/api/tenants/${TEST_TENANT_B.id}`)
.set('Authorization', `Bearer ${customerAdminAToken}`);
expect(res.status).toBe(403);
});
it('should return 404 for non-existent tenant', async () => {
const res = await request(app)
.get('/api/tenants/non-existent-id')
.set('Authorization', `Bearer ${systemAdminToken}`);
expect(res.status).toBe(404);
});
it('should return 401 without authentication', async () => {
const res = await request(app).get(`/api/tenants/${TEST_TENANT_A.id}`);
expect(res.status).toBe(401);
});
});
// ============================================================
// PUT /api/tenants/:id (Update)
// ============================================================
describe('PUT /api/tenants/:id', () => {
it('should allow SYSTEM_ADMIN to update a tenant', async () => {
const res = await request(app)
.put(`/api/tenants/${TEST_TENANT_A.id}`)
.set('Authorization', `Bearer ${systemAdminToken}`)
.send({ name: 'Updated Tenant A' });
expect(res.status).toBe(200);
expect(res.body.data.name).toBe('Updated Tenant A');
});
it('should reject TECHNICIAN from updating a tenant', async () => {
const res = await request(app)
.put(`/api/tenants/${TEST_TENANT_A.id}`)
.set('Authorization', `Bearer ${technicianToken}`)
.send({ name: 'Should Fail' });
expect(res.status).toBe(403);
});
it('should reject CUSTOMER_ADMIN from updating a tenant', async () => {
const res = await request(app)
.put(`/api/tenants/${TEST_TENANT_A.id}`)
.set('Authorization', `Bearer ${customerAdminAToken}`)
.send({ name: 'Should Fail' });
expect(res.status).toBe(403);
});
it('should reject CUSTOMER_USER from updating a tenant', async () => {
const res = await request(app)
.put(`/api/tenants/${TEST_TENANT_A.id}`)
.set('Authorization', `Bearer ${customerUserAToken}`)
.send({ name: 'Should Fail' });
expect(res.status).toBe(403);
});
it('should return 404 for non-existent tenant', async () => {
const res = await request(app)
.put('/api/tenants/non-existent-id')
.set('Authorization', `Bearer ${systemAdminToken}`)
.send({ name: 'Ghost' });
expect(res.status).toBe(404);
});
});
// ============================================================
// DELETE /api/tenants/:id (Deactivate)
// ============================================================
describe('DELETE /api/tenants/:id', () => {
it('should allow SYSTEM_ADMIN to deactivate a tenant', async () => {
const res = await request(app)
.delete(`/api/tenants/${TEST_TENANT_A.id}`)
.set('Authorization', `Bearer ${systemAdminToken}`);
expect(res.status).toBe(200);
expect(res.body.message).toBe('Tenant deactivated successfully');
});
it('should reject TECHNICIAN from deleting a tenant', async () => {
const res = await request(app)
.delete(`/api/tenants/${TEST_TENANT_A.id}`)
.set('Authorization', `Bearer ${technicianToken}`);
expect(res.status).toBe(403);
});
it('should reject CUSTOMER_ADMIN from deleting a tenant', async () => {
const res = await request(app)
.delete(`/api/tenants/${TEST_TENANT_A.id}`)
.set('Authorization', `Bearer ${customerAdminAToken}`);
expect(res.status).toBe(403);
});
it('should reject CUSTOMER_USER from deleting a tenant', async () => {
const res = await request(app)
.delete(`/api/tenants/${TEST_TENANT_A.id}`)
.set('Authorization', `Bearer ${customerUserAToken}`);
expect(res.status).toBe(403);
});
});
// ============================================================
// Role-based Access Summary
// ============================================================
describe('Role-based access control', () => {
it('only SYSTEM_ADMIN can perform write operations on tenants', async () => {
const createPayload = { name: 'Role Test', slug: 'role-test' };
const updatePayload = { name: 'Updated' };
// SYSTEM_ADMIN - create should work
const createRes = await request(app)
.post('/api/tenants')
.set('Authorization', `Bearer ${systemAdminToken}`)
.send(createPayload);
expect(createRes.status).toBe(201);
// TECHNICIAN - create should be forbidden
const techCreateRes = await request(app)
.post('/api/tenants')
.set('Authorization', `Bearer ${technicianToken}`)
.send({ name: 'Tech Try', slug: 'tech-try' });
expect(techCreateRes.status).toBe(403);
// CUSTOMER_ADMIN - create should be forbidden
const adminCreateRes = await request(app)
.post('/api/tenants')
.set('Authorization', `Bearer ${customerAdminAToken}`)
.send({ name: 'Admin Try', slug: 'admin-try' });
expect(adminCreateRes.status).toBe(403);
// CUSTOMER_USER - create should be forbidden
const userCreateRes = await request(app)
.post('/api/tenants')
.set('Authorization', `Bearer ${customerUserAToken}`)
.send({ name: 'User Try', slug: 'user-try' });
expect(userCreateRes.status).toBe(403);
});
});
});
+410
View File
@@ -0,0 +1,410 @@
import request from 'supertest';
import {
seedTestData,
clearTestData,
generateTestToken,
TEST_SYSTEM_ADMIN,
TEST_TECHNICIAN,
TEST_CUSTOMER_ADMIN_A,
TEST_CUSTOMER_USER_A,
TEST_CUSTOMER_USER_B,
TEST_TENANT_A,
TEST_TENANT_B,
} from './setup';
import app from '../index';
describe('Workflow Routes', () => {
let systemAdminToken: string;
let technicianToken: string;
let customerAdminAToken: string;
let customerUserAToken: string;
let customerUserBToken: string;
beforeEach(() => {
seedTestData();
jest.clearAllMocks();
systemAdminToken = generateTestToken(TEST_SYSTEM_ADMIN);
technicianToken = generateTestToken(TEST_TECHNICIAN);
customerAdminAToken = generateTestToken(TEST_CUSTOMER_ADMIN_A);
customerUserAToken = generateTestToken(TEST_CUSTOMER_USER_A);
customerUserBToken = generateTestToken(TEST_CUSTOMER_USER_B);
});
afterEach(() => {
clearTestData();
});
// ============================================================
// GET /api/workflows (List)
// ============================================================
describe('GET /api/workflows', () => {
it('should return 401 without authentication', async () => {
const res = await request(app).get('/api/workflows');
expect(res.status).toBe(401);
expect(res.body).toHaveProperty('error');
});
it('should return 200 and workflow list with valid auth', async () => {
const res = await request(app)
.get('/api/workflows')
.set('Authorization', `Bearer ${systemAdminToken}`);
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('data');
expect(res.body).toHaveProperty('pagination');
expect(Array.isArray(res.body.data)).toBe(true);
expect(res.body.pagination).toHaveProperty('page');
expect(res.body.pagination).toHaveProperty('limit');
expect(res.body.pagination).toHaveProperty('total');
expect(res.body.pagination).toHaveProperty('totalPages');
});
it('should return all workflows for SYSTEM_ADMIN', async () => {
const res = await request(app)
.get('/api/workflows')
.set('Authorization', `Bearer ${systemAdminToken}`);
expect(res.status).toBe(200);
// SYSTEM_ADMIN sees all workflows (both tenant A and B)
expect(res.body.data.length).toBe(2);
});
it('should return all workflows for TECHNICIAN', async () => {
const res = await request(app)
.get('/api/workflows')
.set('Authorization', `Bearer ${technicianToken}`);
expect(res.status).toBe(200);
expect(res.body.data.length).toBe(2);
});
it('should only return own tenant workflows for CUSTOMER_USER', async () => {
const res = await request(app)
.get('/api/workflows')
.set('Authorization', `Bearer ${customerUserAToken}`);
expect(res.status).toBe(200);
// CUSTOMER_USER_A should only see tenant A workflows
expect(res.body.data.length).toBe(1);
expect(res.body.data[0].tenantId).toBe(TEST_TENANT_A.id);
});
it('should only return tenant B workflows for tenant B user', async () => {
const res = await request(app)
.get('/api/workflows')
.set('Authorization', `Bearer ${customerUserBToken}`);
expect(res.status).toBe(200);
expect(res.body.data.length).toBe(1);
expect(res.body.data[0].tenantId).toBe(TEST_TENANT_B.id);
});
});
// ============================================================
// POST /api/workflows (Create)
// ============================================================
describe('POST /api/workflows', () => {
const validWorkflow = {
title: 'New Test Workflow',
description: 'A test workflow description',
tenantId: 'tenant-a-id',
tags: ['new', 'test'],
};
it('should create a workflow as SYSTEM_ADMIN', async () => {
const res = await request(app)
.post('/api/workflows')
.set('Authorization', `Bearer ${systemAdminToken}`)
.send(validWorkflow);
expect(res.status).toBe(201);
expect(res.body).toHaveProperty('data');
expect(res.body.data.title).toBe(validWorkflow.title);
expect(res.body.data.description).toBe(validWorkflow.description);
expect(res.body.data.tenantId).toBe(validWorkflow.tenantId);
expect(res.body.data.status).toBe('DRAFT');
});
it('should create a workflow as TECHNICIAN', async () => {
const res = await request(app)
.post('/api/workflows')
.set('Authorization', `Bearer ${technicianToken}`)
.send(validWorkflow);
expect(res.status).toBe(201);
expect(res.body.data.title).toBe(validWorkflow.title);
});
it('should create a workflow as CUSTOMER_ADMIN in own tenant', async () => {
const res = await request(app)
.post('/api/workflows')
.set('Authorization', `Bearer ${customerAdminAToken}`)
.send(validWorkflow);
expect(res.status).toBe(201);
expect(res.body.data.tenantId).toBe('tenant-a-id');
});
it('should reject CUSTOMER_ADMIN creating workflow in another tenant', async () => {
const res = await request(app)
.post('/api/workflows')
.set('Authorization', `Bearer ${customerAdminAToken}`)
.send({ ...validWorkflow, tenantId: 'tenant-b-id' });
expect(res.status).toBe(403);
});
it('should reject CUSTOMER_USER creating workflows (no permission)', async () => {
const res = await request(app)
.post('/api/workflows')
.set('Authorization', `Bearer ${customerUserAToken}`)
.send(validWorkflow);
expect(res.status).toBe(403);
});
it('should reject creation without authentication', async () => {
const res = await request(app)
.post('/api/workflows')
.send(validWorkflow);
expect(res.status).toBe(401);
});
it('should return 400 with missing title', async () => {
const res = await request(app)
.post('/api/workflows')
.set('Authorization', `Bearer ${systemAdminToken}`)
.send({ tenantId: 'tenant-a-id' });
expect(res.status).toBe(400);
expect(res.body).toHaveProperty('error');
});
it('should return 400 with missing tenantId', async () => {
const res = await request(app)
.post('/api/workflows')
.set('Authorization', `Bearer ${systemAdminToken}`)
.send({ title: 'No Tenant' });
expect(res.status).toBe(400);
});
it('should create a workflow with steps', async () => {
const workflowWithSteps = {
...validWorkflow,
steps: [
{ order: 0, title: 'Step 1', content: 'Do this first', type: 'INSTRUCTION' },
{ order: 1, title: 'Step 2', content: 'Then do this', type: 'CHECKLIST' },
],
};
const res = await request(app)
.post('/api/workflows')
.set('Authorization', `Bearer ${systemAdminToken}`)
.send(workflowWithSteps);
expect(res.status).toBe(201);
expect(res.body.data.steps).toBeDefined();
expect(res.body.data.steps.length).toBe(2);
});
});
// ============================================================
// GET /api/workflows/:id (Get Single)
// ============================================================
describe('GET /api/workflows/:id', () => {
it('should return a workflow by ID', async () => {
const res = await request(app)
.get('/api/workflows/workflow-a1-id')
.set('Authorization', `Bearer ${systemAdminToken}`);
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('data');
expect(res.body.data.id).toBe('workflow-a1-id');
expect(res.body.data.title).toBe('Workflow A1');
});
it('should return 404 for non-existent workflow', async () => {
const res = await request(app)
.get('/api/workflows/non-existent-id')
.set('Authorization', `Bearer ${systemAdminToken}`);
expect(res.status).toBe(404);
});
it('should allow CUSTOMER_USER to view own tenant workflow', async () => {
const res = await request(app)
.get('/api/workflows/workflow-a1-id')
.set('Authorization', `Bearer ${customerUserAToken}`);
expect(res.status).toBe(200);
expect(res.body.data.id).toBe('workflow-a1-id');
});
it('should deny CUSTOMER_USER access to other tenant workflow', async () => {
const res = await request(app)
.get('/api/workflows/workflow-b1-id')
.set('Authorization', `Bearer ${customerUserAToken}`);
expect(res.status).toBe(403);
});
it('should allow TECHNICIAN to view any tenant workflow', async () => {
const res = await request(app)
.get('/api/workflows/workflow-b1-id')
.set('Authorization', `Bearer ${technicianToken}`);
expect(res.status).toBe(200);
expect(res.body.data.id).toBe('workflow-b1-id');
});
it('should return 401 without authentication', async () => {
const res = await request(app).get('/api/workflows/workflow-a1-id');
expect(res.status).toBe(401);
});
});
// ============================================================
// PUT /api/workflows/:id (Update)
// ============================================================
describe('PUT /api/workflows/:id', () => {
it('should update a workflow as SYSTEM_ADMIN', async () => {
const res = await request(app)
.put('/api/workflows/workflow-a1-id')
.set('Authorization', `Bearer ${systemAdminToken}`)
.send({ title: 'Updated Title' });
expect(res.status).toBe(200);
expect(res.body.data.title).toBe('Updated Title');
});
it('should update a workflow as TECHNICIAN', async () => {
const res = await request(app)
.put('/api/workflows/workflow-a1-id')
.set('Authorization', `Bearer ${technicianToken}`)
.send({ description: 'Updated description' });
expect(res.status).toBe(200);
expect(res.body.data.description).toBe('Updated description');
});
it('should allow CUSTOMER_ADMIN to update own tenant workflow', async () => {
const res = await request(app)
.put('/api/workflows/workflow-a1-id')
.set('Authorization', `Bearer ${customerAdminAToken}`)
.send({ title: 'Admin Updated' });
expect(res.status).toBe(200);
expect(res.body.data.title).toBe('Admin Updated');
});
it('should deny CUSTOMER_ADMIN updating another tenant workflow', async () => {
const res = await request(app)
.put('/api/workflows/workflow-b1-id')
.set('Authorization', `Bearer ${customerAdminAToken}`)
.send({ title: 'Should Not Work' });
expect(res.status).toBe(403);
});
it('should deny CUSTOMER_USER updating workflows', async () => {
const res = await request(app)
.put('/api/workflows/workflow-a1-id')
.set('Authorization', `Bearer ${customerUserAToken}`)
.send({ title: 'User Try' });
expect(res.status).toBe(403);
});
it('should return 404 for non-existent workflow', async () => {
const res = await request(app)
.put('/api/workflows/non-existent-id')
.set('Authorization', `Bearer ${systemAdminToken}`)
.send({ title: 'Ghost' });
expect(res.status).toBe(404);
});
it('should increment version on update', async () => {
const res = await request(app)
.put('/api/workflows/workflow-a1-id')
.set('Authorization', `Bearer ${systemAdminToken}`)
.send({ title: 'Version Bump' });
expect(res.status).toBe(200);
expect(res.body.data.version).toBe(2);
});
});
// ============================================================
// Tenant Isolation
// ============================================================
describe('Tenant Isolation', () => {
it('user from tenant A cannot see tenant B workflows in list', async () => {
const res = await request(app)
.get('/api/workflows')
.set('Authorization', `Bearer ${customerUserAToken}`);
expect(res.status).toBe(200);
const tenantBWorkflows = res.body.data.filter(
(w: any) => w.tenantId === TEST_TENANT_B.id
);
expect(tenantBWorkflows.length).toBe(0);
});
it('user from tenant B cannot see tenant A workflows in list', async () => {
const res = await request(app)
.get('/api/workflows')
.set('Authorization', `Bearer ${customerUserBToken}`);
expect(res.status).toBe(200);
const tenantAWorkflows = res.body.data.filter(
(w: any) => w.tenantId === TEST_TENANT_A.id
);
expect(tenantAWorkflows.length).toBe(0);
});
it('user from tenant A cannot access tenant B workflow by ID', async () => {
const res = await request(app)
.get('/api/workflows/workflow-b1-id')
.set('Authorization', `Bearer ${customerUserAToken}`);
expect(res.status).toBe(403);
});
it('user from tenant B cannot access tenant A workflow by ID', async () => {
const res = await request(app)
.get('/api/workflows/workflow-a1-id')
.set('Authorization', `Bearer ${customerUserBToken}`);
expect(res.status).toBe(403);
});
it('SYSTEM_ADMIN can see workflows from all tenants', async () => {
const res = await request(app)
.get('/api/workflows')
.set('Authorization', `Bearer ${systemAdminToken}`);
expect(res.status).toBe(200);
const tenantIds = res.body.data.map((w: any) => w.tenantId);
expect(tenantIds).toContain(TEST_TENANT_A.id);
expect(tenantIds).toContain(TEST_TENANT_B.id);
});
it('TECHNICIAN can see workflows from all tenants', async () => {
const res = await request(app)
.get('/api/workflows')
.set('Authorization', `Bearer ${technicianToken}`);
expect(res.status).toBe(200);
const tenantIds = res.body.data.map((w: any) => w.tenantId);
expect(tenantIds).toContain(TEST_TENANT_A.id);
expect(tenantIds).toContain(TEST_TENANT_B.id);
});
});
});
+18 -3
View File
@@ -2,7 +2,14 @@ import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { Role } from '@prisma/client';
const JWT_SECRET = process.env.JWT_SECRET || 'change-me-in-production';
const JWT_SECRET = process.env.JWT_SECRET;
if (!JWT_SECRET || JWT_SECRET === 'change-me-in-production') {
if (process.env.NODE_ENV === 'production') {
throw new Error('FATAL: JWT_SECRET must be set to a strong, unique value in production');
}
console.warn('WARNING: Using insecure default JWT_SECRET. Set JWT_SECRET env var before deploying.');
}
const RESOLVED_JWT_SECRET = JWT_SECRET || 'change-me-in-production';
interface JwtPayload {
id: string;
@@ -24,7 +31,7 @@ export function authenticate(req: Request, res: Response, next: NextFunction): v
const token = authHeader.substring(7);
try {
const decoded = jwt.verify(token, JWT_SECRET) as JwtPayload;
const decoded = jwt.verify(token, RESOLVED_JWT_SECRET, { algorithms: ['HS256'] }) as JwtPayload;
req.user = {
id: decoded.id,
email: decoded.email,
@@ -77,7 +84,15 @@ export function tenantGuard(req: Request, res: Response, next: NextFunction): vo
// For tenant-scoped users, check that the requested tenantId matches their own
const requestedTenantId = req.params.tenantId || req.body?.tenantId || req.query.tenantId;
if (requestedTenantId && requestedTenantId !== req.user.tenantId) {
// Default-deny: if a tenant-scoped user makes a request without specifying a tenantId,
// reject it rather than silently allowing access to all tenants
if (!requestedTenantId) {
// Allow if the route doesn't need tenant context (handled by individual route guards)
next();
return;
}
if (requestedTenantId !== req.user.tenantId) {
res.status(403).json({
error: 'Forbidden',
message: 'You can only access data within your own tenant',
+38 -9
View File
@@ -8,13 +8,40 @@ import { AppError } from '../lib/errors';
const router = Router();
const JWT_SECRET = process.env.JWT_SECRET || 'change-me-in-production';
const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'change-me-too';
const JWT_SECRET = process.env.JWT_SECRET;
const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET;
if (!JWT_SECRET || JWT_SECRET === 'change-me-in-production') {
if (process.env.NODE_ENV === 'production') {
throw new Error('FATAL: JWT_SECRET must be set to a strong, unique value in production');
}
console.warn('WARNING: Using insecure default JWT_SECRET. Set JWT_SECRET env var before deploying.');
}
if (!JWT_REFRESH_SECRET || JWT_REFRESH_SECRET === 'change-me-too') {
if (process.env.NODE_ENV === 'production') {
throw new Error('FATAL: JWT_REFRESH_SECRET must be set to a strong, unique value in production');
}
console.warn('WARNING: Using insecure default JWT_REFRESH_SECRET. Set JWT_REFRESH_SECRET env var before deploying.');
}
const RESOLVED_JWT_SECRET = JWT_SECRET || 'change-me-in-production';
const RESOLVED_JWT_REFRESH_SECRET = JWT_REFRESH_SECRET || 'change-me-too';
const ACCESS_TOKEN_EXPIRY = '15m';
const REFRESH_TOKEN_EXPIRY = '7d';
// Store for invalidated refresh tokens (in production, use Redis)
const invalidatedTokens = new Set<string>();
// Tokens are stored with their expiry time so we can periodically prune expired entries
const invalidatedTokens = new Map<string, number>();
// Prune expired tokens every 15 minutes to prevent unbounded memory growth
setInterval(() => {
const now = Date.now();
for (const [token, expiresAt] of invalidatedTokens) {
if (expiresAt < now) {
invalidatedTokens.delete(token);
}
}
}, 15 * 60 * 1000);
const loginSchema = z.object({
email: z.string().email('Invalid email format'),
@@ -35,8 +62,8 @@ function generateTokens(user: { id: string; email: string; role: string; tenantI
lastName: user.lastName,
};
const accessToken = jwt.sign(payload, JWT_SECRET, { expiresIn: ACCESS_TOKEN_EXPIRY });
const refreshToken = jwt.sign({ id: user.id, type: 'refresh' }, JWT_REFRESH_SECRET, { expiresIn: REFRESH_TOKEN_EXPIRY });
const accessToken = jwt.sign(payload, RESOLVED_JWT_SECRET, { algorithm: 'HS256', expiresIn: ACCESS_TOKEN_EXPIRY });
const refreshToken = jwt.sign({ id: user.id, type: 'refresh' }, RESOLVED_JWT_REFRESH_SECRET, { algorithm: 'HS256', expiresIn: REFRESH_TOKEN_EXPIRY });
return { accessToken, refreshToken };
}
@@ -111,7 +138,7 @@ router.post('/refresh', async (req: Request, res: Response, next: NextFunction)
let decoded: any;
try {
decoded = jwt.verify(refreshToken, JWT_REFRESH_SECRET);
decoded = jwt.verify(refreshToken, RESOLVED_JWT_REFRESH_SECRET, { algorithms: ['HS256'] });
} catch {
res.status(401).json({ error: 'Invalid refresh token' });
return;
@@ -131,8 +158,9 @@ router.post('/refresh', async (req: Request, res: Response, next: NextFunction)
return;
}
// Invalidate old refresh token
invalidatedTokens.add(refreshToken);
// Invalidate old refresh token (store with expiry for cleanup)
const tokenExp = decoded.exp ? decoded.exp * 1000 : Date.now() + 7 * 24 * 60 * 60 * 1000;
invalidatedTokens.set(refreshToken, tokenExp);
const tokens = generateTokens(user);
@@ -147,7 +175,8 @@ router.post('/logout', authenticate, async (req: Request, res: Response, next: N
try {
const refreshToken = req.body.refreshToken;
if (refreshToken) {
invalidatedTokens.add(refreshToken);
// Store with 7-day expiry for cleanup
invalidatedTokens.set(refreshToken, Date.now() + 7 * 24 * 60 * 60 * 1000);
}
res.json({ message: 'Logged out successfully' });
} catch (err) {
+1 -1
View File
@@ -9,7 +9,7 @@ const router = Router();
const createUserSchema = z.object({
email: z.string().email('Invalid email format'),
password: z.string().min(6, 'Password must be at least 6 characters'),
password: z.string().min(8, 'Password must be at least 8 characters').regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/, 'Password must contain uppercase, lowercase, and a number'),
firstName: z.string().min(1, 'First name is required').max(100),
lastName: z.string().min(1, 'Last name is required').max(100),
role: z.nativeEnum(Role),
+16
View File
@@ -603,6 +603,22 @@ router.patch('/:id/steps/reorder', authorize('SYSTEM_ADMIN', 'TECHNICIAN', 'CUST
return;
}
// Verify all step IDs belong to this workflow before reordering
const stepIds = parsed.data.steps.map((s) => s.id);
const existingSteps = await prisma.workflowStep.findMany({
where: { id: { in: stepIds }, workflowId: id },
select: { id: true },
});
const existingStepIds = new Set(existingSteps.map((s) => s.id));
const invalidStepIds = stepIds.filter((sid) => !existingStepIds.has(sid));
if (invalidStepIds.length > 0) {
res.status(400).json({
error: 'Invalid step IDs',
message: `Steps [${invalidStepIds.join(', ')}] do not belong to this workflow`,
});
return;
}
// Update all step orders in a transaction
await prisma.$transaction(
parsed.data.steps.map((step) =>