## 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>
162 lines
4.9 KiB
TypeScript
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');
|
|
});
|
|
});
|
|
});
|