Files
twenty/packages/twenty-server/test/integration/graphql/suites/file-upload/direct-file-upload.integration-spec.ts
T
martmull d6b6962604 feat(files): content-verify direct uploads and pin pending files to octet-stream (#22533)
## Context

Follow-up to #22449 (direct-to-storage upload endpoints). In that flow
`createFileUpload` inserts a `PENDING` file record before any bytes
exist, and until now it guessed the mime type from the **filename
extension** — an untrusted, client-controlled value. This PR makes a
pending file opaque and only trusts a mime type that was verified
against the actual stored bytes.

## What this does

**1. A pending file is always `application/octet-stream`.**
`createFileUpload` records the pending file — and signs the presigned
PUT — as `application/octet-stream`. The extension is still kept on the
stored object name so the content can be checked against it later.

**2. Content verification at completion.**
`completeFileUpload`, after the existing size check, reads a **bounded
prefix** of the stored object (`readReadablePrefix`, capped at 64 KiB —
a large object is never buffered in full) and runs the existing
`extractFileInfoOrThrow` util to detect the real mime type from the
content. It:
- writes the detected type alongside `status = UPLOADED`, and
- rejects a file whose bytes don't match its declared extension (the
record stays `PENDING`, so it can never be served or attached, and is
reaped by the pending-file cleanup cron).

Serving already overrides `Content-Type` from the DB record, so storing
the object as octet-stream is fine.

**3. A database constraint as backstop.**
`CHK_FILE_PENDING_MIME_OCTET_STREAM` — `"status" != 'PENDING' OR
"mimeType" = 'application/octet-stream'` — added to `FileEntity` and
applied by a fast instance command (`2-19`). It is added `NOT VALID` on
purpose: an instance freshly upgraded past #22449 may still hold
`PENDING` rows whose mime came from the old extension-guess path, and
`NOT VALID` enforces the invariant on every new/updated row without
failing on that legacy backlog (those rows get overwritten to
octet-stream when completed — `status` flips to `UPLOADED`, so the check
passes — or are reaped while pending).

## Tests

- `read-readable-prefix.spec.ts` — prefix reader: short source, early
stop on a large source (asserts it tears the stream down without
draining it), error propagation, empty stream.
- `file-upload.service.spec.ts` — create records octet-stream; complete
sniffs and sets the detected type, overrides a spoofed extension with
the real content type, and rejects content that can't be matched to the
declared extension.
- `direct-file-upload.integration-spec.ts` — end-to-end case rejecting a
`.png` upload whose bytes are plain text.

## Verification

`typecheck` green, `lint:diff-with-main` clean, unit suites pass (17
tests). No GraphQL schema change, so no codegen drift.

## Scope

Server-only, part of the incremental direct-upload rollout being split
into small PRs. Independent of the reaper-cron PR (#22531).

https://claude.ai/code/session_015UH8KWmsB9zdYaog8MFG1d

---
_Generated by [Claude
Code](https://claude.ai/code/session_015UH8KWmsB9zdYaog8MFG1d)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22533?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-06 08:12:30 +00: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();
});
});