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,196 @@
import crypto from 'crypto';
import gql from 'graphql-tag';
import { isDefined } from 'twenty-shared/utils';
import { type DataSource } from 'typeorm';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { buildSecretEncryptionServiceFromEnv } from 'test/integration/upgrade/utils/build-secret-encryption-service.util';
import { SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX } from 'src/engine/core-modules/secret-encryption/constants/secret-encryption.constant';
import { type SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service';
const V2_ENVELOPE_REGEX = /^enc:v2:[0-9a-f]{8}:[A-Za-z0-9+/=]+$/;
const CONSTRAINT_NAME =
'CHK_applicationRegistrationVariable_encryptedValue_encrypted';
const CONSTRAINT_EXPR = `"encryptedValue" LIKE 'enc:v2:%'`;
describe('ApplicationRegistrationVariable encryption (integration)', () => {
let dataSource: DataSource;
let secretEncryption: SecretEncryptionService;
let applicationRegistrationId: string;
beforeAll(async () => {
dataSource = global.testDataSource;
secretEncryption = buildSecretEncryptionServiceFromEnv();
const createRegistrationResponse = await makeMetadataAPIRequest({
query: gql`
mutation CreateRegistrationForEncryptionTest(
$input: CreateApplicationRegistrationInput!
) {
createApplicationRegistration(input: $input) {
applicationRegistration {
id
}
}
}
`,
variables: {
input: { name: 'enc-integration-test-registration' },
},
});
const registrationId =
createRegistrationResponse.body?.data?.createApplicationRegistration
?.applicationRegistration?.id;
if (!isDefined(registrationId)) {
throw new Error(
`createApplicationRegistration failed: ${JSON.stringify(
createRegistrationResponse.body,
)}`,
);
}
applicationRegistrationId = registrationId;
});
afterAll(async () => {
await makeMetadataAPIRequest({
query: gql`
mutation DeleteRegistrationForEncryptionTest($id: String!) {
deleteApplicationRegistration(id: $id)
}
`,
variables: { id: applicationRegistrationId },
});
});
it('encrypts the value on the API write path, persists ciphertext in Postgres, and decrypts back via the API read path', async () => {
const plaintext = 'this-is-a-v2-encrypted-secret-value';
const createVariableResponse = await makeMetadataAPIRequest({
query: gql`
mutation CreateVariableForEncryptionTest(
$input: CreateApplicationRegistrationVariableInput!
) {
createApplicationRegistrationVariable(input: $input) {
id
}
}
`,
variables: {
input: {
applicationRegistrationId,
key: 'TEST_V2_KEY',
value: plaintext,
isSecret: false,
},
},
});
expect(createVariableResponse.body.errors).toBeUndefined();
const variableId =
createVariableResponse.body.data.createApplicationRegistrationVariable.id;
const [dbRow] = await dataSource.query(
`SELECT "encryptedValue" FROM "core"."applicationRegistrationVariable" WHERE id = $1`,
[variableId],
);
expect(dbRow.encryptedValue).not.toContain(plaintext);
expect(
dbRow.encryptedValue.startsWith(SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX),
).toBe(true);
expect(dbRow.encryptedValue).toMatch(V2_ENVELOPE_REGEX);
const findResponse = await makeMetadataAPIRequest({
query: gql`
query FindVariablesForEncryptionTest(
$applicationRegistrationId: String!
) {
findApplicationRegistrationVariables(
applicationRegistrationId: $applicationRegistrationId
) {
id
key
value
isSecret
}
}
`,
variables: { applicationRegistrationId },
});
expect(findResponse.body.errors).toBeUndefined();
const variable =
findResponse.body.data.findApplicationRegistrationVariables.find(
(v: { id: string }) => v.id === variableId,
);
expect(variable).toBeDefined();
expect(variable.isSecret).toBe(false);
expect(variable.value).toBe(plaintext);
});
describe('legacy CTR values are rejected at runtime', () => {
let legacyVariableId: string;
beforeAll(async () => {
await dataSource.query(
`ALTER TABLE core."applicationRegistrationVariable"
DROP CONSTRAINT IF EXISTS "${CONSTRAINT_NAME}"`,
);
});
afterAll(async () => {
await dataSource.query(
`DELETE FROM core."applicationRegistrationVariable" WHERE id = $1`,
[legacyVariableId],
);
await dataSource.query(
`ALTER TABLE core."applicationRegistrationVariable"
ADD CONSTRAINT "${CONSTRAINT_NAME}" CHECK (${CONSTRAINT_EXPR})`,
);
});
it('rejects a legacy CTR-encrypted value through the live API', async () => {
legacyVariableId = crypto.randomUUID();
const plaintext = 'legacy-ctr-registration-variable-secret';
await dataSource.query(
`INSERT INTO core."applicationRegistrationVariable"
(id, "applicationRegistrationId", "key", "encryptedValue",
"isSecret", "isRequired")
VALUES ($1, $2, 'TEST_LEGACY_CTR_KEY', $3, false, false)`,
[
legacyVariableId,
applicationRegistrationId,
secretEncryption.encrypt(plaintext),
],
);
const findResponse = await makeMetadataAPIRequest({
query: gql`
query FindLegacyCtrVariablesForEncryptionTest(
$applicationRegistrationId: String!
) {
findApplicationRegistrationVariables(
applicationRegistrationId: $applicationRegistrationId
) {
id
value
isSecret
}
}
`,
variables: { applicationRegistrationId },
});
expect(findResponse.body.errors).toBeDefined();
expect(findResponse.body.errors[0].message).toContain('enc:v2 envelope');
});
});
});

View File

@@ -0,0 +1,299 @@
import crypto from 'crypto';
import gql from 'graphql-tag';
import { isDefined } from 'twenty-shared/utils';
import { type DataSource } from 'typeorm';
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 { buildSecretEncryptionServiceFromEnv } from 'test/integration/upgrade/utils/build-secret-encryption-service.util';
import { SECRET_APPLICATION_VARIABLE_MASK } from 'src/engine/core-modules/application/application-variable/constants/secret-application-variable-mask.constant';
import { SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX } from 'src/engine/core-modules/secret-encryption/constants/secret-encryption.constant';
import { type SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service';
const V2_ENVELOPE_REGEX = /^enc:v2:[0-9a-f]{8}:[A-Za-z0-9+/=]+$/;
const CONSTRAINT_NAME = 'CHK_applicationVariable_value_encrypted';
const CONSTRAINT_EXPR = `"value" LIKE 'enc:v2:%'`;
const V2_VARIABLE_KEY = 'TEST_V2_SECRET';
const V2_NON_SECRET_VARIABLE_KEY = 'TEST_V2_NON_SECRET';
const LEGACY_VARIABLE_KEY = 'TEST_LEGACY_CTR_SECRET';
const buildExpectedMask = (plaintext: string): string => {
const visibleCharsCount = Math.min(5, Math.floor(plaintext.length / 10));
return `${plaintext.slice(0, visibleCharsCount)}${SECRET_APPLICATION_VARIABLE_MASK}`;
};
describe('ApplicationVariable encryption (integration)', () => {
let dataSource: DataSource;
let secretEncryption: SecretEncryptionService;
let applicationUniversalIdentifier: string;
let applicationId: string;
beforeAll(async () => {
dataSource = global.testDataSource;
secretEncryption = buildSecretEncryptionServiceFromEnv();
applicationUniversalIdentifier = crypto.randomUUID();
const roleUniversalIdentifier = crypto.randomUUID();
await setupApplicationForSync({
applicationUniversalIdentifier,
name: 'Test Application',
description: 'App for testing application-variable encryption',
sourcePath: 'test-application-variable-encryption',
});
await syncApplication({
manifest: buildBaseManifest({
appId: applicationUniversalIdentifier,
roleId: roleUniversalIdentifier,
overrides: {
application: {
universalIdentifier: applicationUniversalIdentifier,
defaultRoleUniversalIdentifier: roleUniversalIdentifier,
displayName: 'Test Application',
description: 'App for testing application-variable encryption',
applicationVariables: {
[V2_VARIABLE_KEY]: {
universalIdentifier: crypto.randomUUID(),
isSecret: true,
},
[V2_NON_SECRET_VARIABLE_KEY]: {
universalIdentifier: crypto.randomUUID(),
isSecret: false,
},
[LEGACY_VARIABLE_KEY]: {
universalIdentifier: crypto.randomUUID(),
isSecret: true,
},
},
packageJsonChecksum: null,
yarnLockChecksum: null,
},
},
}),
expectToFail: false,
});
const findResponse = await makeMetadataAPIRequest({
query: gql`
query FindAppForEncryptionTestSetup($universalIdentifier: UUID!) {
findOneApplication(universalIdentifier: $universalIdentifier) {
id
}
}
`,
variables: { universalIdentifier: applicationUniversalIdentifier },
});
if (!isDefined(findResponse.body?.data?.findOneApplication?.id)) {
throw new Error(
`findOneApplication after sync did not return an id: ${JSON.stringify(
findResponse.body,
)}`,
);
}
applicationId = findResponse.body.data.findOneApplication.id;
}, 120000);
afterAll(async () => {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier,
});
});
it('encrypts the value on the API write path, persists a v2 envelope in Postgres, and returns the masked decrypted value via the API read path', async () => {
const plaintext = 'v2-encrypted-application-variable-secret-value-here';
const updateResponse = await makeMetadataAPIRequest({
query: gql`
mutation UpdateAppVariableForEncryptionTest(
$key: String!
$value: String!
$applicationId: UUID!
) {
updateOneApplicationVariable(
key: $key
value: $value
applicationId: $applicationId
)
}
`,
variables: {
key: V2_VARIABLE_KEY,
value: plaintext,
applicationId,
},
});
expect(updateResponse.body.errors).toBeUndefined();
expect(updateResponse.body.data.updateOneApplicationVariable).toBe(true);
const [dbRow] = await dataSource.query(
`SELECT "value"
FROM "core"."applicationVariable"
WHERE "applicationId" = $1 AND "key" = $2`,
[applicationId, V2_VARIABLE_KEY],
);
expect(dbRow.value).not.toContain(plaintext);
expect(dbRow.value.startsWith(SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX)).toBe(
true,
);
expect(dbRow.value).toMatch(V2_ENVELOPE_REGEX);
const findResponse = await makeMetadataAPIRequest({
query: gql`
query FindAppVariablesForEncryptionTest($id: UUID!) {
findOneApplication(id: $id) {
applicationVariables {
key
value
isSecret
}
}
}
`,
variables: { id: applicationId },
});
expect(findResponse.body.errors).toBeUndefined();
const variable =
findResponse.body.data.findOneApplication.applicationVariables.find(
(v: { key: string }) => v.key === V2_VARIABLE_KEY,
);
expect(variable).toBeDefined();
expect(variable.isSecret).toBe(true);
expect(variable.value).toBe(buildExpectedMask(plaintext));
});
it('encrypts a non-secret variable on write, persists a v2 envelope, and returns the full decrypted plaintext (not masked) on read', async () => {
const plaintext = 'https://public-url.example.com/webhook';
const updateResponse = await makeMetadataAPIRequest({
query: gql`
mutation UpdateNonSecretAppVarForEncryptionTest(
$key: String!
$value: String!
$applicationId: UUID!
) {
updateOneApplicationVariable(
key: $key
value: $value
applicationId: $applicationId
)
}
`,
variables: {
key: V2_NON_SECRET_VARIABLE_KEY,
value: plaintext,
applicationId,
},
});
expect(updateResponse.body.errors).toBeUndefined();
expect(updateResponse.body.data.updateOneApplicationVariable).toBe(true);
const [dbRow] = await dataSource.query(
`SELECT "value"
FROM "core"."applicationVariable"
WHERE "applicationId" = $1 AND "key" = $2`,
[applicationId, V2_NON_SECRET_VARIABLE_KEY],
);
expect(dbRow.value).not.toContain(plaintext);
expect(dbRow.value.startsWith(SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX)).toBe(
true,
);
expect(dbRow.value).toMatch(V2_ENVELOPE_REGEX);
const findResponse = await makeMetadataAPIRequest({
query: gql`
query FindNonSecretAppVarsForEncryptionTest($id: UUID!) {
findOneApplication(id: $id) {
applicationVariables {
key
value
isSecret
}
}
}
`,
variables: { id: applicationId },
});
expect(findResponse.body.errors).toBeUndefined();
const variable =
findResponse.body.data.findOneApplication.applicationVariables.find(
(v: { key: string }) => v.key === V2_NON_SECRET_VARIABLE_KEY,
);
expect(variable).toBeDefined();
expect(variable.isSecret).toBe(false);
expect(variable.value).toBe(plaintext);
});
describe('legacy CTR values are rejected at runtime', () => {
beforeAll(async () => {
await dataSource.query(
`ALTER TABLE core."applicationVariable"
DROP CONSTRAINT IF EXISTS "${CONSTRAINT_NAME}"`,
);
});
afterAll(async () => {
await dataSource.query(
`DELETE FROM core."applicationVariable"
WHERE "applicationId" = $1 AND "key" = $2`,
[applicationId, LEGACY_VARIABLE_KEY],
);
await dataSource.query(
`ALTER TABLE core."applicationVariable"
ADD CONSTRAINT "${CONSTRAINT_NAME}" CHECK (${CONSTRAINT_EXPR})`,
);
});
it('rejects a legacy CTR-encrypted value through the live API read path', async () => {
const plaintext = 'legacy-ctr-application-variable-secret-value-here';
await dataSource.query(
`UPDATE core."applicationVariable"
SET "value" = $1
WHERE "applicationId" = $2 AND "key" = $3`,
[
secretEncryption.encrypt(plaintext),
applicationId,
LEGACY_VARIABLE_KEY,
],
);
const findResponse = await makeMetadataAPIRequest({
query: gql`
query FindLegacyCtrAppVariablesForEncryptionTest($id: UUID!) {
findOneApplication(id: $id) {
applicationVariables {
key
value
isSecret
}
}
}
`,
variables: { id: applicationId },
});
expect(findResponse.body.errors).toBeDefined();
expect(findResponse.body.errors[0].message).toContain('enc:v2 envelope');
});
});
});

View File

@@ -0,0 +1,181 @@
import { ACCOUNT_TYPES, type AccountType } from 'twenty-shared/constants';
import { type DataSource } from 'typeorm';
import { deleteConnectedAccount } from 'test/integration/metadata/suites/connected-account/utils/delete-connected-account.util';
import { saveImapSmtpCaldavAccount } from 'test/integration/metadata/suites/connected-account/utils/save-imap-smtp-caldav-account.util';
import { runSecretEncryptionRotationCommand } from 'test/integration/secret-encryption/utils/run-secret-encryption-rotation-command.util';
import { buildSecretEncryptionServiceFromEnv } from 'test/integration/upgrade/utils/build-secret-encryption-service.util';
import { EmailConnectionSecurity } from 'src/engine/core-modules/imap-smtp-caldav-connection/enums/email-connection-security.enum';
import { type EncryptedImapSmtpCaldavParams } from 'src/engine/core-modules/imap-smtp-caldav-connection/types/imap-smtp-caldav-connection.type';
import { type SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service';
const V2_ENVELOPE_REGEX = /^enc:v2:[0-9a-f]{8}:[A-Za-z0-9+/=]+$/;
const HANDLE = 'rotate-connection-parameters@example.com';
const IMAP_PASSWORD = 'rotation-test-imap-password';
const SMTP_PASSWORD = 'rotation-test-smtp-password';
const CALDAV_PASSWORD = 'rotation-test-caldav-password';
type ConnectionParametersRow = {
workspaceId: string;
connectionParameters: EncryptedImapSmtpCaldavParams;
};
const readConnectionParameters = async (
dataSource: DataSource,
connectedAccountId: string,
): Promise<ConnectionParametersRow> => {
const [row] = (await dataSource.query(
`SELECT "workspaceId", "connectionParameters"
FROM "core"."connectedAccount"
WHERE id = $1`,
[connectedAccountId],
)) as ConnectionParametersRow[];
expect(row).toBeDefined();
expect(row.connectionParameters).toBeDefined();
return row;
};
const expectAllPasswordsDecryptTo = ({
row,
secretEncryption,
expectedPlaintextByProtocol,
}: {
row: ConnectionParametersRow;
secretEncryption: SecretEncryptionService;
expectedPlaintextByProtocol: Record<AccountType, string>;
}): void => {
for (const protocol of ACCOUNT_TYPES) {
const params = row.connectionParameters[protocol];
expect(params).toBeDefined();
expect(params?.password).toMatch(V2_ENVELOPE_REGEX);
const decrypted = secretEncryption.decryptVersionedOrThrow(
params!.password,
{
workspaceId: row.workspaceId,
},
);
expect(decrypted).toBe(expectedPlaintextByProtocol[protocol]);
}
};
describe('secret-encryption:rotate command — connection-parameters site (integration)', () => {
let dataSource: DataSource;
let secretEncryption: SecretEncryptionService;
let connectedAccountId: string;
beforeAll(async () => {
dataSource = global.testDataSource;
secretEncryption = buildSecretEncryptionServiceFromEnv();
const { data } = await saveImapSmtpCaldavAccount({
expectToFail: false,
input: {
handle: HANDLE,
connectionParameters: {
IMAP: {
host: 'imap.fastmail.com',
port: 993,
username: 'rotation@example.com',
password: IMAP_PASSWORD,
connectionSecurity: EmailConnectionSecurity.SSL_TLS,
},
SMTP: {
host: 'smtp.fastmail.com',
port: 465,
username: 'rotation@example.com',
password: SMTP_PASSWORD,
connectionSecurity: EmailConnectionSecurity.SSL_TLS,
},
CALDAV: {
host: 'caldav.fastmail.com',
port: 443,
username: 'rotation@example.com',
password: CALDAV_PASSWORD,
connectionSecurity: EmailConnectionSecurity.SSL_TLS,
},
},
},
});
connectedAccountId = data.connectedAccountId as string;
}, 120000);
afterAll(async () => {
if (connectedAccountId !== undefined) {
await deleteConnectedAccount({
id: connectedAccountId,
expectToFail: false,
});
}
});
it('keeps every protocol password decryptable after running the rotation', async () => {
const beforeRotation = await readConnectionParameters(
dataSource,
connectedAccountId,
);
expectAllPasswordsDecryptTo({
row: beforeRotation,
secretEncryption,
expectedPlaintextByProtocol: {
IMAP: IMAP_PASSWORD,
SMTP: SMTP_PASSWORD,
CALDAV: CALDAV_PASSWORD,
},
});
await runSecretEncryptionRotationCommand();
const afterRotation = await readConnectionParameters(
dataSource,
connectedAccountId,
);
expectAllPasswordsDecryptTo({
row: afterRotation,
secretEncryption,
expectedPlaintextByProtocol: {
IMAP: IMAP_PASSWORD,
SMTP: SMTP_PASSWORD,
CALDAV: CALDAV_PASSWORD,
},
});
expect(afterRotation.connectionParameters.IMAP?.host).toBe(
'imap.fastmail.com',
);
expect(afterRotation.connectionParameters.SMTP?.port).toBe(465);
expect(afterRotation.connectionParameters.CALDAV?.username).toBe(
'rotation@example.com',
);
}, 90000);
it('is idempotent when targeting only the connection-parameters site', async () => {
await runSecretEncryptionRotationCommand({
site: 'connected-account-connection-parameters',
});
await runSecretEncryptionRotationCommand({
site: 'connected-account-connection-parameters',
});
const row = await readConnectionParameters(dataSource, connectedAccountId);
expectAllPasswordsDecryptTo({
row,
secretEncryption,
expectedPlaintextByProtocol: {
IMAP: IMAP_PASSWORD,
SMTP: SMTP_PASSWORD,
CALDAV: CALDAV_PASSWORD,
},
});
}, 120000);
});

View File

@@ -0,0 +1,120 @@
import crypto from 'crypto';
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 {
findOneApplicationIdByUniversalIdentifier,
findOneApplicationVariables,
} from 'test/integration/secret-encryption/utils/find-one-application.util';
import { runSecretEncryptionRotationCommand } from 'test/integration/secret-encryption/utils/run-secret-encryption-rotation-command.util';
import { updateOneApplicationVariable } from 'test/integration/secret-encryption/utils/update-one-application-variable.util';
import { SECRET_APPLICATION_VARIABLE_MASK } from 'src/engine/core-modules/application/application-variable/constants/secret-application-variable-mask.constant';
const ROTATION_VARIABLE_KEY = 'TEST_ROTATION_SECRET';
const buildExpectedMask = (plaintext: string): string => {
const visibleCharsCount = Math.min(5, Math.floor(plaintext.length / 10));
return `${plaintext.slice(0, visibleCharsCount)}${SECRET_APPLICATION_VARIABLE_MASK}`;
};
describe('secret-encryption:rotate command (integration)', () => {
let applicationUniversalIdentifier: string;
let applicationId: string;
const plaintext = 'secret-value-that-must-survive-key-rotation';
beforeAll(async () => {
applicationUniversalIdentifier = crypto.randomUUID();
const roleUniversalIdentifier = crypto.randomUUID();
const roleLabel = `Rotation Test Role ${crypto.randomUUID()}`;
await setupApplicationForSync({
applicationUniversalIdentifier,
name: 'Rotation Test Application',
description: 'Verifies secret-encryption:rotate keeps secrets readable',
sourcePath: 'test-secret-encryption-rotation',
});
await syncApplication({
manifest: buildBaseManifest({
appId: applicationUniversalIdentifier,
roleId: roleUniversalIdentifier,
overrides: {
application: {
universalIdentifier: applicationUniversalIdentifier,
defaultRoleUniversalIdentifier: roleUniversalIdentifier,
displayName: 'Rotation Test Application',
description:
'Verifies secret-encryption:rotate keeps secrets readable',
applicationVariables: {
[ROTATION_VARIABLE_KEY]: {
universalIdentifier: crypto.randomUUID(),
isSecret: true,
},
},
packageJsonChecksum: null,
yarnLockChecksum: null,
},
roles: [
{
universalIdentifier: roleUniversalIdentifier,
label: roleLabel,
description: 'A role for the secret encryption rotation test',
},
],
},
}),
expectToFail: false,
});
applicationId = await findOneApplicationIdByUniversalIdentifier({
universalIdentifier: applicationUniversalIdentifier,
});
await updateOneApplicationVariable({
key: ROTATION_VARIABLE_KEY,
value: plaintext,
applicationId,
});
}, 120000);
afterAll(async () => {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier,
});
});
it('keeps the secret applicationVariable decryptable via GraphQL after running the rotation', async () => {
await runSecretEncryptionRotationCommand();
const variables = await findOneApplicationVariables({
id: applicationId,
});
const variable = variables.find(
(applicationVariable) =>
applicationVariable.key === ROTATION_VARIABLE_KEY,
);
expect(variable).toBeDefined();
expect(variable?.isSecret).toBe(true);
expect(variable?.value).toBe(buildExpectedMask(plaintext));
}, 60000);
it('is idempotent: running rotation twice does not corrupt secrets', async () => {
await runSecretEncryptionRotationCommand();
await runSecretEncryptionRotationCommand();
const variables = await findOneApplicationVariables({
id: applicationId,
});
const variable = variables.find(
(applicationVariable) =>
applicationVariable.key === ROTATION_VARIABLE_KEY,
);
expect(variable?.value).toBe(buildExpectedMask(plaintext));
}, 90000);
});

View File

@@ -0,0 +1,66 @@
import gql from 'graphql-tag';
import { isDefined } from 'twenty-shared/utils';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
export type ApplicationVariableSummary = {
key: string;
value: string;
isSecret: boolean;
};
export const findOneApplicationIdByUniversalIdentifier = async ({
universalIdentifier,
}: {
universalIdentifier: string;
}): Promise<string> => {
const response = await makeMetadataAPIRequest({
query: gql`
query FindOneApplicationIdByUniversalIdentifier(
$universalIdentifier: UUID!
) {
findOneApplication(universalIdentifier: $universalIdentifier) {
id
}
}
`,
variables: { universalIdentifier },
});
const id: string | undefined = response.body?.data?.findOneApplication?.id;
if (!isDefined(id)) {
throw new Error(
`findOneApplication did not return an id for universalIdentifier=${universalIdentifier}: ${JSON.stringify(
response.body,
)}`,
);
}
return id;
};
export const findOneApplicationVariables = async ({
id,
}: {
id: string;
}): Promise<ApplicationVariableSummary[]> => {
const response = await makeMetadataAPIRequest({
query: gql`
query FindOneApplicationVariables($id: UUID!) {
findOneApplication(id: $id) {
applicationVariables {
key
value
isSecret
}
}
}
`,
variables: { id },
});
expect(response.body.errors).toBeUndefined();
return response.body.data.findOneApplication.applicationVariables;
};

View File

@@ -0,0 +1,74 @@
import { spawn } from 'child_process';
import path from 'path';
const TWENTY_SERVER_ROOT = path.resolve(__dirname, '..', '..', '..', '..');
const COMMAND_JS_PATH = path.join(
TWENTY_SERVER_ROOT,
'dist',
'command',
'command.js',
);
type RotateArguments = {
site?: string;
batchSize?: number;
dryRun?: boolean;
};
const buildArgs = ({ site, batchSize, dryRun }: RotateArguments): string[] => {
const args = ['secret-encryption:rotate'];
if (site !== undefined) {
args.push('--site', site);
}
if (batchSize !== undefined) {
args.push('--batch-size', String(batchSize));
}
if (dryRun === true) {
args.push('--dry-run');
}
return args;
};
export const runSecretEncryptionRotationCommand = async (
args: RotateArguments = {},
): Promise<void> => {
await new Promise<void>((resolve, reject) => {
const child = spawn('node', [COMMAND_JS_PATH, ...buildArgs(args)], {
cwd: TWENTY_SERVER_ROOT,
env: { ...process.env, NODE_ENV: 'test' },
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
child.stdout?.on('data', (chunk: Buffer) => {
stdout += chunk.toString();
});
child.stderr?.on('data', (chunk: Buffer) => {
stderr += chunk.toString();
});
child.on('error', (error) => {
reject(
new Error(
`Failed to spawn secret-encryption:rotate: ${error.message}\nstdout:\n${stdout}\nstderr:\n${stderr}`,
),
);
});
child.on('close', (code) => {
if (code === 0) {
resolve();
return;
}
reject(
new Error(
`secret-encryption:rotate exited with code ${code}\nstdout:\n${stdout}\nstderr:\n${stderr}`,
),
);
});
});
};

View File

@@ -0,0 +1,32 @@
import gql from 'graphql-tag';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
export const updateOneApplicationVariable = async ({
key,
value,
applicationId,
}: {
key: string;
value: string;
applicationId: string;
}): Promise<void> => {
const response = await makeMetadataAPIRequest({
query: gql`
mutation UpdateOneApplicationVariable(
$key: String!
$value: String!
$applicationId: UUID!
) {
updateOneApplicationVariable(
key: $key
value: $value
applicationId: $applicationId
)
}
`,
variables: { key, value, applicationId },
});
expect(response.body.errors).toBeUndefined();
};