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

View File

@ -1,5 +1,14 @@
DATABASE_URL=postgresql://workflow:workflow@localhost:5432/workflow_portal # Database
JWT_SECRET=change-me-in-production DATABASE_URL=postgresql://workflow:CHANGE_DB_PASSWORD@localhost:5432/workflow_portal
JWT_REFRESH_SECRET=change-me-too
# 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 PORT=4000
NODE_ENV=development
# CORS - set to your frontend URL in production
CORS_ORIGIN=http://localhost:3000 CORS_ORIGIN=http://localhost:3000

24
backend/jest.config.js Normal file
View File

@ -0,0 +1,24 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/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,
},
},
};

View File

@ -5,6 +5,9 @@
"dev": "nodemon --exec ts-node src/index.ts", "dev": "nodemon --exec ts-node src/index.ts",
"build": "tsc", "build": "tsc",
"start": "node dist/index.js", "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:migrate": "npx prisma migrate deploy",
"db:seed": "ts-node prisma/seed.ts", "db:seed": "ts-node prisma/seed.ts",
"db:generate": "npx prisma generate" "db:generate": "npx prisma generate"
@ -24,10 +27,15 @@
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/jest": "^29.5.0",
"@types/jsonwebtoken": "^9.0.7", "@types/jsonwebtoken": "^9.0.7",
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"@types/supertest": "^6.0.0",
"jest": "^29.7.0",
"nodemon": "^3.1.0", "nodemon": "^3.1.0",
"prisma": "^6.0.0", "prisma": "^6.0.0",
"supertest": "^7.0.0",
"ts-jest": "^29.2.0",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"typescript": "^5.6.0" "typescript": "^5.6.0"
} }

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

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

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

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

View File

@ -2,7 +2,14 @@ import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import { Role } from '@prisma/client'; 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 { interface JwtPayload {
id: string; id: string;
@ -24,7 +31,7 @@ export function authenticate(req: Request, res: Response, next: NextFunction): v
const token = authHeader.substring(7); const token = authHeader.substring(7);
try { try {
const decoded = jwt.verify(token, JWT_SECRET) as JwtPayload; const decoded = jwt.verify(token, RESOLVED_JWT_SECRET, { algorithms: ['HS256'] }) as JwtPayload;
req.user = { req.user = {
id: decoded.id, id: decoded.id,
email: decoded.email, 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 // For tenant-scoped users, check that the requested tenantId matches their own
const requestedTenantId = req.params.tenantId || req.body?.tenantId || req.query.tenantId; 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({ res.status(403).json({
error: 'Forbidden', error: 'Forbidden',
message: 'You can only access data within your own tenant', message: 'You can only access data within your own tenant',

View File

@ -8,13 +8,40 @@ import { AppError } from '../lib/errors';
const router = Router(); const router = Router();
const JWT_SECRET = process.env.JWT_SECRET || 'change-me-in-production'; const JWT_SECRET = process.env.JWT_SECRET;
const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'change-me-too'; 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 ACCESS_TOKEN_EXPIRY = '15m';
const REFRESH_TOKEN_EXPIRY = '7d'; const REFRESH_TOKEN_EXPIRY = '7d';
// Store for invalidated refresh tokens (in production, use Redis) // 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({ const loginSchema = z.object({
email: z.string().email('Invalid email format'), email: z.string().email('Invalid email format'),
@ -35,8 +62,8 @@ function generateTokens(user: { id: string; email: string; role: string; tenantI
lastName: user.lastName, lastName: user.lastName,
}; };
const accessToken = jwt.sign(payload, JWT_SECRET, { expiresIn: ACCESS_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' }, JWT_REFRESH_SECRET, { expiresIn: REFRESH_TOKEN_EXPIRY }); const refreshToken = jwt.sign({ id: user.id, type: 'refresh' }, RESOLVED_JWT_REFRESH_SECRET, { algorithm: 'HS256', expiresIn: REFRESH_TOKEN_EXPIRY });
return { accessToken, refreshToken }; return { accessToken, refreshToken };
} }
@ -111,7 +138,7 @@ router.post('/refresh', async (req: Request, res: Response, next: NextFunction)
let decoded: any; let decoded: any;
try { try {
decoded = jwt.verify(refreshToken, JWT_REFRESH_SECRET); decoded = jwt.verify(refreshToken, RESOLVED_JWT_REFRESH_SECRET, { algorithms: ['HS256'] });
} catch { } catch {
res.status(401).json({ error: 'Invalid refresh token' }); res.status(401).json({ error: 'Invalid refresh token' });
return; return;
@ -131,8 +158,9 @@ router.post('/refresh', async (req: Request, res: Response, next: NextFunction)
return; return;
} }
// Invalidate old refresh token // Invalidate old refresh token (store with expiry for cleanup)
invalidatedTokens.add(refreshToken); const tokenExp = decoded.exp ? decoded.exp * 1000 : Date.now() + 7 * 24 * 60 * 60 * 1000;
invalidatedTokens.set(refreshToken, tokenExp);
const tokens = generateTokens(user); const tokens = generateTokens(user);
@ -147,7 +175,8 @@ router.post('/logout', authenticate, async (req: Request, res: Response, next: N
try { try {
const refreshToken = req.body.refreshToken; const refreshToken = req.body.refreshToken;
if (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' }); res.json({ message: 'Logged out successfully' });
} catch (err) { } catch (err) {

View File

@ -9,7 +9,7 @@ const router = Router();
const createUserSchema = z.object({ const createUserSchema = z.object({
email: z.string().email('Invalid email format'), 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), firstName: z.string().min(1, 'First name is required').max(100),
lastName: z.string().min(1, 'Last name is required').max(100), lastName: z.string().min(1, 'Last name is required').max(100),
role: z.nativeEnum(Role), role: z.nativeEnum(Role),

View File

@ -603,6 +603,22 @@ router.patch('/:id/steps/reorder', authorize('SYSTEM_ADMIN', 'TECHNICIAN', 'CUST
return; 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 // Update all step orders in a transaction
await prisma.$transaction( await prisma.$transaction(
parsed.data.steps.map((step) => parsed.data.steps.map((step) =>

View File

@ -57,7 +57,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const logout = useCallback(async () => { const logout = useCallback(async () => {
try { try {
await authApi.logout(); const refreshToken = localStorage.getItem('refreshToken');
await authApi.logout(refreshToken);
} catch { } catch {
// ignore // ignore
} }

View File

@ -217,7 +217,7 @@ export default function Dashboard() {
{wf.title} {wf.title}
</p> </p>
<p className="truncate text-sm text-gray-500"> <p className="truncate text-sm text-gray-500">
{wf.steps?.length || 0} Schritte &middot; v{wf.version} {wf._count?.steps ?? wf.steps?.length ?? 0} Schritte &middot; v{wf.version}
</p> </p>
</div> </div>
<StatusBadge status={wf.status} /> <StatusBadge status={wf.status} />

View File

@ -11,8 +11,9 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { DragDropContext, Draggable, Droppable, DropResult } from '@hello-pangea/dnd'; import { DragDropContext, Draggable, Droppable, DropResult } from '@hello-pangea/dnd';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { workflowsApi, categoriesApi } from '../services/api'; import { workflowsApi, stepsApi, categoriesApi, tenantsApi } from '../services/api';
import type { Category, Workflow, WorkflowStep } from '../types'; import { useAuth } from '../hooks/useAuth';
import type { Category, Tenant, Workflow, WorkflowStep } from '../types';
import StepTypeIcon from '../components/StepTypeIcon'; import StepTypeIcon from '../components/StepTypeIcon';
interface StepDraft { interface StepDraft {
@ -40,6 +41,7 @@ function nextKey() {
export default function WorkflowEditor() { export default function WorkflowEditor() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const { user } = useAuth();
const isNew = !id; const isNew = !id;
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
@ -47,20 +49,28 @@ export default function WorkflowEditor() {
const [categoryId, setCategoryId] = useState(''); const [categoryId, setCategoryId] = useState('');
const [tagsInput, setTagsInput] = useState(''); const [tagsInput, setTagsInput] = useState('');
const [isTemplate, setIsTemplate] = useState(false); const [isTemplate, setIsTemplate] = useState(false);
const [tenantId, setTenantId] = useState(user?.tenantId || '');
const [tenants, setTenants] = useState<Tenant[]>([]);
const [steps, setSteps] = useState<StepDraft[]>([]); const [steps, setSteps] = useState<StepDraft[]>([]);
const [categories, setCategories] = useState<Category[]>([]); const [categories, setCategories] = useState<Category[]>([]);
const [loading, setLoading] = useState(!isNew); const [loading, setLoading] = useState(!isNew);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [autoSaved, setAutoSaved] = useState(false); const [autoSaved, setAutoSaved] = useState(false);
const isAdmin = user?.role === 'SYSTEM_ADMIN' || user?.role === 'TECHNICIAN';
const autoSaveTimer = useRef<ReturnType<typeof setTimeout>>(); const autoSaveTimer = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => { useEffect(() => {
categoriesApi.list().then(setCategories).catch(() => {}); categoriesApi.list().then(setCategories).catch(() => {});
}, []); if (isAdmin) {
tenantsApi.list({ pageSize: 100 }).then((r) => setTenants(r.data)).catch(() => {});
}
}, [isAdmin]);
useEffect(() => { useEffect(() => {
if (!id) return; if (!id) return;
setWorkflowId(id);
setLoading(true); setLoading(true);
workflowsApi workflowsApi
.get(id) .get(id)
@ -95,23 +105,53 @@ export default function WorkflowEditor() {
.map((t) => t.trim()) .map((t) => t.trim())
.filter(Boolean); .filter(Boolean);
const buildPayload = (status?: Workflow['status']) => ({ const buildMetadataPayload = (status?: Workflow['status']) => ({
title, title,
description: description || undefined, description: description || undefined,
categoryId: categoryId || undefined, categoryId: categoryId || undefined,
tags: parseTags(), tags: parseTags(),
isTemplate, isTemplate,
status, ...(status ? { status } : {}),
steps: steps.map((s, idx) => ({
id: s.id,
title: s.title,
content: s.content,
type: s.type,
isRequired: s.isRequired,
order: idx + 1,
})),
}); });
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( const handleSave = useCallback(
async (status?: Workflow['status']) => { async (status?: Workflow['status']) => {
if (!title.trim()) { if (!title.trim()) {
@ -120,16 +160,39 @@ export default function WorkflowEditor() {
} }
setSaving(true); setSaving(true);
try { try {
const payload = buildPayload(status);
if (isNew) { if (isNew) {
const created = await workflowsApi.create({ // Build create payload with tenantId, status, and inline steps
...payload, 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', status: status || 'DRAFT',
} as Partial<Workflow>); 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<Workflow>);
toast.success('Workflow erstellt'); toast.success('Workflow erstellt');
navigate(`/workflows/${created.id}/edit`, { replace: true }); navigate(`/workflows/${created.id}/edit`, { replace: true });
} else { } else {
// Update metadata first
const payload = buildMetadataPayload(status);
await workflowsApi.update(id!, payload as Partial<Workflow>); await workflowsApi.update(id!, payload as Partial<Workflow>);
// Sync steps via individual step endpoints
await syncSteps(id!);
toast.success('Workflow gespeichert'); toast.success('Workflow gespeichert');
} }
} catch { } catch {
@ -138,17 +201,17 @@ export default function WorkflowEditor() {
setSaving(false); 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(() => { useEffect(() => {
if (isNew || loading) return; if (isNew || loading) return;
clearTimeout(autoSaveTimer.current); clearTimeout(autoSaveTimer.current);
autoSaveTimer.current = setTimeout(async () => { autoSaveTimer.current = setTimeout(async () => {
if (!title.trim()) return; if (!title.trim()) return;
try { try {
await workflowsApi.update(id!, buildPayload() as Partial<Workflow>); await workflowsApi.update(id!, buildMetadataPayload() as Partial<Workflow>);
setAutoSaved(true); setAutoSaved(true);
setTimeout(() => setAutoSaved(false), 2000); setTimeout(() => setAutoSaved(false), 2000);
} catch { } catch {
@ -156,7 +219,7 @@ export default function WorkflowEditor() {
} }
}, 5000); }, 5000);
return () => clearTimeout(autoSaveTimer.current); return () => clearTimeout(autoSaveTimer.current);
}, [title, description, categoryId, tagsInput, isTemplate, steps, isNew, loading, id]); }, [title, description, categoryId, tagsInput, isTemplate, isNew, loading, id]);
const addStep = () => { const addStep = () => {
setSteps((prev) => [ setSteps((prev) => [
@ -250,6 +313,26 @@ export default function WorkflowEditor() {
/> />
</div> </div>
{isAdmin && isNew && (
<div>
<label className="mb-1.5 block text-sm font-medium text-gray-700">
Mandant *
</label>
<select
value={tenantId}
onChange={(e) => setTenantId(e.target.value)}
className="input-field"
>
<option value="">Mandant wählen...</option>
{tenants.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</select>
</div>
)}
<div className="grid gap-5 sm:grid-cols-2"> <div className="grid gap-5 sm:grid-cols-2">
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-gray-700"> <label className="mb-1.5 block text-sm font-medium text-gray-700">

View File

@ -197,7 +197,7 @@ export default function WorkflowList() {
)} )}
<div className="mt-4 flex items-center justify-between text-xs text-gray-400"> <div className="mt-4 flex items-center justify-between text-xs text-gray-400">
<span> <span>
{wf.steps?.length || 0} Schritte &middot; v{wf.version} {wf._count?.steps ?? wf.steps?.length ?? 0} Schritte &middot; v{wf.version}
</span> </span>
<span> <span>
{new Date(wf.updatedAt).toLocaleDateString('de-DE')} {new Date(wf.updatedAt).toLocaleDateString('de-DE')}
@ -254,7 +254,7 @@ export default function WorkflowList() {
</span> </span>
)} )}
<span className="hidden text-sm text-gray-400 sm:inline"> <span className="hidden text-sm text-gray-400 sm:inline">
{wf.steps?.length || 0} Schritte {wf._count?.steps ?? wf.steps?.length ?? 0} Schritte
</span> </span>
<StatusBadge status={wf.status} /> <StatusBadge status={wf.status} />
<span className="hidden text-xs text-gray-400 lg:inline"> <span className="hidden text-xs text-gray-400 lg:inline">

View File

@ -86,8 +86,10 @@ export default function WorkflowView() {
const handlePublish = async () => { const handlePublish = async () => {
if (!id) return; if (!id) return;
try { try {
const updated = await workflowsApi.publish(id); await workflowsApi.publish(id);
setWorkflow(updated); // Reload full workflow with steps
const refreshed = await workflowsApi.get(id);
setWorkflow(refreshed);
toast.success('Workflow veröffentlicht'); toast.success('Workflow veröffentlicht');
} catch { } catch {
toast.error('Veröffentlichen fehlgeschlagen'); toast.error('Veröffentlichen fehlgeschlagen');

View File

@ -73,9 +73,10 @@ api.interceptors.response.use(
} }
try { try {
const { data } = await axios.post<AuthResponse>('/api/auth/refresh', { const { data } = await axios.post<{ accessToken: string; refreshToken: string }>(
refreshToken, '/api/auth/refresh',
}); { refreshToken },
);
localStorage.setItem('accessToken', data.accessToken); localStorage.setItem('accessToken', data.accessToken);
localStorage.setItem('refreshToken', data.refreshToken); localStorage.setItem('refreshToken', data.refreshToken);
processQueue(null, data.accessToken); processQueue(null, data.accessToken);
@ -99,35 +100,66 @@ api.interceptors.response.use(
); );
// --- Auth --- // --- Auth ---
// Login returns { accessToken, refreshToken, user } directly
// /me returns user object directly
// /refresh returns { accessToken, refreshToken } (no user)
export const authApi = { export const authApi = {
login: (email: string, password: string) => login: (email: string, password: string) =>
api.post<AuthResponse>('/auth/login', { email, password }).then((r) => r.data), api.post<AuthResponse>('/auth/login', { email, password }).then((r) => r.data),
refresh: (refreshToken: string) => refresh: (refreshToken: string) =>
api.post<AuthResponse>('/auth/refresh', { refreshToken }).then((r) => r.data), api
.post<{ accessToken: string; refreshToken: string }>('/auth/refresh', { refreshToken })
.then((r) => r.data),
me: () => api.get<User>('/auth/me').then((r) => r.data), me: () => api.get<User>('/auth/me').then((r) => r.data),
logout: () => api.post('/auth/logout').then((r) => r.data), logout: (refreshToken?: string | null) =>
api.post('/auth/logout', { refreshToken }).then((r) => r.data),
}; };
// --- Tenants --- // --- Tenants ---
// Backend returns { data: Tenant[] } (no pagination)
export const tenantsApi = { export const tenantsApi = {
list: (params?: { page?: number; pageSize?: number; search?: string }) => list: (params?: { page?: number; pageSize?: number; search?: string }) =>
api.get<PaginatedResponse<Tenant>>('/tenants', { params }).then((r) => r.data), api
.get<{ data: Tenant[] }>('/tenants', { params })
.then((r) => {
const tenants = r.data.data;
// Client-side search if the backend doesn't support it
let filtered = tenants;
if (params?.search) {
const s = params.search.toLowerCase();
filtered = tenants.filter(
(t) =>
t.name.toLowerCase().includes(s) ||
t.slug.toLowerCase().includes(s)
);
}
// Client-side pagination
const page = params?.page || 1;
const pageSize = params?.pageSize || 20;
const total = filtered.length;
const totalPages = Math.ceil(total / pageSize) || 1;
const start = (page - 1) * pageSize;
const data = filtered.slice(start, start + pageSize);
return { data, total, page, pageSize, totalPages } as PaginatedResponse<Tenant>;
}),
get: (id: string) => api.get<Tenant>(`/tenants/${id}`).then((r) => r.data), get: (id: string) =>
api.get<{ data: Tenant }>(`/tenants/${id}`).then((r) => r.data.data),
create: (data: Partial<Tenant>) => create: (data: Partial<Tenant>) =>
api.post<Tenant>('/tenants', data).then((r) => r.data), api.post<{ data: Tenant }>('/tenants', data).then((r) => r.data.data),
update: (id: string, data: Partial<Tenant>) => update: (id: string, data: Partial<Tenant>) =>
api.put<Tenant>(`/tenants/${id}`, data).then((r) => r.data), api.put<{ data: Tenant }>(`/tenants/${id}`, data).then((r) => r.data.data),
delete: (id: string) => api.delete(`/tenants/${id}`).then((r) => r.data), delete: (id: string) => api.delete(`/tenants/${id}`).then((r) => r.data),
}; };
// --- Users --- // --- Users ---
// Backend returns { data: User[] } (no pagination)
export const usersApi = { export const usersApi = {
list: (params?: { list: (params?: {
page?: number; page?: number;
@ -135,20 +167,55 @@ export const usersApi = {
search?: string; search?: string;
tenantId?: string; tenantId?: string;
role?: string; role?: string;
}) => api.get<PaginatedResponse<User>>('/users', { params }).then((r) => r.data), }) =>
api
.get<{ data: User[] }>('/users', {
params: {
tenantId: params?.tenantId,
},
})
.then((r) => {
let users = r.data.data;
// Client-side search
if (params?.search) {
const s = params.search.toLowerCase();
users = users.filter(
(u) =>
u.firstName.toLowerCase().includes(s) ||
u.lastName.toLowerCase().includes(s) ||
u.email.toLowerCase().includes(s)
);
}
// Client-side role filter
if (params?.role) {
users = users.filter((u) => u.role === params.role);
}
// Client-side pagination
const page = params?.page || 1;
const pageSize = params?.pageSize || 20;
const total = users.length;
const totalPages = Math.ceil(total / pageSize) || 1;
const start = (page - 1) * pageSize;
const data = users.slice(start, start + pageSize);
return { data, total, page, pageSize, totalPages } as PaginatedResponse<User>;
}),
get: (id: string) => api.get<User>(`/users/${id}`).then((r) => r.data), get: (id: string) =>
api.get<{ data: User }>(`/users/${id}`).then((r) => r.data.data),
create: (data: Partial<User> & { password?: string }) => create: (data: Partial<User> & { password?: string }) =>
api.post<User>('/users', data).then((r) => r.data), api.post<{ data: User }>('/users', data).then((r) => r.data.data),
update: (id: string, data: Partial<User>) => update: (id: string, data: Partial<User>) =>
api.put<User>(`/users/${id}`, data).then((r) => r.data), api.put<{ data: User }>(`/users/${id}`, data).then((r) => r.data.data),
delete: (id: string) => api.delete(`/users/${id}`).then((r) => r.data), delete: (id: string) => api.delete(`/users/${id}`).then((r) => r.data),
}; };
// --- Workflows --- // --- Workflows ---
// Backend list returns { data: Workflow[], pagination: { page, limit, total, totalPages } }
// Backend uses "limit" query param (not "pageSize")
// Single-item endpoints return { data: Workflow }
export const workflowsApi = { export const workflowsApi = {
list: (params?: { list: (params?: {
page?: number; page?: number;
@ -161,59 +228,91 @@ export const workflowsApi = {
tags?: string; tags?: string;
}) => }) =>
api api
.get<PaginatedResponse<Workflow>>('/workflows', { params }) .get<{
.then((r) => r.data), data: Workflow[];
pagination: { page: number; limit: number; total: number; totalPages: number };
}>('/workflows', {
params: {
page: params?.page,
limit: params?.pageSize,
search: params?.search,
status: params?.status,
categoryId: params?.categoryId,
tenantId: params?.tenantId,
isTemplate: params?.isTemplate,
tags: params?.tags,
},
})
.then((r) => ({
data: r.data.data,
total: r.data.pagination.total,
page: r.data.pagination.page,
pageSize: r.data.pagination.limit,
totalPages: r.data.pagination.totalPages,
})),
get: (id: string) => api.get<Workflow>(`/workflows/${id}`).then((r) => r.data), get: (id: string) =>
api.get<{ data: Workflow }>(`/workflows/${id}`).then((r) => r.data.data),
create: (data: Partial<Workflow>) => create: (data: Partial<Workflow>) =>
api.post<Workflow>('/workflows', data).then((r) => r.data), api.post<{ data: Workflow }>('/workflows', data).then((r) => r.data.data),
update: (id: string, data: Partial<Workflow>) => update: (id: string, data: Partial<Workflow>) =>
api.put<Workflow>(`/workflows/${id}`, data).then((r) => r.data), api.put<{ data: Workflow }>(`/workflows/${id}`, data).then((r) => r.data.data),
delete: (id: string) => api.delete(`/workflows/${id}`).then((r) => r.data), delete: (id: string) => api.delete(`/workflows/${id}`).then((r) => r.data),
duplicate: (id: string) => duplicate: (id: string) =>
api.post<Workflow>(`/workflows/${id}/duplicate`).then((r) => r.data), api.post<{ data: Workflow }>(`/workflows/${id}/duplicate`).then((r) => r.data.data),
// Publish and archive use the PATCH /:id/status endpoint
publish: (id: string) => publish: (id: string) =>
api.post<Workflow>(`/workflows/${id}/publish`).then((r) => r.data), api
.patch<{ data: Workflow }>(`/workflows/${id}/status`, { status: 'PUBLISHED' })
.then((r) => r.data.data),
archive: (id: string) => archive: (id: string) =>
api.post<Workflow>(`/workflows/${id}/archive`).then((r) => r.data), api
.patch<{ data: Workflow }>(`/workflows/${id}/status`, { status: 'ARCHIVED' })
.then((r) => r.data.data),
changeStatus: (id: string, status: Workflow['status']) =>
api
.patch<{ data: Workflow }>(`/workflows/${id}/status`, { status })
.then((r) => r.data.data),
}; };
// --- Workflow Steps --- // --- Workflow Steps ---
export const stepsApi = { export const stepsApi = {
create: (workflowId: string, data: Partial<WorkflowStep>) => create: (workflowId: string, data: Partial<WorkflowStep>) =>
api api
.post<WorkflowStep>(`/workflows/${workflowId}/steps`, data) .post<{ data: WorkflowStep }>(`/workflows/${workflowId}/steps`, data)
.then((r) => r.data), .then((r) => r.data.data),
update: (workflowId: string, stepId: string, data: Partial<WorkflowStep>) => update: (workflowId: string, stepId: string, data: Partial<WorkflowStep>) =>
api api
.put<WorkflowStep>(`/workflows/${workflowId}/steps/${stepId}`, data) .put<{ data: WorkflowStep }>(`/workflows/${workflowId}/steps/${stepId}`, data)
.then((r) => r.data), .then((r) => r.data.data),
delete: (workflowId: string, stepId: string) => delete: (workflowId: string, stepId: string) =>
api.delete(`/workflows/${workflowId}/steps/${stepId}`).then((r) => r.data), api.delete(`/workflows/${workflowId}/steps/${stepId}`).then((r) => r.data),
reorder: (workflowId: string, stepIds: string[]) => reorder: (workflowId: string, steps: { id: string; order: number }[]) =>
api api
.put(`/workflows/${workflowId}/steps/reorder`, { stepIds }) .patch(`/workflows/${workflowId}/steps/reorder`, { steps })
.then((r) => r.data), .then((r) => r.data),
}; };
// --- Categories --- // --- Categories ---
export const categoriesApi = { export const categoriesApi = {
list: () => api.get<Category[]>('/categories').then((r) => r.data), list: () =>
api.get<{ data: Category[] }>('/categories').then((r) => r.data.data),
create: (data: Partial<Category>) => create: (data: Partial<Category>) =>
api.post<Category>('/categories', data).then((r) => r.data), api.post<{ data: Category }>('/categories', data).then((r) => r.data.data),
update: (id: string, data: Partial<Category>) => update: (id: string, data: Partial<Category>) =>
api.put<Category>(`/categories/${id}`, data).then((r) => r.data), api.put<{ data: Category }>(`/categories/${id}`, data).then((r) => r.data.data),
delete: (id: string) => api.delete(`/categories/${id}`).then((r) => r.data), delete: (id: string) => api.delete(`/categories/${id}`).then((r) => r.data),
}; };

View File

@ -27,7 +27,8 @@ export interface Workflow {
version: number; version: number;
isTemplate: boolean; isTemplate: boolean;
tags: string[]; tags: string[];
steps: WorkflowStep[]; steps?: WorkflowStep[];
_count?: { steps: number };
createdBy: User; createdBy: User;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;