Compare commits

...

4 Commits

Author SHA1 Message Date
prastoin dfef76cd20 feat(server): extract hooks function 2026-02-26 12:16:00 +01:00
prastoin 7c632c4884 test(server): edge case 2026-02-26 11:52:25 +01:00
prastoin d2694a0865 mime type and legeacy write file 2026-02-26 11:26:21 +01:00
prastoin fdfdc44891 feat(server): basic tarball installation 2026-02-26 11:26:19 +01:00
16 changed files with 938 additions and 110 deletions
+3
View File
@@ -30,3 +30,6 @@ AUTH_MICROSOFT_APIS_CALLBACK_URL=http://localhost:3000/auth/microsoft-apis/get-a
CLICKHOUSE_URL=http://default:clickhousePassword@localhost:8123/twenty
IS_WORKSPACE_CREATION_V2_ENABLED=true
SHOULD_SEED_STANDARD_RECORD_PAGE_LAYOUTS=true
APPLICATION_REGISTRY_URL=http://127.0.0.1:4444
OUTBOUND_HTTP_SAFE_MODE_ENABLED=false
+1
View File
@@ -171,6 +171,7 @@
"semver": "7.6.3",
"sharp": "0.32.6",
"stripe": "19.3.1",
"tar": "^7.5.9",
"temporal-polyfill": "^0.3.0",
"transliteration": "2.3.5",
"tsconfig-paths": "^4.2.0",
@@ -24,6 +24,7 @@ export class ApplicationExceptionFilter implements ExceptionFilter {
throw new NotFoundError(exception);
case ApplicationExceptionCode.FORBIDDEN:
case ApplicationExceptionCode.INVALID_INPUT:
case ApplicationExceptionCode.INSTALL_HOOK_EXECUTION_FAILED:
throw new UserInputError(exception);
default: {
assertUnreachable(exception.code);
@@ -5,12 +5,15 @@ import { ApplicationModule } from 'src/engine/core-modules/application/applicati
import { ApplicationDevelopmentResolver } from 'src/engine/core-modules/application/resolvers/application-development.resolver';
import { ApplicationResolver } from 'src/engine/core-modules/application/resolvers/application.resolver';
import { MarketplaceResolver } from 'src/engine/core-modules/application/resolvers/marketplace.resolver';
import { ApplicationInstallService } from 'src/engine/core-modules/application/services/application-install.service';
import { ApplicationManifestMigrationService } from 'src/engine/core-modules/application/services/application-manifest-migration.service';
import { ApplicationSyncService } from 'src/engine/core-modules/application/services/application-sync.service';
import { ApplicationVariableEntityModule } from 'src/engine/core-modules/applicationVariable/application-variable.module';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { LogicFunctionExecutorModule } from 'src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.module';
import { SecureHttpClientModule } from 'src/engine/core-modules/secure-http-client/secure-http-client.module';
import { ObjectPermissionModule } from 'src/engine/metadata-modules/object-permission/object-permission.module';
import { PermissionFlagModule } from 'src/engine/metadata-modules/permission-flag/permission-flag.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
@@ -36,11 +39,14 @@ import { CodeStepBuildModule } from 'src/modules/workflow/workflow-builder/workf
FileStorageModule,
WorkspaceCacheModule,
WorkspaceMigrationRunnerModule,
SecureHttpClientModule,
LogicFunctionExecutorModule,
],
providers: [
ApplicationResolver,
ApplicationDevelopmentResolver,
MarketplaceResolver,
ApplicationInstallService,
ApplicationManifestMigrationService,
ApplicationSyncService,
WorkspaceMigrationGraphqlApiExceptionInterceptor,
@@ -13,6 +13,7 @@ export enum ApplicationExceptionCode {
APPLICATION_NOT_FOUND = 'APPLICATION_NOT_FOUND',
FORBIDDEN = 'FORBIDDEN',
INVALID_INPUT = 'INVALID_INPUT',
INSTALL_HOOK_EXECUTION_FAILED = 'INSTALL_HOOK_EXECUTION_FAILED',
}
const getApplicationExceptionUserFriendlyMessage = (
@@ -35,6 +36,8 @@ const getApplicationExceptionUserFriendlyMessage = (
return msg`You do not have permission to perform this action.`;
case ApplicationExceptionCode.INVALID_INPUT:
return msg`Invalid input provided.`;
case ApplicationExceptionCode.INSTALL_HOOK_EXECUTION_FAILED:
return msg`Install hook execution failed.`;
default:
assertUnreachable(code);
}
@@ -18,8 +18,8 @@ import {
} from 'src/engine/core-modules/application/application.exception';
import { ApplicationTokenPairDTO } from 'src/engine/core-modules/application/dtos/application-token-pair.dto';
import { ApplicationDTO } from 'src/engine/core-modules/application/dtos/application.dto';
import { InstallApplicationInput } from 'src/engine/core-modules/application/dtos/install-application.input';
import { UninstallApplicationInput } from 'src/engine/core-modules/application/dtos/uninstallApplicationInput';
import { ApplicationInstallService } from 'src/engine/core-modules/application/services/application-install.service';
import { ApplicationSyncService } from 'src/engine/core-modules/application/services/application-sync.service';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service';
@@ -33,7 +33,6 @@ import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.g
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-graphql-api-exception.interceptor';
import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/services/workspace-migration-runner.service';
@UsePipes(ResolverValidationPipe)
@MetadataResolver()
@@ -42,7 +41,7 @@ import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/wo
@UseGuards(WorkspaceAuthGuard)
export class ApplicationResolver {
constructor(
private readonly workspaceMigrationRunnerService: WorkspaceMigrationRunnerService,
private readonly applicationInstallService: ApplicationInstallService,
private readonly applicationSyncService: ApplicationSyncService,
private readonly applicationService: ApplicationService,
private readonly applicationTokenService: ApplicationTokenService,
@@ -102,7 +101,11 @@ export class ApplicationResolver {
@UseGuards(SettingsPermissionGuard(PermissionFlagType.APPLICATIONS))
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
async installApplication(
@Args() { workspaceMigration: { actions } }: InstallApplicationInput,
@Args('applicationUniversalIdentifier', {
type: () => UUIDScalarType,
})
applicationUniversalIdentifier: string,
@Args('version', { type: () => String }) version: string,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
) {
const { featureFlagsMap } = await this.workspaceCacheService.getOrRecompute(
@@ -121,23 +124,11 @@ export class ApplicationResolver {
);
}
const { workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{
workspaceId,
},
);
await this.workspaceMigrationRunnerService.run({
workspaceMigration: {
actions,
applicationUniversalIdentifier:
workspaceCustomFlatApplication.universalIdentifier,
},
return this.applicationInstallService.installApplication({
applicationUniversalIdentifier,
version,
workspaceId,
});
return true;
}
@Mutation(() => Boolean)
@@ -0,0 +1,522 @@
import { Injectable, Logger } from '@nestjs/common';
import * as fs from 'fs/promises';
import { tmpdir } from 'os';
import { basename, join } from 'path';
import { extract, type ReadEntry } from 'tar';
import {
type LogicFunctionManifest,
type Manifest,
} from 'twenty-shared/application';
import { FileFolder } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import {
ApplicationException,
ApplicationExceptionCode,
} from 'src/engine/core-modules/application/application.exception';
import { ApplicationSyncService } from 'src/engine/core-modules/application/services/application-sync.service';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { extractFileInfo } from 'src/engine/core-modules/file/utils/extract-file-info.utils';
import { LogicFunctionExecutorService } from 'src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service';
import { LogicFunctionExecutionStatus } from 'src/engine/metadata-modules/logic-function/dtos/logic-function-execution-result.dto';
import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
const MAX_EXTRACTED_SIZE_BYTES = 500 * 1024 * 1024;
type FileFolderMapping = {
fileFolder: FileFolder;
resourcePath: string;
};
type InstallHookIdentifiers = {
preInstallLogicFunctionUniversalIdentifier: string | undefined;
postInstallLogicFunctionUniversalIdentifier: string | undefined;
};
@Injectable()
export class ApplicationInstallService {
private readonly logger = new Logger(ApplicationInstallService.name);
constructor(
private readonly secureHttpClientService: SecureHttpClientService,
private readonly fileStorageService: FileStorageService,
private readonly applicationSyncService: ApplicationSyncService,
private readonly twentyConfigService: TwentyConfigService,
private readonly logicFunctionExecutorService: LogicFunctionExecutorService,
private readonly workspaceCacheService: WorkspaceCacheService,
) {}
async installApplication({
applicationUniversalIdentifier,
version,
workspaceId,
}: {
applicationUniversalIdentifier: string;
version: string;
workspaceId: string;
}): Promise<boolean> {
const temporaryDir = join(tmpdir(), `application-install-${v4()}`);
try {
await fs.mkdir(temporaryDir, { recursive: true });
const tarballPath = await this.downloadTarball({
applicationUniversalIdentifier,
version,
temporaryDir,
workspaceId,
});
await this.extractTarball({ tarballPath, temporaryDir });
const manifest = await this.readManifest(temporaryDir);
await this.writeExtractedFilesToStorage({
extractedDir: temporaryDir,
applicationUniversalIdentifier,
workspaceId,
});
const hookIdentifiers = this.resolveInstallHookIdentifiers(manifest);
const hookLogicFunctions = this.extractHookLogicFunctions({
manifest,
hookIdentifiers,
});
if (hookLogicFunctions.length > 0) {
const hooksOnlyManifest = this.buildHooksOnlyManifest({
manifest,
hookLogicFunctions,
});
await this.applicationSyncService.synchronizeFromManifest({
workspaceId,
manifest: hooksOnlyManifest,
});
}
await this.executeInstallHook({
hookUniversalIdentifier:
hookIdentifiers.preInstallLogicFunctionUniversalIdentifier,
hookName: 'preInstall',
workspaceId,
applicationUniversalIdentifier,
version,
});
await this.applicationSyncService.synchronizeFromManifest({
workspaceId,
manifest,
});
await this.executeInstallHook({
hookUniversalIdentifier:
hookIdentifiers.postInstallLogicFunctionUniversalIdentifier,
hookName: 'postInstall',
workspaceId,
applicationUniversalIdentifier,
version,
});
this.logger.log(
`Application ${applicationUniversalIdentifier}@${version} installed successfully`,
);
return true;
} finally {
await fs.rm(temporaryDir, { recursive: true, force: true });
}
}
private async downloadTarball({
applicationUniversalIdentifier,
version,
temporaryDir,
workspaceId,
}: {
applicationUniversalIdentifier: string;
version: string;
temporaryDir: string;
workspaceId: string;
}): Promise<string> {
const registryUrl = this.twentyConfigService.get(
'APPLICATION_REGISTRY_URL',
);
const tarballUrl = `${registryUrl}/${applicationUniversalIdentifier}@${version}/app.tar.gz`;
this.logger.log(`Downloading tarball from ${tarballUrl}`);
const httpClient = this.secureHttpClientService.getHttpClient(
{ responseType: 'arraybuffer', timeout: 60_000 },
{
workspaceId,
source: 'application-install',
},
);
let response: { data: ArrayBuffer; status: number };
try {
response = await httpClient.get(tarballUrl);
} catch (error: unknown) {
const isAxiosError =
error !== null &&
typeof error === 'object' &&
'response' in error &&
typeof (error as { response?: { status?: number } }).response
?.status === 'number';
if (isAxiosError) {
const status = (error as { response: { status: number } }).response
.status;
if (status === 404) {
throw new ApplicationException(
`Tarball not found for ${applicationUniversalIdentifier}@${version}`,
ApplicationExceptionCode.APPLICATION_NOT_FOUND,
);
}
}
throw new ApplicationException(
`Failed to download tarball for ${applicationUniversalIdentifier}@${version}: ${error instanceof Error ? error.message : String(error)}`,
ApplicationExceptionCode.INVALID_INPUT,
);
}
const tarballPath = join(temporaryDir, 'app.tar.gz');
await fs.writeFile(tarballPath, Buffer.from(response.data));
return tarballPath;
}
private async extractTarball({
tarballPath,
temporaryDir,
}: {
tarballPath: string;
temporaryDir: string;
}): Promise<void> {
let totalExtractedSize = 0;
try {
await extract({
file: tarballPath,
cwd: temporaryDir,
filter: (path, entry) => {
if (path.includes('..')) {
this.logger.warn(`Skipping path traversal entry: ${path}`);
return false;
}
if ('type' in entry) {
const readEntry = entry as ReadEntry;
if (
readEntry.type === 'SymbolicLink' ||
readEntry.type === 'Link'
) {
this.logger.warn(`Skipping symlink entry: ${path}`);
return false;
}
}
totalExtractedSize += entry.size ?? 0;
if (totalExtractedSize > MAX_EXTRACTED_SIZE_BYTES) {
throw new ApplicationException(
`Extracted tarball exceeds maximum allowed size of ${MAX_EXTRACTED_SIZE_BYTES} bytes`,
ApplicationExceptionCode.INVALID_INPUT,
);
}
return true;
},
});
} catch (error) {
if (error instanceof ApplicationException) {
throw error;
}
throw new ApplicationException(
`Failed to extract tarball: ${error instanceof Error ? error.message : String(error)}`,
ApplicationExceptionCode.INVALID_INPUT,
);
}
await fs.rm(tarballPath);
}
private async readManifest(extractedDir: string): Promise<Manifest> {
const manifestPath = join(extractedDir, 'manifest.json');
let manifestContent: string;
try {
manifestContent = await fs.readFile(manifestPath, 'utf-8');
} catch {
throw new ApplicationException(
'manifest.json not found in tarball',
ApplicationExceptionCode.INVALID_INPUT,
);
}
try {
return JSON.parse(manifestContent) as Manifest;
} catch {
throw new ApplicationException(
'Failed to parse manifest.json',
ApplicationExceptionCode.INVALID_INPUT,
);
}
}
private resolveFileFolderMapping(
relativePath: string,
): FileFolderMapping | null {
if (relativePath === 'manifest.json') {
return null;
}
if (relativePath === 'package.json') {
return {
fileFolder: FileFolder.Dependencies,
resourcePath: 'package.json',
};
}
if (relativePath === 'yarn.lock') {
return {
fileFolder: FileFolder.Dependencies,
resourcePath: 'yarn.lock',
};
}
if (relativePath.startsWith('public/')) {
return {
fileFolder: FileFolder.PublicAsset,
resourcePath: relativePath,
};
}
if (relativePath.startsWith('src/')) {
if (
relativePath.endsWith('.front-component.mjs') ||
relativePath.endsWith('.front-component.mjs.map')
) {
return {
fileFolder: FileFolder.BuiltFrontComponent,
resourcePath: relativePath,
};
}
if (
relativePath.endsWith('.function.mjs') ||
relativePath.endsWith('.function.mjs.map')
) {
return {
fileFolder: FileFolder.BuiltLogicFunction,
resourcePath: relativePath,
};
}
return {
fileFolder: FileFolder.Source,
resourcePath: relativePath,
};
}
return {
fileFolder: FileFolder.Source,
resourcePath: relativePath,
};
}
private async writeExtractedFilesToStorage({
extractedDir,
applicationUniversalIdentifier,
workspaceId,
}: {
extractedDir: string;
applicationUniversalIdentifier: string;
workspaceId: string;
}): Promise<void> {
const filePaths = await this.collectFilePaths(extractedDir, '');
for (const relativePath of filePaths) {
const mapping = this.resolveFileFolderMapping(relativePath);
if (mapping === null) {
continue;
}
const absolutePath = join(extractedDir, relativePath);
const fileContent = await fs.readFile(absolutePath);
const { mimeType } = await extractFileInfo({
file: fileContent,
filename: basename(relativePath),
});
await this.fileStorageService.writeFile({
sourceFile: fileContent,
mimeType,
fileFolder: mapping.fileFolder,
applicationUniversalIdentifier,
workspaceId,
resourcePath: mapping.resourcePath,
settings: {
isTemporaryFile: false,
toDelete: false,
},
});
}
}
private async collectFilePaths(
directory: string,
prefix: string,
): Promise<string[]> {
const entries = await fs.readdir(directory, { withFileTypes: true });
const filePaths: string[] = [];
for (const entry of entries) {
const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
const nestedPaths = await this.collectFilePaths(
join(directory, entry.name),
relativePath,
);
filePaths.push(...nestedPaths);
} else if (entry.isFile()) {
filePaths.push(relativePath);
}
}
return filePaths;
}
private resolveInstallHookIdentifiers(
manifest: Manifest,
): InstallHookIdentifiers {
return {
preInstallLogicFunctionUniversalIdentifier:
manifest.application.preInstallLogicFunctionUniversalIdentifier,
postInstallLogicFunctionUniversalIdentifier:
manifest.application.postInstallLogicFunctionUniversalIdentifier,
};
}
private extractHookLogicFunctions({
manifest,
hookIdentifiers,
}: {
manifest: Manifest;
hookIdentifiers: InstallHookIdentifiers;
}): LogicFunctionManifest[] {
const hookUniversalIdentifiers = [
hookIdentifiers.preInstallLogicFunctionUniversalIdentifier,
hookIdentifiers.postInstallLogicFunctionUniversalIdentifier,
].filter(isDefined);
if (hookUniversalIdentifiers.length === 0) {
return [];
}
const hookUniversalIdentifierSet = new Set(hookUniversalIdentifiers);
return manifest.logicFunctions.filter((logicFunction) =>
hookUniversalIdentifierSet.has(logicFunction.universalIdentifier),
);
}
private buildHooksOnlyManifest({
manifest,
hookLogicFunctions,
}: {
manifest: Manifest;
hookLogicFunctions: LogicFunctionManifest[];
}): Manifest {
return {
application: manifest.application,
logicFunctions: hookLogicFunctions,
objects: [],
fields: [],
frontComponents: [],
roles: [],
skills: [],
publicAssets: [],
views: [],
navigationMenuItems: [],
pageLayouts: [],
};
}
private async executeInstallHook({
hookUniversalIdentifier,
hookName,
workspaceId,
applicationUniversalIdentifier,
version,
}: {
hookUniversalIdentifier: string | undefined;
hookName: 'preInstall' | 'postInstall';
workspaceId: string;
applicationUniversalIdentifier: string;
version: string;
}): Promise<void> {
if (!isDefined(hookUniversalIdentifier)) {
return;
}
const { flatLogicFunctionMaps } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatLogicFunctionMaps',
]);
const flatLogicFunction =
flatLogicFunctionMaps.byUniversalIdentifier[hookUniversalIdentifier];
if (!isDefined(flatLogicFunction)) {
throw new ApplicationException(
`${hookName} logic function with universalIdentifier ${hookUniversalIdentifier} not found after manifest sync`,
ApplicationExceptionCode.ENTITY_NOT_FOUND,
);
}
this.logger.log(
`Executing ${hookName} hook (${hookUniversalIdentifier}) for ${applicationUniversalIdentifier}@${version}`,
);
const result = await this.logicFunctionExecutorService.execute({
logicFunctionId: flatLogicFunction.id,
workspaceId,
payload: {
applicationUniversalIdentifier,
version,
},
});
if (result.status === LogicFunctionExecutionStatus.ERROR) {
throw new ApplicationException(
`${hookName} hook failed for ${applicationUniversalIdentifier}@${version}: ${result.error?.errorMessage ?? 'Unknown error'}`,
ApplicationExceptionCode.INSTALL_HOOK_EXECUTION_FAILED,
);
}
this.logger.log(
`${hookName} hook completed successfully for ${applicationUniversalIdentifier}@${version}`,
);
}
}
@@ -1,4 +1,5 @@
export type OutboundRequestSource =
| 'webhook'
| 'workflow-http'
| 'logic-function';
| 'logic-function'
| 'application-install';
@@ -1606,6 +1606,16 @@ export class ConfigVariables {
@CastToPositiveNumber()
@IsOptional()
PG_DATABASE_REPLICA_TIMEOUT_MS: number = 10000;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.OTHER,
description:
'Base URL for downloading application tarballs. The full URL is {APPLICATION_REGISTRY_URL}/{id}@{version}/app.tar.gz',
type: ConfigVariableType.STRING,
})
@IsOptional()
APPLICATION_REGISTRY_URL: string =
'https://github.com/twentyhq/twenty-apps/releases/download';
}
export const validate = (config: Record<string, unknown>): ConfigVariables => {
@@ -1,60 +1 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`Install application should fail when entity does not exist should fail when a role has an invalid universalIdentifier 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"role": [
{
"errors": [
{
"code": "ENTITY_MALFORMED",
"message": "Invalid universalIdentifier: "not-a-valid-uuid" is not a valid UUID v4",
"value": "not-a-valid-uuid",
},
],
"flatEntityMinimalInformation": {
"universalIdentifier": Any<String>,
},
"metadataName": "role",
"status": "fail",
"type": "create",
},
],
},
"message": "Validation failed for 1 role",
"summary": {
"role": 1,
"totalErrors": 1,
},
"userFriendlyMessage": "Metadata validation failed",
},
"message": "Validation errors occurred while syncing application manifest metadata",
"name": "GraphQLError",
}
`;
exports[`Install application should fail when entity does not exist should fail with execution error when deleting non-existent field metadata 1`] = `
{
"eventId": Any<String>,
"extensions": {
"action": {
"metadataName": "fieldMetadata",
"type": "delete",
"universalIdentifier": Any<String>,
},
"code": "APPLICATION_INSTALLATION_FAILED",
"errors": {
"actionTranspilation": {
"code": "ENTITY_NOT_FOUND",
"message": "Could not find flat entity with universal identifier 20202020-6110-4547-9fd0-2525257a2c3f",
},
},
"exceptionEventId": Any<String>,
"userFriendlyMessage": "Migration execution failed.",
},
"message": "Migration action 'delete' for 'fieldMetadata' failed",
"name": "GraphQLError",
}
`;
@@ -6,15 +6,8 @@ describe('Install application should fail when feature flag is disabled', () =>
const { errors } = await installApplication({
expectToFail: true,
input: {
workspaceMigration: {
actions: [
{
type: 'delete',
metadataName: 'fieldMetadata',
universalIdentifier: '20202020-784f-4042-b58f-ae8dbf718f6e',
},
],
},
applicationUniversalIdentifier: '20202020-784f-4042-b58f-ae8dbf718f6e',
version: '0.0.1',
},
});
@@ -1,21 +1,87 @@
import http from 'http';
import { v4 as uuidv4 } from 'uuid';
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 { buildTestTarball } from 'test/integration/metadata/suites/application/utils/build-test-tarball.util';
import { installApplication } from 'test/integration/metadata/suites/application/utils/install-application.util';
import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util';
import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util';
import { uninstallApplication } from 'test/integration/metadata/suites/application/utils/uninstall-application.util';
import { updateFeatureFlag } from 'test/integration/metadata/suites/utils/update-feature-flag.util';
import { v4 as uuidv4 } from 'uuid';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
const INVALID_UUID_APP_ID = uuidv4();
const INVALID_UUID_ROLE_ID = uuidv4();
describe('Install application should fail when entity does not exist', () => {
const TARBALL_SERVER_PORT = 4444;
const buildTarballServerUrl = (path: string) => `/${path}/app.tar.gz`;
const startTarballServer = (
routes: Record<string, Buffer>,
): Promise<http.Server> => {
return new Promise((resolve) => {
const server = http.createServer((req, res) => {
const tarball = routes[req.url ?? ''];
if (tarball) {
res.writeHead(200, {
'Content-Type': 'application/gzip',
'Content-Length': tarball.length,
});
res.end(tarball);
return;
}
res.writeHead(404);
res.end('Not found');
});
server.listen(TARBALL_SERVER_PORT, '127.0.0.1', () => {
resolve(server);
});
});
};
const stopServer = (server: http.Server): Promise<void> => {
return new Promise((resolve) => server.close(() => resolve()));
};
describe('Install application should fail', () => {
let appCreated = false;
let tarballServer: http.Server;
const NO_MANIFEST_APP_ID = uuidv4();
const NO_MANIFEST_VERSION = '0.1.0';
const INVALID_MANIFEST_APP_ID = uuidv4();
const INVALID_MANIFEST_VERSION = '0.1.0';
beforeAll(async () => {
jest.useRealTimers();
const tarballWithoutManifest = await buildTestTarball({
packageJson: { name: 'no-manifest', version: '0.1.0' },
});
const tarballWithInvalidManifest = await buildTestTarball({
rawManifestContent: '{ this is not valid json !!!',
packageJson: { name: 'invalid-manifest', version: '0.1.0' },
});
const routes: Record<string, Buffer> = {
[buildTarballServerUrl(`${NO_MANIFEST_APP_ID}@${NO_MANIFEST_VERSION}`)]:
tarballWithoutManifest,
[buildTarballServerUrl(
`${INVALID_MANIFEST_APP_ID}@${INVALID_MANIFEST_VERSION}`,
)]: tarballWithInvalidManifest,
};
tarballServer = await startTarballServer(routes);
await updateFeatureFlag({
featureFlag:
FeatureFlagKey.IS_APPLICATION_INSTALLATION_FROM_TARBALL_ENABLED,
@@ -31,15 +97,23 @@ describe('Install application should fail when entity does not exist', () => {
});
appCreated = true;
jest.useFakeTimers();
}, 60000);
afterAll(async () => {
jest.useRealTimers();
await stopServer(tarballServer);
await updateFeatureFlag({
featureFlag:
FeatureFlagKey.IS_APPLICATION_INSTALLATION_FROM_TARBALL_ENABLED,
value: false,
expectToFail: false,
});
jest.useFakeTimers();
});
afterEach(async () => {
@@ -53,23 +127,52 @@ describe('Install application should fail when entity does not exist', () => {
});
});
it('should fail with execution error when deleting non-existent field metadata', async () => {
it('should fail when tarball is not found on registry (404)', async () => {
jest.useRealTimers();
const { errors } = await installApplication({
expectToFail: true,
input: {
workspaceMigration: {
actions: [
{
type: 'delete',
metadataName: 'fieldMetadata',
universalIdentifier: '20202020-6110-4547-9fd0-2525257a2c3f',
},
],
},
applicationUniversalIdentifier: uuidv4(),
version: '9.9.9',
},
});
expectOneNotInternalServerErrorSnapshot({ errors });
jest.useFakeTimers();
});
it('should fail when tarball does not contain manifest.json', async () => {
jest.useRealTimers();
const { errors } = await installApplication({
expectToFail: true,
input: {
applicationUniversalIdentifier: NO_MANIFEST_APP_ID,
version: NO_MANIFEST_VERSION,
},
});
expectOneNotInternalServerErrorSnapshot({ errors });
jest.useFakeTimers();
});
it('should fail when manifest.json is not valid JSON', async () => {
jest.useRealTimers();
const { errors } = await installApplication({
expectToFail: true,
input: {
applicationUniversalIdentifier: INVALID_MANIFEST_APP_ID,
version: INVALID_MANIFEST_VERSION,
},
});
expectOneNotInternalServerErrorSnapshot({ errors });
jest.useFakeTimers();
});
it('should fail when a role has an invalid universalIdentifier', async () => {
@@ -0,0 +1,143 @@
import http from 'http';
import { type Manifest } from 'twenty-shared/application';
import { v4 as uuidv4 } from 'uuid';
import { buildTestTarball } from 'test/integration/metadata/suites/application/utils/build-test-tarball.util';
import { installApplication } from 'test/integration/metadata/suites/application/utils/install-application.util';
import { uninstallApplication } from 'test/integration/metadata/suites/application/utils/uninstall-application.util';
import { updateFeatureFlag } from 'test/integration/metadata/suites/utils/update-feature-flag.util';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
const TEST_APP_ID = uuidv4();
const TEST_ROLE_ID = uuidv4();
const TEST_VERSION = '0.1.0';
const TARBALL_SERVER_PORT = 4444;
const manifest: Manifest = {
application: {
universalIdentifier: TEST_APP_ID,
defaultRoleUniversalIdentifier: TEST_ROLE_ID,
displayName: 'Test Install App',
description: 'An application installed from tarball',
icon: 'IconTestPipe',
applicationVariables: {},
packageJsonChecksum: null,
yarnLockChecksum: null,
apiClientChecksum: null,
},
roles: [
{
universalIdentifier: TEST_ROLE_ID,
label: 'Default Role',
description: 'Default role for the test app',
},
],
skills: [],
objects: [],
fields: [],
logicFunctions: [],
frontComponents: [],
publicAssets: [],
views: [],
navigationMenuItems: [],
pageLayouts: [],
};
const packageJson = {
name: 'test-install-app',
version: TEST_VERSION,
};
const startTarballServer = (tarballBuffer: Buffer): Promise<http.Server> => {
return new Promise((resolve) => {
const expectedPath = `/${TEST_APP_ID}@${TEST_VERSION}/app.tar.gz`;
const server = http.createServer((req, res) => {
if (req.url === expectedPath) {
res.writeHead(200, {
'Content-Type': 'application/gzip',
'Content-Length': tarballBuffer.length,
});
res.end(tarballBuffer);
return;
}
res.writeHead(404);
res.end('Not found');
});
server.listen(TARBALL_SERVER_PORT, '127.0.0.1', () => {
resolve(server);
});
});
};
const stopServer = (server: http.Server): Promise<void> => {
return new Promise((resolve) => server.close(() => resolve()));
};
describe('Install application from tarball', () => {
let tarballServer: http.Server;
beforeAll(async () => {
jest.useRealTimers();
const tarballBuffer = await buildTestTarball({
manifest,
packageJson,
});
tarballServer = await startTarballServer(tarballBuffer);
await updateFeatureFlag({
featureFlag:
FeatureFlagKey.IS_APPLICATION_INSTALLATION_FROM_TARBALL_ENABLED,
value: true,
expectToFail: false,
});
jest.useFakeTimers();
}, 30000);
afterAll(async () => {
jest.useRealTimers();
await stopServer(tarballServer);
await updateFeatureFlag({
featureFlag:
FeatureFlagKey.IS_APPLICATION_INSTALLATION_FROM_TARBALL_ENABLED,
value: false,
expectToFail: false,
});
jest.useFakeTimers();
});
afterEach(async () => {
await uninstallApplication({
universalIdentifier: TEST_APP_ID,
expectToFail: false,
});
});
it('should successfully install an application from a tarball', async () => {
jest.useRealTimers();
const { data, errors } = await installApplication({
input: {
applicationUniversalIdentifier: TEST_APP_ID,
version: TEST_VERSION,
},
expectToFail: false,
});
expect(errors).toBeUndefined();
expect(data?.installApplication).toBe(true);
jest.useFakeTimers();
}, 60000);
});
@@ -0,0 +1,55 @@
import * as fs from 'fs/promises';
import { join } from 'path';
import { tmpdir } from 'os';
import { create } from 'tar';
import { v4 } from 'uuid';
import { type Manifest } from 'twenty-shared/application';
export const buildTestTarball = async ({
manifest,
rawManifestContent,
packageJson,
}: {
manifest?: Manifest;
rawManifestContent?: string;
packageJson: Record<string, unknown>;
}): Promise<Buffer> => {
const tempDir = join(tmpdir(), `test-tarball-${v4()}`);
try {
await fs.mkdir(tempDir, { recursive: true });
const fileNames: string[] = [];
const manifestContent =
rawManifestContent ??
(manifest ? JSON.stringify(manifest, null, 2) : undefined);
if (manifestContent) {
await fs.writeFile(join(tempDir, 'manifest.json'), manifestContent);
fileNames.push('manifest.json');
}
await fs.writeFile(
join(tempDir, 'package.json'),
JSON.stringify(packageJson, null, 2),
);
fileNames.push('package.json');
const tarballPath = join(tempDir, 'app.tar.gz');
await create(
{
gzip: true,
file: tarballPath,
cwd: tempDir,
},
fileNames,
);
return await fs.readFile(tarballPath);
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
};
@@ -1,13 +1,8 @@
import gql from 'graphql-tag';
export type InstallApplicationFactoryInput = {
workspaceMigration: {
actions: {
type: 'delete';
metadataName: string;
universalIdentifier: string;
}[];
};
applicationUniversalIdentifier: string;
version: string;
};
export const installApplicationQueryFactory = ({
@@ -16,11 +11,18 @@ export const installApplicationQueryFactory = ({
input: InstallApplicationFactoryInput;
}) => ({
query: gql`
mutation InstallApplication($workspaceMigration: WorkspaceMigrationInput!) {
installApplication(workspaceMigration: $workspaceMigration)
mutation InstallApplication(
$applicationUniversalIdentifier: UUID!
$version: String!
) {
installApplication(
applicationUniversalIdentifier: $applicationUniversalIdentifier
version: $version
)
}
`,
variables: {
workspaceMigration: input.workspaceMigration,
applicationUniversalIdentifier: input.applicationUniversalIdentifier,
version: input.version,
},
});
+53
View File
@@ -8720,6 +8720,15 @@ __metadata:
languageName: node
linkType: hard
"@isaacs/fs-minipass@npm:^4.0.0":
version: 4.0.1
resolution: "@isaacs/fs-minipass@npm:4.0.1"
dependencies:
minipass: "npm:^7.0.4"
checksum: 10c0/c25b6dc1598790d5b55c0947a9b7d111cfa92594db5296c3b907e2f533c033666f692a3939eadac17b1c7c40d362d0b0635dc874cbfe3e70db7c2b07cc97a5d2
languageName: node
linkType: hard
"@isaacs/string-locale-compare@npm:^1.0.1":
version: 1.1.0
resolution: "@isaacs/string-locale-compare@npm:1.1.0"
@@ -30972,6 +30981,13 @@ __metadata:
languageName: node
linkType: hard
"chownr@npm:^3.0.0":
version: 3.0.0
resolution: "chownr@npm:3.0.0"
checksum: 10c0/43925b87700f7e3893296c8e9c56cc58f926411cce3a6e5898136daaf08f08b9a8eb76d37d3267e707d0dcc17aed2e2ebdf5848c0c3ce95cf910a919935c1b10
languageName: node
linkType: hard
"chromatic@npm:^13.3.3":
version: 13.3.5
resolution: "chromatic@npm:13.3.5"
@@ -46527,6 +46543,13 @@ __metadata:
languageName: node
linkType: hard
"minipass@npm:^7.0.4":
version: 7.1.3
resolution: "minipass@npm:7.1.3"
checksum: 10c0/539da88daca16533211ea5a9ee98dc62ff5742f531f54640dd34429e621955e91cc280a91a776026264b7f9f6735947629f920944e9c1558369e8bf22eb33fbb
languageName: node
linkType: hard
"minizlib@npm:^2.0.0, minizlib@npm:^2.1.1, minizlib@npm:^2.1.2":
version: 2.1.2
resolution: "minizlib@npm:2.1.2"
@@ -46537,6 +46560,15 @@ __metadata:
languageName: node
linkType: hard
"minizlib@npm:^3.1.0":
version: 3.1.0
resolution: "minizlib@npm:3.1.0"
dependencies:
minipass: "npm:^7.1.2"
checksum: 10c0/5aad75ab0090b8266069c9aabe582c021ae53eb33c6c691054a13a45db3b4f91a7fb1bd79151e6b4e9e9a86727b522527c0a06ec7d45206b745d54cd3097bcec
languageName: node
linkType: hard
"mintlify@npm:latest":
version: 4.2.179
resolution: "mintlify@npm:4.2.179"
@@ -56561,6 +56593,19 @@ __metadata:
languageName: node
linkType: hard
"tar@npm:^7.5.9":
version: 7.5.9
resolution: "tar@npm:7.5.9"
dependencies:
"@isaacs/fs-minipass": "npm:^4.0.0"
chownr: "npm:^3.0.0"
minipass: "npm:^7.1.2"
minizlib: "npm:^3.1.0"
yallist: "npm:^5.0.0"
checksum: 10c0/e870beb1b2477135ca2abe86b2d18f7b35d0a4e3a37bbc523d3b8f7adca268dfab543f26528a431d569897f8c53a7cac745cdfbc4411c2f89aeeacc652b81b0a
languageName: node
linkType: hard
"temp@npm:^0.8.4":
version: 0.8.4
resolution: "temp@npm:0.8.4"
@@ -57954,6 +57999,7 @@ __metadata:
semver: "npm:7.6.3"
sharp: "npm:0.32.6"
stripe: "npm:19.3.1"
tar: "npm:^7.5.9"
temporal-polyfill: "npm:^0.3.0"
transliteration: "npm:2.3.5"
tsconfig-paths: "npm:^4.2.0"
@@ -61276,6 +61322,13 @@ __metadata:
languageName: node
linkType: hard
"yallist@npm:^5.0.0":
version: 5.0.0
resolution: "yallist@npm:5.0.0"
checksum: 10c0/a499c81ce6d4a1d260d4ea0f6d49ab4da09681e32c3f0472dee16667ed69d01dae63a3b81745a24bd78476ec4fcf856114cb4896ace738e01da34b2c42235416
languageName: node
linkType: hard
"yaml-ast-parser@npm:^0.0.43":
version: 0.0.43
resolution: "yaml-ast-parser@npm:0.0.43"