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,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);
});

View File

@@ -0,0 +1,138 @@
import crypto from 'crypto';
import gql from 'graphql-tag';
import { type Manifest } from 'twenty-shared/application';
import { isDefined } from 'twenty-shared/utils';
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 { findOneApplicationIdByUniversalIdentifier } from 'test/integration/secret-encryption/utils/find-one-application.util';
import { updateOneApplicationVariable } from 'test/integration/secret-encryption/utils/update-one-application-variable.util';
const TEST_APP_ID = crypto.randomUUID();
const TEST_ROLE_ID = crypto.randomUUID();
const VARIABLE_ID = crypto.randomUUID();
const VARIABLE_KEY = 'API_KEY';
const OPERATOR_SET_VALUE = 'operator-entered-api-key';
const buildManifest = ({
isDeprecated,
}: {
isDeprecated?: boolean;
}): Manifest => {
const baseManifest = buildBaseManifest({
appId: TEST_APP_ID,
roleId: TEST_ROLE_ID,
});
return {
...baseManifest,
application: {
...baseManifest.application,
applicationVariables: {
[VARIABLE_KEY]: {
universalIdentifier: VARIABLE_ID,
description: 'Third-party API key',
...(isDefined(isDeprecated) && { isDeprecated }),
},
},
},
};
};
const readVariableRow = async () => {
const [row] = await globalThis.testDataSource.query(
`SELECT key, value, "isDeprecated" FROM core."applicationVariable"
WHERE "universalIdentifier" = $1`,
[VARIABLE_ID],
);
return row;
};
describe('Manifest sync - deprecating an application variable', () => {
let applicationId: string;
beforeAll(async () => {
await setupApplicationForSync({
applicationUniversalIdentifier: TEST_APP_ID,
name: 'Test Deprecated Variable App',
description: 'App for testing application variable deprecation',
sourcePath: 'test-application-variable-is-deprecated',
});
await syncApplication({
manifest: buildManifest({}),
expectToFail: false,
});
applicationId = await findOneApplicationIdByUniversalIdentifier({
universalIdentifier: TEST_APP_ID,
});
}, 120000);
afterAll(async () => {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: TEST_APP_ID,
});
});
it('should default isDeprecated to false when the manifest omits it', async () => {
const row = await readVariableRow();
expect(row.key).toBe(VARIABLE_KEY);
expect(row.isDeprecated).toBe(false);
});
it('should flip isDeprecated on re-sync while preserving the operator-set value', async () => {
await updateOneApplicationVariable({
key: VARIABLE_KEY,
value: OPERATOR_SET_VALUE,
applicationId,
});
const { value: valueBeforeDeprecation } = await readVariableRow();
expect(valueBeforeDeprecation).toMatch(/^enc:v2:/);
await syncApplication({
manifest: buildManifest({ isDeprecated: true }),
expectToFail: false,
});
const rowAfterDeprecation = await readVariableRow();
expect(rowAfterDeprecation.isDeprecated).toBe(true);
expect(rowAfterDeprecation.value).toBe(valueBeforeDeprecation);
});
it('should still expose a deprecated variable to the application', async () => {
const { data } = await makeMetadataAPIRequest({
query: gql`
query FindAppVariablesForDeprecatedVariableTest(
$universalIdentifier: UUID!
) {
findOneApplication(universalIdentifier: $universalIdentifier) {
applicationVariables {
key
value
isDeprecated
}
}
}
`,
variables: { universalIdentifier: TEST_APP_ID },
}).then((response) => response.body);
expect(data.findOneApplication.applicationVariables).toEqual([
{
key: VARIABLE_KEY,
value: OPERATOR_SET_VALUE,
isDeprecated: true,
},
]);
});
});

View File

@@ -0,0 +1,270 @@
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 { type Manifest } from 'twenty-shared/application';
import { v4 as uuidv4 } from 'uuid';
const TEST_APP_ID = uuidv4();
const TEST_ROLE_ID = uuidv4();
const TEST_PROVIDER_ID = uuidv4();
const TEST_SECOND_PROVIDER_ID = uuidv4();
const buildManifest = (
overrides?: Partial<Pick<Manifest, 'connectionProviders'>>,
) => buildBaseManifest({ appId: TEST_APP_ID, roleId: TEST_ROLE_ID, overrides });
describe('Manifest update - connection providers', () => {
beforeEach(async () => {
await setupApplicationForSync({
applicationUniversalIdentifier: TEST_APP_ID,
name: 'Test Application',
description: 'App for testing connection-provider manifest updates',
sourcePath: 'test-manifest-update-connection-provider',
});
}, 60000);
afterEach(async () => {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: TEST_APP_ID,
});
});
it('should create a new connection provider when added to manifest on second sync', async () => {
await syncApplication({
manifest: buildManifest({ connectionProviders: [] }),
expectToFail: false,
});
const providersAfterFirstSync =
await findConnectionProvidersByApplication(TEST_APP_ID);
expect(providersAfterFirstSync).toHaveLength(0);
await syncApplication({
manifest: buildManifest({
connectionProviders: [
{
universalIdentifier: TEST_PROVIDER_ID,
name: 'linear',
displayName: 'Linear',
type: 'oauth',
oauth: {
authorizationEndpoint: 'https://linear.app/oauth/authorize',
tokenEndpoint: 'https://api.linear.app/oauth/token',
scopes: ['read', 'write'],
clientIdVariable: 'LINEAR_CLIENT_ID',
clientSecretVariable: 'LINEAR_CLIENT_SECRET',
},
},
],
}),
expectToFail: false,
});
const providersAfterSecondSync =
await findConnectionProvidersByApplication(TEST_APP_ID);
expect(providersAfterSecondSync).toHaveLength(1);
expect(providersAfterSecondSync[0]).toMatchObject({
universalIdentifier: TEST_PROVIDER_ID,
name: 'linear',
displayName: 'Linear',
type: 'oauth',
oauthConfig: {
authorizationEndpoint: 'https://linear.app/oauth/authorize',
tokenEndpoint: 'https://api.linear.app/oauth/token',
revokeEndpoint: null,
scopes: ['read', 'write'],
clientIdVariable: 'LINEAR_CLIENT_ID',
clientSecretVariable: 'LINEAR_CLIENT_SECRET',
authorizationParams: null,
tokenRequestContentType: 'json',
usePkce: true,
},
onConnectLogicFunctionUniversalIdentifier: null,
onDisconnectLogicFunctionUniversalIdentifier: null,
});
}, 60000);
it('should update oauthConfig fields when manifest changes on second sync', async () => {
await syncApplication({
manifest: buildManifest({
connectionProviders: [
{
universalIdentifier: TEST_PROVIDER_ID,
name: 'linear',
displayName: 'Linear',
type: 'oauth',
oauth: {
authorizationEndpoint: 'https://linear.app/oauth/authorize',
tokenEndpoint: 'https://api.linear.app/oauth/token',
scopes: ['read'],
clientIdVariable: 'LINEAR_CLIENT_ID',
clientSecretVariable: 'LINEAR_CLIENT_SECRET',
},
},
],
}),
expectToFail: false,
});
const providersAfterFirstSync =
await findConnectionProvidersByApplication(TEST_APP_ID);
expect(providersAfterFirstSync).toHaveLength(1);
expect(providersAfterFirstSync[0].oauthConfig?.scopes).toEqual(['read']);
expect(providersAfterFirstSync[0].oauthConfig?.usePkce).toBe(true);
await syncApplication({
manifest: buildManifest({
connectionProviders: [
{
universalIdentifier: TEST_PROVIDER_ID,
name: 'linear',
displayName: 'Linear (renamed)',
type: 'oauth',
oauth: {
authorizationEndpoint: 'https://linear.app/oauth/authorize',
tokenEndpoint: 'https://api.linear.app/oauth/token',
revokeEndpoint: 'https://api.linear.app/oauth/revoke',
scopes: ['read', 'write', 'admin'],
clientIdVariable: 'LINEAR_CLIENT_ID',
clientSecretVariable: 'LINEAR_CLIENT_SECRET',
authorizationParams: { prompt: 'consent' },
tokenRequestContentType: 'form-urlencoded',
usePkce: false,
},
},
],
}),
expectToFail: false,
});
const providersAfterSecondSync =
await findConnectionProvidersByApplication(TEST_APP_ID);
expect(providersAfterSecondSync).toHaveLength(1);
expect(providersAfterSecondSync[0]).toMatchObject({
universalIdentifier: TEST_PROVIDER_ID,
displayName: 'Linear (renamed)',
oauthConfig: {
revokeEndpoint: 'https://api.linear.app/oauth/revoke',
scopes: ['read', 'write', 'admin'],
authorizationParams: { prompt: 'consent' },
tokenRequestContentType: 'form-urlencoded',
usePkce: false,
},
});
}, 60000);
it('should delete a connection provider when removed from manifest on second sync', async () => {
await syncApplication({
manifest: buildManifest({
connectionProviders: [
{
universalIdentifier: TEST_PROVIDER_ID,
name: 'linear',
displayName: 'Linear',
type: 'oauth',
oauth: {
authorizationEndpoint: 'https://linear.app/oauth/authorize',
tokenEndpoint: 'https://api.linear.app/oauth/token',
scopes: ['read'],
clientIdVariable: 'LINEAR_CLIENT_ID',
clientSecretVariable: 'LINEAR_CLIENT_SECRET',
},
},
{
universalIdentifier: TEST_SECOND_PROVIDER_ID,
name: 'slack',
displayName: 'Slack',
type: 'oauth',
oauth: {
authorizationEndpoint: 'https://slack.com/oauth/v2/authorize',
tokenEndpoint: 'https://slack.com/api/oauth.v2.access',
scopes: ['chat:write'],
clientIdVariable: 'SLACK_CLIENT_ID',
clientSecretVariable: 'SLACK_CLIENT_SECRET',
},
},
],
}),
expectToFail: false,
});
const providersAfterFirstSync =
await findConnectionProvidersByApplication(TEST_APP_ID);
expect(providersAfterFirstSync).toHaveLength(2);
expect(providersAfterFirstSync.map((p) => p.name).sort()).toEqual([
'linear',
'slack',
]);
await syncApplication({
manifest: buildManifest({
connectionProviders: [
{
universalIdentifier: TEST_PROVIDER_ID,
name: 'linear',
displayName: 'Linear',
type: 'oauth',
oauth: {
authorizationEndpoint: 'https://linear.app/oauth/authorize',
tokenEndpoint: 'https://api.linear.app/oauth/token',
scopes: ['read'],
clientIdVariable: 'LINEAR_CLIENT_ID',
clientSecretVariable: 'LINEAR_CLIENT_SECRET',
},
},
],
}),
expectToFail: false,
});
const providersAfterSecondSync =
await findConnectionProvidersByApplication(TEST_APP_ID);
expect(providersAfterSecondSync).toHaveLength(1);
expect(providersAfterSecondSync[0].name).toBe('linear');
}, 60000);
it('should hard-delete connection providers (no soft-delete behaviour)', async () => {
await syncApplication({
manifest: buildManifest({
connectionProviders: [
{
universalIdentifier: TEST_PROVIDER_ID,
name: 'linear',
displayName: 'Linear',
type: 'oauth',
oauth: {
authorizationEndpoint: 'https://linear.app/oauth/authorize',
tokenEndpoint: 'https://api.linear.app/oauth/token',
scopes: ['read'],
clientIdVariable: 'LINEAR_CLIENT_ID',
clientSecretVariable: 'LINEAR_CLIENT_SECRET',
},
},
],
}),
expectToFail: false,
});
await syncApplication({
manifest: buildManifest({ connectionProviders: [] }),
expectToFail: false,
});
// Bypass the helper's `JOIN application` so a soft-deleted row would
// still surface here if it existed.
const rawRows = await globalThis.testDataSource.query(
`SELECT id FROM core."connectionProvider" WHERE "universalIdentifier" = $1`,
[TEST_PROVIDER_ID],
);
expect(rawRows).toHaveLength(0);
}, 60000);
});

View File

@@ -0,0 +1,469 @@
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 { createOneOperationFactory } from 'test/integration/graphql/utils/create-one-operation-factory.util';
import { findOneOperationFactory } from 'test/integration/graphql/utils/find-one-operation-factory.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import { findManyObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata.util';
import { findManyObjectMetadataWithIndexes } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata-with-indexes.util';
import { type FieldManifest, type Manifest } from 'twenty-shared/application';
import { FieldMetadataType } from 'twenty-shared/types';
import { capitalize, isDefined } from 'twenty-shared/utils';
import { v4 as uuidv4 } from 'uuid';
const TEST_APP_ID = uuidv4();
const TEST_ROLE_ID = uuidv4();
const TEST_FIELD_ID = uuidv4();
const TEST_SECOND_FIELD_ID = uuidv4();
const TEST_NUMBER_FIELD_ID = uuidv4();
const TEST_OBJECT = buildDefaultObjectManifest({
applicationUniversalIdentifier: TEST_APP_ID,
nameSingular: 'ticket',
namePlural: 'tickets',
labelSingular: 'Ticket',
labelPlural: 'Tickets',
description: 'A support ticket',
icon: 'IconTicket',
});
const buildManifest = (overrides?: Partial<Pick<Manifest, 'fields'>>) =>
buildBaseManifest({
appId: TEST_APP_ID,
roleId: TEST_ROLE_ID,
overrides: { objects: [TEST_OBJECT], ...overrides },
});
const findObjectFields = async () => {
const objects = await findManyObjectMetadataWithIndexes({
expectToFail: false,
});
const object = objects.find(
(o) => o.universalIdentifier === TEST_OBJECT.universalIdentifier,
);
return object?.fieldsList ?? [];
};
const findFieldWithNullable = async (fieldName: string) => {
const { objects } = await findManyObjectMetadata({
expectToFail: false,
input: {
filter: {},
paging: { first: 100 },
},
gqlFields: `
id
universalIdentifier
fieldsList {
name
isNullable
}
`,
});
const object = objects.find(
(o) => o.universalIdentifier === TEST_OBJECT.universalIdentifier,
);
return object?.fieldsList?.find((field) => field.name === fieldName);
};
const buildReferenceFieldManifest = (isNullable: boolean): FieldManifest => ({
universalIdentifier: TEST_FIELD_ID,
type: FieldMetadataType.TEXT,
name: 'reference',
label: 'Reference',
description: 'Ticket reference',
icon: 'IconFileDescription',
isNullable,
defaultValue: "'N/A'",
objectUniversalIdentifier: TEST_OBJECT.universalIdentifier,
});
// NUMBER is used here (rather than a TEXT field) because the data API
// coerces null/omitted TEXT values to '' via the null-equivalent processor,
// so a TEXT column can never actually hold NULL. NUMBER preserves NULL, which
// is what the nullable -> non-nullable backfill needs to act on.
const buildEstimateFieldManifest = (
params:
| { isNullable: true; defaultValue?: number }
| { isNullable: false; defaultValue: number },
): FieldManifest => {
const commonEstimateFields = {
universalIdentifier: TEST_NUMBER_FIELD_ID,
type: FieldMetadataType.NUMBER as const,
name: 'estimate',
label: 'Estimate',
description: 'Ticket estimate',
icon: 'IconNumber',
objectUniversalIdentifier: TEST_OBJECT.universalIdentifier,
};
if (params.isNullable) {
return {
...commonEstimateFields,
isNullable: true,
...(isDefined(params.defaultValue)
? { defaultValue: params.defaultValue }
: {}),
};
}
return {
...commonEstimateFields,
isNullable: false,
defaultValue: params.defaultValue,
};
};
const createTicketRecord = async (data: Record<string, unknown>) => {
const response = await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: TEST_OBJECT.nameSingular,
gqlFields: `
id
estimate
`,
data,
}),
);
return response.body.data?.[`create${capitalize(TEST_OBJECT.nameSingular)}`];
};
const findTicketRecordById = async (recordId: string) => {
const response = await makeGraphqlAPIRequest(
findOneOperationFactory({
objectMetadataSingularName: TEST_OBJECT.nameSingular,
gqlFields: `
id
estimate
`,
filter: { id: { eq: recordId } },
}),
);
return response.body.data?.[TEST_OBJECT.nameSingular];
};
describe('Manifest update - fields', () => {
beforeEach(async () => {
await setupApplicationForSync({
applicationUniversalIdentifier: TEST_APP_ID,
name: 'Test Application',
description: 'App for testing field manifest updates',
sourcePath: 'test-manifest-update-field',
});
}, 60000);
afterEach(async () => {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: TEST_APP_ID,
});
});
it('should create a new field when added to manifest on second sync', async () => {
await syncApplication({
manifest: buildManifest({ fields: [] }),
expectToFail: false,
});
const fieldsAfterFirstSync = await findObjectFields();
const descriptionBefore = fieldsAfterFirstSync.find(
(field: { name: string }) => field.name === 'description',
);
expect(descriptionBefore).toBeUndefined();
await syncApplication({
manifest: buildManifest({
fields: [
{
universalIdentifier: TEST_FIELD_ID,
type: FieldMetadataType.TEXT,
name: 'description',
label: 'Description',
description: 'Ticket description',
icon: 'IconFileDescription',
objectUniversalIdentifier: TEST_OBJECT.universalIdentifier,
},
],
}),
expectToFail: false,
});
const fieldsAfterSecondSync = await findObjectFields();
const descriptionAfter = fieldsAfterSecondSync.find(
(field: { name: string }) => field.name === 'description',
);
expect(descriptionAfter).toBeDefined();
expect(descriptionAfter).toMatchObject({
name: 'description',
label: 'Description',
type: FieldMetadataType.TEXT,
description: 'Ticket description',
icon: 'IconFileDescription',
});
}, 60000);
it('should update a field when properties change in manifest on second sync', async () => {
await syncApplication({
manifest: buildManifest({
fields: [
{
universalIdentifier: TEST_FIELD_ID,
type: FieldMetadataType.TEXT,
name: 'description',
label: 'Description',
description: 'Ticket description',
icon: 'IconFileDescription',
objectUniversalIdentifier: TEST_OBJECT.universalIdentifier,
},
],
}),
expectToFail: false,
});
const fieldsAfterFirstSync = await findObjectFields();
const fieldBefore = fieldsAfterFirstSync.find(
(field: { name: string }) => field.name === 'description',
);
expect(fieldBefore).toBeDefined();
expect(fieldBefore).toMatchObject({
name: 'description',
label: 'Description',
});
await syncApplication({
manifest: buildManifest({
fields: [
{
universalIdentifier: TEST_FIELD_ID,
type: FieldMetadataType.TEXT,
name: 'body',
label: 'Body',
description: 'Ticket body content',
icon: 'IconFileDescription',
objectUniversalIdentifier: TEST_OBJECT.universalIdentifier,
},
],
}),
expectToFail: false,
});
const fieldsAfterSecondSync = await findObjectFields();
const renamedField = fieldsAfterSecondSync.find(
(field: { name: string }) => field.name === 'body',
);
expect(renamedField).toBeDefined();
expect(renamedField).toMatchObject({
name: 'body',
label: 'Body',
type: FieldMetadataType.TEXT,
description: 'Ticket body content',
});
const oldField = fieldsAfterSecondSync.find(
(field: { name: string }) => field.name === 'description',
);
expect(oldField).toBeUndefined();
}, 60000);
it('should delete a field when removed from manifest on second sync', async () => {
await syncApplication({
manifest: buildManifest({
fields: [
{
universalIdentifier: TEST_FIELD_ID,
type: FieldMetadataType.TEXT,
name: 'description',
label: 'Description',
description: 'Ticket description',
icon: 'IconFileDescription',
objectUniversalIdentifier: TEST_OBJECT.universalIdentifier,
},
{
universalIdentifier: TEST_SECOND_FIELD_ID,
type: FieldMetadataType.TEXT,
name: 'priority',
label: 'Priority',
description: 'Ticket priority',
icon: 'IconFlag',
objectUniversalIdentifier: TEST_OBJECT.universalIdentifier,
},
],
}),
expectToFail: false,
});
const fieldsAfterFirstSync = await findObjectFields();
expect(
fieldsAfterFirstSync.find(
(field: { name: string }) => field.name === 'description',
),
).toBeDefined();
expect(
fieldsAfterFirstSync.find(
(field: { name: string }) => field.name === 'priority',
),
).toBeDefined();
await syncApplication({
manifest: buildManifest({
fields: [
{
universalIdentifier: TEST_FIELD_ID,
type: FieldMetadataType.TEXT,
name: 'description',
label: 'Description',
description: 'Ticket description',
icon: 'IconFileDescription',
objectUniversalIdentifier: TEST_OBJECT.universalIdentifier,
},
],
}),
expectToFail: false,
});
const fieldsAfterSecondSync = await findObjectFields();
expect(
fieldsAfterSecondSync.find(
(field: { name: string }) => field.name === 'description',
),
).toBeDefined();
expect(
fieldsAfterSecondSync.find(
(field: { name: string }) => field.name === 'priority',
),
).toBeUndefined();
}, 60000);
it('should update isNullable when changed in manifest on second sync', async () => {
await syncApplication({
manifest: buildManifest({ fields: [buildReferenceFieldManifest(true)] }),
expectToFail: false,
});
const fieldAfterFirstSync = await findFieldWithNullable('reference');
expect(fieldAfterFirstSync).toBeDefined();
expect(fieldAfterFirstSync?.isNullable).toBe(true);
await syncApplication({
manifest: buildManifest({ fields: [buildReferenceFieldManifest(false)] }),
expectToFail: false,
});
const fieldAfterSecondSync = await findFieldWithNullable('reference');
expect(fieldAfterSecondSync?.isNullable).toBe(false);
await syncApplication({
manifest: buildManifest({ fields: [buildReferenceFieldManifest(true)] }),
expectToFail: false,
});
const fieldAfterThirdSync = await findFieldWithNullable('reference');
expect(fieldAfterThirdSync?.isNullable).toBe(true);
}, 60000);
it('should backfill existing null rows when a field becomes non-nullable on second sync', async () => {
// First sync creates a nullable field with no default.
await syncApplication({
manifest: buildManifest({
fields: [buildEstimateFieldManifest({ isNullable: true })],
}),
expectToFail: false,
});
// Persist a record whose estimate is NULL on the underlying column.
const recordId = uuidv4();
const createdRecord = await createTicketRecord({
id: recordId,
estimate: null,
});
expect(createdRecord?.id).toBe(recordId);
expect(createdRecord?.estimate).toBeNull();
// Second sync makes the field non-nullable with a default value, which
// must backfill the existing NULL row before SET NOT NULL is enforced.
await syncApplication({
manifest: buildManifest({
fields: [
buildEstimateFieldManifest({ isNullable: false, defaultValue: 42 }),
],
}),
expectToFail: false,
});
const fieldAfterSecondSync = await findFieldWithNullable('estimate');
expect(fieldAfterSecondSync?.isNullable).toBe(false);
const backfilledRecord = await findTicketRecordById(recordId);
expect(backfilledRecord?.estimate).toBe(42);
}, 60000);
it('should create a unique index when field has isUnique set to true', async () => {
await syncApplication({
manifest: buildManifest({
fields: [
{
universalIdentifier: TEST_FIELD_ID,
type: FieldMetadataType.TEXT,
name: 'externalId',
label: 'External ID',
description: 'Unique external identifier',
icon: 'IconId',
isUnique: true,
isNullable: true,
objectUniversalIdentifier: TEST_OBJECT.universalIdentifier,
},
],
}),
expectToFail: false,
});
const objects = await findManyObjectMetadataWithIndexes({
expectToFail: false,
});
const object = objects.find(
(objectMetadata) =>
objectMetadata.universalIdentifier === TEST_OBJECT.universalIdentifier,
);
expect(object).toBeDefined();
const externalIdField = object?.fieldsList.find(
(field) => field.name === 'externalId',
);
expect(externalIdField).toBeDefined();
const uniqueIndex = object?.indexMetadataList.find(
(index) =>
index.isUnique &&
index.indexFieldMetadataList.some(
(indexField) => indexField.fieldMetadataId === externalIdField?.id,
),
);
expect(uniqueIndex).toBeDefined();
expect(uniqueIndex?.isUnique).toBe(true);
}, 60000);
});

View File

@@ -0,0 +1,292 @@
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 { findNavigationMenuItems } from 'test/integration/metadata/suites/navigation-menu-item/utils/find-navigation-menu-items.util';
import { type Manifest } from 'twenty-shared/application';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { v4 as uuidv4 } from 'uuid';
const TEST_APP_ID = uuidv4();
const TEST_ROLE_ID = uuidv4();
const TEST_FOLDER_ID = uuidv4();
const TEST_CHILD_ID = uuidv4();
const NAV_ITEM_GQL_FIELDS = `
id
type
name
icon
link
position
folderId
applicationId
`;
let testApplicationId: string;
const buildManifest = (
overrides?: Partial<Pick<Manifest, 'navigationMenuItems'>>,
) =>
buildBaseManifest({
appId: TEST_APP_ID,
roleId: TEST_ROLE_ID,
overrides,
});
const findAppNavigationMenuItems = async () => {
const { data } = await findNavigationMenuItems({
gqlFields: NAV_ITEM_GQL_FIELDS,
expectToFail: false,
input: undefined,
});
return data.navigationMenuItems.filter(
(item) => item.applicationId === testApplicationId,
);
};
describe('Manifest update - navigation menu items', () => {
beforeEach(async () => {
await setupApplicationForSync({
applicationUniversalIdentifier: TEST_APP_ID,
name: 'Test Application',
description: 'App for testing navigation menu item manifest updates',
sourcePath: 'test-manifest-update-nav',
});
const result = await globalThis.testDataSource.query(
`SELECT id FROM core."application" WHERE "universalIdentifier" = $1`,
[TEST_APP_ID],
);
testApplicationId = result[0].id;
}, 60000);
afterEach(async () => {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: TEST_APP_ID,
});
});
it('should create a folder and a child item when child is listed BEFORE folder in manifest', async () => {
await syncApplication({
manifest: buildManifest({
navigationMenuItems: [
{
universalIdentifier: TEST_CHILD_ID,
type: NavigationMenuItemType.LINK,
name: 'Child Link',
icon: 'IconLink',
position: 1,
link: 'https://example.com',
folderUniversalIdentifier: TEST_FOLDER_ID,
},
{
universalIdentifier: TEST_FOLDER_ID,
type: NavigationMenuItemType.FOLDER,
name: 'Test Folder',
icon: 'IconFolder',
position: 0,
},
],
}),
expectToFail: false,
});
const items = await findAppNavigationMenuItems();
expect(items).toHaveLength(2);
const folder = items.find(
(item) => item.type === NavigationMenuItemType.FOLDER,
);
const child = items.find(
(item) => item.type === NavigationMenuItemType.LINK,
);
expect(folder).toBeDefined();
expect(folder).toMatchObject({
type: NavigationMenuItemType.FOLDER,
name: 'Test Folder',
});
expect(child).toBeDefined();
expect(child).toMatchObject({
type: NavigationMenuItemType.LINK,
name: 'Child Link',
folderId: folder!.id,
});
}, 60000);
it('should update navigation menu item properties on second sync', async () => {
await syncApplication({
manifest: buildManifest({
navigationMenuItems: [
{
universalIdentifier: TEST_FOLDER_ID,
type: NavigationMenuItemType.FOLDER,
name: 'Test Folder',
icon: 'IconFolder',
position: 0,
},
],
}),
expectToFail: false,
});
const itemsAfterFirstSync = await findAppNavigationMenuItems();
expect(itemsAfterFirstSync).toHaveLength(1);
expect(itemsAfterFirstSync[0]).toMatchObject({
name: 'Test Folder',
icon: 'IconFolder',
});
await syncApplication({
manifest: buildManifest({
navigationMenuItems: [
{
universalIdentifier: TEST_FOLDER_ID,
type: NavigationMenuItemType.FOLDER,
name: 'Renamed Folder',
icon: 'IconFolderOpen',
position: 0,
},
],
}),
expectToFail: false,
});
const itemsAfterSecondSync = await findAppNavigationMenuItems();
expect(itemsAfterSecondSync).toHaveLength(1);
expect(itemsAfterSecondSync[0]).toMatchObject({
name: 'Renamed Folder',
icon: 'IconFolderOpen',
});
}, 60000);
it('should move existing menu items into a folder created in the same sync', async () => {
await syncApplication({
manifest: buildManifest({
navigationMenuItems: [
{
universalIdentifier: TEST_CHILD_ID,
type: NavigationMenuItemType.LINK,
name: 'Child Link',
icon: 'IconLink',
position: 0,
link: 'https://example.com',
},
],
}),
expectToFail: false,
});
const itemsAfterFirstSync = await findAppNavigationMenuItems();
expect(itemsAfterFirstSync).toHaveLength(1);
expect(itemsAfterFirstSync[0]).toMatchObject({
type: NavigationMenuItemType.LINK,
name: 'Child Link',
folderId: null,
});
await syncApplication({
manifest: buildManifest({
navigationMenuItems: [
{
universalIdentifier: TEST_FOLDER_ID,
type: NavigationMenuItemType.FOLDER,
name: 'Test Folder',
icon: 'IconFolder',
position: 0,
},
{
universalIdentifier: TEST_CHILD_ID,
type: NavigationMenuItemType.LINK,
name: 'Child Link',
icon: 'IconLink',
position: 0,
link: 'https://example.com',
folderUniversalIdentifier: TEST_FOLDER_ID,
},
],
}),
expectToFail: false,
});
const itemsAfterSecondSync = await findAppNavigationMenuItems();
expect(itemsAfterSecondSync).toHaveLength(2);
const folder = itemsAfterSecondSync.find(
(item) => item.type === NavigationMenuItemType.FOLDER,
);
const child = itemsAfterSecondSync.find(
(item) => item.type === NavigationMenuItemType.LINK,
);
expect(folder).toBeDefined();
expect(child).toBeDefined();
expect(child).toMatchObject({
type: NavigationMenuItemType.LINK,
name: 'Child Link',
folderId: folder!.id,
});
}, 60000);
it('should delete navigation menu items when removed from manifest on second sync', async () => {
await syncApplication({
manifest: buildManifest({
navigationMenuItems: [
{
universalIdentifier: TEST_FOLDER_ID,
type: NavigationMenuItemType.FOLDER,
name: 'Test Folder',
icon: 'IconFolder',
position: 0,
},
{
universalIdentifier: TEST_CHILD_ID,
type: NavigationMenuItemType.LINK,
name: 'Child Link',
icon: 'IconLink',
position: 1,
link: 'https://example.com',
folderUniversalIdentifier: TEST_FOLDER_ID,
},
],
}),
expectToFail: false,
});
const itemsAfterFirstSync = await findAppNavigationMenuItems();
expect(itemsAfterFirstSync).toHaveLength(2);
await syncApplication({
manifest: buildManifest({
navigationMenuItems: [
{
universalIdentifier: TEST_FOLDER_ID,
type: NavigationMenuItemType.FOLDER,
name: 'Test Folder',
icon: 'IconFolder',
position: 0,
},
],
}),
expectToFail: false,
});
const itemsAfterSecondSync = await findAppNavigationMenuItems();
expect(itemsAfterSecondSync).toHaveLength(1);
expect(itemsAfterSecondSync[0]).toMatchObject({
type: NavigationMenuItemType.FOLDER,
name: 'Test Folder',
});
}, 60000);
});

View File

@@ -0,0 +1,228 @@
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 OBJECT_GQL_FIELDS =
'id nameSingular namePlural labelSingular labelPlural description icon isActive universalIdentifier';
const findCustomObjects = async () => {
const { objects } = await findManyObjectMetadata({
input: {
filter: {},
paging: { first: 100 },
},
gqlFields: OBJECT_GQL_FIELDS,
expectToFail: false,
});
return objects;
};
describe('Manifest update - objects', () => {
beforeEach(async () => {
await setupApplicationForSync({
applicationUniversalIdentifier: TEST_APP_ID,
name: 'Test Application',
description: 'App for testing object manifest updates',
sourcePath: 'test-manifest-update-object',
});
}, 60000);
afterEach(async () => {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: TEST_APP_ID,
});
});
it('should create a new object when added to manifest on second sync', async () => {
const ticketObject = buildDefaultObjectManifest({
applicationUniversalIdentifier: TEST_APP_ID,
nameSingular: 'ticket',
namePlural: 'tickets',
labelSingular: 'Ticket',
labelPlural: 'Tickets',
description: 'A support ticket',
icon: 'IconTicket',
});
await syncApplication({
manifest: buildManifest({ objects: [ticketObject] }),
expectToFail: false,
});
const objectsAfterFirstSync = await findCustomObjects();
const ticket = objectsAfterFirstSync.find(
(obj) => obj.nameSingular === 'ticket',
);
expect(ticket).toBeDefined();
expect(ticket).toMatchObject({
nameSingular: 'ticket',
labelSingular: 'Ticket',
description: 'A support ticket',
icon: 'IconTicket',
});
const invoiceObject = buildDefaultObjectManifest({
applicationUniversalIdentifier: TEST_APP_ID,
nameSingular: 'invoice',
namePlural: 'invoices',
labelSingular: 'Invoice',
labelPlural: 'Invoices',
description: 'A billing invoice',
icon: 'IconFileInvoice',
});
await syncApplication({
manifest: buildManifest({
objects: [ticketObject, invoiceObject],
}),
expectToFail: false,
});
const objectsAfterSecondSync = await findCustomObjects();
const ticketAfter = objectsAfterSecondSync.find(
(obj) => obj.nameSingular === 'ticket',
);
const invoiceAfter = objectsAfterSecondSync.find(
(obj) => obj.nameSingular === 'invoice',
);
expect(ticketAfter).toBeDefined();
expect(invoiceAfter).toBeDefined();
expect(invoiceAfter).toMatchObject({
nameSingular: 'invoice',
namePlural: 'invoices',
labelSingular: 'Invoice',
labelPlural: 'Invoices',
description: 'A billing invoice',
icon: 'IconFileInvoice',
});
}, 60000);
it('should update object properties when changed in manifest on second sync', async () => {
const universalIdentifier = uuidv4();
const ticketObject = buildDefaultObjectManifest({
applicationUniversalIdentifier: TEST_APP_ID,
nameSingular: 'ticket',
namePlural: 'tickets',
labelSingular: 'Ticket',
labelPlural: 'Tickets',
description: 'A support ticket',
icon: 'IconTicket',
universalIdentifier,
});
await syncApplication({
manifest: buildManifest({ objects: [ticketObject] }),
expectToFail: false,
});
const objectsAfterFirstSync = await findCustomObjects();
const ticketBefore = objectsAfterFirstSync.find(
(obj) => obj.universalIdentifier === universalIdentifier,
);
expect(ticketBefore).toBeDefined();
expect(ticketBefore).toMatchObject({
nameSingular: 'ticket',
labelSingular: 'Ticket',
description: 'A support ticket',
icon: 'IconTicket',
});
const updatedTicketObject = {
...ticketObject,
nameSingular: 'ticket2',
namePlural: 'tickets2',
labelSingular: 'Support Ticket',
labelPlural: 'Support Tickets',
description: 'A customer support ticket',
icon: 'IconHeadset',
};
await syncApplication({
manifest: buildManifest({ objects: [updatedTicketObject] }),
expectToFail: false,
});
const objectsAfterSecondSync = await findCustomObjects();
const ticketAfter = objectsAfterSecondSync.find(
(obj) => obj.universalIdentifier === universalIdentifier,
);
expect(ticketAfter).toBeDefined();
expect(ticketAfter).toMatchObject({
nameSingular: 'ticket2',
namePlural: 'tickets2',
labelSingular: 'Support Ticket',
labelPlural: 'Support Tickets',
description: 'A customer support ticket',
icon: 'IconHeadset',
});
}, 60000);
it('should delete an object when removed from manifest on second sync', async () => {
const ticketObject = buildDefaultObjectManifest({
applicationUniversalIdentifier: TEST_APP_ID,
nameSingular: 'ticket',
namePlural: 'tickets',
labelSingular: 'Ticket',
labelPlural: 'Tickets',
description: 'A support ticket',
icon: 'IconTicket',
});
const invoiceObject = buildDefaultObjectManifest({
applicationUniversalIdentifier: TEST_APP_ID,
nameSingular: 'invoice',
namePlural: 'invoices',
labelSingular: 'Invoice',
labelPlural: 'Invoices',
description: 'A billing invoice',
icon: 'IconFileInvoice',
});
await syncApplication({
manifest: buildManifest({
objects: [ticketObject, invoiceObject],
}),
expectToFail: false,
});
const objectsAfterFirstSync = await findCustomObjects();
expect(
objectsAfterFirstSync.find((obj) => obj.nameSingular === 'ticket'),
).toBeDefined();
expect(
objectsAfterFirstSync.find((obj) => obj.nameSingular === 'invoice'),
).toBeDefined();
await syncApplication({
manifest: buildManifest({ objects: [ticketObject] }),
expectToFail: false,
});
const objectsAfterSecondSync = await findCustomObjects();
expect(
objectsAfterSecondSync.find((obj) => obj.nameSingular === 'ticket'),
).toBeDefined();
expect(
objectsAfterSecondSync.find((obj) => obj.nameSingular === 'invoice'),
).toBeUndefined();
}, 60000);
});

View File

@@ -0,0 +1,439 @@
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 { 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 { getAppProviderByClassName } from 'test/integration/utils/get-app-provider-by-class-name.util';
import { type Manifest } from 'twenty-shared/application';
import { STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS } from 'twenty-shared/metadata';
import { PageLayoutTabLayoutMode } from 'twenty-shared/types';
import { v4 as uuidv4 } from 'uuid';
import { MigrateCanvasTabsToVerticalListSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-40/2-40-instance-command-slow-1789139070588-migrate-canvas-tabs-to-vertical-list';
import { type WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
const TEST_APP_ID = uuidv4();
const TEST_ROLE_ID = uuidv4();
const TEST_TAB_ID = uuidv4();
const TEST_WIDGET_ID = uuidv4();
const STANDARD_PERSON_PAGE_LAYOUT_UNIVERSAL_ID =
STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.personRecordPage
.universalIdentifier;
const PAGE_LAYOUT_TAB_GQL_FIELDS = `
id
universalIdentifier
title
position
layoutMode
pageLayoutId
applicationId
`;
const PAGE_LAYOUT_WIDGET_GQL_FIELDS = `
id
universalIdentifier
applicationId
pageLayoutTabId
title
type
position {
... on PageLayoutWidgetCanvasPosition {
layoutMode
}
}
configuration {
... on TimelineConfiguration {
configurationType
}
}
`;
let testApplicationId: string;
let standardPersonPageLayoutId: string;
const buildManifest = (overrides?: Partial<Pick<Manifest, 'pageLayoutTabs'>>) =>
buildBaseManifest({
appId: TEST_APP_ID,
roleId: TEST_ROLE_ID,
overrides,
});
const findStandardPersonPageLayoutTabs = async () => {
const { data } = await findPageLayoutTabs({
gqlFields: PAGE_LAYOUT_TAB_GQL_FIELDS,
expectToFail: false,
input: { pageLayoutId: standardPersonPageLayoutId },
});
return data.getPageLayoutTabs.filter(
(tab) => tab.applicationId === testApplicationId,
);
};
describe('Manifest update - page layout tabs (standalone)', () => {
beforeEach(async () => {
await setupApplicationForSync({
applicationUniversalIdentifier: TEST_APP_ID,
name: 'Test Application',
description:
'App for testing standalone page layout tab manifest updates',
sourcePath: 'test-manifest-update-page-layout-tab',
});
const applicationRow = await globalThis.testDataSource.query(
`SELECT id FROM core."application" WHERE "universalIdentifier" = $1`,
[TEST_APP_ID],
);
testApplicationId = applicationRow[0].id;
const pageLayoutRow = await globalThis.testDataSource.query(
`SELECT id FROM core."pageLayout" WHERE "universalIdentifier" = $1`,
[STANDARD_PERSON_PAGE_LAYOUT_UNIVERSAL_ID],
);
standardPersonPageLayoutId = pageLayoutRow[0].id;
}, 60000);
afterEach(async () => {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: TEST_APP_ID,
});
});
it('should attach a standalone tab to a standard page layout', async () => {
await syncApplication({
manifest: buildManifest({
pageLayoutTabs: [
{
universalIdentifier: TEST_TAB_ID,
pageLayoutUniversalIdentifier:
STANDARD_PERSON_PAGE_LAYOUT_UNIVERSAL_ID,
title: 'Insights',
position: 1000,
icon: 'IconChartBar',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
},
],
}),
expectToFail: false,
});
const tabs = await findStandardPersonPageLayoutTabs();
expect(tabs).toHaveLength(1);
expect(tabs[0]).toMatchObject({
title: 'Insights',
position: 1000,
pageLayoutId: standardPersonPageLayoutId,
applicationId: testApplicationId,
});
}, 60000);
it('should rename and reposition a standalone tab on second sync', async () => {
await syncApplication({
manifest: buildManifest({
pageLayoutTabs: [
{
universalIdentifier: TEST_TAB_ID,
pageLayoutUniversalIdentifier:
STANDARD_PERSON_PAGE_LAYOUT_UNIVERSAL_ID,
title: 'Insights',
position: 1000,
layoutMode: PageLayoutTabLayoutMode.CANVAS,
},
],
}),
expectToFail: false,
});
const tabsAfterFirstSync = await findStandardPersonPageLayoutTabs();
expect(tabsAfterFirstSync).toHaveLength(1);
expect(tabsAfterFirstSync[0]).toMatchObject({
title: 'Insights',
position: 1000,
});
await syncApplication({
manifest: buildManifest({
pageLayoutTabs: [
{
universalIdentifier: TEST_TAB_ID,
pageLayoutUniversalIdentifier:
STANDARD_PERSON_PAGE_LAYOUT_UNIVERSAL_ID,
title: 'Renamed Insights',
position: 1500,
layoutMode: PageLayoutTabLayoutMode.CANVAS,
},
],
}),
expectToFail: false,
});
const tabsAfterSecondSync = await findStandardPersonPageLayoutTabs();
expect(tabsAfterSecondSync).toHaveLength(1);
expect(tabsAfterSecondSync[0]).toMatchObject({
title: 'Renamed Insights',
position: 1500,
});
}, 60000);
describe.each(['nested', 'standalone'])('invalid %s tab', (location) => {
it.each([
{
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
heightBehavior: 'TAB_VIEPORT',
message: 'unsupported heightBehavior "TAB_VIEPORT"',
},
{
layoutMode: PageLayoutTabLayoutMode.CANVAS,
heightBehavior: 'TAB_VIEWPORT',
message: 'heightBehavior is only supported for VERTICAL_LIST tabs',
},
])(
'returns an input error for $layoutMode with $heightBehavior',
async ({ layoutMode, heightBehavior, message }) => {
const pageLayoutTab = {
universalIdentifier: TEST_TAB_ID,
title: 'Invalid tab',
position: 1000,
layoutMode,
widgets: [
{
universalIdentifier: TEST_WIDGET_ID,
title: 'Timeline',
type: 'TIMELINE',
heightBehavior,
configuration: { configurationType: 'TIMELINE' },
},
],
};
const manifest: Manifest = JSON.parse(
JSON.stringify({
...buildManifest(),
pageLayouts:
location === 'nested'
? [
{
universalIdentifier: uuidv4(),
name: 'Invalid page',
type: 'STANDALONE_PAGE',
tabs: [pageLayoutTab],
},
]
: [],
pageLayoutTabs:
location === 'standalone'
? [
{
...pageLayoutTab,
pageLayoutUniversalIdentifier:
STANDARD_PERSON_PAGE_LAYOUT_UNIVERSAL_ID,
},
]
: [],
}),
);
const { errors } = await syncApplication({
manifest,
expectToFail: true,
});
expect(errors).toEqual([
expect.objectContaining({
message: expect.stringContaining(message),
extensions: expect.objectContaining({ code: 'BAD_USER_INPUT' }),
}),
]);
const applicationTabs = await globalThis.testDataSource.query(
`SELECT id FROM core."pageLayoutTab" WHERE "applicationId" = $1`,
[testApplicationId],
);
expect(applicationTabs).toHaveLength(0);
},
);
});
it('should preserve a legacy Canvas manifest and position override through migration and sync', async () => {
const widgetOverrides = { position: { layoutMode: 'CANVAS' } };
const buildLegacyCanvasPageLayoutTab = () => ({
universalIdentifier: TEST_TAB_ID,
pageLayoutUniversalIdentifier: STANDARD_PERSON_PAGE_LAYOUT_UNIVERSAL_ID,
title: 'Timeline',
position: 1000,
layoutMode: PageLayoutTabLayoutMode.CANVAS,
widgets: [
{
universalIdentifier: TEST_WIDGET_ID,
title: 'Timeline',
type: 'TIMELINE' as const,
configuration: {
configurationType: 'TIMELINE' as const,
},
},
],
});
await syncApplication({
manifest: buildManifest({
pageLayoutTabs: [buildLegacyCanvasPageLayoutTab()],
}),
expectToFail: false,
});
const tabsAfterFirstSync = await findStandardPersonPageLayoutTabs();
expect(tabsAfterFirstSync).toHaveLength(1);
const tabAfterFirstSync = tabsAfterFirstSync[0];
expect(tabAfterFirstSync).toMatchObject({
universalIdentifier: TEST_TAB_ID,
layoutMode: PageLayoutTabLayoutMode.CANVAS,
});
const { data: widgetsAfterFirstSyncData } = await findPageLayoutWidgets({
gqlFields: PAGE_LAYOUT_WIDGET_GQL_FIELDS,
expectToFail: false,
input: { pageLayoutTabId: tabAfterFirstSync.id },
});
expect(widgetsAfterFirstSyncData.getPageLayoutWidgets).toHaveLength(1);
const widgetAfterFirstSync =
widgetsAfterFirstSyncData.getPageLayoutWidgets[0];
expect(widgetAfterFirstSync).toMatchObject({
universalIdentifier: TEST_WIDGET_ID,
applicationId: testApplicationId,
pageLayoutTabId: tabAfterFirstSync.id,
title: 'Timeline',
type: 'TIMELINE',
position: {
layoutMode: PageLayoutTabLayoutMode.CANVAS,
},
configuration: {
configurationType: 'TIMELINE',
},
});
await globalThis.testDataSource.query(
`UPDATE core."pageLayoutWidget" SET "overrides" = $1 WHERE "id" = $2`,
[JSON.stringify(widgetOverrides), widgetAfterFirstSync.id],
);
const workspaceCacheService =
getAppProviderByClassName<WorkspaceCacheService>('WorkspaceCacheService');
const command = new MigrateCanvasTabsToVerticalListSlowInstanceCommand(
workspaceCacheService,
);
await command.runDataMigration(globalThis.testDataSource);
await syncApplication({
manifest: buildManifest({
pageLayoutTabs: [buildLegacyCanvasPageLayoutTab()],
}),
expectToFail: false,
});
const tabsAfterSecondSync = await findStandardPersonPageLayoutTabs();
expect(tabsAfterSecondSync).toHaveLength(1);
const tabAfterSecondSync = tabsAfterSecondSync[0];
expect(tabAfterSecondSync).toMatchObject({
id: tabAfterFirstSync.id,
universalIdentifier: TEST_TAB_ID,
layoutMode: PageLayoutTabLayoutMode.CANVAS,
});
const { data: widgetsAfterSecondSyncData } = await findPageLayoutWidgets({
gqlFields: PAGE_LAYOUT_WIDGET_GQL_FIELDS,
expectToFail: false,
input: { pageLayoutTabId: tabAfterSecondSync.id },
});
expect(widgetsAfterSecondSyncData.getPageLayoutWidgets).toEqual([
expect.objectContaining({
id: widgetAfterFirstSync.id,
universalIdentifier: TEST_WIDGET_ID,
applicationId: testApplicationId,
pageLayoutTabId: tabAfterSecondSync.id,
title: 'Timeline',
type: 'TIMELINE',
position: {
layoutMode: PageLayoutTabLayoutMode.CANVAS,
},
configuration: {
configurationType: 'TIMELINE',
},
}),
]);
const [widgetAfterMigrationAndSync] = await globalThis.testDataSource.query(
`SELECT "overrides" FROM core."pageLayoutWidget" WHERE "id" = $1`,
[widgetAfterFirstSync.id],
);
expect(widgetAfterMigrationAndSync.overrides).toEqual(widgetOverrides);
}, 60000);
it('should delete a standalone tab when removed from manifest on second sync', async () => {
await syncApplication({
manifest: buildManifest({
pageLayoutTabs: [
{
universalIdentifier: TEST_TAB_ID,
pageLayoutUniversalIdentifier:
STANDARD_PERSON_PAGE_LAYOUT_UNIVERSAL_ID,
title: 'Insights',
position: 1000,
layoutMode: PageLayoutTabLayoutMode.CANVAS,
},
],
}),
expectToFail: false,
});
const tabsAfterFirstSync = await findStandardPersonPageLayoutTabs();
expect(tabsAfterFirstSync).toHaveLength(1);
await syncApplication({
manifest: buildManifest({ pageLayoutTabs: [] }),
expectToFail: false,
});
const tabsAfterSecondSync = await findStandardPersonPageLayoutTabs();
expect(tabsAfterSecondSync).toHaveLength(0);
}, 60000);
it('should fail to sync when standalone tab references a non-existent page layout', async () => {
const { errors } = await syncApplication({
manifest: buildManifest({
pageLayoutTabs: [
{
universalIdentifier: TEST_TAB_ID,
pageLayoutUniversalIdentifier: uuidv4(),
title: 'Insights',
position: 1000,
layoutMode: PageLayoutTabLayoutMode.CANVAS,
},
],
}),
expectToFail: true,
});
expect(errors).toBeDefined();
expect(errors?.length).toBeGreaterThan(0);
}, 60000);
});

View File

@@ -0,0 +1,232 @@
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 { WIDGET_POSITION_GQL_FIELDS } from 'test/integration/metadata/suites/page-layout-widget/constants/widget-position-gql-fields.constant';
import { findPageLayoutWidgets } from 'test/integration/metadata/suites/page-layout-widget/utils/find-page-layout-widgets.util';
import {
type Manifest,
type StandalonePageLayoutWidgetManifest,
} from 'twenty-shared/application';
import { STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS } from 'twenty-shared/metadata';
import { PageLayoutTabLayoutMode } from 'twenty-shared/types';
import { v4 as uuidv4 } from 'uuid';
const TEST_APP_ID = uuidv4();
const TEST_ROLE_ID = uuidv4();
const TEST_WIDGET_ID = uuidv4();
const STANDARD_PERSON_HOME_TAB_UNIVERSAL_ID =
STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.personRecordPage.tabs.home
.universalIdentifier;
const PAGE_LAYOUT_WIDGET_GQL_FIELDS = `
id
universalIdentifier
applicationId
pageLayoutTabId
title
type
position {
${WIDGET_POSITION_GQL_FIELDS}
}
configuration {
... on TimelineConfiguration {
configurationType
}
}
`;
let testApplicationId: string;
let standardPersonHomeTabId: string;
const buildManifest = (
overrides?: Partial<Pick<Manifest, 'pageLayoutWidgets'>>,
) =>
buildBaseManifest({
appId: TEST_APP_ID,
roleId: TEST_ROLE_ID,
overrides,
});
const buildTimelineWidget = ({
title,
index,
}: {
title: string;
index: number;
}): StandalonePageLayoutWidgetManifest => ({
universalIdentifier: TEST_WIDGET_ID,
pageLayoutTabUniversalIdentifier: STANDARD_PERSON_HOME_TAB_UNIVERSAL_ID,
title,
type: 'TIMELINE',
position: { layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST, index },
configuration: { configurationType: 'TIMELINE' },
});
const findStandardPersonHomeTabWidgets = async () => {
const { data } = await findPageLayoutWidgets({
gqlFields: PAGE_LAYOUT_WIDGET_GQL_FIELDS,
expectToFail: false,
input: { pageLayoutTabId: standardPersonHomeTabId },
});
return data.getPageLayoutWidgets.filter(
(widget) => widget.applicationId === testApplicationId,
);
};
describe('Manifest update - page layout widgets (standalone)', () => {
beforeEach(async () => {
await setupApplicationForSync({
applicationUniversalIdentifier: TEST_APP_ID,
name: 'Test Application',
description:
'App for testing standalone page layout widget manifest updates',
sourcePath: 'test-manifest-update-page-layout-widget',
});
const applicationRow = await globalThis.testDataSource.query(
`SELECT id FROM core."application" WHERE "universalIdentifier" = $1`,
[TEST_APP_ID],
);
testApplicationId = applicationRow[0].id;
const pageLayoutTabRow = await globalThis.testDataSource.query(
`SELECT id FROM core."pageLayoutTab" WHERE "universalIdentifier" = $1`,
[STANDARD_PERSON_HOME_TAB_UNIVERSAL_ID],
);
standardPersonHomeTabId = pageLayoutTabRow[0].id;
}, 60000);
afterEach(async () => {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: TEST_APP_ID,
});
});
it('should attach a standalone widget to a standard page layout tab', async () => {
await syncApplication({
manifest: buildManifest({
pageLayoutWidgets: [
buildTimelineWidget({ title: 'Activity', index: 1000 }),
],
}),
expectToFail: false,
});
const widgets = await findStandardPersonHomeTabWidgets();
expect(widgets).toHaveLength(1);
expect(widgets[0]).toMatchObject({
universalIdentifier: TEST_WIDGET_ID,
applicationId: testApplicationId,
pageLayoutTabId: standardPersonHomeTabId,
title: 'Activity',
type: 'TIMELINE',
position: {
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
index: 1000,
},
configuration: { configurationType: 'TIMELINE' },
});
}, 60000);
it('should rename and reposition a standalone widget on second sync', async () => {
await syncApplication({
manifest: buildManifest({
pageLayoutWidgets: [
buildTimelineWidget({ title: 'Activity', index: 1000 }),
],
}),
expectToFail: false,
});
const widgetsAfterFirstSync = await findStandardPersonHomeTabWidgets();
expect(widgetsAfterFirstSync).toHaveLength(1);
await syncApplication({
manifest: buildManifest({
pageLayoutWidgets: [
buildTimelineWidget({ title: 'Recent activity', index: 1500 }),
],
}),
expectToFail: false,
});
const widgetsAfterSecondSync = await findStandardPersonHomeTabWidgets();
expect(widgetsAfterSecondSync).toHaveLength(1);
expect(widgetsAfterSecondSync[0]).toMatchObject({
id: widgetsAfterFirstSync[0].id,
title: 'Recent activity',
position: {
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
index: 1500,
},
});
}, 60000);
it('should delete a standalone widget when removed from manifest on second sync', async () => {
await syncApplication({
manifest: buildManifest({
pageLayoutWidgets: [
buildTimelineWidget({ title: 'Activity', index: 1000 }),
],
}),
expectToFail: false,
});
expect(await findStandardPersonHomeTabWidgets()).toHaveLength(1);
await syncApplication({
manifest: buildManifest({ pageLayoutWidgets: [] }),
expectToFail: false,
});
expect(await findStandardPersonHomeTabWidgets()).toHaveLength(0);
}, 60000);
it('should fail to sync when a standalone widget references a non-existent tab', async () => {
const { errors } = await syncApplication({
manifest: buildManifest({
pageLayoutWidgets: [
{
...buildTimelineWidget({ title: 'Activity', index: 1000 }),
pageLayoutTabUniversalIdentifier: uuidv4(),
},
],
}),
expectToFail: true,
});
expect(errors).toBeDefined();
expect(errors?.length).toBeGreaterThan(0);
}, 60000);
it('should fail to sync when a standalone widget is positioned for another layout mode than its tab', async () => {
const { errors } = await syncApplication({
manifest: buildManifest({
pageLayoutWidgets: [
{
...buildTimelineWidget({ title: 'Activity', index: 1000 }),
position: {
layoutMode: PageLayoutTabLayoutMode.GRID,
row: 0,
column: 0,
rowSpan: 4,
columnSpan: 4,
},
},
],
}),
expectToFail: true,
});
expect(errors).toBeDefined();
expect(errors?.length).toBeGreaterThan(0);
}, 60000);
});

View File

@@ -0,0 +1,186 @@
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 { findRoles } from 'test/integration/metadata/suites/role/utils/find-roles.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_SECOND_ROLE_ID = uuidv4();
const buildManifest = (overrides?: Partial<Pick<Manifest, 'roles'>>) =>
buildBaseManifest({
appId: TEST_APP_ID,
roleId: TEST_ROLE_ID,
overrides: {
roles: [
{
universalIdentifier: TEST_ROLE_ID,
label: 'Editor',
description: 'Can edit',
},
],
...overrides,
},
});
const findAppRoles = async () => {
const { data } = await findRoles({
gqlFields: 'id label description universalIdentifier',
expectToFail: false,
});
return data.getRoles.filter(
(role) =>
role.universalIdentifier === TEST_ROLE_ID ||
role.universalIdentifier === TEST_SECOND_ROLE_ID,
);
};
describe('Manifest update - roles', () => {
beforeEach(async () => {
await setupApplicationForSync({
applicationUniversalIdentifier: TEST_APP_ID,
name: 'Test Application',
description: 'App for testing role manifest updates',
sourcePath: 'test-manifest-update-role',
});
}, 60000);
afterEach(async () => {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: TEST_APP_ID,
});
});
it('should create a new role when added to manifest on second sync', async () => {
await syncApplication({
manifest: buildManifest({}),
expectToFail: false,
});
const rolesAfterFirstSync = await findAppRoles();
expect(rolesAfterFirstSync).toHaveLength(1);
expect(rolesAfterFirstSync[0]).toMatchObject({
label: 'Editor',
description: 'Can edit',
});
await syncApplication({
manifest: buildManifest({
roles: [
{
universalIdentifier: TEST_ROLE_ID,
label: 'Editor',
description: 'Can edit',
},
{
universalIdentifier: TEST_SECOND_ROLE_ID,
label: 'Viewer',
description: 'Read-only access',
},
],
}),
expectToFail: false,
});
const rolesAfterSecondSync = await findAppRoles();
expect(rolesAfterSecondSync).toHaveLength(2);
expect(
rolesAfterSecondSync.find((r) => r.label === 'Editor'),
).toBeDefined();
expect(
rolesAfterSecondSync.find((r) => r.label === 'Viewer'),
).toBeDefined();
expect(
rolesAfterSecondSync.find((r) => r.label === 'Viewer'),
).toMatchObject({
description: 'Read-only access',
});
}, 60000);
it('should update a role when properties change in manifest on second sync', async () => {
await syncApplication({
manifest: buildManifest({}),
expectToFail: false,
});
const rolesAfterFirstSync = await findAppRoles();
expect(rolesAfterFirstSync).toHaveLength(1);
expect(rolesAfterFirstSync[0]).toMatchObject({
label: 'Editor',
description: 'Can edit',
});
await syncApplication({
manifest: buildManifest({
roles: [
{
universalIdentifier: TEST_ROLE_ID,
label: 'Senior Editor',
description: 'Can edit everything',
},
],
}),
expectToFail: false,
});
const rolesAfterSecondSync = await findAppRoles();
expect(rolesAfterSecondSync).toHaveLength(1);
expect(rolesAfterSecondSync[0]).toMatchObject({
label: 'Senior Editor',
description: 'Can edit everything',
});
}, 60000);
it('should delete a role when removed from manifest on second sync', async () => {
await syncApplication({
manifest: buildManifest({
roles: [
{
universalIdentifier: TEST_ROLE_ID,
label: 'Editor',
description: 'Can edit',
},
{
universalIdentifier: TEST_SECOND_ROLE_ID,
label: 'Viewer',
description: 'Read-only access',
},
],
}),
expectToFail: false,
});
const rolesAfterFirstSync = await findAppRoles();
expect(rolesAfterFirstSync).toHaveLength(2);
await syncApplication({
manifest: buildManifest({
roles: [
{
universalIdentifier: TEST_ROLE_ID,
label: 'Editor',
description: 'Can edit',
},
],
}),
expectToFail: false,
});
const rolesAfterSecondSync = await findAppRoles();
expect(rolesAfterSecondSync).toHaveLength(1);
expect(rolesAfterSecondSync[0]).toMatchObject({
label: 'Editor',
description: 'Can edit',
});
}, 60000);
});

View File

@@ -0,0 +1,201 @@
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 { findSkills } from 'test/integration/metadata/suites/skill/utils/find-skills.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_SKILL_ID = uuidv4();
const TEST_SECOND_SKILL_ID = uuidv4();
const SKILL_GQL_FIELDS = 'id name label description content icon applicationId';
const buildManifest = (overrides?: Partial<Pick<Manifest, 'skills'>>) =>
buildBaseManifest({ appId: TEST_APP_ID, roleId: TEST_ROLE_ID, overrides });
const findAppSkills = async () => {
const { data } = await findSkills({
gqlFields: SKILL_GQL_FIELDS,
expectToFail: false,
input: undefined,
});
return data.skills.filter(
(skill) => skill.name === 'enrichment' || skill.name === 'scoring',
);
};
describe('Manifest update - skills', () => {
beforeEach(async () => {
await setupApplicationForSync({
applicationUniversalIdentifier: TEST_APP_ID,
name: 'Test Application',
description: 'App for testing skill manifest updates',
sourcePath: 'test-manifest-update-skill',
});
}, 60000);
afterEach(async () => {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: TEST_APP_ID,
});
});
it('should create a new skill when added to manifest on second sync', async () => {
await syncApplication({
manifest: buildManifest({ skills: [] }),
expectToFail: false,
});
const skillsAfterFirstSync = await findAppSkills();
expect(skillsAfterFirstSync).toHaveLength(0);
await syncApplication({
manifest: buildManifest({
skills: [
{
universalIdentifier: TEST_SKILL_ID,
name: 'enrichment',
label: 'Enrichment',
description: 'Enrich contact data',
icon: 'IconSparkles',
content: '# Enrichment\n\nEnrich your data.',
},
],
}),
expectToFail: false,
});
const skillsAfterSecondSync = await findAppSkills();
expect(skillsAfterSecondSync).toHaveLength(1);
expect(skillsAfterSecondSync[0]).toMatchObject({
name: 'enrichment',
label: 'Enrichment',
description: 'Enrich contact data',
icon: 'IconSparkles',
content: '# Enrichment\n\nEnrich your data.',
});
}, 60000);
it('should update a skill when properties change in manifest on second sync', async () => {
await syncApplication({
manifest: buildManifest({
skills: [
{
universalIdentifier: TEST_SKILL_ID,
name: 'enrichment',
label: 'Enrichment',
description: 'Enrich contact data',
icon: 'IconSparkles',
content: '# Enrichment\n\nEnrich your data.',
},
],
}),
expectToFail: false,
});
const skillsAfterFirstSync = await findAppSkills();
expect(skillsAfterFirstSync).toHaveLength(1);
expect(skillsAfterFirstSync[0]).toMatchObject({
label: 'Enrichment',
description: 'Enrich contact data',
});
await syncApplication({
manifest: buildManifest({
skills: [
{
universalIdentifier: TEST_SKILL_ID,
name: 'enrichment',
label: 'Data Enrichment',
description: 'Enrich contact and company data',
icon: 'IconSparkles',
content:
'# Data Enrichment\n\nEnrich your contact and company data from multiple sources.',
},
],
}),
expectToFail: false,
});
const skillsAfterSecondSync = await findAppSkills();
expect(skillsAfterSecondSync).toHaveLength(1);
expect(skillsAfterSecondSync[0]).toMatchObject({
name: 'enrichment',
label: 'Data Enrichment',
description: 'Enrich contact and company data',
content:
'# Data Enrichment\n\nEnrich your contact and company data from multiple sources.',
});
}, 60000);
it('should delete a skill when removed from manifest on second sync', async () => {
await syncApplication({
manifest: buildManifest({
skills: [
{
universalIdentifier: TEST_SKILL_ID,
name: 'enrichment',
label: 'Enrichment',
description: 'Enrich contact data',
icon: 'IconSparkles',
content: '# Enrichment\n\nEnrich your data.',
},
{
universalIdentifier: TEST_SECOND_SKILL_ID,
name: 'scoring',
label: 'Lead Scoring',
description: 'Score your leads',
icon: 'IconChartBar',
content: '# Lead Scoring\n\nScore leads automatically.',
},
],
}),
expectToFail: false,
});
const skillsAfterFirstSync = await findAppSkills();
expect(skillsAfterFirstSync).toHaveLength(2);
expect(
skillsAfterFirstSync.find((s) => s.name === 'enrichment'),
).toBeDefined();
expect(
skillsAfterFirstSync.find((s) => s.name === 'scoring'),
).toBeDefined();
await syncApplication({
manifest: buildManifest({
skills: [
{
universalIdentifier: TEST_SKILL_ID,
name: 'enrichment',
label: 'Enrichment',
description: 'Enrich contact data',
icon: 'IconSparkles',
content: '# Enrichment\n\nEnrich your data.',
},
],
}),
expectToFail: false,
});
const skillsAfterSecondSync = await findAppSkills();
expect(skillsAfterSecondSync).toHaveLength(1);
expect(skillsAfterSecondSync[0]).toMatchObject({
name: 'enrichment',
label: 'Enrichment',
});
expect(
skillsAfterSecondSync.find((s) => s.name === 'scoring'),
).toBeUndefined();
}, 60000);
});

View File

@@ -0,0 +1,86 @@
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 { 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 PUBLIC_ASSET_PATH = 'assets/logo.svg';
const PUBLIC_ASSET_CONTENT = '<svg><circle r="10" /></svg>';
const EXPECTED_SANITIZED_PUBLIC_ASSET_CONTENT =
'<svg><circle r="10"></circle></svg>';
const PUBLIC_ASSET_CONTENT_TYPE = 'image/svg+xml';
describe('Public assets controller download should succeed', () => {
let applicationId: string;
beforeAll(async () => {
await setupApplicationForSync({
applicationUniversalIdentifier: TEST_APP_UID,
name: 'Test Public Assets Download Success App',
description:
'App for testing successful public-assets controller downloads',
sourcePath: 'test-public-assets-download-success',
});
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('should stream a public asset with the correct headers and body', async () => {
jest.useRealTimers();
// `image/svg+xml` is treated as binary by superagent: the bytes land in
// `response.body` as a Buffer, and `response.text` stays undefined.
const response = await request(global.app.getHttpServer())
.get(
`/public-assets/${TEST_WORKSPACE_ID}/${applicationId}/${PUBLIC_ASSET_PATH}`,
)
.buffer(true)
.parse((res, callback) => {
const chunks: Buffer[] = [];
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => callback(null, Buffer.concat(chunks)));
});
jest.useFakeTimers();
expect(response.status).toBe(200);
expect(response.headers['content-type']).toContain(
PUBLIC_ASSET_CONTENT_TYPE,
);
expect(response.headers['x-content-type-options']).toBe('nosniff');
expect((response.body as Buffer).toString('utf-8')).toBe(
EXPECTED_SANITIZED_PUBLIC_ASSET_CONTENT,
);
}, 30000);
});

View File

@@ -0,0 +1,249 @@
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 { findManyObjectMetadataWithIndexes } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata-with-indexes.util';
import { createOneViewField } from 'test/integration/metadata/suites/view-field/utils/create-one-view-field.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 Manifest } from 'twenty-shared/application';
import { FieldMetadataType } 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 ARM_FIELD_ID = uuidv4();
const LEG_FIELD_ID = uuidv4();
const HEAD_FIELD_ID = uuidv4();
const TAIL_FIELD_ID = uuidv4();
const BODY_VIEW_ID = uuidv4();
const ARM_VIEW_FIELD_ID = uuidv4();
const HEAD_VIEW_FIELD_ID = uuidv4();
const TAIL_VIEW_FIELD_ID = uuidv4();
const INITIAL_ANY_FIELD_FILTER_VALUE = 'arm';
const UPDATED_ANY_FIELD_FILTER_VALUE = 'tail';
const HUMAN_OBJECT = buildDefaultObjectManifest({
applicationUniversalIdentifier: TEST_APP_ID,
nameSingular: 'human',
namePlural: 'humans',
labelSingular: 'Human',
labelPlural: 'Humans',
description: 'A human being',
icon: 'IconUser',
additionalFields: [
{
universalIdentifier: ARM_FIELD_ID,
type: FieldMetadataType.TEXT,
name: 'arm',
label: 'Arm',
},
{
universalIdentifier: LEG_FIELD_ID,
type: FieldMetadataType.TEXT,
name: 'leg',
label: 'Leg',
},
{
universalIdentifier: HEAD_FIELD_ID,
type: FieldMetadataType.TEXT,
name: 'head',
label: 'Head',
},
],
});
const buildInitialManifest = (): Manifest =>
buildBaseManifest({
appId: TEST_APP_ID,
roleId: TEST_ROLE_ID,
overrides: {
objects: [HUMAN_OBJECT],
views: [
{
universalIdentifier: BODY_VIEW_ID,
name: 'Body',
objectUniversalIdentifier: HUMAN_OBJECT.universalIdentifier,
anyFieldFilterValue: INITIAL_ANY_FIELD_FILTER_VALUE,
fields: [
{
universalIdentifier: ARM_VIEW_FIELD_ID,
fieldMetadataUniversalIdentifier: ARM_FIELD_ID,
position: 0,
},
{
universalIdentifier: HEAD_VIEW_FIELD_ID,
fieldMetadataUniversalIdentifier: HEAD_FIELD_ID,
position: 1,
},
],
},
],
},
});
const buildResyncManifest = (): Manifest =>
buildBaseManifest({
appId: TEST_APP_ID,
roleId: TEST_ROLE_ID,
overrides: {
objects: [
{
...HUMAN_OBJECT,
fields: [
...HUMAN_OBJECT.fields,
{
universalIdentifier: TAIL_FIELD_ID,
type: FieldMetadataType.TEXT,
name: 'tail',
label: 'Tail',
},
],
},
],
views: [
{
universalIdentifier: BODY_VIEW_ID,
name: 'Body',
objectUniversalIdentifier: HUMAN_OBJECT.universalIdentifier,
anyFieldFilterValue: UPDATED_ANY_FIELD_FILTER_VALUE,
fields: [
{
universalIdentifier: ARM_VIEW_FIELD_ID,
fieldMetadataUniversalIdentifier: ARM_FIELD_ID,
position: 0,
},
{
universalIdentifier: HEAD_VIEW_FIELD_ID,
fieldMetadataUniversalIdentifier: HEAD_FIELD_ID,
position: 1,
},
{
universalIdentifier: TAIL_VIEW_FIELD_ID,
fieldMetadataUniversalIdentifier: TAIL_FIELD_ID,
position: 2,
},
],
},
],
},
});
const findHumanObject = async () => {
const objects = await findManyObjectMetadataWithIndexes({
expectToFail: false,
});
return objects.find(
(objectMetadata) =>
objectMetadata.universalIdentifier === HUMAN_OBJECT.universalIdentifier,
);
};
describe('Successful re-sync of an application whose app-owned view references a cross-app-owned view field', () => {
beforeAll(async () => {
await setupApplicationForSync({
applicationUniversalIdentifier: TEST_APP_ID,
name: 'Human Application',
description: 'App for testing view field ownership conflicts on re-sync',
sourcePath: 'test-api-owned-view-field',
});
}, 60000);
afterAll(async () => {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: TEST_APP_ID,
});
});
it('should re-sync successfully when the app-owned Body view carries a view field owned by another application', async () => {
await syncApplication({
manifest: buildInitialManifest(),
expectToFail: false,
});
const humanObject = await findHumanObject();
expect(humanObject).toBeDefined();
const legField = humanObject?.fieldsList.find(
(field) => field.name === 'leg',
);
expect(legField).toBeDefined();
const { data: viewsData } = await findViews({
objectMetadataId: humanObject?.id,
expectToFail: false,
});
const bodyView = viewsData?.getViews.find((view) => view.name === 'Body');
expect(bodyView).toBeDefined();
expect(bodyView?.anyFieldFilterValue).toBe(INITIAL_ANY_FIELD_FILTER_VALUE);
const { data: createViewFieldData } = await createOneViewField({
input: {
fieldMetadataId: legField?.id ?? '',
viewId: bodyView?.id ?? '',
position: 2,
},
expectToFail: false,
});
const legViewFieldId = createViewFieldData?.createViewField.id;
expect(isDefined(legViewFieldId)).toBe(true);
const { errors } = await syncApplication({
manifest: buildResyncManifest(),
expectToFail: false,
});
expect(isDefined(errors)).toBe(false);
const humanObjectAfterResync = await findHumanObject();
const tailField = humanObjectAfterResync?.fieldsList.find(
(field) => field.name === 'tail',
);
expect(tailField).toBeDefined();
const { data: viewsDataAfterResync } = await findViews({
objectMetadataId: humanObjectAfterResync?.id,
expectToFail: false,
});
const bodyViewAfterResync = viewsDataAfterResync?.getViews.find(
(view) => view.name === 'Body',
);
expect(bodyViewAfterResync?.anyFieldFilterValue).toBe(
UPDATED_ANY_FIELD_FILTER_VALUE,
);
const { data: viewFieldsData } = await findViewFields({
viewId: bodyView?.id ?? '',
expectToFail: false,
});
const viewFieldFieldMetadataIds = (viewFieldsData?.getViewFields ?? []).map(
(viewField) => viewField.fieldMetadataId,
);
expect(viewFieldFieldMetadataIds).toEqual(
expect.arrayContaining([legField?.id, tailField?.id]),
);
const legViewFieldStillPresent = (viewFieldsData?.getViewFields ?? []).some(
(viewField) => viewField.id === legViewFieldId,
);
expect(legViewFieldStillPresent).toBe(true);
}, 120000);
});

View File

@@ -0,0 +1,276 @@
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 { uploadApplicationFile } from 'test/integration/metadata/suites/application/utils/upload-application-file.util';
import { findManyObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata.util';
import { deactivateSkill } from 'test/integration/metadata/suites/skill/utils/deactivate-skill.util';
import { findSkills } from 'test/integration/metadata/suites/skill/utils/find-skills.util';
import { type Manifest, type SkillManifest } from 'twenty-shared/application';
import { isDefined } from 'twenty-shared/utils';
import { v4 as uuidv4 } from 'uuid';
const TEST_APP_ID = uuidv4();
const TEST_ROLE_ID = uuidv4();
const SKILL_NAME = 'additiveModeSkill';
const CLEANUP_LOGIC_FUNCTION_ID = uuidv4();
const SETTINGS_FRONT_COMPONENT_ID = uuidv4();
const BUILT_SETTINGS_COMPONENT_PATH = 'src/front-components/settings.mjs';
const skill: SkillManifest = {
universalIdentifier: uuidv4(),
name: SKILL_NAME,
label: 'Additive Mode Skill',
content: 'Summarize the record in one sentence.',
};
const ticketObject = buildDefaultObjectManifest({
applicationUniversalIdentifier: TEST_APP_ID,
nameSingular: 'additiveTicket',
namePlural: 'additiveTickets',
labelSingular: 'Additive Ticket',
labelPlural: 'Additive Tickets',
description: 'A support ticket',
});
const invoiceObject = buildDefaultObjectManifest({
applicationUniversalIdentifier: TEST_APP_ID,
nameSingular: 'additiveInvoice',
namePlural: 'additiveInvoices',
labelSingular: 'Additive Invoice',
labelPlural: 'Additive Invoices',
description: 'A billing invoice',
});
const buildManifest = (
overrides?: Partial<Pick<Manifest, 'objects' | 'skills'>>,
) =>
buildBaseManifest({
appId: TEST_APP_ID,
roleId: TEST_ROLE_ID,
overrides: { skills: [skill], ...overrides },
});
const buildManifestWithApplicationReferences = (): Manifest => {
const baseManifest = buildManifest({
objects: [ticketObject, invoiceObject],
});
return {
...baseManifest,
application: {
...baseManifest.application,
settingsFrontComponent: {
universalIdentifier: SETTINGS_FRONT_COMPONENT_ID,
},
uninstallLogicFunction: {
universalIdentifier: CLEANUP_LOGIC_FUNCTION_ID,
},
},
logicFunctions: [
{
universalIdentifier: CLEANUP_LOGIC_FUNCTION_ID,
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,
},
},
],
frontComponents: [
{
universalIdentifier: SETTINGS_FRONT_COMPONENT_ID,
name: 'SettingsComponent',
description: 'The settings tab of the application',
sourceComponentPath: 'src/front-components/settings.tsx',
builtComponentPath: BUILT_SETTINGS_COMPONENT_PATH,
builtComponentChecksum: 'settings-checksum',
componentName: 'SettingsComponent',
isHeadless: false,
},
],
};
};
const findApplicationReferences = async (): Promise<{
settingsCustomTabFrontComponentId: string | null;
uninstallLogicFunctionId: string | null;
}> => {
const [application] = await globalThis.testDataSource.query(
`SELECT "settingsCustomTabFrontComponentId", "uninstallLogicFunctionId"
FROM core."application"
WHERE "universalIdentifier" = $1 AND "deletedAt" IS NULL`,
[TEST_APP_ID],
);
return application;
};
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);
};
const findTestSkill = async () => {
const { data } = await findSkills({
input: undefined,
gqlFields: 'id name isActive',
expectToFail: false,
});
const found = data.skills.find(
(candidate: { name: string }) => candidate.name === SKILL_NAME,
);
expect(isDefined(found)).toBe(true);
return found as { id: string; name: string; isActive: boolean };
};
describe('Manifest sync - additive mode', () => {
beforeEach(async () => {
await setupApplicationForSync({
applicationUniversalIdentifier: TEST_APP_ID,
name: 'Additive Mode Test Application',
description: 'App for testing additive manifest sync',
sourcePath: 'additive-mode-manifest-sync',
});
await syncApplication({
manifest: buildManifest({ objects: [ticketObject, invoiceObject] }),
expectToFail: false,
});
}, 60000);
afterEach(async () => {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: TEST_APP_ID,
});
});
it('keeps entities missing from the manifest when deletions are turned off', async () => {
const manifestWithoutInvoice = buildManifest({ objects: [ticketObject] });
const additivePlan = await syncApplication({
manifest: manifestWithoutInvoice,
dryRun: true,
inferDeletionFromMissingEntities: false,
expectToFail: false,
});
expect(additivePlan.errors).toBeUndefined();
expect(
additivePlan.data.syncApplication.actions.filter(
(action) => (action as { type: string }).type === 'delete',
),
).toHaveLength(0);
const pruningPlan = await syncApplication({
manifest: manifestWithoutInvoice,
dryRun: true,
expectToFail: false,
});
expect(
pruningPlan.data.syncApplication.actions.filter(
(action) => (action as { type: string }).type === 'delete',
).length,
).toBeGreaterThan(0);
const additiveSync = await syncApplication({
manifest: manifestWithoutInvoice,
inferDeletionFromMissingEntities: false,
expectToFail: false,
});
expect(additiveSync.errors).toBeUndefined();
const objectNames = await findObjectNames();
expect(objectNames).toContain('additiveTicket');
expect(objectNames).toContain('additiveInvoice');
}, 60000);
it('keeps the settings component and uninstall hook bindings when the source stops declaring them and deletions are turned off', async () => {
jest.useRealTimers();
await uploadApplicationFile({
applicationUniversalIdentifier: TEST_APP_ID,
fileFolder: 'BuiltFrontComponent',
filePath: BUILT_SETTINGS_COMPONENT_PATH,
fileBuffer: Buffer.from('dummy built component content'),
filename: 'settings.mjs',
contentType: 'application/javascript',
expectToFail: false,
});
jest.useFakeTimers();
await syncApplication({
manifest: buildManifestWithApplicationReferences(),
expectToFail: false,
});
const declaredReferences = await findApplicationReferences();
expect(declaredReferences.settingsCustomTabFrontComponentId).not.toBeNull();
expect(declaredReferences.uninstallLogicFunctionId).not.toBeNull();
const manifestWithoutReferences = buildManifest({
objects: [ticketObject, invoiceObject],
});
const additiveSync = await syncApplication({
manifest: manifestWithoutReferences,
inferDeletionFromMissingEntities: false,
expectToFail: false,
});
expect(additiveSync.errors).toBeUndefined();
expect(await findApplicationReferences()).toEqual(declaredReferences);
await syncApplication({
manifest: manifestWithoutReferences,
expectToFail: false,
});
expect(await findApplicationReferences()).toEqual({
settingsCustomTabFrontComponentId: null,
uninstallLogicFunctionId: null,
});
}, 60000);
it('keeps a skill deactivated by the workspace across a sync', async () => {
const skillBefore = await findTestSkill();
expect(skillBefore.isActive).toBe(true);
const deactivation = await deactivateSkill({
input: { id: skillBefore.id },
expectToFail: false,
});
expect(deactivation.errors).toBeUndefined();
await syncApplication({
manifest: buildManifest({ objects: [ticketObject, invoiceObject] }),
expectToFail: false,
});
const skillAfter = await findTestSkill();
expect(skillAfter.isActive).toBe(false);
}, 60000);
});

View File

@@ -0,0 +1,163 @@
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 type { FieldManifest } from 'twenty-shared/application';
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
import { FieldMetadataType, ViewType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { v4 as uuidv4 } from 'uuid';
const APP_A_ID = uuidv4();
const APP_A_ROLE_ID = uuidv4();
const APP_A_VIEW_ID = uuidv4();
const APP_B_ID = uuidv4();
const APP_B_ROLE_ID = uuidv4();
const APP_B_FIELD_ID = uuidv4();
const APP_B_VIEW_FIELD_ID = uuidv4();
const PERSON_OBJECT_UNIVERSAL_IDENTIFIER =
STANDARD_OBJECTS.person.universalIdentifier;
const APP_A_VIEW_NAME = 'App A View';
const appBPersonField: FieldManifest = {
universalIdentifier: APP_B_FIELD_ID,
type: FieldMetadataType.TEXT,
name: 'appBContributedColumn',
label: 'App B Contributed Column',
description: 'Custom field App B owns on the standard Person object',
icon: 'IconStar',
objectUniversalIdentifier: PERSON_OBJECT_UNIVERSAL_IDENTIFIER,
};
describe('Sync application should succeed when extending another app view with a view field', () => {
beforeAll(async () => {
await setupApplicationForSync({
applicationUniversalIdentifier: APP_A_ID,
name: 'App A',
description: 'App owning the target view',
sourcePath: 'test-cross-app-view-field-app-a',
});
await setupApplicationForSync({
applicationUniversalIdentifier: APP_B_ID,
name: 'App B',
description: 'App extending App A view with a view field',
sourcePath: 'test-cross-app-view-field-app-b',
});
await syncApplication({
manifest: buildBaseManifest({
appId: APP_A_ID,
roleId: APP_A_ROLE_ID,
overrides: {
// Role labels are unique workspace-wide (PG constraint), so each app
// must ship a distinct label
roles: [
{
universalIdentifier: APP_A_ROLE_ID,
label: 'App A Role',
description: 'Role owned by App A',
},
],
views: [
{
universalIdentifier: APP_A_VIEW_ID,
name: APP_A_VIEW_NAME,
objectUniversalIdentifier: PERSON_OBJECT_UNIVERSAL_IDENTIFIER,
type: ViewType.TABLE,
icon: 'IconTable',
},
],
},
}),
expectToFail: false,
});
}, 60000);
afterAll(async () => {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: APP_B_ID,
});
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: APP_A_ID,
});
});
it('accepts a standalone view field from App B targeting an App A view', async () => {
const { errors } = await syncApplication({
manifest: buildBaseManifest({
appId: APP_B_ID,
roleId: APP_B_ROLE_ID,
overrides: {
roles: [
{
universalIdentifier: APP_B_ROLE_ID,
label: 'App B Role',
description: 'Role owned by App B',
},
],
fields: [appBPersonField],
viewFields: [
{
universalIdentifier: APP_B_VIEW_FIELD_ID,
viewUniversalIdentifier: APP_A_VIEW_ID,
fieldMetadataUniversalIdentifier: APP_B_FIELD_ID,
position: 0,
isVisible: true,
size: 150,
},
],
},
}),
expectToFail: false,
});
expect(isDefined(errors)).toBe(false);
const objects = await findManyObjectMetadataWithIndexes({
expectToFail: false,
});
const personObject = objects.find(
(objectMetadata) =>
objectMetadata.universalIdentifier ===
PERSON_OBJECT_UNIVERSAL_IDENTIFIER,
);
expect(personObject).toBeDefined();
const appBField = personObject?.fieldsList.find(
(field) => field.name === appBPersonField.name,
);
expect(appBField).toBeDefined();
const { data: viewsData } = await findViews({
objectMetadataId: personObject?.id,
expectToFail: false,
});
const appAView = viewsData?.getViews.find(
(view) => view.name === APP_A_VIEW_NAME,
);
expect(appAView).toBeDefined();
const { data: viewFieldsData } = await findViewFields({
viewId: appAView?.id ?? '',
expectToFail: false,
});
const appBViewField = (viewFieldsData?.getViewFields ?? []).find(
(viewField) => viewField.fieldMetadataId === appBField?.id,
);
expect(appBViewField).toBeDefined();
}, 60000);
});

View File

@@ -0,0 +1,177 @@
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 { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util';
import { upsertFieldPermissions } from 'test/integration/metadata/suites/field-permission/utils/upsert-field-permissions.util';
import { findManyObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata.util';
import { upsertObjectPermissions } from 'test/integration/metadata/suites/object-permission/utils/upsert-object-permissions.util';
import { upsertPermissionFlags } from 'test/integration/metadata/suites/role-permission-flag/utils/upsert-permission-flags.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 { jestExpectToBeDefined } from 'test/utils/jest-expect-to-be-defined.util.test';
import {
PermissionFlagType,
SystemPermissionFlag,
} from 'twenty-shared/constants';
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
import { WORKSPACE_CUSTOM_APPLICATION_NAME } from 'src/engine/core-modules/application/constants/workspace-custom-application.constant';
import { STANDARD_ROLE } from 'src/engine/workspace-manager/twenty-standard-application/constants/standard-role.constant';
const ROLE_LABEL = 'Derived Permission Identifiers Role';
const PERMISSION_METADATA_NAMES = [
'objectPermission',
'fieldPermission',
'rolePermissionFlag',
];
describe('Sync application should derive the permission identifiers the metadata API derives', () => {
it('should plan no permission action for a manifest declaring permissions created through the metadata API without their universal identifiers', async () => {
const { objects } = await findManyObjectMetadata({
expectToFail: false,
input: { filter: {}, paging: { first: 1000 } },
gqlFields: `
id
nameSingular
fieldsList {
id
name
}
`,
});
const personObject = objects.find(
({ nameSingular }) => nameSingular === 'person',
);
jestExpectToBeDefined(personObject);
const personJobTitleField = personObject.fieldsList?.find(
({ name }) => name === 'jobTitle',
);
jestExpectToBeDefined(personJobTitleField);
const { data: applicationsData } = await findManyApplications({
expectToFail: false,
});
const customApplication = applicationsData.findManyApplications.find(
({ name }) => name === WORKSPACE_CUSTOM_APPLICATION_NAME,
);
jestExpectToBeDefined(customApplication);
const { data: createdRoleData } = await createOneRole({
expectToFail: false,
input: {
label: ROLE_LABEL,
canUpdateAllSettings: false,
canAccessAllTools: false,
canReadAllObjectRecords: false,
canUpdateAllObjectRecords: false,
canSoftDeleteAllObjectRecords: false,
canDestroyAllObjectRecords: false,
canBeAssignedToUsers: true,
},
gqlFields: 'id universalIdentifier',
});
const createdRole = createdRoleData.createOneRole;
try {
const createdRoleUniversalIdentifier = createdRole.universalIdentifier;
jestExpectToBeDefined(createdRoleUniversalIdentifier);
await upsertObjectPermissions({
expectToFail: false,
input: {
roleId: createdRole.id,
objectPermissions: [
{
objectMetadataId: personObject.id,
canReadObjectRecords: true,
canUpdateObjectRecords: false,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
],
},
});
await upsertFieldPermissions({
expectToFail: false,
input: {
roleId: createdRole.id,
fieldPermissions: [
{
objectMetadataId: personObject.id,
fieldMetadataId: personJobTitleField.id,
canReadFieldValue: false,
canUpdateFieldValue: false,
},
],
},
});
await upsertPermissionFlags({
expectToFail: false,
input: {
roleId: createdRole.id,
permissionFlagKeys: [PermissionFlagType.EXPORT_CSV],
},
});
const dryRun = await syncApplication({
manifest: buildBaseManifest({
appId: customApplication.universalIdentifier,
roleId: STANDARD_ROLE.admin.universalIdentifier,
overrides: {
roles: [
{
universalIdentifier: createdRoleUniversalIdentifier,
label: ROLE_LABEL,
objectPermissions: [
{
objectUniversalIdentifier:
STANDARD_OBJECTS.person.universalIdentifier,
canReadObjectRecords: true,
canUpdateObjectRecords: false,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
],
fieldPermissions: [
{
objectUniversalIdentifier:
STANDARD_OBJECTS.person.universalIdentifier,
fieldUniversalIdentifier:
STANDARD_OBJECTS.person.fields.jobTitle
.universalIdentifier,
canReadFieldValue: false,
canUpdateFieldValue: false,
},
],
permissionFlagUniversalIdentifiers: [
SystemPermissionFlag.EXPORT_CSV,
],
},
],
},
}),
dryRun: true,
inferDeletionFromMissingEntities: false,
expectToFail: false,
});
expect(dryRun.errors).toBeUndefined();
const permissionActions = dryRun.data.syncApplication.actions.filter(
({ metadataName }) => PERMISSION_METADATA_NAMES.includes(metadataName),
);
expect(permissionActions).toEqual([]);
} finally {
await deleteOneRole({
expectToFail: false,
input: { idToDelete: createdRole.id },
});
}
}, 60000);
});

View File

@@ -0,0 +1,137 @@
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 { findManyFieldsMetadata } from 'test/integration/metadata/suites/field-metadata/utils/find-many-fields-metadata.util';
import { findManyObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata.util';
import {
getFieldUniversalIdentifier,
type ObjectManifest,
} from 'twenty-shared/application';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { v4 as uuidv4 } from 'uuid';
const SYSTEM_FIELD_NAMES = [
'id',
'createdAt',
'updatedAt',
'deletedAt',
'createdBy',
'updatedBy',
'position',
'searchVector',
];
const TEST_APP_UNIVERSAL_IDENTIFIER = uuidv4();
const TEST_ROLE_UNIVERSAL_IDENTIFIER = uuidv4();
const OBJECT_UNIVERSAL_IDENTIFIER = uuidv4();
const NAME_FIELD_UNIVERSAL_IDENTIFIER = uuidv4();
const OBJECT_NAME_SINGULAR = 'rocketForManifestUniversalIdentifier';
const TEST_OBJECT: ObjectManifest = {
universalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER,
labelIdentifierFieldMetadataUniversalIdentifier:
NAME_FIELD_UNIVERSAL_IDENTIFIER,
nameSingular: OBJECT_NAME_SINGULAR,
namePlural: `${OBJECT_NAME_SINGULAR}s`,
labelSingular: 'Rocket For Manifest Universal Identifier',
labelPlural: 'Rockets For Manifest Universal Identifier',
description: 'A rocket synced through the manifest funnel',
icon: 'IconRocket',
fields: [
{
universalIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldMetadataType.TEXT,
name: 'name',
label: 'Name',
},
],
};
type FetchedField = {
id: string;
name: string;
universalIdentifier: string;
};
describe('Application manifest sync deterministic system field universal identifiers', () => {
let objectUniversalIdentifier: string;
let fetchedFields: FetchedField[];
beforeAll(async () => {
await setupApplicationForSync({
applicationUniversalIdentifier: TEST_APP_UNIVERSAL_IDENTIFIER,
name: 'Test Application',
description: 'A test application',
sourcePath: 'test-sync-deterministic',
});
await syncApplication({
expectToFail: false,
manifest: buildBaseManifest({
appId: TEST_APP_UNIVERSAL_IDENTIFIER,
roleId: TEST_ROLE_UNIVERSAL_IDENTIFIER,
overrides: { objects: [TEST_OBJECT] },
}),
});
const { objects } = await findManyObjectMetadata({
expectToFail: false,
input: { filter: {}, paging: { first: 100 } },
gqlFields: 'id nameSingular universalIdentifier',
});
const syncedObject = objects.find(
(object) => object.universalIdentifier === OBJECT_UNIVERSAL_IDENTIFIER,
);
if (!isDefined(syncedObject)) {
throw new Error(
'Could not resolve the object synced through the manifest funnel',
);
}
objectUniversalIdentifier = syncedObject.universalIdentifier;
const { fields } = await findManyFieldsMetadata({
expectToFail: false,
input: {
filter: { objectMetadataId: { eq: syncedObject.id } },
paging: { first: 100 },
},
gqlFields: `
id
name
universalIdentifier
`,
});
fetchedFields = fields.map((edge: { node: FetchedField }) => edge.node);
}, 60000);
afterAll(async () => {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: TEST_APP_UNIVERSAL_IDENTIFIER,
});
});
it.each(SYSTEM_FIELD_NAMES)(
'should derive the %s system field universal identifier deterministically through the manifest sync funnel',
(systemFieldName) => {
const systemField = fetchedFields.find(
(field) => field.name === systemFieldName,
);
expect(systemField).toBeDefined();
expect(systemField?.universalIdentifier).toBe(
getFieldUniversalIdentifier({
applicationUniversalIdentifier: TEST_APP_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier,
name: systemFieldName,
}),
);
},
);
});

View File

@@ -0,0 +1,185 @@
import { createManyOperation } from 'test/integration/graphql/utils/create-many-operation.util';
import { search } from 'test/integration/graphql/utils/search.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 {
getFieldUniversalIdentifier,
type ObjectManifest,
} from 'twenty-shared/application';
import { FieldMetadataType } from 'twenty-shared/types';
import { v4 as uuidv4 } from 'uuid';
const TEST_APP_UNIVERSAL_IDENTIFIER = uuidv4();
const TEST_ROLE_UNIVERSAL_IDENTIFIER = uuidv4();
const OBJECT_UNIVERSAL_IDENTIFIER = uuidv4();
const OBJECT_NAME_SINGULAR = 'relabelableRocket';
const OBJECT_NAME_PLURAL = 'relabelableRockets';
const RECORD_NAME_VALUE = 'AlphaManifestRelabelNameTerm';
const RECORD_TOTO_VALUE = 'BravoManifestRelabelTotoTerm';
const NAME_FIELD_UNIVERSAL_IDENTIFIER = getFieldUniversalIdentifier({
applicationUniversalIdentifier: TEST_APP_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER,
name: 'name',
});
const TOTO_FIELD_UNIVERSAL_IDENTIFIER = getFieldUniversalIdentifier({
applicationUniversalIdentifier: TEST_APP_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER,
name: 'toto',
});
const NAME_FIELD_MANIFEST = {
universalIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldMetadataType.TEXT,
name: 'name',
label: 'Name',
} as const;
const TOTO_FIELD_MANIFEST = {
universalIdentifier: TOTO_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldMetadataType.TEXT,
name: 'toto',
label: 'Toto',
} as const;
const buildObjectManifest = ({
labelIdentifierFieldMetadataUniversalIdentifier,
fields,
}: {
labelIdentifierFieldMetadataUniversalIdentifier: string;
fields: ObjectManifest['fields'];
}): ObjectManifest => ({
universalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER,
labelIdentifierFieldMetadataUniversalIdentifier,
nameSingular: OBJECT_NAME_SINGULAR,
namePlural: OBJECT_NAME_PLURAL,
labelSingular: 'Relabelable Rocket',
labelPlural: 'Relabelable Rockets',
description: 'A rocket whose label identifier is relabeled across syncs',
icon: 'IconRocket',
isSearchable: true,
fields,
});
const syncObjectManifest = async (objectManifest: ObjectManifest) => {
await syncApplication({
expectToFail: false,
manifest: buildBaseManifest({
appId: TEST_APP_UNIVERSAL_IDENTIFIER,
roleId: TEST_ROLE_UNIVERSAL_IDENTIFIER,
overrides: { objects: [objectManifest] },
}),
});
};
const searchRecordIds = async (searchInput: string): Promise<string[]> => {
const searchResult = await search({
searchInput,
includedObjectNameSingulars: [OBJECT_NAME_SINGULAR],
limit: 10,
expectToFail: false,
});
return searchResult.data.search.edges.map((edge) => edge.node.recordId);
};
describe('Application manifest sync - search field metadata on label identifier relabel', () => {
let recordId: string;
beforeAll(async () => {
await setupApplicationForSync({
applicationUniversalIdentifier: TEST_APP_UNIVERSAL_IDENTIFIER,
name: 'Test Application',
description: 'A test application',
sourcePath: 'test-sync-search-relabel',
});
// First sync: object with `name` and `toto` text fields, `name` as label
// identifier. `toto` exists from the start but is not the label identifier,
// so only `name` is indexed for search initially.
// NOTE: `toto` is created here rather than in the relabel sync because a
// single sync cannot both create a field and relabel the object onto it
// yet (objectMetadata.update is ordered before fieldMetadata.create in the
// migration runner). Tracked for a follow-up core fix:
// https://github.com/twentyhq/core-team-issues/issues/2655
await syncObjectManifest(
buildObjectManifest({
labelIdentifierFieldMetadataUniversalIdentifier:
NAME_FIELD_UNIVERSAL_IDENTIFIER,
fields: [NAME_FIELD_MANIFEST, TOTO_FIELD_MANIFEST],
}),
);
const { data } = await createManyOperation({
objectMetadataSingularName: OBJECT_NAME_SINGULAR,
objectMetadataPluralName: OBJECT_NAME_PLURAL,
gqlFields: 'id name toto',
data: [{ name: RECORD_NAME_VALUE, toto: RECORD_TOTO_VALUE }],
expectToFail: false,
});
recordId = data.createdRecords[0].id;
}, 120000);
afterAll(async () => {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: TEST_APP_UNIVERSAL_IDENTIFIER,
});
});
it('should make records searchable through the name label identifier only', async () => {
expect(await searchRecordIds(RECORD_NAME_VALUE)).toEqual([recordId]);
// `toto` is not the label identifier yet, so it is not part of the search surface.
expect(await searchRecordIds(RECORD_TOTO_VALUE)).toEqual([]);
}, 120000);
it('should swap the search surface onto toto after relabeling onto it', async () => {
// Second sync: relabel the object onto the pre-existing `toto` field.
await syncObjectManifest(
buildObjectManifest({
labelIdentifierFieldMetadataUniversalIdentifier:
TOTO_FIELD_UNIVERSAL_IDENTIFIER,
fields: [NAME_FIELD_MANIFEST, TOTO_FIELD_MANIFEST],
}),
);
expect(await searchRecordIds(RECORD_NAME_VALUE)).toEqual([]);
expect(await searchRecordIds(RECORD_TOTO_VALUE)).toEqual([recordId]);
}, 120000);
it('should keep the previous label searchable when the manifest declares it so', async () => {
await syncObjectManifest(
buildObjectManifest({
labelIdentifierFieldMetadataUniversalIdentifier:
TOTO_FIELD_UNIVERSAL_IDENTIFIER,
fields: [
{ ...NAME_FIELD_MANIFEST, isSearchable: true },
TOTO_FIELD_MANIFEST,
],
}),
);
expect(await searchRecordIds(RECORD_NAME_VALUE)).toEqual([recordId]);
expect(await searchRecordIds(RECORD_TOTO_VALUE)).toEqual([recordId]);
}, 120000);
it('should only remain searchable through name after toto is removed', async () => {
// Third sync: relabel back onto `name` and remove the `toto` field. Removing
// the field must drop its searchFieldMetadata row so it leaves the search surface.
await syncObjectManifest(
buildObjectManifest({
labelIdentifierFieldMetadataUniversalIdentifier:
NAME_FIELD_UNIVERSAL_IDENTIFIER,
fields: [NAME_FIELD_MANIFEST],
}),
);
expect(await searchRecordIds(RECORD_NAME_VALUE)).toEqual([recordId]);
expect(await searchRecordIds(RECORD_TOTO_VALUE)).toEqual([]);
}, 120000);
});

View File

@@ -0,0 +1,131 @@
import { createManyOperation } from 'test/integration/graphql/utils/create-many-operation.util';
import { search } from 'test/integration/graphql/utils/search.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 {
getFieldUniversalIdentifier,
type ObjectManifest,
} from 'twenty-shared/application';
import { FieldMetadataType } from 'twenty-shared/types';
import { v4 as uuidv4 } from 'uuid';
const TEST_APP_UNIVERSAL_IDENTIFIER = uuidv4();
const TEST_ROLE_UNIVERSAL_IDENTIFIER = uuidv4();
const OBJECT_UNIVERSAL_IDENTIFIER = uuidv4();
const OBJECT_NAME_SINGULAR = 'searchableRocket';
const OBJECT_NAME_PLURAL = 'searchableRockets';
const RECORD_NAME_VALUE = 'FalconHeavyManifestSearchTerm';
const OTHER_RECORD_NAME_VALUE = 'AtlasFiveManifestSearchTerm';
const NAME_FIELD_UNIVERSAL_IDENTIFIER = getFieldUniversalIdentifier({
applicationUniversalIdentifier: TEST_APP_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER,
name: 'name',
});
const TEST_OBJECT: ObjectManifest = {
universalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER,
labelIdentifierFieldMetadataUniversalIdentifier:
NAME_FIELD_UNIVERSAL_IDENTIFIER,
nameSingular: OBJECT_NAME_SINGULAR,
namePlural: OBJECT_NAME_PLURAL,
labelSingular: 'Searchable Rocket',
labelPlural: 'Searchable Rockets',
description: 'A rocket synced through the manifest funnel',
icon: 'IconRocket',
isSearchable: true,
fields: [
{
universalIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldMetadataType.TEXT,
name: 'name',
label: 'Name',
},
],
};
describe('Application manifest sync search field metadata population', () => {
let matchingRecordId: string;
beforeAll(async () => {
await setupApplicationForSync({
applicationUniversalIdentifier: TEST_APP_UNIVERSAL_IDENTIFIER,
name: 'Test Application',
description: 'A test application',
sourcePath: 'test-sync-search',
});
await syncApplication({
expectToFail: false,
manifest: buildBaseManifest({
appId: TEST_APP_UNIVERSAL_IDENTIFIER,
roleId: TEST_ROLE_UNIVERSAL_IDENTIFIER,
overrides: { objects: [TEST_OBJECT] },
}),
});
const { data } = await createManyOperation({
objectMetadataSingularName: OBJECT_NAME_SINGULAR,
objectMetadataPluralName: OBJECT_NAME_PLURAL,
gqlFields: 'id name',
data: [
{ name: RECORD_NAME_VALUE },
{ name: OTHER_RECORD_NAME_VALUE },
],
expectToFail: false,
});
const matchingRecord = data.createdRecords.find(
(record) => record.name === RECORD_NAME_VALUE,
);
if (matchingRecord === undefined) {
throw new Error('Could not create the record to search for');
}
matchingRecordId = matchingRecord.id;
}, 60000);
afterAll(async () => {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: TEST_APP_UNIVERSAL_IDENTIFIER,
});
});
it('should make the synced object searchable through its server-synthesized search field metadata', async () => {
const searchResult = await search({
searchInput: RECORD_NAME_VALUE,
includedObjectNameSingulars: [OBJECT_NAME_SINGULAR],
limit: 10,
expectToFail: false,
});
expect(searchResult.data.search.edges).toHaveLength(1);
const searchResultNode = searchResult.data.search.edges[0].node;
expect(searchResultNode.recordId).toBe(matchingRecordId);
expect(searchResultNode.objectNameSingular).toBe(OBJECT_NAME_SINGULAR);
expect(searchResultNode.label).toBe(RECORD_NAME_VALUE);
}, 60000);
it('should not return records whose label identifier does not match the search term', async () => {
const searchResult = await search({
searchInput: RECORD_NAME_VALUE,
includedObjectNameSingulars: [OBJECT_NAME_SINGULAR],
limit: 10,
expectToFail: false,
});
const returnedRecordIds = searchResult.data.search.edges.map(
(edge) => edge.node.recordId,
);
expect(returnedRecordIds).not.toContain(OTHER_RECORD_NAME_VALUE);
expect(returnedRecordIds).toEqual([matchingRecordId]);
}, 60000);
});

View File

@@ -0,0 +1,246 @@
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 { findManyObjectMetadataWithIndexes } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata-with-indexes.util';
import {
getFieldUniversalIdentifier,
type FieldManifest,
} 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 TEST_OBJECT = buildDefaultObjectManifest({
applicationUniversalIdentifier: TEST_APP_ID,
nameSingular: 'uniqueRenameObject',
namePlural: 'uniqueRenameObjects',
labelSingular: 'Unique Rename Object',
labelPlural: 'Unique Rename Objects',
description: 'Object used to test unique field renames',
icon: 'IconTag',
});
const buildUniqueFieldManifest = ({
universalIdentifier,
name,
}: {
universalIdentifier: string;
name: string;
}): FieldManifest => ({
universalIdentifier,
type: FieldMetadataType.TEXT,
name,
label: 'Unique Reference',
description: 'Unique reference field',
icon: 'IconId',
isUnique: true,
isNullable: true,
objectUniversalIdentifier: TEST_OBJECT.universalIdentifier,
});
const buildManifest = (fields: FieldManifest[]) =>
buildBaseManifest({
appId: TEST_APP_ID,
roleId: TEST_ROLE_ID,
overrides: { objects: [TEST_OBJECT], fields },
});
// Mirrors the engine's derivation so the test asserts the exact backing index
// the side-effect engine is expected to own for a given field name.
const computeExpectedUniqueIndexName = (fieldName: string): string =>
generateDeterministicIndexName({
flatObjectMetadata: {
nameSingular: TEST_OBJECT.nameSingular,
applicationUniversalIdentifier: TEST_APP_ID,
} as UniversalFlatObjectMetadata,
orderedIndexColumnNames: [fieldName],
isUnique: true,
indexWhereClause: null,
});
const findTestObject = async () => {
const objects = await findManyObjectMetadataWithIndexes({
expectToFail: false,
});
const object = objects.find(
(objectMetadata) =>
objectMetadata.universalIdentifier === TEST_OBJECT.universalIdentifier,
);
expect(object).toBeDefined();
return object!;
};
describe('Sync application unique field rename', () => {
beforeEach(async () => {
await setupApplicationForSync({
applicationUniversalIdentifier: TEST_APP_ID,
name: 'Unique Field Rename App',
description: 'App for testing unique field rename behaviors',
sourcePath: 'test-unique-field-rename',
});
}, 60000);
afterEach(async () => {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: TEST_APP_ID,
});
});
it('should drop and recreate the field and its backing unique index when the rename changes the field universal identifier', async () => {
const computeNameDerivedFieldUniversalIdentifier = (name: string) =>
getFieldUniversalIdentifier({
applicationUniversalIdentifier: TEST_APP_ID,
objectUniversalIdentifier: TEST_OBJECT.universalIdentifier,
name,
});
await syncApplication({
manifest: buildManifest([
buildUniqueFieldManifest({
universalIdentifier:
computeNameDerivedFieldUniversalIdentifier('tata'),
name: 'tata',
}),
]),
expectToFail: false,
});
const objectAfterFirstSync = await findTestObject();
const tataField = objectAfterFirstSync.fieldsList.find(
(field) => field.name === 'tata',
);
expect(tataField).toBeDefined();
expect(
objectAfterFirstSync.indexMetadataList.find(
(index) => index.name === computeExpectedUniqueIndexName('tata'),
),
).toBeDefined();
await syncApplication({
manifest: buildManifest([
buildUniqueFieldManifest({
universalIdentifier:
computeNameDerivedFieldUniversalIdentifier('toto'),
name: 'toto',
}),
]),
expectToFail: false,
});
const objectAfterSecondSync = await findTestObject();
const totoField = objectAfterSecondSync.fieldsList.find(
(field) => field.name === 'toto',
);
expect(totoField).toBeDefined();
expect(totoField?.universalIdentifier).toBe(
computeNameDerivedFieldUniversalIdentifier('toto'),
);
// A different metadata id proves the field was dropped and recreated,
// not updated in place.
expect(totoField?.id).not.toBe(tataField?.id);
expect(
objectAfterSecondSync.fieldsList.find((field) => field.name === 'tata'),
).toBeUndefined();
const totoUniqueIndex = objectAfterSecondSync.indexMetadataList.find(
(index) => index.name === computeExpectedUniqueIndexName('toto'),
);
expect(totoUniqueIndex).toBeDefined();
expect(totoUniqueIndex?.isUnique).toBe(true);
expect(
totoUniqueIndex?.indexFieldMetadataList.some(
(indexField) => indexField.fieldMetadataId === totoField?.id,
),
).toBe(true);
expect(
objectAfterSecondSync.indexMetadataList.find(
(index) => index.name === computeExpectedUniqueIndexName('tata'),
),
).toBeUndefined();
}, 60000);
it('should update the field in place and swap its backing unique index when the rename keeps a static universal identifier', async () => {
const staticFieldUniversalIdentifier = uuidv4();
await syncApplication({
manifest: buildManifest([
buildUniqueFieldManifest({
universalIdentifier: staticFieldUniversalIdentifier,
name: 'tata',
}),
]),
expectToFail: false,
});
const objectAfterFirstSync = await findTestObject();
const tataField = objectAfterFirstSync.fieldsList.find(
(field) => field.name === 'tata',
);
expect(tataField).toBeDefined();
expect(tataField?.universalIdentifier).toBe(staticFieldUniversalIdentifier);
expect(
objectAfterFirstSync.indexMetadataList.find(
(index) => index.name === computeExpectedUniqueIndexName('tata'),
),
).toBeDefined();
await syncApplication({
manifest: buildManifest([
buildUniqueFieldManifest({
universalIdentifier: staticFieldUniversalIdentifier,
name: 'toto',
}),
]),
expectToFail: false,
});
const objectAfterSecondSync = await findTestObject();
const renamedField = objectAfterSecondSync.fieldsList.find(
(field) => field.universalIdentifier === staticFieldUniversalIdentifier,
);
expect(renamedField).toBeDefined();
expect(renamedField?.name).toBe('toto');
// The same metadata id proves the field was updated in place, not dropped
// and recreated.
expect(renamedField?.id).toBe(tataField?.id);
expect(
objectAfterSecondSync.fieldsList.find((field) => field.name === 'tata'),
).toBeUndefined();
const totoUniqueIndex = objectAfterSecondSync.indexMetadataList.find(
(index) => index.name === computeExpectedUniqueIndexName('toto'),
);
expect(totoUniqueIndex).toBeDefined();
expect(totoUniqueIndex?.isUnique).toBe(true);
expect(
totoUniqueIndex?.indexFieldMetadataList.some(
(indexField) => indexField.fieldMetadataId === renamedField?.id,
),
).toBe(true);
expect(
objectAfterSecondSync.indexMetadataList.find(
(index) => index.name === computeExpectedUniqueIndexName('tata'),
),
).toBeUndefined();
}, 60000);
});

View File

@@ -0,0 +1,82 @@
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 FieldManifest } 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 REUSABLE_UID = uuidv4();
const TEST_OBJECT = buildDefaultObjectManifest({
applicationUniversalIdentifier: TEST_APP_ID,
universalIdentifier: REUSABLE_UID,
nameSingular: 'ephemeral',
namePlural: 'ephemerals',
labelSingular: 'Ephemeral',
labelPlural: 'Ephemerals',
description: 'An ephemeral object that will be removed on second sync',
icon: 'IconTrash',
});
const REUSED_UID_FIELD: FieldManifest = {
universalIdentifier: REUSABLE_UID,
type: FieldMetadataType.TEXT,
name: 'reusedUidField',
label: 'Reused UID Field',
description:
'Field that reuses the universalIdentifier from the deleted object',
icon: 'IconRefresh',
objectUniversalIdentifier: STANDARD_OBJECTS.company.universalIdentifier,
};
describe('Cross-entity universalIdentifier reuse across syncs', () => {
beforeAll(async () => {
await setupApplicationForSync({
applicationUniversalIdentifier: TEST_APP_ID,
name: 'Test UID Reuse App',
description:
'App for testing universalIdentifier reuse across entity types',
sourcePath: 'test-uid-reuse',
});
}, 60000);
afterAll(async () => {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: TEST_APP_ID,
});
});
it('should allow reusing a universalIdentifier from a deleted object as a field in the same sync', async () => {
const firstManifest = buildBaseManifest({
appId: TEST_APP_ID,
roleId: TEST_ROLE_ID,
overrides: {
objects: [TEST_OBJECT],
},
});
await syncApplication({
manifest: firstManifest,
expectToFail: false,
});
const secondManifest = buildBaseManifest({
appId: TEST_APP_ID,
roleId: TEST_ROLE_ID,
overrides: {
objects: [],
fields: [REUSED_UID_FIELD],
},
});
await syncApplication({
manifest: secondManifest,
expectToFail: false,
});
}, 60000);
});

View File

@@ -0,0 +1,410 @@
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 { findManyObjectMetadataWithIndexes } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata-with-indexes.util';
import { findManyObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata.util';
import { findRoles } from 'test/integration/metadata/suites/role/utils/find-roles.util';
import { findSkills } from 'test/integration/metadata/suites/skill/utils/find-skills.util';
import { extractRecordIdsAndDatesAsExpectAny } from 'test/utils/extract-record-ids-and-dates-as-expect-any';
import {
type FieldManifest,
getSystemRelationFieldUniversalIdentifier,
type Manifest,
} from 'twenty-shared/application';
import {
DEFAULT_RELATIONS_OBJECTS_STANDARD_IDS,
STANDARD_OBJECTS,
} from 'twenty-shared/metadata';
import { FieldMetadataType } from 'twenty-shared/types';
import { v4 as uuidv4 } from 'uuid';
const TEST_APP_ID = '0c17e907-f32a-4526-98e4-9addd4302b1a';
const TEST_ROLE_ID = uuidv4();
const TEST_FIELD_ID = uuidv4();
const TEST_SKILL_ID = uuidv4();
const TEST_OBJECT = buildDefaultObjectManifest({
applicationUniversalIdentifier: TEST_APP_ID,
nameSingular: 'ticket',
namePlural: 'tickets',
labelSingular: 'Ticket',
labelPlural: 'Tickets',
description: 'A support ticket',
icon: 'IconTicket',
});
const TEST_SKILL = {
universalIdentifier: TEST_SKILL_ID,
name: 'test-skill',
label: 'Test Skill',
description: 'A skill for testing',
icon: 'IconBrain',
content: '# Test Skill\n\nThis is a test skill.',
};
const TEST_FIELD: FieldManifest = {
universalIdentifier: TEST_FIELD_ID,
type: FieldMetadataType.TEXT,
name: 'description',
label: 'Description',
description: 'Ticket description',
icon: 'IconFileDescription',
objectUniversalIdentifier: TEST_OBJECT.universalIdentifier,
};
const buildManifest = (
overrides?: Partial<Pick<Manifest, 'fields' | 'skills' | 'objects'>>,
) =>
buildBaseManifest({
appId: TEST_APP_ID,
roleId: TEST_ROLE_ID,
overrides: {
objects: [TEST_OBJECT],
skills: [TEST_SKILL],
fields: [TEST_FIELD],
...overrides,
},
});
describe('syncApplication', () => {
beforeEach(async () => {
await setupApplicationForSync({
applicationUniversalIdentifier: TEST_APP_ID,
name: 'Test Application',
description: 'A test application',
sourcePath: 'test-sync',
});
}, 60000);
afterEach(async () => {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: TEST_APP_ID,
});
});
it('should return workspace migration actions on initial sync then on second sync with field rename and new role', async () => {
const { data: firstSyncData } = await syncApplication({
manifest: buildManifest(),
expectToFail: false,
});
expect(firstSyncData).toMatchSnapshot(
extractRecordIdsAndDatesAsExpectAny(firstSyncData),
);
const { objects: objectsAfterSync } = await findManyObjectMetadata({
input: {
filter: {},
paging: { first: 100 },
},
gqlFields:
'id nameSingular namePlural labelSingular labelPlural description icon',
expectToFail: false,
});
const ticketObject = objectsAfterSync.find(
(obj) => obj.nameSingular === 'ticket',
);
expect(ticketObject).toBeDefined();
expect(ticketObject).toMatchObject({
nameSingular: 'ticket',
namePlural: 'tickets',
labelSingular: 'Ticket',
labelPlural: 'Tickets',
description: 'A support ticket',
icon: 'IconTicket',
});
const objects = await findManyObjectMetadataWithIndexes({
expectToFail: false,
});
const fieldsAfterSync = objects.find(
(o) => o.universalIdentifier === TEST_OBJECT.universalIdentifier,
)?.fieldsList;
const descriptionField = fieldsAfterSync?.find(
(f) => f.universalIdentifier === TEST_FIELD_ID,
);
expect(descriptionField).toBeDefined();
expect(descriptionField).toMatchObject({
name: 'description',
label: 'Description',
type: FieldMetadataType.TEXT,
description: 'Ticket description',
icon: 'IconFileDescription',
});
const { data: rolesAfterSync } = await findRoles({
gqlFields: 'id label description universalIdentifier',
expectToFail: false,
});
const testRole = rolesAfterSync.getRoles.find(
(role) => role.universalIdentifier === TEST_ROLE_ID,
);
const { data: skillsAfterSync } = await findSkills({
gqlFields: 'id name label description content icon',
expectToFail: false,
input: undefined,
});
const testSkill = skillsAfterSync.skills.find(
(skill) => skill.name === 'test-skill',
);
expect(testRole).toBeDefined();
expect(testRole).toMatchObject({
label: 'Test Role',
description: 'A test role',
});
expect(testSkill).toBeDefined();
expect(testSkill).toMatchObject({
name: 'test-skill',
label: 'Test Skill',
description: 'A skill for testing',
icon: 'IconBrain',
content: '# Test Skill\n\nThis is a test skill.',
});
}, 60000);
// The snapshot masks every identifier as Any<String>, so it cannot catch a
// regression in the deterministic universal identifiers of the default
// relations. This test recomputes them and asserts the emitted migration
// actions carry them exactly, on both sides of each relation.
it('should provision the four engine-owned default relations on initial sync', async () => {
const NAME_PLURAL_BY_STANDARD_OBJECT_NAME_SINGULAR = {
timelineActivity: 'timelineActivities',
attachment: 'attachments',
noteTarget: 'noteTargets',
taskTarget: 'taskTargets',
} as const;
const { data: syncData } = await syncApplication({
manifest: buildManifest(),
expectToFail: false,
});
type FlatFieldEntity = {
universalIdentifier: string;
name?: string;
type?: string;
isSystem?: boolean;
isSystemSideEffect?: boolean;
morphId?: string | null;
objectMetadataUniversalIdentifier?: string;
relationTargetObjectMetadataUniversalIdentifier?: string | null;
relationTargetFieldMetadataUniversalIdentifier?: string | null;
universalSettings?: { joinColumnName?: string } | null;
universalFlatIndexFieldMetadatas?: {
fieldMetadataUniversalIdentifier: string;
}[];
};
type FlatEntityAction = {
type: string;
metadataName: string;
flatEntity: FlatFieldEntity;
// A relation field create action carries its reverse field inline.
relatedUniversalFlatFieldMetadata?: FlatFieldEntity;
};
const actions = (
syncData as {
syncApplication: { actions: FlatEntityAction[] };
}
).syncApplication.actions;
const fieldCreateActionByUniversalIdentifier = Object.fromEntries(
actions
.filter(
(action) =>
action.metadataName === 'fieldMetadata' && action.type === 'create',
)
.map((action) => [action.flatEntity.universalIdentifier, action]),
);
const indexCreateActions = actions.filter(
(action) => action.metadataName === 'index' && action.type === 'create',
);
for (const standardObjectNameSingular of DEFAULT_RELATIONS_OBJECTS_STANDARD_IDS) {
const standardObjectUniversalIdentifier =
STANDARD_OBJECTS[standardObjectNameSingular].universalIdentifier;
const standardObjectNamePlural =
NAME_PLURAL_BY_STANDARD_OBJECT_NAME_SINGULAR[
standardObjectNameSingular
];
const forwardUniversalIdentifier =
getSystemRelationFieldUniversalIdentifier({
applicationUniversalIdentifier: TEST_APP_ID,
objectUniversalIdentifier: TEST_OBJECT.universalIdentifier,
relationTargetObjectUniversalIdentifier:
standardObjectUniversalIdentifier,
});
const reverseUniversalIdentifier =
getSystemRelationFieldUniversalIdentifier({
applicationUniversalIdentifier: TEST_APP_ID,
objectUniversalIdentifier: standardObjectUniversalIdentifier,
relationTargetObjectUniversalIdentifier:
TEST_OBJECT.universalIdentifier,
});
// Forward RELATION field on the ticket, named after the standard
// object's namePlural, under the name-free universal identifier.
const forwardCreateAction =
fieldCreateActionByUniversalIdentifier[forwardUniversalIdentifier];
expect(forwardCreateAction?.flatEntity).toMatchObject({
name: standardObjectNamePlural,
type: FieldMetadataType.RELATION,
// Engine-owned (isSystemSideEffect) but not isSystem: the engine
// rename/delete cascades must pass the validator system-field gates.
isSystem: false,
isSystemSideEffect: true,
objectMetadataUniversalIdentifier: TEST_OBJECT.universalIdentifier,
relationTargetObjectMetadataUniversalIdentifier:
standardObjectUniversalIdentifier,
relationTargetFieldMetadataUniversalIdentifier:
reverseUniversalIdentifier,
});
// Reverse MORPH_RELATION field on the standard object, carried inline on
// the forward create action, under the name-free deterministic universal
// identifier, with the engine-pinned targetMorphId and the join column.
expect(
forwardCreateAction?.relatedUniversalFlatFieldMetadata,
).toMatchObject({
name: 'targetTicket',
type: FieldMetadataType.MORPH_RELATION,
isSystem: false,
isSystemSideEffect: true,
universalIdentifier: reverseUniversalIdentifier,
morphId:
STANDARD_OBJECTS[standardObjectNameSingular].morphIds.targetMorphId
.morphId,
objectMetadataUniversalIdentifier: standardObjectUniversalIdentifier,
relationTargetObjectMetadataUniversalIdentifier:
TEST_OBJECT.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier:
forwardUniversalIdentifier,
universalSettings: expect.objectContaining({
joinColumnName: 'targetTicketId',
}),
});
// Engine-owned join-column index backing the reverse field.
const reverseJoinColumnIndexAction = indexCreateActions.find((action) =>
action.flatEntity.universalFlatIndexFieldMetadatas?.some(
(indexFieldMetadata) =>
indexFieldMetadata.fieldMetadataUniversalIdentifier ===
reverseUniversalIdentifier,
),
);
expect(reverseJoinColumnIndexAction).toBeDefined();
expect(reverseJoinColumnIndexAction?.flatEntity).toMatchObject({
isSystemSideEffect: true,
objectMetadataUniversalIdentifier: standardObjectUniversalIdentifier,
});
}
}, 60000);
it('should delete old field and create equivalent one when field universalIdentifier changes', async () => {
const originalFieldId = '8abbef24-f8c3-41d5-826b-73a02825e712';
const updatedFieldId = 'a4262080-673d-430a-883b-c5d04610abf1';
const testObject = buildDefaultObjectManifest({
applicationUniversalIdentifier: TEST_APP_ID,
universalIdentifier: '8fc4bfec-01d8-4404-bd1a-dc90fe8e7699',
nameSingular: 'ticket',
namePlural: 'tickets',
labelSingular: 'Ticket',
labelPlural: 'Tickets',
description: 'A support ticket',
icon: 'IconTicket',
});
const baseField: FieldManifest = {
universalIdentifier: originalFieldId,
type: FieldMetadataType.TEXT,
name: 'description',
label: 'Description',
description: 'Ticket description',
icon: 'IconFileDescription',
objectUniversalIdentifier: testObject.universalIdentifier,
};
const { data: firstSyncData } = await syncApplication({
manifest: buildBaseManifest({
appId: TEST_APP_ID,
roleId: TEST_ROLE_ID,
overrides: {
objects: [testObject],
fields: [baseField],
},
}),
expectToFail: false,
});
expect(firstSyncData).toMatchSnapshot(
extractRecordIdsAndDatesAsExpectAny(firstSyncData),
);
const { data: secondSyncData } = await syncApplication({
manifest: buildBaseManifest({
appId: TEST_APP_ID,
roleId: TEST_ROLE_ID,
overrides: {
objects: [testObject],
fields: [
{
...baseField,
universalIdentifier: updatedFieldId,
},
],
},
}),
expectToFail: false,
});
expect(secondSyncData).toMatchSnapshot(
extractRecordIdsAndDatesAsExpectAny(secondSyncData),
);
}, 60000);
it('should create a TEXT field on the standard Company object', async () => {
const companyFieldId = uuidv4();
const manifest = buildManifest({
skills: [],
objects: [],
fields: [
{
universalIdentifier: companyFieldId,
type: FieldMetadataType.TEXT,
name: 'industry',
label: 'Industry',
description: 'The industry of the company',
icon: 'IconBuildingFactory2',
objectUniversalIdentifier:
STANDARD_OBJECTS.company.universalIdentifier,
},
],
});
const { data: syncData } = await syncApplication({
manifest,
expectToFail: false,
});
expect(syncData).toMatchSnapshot(
extractRecordIdsAndDatesAsExpectAny(syncData),
);
}, 60000);
});

View File

@@ -0,0 +1,273 @@
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 { getAppProviderByClassName } from 'test/integration/utils/get-app-provider-by-class-name.util';
import { type Manifest } from 'twenty-shared/application';
import { v4 as uuidv4 } from 'uuid';
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';
const buildManifestWithLogicFunction = ({
appId,
roleId,
logicFunctionId,
withUninstallHook,
}: {
appId: string;
roleId: string;
logicFunctionId: string;
withUninstallHook: boolean;
}): Manifest => {
const baseManifest = buildBaseManifest({ appId, roleId });
return {
...baseManifest,
application: {
...baseManifest.application,
...(withUninstallHook
? { uninstallLogicFunction: { universalIdentifier: logicFunctionId } }
: {}),
},
logicFunctions: [
{
universalIdentifier: logicFunctionId,
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,
},
},
],
};
};
describe('Uninstall application logic function hook', () => {
let appId: string;
let roleId: string;
let logicFunctionId: string;
let executeSpy: jest.SpyInstance;
const syncTestApplication = ({
withUninstallHook,
}: {
withUninstallHook: boolean;
}) =>
syncApplication({
manifest: buildManifestWithLogicFunction({
appId,
roleId,
logicFunctionId,
withUninstallHook,
}),
expectToFail: false,
});
const setRegistrationManifest = (manifest: Manifest | null) =>
globalThis.testDataSource.query(
`UPDATE core."applicationRegistration"
SET "manifest" = $1::jsonb
WHERE "universalIdentifier" = $2`,
[manifest ? JSON.stringify(manifest) : null, appId],
);
const findInstalledUninstallLogicFunctionId = async (): Promise<string> => {
const [installedLogicFunction] = await globalThis.testDataSource.query(
`SELECT id FROM core."logicFunction"
WHERE "universalIdentifier" = $1 AND "deletedAt" IS NULL`,
[logicFunctionId],
);
expect(installedLogicFunction).toBeDefined();
return installedLogicFunction.id;
};
beforeEach(async () => {
appId = uuidv4();
roleId = uuidv4();
logicFunctionId = uuidv4();
await setupApplicationForSync({
applicationUniversalIdentifier: appId,
name: 'Test Application',
description: 'App for testing the uninstall logic function hook',
sourcePath: 'test-uninstall-hook',
});
const logicFunctionExecutorService =
getAppProviderByClassName<LogicFunctionExecutorService>(
'LogicFunctionExecutorService',
);
executeSpy = jest
.spyOn(logicFunctionExecutorService, 'execute')
.mockResolvedValue({
data: {},
duration: 1,
billedDurationMs: 1,
logs: '',
status: LogicFunctionExecutionStatus.SUCCESS,
});
}, 60000);
afterEach(async () => {
executeSpy.mockRestore();
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: appId,
});
});
it('executes the uninstall hook before deleting the application', async () => {
await syncTestApplication({ withUninstallHook: true });
const { data, errors } = await uninstallApplication({
universalIdentifier: appId,
expectToFail: false,
});
expect(errors).toBeUndefined();
expect(data?.uninstallApplication).toBe(true);
expect(executeSpy).toHaveBeenCalledTimes(1);
expect(executeSpy).toHaveBeenCalledWith(
expect.objectContaining({
payload: { version: '1.0.0' },
logicFunctionId: expect.any(String),
}),
);
}, 60000);
it('executes the hook when the registration manifest is unavailable', async () => {
await syncTestApplication({ withUninstallHook: true });
const installedUninstallLogicFunctionId =
await findInstalledUninstallLogicFunctionId();
await setRegistrationManifest(null);
const { data, errors } = await uninstallApplication({
universalIdentifier: appId,
expectToFail: false,
});
expect(errors).toBeUndefined();
expect(data?.uninstallApplication).toBe(true);
expect(executeSpy).toHaveBeenCalledTimes(1);
expect(executeSpy).toHaveBeenCalledWith(
expect.objectContaining({
logicFunctionId: installedUninstallLogicFunctionId,
payload: { version: '1.0.0' },
}),
);
}, 60000);
it('executes the hook resolved at sync time when the registration manifest advances', async () => {
await syncTestApplication({ withUninstallHook: true });
const installedUninstallLogicFunctionId =
await findInstalledUninstallLogicFunctionId();
await setRegistrationManifest(
buildManifestWithLogicFunction({
appId,
roleId,
logicFunctionId: uuidv4(),
withUninstallHook: true,
}),
);
const { data, errors } = await uninstallApplication({
universalIdentifier: appId,
expectToFail: false,
});
expect(errors).toBeUndefined();
expect(data?.uninstallApplication).toBe(true);
expect(executeSpy).toHaveBeenCalledTimes(1);
expect(executeSpy).toHaveBeenCalledWith(
expect.objectContaining({
logicFunctionId: installedUninstallLogicFunctionId,
payload: { version: '1.0.0' },
}),
);
}, 60000);
it('does not execute any hook when the manifest declares no uninstall logic function', async () => {
await syncTestApplication({ withUninstallHook: false });
const { data, errors } = await uninstallApplication({
universalIdentifier: appId,
expectToFail: false,
});
expect(errors).toBeUndefined();
expect(data?.uninstallApplication).toBe(true);
expect(executeSpy).not.toHaveBeenCalled();
}, 60000);
it('does not execute any hook when an upgrade removes the uninstall logic function from the manifest', async () => {
await syncTestApplication({ withUninstallHook: true });
await syncTestApplication({ withUninstallHook: false });
const [application] = await globalThis.testDataSource.query(
`SELECT "uninstallLogicFunctionId" FROM core."application"
WHERE "universalIdentifier" = $1 AND "deletedAt" IS NULL`,
[appId],
);
expect(application.uninstallLogicFunctionId).toBeNull();
const { data, errors } = await uninstallApplication({
universalIdentifier: appId,
expectToFail: false,
});
expect(errors).toBeUndefined();
expect(data?.uninstallApplication).toBe(true);
expect(executeSpy).not.toHaveBeenCalled();
}, 60000);
it('uninstalls the application even when the uninstall hook returns an error (best-effort)', async () => {
executeSpy.mockResolvedValue({
data: null,
duration: 1,
billedDurationMs: 1,
logs: '',
status: LogicFunctionExecutionStatus.ERROR,
error: {
errorType: 'UnhandledError',
errorMessage: 'Uninstall hook failed on purpose',
stackTrace: '',
},
});
await syncTestApplication({ withUninstallHook: true });
const { data, errors } = await uninstallApplication({
universalIdentifier: appId,
expectToFail: false,
});
expect(errors).toBeUndefined();
expect(data?.uninstallApplication).toBe(true);
expect(executeSpy).toHaveBeenCalledTimes(1);
const applicationsAfterUninstall = await globalThis.testDataSource.query(
`SELECT id FROM core."application" WHERE "universalIdentifier" = $1`,
[appId],
);
expect(applicationsAfterUninstall).toHaveLength(0);
}, 60000);
});

View File

@@ -0,0 +1,105 @@
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 { uploadApplicationFile } from 'test/integration/metadata/suites/application/utils/upload-application-file.util';
import { v4 as uuidv4 } from 'uuid';
const TEST_APP_ID = uuidv4();
const TEST_ROLE_ID = uuidv4();
describe('Uninstall application with package file FKs populated', () => {
beforeEach(async () => {
await setupApplicationForSync({
applicationUniversalIdentifier: TEST_APP_ID,
name: 'Test Application',
description: 'App for testing uninstall with package file FKs',
sourcePath: 'test-uninstall-package-file-fks',
});
await syncApplication({
manifest: buildBaseManifest({
appId: TEST_APP_ID,
roleId: TEST_ROLE_ID,
}),
expectToFail: false,
});
}, 60000);
afterEach(async () => {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: TEST_APP_ID,
});
});
it('uninstalls the application and deletes its file rows in one pass', async () => {
jest.useRealTimers();
await uploadApplicationFile({
applicationUniversalIdentifier: TEST_APP_ID,
fileFolder: 'Dependencies',
filePath: 'yarn.lock',
fileBuffer: Buffer.from('# test yarn.lock\n'),
filename: 'yarn.lock',
contentType: 'text/plain',
expectToFail: false,
});
jest.useFakeTimers();
const [application] = await globalThis.testDataSource.query(
`SELECT id FROM core."application" WHERE "universalIdentifier" = $1`,
[TEST_APP_ID],
);
const applicationId: string = application.id;
await globalThis.testDataSource.query(
`UPDATE core."application" a
SET "packageJsonFileId" = (
SELECT f.id FROM core."file" f
WHERE f."applicationId" = a.id
AND f.path = 'dependencies/package.json'
),
"yarnLockFileId" = (
SELECT f.id FROM core."file" f
WHERE f."applicationId" = a.id
AND f.path = 'dependencies/yarn.lock'
)
WHERE a.id = $1`,
[applicationId],
);
const [applicationBeforeUninstall] = await globalThis.testDataSource.query(
`SELECT "packageJsonFileId", "yarnLockFileId"
FROM core."application" WHERE id = $1`,
[applicationId],
);
expect(applicationBeforeUninstall.packageJsonFileId).not.toBeNull();
expect(applicationBeforeUninstall.yarnLockFileId).not.toBeNull();
const { data, errors } = await uninstallApplication({
universalIdentifier: TEST_APP_ID,
expectToFail: false,
});
expect(errors).toBeUndefined();
expect(data?.uninstallApplication).toBe(true);
const applicationsAfterUninstall = await globalThis.testDataSource.query(
`SELECT id FROM core."application" WHERE "universalIdentifier" = $1`,
[TEST_APP_ID],
);
expect(applicationsAfterUninstall).toHaveLength(0);
const fileRowsAfterUninstall = await globalThis.testDataSource.query(
`SELECT id FROM core."file" WHERE "applicationId" = $1`,
[applicationId],
);
expect(fileRowsAfterUninstall).toHaveLength(0);
}, 60000);
});

View File

@@ -0,0 +1,128 @@
import { readFileSync } from 'fs';
import { join } from 'path';
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 { 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_ID = uuidv4();
const TEST_WORKSPACE_ID = SEED_APPLE_WORKSPACE_ID;
const STORAGE_LOCAL_PATH = join(process.cwd(), '.local-storage');
const MALICIOUS_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" onload="alert(1)">
<script>alert('xss')</script>
<a xlink:href="javascript:alert('xss-link')">
<circle cx="50" cy="50" r="40" />
</a>
</svg>`;
// Deterministic DOMPurify output for MALICIOUS_SVG. Shape:
// - <script>...</script> stripped (the two leading spaces on that line are
// preserved — DOMPurify removes the element, not surrounding whitespace).
// - onload="..." attribute stripped from <svg>.
// - xlink:href="javascript:..." stripped from <a> (anchor element kept).
// - <circle .../> self-closing form normalized to <circle ...></circle>.
// Encoded as a single-line literal so whitespace is unambiguous.
const EXPECTED_SANITIZED_MALICIOUS_SVG =
'<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">\n \n <a>\n <circle cx="50" cy="50" r="40"></circle>\n </a>\n</svg>';
const BENIGN_SVG =
'<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50"><rect width="50" height="50" fill="red" /></svg>';
const EXPECTED_SANITIZED_BENIGN_SVG =
'<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50"><rect width="50" height="50" fill="red"></rect></svg>';
describe('Upload application file — SVG sanitization (end-to-end)', () => {
beforeAll(async () => {
await setupApplicationForSync({
applicationUniversalIdentifier: TEST_APP_ID,
name: 'Test SVG Sanitization App',
description: 'App for verifying SVG sanitization end-to-end',
sourcePath: 'test-svg-sanitization',
});
}, 60000);
afterAll(async () => {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: TEST_APP_ID,
});
});
it('strips scripts, event handlers, and javascript: URIs from an uploaded SVG, persisting the exact sanitized content with image/svg+xml mime', async () => {
jest.useRealTimers();
const filePath = 'assets/malicious.svg';
const { data, errors } = await uploadApplicationFile({
applicationUniversalIdentifier: TEST_APP_ID,
fileFolder: 'PublicAsset',
filePath,
fileBuffer: Buffer.from(MALICIOUS_SVG, 'utf-8'),
filename: 'malicious.svg',
contentType: 'image/svg+xml',
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/svg+xml');
const onDiskPath = join(
STORAGE_LOCAL_PATH,
TEST_WORKSPACE_ID,
TEST_APP_ID,
'public-asset',
filePath,
);
const storedContent = readFileSync(onDiskPath, 'utf-8');
expect(storedContent).toBe(EXPECTED_SANITIZED_MALICIOUS_SVG);
}, 60000);
it('preserves a benign SVG with only DOMPurify structural normalization', async () => {
jest.useRealTimers();
const filePath = 'assets/benign.svg';
const { errors } = await uploadApplicationFile({
applicationUniversalIdentifier: TEST_APP_ID,
fileFolder: 'PublicAsset',
filePath,
fileBuffer: Buffer.from(BENIGN_SVG, 'utf-8'),
filename: 'benign.svg',
contentType: 'image/svg+xml',
expectToFail: false,
});
jest.useFakeTimers();
expect(errors).toBeUndefined();
const onDiskPath = join(
STORAGE_LOCAL_PATH,
TEST_WORKSPACE_ID,
TEST_APP_ID,
'public-asset',
filePath,
);
const storedContent = readFileSync(onDiskPath, 'utf-8');
expect(storedContent).toBe(EXPECTED_SANITIZED_BENIGN_SVG);
}, 60000);
});

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