diff --git a/src/lib/event-differ.js b/src/lib/event-differ.js index 00f65c0fe6..274f99b310 100644 --- a/src/lib/event-differ.js +++ b/src/lib/event-differ.js @@ -32,6 +32,9 @@ const { APPLICATION_CREATED, FEATURE_STALE_ON, FEATURE_STALE_OFF, + USER_CREATED, + USER_UPDATED, + USER_DELETED, } = require('./event-type'); const strategyTypes = [ @@ -63,6 +66,8 @@ const contextTypes = [ CONTEXT_FIELD_UPDATED, ]; +const userTypes = [USER_CREATED, USER_UPDATED, USER_DELETED]; + const tagTypes = [TAG_CREATED, TAG_DELETED]; const tagTypeTypes = [TAG_TYPE_CREATED, TAG_TYPE_DELETED]; @@ -88,23 +93,34 @@ function baseTypeFor(event) { if (tagTypeTypes.indexOf(event.type) !== -1) { return 'tag-type'; } + if (userTypes.indexOf(event.type) !== -1) { + return 'user'; + } if (event.type === APPLICATION_CREATED) { return 'application'; } return event.type; } +const uniqueFieldForType = baseType => { + if (baseType === 'user') { + return 'id'; + } + return 'name'; +}; + function groupByBaseTypeAndName(events) { const groups = {}; events.forEach(event => { const baseType = baseTypeFor(event); + const uniqueField = uniqueFieldForType(baseType); groups[baseType] = groups[baseType] || {}; - groups[baseType][event.data.name] = - groups[baseType][event.data.name] || []; + groups[baseType][event.data[uniqueField]] = + groups[baseType][event.data[uniqueField]] || []; - groups[baseType][event.data.name].push(event); + groups[baseType][event.data[uniqueField]].push(event); }); return groups; diff --git a/src/lib/event-type.js b/src/lib/event-type.js index 7d1c0aeafd..e5a10f25bf 100644 --- a/src/lib/event-type.js +++ b/src/lib/event-type.js @@ -42,4 +42,7 @@ module.exports = { ADDON_CONFIG_UPDATED: 'addon-config-updated', ADDON_CONFIG_DELETED: 'addon-config-deleted', DB_POOL_UPDATE: 'db-pool-update', + USER_CREATED: 'user-created', + USER_UPDATED: 'user-updated', + USER_DELETED: 'user-deleted', }; diff --git a/src/lib/events.ts b/src/lib/events.ts index 0c84c45021..0b9c891ee2 100644 --- a/src/lib/events.ts +++ b/src/lib/events.ts @@ -1,4 +1,4 @@ const REQUEST_TIME = 'request_time'; -const DB_TIME = 'db_time'; +const DB_TIME = 'db_time'; -export {REQUEST_TIME, DB_TIME}; +export { REQUEST_TIME, DB_TIME }; diff --git a/src/lib/routes/admin-api/user-admin.ts b/src/lib/routes/admin-api/user-admin.ts index 018ae1a44a..50a306d32a 100644 --- a/src/lib/routes/admin-api/user-admin.ts +++ b/src/lib/routes/admin-api/user-admin.ts @@ -6,13 +6,11 @@ import { AccessService } from '../../services/access-service'; import { Logger } from '../../logger'; import { handleErrors } from './util'; import { IUnleashConfig } from '../../types/option'; -import { EmailService, MAIL_ACCEPTED } from '../../services/email-service'; +import { EmailService } from '../../services/email-service'; import ResetTokenService from '../../services/reset-token-service'; import { IUnleashServices } from '../../types/services'; import SessionService from '../../services/session-service'; -const getCreatorUsernameOrPassword = req => req.user.username || req.user.email; - export default class UserAdminController extends Controller { private userService: UserService; @@ -46,8 +44,8 @@ export default class UserAdminController extends Controller { super(config); this.userService = userService; this.accessService = accessService; - this.logger = config.getLogger('routes/user-controller.ts'); this.emailService = emailService; + this.logger = config.getLogger('routes/user-controller.ts'); this.resetTokenService = resetTokenService; this.sessionService = sessionService; @@ -64,12 +62,12 @@ export default class UserAdminController extends Controller { // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types async resetPassword(req, res): Promise { + const { user } = req; try { - const requester = getCreatorUsernameOrPassword(req); const receiver = req.body.id; const resetPasswordUrl = await this.userService.createResetPasswordEmail( receiver, - requester, + user, ); res.json({ resetPasswordUrl }); } catch (e) { @@ -124,12 +122,15 @@ export default class UserAdminController extends Controller { const { user } = req; try { - const createdUser = await this.userService.createUser({ - username, - email, - name, - rootRole: Number(rootRole), - }); + const createdUser = await this.userService.createUser( + { + username, + email, + name, + rootRole: Number(rootRole), + }, + user, + ); const inviteLink = await this.resetTokenService.createNewUserUrl( createdUser.id, @@ -163,17 +164,22 @@ export default class UserAdminController extends Controller { // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types async updateUser(req, res): Promise { - const { id } = req.params; - const { name, email, rootRole } = req.body; + const { user, params, body } = req; + + const { id } = params; + const { name, email, rootRole } = body; try { - const user = await this.userService.updateUser({ - id: Number(id), - name, - email, - rootRole: Number(rootRole), - }); - res.status(200).send({ ...user, rootRole }); + const updateUser = await this.userService.updateUser( + { + id: Number(id), + name, + email, + rootRole: Number(rootRole), + }, + user, + ); + res.status(200).send({ ...updateUser, rootRole }); } catch (e) { this.logger.warn(e.message); res.status(400).send([{ msg: e.message }]); @@ -182,10 +188,11 @@ export default class UserAdminController extends Controller { // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types async deleteUser(req, res): Promise { - const { id } = req.params; + const { user, params } = req; + const { id } = params; try { - await this.userService.deleteUser(+id); + await this.userService.deleteUser(+id, user); res.status(200).send(); } catch (error) { this.logger.warn(error); diff --git a/src/lib/routes/auth/reset-password-controller.ts b/src/lib/routes/auth/reset-password-controller.ts index 173003741c..d3f6e9b87b 100644 --- a/src/lib/routes/auth/reset-password-controller.ts +++ b/src/lib/routes/auth/reset-password-controller.ts @@ -21,7 +21,6 @@ interface SessionRequest user?; } -const UNLEASH = 'Unleash'; class ResetPasswordController extends Controller { private userService: UserService; @@ -43,7 +42,7 @@ class ResetPasswordController extends Controller { const { email } = req.body; try { - await this.userService.createResetPasswordEmail(email, UNLEASH); + await this.userService.createResetPasswordEmail(email); res.status(200).end(); } catch (e) { handleErrors(res, this.logger, e); diff --git a/src/lib/services/user-service.test.ts b/src/lib/services/user-service.test.ts index 20e2a9d7ba..37a483ab6c 100644 --- a/src/lib/services/user-service.test.ts +++ b/src/lib/services/user-service.test.ts @@ -1,6 +1,7 @@ import test from 'ava'; import UserService from './user-service'; import UserStoreMock from '../../test/fixtures/fake-user-store'; +import EventStoreMock from '../../test/fixtures/fake-event-store'; import AccessServiceMock from '../../test/fixtures/access-service-mock'; import { ResetTokenStoreMock } from '../../test/fixtures/fake-reset-token-store'; import ResetTokenService from './reset-token-service'; @@ -10,11 +11,15 @@ import { IUnleashConfig } from '../types/option'; import { createTestConfig } from '../../test/config/test-config'; import SessionService from './session-service'; import FakeSessionStore from '../../test/fixtures/fake-session-store'; +import User from '../types/user'; const config: IUnleashConfig = createTestConfig(); +const systemUser = new User({ id: -1, username: 'system' }); + test('Should create new user', async t => { const userStore = new UserStoreMock(); + const eventStore = new EventStoreMock(); const accessService = new AccessServiceMock(); const resetTokenStore = new ResetTokenStoreMock(); const resetTokenService = new ResetTokenService( @@ -25,16 +30,19 @@ test('Should create new user', async t => { const sessionService = new SessionService({ sessionStore }, config); const emailService = new EmailService(config.email, config.getLogger); - const service = new UserService({ userStore }, config, { + const service = new UserService({ userStore, eventStore }, config, { accessService, resetTokenService, emailService, sessionService, }); - const user = await service.createUser({ - username: 'test', - rootRole: 1, - }); + const user = await service.createUser( + { + username: 'test', + rootRole: 1, + }, + systemUser, + ); const storedUser = await userStore.get(user); const allUsers = await userStore.getAll(); @@ -46,6 +54,7 @@ test('Should create new user', async t => { test('Should create default user', async t => { const userStore = new UserStoreMock(); + const eventStore = new EventStoreMock(); const accessService = new AccessServiceMock(); const resetTokenStore = new ResetTokenStoreMock(); const resetTokenService = new ResetTokenService( @@ -56,7 +65,7 @@ test('Should create default user', async t => { const sessionStore = new FakeSessionStore(); const sessionService = new SessionService({ sessionStore }, config); - const service = new UserService({ userStore }, config, { + const service = new UserService({ userStore, eventStore }, config, { accessService, resetTokenService, emailService, @@ -71,6 +80,7 @@ test('Should create default user', async t => { test('Should be a valid password', async t => { const userStore = new UserStoreMock(); + const eventStore = new EventStoreMock(); const accessService = new AccessServiceMock(); const resetTokenStore = new ResetTokenStoreMock(); const resetTokenService = new ResetTokenService( @@ -82,7 +92,7 @@ test('Should be a valid password', async t => { const sessionStore = new FakeSessionStore(); const sessionService = new SessionService({ sessionStore }, config); - const service = new UserService({ userStore }, config, { + const service = new UserService({ userStore, eventStore }, config, { accessService, resetTokenService, emailService, @@ -96,6 +106,7 @@ test('Should be a valid password', async t => { test('Password must be at least 10 chars', async t => { const userStore = new UserStoreMock(); + const eventStore = new EventStoreMock(); const accessService = new AccessServiceMock(); const resetTokenStore = new ResetTokenStoreMock(); const resetTokenService = new ResetTokenService( @@ -106,7 +117,7 @@ test('Password must be at least 10 chars', async t => { const sessionStore = new FakeSessionStore(); const sessionService = new SessionService({ sessionStore }, config); - const service = new UserService({ userStore }, config, { + const service = new UserService({ userStore, eventStore }, config, { accessService, resetTokenService, emailService, @@ -121,6 +132,7 @@ test('Password must be at least 10 chars', async t => { test('The password must contain at least one uppercase letter.', async t => { const userStore = new UserStoreMock(); + const eventStore = new EventStoreMock(); const accessService = new AccessServiceMock(); const resetTokenStore = new ResetTokenStoreMock(); const resetTokenService = new ResetTokenService( @@ -131,7 +143,7 @@ test('The password must contain at least one uppercase letter.', async t => { const sessionStore = new FakeSessionStore(); const sessionService = new SessionService({ sessionStore }, config); - const service = new UserService({ userStore }, config, { + const service = new UserService({ userStore, eventStore }, config, { accessService, resetTokenService, emailService, @@ -146,6 +158,7 @@ test('The password must contain at least one uppercase letter.', async t => { test('The password must contain at least one number', async t => { const userStore = new UserStoreMock(); + const eventStore = new EventStoreMock(); const accessService = new AccessServiceMock(); const resetTokenStore = new ResetTokenStoreMock(); const resetTokenService = new ResetTokenService( @@ -157,7 +170,7 @@ test('The password must contain at least one number', async t => { const sessionStore = new FakeSessionStore(); const sessionService = new SessionService({ sessionStore }, config); - const service = new UserService({ userStore }, config, { + const service = new UserService({ userStore, eventStore }, config, { accessService, resetTokenService, emailService, @@ -172,6 +185,7 @@ test('The password must contain at least one number', async t => { test('The password must contain at least one special character', async t => { const userStore = new UserStoreMock(); + const eventStore = new EventStoreMock(); const accessService = new AccessServiceMock(); const resetTokenStore = new ResetTokenStoreMock(); const resetTokenService = new ResetTokenService( @@ -182,7 +196,7 @@ test('The password must contain at least one special character', async t => { const sessionStore = new FakeSessionStore(); const sessionService = new SessionService({ sessionStore }, config); - const service = new UserService({ userStore }, config, { + const service = new UserService({ userStore, eventStore }, config, { accessService, resetTokenService, emailService, @@ -197,6 +211,7 @@ test('The password must contain at least one special character', async t => { test('Should be a valid password with special chars', async t => { const userStore = new UserStoreMock(); + const eventStore = new EventStoreMock(); const accessService = new AccessServiceMock(); const resetTokenStore = new ResetTokenStoreMock(); const resetTokenService = new ResetTokenService( @@ -207,7 +222,7 @@ test('Should be a valid password with special chars', async t => { const sessionStore = new FakeSessionStore(); const sessionService = new SessionService({ sessionStore }, config); - const service = new UserService({ userStore }, config, { + const service = new UserService({ userStore, eventStore }, config, { accessService, resetTokenService, emailService, diff --git a/src/lib/services/user-service.ts b/src/lib/services/user-service.ts index 6a1143f27a..f31e9bf07e 100644 --- a/src/lib/services/user-service.ts +++ b/src/lib/services/user-service.ts @@ -19,6 +19,10 @@ import SessionService from './session-service'; import { IUnleashServices } from '../types/services'; import { IUnleashStores } from '../types/stores'; import PasswordUndefinedError from '../error/password-undefined'; +import EventStore from '../db/event-store'; +import { USER_UPDATED, USER_CREATED, USER_DELETED } from '../event-type'; + +const systemUser = new User({ id: -1, username: 'system' }); export interface ICreateUser { name?: string; @@ -56,6 +60,8 @@ class UserService { private store: UserStore; + private eventStore: EventStore; + private accessService: AccessService; private resetTokenService: ResetTokenService; @@ -65,7 +71,7 @@ class UserService { private emailService: EmailService; constructor( - stores: Pick, + stores: Pick, { getLogger, authentication, @@ -85,6 +91,7 @@ class UserService { ) { this.logger = getLogger('service/user-service.js'); this.store = stores.userStore; + this.eventStore = stores.eventStore; this.accessService = accessService; this.resetTokenService = resetTokenService; this.emailService = emailService; @@ -164,13 +171,10 @@ class UserService { return this.store.get({ email }); } - async createUser({ - username, - email, - name, - password, - rootRole, - }: ICreateUser): Promise { + async createUser( + { username, email, name, password, rootRole }: ICreateUser, + updatedBy?: User, + ): Promise { assert.ok(username || email, 'You must specify username or email'); if (email) { @@ -195,15 +199,32 @@ class UserService { await this.store.setPasswordHash(user.id, passwordHash); } + await this.updateChangeLog(USER_CREATED, user, updatedBy); + return user; } - async updateUser({ - id, - name, - email, - rootRole, - }: IUpdateUser): Promise { + private async updateChangeLog( + type: string, + user: User, + updatedBy: User = systemUser, + ): Promise { + await this.eventStore.store({ + type, + createdBy: updatedBy.username || updatedBy.email, + data: { + id: user.id, + name: user.name, + username: user.username, + email: user.email, + }, + }); + } + + async updateUser( + { id, name, email, rootRole }: IUpdateUser, + updatedBy?: User, + ): Promise { if (email) { Joi.assert(email, Joi.string().email(), 'Email'); } @@ -212,7 +233,11 @@ class UserService { await this.accessService.setUserRootRole(id, rootRole); } - return this.store.update(id, { name, email }); + const user = await this.store.update(id, { name, email }); + + await this.updateChangeLog(USER_UPDATED, user, updatedBy); + + return user; } async loginUser(usernameOrEmail: string, password: string): Promise { @@ -269,7 +294,8 @@ class UserService { return this.store.setPasswordHash(userId, passwordHash); } - async deleteUser(userId: number): Promise { + async deleteUser(userId: number, updatedBy?: User): Promise { + const user = await this.store.get({ id: userId }); const roles = await this.accessService.getRolesForUser(userId); await Promise.all( roles.map(role => @@ -278,6 +304,8 @@ class UserService { ); await this.store.delete(userId); + + await this.updateChangeLog(USER_DELETED, user, updatedBy); } async getUserForToken(token: string): Promise { @@ -322,7 +350,7 @@ class UserService { async createResetPasswordEmail( receiverEmail: string, - requester: string, + user: User = systemUser, ): Promise { const receiver = await this.getByEmail(receiverEmail); if (!receiver) { @@ -330,7 +358,7 @@ class UserService { } const resetLink = await this.resetTokenService.createResetPasswordUrl( receiver.id, - requester, + user.username || user.email, ); await this.emailService.sendResetMail( diff --git a/src/test/e2e/api/admin/user-admin.e2e.test.ts b/src/test/e2e/api/admin/user-admin.e2e.test.ts index c24f682ff8..5d5830bf76 100644 --- a/src/test/e2e/api/admin/user-admin.e2e.test.ts +++ b/src/test/e2e/api/admin/user-admin.e2e.test.ts @@ -6,11 +6,14 @@ import User from '../../../../lib/types/user'; import UserStore from '../../../../lib/db/user-store'; import { AccessStore, IRole } from '../../../../lib/db/access-store'; import { RoleName } from '../../../../lib/services/access-service'; +import EventStore from '../../../../lib/db/event-store'; +import eventType from '../../../../lib/event-type'; let stores; let db; let userStore: UserStore; +let eventStore: EventStore; let accessStore: AccessStore; let editorRole: IRole; let adminRole: IRole; @@ -20,6 +23,7 @@ test.before(async () => { stores = db.stores; userStore = stores.userStore; accessStore = stores.accessStore; + eventStore = stores.eventStore; const roles = await accessStore.getRootRoles(); editorRole = roles.find(r => r.name === RoleName.EDITOR); adminRole = roles.find(r => r.name === RoleName.ADMIN); @@ -242,3 +246,66 @@ test.serial( }); }, ); + +test.serial('generates USER_CREATED event', async t => { + t.plan(5); + const email = 'some@getunelash.ai'; + const name = 'Some Name'; + const request = await setupApp(stores); + const { body } = await request + .post('/api/admin/user-admin') + .send({ + email, + name, + password: 'some-strange-pass-123-GH', + rootRole: adminRole.id, + }) + .set('Content-Type', 'application/json') + .expect(201); + + const events = await eventStore.getEvents(); + + t.is(events[0].type, eventType.USER_CREATED); + t.is(events[0].data.email, email); + t.is(events[0].data.name, name); + t.is(events[0].data.id, body.id); + t.falsy(events[0].data.password); +}); + +test.serial('generates USER_DELETED event', async t => { + t.plan(3); + const request = await setupApp(stores); + + const user = await userStore.insert({ email: 'some@mail.com' }); + await request.delete(`/api/admin/user-admin/${user.id}`); + + const events = await eventStore.getEvents(); + t.is(events[0].type, eventType.USER_DELETED); + t.is(events[0].data.id, user.id); + t.is(events[0].data.email, user.email); +}); + +test.serial('generates USER_UPDATED event', async t => { + t.plan(3); + const request = await setupApp(stores); + const { body } = await request + .post('/api/admin/user-admin') + .send({ + email: 'some@getunelash.ai', + name: 'Some Name', + rootRole: editorRole.id, + }) + .set('Content-Type', 'application/json'); + + await request + .put(`/api/admin/user-admin/${body.id}`) + .send({ + name: 'New name', + }) + .set('Content-Type', 'application/json'); + + const events = await eventStore.getEvents(); + t.is(events[0].type, eventType.USER_UPDATED); + t.is(events[0].data.id, body.id); + t.is(events[0].data.name, 'New name'); +}); diff --git a/src/test/fixtures/fake-event-store.js b/src/test/fixtures/fake-event-store.ts similarity index 50% rename from src/test/fixtures/fake-event-store.js rename to src/test/fixtures/fake-event-store.ts index b547acd51d..2535ae300a 100644 --- a/src/test/fixtures/fake-event-store.js +++ b/src/test/fixtures/fake-event-store.ts @@ -1,21 +1,22 @@ -'use strict'; +import EventStore, { IEvent } from '../../lib/db/event-store'; +import noLoggerProvider from './no-logger'; -const { EventEmitter } = require('events'); +class FakeEventStore extends EventStore { + events: IEvent[]; -class EventStore extends EventEmitter { constructor() { - super(); + super(undefined, noLoggerProvider); this.setMaxListeners(0); this.events = []; } - store(event) { + store(event: IEvent): Promise { this.events.push(event); this.emit(event.type, event); return Promise.resolve(); } - batchStore(events) { + batchStore(events: IEvent[]): Promise { events.forEach(event => { this.events.push(event); this.emit(event.type, event); @@ -23,9 +24,10 @@ class EventStore extends EventEmitter { return Promise.resolve(); } - getEvents() { + getEvents(): Promise { return Promise.resolve(this.events); } } -module.exports = EventStore; +module.exports = FakeEventStore; +export default FakeEventStore;