diff --git a/CHANGELOG.md b/CHANGELOG.md index 6191fa4..1c68586 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). - Introduce new administrative channel management endpoints `/admin/v1/realms/{realmId}/channels`. - Introduce new administrative subscription management endpoints `/admin/v1/realms/{realmId}/subscriptions`. - Introduce new administrative endpoint for deleting auth tokens `DELETE /admin/v1/realms/{realmId}/tokens/{tokenId}` +- Introduce new administrative endpoint for deleting channels `DELETE /admin/v1/realms/{realmId}/channels/{channelId}`. +- Introduce new administrative endpoint for deleting subscriptions `DELETE /admin/v1/realms/{realmId}/subscriptions/{subscriptionsId}`. +- Introduce new administrative endpoint for deleting users `DELETE /admin/v1/realms/{realmId}/users/{userId}`. ### Changed - Add support for upgraded verneMQ broker. This requires the acceptance of their [EULA](https://vernemq.com/end-user-license-agreement/), diff --git a/src/api/admin/v1/routes/realms/{realmId}/channels/{id}.js b/src/api/admin/v1/routes/realms/{realmId}/channels/{id}.js new file mode 100644 index 0000000..a53953c --- /dev/null +++ b/src/api/admin/v1/routes/realms/{realmId}/channels/{id}.js @@ -0,0 +1,22 @@ +/** + * @param {{admin: AdminTasks}} tasks Tasks + * @returns {{delete: delete}} Method handlers + */ +module.exports = tasks => ({ + /** + * DELETE /realms/{realmId}/channels/{id} + * @param {object} request Request + * @param {object} response Response + */ + async delete(request, response) { + const {realmId, id} = request.params; + + const {ok, error} = await tasks.admin.deleteChannel({realmId, id}); + + if (!ok) { + throw error; + } + + response.status(204).send(); + }, +}); diff --git a/src/api/admin/v1/routes/realms/{realmId}/channels/{id}.yaml b/src/api/admin/v1/routes/realms/{realmId}/channels/{id}.yaml new file mode 100644 index 0000000..310e198 --- /dev/null +++ b/src/api/admin/v1/routes/realms/{realmId}/channels/{id}.yaml @@ -0,0 +1,25 @@ +/realms/{realmId}/channels/{id}: + parameters: + - $ref: '#/parameters/realmIdInPath' + - name: id + description: The channel id. + in: path + required: true + type: string + pattern: ^[\w-]+$ + + delete: + summary: Delete the channel referenced by id. + description: | + Delete the channel and all sub resources like subscriptions and messages. + tags: + - Channels + responses: + 204: + description: Channel has been deleted successfully. + 400: + description: Request validation failed. + 401: + description: The channel could not be deleted due to failed authorization. + 404: + description: The requested channel does not exist. diff --git a/src/api/admin/v1/routes/realms/{realmId}/subscriptions/{id}.js b/src/api/admin/v1/routes/realms/{realmId}/subscriptions/{id}.js new file mode 100644 index 0000000..603c9b9 --- /dev/null +++ b/src/api/admin/v1/routes/realms/{realmId}/subscriptions/{id}.js @@ -0,0 +1,22 @@ +/** + * @param {{admin: AdminTasks}} tasks Tasks + * @returns {{delete: delete}} Method handlers + */ +module.exports = tasks => ({ + /** + * DELETE /realms/{realmId}/subscriptions/{id} + * @param {object} request Request + * @param {object} response Response + */ + async delete(request, response) { + const {realmId, id} = request.params; + + const {ok, error} = await tasks.admin.deleteSubscription({realmId, id}); + + if (!ok) { + throw error; + } + + response.status(204).send(); + }, +}); diff --git a/src/api/admin/v1/routes/realms/{realmId}/subscriptions/{id}.yaml b/src/api/admin/v1/routes/realms/{realmId}/subscriptions/{id}.yaml new file mode 100644 index 0000000..1482b07 --- /dev/null +++ b/src/api/admin/v1/routes/realms/{realmId}/subscriptions/{id}.yaml @@ -0,0 +1,25 @@ +/realms/{realmId}/subscriptions/{id}: + parameters: + - $ref: '#/parameters/realmIdInPath' + - name: id + description: The subscription id. + in: path + required: true + type: string + pattern: ^[\w-]+$ + + delete: + summary: Delete the subscription referenced by id. + description: | + Delete the subscription and emit a subscription sync event. + tags: + - Subscriptions + responses: + 204: + description: Subscription has been deleted successfully. + 400: + description: Request validation failed. + 401: + description: The subscription could not be deleted due to failed authorization. + 404: + description: The requested subscription does not exist. diff --git a/src/api/admin/v1/routes/realms/{realmId}/users/{id}.js b/src/api/admin/v1/routes/realms/{realmId}/users/{id}.js new file mode 100644 index 0000000..da81643 --- /dev/null +++ b/src/api/admin/v1/routes/realms/{realmId}/users/{id}.js @@ -0,0 +1,22 @@ +/** + * @param {{admin: AdminTasks}} tasks Tasks + * @returns {{delete: delete}} Method handlers + */ +module.exports = tasks => ({ + /** + * DELETE /realms/{realmId}/users/{id} + * @param {object} request Request + * @param {object} response Response + */ + async delete(request, response) { + const {realmId, id} = request.params; + + const {ok, error} = await tasks.admin.deleteUser({realmId, id}); + + if (!ok) { + throw error; + } + + response.status(204).send(); + }, +}); diff --git a/src/api/admin/v1/routes/realms/{realmId}/users/{id}.yaml b/src/api/admin/v1/routes/realms/{realmId}/users/{id}.yaml new file mode 100644 index 0000000..5dd63e4 --- /dev/null +++ b/src/api/admin/v1/routes/realms/{realmId}/users/{id}.yaml @@ -0,0 +1,25 @@ +/realms/{realmId}/users/{id}: + parameters: + - $ref: '#/parameters/realmIdInPath' + - name: id + description: The user id. + in: path + required: true + type: string + pattern: ^[\w-]+$ + + delete: + summary: Delete the user referenced by id. + description: | + Delete the user and all sub resources like subscriptions, auth tokens and realtime connections. + tags: + - Users + responses: + 204: + description: User has been deleted successfully. + 400: + description: Request validation failed. + 401: + description: The user could not be deleted due to failed authorization. + 404: + description: The requested user does not exist. diff --git a/src/bootstrap/tasks.js b/src/bootstrap/tasks.js index f386c2b..31a2bdf 100644 --- a/src/bootstrap/tasks.js +++ b/src/bootstrap/tasks.js @@ -43,6 +43,7 @@ module.exports = ({ authTokenRules, authRepository, channelRepository, + messageRepository, realmRepository, subscriptionRepository, userRepository, diff --git a/src/repositories/message.js b/src/repositories/message.js index cd9c41d..2d67b8f 100644 --- a/src/repositories/message.js +++ b/src/repositories/message.js @@ -64,5 +64,19 @@ module.exports = ({collection, createModel = createMessageModel}) => { return multiRealmRepo.find({...query}, {limit, offset, sort}); }, + + /** + * Remove all messages from the given channel. + * + * @param {string} realmId Realm context + * @param {string} channelId Id of channel to remove messages for + */ + async deleteAllByChannelId({realmId, channelId}) { + if (!channelId) { + throw new Error('Missing channel id.'); + } + + await multiRealmRepo.deleteMany({realmId, channelId}); + }, }; }; diff --git a/src/tasks/admin/delete-channel.js b/src/tasks/admin/delete-channel.js new file mode 100644 index 0000000..457a83c --- /dev/null +++ b/src/tasks/admin/delete-channel.js @@ -0,0 +1,49 @@ +const {success, failure} = require('../../lib/result'); +const taskError = require('../../lib/error/task'); +const createTaskError = require('../../lib/error/task'); + +/** + * Init delete channel task + * @param {RealmRepository} realmRepository Realm repository + * @param {ChannelRepository} channelRepository Channel repository + * @param {SubscriptionRepository} subscriptionRepository Subscription repository + * @param {MessageRepository} messageRepository Message repository + * @returns {Function} Task + */ +module.exports = ({ + realmRepository, + channelRepository, + subscriptionRepository, + messageRepository, +}) => + /** + * @function AdminTasks#deleteChannel + * @param {string} realmId Realm id + * @param {string} id Channel id + * @returns {Result} + */ + async ({realmId, id}) => { + const realm = await realmRepository.findOne({id: realmId}); + if (!realm) { + return failure(createTaskError({ + code: 'UnknownRealm', + message: 'Cannot lookup the given realm.', + })); + } + + const channel = await channelRepository.findOne({realmId, id}); + if (!channel) { + return failure(taskError({ + code: 'UnknownChannel', + message: 'Channel does not exists.', + })); + } + + await Promise.all([ + channelRepository.deleteOne({realmId, id}), + subscriptionRepository.deleteAllByChannelId({realmId, channelId: id}), + messageRepository.deleteAllByChannelId({realmId, channelId: id}), + ]); + + return success(channel); + }; diff --git a/src/tasks/admin/delete-channel.spec.js b/src/tasks/admin/delete-channel.spec.js new file mode 100644 index 0000000..ccadbfa --- /dev/null +++ b/src/tasks/admin/delete-channel.spec.js @@ -0,0 +1,84 @@ +const channelRepository = require('../../lib/test/mocks/repositories/channel'); +const realmRepository = require('../../lib/test/mocks/repositories/realm'); +const messageRepository = require('../../lib/test/mocks/repositories/message'); +const subscriptionRepository = require('../../lib/test/mocks/repositories/subscription'); +const initDeleteChannel = require('./delete-channel'); + +describe('The client deleteChannel task', () => { + let deleteChannel; + + beforeEach(() => { + channelRepository.deleteOne = jest.fn(); + messageRepository.deleteAllByChannelId = jest.fn(); + subscriptionRepository.deleteAllByChannelId = jest.fn(); + deleteChannel = initDeleteChannel({ + realmRepository, + messageRepository, + channelRepository, + subscriptionRepository, + }); + }); + + describe('when called with unknown realm id', () => { + it('should fail with appropriate error', async () => { + const {ok, error} = await deleteChannel({realmId: realmRepository.unknownRealmId, id: channelRepository.knownChannelId}); + + expect(ok).toBe(false); + expect(error).toBeDefined(); + expect(error.code).toBe('UnknownRealm'); + }); + }); + + describe('when given an unknown channel id', () => { + it('should fail with appropriate error', async () => { + const {ok, error} = await deleteChannel({ + realmId: realmRepository.knownRealmId, + id: channelRepository.unknownChannelId, + }); + + expect(ok).toBe(false); + expect(error).toBeDefined(); + expect(error.code).toBe('UnknownChannel'); + }); + }); + + describe('when configured correctly', () => { + it('should delete the channel', async () => { + const {ok} = await deleteChannel({ + realmId: realmRepository.knownRealmId, + id: channelRepository.knownChannelId, + }); + + expect(ok).toBe(true); + expect(channelRepository.deleteOne).toHaveBeenCalled(); + }); + + it('should delete all subscriptions on a channel', async () => { + const subscription = subscriptionRepository.validSubscription; + await deleteChannel({ + realmId: subscription.realmId, + id: subscription.channelId, + }); + + const deleteMethod = subscriptionRepository.deleteAllByChannelId; + expect(deleteMethod).toHaveBeenCalledWith({ + realmId: subscription.realmId, + channelId: subscription.channelId, + }); + }); + + it('should delete all messages on a channel', async () => { + const message = messageRepository.validMessage; + await deleteChannel({ + realmId: message.realmId, + id: message.channelId, + }); + + const deleteMethod = messageRepository.deleteAllByChannelId; + expect(deleteMethod).toHaveBeenCalledWith({ + realmId: message.realmId, + channelId: message.channelId, + }); + }); + }); +}); diff --git a/src/tasks/admin/delete-subscription.js b/src/tasks/admin/delete-subscription.js new file mode 100644 index 0000000..1e8fd59 --- /dev/null +++ b/src/tasks/admin/delete-subscription.js @@ -0,0 +1,44 @@ +const {success, failure} = require('../../lib/result'); +const taskError = require('../../lib/error/task'); +const createTaskError = require('../../lib/error/task'); + +/** + * Init delete subscription task + * @param {RealmRepository} realmRepository Realm repository + * @param {SubscriptionRepository} subscriptionRepository Subscription repository + * @param {CommonTasks#sendSubscriptionSyncMessage} sendSubscriptionSyncMessage Task for sending subscription sync + * @returns {AdminTasks#deleteSubscription} Task + */ +module.exports = ({ + realmRepository, + subscriptionRepository, + sendSubscriptionSyncMessage, +}) => + /** + * @function AdminTasks#deleteSubscription + * @param {string} realmId Realm id + * @param {string} id Subscription id + * @returns {Result} + */ + async ({realmId, id}) => { + const realm = await realmRepository.findOne({id: realmId}); + if (!realm) { + return failure(createTaskError({ + code: 'UnknownRealm', + message: 'Cannot lookup the given realm.', + })); + } + + const subscription = await subscriptionRepository.findOne({realmId, id}); + if (!subscription) { + return failure(taskError({ + code: 'UnknownSubscription', + message: 'Subscription does not exists.', + })); + } + + await subscriptionRepository.deleteOne({realmId, id}); + sendSubscriptionSyncMessage({subscription, action: 'deleted'}); + + return success(subscription); + }; diff --git a/src/tasks/admin/delete-subscription.spec.js b/src/tasks/admin/delete-subscription.spec.js new file mode 100644 index 0000000..a25ace2 --- /dev/null +++ b/src/tasks/admin/delete-subscription.spec.js @@ -0,0 +1,68 @@ +const subscriptionRepository = require('../../lib/test/mocks/repositories/subscription'); +const realmRepository = require('../../lib/test/mocks/repositories/realm'); +const initDeleteSubscription = require('./delete-subscription'); + +describe('The admin deleteSubscription task', () => { + const sendSubscriptionSyncMessage = jest.fn(); + let deleteSubscription; + + beforeEach(() => { + subscriptionRepository.deleteOne = jest.fn(); + deleteSubscription = initDeleteSubscription({ + realmRepository, + subscriptionRepository, + sendSubscriptionSyncMessage, + }); + }); + + describe('when called with unknown realm id', () => { + it('should fail with appropriate error', async () => { + const {ok, error} = await deleteSubscription({ + realmId: realmRepository.unknownRealmId, + id: subscriptionRepository.knownSubscriptionId, + }); + + expect(ok).toBe(false); + expect(error).toBeDefined(); + expect(error.code).toBe('UnknownRealm'); + }); + }); + + describe('when given an unknown subscription id', () => { + it('should fail with appropriate error', async () => { + const {ok, error} = await deleteSubscription({ + realmId: realmRepository.knownRealmId, + id: subscriptionRepository.unknownSubscriptionId, + }); + + expect(ok).toBe(false); + expect(error).toBeDefined(); + expect(error.code).toBe('UnknownSubscription'); + }); + }); + + describe('when configured correctly', () => { + it('should delete the subscription', async () => { + const {ok} = await deleteSubscription({ + realmId: realmRepository.knownRealmId, + id: subscriptionRepository.knownSubscriptionId, + }); + + expect(ok).toBe(true); + expect(subscriptionRepository.deleteOne).toHaveBeenCalled(); + }); + + it('should send a subscription deleted sync message', async () => { + const {ok, result} = await deleteSubscription({ + realmId: realmRepository.knownRealmId, + id: subscriptionRepository.knownSubscriptionId, + }); + + expect(ok).toBe(true); + expect(sendSubscriptionSyncMessage).toHaveBeenCalledWith({ + subscription: result, + action: 'deleted', + }); + }); + }); +}); diff --git a/src/tasks/admin/delete-user.js b/src/tasks/admin/delete-user.js new file mode 100644 index 0000000..5d32da0 --- /dev/null +++ b/src/tasks/admin/delete-user.js @@ -0,0 +1,53 @@ +const {success, failure} = require('../../lib/result'); +const taskError = require('../../lib/error/task'); +const createTaskError = require('../../lib/error/task'); + +/** + * Init delete user task. + * @param {RealmRepository} realmRepository Realm repository + * @param {UserRepository} userRepository The user repository + * @param {AuthRepository} authRepository The auth repository + * @param {RealtimeConnectionRepository} realtimeConnectionRepository The realtime connection repository + * @param {SubscriptionRepository} subscriptionRepository The subscription repository + * @returns {ClientTasks#deleteUser} Task + */ +module.exports = ({ + realmRepository, + userRepository, + authRepository, + subscriptionRepository, + realtimeConnectionRepository, +}) => + /** + * Delete a user and its auth tokens and subscriptions. + * @function AdminTasks#deleteUser + * @param {string} realmId Realm id + * @param {string} id User id + * @returns {Result} + */ + async ({realmId, id}) => { + const realm = await realmRepository.findOne({id: realmId}); + if (!realm) { + return failure(createTaskError({ + code: 'UnknownRealm', + message: 'Cannot lookup the given realm.', + })); + } + + const user = await userRepository.findOne({realmId, id}); + if (!user) { + return failure(taskError({ + code: 'UnknownUser', + message: 'User does not exists.', + })); + } + + await Promise.all([ + userRepository.deleteOne({realmId, id}), + authRepository.deleteAllByUserId({realmId, userId: id}), + subscriptionRepository.deleteAllByUserId({realmId, userId: id}), + realtimeConnectionRepository.deleteAllByUserId({realmId, userId: id}), + ]); + + return success(user); + }; diff --git a/src/tasks/admin/delete-user.spec.js b/src/tasks/admin/delete-user.spec.js new file mode 100644 index 0000000..ec7ea5e --- /dev/null +++ b/src/tasks/admin/delete-user.spec.js @@ -0,0 +1,62 @@ +const userRepository = require('../../lib/test/mocks/repositories/user'); +const realmRepository = require('../../lib/test/mocks/repositories/realm'); +const authRepository = require('../../lib/test/mocks/repositories/auth'); +const realtimeConnectionRepository = require('../../lib/test/mocks/repositories/realtime-connection'); +const subscriptionRepository = require('../../lib/test/mocks/repositories/subscription'); +const initDeleteUser = require('./delete-user'); + +describe('The admin deleteUser task', () => { + let deleteUser; + + beforeEach(() => { + userRepository.deleteOne = jest.fn(); + authRepository.deleteAllByUserId = jest.fn(); + subscriptionRepository.deleteAllByUserId = jest.fn(); + realtimeConnectionRepository.deleteAllByUserId = jest.fn(); + deleteUser = initDeleteUser({ + realmRepository, + userRepository, + authRepository, + subscriptionRepository, + realtimeConnectionRepository, + }); + }); + + describe('when called with an invalid realm id', () => { + it('should fail with appropriate error', async () => { + const {ok, error} = await deleteUser({realmId: realmRepository.unknownRealmId, id: userRepository.knownUserId}); + + expect(ok).toBe(false); + expect(error).toBeDefined(); + expect(error.code).toBe('UnknownRealm'); + }); + }); + + describe('when given an unknown user id', () => { + it('should fail with appropriate error', async () => { + const {ok, error} = await deleteUser({ + realmId: realmRepository.knownRealmId, + id: userRepository.unknownUserId, + }); + + expect(ok).toBe(false); + expect(error).toBeDefined(); + expect(error.code).toBe('UnknownUser'); + }); + }); + + describe('when configured correctly', () => { + it('should delete the user', async () => { + const {ok} = await deleteUser({ + realmId: realmRepository.knownRealmId, + id: userRepository.knownUserId, + }); + + expect(ok).toBe(true); + expect(userRepository.deleteOne).toHaveBeenCalled(); + expect(authRepository.deleteAllByUserId).toHaveBeenCalled(); + expect(subscriptionRepository.deleteAllByUserId).toHaveBeenCalled(); + expect(realtimeConnectionRepository.deleteAllByUserId).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/tasks/admin/index.js b/src/tasks/admin/index.js index d38089b..d2a477d 100644 --- a/src/tasks/admin/index.js +++ b/src/tasks/admin/index.js @@ -3,7 +3,10 @@ const initCreateRealm = require('./create-realm'); const initCreateRealmToken = require('./create-realm-token'); const initCreateSubscription = require('./create-subscription'); const initCreateUser = require('./create-user'); +const initDeleteChannel = require('./delete-channel'); +const initDeleteSubscription = require('./delete-subscription'); const initDeleteToken = require('./delete-token'); +const initDeleteUser = require('./delete-user'); const initFetchRealm = require('./fetch-realm'); const initListChannels = require('./list-channels'); const initListRealms = require('./list-realms'); @@ -17,6 +20,7 @@ const initListUsers = require('./list-users'); * @param {RealmRepository} realmRepository The realm repository * @param {AuthRepository} authRepository The auth repository * @param {ChannelRepository} channelRepository The channel repository + * @param {MessageRepository} messageRepository The message repository * @param {RealtimeConnectionRepository} realtimeConnectionRepository The real-time connection repository * @param {SubscriptionRepository} subscriptionRepository The subscription repository * @param {UserRepository} userRepository The user repository @@ -27,6 +31,7 @@ module.exports = ({ authTokenRules, authRepository, channelRepository, + messageRepository, realmRepository, realtimeConnectionRepository, subscriptionRepository, @@ -45,11 +50,29 @@ module.exports = ({ subscriptionRepository, }), createUser: initCreateUser({userRepository, realmRepository}), + deleteChannel: initDeleteChannel({ + messageRepository, + realmRepository, + channelRepository, + subscriptionRepository, + }), + deleteSubscription: initDeleteSubscription({ + realmRepository, + subscriptionRepository, + sendSubscriptionSyncMessage, + }), deleteToken: initDeleteToken({ authRepository, realmRepository, realtimeConnectionRepository, }), + deleteUser: initDeleteUser({ + authRepository, + realmRepository, + realtimeConnectionRepository, + userRepository, + subscriptionRepository, + }), listChannels: initListChannels({realmRepository, channelRepository}), listRealms: initListRealms({realmRepository}), listRealmTokens: initListRealmTokens({realmRepository, authRepository}),