1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-07-26 13:48:33 +02:00

feat: add user create/update/delete events (#807)

This commit is contained in:
Ivar Conradi Østhus 2021-04-27 20:47:11 +02:00 committed by GitHub
parent d0b17af770
commit 886e0bb008
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 205 additions and 68 deletions

View File

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

View File

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

View File

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

View File

@ -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<void> {
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<void> {
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<void> {
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);

View File

@ -21,7 +21,6 @@ interface SessionRequest<PARAMS, QUERY, BODY, K>
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);

View File

@ -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,

View File

@ -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<IUnleashStores, 'userStore'>,
stores: Pick<IUnleashStores, 'userStore' | 'eventStore'>,
{
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<User> {
async createUser(
{ username, email, name, password, rootRole }: ICreateUser,
updatedBy?: User,
): Promise<User> {
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<User> {
private async updateChangeLog(
type: string,
user: User,
updatedBy: User = systemUser,
): Promise<void> {
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<User> {
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<User> {
@ -269,7 +294,8 @@ class UserService {
return this.store.setPasswordHash(userId, passwordHash);
}
async deleteUser(userId: number): Promise<void> {
async deleteUser(userId: number, updatedBy?: User): Promise<void> {
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<ITokenUser> {
@ -322,7 +350,7 @@ class UserService {
async createResetPasswordEmail(
receiverEmail: string,
requester: string,
user: User = systemUser,
): Promise<URL> {
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(

View File

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

View File

@ -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<void> {
this.events.push(event);
this.emit(event.type, event);
return Promise.resolve();
}
batchStore(events) {
batchStore(events: IEvent[]): Promise<void> {
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<IEvent[]> {
return Promise.resolve(this.events);
}
}
module.exports = EventStore;
module.exports = FakeEventStore;
export default FakeEventStore;