refactor: Webhooks (#12487)
Closes #12303 ### What’s Changed - Replace auto‐save with explicit Save / Cancel Webhook forms now use manual “Save” and “Cancel” buttons instead of the old debounced auto‐save/update. - Separate “New” and “Detail” routes Two dedicated paths `/settings/webhooks/new` for creation and /`settings/webhooks/:webhookId` for editing, making the UX clearer. - URL hint & normalization If a user omits the http(s):// scheme, we display a “Will be saved as https://…” hint and automatically default to HTTPS. - Centralized validation with Zod Introduced a `webhookFormSchema` for client‐side URL, operations, and secret validation. - Storybook coverage Added stories for both “New Webhook” and “Webhook Detail” - Unit tests Added tests for the new `useWebhookForm` hook
This commit is contained in:
@ -3,6 +3,8 @@ import { Logger } from '@nestjs/common';
|
||||
|
||||
import crypto from 'crypto';
|
||||
|
||||
import { getAbsoluteUrl } from 'twenty-shared/utils';
|
||||
|
||||
import { AuditService } from 'src/engine/core-modules/audit/services/audit.service';
|
||||
import { WEBHOOK_RESPONSE_EVENT } from 'src/engine/core-modules/audit/utils/events/workspace-event/webhook/webhook-response';
|
||||
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
|
||||
@ -72,7 +74,7 @@ export class CallWebhookJob {
|
||||
}
|
||||
|
||||
const response = await this.httpService.axiosRef.post(
|
||||
data.targetUrl,
|
||||
getAbsoluteUrl(data.targetUrl),
|
||||
payloadWithoutSecret,
|
||||
{ headers },
|
||||
);
|
||||
|
||||
@ -0,0 +1,117 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
|
||||
import {
|
||||
GraphqlQueryRunnerException,
|
||||
GraphqlQueryRunnerExceptionCode,
|
||||
} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
|
||||
import { WebhookUrlValidationService } from 'src/modules/webhook/query-hooks/webhook-url-validation.service';
|
||||
|
||||
describe('WebhookUrlValidationService', () => {
|
||||
let service: WebhookUrlValidationService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [WebhookUrlValidationService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<WebhookUrlValidationService>(
|
||||
WebhookUrlValidationService,
|
||||
);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('validateWebhookUrl', () => {
|
||||
it('should accept valid HTTP URLs', () => {
|
||||
expect(() => {
|
||||
service.validateWebhookUrl('http://example.com/webhook');
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should accept valid HTTPS URLs', () => {
|
||||
expect(() => {
|
||||
service.validateWebhookUrl('https://example.com/webhook');
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should accept URLs with ports', () => {
|
||||
expect(() => {
|
||||
service.validateWebhookUrl('http://localhost:3000/webhook');
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should accept URLs with paths and query parameters', () => {
|
||||
expect(() => {
|
||||
service.validateWebhookUrl(
|
||||
'https://api.example.com/webhooks/receive?token=abc123',
|
||||
);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should reject URLs without scheme', () => {
|
||||
expect(() => {
|
||||
service.validateWebhookUrl('example.com/webhook');
|
||||
}).toThrow(GraphqlQueryRunnerException);
|
||||
});
|
||||
|
||||
it('should reject malformed URLs', () => {
|
||||
expect(() => {
|
||||
service.validateWebhookUrl('not-a-url');
|
||||
}).toThrow(GraphqlQueryRunnerException);
|
||||
});
|
||||
|
||||
it('should reject URLs with FTP scheme', () => {
|
||||
expect(() => {
|
||||
service.validateWebhookUrl('ftp://example.com/webhook');
|
||||
}).toThrow(GraphqlQueryRunnerException);
|
||||
});
|
||||
|
||||
it('should reject URLs with mailto scheme', () => {
|
||||
expect(() => {
|
||||
service.validateWebhookUrl('mailto:user@example.com');
|
||||
}).toThrow(GraphqlQueryRunnerException);
|
||||
});
|
||||
|
||||
it('should reject URLs with custom schemes', () => {
|
||||
expect(() => {
|
||||
service.validateWebhookUrl('custom://example.com/webhook');
|
||||
}).toThrow(GraphqlQueryRunnerException);
|
||||
});
|
||||
|
||||
it('should provide helpful error message for malformed URLs', () => {
|
||||
try {
|
||||
service.validateWebhookUrl('example.com/webhook');
|
||||
fail('Expected exception to be thrown');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(GraphqlQueryRunnerException);
|
||||
expect(error.code).toBe(
|
||||
GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
|
||||
);
|
||||
expect(error.message).toContain('Invalid URL: missing scheme');
|
||||
expect(error.message).toContain('example.com/webhook');
|
||||
}
|
||||
});
|
||||
|
||||
it('should provide helpful error message for invalid scheme', () => {
|
||||
try {
|
||||
service.validateWebhookUrl('ftp://example.com/webhook');
|
||||
fail('Expected exception to be thrown');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(GraphqlQueryRunnerException);
|
||||
expect(error.code).toBe(
|
||||
GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
|
||||
);
|
||||
expect(error.message).toContain('Only HTTP and HTTPS are allowed');
|
||||
expect(error.message).toContain('ftp:');
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject empty strings', () => {
|
||||
expect(() => {
|
||||
service.validateWebhookUrl('');
|
||||
}).toThrow(GraphqlQueryRunnerException);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,21 @@
|
||||
import { WorkspacePreQueryHookInstance } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/interfaces/workspace-query-hook.interface';
|
||||
import { CreateManyResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
|
||||
|
||||
import {
|
||||
GraphqlQueryRunnerException,
|
||||
GraphqlQueryRunnerExceptionCode,
|
||||
} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
|
||||
import { WorkspaceQueryHook } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/decorators/workspace-query-hook.decorator';
|
||||
import { WebhookWorkspaceEntity } from 'src/modules/webhook/standard-objects/webhook.workspace-entity';
|
||||
|
||||
@WorkspaceQueryHook(`webhook.createMany`)
|
||||
export class WebhookCreateManyPreQueryHook
|
||||
implements WorkspacePreQueryHookInstance
|
||||
{
|
||||
async execute(): Promise<CreateManyResolverArgs<WebhookWorkspaceEntity>> {
|
||||
throw new GraphqlQueryRunnerException(
|
||||
'Method not allowed.',
|
||||
GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
import { WorkspacePreQueryHookInstance } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/interfaces/workspace-query-hook.interface';
|
||||
import { CreateOneResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
|
||||
|
||||
import { WorkspaceQueryHook } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/decorators/workspace-query-hook.decorator';
|
||||
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { WebhookUrlValidationService } from 'src/modules/webhook/query-hooks/webhook-url-validation.service';
|
||||
import { WebhookWorkspaceEntity } from 'src/modules/webhook/standard-objects/webhook.workspace-entity';
|
||||
|
||||
@WorkspaceQueryHook(`webhook.createOne`)
|
||||
export class WebhookCreateOnePreQueryHook
|
||||
implements WorkspacePreQueryHookInstance
|
||||
{
|
||||
constructor(
|
||||
private readonly webhookUrlValidationService: WebhookUrlValidationService,
|
||||
) {}
|
||||
|
||||
async execute(
|
||||
_authContext: AuthContext,
|
||||
_objectName: string,
|
||||
payload: CreateOneResolverArgs<WebhookWorkspaceEntity>,
|
||||
): Promise<CreateOneResolverArgs<WebhookWorkspaceEntity>> {
|
||||
this.webhookUrlValidationService.validateWebhookUrl(payload.data.targetUrl);
|
||||
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { WebhookCreateManyPreQueryHook } from 'src/modules/webhook/query-hooks/webhook-create-many.pre-query.hook';
|
||||
import { WebhookCreateOnePreQueryHook } from 'src/modules/webhook/query-hooks/webhook-create-one.pre-query.hook';
|
||||
import { WebhookUpdateManyPreQueryHook } from 'src/modules/webhook/query-hooks/webhook-update-many.pre-query.hook';
|
||||
import { WebhookUpdateOnePreQueryHook } from 'src/modules/webhook/query-hooks/webhook-update-one.pre-query.hook';
|
||||
import { WebhookUrlValidationService } from 'src/modules/webhook/query-hooks/webhook-url-validation.service';
|
||||
|
||||
@Module({
|
||||
providers: [
|
||||
WebhookUrlValidationService,
|
||||
WebhookCreateOnePreQueryHook,
|
||||
WebhookCreateManyPreQueryHook,
|
||||
WebhookUpdateOnePreQueryHook,
|
||||
WebhookUpdateManyPreQueryHook,
|
||||
],
|
||||
})
|
||||
export class WebhookQueryHookModule {}
|
||||
@ -0,0 +1,21 @@
|
||||
import { WorkspacePreQueryHookInstance } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/interfaces/workspace-query-hook.interface';
|
||||
import { UpdateManyResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
|
||||
|
||||
import {
|
||||
GraphqlQueryRunnerException,
|
||||
GraphqlQueryRunnerExceptionCode,
|
||||
} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
|
||||
import { WorkspaceQueryHook } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/decorators/workspace-query-hook.decorator';
|
||||
import { WebhookWorkspaceEntity } from 'src/modules/webhook/standard-objects/webhook.workspace-entity';
|
||||
|
||||
@WorkspaceQueryHook(`webhook.updateMany`)
|
||||
export class WebhookUpdateManyPreQueryHook
|
||||
implements WorkspacePreQueryHookInstance
|
||||
{
|
||||
async execute(): Promise<UpdateManyResolverArgs<WebhookWorkspaceEntity>> {
|
||||
throw new GraphqlQueryRunnerException(
|
||||
'Method not allowed.',
|
||||
GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
import { WorkspacePreQueryHookInstance } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/interfaces/workspace-query-hook.interface';
|
||||
import { UpdateOneResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
|
||||
|
||||
import { WorkspaceQueryHook } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/decorators/workspace-query-hook.decorator';
|
||||
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { WebhookUrlValidationService } from 'src/modules/webhook/query-hooks/webhook-url-validation.service';
|
||||
import { WebhookWorkspaceEntity } from 'src/modules/webhook/standard-objects/webhook.workspace-entity';
|
||||
|
||||
@WorkspaceQueryHook(`webhook.updateOne`)
|
||||
export class WebhookUpdateOnePreQueryHook
|
||||
implements WorkspacePreQueryHookInstance
|
||||
{
|
||||
constructor(
|
||||
private readonly webhookUrlValidationService: WebhookUrlValidationService,
|
||||
) {}
|
||||
|
||||
async execute(
|
||||
_authContext: AuthContext,
|
||||
_objectName: string,
|
||||
payload: UpdateOneResolverArgs<WebhookWorkspaceEntity>,
|
||||
): Promise<UpdateOneResolverArgs<WebhookWorkspaceEntity>> {
|
||||
if (payload.data.targetUrl) {
|
||||
this.webhookUrlValidationService.validateWebhookUrl(
|
||||
payload.data.targetUrl,
|
||||
);
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
GraphqlQueryRunnerException,
|
||||
GraphqlQueryRunnerExceptionCode,
|
||||
} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
|
||||
|
||||
@Injectable()
|
||||
export class WebhookUrlValidationService {
|
||||
validateWebhookUrl(targetUrl: string): void {
|
||||
let parsedUrl: URL;
|
||||
|
||||
try {
|
||||
parsedUrl = new URL(targetUrl);
|
||||
} catch {
|
||||
throw new GraphqlQueryRunnerException(
|
||||
`Invalid URL: missing scheme. URLs must include http:// or https://. Received: ${targetUrl}`,
|
||||
GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
|
||||
);
|
||||
}
|
||||
|
||||
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
|
||||
throw new GraphqlQueryRunnerException(
|
||||
`Invalid URL scheme. Only HTTP and HTTPS are allowed. Received: ${parsedUrl.protocol}`,
|
||||
GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user