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 2s

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,295 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`Agent creation should fail when computed name already exists 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"agent": [
{
"errors": [
{
"code": "AGENT_ALREADY_EXISTS",
"message": "Agent with name "existingTestAgent" already exists",
"userFriendlyMessage": "An agent with this name already exists",
},
],
"flatEntityMinimalInformation": {
"name": "existingTestAgent",
"universalIdentifier": Any<String>,
},
"metadataName": "agent",
"status": "fail",
"type": "create",
},
],
},
"message": "Validation failed for 1 agent",
"summary": {
"agent": 1,
"totalErrors": 1,
},
"userFriendlyMessage": "An agent with this name already exists",
},
"message": "Multiple validation errors occurred while creating agent",
"name": "GraphQLError",
}
`;
exports[`Agent creation should fail when label is empty string 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"agent": [
{
"errors": [
{
"code": "INVALID_AGENT_INPUT",
"message": "Label cannot be empty",
"userFriendlyMessage": "Label cannot be empty",
},
],
"flatEntityMinimalInformation": {
"name": "",
"universalIdentifier": Any<String>,
},
"metadataName": "agent",
"status": "fail",
"type": "create",
},
],
},
"message": "Validation failed for 1 agent",
"summary": {
"agent": 1,
"totalErrors": 1,
},
"userFriendlyMessage": "Label cannot be empty",
},
"message": "Multiple validation errors occurred while creating agent",
"name": "GraphQLError",
}
`;
exports[`Agent creation should fail when label is missing 1`] = `
{
"extensions": {
"code": "BAD_USER_INPUT",
"http": {
"status": 400,
},
"userFriendlyMessage": "An error occurred.",
},
"message": "Field "label" of required type "String!" was not provided.",
"name": "GraphQLError",
}
`;
exports[`Agent creation should fail when modelId is empty string 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"agent": [
{
"errors": [
{
"code": "INVALID_AGENT_INPUT",
"message": "Model ID cannot be empty",
"userFriendlyMessage": "Model ID cannot be empty",
},
],
"flatEntityMinimalInformation": {
"name": "emptyModelidAgent",
"universalIdentifier": Any<String>,
},
"metadataName": "agent",
"status": "fail",
"type": "create",
},
],
},
"message": "Validation failed for 1 agent",
"summary": {
"agent": 1,
"totalErrors": 1,
},
"userFriendlyMessage": "Model ID cannot be empty",
},
"message": "Multiple validation errors occurred while creating agent",
"name": "GraphQLError",
}
`;
exports[`Agent creation should fail when modelId is missing 1`] = `
{
"extensions": {
"code": "BAD_USER_INPUT",
"http": {
"status": 400,
},
"userFriendlyMessage": "An error occurred.",
},
"message": "Field "modelId" of required type "String!" was not provided.",
"name": "GraphQLError",
}
`;
exports[`Agent creation should fail when prompt is empty string 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"agent": [
{
"errors": [
{
"code": "INVALID_AGENT_INPUT",
"message": "Prompt cannot be empty",
"userFriendlyMessage": "Prompt cannot be empty",
},
],
"flatEntityMinimalInformation": {
"name": "emptyPromptAgent",
"universalIdentifier": Any<String>,
},
"metadataName": "agent",
"status": "fail",
"type": "create",
},
],
},
"message": "Validation failed for 1 agent",
"summary": {
"agent": 1,
"totalErrors": 1,
},
"userFriendlyMessage": "Prompt cannot be empty",
},
"message": "Multiple validation errors occurred while creating agent",
"name": "GraphQLError",
}
`;
exports[`Agent creation should fail when prompt is missing 1`] = `
{
"extensions": {
"code": "BAD_USER_INPUT",
"http": {
"status": 400,
},
"userFriendlyMessage": "An error occurred.",
},
"message": "Field "prompt" of required type "String!" was not provided.",
"name": "GraphQLError",
}
`;
exports[`Agent creation should fail when responseFormat has invalid type 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"agent": [
{
"errors": [
{
"code": "INVALID_AGENT_INPUT",
"message": "Response format type must be either "text" or "json"",
"userFriendlyMessage": "Invalid response format type",
},
],
"flatEntityMinimalInformation": {
"name": "invalidResponseFormatAgent",
"universalIdentifier": Any<String>,
},
"metadataName": "agent",
"status": "fail",
"type": "create",
},
],
},
"message": "Validation failed for 1 agent",
"summary": {
"agent": 1,
"totalErrors": 1,
},
"userFriendlyMessage": "Invalid response format type",
},
"message": "Multiple validation errors occurred while creating agent",
"name": "GraphQLError",
}
`;
exports[`Agent creation should fail when responseFormat type is json but schema is missing 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"agent": [
{
"errors": [
{
"code": "INVALID_AGENT_INPUT",
"message": "Response format with type "json" must include a schema",
"userFriendlyMessage": "JSON response format requires a schema",
},
],
"flatEntityMinimalInformation": {
"name": "jsonWithoutSchemaAgent",
"universalIdentifier": Any<String>,
},
"metadataName": "agent",
"status": "fail",
"type": "create",
},
],
},
"message": "Validation failed for 1 agent",
"summary": {
"agent": 1,
"totalErrors": 1,
},
"userFriendlyMessage": "JSON response format requires a schema",
},
"message": "Multiple validation errors occurred while creating agent",
"name": "GraphQLError",
}
`;
exports[`Agent creation should fail when responseFormat type is text but schema is present 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"agent": [
{
"errors": [
{
"code": "INVALID_AGENT_INPUT",
"message": "Response format with type "text" should not include a schema",
"userFriendlyMessage": "Text response format should not have a schema",
},
],
"flatEntityMinimalInformation": {
"name": "textWithSchemaAgent",
"universalIdentifier": Any<String>,
},
"metadataName": "agent",
"status": "fail",
"type": "create",
},
],
},
"message": "Validation failed for 1 agent",
"summary": {
"agent": 1,
"totalErrors": 1,
},
"userFriendlyMessage": "Text response format should not have a schema",
},
"message": "Multiple validation errors occurred while creating agent",
"name": "GraphQLError",
}
`;

View File

@@ -0,0 +1,49 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`Agent deletion should fail should fail when attempting to delete a standard agent 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"agent": [
{
"errors": [
{
"code": "AGENT_IS_STANDARD",
"message": "Cannot delete standard agent",
"userFriendlyMessage": "Cannot delete standard agent",
},
],
"flatEntityMinimalInformation": {
"name": "helper",
"universalIdentifier": Any<String>,
},
"metadataName": "agent",
"status": "fail",
"type": "delete",
},
],
},
"message": "Validation failed for 1 agent",
"summary": {
"agent": 1,
"totalErrors": 1,
},
"userFriendlyMessage": "Cannot delete standard agent",
},
"message": "Multiple validation errors occurred while deleting agent",
"name": "GraphQLError",
}
`;
exports[`Agent deletion should fail when deleting a non-existent agent 1`] = `
{
"extensions": {
"code": "NOT_FOUND",
"subCode": "AGENT_NOT_FOUND",
"userFriendlyMessage": "Agent not found.",
},
"message": "Could not retrieve deleted agent",
"name": "NotFoundError",
}
`;

View File

@@ -0,0 +1,338 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`Agent update should fail should fail when attempting to update a standard agent 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"agent": [
{
"errors": [
{
"code": "AGENT_IS_STANDARD",
"message": "Cannot update standard agent",
"userFriendlyMessage": "Cannot update standard agent",
},
],
"flatEntityMinimalInformation": {
"universalIdentifier": Any<String>,
},
"metadataName": "agent",
"status": "fail",
"type": "update",
},
],
},
"message": "Validation failed for 1 agent",
"summary": {
"agent": 1,
"totalErrors": 1,
},
"userFriendlyMessage": "Cannot update standard agent",
},
"message": "Multiple validation errors occurred while updating agent",
"name": "GraphQLError",
}
`;
exports[`Agent update should fail should fail when updating a non-existent agent 1`] = `
{
"extensions": {
"code": "NOT_FOUND",
"subCode": "AGENT_NOT_FOUND",
"userFriendlyMessage": "The agent you are looking for could not be found. It may have been deleted or you may not have access to it.",
},
"message": "Agent not found",
"name": "NotFoundError",
}
`;
exports[`Agent update should fail when settings null required properties 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"agent": [
{
"errors": [
{
"code": "INVALID_AGENT_INPUT",
"message": "Label cannot be empty",
"userFriendlyMessage": "Label cannot be empty",
},
{
"code": "INVALID_AGENT_INPUT",
"message": "Prompt cannot be empty",
"userFriendlyMessage": "Prompt cannot be empty",
},
{
"code": "INVALID_AGENT_INPUT",
"message": "Model ID cannot be empty",
"userFriendlyMessage": "Model ID cannot be empty",
},
],
"flatEntityMinimalInformation": {
"universalIdentifier": Any<String>,
},
"metadataName": "agent",
"status": "fail",
"type": "update",
},
],
},
"message": "Validation failed for 1 agent",
"summary": {
"agent": 1,
"totalErrors": 1,
},
"userFriendlyMessage": "Label cannot be empty",
},
"message": "Multiple validation errors occurred while updating agent",
"name": "GraphQLError",
}
`;
exports[`Agent update should fail when updating label to one that already exists 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"agent": [
{
"errors": [
{
"code": "AGENT_ALREADY_EXISTS",
"message": "Agent with name "existingAgentForDuplicateTest" already exists",
"userFriendlyMessage": "An agent with this name already exists",
},
],
"flatEntityMinimalInformation": {
"universalIdentifier": Any<String>,
},
"metadataName": "agent",
"status": "fail",
"type": "update",
},
],
},
"message": "Validation failed for 1 agent",
"summary": {
"agent": 1,
"totalErrors": 1,
},
"userFriendlyMessage": "An agent with this name already exists",
},
"message": "Multiple validation errors occurred while updating agent",
"name": "GraphQLError",
}
`;
exports[`Agent update should fail when updating responseFormat type to json without schema 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"agent": [
{
"errors": [
{
"code": "INVALID_AGENT_INPUT",
"message": "Response format with type "json" must include a schema",
"userFriendlyMessage": "JSON response format requires a schema",
},
],
"flatEntityMinimalInformation": {
"universalIdentifier": Any<String>,
},
"metadataName": "agent",
"status": "fail",
"type": "update",
},
],
},
"message": "Validation failed for 1 agent",
"summary": {
"agent": 1,
"totalErrors": 1,
},
"userFriendlyMessage": "JSON response format requires a schema",
},
"message": "Multiple validation errors occurred while updating agent",
"name": "GraphQLError",
}
`;
exports[`Agent update should fail when updating responseFormat type to text with schema 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"agent": [
{
"errors": [
{
"code": "INVALID_AGENT_INPUT",
"message": "Response format with type "text" should not include a schema",
"userFriendlyMessage": "Text response format should not have a schema",
},
],
"flatEntityMinimalInformation": {
"universalIdentifier": Any<String>,
},
"metadataName": "agent",
"status": "fail",
"type": "update",
},
],
},
"message": "Validation failed for 1 agent",
"summary": {
"agent": 1,
"totalErrors": 1,
},
"userFriendlyMessage": "Text response format should not have a schema",
},
"message": "Multiple validation errors occurred while updating agent",
"name": "GraphQLError",
}
`;
exports[`Agent update should fail when updating responseFormat with invalid type 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"agent": [
{
"errors": [
{
"code": "INVALID_AGENT_INPUT",
"message": "Response format type must be either "text" or "json"",
"userFriendlyMessage": "Invalid response format type",
},
],
"flatEntityMinimalInformation": {
"universalIdentifier": Any<String>,
},
"metadataName": "agent",
"status": "fail",
"type": "update",
},
],
},
"message": "Validation failed for 1 agent",
"summary": {
"agent": 1,
"totalErrors": 1,
},
"userFriendlyMessage": "Invalid response format type",
},
"message": "Multiple validation errors occurred while updating agent",
"name": "GraphQLError",
}
`;
exports[`Agent update should fail when updating with empty label 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"agent": [
{
"errors": [
{
"code": "INVALID_AGENT_INPUT",
"message": "Label cannot be empty",
"userFriendlyMessage": "Label cannot be empty",
},
],
"flatEntityMinimalInformation": {
"universalIdentifier": Any<String>,
},
"metadataName": "agent",
"status": "fail",
"type": "update",
},
],
},
"message": "Validation failed for 1 agent",
"summary": {
"agent": 1,
"totalErrors": 1,
},
"userFriendlyMessage": "Label cannot be empty",
},
"message": "Multiple validation errors occurred while updating agent",
"name": "GraphQLError",
}
`;
exports[`Agent update should fail when updating with empty modelId 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"agent": [
{
"errors": [
{
"code": "INVALID_AGENT_INPUT",
"message": "Model ID cannot be empty",
"userFriendlyMessage": "Model ID cannot be empty",
},
],
"flatEntityMinimalInformation": {
"universalIdentifier": Any<String>,
},
"metadataName": "agent",
"status": "fail",
"type": "update",
},
],
},
"message": "Validation failed for 1 agent",
"summary": {
"agent": 1,
"totalErrors": 1,
},
"userFriendlyMessage": "Model ID cannot be empty",
},
"message": "Multiple validation errors occurred while updating agent",
"name": "GraphQLError",
}
`;
exports[`Agent update should fail when updating with empty prompt 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"agent": [
{
"errors": [
{
"code": "INVALID_AGENT_INPUT",
"message": "Prompt cannot be empty",
"userFriendlyMessage": "Prompt cannot be empty",
},
],
"flatEntityMinimalInformation": {
"universalIdentifier": Any<String>,
},
"metadataName": "agent",
"status": "fail",
"type": "update",
},
],
},
"message": "Validation failed for 1 agent",
"summary": {
"agent": 1,
"totalErrors": 1,
},
"userFriendlyMessage": "Prompt cannot be empty",
},
"message": "Multiple validation errors occurred while updating agent",
"name": "GraphQLError",
}
`;

View File

@@ -0,0 +1,175 @@
import { TEST_AI_MODEL_ID } from 'test/integration/constants/test-ai-model-ids.constants';
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
import { createOneAgent } from 'test/integration/metadata/suites/agent/utils/create-one-agent.util';
import { deleteOneAgent } from 'test/integration/metadata/suites/agent/utils/delete-one-agent.util';
import {
eachTestingContextFilter,
type EachTestingContext,
} from 'twenty-shared/testing';
import { isDefined } from 'twenty-shared/utils';
import { type CreateAgentInput } from 'src/engine/metadata-modules/ai/ai-agent/dtos/create-agent.input';
type TestContext = {
input: CreateAgentInput;
};
type GlobalTestContext = {
existingAgentLabel: string;
};
const globalTestContext: GlobalTestContext = {
existingAgentLabel: 'Existing Test Agent',
};
type CreateOneAgentTestingContext = EachTestingContext<TestContext>[];
describe('Agent creation should fail', () => {
let existingAgentId: string | undefined;
beforeAll(async () => {
const { data } = await createOneAgent({
expectToFail: false,
input: {
label: globalTestContext.existingAgentLabel,
prompt: 'Existing agent for testing',
modelId: TEST_AI_MODEL_ID,
},
});
existingAgentId = data.createOneAgent.id;
});
afterAll(async () => {
if (isDefined(existingAgentId)) {
await deleteOneAgent({
expectToFail: false,
input: { id: existingAgentId },
});
}
});
const failingAgentCreationTestCases: CreateOneAgentTestingContext = [
{
title: 'when label is missing',
context: {
input: {
prompt: 'Test prompt',
modelId: TEST_AI_MODEL_ID,
} as CreateAgentInput,
},
},
{
title: 'when prompt is missing',
context: {
input: {
label: 'Test Agent Missing Prompt',
modelId: TEST_AI_MODEL_ID,
} as CreateAgentInput,
},
},
{
title: 'when modelId is missing',
context: {
input: {
label: 'Test Agent Missing ModelId',
prompt: 'Test prompt',
} as CreateAgentInput,
},
},
{
title: 'when label is empty string',
context: {
input: {
label: '',
prompt: 'Test prompt',
modelId: TEST_AI_MODEL_ID,
},
},
},
{
title: 'when prompt is empty string',
context: {
input: {
label: 'Empty Prompt Agent',
prompt: '',
modelId: TEST_AI_MODEL_ID,
},
},
},
{
title: 'when modelId is empty string',
context: {
input: {
label: 'Empty ModelId Agent',
prompt: 'Test prompt',
modelId: '' as any,
},
},
},
{
title: 'when computed name already exists',
context: {
input: {
label: globalTestContext.existingAgentLabel,
prompt: 'Duplicate agent',
modelId: TEST_AI_MODEL_ID,
},
},
},
{
title: 'when responseFormat has invalid type',
context: {
input: {
label: 'Invalid Response Format Agent',
prompt: 'Test prompt',
modelId: TEST_AI_MODEL_ID,
responseFormat: {
type: 'invalid',
} as any,
},
},
},
{
title: 'when responseFormat type is json but schema is missing',
context: {
input: {
label: 'JSON Without Schema Agent',
prompt: 'Test prompt',
modelId: TEST_AI_MODEL_ID,
responseFormat: {
type: 'json',
} as any,
},
},
},
{
title: 'when responseFormat type is text but schema is present',
context: {
input: {
label: 'Text With Schema Agent',
prompt: 'Test prompt',
modelId: TEST_AI_MODEL_ID,
responseFormat: {
type: 'text',
schema: { type: 'object' },
} as any,
},
},
},
];
it.each(eachTestingContextFilter(failingAgentCreationTestCases))(
'$title',
async ({ context }) => {
const { errors } = await createOneAgent({
expectToFail: true,
input: context.input,
});
expectOneNotInternalServerErrorSnapshot({
errors,
});
},
);
});

View File

@@ -0,0 +1,68 @@
import { faker } from '@faker-js/faker';
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
import { deleteOneAgent } from 'test/integration/metadata/suites/agent/utils/delete-one-agent.util';
import { findAgents } from 'test/integration/metadata/suites/agent/utils/find-agents.util';
import {
eachTestingContextFilter,
type EachTestingContext,
} from 'twenty-shared/testing';
type TestContext = {
input: () => {
id: string;
};
};
type DeleteOneAgentTestingContext = EachTestingContext<TestContext>[];
describe('Agent deletion should fail', () => {
const failingAgentDeletionTestCases: DeleteOneAgentTestingContext = [
{
title: 'when deleting a non-existent agent',
context: {
input: () => ({
id: faker.string.uuid(),
}),
},
},
];
it.each(eachTestingContextFilter(failingAgentDeletionTestCases))(
'$title',
async ({ context }) => {
const { id } = context.input();
const { errors } = await deleteOneAgent({
expectToFail: true,
input: {
id,
},
});
expectOneNotInternalServerErrorSnapshot({ errors });
},
);
it('should fail when attempting to delete a standard agent', async () => {
const { data } = await findAgents({
expectToFail: false,
input: undefined,
gqlFields: 'id name isCustom',
});
const helperAgent = data.findManyAgents.find(
(agent) => agent.name === 'helper' && agent.isCustom === false,
);
expect(helperAgent).toBeDefined();
const { errors } = await deleteOneAgent({
expectToFail: true,
input: {
id: helperAgent!.id,
},
});
expectOneNotInternalServerErrorSnapshot({ errors });
});
});

View File

@@ -0,0 +1,227 @@
import { TEST_AI_MODEL_ID } from 'test/integration/constants/test-ai-model-ids.constants';
import { faker } from '@faker-js/faker';
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
import { createOneAgent } from 'test/integration/metadata/suites/agent/utils/create-one-agent.util';
import { deleteOneAgent } from 'test/integration/metadata/suites/agent/utils/delete-one-agent.util';
import { findAgents } from 'test/integration/metadata/suites/agent/utils/find-agents.util';
import { updateOneAgent } from 'test/integration/metadata/suites/agent/utils/update-one-agent.util';
import {
eachTestingContextFilter,
type EachTestingContext,
} from 'twenty-shared/testing';
import { type UpdateAgentInput } from 'src/engine/metadata-modules/ai/ai-agent/dtos/update-agent.input';
type TestContext = {
input: (testSetup: TestSetup) => UpdateAgentInput;
};
type TestSetup = {
testAgentId: string;
existingAgentLabelForDuplicate: string;
};
type GlobalTestContext = {
existingAgentLabelForDuplicate: string;
};
const globalTestContext: GlobalTestContext = {
existingAgentLabelForDuplicate: 'Existing Agent For Duplicate Test',
};
type UpdateOneAgentTestingContext = EachTestingContext<TestContext>[];
describe('Agent update should fail', () => {
let testAgentId: string;
let existingAgentIdForDuplicate: string;
beforeAll(async () => {
const { data: duplicateData } = await createOneAgent({
expectToFail: false,
input: {
label: globalTestContext.existingAgentLabelForDuplicate,
prompt: 'Existing agent for duplicate test',
modelId: TEST_AI_MODEL_ID,
},
});
existingAgentIdForDuplicate = duplicateData.createOneAgent.id;
});
beforeEach(async () => {
const { data } = await createOneAgent({
expectToFail: false,
input: {
label: 'Test Agent To Update',
description: 'Original description',
icon: 'IconRobot',
prompt: 'Original prompt',
modelId: TEST_AI_MODEL_ID,
},
});
testAgentId = data.createOneAgent.id;
});
afterEach(async () => {
await deleteOneAgent({
expectToFail: false,
input: { id: testAgentId },
});
});
afterAll(async () => {
await deleteOneAgent({
expectToFail: false,
input: { id: existingAgentIdForDuplicate },
});
});
const failingAgentUpdateTestCases: UpdateOneAgentTestingContext = [
{
title: 'when updating label to one that already exists',
context: {
input: (testSetup) => ({
id: testSetup.testAgentId,
label: testSetup.existingAgentLabelForDuplicate,
}),
},
},
{
title: 'when updating with empty label',
context: {
input: (testSetup) => ({
id: testSetup.testAgentId,
label: '',
}),
},
},
{
title: 'when updating with empty prompt',
context: {
input: (testSetup) => ({
id: testSetup.testAgentId,
prompt: '',
}),
},
},
{
title: 'when updating with empty modelId',
context: {
input: (testSetup) => ({
id: testSetup.testAgentId,
modelId: '' as any,
}),
},
},
{
title: 'when settings null required properties',
context: {
input: (testSetup) => ({
id: testSetup.testAgentId,
label: null,
prompt: null,
modelId: null,
}),
},
},
{
title: 'when updating responseFormat with invalid type',
context: {
input: (testSetup) => ({
id: testSetup.testAgentId,
responseFormat: {
type: 'invalid',
} as any,
}),
},
},
{
title: 'when updating responseFormat type to json without schema',
context: {
input: (testSetup) => ({
id: testSetup.testAgentId,
responseFormat: {
type: 'json',
} as any,
}),
},
},
{
title: 'when updating responseFormat type to text with schema',
context: {
input: (testSetup) => ({
id: testSetup.testAgentId,
responseFormat: {
type: 'text',
schema: { type: 'object' },
} as any,
}),
},
},
];
it.each(eachTestingContextFilter(failingAgentUpdateTestCases))(
'$title',
async ({ context }) => {
const testSetup: TestSetup = {
testAgentId,
existingAgentLabelForDuplicate:
globalTestContext.existingAgentLabelForDuplicate,
};
const input = context.input(testSetup);
const { errors } = await updateOneAgent({
expectToFail: true,
input,
});
expectOneNotInternalServerErrorSnapshot({
errors,
});
},
);
it('should fail when updating a non-existent agent', async () => {
const nonExistentAgentId = faker.string.uuid();
const { errors } = await updateOneAgent({
expectToFail: true,
input: {
id: nonExistentAgentId,
label: 'Updated Label',
},
});
expectOneNotInternalServerErrorSnapshot({
errors,
});
});
it('should fail when attempting to update a standard agent', async () => {
const { data } = await findAgents({
expectToFail: false,
input: undefined,
gqlFields: 'id name isCustom',
});
const helperAgent = data.findManyAgents.find(
(agent) => agent.name === 'helper' && agent.isCustom === false,
);
expect(helperAgent).toBeDefined();
const { errors } = await updateOneAgent({
expectToFail: true,
input: {
id: helperAgent!.id,
label: 'Attempted Update to Standard Agent',
},
});
expectOneNotInternalServerErrorSnapshot({
errors,
});
});
});

View File

@@ -0,0 +1,227 @@
import {
TEST_AI_MODEL_ID,
TEST_AI_OTHER_MODEL_ID,
} from 'test/integration/constants/test-ai-model-ids.constants';
import { createOneAgent } from 'test/integration/metadata/suites/agent/utils/create-one-agent.util';
import { deleteOneAgent } from 'test/integration/metadata/suites/agent/utils/delete-one-agent.util';
import { createOneRole } from 'test/integration/metadata/suites/role/utils/create-one-role.util';
import { deleteOneRole } from 'test/integration/metadata/suites/role/utils/delete-one-role.util';
import { type CreateAgentInput } from 'src/engine/metadata-modules/ai/ai-agent/dtos/create-agent.input';
describe('Agent creation should succeed', () => {
let createdAgentId: string;
afterEach(async () => {
if (createdAgentId) {
await deleteOneAgent({
expectToFail: false,
input: { id: createdAgentId },
});
}
});
it('should create a basic custom agent with minimal input', async () => {
const { data } = await createOneAgent({
expectToFail: false,
input: {
label: 'Test Agent',
prompt: 'You are a helpful test assistant',
modelId: TEST_AI_MODEL_ID,
},
});
createdAgentId = data?.createOneAgent?.id;
expect(data.createOneAgent).toMatchObject({
id: expect.any(String),
name: 'testAgent',
label: 'Test Agent',
icon: null,
description: null,
prompt: 'You are a helpful test assistant',
modelId: TEST_AI_MODEL_ID,
responseFormat: { type: 'text' },
roleId: null,
isCustom: true,
modelConfiguration: null,
evaluationInputs: [],
});
});
it('should create agent with all optional fields', async () => {
const input = {
name: 'customAgentName',
label: 'Custom Agent Label',
icon: 'IconRobot',
description: 'A custom agent with all fields specified',
prompt: 'You are a specialized assistant for testing',
modelId: TEST_AI_OTHER_MODEL_ID,
responseFormat: { type: 'text' },
modelConfiguration: {
webSearch: {
enabled: true,
configuration: {
maxTokens: 1000,
temperature: 0.7,
},
},
twitterSearch: {
enabled: true,
configuration: {},
},
},
evaluationInputs: ['test input 1', 'test input 2'],
} as const satisfies CreateAgentInput;
const { data } = await createOneAgent({
expectToFail: false,
input,
});
createdAgentId = data?.createOneAgent?.id;
expect(data.createOneAgent).toMatchObject({
id: expect.any(String),
...input,
});
});
it('should create agent with JSON response format', async () => {
const { data } = await createOneAgent({
expectToFail: false,
input: {
label: 'JSON Response Agent',
prompt: 'Return structured JSON data',
modelId: TEST_AI_MODEL_ID,
responseFormat: {
type: 'json',
schema: {
type: 'object',
properties: {
result: { type: 'string' },
confidence: { type: 'number' },
},
},
},
},
});
createdAgentId = data?.createOneAgent?.id;
expect(data.createOneAgent).toMatchObject({
id: expect.any(String),
label: 'JSON Response Agent',
prompt: 'Return structured JSON data',
responseFormat: {
type: 'json',
schema: {
type: 'object',
properties: {
result: { type: 'string' },
confidence: { type: 'number' },
},
},
},
});
});
it('should create agent and automatically compute name from label', async () => {
const { data } = await createOneAgent({
expectToFail: false,
input: {
label: 'My Test Agent With Spaces',
prompt: 'Testing name computation',
modelId: TEST_AI_MODEL_ID,
},
});
createdAgentId = data?.createOneAgent?.id;
expect(data.createOneAgent).toMatchObject({
id: expect.any(String),
name: 'myTestAgentWithSpaces',
label: 'My Test Agent With Spaces',
});
});
it('should sanitize input by trimming whitespace', async () => {
const { data } = await createOneAgent({
expectToFail: false,
input: {
name: ' agentWithSpaces ',
label: ' Agent With Spaces ',
icon: ' IconRobot ',
description: ' Description with spaces ',
prompt: ' Prompt with spaces ',
modelId: TEST_AI_MODEL_ID,
},
});
createdAgentId = data?.createOneAgent?.id;
expect(data.createOneAgent).toMatchObject({
id: expect.any(String),
name: 'agentWithSpaces',
label: 'Agent With Spaces',
icon: 'IconRobot',
description: 'Description with spaces',
prompt: 'Prompt with spaces',
});
});
it('should create agent with role assignment', async () => {
const { data: roleData } = await createOneRole({
expectToFail: false,
input: {
label: 'Test Agent Role',
description: 'A role for agent testing',
canUpdateAllSettings: false,
canAccessAllTools: true,
canReadAllObjectRecords: true,
canUpdateAllObjectRecords: false,
canSoftDeleteAllObjectRecords: false,
canDestroyAllObjectRecords: false,
canBeAssignedToUsers: false,
canBeAssignedToAgents: true,
canBeAssignedToApiKeys: false,
},
});
const createdRoleId = roleData?.createOneRole?.id;
const { data } = await createOneAgent({
expectToFail: false,
input: {
label: 'Agent With Role',
prompt: 'Agent with role assignment',
modelId: TEST_AI_MODEL_ID,
roleId: createdRoleId,
},
});
createdAgentId = data?.createOneAgent?.id;
expect(data.createOneAgent).toMatchObject({
id: expect.any(String),
label: 'Agent With Role',
prompt: 'Agent with role assignment',
modelId: TEST_AI_MODEL_ID,
roleId: createdRoleId,
isCustom: true,
});
// Delete the agent first so its role_target is removed; otherwise
// deleting the role would refuse to orphan the agent (the workspace
// default role isn't agent-assignable).
await deleteOneAgent({
expectToFail: false,
input: { id: createdAgentId },
});
createdAgentId = '';
await deleteOneRole({
expectToFail: false,
input: { idToDelete: createdRoleId },
});
});
});

View File

@@ -0,0 +1,42 @@
import { TEST_AI_MODEL_ID } from 'test/integration/constants/test-ai-model-ids.constants';
import { createOneAgent } from 'test/integration/metadata/suites/agent/utils/create-one-agent.util';
import { deleteOneAgent } from 'test/integration/metadata/suites/agent/utils/delete-one-agent.util';
import { findOneAgent } from 'test/integration/metadata/suites/agent/utils/find-one-agent.util';
describe('Agent deletion should succeed', () => {
it('should successfully delete a custom agent', async () => {
const { data: createData } = await createOneAgent({
expectToFail: false,
input: {
label: 'Agent To Delete',
prompt: 'This agent will be deleted',
modelId: TEST_AI_MODEL_ID,
},
});
const agentId = createData.createOneAgent.id;
const { data: findBeforeData } = await findOneAgent({
expectToFail: false,
input: { id: agentId },
});
expect(findBeforeData.findOneAgent.id).toBe(agentId);
const { data: deleteData } = await deleteOneAgent({
expectToFail: false,
input: { id: agentId },
});
expect(deleteData.deleteOneAgent).toMatchObject({
id: agentId,
});
const { errors: findAfterErrors } = await findOneAgent({
expectToFail: true,
input: { id: agentId },
});
expect(findAfterErrors).toBeDefined();
});
});

View File

@@ -0,0 +1,160 @@
import { TEST_AI_MODEL_ID } from 'test/integration/constants/test-ai-model-ids.constants';
import { createOneAgent } from 'test/integration/metadata/suites/agent/utils/create-one-agent.util';
import { deleteOneAgent } from 'test/integration/metadata/suites/agent/utils/delete-one-agent.util';
import { updateOneAgent } from 'test/integration/metadata/suites/agent/utils/update-one-agent.util';
import { createOneRole } from 'test/integration/metadata/suites/role/utils/create-one-role.util';
import { deleteOneRole } from 'test/integration/metadata/suites/role/utils/delete-one-role.util';
describe('Agent role update should succeed', () => {
let testAgentId: string;
let testRoleId: string;
let secondTestRoleId: string;
beforeAll(async () => {
const { data: role1Data } = await createOneRole({
expectToFail: false,
input: {
label: 'Test Agent Role 1',
description: 'First test role for agent assignment',
canUpdateAllSettings: false,
canAccessAllTools: true,
canReadAllObjectRecords: true,
canUpdateAllObjectRecords: false,
canSoftDeleteAllObjectRecords: false,
canDestroyAllObjectRecords: false,
canBeAssignedToUsers: false,
canBeAssignedToAgents: true,
canBeAssignedToApiKeys: false,
},
});
const { data: role2Data } = await createOneRole({
expectToFail: false,
input: {
label: 'Test Agent Role 2',
description: 'Second test role for agent assignment',
canUpdateAllSettings: false,
canAccessAllTools: false,
canReadAllObjectRecords: true,
canUpdateAllObjectRecords: false,
canSoftDeleteAllObjectRecords: false,
canDestroyAllObjectRecords: false,
canBeAssignedToUsers: false,
canBeAssignedToAgents: true,
canBeAssignedToApiKeys: false,
},
});
testRoleId = role1Data.createOneRole.id;
secondTestRoleId = role2Data.createOneRole.id;
});
afterAll(async () => {
await deleteOneRole({
expectToFail: false,
input: { idToDelete: testRoleId },
});
await deleteOneRole({
expectToFail: false,
input: { idToDelete: secondTestRoleId },
});
});
afterEach(async () => {
if (testAgentId) {
await deleteOneAgent({
expectToFail: false,
input: { id: testAgentId },
});
}
});
it('should add a role to an agent that does not have one initially', async () => {
const { data: createData } = await createOneAgent({
expectToFail: false,
input: {
label: 'Agent Without Role',
prompt: 'Test prompt',
modelId: TEST_AI_MODEL_ID,
},
});
testAgentId = createData.createOneAgent.id;
expect(createData.createOneAgent.roleId).toBeNull();
const { data: updateData } = await updateOneAgent({
expectToFail: false,
input: {
id: testAgentId,
roleId: testRoleId,
},
});
expect(updateData.updateOneAgent).toMatchObject({
id: testAgentId,
roleId: testRoleId,
label: 'Agent Without Role',
});
});
it('should change the role of an agent that already has one', async () => {
const { data: createData } = await createOneAgent({
expectToFail: false,
input: {
label: 'Agent With Initial Role',
prompt: 'Test prompt',
modelId: TEST_AI_MODEL_ID,
roleId: testRoleId,
},
});
testAgentId = createData.createOneAgent.id;
expect(createData.createOneAgent.roleId).toBe(testRoleId);
const { data: updateData } = await updateOneAgent({
expectToFail: false,
input: {
id: testAgentId,
roleId: secondTestRoleId,
},
});
expect(updateData.updateOneAgent).toMatchObject({
id: testAgentId,
roleId: secondTestRoleId,
label: 'Agent With Initial Role',
});
});
it('should remove a role from an agent that already has one', async () => {
const { data: createData } = await createOneAgent({
expectToFail: false,
input: {
label: 'Agent With Role To Remove',
prompt: 'Test prompt',
modelId: TEST_AI_MODEL_ID,
roleId: testRoleId,
},
});
testAgentId = createData.createOneAgent.id;
expect(createData.createOneAgent.roleId).toBe(testRoleId);
const { data: updateData } = await updateOneAgent({
expectToFail: false,
input: {
id: testAgentId,
roleId: null,
},
});
expect(updateData.updateOneAgent).toMatchObject({
id: testAgentId,
roleId: null,
label: 'Agent With Role To Remove',
});
});
});

View File

@@ -0,0 +1,311 @@
import {
TEST_AI_MODEL_ID,
TEST_AI_OTHER_MODEL_ID,
} from 'test/integration/constants/test-ai-model-ids.constants';
import { DEFAULT_TOOL_INPUT_SCHEMA } from 'twenty-shared/logic-function';
import { type AgentResponseSchema } from 'twenty-shared/ai';
import { createOneAgent } from 'test/integration/metadata/suites/agent/utils/create-one-agent.util';
import { deleteOneAgent } from 'test/integration/metadata/suites/agent/utils/delete-one-agent.util';
import { updateOneAgent } from 'test/integration/metadata/suites/agent/utils/update-one-agent.util';
describe('Agent update should succeed', () => {
let testAgentId: string;
beforeEach(async () => {
const { data } = await createOneAgent({
expectToFail: false,
input: {
label: 'Original Agent Label',
description: 'Original description',
icon: 'IconRobot',
prompt: 'Original prompt',
modelId: TEST_AI_MODEL_ID,
responseFormat: { type: 'text' },
evaluationInputs: ['input 1'],
},
});
testAgentId = data.createOneAgent.id;
});
afterEach(async () => {
await deleteOneAgent({
expectToFail: false,
input: { id: testAgentId },
});
});
it('should update agent label', async () => {
const { data } = await updateOneAgent({
expectToFail: false,
input: {
id: testAgentId,
label: 'Updated Agent Label',
},
});
expect(data.updateOneAgent).toMatchObject({
id: testAgentId,
label: 'Updated Agent Label',
description: 'Original description',
icon: 'IconRobot',
prompt: 'Original prompt',
});
});
it('should update agent description', async () => {
const { data } = await updateOneAgent({
expectToFail: false,
input: {
id: testAgentId,
description: 'Updated description for the agent',
},
});
expect(data.updateOneAgent).toMatchObject({
id: testAgentId,
label: 'Original Agent Label',
description: 'Updated description for the agent',
});
});
it('should update agent icon', async () => {
const { data } = await updateOneAgent({
expectToFail: false,
input: {
id: testAgentId,
icon: 'IconSparkles',
},
});
expect(data.updateOneAgent).toMatchObject({
id: testAgentId,
icon: 'IconSparkles',
});
});
it('should update agent prompt', async () => {
const { data } = await updateOneAgent({
expectToFail: false,
input: {
id: testAgentId,
prompt: 'Updated prompt with new instructions',
},
});
expect(data.updateOneAgent).toMatchObject({
id: testAgentId,
prompt: 'Updated prompt with new instructions',
});
});
it('should update agent modelId', async () => {
const { data } = await updateOneAgent({
expectToFail: false,
input: {
id: testAgentId,
modelId: TEST_AI_OTHER_MODEL_ID,
},
});
expect(data.updateOneAgent).toMatchObject({
id: testAgentId,
modelId: TEST_AI_OTHER_MODEL_ID,
});
});
it('should update agent responseFormat from text to json', async () => {
const { data } = await updateOneAgent({
expectToFail: false,
input: {
id: testAgentId,
responseFormat: {
type: 'json',
schema: {
type: 'object',
properties: {
answer: { type: 'string' },
confidence: { type: 'number' },
},
},
},
},
});
expect(data.updateOneAgent).toMatchObject({
id: testAgentId,
responseFormat: {
type: 'json',
schema: {
type: 'object',
properties: {
answer: { type: 'string' },
confidence: { type: 'number' },
},
},
},
});
});
it('should update agent responseFormat from json to text', async () => {
await updateOneAgent({
expectToFail: false,
input: {
id: testAgentId,
responseFormat: {
type: 'json',
schema: DEFAULT_TOOL_INPUT_SCHEMA as AgentResponseSchema,
},
},
});
const { data } = await updateOneAgent({
expectToFail: false,
input: {
id: testAgentId,
responseFormat: { type: 'text' },
},
});
expect(data.updateOneAgent).toMatchObject({
id: testAgentId,
responseFormat: { type: 'text' },
});
});
it('should update agent modelConfiguration', async () => {
const { data } = await updateOneAgent({
expectToFail: false,
input: {
id: testAgentId,
modelConfiguration: {
twitterSearch: {
enabled: true,
configuration: {
temperature: 0.9,
maxTokens: 2000,
topP: 0.95,
},
},
},
},
});
expect(data.updateOneAgent).toMatchObject({
id: testAgentId,
modelConfiguration: {
twitterSearch: {
enabled: true,
configuration: {
temperature: 0.9,
maxTokens: 2000,
topP: 0.95,
},
},
},
});
});
it('should update agent evaluationInputs', async () => {
const { data } = await updateOneAgent({
expectToFail: false,
input: {
id: testAgentId,
evaluationInputs: ['new input 1', 'new input 2', 'new input 3'],
},
});
expect(data.updateOneAgent).toMatchObject({
id: testAgentId,
evaluationInputs: ['new input 1', 'new input 2', 'new input 3'],
});
});
it('should update multiple agent properties at once', async () => {
const { data } = await updateOneAgent({
expectToFail: false,
input: {
id: testAgentId,
label: 'Comprehensive Update',
description: 'Updated multiple fields',
icon: 'IconBrain',
prompt: 'New comprehensive prompt',
modelId: TEST_AI_OTHER_MODEL_ID,
responseFormat: {
type: 'json',
schema: DEFAULT_TOOL_INPUT_SCHEMA as AgentResponseSchema,
},
evaluationInputs: ['eval 1', 'eval 2'],
},
});
expect(data.updateOneAgent).toMatchObject({
id: testAgentId,
label: 'Comprehensive Update',
description: 'Updated multiple fields',
icon: 'IconBrain',
prompt: 'New comprehensive prompt',
modelId: TEST_AI_OTHER_MODEL_ID,
responseFormat: {
type: 'json',
schema: { type: 'object' },
},
evaluationInputs: ['eval 1', 'eval 2'],
});
});
it('should sanitize input by trimming whitespace', async () => {
const { data } = await updateOneAgent({
expectToFail: false,
input: {
id: testAgentId,
label: ' Updated Label With Spaces ',
icon: ' IconRobot ',
description: ' Description with spaces ',
prompt: ' Prompt with spaces ',
},
});
expect(data.updateOneAgent).toMatchObject({
id: testAgentId,
label: 'Updated Label With Spaces',
icon: 'IconRobot',
description: 'Description with spaces',
prompt: 'Prompt with spaces',
});
});
it('should clear optional fields by setting to null', async () => {
const { data } = await updateOneAgent({
expectToFail: false,
input: {
id: testAgentId,
icon: null,
description: null,
modelConfiguration: null,
} as any,
});
expect(data.updateOneAgent).toMatchObject({
id: testAgentId,
icon: null,
description: null,
modelConfiguration: null,
});
});
it('should clear evaluationInputs by setting to empty array', async () => {
const { data } = await updateOneAgent({
expectToFail: false,
input: {
id: testAgentId,
evaluationInputs: [],
},
});
expect(data.updateOneAgent).toMatchObject({
id: testAgentId,
evaluationInputs: [],
});
});
});

View File

@@ -0,0 +1,40 @@
import gql from 'graphql-tag';
import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type';
import { type CreateAgentInput } from 'src/engine/metadata-modules/ai/ai-agent/dtos/create-agent.input';
export type CreateOneAgentFactoryInput = CreateAgentInput;
const DEFAULT_AGENT_GQL_FIELDS = `
id
name
label
icon
description
prompt
modelId
responseFormat
roleId
isCustom
applicationId
modelConfiguration
evaluationInputs
createdAt
updatedAt
`;
export const createOneAgentQueryFactory = ({
input,
gqlFields = DEFAULT_AGENT_GQL_FIELDS,
}: PerformMetadataQueryParams<CreateOneAgentFactoryInput>) => ({
query: gql`
mutation CreateOneAgent($input: CreateAgentInput!) {
createOneAgent(input: $input) {
${gqlFields}
}
}
`,
variables: {
input,
},
});

View File

@@ -0,0 +1,43 @@
import {
type CreateOneAgentFactoryInput,
createOneAgentQueryFactory,
} from 'test/integration/metadata/suites/agent/utils/create-one-agent-query-factory.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type';
import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type';
import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util';
import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util';
import { type AgentDTO } from 'src/engine/metadata-modules/ai/ai-agent/dtos/agent.dto';
export const createOneAgent = async ({
input,
gqlFields,
expectToFail = false,
token,
}: PerformMetadataQueryParams<CreateOneAgentFactoryInput>): CommonResponseBody<{
createOneAgent: AgentDTO;
}> => {
const graphqlOperation = createOneAgentQueryFactory({
input,
gqlFields,
});
const response = await makeMetadataAPIRequest(graphqlOperation, token);
if (expectToFail === true) {
warnIfNoErrorButExpectedToFail({
response,
errorMessage: 'Agent creation should have failed but did not',
});
}
if (expectToFail === false) {
warnIfErrorButNotExpectedToFail({
response,
errorMessage: 'Agent creation has failed but should not',
});
}
return { data: response.body.data, errors: response.body.errors };
};

View File

@@ -0,0 +1,23 @@
import gql from 'graphql-tag';
import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type';
export type DeleteOneAgentFactoryInput = {
id: string;
};
export const deleteOneAgentQueryFactory = ({
input,
}: PerformMetadataQueryParams<DeleteOneAgentFactoryInput>) => ({
query: gql`
mutation DeleteOneAgent($input: AgentIdInput!) {
deleteOneAgent(input: $input) {
id
}
}
`,
variables: {
input: {
id: input.id,
},
},
});

View File

@@ -0,0 +1,41 @@
import {
type DeleteOneAgentFactoryInput,
deleteOneAgentQueryFactory,
} from 'test/integration/metadata/suites/agent/utils/delete-one-agent-query-factory.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type';
import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type';
import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util';
import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util';
export const deleteOneAgent = async ({
input,
gqlFields,
expectToFail = false,
token,
}: PerformMetadataQueryParams<DeleteOneAgentFactoryInput>): CommonResponseBody<{
deleteOneAgent: { id: string };
}> => {
const graphqlOperation = deleteOneAgentQueryFactory({
input,
gqlFields,
});
const response = await makeMetadataAPIRequest(graphqlOperation, token);
if (expectToFail === true) {
warnIfNoErrorButExpectedToFail({
response,
errorMessage: 'Agent deletion should have failed but did not',
});
}
if (expectToFail === false) {
warnIfErrorButNotExpectedToFail({
response,
errorMessage: 'Agent deletion has failed but should not',
});
}
return { data: response.body.data, errors: response.body.errors };
};

View File

@@ -0,0 +1,32 @@
import gql from 'graphql-tag';
import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type';
const DEFAULT_AGENT_GQL_FIELDS = `
id
name
label
icon
description
prompt
modelId
responseFormat
roleId
isCustom
applicationId
modelConfiguration
evaluationInputs
createdAt
updatedAt
`;
export const findAgentsQueryFactory = ({
gqlFields = DEFAULT_AGENT_GQL_FIELDS,
}: PerformMetadataQueryParams<void>) => ({
query: gql`
query findManyAgents {
findManyAgents {
${gqlFields}
}
}
`,
});

View File

@@ -0,0 +1,39 @@
import { findAgentsQueryFactory } from 'test/integration/metadata/suites/agent/utils/find-agents-query-factory.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type';
import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type';
import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util';
import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util';
import { type AgentDTO } from 'src/engine/metadata-modules/ai/ai-agent/dtos/agent.dto';
export const findAgents = async ({
gqlFields,
expectToFail = false,
token,
}: PerformMetadataQueryParams<undefined>): CommonResponseBody<{
findManyAgents: AgentDTO[];
}> => {
const graphqlOperation = findAgentsQueryFactory({
gqlFields,
input: undefined,
});
const response = await makeMetadataAPIRequest(graphqlOperation, token);
if (expectToFail === true) {
warnIfNoErrorButExpectedToFail({
response,
errorMessage: 'Finding agents should have failed but did not',
});
}
if (expectToFail === false) {
warnIfErrorButNotExpectedToFail({
response,
errorMessage: 'Finding agents has failed but should not',
});
}
return { data: response.body.data, errors: response.body.errors };
};

View File

@@ -0,0 +1,42 @@
import gql from 'graphql-tag';
import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type';
export type FindOneAgentFactoryInput = {
id: string;
};
const DEFAULT_AGENT_GQL_FIELDS = `
id
name
label
icon
description
prompt
modelId
responseFormat
roleId
isCustom
applicationId
modelConfiguration
evaluationInputs
createdAt
updatedAt
`;
export const findOneAgentQueryFactory = ({
input,
gqlFields = DEFAULT_AGENT_GQL_FIELDS,
}: PerformMetadataQueryParams<FindOneAgentFactoryInput>) => ({
query: gql`
query FindOneAgent($input: AgentIdInput!) {
findOneAgent(input: $input) {
${gqlFields}
}
}
`,
variables: {
input: {
id: input.id,
},
},
});

View File

@@ -0,0 +1,43 @@
import {
type FindOneAgentFactoryInput,
findOneAgentQueryFactory,
} from 'test/integration/metadata/suites/agent/utils/find-one-agent-query-factory.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type';
import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type';
import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util';
import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util';
import { type AgentDTO } from 'src/engine/metadata-modules/ai/ai-agent/dtos/agent.dto';
export const findOneAgent = async ({
input,
gqlFields,
expectToFail = false,
token,
}: PerformMetadataQueryParams<FindOneAgentFactoryInput>): CommonResponseBody<{
findOneAgent: AgentDTO;
}> => {
const graphqlOperation = findOneAgentQueryFactory({
input,
gqlFields,
});
const response = await makeMetadataAPIRequest(graphqlOperation, token);
if (expectToFail === true) {
warnIfNoErrorButExpectedToFail({
response,
errorMessage: 'Finding agent should have failed but did not',
});
}
if (expectToFail === false) {
warnIfErrorButNotExpectedToFail({
response,
errorMessage: 'Finding agent has failed but should not',
});
}
return { data: response.body.data, errors: response.body.errors };
};

View File

@@ -0,0 +1,40 @@
import gql from 'graphql-tag';
import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type';
import { type UpdateAgentInput } from 'src/engine/metadata-modules/ai/ai-agent/dtos/update-agent.input';
export type UpdateOneAgentFactoryInput = UpdateAgentInput;
const DEFAULT_AGENT_GQL_FIELDS = `
id
name
label
icon
description
prompt
modelId
responseFormat
roleId
isCustom
applicationId
modelConfiguration
evaluationInputs
createdAt
updatedAt
`;
export const updateOneAgentQueryFactory = ({
gqlFields = DEFAULT_AGENT_GQL_FIELDS,
input,
}: PerformMetadataQueryParams<UpdateOneAgentFactoryInput>) => ({
query: gql`
mutation UpdateOneAgent($input: UpdateAgentInput!) {
updateOneAgent(input: $input) {
${gqlFields}
}
}
`,
variables: {
input,
},
});

View File

@@ -0,0 +1,43 @@
import {
type UpdateOneAgentFactoryInput,
updateOneAgentQueryFactory,
} from 'test/integration/metadata/suites/agent/utils/update-one-agent-query-factory.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type';
import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type';
import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util';
import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util';
import { type AgentDTO } from 'src/engine/metadata-modules/ai/ai-agent/dtos/agent.dto';
export const updateOneAgent = async ({
input,
gqlFields,
expectToFail = false,
token,
}: PerformMetadataQueryParams<UpdateOneAgentFactoryInput>): CommonResponseBody<{
updateOneAgent: AgentDTO;
}> => {
const graphqlOperation = updateOneAgentQueryFactory({
input,
gqlFields,
});
const response = await makeMetadataAPIRequest(graphqlOperation, token);
if (expectToFail === true) {
warnIfNoErrorButExpectedToFail({
response,
errorMessage: 'Agent update should have failed but did not',
});
}
if (expectToFail === false) {
warnIfErrorButNotExpectedToFail({
response,
errorMessage: 'Agent update has failed but should not',
});
}
return { data: response.body.data, errors: response.body.errors };
};

View File

@@ -0,0 +1,294 @@
import crypto from 'crypto';
import gql from 'graphql-tag';
import {
createApplicationRegistrationVariable,
deleteApplicationRegistrationVariable,
findApplicationRegistrationVariables,
updateApplicationRegistrationVariable,
} from 'test/integration/metadata/suites/application-registration-variable/utils/application-registration-variable-api.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
const TEST_WORKSPACE_ID = '20202020-1c25-4d02-bf25-6aeccf7ea419';
const insertRegistrationDirect = async (
name: string,
): Promise<{ id: string }> => {
const id = crypto.randomUUID();
const universalIdentifier = crypto.randomUUID();
const oAuthClientId = crypto.randomUUID();
await globalThis.testDataSource.query(
`INSERT INTO core."applicationRegistration"
(id, "universalIdentifier", name, "oAuthClientId", "oAuthRedirectUris", "oAuthScopes", "workspaceId")
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[
id,
universalIdentifier,
name,
oAuthClientId,
['http://localhost:3000/callback'],
['read'],
TEST_WORKSPACE_ID,
],
);
return { id };
};
const deleteRegistrationDirect = async (id: string): Promise<void> => {
await globalThis.testDataSource.query(
`DELETE FROM core."applicationRegistration" WHERE id = $1`,
[id],
);
};
describe('ApplicationRegistrationVariable (integration)', () => {
let registrationId: string;
beforeAll(async () => {
const registration = await insertRegistrationDirect('Variable Test App');
registrationId = registration.id;
});
afterAll(async () => {
await deleteRegistrationDirect(registrationId);
});
describe('CRUD lifecycle', () => {
let variableId: string;
it('should create a variable', async () => {
const { data } = await createApplicationRegistrationVariable({
applicationRegistrationId: registrationId,
key: 'API_KEY',
value: 'secret-value-123',
description: 'Third-party API key',
isSecret: true,
expectToFail: false,
});
const variable = data.createApplicationRegistrationVariable;
expect(variable).toBeDefined();
expect(variable.id).toBeDefined();
expect(variable.key).toBe('API_KEY');
expect(variable.description).toBe('Third-party API key');
expect(variable.isSecret).toBe(true);
expect(variable.isRequired).toBe(false);
expect(variable.isDeprecated).toBe(false);
expect(variable.isFilled).toBe(true);
variableId = variable.id;
});
it('should find variables for the registration', async () => {
const { data } = await findApplicationRegistrationVariables({
applicationRegistrationId: registrationId,
expectToFail: false,
});
const variables = data.findApplicationRegistrationVariables;
expect(variables).toBeDefined();
expect(variables.length).toBe(1);
expect(variables[0].key).toBe('API_KEY');
expect(variables[0].isFilled).toBe(true);
});
it('should update the variable value', async () => {
const { data } = await updateApplicationRegistrationVariable({
id: variableId,
value: 'new-secret-value-456',
expectToFail: false,
});
const variable = data.updateApplicationRegistrationVariable;
expect(variable).toBeDefined();
expect(variable.id).toBe(variableId);
expect(variable.isFilled).toBe(true);
});
it('should update the variable description', async () => {
const { data } = await updateApplicationRegistrationVariable({
id: variableId,
description: 'Updated API key description',
expectToFail: false,
});
const variable = data.updateApplicationRegistrationVariable;
expect(variable).toBeDefined();
expect(variable.description).toBe('Updated API key description');
});
it('should delete the variable', async () => {
const { data } = await deleteApplicationRegistrationVariable({
id: variableId,
expectToFail: false,
});
expect(data.deleteApplicationRegistrationVariable).toBe(true);
});
it('should return empty list after deletion', async () => {
const { data } = await findApplicationRegistrationVariables({
applicationRegistrationId: registrationId,
expectToFail: false,
});
expect(data.findApplicationRegistrationVariables).toHaveLength(0);
});
});
describe('non-secret variable', () => {
let variableId: string;
afterAll(async () => {
if (variableId) {
await deleteApplicationRegistrationVariable({
id: variableId,
});
}
});
it('should create a non-secret variable', async () => {
const { data } = await createApplicationRegistrationVariable({
applicationRegistrationId: registrationId,
key: 'PUBLIC_URL',
value: 'https://example.com',
description: 'Public webhook URL',
isSecret: false,
expectToFail: false,
});
const variable = data.createApplicationRegistrationVariable;
expect(variable).toBeDefined();
expect(variable.key).toBe('PUBLIC_URL');
expect(variable.isSecret).toBe(false);
expect(variable.isFilled).toBe(true);
variableId = variable.id;
});
});
describe('error cases', () => {
it('should fail to create a variable for a non-existent registration', async () => {
const { errors } = await createApplicationRegistrationVariable({
applicationRegistrationId: '00000000-0000-0000-0000-000000000000',
key: 'SOME_KEY',
value: 'some-value',
expectToFail: true,
});
expect(errors).toBeDefined();
expect(errors.length).toBeGreaterThan(0);
});
it('should fail to update a non-existent variable', async () => {
const { errors } = await updateApplicationRegistrationVariable({
id: '00000000-0000-0000-0000-000000000000',
value: 'new-value',
expectToFail: true,
});
expect(errors).toBeDefined();
expect(errors.length).toBeGreaterThan(0);
});
it('should fail to delete a non-existent variable', async () => {
const { errors } = await deleteApplicationRegistrationVariable({
id: '00000000-0000-0000-0000-000000000000',
expectToFail: true,
});
expect(errors).toBeDefined();
expect(errors.length).toBeGreaterThan(0);
});
it('should fail to find variables for a registration not owned by current workspace', async () => {
const { errors } = await findApplicationRegistrationVariables({
applicationRegistrationId: '00000000-0000-0000-0000-000000000000',
expectToFail: true,
});
expect(errors).toBeDefined();
expect(errors.length).toBeGreaterThan(0);
});
});
describe('variable via GraphQL createApplicationRegistration', () => {
let gqlRegistrationId: string;
afterAll(async () => {
if (gqlRegistrationId) {
await globalThis.testDataSource.query(
`DELETE FROM core."applicationRegistration" WHERE id = $1`,
[gqlRegistrationId],
);
}
});
it('should create a registration via GraphQL and manage variables on it', async () => {
const createResponse = await makeMetadataAPIRequest({
query: gql`
mutation CreateApplicationRegistration(
$input: CreateApplicationRegistrationInput!
) {
createApplicationRegistration(input: $input) {
applicationRegistration {
id
name
}
}
}
`,
variables: {
input: {
name: 'GQL Variable Test App',
},
},
});
expect(createResponse.body.data).toBeDefined();
const registration =
createResponse.body.data.createApplicationRegistration
.applicationRegistration;
expect(registration).toBeDefined();
expect(registration.id).toBeDefined();
gqlRegistrationId = registration.id;
const { data: createVarData } =
await createApplicationRegistrationVariable({
applicationRegistrationId: gqlRegistrationId,
key: 'WEBHOOK_SECRET',
value: 'whsec_test123',
description: 'Webhook signing secret',
expectToFail: false,
});
expect(createVarData.createApplicationRegistrationVariable.key).toBe(
'WEBHOOK_SECRET',
);
expect(createVarData.createApplicationRegistrationVariable.isFilled).toBe(
true,
);
const { data: findVarData } = await findApplicationRegistrationVariables({
applicationRegistrationId: gqlRegistrationId,
expectToFail: false,
});
expect(findVarData.findApplicationRegistrationVariables).toHaveLength(1);
expect(findVarData.findApplicationRegistrationVariables[0].key).toBe(
'WEBHOOK_SECRET',
);
});
});
});

View File

@@ -0,0 +1,155 @@
import {
createApplicationRegistrationVariableMutationFactory,
deleteApplicationRegistrationVariableMutationFactory,
findApplicationRegistrationVariablesQueryFactory,
updateApplicationRegistrationVariableMutationFactory,
} from 'test/integration/metadata/suites/application-registration-variable/utils/application-registration-variable-query-factories.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type';
import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util';
import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util';
import { type ApplicationRegistrationVariableDTO } from 'src/engine/core-modules/application/application-registration-variable/dtos/application-registration-variable.dto';
type VariableFields = Pick<
ApplicationRegistrationVariableDTO,
| 'id'
| 'key'
| 'description'
| 'isSecret'
| 'isRequired'
| 'isDeprecated'
| 'isFilled'
| 'createdAt'
| 'updatedAt'
>;
const handleExpectation = (
response: { body: { errors?: unknown[]; data?: unknown } },
expectToFail: boolean | undefined,
operationName: string,
) => {
if (expectToFail === true) {
warnIfNoErrorButExpectedToFail({
response: response as never,
errorMessage: `${operationName} should have failed but did not`,
});
}
if (expectToFail === false) {
warnIfErrorButNotExpectedToFail({
response: response as never,
errorMessage: `${operationName} has failed but should not`,
});
}
};
export const findApplicationRegistrationVariables = async ({
applicationRegistrationId,
expectToFail,
token,
}: {
applicationRegistrationId: string;
expectToFail?: boolean;
token?: string;
}): CommonResponseBody<{
findApplicationRegistrationVariables: VariableFields[];
}> => {
const graphqlOperation = findApplicationRegistrationVariablesQueryFactory({
applicationRegistrationId,
});
const response = await makeMetadataAPIRequest(graphqlOperation, token);
handleExpectation(response, expectToFail, 'Find variables');
return { data: response.body.data, errors: response.body.errors };
};
export const createApplicationRegistrationVariable = async ({
applicationRegistrationId,
key,
value,
description,
isSecret,
expectToFail,
token,
}: {
applicationRegistrationId: string;
key: string;
value: string;
description?: string;
isSecret?: boolean;
expectToFail?: boolean;
token?: string;
}): CommonResponseBody<{
createApplicationRegistrationVariable: VariableFields;
}> => {
const graphqlOperation = createApplicationRegistrationVariableMutationFactory(
{
applicationRegistrationId,
key,
value,
description,
isSecret,
},
);
const response = await makeMetadataAPIRequest(graphqlOperation, token);
handleExpectation(response, expectToFail, 'Create variable');
return { data: response.body.data, errors: response.body.errors };
};
export const updateApplicationRegistrationVariable = async ({
id,
value,
description,
expectToFail,
token,
}: {
id: string;
value?: string;
description?: string;
expectToFail?: boolean;
token?: string;
}): CommonResponseBody<{
updateApplicationRegistrationVariable: VariableFields;
}> => {
const graphqlOperation = updateApplicationRegistrationVariableMutationFactory(
{
id,
value,
description,
},
);
const response = await makeMetadataAPIRequest(graphqlOperation, token);
handleExpectation(response, expectToFail, 'Update variable');
return { data: response.body.data, errors: response.body.errors };
};
export const deleteApplicationRegistrationVariable = async ({
id,
expectToFail,
token,
}: {
id: string;
expectToFail?: boolean;
token?: string;
}): CommonResponseBody<{
deleteApplicationRegistrationVariable: boolean;
}> => {
const graphqlOperation = deleteApplicationRegistrationVariableMutationFactory(
{ id },
);
const response = await makeMetadataAPIRequest(graphqlOperation, token);
handleExpectation(response, expectToFail, 'Delete variable');
return { data: response.body.data, errors: response.body.errors };
};

View File

@@ -0,0 +1,107 @@
import gql from 'graphql-tag';
const VARIABLE_GQL_FIELDS = `
id
key
description
isSecret
isRequired
isDeprecated
isFilled
createdAt
updatedAt
`;
export const findApplicationRegistrationVariablesQueryFactory = ({
applicationRegistrationId,
}: {
applicationRegistrationId: string;
}) => ({
query: gql`
query FindApplicationRegistrationVariables(
$applicationRegistrationId: String!
) {
findApplicationRegistrationVariables(
applicationRegistrationId: $applicationRegistrationId
) {
${VARIABLE_GQL_FIELDS}
}
}
`,
variables: { applicationRegistrationId },
});
export const createApplicationRegistrationVariableMutationFactory = ({
applicationRegistrationId,
key,
value,
description,
isSecret,
}: {
applicationRegistrationId: string;
key: string;
value: string;
description?: string;
isSecret?: boolean;
}) => ({
query: gql`
mutation CreateApplicationRegistrationVariable(
$input: CreateApplicationRegistrationVariableInput!
) {
createApplicationRegistrationVariable(input: $input) {
${VARIABLE_GQL_FIELDS}
}
}
`,
variables: {
input: {
applicationRegistrationId,
key,
value,
...(description !== undefined && { description }),
...(isSecret !== undefined && { isSecret }),
},
},
});
export const updateApplicationRegistrationVariableMutationFactory = ({
id,
value,
description,
}: {
id: string;
value?: string;
description?: string;
}) => ({
query: gql`
mutation UpdateApplicationRegistrationVariable(
$input: UpdateApplicationRegistrationVariableInput!
) {
updateApplicationRegistrationVariable(input: $input) {
${VARIABLE_GQL_FIELDS}
}
}
`,
variables: {
input: {
id,
update: {
...(value !== undefined && { value }),
...(description !== undefined && { description }),
},
},
},
});
export const deleteApplicationRegistrationVariableMutationFactory = ({
id,
}: {
id: string;
}) => ({
query: gql`
mutation DeleteApplicationRegistrationVariable($id: String!) {
deleteApplicationRegistrationVariable(id: $id)
}
`,
variables: { id },
});

View File

@@ -0,0 +1,79 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Application registration instance flags (integration) updateAdminApplicationRegistration should fail for a caller without the SECURITY permission flag 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[`Application registration instance flags (integration) updateApplicationRegistration should fail when another workspace owns the registration 1`] = `
{
"extensions": {
"code": "NOT_FOUND",
"subCode": "APPLICATION_REGISTRATION_NOT_FOUND",
"userFriendlyMessage": "Application registration not found.",
},
"message": "Application registration with id <applicationRegistrationId> not found",
"name": "NotFoundError",
}
`;
exports[`Application registration instance flags (integration) updateApplicationRegistration should fail when no workspace has claimed the registration 1`] = `
{
"extensions": {
"code": "NOT_FOUND",
"subCode": "APPLICATION_REGISTRATION_NOT_FOUND",
"userFriendlyMessage": "Application registration not found.",
},
"message": "Application registration with id <applicationRegistrationId> not found",
"name": "NotFoundError",
}
`;
exports[`Application registration instance flags (integration) updateApplicationRegistration should fail when the payload carries isListed 1`] = `
{
"extensions": {
"code": "BAD_USER_INPUT",
"http": {
"status": 400,
},
"userFriendlyMessage": "An error occurred.",
},
"message": "Field "isListed" is not defined by type "UpdateApplicationRegistrationPayload".",
"name": "GraphQLError",
}
`;
exports[`Application registration instance flags (integration) updateApplicationRegistration should fail when the payload carries isPreInstalled 1`] = `
{
"extensions": {
"code": "BAD_USER_INPUT",
"http": {
"status": 400,
},
"userFriendlyMessage": "An error occurred.",
},
"message": "Field "isPreInstalled" is not defined by type "UpdateApplicationRegistrationPayload".",
"name": "GraphQLError",
}
`;
exports[`Application registration instance flags (integration) updateApplicationRegistration should fail when the payload carries isVetted 1`] = `
{
"extensions": {
"code": "BAD_USER_INPUT",
"http": {
"status": 400,
},
"userFriendlyMessage": "An error occurred.",
},
"message": "Field "isVetted" is not defined by type "UpdateApplicationRegistrationPayload".",
"name": "GraphQLError",
}
`;

View File

@@ -0,0 +1,25 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Install application is gated by the workspace completed upgrade version rejects installation when the workspace has not completed the required upgrade version 1`] = `
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "WORKSPACE_VERSION_INCOMPATIBLE",
"userFriendlyMessage": "This app requires a newer version than this workspace has finished upgrading to. Please try again once the workspace upgrade completes.",
},
"message": "App requires Twenty server >=<version> but this workspace has only completed the upgrade to <version>.",
"name": "UserInputError",
}
`;
exports[`Install application is gated by the workspace completed upgrade version rejects installation when the workspace upgrade cursor cannot be interpreted 1`] = `
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_WORKSPACE_VERSION",
"userFriendlyMessage": "This workspace's upgrade state could not be determined. Please try again once the workspace has finished upgrading.",
},
"message": "Cannot determine the completed upgrade version for workspace 20202020-1c25-4d02-bf25-6aeccf7ea419: no interpretable upgrade cursor found.",
"name": "UserInputError",
}
`;

View File

@@ -0,0 +1,145 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Application file endpoints should fail completeApplicationFileUploads when another workspace owns the application registration 1`] = `
{
"extensions": {
"code": "FORBIDDEN",
"subCode": "FORBIDDEN",
"userFriendlyMessage": "You do not have permission to perform this action.",
},
"message": ""<applicationUniversalIdentifier>" is registered to another workspace. Change the universalIdentifier in your manifest, or transfer the registration from the owning workspace.",
"name": "ForbiddenError",
}
`;
exports[`Application file endpoints should fail completeApplicationFileUploads when no workspace has claimed the application registration 1`] = `
{
"extensions": {
"code": "FORBIDDEN",
"subCode": "FORBIDDEN",
"userFriendlyMessage": "You do not have permission to perform this action.",
},
"message": ""<applicationUniversalIdentifier>" is registered on this instance but claimed by no workspace. Claim its ownership before developing on it.",
"name": "ForbiddenError",
}
`;
exports[`Application file endpoints should fail completeApplicationFileUploads when the application has no registration 1`] = `
{
"extensions": {
"code": "NOT_FOUND",
"subCode": "APPLICATION_NOT_FOUND",
"userFriendlyMessage": "Application not found.",
},
"message": "No registration found for "20202020-64aa-4b6f-b003-9c74b97cee20". Create one first with createApplicationRegistration.",
"name": "NotFoundError",
}
`;
exports[`Application file endpoints should fail completeApplicationFileUploads when the application has no registration link and another workspace owns the registration 1`] = `
{
"extensions": {
"code": "FORBIDDEN",
"subCode": "FORBIDDEN",
"userFriendlyMessage": "You do not have permission to perform this action.",
},
"message": ""<applicationUniversalIdentifier>" is registered to another workspace. Change the universalIdentifier in your manifest, or transfer the registration from the owning workspace.",
"name": "ForbiddenError",
}
`;
exports[`Application file endpoints should fail createApplicationFileUploads when another workspace owns the application registration 1`] = `
{
"extensions": {
"code": "FORBIDDEN",
"subCode": "FORBIDDEN",
"userFriendlyMessage": "You do not have permission to perform this action.",
},
"message": ""<applicationUniversalIdentifier>" is registered to another workspace. Change the universalIdentifier in your manifest, or transfer the registration from the owning workspace.",
"name": "ForbiddenError",
}
`;
exports[`Application file endpoints should fail createApplicationFileUploads when no workspace has claimed the application registration 1`] = `
{
"extensions": {
"code": "FORBIDDEN",
"subCode": "FORBIDDEN",
"userFriendlyMessage": "You do not have permission to perform this action.",
},
"message": ""<applicationUniversalIdentifier>" is registered on this instance but claimed by no workspace. Claim its ownership before developing on it.",
"name": "ForbiddenError",
}
`;
exports[`Application file endpoints should fail createApplicationFileUploads when the application has no registration 1`] = `
{
"extensions": {
"code": "NOT_FOUND",
"subCode": "APPLICATION_NOT_FOUND",
"userFriendlyMessage": "Application not found.",
},
"message": "No registration found for "20202020-64aa-4b6f-b003-9c74b97cee20". Create one first with createApplicationRegistration.",
"name": "NotFoundError",
}
`;
exports[`Application file endpoints should fail createApplicationFileUploads when the application has no registration link and another workspace owns the registration 1`] = `
{
"extensions": {
"code": "FORBIDDEN",
"subCode": "FORBIDDEN",
"userFriendlyMessage": "You do not have permission to perform this action.",
},
"message": ""<applicationUniversalIdentifier>" is registered to another workspace. Change the universalIdentifier in your manifest, or transfer the registration from the owning workspace.",
"name": "ForbiddenError",
}
`;
exports[`Application file endpoints should fail uploadApplicationFile when another workspace owns the application registration 1`] = `
{
"extensions": {
"code": "FORBIDDEN",
"subCode": "FORBIDDEN",
"userFriendlyMessage": "You do not have permission to perform this action.",
},
"message": ""<applicationUniversalIdentifier>" is registered to another workspace. Change the universalIdentifier in your manifest, or transfer the registration from the owning workspace.",
"name": "ForbiddenError",
}
`;
exports[`Application file endpoints should fail uploadApplicationFile when no workspace has claimed the application registration 1`] = `
{
"extensions": {
"code": "FORBIDDEN",
"subCode": "FORBIDDEN",
"userFriendlyMessage": "You do not have permission to perform this action.",
},
"message": ""<applicationUniversalIdentifier>" is registered on this instance but claimed by no workspace. Claim its ownership before developing on it.",
"name": "ForbiddenError",
}
`;
exports[`Application file endpoints should fail uploadApplicationFile when the application has no registration 1`] = `
{
"extensions": {
"code": "NOT_FOUND",
"subCode": "APPLICATION_NOT_FOUND",
"userFriendlyMessage": "Application not found.",
},
"message": "No registration found for "20202020-64aa-4b6f-b003-9c74b97cee20". Create one first with createApplicationRegistration.",
"name": "NotFoundError",
}
`;
exports[`Application file endpoints should fail uploadApplicationFile when the application has no registration link and another workspace owns the registration 1`] = `
{
"extensions": {
"code": "FORBIDDEN",
"subCode": "FORBIDDEN",
"userFriendlyMessage": "You do not have permission to perform this action.",
},
"message": ""<applicationUniversalIdentifier>" is registered to another workspace. Change the universalIdentifier in your manifest, or transfer the registration from the owning workspace.",
"name": "ForbiddenError",
}
`;

View File

@@ -0,0 +1,37 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Application version progression gate rejects deploying a version lower than the deployed one 1`] = `
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "VERSION_ALREADY_EXISTS",
"userFriendlyMessage": "This version is not higher than the currently deployed version. Please bump the version in package.json before deploying again.",
},
"message": "Cannot deploy 20202020-6b0e-4b1e-9d3a-000000002931@<version>: version must be higher than the currently deployed version <version>. Please bump the version in package.json.",
"name": "UserInputError",
}
`;
exports[`Application version progression gate rejects re-deploying the currently deployed version 1`] = `
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "VERSION_ALREADY_EXISTS",
"userFriendlyMessage": "This version is not higher than the currently deployed version. Please bump the version in package.json before deploying again.",
},
"message": "Cannot deploy 20202020-6b0e-4b1e-9d3a-000000002931@<version>: version must be higher than the currently deployed version <version>. Please bump the version in package.json.",
"name": "UserInputError",
}
`;
exports[`Application version progression gate rejects re-installing the already installed version 1`] = `
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "APP_ALREADY_INSTALLED",
"userFriendlyMessage": "This version of the application is already installed in this workspace.",
},
"message": "20202020-6b0e-4b1e-9d3a-000000002931@<version> is already installed in this workspace.",
"name": "UserInputError",
}
`;

View File

@@ -0,0 +1,48 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Install application should fail when entity does not exist should fail when a role has an invalid universalIdentifier 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"role": [
{
"errors": [
{
"code": "ENTITY_MALFORMED",
"message": "Invalid universalIdentifier: "not-a-valid-uuid" is not a valid UUID, uuid version should be greater than 4",
"value": "not-a-valid-uuid",
},
],
"flatEntityMinimalInformation": {
"universalIdentifier": Any<String>,
},
"metadataName": "role",
"status": "fail",
"type": "create",
},
],
},
"message": "Validation failed for 1 role",
"summary": {
"role": 1,
"totalErrors": 1,
},
"userFriendlyMessage": "Metadata validation failed",
},
"message": "Validation errors occurred while syncing application manifest metadata",
"name": "GraphQLError",
}
`;
exports[`Install application should fail when entity does not exist should fail with execution error when installing non-existent app registration 1`] = `
{
"extensions": {
"code": "NOT_FOUND",
"subCode": "APPLICATION_REGISTRATION_NOT_FOUND",
"userFriendlyMessage": "Application registration not found.",
},
"message": "No application registration found for identifier "20202020-0000-0000-0000-000000000000"",
"name": "NotFoundError",
}
`;

View File

@@ -0,0 +1,52 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Failing manifest sync - RECORD_TABLE widget with unknown view universal identifier rejects a dashboard RECORD_TABLE widget whose target view does not exist 1`] = `
{
"eventId": Any<String>,
"extensions": {
"action": {
"flatEntity": {
"applicationUniversalIdentifier": Any<String>,
"conditionalAvailabilityExpression": null,
"conditionalDisplay": null,
"createdAt": Any<String>,
"deletedAt": null,
"isActive": true,
"isSystemSideEffect": false,
"objectMetadataUniversalIdentifier": Any<String>,
"pageLayoutTabUniversalIdentifier": Any<String>,
"position": {
"column": 0,
"columnSpan": 6,
"layoutMode": "GRID",
"row": 0,
"rowSpan": 4,
},
"title": "RT test table",
"type": "RECORD_TABLE",
"universalConfiguration": {
"configurationType": "RECORD_TABLE",
"viewUniversalIdentifier": Any<String>,
},
"universalIdentifier": Any<String>,
"universalOverrides": null,
"updatedAt": Any<String>,
},
"id": Any<String>,
"metadataName": "pageLayoutWidget",
"type": "create",
},
"code": "APPLICATION_INSTALLATION_FAILED",
"errors": {
"actionTranspilation": {
"code": "ENTITY_NOT_FOUND",
"message": "View not found for universal identifier: b0000000-0000-4000-8000-00000000000b",
},
},
"exceptionEventId": Any<String>,
"userFriendlyMessage": "Migration execution failed.",
},
"message": "Migration action 'create' for 'pageLayoutWidget' (universalIdentifier: a0000000-0000-4000-8000-00000000000a) failed: [actionTranspilation] View not found for universal identifier: b0000000-0000-4000-8000-00000000000b",
"name": "GraphQLError",
}
`;

View File

@@ -0,0 +1,129 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Failing manifest update - standalone view fields on existing views rejects a standalone view field claiming the pair the engine emits for the created field 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"viewField": [
{
"errors": [
{
"code": "INVALID_VIEW_DATA",
"message": "View field with same fieldMetadataUniversalIdentifier and viewUniversalIdentifier already exists",
"userFriendlyMessage": "View field already exists",
},
],
"flatEntityMinimalInformation": {
"fieldMetadataUniversalIdentifier": Any<String>,
"universalIdentifier": Any<String>,
"viewUniversalIdentifier": Any<String>,
},
"metadataName": "viewField",
"status": "fail",
"type": "create",
},
],
},
"message": "Validation failed for 1 viewField",
"summary": {
"totalErrors": 1,
"viewField": 1,
},
"userFriendlyMessage": "View field already exists",
},
"message": "Validation errors occurred while syncing application manifest metadata",
"name": "GraphQLError",
}
`;
exports[`Failing manifest update - standalone view fields on existing views rejects a standalone view field whose target view does not exist 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"viewField": [
{
"errors": [
{
"code": "INVALID_VIEW_DATA",
"message": "View not found",
"userFriendlyMessage": "View not found",
},
],
"flatEntityMinimalInformation": {
"fieldMetadataUniversalIdentifier": Any<String>,
"universalIdentifier": Any<String>,
"viewUniversalIdentifier": Any<String>,
},
"metadataName": "viewField",
"status": "fail",
"type": "create",
},
],
},
"message": "Validation failed for 1 viewField",
"summary": {
"totalErrors": 1,
"viewField": 1,
},
"userFriendlyMessage": "View not found",
},
"message": "Validation errors occurred while syncing application manifest metadata",
"name": "GraphQLError",
}
`;
exports[`Failing manifest update - standalone view fields on existing views rejects two standalone view fields targeting the same field on the same view 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"viewField": [
{
"errors": [
{
"code": "INVALID_VIEW_DATA",
"message": "View field with same fieldMetadataUniversalIdentifier and viewUniversalIdentifier already exists",
"userFriendlyMessage": "View field already exists",
},
],
"flatEntityMinimalInformation": {
"fieldMetadataUniversalIdentifier": Any<String>,
"universalIdentifier": Any<String>,
"viewUniversalIdentifier": Any<String>,
},
"metadataName": "viewField",
"status": "fail",
"type": "create",
},
{
"errors": [
{
"code": "INVALID_VIEW_DATA",
"message": "View field with same fieldMetadataUniversalIdentifier and viewUniversalIdentifier already exists",
"userFriendlyMessage": "View field already exists",
},
],
"flatEntityMinimalInformation": {
"fieldMetadataUniversalIdentifier": Any<String>,
"universalIdentifier": Any<String>,
"viewUniversalIdentifier": Any<String>,
},
"metadataName": "viewField",
"status": "fail",
"type": "create",
},
],
},
"message": "Validation failed for 2 viewFields",
"summary": {
"totalErrors": 2,
"viewField": 2,
},
"userFriendlyMessage": "Many validation errors",
},
"message": "Validation errors occurred while syncing application manifest metadata",
"name": "GraphQLError",
}
`;

View File

@@ -0,0 +1,99 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Public assets controller download should fail when path attempts to escape into another file folder 1`] = `
{
"body": {
"code": "FILE_NOT_FOUND",
"error": "Error",
"messages": [
"File not found",
],
"statusCode": 404,
},
"status": 404,
}
`;
exports[`Public assets controller download should fail when path contains URL-encoded backslash traversal 1`] = `
{
"body": {
"code": "FILE_NOT_FOUND",
"error": "Error",
"messages": [
"File not found",
],
"statusCode": 404,
},
"status": 404,
}
`;
exports[`Public assets controller download should fail when path contains a leading parent-directory segment (..) 1`] = `
{
"body": {
"code": "FILE_NOT_FOUND",
"error": "Error",
"messages": [
"File not found",
],
"statusCode": 404,
},
"status": 404,
}
`;
exports[`Public assets controller download should fail when path contains multiple upward traversal segments (../../) 1`] = `
{
"body": {
"code": "FILE_NOT_FOUND",
"error": "Error",
"messages": [
"File not found",
],
"statusCode": 404,
},
"status": 404,
}
`;
exports[`Public assets controller download should fail when the applicationId does not match any application 1`] = `
{
"body": {
"code": "FILE_NOT_FOUND",
"error": "Error",
"messages": [
"File not found",
],
"statusCode": 404,
},
"status": 404,
}
`;
exports[`Public assets controller download should fail when the requested asset does not exist 1`] = `
{
"body": {
"code": "FILE_NOT_FOUND",
"error": "Error",
"messages": [
"File not found",
],
"statusCode": 404,
},
"status": 404,
}
`;
exports[`Public assets controller download should fail when the workspaceId does not match any workspace 1`] = `
{
"body": {
"code": "FILE_NOT_FOUND",
"error": "Error",
"messages": [
"File not found",
],
"statusCode": 404,
},
"status": 404,
}
`;

View File

@@ -0,0 +1,194 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Sync application should fail when creating permissions on a standard role from another app should fail when adding a field permission under the standard admin role 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"fieldPermission": [
{
"errors": [
{
"code": "ROLE_BELONGS_TO_ANOTHER_APPLICATION",
"message": "Cannot target a role owned by another application",
"userFriendlyMessage": "Cannot target a role owned by another application.",
},
{
"code": "ROLE_NOT_EDITABLE",
"message": "Role is not editable",
"userFriendlyMessage": "This role cannot be modified because it is a system role. Only custom roles can be edited.",
},
{
"code": "OBJECT_METADATA_NOT_FOUND_PERMISSIONS",
"message": "Object metadata not found",
"userFriendlyMessage": "Object metadata not found",
},
{
"code": "FIELD_METADATA_NOT_FOUND",
"message": "Field metadata not found",
"userFriendlyMessage": "Field metadata not found",
},
],
"flatEntityMinimalInformation": {
"fieldMetadataUniversalIdentifier": Any<String>,
"objectMetadataUniversalIdentifier": Any<String>,
"roleUniversalIdentifier": Any<String>,
"universalIdentifier": Any<String>,
},
"metadataName": "fieldPermission",
"status": "fail",
"type": "create",
},
],
"role": [
{
"errors": [
{
"code": "ENTITY_ALREADY_EXISTS",
"message": "Cannot create role: universalIdentifier "20202020-02c2-43f2-b94d-cab1f2b532eb" already exists in role maps from application "20202020-64aa-4b6f-b003-9c74b97cee20"",
},
],
"flatEntityMinimalInformation": {
"universalIdentifier": Any<String>,
},
"metadataName": "role",
"status": "fail",
"type": "create",
},
],
},
"message": "Validation failed for 1 role, 1 fieldPermission",
"summary": {
"fieldPermission": 1,
"role": 1,
"totalErrors": 2,
},
"userFriendlyMessage": "Many validation errors",
},
"message": "Validation errors occurred while syncing application manifest metadata",
"name": "GraphQLError",
}
`;
exports[`Sync application should fail when creating permissions on a standard role from another app should fail when adding a permission flag under the standard admin role 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"role": [
{
"errors": [
{
"code": "ENTITY_ALREADY_EXISTS",
"message": "Cannot create role: universalIdentifier "20202020-02c2-43f2-b94d-cab1f2b532eb" already exists in role maps from application "20202020-64aa-4b6f-b003-9c74b97cee20"",
},
],
"flatEntityMinimalInformation": {
"universalIdentifier": Any<String>,
},
"metadataName": "role",
"status": "fail",
"type": "create",
},
],
"rolePermissionFlag": [
{
"errors": [
{
"code": "ROLE_BELONGS_TO_ANOTHER_APPLICATION",
"message": "Cannot target a role owned by another application",
"userFriendlyMessage": "Cannot target a role owned by another application.",
},
{
"code": "ROLE_NOT_EDITABLE",
"message": "Role is not editable",
"userFriendlyMessage": "This role cannot be modified because it is a system role. Only custom roles can be edited.",
},
],
"flatEntityMinimalInformation": {
"permissionFlagUniversalIdentifier": Any<String>,
"roleUniversalIdentifier": Any<String>,
"universalIdentifier": Any<String>,
},
"metadataName": "rolePermissionFlag",
"status": "fail",
"type": "create",
},
],
},
"message": "Validation failed for 1 role, 1 rolePermissionFlag",
"summary": {
"role": 1,
"rolePermissionFlag": 1,
"totalErrors": 2,
},
"userFriendlyMessage": "Many validation errors",
},
"message": "Validation errors occurred while syncing application manifest metadata",
"name": "GraphQLError",
}
`;
exports[`Sync application should fail when creating permissions on a standard role from another app should fail when adding an object permission under the standard admin role 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"objectPermission": [
{
"errors": [
{
"code": "ROLE_BELONGS_TO_ANOTHER_APPLICATION",
"message": "Cannot target a role owned by another application",
"userFriendlyMessage": "Cannot target a role owned by another application.",
},
{
"code": "ROLE_NOT_EDITABLE",
"message": "Role is not editable",
"userFriendlyMessage": "This role cannot be modified because it is a system role. Only custom roles can be edited.",
},
{
"code": "OBJECT_METADATA_NOT_FOUND_PERMISSIONS",
"message": "Object metadata not found",
"userFriendlyMessage": "Object metadata not found",
},
],
"flatEntityMinimalInformation": {
"objectMetadataUniversalIdentifier": Any<String>,
"roleUniversalIdentifier": Any<String>,
"universalIdentifier": Any<String>,
},
"metadataName": "objectPermission",
"status": "fail",
"type": "create",
},
],
"role": [
{
"errors": [
{
"code": "ENTITY_ALREADY_EXISTS",
"message": "Cannot create role: universalIdentifier "20202020-02c2-43f2-b94d-cab1f2b532eb" already exists in role maps from application "20202020-64aa-4b6f-b003-9c74b97cee20"",
},
],
"flatEntityMinimalInformation": {
"universalIdentifier": Any<String>,
},
"metadataName": "role",
"status": "fail",
"type": "create",
},
],
},
"message": "Validation failed for 1 role, 1 objectPermission",
"summary": {
"objectPermission": 1,
"role": 1,
"totalErrors": 2,
},
"userFriendlyMessage": "Many validation errors",
},
"message": "Validation errors occurred while syncing application manifest metadata",
"name": "GraphQLError",
}
`;

View File

@@ -0,0 +1,192 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Sync application should fail when retargeting existing permissions to a standard role on update should fail when retargeting a field permission from own role to the standard admin role 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"fieldPermission": [
{
"errors": [
{
"code": "ROLE_BELONGS_TO_ANOTHER_APPLICATION",
"message": "Cannot target a role owned by another application",
"userFriendlyMessage": "Cannot target a role owned by another application.",
},
{
"code": "ROLE_NOT_EDITABLE",
"message": "Role is not editable",
"userFriendlyMessage": "This role cannot be modified because it is a system role. Only custom roles can be edited.",
},
],
"flatEntityMinimalInformation": {
"universalIdentifier": Any<String>,
},
"metadataName": "fieldPermission",
"status": "fail",
"type": "update",
},
],
"role": [
{
"errors": [
{
"code": "ROLE_LABEL_ALREADY_EXISTS",
"message": "A role with this label already exists",
"userFriendlyMessage": "A role with this label already exists.",
},
{
"code": "ENTITY_ALREADY_EXISTS",
"message": "Cannot create role: universalIdentifier "20202020-02c2-43f2-b94d-cab1f2b532eb" already exists in role maps from application "20202020-64aa-4b6f-b003-9c74b97cee20"",
},
],
"flatEntityMinimalInformation": {
"label": "Admin",
"universalIdentifier": Any<String>,
},
"metadataName": "role",
"status": "fail",
"type": "create",
},
],
},
"message": "Validation failed for 1 role, 1 fieldPermission",
"summary": {
"fieldPermission": 1,
"role": 1,
"totalErrors": 2,
},
"userFriendlyMessage": "Many validation errors",
},
"message": "Validation errors occurred while syncing application manifest metadata",
"name": "GraphQLError",
}
`;
exports[`Sync application should fail when retargeting existing permissions to a standard role on update should fail when retargeting a permission flag from own role to the standard admin role 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"role": [
{
"errors": [
{
"code": "ROLE_LABEL_ALREADY_EXISTS",
"message": "A role with this label already exists",
"userFriendlyMessage": "A role with this label already exists.",
},
{
"code": "ENTITY_ALREADY_EXISTS",
"message": "Cannot create role: universalIdentifier "20202020-02c2-43f2-b94d-cab1f2b532eb" already exists in role maps from application "20202020-64aa-4b6f-b003-9c74b97cee20"",
},
],
"flatEntityMinimalInformation": {
"label": "Admin",
"universalIdentifier": Any<String>,
},
"metadataName": "role",
"status": "fail",
"type": "create",
},
],
"rolePermissionFlag": [
{
"errors": [
{
"code": "ROLE_BELONGS_TO_ANOTHER_APPLICATION",
"message": "Cannot target a role owned by another application",
"userFriendlyMessage": "Cannot target a role owned by another application.",
},
{
"code": "ROLE_NOT_EDITABLE",
"message": "Role is not editable",
"userFriendlyMessage": "This role cannot be modified because it is a system role. Only custom roles can be edited.",
},
],
"flatEntityMinimalInformation": {
"permissionFlagUniversalIdentifier": Any<String>,
"roleUniversalIdentifier": Any<String>,
"universalIdentifier": Any<String>,
},
"metadataName": "rolePermissionFlag",
"status": "fail",
"type": "create",
},
],
},
"message": "Validation failed for 1 role, 1 rolePermissionFlag",
"summary": {
"role": 1,
"rolePermissionFlag": 1,
"totalErrors": 2,
},
"userFriendlyMessage": "Many validation errors",
},
"message": "Validation errors occurred while syncing application manifest metadata",
"name": "GraphQLError",
}
`;
exports[`Sync application should fail when retargeting existing permissions to a standard role on update should fail when retargeting an object permission from own role to the standard admin role 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"objectPermission": [
{
"errors": [
{
"code": "ROLE_BELONGS_TO_ANOTHER_APPLICATION",
"message": "Cannot target a role owned by another application",
"userFriendlyMessage": "Cannot target a role owned by another application.",
},
{
"code": "ROLE_NOT_EDITABLE",
"message": "Role is not editable",
"userFriendlyMessage": "This role cannot be modified because it is a system role. Only custom roles can be edited.",
},
],
"flatEntityMinimalInformation": {
"universalIdentifier": Any<String>,
},
"metadataName": "objectPermission",
"status": "fail",
"type": "update",
},
],
"role": [
{
"errors": [
{
"code": "ROLE_LABEL_ALREADY_EXISTS",
"message": "A role with this label already exists",
"userFriendlyMessage": "A role with this label already exists.",
},
{
"code": "ENTITY_ALREADY_EXISTS",
"message": "Cannot create role: universalIdentifier "20202020-02c2-43f2-b94d-cab1f2b532eb" already exists in role maps from application "20202020-64aa-4b6f-b003-9c74b97cee20"",
},
],
"flatEntityMinimalInformation": {
"label": "Admin",
"universalIdentifier": Any<String>,
},
"metadataName": "role",
"status": "fail",
"type": "create",
},
],
},
"message": "Validation failed for 1 role, 1 objectPermission",
"summary": {
"objectPermission": 1,
"role": 1,
"totalErrors": 2,
},
"userFriendlyMessage": "Many validation errors",
},
"message": "Validation errors occurred while syncing application manifest metadata",
"name": "GraphQLError",
}
`;

View File

@@ -0,0 +1,111 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Sync application default relation name collision should reject a manifest field named after a default relation when it carries a pinned universal identifier 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"fieldMetadata": [
{
"errors": [
{
"code": "NOT_AVAILABLE",
"message": "Name "attachments" is not available as it is already used by another field",
"userFriendlyMessage": "Name "attachments" is not available as it is already used by another field",
"value": "attachments",
},
],
"flatEntityMinimalInformation": {
"name": "attachments",
"objectMetadataUniversalIdentifier": Any<String>,
"universalIdentifier": Any<String>,
},
"metadataName": "fieldMetadata",
"status": "fail",
"type": "create",
},
{
"errors": [
{
"code": "FIELD_METADATA_NOT_FOUND",
"message": "Relation field target metadata not found in both existing and about to be created field metadatas",
"userFriendlyMessage": "Relation field target metadata not found",
},
],
"flatEntityMinimalInformation": {
"name": "targetDefaultRelationCollisionTarget",
"objectMetadataUniversalIdentifier": Any<String>,
"universalIdentifier": Any<String>,
},
"metadataName": "fieldMetadata",
"status": "fail",
"type": "create",
},
],
"index": [
{
"errors": [
{
"code": "INDEX_FIELD_NOT_FOUND",
"message": "Could not find index field related field metadata",
"userFriendlyMessage": "Field referenced in index does not exist",
},
],
"flatEntityMinimalInformation": {
"name": "IDX_6d15112a8688389df51c6dfc321",
"universalIdentifier": Any<String>,
},
"metadataName": "index",
"status": "fail",
"type": "create",
},
],
},
"message": "Validation failed for 2 fieldMetadata, 1 index",
"summary": {
"fieldMetadata": 2,
"index": 1,
"totalErrors": 3,
},
"userFriendlyMessage": "Many validation errors",
},
"message": "Validation errors occurred while syncing application manifest metadata",
"name": "GraphQLError",
}
`;
exports[`Sync application default relation name collision should reject a manifest field named after a default relation when it carries the derived universal identifier 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"fieldMetadata": [
{
"errors": [
{
"code": "RESERVED_SYSTEM_UNIVERSAL_IDENTIFIER",
"message": "Universal identifier is reserved for system-managed metadata",
"userFriendlyMessage": "This identifier is reserved by the system",
},
],
"flatEntityMinimalInformation": {
"name": "attachments",
"universalIdentifier": Any<String>,
},
"metadataName": "fieldMetadata",
"status": "fail",
"type": "create",
},
],
},
"message": "Validation failed for 1 fieldMetadata",
"summary": {
"fieldMetadata": 1,
"totalErrors": 1,
},
"userFriendlyMessage": "This identifier is reserved by the system",
},
"message": "Validation errors occurred while syncing application manifest metadata",
"name": "GraphQLError",
}
`;

View File

@@ -0,0 +1,29 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Sync application should surface a human error on flat-entity map conflicts should fail with an installation error naming the field when two fields of an object share a universalIdentifier 1`] = `
{
"eventId": Any<String>,
"extensions": {
"code": "APPLICATION_INSTALLATION_FAILED",
"exceptionEventId": Any<String>,
"subCode": "APPLICATION_INSTALLATION_FAILED",
"userFriendlyMessage": "We couldn't install "Test Application". Its field "Due Date" could not be applied to your workspace.",
},
"message": "Installing application 'Test Application' failed [field: Due Date]: addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow: flat entity to add already exists (universalIdentifier: b1b2c3d4-0004-4000-a000-000000000004)",
"name": "GraphQLError",
}
`;
exports[`Sync application should surface a human error on flat-entity map conflicts should fail with an installation error naming the object when two objects share a universalIdentifier 1`] = `
{
"eventId": Any<String>,
"extensions": {
"code": "APPLICATION_INSTALLATION_FAILED",
"exceptionEventId": Any<String>,
"subCode": "APPLICATION_INSTALLATION_FAILED",
"userFriendlyMessage": "We couldn't install "Test Application". Its object "Invoice" could not be applied to your workspace.",
},
"message": "Installing application 'Test Application' failed [object: Invoice]: addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow: flat entity to add already exists (universalIdentifier: b1b2c3d4-0003-4000-a000-000000000003)",
"name": "GraphQLError",
}
`;

View File

@@ -0,0 +1,334 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Sync application should fail on invalid navigation menu items when syncing a FOLDER item with a blank name 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"navigationMenuItem": [
{
"errors": [
{
"code": "INVALID_NAVIGATION_MENU_ITEM_INPUT",
"message": "A name is required for FOLDER type",
"userFriendlyMessage": "A name is required for FOLDER type",
},
],
"flatEntityMinimalInformation": {
"name": " ",
"type": "FOLDER",
"universalIdentifier": Any<String>,
},
"metadataName": "navigationMenuItem",
"status": "fail",
"type": "create",
},
],
},
"message": "Validation failed for 1 navigationMenuItem",
"summary": {
"navigationMenuItem": 1,
"totalErrors": 1,
},
"userFriendlyMessage": "A name is required for FOLDER type",
},
"message": "Validation errors occurred while syncing application manifest metadata",
"name": "GraphQLError",
}
`;
exports[`Sync application should fail on invalid navigation menu items when syncing a FOLDER item without name 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"navigationMenuItem": [
{
"errors": [
{
"code": "INVALID_NAVIGATION_MENU_ITEM_INPUT",
"message": "A name is required for FOLDER type",
"userFriendlyMessage": "A name is required for FOLDER type",
},
],
"flatEntityMinimalInformation": {
"name": null,
"type": "FOLDER",
"universalIdentifier": Any<String>,
},
"metadataName": "navigationMenuItem",
"status": "fail",
"type": "create",
},
],
},
"message": "Validation failed for 1 navigationMenuItem",
"summary": {
"navigationMenuItem": 1,
"totalErrors": 1,
},
"userFriendlyMessage": "A name is required for FOLDER type",
},
"message": "Validation errors occurred while syncing application manifest metadata",
"name": "GraphQLError",
}
`;
exports[`Sync application should fail on invalid navigation menu items when syncing a LINK item with a link that is not a valid url 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"navigationMenuItem": [
{
"errors": [
{
"code": "INVALID_NAVIGATION_MENU_ITEM_INPUT",
"message": "A valid link is required for LINK type",
"userFriendlyMessage": "A valid link is required for LINK type",
},
],
"flatEntityMinimalInformation": {
"name": "Link with invalid url",
"type": "LINK",
"universalIdentifier": Any<String>,
},
"metadataName": "navigationMenuItem",
"status": "fail",
"type": "create",
},
],
},
"message": "Validation failed for 1 navigationMenuItem",
"summary": {
"navigationMenuItem": 1,
"totalErrors": 1,
},
"userFriendlyMessage": "A valid link is required for LINK type",
},
"message": "Validation errors occurred while syncing application manifest metadata",
"name": "GraphQLError",
}
`;
exports[`Sync application should fail on invalid navigation menu items when syncing a LINK item without link 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"navigationMenuItem": [
{
"errors": [
{
"code": "INVALID_NAVIGATION_MENU_ITEM_INPUT",
"message": "A valid link is required for LINK type",
"userFriendlyMessage": "A valid link is required for LINK type",
},
],
"flatEntityMinimalInformation": {
"name": "Link without url",
"type": "LINK",
"universalIdentifier": Any<String>,
},
"metadataName": "navigationMenuItem",
"status": "fail",
"type": "create",
},
],
},
"message": "Validation failed for 1 navigationMenuItem",
"summary": {
"navigationMenuItem": 1,
"totalErrors": 1,
},
"userFriendlyMessage": "A valid link is required for LINK type",
},
"message": "Validation errors occurred while syncing application manifest metadata",
"name": "GraphQLError",
}
`;
exports[`Sync application should fail on invalid navigation menu items when syncing a PAGE_LAYOUT item with a pageLayoutUniversalIdentifier that is not a uuid 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"navigationMenuItem": [
{
"errors": [
{
"code": "INVALID_NAVIGATION_MENU_ITEM_INPUT",
"message": "A valid pageLayoutUniversalIdentifier is required for PAGE_LAYOUT type",
"userFriendlyMessage": "A valid pageLayoutUniversalIdentifier is required for PAGE_LAYOUT type",
},
],
"flatEntityMinimalInformation": {
"name": "Page layout with malformed identifier",
"type": "PAGE_LAYOUT",
"universalIdentifier": Any<String>,
},
"metadataName": "navigationMenuItem",
"status": "fail",
"type": "create",
},
],
},
"message": "Validation failed for 1 navigationMenuItem",
"summary": {
"navigationMenuItem": 1,
"totalErrors": 1,
},
"userFriendlyMessage": "A valid pageLayoutUniversalIdentifier is required for PAGE_LAYOUT type",
},
"message": "Validation errors occurred while syncing application manifest metadata",
"name": "GraphQLError",
}
`;
exports[`Sync application should fail on invalid navigation menu items when syncing a VIEW item with a viewUniversalIdentifier that is not a uuid 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"navigationMenuItem": [
{
"errors": [
{
"code": "INVALID_NAVIGATION_MENU_ITEM_INPUT",
"message": "A valid viewUniversalIdentifier is required for VIEW type",
"userFriendlyMessage": "A valid viewUniversalIdentifier is required for VIEW type",
},
],
"flatEntityMinimalInformation": {
"name": "View with malformed identifier",
"type": "VIEW",
"universalIdentifier": Any<String>,
},
"metadataName": "navigationMenuItem",
"status": "fail",
"type": "create",
},
],
},
"message": "Validation failed for 1 navigationMenuItem",
"summary": {
"navigationMenuItem": 1,
"totalErrors": 1,
},
"userFriendlyMessage": "A valid viewUniversalIdentifier is required for VIEW type",
},
"message": "Validation errors occurred while syncing application manifest metadata",
"name": "GraphQLError",
}
`;
exports[`Sync application should fail on invalid navigation menu items when syncing an OBJECT item with a targetObjectUniversalIdentifier that is not a uuid 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"navigationMenuItem": [
{
"errors": [
{
"code": "INVALID_NAVIGATION_MENU_ITEM_INPUT",
"message": "A valid targetObjectMetadataUniversalIdentifier is required for OBJECT type",
"userFriendlyMessage": "A valid targetObjectMetadataUniversalIdentifier is required for OBJECT type",
},
],
"flatEntityMinimalInformation": {
"name": "Object with malformed target",
"type": "OBJECT",
"universalIdentifier": Any<String>,
},
"metadataName": "navigationMenuItem",
"status": "fail",
"type": "create",
},
],
},
"message": "Validation failed for 1 navigationMenuItem",
"summary": {
"navigationMenuItem": 1,
"totalErrors": 1,
},
"userFriendlyMessage": "A valid targetObjectMetadataUniversalIdentifier is required for OBJECT type",
},
"message": "Validation errors occurred while syncing application manifest metadata",
"name": "GraphQLError",
}
`;
exports[`Sync application should fail on invalid navigation menu items when syncing an OBJECT item without targetObjectUniversalIdentifier 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"navigationMenuItem": [
{
"errors": [
{
"code": "INVALID_NAVIGATION_MENU_ITEM_INPUT",
"message": "A valid targetObjectMetadataUniversalIdentifier is required for OBJECT type",
"userFriendlyMessage": "A valid targetObjectMetadataUniversalIdentifier is required for OBJECT type",
},
],
"flatEntityMinimalInformation": {
"name": "Object without target",
"type": "OBJECT",
"universalIdentifier": Any<String>,
},
"metadataName": "navigationMenuItem",
"status": "fail",
"type": "create",
},
],
},
"message": "Validation failed for 1 navigationMenuItem",
"summary": {
"navigationMenuItem": 1,
"totalErrors": 1,
},
"userFriendlyMessage": "A valid targetObjectMetadataUniversalIdentifier is required for OBJECT type",
},
"message": "Validation errors occurred while syncing application manifest metadata",
"name": "GraphQLError",
}
`;
exports[`Sync application should fail on invalid navigation menu items when syncing an item with an unknown type 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"navigationMenuItem": [
{
"errors": [
{
"code": "INVALID_NAVIGATION_MENU_ITEM_INPUT",
"message": "Unknown navigation menu item type UNKNOWN_NAVIGATION_MENU_ITEM_TYPE",
"userFriendlyMessage": "Unknown navigation menu item type UNKNOWN_NAVIGATION_MENU_ITEM_TYPE",
},
],
"flatEntityMinimalInformation": {
"name": "Item with unknown type",
"type": "UNKNOWN_NAVIGATION_MENU_ITEM_TYPE",
"universalIdentifier": Any<String>,
},
"metadataName": "navigationMenuItem",
"status": "fail",
"type": "create",
},
],
},
"message": "Validation failed for 1 navigationMenuItem",
"summary": {
"navigationMenuItem": 1,
"totalErrors": 1,
},
"userFriendlyMessage": "Unknown navigation menu item type UNKNOWN_NAVIGATION_MENU_ITEM_TYPE",
},
"message": "Validation errors occurred while syncing application manifest metadata",
"name": "GraphQLError",
}
`;

View File

@@ -0,0 +1,168 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Sync application should reject hijacking the searchVector system field should reject a manifest field declared under the reserved searchVector name 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"fieldMetadata": [
{
"errors": [
{
"code": "FIELD_MUTATION_NOT_ALLOWED",
"message": "System fields only allow updating: universalSettings, isActive. Forbidden properties: description, icon, isUIEditable, writability",
"userFriendlyMessage": "System fields cannot be updated",
},
{
"code": "INVALID_FIELD_INPUT",
"message": "Field type TS_VECTOR must have SYSTEM writability",
"userFriendlyMessage": "Field type TS_VECTOR must have SYSTEM writability",
"value": "OPEN",
},
],
"flatEntityMinimalInformation": {
"name": "searchVector",
"objectMetadataUniversalIdentifier": Any<String>,
"universalIdentifier": Any<String>,
},
"metadataName": "fieldMetadata",
"status": "fail",
"type": "update",
},
],
},
"message": "Validation failed for 1 fieldMetadata",
"summary": {
"fieldMetadata": 1,
"totalErrors": 1,
},
"userFriendlyMessage": "System fields cannot be updated",
},
"message": "Validation errors occurred while syncing application manifest metadata",
"name": "GraphQLError",
}
`;
exports[`Sync application should reject hijacking the searchVector system field should reject a manifest field reusing the searchVector name under a foreign universal identifier 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"fieldMetadata": [
{
"errors": [
{
"code": "NOT_AVAILABLE",
"message": "Name "searchVector" is not available as it is already used by another field",
"userFriendlyMessage": "Name "searchVector" is not available as it is already used by another field",
"value": "searchVector",
},
],
"flatEntityMinimalInformation": {
"name": "searchVector",
"objectMetadataUniversalIdentifier": Any<String>,
"universalIdentifier": Any<String>,
},
"metadataName": "fieldMetadata",
"status": "fail",
"type": "create",
},
],
"viewField": [
{
"errors": [
{
"code": "INVALID_VIEW_DATA",
"message": "Field metadata not found",
"userFriendlyMessage": "Field metadata not found",
},
],
"flatEntityMinimalInformation": {
"fieldMetadataUniversalIdentifier": Any<String>,
"universalIdentifier": Any<String>,
"viewUniversalIdentifier": Any<String>,
},
"metadataName": "viewField",
"status": "fail",
"type": "create",
},
{
"errors": [
{
"code": "INVALID_VIEW_DATA",
"message": "Field metadata not found",
"userFriendlyMessage": "Field metadata not found",
},
],
"flatEntityMinimalInformation": {
"fieldMetadataUniversalIdentifier": Any<String>,
"universalIdentifier": Any<String>,
"viewUniversalIdentifier": Any<String>,
},
"metadataName": "viewField",
"status": "fail",
"type": "create",
},
],
},
"message": "Validation failed for 1 fieldMetadata, 2 viewFields",
"summary": {
"fieldMetadata": 1,
"totalErrors": 3,
"viewField": 2,
},
"userFriendlyMessage": "Many validation errors",
},
"message": "Validation errors occurred while syncing application manifest metadata",
"name": "GraphQLError",
}
`;
exports[`Sync application should reject hijacking the searchVector system field should reject a manifest field squatting the searchVector deterministic universal identifier 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"fieldMetadata": [
{
"errors": [
{
"code": "FIELD_MUTATION_NOT_ALLOWED",
"message": "System fields only allow updating: universalSettings, isActive. Forbidden properties: description, icon, label, name, isUIEditable, writability",
"userFriendlyMessage": "System fields cannot be updated",
},
{
"code": "INVALID_FIELD_INPUT",
"message": "Field type TS_VECTOR must be named "searchVector", got "stolenSearchVector"",
"userFriendlyMessage": "Field type TS_VECTOR must be named "searchVector"",
"value": "stolenSearchVector",
},
{
"code": "INVALID_FIELD_INPUT",
"message": "Field type TS_VECTOR must have SYSTEM writability",
"userFriendlyMessage": "Field type TS_VECTOR must have SYSTEM writability",
"value": "OPEN",
},
],
"flatEntityMinimalInformation": {
"name": "stolenSearchVector",
"objectMetadataUniversalIdentifier": Any<String>,
"universalIdentifier": Any<String>,
},
"metadataName": "fieldMetadata",
"status": "fail",
"type": "update",
},
],
},
"message": "Validation failed for 1 fieldMetadata",
"summary": {
"fieldMetadata": 1,
"totalErrors": 1,
},
"userFriendlyMessage": "System fields cannot be updated",
},
"message": "Validation errors occurred while syncing application manifest metadata",
"name": "GraphQLError",
}
`;

View File

@@ -0,0 +1,52 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Sync application should surface the root cause when a unique index cannot be created over duplicated data should fail with the underlying postgres error in the migration runner error message 1`] = `
{
"eventId": Any<String>,
"extensions": {
"action": {
"flatEntity": {
"applicationUniversalIdentifier": Any<String>,
"createdAt": Any<String>,
"indexType": "BTREE",
"indexWhereClause": null,
"isCustom": true,
"isSystemSideEffect": true,
"isUnique": true,
"name": "IDX_UNIQUE_85951922a2bfc9f7811d8c5cdb8",
"objectMetadataUniversalIdentifier": Any<String>,
"universalFlatIndexFieldMetadatas": [
{
"createdAt": Any<String>,
"fieldMetadataUniversalIdentifier": Any<String>,
"indexMetadataUniversalIdentifier": Any<String>,
"order": 0,
"subFieldName": null,
"updatedAt": Any<String>,
},
],
"universalIdentifier": Any<String>,
"updatedAt": Any<String>,
},
"id": Any<String>,
"metadataName": "index",
"type": "create",
},
"code": "APPLICATION_INSTALLATION_FAILED",
"errors": {
"metadata": {
"code": "25P02",
"message": "current transaction is aborted, commands ignored until end of transaction block",
},
"workspaceSchema": {
"code": "23505",
"message": "could not create unique index "IDX_UNIQUE_85951922a2bfc9f7811d8c5cdb8"",
},
},
"exceptionEventId": Any<String>,
"userFriendlyMessage": "Migration execution failed.",
},
"message": "Migration action 'create' for 'index' (universalIdentifier: 67c6811e-9c4e-578a-ad3a-ea4ef152c0fd) failed: [workspaceSchema] could not create unique index "IDX_UNIQUE_85951922a2bfc9f7811d8c5cdb8" (pg code: 23505, detail: Key ("externalId")=(DUPLICATED-VALUE) is duplicated.)",
"name": "GraphQLError",
}
`;

View File

@@ -0,0 +1,37 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Sync application should fail on unique index side-effect collision should fail when a declared index reuses the reserved deterministic identifier of the engine-owned unique backing index 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"index": [
{
"errors": [
{
"code": "RESERVED_SYSTEM_UNIVERSAL_IDENTIFIER",
"message": "Universal identifier is reserved for system-managed metadata",
"userFriendlyMessage": "This identifier is reserved by the system",
},
],
"flatEntityMinimalInformation": {
"name": "IDX_UNIQUE_790b15e5a08e02973ecff530bce",
"universalIdentifier": Any<String>,
},
"metadataName": "index",
"status": "fail",
"type": "create",
},
],
},
"message": "Validation failed for 1 index",
"summary": {
"index": 1,
"totalErrors": 1,
},
"userFriendlyMessage": "This identifier is reserved by the system",
},
"message": "Validation errors occurred while syncing application manifest metadata",
"name": "GraphQLError",
}
`;

View File

@@ -0,0 +1,68 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`Sync application should fail on universalIdentifier conflicts should fail when an entity uses a universalIdentifier from the twenty-standard app 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"role": [
{
"errors": [
{
"code": "ENTITY_ALREADY_EXISTS",
"message": "Cannot create role: universalIdentifier "20202020-02c2-43f2-b94d-cab1f2b532eb" already exists in role maps from application "20202020-64aa-4b6f-b003-9c74b97cee20"",
},
],
"flatEntityMinimalInformation": {
"universalIdentifier": Any<String>,
},
"metadataName": "role",
"status": "fail",
"type": "create",
},
],
},
"message": "Validation failed for 1 role",
"summary": {
"role": 1,
"totalErrors": 1,
},
"userFriendlyMessage": "Metadata validation failed",
},
"message": "Validation errors occurred while syncing application manifest metadata",
"name": "GraphQLError",
}
`;
exports[`Sync application should fail on universalIdentifier conflicts should fail when two entities in the same manifest share the same universalIdentifier 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"role": [
{
"errors": [
{
"code": "ENTITY_ALREADY_EXISTS",
"message": "Cannot create role: universalIdentifier "a1b2c3d4-0003-4000-a000-000000000003" is already taken by objectPermission from application "a1b2c3d4-0001-4000-a000-000000000001"",
},
],
"flatEntityMinimalInformation": {
"universalIdentifier": Any<String>,
},
"metadataName": "role",
"type": "create",
},
],
},
"message": "Validation failed for 1 role",
"summary": {
"role": 1,
"totalErrors": 1,
},
"userFriendlyMessage": "Metadata validation failed",
},
"message": "Validation errors occurred while syncing application manifest metadata",
"name": "GraphQLError",
}
`;

View File

@@ -0,0 +1,246 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Sync application should fail on invalid view filter values when a BOOLEAN filter is neither true nor false 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"viewFilter": [
{
"errors": [
{
"code": "INVALID_VIEW_FILTER_DATA",
"message": "Value "yes" is not valid for operand "IS" on field type "BOOLEAN". Invalid option: expected one of "true"|"false"",
"userFriendlyMessage": "Filter value is not valid for this operand",
},
],
"flatEntityMinimalInformation": {
"universalIdentifier": Any<String>,
},
"metadataName": "viewFilter",
"status": "fail",
"type": "create",
},
],
},
"message": "Validation failed for 1 viewFilter",
"summary": {
"totalErrors": 1,
"viewFilter": 1,
},
"userFriendlyMessage": "Filter value is not valid for this operand",
},
"message": "Validation errors occurred while syncing application manifest metadata",
"name": "GraphQLError",
}
`;
exports[`Sync application should fail on invalid view filter values when a DATE filter is not an ISO date 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"viewFilter": [
{
"errors": [
{
"code": "INVALID_VIEW_FILTER_DATA",
"message": "Value "31/01/2026" is not valid for operand "IS" on field type "DATE". Expected an ISO date, e.g. "2026-01-31"",
"userFriendlyMessage": "Filter value is not valid for this operand",
},
],
"flatEntityMinimalInformation": {
"universalIdentifier": Any<String>,
},
"metadataName": "viewFilter",
"status": "fail",
"type": "create",
},
],
},
"message": "Validation failed for 1 viewFilter",
"summary": {
"totalErrors": 1,
"viewFilter": 1,
},
"userFriendlyMessage": "Filter value is not valid for this operand",
},
"message": "Validation errors occurred while syncing application manifest metadata",
"name": "GraphQLError",
}
`;
exports[`Sync application should fail on invalid view filter values when a NUMBER filter has trailing characters 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"viewFilter": [
{
"errors": [
{
"code": "INVALID_VIEW_FILTER_DATA",
"message": "Value "30abc" is not valid for operand "IS" on field type "NUMBER". Expected a number",
"userFriendlyMessage": "Filter value is not valid for this operand",
},
],
"flatEntityMinimalInformation": {
"universalIdentifier": Any<String>,
},
"metadataName": "viewFilter",
"status": "fail",
"type": "create",
},
],
},
"message": "Validation failed for 1 viewFilter",
"summary": {
"totalErrors": 1,
"viewFilter": 1,
},
"userFriendlyMessage": "Filter value is not valid for this operand",
},
"message": "Validation errors occurred while syncing application manifest metadata",
"name": "GraphQLError",
}
`;
exports[`Sync application should fail on invalid view filter values when a NUMBER filter is not a number 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"viewFilter": [
{
"errors": [
{
"code": "INVALID_VIEW_FILTER_DATA",
"message": "Value "thirty" is not valid for operand "GREATER_THAN_OR_EQUAL" on field type "NUMBER". Expected a number",
"userFriendlyMessage": "Filter value is not valid for this operand",
},
],
"flatEntityMinimalInformation": {
"universalIdentifier": Any<String>,
},
"metadataName": "viewFilter",
"status": "fail",
"type": "create",
},
],
},
"message": "Validation failed for 1 viewFilter",
"summary": {
"totalErrors": 1,
"viewFilter": 1,
},
"userFriendlyMessage": "Filter value is not valid for this operand",
},
"message": "Validation errors occurred while syncing application manifest metadata",
"name": "GraphQLError",
}
`;
exports[`Sync application should fail on invalid view filter values when a SELECT filter is an object rather than an array of options 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"viewFilter": [
{
"errors": [
{
"code": "INVALID_VIEW_FILTER_DATA",
"message": "Value "{"stage":"WON"}" is not valid for operand "IS" on field type "SELECT". Expected an array of option values",
"userFriendlyMessage": "Filter value is not valid for this operand",
},
],
"flatEntityMinimalInformation": {
"universalIdentifier": Any<String>,
},
"metadataName": "viewFilter",
"status": "fail",
"type": "create",
},
],
},
"message": "Validation failed for 1 viewFilter",
"summary": {
"totalErrors": 1,
"viewFilter": 1,
},
"userFriendlyMessage": "Filter value is not valid for this operand",
},
"message": "Validation errors occurred while syncing application manifest metadata",
"name": "GraphQLError",
}
`;
exports[`Sync application should fail on invalid view filter values when an IS_RELATIVE filter has no amount 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"viewFilter": [
{
"errors": [
{
"code": "INVALID_VIEW_FILTER_DATA",
"message": "Value "NEXT__DAY" is not valid for operand "IS_RELATIVE" on field type "DATE". Expected a stringified relative date such as "NEXT_30_DAY".",
"userFriendlyMessage": "Filter value is not valid for this operand",
},
],
"flatEntityMinimalInformation": {
"universalIdentifier": Any<String>,
},
"metadataName": "viewFilter",
"status": "fail",
"type": "create",
},
],
},
"message": "Validation failed for 1 viewFilter",
"summary": {
"totalErrors": 1,
"viewFilter": 1,
},
"userFriendlyMessage": "Filter value is not valid for this operand",
},
"message": "Validation errors occurred while syncing application manifest metadata",
"name": "GraphQLError",
}
`;
exports[`Sync application should fail on invalid view filter values when an IS_RELATIVE filter uses the relative date object form instead of its stringified form 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"viewFilter": [
{
"errors": [
{
"code": "INVALID_VIEW_FILTER_DATA",
"message": "Value "{"direction":"NEXT","amount":30,"unit":"DAY"}" is not valid for operand "IS_RELATIVE" on field type "DATE". Expected a stringified relative date such as "NEXT_30_DAY".",
"userFriendlyMessage": "Filter value is not valid for this operand",
},
],
"flatEntityMinimalInformation": {
"universalIdentifier": Any<String>,
},
"metadataName": "viewFilter",
"status": "fail",
"type": "create",
},
],
},
"message": "Validation failed for 1 viewFilter",
"summary": {
"totalErrors": 1,
"viewFilter": 1,
},
"userFriendlyMessage": "Filter value is not valid for this operand",
},
"message": "Validation errors occurred while syncing application manifest metadata",
"name": "GraphQLError",
}
`;

View File

@@ -0,0 +1,25 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Sync application is gated by the workspace completed upgrade version rejects sync when the workspace has not completed the required upgrade version 1`] = `
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "WORKSPACE_VERSION_INCOMPATIBLE",
"userFriendlyMessage": "This app requires a newer version than this workspace has finished upgrading to. Please try again once the workspace upgrade completes.",
},
"message": "App requires Twenty server >=<version> but this workspace has only completed the upgrade to <version>.",
"name": "UserInputError",
}
`;
exports[`Sync application is gated by the workspace completed upgrade version rejects sync when the workspace upgrade cursor cannot be interpreted 1`] = `
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_WORKSPACE_VERSION",
"userFriendlyMessage": "This workspace's upgrade state could not be determined. Please try again once the workspace has finished upgrading.",
},
"message": "Cannot determine the completed upgrade version for workspace 20202020-1c25-4d02-bf25-6aeccf7ea419: no interpretable upgrade cursor found.",
"name": "UserInputError",
}
`;

View File

@@ -0,0 +1,37 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Upload application file should fail on mime/magic-byte mismatch when a PublicAsset .pdf path is uploaded with plain text body (magic-byte mismatch) 1`] = `
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_EXTENSION",
"userFriendlyMessage": "The file extension doesn't match the file content. Please check that your file is not corrupted and has the correct extension.",
},
"message": "File content does not match its extension. The file has extension 'pdf' (expected mime type: application/pdf), but the file content could not be detected as this type. The file may be corrupted, have the wrong extension, or be a security risk.",
"name": "UserInputError",
}
`;
exports[`Upload application file should fail on mime/magic-byte mismatch when a PublicAsset .png path is uploaded with plain text body (magic-byte mismatch) 1`] = `
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_EXTENSION",
"userFriendlyMessage": "The file extension doesn't match the file content. Please check that your file is not corrupted and has the correct extension.",
},
"message": "File content does not match its extension. The file has extension 'png' (expected mime type: image/png), but the file content could not be detected as this type. The file may be corrupted, have the wrong extension, or be a security risk.",
"name": "UserInputError",
}
`;
exports[`Upload application file should fail on mime/magic-byte mismatch when a PublicAsset .zip path is uploaded with plain text body (magic-byte mismatch) 1`] = `
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_EXTENSION",
"userFriendlyMessage": "The file extension doesn't match the file content. Please check that your file is not corrupted and has the correct extension.",
},
"message": "File content does not match its extension. The file has extension 'zip' (expected mime type: application/zip), but the file content could not be detected as this type. The file may be corrupted, have the wrong extension, or be a security risk.",
"name": "UserInputError",
}
`;

View File

@@ -0,0 +1,163 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Upload application file should fail when applicationUniversalIdentifier does not match any installed application 1`] = `
{
"extensions": {
"code": "NOT_FOUND",
"subCode": "APPLICATION_NOT_FOUND",
"userFriendlyMessage": "Application not found.",
},
"message": "Application not found in workspace.",
"name": "NotFoundError",
}
`;
exports[`Upload application file should fail when applicationUniversalIdentifier is empty 1`] = `
{
"extensions": {
"code": "BAD_USER_INPUT",
"userFriendlyMessage": "An error occurred.",
},
"message": "applicationUniversalIdentifier should not be empty",
"name": "UserInputError",
}
`;
exports[`Upload application file should fail when fileFolder is not an allowed application file folder 1`] = `
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_INPUT",
"userFriendlyMessage": "Invalid input provided.",
},
"message": "Invalid fileFolder for application file upload. Allowed values: built-logic-function, built-front-component, public-asset, source, dependencies",
"name": "UserInputError",
}
`;
exports[`Upload application file should fail when filePath contains backslash path traversal 1`] = `
{
"extensions": {
"code": "BAD_USER_INPUT",
"userFriendlyMessage": "An error occurred.",
},
"message": "filePath contains unsafe characters or path traversal",
"name": "UserInputError",
}
`;
exports[`Upload application file should fail when filePath contains relative path traversal (../) 1`] = `
{
"extensions": {
"code": "BAD_USER_INPUT",
"userFriendlyMessage": "An error occurred.",
},
"message": "filePath contains unsafe characters or path traversal",
"name": "UserInputError",
}
`;
exports[`Upload application file should fail when filePath contains upward traversal (../../) 1`] = `
{
"extensions": {
"code": "BAD_USER_INPUT",
"userFriendlyMessage": "An error occurred.",
},
"message": "filePath contains unsafe characters or path traversal",
"name": "UserInputError",
}
`;
exports[`Upload application file should fail when filePath has an invalid extension for BuiltFrontComponent (.js instead of .mjs) 1`] = `
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_INPUT",
"userFriendlyMessage": "Invalid input provided.",
},
"message": "Invalid file extension. Allowed extensions: .mjs",
"name": "UserInputError",
}
`;
exports[`Upload application file should fail when filePath has an invalid extension for BuiltLogicFunction (.html) 1`] = `
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_INPUT",
"userFriendlyMessage": "Invalid input provided.",
},
"message": "Invalid file extension. Allowed extensions: .mjs",
"name": "UserInputError",
}
`;
exports[`Upload application file should fail when filePath has an invalid extension for Dependencies (.sh instead of .json/.lock) 1`] = `
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_INPUT",
"userFriendlyMessage": "Invalid input provided.",
},
"message": "Invalid file extension. Allowed extensions: .json, .lock",
"name": "UserInputError",
}
`;
exports[`Upload application file should fail when filePath has an invalid extension for Source (.js instead of .ts) 1`] = `
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_INPUT",
"userFriendlyMessage": "Invalid input provided.",
},
"message": "Invalid file extension. Allowed extensions: .ts, .tsx, .json",
"name": "UserInputError",
}
`;
exports[`Upload application file should fail when filePath is a folder path without extension (bare UUID) 1`] = `
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_INPUT",
"userFriendlyMessage": "Invalid input provided.",
},
"message": "Filename must have an extension",
"name": "UserInputError",
}
`;
exports[`Upload application file should fail when filePath is a nested folder path without extension 1`] = `
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_INPUT",
"userFriendlyMessage": "Invalid input provided.",
},
"message": "Filename must have an extension",
"name": "UserInputError",
}
`;
exports[`Upload application file should fail when filePath is an absolute path 1`] = `
{
"extensions": {
"code": "BAD_USER_INPUT",
"userFriendlyMessage": "An error occurred.",
},
"message": "filePath contains unsafe characters or path traversal",
"name": "UserInputError",
}
`;
exports[`Upload application file should fail when filePath is empty 1`] = `
{
"extensions": {
"code": "BAD_USER_INPUT",
"userFriendlyMessage": "An error occurred.",
},
"message": "filePath contains unsafe characters or path traversal, filePath should not be empty",
"name": "UserInputError",
}
`;

View File

@@ -0,0 +1,13 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Publish application is gated by the instance server version rejects publishing when the app requires a server version the instance does not satisfy 1`] = `
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "SERVER_VERSION_INCOMPATIBLE",
"userFriendlyMessage": "This app requires a newer version of the Twenty server. Please upgrade your server or use a compatible app version.",
},
"message": "App requires Twenty server >=<version> but this server is <version>.",
"name": "UserInputError",
}
`;

View File

@@ -0,0 +1,13 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`npm application install package identity (integration) rejects a path-traversal version before any registry call 1`] = `
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_INPUT",
"userFriendlyMessage": "Invalid input provided.",
},
"message": "Invalid npm version: expected an exact semver version or a dist-tag",
"name": "UserInputError",
}
`;

View File

@@ -0,0 +1,388 @@
import crypto from 'crypto';
import { promises as fs } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import request from 'supertest';
import * as tar from 'tar';
import { type DataSource } from 'typeorm';
import { uploadAppTarball } from 'test/integration/metadata/suites/application/utils/upload-app-tarball.util';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
const TEST_WORKSPACE_ID = SEED_APPLE_WORKSPACE_ID;
const createTestTarball = async (
files: Record<string, string>,
): Promise<Buffer> => {
const tempId = crypto.randomUUID();
const sourceDir = join(tmpdir(), `test-tarball-src-${tempId}`);
const tarballPath = join(tmpdir(), `test-tarball-${tempId}.tar.gz`);
await fs.mkdir(sourceDir, { recursive: true });
for (const [name, content] of Object.entries(files)) {
const filePath = join(sourceDir, name);
const dir = filePath.substring(0, filePath.lastIndexOf('/'));
if (dir !== sourceDir) {
await fs.mkdir(dir, { recursive: true });
}
await fs.writeFile(filePath, content);
}
await tar.create(
{
file: tarballPath,
gzip: true,
cwd: sourceDir,
},
Object.keys(files),
);
const buffer = await fs.readFile(tarballPath);
await fs.rm(sourceDir, { recursive: true, force: true });
await fs.rm(tarballPath, { force: true });
return buffer;
};
const createValidManifest = (universalIdentifier: string) =>
JSON.stringify({
application: {
universalIdentifier,
displayName: 'Test Tarball App',
description: 'A test app',
icon: 'IconTestPipe',
defaultRoleUniversalIdentifier: crypto.randomUUID(),
applicationVariables: {},
packageJsonChecksum: null,
yarnLockChecksum: null,
},
roles: [],
skills: [],
objects: [],
fields: [],
logicFunctions: [],
frontComponents: [],
publicAssets: [],
views: [],
navigationMenuItems: [],
pageLayouts: [],
pageLayoutTabs: [],
pageLayoutWidgets: [],
});
const insertRegistrationWithSource = async (
ds: DataSource,
params: {
universalIdentifier: string;
name: string;
sourceType: 'npm' | 'tarball' | 'local';
sourcePackage?: string;
},
): Promise<string> => {
const id = crypto.randomUUID();
const oAuthClientId = crypto.randomUUID();
await ds.query(
`INSERT INTO core."applicationRegistration"
(id, "universalIdentifier", name, "oAuthClientId",
"oAuthRedirectUris", "oAuthScopes", "workspaceId",
"sourceType", "sourcePackage")
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[
id,
params.universalIdentifier,
params.name,
oAuthClientId,
[],
[],
TEST_WORKSPACE_ID,
params.sourceType,
params.sourcePackage ?? null,
],
);
return id;
};
describe('App Distribution (integration)', () => {
let ds: DataSource;
const createdRegistrationIds: string[] = [];
beforeAll(() => {
jest.useRealTimers();
ds = global.testDataSource;
});
afterAll(async () => {
for (const id of createdRegistrationIds) {
await ds.query(
`DELETE FROM core."applicationRegistration" WHERE id = $1`,
[id],
);
}
jest.useFakeTimers();
});
describe('Upload tarball via GraphQL', () => {
it('should reject tarball without manifest.json', async () => {
const tarball = await createTestTarball({
'readme.txt': 'no manifest here',
});
const { errors } = await uploadAppTarball({
tarballBuffer: tarball,
expectToFail: true,
});
expect(errors).toBeDefined();
expect(
errors?.some((error: { message: string }) =>
error.message.includes('manifest.json'),
),
).toBe(true);
});
it('should successfully upload a valid tarball and create registration', async () => {
const uid = crypto.randomUUID();
const manifest = createValidManifest(uid);
const tarball = await createTestTarball({
'manifest.json': manifest,
'package.json': JSON.stringify({
name: 'test-app',
version: '1.0.0',
}),
});
const { data, errors } = await uploadAppTarball({
tarballBuffer: tarball,
universalIdentifier: uid,
});
expect(errors).toBeUndefined();
expect(data?.uploadAppTarball.id).toBeDefined();
expect(data?.uploadAppTarball.universalIdentifier).toBe(uid);
createdRegistrationIds.push(data!.uploadAppTarball.id);
const rows = await ds.query(
`SELECT "sourceType" FROM core."applicationRegistration"
WHERE id = $1`,
[data!.uploadAppTarball.id],
);
expect(rows[0].sourceType).toBe('tarball');
});
it('should reject a tarball whose manifest identifier is not a valid UUID', async () => {
const tarball = await createTestTarball({
'manifest.json': createValidManifest('not-a-uuid'),
'package.json': JSON.stringify({ name: 'test-app', version: '1.0.0' }),
});
const { errors } = await uploadAppTarball({
tarballBuffer: tarball,
expectToFail: true,
});
expect(errors).toBeDefined();
});
it('should reject a tarball whose manifest has no application identifier', async () => {
const tarball = await createTestTarball({
'manifest.json': JSON.stringify({
application: {
displayName: 'App Without Identifier',
description: 'A test app',
applicationVariables: {},
packageJsonChecksum: null,
yarnLockChecksum: null,
},
}),
'package.json': JSON.stringify({ name: 'test-app', version: '1.0.0' }),
});
const { errors } = await uploadAppTarball({
tarballBuffer: tarball,
expectToFail: true,
});
expect(errors).toBeDefined();
});
it('should fail to update existing version', async () => {
const uid = crypto.randomUUID();
const manifest = createValidManifest(uid);
const tarball = await createTestTarball({
'manifest.json': manifest,
'package.json': JSON.stringify({
name: 'test-app',
version: '1.0.0',
}),
});
const firstResult = await uploadAppTarball({
tarballBuffer: tarball,
universalIdentifier: uid,
});
createdRegistrationIds.push(firstResult.data!.uploadAppTarball.id);
const { errors } = await uploadAppTarball({
tarballBuffer: tarball,
universalIdentifier: uid,
expectToFail: true,
});
expect(errors).toBeDefined();
expect(
errors?.some((error: { message: string }) =>
error.message.includes(
'version must be higher than the currently deployed version',
),
),
).toBe(true);
});
it('should update existing tarball registration on re-upload', async () => {
const uid = crypto.randomUUID();
const manifest = createValidManifest(uid);
const firstTarball = await createTestTarball({
'manifest.json': manifest,
'package.json': JSON.stringify({
name: 'test-app',
version: '1.1.0',
}),
});
const firstResult = await uploadAppTarball({
tarballBuffer: firstTarball,
universalIdentifier: uid,
});
createdRegistrationIds.push(firstResult.data!.uploadAppTarball.id);
const secondTarball = await createTestTarball({
'manifest.json': manifest,
'package.json': JSON.stringify({
name: 'test-app',
version: '1.2.0',
}),
});
const secondResult = await uploadAppTarball({
tarballBuffer: secondTarball,
universalIdentifier: uid,
});
expect(secondResult.data?.uploadAppTarball.id).toBe(
firstResult.data?.uploadAppTarball.id,
);
});
});
describe('Source channel enforcement', () => {
it('should reject tarball upload for npm-sourced registration', async () => {
const uid = crypto.randomUUID();
const regId = await insertRegistrationWithSource(ds, {
universalIdentifier: uid,
name: 'NPM App',
sourceType: 'npm',
sourcePackage: '@test/npm-app',
});
createdRegistrationIds.push(regId);
const manifest = createValidManifest(uid);
const tarball = await createTestTarball({
'manifest.json': manifest,
'package.json': JSON.stringify({
name: 'test-app',
version: '1.0.0',
}),
});
const { errors } = await uploadAppTarball({
tarballBuffer: tarball,
universalIdentifier: uid,
expectToFail: true,
});
expect(errors).toBeDefined();
expect(
errors?.some((error: { message: string }) =>
error.message.includes('registered as npm'),
),
).toBe(true);
});
it('should allow tarball upload for local-sourced registration', async () => {
const uid = crypto.randomUUID();
const regId = await insertRegistrationWithSource(ds, {
universalIdentifier: uid,
name: 'Local Source App',
sourceType: 'local',
});
createdRegistrationIds.push(regId);
const manifest = createValidManifest(uid);
const tarball = await createTestTarball({
'manifest.json': manifest,
'package.json': JSON.stringify({
name: 'test-app',
version: '1.0.0',
}),
});
const { data, errors } = await uploadAppTarball({
tarballBuffer: tarball,
universalIdentifier: uid,
});
expect(errors).toBeUndefined();
expect(data?.uploadAppTarball.id).toBe(regId);
const rows = await ds.query(
`SELECT "sourceType" FROM core."applicationRegistration"
WHERE id = $1`,
[regId],
);
expect(rows[0].sourceType).toBe('tarball');
});
});
describe('Authentication', () => {
it('should reject unauthenticated requests', async () => {
const baseUrl = `http://localhost:${APP_PORT}`;
const response = await request(baseUrl)
.post('/metadata')
.field(
'operations',
JSON.stringify({
query: `mutation UploadAppTarball($file: Upload!) {
uploadAppTarball(file: $file) { id }
}`,
variables: { file: null },
}),
)
.field('map', JSON.stringify({ '0': ['variables.file'] }))
.attach('0', Buffer.from('test'), {
filename: 'app.tar.gz',
contentType: 'application/gzip',
});
expect(response.body.errors).toBeDefined();
});
});
});

View File

@@ -0,0 +1,230 @@
import gql from 'graphql-tag';
import { findManyApplications } from 'test/integration/graphql/utils/find-many-applications.util';
import { generateApplicationToken } from 'test/integration/metadata/suites/application/utils/generate-application-token.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { v4 as uuidv4 } from 'uuid';
import { TWENTY_STANDARD_APPLICATION } from 'src/engine/workspace-manager/twenty-standard-application/constants/twenty-standard-applications';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
const GET_APP_KEY_VALUE = gql`
query AppKeyValue($key: String!, $scope: AppKeyValueScope) {
appKeyValue(key: $key, scope: $scope) {
key
value
scope
}
}
`;
const SET_APP_KEY_VALUE = gql`
mutation SetAppKeyValue($input: SetAppKeyValueInput!) {
setAppKeyValue(input: $input) {
key
value
scope
}
}
`;
const DELETE_APP_KEY_VALUE = gql`
mutation DeleteAppKeyValue($key: String!, $scope: AppKeyValueScope) {
deleteAppKeyValue(key: $key, scope: $scope)
}
`;
const KEY_PREFIX = `test-kv-${uuidv4()}`;
describe('application key-value store (e2e)', () => {
let appToken: string;
beforeAll(async () => {
const { data } = await findManyApplications({ expectToFail: false });
const standardApplication = data.findManyApplications.find(
(application) =>
application.universalIdentifier ===
TWENTY_STANDARD_APPLICATION.universalIdentifier,
);
expect(standardApplication).toBeDefined();
const { data: tokenData } = await generateApplicationToken({
applicationId: standardApplication!.id,
expectToFail: false,
});
appToken = tokenData.generateApplicationToken.applicationAccessToken.token;
});
afterAll(async () => {
await globalThis.testDataSource.query(
`DELETE FROM core."keyValuePair" WHERE "key" LIKE $1`,
[`${KEY_PREFIX}%`],
);
});
it('rejects requests that do not carry an APPLICATION_ACCESS token', async () => {
const response = await makeMetadataAPIRequest({
query: GET_APP_KEY_VALUE,
variables: { key: `${KEY_PREFIX}:no-app-token` },
});
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toContain('APPLICATION_ACCESS');
});
it('sets, reads, overwrites and deletes a WORKSPACE entry', async () => {
const key = `${KEY_PREFIX}:workspace`;
const setResponse = await makeMetadataAPIRequest(
{
query: SET_APP_KEY_VALUE,
variables: { input: { key, value: { count: 1 } } },
},
appToken,
);
expect(setResponse.body.errors).toBeUndefined();
expect(setResponse.body.data.setAppKeyValue).toEqual({
key,
value: { count: 1 },
scope: 'WORKSPACE',
});
const overwriteResponse = await makeMetadataAPIRequest(
{
query: SET_APP_KEY_VALUE,
variables: { input: { key, value: 'overwritten' } },
},
appToken,
);
expect(overwriteResponse.body.errors).toBeUndefined();
const getResponse = await makeMetadataAPIRequest(
{ query: GET_APP_KEY_VALUE, variables: { key } },
appToken,
);
expect(getResponse.body.errors).toBeUndefined();
expect(getResponse.body.data.appKeyValue.value).toBe('overwritten');
const deleteResponse = await makeMetadataAPIRequest(
{ query: DELETE_APP_KEY_VALUE, variables: { key } },
appToken,
);
expect(deleteResponse.body.errors).toBeUndefined();
expect(deleteResponse.body.data.deleteAppKeyValue).toBe(true);
const getAfterDeleteResponse = await makeMetadataAPIRequest(
{ query: GET_APP_KEY_VALUE, variables: { key } },
appToken,
);
expect(getAfterDeleteResponse.body.data.appKeyValue).toBeNull();
const deleteAgainResponse = await makeMetadataAPIRequest(
{ query: DELETE_APP_KEY_VALUE, variables: { key } },
appToken,
);
expect(deleteAgainResponse.body.data.deleteAppKeyValue).toBe(false);
});
it('returns null for a missing key', async () => {
const response = await makeMetadataAPIRequest(
{
query: GET_APP_KEY_VALUE,
variables: { key: `${KEY_PREFIX}:missing` },
},
appToken,
);
expect(response.body.errors).toBeUndefined();
expect(response.body.data.appKeyValue).toBeNull();
});
it('claims a SERVER key for the caller workspace and keeps it separate from WORKSPACE entries', async () => {
const key = `${KEY_PREFIX}:server`;
const claimResponse = await makeMetadataAPIRequest(
{
query: SET_APP_KEY_VALUE,
variables: { input: { key, scope: 'SERVER' } },
},
appToken,
);
expect(claimResponse.body.errors).toBeUndefined();
expect(claimResponse.body.data.setAppKeyValue).toEqual({
key,
value: SEED_APPLE_WORKSPACE_ID,
scope: 'SERVER',
});
// Claiming again from the same workspace is idempotent
const reclaimResponse = await makeMetadataAPIRequest(
{
query: SET_APP_KEY_VALUE,
variables: { input: { key, scope: 'SERVER' } },
},
appToken,
);
expect(reclaimResponse.body.errors).toBeUndefined();
expect(reclaimResponse.body.data.setAppKeyValue.value).toBe(
SEED_APPLE_WORKSPACE_ID,
);
const getServerScopeResponse = await makeMetadataAPIRequest(
{ query: GET_APP_KEY_VALUE, variables: { key, scope: 'SERVER' } },
appToken,
);
expect(getServerScopeResponse.body.data.appKeyValue.value).toBe(
SEED_APPLE_WORKSPACE_ID,
);
const getWorkspaceScopeResponse = await makeMetadataAPIRequest(
{ query: GET_APP_KEY_VALUE, variables: { key } },
appToken,
);
expect(getWorkspaceScopeResponse.body.data.appKeyValue).toBeNull();
const releaseResponse = await makeMetadataAPIRequest(
{ query: DELETE_APP_KEY_VALUE, variables: { key, scope: 'SERVER' } },
appToken,
);
expect(releaseResponse.body.data.deleteAppKeyValue).toBe(true);
const getAfterReleaseResponse = await makeMetadataAPIRequest(
{ query: GET_APP_KEY_VALUE, variables: { key, scope: 'SERVER' } },
appToken,
);
expect(getAfterReleaseResponse.body.data.appKeyValue).toBeNull();
});
it('ignores a provided value for SERVER claims and stores the caller workspaceId', async () => {
const response = await makeMetadataAPIRequest(
{
query: SET_APP_KEY_VALUE,
variables: {
input: {
key: `${KEY_PREFIX}:server-foreign-value`,
value: 'some-other-workspace-id',
scope: 'SERVER',
},
},
},
appToken,
);
expect(response.body.errors).toBeUndefined();
expect(response.body.data.setAppKeyValue.value).toBe(SEED_APPLE_WORKSPACE_ID);
});
});

View File

@@ -0,0 +1,196 @@
import gql from 'graphql-tag';
import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util';
import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util';
import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util';
import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util';
import { uninstallApplication } from 'test/integration/metadata/suites/application/utils/uninstall-application.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { getAppProviderByClassName } from 'test/integration/utils/get-app-provider-by-class-name.util';
import { v4 as uuidv4 } from 'uuid';
import { type WorkspaceBroadcastEvent } from 'src/engine/subscriptions/workspace-event-broadcaster/types/workspace-broadcast-event.type';
import { type WorkspaceEventBroadcaster } from 'src/engine/subscriptions/workspace-event-broadcaster/workspace-event-broadcaster.service';
const APPLICATION_PAYLOAD_KEYS = [
'applicationRegistrationId',
'description',
'id',
'name',
'sdkClientCoreChecksum',
'universalIdentifier',
'version',
];
const APPLICATION_REGISTRATION_PAYLOAD_KEYS = [
'id',
'isListed',
'isVetted',
'latestAvailableVersion',
'name',
'ownerWorkspaceId',
'sourceType',
'universalIdentifier',
];
describe('Application broadcast events', () => {
let appId: string;
let roleId: string;
let broadcastSpy: jest.SpyInstance;
const eventsFor = (
entityName: string,
type: WorkspaceBroadcastEvent['type'],
): WorkspaceBroadcastEvent[] =>
broadcastSpy.mock.calls
.flatMap(([{ events }]) => events)
.filter(
(event) => event.entityName === entityName && event.type === type,
);
beforeEach(async () => {
appId = uuidv4();
roleId = uuidv4();
const workspaceEventBroadcaster =
getAppProviderByClassName<WorkspaceEventBroadcaster>(
'WorkspaceEventBroadcaster',
);
broadcastSpy = jest
.spyOn(workspaceEventBroadcaster, 'broadcast')
.mockResolvedValue(undefined);
await setupApplicationForSync({
applicationUniversalIdentifier: appId,
name: 'Broadcast test application',
description: 'App for testing lifecycle broadcasts',
sourcePath: 'test-broadcast-events',
});
}, 60000);
afterEach(async () => {
broadcastSpy.mockRestore();
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: appId,
});
});
it('broadcasts the registration and application rows through their lifecycle', async () => {
const registrationCreatedEvent = eventsFor(
'applicationRegistration',
'created',
).find(
(event) =>
(event.properties.after as { universalIdentifier?: string })
?.universalIdentifier === appId,
);
expect(registrationCreatedEvent).toBeDefined();
expect(
Object.keys(registrationCreatedEvent?.properties.after ?? {}).sort(),
).toEqual(APPLICATION_REGISTRATION_PAYLOAD_KEYS);
expect(registrationCreatedEvent?.properties.after).toMatchObject({
name: 'Broadcast test application',
});
const applicationCreatedEvent = eventsFor('application', 'created').find(
(event) =>
(event.properties.after as { universalIdentifier?: string })
?.universalIdentifier === appId,
);
expect(applicationCreatedEvent).toBeDefined();
expect(
Object.keys(applicationCreatedEvent?.properties.after ?? {}).sort(),
).toEqual(APPLICATION_PAYLOAD_KEYS);
expect(applicationCreatedEvent?.properties.after).toMatchObject({
id: applicationCreatedEvent?.recordId,
name: 'Broadcast test application',
});
const applicationRegistrationId = registrationCreatedEvent?.recordId;
broadcastSpy.mockClear();
const updateRegistrationResponse = await makeMetadataAPIRequest({
query: gql`
mutation UpdateApplicationRegistration(
$input: UpdateApplicationRegistrationInput!
) {
updateApplicationRegistration(input: $input) {
id
}
}
`,
variables: {
input: {
id: applicationRegistrationId,
update: { name: 'Broadcast test application renamed' },
},
},
});
expect(updateRegistrationResponse.body.errors).toBeUndefined();
const registrationUpdatedEvents = eventsFor(
'applicationRegistration',
'updated',
).filter((event) => event.recordId === applicationRegistrationId);
expect(registrationUpdatedEvents).toHaveLength(1);
expect(
Object.keys(registrationUpdatedEvents[0].properties.after ?? {}).sort(),
).toEqual(APPLICATION_REGISTRATION_PAYLOAD_KEYS);
expect(registrationUpdatedEvents[0].properties.after).toMatchObject({
name: 'Broadcast test application renamed',
});
broadcastSpy.mockClear();
await syncApplication({
manifest: buildBaseManifest({ appId, roleId }),
expectToFail: false,
});
const applicationUpdatedEvents = eventsFor('application', 'updated');
expect(
applicationUpdatedEvents.some(
(event) =>
(event.properties.after as { universalIdentifier?: string })
?.universalIdentifier === appId,
),
).toBe(true);
for (const applicationUpdatedEvent of applicationUpdatedEvents) {
expect(
Object.keys(applicationUpdatedEvent.properties.after ?? {}).sort(),
).toEqual(APPLICATION_PAYLOAD_KEYS);
expect(applicationUpdatedEvent.properties.updatedFields).toEqual(
expect.arrayContaining([expect.any(String)]),
);
expect(applicationUpdatedEvent.properties.updatedFields).not.toContain(
'workspaceId',
);
}
broadcastSpy.mockClear();
await uninstallApplication({
universalIdentifier: appId,
expectToFail: false,
});
const applicationDeletedEvent = eventsFor('application', 'deleted').find(
(event) =>
(event.properties.before as { universalIdentifier?: string })
?.universalIdentifier === appId,
);
expect(applicationDeletedEvent).toBeDefined();
expect(
Object.keys(applicationDeletedEvent?.properties.before ?? {}).sort(),
).toEqual(APPLICATION_PAYLOAD_KEYS);
}, 60000);
});

View File

@@ -0,0 +1,168 @@
import { Queue } from 'bullmq';
import gql from 'graphql-tag';
import IORedis from 'ioredis';
import { findManyApplications } from 'test/integration/graphql/utils/find-many-applications.util';
import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util';
import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util';
import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util';
import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { waitForAllJobsToFinish } from 'test/integration/utils/wait-for-all-jobs-to-finish.util';
import { v4 as uuidv4 } from 'uuid';
import { JobStateEnum } from 'src/engine/core-modules/message-queue/enums/job-state.enum';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { getQueueJobIdPrefix } from 'src/engine/core-modules/message-queue/utils/get-queue-job-id-prefix.util';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
const TRIGGER_INSTALL_APPLICATION_JOB = gql`
mutation TriggerInstallApplicationJob(
$input: TriggerInstallApplicationJobInput!
) {
triggerInstallApplicationJob(input: $input) {
jobId
}
}
`;
const TRIGGER_UNINSTALL_APPLICATION_JOB = gql`
mutation TriggerUninstallApplicationJob(
$input: TriggerUninstallApplicationJobInput!
) {
triggerUninstallApplicationJob(input: $input) {
jobId
}
}
`;
const FIND_UNINSTALL_APPLICATION_JOB_STATUS = gql`
query FindUninstallApplicationJobStatus($universalIdentifier: String!) {
findUninstallApplicationJobStatus(
universalIdentifier: $universalIdentifier
) {
jobId
state
failedReason
}
}
`;
describe('Application lifecycle jobs', () => {
let appId: string;
let roleId: string;
let redisConnection: IORedis;
let workspaceQueue: Queue;
const triggerUninstallApplicationJob = async () => {
const response = await makeMetadataAPIRequest({
query: TRIGGER_UNINSTALL_APPLICATION_JOB,
variables: { input: { universalIdentifier: appId } },
});
expect(response.body.errors).toBeUndefined();
return response.body.data.triggerUninstallApplicationJob.jobId as string;
};
const findUninstallApplicationJobStatus = async () => {
const response = await makeMetadataAPIRequest({
query: FIND_UNINSTALL_APPLICATION_JOB_STATUS,
variables: { universalIdentifier: appId },
});
expect(response.body.errors).toBeUndefined();
return response.body.data.findUninstallApplicationJobStatus;
};
beforeAll(() => {
redisConnection = new IORedis(
process.env.REDIS_QUEUE_URL ??
process.env.REDIS_URL ??
'redis://localhost:6379',
{ maxRetriesPerRequest: null },
);
workspaceQueue = new Queue(MessageQueue.workspaceQueue, {
connection: redisConnection,
});
});
afterAll(async () => {
await workspaceQueue.close();
await redisConnection.quit();
});
beforeEach(async () => {
appId = uuidv4();
roleId = uuidv4();
await setupApplicationForSync({
applicationUniversalIdentifier: appId,
name: 'Lifecycle job test application',
description: 'App for testing install and uninstall jobs',
sourcePath: 'test-lifecycle-jobs',
});
await syncApplication({
manifest: buildBaseManifest({ appId, roleId }),
expectToFail: false,
});
jest.useRealTimers();
}, 60000);
afterEach(async () => {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: appId,
});
});
it('uninstalls an installed application through a queue job and reads the job back', async () => {
const jobId = await triggerUninstallApplicationJob();
expect(getQueueJobIdPrefix(jobId)).toBe(
`uninstall-application.${SEED_APPLE_WORKSPACE_ID}.${appId}`,
);
await waitForAllJobsToFinish();
expect(await findUninstallApplicationJobStatus()).toBeNull();
const { data } = await findManyApplications({ expectToFail: false });
expect(
data.findManyApplications.some(
(application) => application.universalIdentifier === appId,
),
).toBe(false);
}, 60000);
it('reports the waiting uninstall job and refuses a conflicting install', async () => {
await workspaceQueue.pause();
try {
const jobId = await triggerUninstallApplicationJob();
expect(await findUninstallApplicationJobStatus()).toMatchObject({
jobId,
state: JobStateEnum.PRIORITIZED,
});
expect(await triggerUninstallApplicationJob()).toBe(jobId);
const installResponse = await makeMetadataAPIRequest({
query: TRIGGER_INSTALL_APPLICATION_JOB,
variables: { input: { universalIdentifier: appId } },
});
expect(installResponse.body.errors).toHaveLength(1);
expect(installResponse.body.errors[0].message).toBe(
`Cannot install application ${appId} while its uninstall is in progress`,
);
} finally {
await workspaceQueue.resume();
}
await waitForAllJobsToFinish();
}, 60000);
});

View File

@@ -0,0 +1,241 @@
import crypto from 'crypto';
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
import { updateAdminApplicationRegistration } from 'test/integration/metadata/suites/application/utils/update-admin-application-registration.util';
import { updateApplicationRegistration } from 'test/integration/metadata/suites/application/utils/update-application-registration.util';
import {
type EachTestingContext,
eachTestingContextFilter,
} from 'twenty-shared/testing';
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
import {
SEED_APPLE_WORKSPACE_ID,
SEED_YCOMBINATOR_WORKSPACE_ID,
} from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
const INITIAL_NAME = 'Instance flags integration test app';
const RENAMED = 'Instance flags integration test app renamed';
const INITIAL_REDIRECT_URIS = ['https://example.com/oauth/callback'];
const REPLACEMENT_REDIRECT_URIS = ['https://attacker.example/oauth/callback'];
type InstanceFlag = 'isListed' | 'isPreInstalled' | 'isVetted';
const INSTANCE_FLAG_TEST_CASES: EachTestingContext<{ flag: InstanceFlag }>[] = [
{
title: 'when the payload carries isListed',
context: { flag: 'isListed' },
},
{
title: 'when the payload carries isPreInstalled',
context: { flag: 'isPreInstalled' },
},
{
title: 'when the payload carries isVetted',
context: { flag: 'isVetted' },
},
];
// The caller's workspace owns 'owned'; the two others must be out of its reach.
type RegistrationOwnership = 'owned' | 'foreign' | 'unclaimed';
const NOT_OWNED_TEST_CASES: EachTestingContext<{
ownership: Exclude<RegistrationOwnership, 'owned'>;
}>[] = [
{
title: 'when another workspace owns the registration',
context: { ownership: 'foreign' },
},
{
title: 'when no workspace has claimed the registration',
context: { ownership: 'unclaimed' },
},
];
const OWNER_WORKSPACE_ID_BY_OWNERSHIP: Record<
RegistrationOwnership,
string | null
> = {
owned: SEED_APPLE_WORKSPACE_ID,
foreign: SEED_YCOMBINATOR_WORKSPACE_ID,
unclaimed: null,
};
type RegistrationRow = {
name: string;
oAuthRedirectUris: string[];
isListed: boolean;
isPreInstalled: boolean;
isVetted: boolean;
};
const INITIAL_ROW: RegistrationRow = {
name: INITIAL_NAME,
oAuthRedirectUris: INITIAL_REDIRECT_URIS,
isListed: false,
isPreInstalled: false,
isVetted: false,
};
describe('Application registration instance flags (integration)', () => {
const registrationIdByOwnership = {} as Record<RegistrationOwnership, string>;
const readRegistrationRow = async (
registrationId: string,
): Promise<RegistrationRow> => {
const [row] = await globalThis.testDataSource.query(
`SELECT name, "oAuthRedirectUris", "isListed", "isPreInstalled", "isVetted"
FROM core."applicationRegistration" WHERE id = $1`,
[registrationId],
);
return row;
};
beforeAll(async () => {
for (const [ownership, ownerWorkspaceId] of Object.entries(
OWNER_WORKSPACE_ID_BY_OWNERSHIP,
)) {
const registrationId = crypto.randomUUID();
await globalThis.testDataSource.query(
`INSERT INTO core."applicationRegistration"
(id, "universalIdentifier", name, "oAuthClientId",
"oAuthRedirectUris", "oAuthScopes", "sourceType", "workspaceId")
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[
registrationId,
crypto.randomUUID(),
INITIAL_NAME,
crypto.randomUUID(),
INITIAL_REDIRECT_URIS,
[],
ApplicationRegistrationSourceType.TARBALL,
ownerWorkspaceId,
],
);
registrationIdByOwnership[ownership as RegistrationOwnership] =
registrationId;
}
});
afterAll(async () => {
await globalThis.testDataSource.query(
`DELETE FROM core."applicationRegistration" WHERE id = ANY($1)`,
[Object.values(registrationIdByOwnership)],
);
});
describe('updateApplicationRegistration', () => {
it.each(eachTestingContextFilter(NOT_OWNED_TEST_CASES))(
'should fail $title',
async ({ context: { ownership } }) => {
const registrationId = registrationIdByOwnership[ownership];
const { errors } = await updateApplicationRegistration({
id: registrationId,
update: {
name: RENAMED,
oAuthRedirectUris: REPLACEMENT_REDIRECT_URIS,
},
expectToFail: true,
});
expectOneNotInternalServerErrorSnapshot({
errors,
normalizeMessage: (message) =>
message.replace(registrationId, '<applicationRegistrationId>'),
});
expect(await readRegistrationRow(registrationId)).toEqual(INITIAL_ROW);
},
);
it.each(eachTestingContextFilter(INSTANCE_FLAG_TEST_CASES))(
'should fail $title',
async ({ context: { flag } }) => {
const registrationId = registrationIdByOwnership.owned;
const { errors } = await updateApplicationRegistration({
id: registrationId,
update: { name: RENAMED, [flag]: true },
expectToFail: true,
});
expectOneNotInternalServerErrorSnapshot({ errors });
// The name travelled in the same payload, so a row still carrying its
// initial name proves nothing was written.
expect(await readRegistrationRow(registrationId)).toEqual(INITIAL_ROW);
},
);
it('should update the fields the tenant input declares', async () => {
const registrationId = registrationIdByOwnership.owned;
const { data } = await updateApplicationRegistration({
id: registrationId,
update: { name: RENAMED },
expectToFail: false,
});
expect(data.updateApplicationRegistration).toMatchObject({
id: registrationId,
name: RENAMED,
isListed: false,
isPreInstalled: false,
isVetted: false,
});
expect(await readRegistrationRow(registrationId)).toEqual({
...INITIAL_ROW,
name: RENAMED,
});
});
});
describe('updateAdminApplicationRegistration', () => {
it('should fail for a caller without the SECURITY permission flag', async () => {
const registrationId = registrationIdByOwnership.owned;
const { errors } = await updateAdminApplicationRegistration({
id: registrationId,
update: { isListed: true, isPreInstalled: true, isVetted: true },
withGuestRole: true,
expectToFail: true,
});
expectOneNotInternalServerErrorSnapshot({ errors });
expect(await readRegistrationRow(registrationId)).toMatchObject({
isListed: false,
isPreInstalled: false,
isVetted: false,
});
});
it('should set the instance flags', async () => {
const registrationId = registrationIdByOwnership.owned;
const { data } = await updateAdminApplicationRegistration({
id: registrationId,
update: { isListed: true, isPreInstalled: true, isVetted: true },
expectToFail: false,
});
expect(data.updateAdminApplicationRegistration).toMatchObject({
id: registrationId,
isListed: true,
isPreInstalled: true,
isVetted: true,
});
expect(await readRegistrationRow(registrationId)).toMatchObject({
isListed: true,
isPreInstalled: true,
isVetted: true,
});
});
});
});

View File

@@ -0,0 +1,97 @@
import request from 'supertest';
import { findManyApplications } from 'test/integration/graphql/utils/find-many-applications.util';
import { generateApplicationToken } from 'test/integration/metadata/suites/application/utils/generate-application-token.util';
import { TWENTY_STANDARD_APPLICATION } from 'src/engine/workspace-manager/twenty-standard-application/constants/twenty-standard-applications';
import { getConnectionTypename, getEdgeTypename } from 'twenty-shared/utils';
const INTROSPECTION_QUERY = `
query IntrospectionQuery {
__schema {
types {
kind
name
}
}
}
`;
// Custom objects seeded in the dev workspace that should NOT appear
// in a schema scoped to the Standard Application
const CUSTOM_OBJECT_TYPE_NAMES = [
'Rocket',
'Pet',
'PetCareAgreement',
'SurveyResult',
'EmploymentHistory',
];
const makeGraphqlIntrospectionRequest = async (
token: string,
): Promise<request.Response> => {
const client = request(`http://localhost:${APP_PORT}`);
const response = await client
.post('/graphql')
.set('Authorization', `Bearer ${token}`)
.send({ query: INTROSPECTION_QUERY });
return response;
};
describe('Application token schema filtering', () => {
let standardAppToken: string;
beforeAll(async () => {
const { data: applicationsData } = await findManyApplications({
expectToFail: false,
});
const standardApp = applicationsData.findManyApplications.find(
(application) =>
application.universalIdentifier ===
TWENTY_STANDARD_APPLICATION.universalIdentifier,
);
expect(standardApp).toBeDefined();
const { data: tokenData } = await generateApplicationToken({
applicationId: standardApp!.id,
expectToFail: false,
});
standardAppToken =
tokenData.generateApplicationToken.applicationAccessToken.token;
});
it('should not include custom objects in the schema when using a standard app token', async () => {
const response = await makeGraphqlIntrospectionRequest(standardAppToken);
expect(response.body.errors).toBeUndefined();
expect(response.body.data.__schema).toBeDefined();
const typeNames: string[] = response.body.data.__schema.types.map(
(type: { name: string }) => type.name,
);
for (const customTypeName of CUSTOM_OBJECT_TYPE_NAMES) {
expect(typeNames).not.toContain(customTypeName);
expect(typeNames).not.toContain(getEdgeTypename(customTypeName));
expect(typeNames).not.toContain(getConnectionTypename(customTypeName));
}
});
it('should include standard objects in the schema when using a standard app token', async () => {
const response = await makeGraphqlIntrospectionRequest(standardAppToken);
expect(response.body.errors).toBeUndefined();
const typeNames: string[] = response.body.data.__schema.types.map(
(type: { name: string }) => type.name,
);
expect(typeNames).toContain('Person');
expect(typeNames).toContain('Company');
expect(typeNames).toContain('Opportunity');
});
});

View File

@@ -0,0 +1,315 @@
import { randomUUID } from 'crypto';
import { activateWorkspace } from 'test/integration/graphql/utils/activate-workspace.util';
import { deleteUser } from 'test/integration/graphql/utils/delete-user.util';
import { getAuthTokensFromLoginToken } from 'test/integration/graphql/utils/get-auth-tokens-from-login-token.util';
import { signUpInNewWorkspace } from 'test/integration/graphql/utils/sign-up-in-new-workspace.util';
import { signUp } from 'test/integration/graphql/utils/sign-up.util';
import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util';
import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util';
import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util';
import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util';
import { getAppProviderByClassName } from 'test/integration/utils/get-app-provider-by-class-name.util';
import { type Manifest } from 'twenty-shared/application';
import { isDefined } from 'twenty-shared/utils';
import { v4 as uuidv4 } from 'uuid';
import { type ApplicationUninstallService } from 'src/engine/core-modules/application/application-manifest/services/application-uninstall.service';
import { type LogicFunctionExecutorService } from 'src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service';
import { LogicFunctionExecutionStatus } from 'src/engine/metadata-modules/logic-function/dtos/logic-function-execution-result.dto';
type InstalledApplication = {
universalIdentifier: string;
uninstallLogicFunctionId: string;
uninstallHookCompletedForRequestedAt: Date | null;
workspaceId: string;
};
const SUCCESSFUL_EXECUTION_RESULT = {
data: {},
duration: 1,
billedDurationMs: 1,
logs: '',
status: LogicFunctionExecutionStatus.SUCCESS,
};
const FAILED_EXECUTION_RESULT = {
data: null,
duration: 1,
billedDurationMs: 1,
logs: '',
status: LogicFunctionExecutionStatus.ERROR,
error: {
errorType: 'Error',
errorMessage: 'cleanup failed',
stackTrace: '',
},
};
const buildManifestWithUninstallHook = ({
applicationUniversalIdentifier,
roleUniversalIdentifier,
logicFunctionUniversalIdentifier,
}: {
applicationUniversalIdentifier: string;
roleUniversalIdentifier: string;
logicFunctionUniversalIdentifier: string;
}): Manifest => {
const baseManifest = buildBaseManifest({
appId: applicationUniversalIdentifier,
roleId: roleUniversalIdentifier,
});
return {
...baseManifest,
application: {
...baseManifest.application,
uninstallLogicFunction: {
universalIdentifier: logicFunctionUniversalIdentifier,
},
},
logicFunctions: [
{
universalIdentifier: logicFunctionUniversalIdentifier,
name: 'Cleanup',
description: 'Uninstall cleanup logic function',
handlerName: 'handler',
sourceHandlerPath: 'src/cleanup.ts',
builtHandlerPath: 'dist/cleanup.mjs',
builtHandlerChecksum: 'checksum-cleanup',
httpRouteTriggerSettings: {
path: '/cleanup',
httpMethod: 'POST',
isAuthRequired: true,
},
},
],
roles: baseManifest.roles.map((role) => ({
...role,
label: `Retry Test Role ${applicationUniversalIdentifier}`,
})),
};
};
describe('Application uninstall retry', () => {
const applicationUniversalIdentifiers = [uuidv4(), uuidv4()];
let executeSpy: jest.SpyInstance;
let userAccessToken: string;
let workspaceAccessToken: string;
let workspaceId: string;
const findInstalledApplications = (): Promise<InstalledApplication[]> =>
globalThis.testDataSource.query(
`SELECT
"universalIdentifier",
"uninstallLogicFunctionId",
"uninstallHookCompletedForRequestedAt",
"workspaceId"
FROM core."application"
WHERE "universalIdentifier" = ANY($1::uuid[])`,
[applicationUniversalIdentifiers],
);
beforeAll(async () => {
const logicFunctionExecutorService =
getAppProviderByClassName<LogicFunctionExecutorService>(
'LogicFunctionExecutorService',
);
executeSpy = jest
.spyOn(logicFunctionExecutorService, 'execute')
.mockResolvedValue(SUCCESSFUL_EXECUTION_RESULT);
const uniqueEmail = `test-${randomUUID()}@example.com`;
const { data: signUpData } = await signUp({
input: { email: uniqueEmail, password: 'Test123!@#' },
expectToFail: false,
});
userAccessToken =
signUpData.signUp.tokens.accessOrWorkspaceAgnosticToken.token;
await globalThis.testDataSource.query(
'UPDATE core."user" SET "isEmailVerified" = true WHERE email = $1',
[uniqueEmail],
);
const {
data: { signUpInNewWorkspace: newWorkspaceData },
} = await signUpInNewWorkspace({
accessToken: userAccessToken,
expectToFail: false,
});
workspaceId = newWorkspaceData.workspace.id;
const {
data: { getAuthTokensFromLoginToken: authTokensData },
} = await getAuthTokensFromLoginToken({
origin: newWorkspaceData.workspace.workspaceUrls.subdomainUrl,
loginToken: newWorkspaceData.loginToken.token,
expectToFail: false,
});
workspaceAccessToken =
authTokensData.tokens.accessOrWorkspaceAgnosticToken.token;
await activateWorkspace({
accessToken: workspaceAccessToken,
expectToFail: false,
});
for (const [
index,
applicationUniversalIdentifier,
] of applicationUniversalIdentifiers.entries()) {
await setupApplicationForSync({
applicationUniversalIdentifier,
name: `Workspace Deletion Retry Test App ${index + 1}`,
description: 'App for testing workspace deletion uninstall retries',
sourcePath: `test-workspace-deletion-retry-${applicationUniversalIdentifier}`,
token: workspaceAccessToken,
});
await syncApplication({
manifest: buildManifestWithUninstallHook({
applicationUniversalIdentifier,
roleUniversalIdentifier: uuidv4(),
logicFunctionUniversalIdentifier: uuidv4(),
}),
expectToFail: false,
token: workspaceAccessToken,
});
}
}, 120000);
afterAll(async () => {
executeSpy.mockResolvedValue(SUCCESSFUL_EXECUTION_RESULT);
for (const applicationUniversalIdentifier of applicationUniversalIdentifiers) {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier,
});
}
executeSpy.mockRestore();
await deleteUser({ accessToken: userAccessToken });
});
it('records partial progress and retries only the failed hook', async () => {
const workspaceDeletedAt = new Date('2026-08-18T10:00:00.000Z');
const applicationUninstallService =
getAppProviderByClassName<ApplicationUninstallService>(
'ApplicationUninstallService',
);
const installedApplications = await findInstalledApplications();
expect(installedApplications).toHaveLength(2);
expect(
new Set(
installedApplications.map((application) => application.workspaceId),
).size,
).toBe(1);
expect(installedApplications[0].workspaceId).toBe(workspaceId);
executeSpy.mockClear();
executeSpy
.mockResolvedValueOnce(FAILED_EXECUTION_RESULT)
.mockResolvedValueOnce(SUCCESSFUL_EXECUTION_RESULT);
await expect(
applicationUninstallService.runUninstallHooksForWorkspaceDeletion({
workspaceId,
workspaceDeletedAt,
}),
).rejects.toThrow('cleanup failed');
expect(executeSpy).toHaveBeenCalledTimes(2);
const failedLogicFunctionId = executeSpy.mock.calls[0][0].logicFunctionId;
const successfulLogicFunctionId =
executeSpy.mock.calls[1][0].logicFunctionId;
const failedApplication = installedApplications.find(
(application) =>
application.uninstallLogicFunctionId === failedLogicFunctionId,
);
const successfulApplication = installedApplications.find(
(application) =>
application.uninstallLogicFunctionId === successfulLogicFunctionId,
);
expect(failedApplication).toBeDefined();
expect(successfulApplication).toBeDefined();
if (!isDefined(failedApplication) || !isDefined(successfulApplication)) {
throw new Error('Expected both uninstall applications to be installed');
}
const applicationsAfterFirstAttempt = await findInstalledApplications();
const failedApplicationAfterFirstAttempt =
applicationsAfterFirstAttempt.find(
(application) =>
application.universalIdentifier ===
failedApplication.universalIdentifier,
);
const successfulApplicationAfterFirstAttempt =
applicationsAfterFirstAttempt.find(
(application) =>
application.universalIdentifier ===
successfulApplication.universalIdentifier,
);
expect(
failedApplicationAfterFirstAttempt?.uninstallHookCompletedForRequestedAt,
).toBeNull();
expect(
successfulApplicationAfterFirstAttempt?.uninstallHookCompletedForRequestedAt?.toISOString(),
).toBe(workspaceDeletedAt.toISOString());
const workspaceIdsWithPendingUninstallHooks =
await applicationUninstallService.findWorkspaceIdsWithPendingUninstallHooks(
[{ workspaceId, uninstallRequestedAt: workspaceDeletedAt }],
);
expect(workspaceIdsWithPendingUninstallHooks.size).toBe(1);
expect(workspaceIdsWithPendingUninstallHooks.has(workspaceId)).toBe(true);
executeSpy.mockClear();
executeSpy.mockResolvedValue(SUCCESSFUL_EXECUTION_RESULT);
await applicationUninstallService.runUninstallHooksForWorkspaceDeletion({
workspaceId,
workspaceDeletedAt,
});
expect(executeSpy).toHaveBeenCalledTimes(1);
expect(executeSpy).toHaveBeenCalledWith(
expect.objectContaining({
logicFunctionId: failedApplication.uninstallLogicFunctionId,
workspaceDeletionRequestTimestamp: workspaceDeletedAt.toISOString(),
payload: expect.objectContaining({
idempotencyKey: `workspace-deletion:${workspaceId}:${workspaceDeletedAt.toISOString()}:${failedApplication.universalIdentifier}`,
}),
}),
);
const applicationsAfterRetry = await findInstalledApplications();
expect(
applicationsAfterRetry.map((application) =>
application.uninstallHookCompletedForRequestedAt?.toISOString(),
),
).toEqual([
workspaceDeletedAt.toISOString(),
workspaceDeletedAt.toISOString(),
]);
const workspaceIdsWithPendingUninstallHooksAfterRetry =
await applicationUninstallService.findWorkspaceIdsWithPendingUninstallHooks(
[{ workspaceId, uninstallRequestedAt: workspaceDeletedAt }],
);
expect(workspaceIdsWithPendingUninstallHooksAfterRetry.size).toBe(0);
}, 120000);
});

View File

@@ -0,0 +1,292 @@
import crypto from 'crypto';
import request from 'supertest';
import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util';
import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util';
import { uploadApplicationFile } from 'test/integration/metadata/suites/application/utils/upload-application-file.util';
import { type DataSource } from 'typeorm';
// A published npm app lands on an instance through the marketplace catalog
// sync as an instance-global, unowned registration (sourceType 'npm').
// Uploading code under an application identity is an authorship claim, so dev
// mode requires ownership of the registration. Once the workspace owns it, the
// dev sync must still leave the shared registration untouched, without
// rotating the shared OAuth client secret and without delisting the app from
// the marketplace for every workspace or fighting with the catalog sync cron.
describe('CLI dev mode on a catalog-synced (npm) app', () => {
const baseUrl = `http://localhost:${APP_PORT}`;
const universalIdentifier = crypto.randomUUID();
const roleId = crypto.randomUUID();
const registrationId = crypto.randomUUID();
const oAuthClientId = crypto.randomUUID();
let ds: DataSource;
const gqlRequest = (query: string, variables?: Record<string, unknown>) =>
request(baseUrl)
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({ query, variables });
const fetchRegistrationRow = async () => {
const [row] = await ds.query(
`SELECT name, "sourceType", "workspaceId", "manifest", "isListed"
FROM core."applicationRegistration" WHERE id = $1`,
[registrationId],
);
return row;
};
beforeAll(async () => {
jest.useRealTimers();
ds = global.testDataSource;
// Simulate MarketplaceCatalogSyncService.upsertFromCatalog: unowned
// registration (workspaceId NULL), npm-sourced, no client secret.
await ds.query(
`INSERT INTO core."applicationRegistration"
(id, "universalIdentifier", name, "oAuthClientId",
"oAuthRedirectUris", "oAuthScopes", "workspaceId", "sourceType",
"sourcePackage", "latestAvailableVersion", "isListed", "manifest")
VALUES ($1, $2, $3, $4, $5, $6, NULL, 'npm', $7, '1.0.0', true, $8)`,
[
registrationId,
universalIdentifier,
'Published Catalog App',
oAuthClientId,
[],
[],
'@test/published-catalog-app',
JSON.stringify({ application: { universalIdentifier } }),
],
);
});
afterAll(async () => {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: universalIdentifier,
});
jest.useFakeTimers();
});
it('reports the universal identifier as already claimed on createApplicationRegistration', async () => {
const res = await gqlRequest(
`mutation CreateApplicationRegistration($input: CreateApplicationRegistrationInput!) {
createApplicationRegistration(input: $input) {
applicationRegistration { id }
clientSecret
}
}`,
{
input: {
name: 'Published Catalog App (dev)',
universalIdentifier,
},
},
).expect(200);
expect(res.body.errors).toBeDefined();
expect(res.body.errors[0].message).toContain('already claimed');
});
it('refuses to rotate the shared client secret for a non-owner workspace', async () => {
const res = await gqlRequest(
`mutation RotateSecret($id: String!) {
rotateApplicationRegistrationClientSecret(id: $id) {
clientSecret
}
}`,
{ id: registrationId },
).expect(200);
expect(res.body.errors).toBeDefined();
expect(res.body.errors[0].message).toContain('not found');
});
it('refuses dev mode while no workspace owns the registration', async () => {
const res = await gqlRequest(
`mutation CreateDevApp($universalIdentifier: String!, $name: String!) {
createDevelopmentApplication(universalIdentifier: $universalIdentifier, name: $name) {
id
}
}`,
{ universalIdentifier, name: 'Published Catalog App (dev)' },
).expect(200);
expect(res.body.errors).toBeDefined();
expect(res.body.errors[0].message).toContain('claimed by no workspace');
});
it('lets a workspace dev-sync the app and mint a dev token once it owns the registration, without touching the shared registration', async () => {
await ds.query(
`UPDATE core."applicationRegistration" SET "workspaceId" = $1 WHERE id = $2`,
[SEED_APPLE_WORKSPACE_ID, registrationId],
);
const createDevAppRes = await gqlRequest(
`mutation CreateDevApp($universalIdentifier: String!, $name: String!) {
createDevelopmentApplication(universalIdentifier: $universalIdentifier, name: $name) {
id
}
}`,
{ universalIdentifier, name: 'Published Catalog App (dev)' },
).expect(200);
expect(createDevAppRes.body.errors).toBeUndefined();
const applicationId = createDevAppRes.body.data.createDevelopmentApplication
.id as string;
// The CLI uploads the app's package.json before syncing the manifest.
await uploadApplicationFile({
applicationUniversalIdentifier: universalIdentifier,
fileFolder: 'Dependencies',
filePath: 'package.json',
fileBuffer: Buffer.from(
JSON.stringify({ name: 'published-catalog-app', version: '1.0.1' }),
),
filename: 'package.json',
expectToFail: false,
});
const syncRes = await syncApplication({
manifest: buildBaseManifest({
appId: universalIdentifier,
roleId,
overrides: {
application: {
universalIdentifier,
defaultRoleUniversalIdentifier: roleId,
displayName: 'Published Catalog App (dev)',
description: 'Local dev iteration of a published app',
applicationVariables: {},
packageJsonChecksum: null,
yarnLockChecksum: null,
},
},
}),
expectToFail: false,
});
expect(syncRes.errors).toBeUndefined();
// The shared registration is untouched: still npm-sourced, still listed in
// the marketplace catalog, and carrying the published name and manifest.
const registrationRow = await fetchRegistrationRow();
expect(registrationRow.sourceType).toBe('npm');
expect(registrationRow.name).toBe('Published Catalog App');
expect(registrationRow.isListed).toBe(true);
expect(registrationRow.manifest).toEqual({
application: { universalIdentifier },
});
// Dev tooling gets a workspace-scoped app token via
// generateApplicationToken instead of rotating the shared client secret.
const tokenRes = await gqlRequest(
`mutation GenerateApplicationToken($applicationId: UUID!) {
generateApplicationToken(applicationId: $applicationId) {
applicationAccessToken { token expiresAt }
applicationRefreshToken { token expiresAt }
}
}`,
{ applicationId },
).expect(200);
expect(tokenRes.body.errors).toBeUndefined();
expect(
tokenRes.body.data.generateApplicationToken.applicationAccessToken.token,
).toBeDefined();
});
it('still lets a workspace that owns a non-npm registration sync its metadata', async () => {
// Control: a local registration owned by the workspace keeps receiving
// manifest updates from dev sync (the guard only protects npm/unowned
// registrations).
const ownedUid = crypto.randomUUID();
const ownedRoleId = crypto.randomUUID();
const createRes = await gqlRequest(
`mutation CreateApplicationRegistration($input: CreateApplicationRegistrationInput!) {
createApplicationRegistration(input: $input) {
applicationRegistration { id }
}
}`,
{ input: { name: 'Owned Local App', universalIdentifier: ownedUid } },
).expect(200);
expect(createRes.body.errors).toBeUndefined();
const ownedRegistrationId =
createRes.body.data.createApplicationRegistration.applicationRegistration
.id;
try {
const createDevAppRes = await gqlRequest(
`mutation CreateDevApp($universalIdentifier: String!, $name: String!) {
createDevelopmentApplication(universalIdentifier: $universalIdentifier, name: $name) {
id
}
}`,
{ universalIdentifier: ownedUid, name: 'Owned Local App' },
).expect(200);
expect(createDevAppRes.body.errors).toBeUndefined();
await uploadApplicationFile({
applicationUniversalIdentifier: ownedUid,
fileFolder: 'Dependencies',
filePath: 'package.json',
fileBuffer: Buffer.from(
JSON.stringify({ name: 'owned-local-app', version: '0.0.1' }),
),
filename: 'package.json',
expectToFail: false,
});
const syncRes = await syncApplication({
manifest: buildBaseManifest({
appId: ownedUid,
roleId: ownedRoleId,
overrides: {
application: {
universalIdentifier: ownedUid,
defaultRoleUniversalIdentifier: ownedRoleId,
displayName: 'Owned Local App (renamed)',
description: 'Owned local app',
applicationVariables: {},
packageJsonChecksum: null,
yarnLockChecksum: null,
},
roles: [
{
universalIdentifier: ownedRoleId,
label: 'Owned Local App Role',
description: 'A test role',
},
],
},
}),
expectToFail: false,
});
expect(syncRes.errors).toBeUndefined();
const [row] = await ds.query(
`SELECT name, "sourceType" FROM core."applicationRegistration" WHERE id = $1`,
[ownedRegistrationId],
);
expect(row.name).toBe('Owned Local App (renamed)');
expect(row.sourceType).toBe('local');
} finally {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: ownedUid,
});
}
});
});

View File

@@ -0,0 +1,265 @@
import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util';
import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util';
import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util';
import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util';
import { findConnectionProvidersByApplication } from 'test/integration/metadata/suites/connection-provider/utils/find-connection-providers-by-application.util';
import { getAppProviderByClassName } from 'test/integration/utils/get-app-provider-by-class-name.util';
import { deleteConnectedAccount } from 'test/integration/utils/query-messaging.util';
import { type Manifest } from 'twenty-shared/application';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { v4 as uuidv4 } from 'uuid';
import { AppOAuthRevokeService } from 'src/engine/core-modules/application/connection-provider/refresh/services/app-oauth-revoke.service';
import { LogicFunctionExecutorService } from 'src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service';
import { LogicFunctionExecutionStatus } from 'src/engine/metadata-modules/logic-function/dtos/logic-function-execution-result.dto';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
const PROVIDER_NAME = 'fathom';
const REFRESHED_ACCESS_TOKEN = 'enc:v2:refreshed-by-hook';
const buildManifestWithConnectionProvider = ({
appId,
roleId,
providerId,
logicFunctionId,
withOnDisconnectHook,
}: {
appId: string;
roleId: string;
providerId: string;
logicFunctionId: string;
withOnDisconnectHook: boolean;
}): Manifest =>
buildBaseManifest({
appId,
roleId,
overrides: {
connectionProviders: [
{
universalIdentifier: providerId,
name: PROVIDER_NAME,
displayName: 'Fathom',
type: 'oauth',
oauth: {
authorizationEndpoint: 'https://fathom.video/oauth2/authorize',
tokenEndpoint: 'https://api.fathom.ai/oauth2/token',
scopes: ['public_api'],
clientIdVariable: 'FATHOM_CLIENT_ID',
clientSecretVariable: 'FATHOM_CLIENT_SECRET',
},
...(withOnDisconnectHook
? {
onDisconnectLogicFunction: {
universalIdentifier: logicFunctionId,
},
}
: {}),
},
],
logicFunctions: [
{
universalIdentifier: logicFunctionId,
name: 'Release remote resources',
description: 'On-disconnect cleanup logic function',
handlerName: 'handler',
sourceHandlerPath: 'src/disconnect.ts',
builtHandlerPath: 'dist/disconnect.mjs',
builtHandlerChecksum: 'checksum-disconnect',
httpRouteTriggerSettings: {
path: '/disconnect',
httpMethod: 'POST',
isAuthRequired: true,
},
},
],
},
});
const countConnectedAccounts = async (
connectedAccountId: string,
): Promise<number> => {
const [{ count }] = await globalThis.testDataSource.query(
`SELECT COUNT(*)::int AS count FROM core."connectedAccount" WHERE id = $1`,
[connectedAccountId],
);
return count;
};
describe('Connected account deletion runs the on-disconnect hook', () => {
let appId: string;
let roleId: string;
let providerId: string;
let logicFunctionId: string;
let connectedAccountId: string;
let executeSpy: jest.SpyInstance;
let revokeSpy: jest.SpyInstance;
let connectedAccountCountDuringHook: number | undefined;
const syncTestApplication = ({
withOnDisconnectHook,
}: {
withOnDisconnectHook: boolean;
}) =>
syncApplication({
manifest: buildManifestWithConnectionProvider({
appId,
roleId,
providerId,
logicFunctionId,
withOnDisconnectHook,
}),
expectToFail: false,
});
const insertAppConnectedAccount = async (): Promise<void> => {
const [provider] = await findConnectionProvidersByApplication(appId);
const [userWorkspace] = await globalThis.testDataSource.query(
`SELECT id FROM core."userWorkspace" WHERE "workspaceId" = $1 LIMIT 1`,
[SEED_APPLE_WORKSPACE_ID],
);
await globalThis.testDataSource.query(
`INSERT INTO core."connectedAccount"
(id, handle, provider, visibility, "workspaceId", "userWorkspaceId", "applicationId", "connectionProviderId")
VALUES ($1, $2, $3, 'workspace', $4, $5, $6, $7)`,
[
connectedAccountId,
'fathom-user@apple.dev',
ConnectedAccountProvider.APP,
SEED_APPLE_WORKSPACE_ID,
userWorkspace.id,
provider.applicationId,
provider.id,
],
);
};
beforeEach(async () => {
appId = uuidv4();
roleId = uuidv4();
providerId = uuidv4();
logicFunctionId = uuidv4();
connectedAccountId = uuidv4();
connectedAccountCountDuringHook = undefined;
await setupApplicationForSync({
applicationUniversalIdentifier: appId,
name: 'Test Application',
description: 'App for testing the on-disconnect logic function hook',
sourcePath: 'test-on-disconnect-hook',
});
const logicFunctionExecutorService =
getAppProviderByClassName<LogicFunctionExecutorService>(
'LogicFunctionExecutorService',
);
// setupApplicationForSync leaves fake timers on, and the delete helper
// polls the job queues with real setTimeout calls.
jest.useRealTimers();
revokeSpy = jest
.spyOn(
getAppProviderByClassName<AppOAuthRevokeService>(
'AppOAuthRevokeService',
),
'revokeIfApp',
)
.mockResolvedValue(undefined);
// Stands in for the token refresh getConnection performs inside a hook.
executeSpy = jest
.spyOn(logicFunctionExecutorService, 'execute')
.mockImplementation(async () => {
connectedAccountCountDuringHook =
await countConnectedAccounts(connectedAccountId);
await globalThis.testDataSource.query(
`UPDATE core."connectedAccount" SET "accessToken" = $1 WHERE id = $2`,
[REFRESHED_ACCESS_TOKEN, connectedAccountId],
);
return {
data: {},
duration: 1,
billedDurationMs: 1,
logs: '',
status: LogicFunctionExecutionStatus.SUCCESS,
};
});
}, 60000);
afterEach(async () => {
executeSpy.mockRestore();
revokeSpy.mockRestore();
await globalThis.testDataSource.query(
`DELETE FROM core."connectedAccount" WHERE id = $1`,
[connectedAccountId],
);
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: appId,
});
});
it('executes the hook while the connected account still exists, revokes the tokens it left behind, then deletes it', async () => {
await syncTestApplication({ withOnDisconnectHook: true });
await insertAppConnectedAccount();
const [provider] = await findConnectionProvidersByApplication(appId);
await deleteConnectedAccount(connectedAccountId);
expect(executeSpy).toHaveBeenCalledTimes(1);
expect(executeSpy).toHaveBeenCalledWith(
expect.objectContaining({
workspaceId: SEED_APPLE_WORKSPACE_ID,
payload: {
connectionProviderId: provider.id,
connectionProviderName: PROVIDER_NAME,
connectedAccountId,
},
}),
);
expect(connectedAccountCountDuringHook).toBe(1);
expect(revokeSpy).toHaveBeenCalledWith(
expect.objectContaining({
id: connectedAccountId,
accessToken: REFRESHED_ACCESS_TOKEN,
}),
);
expect(await countConnectedAccounts(connectedAccountId)).toBe(0);
}, 60000);
it('still deletes the connected account when the hook returns an error', async () => {
executeSpy.mockResolvedValue({
data: null,
duration: 1,
billedDurationMs: 1,
logs: '',
status: LogicFunctionExecutionStatus.ERROR,
error: {
errorType: 'UnhandledError',
errorMessage: 'On-disconnect hook failed on purpose',
stackTrace: '',
},
});
await syncTestApplication({ withOnDisconnectHook: true });
await insertAppConnectedAccount();
await deleteConnectedAccount(connectedAccountId);
expect(executeSpy).toHaveBeenCalledTimes(1);
expect(await countConnectedAccounts(connectedAccountId)).toBe(0);
}, 60000);
it('does not execute any hook when the provider declares no on-disconnect logic function', async () => {
await syncTestApplication({ withOnDisconnectHook: false });
await insertAppConnectedAccount();
await deleteConnectedAccount(connectedAccountId);
expect(executeSpy).not.toHaveBeenCalled();
expect(await countConnectedAccounts(connectedAccountId)).toBe(0);
}, 60000);
});

View File

@@ -0,0 +1,105 @@
import crypto from 'crypto';
import request from 'supertest';
import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util';
import { type DataSource } from 'typeorm';
import { SEED_YCOMBINATOR_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
describe('CLI dev mode on a registration owned by another workspace', () => {
const baseUrl = `http://localhost:${APP_PORT}`;
const foreignWorkspaceId = SEED_YCOMBINATOR_WORKSPACE_ID;
let ds: DataSource;
const gqlRequest = (query: string, variables?: Record<string, unknown>) =>
request(baseUrl)
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({ query, variables });
const createDevelopmentApplication = (universalIdentifier: string) =>
gqlRequest(
`mutation CreateDevApp($universalIdentifier: String!, $name: String!) {
createDevelopmentApplication(universalIdentifier: $universalIdentifier, name: $name) {
id
}
}`,
{ universalIdentifier, name: 'Stolen App' },
).expect(200);
const seedForeignRegistration = async ({
universalIdentifier,
isListed,
isPreInstalled,
}: {
universalIdentifier: string;
isListed: boolean;
isPreInstalled: boolean;
}) => {
await ds.query(
`INSERT INTO core."applicationRegistration"
(id, "universalIdentifier", name, "oAuthClientId", "oAuthRedirectUris",
"oAuthScopes", "workspaceId", "sourceType", "isListed", "isPreInstalled")
VALUES ($1, $2, $3, $4, $5, $6, $7, 'tarball', $8, $9)`,
[
crypto.randomUUID(),
universalIdentifier,
'Victim Internal App',
crypto.randomUUID(),
[],
[],
foreignWorkspaceId,
isListed,
isPreInstalled,
],
);
};
beforeAll(() => {
jest.useRealTimers();
ds = global.testDataSource;
});
afterAll(() => {
jest.useFakeTimers();
});
it.each([
{ label: 'unlisted', isListed: false, isPreInstalled: false },
{
label: 'listed on the marketplace',
isListed: true,
isPreInstalled: false,
},
{
label: 'pre-installed by the operator',
isListed: false,
isPreInstalled: true,
},
])(
'refuses to create a development application on a $label foreign registration',
async ({ isListed, isPreInstalled }) => {
const universalIdentifier = crypto.randomUUID();
await seedForeignRegistration({
universalIdentifier,
isListed,
isPreInstalled,
});
try {
const res = await createDevelopmentApplication(universalIdentifier);
expect(res.body.errors).toBeDefined();
expect(res.body.errors[0].message).toContain('another workspace');
expect(res.body.data?.createDevelopmentApplication).toBeFalsy();
} finally {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: universalIdentifier,
});
}
},
);
});

View File

@@ -0,0 +1,83 @@
import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util';
import { buildDefaultObjectManifest } from 'test/integration/metadata/suites/application/utils/build-default-object-manifest.util';
import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util';
import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util';
import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util';
import { findManyObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata.util';
import { type Manifest } from 'twenty-shared/application';
import { v4 as uuidv4 } from 'uuid';
const TEST_APP_ID = uuidv4();
const TEST_ROLE_ID = uuidv4();
const buildManifest = (
overrides?: Partial<Pick<Manifest, 'objects' | 'fields'>>,
) => buildBaseManifest({ appId: TEST_APP_ID, roleId: TEST_ROLE_ID, overrides });
const findObjectNames = async (): Promise<string[]> => {
const { objects } = await findManyObjectMetadata({
input: { filter: {}, paging: { first: 100 } },
gqlFields: 'id nameSingular',
expectToFail: false,
});
return objects.map((object) => object.nameSingular);
};
describe('Manifest sync - dry run', () => {
beforeEach(async () => {
await setupApplicationForSync({
applicationUniversalIdentifier: TEST_APP_ID,
name: 'Dry Run Test Application',
description: 'App for testing dry-run manifest sync',
sourcePath: 'dry-run-manifest-sync',
});
}, 60000);
afterEach(async () => {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: TEST_APP_ID,
});
});
it('returns the planned actions without applying them', async () => {
const ticketObject = buildDefaultObjectManifest({
applicationUniversalIdentifier: TEST_APP_ID,
nameSingular: 'dryRunTicket',
namePlural: 'dryRunTickets',
labelSingular: 'Dry Run Ticket',
labelPlural: 'Dry Run Tickets',
description: 'A support ticket',
});
await syncApplication({
manifest: buildManifest({ objects: [ticketObject] }),
expectToFail: false,
});
const invoiceObject = buildDefaultObjectManifest({
applicationUniversalIdentifier: TEST_APP_ID,
nameSingular: 'dryRunInvoice',
namePlural: 'dryRunInvoices',
labelSingular: 'Dry Run Invoice',
labelPlural: 'Dry Run Invoices',
description: 'A billing invoice',
});
const dryRunResponse = await syncApplication({
manifest: buildManifest({ objects: [ticketObject, invoiceObject] }),
dryRun: true,
expectToFail: false,
});
expect(dryRunResponse.errors).toBeUndefined();
expect(dryRunResponse.data.syncApplication.actions.length).toBeGreaterThan(
0,
);
const objectNames = await findObjectNames();
expect(objectNames).toContain('dryRunTicket');
expect(objectNames).not.toContain('dryRunInvoice');
}, 60000);
});

View File

@@ -0,0 +1,720 @@
import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { randomUUID } from 'crypto';
import gql from 'graphql-tag';
import { activateWorkspace } from 'test/integration/graphql/utils/activate-workspace.util';
import { deleteUser } from 'test/integration/graphql/utils/delete-user.util';
import { findManyApplications } from 'test/integration/graphql/utils/find-many-applications.util';
import { getAuthTokensFromLoginToken } from 'test/integration/graphql/utils/get-auth-tokens-from-login-token.util';
import { signUpInNewWorkspace } from 'test/integration/graphql/utils/sign-up-in-new-workspace.util';
import { signUp } from 'test/integration/graphql/utils/sign-up.util';
import { generateApplicationToken } from 'test/integration/metadata/suites/application/utils/generate-application-token.util';
import { createOneLogicFunction } from 'test/integration/metadata/suites/logic-function/utils/create-logic-function.util';
import { deleteLogicFunction } from 'test/integration/metadata/suites/logic-function/utils/delete-logic-function.util';
import { executeLogicFunction } from 'test/integration/metadata/suites/logic-function/utils/execute-logic-function.util';
import { updateLogicFunctionSource } from 'test/integration/metadata/suites/logic-function/utils/update-logic-function-source.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { expectEventually } from 'test/integration/utils/expect-eventually.util';
import { waitForAllJobsToFinish } from 'test/integration/utils/wait-for-all-jobs-to-finish.util';
import { isDefined } from 'twenty-shared/utils';
import { v4 as uuidv4 } from 'uuid';
import { WORKSPACE_CUSTOM_APPLICATION_NAME } from 'src/engine/core-modules/application/constants/workspace-custom-application.constant';
import { TWENTY_STANDARD_APPLICATION } from 'src/engine/workspace-manager/twenty-standard-application/constants/twenty-standard-applications';
const MARKER_DIRECTORY = join(tmpdir(), `enqueue-job-${uuidv4()}`);
const TARGET_SOURCE_CODE = `import { writeFileSync } from 'node:fs';
export const main = async (params: { markerPath: string }): Promise<object> => {
writeFileSync(params.markerPath, 'ran', 'utf-8');
return { ok: true };
};`;
const APPENDING_TARGET_SOURCE_CODE = `import { appendFileSync } from 'node:fs';
export const main = async (params: { markerPath: string }): Promise<object> => {
appendFileSync(params.markerPath, 'ran\\n', 'utf-8');
return { ok: true };
};`;
const RETRYABLE_TARGET_SOURCE_CODE = `import { appendFileSync } from 'node:fs';
type RetryableTargetParams = {
markerPath: string;
shouldRetry: boolean;
};
type RetryContext = {
retryCount: number;
maxRetries: number;
};
export const main = async (
params: RetryableTargetParams,
context: RetryContext,
): Promise<object> => {
appendFileSync(
params.markerPath,
context.retryCount + '/' + context.maxRetries + '\\n',
'utf-8',
);
if (params.shouldRetry) {
const retryableError = new Error('Dependency is temporarily unavailable');
retryableError.name = 'RetryableLogicFunctionError';
throw retryableError;
}
return { ok: true };
};`;
const ENQUEUE_JOB = gql`
mutation EnqueueJob($input: EnqueueJobInput!) {
enqueueJob(input: $input) {
enqueued
logicFunctionUniversalIdentifier
jobId
}
}
`;
const ENQUEUE_JOBS = gql`
mutation EnqueueJobs($input: EnqueueJobsInput!) {
enqueueJobs(input: $input) {
enqueued
logicFunctionUniversalIdentifier
enqueuedJobsCount
jobIds
}
}
`;
const GET_JOBS = gql`
query GetJobs($jobIds: [String!]!) {
getJobs(jobIds: $jobIds) {
jobId
state
attemptsMade
failedReason
enqueuedAt
startedAt
finishedAt
}
}
`;
describe('enqueueJob (e2e)', () => {
let customApplicationToken: string;
let standardApplicationToken: string;
let logicFunctionId: string;
let logicFunctionUniversalIdentifier: string;
let retryableLogicFunctionId: string;
let retryableLogicFunctionUniversalIdentifier: string;
let appendingLogicFunctionId: string;
let appendingLogicFunctionUniversalIdentifier: string;
let otherWorkspaceUserAccessToken: string | undefined;
const createOtherWorkspaceApplicationToken = async () => {
const email = `enqueue-job-other-workspace-${randomUUID()}@example.com`;
const { data: signUpData } = await signUp({
input: { email, password: 'Test123!@#' },
expectToFail: false,
});
otherWorkspaceUserAccessToken =
signUpData.signUp.tokens.accessOrWorkspaceAgnosticToken.token;
await globalThis.testDataSource.query(
'UPDATE core."user" SET "isEmailVerified" = true WHERE email = $1',
[email],
);
const { data: newWorkspaceData } = await signUpInNewWorkspace({
accessToken: otherWorkspaceUserAccessToken,
expectToFail: false,
});
const { data: authTokensData } = await getAuthTokensFromLoginToken({
loginToken: newWorkspaceData.signUpInNewWorkspace.loginToken.token,
origin:
newWorkspaceData.signUpInNewWorkspace.workspace.workspaceUrls
.subdomainUrl,
expectToFail: false,
});
const workspaceAccessToken =
authTokensData.getAuthTokensFromLoginToken.tokens
.accessOrWorkspaceAgnosticToken.token;
await activateWorkspace({
accessToken: workspaceAccessToken,
expectToFail: false,
});
const { data: applicationsData } = await findManyApplications({
accessToken: workspaceAccessToken,
expectToFail: false,
});
const otherWorkspaceApplication =
applicationsData.findManyApplications.find(
(application) => application.name === WORKSPACE_CUSTOM_APPLICATION_NAME,
);
expect(otherWorkspaceApplication).toBeDefined();
const { data: tokenData } = await generateApplicationToken({
applicationId: otherWorkspaceApplication!.id,
expectToFail: false,
token: workspaceAccessToken,
});
return tokenData.generateApplicationToken.applicationAccessToken.token;
};
beforeAll(async () => {
mkdirSync(MARKER_DIRECTORY, { recursive: true });
const { data } = await findManyApplications({ expectToFail: false });
const customApplication = data.findManyApplications.find(
(application) => application.name === WORKSPACE_CUSTOM_APPLICATION_NAME,
);
const standardApplication = data.findManyApplications.find(
(application) =>
application.universalIdentifier ===
TWENTY_STANDARD_APPLICATION.universalIdentifier,
);
expect(customApplication).toBeDefined();
expect(standardApplication).toBeDefined();
const [{ data: customTokenData }, { data: standardTokenData }] =
await Promise.all([
generateApplicationToken({
applicationId: customApplication!.id,
expectToFail: false,
}),
generateApplicationToken({
applicationId: standardApplication!.id,
expectToFail: false,
}),
]);
customApplicationToken =
customTokenData.generateApplicationToken.applicationAccessToken.token;
standardApplicationToken =
standardTokenData.generateApplicationToken.applicationAccessToken.token;
const { data: createData } = await createOneLogicFunction({
input: { name: `enqueue-job-target-${uuidv4()}` },
gqlFields: 'id universalIdentifier',
expectToFail: false,
});
const { data: retryableCreateData } = await createOneLogicFunction({
input: { name: `enqueue-job-retryable-target-${uuidv4()}` },
gqlFields: 'id universalIdentifier',
expectToFail: false,
});
const { data: appendingCreateData } = await createOneLogicFunction({
input: { name: `enqueue-job-appending-target-${uuidv4()}` },
gqlFields: 'id universalIdentifier',
expectToFail: false,
});
expect(createData.createOneLogicFunction.universalIdentifier).toBeDefined();
expect(
retryableCreateData.createOneLogicFunction.universalIdentifier,
).toBeDefined();
expect(
appendingCreateData.createOneLogicFunction.universalIdentifier,
).toBeDefined();
logicFunctionId = createData.createOneLogicFunction.id;
logicFunctionUniversalIdentifier =
createData.createOneLogicFunction.universalIdentifier!;
retryableLogicFunctionId = retryableCreateData.createOneLogicFunction.id;
retryableLogicFunctionUniversalIdentifier =
retryableCreateData.createOneLogicFunction.universalIdentifier!;
appendingLogicFunctionId = appendingCreateData.createOneLogicFunction.id;
appendingLogicFunctionUniversalIdentifier =
appendingCreateData.createOneLogicFunction.universalIdentifier!;
await updateLogicFunctionSource({
input: {
id: retryableLogicFunctionId,
update: { sourceHandlerCode: RETRYABLE_TARGET_SOURCE_CODE },
},
expectToFail: false,
});
await updateLogicFunctionSource({
input: {
id: appendingLogicFunctionId,
update: { sourceHandlerCode: APPENDING_TARGET_SOURCE_CODE },
},
expectToFail: false,
});
const { data: retryableBuildData } = await executeLogicFunction({
input: {
id: retryableLogicFunctionId,
payload: {
markerPath: join(MARKER_DIRECTORY, 'retryable-build.txt'),
shouldRetry: false,
},
},
expectToFail: false,
});
expect(retryableBuildData.executeOneLogicFunction.error).toBeNull();
const { data: appendingBuildData } = await executeLogicFunction({
input: {
id: appendingLogicFunctionId,
payload: {
markerPath: join(MARKER_DIRECTORY, 'appending-build.txt'),
},
},
expectToFail: false,
});
expect(appendingBuildData.executeOneLogicFunction.error).toBeNull();
});
afterAll(async () => {
const teardownErrors: unknown[] = [];
for (const id of [
logicFunctionId,
retryableLogicFunctionId,
appendingLogicFunctionId,
]) {
try {
await deleteLogicFunction({ input: { id }, expectToFail: false });
} catch (error) {
teardownErrors.push(error);
}
}
if (teardownErrors.length > 0) {
throw teardownErrors[0];
}
if (isDefined(otherWorkspaceUserAccessToken)) {
await deleteUser({ accessToken: otherWorkspaceUserAccessToken });
}
rmSync(MARKER_DIRECTORY, { recursive: true, force: true });
});
it('rejects requests that do not carry an APPLICATION_ACCESS token', async () => {
const response = await makeMetadataAPIRequest({
query: ENQUEUE_JOB,
variables: { input: { logicFunctionUniversalIdentifier } },
});
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toContain('APPLICATION_ACCESS');
});
it('enqueues a logic function owned by the calling application and the worker runs it', async () => {
const markerPath = join(MARKER_DIRECTORY, 'enqueued.txt');
await updateLogicFunctionSource({
input: {
id: logicFunctionId,
update: { sourceHandlerCode: TARGET_SOURCE_CODE },
},
expectToFail: false,
});
const { data: buildData } = await executeLogicFunction({
input: {
id: logicFunctionId,
payload: { markerPath: join(MARKER_DIRECTORY, 'build.txt') },
},
expectToFail: false,
});
expect(buildData.executeOneLogicFunction.error).toBeNull();
const response = await makeMetadataAPIRequest(
{
query: ENQUEUE_JOB,
variables: {
input: { logicFunctionUniversalIdentifier, payload: { markerPath } },
},
},
customApplicationToken,
);
expect(response.body.errors).toBeUndefined();
expect(response.body.data.enqueueJob).toEqual({
enqueued: true,
logicFunctionUniversalIdentifier,
jobId: expect.any(String),
});
await waitForAllJobsToFinish();
await expectEventually(() => {
expect(existsSync(markerPath)).toBe(true);
});
});
it('caps application-requested retries independently from the overall queue budget', async () => {
const markerPath = join(MARKER_DIRECTORY, 'application-retry-cap.txt');
const response = await makeMetadataAPIRequest(
{
query: ENQUEUE_JOB,
variables: {
input: {
logicFunctionUniversalIdentifier:
retryableLogicFunctionUniversalIdentifier,
payload: { markerPath, shouldRetry: true },
retryLimit: 10,
},
},
},
customApplicationToken,
);
expect(response.body.errors).toBeUndefined();
expect(response.body.data.enqueueJob.enqueued).toBe(true);
await waitForAllJobsToFinish();
expect(readFileSync(markerPath, 'utf-8').trim().split('\n')).toEqual([
'0/3',
'1/3',
'2/3',
'3/3',
]);
}, 60_000);
it('uses a smaller overall queue budget as the application retry maximum', async () => {
const markerPath = join(MARKER_DIRECTORY, 'queue-retry-cap.txt');
const response = await makeMetadataAPIRequest(
{
query: ENQUEUE_JOB,
variables: {
input: {
logicFunctionUniversalIdentifier:
retryableLogicFunctionUniversalIdentifier,
payload: { markerPath, shouldRetry: true },
retryLimit: 1,
},
},
},
customApplicationToken,
);
expect(response.body.errors).toBeUndefined();
expect(response.body.data.enqueueJob.enqueued).toBe(true);
await waitForAllJobsToFinish();
expect(readFileSync(markerPath, 'utf-8').trim().split('\n')).toEqual([
'0/1',
'1/1',
]);
}, 60_000);
it('rejects a logic function that belongs to another application', async () => {
const response = await makeMetadataAPIRequest(
{
query: ENQUEUE_JOB,
variables: {
input: { logicFunctionUniversalIdentifier },
},
},
standardApplicationToken,
);
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toContain('not found');
});
it('rejects an unknown logic function', async () => {
const response = await makeMetadataAPIRequest(
{
query: ENQUEUE_JOB,
variables: {
input: { logicFunctionUniversalIdentifier: uuidv4() },
},
},
customApplicationToken,
);
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toContain('not found');
});
it('enqueues a batch of payloads in a single call and the worker runs them all', async () => {
const markerPaths = [
join(MARKER_DIRECTORY, 'batch-0.txt'),
join(MARKER_DIRECTORY, 'batch-1.txt'),
];
const response = await makeMetadataAPIRequest(
{
query: ENQUEUE_JOBS,
variables: {
input: {
logicFunctionUniversalIdentifier,
payloads: markerPaths.map((markerPath) => ({ markerPath })),
},
},
},
customApplicationToken,
);
expect(response.body.errors).toBeUndefined();
expect(response.body.data.enqueueJobs).toEqual({
enqueued: true,
logicFunctionUniversalIdentifier,
enqueuedJobsCount: markerPaths.length,
jobIds: markerPaths.map(() => expect.any(String)),
});
await waitForAllJobsToFinish();
await expectEventually(() => {
for (const markerPath of markerPaths) {
expect(existsSync(markerPath)).toBe(true);
}
});
});
it('rejects a batch targeting an unknown logic function', async () => {
const response = await makeMetadataAPIRequest(
{
query: ENQUEUE_JOBS,
variables: {
input: {
logicFunctionUniversalIdentifier: uuidv4(),
payloads: [{}],
},
},
},
customApplicationToken,
);
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toContain('not found');
});
it('rejects an empty batch', async () => {
const response = await makeMetadataAPIRequest(
{
query: ENQUEUE_JOBS,
variables: {
input: { logicFunctionUniversalIdentifier, payloads: [] },
},
},
customApplicationToken,
);
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toContain('payloads');
});
it('reports a caller-supplied job id as completed once the worker ran it', async () => {
const markerPath = join(MARKER_DIRECTORY, 'job-status.txt');
const jobId = `job-status-${uuidv4()}`;
const enqueueResponse = await makeMetadataAPIRequest(
{
query: ENQUEUE_JOBS,
variables: {
input: {
logicFunctionUniversalIdentifier,
jobs: [{ payload: { markerPath }, jobId }],
},
},
},
customApplicationToken,
);
expect(enqueueResponse.body.errors).toBeUndefined();
expect(enqueueResponse.body.data.enqueueJobs.jobIds).toEqual([jobId]);
await waitForAllJobsToFinish();
const statusResponse = await makeMetadataAPIRequest(
{ query: GET_JOBS, variables: { jobIds: [jobId] } },
customApplicationToken,
);
expect(statusResponse.body.errors).toBeUndefined();
expect(statusResponse.body.data.getJobs).toHaveLength(1);
expect(statusResponse.body.data.getJobs[0]).toMatchObject({
jobId,
state: 'COMPLETED',
});
});
it('omits unknown job ids instead of failing the whole read', async () => {
const response = await makeMetadataAPIRequest(
{ query: GET_JOBS, variables: { jobIds: [uuidv4()] } },
customApplicationToken,
);
expect(response.body.errors).toBeUndefined();
expect(response.body.data.getJobs).toEqual([]);
});
it('does not run a caller-supplied job id a second time', async () => {
const markerPath = join(MARKER_DIRECTORY, 'idempotent.txt');
const jobId = `idempotent-${uuidv4()}`;
const variables = {
input: {
logicFunctionUniversalIdentifier:
appendingLogicFunctionUniversalIdentifier,
jobs: [{ payload: { markerPath }, jobId }],
},
};
const firstResponse = await makeMetadataAPIRequest(
{ query: ENQUEUE_JOBS, variables },
customApplicationToken,
);
expect(firstResponse.body.errors).toBeUndefined();
expect(firstResponse.body.data.enqueueJobs.jobIds).toEqual([jobId]);
await waitForAllJobsToFinish();
await expectEventually(() => {
expect(existsSync(markerPath)).toBe(true);
});
const secondResponse = await makeMetadataAPIRequest(
{ query: ENQUEUE_JOBS, variables },
customApplicationToken,
);
expect(secondResponse.body.errors).toBeUndefined();
expect(secondResponse.body.data.enqueueJobs.jobIds).toEqual([jobId]);
await waitForAllJobsToFinish();
expect(readFileSync(markerPath, 'utf-8').trim().split('\n')).toEqual([
'ran',
]);
}, 60_000);
it('does not expose a job to another workspace', async () => {
const markerPath = join(MARKER_DIRECTORY, 'cross-workspace.txt');
const jobId = `cross-workspace-${uuidv4()}`;
const enqueueResponse = await makeMetadataAPIRequest(
{
query: ENQUEUE_JOBS,
variables: {
input: {
logicFunctionUniversalIdentifier,
jobs: [{ payload: { markerPath }, jobId }],
},
},
},
customApplicationToken,
);
expect(enqueueResponse.body.errors).toBeUndefined();
await waitForAllJobsToFinish();
await expectEventually(() => {
expect(existsSync(markerPath)).toBe(true);
});
const ownWorkspaceResponse = await makeMetadataAPIRequest(
{ query: GET_JOBS, variables: { jobIds: [jobId] } },
customApplicationToken,
);
expect(ownWorkspaceResponse.body.errors).toBeUndefined();
expect(ownWorkspaceResponse.body.data.getJobs).toHaveLength(1);
const otherWorkspaceApplicationToken =
await createOtherWorkspaceApplicationToken();
const otherWorkspaceResponse = await makeMetadataAPIRequest(
{ query: GET_JOBS, variables: { jobIds: [jobId] } },
otherWorkspaceApplicationToken,
);
expect(otherWorkspaceResponse.body.errors).toBeUndefined();
expect(otherWorkspaceResponse.body.data.getJobs).toEqual([]);
}, 120_000);
it('rejects a batch that repeats a caller-supplied job id', async () => {
const jobId = `duplicate-${uuidv4()}`;
const response = await makeMetadataAPIRequest(
{
query: ENQUEUE_JOBS,
variables: {
input: {
logicFunctionUniversalIdentifier,
jobs: [
{ payload: {}, jobId },
{ payload: {}, jobId },
],
},
},
},
customApplicationToken,
);
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toContain('unique');
});
it('rejects a batch that sets both payloads and jobs', async () => {
const response = await makeMetadataAPIRequest(
{
query: ENQUEUE_JOBS,
variables: {
input: {
logicFunctionUniversalIdentifier,
payloads: [{}],
jobs: [{ payload: {} }],
},
},
},
customApplicationToken,
);
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toContain('not both');
});
it('rejects job options outside of their allowed range', async () => {
const response = await makeMetadataAPIRequest(
{
query: ENQUEUE_JOB,
variables: {
input: { logicFunctionUniversalIdentifier, retryLimit: 99 },
},
},
customApplicationToken,
);
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toContain('retryLimit');
});
});

View File

@@ -0,0 +1,181 @@
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util';
import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util';
import { createAppTarball } from 'test/integration/metadata/suites/application/utils/create-app-tarball.util';
import { installApplication } from 'test/integration/metadata/suites/application/utils/install-application.util';
import { uploadAppTarball } from 'test/integration/metadata/suites/application/utils/upload-app-tarball.util';
import { scrubSemverVersions } from 'test/utils/scrub-semver-versions.util';
import { isDefined } from 'twenty-shared/utils';
import { v4 as uuidv4 } from 'uuid';
import { extractVersionFromCommandNameOrThrow } from 'src/engine/core-modules/upgrade/utils/extract-version-from-command-name-or-throw.util';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
// The full install flow runs cache-lock retries with real delays, so fake
// timers would hang it — mirror the other install suites.
jest.setTimeout(120000);
const INJECTED_CURSOR_MARKER = 'integration-test-workspace-version-gate';
const injectWorkspaceCursor = async (
name: string,
status: 'completed' | 'failed',
): Promise<void> => {
// Inject as attempt 2 with a fresh createdAt (now): this stays unique against
// the seeded completed attempt-1 cursor (which may share this name) and
// outranks it, so getWorkspaceCompletedVersion reads this attempt.
await global.testDataSource.query(
`INSERT INTO core."upgradeMigration"
(name, status, attempt, "executedByVersion", "workspaceId", "isInitial")
VALUES ($1, $2, 2, $3, $4, false)`,
[name, status, INJECTED_CURSOR_MARKER, SEED_APPLE_WORKSPACE_ID],
);
};
const clearInjectedWorkspaceCursors = async (): Promise<void> => {
await global.testDataSource.query(
`DELETE FROM core."upgradeMigration"
WHERE "workspaceId" = $1 AND "executedByVersion" = $2`,
[SEED_APPLE_WORKSPACE_ID, INJECTED_CURSOR_MARKER],
);
};
const uploadTarballApp = async ({
universalIdentifier,
roleId,
requiredServerVersion,
}: {
universalIdentifier: string;
roleId: string;
requiredServerVersion: string;
}): Promise<void> => {
const tarball = await createAppTarball({
'manifest.json': JSON.stringify(
buildBaseManifest({ appId: universalIdentifier, roleId }),
),
'package.json': JSON.stringify({
name: `test-workspace-version-gate-${universalIdentifier}`,
version: '1.0.0',
engines: { twenty: requiredServerVersion },
}),
});
const uploadResult = await uploadAppTarball({
tarballBuffer: tarball,
universalIdentifier,
});
expect(uploadResult.errors).toBeUndefined();
};
describe('Install application is gated by the workspace completed upgrade version', () => {
let currentVersionCommandName: string;
let currentServerVersion: string;
const createdApplicationUniversalIdentifiers: string[] = [];
beforeAll(async () => {
jest.useRealTimers();
// Derive the gate version from the last attempted instance command, so the
// requirement is one the instance gate at upload time always satisfies and
// the workspace gate under test is actually reached.
const [instanceCommand] = await global.testDataSource.query(
`SELECT migration.name AS name
FROM core."upgradeMigration" migration
WHERE migration."workspaceId" IS NULL
AND migration."isInitial" = false
AND migration.attempt = (
SELECT MAX(sub.attempt)
FROM core."upgradeMigration" sub
WHERE sub.name = migration.name
AND sub."workspaceId" IS NULL
)
ORDER BY migration."createdAt" DESC
LIMIT 1`,
);
if (!isDefined(instanceCommand)) {
throw new Error('Expected a seeded instance upgrade command');
}
// Re-injecting this command as a failed workspace attempt makes the
// workspace resolve to the previous completed version (behind the
// instance), so the install reaches the workspace gate.
currentVersionCommandName = instanceCommand.name;
currentServerVersion = extractVersionFromCommandNameOrThrow(
currentVersionCommandName,
);
});
afterEach(async () => {
await clearInjectedWorkspaceCursors();
});
afterAll(async () => {
for (const universalIdentifier of createdApplicationUniversalIdentifiers) {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: universalIdentifier,
});
}
jest.useFakeTimers();
});
it('rejects installation when the workspace has not completed the required upgrade version', async () => {
const universalIdentifier = uuidv4();
const roleId = uuidv4();
await uploadTarballApp({
universalIdentifier,
roleId,
requiredServerVersion: `>=${currentServerVersion}`,
});
createdApplicationUniversalIdentifiers.push(universalIdentifier);
// The workspace failed mid-way through the current version's upgrade
// segment, so its last completed version is the previous one.
await injectWorkspaceCursor(currentVersionCommandName, 'failed');
const { errors } = await installApplication({
input: { universalIdentifier },
expectToFail: true,
});
expectOneNotInternalServerErrorSnapshot({
errors,
normalizeMessage: scrubSemverVersions,
});
});
it('rejects installation when the workspace upgrade cursor cannot be interpreted', async () => {
const universalIdentifier = uuidv4();
const roleId = uuidv4();
await uploadTarballApp({
universalIdentifier,
roleId,
requiredServerVersion: '>=1.0.0',
});
createdApplicationUniversalIdentifiers.push(universalIdentifier);
// A cursor pointing at a command outside the supported upgrade sequence
// cannot be mapped to a completed version.
await injectWorkspaceCursor(
'1.0.0_UnknownLegacyCommand_1700000000000',
'completed',
);
const { errors } = await installApplication({
input: { universalIdentifier },
expectToFail: true,
});
expectOneNotInternalServerErrorSnapshot({
errors,
normalizeMessage: scrubSemverVersions,
});
});
});

View File

@@ -0,0 +1,330 @@
import { readFileSync } from 'fs';
import { join } from 'path';
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util';
import { completeApplicationFileUploads } from 'test/integration/metadata/suites/application/utils/complete-application-file-uploads.util';
import { createApplicationFileUploads } from 'test/integration/metadata/suites/application/utils/create-application-file-uploads.util';
import { putApplicationFileUploadTarget } from 'test/integration/metadata/suites/application/utils/put-application-file-upload-target.util';
import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util';
import { uploadApplicationFile } from 'test/integration/metadata/suites/application/utils/upload-application-file.util';
import {
type EachTestingContext,
eachTestingContextFilter,
} from 'twenty-shared/testing';
import { v4 as uuidv4 } from 'uuid';
import {
SEED_APPLE_WORKSPACE_ID,
SEED_YCOMBINATOR_WORKSPACE_ID,
} from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
import { TWENTY_STANDARD_APPLICATION } from 'src/engine/workspace-manager/twenty-standard-application/constants/twenty-standard-applications';
const TEST_APP_UID = uuidv4();
const STORAGE_LOCAL_PATH = join(process.cwd(), '.local-storage');
const HANDLER_PATH = 'handler.mjs';
const OWNER_HANDLER = 'export const main = () => "owner";';
const REPLACEMENT_HANDLER = 'export const main = () => process.env;';
type RegistrationOwnership = 'foreign' | 'unclaimed' | 'none';
type TestContext = {
registrationOwnership: RegistrationOwnership;
unlinkApplicationRegistration?: boolean;
};
const FAILING_TEST_CASES: EachTestingContext<TestContext>[] = [
{
title: 'when another workspace owns the application registration',
context: { registrationOwnership: 'foreign' },
},
{
title: 'when no workspace has claimed the application registration',
context: { registrationOwnership: 'unclaimed' },
},
{
title: 'when the application has no registration',
context: { registrationOwnership: 'none' },
},
{
title:
'when the application has no registration link and another workspace owns the registration',
context: {
registrationOwnership: 'foreign',
unlinkApplicationRegistration: true,
},
},
];
const normalizeMessage = (message: string) =>
message.replace(
new RegExp(TEST_APP_UID, 'g'),
'<applicationUniversalIdentifier>',
);
const getTargetApplicationUniversalIdentifier = (
registrationOwnership: RegistrationOwnership,
) =>
registrationOwnership === 'none'
? TWENTY_STANDARD_APPLICATION.universalIdentifier
: TEST_APP_UID;
const setTestRegistrationOwnerWorkspaceId = async (
ownerWorkspaceId: string | null,
) => {
await globalThis.testDataSource.query(
`UPDATE core."applicationRegistration" SET "workspaceId" = $1
WHERE "universalIdentifier" = $2`,
[ownerWorkspaceId, TEST_APP_UID],
);
};
const unlinkTestApplicationRegistration = async () => {
await globalThis.testDataSource.query(
`UPDATE core."application" SET "applicationRegistrationId" = NULL
WHERE "universalIdentifier" = $1 AND "workspaceId" = $2`,
[TEST_APP_UID, SEED_APPLE_WORKSPACE_ID],
);
};
const relinkTestApplicationRegistration = async () => {
await globalThis.testDataSource.query(
`UPDATE core."application" SET "applicationRegistrationId" = (
SELECT id FROM core."applicationRegistration" WHERE "universalIdentifier" = $1
)
WHERE "universalIdentifier" = $1 AND "workspaceId" = $2`,
[TEST_APP_UID, SEED_APPLE_WORKSPACE_ID],
);
};
const applyRegistrationLink = async ({
unlinkApplicationRegistration,
}: TestContext) => {
if (unlinkApplicationRegistration === true) {
await unlinkTestApplicationRegistration();
}
};
const applyRegistrationOwnership = async (
registrationOwnership: RegistrationOwnership,
) => {
if (registrationOwnership === 'foreign') {
await setTestRegistrationOwnerWorkspaceId(SEED_YCOMBINATOR_WORKSPACE_ID);
}
if (registrationOwnership === 'unclaimed') {
await setTestRegistrationOwnerWorkspaceId(null);
}
};
const readStoredHandler = (applicationUniversalIdentifier: string) => {
try {
return readFileSync(
join(
STORAGE_LOCAL_PATH,
SEED_APPLE_WORKSPACE_ID,
applicationUniversalIdentifier,
'built-logic-function',
HANDLER_PATH,
),
'utf-8',
);
} catch {
return null;
}
};
const countApplicationFiles = async (
applicationUniversalIdentifier: string,
) => {
const [{ count }] = await globalThis.testDataSource.query(
`SELECT count(*)::int AS count FROM core."file" WHERE "applicationId" IN (
SELECT id FROM core."application"
WHERE "universalIdentifier" = $1 AND "workspaceId" = $2
)`,
[applicationUniversalIdentifier, SEED_APPLE_WORKSPACE_ID],
);
return count as number;
};
const getFileStatus = async (fileId: string) => {
const [file] = await globalThis.testDataSource.query(
`SELECT status FROM core."file" WHERE id = $1`,
[fileId],
);
return file?.status as string | undefined;
};
const reservePendingHandlerUpload = async () => {
const body = Buffer.from(REPLACEMENT_HANDLER);
const { data } = await createApplicationFileUploads({
applicationUniversalIdentifier: TEST_APP_UID,
files: [
{
fileFolder: 'BuiltLogicFunction',
filePath: HANDLER_PATH,
size: body.length,
},
],
});
const [uploadTarget] = data.createApplicationFileUploads.targets;
const response = await putApplicationFileUploadTarget({
uploadTarget,
body,
});
expect(response.status).toBe(204);
return uploadTarget.fileId;
};
describe('Application file endpoints should fail', () => {
beforeAll(async () => {
await setupApplicationForSync({
applicationUniversalIdentifier: TEST_APP_UID,
name: 'Test Application File Upload Ownership App',
description: 'App for verifying registration ownership on file uploads',
sourcePath: 'test-application-file-upload-ownership',
});
jest.useRealTimers();
await uploadApplicationFile({
applicationUniversalIdentifier: TEST_APP_UID,
fileFolder: 'BuiltLogicFunction',
filePath: HANDLER_PATH,
fileBuffer: Buffer.from(OWNER_HANDLER),
filename: HANDLER_PATH,
contentType: 'application/javascript',
});
jest.useFakeTimers();
}, 60000);
beforeEach(() => {
jest.useRealTimers();
});
afterEach(async () => {
await setTestRegistrationOwnerWorkspaceId(SEED_APPLE_WORKSPACE_ID);
await relinkTestApplicationRegistration();
jest.useFakeTimers();
});
afterAll(async () => {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: TEST_APP_UID,
});
});
describe('uploadApplicationFile', () => {
it.each(eachTestingContextFilter(FAILING_TEST_CASES))(
'$title',
async ({ context }) => {
const applicationUniversalIdentifier =
getTargetApplicationUniversalIdentifier(
context.registrationOwnership,
);
const storedHandlerBefore = readStoredHandler(
applicationUniversalIdentifier,
);
const filesBefore = await countApplicationFiles(
applicationUniversalIdentifier,
);
await applyRegistrationLink(context);
await applyRegistrationOwnership(context.registrationOwnership);
const { errors } = await uploadApplicationFile({
applicationUniversalIdentifier,
fileFolder: 'BuiltLogicFunction',
filePath: HANDLER_PATH,
fileBuffer: Buffer.from(REPLACEMENT_HANDLER),
filename: HANDLER_PATH,
contentType: 'application/javascript',
expectToFail: true,
});
expectOneNotInternalServerErrorSnapshot({ errors, normalizeMessage });
expect(readStoredHandler(applicationUniversalIdentifier)).toBe(
storedHandlerBefore,
);
expect(
await countApplicationFiles(applicationUniversalIdentifier),
).toBe(filesBefore);
},
30000,
);
});
describe('createApplicationFileUploads', () => {
it.each(eachTestingContextFilter(FAILING_TEST_CASES))(
'$title',
async ({ context }) => {
const applicationUniversalIdentifier =
getTargetApplicationUniversalIdentifier(
context.registrationOwnership,
);
const filesBefore = await countApplicationFiles(
applicationUniversalIdentifier,
);
await applyRegistrationLink(context);
await applyRegistrationOwnership(context.registrationOwnership);
const { errors } = await createApplicationFileUploads({
applicationUniversalIdentifier,
files: [
{
fileFolder: 'BuiltLogicFunction',
filePath: HANDLER_PATH,
size: 10,
},
{ fileFolder: 'Dependencies', filePath: 'package.json', size: 10 },
],
expectToFail: true,
});
expectOneNotInternalServerErrorSnapshot({ errors, normalizeMessage });
expect(
await countApplicationFiles(applicationUniversalIdentifier),
).toBe(filesBefore);
},
30000,
);
});
describe('completeApplicationFileUploads', () => {
it.each(eachTestingContextFilter(FAILING_TEST_CASES))(
'$title',
async ({ context }) => {
await applyRegistrationLink(context);
// Reserving while the row is unlinked also proves the owner is still
// allowed to upload when the registration only matches by identifier.
const pendingFileId = await reservePendingHandlerUpload();
await applyRegistrationOwnership(context.registrationOwnership);
const { errors } = await completeApplicationFileUploads({
applicationUniversalIdentifier:
getTargetApplicationUniversalIdentifier(
context.registrationOwnership,
),
fileIds: [pendingFileId],
expectToFail: true,
});
expectOneNotInternalServerErrorSnapshot({ errors, normalizeMessage });
expect(await getFileStatus(pendingFileId)).toBe('PENDING');
},
30000,
);
});
});

View File

@@ -0,0 +1,110 @@
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util';
import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util';
import { createAppTarball } from 'test/integration/metadata/suites/application/utils/create-app-tarball.util';
import { installApplication } from 'test/integration/metadata/suites/application/utils/install-application.util';
import { uploadAppTarball } from 'test/integration/metadata/suites/application/utils/upload-app-tarball.util';
import { scrubSemverVersions } from 'test/utils/scrub-semver-versions.util';
// Fixed identifiers keep the exception messages, and therefore the error
// snapshots, stable across runs.
const APP_UNIVERSAL_IDENTIFIER = '20202020-6b0e-4b1e-9d3a-000000002931';
const ROLE_UNIVERSAL_IDENTIFIER = '20202020-6b0e-4b1e-9d3a-000000002932';
// The upload flow runs cache-lock retries with real delays, so fake timers
// would hang it — mirror the other application suites.
jest.setTimeout(120000);
const buildTarball = (version: string): Promise<Buffer> =>
createAppTarball({
'manifest.json': JSON.stringify(
buildBaseManifest({
appId: APP_UNIVERSAL_IDENTIFIER,
roleId: ROLE_UNIVERSAL_IDENTIFIER,
}),
),
'package.json': JSON.stringify({
name: 'test-version-progression',
version,
}),
});
const findApplicationState = async (): Promise<string> => {
const [application] = await globalThis.testDataSource.query(
`SELECT state FROM core."application" WHERE "universalIdentifier" = $1`,
[APP_UNIVERSAL_IDENTIFIER],
);
return application.state;
};
describe('Application version progression gate', () => {
beforeAll(async () => {
jest.useRealTimers();
const tarball = await buildTarball('1.0.0');
await uploadAppTarball({
tarballBuffer: tarball,
universalIdentifier: APP_UNIVERSAL_IDENTIFIER,
});
});
afterAll(async () => {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: APP_UNIVERSAL_IDENTIFIER,
});
jest.useFakeTimers();
});
it('rejects re-deploying the currently deployed version', async () => {
const tarball = await buildTarball('1.0.0');
const { errors } = await uploadAppTarball({
tarballBuffer: tarball,
universalIdentifier: APP_UNIVERSAL_IDENTIFIER,
expectToFail: true,
});
expectOneNotInternalServerErrorSnapshot({
errors,
normalizeMessage: scrubSemverVersions,
});
});
it('rejects deploying a version lower than the deployed one', async () => {
const tarball = await buildTarball('0.9.0');
const { errors } = await uploadAppTarball({
tarballBuffer: tarball,
universalIdentifier: APP_UNIVERSAL_IDENTIFIER,
expectToFail: true,
});
expectOneNotInternalServerErrorSnapshot({
errors,
normalizeMessage: scrubSemverVersions,
});
});
it('rejects re-installing the already installed version', async () => {
await installApplication({
input: { universalIdentifier: APP_UNIVERSAL_IDENTIFIER },
});
expect(await findApplicationState()).toBe('INSTALLED');
const { errors } = await installApplication({
input: { universalIdentifier: APP_UNIVERSAL_IDENTIFIER },
expectToFail: true,
});
expectOneNotInternalServerErrorSnapshot({
errors,
normalizeMessage: scrubSemverVersions,
});
expect(await findApplicationState()).toBe('INSTALLED');
});
});

View File

@@ -0,0 +1,165 @@
import crypto from 'crypto';
import { promises as fs } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import * as tar from 'tar';
import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util';
import { installApplication } from 'test/integration/metadata/suites/application/utils/install-application.util';
import { uploadAppTarball } from 'test/integration/metadata/suites/application/utils/upload-app-tarball.util';
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
const createTestTarball = async (
files: Record<string, string>,
): Promise<Buffer> => {
const tempId = crypto.randomUUID();
const sourceDir = join(tmpdir(), `test-tarball-src-${tempId}`);
const tarballPath = join(tmpdir(), `test-tarball-${tempId}.tar.gz`);
await fs.mkdir(sourceDir, { recursive: true });
for (const [name, content] of Object.entries(files)) {
const filePath = join(sourceDir, name);
const dir = filePath.substring(0, filePath.lastIndexOf('/'));
if (dir !== sourceDir) {
await fs.mkdir(dir, { recursive: true });
}
await fs.writeFile(filePath, content);
}
await tar.create(
{
file: tarballPath,
gzip: true,
cwd: sourceDir,
},
Object.keys(files),
);
const buffer = await fs.readFile(tarballPath);
await fs.rm(sourceDir, { recursive: true, force: true });
await fs.rm(tarballPath, { force: true });
return buffer;
};
const buildManifestWithCrossEntityIdentifierConflict = (
universalIdentifier: string,
roleUniversalIdentifier: string,
duplicatedUniversalIdentifier: string,
) =>
JSON.stringify({
application: {
universalIdentifier,
displayName: 'Test App With Cross Entity Identifier Conflict',
description:
'A test app whose manifest reuses a universalIdentifier across entity types',
icon: 'IconTestPipe',
defaultRoleUniversalIdentifier: roleUniversalIdentifier,
applicationVariables: {},
packageJsonChecksum: null,
yarnLockChecksum: null,
},
roles: [
{
universalIdentifier: roleUniversalIdentifier,
label: 'First Role',
description: 'First role',
},
{
universalIdentifier: duplicatedUniversalIdentifier,
label: 'Second Role',
description: 'Second role',
objectPermissions: [
{
universalIdentifier: duplicatedUniversalIdentifier,
objectUniversalIdentifier:
STANDARD_OBJECTS.company.universalIdentifier,
canReadObjectRecords: true,
canUpdateObjectRecords: false,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
],
},
],
skills: [],
agents: [],
objects: [],
fields: [],
logicFunctions: [],
frontComponents: [],
publicAssets: [],
views: [],
navigationMenuItems: [],
pageLayouts: [],
pageLayoutTabs: [],
pageLayoutWidgets: [],
});
describe('Install application should return structured validation errors', () => {
const createdApplicationUniversalIdentifiers: string[] = [];
beforeAll(() => {
jest.useRealTimers();
});
afterAll(async () => {
for (const uid of createdApplicationUniversalIdentifiers) {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: uid,
});
}
jest.useFakeTimers();
});
it('should return METADATA_VALIDATION_FAILED with structured errors when installing an app whose manifest has validation errors', async () => {
const universalIdentifier = crypto.randomUUID();
const roleUniversalIdentifier = crypto.randomUUID();
const duplicatedUniversalIdentifier = crypto.randomUUID();
const manifest = buildManifestWithCrossEntityIdentifierConflict(
universalIdentifier,
roleUniversalIdentifier,
duplicatedUniversalIdentifier,
);
const tarball = await createTestTarball({
'manifest.json': manifest,
'package.json': JSON.stringify({
name: 'test-cross-entity-identifier-conflict-app',
version: '1.0.0',
}),
});
const uploadResult = await uploadAppTarball({
tarballBuffer: tarball,
universalIdentifier,
});
expect(uploadResult.errors).toBeUndefined();
expect(uploadResult.data?.uploadAppTarball.id).toBeDefined();
createdApplicationUniversalIdentifiers.push(universalIdentifier);
const { errors } = await installApplication({
input: {
universalIdentifier,
},
expectToFail: true,
});
expect(errors).toBeDefined();
expect(errors.length).toBe(1);
const [error] = errors;
expect(error.extensions.code).toBe('METADATA_VALIDATION_FAILED');
expect(error.extensions.errors).toBeDefined();
expect(error.extensions.summary).toBeDefined();
expect(error.extensions.summary.totalErrors).toBeGreaterThan(0);
expect(error.extensions.message).toMatch(/Validation failed for/);
}, 120000);
});

View File

@@ -0,0 +1,61 @@
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util';
import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util';
import { installApplication } from 'test/integration/metadata/suites/application/utils/install-application.util';
import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util';
import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util';
import { v4 as uuidv4 } from 'uuid';
const INVALID_UUID_APP_ID = uuidv4();
const INVALID_UUID_ROLE_ID = uuidv4();
describe('Install application should fail when entity does not exist', () => {
beforeEach(async () => {
await setupApplicationForSync({
applicationUniversalIdentifier: INVALID_UUID_APP_ID,
name: 'Test Invalid UUID App',
description: 'App for testing UUID v4 validation',
sourcePath: 'test-invalid-uuid',
});
}, 60000);
afterEach(async () => {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: INVALID_UUID_APP_ID,
});
});
it('should fail with execution error when installing non-existent app registration', async () => {
const { errors } = await installApplication({
expectToFail: true,
input: {
universalIdentifier: '20202020-0000-0000-0000-000000000000',
},
});
expectOneNotInternalServerErrorSnapshot({ errors });
});
it('should fail when a role has an invalid universalIdentifier', async () => {
const manifest = buildBaseManifest({
appId: INVALID_UUID_APP_ID,
roleId: INVALID_UUID_ROLE_ID,
overrides: {
roles: [
{
universalIdentifier: 'not-a-valid-uuid',
label: 'Invalid UUID Role',
description: 'Role with invalid universalIdentifier',
},
],
},
});
const { errors } = await syncApplication({
manifest,
expectToFail: true,
});
expectOneNotInternalServerErrorSnapshot({ errors });
}, 60000);
});

View File

@@ -0,0 +1,90 @@
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util';
import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util';
import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util';
import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util';
import { type Manifest } from 'twenty-shared/application';
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
import { PageLayoutTabLayoutMode, PageLayoutType } from 'twenty-shared/types';
import { v4 as uuidv4 } from 'uuid';
const TEST_APP_ID = uuidv4();
const TEST_ROLE_ID = uuidv4();
const TEST_LAYOUT_ID = uuidv4();
const TEST_TAB_ID = uuidv4();
const TEST_WIDGET_ID = 'a0000000-0000-4000-8000-00000000000a';
const UNKNOWN_VIEW_ID = 'b0000000-0000-4000-8000-00000000000b';
const PERSON_OBJECT_UNIVERSAL_IDENTIFIER =
STANDARD_OBJECTS.person.universalIdentifier;
const buildManifest = (overrides?: Partial<Pick<Manifest, 'pageLayouts'>>) =>
buildBaseManifest({
appId: TEST_APP_ID,
roleId: TEST_ROLE_ID,
overrides,
});
describe('Failing manifest sync - RECORD_TABLE widget with unknown view universal identifier', () => {
beforeEach(async () => {
await setupApplicationForSync({
applicationUniversalIdentifier: TEST_APP_ID,
name: 'Test Application',
description:
'App for testing a RECORD_TABLE widget referencing an unknown view universal identifier',
sourcePath:
'test-manifest-record-table-unknown-view-universal-identifier',
});
}, 60000);
afterEach(async () => {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: TEST_APP_ID,
});
});
it('rejects a dashboard RECORD_TABLE widget whose target view does not exist', async () => {
const { errors } = await syncApplication({
manifest: buildManifest({
pageLayouts: [
{
universalIdentifier: TEST_LAYOUT_ID,
name: 'RT dashboard',
type: PageLayoutType.DASHBOARD,
tabs: [
{
universalIdentifier: TEST_TAB_ID,
title: 'Tables',
position: 0,
layoutMode: PageLayoutTabLayoutMode.GRID,
widgets: [
{
universalIdentifier: TEST_WIDGET_ID,
title: 'RT test table',
type: 'RECORD_TABLE',
objectUniversalIdentifier:
PERSON_OBJECT_UNIVERSAL_IDENTIFIER,
position: {
layoutMode: PageLayoutTabLayoutMode.GRID,
row: 0,
column: 0,
rowSpan: 4,
columnSpan: 6,
},
configuration: {
configurationType: 'RECORD_TABLE',
viewUniversalIdentifier: UNKNOWN_VIEW_ID,
},
},
],
},
],
},
],
}),
expectToFail: true,
});
expectOneNotInternalServerErrorSnapshot({ errors });
}, 60000);
});

View File

@@ -0,0 +1,137 @@
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util';
import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util';
import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util';
import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util';
import { type FieldManifest, type Manifest } from 'twenty-shared/application';
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
import { FieldMetadataType } from 'twenty-shared/types';
import { v4 as uuidv4 } from 'uuid';
const TEST_APP_ID = uuidv4();
const TEST_ROLE_ID = uuidv4();
const TEST_FIELD_ID = uuidv4();
const TEST_VIEW_FIELD_ID = uuidv4();
const TEST_SECOND_VIEW_FIELD_ID = uuidv4();
const PERSON_OBJECT_UNIVERSAL_IDENTIFIER =
STANDARD_OBJECTS.person.universalIdentifier;
const PERSON_RECORD_PAGE_VIEW_UNIVERSAL_IDENTIFIER =
STANDARD_OBJECTS.person.views.personRecordPageFields.universalIdentifier;
const PERSON_RECORD_PAGE_GENERAL_GROUP_UNIVERSAL_IDENTIFIER =
STANDARD_OBJECTS.person.views.personRecordPageFields.viewFieldGroups.general
.universalIdentifier;
const CUSTOM_FIELD_NAME = 'integrationContributedColumn';
const personFieldManifest: FieldManifest = {
universalIdentifier: TEST_FIELD_ID,
type: FieldMetadataType.TEXT,
name: CUSTOM_FIELD_NAME,
label: 'Integration Contributed Column',
description: 'Custom field contributed to the standard Person object',
icon: 'IconStar',
objectUniversalIdentifier: PERSON_OBJECT_UNIVERSAL_IDENTIFIER,
};
const buildManifest = (
overrides?: Partial<Pick<Manifest, 'fields' | 'viewFields'>>,
) =>
buildBaseManifest({
appId: TEST_APP_ID,
roleId: TEST_ROLE_ID,
overrides,
});
describe('Failing manifest update - standalone view fields on existing views', () => {
beforeEach(async () => {
await setupApplicationForSync({
applicationUniversalIdentifier: TEST_APP_ID,
name: 'Test Application',
description: 'App for testing standalone view field manifest updates',
sourcePath: 'test-manifest-update-view-field',
});
}, 60000);
afterEach(async () => {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: TEST_APP_ID,
});
});
it('rejects a standalone view field claiming the pair the engine emits for the created field', async () => {
const { errors } = await syncApplication({
manifest: buildManifest({
fields: [personFieldManifest],
viewFields: [
{
universalIdentifier: TEST_VIEW_FIELD_ID,
viewUniversalIdentifier:
PERSON_RECORD_PAGE_VIEW_UNIVERSAL_IDENTIFIER,
viewFieldGroupUniversalIdentifier:
PERSON_RECORD_PAGE_GENERAL_GROUP_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier: TEST_FIELD_ID,
position: 10,
isVisible: true,
},
],
}),
expectToFail: true,
});
expectOneNotInternalServerErrorSnapshot({ errors });
}, 60000);
it('rejects two standalone view fields targeting the same field on the same view', async () => {
const { errors } = await syncApplication({
manifest: buildManifest({
fields: [personFieldManifest],
viewFields: [
{
universalIdentifier: TEST_VIEW_FIELD_ID,
viewUniversalIdentifier:
PERSON_RECORD_PAGE_VIEW_UNIVERSAL_IDENTIFIER,
viewFieldGroupUniversalIdentifier:
PERSON_RECORD_PAGE_GENERAL_GROUP_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier: TEST_FIELD_ID,
position: 10,
isVisible: true,
},
{
universalIdentifier: TEST_SECOND_VIEW_FIELD_ID,
viewUniversalIdentifier:
PERSON_RECORD_PAGE_VIEW_UNIVERSAL_IDENTIFIER,
viewFieldGroupUniversalIdentifier:
PERSON_RECORD_PAGE_GENERAL_GROUP_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier: TEST_FIELD_ID,
position: 11,
isVisible: true,
},
],
}),
expectToFail: true,
});
expectOneNotInternalServerErrorSnapshot({ errors });
}, 60000);
it('rejects a standalone view field whose target view does not exist', async () => {
const { errors } = await syncApplication({
manifest: buildManifest({
fields: [personFieldManifest],
viewFields: [
{
universalIdentifier: TEST_VIEW_FIELD_ID,
viewUniversalIdentifier: uuidv4(),
fieldMetadataUniversalIdentifier: TEST_FIELD_ID,
position: 10,
isVisible: true,
},
],
}),
expectToFail: true,
});
expectOneNotInternalServerErrorSnapshot({ errors });
}, 60000);
});

View File

@@ -0,0 +1,140 @@
import request from 'supertest';
import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util';
import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util';
import { uploadApplicationFile } from 'test/integration/metadata/suites/application/utils/upload-application-file.util';
import { expectOneNotInternalServerErrorHttpResponseSnapshot } from 'test/integration/utils/expect-one-not-internal-server-error-http-response-snapshot.util';
import {
type EachTestingContext,
eachTestingContextFilter,
} from 'twenty-shared/testing';
import { v4 as uuidv4 } from 'uuid';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
const TEST_APP_UID = uuidv4();
const TEST_WORKSPACE_ID = SEED_APPLE_WORKSPACE_ID;
const UNKNOWN_WORKSPACE_ID = uuidv4();
const UNKNOWN_APPLICATION_ID = uuidv4();
const PUBLIC_ASSET_PATH = 'assets/logo.svg';
const PUBLIC_ASSET_CONTENT = '<svg><circle r="10" /></svg>';
const PERSISTED_PUBLIC_ASSET_CONTENT = '<svg><circle r="10"></circle></svg>';
const PUBLIC_ASSET_CONTENT_TYPE = 'image/svg+xml';
type FailingCase = {
buildUrl: (validApplicationId: string) => string;
};
const FAILING_CASES: EachTestingContext<FailingCase>[] = [
{
title: 'when path contains a leading parent-directory segment (..)',
context: {
buildUrl: (applicationId) =>
`/public-assets/${TEST_WORKSPACE_ID}/${applicationId}/..`,
},
},
{
title: 'when path contains multiple upward traversal segments (../../)',
context: {
buildUrl: (applicationId) =>
`/public-assets/${TEST_WORKSPACE_ID}/${applicationId}/../../sensitive-file`,
},
},
{
title: 'when path attempts to escape into another file folder',
context: {
buildUrl: (applicationId) =>
`/public-assets/${TEST_WORKSPACE_ID}/${applicationId}/../workflow/secret.json`,
},
},
{
title: 'when path contains URL-encoded backslash traversal',
context: {
buildUrl: (applicationId) =>
`/public-assets/${TEST_WORKSPACE_ID}/${applicationId}/..%5C..%5Cetc%5Cpasswd`,
},
},
{
title: 'when the requested asset does not exist',
context: {
buildUrl: (applicationId) =>
`/public-assets/${TEST_WORKSPACE_ID}/${applicationId}/assets/does-not-exist.svg`,
},
},
{
title: 'when the workspaceId does not match any workspace',
context: {
buildUrl: (applicationId) =>
`/public-assets/${UNKNOWN_WORKSPACE_ID}/${applicationId}/${PUBLIC_ASSET_PATH}`,
},
},
{
title: 'when the applicationId does not match any application',
context: {
buildUrl: () =>
`/public-assets/${TEST_WORKSPACE_ID}/${UNKNOWN_APPLICATION_ID}/${PUBLIC_ASSET_PATH}`,
},
},
];
describe('Public assets controller download should fail', () => {
let applicationId: string;
beforeAll(async () => {
await setupApplicationForSync({
applicationUniversalIdentifier: TEST_APP_UID,
name: 'Test Public Assets Download Failure App',
description: 'App for testing failing public-assets controller downloads',
sourcePath: 'test-public-assets-download-failure',
});
const [{ id }] = await globalThis.testDataSource.query(
`SELECT id FROM core."application" WHERE "universalIdentifier" = $1`,
[TEST_APP_UID],
);
applicationId = id;
jest.useRealTimers();
await uploadApplicationFile({
applicationUniversalIdentifier: TEST_APP_UID,
fileFolder: 'PublicAsset',
filePath: PUBLIC_ASSET_PATH,
fileBuffer: Buffer.from(PUBLIC_ASSET_CONTENT),
filename: 'logo.svg',
contentType: PUBLIC_ASSET_CONTENT_TYPE,
expectToFail: false,
});
jest.useFakeTimers();
}, 60000);
afterAll(async () => {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: TEST_APP_UID,
});
});
it.each(eachTestingContextFilter(FAILING_CASES))(
'$title',
async ({ context }) => {
jest.useRealTimers();
const response = await request(global.app.getHttpServer()).get(
context.buildUrl(applicationId),
);
jest.useFakeTimers();
// The legitimate asset content must never leak through a failure path.
expect(response.text).not.toContain(PERSISTED_PUBLIC_ASSET_CONTENT);
expectOneNotInternalServerErrorHttpResponseSnapshot({
status: response.status,
body: response.body,
});
},
30000,
);
});

View File

@@ -0,0 +1,136 @@
import { SystemPermissionFlag } from 'twenty-shared/constants';
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util';
import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util';
import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util';
import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util';
import { STANDARD_ROLE } from 'src/engine/workspace-manager/twenty-standard-application/constants/standard-role.constant';
const TEST_APP_ID = 'a1b2c3d4-0010-4000-a000-000000000010';
const TEST_ROLE_ID = 'a1b2c3d4-0010-4000-a000-000000000011';
const TEST_OBJECT_PERMISSION_ID = 'a1b2c3d4-0010-4000-a000-000000000013';
const TEST_FIELD_PERMISSION_ID = 'a1b2c3d4-0010-4000-a000-000000000014';
const FAKE_OBJECT_ID = 'a1b2c3d4-0010-4000-a000-000000000020';
const FAKE_FIELD_ID = 'a1b2c3d4-0010-4000-a000-000000000021';
describe('Sync application should fail when creating permissions on a standard role from another app', () => {
beforeAll(async () => {
await setupApplicationForSync({
applicationUniversalIdentifier: TEST_APP_ID,
name: 'Test Cross App Permission App',
description: 'App for testing cross-app permission flag on standard role',
sourcePath: 'test-cross-app-permission',
});
}, 60000);
afterAll(async () => {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: TEST_APP_ID,
});
});
it('should fail when adding a permission flag under the standard admin role', async () => {
const manifest = buildBaseManifest({
appId: TEST_APP_ID,
roleId: TEST_ROLE_ID,
overrides: {
roles: [
{
universalIdentifier: TEST_ROLE_ID,
label: 'App Default Role',
description: 'Default role for the test app',
},
{
universalIdentifier: STANDARD_ROLE.admin.universalIdentifier,
label: 'Stolen Admin Role',
description:
'Attempts to add permissions to the standard admin role',
permissionFlagUniversalIdentifiers: [
SystemPermissionFlag.WORKSPACE,
],
},
],
},
});
const { errors } = await syncApplication({
manifest,
expectToFail: true,
});
expectOneNotInternalServerErrorSnapshot({ errors });
}, 60000);
it('should fail when adding an object permission under the standard admin role', async () => {
const manifest = buildBaseManifest({
appId: TEST_APP_ID,
roleId: TEST_ROLE_ID,
overrides: {
roles: [
{
universalIdentifier: TEST_ROLE_ID,
label: 'App Default Role',
description: 'Default role for the test app',
},
{
universalIdentifier: STANDARD_ROLE.admin.universalIdentifier,
label: 'Stolen Admin Role',
description:
'Attempts to add object permission to the standard admin role',
objectPermissions: [
{
universalIdentifier: TEST_OBJECT_PERMISSION_ID,
objectUniversalIdentifier: FAKE_OBJECT_ID,
},
],
},
],
},
});
const { errors } = await syncApplication({
manifest,
expectToFail: true,
});
expectOneNotInternalServerErrorSnapshot({ errors });
}, 60000);
it('should fail when adding a field permission under the standard admin role', async () => {
const manifest = buildBaseManifest({
appId: TEST_APP_ID,
roleId: TEST_ROLE_ID,
overrides: {
roles: [
{
universalIdentifier: TEST_ROLE_ID,
label: 'App Default Role',
description: 'Default role for the test app',
},
{
universalIdentifier: STANDARD_ROLE.admin.universalIdentifier,
label: 'Stolen Admin Role',
description:
'Attempts to add field permission to the standard admin role',
fieldPermissions: [
{
universalIdentifier: TEST_FIELD_PERMISSION_ID,
objectUniversalIdentifier: FAKE_OBJECT_ID,
fieldUniversalIdentifier: FAKE_FIELD_ID,
},
],
},
],
},
});
const { errors } = await syncApplication({
manifest,
expectToFail: true,
});
expectOneNotInternalServerErrorSnapshot({ errors });
}, 60000);
});

View File

@@ -0,0 +1,209 @@
import { SystemPermissionFlag } from 'twenty-shared/constants';
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util';
import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util';
import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util';
import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util';
import { STANDARD_ROLE } from 'src/engine/workspace-manager/twenty-standard-application/constants/standard-role.constant';
const TEST_APP_ID = 'a1b2c3d4-0020-4000-a000-000000000010';
const TEST_ROLE_ID = 'a1b2c3d4-0020-4000-a000-000000000011';
const TEST_OBJECT_PERMISSION_ID = 'a1b2c3d4-0020-4000-a000-000000000013';
const TEST_FIELD_PERMISSION_ID = 'a1b2c3d4-0020-4000-a000-000000000014';
const COMPANY_OBJECT_ID = STANDARD_OBJECTS.company.universalIdentifier;
const COMPANY_NAME_FIELD_ID =
STANDARD_OBJECTS.company.fields.name.universalIdentifier;
describe('Sync application should fail when retargeting existing permissions to a standard role on update', () => {
beforeAll(async () => {
await setupApplicationForSync({
applicationUniversalIdentifier: TEST_APP_ID,
name: 'Test Cross App Permission Retarget App',
description: 'App for testing cross-app permission retargeting on update',
sourcePath: 'test-cross-app-retarget',
});
}, 60000);
afterAll(async () => {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: TEST_APP_ID,
});
});
it('should fail when retargeting a permission flag from own role to the standard admin role', async () => {
const initialManifest = buildBaseManifest({
appId: TEST_APP_ID,
roleId: TEST_ROLE_ID,
overrides: {
roles: [
{
universalIdentifier: TEST_ROLE_ID,
label: 'App Default Role',
description: 'Default role for the test app',
permissionFlagUniversalIdentifiers: [
SystemPermissionFlag.WORKSPACE,
],
},
],
},
});
await syncApplication({
manifest: initialManifest,
expectToFail: false,
});
const retargetManifest = buildBaseManifest({
appId: TEST_APP_ID,
roleId: TEST_ROLE_ID,
overrides: {
roles: [
{
universalIdentifier: TEST_ROLE_ID,
label: 'App Default Role',
description: 'Default role for the test app',
},
{
universalIdentifier: STANDARD_ROLE.admin.universalIdentifier,
label: 'Admin',
description: 'Attempts to retarget permission flag to admin role',
permissionFlagUniversalIdentifiers: [
SystemPermissionFlag.WORKSPACE,
],
},
],
},
});
const { errors } = await syncApplication({
manifest: retargetManifest,
expectToFail: true,
});
expectOneNotInternalServerErrorSnapshot({ errors });
}, 60000);
it('should fail when retargeting an object permission from own role to the standard admin role', async () => {
const initialManifest = buildBaseManifest({
appId: TEST_APP_ID,
roleId: TEST_ROLE_ID,
overrides: {
roles: [
{
universalIdentifier: TEST_ROLE_ID,
label: 'App Default Role',
description: 'Default role for the test app',
objectPermissions: [
{
universalIdentifier: TEST_OBJECT_PERMISSION_ID,
objectUniversalIdentifier: COMPANY_OBJECT_ID,
},
],
},
],
},
});
await syncApplication({
manifest: initialManifest,
expectToFail: false,
});
const retargetManifest = buildBaseManifest({
appId: TEST_APP_ID,
roleId: TEST_ROLE_ID,
overrides: {
roles: [
{
universalIdentifier: TEST_ROLE_ID,
label: 'App Default Role',
description: 'Default role for the test app',
},
{
universalIdentifier: STANDARD_ROLE.admin.universalIdentifier,
label: 'Admin',
description: 'Attempts to retarget object permission to admin role',
objectPermissions: [
{
universalIdentifier: TEST_OBJECT_PERMISSION_ID,
objectUniversalIdentifier: COMPANY_OBJECT_ID,
},
],
},
],
},
});
const { errors } = await syncApplication({
manifest: retargetManifest,
expectToFail: true,
});
expectOneNotInternalServerErrorSnapshot({ errors });
}, 60000);
it('should fail when retargeting a field permission from own role to the standard admin role', async () => {
const initialManifest = buildBaseManifest({
appId: TEST_APP_ID,
roleId: TEST_ROLE_ID,
overrides: {
roles: [
{
universalIdentifier: TEST_ROLE_ID,
label: 'App Default Role',
description: 'Default role for the test app',
fieldPermissions: [
{
universalIdentifier: TEST_FIELD_PERMISSION_ID,
objectUniversalIdentifier: COMPANY_OBJECT_ID,
fieldUniversalIdentifier: COMPANY_NAME_FIELD_ID,
},
],
},
],
},
});
await syncApplication({
manifest: initialManifest,
expectToFail: false,
});
const retargetManifest = buildBaseManifest({
appId: TEST_APP_ID,
roleId: TEST_ROLE_ID,
overrides: {
roles: [
{
universalIdentifier: TEST_ROLE_ID,
label: 'App Default Role',
description: 'Default role for the test app',
},
{
universalIdentifier: STANDARD_ROLE.admin.universalIdentifier,
label: 'Admin',
description: 'Attempts to retarget field permission to admin role',
fieldPermissions: [
{
universalIdentifier: TEST_FIELD_PERMISSION_ID,
objectUniversalIdentifier: COMPANY_OBJECT_ID,
fieldUniversalIdentifier: COMPANY_NAME_FIELD_ID,
},
],
},
],
},
});
const { errors } = await syncApplication({
manifest: retargetManifest,
expectToFail: true,
});
expectOneNotInternalServerErrorSnapshot({ errors });
}, 60000);
});

View File

@@ -0,0 +1,123 @@
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util';
import { buildDefaultObjectManifest } from 'test/integration/metadata/suites/application/utils/build-default-object-manifest.util';
import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util';
import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util';
import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util';
import {
getSystemRelationFieldUniversalIdentifier,
type ObjectManifest,
} from 'twenty-shared/application';
import { STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-shared/metadata';
import { FieldMetadataType } from 'twenty-shared/types';
import { v4 as uuidv4 } from 'uuid';
const TEST_APP_ID = uuidv4();
const TEST_ROLE_ID = uuidv4();
const OBJECT_UNIVERSAL_IDENTIFIER = uuidv4();
const OBJECT_NAME_SINGULAR = 'defaultRelationCollisionTarget';
const OBJECT_NAME_PLURAL = 'defaultRelationCollisionTargets';
// The engine forward field of the attachment default relation derives its
// universal identifier name-free from the host and relation target object
// identifiers, so a manifest field can only squat it by pinning that exact
// derived value.
const DERIVED_ATTACHMENTS_UNIVERSAL_IDENTIFIER =
getSystemRelationFieldUniversalIdentifier({
applicationUniversalIdentifier: TEST_APP_ID,
objectUniversalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetObjectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.attachment,
});
const buildObjectWithAttachmentsField = ({
attachmentsFieldUniversalIdentifier,
}: {
attachmentsFieldUniversalIdentifier: string;
}): ObjectManifest =>
buildDefaultObjectManifest({
applicationUniversalIdentifier: TEST_APP_ID,
nameSingular: OBJECT_NAME_SINGULAR,
namePlural: OBJECT_NAME_PLURAL,
labelSingular: 'Default Relation Collision Target',
labelPlural: 'Default Relation Collision Targets',
description: 'Object used to test default relation name collisions',
universalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER,
additionalFields: [
{
universalIdentifier: attachmentsFieldUniversalIdentifier,
type: FieldMetadataType.TEXT,
name: 'attachments',
label: 'Caller Attachments',
isNullable: true,
},
],
});
// The default relations are engine-owned: the create side-effect handler
// always emits them, so a manifest field can never take their place. It either
// collides on name (random identifier) or on universal identifier (derived
// identifier), and both collisions hard-fail the sync.
describe('Sync application default relation name collision', () => {
beforeAll(async () => {
await setupApplicationForSync({
applicationUniversalIdentifier: TEST_APP_ID,
name: 'Test Default Relation Name Collision App',
description: 'App for testing default relation name collisions',
sourcePath: 'test-default-relation-name-collision',
});
}, 60000);
afterAll(async () => {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: TEST_APP_ID,
});
});
it('should reject a manifest field named after a default relation when it carries a pinned universal identifier', async () => {
// The pinned identifier differs from the derived forward field identifier,
// so the side-effect "attachments" RELATION field and the manifest TEXT
// field coexist in the create bucket and collide on name in the validator.
const { errors } = await syncApplication({
manifest: buildBaseManifest({
appId: TEST_APP_ID,
roleId: TEST_ROLE_ID,
overrides: {
objects: [
buildObjectWithAttachmentsField({
attachmentsFieldUniversalIdentifier: uuidv4(),
}),
],
},
}),
expectToFail: true,
});
expectOneNotInternalServerErrorSnapshot({ errors });
}, 60000);
it('should reject a manifest field named after a default relation when it carries the derived universal identifier', async () => {
// With the derived identifier the manifest field collides with the
// side-effect forward field on universal identifier: the engine merge
// detects a caller entity squatting a system-reserved identifier and
// hard-fails (RESERVED_SYSTEM_UNIVERSAL_IDENTIFIER).
const { errors } = await syncApplication({
manifest: buildBaseManifest({
appId: TEST_APP_ID,
roleId: TEST_ROLE_ID,
overrides: {
objects: [
buildObjectWithAttachmentsField({
attachmentsFieldUniversalIdentifier:
DERIVED_ATTACHMENTS_UNIVERSAL_IDENTIFIER,
}),
],
},
}),
expectToFail: true,
});
expectOneNotInternalServerErrorSnapshot({ errors });
}, 60000);
});

View File

@@ -0,0 +1,106 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util';
import { buildDefaultObjectManifest } from 'test/integration/metadata/suites/application/utils/build-default-object-manifest.util';
import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util';
import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util';
import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util';
const TEST_APP_ID = 'b1b2c3d4-0001-4000-a000-000000000001';
const TEST_ROLE_ID = 'b1b2c3d4-0002-4000-a000-000000000002';
const DUPLICATED_OBJECT_UNIVERSAL_IDENTIFIER =
'b1b2c3d4-0003-4000-a000-000000000003';
const DUPLICATED_FIELD_UNIVERSAL_IDENTIFIER =
'b1b2c3d4-0004-4000-a000-000000000004';
describe('Sync application should surface a human error on flat-entity map conflicts', () => {
beforeAll(async () => {
await setupApplicationForSync({
applicationUniversalIdentifier: TEST_APP_ID,
name: 'Test Flat Entity Map Conflict App',
description: 'App for testing flat-entity map conflict error formatting',
sourcePath: 'test-flat-entity-map-conflict',
});
}, 60000);
afterAll(async () => {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: TEST_APP_ID,
});
});
it('should fail with an installation error naming the object when two objects share a universalIdentifier', async () => {
const firstObject = buildDefaultObjectManifest({
applicationUniversalIdentifier: TEST_APP_ID,
nameSingular: 'invoice',
namePlural: 'invoices',
labelSingular: 'Invoice',
labelPlural: 'Invoices',
universalIdentifier: DUPLICATED_OBJECT_UNIVERSAL_IDENTIFIER,
});
const conflictingObject = buildDefaultObjectManifest({
applicationUniversalIdentifier: TEST_APP_ID,
nameSingular: 'invoiceDuplicate',
namePlural: 'invoiceDuplicates',
labelSingular: 'Invoice Duplicate',
labelPlural: 'Invoice Duplicates',
universalIdentifier: DUPLICATED_OBJECT_UNIVERSAL_IDENTIFIER,
});
const manifest = buildBaseManifest({
appId: TEST_APP_ID,
roleId: TEST_ROLE_ID,
overrides: {
objects: [firstObject, conflictingObject],
},
});
const { errors } = await syncApplication({
manifest,
expectToFail: true,
});
expectOneNotInternalServerErrorSnapshot({ errors });
}, 60000);
it('should fail with an installation error naming the field when two fields of an object share a universalIdentifier', async () => {
const objectWithConflictingFields = buildDefaultObjectManifest({
applicationUniversalIdentifier: TEST_APP_ID,
nameSingular: 'invoice',
namePlural: 'invoices',
labelSingular: 'Invoice',
labelPlural: 'Invoices',
additionalFields: [
{
universalIdentifier: DUPLICATED_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldMetadataType.DATE_TIME,
name: 'dueDate',
label: 'Due Date',
},
{
universalIdentifier: DUPLICATED_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldMetadataType.DATE_TIME,
name: 'dueDateDuplicate',
label: 'Due Date Duplicate',
},
],
});
const manifest = buildBaseManifest({
appId: TEST_APP_ID,
roleId: TEST_ROLE_ID,
overrides: {
objects: [objectWithConflictingFields],
},
});
const { errors } = await syncApplication({
manifest,
expectToFail: true,
});
expectOneNotInternalServerErrorSnapshot({ errors });
}, 60000);
});

View File

@@ -0,0 +1,166 @@
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util';
import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util';
import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util';
import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util';
import { type NavigationMenuItemManifest } from 'twenty-shared/application';
import {
eachTestingContextFilter,
type EachTestingContext,
} from 'twenty-shared/testing';
import { NavigationMenuItemType } from 'twenty-shared/types';
const TEST_APP_ID = 'c1b2c3d4-0001-4000-a000-000000000001';
const TEST_ROLE_ID = 'c1b2c3d4-0002-4000-a000-000000000002';
const TEST_NAVIGATION_MENU_ITEM_ID = 'c1b2c3d4-0003-4000-a000-000000000003';
type TestContext = {
navigationMenuItem: NavigationMenuItemManifest;
};
const failingNavigationMenuItemSyncTestCases: EachTestingContext<TestContext>[] =
[
{
title: 'when syncing a FOLDER item without name',
context: {
navigationMenuItem: {
universalIdentifier: TEST_NAVIGATION_MENU_ITEM_ID,
type: NavigationMenuItemType.FOLDER,
position: 0,
icon: 'IconFolder',
},
},
},
{
title: 'when syncing a FOLDER item with a blank name',
context: {
navigationMenuItem: {
universalIdentifier: TEST_NAVIGATION_MENU_ITEM_ID,
type: NavigationMenuItemType.FOLDER,
position: 0,
name: ' ',
},
},
},
{
title: 'when syncing a LINK item without link',
context: {
navigationMenuItem: {
universalIdentifier: TEST_NAVIGATION_MENU_ITEM_ID,
type: NavigationMenuItemType.LINK,
position: 0,
name: 'Link without url',
},
},
},
{
title: 'when syncing a LINK item with a link that is not a valid url',
context: {
navigationMenuItem: {
universalIdentifier: TEST_NAVIGATION_MENU_ITEM_ID,
type: NavigationMenuItemType.LINK,
position: 0,
name: 'Link with invalid url',
link: 'not a link',
},
},
},
{
title:
'when syncing an OBJECT item without targetObjectUniversalIdentifier',
context: {
navigationMenuItem: {
universalIdentifier: TEST_NAVIGATION_MENU_ITEM_ID,
type: NavigationMenuItemType.OBJECT,
position: 0,
name: 'Object without target',
},
},
},
{
title:
'when syncing an OBJECT item with a targetObjectUniversalIdentifier that is not a uuid',
context: {
navigationMenuItem: {
universalIdentifier: TEST_NAVIGATION_MENU_ITEM_ID,
type: NavigationMenuItemType.OBJECT,
position: 0,
name: 'Object with malformed target',
targetObjectUniversalIdentifier: 'not-a-uuid',
},
},
},
{
title:
'when syncing a VIEW item with a viewUniversalIdentifier that is not a uuid',
context: {
navigationMenuItem: {
universalIdentifier: TEST_NAVIGATION_MENU_ITEM_ID,
type: NavigationMenuItemType.VIEW,
position: 0,
name: 'View with malformed identifier',
viewUniversalIdentifier: 'not-a-uuid',
},
},
},
{
title:
'when syncing a PAGE_LAYOUT item with a pageLayoutUniversalIdentifier that is not a uuid',
context: {
navigationMenuItem: {
universalIdentifier: TEST_NAVIGATION_MENU_ITEM_ID,
type: NavigationMenuItemType.PAGE_LAYOUT,
position: 0,
name: 'Page layout with malformed identifier',
pageLayoutUniversalIdentifier: 'not-a-uuid',
},
},
},
{
title: 'when syncing an item with an unknown type',
context: {
navigationMenuItem: {
universalIdentifier: TEST_NAVIGATION_MENU_ITEM_ID,
type: 'UNKNOWN_NAVIGATION_MENU_ITEM_TYPE' as NavigationMenuItemType,
position: 0,
name: 'Item with unknown type',
},
},
},
];
describe('Sync application should fail on invalid navigation menu items', () => {
beforeAll(async () => {
await setupApplicationForSync({
applicationUniversalIdentifier: TEST_APP_ID,
name: 'Test Invalid Navigation Menu Item App',
description: 'App for testing navigation menu item manifest validation',
sourcePath: 'test-invalid-navigation-menu-item',
});
}, 60000);
afterAll(async () => {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: TEST_APP_ID,
});
});
it.each(eachTestingContextFilter(failingNavigationMenuItemSyncTestCases))(
'$title',
async ({ context }) => {
const { errors } = await syncApplication({
manifest: buildBaseManifest({
appId: TEST_APP_ID,
roleId: TEST_ROLE_ID,
overrides: {
navigationMenuItems: [context.navigationMenuItem],
},
}),
expectToFail: true,
});
expectOneNotInternalServerErrorSnapshot({ errors });
},
60000,
);
});

View File

@@ -0,0 +1,167 @@
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util';
import { buildDefaultObjectManifest } from 'test/integration/metadata/suites/application/utils/build-default-object-manifest.util';
import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util';
import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util';
import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util';
import {
getFieldUniversalIdentifier,
type ObjectManifest,
} from 'twenty-shared/application';
import { FieldMetadataType } from 'twenty-shared/types';
import { v4 as uuidv4 } from 'uuid';
const TEST_APP_ID = uuidv4();
const TEST_ROLE_ID = uuidv4();
const OBJECT_UNIVERSAL_IDENTIFIER = uuidv4();
const OBJECT_NAME_SINGULAR = 'searchVectorHijackTarget';
const OBJECT_NAME_PLURAL = 'searchVectorHijackTargets';
const SEARCH_VECTOR_UNIVERSAL_IDENTIFIER = getFieldUniversalIdentifier({
applicationUniversalIdentifier: TEST_APP_ID,
objectUniversalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER,
name: 'searchVector',
});
const buildCleanObject = (): ObjectManifest =>
buildDefaultObjectManifest({
applicationUniversalIdentifier: TEST_APP_ID,
nameSingular: OBJECT_NAME_SINGULAR,
namePlural: OBJECT_NAME_PLURAL,
labelSingular: 'Search Vector Hijack Target',
labelPlural: 'Search Vector Hijack Targets',
description: 'Object used to test search vector identifier hijack',
universalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER,
});
// Squat the engine-owned searchVector slot: declare a foreign, non-system
// field carrying the deterministic universal identifier the engine derives
// for searchVector. This is the exact override a malicious manifest could
// attempt on a second sync to hijack a system field.
const buildObjectHijackingSearchVectorUniversalIdentifier =
(): ObjectManifest => {
const cleanObject = buildCleanObject();
return {
...cleanObject,
fields: [
...cleanObject.fields,
{
universalIdentifier: SEARCH_VECTOR_UNIVERSAL_IDENTIFIER,
type: FieldMetadataType.TEXT,
name: 'stolenSearchVector',
label: 'Stolen Search Vector',
isNullable: true,
},
],
};
};
// Redeclare searchVector under its derived universal identifier: the builder
// matches the engine-provisioned field and emits an update, rejected because
// the manifest defaults mutate properties system fields do not allow updating.
const buildObjectAuthoringSearchVector = (): ObjectManifest => {
const cleanObject = buildCleanObject();
return {
...cleanObject,
fields: [
...cleanObject.fields,
{
universalIdentifier: SEARCH_VECTOR_UNIVERSAL_IDENTIFIER,
type: FieldMetadataType.TS_VECTOR,
name: 'searchVector',
label: 'Search vector',
},
],
};
};
// Declare a field reusing the reserved searchVector name under a foreign
// universal identifier: the builder emits a create colliding with the
// engine-provisioned field name.
const buildObjectCreatingForeignSearchVector = (): ObjectManifest => {
const cleanObject = buildCleanObject();
return {
...cleanObject,
fields: [
...cleanObject.fields,
{
universalIdentifier: uuidv4(),
type: FieldMetadataType.TEXT,
name: 'searchVector',
label: 'Foreign Search Vector',
isNullable: true,
},
],
};
};
describe('Sync application should reject hijacking the searchVector system field', () => {
beforeAll(async () => {
await setupApplicationForSync({
applicationUniversalIdentifier: TEST_APP_ID,
name: 'Test Search Vector Hijack App',
description: 'App for testing search vector identifier hijack',
sourcePath: 'test-search-vector-hijack',
});
await syncApplication({
expectToFail: false,
manifest: buildBaseManifest({
appId: TEST_APP_ID,
roleId: TEST_ROLE_ID,
overrides: { objects: [buildCleanObject()] },
}),
});
}, 60000);
afterAll(async () => {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: TEST_APP_ID,
});
});
it('should reject a manifest field declared under the reserved searchVector name', async () => {
const { errors } = await syncApplication({
manifest: buildBaseManifest({
appId: TEST_APP_ID,
roleId: TEST_ROLE_ID,
overrides: { objects: [buildObjectAuthoringSearchVector()] },
}),
expectToFail: true,
});
expectOneNotInternalServerErrorSnapshot({ errors });
}, 60000);
it('should reject a manifest field reusing the searchVector name under a foreign universal identifier', async () => {
const { errors } = await syncApplication({
manifest: buildBaseManifest({
appId: TEST_APP_ID,
roleId: TEST_ROLE_ID,
overrides: { objects: [buildObjectCreatingForeignSearchVector()] },
}),
expectToFail: true,
});
expectOneNotInternalServerErrorSnapshot({ errors });
}, 60000);
it('should reject a manifest field squatting the searchVector deterministic universal identifier', async () => {
const { errors } = await syncApplication({
manifest: buildBaseManifest({
appId: TEST_APP_ID,
roleId: TEST_ROLE_ID,
overrides: {
objects: [buildObjectHijackingSearchVectorUniversalIdentifier()],
},
}),
expectToFail: true,
});
expectOneNotInternalServerErrorSnapshot({ errors });
}, 60000);
});

View File

@@ -0,0 +1,105 @@
import { createOneOperationFactory } from 'test/integration/graphql/utils/create-one-operation-factory.util';
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util';
import { buildDefaultObjectManifest } from 'test/integration/metadata/suites/application/utils/build-default-object-manifest.util';
import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util';
import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util';
import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util';
import { type Manifest } from 'twenty-shared/application';
import { FieldMetadataType } from 'twenty-shared/types';
import { capitalize } from 'twenty-shared/utils';
import { v4 as uuidv4 } from 'uuid';
// Fixed identifiers keep the deterministic index name (hashed from the
// application universal identifier) stable across runs for the snapshot.
const TEST_APP_ID = '3d05deeb-e0b6-4b7a-abe9-cea3b81dc1a1';
const TEST_ROLE_ID = 'e37f849e-04a1-4fbf-b463-90b3131de79f';
const TEST_FIELD_ID = 'c2a24a3a-3960-4c4b-bd91-c22e1e1e2f31';
const TEST_OBJECT = buildDefaultObjectManifest({
applicationUniversalIdentifier: TEST_APP_ID,
universalIdentifier: '0e1f18ce-6273-4e83-bc9e-6cdeb2b57d81',
nameSingular: 'duplicatedDataObject',
namePlural: 'duplicatedDataObjects',
labelSingular: 'Duplicated Data Object',
labelPlural: 'Duplicated Data Objects',
description: 'Object used to test unique index creation over duplicated data',
});
const buildManifest = ({ isUnique }: { isUnique: boolean }): Manifest =>
buildBaseManifest({
appId: TEST_APP_ID,
roleId: TEST_ROLE_ID,
overrides: {
objects: [TEST_OBJECT],
fields: [
{
universalIdentifier: TEST_FIELD_ID,
type: FieldMetadataType.TEXT,
name: 'externalId',
label: 'External ID',
description: 'External identifier',
icon: 'IconId',
isUnique,
isNullable: true,
objectUniversalIdentifier: TEST_OBJECT.universalIdentifier,
},
],
},
});
const createRecordWithExternalId = async (externalId: string) => {
const response = await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: TEST_OBJECT.nameSingular,
gqlFields: `
id
externalId
`,
data: { id: uuidv4(), externalId },
}),
);
return response.body.data?.[`create${capitalize(TEST_OBJECT.nameSingular)}`];
};
describe('Sync application should surface the root cause when a unique index cannot be created over duplicated data', () => {
beforeAll(async () => {
await setupApplicationForSync({
applicationUniversalIdentifier: TEST_APP_ID,
name: 'Test Unique Index Duplicate Data App',
description: 'App for testing unique index creation over duplicated data',
sourcePath: 'test-unique-index-duplicate-data',
});
}, 60000);
afterAll(async () => {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: TEST_APP_ID,
});
});
it('should fail with the underlying postgres error in the migration runner error message', async () => {
await syncApplication({
manifest: buildManifest({ isUnique: false }),
expectToFail: false,
});
const firstRecord = await createRecordWithExternalId('DUPLICATED-VALUE');
const secondRecord = await createRecordWithExternalId('DUPLICATED-VALUE');
expect(firstRecord?.externalId).toBe('DUPLICATED-VALUE');
expect(secondRecord?.externalId).toBe('DUPLICATED-VALUE');
// Turning the field unique generates a create action for its backing
// unique index; the index cannot be created over duplicated data so the
// migration runner fails at workspace schema level with a 23505.
const { errors } = await syncApplication({
manifest: buildManifest({ isUnique: true }),
expectToFail: true,
});
expectOneNotInternalServerErrorSnapshot({ errors });
}, 60000);
});

View File

@@ -0,0 +1,120 @@
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util';
import { buildDefaultObjectManifest } from 'test/integration/metadata/suites/application/utils/build-default-object-manifest.util';
import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util';
import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util';
import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util';
import {
getIndexUniversalIdentifier,
type Manifest,
} from 'twenty-shared/application';
import { FieldMetadataType } from 'twenty-shared/types';
import { v4 as uuidv4 } from 'uuid';
import { generateDeterministicIndexName } from 'src/engine/metadata-modules/index-metadata/utils/generate-deterministic-index-name';
import { type UniversalFlatObjectMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-object-metadata.type';
const TEST_APP_ID = uuidv4();
const TEST_ROLE_ID = uuidv4();
const OBJECT_NAME_SINGULAR = 'collidingIndexObject';
const UNIQUE_FIELD_NAME = 'externalId';
const computeSystemUniqueIndexUniversalIdentifier = (
objectUniversalIdentifier: string,
): string => {
const name = generateDeterministicIndexName({
flatObjectMetadata: {
nameSingular: OBJECT_NAME_SINGULAR,
applicationUniversalIdentifier: TEST_APP_ID,
} as UniversalFlatObjectMetadata,
orderedIndexColumnNames: [UNIQUE_FIELD_NAME],
isUnique: true,
indexWhereClause: null,
});
return getIndexUniversalIdentifier({
applicationUniversalIdentifier: TEST_APP_ID,
objectUniversalIdentifier,
name,
});
};
// The object is otherwise fully valid (all system fields present) so the reserved-identifier
// violation is the only validation error surfaced.
const buildCollidingManifest = (): Manifest => {
const objectUniversalIdentifier = uuidv4();
const uniqueFieldUniversalIdentifier = uuidv4();
const object = buildDefaultObjectManifest({
applicationUniversalIdentifier: TEST_APP_ID,
nameSingular: OBJECT_NAME_SINGULAR,
namePlural: 'collidingIndexObjects',
labelSingular: 'Colliding Index Object',
labelPlural: 'Colliding Index Objects',
description: 'Object used to test unique index side-effect collision',
universalIdentifier: objectUniversalIdentifier,
additionalFields: [
{
universalIdentifier: uniqueFieldUniversalIdentifier,
type: FieldMetadataType.TEXT,
name: UNIQUE_FIELD_NAME,
label: 'External ID',
description: 'Unique external identifier',
icon: 'IconId',
isUnique: true,
isNullable: true,
},
],
});
return buildBaseManifest({
appId: TEST_APP_ID,
roleId: TEST_ROLE_ID,
overrides: {
objects: [object],
fields: [],
indexes: [
{
universalIdentifier: computeSystemUniqueIndexUniversalIdentifier(
objectUniversalIdentifier,
),
objectUniversalIdentifier,
isUnique: true,
fields: [
{
universalIdentifier: uuidv4(),
fieldUniversalIdentifier: uniqueFieldUniversalIdentifier,
},
],
},
],
},
});
};
describe('Sync application should fail on unique index side-effect collision', () => {
beforeAll(async () => {
await setupApplicationForSync({
applicationUniversalIdentifier: TEST_APP_ID,
name: 'Test Unique Index Collision App',
description: 'App for testing unique index side-effect collision',
sourcePath: 'test-unique-index-collision',
});
}, 60000);
afterAll(async () => {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: TEST_APP_ID,
});
});
it('should fail when a declared index reuses the reserved deterministic identifier of the engine-owned unique backing index', async () => {
const { errors } = await syncApplication({
manifest: buildCollidingManifest(),
expectToFail: true,
});
expectOneNotInternalServerErrorSnapshot({ errors });
}, 60000);
});

View File

@@ -0,0 +1,97 @@
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util';
import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util';
import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util';
import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util';
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
import { STANDARD_ROLE } from 'src/engine/workspace-manager/twenty-standard-application/constants/standard-role.constant';
const TEST_APP_ID = 'a1b2c3d4-0001-4000-a000-000000000001';
const TEST_ROLE_ID = 'a1b2c3d4-0002-4000-a000-000000000002';
const DUPLICATED_UNIVERSAL_IDENTIFIER = 'a1b2c3d4-0003-4000-a000-000000000003';
describe('Sync application should fail on universalIdentifier conflicts', () => {
beforeAll(async () => {
await setupApplicationForSync({
applicationUniversalIdentifier: TEST_APP_ID,
name: 'Test Universal Id Conflict App',
description: 'App for testing universalIdentifier conflict detection',
sourcePath: 'test-universal-id-conflict',
});
}, 60000);
afterAll(async () => {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: TEST_APP_ID,
});
});
it('should fail when two entities in the same manifest share the same universalIdentifier', async () => {
const manifest = buildBaseManifest({
appId: TEST_APP_ID,
roleId: TEST_ROLE_ID,
overrides: {
roles: [
{
universalIdentifier: TEST_ROLE_ID,
label: 'First Role',
description: 'First role',
},
{
universalIdentifier: DUPLICATED_UNIVERSAL_IDENTIFIER,
label: 'Second Role',
description: 'Second role',
objectPermissions: [
{
universalIdentifier: DUPLICATED_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier:
STANDARD_OBJECTS.company.universalIdentifier,
canReadObjectRecords: true,
canUpdateObjectRecords: false,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
],
},
],
},
});
const { errors } = await syncApplication({
manifest,
expectToFail: true,
});
expectOneNotInternalServerErrorSnapshot({ errors });
}, 60000);
it('should fail when an entity uses a universalIdentifier from the twenty-standard app', async () => {
const manifest = buildBaseManifest({
appId: TEST_APP_ID,
roleId: TEST_ROLE_ID,
overrides: {
roles: [
{
universalIdentifier: TEST_ROLE_ID,
label: 'App Default Role',
description: 'Default role for the test app',
},
{
universalIdentifier: STANDARD_ROLE.admin.universalIdentifier,
label: 'Stolen Admin Role',
description:
'Attempts to create a role with the standard admin universalIdentifier',
},
],
},
});
const { errors } = await syncApplication({
manifest,
expectToFail: true,
});
expectOneNotInternalServerErrorSnapshot({ errors });
}, 60000);
});

View File

@@ -0,0 +1,238 @@
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util';
import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util';
import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util';
import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util';
import {
type ObjectManifest,
type ViewFilterManifest,
type ViewManifest,
} from 'twenty-shared/application';
import {
eachTestingContextFilter,
type EachTestingContext,
} from 'twenty-shared/testing';
import {
FieldMetadataType,
ViewFilterOperand,
ViewType,
} from 'twenty-shared/types';
const TEST_APP_ID = 'd1b2c3d4-0001-4000-a000-000000000001';
const TEST_ROLE_ID = 'd1b2c3d4-0002-4000-a000-000000000002';
const TEST_OBJECT_ID = 'd1b2c3d4-0003-4000-a000-000000000003';
const TEST_VIEW_ID = 'd1b2c3d4-0004-4000-a000-000000000004';
const TEST_VIEW_FILTER_ID = 'd1b2c3d4-0005-4000-a000-000000000005';
const NAME_FIELD_ID = 'd1b2c3d4-0100-4000-a000-000000000001';
const DATE_FIELD_ID = 'd1b2c3d4-0100-4000-a000-000000000002';
const NUMBER_FIELD_ID = 'd1b2c3d4-0100-4000-a000-000000000003';
const BOOLEAN_FIELD_ID = 'd1b2c3d4-0100-4000-a000-000000000004';
const SELECT_FIELD_ID = 'd1b2c3d4-0100-4000-a000-000000000005';
const SELECT_OPTION_ID = 'd1b2c3d4-0200-4000-a000-000000000001';
const buildObject = (): ObjectManifest => ({
universalIdentifier: TEST_OBJECT_ID,
nameSingular: 'filterValueSubject',
namePlural: 'filterValueSubjects',
labelSingular: 'Filter Value Subject',
labelPlural: 'Filter Value Subjects',
labelIdentifierFieldMetadataUniversalIdentifier: NAME_FIELD_ID,
fields: [
{
universalIdentifier: NAME_FIELD_ID,
name: 'name',
label: 'Name',
type: FieldMetadataType.TEXT,
},
{
universalIdentifier: DATE_FIELD_ID,
name: 'expiresAt',
label: 'Expires At',
type: FieldMetadataType.DATE,
},
{
universalIdentifier: NUMBER_FIELD_ID,
name: 'seatCount',
label: 'Seat Count',
type: FieldMetadataType.NUMBER,
},
{
universalIdentifier: BOOLEAN_FIELD_ID,
name: 'isActive',
label: 'Is Active',
type: FieldMetadataType.BOOLEAN,
},
{
universalIdentifier: SELECT_FIELD_ID,
name: 'stage',
label: 'Stage',
type: FieldMetadataType.SELECT,
options: [
{
id: SELECT_OPTION_ID,
label: 'Won',
value: 'WON',
position: 0,
color: 'green',
},
],
},
],
});
const buildView = (viewFilter: ViewFilterManifest): ViewManifest => ({
universalIdentifier: TEST_VIEW_ID,
name: 'Filter Value View',
objectUniversalIdentifier: TEST_OBJECT_ID,
type: ViewType.TABLE,
filters: [viewFilter],
});
type TestContext = {
viewFilter: ViewFilterManifest;
};
const failingViewFilterValueSyncTestCases: EachTestingContext<TestContext>[] = [
{
title:
'when an IS_RELATIVE filter uses the relative date object form instead of its stringified form',
context: {
viewFilter: {
universalIdentifier: TEST_VIEW_FILTER_ID,
fieldMetadataUniversalIdentifier: DATE_FIELD_ID,
operand: ViewFilterOperand.IS_RELATIVE,
value: { direction: 'NEXT', amount: 30, unit: 'DAY' },
},
},
},
{
title: 'when an IS_RELATIVE filter has no amount',
context: {
viewFilter: {
universalIdentifier: TEST_VIEW_FILTER_ID,
fieldMetadataUniversalIdentifier: DATE_FIELD_ID,
operand: ViewFilterOperand.IS_RELATIVE,
value: 'NEXT__DAY',
},
},
},
{
title: 'when a DATE filter is not an ISO date',
context: {
viewFilter: {
universalIdentifier: TEST_VIEW_FILTER_ID,
fieldMetadataUniversalIdentifier: DATE_FIELD_ID,
operand: ViewFilterOperand.IS,
value: '31/01/2026',
},
},
},
{
title: 'when a NUMBER filter is not a number',
context: {
viewFilter: {
universalIdentifier: TEST_VIEW_FILTER_ID,
fieldMetadataUniversalIdentifier: NUMBER_FIELD_ID,
operand: ViewFilterOperand.GREATER_THAN_OR_EQUAL,
value: 'thirty',
},
},
},
{
title: 'when a NUMBER filter has trailing characters',
context: {
viewFilter: {
universalIdentifier: TEST_VIEW_FILTER_ID,
fieldMetadataUniversalIdentifier: NUMBER_FIELD_ID,
operand: ViewFilterOperand.IS,
value: '30abc',
},
},
},
{
title: 'when a BOOLEAN filter is neither true nor false',
context: {
viewFilter: {
universalIdentifier: TEST_VIEW_FILTER_ID,
fieldMetadataUniversalIdentifier: BOOLEAN_FIELD_ID,
operand: ViewFilterOperand.IS,
value: 'yes',
},
},
},
{
title: 'when a SELECT filter is an object rather than an array of options',
context: {
viewFilter: {
universalIdentifier: TEST_VIEW_FILTER_ID,
fieldMetadataUniversalIdentifier: SELECT_FIELD_ID,
operand: ViewFilterOperand.IS,
value: { stage: 'WON' },
},
},
},
];
describe('Sync application should fail on invalid view filter values', () => {
beforeAll(async () => {
await setupApplicationForSync({
applicationUniversalIdentifier: TEST_APP_ID,
name: 'Test Invalid View Filter Value App',
description: 'App for testing view filter value manifest validation',
sourcePath: 'test-invalid-view-filter-value',
});
}, 60000);
afterAll(async () => {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: TEST_APP_ID,
});
});
it.each(eachTestingContextFilter(failingViewFilterValueSyncTestCases))(
'$title',
async ({ context }) => {
const { errors } = await syncApplication({
manifest: buildBaseManifest({
appId: TEST_APP_ID,
roleId: TEST_ROLE_ID,
overrides: {
objects: [buildObject()],
views: [buildView(context.viewFilter)],
},
}),
expectToFail: true,
});
expectOneNotInternalServerErrorSnapshot({ errors });
},
60000,
);
it('should sync the same view filters once their values are valid', async () => {
const { errors } = await syncApplication({
manifest: buildBaseManifest({
appId: TEST_APP_ID,
roleId: TEST_ROLE_ID,
overrides: {
objects: [buildObject()],
views: [
{
...buildView({
universalIdentifier: TEST_VIEW_FILTER_ID,
fieldMetadataUniversalIdentifier: DATE_FIELD_ID,
operand: ViewFilterOperand.IS_RELATIVE,
value: 'NEXT_30_DAY',
}),
},
],
},
}),
expectToFail: false,
});
expect(errors).toBeUndefined();
}, 60000);
});

View File

@@ -0,0 +1,137 @@
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util';
import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util';
import { scrubSemverVersions } from 'test/utils/scrub-semver-versions.util';
import { type Manifest } from 'twenty-shared/application';
import { isDefined } from 'twenty-shared/utils';
import { v4 as uuidv4 } from 'uuid';
import { TWENTY_CURRENT_VERSION } from 'src/engine/core-modules/upgrade/constants/twenty-current-version.constant';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
// The sync flow can run cache-lock retries with real delays, so fake timers
// would hang it — mirror the other application suites.
jest.setTimeout(120000);
const INJECTED_CURSOR_MARKER = 'integration-test-sync-workspace-version-gate';
const injectWorkspaceCursor = async (
name: string,
status: 'completed' | 'failed',
): Promise<void> => {
// Inject as attempt 2 with a fresh createdAt (now): this stays unique against
// the seeded completed attempt-1 cursor (which may share this name) and
// outranks it, so getWorkspaceCompletedVersion reads this attempt.
await global.testDataSource.query(
`INSERT INTO core."upgradeMigration"
(name, status, attempt, "executedByVersion", "workspaceId", "isInitial")
VALUES ($1, $2, 2, $3, $4, false)`,
[name, status, INJECTED_CURSOR_MARKER, SEED_APPLE_WORKSPACE_ID],
);
};
const clearInjectedWorkspaceCursors = async (): Promise<void> => {
await global.testDataSource.query(
`DELETE FROM core."upgradeMigration"
WHERE "workspaceId" = $1 AND "executedByVersion" = $2`,
[SEED_APPLE_WORKSPACE_ID, INJECTED_CURSOR_MARKER],
);
};
const buildManifestWithRequiredServerVersionRange = ({
appId,
roleId,
requiredServerVersionRange,
}: {
appId: string;
roleId: string;
requiredServerVersionRange: string;
}): Manifest => {
const manifest = buildBaseManifest({ appId, roleId });
manifest.application.requiredServerVersionRange = requiredServerVersionRange;
return manifest;
};
describe('Sync application is gated by the workspace completed upgrade version', () => {
let currentVersionCommandName: string;
beforeAll(async () => {
jest.useRealTimers();
// The seeded workspace's cursor is the last step of the current version.
// Re-injecting it as a failed attempt makes the workspace resolve to the
// previous completed version — i.e. behind the instance.
const [workspaceCursor] = await global.testDataSource.query(
`SELECT name FROM core."upgradeMigration"
WHERE "workspaceId" = $1
ORDER BY "createdAt" DESC, attempt DESC
LIMIT 1`,
[SEED_APPLE_WORKSPACE_ID],
);
if (!isDefined(workspaceCursor)) {
throw new Error(
`Expected a seeded upgrade cursor for workspace ${SEED_APPLE_WORKSPACE_ID}`,
);
}
currentVersionCommandName = workspaceCursor.name;
});
afterEach(async () => {
await clearInjectedWorkspaceCursors();
});
afterAll(() => {
jest.useFakeTimers();
});
it('rejects sync when the workspace has not completed the required upgrade version', async () => {
const manifest = buildManifestWithRequiredServerVersionRange({
appId: uuidv4(),
roleId: uuidv4(),
requiredServerVersionRange: `>=${TWENTY_CURRENT_VERSION}`,
});
// The workspace failed mid-way through the current version's upgrade
// segment, so its last completed version is the previous one.
await injectWorkspaceCursor(currentVersionCommandName, 'failed');
const { errors } = await syncApplication({
manifest,
expectToFail: true,
});
expectOneNotInternalServerErrorSnapshot({
errors,
normalizeMessage: scrubSemverVersions,
});
});
it('rejects sync when the workspace upgrade cursor cannot be interpreted', async () => {
const manifest = buildManifestWithRequiredServerVersionRange({
appId: uuidv4(),
roleId: uuidv4(),
requiredServerVersionRange: '>=1.0.0',
});
// A cursor pointing at a command outside the supported upgrade sequence
// cannot be mapped to a completed version.
await injectWorkspaceCursor(
'1.0.0_UnknownLegacyCommand_1700000000000',
'completed',
);
const { errors } = await syncApplication({
manifest,
expectToFail: true,
});
expectOneNotInternalServerErrorSnapshot({
errors,
normalizeMessage: scrubSemverVersions,
});
});
});

View File

@@ -0,0 +1,136 @@
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util';
import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util';
import { uploadApplicationFile } from 'test/integration/metadata/suites/application/utils/upload-application-file.util';
import {
type EachTestingContext,
eachTestingContextFilter,
} from 'twenty-shared/testing';
import { v4 as uuidv4 } from 'uuid';
const TEST_APP_ID = uuidv4();
const PNG_BUFFER = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49,
0x48, 0x44, 0x52,
]);
const TEXT_BUFFER = Buffer.from('not actually binary content', 'utf-8');
type TestContext = {
applicationUniversalIdentifier: string;
fileFolder: string;
filePath: string;
fileBuffer: Buffer;
filename: string;
contentType: string;
};
const FAILING_TEST_CASES: EachTestingContext<TestContext>[] = [
{
title:
'when a PublicAsset .png path is uploaded with plain text body (magic-byte mismatch)',
context: {
applicationUniversalIdentifier: TEST_APP_ID,
fileFolder: 'PublicAsset',
filePath: 'assets/fake-image.png',
fileBuffer: TEXT_BUFFER,
filename: 'fake-image.png',
contentType: 'image/png',
},
},
{
title:
'when a PublicAsset .pdf path is uploaded with plain text body (magic-byte mismatch)',
context: {
applicationUniversalIdentifier: TEST_APP_ID,
fileFolder: 'PublicAsset',
filePath: 'docs/fake.pdf',
fileBuffer: TEXT_BUFFER,
filename: 'fake.pdf',
contentType: 'application/pdf',
},
},
{
title:
'when a PublicAsset .zip path is uploaded with plain text body (magic-byte mismatch)',
context: {
applicationUniversalIdentifier: TEST_APP_ID,
fileFolder: 'PublicAsset',
filePath: 'archives/fake.zip',
fileBuffer: TEXT_BUFFER,
filename: 'fake.zip',
contentType: 'application/zip',
},
},
];
describe('Upload application file should fail on mime/magic-byte mismatch', () => {
beforeAll(async () => {
await setupApplicationForSync({
applicationUniversalIdentifier: TEST_APP_ID,
name: 'Test Upload Mime Mismatch App',
description: 'App for testing magic-byte vs extension validation',
sourcePath: 'test-upload-mime-mismatch',
});
}, 60000);
afterAll(async () => {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: TEST_APP_ID,
});
});
it.each(eachTestingContextFilter(FAILING_TEST_CASES))(
'$title',
async ({ context }) => {
jest.useRealTimers();
const { errors } = await uploadApplicationFile({
applicationUniversalIdentifier: context.applicationUniversalIdentifier,
fileFolder: context.fileFolder,
filePath: context.filePath,
fileBuffer: context.fileBuffer,
filename: context.filename,
contentType: context.contentType,
expectToFail: true,
});
jest.useFakeTimers();
expectOneNotInternalServerErrorSnapshot({ errors });
},
60000,
);
it('should succeed but persist server-derived mime when uploader-controlled mime is misleading', async () => {
jest.useRealTimers();
const filePath = 'assets/legit-image.png';
const { data, errors } = await uploadApplicationFile({
applicationUniversalIdentifier: TEST_APP_ID,
fileFolder: 'PublicAsset',
filePath,
fileBuffer: PNG_BUFFER,
filename: 'legit-image.png',
// Misleading multipart mime — server should ignore it and persist image/png.
contentType: 'text/html',
expectToFail: false,
});
jest.useFakeTimers();
expect(errors).toBeUndefined();
expect(data.uploadApplicationFile).toEqual({
id: expect.any(String),
path: `public-asset/${filePath}`,
});
const [row] = await globalThis.testDataSource.query(
`SELECT "mimeType" FROM core."file" WHERE id = $1`,
[data.uploadApplicationFile.id],
);
expect(row.mimeType).toBe('image/png');
}, 60000);
});

View File

@@ -0,0 +1,178 @@
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util';
import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util';
import { uploadApplicationFile } from 'test/integration/metadata/suites/application/utils/upload-application-file.util';
import {
type EachTestingContext,
eachTestingContextFilter,
} from 'twenty-shared/testing';
import { v4 as uuidv4 } from 'uuid';
const TEST_APP_ID = uuidv4();
const UNKNOWN_APP_ID = uuidv4();
type TestContext = {
applicationUniversalIdentifier: string;
fileFolder: string;
filePath: string;
};
const FAILING_TEST_CASES: EachTestingContext<TestContext>[] = [
{
title: 'when filePath contains relative path traversal (../)',
context: {
applicationUniversalIdentifier: TEST_APP_ID,
fileFolder: 'BuiltFrontComponent',
filePath:
'../../../other-workspace/other-app/BuiltFrontComponent/stolen.mjs',
},
},
{
title: 'when filePath contains upward traversal (../../)',
context: {
applicationUniversalIdentifier: TEST_APP_ID,
fileFolder: 'BuiltFrontComponent',
filePath: '../../etc/passwd',
},
},
{
title: 'when filePath is an absolute path',
context: {
applicationUniversalIdentifier: TEST_APP_ID,
fileFolder: 'BuiltFrontComponent',
filePath: '/etc/passwd',
},
},
{
title: 'when filePath contains backslash path traversal',
context: {
applicationUniversalIdentifier: TEST_APP_ID,
fileFolder: 'BuiltFrontComponent',
filePath: '..\\..\\..\\etc\\passwd',
},
},
{
title: 'when filePath is empty',
context: {
applicationUniversalIdentifier: TEST_APP_ID,
fileFolder: 'BuiltFrontComponent',
filePath: '',
},
},
{
title:
'when applicationUniversalIdentifier does not match any installed application',
context: {
applicationUniversalIdentifier: UNKNOWN_APP_ID,
fileFolder: 'BuiltFrontComponent',
filePath: 'src/components/legit.mjs',
},
},
{
title: 'when applicationUniversalIdentifier is empty',
context: {
applicationUniversalIdentifier: '',
fileFolder: 'BuiltFrontComponent',
filePath: 'src/components/legit.mjs',
},
},
{
title: 'when fileFolder is not an allowed application file folder',
context: {
applicationUniversalIdentifier: TEST_APP_ID,
fileFolder: 'CorePicture',
filePath: 'src/components/legit.mjs',
},
},
{
title: 'when filePath is a folder path without extension (bare UUID)',
context: {
applicationUniversalIdentifier: TEST_APP_ID,
fileFolder: 'BuiltFrontComponent',
filePath: '8b2df3cc-23ad-4e1b-87fd-f880d4cefd58',
},
},
{
title: 'when filePath is a nested folder path without extension',
context: {
applicationUniversalIdentifier: TEST_APP_ID,
fileFolder: 'Source',
filePath: 'src/logic-functions/my-handler',
},
},
{
title:
'when filePath has an invalid extension for BuiltFrontComponent (.js instead of .mjs)',
context: {
applicationUniversalIdentifier: TEST_APP_ID,
fileFolder: 'BuiltFrontComponent',
filePath: 'src/components/component.js',
},
},
{
title:
'when filePath has an invalid extension for BuiltLogicFunction (.html)',
context: {
applicationUniversalIdentifier: TEST_APP_ID,
fileFolder: 'BuiltLogicFunction',
filePath: 'src/handlers/handler.html',
},
},
{
title:
'when filePath has an invalid extension for Source (.js instead of .ts)',
context: {
applicationUniversalIdentifier: TEST_APP_ID,
fileFolder: 'Source',
filePath: 'src/index.js',
},
},
{
title:
'when filePath has an invalid extension for Dependencies (.sh instead of .json/.lock)',
context: {
applicationUniversalIdentifier: TEST_APP_ID,
fileFolder: 'Dependencies',
filePath: 'install.sh',
},
},
];
describe('Upload application file should fail', () => {
beforeAll(async () => {
await setupApplicationForSync({
applicationUniversalIdentifier: TEST_APP_ID,
name: 'Test Upload Path Traversal App',
description: 'App for testing path traversal on file upload',
sourcePath: 'test-upload-path-traversal',
});
}, 60000);
afterAll(async () => {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: TEST_APP_ID,
});
});
it.each(eachTestingContextFilter(FAILING_TEST_CASES))(
'$title',
async ({ context }) => {
jest.useRealTimers();
const { errors } = await uploadApplicationFile({
applicationUniversalIdentifier: context.applicationUniversalIdentifier,
fileFolder: context.fileFolder,
filePath: context.filePath,
fileBuffer: Buffer.from('content'),
filename: 'test-file.mjs',
contentType: 'application/javascript',
expectToFail: true,
});
jest.useFakeTimers();
expectOneNotInternalServerErrorSnapshot({ errors });
},
60000,
);
});

View File

@@ -0,0 +1,60 @@
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util';
import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util';
import { createAppTarball } from 'test/integration/metadata/suites/application/utils/create-app-tarball.util';
import { uploadAppTarball } from 'test/integration/metadata/suites/application/utils/upload-app-tarball.util';
import { scrubSemverVersions } from 'test/utils/scrub-semver-versions.util';
import { v4 as uuidv4 } from 'uuid';
// The upload flow runs cache-lock retries with real delays, so fake timers
// would hang it — mirror the other application suites.
jest.setTimeout(120000);
describe('Publish application is gated by the instance server version', () => {
const createdApplicationUniversalIdentifiers: string[] = [];
beforeAll(() => {
jest.useRealTimers();
});
afterAll(async () => {
for (const universalIdentifier of createdApplicationUniversalIdentifiers) {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: universalIdentifier,
});
}
jest.useFakeTimers();
});
it('rejects publishing when the app requires a server version the instance does not satisfy', async () => {
const universalIdentifier = uuidv4();
const roleId = uuidv4();
const tarball = await createAppTarball({
'manifest.json': JSON.stringify(
buildBaseManifest({ appId: universalIdentifier, roleId }),
),
'package.json': JSON.stringify({
name: `test-server-version-gate-${universalIdentifier}`,
version: '1.0.0',
// Require a server version far above any real instance version so the
// instance-level compatibility check always rejects it.
engines: { twenty: '>=999.0.0' },
}),
});
createdApplicationUniversalIdentifiers.push(universalIdentifier);
const { errors } = await uploadAppTarball({
tarballBuffer: tarball,
universalIdentifier,
expectToFail: true,
});
expectOneNotInternalServerErrorSnapshot({
errors,
normalizeMessage: scrubSemverVersions,
});
});
});

View File

@@ -0,0 +1,65 @@
import { findManyApplications } from 'test/integration/graphql/utils/find-many-applications.util';
import { generateApplicationToken } from 'test/integration/metadata/suites/application/utils/generate-application-token.util';
describe('generateApplicationToken', () => {
let applicationId: string;
beforeAll(async () => {
const { data } = await findManyApplications({
expectToFail: false,
});
const application = data.findManyApplications[0];
expect(application).toBeDefined();
applicationId = application.id;
});
it('should generate an application token with admin access token', async () => {
const { data } = await generateApplicationToken({
applicationId,
expectToFail: false,
});
const tokenPair = data.generateApplicationToken;
expect(tokenPair).toBeDefined();
expect(tokenPair.applicationAccessToken).toBeDefined();
expect(typeof tokenPair.applicationAccessToken.token).toBe('string');
expect(tokenPair.applicationAccessToken.token.length).toBeGreaterThan(0);
expect(tokenPair.applicationAccessToken.expiresAt).toBeDefined();
expect(tokenPair.applicationRefreshToken).toBeDefined();
expect(typeof tokenPair.applicationRefreshToken.token).toBe('string');
expect(tokenPair.applicationRefreshToken.expiresAt).toBeDefined();
});
it('should generate an application token with API key access token', async () => {
const { data } = await generateApplicationToken({
applicationId,
expectToFail: false,
token: API_KEY_ACCESS_TOKEN,
});
const tokenPair = data.generateApplicationToken;
expect(tokenPair).toBeDefined();
expect(tokenPair.applicationAccessToken).toBeDefined();
expect(typeof tokenPair.applicationAccessToken.token).toBe('string');
expect(tokenPair.applicationAccessToken.token.length).toBeGreaterThan(0);
expect(tokenPair.applicationAccessToken.expiresAt).toBeDefined();
expect(tokenPair.applicationRefreshToken).toBeDefined();
expect(typeof tokenPair.applicationRefreshToken.token).toBe('string');
expect(tokenPair.applicationRefreshToken.expiresAt).toBeDefined();
});
it('should fail with a non-existent application id', async () => {
const { errors } = await generateApplicationToken({
applicationId: '00000000-0000-0000-0000-000000000000',
expectToFail: true,
});
expect(errors).toBeDefined();
expect(errors.length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,148 @@
import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util';
import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util';
import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util';
import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util';
import {
type LogicFunctionManifest,
type Manifest,
} from 'twenty-shared/application';
import { v4 as uuidv4 } from 'uuid';
/**
* Performance harness for installing / updating many logic functions through
* application manifest sync. The goal is to find what could take >10s in prod
* (which trips the node-postgres `query_timeout` in core.datasource.ts and
* produces "Migration action 'update' for 'logicFunction' failed" + 504).
*
* IMPORTANT: the integration harness boots the NestJS app in-process with
* `fakeTimers.enableGlobally: true`. We call `jest.useRealTimers()` for the whole
* suite so timing (`performance.now()`) is real and cache-lock retry delays etc.
* do not hang.
*/
// Real timers for the whole suite — see note above.
jest.useRealTimers();
jest.setTimeout(120000);
const FN_COUNTS = [1, 8, 30];
// Stable universalIdentifiers across versions so the second sync exercises
// UPDATE (the actual incident), not CREATE+DELETE.
const buildManifest = ({
appId,
roleId,
universalIdentifiers,
checksumVersion,
}: {
appId: string;
roleId: string;
universalIdentifiers: string[];
checksumVersion: 'v1' | 'v2';
}): Manifest => {
const logicFunctions: LogicFunctionManifest[] = universalIdentifiers.map(
(universalIdentifier, i) => ({
universalIdentifier,
name: `PerfFn${i}`,
description: `Perf logic function ${i}`,
handlerName: 'handler',
sourceHandlerPath: `src/fn-${i}.ts`,
builtHandlerPath: `dist/fn-${i}.mjs`,
builtHandlerChecksum: `checksum-${i}-${checksumVersion}`,
httpRouteTriggerSettings: {
path: `/fn-${i}`,
httpMethod: 'GET',
isAuthRequired: true,
},
}),
);
return buildBaseManifest({
appId,
roleId,
overrides: { logicFunctions },
});
};
const timeSync = async (label: string, manifest: Manifest): Promise<number> => {
const start = performance.now();
await syncApplication({ manifest, expectToFail: false });
const ms = performance.now() - start;
// oxlint-disable-next-line no-console
console.log(`[install-perf][test] ${label} took ${ms.toFixed(1)}ms`);
return ms;
};
// TODO(install-perf): temporary manual perf harness, remove. Skipped in CI.
describe.skip('Logic function install performance', () => {
it.each(FN_COUNTS)(
'create + update sync with %i logic functions',
async (count) => {
const appId = uuidv4();
const roleId = uuidv4();
const universalIdentifiers = Array.from({ length: count }, () =>
uuidv4(),
);
await setupApplicationForSync({
applicationUniversalIdentifier: appId,
name: `Perf App ${count}`,
description: `Perf app with ${count} logic functions`,
sourcePath: `perf-app-${count}`,
});
jest.useRealTimers();
try {
// No built-handler file upload is needed: the migration create/update
// handlers never read the built file for LIVE functions (prebuilt
// install is skipped), and uploading N files would trip the file-upload
// rate limiter (30 per 30s). We only measure migration + cache cost.
// oxlint-disable-next-line no-console
console.log(
`[install-perf][test] ===== N=${count} : FIRST SYNC (create ${count} functions) =====`,
);
const createMs = await timeSync(
`N=${count} create sync`,
buildManifest({
appId,
roleId,
universalIdentifiers,
checksumVersion: 'v1',
}),
);
// oxlint-disable-next-line no-console
console.log(
`[install-perf][test] ===== N=${count} : SECOND SYNC (update ${count} functions, checksum v2) =====`,
);
const updateMs = await timeSync(
`N=${count} update sync`,
buildManifest({
appId,
roleId,
universalIdentifiers,
checksumVersion: 'v2',
}),
);
// oxlint-disable-next-line no-console
console.log(
`[install-perf][test] ===== N=${count} SUMMARY: create=${createMs.toFixed(1)}ms update=${updateMs.toFixed(1)}ms =====`,
);
} finally {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: appId,
});
}
},
120000,
);
});

View File

@@ -0,0 +1,837 @@
import crypto from 'crypto';
import { promises as fs } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { gql } from 'graphql-tag';
import request from 'supertest';
import * as tar from 'tar';
import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util';
import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util';
import { findApplicationRegistrationByUniversalIdentifier } from 'test/integration/metadata/suites/application/utils/find-application-registration-by-universal-identifier.util';
import { insertCatalogApplicationRegistration } from 'test/integration/metadata/suites/application/utils/insert-catalog-application-registration.util';
import { syncMarketplaceCatalogFromRegistryPackage } from 'test/integration/metadata/suites/application/utils/sync-marketplace-catalog-from-registry-package.util';
import { upsertApplicationRegistrationFromCatalog } from 'test/integration/metadata/suites/application/utils/upsert-application-registration-from-catalog.util';
import { makeAdminPanelAPIRequest } from 'test/integration/twenty-config/utils/make-admin-panel-api-request.util';
import { getAppProviderByClassName } from 'test/integration/utils/get-app-provider-by-class-name.util';
import { type DataSource } from 'typeorm';
import { MARKETPLACE_VETTED_APPLICATIONS } from 'src/engine/core-modules/application/application-marketplace/constants/marketplace-vetted-applications.constant';
import { type ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
const TEST_WORKSPACE_ID = SEED_APPLE_WORKSPACE_ID;
const MARKETPLACE_QUERY = `
query {
findManyMarketplaceApps {
id
name
description
author
sourcePackage
category
logoUrl
isVetted
}
}
`;
const INSTALL_MUTATION = `
mutation InstallApplication($universalIdentifier: String!) {
installApplication(universalIdentifier: $universalIdentifier) {
id
}
}
`;
describe('Marketplace Catalog Sync (integration)', () => {
const baseUrl = `http://localhost:${APP_PORT}`;
let ds: DataSource;
const createdRegistrationIds: string[] = [];
beforeAll(() => {
jest.useRealTimers();
ds = global.testDataSource;
});
afterAll(async () => {
for (const id of createdRegistrationIds) {
await ds.query(
`DELETE FROM core."applicationRegistration" WHERE id = $1`,
[id],
);
}
jest.useFakeTimers();
});
const gqlRequest = (query: string, variables?: Record<string, unknown>) =>
request(baseUrl)
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({ query, variables });
const insertCatalogRegistration = async (
params: Parameters<typeof insertCatalogApplicationRegistration>[0],
): Promise<string> => {
const id = await insertCatalogApplicationRegistration(params);
createdRegistrationIds.push(id);
return id;
};
describe('findManyMarketplaceApps', () => {
const npmUid = crypto.randomUUID();
const tarballUid = crypto.randomUUID();
const curatedUid = 'a1b2c3d4-0000-0000-0000-000000000001';
// Insert all data before queries so the in-memory cache is populated once
// with complete data rather than being stale for later tests.
beforeAll(async () => {
await insertCatalogRegistration({
universalIdentifier: npmUid,
name: 'Test Catalog App',
sourcePackage: '@test/catalog-app',
});
const tarballId = crypto.randomUUID();
const oAuthClientId = crypto.randomUUID();
await ds.query(
`INSERT INTO core."applicationRegistration"
(id, "universalIdentifier", name, "oAuthClientId",
"oAuthRedirectUris", "oAuthScopes", "workspaceId",
"sourceType")
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[
tarballId,
tarballUid,
'Tarball Only App',
oAuthClientId,
[],
[],
TEST_WORKSPACE_ID,
'tarball',
],
);
createdRegistrationIds.push(tarballId);
await insertCatalogRegistration({
universalIdentifier: curatedUid,
name: 'Data Enrichment',
sourcePackage: '@twentyhq/app-data-enrichment',
manifest: {
application: {
category: 'Data',
},
},
category: 'Data',
});
});
it('should return npm-sourced registrations from the database', async () => {
const res = await gqlRequest(MARKETPLACE_QUERY).expect(200);
const apps = res.body.data.findManyMarketplaceApps;
const testApp = apps.find((app: { id: string }) => app.id === npmUid);
expect(testApp).toBeDefined();
expect(testApp.name).toBe('Test Catalog App');
expect(testApp.sourcePackage).toBe('@test/catalog-app');
});
it('should not return tarball-sourced registrations', async () => {
const res = await gqlRequest(MARKETPLACE_QUERY).expect(200);
const apps = res.body.data.findManyMarketplaceApps;
const tarballApp = apps.find(
(app: { id: string }) => app.id === tarballUid,
);
expect(tarballApp).toBeUndefined();
});
it('should enrich curated apps with rich display data', async () => {
const res = await gqlRequest(MARKETPLACE_QUERY).expect(200);
const apps = res.body.data.findManyMarketplaceApps;
const curatedApp = apps.find(
(app: { id: string }) => app.id === curatedUid,
);
expect(curatedApp).toBeDefined();
expect(curatedApp.name).toBe('Data Enrichment');
expect(curatedApp.category).toBe('Data');
});
});
describe('catalog sync', () => {
it.each([true, false])(
'preserves the admin choice isVetted=%s',
async (isVetted) => {
const applicationRegistrationService =
getAppProviderByClassName<ApplicationRegistrationService>(
'ApplicationRegistrationService',
);
const universalIdentifier = isVetted
? crypto.randomUUID()
: MARKETPLACE_VETTED_APPLICATIONS[0].universalIdentifier;
const catalogParams = {
universalIdentifier,
name: 'Vetted catalog sync test',
sourceType: ApplicationRegistrationSourceType.NPM,
sourcePackage: isVetted
? '@test/vetted-catalog-sync'
: MARKETPLACE_VETTED_APPLICATIONS[0].sourcePackage,
latestAvailableVersion: '1.0.0',
manifest: buildBaseManifest({
appId: universalIdentifier,
roleId: crypto.randomUUID(),
}),
};
expect(
await applicationRegistrationService.findOneByUniversalIdentifierGlobal(
universalIdentifier,
),
).toBeNull();
try {
await applicationRegistrationService.upsertFromCatalog(catalogParams);
const registration =
await applicationRegistrationService.findOneByUniversalIdentifierGlobal(
universalIdentifier,
);
expect(registration).toMatchObject({ isVetted: !isVetted });
const updateResponse = await makeAdminPanelAPIRequest({
query: gql`
mutation UpdateAdminApplicationRegistration(
$input: AdminUpdateApplicationRegistrationInput!
) {
updateAdminApplicationRegistration(input: $input) {
id
isVetted
}
}
`,
variables: {
input: { id: registration?.id, update: { isVetted } },
},
});
expect(updateResponse.body.errors).toBeUndefined();
expect(
updateResponse.body.data.updateAdminApplicationRegistration,
).toMatchObject({ isVetted });
await applicationRegistrationService.upsertFromCatalog(catalogParams);
const refreshedResponse = await makeAdminPanelAPIRequest({
query: gql`
query FindOneAdminApplicationRegistration($id: String!) {
findOneAdminApplicationRegistration(id: $id) {
id
isVetted
}
}
`,
variables: { id: registration?.id },
});
expect(refreshedResponse.body.errors).toBeUndefined();
expect(
refreshedResponse.body.data.findOneAdminApplicationRegistration,
).toMatchObject({ isVetted });
} finally {
await ds.query(
`DELETE FROM core."applicationRegistration" WHERE "universalIdentifier" = $1`,
[universalIdentifier],
);
}
},
);
});
describe('catalog sync source package pinning', () => {
const OFFICIAL_APPLICATION = MARKETPLACE_VETTED_APPLICATIONS[0];
it('does not let another package overwrite an existing registration', async () => {
const universalIdentifier = crypto.randomUUID();
await insertCatalogRegistration({
universalIdentifier,
name: 'Pinned App',
sourcePackage: '@test/pinned-app',
});
await upsertApplicationRegistrationFromCatalog({
universalIdentifier,
name: 'Squatted App',
sourceType: ApplicationRegistrationSourceType.NPM,
sourcePackage: '@test/squatter-app',
latestAvailableVersion: '9.9.9',
manifest: buildBaseManifest({
appId: universalIdentifier,
roleId: crypto.randomUUID(),
}),
});
expect(
await findApplicationRegistrationByUniversalIdentifier({
universalIdentifier,
}),
).toMatchObject({
name: 'Pinned App',
sourcePackage: '@test/pinned-app',
latestAvailableVersion: '1.0.0',
});
});
it('does not attach a package to a registration created from a local install', async () => {
const universalIdentifier = crypto.randomUUID();
await insertCatalogRegistration({
universalIdentifier,
name: 'Dev Mode App',
sourceType: ApplicationRegistrationSourceType.LOCAL,
});
await upsertApplicationRegistrationFromCatalog({
universalIdentifier,
name: 'Squatted App',
sourceType: ApplicationRegistrationSourceType.NPM,
sourcePackage: '@test/squatter-app',
latestAvailableVersion: '9.9.9',
manifest: buildBaseManifest({
appId: universalIdentifier,
roleId: crypto.randomUUID(),
}),
});
expect(
await findApplicationRegistrationByUniversalIdentifier({
universalIdentifier,
}),
).toMatchObject({
name: 'Dev Mode App',
sourcePackage: null,
latestAvailableVersion: '1.0.0',
});
});
it('keeps updating an existing registration from its own package', async () => {
const universalIdentifier = crypto.randomUUID();
await insertCatalogRegistration({
universalIdentifier,
name: 'Pinned App',
sourcePackage: '@test/pinned-app',
});
await upsertApplicationRegistrationFromCatalog({
universalIdentifier,
name: 'Pinned App',
sourceType: ApplicationRegistrationSourceType.NPM,
sourcePackage: '@test/pinned-app',
latestAvailableVersion: '2.0.0',
manifest: buildBaseManifest({
appId: universalIdentifier,
roleId: crypto.randomUUID(),
}),
});
expect(
await findApplicationRegistrationByUniversalIdentifier({
universalIdentifier,
}),
).toMatchObject({
sourcePackage: '@test/pinned-app',
latestAvailableVersion: '2.0.0',
});
});
it('does not register an official identifier from a non-official package', async () => {
const { universalIdentifier } = OFFICIAL_APPLICATION;
expect(
await findApplicationRegistrationByUniversalIdentifier({
universalIdentifier,
}),
).toBeNull();
try {
await upsertApplicationRegistrationFromCatalog({
universalIdentifier,
name: 'Squatted Official App',
sourceType: ApplicationRegistrationSourceType.NPM,
sourcePackage: '@test/squatter-app',
latestAvailableVersion: '1.0.0',
manifest: buildBaseManifest({
appId: universalIdentifier,
roleId: crypto.randomUUID(),
}),
});
expect(
await findApplicationRegistrationByUniversalIdentifier({
universalIdentifier,
}),
).toBeNull();
} finally {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: universalIdentifier,
});
}
});
it.each([
{
variant: 'uppercase',
transform: (universalIdentifier: string) =>
universalIdentifier.toUpperCase(),
},
{
variant: 'hyphenless',
transform: (universalIdentifier: string) =>
universalIdentifier.replace(/-/g, ''),
},
])(
'does not register an official identifier spelled $variant from a non-official package',
async ({ transform }) => {
const { universalIdentifier } = OFFICIAL_APPLICATION;
expect(
await findApplicationRegistrationByUniversalIdentifier({
universalIdentifier,
}),
).toBeNull();
try {
await upsertApplicationRegistrationFromCatalog({
universalIdentifier: transform(universalIdentifier),
name: 'Squatted Official App',
sourceType: ApplicationRegistrationSourceType.NPM,
sourcePackage: '@test/squatter-app',
latestAvailableVersion: '1.0.0',
manifest: buildBaseManifest({
appId: transform(universalIdentifier),
roleId: crypto.randomUUID(),
}),
});
expect(
await findApplicationRegistrationByUniversalIdentifier({
universalIdentifier,
}),
).toBeNull();
} finally {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: universalIdentifier,
});
}
},
);
it('does not let another package overwrite an existing registration through an uppercase identifier', async () => {
const universalIdentifier = crypto.randomUUID();
await insertCatalogRegistration({
universalIdentifier,
name: 'Pinned App',
sourcePackage: '@test/pinned-app',
});
await upsertApplicationRegistrationFromCatalog({
universalIdentifier: universalIdentifier.toUpperCase(),
name: 'Squatted App',
sourceType: ApplicationRegistrationSourceType.NPM,
sourcePackage: '@test/squatter-app',
latestAvailableVersion: '9.9.9',
manifest: buildBaseManifest({
appId: universalIdentifier.toUpperCase(),
roleId: crypto.randomUUID(),
}),
});
expect(
await findApplicationRegistrationByUniversalIdentifier({
universalIdentifier,
}),
).toMatchObject({
name: 'Pinned App',
sourcePackage: '@test/pinned-app',
latestAvailableVersion: '1.0.0',
});
});
it('lets the official package take back an official identifier registered from another package', async () => {
const { universalIdentifier, sourcePackage } = OFFICIAL_APPLICATION;
expect(
await findApplicationRegistrationByUniversalIdentifier({
universalIdentifier,
}),
).toBeNull();
try {
await insertCatalogApplicationRegistration({
universalIdentifier,
name: 'Squatted Official App',
sourcePackage: '@test/squatter-app',
});
await upsertApplicationRegistrationFromCatalog({
universalIdentifier,
name: 'Official App',
sourceType: ApplicationRegistrationSourceType.NPM,
sourcePackage,
latestAvailableVersion: '2.0.0',
manifest: buildBaseManifest({
appId: universalIdentifier,
roleId: crypto.randomUUID(),
}),
});
expect(
await findApplicationRegistrationByUniversalIdentifier({
universalIdentifier,
}),
).toMatchObject({
name: 'Official App',
sourcePackage,
latestAvailableVersion: '2.0.0',
});
} finally {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: universalIdentifier,
});
}
});
});
describe('catalog sync versions', () => {
const SOURCE_PACKAGE = '@test/versioned-app';
const buildCatalogParams = ({
universalIdentifier,
name,
latestAvailableVersion,
}: {
universalIdentifier: string;
name: string;
latestAvailableVersion: string | null;
}) => ({
universalIdentifier,
name,
sourceType: ApplicationRegistrationSourceType.NPM,
sourcePackage: SOURCE_PACKAGE,
latestAvailableVersion,
manifest: buildBaseManifest({
appId: universalIdentifier,
roleId: crypto.randomUUID(),
}),
});
it('does not downgrade a registration to an older catalog version', async () => {
const universalIdentifier = crypto.randomUUID();
await insertCatalogRegistration({
universalIdentifier,
name: 'Versioned App',
sourcePackage: SOURCE_PACKAGE,
latestAvailableVersion: '2.0.0',
});
const registration = await upsertApplicationRegistrationFromCatalog(
buildCatalogParams({
universalIdentifier,
name: 'Older Versioned App',
latestAvailableVersion: '1.0.0',
}),
);
expect(registration).toBeNull();
expect(
await findApplicationRegistrationByUniversalIdentifier({
universalIdentifier,
}),
).toMatchObject({
name: 'Versioned App',
latestAvailableVersion: '2.0.0',
});
});
it('does not erase a known version when the catalog entry has none', async () => {
const universalIdentifier = crypto.randomUUID();
await insertCatalogRegistration({
universalIdentifier,
name: 'Versioned App',
sourcePackage: SOURCE_PACKAGE,
latestAvailableVersion: '1.0.0',
});
const registration = await upsertApplicationRegistrationFromCatalog(
buildCatalogParams({
universalIdentifier,
name: 'Unversioned App',
latestAvailableVersion: null,
}),
);
expect(registration).toBeNull();
expect(
await findApplicationRegistrationByUniversalIdentifier({
universalIdentifier,
}),
).toMatchObject({
name: 'Versioned App',
latestAvailableVersion: '1.0.0',
});
});
it('refreshes a registration served again on the same version', async () => {
const universalIdentifier = crypto.randomUUID();
await insertCatalogRegistration({
universalIdentifier,
name: 'Versioned App',
sourcePackage: SOURCE_PACKAGE,
latestAvailableVersion: '1.0.0',
});
const registration = await upsertApplicationRegistrationFromCatalog(
buildCatalogParams({
universalIdentifier,
name: 'Renamed Versioned App',
latestAvailableVersion: '1.0.0',
}),
);
expect(registration).toMatchObject({
name: 'Renamed Versioned App',
latestAvailableVersion: '1.0.0',
});
expect(
await findApplicationRegistrationByUniversalIdentifier({
universalIdentifier,
}),
).toMatchObject({
name: 'Renamed Versioned App',
latestAvailableVersion: '1.0.0',
});
});
it('sets the version on a registration that has none', async () => {
const universalIdentifier = crypto.randomUUID();
await insertCatalogRegistration({
universalIdentifier,
name: 'Versioned App',
sourcePackage: SOURCE_PACKAGE,
latestAvailableVersion: null,
});
await upsertApplicationRegistrationFromCatalog(
buildCatalogParams({
universalIdentifier,
name: 'Versioned App',
latestAvailableVersion: '1.0.0',
}),
);
expect(
await findApplicationRegistrationByUniversalIdentifier({
universalIdentifier,
}),
).toMatchObject({ latestAvailableVersion: '1.0.0' });
});
});
describe('catalog sync assets', () => {
// 1x1 transparent PNG, so file type detection accepts the asset
const LOGO_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
'base64',
);
const buildManifestWithLogo = (universalIdentifier: string) => {
const manifest = buildBaseManifest({
appId: universalIdentifier,
roleId: crypto.randomUUID(),
});
manifest.application.logo = 'public/logo.png';
return manifest;
};
it('does not store assets from another package on an existing registration', async () => {
const universalIdentifier = crypto.randomUUID();
await insertCatalogRegistration({
universalIdentifier,
name: 'Pinned App',
sourcePackage: '@test/pinned-app',
});
await syncMarketplaceCatalogFromRegistryPackage({
packageName: '@test/squatter-app',
version: '2.0.0',
manifest: buildManifestWithLogo(universalIdentifier),
asset: LOGO_PNG,
});
expect(
await findApplicationRegistrationByUniversalIdentifier({
universalIdentifier,
}),
).toMatchObject({ logoFileId: null });
});
it('stores assets from the package the registration is bound to', async () => {
const universalIdentifier = crypto.randomUUID();
await insertCatalogRegistration({
universalIdentifier,
name: 'Pinned App',
sourcePackage: '@test/pinned-app',
});
await syncMarketplaceCatalogFromRegistryPackage({
packageName: '@test/pinned-app',
version: '2.0.0',
manifest: buildManifestWithLogo(universalIdentifier),
asset: LOGO_PNG,
});
expect(
await findApplicationRegistrationByUniversalIdentifier({
universalIdentifier,
}),
).toMatchObject({ logoFileId: expect.any(String) });
});
});
describe('installApplication', () => {
it('should fail if registration does not exist', async () => {
const res = await gqlRequest(INSTALL_MUTATION, {
universalIdentifier: crypto.randomUUID(),
}).expect(200);
expect(res.body.errors).toBeDefined();
expect(res.body.errors[0].message).toContain(
'No application registration found',
);
});
it('should install a tarball app and return the application id', async () => {
const universalIdentifier = crypto.randomUUID();
const roleId = crypto.randomUUID();
const manifest = JSON.stringify({
application: {
universalIdentifier,
displayName: 'Install Test App',
description: 'App for testing installApplication',
icon: 'IconTestPipe',
defaultRoleUniversalIdentifier: roleId,
applicationVariables: {},
packageJsonChecksum: null,
yarnLockChecksum: null,
},
roles: [
{
universalIdentifier: roleId,
label: 'Default Role',
description: 'Default role',
},
],
skills: [],
agents: [],
objects: [],
fields: [],
logicFunctions: [],
frontComponents: [],
publicAssets: [],
views: [],
navigationMenuItems: [],
pageLayouts: [],
pageLayoutTabs: [],
pageLayoutWidgets: [],
commandMenuItems: [],
});
const packageJson = JSON.stringify({
name: 'test-install-app',
version: '1.0.0',
});
const tempId = crypto.randomUUID();
const sourceDir = join(tmpdir(), `test-tarball-src-${tempId}`);
const tarballPath = join(tmpdir(), `test-tarball-${tempId}.tar.gz`);
await fs.mkdir(sourceDir, { recursive: true });
await fs.writeFile(join(sourceDir, 'manifest.json'), manifest);
await fs.writeFile(join(sourceDir, 'package.json'), packageJson);
await tar.create({ file: tarballPath, gzip: true, cwd: sourceDir }, [
'manifest.json',
'package.json',
]);
const tarballBuffer = await fs.readFile(tarballPath);
await fs.rm(sourceDir, { recursive: true, force: true });
await fs.rm(tarballPath, { force: true });
const UPLOAD_MUTATION = `
mutation UploadAppTarball($file: Upload!, $universalIdentifier: String) {
uploadAppTarball(file: $file, universalIdentifier: $universalIdentifier) {
id
universalIdentifier
name
}
}
`;
const uploadRes = await request(baseUrl)
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.field(
'operations',
JSON.stringify({
query: UPLOAD_MUTATION,
variables: { file: null, universalIdentifier },
}),
)
.field('map', JSON.stringify({ '0': ['variables.file'] }))
.attach('0', tarballBuffer, 'app.tar.gz')
.expect(200);
expect(uploadRes.body.errors).toBeUndefined();
expect(uploadRes.body.data.uploadAppTarball.id).toBeDefined();
try {
const installRes = await gqlRequest(INSTALL_MUTATION, {
universalIdentifier,
}).expect(200);
expect(installRes.body.errors).toBeUndefined();
expect(installRes.body.data.installApplication).toBeDefined();
expect(installRes.body.data.installApplication.id).toBeDefined();
} finally {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: universalIdentifier,
});
}
}, 120000);
});
});

View File

@@ -0,0 +1,184 @@
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
import { type BaseGraphQLError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util';
import {
buildNpmRegistryPackage,
type StubbedRegistryPackage,
} from 'test/integration/metadata/suites/application/utils/build-npm-registry-package.util';
import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util';
import { insertCatalogApplicationRegistration } from 'test/integration/metadata/suites/application/utils/insert-catalog-application-registration.util';
import { installApplication } from 'test/integration/metadata/suites/application/utils/install-application.util';
import {
type NpmRegistryStub,
stubNpmRegistry,
} from 'test/integration/metadata/suites/application/utils/stub-npm-registry.util';
import { getAppProviderByClassName } from 'test/integration/utils/get-app-provider-by-class-name.util';
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
import { type TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
const PACKAGE_NAME = '@twenty-test/install-identity-app';
const APP_UNIVERSAL_IDENTIFIER = 'b4a1a0e2-1b6a-4b2f-9c58-0d2a5d5b6f10';
const FOREIGN_APP_IDENTIFIER = 'e7d3c8f1-5a2b-4c9d-8e6f-1a2b3c4d5e6f';
const ROLE_UNIVERSAL_IDENTIFIER = 'a1b2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d';
const VALID_VERSION = '1.2.3';
const TRAVERSAL_VERSION = '../@evil/pkg/latest';
const SUBSTITUTE_PACKAGE_NAME = '@evil/substitute-app';
const expectPackageResolutionFailure = (errors: BaseGraphQLError[]) => {
expect(errors).toHaveLength(1);
expect(errors[0].extensions.code).toBe('INTERNAL_SERVER_ERROR');
expect(errors[0].extensions.subCode).toBe('PACKAGE_RESOLUTION_FAILED');
};
describe('npm application install package identity (integration)', () => {
let registryBaseUrl: string;
let activeStub: NpmRegistryStub | undefined;
const buildRegistryPackage = ({
manifestAppId = APP_UNIVERSAL_IDENTIFIER,
metadataName,
packageJsonName,
}: {
manifestAppId?: string;
metadataName?: string | null;
packageJsonName?: string;
} = {}): Promise<StubbedRegistryPackage> =>
buildNpmRegistryPackage({
registryBaseUrl,
packageName: PACKAGE_NAME,
version: VALID_VERSION,
manifestJson: JSON.stringify(
buildBaseManifest({
appId: manifestAppId,
roleId: ROLE_UNIVERSAL_IDENTIFIER,
}),
),
metadataName,
packageJsonName,
});
const expectedMetadataUrl = () =>
`${registryBaseUrl}/${encodeURIComponent(PACKAGE_NAME)}/${VALID_VERSION}`;
beforeAll(async () => {
jest.useRealTimers();
registryBaseUrl = getAppProviderByClassName<TwentyConfigService>(
'TwentyConfigService',
)
.get('APP_REGISTRY_URL')
.replace(/\/$/, '');
await insertCatalogApplicationRegistration({
universalIdentifier: APP_UNIVERSAL_IDENTIFIER,
name: 'Install identity test app',
sourcePackage: PACKAGE_NAME,
sourceType: ApplicationRegistrationSourceType.NPM,
});
});
afterEach(() => {
activeStub?.restore();
activeStub = undefined;
});
afterAll(async () => {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: APP_UNIVERSAL_IDENTIFIER,
});
jest.useFakeTimers();
});
it('rejects a path-traversal version before any registry call', async () => {
const stub = stubNpmRegistry(await buildRegistryPackage());
activeStub = stub;
const { errors } = await installApplication({
input: {
universalIdentifier: APP_UNIVERSAL_IDENTIFIER,
version: TRAVERSAL_VERSION,
},
expectToFail: true,
});
expectOneNotInternalServerErrorSnapshot({ errors });
expect(stub.requestedUrls).toEqual([]);
});
it('percent-encodes the request and refuses a mismatched registry package name', async () => {
const stub = stubNpmRegistry(
await buildRegistryPackage({ metadataName: SUBSTITUTE_PACKAGE_NAME }),
);
activeStub = stub;
const { errors } = await installApplication({
input: {
universalIdentifier: APP_UNIVERSAL_IDENTIFIER,
version: VALID_VERSION,
},
expectToFail: true,
});
expectPackageResolutionFailure(errors);
// The name check runs on the metadata response, so the tarball is never
// requested and the version travels percent-encoded in the path.
expect(stub.requestedUrls).toEqual([expectedMetadataUrl()]);
});
it('rejects a package whose package.json name differs from the registration', async () => {
const stub = stubNpmRegistry(
await buildRegistryPackage({ packageJsonName: SUBSTITUTE_PACKAGE_NAME }),
);
activeStub = stub;
const { errors } = await installApplication({
input: {
universalIdentifier: APP_UNIVERSAL_IDENTIFIER,
version: VALID_VERSION,
},
expectToFail: true,
});
expectPackageResolutionFailure(errors);
});
it('rejects a manifest whose application identifier differs from the registration', async () => {
const stub = stubNpmRegistry(
await buildRegistryPackage({ manifestAppId: FOREIGN_APP_IDENTIFIER }),
);
activeStub = stub;
const { errors } = await installApplication({
input: {
universalIdentifier: APP_UNIVERSAL_IDENTIFIER,
version: VALID_VERSION,
},
expectToFail: true,
});
expectPackageResolutionFailure(errors);
});
it('installs a package whose identity and digest match the registration', async () => {
const stub = stubNpmRegistry(await buildRegistryPackage());
activeStub = stub;
const { data, errors } = await installApplication({
input: {
universalIdentifier: APP_UNIVERSAL_IDENTIFIER,
version: VALID_VERSION,
},
expectToFail: false,
});
expect(errors).toBeUndefined();
expect(data.installApplication.id).toBeDefined();
expect(stub.requestedUrls[0]).toBe(expectedMetadataUrl());
});
});

View File

@@ -0,0 +1,333 @@
import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util';
import { buildDefaultObjectManifest } from 'test/integration/metadata/suites/application/utils/build-default-object-manifest.util';
import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util';
import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util';
import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util';
import { findManyObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata.util';
import { findPageLayoutTabs } from 'test/integration/metadata/suites/page-layout-tab/utils/find-page-layout-tabs.util';
import { findPageLayoutWidgets } from 'test/integration/metadata/suites/page-layout-widget/utils/find-page-layout-widgets.util';
import { findPageLayouts } from 'test/integration/metadata/suites/page-layout/utils/find-page-layouts.util';
import {
getSystemFormFieldPageLayoutWidgetUniversalIdentifier,
getSystemPageLayoutTabUniversalIdentifier,
getSystemRecordFormPageLayoutUniversalIdentifier,
type ObjectManifest,
} from 'twenty-shared/application';
import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type';
import {
FieldMetadataType,
PageLayoutType,
WidgetType,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { v4 as uuidv4 } from 'uuid';
const TEST_APP_ID = uuidv4();
const TEST_ROLE_ID = uuidv4();
const TEST_OBJECT_ID = uuidv4();
const NAME_FIELD_ID = uuidv4();
const CODE_FIELD_ID = uuidv4();
const RATING_FIELD_ID = uuidv4();
const RATING_OPTION_ID = uuidv4();
const NAME_FIELD: ObjectManifest['fields'][number] = {
universalIdentifier: NAME_FIELD_ID,
type: FieldMetadataType.TEXT,
name: 'name',
label: 'Name',
};
const CODE_FIELD: ObjectManifest['fields'][number] = {
universalIdentifier: CODE_FIELD_ID,
type: FieldMetadataType.TEXT,
name: 'code',
label: 'Code',
};
const RATING_FIELD: ObjectManifest['fields'][number] = {
universalIdentifier: RATING_FIELD_ID,
type: FieldMetadataType.RATING,
name: 'severity',
label: 'Severity',
options: [
{
id: RATING_OPTION_ID,
label: '1',
value: 'RATING_1',
position: 0,
},
],
};
const buildTicketObject = (additionalFields: ObjectManifest['fields']) =>
buildDefaultObjectManifest({
applicationUniversalIdentifier: TEST_APP_ID,
universalIdentifier: TEST_OBJECT_ID,
nameSingular: 'ticket',
namePlural: 'tickets',
labelSingular: 'Ticket',
labelPlural: 'Tickets',
description: 'A support ticket',
icon: 'IconTicket',
labelIdentifierFieldMetadataUniversalIdentifier: NAME_FIELD_ID,
additionalFields,
});
const buildManifest = (object: ObjectManifest) =>
buildBaseManifest({
appId: TEST_APP_ID,
roleId: TEST_ROLE_ID,
overrides: { objects: [object] },
});
const DERIVED_RECORD_FORM_LAYOUT_UNIVERSAL_IDENTIFIER =
getSystemRecordFormPageLayoutUniversalIdentifier({
objectMetadataApplicationUniversalIdentifier: TEST_APP_ID,
objectUniversalIdentifier: TEST_OBJECT_ID,
});
const DERIVED_RECORD_FORM_TAB_UNIVERSAL_IDENTIFIER =
getSystemPageLayoutTabUniversalIdentifier({
objectMetadataApplicationUniversalIdentifier: TEST_APP_ID,
pageLayoutUniversalIdentifier:
DERIVED_RECORD_FORM_LAYOUT_UNIVERSAL_IDENTIFIER,
title: 'Fields',
});
const derivedFormFieldWidgetUniversalIdentifier = (
fieldMetadataUniversalIdentifier: string,
) =>
getSystemFormFieldPageLayoutWidgetUniversalIdentifier({
fieldMetadataApplicationUniversalIdentifier: TEST_APP_ID,
pageLayoutTabUniversalIdentifier:
DERIVED_RECORD_FORM_TAB_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier,
});
const findTicketObject = async () => {
const { objects } = await findManyObjectMetadata({
input: {
filter: {},
paging: { first: 100 },
},
gqlFields: `
id
universalIdentifier
fieldsList {
id
name
applicationId
universalIdentifier
}
`,
expectToFail: false,
});
return objects.find(
(object) => object.universalIdentifier === TEST_OBJECT_ID,
);
};
const findRecordFormTabId = async (objectMetadataId: string) => {
const { data: pageLayoutsData } = await findPageLayouts({
input: { objectMetadataId },
gqlFields:
'id type objectMetadataId universalIdentifier applicationId isSystemSideEffect',
expectToFail: false,
});
const recordFormLayout = pageLayoutsData?.getPageLayouts.find(
(pageLayout) => pageLayout.type === PageLayoutType.RECORD_FORM,
);
if (!isDefined(recordFormLayout)) {
throw new Error('expected the engine record-form layout');
}
expect(recordFormLayout.universalIdentifier).toBe(
DERIVED_RECORD_FORM_LAYOUT_UNIVERSAL_IDENTIFIER,
);
expect(recordFormLayout.isSystemSideEffect).toBe(true);
const { data: tabsData } = await findPageLayoutTabs({
input: { pageLayoutId: recordFormLayout.id },
gqlFields: 'id title universalIdentifier isSystemSideEffect',
expectToFail: false,
});
const tabs = tabsData?.getPageLayoutTabs ?? [];
expect(tabs).toHaveLength(1);
expect(tabs[0].title).toBe('Fields');
expect(tabs[0].universalIdentifier).toBe(
DERIVED_RECORD_FORM_TAB_UNIVERSAL_IDENTIFIER,
);
expect(tabs[0].isSystemSideEffect).toBe(true);
return tabs[0].id;
};
const findRecordFormWidgets = async (pageLayoutTabId: string) => {
const { data } = await findPageLayoutWidgets({
input: { pageLayoutTabId },
gqlFields:
'id title type universalIdentifier isSystemSideEffect configuration { ... on FormFieldConfiguration { configurationType fieldMetadataId } }',
expectToFail: false,
});
return (data?.getPageLayoutWidgets ?? []).map((pageLayoutWidget) => ({
...pageLayoutWidget,
formFieldMetadataId:
pageLayoutWidget.configuration?.configurationType ===
WidgetConfigurationType.FORM_FIELD
? pageLayoutWidget.configuration.fieldMetadataId
: undefined,
}));
};
describe('Manifest sync - engine-provisioned record-form stack', () => {
beforeEach(async () => {
await setupApplicationForSync({
applicationUniversalIdentifier: TEST_APP_ID,
name: 'Test Application',
description: 'App for testing the engine-provisioned record-form stack',
sourcePath: 'test-record-form-side-effects',
});
}, 60000);
afterEach(async () => {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: TEST_APP_ID,
});
});
it('provisions the RECORD_FORM layout with one FORM_FIELD widget per creatable field, skipping the types the form has no input for', async () => {
await syncApplication({
manifest: buildManifest(
buildTicketObject([NAME_FIELD, CODE_FIELD, RATING_FIELD]),
),
expectToFail: false,
});
const ticket = await findTicketObject();
if (!isDefined(ticket)) {
throw new Error('expected the ticket object');
}
const recordFormTabId = await findRecordFormTabId(ticket.id);
const widgets = await findRecordFormWidgets(recordFormTabId);
const nameField = ticket.fieldsList?.find((field) => field.name === 'name');
const codeField = ticket.fieldsList?.find((field) => field.name === 'code');
if (!isDefined(nameField) || !isDefined(codeField)) {
throw new Error('expected the name and code fields');
}
expect(
widgets.every((widget) => widget.type === WidgetType.FORM_FIELD),
).toBe(true);
expect(widgets.every((widget) => widget.isSystemSideEffect)).toBe(true);
const widgetFieldMetadataIds = widgets.map(
(widget) => widget.formFieldMetadataId,
);
expect(widgetFieldMetadataIds).toContain(nameField.id);
expect(widgetFieldMetadataIds).toContain(codeField.id);
expect(widgets).toHaveLength(2);
const nameWidget = widgets.find(
(widget) => widget.formFieldMetadataId === nameField.id,
);
expect(nameWidget?.universalIdentifier).toBe(
derivedFormFieldWidgetUniversalIdentifier(NAME_FIELD_ID),
);
}, 60000);
it('removes the widget when a field stops being UI editable and restores it when it comes back', async () => {
await syncApplication({
manifest: buildManifest(buildTicketObject([NAME_FIELD, CODE_FIELD])),
expectToFail: false,
});
const ticket = await findTicketObject();
if (!isDefined(ticket)) {
throw new Error('expected the ticket object');
}
const recordFormTabId = await findRecordFormTabId(ticket.id);
await syncApplication({
manifest: buildManifest(
buildTicketObject([NAME_FIELD, { ...CODE_FIELD, isUIEditable: false }]),
),
expectToFail: false,
});
expect(
(await findRecordFormWidgets(recordFormTabId)).map(
(widget) => widget.universalIdentifier,
),
).toEqual([derivedFormFieldWidgetUniversalIdentifier(NAME_FIELD_ID)]);
await syncApplication({
manifest: buildManifest(buildTicketObject([NAME_FIELD, CODE_FIELD])),
expectToFail: false,
});
expect(
(await findRecordFormWidgets(recordFormTabId)).map(
(widget) => widget.universalIdentifier,
),
).toEqual(
expect.arrayContaining([
derivedFormFieldWidgetUniversalIdentifier(NAME_FIELD_ID),
derivedFormFieldWidgetUniversalIdentifier(CODE_FIELD_ID),
]),
);
}, 60000);
it('appends a widget for a field added after the object and removes it on field deletion', async () => {
await syncApplication({
manifest: buildManifest(buildTicketObject([NAME_FIELD])),
expectToFail: false,
});
await syncApplication({
manifest: buildManifest(buildTicketObject([NAME_FIELD, CODE_FIELD])),
expectToFail: false,
});
const ticket = await findTicketObject();
if (!isDefined(ticket)) {
throw new Error('expected the ticket object');
}
const recordFormTabId = await findRecordFormTabId(ticket.id);
const widgetsAfterAdd = await findRecordFormWidgets(recordFormTabId);
expect(widgetsAfterAdd.map((widget) => widget.universalIdentifier)).toEqual(
expect.arrayContaining([
derivedFormFieldWidgetUniversalIdentifier(NAME_FIELD_ID),
derivedFormFieldWidgetUniversalIdentifier(CODE_FIELD_ID),
]),
);
await syncApplication({
manifest: buildManifest(buildTicketObject([NAME_FIELD])),
expectToFail: false,
});
const widgetsAfterDelete = await findRecordFormWidgets(recordFormTabId);
expect(
widgetsAfterDelete.map((widget) => widget.universalIdentifier),
).toEqual([derivedFormFieldWidgetUniversalIdentifier(NAME_FIELD_ID)]);
}, 60000);
});

View File

@@ -0,0 +1,335 @@
import { VIEW_FIELD_GQL_FIELDS } from 'test/integration/constants/view-gql-fields.constants';
import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util';
import { buildDefaultObjectManifest } from 'test/integration/metadata/suites/application/utils/build-default-object-manifest.util';
import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util';
import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util';
import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util';
import { findManyObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata.util';
import { findPageLayoutTabs } from 'test/integration/metadata/suites/page-layout-tab/utils/find-page-layout-tabs.util';
import { findPageLayouts } from 'test/integration/metadata/suites/page-layout/utils/find-page-layouts.util';
import { findViewFields } from 'test/integration/metadata/suites/view-field/utils/find-view-fields.util';
import { findViews } from 'test/integration/metadata/suites/view/utils/find-views.util';
import {
SYSTEM_VIEW_KEYS,
getSystemPageLayoutTabUniversalIdentifier,
getSystemRecordPageLayoutUniversalIdentifier,
getSystemViewFieldUniversalIdentifier,
getSystemViewUniversalIdentifier,
type ObjectManifest,
} from 'twenty-shared/application';
import { FieldMetadataType, PageLayoutType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { v4 as uuidv4 } from 'uuid';
const TEST_APP_ID = uuidv4();
const TEST_ROLE_ID = uuidv4();
const TEST_OBJECT_ID = uuidv4();
const NAME_FIELD_ID = uuidv4();
const CODE_FIELD_ID = uuidv4();
const NAME_FIELD: ObjectManifest['fields'][number] = {
universalIdentifier: NAME_FIELD_ID,
type: FieldMetadataType.TEXT,
name: 'name',
label: 'Name',
};
const CODE_FIELD: ObjectManifest['fields'][number] = {
universalIdentifier: CODE_FIELD_ID,
type: FieldMetadataType.TEXT,
name: 'code',
label: 'Code',
};
const buildTicketObject = ({
labelIdentifierFieldMetadataUniversalIdentifier,
additionalFields,
}: {
labelIdentifierFieldMetadataUniversalIdentifier: string;
additionalFields: ObjectManifest['fields'];
}) =>
buildDefaultObjectManifest({
applicationUniversalIdentifier: TEST_APP_ID,
universalIdentifier: TEST_OBJECT_ID,
nameSingular: 'ticket',
namePlural: 'tickets',
labelSingular: 'Ticket',
labelPlural: 'Tickets',
description: 'A support ticket',
icon: 'IconTicket',
labelIdentifierFieldMetadataUniversalIdentifier,
additionalFields,
});
const buildManifest = (object: ObjectManifest) =>
buildBaseManifest({
appId: TEST_APP_ID,
roleId: TEST_ROLE_ID,
overrides: { objects: [object] },
});
const DERIVED_RECORD_PAGE_VIEW_UNIVERSAL_IDENTIFIER =
getSystemViewUniversalIdentifier({
objectMetadataApplicationUniversalIdentifier: TEST_APP_ID,
objectUniversalIdentifier: TEST_OBJECT_ID,
viewKey: SYSTEM_VIEW_KEYS.FIELDS_WIDGET,
});
const DERIVED_RECORD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER =
getSystemRecordPageLayoutUniversalIdentifier({
objectMetadataApplicationUniversalIdentifier: TEST_APP_ID,
objectUniversalIdentifier: TEST_OBJECT_ID,
});
const findTicketObject = async () => {
const { objects } = await findManyObjectMetadata({
input: {
filter: {},
paging: { first: 100 },
},
gqlFields: `
id
universalIdentifier
labelIdentifierFieldMetadataId
fieldsList {
id
name
applicationId
universalIdentifier
}
`,
expectToFail: false,
});
return objects.find(
(object) => object.universalIdentifier === TEST_OBJECT_ID,
);
};
const findTicketRecordPageView = async (objectMetadataId: string) => {
const { data } = await findViews({
objectMetadataId,
gqlFields:
'id key type universalIdentifier applicationId isSystemSideEffect',
expectToFail: false,
});
// The key is never persisted for the record-page view: it resolves strictly
// by its derived universal identifier.
return data?.getViews.find(
(view) =>
view.universalIdentifier ===
DERIVED_RECORD_PAGE_VIEW_UNIVERSAL_IDENTIFIER,
);
};
const findRecordPageViewFields = async (viewId: string) => {
const { data } = await findViewFields({
viewId,
gqlFields: VIEW_FIELD_GQL_FIELDS,
expectToFail: false,
});
return data?.getViewFields ?? [];
};
// Coverage of the record-page side-effect handlers through the manifest path:
// objectRecordPageOnCreate on object creation and
// objectRecordPageLabelIdentifierOnUpdate on relabel.
describe('Manifest sync - engine-provisioned record-page stack', () => {
beforeEach(async () => {
await setupApplicationForSync({
applicationUniversalIdentifier: TEST_APP_ID,
name: 'Test Application',
description: 'App for testing the engine-provisioned record-page stack',
sourcePath: 'test-record-page-side-effects',
});
}, 60000);
afterEach(async () => {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: TEST_APP_ID,
});
});
it('provisions the FIELDS_WIDGET view (label identifier excluded) and the RECORD_PAGE layout with its 5 tabs on object creation', async () => {
await syncApplication({
manifest: buildManifest(
buildTicketObject({
labelIdentifierFieldMetadataUniversalIdentifier: NAME_FIELD_ID,
additionalFields: [NAME_FIELD, CODE_FIELD],
}),
),
expectToFail: false,
});
const ticket = await findTicketObject();
if (!isDefined(ticket)) {
throw new Error('expected the ticket object');
}
const recordPageView = await findTicketRecordPageView(ticket.id);
expect(recordPageView).toBeDefined();
if (!isDefined(recordPageView)) {
throw new Error('expected the engine record-page view');
}
expect(recordPageView.universalIdentifier).toBe(
DERIVED_RECORD_PAGE_VIEW_UNIVERSAL_IDENTIFIER,
);
expect(recordPageView.isSystemSideEffect).toBe(true);
const nameField = ticket.fieldsList?.find((field) => field.name === 'name');
const codeField = ticket.fieldsList?.find((field) => field.name === 'code');
if (!isDefined(nameField) || !isDefined(codeField)) {
throw new Error('expected the name and code fields');
}
const recordPageViewFields = await findRecordPageViewFields(
recordPageView.id,
);
// The label identifier is displayed in the record title, never in the
// fields widget.
expect(
recordPageViewFields.some(
(viewField) => viewField.fieldMetadataId === nameField.id,
),
).toBe(false);
const codeViewField = recordPageViewFields.find(
(viewField) => viewField.fieldMetadataId === codeField.id,
);
expect(codeViewField).toBeDefined();
expect(codeViewField?.universalIdentifier).toBe(
getSystemViewFieldUniversalIdentifier({
fieldMetadataApplicationUniversalIdentifier: TEST_APP_ID,
viewUniversalIdentifier: DERIVED_RECORD_PAGE_VIEW_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier: CODE_FIELD_ID,
}),
);
expect(codeViewField?.applicationId).toBe(codeField.applicationId);
expect(codeViewField?.isSystemSideEffect).toBe(true);
const { data: pageLayoutsData } = await findPageLayouts({
input: { objectMetadataId: ticket.id },
gqlFields:
'id type objectMetadataId universalIdentifier applicationId isSystemSideEffect',
expectToFail: false,
});
const recordPageLayout = pageLayoutsData?.getPageLayouts.find(
(pageLayout) => pageLayout.type === PageLayoutType.RECORD_PAGE,
);
expect(recordPageLayout).toBeDefined();
expect(recordPageLayout?.universalIdentifier).toBe(
DERIVED_RECORD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
);
expect(recordPageLayout?.applicationId).toBe(codeField.applicationId);
expect(recordPageLayout?.isSystemSideEffect).toBe(true);
if (!isDefined(recordPageLayout)) {
throw new Error('expected the engine record-page layout');
}
const { data: tabsData } = await findPageLayoutTabs({
input: { pageLayoutId: recordPageLayout.id },
gqlFields: 'id title universalIdentifier isSystemSideEffect',
expectToFail: false,
});
const tabs = tabsData?.getPageLayoutTabs ?? [];
const tabTitles = tabs.map((tab) => tab.title);
expect(tabTitles).toHaveLength(5);
expect(tabTitles).toEqual(
expect.arrayContaining(['Home', 'Timeline', 'Tasks', 'Notes', 'Files']),
);
// Tabs carry title-keyed derived identifiers under the engine layout.
for (const tab of tabs) {
expect(tab.universalIdentifier).toBe(
getSystemPageLayoutTabUniversalIdentifier({
objectMetadataApplicationUniversalIdentifier: TEST_APP_ID,
pageLayoutUniversalIdentifier:
DERIVED_RECORD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
title: tab.title,
}),
);
expect(tab.isSystemSideEffect).toBe(true);
}
}, 60000);
it('swaps the record-page view fields when the label identifier changes', async () => {
await syncApplication({
manifest: buildManifest(
buildTicketObject({
labelIdentifierFieldMetadataUniversalIdentifier: NAME_FIELD_ID,
additionalFields: [NAME_FIELD, CODE_FIELD],
}),
),
expectToFail: false,
});
await syncApplication({
manifest: buildManifest(
buildTicketObject({
labelIdentifierFieldMetadataUniversalIdentifier: CODE_FIELD_ID,
additionalFields: [NAME_FIELD, CODE_FIELD],
}),
),
expectToFail: false,
});
const ticket = await findTicketObject();
if (!isDefined(ticket)) {
throw new Error('expected the ticket object');
}
const recordPageView = await findTicketRecordPageView(ticket.id);
if (!isDefined(recordPageView)) {
throw new Error('expected the engine record-page view');
}
const nameField = ticket.fieldsList?.find((field) => field.name === 'name');
const codeField = ticket.fieldsList?.find((field) => field.name === 'code');
if (!isDefined(nameField) || !isDefined(codeField)) {
throw new Error('expected the name and code fields');
}
const recordPageViewFields = await findRecordPageViewFields(
recordPageView.id,
);
// The new label identifier leaves the fields widget (displayed in the
// title), the previous one is restored into it.
expect(
recordPageViewFields.some(
(viewField) => viewField.fieldMetadataId === codeField.id,
),
).toBe(false);
const restoredNameViewField = recordPageViewFields.find(
(viewField) => viewField.fieldMetadataId === nameField.id,
);
expect(restoredNameViewField).toBeDefined();
expect(restoredNameViewField?.isVisible).toBe(true);
expect(restoredNameViewField?.universalIdentifier).toBe(
getSystemViewFieldUniversalIdentifier({
fieldMetadataApplicationUniversalIdentifier: TEST_APP_ID,
viewUniversalIdentifier: DERIVED_RECORD_PAGE_VIEW_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier: NAME_FIELD_ID,
}),
);
expect(restoredNameViewField?.isSystemSideEffect).toBe(true);
}, 60000);
});

View File

@@ -0,0 +1,219 @@
import { VIEW_FIELD_GQL_FIELDS } from 'test/integration/constants/view-gql-fields.constants';
import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util';
import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util';
import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util';
import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util';
import { uninstallApplication } from 'test/integration/metadata/suites/application/utils/uninstall-application.util';
import { findManyObjectMetadataWithIndexes } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata-with-indexes.util';
import { findViewFields } from 'test/integration/metadata/suites/view-field/utils/find-view-fields.util';
import { findViews } from 'test/integration/metadata/suites/view/utils/find-views.util';
import {
getSystemViewFieldUniversalIdentifier,
type FieldManifest,
type Manifest,
} from 'twenty-shared/application';
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
import { FieldMetadataType, ViewType } from 'twenty-shared/types';
import { v4 as uuidv4 } from 'uuid';
const TEST_APP_ID = uuidv4();
const TEST_ROLE_ID = uuidv4();
const TEST_FIELD_ID = uuidv4();
const PERSON_OBJECT_UNIVERSAL_IDENTIFIER =
STANDARD_OBJECTS.person.universalIdentifier;
const CUSTOM_FIELD_NAME = 'integrationContributedColumn';
const personFieldManifest: FieldManifest = {
universalIdentifier: TEST_FIELD_ID,
type: FieldMetadataType.TEXT,
name: CUSTOM_FIELD_NAME,
label: 'Integration Contributed Column',
description: 'Custom field contributed to the standard Person object',
icon: 'IconStar',
objectUniversalIdentifier: PERSON_OBJECT_UNIVERSAL_IDENTIFIER,
};
const buildManifest = (overrides?: Partial<Pick<Manifest, 'fields'>>) =>
buildBaseManifest({
appId: TEST_APP_ID,
roleId: TEST_ROLE_ID,
overrides,
});
const findPersonObject = async () => {
const objects = await findManyObjectMetadataWithIndexes({
expectToFail: false,
});
const person = objects.find(
(object) =>
object.universalIdentifier === PERSON_OBJECT_UNIVERSAL_IDENTIFIER,
);
if (!person) {
throw new Error('Standard Person object not found in workspace');
}
return person;
};
const findRecordPageViewId = async (personObjectId: string) => {
const { data } = await findViews({
objectMetadataId: personObjectId,
gqlFields: 'id key name type',
expectToFail: false,
});
const recordPageView = data?.getViews.find(
(view) => view.type === ViewType.FIELDS_WIDGET,
);
if (!recordPageView) {
throw new Error('Standard record-page view not found for Person');
}
return recordPageView.id;
};
const findRecordPageViewFields = async (viewId: string) => {
const { data } = await findViewFields({
viewId,
gqlFields: VIEW_FIELD_GQL_FIELDS,
expectToFail: false,
});
return data?.getViewFields ?? [];
};
describe('Manifest sync - engine-emitted record-page view field for an app field on a standard object', () => {
beforeEach(async () => {
await setupApplicationForSync({
applicationUniversalIdentifier: TEST_APP_ID,
name: 'Test Application',
description: 'App for testing engine-emitted record-page view fields',
sourcePath: 'test-record-page-view-field-manifest-sync',
});
}, 60000);
afterEach(async () => {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: TEST_APP_ID,
});
});
it('emits a record-page view field for the app field without any manifest declaration', async () => {
const person = await findPersonObject();
const recordPageViewId = await findRecordPageViewId(person.id);
const standardViewFields = await findRecordPageViewFields(recordPageViewId);
const standardViewFieldIds = standardViewFields.map(
(viewField) => viewField.id,
);
expect(standardViewFields.length).toBeGreaterThan(0);
const { data, errors } = await syncApplication({
manifest: buildManifest({ fields: [personFieldManifest] }),
expectToFail: false,
});
expect(errors).toBeUndefined();
expect(data?.syncApplication).toBeDefined();
const personAfterSync = await findPersonObject();
const customField = personAfterSync.fieldsList.find(
(field) => field.universalIdentifier === TEST_FIELD_ID,
);
expect(customField).toBeDefined();
const viewFieldsAfterSync =
await findRecordPageViewFields(recordPageViewId);
const emittedViewField = viewFieldsAfterSync.find(
(viewField) => viewField.fieldMetadataId === customField?.id,
);
expect(emittedViewField).toBeDefined();
expect(emittedViewField?.isVisible).toBe(true);
expect(emittedViewField?.viewFieldGroupId).not.toBeNull();
// Engine-owned but attributed to the contributing app: the identifier is
// keyed on the displayed field's application.
expect(emittedViewField?.universalIdentifier).toBe(
getSystemViewFieldUniversalIdentifier({
fieldMetadataApplicationUniversalIdentifier: TEST_APP_ID,
viewUniversalIdentifier:
STANDARD_OBJECTS.person.views.personRecordPageFields
.universalIdentifier,
fieldMetadataUniversalIdentifier: TEST_FIELD_ID,
}),
);
expect(emittedViewField?.applicationId).toBe(customField?.applicationId);
expect(emittedViewField?.isSystemSideEffect).toBe(true);
expect(viewFieldsAfterSync.length).toBe(standardViewFields.length + 1);
for (const standardViewFieldId of standardViewFieldIds) {
expect(
viewFieldsAfterSync.some(
(viewField) => viewField.id === standardViewFieldId,
),
).toBe(true);
}
}, 60000);
it('removes the emitted view field on uninstall while keeping the standard view intact', async () => {
const person = await findPersonObject();
const recordPageViewId = await findRecordPageViewId(person.id);
const standardViewFields = await findRecordPageViewFields(recordPageViewId);
const standardViewFieldIds = standardViewFields.map(
(viewField) => viewField.id,
);
await syncApplication({
manifest: buildManifest({ fields: [personFieldManifest] }),
expectToFail: false,
});
const personAfterSync = await findPersonObject();
const customFieldId = personAfterSync.fieldsList.find(
(field) => field.universalIdentifier === TEST_FIELD_ID,
)?.id;
expect(customFieldId).toBeDefined();
expect(
(await findRecordPageViewFields(recordPageViewId)).some(
(viewField) => viewField.fieldMetadataId === customFieldId,
),
).toBe(true);
await uninstallApplication({
universalIdentifier: TEST_APP_ID,
expectToFail: false,
});
const recordPageViewIdAfterUninstall = await findRecordPageViewId(
person.id,
);
expect(recordPageViewIdAfterUninstall).toBe(recordPageViewId);
const viewFieldsAfterUninstall =
await findRecordPageViewFields(recordPageViewId);
expect(
viewFieldsAfterUninstall.some(
(viewField) => viewField.fieldMetadataId === customFieldId,
),
).toBe(false);
expect(viewFieldsAfterUninstall.length).toBe(standardViewFields.length);
for (const standardViewFieldId of standardViewFieldIds) {
expect(
viewFieldsAfterUninstall.some(
(viewField) => viewField.id === standardViewFieldId,
),
).toBe(true);
}
}, 60000);
});

View File

@@ -0,0 +1,255 @@
import { VIEW_FIELD_GQL_FIELDS } from 'test/integration/constants/view-gql-fields.constants';
import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util';
import { buildDefaultObjectManifest } from 'test/integration/metadata/suites/application/utils/build-default-object-manifest.util';
import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util';
import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util';
import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util';
import { findManyObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata.util';
import { findViewFields } from 'test/integration/metadata/suites/view-field/utils/find-view-fields.util';
import { findViews } from 'test/integration/metadata/suites/view/utils/find-views.util';
import { type ObjectManifest } from 'twenty-shared/application';
import { FieldMetadataType, ViewKey } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { v4 as uuidv4 } from 'uuid';
const TEST_APP_ID = uuidv4();
const TEST_ROLE_ID = uuidv4();
const TEST_OBJECT_ID = uuidv4();
const NAME_FIELD_ID = uuidv4();
const CODE_FIELD_ID = uuidv4();
const buildTicketObject = ({
labelIdentifierFieldMetadataUniversalIdentifier,
additionalFields,
}: {
labelIdentifierFieldMetadataUniversalIdentifier: string;
additionalFields: ObjectManifest['fields'];
}) =>
buildDefaultObjectManifest({
applicationUniversalIdentifier: TEST_APP_ID,
universalIdentifier: TEST_OBJECT_ID,
nameSingular: 'ticket',
namePlural: 'tickets',
labelSingular: 'Ticket',
labelPlural: 'Tickets',
description: 'A support ticket',
icon: 'IconTicket',
labelIdentifierFieldMetadataUniversalIdentifier,
additionalFields,
});
const NAME_FIELD: ObjectManifest['fields'][number] = {
universalIdentifier: NAME_FIELD_ID,
type: FieldMetadataType.TEXT,
name: 'name',
label: 'Name',
};
const CODE_FIELD: ObjectManifest['fields'][number] = {
universalIdentifier: CODE_FIELD_ID,
type: FieldMetadataType.TEXT,
name: 'code',
label: 'Code',
};
const buildManifest = (object: ObjectManifest) =>
buildBaseManifest({
appId: TEST_APP_ID,
roleId: TEST_ROLE_ID,
overrides: { objects: [object] },
});
const findTicketObject = async () => {
const { objects } = await findManyObjectMetadata({
input: {
filter: {},
paging: { first: 100 },
},
gqlFields: `
id
universalIdentifier
labelIdentifierFieldMetadataId
fieldsList {
id
name
universalIdentifier
}
`,
expectToFail: false,
});
return objects.find(
(object) => object.universalIdentifier === TEST_OBJECT_ID,
);
};
// The label identifier view field on the INDEX view must be visible and
// strictly lowest, whatever provisioned it (fieldIndexViewFieldOnCreate on
// creation, objectIndexViewLabelIdentifierOnUpdate on relabel).
const expectLabelIdentifierIndexViewFieldVisibleAndLowest = async ({
objectMetadataId,
labelFieldMetadataId,
}: {
objectMetadataId: string;
labelFieldMetadataId: string;
}) => {
const { data: viewsData } = await findViews({
objectMetadataId,
gqlFields: 'id key',
expectToFail: false,
});
const indexView = viewsData?.getViews.find(
(view) => view.key === ViewKey.INDEX,
);
if (!isDefined(indexView)) {
throw new Error('expected an INDEX view for the ticket object');
}
const { data: viewFieldsData } = await findViewFields({
viewId: indexView.id,
gqlFields: VIEW_FIELD_GQL_FIELDS,
expectToFail: false,
});
const viewFields = viewFieldsData?.getViewFields ?? [];
const labelViewField = viewFields.find(
(viewField) => viewField.fieldMetadataId === labelFieldMetadataId,
);
if (!isDefined(labelViewField)) {
throw new Error('expected an INDEX view field for the label identifier');
}
expect(labelViewField.isVisible).toBe(true);
for (const viewField of viewFields) {
if (viewField.id !== labelViewField.id) {
expect(labelViewField.position).toBeLessThan(viewField.position);
}
}
};
describe('Manifest sync - relabel label identifier onto a newly introduced field', () => {
beforeEach(async () => {
await setupApplicationForSync({
applicationUniversalIdentifier: TEST_APP_ID,
name: 'Test Application',
description: 'App for testing label identifier relabel on manifest sync',
sourcePath: 'test-relabel-onto-new-field',
});
}, 60000);
afterEach(async () => {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: TEST_APP_ID,
});
});
it('should introduce a new field and relabel onto it in a single sync', async () => {
await syncApplication({
manifest: buildManifest(
buildTicketObject({
labelIdentifierFieldMetadataUniversalIdentifier: NAME_FIELD_ID,
additionalFields: [NAME_FIELD],
}),
),
expectToFail: false,
});
const ticketAfterFirstSync = await findTicketObject();
expect(ticketAfterFirstSync).toBeDefined();
const nameField = ticketAfterFirstSync?.fieldsList?.find(
(field) => field.name === 'name',
);
expect(nameField).toBeDefined();
expect(ticketAfterFirstSync?.labelIdentifierFieldMetadataId).toBe(
nameField?.id,
);
// Object created with a custom label identifier backed by a field created
// in the same sync: its INDEX view field is visible and strictly lowest.
if (!isDefined(ticketAfterFirstSync) || !isDefined(nameField)) {
throw new Error('expected the ticket object and its name field');
}
await expectLabelIdentifierIndexViewFieldVisibleAndLowest({
objectMetadataId: ticketAfterFirstSync.id,
labelFieldMetadataId: nameField.id,
});
await syncApplication({
manifest: buildManifest(
buildTicketObject({
labelIdentifierFieldMetadataUniversalIdentifier: CODE_FIELD_ID,
additionalFields: [NAME_FIELD, CODE_FIELD],
}),
),
expectToFail: false,
});
const ticketAfterSecondSync = await findTicketObject();
const codeField = ticketAfterSecondSync?.fieldsList?.find(
(field) => field.name === 'code',
);
expect(codeField).toBeDefined();
expect(ticketAfterSecondSync?.labelIdentifierFieldMetadataId).toBe(
codeField?.id,
);
if (!isDefined(ticketAfterSecondSync) || !isDefined(codeField)) {
throw new Error('expected the ticket object and its code field');
}
await expectLabelIdentifierIndexViewFieldVisibleAndLowest({
objectMetadataId: ticketAfterSecondSync.id,
labelFieldMetadataId: codeField.id,
});
}, 60000);
it('should relabel onto a newly introduced field when introduction and relabel are split across two syncs, exposing the enriched labelIdentifierFieldMetadataId through the metadata API', async () => {
await syncApplication({
manifest: buildManifest(
buildTicketObject({
labelIdentifierFieldMetadataUniversalIdentifier: NAME_FIELD_ID,
additionalFields: [NAME_FIELD],
}),
),
expectToFail: false,
});
await syncApplication({
manifest: buildManifest(
buildTicketObject({
labelIdentifierFieldMetadataUniversalIdentifier: NAME_FIELD_ID,
additionalFields: [NAME_FIELD, CODE_FIELD],
}),
),
expectToFail: false,
});
await syncApplication({
manifest: buildManifest(
buildTicketObject({
labelIdentifierFieldMetadataUniversalIdentifier: CODE_FIELD_ID,
additionalFields: [NAME_FIELD, CODE_FIELD],
}),
),
expectToFail: false,
});
const ticket = await findTicketObject();
const codeField = ticket?.fieldsList?.find(
(field) => field.name === 'code',
);
expect(codeField).toBeDefined();
expect(isDefined(ticket?.labelIdentifierFieldMetadataId)).toBe(true);
expect(ticket?.labelIdentifierFieldMetadataId).toBe(codeField?.id);
}, 60000);
});

View File

@@ -0,0 +1,249 @@
import { VIEW_FIELD_GQL_FIELDS } from 'test/integration/constants/view-gql-fields.constants';
import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util';
import { buildDefaultObjectManifest } from 'test/integration/metadata/suites/application/utils/build-default-object-manifest.util';
import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util';
import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util';
import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util';
import { findManyObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata.util';
import { findViewFields } from 'test/integration/metadata/suites/view-field/utils/find-view-fields.util';
import { findViews } from 'test/integration/metadata/suites/view/utils/find-views.util';
import { type ObjectManifest } from 'twenty-shared/application';
import {
FieldMetadataType,
RelationOnDeleteAction,
RelationType,
ViewKey,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { v4 as uuidv4 } from 'uuid';
const TEST_APP_ID = uuidv4();
const TEST_ROLE_ID = uuidv4();
const TICKET_OBJECT_ID = uuidv4();
const PROJECT_OBJECT_ID = uuidv4();
const TICKET_NAME_FIELD_ID = uuidv4();
const PROJECT_NAME_FIELD_ID = uuidv4();
const TICKET_PROJECT_RELATION_FIELD_ID = uuidv4();
const PROJECT_TICKETS_RELATION_FIELD_ID = uuidv4();
const TICKET_NAME_FIELD: ObjectManifest['fields'][number] = {
universalIdentifier: TICKET_NAME_FIELD_ID,
type: FieldMetadataType.TEXT,
name: 'name',
label: 'Name',
};
const PROJECT_NAME_FIELD: ObjectManifest['fields'][number] = {
universalIdentifier: PROJECT_NAME_FIELD_ID,
type: FieldMetadataType.TEXT,
name: 'name',
label: 'Name',
};
const TICKET_PROJECT_RELATION_FIELD: ObjectManifest['fields'][number] = {
universalIdentifier: TICKET_PROJECT_RELATION_FIELD_ID,
type: FieldMetadataType.RELATION,
name: 'project',
label: 'Project',
relationTargetFieldMetadataUniversalIdentifier:
PROJECT_TICKETS_RELATION_FIELD_ID,
relationTargetObjectMetadataUniversalIdentifier: PROJECT_OBJECT_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
joinColumnName: 'projectId',
onDelete: RelationOnDeleteAction.SET_NULL,
},
};
const PROJECT_TICKETS_RELATION_FIELD: ObjectManifest['fields'][number] = {
universalIdentifier: PROJECT_TICKETS_RELATION_FIELD_ID,
type: FieldMetadataType.RELATION,
name: 'tickets',
label: 'Tickets',
relationTargetFieldMetadataUniversalIdentifier:
TICKET_PROJECT_RELATION_FIELD_ID,
relationTargetObjectMetadataUniversalIdentifier: TICKET_OBJECT_ID,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
};
const buildManifest = ({
includeRelationFields,
}: {
includeRelationFields: boolean;
}) =>
buildBaseManifest({
appId: TEST_APP_ID,
roleId: TEST_ROLE_ID,
overrides: {
objects: [
buildDefaultObjectManifest({
applicationUniversalIdentifier: TEST_APP_ID,
universalIdentifier: TICKET_OBJECT_ID,
nameSingular: 'ticket',
namePlural: 'tickets',
labelSingular: 'Ticket',
labelPlural: 'Tickets',
description: 'A support ticket',
icon: 'IconTicket',
labelIdentifierFieldMetadataUniversalIdentifier:
TICKET_NAME_FIELD_ID,
additionalFields: [
TICKET_NAME_FIELD,
...(includeRelationFields ? [TICKET_PROJECT_RELATION_FIELD] : []),
],
}),
buildDefaultObjectManifest({
applicationUniversalIdentifier: TEST_APP_ID,
universalIdentifier: PROJECT_OBJECT_ID,
nameSingular: 'project',
namePlural: 'projects',
labelSingular: 'Project',
labelPlural: 'Projects',
description: 'A project',
icon: 'IconBriefcase',
labelIdentifierFieldMetadataUniversalIdentifier:
PROJECT_NAME_FIELD_ID,
additionalFields: [
PROJECT_NAME_FIELD,
...(includeRelationFields ? [PROJECT_TICKETS_RELATION_FIELD] : []),
],
}),
],
},
});
const findObjectWithFields = async (objectUniversalIdentifier: string) => {
const { objects } = await findManyObjectMetadata({
input: {
filter: {},
paging: { first: 100 },
},
gqlFields: `
id
universalIdentifier
fieldsList {
id
name
universalIdentifier
}
`,
expectToFail: false,
});
const object = objects.find(
(objectMetadata) =>
objectMetadata.universalIdentifier === objectUniversalIdentifier,
);
if (!isDefined(object)) {
throw new Error(
`expected object ${objectUniversalIdentifier} to exist after sync`,
);
}
return object;
};
const findIndexViewFields = async (objectMetadataId: string) => {
const { data: viewsData } = await findViews({
objectMetadataId,
gqlFields: 'id key',
expectToFail: false,
});
const indexView = viewsData?.getViews.find(
(view) => view.key === ViewKey.INDEX,
);
if (!isDefined(indexView)) {
throw new Error('expected an INDEX view for the object');
}
const { data: viewFieldsData } = await findViewFields({
viewId: indexView.id,
gqlFields: VIEW_FIELD_GQL_FIELDS,
expectToFail: false,
});
return viewFieldsData?.getViewFields ?? [];
};
const expectRelationIndexViewFields = async () => {
const ticketObject = await findObjectWithFields(TICKET_OBJECT_ID);
const projectObject = await findObjectWithFields(PROJECT_OBJECT_ID);
const ticketProjectField = ticketObject.fieldsList?.find(
(field) =>
field.universalIdentifier === TICKET_PROJECT_RELATION_FIELD_ID,
);
const projectTicketsField = projectObject.fieldsList?.find(
(field) =>
field.universalIdentifier === PROJECT_TICKETS_RELATION_FIELD_ID,
);
expect(ticketProjectField).toBeDefined();
expect(projectTicketsField).toBeDefined();
const ticketIndexViewFields = await findIndexViewFields(ticketObject.id);
const projectIndexViewFields = await findIndexViewFields(projectObject.id);
const ticketProjectViewField = ticketIndexViewFields.find(
(viewField) => viewField.fieldMetadataId === ticketProjectField?.id,
);
const projectTicketsViewField = projectIndexViewFields.find(
(viewField) => viewField.fieldMetadataId === projectTicketsField?.id,
);
expect(ticketProjectViewField).toBeDefined();
expect(projectTicketsViewField).toBeDefined();
// Same visibility as a relation added to a pre-existing object.
expect(ticketProjectViewField?.isVisible).toBe(true);
expect(projectTicketsViewField?.isVisible).toBe(true);
};
describe('Manifest sync - INDEX view fields for relation fields created in the same batch as their objects', () => {
beforeEach(async () => {
await setupApplicationForSync({
applicationUniversalIdentifier: TEST_APP_ID,
name: 'Test Application',
description:
'App for testing INDEX view fields of same-batch relation fields',
sourcePath: 'test-same-batch-relation-index-view-field',
});
}, 60000);
afterEach(async () => {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: TEST_APP_ID,
});
});
// Reproduces twentyhq/core-team-issues#2749: relations created in the same
// sync as their objects get no INDEX view field at all, not even a hidden
// one, while the same relations added in a later sync do (next test).
it('should provision INDEX view fields for relation fields created in the same sync as their objects', async () => {
await syncApplication({
manifest: buildManifest({ includeRelationFields: true }),
expectToFail: false,
});
await expectRelationIndexViewFields();
}, 60000);
it('should provision INDEX view fields for relation fields added to pre-existing objects in a second sync', async () => {
await syncApplication({
manifest: buildManifest({ includeRelationFields: false }),
expectToFail: false,
});
await syncApplication({
manifest: buildManifest({ includeRelationFields: true }),
expectToFail: false,
});
await expectRelationIndexViewFields();
}, 60000);
});

View File

@@ -0,0 +1,153 @@
import { VIEW_FIELD_GQL_FIELDS } from 'test/integration/constants/view-gql-fields.constants';
import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util';
import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util';
import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util';
import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util';
import { findManyObjectMetadataWithIndexes } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata-with-indexes.util';
import { findViewFields } from 'test/integration/metadata/suites/view-field/utils/find-view-fields.util';
import { findViews } from 'test/integration/metadata/suites/view/utils/find-views.util';
import {
getSystemViewFieldUniversalIdentifier,
type FieldManifest,
type Manifest,
} from 'twenty-shared/application';
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
import { FieldMetadataType, ViewKey } from 'twenty-shared/types';
import { v4 as uuidv4 } from 'uuid';
const TEST_APP_ID = uuidv4();
const TEST_ROLE_ID = uuidv4();
const TEST_FIELD_ID = uuidv4();
const PERSON_OBJECT_UNIVERSAL_IDENTIFIER =
STANDARD_OBJECTS.person.universalIdentifier;
const CUSTOM_FIELD_NAME = 'integrationContributedColumn';
const personFieldManifest: FieldManifest = {
universalIdentifier: TEST_FIELD_ID,
type: FieldMetadataType.TEXT,
name: CUSTOM_FIELD_NAME,
label: 'Integration Contributed Column',
description: 'Custom field contributed to the standard Person object',
icon: 'IconStar',
objectUniversalIdentifier: PERSON_OBJECT_UNIVERSAL_IDENTIFIER,
};
const buildManifest = (overrides?: Partial<Pick<Manifest, 'fields'>>) =>
buildBaseManifest({
appId: TEST_APP_ID,
roleId: TEST_ROLE_ID,
overrides,
});
const findPersonObject = async () => {
const objects = await findManyObjectMetadataWithIndexes({
expectToFail: false,
});
const person = objects.find(
(object) =>
object.universalIdentifier === PERSON_OBJECT_UNIVERSAL_IDENTIFIER,
);
if (!person) {
throw new Error('Standard Person object not found in workspace');
}
return person;
};
const findIndexViewId = async (personObjectId: string) => {
const { data } = await findViews({
objectMetadataId: personObjectId,
gqlFields: 'id key type',
expectToFail: false,
});
const indexView = data?.getViews.find((view) => view.key === ViewKey.INDEX);
if (!indexView) {
throw new Error('Standard INDEX view not found for Person');
}
return indexView.id;
};
// Coverage of fieldIndexViewFieldOnCreate through the manifest path on a
// standard object: the own-object variants live in
// same-batch-relation-index-view-field-manifest-sync.
describe('Manifest sync - engine-emitted INDEX view field for an app field on a standard object', () => {
beforeEach(async () => {
await setupApplicationForSync({
applicationUniversalIdentifier: TEST_APP_ID,
name: 'Test Application',
description: 'App for testing engine-emitted INDEX view fields',
sourcePath: 'test-standard-object-index-view-field-manifest-sync',
});
}, 60000);
afterEach(async () => {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: TEST_APP_ID,
});
});
it('emits a visible INDEX view field appended after the standard columns', async () => {
const person = await findPersonObject();
const indexViewId = await findIndexViewId(person.id);
const { data: viewFieldsBeforeSyncData } = await findViewFields({
viewId: indexViewId,
gqlFields: VIEW_FIELD_GQL_FIELDS,
expectToFail: false,
});
const viewFieldsBeforeSync = viewFieldsBeforeSyncData?.getViewFields ?? [];
const highestExistingActivePosition = Math.max(
...viewFieldsBeforeSync.map((viewField) => viewField.position),
);
await syncApplication({
manifest: buildManifest({ fields: [personFieldManifest] }),
expectToFail: false,
});
const personAfterSync = await findPersonObject();
const customField = personAfterSync.fieldsList.find(
(field) => field.universalIdentifier === TEST_FIELD_ID,
);
expect(customField).toBeDefined();
const { data: viewFieldsAfterSyncData } = await findViewFields({
viewId: indexViewId,
gqlFields: VIEW_FIELD_GQL_FIELDS,
expectToFail: false,
});
const emittedIndexViewField = (
viewFieldsAfterSyncData?.getViewFields ?? []
).find((viewField) => viewField.fieldMetadataId === customField?.id);
expect(emittedIndexViewField).toBeDefined();
expect(emittedIndexViewField?.isVisible).toBe(true);
expect(emittedIndexViewField?.position).toBeGreaterThan(
highestExistingActivePosition,
);
// Engine-owned but attributed to the contributing app: the identifier is
// keyed on the displayed field's application.
expect(emittedIndexViewField?.universalIdentifier).toBe(
getSystemViewFieldUniversalIdentifier({
fieldMetadataApplicationUniversalIdentifier: TEST_APP_ID,
viewUniversalIdentifier:
STANDARD_OBJECTS.person.views.allPeople.universalIdentifier,
fieldMetadataUniversalIdentifier: TEST_FIELD_ID,
}),
);
expect(emittedIndexViewField?.applicationId).toBe(
customField?.applicationId,
);
expect(emittedIndexViewField?.isSystemSideEffect).toBe(true);
}, 60000);
});

View File

@@ -0,0 +1,220 @@
import { readFileSync } from 'fs';
import { join } from 'path';
import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util';
import { completeApplicationFileUploads } from 'test/integration/metadata/suites/application/utils/complete-application-file-uploads.util';
import { createApplicationFileUploads } from 'test/integration/metadata/suites/application/utils/create-application-file-uploads.util';
import { putApplicationFileUploadTarget } from 'test/integration/metadata/suites/application/utils/put-application-file-upload-target.util';
import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util';
import { v4 as uuidv4 } from 'uuid';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
const TEST_APP_UID = uuidv4();
const TEST_WORKSPACE_ID = SEED_APPLE_WORKSPACE_ID;
const STORAGE_LOCAL_PATH = join(process.cwd(), '.local-storage');
const HANDLER_PATH = 'handler.mjs';
const HANDLER_CONTENT = 'export const main = () => "hello";';
const LOGO_PATH = 'assets/logo.svg';
const LOGO_CONTENT =
'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script><circle r="10" /></svg>';
const EXPECTED_SANITIZED_LOGO_CONTENT =
'<svg xmlns="http://www.w3.org/2000/svg"><circle r="10"></circle></svg>';
const readStoredFile = (fileFolder: string, filePath: string) =>
readFileSync(
join(
STORAGE_LOCAL_PATH,
TEST_WORKSPACE_ID,
TEST_APP_UID,
fileFolder,
filePath,
),
'utf-8',
);
describe('Direct application file upload', () => {
beforeAll(async () => {
await setupApplicationForSync({
applicationUniversalIdentifier: TEST_APP_UID,
name: 'Test Direct Application File Upload App',
description: 'App for verifying batched direct application file uploads',
sourcePath: 'test-direct-application-file-upload',
});
}, 60000);
afterAll(async () => {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: TEST_APP_UID,
});
});
it('should upload a whole batch of files without going through the server body', async () => {
jest.useRealTimers();
const handlerBuffer = Buffer.from(HANDLER_CONTENT, 'utf-8');
const logoBuffer = Buffer.from(LOGO_CONTENT, 'utf-8');
const { data: createData, errors: createErrors } =
await createApplicationFileUploads({
applicationUniversalIdentifier: TEST_APP_UID,
files: [
{
fileFolder: 'BuiltLogicFunction',
filePath: HANDLER_PATH,
size: handlerBuffer.length,
},
{
fileFolder: 'PublicAsset',
filePath: LOGO_PATH,
size: logoBuffer.length,
},
],
});
expect(createErrors).toBeUndefined();
const { targets, errors: reservationErrors } =
createData!.createApplicationFileUploads;
expect(reservationErrors).toEqual([]);
expect(targets).toHaveLength(2);
expect(targets[0].filePath).toBe(HANDLER_PATH);
expect(targets[0].fileFolder).toBe('BuiltLogicFunction');
expect(targets[1].filePath).toBe(LOGO_PATH);
const [pendingRow] = await globalThis.testDataSource.query(
`SELECT status, "mimeType" FROM core."file" WHERE id = $1`,
[targets[0].fileId],
);
expect(pendingRow.status).toBe('PENDING');
expect(pendingRow.mimeType).toBe('application/octet-stream');
const handlerResponse = await putApplicationFileUploadTarget({
uploadTarget: targets[0],
body: handlerBuffer,
});
const logoResponse = await putApplicationFileUploadTarget({
uploadTarget: targets[1],
body: logoBuffer,
});
expect(handlerResponse.status).toBe(204);
expect(logoResponse.status).toBe(204);
const { data: completeData, errors: completeRequestErrors } =
await completeApplicationFileUploads({
applicationUniversalIdentifier: TEST_APP_UID,
fileIds: targets.map((target) => target.fileId),
});
jest.useFakeTimers();
expect(completeRequestErrors).toBeUndefined();
const { files: completedFiles, errors: completionErrors } =
completeData!.completeApplicationFileUploads;
expect(completionErrors).toEqual([]);
expect(completedFiles).toHaveLength(2);
const rows = await globalThis.testDataSource.query(
`SELECT id, status, "mimeType" FROM core."file" WHERE id = ANY($1)`,
[targets.map((target) => target.fileId)],
);
expect(rows).toHaveLength(2);
expect(
rows.every((row: { status: string }) => row.status === 'UPLOADED'),
).toBe(true);
expect(readStoredFile('built-logic-function', HANDLER_PATH)).toBe(
HANDLER_CONTENT,
);
expect(readStoredFile('public-asset', LOGO_PATH)).toBe(
EXPECTED_SANITIZED_LOGO_CONTENT,
);
const logoRow = rows.find(
(row: { id: string }) => row.id === targets[1].fileId,
);
expect(logoRow.mimeType).toBe('image/svg+xml');
}, 60000);
it('should fail slow: reserve valid files and report a disallowed folder as a per-file error', async () => {
jest.useRealTimers();
const { data } = await createApplicationFileUploads({
applicationUniversalIdentifier: TEST_APP_UID,
files: [
{ fileFolder: 'Source', filePath: 'src/valid.ts', size: 10 },
{ fileFolder: 'FilesField', filePath: 'note.pdf', size: 10 },
],
});
jest.useFakeTimers();
const { targets, errors } = data!.createApplicationFileUploads;
expect(targets).toHaveLength(1);
expect(targets[0].filePath).toBe('src/valid.ts');
expect(errors).toHaveLength(1);
expect(errors[0].filePath).toBe('note.pdf');
expect(errors[0].message).toContain('Invalid fileFolder');
}, 30000);
it('should fail slow: report a path escaping the application folder as a per-file error', async () => {
jest.useRealTimers();
const { data } = await createApplicationFileUploads({
applicationUniversalIdentifier: TEST_APP_UID,
files: [
{
fileFolder: 'Source',
filePath: '../../../etc/passwd.ts',
size: 10,
},
],
});
jest.useFakeTimers();
const { targets, errors } = data!.createApplicationFileUploads;
expect(targets).toEqual([]);
expect(errors).toHaveLength(1);
expect(errors[0].filePath).toBe('../../../etc/passwd.ts');
}, 30000);
it('should fail slow: report a file whose bytes never reached storage as a per-file completion error', async () => {
jest.useRealTimers();
const { data: createData } = await createApplicationFileUploads({
applicationUniversalIdentifier: TEST_APP_UID,
files: [
{ fileFolder: 'Source', filePath: 'never-uploaded.ts', size: 10 },
],
});
const fileId = createData!.createApplicationFileUploads.targets[0].fileId;
const { data: completeData } = await completeApplicationFileUploads({
applicationUniversalIdentifier: TEST_APP_UID,
fileIds: [fileId],
});
jest.useFakeTimers();
const { files, errors } = completeData!.completeApplicationFileUploads;
expect(files).toEqual([]);
expect(errors).toHaveLength(1);
expect(errors[0].fileId).toBe(fileId);
expect(errors[0].message).toContain('has not been uploaded to storage');
}, 30000);
});

View File

@@ -0,0 +1,197 @@
import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util';
import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util';
import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util';
import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util';
import { type Manifest } from 'twenty-shared/application';
import { PageLayoutType } from 'twenty-shared/types';
import { v4 as uuidv4 } from 'uuid';
const TEST_APP_ID = uuidv4();
const TEST_ROLE_ID = uuidv4();
const TEST_PAGE_LAYOUT_ID = uuidv4();
const TEST_FIRST_TAB_ID = uuidv4();
const TEST_SECOND_TAB_ID = uuidv4();
const buildManifest = (overrides?: Partial<Pick<Manifest, 'pageLayouts'>>) =>
buildBaseManifest({
appId: TEST_APP_ID,
roleId: TEST_ROLE_ID,
overrides,
});
const findTestPageLayoutRow = async () => {
const pageLayoutRows = await globalThis.testDataSource.query(
`SELECT id, "defaultTabToFocusOnMobileAndSidePanelId" FROM core."pageLayout" WHERE "universalIdentifier" = $1`,
[TEST_PAGE_LAYOUT_ID],
);
return pageLayoutRows[0];
};
const findTestPageLayoutTabRow = async (tabUniversalIdentifier: string) => {
const pageLayoutTabRows = await globalThis.testDataSource.query(
`SELECT id, "pageLayoutId" FROM core."pageLayoutTab" WHERE "universalIdentifier" = $1`,
[tabUniversalIdentifier],
);
return pageLayoutTabRows[0];
};
describe('Manifest sync - page layout default tab', () => {
beforeEach(async () => {
await setupApplicationForSync({
applicationUniversalIdentifier: TEST_APP_ID,
name: 'Test Application',
description: 'App for testing page layout default tab manifest sync',
sourcePath: 'test-manifest-page-layout-default-tab',
});
}, 60000);
afterEach(async () => {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: TEST_APP_ID,
});
});
it('should resolve defaultTabToFocusOnMobileAndSidePanel to a tab created in the same first sync', async () => {
await syncApplication({
manifest: buildManifest({
pageLayouts: [
{
universalIdentifier: TEST_PAGE_LAYOUT_ID,
name: 'Test Page',
type: PageLayoutType.STANDALONE_PAGE,
defaultTabToFocusOnMobileAndSidePanelUniversalIdentifier:
TEST_FIRST_TAB_ID,
tabs: [
{
universalIdentifier: TEST_FIRST_TAB_ID,
title: 'Overview',
position: 0,
},
],
},
],
}),
expectToFail: false,
});
const pageLayoutRow = await findTestPageLayoutRow();
const firstTabRow = await findTestPageLayoutTabRow(TEST_FIRST_TAB_ID);
expect(pageLayoutRow).toBeDefined();
expect(firstTabRow).toBeDefined();
expect(firstTabRow.pageLayoutId).toBe(pageLayoutRow.id);
expect(pageLayoutRow.defaultTabToFocusOnMobileAndSidePanelId).toBe(
firstTabRow.id,
);
}, 60000);
it('should resolve defaultTabToFocusOnMobileAndSidePanel to a tab added in the same second sync', async () => {
await syncApplication({
manifest: buildManifest({
pageLayouts: [
{
universalIdentifier: TEST_PAGE_LAYOUT_ID,
name: 'Test Page',
type: PageLayoutType.STANDALONE_PAGE,
defaultTabToFocusOnMobileAndSidePanelUniversalIdentifier:
TEST_FIRST_TAB_ID,
tabs: [
{
universalIdentifier: TEST_FIRST_TAB_ID,
title: 'Overview',
position: 0,
},
],
},
],
}),
expectToFail: false,
});
await syncApplication({
manifest: buildManifest({
pageLayouts: [
{
universalIdentifier: TEST_PAGE_LAYOUT_ID,
name: 'Test Page',
type: PageLayoutType.STANDALONE_PAGE,
defaultTabToFocusOnMobileAndSidePanelUniversalIdentifier:
TEST_SECOND_TAB_ID,
tabs: [
{
universalIdentifier: TEST_FIRST_TAB_ID,
title: 'Overview',
position: 0,
},
{
universalIdentifier: TEST_SECOND_TAB_ID,
title: 'Insights',
position: 1,
},
],
},
],
}),
expectToFail: false,
});
const pageLayoutRow = await findTestPageLayoutRow();
const secondTabRow = await findTestPageLayoutTabRow(TEST_SECOND_TAB_ID);
expect(secondTabRow).toBeDefined();
expect(secondTabRow.pageLayoutId).toBe(pageLayoutRow.id);
expect(pageLayoutRow.defaultTabToFocusOnMobileAndSidePanelId).toBe(
secondTabRow.id,
);
}, 60000);
it('should clear defaultTabToFocusOnMobileAndSidePanel when a second sync removes it from the manifest', async () => {
await syncApplication({
manifest: buildManifest({
pageLayouts: [
{
universalIdentifier: TEST_PAGE_LAYOUT_ID,
name: 'Test Page',
type: PageLayoutType.STANDALONE_PAGE,
defaultTabToFocusOnMobileAndSidePanelUniversalIdentifier:
TEST_FIRST_TAB_ID,
tabs: [
{
universalIdentifier: TEST_FIRST_TAB_ID,
title: 'Overview',
position: 0,
},
],
},
],
}),
expectToFail: false,
});
await syncApplication({
manifest: buildManifest({
pageLayouts: [
{
universalIdentifier: TEST_PAGE_LAYOUT_ID,
name: 'Test Page',
type: PageLayoutType.STANDALONE_PAGE,
tabs: [
{
universalIdentifier: TEST_FIRST_TAB_ID,
title: 'Overview',
position: 0,
},
],
},
],
}),
expectToFail: false,
});
const pageLayoutRow = await findTestPageLayoutRow();
expect(pageLayoutRow.defaultTabToFocusOnMobileAndSidePanelId).toBeNull();
}, 60000);
});

View File

@@ -0,0 +1,125 @@
import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util';
import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util';
import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util';
import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util';
import { type Manifest } from 'twenty-shared/application';
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
import {
PageLayoutTabLayoutMode,
PageLayoutType,
ViewType,
} from 'twenty-shared/types';
import { v4 as uuidv4 } from 'uuid';
const TEST_APP_ID = uuidv4();
const TEST_ROLE_ID = uuidv4();
const TEST_VIEW_ID = uuidv4();
const TEST_LAYOUT_ID = uuidv4();
const TEST_TAB_ID = uuidv4();
const TEST_WIDGET_ID = uuidv4();
const PERSON_OBJECT_UNIVERSAL_IDENTIFIER =
STANDARD_OBJECTS.person.universalIdentifier;
const buildManifest = (
overrides?: Partial<Pick<Manifest, 'views' | 'pageLayouts'>>,
) =>
buildBaseManifest({
appId: TEST_APP_ID,
roleId: TEST_ROLE_ID,
overrides,
});
const buildManifestWithRecordTableDashboard = () =>
buildManifest({
views: [
{
universalIdentifier: TEST_VIEW_ID,
name: 'RT test view',
objectUniversalIdentifier: PERSON_OBJECT_UNIVERSAL_IDENTIFIER,
type: ViewType.TABLE,
icon: 'IconList',
position: 0,
},
],
pageLayouts: [
{
universalIdentifier: TEST_LAYOUT_ID,
name: 'RT dashboard',
type: PageLayoutType.DASHBOARD,
tabs: [
{
universalIdentifier: TEST_TAB_ID,
title: 'Tables',
position: 0,
layoutMode: PageLayoutTabLayoutMode.GRID,
widgets: [
{
universalIdentifier: TEST_WIDGET_ID,
title: 'RT test table',
type: 'RECORD_TABLE',
objectUniversalIdentifier: PERSON_OBJECT_UNIVERSAL_IDENTIFIER,
position: {
layoutMode: PageLayoutTabLayoutMode.GRID,
row: 0,
column: 0,
rowSpan: 4,
columnSpan: 6,
},
configuration: {
configurationType: 'RECORD_TABLE',
viewUniversalIdentifier: TEST_VIEW_ID,
},
},
],
},
],
},
],
});
describe('Manifest sync - RECORD_TABLE widget view universal identifier', () => {
beforeEach(async () => {
await setupApplicationForSync({
applicationUniversalIdentifier: TEST_APP_ID,
name: 'Test Application',
description:
'App for testing RECORD_TABLE widget viewUniversalIdentifier resolution',
sourcePath: 'test-manifest-record-table-view-universal-identifier',
});
}, 60000);
afterEach(async () => {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: TEST_APP_ID,
});
});
it('resolves a dashboard RECORD_TABLE widget viewUniversalIdentifier to the concrete view id', async () => {
const { data, errors } = await syncApplication({
manifest: buildManifestWithRecordTableDashboard(),
expectToFail: false,
});
expect(errors).toBeUndefined();
expect(data?.syncApplication).toBeDefined();
const [viewRow] = await globalThis.testDataSource.query(
`SELECT id FROM core."view" WHERE "universalIdentifier" = $1`,
[TEST_VIEW_ID],
);
expect(viewRow?.id).toBeDefined();
expect(viewRow.id).not.toBe(TEST_VIEW_ID);
const [widgetRow] = await globalThis.testDataSource.query(
`SELECT configuration FROM core."pageLayoutWidget" WHERE "universalIdentifier" = $1`,
[TEST_WIDGET_ID],
);
expect(widgetRow?.configuration).toEqual({
configurationType: 'RECORD_TABLE',
viewId: viewRow.id,
});
}, 60000);
});

View File

@@ -0,0 +1,354 @@
import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util';
import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util';
import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util';
import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util';
import { findManyObjectMetadataWithIndexes } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata-with-indexes.util';
import { findViews } from 'test/integration/metadata/suites/view/utils/find-views.util';
import { type FieldManifest, type Manifest } from 'twenty-shared/application';
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
import {
FieldMetadataType,
ViewFilterGroupLogicalOperator,
ViewFilterOperand,
ViewSortDirection,
ViewType,
} from 'twenty-shared/types';
import { v4 as uuidv4 } from 'uuid';
const TEST_APP_ID = uuidv4();
const TEST_ROLE_ID = uuidv4();
const TEST_FIELD_ID = uuidv4();
const SOURCE_VIEW_ID = uuidv4();
const TARGET_VIEW_ID = uuidv4();
const VIEW_FIELD_ID = uuidv4();
const VIEW_FIELD_GROUP_ID = uuidv4();
const VIEW_GROUP_ID = uuidv4();
const VIEW_FILTER_ID = uuidv4();
const VIEW_SORT_ID = uuidv4();
const VIEW_FILTER_GROUP_ID = uuidv4();
const SOURCE_VIEW_NAME = 'Reparent Source View';
const TARGET_VIEW_NAME = 'Reparent Target View';
const CUSTOM_FIELD_NAME = 'integrationReparentColumn';
const PERSON_OBJECT_UNIVERSAL_IDENTIFIER =
STANDARD_OBJECTS.person.universalIdentifier;
const personFieldManifest: FieldManifest = {
universalIdentifier: TEST_FIELD_ID,
type: FieldMetadataType.TEXT,
name: CUSTOM_FIELD_NAME,
label: 'Integration Reparent Column',
description: 'Custom field used to reparent view children between views',
icon: 'IconStar',
objectUniversalIdentifier: PERSON_OBJECT_UNIVERSAL_IDENTIFIER,
};
// Each of the six view-xxx child entities is reparentable: moving a child
// (same universalIdentifier) from one view to another must only update its
// viewId (the row is preserved, not recreated).
const buildViewChildren = () => ({
fields: [
{
universalIdentifier: VIEW_FIELD_ID,
fieldMetadataUniversalIdentifier: TEST_FIELD_ID,
position: 1,
isVisible: true,
size: 150,
},
],
fieldGroups: [
{
universalIdentifier: VIEW_FIELD_GROUP_ID,
name: 'Reparent Field Group',
position: 0,
isVisible: true,
},
],
groups: [
{
universalIdentifier: VIEW_GROUP_ID,
fieldValue: 'reparent-group-value',
position: 0,
isVisible: true,
},
],
filters: [
{
universalIdentifier: VIEW_FILTER_ID,
fieldMetadataUniversalIdentifier: TEST_FIELD_ID,
operand: ViewFilterOperand.CONTAINS,
value: 'reparent',
},
],
sorts: [
{
universalIdentifier: VIEW_SORT_ID,
fieldMetadataUniversalIdentifier: TEST_FIELD_ID,
direction: ViewSortDirection.ASC,
},
],
filterGroups: [
{
universalIdentifier: VIEW_FILTER_GROUP_ID,
logicalOperator: ViewFilterGroupLogicalOperator.AND,
},
],
});
const SOURCE_VIEW = {
universalIdentifier: SOURCE_VIEW_ID,
name: SOURCE_VIEW_NAME,
objectUniversalIdentifier: PERSON_OBJECT_UNIVERSAL_IDENTIFIER,
type: ViewType.TABLE,
icon: 'IconLayoutList',
};
const TARGET_VIEW = {
universalIdentifier: TARGET_VIEW_ID,
name: TARGET_VIEW_NAME,
objectUniversalIdentifier: PERSON_OBJECT_UNIVERSAL_IDENTIFIER,
type: ViewType.TABLE,
icon: 'IconLayoutList',
};
const buildManifestWithViews = (views: Manifest['views']): Manifest =>
buildBaseManifest({
appId: TEST_APP_ID,
roleId: TEST_ROLE_ID,
overrides: {
fields: [personFieldManifest],
views,
},
});
// Both views exist; the children live under the chosen view.
const buildManifestWithChildrenOnView = (
childrenViewUniversalIdentifier: string,
): Manifest => {
const children = buildViewChildren();
return buildManifestWithViews([
childrenViewUniversalIdentifier === SOURCE_VIEW_ID
? { ...SOURCE_VIEW, ...children }
: SOURCE_VIEW,
childrenViewUniversalIdentifier === TARGET_VIEW_ID
? { ...TARGET_VIEW, ...children }
: TARGET_VIEW,
]);
};
// The source view is removed from the manifest (deleted) while its children are
// reparented onto the target view in the very same sync.
const buildManifestWithSourceViewDeletedAndChildrenOnTarget = (): Manifest =>
buildManifestWithViews([{ ...TARGET_VIEW, ...buildViewChildren() }]);
const VIEW_WITH_CHILDREN_GQL_FIELDS = `
id
name
viewFields { id viewId }
viewFieldGroups { id viewId }
viewGroups { id viewId }
viewFilters { id viewId }
viewSorts { id viewId }
viewFilterGroups { id viewId }
`;
type ViewWithChildren = {
id: string;
name: string;
viewFields: { id: string; viewId: string }[];
viewFieldGroups: { id: string; viewId: string }[];
viewGroups: { id: string; viewId: string }[];
viewFilters: { id: string; viewId: string }[];
viewSorts: { id: string; viewId: string }[];
viewFilterGroups: { id: string; viewId: string }[];
};
const CHILD_COLLECTION_KEYS = [
'viewFields',
'viewFieldGroups',
'viewGroups',
'viewFilters',
'viewSorts',
'viewFilterGroups',
] as const satisfies readonly (keyof ViewWithChildren)[];
const findPersonObjectId = async () => {
const objects = await findManyObjectMetadataWithIndexes({
expectToFail: false,
});
const person = objects.find(
(object) =>
object.universalIdentifier === PERSON_OBJECT_UNIVERSAL_IDENTIFIER,
);
if (!person) {
throw new Error('Standard Person object not found in workspace');
}
return person.id;
};
const findPersonViewsWithChildren = async (
personObjectId: string,
): Promise<ViewWithChildren[]> => {
const { data } = await findViews({
objectMetadataId: personObjectId,
gqlFields: VIEW_WITH_CHILDREN_GQL_FIELDS,
expectToFail: false,
});
return (data?.getViews ?? []) as unknown as ViewWithChildren[];
};
const findReparentViews = async (personObjectId: string) => {
const views = await findPersonViewsWithChildren(personObjectId);
const sourceView = views.find((view) => view.name === SOURCE_VIEW_NAME);
const targetView = views.find((view) => view.name === TARGET_VIEW_NAME);
if (!sourceView || !targetView) {
throw new Error('Reparent source and/or target views not found');
}
return { sourceView, targetView };
};
const getChildIdsByCollection = (view: ViewWithChildren) =>
Object.fromEntries(
CHILD_COLLECTION_KEYS.map((childCollectionKey) => [
childCollectionKey,
view[childCollectionKey][0].id,
]),
) as Record<(typeof CHILD_COLLECTION_KEYS)[number], string>;
describe('Manifest reparent - all view children are reparentable', () => {
beforeEach(async () => {
await setupApplicationForSync({
applicationUniversalIdentifier: TEST_APP_ID,
name: 'Test Application',
description: 'App for testing view children reparenting across views',
sourcePath: 'test-manifest-reparent-view-children',
});
}, 60000);
afterEach(async () => {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: TEST_APP_ID,
});
});
it('moves every view child from the source view to the target view by only updating viewId', async () => {
const personObjectId = await findPersonObjectId();
const { errors: firstSyncErrors } = await syncApplication({
manifest: buildManifestWithChildrenOnView(SOURCE_VIEW_ID),
expectToFail: false,
});
expect(firstSyncErrors).toBeUndefined();
const {
sourceView: sourceAfterFirstSync,
targetView: targetAfterFirstSync,
} = await findReparentViews(personObjectId);
// Every child collection starts on the source view with exactly one row,
// and the target view has none.
for (const childCollectionKey of CHILD_COLLECTION_KEYS) {
expect(sourceAfterFirstSync[childCollectionKey]).toHaveLength(1);
expect(sourceAfterFirstSync[childCollectionKey][0].viewId).toBe(
sourceAfterFirstSync.id,
);
expect(targetAfterFirstSync[childCollectionKey]).toHaveLength(0);
}
const childIdsByCollectionBeforeReparent =
getChildIdsByCollection(sourceAfterFirstSync);
const { errors: secondSyncErrors } = await syncApplication({
manifest: buildManifestWithChildrenOnView(TARGET_VIEW_ID),
expectToFail: false,
});
expect(secondSyncErrors).toBeUndefined();
const { sourceView: sourceAfterReparent, targetView: targetAfterReparent } =
await findReparentViews(personObjectId);
for (const childCollectionKey of CHILD_COLLECTION_KEYS) {
expect(sourceAfterReparent[childCollectionKey]).toHaveLength(0);
// The target view now owns exactly the same row (same id), proving the
// reparent updated viewId instead of deleting and recreating the child.
expect(targetAfterReparent[childCollectionKey]).toHaveLength(1);
expect(targetAfterReparent[childCollectionKey][0].id).toBe(
childIdsByCollectionBeforeReparent[childCollectionKey],
);
expect(targetAfterReparent[childCollectionKey][0].viewId).toBe(
targetAfterReparent.id,
);
}
}, 60000);
it('reparents every view child onto the target view when the source view is deleted in the same sync', async () => {
const personObjectId = await findPersonObjectId();
const { errors: firstSyncErrors } = await syncApplication({
manifest: buildManifestWithChildrenOnView(SOURCE_VIEW_ID),
expectToFail: false,
});
expect(firstSyncErrors).toBeUndefined();
const { sourceView: sourceAfterFirstSync } =
await findReparentViews(personObjectId);
const childIdsByCollectionBeforeReparent =
getChildIdsByCollection(sourceAfterFirstSync);
// Second sync deletes the source view and reparents its children onto the
// target view at once. Because viewId is onDelete: CASCADE, this only
// succeeds if the reparent updates run before the source view deletion.
const { errors: secondSyncErrors } = await syncApplication({
manifest: buildManifestWithSourceViewDeletedAndChildrenOnTarget(),
expectToFail: false,
});
expect(secondSyncErrors).toBeUndefined();
const viewsAfterReparent =
await findPersonViewsWithChildren(personObjectId);
const sourceAfterReparent = viewsAfterReparent.find(
(view) => view.name === SOURCE_VIEW_NAME,
);
const targetAfterReparent = viewsAfterReparent.find(
(view) => view.name === TARGET_VIEW_NAME,
);
expect(sourceAfterReparent).toBeUndefined();
if (!targetAfterReparent) {
throw new Error('Reparent target view not found after sync');
}
for (const childCollectionKey of CHILD_COLLECTION_KEYS) {
// Each child survived the source view deletion and now lives on the
// target view as the very same row (same id) instead of being
// cascade-deleted along with the source view.
expect(targetAfterReparent[childCollectionKey]).toHaveLength(1);
expect(targetAfterReparent[childCollectionKey][0].id).toBe(
childIdsByCollectionBeforeReparent[childCollectionKey],
);
expect(targetAfterReparent[childCollectionKey][0].viewId).toBe(
targetAfterReparent.id,
);
}
}, 60000);
});

View File

@@ -0,0 +1,226 @@
import { findAgents } from 'test/integration/metadata/suites/agent/utils/find-agents.util';
import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util';
import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util';
import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util';
import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util';
import {
getRoleTargetUniversalIdentifier,
type Manifest,
} from 'twenty-shared/application';
import { v4 as uuidv4 } from 'uuid';
const TEST_APP_ID = uuidv4();
const TEST_ROLE_ID = uuidv4();
const TEST_SECOND_ROLE_ID = uuidv4();
const TEST_AGENT_ID = uuidv4();
const AGENT_GQL_FIELDS = 'id name label roleId applicationId';
const buildManifest = (
overrides?: Partial<Pick<Manifest, 'agents' | 'roles'>>,
) =>
buildBaseManifest({
appId: TEST_APP_ID,
roleId: TEST_ROLE_ID,
overrides: {
roles: [
{
universalIdentifier: TEST_ROLE_ID,
label: 'Test Role',
description: 'A test role',
canBeAssignedToAgents: true,
},
{
universalIdentifier: TEST_SECOND_ROLE_ID,
label: 'Second Agent Role',
description: 'Another agent-assignable role',
canBeAssignedToAgents: true,
},
],
...overrides,
},
});
const findTestApplicationId = async (): Promise<string> => {
const rows = await global.testDataSource.query(
`SELECT id FROM core."application" WHERE "universalIdentifier" = $1`,
[TEST_APP_ID],
);
return rows[0].id;
};
const findAppAgent = async () => {
const testApplicationId = await findTestApplicationId();
const { data } = await findAgents({
gqlFields: AGENT_GQL_FIELDS,
expectToFail: false,
input: undefined,
});
return data.findManyAgents.find(
(agent) =>
agent.applicationId === testApplicationId &&
agent.name === 'sales-assistant',
);
};
describe('Manifest sync - agent roleTarget', () => {
beforeEach(async () => {
await setupApplicationForSync({
applicationUniversalIdentifier: TEST_APP_ID,
name: 'Test Application',
description: 'App for testing agent roleTarget manifest sync',
sourcePath: 'test-manifest-sync-agent-role-target',
});
}, 60000);
afterEach(async () => {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: TEST_APP_ID,
});
});
it('should create a roleTarget when an agent declares roleUniversalIdentifier', async () => {
await syncApplication({
manifest: buildManifest({
agents: [
{
universalIdentifier: TEST_AGENT_ID,
name: 'sales-assistant',
label: 'Sales Assistant',
prompt: 'You are a sales assistant.',
roleUniversalIdentifier: TEST_ROLE_ID,
},
],
}),
expectToFail: false,
});
const agent = await findAppAgent();
expect(agent).toBeDefined();
expect(agent?.roleId).toBeDefined();
const rows = await global.testDataSource.query(
`
SELECT "roleId", "agentId", "universalIdentifier"
FROM "core"."roleTarget"
WHERE "agentId" = $1
`,
[agent?.id],
);
expect(rows).toHaveLength(1);
expect(rows[0].universalIdentifier).toBe(
getRoleTargetUniversalIdentifier({
applicationUniversalIdentifier: TEST_APP_ID,
agentUniversalIdentifier: TEST_AGENT_ID,
}),
);
}, 60000);
it('should update the roleTarget when roleUniversalIdentifier changes', async () => {
await syncApplication({
manifest: buildManifest({
agents: [
{
universalIdentifier: TEST_AGENT_ID,
name: 'sales-assistant',
label: 'Sales Assistant',
prompt: 'You are a sales assistant.',
roleUniversalIdentifier: TEST_ROLE_ID,
},
],
}),
expectToFail: false,
});
const agentAfterFirstSync = await findAppAgent();
const firstRoleId = agentAfterFirstSync?.roleId;
expect(firstRoleId).toBeDefined();
await syncApplication({
manifest: buildManifest({
agents: [
{
universalIdentifier: TEST_AGENT_ID,
name: 'sales-assistant',
label: 'Sales Assistant',
prompt: 'You are a sales assistant.',
roleUniversalIdentifier: TEST_SECOND_ROLE_ID,
},
],
}),
expectToFail: false,
});
const agentAfterSecondSync = await findAppAgent();
expect(agentAfterSecondSync?.roleId).toBeDefined();
expect(agentAfterSecondSync?.roleId).not.toBe(firstRoleId);
const rows = await global.testDataSource.query(
`
SELECT "roleId", "agentId"
FROM "core"."roleTarget"
WHERE "agentId" = $1
`,
[agentAfterSecondSync?.id],
);
expect(rows).toHaveLength(1);
expect(rows[0].roleId).toBe(agentAfterSecondSync?.roleId);
}, 60000);
it('should delete the roleTarget when roleUniversalIdentifier is removed', async () => {
await syncApplication({
manifest: buildManifest({
agents: [
{
universalIdentifier: TEST_AGENT_ID,
name: 'sales-assistant',
label: 'Sales Assistant',
prompt: 'You are a sales assistant.',
roleUniversalIdentifier: TEST_ROLE_ID,
},
],
}),
expectToFail: false,
});
const agentAfterFirstSync = await findAppAgent();
expect(agentAfterFirstSync?.roleId).toBeDefined();
await syncApplication({
manifest: buildManifest({
agents: [
{
universalIdentifier: TEST_AGENT_ID,
name: 'sales-assistant',
label: 'Sales Assistant',
prompt: 'You are a sales assistant.',
},
],
}),
expectToFail: false,
});
const agentAfterSecondSync = await findAppAgent();
expect(agentAfterSecondSync?.roleId).toBeNull();
const rows = await global.testDataSource.query(
`
SELECT "id"
FROM "core"."roleTarget"
WHERE "agentId" = $1
`,
[agentAfterSecondSync?.id],
);
expect(rows).toHaveLength(0);
}, 60000);
});

View File

@@ -0,0 +1,226 @@
import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util';
import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util';
import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util';
import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util';
import { uninstallApplication } from 'test/integration/metadata/suites/application/utils/uninstall-application.util';
import { type FieldManifest, type Manifest } from 'twenty-shared/application';
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
import {
FieldMetadataType,
RowLevelPermissionPredicateOperand,
} from 'twenty-shared/types';
import { v4 as uuidv4 } from 'uuid';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
const TEST_WORKSPACE_ID = SEED_APPLE_WORKSPACE_ID;
const TEST_APP_ID = uuidv4();
const TEST_ROLE_ID = uuidv4();
const TEST_OBJECT_PERMISSION_ID = uuidv4();
const TEST_FIELD_ID = uuidv4();
const TEST_PREDICATE_ID = uuidv4();
const PERSON_OBJECT_UNIVERSAL_IDENTIFIER =
STANDARD_OBJECTS.person.universalIdentifier;
const WORKSPACE_MEMBER_ID_FIELD_UNIVERSAL_IDENTIFIER =
STANDARD_OBJECTS.workspaceMember.fields.id.universalIdentifier;
const personScopingFieldManifest: FieldManifest = {
universalIdentifier: TEST_FIELD_ID,
type: FieldMetadataType.TEXT,
name: 'integrationRlsScopingColumn',
label: 'Integration RLS Scoping Column',
description: 'Custom field a row-level predicate scopes on',
icon: 'IconLock',
objectUniversalIdentifier: PERSON_OBJECT_UNIVERSAL_IDENTIFIER,
};
const buildManifestWithPredicates = (
predicates: NonNullable<
Manifest['roles'][number]['rowLevelPermissionPredicates']
>,
): Manifest =>
buildBaseManifest({
appId: TEST_APP_ID,
roleId: TEST_ROLE_ID,
overrides: {
fields: [personScopingFieldManifest],
roles: [
{
universalIdentifier: TEST_ROLE_ID,
label: 'RLS Test Role',
description: 'Role exercising declarative row-level predicates',
objectPermissions: [
{
universalIdentifier: TEST_OBJECT_PERMISSION_ID,
objectUniversalIdentifier: PERSON_OBJECT_UNIVERSAL_IDENTIFIER,
canReadObjectRecords: true,
},
],
rowLevelPermissionPredicates: predicates,
},
],
},
});
const partnerUserStylePredicate = (
operand: RowLevelPermissionPredicateOperand,
) => ({
universalIdentifier: TEST_PREDICATE_ID,
objectUniversalIdentifier: PERSON_OBJECT_UNIVERSAL_IDENTIFIER,
fieldUniversalIdentifier: TEST_FIELD_ID,
operand,
workspaceMemberFieldUniversalIdentifier:
WORKSPACE_MEMBER_ID_FIELD_UNIVERSAL_IDENTIFIER,
});
type PredicateRow = {
id: string;
operand: string;
roleId: string;
objectMetadataId: string;
fieldMetadataId: string;
workspaceMemberFieldMetadataId: string | null;
applicationId: string;
};
const findActivePredicateRows = async (): Promise<PredicateRow[]> =>
globalThis.testDataSource.query(
`SELECT id, operand, "roleId", "objectMetadataId", "fieldMetadataId",
"workspaceMemberFieldMetadataId", "applicationId"
FROM core."rowLevelPermissionPredicate"
WHERE "universalIdentifier" = $1 AND "workspaceId" = $2 AND "deletedAt" IS NULL`,
[TEST_PREDICATE_ID, TEST_WORKSPACE_ID],
);
const findApplicationId = async (): Promise<string> => {
const rows = await globalThis.testDataSource.query(
`SELECT id FROM core."application"
WHERE "universalIdentifier" = $1 AND "workspaceId" = $2`,
[TEST_APP_ID, TEST_WORKSPACE_ID],
);
return rows[0]?.id;
};
const findRoleId = async (): Promise<string> => {
const rows = await globalThis.testDataSource.query(
`SELECT id FROM core."role"
WHERE "universalIdentifier" = $1 AND "workspaceId" = $2`,
[TEST_ROLE_ID, TEST_WORKSPACE_ID],
);
return rows[0]?.id;
};
const findPersonObjectId = async (): Promise<string> => {
const rows = await globalThis.testDataSource.query(
`SELECT id FROM core."objectMetadata"
WHERE "universalIdentifier" = $1 AND "workspaceId" = $2`,
[PERSON_OBJECT_UNIVERSAL_IDENTIFIER, TEST_WORKSPACE_ID],
);
return rows[0]?.id;
};
describe('Manifest sync - row level permission predicates declared on a role', () => {
beforeEach(async () => {
await setupApplicationForSync({
applicationUniversalIdentifier: TEST_APP_ID,
name: 'Test RLS Predicate Application',
description: 'App for testing declarative row-level predicate sync',
sourcePath: 'test-manifest-rls-predicate',
});
}, 60000);
afterEach(async () => {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: TEST_APP_ID,
});
});
it('creates a row-level predicate owned by the app and pointing at the declared role/object/field', async () => {
const { errors } = await syncApplication({
manifest: buildManifestWithPredicates([
partnerUserStylePredicate(RowLevelPermissionPredicateOperand.IS),
]),
expectToFail: false,
});
expect(errors).toBeUndefined();
const [applicationId, roleId, personObjectId, predicateRows] =
await Promise.all([
findApplicationId(),
findRoleId(),
findPersonObjectId(),
findActivePredicateRows(),
]);
expect(predicateRows).toHaveLength(1);
const predicate = predicateRows[0];
expect(predicate.operand).toBe(RowLevelPermissionPredicateOperand.IS);
expect(predicate.roleId).toBe(roleId);
expect(predicate.objectMetadataId).toBe(personObjectId);
expect(predicate.fieldMetadataId).toBeTruthy();
expect(predicate.workspaceMemberFieldMetadataId).toBeTruthy();
expect(predicate.applicationId).toBe(applicationId);
}, 60000);
it('updates the predicate in place on re-sync and removes it when dropped from the manifest', async () => {
await syncApplication({
manifest: buildManifestWithPredicates([
partnerUserStylePredicate(RowLevelPermissionPredicateOperand.IS),
]),
expectToFail: false,
});
const createdRows = await findActivePredicateRows();
expect(createdRows).toHaveLength(1);
const predicateId = createdRows[0].id;
await syncApplication({
manifest: buildManifestWithPredicates([
partnerUserStylePredicate(RowLevelPermissionPredicateOperand.IS_NOT),
]),
expectToFail: false,
});
const updatedRows = await findActivePredicateRows();
expect(updatedRows).toHaveLength(1);
expect(updatedRows[0].id).toBe(predicateId);
expect(updatedRows[0].operand).toBe(
RowLevelPermissionPredicateOperand.IS_NOT,
);
await syncApplication({
manifest: buildManifestWithPredicates([]),
expectToFail: false,
});
expect(await findActivePredicateRows()).toHaveLength(0);
}, 60000);
it('removes the predicate on uninstall', async () => {
await syncApplication({
manifest: buildManifestWithPredicates([
partnerUserStylePredicate(RowLevelPermissionPredicateOperand.IS),
]),
expectToFail: false,
});
expect(await findActivePredicateRows()).toHaveLength(1);
await uninstallApplication({
universalIdentifier: TEST_APP_ID,
expectToFail: false,
});
expect(await findActivePredicateRows()).toHaveLength(0);
}, 60000);
});

View File

@@ -0,0 +1,208 @@
import { findAgents } from 'test/integration/metadata/suites/agent/utils/find-agents.util';
import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util';
import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util';
import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util';
import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util';
import { type Manifest } from 'twenty-shared/application';
import { v4 as uuidv4 } from 'uuid';
const TEST_APP_ID = uuidv4();
const TEST_ROLE_ID = uuidv4();
const TEST_AGENT_ID = uuidv4();
const TEST_SECOND_AGENT_ID = uuidv4();
const AGENT_GQL_FIELDS =
'id name label description prompt icon applicationId modelId';
const buildManifest = (overrides?: Partial<Pick<Manifest, 'agents'>>) =>
buildBaseManifest({ appId: TEST_APP_ID, roleId: TEST_ROLE_ID, overrides });
const findAppAgents = async () => {
const { data } = await findAgents({
gqlFields: AGENT_GQL_FIELDS,
expectToFail: false,
input: undefined,
});
return data.findManyAgents.filter(
(agent) => agent.name === 'sales-assistant' || agent.name === 'support-bot',
);
};
describe('Manifest update - agents', () => {
beforeEach(async () => {
await setupApplicationForSync({
applicationUniversalIdentifier: TEST_APP_ID,
name: 'Test Application',
description: 'App for testing agent manifest updates',
sourcePath: 'test-manifest-update-agent',
});
}, 60000);
afterEach(async () => {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: TEST_APP_ID,
});
});
it('should create a new agent when added to manifest on second sync', async () => {
await syncApplication({
manifest: buildManifest({ agents: [] }),
expectToFail: false,
});
const agentsAfterFirstSync = await findAppAgents();
expect(agentsAfterFirstSync).toHaveLength(0);
await syncApplication({
manifest: buildManifest({
agents: [
{
universalIdentifier: TEST_AGENT_ID,
name: 'sales-assistant',
label: 'Sales Assistant',
description: 'An AI agent that helps with sales tasks',
icon: 'IconRobot',
prompt:
'You are a sales assistant. Help users with prospecting and outreach.',
},
],
}),
expectToFail: false,
});
const agentsAfterSecondSync = await findAppAgents();
expect(agentsAfterSecondSync).toHaveLength(1);
expect(agentsAfterSecondSync[0]).toMatchObject({
name: 'sales-assistant',
label: 'Sales Assistant',
description: 'An AI agent that helps with sales tasks',
icon: 'IconRobot',
prompt:
'You are a sales assistant. Help users with prospecting and outreach.',
});
}, 60000);
it('should update an agent when properties change in manifest on second sync', async () => {
await syncApplication({
manifest: buildManifest({
agents: [
{
universalIdentifier: TEST_AGENT_ID,
name: 'sales-assistant',
label: 'Sales Assistant',
description: 'An AI agent that helps with sales tasks',
icon: 'IconRobot',
prompt:
'You are a sales assistant. Help users with prospecting and outreach.',
},
],
}),
expectToFail: false,
});
const agentsAfterFirstSync = await findAppAgents();
expect(agentsAfterFirstSync).toHaveLength(1);
expect(agentsAfterFirstSync[0]).toMatchObject({
label: 'Sales Assistant',
description: 'An AI agent that helps with sales tasks',
});
await syncApplication({
manifest: buildManifest({
agents: [
{
universalIdentifier: TEST_AGENT_ID,
name: 'sales-assistant',
label: 'Senior Sales Assistant',
description: 'An advanced AI agent for enterprise sales',
icon: 'IconRobot',
prompt:
'You are a senior sales assistant specializing in enterprise deals. Help users with complex prospecting, multi-threaded outreach, and deal strategy.',
},
],
}),
expectToFail: false,
});
const agentsAfterSecondSync = await findAppAgents();
expect(agentsAfterSecondSync).toHaveLength(1);
expect(agentsAfterSecondSync[0]).toMatchObject({
name: 'sales-assistant',
label: 'Senior Sales Assistant',
description: 'An advanced AI agent for enterprise sales',
prompt:
'You are a senior sales assistant specializing in enterprise deals. Help users with complex prospecting, multi-threaded outreach, and deal strategy.',
});
}, 60000);
it('should delete an agent when removed from manifest on second sync', async () => {
await syncApplication({
manifest: buildManifest({
agents: [
{
universalIdentifier: TEST_AGENT_ID,
name: 'sales-assistant',
label: 'Sales Assistant',
description: 'An AI agent that helps with sales tasks',
icon: 'IconRobot',
prompt:
'You are a sales assistant. Help users with prospecting and outreach.',
},
{
universalIdentifier: TEST_SECOND_AGENT_ID,
name: 'support-bot',
label: 'Support Bot',
description: 'An AI agent for customer support',
icon: 'IconHeadset',
prompt:
'You are a customer support agent. Help users resolve their issues.',
},
],
}),
expectToFail: false,
});
const agentsAfterFirstSync = await findAppAgents();
expect(agentsAfterFirstSync).toHaveLength(2);
expect(
agentsAfterFirstSync.find((agent) => agent.name === 'sales-assistant'),
).toBeDefined();
expect(
agentsAfterFirstSync.find((agent) => agent.name === 'support-bot'),
).toBeDefined();
await syncApplication({
manifest: buildManifest({
agents: [
{
universalIdentifier: TEST_AGENT_ID,
name: 'sales-assistant',
label: 'Sales Assistant',
description: 'An AI agent that helps with sales tasks',
icon: 'IconRobot',
prompt:
'You are a sales assistant. Help users with prospecting and outreach.',
},
],
}),
expectToFail: false,
});
const agentsAfterSecondSync = await findAppAgents();
expect(agentsAfterSecondSync).toHaveLength(1);
expect(agentsAfterSecondSync[0]).toMatchObject({
name: 'sales-assistant',
label: 'Sales Assistant',
});
expect(
agentsAfterSecondSync.find((agent) => agent.name === 'support-bot'),
).toBeUndefined();
}, 60000);
});

Some files were not shown because too many files have changed in this diff Show More