Files
twenty/packages/twenty-server/test/integration/graphql/suites/files-field/upload-files-field-file.integration-spec.ts
T
Charles Bochet 9e21e55db4 Prevent leak between /metadata and /graphql GQL schemas (#17845)
## Fix resolver schema leaking between `/metadata` and `/graphql`
endpoints

### Summary
- Patch `@nestjs/graphql` to support a `resolverSchemaScope` option that
filters resolvers at both schema generation and runtime, preventing
cross-endpoint leaking
- Introduce `@CoreResolver()` and `@MetadataResolver()` decorators to
explicitly scope each resolver to its endpoint
- Move most resolvers (auth, billing, workspace, user, etc.) to the
metadata schema where the frontend expects them; only workflow and
timeline calendar/messaging resolvers remain on `/graphql`
- Fix frontend `SSEQuerySubscribeEffect` to use the default (metadata)
Apollo client instead of the core client

### Problem
NestJS GraphQL's module-based resolver discovery traverses transitive
imports, causing resolvers from `/metadata` modules to leak into the
`/graphql` schema and vice versa. This made the schemas unpredictable
and tightly coupled to module import order.

### Approach
- Added `resolverSchemaScope` to `GqlModuleOptions` via a patch on
`@nestjs/graphql`, filtering in both `filterResolvers()` (runtime
binding) and `getAllCtors()` (schema generation)
- Each resolver is explicitly decorated with `@CoreResolver()` or
`@MetadataResolver()`
- Organized decorator, constant, and type files under `graphql-config/`
following project conventions


Core GQL Schema: (see: no more fields!)
<img width="827" height="894" alt="image"
src="https://github.com/user-attachments/assets/668f3f0f-485e-43f0-92be-4345aeccacb6"
/>

Metadata GQL Schema (see no more getTimelineCalendarEventsFromCompany)
<img width="827" height="894" alt="image"
src="https://github.com/user-attachments/assets/443913db-e5fe-4161-b0e7-4a971cc80a71"
/>
2026-02-11 10:05:24 +00:00

146 lines
4.4 KiB
TypeScript

import gql from 'graphql-tag';
import { uploadFilesFieldFileMutation } from 'test/integration/graphql/utils/upload-files-field-file-mutation.util';
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 { 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 { FieldMetadataType, FileFolder } from 'twenty-shared/types';
const deleteFileMutation = gql`
mutation DeleteFile($fileId: UUID!) {
deleteFile(fileId: $fileId) {
id
}
}
`;
describe('uploadFilesFieldFile', () => {
let createdObjectMetadataId: string;
let createdFieldMetadataId: string;
let uploadedFileId: string | null = null;
const getFieldMetadataUniversalIdentifier = async (
fieldMetadataId: string,
): Promise<string> => {
const result = await global.testDataSource.query(
'SELECT "universalIdentifier" FROM core."fieldMetadata" WHERE id = $1',
[fieldMetadataId],
);
return result[0].universalIdentifier;
};
beforeAll(async () => {
jest.useRealTimers();
const {
data: {
createOneObject: { id: objectMetadataId },
},
} = await createOneObjectMetadata({
input: {
nameSingular: 'uploadTestObject',
namePlural: 'uploadTestObjects',
labelSingular: 'Upload Test Object',
labelPlural: '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
name
label
type
`,
});
createdFieldMetadataId = fieldMetadataId;
});
afterAll(async () => {
if (uploadedFileId) {
await makeMetadataAPIRequest({
query: deleteFileMutation,
variables: { fileId: uploadedFileId },
});
}
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: createdObjectMetadataId,
updatePayload: {
isActive: false,
},
},
});
await deleteOneObjectMetadata({
input: { idToDelete: createdObjectMetadataId },
});
jest.useFakeTimers();
});
it('should upload a file and return file metadata', async () => {
const testFileContent = 'Hello, this is a test file content';
const testFileName = 'test-file.txt';
const testMimeType = 'text/plain';
const response = await makeMetadataAPIRequestWithFileUpload(
{
query: uploadFilesFieldFileMutation,
variables: {
file: null,
fieldMetadataId: createdFieldMetadataId,
},
},
{
field: 'file',
buffer: Buffer.from(testFileContent),
filename: testFileName,
contentType: testMimeType,
},
);
expect(response.status).toBe(200);
expect(response.body.errors).toBeUndefined();
expect(response.body.data).toBeDefined();
const fileResult = response.body.data.uploadFilesFieldFile;
expect(fileResult).toBeDefined();
expect(fileResult.id).toBeDefined();
expect(typeof fileResult.id).toBe('string');
expect(fileResult.path).toBeDefined();
expect(typeof fileResult.path).toBe('string');
expect(fileResult.path).toContain(FileFolder.FilesField);
const fieldUniversalIdentifier = await getFieldMetadataUniversalIdentifier(
createdFieldMetadataId,
);
expect(fileResult.path).toContain(fieldUniversalIdentifier);
expect(fileResult.size).toBe(testFileContent.length);
expect(fileResult.createdAt).toBeDefined();
uploadedFileId = fileResult.id;
});
});