diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-18/1-18-migrate-workspace-pictures.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-18/1-18-migrate-workspace-pictures.command.ts index 5cf7244336..3439eb02ee 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/1-18/1-18-migrate-workspace-pictures.command.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/1-18/1-18-migrate-workspace-pictures.command.ts @@ -1,6 +1,7 @@ import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; import { isNonEmptyString } from '@sniptt/guards'; +import FileType from 'file-type'; import { Command } from 'nest-commander'; import { STANDARD_OBJECTS } from 'twenty-shared/metadata'; import { FileFolder } from 'twenty-shared/types'; @@ -20,6 +21,7 @@ import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/service import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity'; import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service'; +import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service'; import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util'; @@ -27,6 +29,7 @@ import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; +import { getImageBufferFromUrl } from 'src/utils/image'; @Command({ name: 'upgrade:1-18:migrate-workspace-pictures', @@ -44,6 +47,7 @@ export class MigrateWorkspacePicturesCommand extends ActiveOrSuspendedWorkspaces private readonly workspaceCacheService: WorkspaceCacheService, private readonly applicationService: ApplicationService, private readonly fileUrlService: FileUrlService, + private readonly secureHttpClientService: SecureHttpClientService, @InjectDataSource() private readonly coreDataSource: DataSource, ) { @@ -135,14 +139,60 @@ export class MigrateWorkspacePicturesCommand extends ActiveOrSuspendedWorkspaces return; } + const isInWorkspaceLogo = workspace.logo.startsWith( + FileFolder.WorkspaceLogo, + ); + + const isTwentyIconLogo = workspace.logo.includes('twenty-icons'); + + if (!isTwentyIconLogo && !isInWorkspaceLogo) { + this.logger.log( + `Workspace logo is not a twenty icon or a workspace logo, skipping`, + ); + + return; + } + this.logger.log( `Migrating workspace logo for workspace ${workspaceId}: ${workspace.logo}`, ); + if (isInWorkspaceLogo) { + await this.migrateWorkspaceLogoFromWorkspaceFolder({ + workspaceId, + logoPath: workspace.logo, + isDryRun, + workspaceCustomFlatApplication, + fileRepository, + }); + } + + if (isTwentyIconLogo) { + await this.migrateWorkspaceLogoFromTwentyIcons({ + workspaceId, + logoUrl: workspace.logo, + isDryRun, + workspaceCustomFlatApplication, + }); + } + } + + private async migrateWorkspaceLogoFromWorkspaceFolder({ + workspaceId, + logoPath, + isDryRun, + workspaceCustomFlatApplication, + fileRepository, + }: { + workspaceId: string; + logoPath: string; + isDryRun: boolean; + workspaceCustomFlatApplication: FlatApplication; + fileRepository: Repository; + }): Promise { try { - const { type: fileExtension } = extractFolderPathFilenameAndTypeOrThrow( - workspace.logo, - ); + const { type: fileExtension } = + extractFolderPathFilenameAndTypeOrThrow(logoPath); const fileId = v4(); const newFilename = `${fileId}${isNonEmptyString(fileExtension) ? `.${fileExtension}` : ''}`; @@ -152,7 +202,7 @@ export class MigrateWorkspacePicturesCommand extends ActiveOrSuspendedWorkspaces await this.fileStorageService.copyLegacy({ from: { folderPath: `workspace-${workspaceId}`, - filename: workspace.logo, + filename: logoPath, }, to: { folderPath: `${workspaceId}/${workspaceCustomFlatApplication.universalIdentifier}`, @@ -181,7 +231,7 @@ export class MigrateWorkspacePicturesCommand extends ActiveOrSuspendedWorkspaces } this.logger.log( - `Migrated workspace logo for workspace ${workspaceId} (${workspace.logo} -> ${newResourcePath})`, + `Migrated workspace logo for workspace ${workspaceId} (${logoPath} -> ${newResourcePath})`, ); } catch (error) { this.logger.error( @@ -191,6 +241,67 @@ export class MigrateWorkspacePicturesCommand extends ActiveOrSuspendedWorkspaces } } + private async migrateWorkspaceLogoFromTwentyIcons({ + workspaceId, + logoUrl, + isDryRun, + workspaceCustomFlatApplication, + }: { + workspaceId: string; + logoUrl: string; + isDryRun: boolean; + workspaceCustomFlatApplication: FlatApplication; + }): Promise { + try { + const httpClient = this.secureHttpClientService.getHttpClient(); + const buffer = await getImageBufferFromUrl(logoUrl, httpClient); + + const type = await FileType.fromBuffer(buffer); + + if (!isDefined(type) || !type.mime.startsWith('image/')) { + this.logger.warn( + `Unable to detect image type for workspace logo ${logoUrl}, skipping`, + ); + + return; + } + + const fileId = v4(); + const newFilename = `${fileId}.${type.ext}`; + const newResourcePath = `${newFilename}`; + + if (!isDryRun) { + const fileEntity = await this.fileStorageService.writeFile({ + workspaceId, + applicationUniversalIdentifier: + workspaceCustomFlatApplication.universalIdentifier, + fileFolder: FileFolder.CorePicture, + resourcePath: newResourcePath, + sourceFile: buffer, + mimeType: type.mime, + fileId, + settings: { + isTemporaryFile: false, + toDelete: false, + }, + }); + + await this.workspaceRepository.update( + { id: workspaceId }, + { logoFileId: fileEntity.id }, + ); + } + + this.logger.log( + `Migrated workspace logo from twenty-icons for workspace ${workspaceId} (${logoUrl} -> ${FileFolder.CorePicture}/${newResourcePath})`, + ); + } catch (error) { + this.logger.error( + `Failed to migrate workspace logo from twenty-icons for workspace ${workspaceId}: ${error.message}`, + ); + } + } + private async migrateWorkspaceMemberAvatars({ workspaceId, isDryRun, diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-18/1-18-upgrade-version-command.module.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-18/1-18-upgrade-version-command.module.ts index a0c5f7cc8c..5c76a9be8c 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/1-18/1-18-upgrade-version-command.module.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/1-18/1-18-upgrade-version-command.module.ts @@ -15,6 +15,7 @@ import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature- import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity'; import { FileModule } from 'src/engine/core-modules/file/file.module'; +import { SecureHttpClientModule } from 'src/engine/core-modules/secure-http-client/secure-http-client.module'; import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module'; @@ -51,6 +52,7 @@ import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/sta FileModule, UserWorkspaceModule, WorkspaceMigrationModule, + SecureHttpClientModule, ], providers: [ MigratePersonAvatarFilesCommand, diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts index 45f710f6c3..9d160d6204 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts @@ -38,13 +38,14 @@ import { EmailVerificationModule } from 'src/engine/core-modules/email-verificat import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-upload.module'; +import { FileModule } from 'src/engine/core-modules/file/file.module'; import { GuardRedirectModule } from 'src/engine/core-modules/guard-redirect/guard-redirect.module'; import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module'; import { KeyValuePairEntity } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity'; import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module'; import { OnboardingModule } from 'src/engine/core-modules/onboarding/onboarding.module'; -import { WorkspaceSSOModule } from 'src/engine/core-modules/sso/sso.module'; import { SecureHttpClientModule } from 'src/engine/core-modules/secure-http-client/secure-http-client.module'; +import { WorkspaceSSOModule } from 'src/engine/core-modules/sso/sso.module'; import { WorkspaceSSOIdentityProviderEntity } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity'; import { TwoFactorAuthenticationMethodEntity } from 'src/engine/core-modules/two-factor-authentication/entities/two-factor-authentication-method.entity'; import { TwoFactorAuthenticationModule } from 'src/engine/core-modules/two-factor-authentication/two-factor-authentication.module'; @@ -117,6 +118,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy'; ApplicationModule, WorkspaceCacheModule, SecureHttpClientModule, + FileModule, ], controllers: [ GoogleAuthController, diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.spec.ts index 2a54372344..7c42caa292 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.spec.ts @@ -97,6 +97,9 @@ const createSignInUpServiceForTests = () => { { createWorkspaceCustomApplication: jest.fn(), } as any, + { + uploadWorkspaceLogoFromUrl: jest.fn(), + } as any, { createQueryRunner: jest.fn(() => queryRunnerMock), } as any, diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts index ad85a87733..48d508084a 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts @@ -3,6 +3,7 @@ import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; import { msg } from '@lingui/core/macro'; import { TWENTY_ICONS_BASE_URL } from 'twenty-shared/constants'; +import { isDefined } from 'twenty-shared/utils'; import { WorkspaceActivationStatus } from 'twenty-shared/workspace'; import { Repository, type DataSource, type QueryRunner } from 'typeorm'; import { v4 } from 'uuid'; @@ -27,6 +28,7 @@ import { type SignInUpNewUserPayload, } from 'src/engine/core-modules/auth/types/signInUp.type'; import { SubdomainManagerService } from 'src/engine/core-modules/domain/subdomain-manager/services/subdomain-manager.service'; +import { FileCorePictureService } from 'src/engine/core-modules/file/file-core-picture/services/file-core-picture.service'; import { MetricsService } from 'src/engine/core-modules/metrics/metrics.service'; import { MetricsKeys } from 'src/engine/core-modules/metrics/types/metrics-keys.type'; import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service'; @@ -62,6 +64,7 @@ export class SignInUpService { private readonly metricsService: MetricsService, private readonly workspaceCacheService: WorkspaceCacheService, private readonly applicationService: ApplicationService, + private readonly fileCorePictureService: FileCorePictureService, @InjectDataSource() private readonly dataSource: DataSource, ) {} @@ -463,21 +466,7 @@ export class SignInUpService { const shouldGrantServerAdmin = !(await this.hasServerAdmin()); - const logoUrl = `${TWENTY_ICONS_BASE_URL}/${getDomainNameByEmail(email)}`; - const isLogoUrlValid = async () => { - try { - const httpClient = this.secureHttpClientService.getHttpClient(); - const response = await httpClient.get(logoUrl, { timeout: 600 }); - - return response.status === 200; - } catch { - return false; - } - }; - const isWorkEmailFound = isWorkEmail(email); - const logo = - isWorkEmailFound && (await isLogoUrlValid()) ? logoUrl : undefined; const workspaceId = v4(); const workspaceCustomApplicationId = v4(); @@ -496,7 +485,6 @@ export class SignInUpService { displayName: '', inviteHash: v4(), activationStatus: WorkspaceActivationStatus.PENDING_CREATION, - logo, }); const workspace = await queryRunner.manager.save( @@ -513,6 +501,26 @@ export class SignInUpService { queryRunner, ); + if (isWorkEmailFound) { + const logoUrl = `${TWENTY_ICONS_BASE_URL}/${getDomainNameByEmail(email)}`; + const logoFile = + await this.fileCorePictureService.uploadWorkspaceLogoFromUrl({ + imageUrl: logoUrl, + workspaceId, + applicationUniversalIdentifier: + customApplication.universalIdentifier, + queryRunner, + }); + + if (isDefined(logoFile)) { + await queryRunner.manager.update( + WorkspaceEntity, + { id: workspaceId }, + { logoFileId: logoFile.id }, + ); + } + } + const isExistingUser = userData.type === 'existingUser'; const user = isExistingUser ? userData.existingUser diff --git a/packages/twenty-server/src/engine/core-modules/file/file-core-picture/resolvers/file-core-picture.resolver.ts b/packages/twenty-server/src/engine/core-modules/file/file-core-picture/resolvers/file-core-picture.resolver.ts index 3e4bdf0545..0312515691 100644 --- a/packages/twenty-server/src/engine/core-modules/file/file-core-picture/resolvers/file-core-picture.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/file/file-core-picture/resolvers/file-core-picture.resolver.ts @@ -16,11 +16,15 @@ import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.ent import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; +import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter'; import { streamToBuffer } from 'src/utils/stream-to-buffer'; @UseGuards(WorkspaceAuthGuard) @UsePipes(ResolverValidationPipe) -@UseFilters(PreventNestToAutoLogGraphqlErrorsFilter) +@UseFilters( + PermissionsGraphqlApiExceptionFilter, + PreventNestToAutoLogGraphqlErrorsFilter, +) @MetadataResolver() export class FileCorePictureResolver { constructor( diff --git a/packages/twenty-server/src/engine/core-modules/file/file-core-picture/services/file-core-picture.service.ts b/packages/twenty-server/src/engine/core-modules/file/file-core-picture/services/file-core-picture.service.ts index 7c3995404e..75fb6e9d64 100644 --- a/packages/twenty-server/src/engine/core-modules/file/file-core-picture/services/file-core-picture.service.ts +++ b/packages/twenty-server/src/engine/core-modules/file/file-core-picture/services/file-core-picture.service.ts @@ -64,7 +64,7 @@ export class FileCorePictureService { const savedFile = await this.fileStorageService.writeFile({ sourceFile: sanitizedFile, - resourcePath: `${FileFolder.CorePicture}/${finalName}`, + resourcePath: finalName, mimeType, fileFolder: FileFolder.CorePicture, applicationUniversalIdentifier: universalIdentifier, @@ -182,6 +182,25 @@ export class FileCorePictureService { }); } + private async fetchImageBufferFromUrl( + imageUrl: string, + ): Promise<{ buffer: Buffer; extension: string } | undefined> { + try { + const httpClient = this.secureHttpClientService.getHttpClient(); + const buffer = await getImageBufferFromUrl(imageUrl, httpClient); + + const type = await FileType.fromBuffer(buffer); + + if (!isDefined(type) || !type.mime.startsWith('image/')) { + return undefined; + } + + return { buffer, extension: type.ext }; + } catch { + return undefined; + } + } + async uploadWorkspaceMemberProfilePictureFromUrl({ imageUrl, workspaceId, @@ -193,18 +212,41 @@ export class FileCorePictureService { applicationUniversalIdentifier?: string; queryRunner?: QueryRunner; }): Promise { - const httpClient = this.secureHttpClientService.getHttpClient(); - const buffer = await getImageBufferFromUrl(imageUrl, httpClient); + const imageData = await this.fetchImageBufferFromUrl(imageUrl); - const type = await FileType.fromBuffer(buffer); - - if (!isDefined(type) || !type.mime.startsWith('image/')) { - return; + if (!isDefined(imageData)) { + return undefined; } return this.uploadWorkspaceMemberProfilePicture({ - file: buffer, - filename: `avatar.${type.ext}`, + file: imageData.buffer, + filename: `avatar.${imageData.extension}`, + workspaceId, + applicationUniversalIdentifier, + queryRunner, + }); + } + + async uploadWorkspaceLogoFromUrl({ + imageUrl, + workspaceId, + applicationUniversalIdentifier, + queryRunner, + }: { + imageUrl: string; + workspaceId: string; + applicationUniversalIdentifier?: string; + queryRunner?: QueryRunner; + }): Promise { + const imageData = await this.fetchImageBufferFromUrl(imageUrl); + + if (!isDefined(imageData)) { + return undefined; + } + + return this.uploadCorePicture({ + file: imageData.buffer, + filename: `logo.${imageData.extension}`, workspaceId, applicationUniversalIdentifier, queryRunner, diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts index 4c5c5585b7..92ba450a35 100644 --- a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts @@ -111,6 +111,21 @@ export const seedFeatureFlags = async ({ workspaceId: workspaceId, value: true, }, + { + key: FeatureFlagKey.IS_CORE_PICTURE_MIGRATED, + workspaceId: workspaceId, + value: true, + }, + { + key: FeatureFlagKey.IS_FILES_FIELD_MIGRATED, + workspaceId: workspaceId, + value: true, + }, + { + key: FeatureFlagKey.IS_OTHER_FILE_MIGRATED, + workspaceId: workspaceId, + value: true, + }, ]) .execute(); }; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/constant/default-feature-flags.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/constant/default-feature-flags.ts index 95f7ac272c..f3e9b049a8 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/constant/default-feature-flags.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/constant/default-feature-flags.ts @@ -5,4 +5,7 @@ export const DEFAULT_FEATURE_FLAGS = [ FeatureFlagKey.IS_NOTE_TARGET_MIGRATED, FeatureFlagKey.IS_TASK_TARGET_MIGRATED, FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_ENABLED, + FeatureFlagKey.IS_CORE_PICTURE_MIGRATED, + FeatureFlagKey.IS_FILES_FIELD_MIGRATED, + FeatureFlagKey.IS_OTHER_FILE_MIGRATED, ] as const satisfies FeatureFlagKey[]; diff --git a/packages/twenty-server/test/integration/graphql/suites/settings-permissions/security.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/settings-permissions/security.integration-spec.ts index e362291eeb..0eb12859d8 100644 --- a/packages/twenty-server/test/integration/graphql/suites/settings-permissions/security.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/settings-permissions/security.integration-spec.ts @@ -1,10 +1,20 @@ import { gql } from 'graphql-tag'; import request from 'supertest'; +import { makeMetadataAPIRequestWithFileUpload } from 'test/integration/metadata/suites/utils/make-metadata-api-request-with-file-upload.util'; import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util'; import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; import { PermissionsExceptionMessage } from 'src/engine/metadata-modules/permissions/permissions.exception'; +const uploadWorkspaceLogoMutation = gql` + mutation UploadWorkspaceLogo($file: Upload!) { + uploadWorkspaceLogo(file: $file) { + id + url + } + } +`; + const client = request(`http://localhost:${APP_PORT}`); describe('Security permissions', () => { @@ -506,62 +516,85 @@ describe('Security permissions', () => { }); describe('logo update', () => { + beforeAll(() => { + jest.useRealTimers(); + }); + + afterAll(() => { + jest.useFakeTimers(); + }); + it('should update workspace logo when user has workspace settings permission', async () => { - const queryData = { - query: ` - mutation updateWorkspace { - updateWorkspace(data: { logo: "new-logo" }) { - id + const testImageBuffer = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', + 'base64', + ); + + const uploadResponse = await makeMetadataAPIRequestWithFileUpload( + { + query: uploadWorkspaceLogoMutation, + variables: { file: null }, + }, + { + field: 'file', + buffer: testImageBuffer, + filename: 'test-logo.png', + contentType: 'image/png', + }, + APPLE_JANE_ADMIN_ACCESS_TOKEN, + ); + + expect(uploadResponse.status).toBe(200); + expect(uploadResponse.body.errors).toBeUndefined(); + expect(uploadResponse.body.data).toBeDefined(); + expect(uploadResponse.body.data.uploadWorkspaceLogo).toBeDefined(); + expect(uploadResponse.body.data.uploadWorkspaceLogo.id).toBeDefined(); + expect(uploadResponse.body.data.uploadWorkspaceLogo.url).toBeDefined(); + + const getWorkspaceQuery = gql` + query GetWorkspace { + currentWorkspace { logo } } - `, - }; + `; - return client - .post('/metadata') - .set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`) - .send(queryData) - .expect(200) - .expect((res) => { - expect(res.body.data).toBeDefined(); - expect(res.body.errors).toBeUndefined(); - }) - .expect((res) => { - const data = res.body.data.updateWorkspace; + const workspaceResponse = await makeMetadataAPIRequest({ + query: getWorkspaceQuery, + }); - expect(data).toBeDefined(); - expect(data.logo).toContain('new-logo'); - }); + expect(workspaceResponse.body.data.currentWorkspace.logo).toBeDefined(); }); it('should throw a permission error when user does not have permission (member role)', async () => { - const queryData = { - query: ` - mutation updateWorkspace { - updateWorkspace(data: { logo: "another-new-logo" }) { - id - logo - } - } - `, - }; + const testImageBuffer = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', + 'base64', + ); - await client - .post('/metadata') - .set('Authorization', `Bearer ${APPLE_JONY_MEMBER_ACCESS_TOKEN}`) - .send(queryData) - .expect(200) - .expect((res) => { - expect(res.body.data).toBeNull(); - expect(res.body.errors).toBeDefined(); - expect(res.body.errors[0].message).toBe( - PermissionsExceptionMessage.PERMISSION_DENIED, - ); - expect(res.body.errors[0].extensions.code).toBe( - ErrorCode.FORBIDDEN, - ); - }); + const response = await makeMetadataAPIRequestWithFileUpload( + { + query: uploadWorkspaceLogoMutation, + variables: { file: null }, + }, + { + field: 'file', + buffer: testImageBuffer, + filename: 'test-logo.png', + contentType: 'image/png', + }, + APPLE_JONY_MEMBER_ACCESS_TOKEN, + ); + + expect(response.status).toBe(200); + expect(response.body.data).toBeNull(); + expect(response.body.errors).toBeDefined(); + expect(response.body.errors[0].message).toBe( + PermissionsExceptionMessage.PERMISSION_DENIED, + ); + expect(response.body.errors[0].extensions.code).toBe( + ErrorCode.FORBIDDEN, + ); }); }); }); diff --git a/packages/twenty-server/test/integration/graphql/suites/settings-permissions/workspace.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/settings-permissions/workspace.integration-spec.ts index b3229f365f..4cd5602cf5 100644 --- a/packages/twenty-server/test/integration/graphql/suites/settings-permissions/workspace.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/settings-permissions/workspace.integration-spec.ts @@ -1,5 +1,6 @@ import gql from 'graphql-tag'; import request from 'supertest'; +import { makeMetadataAPIRequestWithFileUpload } from 'test/integration/metadata/suites/utils/make-metadata-api-request-with-file-upload.util'; import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util'; import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum'; @@ -7,6 +8,15 @@ import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/featu import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; import { PermissionsExceptionMessage } from 'src/engine/metadata-modules/permissions/permissions.exception'; +const uploadWorkspaceLogoMutation = gql` + mutation UploadWorkspaceLogo($file: Upload!) { + uploadWorkspaceLogo(file: $file) { + id + url + } + } +`; + const client = request(`http://localhost:${APP_PORT}`); describe('workspace permissions', () => { @@ -266,62 +276,85 @@ describe('workspace permissions', () => { }); describe('logo update', () => { + beforeAll(() => { + jest.useRealTimers(); + }); + + afterAll(() => { + jest.useFakeTimers(); + }); + it('should update workspace logo when user has workspace settings permission', async () => { - const queryData = { - query: ` - mutation updateWorkspace { - updateWorkspace(data: { logo: "new-logo" }) { - id - logo + const testImageBuffer = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', + 'base64', + ); + + const uploadResponse = await makeMetadataAPIRequestWithFileUpload( + { + query: uploadWorkspaceLogoMutation, + variables: { file: null }, + }, + { + field: 'file', + buffer: testImageBuffer, + filename: 'test-logo.png', + contentType: 'image/png', + }, + APPLE_JANE_ADMIN_ACCESS_TOKEN, + ); + + expect(uploadResponse.status).toBe(200); + expect(uploadResponse.body.errors).toBeUndefined(); + expect(uploadResponse.body.data).toBeDefined(); + expect(uploadResponse.body.data.uploadWorkspaceLogo).toBeDefined(); + expect(uploadResponse.body.data.uploadWorkspaceLogo.id).toBeDefined(); + expect(uploadResponse.body.data.uploadWorkspaceLogo.url).toBeDefined(); + + const getWorkspaceQuery = gql` + query GetWorkspace { + currentWorkspace { + logo + } } - } - `, - }; + `; - return client - .post('/metadata') - .set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`) - .send(queryData) - .expect(200) - .expect((res) => { - expect(res.body.data).toBeDefined(); - expect(res.body.errors).toBeUndefined(); - }) - .expect((res) => { - const data = res.body.data.updateWorkspace; + const workspaceResponse = await makeMetadataAPIRequest({ + query: getWorkspaceQuery, + }); - expect(data).toBeDefined(); - expect(data.logo).toContain('new-logo'); - }); + expect(workspaceResponse.body.data.currentWorkspace.logo).toBeDefined(); }); it('should throw a permission error when user does not have permission (member role)', async () => { - const queryData = { - query: ` - mutation updateWorkspace { - updateWorkspace(data: { logo: "another-new-logo" }) { - id - logo - } - } - `, - }; + const testImageBuffer = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', + 'base64', + ); - await client - .post('/metadata') - .set('Authorization', `Bearer ${APPLE_JONY_MEMBER_ACCESS_TOKEN}`) - .send(queryData) - .expect(200) - .expect((res) => { - expect(res.body.data).toBeNull(); - expect(res.body.errors).toBeDefined(); - expect(res.body.errors[0].message).toBe( - PermissionsExceptionMessage.PERMISSION_DENIED, - ); - expect(res.body.errors[0].extensions.code).toBe( - ErrorCode.FORBIDDEN, - ); - }); + const response = await makeMetadataAPIRequestWithFileUpload( + { + query: uploadWorkspaceLogoMutation, + variables: { file: null }, + }, + { + field: 'file', + buffer: testImageBuffer, + filename: 'test-logo.png', + contentType: 'image/png', + }, + APPLE_JONY_MEMBER_ACCESS_TOKEN, + ); + + expect(response.status).toBe(200); + expect(response.body.data).toBeNull(); + expect(response.body.errors).toBeDefined(); + expect(response.body.errors[0].message).toBe( + PermissionsExceptionMessage.PERMISSION_DENIED, + ); + expect(response.body.errors[0].extensions.code).toBe( + ErrorCode.FORBIDDEN, + ); }); }); });