Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions apps/api/src/services/WorkflowService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,21 @@ import {NtfyService} from './NtfyService.js';
import {WorkflowExecutionService} from './WorkflowExecutionService.js';

export class WorkflowService {
private static async validateStepTemplate(projectId: string, templateId?: string | null): Promise<void> {
if (templateId === undefined || templateId === null) {
return;
}

const template = await prisma.template.findFirst({
where: {id: templateId, projectId},
select: {id: true},
});

if (!template) {
throw new HttpException(404, 'Template not found');
}
}

/**
* Get all workflows for a project with pagination
*/
Expand Down Expand Up @@ -504,6 +519,8 @@ export class WorkflowService {
}
}

await this.validateStepTemplate(projectId, data.templateId);

// Create the new step
const newStep = await prisma.workflowStep.create({
data: {
Expand Down Expand Up @@ -589,6 +606,8 @@ export class WorkflowService {
}
}

await this.validateStepTemplate(projectId, data.templateId);

const updateData: Prisma.WorkflowStepUpdateInput = {};

if (data.name !== undefined) updateData.name = data.name;
Expand Down Expand Up @@ -860,6 +879,8 @@ export class WorkflowService {
);
}

await this.validateStepTemplate(projectId, data.templateId);

return prisma.$transaction(async tx => {
const newStep = await tx.workflowStep.create({
data: {
Expand Down
90 changes: 90 additions & 0 deletions apps/api/src/services/__tests__/WorkflowService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,44 @@ describe('WorkflowService', () => {
expect(step.templateId).toBe(template.id);
});

it('should reject foreign and missing templates without revealing which exists', async () => {
const workflow = await factories.createWorkflow({projectId});
const {project: otherProject} = await factories.createUserWithProject();
const foreignTemplate = await factories.createTemplate({projectId: otherProject.id});
const missingTemplateId = '00000000-0000-0000-0000-000000000000';

const addEmailStep = (templateId: string) =>
WorkflowService.addStep(projectId, workflow.id, {
type: WorkflowStepType.SEND_EMAIL,
name: 'Send welcome email',
position: {x: 200, y: 100},
config: {},
templateId,
});

await expect(addEmailStep(foreignTemplate.id)).rejects.toMatchObject({
code: 404,
message: 'Template not found',
});
await expect(addEmailStep(missingTemplateId)).rejects.toMatchObject({
code: 404,
message: 'Template not found',
});
await expect(
WorkflowService.addStep(projectId, workflow.id, {
type: WorkflowStepType.DELAY,
name: 'Wait 1 hour',
position: {x: 200, y: 100},
config: {delay: 3600},
templateId: foreignTemplate.id,
}),
).rejects.toMatchObject({code: 404, message: 'Template not found'});

const addedSteps = await prisma.workflowStep.findMany({where: {workflowId: workflow.id}});
expect(addedSteps).toHaveLength(1);
expect(addedSteps[0]?.type).toBe(WorkflowStepType.TRIGGER);
});

it('should auto-connect to previous step by default', async () => {
const workflow = await factories.createWorkflow({projectId});

Expand Down Expand Up @@ -614,6 +652,33 @@ describe('WorkflowService', () => {
expect(updated.templateId).toBe(template2.id);
});

it('should reject updating an email step to foreign and missing templates', async () => {
const workflow = await factories.createWorkflow({projectId});
const template = await factories.createTemplate({projectId});
const {project: otherProject} = await factories.createUserWithProject();
const foreignTemplate = await factories.createTemplate({projectId: otherProject.id});
const missingTemplateId = '00000000-0000-0000-0000-000000000000';
const step = await factories.createWorkflowStep({
workflowId: workflow.id,
type: WorkflowStepType.SEND_EMAIL,
templateId: template.id,
});

await expect(
WorkflowService.updateStep(projectId, workflow.id, step.id, {
templateId: foreignTemplate.id,
}),
).rejects.toMatchObject({code: 404, message: 'Template not found'});
await expect(
WorkflowService.updateStep(projectId, workflow.id, step.id, {
templateId: missingTemplateId,
}),
).rejects.toMatchObject({code: 404, message: 'Template not found'});

const unchanged = await prisma.workflowStep.findUnique({where: {id: step.id}});
expect(unchanged?.templateId).toBe(template.id);
});

it('should remove template reference when set to null', async () => {
const workflow = await factories.createWorkflow({projectId});
const template = await factories.createTemplate({projectId});
Expand Down Expand Up @@ -878,6 +943,31 @@ describe('WorkflowService', () => {
expect(outgoing[0]?.condition).toBeNull();
});

it('should reject inserting an email step with a foreign template', async () => {
const workflow = await factories.createWorkflow({projectId});
const stepA = await factories.createWorkflowStep({workflowId: workflow.id});
const stepB = await factories.createWorkflowStep({workflowId: workflow.id});
const transition = await prisma.workflowTransition.create({
data: {fromStepId: stepA.id, toStepId: stepB.id},
});
const {project: otherProject} = await factories.createUserWithProject();
const foreignTemplate = await factories.createTemplate({projectId: otherProject.id});
const stepCount = await prisma.workflowStep.count({where: {workflowId: workflow.id}});

await expect(
WorkflowService.insertStepOnTransition(projectId, workflow.id, transition.id, {
type: WorkflowStepType.SEND_EMAIL,
name: 'Inserted email',
config: {},
templateId: foreignTemplate.id,
}),
).rejects.toMatchObject({code: 404, message: 'Template not found'});

const original = await prisma.workflowTransition.findUnique({where: {id: transition.id}});
expect(original?.toStepId).toBe(stepB.id);
expect(await prisma.workflowStep.count({where: {workflowId: workflow.id}})).toBe(stepCount);
});

it('should attach the downstream step to the first branch when inserting a CONDITION', async () => {
const workflow = await factories.createWorkflow({projectId});
const stepA = await factories.createWorkflowStep({workflowId: workflow.id});
Expand Down