diff --git a/packages/twenty-server/src/engine/core-modules/file/controllers/file.controller.spec.ts b/packages/twenty-server/src/engine/core-modules/file/controllers/file.controller.spec.ts index 60f0394349..a65a4e221f 100644 --- a/packages/twenty-server/src/engine/core-modules/file/controllers/file.controller.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/file/controllers/file.controller.spec.ts @@ -10,11 +10,6 @@ jest.mock('node:stream/promises', () => ({ pipeline: jest.fn(), })); -import { - FileStorageException, - FileStorageExceptionCode, -} from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception'; - import { FileException, FileExceptionCode, @@ -32,7 +27,6 @@ const createMockStream = (): Readable => { stream.push('file content'); stream.push(null); - stream.pipe = jest.fn(); return stream; }; @@ -153,7 +147,7 @@ describe('FileController', () => { 'Content-Disposition', 'inline', ); - expect(mockStream.pipe).toHaveBeenCalledWith(mockResponse); + expect(mockPipeline).toHaveBeenCalledWith(mockStream, mockResponse); }); it('should force attachment disposition for non-safe MIME types', async () => { @@ -185,15 +179,8 @@ describe('FileController', () => { ); }); - it('should throw FileException with FILE_NOT_FOUND when file is not found', async () => { - jest - .spyOn(fileService, 'getFileResponseById') - .mockRejectedValue( - new FileStorageException( - 'File not found', - FileStorageExceptionCode.FILE_NOT_FOUND, - ), - ); + it('should throw FILE_NOT_FOUND when the service yields null', async () => { + jest.spyOn(fileService, 'getFileResponseById').mockResolvedValue(null); const mockRequest = { workspaceId: 'workspace-id' } as any; const mockResponse = createMockResponse() as any; @@ -210,27 +197,97 @@ describe('FileController', () => { ); }); - it('should throw FileException with INTERNAL_SERVER_ERROR for unexpected errors', async () => { + it('should throw INTERNAL_SERVER_ERROR without leaking the underlying message, and log the original error', async () => { + const loggerSpy = jest + .spyOn(Logger.prototype, 'error') + .mockImplementation(() => undefined); + const underlyingError = new Error( + 'Storage unavailable: postgres://secret-host:5432', + ); + jest .spyOn(fileService, 'getFileResponseById') - .mockRejectedValue(new Error('Storage unavailable')); + .mockRejectedValue(underlyingError); const mockRequest = { workspaceId: 'workspace-id' } as any; const mockResponse = createMockResponse() as any; + const promise = controller.getFileById( + mockResponse, + mockRequest, + FileFolder.Workflow, + 'file-456', + ); + + await expect(promise).rejects.toThrow( + new FileException( + 'Error retrieving file', + FileExceptionCode.INTERNAL_SERVER_ERROR, + ), + ); + await expect(promise).rejects.not.toThrow(/secret-host/); + + expect(loggerSpy).toHaveBeenCalledWith( + 'getFileResponseById failed unexpectedly', + { error: underlyingError }, + ); + }); + + it('should throw INTERNAL_SERVER_ERROR when the stream errors before headers are sent', async () => { + const mockStream = createMockStream(); + + jest.spyOn(fileService, 'getFileResponseById').mockResolvedValue({ + type: 'stream', + stream: mockStream, + mimeType: 'image/png', + }); + + mockPipeline.mockRejectedValue(new Error('source backend exploded')); + + const mockRequest = { workspaceId: 'workspace-id' } as any; + const mockResponse = createMockResponse({ headersSent: false }) as any; + await expect( controller.getFileById( mockResponse, mockRequest, - FileFolder.Workflow, - 'file-456', + FileFolder.CorePicture, + 'file-123', ), ).rejects.toThrow( new FileException( - 'Error retrieving file: Storage unavailable', + 'Error streaming file from storage', FileExceptionCode.INTERNAL_SERVER_ERROR, ), ); + + expect(mockResponse.destroy).not.toHaveBeenCalled(); + }); + + it('should destroy the response without throwing when the stream errors after headers are sent', async () => { + const mockStream = createMockStream(); + + jest.spyOn(fileService, 'getFileResponseById').mockResolvedValue({ + type: 'stream', + stream: mockStream, + mimeType: 'image/png', + }); + + mockPipeline.mockRejectedValue(new Error('socket reset mid-flight')); + + const mockRequest = { workspaceId: 'workspace-id' } as any; + const mockResponse = createMockResponse({ headersSent: true }) as any; + + // No throw expected — once headers are out, the controller cannot honestly + // switch to a 500 response, so it tears the socket down instead. + await controller.getFileById( + mockResponse, + mockRequest, + FileFolder.CorePicture, + 'file-123', + ); + + expect(mockResponse.destroy).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/twenty-server/src/engine/core-modules/file/controllers/file.controller.ts b/packages/twenty-server/src/engine/core-modules/file/controllers/file.controller.ts index ff3193fad7..c2de628a38 100644 --- a/packages/twenty-server/src/engine/core-modules/file/controllers/file.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/file/controllers/file.controller.ts @@ -15,10 +15,6 @@ import { join } from 'path'; import { Request, Response } from 'express'; import { FileFolder } from 'twenty-shared/types'; -import { - FileStorageException, - FileStorageExceptionCode, -} from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception'; import { validateFilePath } from 'src/engine/core-modules/file-storage/utils/validate-file-path.util'; import { FileException, @@ -120,45 +116,49 @@ export class FileController { // oxlint-disable-next-line @typescripttypescript/no-explicit-any const workspaceId = (req as any)?.workspaceId; - try { - const fileResponse = await this.fileService.getFileResponseById({ + const fileResponse = await this.fileService + .getFileResponseById({ fileId, workspaceId, fileFolder, - }); + }) + .catch((error) => { + this.logger.error('getFileResponseById failed unexpectedly', { + error, + }); - if (fileResponse.type === 'redirect') { - return res.redirect(fileResponse.presignedUrl); - } - - setFileResponseHeaders(res, fileResponse.mimeType); - - fileResponse.stream.on('error', () => { - if (!res.headersSent) { - res.status(500).send('Error streaming file from storage'); - - return; - } - - res.destroy(); - }); - - fileResponse.stream.pipe(res); - } catch (error) { - if ( - error instanceof FileStorageException && - error.code === FileStorageExceptionCode.FILE_NOT_FOUND - ) { throw new FileException( - 'File not found', - FileExceptionCode.FILE_NOT_FOUND, + 'Error retrieving file', + FileExceptionCode.INTERNAL_SERVER_ERROR, + ); + }); + + if (fileResponse === null) { + throw new FileException( + 'File not found', + FileExceptionCode.FILE_NOT_FOUND, + ); + } + + if (fileResponse.type === 'redirect') { + return res.redirect(fileResponse.presignedUrl); + } + + setFileResponseHeaders(res, fileResponse.mimeType); + + try { + await pipeline(fileResponse.stream, res); + } catch (error) { + this.logger.error('File-by-id stream failed mid-transfer', { error }); + + if (!res.headersSent) { + throw new FileException( + 'Error streaming file from storage', + FileExceptionCode.INTERNAL_SERVER_ERROR, ); } - throw new FileException( - `Error retrieving file: ${error.message}`, - FileExceptionCode.INTERNAL_SERVER_ERROR, - ); + res.destroy(); } } } diff --git a/packages/twenty-server/src/engine/core-modules/file/services/file.service.ts b/packages/twenty-server/src/engine/core-modules/file/services/file.service.ts index ffb82b7f11..a13c57a72d 100644 --- a/packages/twenty-server/src/engine/core-modules/file/services/file.service.ts +++ b/packages/twenty-server/src/engine/core-modules/file/services/file.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { type Readable } from 'stream'; @@ -22,6 +22,8 @@ import { streamToBuffer } from 'src/utils/stream-to-buffer'; @Injectable() export class FileService { + private readonly logger = new Logger(FileService.name); + constructor( private readonly jwtWrapperService: JwtWrapperService, private readonly fileStorageService: FileStorageService, @@ -98,8 +100,8 @@ export class FileService { fileId: string; workspaceId: string; fileFolder: FileFolder; - }): Promise<{ stream: Readable; mimeType: string }> { - const file = await this.fileRepository.findOneOrFail({ + }): Promise<{ stream: Readable; mimeType: string } | null> { + const file = await this.fileRepository.findOne({ where: { id: fileId, workspaceId, @@ -107,32 +109,55 @@ export class FileService { }, }); - const application = await this.applicationRepository.findOneOrFail({ + if (file === null) { + return null; + } + + const application = await this.applicationRepository.findOne({ where: { id: file.applicationId, workspaceId, }, }); - const stream = await this.fileStorageService.readFile({ - resourcePath: removeFileFolderFromFileEntityPath(file.path), - fileFolder, - applicationUniversalIdentifier: application.universalIdentifier, - workspaceId, - }); + if (application === null) { + this.logger.warn( + `File ${file.id} references missing application ${file.applicationId} in workspace ${workspaceId}`, + ); - return { - stream, - mimeType: file.mimeType, - }; + return null; + } + + try { + const stream = await this.fileStorageService.readFile({ + resourcePath: removeFileFolderFromFileEntityPath(file.path), + fileFolder, + applicationUniversalIdentifier: application.universalIdentifier, + workspaceId, + }); + + return { + stream, + mimeType: file.mimeType, + }; + } catch (error) { + if ( + error instanceof FileStorageException && + error.code === FileStorageExceptionCode.FILE_NOT_FOUND + ) { + return null; + } + + throw error; + } } async getFileResponseById(params: { fileId: string; workspaceId: string; fileFolder: FileFolder; - }): Promise { - const file = await this.fileRepository.findOneOrFail({ + }): Promise { + const file = await this.fileRepository.findOne({ where: { id: params.fileId, workspaceId: params.workspaceId, @@ -140,13 +165,25 @@ export class FileService { }, }); - const application = await this.applicationRepository.findOneOrFail({ + if (file === null) { + return null; + } + + const application = await this.applicationRepository.findOne({ where: { id: file.applicationId, workspaceId: params.workspaceId, }, }); + if (application === null) { + this.logger.warn( + `File ${file.id} references missing application ${file.applicationId} in workspace ${params.workspaceId}`, + ); + + return null; + } + const mimeType = file.mimeType ?? 'application/octet-stream'; const resourceIdentifier = { resourcePath: removeFileFolderFromFileEntityPath(file.path), @@ -168,9 +205,20 @@ export class FileService { return { type: 'redirect', presignedUrl }; } - const stream = await this.fileStorageService.readFile(resourceIdentifier); + try { + const stream = await this.fileStorageService.readFile(resourceIdentifier); - return { type: 'stream', stream, mimeType }; + return { type: 'stream', stream, mimeType }; + } catch (error) { + if ( + error instanceof FileStorageException && + error.code === FileStorageExceptionCode.FILE_NOT_FOUND + ) { + return null; + } + + throw error; + } } async getFileContentById({ @@ -181,8 +229,8 @@ export class FileService { fileId: string; workspaceId: string; fileFolder: FileFolder; - }): Promise<{ buffer: Buffer; mimeType: string }> { - const file = await this.fileRepository.findOneOrFail({ + }): Promise<{ buffer: Buffer; mimeType: string } | null> { + const file = await this.fileRepository.findOne({ where: { id: fileId, workspaceId, @@ -190,26 +238,49 @@ export class FileService { }, }); - const application = await this.applicationRepository.findOneOrFail({ + if (file === null) { + return null; + } + + const application = await this.applicationRepository.findOne({ where: { id: file.applicationId, workspaceId, }, }); - const stream = await this.fileStorageService.readFile({ - resourcePath: removeFileFolderFromFileEntityPath(file.path), - fileFolder, - applicationUniversalIdentifier: application.universalIdentifier, - workspaceId, - }); + if (application === null) { + this.logger.warn( + `File ${file.id} references missing application ${file.applicationId} in workspace ${workspaceId}`, + ); - const buffer = await streamToBuffer(stream); + return null; + } - return { - buffer, - mimeType: file.mimeType ?? 'application/octet-stream', - }; + try { + const stream = await this.fileStorageService.readFile({ + resourcePath: removeFileFolderFromFileEntityPath(file.path), + fileFolder, + applicationUniversalIdentifier: application.universalIdentifier, + workspaceId, + }); + + const buffer = await streamToBuffer(stream); + + return { + buffer, + mimeType: file.mimeType ?? 'application/octet-stream', + }; + } catch (error) { + if ( + error instanceof FileStorageException && + error.code === FileStorageExceptionCode.FILE_NOT_FOUND + ) { + return null; + } + + throw error; + } } async deleteWorkspaceFolder(workspaceId: string) { diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/code-interpreter-tool/code-interpreter-tool.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/code-interpreter-tool/code-interpreter-tool.ts index b30a5f122b..79241faa85 100644 --- a/packages/twenty-server/src/engine/core-modules/tool/tools/code-interpreter-tool/code-interpreter-tool.ts +++ b/packages/twenty-server/src/engine/core-modules/tool/tools/code-interpreter-tool/code-interpreter-tool.ts @@ -274,16 +274,23 @@ export class CodeInterpreterTool implements Tool { continue; } - const { buffer, mimeType } = await this.fileService.getFileContentById({ + const fileContent = await this.fileService.getFileContentById({ fileId: file.fileId, workspaceId, fileFolder: FileFolder.AgentChat, }); + if (fileContent === null) { + this.logger.warn( + `File ${file.filename} no longer available (id=${file.fileId})`, + ); + continue; + } + inputFiles.push({ filename: file.filename, - content: buffer, - mimeType, + content: fileContent.buffer, + mimeType: fileContent.mimeType, }); } catch (error) { this.logger.warn(`Failed to resolve file ${file.filename}`, error); diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/email-composer.service.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/email-composer.service.ts index b12330c720..fd03b6d6fb 100644 --- a/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/email-composer.service.ts +++ b/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/email-composer.service.ts @@ -219,13 +219,20 @@ export class EmailComposerService { for (const fileMetadata of files) { const fileEntity = fileEntityMap.get(fileMetadata.id); - const { stream } = await this.fileService.getFileStreamById({ + const fileStream = await this.fileService.getFileStreamById({ fileId: fileMetadata.id, workspaceId, fileFolder, }); - const buffer = await streamToBuffer(stream); + if (fileStream === null) { + throw new EmailToolException( + `Files not found: ${fileMetadata.name} (${fileMetadata.id})`, + EmailToolExceptionCode.FILE_NOT_FOUND, + ); + } + + const buffer = await streamToBuffer(fileStream.stream); attachments.push({ filename: fileMetadata.name, diff --git a/packages/twenty-server/test/integration/graphql/utils/upload-workspace-logo-mutation.util.ts b/packages/twenty-server/test/integration/graphql/utils/upload-workspace-logo-mutation.util.ts new file mode 100644 index 0000000000..1a8af27ce0 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/upload-workspace-logo-mutation.util.ts @@ -0,0 +1,10 @@ +import gql from 'graphql-tag'; + +export const uploadWorkspaceLogoMutation = gql` + mutation UploadWorkspaceLogo($file: Upload!) { + uploadWorkspaceLogo(file: $file) { + id + url + } + } +`; diff --git a/packages/twenty-server/test/integration/metadata/suites/file/__snapshots__/failing-file-by-id-download.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/file/__snapshots__/failing-file-by-id-download.integration-spec.ts.snap new file mode 100644 index 0000000000..59a670e5d6 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/file/__snapshots__/failing-file-by-id-download.integration-spec.ts.snap @@ -0,0 +1,15 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`File-by-id controller download should fail should respond 403 when the URL fileId does not match the token payload 1`] = ` +{ + "body": {}, + "status": 403, +} +`; + +exports[`File-by-id controller download should fail should respond 403 when the request has no token query parameter 1`] = ` +{ + "body": {}, + "status": 403, +} +`; diff --git a/packages/twenty-server/test/integration/metadata/suites/file/failing-file-by-id-download.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/file/failing-file-by-id-download.integration-spec.ts new file mode 100644 index 0000000000..2a86baf51f --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/file/failing-file-by-id-download.integration-spec.ts @@ -0,0 +1,124 @@ +import request from 'supertest'; +import { + extractPathAndQueryFromUrl, + swapFileIdInUrl, +} from 'test/integration/metadata/suites/file/utils/file-by-id-url-helpers.util'; +import { seedWorkspaceLogo } from 'test/integration/metadata/suites/file/utils/seed-workspace-logo.util'; +import { expectOneNotInternalServerErrorHttpResponseSnapshot } from 'test/integration/utils/expect-one-not-internal-server-error-http-response-snapshot.util'; +import { FileFolder } from 'twenty-shared/types'; +import { v4 as uuidv4 } from 'uuid'; + +describe('File-by-id controller download should fail', () => { + let fileId: string; + let validUrlPath: string; + let cleanup: () => Promise; + + beforeAll(async () => { + jest.useRealTimers(); + + const seeded = await seedWorkspaceLogo(); + + fileId = seeded.fileId; + validUrlPath = extractPathAndQueryFromUrl(seeded.signedUrl); + cleanup = seeded.cleanup; + + jest.useFakeTimers(); + }, 60000); + + afterAll(async () => { + await cleanup(); + }); + + it('should respond 403 when the request has no token query parameter', async () => { + jest.useRealTimers(); + + const response = await request(global.app.getHttpServer()).get( + `/file/${FileFolder.CorePicture}/${fileId}`, + ); + + jest.useFakeTimers(); + + expect(response.text ?? '').not.toMatch(/postgres:|secret/i); + + expectOneNotInternalServerErrorHttpResponseSnapshot({ + status: response.status, + body: response.body, + }); + }, 30000); + + it('should respond 403 when the URL fileId does not match the token payload', async () => { + jest.useRealTimers(); + + const tamperedUrlPath = swapFileIdInUrl(validUrlPath, uuidv4()); + + const response = await request(global.app.getHttpServer()).get( + tamperedUrlPath, + ); + + jest.useFakeTimers(); + + expect(response.text ?? '').not.toMatch(/postgres:|secret/i); + + expectOneNotInternalServerErrorHttpResponseSnapshot({ + status: response.status, + body: response.body, + }); + }, 30000); + + it('should respond 404 when the file row no longer exists', async () => { + jest.useRealTimers(); + + await globalThis.testDataSource.query( + `UPDATE core."file" SET "deletedAt" = now() WHERE id = $1`, + [fileId], + ); + + try { + const response = await request(global.app.getHttpServer()).get( + validUrlPath, + ); + + expect(response.status).toBe(404); + expect(response.body.code).toBe('FILE_NOT_FOUND'); + expect(response.text ?? '').not.toMatch(/postgres:|secret/i); + } finally { + await globalThis.testDataSource.query( + `UPDATE core."file" SET "deletedAt" = NULL WHERE id = $1`, + [fileId], + ); + + jest.useFakeTimers(); + } + }, 30000); + + it('should respond 404 when the storage object is missing while the DB row still exists', async () => { + jest.useRealTimers(); + + const [{ path: originalPath }] = (await globalThis.testDataSource.query( + `SELECT path FROM core."file" WHERE id = $1`, + [fileId], + )) as [{ path: string }]; + + await globalThis.testDataSource.query( + `UPDATE core."file" SET path = $1 WHERE id = $2`, + [`CorePicture/${uuidv4()}.png`, fileId], + ); + + try { + const response = await request(global.app.getHttpServer()).get( + validUrlPath, + ); + + expect(response.status).toBe(404); + expect(response.body.code).toBe('FILE_NOT_FOUND'); + expect(response.text ?? '').not.toMatch(/postgres:|secret/i); + } finally { + await globalThis.testDataSource.query( + `UPDATE core."file" SET path = $1 WHERE id = $2`, + [originalPath, fileId], + ); + + jest.useFakeTimers(); + } + }, 30000); +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/file/successful-file-by-id-download.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/file/successful-file-by-id-download.integration-spec.ts new file mode 100644 index 0000000000..f9459b7f65 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/file/successful-file-by-id-download.integration-spec.ts @@ -0,0 +1,51 @@ +import request from 'supertest'; +import { extractPathAndQueryFromUrl } from 'test/integration/metadata/suites/file/utils/file-by-id-url-helpers.util'; +import { + ONE_BY_ONE_TRANSPARENT_PNG, + seedWorkspaceLogo, +} from 'test/integration/metadata/suites/file/utils/seed-workspace-logo.util'; + +describe('File-by-id controller download should succeed', () => { + let signedUrl: string; + let cleanup: () => Promise; + + beforeAll(async () => { + jest.useRealTimers(); + + const seeded = await seedWorkspaceLogo(); + + signedUrl = seeded.signedUrl; + cleanup = seeded.cleanup; + + jest.useFakeTimers(); + }, 60000); + + afterAll(async () => { + await cleanup(); + }); + + it('should stream the workspace logo with correct headers and a non-empty image body', async () => { + jest.useRealTimers(); + + const response = await request(global.app.getHttpServer()) + .get(extractPathAndQueryFromUrl(signedUrl)) + .buffer(true) + .parse((res, callback) => { + const chunks: Buffer[] = []; + + res.on('data', (chunk) => chunks.push(chunk)); + res.on('end', () => callback(null, Buffer.concat(chunks))); + }); + + jest.useFakeTimers(); + + expect(response.status).toBe(200); + expect(response.headers['content-type']).toContain('image/png'); + expect(response.headers['x-content-type-options']).toBe('nosniff'); + expect(response.headers['content-disposition']).toBe('inline'); + + const body = response.body as Buffer; + + expect(body.equals(ONE_BY_ONE_TRANSPARENT_PNG)).toBe(true); + }, 30000); +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/file/utils/file-by-id-url-helpers.util.ts b/packages/twenty-server/test/integration/metadata/suites/file/utils/file-by-id-url-helpers.util.ts new file mode 100644 index 0000000000..2464dbe27a --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/file/utils/file-by-id-url-helpers.util.ts @@ -0,0 +1,20 @@ +// Strips host + port from an absolute signed file URL so `supertest` can hit +// the in-process Nest app via its bound port. +export const extractPathAndQueryFromUrl = (fullUrl: string): string => { + const parsed = new URL(fullUrl); + + return parsed.pathname + parsed.search; +}; + +// `/file//?token=...` → swaps `` for a different uuid; +// the embedded token (signed for the original fileId) stays untouched. Used to +// exercise the `payload.fileId !== URL.fileId` branch of `FileByIdGuard`. +export const swapFileIdInUrl = ( + pathAndQuery: string, + newFileId: string, +): string => { + return pathAndQuery.replace( + /(\/file\/[^/]+\/)[^?]+(\?|$)/, + `$1${newFileId}$2`, + ); +}; diff --git a/packages/twenty-server/test/integration/metadata/suites/file/utils/seed-workspace-logo.util.ts b/packages/twenty-server/test/integration/metadata/suites/file/utils/seed-workspace-logo.util.ts new file mode 100644 index 0000000000..312136f437 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/file/utils/seed-workspace-logo.util.ts @@ -0,0 +1,58 @@ +import { uploadWorkspaceLogoMutation } from 'test/integration/graphql/utils/upload-workspace-logo-mutation.util'; +import { makeMetadataAPIRequestWithFileUpload } from 'test/integration/metadata/suites/utils/make-metadata-api-request-with-file-upload.util'; + +import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant'; + +// 67-byte 1x1 transparent PNG. Big enough for `file-type` to detect a valid +// image header, small enough to keep the upload negligible. Exported so the +// success spec can assert round-trip byte-equality against the served body. +export const ONE_BY_ONE_TRANSPARENT_PNG = Buffer.from( + '89504E470D0A1A0A0000000D49484452000000010000000108060000001F15C4890000000D4944415478DA63000100000005000100200CB81000000000049454E44AE426082', + 'hex', +); + +type SeededWorkspaceLogo = { + fileId: string; + signedUrl: string; + workspaceId: string; + cleanup: () => Promise; +}; + +export const seedWorkspaceLogo = async (): Promise => { + const response = await makeMetadataAPIRequestWithFileUpload( + { + query: uploadWorkspaceLogoMutation, + variables: { file: null }, + }, + { + field: 'file', + buffer: ONE_BY_ONE_TRANSPARENT_PNG, + filename: 'logo.png', + contentType: 'image/png', + }, + ); + + if (response.body.errors !== undefined) { + throw new Error( + `uploadWorkspaceLogo failed: ${JSON.stringify(response.body.errors)}`, + ); + } + + const { id: fileId, url: signedUrl } = response.body.data + .uploadWorkspaceLogo as { id: string; url: string }; + + const workspaceId = SEED_APPLE_WORKSPACE_ID; + + const cleanup = async (): Promise => { + await globalThis.testDataSource.query( + `UPDATE core."workspace" SET "logoFileId" = NULL WHERE id = $1`, + [workspaceId], + ); + await globalThis.testDataSource.query( + `DELETE FROM core."file" WHERE id = $1`, + [fileId], + ); + }; + + return { fileId, signedUrl, workspaceId, cleanup }; +};