From a63d94f28c8e36deaa0d01e94e0aef5a2cdbaaef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E0=A4=95=E0=A4=BE=E0=A4=B0=E0=A4=A4=E0=A5=8B=E0=A4=AB?= =?UTF-8?q?=E0=A5=8D=E0=A4=AB=E0=A5=87=E0=A4=B2=E0=A4=B8=E0=A5=8D=E0=A4=95?= =?UTF-8?q?=E0=A5=8D=E0=A4=B0=E0=A4=BF=E0=A4=AA=E0=A5=8D=E0=A4=9F=E2=84=A2?= Date: Tue, 19 Dec 2023 12:13:19 +0100 Subject: [PATCH] refactor(core): Move license endpoints to a decorated controller class (no-changelog) (#8074) --- packages/@n8n/permissions/src/types.ts | 3 + packages/cli/src/Server.ts | 10 +- .../repositories/workflow.repository.ts | 7 + packages/cli/src/license/License.service.ts | 34 ---- .../cli/src/license/license.controller.ts | 154 ++++-------------- packages/cli/src/license/license.service.ts | 83 ++++++++++ packages/cli/src/permissions/roles.ts | 1 + packages/cli/src/telemetry/index.ts | 8 +- .../cli/test/integration/license.api.test.ts | 14 +- .../integration/shared/utils/testServer.ts | 4 +- packages/cli/test/unit/License.test.ts | 16 +- packages/cli/test/unit/Telemetry.test.ts | 7 - .../test/unit/license/license.service.test.ts | 76 +++++++++ 13 files changed, 224 insertions(+), 193 deletions(-) delete mode 100644 packages/cli/src/license/License.service.ts create mode 100644 packages/cli/src/license/license.service.ts create mode 100644 packages/cli/test/unit/license/license.service.test.ts diff --git a/packages/@n8n/permissions/src/types.ts b/packages/@n8n/permissions/src/types.ts index 5896ca52cb..bbca21c954 100644 --- a/packages/@n8n/permissions/src/types.ts +++ b/packages/@n8n/permissions/src/types.ts @@ -8,6 +8,7 @@ export type Resource = | 'eventBusEvent' | 'eventBusDestination' | 'ldap' + | 'license' | 'logStreaming' | 'orchestration' | 'sourceControl' @@ -41,6 +42,7 @@ export type EventBusDestinationScope = ResourceScope< >; export type EventBusEventScope = ResourceScope<'eventBusEvent', DefaultOperations | 'query'>; export type LdapScope = ResourceScope<'ldap', 'manage' | 'sync'>; +export type LicenseScope = ResourceScope<'license', 'manage'>; export type LogStreamingScope = ResourceScope<'logStreaming', 'manage'>; export type OrchestrationScope = ResourceScope<'orchestration', 'read' | 'list'>; export type SamlScope = ResourceScope<'saml', 'manage'>; @@ -59,6 +61,7 @@ export type Scope = | EventBusEventScope | EventBusDestinationScope | LdapScope + | LicenseScope | LogStreamingScope | OrchestrationScope | SamlScope diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 6884e05706..5e25d66b86 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -67,7 +67,6 @@ import { ExternalSecretsController } from '@/ExternalSecrets/ExternalSecrets.con import { executionsController } from '@/executions/executions.controller'; import { isApiEnabled, loadPublicApiVersions } from '@/PublicApi'; import { whereClause } from '@/UserManagement/UserManagementHelper'; -import { UserManagementMailer } from '@/UserManagement/email'; import type { ICredentialsOverwrite, IDiagnosticInfo, IExecutionsStopData } from '@/Interfaces'; import { ActiveExecutions } from '@/ActiveExecutions'; import { CredentialsOverwrites } from '@/CredentialsOverwrites'; @@ -79,7 +78,7 @@ import { WaitTracker } from '@/WaitTracker'; import { toHttpNodeParameters } from '@/CurlConverterHelper'; import { EventBusController } from '@/eventbus/eventBus.controller'; import { EventBusControllerEE } from '@/eventbus/eventBus.controller.ee'; -import { licenseController } from './license/license.controller'; +import { LicenseController } from '@/license/license.controller'; import { setupPushServer, setupPushHandler } from '@/push'; import { setupAuthMiddlewares } from './middlewares'; import { handleLdapInit, isLdapEnabled } from './Ldap/helpers'; @@ -249,7 +248,6 @@ export class Server extends AbstractServer { setupAuthMiddlewares(app, ignoredEndpoints, this.restEndpoint); const internalHooks = Container.get(InternalHooks); - const mailer = Container.get(UserManagementMailer); const userService = Container.get(UserService); const postHog = this.postHog; const mfaService = Container.get(MfaService); @@ -258,6 +256,7 @@ export class Server extends AbstractServer { new EventBusController(), new EventBusControllerEE(), Container.get(AuthController), + Container.get(LicenseController), Container.get(OAuth1CredentialController), Container.get(OAuth2CredentialController), new OwnerController( @@ -423,11 +422,6 @@ export class Server extends AbstractServer { // ---------------------------------------- this.app.use(`/${this.restEndpoint}/workflows`, workflowsController); - // ---------------------------------------- - // License - // ---------------------------------------- - this.app.use(`/${this.restEndpoint}/license`, licenseController); - // ---------------------------------------- // SAML // ---------------------------------------- diff --git a/packages/cli/src/databases/repositories/workflow.repository.ts b/packages/cli/src/databases/repositories/workflow.repository.ts index 20a937f0eb..d5b193ff26 100644 --- a/packages/cli/src/databases/repositories/workflow.repository.ts +++ b/packages/cli/src/databases/repositories/workflow.repository.ts @@ -21,4 +21,11 @@ export class WorkflowRepository extends Repository { relations: ['shared', 'shared.user', 'shared.user.globalRole', 'shared.role'], }); } + + async getActiveTriggerCount() { + const totalTriggerCount = await this.sum('triggerCount', { + active: true, + }); + return totalTriggerCount ?? 0; + } } diff --git a/packages/cli/src/license/License.service.ts b/packages/cli/src/license/License.service.ts deleted file mode 100644 index 20437a5a38..0000000000 --- a/packages/cli/src/license/License.service.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Container } from 'typedi'; -import { License } from '@/License'; -import type { ILicenseReadResponse } from '@/Interfaces'; -import { WorkflowRepository } from '@db/repositories/workflow.repository'; - -export class LicenseService { - static async getActiveTriggerCount(): Promise { - const totalTriggerCount = await Container.get(WorkflowRepository).sum('triggerCount', { - active: true, - }); - return totalTriggerCount ?? 0; - } - - // Helper for getting the basic license data that we want to return - static async getLicenseData(): Promise { - const triggerCount = await LicenseService.getActiveTriggerCount(); - const license = Container.get(License); - const mainPlan = license.getMainPlan(); - - return { - usage: { - executions: { - value: triggerCount, - limit: license.getTriggerLimit(), - warningThreshold: 0.8, - }, - }, - license: { - planId: mainPlan?.productId ?? '', - planName: license.getPlanName(), - }, - }; - } -} diff --git a/packages/cli/src/license/license.controller.ts b/packages/cli/src/license/license.controller.ts index b907def729..0351361648 100644 --- a/packages/cli/src/license/license.controller.ts +++ b/packages/cli/src/license/license.controller.ts @@ -1,129 +1,37 @@ -import express from 'express'; -import { Container } from 'typedi'; +import { Service } from 'typedi'; +import { Authorized, Get, Post, RequireGlobalScope, RestController } from '@/decorators'; +import { LicenseRequest } from '@/requests'; +import { LicenseService } from './license.service'; -import { Logger } from '@/Logger'; -import * as ResponseHelper from '@/ResponseHelper'; -import type { ILicensePostResponse, ILicenseReadResponse } from '@/Interfaces'; -import { LicenseService } from './License.service'; -import { License } from '@/License'; -import type { AuthenticatedRequest, LicenseRequest } from '@/requests'; -import { InternalHooks } from '@/InternalHooks'; -import { UnauthorizedError } from '@/errors/response-errors/unauthorized.error'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +@Service() +@Authorized() +@RestController('/license') +export class LicenseController { + constructor(private readonly licenseService: LicenseService) {} -export const licenseController = express.Router(); - -const OWNER_ROUTES = ['/activate', '/renew']; - -/** - * Owner checking - */ -licenseController.use((req: AuthenticatedRequest, res, next) => { - if (OWNER_ROUTES.includes(req.path) && req.user) { - if (!req.user.isOwner) { - Container.get(Logger).info('Non-owner attempted to activate or renew a license', { - userId: req.user.id, - }); - ResponseHelper.sendErrorResponse( - res, - new UnauthorizedError('Only an instance owner may activate or renew a license'), - ); - return; - } + @Get('/') + async getLicenseData() { + return this.licenseService.getLicenseData(); } - next(); -}); -/** - * GET /license - * Get the license data, usable by everyone - */ -licenseController.get( - '/', - ResponseHelper.send(async (): Promise => { - return LicenseService.getLicenseData(); - }), -); + @Post('/activate') + @RequireGlobalScope('license:manage') + async activateLicense(req: LicenseRequest.Activate) { + const { activationKey } = req.body; + await this.licenseService.activateLicense(activationKey); + return this.getTokenAndData(); + } -/** - * POST /license/activate - * Only usable by the instance owner, activates a license. - */ -licenseController.post( - '/activate', - ResponseHelper.send(async (req: LicenseRequest.Activate): Promise => { - // Call the license manager activate function and tell it to throw an error - const license = Container.get(License); - try { - await license.activate(req.body.activationKey); - } catch (e) { - const error = e as Error & { errorId?: string }; + @Post('/renew') + @RequireGlobalScope('license:manage') + async renewLicense() { + await this.licenseService.renewLicense(); + return this.getTokenAndData(); + } - let message = 'Failed to activate license'; - - //override specific error messages (to map License Server vocabulary to n8n terms) - switch (error.errorId ?? 'UNSPECIFIED') { - case 'SCHEMA_VALIDATION': - message = 'Activation key is in the wrong format'; - break; - case 'RESERVATION_EXHAUSTED': - message = - 'Activation key has been used too many times. Please contact sales@n8n.io if you would like to extend it'; - break; - case 'RESERVATION_EXPIRED': - message = 'Activation key has expired'; - break; - case 'NOT_FOUND': - case 'RESERVATION_CONFLICT': - message = 'Activation key not found'; - break; - case 'RESERVATION_DUPLICATE': - message = 'Activation key has already been used on this instance'; - break; - default: - message += `: ${error.message}`; - Container.get(Logger).error(message, { stack: error.stack ?? 'n/a' }); - } - - throw new BadRequestError(message); - } - - // Return the read data, plus the management JWT - return { - managementToken: license.getManagementJwt(), - ...(await LicenseService.getLicenseData()), - }; - }), -); - -/** - * POST /license/renew - * Only usable by instance owner, renews a license - */ -licenseController.post( - '/renew', - ResponseHelper.send(async (): Promise => { - // Call the license manager activate function and tell it to throw an error - const license = Container.get(License); - try { - await license.renew(); - } catch (e) { - const error = e as Error & { errorId?: string }; - - // not awaiting so as not to make the endpoint hang - void Container.get(InternalHooks).onLicenseRenewAttempt({ success: false }); - if (error instanceof Error) { - throw new BadRequestError(error.message); - } - } - - // not awaiting so as not to make the endpoint hang - void Container.get(InternalHooks).onLicenseRenewAttempt({ success: true }); - - // Return the read data, plus the management JWT - return { - managementToken: license.getManagementJwt(), - ...(await LicenseService.getLicenseData()), - }; - }), -); + private async getTokenAndData() { + const managementToken = this.licenseService.getManagementJwt(); + const data = await this.licenseService.getLicenseData(); + return { ...data, managementToken }; + } +} diff --git a/packages/cli/src/license/license.service.ts b/packages/cli/src/license/license.service.ts new file mode 100644 index 0000000000..32d30b16c5 --- /dev/null +++ b/packages/cli/src/license/license.service.ts @@ -0,0 +1,83 @@ +import { Service } from 'typedi'; +import { Logger } from '@/Logger'; +import { License } from '@/License'; +import { InternalHooks } from '@/InternalHooks'; +import { WorkflowRepository } from '@db/repositories/workflow.repository'; +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; + +type LicenseError = Error & { errorId?: keyof typeof LicenseErrors }; + +export const LicenseErrors = { + SCHEMA_VALIDATION: 'Activation key is in the wrong format', + RESERVATION_EXHAUSTED: + 'Activation key has been used too many times. Please contact sales@n8n.io if you would like to extend it', + RESERVATION_EXPIRED: 'Activation key has expired', + NOT_FOUND: 'Activation key not found', + RESERVATION_CONFLICT: 'Activation key not found', + RESERVATION_DUPLICATE: 'Activation key has already been used on this instance', +}; + +@Service() +export class LicenseService { + constructor( + private readonly logger: Logger, + private readonly license: License, + private readonly internalHooks: InternalHooks, + private readonly workflowRepository: WorkflowRepository, + ) {} + + async getLicenseData() { + const triggerCount = await this.workflowRepository.getActiveTriggerCount(); + const mainPlan = this.license.getMainPlan(); + + return { + usage: { + executions: { + value: triggerCount, + limit: this.license.getTriggerLimit(), + warningThreshold: 0.8, + }, + }, + license: { + planId: mainPlan?.productId ?? '', + planName: this.license.getPlanName(), + }, + }; + } + + getManagementJwt(): string { + return this.license.getManagementJwt(); + } + + async activateLicense(activationKey: string) { + try { + await this.license.activate(activationKey); + } catch (e) { + const message = this.mapErrorMessage(e as LicenseError, 'activate'); + throw new BadRequestError(message); + } + } + + async renewLicense() { + try { + await this.license.renew(); + } catch (e) { + const message = this.mapErrorMessage(e as LicenseError, 'renew'); + // not awaiting so as not to make the endpoint hang + void this.internalHooks.onLicenseRenewAttempt({ success: false }); + throw new BadRequestError(message); + } + + // not awaiting so as not to make the endpoint hang + void this.internalHooks.onLicenseRenewAttempt({ success: true }); + } + + private mapErrorMessage(error: LicenseError, action: 'activate' | 'renew') { + let message = error.errorId && LicenseErrors[error.errorId]; + if (!message) { + message = `Failed to ${action} license: ${error.message}`; + this.logger.error(message, { stack: error.stack ?? 'n/a' }); + } + return message; + } +} diff --git a/packages/cli/src/permissions/roles.ts b/packages/cli/src/permissions/roles.ts index 9db80b3d2b..44977e0aaf 100644 --- a/packages/cli/src/permissions/roles.ts +++ b/packages/cli/src/permissions/roles.ts @@ -34,6 +34,7 @@ export const ownerPermissions: Scope[] = [ 'externalSecret:use', 'ldap:manage', 'ldap:sync', + 'license:manage', 'logStreaming:manage', 'orchestration:read', 'orchestration:list', diff --git a/packages/cli/src/telemetry/index.ts b/packages/cli/src/telemetry/index.ts index bbd8cfcbeb..158677c047 100644 --- a/packages/cli/src/telemetry/index.ts +++ b/packages/cli/src/telemetry/index.ts @@ -2,14 +2,15 @@ import type RudderStack from '@rudderstack/rudder-sdk-node'; import { PostHogClient } from '@/posthog'; import { Container, Service } from 'typedi'; import type { ITelemetryTrackProperties } from 'n8n-workflow'; +import { InstanceSettings } from 'n8n-core'; + import config from '@/config'; import type { IExecutionTrackProperties } from '@/Interfaces'; import { Logger } from '@/Logger'; import { License } from '@/License'; -import { LicenseService } from '@/license/License.service'; import { N8N_VERSION } from '@/constants'; +import { WorkflowRepository } from '@db/repositories/workflow.repository'; import { SourceControlPreferencesService } from '../environments/sourceControl/sourceControlPreferences.service.ee'; -import { InstanceSettings } from 'n8n-core'; type ExecutionTrackDataKey = 'manual_error' | 'manual_success' | 'prod_error' | 'prod_success'; @@ -41,6 +42,7 @@ export class Telemetry { private postHog: PostHogClient, private license: License, private readonly instanceSettings: InstanceSettings, + private readonly workflowRepository: WorkflowRepository, ) {} async init() { @@ -107,7 +109,7 @@ export class Telemetry { const pulsePacket = { plan_name_current: this.license.getPlanName(), quota: this.license.getTriggerLimit(), - usage: await LicenseService.getActiveTriggerCount(), + usage: await this.workflowRepository.getActiveTriggerCount(), source_control_set_up: Container.get(SourceControlPreferencesService).isSourceControlSetup(), branchName: sourceControlPreferences.branchName, read_only_instance: sourceControlPreferences.branchReadOnly, diff --git a/packages/cli/test/integration/license.api.test.ts b/packages/cli/test/integration/license.api.test.ts index 30c64c213a..1430beb064 100644 --- a/packages/cli/test/integration/license.api.test.ts +++ b/packages/cli/test/integration/license.api.test.ts @@ -60,7 +60,7 @@ describe('POST /license/activate', () => { await authMemberAgent .post('/license/activate') .send({ activationKey: 'abcde' }) - .expect(403, { code: 403, message: NON_OWNER_ACTIVATE_RENEW_MESSAGE }); + .expect(403, UNAUTHORIZED_RESPONSE); }); test('errors out properly', async () => { @@ -82,19 +82,17 @@ describe('POST /license/renew', () => { }); test('does not work for regular users', async () => { - await authMemberAgent - .post('/license/renew') - .expect(403, { code: 403, message: NON_OWNER_ACTIVATE_RENEW_MESSAGE }); + await authMemberAgent.post('/license/renew').expect(403, UNAUTHORIZED_RESPONSE); }); test('errors out properly', async () => { License.prototype.renew = jest.fn().mockImplementation(() => { - throw new Error(RENEW_ERROR_MESSAGE); + throw new Error(GENERIC_ERROR_MESSAGE); }); await authOwnerAgent .post('/license/renew') - .expect(400, { code: 400, message: RENEW_ERROR_MESSAGE }); + .expect(400, { code: 400, message: `Failed to renew license: ${GENERIC_ERROR_MESSAGE}` }); }); }); @@ -131,6 +129,6 @@ const DEFAULT_POST_RESPONSE: { data: ILicensePostResponse } = { }, }; -const NON_OWNER_ACTIVATE_RENEW_MESSAGE = 'Only an instance owner may activate or renew a license'; +const UNAUTHORIZED_RESPONSE = { status: 'error', message: 'Unauthorized' }; const ACTIVATION_FAILED_MESSAGE = 'Failed to activate license'; -const RENEW_ERROR_MESSAGE = 'Something went wrong when trying to renew license'; +const GENERIC_ERROR_MESSAGE = 'Something went wrong'; diff --git a/packages/cli/test/integration/shared/utils/testServer.ts b/packages/cli/test/integration/shared/utils/testServer.ts index 351e0413e2..f765f83dac 100644 --- a/packages/cli/test/integration/shared/utils/testServer.ts +++ b/packages/cli/test/integration/shared/utils/testServer.ts @@ -144,8 +144,8 @@ export const setupTestServer = ({ break; case 'license': - const { licenseController } = await import('@/license/license.controller'); - app.use(`/${REST_PATH_SEGMENT}/license`, licenseController); + const { LicenseController } = await import('@/license/license.controller'); + registerController(app, config, Container.get(LicenseController)); break; case 'metrics': diff --git a/packages/cli/test/unit/License.test.ts b/packages/cli/test/unit/License.test.ts index 7238122380..3079695d46 100644 --- a/packages/cli/test/unit/License.test.ts +++ b/packages/cli/test/unit/License.test.ts @@ -28,7 +28,7 @@ describe('License', () => { let license: License; const logger = mockInstance(Logger); const instanceSettings = mockInstance(InstanceSettings, { instanceId: MOCK_INSTANCE_ID }); - const multiMainSetup = mockInstance(MultiMainSetup); + mockInstance(MultiMainSetup); beforeEach(async () => { license = new License(logger, instanceSettings, mock(), mock(), mock()); @@ -85,20 +85,20 @@ describe('License', () => { expect(LicenseManager.prototype.renew).toHaveBeenCalled(); }); - test('check if feature is enabled', async () => { - await license.isFeatureEnabled(MOCK_FEATURE_FLAG); + test('check if feature is enabled', () => { + license.isFeatureEnabled(MOCK_FEATURE_FLAG); expect(LicenseManager.prototype.hasFeatureEnabled).toHaveBeenCalledWith(MOCK_FEATURE_FLAG); }); - test('check if sharing feature is enabled', async () => { - await license.isFeatureEnabled(MOCK_FEATURE_FLAG); + test('check if sharing feature is enabled', () => { + license.isFeatureEnabled(MOCK_FEATURE_FLAG); expect(LicenseManager.prototype.hasFeatureEnabled).toHaveBeenCalledWith(MOCK_FEATURE_FLAG); }); - test('check fetching entitlements', async () => { - await license.getCurrentEntitlements(); + test('check fetching entitlements', () => { + license.getCurrentEntitlements(); expect(LicenseManager.prototype.getCurrentEntitlements).toHaveBeenCalled(); }); @@ -110,7 +110,7 @@ describe('License', () => { }); test('check management jwt', async () => { - await license.getManagementJwt(); + license.getManagementJwt(); expect(LicenseManager.prototype.getManagementJwt).toHaveBeenCalled(); }); diff --git a/packages/cli/test/unit/Telemetry.test.ts b/packages/cli/test/unit/Telemetry.test.ts index 129df8daeb..d943d83e50 100644 --- a/packages/cli/test/unit/Telemetry.test.ts +++ b/packages/cli/test/unit/Telemetry.test.ts @@ -8,13 +8,6 @@ import { InstanceSettings } from 'n8n-core'; import { mockInstance } from '../shared/mocking'; jest.unmock('@/telemetry'); -jest.mock('@/license/License.service', () => { - return { - LicenseService: { - getActiveTriggerCount: async () => 0, - }, - }; -}); jest.mock('@/posthog'); describe('Telemetry', () => { diff --git a/packages/cli/test/unit/license/license.service.test.ts b/packages/cli/test/unit/license/license.service.test.ts new file mode 100644 index 0000000000..7ac75ba5b3 --- /dev/null +++ b/packages/cli/test/unit/license/license.service.test.ts @@ -0,0 +1,76 @@ +import { LicenseErrors, LicenseService } from '@/license/license.service'; +import type { License } from '@/License'; +import type { InternalHooks } from '@/InternalHooks'; +import type { WorkflowRepository } from '@db/repositories/workflow.repository'; +import type { TEntitlement } from '@n8n_io/license-sdk'; +import { mock } from 'jest-mock-extended'; +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; + +describe('LicenseService', () => { + const license = mock(); + const internalHooks = mock(); + const workflowRepository = mock(); + const entitlement = mock({ productId: '123' }); + const licenseService = new LicenseService(mock(), license, internalHooks, workflowRepository); + + license.getMainPlan.mockReturnValue(entitlement); + license.getTriggerLimit.mockReturnValue(400); + license.getPlanName.mockReturnValue('Test Plan'); + workflowRepository.getActiveTriggerCount.mockResolvedValue(7); + + beforeEach(() => jest.clearAllMocks()); + + class LicenseError extends Error { + constructor(readonly errorId: string) { + super(`License error: ${errorId}`); + } + } + + describe('getLicenseData', () => { + it('should return usage and license data', async () => { + const data = await licenseService.getLicenseData(); + expect(data).toEqual({ + usage: { + executions: { + limit: 400, + value: 7, + warningThreshold: 0.8, + }, + }, + license: { + planId: '123', + planName: 'Test Plan', + }, + }); + }); + }); + + describe('activateLicense', () => { + Object.entries(LicenseErrors).forEach(([errorId, message]) => + it(`should handle ${errorId} error`, async () => { + license.activate.mockRejectedValueOnce(new LicenseError(errorId)); + await expect(licenseService.activateLicense('')).rejects.toThrowError( + new BadRequestError(message), + ); + }), + ); + }); + + describe('renewLicense', () => { + test('on success', async () => { + license.renew.mockResolvedValueOnce(); + await licenseService.renewLicense(); + + expect(internalHooks.onLicenseRenewAttempt).toHaveBeenCalledWith({ success: true }); + }); + + test('on failure', async () => { + license.renew.mockRejectedValueOnce(new LicenseError('RESERVATION_EXPIRED')); + await expect(licenseService.renewLicense()).rejects.toThrowError( + new BadRequestError('Activation key has expired'), + ); + + expect(internalHooks.onLicenseRenewAttempt).toHaveBeenCalledWith({ success: false }); + }); + }); +});