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
333 lines
9.7 KiB
TypeScript
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();
|
|
});
|
|
});
|