refactor(core): Move license endpoints to a decorated controller class (no-changelog) (#8074)

This commit is contained in:
कारतोफ्फेलस्क्रिप्ट™ 2023-12-19 12:13:19 +01:00 committed by GitHub
parent 63a6e7e034
commit a63d94f28c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 224 additions and 193 deletions

View file

@ -8,6 +8,7 @@ export type Resource =
| 'eventBusEvent' | 'eventBusEvent'
| 'eventBusDestination' | 'eventBusDestination'
| 'ldap' | 'ldap'
| 'license'
| 'logStreaming' | 'logStreaming'
| 'orchestration' | 'orchestration'
| 'sourceControl' | 'sourceControl'
@ -41,6 +42,7 @@ export type EventBusDestinationScope = ResourceScope<
>; >;
export type EventBusEventScope = ResourceScope<'eventBusEvent', DefaultOperations | 'query'>; export type EventBusEventScope = ResourceScope<'eventBusEvent', DefaultOperations | 'query'>;
export type LdapScope = ResourceScope<'ldap', 'manage' | 'sync'>; export type LdapScope = ResourceScope<'ldap', 'manage' | 'sync'>;
export type LicenseScope = ResourceScope<'license', 'manage'>;
export type LogStreamingScope = ResourceScope<'logStreaming', 'manage'>; export type LogStreamingScope = ResourceScope<'logStreaming', 'manage'>;
export type OrchestrationScope = ResourceScope<'orchestration', 'read' | 'list'>; export type OrchestrationScope = ResourceScope<'orchestration', 'read' | 'list'>;
export type SamlScope = ResourceScope<'saml', 'manage'>; export type SamlScope = ResourceScope<'saml', 'manage'>;
@ -59,6 +61,7 @@ export type Scope =
| EventBusEventScope | EventBusEventScope
| EventBusDestinationScope | EventBusDestinationScope
| LdapScope | LdapScope
| LicenseScope
| LogStreamingScope | LogStreamingScope
| OrchestrationScope | OrchestrationScope
| SamlScope | SamlScope

View file

@ -67,7 +67,6 @@ import { ExternalSecretsController } from '@/ExternalSecrets/ExternalSecrets.con
import { executionsController } from '@/executions/executions.controller'; import { executionsController } from '@/executions/executions.controller';
import { isApiEnabled, loadPublicApiVersions } from '@/PublicApi'; import { isApiEnabled, loadPublicApiVersions } from '@/PublicApi';
import { whereClause } from '@/UserManagement/UserManagementHelper'; import { whereClause } from '@/UserManagement/UserManagementHelper';
import { UserManagementMailer } from '@/UserManagement/email';
import type { ICredentialsOverwrite, IDiagnosticInfo, IExecutionsStopData } from '@/Interfaces'; import type { ICredentialsOverwrite, IDiagnosticInfo, IExecutionsStopData } from '@/Interfaces';
import { ActiveExecutions } from '@/ActiveExecutions'; import { ActiveExecutions } from '@/ActiveExecutions';
import { CredentialsOverwrites } from '@/CredentialsOverwrites'; import { CredentialsOverwrites } from '@/CredentialsOverwrites';
@ -79,7 +78,7 @@ import { WaitTracker } from '@/WaitTracker';
import { toHttpNodeParameters } from '@/CurlConverterHelper'; import { toHttpNodeParameters } from '@/CurlConverterHelper';
import { EventBusController } from '@/eventbus/eventBus.controller'; import { EventBusController } from '@/eventbus/eventBus.controller';
import { EventBusControllerEE } from '@/eventbus/eventBus.controller.ee'; 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 { setupPushServer, setupPushHandler } from '@/push';
import { setupAuthMiddlewares } from './middlewares'; import { setupAuthMiddlewares } from './middlewares';
import { handleLdapInit, isLdapEnabled } from './Ldap/helpers'; import { handleLdapInit, isLdapEnabled } from './Ldap/helpers';
@ -249,7 +248,6 @@ export class Server extends AbstractServer {
setupAuthMiddlewares(app, ignoredEndpoints, this.restEndpoint); setupAuthMiddlewares(app, ignoredEndpoints, this.restEndpoint);
const internalHooks = Container.get(InternalHooks); const internalHooks = Container.get(InternalHooks);
const mailer = Container.get(UserManagementMailer);
const userService = Container.get(UserService); const userService = Container.get(UserService);
const postHog = this.postHog; const postHog = this.postHog;
const mfaService = Container.get(MfaService); const mfaService = Container.get(MfaService);
@ -258,6 +256,7 @@ export class Server extends AbstractServer {
new EventBusController(), new EventBusController(),
new EventBusControllerEE(), new EventBusControllerEE(),
Container.get(AuthController), Container.get(AuthController),
Container.get(LicenseController),
Container.get(OAuth1CredentialController), Container.get(OAuth1CredentialController),
Container.get(OAuth2CredentialController), Container.get(OAuth2CredentialController),
new OwnerController( new OwnerController(
@ -423,11 +422,6 @@ export class Server extends AbstractServer {
// ---------------------------------------- // ----------------------------------------
this.app.use(`/${this.restEndpoint}/workflows`, workflowsController); this.app.use(`/${this.restEndpoint}/workflows`, workflowsController);
// ----------------------------------------
// License
// ----------------------------------------
this.app.use(`/${this.restEndpoint}/license`, licenseController);
// ---------------------------------------- // ----------------------------------------
// SAML // SAML
// ---------------------------------------- // ----------------------------------------

View file

@ -21,4 +21,11 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
relations: ['shared', 'shared.user', 'shared.user.globalRole', 'shared.role'], relations: ['shared', 'shared.user', 'shared.user.globalRole', 'shared.role'],
}); });
} }
async getActiveTriggerCount() {
const totalTriggerCount = await this.sum('triggerCount', {
active: true,
});
return totalTriggerCount ?? 0;
}
} }

View file

@ -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<number> {
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<ILicenseReadResponse> {
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(),
},
};
}
}

View file

@ -1,129 +1,37 @@
import express from 'express'; import { Service } from 'typedi';
import { Container } from 'typedi'; import { Authorized, Get, Post, RequireGlobalScope, RestController } from '@/decorators';
import { LicenseRequest } from '@/requests';
import { LicenseService } from './license.service';
import { Logger } from '@/Logger'; @Service()
import * as ResponseHelper from '@/ResponseHelper'; @Authorized()
import type { ILicensePostResponse, ILicenseReadResponse } from '@/Interfaces'; @RestController('/license')
import { LicenseService } from './License.service'; export class LicenseController {
import { License } from '@/License'; constructor(private readonly licenseService: LicenseService) {}
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';
export const licenseController = express.Router(); @Get('/')
async getLicenseData() {
const OWNER_ROUTES = ['/activate', '/renew']; return this.licenseService.getLicenseData();
/**
* 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;
}
} }
next();
});
/** @Post('/activate')
* GET /license @RequireGlobalScope('license:manage')
* Get the license data, usable by everyone async activateLicense(req: LicenseRequest.Activate) {
*/ const { activationKey } = req.body;
licenseController.get( await this.licenseService.activateLicense(activationKey);
'/', return this.getTokenAndData();
ResponseHelper.send(async (): Promise<ILicenseReadResponse> => { }
return LicenseService.getLicenseData();
}),
);
/** @Post('/renew')
* POST /license/activate @RequireGlobalScope('license:manage')
* Only usable by the instance owner, activates a license. async renewLicense() {
*/ await this.licenseService.renewLicense();
licenseController.post( return this.getTokenAndData();
'/activate', }
ResponseHelper.send(async (req: LicenseRequest.Activate): Promise<ILicensePostResponse> => {
// 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 };
let message = 'Failed to activate license'; private async getTokenAndData() {
const managementToken = this.licenseService.getManagementJwt();
//override specific error messages (to map License Server vocabulary to n8n terms) const data = await this.licenseService.getLicenseData();
switch (error.errorId ?? 'UNSPECIFIED') { return { ...data, managementToken };
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<ILicensePostResponse> => {
// 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()),
};
}),
);

View file

@ -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;
}
}

View file

@ -34,6 +34,7 @@ export const ownerPermissions: Scope[] = [
'externalSecret:use', 'externalSecret:use',
'ldap:manage', 'ldap:manage',
'ldap:sync', 'ldap:sync',
'license:manage',
'logStreaming:manage', 'logStreaming:manage',
'orchestration:read', 'orchestration:read',
'orchestration:list', 'orchestration:list',

View file

@ -2,14 +2,15 @@ import type RudderStack from '@rudderstack/rudder-sdk-node';
import { PostHogClient } from '@/posthog'; import { PostHogClient } from '@/posthog';
import { Container, Service } from 'typedi'; import { Container, Service } from 'typedi';
import type { ITelemetryTrackProperties } from 'n8n-workflow'; import type { ITelemetryTrackProperties } from 'n8n-workflow';
import { InstanceSettings } from 'n8n-core';
import config from '@/config'; import config from '@/config';
import type { IExecutionTrackProperties } from '@/Interfaces'; import type { IExecutionTrackProperties } from '@/Interfaces';
import { Logger } from '@/Logger'; import { Logger } from '@/Logger';
import { License } from '@/License'; import { License } from '@/License';
import { LicenseService } from '@/license/License.service';
import { N8N_VERSION } from '@/constants'; import { N8N_VERSION } from '@/constants';
import { WorkflowRepository } from '@db/repositories/workflow.repository';
import { SourceControlPreferencesService } from '../environments/sourceControl/sourceControlPreferences.service.ee'; import { SourceControlPreferencesService } from '../environments/sourceControl/sourceControlPreferences.service.ee';
import { InstanceSettings } from 'n8n-core';
type ExecutionTrackDataKey = 'manual_error' | 'manual_success' | 'prod_error' | 'prod_success'; type ExecutionTrackDataKey = 'manual_error' | 'manual_success' | 'prod_error' | 'prod_success';
@ -41,6 +42,7 @@ export class Telemetry {
private postHog: PostHogClient, private postHog: PostHogClient,
private license: License, private license: License,
private readonly instanceSettings: InstanceSettings, private readonly instanceSettings: InstanceSettings,
private readonly workflowRepository: WorkflowRepository,
) {} ) {}
async init() { async init() {
@ -107,7 +109,7 @@ export class Telemetry {
const pulsePacket = { const pulsePacket = {
plan_name_current: this.license.getPlanName(), plan_name_current: this.license.getPlanName(),
quota: this.license.getTriggerLimit(), quota: this.license.getTriggerLimit(),
usage: await LicenseService.getActiveTriggerCount(), usage: await this.workflowRepository.getActiveTriggerCount(),
source_control_set_up: Container.get(SourceControlPreferencesService).isSourceControlSetup(), source_control_set_up: Container.get(SourceControlPreferencesService).isSourceControlSetup(),
branchName: sourceControlPreferences.branchName, branchName: sourceControlPreferences.branchName,
read_only_instance: sourceControlPreferences.branchReadOnly, read_only_instance: sourceControlPreferences.branchReadOnly,

View file

@ -60,7 +60,7 @@ describe('POST /license/activate', () => {
await authMemberAgent await authMemberAgent
.post('/license/activate') .post('/license/activate')
.send({ activationKey: 'abcde' }) .send({ activationKey: 'abcde' })
.expect(403, { code: 403, message: NON_OWNER_ACTIVATE_RENEW_MESSAGE }); .expect(403, UNAUTHORIZED_RESPONSE);
}); });
test('errors out properly', async () => { test('errors out properly', async () => {
@ -82,19 +82,17 @@ describe('POST /license/renew', () => {
}); });
test('does not work for regular users', async () => { test('does not work for regular users', async () => {
await authMemberAgent await authMemberAgent.post('/license/renew').expect(403, UNAUTHORIZED_RESPONSE);
.post('/license/renew')
.expect(403, { code: 403, message: NON_OWNER_ACTIVATE_RENEW_MESSAGE });
}); });
test('errors out properly', async () => { test('errors out properly', async () => {
License.prototype.renew = jest.fn().mockImplementation(() => { License.prototype.renew = jest.fn().mockImplementation(() => {
throw new Error(RENEW_ERROR_MESSAGE); throw new Error(GENERIC_ERROR_MESSAGE);
}); });
await authOwnerAgent await authOwnerAgent
.post('/license/renew') .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 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';

View file

@ -144,8 +144,8 @@ export const setupTestServer = ({
break; break;
case 'license': case 'license':
const { licenseController } = await import('@/license/license.controller'); const { LicenseController } = await import('@/license/license.controller');
app.use(`/${REST_PATH_SEGMENT}/license`, licenseController); registerController(app, config, Container.get(LicenseController));
break; break;
case 'metrics': case 'metrics':

View file

@ -28,7 +28,7 @@ describe('License', () => {
let license: License; let license: License;
const logger = mockInstance(Logger); const logger = mockInstance(Logger);
const instanceSettings = mockInstance(InstanceSettings, { instanceId: MOCK_INSTANCE_ID }); const instanceSettings = mockInstance(InstanceSettings, { instanceId: MOCK_INSTANCE_ID });
const multiMainSetup = mockInstance(MultiMainSetup); mockInstance(MultiMainSetup);
beforeEach(async () => { beforeEach(async () => {
license = new License(logger, instanceSettings, mock(), mock(), mock()); license = new License(logger, instanceSettings, mock(), mock(), mock());
@ -85,20 +85,20 @@ describe('License', () => {
expect(LicenseManager.prototype.renew).toHaveBeenCalled(); expect(LicenseManager.prototype.renew).toHaveBeenCalled();
}); });
test('check if feature is enabled', async () => { test('check if feature is enabled', () => {
await license.isFeatureEnabled(MOCK_FEATURE_FLAG); license.isFeatureEnabled(MOCK_FEATURE_FLAG);
expect(LicenseManager.prototype.hasFeatureEnabled).toHaveBeenCalledWith(MOCK_FEATURE_FLAG); expect(LicenseManager.prototype.hasFeatureEnabled).toHaveBeenCalledWith(MOCK_FEATURE_FLAG);
}); });
test('check if sharing feature is enabled', async () => { test('check if sharing feature is enabled', () => {
await license.isFeatureEnabled(MOCK_FEATURE_FLAG); license.isFeatureEnabled(MOCK_FEATURE_FLAG);
expect(LicenseManager.prototype.hasFeatureEnabled).toHaveBeenCalledWith(MOCK_FEATURE_FLAG); expect(LicenseManager.prototype.hasFeatureEnabled).toHaveBeenCalledWith(MOCK_FEATURE_FLAG);
}); });
test('check fetching entitlements', async () => { test('check fetching entitlements', () => {
await license.getCurrentEntitlements(); license.getCurrentEntitlements();
expect(LicenseManager.prototype.getCurrentEntitlements).toHaveBeenCalled(); expect(LicenseManager.prototype.getCurrentEntitlements).toHaveBeenCalled();
}); });
@ -110,7 +110,7 @@ describe('License', () => {
}); });
test('check management jwt', async () => { test('check management jwt', async () => {
await license.getManagementJwt(); license.getManagementJwt();
expect(LicenseManager.prototype.getManagementJwt).toHaveBeenCalled(); expect(LicenseManager.prototype.getManagementJwt).toHaveBeenCalled();
}); });

View file

@ -8,13 +8,6 @@ import { InstanceSettings } from 'n8n-core';
import { mockInstance } from '../shared/mocking'; import { mockInstance } from '../shared/mocking';
jest.unmock('@/telemetry'); jest.unmock('@/telemetry');
jest.mock('@/license/License.service', () => {
return {
LicenseService: {
getActiveTriggerCount: async () => 0,
},
};
});
jest.mock('@/posthog'); jest.mock('@/posthog');
describe('Telemetry', () => { describe('Telemetry', () => {

View file

@ -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<License>();
const internalHooks = mock<InternalHooks>();
const workflowRepository = mock<WorkflowRepository>();
const entitlement = mock<TEntitlement>({ 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 });
});
});
});