diff --git a/packages/twenty-front/src/pages/settings/applications/SettingsApplicationDetails.tsx b/packages/twenty-front/src/pages/settings/applications/SettingsApplicationDetails.tsx index afcc4891bf..15826c66a1 100644 --- a/packages/twenty-front/src/pages/settings/applications/SettingsApplicationDetails.tsx +++ b/packages/twenty-front/src/pages/settings/applications/SettingsApplicationDetails.tsx @@ -33,13 +33,13 @@ import { IconSettings, } from 'twenty-ui/icon'; import { - ApplicationRegistrationSourceType, FindMarketplaceAppDetailDocument, FindMarketplaceAppManifestDocument, FindOneApplicationDocument, PermissionFlagType, UninstallApplicationDocument, } from '~/generated-metadata/graphql'; +import { isUpgradableApplicationSourceType } from '~/pages/settings/applications/utils/isUpgradableApplicationSourceType'; import { useNavigateSettings } from '~/hooks/useNavigateSettings'; import { SettingsSectionSkeletonLoader } from '@/settings/components/SettingsSectionSkeletonLoader'; import { CUSTOM_APPLICATION_ILLUSTRATIONS } from '~/pages/settings/applications/constants/CustomApplicationIllustrations'; @@ -115,7 +115,6 @@ export const SettingsApplicationDetails = () => { ); const sourceType = application?.applicationRegistration?.sourceType; - const isNpmApp = sourceType === ApplicationRegistrationSourceType.NPM; const registrationId = detail?.id ?? application?.applicationRegistration?.id; const currentVersion = application?.version; const latestAvailableVersion = @@ -123,7 +122,7 @@ export const SettingsApplicationDetails = () => { application?.applicationRegistration?.latestAvailableVersion; const hasUpdate = - isNpmApp && + isUpgradableApplicationSourceType(sourceType) && isDefined(latestAvailableVersion) && isDefined(currentVersion) && isNewerSemver(latestAvailableVersion, currentVersion); diff --git a/packages/twenty-front/src/pages/settings/applications/SettingsAvailableApplicationDetails.tsx b/packages/twenty-front/src/pages/settings/applications/SettingsAvailableApplicationDetails.tsx index b93fa08d95..87d3e82df3 100644 --- a/packages/twenty-front/src/pages/settings/applications/SettingsAvailableApplicationDetails.tsx +++ b/packages/twenty-front/src/pages/settings/applications/SettingsAvailableApplicationDetails.tsx @@ -41,6 +41,7 @@ import { SettingsApplicationDetailAboutTab } from '~/pages/settings/applications import { SettingsApplicationDetailContentTab } from '~/pages/settings/applications/tabs/SettingsApplicationDetailContentTab'; import { SettingsApplicationPermissionsTab } from '~/pages/settings/applications/tabs/SettingsApplicationPermissionsTab'; import { isNewerSemver } from '~/pages/settings/applications/utils/isNewerSemver'; +import { isUpgradableApplicationSourceType } from '~/pages/settings/applications/utils/isUpgradableApplicationSourceType'; const AVAILABLE_APPLICATION_DETAIL_ID = 'available-application-detail'; @@ -103,7 +104,7 @@ export const SettingsAvailableApplicationDetails = () => { const defaultRole = getMarketplaceAppDefaultRoleManifest(detail); const hasUpdate = - isNpmApp && + isUpgradableApplicationSourceType(sourceType) && isDefined(latestAvailableVersion) && isDefined(currentVersion) && isNewerSemver(latestAvailableVersion, currentVersion); diff --git a/packages/twenty-front/src/pages/settings/applications/components/SettingsApplicationVersionContainer.tsx b/packages/twenty-front/src/pages/settings/applications/components/SettingsApplicationVersionContainer.tsx index 223a08cba9..6cf6129236 100644 --- a/packages/twenty-front/src/pages/settings/applications/components/SettingsApplicationVersionContainer.tsx +++ b/packages/twenty-front/src/pages/settings/applications/components/SettingsApplicationVersionContainer.tsx @@ -7,11 +7,9 @@ import { t } from '@lingui/core/macro'; import { isDefined } from 'twenty-shared/utils'; import { IconCircleDot, IconStatusChange, IconUpload } from 'twenty-ui/icon'; import { Button } from 'twenty-ui/input'; -import { - ApplicationRegistrationSourceType, - type Application, -} from '~/generated-metadata/graphql'; +import { type Application } from '~/generated-metadata/graphql'; import { isNewerSemver } from '~/pages/settings/applications/utils/isNewerSemver'; +import { isUpgradableApplicationSourceType } from '~/pages/settings/applications/utils/isUpgradableApplicationSourceType'; const StyledContainer = styled.div` display: flex; @@ -37,14 +35,14 @@ export const SettingsApplicationVersionContainer = ({ const currentVersion = application?.version; const sourceType = application?.applicationRegistration?.sourceType; - const isNpmApp = sourceType === ApplicationRegistrationSourceType.NPM; + const isUpgradableApp = isUpgradableApplicationSourceType(sourceType); - const latestVersion = isNpmApp + const latestVersion = isUpgradableApp ? (latestAvailableVersion ?? currentVersion) : currentVersion; const hasUpdate = - isNpmApp && + isUpgradableApp && isDefined(latestAvailableVersion) && isDefined(currentVersion) && isNewerSemver(latestAvailableVersion, currentVersion); @@ -74,7 +72,7 @@ export const SettingsApplicationVersionContainer = ({ /> ), }, - ...(isNpmApp + ...(isUpgradableApp ? [ { Icon: IconStatusChange, diff --git a/packages/twenty-front/src/pages/settings/applications/utils/isUpgradableApplicationSourceType.ts b/packages/twenty-front/src/pages/settings/applications/utils/isUpgradableApplicationSourceType.ts new file mode 100644 index 0000000000..3e37f5c9a0 --- /dev/null +++ b/packages/twenty-front/src/pages/settings/applications/utils/isUpgradableApplicationSourceType.ts @@ -0,0 +1,7 @@ +import { ApplicationRegistrationSourceType } from '~/generated-metadata/graphql'; + +export const isUpgradableApplicationSourceType = ( + sourceType: ApplicationRegistrationSourceType | null | undefined, +): boolean => + sourceType === ApplicationRegistrationSourceType.NPM || + sourceType === ApplicationRegistrationSourceType.TARBALL; diff --git a/packages/twenty-server/src/engine/core-modules/application/application-install/application-install.service.ts b/packages/twenty-server/src/engine/core-modules/application/application-install/application-install.service.ts index 3cf6d72977..adac1a5d65 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-install/application-install.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-install/application-install.service.ts @@ -4,7 +4,6 @@ import { InjectRepository } from '@nestjs/typeorm'; import { promises as fs } from 'fs'; import { isAbsolute, relative, resolve } from 'path'; -import semver from 'semver'; import { Manifest } from 'twenty-shared/application'; import { FileFolder } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; @@ -24,7 +23,10 @@ import { ApplicationPackageFetcherService, } from 'src/engine/core-modules/application/application-package/application-package-fetcher.service'; import { ApplicationVersionValidationService } from 'src/engine/core-modules/application/application-package/application-version-validation.service'; -import { VERSION_REASON_TO_APPLICATION_EXCEPTION_CODE } from 'src/engine/core-modules/application/application-package/constants/version-reason-to-exception-code.constant'; +import { + VERSION_PROGRESSION_REASON_TO_INSTALL_EXCEPTION_CODE, + VERSION_REASON_TO_APPLICATION_EXCEPTION_CODE, +} from 'src/engine/core-modules/application/application-package/constants/version-reason-to-exception-code.constant'; import { ApplicationManifestApplyService } from 'src/engine/core-modules/application/application-manifest/application-manifest-apply.service'; import { ApplicationSyncService } from 'src/engine/core-modules/application/application-manifest/application-sync.service'; import { CacheLockService } from 'src/engine/core-modules/cache-lock/cache-lock.service'; @@ -113,9 +115,37 @@ export class ApplicationInstallService { } private async doInstallApplication( - appRegistration: ApplicationRegistrationEntity, + preLockAppRegistration: ApplicationRegistrationEntity, params: { version?: string; workspaceId: string }, ): Promise { + // Re-read inside the lock so the authorization below cannot act on stale + // listing or ownership state. + const appRegistration = await this.appRegistrationRepository.findOne({ + where: { id: preLockAppRegistration.id }, + }); + + if (!appRegistration) { + throw new ApplicationException( + `Application registration with id ${preLockAppRegistration.id} not found`, + ApplicationExceptionCode.APPLICATION_NOT_FOUND, + ); + } + + // Tarball registrations that are neither listed nor pre-installed are + // only installable by their owner workspace. + if ( + appRegistration.sourceType === + ApplicationRegistrationSourceType.TARBALL && + !appRegistration.isListed && + !appRegistration.isPreInstalled && + appRegistration.ownerWorkspaceId !== params.workspaceId + ) { + throw new ApplicationException( + `Application registration ${appRegistration.universalIdentifier} is not available for this workspace`, + ApplicationExceptionCode.FORBIDDEN, + ); + } + const resolvedPackage = await this.applicationPackageFetcherService.resolvePackage( appRegistration, @@ -265,28 +295,22 @@ export class ApplicationInstallService { isDefined(application.version) && isDefined(incomingVersion) ) { - if (!isDefined(semver.valid(incomingVersion))) { + const progression = + this.applicationVersionValidationService.validateVersionProgression({ + incomingVersion, + currentVersion: application.version, + universalIdentifier, + action: 'install', + }); + + if (!progression.allowed) { throw new ApplicationException( - `Invalid version "${incomingVersion}" in package.json. Must be a valid semver version.`, - ApplicationExceptionCode.INVALID_INPUT, + progression.message, + VERSION_PROGRESSION_REASON_TO_INSTALL_EXCEPTION_CODE[ + progression.reason + ], ); } - - if (isDefined(semver.valid(application.version))) { - if (semver.eq(incomingVersion, application.version)) { - throw new ApplicationException( - `${universalIdentifier}@${incomingVersion} is already installed in this workspace.`, - ApplicationExceptionCode.APP_ALREADY_INSTALLED, - ); - } - - if (semver.lt(incomingVersion, application.version)) { - throw new ApplicationException( - `Cannot install ${universalIdentifier}@${incomingVersion}: version ${application.version} is already installed and downgrading is not allowed.`, - ApplicationExceptionCode.CANNOT_DOWNGRADE_APPLICATION, - ); - } - } } await this.writeFilesToStorage( diff --git a/packages/twenty-server/src/engine/core-modules/application/application-package/application-version-validation.service.spec.ts b/packages/twenty-server/src/engine/core-modules/application/application-package/application-version-validation.service.spec.ts index 57537a0eca..3709826eb1 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-package/application-version-validation.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-package/application-version-validation.service.spec.ts @@ -148,4 +148,93 @@ describe('ApplicationVersionValidationService', () => { expect(getInferredVersion).not.toHaveBeenCalled(); }); }); + + describe('validateVersionProgression', () => { + it('should reject an invalid incoming version', () => { + const result = service.validateVersionProgression({ + incomingVersion: 'not-semver', + currentVersion: '1.0.0', + universalIdentifier: 'my-app', + action: 'install', + }); + + expect(result).toMatchObject({ + allowed: false, + reason: 'INVALID_INCOMING_VERSION', + }); + }); + + it('should allow any progression when the current version is not valid semver', () => { + expect( + service.validateVersionProgression({ + incomingVersion: '1.0.0', + currentVersion: 'unknown', + universalIdentifier: 'my-app', + action: 'install', + }), + ).toEqual({ allowed: true }); + }); + + it('should allow a higher version', () => { + for (const action of ['install', 'deploy'] as const) { + expect( + service.validateVersionProgression({ + incomingVersion: '1.1.0', + currentVersion: '1.0.0', + universalIdentifier: 'my-app', + action, + }), + ).toEqual({ allowed: true }); + } + }); + + it('should reject the same version on install as already installed', () => { + const result = service.validateVersionProgression({ + incomingVersion: '1.0.0', + currentVersion: '1.0.0', + universalIdentifier: 'my-app', + action: 'install', + }); + + expect(result).toMatchObject({ + allowed: false, + reason: 'SAME_VERSION', + message: 'my-app@1.0.0 is already installed in this workspace.', + }); + }); + + it('should reject a lower version on install as a downgrade', () => { + const result = service.validateVersionProgression({ + incomingVersion: '0.9.0', + currentVersion: '1.0.0', + universalIdentifier: 'my-app', + action: 'install', + }); + + expect(result).toMatchObject({ + allowed: false, + reason: 'DOWNGRADE', + }); + }); + + it('should reject same and lower versions on deploy with the bump message', () => { + for (const [incomingVersion, reason] of [ + ['1.0.0', 'SAME_VERSION'], + ['0.9.0', 'DOWNGRADE'], + ] as const) { + const result = service.validateVersionProgression({ + incomingVersion, + currentVersion: '1.0.0', + universalIdentifier: 'my-app', + action: 'deploy', + }); + + expect(result).toMatchObject({ + allowed: false, + reason, + message: `Cannot deploy my-app@${incomingVersion}: version must be higher than the currently deployed version 1.0.0. Please bump the version in package.json.`, + }); + } + }); + }); }); diff --git a/packages/twenty-server/src/engine/core-modules/application/application-package/application-version-validation.service.ts b/packages/twenty-server/src/engine/core-modules/application/application-package/application-version-validation.service.ts index addff3870d..24cc9f39cd 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-package/application-version-validation.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-package/application-version-validation.service.ts @@ -20,6 +20,19 @@ export type VersionValidationResult = message: string; }; +export type VersionProgressionFailureReason = + | 'INVALID_INCOMING_VERSION' + | 'SAME_VERSION' + | 'DOWNGRADE'; + +export type VersionProgressionResult = + | { allowed: true } + | { + allowed: false; + reason: VersionProgressionFailureReason; + message: string; + }; + @Injectable() export class ApplicationVersionValidationService { constructor( @@ -89,6 +102,60 @@ export class ApplicationVersionValidationService { }); } + // A current version that is not valid semver never blocks: there is + // nothing reliable to compare against. + validateVersionProgression({ + incomingVersion, + currentVersion, + universalIdentifier, + action, + }: { + incomingVersion: string; + currentVersion: string; + universalIdentifier: string; + action: 'install' | 'deploy'; + }): VersionProgressionResult { + if (!isDefined(semver.valid(incomingVersion))) { + return { + allowed: false, + reason: 'INVALID_INCOMING_VERSION', + message: `Invalid version "${incomingVersion}" in package.json. Must be a valid semver version.`, + }; + } + + if (!isDefined(semver.valid(currentVersion))) { + return { allowed: true }; + } + + if (action === 'deploy' && semver.lte(incomingVersion, currentVersion)) { + return { + allowed: false, + reason: semver.eq(incomingVersion, currentVersion) + ? 'SAME_VERSION' + : 'DOWNGRADE', + message: `Cannot deploy ${universalIdentifier}@${incomingVersion}: version must be higher than the currently deployed version ${currentVersion}. Please bump the version in package.json.`, + }; + } + + if (action === 'install' && semver.eq(incomingVersion, currentVersion)) { + return { + allowed: false, + reason: 'SAME_VERSION', + message: `${universalIdentifier}@${incomingVersion} is already installed in this workspace.`, + }; + } + + if (action === 'install' && semver.lt(incomingVersion, currentVersion)) { + return { + allowed: false, + reason: 'DOWNGRADE', + message: `Cannot install ${universalIdentifier}@${incomingVersion}: version ${currentVersion} is already installed and downgrading is not allowed.`, + }; + } + + return { allowed: true }; + } + private validateVersionAgainstRange({ version, requiredVersionRange, diff --git a/packages/twenty-server/src/engine/core-modules/application/application-package/constants/version-reason-to-exception-code.constant.ts b/packages/twenty-server/src/engine/core-modules/application/application-package/constants/version-reason-to-exception-code.constant.ts index 222935e89a..77f1097927 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-package/constants/version-reason-to-exception-code.constant.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-package/constants/version-reason-to-exception-code.constant.ts @@ -1,4 +1,7 @@ -import { type VersionValidationFailureReason } from 'src/engine/core-modules/application/application-package/application-version-validation.service'; +import { + type VersionProgressionFailureReason, + type VersionValidationFailureReason, +} from 'src/engine/core-modules/application/application-package/application-version-validation.service'; import { ApplicationRegistrationExceptionCode } from 'src/engine/core-modules/application/application-registration/application-registration.exception'; import { ApplicationExceptionCode } from 'src/engine/core-modules/application/application.exception'; @@ -32,3 +35,21 @@ export const VERSION_REASON_TO_APPLICATION_REGISTRATION_EXCEPTION_CODE: Record< WORKSPACE_INCOMPATIBLE: ApplicationRegistrationExceptionCode.SERVER_VERSION_INCOMPATIBLE, }; + +export const VERSION_PROGRESSION_REASON_TO_INSTALL_EXCEPTION_CODE: Record< + VersionProgressionFailureReason, + ApplicationExceptionCode +> = { + INVALID_INCOMING_VERSION: ApplicationExceptionCode.INVALID_INPUT, + SAME_VERSION: ApplicationExceptionCode.APP_ALREADY_INSTALLED, + DOWNGRADE: ApplicationExceptionCode.CANNOT_DOWNGRADE_APPLICATION, +}; + +export const VERSION_PROGRESSION_REASON_TO_DEPLOY_EXCEPTION_CODE: Record< + VersionProgressionFailureReason, + ApplicationRegistrationExceptionCode +> = { + INVALID_INCOMING_VERSION: ApplicationRegistrationExceptionCode.INVALID_INPUT, + SAME_VERSION: ApplicationRegistrationExceptionCode.VERSION_ALREADY_EXISTS, + DOWNGRADE: ApplicationRegistrationExceptionCode.VERSION_ALREADY_EXISTS, +}; diff --git a/packages/twenty-server/src/engine/core-modules/application/application-registration/application-registration.service.ts b/packages/twenty-server/src/engine/core-modules/application/application-registration/application-registration.service.ts index feddb9fbc6..4e25f43719 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-registration/application-registration.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-registration/application-registration.service.ts @@ -7,6 +7,7 @@ import * as bcrypt from 'bcrypt'; import { type Manifest } from 'twenty-shared/application'; import { isDefined } from 'twenty-shared/utils'; import { ILike, IsNull, type FindOptionsWhere, type Repository } from 'typeorm'; +import { type QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'; import { v4 } from 'uuid'; import { CoreEntityCacheService } from 'src/engine/core-entity-cache/services/core-entity-cache.service'; @@ -31,6 +32,7 @@ import { type UpdateApplicationRegistrationPayload, } from 'src/engine/core-modules/application/application-registration/dtos/update-application-registration.input'; import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum'; +import { buildRegistrationManifestUpdateFields } from 'src/engine/core-modules/application/application-registration/utils/build-registration-manifest-update-fields.util'; import { fromManifestApplicationToDisplayFields } from 'src/engine/core-modules/application/application-registration/utils/from-manifest-application-to-display-fields.util'; import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; import { validateRedirectUri } from 'src/engine/core-modules/auth/utils/validate-redirect-uri.util'; @@ -45,8 +47,6 @@ const BCRYPT_SALT_ROUNDS = 10; const MAX_APPLICATION_REGISTRATIONS_PAGE_SIZE = 100; -// Sized well above the manifest save + variable schema sync duration so the -// lease cannot expire mid-update and let a concurrent refresh interleave. const APPLICATION_REGISTRATION_UPDATE_LOCK_OPTIONS = { ttl: 60_000, ms: 500, @@ -432,12 +432,25 @@ export class ApplicationRegistrationService { sourceType, latestAvailableVersion, preventVersionDowngrade = false, + additionalFields, }: { applicationRegistrationId: string; manifest: Manifest; sourceType?: ApplicationRegistrationSourceType; - latestAvailableVersion?: string; + // null clears the stored version; undefined leaves it untouched. + latestAvailableVersion?: string | null; preventVersionDowngrade?: boolean; + additionalFields?: Partial< + Pick< + ApplicationRegistrationEntity, + | 'name' + | 'sourcePackage' + | 'tarballFileId' + | 'isListed' + | 'isVetted' + | 'ownerWorkspaceId' + > + >; }): Promise { return this.cacheLockService.withLock( async () => { @@ -461,43 +474,35 @@ export class ApplicationRegistrationService { return false; } - const displayFields = fromManifestApplicationToDisplayFields( - manifest.application, - ); + const manifestUpdateFields = buildRegistrationManifestUpdateFields({ + manifestApplication: manifest.application, + existingGalleryImages: existing.galleryImages, + }); - // Gallery image files are stored by the source-specific flows (tarball - // upload, dev sync); keep their fileIds for paths that did not change. - const existingFileIdByPath = new Map( - (existing.galleryImages ?? []).map(({ path, fileId }) => [ - path, - fileId, - ]), - ); + // The stored logo file belongs to the previous logo path. + const hasLogoPathChanged = + (manifestUpdateFields.logo ?? null) !== (existing.logo ?? null); - // One transaction so the registration row and its variable schemas - // always come from the same manifest, even when the sync fails midway. + // Partial update in one transaction: the row and its variable schemas + // stay on the same manifest without clobbering columns written by + // flows outside this lock. await this.applicationRegistrationRepository.manager.transaction( async (entityManager) => { await entityManager .getRepository(ApplicationRegistrationEntity) - .save({ - ...existing, - name: manifest.application.displayName, + .update(applicationRegistrationId, { + name: manifest.application?.displayName ?? existing.name, manifest, - ...displayFields, - galleryImages: displayFields.galleryImages.map( - (galleryImage) => ({ - ...galleryImage, - fileId: existingFileIdByPath.get(galleryImage.path) ?? null, - }), - ), + ...manifestUpdateFields, + ...(hasLogoPathChanged && { logoFileId: null }), ...(sourceType !== undefined && { sourceType }), ...(latestAvailableVersion !== undefined && { latestAvailableVersion, }), - }); + ...additionalFields, + } as QueryDeepPartialEntity); - if (isDefined(manifest.application.serverVariables)) { + if (isDefined(manifest.application?.serverVariables)) { await this.applicationRegistrationVariableService.syncVariableSchemas( applicationRegistrationId, manifest.application.serverVariables, @@ -587,18 +592,38 @@ export class ApplicationRegistrationService { const isVetted = vettedIdentifiers.has(params.universalIdentifier); + if (isDefined(existing) && isDefined(params.manifest)) { + const isNewVersion = await this.setLatestAvailableVersionIfChanged( + existing.id, + params.latestAvailableVersion ?? null, + ); + + await this.updateFromManifest({ + applicationRegistrationId: existing.id, + manifest: params.manifest, + sourceType: params.sourceType, + latestAvailableVersion: params.latestAvailableVersion, + additionalFields: { + name: params.name, + sourcePackage: params.sourcePackage, + isVetted, + }, + }); + + if (isNewVersion) { + this.emitRegistrationPublishMetric({ + isNewRegistration: false, + universalIdentifier: params.universalIdentifier, + name: params.name, + sourceType: params.sourceType, + version: params.latestAvailableVersion, + }); + } + + return; + } + if (isDefined(existing)) { - const displayFields = fromManifestApplicationToDisplayFields( - params.manifest?.application, - ); - - const existingFileIdByPath = new Map( - (existing.galleryImages ?? []).map(({ path, fileId }) => [ - path, - fileId, - ]), - ); - const isNewVersion = await this.setLatestAvailableVersionIfChanged( existing.id, params.latestAvailableVersion ?? null, @@ -612,13 +637,11 @@ export class ApplicationRegistrationService { latestAvailableVersion: params.latestAvailableVersion, isVetted, manifest: params.manifest, - ...displayFields, - galleryImages: displayFields.galleryImages.map((galleryImage) => ({ - ...galleryImage, - fileId: existingFileIdByPath.get(galleryImage.path) ?? null, - })), + ...fromManifestApplicationToDisplayFields(params.manifest?.application), }); + await this.invalidateMarketplaceAppsCache(); + if (isNewVersion) { this.emitRegistrationPublishMetric({ isNewRegistration: false, @@ -628,48 +651,42 @@ export class ApplicationRegistrationService { version: params.latestAvailableVersion, }); } - } else { - const registration = this.applicationRegistrationRepository.create({ - universalIdentifier: params.universalIdentifier, - name: params.name, - sourceType: params.sourceType, - sourcePackage: params.sourcePackage, - latestAvailableVersion: params.latestAvailableVersion, - isListed: true, - isVetted, - manifest: params.manifest, - ...fromManifestApplicationToDisplayFields(params.manifest?.application), - oAuthClientId: v4(), - oAuthRedirectUris: [], - oAuthScopes: [], - ownerWorkspaceId: null, - }); - await this.applicationRegistrationRepository.save(registration); - - this.emitRegistrationPublishMetric({ - isNewRegistration: true, - universalIdentifier: params.universalIdentifier, - name: params.name, - sourceType: params.sourceType, - version: params.latestAvailableVersion, - }); + return; } + const registration = this.applicationRegistrationRepository.create({ + universalIdentifier: params.universalIdentifier, + name: params.name, + sourceType: params.sourceType, + sourcePackage: params.sourcePackage, + latestAvailableVersion: params.latestAvailableVersion, + isListed: true, + isVetted, + manifest: params.manifest, + ...fromManifestApplicationToDisplayFields(params.manifest?.application), + oAuthClientId: v4(), + oAuthRedirectUris: [], + oAuthScopes: [], + ownerWorkspaceId: null, + }); + + await this.applicationRegistrationRepository.save(registration); + + this.emitRegistrationPublishMetric({ + isNewRegistration: true, + universalIdentifier: params.universalIdentifier, + name: params.name, + sourceType: params.sourceType, + version: params.latestAvailableVersion, + }); + await this.invalidateMarketplaceAppsCache(); if (!isDefined(params.manifest?.application?.serverVariables)) { return; } - const registration = await this.findOneByUniversalIdentifier( - params.universalIdentifier, - ); - - if (!isDefined(registration)) { - return; - } - await this.applicationRegistrationVariableService.syncVariableSchemas( registration.id, params.manifest.application.serverVariables, diff --git a/packages/twenty-server/src/engine/core-modules/application/application-registration/application-tarball.service.ts b/packages/twenty-server/src/engine/core-modules/application/application-registration/application-tarball.service.ts index 55a6e93d42..c77497aee1 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-registration/application-tarball.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-registration/application-tarball.service.ts @@ -5,19 +5,19 @@ import { promises as fs } from 'fs'; import { tmpdir } from 'os'; import { isAbsolute, join, relative, resolve } from 'path'; -import semver from 'semver'; import { FileFolder } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; import { Repository } from 'typeorm'; -import { type QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'; import { v4 } from 'uuid'; import { ApplicationVersionValidationService } from 'src/engine/core-modules/application/application-package/application-version-validation.service'; -import { VERSION_REASON_TO_APPLICATION_REGISTRATION_EXCEPTION_CODE } from 'src/engine/core-modules/application/application-package/constants/version-reason-to-exception-code.constant'; +import { + VERSION_PROGRESSION_REASON_TO_DEPLOY_EXCEPTION_CODE, + VERSION_REASON_TO_APPLICATION_REGISTRATION_EXCEPTION_CODE, +} from 'src/engine/core-modules/application/application-package/constants/version-reason-to-exception-code.constant'; 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 { ApplicationRegistrationAssetService } from 'src/engine/core-modules/application/application-registration/application-registration-asset.service'; import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity'; import { @@ -29,7 +29,7 @@ import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/appli import { fromManifestApplicationToDisplayFields } from 'src/engine/core-modules/application/application-registration/utils/from-manifest-application-to-display-fields.util'; import { ApplicationService } from 'src/engine/core-modules/application/application.service'; import { FileStorageService } from 'src/engine/core-modules/file-storage/services/file-storage.service'; -import type { ApplicationManifest } from 'twenty-shared/application'; +import type { ApplicationManifest, Manifest } from 'twenty-shared/application'; @Injectable() export class ApplicationTarballService { @@ -41,7 +41,6 @@ export class ApplicationTarballService { private readonly fileStorageService: FileStorageService, private readonly applicationRegistrationAssetService: ApplicationRegistrationAssetService, private readonly applicationService: ApplicationService, - private readonly applicationRegistrationVariableService: ApplicationRegistrationVariableService, private readonly applicationVersionValidationService: ApplicationVersionValidationService, private readonly applicationRegistrationService: ApplicationRegistrationService, ) {} @@ -56,48 +55,8 @@ export class ApplicationTarballService { await fs.mkdir(tempDir, { recursive: true }); try { - const tarballPath = join(tempDir, 'app.tar.gz'); - - await fs.writeFile(tarballPath, params.tarballBuffer); - - const extractDir = join(tempDir, 'extracted'); - - await fs.mkdir(extractDir, { recursive: true }); - await extractTarballSecurely(tarballPath, extractDir); - - const contentDir = await resolvePackageContentDir(extractDir); - - const manifest = await readJsonFile<{ - application?: ApplicationManifest; - }>(contentDir, 'manifest.json'); - - const packageJson = await readJsonFile<{ - version: string; - engines?: { twenty?: string }; - }>(contentDir, 'package.json'); - - if (manifest === null) { - throw new ApplicationRegistrationException( - 'manifest.json not found or invalid in tarball', - ApplicationRegistrationExceptionCode.INVALID_INPUT, - ); - } - - const requiredServerVersion = packageJson?.engines?.twenty; - - const versionValidation = - await this.applicationVersionValidationService.validateServerCompatibility( - requiredServerVersion, - ); - - if (!versionValidation.compatible) { - throw new ApplicationRegistrationException( - versionValidation.message, - VERSION_REASON_TO_APPLICATION_REGISTRATION_EXCEPTION_CODE[ - versionValidation.reason - ], - ); - } + const { contentDir, manifest, packageJson } = + await this.extractAndValidateTarball(tempDir, params.tarballBuffer); const universalIdentifier = params.universalIdentifier ?? manifest.application?.universalIdentifier; @@ -109,120 +68,57 @@ export class ApplicationTarballService { ); } - let appRegistration = await this.appRegistrationRepository.findOne({ - where: { - universalIdentifier, - ownerWorkspaceId: params.ownerWorkspaceId, + const existingRegistration = await this.appRegistrationRepository.findOne( + { + where: { + universalIdentifier, + ownerWorkspaceId: params.ownerWorkspaceId, + }, }, + ); + + const isNewRegistration = !isDefined(existingRegistration); + const previousLatestAvailableVersion = + existingRegistration?.latestAvailableVersion ?? null; + + const appRegistration = isDefined(existingRegistration) + ? this.assertTarballCanReplaceRegistration({ + registration: existingRegistration, + incomingVersion: packageJson?.version, + universalIdentifier, + }) + : await this.createTarballRegistration({ + universalIdentifier, + manifest, + packageJsonVersion: packageJson?.version ?? null, + ownerWorkspaceId: params.ownerWorkspaceId, + }); + + const savedFile = await this.storeTarballFile({ + appRegistration, + tarballBuffer: params.tarballBuffer, + ownerWorkspaceId: params.ownerWorkspaceId, }); - const isNewRegistration = !isDefined(appRegistration); - const previousLatestAvailableVersion = - appRegistration?.latestAvailableVersion ?? null; - - if (isDefined(appRegistration)) { - if ( - appRegistration.sourceType !== - ApplicationRegistrationSourceType.LOCAL && - appRegistration.sourceType !== - ApplicationRegistrationSourceType.TARBALL - ) { - throw new ApplicationRegistrationException( - `This app is registered as ${appRegistration.sourceType}. Cannot upload tarball.`, - ApplicationRegistrationExceptionCode.SOURCE_CHANNEL_MISMATCH, - ); - } - - if ( - appRegistration.sourceType === - ApplicationRegistrationSourceType.TARBALL && - isDefined(appRegistration.latestAvailableVersion) && - isDefined(packageJson?.version) - ) { - const incomingVersion = packageJson.version; - const currentVersion = appRegistration.latestAvailableVersion; - - if (!isDefined(semver.valid(incomingVersion))) { - throw new ApplicationRegistrationException( - `Invalid version "${incomingVersion}" in package.json. Must be a valid semver version.`, - ApplicationRegistrationExceptionCode.INVALID_INPUT, - ); - } - - if ( - isDefined(semver.valid(currentVersion)) && - semver.lte(incomingVersion, currentVersion) - ) { - throw new ApplicationRegistrationException( - `Cannot deploy ${universalIdentifier}@${incomingVersion}: version must be higher than the currently deployed version ${currentVersion}. Please bump the version in package.json.`, - ApplicationRegistrationExceptionCode.VERSION_ALREADY_EXISTS, - ); - } - } - } else { - appRegistration = this.appRegistrationRepository.create({ - universalIdentifier, - name: manifest.application?.displayName ?? 'Unknown App', - sourceType: ApplicationRegistrationSourceType.TARBALL, - manifest, - ...fromManifestApplicationToDisplayFields(manifest.application), - latestAvailableVersion: packageJson?.version ?? null, + await this.applicationRegistrationService.updateFromManifest({ + applicationRegistrationId: appRegistration.id, + manifest: manifest as Manifest, + sourceType: ApplicationRegistrationSourceType.TARBALL, + latestAvailableVersion: packageJson?.version ?? null, + additionalFields: { + tarballFileId: savedFile.id, isListed: false, isVetted: false, - oAuthClientId: v4(), - oAuthRedirectUris: [], - oAuthScopes: [], ownerWorkspaceId: params.ownerWorkspaceId, - }); - - appRegistration = - await this.appRegistrationRepository.save(appRegistration); - } - - const { workspaceCustomFlatApplication } = - await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow( - { workspaceId: params.ownerWorkspaceId }, - ); - - const savedFile = await this.fileStorageService.writeFile({ - sourceFile: params.tarballBuffer, - resourcePath: `${appRegistration.id}/app.tar.gz`, - fileFolder: FileFolder.AppTarball, - applicationUniversalIdentifier: - workspaceCustomFlatApplication.universalIdentifier, - workspaceId: params.ownerWorkspaceId, - fileId: appRegistration.tarballFileId ?? v4(), - settings: { - isTemporaryFile: false, - toDelete: false, }, }); - await this.appRegistrationRepository.update(appRegistration.id, { - sourceType: ApplicationRegistrationSourceType.TARBALL, - tarballFileId: savedFile.id, - name: manifest.application?.displayName ?? 'Unknown App', - manifest, - ...fromManifestApplicationToDisplayFields(manifest.application), - latestAvailableVersion: packageJson?.version ?? null, - isListed: false, - isVetted: false, - ownerWorkspaceId: params.ownerWorkspaceId, - } as QueryDeepPartialEntity); - await this.applicationRegistrationAssetService.storeRegistrationAssets({ applicationRegistrationId: appRegistration.id, manifestApplication: manifest.application, readAsset: (path) => this.readAssetFromContentDir(contentDir, path), }); - if (manifest.application?.serverVariables) { - await this.applicationRegistrationVariableService.syncVariableSchemas( - appRegistration.id, - manifest.application.serverVariables, - ); - } - this.logger.log( `Tarball uploaded for app ${universalIdentifier} (registration ${appRegistration.id})`, ); @@ -249,6 +145,161 @@ export class ApplicationTarballService { } } + private async extractAndValidateTarball( + tempDir: string, + tarballBuffer: Buffer, + ): Promise<{ + contentDir: string; + manifest: { application?: ApplicationManifest }; + packageJson: { version: string; engines?: { twenty?: string } } | null; + }> { + const tarballPath = join(tempDir, 'app.tar.gz'); + + await fs.writeFile(tarballPath, tarballBuffer); + + const extractDir = join(tempDir, 'extracted'); + + await fs.mkdir(extractDir, { recursive: true }); + await extractTarballSecurely(tarballPath, extractDir); + + const contentDir = await resolvePackageContentDir(extractDir); + + const manifest = await readJsonFile<{ + application?: ApplicationManifest; + }>(contentDir, 'manifest.json'); + + const packageJson = await readJsonFile<{ + version: string; + engines?: { twenty?: string }; + }>(contentDir, 'package.json'); + + if (manifest === null) { + throw new ApplicationRegistrationException( + 'manifest.json not found or invalid in tarball', + ApplicationRegistrationExceptionCode.INVALID_INPUT, + ); + } + + const versionValidation = + await this.applicationVersionValidationService.validateServerCompatibility( + packageJson?.engines?.twenty, + ); + + if (!versionValidation.compatible) { + throw new ApplicationRegistrationException( + versionValidation.message, + VERSION_REASON_TO_APPLICATION_REGISTRATION_EXCEPTION_CODE[ + versionValidation.reason + ], + ); + } + + return { contentDir, manifest, packageJson }; + } + + private assertTarballCanReplaceRegistration({ + registration, + incomingVersion, + universalIdentifier, + }: { + registration: ApplicationRegistrationEntity; + incomingVersion: string | undefined; + universalIdentifier: string; + }): ApplicationRegistrationEntity { + if ( + registration.sourceType !== ApplicationRegistrationSourceType.LOCAL && + registration.sourceType !== ApplicationRegistrationSourceType.TARBALL + ) { + throw new ApplicationRegistrationException( + `This app is registered as ${registration.sourceType}. Cannot upload tarball.`, + ApplicationRegistrationExceptionCode.SOURCE_CHANNEL_MISMATCH, + ); + } + + if ( + registration.sourceType === ApplicationRegistrationSourceType.TARBALL && + isDefined(registration.latestAvailableVersion) && + isDefined(incomingVersion) + ) { + const progression = + this.applicationVersionValidationService.validateVersionProgression({ + incomingVersion, + currentVersion: registration.latestAvailableVersion, + universalIdentifier, + action: 'deploy', + }); + + if (!progression.allowed) { + throw new ApplicationRegistrationException( + progression.message, + VERSION_PROGRESSION_REASON_TO_DEPLOY_EXCEPTION_CODE[ + progression.reason + ], + ); + } + } + + return registration; + } + + private async createTarballRegistration({ + universalIdentifier, + manifest, + packageJsonVersion, + ownerWorkspaceId, + }: { + universalIdentifier: string; + manifest: { application?: ApplicationManifest }; + packageJsonVersion: string | null; + ownerWorkspaceId: string; + }): Promise { + return this.appRegistrationRepository.save( + this.appRegistrationRepository.create({ + universalIdentifier, + name: manifest.application?.displayName ?? 'Unknown App', + sourceType: ApplicationRegistrationSourceType.TARBALL, + manifest, + ...fromManifestApplicationToDisplayFields(manifest.application), + latestAvailableVersion: packageJsonVersion, + isListed: false, + isVetted: false, + oAuthClientId: v4(), + oAuthRedirectUris: [], + oAuthScopes: [], + ownerWorkspaceId, + }), + ); + } + + private async storeTarballFile({ + appRegistration, + tarballBuffer, + ownerWorkspaceId, + }: { + appRegistration: ApplicationRegistrationEntity; + tarballBuffer: Buffer; + ownerWorkspaceId: string; + }) { + const { workspaceCustomFlatApplication } = + await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow( + { workspaceId: ownerWorkspaceId }, + ); + + return this.fileStorageService.writeFile({ + sourceFile: tarballBuffer, + resourcePath: `${appRegistration.id}/app.tar.gz`, + fileFolder: FileFolder.AppTarball, + applicationUniversalIdentifier: + workspaceCustomFlatApplication.universalIdentifier, + workspaceId: ownerWorkspaceId, + fileId: appRegistration.tarballFileId ?? v4(), + settings: { + isTemporaryFile: false, + toDelete: false, + }, + }); + } + private async readAssetFromContentDir( contentDir: string, path: string, diff --git a/packages/twenty-server/src/engine/core-modules/application/application-registration/utils/__tests__/build-registration-manifest-update-fields.util.spec.ts b/packages/twenty-server/src/engine/core-modules/application/application-registration/utils/__tests__/build-registration-manifest-update-fields.util.spec.ts new file mode 100644 index 0000000000..111fabce1b --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/application-registration/utils/__tests__/build-registration-manifest-update-fields.util.spec.ts @@ -0,0 +1,33 @@ +import { type ApplicationManifest } from 'twenty-shared/application'; + +import { buildRegistrationManifestUpdateFields } from 'src/engine/core-modules/application/application-registration/utils/build-registration-manifest-update-fields.util'; + +describe('buildRegistrationManifestUpdateFields', () => { + it('should keep stored fileIds for gallery paths that did not change', () => { + const result = buildRegistrationManifestUpdateFields({ + manifestApplication: { + universalIdentifier: 'my-app', + galleryImages: ['images/kept.png', 'images/new.png'], + } as unknown as ApplicationManifest, + existingGalleryImages: [ + { path: 'images/kept.png', fileId: 'file-kept' }, + { path: 'images/removed.png', fileId: 'file-removed' }, + ], + }); + + expect(result.galleryImages).toEqual([ + { path: 'images/kept.png', fileId: 'file-kept' }, + { path: 'images/new.png', fileId: null }, + ]); + }); + + it('should handle a missing manifest application and no stored images', () => { + const result = buildRegistrationManifestUpdateFields({ + manifestApplication: undefined, + existingGalleryImages: null, + }); + + expect(result.galleryImages).toEqual([]); + expect(result.logo).toBeNull(); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/application/application-registration/utils/build-registration-manifest-update-fields.util.ts b/packages/twenty-server/src/engine/core-modules/application/application-registration/utils/build-registration-manifest-update-fields.util.ts new file mode 100644 index 0000000000..764566a584 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/application-registration/utils/build-registration-manifest-update-fields.util.ts @@ -0,0 +1,29 @@ +import { type ApplicationManifest } from 'twenty-shared/application'; + +import { type ApplicationRegistrationGalleryImage } from 'src/engine/core-modules/application/application-registration/types/application-registration-gallery-image.type'; +import { fromManifestApplicationToDisplayFields } from 'src/engine/core-modules/application/application-registration/utils/from-manifest-application-to-display-fields.util'; + +// Gallery fileIds are kept for unchanged paths so a metadata refresh cannot +// drop already stored assets. +export const buildRegistrationManifestUpdateFields = ({ + manifestApplication, + existingGalleryImages, +}: { + manifestApplication: ApplicationManifest | undefined; + existingGalleryImages: ApplicationRegistrationGalleryImage[] | null; +}) => { + const displayFields = + fromManifestApplicationToDisplayFields(manifestApplication); + + const existingFileIdByPath = new Map( + (existingGalleryImages ?? []).map(({ path, fileId }) => [path, fileId]), + ); + + return { + ...displayFields, + galleryImages: displayFields.galleryImages.map((galleryImage) => ({ + ...galleryImage, + fileId: existingFileIdByPath.get(galleryImage.path) ?? null, + })), + }; +}; diff --git a/packages/twenty-server/src/engine/core-modules/application/application-upgrade/application-upgrade.service.ts b/packages/twenty-server/src/engine/core-modules/application/application-upgrade/application-upgrade.service.ts index 9344384691..cd70978132 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-upgrade/application-upgrade.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-upgrade/application-upgrade.service.ts @@ -110,15 +110,15 @@ export class ApplicationUpgradeService { where: { id: params.appRegistrationId }, }); + // LOCAL apps are updated by dev sync and OAUTH_ONLY registrations have no + // code artifacts. if ( appRegistration.sourceType === ApplicationRegistrationSourceType.LOCAL || - appRegistration.sourceType === - ApplicationRegistrationSourceType.TARBALL || appRegistration.sourceType === ApplicationRegistrationSourceType.OAUTH_ONLY ) { throw new ApplicationException( - 'Cannot upgrade an app installed from a tarball, local source, or OAuth-only registration', + 'Cannot upgrade an app installed from a local source or OAuth-only registration', ApplicationExceptionCode.UPGRADE_FAILED, ); } diff --git a/packages/twenty-server/test/integration/metadata/suites/application/__snapshots__/failing-application-version-progression.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/application/__snapshots__/failing-application-version-progression.integration-spec.ts.snap new file mode 100644 index 0000000000..68def2e289 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/application/__snapshots__/failing-application-version-progression.integration-spec.ts.snap @@ -0,0 +1,37 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Application version progression gate rejects deploying a version lower than the deployed one 1`] = ` +{ + "extensions": { + "code": "BAD_USER_INPUT", + "subCode": "VERSION_ALREADY_EXISTS", + "userFriendlyMessage": "This version is not higher than the currently deployed version. Please bump the version in package.json before deploying again.", + }, + "message": "Cannot deploy 20202020-6b0e-4b1e-9d3a-000000002931@: version must be higher than the currently deployed version . Please bump the version in package.json.", + "name": "UserInputError", +} +`; + +exports[`Application version progression gate rejects re-deploying the currently deployed version 1`] = ` +{ + "extensions": { + "code": "BAD_USER_INPUT", + "subCode": "VERSION_ALREADY_EXISTS", + "userFriendlyMessage": "This version is not higher than the currently deployed version. Please bump the version in package.json before deploying again.", + }, + "message": "Cannot deploy 20202020-6b0e-4b1e-9d3a-000000002931@: version must be higher than the currently deployed version . Please bump the version in package.json.", + "name": "UserInputError", +} +`; + +exports[`Application version progression gate rejects re-installing the already installed version 1`] = ` +{ + "extensions": { + "code": "BAD_USER_INPUT", + "subCode": "APP_ALREADY_INSTALLED", + "userFriendlyMessage": "This version of the application is already installed in this workspace.", + }, + "message": "20202020-6b0e-4b1e-9d3a-000000002931@ is already installed in this workspace.", + "name": "UserInputError", +} +`; diff --git a/packages/twenty-server/test/integration/metadata/suites/application/failing-application-version-progression.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/application/failing-application-version-progression.integration-spec.ts new file mode 100644 index 0000000000..3b7f4b4026 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/application/failing-application-version-progression.integration-spec.ts @@ -0,0 +1,97 @@ +import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util'; +import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util'; +import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util'; +import { createAppTarball } from 'test/integration/metadata/suites/application/utils/create-app-tarball.util'; +import { installApplication } from 'test/integration/metadata/suites/application/utils/install-application.util'; +import { uploadAppTarball } from 'test/integration/metadata/suites/application/utils/upload-app-tarball.util'; +import { scrubSemverVersions } from 'test/utils/scrub-semver-versions.util'; + +// Fixed identifiers keep the exception messages, and therefore the error +// snapshots, stable across runs. +const APP_UNIVERSAL_IDENTIFIER = '20202020-6b0e-4b1e-9d3a-000000002931'; +const ROLE_UNIVERSAL_IDENTIFIER = '20202020-6b0e-4b1e-9d3a-000000002932'; + +// The upload flow runs cache-lock retries with real delays, so fake timers +// would hang it — mirror the other application suites. +jest.setTimeout(120000); + +const buildTarball = (version: string): Promise => + createAppTarball({ + 'manifest.json': JSON.stringify( + buildBaseManifest({ + appId: APP_UNIVERSAL_IDENTIFIER, + roleId: ROLE_UNIVERSAL_IDENTIFIER, + }), + ), + 'package.json': JSON.stringify({ + name: 'test-version-progression', + version, + }), + }); + +describe('Application version progression gate', () => { + beforeAll(async () => { + jest.useRealTimers(); + + const tarball = await buildTarball('1.0.0'); + + await uploadAppTarball({ + tarballBuffer: tarball, + universalIdentifier: APP_UNIVERSAL_IDENTIFIER, + }); + }); + + afterAll(async () => { + await cleanupApplicationAndAppRegistration({ + applicationUniversalIdentifier: APP_UNIVERSAL_IDENTIFIER, + }); + + jest.useFakeTimers(); + }); + + it('rejects re-deploying the currently deployed version', async () => { + const tarball = await buildTarball('1.0.0'); + + const { errors } = await uploadAppTarball({ + tarballBuffer: tarball, + universalIdentifier: APP_UNIVERSAL_IDENTIFIER, + expectToFail: true, + }); + + expectOneNotInternalServerErrorSnapshot({ + errors, + normalizeMessage: scrubSemverVersions, + }); + }); + + it('rejects deploying a version lower than the deployed one', async () => { + const tarball = await buildTarball('0.9.0'); + + const { errors } = await uploadAppTarball({ + tarballBuffer: tarball, + universalIdentifier: APP_UNIVERSAL_IDENTIFIER, + expectToFail: true, + }); + + expectOneNotInternalServerErrorSnapshot({ + errors, + normalizeMessage: scrubSemverVersions, + }); + }); + + it('rejects re-installing the already installed version', async () => { + await installApplication({ + input: { universalIdentifier: APP_UNIVERSAL_IDENTIFIER }, + }); + + const { errors } = await installApplication({ + input: { universalIdentifier: APP_UNIVERSAL_IDENTIFIER }, + expectToFail: true, + }); + + expectOneNotInternalServerErrorSnapshot({ + errors, + normalizeMessage: scrubSemverVersions, + }); + }); +});