App installation workspace version check engines constraint (#22613)
## What
Makes the app-installation version gate **workspace-scoped**. App
installation now validates a manifest's `engines.twenty` requirement
against the version the **target workspace has actually finished
upgrading to**, instead of the instance/server's inferred version.
## Why
The server binary and a given workspace's migration state can diverge.
In a multi-workspace deployment the instance can already report version
`X` while an individual workspace still hasn't completed its
workspace-scoped upgrade commands for `X` (it's mid-upgrade or a
migration failed). Gating on the instance version let an app that
requires `X` install into a workspace whose schema/metadata is
effectively still at `X-1`, which can break the app. The requirement
should be checked against what the *workspace* has completed, not what
the server reports.
## How
- **`UpgradeStatusService.getWorkspaceCompletedVersion(workspaceId)`**
(new): resolves the last fully-completed upgrade version for a workspace
by reading its upgrade cursor and walking the upgrade sequence:
- Returns the cursor's version when the cursor sits on the **last step
of its version segment** and its status is `completed`.
- Otherwise walks backwards to the previous fully-completed version
segment.
- Returns `null` when the cursor is missing, not found in the sequence,
or otherwise uninterpretable.
- **`ApplicationVersionValidationService`**:
- Adds `validateWorkspaceCompatibility({ requiredServerVersion,
workspaceId })`.
- Extracts the shared semver logic into a private
`validateVersionAgainstRange({ version, requiredVersionRange, scope })`
and makes error messages scope-aware (workspace vs. instance).
`validateServerCompatibility` is preserved and now delegates to it.
- New failure reason `INVALID_WORKSPACE_VERSION`.
- **`ApplicationInstallService`** now calls
`validateWorkspaceCompatibility` with the `workspaceId` instead of
`validateServerCompatibility`.
- **Exception plumbing**: new
`ApplicationExceptionCode.INVALID_WORKSPACE_VERSION`, surfaced as a
`UserInputError` (`BAD_USER_INPUT`) with a user-friendly message ("This
workspace's upgrade state could not be determined…"). The
tarball/registration path maps it onto the existing
`INVALID_SERVER_VERSION` registration code.
## Notes
- **Publishing (app registration) is intentionally not
workspace-gated.** The tarball/registration path
(`ApplicationTarballService`) still uses the instance-level
`validateServerCompatibility` check, not the new workspace-scoped one.
Publishing an app is not tied to any particular workspace's upgrade
state, so there is no workspace version to check at that point — the
workspace-completed-version gate only applies when installing an app
into a specific workspace.
## Testing
- Unit tests for `ApplicationVersionValidationService`
(`validateServerCompatibility` + new `validateWorkspaceCompatibility`)
covering: no requirement, invalid semver range, satisfied/unsatisfied
ranges, and the uninterpretable-cursor case.
- Unit tests for `UpgradeStatusService.getWorkspaceCompletedVersion`
against a three-segment mock upgrade sequence (multi-command version,
instance-only version, workspace-terminated version).
- New integration suite
`failing-app-installation-workspace-version.integration-spec.ts` (+
snapshots) exercising the real install flow: rejects installation when
the workspace hasn't completed the required version, and when the
workspace's upgrade cursor can't be interpreted. Adds a
`create-app-tarball.util.ts` test helper.
This commit is contained in:
+2
@@ -34,7 +34,9 @@ export class ApplicationExceptionFilter implements ExceptionFilter {
|
||||
case ApplicationExceptionCode.APP_ALREADY_INSTALLED:
|
||||
case ApplicationExceptionCode.CANNOT_DOWNGRADE_APPLICATION:
|
||||
case ApplicationExceptionCode.SERVER_VERSION_INCOMPATIBLE:
|
||||
case ApplicationExceptionCode.WORKSPACE_VERSION_INCOMPATIBLE:
|
||||
case ApplicationExceptionCode.INVALID_APP_ENGINE_REQUIREMENT:
|
||||
case ApplicationExceptionCode.INVALID_WORKSPACE_VERSION:
|
||||
throw new UserInputError(exception);
|
||||
case ApplicationExceptionCode.PACKAGE_RESOLUTION_FAILED:
|
||||
case ApplicationExceptionCode.POST_INSTALL_ERROR:
|
||||
|
||||
+10
-3
@@ -50,7 +50,11 @@ export class ApplicationInstallService {
|
||||
INVALID_REQUIRED_VERSION:
|
||||
ApplicationExceptionCode.INVALID_APP_ENGINE_REQUIREMENT,
|
||||
INVALID_SERVER_VERSION: ApplicationExceptionCode.INVALID_SERVER_VERSION,
|
||||
INCOMPATIBLE: ApplicationExceptionCode.SERVER_VERSION_INCOMPATIBLE,
|
||||
INVALID_WORKSPACE_VERSION:
|
||||
ApplicationExceptionCode.INVALID_WORKSPACE_VERSION,
|
||||
INSTANCE_INCOMPATIBLE: ApplicationExceptionCode.SERVER_VERSION_INCOMPATIBLE,
|
||||
WORKSPACE_INCOMPATIBLE:
|
||||
ApplicationExceptionCode.WORKSPACE_VERSION_INCOMPATIBLE,
|
||||
};
|
||||
|
||||
constructor(
|
||||
@@ -139,8 +143,11 @@ export class ApplicationInstallService {
|
||||
resolvedPackage.packageJson.engines?.['twenty'];
|
||||
|
||||
const versionValidation =
|
||||
await this.applicationVersionValidationService.validateServerCompatibility(
|
||||
requiredServerVersion,
|
||||
await this.applicationVersionValidationService.validateWorkspaceCompatibility(
|
||||
{
|
||||
requiredServerVersion,
|
||||
workspaceId: params.workspaceId,
|
||||
},
|
||||
);
|
||||
|
||||
if (!versionValidation.compatible) {
|
||||
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import { ApplicationVersionValidationService } from 'src/engine/core-modules/application/application-package/application-version-validation.service';
|
||||
import { UpgradeMigrationService } from 'src/engine/core-modules/upgrade/services/upgrade-migration.service';
|
||||
import { UpgradeStatusService } from 'src/engine/core-modules/upgrade/services/upgrade-status.service';
|
||||
|
||||
describe('ApplicationVersionValidationService', () => {
|
||||
let service: ApplicationVersionValidationService;
|
||||
let getInferredVersion: jest.Mock;
|
||||
let getWorkspaceCompletedVersion: jest.Mock;
|
||||
|
||||
beforeEach(async () => {
|
||||
getInferredVersion = jest.fn();
|
||||
getWorkspaceCompletedVersion = jest.fn();
|
||||
|
||||
const module = await Test.createTestingModule({
|
||||
providers: [
|
||||
ApplicationVersionValidationService,
|
||||
{
|
||||
provide: UpgradeMigrationService,
|
||||
useValue: { getInferredVersion },
|
||||
},
|
||||
{
|
||||
provide: UpgradeStatusService,
|
||||
useValue: { getWorkspaceCompletedVersion },
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get(ApplicationVersionValidationService);
|
||||
});
|
||||
|
||||
describe('validateServerCompatibility', () => {
|
||||
it('should be compatible when no required version is declared', async () => {
|
||||
await expect(
|
||||
service.validateServerCompatibility(undefined),
|
||||
).resolves.toEqual({ compatible: true });
|
||||
});
|
||||
|
||||
it('should reject an invalid semver range', async () => {
|
||||
const result = await service.validateServerCompatibility('not-semver');
|
||||
|
||||
expect(result).toMatchObject({
|
||||
compatible: false,
|
||||
reason: 'INVALID_REQUIRED_VERSION',
|
||||
});
|
||||
});
|
||||
|
||||
it('should be compatible when the inferred instance version satisfies the range', async () => {
|
||||
getInferredVersion.mockResolvedValue('2.19.0');
|
||||
|
||||
await expect(
|
||||
service.validateServerCompatibility('>=2.19.0'),
|
||||
).resolves.toEqual({ compatible: true });
|
||||
});
|
||||
|
||||
it('should be incompatible when the inferred instance version does not satisfy the range', async () => {
|
||||
getInferredVersion.mockResolvedValue('2.18.0');
|
||||
|
||||
const result = await service.validateServerCompatibility('>=2.19.0');
|
||||
|
||||
expect(result).toMatchObject({
|
||||
compatible: false,
|
||||
reason: 'INSTANCE_INCOMPATIBLE',
|
||||
});
|
||||
});
|
||||
|
||||
it('should fail when the inferred instance version is not valid semver', async () => {
|
||||
getInferredVersion.mockResolvedValue(null);
|
||||
|
||||
const result = await service.validateServerCompatibility('>=2.19.0');
|
||||
|
||||
expect(result).toMatchObject({
|
||||
compatible: false,
|
||||
reason: 'INVALID_SERVER_VERSION',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateWorkspaceCompatibility', () => {
|
||||
it('should be compatible when no required version is declared', async () => {
|
||||
await expect(
|
||||
service.validateWorkspaceCompatibility({
|
||||
requiredServerVersion: undefined,
|
||||
workspaceId: 'ws-1',
|
||||
}),
|
||||
).resolves.toEqual({ compatible: true });
|
||||
|
||||
expect(getWorkspaceCompletedVersion).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject an invalid semver range', async () => {
|
||||
const result = await service.validateWorkspaceCompatibility({
|
||||
requiredServerVersion: 'not-semver',
|
||||
workspaceId: 'ws-1',
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
compatible: false,
|
||||
reason: 'INVALID_REQUIRED_VERSION',
|
||||
});
|
||||
});
|
||||
|
||||
it('should be compatible when the workspace completed version satisfies the range', async () => {
|
||||
getWorkspaceCompletedVersion.mockResolvedValue('2.19.0');
|
||||
|
||||
await expect(
|
||||
service.validateWorkspaceCompatibility({
|
||||
requiredServerVersion: '>=2.19.0',
|
||||
workspaceId: 'ws-1',
|
||||
}),
|
||||
).resolves.toEqual({ compatible: true });
|
||||
|
||||
expect(getWorkspaceCompletedVersion).toHaveBeenCalledWith('ws-1');
|
||||
expect(getInferredVersion).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should be incompatible when the workspace has only completed an earlier version', async () => {
|
||||
getWorkspaceCompletedVersion.mockResolvedValue('2.18.0');
|
||||
|
||||
const result = await service.validateWorkspaceCompatibility({
|
||||
requiredServerVersion: '>=2.19.0',
|
||||
workspaceId: 'ws-1',
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
compatible: false,
|
||||
reason: 'WORKSPACE_INCOMPATIBLE',
|
||||
message:
|
||||
'App requires Twenty server >=2.19.0 but this workspace has only completed the upgrade to 2.18.0.',
|
||||
});
|
||||
});
|
||||
|
||||
it('should be incompatible when the workspace has no interpretable upgrade cursor', async () => {
|
||||
getWorkspaceCompletedVersion.mockResolvedValue(null);
|
||||
|
||||
const result = await service.validateWorkspaceCompatibility({
|
||||
requiredServerVersion: '>=2.19.0',
|
||||
workspaceId: 'ws-1',
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
compatible: false,
|
||||
reason: 'INVALID_WORKSPACE_VERSION',
|
||||
message:
|
||||
'Cannot determine the completed upgrade version for workspace ws-1: no interpretable upgrade cursor found.',
|
||||
});
|
||||
expect(getInferredVersion).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
+77
-11
@@ -1,13 +1,16 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import semver from 'semver';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { UpgradeMigrationService } from 'src/engine/core-modules/upgrade/services/upgrade-migration.service';
|
||||
import { UpgradeStatusService } from 'src/engine/core-modules/upgrade/services/upgrade-status.service';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export type VersionValidationFailureReason =
|
||||
| 'INVALID_REQUIRED_VERSION'
|
||||
| 'INVALID_SERVER_VERSION'
|
||||
| 'INCOMPATIBLE';
|
||||
| 'INVALID_WORKSPACE_VERSION'
|
||||
| 'INSTANCE_INCOMPATIBLE'
|
||||
| 'WORKSPACE_INCOMPATIBLE';
|
||||
|
||||
export type VersionValidationResult =
|
||||
| { compatible: true }
|
||||
@@ -21,6 +24,7 @@ export type VersionValidationResult =
|
||||
export class ApplicationVersionValidationService {
|
||||
constructor(
|
||||
private readonly upgradeMigrationService: UpgradeMigrationService,
|
||||
private readonly upgradeStatusService: UpgradeStatusService,
|
||||
) {}
|
||||
|
||||
async validateServerCompatibility(
|
||||
@@ -41,25 +45,87 @@ export class ApplicationVersionValidationService {
|
||||
const inferredServerVersion =
|
||||
await this.upgradeMigrationService.getInferredVersion();
|
||||
|
||||
if (
|
||||
!isDefined(inferredServerVersion) ||
|
||||
!isDefined(semver.valid(inferredServerVersion))
|
||||
) {
|
||||
return this.validateVersionAgainstRange({
|
||||
version: inferredServerVersion,
|
||||
requiredVersionRange: requiredServerVersion,
|
||||
scope: 'instance',
|
||||
});
|
||||
}
|
||||
|
||||
async validateWorkspaceCompatibility({
|
||||
requiredServerVersion,
|
||||
workspaceId,
|
||||
}: {
|
||||
requiredServerVersion: string | undefined;
|
||||
workspaceId: string;
|
||||
}): Promise<VersionValidationResult> {
|
||||
if (!isDefined(requiredServerVersion)) {
|
||||
return { compatible: true };
|
||||
}
|
||||
|
||||
if (!isDefined(semver.validRange(requiredServerVersion))) {
|
||||
return {
|
||||
compatible: false,
|
||||
reason: 'INVALID_SERVER_VERSION',
|
||||
message: `Cannot verify server compatibility: inferred server version "${inferredServerVersion ?? 'undefined'}" is not a valid semver version.`,
|
||||
reason: 'INVALID_REQUIRED_VERSION',
|
||||
message: `App manifest declares invalid engines.twenty value "${requiredServerVersion}". Must be a valid semver range.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!semver.satisfies(inferredServerVersion, requiredServerVersion)) {
|
||||
const workspaceCompletedVersion =
|
||||
await this.upgradeStatusService.getWorkspaceCompletedVersion(workspaceId);
|
||||
|
||||
if (!isDefined(workspaceCompletedVersion)) {
|
||||
return {
|
||||
compatible: false,
|
||||
reason: 'INCOMPATIBLE',
|
||||
message: `App requires Twenty server ${requiredServerVersion} but this server is ${inferredServerVersion}.`,
|
||||
reason: 'INVALID_WORKSPACE_VERSION',
|
||||
message: `Cannot determine the completed upgrade version for workspace ${workspaceId}: no interpretable upgrade cursor found.`,
|
||||
};
|
||||
}
|
||||
|
||||
return this.validateVersionAgainstRange({
|
||||
version: workspaceCompletedVersion,
|
||||
requiredVersionRange: requiredServerVersion,
|
||||
scope: 'workspace',
|
||||
});
|
||||
}
|
||||
|
||||
private validateVersionAgainstRange({
|
||||
version,
|
||||
requiredVersionRange,
|
||||
scope,
|
||||
}: {
|
||||
version: string | null;
|
||||
requiredVersionRange: string;
|
||||
scope: 'instance' | 'workspace';
|
||||
}): VersionValidationResult {
|
||||
if (!isDefined(version) || !isDefined(semver.valid(version))) {
|
||||
return scope === 'workspace'
|
||||
? {
|
||||
compatible: false,
|
||||
reason: 'INVALID_WORKSPACE_VERSION',
|
||||
message: `Cannot verify workspace compatibility: workspace completed version "${version ?? 'undefined'}" is not a valid semver version.`,
|
||||
}
|
||||
: {
|
||||
compatible: false,
|
||||
reason: 'INVALID_SERVER_VERSION',
|
||||
message: `Cannot verify server compatibility: inferred server version "${version ?? 'undefined'}" is not a valid semver version.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!semver.satisfies(version, requiredVersionRange)) {
|
||||
return scope === 'workspace'
|
||||
? {
|
||||
compatible: false,
|
||||
reason: 'WORKSPACE_INCOMPATIBLE',
|
||||
message: `App requires Twenty server ${requiredVersionRange} but this workspace has only completed the upgrade to ${version}.`,
|
||||
}
|
||||
: {
|
||||
compatible: false,
|
||||
reason: 'INSTANCE_INCOMPATIBLE',
|
||||
message: `App requires Twenty server ${requiredVersionRange} but this server is ${version}.`,
|
||||
};
|
||||
}
|
||||
|
||||
return { compatible: true };
|
||||
}
|
||||
}
|
||||
|
||||
+13
-9
@@ -12,6 +12,14 @@ import { Repository } from 'typeorm';
|
||||
import { type QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import {
|
||||
ApplicationVersionValidationService,
|
||||
type VersionValidationFailureReason,
|
||||
} from 'src/engine/core-modules/application/application-package/application-version-validation.service';
|
||||
import { extractTarballSecurely } from 'src/engine/core-modules/application/application-package/utils/extract-tarball-securely.util';
|
||||
import { readJsonFile } from 'src/engine/core-modules/application/application-package/utils/read-json-file.util';
|
||||
import { resolvePackageContentDir } from 'src/engine/core-modules/application/application-package/utils/tarball-utils';
|
||||
import { ApplicationRegistrationVariableService } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.service';
|
||||
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
|
||||
import {
|
||||
ApplicationRegistrationException,
|
||||
@@ -19,17 +27,9 @@ import {
|
||||
} from 'src/engine/core-modules/application/application-registration/application-registration.exception';
|
||||
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
|
||||
import { fromManifestApplicationToDisplayFields } from 'src/engine/core-modules/application/application-registration/utils/from-manifest-application-to-display-fields.util';
|
||||
import { extractTarballSecurely } from 'src/engine/core-modules/application/application-package/utils/extract-tarball-securely.util';
|
||||
import { readJsonFile } from 'src/engine/core-modules/application/application-package/utils/read-json-file.util';
|
||||
import { resolvePackageContentDir } from 'src/engine/core-modules/application/application-package/utils/tarball-utils';
|
||||
import {
|
||||
ApplicationVersionValidationService,
|
||||
type VersionValidationFailureReason,
|
||||
} from 'src/engine/core-modules/application/application-package/application-version-validation.service';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
|
||||
import type { ApplicationManifest } from 'twenty-shared/application';
|
||||
import { ApplicationRegistrationVariableService } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.service';
|
||||
|
||||
@Injectable()
|
||||
export class ApplicationTarballService {
|
||||
@@ -43,7 +43,11 @@ export class ApplicationTarballService {
|
||||
ApplicationRegistrationExceptionCode.INVALID_APP_ENGINE_REQUIREMENT,
|
||||
INVALID_SERVER_VERSION:
|
||||
ApplicationRegistrationExceptionCode.INVALID_SERVER_VERSION,
|
||||
INCOMPATIBLE:
|
||||
INVALID_WORKSPACE_VERSION:
|
||||
ApplicationRegistrationExceptionCode.INVALID_SERVER_VERSION,
|
||||
INSTANCE_INCOMPATIBLE:
|
||||
ApplicationRegistrationExceptionCode.SERVER_VERSION_INCOMPATIBLE,
|
||||
WORKSPACE_INCOMPATIBLE:
|
||||
ApplicationRegistrationExceptionCode.SERVER_VERSION_INCOMPATIBLE,
|
||||
};
|
||||
|
||||
|
||||
+2
@@ -34,7 +34,9 @@ const applicationExceptionCodeToHttpStatus = (
|
||||
case ApplicationExceptionCode.APP_ALREADY_INSTALLED:
|
||||
case ApplicationExceptionCode.CANNOT_DOWNGRADE_APPLICATION:
|
||||
case ApplicationExceptionCode.SERVER_VERSION_INCOMPATIBLE:
|
||||
case ApplicationExceptionCode.WORKSPACE_VERSION_INCOMPATIBLE:
|
||||
case ApplicationExceptionCode.INVALID_APP_ENGINE_REQUIREMENT:
|
||||
case ApplicationExceptionCode.INVALID_WORKSPACE_VERSION:
|
||||
return 400;
|
||||
case ApplicationExceptionCode.PACKAGE_RESOLUTION_FAILED:
|
||||
case ApplicationExceptionCode.POST_INSTALL_ERROR:
|
||||
|
||||
@@ -24,8 +24,10 @@ export enum ApplicationExceptionCode {
|
||||
APP_ALREADY_INSTALLED = 'APP_ALREADY_INSTALLED',
|
||||
CANNOT_DOWNGRADE_APPLICATION = 'CANNOT_DOWNGRADE_APPLICATION',
|
||||
SERVER_VERSION_INCOMPATIBLE = 'SERVER_VERSION_INCOMPATIBLE',
|
||||
WORKSPACE_VERSION_INCOMPATIBLE = 'WORKSPACE_VERSION_INCOMPATIBLE',
|
||||
INVALID_APP_ENGINE_REQUIREMENT = 'INVALID_APP_ENGINE_REQUIREMENT',
|
||||
INVALID_SERVER_VERSION = 'INVALID_SERVER_VERSION',
|
||||
INVALID_WORKSPACE_VERSION = 'INVALID_WORKSPACE_VERSION',
|
||||
APPLICATION_INSTALLATION_FAILED = 'APPLICATION_INSTALLATION_FAILED',
|
||||
}
|
||||
|
||||
@@ -69,10 +71,14 @@ const getApplicationExceptionUserFriendlyMessage = (
|
||||
return msg`A higher version of this application is already installed. Downgrading is not allowed.`;
|
||||
case ApplicationExceptionCode.SERVER_VERSION_INCOMPATIBLE:
|
||||
return msg`This app requires a newer version of the Twenty server. Please upgrade your server or use a compatible app version.`;
|
||||
case ApplicationExceptionCode.WORKSPACE_VERSION_INCOMPATIBLE:
|
||||
return msg`This app requires a newer version than this workspace has finished upgrading to. Please try again once the workspace upgrade completes.`;
|
||||
case ApplicationExceptionCode.INVALID_APP_ENGINE_REQUIREMENT:
|
||||
return msg`The app manifest declares an invalid server version requirement.`;
|
||||
case ApplicationExceptionCode.INVALID_SERVER_VERSION:
|
||||
return msg`The server's APP_VERSION is not a valid semver version. Self-hosted instances must configure a valid APP_VERSION.`;
|
||||
case ApplicationExceptionCode.INVALID_WORKSPACE_VERSION:
|
||||
return msg`This workspace's upgrade state could not be determined. Please try again once the workspace has finished upgrading.`;
|
||||
case ApplicationExceptionCode.APPLICATION_INSTALLATION_FAILED:
|
||||
return msg`We couldn't install this application because some of its metadata could not be applied to your workspace.`;
|
||||
default:
|
||||
|
||||
+153
-16
@@ -11,14 +11,26 @@ import { UpgradeStatusCacheService } from 'src/engine/core-modules/upgrade/servi
|
||||
import { UpgradeStatusService } from 'src/engine/core-modules/upgrade/services/upgrade-status.service';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
const LAST_INSTANCE_COMMAND = '1.23.0_LastInstanceCommand_1780000002000';
|
||||
const LAST_WORKSPACE_COMMAND = '1.23.0_LastWorkspaceCommand_1780000003000';
|
||||
const EARLIER_COMMAND = '1.22.0_EarlierCommand_1776000001000';
|
||||
const V1_21_INSTANCE_COMMAND = '1.21.0_InstanceCommand_1772000001000';
|
||||
const V1_21_SLOW_INSTANCE_COMMAND = '1.21.0_SlowInstanceCommand_1772000002000';
|
||||
const V1_21_FIRST_WORKSPACE_COMMAND =
|
||||
'1.21.0_FirstWorkspaceCommand_1772000003000';
|
||||
const V1_21_SECOND_WORKSPACE_COMMAND =
|
||||
'1.21.0_SecondWorkspaceCommand_1772000004000';
|
||||
const V1_22_INSTANCE_COMMAND = '1.22.0_InstanceCommand_1776000001000';
|
||||
const V1_23_INSTANCE_COMMAND = '1.23.0_InstanceCommand_1780000002000';
|
||||
const V1_23_WORKSPACE_COMMAND = '1.23.0_WorkspaceCommand_1780000003000';
|
||||
|
||||
// Three version segments: 1.21.0 has multiple workspace commands, 1.22.0 is
|
||||
// instance-only, 1.23.0 ends the sequence with a workspace command.
|
||||
const MOCK_SEQUENCE = [
|
||||
{ kind: 'fast-instance', name: EARLIER_COMMAND },
|
||||
{ kind: 'fast-instance', name: LAST_INSTANCE_COMMAND },
|
||||
{ kind: 'workspace', name: LAST_WORKSPACE_COMMAND },
|
||||
{ kind: 'fast-instance', name: V1_21_INSTANCE_COMMAND },
|
||||
{ kind: 'slow-instance', name: V1_21_SLOW_INSTANCE_COMMAND },
|
||||
{ kind: 'workspace', name: V1_21_FIRST_WORKSPACE_COMMAND },
|
||||
{ kind: 'workspace', name: V1_21_SECOND_WORKSPACE_COMMAND },
|
||||
{ kind: 'fast-instance', name: V1_22_INSTANCE_COMMAND },
|
||||
{ kind: 'fast-instance', name: V1_23_INSTANCE_COMMAND },
|
||||
{ kind: 'workspace', name: V1_23_WORKSPACE_COMMAND },
|
||||
];
|
||||
|
||||
type WorkspaceRecord = {
|
||||
@@ -131,7 +143,7 @@ describe('UpgradeStatusService', () => {
|
||||
describe('getInstanceStatus', () => {
|
||||
it('should return up-to-date when cursor is at last instance command', async () => {
|
||||
getLastAttemptedInstanceCommand.mockResolvedValue({
|
||||
name: LAST_INSTANCE_COMMAND,
|
||||
name: V1_23_INSTANCE_COMMAND,
|
||||
status: 'completed',
|
||||
executedByVersion: '1.23.0',
|
||||
errorMessage: null,
|
||||
@@ -146,7 +158,7 @@ describe('UpgradeStatusService', () => {
|
||||
|
||||
it('should return behind when cursor is before last instance command', async () => {
|
||||
getLastAttemptedInstanceCommand.mockResolvedValue({
|
||||
name: EARLIER_COMMAND,
|
||||
name: V1_22_INSTANCE_COMMAND,
|
||||
status: 'completed',
|
||||
executedByVersion: '1.22.0',
|
||||
errorMessage: null,
|
||||
@@ -161,7 +173,7 @@ describe('UpgradeStatusService', () => {
|
||||
|
||||
it('should return failed when latest instance command failed', async () => {
|
||||
getLastAttemptedInstanceCommand.mockResolvedValue({
|
||||
name: LAST_INSTANCE_COMMAND,
|
||||
name: V1_23_INSTANCE_COMMAND,
|
||||
status: 'failed',
|
||||
executedByVersion: '1.23.0',
|
||||
errorMessage: 'column does not exist',
|
||||
@@ -185,6 +197,131 @@ describe('UpgradeStatusService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getWorkspaceCompletedVersion', () => {
|
||||
const mockWorkspaceCursor = (
|
||||
cursor: { name: string; status: 'completed' | 'failed' } | null,
|
||||
) => {
|
||||
getWorkspaceLastAttemptedCommandName.mockResolvedValue(
|
||||
cursor === null
|
||||
? new Map()
|
||||
: new Map([
|
||||
[
|
||||
'ws-1',
|
||||
{
|
||||
workspaceId: 'ws-1',
|
||||
...cursor,
|
||||
executedByVersion: '1.23.0',
|
||||
errorMessage: null,
|
||||
createdAt: new Date('2025-06-01T00:00:00Z'),
|
||||
isInitial: false,
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
};
|
||||
|
||||
it('should return the cursor version when at the last step of its segment with completed status', async () => {
|
||||
mockWorkspaceCursor({
|
||||
name: V1_23_WORKSPACE_COMMAND,
|
||||
status: 'completed',
|
||||
});
|
||||
|
||||
await expect(service.getWorkspaceCompletedVersion('ws-1')).resolves.toBe(
|
||||
'1.23.0',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return the immediately previous version when the cursor is mid-segment', async () => {
|
||||
mockWorkspaceCursor({
|
||||
name: V1_23_INSTANCE_COMMAND,
|
||||
status: 'completed',
|
||||
});
|
||||
|
||||
await expect(service.getWorkspaceCompletedVersion('ws-1')).resolves.toBe(
|
||||
'1.22.0',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return the previous version when the last step of the segment failed', async () => {
|
||||
mockWorkspaceCursor({ name: V1_23_WORKSPACE_COMMAND, status: 'failed' });
|
||||
|
||||
await expect(service.getWorkspaceCompletedVersion('ws-1')).resolves.toBe(
|
||||
'1.22.0',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return the cursor version when completed at the end of an instance-only segment', async () => {
|
||||
mockWorkspaceCursor({
|
||||
name: V1_22_INSTANCE_COMMAND,
|
||||
status: 'completed',
|
||||
});
|
||||
|
||||
await expect(service.getWorkspaceCompletedVersion('ws-1')).resolves.toBe(
|
||||
'1.22.0',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return the previous version when an instance-only segment failed', async () => {
|
||||
mockWorkspaceCursor({ name: V1_22_INSTANCE_COMMAND, status: 'failed' });
|
||||
|
||||
await expect(service.getWorkspaceCompletedVersion('ws-1')).resolves.toBe(
|
||||
'1.21.0',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return the cursor version when completed at the last of several workspace commands', async () => {
|
||||
mockWorkspaceCursor({
|
||||
name: V1_21_SECOND_WORKSPACE_COMMAND,
|
||||
status: 'completed',
|
||||
});
|
||||
|
||||
await expect(service.getWorkspaceCompletedVersion('ws-1')).resolves.toBe(
|
||||
'1.21.0',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not consider a segment completed while earlier workspace commands of the same segment remain', async () => {
|
||||
mockWorkspaceCursor({
|
||||
name: V1_21_FIRST_WORKSPACE_COMMAND,
|
||||
status: 'completed',
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.getWorkspaceCompletedVersion('ws-1'),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when the first segment failed with no previous segment', async () => {
|
||||
mockWorkspaceCursor({
|
||||
name: V1_21_SECOND_WORKSPACE_COMMAND,
|
||||
status: 'failed',
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.getWorkspaceCompletedVersion('ws-1'),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when the workspace has no cursor', async () => {
|
||||
mockWorkspaceCursor(null);
|
||||
|
||||
await expect(
|
||||
service.getWorkspaceCompletedVersion('ws-1'),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when the cursor is outside the supported sequence', async () => {
|
||||
mockWorkspaceCursor({
|
||||
name: '1.10.0_OutOfSequenceCommand_1700000000000',
|
||||
status: 'completed',
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.getWorkspaceCompletedVersion('ws-1'),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getWorkspaceStatuses', () => {
|
||||
it('should return up-to-date for workspace at last command', async () => {
|
||||
mockActiveWorkspaces([{ id: 'ws-1', displayName: 'Apple' }]);
|
||||
@@ -195,7 +332,7 @@ describe('UpgradeStatusService', () => {
|
||||
'ws-1',
|
||||
{
|
||||
workspaceId: 'ws-1',
|
||||
name: LAST_WORKSPACE_COMMAND,
|
||||
name: V1_23_WORKSPACE_COMMAND,
|
||||
status: 'completed',
|
||||
executedByVersion: '1.23.0',
|
||||
errorMessage: null,
|
||||
@@ -223,7 +360,7 @@ describe('UpgradeStatusService', () => {
|
||||
'ws-1',
|
||||
{
|
||||
workspaceId: 'ws-1',
|
||||
name: LAST_WORKSPACE_COMMAND,
|
||||
name: V1_23_WORKSPACE_COMMAND,
|
||||
status: 'completed',
|
||||
executedByVersion: '1.23.0',
|
||||
errorMessage: null,
|
||||
@@ -234,7 +371,7 @@ describe('UpgradeStatusService', () => {
|
||||
'ws-2',
|
||||
{
|
||||
workspaceId: 'ws-2',
|
||||
name: EARLIER_COMMAND,
|
||||
name: V1_22_INSTANCE_COMMAND,
|
||||
status: 'completed',
|
||||
executedByVersion: '1.22.0',
|
||||
errorMessage: null,
|
||||
@@ -282,7 +419,7 @@ describe('UpgradeStatusService', () => {
|
||||
cacheGetFailedWorkspaceIds.mockResolvedValue(['ws-3']);
|
||||
cacheGetUpToDateWorkspaceCount.mockResolvedValue(5);
|
||||
getLastAttemptedInstanceCommand.mockResolvedValue({
|
||||
name: LAST_INSTANCE_COMMAND,
|
||||
name: V1_23_INSTANCE_COMMAND,
|
||||
status: 'completed',
|
||||
executedByVersion: '1.23.0',
|
||||
errorMessage: null,
|
||||
@@ -354,7 +491,7 @@ describe('UpgradeStatusService', () => {
|
||||
'ws-1',
|
||||
{
|
||||
workspaceId: 'ws-1',
|
||||
name: LAST_WORKSPACE_COMMAND,
|
||||
name: V1_23_WORKSPACE_COMMAND,
|
||||
status: 'completed',
|
||||
executedByVersion: '1.23.0',
|
||||
errorMessage: null,
|
||||
@@ -365,7 +502,7 @@ describe('UpgradeStatusService', () => {
|
||||
'ws-2',
|
||||
{
|
||||
workspaceId: 'ws-2',
|
||||
name: EARLIER_COMMAND,
|
||||
name: V1_22_INSTANCE_COMMAND,
|
||||
status: 'completed',
|
||||
executedByVersion: '1.22.0',
|
||||
errorMessage: null,
|
||||
@@ -376,7 +513,7 @@ describe('UpgradeStatusService', () => {
|
||||
'ws-3',
|
||||
{
|
||||
workspaceId: 'ws-3',
|
||||
name: LAST_WORKSPACE_COMMAND,
|
||||
name: V1_23_WORKSPACE_COMMAND,
|
||||
status: 'failed',
|
||||
executedByVersion: '1.23.0',
|
||||
errorMessage: 'boom',
|
||||
|
||||
+49
@@ -10,6 +10,7 @@ import { UpgradeMigrationService } from 'src/engine/core-modules/upgrade/service
|
||||
import { UpgradeSequenceReaderService } from 'src/engine/core-modules/upgrade/services/upgrade-sequence-reader.service';
|
||||
import { UpgradeStatusCacheService } from 'src/engine/core-modules/upgrade/services/upgrade-status-cache.service';
|
||||
import { type UpgradeMigrationStatus } from 'src/engine/core-modules/upgrade/upgrade-migration.entity';
|
||||
import { extractVersionFromCommandName } from 'src/engine/core-modules/upgrade/utils/extract-version-from-command-name.util';
|
||||
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { In, Repository } from 'typeorm';
|
||||
@@ -138,6 +139,54 @@ export class UpgradeStatusService {
|
||||
);
|
||||
}
|
||||
|
||||
async getWorkspaceCompletedVersion(
|
||||
workspaceId: string,
|
||||
): Promise<string | null> {
|
||||
const cursors =
|
||||
await this.upgradeMigrationService.getWorkspaceLastAttemptedCommandName([
|
||||
workspaceId,
|
||||
]);
|
||||
const cursor = cursors.get(workspaceId);
|
||||
|
||||
if (!isDefined(cursor)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sequence = this.upgradeSequenceReaderService.getUpgradeSequence();
|
||||
const cursorIndex = sequence.findIndex((step) => step.name === cursor.name);
|
||||
|
||||
if (cursorIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cursorVersion = extractVersionFromCommandName(cursor.name);
|
||||
|
||||
if (!isDefined(cursorVersion)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isLastStepOfItsVersion =
|
||||
cursorIndex === sequence.length - 1 ||
|
||||
extractVersionFromCommandName(sequence[cursorIndex + 1].name) !==
|
||||
cursorVersion;
|
||||
|
||||
if (cursor.status === 'completed' && isLastStepOfItsVersion) {
|
||||
return cursorVersion;
|
||||
}
|
||||
|
||||
for (let stepIndex = cursorIndex - 1; stepIndex >= 0; stepIndex--) {
|
||||
const stepVersion = extractVersionFromCommandName(
|
||||
sequence[stepIndex].name,
|
||||
);
|
||||
|
||||
if (stepVersion !== cursorVersion) {
|
||||
return stepVersion;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async getInstanceAndAllWorkspacesStatus(): Promise<InstanceAndAllWorkspacesUpgradeStatus> {
|
||||
const computedAt = await this.upgradeStatusCacheService.getComputedAt();
|
||||
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Install application is gated by the workspace completed upgrade version rejects installation when the workspace has not completed the required upgrade version 1`] = `
|
||||
{
|
||||
"extensions": {
|
||||
"code": "BAD_USER_INPUT",
|
||||
"subCode": "WORKSPACE_VERSION_INCOMPATIBLE",
|
||||
"userFriendlyMessage": "This app requires a newer version than this workspace has finished upgrading to. Please try again once the workspace upgrade completes.",
|
||||
},
|
||||
"message": "App requires Twenty server >=2.19.0 but this workspace has only completed the upgrade to 2.18.0.",
|
||||
"name": "UserInputError",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Install application is gated by the workspace completed upgrade version rejects installation when the workspace upgrade cursor cannot be interpreted 1`] = `
|
||||
{
|
||||
"extensions": {
|
||||
"code": "BAD_USER_INPUT",
|
||||
"subCode": "INVALID_WORKSPACE_VERSION",
|
||||
"userFriendlyMessage": "This workspace's upgrade state could not be determined. Please try again once the workspace has finished upgrading.",
|
||||
},
|
||||
"message": "Cannot determine the completed upgrade version for workspace 20202020-1c25-4d02-bf25-6aeccf7ea419: no interpretable upgrade cursor found.",
|
||||
"name": "UserInputError",
|
||||
}
|
||||
`;
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Publish application is gated by the instance server version rejects publishing when the app requires a server version the instance does not satisfy 1`] = `
|
||||
{
|
||||
"extensions": {
|
||||
"code": "BAD_USER_INPUT",
|
||||
"subCode": "SERVER_VERSION_INCOMPATIBLE",
|
||||
"userFriendlyMessage": "This app requires a newer version of the Twenty server. Please upgrade your server or use a compatible app version.",
|
||||
},
|
||||
"message": "App requires Twenty server >=999.0.0 but this server is 2.19.0.",
|
||||
"name": "UserInputError",
|
||||
}
|
||||
`;
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
|
||||
import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util';
|
||||
import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util';
|
||||
import { createAppTarball } from 'test/integration/metadata/suites/application/utils/create-app-tarball.util';
|
||||
import { installApplication } from 'test/integration/metadata/suites/application/utils/install-application.util';
|
||||
import { uploadAppTarball } from 'test/integration/metadata/suites/application/utils/upload-app-tarball.util';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { TWENTY_CURRENT_VERSION } from 'src/engine/core-modules/upgrade/constants/twenty-current-version.constant';
|
||||
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
|
||||
|
||||
// The full install flow runs cache-lock retries with real delays, so fake
|
||||
// timers would hang it — mirror the other install suites.
|
||||
jest.setTimeout(120000);
|
||||
|
||||
const INJECTED_CURSOR_MARKER = 'integration-test-workspace-version-gate';
|
||||
|
||||
const injectWorkspaceCursor = async (
|
||||
name: string,
|
||||
status: 'completed' | 'failed',
|
||||
): Promise<void> => {
|
||||
// Inject as attempt 2 with a fresh createdAt (now): this stays unique against
|
||||
// the seeded completed attempt-1 cursor (which may share this name) and
|
||||
// outranks it, so getWorkspaceCompletedVersion reads this attempt.
|
||||
await global.testDataSource.query(
|
||||
`INSERT INTO core."upgradeMigration"
|
||||
(name, status, attempt, "executedByVersion", "workspaceId", "isInitial")
|
||||
VALUES ($1, $2, 2, $3, $4, false)`,
|
||||
[name, status, INJECTED_CURSOR_MARKER, SEED_APPLE_WORKSPACE_ID],
|
||||
);
|
||||
};
|
||||
|
||||
const clearInjectedWorkspaceCursors = async (): Promise<void> => {
|
||||
await global.testDataSource.query(
|
||||
`DELETE FROM core."upgradeMigration"
|
||||
WHERE "workspaceId" = $1 AND "executedByVersion" = $2`,
|
||||
[SEED_APPLE_WORKSPACE_ID, INJECTED_CURSOR_MARKER],
|
||||
);
|
||||
};
|
||||
|
||||
const uploadTarballApp = async ({
|
||||
universalIdentifier,
|
||||
roleId,
|
||||
requiredServerVersion,
|
||||
}: {
|
||||
universalIdentifier: string;
|
||||
roleId: string;
|
||||
requiredServerVersion: string;
|
||||
}): Promise<void> => {
|
||||
const tarball = await createAppTarball({
|
||||
'manifest.json': JSON.stringify(
|
||||
buildBaseManifest({ appId: universalIdentifier, roleId }),
|
||||
),
|
||||
'package.json': JSON.stringify({
|
||||
name: `test-workspace-version-gate-${universalIdentifier}`,
|
||||
version: '1.0.0',
|
||||
engines: { twenty: requiredServerVersion },
|
||||
}),
|
||||
});
|
||||
|
||||
const uploadResult = await uploadAppTarball({
|
||||
tarballBuffer: tarball,
|
||||
universalIdentifier,
|
||||
});
|
||||
|
||||
expect(uploadResult.errors).toBeUndefined();
|
||||
};
|
||||
|
||||
describe('Install application is gated by the workspace completed upgrade version', () => {
|
||||
let currentVersionCommandName: string;
|
||||
const createdApplicationUniversalIdentifiers: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
jest.useRealTimers();
|
||||
|
||||
// The seeded workspace's cursor is the last step of the current version.
|
||||
// Re-injecting it as a failed attempt makes the workspace resolve to the
|
||||
// previous completed version — i.e. behind the instance.
|
||||
const [workspaceCursor] = await global.testDataSource.query(
|
||||
`SELECT name FROM core."upgradeMigration"
|
||||
WHERE "workspaceId" = $1
|
||||
ORDER BY "createdAt" DESC, attempt DESC
|
||||
LIMIT 1`,
|
||||
[SEED_APPLE_WORKSPACE_ID],
|
||||
);
|
||||
|
||||
if (!isDefined(workspaceCursor)) {
|
||||
throw new Error(
|
||||
`Expected a seeded upgrade cursor for workspace ${SEED_APPLE_WORKSPACE_ID}`,
|
||||
);
|
||||
}
|
||||
|
||||
currentVersionCommandName = workspaceCursor.name;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await clearInjectedWorkspaceCursors();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
for (const universalIdentifier of createdApplicationUniversalIdentifiers) {
|
||||
await cleanupApplicationAndAppRegistration({
|
||||
applicationUniversalIdentifier: universalIdentifier,
|
||||
});
|
||||
}
|
||||
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
it('rejects installation when the workspace has not completed the required upgrade version', async () => {
|
||||
const universalIdentifier = uuidv4();
|
||||
const roleId = uuidv4();
|
||||
|
||||
await uploadTarballApp({
|
||||
universalIdentifier,
|
||||
roleId,
|
||||
requiredServerVersion: `>=${TWENTY_CURRENT_VERSION}`,
|
||||
});
|
||||
|
||||
createdApplicationUniversalIdentifiers.push(universalIdentifier);
|
||||
|
||||
// The workspace failed mid-way through the current version's upgrade
|
||||
// segment, so its last completed version is the previous one.
|
||||
await injectWorkspaceCursor(currentVersionCommandName, 'failed');
|
||||
|
||||
const { errors } = await installApplication({
|
||||
input: { universalIdentifier },
|
||||
expectToFail: true,
|
||||
});
|
||||
|
||||
expectOneNotInternalServerErrorSnapshot({ errors });
|
||||
});
|
||||
|
||||
it('rejects installation when the workspace upgrade cursor cannot be interpreted', async () => {
|
||||
const universalIdentifier = uuidv4();
|
||||
const roleId = uuidv4();
|
||||
|
||||
await uploadTarballApp({
|
||||
universalIdentifier,
|
||||
roleId,
|
||||
requiredServerVersion: '>=1.0.0',
|
||||
});
|
||||
|
||||
createdApplicationUniversalIdentifiers.push(universalIdentifier);
|
||||
|
||||
// A cursor pointing at a command outside the supported upgrade sequence
|
||||
// cannot be mapped to a completed version.
|
||||
await injectWorkspaceCursor(
|
||||
'1.0.0_UnknownLegacyCommand_1700000000000',
|
||||
'completed',
|
||||
);
|
||||
|
||||
const { errors } = await installApplication({
|
||||
input: { universalIdentifier },
|
||||
expectToFail: true,
|
||||
});
|
||||
|
||||
expectOneNotInternalServerErrorSnapshot({ errors });
|
||||
});
|
||||
});
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
|
||||
import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util';
|
||||
import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util';
|
||||
import { createAppTarball } from 'test/integration/metadata/suites/application/utils/create-app-tarball.util';
|
||||
import { uploadAppTarball } from 'test/integration/metadata/suites/application/utils/upload-app-tarball.util';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
// The upload flow runs cache-lock retries with real delays, so fake timers
|
||||
// would hang it — mirror the other application suites.
|
||||
jest.setTimeout(120000);
|
||||
|
||||
describe('Publish application is gated by the instance server version', () => {
|
||||
const createdApplicationUniversalIdentifiers: string[] = [];
|
||||
|
||||
beforeAll(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
for (const universalIdentifier of createdApplicationUniversalIdentifiers) {
|
||||
await cleanupApplicationAndAppRegistration({
|
||||
applicationUniversalIdentifier: universalIdentifier,
|
||||
});
|
||||
}
|
||||
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
it('rejects publishing when the app requires a server version the instance does not satisfy', async () => {
|
||||
const universalIdentifier = uuidv4();
|
||||
const roleId = uuidv4();
|
||||
|
||||
const tarball = await createAppTarball({
|
||||
'manifest.json': JSON.stringify(
|
||||
buildBaseManifest({ appId: universalIdentifier, roleId }),
|
||||
),
|
||||
'package.json': JSON.stringify({
|
||||
name: `test-server-version-gate-${universalIdentifier}`,
|
||||
version: '1.0.0',
|
||||
// Require a server version far above any real instance version so the
|
||||
// instance-level compatibility check always rejects it.
|
||||
engines: { twenty: '>=999.0.0' },
|
||||
}),
|
||||
});
|
||||
|
||||
createdApplicationUniversalIdentifiers.push(universalIdentifier);
|
||||
|
||||
const { errors } = await uploadAppTarball({
|
||||
tarballBuffer: tarball,
|
||||
universalIdentifier,
|
||||
expectToFail: true,
|
||||
});
|
||||
|
||||
expectOneNotInternalServerErrorSnapshot({ errors });
|
||||
});
|
||||
});
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
import crypto from 'crypto';
|
||||
import { promises as fs } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
import * as tar from 'tar';
|
||||
|
||||
export const createAppTarball = async (
|
||||
files: Record<string, string>,
|
||||
): Promise<Buffer> => {
|
||||
const tempId = crypto.randomUUID();
|
||||
const sourceDir = join(tmpdir(), `test-app-tarball-src-${tempId}`);
|
||||
const tarballPath = join(tmpdir(), `test-app-tarball-${tempId}.tar.gz`);
|
||||
|
||||
await fs.mkdir(sourceDir, { recursive: true });
|
||||
|
||||
for (const [name, content] of Object.entries(files)) {
|
||||
const filePath = join(sourceDir, name);
|
||||
const dir = filePath.substring(0, filePath.lastIndexOf('/'));
|
||||
|
||||
if (dir !== sourceDir) {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
}
|
||||
await fs.writeFile(filePath, content);
|
||||
}
|
||||
|
||||
await tar.create(
|
||||
{
|
||||
file: tarballPath,
|
||||
gzip: true,
|
||||
cwd: sourceDir,
|
||||
},
|
||||
Object.keys(files),
|
||||
);
|
||||
|
||||
const buffer = await fs.readFile(tarballPath);
|
||||
|
||||
await fs.rm(sourceDir, { recursive: true, force: true });
|
||||
await fs.rm(tarballPath, { force: true });
|
||||
|
||||
return buffer;
|
||||
};
|
||||
+9
-1
@@ -12,9 +12,11 @@ import {
|
||||
makeWorkspace,
|
||||
migrationRecordToKey,
|
||||
resetSeedSequenceCounter,
|
||||
restoreUpgradeMigrations,
|
||||
seedInstanceMigration,
|
||||
seedWorkspaceMigration,
|
||||
setMockActiveWorkspaceIds,
|
||||
snapshotUpgradeMigrations,
|
||||
testGetExecutedMigrationsInOrder,
|
||||
WS_1,
|
||||
WS_2,
|
||||
@@ -62,13 +64,19 @@ const makeWorkspaceFailingForIds = (
|
||||
|
||||
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 context.dataSource.query('DELETE FROM core."upgradeMigration"');
|
||||
await restoreUpgradeMigrations(context.dataSource, savedUpgradeMigrations);
|
||||
await context.module?.close();
|
||||
await context.dataSource?.destroy();
|
||||
}, 15000);
|
||||
|
||||
+9
-1
@@ -6,8 +6,10 @@ import {
|
||||
makeWorkspace,
|
||||
migrationRecordToKey,
|
||||
resetSeedSequenceCounter,
|
||||
restoreUpgradeMigrations,
|
||||
seedInstanceMigration,
|
||||
setMockActiveWorkspaceIds,
|
||||
snapshotUpgradeMigrations,
|
||||
testGetExecutedMigrationsInOrder,
|
||||
WS_1,
|
||||
WS_2,
|
||||
@@ -19,13 +21,19 @@ import {
|
||||
|
||||
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 context.dataSource.query('DELETE FROM core."upgradeMigration"');
|
||||
await restoreUpgradeMigrations(context.dataSource, savedUpgradeMigrations);
|
||||
await context.module?.close();
|
||||
await context.dataSource?.destroy();
|
||||
}, 15000);
|
||||
|
||||
+9
-1
@@ -9,9 +9,11 @@ import {
|
||||
makeWorkspace,
|
||||
migrationRecordToKey,
|
||||
resetSeedSequenceCounter,
|
||||
restoreUpgradeMigrations,
|
||||
seedInstanceMigration,
|
||||
seedWorkspaceMigration,
|
||||
setMockActiveWorkspaceIds,
|
||||
snapshotUpgradeMigrations,
|
||||
testGetExecutedMigrationsInOrder,
|
||||
WS_1,
|
||||
WS_2,
|
||||
@@ -19,13 +21,19 @@ import {
|
||||
|
||||
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 context.dataSource.query('DELETE FROM core."upgradeMigration"');
|
||||
await restoreUpgradeMigrations(context.dataSource, savedUpgradeMigrations);
|
||||
await context.module?.close();
|
||||
await context.dataSource?.destroy();
|
||||
}, 15000);
|
||||
|
||||
+9
-1
@@ -7,9 +7,11 @@ import {
|
||||
makeWorkspace,
|
||||
migrationRecordToKey,
|
||||
resetSeedSequenceCounter,
|
||||
restoreUpgradeMigrations,
|
||||
seedInstanceMigration,
|
||||
seedWorkspaceMigration,
|
||||
setMockActiveWorkspaceIds,
|
||||
snapshotUpgradeMigrations,
|
||||
testGetExecutedMigrationsInOrder,
|
||||
WS_1,
|
||||
WS_2,
|
||||
@@ -18,13 +20,19 @@ import {
|
||||
|
||||
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 context.dataSource.query('DELETE FROM core."upgradeMigration"');
|
||||
await restoreUpgradeMigrations(context.dataSource, savedUpgradeMigrations);
|
||||
await context.module?.close();
|
||||
await context.dataSource?.destroy();
|
||||
}, 15000);
|
||||
|
||||
+53
-2
@@ -2,8 +2,8 @@ import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import { getDataSourceToken, getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
import { config } from 'dotenv';
|
||||
import { DataSource, type Repository } from 'typeorm';
|
||||
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
|
||||
import { DataSource, type Repository } from 'typeorm';
|
||||
|
||||
import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
|
||||
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
|
||||
@@ -16,10 +16,10 @@ import {
|
||||
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 { UpgradeAwareEntityMetadataAdapter } from 'src/engine/twenty-orm/upgrade-aware/upgrade-aware-entity-metadata.adapter';
|
||||
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 { UpgradeAwareEntityMetadataAdapter } from 'src/engine/twenty-orm/upgrade-aware/upgrade-aware-entity-metadata.adapter';
|
||||
import {
|
||||
SEED_APPLE_WORKSPACE_ID,
|
||||
SEED_EMPTY_WORKSPACE_3_ID,
|
||||
@@ -398,6 +398,57 @@ export const seedWorkspaceMigration = async (
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user