snapshot of https://github.com/twentyhq/twenty.git (main, 2026-09-17; blobs > 131072B omitted)
Some checks failed
App Prod-Parity E2E Dispatch / dispatch (push) Failing after 10s
CD Storybook UI / build (push) Failing after 3m25s
CD Storybook UI / dispatch (push) Has been skipped
CD deploy main / website-changed-files (push) Successful in 18m8s
CI Agent Skills / changed-files-check (push) Successful in 18m9s
CI Create App E2E minimal / changed-files-check (push) Successful in 24s
CI Create App / changed-files-check (push) Successful in 26s
CI Docs / changed-files-check (push) Successful in 26s
CI Emails / changed-files-check (push) Successful in 28s
CI Example App Hello World / changed-files-check (push) Successful in 23s
CI Front / changed-files-check (push) Has been skipped
CI Example App Postcard / changed-files-check (push) Successful in 27s
CI UI / changed-files-check (push) Has been skipped
CI Utils Test / changed-files-check (push) Successful in 45s
CI Twenty Apps / discover (push) Successful in 1m3s
CD deploy main / deploy-main (push) Failing after 3s
CI Agent Skills / agent-skills-validate (push) Has been skipped
CI Create App / create-app-test (lint) (push) Has been skipped
CI Emails / emails-test (push) Has been skipped
CI Front / front-task (lingui:compile) (push) Has been skipped
CI Front / front-task (lint) (push) Has been skipped
CI Example App Postcard / example-app-postcard (push) Has been skipped
CI UI / ui-task (lint) (push) Has been skipped
CI UI / ui-task (pack-size) (push) Has been skipped
CI UI / ui-task (size) (push) Has been skipped
CI UI / ui-task (test) (push) Has been skipped
CI UI / ui-task (typecheck) (push) Has been skipped
CI Utils Test / utils-test (push) Has been skipped
CI Twenty Apps / ${{ matrix.app.name }} (push) Has been skipped
CI Emails / ci-emails-status-check (push) Successful in 2s
CI Example App Hello World / ci-example-app-hello-world-status-check (push) Successful in 2s
CI Example App Postcard / ci-example-app-postcard-status-check (push) Successful in 3s
CI UI / ui-sb-test (push) Has been skipped
CI Utils Test / ci-utils-test-status-check (push) Successful in 2s
CI Twenty Apps / ci-twenty-apps-status-check (push) Successful in 2s
CI UI / ci-ui-status-check (push) Successful in 2s
CI Desktop / desktop (push) Failing after 1m8s
Push docs to Crowdin / Push documentation to Crowdin (push) Failing after 55s
Push translations to Crowdin / Extract and upload translations (push) Failing after 5s
Push website translations to Crowdin / Extract and upload website translations (push) Failing after 5s
CI Create App / create-app-test (test) (push) Has been skipped
CI Create App / create-app-test (typecheck) (push) Has been skipped
CI Docs / docs-lint (push) Has been skipped
CI Example App Hello World / example-app-hello-world (push) Has been skipped
CI Front / front-task (lingui:extract) (push) Has been skipped
CI Front / front-task (test) (push) Has been skipped
CI Front / front-task (typecheck) (push) Has been skipped
CI UI / ui-sb-build (push) Failing after 1m32s
CI Twenty Apps / ${{ matrix.app.name }} (${{ matrix.server-source }}) (push) Has been skipped
CI Agent Skills / agent-skills-publish (push) Has been skipped
CI Create App / ci-create-app-status-check (push) Successful in 2s
CI Agent Skills / agent-skills-remote-source (push) Has been skipped
CI Create App E2E minimal / create-app-e2e-minimal (push) Has been cancelled
CI E2E Main / e2e-test (push) Has been cancelled
CI Front / front-sb-build (push) Has been cancelled
CI Front / front-build (push) Has been cancelled
CI Create App E2E minimal / ci-create-app-e2e-minimal-status-check (push) Has been cancelled
CI E2E Main / ci-e2e-main-status-check (push) Has been cancelled
CI E2E Main / notify-main-ci-failure (push) Has been cancelled
CI E2E Main / qa-scout-comment (push) Has been cancelled
CI Front / front-sb-test (push) Has been cancelled
CI Front / front-sb-screenshots (push) Has been cancelled
CI Front / ci-front-status-check (push) Has been cancelled
DPA Sub-Processor Sync / sync-subprocessors (push) Failing after 3s
AI Catalog Sync / sync-catalog (push) Failing after 3s

This commit is contained in:
upstream-mirror
2026-09-17 23:28:13 +05:00
commit e7da97194a
32688 changed files with 4208681 additions and 0 deletions

View File

@@ -0,0 +1,25 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`updateWorkspace while the workspace is pending creation rejects security sensitive fields 1`] = `
{
"extensions": {
"code": "FORBIDDEN",
"subCode": "PERMISSION_DENIED",
"userFriendlyMessage": "User does not have permission.",
},
"message": "Entity performing the request does not have permission",
"name": "ForbiddenError",
}
`;
exports[`updateWorkspace while the workspace is pending creation rejects the whole update when a setup field is mixed with a security sensitive one 1`] = `
{
"extensions": {
"code": "FORBIDDEN",
"subCode": "PERMISSION_DENIED",
"userFriendlyMessage": "User does not have permission.",
},
"message": "Entity performing the request does not have permission",
"name": "ForbiddenError",
}
`;

View File

@@ -0,0 +1,152 @@
import gql from 'graphql-tag';
import request from 'supertest';
import { deleteOneRoleOperationFactory } from 'test/integration/graphql/utils/delete-one-role-operation-factory.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { findOneRoleByLabel } from 'test/integration/metadata/suites/role/utils/find-one-role-by-label.util';
import { updateWorkspaceMemberRole } from 'test/integration/metadata/suites/role/utils/update-workspace-member-role.util';
import { PermissionFlagType } from 'twenty-shared/constants';
import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
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('API key role assignment permission', () => {
let customRoleId: string;
let adminRoleId: string;
let originalMemberRoleId: string;
let apiKeyId: string;
const createApiKeyAsJony = (roleId: string) =>
makeMetadataAPIRequest(
{
query: gql`
mutation CreateApiKey($input: CreateApiKeyInput!) {
createApiKey(input: $input) {
id
}
}
`,
variables: {
input: {
name: 'Escalation attempt',
expiresAt: '2099-01-01T00:00:00Z',
roleId,
},
},
},
APPLE_JONY_MEMBER_ACCESS_TOKEN,
);
beforeAll(async () => {
originalMemberRoleId = (await findOneRoleByLabel({ label: 'Member' })).id;
adminRoleId = (await findOneRoleByLabel({ label: 'Admin' })).id;
const createRoleResponse = await makeMetadataAPIRequest({
query: gql`
mutation CreateOneRole {
createOneRole(
createRoleInput: {
label: "Api Keys Only Role"
canUpdateAllSettings: false
canReadAllObjectRecords: true
canUpdateAllObjectRecords: true
canSoftDeleteAllObjectRecords: false
canDestroyAllObjectRecords: false
}
) {
id
}
}
`,
});
customRoleId = createRoleResponse.body.data.createOneRole.id;
await makeMetadataAPIRequest({
query: gql`
mutation UpsertPermissionFlags {
upsertPermissionFlags(
upsertPermissionFlagsInput: {
roleId: "${customRoleId}"
permissionFlagKeys: ["${PermissionFlagType.API_KEYS_AND_WEBHOOKS}"]
}
) {
id
}
}
`,
});
const createApiKeyResponse = await makeMetadataAPIRequest({
query: gql`
mutation CreateApiKey($input: CreateApiKeyInput!) {
createApiKey(input: $input) {
id
}
}
`,
variables: {
input: {
name: 'Seed key',
expiresAt: '2099-01-01T00:00:00Z',
roleId: adminRoleId,
},
},
});
apiKeyId = createApiKeyResponse.body.data.createApiKey.id;
await updateWorkspaceMemberRole({
input: {
roleId: customRoleId,
workspaceMemberId: WORKSPACE_MEMBER_DATA_SEED_IDS.JONY,
},
expectToFail: false,
});
});
afterAll(async () => {
await updateWorkspaceMemberRole({
input: {
roleId: originalMemberRoleId,
workspaceMemberId: WORKSPACE_MEMBER_DATA_SEED_IDS.JONY,
},
expectToFail: false,
});
await testDataSource
.query('DELETE FROM core."apiKey" WHERE id = $1', [apiKeyId])
.catch(() => {});
await client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send(deleteOneRoleOperationFactory(customRoleId));
});
it('denies creating an API key with the Admin role when the caller lacks ROLES permission', async () => {
const response = await createApiKeyAsJony(adminRoleId);
expect(response.body.data?.createApiKey ?? null).toBeNull();
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].extensions.code).toBe(ErrorCode.FORBIDDEN);
});
it('denies assigning a role to an API key when the caller lacks ROLES permission', async () => {
const response = await makeMetadataAPIRequest(
{
query: gql`
mutation AssignRoleToApiKey($apiKeyId: UUID!, $roleId: UUID!) {
assignRoleToApiKey(apiKeyId: $apiKeyId, roleId: $roleId)
}
`,
variables: { apiKeyId, roleId: adminRoleId },
},
APPLE_JONY_MEMBER_ACCESS_TOKEN,
);
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].extensions.code).toBe(ErrorCode.FORBIDDEN);
});
});

View File

@@ -0,0 +1,86 @@
import request from 'supertest';
import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { PermissionsExceptionMessage } from 'src/engine/metadata-modules/permissions/permissions.exception';
const client = request(`http://localhost:${APP_PORT}`);
describe('api key and webhooks permissions', () => {
describe('generateApiKeyToken', () => {
it('should throw a permission error when user does not have permission (member role)', async () => {
const queryData = {
query: `
mutation generateApiKeyToken {
generateApiKeyToken(apiKeyId: "test-api-key-id", expiresAt: "2025-01-01T00:00:00Z") {
token
}
}
`,
};
await client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JONY_MEMBER_ACCESS_TOKEN}`)
.send(queryData)
.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);
});
});
// Non-ACCESS tokens (API_KEY here, PLAYGROUND same path) must never mint
// an API key — enforced by RequireAccessTokenGuard.
it('should reject a non-ACCESS token even with API key permission', async () => {
const queryData = {
query: `
mutation generateApiKeyToken {
generateApiKeyToken(apiKeyId: "test-api-key-id", expiresAt: "2025-01-01T00:00:00Z") {
token
}
}
`,
};
await client
.post('/metadata')
.set('Authorization', `Bearer ${API_KEY_ACCESS_TOKEN}`)
.send(queryData)
.expect(200)
.expect((res) => {
expect(res.body.data).toBeNull();
expect(res.body.errors).toBeDefined();
expect(res.body.errors[0].extensions.code).toBe(ErrorCode.FORBIDDEN);
});
});
});
describe('createApiKey', () => {
it('should reject a non-ACCESS token even with API key permission', async () => {
const queryData = {
query: `
mutation createApiKey {
createApiKey(input: { name: "escalation", expiresAt: "2025-01-01T00:00:00Z", roleId: "20202020-0000-4000-8000-000000000000" }) {
id
}
}
`,
};
await client
.post('/metadata')
.set('Authorization', `Bearer ${API_KEY_ACCESS_TOKEN}`)
.send(queryData)
.expect(200)
.expect((res) => {
expect(res.body.data).toBeNull();
expect(res.body.errors).toBeDefined();
expect(res.body.errors[0].extensions.code).toBe(ErrorCode.FORBIDDEN);
});
});
});
});

View File

@@ -0,0 +1,250 @@
import { createOneFieldMetadataQueryFactory } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata-query-factory.util';
import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util';
import { deleteOneFieldMetadataQueryFactory } from 'test/integration/metadata/suites/field-metadata/utils/delete-one-field-metadata-query-factory.util';
import { updateOneFieldMetadataQueryFactory } from 'test/integration/metadata/suites/field-metadata/utils/update-one-field-metadata-query-factory.util';
import { createOneObjectMetadataQueryFactory } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata-query-factory.util';
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { deleteOneObjectMetadataQueryFactory } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata-query-factory.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { updateOneObjectMetadataQueryFactory } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata-query-factory.util';
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { makeMetadataAPIRequestWithMemberRole } from 'test/integration/metadata/suites/utils/make-metadata-api-request-with-member-role.util';
import { FieldMetadataType } from 'twenty-shared/types';
import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { PermissionsExceptionMessage } from 'src/engine/metadata-modules/permissions/permissions.exception';
describe('datamodel permissions', () => {
describe('fieldMetadata', () => {
let listingObjectId = '';
let testFieldId = '';
beforeAll(async () => {
const { data } = await createOneObjectMetadata({
input: {
nameSingular: 'listing',
namePlural: 'listings',
labelSingular: 'Listing',
labelPlural: 'Listings',
icon: 'IconBuildingSkyscraper',
},
});
listingObjectId = data.createOneObject.id;
const { data: createdFieldData } = await createOneFieldMetadata({
input: {
name: 'house',
type: FieldMetadataType.TEXT,
label: 'House',
objectMetadataId: listingObjectId,
},
});
testFieldId = createdFieldData.createOneField.id;
});
afterAll(async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: listingObjectId,
updatePayload: {
isActive: false,
},
},
});
await deleteOneObjectMetadata({
input: { idToDelete: listingObjectId },
});
});
describe('createOne', () => {
it('should throw a permission error when user does not have permission (member role)', async () => {
const FIELD_NAME = 'testFieldForCreateOne';
const createFieldInput = {
name: FIELD_NAME,
label: 'Test Field For CreateOne',
type: FieldMetadataType.TEXT,
objectMetadataId: listingObjectId,
};
const graphqlOperation = createOneFieldMetadataQueryFactory({
input: createFieldInput,
gqlFields: `
id
name
`,
});
const response =
await makeMetadataAPIRequestWithMemberRole(graphqlOperation);
expect(response.body.data).toBeNull();
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toBe(
PermissionsExceptionMessage.PERMISSION_DENIED,
);
expect(response.body.errors[0].extensions.code).toBe(
ErrorCode.FORBIDDEN,
);
});
});
describe('updateOne', () => {
it('should throw a permission error when user does not have permission (member role)', async () => {
const updateFieldInput = {
name: 'updatedName',
label: 'Updated Name',
};
const graphqlOperation = updateOneFieldMetadataQueryFactory({
input: { idToUpdate: testFieldId, updatePayload: updateFieldInput },
gqlFields: `
id
name
`,
});
const response =
await makeMetadataAPIRequestWithMemberRole(graphqlOperation);
expect(response.body.data).toBeNull();
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toBe(
PermissionsExceptionMessage.PERMISSION_DENIED,
);
expect(response.body.errors[0].extensions.code).toBe(
ErrorCode.FORBIDDEN,
);
});
});
describe('deleteOne', () => {
it('should throw a permission error when user does not have permission (member role)', async () => {
const graphqlOperation = deleteOneFieldMetadataQueryFactory({
input: { idToDelete: testFieldId },
});
const response =
await makeMetadataAPIRequestWithMemberRole(graphqlOperation);
expect(response.body.data).toBeNull();
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toBe(
PermissionsExceptionMessage.PERMISSION_DENIED,
);
expect(response.body.errors[0].extensions.code).toBe(
ErrorCode.FORBIDDEN,
);
});
});
});
describe('objectMetadata', () => {
describe('createOne', () => {
it('should throw a permission error when user does not have permission (member role)', async () => {
const graphqlOperation = createOneObjectMetadataQueryFactory({
gqlFields: `
id
`,
input: {
labelPlural: 'Test Objects',
labelSingular: 'Test Object',
namePlural: 'testObjects',
nameSingular: 'testObject',
},
});
const response =
await makeMetadataAPIRequestWithMemberRole(graphqlOperation);
expect(response.body.data).toBeNull();
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toBe(
PermissionsExceptionMessage.PERMISSION_DENIED,
);
expect(response.body.errors[0].extensions.code).toBe(
ErrorCode.FORBIDDEN,
);
});
});
describe('update and delete a custom object', () => {
let listingObjectId = '';
beforeAll(async () => {
const { data } = await createOneObjectMetadata({
input: {
labelPlural: 'Listings',
labelSingular: 'Listing',
namePlural: 'listings',
nameSingular: 'listing',
icon: 'IconBuildingSkyscraper',
},
});
listingObjectId = data.createOneObject.id;
});
afterAll(async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: listingObjectId,
updatePayload: {
isActive: false,
},
},
});
await deleteOneObjectMetadata({
input: { idToDelete: listingObjectId },
});
});
describe('updateOne', () => {
it('should throw a permission error when user does not have permission (member role)', async () => {
const graphqlOperation = updateOneObjectMetadataQueryFactory({
gqlFields: `
id
`,
input: {
idToUpdate: listingObjectId,
updatePayload: {
labelPlural: 'Updated Test Objects',
labelSingular: 'Updated Test Object',
},
},
});
const response =
await makeMetadataAPIRequestWithMemberRole(graphqlOperation);
expect(response.body.data).toBeNull();
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toBe(
PermissionsExceptionMessage.PERMISSION_DENIED,
);
expect(response.body.errors[0].extensions.code).toBe(
ErrorCode.FORBIDDEN,
);
});
});
describe('deleteOne', () => {
it('should throw a permission error when user does not have permission (member role)', async () => {
const graphqlOperation = deleteOneObjectMetadataQueryFactory({
input: { idToDelete: listingObjectId },
});
const response =
await makeMetadataAPIRequestWithMemberRole(graphqlOperation);
expect(response.body.data).toBeNull();
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toBe(
PermissionsExceptionMessage.PERMISSION_DENIED,
);
expect(response.body.errors[0].extensions.code).toBe(
ErrorCode.FORBIDDEN,
);
});
});
});
});
});

View File

@@ -0,0 +1,479 @@
import request from 'supertest';
import { deleteOneRoleOperationFactory } from 'test/integration/graphql/utils/delete-one-role-operation-factory.util';
import { destroyOneOperationFactory } from 'test/integration/graphql/utils/destroy-one-operation-factory.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 { findOneRoleByLabel } from 'test/integration/metadata/suites/role/utils/find-one-role-by-label.util';
import { findRoles } from 'test/integration/metadata/suites/role/utils/find-roles.util';
import { updateWorkspaceMemberRole } from 'test/integration/metadata/suites/role/utils/update-workspace-member-role.util';
import { jestExpectToBeDefined } from 'test/utils/jest-expect-to-be-defined.util.test';
import { PermissionFlagType } from 'twenty-shared/constants';
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('Granular settings permissions', () => {
let customRoleId: string;
let originalMemberRoleId: string;
const createdObjectMetadataIds: string[] = [];
beforeAll(async () => {
const memberRole = await findOneRoleByLabel({ label: 'Member' });
originalMemberRoleId = memberRole.id;
// Create a custom role with canUpdateAllSettings = false
// canUpdateAllObjectRecords must be true to allow creating records like workflows
const createRoleQuery = {
query: `
mutation CreateOneRole {
createOneRole(createRoleInput: {
label: "Custom Test Role"
description: "Role for testing specific setting permissions"
canUpdateAllSettings: false
canReadAllObjectRecords: true
canUpdateAllObjectRecords: true
canSoftDeleteAllObjectRecords: false
canDestroyAllObjectRecords: false
}) {
id
label
canUpdateAllSettings
}
}
`,
};
const createRoleResponse = await client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send(createRoleQuery);
customRoleId = createRoleResponse.body.data.createOneRole.id;
const upsertSettingPermissionsQuery = {
query: `
mutation UpsertPermissionFlags {
upsertPermissionFlags(upsertPermissionFlagsInput: {
roleId: "${customRoleId}"
permissionFlagKeys: ["${PermissionFlagType.DATA_MODEL}", "${PermissionFlagType.WORKSPACE}", "${PermissionFlagType.WORKFLOWS}"]
}) {
id
flag
roleId
}
}
`,
};
await client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send(upsertSettingPermissionsQuery);
await updateWorkspaceMemberRole({
input: {
roleId: customRoleId,
workspaceMemberId: WORKSPACE_MEMBER_DATA_SEED_IDS.JONY,
},
expectToFail: false,
});
});
afterAll(async () => {
await updateWorkspaceMemberRole({
input: {
workspaceMemberId: WORKSPACE_MEMBER_DATA_SEED_IDS.JONY,
roleId: originalMemberRoleId,
},
expectToFail: false,
});
const deleteRoleQuery = deleteOneRoleOperationFactory(customRoleId);
await client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send(deleteRoleQuery);
for (const objectMetadataId of createdObjectMetadataIds) {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: objectMetadataId,
updatePayload: {
isActive: false,
},
},
});
await deleteOneObjectMetadata({
input: {
idToDelete: objectMetadataId,
},
expectToFail: false,
});
}
});
describe('Data Model Permissions', () => {
it('should allow access to data model operations when user has DATA_MODEL setting permission', async () => {
const { data, errors } = await createOneObjectMetadata({
input: {
labelSingular: 'House',
labelPlural: 'Houses',
nameSingular: 'house',
namePlural: 'houses',
description: 'a house',
icon: 'IconHome',
},
gqlFields: `
id
labelSingular
labelPlural
`,
expectToFail: false,
});
createdObjectMetadataIds.push(data.createOneObject.id);
expect(errors).toBeUndefined();
expect(data.createOneObject).toBeDefined();
expect(data.createOneObject.labelSingular).toBe('House');
});
});
describe('Workspace Permissions', () => {
it('should allow access to workspace operations when user has WORKSPACE setting permission', async () => {
const updateWorkspaceQuery = {
query: `
mutation UpdateWorkspace {
updateWorkspace(data: {
displayName: "Updated Test Workspace"
}) {
id
displayName
}
}
`,
};
const response = await client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JONY_MEMBER_ACCESS_TOKEN}`)
.send(updateWorkspaceQuery);
expect(response.status).toBe(200);
expect(response.body.errors).toBeUndefined();
expect(response.body.data.updateWorkspace).toBeDefined();
expect(response.body.data.updateWorkspace.displayName).toBe(
'Updated Test Workspace',
);
const restoreWorkspaceQuery = {
query: `
mutation UpdateWorkspace {
updateWorkspace(data: {
displayName: "Apple"
}) {
id
displayName
}
}
`,
};
await client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send(restoreWorkspaceQuery);
});
});
describe('Workflows Permissions', () => {
it('should allow access to workflows operations when user has WORKFLOWS setting permission', async () => {
const createWorkflowQuery = {
query: `
mutation CreateWorkflow {
createWorkflow(data: {
name: "Test Workflow"
}) {
id
name
}
}
`,
};
const response = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JONY_MEMBER_ACCESS_TOKEN}`)
.send(createWorkflowQuery);
expect(response.status).toBe(200);
expect(response.body.errors).toBeUndefined();
expect(response.body.data.createWorkflow).toBeDefined();
expect(response.body.data.createWorkflow.name).toBe('Test Workflow');
const graphqlOperation = destroyOneOperationFactory({
objectMetadataSingularName: 'workflow',
gqlFields: `
id
`,
recordId: response.body.data.createWorkflow.id,
});
await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send(graphqlOperation);
});
});
describe('Denied Permissions', () => {
it('should deny access to roles operations when user does not have ROLES setting permission', async () => {
const createRoleQuery = {
query: `
mutation CreateOneRole {
createOneRole(createRoleInput: {
label: "Unauthorized Role"
}) {
id
}
}
`,
};
const response = await client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JONY_MEMBER_ACCESS_TOKEN}`)
.send(createRoleQuery);
expect(response.status).toBe(200);
expect(response.body.data).toBeNull();
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toBe(
PermissionsExceptionMessage.PERMISSION_DENIED,
);
expect(response.body.errors[0].extensions.code).toBe(ErrorCode.FORBIDDEN);
});
it('should deny access to workspace members operations when user does not have WORKSPACE_MEMBERS setting permission', async () => {
const inviteWorkspaceMemberQuery = {
query: `
mutation SendWorkspaceInvitation {
sendInvitations(
emails: ["test@example.com"],
roleId: "${originalMemberRoleId}"
) {
success
}
}
`,
};
const response = await client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JONY_MEMBER_ACCESS_TOKEN}`)
.send(inviteWorkspaceMemberQuery);
expect(response.status).toBe(200);
expect(response.body.data).toBeNull();
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toBe(
PermissionsExceptionMessage.PERMISSION_DENIED,
);
expect(response.body.errors[0].extensions.code).toBe(ErrorCode.FORBIDDEN);
});
it('should deny access to applications operations when user does not have APPLICATIONS setting permission', async () => {
const findOneApplicationQuery = {
query: `
query FindOneApplication {
findOneApplication(id: "20202020-1c25-4d02-bf25-6aeccf7ea419") {
applicationVariables {
key
value
}
}
}
`,
};
const response = await client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JONY_MEMBER_ACCESS_TOKEN}`)
.send(findOneApplicationQuery);
expect(response.status).toBe(200);
expect(response.body.data).toBeNull();
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toBe(
PermissionsExceptionMessage.PERMISSION_DENIED,
);
expect(response.body.errors[0].extensions.code).toBe(ErrorCode.FORBIDDEN);
});
it('should deny access to API keys operations when user does not have API_KEYS_AND_WEBHOOKS setting permission', async () => {
const createApiKeyQuery = {
query: `
mutation GenerateApiKeyToken {
generateApiKeyToken(apiKeyId: "setting-permissions-test-api-key-id", expiresAt: "2025-12-31T23:59:59.000Z") {
token
}
}
`,
};
const response = await client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JONY_MEMBER_ACCESS_TOKEN}`)
.send(createApiKeyQuery);
expect(response.status).toBe(200);
expect(response.body.data).toBeNull();
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toBe(
PermissionsExceptionMessage.PERMISSION_DENIED,
);
expect(response.body.errors[0].extensions.code).toBe(ErrorCode.FORBIDDEN);
});
});
describe('Permission Inheritance', () => {
it('should verify that canUpdateAllSettings=false is properly overridden by specific setting permissions', async () => {
const { data, errors } = await findRoles({
gqlFields: `
id
label
canUpdateAllSettings
permissionFlags {
flag
}
`,
expectToFail: false,
});
expect(errors).toBeUndefined();
expect(data).toBeDefined();
const customRole = data.getRoles.find((role) => role.id === customRoleId);
jestExpectToBeDefined(customRole);
expect(customRole.canUpdateAllSettings).toBe(false);
expect(customRole.permissionFlags).toHaveLength(3);
jestExpectToBeDefined(customRole.permissionFlags);
expect(customRole.permissionFlags.map((p) => p.flag)).toContain(
PermissionFlagType.DATA_MODEL,
);
expect(customRole.permissionFlags.map((p) => p.flag)).toContain(
PermissionFlagType.WORKSPACE,
);
});
});
describe('Dynamic Permission Updates', () => {
it('should allow adding new setting permissions to existing role', async () => {
const upsertSecurityPermissionQuery = {
query: `
mutation UpsertPermissionFlags {
upsertPermissionFlags(upsertPermissionFlagsInput: {
roleId: "${customRoleId}"
permissionFlagKeys: ["${PermissionFlagType.DATA_MODEL}", "${PermissionFlagType.WORKSPACE}", "${PermissionFlagType.SECURITY}"]
}) {
id
flag
roleId
}
}
`,
};
const response = await client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send(upsertSecurityPermissionQuery);
expect(response.status).toBe(200);
expect(response.body.errors).toBeUndefined();
expect(response.body.data.upsertPermissionFlags).toHaveLength(3);
const { data, errors } = await findRoles({
gqlFields: `
id
permissionFlags {
flag
}
`,
expectToFail: false,
});
expect(errors).toBeUndefined();
expect(data).toBeDefined();
const updatedRole = data.getRoles.find(
(role) => role.id === customRoleId,
);
jestExpectToBeDefined(updatedRole);
expect(updatedRole.permissionFlags).toHaveLength(3);
jestExpectToBeDefined(updatedRole.permissionFlags);
expect(updatedRole.permissionFlags.map((p) => p.flag)).toContain(
PermissionFlagType.SECURITY,
);
});
it('should allow removing setting permissions from existing role', async () => {
const upsertReducedPermissionsQuery = {
query: `
mutation UpsertPermissionFlags {
upsertPermissionFlags(upsertPermissionFlagsInput: {
roleId: "${customRoleId}"
permissionFlagKeys: ["${PermissionFlagType.DATA_MODEL}", "${PermissionFlagType.WORKSPACE}"]
}) {
id
flag
roleId
}
}
`,
};
const response = await client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send(upsertReducedPermissionsQuery);
expect(response.status).toBe(200);
expect(response.body.errors).toBeUndefined();
expect(response.body.data.upsertPermissionFlags).toHaveLength(2);
const { data, errors } = await findRoles({
gqlFields: `
id
permissionFlags {
flag
}
`,
expectToFail: false,
});
expect(errors).toBeUndefined();
expect(data).toBeDefined();
const updatedRole = data.getRoles.find(
(role) => role.id === customRoleId,
);
jestExpectToBeDefined(updatedRole);
jestExpectToBeDefined(updatedRole.permissionFlags);
expect(updatedRole.permissionFlags).toHaveLength(2);
expect(updatedRole.permissionFlags.map((p) => p.flag)).not.toContain(
PermissionFlagType.SECURITY,
);
});
});
});

View File

@@ -0,0 +1,639 @@
import request from 'supertest';
import { deleteOneRoleOperationFactory } from 'test/integration/graphql/utils/delete-one-role-operation-factory.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 { PermissionFlagType } from 'twenty-shared/constants';
import { fieldTextMock } from 'src/engine/api/__mocks__/object-metadata-item.mock';
import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import {
PermissionsExceptionCode,
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}`);
async function assertPermissionDeniedForMemberWithMemberRole({
query,
}: {
query: { query: string };
}) {
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);
});
}
describe('roles permissions', () => {
let adminRoleId: string;
let guestRoleId: string;
beforeAll(async () => {
const query = {
query: `
query GetRoles {
getRoles {
label
id
}
}
`,
};
const resp = await client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send(query);
adminRoleId = resp.body.data.getRoles.find(
// @ts-expect-error legacy noImplicitAny
(role) => role.label === 'Admin',
).id;
guestRoleId = resp.body.data.getRoles.find(
// @ts-expect-error legacy noImplicitAny
(role) => role.label === 'Guest',
).id;
});
describe('getRoles', () => {
it('should allow admin to query getRoles', async () => {
const query = {
query: `
query GetRoles {
getRoles {
label
workspaceMembers {
id
name {
firstName
lastName
}
}
}
}
`,
};
const resp = await client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send(query);
expect(resp.status).toBe(200);
expect(resp.body.errors).toBeUndefined();
expect(resp.body.data.getRoles.length).toBeGreaterThanOrEqual(4);
const roles = resp.body.data.getRoles;
const guestRole = roles.find((role: any) => role.label === 'Guest');
const adminRole = roles.find((role: any) => role.label === 'Admin');
const memberRole = roles.find((role: any) => role.label === 'Member');
const objectRestrictedRole = roles.find(
(role: any) => role.label === 'Object-restricted',
);
expect(guestRole).toBeDefined();
expect(adminRole).toBeDefined();
expect(memberRole).toBeDefined();
expect(objectRestrictedRole).toBeDefined();
expect(guestRole.workspaceMembers).toEqual([
{
id: '20202020-1553-45c6-a028-5a9064cce07f',
name: {
firstName: 'Phil',
lastName: 'Schiler',
},
},
]);
expect(adminRole.workspaceMembers).toEqual([
{
id: '20202020-463f-435b-828c-107e007a2711',
name: {
firstName: 'Jane',
lastName: 'Austen',
},
},
]);
expect(memberRole.workspaceMembers).toEqual(
expect.arrayContaining([
{
id: '20202020-77d5-4cb6-b60a-f4a835a85d61',
name: {
firstName: 'Jony',
lastName: 'Ive',
},
},
]),
);
expect(objectRestrictedRole.workspaceMembers).toEqual([
{
id: '20202020-0687-4c41-b707-ed1bfca972a7',
name: {
firstName: 'Tim',
lastName: 'Apple',
},
},
]);
});
it('should throw a permission error when user does not have permission (member role)', async () => {
const query = {
query: `
query GetRoles {
getRoles {
label
workspaceMembers {
id
name {
firstName
lastName
}
}
}
}
`,
};
await assertPermissionDeniedForMemberWithMemberRole({ query });
});
});
describe('updateWorkspaceMemberRole', () => {
it('should throw a permission error when user does not have permission to update roles (member role)', async () => {
const query = {
query: `
mutation UpdateWorkspaceMemberRole {
updateWorkspaceMemberRole(workspaceMemberId: "test-workspace-member-id", roleId: "test-role-id") {
id
}
}
`,
};
await assertPermissionDeniedForMemberWithMemberRole({ query });
});
it('should throw a permission error when tries to update their own role (admin role)', async () => {
const query = {
query: `
mutation UpdateWorkspaceMemberRole {
updateWorkspaceMemberRole(workspaceMemberId: "${WORKSPACE_MEMBER_DATA_SEED_IDS.JANE}", roleId: "test-role-id") {
id
}
}
`,
};
await client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_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.CANNOT_UPDATE_SELF_ROLE,
);
expect(res.body.errors[0].extensions.code).toBe(ErrorCode.FORBIDDEN);
});
});
it('should allow to update role when user has permission (admin role)', async () => {
const getRolesQuery = {
query: `
query GetRoles {
getRoles {
id
label
}
}
`,
};
const resp = await client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send(getRolesQuery);
const memberRoleId = resp.body.data.getRoles.find(
// @ts-expect-error legacy noImplicitAny
(role) => role.label === 'Member',
).id;
const guestRoleId = resp.body.data.getRoles.find(
// @ts-expect-error legacy noImplicitAny
(role) => role.label === 'Guest',
).id;
const updateRoleQuery = {
query: `
mutation UpdateWorkspaceMemberRole {
updateWorkspaceMemberRole(workspaceMemberId: "${WORKSPACE_MEMBER_DATA_SEED_IDS.PHIL}", roleId: "${memberRoleId}") {
id
}
}
`,
};
await client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send(updateRoleQuery)
.expect(200)
.expect((res) => {
expect(res.body.data).toBeDefined();
expect(res.body.errors).toBeUndefined();
expect(res.body.data.updateWorkspaceMemberRole.id).toBe(
WORKSPACE_MEMBER_DATA_SEED_IDS.PHIL,
);
});
const rollbackRoleUpdateQuery = {
query: `
mutation UpdateWorkspaceMemberRole {
updateWorkspaceMemberRole(workspaceMemberId: "${WORKSPACE_MEMBER_DATA_SEED_IDS.PHIL}", roleId: "${guestRoleId}") {
id
}
}
`,
};
await client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send(rollbackRoleUpdateQuery)
.expect(200)
.expect((res) => {
expect(res.body.data).toBeDefined();
expect(res.body.errors).toBeUndefined();
expect(res.body.data.updateWorkspaceMemberRole.id).toBe(
WORKSPACE_MEMBER_DATA_SEED_IDS.PHIL,
);
});
});
});
describe('createRole', () => {
it('should throw a permission error when user does not have permission to create roles (member role)', async () => {
const query = {
query: `
mutation CreateOneRole {
createOneRole(createRoleInput: {label: "test-role"}) {
id
}
}
`,
};
await assertPermissionDeniedForMemberWithMemberRole({ query });
});
it('should create a role when user has permission to create a role (admin role)', async () => {
const query = {
query: `
mutation CreateOneRole {
createOneRole(createRoleInput: {label: "Test role"}) {
id
}
}
`,
};
const result = await client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send(query)
.expect(200)
.expect((res) => {
expect(res.body.data).toBeDefined();
expect(res.body.errors).toBeUndefined();
});
const createdRoleId = result.body.data.createOneRole.id;
const deleteOneRoleQuery = deleteOneRoleOperationFactory(createdRoleId);
await client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send(deleteOneRoleQuery);
});
});
describe('updateRole', () => {
let createdEditableRoleId: string;
beforeAll(async () => {
const query = {
query: `
mutation CreateOneRole {
createOneRole(createRoleInput: {label: "Test role 2"}) {
id
}
}
`,
};
await client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send(query)
.then((res) => {
createdEditableRoleId = res.body.data.createOneRole.id;
});
});
afterAll(async () => {
const deleteOneRoleQuery = deleteOneRoleOperationFactory(
createdEditableRoleId,
);
await client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send(deleteOneRoleQuery);
});
describe('updateRole', () => {
it('should throw a permission error when user does not have permission to update roles (member role)', async () => {
const query = {
query: `
mutation UpdateOneRole {
updateOneRole(updateRoleInput: {id: "${createdEditableRoleId}", update: {label: "new role label (1)"}}) {
id
}
}
`,
};
await assertPermissionDeniedForMemberWithMemberRole({ query });
});
it('should update a role when user has permission to update a role (admin role)', async () => {
const query = {
query: `
mutation UpdateOneRole {
updateOneRole(updateRoleInput: {id: "${createdEditableRoleId}", update: {label: "new role label (3)"}}) {
id
label
}
}
`,
};
await client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send(query)
.expect(200)
.expect((res) => {
expect(res.body.data).toBeDefined();
expect(res.body.errors).toBeUndefined();
expect(res.body.data.updateOneRole.id).toBe(createdEditableRoleId);
expect(res.body.data.updateOneRole.label).toBe(
'new role label (3)',
);
});
});
});
describe('upsertObjectPermission', () => {
let listingObjectId = '';
beforeAll(async () => {
const { data } = await createOneObjectMetadata({
expectToFail: false,
input: {
nameSingular: 'house',
namePlural: 'houses',
labelSingular: 'House',
labelPlural: 'Houses',
icon: 'IconBuildingSkyscraper',
},
});
listingObjectId = data.createOneObject.id;
});
afterAll(async () => {
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: listingObjectId,
updatePayload: {
isActive: false,
},
},
});
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: listingObjectId },
});
});
const upsertObjectPermissionMutation = ({
objectMetadataId,
roleId,
}: {
objectMetadataId: string;
roleId: string;
}) => `
mutation UpsertObjectPermissions {
upsertObjectPermissions(upsertObjectPermissionsInput: { roleId: "${roleId}", objectPermissions: [{objectMetadataId: "${objectMetadataId}", canUpdateObjectRecords: true, canReadObjectRecords: true}]}) {
objectMetadataId
canUpdateObjectRecords
}
}
`;
it('should throw a permission error when user does not have permission to upsert object permission (member role)', async () => {
const query = {
query: upsertObjectPermissionMutation({
objectMetadataId: listingObjectId,
roleId: guestRoleId,
}),
};
await assertPermissionDeniedForMemberWithMemberRole({ query });
});
it('should throw an error when role is not editable', async () => {
const query = {
query: upsertObjectPermissionMutation({
objectMetadataId: listingObjectId,
roleId: adminRoleId,
}),
};
await client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send(query)
.expect(200)
.expect((res) => {
expect(res.body.data).toBeNull();
expect(res.body.errors).toBeDefined();
expect(res.body.errors[0].extensions.code).toBe(
ErrorCode.METADATA_VALIDATION_FAILED,
);
const objectPermissionErrors =
res.body.errors[0].extensions.errors?.objectPermission ?? [];
const hasRoleNotEditable = objectPermissionErrors.some(
(failure: { errors?: Array<{ code?: string }> }) =>
failure.errors?.some(
(err) =>
err.code === PermissionsExceptionCode.ROLE_NOT_EDITABLE,
),
);
expect(hasRoleNotEditable).toBe(true);
});
});
it('should upsert an object permission when user has permission', async () => {
const query = {
query: upsertObjectPermissionMutation({
objectMetadataId: listingObjectId,
roleId: createdEditableRoleId,
}),
};
await client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send(query)
.expect(200)
.expect((res) => {
expect(res.body.data).toBeDefined();
expect(res.body.errors).toBeUndefined();
expect(res.body.data.upsertObjectPermissions).toEqual(
expect.arrayContaining([
expect.objectContaining({
objectMetadataId: listingObjectId,
canUpdateObjectRecords: true,
}),
]),
);
});
});
describe('upsertFieldPermissions', () => {
it('should throw a permission error when user does not have permission to upsert field permission (member role)', async () => {
const query = {
query: `
mutation UpsertFieldPermissions {
upsertFieldPermissions(upsertFieldPermissionsInput: {roleId: "${guestRoleId}", fieldPermissions: [{objectMetadataId: "${listingObjectId}", fieldMetadataId: "${fieldTextMock.id}", canReadFieldValue: false, canUpdateFieldValue: false}]}) {
id
roleId
objectMetadataId
fieldMetadataId
canReadFieldValue
canUpdateFieldValue
}
}
`,
};
await assertPermissionDeniedForMemberWithMemberRole({ query });
});
});
});
describe('upsertPermissionFlags', () => {
const upsertSettingPermissionsMutation = ({
roleId,
}: {
roleId: string;
}) => `
mutation UpsertPermissionFlags {
upsertPermissionFlags(upsertPermissionFlagsInput: {roleId: "${roleId}", permissionFlagKeys: ["${PermissionFlagType.DATA_MODEL}"]}) {
id
roleId
flag
}
}
`;
it('should throw a permission error when user does not have permission to upsert setting permission (member role)', async () => {
const query = {
query: upsertSettingPermissionsMutation({
roleId: guestRoleId,
}),
};
await assertPermissionDeniedForMemberWithMemberRole({ query });
});
it('should throw an error when role is not editable', async () => {
const query = {
query: upsertSettingPermissionsMutation({
roleId: adminRoleId,
}),
};
await client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send(query)
.expect(200)
.expect((res) => {
expect(res.body.data).toBeNull();
expect(res.body.errors).toBeDefined();
expect(res.body.errors[0].extensions.code).toBe(
ErrorCode.METADATA_VALIDATION_FAILED,
);
const rolePermissionFlagErrors =
res.body.errors[0].extensions.errors?.rolePermissionFlag ?? [];
const hasRoleNotEditable = rolePermissionFlagErrors.some(
(failure: { errors?: Array<{ code?: string }> }) =>
failure.errors?.some(
(err) =>
err.code === PermissionsExceptionCode.ROLE_NOT_EDITABLE,
),
);
expect(hasRoleNotEditable).toBe(true);
});
});
it('should upsert a setting permission when user has permission', async () => {
const query = {
query: upsertSettingPermissionsMutation({
roleId: createdEditableRoleId,
}),
};
await client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send(query)
.expect(200)
.expect((res) => {
expect(res.body.data).toBeDefined();
expect(res.body.errors).toBeUndefined();
expect(res.body.data.upsertPermissionFlags).toEqual(
expect.arrayContaining([
expect.objectContaining({
roleId: createdEditableRoleId,
flag: PermissionFlagType.DATA_MODEL,
}),
]),
);
});
});
});
});
});

View File

@@ -0,0 +1,599 @@
import { gql } from 'graphql-tag';
import request from 'supertest';
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 { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { PermissionsExceptionMessage } from 'src/engine/metadata-modules/permissions/permissions.exception';
const uploadWorkspaceLogoMutation = gql`
mutation UploadWorkspaceLogo($file: Upload!) {
uploadWorkspaceLogo(file: $file) {
id
url
}
}
`;
const client = request(`http://localhost:${APP_PORT}`);
describe('Security permissions', () => {
let originalWorkspaceState: Record<string, unknown>;
beforeAll(async () => {
const query = gql`
query getWorkspace {
currentWorkspace {
displayName
isGoogleAuthEnabled
isMicrosoftAuthEnabled
isPasswordAuthEnabled
logo
isPublicInviteLinkEnabled
subdomain
isCustomDomainEnabled
}
}
`;
const response = await makeMetadataAPIRequest({ query });
originalWorkspaceState = response.body.data.currentWorkspace;
});
afterAll(async () => {
const restoreQuery = gql`
mutation updateWorkspace {
updateWorkspace(data: {
displayName: "${originalWorkspaceState.displayName}",
subdomain: "${originalWorkspaceState.subdomain}",
logo: "${originalWorkspaceState.logo}",
isGoogleAuthEnabled: ${originalWorkspaceState.isGoogleAuthEnabled},
isMicrosoftAuthEnabled: ${originalWorkspaceState.isMicrosoftAuthEnabled},
isPasswordAuthEnabled: ${originalWorkspaceState.isPasswordAuthEnabled}
isPublicInviteLinkEnabled: ${originalWorkspaceState.isPublicInviteLinkEnabled}
}) {
id
}
}
`;
await makeMetadataAPIRequest({ query: restoreQuery });
});
describe('security permissions', () => {
describe('microsoft auth', () => {
it('should update workspace when user has permission (admin role)', async () => {
const queryData = {
query: `
mutation updateWorkspace {
updateWorkspace(data: { isMicrosoftAuthEnabled: false }) {
id
isMicrosoftAuthEnabled
}
}
`,
};
return client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send(queryData)
.expect(200)
.expect((res) => {
expect(res.body.data).toBeDefined();
expect(res.body.errors).toBeUndefined();
})
.expect((res) => {
const data = res.body.data.updateWorkspace;
expect(data).toBeDefined();
expect(data.isMicrosoftAuthEnabled).toBe(false);
});
});
it('should throw a permission error when user does not have permission (member role)', async () => {
const queryData = {
query: `
mutation updateWorkspace {
updateWorkspace(data: { isMicrosoftAuthEnabled: true }) {
id
isMicrosoftAuthEnabled
}
}
`,
};
await client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JONY_MEMBER_ACCESS_TOKEN}`)
.send(queryData)
.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,
);
});
});
});
describe('google auth', () => {
it('should update workspace when user has permission (admin role)', async () => {
const queryData = {
query: `
mutation updateWorkspace {
updateWorkspace(data: { isGoogleAuthEnabled: false }) {
id
isGoogleAuthEnabled
}
}
`,
};
return client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send(queryData)
.expect(200)
.expect((res) => {
expect(res.body.data).toBeDefined();
expect(res.body.errors).toBeUndefined();
})
.expect((res) => {
const data = res.body.data.updateWorkspace;
expect(data).toBeDefined();
expect(data.isGoogleAuthEnabled).toBe(false);
});
});
it('should throw a permission error when user does not have permission (member role)', async () => {
const queryData = {
query: `
mutation updateWorkspace {
updateWorkspace(data: { isGoogleAuthEnabled: true }) {
id
isGoogleAuthEnabled
}
}
`,
};
await client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JONY_MEMBER_ACCESS_TOKEN}`)
.send(queryData)
.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,
);
});
});
});
describe('password auth', () => {
it('should update workspace when user has permission (admin role)', async () => {
const queryData = {
query: `
mutation updateWorkspace {
updateWorkspace(data: { isPasswordAuthEnabled: false }) {
id
isPasswordAuthEnabled
}
}
`,
};
return client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send(queryData)
.expect(200)
.expect((res) => {
expect(res.body.data).toBeDefined();
expect(res.body.errors).toBeUndefined();
})
.expect((res) => {
const data = res.body.data.updateWorkspace;
expect(data).toBeDefined();
expect(data.isPasswordAuthEnabled).toBe(false);
});
});
it('should throw a permission error when user does not have permission (member role)', async () => {
const queryData = {
query: `
mutation updateWorkspace {
updateWorkspace(data: { isPasswordAuthEnabled: true }) {
id
isPasswordAuthEnabled
}
}
`,
};
await client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JONY_MEMBER_ACCESS_TOKEN}`)
.send(queryData)
.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,
);
});
});
});
describe('public invite link', () => {
it('should update isPublicInviteLinkEnabled when user has permission (admin role)', async () => {
const queryData = {
query: `
mutation updateWorkspace {
updateWorkspace(data: { isPublicInviteLinkEnabled: false }) {
id
isPublicInviteLinkEnabled
}
}
`,
};
return client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send(queryData)
.expect(200)
.expect((res) => {
expect(res.body.data).toBeDefined();
expect(res.body.errors).toBeUndefined();
})
.expect((res) => {
const data = res.body.data.updateWorkspace;
expect(data).toBeDefined();
expect(data.isPublicInviteLinkEnabled).toBe(false);
});
});
it('should throw a permission error when user does not have permission (member role)', async () => {
const queryData = {
query: `
mutation updateWorkspace {
updateWorkspace(data: { isPublicInviteLinkEnabled: true }) {
id
isPublicInviteLinkEnabled
}
}
`,
};
await client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JONY_MEMBER_ACCESS_TOKEN}`)
.send(queryData)
.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,
);
});
});
});
});
describe('workspace permissions', () => {
describe('delete workspace', () => {
it('should throw a permission error when user does not have permission (member role)', async () => {
const queryData = {
query: `
mutation DeleteCurrentWorkspace {
deleteCurrentWorkspace {
id
__typename
}
}
`,
};
await client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JONY_MEMBER_ACCESS_TOKEN}`)
.send(queryData)
.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,
);
});
});
});
describe('display name update', () => {
it('should update workspace display name when user has workspace settings permission', async () => {
const queryData = {
query: `
mutation updateWorkspace {
updateWorkspace(data: { displayName: "New Workspace Name" }) {
id
displayName
}
}
`,
};
return client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send(queryData)
.expect(200)
.expect((res) => {
expect(res.body.data).toBeDefined();
expect(res.body.errors).toBeUndefined();
})
.expect((res) => {
const data = res.body.data.updateWorkspace;
expect(data).toBeDefined();
expect(data.displayName).toBe('New Workspace Name');
});
});
it('should throw a permission error when user does not have permission (member role)', async () => {
const queryData = {
query: `
mutation updateWorkspace {
updateWorkspace(data: { displayName: "Another New Workspace Name" }) {
id
displayName
}
}
`,
};
await client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JONY_MEMBER_ACCESS_TOKEN}`)
.send(queryData)
.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,
);
});
});
});
describe('subdomain update', () => {
it('should update workspace subdomain when user has workspace settings permission', async () => {
const queryData = {
query: `
mutation updateWorkspace {
updateWorkspace(data: { subdomain: "new-subdomain" }) {
id
subdomain
}
}
`,
};
return client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send(queryData)
.expect(200)
.expect((res) => {
expect(res.body.data).toBeDefined();
expect(res.body.errors).toBeUndefined();
})
.expect((res) => {
const data = res.body.data.updateWorkspace;
expect(data).toBeDefined();
expect(data.subdomain).toBe('new-subdomain');
});
});
it('should throw a permission error when user does not have permission (member role)', async () => {
const queryData = {
query: `
mutation updateWorkspace {
updateWorkspace(data: { subdomain: "another-new-subdomain" }) {
id
subdomain
}
}
`,
};
await client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JONY_MEMBER_ACCESS_TOKEN}`)
.send(queryData)
.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,
);
});
});
});
describe('custom domain update', () => {
it('should update workspace custom domain when user has workspace settings permission', async () => {
const queryData = {
query: `
mutation updateWorkspace {
updateWorkspace(data: { customDomain: null }) {
id
customDomain
}
}
`,
};
return client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send(queryData)
.expect(200)
.expect((res) => {
expect(res.body.data).toBeDefined();
expect(res.body.errors).toBeUndefined();
})
.expect((res) => {
const data = res.body.data.updateWorkspace;
expect(data).toBeDefined();
expect(data.customDomain).toBe(null);
});
});
it('should throw a permission error when user does not have permission (member role)', async () => {
const queryData = {
query: `
mutation updateWorkspace {
updateWorkspace(data: { customDomain: "another-new-custom-domain" }) {
id
customDomain
}
}
`,
};
await client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JONY_MEMBER_ACCESS_TOKEN}`)
.send(queryData)
.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,
);
});
});
});
describe('logo update', () => {
beforeAll(() => {
jest.useRealTimers();
});
afterAll(() => {
jest.useFakeTimers();
});
it('should update workspace logo when user has workspace settings permission', async () => {
const testImageBuffer = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
'base64',
);
const uploadResponse = await makeMetadataAPIRequestWithFileUpload(
{
query: uploadWorkspaceLogoMutation,
variables: { file: null },
},
{
field: 'file',
buffer: testImageBuffer,
filename: 'test-logo.png',
contentType: 'image/png',
},
APPLE_JANE_ADMIN_ACCESS_TOKEN,
);
expect(uploadResponse.status).toBe(200);
expect(uploadResponse.body.errors).toBeUndefined();
expect(uploadResponse.body.data).toBeDefined();
expect(uploadResponse.body.data.uploadWorkspaceLogo).toBeDefined();
expect(uploadResponse.body.data.uploadWorkspaceLogo.id).toBeDefined();
expect(uploadResponse.body.data.uploadWorkspaceLogo.url).toBeDefined();
const getWorkspaceQuery = gql`
query GetWorkspace {
currentWorkspace {
logo
}
}
`;
const workspaceResponse = await makeMetadataAPIRequest({
query: getWorkspaceQuery,
});
expect(workspaceResponse.body.data.currentWorkspace.logo).toBeDefined();
});
it('should throw a permission error when user does not have permission (member role)', async () => {
const testImageBuffer = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
'base64',
);
const response = await makeMetadataAPIRequestWithFileUpload(
{
query: uploadWorkspaceLogoMutation,
variables: { file: null },
},
{
field: 'file',
buffer: testImageBuffer,
filename: 'test-logo.png',
contentType: 'image/png',
},
APPLE_JONY_MEMBER_ACCESS_TOKEN,
);
expect(response.status).toBe(200);
expect(response.body.data).toBeNull();
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toBe(
PermissionsExceptionMessage.PERMISSION_DENIED,
);
expect(response.body.errors[0].extensions.code).toBe(
ErrorCode.FORBIDDEN,
);
});
});
});
});

View File

@@ -0,0 +1,172 @@
import { randomUUID } from 'crypto';
import gql from 'graphql-tag';
import { deleteUser } from 'test/integration/graphql/utils/delete-user.util';
import { getOnboardingStatus } from 'test/integration/graphql/utils/get-onboarding-status.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import { signUpInWorkspaceAndGetAccessToken } from 'test/integration/graphql/utils/sign-up-in-workspace-and-get-access-token.util';
import { skipSyncEmailOnboardingStep } from 'test/integration/graphql/utils/skip-sync-email-onboarding-step.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { OnboardingStatus } from 'src/engine/core-modules/onboarding/enums/onboarding-status.enum';
describe('updateWorkspaceMemberSettings and profile onboarding', () => {
let newUserAccessToken: string | undefined;
let originalIsPublicInviteLinkEnabled: boolean;
beforeAll(async () => {
const currentWorkspaceQuery = gql`
query CurrentWorkspacePublicInvite {
currentWorkspace {
isPublicInviteLinkEnabled
}
}
`;
const response = await makeMetadataAPIRequest(
{
query: currentWorkspaceQuery,
variables: {},
},
APPLE_JANE_ADMIN_ACCESS_TOKEN,
);
expect(response.body.errors).toBeUndefined();
expect(response.body.data.currentWorkspace).toBeDefined();
originalIsPublicInviteLinkEnabled =
response.body.data.currentWorkspace.isPublicInviteLinkEnabled;
});
afterAll(async () => {
const restoreMutation = gql`
mutation RestoreWorkspacePublicInvite($data: UpdateWorkspaceInput!) {
updateWorkspace(data: $data) {
id
isPublicInviteLinkEnabled
}
}
`;
await makeMetadataAPIRequest(
{
query: restoreMutation,
variables: {
data: {
isPublicInviteLinkEnabled: originalIsPublicInviteLinkEnabled,
},
},
},
APPLE_JANE_ADMIN_ACCESS_TOKEN,
);
});
afterEach(async () => {
if (!newUserAccessToken) {
return;
}
await deleteUser({
accessToken: newUserAccessToken,
expectToFail: false,
});
newUserAccessToken = undefined;
});
it('should clear PROFILE_CREATION onboarding after saving first and last name via updateWorkspaceMemberSettings', async () => {
const uniqueEmail = `profile-onboarding-${randomUUID()}@example.com`;
newUserAccessToken = await signUpInWorkspaceAndGetAccessToken(uniqueEmail);
await skipSyncEmailOnboardingStep({
accessToken: newUserAccessToken,
expectToFail: false,
});
const {
data: { currentUser: currentUserBeforeNameUpdate },
} = await getOnboardingStatus({
accessToken: newUserAccessToken,
expectToFail: false,
});
expect(currentUserBeforeNameUpdate.onboardingStatus).toBe(
OnboardingStatus.PROFILE_CREATION,
);
const workspaceMemberQuery = gql`
query WorkspaceMemberForProfileOnboarding(
$workspaceMemberFilter: WorkspaceMemberFilterInput!
) {
workspaceMember(filter: $workspaceMemberFilter) {
id
}
}
`;
const workspaceMemberResponse = await makeGraphqlAPIRequest(
{
query: workspaceMemberQuery,
variables: {
workspaceMemberFilter: {
userEmail: {
eq: uniqueEmail,
},
},
},
},
newUserAccessToken,
);
expect(workspaceMemberResponse.status).toBe(200);
expect(workspaceMemberResponse.body.errors).toBeUndefined();
const workspaceMemberId =
workspaceMemberResponse.body.data.workspaceMember?.id;
expect(workspaceMemberId).toBeDefined();
const updateWorkspaceMemberSettingsMutation = gql`
mutation UpdateWorkspaceMemberSettings(
$input: UpdateWorkspaceMemberSettingsInput!
) {
updateWorkspaceMemberSettings(input: $input)
}
`;
const updateNameResponse = await makeMetadataAPIRequest(
{
query: updateWorkspaceMemberSettingsMutation,
variables: {
input: {
workspaceMemberId,
update: {
name: {
firstName: 'Onboarding',
lastName: 'Integration',
},
},
},
},
},
newUserAccessToken,
);
expect(updateNameResponse.status).toBe(200);
expect(updateNameResponse.body.errors).toBeUndefined();
expect(updateNameResponse.body.data.updateWorkspaceMemberSettings).toBe(
true,
);
const {
data: { currentUser: currentUserAfterNameUpdate },
} = await getOnboardingStatus({
accessToken: newUserAccessToken,
expectToFail: false,
});
expect(currentUserAfterNameUpdate.onboardingStatus).toBe(
OnboardingStatus.COMPLETED,
);
});
});

View File

@@ -0,0 +1,190 @@
import { randomUUID } from 'node:crypto';
import { WORKFLOW_GQL_FIELDS } from 'test/integration/constants/workflow-gql-fields.constants';
import { createOneOperationFactory } from 'test/integration/graphql/utils/create-one-operation-factory.util';
import { destroyOneOperationFactory } from 'test/integration/graphql/utils/destroy-one-operation-factory.util';
import { makeGraphqlAPIRequestWithApiKey } from 'test/integration/graphql/utils/make-graphql-api-request-with-api-key.util';
import { makeGraphqlAPIRequestWithGuestRole } from 'test/integration/graphql/utils/make-graphql-api-request-with-guest-role.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import { updateOneOperationFactory } from 'test/integration/graphql/utils/update-one-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';
describe('workflowsPermissions', () => {
describe('createOne workflow', () => {
it('should throw a permission error when user does not have permission (guest role)', async () => {
const workflowId = randomUUID();
const graphqlOperation = createOneOperationFactory({
objectMetadataSingularName: 'workflow',
gqlFields: WORKFLOW_GQL_FIELDS,
data: {
id: workflowId,
name: 'Test Workflow V2',
},
});
const response =
await makeGraphqlAPIRequestWithGuestRole(graphqlOperation);
expect(response.body.data).toStrictEqual({ createWorkflow: null });
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toBe(
PermissionsExceptionMessage.PERMISSION_DENIED,
);
expect(response.body.errors[0].extensions.code).toBe(ErrorCode.FORBIDDEN);
});
it('should create a workflow when user has permission (admin role)', async () => {
const workflowId = randomUUID();
const graphqlOperation = createOneOperationFactory({
objectMetadataSingularName: 'workflow',
gqlFields: WORKFLOW_GQL_FIELDS,
data: {
id: workflowId,
name: 'Test Workflow Admin',
},
});
const response = await makeGraphqlAPIRequest(graphqlOperation);
expect(response.body.data).toBeDefined();
expect(response.body.data.createWorkflow).toBeDefined();
expect(response.body.data.createWorkflow.id).toBe(workflowId);
expect(response.body.data.createWorkflow.name).toBe(
'Test Workflow Admin',
);
const destroyWorkflowOperation = destroyOneOperationFactory({
objectMetadataSingularName: 'workflow',
gqlFields: `
id
`,
recordId: response.body.data.createWorkflow.id,
});
await makeGraphqlAPIRequest(destroyWorkflowOperation);
});
it('should create a workflow when executed by api key', async () => {
const workflowId = randomUUID();
const graphqlOperation = createOneOperationFactory({
objectMetadataSingularName: 'workflow',
gqlFields: WORKFLOW_GQL_FIELDS,
data: {
id: workflowId,
name: 'Test Workflow API Key',
},
});
const response = await makeGraphqlAPIRequestWithApiKey(graphqlOperation);
expect(response.body.data).toBeDefined();
expect(response.body.data.createWorkflow).toBeDefined();
expect(response.body.data.createWorkflow.id).toBe(workflowId);
expect(response.body.data.createWorkflow.name).toBe(
'Test Workflow API Key',
);
const destroyWorkflowOperation = destroyOneOperationFactory({
objectMetadataSingularName: 'workflow',
gqlFields: `
id
`,
recordId: response.body.data.createWorkflow.id,
});
await makeGraphqlAPIRequest(destroyWorkflowOperation);
});
});
describe('updateOne workflow', () => {
const workflowId = randomUUID();
beforeAll(async () => {
const createWorkflowOperation = createOneOperationFactory({
objectMetadataSingularName: 'workflow',
gqlFields: WORKFLOW_GQL_FIELDS,
data: {
id: workflowId,
name: 'Original Workflow V2',
},
});
await makeGraphqlAPIRequest(createWorkflowOperation);
});
afterAll(async () => {
const destroyWorkflowOperation = destroyOneOperationFactory({
objectMetadataSingularName: 'workflow',
gqlFields: `
id
`,
recordId: workflowId,
});
await makeGraphqlAPIRequest(destroyWorkflowOperation);
});
it('should throw a permission error when user does not have permission (guest role)', async () => {
const graphqlOperation = updateOneOperationFactory({
objectMetadataSingularName: 'workflow',
gqlFields: WORKFLOW_GQL_FIELDS,
recordId: workflowId,
data: {
name: 'Updated Workflow V2 Guest',
},
});
const response =
await makeGraphqlAPIRequestWithGuestRole(graphqlOperation);
expect(response.body.data).toStrictEqual({ updateWorkflow: null });
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toBe(
PermissionsExceptionMessage.PERMISSION_DENIED,
);
expect(response.body.errors[0].extensions.code).toBe(ErrorCode.FORBIDDEN);
});
it('should update a workflow when user has permission (admin role)', async () => {
const graphqlOperation = updateOneOperationFactory({
objectMetadataSingularName: 'workflow',
gqlFields: WORKFLOW_GQL_FIELDS,
recordId: workflowId,
data: {
name: 'Updated Workflow V2 Admin',
},
});
const response = await makeGraphqlAPIRequest(graphqlOperation);
expect(response.body.data).toBeDefined();
expect(response.body.data.updateWorkflow).toBeDefined();
expect(response.body.data.updateWorkflow.id).toBe(workflowId);
expect(response.body.data.updateWorkflow.name).toBe(
'Updated Workflow V2 Admin',
);
});
it('should update a workflow when executed by api key', async () => {
const graphqlOperation = updateOneOperationFactory({
objectMetadataSingularName: 'workflow',
gqlFields: WORKFLOW_GQL_FIELDS,
recordId: workflowId,
data: {
name: 'Updated Workflow API Key',
},
});
const response = await makeGraphqlAPIRequestWithApiKey(graphqlOperation);
expect(response.body.data).toBeDefined();
expect(response.body.data.updateWorkflow).toBeDefined();
expect(response.body.data.updateWorkflow.id).toBe(workflowId);
expect(response.body.data.updateWorkflow.name).toBe(
'Updated Workflow API Key',
);
});
});
});

View File

@@ -0,0 +1,111 @@
import request from 'supertest';
import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { PermissionsExceptionMessage } from 'src/engine/metadata-modules/permissions/permissions.exception';
const client = request(`http://localhost:${APP_PORT}`);
describe('workspace invitation permissions', () => {
it('should throw a permission error when user does not have permission to send invitation', async () => {
const queryData = {
query: `
mutation sendWorkspaceInvitation {
sendInvitations(emails: ["test@example.com"]) {
success
}
}
`,
};
await client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JONY_MEMBER_ACCESS_TOKEN}`)
.send(queryData)
.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 throw a permission error when user does not have permission to resend invitation', async () => {
const queryData = {
query: `
mutation resendWorkspaceInvitation {
resendWorkspaceInvitation(appTokenId: "test-invitation-id") {
success
}
}
`,
};
await client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JONY_MEMBER_ACCESS_TOKEN}`)
.send(queryData)
.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 throw a permission error when user does not have permission to find invitations', async () => {
const queryData = {
query: `
query findWorkspaceInvitations {
findWorkspaceInvitations {
id
email
}
}
`,
};
await client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JONY_MEMBER_ACCESS_TOKEN}`)
.send(queryData)
.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 throw a permission error when user does not have permission to delete invitation', async () => {
const queryData = {
query: `
mutation deleteWorkspaceInvitation {
deleteWorkspaceInvitation(appTokenId: "test-invitation-id")
}
`,
};
await client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JONY_MEMBER_ACCESS_TOKEN}`)
.send(queryData)
.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);
});
});
});

View File

@@ -0,0 +1,480 @@
import gql from 'graphql-tag';
import { deleteOneOperationFactory } from 'test/integration/graphql/utils/delete-one-operation-factory.util';
import { makeGraphqlAPIRequestWithMemberRole } from 'test/integration/graphql/utils/make-graphql-api-request-with-member-role.util';
import { updateOneOperationFactory } from 'test/integration/graphql/utils/update-one-operation-factory.util';
import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util';
import { deleteOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/delete-one-field-metadata.util';
import { findManyObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata.util';
import { makeMetadataAPIRequestWithMemberRole } from 'test/integration/metadata/suites/utils/make-metadata-api-request-with-member-role.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { FieldMetadataType } from 'twenty-shared/types';
import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
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 WORKSPACE_MEMBER_GQL_FIELDS = `
id
name {
firstName
}
`;
describe('workspace members permissions', () => {
let customFieldId: string;
let customFieldName: string;
let customRelationFieldId: string;
let customRelationJoinColumnName: string;
beforeAll(async () => {
const { objects } = await findManyObjectMetadata({
input: {
filter: {},
paging: {
first: 100,
},
},
gqlFields: `
id
nameSingular
`,
expectToFail: false,
});
const workspaceMemberObjectMetadataId = objects.find(
(objectMetadata) => objectMetadata.nameSingular === 'workspaceMember',
)?.id;
const companyObjectMetadataId = objects.find(
(objectMetadata) => objectMetadata.nameSingular === 'company',
)?.id;
expect(workspaceMemberObjectMetadataId).toBeDefined();
expect(companyObjectMetadataId).toBeDefined();
customFieldName = 'region';
const { data } = await createOneFieldMetadata({
input: {
objectMetadataId: workspaceMemberObjectMetadataId as string,
name: customFieldName,
label: 'In which region the workspace member is based',
type: FieldMetadataType.TEXT,
},
gqlFields: `
id
`,
expectToFail: false,
});
customFieldId = data.createOneField.id;
const customRelationFieldName = `workingWithCompany`;
const { data: customRelationFieldData } = await createOneFieldMetadata({
input: {
objectMetadataId: workspaceMemberObjectMetadataId as string,
name: customRelationFieldName,
label: 'Company the member is working with',
type: FieldMetadataType.RELATION,
relationCreationPayload: {
targetObjectMetadataId: companyObjectMetadataId as string,
targetFieldLabel: 'workspace members working with the company',
targetFieldIcon: 'IconUsers',
type: RelationType.MANY_TO_ONE,
},
},
gqlFields: `
id
settings
`,
expectToFail: false,
});
customRelationFieldId = customRelationFieldData.createOneField.id;
customRelationJoinColumnName = (
customRelationFieldData.createOneField.settings as Record<string, unknown>
).joinColumnName as string;
expect(customRelationJoinColumnName).toBeDefined();
});
afterAll(async () => {
if (!customFieldId) {
return;
}
await deleteOneFieldMetadata({
input: {
idToDelete: customFieldId,
},
expectToFail: false,
});
if (!customRelationFieldId) {
return;
}
await deleteOneFieldMetadata({
input: {
idToDelete: customRelationFieldId,
},
expectToFail: false,
});
});
it('should deny /graphql updateOne on own record for standard field name.firstName when member lacks WORKSPACE_MEMBERS', async () => {
const graphqlOperation = updateOneOperationFactory({
objectMetadataSingularName: 'workspaceMember',
gqlFields: WORKSPACE_MEMBER_GQL_FIELDS,
recordId: WORKSPACE_MEMBER_DATA_SEED_IDS.JONY,
data: {
name: {
firstName: 'Jony',
},
},
});
const response =
await makeGraphqlAPIRequestWithMemberRole(graphqlOperation);
expect(response.body.data).toStrictEqual({ updateWorkspaceMember: null });
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toBe(
PermissionsExceptionMessage.PERMISSION_DENIED,
);
expect(response.body.errors[0].extensions.code).toBe(ErrorCode.FORBIDDEN);
});
it('should deny /graphql updateOne on own record for a custom field when member lacks WORKSPACE_MEMBERS', async () => {
const graphqlOperation = updateOneOperationFactory({
objectMetadataSingularName: 'workspaceMember',
gqlFields: `
id
${customFieldName}
`,
recordId: WORKSPACE_MEMBER_DATA_SEED_IDS.JONY,
data: {
[customFieldName]: 'self-custom-value',
},
});
const response =
await makeGraphqlAPIRequestWithMemberRole(graphqlOperation);
expect(response.body.data).toStrictEqual({ updateWorkspaceMember: null });
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toBe(
PermissionsExceptionMessage.PERMISSION_DENIED,
);
expect(response.body.errors[0].extensions.code).toBe(ErrorCode.FORBIDDEN);
});
it('should deny /graphql updateOne on another workspace member for a custom field when member lacks WORKSPACE_MEMBERS', async () => {
const customFieldValue = 'Ile-de-france';
const graphqlOperation = updateOneOperationFactory({
objectMetadataSingularName: 'workspaceMember',
gqlFields: `
id
${customFieldName}
`,
recordId: WORKSPACE_MEMBER_DATA_SEED_IDS.TIM,
data: {
[customFieldName]: customFieldValue,
},
});
const response =
await makeGraphqlAPIRequestWithMemberRole(graphqlOperation);
expect(response.body.data).toStrictEqual({ updateWorkspaceMember: null });
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toBe(
PermissionsExceptionMessage.PERMISSION_DENIED,
);
expect(response.body.errors[0].extensions.code).toBe(ErrorCode.FORBIDDEN);
});
it('should throw when member updates a standard field for another workspace member', async () => {
const graphqlOperation = updateOneOperationFactory({
objectMetadataSingularName: 'workspaceMember',
gqlFields: WORKSPACE_MEMBER_GQL_FIELDS,
recordId: WORKSPACE_MEMBER_DATA_SEED_IDS.TIM,
data: {
timeZone: 'Europe/Paris',
},
});
const response =
await makeGraphqlAPIRequestWithMemberRole(graphqlOperation);
expect(response.body.data).toStrictEqual({ updateWorkspaceMember: null });
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toBe(
PermissionsExceptionMessage.PERMISSION_DENIED,
);
expect(response.body.errors[0].extensions.code).toBe(ErrorCode.FORBIDDEN);
});
it('should throw when payload only contains updatedBy-managed changes', async () => {
const graphqlOperation = updateOneOperationFactory({
objectMetadataSingularName: 'workspaceMember',
gqlFields: WORKSPACE_MEMBER_GQL_FIELDS,
recordId: WORKSPACE_MEMBER_DATA_SEED_IDS.TIM,
data: {},
});
const response =
await makeGraphqlAPIRequestWithMemberRole(graphqlOperation);
expect(response.body.data).toStrictEqual({ updateWorkspaceMember: null });
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toBe(
PermissionsExceptionMessage.PERMISSION_DENIED,
);
expect(response.body.errors[0].extensions.code).toBe(ErrorCode.FORBIDDEN);
});
it('should allow self update through dedicated metadata mutation', async () => {
const operation = {
query: gql`
mutation UpdateWorkspaceMemberSettings(
$input: UpdateWorkspaceMemberSettingsInput!
) {
updateWorkspaceMemberSettings(input: $input)
}
`,
variables: {
input: {
workspaceMemberId: WORKSPACE_MEMBER_DATA_SEED_IDS.JONY,
update: {
timeZone: 'Europe/Paris',
},
},
},
};
const response = await makeMetadataAPIRequestWithMemberRole(operation);
expect(response.body.errors).toBeUndefined();
expect(response.body.data.updateWorkspaceMemberSettings).toBe(true);
});
it('should deny updating another workspace member through dedicated metadata mutation (member role - no workspace member settings permission)', async () => {
const operation = {
query: gql`
mutation UpdateWorkspaceMemberSettings(
$input: UpdateWorkspaceMemberSettingsInput!
) {
updateWorkspaceMemberSettings(input: $input)
}
`,
variables: {
input: {
workspaceMemberId: WORKSPACE_MEMBER_DATA_SEED_IDS.TIM,
update: {
timeZone: 'Europe/Paris',
},
},
},
};
const response = await makeMetadataAPIRequestWithMemberRole(operation);
expect(response.body.data).toBeNull();
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toBe(
PermissionsExceptionMessage.PERMISSION_DENIED,
);
expect(response.body.errors[0].extensions.code).toBe(ErrorCode.FORBIDDEN);
});
it('should allow updating another workspace member through dedicated metadata mutation for admin (has workspace member settings permission)', async () => {
const operation = {
query: gql`
mutation UpdateWorkspaceMemberSettings(
$input: UpdateWorkspaceMemberSettingsInput!
) {
updateWorkspaceMemberSettings(input: $input)
}
`,
variables: {
input: {
workspaceMemberId: WORKSPACE_MEMBER_DATA_SEED_IDS.TIM,
update: {
timeZone: 'Europe/London',
},
},
},
};
const response = await makeMetadataAPIRequest(operation);
expect(response.body.errors).toBeUndefined();
expect(response.body.data.updateWorkspaceMemberSettings).toBe(true);
});
it('should reject custom field updates through dedicated metadata mutation', async () => {
const operation = {
query: gql`
mutation UpdateWorkspaceMemberSettings(
$input: UpdateWorkspaceMemberSettingsInput!
) {
updateWorkspaceMemberSettings(input: $input)
}
`,
variables: {
input: {
workspaceMemberId: WORKSPACE_MEMBER_DATA_SEED_IDS.JONY,
update: {
[customFieldName]: 'custom-value',
},
},
},
};
const response = await makeMetadataAPIRequestWithMemberRole(operation);
expect(response.body.data).toBeNull();
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toBe(
`Cannot update custom workspaceMember field via this endpoint: ${customFieldName}`,
);
expect(response.body.errors[0].extensions.code).toBe(
ErrorCode.BAD_USER_INPUT,
);
});
it('should reject custom relation join column updates through dedicated metadata mutation', async () => {
const operation = {
query: gql`
mutation UpdateWorkspaceMemberSettings(
$input: UpdateWorkspaceMemberSettingsInput!
) {
updateWorkspaceMemberSettings(input: $input)
}
`,
variables: {
input: {
workspaceMemberId: WORKSPACE_MEMBER_DATA_SEED_IDS.JONY,
update: {
[customRelationJoinColumnName]: null,
},
},
},
};
const response = await makeMetadataAPIRequestWithMemberRole(operation);
expect(response.body.data).toBeNull();
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toBe(
`Cannot update custom workspaceMember field via this endpoint: ${customRelationJoinColumnName}`,
);
expect(response.body.errors[0].extensions.code).toBe(
ErrorCode.BAD_USER_INPUT,
);
});
it('should reject empty update payload through dedicated metadata mutation', async () => {
const operation = {
query: gql`
mutation UpdateWorkspaceMemberSettings(
$input: UpdateWorkspaceMemberSettingsInput!
) {
updateWorkspaceMemberSettings(input: $input)
}
`,
variables: {
input: {
workspaceMemberId: WORKSPACE_MEMBER_DATA_SEED_IDS.JONY,
update: {},
},
},
};
const response = await makeMetadataAPIRequestWithMemberRole(operation);
expect(response.body.data).toBeNull();
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toBe(
'Update payload cannot be empty',
);
expect(response.body.errors[0].extensions.code).toBe(
ErrorCode.BAD_USER_INPUT,
);
});
it('should reject unknown top-level keys through dedicated metadata mutation (allowlist)', async () => {
const unknownKey = 'notAStandardWorkspaceMemberField';
const operation = {
query: gql`
mutation UpdateWorkspaceMemberSettings(
$input: UpdateWorkspaceMemberSettingsInput!
) {
updateWorkspaceMemberSettings(input: $input)
}
`,
variables: {
input: {
workspaceMemberId: WORKSPACE_MEMBER_DATA_SEED_IDS.JONY,
update: {
[unknownKey]: 'value',
},
},
},
};
const response = await makeMetadataAPIRequestWithMemberRole(operation);
expect(response.body.data).toBeNull();
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toBe(
`Cannot update custom workspaceMember field via this endpoint: ${unknownKey}`,
);
expect(response.body.errors[0].extensions.code).toBe(
ErrorCode.BAD_USER_INPUT,
);
});
it('should throw when calling deleteOne ', async () => {
const graphqlOperation = deleteOneOperationFactory({
objectMetadataSingularName: 'workspaceMember',
gqlFields: WORKSPACE_MEMBER_GQL_FIELDS,
recordId: WORKSPACE_MEMBER_DATA_SEED_IDS.TIM,
});
const response =
await makeGraphqlAPIRequestWithMemberRole(graphqlOperation);
expect(response.body.data).toStrictEqual({ deleteWorkspaceMember: null });
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toBe(
'Please use /deleteUserFromWorkspace to remove a workspace member.',
);
expect(response.body.errors[0].extensions.code).toBe(
ErrorCode.BAD_USER_INPUT,
);
});
it('should throw when calling deleteMany', async () => {
const graphqlOperation = deleteOneOperationFactory({
objectMetadataSingularName: 'workspaceMember',
gqlFields: WORKSPACE_MEMBER_GQL_FIELDS,
recordId: WORKSPACE_MEMBER_DATA_SEED_IDS.TIM,
});
const response =
await makeGraphqlAPIRequestWithMemberRole(graphqlOperation);
expect(response.body.data).toStrictEqual({ deleteWorkspaceMember: null });
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toBe(
'Please use /deleteUserFromWorkspace to remove a workspace member.',
);
expect(response.body.errors[0].extensions.code).toBe(
ErrorCode.BAD_USER_INPUT,
);
});
});

View File

@@ -0,0 +1,116 @@
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
import { updateWorkspace } from 'test/integration/graphql/utils/update-workspace.util';
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import { WORKSPACE_FIELDS_UPDATABLE_BEFORE_ACTIVATION } from 'src/engine/core-modules/workspace/constants/workspace-fields-updatable-before-activation.constant';
import { type UpdateWorkspaceInput } from 'src/engine/core-modules/workspace/dtos/update-workspace-input';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
// Every allowlisted field needs a value here, so adding one to the constant
// without covering it fails to compile.
const SETUP_FIELD_VALUES = {
displayName: `Pending setup ${Date.now()}`,
subdomain: `pending-setup-${Date.now()}`,
logo: 'pending-setup-logo.png',
} satisfies Record<
keyof typeof WORKSPACE_FIELDS_UPDATABLE_BEFORE_ACTIVATION,
string
>;
const setActivationStatus = (activationStatus: WorkspaceActivationStatus) =>
testDataSource.query(
'UPDATE core.workspace SET "activationStatus" = $1 WHERE id = $2',
[activationStatus, SEED_APPLE_WORKSPACE_ID],
);
describe('updateWorkspace while the workspace is pending creation', () => {
let originalDisplayName: string;
let originalSubdomain: string;
let originalLogo: string | null;
let originalAllowImpersonation: boolean;
beforeAll(async () => {
const [workspace] = await testDataSource.query(
'SELECT "displayName", "subdomain", "logo", "allowImpersonation" FROM core.workspace WHERE id = $1',
[SEED_APPLE_WORKSPACE_ID],
);
originalDisplayName = workspace.displayName;
originalSubdomain = workspace.subdomain;
originalLogo = workspace.logo;
originalAllowImpersonation = workspace.allowImpersonation;
await setActivationStatus(WorkspaceActivationStatus.PENDING_CREATION);
});
afterAll(async () => {
await setActivationStatus(WorkspaceActivationStatus.ACTIVE);
await testDataSource.query(
'UPDATE core.workspace SET "displayName" = $1, "subdomain" = $2, "logo" = $3 WHERE id = $4',
[
originalDisplayName,
originalSubdomain,
originalLogo,
SEED_APPLE_WORKSPACE_ID,
],
);
});
it('rejects security sensitive fields', async () => {
const { data, errors } = await updateWorkspace({
data: { allowImpersonation: !originalAllowImpersonation },
expectToFail: true,
});
expect(data?.updateWorkspace).toBeFalsy();
expectOneNotInternalServerErrorSnapshot({ errors });
const [workspace] = await testDataSource.query(
'SELECT "allowImpersonation" FROM core.workspace WHERE id = $1',
[SEED_APPLE_WORKSPACE_ID],
);
expect(workspace.allowImpersonation).toBe(originalAllowImpersonation);
});
it('rejects the whole update when a setup field is mixed with a security sensitive one', async () => {
const { data, errors } = await updateWorkspace({
data: {
displayName: 'Should not be applied',
isPublicInviteLinkEnabled: true,
},
expectToFail: true,
});
expect(data?.updateWorkspace).toBeFalsy();
expectOneNotInternalServerErrorSnapshot({ errors });
const [workspace] = await testDataSource.query(
'SELECT "displayName" FROM core.workspace WHERE id = $1',
[SEED_APPLE_WORKSPACE_ID],
);
expect(workspace.displayName).toBe(originalDisplayName);
});
it.each(Object.entries(SETUP_FIELD_VALUES))(
'still allows %s, which is needed to set the workspace up',
async (field, value) => {
const { data, errors } = await updateWorkspace({
data: { [field]: value } as UpdateWorkspaceInput,
expectToFail: false,
});
expect(errors).toBeUndefined();
expect(data.updateWorkspace.id).toBe(SEED_APPLE_WORKSPACE_ID);
const [workspace] = await testDataSource.query(
`SELECT "${field}" FROM core.workspace WHERE id = $1`,
[SEED_APPLE_WORKSPACE_ID],
);
expect(workspace[field]).toBe(value);
},
);
});

View File

@@ -0,0 +1,630 @@
import gql from 'graphql-tag';
import request from 'supertest';
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 { FeatureFlagKey } from 'twenty-shared/types';
import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum';
import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { PermissionsExceptionMessage } from 'src/engine/metadata-modules/permissions/permissions.exception';
const uploadWorkspaceLogoMutation = gql`
mutation UploadWorkspaceLogo($file: Upload!) {
uploadWorkspaceLogo(file: $file) {
id
url
}
}
`;
const client = request(`http://localhost:${APP_PORT}`);
describe('workspace permissions', () => {
let originalWorkspaceState: Record<string, unknown>;
beforeAll(async () => {
const query = gql`
query getWorkspace {
currentWorkspace {
displayName
isGoogleAuthEnabled
isMicrosoftAuthEnabled
isPasswordAuthEnabled
logo
isPublicInviteLinkEnabled
subdomain
}
}
`;
const response = await makeMetadataAPIRequest({ query });
originalWorkspaceState = response.body.data.currentWorkspace;
});
afterAll(async () => {
const restoreQuery = gql`
mutation updateWorkspace {
updateWorkspace(data: {
displayName: "${originalWorkspaceState.displayName}",
subdomain: "${originalWorkspaceState.subdomain}",
logo: "${originalWorkspaceState.logo}",
isGoogleAuthEnabled: ${originalWorkspaceState.isGoogleAuthEnabled},
isMicrosoftAuthEnabled: ${originalWorkspaceState.isMicrosoftAuthEnabled},
isPasswordAuthEnabled: ${originalWorkspaceState.isPasswordAuthEnabled}
isPublicInviteLinkEnabled: ${originalWorkspaceState.isPublicInviteLinkEnabled}
}) {
id
}
}
`;
await makeMetadataAPIRequest({ query: restoreQuery });
});
describe('workspace permissions', () => {
describe('delete workspace', () => {
it('should throw a permission error when user does not have permission (member role)', async () => {
const queryData = {
query: `
mutation DeleteCurrentWorkspace {
deleteCurrentWorkspace {
id
__typename
}
}
`,
};
await client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JONY_MEMBER_ACCESS_TOKEN}`)
.send(queryData)
.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,
);
});
});
});
describe('display name update', () => {
it('should update workspace display name when user has workspace settings permission', async () => {
const queryData = {
query: `
mutation updateWorkspace {
updateWorkspace(data: { displayName: "New Workspace Name" }) {
id
displayName
}
}
`,
};
return client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send(queryData)
.expect(200)
.expect((res) => {
expect(res.body.data).toBeDefined();
expect(res.body.errors).toBeUndefined();
})
.expect((res) => {
const data = res.body.data.updateWorkspace;
expect(data).toBeDefined();
expect(data.displayName).toBe('New Workspace Name');
});
});
it('should throw a permission error when user does not have permission (member role)', async () => {
const queryData = {
query: `
mutation updateWorkspace {
updateWorkspace(data: { displayName: "Another New Workspace Name" }) {
id
displayName
}
}
`,
};
await client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JONY_MEMBER_ACCESS_TOKEN}`)
.send(queryData)
.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,
);
});
});
});
describe('subdomain update', () => {
it('should update workspace subdomain when user has workspace settings permission', async () => {
const queryData = {
query: `
mutation updateWorkspace {
updateWorkspace(data: { subdomain: "new-subdomain" }) {
id
subdomain
}
}
`,
};
return client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send(queryData)
.expect(200)
.expect((res) => {
expect(res.body.data).toBeDefined();
expect(res.body.errors).toBeUndefined();
})
.expect((res) => {
const data = res.body.data.updateWorkspace;
expect(data).toBeDefined();
expect(data.subdomain).toBe('new-subdomain');
});
});
it('should throw a permission error when user does not have permission (member role)', async () => {
const queryData = {
query: `
mutation updateWorkspace {
updateWorkspace(data: { subdomain: "another-new-subdomain" }) {
id
subdomain
}
}
`,
};
await client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JONY_MEMBER_ACCESS_TOKEN}`)
.send(queryData)
.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 return a validation error when subdomain is empty string', async () => {
const queryData = {
query: `
mutation updateWorkspace {
updateWorkspace(data: { subdomain: "" }) {
id
subdomain
}
}
`,
};
const response = await client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send(queryData);
expect(response.body.data).toBeNull();
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].extensions.code).toBe(
ErrorCode.CONFLICT,
);
expect(response.body.errors[0].extensions.userFriendlyMessage).toBe(
'Invalid subdomain.',
);
});
it('should return a validation error when subdomain has invalid characters', async () => {
const queryData = {
query: `
mutation updateWorkspace {
updateWorkspace(data: { subdomain: "INVALID!" }) {
id
subdomain
}
}
`,
};
const response = await client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send(queryData);
expect(response.body.data).toBeNull();
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].extensions.code).toBe(
ErrorCode.CONFLICT,
);
expect(response.body.errors[0].extensions.userFriendlyMessage).toBe(
'Invalid subdomain.',
);
});
it('should return a validation error when subdomain starts with api-', async () => {
const queryData = {
query: `
mutation updateWorkspace {
updateWorkspace(data: { subdomain: "api-workspace" }) {
id
subdomain
}
}
`,
};
const response = await client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send(queryData);
expect(response.body.data).toBeNull();
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].extensions.code).toBe(
ErrorCode.CONFLICT,
);
expect(response.body.errors[0].extensions.userFriendlyMessage).toBe(
'Invalid subdomain.',
);
});
});
describe('custom domain update', () => {
it('should update workspace custom domain when user has workspace settings permission', async () => {
const queryData = {
query: `
mutation updateWorkspace {
updateWorkspace(data: { customDomain: null }) {
id
customDomain
}
}
`,
};
return client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send(queryData)
.expect(200)
.expect((res) => {
expect(res.body.data).toBeDefined();
expect(res.body.errors).toBeUndefined();
})
.expect((res) => {
const data = res.body.data.updateWorkspace;
expect(data).toBeDefined();
expect(data.customDomain).toBe(null);
});
});
it('should throw a permission error when user does not have permission (member role)', async () => {
const queryData = {
query: `
mutation updateWorkspace {
updateWorkspace(data: { customDomain: "another-new-custom-domain" }) {
id
customDomain
}
}
`,
};
await client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JONY_MEMBER_ACCESS_TOKEN}`)
.send(queryData)
.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,
);
});
});
});
describe('logo update', () => {
beforeAll(() => {
jest.useRealTimers();
});
afterAll(() => {
jest.useFakeTimers();
});
it('should update workspace logo when user has workspace settings permission', async () => {
const testImageBuffer = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
'base64',
);
const uploadResponse = await makeMetadataAPIRequestWithFileUpload(
{
query: uploadWorkspaceLogoMutation,
variables: { file: null },
},
{
field: 'file',
buffer: testImageBuffer,
filename: 'test-logo.png',
contentType: 'image/png',
},
APPLE_JANE_ADMIN_ACCESS_TOKEN,
);
expect(uploadResponse.status).toBe(200);
expect(uploadResponse.body.errors).toBeUndefined();
expect(uploadResponse.body.data).toBeDefined();
expect(uploadResponse.body.data.uploadWorkspaceLogo).toBeDefined();
expect(uploadResponse.body.data.uploadWorkspaceLogo.id).toBeDefined();
expect(uploadResponse.body.data.uploadWorkspaceLogo.url).toBeDefined();
const getWorkspaceQuery = gql`
query GetWorkspace {
currentWorkspace {
logo
}
}
`;
const workspaceResponse = await makeMetadataAPIRequest({
query: getWorkspaceQuery,
});
expect(workspaceResponse.body.data.currentWorkspace.logo).toBeDefined();
});
it('should throw a permission error when user does not have permission (member role)', async () => {
const testImageBuffer = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
'base64',
);
const response = await makeMetadataAPIRequestWithFileUpload(
{
query: uploadWorkspaceLogoMutation,
variables: { file: null },
},
{
field: 'file',
buffer: testImageBuffer,
filename: 'test-logo.png',
contentType: 'image/png',
},
APPLE_JONY_MEMBER_ACCESS_TOKEN,
);
expect(response.status).toBe(200);
expect(response.body.data).toBeNull();
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toBe(
PermissionsExceptionMessage.PERMISSION_DENIED,
);
expect(response.body.errors[0].extensions.code).toBe(
ErrorCode.FORBIDDEN,
);
});
});
});
describe('billing', () => {
describe('switchSubscriptionInterval', () => {
it('should throw a permission error when user does not have permission (member role)', async () => {
const queryData = {
query: `
mutation SwitchSubscriptionInterval {
switchSubscriptionInterval {
billingSubscriptions {
id
}
currentBillingSubscription {
id
}
}
}
`,
};
await client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JONY_MEMBER_ACCESS_TOKEN}`)
.send(queryData)
.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,
);
});
});
});
describe('billingPortalSession', () => {
it('should throw a permission error when user does not have permission (member role)', async () => {
const queryData = {
query: `
query BillingPortalSession($returnUrlPath: String!) {
billingPortalSession(returnUrlPath: $returnUrlPath) {
url
}
}
`,
variables: {
returnUrlPath: '/settings/billing',
},
};
await client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JONY_MEMBER_ACCESS_TOKEN}`)
.send(queryData)
.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,
);
});
});
});
describe('checkoutSession', () => {
it('should throw a permission error when user does not have permission (member role)', async () => {
const queryData = {
query: `
mutation CheckoutSession(
$recurringInterval: SubscriptionInterval!
$successUrlPath: String!
$plan: BillingPlanKey!
$requirePaymentMethod: Boolean
) {
checkoutSession(
recurringInterval: $recurringInterval
successUrlPath: $successUrlPath
plan: $plan
requirePaymentMethod: $requirePaymentMethod
) {
url
}
}
`,
variables: {
recurringInterval: 'Month',
successUrlPath: '/settings/billing',
plan: BillingPlanKey.PRO,
requirePaymentMethod: true,
},
};
await client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JONY_MEMBER_ACCESS_TOKEN}`)
.send(queryData)
.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,
);
});
});
});
});
describe('lab', () => {
describe('updateLabPublicFeatureFlag', () => {
it('should update feature flag when user has workspace settings permission', async () => {
const queryData = {
query: `
mutation UpdateLabPublicFeatureFlag(
$input: UpdateLabPublicFeatureFlagInput!
) {
updateLabPublicFeatureFlag(input: $input) {
key
value
}
}
`,
variables: {
input: {
publicFeatureFlag: FeatureFlagKey.IS_UNIQUE_INDEXES_ENABLED,
value: true,
},
},
};
await client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send(queryData)
.expect((res) => {
expect(res.body.data).toBeDefined();
expect(res.body.errors).toBeDefined();
expect(res.body.errors[0].message).toBe(
'Invalid feature flag key, flag is not public',
);
});
});
it('should throw a permission error when user does not have permission (member role)', async () => {
const queryData = {
query: `
mutation UpdateLabPublicFeatureFlag(
$input: UpdateLabPublicFeatureFlagInput!
) {
updateLabPublicFeatureFlag(input: $input) {
key
value
}
}
`,
variables: {
input: {
publicFeatureFlag: 'TestFeature',
value: false,
},
},
};
await client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JONY_MEMBER_ACCESS_TOKEN}`)
.send(queryData)
.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,
);
});
});
});
});
});