Api keys and webhook migration to core (#13011)
TODO: check Zapier trigger records work as expected --------- Co-authored-by: Weiko <corentin@twenty.com>
This commit is contained in:
4
packages/twenty-server/@types/express.d.ts
vendored
4
packages/twenty-server/@types/express.d.ts
vendored
@ -1,13 +1,13 @@
|
||||
import { ApiKey } from 'src/engine/core-modules/api-key/api-key.entity';
|
||||
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { ApiKeyWorkspaceEntity } from 'src/modules/api-key/standard-objects/api-key.workspace-entity';
|
||||
|
||||
declare module 'express-serve-static-core' {
|
||||
interface Request {
|
||||
user?: User | null;
|
||||
apiKey?: ApiKeyWorkspaceEntity | null;
|
||||
apiKey?: ApiKey | null;
|
||||
userWorkspace?: UserWorkspace;
|
||||
workspace?: Workspace;
|
||||
workspaceId?: string;
|
||||
|
||||
3
packages/twenty-server/@types/jest.d.ts
vendored
3
packages/twenty-server/@types/jest.d.ts
vendored
@ -1,4 +1,5 @@
|
||||
import 'jest';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
declare module '@jest/types' {
|
||||
namespace Config {
|
||||
@ -10,6 +11,7 @@ declare module '@jest/types' {
|
||||
MEMBER_ACCESS_TOKEN: string;
|
||||
GUEST_ACCESS_TOKEN: string;
|
||||
API_KEY_ACCESS_TOKEN: string;
|
||||
testDataSource?: DataSource;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -23,6 +25,7 @@ declare global {
|
||||
const GUEST_ACCESS_TOKEN: string;
|
||||
const API_KEY_ACCESS_TOKEN: string;
|
||||
const WORKSPACE_AGNOSTIC_TOKEN: string;
|
||||
const testDataSource: DataSource;
|
||||
}
|
||||
|
||||
export {};
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { CronRegisterAllCommand } from 'src/database/commands/cron-register-all.command';
|
||||
import { DataSeedWorkspaceCommand } from 'src/database/commands/data-seed-dev-workspace.command';
|
||||
import { ConfirmationQuestion } from 'src/database/commands/questions/confirmation.question';
|
||||
import { UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/upgrade-version-command.module';
|
||||
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
|
||||
import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module';
|
||||
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
|
||||
import { FieldMetadataModule } from 'src/engine/metadata-modules/field-metadata/field-metadata.module';
|
||||
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
|
||||
@ -15,6 +15,8 @@ import { CalendarEventImportManagerModule } from 'src/modules/calendar/calendar-
|
||||
import { MessagingImportManagerModule } from 'src/modules/messaging/message-import-manager/messaging-import-manager.module';
|
||||
import { AutomatedTriggerModule } from 'src/modules/workflow/workflow-trigger/automated-trigger/automated-trigger.module';
|
||||
|
||||
import { DataSeedWorkspaceCommand } from './data-seed-dev-workspace.command';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
UpgradeVersionCommandModule,
|
||||
@ -24,7 +26,7 @@ import { AutomatedTriggerModule } from 'src/modules/workflow/workflow-trigger/au
|
||||
CalendarEventImportManagerModule,
|
||||
AutomatedTriggerModule,
|
||||
|
||||
// Only needed for the data seed command
|
||||
// Data seeding dependencies
|
||||
TypeORMModule,
|
||||
FieldMetadataModule,
|
||||
ObjectMetadataModule,
|
||||
@ -32,6 +34,7 @@ import { AutomatedTriggerModule } from 'src/modules/workflow/workflow-trigger/au
|
||||
WorkspaceManagerModule,
|
||||
DataSourceModule,
|
||||
WorkspaceCacheStorageModule,
|
||||
ApiKeyModule,
|
||||
],
|
||||
providers: [
|
||||
DataSeedWorkspaceCommand,
|
||||
|
||||
@ -0,0 +1,212 @@
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { Command } from 'nest-commander';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import {
|
||||
ActiveOrSuspendedWorkspacesMigrationCommandRunner,
|
||||
RunOnWorkspaceArgs,
|
||||
} from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
|
||||
import { ApiKey } from 'src/engine/core-modules/api-key/api-key.entity';
|
||||
import { ApiKeyService } from 'src/engine/core-modules/api-key/api-key.service';
|
||||
import { Webhook } from 'src/engine/core-modules/webhook/webhook.entity';
|
||||
import { WebhookService } from 'src/engine/core-modules/webhook/webhook.service';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
|
||||
import { ApiKeyWorkspaceEntity } from 'src/modules/api-key/standard-objects/api-key.workspace-entity';
|
||||
import { WebhookWorkspaceEntity } from 'src/modules/webhook/standard-objects/webhook.workspace-entity';
|
||||
|
||||
@Command({
|
||||
name: 'upgrade:1-3:migrate-api-keys-webhooks-to-core',
|
||||
description:
|
||||
'Migrate API keys and webhooks from workspace schemas to core schema',
|
||||
})
|
||||
export class MigrateApiKeysWebhooksToCoreCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
|
||||
constructor(
|
||||
@InjectRepository(Workspace, 'core')
|
||||
protected readonly workspaceRepository: Repository<Workspace>,
|
||||
@InjectRepository(ApiKey, 'core')
|
||||
private readonly coreApiKeyRepository: Repository<ApiKey>,
|
||||
@InjectRepository(Webhook, 'core')
|
||||
private readonly coreWebhookRepository: Repository<Webhook>,
|
||||
private readonly apiKeyService: ApiKeyService,
|
||||
private readonly webhookService: WebhookService,
|
||||
protected readonly twentyORMGlobalManager: TwentyORMGlobalManager,
|
||||
) {
|
||||
super(workspaceRepository, twentyORMGlobalManager);
|
||||
}
|
||||
|
||||
override async runOnWorkspace({
|
||||
index,
|
||||
total,
|
||||
workspaceId,
|
||||
options,
|
||||
}: RunOnWorkspaceArgs): Promise<void> {
|
||||
this.logger.log(
|
||||
`Migrating API keys and webhooks for workspace ${workspaceId} ${index + 1}/${total}`,
|
||||
);
|
||||
|
||||
try {
|
||||
await this.migrateApiKeys(workspaceId, options.dryRun);
|
||||
|
||||
await this.migrateWebhooks(workspaceId, options.dryRun);
|
||||
|
||||
this.logger.log(
|
||||
`Successfully migrated API keys and webhooks for workspace ${workspaceId}`,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to migrate API keys and webhooks for workspace ${workspaceId}: ${error.message}`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async migrateApiKeys(
|
||||
workspaceId: string,
|
||||
dryRun?: boolean,
|
||||
): Promise<void> {
|
||||
const workspaceApiKeyRepository =
|
||||
await this.twentyORMGlobalManager.getRepositoryForWorkspace<ApiKeyWorkspaceEntity>(
|
||||
workspaceId,
|
||||
'apiKey',
|
||||
{ shouldBypassPermissionChecks: true },
|
||||
);
|
||||
|
||||
const workspaceApiKeys = await workspaceApiKeyRepository.find({
|
||||
withDeleted: true,
|
||||
});
|
||||
|
||||
if (workspaceApiKeys.length === 0) {
|
||||
this.logger.log(`No API keys to migrate for workspace ${workspaceId}`);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`${dryRun ? 'DRY RUN: ' : ''}Found ${workspaceApiKeys.length} API keys to migrate for workspace ${workspaceId}`,
|
||||
);
|
||||
|
||||
if (dryRun) {
|
||||
workspaceApiKeys.forEach((apiKey) => {
|
||||
const deletedStatus = apiKey.deletedAt ? ' (DELETED)' : '';
|
||||
|
||||
this.logger.log(
|
||||
`DRY RUN: Would migrate API key ${apiKey.id} (${apiKey.name})${deletedStatus} from workspace ${workspaceId}`,
|
||||
);
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const existingCoreApiKeys = await this.coreApiKeyRepository.find({
|
||||
where: { workspaceId },
|
||||
select: ['id'],
|
||||
withDeleted: true,
|
||||
});
|
||||
const existingApiKeyIds = new Set(existingCoreApiKeys.map((ak) => ak.id));
|
||||
|
||||
for (const workspaceApiKey of workspaceApiKeys) {
|
||||
if (existingApiKeyIds.has(workspaceApiKey.id)) {
|
||||
this.logger.warn(
|
||||
`API key ${workspaceApiKey.id} already exists in core schema for workspace ${workspaceId}, skipping`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.apiKeyService.create({
|
||||
id: workspaceApiKey.id,
|
||||
name: workspaceApiKey.name,
|
||||
expiresAt: workspaceApiKey.expiresAt,
|
||||
revokedAt: workspaceApiKey.revokedAt
|
||||
? new Date(workspaceApiKey.revokedAt)
|
||||
: workspaceApiKey.deletedAt
|
||||
? new Date(workspaceApiKey.deletedAt)
|
||||
: undefined,
|
||||
workspaceId,
|
||||
createdAt: new Date(workspaceApiKey.createdAt),
|
||||
updatedAt: new Date(workspaceApiKey.updatedAt),
|
||||
});
|
||||
|
||||
const deletedStatus = workspaceApiKey.deletedAt ? ' (DELETED)' : '';
|
||||
|
||||
this.logger.log(
|
||||
`Migrated API key ${workspaceApiKey.id} (${workspaceApiKey.name})${deletedStatus} to core schema`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async migrateWebhooks(
|
||||
workspaceId: string,
|
||||
dryRun?: boolean,
|
||||
): Promise<void> {
|
||||
const workspaceWebhookRepository =
|
||||
await this.twentyORMGlobalManager.getRepositoryForWorkspace<WebhookWorkspaceEntity>(
|
||||
workspaceId,
|
||||
'webhook',
|
||||
{ shouldBypassPermissionChecks: true },
|
||||
);
|
||||
|
||||
const workspaceWebhooks = await workspaceWebhookRepository.find({
|
||||
withDeleted: true,
|
||||
});
|
||||
|
||||
if (workspaceWebhooks.length === 0) {
|
||||
this.logger.log(`No webhooks to migrate for workspace ${workspaceId}`);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`${dryRun ? 'DRY RUN: ' : ''}Found ${workspaceWebhooks.length} webhooks to migrate for workspace ${workspaceId}`,
|
||||
);
|
||||
|
||||
if (dryRun) {
|
||||
workspaceWebhooks.forEach((webhook) => {
|
||||
const deletedStatus = webhook.deletedAt ? ' (DELETED)' : '';
|
||||
|
||||
this.logger.log(
|
||||
`DRY RUN: Would migrate webhook ${webhook.id} (${webhook.targetUrl})${deletedStatus} from workspace ${workspaceId}`,
|
||||
);
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const existingCoreWebhooks = await this.coreWebhookRepository.find({
|
||||
where: { workspaceId },
|
||||
select: ['id'],
|
||||
withDeleted: true,
|
||||
});
|
||||
const existingWebhookIds = new Set(existingCoreWebhooks.map((wh) => wh.id));
|
||||
|
||||
for (const workspaceWebhook of workspaceWebhooks) {
|
||||
if (existingWebhookIds.has(workspaceWebhook.id)) {
|
||||
this.logger.warn(
|
||||
`Webhook ${workspaceWebhook.id} already exists in core schema for workspace ${workspaceId}, skipping`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.webhookService.create({
|
||||
id: workspaceWebhook.id,
|
||||
targetUrl: workspaceWebhook.targetUrl,
|
||||
operations: workspaceWebhook.operations,
|
||||
description: workspaceWebhook.description,
|
||||
secret: workspaceWebhook.secret,
|
||||
workspaceId,
|
||||
createdAt: new Date(workspaceWebhook.createdAt),
|
||||
updatedAt: new Date(workspaceWebhook.updatedAt),
|
||||
deletedAt: workspaceWebhook.deletedAt
|
||||
? new Date(workspaceWebhook.deletedAt)
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const deletedStatus = workspaceWebhook.deletedAt ? ' (DELETED)' : '';
|
||||
|
||||
this.logger.log(
|
||||
`Migrated webhook ${workspaceWebhook.id} (${workspaceWebhook.targetUrl})${deletedStatus} to core schema`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { MigrateApiKeysWebhooksToCoreCommand } from 'src/database/commands/upgrade-version-command/1-3/1-3-migrate-api-keys-webhooks-to-core.command';
|
||||
import { ApiKey } from 'src/engine/core-modules/api-key/api-key.entity';
|
||||
import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module';
|
||||
import { Webhook } from 'src/engine/core-modules/webhook/webhook.entity';
|
||||
import { WebhookModule } from 'src/engine/core-modules/webhook/webhook.module';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Workspace, ApiKey, Webhook], 'core'),
|
||||
WorkspaceDataSourceModule,
|
||||
ApiKeyModule,
|
||||
WebhookModule,
|
||||
],
|
||||
providers: [MigrateApiKeysWebhooksToCoreCommand],
|
||||
exports: [MigrateApiKeysWebhooksToCoreCommand],
|
||||
})
|
||||
export class V1_3_UpgradeVersionCommandModule {}
|
||||
@ -5,6 +5,7 @@ import { V0_54_UpgradeVersionCommandModule } from 'src/database/commands/upgrade
|
||||
import { V0_55_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/0-55/0-55-upgrade-version-command.module';
|
||||
import { V1_1_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/1-1/1-1-upgrade-version-command.module';
|
||||
import { V1_2_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/1-2/1-2-upgrade-version-command.module';
|
||||
import { V1_3_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/1-3/1-3-upgrade-version-command.module';
|
||||
import {
|
||||
DatabaseMigrationService,
|
||||
UpgradeCommand,
|
||||
@ -19,6 +20,7 @@ import { WorkspaceSyncMetadataModule } from 'src/engine/workspace-manager/worksp
|
||||
V0_55_UpgradeVersionCommandModule,
|
||||
V1_1_UpgradeVersionCommandModule,
|
||||
V1_2_UpgradeVersionCommandModule,
|
||||
V1_3_UpgradeVersionCommandModule,
|
||||
WorkspaceSyncMetadataModule,
|
||||
],
|
||||
providers: [DatabaseMigrationService, UpgradeCommand],
|
||||
|
||||
@ -24,6 +24,7 @@ import { DeduplicateIndexedFieldsCommand } from 'src/database/commands/upgrade-v
|
||||
import { FixSchemaArrayTypeCommand } from 'src/database/commands/upgrade-version-command/1-1/1-1-fix-schema-array-type.command';
|
||||
import { FixUpdateStandardFieldsIsLabelSyncedWithName } from 'src/database/commands/upgrade-version-command/1-1/1-1-fix-update-standard-field-is-label-synced-with-name.command';
|
||||
import { AddEnqueuedStatusToWorkflowRunCommand } from 'src/database/commands/upgrade-version-command/1-2/1-2-add-enqueued-status-to-workflow-run.command';
|
||||
import { MigrateApiKeysWebhooksToCoreCommand } from 'src/database/commands/upgrade-version-command/1-3/1-3-migrate-api-keys-webhooks-to-core.command';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
|
||||
@ -147,6 +148,9 @@ export class UpgradeCommand extends UpgradeCommandRunner {
|
||||
// 1.2 Commands
|
||||
protected readonly migrateWorkflowRunStatesCommand: MigrateWorkflowRunStatesCommand,
|
||||
protected readonly addEnqueuedStatusToWorkflowRunCommand: AddEnqueuedStatusToWorkflowRunCommand,
|
||||
|
||||
// 1.3 Commands
|
||||
protected readonly migrateApiKeysWebhooksToCoreCommand: MigrateApiKeysWebhooksToCoreCommand,
|
||||
) {
|
||||
super(
|
||||
workspaceRepository,
|
||||
@ -200,6 +204,11 @@ export class UpgradeCommand extends UpgradeCommandRunner {
|
||||
afterSyncMetadata: [this.migrateWorkflowRunStatesCommand],
|
||||
};
|
||||
|
||||
const commands_130: VersionCommands = {
|
||||
beforeSyncMetadata: [this.migrateApiKeysWebhooksToCoreCommand],
|
||||
afterSyncMetadata: [],
|
||||
};
|
||||
|
||||
this.allCommands = {
|
||||
'0.53.0': commands_053,
|
||||
'0.54.0': commands_054,
|
||||
@ -208,6 +217,7 @@ export class UpgradeCommand extends UpgradeCommandRunner {
|
||||
'1.0.0': commands_100,
|
||||
'1.1.0': commands_110,
|
||||
'1.2.0': commands_120,
|
||||
'1.3.0': commands_130,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,41 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddApiKeysAndWebhookToCore1751690946522
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddApiKeysAndWebhookToCore1751690946522';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "core"."apiKey" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "expiresAt" TIMESTAMP WITH TIME ZONE NOT NULL, "revokedAt" TIMESTAMP WITH TIME ZONE, "workspaceId" uuid NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_2ae3a5e8e04fb402b2dc8d6ce4b" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_API_KEY_WORKSPACE_ID" ON "core"."apiKey" ("workspaceId") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "core"."webhook" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "targetUrl" character varying NOT NULL, "operations" text array NOT NULL DEFAULT '{*.*}', "description" character varying, "secret" character varying NOT NULL, "workspaceId" uuid NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "deletedAt" TIMESTAMP WITH TIME ZONE, CONSTRAINT "PK_e6765510c2d078db49632b59020" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_WEBHOOK_WORKSPACE_ID" ON "core"."webhook" ("workspaceId") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."apiKey" ADD CONSTRAINT "FK_c8b3efa54a29aa873043e72fb1d" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."webhook" ADD CONSTRAINT "FK_597ab5e7de76f1836b8fd80d6b9" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."webhook" DROP CONSTRAINT "FK_597ab5e7de76f1836b8fd80d6b9"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."apiKey" DROP CONSTRAINT "FK_c8b3efa54a29aa873043e72fb1d"`,
|
||||
);
|
||||
await queryRunner.query(`DROP INDEX "core"."IDX_WEBHOOK_WORKSPACE_ID"`);
|
||||
await queryRunner.query(`DROP TABLE "core"."webhook"`);
|
||||
await queryRunner.query(`DROP INDEX "core"."IDX_API_KEY_WORKSPACE_ID"`);
|
||||
await queryRunner.query(`DROP TABLE "core"."apiKey"`);
|
||||
}
|
||||
}
|
||||
@ -9,7 +9,6 @@ import { BlocklistQueryHookModule } from 'src/modules/blocklist/query-hooks/bloc
|
||||
import { CalendarQueryHookModule } from 'src/modules/calendar/common/query-hooks/calendar-query-hook.module';
|
||||
import { ConnectedAccountQueryHookModule } from 'src/modules/connected-account/query-hooks/connected-account-query-hook.module';
|
||||
import { MessagingQueryHookModule } from 'src/modules/messaging/common/query-hooks/messaging-query-hook.module';
|
||||
import { WebhookQueryHookModule } from 'src/modules/webhook/query-hooks/webhook-query-hook.module';
|
||||
import { WorkspaceMemberQueryHookModule } from 'src/modules/workspace-member/query-hooks/workspace-member-query-hook.module';
|
||||
|
||||
@Module({
|
||||
@ -18,7 +17,6 @@ import { WorkspaceMemberQueryHookModule } from 'src/modules/workspace-member/que
|
||||
CalendarQueryHookModule,
|
||||
ConnectedAccountQueryHookModule,
|
||||
BlocklistQueryHookModule,
|
||||
WebhookQueryHookModule,
|
||||
WorkspaceMemberQueryHookModule,
|
||||
DiscoveryModule,
|
||||
],
|
||||
|
||||
@ -10,9 +10,17 @@ import { RestoreManyResolverFactory } from 'src/engine/api/graphql/workspace-res
|
||||
import { RestoreOneResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/restore-one-resolver.factory';
|
||||
import { UpdateManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/update-many-resolver.factory';
|
||||
import { WorkspaceResolverBuilderService } from 'src/engine/api/graphql/workspace-resolver-builder/workspace-resolver-builder.service';
|
||||
import {
|
||||
AuthException,
|
||||
AuthExceptionCode,
|
||||
} from 'src/engine/core-modules/auth/auth.exception';
|
||||
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||
import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
|
||||
import { metadataArgsStorage } from 'src/engine/twenty-orm/storage/metadata-args.storage';
|
||||
import { getResolverName } from 'src/engine/utils/get-resolver-name.util';
|
||||
import { standardObjectMetadataDefinitions } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-objects';
|
||||
import { isGatedAndNotEnabled } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/is-gate-and-not-enabled.util';
|
||||
|
||||
import { CreateManyResolverFactory } from './factories/create-many-resolver.factory';
|
||||
import { CreateOneResolverFactory } from './factories/create-one-resolver.factory';
|
||||
@ -46,6 +54,7 @@ export class WorkspaceResolverFactory {
|
||||
private readonly restoreManyResolverFactory: RestoreManyResolverFactory,
|
||||
private readonly destroyManyResolverFactory: DestroyManyResolverFactory,
|
||||
private readonly workspaceResolverBuilderService: WorkspaceResolverBuilderService,
|
||||
private readonly featureFlagService: FeatureFlagService,
|
||||
) {}
|
||||
|
||||
async create(
|
||||
@ -76,9 +85,44 @@ export class WorkspaceResolverFactory {
|
||||
Mutation: {},
|
||||
};
|
||||
|
||||
const workspaceId = authContext.workspace?.id;
|
||||
|
||||
if (!workspaceId) {
|
||||
throw new AuthException(
|
||||
'Unauthenticated',
|
||||
AuthExceptionCode.UNAUTHENTICATED,
|
||||
);
|
||||
}
|
||||
|
||||
const workspaceFeatureFlagsMap =
|
||||
await this.featureFlagService.getWorkspaceFeatureFlagsMap(workspaceId);
|
||||
|
||||
for (const objectMetadata of Object.values(objectMetadataMaps.byId).filter(
|
||||
isDefined,
|
||||
)) {
|
||||
const workspaceEntity = standardObjectMetadataDefinitions.find(
|
||||
(entity) => {
|
||||
const entityMetadata = metadataArgsStorage.filterEntities(entity);
|
||||
|
||||
return entityMetadata?.standardId === objectMetadata.standardId;
|
||||
},
|
||||
);
|
||||
|
||||
if (workspaceEntity) {
|
||||
const entityMetadata =
|
||||
metadataArgsStorage.filterEntities(workspaceEntity);
|
||||
|
||||
if (
|
||||
isGatedAndNotEnabled(
|
||||
entityMetadata?.gate,
|
||||
workspaceFeatureFlagsMap,
|
||||
'graphql',
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate query resolvers
|
||||
for (const methodName of workspaceResolverBuilderMethods.queries) {
|
||||
const resolverName = getResolverName(objectMetadata, methodName);
|
||||
|
||||
@ -9,14 +9,22 @@ import { ScalarsExplorerService } from 'src/engine/api/graphql/services/scalars-
|
||||
import { workspaceResolverBuilderMethodNames } from 'src/engine/api/graphql/workspace-resolver-builder/factories/factories';
|
||||
import { WorkspaceResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/workspace-resolver.factory';
|
||||
import { WorkspaceGraphQLSchemaFactory } from 'src/engine/api/graphql/workspace-schema-builder/workspace-graphql-schema.factory';
|
||||
import {
|
||||
AuthException,
|
||||
AuthExceptionCode,
|
||||
} from 'src/engine/core-modules/auth/auth.exception';
|
||||
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
|
||||
import {
|
||||
WorkspaceMetadataCacheException,
|
||||
WorkspaceMetadataCacheExceptionCode,
|
||||
} from 'src/engine/metadata-modules/workspace-metadata-cache/exceptions/workspace-metadata-cache.exception';
|
||||
import { WorkspaceMetadataCacheService } from 'src/engine/metadata-modules/workspace-metadata-cache/services/workspace-metadata-cache.service';
|
||||
import { metadataArgsStorage } from 'src/engine/twenty-orm/storage/metadata-args.storage';
|
||||
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
|
||||
import { standardObjectMetadataDefinitions } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-objects';
|
||||
import { isGatedAndNotEnabled } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/is-gate-and-not-enabled.util';
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceSchemaFactory {
|
||||
@ -27,6 +35,7 @@ export class WorkspaceSchemaFactory {
|
||||
private readonly workspaceResolverFactory: WorkspaceResolverFactory,
|
||||
private readonly workspaceCacheStorageService: WorkspaceCacheStorageService,
|
||||
private readonly workspaceMetadataCacheService: WorkspaceMetadataCacheService,
|
||||
private readonly featureFlagService: FeatureFlagService,
|
||||
) {}
|
||||
|
||||
async createGraphQLSchema(authContext: AuthContext): Promise<GraphQLSchema> {
|
||||
@ -57,13 +66,49 @@ export class WorkspaceSchemaFactory {
|
||||
);
|
||||
}
|
||||
|
||||
const workspaceId = authContext.workspace.id;
|
||||
|
||||
if (!workspaceId) {
|
||||
throw new AuthException(
|
||||
'Unauthenticated',
|
||||
AuthExceptionCode.UNAUTHENTICATED,
|
||||
);
|
||||
}
|
||||
|
||||
const workspaceFeatureFlagsMap =
|
||||
await this.featureFlagService.getWorkspaceFeatureFlagsMap(workspaceId);
|
||||
|
||||
const objectMetadataCollection = Object.values(objectMetadataMaps.byId)
|
||||
.filter(isDefined)
|
||||
.map((objectMetadataItem) => ({
|
||||
...objectMetadataItem,
|
||||
fields: Object.values(objectMetadataItem.fieldsById),
|
||||
indexes: objectMetadataItem.indexMetadatas,
|
||||
}));
|
||||
}))
|
||||
.filter((objectMetadata) => {
|
||||
// Find the corresponding workspace entity for this object metadata
|
||||
const workspaceEntity = standardObjectMetadataDefinitions.find(
|
||||
(entity) => {
|
||||
const entityMetadata = metadataArgsStorage.filterEntities(entity);
|
||||
|
||||
return entityMetadata?.standardId === objectMetadata.standardId;
|
||||
},
|
||||
);
|
||||
|
||||
if (!workspaceEntity) {
|
||||
return true; // Include non-workspace entities (custom objects, etc.)
|
||||
}
|
||||
|
||||
const entityMetadata =
|
||||
metadataArgsStorage.filterEntities(workspaceEntity);
|
||||
|
||||
// Filter out entities that are GraphQL-gated and not enabled
|
||||
return !isGatedAndNotEnabled(
|
||||
entityMetadata?.gate,
|
||||
workspaceFeatureFlagsMap,
|
||||
'graphql',
|
||||
);
|
||||
});
|
||||
|
||||
// Get typeDefs from cache
|
||||
let typeDefs = await this.workspaceCacheStorageService.getGraphQLTypeDefs(
|
||||
|
||||
@ -2,6 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
import { CreatedByFromAuthContextService } from 'src/engine/core-modules/actor/services/created-by-from-auth-context.service';
|
||||
import { ApiKey } from 'src/engine/core-modules/api-key/api-key.entity';
|
||||
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
@ -12,12 +13,11 @@ import {
|
||||
import { FullNameMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/full-name.composite-type';
|
||||
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
|
||||
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
|
||||
import { ApiKeyWorkspaceEntity } from 'src/modules/api-key/standard-objects/api-key.workspace-entity';
|
||||
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
|
||||
|
||||
type TestingAuthContext = Omit<AuthContext, 'workspace' | 'apiKey' | 'user'> & {
|
||||
workspace: Partial<Workspace>;
|
||||
apiKey?: Partial<ApiKeyWorkspaceEntity>;
|
||||
apiKey?: Partial<ApiKey>;
|
||||
user?: Partial<User>;
|
||||
};
|
||||
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import { ApiKey } from 'src/engine/core-modules/api-key/api-key.entity';
|
||||
import {
|
||||
ActorMetadata,
|
||||
FieldActorSource,
|
||||
} from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type';
|
||||
import { ApiKeyWorkspaceEntity } from 'src/modules/api-key/standard-objects/api-key.workspace-entity';
|
||||
|
||||
type BuildCreatedByFromApiKeyArgs = {
|
||||
apiKey: ApiKeyWorkspaceEntity;
|
||||
apiKey: ApiKey;
|
||||
};
|
||||
export const buildCreatedByFromApiKey = ({
|
||||
apiKey,
|
||||
|
||||
@ -0,0 +1,57 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { IDField } from '@ptc-org/nestjs-query-graphql';
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
Relation,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
@Index('IDX_API_KEY_WORKSPACE_ID', ['workspaceId'])
|
||||
@Entity({ name: 'apiKey', schema: 'core' })
|
||||
@ObjectType('ApiKey')
|
||||
export class ApiKey {
|
||||
@IDField(() => UUIDScalarType)
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Field()
|
||||
@Column()
|
||||
name: string;
|
||||
|
||||
@Field(() => Date)
|
||||
@Column({ type: 'timestamptz' })
|
||||
expiresAt: Date;
|
||||
|
||||
@Field(() => Date, { nullable: true })
|
||||
@Column({ type: 'timestamptz', nullable: true })
|
||||
revokedAt?: Date | null;
|
||||
|
||||
@Field()
|
||||
@Column('uuid')
|
||||
workspaceId: string;
|
||||
|
||||
@Field(() => Date)
|
||||
@CreateDateColumn({ type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@Field(() => Date)
|
||||
@UpdateDateColumn({ type: 'timestamptz' })
|
||||
updatedAt: Date;
|
||||
|
||||
@Field(() => Workspace)
|
||||
@ManyToOne(() => Workspace, (workspace) => workspace.apiKeys, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn({ name: 'workspaceId' })
|
||||
workspace: Relation<Workspace>;
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
import { CustomException } from 'src/utils/custom-exception';
|
||||
|
||||
export class ApiKeyException extends CustomException {
|
||||
declare code: ApiKeyExceptionCode;
|
||||
constructor(
|
||||
message: string,
|
||||
code: ApiKeyExceptionCode,
|
||||
{ userFriendlyMessage }: { userFriendlyMessage?: string } = {},
|
||||
) {
|
||||
super(message, code, userFriendlyMessage);
|
||||
}
|
||||
}
|
||||
|
||||
export enum ApiKeyExceptionCode {
|
||||
API_KEY_NOT_FOUND = 'API_KEY_NOT_FOUND',
|
||||
API_KEY_REVOKED = 'API_KEY_REVOKED',
|
||||
API_KEY_EXPIRED = 'API_KEY_EXPIRED',
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { ApiKey } from 'src/engine/core-modules/api-key/api-key.entity';
|
||||
import { ApiKeyResolver } from 'src/engine/core-modules/api-key/api-key.resolver';
|
||||
import { ApiKeyService } from 'src/engine/core-modules/api-key/api-key.service';
|
||||
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([ApiKey], 'core'), JwtModule],
|
||||
providers: [ApiKeyService, ApiKeyResolver],
|
||||
exports: [ApiKeyService, TypeOrmModule],
|
||||
})
|
||||
export class ApiKeyModule {}
|
||||
@ -0,0 +1,82 @@
|
||||
import { UseGuards } from '@nestjs/common';
|
||||
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { CreateApiKeyDTO } from 'src/engine/core-modules/api-key/dtos/create-api-key.dto';
|
||||
import { GetApiKeyDTO } from 'src/engine/core-modules/api-key/dtos/get-api-key.dto';
|
||||
import { RevokeApiKeyDTO } from 'src/engine/core-modules/api-key/dtos/revoke-api-key.dto';
|
||||
import { UpdateApiKeyDTO } from 'src/engine/core-modules/api-key/dtos/update-api-key.dto';
|
||||
import { apiKeyGraphqlApiExceptionHandler } from 'src/engine/core-modules/api-key/utils/api-key-graphql-api-exception-handler.util';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
|
||||
import { ApiKey } from './api-key.entity';
|
||||
import { ApiKeyService } from './api-key.service';
|
||||
|
||||
@Resolver(() => ApiKey)
|
||||
@UseGuards(WorkspaceAuthGuard)
|
||||
export class ApiKeyResolver {
|
||||
constructor(private readonly apiKeyService: ApiKeyService) {}
|
||||
|
||||
@Query(() => [ApiKey])
|
||||
async apiKeys(@AuthWorkspace() workspace: Workspace): Promise<ApiKey[]> {
|
||||
return this.apiKeyService.findActiveByWorkspaceId(workspace.id);
|
||||
}
|
||||
|
||||
@Query(() => ApiKey, { nullable: true })
|
||||
async apiKey(
|
||||
@Args('input') input: GetApiKeyDTO,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
): Promise<ApiKey | null> {
|
||||
try {
|
||||
const apiKey = await this.apiKeyService.findById(input.id, workspace.id);
|
||||
|
||||
if (!apiKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return apiKey;
|
||||
} catch (error) {
|
||||
apiKeyGraphqlApiExceptionHandler(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@Mutation(() => ApiKey)
|
||||
async createApiKey(
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
@Args('input') input: CreateApiKeyDTO,
|
||||
): Promise<ApiKey> {
|
||||
return this.apiKeyService.create({
|
||||
name: input.name,
|
||||
expiresAt: new Date(input.expiresAt),
|
||||
revokedAt: input.revokedAt ? new Date(input.revokedAt) : undefined,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
}
|
||||
|
||||
@Mutation(() => ApiKey, { nullable: true })
|
||||
async updateApiKey(
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
@Args('input') input: UpdateApiKeyDTO,
|
||||
): Promise<ApiKey | null> {
|
||||
const updateData: Partial<ApiKey> = {};
|
||||
|
||||
if (input.name !== undefined) updateData.name = input.name;
|
||||
if (input.expiresAt !== undefined)
|
||||
updateData.expiresAt = new Date(input.expiresAt);
|
||||
if (input.revokedAt !== undefined) {
|
||||
updateData.revokedAt = input.revokedAt ? new Date(input.revokedAt) : null;
|
||||
}
|
||||
|
||||
return this.apiKeyService.update(input.id, workspace.id, updateData);
|
||||
}
|
||||
|
||||
@Mutation(() => ApiKey, { nullable: true })
|
||||
async revokeApiKey(
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
@Args('input') input: RevokeApiKeyDTO,
|
||||
): Promise<ApiKey | null> {
|
||||
return this.apiKeyService.revoke(input.id, workspace.id);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,383 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
import { IsNull } from 'typeorm';
|
||||
|
||||
import {
|
||||
ApiKeyException,
|
||||
ApiKeyExceptionCode,
|
||||
} from 'src/engine/core-modules/api-key/api-key.exception';
|
||||
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
|
||||
|
||||
import { ApiKey } from './api-key.entity';
|
||||
import { ApiKeyService } from './api-key.service';
|
||||
|
||||
describe('ApiKeyService', () => {
|
||||
let service: ApiKeyService;
|
||||
let mockApiKeyRepository: any;
|
||||
let mockJwtWrapperService: any;
|
||||
|
||||
const mockWorkspaceId = 'workspace-123';
|
||||
const mockApiKeyId = 'api-key-456';
|
||||
|
||||
const mockApiKey: ApiKey = {
|
||||
id: mockApiKeyId,
|
||||
name: 'Test API Key',
|
||||
expiresAt: new Date('2025-12-31'),
|
||||
revokedAt: undefined,
|
||||
workspaceId: mockWorkspaceId,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
updatedAt: new Date('2024-01-01'),
|
||||
workspace: {} as any,
|
||||
};
|
||||
|
||||
const mockRevokedApiKey: ApiKey = {
|
||||
...mockApiKey,
|
||||
id: 'revoked-api-key',
|
||||
revokedAt: new Date('2024-06-01'),
|
||||
};
|
||||
|
||||
const mockExpiredApiKey: ApiKey = {
|
||||
...mockApiKey,
|
||||
id: 'expired-api-key',
|
||||
expiresAt: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
mockApiKeyRepository = {
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
find: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
update: jest.fn(),
|
||||
};
|
||||
|
||||
mockJwtWrapperService = {
|
||||
generateAppSecret: jest.fn(),
|
||||
sign: jest.fn(),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
ApiKeyService,
|
||||
{
|
||||
provide: getRepositoryToken(ApiKey, 'core'),
|
||||
useValue: mockApiKeyRepository,
|
||||
},
|
||||
{
|
||||
provide: JwtWrapperService,
|
||||
useValue: mockJwtWrapperService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<ApiKeyService>(ApiKeyService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should create and save an API key', async () => {
|
||||
const apiKeyData = {
|
||||
name: 'New API Key',
|
||||
expiresAt: new Date('2025-12-31'),
|
||||
workspaceId: mockWorkspaceId,
|
||||
};
|
||||
|
||||
mockApiKeyRepository.create.mockReturnValue(mockApiKey);
|
||||
mockApiKeyRepository.save.mockResolvedValue(mockApiKey);
|
||||
|
||||
const result = await service.create(apiKeyData);
|
||||
|
||||
expect(mockApiKeyRepository.create).toHaveBeenCalledWith(apiKeyData);
|
||||
expect(mockApiKeyRepository.save).toHaveBeenCalledWith(mockApiKey);
|
||||
expect(result).toEqual(mockApiKey);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
it('should find an API key by ID and workspace ID', async () => {
|
||||
mockApiKeyRepository.findOne.mockResolvedValue(mockApiKey);
|
||||
|
||||
const result = await service.findById(mockApiKeyId, mockWorkspaceId);
|
||||
|
||||
expect(mockApiKeyRepository.findOne).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: mockApiKeyId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
},
|
||||
});
|
||||
expect(result).toEqual(mockApiKey);
|
||||
});
|
||||
|
||||
it('should return null if API key not found', async () => {
|
||||
mockApiKeyRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
const result = await service.findById('non-existent', mockWorkspaceId);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByWorkspaceId', () => {
|
||||
it('should find all API keys for a workspace', async () => {
|
||||
const mockApiKeys = [mockApiKey, { ...mockApiKey, id: 'another-key' }];
|
||||
|
||||
mockApiKeyRepository.find.mockResolvedValue(mockApiKeys);
|
||||
|
||||
const result = await service.findByWorkspaceId(mockWorkspaceId);
|
||||
|
||||
expect(mockApiKeyRepository.find).toHaveBeenCalledWith({
|
||||
where: {
|
||||
workspaceId: mockWorkspaceId,
|
||||
},
|
||||
});
|
||||
expect(result).toEqual(mockApiKeys);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findActiveByWorkspaceId', () => {
|
||||
it('should find only active (non-revoked) API keys', async () => {
|
||||
const activeApiKeys = [mockApiKey];
|
||||
|
||||
mockApiKeyRepository.find.mockResolvedValue(activeApiKeys);
|
||||
|
||||
const result = await service.findActiveByWorkspaceId(mockWorkspaceId);
|
||||
|
||||
expect(mockApiKeyRepository.find).toHaveBeenCalledWith({
|
||||
where: {
|
||||
workspaceId: mockWorkspaceId,
|
||||
revokedAt: IsNull(),
|
||||
},
|
||||
});
|
||||
expect(result).toEqual(activeApiKeys);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should update an existing API key', async () => {
|
||||
const updateData = { name: 'Updated API Key' };
|
||||
const updatedApiKey = { ...mockApiKey, ...updateData };
|
||||
|
||||
mockApiKeyRepository.findOne
|
||||
.mockResolvedValueOnce(mockApiKey)
|
||||
.mockResolvedValueOnce(updatedApiKey);
|
||||
mockApiKeyRepository.update.mockResolvedValue({ affected: 1 });
|
||||
|
||||
const result = await service.update(
|
||||
mockApiKeyId,
|
||||
mockWorkspaceId,
|
||||
updateData,
|
||||
);
|
||||
|
||||
expect(mockApiKeyRepository.update).toHaveBeenCalledWith(
|
||||
mockApiKeyId,
|
||||
updateData,
|
||||
);
|
||||
expect(result).toEqual(updatedApiKey);
|
||||
});
|
||||
|
||||
it('should return null if API key to update does not exist', async () => {
|
||||
mockApiKeyRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
const result = await service.update('non-existent', mockWorkspaceId, {
|
||||
name: 'Updated',
|
||||
});
|
||||
|
||||
expect(mockApiKeyRepository.update).not.toHaveBeenCalled();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('revoke', () => {
|
||||
it('should revoke an API key by setting revokedAt', async () => {
|
||||
const revokedApiKey = { ...mockApiKey, revokedAt: new Date() };
|
||||
|
||||
mockApiKeyRepository.findOne
|
||||
.mockResolvedValueOnce(mockApiKey)
|
||||
.mockResolvedValueOnce(revokedApiKey);
|
||||
mockApiKeyRepository.update.mockResolvedValue({ affected: 1 });
|
||||
|
||||
const result = await service.revoke(mockApiKeyId, mockWorkspaceId);
|
||||
|
||||
expect(mockApiKeyRepository.update).toHaveBeenCalledWith(
|
||||
mockApiKeyId,
|
||||
expect.objectContaining({
|
||||
revokedAt: expect.any(Date),
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual(revokedApiKey);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateApiKey', () => {
|
||||
it('should validate an active API key', async () => {
|
||||
mockApiKeyRepository.findOne.mockResolvedValue(mockApiKey);
|
||||
|
||||
const result = await service.validateApiKey(
|
||||
mockApiKeyId,
|
||||
mockWorkspaceId,
|
||||
);
|
||||
|
||||
expect(result).toEqual(mockApiKey);
|
||||
});
|
||||
|
||||
it('should throw ApiKeyException if API key does not exist', async () => {
|
||||
mockApiKeyRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.validateApiKey('non-existent', mockWorkspaceId),
|
||||
).rejects.toThrow(ApiKeyException);
|
||||
|
||||
await expect(
|
||||
service.validateApiKey('non-existent', mockWorkspaceId),
|
||||
).rejects.toMatchObject({
|
||||
code: ApiKeyExceptionCode.API_KEY_NOT_FOUND,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw ApiKeyException if API key is revoked', async () => {
|
||||
mockApiKeyRepository.findOne.mockResolvedValue(mockRevokedApiKey);
|
||||
|
||||
await expect(
|
||||
service.validateApiKey(mockRevokedApiKey.id, mockWorkspaceId),
|
||||
).rejects.toThrow(ApiKeyException);
|
||||
|
||||
await expect(
|
||||
service.validateApiKey(mockRevokedApiKey.id, mockWorkspaceId),
|
||||
).rejects.toMatchObject({
|
||||
code: ApiKeyExceptionCode.API_KEY_REVOKED,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw ApiKeyException if API key is expired', async () => {
|
||||
mockApiKeyRepository.findOne.mockResolvedValue(mockExpiredApiKey);
|
||||
|
||||
await expect(
|
||||
service.validateApiKey(mockExpiredApiKey.id, mockWorkspaceId),
|
||||
).rejects.toThrow(ApiKeyException);
|
||||
|
||||
await expect(
|
||||
service.validateApiKey(mockExpiredApiKey.id, mockWorkspaceId),
|
||||
).rejects.toMatchObject({
|
||||
code: ApiKeyExceptionCode.API_KEY_EXPIRED,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateApiKeyToken', () => {
|
||||
const mockSecret = 'mock-secret';
|
||||
const mockToken = 'mock-jwt-token';
|
||||
|
||||
beforeEach(() => {
|
||||
mockJwtWrapperService.generateAppSecret.mockReturnValue(mockSecret);
|
||||
mockJwtWrapperService.sign.mockReturnValue(mockToken);
|
||||
});
|
||||
|
||||
it('should generate a JWT token for a valid API key', async () => {
|
||||
mockApiKeyRepository.findOne.mockResolvedValue(mockApiKey);
|
||||
const expiresAt = new Date('2025-12-31');
|
||||
|
||||
const result = await service.generateApiKeyToken(
|
||||
mockWorkspaceId,
|
||||
mockApiKeyId,
|
||||
expiresAt,
|
||||
);
|
||||
|
||||
expect(mockJwtWrapperService.generateAppSecret).toHaveBeenCalledWith(
|
||||
JwtTokenTypeEnum.ACCESS,
|
||||
mockWorkspaceId,
|
||||
);
|
||||
expect(mockJwtWrapperService.sign).toHaveBeenCalledWith(
|
||||
{
|
||||
sub: mockWorkspaceId,
|
||||
type: JwtTokenTypeEnum.API_KEY,
|
||||
workspaceId: mockWorkspaceId,
|
||||
},
|
||||
{
|
||||
secret: mockSecret,
|
||||
expiresIn: expect.any(Number),
|
||||
jwtid: mockApiKeyId,
|
||||
},
|
||||
);
|
||||
expect(result).toEqual({ token: mockToken });
|
||||
});
|
||||
|
||||
it('should return undefined if no API key ID provided', async () => {
|
||||
const result = await service.generateApiKeyToken(mockWorkspaceId);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(mockJwtWrapperService.generateAppSecret).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use default expiration if no expiresAt provided', async () => {
|
||||
mockApiKeyRepository.findOne.mockResolvedValue(mockApiKey);
|
||||
|
||||
await service.generateApiKeyToken(mockWorkspaceId, mockApiKeyId);
|
||||
|
||||
expect(mockJwtWrapperService.sign).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.objectContaining({
|
||||
expiresIn: '100y',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('utility methods', () => {
|
||||
describe('isExpired', () => {
|
||||
it('should return true for expired API key', () => {
|
||||
const result = service.isExpired(mockExpiredApiKey);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-expired API key', () => {
|
||||
const result = service.isExpired(mockApiKey);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isRevoked', () => {
|
||||
it('should return true for revoked API key', () => {
|
||||
const result = service.isRevoked(mockRevokedApiKey);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-revoked API key', () => {
|
||||
const result = service.isRevoked(mockApiKey);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isActive', () => {
|
||||
it('should return true for active API key', () => {
|
||||
const result = service.isActive(mockApiKey);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for revoked API key', () => {
|
||||
const result = service.isActive(mockRevokedApiKey);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for expired API key', () => {
|
||||
const result = service.isActive(mockExpiredApiKey);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,165 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { IsNull, Repository } from 'typeorm';
|
||||
|
||||
import { ApiKey } from 'src/engine/core-modules/api-key/api-key.entity';
|
||||
import {
|
||||
ApiKeyException,
|
||||
ApiKeyExceptionCode,
|
||||
} from 'src/engine/core-modules/api-key/api-key.exception';
|
||||
import { ApiKeyToken } from 'src/engine/core-modules/auth/dto/token.entity';
|
||||
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
|
||||
|
||||
@Injectable()
|
||||
export class ApiKeyService {
|
||||
constructor(
|
||||
@InjectRepository(ApiKey, 'core')
|
||||
private readonly apiKeyRepository: Repository<ApiKey>,
|
||||
private readonly jwtWrapperService: JwtWrapperService,
|
||||
) {}
|
||||
|
||||
async create(apiKeyData: Partial<ApiKey>): Promise<ApiKey> {
|
||||
const apiKey = this.apiKeyRepository.create(apiKeyData);
|
||||
|
||||
return await this.apiKeyRepository.save(apiKey);
|
||||
}
|
||||
|
||||
async findById(id: string, workspaceId: string): Promise<ApiKey | null> {
|
||||
return await this.apiKeyRepository.findOne({
|
||||
where: {
|
||||
id,
|
||||
workspaceId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findByWorkspaceId(workspaceId: string): Promise<ApiKey[]> {
|
||||
return await this.apiKeyRepository.find({
|
||||
where: {
|
||||
workspaceId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findActiveByWorkspaceId(workspaceId: string): Promise<ApiKey[]> {
|
||||
return await this.apiKeyRepository.find({
|
||||
where: {
|
||||
workspaceId,
|
||||
revokedAt: IsNull(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
workspaceId: string,
|
||||
updateData: Partial<ApiKey>,
|
||||
): Promise<ApiKey | null> {
|
||||
const apiKey = await this.findById(id, workspaceId);
|
||||
|
||||
if (!apiKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await this.apiKeyRepository.update(id, updateData);
|
||||
|
||||
return this.findById(id, workspaceId);
|
||||
}
|
||||
|
||||
async revoke(id: string, workspaceId: string): Promise<ApiKey | null> {
|
||||
return await this.update(id, workspaceId, {
|
||||
revokedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
async validateApiKey(id: string, workspaceId: string): Promise<ApiKey> {
|
||||
const apiKey = await this.findById(id, workspaceId);
|
||||
|
||||
if (!apiKey) {
|
||||
throw new ApiKeyException(
|
||||
`API Key with id ${id} not found`,
|
||||
ApiKeyExceptionCode.API_KEY_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
if (apiKey.revokedAt) {
|
||||
throw new ApiKeyException(
|
||||
'This API Key is revoked',
|
||||
ApiKeyExceptionCode.API_KEY_REVOKED,
|
||||
{
|
||||
userFriendlyMessage:
|
||||
'This API Key has been revoked and can no longer be used.',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (new Date() > apiKey.expiresAt) {
|
||||
throw new ApiKeyException(
|
||||
'This API Key has expired',
|
||||
ApiKeyExceptionCode.API_KEY_EXPIRED,
|
||||
{
|
||||
userFriendlyMessage:
|
||||
'This API Key has expired. Please create a new one.',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
async generateApiKeyToken(
|
||||
workspaceId: string,
|
||||
apiKeyId?: string,
|
||||
expiresAt?: Date | string,
|
||||
): Promise<Pick<ApiKeyToken, 'token'> | undefined> {
|
||||
if (!apiKeyId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.validateApiKey(apiKeyId, workspaceId);
|
||||
|
||||
const secret = this.jwtWrapperService.generateAppSecret(
|
||||
JwtTokenTypeEnum.ACCESS,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
let expiresIn: string | number;
|
||||
|
||||
if (expiresAt) {
|
||||
expiresIn = Math.floor(
|
||||
(new Date(expiresAt).getTime() - new Date().getTime()) / 1000,
|
||||
);
|
||||
} else {
|
||||
expiresIn = '100y';
|
||||
}
|
||||
|
||||
const token = this.jwtWrapperService.sign(
|
||||
{
|
||||
sub: workspaceId,
|
||||
type: JwtTokenTypeEnum.API_KEY,
|
||||
workspaceId,
|
||||
},
|
||||
{
|
||||
secret,
|
||||
expiresIn,
|
||||
jwtid: apiKeyId,
|
||||
},
|
||||
);
|
||||
|
||||
return { token };
|
||||
}
|
||||
|
||||
isExpired(apiKey: ApiKey): boolean {
|
||||
return new Date() > apiKey.expiresAt;
|
||||
}
|
||||
|
||||
isRevoked(apiKey: ApiKey): boolean {
|
||||
return !!apiKey.revokedAt;
|
||||
}
|
||||
|
||||
isActive(apiKey: ApiKey): boolean {
|
||||
return !this.isRevoked(apiKey) && !this.isExpired(apiKey);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
|
||||
import {
|
||||
IsDateString,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
} from 'class-validator';
|
||||
|
||||
@InputType()
|
||||
export class CreateApiKeyDTO {
|
||||
@Field()
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@Field()
|
||||
@IsDateString()
|
||||
expiresAt: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
revokedAt?: string;
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
@InputType()
|
||||
export class GetApiKeyDTO {
|
||||
@Field()
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
id: string;
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
@InputType()
|
||||
export class RevokeApiKeyDTO {
|
||||
@Field()
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
id: string;
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
|
||||
import {
|
||||
IsDateString,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
} from 'class-validator';
|
||||
|
||||
@InputType()
|
||||
export class UpdateApiKeyDTO {
|
||||
@Field()
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
id: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
@IsDateString()
|
||||
@IsOptional()
|
||||
expiresAt?: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
revokedAt?: string;
|
||||
}
|
||||
@ -0,0 +1,33 @@
|
||||
import {
|
||||
ApiKeyException,
|
||||
ApiKeyExceptionCode,
|
||||
} from 'src/engine/core-modules/api-key/api-key.exception';
|
||||
import {
|
||||
ForbiddenError,
|
||||
NotFoundError,
|
||||
UserInputError,
|
||||
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
|
||||
|
||||
export const apiKeyGraphqlApiExceptionHandler = (error: Error) => {
|
||||
if (error instanceof ApiKeyException) {
|
||||
switch (error.code) {
|
||||
case ApiKeyExceptionCode.API_KEY_NOT_FOUND:
|
||||
throw new NotFoundError(error.message);
|
||||
case ApiKeyExceptionCode.API_KEY_REVOKED:
|
||||
throw new ForbiddenError(error.message, {
|
||||
userFriendlyMessage: error.userFriendlyMessage,
|
||||
});
|
||||
case ApiKeyExceptionCode.API_KEY_EXPIRED:
|
||||
throw new UserInputError(error.message, {
|
||||
userFriendlyMessage: error.userFriendlyMessage,
|
||||
});
|
||||
default: {
|
||||
const _exhaustiveCheck: never = error.code;
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
};
|
||||
@ -4,6 +4,7 @@ import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
|
||||
import { ApiKey } from 'src/engine/core-modules/api-key/api-key.entity';
|
||||
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
|
||||
import { AppTokenService } from 'src/engine/core-modules/app-token/services/app-token.service';
|
||||
import { GoogleAPIsAuthController } from 'src/engine/core-modules/auth/controllers/google-apis-auth.controller';
|
||||
@ -79,6 +80,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
|
||||
Workspace,
|
||||
User,
|
||||
AppToken,
|
||||
ApiKey,
|
||||
FeatureFlag,
|
||||
WorkspaceSSOIdentityProvider,
|
||||
KeyValuePair,
|
||||
|
||||
@ -3,235 +3,263 @@ import {
|
||||
AuthExceptionCode,
|
||||
} from 'src/engine/core-modules/auth/auth.exception';
|
||||
import { JwtPayload } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
import { JwtAuthStrategy } from './jwt.auth.strategy';
|
||||
|
||||
describe('JwtAuthStrategy', () => {
|
||||
let strategy: JwtAuthStrategy;
|
||||
|
||||
let workspaceRepository: any;
|
||||
let userWorkspaceRepository: any;
|
||||
let userRepository: any;
|
||||
let twentyORMGlobalManager: any;
|
||||
let apiKeyRepository: any;
|
||||
let jwtWrapperService: any;
|
||||
|
||||
const jwt = {
|
||||
sub: 'sub-default',
|
||||
jti: 'jti-default',
|
||||
};
|
||||
|
||||
workspaceRepository = {
|
||||
findOneBy: jest.fn(async () => new Workspace()),
|
||||
};
|
||||
|
||||
userRepository = {
|
||||
findOne: jest.fn(async () => null),
|
||||
};
|
||||
|
||||
userWorkspaceRepository = {
|
||||
findOne: jest.fn(async () => new UserWorkspace()),
|
||||
};
|
||||
|
||||
const jwtWrapperService: any = {
|
||||
extractJwtFromRequest: jest.fn(() => () => 'token'),
|
||||
};
|
||||
|
||||
twentyORMGlobalManager = {
|
||||
getRepositoryForWorkspace: jest.fn(async () => ({
|
||||
findOne: jest.fn(async () => ({ id: 'api-key-id', revokedAt: null })),
|
||||
})),
|
||||
};
|
||||
|
||||
// first we test the API_KEY case
|
||||
it('should throw AuthException if type is API_KEY and workspace is not found', async () => {
|
||||
const payload = {
|
||||
...jwt,
|
||||
type: 'API_KEY',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
workspaceRepository = {
|
||||
findOneBy: jest.fn(async () => null),
|
||||
};
|
||||
|
||||
strategy = new JwtAuthStrategy(
|
||||
jwtWrapperService,
|
||||
twentyORMGlobalManager,
|
||||
workspaceRepository,
|
||||
{} as any,
|
||||
userWorkspaceRepository,
|
||||
);
|
||||
|
||||
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
|
||||
new AuthException(
|
||||
'Workspace not found',
|
||||
AuthExceptionCode.WORKSPACE_NOT_FOUND,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw AuthExceptionCode if type is API_KEY not found', async () => {
|
||||
const payload = {
|
||||
...jwt,
|
||||
type: 'API_KEY',
|
||||
};
|
||||
|
||||
workspaceRepository = {
|
||||
findOneBy: jest.fn(async () => new Workspace()),
|
||||
};
|
||||
|
||||
twentyORMGlobalManager = {
|
||||
getRepositoryForWorkspace: jest.fn(async () => ({
|
||||
findOne: jest.fn(async () => null),
|
||||
})),
|
||||
};
|
||||
|
||||
strategy = new JwtAuthStrategy(
|
||||
jwtWrapperService,
|
||||
twentyORMGlobalManager,
|
||||
workspaceRepository,
|
||||
{} as any,
|
||||
userWorkspaceRepository,
|
||||
);
|
||||
|
||||
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
|
||||
new AuthException(
|
||||
'This API Key is revoked',
|
||||
AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should be truthy if type is API_KEY and API_KEY is not revoked', async () => {
|
||||
const payload = {
|
||||
...jwt,
|
||||
type: 'API_KEY',
|
||||
};
|
||||
|
||||
workspaceRepository = {
|
||||
findOneBy: jest.fn(async () => new Workspace()),
|
||||
};
|
||||
|
||||
twentyORMGlobalManager = {
|
||||
getRepositoryForWorkspace: jest.fn(async () => ({
|
||||
findOne: jest.fn(async () => ({ id: 'api-key-id', revokedAt: null })),
|
||||
})),
|
||||
};
|
||||
|
||||
strategy = new JwtAuthStrategy(
|
||||
jwtWrapperService,
|
||||
twentyORMGlobalManager,
|
||||
workspaceRepository,
|
||||
{} as any,
|
||||
userWorkspaceRepository,
|
||||
);
|
||||
|
||||
const result = await strategy.validate(payload as JwtPayload);
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(result.apiKey?.id).toBe('api-key-id');
|
||||
});
|
||||
|
||||
// second we test the ACCESS cases
|
||||
it('should throw AuthExceptionCode if type is ACCESS, no jti, and user not found', async () => {
|
||||
const payload = {
|
||||
sub: 'sub-default',
|
||||
type: 'ACCESS',
|
||||
};
|
||||
|
||||
workspaceRepository = {
|
||||
findOneBy: jest.fn(async () => new Workspace()),
|
||||
findOneBy: jest.fn(),
|
||||
};
|
||||
|
||||
userRepository = {
|
||||
findOne: jest.fn(async () => null),
|
||||
};
|
||||
|
||||
strategy = new JwtAuthStrategy(
|
||||
jwtWrapperService,
|
||||
twentyORMGlobalManager,
|
||||
workspaceRepository,
|
||||
userRepository,
|
||||
userWorkspaceRepository,
|
||||
);
|
||||
|
||||
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
|
||||
new AuthException('UserWorkspace not found', expect.any(String)),
|
||||
);
|
||||
try {
|
||||
await strategy.validate(payload as JwtPayload);
|
||||
} catch (e) {
|
||||
expect(e.code).toBe(AuthExceptionCode.USER_WORKSPACE_NOT_FOUND);
|
||||
}
|
||||
});
|
||||
|
||||
it('should throw AuthExceptionCode if type is ACCESS, no jti, and userWorkspace not found', async () => {
|
||||
const payload = {
|
||||
sub: 'sub-default',
|
||||
type: 'ACCESS',
|
||||
};
|
||||
|
||||
workspaceRepository = {
|
||||
findOneBy: jest.fn(async () => new Workspace()),
|
||||
};
|
||||
|
||||
userRepository = {
|
||||
findOne: jest.fn(async () => ({ lastName: 'lastNameDefault' })),
|
||||
findOne: jest.fn(),
|
||||
};
|
||||
|
||||
userWorkspaceRepository = {
|
||||
findOne: jest.fn(async () => null),
|
||||
findOne: jest.fn(),
|
||||
};
|
||||
|
||||
strategy = new JwtAuthStrategy(
|
||||
jwtWrapperService,
|
||||
twentyORMGlobalManager,
|
||||
workspaceRepository,
|
||||
userRepository,
|
||||
userWorkspaceRepository,
|
||||
);
|
||||
apiKeyRepository = {
|
||||
findOne: jest.fn(),
|
||||
};
|
||||
|
||||
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
|
||||
new AuthException('UserWorkspace not found', expect.any(String)),
|
||||
);
|
||||
try {
|
||||
await strategy.validate(payload as JwtPayload);
|
||||
} catch (e) {
|
||||
expect(e.code).toBe(AuthExceptionCode.USER_WORKSPACE_NOT_FOUND);
|
||||
}
|
||||
jwtWrapperService = {
|
||||
extractJwtFromRequest: jest.fn(() => () => 'token'),
|
||||
};
|
||||
});
|
||||
|
||||
it('should not throw if type is ACCESS, no jti, and user and userWorkspace exist', async () => {
|
||||
const payload = {
|
||||
sub: 'sub-default',
|
||||
type: 'ACCESS',
|
||||
userWorkspaceId: 'userWorkspaceId',
|
||||
};
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
workspaceRepository = {
|
||||
findOneBy: jest.fn(async () => new Workspace()),
|
||||
};
|
||||
describe('API_KEY validation', () => {
|
||||
it('should throw AuthException if type is API_KEY and workspace is not found', async () => {
|
||||
const payload = {
|
||||
...jwt,
|
||||
type: 'API_KEY',
|
||||
};
|
||||
|
||||
userRepository = {
|
||||
findOne: jest.fn(async () => ({ lastName: 'lastNameDefault' })),
|
||||
};
|
||||
workspaceRepository.findOneBy.mockResolvedValue(null);
|
||||
|
||||
userWorkspaceRepository = {
|
||||
findOne: jest.fn(async () => ({
|
||||
strategy = new JwtAuthStrategy(
|
||||
jwtWrapperService,
|
||||
workspaceRepository,
|
||||
userRepository,
|
||||
userWorkspaceRepository,
|
||||
apiKeyRepository,
|
||||
);
|
||||
|
||||
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
|
||||
new AuthException(
|
||||
'Workspace not found',
|
||||
AuthExceptionCode.WORKSPACE_NOT_FOUND,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw AuthExceptionCode if type is API_KEY not found', async () => {
|
||||
const payload = {
|
||||
...jwt,
|
||||
type: 'API_KEY',
|
||||
};
|
||||
|
||||
const mockWorkspace = new Workspace();
|
||||
|
||||
mockWorkspace.id = 'workspace-id';
|
||||
workspaceRepository.findOneBy.mockResolvedValue(mockWorkspace);
|
||||
|
||||
apiKeyRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
strategy = new JwtAuthStrategy(
|
||||
jwtWrapperService,
|
||||
workspaceRepository,
|
||||
userRepository,
|
||||
userWorkspaceRepository,
|
||||
apiKeyRepository,
|
||||
);
|
||||
|
||||
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
|
||||
new AuthException(
|
||||
'This API Key is revoked',
|
||||
AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw AuthExceptionCode if API_KEY is revoked', async () => {
|
||||
const payload = {
|
||||
...jwt,
|
||||
type: 'API_KEY',
|
||||
};
|
||||
|
||||
const mockWorkspace = new Workspace();
|
||||
|
||||
mockWorkspace.id = 'workspace-id';
|
||||
workspaceRepository.findOneBy.mockResolvedValue(mockWorkspace);
|
||||
|
||||
apiKeyRepository.findOne.mockResolvedValue({
|
||||
id: 'api-key-id',
|
||||
revokedAt: new Date(),
|
||||
});
|
||||
|
||||
strategy = new JwtAuthStrategy(
|
||||
jwtWrapperService,
|
||||
workspaceRepository,
|
||||
userRepository,
|
||||
userWorkspaceRepository,
|
||||
apiKeyRepository,
|
||||
);
|
||||
|
||||
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
|
||||
new AuthException(
|
||||
'This API Key is revoked',
|
||||
AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should be truthy if type is API_KEY and API_KEY is not revoked', async () => {
|
||||
const payload = {
|
||||
...jwt,
|
||||
type: 'API_KEY',
|
||||
};
|
||||
|
||||
const mockWorkspace = new Workspace();
|
||||
|
||||
mockWorkspace.id = 'workspace-id';
|
||||
workspaceRepository.findOneBy.mockResolvedValue(mockWorkspace);
|
||||
|
||||
apiKeyRepository.findOne.mockResolvedValue({
|
||||
id: 'api-key-id',
|
||||
revokedAt: null,
|
||||
});
|
||||
|
||||
strategy = new JwtAuthStrategy(
|
||||
jwtWrapperService,
|
||||
workspaceRepository,
|
||||
userRepository,
|
||||
userWorkspaceRepository,
|
||||
apiKeyRepository,
|
||||
);
|
||||
|
||||
const result = await strategy.validate(payload as JwtPayload);
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(result.apiKey?.id).toBe('api-key-id');
|
||||
|
||||
expect(apiKeyRepository.findOne).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: payload.jti,
|
||||
workspaceId: mockWorkspace.id,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ACCESS token validation', () => {
|
||||
it('should throw AuthExceptionCode if type is ACCESS, no jti, and user not found', async () => {
|
||||
const payload = {
|
||||
sub: 'sub-default',
|
||||
type: 'ACCESS',
|
||||
userWorkspaceId: 'userWorkspaceId',
|
||||
};
|
||||
|
||||
workspaceRepository.findOneBy.mockResolvedValue(new Workspace());
|
||||
|
||||
userRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
strategy = new JwtAuthStrategy(
|
||||
jwtWrapperService,
|
||||
workspaceRepository,
|
||||
userRepository,
|
||||
userWorkspaceRepository,
|
||||
apiKeyRepository,
|
||||
);
|
||||
|
||||
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
|
||||
new AuthException('UserWorkspace not found', expect.any(String)),
|
||||
);
|
||||
|
||||
try {
|
||||
await strategy.validate(payload as JwtPayload);
|
||||
} catch (e) {
|
||||
expect(e.code).toBe(AuthExceptionCode.USER_WORKSPACE_NOT_FOUND);
|
||||
}
|
||||
});
|
||||
|
||||
it('should throw AuthExceptionCode if type is ACCESS, no jti, and userWorkspace not found', async () => {
|
||||
const payload = {
|
||||
sub: 'sub-default',
|
||||
type: 'ACCESS',
|
||||
userWorkspaceId: 'userWorkspaceId',
|
||||
};
|
||||
|
||||
workspaceRepository.findOneBy.mockResolvedValue(new Workspace());
|
||||
|
||||
userRepository.findOne.mockResolvedValue({ lastName: 'lastNameDefault' });
|
||||
|
||||
userWorkspaceRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
strategy = new JwtAuthStrategy(
|
||||
jwtWrapperService,
|
||||
workspaceRepository,
|
||||
userRepository,
|
||||
userWorkspaceRepository,
|
||||
apiKeyRepository,
|
||||
);
|
||||
|
||||
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
|
||||
new AuthException('UserWorkspace not found', expect.any(String)),
|
||||
);
|
||||
|
||||
try {
|
||||
await strategy.validate(payload as JwtPayload);
|
||||
} catch (e) {
|
||||
expect(e.code).toBe(AuthExceptionCode.USER_WORKSPACE_NOT_FOUND);
|
||||
}
|
||||
});
|
||||
|
||||
it('should not throw if type is ACCESS, no jti, and user and userWorkspace exist', async () => {
|
||||
const payload = {
|
||||
sub: 'sub-default',
|
||||
type: 'ACCESS',
|
||||
userWorkspaceId: 'userWorkspaceId',
|
||||
};
|
||||
|
||||
workspaceRepository.findOneBy.mockResolvedValue(new Workspace());
|
||||
|
||||
userRepository.findOne.mockResolvedValue({ lastName: 'lastNameDefault' });
|
||||
|
||||
userWorkspaceRepository.findOne.mockResolvedValue({
|
||||
id: 'userWorkspaceId',
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
strategy = new JwtAuthStrategy(
|
||||
jwtWrapperService,
|
||||
twentyORMGlobalManager,
|
||||
workspaceRepository,
|
||||
userRepository,
|
||||
userWorkspaceRepository,
|
||||
);
|
||||
strategy = new JwtAuthStrategy(
|
||||
jwtWrapperService,
|
||||
workspaceRepository,
|
||||
userRepository,
|
||||
userWorkspaceRepository,
|
||||
apiKeyRepository,
|
||||
);
|
||||
|
||||
const user = await strategy.validate(payload as JwtPayload);
|
||||
const user = await strategy.validate(payload as JwtPayload);
|
||||
|
||||
expect(user.user?.lastName).toBe('lastNameDefault');
|
||||
expect(user.userWorkspaceId).toBe('userWorkspaceId');
|
||||
expect(user.user?.lastName).toBe('lastNameDefault');
|
||||
expect(user.userWorkspaceId).toBe('userWorkspaceId');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -5,6 +5,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Strategy } from 'passport-jwt';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { ApiKey } from 'src/engine/core-modules/api-key/api-key.entity';
|
||||
import {
|
||||
AuthException,
|
||||
AuthExceptionCode,
|
||||
@ -24,20 +25,18 @@ import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { userValidator } from 'src/engine/core-modules/user/user.validate';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
|
||||
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
|
||||
import { ApiKeyWorkspaceEntity } from 'src/modules/api-key/standard-objects/api-key.workspace-entity';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
constructor(
|
||||
private readonly jwtWrapperService: JwtWrapperService,
|
||||
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
|
||||
@InjectRepository(Workspace, 'core')
|
||||
private readonly workspaceRepository: Repository<Workspace>,
|
||||
@InjectRepository(User, 'core')
|
||||
private readonly userRepository: Repository<User>,
|
||||
@InjectRepository(UserWorkspace, 'core')
|
||||
private readonly userWorkspaceRepository: Repository<UserWorkspace>,
|
||||
@InjectRepository(ApiKey, 'core')
|
||||
private readonly apiKeyRepository: Repository<ApiKey>,
|
||||
) {
|
||||
const jwtFromRequestFunction = jwtWrapperService.extractJwtFromRequest();
|
||||
// @ts-expect-error legacy noImplicitAny
|
||||
@ -87,15 +86,10 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
),
|
||||
);
|
||||
|
||||
const apiKeyRepository =
|
||||
await this.twentyORMGlobalManager.getRepositoryForWorkspace<ApiKeyWorkspaceEntity>(
|
||||
workspace.id,
|
||||
'apiKey',
|
||||
);
|
||||
|
||||
const apiKey = await apiKeyRepository.findOne({
|
||||
const apiKey = await this.apiKeyRepository.findOne({
|
||||
where: {
|
||||
id: payload.jti,
|
||||
workspaceId: workspace.id,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@ import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
|
||||
import { ApiKey } from 'src/engine/core-modules/api-key/api-key.entity';
|
||||
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
|
||||
import { JwtAuthStrategy } from 'src/engine/core-modules/auth/strategies/jwt.auth.strategy';
|
||||
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
|
||||
@ -21,7 +22,7 @@ import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-s
|
||||
imports: [
|
||||
JwtModule,
|
||||
TypeOrmModule.forFeature(
|
||||
[User, AppToken, Workspace, UserWorkspace],
|
||||
[User, AppToken, Workspace, UserWorkspace, ApiKey],
|
||||
'core',
|
||||
),
|
||||
TypeORMModule,
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
import { ApiKey } from 'src/engine/core-modules/api-key/api-key.entity';
|
||||
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { ApiKeyWorkspaceEntity } from 'src/modules/api-key/standard-objects/api-key.workspace-entity';
|
||||
|
||||
export type AuthContext = {
|
||||
user?: User | null | undefined;
|
||||
apiKey?: ApiKeyWorkspaceEntity | null | undefined;
|
||||
apiKey?: ApiKey | null | undefined;
|
||||
workspaceMemberId?: string;
|
||||
workspace?: Workspace;
|
||||
userWorkspaceId?: string;
|
||||
|
||||
@ -7,6 +7,7 @@ import { ActorModule } from 'src/engine/core-modules/actor/actor.module';
|
||||
import { AdminPanelModule } from 'src/engine/core-modules/admin-panel/admin-panel.module';
|
||||
import { AiModule } from 'src/engine/core-modules/ai/ai.module';
|
||||
import { aiModuleFactory } from 'src/engine/core-modules/ai/ai.module-factory';
|
||||
import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module';
|
||||
import { AppTokenModule } from 'src/engine/core-modules/app-token/app-token.module';
|
||||
import { ApprovedAccessDomainModule } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.module';
|
||||
import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
|
||||
@ -42,6 +43,7 @@ import { TelemetryModule } from 'src/engine/core-modules/telemetry/telemetry.mod
|
||||
import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { UserModule } from 'src/engine/core-modules/user/user.module';
|
||||
import { WebhookModule } from 'src/engine/core-modules/webhook/webhook.module';
|
||||
import { WorkflowApiModule } from 'src/engine/core-modules/workflow/workflow-api.module';
|
||||
import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.module';
|
||||
import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module';
|
||||
@ -116,6 +118,8 @@ import { FileModule } from './file/file.module';
|
||||
inject: [TwentyConfigService, FileStorageService],
|
||||
}),
|
||||
SearchModule,
|
||||
ApiKeyModule,
|
||||
WebhookModule,
|
||||
],
|
||||
exports: [
|
||||
AuditModule,
|
||||
|
||||
@ -8,5 +8,7 @@ export enum FeatureFlagKey {
|
||||
IS_IMAP_ENABLED = 'IS_IMAP_ENABLED',
|
||||
IS_MORPH_RELATION_ENABLED = 'IS_MORPH_RELATION_ENABLED',
|
||||
IS_WORKFLOW_FILTERING_ENABLED = 'IS_WORKFLOW_FILTERING_ENABLED',
|
||||
IS_RELATION_CONNECT_ENABLED = 'IS_RELATION_CONNECT_ENABLED',
|
||||
IS_WORKSPACE_API_KEY_WEBHOOK_GRAPHQL_ENABLED = 'IS_WORKSPACE_API_KEY_WEBHOOK_GRAPHQL_ENABLED',
|
||||
IS_FIELDS_PERMISSIONS_ENABLED = 'IS_FIELDS_PERMISSIONS_ENABLED',
|
||||
}
|
||||
|
||||
@ -0,0 +1,20 @@
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
|
||||
import { IsNotEmpty, IsUrl } from 'class-validator';
|
||||
|
||||
@InputType()
|
||||
export class CreateWebhookDTO {
|
||||
@Field()
|
||||
@IsNotEmpty()
|
||||
@IsUrl()
|
||||
targetUrl: string;
|
||||
|
||||
@Field(() => [String])
|
||||
operations: string[];
|
||||
|
||||
@Field({ nullable: true })
|
||||
description?: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
secret?: string;
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
@InputType()
|
||||
export class DeleteWebhookDTO {
|
||||
@Field()
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
id: string;
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
@InputType()
|
||||
export class GetWebhookDTO {
|
||||
@Field()
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
id: string;
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
@InputType()
|
||||
export class UpdateWebhookDTO {
|
||||
@Field()
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
id: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
targetUrl?: string;
|
||||
|
||||
@Field(() => [String], { nullable: true })
|
||||
operations?: string[];
|
||||
|
||||
@Field({ nullable: true })
|
||||
description?: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
secret?: string;
|
||||
}
|
||||
@ -0,0 +1,28 @@
|
||||
import {
|
||||
NotFoundError,
|
||||
UserInputError,
|
||||
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
|
||||
import {
|
||||
WebhookException,
|
||||
WebhookExceptionCode,
|
||||
} from 'src/engine/core-modules/webhook/webhook.exception';
|
||||
|
||||
export const webhookGraphqlApiExceptionHandler = (error: Error) => {
|
||||
if (error instanceof WebhookException) {
|
||||
switch (error.code) {
|
||||
case WebhookExceptionCode.WEBHOOK_NOT_FOUND:
|
||||
throw new NotFoundError(error.message);
|
||||
case WebhookExceptionCode.INVALID_TARGET_URL:
|
||||
throw new UserInputError(error.message, {
|
||||
userFriendlyMessage: error.userFriendlyMessage,
|
||||
});
|
||||
default: {
|
||||
const _exhaustiveCheck: never = error.code;
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
};
|
||||
@ -0,0 +1,66 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { IDField } from '@ptc-org/nestjs-query-graphql';
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
DeleteDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
Relation,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
@Index('IDX_WEBHOOK_WORKSPACE_ID', ['workspaceId'])
|
||||
@Entity({ name: 'webhook', schema: 'core' })
|
||||
@ObjectType('Webhook')
|
||||
export class Webhook {
|
||||
@IDField(() => UUIDScalarType)
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Field()
|
||||
@Column()
|
||||
targetUrl: string;
|
||||
|
||||
@Field(() => [String])
|
||||
@Column('text', { array: true, default: ['*.*'] })
|
||||
operations: string[];
|
||||
|
||||
@Field({ nullable: true })
|
||||
@Column({ nullable: true })
|
||||
description?: string;
|
||||
|
||||
@Field()
|
||||
@Column()
|
||||
secret: string;
|
||||
|
||||
@Field()
|
||||
@Column('uuid')
|
||||
workspaceId: string;
|
||||
|
||||
@Field()
|
||||
@CreateDateColumn({ type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@Field()
|
||||
@UpdateDateColumn({ type: 'timestamptz' })
|
||||
updatedAt: Date;
|
||||
|
||||
@Field({ nullable: true })
|
||||
@DeleteDateColumn({ type: 'timestamptz' })
|
||||
deletedAt?: Date;
|
||||
|
||||
@Field(() => Workspace)
|
||||
@ManyToOne(() => Workspace, (workspace) => workspace.webhooks, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn({ name: 'workspaceId' })
|
||||
workspace: Relation<Workspace>;
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
import { CustomException } from 'src/utils/custom-exception';
|
||||
|
||||
export class WebhookException extends CustomException {
|
||||
declare code: WebhookExceptionCode;
|
||||
constructor(
|
||||
message: string,
|
||||
code: WebhookExceptionCode,
|
||||
{ userFriendlyMessage }: { userFriendlyMessage?: string } = {},
|
||||
) {
|
||||
super(message, code, userFriendlyMessage);
|
||||
}
|
||||
}
|
||||
|
||||
export enum WebhookExceptionCode {
|
||||
WEBHOOK_NOT_FOUND = 'WEBHOOK_NOT_FOUND',
|
||||
INVALID_TARGET_URL = 'INVALID_TARGET_URL',
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { Webhook } from './webhook.entity';
|
||||
import { WebhookResolver } from './webhook.resolver';
|
||||
import { WebhookService } from './webhook.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Webhook], 'core')],
|
||||
providers: [WebhookService, WebhookResolver],
|
||||
exports: [WebhookService, TypeOrmModule],
|
||||
})
|
||||
export class WebhookModule {}
|
||||
@ -0,0 +1,88 @@
|
||||
import { UseGuards } from '@nestjs/common';
|
||||
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { CreateWebhookDTO } from 'src/engine/core-modules/webhook/dtos/create-webhook.dto';
|
||||
import { DeleteWebhookDTO } from 'src/engine/core-modules/webhook/dtos/delete-webhook.dto';
|
||||
import { GetWebhookDTO } from 'src/engine/core-modules/webhook/dtos/get-webhook.dto';
|
||||
import { UpdateWebhookDTO } from 'src/engine/core-modules/webhook/dtos/update-webhook.dto';
|
||||
import { webhookGraphqlApiExceptionHandler } from 'src/engine/core-modules/webhook/utils/webhook-graphql-api-exception-handler.util';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
|
||||
import { Webhook } from './webhook.entity';
|
||||
import { WebhookService } from './webhook.service';
|
||||
|
||||
@Resolver(() => Webhook)
|
||||
@UseGuards(WorkspaceAuthGuard)
|
||||
export class WebhookResolver {
|
||||
constructor(private readonly webhookService: WebhookService) {}
|
||||
|
||||
@Query(() => [Webhook])
|
||||
async webhooks(@AuthWorkspace() workspace: Workspace): Promise<Webhook[]> {
|
||||
return this.webhookService.findByWorkspaceId(workspace.id);
|
||||
}
|
||||
|
||||
@Query(() => Webhook, { nullable: true })
|
||||
async webhook(
|
||||
@Args('input') input: GetWebhookDTO,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
): Promise<Webhook | null> {
|
||||
return this.webhookService.findById(input.id, workspace.id);
|
||||
}
|
||||
|
||||
@Mutation(() => Webhook)
|
||||
async createWebhook(
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
@Args('input') input: CreateWebhookDTO,
|
||||
): Promise<Webhook> {
|
||||
try {
|
||||
return await this.webhookService.create({
|
||||
targetUrl: input.targetUrl,
|
||||
operations: input.operations,
|
||||
description: input.description,
|
||||
secret: input.secret,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
} catch (error) {
|
||||
webhookGraphqlApiExceptionHandler(error);
|
||||
throw error; // This line will never be reached but satisfies TypeScript
|
||||
}
|
||||
}
|
||||
|
||||
@Mutation(() => Webhook, { nullable: true })
|
||||
async updateWebhook(
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
@Args('input') input: UpdateWebhookDTO,
|
||||
): Promise<Webhook | null> {
|
||||
try {
|
||||
const updateData: Partial<Webhook> = {};
|
||||
|
||||
if (input.targetUrl !== undefined) updateData.targetUrl = input.targetUrl;
|
||||
if (input.operations !== undefined)
|
||||
updateData.operations = input.operations;
|
||||
if (input.description !== undefined)
|
||||
updateData.description = input.description;
|
||||
if (input.secret !== undefined) updateData.secret = input.secret;
|
||||
|
||||
return await this.webhookService.update(
|
||||
input.id,
|
||||
workspace.id,
|
||||
updateData,
|
||||
);
|
||||
} catch (error) {
|
||||
webhookGraphqlApiExceptionHandler(error);
|
||||
throw error; // This line will never be reached but satisfies TypeScript
|
||||
}
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
async deleteWebhook(
|
||||
@Args('input') input: DeleteWebhookDTO,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
): Promise<boolean> {
|
||||
const result = await this.webhookService.delete(input.id, workspace.id);
|
||||
|
||||
return result !== null;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,420 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
import { ArrayContains, IsNull } from 'typeorm';
|
||||
|
||||
import { Webhook } from './webhook.entity';
|
||||
import { WebhookException, WebhookExceptionCode } from './webhook.exception';
|
||||
import { WebhookService } from './webhook.service';
|
||||
|
||||
describe('WebhookService', () => {
|
||||
let service: WebhookService;
|
||||
let mockWebhookRepository: any;
|
||||
|
||||
const mockWorkspaceId = 'workspace-123';
|
||||
const mockWebhookId = 'webhook-456';
|
||||
|
||||
const mockWebhook: Webhook = {
|
||||
id: mockWebhookId,
|
||||
targetUrl: 'https://example.com/webhook',
|
||||
secret: 'webhook-secret',
|
||||
operations: ['create', 'update'],
|
||||
workspaceId: mockWorkspaceId,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
updatedAt: new Date('2024-01-01'),
|
||||
deletedAt: undefined,
|
||||
workspace: {} as any,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
mockWebhookRepository = {
|
||||
find: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
update: jest.fn(),
|
||||
softDelete: jest.fn(),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
WebhookService,
|
||||
{
|
||||
provide: getRepositoryToken(Webhook, 'core'),
|
||||
useValue: mockWebhookRepository,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<WebhookService>(WebhookService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('normalizeTargetUrl', () => {
|
||||
it('should normalize valid URLs', () => {
|
||||
const result = (service as any).normalizeTargetUrl(
|
||||
'https://example.com/webhook',
|
||||
);
|
||||
|
||||
expect(result).toBe('https://example.com/webhook');
|
||||
});
|
||||
|
||||
it('should return original string if invalid URL', () => {
|
||||
const invalidUrl = 'not-a-url';
|
||||
const result = (service as any).normalizeTargetUrl(invalidUrl);
|
||||
|
||||
expect(result).toBe(invalidUrl);
|
||||
});
|
||||
|
||||
it('should normalize URL with trailing slash', () => {
|
||||
const result = (service as any).normalizeTargetUrl(
|
||||
'https://example.com/webhook/',
|
||||
);
|
||||
|
||||
expect(result).toBe('https://example.com/webhook/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateTargetUrl', () => {
|
||||
it('should validate HTTPS URLs', () => {
|
||||
const result = (service as any).validateTargetUrl(
|
||||
'https://example.com/webhook',
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate HTTP URLs', () => {
|
||||
const result = (service as any).validateTargetUrl(
|
||||
'http://example.com/webhook',
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject invalid URLs', () => {
|
||||
const result = (service as any).validateTargetUrl('not-a-url');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject non-HTTP protocols', () => {
|
||||
const result = (service as any).validateTargetUrl(
|
||||
'ftp://example.com/webhook',
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByWorkspaceId', () => {
|
||||
it('should find all webhooks for a workspace', async () => {
|
||||
const mockWebhooks = [
|
||||
mockWebhook,
|
||||
{ ...mockWebhook, id: 'another-webhook' },
|
||||
];
|
||||
|
||||
mockWebhookRepository.find.mockResolvedValue(mockWebhooks);
|
||||
|
||||
const result = await service.findByWorkspaceId(mockWorkspaceId);
|
||||
|
||||
expect(mockWebhookRepository.find).toHaveBeenCalledWith({
|
||||
where: {
|
||||
workspaceId: mockWorkspaceId,
|
||||
deletedAt: IsNull(),
|
||||
},
|
||||
});
|
||||
expect(result).toEqual(mockWebhooks);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByOperations', () => {
|
||||
it('should find webhooks by operations using ArrayContains', async () => {
|
||||
const operations = ['create', 'update'];
|
||||
const mockWebhooks = [mockWebhook];
|
||||
|
||||
mockWebhookRepository.find.mockResolvedValue(mockWebhooks);
|
||||
|
||||
const result = await service.findByOperations(
|
||||
mockWorkspaceId,
|
||||
operations,
|
||||
);
|
||||
|
||||
expect(mockWebhookRepository.find).toHaveBeenCalledWith({
|
||||
where: operations.map((operation) => ({
|
||||
workspaceId: mockWorkspaceId,
|
||||
operations: ArrayContains([operation]),
|
||||
deletedAt: IsNull(),
|
||||
})),
|
||||
});
|
||||
expect(result).toEqual(mockWebhooks);
|
||||
});
|
||||
|
||||
it('should handle single operation', async () => {
|
||||
const operations = ['create'];
|
||||
|
||||
mockWebhookRepository.find.mockResolvedValue([mockWebhook]);
|
||||
|
||||
const result = await service.findByOperations(
|
||||
mockWorkspaceId,
|
||||
operations,
|
||||
);
|
||||
|
||||
expect(mockWebhookRepository.find).toHaveBeenCalledWith({
|
||||
where: [
|
||||
{
|
||||
workspaceId: mockWorkspaceId,
|
||||
operations: ArrayContains(['create']),
|
||||
deletedAt: IsNull(),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result).toEqual([mockWebhook]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
it('should find a webhook by ID and workspace ID', async () => {
|
||||
mockWebhookRepository.findOne.mockResolvedValue(mockWebhook);
|
||||
|
||||
const result = await service.findById(mockWebhookId, mockWorkspaceId);
|
||||
|
||||
expect(mockWebhookRepository.findOne).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: mockWebhookId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
deletedAt: IsNull(),
|
||||
},
|
||||
});
|
||||
expect(result).toEqual(mockWebhook);
|
||||
});
|
||||
|
||||
it('should return null if webhook not found', async () => {
|
||||
mockWebhookRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
const result = await service.findById('non-existent', mockWorkspaceId);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should create and save a webhook with valid target URL', async () => {
|
||||
const webhookData = {
|
||||
targetUrl: 'https://example.com/webhook',
|
||||
secret: 'webhook-secret',
|
||||
operations: ['create', 'update'],
|
||||
workspaceId: mockWorkspaceId,
|
||||
};
|
||||
|
||||
mockWebhookRepository.create.mockReturnValue(mockWebhook);
|
||||
mockWebhookRepository.save.mockResolvedValue(mockWebhook);
|
||||
|
||||
const result = await service.create(webhookData);
|
||||
|
||||
expect(mockWebhookRepository.create).toHaveBeenCalledWith({
|
||||
...webhookData,
|
||||
targetUrl: 'https://example.com/webhook',
|
||||
secret: 'webhook-secret',
|
||||
});
|
||||
expect(mockWebhookRepository.save).toHaveBeenCalledWith(mockWebhook);
|
||||
expect(result).toEqual(mockWebhook);
|
||||
});
|
||||
|
||||
it('should throw WebhookException for invalid target URL', async () => {
|
||||
const webhookData = {
|
||||
targetUrl: 'invalid-url',
|
||||
operations: ['create'],
|
||||
workspaceId: mockWorkspaceId,
|
||||
};
|
||||
|
||||
await expect(service.create(webhookData)).rejects.toThrow(
|
||||
WebhookException,
|
||||
);
|
||||
|
||||
await expect(service.create(webhookData)).rejects.toMatchObject({
|
||||
code: WebhookExceptionCode.INVALID_TARGET_URL,
|
||||
});
|
||||
|
||||
expect(mockWebhookRepository.create).not.toHaveBeenCalled();
|
||||
expect(mockWebhookRepository.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw WebhookException for webhook data without target URL', async () => {
|
||||
const webhookData = {
|
||||
operations: ['create'],
|
||||
workspaceId: mockWorkspaceId,
|
||||
};
|
||||
|
||||
await expect(service.create(webhookData)).rejects.toThrow(
|
||||
WebhookException,
|
||||
);
|
||||
|
||||
await expect(service.create(webhookData)).rejects.toMatchObject({
|
||||
code: WebhookExceptionCode.INVALID_TARGET_URL,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should update an existing webhook', async () => {
|
||||
const updateData = { targetUrl: 'https://updated.example.com/webhook' };
|
||||
const updatedWebhook = { ...mockWebhook, ...updateData };
|
||||
|
||||
mockWebhookRepository.findOne
|
||||
.mockResolvedValueOnce(mockWebhook)
|
||||
.mockResolvedValueOnce(updatedWebhook);
|
||||
mockWebhookRepository.update.mockResolvedValue({ affected: 1 });
|
||||
|
||||
const result = await service.update(
|
||||
mockWebhookId,
|
||||
mockWorkspaceId,
|
||||
updateData,
|
||||
);
|
||||
|
||||
expect(mockWebhookRepository.update).toHaveBeenCalledWith(
|
||||
mockWebhookId,
|
||||
updateData,
|
||||
);
|
||||
expect(result).toEqual(updatedWebhook);
|
||||
});
|
||||
|
||||
it('should return null if webhook to update does not exist', async () => {
|
||||
mockWebhookRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
const result = await service.update('non-existent', mockWorkspaceId, {
|
||||
targetUrl: 'https://updated.example.com',
|
||||
});
|
||||
|
||||
expect(mockWebhookRepository.update).not.toHaveBeenCalled();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should throw WebhookException for invalid target URL during update', async () => {
|
||||
const updateData = { targetUrl: 'invalid-url' };
|
||||
|
||||
mockWebhookRepository.findOne.mockResolvedValue(mockWebhook);
|
||||
|
||||
await expect(
|
||||
service.update(mockWebhookId, mockWorkspaceId, updateData),
|
||||
).rejects.toThrow(WebhookException);
|
||||
|
||||
await expect(
|
||||
service.update(mockWebhookId, mockWorkspaceId, updateData),
|
||||
).rejects.toMatchObject({
|
||||
code: WebhookExceptionCode.INVALID_TARGET_URL,
|
||||
});
|
||||
|
||||
expect(mockWebhookRepository.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should update without target URL validation if targetUrl not in updateData', async () => {
|
||||
const updateData = { operations: ['create', 'update', 'delete'] };
|
||||
const updatedWebhook = { ...mockWebhook, ...updateData };
|
||||
|
||||
mockWebhookRepository.findOne
|
||||
.mockResolvedValueOnce(mockWebhook)
|
||||
.mockResolvedValueOnce(updatedWebhook);
|
||||
mockWebhookRepository.update.mockResolvedValue({ affected: 1 });
|
||||
|
||||
const result = await service.update(
|
||||
mockWebhookId,
|
||||
mockWorkspaceId,
|
||||
updateData,
|
||||
);
|
||||
|
||||
expect(mockWebhookRepository.update).toHaveBeenCalledWith(
|
||||
mockWebhookId,
|
||||
updateData,
|
||||
);
|
||||
expect(result).toEqual(updatedWebhook);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('should soft delete a webhook', async () => {
|
||||
mockWebhookRepository.findOne.mockResolvedValue(mockWebhook);
|
||||
mockWebhookRepository.softDelete.mockResolvedValue({ affected: 1 });
|
||||
|
||||
const result = await service.delete(mockWebhookId, mockWorkspaceId);
|
||||
|
||||
expect(mockWebhookRepository.findOne).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: mockWebhookId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
deletedAt: IsNull(),
|
||||
},
|
||||
});
|
||||
expect(mockWebhookRepository.softDelete).toHaveBeenCalledWith(
|
||||
mockWebhookId,
|
||||
);
|
||||
expect(result).toEqual(mockWebhook);
|
||||
});
|
||||
|
||||
it('should return null if webhook to delete does not exist', async () => {
|
||||
mockWebhookRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
const result = await service.delete('non-existent', mockWorkspaceId);
|
||||
|
||||
expect(mockWebhookRepository.softDelete).not.toHaveBeenCalled();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle URLs with query parameters', async () => {
|
||||
const webhookData = {
|
||||
targetUrl: 'https://example.com/webhook?param=value',
|
||||
operations: ['create'],
|
||||
workspaceId: mockWorkspaceId,
|
||||
};
|
||||
|
||||
const normalizedWebhook = {
|
||||
...mockWebhook,
|
||||
targetUrl: 'https://example.com/webhook?param=value',
|
||||
};
|
||||
|
||||
mockWebhookRepository.create.mockReturnValue(normalizedWebhook);
|
||||
mockWebhookRepository.save.mockResolvedValue(normalizedWebhook);
|
||||
|
||||
const result = await service.create(webhookData);
|
||||
|
||||
expect(result.targetUrl).toBe('https://example.com/webhook?param=value');
|
||||
});
|
||||
|
||||
it('should handle URLs with fragments', async () => {
|
||||
const webhookData = {
|
||||
targetUrl: 'https://example.com/webhook#section',
|
||||
operations: ['create'],
|
||||
workspaceId: mockWorkspaceId,
|
||||
};
|
||||
|
||||
const normalizedWebhook = {
|
||||
...mockWebhook,
|
||||
targetUrl: 'https://example.com/webhook#section',
|
||||
};
|
||||
|
||||
mockWebhookRepository.create.mockReturnValue(normalizedWebhook);
|
||||
mockWebhookRepository.save.mockResolvedValue(normalizedWebhook);
|
||||
|
||||
const result = await service.create(webhookData);
|
||||
|
||||
expect(result.targetUrl).toBe('https://example.com/webhook#section');
|
||||
});
|
||||
|
||||
it('should handle empty operations array', async () => {
|
||||
await service.findByOperations(mockWorkspaceId, []);
|
||||
|
||||
expect(mockWebhookRepository.find).toHaveBeenCalledWith({
|
||||
where: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,134 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { ArrayContains, IsNull, Repository } from 'typeorm';
|
||||
|
||||
import { Webhook } from './webhook.entity';
|
||||
import { WebhookException, WebhookExceptionCode } from './webhook.exception';
|
||||
|
||||
@Injectable()
|
||||
export class WebhookService {
|
||||
constructor(
|
||||
@InjectRepository(Webhook, 'core')
|
||||
private readonly webhookRepository: Repository<Webhook>,
|
||||
) {}
|
||||
|
||||
private normalizeTargetUrl(targetUrl: string): string {
|
||||
try {
|
||||
const url = new URL(targetUrl);
|
||||
|
||||
return url.toString();
|
||||
} catch {
|
||||
return targetUrl;
|
||||
}
|
||||
}
|
||||
|
||||
private validateTargetUrl(targetUrl: string): boolean {
|
||||
try {
|
||||
const url = new URL(targetUrl);
|
||||
|
||||
return url.protocol === 'http:' || url.protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async findByWorkspaceId(workspaceId: string): Promise<Webhook[]> {
|
||||
return this.webhookRepository.find({
|
||||
where: {
|
||||
workspaceId,
|
||||
deletedAt: IsNull(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findByOperations(
|
||||
workspaceId: string,
|
||||
operations: string[],
|
||||
): Promise<Webhook[]> {
|
||||
return this.webhookRepository.find({
|
||||
where: operations.map((operation) => ({
|
||||
workspaceId,
|
||||
operations: ArrayContains([operation]),
|
||||
deletedAt: IsNull(),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
async findById(id: string, workspaceId: string): Promise<Webhook | null> {
|
||||
const webhook = await this.webhookRepository.findOne({
|
||||
where: {
|
||||
id,
|
||||
workspaceId,
|
||||
deletedAt: IsNull(),
|
||||
},
|
||||
});
|
||||
|
||||
return webhook || null;
|
||||
}
|
||||
|
||||
async create(webhookData: Partial<Webhook>): Promise<Webhook> {
|
||||
const normalizedTargetUrl = this.normalizeTargetUrl(
|
||||
webhookData.targetUrl || '',
|
||||
);
|
||||
|
||||
if (!this.validateTargetUrl(normalizedTargetUrl)) {
|
||||
throw new WebhookException(
|
||||
'Invalid target URL provided',
|
||||
WebhookExceptionCode.INVALID_TARGET_URL,
|
||||
{ userFriendlyMessage: 'Please provide a valid HTTP or HTTPS URL.' },
|
||||
);
|
||||
}
|
||||
|
||||
const webhook = this.webhookRepository.create({
|
||||
...webhookData,
|
||||
targetUrl: normalizedTargetUrl,
|
||||
secret: webhookData.secret,
|
||||
});
|
||||
|
||||
return this.webhookRepository.save(webhook);
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
workspaceId: string,
|
||||
updateData: Partial<Webhook>,
|
||||
): Promise<Webhook | null> {
|
||||
const webhook = await this.findById(id, workspaceId);
|
||||
|
||||
if (!webhook) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isDefined(updateData.targetUrl)) {
|
||||
const normalizedTargetUrl = this.normalizeTargetUrl(updateData.targetUrl);
|
||||
|
||||
if (!this.validateTargetUrl(normalizedTargetUrl)) {
|
||||
throw new WebhookException(
|
||||
'Invalid target URL provided',
|
||||
WebhookExceptionCode.INVALID_TARGET_URL,
|
||||
{ userFriendlyMessage: 'Please provide a valid HTTP or HTTPS URL.' },
|
||||
);
|
||||
}
|
||||
|
||||
updateData.targetUrl = normalizedTargetUrl;
|
||||
}
|
||||
|
||||
await this.webhookRepository.update(id, updateData);
|
||||
|
||||
return this.findById(id, workspaceId);
|
||||
}
|
||||
|
||||
async delete(id: string, workspaceId: string): Promise<Webhook | null> {
|
||||
const webhook = await this.findById(id, workspaceId);
|
||||
|
||||
if (!webhook) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await this.webhookRepository.softDelete(id);
|
||||
|
||||
return webhook;
|
||||
}
|
||||
}
|
||||
@ -15,6 +15,7 @@ import {
|
||||
} from 'typeorm';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { ApiKey } from 'src/engine/core-modules/api-key/api-key.entity';
|
||||
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
|
||||
import { ApprovedAccessDomain } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.entity';
|
||||
import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
|
||||
@ -22,6 +23,7 @@ import { KeyValuePair } from 'src/engine/core-modules/key-value-pair/key-value-p
|
||||
import { PostgresCredentials } from 'src/engine/core-modules/postgres-credentials/postgres-credentials.entity';
|
||||
import { WorkspaceSSOIdentityProvider } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
|
||||
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { Webhook } from 'src/engine/core-modules/webhook/webhook.entity';
|
||||
import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
|
||||
import { RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto';
|
||||
|
||||
@ -127,6 +129,12 @@ export class Workspace {
|
||||
})
|
||||
agents: Relation<AgentEntity[]>;
|
||||
|
||||
@OneToMany(() => Webhook, (webhook) => webhook.workspace)
|
||||
webhooks: Relation<Webhook[]>;
|
||||
|
||||
@OneToMany(() => ApiKey, (apiKey) => apiKey.workspace)
|
||||
apiKeys: Relation<ApiKey[]>;
|
||||
|
||||
@Field()
|
||||
@Column({ default: 1 })
|
||||
metadataVersion: number;
|
||||
|
||||
@ -4,6 +4,8 @@ import { TypedReflect } from 'src/utils/typed-reflect';
|
||||
|
||||
export interface WorkspaceGateOptions {
|
||||
featureFlag: string;
|
||||
excludeFromDatabase?: boolean;
|
||||
excludeFromGraphQL?: boolean;
|
||||
}
|
||||
|
||||
export function WorkspaceGate(options: WorkspaceGateOptions) {
|
||||
@ -16,19 +18,25 @@ export function WorkspaceGate(options: WorkspaceGateOptions) {
|
||||
);
|
||||
}
|
||||
|
||||
const gateOptions = {
|
||||
featureFlag: options.featureFlag,
|
||||
excludeFromDatabase: options.excludeFromDatabase ?? true,
|
||||
excludeFromGraphQL: options.excludeFromGraphQL ?? true,
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return (target: any, propertyKey?: string | symbol) => {
|
||||
if (propertyKey !== undefined) {
|
||||
TypedReflect.defineMetadata(
|
||||
'workspace:gate-metadata-args',
|
||||
options,
|
||||
gateOptions,
|
||||
target,
|
||||
propertyKey.toString(),
|
||||
);
|
||||
} else {
|
||||
TypedReflect.defineMetadata(
|
||||
'workspace:gate-metadata-args',
|
||||
options,
|
||||
gateOptions,
|
||||
target,
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
export interface Gate {
|
||||
featureFlag: string;
|
||||
excludeFromDatabase?: boolean;
|
||||
excludeFromGraphQL?: boolean;
|
||||
}
|
||||
|
||||
@ -0,0 +1,29 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
const tableName = 'apiKey';
|
||||
|
||||
export const seedApiKeys = async (
|
||||
dataSource: DataSource,
|
||||
schemaName: string,
|
||||
workspaceId: string,
|
||||
) => {
|
||||
await dataSource
|
||||
.createQueryBuilder()
|
||||
.insert()
|
||||
.into(`${schemaName}.${tableName}`, [
|
||||
'id',
|
||||
'name',
|
||||
'expiresAt',
|
||||
'workspaceId',
|
||||
])
|
||||
.orIgnore()
|
||||
.values([
|
||||
{
|
||||
id: '20202020-f401-4d8a-a731-64d007c27bad',
|
||||
name: 'My api key',
|
||||
expiresAt: '2025-12-31T23:59:59.000Z',
|
||||
workspaceId: workspaceId,
|
||||
},
|
||||
])
|
||||
.execute();
|
||||
};
|
||||
@ -1,6 +1,7 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { seedBillingSubscriptions } from 'src/engine/workspace-manager/dev-seeder/core/billing/utils/seed-billing-subscriptions.util';
|
||||
import { seedApiKeys } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-api-keys.util';
|
||||
import { seedFeatureFlags } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util';
|
||||
import { seedUserWorkspaces } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-user-workspaces.util';
|
||||
import { seedUsers } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-users.util';
|
||||
@ -32,6 +33,8 @@ export const seedCoreSchema = async ({
|
||||
await seedUsers(dataSource, schemaName);
|
||||
await seedUserWorkspaces(dataSource, schemaName, workspaceId);
|
||||
|
||||
await seedApiKeys(dataSource, schemaName, workspaceId);
|
||||
|
||||
if (shouldSeedFeatureFlags) {
|
||||
await seedFeatureFlags(dataSource, schemaName, workspaceId);
|
||||
}
|
||||
|
||||
@ -19,7 +19,7 @@ export const API_KEY_DATA_SEEDS: ApiKeyDataSeed[] = [
|
||||
id: API_KEY_DATA_SEED_IDS.ID_1,
|
||||
name: 'My api key',
|
||||
expiresAt: new Date(
|
||||
new Date().getTime() + 1000 * 60 * 60 * 24 * 365 * 100, // In 100 years
|
||||
new Date().getTime() + 1000 * 60 * 60 * 24 * 365 * 100, // 100 years from now
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
@ -5,10 +5,6 @@ import { WorkspaceEntityManager } from 'src/engine/twenty-orm/entity-manager/wor
|
||||
import { computeTableName } from 'src/engine/utils/compute-table-name.util';
|
||||
import { shouldSeedWorkspaceFavorite } from 'src/engine/utils/should-seed-workspace-favorite';
|
||||
import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service';
|
||||
import {
|
||||
API_KEY_DATA_SEED_COLUMNS,
|
||||
API_KEY_DATA_SEEDS,
|
||||
} from 'src/engine/workspace-manager/dev-seeder/data/constants/api-key-data-seeds.constant';
|
||||
import {
|
||||
CALENDAR_CHANNEL_DATA_SEED_COLUMNS,
|
||||
CALENDAR_CHANNEL_DATA_SEEDS,
|
||||
@ -130,11 +126,6 @@ const RECORD_SEEDS_CONFIGS = [
|
||||
pgColumns: OPPORTUNITY_DATA_SEED_COLUMNS,
|
||||
recordSeeds: OPPORTUNITY_DATA_SEEDS,
|
||||
},
|
||||
{
|
||||
tableName: 'apiKey',
|
||||
pgColumns: API_KEY_DATA_SEED_COLUMNS,
|
||||
recordSeeds: API_KEY_DATA_SEEDS,
|
||||
},
|
||||
{
|
||||
tableName: 'connectedAccount',
|
||||
pgColumns: CONNECTED_ACCOUNT_DATA_SEED_COLUMNS,
|
||||
|
||||
@ -49,6 +49,7 @@ export class StandardFieldFactory {
|
||||
isGatedAndNotEnabled(
|
||||
workspaceEntityMetadataArgs.gate,
|
||||
context.featureFlags,
|
||||
'database',
|
||||
)
|
||||
) {
|
||||
return acc;
|
||||
|
||||
@ -37,6 +37,7 @@ export class StandardObjectFactory {
|
||||
isGatedAndNotEnabled(
|
||||
workspaceEntityMetadataArgs.gate,
|
||||
context.featureFlags,
|
||||
'database',
|
||||
)
|
||||
) {
|
||||
return undefined;
|
||||
|
||||
@ -37,7 +37,6 @@ import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/sta
|
||||
|
||||
// TODO: Maybe we should automate this with the DiscoverService of Nest.JS
|
||||
export const standardObjectMetadataDefinitions = [
|
||||
ApiKeyWorkspaceEntity,
|
||||
AttachmentWorkspaceEntity,
|
||||
BlocklistWorkspaceEntity,
|
||||
CalendarEventWorkspaceEntity,
|
||||
@ -55,7 +54,6 @@ export const standardObjectMetadataDefinitions = [
|
||||
ViewFilterGroupWorkspaceEntity,
|
||||
ViewSortWorkspaceEntity,
|
||||
ViewWorkspaceEntity,
|
||||
WebhookWorkspaceEntity,
|
||||
WorkflowWorkspaceEntity,
|
||||
WorkflowVersionWorkspaceEntity,
|
||||
WorkflowRunWorkspaceEntity,
|
||||
@ -73,4 +71,6 @@ export const standardObjectMetadataDefinitions = [
|
||||
PersonWorkspaceEntity,
|
||||
TaskWorkspaceEntity,
|
||||
TaskTargetWorkspaceEntity,
|
||||
ApiKeyWorkspaceEntity,
|
||||
WebhookWorkspaceEntity,
|
||||
];
|
||||
|
||||
@ -1,11 +1,33 @@
|
||||
import { Gate } from 'src/engine/twenty-orm/interfaces/gate.interface';
|
||||
|
||||
export type GateContext = 'database' | 'graphql';
|
||||
|
||||
export const isGatedAndNotEnabled = (
|
||||
gate: Gate | undefined,
|
||||
workspaceFeatureFlagsMap: Record<string, boolean>,
|
||||
context?: GateContext,
|
||||
): boolean => {
|
||||
const featureFlagValue =
|
||||
gate?.featureFlag && workspaceFeatureFlagsMap[gate.featureFlag];
|
||||
// If no gate, not gated
|
||||
if (!gate?.featureFlag) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return gate?.featureFlag !== undefined && !featureFlagValue;
|
||||
// Check if explicitly excluded from the specific context
|
||||
switch (context) {
|
||||
case 'database':
|
||||
if (gate.excludeFromDatabase === false) {
|
||||
return false; // Not gated for database
|
||||
}
|
||||
break;
|
||||
case 'graphql':
|
||||
if (gate.excludeFromGraphQL === false) {
|
||||
return false; // Not gated for GraphQL
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// If context-specific exclusion is true or undefined (default behavior), check the flag
|
||||
const featureFlagValue = workspaceFeatureFlagsMap[gate.featureFlag];
|
||||
|
||||
return !featureFlagValue;
|
||||
};
|
||||
|
||||
@ -4,6 +4,7 @@ import { FieldMetadataType } from 'twenty-shared/types';
|
||||
import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity';
|
||||
import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator';
|
||||
import { WorkspaceField } from 'src/engine/twenty-orm/decorators/workspace-field.decorator';
|
||||
import { WorkspaceGate } from 'src/engine/twenty-orm/decorators/workspace-gate.decorator';
|
||||
import { WorkspaceIsNullable } from 'src/engine/twenty-orm/decorators/workspace-is-nullable.decorator';
|
||||
import { WorkspaceIsSystem } from 'src/engine/twenty-orm/decorators/workspace-is-system.decorator';
|
||||
import { API_KEY_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids';
|
||||
@ -20,6 +21,11 @@ import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync
|
||||
labelIdentifierStandardId: API_KEY_STANDARD_FIELD_IDS.name,
|
||||
})
|
||||
@WorkspaceIsSystem()
|
||||
@WorkspaceGate({
|
||||
featureFlag: 'IS_WORKSPACE_API_KEY_WEBHOOK_GRAPHQL_ENABLED',
|
||||
excludeFromDatabase: false,
|
||||
excludeFromGraphQL: true,
|
||||
})
|
||||
export class ApiKeyWorkspaceEntity extends BaseWorkspaceEntity {
|
||||
@WorkspaceField({
|
||||
standardId: API_KEY_STANDARD_FIELD_IDS.name,
|
||||
|
||||
@ -1,28 +1,26 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { ArrayContains } from 'typeorm';
|
||||
|
||||
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
|
||||
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
|
||||
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
|
||||
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
||||
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
|
||||
import { WebhookService } from 'src/engine/core-modules/webhook/webhook.service';
|
||||
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
|
||||
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
|
||||
import { WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event.type';
|
||||
import {
|
||||
CallWebhookJob,
|
||||
CallWebhookJobData,
|
||||
} from 'src/modules/webhook/jobs/call-webhook.job';
|
||||
import { WebhookWorkspaceEntity } from 'src/modules/webhook/standard-objects/webhook.workspace-entity';
|
||||
import { removeSecretFromWebhookRecord } from 'src/utils/remove-secret-from-webhook-record';
|
||||
import { ObjectRecordEventForWebhook } from 'src/modules/webhook/types/object-record-event-for-webhook.type';
|
||||
import { removeSecretFromWebhookRecord } from 'src/utils/remove-secret-from-webhook-record';
|
||||
|
||||
@Processor(MessageQueue.webhookQueue)
|
||||
export class CallWebhookJobsJob {
|
||||
constructor(
|
||||
@InjectMessageQueue(MessageQueue.webhookQueue)
|
||||
private readonly messageQueueService: MessageQueueService,
|
||||
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
|
||||
private readonly webhookService: WebhookService,
|
||||
) {}
|
||||
|
||||
@Process(CallWebhookJobsJob.name)
|
||||
@ -34,22 +32,17 @@ export class CallWebhookJobsJob {
|
||||
// Also change the openApi schema for webhooks
|
||||
// packages/twenty-server/src/engine/core-modules/open-api/utils/computeWebhooks.utils.ts
|
||||
|
||||
const webhookRepository =
|
||||
await this.twentyORMGlobalManager.getRepositoryForWorkspace<WebhookWorkspaceEntity>(
|
||||
workspaceEventBatch.workspaceId,
|
||||
'webhook',
|
||||
);
|
||||
|
||||
const [nameSingular, operation] = workspaceEventBatch.name.split('.');
|
||||
|
||||
const webhooks = await webhookRepository.find({
|
||||
where: [
|
||||
{ operations: ArrayContains([`${nameSingular}.${operation}`]) },
|
||||
{ operations: ArrayContains([`*.${operation}`]) },
|
||||
{ operations: ArrayContains([`${nameSingular}.*`]) },
|
||||
{ operations: ArrayContains(['*.*']) },
|
||||
const webhooks = await this.webhookService.findByOperations(
|
||||
workspaceEventBatch.workspaceId,
|
||||
[
|
||||
`${nameSingular}.${operation}`,
|
||||
`*.${operation}`,
|
||||
`${nameSingular}.*`,
|
||||
'*.*',
|
||||
],
|
||||
});
|
||||
);
|
||||
|
||||
for (const eventData of workspaceEventBatch.events) {
|
||||
const eventName = workspaceEventBatch.name;
|
||||
|
||||
@ -2,11 +2,12 @@ import { HttpModule } from '@nestjs/axios';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AuditModule } from 'src/engine/core-modules/audit/audit.module';
|
||||
import { WebhookModule } from 'src/engine/core-modules/webhook/webhook.module';
|
||||
import { CallWebhookJobsJob } from 'src/modules/webhook/jobs/call-webhook-jobs.job';
|
||||
import { CallWebhookJob } from 'src/modules/webhook/jobs/call-webhook.job';
|
||||
|
||||
@Module({
|
||||
imports: [HttpModule, AuditModule],
|
||||
imports: [HttpModule, AuditModule, WebhookModule],
|
||||
providers: [CallWebhookJobsJob, CallWebhookJob],
|
||||
})
|
||||
export class WebhookJobModule {}
|
||||
|
||||
@ -1,117 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,21 +0,0 @@
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,26 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -1,18 +0,0 @@
|
||||
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 {}
|
||||
@ -1,21 +0,0 @@
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,30 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -1,29 +0,0 @@
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -4,6 +4,7 @@ import { FieldMetadataType } from 'twenty-shared/types';
|
||||
import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity';
|
||||
import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator';
|
||||
import { WorkspaceField } from 'src/engine/twenty-orm/decorators/workspace-field.decorator';
|
||||
import { WorkspaceGate } from 'src/engine/twenty-orm/decorators/workspace-gate.decorator';
|
||||
import { WorkspaceIsDeprecated } from 'src/engine/twenty-orm/decorators/workspace-is-deprecated.decorator';
|
||||
import { WorkspaceIsNullable } from 'src/engine/twenty-orm/decorators/workspace-is-nullable.decorator';
|
||||
import { WorkspaceIsSystem } from 'src/engine/twenty-orm/decorators/workspace-is-system.decorator';
|
||||
@ -21,6 +22,11 @@ import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync
|
||||
labelIdentifierStandardId: WEBHOOK_STANDARD_FIELD_IDS.targetUrl,
|
||||
})
|
||||
@WorkspaceIsSystem()
|
||||
@WorkspaceGate({
|
||||
featureFlag: 'IS_WORKSPACE_API_KEY_WEBHOOK_GRAPHQL_ENABLED',
|
||||
excludeFromDatabase: false,
|
||||
excludeFromGraphQL: true,
|
||||
})
|
||||
export class WebhookWorkspaceEntity extends BaseWorkspaceEntity {
|
||||
@WorkspaceField({
|
||||
standardId: WEBHOOK_STANDARD_FIELD_IDS.targetUrl,
|
||||
|
||||
@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||
import { ApiKey } from 'src/engine/core-modules/api-key/api-key.entity';
|
||||
import { SettingPermissionType } from 'src/engine/metadata-modules/permissions/constants/setting-permission-type.constants';
|
||||
import {
|
||||
PermissionsException,
|
||||
@ -10,14 +10,10 @@ import {
|
||||
PermissionsExceptionMessage,
|
||||
} from 'src/engine/metadata-modules/permissions/permissions.exception';
|
||||
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
|
||||
import { ApiKeyWorkspaceEntity } from 'src/modules/api-key/standard-objects/api-key.workspace-entity';
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceMemberPreQueryHookService {
|
||||
constructor(
|
||||
private readonly permissionsService: PermissionsService,
|
||||
private readonly featureFlagService: FeatureFlagService,
|
||||
) {}
|
||||
constructor(private readonly permissionsService: PermissionsService) {}
|
||||
|
||||
async validateWorkspaceMemberUpdatePermissionOrThrow({
|
||||
userWorkspaceId,
|
||||
@ -30,7 +26,7 @@ export class WorkspaceMemberPreQueryHookService {
|
||||
workspaceMemberId?: string;
|
||||
targettedWorkspaceMemberId?: string;
|
||||
workspaceId: string;
|
||||
apiKey?: ApiKeyWorkspaceEntity | null;
|
||||
apiKey?: ApiKey | null;
|
||||
}) {
|
||||
if (isDefined(apiKey)) {
|
||||
return;
|
||||
|
||||
@ -1,57 +0,0 @@
|
||||
import request from 'supertest';
|
||||
|
||||
const client = request(`http://localhost:${APP_PORT}`);
|
||||
|
||||
describe('apiKeysResolver (e2e)', () => {
|
||||
it('should find many apiKeys', () => {
|
||||
const queryData = {
|
||||
query: `
|
||||
query apiKeys {
|
||||
apiKeys {
|
||||
edges {
|
||||
node {
|
||||
name
|
||||
expiresAt
|
||||
revokedAt
|
||||
id
|
||||
createdAt
|
||||
updatedAt
|
||||
deletedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
return client
|
||||
.post('/graphql')
|
||||
.set('Authorization', `Bearer ${ADMIN_ACCESS_TOKEN}`)
|
||||
.send(queryData)
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
expect(res.body.data).toBeDefined();
|
||||
expect(res.body.errors).toBeUndefined();
|
||||
})
|
||||
.expect((res) => {
|
||||
const data = res.body.data.apiKeys;
|
||||
|
||||
expect(data).toBeDefined();
|
||||
expect(Array.isArray(data.edges)).toBe(true);
|
||||
|
||||
const edges = data.edges;
|
||||
|
||||
if (edges.length > 0) {
|
||||
const apiKeys = edges[0].node;
|
||||
|
||||
expect(apiKeys).toHaveProperty('name');
|
||||
expect(apiKeys).toHaveProperty('expiresAt');
|
||||
expect(apiKeys).toHaveProperty('revokedAt');
|
||||
expect(apiKeys).toHaveProperty('id');
|
||||
expect(apiKeys).toHaveProperty('createdAt');
|
||||
expect(apiKeys).toHaveProperty('updatedAt');
|
||||
expect(apiKeys).toHaveProperty('deletedAt');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,57 +0,0 @@
|
||||
import request from 'supertest';
|
||||
|
||||
const client = request(`http://localhost:${APP_PORT}`);
|
||||
|
||||
describe('webhooksResolver (e2e)', () => {
|
||||
it('should find many webhooks', () => {
|
||||
const queryData = {
|
||||
query: `
|
||||
query webhooks {
|
||||
webhooks {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
targetUrl
|
||||
operations
|
||||
description
|
||||
createdAt
|
||||
updatedAt
|
||||
deletedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
return client
|
||||
.post('/graphql')
|
||||
.set('Authorization', `Bearer ${ADMIN_ACCESS_TOKEN}`)
|
||||
.send(queryData)
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
expect(res.body.data).toBeDefined();
|
||||
expect(res.body.errors).toBeUndefined();
|
||||
})
|
||||
.expect((res) => {
|
||||
const data = res.body.data.webhooks;
|
||||
|
||||
expect(data).toBeDefined();
|
||||
expect(Array.isArray(data.edges)).toBe(true);
|
||||
|
||||
const edges = data.edges;
|
||||
|
||||
if (edges.length > 0) {
|
||||
const webhooks = edges[0].node;
|
||||
|
||||
expect(webhooks).toHaveProperty('id');
|
||||
expect(webhooks).toHaveProperty('targetUrl');
|
||||
expect(webhooks).toHaveProperty('operations');
|
||||
expect(webhooks).toHaveProperty('description');
|
||||
expect(webhooks).toHaveProperty('createdAt');
|
||||
expect(webhooks).toHaveProperty('updatedAt');
|
||||
expect(webhooks).toHaveProperty('deletedAt');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,6 +1,5 @@
|
||||
import { OBJECT_MODEL_COMMON_FIELDS } from 'test/integration/constants/object-model-common-fields';
|
||||
import { PERSON_GQL_FIELDS } from 'test/integration/constants/person-gql-fields.constants';
|
||||
import { TEST_API_KEY_1_ID } from 'test/integration/constants/test-api-key-ids.constant';
|
||||
import {
|
||||
TEST_PERSON_1_ID,
|
||||
TEST_PERSON_2_ID,
|
||||
@ -31,14 +30,6 @@ describe('SearchResolver', () => {
|
||||
{ id: TEST_PERSON_3_ID, name: { firstName: 'searchInput3' } },
|
||||
];
|
||||
|
||||
const [apiKey] = [
|
||||
{
|
||||
id: TEST_API_KEY_1_ID,
|
||||
name: 'record not searchable',
|
||||
expiresAt: new Date(Date.now()),
|
||||
},
|
||||
];
|
||||
|
||||
const [firstPet, secondPet] = [
|
||||
{ id: TEST_PET_ID_1, name: 'searchInput1' },
|
||||
{ id: TEST_PET_ID_2, name: 'searchInput2' },
|
||||
@ -68,13 +59,6 @@ describe('SearchResolver', () => {
|
||||
secondPerson,
|
||||
thirdPerson,
|
||||
]);
|
||||
|
||||
await performCreateManyOperation(
|
||||
'apiKey',
|
||||
'apiKeys',
|
||||
OBJECT_MODEL_COMMON_FIELDS,
|
||||
[apiKey],
|
||||
);
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(error);
|
||||
|
||||
@ -0,0 +1,273 @@
|
||||
import { gql } from 'graphql-tag';
|
||||
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
|
||||
|
||||
describe('apiKeysResolver (e2e)', () => {
|
||||
let createdApiKeyId: string | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
if (createdApiKeyId) {
|
||||
await testDataSource
|
||||
.query('DELETE FROM core."apiKey" WHERE id = $1', [createdApiKeyId])
|
||||
.catch(() => {});
|
||||
createdApiKeyId = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
describe('apiKeys query', () => {
|
||||
it('should find many API keys', async () => {
|
||||
const response = await makeMetadataAPIRequest({
|
||||
query: gql`
|
||||
query GetApiKeys {
|
||||
apiKeys {
|
||||
id
|
||||
name
|
||||
expiresAt
|
||||
revokedAt
|
||||
}
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data).toBeDefined();
|
||||
expect(response.body.errors).toBeUndefined();
|
||||
expect(response.body.data.apiKeys).toBeDefined();
|
||||
expect(Array.isArray(response.body.data.apiKeys)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createApiKey mutation', () => {
|
||||
it('should create an API key successfully', async () => {
|
||||
const apiKeyInput = {
|
||||
name: 'Test API Key',
|
||||
expiresAt: '2025-12-31T23:59:59Z',
|
||||
};
|
||||
|
||||
const response = await makeMetadataAPIRequest({
|
||||
query: gql`
|
||||
mutation CreateApiKey($input: CreateApiKeyDTO!) {
|
||||
createApiKey(input: $input) {
|
||||
id
|
||||
name
|
||||
expiresAt
|
||||
revokedAt
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
input: apiKeyInput,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data).toBeDefined();
|
||||
expect(response.body.errors).toBeUndefined();
|
||||
|
||||
const createdApiKey = response.body.data.createApiKey;
|
||||
|
||||
expect(createdApiKey).toBeDefined();
|
||||
expect(createdApiKey.id).toBeDefined();
|
||||
expect(createdApiKey.name).toBe(apiKeyInput.name);
|
||||
expect(createdApiKey.expiresAt).toBe('2025-12-31T23:59:59.000Z');
|
||||
expect(createdApiKey.revokedAt).toBeNull();
|
||||
|
||||
createdApiKeyId = createdApiKey.id;
|
||||
});
|
||||
|
||||
it('should fail to create API key with invalid expiry date', async () => {
|
||||
const apiKeyInput = {
|
||||
name: 'Test API Key',
|
||||
expiresAt: 'invalid-date',
|
||||
};
|
||||
|
||||
const response = await makeMetadataAPIRequest({
|
||||
query: gql`
|
||||
mutation CreateApiKey($input: CreateApiKeyDTO!) {
|
||||
createApiKey(input: $input) {
|
||||
id
|
||||
name
|
||||
expiresAt
|
||||
revokedAt
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
input: apiKeyInput,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.errors).toBeDefined();
|
||||
expect(response.body.errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateApiKey mutation', () => {
|
||||
it('should update an API key successfully', async () => {
|
||||
const createResponse = await makeMetadataAPIRequest({
|
||||
query: gql`
|
||||
mutation CreateApiKey($input: CreateApiKeyDTO!) {
|
||||
createApiKey(input: $input) {
|
||||
id
|
||||
name
|
||||
expiresAt
|
||||
revokedAt
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
input: {
|
||||
name: 'Test API Key',
|
||||
expiresAt: '2025-12-31T23:59:59Z',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const createdApiKey = createResponse.body.data.createApiKey;
|
||||
|
||||
createdApiKeyId = createdApiKey.id;
|
||||
|
||||
const updateInput = {
|
||||
id: createdApiKey.id,
|
||||
name: 'Updated API Key',
|
||||
expiresAt: '2026-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
const updateResponse = await makeMetadataAPIRequest({
|
||||
query: gql`
|
||||
mutation UpdateApiKey($input: UpdateApiKeyDTO!) {
|
||||
updateApiKey(input: $input) {
|
||||
id
|
||||
name
|
||||
expiresAt
|
||||
revokedAt
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
input: updateInput,
|
||||
},
|
||||
});
|
||||
|
||||
expect(updateResponse.status).toBe(200);
|
||||
expect(updateResponse.body.data).toBeDefined();
|
||||
expect(updateResponse.body.errors).toBeUndefined();
|
||||
|
||||
const updatedApiKey = updateResponse.body.data.updateApiKey;
|
||||
|
||||
expect(updatedApiKey.id).toBe(createdApiKey.id);
|
||||
expect(updatedApiKey.name).toBe(updateInput.name);
|
||||
expect(updatedApiKey.expiresAt).toBe('2026-01-01T00:00:00.000Z');
|
||||
expect(updatedApiKey.revokedAt).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('apiKey query', () => {
|
||||
it('should find a specific API key', async () => {
|
||||
const createResponse = await makeMetadataAPIRequest({
|
||||
query: gql`
|
||||
mutation CreateApiKey($input: CreateApiKeyDTO!) {
|
||||
createApiKey(input: $input) {
|
||||
id
|
||||
name
|
||||
expiresAt
|
||||
revokedAt
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
input: {
|
||||
name: 'Test API Key',
|
||||
expiresAt: '2025-12-31T23:59:59Z',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const createdApiKey = createResponse.body.data.createApiKey;
|
||||
|
||||
createdApiKeyId = createdApiKey.id;
|
||||
|
||||
const queryResponse = await makeMetadataAPIRequest({
|
||||
query: gql`
|
||||
query GetApiKey($input: GetApiKeyDTO!) {
|
||||
apiKey(input: $input) {
|
||||
id
|
||||
name
|
||||
expiresAt
|
||||
revokedAt
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
input: { id: createdApiKey.id },
|
||||
},
|
||||
});
|
||||
|
||||
expect(queryResponse.status).toBe(200);
|
||||
expect(queryResponse.body.data).toBeDefined();
|
||||
expect(queryResponse.body.errors).toBeUndefined();
|
||||
|
||||
const apiKey = queryResponse.body.data.apiKey;
|
||||
|
||||
expect(apiKey).toBeDefined();
|
||||
expect(apiKey.id).toBe(createdApiKey.id);
|
||||
expect(apiKey.name).toBe(createdApiKey.name);
|
||||
expect(apiKey.expiresAt).toBe(createdApiKey.expiresAt);
|
||||
expect(apiKey.revokedAt).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('revokeApiKey mutation', () => {
|
||||
it('should revoke an API key successfully', async () => {
|
||||
const createResponse = await makeMetadataAPIRequest({
|
||||
query: gql`
|
||||
mutation CreateApiKey($input: CreateApiKeyDTO!) {
|
||||
createApiKey(input: $input) {
|
||||
id
|
||||
name
|
||||
expiresAt
|
||||
revokedAt
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
input: {
|
||||
name: 'Test API Key',
|
||||
expiresAt: '2025-12-31T23:59:59Z',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const createdApiKey = createResponse.body.data.createApiKey;
|
||||
|
||||
createdApiKeyId = createdApiKey.id;
|
||||
|
||||
const revokeResponse = await makeMetadataAPIRequest({
|
||||
query: gql`
|
||||
mutation RevokeApiKey($input: RevokeApiKeyDTO!) {
|
||||
revokeApiKey(input: $input) {
|
||||
id
|
||||
name
|
||||
expiresAt
|
||||
revokedAt
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
input: { id: createdApiKey.id },
|
||||
},
|
||||
});
|
||||
|
||||
expect(revokeResponse.status).toBe(200);
|
||||
expect(revokeResponse.body.data).toBeDefined();
|
||||
expect(revokeResponse.body.errors).toBeUndefined();
|
||||
|
||||
const revokedApiKey = revokeResponse.body.data.revokeApiKey;
|
||||
|
||||
expect(revokedApiKey.id).toBe(createdApiKey.id);
|
||||
expect(revokedApiKey.name).toBe(createdApiKey.name);
|
||||
expect(revokedApiKey.expiresAt).toBe(createdApiKey.expiresAt);
|
||||
expect(revokedApiKey.revokedAt).not.toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,311 @@
|
||||
import { gql } from 'graphql-tag';
|
||||
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
|
||||
|
||||
describe('webhooksResolver (e2e)', () => {
|
||||
let createdWebhookId: string | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
if (createdWebhookId) {
|
||||
await makeMetadataAPIRequest({
|
||||
query: gql`
|
||||
mutation DeleteWebhook($input: DeleteWebhookDTO!) {
|
||||
deleteWebhook(input: $input)
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
input: { id: createdWebhookId },
|
||||
},
|
||||
}).catch(() => {});
|
||||
createdWebhookId = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
describe('webhooks query', () => {
|
||||
it('should find many webhooks', async () => {
|
||||
const response = await makeMetadataAPIRequest({
|
||||
query: gql`
|
||||
query GetWebhooks {
|
||||
webhooks {
|
||||
id
|
||||
targetUrl
|
||||
operations
|
||||
description
|
||||
secret
|
||||
}
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data).toBeDefined();
|
||||
expect(response.body.errors).toBeUndefined();
|
||||
expect(response.body.data.webhooks).toBeDefined();
|
||||
expect(Array.isArray(response.body.data.webhooks)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createWebhook mutation', () => {
|
||||
it('should create a webhook successfully', async () => {
|
||||
const webhookInput = {
|
||||
targetUrl: 'https://example.com/webhook',
|
||||
operations: ['person.created', 'company.updated'],
|
||||
description: 'Test webhook',
|
||||
secret: 'test-secret',
|
||||
};
|
||||
|
||||
const response = await makeMetadataAPIRequest({
|
||||
query: gql`
|
||||
mutation CreateWebhook($input: CreateWebhookDTO!) {
|
||||
createWebhook(input: $input) {
|
||||
id
|
||||
targetUrl
|
||||
operations
|
||||
description
|
||||
secret
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
input: webhookInput,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data).toBeDefined();
|
||||
expect(response.body.errors).toBeUndefined();
|
||||
|
||||
const createdWebhook = response.body.data.createWebhook;
|
||||
|
||||
expect(createdWebhook).toBeDefined();
|
||||
expect(createdWebhook.id).toBeDefined();
|
||||
expect(createdWebhook.targetUrl).toBe(webhookInput.targetUrl);
|
||||
expect(createdWebhook.operations).toEqual(webhookInput.operations);
|
||||
expect(createdWebhook.description).toBe(webhookInput.description);
|
||||
expect(createdWebhook.secret).toBe(webhookInput.secret);
|
||||
|
||||
createdWebhookId = createdWebhook.id;
|
||||
});
|
||||
|
||||
it('should fail to create webhook with invalid URL', async () => {
|
||||
const webhookInput = {
|
||||
targetUrl: 'invalid-url',
|
||||
operations: ['person.created'],
|
||||
description: 'Test webhook',
|
||||
secret: 'test-secret',
|
||||
};
|
||||
|
||||
const response = await makeMetadataAPIRequest({
|
||||
query: gql`
|
||||
mutation CreateWebhook($input: CreateWebhookDTO!) {
|
||||
createWebhook(input: $input) {
|
||||
id
|
||||
targetUrl
|
||||
operations
|
||||
description
|
||||
secret
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
input: webhookInput,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.errors).toBeDefined();
|
||||
expect(response.body.errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateWebhook mutation', () => {
|
||||
it('should update a webhook successfully', async () => {
|
||||
const createResponse = await makeMetadataAPIRequest({
|
||||
query: gql`
|
||||
mutation CreateWebhook($input: CreateWebhookDTO!) {
|
||||
createWebhook(input: $input) {
|
||||
id
|
||||
targetUrl
|
||||
operations
|
||||
description
|
||||
secret
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
input: {
|
||||
targetUrl: 'https://example.com/webhook',
|
||||
operations: ['person.created'],
|
||||
description: 'Test webhook',
|
||||
secret: 'test-secret',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const createdWebhook = createResponse.body.data.createWebhook;
|
||||
|
||||
createdWebhookId = createdWebhook.id;
|
||||
|
||||
const updateInput = {
|
||||
id: createdWebhook.id,
|
||||
targetUrl: 'https://updated.com/webhook',
|
||||
operations: ['person.updated', 'company.created'],
|
||||
description: 'Updated webhook',
|
||||
secret: 'updated-secret',
|
||||
};
|
||||
|
||||
const updateResponse = await makeMetadataAPIRequest({
|
||||
query: gql`
|
||||
mutation UpdateWebhook($input: UpdateWebhookDTO!) {
|
||||
updateWebhook(input: $input) {
|
||||
id
|
||||
targetUrl
|
||||
operations
|
||||
description
|
||||
secret
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
input: updateInput,
|
||||
},
|
||||
});
|
||||
|
||||
expect(updateResponse.status).toBe(200);
|
||||
expect(updateResponse.body.data).toBeDefined();
|
||||
expect(updateResponse.body.errors).toBeUndefined();
|
||||
|
||||
const updatedWebhook = updateResponse.body.data.updateWebhook;
|
||||
|
||||
expect(updatedWebhook.id).toBe(createdWebhook.id);
|
||||
expect(updatedWebhook.targetUrl).toBe(updateInput.targetUrl);
|
||||
expect(updatedWebhook.operations).toEqual(updateInput.operations);
|
||||
expect(updatedWebhook.description).toBe(updateInput.description);
|
||||
expect(updatedWebhook.secret).toBe(updateInput.secret);
|
||||
});
|
||||
});
|
||||
|
||||
describe('webhook query', () => {
|
||||
it('should find a specific webhook', async () => {
|
||||
const createResponse = await makeMetadataAPIRequest({
|
||||
query: gql`
|
||||
mutation CreateWebhook($input: CreateWebhookDTO!) {
|
||||
createWebhook(input: $input) {
|
||||
id
|
||||
targetUrl
|
||||
operations
|
||||
description
|
||||
secret
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
input: {
|
||||
targetUrl: 'https://example.com/webhook',
|
||||
operations: ['person.created'],
|
||||
description: 'Test webhook',
|
||||
secret: 'test-secret',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const createdWebhook = createResponse.body.data.createWebhook;
|
||||
|
||||
createdWebhookId = createdWebhook.id;
|
||||
|
||||
const queryResponse = await makeMetadataAPIRequest({
|
||||
query: gql`
|
||||
query GetWebhook($input: GetWebhookDTO!) {
|
||||
webhook(input: $input) {
|
||||
id
|
||||
targetUrl
|
||||
operations
|
||||
description
|
||||
secret
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
input: { id: createdWebhook.id },
|
||||
},
|
||||
});
|
||||
|
||||
expect(queryResponse.status).toBe(200);
|
||||
expect(queryResponse.body.data).toBeDefined();
|
||||
expect(queryResponse.body.errors).toBeUndefined();
|
||||
|
||||
const webhook = queryResponse.body.data.webhook;
|
||||
|
||||
expect(webhook).toBeDefined();
|
||||
expect(webhook.id).toBe(createdWebhook.id);
|
||||
expect(webhook.targetUrl).toBe(createdWebhook.targetUrl);
|
||||
expect(webhook.operations).toEqual(createdWebhook.operations);
|
||||
expect(webhook.description).toBe(createdWebhook.description);
|
||||
expect(webhook.secret).toBe(createdWebhook.secret);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteWebhook mutation', () => {
|
||||
it('should delete a webhook successfully', async () => {
|
||||
const createResponse = await makeMetadataAPIRequest({
|
||||
query: gql`
|
||||
mutation CreateWebhook($input: CreateWebhookDTO!) {
|
||||
createWebhook(input: $input) {
|
||||
id
|
||||
targetUrl
|
||||
operations
|
||||
description
|
||||
secret
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
input: {
|
||||
targetUrl: 'https://example.com/webhook',
|
||||
operations: ['person.created'],
|
||||
description: 'Test webhook',
|
||||
secret: 'test-secret',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const createdWebhook = createResponse.body.data.createWebhook;
|
||||
|
||||
const deleteResponse = await makeMetadataAPIRequest({
|
||||
query: gql`
|
||||
mutation DeleteWebhook($input: DeleteWebhookDTO!) {
|
||||
deleteWebhook(input: $input)
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
input: { id: createdWebhook.id },
|
||||
},
|
||||
});
|
||||
|
||||
expect(deleteResponse.status).toBe(200);
|
||||
expect(deleteResponse.body.data).toBeDefined();
|
||||
expect(deleteResponse.body.errors).toBeUndefined();
|
||||
|
||||
const queryResponse = await makeMetadataAPIRequest({
|
||||
query: gql`
|
||||
query GetWebhook($input: GetWebhookDTO!) {
|
||||
webhook(input: $input) {
|
||||
id
|
||||
targetUrl
|
||||
operations
|
||||
description
|
||||
secret
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
input: { id: createdWebhook.id },
|
||||
},
|
||||
});
|
||||
|
||||
expect(queryResponse.status).toBe(200);
|
||||
expect(queryResponse.body.data.webhook).toBeNull();
|
||||
|
||||
createdWebhookId = undefined;
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user