Files
twenty/packages/twenty-server/test/integration/graphql/suites/user.integration-spec.ts
T
Charles BochetandGitHub b456f79167 Reduce leak between gql schema (#17878)
## Reduce type leakage between GraphQL schemas

### Why

Twenty runs two separate GraphQL schemas: **core** and **metadata**.
NestJS's `@nestjs/graphql` uses a global `TypeMetadataStorage` that
accumulates all decorated types across all modules. When each schema is
built, every registered type leaks into both schemas regardless of which
module it belongs to.

This means the core schema's generated TypeScript
(`generated/graphql.ts`) contained ~2,700 lines of types that only
belong to the metadata schema (and vice versa). This creates confusion
about type ownership, inflates generated code, and makes it harder to
reason about which API surface each schema actually exposes.

### How

**1. Patch `@nestjs/graphql` to support schema-scoped type resolution**

- **(Already done)** Added a `resolverSchemaScope` option to
`GqlModuleOptions`, allowing each schema to declare a scope (e.g.
`'metadata'`)
- `ResolversExplorerService` now filters resolvers by a
`RESOLVER_SCHEMA_SCOPE` metadata key, so each schema only sees its own
resolvers
- `GraphQLSchemaFactory` now performs a **reachability walk**
(`computeReachableTypes`) starting from scoped resolver return types and
arguments, only including types that are transitively referenced —
handling unions, interfaces, and prototype chains
- Type definition storage and orphaned reference registry are cleared
between schema builds to prevent cross-contamination

**2. Register `ClientConfig` as orphaned type in metadata schema**

Since `ClientConfig` is needed in the metadata schema but not directly
returned by a resolver, it's explicitly declared via
`buildSchemaOptions.orphanedTypes`.

**3. Regenerate frontend types and fix imports**

- `generated/graphql.ts` shrank by ~2,700 lines (types moved to where
they belong)
- `generated-metadata/graphql.ts` gained types like `ClientConfig` that
were previously missing
- ~500 frontend files updated to import from the correct generated file
2026-02-12 10:58:52 +01:00

190 lines
5.8 KiB
TypeScript

import request from 'supertest';
import { signUpOperationFactory } from 'test/integration/graphql/utils/sign-up-operation-factory.util';
import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { PermissionsExceptionMessage } from 'src/engine/metadata-modules/permissions/permissions.exception';
import { WORKSPACE_MEMBER_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/data/constants/workspace-member-data-seeds.constant';
const client = request(`http://localhost:${APP_PORT}`);
describe('deleteUser', () => {
it('should not allow to delete user if they are the unique admin of a workspace', async () => {
const query = {
query: `
mutation DeleteUser {
deleteUser {
id
}
}
`,
};
await client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send(query)
.expect((res) => {
expect(res.body.data).toBeNull();
expect(res.body.errors).toBeDefined();
expect(res.body.errors[0].message).toBe(
'Cannot delete account: user is the unique admin of a workspace',
);
expect(res.body.errors[0].extensions.code).toBe(ErrorCode.FORBIDDEN);
});
});
it('should deny deleting another user when caller lacks WORKSPACE_MEMBERS permission', async () => {
const query = {
query: `
mutation DeleteUserFromWorkspace {
deleteUserFromWorkspace(workspaceMemberIdToDelete: "${WORKSPACE_MEMBER_DATA_SEED_IDS.JANE}") {
id
}
}
`,
};
await client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JONY_MEMBER_ACCESS_TOKEN}`)
.send(query)
.expect(200)
.expect((res) => {
expect(res.body.data).toBeNull();
expect(res.body.errors).toBeDefined();
expect(res.body.errors[0].message).toBe(
PermissionsExceptionMessage.PERMISSION_DENIED,
);
expect(res.body.errors[0].extensions.code).toBe(ErrorCode.FORBIDDEN);
});
});
it('should soft delete user and remove workspace relations when deleting a user in their only workspace', async () => {
// 1. Arrange
// Enable public invite link to allow sign up without personal token
const enablePublicInviteLinkMutation = {
query: `
mutation updateWorkspace {
updateWorkspace(data: { isPublicInviteLinkEnabled: true }) {
id
isPublicInviteLinkEnabled
}
}
`,
};
await client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send(enablePublicInviteLinkMutation)
.expect(200)
.expect((res) => {
expect(res.body.errors).toBeUndefined();
expect(res.body.data.updateWorkspace.isPublicInviteLinkEnabled).toBe(
true,
);
});
// Sign up a new user into the current workspace via public invite link
const testEmail = `test_user_${Date.now()}@example.com`;
const signUpMutation = signUpOperationFactory({
email: testEmail,
password: 'Password123!',
});
const signUpResponse = await client.post('/metadata').send(signUpMutation);
expect(signUpResponse.status).toBe(200);
expect(signUpResponse.body.errors).toBeUndefined();
// Query workspace members and find the created user by email to get workspaceMemberId
const newWorkspaceMemberQuery = {
query: `
query WorkspaceMember($workspaceMemberFilter: WorkspaceMemberFilterInput!) {
workspaceMember(filter: $workspaceMemberFilter) {
id
userId
}
}
`,
variables: {
workspaceMemberFilter: {
userEmail: {
eq: testEmail,
},
},
},
} as const;
const newMemberResponse = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send(newWorkspaceMemberQuery);
expect(newMemberResponse.status).toBe(200);
const createdMember = newMemberResponse.body.data.workspaceMember;
expect(createdMember).toBeDefined();
const createdWorkspaceMemberId = createdMember.id;
// 2. Act
const deleteUserFromWorkspaceMutation = {
query: `
mutation DeleteUserFromWorkspace {
deleteUserFromWorkspace(workspaceMemberIdToDelete: "${createdWorkspaceMemberId}") {
id
}
}
`,
};
const deleteResponse = await client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send(deleteUserFromWorkspaceMutation);
expect(deleteResponse.status).toBe(200);
expect(deleteResponse.body.errors).toBeUndefined();
// 3. Assert
const membersAfterDeletionResponse = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send(newWorkspaceMemberQuery);
const createdMemberAfterDeletion =
membersAfterDeletionResponse.body.data.workspaceMember;
expect(createdMemberAfterDeletion).toBeNull();
const getRolesWithMembersQuery = {
query: `
query GetRoles {
getRoles { id label workspaceMembers { id } }
}
`,
};
const rolesResponse = await client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send(getRolesWithMembersQuery);
expect(rolesResponse.status).toBe(200);
expect(rolesResponse.body.errors).toBeUndefined();
const roles = rolesResponse.body.data.getRoles as Array<{
id: string;
workspaceMembers: Array<{ id: string }>;
}>;
for (const role of roles) {
expect(
role.workspaceMembers.find((wm) => wm.id === createdWorkspaceMemberId),
).toBeUndefined();
}
});
});