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:
@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user