Files
upstream-twenty/packages/twenty-server/test/integration/graphql/suites/file-upload/direct-file-upload.integration-spec.ts
upstream-mirror e7da97194a
Some checks failed
App Prod-Parity E2E Dispatch / dispatch (push) Failing after 10s
CD Storybook UI / build (push) Failing after 3m25s
CD Storybook UI / dispatch (push) Has been skipped
CD deploy main / website-changed-files (push) Successful in 18m8s
CI Agent Skills / changed-files-check (push) Successful in 18m9s
CI Create App E2E minimal / changed-files-check (push) Successful in 24s
CI Create App / changed-files-check (push) Successful in 26s
CI Docs / changed-files-check (push) Successful in 26s
CI Emails / changed-files-check (push) Successful in 28s
CI Example App Hello World / changed-files-check (push) Successful in 23s
CI Front / changed-files-check (push) Has been skipped
CI Example App Postcard / changed-files-check (push) Successful in 27s
CI UI / changed-files-check (push) Has been skipped
CI Utils Test / changed-files-check (push) Successful in 45s
CI Twenty Apps / discover (push) Successful in 1m3s
CD deploy main / deploy-main (push) Failing after 3s
CI Agent Skills / agent-skills-validate (push) Has been skipped
CI Create App / create-app-test (lint) (push) Has been skipped
CI Emails / emails-test (push) Has been skipped
CI Front / front-task (lingui:compile) (push) Has been skipped
CI Front / front-task (lint) (push) Has been skipped
CI Example App Postcard / example-app-postcard (push) Has been skipped
CI UI / ui-task (lint) (push) Has been skipped
CI UI / ui-task (pack-size) (push) Has been skipped
CI UI / ui-task (size) (push) Has been skipped
CI UI / ui-task (test) (push) Has been skipped
CI UI / ui-task (typecheck) (push) Has been skipped
CI Utils Test / utils-test (push) Has been skipped
CI Twenty Apps / ${{ matrix.app.name }} (push) Has been skipped
CI Emails / ci-emails-status-check (push) Successful in 2s
CI Example App Hello World / ci-example-app-hello-world-status-check (push) Successful in 2s
CI Example App Postcard / ci-example-app-postcard-status-check (push) Successful in 3s
CI UI / ui-sb-test (push) Has been skipped
CI Utils Test / ci-utils-test-status-check (push) Successful in 2s
CI Twenty Apps / ci-twenty-apps-status-check (push) Successful in 2s
CI UI / ci-ui-status-check (push) Successful in 2s
CI Desktop / desktop (push) Failing after 1m8s
Push docs to Crowdin / Push documentation to Crowdin (push) Failing after 55s
Push translations to Crowdin / Extract and upload translations (push) Failing after 5s
Push website translations to Crowdin / Extract and upload website translations (push) Failing after 5s
CI Create App / create-app-test (test) (push) Has been skipped
CI Create App / create-app-test (typecheck) (push) Has been skipped
CI Docs / docs-lint (push) Has been skipped
CI Example App Hello World / example-app-hello-world (push) Has been skipped
CI Front / front-task (lingui:extract) (push) Has been skipped
CI Front / front-task (test) (push) Has been skipped
CI Front / front-task (typecheck) (push) Has been skipped
CI UI / ui-sb-build (push) Failing after 1m32s
CI Twenty Apps / ${{ matrix.app.name }} (${{ matrix.server-source }}) (push) Has been skipped
CI Agent Skills / agent-skills-publish (push) Has been skipped
CI Create App / ci-create-app-status-check (push) Successful in 2s
CI Agent Skills / agent-skills-remote-source (push) Has been skipped
CI Create App E2E minimal / create-app-e2e-minimal (push) Has been cancelled
CI E2E Main / e2e-test (push) Has been cancelled
CI Front / front-sb-build (push) Has been cancelled
CI Front / front-build (push) Has been cancelled
CI Create App E2E minimal / ci-create-app-e2e-minimal-status-check (push) Has been cancelled
CI E2E Main / ci-e2e-main-status-check (push) Has been cancelled
CI E2E Main / notify-main-ci-failure (push) Has been cancelled
CI E2E Main / qa-scout-comment (push) Has been cancelled
CI Front / front-sb-test (push) Has been cancelled
CI Front / front-sb-screenshots (push) Has been cancelled
CI Front / ci-front-status-check (push) Has been cancelled
DPA Sub-Processor Sync / sync-subprocessors (push) Failing after 3s
AI Catalog Sync / sync-catalog (push) Failing after 2s
snapshot of https://github.com/twentyhq/twenty.git (main, 2026-09-17; blobs > 131072B omitted)
2026-09-17 23:28:13 +05:00

333 lines
9.7 KiB
TypeScript

import gql from 'graphql-tag';
import request from 'supertest';
import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util';
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { FieldMetadataType, FileFolder } from 'twenty-shared/types';
const createFileUploadMutation = gql`
mutation CreateFileUpload(
$filename: String!
$size: Float!
$fileFolder: FileFolder!
$fieldMetadataId: String
) {
createFileUpload(
filename: $filename
size: $size
fileFolder: $fileFolder
fieldMetadataId: $fieldMetadataId
) {
fileId
uploadUrl
contentType
expiresAt
}
}
`;
const completeFileUploadMutation = gql`
mutation CompleteFileUpload($fileId: String!) {
completeFileUpload(fileId: $fileId) {
id
path
size
createdAt
url
}
}
`;
const deleteFileMutation = gql`
mutation DeleteFile($fileId: UUID!) {
deleteFile(fileId: $fileId) {
id
}
}
`;
describe('direct file upload (createFileUpload / completeFileUpload)', () => {
let createdObjectMetadataId: string;
let createdFieldMetadataId: string;
const uploadedFileIds: string[] = [];
const createFileUpload = async (variables: Record<string, unknown>) => {
return makeMetadataAPIRequest({
query: createFileUploadMutation,
variables,
});
};
const completeFileUpload = async (fileId: string) => {
return makeMetadataAPIRequest({
query: completeFileUploadMutation,
variables: { fileId },
});
};
const putFileToUploadUrl = async (
uploadUrl: string,
contentType: string,
content: Buffer,
) => {
// Integration tests run on the local storage driver, so the upload url
// targets the server's streaming endpoint: replay it against the test app.
const { pathname, search } = new URL(uploadUrl);
return request(global.app.getHttpServer())
.put(`${pathname}${search}`)
.set('Content-Type', contentType)
.send(content);
};
beforeAll(async () => {
jest.useRealTimers();
const {
data: {
createOneObject: { id: objectMetadataId },
},
} = await createOneObjectMetadata({
input: {
nameSingular: 'directUploadTestObject',
namePlural: 'directUploadTestObjects',
labelSingular: 'Direct Upload Test Object',
labelPlural: 'Direct Upload Test Objects',
icon: 'IconFile',
},
});
createdObjectMetadataId = objectMetadataId;
const {
data: {
createOneField: { id: fieldMetadataId },
},
} = await createOneFieldMetadata({
input: {
name: 'filesField',
label: 'Files Field',
type: FieldMetadataType.FILES,
objectMetadataId: createdObjectMetadataId,
settings: { maxNumberOfValues: 5 },
},
gqlFields: `
id
`,
});
createdFieldMetadataId = fieldMetadataId;
});
afterAll(async () => {
for (const fileId of uploadedFileIds) {
try {
await makeMetadataAPIRequest({
query: deleteFileMutation,
variables: { fileId },
});
} catch {
// Cleanup is best-effort: a failed deletion must not prevent the
// object metadata teardown below.
}
}
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: createdObjectMetadataId,
updatePayload: {
isActive: false,
},
},
});
await deleteOneObjectMetadata({
input: { idToDelete: createdObjectMetadataId },
});
jest.useFakeTimers();
});
it('should upload a file end to end: initiate, PUT bytes, complete', async () => {
const testFileContent = Buffer.from('direct upload test content');
const createResponse = await createFileUpload({
filename: 'direct-upload.txt',
size: testFileContent.length,
fileFolder: 'FilesField',
fieldMetadataId: createdFieldMetadataId,
});
expect(createResponse.status).toBe(200);
expect(createResponse.body.errors).toBeUndefined();
const uploadTarget = createResponse.body.data.createFileUpload;
expect(uploadTarget.fileId).toBeDefined();
expect(uploadTarget.uploadUrl).toContain(
`/file-upload/${uploadTarget.fileId}?token=`,
);
expect(uploadTarget.contentType).toBe('application/octet-stream');
expect(new Date(uploadTarget.expiresAt).getTime()).toBeGreaterThan(
Date.now(),
);
uploadedFileIds.push(uploadTarget.fileId);
const putResponse = await putFileToUploadUrl(
uploadTarget.uploadUrl,
uploadTarget.contentType,
testFileContent,
);
expect(putResponse.status).toBe(204);
const completeResponse = await completeFileUpload(uploadTarget.fileId);
expect(completeResponse.status).toBe(200);
expect(completeResponse.body.errors).toBeUndefined();
const completedFile = completeResponse.body.data.completeFileUpload;
expect(completedFile.id).toBe(uploadTarget.fileId);
expect(completedFile.path).toContain(FileFolder.FilesField);
expect(completedFile.size).toBe(testFileContent.length);
expect(completedFile.url).toContain(
`/file/${FileFolder.FilesField}/${uploadTarget.fileId}?token=`,
);
// The confirmed file is downloadable through the regular file endpoint
const { pathname, search } = new URL(completedFile.url);
const downloadResponse = await request(global.app.getHttpServer()).get(
`${pathname}${search}`,
);
expect(downloadResponse.status).toBe(200);
expect(downloadResponse.text).toBe(testFileContent.toString());
});
it('should refuse to complete an upload whose bytes never reached storage', async () => {
const createResponse = await createFileUpload({
filename: 'never-uploaded.txt',
size: 100,
fileFolder: 'FilesField',
fieldMetadataId: createdFieldMetadataId,
});
const uploadTarget = createResponse.body.data.createFileUpload;
uploadedFileIds.push(uploadTarget.fileId);
const completeResponse = await completeFileUpload(uploadTarget.fileId);
expect(completeResponse.body.errors).toBeDefined();
expect(completeResponse.body.errors[0].message).toContain(
'has not been uploaded',
);
});
it('should reject completing an upload whose content contradicts its extension', async () => {
const spoofedContent = Buffer.from('this is not really a png image');
const createResponse = await createFileUpload({
filename: 'actually-text.png',
size: spoofedContent.length,
fileFolder: 'FilesField',
fieldMetadataId: createdFieldMetadataId,
});
const uploadTarget = createResponse.body.data.createFileUpload;
uploadedFileIds.push(uploadTarget.fileId);
const putResponse = await putFileToUploadUrl(
uploadTarget.uploadUrl,
uploadTarget.contentType,
spoofedContent,
);
expect(putResponse.status).toBe(204);
const completeResponse = await completeFileUpload(uploadTarget.fileId);
expect(completeResponse.body.errors).toBeDefined();
expect(completeResponse.body.errors[0].message).toContain(
'does not match its extension',
);
const retryResponse = await completeFileUpload(uploadTarget.fileId);
expect(retryResponse.body.errors).toBeDefined();
expect(retryResponse.body.data?.completeFileUpload ?? null).toBeNull();
});
it('should refuse a PUT larger than the declared size', async () => {
const declaredSize = 10;
const oversizedContent = Buffer.from(
'this content is longer than ten bytes',
);
const createResponse = await createFileUpload({
filename: 'oversized.txt',
size: declaredSize,
fileFolder: 'FilesField',
fieldMetadataId: createdFieldMetadataId,
});
const uploadTarget = createResponse.body.data.createFileUpload;
uploadedFileIds.push(uploadTarget.fileId);
const putResponse = await putFileToUploadUrl(
uploadTarget.uploadUrl,
uploadTarget.contentType,
oversizedContent,
);
expect(putResponse.status).toBe(413);
});
it('should refuse a PUT without a valid upload token', async () => {
const createResponse = await createFileUpload({
filename: 'bad-token.txt',
size: 10,
fileFolder: 'FilesField',
fieldMetadataId: createdFieldMetadataId,
});
const uploadTarget = createResponse.body.data.createFileUpload;
uploadedFileIds.push(uploadTarget.fileId);
const putResponse = await request(global.app.getHttpServer())
.put(`/file-upload/${uploadTarget.fileId}?token=not-a-valid-token`)
.set('Content-Type', 'application/octet-stream')
.send(Buffer.from('0123456789'));
expect(putResponse.status).toBe(403);
});
it('should reject file folders without direct upload support', async () => {
const createResponse = await createFileUpload({
filename: 'picture.png',
size: 10,
fileFolder: 'CorePicture',
});
expect(createResponse.body.errors).toBeDefined();
});
it('should reject a size above the direct upload maximum', async () => {
const createResponse = await createFileUpload({
filename: 'huge.bin',
size: 5 * 1024 * 1024 * 1024,
fileFolder: 'FilesField',
fieldMetadataId: createdFieldMetadataId,
});
expect(createResponse.body.errors).toBeDefined();
});
});