From 093fe8557329045e3964ef345054a41943e8ca8a Mon Sep 17 00:00:00 2001 From: root Date: Fri, 20 Mar 2026 23:15:07 +0000 Subject: [PATCH] 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 --- backend/.env.example | 15 +- backend/jest.config.js | 24 ++ backend/package.json | 8 + backend/src/__tests__/auth.test.ts | 303 ++++++++++++++ backend/src/__tests__/setup.ts | 509 ++++++++++++++++++++++++ backend/src/__tests__/tenants.test.ts | 399 +++++++++++++++++++ backend/src/__tests__/workflows.test.ts | 410 +++++++++++++++++++ backend/src/middleware/auth.ts | 21 +- backend/src/routes/auth.routes.ts | 47 ++- backend/src/routes/user.routes.ts | 2 +- backend/src/routes/workflow.routes.ts | 16 + frontend/src/hooks/useAuth.tsx | 3 +- frontend/src/pages/Dashboard.tsx | 2 +- frontend/src/pages/WorkflowEditor.tsx | 125 +++++- frontend/src/pages/WorkflowList.tsx | 4 +- frontend/src/pages/WorkflowView.tsx | 6 +- frontend/src/services/api.ts | 159 ++++++-- frontend/src/types/index.ts | 3 +- 18 files changed, 1982 insertions(+), 74 deletions(-) create mode 100644 backend/jest.config.js create mode 100644 backend/src/__tests__/auth.test.ts create mode 100644 backend/src/__tests__/setup.ts create mode 100644 backend/src/__tests__/tenants.test.ts create mode 100644 backend/src/__tests__/workflows.test.ts diff --git a/backend/.env.example b/backend/.env.example index be8c8be..5dd74db 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,5 +1,14 @@ -DATABASE_URL=postgresql://workflow:workflow@localhost:5432/workflow_portal -JWT_SECRET=change-me-in-production -JWT_REFRESH_SECRET=change-me-too +# Database +DATABASE_URL=postgresql://workflow:CHANGE_DB_PASSWORD@localhost:5432/workflow_portal + +# JWT Secrets - MUST be changed before deploying! +# Generate with: node -e "console.log(require('crypto').randomBytes(64).toString('hex'))" +JWT_SECRET=GENERATE_A_STRONG_RANDOM_SECRET_HERE +JWT_REFRESH_SECRET=GENERATE_ANOTHER_STRONG_RANDOM_SECRET_HERE + +# Server PORT=4000 +NODE_ENV=development + +# CORS - set to your frontend URL in production CORS_ORIGIN=http://localhost:3000 diff --git a/backend/jest.config.js b/backend/jest.config.js new file mode 100644 index 0000000..ade8740 --- /dev/null +++ b/backend/jest.config.js @@ -0,0 +1,24 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/src'], + testMatch: ['**/__tests__/**/*.test.ts'], + moduleFileExtensions: ['ts', 'js', 'json'], + clearMocks: true, + collectCoverageFrom: [ + 'src/**/*.ts', + '!src/**/*.d.ts', + '!src/__tests__/**', + '!src/index.ts', + ], + coverageDirectory: 'coverage', + coverageThresholds: { + global: { + branches: 70, + functions: 80, + lines: 80, + statements: 80, + }, + }, +}; diff --git a/backend/package.json b/backend/package.json index 29418ce..3462582 100644 --- a/backend/package.json +++ b/backend/package.json @@ -5,6 +5,9 @@ "dev": "nodemon --exec ts-node src/index.ts", "build": "tsc", "start": "node dist/index.js", + "test": "jest --forceExit --detectOpenHandles", + "test:coverage": "jest --forceExit --detectOpenHandles --coverage", + "test:watch": "jest --watch", "db:migrate": "npx prisma migrate deploy", "db:seed": "ts-node prisma/seed.ts", "db:generate": "npx prisma generate" @@ -24,10 +27,15 @@ "@types/bcryptjs": "^2.4.6", "@types/cors": "^2.8.17", "@types/express": "^5.0.0", + "@types/jest": "^29.5.0", "@types/jsonwebtoken": "^9.0.7", "@types/node": "^22.0.0", + "@types/supertest": "^6.0.0", + "jest": "^29.7.0", "nodemon": "^3.1.0", "prisma": "^6.0.0", + "supertest": "^7.0.0", + "ts-jest": "^29.2.0", "ts-node": "^10.9.2", "typescript": "^5.6.0" } diff --git a/backend/src/__tests__/auth.test.ts b/backend/src/__tests__/auth.test.ts new file mode 100644 index 0000000..8e1cf66 --- /dev/null +++ b/backend/src/__tests__/auth.test.ts @@ -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); + }); + }); +}); diff --git a/backend/src/__tests__/setup.ts b/backend/src/__tests__/setup.ts new file mode 100644 index 0000000..cda1c30 --- /dev/null +++ b/backend/src/__tests__/setup.ts @@ -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 }; diff --git a/backend/src/__tests__/tenants.test.ts b/backend/src/__tests__/tenants.test.ts new file mode 100644 index 0000000..358db39 --- /dev/null +++ b/backend/src/__tests__/tenants.test.ts @@ -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); + }); + }); +}); diff --git a/backend/src/__tests__/workflows.test.ts b/backend/src/__tests__/workflows.test.ts new file mode 100644 index 0000000..f7a05d9 --- /dev/null +++ b/backend/src/__tests__/workflows.test.ts @@ -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); + }); + }); +}); diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts index a13b690..c560c53 100644 --- a/backend/src/middleware/auth.ts +++ b/backend/src/middleware/auth.ts @@ -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', diff --git a/backend/src/routes/auth.routes.ts b/backend/src/routes/auth.routes.ts index abc4fdd..edc9541 100644 --- a/backend/src/routes/auth.routes.ts +++ b/backend/src/routes/auth.routes.ts @@ -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(); +// Tokens are stored with their expiry time so we can periodically prune expired entries +const invalidatedTokens = new Map(); + +// 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) { diff --git a/backend/src/routes/user.routes.ts b/backend/src/routes/user.routes.ts index 92a4225..58471e0 100644 --- a/backend/src/routes/user.routes.ts +++ b/backend/src/routes/user.routes.ts @@ -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), diff --git a/backend/src/routes/workflow.routes.ts b/backend/src/routes/workflow.routes.ts index e4cd0d1..41f349a 100644 --- a/backend/src/routes/workflow.routes.ts +++ b/backend/src/routes/workflow.routes.ts @@ -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) => diff --git a/frontend/src/hooks/useAuth.tsx b/frontend/src/hooks/useAuth.tsx index 8dfd74a..5a1faef 100644 --- a/frontend/src/hooks/useAuth.tsx +++ b/frontend/src/hooks/useAuth.tsx @@ -57,7 +57,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const logout = useCallback(async () => { try { - await authApi.logout(); + const refreshToken = localStorage.getItem('refreshToken'); + await authApi.logout(refreshToken); } catch { // ignore } diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 8e9f99d..8ef66be 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -217,7 +217,7 @@ export default function Dashboard() { {wf.title}

- {wf.steps?.length || 0} Schritte · v{wf.version} + {wf._count?.steps ?? wf.steps?.length ?? 0} Schritte · v{wf.version}

diff --git a/frontend/src/pages/WorkflowEditor.tsx b/frontend/src/pages/WorkflowEditor.tsx index d8c700e..cef2ffa 100644 --- a/frontend/src/pages/WorkflowEditor.tsx +++ b/frontend/src/pages/WorkflowEditor.tsx @@ -11,8 +11,9 @@ import { } from 'lucide-react'; import { DragDropContext, Draggable, Droppable, DropResult } from '@hello-pangea/dnd'; import toast from 'react-hot-toast'; -import { workflowsApi, categoriesApi } from '../services/api'; -import type { Category, Workflow, WorkflowStep } from '../types'; +import { workflowsApi, stepsApi, categoriesApi, tenantsApi } from '../services/api'; +import { useAuth } from '../hooks/useAuth'; +import type { Category, Tenant, Workflow, WorkflowStep } from '../types'; import StepTypeIcon from '../components/StepTypeIcon'; interface StepDraft { @@ -40,6 +41,7 @@ function nextKey() { export default function WorkflowEditor() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); + const { user } = useAuth(); const isNew = !id; const [title, setTitle] = useState(''); @@ -47,20 +49,28 @@ export default function WorkflowEditor() { const [categoryId, setCategoryId] = useState(''); const [tagsInput, setTagsInput] = useState(''); const [isTemplate, setIsTemplate] = useState(false); + const [tenantId, setTenantId] = useState(user?.tenantId || ''); + const [tenants, setTenants] = useState([]); const [steps, setSteps] = useState([]); const [categories, setCategories] = useState([]); const [loading, setLoading] = useState(!isNew); const [saving, setSaving] = useState(false); const [autoSaved, setAutoSaved] = useState(false); + const isAdmin = user?.role === 'SYSTEM_ADMIN' || user?.role === 'TECHNICIAN'; + const autoSaveTimer = useRef>(); useEffect(() => { categoriesApi.list().then(setCategories).catch(() => {}); - }, []); + if (isAdmin) { + tenantsApi.list({ pageSize: 100 }).then((r) => setTenants(r.data)).catch(() => {}); + } + }, [isAdmin]); useEffect(() => { if (!id) return; + setWorkflowId(id); setLoading(true); workflowsApi .get(id) @@ -95,23 +105,53 @@ export default function WorkflowEditor() { .map((t) => t.trim()) .filter(Boolean); - const buildPayload = (status?: Workflow['status']) => ({ + const buildMetadataPayload = (status?: Workflow['status']) => ({ title, description: description || undefined, categoryId: categoryId || undefined, tags: parseTags(), isTemplate, - status, - steps: steps.map((s, idx) => ({ - id: s.id, - title: s.title, - content: s.content, - type: s.type, - isRequired: s.isRequired, - order: idx + 1, - })), + ...(status ? { status } : {}), }); + const syncSteps = async (wfId: string) => { + // Get current steps from server + const currentWorkflow = await workflowsApi.get(wfId); + const serverSteps = currentWorkflow.steps || []; + const serverStepIds = new Set(serverSteps.map((s) => s.id)); + + // Determine which local steps need to be created, updated, or deleted + const localStepIds = new Set(steps.filter((s) => s.id).map((s) => s.id!)); + + // Delete steps that no longer exist locally + for (const serverStep of serverSteps) { + if (!localStepIds.has(serverStep.id)) { + await stepsApi.delete(wfId, serverStep.id); + } + } + + // Create or update steps + for (let idx = 0; idx < steps.length; idx++) { + const step = steps[idx]; + const stepData = { + title: step.title || 'Unbenannter Schritt', + content: step.content || ' ', + type: step.type, + isRequired: step.isRequired, + order: idx + 1, + }; + if (step.id && serverStepIds.has(step.id)) { + // Update existing step + await stepsApi.update(wfId, step.id, stepData); + } else { + // Create new step + const created = await stepsApi.create(wfId, stepData); + // Update local step with new ID + step.id = created.id; + } + } + }; + const handleSave = useCallback( async (status?: Workflow['status']) => { if (!title.trim()) { @@ -120,16 +160,39 @@ export default function WorkflowEditor() { } setSaving(true); try { - const payload = buildPayload(status); if (isNew) { - const created = await workflowsApi.create({ - ...payload, + // Build create payload with tenantId, status, and inline steps + const effectiveTenantId = tenantId || user?.tenantId; + if (!effectiveTenantId) { + toast.error('Bitte wählen Sie einen Mandanten'); + setSaving(false); + return; + } + const createPayload = { + title, + description: description || undefined, + tenantId: effectiveTenantId, + categoryId: categoryId || undefined, + tags: parseTags(), + isTemplate, status: status || 'DRAFT', - } as Partial); + steps: steps.map((s, idx) => ({ + title: s.title || 'Unbenannter Schritt', + content: s.content || ' ', + type: s.type, + isRequired: s.isRequired, + order: idx + 1, + })), + }; + const created = await workflowsApi.create(createPayload as Partial); toast.success('Workflow erstellt'); navigate(`/workflows/${created.id}/edit`, { replace: true }); } else { + // Update metadata first + const payload = buildMetadataPayload(status); await workflowsApi.update(id!, payload as Partial); + // Sync steps via individual step endpoints + await syncSteps(id!); toast.success('Workflow gespeichert'); } } catch { @@ -138,17 +201,17 @@ export default function WorkflowEditor() { setSaving(false); } }, - [title, description, categoryId, tagsInput, isTemplate, steps, isNew, id, navigate] + [title, description, categoryId, tagsInput, isTemplate, tenantId, steps, isNew, id, navigate, user] ); - // Auto-save (only for existing workflows) + // Auto-save (only for existing workflows) - metadata only useEffect(() => { if (isNew || loading) return; clearTimeout(autoSaveTimer.current); autoSaveTimer.current = setTimeout(async () => { if (!title.trim()) return; try { - await workflowsApi.update(id!, buildPayload() as Partial); + await workflowsApi.update(id!, buildMetadataPayload() as Partial); setAutoSaved(true); setTimeout(() => setAutoSaved(false), 2000); } catch { @@ -156,7 +219,7 @@ export default function WorkflowEditor() { } }, 5000); return () => clearTimeout(autoSaveTimer.current); - }, [title, description, categoryId, tagsInput, isTemplate, steps, isNew, loading, id]); + }, [title, description, categoryId, tagsInput, isTemplate, isNew, loading, id]); const addStep = () => { setSteps((prev) => [ @@ -250,6 +313,26 @@ export default function WorkflowEditor() { /> + {isAdmin && isNew && ( +
+ + +
+ )} +