diff --git a/.env.template b/.env.template index e9896cc8..77ea7a22 100644 --- a/.env.template +++ b/.env.template @@ -39,3 +39,9 @@ STORJ_BRIDGE=http://network-api:6382 STRIPE_SK=sk_test_F3Ny2VGUnPga9FtyXkl7mzPc STRIPE_SK_TEST=sk_test_R3Ny2VG7nPgeJFrtXdt8mrPc SENDGRID_MODE_SANDBOX=false + +# APN +APN_BUNDLE_ID= +APN_TEAM_ID= +APN_KEY_ID= +APN_SECRET= \ No newline at end of file diff --git a/src/app/routes/storage.ts b/src/app/routes/storage.ts index 19250b1a..5ebe705e 100644 --- a/src/app/routes/storage.ts +++ b/src/app/routes/storage.ts @@ -11,16 +11,16 @@ import { FileAttributes } from '../models/file'; import CONSTANTS from '../constants'; import { LockNotAvaliableError } from '../services/errors/locks'; import { ConnectionTimedOutError, UniqueConstraintError } from 'sequelize'; -import { - FileAlreadyExistsError, - FileWithNameAlreadyExistsError +import { + FileAlreadyExistsError, + FileWithNameAlreadyExistsError, } from '../services/errors/FileWithNameAlreadyExistsError'; -import { +import { FolderAlreadyExistsError, - FolderWithNameAlreadyExistsError + FolderWithNameAlreadyExistsError, } from '../services/errors/FolderWithNameAlreadyExistsError'; import * as resourceSharingMiddlewareBuilder from '../middleware/resource-sharing.middleware'; -import {validate } from 'uuid'; +import { validate } from 'uuid'; type AuthorizedRequest = Request & { user: UserAttributes }; interface Services { @@ -34,6 +34,7 @@ interface Services { Share: any; Crypt: any; Inxt: any; + Apn: any; } type SharedRequest = Request & { behalfUser: UserAttributes }; @@ -82,23 +83,24 @@ export class StorageController { const workspaceMembers = await this.services.User.findWorkspaceMembers(behalfUser.bridgeUser); - workspaceMembers.forEach( - ({ email, uuid }: { email: string; uuid: string }) => - void this.services.Notifications.fileCreated({ - file: result, - email, - uuid, - clientId: clientId, - }), - ); + workspaceMembers.forEach(({ email, uuid }: { email: string; uuid: string }) => { + void this.services.Notifications.fileCreated({ + file: result, + email, + uuid, + clientId: clientId, + }); + + void this.getTokensAndSendNotification(uuid); + }); } catch (err) { if (err instanceof FileAlreadyExistsError || err instanceof UniqueConstraintError) { return res.status(409).send({ error: 'File already exists' }); } this.logger.error( - `[FILE/CREATE] ERROR: ${(err as Error).message}, BODY ${ - JSON.stringify(file) - }, STACK: ${(err as Error).stack} USER: ${behalfUser.email}`, + `[FILE/CREATE] ERROR: ${(err as Error).message}, BODY ${JSON.stringify(file)}, STACK: ${ + (err as Error).stack + } USER: ${behalfUser.email}`, ); res.status(500).send({ error: 'Internal Server Error' }); } @@ -108,16 +110,13 @@ export class StorageController { const { behalfUser } = req as SharedRequest; const { file } = req.body as { file: { name: string; folderId: number; type: string } }; - if ( - !file || - !file.name || - !file.folderId || - !file.type - ) { + if (!file || !file.name || !file.folderId || !file.type) { this.logger.error( - `Missing body params to check the file existence for user ${ - behalfUser.email - }: ${JSON.stringify(file, null, 2)}`, + `Missing body params to check the file existence for user ${behalfUser.email}: ${JSON.stringify( + file, + null, + 2, + )}`, ); return res.status(400).json({ error: 'Missing information to check file existence' }); } @@ -132,9 +131,9 @@ export class StorageController { res.status(200).json(result.file); } catch (err) { this.logger.error( - `[FILE/CHECK-EXISTENCE] ERROR: ${ - (err as Error).message - }, BODY ${JSON.stringify(file)}, STACK: ${(err as Error).stack} USER: ${behalfUser.email}`, + `[FILE/CHECK-EXISTENCE] ERROR: ${(err as Error).message}, BODY ${JSON.stringify(file)}, STACK: ${ + (err as Error).stack + } USER: ${behalfUser.email}`, ); res.status(500).send({ error: 'Internal Server Error' }); } @@ -167,21 +166,21 @@ export class StorageController { if (parentFolder.userId !== user.id) { throw createHttpError(403, 'Parent folder does not belong to user'); } - return this.services.Folder.Create(user, folderName, parentFolderId, null, clientCreatedUuuid) .then(async (result: FolderAttributes) => { res.status(201).json(result); const workspaceMembers = await this.services.User.findWorkspaceMembers(user.bridgeUser); - workspaceMembers.forEach( - ({ email, uuid }: { uuid: string; email: string }) => - void this.services.Notifications.folderCreated({ - folder: result, - email: email, - uuid, - clientId: clientId, - }), - ); + workspaceMembers.forEach(({ email, uuid }: { uuid: string; email: string }) => { + void this.services.Notifications.folderCreated({ + folder: result, + email: email, + uuid, + clientId: clientId, + }); + + void this.getTokensAndSendNotification(uuid); + }); }) .catch((err: Error) => { if (err instanceof FolderAlreadyExistsError) { @@ -205,7 +204,7 @@ export class StorageController { } return this.services.Folder.CheckFolderExistence(user, name, parentId) - .then((result: { folder: FolderAttributes, exists: boolean }) => { + .then((result: { folder: FolderAttributes; exists: boolean }) => { if (result.exists) { res.status(200).json(result); } else { @@ -274,15 +273,16 @@ export class StorageController { res.status(204).send(result); this.services.User.findWorkspaceMembers(user.bridgeUser).then((workspaceMembers: any) => { - workspaceMembers.forEach( - ({ email, uuid }: { email: string; uuid: string }) => - void this.services.Notifications.folderDeleted({ - id: folderId, - email: email, - uuid, - clientId: clientId, - }), - ); + workspaceMembers.forEach(({ email, uuid }: { email: string; uuid: string }) => { + void this.services.Notifications.folderDeleted({ + id: folderId, + email: email, + uuid, + clientId: clientId, + }); + + void this.getTokensAndSendNotification(uuid); + }); }); } catch (error) { const err = error as Error; @@ -322,15 +322,16 @@ export class StorageController { .then(async (result: { result: FolderAttributes }) => { res.status(200).json(result); const workspaceMembers = await this.services.User.findWorkspaceMembers(user.bridgeUser); - workspaceMembers.forEach( - ({ email, uuid }: { email: string; uuid: string }) => - void this.services.Notifications.folderUpdated({ - folder: result.result, - email: email, - uuid, - clientId: clientId, - }), - ); + workspaceMembers.forEach(({ email, uuid }: { email: string; uuid: string }) => { + void this.services.Notifications.folderUpdated({ + folder: result.result, + email: email, + uuid, + clientId: clientId, + }); + + void this.getTokensAndSendNotification(uuid); + }); }) .catch((err: Error) => { if (err instanceof HttpError) { @@ -357,15 +358,16 @@ export class StorageController { .then(async (result: FolderAttributes) => { res.status(200).json(result); const workspaceMembers = await this.services.User.findWorkspaceMembers(user.bridgeUser); - workspaceMembers.forEach( - ({ email, uuid }: { email: string; uuid: string }) => - void this.services.Notifications.folderUpdated({ - folder: result, - email: email, - uuid, - clientId: clientId, - }), - ); + workspaceMembers.forEach(({ email, uuid }: { email: string; uuid: string }) => { + void this.services.Notifications.folderUpdated({ + folder: result, + email: email, + uuid, + clientId: clientId, + }); + + void this.getTokensAndSendNotification(uuid); + }); }) .catch((err: Error) => { this.logger.error(`Error updating metadata from folder ${folderId}: ${err}`); @@ -455,15 +457,16 @@ export class StorageController { .then(async (result: { result: FileAttributes }) => { res.status(200).json(result); const workspaceMembers = await this.services.User.findWorkspaceMembers(user.bridgeUser); - workspaceMembers.forEach( - ({ email, uuid }: { email: string; uuid: string }) => - void this.services.Notifications.fileUpdated({ - file: result.result, - email: email, - uuid, - clientId: clientId, - }), - ); + workspaceMembers.forEach(({ email, uuid }: { email: string; uuid: string }) => { + void this.services.Notifications.fileUpdated({ + file: result.result, + email: email, + uuid, + clientId: clientId, + }); + + void this.getTokensAndSendNotification(uuid); + }); }) .catch((err: Error) => { this.logger.error(err); @@ -498,15 +501,16 @@ export class StorageController { .then(async (result: FileAttributes) => { res.status(200).json(result); const workspaceMembers = await this.services.User.findWorkspaceMembers(user.bridgeUser); - workspaceMembers.forEach( - ({ email, uuid }: { email: string; uuid: string }) => - void this.services.Notifications.fileUpdated({ - file: result, - email, - uuid, - clientId, - }), - ); + workspaceMembers.forEach(({ email, uuid }: { email: string; uuid: string }) => { + void this.services.Notifications.fileUpdated({ + file: result, + email, + uuid, + clientId, + }); + + void this.getTokensAndSendNotification(uuid); + }); }) .catch((err: Error) => { this.logger.error(`Error updating metadata from file ${fileId} : ${err}`); @@ -566,15 +570,16 @@ export class StorageController { .then(async () => { res.status(200).json({ deleted: true }); const workspaceMembers = await this.services.User.findWorkspaceMembers(user.bridgeUser); - workspaceMembers.forEach( - ({ email, uuid }: { email: string; uuid: string }) => - void this.services.Notifications.fileDeleted({ - id: Number(fileid), - email, - uuid, - clientId, - }), - ); + workspaceMembers.forEach(({ email, uuid }: { email: string; uuid: string }) => { + void this.services.Notifications.fileDeleted({ + id: Number(fileid), + email, + uuid, + clientId, + }); + + void this.getTokensAndSendNotification(uuid); + }); this.services.Analytics.trackFileDeleted(req); }) @@ -802,6 +807,14 @@ export class StorageController { res.status(200).json(result); } + + private async getTokensAndSendNotification(userUuid: string) { + const tokens = await this.services.User.getUserNotificationTokens(userUuid, 'macos'); + + tokens.forEach((token: string) => { + this.services.Apn.sendStorageNotification(token, userUuid); + }); + } } export default (router: Router, service: any) => { @@ -811,22 +824,20 @@ export default (router: Router, service: any) => { const resourceSharingAdapter = resourceSharingMiddlewareBuilder.build(service); const controller = new StorageController(service, Logger); - router.post('/storage/file', + router.post( + '/storage/file', passportAuth, sharedAdapter, resourceSharingAdapter.UploadFile, - controller.createFile.bind(controller) + controller.createFile.bind(controller), ); - router.post('/storage/file/exists', - passportAuth, - sharedAdapter, - controller.checkFileExistence.bind(controller) - ); - router.post('/storage/thumbnail', + router.post('/storage/file/exists', passportAuth, sharedAdapter, controller.checkFileExistence.bind(controller)); + router.post( + '/storage/thumbnail', passportAuth, sharedAdapter, resourceSharingAdapter.UploadThumbnail, - controller.createThumbnail.bind(controller) + controller.createThumbnail.bind(controller), ); router.post('/storage/folder', passportAuth, sharedAdapter, controller.createFolder.bind(controller)); router.post('/storage/folder/exists', passportAuth, sharedAdapter, controller.checkFolderExistence.bind(controller)); @@ -847,7 +858,7 @@ export default (router: Router, service: any) => { passportAuth, sharedAdapter, resourceSharingAdapter.RenameFile, - controller.updateFile.bind(controller) + controller.updateFile.bind(controller), ); router.delete('/storage/bucket/:bucketid/file/:fileid', passportAuth, controller.deleteFileBridge.bind(controller)); router.delete( diff --git a/src/app/services/user.js b/src/app/services/user.js index b8fcfba1..246feb9c 100644 --- a/src/app/services/user.js +++ b/src/app/services/user.js @@ -800,8 +800,8 @@ module.exports = (Model, App) => { } }; - const getUserNotificationTokens = async (user, type = null) => { - let whereClause = { userId: user.uuid }; + const getUserNotificationTokens = async (userUuid, type = null) => { + let whereClause = { userId: userUuid }; if (type !== null) { whereClause.type = type; diff --git a/src/config/initializers/apn.ts b/src/config/initializers/apn.ts index 5603c4fd..b185a623 100644 --- a/src/config/initializers/apn.ts +++ b/src/config/initializers/apn.ts @@ -10,6 +10,9 @@ export default class Apn { private reconnectDelay = 1000; private readonly bundleId = process.env.APN_BUNDLE_ID; + private readonly apnUrl = + process.env.NODE_ENV === 'production' ? 'https://api.push.apple.com' : 'http://api.sandbox.push.apple.com'; + private jwt: string | null = null; private jwtGeneratedAt = 0; @@ -33,7 +36,7 @@ export default class Apn { Logger.getInstance().warn('APN env variables must be defined'); } - const client = http2.connect(process.env.APN_URL as string, {}); + const client = http2.connect(this.apnUrl, {}); client.on('error', (err) => { Logger.getInstance().error('APN connection error', err); @@ -59,7 +62,7 @@ export default class Apn { iss: process.env.APN_TEAM_ID, iat: Math.floor(Date.now() / 1000), }, - process.env.APN_SECRET as string, + Buffer.from(process.env.APN_SECRET as string, 'base64').toString('utf8'), { algorithm: 'ES256', header: { @@ -71,6 +74,7 @@ export default class Apn { this.jwtGeneratedAt = Date.now(); + console.log('Generated new APN JWT', this.jwt); return this.jwt; } @@ -86,15 +90,15 @@ export default class Apn { } } - public sendNotification(payload: Record, topic?: string): void { + public sendStorageNotification(deviceToken: string, userUuid: string): void { const headers = { - 'apns-topic': topic ?? `${this.bundleId}.pushkit.fileprovider`, + 'apns-topic': `${this.bundleId}.pushkit.fileprovider`, authorization: `bearer ${this.generateJwt()}`, }; const options = { ':method': 'POST', - ':path': `/3/device/${payload.deviceToken}`, + ':path': `/3/device/${deviceToken}`, ':scheme': 'https', ':authority': 'api.push.apple.com', 'content-type': 'application/json', @@ -103,7 +107,12 @@ export default class Apn { const req = this.client.request({ ...options, ...headers }); req.setEncoding('utf8'); - req.write(JSON.stringify(payload)); + req.write( + JSON.stringify({ + 'container-identifier': 'NSFileProviderWorkingSetContainerItemIdentifier', + domain: userUuid, + }), + ); req.end(); req.on('error', (err) => { diff --git a/tests/controllers/storage.test.ts b/tests/controllers/storage.test.ts index ba7ff9ad..e53cfa10 100644 --- a/tests/controllers/storage.test.ts +++ b/tests/controllers/storage.test.ts @@ -227,10 +227,14 @@ describe('Storage controller', () => { }, User: { findWorkspaceMembers: stubOf('findWorkspaceMembers').resolves([{}, {}]), + getUserNotificationTokens: stubOf('getUserNotificationTokens').resolves(['token']), }, Notifications: { fileCreated: sinon.spy(), }, + Apn: { + sendStorageNotification: sinon.spy(), + }, }; const controller = getController(services); const request = getRequest({ @@ -267,6 +271,7 @@ describe('Storage controller', () => { expect(services.Analytics.trackUploadCompleted.calledOnce).to.be.true; expect(services.User.findWorkspaceMembers.calledOnce).to.be.true; expect(services.Notifications.fileCreated.calledTwice).to.be.true; + expect(services.Apn.sendStorageNotification.calledTwice).to.be.true; expect(jsonSpy.calledOnce).to.be.true; }); @@ -281,10 +286,14 @@ describe('Storage controller', () => { }, User: { findWorkspaceMembers: stubOf('findWorkspaceMembers').resolves([{}, {}]), + getUserNotificationTokens: stubOf('getUserNotificationTokens').resolves(['token']), }, Notifications: { fileCreated: sinon.spy(), }, + Apn: { + sendStorageNotification: sinon.spy(), + }, }; const controller = getController(services); const request = getRequest({ @@ -322,6 +331,7 @@ describe('Storage controller', () => { expect(services.Analytics.trackUploadCompleted.calledOnce).to.be.true; expect(services.User.findWorkspaceMembers.calledOnce).to.be.true; expect(services.Notifications.fileCreated.calledTwice).to.be.true; + expect(services.Apn.sendStorageNotification.calledTwice).to.be.true; expect(jsonSpy.calledOnce).to.be.true; }); @@ -339,10 +349,14 @@ describe('Storage controller', () => { }, User: { findWorkspaceMembers: stubOf('findWorkspaceMembers').resolves([{}, {}]), + getUserNotificationTokens: stubOf('getUserNotificationTokens').resolves(['token']), }, Notifications: { fileCreated: sinon.spy(), }, + Apn: { + sendStorageNotification: sinon.spy(), + }, }; const controller = getController(services); const request = getRequest({ @@ -380,6 +394,7 @@ describe('Storage controller', () => { expect(services.Analytics.trackUploadCompleted.calledOnce).to.be.true; expect(services.User.findWorkspaceMembers.calledOnce).to.be.true; expect(services.Notifications.fileCreated.calledTwice).to.be.true; + expect(services.Apn.sendStorageNotification.calledTwice).to.be.true; expect(jsonSpy.calledOnce).to.be.true; expect(services.UsersReferrals.applyUserReferral.calledOnce).to.be.true; expect(services.UsersReferrals.applyUserReferral.args[0]).to.deep.equal(['id', 'install-mobile-app']); @@ -399,10 +414,14 @@ describe('Storage controller', () => { }, User: { findWorkspaceMembers: stubOf('findWorkspaceMembers').resolves([{}, {}]), + getUserNotificationTokens: stubOf('getUserNotificationTokens').resolves(['token']), }, Notifications: { fileCreated: sinon.spy(), }, + Apn: { + sendStorageNotification: sinon.spy(), + }, }; const controller = getController(services); const request = getRequest({ @@ -440,6 +459,7 @@ describe('Storage controller', () => { expect(services.Analytics.trackUploadCompleted.calledOnce).to.be.true; expect(services.User.findWorkspaceMembers.calledOnce).to.be.true; expect(services.Notifications.fileCreated.calledTwice).to.be.true; + expect(services.Apn.sendStorageNotification.calledTwice).to.be.true; expect(jsonSpy.calledOnce).to.be.true; expect(services.UsersReferrals.applyUserReferral.calledOnce).to.be.true; expect(services.UsersReferrals.applyUserReferral.args[0]).to.deep.equal(['id', 'install-desktop-app']); @@ -549,10 +569,14 @@ describe('Storage controller', () => { }, User: { findWorkspaceMembers: stubOf('findWorkspaceMembers').resolves([{}, {}]), + getUserNotificationTokens: stubOf('getUserNotificationTokens').resolves(['token']), }, Notifications: { folderCreated: sinon.spy(), }, + Apn: { + sendStorageNotification: sinon.spy(), + }, }; const controller = getController(services); const request = getRequest({ @@ -581,6 +605,7 @@ describe('Storage controller', () => { expect(services.Folder.Create.calledOnce).to.be.true; expect(services.User.findWorkspaceMembers.calledOnce).to.be.true; expect(services.Notifications.folderCreated.calledTwice).to.be.true; + expect(services.Apn.sendStorageNotification.calledTwice).to.be.true; expect(jsonSpy.calledOnce).to.be.true; expect(jsonSpy.args[0]).to.deep.equal([ { @@ -843,10 +868,14 @@ describe('Storage controller', () => { }, User: { findWorkspaceMembers: stubOf('findWorkspaceMembers').resolves([{}, {}]), + getUserNotificationTokens: stubOf('getUserNotificationTokens').resolves(['token']), }, Notifications: { folderDeleted: sinon.spy(), }, + Apn: { + sendStorageNotification: sinon.spy(), + }, }; const controller = getController(services); const request = getRequest({ @@ -872,6 +901,7 @@ describe('Storage controller', () => { expect(services.Folder.Delete.calledOnce).to.be.true; expect(services.User.findWorkspaceMembers.calledOnce).to.be.true; expect(services.Notifications.folderDeleted.calledTwice).to.be.true; + expect(services.Apn.sendStorageNotification.calledTwice).to.be.true; expect(sendSpy.calledOnce).to.be.true; expect(sendSpy.args[0]).to.deep.equal([ { @@ -973,10 +1003,14 @@ describe('Storage controller', () => { }, User: { findWorkspaceMembers: stubOf('findWorkspaceMembers').resolves([{}, {}]), + getUserNotificationTokens: stubOf('getUserNotificationTokens').resolves(['token']), }, Notifications: { folderUpdated: sinon.spy(), }, + Apn: { + sendStorageNotification: sinon.spy(), + }, }; const controller = getController(services); const request = getRequest({ @@ -1003,6 +1037,7 @@ describe('Storage controller', () => { expect(services.Folder.MoveFolder.calledOnce).to.be.true; expect(services.User.findWorkspaceMembers.calledOnce).to.be.true; expect(services.Notifications.folderUpdated.calledTwice).to.be.true; + expect(services.Apn.sendStorageNotification.calledTwice).to.be.true; expect(jsonSpy.calledOnce).to.be.true; expect(jsonSpy.args[0]).to.deep.equal([ { @@ -1092,10 +1127,14 @@ describe('Storage controller', () => { }, User: { findWorkspaceMembers: stubOf('findWorkspaceMembers').resolves([{}, {}]), + getUserNotificationTokens: stubOf('getUserNotificationTokens').resolves(['token']), }, Notifications: { folderUpdated: sinon.spy(), }, + Apn: { + sendStorageNotification: sinon.spy(), + }, }; const controller = getController(services); const request = getRequest({ @@ -1124,6 +1163,7 @@ describe('Storage controller', () => { expect(services.Folder.UpdateMetadata.calledOnce).to.be.true; expect(services.User.findWorkspaceMembers.calledOnce).to.be.true; expect(services.Notifications.folderUpdated.calledTwice).to.be.true; + expect(services.Apn.sendStorageNotification.calledTwice).to.be.true; expect(jsonSpy.calledOnce).to.be.true; expect(jsonSpy.args[0]).to.deep.equal([ { @@ -1523,10 +1563,14 @@ describe('Storage controller', () => { }, User: { findWorkspaceMembers: stubOf('findWorkspaceMembers').resolves([{}, {}]), + getUserNotificationTokens: stubOf('getUserNotificationTokens').resolves(['token']), }, Notifications: { fileUpdated: sinon.spy(), }, + Apn: { + sendStorageNotification: sinon.spy(), + }, }; const controller = getController(services); const request = getRequest({ @@ -1553,6 +1597,7 @@ describe('Storage controller', () => { expect(services.Files.MoveFile.calledOnce).to.be.true; expect(services.User.findWorkspaceMembers.calledOnce).to.be.true; expect(services.Notifications.fileUpdated.calledTwice).to.be.true; + expect(services.Apn.sendStorageNotification.calledTwice).to.be.true; expect(jsonSpy.calledOnce).to.be.true; expect(jsonSpy.args[0]).to.deep.equal([ { @@ -1726,10 +1771,14 @@ describe('Storage controller', () => { }, User: { findWorkspaceMembers: stubOf('findWorkspaceMembers').resolves([{}, {}]), + getUserNotificationTokens: stubOf('getUserNotificationTokens').resolves(['token']), }, Notifications: { fileUpdated: sinon.spy(), }, + Apn: { + sendStorageNotification: sinon.spy(), + }, }; const controller = getController(services); const request = getRequest({ @@ -2025,10 +2074,14 @@ describe('Storage controller', () => { }, User: { findWorkspaceMembers: stubOf('findWorkspaceMembers').resolves([{}, {}]), + getUserNotificationTokens: stubOf('getUserNotificationTokens').resolves(['token']), }, Notifications: { fileDeleted: sinon.spy(), }, + Apn: { + sendStorageNotification: sinon.spy(), + }, Analytics: { trackFileDeleted: sinon.spy(), }, @@ -2785,7 +2838,6 @@ describe('Storage controller', () => { }); }); - function getController(services = {}, logger = {}): StorageController { const defaultServices = { Files: {}, @@ -2801,17 +2853,17 @@ function getController(services = {}, logger = {}): StorageController { const finalServices = { ...defaultServices, - ...services + ...services, }; const defaultLogger = { error: () => null, - warn: () => null + warn: () => null, }; const finalLogger = { ...defaultLogger, - ...logger + ...logger, } as unknown as Logger; return new StorageController(finalServices, finalLogger); @@ -2826,7 +2878,10 @@ function getResponse(props = {}): Response { } function stubOf(functionName: string): SinonStub { - return sinon.stub({ - [functionName]: null - }, functionName); -} \ No newline at end of file + return sinon.stub( + { + [functionName]: null, + }, + functionName, + ); +}