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,189 @@
import { config } from 'dotenv';
import { isDefined } from 'twenty-shared/utils';
import { DataSource } from 'typeorm';
import { BackfillConnectionSecuritySlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-15/2-15-instance-command-slow-1781461753981-backfill-connection-security';
jest.useRealTimers();
config({
path: process.env.NODE_ENV === 'test' ? '.env.test' : '.env',
override: true,
});
const TEST_ROW_HANDLE_PREFIX = 'backfill-connection-security-test-';
describe('BackfillConnectionSecuritySlowInstanceCommand (integration)', () => {
let dataSource: DataSource;
let command: BackfillConnectionSecuritySlowInstanceCommand;
let workspaceId: string;
let userWorkspaceId: string;
const seededRowIds: string[] = [];
const seedAccount = async (
connectionParameters: Record<string, unknown>,
): Promise<string> => {
const [row] = await dataSource.query(
`INSERT INTO "core"."connectedAccount"
("handle", "provider", "userWorkspaceId", "workspaceId", "visibility", "connectionParameters")
VALUES ($1, 'imap_smtp_caldav', $2, $3, 'user', $4)
RETURNING id`,
[
`${TEST_ROW_HANDLE_PREFIX}${seededRowIds.length}`,
userWorkspaceId,
workspaceId,
JSON.stringify(connectionParameters),
],
);
seededRowIds.push(row.id as string);
return row.id as string;
};
const getConnectionParameters = async (
id: string,
): Promise<Record<string, Record<string, unknown>>> => {
const [row] = await dataSource.query(
`SELECT "connectionParameters" FROM "core"."connectedAccount" WHERE id = $1`,
[id],
);
return row.connectionParameters;
};
beforeAll(async () => {
dataSource = new DataSource({
type: 'postgres',
url: process.env.PG_DATABASE_URL,
schema: 'core',
entities: [],
synchronize: false,
});
await dataSource.initialize();
command = new BackfillConnectionSecuritySlowInstanceCommand();
const [seedWorkspaceRow] = await dataSource.query(
`SELECT uw.id AS "userWorkspaceId", uw."workspaceId" AS "workspaceId"
FROM "core"."userWorkspace" uw
LIMIT 1`,
);
if (!isDefined(seedWorkspaceRow)) {
throw new Error(
'No seeded userWorkspace row found; run database:reset before the integration suite.',
);
}
userWorkspaceId = seedWorkspaceRow.userWorkspaceId;
workspaceId = seedWorkspaceRow.workspaceId;
}, 30000);
afterEach(async () => {
if (seededRowIds.length > 0) {
await dataSource.query(
`DELETE FROM "core"."connectedAccount" WHERE id = ANY($1::uuid[])`,
[seededRowIds],
);
seededRowIds.length = 0;
}
});
afterAll(async () => {
await dataSource?.destroy();
});
it('maps every legacy protocol to the mode matching its prior on-wire behavior and drops the secure key', async () => {
const id = await seedAccount({
IMAP: {
host: 'imap.example.com',
port: 993,
secure: true,
password: 'enc:v2:x',
},
SMTP: {
host: 'smtp.example.com',
port: 587,
secure: true,
password: 'enc:v2:x',
},
CALDAV: {
host: 'https://dav.example.com',
port: 443,
secure: true,
password: 'enc:v2:x',
},
});
await command.runDataMigration(dataSource);
const params = await getConnectionParameters(id);
expect(params.IMAP.connectionSecurity).toBe('SSL_TLS');
expect(params.SMTP.connectionSecurity).toBe('STARTTLS');
expect(params.CALDAV.connectionSecurity).toBe('SSL_TLS');
expect(params.IMAP.secure).toBeUndefined();
expect(params.SMTP.secure).toBeUndefined();
expect(params.CALDAV.secure).toBeUndefined();
});
it('maps implicit-TLS SMTP (port 465) and opportunistic IMAP (secure:false) correctly', async () => {
const implicitSmtpId = await seedAccount({
SMTP: {
host: 'smtp.example.com',
port: 465,
secure: true,
password: 'enc:v2:x',
},
});
const plaintextImapId = await seedAccount({
IMAP: {
host: 'imap.example.com',
port: 143,
secure: false,
password: 'enc:v2:x',
},
});
await command.runDataMigration(dataSource);
expect(
(await getConnectionParameters(implicitSmtpId)).SMTP.connectionSecurity,
).toBe('SSL_TLS');
expect(
(await getConnectionParameters(plaintextImapId)).IMAP.connectionSecurity,
).toBe('STARTTLS');
});
it('is idempotent and leaves already-migrated accounts untouched', async () => {
const legacyId = await seedAccount({
SMTP: {
host: 'smtp.example.com',
port: 587,
secure: true,
password: 'enc:v2:x',
},
});
const alreadyMigratedId = await seedAccount({
SMTP: {
host: 'smtp.example.com',
port: 465,
connectionSecurity: 'STARTTLS',
password: 'enc:v2:x',
},
});
await command.runDataMigration(dataSource);
await command.runDataMigration(dataSource);
expect(
(await getConnectionParameters(legacyId)).SMTP.connectionSecurity,
).toBe('STARTTLS');
// A pre-set connectionSecurity is never overwritten, even when it disagrees with the port.
expect(
(await getConnectionParameters(alreadyMigratedId)).SMTP
.connectionSecurity,
).toBe('STARTTLS');
});
});

View File

@@ -0,0 +1,222 @@
import crypto from 'crypto';
import { config } from 'dotenv';
import { isDefined } from 'twenty-shared/utils';
import { DataSource } from 'typeorm';
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 { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service';
import { EncryptEmptyApplicationVariablesSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-31/2-31-instance-command-slow-1786533438000-encrypt-empty-application-variables';
jest.useRealTimers();
config({
path: process.env.NODE_ENV === 'test' ? '.env.test' : '.env',
override: true,
});
const TEST_REGISTRATION_NAME_PREFIX = 'encrypt-empty-app-var-test-';
const CONSTRAINTS = [
{
tableName: 'applicationVariable',
constraintName: 'CHK_applicationVariable_value_encrypted',
constraintExpr: `"value" LIKE '${SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX}%'`,
},
{
tableName: 'applicationRegistrationVariable',
constraintName:
'CHK_applicationRegistrationVariable_encryptedValue_encrypted',
constraintExpr: `"encryptedValue" LIKE '${SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX}%'`,
},
] as const;
const dropCheckConstraints = async (dataSource: DataSource): Promise<void> => {
for (const { tableName, constraintName } of CONSTRAINTS) {
await dataSource.query(
`ALTER TABLE "core"."${tableName}" DROP CONSTRAINT IF EXISTS "${constraintName}"`,
);
}
};
const restoreCheckConstraints = async (
dataSource: DataSource,
): Promise<void> => {
await dropCheckConstraints(dataSource);
for (const { tableName, constraintName, constraintExpr } of CONSTRAINTS) {
await dataSource.query(
`ALTER TABLE "core"."${tableName}" ADD CONSTRAINT "${constraintName}" CHECK (${constraintExpr})`,
);
}
};
describe('2-31 slow instance command 1786533438000 - EncryptEmptyApplicationVariablesSlowInstanceCommand (integration)', () => {
let dataSource: DataSource;
let secretEncryptionService: SecretEncryptionService;
let command: EncryptEmptyApplicationVariablesSlowInstanceCommand;
let workspaceId: string;
let applicationId: string;
let registrationId: string;
const seededApplicationVariableIds: string[] = [];
const seededRegistrationVariableIds: string[] = [];
const seedApplicationVariable = async (value: string): Promise<string> => {
await dropCheckConstraints(dataSource);
const id = crypto.randomUUID();
await dataSource.query(
`INSERT INTO "core"."applicationVariable"
(id, "universalIdentifier", "applicationId", "workspaceId",
"key", "value", "isSecret")
VALUES ($1, $2, $3, $4, $5, $6, false)`,
[id, crypto.randomUUID(), applicationId, workspaceId, `KEY_${id}`, value],
);
seededApplicationVariableIds.push(id);
return id;
};
const seedRegistrationVariable = async (
encryptedValue: string,
): Promise<string> => {
await dropCheckConstraints(dataSource);
const id = crypto.randomUUID();
await dataSource.query(
`INSERT INTO "core"."applicationRegistrationVariable"
(id, "applicationRegistrationId", "key", "encryptedValue",
"isSecret", "isRequired")
VALUES ($1, $2, $3, $4, true, false)`,
[id, registrationId, `KEY_${id}`, encryptedValue],
);
seededRegistrationVariableIds.push(id);
return id;
};
beforeAll(async () => {
dataSource = new DataSource({
type: 'postgres',
url: process.env.PG_DATABASE_URL,
schema: 'core',
entities: [
'src/engine/core-modules/**/*.entity.ts',
'src/engine/metadata-modules/**/*.entity.ts',
],
synchronize: false,
});
await dataSource.initialize();
secretEncryptionService = buildSecretEncryptionServiceFromEnv();
command = new EncryptEmptyApplicationVariablesSlowInstanceCommand(
secretEncryptionService,
);
const [seedWorkspace] = await dataSource.query(
`SELECT id, "workspaceCustomApplicationId"
FROM "core"."workspace"
WHERE "workspaceCustomApplicationId" IS NOT NULL
LIMIT 1`,
);
if (!isDefined(seedWorkspace)) {
throw new Error(
'No seeded workspace with a custom application found; run database:reset before the integration suite.',
);
}
workspaceId = seedWorkspace.id as string;
applicationId = seedWorkspace.workspaceCustomApplicationId as string;
registrationId = crypto.randomUUID();
await dataSource.query(
`INSERT INTO "core"."applicationRegistration"
(id, "universalIdentifier", name, "oAuthClientId",
"oAuthRedirectUris", "oAuthScopes", "workspaceId", "sourceType")
VALUES ($1, $2, $3, $4, $5, $6, $7, 'local')`,
[
registrationId,
crypto.randomUUID(),
`${TEST_REGISTRATION_NAME_PREFIX}${registrationId}`,
crypto.randomUUID(),
['http://localhost:3000/callback'],
['read'],
workspaceId,
],
);
}, 30000);
afterEach(async () => {
if (seededApplicationVariableIds.length > 0) {
await dataSource.query(
`DELETE FROM "core"."applicationVariable" WHERE id = ANY($1::uuid[])`,
[seededApplicationVariableIds],
);
seededApplicationVariableIds.length = 0;
}
if (seededRegistrationVariableIds.length > 0) {
await dataSource.query(
`DELETE FROM "core"."applicationRegistrationVariable" WHERE id = ANY($1::uuid[])`,
[seededRegistrationVariableIds],
);
seededRegistrationVariableIds.length = 0;
}
await restoreCheckConstraints(dataSource);
});
afterAll(async () => {
await dataSource.query(
`DELETE FROM "core"."applicationRegistration" WHERE id = $1`,
[registrationId],
);
await dataSource?.destroy();
});
it('encrypts empty values in both tables with their own encryption scope', async () => {
const applicationVariableId = await seedApplicationVariable('');
const registrationVariableId = await seedRegistrationVariable('');
await command.runDataMigration(dataSource);
const [applicationVariableRow] = await dataSource.query(
`SELECT "value" FROM "core"."applicationVariable" WHERE id = $1`,
[applicationVariableId],
);
expect(
applicationVariableRow.value.startsWith(
SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX,
),
).toBe(true);
expect(
secretEncryptionService.decryptVersionedOrThrow(
applicationVariableRow.value,
{ workspaceId },
),
).toBe('');
const [registrationVariableRow] = await dataSource.query(
`SELECT "encryptedValue" FROM "core"."applicationRegistrationVariable" WHERE id = $1`,
[registrationVariableId],
);
expect(
registrationVariableRow.encryptedValue.startsWith(
SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX,
),
).toBe(true);
expect(
secretEncryptionService.decryptVersionedOrThrow(
registrationVariableRow.encryptedValue,
),
).toBe('');
});
});

View File

@@ -0,0 +1,493 @@
import { isDefined } from 'twenty-shared/utils';
import { DataSource, type QueryRunner } from 'typeorm';
import { v4 } 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';
jest.useRealTimers();
type SeededTab = {
tabId: string;
widgetIds: string[];
};
type TabAndWidgetState = {
layoutMode: string;
overrides: Record<string, unknown> | null;
position: Record<string, unknown> | null;
};
describe('MigrateCanvasTabsToVerticalListSlowInstanceCommand (integration)', () => {
let dataSource: DataSource;
let queryRunner: QueryRunner;
let command: MigrateCanvasTabsToVerticalListSlowInstanceCommand;
let workspaceId: string;
let applicationId: string;
let pageLayoutId: string;
const workspaceCacheService = { flush: jest.fn() };
const seedTab = async ({
layoutMode,
widgetIsActiveValues,
widgetOverrides = null,
widgetPosition = { layoutMode },
}: {
layoutMode: 'CANVAS' | 'VERTICAL_LIST';
widgetIsActiveValues: boolean[];
widgetOverrides?: Record<string, unknown> | null;
widgetPosition?: Record<string, unknown> | null;
}): Promise<SeededTab> => {
const tabId = v4();
await dataSource.query(
`INSERT INTO "core"."pageLayoutTab" (
"id",
"workspaceId",
"applicationId",
"universalIdentifier",
"pageLayoutId",
"title",
"position",
"layoutMode",
"isActive"
) VALUES ($1, $2, $3, $4, $5, 'Migration test tab', 0, $6, true)`,
[tabId, workspaceId, applicationId, v4(), pageLayoutId, layoutMode],
);
const widgetIds: string[] = [];
for (const [index, isActive] of widgetIsActiveValues.entries()) {
const widgetId = v4();
await dataSource.query(
`INSERT INTO "core"."pageLayoutWidget" (
"id",
"workspaceId",
"applicationId",
"universalIdentifier",
"pageLayoutTabId",
"title",
"type",
"gridPosition",
"position",
"overrides",
"configuration",
"isActive"
) VALUES (
$1,
$2,
$3,
$4,
$5,
'Migration test widget',
'VIEW',
$6,
$7,
$8,
$9,
$10
)`,
[
widgetId,
workspaceId,
applicationId,
v4(),
tabId,
JSON.stringify({ row: index, column: 0, rowSpan: 1, columnSpan: 12 }),
JSON.stringify(widgetPosition),
JSON.stringify(widgetOverrides),
JSON.stringify({ configurationType: 'VIEW' }),
isActive,
],
);
widgetIds.push(widgetId);
}
return { tabId, widgetIds };
};
const readTabAndWidgetState = async ({
tabId,
widgetId,
}: {
tabId: string;
widgetId: string;
}): Promise<TabAndWidgetState> => {
const [state] = (await dataSource.query(
`SELECT tab."layoutMode", widget."position", widget."overrides"
FROM "core"."pageLayoutTab" tab
JOIN "core"."pageLayoutWidget" widget
ON widget."pageLayoutTabId" = tab."id"
WHERE tab."id" = $1 AND widget."id" = $2`,
[tabId, widgetId],
)) as TabAndWidgetState[];
if (!isDefined(state)) {
throw new Error('Seeded page layout tab or widget was not found');
}
return state;
};
const readTabLayoutMode = async (tabId: string): Promise<string> => {
const [state] = (await dataSource.query(
`SELECT "layoutMode"
FROM "core"."pageLayoutTab"
WHERE "id" = $1`,
[tabId],
)) as { layoutMode: string }[];
if (!isDefined(state)) {
throw new Error('Seeded page layout tab was not found');
}
return state.layoutMode;
};
beforeAll(async () => {
dataSource = new DataSource({
type: 'postgres',
url: process.env.PG_DATABASE_URL,
schema: 'core',
entities: [],
synchronize: false,
});
await dataSource.initialize();
command = new MigrateCanvasTabsToVerticalListSlowInstanceCommand(
workspaceCacheService as unknown as WorkspaceCacheService,
);
const [source] = (await dataSource.query(
`SELECT "id" AS "workspaceId", "workspaceCustomApplicationId" AS "applicationId"
FROM "core"."workspace"
WHERE "workspaceCustomApplicationId" IS NOT NULL
ORDER BY "id"
LIMIT 1`,
)) as { workspaceId: string; applicationId: string }[];
if (!isDefined(source)) {
throw new Error(
'No seeded workspace found; run database:reset before the integration suite.',
);
}
workspaceId = source.workspaceId;
applicationId = source.applicationId;
}, 30000);
beforeEach(async () => {
workspaceCacheService.flush.mockReset().mockResolvedValue(undefined);
queryRunner = dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
// Roll back changes to seeded layouts along with the test fixtures.
jest
.spyOn(dataSource, 'query')
.mockImplementation((query, parameters) =>
queryRunner.query(query, parameters),
);
pageLayoutId = v4();
await dataSource.query(
`INSERT INTO "core"."pageLayout" (
"id",
"workspaceId",
"applicationId",
"universalIdentifier",
"name",
"type"
) VALUES ($1, $2, $3, $4, 'Canvas migration integration test', 'RECORD_PAGE')`,
[pageLayoutId, workspaceId, applicationId, v4()],
);
});
afterEach(async () => {
jest.restoreAllMocks();
await queryRunner.rollbackTransaction();
await queryRunner.release();
});
afterAll(async () => {
await dataSource?.destroy();
});
it.each([
{ widgetPosition: { layoutMode: 'GRID', row: 2, column: 1 } },
{ widgetPosition: { layoutMode: 'VERTICAL_LIST', index: 4 } },
{
widgetOverrides: { position: { layoutMode: 'GRID', row: 2, column: 1 } },
},
{
widgetOverrides: { position: { layoutMode: 'VERTICAL_LIST', index: 4 } },
},
])('preserves authored non-Canvas positions %j', async (widgetOptions) => {
const tab = await seedTab({
layoutMode: 'CANVAS',
widgetIsActiveValues: [true],
...widgetOptions,
});
const originalState = await readTabAndWidgetState({
tabId: tab.tabId,
widgetId: tab.widgetIds[0],
});
await command.runDataMigration(dataSource);
expect(
await readTabAndWidgetState({
tabId: tab.tabId,
widgetId: tab.widgetIds[0],
}),
).toEqual(originalState);
});
it.each([false, true])(
'preserves outgoing tab overrides with a position override: %s',
async (hasPositionOverride) => {
const destinationTab = await seedTab({
layoutMode: 'CANVAS',
widgetIsActiveValues: [true],
});
const sourceTab = await seedTab({
layoutMode: 'CANVAS',
widgetIsActiveValues: [true],
widgetOverrides: {
pageLayoutTabId: destinationTab.tabId,
...(hasPositionOverride
? { position: { layoutMode: 'CANVAS' } }
: {}),
},
});
const originalStates = await Promise.all(
[sourceTab, destinationTab].map((tab) =>
readTabAndWidgetState({
tabId: tab.tabId,
widgetId: tab.widgetIds[0],
}),
),
);
await command.runDataMigration(dataSource);
for (const [index, tab] of [sourceTab, destinationTab].entries()) {
expect(
await readTabAndWidgetState({
tabId: tab.tabId,
widgetId: tab.widgetIds[0],
}),
).toEqual(originalStates[index]);
}
},
);
it('retries cache invalidation after the rows have already been migrated', async () => {
const canvasTab = await seedTab({
layoutMode: 'CANVAS',
widgetIsActiveValues: [true],
});
const cacheError = new Error('Cache unavailable');
workspaceCacheService.flush.mockRejectedValueOnce(cacheError);
await expect(command.runDataMigration(dataSource)).rejects.toThrow(
cacheError,
);
expect(await readTabLayoutMode(canvasTab.tabId)).toBe('VERTICAL_LIST');
workspaceCacheService.flush.mockClear();
await command.runDataMigration(dataSource);
expect(workspaceCacheService.flush).toHaveBeenCalledWith(workspaceId, [
'flatPageLayoutTabMaps',
'flatPageLayoutWidgetMaps',
]);
expect(
await readTabAndWidgetState({
tabId: canvasTab.tabId,
widgetId: canvasTab.widgetIds[0],
}),
).toMatchObject({
layoutMode: 'VERTICAL_LIST',
position: {
layoutMode: 'VERTICAL_LIST',
index: 0,
heightBehavior: 'TAB_VIEWPORT',
},
});
});
it.each([
null,
{ position: null },
{ position: { layoutMode: 'CANVAS' }, title: 'Custom title' },
])(
'migrates a workspace Canvas tab and preserves unrelated overrides %j',
async (widgetOverrides) => {
const tab = await seedTab({
layoutMode: 'CANVAS',
widgetIsActiveValues: [true],
widgetPosition: null,
widgetOverrides,
});
const position = {
layoutMode: 'VERTICAL_LIST',
index: 0,
heightBehavior: 'TAB_VIEWPORT',
};
await command.runDataMigration(dataSource);
await command.runDataMigration(dataSource);
expect(
await readTabAndWidgetState({
tabId: tab.tabId,
widgetId: tab.widgetIds[0],
}),
).toEqual({
layoutMode: 'VERTICAL_LIST',
position,
overrides: isDefined(widgetOverrides)
? { ...widgetOverrides, position }
: null,
});
},
);
it.each(
[[], [false], [true, true], [true, false]].map((widgetIsActiveValues) => ({
widgetIsActiveValues,
})),
)(
'preserves Canvas tabs without exactly one active, undeleted widget: %j',
async ({ widgetIsActiveValues }) => {
const tab = await seedTab({ layoutMode: 'CANVAS', widgetIsActiveValues });
await command.runDataMigration(dataSource);
expect(await readTabLayoutMode(tab.tabId)).toBe('CANVAS');
},
);
it.each(['pageLayoutTab', 'pageLayoutWidget'] as const)(
'preserves manifest-owned %s and its Canvas position override',
async (entity) => {
const tab = await seedTab({
layoutMode: 'CANVAS',
widgetIsActiveValues: [true],
widgetOverrides: { position: { layoutMode: 'CANVAS' } },
});
await dataSource.query(
`UPDATE "core"."${entity}" SET "applicationId" = (
SELECT "applicationId" FROM "core"."pageLayout"
WHERE "workspaceId" = $1 AND "applicationId" <> $2 LIMIT 1
) WHERE "id" = $3`,
[
workspaceId,
applicationId,
entity === 'pageLayoutTab' ? tab.tabId : tab.widgetIds[0],
],
);
const originalState = await readTabAndWidgetState({
tabId: tab.tabId,
widgetId: tab.widgetIds[0],
});
await command.runDataMigration(dataSource);
expect(
await readTabAndWidgetState({
tabId: tab.tabId,
widgetId: tab.widgetIds[0],
}),
).toEqual(originalState);
},
);
it('preserves edits to an already migrated vertical-list position on retry', async () => {
const tab = await seedTab({
layoutMode: 'CANVAS',
widgetIsActiveValues: [true],
});
await command.runDataMigration(dataSource);
const editedPosition = {
layoutMode: 'VERTICAL_LIST',
index: 0,
heightBehavior: 'FIT_CONTENT',
};
await dataSource.query(
`UPDATE "core"."pageLayoutWidget" SET "position" = $1 WHERE "id" = $2`,
[editedPosition, tab.widgetIds[0]],
);
await command.runDataMigration(dataSource);
expect(
await readTabAndWidgetState({
tabId: tab.tabId,
widgetId: tab.widgetIds[0],
}),
).toMatchObject({ position: editedPosition });
});
it('ignores deleted widgets when counting active Canvas contents', async () => {
const tab = await seedTab({
layoutMode: 'CANVAS',
widgetIsActiveValues: [true, true],
});
await dataSource.query(
`UPDATE "core"."pageLayoutWidget" SET "deletedAt" = NOW() WHERE "id" = $1`,
[tab.widgetIds[1]],
);
await command.runDataMigration(dataSource);
expect(await readTabLayoutMode(tab.tabId)).toBe('VERTICAL_LIST');
expect(
await readTabAndWidgetState({
tabId: tab.tabId,
widgetId: tab.widgetIds[1],
}),
).toMatchObject({ position: { layoutMode: 'CANVAS' } });
});
it('preserves deleted Canvas tabs', async () => {
const tab = await seedTab({
layoutMode: 'CANVAS',
widgetIsActiveValues: [true],
});
await dataSource.query(
`UPDATE "core"."pageLayoutTab" SET "deletedAt" = NOW() WHERE "id" = $1`,
[tab.tabId],
);
await command.runDataMigration(dataSource);
expect(await readTabLayoutMode(tab.tabId)).toBe('CANVAS');
});
it('preserves tabs receiving an inactive widget through an override', async () => {
const destinationTab = await seedTab({
layoutMode: 'CANVAS',
widgetIsActiveValues: [true],
});
await seedTab({
layoutMode: 'CANVAS',
widgetIsActiveValues: [false],
widgetOverrides: { pageLayoutTabId: destinationTab.tabId },
});
await command.runDataMigration(dataSource);
expect(await readTabLayoutMode(destinationTab.tabId)).toBe('CANVAS');
});
});

View File

@@ -0,0 +1,198 @@
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { getAppProviderByClassName } from 'test/integration/utils/get-app-provider-by-class-name.util';
import { getCoreRepository } from 'test/integration/utils/get-core-repository.util';
import { STANDARD_OBJECT_FIELDS } from 'twenty-shared/metadata';
import { MetadataReadability } from 'twenty-shared/types';
import { In } from 'typeorm';
import { type MakeStandardChildObjectsInheritedCommand } from 'src/database/commands/upgrade-version-command/2-41/2-41-workspace-command-1789373200001-make-standard-child-objects-inherited.command';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import { WorkspaceOrmManager } from 'src/engine/twenty-orm/workspace-orm.manager';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
const authContext = buildSystemAuthContext(SEED_APPLE_WORKSPACE_ID);
const STANDARD_CHILD_OBJECTS_TO_MAKE_INHERITED = [
{
nameSingular: 'attachment',
readabilityParentFieldUniversalIdentifiers: [
STANDARD_OBJECT_FIELDS.attachment.targetNote.universalIdentifier,
],
},
{
nameSingular: 'timelineActivity',
readabilityParentFieldUniversalIdentifiers: [
STANDARD_OBJECT_FIELDS.timelineActivity.targetPerson.universalIdentifier,
],
},
{
nameSingular: 'noteTarget',
readabilityParentFieldUniversalIdentifiers: [
STANDARD_OBJECT_FIELDS.noteTarget.note.universalIdentifier,
],
},
{
nameSingular: 'taskTarget',
readabilityParentFieldUniversalIdentifiers: [
STANDARD_OBJECT_FIELDS.taskTarget.task.universalIdentifier,
],
},
{
nameSingular: 'messageThreadTarget',
readabilityParentFieldUniversalIdentifiers: [
STANDARD_OBJECT_FIELDS.messageThreadTarget.messageThread
.universalIdentifier,
],
},
{
nameSingular: 'calendarEventTarget',
readabilityParentFieldUniversalIdentifiers: [
STANDARD_OBJECT_FIELDS.calendarEventTarget.calendarEvent
.universalIdentifier,
],
},
];
const OBJECT_NAMES_SINGULAR = STANDARD_CHILD_OBJECTS_TO_MAKE_INHERITED.map(
({ nameSingular }) => nameSingular,
);
const RUN_ON_WORKSPACE_ARGS = {
workspaceId: SEED_APPLE_WORKSPACE_ID,
options: {},
index: 0,
total: 1,
};
type ReadabilityState = {
readability: MetadataReadability;
readabilityParentFieldUniversalIdentifiers: string[] | null;
};
const EXPECTED_INHERITED_STATE: Record<string, ReadabilityState> =
Object.fromEntries(
STANDARD_CHILD_OBJECTS_TO_MAKE_INHERITED.map(
({ nameSingular, readabilityParentFieldUniversalIdentifiers }) => [
nameSingular,
{
readability: MetadataReadability.INHERITED,
readabilityParentFieldUniversalIdentifiers: [
...readabilityParentFieldUniversalIdentifiers,
],
},
],
),
);
const EXPECTED_LEGACY_STATE: Record<string, ReadabilityState> =
Object.fromEntries(
OBJECT_NAMES_SINGULAR.map((nameSingular) => [
nameSingular,
{
readability: MetadataReadability.OPEN,
readabilityParentFieldUniversalIdentifiers: null,
},
]),
);
describe('2-41 workspace command 1789373200001 - MakeStandardChildObjectsInheritedCommand (integration)', () => {
let command: MakeStandardChildObjectsInheritedCommand;
let workspaceOrmManager: WorkspaceOrmManager;
let objectMetadataIds: string[];
const objectMetadataRepository = () =>
getCoreRepository<ObjectMetadataEntity>(ObjectMetadataEntity);
const runCommand = (options: { dryRun?: boolean } = {}) =>
workspaceOrmManager.executeInWorkspaceContext(
() => command.runOnWorkspace({ ...RUN_ON_WORKSPACE_ARGS, options }),
authContext,
);
const findReadabilityStates = async (): Promise<
Record<string, ReadabilityState>
> => {
const objectMetadataItems = await objectMetadataRepository().find({
where: {
workspaceId: SEED_APPLE_WORKSPACE_ID,
nameSingular: In(OBJECT_NAMES_SINGULAR),
},
});
return Object.fromEntries(
objectMetadataItems.map((objectMetadata) => [
objectMetadata.nameSingular,
{
readability: objectMetadata.readability,
readabilityParentFieldUniversalIdentifiers:
objectMetadata.readabilityParentFieldUniversalIdentifiers,
},
]),
);
};
const setLegacyState = async () => {
await objectMetadataRepository().update(
{ id: In(objectMetadataIds) },
{
readability: MetadataReadability.OPEN,
readabilityParentFieldUniversalIdentifiers: null,
},
);
for (const objectMetadataId of objectMetadataIds) {
const { errors } = await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: objectMetadataId,
updatePayload: { description: 'readability reset to legacy OPEN' },
},
});
expect(errors).toBeUndefined();
}
};
beforeAll(async () => {
command =
getAppProviderByClassName<MakeStandardChildObjectsInheritedCommand>(
'MakeStandardChildObjectsInheritedCommand',
);
workspaceOrmManager = getAppProviderByClassName<WorkspaceOrmManager>(
'WorkspaceOrmManager',
);
const objectMetadataItems = await objectMetadataRepository().find({
where: {
workspaceId: SEED_APPLE_WORKSPACE_ID,
nameSingular: In(OBJECT_NAMES_SINGULAR),
},
});
expect(objectMetadataItems).toHaveLength(OBJECT_NAMES_SINGULAR.length);
objectMetadataIds = objectMetadataItems.map(
(objectMetadata) => objectMetadata.id,
);
await setLegacyState();
});
afterAll(async () => {
await runCommand();
});
it('leaves the objects OPEN on a dry run', async () => {
await runCommand({ dryRun: true });
expect(await findReadabilityStates()).toEqual(EXPECTED_LEGACY_STATE);
});
it('makes the six child objects INHERITED with their parent fields and skips them on a second run', async () => {
await runCommand();
await runCommand();
expect(await findReadabilityStates()).toEqual(EXPECTED_INHERITED_STATE);
});
});

View File

@@ -0,0 +1,199 @@
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { getAppProviderByClassName } from 'test/integration/utils/get-app-provider-by-class-name.util';
import { getCoreRepository } from 'test/integration/utils/get-core-repository.util';
import { STANDARD_OBJECT_FIELDS } from 'twenty-shared/metadata';
import { MetadataReadability } from 'twenty-shared/types';
import { In } from 'typeorm';
import { type MakeNotesAndTasksInheritTheirTargetsCommand } from 'src/database/commands/upgrade-version-command/2-41/2-41-workspace-command-1789373200002-make-notes-and-tasks-inherit-their-targets.command';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import { WorkspaceOrmManager } from 'src/engine/twenty-orm/workspace-orm.manager';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
const authContext = buildSystemAuthContext(SEED_APPLE_WORKSPACE_ID);
// Listed here rather than imported from the command: a value import would load
// the command's module graph, which jest cannot resolve in this suite
const STANDARD_OBJECTS_TO_INHERIT_THROUGH_TARGETS = [
{
nameSingular: 'note',
readabilityParentFieldUniversalIdentifiers: [
STANDARD_OBJECT_FIELDS.note.noteTargets.universalIdentifier,
],
},
{
nameSingular: 'noteTarget',
readabilityParentFieldUniversalIdentifiers: [
STANDARD_OBJECT_FIELDS.noteTarget.targetPerson.universalIdentifier,
],
},
{
nameSingular: 'task',
readabilityParentFieldUniversalIdentifiers: [
STANDARD_OBJECT_FIELDS.task.taskTargets.universalIdentifier,
],
},
{
nameSingular: 'taskTarget',
readabilityParentFieldUniversalIdentifiers: [
STANDARD_OBJECT_FIELDS.taskTarget.targetPerson.universalIdentifier,
],
},
];
const OBJECT_NAMES_SINGULAR = STANDARD_OBJECTS_TO_INHERIT_THROUGH_TARGETS.map(
({ nameSingular }) => nameSingular,
);
const RUN_ON_WORKSPACE_ARGS = {
workspaceId: SEED_APPLE_WORKSPACE_ID,
options: {},
index: 0,
total: 1,
};
type ReadabilityState = {
readability: MetadataReadability;
readabilityParentFieldUniversalIdentifiers: string[] | null;
};
const EXPECTED_TARGET_STATE: Record<string, ReadabilityState> =
Object.fromEntries(
STANDARD_OBJECTS_TO_INHERIT_THROUGH_TARGETS.map(
({ nameSingular, readabilityParentFieldUniversalIdentifiers }) => [
nameSingular,
{
readability: MetadataReadability.INHERITED,
readabilityParentFieldUniversalIdentifiers: [
...readabilityParentFieldUniversalIdentifiers,
],
},
],
),
);
// The state the previous 2-41 command leaves: targets inherit from their note
// or task, notes and tasks are still OPEN
const LEGACY_STATE: Record<string, ReadabilityState> = {
note: {
readability: MetadataReadability.OPEN,
readabilityParentFieldUniversalIdentifiers: null,
},
noteTarget: {
readability: MetadataReadability.INHERITED,
readabilityParentFieldUniversalIdentifiers: [
STANDARD_OBJECT_FIELDS.noteTarget.note.universalIdentifier,
],
},
task: {
readability: MetadataReadability.OPEN,
readabilityParentFieldUniversalIdentifiers: null,
},
taskTarget: {
readability: MetadataReadability.INHERITED,
readabilityParentFieldUniversalIdentifiers: [
STANDARD_OBJECT_FIELDS.taskTarget.task.universalIdentifier,
],
},
};
describe('2-41 workspace command 1789373200002 - MakeNotesAndTasksInheritTheirTargetsCommand (integration)', () => {
let command: MakeNotesAndTasksInheritTheirTargetsCommand;
let workspaceOrmManager: WorkspaceOrmManager;
let objectMetadataIdByNameSingular: Record<string, string>;
const objectMetadataRepository = () =>
getCoreRepository<ObjectMetadataEntity>(ObjectMetadataEntity);
const runCommand = (options: { dryRun?: boolean } = {}) =>
workspaceOrmManager.executeInWorkspaceContext(
() => command.runOnWorkspace({ ...RUN_ON_WORKSPACE_ARGS, options }),
authContext,
);
const findReadabilityStates = async (): Promise<
Record<string, ReadabilityState>
> => {
const objectMetadataItems = await objectMetadataRepository().find({
where: {
workspaceId: SEED_APPLE_WORKSPACE_ID,
nameSingular: In(OBJECT_NAMES_SINGULAR),
},
});
return Object.fromEntries(
objectMetadataItems.map((objectMetadata) => [
objectMetadata.nameSingular,
{
readability: objectMetadata.readability,
readabilityParentFieldUniversalIdentifiers:
objectMetadata.readabilityParentFieldUniversalIdentifiers,
},
]),
);
};
const setLegacyState = async () => {
for (const [nameSingular, state] of Object.entries(LEGACY_STATE)) {
const objectMetadataId = objectMetadataIdByNameSingular[nameSingular];
await objectMetadataRepository().update(objectMetadataId, state);
const { errors } = await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: objectMetadataId,
updatePayload: { description: 'readability reset to legacy state' },
},
});
expect(errors).toBeUndefined();
}
};
beforeAll(async () => {
command =
getAppProviderByClassName<MakeNotesAndTasksInheritTheirTargetsCommand>(
'MakeNotesAndTasksInheritTheirTargetsCommand',
);
workspaceOrmManager = getAppProviderByClassName<WorkspaceOrmManager>(
'WorkspaceOrmManager',
);
const objectMetadataItems = await objectMetadataRepository().find({
where: {
workspaceId: SEED_APPLE_WORKSPACE_ID,
nameSingular: In(OBJECT_NAMES_SINGULAR),
},
});
expect(objectMetadataItems).toHaveLength(OBJECT_NAMES_SINGULAR.length);
objectMetadataIdByNameSingular = Object.fromEntries(
objectMetadataItems.map((objectMetadata) => [
objectMetadata.nameSingular,
objectMetadata.id,
]),
);
await setLegacyState();
});
afterAll(async () => {
await runCommand();
});
it('leaves the legacy state on a dry run', async () => {
await runCommand({ dryRun: true });
expect(await findReadabilityStates()).toEqual(LEGACY_STATE);
});
it('makes notes and tasks inherit through their targets and skips them on a second run', async () => {
await runCommand();
await runCommand();
expect(await findReadabilityStates()).toEqual(EXPECTED_TARGET_STATE);
});
});

View File

@@ -0,0 +1,340 @@
import { isNonEmptyString } from '@sniptt/guards';
import { config } from 'dotenv';
import { isDefined } from 'twenty-shared/utils';
import { DataSource } from 'typeorm';
import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type';
import { SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX } from 'src/engine/core-modules/secret-encryption/constants/secret-encryption.constant';
import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service';
import { type EnvironmentConfigDriver } from 'src/engine/core-modules/twenty-config/drivers/environment-config.driver';
import { ConnectedAccountTokenEncryptionService } from 'src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.service';
import { EncryptConnectedAccountTokensSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000004000-encrypt-connected-account-tokens';
jest.useRealTimers();
config({
path: process.env.NODE_ENV === 'test' ? '.env.test' : '.env',
override: true,
});
const TEST_ROW_HANDLE_PREFIX = 'encrypt-slow-cmd-test-';
const buildSecretEncryptionService = (): SecretEncryptionService => {
const appSecret = process.env.APP_SECRET;
if (!isNonEmptyString(appSecret)) {
throw new Error(
'APP_SECRET must be set in the integration test environment to run this suite.',
);
}
const driver = {
get: (key: string) => (key === 'APP_SECRET' ? appSecret : undefined),
} as unknown as EnvironmentConfigDriver;
return new SecretEncryptionService(driver);
};
const seedRow = async ({
dataSource,
workspaceId,
userWorkspaceId,
handle,
accessToken,
refreshToken,
}: {
dataSource: DataSource;
workspaceId: string;
userWorkspaceId: string;
handle: string;
accessToken: string | null;
refreshToken: string | null;
}): Promise<string> => {
// Insert directly with raw SQL to bypass the CHECK constraint while we
// seed the plaintext rows that the slow command is meant to upgrade.
await dataSource.query(
`ALTER TABLE "core"."connectedAccount"
DROP CONSTRAINT IF EXISTS "CHK_connectedAccount_accessToken_encrypted"`,
);
await dataSource.query(
`ALTER TABLE "core"."connectedAccount"
DROP CONSTRAINT IF EXISTS "CHK_connectedAccount_refreshToken_encrypted"`,
);
const result = await dataSource.query(
`INSERT INTO "core"."connectedAccount"
("handle", "provider", "accessToken", "refreshToken",
"userWorkspaceId", "workspaceId")
VALUES ($1, 'google', $2, $3, $4, $5)
RETURNING id`,
[handle, accessToken, refreshToken, userWorkspaceId, workspaceId],
);
return result[0].id as string;
};
const dropEncryptionCheckConstraints = async (
dataSource: DataSource,
): Promise<void> => {
await dataSource.query(
`ALTER TABLE "core"."connectedAccount"
DROP CONSTRAINT IF EXISTS "CHK_connectedAccount_accessToken_encrypted"`,
);
await dataSource.query(
`ALTER TABLE "core"."connectedAccount"
DROP CONSTRAINT IF EXISTS "CHK_connectedAccount_refreshToken_encrypted"`,
);
};
const restoreEncryptionCheckConstraints = async (
dataSource: DataSource,
): Promise<void> => {
await dropEncryptionCheckConstraints(dataSource);
await dataSource.query(
`ALTER TABLE "core"."connectedAccount"
ADD CONSTRAINT "CHK_connectedAccount_accessToken_encrypted"
CHECK ("accessToken" IS NULL OR "accessToken" LIKE '${SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX}%')`,
);
await dataSource.query(
`ALTER TABLE "core"."connectedAccount"
ADD CONSTRAINT "CHK_connectedAccount_refreshToken_encrypted"
CHECK ("refreshToken" IS NULL OR "refreshToken" LIKE '${SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX}%')`,
);
};
describe('2-5 slow instance command 1798000004000 - EncryptConnectedAccountTokensSlowInstanceCommand (integration)', () => {
let dataSource: DataSource;
let secretEncryptionService: SecretEncryptionService;
let connectedAccountTokenEncryptionService: ConnectedAccountTokenEncryptionService;
let command: EncryptConnectedAccountTokensSlowInstanceCommand;
let workspaceId: string;
let userWorkspaceId: string;
const seededRowIds: string[] = [];
beforeAll(async () => {
dataSource = new DataSource({
type: 'postgres',
url: process.env.PG_DATABASE_URL,
schema: 'core',
entities: [],
synchronize: false,
});
await dataSource.initialize();
secretEncryptionService = buildSecretEncryptionService();
connectedAccountTokenEncryptionService =
new ConnectedAccountTokenEncryptionService(secretEncryptionService);
command = new EncryptConnectedAccountTokensSlowInstanceCommand(
connectedAccountTokenEncryptionService,
);
const seedWorkspaceRow = await dataSource.query(
`SELECT uw.id AS "userWorkspaceId", uw."workspaceId" AS "workspaceId"
FROM "core"."userWorkspace" uw
LIMIT 1`,
);
if (!isDefined(seedWorkspaceRow[0])) {
throw new Error(
'No seeded userWorkspace row found; run database:reset before the integration suite.',
);
}
userWorkspaceId = seedWorkspaceRow[0].userWorkspaceId as string;
workspaceId = seedWorkspaceRow[0].workspaceId as string;
}, 30000);
afterEach(async () => {
if (seededRowIds.length > 0) {
await dataSource.query(
`DELETE FROM "core"."connectedAccount" WHERE id = ANY($1::uuid[])`,
[seededRowIds],
);
seededRowIds.length = 0;
}
// Always leave the schema with the production-shape constraints so
// subsequent integration suites see the expected state.
await restoreEncryptionCheckConstraints(dataSource);
});
afterAll(async () => {
await dataSource?.destroy();
});
it('upgrades plaintext rows to enc:v2 with workspaceId-bound HKDF', async () => {
const handle = `${TEST_ROW_HANDLE_PREFIX}plaintext`;
const accessTokenPlaintext = 'plaintext-access-token';
const refreshTokenPlaintext = 'plaintext-refresh-token';
const id = await seedRow({
dataSource,
workspaceId,
userWorkspaceId,
handle,
accessToken: accessTokenPlaintext,
refreshToken: refreshTokenPlaintext,
});
seededRowIds.push(id);
await command.runDataMigration(dataSource);
const [row] = await dataSource.query(
`SELECT "accessToken", "refreshToken"
FROM "core"."connectedAccount" WHERE id = $1`,
[id],
);
expect(
row.accessToken.startsWith(SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX),
).toBe(true);
expect(
row.refreshToken.startsWith(SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX),
).toBe(true);
expect(
connectedAccountTokenEncryptionService.decrypt({
ciphertext: row.accessToken,
workspaceId,
}),
).toBe(accessTokenPlaintext);
expect(
connectedAccountTokenEncryptionService.decrypt({
ciphertext: row.refreshToken,
workspaceId,
}),
).toBe(refreshTokenPlaintext);
});
it('leaves enc:v2 rows untouched and is idempotent across re-runs', async () => {
const handle = `${TEST_ROW_HANDLE_PREFIX}v2`;
const plaintext = 'v2-token';
const preexistingV2Ciphertext = secretEncryptionService.encryptVersioned(
plaintext as PlaintextString,
{ workspaceId },
);
const id = await seedRow({
dataSource,
workspaceId,
userWorkspaceId,
handle,
accessToken: preexistingV2Ciphertext,
refreshToken: null,
});
seededRowIds.push(id);
await command.runDataMigration(dataSource);
const [afterFirstRun] = await dataSource.query(
`SELECT "accessToken" FROM "core"."connectedAccount" WHERE id = $1`,
[id],
);
expect(afterFirstRun.accessToken).toBe(preexistingV2Ciphertext);
await command.runDataMigration(dataSource);
const [afterSecondRun] = await dataSource.query(
`SELECT "accessToken" FROM "core"."connectedAccount" WHERE id = $1`,
[id],
);
expect(afterSecondRun.accessToken).toBe(preexistingV2Ciphertext);
});
it('handles a row that mixes a v2 column and a plaintext column (per-cell guard)', async () => {
const handle = `${TEST_ROW_HANDLE_PREFIX}mixed`;
const accessPlaintext = 'mixed-access';
const refreshPlaintext = 'mixed-refresh';
const preexistingV2Access = secretEncryptionService.encryptVersioned(
accessPlaintext as PlaintextString,
{ workspaceId },
);
const id = await seedRow({
dataSource,
workspaceId,
userWorkspaceId,
handle,
accessToken: preexistingV2Access,
refreshToken: refreshPlaintext,
});
seededRowIds.push(id);
await command.runDataMigration(dataSource);
const [row] = await dataSource.query(
`SELECT "accessToken", "refreshToken"
FROM "core"."connectedAccount" WHERE id = $1`,
[id],
);
expect(row.accessToken).toBe(preexistingV2Access);
expect(
row.refreshToken.startsWith(SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX),
).toBe(true);
expect(
connectedAccountTokenEncryptionService.decrypt({
ciphertext: row.refreshToken,
workspaceId,
}),
).toBe(refreshPlaintext);
});
it('rejects a plaintext insert once the CHECK constraint is in place', async () => {
await dropEncryptionCheckConstraints(dataSource);
const queryRunner = dataSource.createQueryRunner();
try {
await command.up(queryRunner);
await expect(
dataSource.query(
`INSERT INTO "core"."connectedAccount"
("handle", "provider", "accessToken", "refreshToken",
"userWorkspaceId", "workspaceId")
VALUES ($1, 'google', $2, NULL, $3, $4)`,
[
`${TEST_ROW_HANDLE_PREFIX}rejected`,
'plaintext-should-be-rejected',
userWorkspaceId,
workspaceId,
],
),
).rejects.toThrow(/check constraint/i);
} finally {
await queryRunner.release();
}
});
it('down() removes the CHECK constraint and lets plaintext through (for rollback safety only)', async () => {
const queryRunner = dataSource.createQueryRunner();
try {
await command.down(queryRunner);
const handle = `${TEST_ROW_HANDLE_PREFIX}down`;
const id = await seedRow({
dataSource,
workspaceId,
userWorkspaceId,
handle,
accessToken: 'plaintext-allowed-after-down',
refreshToken: null,
});
seededRowIds.push(id);
const [row] = await dataSource.query(
`SELECT "accessToken" FROM "core"."connectedAccount" WHERE id = $1`,
[id],
);
expect(row.accessToken).toBe('plaintext-allowed-after-down');
} finally {
await queryRunner.release();
}
});
});

View File

@@ -0,0 +1,269 @@
import crypto from 'crypto';
import { config } from 'dotenv';
import { isDefined } from 'twenty-shared/utils';
import { DataSource } from 'typeorm';
import { buildSecretEncryptionServiceFromEnv } from 'test/integration/upgrade/utils/build-secret-encryption-service.util';
import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type';
import { SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX } from 'src/engine/core-modules/secret-encryption/constants/secret-encryption.constant';
import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service';
import { EncryptApplicationVariableSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000005000-encrypt-application-variable';
jest.useRealTimers();
config({
path: process.env.NODE_ENV === 'test' ? '.env.test' : '.env',
override: true,
});
const TEST_ROW_KEY_PREFIX = 'ENCRYPT_APP_VAR_TEST_';
const CHECK_CONSTRAINT_NAME = 'CHK_applicationVariable_value_encrypted';
const CHECK_CONSTRAINT_EXPR = `"value" LIKE '${SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX}%'`;
const dropCheckConstraint = (dataSource: DataSource): Promise<unknown> =>
dataSource.query(
`ALTER TABLE "core"."applicationVariable"
DROP CONSTRAINT IF EXISTS "${CHECK_CONSTRAINT_NAME}"`,
);
const restoreCheckConstraint = async (
dataSource: DataSource,
): Promise<void> => {
await dropCheckConstraint(dataSource);
await dataSource.query(
`ALTER TABLE "core"."applicationVariable"
ADD CONSTRAINT "${CHECK_CONSTRAINT_NAME}"
CHECK (${CHECK_CONSTRAINT_EXPR})`,
);
};
describe('2-5 slow instance command 1798000005000 - EncryptApplicationVariableSlowInstanceCommand (integration)', () => {
let dataSource: DataSource;
let secretEncryptionService: SecretEncryptionService;
let command: EncryptApplicationVariableSlowInstanceCommand;
let workspaceId: string;
let applicationId: string;
const seededRowIds: string[] = [];
const seedRow = async ({
isSecret,
value,
}: {
isSecret: boolean;
value: string;
}): Promise<string> => {
await dropCheckConstraint(dataSource);
const id = crypto.randomUUID();
const universalIdentifier = crypto.randomUUID();
const key = `${TEST_ROW_KEY_PREFIX}${id}`;
await dataSource.query(
`INSERT INTO "core"."applicationVariable"
(id, "universalIdentifier", "applicationId", "workspaceId",
"key", "value", "isSecret")
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[
id,
universalIdentifier,
applicationId,
workspaceId,
key,
value,
isSecret,
],
);
seededRowIds.push(id);
return id;
};
beforeAll(async () => {
dataSource = new DataSource({
type: 'postgres',
url: process.env.PG_DATABASE_URL,
schema: 'core',
entities: [],
synchronize: false,
});
await dataSource.initialize();
secretEncryptionService = buildSecretEncryptionServiceFromEnv();
command = new EncryptApplicationVariableSlowInstanceCommand(
secretEncryptionService,
);
const [seedWorkspace] = await dataSource.query(
`SELECT id, "workspaceCustomApplicationId"
FROM "core"."workspace"
WHERE "workspaceCustomApplicationId" IS NOT NULL
LIMIT 1`,
);
if (!isDefined(seedWorkspace)) {
throw new Error(
'No seeded workspace with a custom application found; run database:reset before the integration suite.',
);
}
workspaceId = seedWorkspace.id as string;
applicationId = seedWorkspace.workspaceCustomApplicationId as string;
}, 30000);
afterEach(async () => {
if (seededRowIds.length > 0) {
await dataSource.query(
`DELETE FROM "core"."applicationVariable" WHERE id = ANY($1::uuid[])`,
[seededRowIds],
);
seededRowIds.length = 0;
}
await restoreCheckConstraint(dataSource);
});
afterAll(async () => {
await dataSource?.destroy();
});
it('upgrades legacy CTR secret rows to enc:v2 with workspaceId-bound HKDF', async () => {
const plaintext = 'legacy-ctr-application-variable-secret';
const id = await seedRow({
isSecret: true,
value: secretEncryptionService.encrypt(plaintext),
});
await command.runDataMigration(dataSource);
const [row] = await dataSource.query(
`SELECT "value" FROM "core"."applicationVariable" WHERE id = $1`,
[id],
);
expect(row.value.startsWith(SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX)).toBe(
true,
);
expect(
secretEncryptionService.decryptVersionedOrThrow(row.value, {
workspaceId,
}),
).toBe(plaintext);
});
it('leaves non-secret rows untouched', async () => {
const plaintext = 'https://public.example.com/manifest.json';
const id = await seedRow({ isSecret: false, value: plaintext });
await command.runDataMigration(dataSource);
const [row] = await dataSource.query(
`SELECT "value" FROM "core"."applicationVariable" WHERE id = $1`,
[id],
);
expect(row.value).toBe(plaintext);
});
it('treats plaintext-under-isSecret=true as plaintext and re-encrypts as v2', async () => {
const plaintext =
'https://hooks.slack.com/services/T09QGPB2ZP1/B09QUQ5LY2Z/abc';
const id = await seedRow({ isSecret: true, value: plaintext });
await command.runDataMigration(dataSource);
const [row] = await dataSource.query(
`SELECT "value" FROM "core"."applicationVariable" WHERE id = $1`,
[id],
);
expect(row.value.startsWith(SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX)).toBe(
true,
);
expect(
secretEncryptionService.decryptVersionedOrThrow(row.value, {
workspaceId,
}),
).toBe(plaintext);
});
it('leaves enc:v2 rows untouched and is idempotent across re-runs', async () => {
const plaintext = 'already-v2-secret';
const preexistingV2 = secretEncryptionService.encryptVersioned(plaintext as PlaintextString, {
workspaceId,
});
const id = await seedRow({ isSecret: true, value: preexistingV2 });
await command.runDataMigration(dataSource);
const [afterFirstRun] = await dataSource.query(
`SELECT "value" FROM "core"."applicationVariable" WHERE id = $1`,
[id],
);
expect(afterFirstRun.value).toBe(preexistingV2);
await command.runDataMigration(dataSource);
const [afterSecondRun] = await dataSource.query(
`SELECT "value" FROM "core"."applicationVariable" WHERE id = $1`,
[id],
);
expect(afterSecondRun.value).toBe(preexistingV2);
});
it('up() applies the CHECK constraint that rejects plaintext secret inserts', async () => {
await dropCheckConstraint(dataSource);
const queryRunner = dataSource.createQueryRunner();
try {
await command.up(queryRunner);
const id = crypto.randomUUID();
seededRowIds.push(id);
await expect(
dataSource.query(
`INSERT INTO "core"."applicationVariable"
(id, "universalIdentifier", "applicationId", "workspaceId",
"key", "value", "isSecret")
VALUES ($1, $2, $3, $4, $5, 'plaintext-should-be-rejected', true)`,
[
id,
crypto.randomUUID(),
applicationId,
workspaceId,
`${TEST_ROW_KEY_PREFIX}${id}`,
],
),
).rejects.toThrow(/check constraint/i);
} finally {
await queryRunner.release();
}
});
it('down() removes the CHECK constraint and lets plaintext through (for rollback safety only)', async () => {
const queryRunner = dataSource.createQueryRunner();
try {
await command.down(queryRunner);
const id = await seedRow({
isSecret: true,
value: 'plaintext-allowed-after-down',
});
const [row] = await dataSource.query(
`SELECT "value" FROM "core"."applicationVariable" WHERE id = $1`,
[id],
);
expect(row.value).toBe('plaintext-allowed-after-down');
} finally {
await queryRunner.release();
}
});
});

View File

@@ -0,0 +1,254 @@
import crypto from 'crypto';
import { config } from 'dotenv';
import { isDefined } from 'twenty-shared/utils';
import { DataSource } from 'typeorm';
import { buildSecretEncryptionServiceFromEnv } from 'test/integration/upgrade/utils/build-secret-encryption-service.util';
import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type';
import { SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX } from 'src/engine/core-modules/secret-encryption/constants/secret-encryption.constant';
import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service';
import { EncryptApplicationRegistrationVariableSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000006000-encrypt-application-registration-variable';
jest.useRealTimers();
config({
path: process.env.NODE_ENV === 'test' ? '.env.test' : '.env',
override: true,
});
const TEST_REGISTRATION_NAME_PREFIX = 'encrypt-app-reg-var-test-';
const CHECK_CONSTRAINT_NAME =
'CHK_applicationRegistrationVariable_encryptedValue_encrypted';
const CHECK_CONSTRAINT_EXPR = `"encryptedValue" LIKE '${SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX}%'`;
const dropCheckConstraint = (dataSource: DataSource): Promise<unknown> =>
dataSource.query(
`ALTER TABLE "core"."applicationRegistrationVariable"
DROP CONSTRAINT IF EXISTS "${CHECK_CONSTRAINT_NAME}"`,
);
const restoreCheckConstraint = async (
dataSource: DataSource,
): Promise<void> => {
await dropCheckConstraint(dataSource);
await dataSource.query(
`ALTER TABLE "core"."applicationRegistrationVariable"
ADD CONSTRAINT "${CHECK_CONSTRAINT_NAME}"
CHECK (${CHECK_CONSTRAINT_EXPR})`,
);
};
describe('2-5 slow instance command 1798000006000 - EncryptApplicationRegistrationVariableSlowInstanceCommand (integration)', () => {
let dataSource: DataSource;
let secretEncryptionService: SecretEncryptionService;
let command: EncryptApplicationRegistrationVariableSlowInstanceCommand;
let workspaceId: string;
let registrationId: string;
const seededVariableIds: string[] = [];
const seedVariable = async ({
encryptedValue,
isSecret = true,
}: {
encryptedValue: string;
isSecret?: boolean;
}): Promise<string> => {
await dropCheckConstraint(dataSource);
const id = crypto.randomUUID();
await dataSource.query(
`INSERT INTO "core"."applicationRegistrationVariable"
(id, "applicationRegistrationId", "key", "encryptedValue",
"isSecret", "isRequired")
VALUES ($1, $2, $3, $4, $5, false)`,
[id, registrationId, `KEY_${id}`, encryptedValue, isSecret],
);
seededVariableIds.push(id);
return id;
};
beforeAll(async () => {
dataSource = new DataSource({
type: 'postgres',
url: process.env.PG_DATABASE_URL,
schema: 'core',
entities: [],
synchronize: false,
});
await dataSource.initialize();
secretEncryptionService = buildSecretEncryptionServiceFromEnv();
command = new EncryptApplicationRegistrationVariableSlowInstanceCommand(
secretEncryptionService,
);
const [seedWorkspace] = await dataSource.query(
`SELECT id FROM "core"."workspace" LIMIT 1`,
);
if (!isDefined(seedWorkspace)) {
throw new Error(
'No seeded workspace found; run database:reset before the integration suite.',
);
}
workspaceId = seedWorkspace.id as string;
registrationId = crypto.randomUUID();
await dataSource.query(
`INSERT INTO "core"."applicationRegistration"
(id, "universalIdentifier", name, "oAuthClientId",
"oAuthRedirectUris", "oAuthScopes", "workspaceId", "sourceType")
VALUES ($1, $2, $3, $4, $5, $6, $7, 'local')`,
[
registrationId,
crypto.randomUUID(),
`${TEST_REGISTRATION_NAME_PREFIX}${registrationId}`,
crypto.randomUUID(),
['http://localhost:3000/callback'],
['read'],
workspaceId,
],
);
}, 30000);
afterEach(async () => {
if (seededVariableIds.length > 0) {
await dataSource.query(
`DELETE FROM "core"."applicationRegistrationVariable"
WHERE id = ANY($1::uuid[])`,
[seededVariableIds],
);
seededVariableIds.length = 0;
}
await restoreCheckConstraint(dataSource);
});
afterAll(async () => {
await dataSource.query(
`DELETE FROM "core"."applicationRegistration" WHERE id = $1`,
[registrationId],
);
await dataSource?.destroy();
});
it('upgrades legacy CTR rows to enc:v2 with instance-scoped HKDF', async () => {
const plaintext = 'legacy-ctr-registration-variable-secret';
const id = await seedVariable({
encryptedValue: secretEncryptionService.encrypt(plaintext),
});
await command.runDataMigration(dataSource);
const [row] = await dataSource.query(
`SELECT "encryptedValue"
FROM "core"."applicationRegistrationVariable"
WHERE id = $1`,
[id],
);
expect(
row.encryptedValue.startsWith(SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX),
).toBe(true);
expect(
secretEncryptionService.decryptVersionedOrThrow(row.encryptedValue),
).toBe(plaintext);
});
it('leaves unfilled rows (encryptedValue = "") untouched', async () => {
const id = await seedVariable({ encryptedValue: '' });
await command.runDataMigration(dataSource);
const [row] = await dataSource.query(
`SELECT "encryptedValue"
FROM "core"."applicationRegistrationVariable"
WHERE id = $1`,
[id],
);
expect(row.encryptedValue).toBe('');
});
it('leaves enc:v2 rows untouched and is idempotent across re-runs', async () => {
const plaintext = 'already-v2-registration-secret';
const preexistingV2 = secretEncryptionService.encryptVersioned(plaintext as PlaintextString);
const id = await seedVariable({ encryptedValue: preexistingV2 });
await command.runDataMigration(dataSource);
const [afterFirstRun] = await dataSource.query(
`SELECT "encryptedValue"
FROM "core"."applicationRegistrationVariable"
WHERE id = $1`,
[id],
);
expect(afterFirstRun.encryptedValue).toBe(preexistingV2);
await command.runDataMigration(dataSource);
const [afterSecondRun] = await dataSource.query(
`SELECT "encryptedValue"
FROM "core"."applicationRegistrationVariable"
WHERE id = $1`,
[id],
);
expect(afterSecondRun.encryptedValue).toBe(preexistingV2);
});
it('up() applies the CHECK constraint that rejects plaintext inserts', async () => {
await dropCheckConstraint(dataSource);
const queryRunner = dataSource.createQueryRunner();
try {
await command.up(queryRunner);
const id = crypto.randomUUID();
seededVariableIds.push(id);
await expect(
dataSource.query(
`INSERT INTO "core"."applicationRegistrationVariable"
(id, "applicationRegistrationId", "key", "encryptedValue",
"isSecret", "isRequired")
VALUES ($1, $2, $3, 'plaintext-should-be-rejected', true, false)`,
[id, registrationId, `KEY_${id}`],
),
).rejects.toThrow(/check constraint/i);
} finally {
await queryRunner.release();
}
});
it('down() removes the CHECK constraint and lets plaintext through (for rollback safety only)', async () => {
const queryRunner = dataSource.createQueryRunner();
try {
await command.down(queryRunner);
const id = await seedVariable({
encryptedValue: 'plaintext-allowed-after-down',
});
const [row] = await dataSource.query(
`SELECT "encryptedValue"
FROM "core"."applicationRegistrationVariable"
WHERE id = $1`,
[id],
);
expect(row.encryptedValue).toBe('plaintext-allowed-after-down');
} finally {
await queryRunner.release();
}
});
});

View File

@@ -0,0 +1,202 @@
import crypto from 'crypto';
import { config } from 'dotenv';
import { DataSource } from 'typeorm';
import { buildSecretEncryptionServiceFromEnv } from 'test/integration/upgrade/utils/build-secret-encryption-service.util';
import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type';
import { SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX } from 'src/engine/core-modules/secret-encryption/constants/secret-encryption.constant';
import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service';
import { EncryptSigningKeyPrivateKeysSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000007000-encrypt-signing-key-private-keys';
jest.useRealTimers();
config({
path: process.env.NODE_ENV === 'test' ? '.env.test' : '.env',
override: true,
});
const PUBLIC_KEY_FIXTURE =
'-----BEGIN PUBLIC KEY-----\nintegration-test-public\n-----END PUBLIC KEY-----';
const CHECK_CONSTRAINT_NAME = 'CHK_signingKey_privateKey_encrypted';
const CHECK_CONSTRAINT_EXPR = `"privateKey" IS NULL OR "privateKey" LIKE '${SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX}%'`;
const dropCheckConstraint = (dataSource: DataSource): Promise<unknown> =>
dataSource.query(
`ALTER TABLE "core"."signingKey"
DROP CONSTRAINT IF EXISTS "${CHECK_CONSTRAINT_NAME}"`,
);
const restoreCheckConstraint = async (
dataSource: DataSource,
): Promise<void> => {
await dropCheckConstraint(dataSource);
await dataSource.query(
`ALTER TABLE "core"."signingKey"
ADD CONSTRAINT "${CHECK_CONSTRAINT_NAME}"
CHECK (${CHECK_CONSTRAINT_EXPR})`,
);
};
describe('2-5 slow instance command 1798000007000 - EncryptSigningKeyPrivateKeysSlowInstanceCommand (integration)', () => {
let dataSource: DataSource;
let secretEncryptionService: SecretEncryptionService;
let command: EncryptSigningKeyPrivateKeysSlowInstanceCommand;
const seededRowIds: string[] = [];
const seedRow = async ({
privateKey,
}: {
privateKey: string | null;
}): Promise<string> => {
await dropCheckConstraint(dataSource);
const id = crypto.randomUUID();
await dataSource.query(
`INSERT INTO "core"."signingKey"
(id, "publicKey", "privateKey", "isCurrent")
VALUES ($1, $2, $3, false)`,
[id, PUBLIC_KEY_FIXTURE, privateKey],
);
seededRowIds.push(id);
return id;
};
beforeAll(async () => {
dataSource = new DataSource({
type: 'postgres',
url: process.env.PG_DATABASE_URL,
schema: 'core',
entities: [],
synchronize: false,
});
await dataSource.initialize();
secretEncryptionService = buildSecretEncryptionServiceFromEnv();
command = new EncryptSigningKeyPrivateKeysSlowInstanceCommand(
secretEncryptionService,
);
}, 30000);
afterEach(async () => {
if (seededRowIds.length > 0) {
await dataSource.query(
`DELETE FROM "core"."signingKey" WHERE id = ANY($1::uuid[])`,
[seededRowIds],
);
seededRowIds.length = 0;
}
await restoreCheckConstraint(dataSource);
});
afterAll(async () => {
await dataSource?.destroy();
});
it('upgrades a legacy CTR-encrypted private key to enc:v2 with instance-scoped HKDF', async () => {
const plaintextPem =
'-----BEGIN PRIVATE KEY-----\nlegacy-pem-material\n-----END PRIVATE KEY-----';
const id = await seedRow({
privateKey: secretEncryptionService.encrypt(plaintextPem),
});
await command.runDataMigration(dataSource);
const [row] = await dataSource.query(
`SELECT "privateKey" FROM "core"."signingKey" WHERE id = $1`,
[id],
);
expect(
row.privateKey.startsWith(SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX),
).toBe(true);
expect(
secretEncryptionService.decryptVersionedOrThrow(row.privateKey),
).toBe(plaintextPem);
});
it('leaves NULL private keys untouched (revoked / rotated keys)', async () => {
const id = await seedRow({ privateKey: null });
await command.runDataMigration(dataSource);
const [row] = await dataSource.query(
`SELECT "privateKey" FROM "core"."signingKey" WHERE id = $1`,
[id],
);
expect(row.privateKey).toBeNull();
});
it('leaves enc:v2 rows untouched and is idempotent across re-runs', async () => {
const plaintext =
'-----BEGIN PRIVATE KEY-----\nalready-v2\n-----END PRIVATE KEY-----';
const preexistingV2 = secretEncryptionService.encryptVersioned(plaintext as PlaintextString);
const id = await seedRow({ privateKey: preexistingV2 });
await command.runDataMigration(dataSource);
const [afterFirstRun] = await dataSource.query(
`SELECT "privateKey" FROM "core"."signingKey" WHERE id = $1`,
[id],
);
expect(afterFirstRun.privateKey).toBe(preexistingV2);
await command.runDataMigration(dataSource);
const [afterSecondRun] = await dataSource.query(
`SELECT "privateKey" FROM "core"."signingKey" WHERE id = $1`,
[id],
);
expect(afterSecondRun.privateKey).toBe(preexistingV2);
});
it('up() applies the CHECK constraint that rejects plaintext inserts', async () => {
await dropCheckConstraint(dataSource);
const queryRunner = dataSource.createQueryRunner();
try {
await command.up(queryRunner);
const id = crypto.randomUUID();
seededRowIds.push(id);
await expect(
dataSource.query(
`INSERT INTO "core"."signingKey"
(id, "publicKey", "privateKey", "isCurrent")
VALUES ($1, $2, 'plaintext-should-be-rejected', false)`,
[id, PUBLIC_KEY_FIXTURE],
),
).rejects.toThrow(/check constraint/i);
} finally {
await queryRunner.release();
}
});
it('down() removes the CHECK constraint and lets plaintext through (for rollback safety only)', async () => {
const queryRunner = dataSource.createQueryRunner();
try {
await command.down(queryRunner);
const id = await seedRow({ privateKey: 'plaintext-allowed-after-down' });
const [row] = await dataSource.query(
`SELECT "privateKey" FROM "core"."signingKey" WHERE id = $1`,
[id],
);
expect(row.privateKey).toBe('plaintext-allowed-after-down');
} finally {
await queryRunner.release();
}
});
});

View File

@@ -0,0 +1,131 @@
import crypto from 'crypto';
import { config } from 'dotenv';
import { DataSource } from 'typeorm';
import { buildSecretEncryptionServiceFromEnv } from 'test/integration/upgrade/utils/build-secret-encryption-service.util';
import { KeyValuePairType } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity';
import { type EncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/encrypted-string.type';
import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type';
import { SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX } from 'src/engine/core-modules/secret-encryption/constants/secret-encryption.constant';
import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service';
import { EncryptSensitiveConfigStorageSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000008000-encrypt-sensitive-config-storage';
jest.useRealTimers();
config({
path: process.env.NODE_ENV === 'test' ? '.env.test' : '.env',
override: true,
});
const SENSITIVE_STRING_KEY = 'EMAIL_SMTP_USER';
describe('2-5 slow instance command 1798000008000 - EncryptSensitiveConfigStorageSlowInstanceCommand (integration)', () => {
let dataSource: DataSource;
let secretEncryptionService: SecretEncryptionService;
let command: EncryptSensitiveConfigStorageSlowInstanceCommand;
const seededRowIds: string[] = [];
const clearSeededKey = (): Promise<unknown> =>
dataSource.query(
`DELETE FROM "core"."keyValuePair"
WHERE type = $1 AND key = $2
AND "userId" IS NULL AND "workspaceId" IS NULL`,
[KeyValuePairType.CONFIG_VARIABLE, SENSITIVE_STRING_KEY],
);
const seedRow = async (value: string): Promise<string> => {
const id = crypto.randomUUID();
await dataSource.query(
`INSERT INTO "core"."keyValuePair"
(id, "userId", "workspaceId", key, value, type)
VALUES ($1, NULL, NULL, $2, to_jsonb($3::text), $4)`,
[id, SENSITIVE_STRING_KEY, value, KeyValuePairType.CONFIG_VARIABLE],
);
seededRowIds.push(id);
return id;
};
const readValue = async (id: string): Promise<string> => {
const [row] = await dataSource.query(
`SELECT value FROM "core"."keyValuePair" WHERE id = $1`,
[id],
);
return row.value as string;
};
beforeAll(async () => {
dataSource = new DataSource({
type: 'postgres',
url: process.env.PG_DATABASE_URL,
schema: 'core',
entities: [],
synchronize: false,
});
await dataSource.initialize();
secretEncryptionService = buildSecretEncryptionServiceFromEnv();
command = new EncryptSensitiveConfigStorageSlowInstanceCommand(
secretEncryptionService,
);
await clearSeededKey();
}, 30000);
afterEach(async () => {
if (seededRowIds.length > 0) {
await dataSource.query(
`DELETE FROM "core"."keyValuePair" WHERE id = ANY($1::uuid[])`,
[seededRowIds],
);
seededRowIds.length = 0;
}
});
afterAll(async () => {
await clearSeededKey();
await dataSource?.destroy();
});
it('upgrades a legacy CTR sensitive STRING config row to enc:v2 with instance-scoped HKDF', async () => {
const plaintext = 'smtp-legacy-username';
const id = await seedRow(secretEncryptionService.encrypt(plaintext));
await command.runDataMigration(dataSource);
const value = await readValue(id);
expect(value.startsWith(SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX)).toBe(true);
expect(
secretEncryptionService.decryptVersionedOrThrow(
value as EncryptedString,
),
).toBe(plaintext);
});
it('leaves enc:v2 rows untouched and is idempotent across re-runs', async () => {
const plaintext = 'smtp-already-v2-username';
const preexistingV2 = secretEncryptionService.encryptVersioned(plaintext as PlaintextString);
const id = await seedRow(preexistingV2);
await command.runDataMigration(dataSource);
expect(await readValue(id)).toBe(preexistingV2);
await command.runDataMigration(dataSource);
expect(await readValue(id)).toBe(preexistingV2);
});
it('leaves empty sensitive config rows untouched', async () => {
const id = await seedRow('');
await command.runDataMigration(dataSource);
expect(await readValue(id)).toBe('');
});
});

View File

@@ -0,0 +1,266 @@
import { createCipheriv, createHash, randomBytes, randomUUID } from 'crypto';
import { config } from 'dotenv';
import { isDefined } from 'twenty-shared/utils';
import { DataSource } from 'typeorm';
import { buildSecretEncryptionServiceFromEnv } from 'test/integration/upgrade/utils/build-secret-encryption-service.util';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
import { type JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type';
import { SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX } from 'src/engine/core-modules/secret-encryption/constants/secret-encryption.constant';
import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service';
import { SimpleSecretEncryptionUtil } from 'src/engine/core-modules/two-factor-authentication/utils/simple-secret-encryption.util';
import { EncryptTotpSecretsSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000009000-encrypt-totp-secrets';
jest.useRealTimers();
config({
path: process.env.NODE_ENV === 'test' ? '.env.test' : '.env',
override: true,
});
const CHECK_CONSTRAINT_NAME =
'CHK_twoFactorAuthenticationMethod_secret_encrypted';
const CHECK_CONSTRAINT_EXPR = `"secret" LIKE '${SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX}%'`;
const buildLegacyAesCbcCiphertext = ({
plaintext,
appSecret,
purpose,
}: {
plaintext: string;
appSecret: string;
purpose: string;
}): string => {
const appSecretHex = createHash('sha256')
.update(`${appSecret}${purpose}KEY_ENCRYPTION_KEY`)
.digest('hex');
const key = createHash('sha256')
.update(appSecretHex)
.digest()
.subarray(0, 32);
const iv = randomBytes(16);
const cipher = createCipheriv('aes-256-cbc', key, iv);
const encrypted = Buffer.concat([
cipher.update(plaintext, 'utf8'),
cipher.final(),
]);
return `${iv.toString('hex')}:${encrypted.toString('hex')}`;
};
const dropCheckConstraint = (dataSource: DataSource): Promise<unknown> =>
dataSource.query(
`ALTER TABLE "core"."twoFactorAuthenticationMethod"
DROP CONSTRAINT IF EXISTS "${CHECK_CONSTRAINT_NAME}"`,
);
const restoreCheckConstraint = async (
dataSource: DataSource,
): Promise<void> => {
await dropCheckConstraint(dataSource);
await dataSource.query(
`ALTER TABLE "core"."twoFactorAuthenticationMethod"
ADD CONSTRAINT "${CHECK_CONSTRAINT_NAME}"
CHECK (${CHECK_CONSTRAINT_EXPR})`,
);
};
// Stand-in for the real JwtWrapperService used by SimpleSecretEncryptionUtil.
// Reproduces JwtWrapperService.generateAppSecret byte-for-byte so the legacy
// CBC key derivation matches what production rows were sealed with.
const buildJwtWrapperServiceStub = (appSecret: string): JwtWrapperService => {
return {
generateAppSecret: (
type: JwtTokenTypeEnum,
appSecretBody: string,
): string =>
createHash('sha256')
.update(`${appSecret}${appSecretBody}${type}`)
.digest('hex'),
} as unknown as JwtWrapperService;
};
describe('2-5 slow instance command 1798000009000 - EncryptTotpSecretsSlowInstanceCommand (integration)', () => {
let dataSource: DataSource;
let secretEncryptionService: SecretEncryptionService;
let simpleSecretEncryptionUtil: SimpleSecretEncryptionUtil;
let command: EncryptTotpSecretsSlowInstanceCommand;
let appSecret: string;
let userId: string;
let workspaceId: string;
let userWorkspaceId: string;
const seededRowIds: string[] = [];
const seedRow = async ({ secret }: { secret: string }): Promise<string> => {
await dropCheckConstraint(dataSource);
const id = randomUUID();
await dataSource.query(
`INSERT INTO "core"."twoFactorAuthenticationMethod"
(id, "workspaceId", "userWorkspaceId", "secret", "status", "strategy")
VALUES ($1, $2, $3, $4, 'VERIFIED', 'TOTP')`,
[id, workspaceId, userWorkspaceId, secret],
);
seededRowIds.push(id);
return id;
};
beforeAll(async () => {
dataSource = new DataSource({
type: 'postgres',
url: process.env.PG_DATABASE_URL,
schema: 'core',
entities: [],
synchronize: false,
});
await dataSource.initialize();
if (!isDefined(process.env.APP_SECRET) || process.env.APP_SECRET === '') {
throw new Error(
'APP_SECRET must be set in the integration test environment to build legacy CBC fixtures.',
);
}
appSecret = process.env.APP_SECRET;
secretEncryptionService = buildSecretEncryptionServiceFromEnv();
simpleSecretEncryptionUtil = new SimpleSecretEncryptionUtil(
buildJwtWrapperServiceStub(appSecret),
);
command = new EncryptTotpSecretsSlowInstanceCommand(
secretEncryptionService,
simpleSecretEncryptionUtil,
);
const [seedUserWorkspace] = await dataSource.query(
`SELECT id, "userId", "workspaceId"
FROM "core"."userWorkspace"
LIMIT 1`,
);
if (!isDefined(seedUserWorkspace)) {
throw new Error(
'No seeded userWorkspace row found; run database:reset before the integration suite.',
);
}
userWorkspaceId = seedUserWorkspace.id as string;
userId = seedUserWorkspace.userId as string;
workspaceId = seedUserWorkspace.workspaceId as string;
}, 30000);
afterEach(async () => {
if (seededRowIds.length > 0) {
await dataSource.query(
`DELETE FROM "core"."twoFactorAuthenticationMethod" WHERE id = ANY($1::uuid[])`,
[seededRowIds],
);
seededRowIds.length = 0;
}
await restoreCheckConstraint(dataSource);
});
afterAll(async () => {
await dataSource?.destroy();
});
it('upgrades legacy AES-CBC TOTP secrets to enc:v2 with workspaceId-bound HKDF', async () => {
const plaintext = 'KVKFKRCPNZQUYMLXOVYDSKLMNBVCXZ';
const legacyCiphertext = buildLegacyAesCbcCiphertext({
plaintext,
appSecret,
purpose: `${userId}${workspaceId}otp-secret`,
});
const id = await seedRow({ secret: legacyCiphertext });
await command.runDataMigration(dataSource);
const [row] = await dataSource.query(
`SELECT "secret" FROM "core"."twoFactorAuthenticationMethod" WHERE id = $1`,
[id],
);
expect(row.secret.startsWith(SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX)).toBe(
true,
);
expect(
secretEncryptionService.decryptVersionedOrThrow(row.secret, {
workspaceId,
}),
).toBe(plaintext);
});
it('leaves enc:v2 rows untouched and is idempotent across re-runs', async () => {
const plaintext = 'already-v2-totp-secret';
const preexistingV2 = secretEncryptionService.encryptVersioned(plaintext as PlaintextString, {
workspaceId,
});
const id = await seedRow({ secret: preexistingV2 });
await command.runDataMigration(dataSource);
const [afterFirstRun] = await dataSource.query(
`SELECT "secret" FROM "core"."twoFactorAuthenticationMethod" WHERE id = $1`,
[id],
);
expect(afterFirstRun.secret).toBe(preexistingV2);
await command.runDataMigration(dataSource);
const [afterSecondRun] = await dataSource.query(
`SELECT "secret" FROM "core"."twoFactorAuthenticationMethod" WHERE id = $1`,
[id],
);
expect(afterSecondRun.secret).toBe(preexistingV2);
});
it('up() applies the CHECK constraint that rejects plaintext secret inserts', async () => {
await dropCheckConstraint(dataSource);
const queryRunner = dataSource.createQueryRunner();
try {
await command.up(queryRunner);
const id = randomUUID();
seededRowIds.push(id);
await expect(
dataSource.query(
`INSERT INTO "core"."twoFactorAuthenticationMethod"
(id, "workspaceId", "userWorkspaceId", "secret", "status", "strategy")
VALUES ($1, $2, $3, 'plaintext-should-be-rejected', 'VERIFIED', 'TOTP')`,
[id, workspaceId, userWorkspaceId],
),
).rejects.toThrow(/check constraint/i);
} finally {
await queryRunner.release();
}
});
it('down() removes the CHECK constraint and lets plaintext through (for rollback safety only)', async () => {
const queryRunner = dataSource.createQueryRunner();
try {
await command.down(queryRunner);
const id = await seedRow({ secret: 'plaintext-allowed-after-down' });
const [row] = await dataSource.query(
`SELECT "secret" FROM "core"."twoFactorAuthenticationMethod" WHERE id = $1`,
[id],
);
expect(row.secret).toBe('plaintext-allowed-after-down');
} finally {
await queryRunner.release();
}
});
});

View File

@@ -0,0 +1,187 @@
import { config } from 'dotenv';
import { isDefined } from 'twenty-shared/utils';
import { DataSource } from 'typeorm';
import { v4 } from 'uuid';
jest.useRealTimers();
config({
path: process.env.NODE_ENV === 'test' ? '.env.test' : '.env',
override: true,
});
const CLAIM_TTL_MS = 5 * 60 * 1000;
describe('campaignDelivery claim protocol (integration)', () => {
let dataSource: DataSource;
let workspaceId: string;
const seededDeliveryIds: string[] = [];
const claim = async (
deliveryId: string,
): Promise<{ claimToken: string; won: boolean }> => {
const claimToken = v4();
const [, affected] = await dataSource.query(
`UPDATE "core"."campaignDelivery"
SET "state" = 'SENDING', "claimToken" = $1, "claimExpiresAt" = $2
WHERE "id" = $3 AND "state" IN ('QUEUED', 'FAILED')`,
[claimToken, new Date(Date.now() + CLAIM_TTL_MS), deliveryId],
);
return { claimToken, won: affected === 1 };
};
const settle = async (
deliveryId: string,
claimToken: string,
): Promise<number> => {
const [, affected] = await dataSource.query(
`UPDATE "core"."campaignDelivery"
SET "state" = 'SENT', "claimToken" = NULL, "claimExpiresAt" = NULL
WHERE "id" = $1 AND "claimToken" = $2`,
[deliveryId, claimToken],
);
return affected;
};
const seedQueuedDelivery = async (): Promise<string> => {
const deliveryId = v4();
await dataSource.query(
`INSERT INTO "core"."campaignDelivery"
("id", "workspaceId", "campaignId", "personId", "recipientEmail", "state")
VALUES ($1, $2, $3, $4, $5, 'QUEUED')`,
[deliveryId, workspaceId, v4(), v4(), `claim-test-${deliveryId}@example.com`],
);
seededDeliveryIds.push(deliveryId);
return deliveryId;
};
const readDelivery = async (
deliveryId: string,
): Promise<{ state: string; claimToken: string | null }> => {
const [row] = await dataSource.query(
`SELECT "state", "claimToken" FROM "core"."campaignDelivery" WHERE "id" = $1`,
[deliveryId],
);
return row;
};
beforeAll(async () => {
dataSource = new DataSource({
type: 'postgres',
url: process.env.PG_DATABASE_URL,
schema: 'core',
entities: [],
synchronize: false,
});
await dataSource.initialize();
const [seedWorkspaceRow] = await dataSource.query(
`SELECT id AS "workspaceId" FROM "core"."workspace" LIMIT 1`,
);
if (!isDefined(seedWorkspaceRow)) {
throw new Error(
'No seeded workspace row found; run database:reset before the integration suite.',
);
}
workspaceId = seedWorkspaceRow.workspaceId;
});
afterAll(async () => {
if (seededDeliveryIds.length > 0) {
await dataSource.query(
`DELETE FROM "core"."campaignDelivery" WHERE "id" = ANY($1)`,
[seededDeliveryIds],
);
}
await dataSource.destroy();
});
it('lets exactly one of two concurrent workers claim the same delivery', async () => {
const deliveryId = await seedQueuedDelivery();
const [first, second] = await Promise.all([
claim(deliveryId),
claim(deliveryId),
]);
expect([first.won, second.won].filter(Boolean)).toHaveLength(1);
});
it('refuses the losing worker a settle, so it can neither record nor bill the send', async () => {
const deliveryId = await seedQueuedDelivery();
const [first, second] = await Promise.all([
claim(deliveryId),
claim(deliveryId),
]);
const winner = first.won ? first : second;
const loser = first.won ? second : first;
expect(await settle(deliveryId, loser.claimToken)).toBe(0);
expect(await settle(deliveryId, winner.claimToken)).toBe(1);
expect(await readDelivery(deliveryId)).toEqual({
state: 'SENT',
claimToken: null,
});
});
it('refuses a stale claim token after the sweeper reclaims an expired lease', async () => {
const deliveryId = await seedQueuedDelivery();
const abandoned = await claim(deliveryId);
await dataSource.query(
`UPDATE "core"."campaignDelivery"
SET "claimExpiresAt" = $1 WHERE "id" = $2`,
[new Date(Date.now() - 1), deliveryId],
);
await dataSource.query(
`UPDATE "core"."campaignDelivery"
SET "state" = 'FAILED', "failureReason" = 'CLAIM_EXPIRED',
"claimToken" = NULL, "claimExpiresAt" = NULL
WHERE "id" = $1 AND "state" = 'SENDING' AND "claimExpiresAt" < now()`,
[deliveryId],
);
const reclaimed = await claim(deliveryId);
expect(reclaimed.won).toBe(true);
expect(await settle(deliveryId, abandoned.claimToken)).toBe(0);
expect(await settle(deliveryId, reclaimed.claimToken)).toBe(1);
});
it('never leaves a claim half written', async () => {
const deliveryId = await seedQueuedDelivery();
await expect(
dataSource.query(
`UPDATE "core"."campaignDelivery"
SET "claimToken" = $1, "claimExpiresAt" = NULL WHERE "id" = $2`,
[v4(), deliveryId],
),
).rejects.toThrow(/CHK_CAMPAIGN_DELIVERY_CLAIM_IS_WHOLE/);
});
it('never leaves a sending delivery without a lease', async () => {
const deliveryId = await seedQueuedDelivery();
await expect(
dataSource.query(
`UPDATE "core"."campaignDelivery"
SET "state" = 'SENDING' WHERE "id" = $1`,
[deliveryId],
),
).rejects.toThrow(/CHK_CAMPAIGN_DELIVERY_CLAIM_HAS_LEASE/);
});
});

View File

@@ -0,0 +1,365 @@
import {
type UpgradeStep,
type WorkspaceUpgradeStep,
} from 'src/engine/core-modules/upgrade/services/upgrade-sequence-reader.service';
import {
type IntegrationTestContext,
createUpgradeSequenceRunnerIntegrationTestModule,
DEFAULT_OPTIONS,
makeFastInstance,
makeSlowInstance,
makeStep,
makeWorkspace,
migrationRecordToKey,
resetSeedSequenceCounter,
restoreUpgradeMigrations,
seedInstanceMigration,
seedWorkspaceMigration,
setMockActiveWorkspaceIds,
snapshotUpgradeMigrations,
testGetExecutedMigrationsInOrder,
WS_1,
WS_2,
} from 'test/integration/upgrade/utils/upgrade-sequence-runner-integration-test.util';
const makeFailingFastInstance = (name: string, error: Error): UpgradeStep =>
({
...makeStep('fast-instance', name),
command: {
up: async () => {
throw error;
},
down: async () => {},
},
}) as unknown as UpgradeStep;
const makeFailingWorkspace = (
name: string,
error: Error,
): WorkspaceUpgradeStep =>
({
...makeStep('workspace', name),
command: {
runOnWorkspace: async () => {
throw error;
},
},
}) as unknown as WorkspaceUpgradeStep;
const makeWorkspaceFailingForIds = (
name: string,
failingWorkspaceIds: Set<string>,
error: Error,
): WorkspaceUpgradeStep =>
({
...makeStep('workspace', name),
command: {
runOnWorkspace: async ({ workspaceId }: { workspaceId: string }) => {
if (failingWorkspaceIds.has(workspaceId)) {
throw error;
}
},
},
}) as unknown as WorkspaceUpgradeStep;
describe('UpgradeSequenceRunnerService — failing sequence (integration)', () => {
let context: IntegrationTestContext;
let savedUpgradeMigrations: Awaited<
ReturnType<typeof snapshotUpgradeMigrations>
>;
beforeAll(async () => {
context = await createUpgradeSequenceRunnerIntegrationTestModule();
savedUpgradeMigrations = await snapshotUpgradeMigrations(
context.dataSource,
);
}, 30000);
afterAll(async () => {
await restoreUpgradeMigrations(context.dataSource, savedUpgradeMigrations);
await context.module?.close();
await context.dataSource?.destroy();
}, 15000);
beforeEach(async () => {
await context.dataSource.query('DELETE FROM core."upgradeMigration"');
resetSeedSequenceCounter();
setMockActiveWorkspaceIds([]);
jest.restoreAllMocks();
});
it('should throw when no migration history exists', async () => {
const sequence = [makeFastInstance('Ic1')];
await expect(
context.runner.run({
sequence,
options: DEFAULT_OPTIONS,
}),
).rejects.toThrow(
'No upgrade migration found — the database may not have been initialized',
);
});
it('should throw when cursor command is not found in the sequence', async () => {
const sequence = [makeFastInstance('Ic1'), makeFastInstance('Ic2')];
await seedInstanceMigration(context.dataSource, {
name: 'RemovedCommand',
status: 'completed',
});
await expect(
context.runner.run({
sequence,
options: DEFAULT_OPTIONS,
}),
).rejects.toThrow(/Step "RemovedCommand" not found in upgrade sequence/);
});
it('should throw when workspace cursors are outside the current slice', async () => {
const sequence = [
makeWorkspace('Wc1'),
makeWorkspace('Wc2'),
makeFastInstance('Ic1'),
makeWorkspace('Wc3'),
makeWorkspace('Wc4'),
];
setMockActiveWorkspaceIds([WS_1, WS_2]);
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc1',
status: 'completed',
workspaceId: WS_1,
});
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc2',
status: 'completed',
workspaceId: WS_1,
});
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc1',
status: 'completed',
workspaceId: WS_2,
});
await seedInstanceMigration(context.dataSource, {
name: 'Ic1',
status: 'completed',
workspaceIds: [WS_1],
});
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc3',
status: 'completed',
workspaceId: WS_1,
});
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc4',
status: 'completed',
workspaceId: WS_1,
});
await expect(
context.runner.run({
sequence,
options: DEFAULT_OPTIONS,
}),
).rejects.toThrow(
'workspace(s) have invalid cursors for workspace segment',
);
});
it('should throw when an active workspace has no migration history', async () => {
const sequence = [makeFastInstance('Ic1'), makeWorkspace('Wc1')];
setMockActiveWorkspaceIds([WS_1, WS_2]);
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc1',
status: 'completed',
workspaceId: WS_1,
});
await expect(
context.runner.run({
sequence,
options: DEFAULT_OPTIONS,
}),
).rejects.toThrow('No upgrade migration found for workspace(s)');
});
it('should record failure in DB when a fast instance command fails', async () => {
const error = new Error('fast command exploded');
const sequence = [
makeFastInstance('Ic1'),
makeFailingFastInstance('Ic2', error),
];
await seedInstanceMigration(context.dataSource, {
name: 'Ic1',
status: 'completed',
});
await expect(
context.runner.run({
sequence,
options: DEFAULT_OPTIONS,
}),
).rejects.toThrow('fast command exploded');
const executed = await testGetExecutedMigrationsInOrder(context.dataSource);
expect(executed.map(migrationRecordToKey)).toStrictEqual([
'Ic1:instance:completed:1',
// Ic2 attempted and failed
'Ic2:instance:failed:1',
]);
});
it('should record failure in DB when a slow instance command fails', async () => {
const error = new Error('slow data migration exploded');
const sequence = [
makeFastInstance('Ic1'),
{
...makeSlowInstance('Ic2'),
command: {
up: async () => {},
down: async () => {},
runDataMigration: async () => {
throw error;
},
},
} as unknown as UpgradeStep,
];
setMockActiveWorkspaceIds([WS_1]);
await seedInstanceMigration(context.dataSource, {
name: 'Ic1',
status: 'completed',
workspaceIds: [WS_1],
});
await expect(
context.runner.run({
sequence,
options: DEFAULT_OPTIONS,
}),
).rejects.toThrow('slow data migration exploded');
const executed = await testGetExecutedMigrationsInOrder(context.dataSource);
expect(executed.map(migrationRecordToKey)).toStrictEqual([
'Ic1:instance:completed:1',
`Ic1:${WS_1}:completed:1`,
// Ic2 attempted and failed
'Ic2:instance:failed:1',
`Ic2:${WS_1}:failed:1`,
]);
});
it('should abort and report failures when workspace commands fail, without running subsequent instance steps', async () => {
const error = new Error('workspace command exploded');
const sequence = [
makeFailingWorkspace('Wc1', error),
makeFastInstance('Ic1'),
];
setMockActiveWorkspaceIds([WS_1]);
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc1',
status: 'failed',
workspaceId: WS_1,
});
const report = await context.runner.run({
sequence,
options: DEFAULT_OPTIONS,
});
expect(report.totalFailures).toBe(1);
expect(report.totalSuccesses).toBe(0);
const executed = await testGetExecutedMigrationsInOrder(context.dataSource);
expect(executed.map(migrationRecordToKey)).toStrictEqual([
`Wc1:${WS_1}:failed:1`,
// Runner retries Wc1, still fails — Ic1 never reached
`Wc1:${WS_1}:failed:2`,
]);
});
it('should abort at workspace failure in a multi-segment sequence with two workspaces starting aligned', async () => {
const error = new Error('Wc2 exploded for WS_2');
const sequence = [
makeWorkspace('Wc0'),
makeFastInstance('Ic1'),
makeWorkspace('Wc1'),
makeWorkspaceFailingForIds('Wc2', new Set([WS_2]), error),
makeFastInstance('Ic2'),
makeWorkspace('Wc3'),
];
setMockActiveWorkspaceIds([WS_1, WS_2]);
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc0',
status: 'completed',
workspaceId: WS_1,
});
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc0',
status: 'completed',
workspaceId: WS_2,
});
await seedInstanceMigration(context.dataSource, {
name: 'Ic1',
status: 'completed',
workspaceIds: [WS_1, WS_2],
});
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc1',
status: 'completed',
workspaceId: WS_1,
});
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc1',
status: 'completed',
workspaceId: WS_2,
});
const report = await context.runner.run({
sequence,
options: {
...DEFAULT_OPTIONS,
workspaceIds: [WS_1, WS_2],
},
});
expect(report.totalSuccesses).toBe(1);
expect(report.totalFailures).toBe(1);
const executed = await testGetExecutedMigrationsInOrder(context.dataSource);
expect(executed.map(migrationRecordToKey)).toStrictEqual([
`Wc0:${WS_1}:completed:1`,
`Wc0:${WS_2}:completed:1`,
'Ic1:instance:completed:1',
`Ic1:${WS_1}:completed:1`,
`Ic1:${WS_2}:completed:1`,
`Wc1:${WS_1}:completed:1`,
`Wc1:${WS_2}:completed:1`,
// WS_1 succeeds Wc2, WS_2 fails Wc2
`Wc2:${WS_1}:completed:1`,
`Wc2:${WS_2}:failed:1`,
// Ic2 and Wc3 never reached — runner aborted at workspace failure
]);
});
});

View File

@@ -0,0 +1,187 @@
import { TWENTY_CROSS_UPGRADE_SUPPORTED_VERSIONS } from 'src/engine/core-modules/upgrade/constants/twenty-cross-upgrade-supported-version.constant';
import { type UpgradeStep } from 'src/engine/core-modules/upgrade/services/upgrade-sequence-reader.service';
import {
type IntegrationTestContext,
createUpgradeSequenceRunnerIntegrationTestModule,
DEFAULT_OPTIONS,
makeVersionedStep,
resetSeedSequenceCounter,
restoreUpgradeMigrations,
seedInstanceMigration,
setMockActiveWorkspaceIds,
snapshotUpgradeMigrations,
WS_1,
} from 'test/integration/upgrade/utils/upgrade-sequence-runner-integration-test.util';
// The sequence only ever covers supported versions, and the skip rule walks
// that same list, so the fixture has to be built from real ones.
const [OLDEST_VERSION, MIDDLE_VERSION, NEWEST_VERSION] =
TWENTY_CROSS_UPGRADE_SUPPORTED_VERSIONS.slice(-3);
const OLDEST_INSTANCE_COMMAND = makeVersionedStep('fast-instance', {
version: OLDEST_VERSION,
label: 'OldestInstanceCommand',
});
const OLDEST_SLOW_INSTANCE_COMMAND = makeVersionedStep('slow-instance', {
version: OLDEST_VERSION,
label: 'OldestSlowInstanceCommand',
});
const MIDDLE_INSTANCE_COMMAND = makeVersionedStep('fast-instance', {
version: MIDDLE_VERSION,
label: 'MiddleInstanceCommand',
});
const NEWEST_INSTANCE_COMMAND = makeVersionedStep('fast-instance', {
version: NEWEST_VERSION,
label: 'NewestInstanceCommand',
});
const NEWEST_WORKSPACE_COMMAND = makeVersionedStep('workspace', {
version: NEWEST_VERSION,
label: 'NewestWorkspaceCommand',
});
describe('UpgradeStatusService — instance completed version (integration)', () => {
let context: IntegrationTestContext;
let savedUpgradeMigrations: Awaited<
ReturnType<typeof snapshotUpgradeMigrations>
>;
const mockSequence = (sequence: UpgradeStep[]) => {
jest
.spyOn(context.upgradeSequenceReaderService, 'getUpgradeSequence')
.mockReturnValue(sequence);
};
beforeAll(async () => {
context = await createUpgradeSequenceRunnerIntegrationTestModule();
savedUpgradeMigrations = await snapshotUpgradeMigrations(
context.dataSource,
);
}, 30000);
afterAll(async () => {
await restoreUpgradeMigrations(context.dataSource, savedUpgradeMigrations);
await context.module?.close();
await context.dataSource?.destroy();
}, 15000);
beforeEach(async () => {
await context.dataSource.query('DELETE FROM core."upgradeMigration"');
resetSeedSequenceCounter();
setMockActiveWorkspaceIds([]);
jest.restoreAllMocks();
});
it('should return null when no instance command has ever run', async () => {
mockSequence([OLDEST_INSTANCE_COMMAND, MIDDLE_INSTANCE_COMMAND]);
await expect(
context.upgradeStatusService.getInstanceCompletedVersion(),
).resolves.toBeNull();
});
it('should reach a trailing version that ships no instance command', async () => {
mockSequence([MIDDLE_INSTANCE_COMMAND, NEWEST_WORKSPACE_COMMAND]);
await seedInstanceMigration(context.dataSource, {
name: MIDDLE_INSTANCE_COMMAND.name,
status: 'completed',
});
await expect(
context.upgradeStatusService.getInstanceCompletedVersion(),
).resolves.toBe(NEWEST_VERSION);
});
it('should hold at the previous version while the trailing instance command has not run', async () => {
mockSequence([MIDDLE_INSTANCE_COMMAND, NEWEST_INSTANCE_COMMAND]);
await seedInstanceMigration(context.dataSource, {
name: MIDDLE_INSTANCE_COMMAND.name,
status: 'completed',
});
await expect(
context.upgradeStatusService.getInstanceCompletedVersion(),
).resolves.toBe(MIDDLE_VERSION);
});
it('should not credit a version whose last instance command failed', async () => {
mockSequence([MIDDLE_INSTANCE_COMMAND, NEWEST_INSTANCE_COMMAND]);
await seedInstanceMigration(context.dataSource, {
name: MIDDLE_INSTANCE_COMMAND.name,
status: 'completed',
});
await seedInstanceMigration(context.dataSource, {
name: NEWEST_INSTANCE_COMMAND.name,
status: 'failed',
});
await expect(
context.upgradeStatusService.getInstanceCompletedVersion(),
).resolves.toBe(MIDDLE_VERSION);
});
it('should credit a version once its failed command is retried successfully', async () => {
mockSequence([MIDDLE_INSTANCE_COMMAND, NEWEST_INSTANCE_COMMAND]);
await seedInstanceMigration(context.dataSource, {
name: MIDDLE_INSTANCE_COMMAND.name,
status: 'completed',
});
await seedInstanceMigration(context.dataSource, {
name: NEWEST_INSTANCE_COMMAND.name,
status: 'failed',
});
await seedInstanceMigration(context.dataSource, {
name: NEWEST_INSTANCE_COMMAND.name,
status: 'completed',
attempt: 2,
});
await expect(
context.upgradeStatusService.getInstanceCompletedVersion(),
).resolves.toBe(NEWEST_VERSION);
});
it('should not credit a version while earlier instance commands of that version remain', async () => {
mockSequence([OLDEST_INSTANCE_COMMAND, OLDEST_SLOW_INSTANCE_COMMAND]);
await seedInstanceMigration(context.dataSource, {
name: OLDEST_INSTANCE_COMMAND.name,
status: 'completed',
});
await expect(
context.upgradeStatusService.getInstanceCompletedVersion(),
).resolves.toBeNull();
});
it('should report the version reached after the runner executes the whole sequence', async () => {
const sequence = [
OLDEST_INSTANCE_COMMAND,
MIDDLE_INSTANCE_COMMAND,
NEWEST_WORKSPACE_COMMAND,
];
mockSequence(sequence);
setMockActiveWorkspaceIds([WS_1]);
// The runner resumes from a cursor, so the sequence needs a starting point.
await seedInstanceMigration(context.dataSource, {
name: OLDEST_INSTANCE_COMMAND.name,
status: 'completed',
workspaceIds: [WS_1],
});
await context.runner.run({
sequence,
options: DEFAULT_OPTIONS,
});
await expect(
context.upgradeStatusService.getInstanceCompletedVersion(),
).resolves.toBe(NEWEST_VERSION);
});
});

View File

@@ -0,0 +1,196 @@
import {
type IntegrationTestContext,
createUpgradeSequenceRunnerIntegrationTestModule,
DEFAULT_OPTIONS,
makeFastInstance,
makeWorkspace,
migrationRecordToKey,
resetSeedSequenceCounter,
restoreUpgradeMigrations,
seedInstanceMigration,
setMockActiveWorkspaceIds,
snapshotUpgradeMigrations,
testGetExecutedMigrationsInOrder,
WS_1,
WS_2,
WS_3,
WS_4,
} from 'test/integration/upgrade/utils/upgrade-sequence-runner-integration-test.util';
// Sorted ASC: WS_1 < WS_2 < WS_3 < WS_4
describe('UpgradeSequenceRunnerService — startFromWorkspaceId (integration)', () => {
let context: IntegrationTestContext;
let savedUpgradeMigrations: Awaited<
ReturnType<typeof snapshotUpgradeMigrations>
>;
beforeAll(async () => {
context = await createUpgradeSequenceRunnerIntegrationTestModule();
savedUpgradeMigrations = await snapshotUpgradeMigrations(
context.dataSource,
);
}, 30000);
afterAll(async () => {
await restoreUpgradeMigrations(context.dataSource, savedUpgradeMigrations);
await context.module?.close();
await context.dataSource?.destroy();
}, 15000);
beforeEach(async () => {
await context.dataSource.query('DELETE FROM core."upgradeMigration"');
resetSeedSequenceCounter();
setMockActiveWorkspaceIds([]);
jest.restoreAllMocks();
});
it('should only process workspaces whose id >= startFromWorkspaceId', async () => {
const sequence = [
makeFastInstance('Ic1'),
makeWorkspace('Wc1'),
makeWorkspace('Wc2'),
makeFastInstance('Ic2'),
];
setMockActiveWorkspaceIds([WS_1, WS_2, WS_3]);
await seedInstanceMigration(context.dataSource, {
name: 'Ic1',
status: 'completed',
workspaceIds: [WS_1, WS_2, WS_3],
});
const report = await context.runner.run({
sequence,
options: {
...DEFAULT_OPTIONS,
startFromWorkspaceId: WS_2,
},
});
expect(report.totalFailures).toBe(0);
expect(report.totalSuccesses).toBe(2);
const executed = await testGetExecutedMigrationsInOrder(context.dataSource);
expect(executed.map(migrationRecordToKey)).toStrictEqual([
'Ic1:instance:completed:1',
`Ic1:${WS_1}:completed:1`,
`Ic1:${WS_2}:completed:1`,
`Ic1:${WS_3}:completed:1`,
// Only WS_2 and WS_3 are processed (WS_1 skipped)
`Wc1:${WS_2}:completed:1`,
`Wc2:${WS_2}:completed:1`,
`Wc1:${WS_3}:completed:1`,
`Wc2:${WS_3}:completed:1`,
]);
});
it('should respect workspaceCountLimit together with startFromWorkspaceId', async () => {
const sequence = [makeFastInstance('Ic1'), makeWorkspace('Wc1')];
setMockActiveWorkspaceIds([WS_1, WS_2, WS_3, WS_4]);
await seedInstanceMigration(context.dataSource, {
name: 'Ic1',
status: 'completed',
workspaceIds: [WS_1, WS_2, WS_3, WS_4],
});
const report = await context.runner.run({
sequence,
options: {
...DEFAULT_OPTIONS,
startFromWorkspaceId: WS_2,
workspaceCountLimit: 1,
},
});
expect(report.totalFailures).toBe(0);
expect(report.totalSuccesses).toBe(1);
const executed = await testGetExecutedMigrationsInOrder(context.dataSource);
expect(executed.map(migrationRecordToKey)).toStrictEqual([
'Ic1:instance:completed:1',
`Ic1:${WS_1}:completed:1`,
`Ic1:${WS_2}:completed:1`,
`Ic1:${WS_3}:completed:1`,
`Ic1:${WS_4}:completed:1`,
// Only WS_2 processed (first after startFrom, limited to 1)
`Wc1:${WS_2}:completed:1`,
]);
});
it('should stop before instance step when startFromWorkspaceId is set', async () => {
const sequence = [
makeFastInstance('Ic1'),
makeWorkspace('Wc1'),
makeWorkspace('Wc2'),
makeFastInstance('Ic2'),
makeWorkspace('Wc3'),
];
setMockActiveWorkspaceIds([WS_1, WS_2, WS_3]);
await seedInstanceMigration(context.dataSource, {
name: 'Ic1',
status: 'completed',
workspaceIds: [WS_1, WS_2, WS_3],
});
const report = await context.runner.run({
sequence,
options: {
...DEFAULT_OPTIONS,
startFromWorkspaceId: WS_2,
},
});
expect(report.totalFailures).toBe(0);
expect(report.totalSuccesses).toBe(2);
const executed = await testGetExecutedMigrationsInOrder(context.dataSource);
expect(executed.map(migrationRecordToKey)).toStrictEqual([
'Ic1:instance:completed:1',
`Ic1:${WS_1}:completed:1`,
`Ic1:${WS_2}:completed:1`,
`Ic1:${WS_3}:completed:1`,
// Workspace segment: only WS_2 and WS_3 run
`Wc1:${WS_2}:completed:1`,
`Wc2:${WS_2}:completed:1`,
`Wc1:${WS_3}:completed:1`,
`Wc2:${WS_3}:completed:1`,
// Ic2 and Wc3 never reached — runner stopped at instance step boundary
]);
});
it('should process no workspaces when startFromWorkspaceId is greater than all ids', async () => {
const sequence = [makeFastInstance('Ic1'), makeWorkspace('Wc1')];
setMockActiveWorkspaceIds([WS_1, WS_2]);
await seedInstanceMigration(context.dataSource, {
name: 'Ic1',
status: 'completed',
workspaceIds: [WS_1, WS_2],
});
const report = await context.runner.run({
sequence,
options: {
...DEFAULT_OPTIONS,
startFromWorkspaceId: 'ffffffff-ffff-ffff-ffff-ffffffffffff',
},
});
expect(report.totalFailures).toBe(0);
expect(report.totalSuccesses).toBe(0);
});
});

View File

@@ -0,0 +1,517 @@
import { InstanceCommandRunnerService } from 'src/engine/core-modules/upgrade/services/instance-command-runner.service';
import {
type IntegrationTestContext,
createUpgradeSequenceRunnerIntegrationTestModule,
DEFAULT_OPTIONS,
makeFastInstance,
makeSlowInstance,
makeWorkspace,
migrationRecordToKey,
resetSeedSequenceCounter,
restoreUpgradeMigrations,
seedInstanceMigration,
seedWorkspaceMigration,
setMockActiveWorkspaceIds,
snapshotUpgradeMigrations,
testGetExecutedMigrationsInOrder,
WS_1,
WS_2,
} from 'test/integration/upgrade/utils/upgrade-sequence-runner-integration-test.util';
describe('UpgradeSequenceRunnerService — execution (integration)', () => {
let context: IntegrationTestContext;
let savedUpgradeMigrations: Awaited<
ReturnType<typeof snapshotUpgradeMigrations>
>;
beforeAll(async () => {
context = await createUpgradeSequenceRunnerIntegrationTestModule();
savedUpgradeMigrations = await snapshotUpgradeMigrations(
context.dataSource,
);
}, 30000);
afterAll(async () => {
await restoreUpgradeMigrations(context.dataSource, savedUpgradeMigrations);
await context.module?.close();
await context.dataSource?.destroy();
}, 15000);
beforeEach(async () => {
await context.dataSource.query('DELETE FROM core."upgradeMigration"');
resetSeedSequenceCounter();
setMockActiveWorkspaceIds([]);
jest.restoreAllMocks();
});
it('should return zero counts for an empty sequence', async () => {
const report = await context.runner.run({
sequence: [],
options: DEFAULT_OPTIONS,
});
expect(report).toEqual({ totalSuccesses: 0, totalFailures: 0 });
});
it('should resume from a completed instance command and run remaining steps', async () => {
const sequence = [
makeFastInstance('Ic1'),
makeFastInstance('Ic2'),
makeSlowInstance('Ic3'),
];
await seedInstanceMigration(context.dataSource, {
name: 'Ic1',
status: 'completed',
});
await seedInstanceMigration(context.dataSource, {
name: 'Ic2',
status: 'completed',
});
await context.runner.run({
sequence,
options: DEFAULT_OPTIONS,
});
const executed = await testGetExecutedMigrationsInOrder(context.dataSource);
expect(executed.map(migrationRecordToKey)).toStrictEqual([
'Ic1:instance:completed:1',
'Ic2:instance:completed:1',
// Runner resumes after Ic2, runs Ic3
'Ic3:instance:completed:1',
]);
});
it('should retry a failed instance command', async () => {
const sequence = [makeFastInstance('Ic1'), makeFastInstance('Ic2')];
await seedInstanceMigration(context.dataSource, {
name: 'Ic1',
status: 'completed',
});
await seedInstanceMigration(context.dataSource, {
name: 'Ic2',
status: 'failed',
});
await context.runner.run({
sequence,
options: DEFAULT_OPTIONS,
});
const executed = await testGetExecutedMigrationsInOrder(context.dataSource);
expect(executed.map(migrationRecordToKey)).toStrictEqual([
'Ic1:instance:completed:1',
'Ic2:instance:failed:1',
// Runner retries Ic2
'Ic2:instance:completed:2',
]);
});
it('should resume workspace commands from per-workspace cursors', async () => {
const sequence = [
makeFastInstance('Ic1'),
makeWorkspace('Wc1'),
makeWorkspace('Wc2'),
];
setMockActiveWorkspaceIds([WS_1]);
await seedInstanceMigration(context.dataSource, {
name: 'Ic1',
status: 'completed',
workspaceIds: [WS_1],
});
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc1',
status: 'completed',
workspaceId: WS_1,
});
await context.runner.run({
sequence,
options: DEFAULT_OPTIONS,
});
const executed = await testGetExecutedMigrationsInOrder(context.dataSource);
expect(executed.map(migrationRecordToKey)).toStrictEqual([
'Ic1:instance:completed:1',
`Ic1:${WS_1}:completed:1`,
`Wc1:${WS_1}:completed:1`,
// Runner resumes after Wc1, runs Wc2
`Wc2:${WS_1}:completed:1`,
]);
});
it('should enforce workspace sync barrier before instance step', async () => {
const sequence = [makeWorkspace('Wc1'), makeFastInstance('Ic1')];
setMockActiveWorkspaceIds([WS_1]);
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc1',
status: 'completed',
workspaceId: WS_1,
});
await context.runner.run({
sequence,
options: DEFAULT_OPTIONS,
});
const executed = await testGetExecutedMigrationsInOrder(context.dataSource);
expect(executed.map(migrationRecordToKey)).toStrictEqual([
`Wc1:${WS_1}:completed:1`,
// Barrier passes, runner executes Ic1
'Ic1:instance:completed:1',
`Ic1:${WS_1}:completed:1`,
]);
});
it('should skip data migration for slow instance commands when no workspaces exist', async () => {
const sequence = [makeFastInstance('Ic1'), makeSlowInstance('Ic2')];
await seedInstanceMigration(context.dataSource, {
name: 'Ic1',
status: 'completed',
});
const instanceCommandRunnerService = context.module.get(
InstanceCommandRunnerService,
);
const spy = jest.spyOn(
instanceCommandRunnerService,
'runSlowInstanceCommand',
);
await context.runner.run({
sequence,
options: DEFAULT_OPTIONS,
});
expect(spy).toHaveBeenCalledWith(
expect.objectContaining({ skipDataMigration: true }),
);
const executed = await testGetExecutedMigrationsInOrder(context.dataSource);
expect(executed.map(migrationRecordToKey)).toStrictEqual([
'Ic1:instance:completed:1',
// Runner runs Ic2 (slow, no workspaces → skip data migration)
'Ic2:instance:completed:1',
]);
});
it('should run data migration for slow instance commands when workspaces exist', async () => {
const sequence = [makeFastInstance('Ic0'), makeSlowInstance('Ic1')];
setMockActiveWorkspaceIds([WS_1]);
await seedInstanceMigration(context.dataSource, {
name: 'Ic0',
status: 'completed',
workspaceIds: [WS_1],
});
const instanceCommandRunnerService = context.module.get(
InstanceCommandRunnerService,
);
const spy = jest.spyOn(
instanceCommandRunnerService,
'runSlowInstanceCommand',
);
await context.runner.run({
sequence,
options: DEFAULT_OPTIONS,
});
expect(spy).toHaveBeenCalledWith(
expect.objectContaining({ skipDataMigration: false }),
);
const executed = await testGetExecutedMigrationsInOrder(context.dataSource);
expect(executed.map(migrationRecordToKey)).toStrictEqual([
'Ic0:instance:completed:1',
`Ic0:${WS_1}:completed:1`,
// Runner runs Ic1 (slow, workspaces exist → run data migration)
'Ic1:instance:completed:1',
`Ic1:${WS_1}:completed:1`,
]);
});
it('should run workspace commands for multiple workspaces successfully', async () => {
const sequence = [
makeFastInstance('Ic1'),
makeWorkspace('Wc1'),
makeWorkspace('Wc2'),
];
setMockActiveWorkspaceIds([WS_1, WS_2]);
await seedInstanceMigration(context.dataSource, {
name: 'Ic1',
status: 'completed',
workspaceIds: [WS_1, WS_2],
});
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc1',
status: 'completed',
workspaceId: WS_1,
});
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc1',
status: 'completed',
workspaceId: WS_2,
});
const report = await context.runner.run({
sequence,
options: {
...DEFAULT_OPTIONS,
workspaceIds: [WS_1, WS_2],
},
});
expect(report).toEqual({ totalSuccesses: 2, totalFailures: 0 });
const executed = await testGetExecutedMigrationsInOrder(context.dataSource);
expect(executed.map(migrationRecordToKey)).toStrictEqual([
'Ic1:instance:completed:1',
`Ic1:${WS_1}:completed:1`,
`Ic1:${WS_2}:completed:1`,
`Wc1:${WS_1}:completed:1`,
`Wc1:${WS_2}:completed:1`,
// Both workspaces run Wc2
`Wc2:${WS_1}:completed:1`,
`Wc2:${WS_2}:completed:1`,
]);
});
it('should execute the full sequence from the initial cursor on a fresh run', async () => {
const sequence = [
makeWorkspace('Wc0'),
makeFastInstance('Ic1'),
makeFastInstance('Ic2'),
makeWorkspace('Wc1'),
];
setMockActiveWorkspaceIds([WS_1]);
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc0',
status: 'completed',
workspaceId: WS_1,
});
await seedInstanceMigration(context.dataSource, {
name: 'Ic1',
status: 'completed',
workspaceIds: [WS_1],
});
await context.runner.run({
sequence,
options: DEFAULT_OPTIONS,
});
const executed = await testGetExecutedMigrationsInOrder(context.dataSource);
expect(executed.map(migrationRecordToKey)).toStrictEqual([
`Wc0:${WS_1}:completed:1`,
'Ic1:instance:completed:1',
`Ic1:${WS_1}:completed:1`,
// Runner resumes after Ic1, runs Ic2 then Wc1
'Ic2:instance:completed:1',
`Ic2:${WS_1}:completed:1`,
`Wc1:${WS_1}:completed:1`,
]);
});
it('should retry a failed workspace command', async () => {
const sequence = [
makeFastInstance('Ic1'),
makeWorkspace('Wc1'),
makeWorkspace('Wc2'),
];
setMockActiveWorkspaceIds([WS_1]);
await seedInstanceMigration(context.dataSource, {
name: 'Ic1',
status: 'completed',
workspaceIds: [WS_1],
});
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc1',
status: 'failed',
workspaceId: WS_1,
});
const report = await context.runner.run({
sequence,
options: DEFAULT_OPTIONS,
});
expect(report.totalFailures).toBe(0);
const executed = await testGetExecutedMigrationsInOrder(context.dataSource);
expect(executed.map(migrationRecordToKey)).toStrictEqual([
'Ic1:instance:completed:1',
`Ic1:${WS_1}:completed:1`,
`Wc1:${WS_1}:failed:1`,
// Runner retries Wc1, then runs Wc2
`Wc1:${WS_1}:completed:2`,
`Wc2:${WS_1}:completed:1`,
]);
});
it('should traverse a multi-segment sequence with sync barriers', async () => {
const sequence = [
makeFastInstance('Ic1'),
makeWorkspace('Wc1'),
makeFastInstance('Ic2'),
makeWorkspace('Wc2'),
];
setMockActiveWorkspaceIds([WS_1]);
await seedInstanceMigration(context.dataSource, {
name: 'Ic1',
status: 'completed',
workspaceIds: [WS_1],
});
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc1',
status: 'completed',
workspaceId: WS_1,
});
const report = await context.runner.run({
sequence,
options: DEFAULT_OPTIONS,
});
expect(report.totalFailures).toBe(0);
const executed = await testGetExecutedMigrationsInOrder(context.dataSource);
expect(executed.map(migrationRecordToKey)).toStrictEqual([
'Ic1:instance:completed:1',
`Ic1:${WS_1}:completed:1`,
`Wc1:${WS_1}:completed:1`,
// Sync barrier → Ic2, then Wc2
'Ic2:instance:completed:1',
`Ic2:${WS_1}:completed:1`,
`Wc2:${WS_1}:completed:1`,
]);
});
it('should stop before the next instance step when running with workspace filter (-w)', async () => {
const sequence = [
makeFastInstance('Ic1'),
makeWorkspace('Wc1'),
makeWorkspace('Wc2'),
makeFastInstance('Ic2'),
makeWorkspace('Wc3'),
];
setMockActiveWorkspaceIds([WS_1, WS_2]);
await seedInstanceMigration(context.dataSource, {
name: 'Ic1',
status: 'completed',
workspaceIds: [WS_1, WS_2],
});
const report = await context.runner.run({
sequence,
options: {
...DEFAULT_OPTIONS,
workspaceIds: [WS_1],
},
});
expect(report.totalFailures).toBe(0);
expect(report.totalSuccesses).toBe(1);
const executed = await testGetExecutedMigrationsInOrder(context.dataSource);
expect(executed.map(migrationRecordToKey)).toStrictEqual([
'Ic1:instance:completed:1',
`Ic1:${WS_1}:completed:1`,
`Ic1:${WS_2}:completed:1`,
// Workspace segment: only WS_1 runs (filtered with -w)
`Wc1:${WS_1}:completed:1`,
`Wc2:${WS_1}:completed:1`,
// Ic2 and Wc3 never reached — runner stopped at instance step boundary
]);
});
it('should ignore migration records from inactive workspaces when resolving the global cursor', async () => {
const sequence = [
makeFastInstance('Ic1'),
makeWorkspace('Wc1'),
makeWorkspace('Wc2'),
];
setMockActiveWorkspaceIds([WS_1]);
await seedInstanceMigration(context.dataSource, {
name: 'Ic1',
status: 'completed',
workspaceIds: [WS_1],
});
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc1',
status: 'completed',
workspaceId: WS_1,
});
// WS_2 is inactive — its record is more recent (seeded later)
// but should not influence the global cursor
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc2',
status: 'completed',
workspaceId: WS_2,
});
const report = await context.runner.run({
sequence,
options: DEFAULT_OPTIONS,
});
expect(report.totalFailures).toBe(0);
const executed = await testGetExecutedMigrationsInOrder(context.dataSource);
expect(executed.map(migrationRecordToKey)).toStrictEqual([
'Ic1:instance:completed:1',
`Ic1:${WS_1}:completed:1`,
`Wc1:${WS_1}:completed:1`,
`Wc2:${WS_2}:completed:1`,
// WS_2 is inactive, runner only processes WS_1
`Wc2:${WS_1}:completed:1`,
]);
});
});

View File

@@ -0,0 +1,585 @@
import {
type IntegrationTestContext,
createUpgradeSequenceRunnerIntegrationTestModule,
DEFAULT_OPTIONS,
makeFastInstance,
makeSlowInstance,
makeWorkspace,
migrationRecordToKey,
resetSeedSequenceCounter,
restoreUpgradeMigrations,
seedInstanceMigration,
seedWorkspaceMigration,
setMockActiveWorkspaceIds,
snapshotUpgradeMigrations,
testGetExecutedMigrationsInOrder,
WS_1,
WS_2,
WS_3,
} from 'test/integration/upgrade/utils/upgrade-sequence-runner-integration-test.util';
describe('UpgradeSequenceRunnerService — workspace segment alignment (integration)', () => {
let context: IntegrationTestContext;
let savedUpgradeMigrations: Awaited<
ReturnType<typeof snapshotUpgradeMigrations>
>;
beforeAll(async () => {
context = await createUpgradeSequenceRunnerIntegrationTestModule();
savedUpgradeMigrations = await snapshotUpgradeMigrations(
context.dataSource,
);
}, 30000);
afterAll(async () => {
await restoreUpgradeMigrations(context.dataSource, savedUpgradeMigrations);
await context.module?.close();
await context.dataSource?.destroy();
}, 15000);
beforeEach(async () => {
await context.dataSource.query('DELETE FROM core."upgradeMigration"');
resetSeedSequenceCounter();
setMockActiveWorkspaceIds([]);
jest.restoreAllMocks();
});
it('should upgrade all workspaces when they are in the same segment at different positions', async () => {
// Sequence: Ic0 → Wc0 → Wc1 → Wc2 → Ic1 → Ic2 → Wc3 → Wc4
const sequence = [
makeFastInstance('Ic0'),
makeWorkspace('Wc0'),
makeWorkspace('Wc1'),
makeWorkspace('Wc2'),
makeFastInstance('Ic1'),
makeSlowInstance('Ic2'),
makeWorkspace('Wc3'),
makeWorkspace('Wc4'),
];
setMockActiveWorkspaceIds([WS_1, WS_2, WS_3]);
await seedInstanceMigration(context.dataSource, {
name: 'Ic0',
status: 'completed',
workspaceIds: [WS_1, WS_2, WS_3],
});
// WS_1: at Wc0, completed
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc0',
status: 'completed',
workspaceId: WS_1,
});
// WS_2: at Wc1, failed (needs retry)
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc0',
status: 'completed',
workspaceId: WS_2,
});
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc1',
status: 'failed',
workspaceId: WS_2,
});
// WS_3: at Wc2, completed (most advanced in segment)
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc0',
status: 'completed',
workspaceId: WS_3,
});
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc1',
status: 'completed',
workspaceId: WS_3,
});
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc2',
status: 'completed',
workspaceId: WS_3,
});
const report = await context.runner.run({
sequence,
options: DEFAULT_OPTIONS,
});
expect(report.totalFailures).toBe(0);
const executed = await testGetExecutedMigrationsInOrder(context.dataSource);
expect(executed.map(migrationRecordToKey)).toStrictEqual([
// Seeds (Ic0 global + workspace rows inserted at same timestamp)
'Ic0:instance:completed:1',
`Ic0:${WS_1}:completed:1`,
`Ic0:${WS_2}:completed:1`,
`Ic0:${WS_3}:completed:1`,
`Wc0:${WS_1}:completed:1`,
`Wc0:${WS_2}:completed:1`,
`Wc1:${WS_2}:failed:1`,
`Wc0:${WS_3}:completed:1`,
`Wc1:${WS_3}:completed:1`,
`Wc2:${WS_3}:completed:1`,
// Segment A: WS_1 runs Wc1, Wc2; WS_2 retries Wc1, runs Wc2; WS_3 already done
`Wc1:${WS_1}:completed:1`,
`Wc2:${WS_1}:completed:1`,
`Wc1:${WS_2}:completed:2`,
`Wc2:${WS_2}:completed:1`,
// Sync barrier → instance steps (with workspace-level rows)
'Ic1:instance:completed:1',
`Ic1:${WS_1}:completed:1`,
`Ic1:${WS_2}:completed:1`,
`Ic1:${WS_3}:completed:1`,
'Ic2:instance:completed:1',
`Ic2:${WS_1}:completed:1`,
`Ic2:${WS_2}:completed:1`,
`Ic2:${WS_3}:completed:1`,
// Segment B: all workspaces run Wc3, Wc4
`Wc3:${WS_1}:completed:1`,
`Wc4:${WS_1}:completed:1`,
`Wc3:${WS_2}:completed:1`,
`Wc4:${WS_2}:completed:1`,
`Wc3:${WS_3}:completed:1`,
`Wc4:${WS_3}:completed:1`,
]);
});
it('should reject workspaces with cursors ahead of the current segment', async () => {
// Sequence: Ic0 → Wc0 → Wc1 → Ic1 → Wc2 → Wc3
const sequence = [
makeFastInstance('Ic0'),
makeWorkspace('Wc0'),
makeWorkspace('Wc1'),
makeFastInstance('Ic1'),
makeWorkspace('Wc2'),
makeWorkspace('Wc3'),
];
setMockActiveWorkspaceIds([WS_1, WS_2]);
// WS_1 existed when Ic0 ran; WS_2 was created later (initial at Wc2)
await seedInstanceMigration(context.dataSource, {
name: 'Ic0',
status: 'completed',
workspaceIds: [WS_1],
});
// WS_1: at Wc0 (in segment A) - correct
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc0',
status: 'completed',
workspaceId: WS_1,
});
// WS_2: at Wc2 (in segment B) - WRONG! Ahead of current segment
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc2',
status: 'completed',
workspaceId: WS_2,
isInitial: true,
});
await expect(
context.runner.run({
sequence,
options: {
...DEFAULT_OPTIONS,
workspaceIds: [WS_1, WS_2],
},
}),
).rejects.toThrow(
/workspace\(s\) have invalid cursors for workspace segment/,
);
});
it('should reject workspaces with cursors behind the current segment', async () => {
// Sequence: Wc-1 → Ic0 → Wc0 → Wc1 → Ic1 → Wc2
const sequence = [
makeWorkspace('Wc-1'),
makeFastInstance('Ic0'),
makeWorkspace('Wc0'),
makeWorkspace('Wc1'),
makeFastInstance('Ic1'),
makeWorkspace('Wc2'),
];
setMockActiveWorkspaceIds([WS_1, WS_2]);
await seedInstanceMigration(context.dataSource, {
name: 'Ic0',
status: 'completed',
workspaceIds: [WS_1, WS_2],
});
// WS_1: at Wc0 (in segment after Ic0) - correct
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc0',
status: 'completed',
workspaceId: WS_1,
});
// WS_2: at Wc-1 (in segment before Ic0) - WRONG! Behind current segment
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc-1',
status: 'completed',
workspaceId: WS_2,
});
await expect(
context.runner.run({
sequence,
options: {
...DEFAULT_OPTIONS,
workspaceIds: [WS_1, WS_2],
},
}),
).rejects.toThrow(
/workspace\(s\) have invalid cursors for workspace segment/,
);
});
it('should handle workspaces created at correct segment via isInitial', async () => {
// Sequence: Wc0 → Ic0 → Ic1 → Wc1 → Wc2
// WS_1 existed from the start, WS_2 was created after Ic1
const sequence = [
makeWorkspace('Wc0'),
makeFastInstance('Ic0'),
makeFastInstance('Ic1'),
makeWorkspace('Wc1'),
makeWorkspace('Wc2'),
];
setMockActiveWorkspaceIds([WS_1, WS_2]);
await seedInstanceMigration(context.dataSource, {
name: 'Ic0',
status: 'completed',
workspaceIds: [WS_1],
});
await seedInstanceMigration(context.dataSource, {
name: 'Ic1',
status: 'completed',
workspaceIds: [WS_1],
});
// WS_1: at Wc1 (in correct segment after Ic1)
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc1',
status: 'completed',
workspaceId: WS_1,
});
// WS_2: newly created with isInitial at Wc2 (correct segment after Ic1)
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc2',
status: 'completed',
workspaceId: WS_2,
isInitial: true,
});
const report = await context.runner.run({
sequence,
options: {
...DEFAULT_OPTIONS,
workspaceIds: [WS_1, WS_2],
},
});
expect(report.totalFailures).toBe(0);
const executed = await testGetExecutedMigrationsInOrder(context.dataSource);
expect(executed.map(migrationRecordToKey)).toStrictEqual([
// Seeds
'Ic0:instance:completed:1',
`Ic0:${WS_1}:completed:1`,
'Ic1:instance:completed:1',
`Ic1:${WS_1}:completed:1`,
`Wc1:${WS_1}:completed:1`,
`Wc2:${WS_2}:completed:1:initial`,
// WS_1 runs Wc2, WS_2 already at Wc2 (no new commands needed)
`Wc2:${WS_1}:completed:1`,
]);
});
it('should accept workspaces at the preceding completed IC when others are in the WC segment (-w scenario)', async () => {
// Sequence: Ic0 → Ic1 → Wc0 → Wc1
// WS_1 was upgraded via -w and is at Wc1:completed
// WS_2 is still at Ic1:completed (preceding IC)
const sequence = [
makeFastInstance('Ic0'),
makeFastInstance('Ic1'),
makeWorkspace('Wc0'),
makeWorkspace('Wc1'),
];
setMockActiveWorkspaceIds([WS_1, WS_2]);
await seedInstanceMigration(context.dataSource, {
name: 'Ic0',
status: 'completed',
workspaceIds: [WS_1, WS_2],
});
await seedInstanceMigration(context.dataSource, {
name: 'Ic1',
status: 'completed',
workspaceIds: [WS_1, WS_2],
});
// WS_1 was upgraded ahead via -w
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc0',
status: 'completed',
workspaceId: WS_1,
});
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc1',
status: 'completed',
workspaceId: WS_1,
});
const report = await context.runner.run({
sequence,
options: {
...DEFAULT_OPTIONS,
workspaceIds: [WS_1, WS_2],
},
});
expect(report.totalFailures).toBe(0);
const executed = await testGetExecutedMigrationsInOrder(context.dataSource);
expect(executed.map(migrationRecordToKey)).toStrictEqual([
// Seeds
'Ic0:instance:completed:1',
`Ic0:${WS_1}:completed:1`,
`Ic0:${WS_2}:completed:1`,
'Ic1:instance:completed:1',
`Ic1:${WS_1}:completed:1`,
`Ic1:${WS_2}:completed:1`,
`Wc0:${WS_1}:completed:1`,
`Wc1:${WS_1}:completed:1`,
// WS_2 runs full segment, WS_1 already done
`Wc0:${WS_2}:completed:1`,
`Wc1:${WS_2}:completed:1`,
]);
});
it('should reject workspace at preceding IC with failed status', async () => {
// Sequence: Ic0 → Wc0 → Wc1
// WS_1 is in the WC segment, WS_2 is at Ic0:failed
const sequence = [
makeFastInstance('Ic0'),
makeWorkspace('Wc0'),
makeWorkspace('Wc1'),
];
setMockActiveWorkspaceIds([WS_1, WS_2]);
await seedInstanceMigration(context.dataSource, {
name: 'Ic0',
status: 'completed',
workspaceIds: [WS_1],
});
// WS_2 at Ic0:failed — should be rejected
await seedWorkspaceMigration(context.dataSource, {
name: 'Ic0',
status: 'failed',
workspaceId: WS_2,
});
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc0',
status: 'completed',
workspaceId: WS_1,
});
await expect(
context.runner.run({
sequence,
options: {
...DEFAULT_OPTIONS,
workspaceIds: [WS_1, WS_2],
},
}),
).rejects.toThrow(
/workspace\(s\) have invalid cursors for workspace segment/,
);
});
it('should reject workspace stuck in a previous WC segment (corrupted state)', async () => {
// Sequence: Wc0 → Wc1 → Ic0 → Wc2 → Wc3
// WS_1 is in segment [Wc2..Wc3], WS_2 is stuck at Wc0 (previous WC segment)
const sequence = [
makeWorkspace('Wc0'),
makeWorkspace('Wc1'),
makeFastInstance('Ic0'),
makeWorkspace('Wc2'),
makeWorkspace('Wc3'),
];
setMockActiveWorkspaceIds([WS_1, WS_2]);
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc0',
status: 'completed',
workspaceId: WS_1,
});
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc1',
status: 'completed',
workspaceId: WS_1,
});
await seedInstanceMigration(context.dataSource, {
name: 'Ic0',
status: 'completed',
workspaceIds: [WS_1],
});
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc2',
status: 'completed',
workspaceId: WS_1,
});
// WS_2 stuck at Wc0 in previous WC segment — corrupted
await seedWorkspaceMigration(context.dataSource, {
name: 'Wc0',
status: 'completed',
workspaceId: WS_2,
});
await expect(
context.runner.run({
sequence,
options: {
...DEFAULT_OPTIONS,
workspaceIds: [WS_1, WS_2],
},
}),
).rejects.toThrow(
/workspace\(s\) have invalid cursors for workspace segment/,
);
});
it('should write workspace rows for IC failure, accept workspace created mid-failure, and succeed on restart', async () => {
// Sequence: Ic0 → Ic1 → Wc0 → Wc1
// First run: Ic1 fails → global + workspace rows written as failed
// WS_3 is created while Ic1 is failed → initial cursor at Ic1:failed
// Second run: Ic1 retries and succeeds → WC segment runs for all 3 workspaces
const failOnce = { shouldFail: true };
const ic1Command = {
up: async () => {
if (failOnce.shouldFail) {
failOnce.shouldFail = false;
throw new Error('Ic1 temporary failure');
}
},
down: async () => {},
};
const sequence = [
makeFastInstance('Ic0'),
{
...makeFastInstance('Ic1'),
command: ic1Command,
} as unknown as ReturnType<typeof makeFastInstance>,
makeWorkspace('Wc0'),
makeWorkspace('Wc1'),
];
setMockActiveWorkspaceIds([WS_1, WS_2]);
await seedInstanceMigration(context.dataSource, {
name: 'Ic0',
status: 'completed',
workspaceIds: [WS_1, WS_2],
});
// First run — Ic1 fails
await expect(
context.runner.run({
sequence,
options: DEFAULT_OPTIONS,
}),
).rejects.toThrow('Ic1 temporary failure');
const afterFailure = await testGetExecutedMigrationsInOrder(
context.dataSource,
);
expect(afterFailure.map(migrationRecordToKey)).toStrictEqual([
// Seeds
'Ic0:instance:completed:1',
`Ic0:${WS_1}:completed:1`,
`Ic0:${WS_2}:completed:1`,
// Ic1 failed globally + workspace rows
'Ic1:instance:failed:1',
`Ic1:${WS_1}:failed:1`,
`Ic1:${WS_2}:failed:1`,
]);
// WS_3 created while Ic1 is failed —
// getInitialCursorForNewWorkspace returns { name: 'Ic1', status: 'failed' }
await seedWorkspaceMigration(context.dataSource, {
name: 'Ic1',
status: 'failed',
workspaceId: WS_3,
isInitial: true,
useCurrentTimestamp: true,
});
setMockActiveWorkspaceIds([WS_1, WS_2, WS_3]);
// Second run — Ic1 retries and succeeds, then WC segment runs for all 3
const report = await context.runner.run({
sequence,
options: DEFAULT_OPTIONS,
});
expect(report.totalFailures).toBe(0);
const afterRetry = await testGetExecutedMigrationsInOrder(
context.dataSource,
);
expect(afterRetry.map(migrationRecordToKey)).toStrictEqual([
// Seeds
'Ic0:instance:completed:1',
`Ic0:${WS_1}:completed:1`,
`Ic0:${WS_2}:completed:1`,
// First run — Ic1 failed
'Ic1:instance:failed:1',
`Ic1:${WS_1}:failed:1`,
`Ic1:${WS_2}:failed:1`,
// WS_3 created after Ic1 failure, initial cursor at Ic1:failed
`Ic1:${WS_3}:failed:1:initial`,
// Second run — Ic1 retry succeeds
'Ic1:instance:completed:2',
`Ic1:${WS_1}:completed:2`,
`Ic1:${WS_2}:completed:2`,
`Ic1:${WS_3}:completed:2`,
// WC segment runs for all 3 workspaces
`Wc0:${WS_1}:completed:1`,
`Wc1:${WS_1}:completed:1`,
`Wc0:${WS_2}:completed:1`,
`Wc1:${WS_2}:completed:1`,
`Wc0:${WS_3}:completed:1`,
`Wc1:${WS_3}:completed:1`,
]);
});
});

View File

@@ -0,0 +1,21 @@
import { isNonEmptyString } from '@sniptt/guards';
import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service';
import { type EnvironmentConfigDriver } from 'src/engine/core-modules/twenty-config/drivers/environment-config.driver';
export const buildSecretEncryptionServiceFromEnv =
(): SecretEncryptionService => {
const appSecret = process.env.APP_SECRET;
if (!isNonEmptyString(appSecret)) {
throw new Error(
'APP_SECRET must be set in the integration test environment to run encryption backfill suites.',
);
}
const driver = {
get: (key: string) => process.env[key],
} as unknown as EnvironmentConfigDriver;
return new SecretEncryptionService(driver);
};

View File

@@ -0,0 +1,514 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { getDataSourceToken, getRepositoryToken } from '@nestjs/typeorm';
import { config } from 'dotenv';
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import { DataSource, type Repository } from 'typeorm';
import { CommandShutdownService } from 'src/database/commands/command-runners/command-shutdown.service';
import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
import { CoreEntityCacheService } from 'src/engine/core-entity-cache/services/core-entity-cache.service';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { InstanceCommandRunnerService } from 'src/engine/core-modules/upgrade/services/instance-command-runner.service';
import { UpgradeMigrationService } from 'src/engine/core-modules/upgrade/services/upgrade-migration.service';
import {
UpgradeSequenceReaderService,
type UpgradeStep,
type WorkspaceUpgradeStep,
} from 'src/engine/core-modules/upgrade/services/upgrade-sequence-reader.service';
import { UpgradeSequenceRunnerService } from 'src/engine/core-modules/upgrade/services/upgrade-sequence-runner.service';
import { UpgradeStatusCacheService } from 'src/engine/core-modules/upgrade/services/upgrade-status-cache.service';
import { UpgradeStatusService } from 'src/engine/core-modules/upgrade/services/upgrade-status.service';
import { WorkspaceCommandRunnerService } from 'src/engine/core-modules/upgrade/services/workspace-command-runner.service';
import { UpgradeMigrationEntity } from 'src/engine/core-modules/upgrade/upgrade-migration.entity';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { UpgradeAwareEntityMetadataAdapter } from 'src/engine/twenty-orm/upgrade-aware/upgrade-aware-entity-metadata.adapter';
import {
SEED_APPLE_WORKSPACE_ID,
SEED_EMPTY_WORKSPACE_3_ID,
SEED_EMPTY_WORKSPACE_4_ID,
SEED_YCOMBINATOR_WORKSPACE_ID,
} from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
import { createWorkspace } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-workspace.util';
import { WorkspaceVersionService } from 'src/engine/workspace-manager/workspace-version/services/workspace-version.service';
jest.useRealTimers();
config({
path: process.env.NODE_ENV === 'test' ? '.env.test' : '.env',
override: true,
});
export const WS_1 = SEED_APPLE_WORKSPACE_ID;
export const WS_2 = SEED_YCOMBINATOR_WORKSPACE_ID;
export const WS_3 = SEED_EMPTY_WORKSPACE_3_ID;
export const WS_4 = SEED_EMPTY_WORKSPACE_4_ID;
const FK_WORKSPACE_FIXTURES = [
{
workspaceId: WS_3,
applicationId: 'f1c0ffee-0000-4000-8000-0000000000c3',
subdomain: 'upgrade-test-fixture-3',
},
{
workspaceId: WS_4,
applicationId: 'f1c0ffee-0000-4000-8000-0000000000c4',
subdomain: 'upgrade-test-fixture-4',
},
];
const seedEmptyWorkspaces = async (dataSource: DataSource) => {
const queryRunner = dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
for (const {
workspaceId,
applicationId,
subdomain,
} of FK_WORKSPACE_FIXTURES) {
await createWorkspace({
queryRunner,
schemaName: 'core',
createWorkspaceInput: {
id: workspaceId,
displayName: subdomain,
subdomain,
inviteHash: `${subdomain}.dev-invite-hash`,
logo: '',
activationStatus: WorkspaceActivationStatus.PENDING_CREATION,
isTwoFactorAuthenticationEnforced: false,
workspaceCustomApplicationId: applicationId,
},
});
await queryRunner.manager
.createQueryBuilder()
.insert()
.into(ApplicationEntity)
.values({
id: applicationId,
universalIdentifier: applicationId,
name: 'upgrade-test-fixture',
sourcePath: '',
workspaceId,
})
.orIgnore()
.execute();
}
await queryRunner.commitTransaction();
} catch (error) {
await queryRunner.rollbackTransaction();
throw error;
} finally {
await queryRunner.release();
}
};
const EXECUTED_BY_VERSION = '42.42.42';
const noopAsync = async () => {};
const DEFAULT_STEP_VERSION = '1.21.0';
export const makeStep = (
kind: UpgradeStep['kind'],
name: string,
version: string = DEFAULT_STEP_VERSION,
): UpgradeStep => {
const command =
kind === 'workspace'
? { runOnWorkspace: noopAsync }
: kind === 'slow-instance'
? { up: noopAsync, down: noopAsync, runDataMigration: noopAsync }
: { up: noopAsync, down: noopAsync };
return {
kind,
name,
command,
version,
timestamp: 0,
} as unknown as UpgradeStep;
};
export const makeFastInstance = (name: string, version?: string) =>
makeStep('fast-instance', name, version);
export const makeSlowInstance = (name: string, version?: string) =>
makeStep('slow-instance', name, version);
export const makeWorkspace = (name: string, version?: string) =>
makeStep('workspace', name, version) as WorkspaceUpgradeStep;
// Steps the status service can resolve a version from: it reads the version
// off the command name, not off the step's `version` field.
export const makeVersionedStep = (
kind: UpgradeStep['kind'],
{ version, label }: { version: string; label: string },
): UpgradeStep => makeStep(kind, `${version}_${label}_0`, version);
let mockActiveWorkspaceIds: string[] = [];
export const setMockActiveWorkspaceIds = (ids: string[]) => {
mockActiveWorkspaceIds = ids;
};
export const DEFAULT_OPTIONS = {
workspaceIds: undefined,
startFromWorkspaceId: undefined,
workspaceCountLimit: undefined,
dryRun: false,
verbose: false,
};
type IntegrationTestModule = Awaited<
ReturnType<typeof createUpgradeSequenceRunnerIntegrationTestModule>
>;
export type IntegrationTestContext = {
[K in keyof IntegrationTestModule]: IntegrationTestModule[K];
};
export const createUpgradeSequenceRunnerIntegrationTestModule = async () => {
const dataSource = new DataSource({
type: 'postgres',
url: process.env.PG_DATABASE_URL,
schema: 'core',
entities: [
'src/engine/core-modules/**/*.entity.ts',
'src/engine/metadata-modules/**/*.entity.ts',
],
synchronize: false,
});
await dataSource.initialize();
await seedEmptyWorkspaces(dataSource);
const migrationRepo: Repository<UpgradeMigrationEntity> =
dataSource.getRepository(UpgradeMigrationEntity);
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: getRepositoryToken(UpgradeMigrationEntity),
useValue: migrationRepo,
},
{
provide: getDataSourceToken(),
useValue: dataSource,
},
{
provide: TwentyConfigService,
useValue: {
get: (key: string) =>
key === 'APP_VERSION' ? EXECUTED_BY_VERSION : undefined,
},
},
UpgradeMigrationService,
{
provide: WorkspaceVersionService,
useValue: {
getProvisionedWorkspaceIds: jest
.fn()
.mockImplementation(async () => mockActiveWorkspaceIds),
hasProvisionedWorkspaces: jest
.fn()
.mockImplementation(async () => mockActiveWorkspaceIds.length > 0),
},
},
{
provide: UpgradeSequenceReaderService,
useFactory: () => new UpgradeSequenceReaderService({} as any),
},
{
provide: getRepositoryToken(WorkspaceEntity),
useValue: dataSource.getRepository(WorkspaceEntity),
},
{
provide: UpgradeStatusCacheService,
useValue: {
getComputedAt: jest.fn().mockResolvedValue(null),
getBehindWorkspaceIds: jest.fn().mockResolvedValue([]),
getFailedWorkspaceIds: jest.fn().mockResolvedValue([]),
getUpToDateWorkspaceCount: jest.fn().mockResolvedValue(0),
write: jest.fn().mockResolvedValue(undefined),
invalidate: jest.fn().mockResolvedValue(undefined),
},
},
{
provide: CoreEntityCacheService,
useValue: { get: jest.fn().mockResolvedValue(null) },
},
UpgradeStatusService,
InstanceCommandRunnerService,
WorkspaceCommandRunnerService,
{
provide: WorkspaceIteratorService,
useValue: {
iterate: jest.fn().mockImplementation(async (args: any) => {
const { callback, workspaceIds } = args;
const ids = workspaceIds ?? [WS_1];
const report = {
fail: [] as any[],
success: [] as any[],
interrupted: false,
};
for (const [index, workspaceId] of ids.entries()) {
try {
await callback({
workspaceId,
index,
total: ids.length,
dataSource,
});
report.success.push({ workspaceId });
} catch (error) {
report.fail.push({ error, workspaceId });
}
}
return report;
}),
},
},
{
provide: UpgradeAwareEntityMetadataAdapter,
useValue: {
refresh: jest.fn().mockResolvedValue(undefined),
isEntityAvailable: jest.fn().mockReturnValue(true),
getHiddenColumnPropertyNames: jest.fn().mockReturnValue(new Set()),
},
},
CommandShutdownService,
UpgradeSequenceRunnerService,
],
}).compile();
const runner = module.get(UpgradeSequenceRunnerService);
jest.spyOn(runner['logger'], 'log').mockImplementation();
jest.spyOn(runner['logger'], 'error').mockImplementation();
jest.spyOn(runner['logger'], 'warn').mockImplementation();
const instanceCommandRunnerService = module.get(InstanceCommandRunnerService);
jest
.spyOn(instanceCommandRunnerService['logger'], 'log')
.mockImplementation();
jest
.spyOn(instanceCommandRunnerService['logger'], 'error')
.mockImplementation();
const workspaceCommandRunnerService = module.get(
WorkspaceCommandRunnerService,
);
jest
.spyOn(workspaceCommandRunnerService['logger'], 'log')
.mockImplementation();
jest
.spyOn(workspaceCommandRunnerService['logger'], 'error')
.mockImplementation();
return {
module,
dataSource,
runner,
upgradeStatusService: module.get(UpgradeStatusService),
upgradeSequenceReaderService: module.get(UpgradeSequenceReaderService),
};
};
let seedSequenceCounter = 0;
export const resetSeedSequenceCounter = () => {
seedSequenceCounter = 0;
};
export const seedInstanceMigration = async (
dataSource: DataSource,
{
name,
status,
workspaceIds = [],
attempt = 1,
}: {
name: string;
status: 'completed' | 'failed';
workspaceIds?: string[];
attempt?: number;
},
) => {
// Seeds must have past timestamps so the runner's NOW()-based records
// always sort after them in createdAt order.
const createdAt = new Date(
Date.now() - (1000000 - seedSequenceCounter * 1000),
).toISOString();
seedSequenceCounter++;
const values: string[] = [];
const args: unknown[] = [];
let paramIndex = 1;
values.push(
`($${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, NULL, $${paramIndex++}, false)`,
);
args.push(name, status, attempt, EXECUTED_BY_VERSION, createdAt);
for (const workspaceId of workspaceIds) {
values.push(
`($${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, false)`,
);
args.push(
name,
status,
attempt,
EXECUTED_BY_VERSION,
workspaceId,
createdAt,
);
}
await dataSource.query(
`INSERT INTO core."upgradeMigration" (name, status, attempt, "executedByVersion", "workspaceId", "createdAt", "isInitial")
VALUES ${values.join(', ')}`,
args,
);
};
export const seedWorkspaceMigration = async (
dataSource: DataSource,
{
name,
status,
workspaceId,
attempt = 1,
isInitial = false,
useCurrentTimestamp = false,
}: {
name: string;
status: 'completed' | 'failed';
workspaceId: string;
attempt?: number;
isInitial?: boolean;
useCurrentTimestamp?: boolean;
},
) => {
if (useCurrentTimestamp) {
await dataSource.query(
`INSERT INTO core."upgradeMigration" (name, status, attempt, "executedByVersion", "workspaceId", "isInitial")
VALUES ($1, $2, $3, $4, $5, $6)`,
[name, status, attempt, EXECUTED_BY_VERSION, workspaceId, isInitial],
);
} else {
const createdAt = new Date(
Date.now() - (1000000 - seedSequenceCounter * 1000),
).toISOString();
seedSequenceCounter++;
await dataSource.query(
`INSERT INTO core."upgradeMigration" (name, status, attempt, "executedByVersion", "workspaceId", "createdAt", "isInitial")
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[
name,
status,
attempt,
EXECUTED_BY_VERSION,
workspaceId,
createdAt,
isInitial,
],
);
}
};
export const snapshotUpgradeMigrations = async (
dataSource: DataSource,
): Promise<UpgradeMigrationEntity[]> =>
dataSource.query(
`SELECT id, name, status, attempt, "executedByVersion", "errorMessage", "isInitial", "workspaceId", "createdAt"
FROM core."upgradeMigration"`,
);
export const restoreUpgradeMigrations = async (
dataSource: DataSource,
rows: UpgradeMigrationEntity[],
): Promise<void> => {
await dataSource.query('DELETE FROM core."upgradeMigration"');
if (rows.length === 0) {
return;
}
const columnsPerRow = 9;
const valueGroups: string[] = [];
const args: unknown[] = [];
let paramIndex = 1;
for (const row of rows) {
const placeholders = Array.from(
{ length: columnsPerRow },
() => `$${paramIndex++}`,
);
valueGroups.push(`(${placeholders.join(', ')})`);
args.push(
row.id,
row.name,
row.status,
row.attempt,
row.executedByVersion,
row.errorMessage,
row.isInitial,
row.workspaceId,
row.createdAt,
);
}
await dataSource.query(
`INSERT INTO core."upgradeMigration"
(id, name, status, attempt, "executedByVersion", "errorMessage", "isInitial", "workspaceId", "createdAt")
VALUES ${valueGroups.join(', ')}`,
args,
);
};
export type ExecutedMigrationRecord = {
name: string;
status: string;
attempt: number;
workspaceId: string | null;
isInitial: boolean;
};
export const testGetExecutedMigrationsInOrder = async (
dataSource: DataSource,
): Promise<ExecutedMigrationRecord[]> => {
return dataSource.query(
`SELECT name, status, attempt, "workspaceId", "isInitial"
FROM core."upgradeMigration"
ORDER BY "createdAt" ASC, "workspaceId" ASC NULLS FIRST, attempt ASC`,
);
};
export const migrationRecordToKey = ({
name,
workspaceId,
status,
attempt,
isInitial,
}: ExecutedMigrationRecord): string => {
const scope = workspaceId ?? 'instance';
const initial = isInitial ? ':initial' : '';
return `${name}:${scope}:${status}:${attempt}${initial}`;
};