Files
twenty/packages/twenty-server/test/integration/metadata/suites/message-folder/message-folder-resolver.integration-spec.ts
T
9cb21e71fa feat: secure and user-scope metadata resolvers for messaging infrastructure (#18787)
## Summary

Builds on the messaging infrastructure migration (#18784) by securing
and user-scoping all 4 metadata resolvers:

### DTOs secured
- **ConnectedAccountDTO**: `@HideField()` on `accessToken`,
`refreshToken`, `connectionParameters`, `oidcTokenClaims`
- **MessageChannelDTO / CalendarChannelDTO**: `@HideField()` on
`syncCursor`
- **MessageFolderDTO**: `@HideField()` on `syncCursor`, `externalId`
- **UpdateMessageFolderInputUpdates**: stripped to only `isSynced`
(removed `name`, `syncCursor`, `pendingSyncAction`)

### Resolvers user-scoped via `@AuthUserWorkspaceId()`
- `myConnectedAccounts` — returns only the calling user's accounts (no
permission guard)
- `myMessageChannels(connectedAccountId?)` — returns channels for the
user's connected accounts
- `myCalendarChannels(connectedAccountId?)` — same pattern
- `myMessageFolders(messageChannelId?)` — returns folders through the
ownership chain

### Admin-only listing with permission guard
- `connectedAccounts` query retained with
`SettingsPermissionGuard(CONNECTED_ACCOUNTS)` for admin listing of all
workspace accounts

### Unsafe mutations removed
- Removed `createConnectedAccount`, `updateConnectedAccount` (OAuth/IMAP
flows create/refresh tokens server-side)
- Removed `create*`/`delete*` mutations from MessageChannel,
CalendarChannel, MessageFolder (managed by sync engine)

### Update mutations restricted with ownership verification
- `deleteConnectedAccount(id)` — verifies `entity.userWorkspaceId ===
currentUserWorkspaceId`
- `updateMessageChannel` / `updateCalendarChannel` /
`updateMessageFolder` — verify ownership through connected account chain
- New `OWNERSHIP_VIOLATION` exception codes map to `ForbiddenError` in
GraphQL

### `@AuthUserWorkspaceId` decorator hardened
- Added `allowUndefined` option (default: `false`) — throws
`ForbiddenException` if `userWorkspaceId` is undefined (e.g. API key
auth)
- Existing callers updated to `@AuthUserWorkspaceId({ allowUndefined:
true })` where needed
- New user-scoped resolvers enforce non-undefined `userWorkspaceId` at
decorator level

### Exception handler chaining
- `MessageFolderGraphqlApiExceptionInterceptor`,
`MessageChannelGraphqlApiExceptionInterceptor`,
`CalendarChannelGraphqlApiExceptionInterceptor` chain upstream exception
handling (ConnectedAccountException, MessageChannelException) for
correct `ForbiddenError` propagation

### Metadata services enhanced
- `findByUserWorkspaceId()`, `getUserConnectedAccountIds()`,
`findByConnectedAccountIds()`, `findByMessageChannelIds()`
- `findBy*ForUser()` methods encapsulate ownership checks before
querying
- `verifyOwnership()` on all 4 services with proper chain validation
- Named parameters throughout for clarity

### Dev seeds for both schemas
- Added JANE to connected account, message channel, calendar channel
workspace seeds
- Created message folder workspace seeds (TIM, JONY, JANE)
- New `seed-metadata-entities.util.ts` seeds core schema tables
(connectedAccount, messageChannel, calendarChannel, messageFolder) with
same IDs as workspace seeds, mapping `accountOwnerId` →
`userWorkspaceId`

### Integration tests (using seeds, not raw SQL)
- 4 test suites (`connected-account`, `message-channel`,
`calendar-channel`, `message-folder`)
- Tests use seeded data IDs from seed constants — no raw SQL
inserts/deletes
- Tests read via GraphQL resolvers
- Tests cover: user scoping, admin permission checks, sensitive field
exclusion, ownership enforcement on mutations

### Frontend migration
- Feature-flag-gated hooks (`useMyConnectedAccounts`,
`useMyMessageChannels`, `useMyCalendarChannels`, `useMyMessageFolders`)
- When `IS_CONNECTED_ACCOUNT_MIGRATED` is on: hooks use metadata API
(`POST /metadata`)
- When flag is off: hooks use existing workspace API (`POST /graphql`,
current behavior)
- Settings account pages updated to use new hooks
- `useEffect` extracted to
`SettingsAccountsSelectedMessageChannelEffect` component per project
conventions
- Error messages translated with Lingui

## Test plan
- [x] Server typecheck passes
- [x] Server lint passes
- [x] Server unit tests pass (477 suites, 4269 tests)
- [x] Frontend typecheck passes
- [x] Frontend lint passes
- [x] Integration tests verify user-scoping, ownership enforcement,
hidden fields
- [ ] CI green

---------

Co-authored-by: neo773 <neo773@protonmail.com>
2026-03-20 17:22:22 +01:00

162 lines
4.9 KiB
TypeScript

import { gql } from 'graphql-tag';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { updateFeatureFlag } from 'test/integration/metadata/suites/utils/update-feature-flag.util';
import { FeatureFlagKey } from 'twenty-shared/types';
import { MESSAGE_CHANNEL_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/data/constants/message-channel-data-seeds.constant';
import { MESSAGE_FOLDER_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/data/constants/message-folder-data-seeds.constant';
describe('messageFolderResolver (e2e)', () => {
beforeAll(async () => {
await updateFeatureFlag({
featureFlag: FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED,
value: true,
expectToFail: false,
});
});
afterAll(async () => {
await updateFeatureFlag({
featureFlag: FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED,
value: false,
expectToFail: false,
});
});
describe('myMessageFolders', () => {
it('should return only the current user message folders', async () => {
const response = await makeMetadataAPIRequest({
query: gql`
query MyMessageFolders {
myMessageFolders {
id
name
isSynced
messageChannelId
}
}
`,
});
expect(response.status).toBe(200);
expect(response.body.errors).toBeUndefined();
const folders = response.body.data.myMessageFolders;
const folderIds = folders.map((folder: { id: string }) => folder.id);
expect(folderIds).toContain(MESSAGE_FOLDER_DATA_SEED_IDS.JANE_INBOX);
expect(folderIds).toContain(MESSAGE_FOLDER_DATA_SEED_IDS.JANE_SENT);
expect(folderIds).not.toContain(
MESSAGE_FOLDER_DATA_SEED_IDS.JONY_INBOX,
);
});
it('should filter by messageChannelId', async () => {
const response = await makeMetadataAPIRequest({
query: gql`
query MyMessageFolders($messageChannelId: UUID) {
myMessageFolders(messageChannelId: $messageChannelId) {
id
name
}
}
`,
variables: {
messageChannelId: MESSAGE_CHANNEL_DATA_SEED_IDS.JANE,
},
});
expect(response.status).toBe(200);
expect(response.body.errors).toBeUndefined();
const folders = response.body.data.myMessageFolders;
const folderIds = folders.map((folder: { id: string }) => folder.id);
expect(folderIds).toContain(MESSAGE_FOLDER_DATA_SEED_IDS.JANE_INBOX);
expect(folderIds).not.toContain(
MESSAGE_FOLDER_DATA_SEED_IDS.JONY_INBOX,
);
});
it('should deny filtering by another user messageChannelId', async () => {
const response = await makeMetadataAPIRequest({
query: gql`
query MyMessageFolders($messageChannelId: UUID) {
myMessageFolders(messageChannelId: $messageChannelId) {
id
}
}
`,
variables: {
messageChannelId: MESSAGE_CHANNEL_DATA_SEED_IDS.JONY,
},
});
expect(response.status).toBe(200);
expect(response.body.errors?.[0]?.extensions?.code).toBe('FORBIDDEN');
});
it('should not expose hidden fields', async () => {
const response = await makeMetadataAPIRequest({
query: gql`
query MyMessageFolders {
myMessageFolders {
id
syncCursor
}
}
`,
});
expect(response.status).toBe(200);
expect(response.body.errors).toBeDefined();
});
});
describe('updateMessageFolder', () => {
it('should allow updating own folder isSynced', async () => {
const response = await makeMetadataAPIRequest({
query: gql`
mutation UpdateMessageFolder($input: UpdateMessageFolderInput!) {
updateMessageFolder(input: $input) {
id
isSynced
}
}
`,
variables: {
input: {
id: MESSAGE_FOLDER_DATA_SEED_IDS.JANE_INBOX,
update: { isSynced: false },
},
},
});
expect(response.status).toBe(200);
expect(response.body.errors).toBeUndefined();
expect(response.body.data.updateMessageFolder.isSynced).toBe(false);
});
it('should deny updating another user folder', async () => {
const response = await makeMetadataAPIRequest({
query: gql`
mutation UpdateMessageFolder($input: UpdateMessageFolderInput!) {
updateMessageFolder(input: $input) {
id
}
}
`,
variables: {
input: {
id: MESSAGE_FOLDER_DATA_SEED_IDS.JONY_INBOX,
update: { isSynced: false },
},
},
});
expect(response.status).toBe(200);
expect(response.body.errors?.[0]?.extensions?.code).toBe('FORBIDDEN');
});
});
});