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/models/index.ts b/src/app/models/index.ts index 79f22acd..fe3b69b7 100644 --- a/src/app/models/index.ts +++ b/src/app/models/index.ts @@ -26,6 +26,7 @@ import initLimit, { LimitModel } from './limit'; import initTier, { TierModel } from './tier'; import initPaidPlan, { PaidPlansModel } from './paidPlans'; import initTierLimit, { TierLimitsModel } from './tierLimit'; +import initUserNotificationTokens, { UserNotificationTokenModel } from './userNotificationTokens'; export type ModelType = | AppSumoModel @@ -53,7 +54,8 @@ export type ModelType = | PaidPlansModel | TierLimitsModel | LimitModel - | TierModel; + | TierModel + | UserNotificationTokenModel; export default (database: Sequelize) => { const AppSumo = initAppSumo(database); @@ -82,6 +84,7 @@ export default (database: Sequelize) => { const Tier = initTier(database); const PaidPlans = initPaidPlan(database); const TierLimit = initTierLimit(database); + const UserNotificationToken = initUserNotificationTokens(database); AppSumo.belongsTo(User); @@ -132,6 +135,7 @@ export default (database: Sequelize) => { User.hasMany(PrivateSharingFolder, { foreignKey: 'shared_with', sourceKey: 'uuid' }); User.hasMany(Sharings, { foreignKey: 'owner_id', sourceKey: 'uuid' }); User.hasMany(Sharings, { foreignKey: 'shared_with', sourceKey: 'uuid' }); + User.hasMany(UserNotificationToken, { foreignKey: 'userId', sourceKey: 'uuid' }); UserReferral.belongsTo(User, { foreignKey: 'user_id' }); UserReferral.belongsTo(Referral, { foreignKey: 'referral_id' }); @@ -170,6 +174,8 @@ export default (database: Sequelize) => { as: 'tiers', }); + UserNotificationToken.belongsTo(User, { foreignKey: 'userId', targetKey: 'uuid' }); + return { [AppSumo.name]: AppSumo, [Backup.name]: Backup, @@ -197,5 +203,6 @@ export default (database: Sequelize) => { [Limit.name]: Limit, [PaidPlans.name]: PaidPlans, [TierLimit.name]: TierLimit, + ['userNotificationToken']: UserNotificationToken, }; }; diff --git a/src/app/models/userNotificationTokens.ts b/src/app/models/userNotificationTokens.ts new file mode 100644 index 00000000..fe8d73d3 --- /dev/null +++ b/src/app/models/userNotificationTokens.ts @@ -0,0 +1,60 @@ +import { DataTypes, ModelDefined, Sequelize } from 'sequelize'; + +export interface UserNotificationTokenAttributes { + id: string; + userId: string; + token: string; + type: 'macos' | 'android' | 'ios'; + createdAt: Date; + updatedAt: Date; +} + +export type UserNotificationTokenModel = ModelDefined; + +export default (database: Sequelize): UserNotificationTokenModel => { + const UserNotificationToken: UserNotificationTokenModel = database.define( + 'user_notification_tokens', + { + id: { + type: DataTypes.UUID, + primaryKey: true, + defaultValue: DataTypes.UUIDV4, + }, + userId: { + type: DataTypes.STRING(36), + allowNull: false, + references: { + model: 'users', + key: 'uuid', + }, + }, + token: { + type: DataTypes.STRING, + allowNull: false, + }, + type: { + type: DataTypes.ENUM('macos', 'android', 'ios'), + allowNull: false, + }, + createdAt: { + field: 'created_at', + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + }, + updatedAt: { + field: 'updated_at', + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + }, + }, + { + tableName: 'user_notification_tokens', + timestamps: true, + underscored: true, + }, + ); + + return UserNotificationToken; +}; diff --git a/src/app/routes/routes.ts b/src/app/routes/routes.ts index 4fb54168..648582f8 100644 --- a/src/app/routes/routes.ts +++ b/src/app/routes/routes.ts @@ -25,6 +25,7 @@ import * as ReCaptchaV3 from '../../lib/recaptcha'; import * as AnalyticsService from '../../lib/analytics/AnalyticsService'; import { AuthorizedUser } from './types'; import { default as Notifications } from '../../config/initializers/notifications'; +import { default as Apn } from '../../config/initializers/apn'; const Logger = logger.getInstance(); @@ -32,6 +33,7 @@ export default (router: Router, service: any, App: any): Router => { service.Analytics = AnalyticsService; service.ReCaptcha = ReCaptchaV3; service.Notifications = Notifications.getInstance(); + service.Apn = Apn.getInstance(); AuthRoutes(router, service, App.config); ActivationRoutes(router, service); @@ -107,7 +109,7 @@ export default (router: Router, service: any, App: any): Router => { if (!userData.root_folder_id) { throw createHttpError(500, 'Account can not be initialized'); } - + const user = { email: userData.email, bucket: userData.bucket, @@ -121,13 +123,9 @@ export default (router: Router, service: any, App: any): Router => { res.status(200).send({ user }); } catch (err) { Logger.error( - `[AUTH/INITIALIZE] ERROR: ${ - (err as Error).message - }, BODY ${ - JSON.stringify(req.body) - }, STACK: ${ + `[AUTH/INITIALIZE] ERROR: ${(err as Error).message}, BODY ${JSON.stringify(req.body)}, STACK: ${ (err as Error).stack - }` + }`, ); return res.status(500).send({ error: 'Internal Server Error' }); diff --git a/src/app/routes/storage.ts b/src/app/routes/storage.ts index 19250b1a..2da12551 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,28 @@ export class StorageController { res.status(200).json(result); } + + public async getTokensAndSendNotification(userUuid: string) { + const tokens = await this.services.User.getUserNotificationTokens(userUuid, 'macos'); + + const tokenPromises = tokens.map(async ({ token }: { token: string }) => { + try { + const response = await this.services.Apn.sendStorageNotification(token, userUuid); + return response.statusCode === 410 ? token : null; + } catch (error) { + this.logger.error(`Error sending APN notification to ${userUuid}: ${error}`); + return null; + } + }); + + const results = await Promise.all(tokenPromises); + + const expiredTokens = results.filter((token) => token !== null); + + if (expiredTokens.length > 0) { + await this.services.User.deleteUserNotificationTokens(userUuid, expiredTokens); + } + } } export default (router: Router, service: any) => { @@ -811,22 +838,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 +872,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 aeb4b499..b4a5103e 100644 --- a/src/app/services/user.js +++ b/src/app/services/user.js @@ -307,12 +307,15 @@ module.exports = (Model, App) => { await user.destroy(); - await Model.folder.update({ deleted: true, removed: true }, { - where: { - user_id: user.id, - parent_id: null, - } - }); + await Model.folder.update( + { deleted: true, removed: true }, + { + where: { + user_id: user.id, + parent_id: null, + }, + }, + ); logger.info('User %s confirmed deactivation', userEmail); } catch (err) { @@ -357,7 +360,7 @@ module.exports = (Model, App) => { password: newPassword, mnemonic, hKey: newSalt, - lastPasswordChangedAt: new Date() + lastPasswordChangedAt: new Date(), }, { where: { username: { [Op.eq]: user.email } }, @@ -522,7 +525,7 @@ module.exports = (Model, App) => { attributes: [[fn('sum', col('size')), 'total']], raw: true, }); - + driveUsage = parseInt(usage[0].total); await Redis.setUsage(user.uuid, driveUsage); @@ -797,6 +800,26 @@ module.exports = (Model, App) => { } }; + const getUserNotificationTokens = async (userUuid, type = null) => { + let whereClause = { userId: userUuid }; + + if (type !== null) { + whereClause.type = type; + } + return Model.userNotificationToken.findAll({ where: whereClause }); + }; + + const deleteUserNotificationTokens = async (userUuid, tokens) => { + return Model.userNotificationToken.destroy({ + where: { + userId: userUuid, + token: { + [Op.in]: tokens, + }, + }, + }); + }; + return { Name: 'User', FindOrCreate, @@ -833,5 +856,7 @@ module.exports = (Model, App) => { sendEmailVerification, verifyEmail, updateTier, + getUserNotificationTokens, + deleteUserNotificationTokens, }; }; diff --git a/src/config/initializers/apn.ts b/src/config/initializers/apn.ts new file mode 100644 index 00000000..014e3fd9 --- /dev/null +++ b/src/config/initializers/apn.ts @@ -0,0 +1,143 @@ +import * as http2 from 'http2'; +import jwt, { JwtHeader } from 'jsonwebtoken'; +import Logger from '../../lib/logger'; + +export default class Apn { + private static instance: Apn; + private client: http2.ClientHttp2Session; + private readonly maxReconnectAttempts = 3; + private reconnectAttempts = 0; + private reconnectDelay = 1000; + private readonly bundleId = process.env.APN_BUNDLE_ID; + + private readonly apnUrl = process.env.NODE_ENV as string; + + private jwt: string | null = null; + private jwtGeneratedAt = 0; + + constructor() { + this.client = this.connectToAPN(); + } + + static getInstance(): Apn { + if (!Apn.instance) { + Apn.instance = new Apn(); + } + return Apn.instance; + } + + private connectToAPN(): http2.ClientHttp2Session { + const apnSecret = process.env.APN_SECRET; + const apnKeyId = process.env.APN_KEY_ID; + const apnTeamId = process.env.APN_TEAM_ID; + + if (!apnSecret || !apnKeyId || !apnTeamId || !this.apnUrl) { + Logger.getInstance().warn('APN env variables are not defined'); + } + + const client = http2.connect(this.apnUrl); + + client.on('error', (err) => { + Logger.getInstance().error('APN connection error', err); + }); + client.on('close', () => { + Logger.getInstance().warn('APN connection was closed'); + this.handleReconnect(); + }); + client.on('connect', () => { + Logger.getInstance().info('Connected to APN'); + }); + + return client; + } + + private generateJwt(): string { + if (!process.env.APN_SECRET || !process.env.APN_KEY_ID || !process.env.APN_TEAM_ID) { + throw new Error('Undefined APN env variables, necessary for JWT generation'); + } + if (this.jwt && Date.now() - this.jwtGeneratedAt < 3600) { + return this.jwt; + } + + this.jwt = jwt.sign( + { + iss: process.env.APN_TEAM_ID, + iat: Math.floor(Date.now() / 1000), + }, + Buffer.from(process.env.APN_SECRET, 'base64').toString('utf8'), + { + algorithm: 'ES256', + header: { + alg: 'ES256', + kid: process.env.APN_KEY_ID, + } as JwtHeader, + }, + ); + + this.jwtGeneratedAt = Date.now(); + + return this.jwt; + } + + private handleReconnect() { + if (this.reconnectAttempts < this.maxReconnectAttempts) { + setTimeout(() => { + Logger.getInstance().info(`Attempting to reconnect to APN (#${this.reconnectAttempts + 1})`); + this.connectToAPN(); + this.reconnectAttempts++; + }, this.reconnectDelay * Math.pow(2, this.reconnectAttempts)); + } else { + Logger.getInstance().error('Maximum APN reconnection attempts reached'); + } + } + + public sendStorageNotification(deviceToken: string, userUuid: string): Promise<{ statusCode: number; body: string }> { + return new Promise((resolve, reject) => { + if (!this.client || this.client.closed) { + this.connectToAPN(); + } + + const headers: http2.OutgoingHttpHeaders = { + [http2.constants.HTTP2_HEADER_METHOD]: 'POST', + [http2.constants.HTTP2_HEADER_PATH]: `/3/device/${deviceToken}`, + [http2.constants.HTTP2_HEADER_SCHEME]: 'https', + [http2.constants.HTTP2_HEADER_AUTHORITY]: 'api.push.apple.com', + [http2.constants.HTTP2_HEADER_CONTENT_TYPE]: 'application/json', + [http2.constants.HTTP2_HEADER_AUTHORIZATION]: `bearer ${this.generateJwt()}`, + 'apns-topic': `${this.bundleId}.pushkit.fileprovider`, + }; + + const req = this.client.request({ ...headers }); + + req.setEncoding('utf8'); + req.write( + JSON.stringify({ + 'container-identifier': 'NSFileProviderWorkingSetContainerItemIdentifier', + domain: userUuid, + }), + ); + + req.end(); + + let statusCode = 0; + let data = ''; + + req.on('response', (_, status) => { + statusCode = status || 0; + }); + + req.on('data', (chunk) => { + data += chunk; + }); + + req.on('end', () => { + resolve({ statusCode, body: data }); + }); + + req.on('error', (err) => { + Logger.getInstance().error('APN request error', err); + reject(new Error(err)); + }); + }); + } +} diff --git a/tests/controllers/storage.test.ts b/tests/controllers/storage.test.ts index ba7ff9ad..cdcae70f 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: sinon.stub().resolves([{ token: 'token' }]), }, Notifications: { fileCreated: sinon.spy(), }, + Apn: { + sendStorageNotification: sinon.stub().resolves(Promise.resolve({ statusCode: 200, body: 'ok' })), + }, }; 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: sinon.stub().resolves([{ token: 'token' }]), }, Notifications: { fileCreated: sinon.spy(), }, + Apn: { + sendStorageNotification: sinon.stub().resolves(Promise.resolve({ statusCode: 200, body: 'ok' })), + }, }; 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: sinon.stub().resolves([{ token: 'token' }]), }, Notifications: { fileCreated: sinon.spy(), }, + Apn: { + sendStorageNotification: sinon.stub().resolves(Promise.resolve({ statusCode: 200, body: 'ok' })), + }, }; 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: sinon.stub().resolves([{ token: 'token' }]), }, Notifications: { fileCreated: sinon.spy(), }, + Apn: { + sendStorageNotification: sinon.stub().resolves(Promise.resolve({ statusCode: 200, body: 'ok' })), + }, }; 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: sinon.stub().resolves([{ token: 'token' }]), }, Notifications: { folderCreated: sinon.spy(), }, + Apn: { + sendStorageNotification: sinon.stub().resolves(Promise.resolve({ statusCode: 200, body: 'ok' })), + }, }; 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: 'token' }]), }, Notifications: { folderDeleted: sinon.spy(), }, + Apn: { + sendStorageNotification: sinon.stub().resolves(Promise.resolve({ statusCode: 200, body: 'ok' })), + }, }; 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: sinon.stub().resolves([{ token: 'token' }]), }, Notifications: { folderUpdated: sinon.spy(), }, + Apn: { + sendStorageNotification: sinon.stub().resolves(Promise.resolve({ statusCode: 200, body: 'ok' })), + }, }; 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: sinon.stub().resolves([{ token: 'token' }]), }, Notifications: { folderUpdated: sinon.spy(), }, + Apn: { + sendStorageNotification: sinon.stub().resolves(Promise.resolve({ statusCode: 200, body: 'ok' })), + }, }; 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: sinon.stub().resolves([{ token: 'token' }]), }, Notifications: { fileUpdated: sinon.spy(), }, + Apn: { + sendStorageNotification: sinon.stub().resolves(Promise.resolve({ statusCode: 200, body: 'ok' })), + }, }; 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: sinon.stub().resolves([{ token: 'token' }]), }, Notifications: { fileUpdated: sinon.spy(), }, + Apn: { + sendStorageNotification: sinon.stub().resolves(Promise.resolve({ statusCode: 200, body: 'ok' })), + }, }; const controller = getController(services); const request = getRequest({ @@ -2025,10 +2074,14 @@ describe('Storage controller', () => { }, User: { findWorkspaceMembers: stubOf('findWorkspaceMembers').resolves([{}, {}]), + getUserNotificationTokens: sinon.stub().resolves([{ token: 'token' }]), }, Notifications: { fileDeleted: sinon.spy(), }, + Apn: { + sendStorageNotification: sinon.stub().resolves(Promise.resolve({ statusCode: 200, body: 'ok' })), + }, Analytics: { trackFileDeleted: sinon.spy(), }, @@ -2783,8 +2836,66 @@ describe('Storage controller', () => { ]); }); }); -}); + describe('getTokensAndSendNotification', () => { + it('When no tokens are found apn should not be called', async () => { + const services = { + User: { + getUserNotificationTokens: sinon.stub().resolves([]), + }, + Apn: { + sendStorageNotification: sinon.stub(), + }, + }; + + const controller = getController(services); + + await controller.getTokensAndSendNotification('userId'); + expect(services.User.getUserNotificationTokens.calledOnce).to.be.true; + expect(services.Apn.sendStorageNotification.called).to.be.false; + }); + + it('When APN returns 410 the token should be deleted', async () => { + const services = { + User: { + getUserNotificationTokens: sinon.stub().resolves(['token']), + deleteUserNotificationTokens: sinon.stub().resolves(1), + }, + Apn: { + sendStorageNotification: sinon.stub().resolves(Promise.resolve({ statusCode: 410, body: 'Expired token' })), + }, + }; + + const controller = getController(services); + + await controller.getTokensAndSendNotification('userId'); + + expect(services.User.getUserNotificationTokens.calledOnce).to.be.true; + expect(services.Apn.sendStorageNotification.calledOnce).to.be.true; + expect(services.User.deleteUserNotificationTokens.calledOnce).to.be.true; + }); + }); + + it('When APN returns 200 the token should not be deleted', async () => { + const services = { + User: { + getUserNotificationTokens: sinon.stub().resolves([{ token: 'token' }]), + deleteUserNotificationTokens: sinon.spy(), + }, + Apn: { + sendStorageNotification: sinon.stub().resolves(Promise.resolve({ statusCode: 200, body: 'ok' })), + }, + }; + + const controller = getController(services); + + await controller.getTokensAndSendNotification('userId'); + + expect(services.User.getUserNotificationTokens.calledOnce).to.be.true; + expect(services.Apn.sendStorageNotification.calledOnce).to.be.true; + expect(services.User.deleteUserNotificationTokens.called).to.be.false; + }); +}); function getController(services = {}, logger = {}): StorageController { const defaultServices = { @@ -2801,17 +2912,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 +2937,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, + ); +}