From 2d83f297a1babeb234ac238ee668776e2bd466d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gast=C3=B3n=20Fournier?= Date: Tue, 8 Jul 2025 12:17:16 +0200 Subject: [PATCH] fix: make user creation transactional (#10327) ## About the changes When inserting a user with an invalid role id, the user creation will succeed but there will be no record in the audit log. The API call returns a 400 misleading you to believe the user was not created, but it actually was. This makes the whole user creation transactional, so if something fails, data will be in the right state. ## Testing The e2e test was split in 2 scenarios, one with smtp and another one without. This test was added, and it was failing before adding the transaction, because when fetching the users, the user was there, despite having returned a 400 error in the API call: https://github.com/Unleash/unleash/blob/80a2e65b6f1a85ac731d4116a4df3c1451b8a4d1/src/test/e2e/api/admin/user-admin.e2e.test.ts#L181-L204 --- src/lib/features/users/createUserService.ts | 55 + src/lib/routes/admin-api/user-admin.ts | 63 +- src/lib/services/index.ts | 28 +- src/lib/services/user-service.ts | 4 +- src/test/e2e/api/admin/user-admin.e2e.test.ts | 958 ++++++++++-------- 5 files changed, 622 insertions(+), 486 deletions(-) create mode 100644 src/lib/features/users/createUserService.ts diff --git a/src/lib/features/users/createUserService.ts b/src/lib/features/users/createUserService.ts new file mode 100644 index 0000000000..dd2bf3b9d1 --- /dev/null +++ b/src/lib/features/users/createUserService.ts @@ -0,0 +1,55 @@ +import { ResetTokenStore } from '../../db/reset-token-store.js'; +import SettingStore from '../../db/setting-store.js'; +import { + createAccessService, + createEventsService, + type Db, + EmailService, + type IUnleashConfig, + ResetTokenService, + SessionService, + SessionStore, + SettingService, + UserService, +} from '../../server-impl.js'; +import { UserStore } from './user-store.js'; + +export const createUserService = ( + db: Db, + config: IUnleashConfig, +): UserService => { + const userStore = new UserStore(db, config.getLogger); + const resetTokenStore = new ResetTokenStore( + db, + config.eventBus, + config.getLogger, + ); + const resetTokenService = new ResetTokenService( + { resetTokenStore }, + config, + ); + const eventService = createEventsService(db, config); + const sessionStore = new SessionStore( + db, + config.eventBus, + config.getLogger, + ); + const sessionService = new SessionService({ sessionStore }, config); + const settingStore = new SettingStore(db, config.getLogger); + const settingService = new SettingService( + { settingStore }, + config, + eventService, + ); + const accessService = createAccessService(db, config); + const emailService = new EmailService(config); + + return new UserService({ userStore }, config, { + accessService, + resetTokenService, + emailService, + eventService, + sessionService, + settingService, + }); +}; diff --git a/src/lib/routes/admin-api/user-admin.ts b/src/lib/routes/admin-api/user-admin.ts index 5000cb18d3..7a2963aade 100644 --- a/src/lib/routes/admin-api/user-admin.ts +++ b/src/lib/routes/admin-api/user-admin.ts @@ -63,11 +63,12 @@ import { type UserAccessOverviewSchema, userAccessOverviewSchema, } from '../../openapi/index.js'; +import type { WithTransactional } from '../../server-impl.js'; export default class UserAdminController extends Controller { private flagResolver: IFlagResolver; - private userService: UserService; + private userService: WithTransactional; private accountService: AccountService; @@ -600,35 +601,43 @@ export default class UserAdminController extends Controller { ? Number(rootRole) : (rootRole as RoleName); - const createdUser = await this.userService.createUser( - { - username, - email, - name, - password, - rootRole: normalizedRootRole, + const responseData = await this.userService.transactional( + async (txUserService) => { + const createdUser = await txUserService.createUser( + { + username, + email, + name, + password, + rootRole: normalizedRootRole, + }, + req.audit, + ); + + const inviteLink = await txUserService.newUserInviteLink( + createdUser, + req.audit, + ); + + // send email defaults to true + const emailSent = (sendEmail !== undefined ? sendEmail : true) + ? await txUserService.sendWelcomeEmail( + createdUser, + inviteLink, + ) + : false; + + const { isAPI, ...user } = createdUser; + const responseData: CreateUserResponseSchema = { + ...serializeDates(user), + inviteLink, + emailSent, + rootRole: normalizedRootRole, + }; + return responseData; }, - req.audit, ); - const inviteLink = await this.userService.newUserInviteLink( - createdUser, - req.audit, - ); - - // send email defaults to true - const emailSent = (sendEmail !== undefined ? sendEmail : true) - ? await this.userService.sendWelcomeEmail(createdUser, inviteLink) - : false; - - const { isAPI, ...user } = createdUser; - const responseData: CreateUserResponseSchema = { - ...serializeDates(user), - inviteLink, - emailSent, - rootRole: normalizedRootRole, - }; - this.openApiService.respondWithValidation( 201, res, diff --git a/src/lib/services/index.ts b/src/lib/services/index.ts index 0f80b77f38..1871512156 100644 --- a/src/lib/services/index.ts +++ b/src/lib/services/index.ts @@ -91,8 +91,6 @@ import { createDependentFeaturesService, createFakeDependentFeaturesService, } from '../features/dependent-features/createDependentFeaturesService.js'; -import { DependentFeaturesReadModel } from '../features/dependent-features/dependent-features-read-model.js'; -import { FakeDependentFeaturesReadModel } from '../features/dependent-features/fake-dependent-features-read-model.js'; import { createFakeLastSeenService, createLastSeenService, @@ -171,6 +169,7 @@ import type { import type { IPrivateProjectChecker } from '../features/private-project/privateProjectCheckerType.js'; import { UnknownFlagsService } from '../features/metrics/unknown-flags/unknown-flags-service.js'; import type FeatureLinkService from '../features/feature-links/feature-link-service.js'; +import { createUserService } from '../features/users/createUserService.js'; export const createServices = ( stores: IUnleashStores, @@ -215,9 +214,6 @@ export const createServices = ( unknownFlagsService, ); - const dependentFeaturesReadModel = db - ? new DependentFeaturesReadModel(db) - : new FakeDependentFeaturesReadModel(); const featureLifecycleReadModel = db ? new FeatureLifecycleReadModel(db) : new FakeFeatureLifecycleReadModel(); @@ -252,14 +248,18 @@ export const createServices = ( ); const sessionService = new SessionService(stores, config); const settingService = new SettingService(stores, config, eventService); - const userService = new UserService(stores, config, { - accessService, - resetTokenService, - emailService, - eventService, - sessionService, - settingService, - }); + const userService = db + ? withTransactional((db) => createUserService(db, config), db) + : withFakeTransactional( + new UserService(stores, config, { + accessService, + resetTokenService, + emailService, + eventService, + sessionService, + settingService, + }), + ); const accountService = new AccountService(stores, config, { accessService, }); @@ -607,7 +607,7 @@ export interface IUnleashServices { tagTypeService: TagTypeService; transactionalTagTypeService: WithTransactional; userFeedbackService: UserFeedbackService; - userService: UserService; + userService: WithTransactional; versionService: VersionService; userSplashService: UserSplashService; segmentService: ISegmentService; diff --git a/src/lib/services/user-service.ts b/src/lib/services/user-service.ts index 6f4baef931..f41520fa2c 100644 --- a/src/lib/services/user-service.ts +++ b/src/lib/services/user-service.ts @@ -78,7 +78,7 @@ export interface ILoginUserRequest { const saltRounds = 10; const disallowNPreviousPasswords = 5; -class UserService { +export class UserService { private logger: Logger; private store: IUserStore; @@ -404,7 +404,7 @@ class UserService { async deleteScimUsers(auditUser: IAuditUser): Promise { const users = await this.store.deleteScimUsers(); - // Note: after deletion we can't get the role for the user + // Note: after deletion we can't get the role for the user. This is a simplification const viewerRole = await this.accessService.getPredefinedRole( RoleName.VIEWER, ); 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 fb0e5919e4..2327c4323c 100644 --- a/src/test/e2e/api/admin/user-admin.e2e.test.ts +++ b/src/test/e2e/api/admin/user-admin.e2e.test.ts @@ -19,7 +19,6 @@ import { omitKeys } from '../../../../lib/util/omit-keys.js'; import type { ISessionStore } from '../../../../lib/types/stores/session-store.js'; import type { IUnleashStores } from '../../../../lib/types/index.js'; import { createHash } from 'crypto'; -import { vi } from 'vitest'; let stores: IUnleashStores; let db: ITestDb; @@ -32,463 +31,536 @@ let sessionStore: ISessionStore; let editorRole: IRole; let adminRole: IRole; -beforeAll(async () => { - db = await dbInit('user_admin_api_serial', getLogger); - stores = db.stores; - app = await setupAppWithCustomConfig(stores, { - experimental: { - flags: { - strictSchemaValidation: true, - showUserDeviceCount: true, +describe('User Admin API with email configuration', () => { + beforeAll(async () => { + db = await dbInit('user_admin_api_serial', getLogger); + app = await setupAppWithCustomConfig( + db.stores, + { + email: { + host: 'smtp.ethereal.email', + smtpuser: 'rafaela.pouros@ethereal.email', + smtppass: 'CuVPBSvUFBPuqXMFEe', + }, + experimental: { + flags: { + strictSchemaValidation: true, + showUserDeviceCount: true, + }, + }, }, - }, + db.rawDatabase, + ); + const roles = await db.stores.roleStore.getRootRoles(); + editorRole = roles.find((r) => r.name === RoleName.EDITOR)!!; }); - userStore = stores.userStore; - eventStore = stores.eventStore; - roleStore = stores.roleStore; - sessionStore = stores.sessionStore; - const roles = await roleStore.getRootRoles(); - editorRole = roles.find((r) => r.name === RoleName.EDITOR)!!; - adminRole = roles.find((r) => r.name === RoleName.ADMIN)!!; -}); - -afterAll(async () => { - await app.destroy(); - await db.destroy(); -}); - -afterEach(async () => { - await userStore.deleteAll(); -}); - -test('returns empty list of users', async () => { - return app.request - .get('/api/admin/user-admin') - .expect('Content-Type', /json/) - .expect(200) - .expect((res) => { - expect(res.body.users.length).toBe(0); - }); -}); - -test('creates and returns all users', async () => { - const createUserRequests = [...Array(10).keys()].map((i) => - app.request + afterAll(async () => { + await app.destroy(); + await db.destroy(); + }); + test('Creates a user but does not send email if sendEmail is set to false', async () => { + await app.request .post('/api/admin/user-admin') .send({ - email: `some${i}@getunleash.ai`, - name: `Some Name ${i}`, + email: 'some@getunelash.ai', + name: 'Some Name', + rootRole: editorRole.id, + sendEmail: false, + }) + .set('Content-Type', 'application/json') + .expect(201) + .expect((res) => { + expect(res.body.emailSent).toBeFalsy(); + }); + + await app.request + .post('/api/admin/user-admin') + .send({ + email: 'some2@getunelash.ai', + name: 'Some2 Name', rootRole: editorRole.id, }) - .set('Content-Type', 'application/json'), - ); - - await Promise.all(createUserRequests); - - return app.request - .get('/api/admin/user-admin') - .expect('Content-Type', /json/) - .expect(200) - .expect((res) => { - expect(res.body.users.length).toBe(10); - expect(res.body.users[2].rootRole).toBe(editorRole.id); - }); -}); - -test('creates editor-user without password', async () => { - return app.request - .post('/api/admin/user-admin') - .send({ - email: 'some@getunelash.ai', - name: 'Some Name', - rootRole: editorRole.id, - }) - .set('Content-Type', 'application/json') - .expect(201) - .expect((res) => { - expect(res.body.email).toBe('some@getunelash.ai'); - expect(res.body.rootRole).toBe(editorRole.id); - expect(res.body.id).toBeTruthy(); - }); -}); - -test('creates admin-user with password', async () => { - const { body } = await app.request - .post('/api/admin/user-admin') - .send({ - email: 'some@getunelash.ai', - name: 'Some Name', - password: 'some-strange-pass-123-GH', - rootRole: adminRole.id, - }) - .set('Content-Type', 'application/json') - .expect(201); - - expect(body.rootRole).toBe(adminRole.id); - - const user = await userStore.getByQuery({ id: body.id }); - expect(user.email).toBe('some@getunelash.ai'); - expect(user.name).toBe('Some Name'); - - const passwordHash = userStore.getPasswordHash(body.id); - expect(passwordHash).toBeTruthy(); - - const roles = await stores.accessStore.getRolesForUserId(body.id); - expect(roles.length).toBe(1); - expect(roles[0].name).toBe(RoleName.ADMIN); -}); - -test('requires known root role', async () => { - return app.request - .post('/api/admin/user-admin') - .send({ - email: 'some@getunelash.ai', - name: 'Some Name', - rootRole: 'Unknown', - }) - .set('Content-Type', 'application/json') - .expect(400); -}); - -test('should require username or email on create', async () => { - await app.request - .post('/api/admin/user-admin') - .send({ rootRole: adminRole.id }) - .set('Content-Type', 'application/json') - .expect(400) - .expect((res) => { - expect(res.body.details[0].message).toEqual( - 'You must specify username or email', - ); - }); -}); - -test('update user name', async () => { - const { body } = await app.request - .post('/api/admin/user-admin') - .send({ - email: 'some@getunelash.ai', - name: 'Some Name', - rootRole: editorRole.id, - }) - .set('Content-Type', 'application/json'); - - return app.request - .put(`/api/admin/user-admin/${body.id}`) - .send({ - name: 'New name', - }) - .set('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body.email).toBe('some@getunelash.ai'); - expect(res.body.name).toBe('New name'); - expect(res.body.id).toBe(body.id); - }); -}); - -test('should not require any fields on update', async () => { - const { body: created } = await app.request - .post('/api/admin/user-admin') - .send({ email: `${randomId()}@example.com`, rootRole: editorRole.id }) - .set('Content-Type', 'application/json') - .expect(201); - - const { body: updated } = await app.request - .put(`/api/admin/user-admin/${created.id}`) - .send({}) - .set('Content-Type', 'application/json') - .expect(200); - - expect(updated).toEqual( - omitKeys(created, 'emailSent', 'inviteLink', 'rootRole'), - ); -}); - -test('get a single user', async () => { - const { body } = await app.request - .post('/api/admin/user-admin') - .send({ - email: 'some2@getunelash.ai', - name: 'Some Name 2', - rootRole: editorRole.id, - }) - .set('Content-Type', 'application/json'); - - const { body: user } = await app.request - .get(`/api/admin/user-admin/${body.id}`) - .expect(200); - - expect(user.email).toBe('some2@getunelash.ai'); - expect(user.name).toBe('Some Name 2'); - expect(user.id).toBe(body.id); -}); - -test('should delete user', async () => { - const user = await userStore.insert({ email: 'some@mail.com' }); - - await app.request.delete(`/api/admin/user-admin/${user.id}`).expect(200); - await app.request.get(`/api/admin/user-admin/${user.id}`).expect(404); -}); - -test('validator should require strong password', async () => { - return app.request - .post('/api/admin/user-admin/validate-password') - .send({ password: 'simple' }) - .expect(400); -}); - -test('validator should accept strong password', async () => { - return app.request - .post('/api/admin/user-admin/validate-password') - .send({ password: 'simple123-_ASsad' }) - .expect(200); -}); - -test('should change password', async () => { - const user = await userStore.insert({ email: 'some@mail.com' }); - const spy = vi.spyOn(sessionStore, 'deleteSessionsForUser'); - await app.request - .post(`/api/admin/user-admin/${user.id}/change-password`) - .send({ password: 'simple123-_ASsad' }) - .expect(200); - expect(spy).toHaveBeenCalled(); -}); - -test('should search for users', async () => { - await userStore.insert({ email: 'some@mail.com' }); - await userStore.insert({ email: 'another@mail.com' }); - await userStore.insert({ email: 'another2@mail.com' }); - - return app.request - .get('/api/admin/user-admin/search?q=another') - .expect(200) - .expect((res) => { - expect(res.body.length).toBe(2); - expect(res.body.some((u) => u.email === 'another@mail.com')).toBe( - true, - ); - }); -}); - -test('Creates a user and includes inviteLink and emailConfigured', async () => { - return app.request - .post('/api/admin/user-admin') - .send({ - email: 'some@getunelash.ai', - name: 'Some Name', - rootRole: editorRole.id, - }) - .set('Content-Type', 'application/json') - .expect(201) - .expect((res) => { - expect(res.body.email).toBe('some@getunelash.ai'); - expect(res.body.rootRole).toBe(editorRole.id); - expect(res.body.inviteLink).toBeTruthy(); - expect(res.body.emailSent).toBeFalsy(); - expect(res.body.id).toBeTruthy(); - }); -}); - -test('Creates a user but does not send email if sendEmail is set to false', async () => { - const myAppConfig = await setupAppWithCustomConfig(stores, { - email: { - host: 'smtp.ethereal.email', - smtpuser: 'rafaela.pouros@ethereal.email', - smtppass: 'CuVPBSvUFBPuqXMFEe', - }, - }); - - await myAppConfig.request - .post('/api/admin/user-admin') - .send({ - email: 'some@getunelash.ai', - name: 'Some Name', - rootRole: editorRole.id, - sendEmail: false, - }) - .set('Content-Type', 'application/json') - .expect(201) - .expect((res) => { - expect(res.body.emailSent).toBeFalsy(); - }); - await myAppConfig.request - .post('/api/admin/user-admin') - .send({ - email: 'some2@getunelash.ai', - name: 'Some2 Name', - rootRole: editorRole.id, - }) - .set('Content-Type', 'application/json') - .expect(201) - .expect((res) => { - expect(res.body.emailSent).toBeTruthy(); - }); - - await myAppConfig.destroy(); -}); - -test('generates USER_CREATED event', async () => { - const email = 'some@getunelash.ai'; - const name = 'Some Name'; - - const { body } = await app.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(); - - expect(events[0].type).toBe(USER_CREATED); - expect(events[0].data.email).toBe(email); - expect(events[0].data.name).toBe(name); - expect(events[0].data.id).toBe(body.id); - expect(events[0].data.password).toBeFalsy(); -}); - -test('generates USER_DELETED event', async () => { - const user = await userStore.insert({ email: 'some@mail.com' }); - await app.request.delete(`/api/admin/user-admin/${user.id}`).expect(200); - - const events = await eventStore.getEvents(); - expect(events[0].type).toBe(USER_DELETED); - expect(events[0].preData.id).toBe(user.id); - expect(events[0].preData.email).toBe(user.email); -}); - -test('generates USER_UPDATED event', async () => { - const { body } = await app.request - .post('/api/admin/user-admin') - .send({ - email: 'some@getunelash.ai', - name: 'Some Name', - rootRole: editorRole.id, - }) - .set('Content-Type', 'application/json'); - - await app.request - .put(`/api/admin/user-admin/${body.id}`) - .send({ - name: 'New name', - }) - .set('Content-Type', 'application/json'); - - const events = await eventStore.getEvents(); - expect(events[0].type).toBe(USER_UPDATED); - expect(events[0].data.id).toBe(body.id); - expect(events[0].data.name).toBe('New name'); -}); - -test('Anonymises name, username and email fields if anonymiseEventLog flag is set', async () => { - const anonymisedApp = await setupAppWithCustomConfig( - stores, - { experimental: { flags: { anonymiseEventLog: true } } }, - db.rawDatabase, - ); - await anonymisedApp.request - .post('/api/admin/user-admin') - .send({ - email: 'some@getunleash.ai', - name: 'Some Name', - rootRole: editorRole.id, - }) - .set('Content-Type', 'application/json'); - const response = await anonymisedApp.request.get( - '/api/admin/user-admin/access', - ); - const body = response.body; - expect(body.users[0].email).toEqual('aeb83743e@unleash.run'); - expect(body.users[0].name).toEqual('3a8b17647@unleash.run'); - expect(body.users[0].username).toEqual(''); // Not set, so anonymise should return the empty string. -}); - -test('creates user with email sha256 hash', async () => { - await app.request - .post('/api/admin/user-admin') - .send({ - email: `hasher@getunleash.ai`, - name: `Some Name Hash`, - rootRole: editorRole.id, - }) - .set('Content-Type', 'application/json'); - - const user = await db - .rawDatabase('users') - .where({ email: 'hasher@getunleash.ai' }) - .first(['email_hash']); - - const expectedHash = createHash('sha256') - .update('hasher@getunleash.ai') - .digest('hex'); - - expect(user.email_hash).toBe(expectedHash); -}); - -test('should return number of sessions per user', async () => { - const user = await userStore.insert({ email: 'tester@example.com' }); - await sessionStore.insertSession({ - sid: '1', - sess: { user: { id: user.id } }, - }); - await sessionStore.insertSession({ - sid: '2', - sess: { user: { id: user.id } }, - }); - - const user2 = await userStore.insert({ email: 'tester2@example.com' }); - await sessionStore.insertSession({ - sid: '3', - sess: { user: { id: user2.id } }, - }); - - const response = await app.request.get(`/api/admin/user-admin`).expect(200); - - expect(response.body).toMatchObject({ - users: expect.arrayContaining([ - expect.objectContaining({ - email: 'tester@example.com', - activeSessions: 2, - }), - expect.objectContaining({ - email: 'tester2@example.com', - activeSessions: 1, - }), - ]), + .set('Content-Type', 'application/json') + .expect(201) + .expect((res) => { + expect(res.body.emailSent).toBeTruthy(); + }); }); }); -test('should only delete scim users', async () => { - userStore.insert({ - email: 'boring@example.com', +describe('User Admin API without email', () => { + beforeAll(async () => { + db = await dbInit('user_admin_api_serial', getLogger); + stores = db.stores; + app = await setupAppWithCustomConfig( + stores, + { + experimental: { + flags: { + strictSchemaValidation: true, + showUserDeviceCount: true, + }, + }, + rateLimiting: { + createUserMaxPerMinute: 100, + }, + }, + db.rawDatabase, + ); + + userStore = stores.userStore; + eventStore = stores.eventStore; + roleStore = stores.roleStore; + sessionStore = stores.sessionStore; + const roles = await roleStore.getRootRoles(); + editorRole = roles.find((r) => r.name === RoleName.EDITOR)!!; + adminRole = roles.find((r) => r.name === RoleName.ADMIN)!!; }); - await userStore.insert({ - email: 'really-boring@example.com', + afterAll(async () => { + await app.destroy(); + await db.destroy(); }); - const scimUser = ( - await db - .rawDatabase('users') - .insert({ - email: 'made-by-scim@example.com', - scim_id: 'some-random-scim-id', + afterEach(async () => { + await userStore.deleteAll(); + }); + + test('returns empty list of users', async () => { + return app.request + .get('/api/admin/user-admin') + .expect('Content-Type', /json/) + .expect(200) + .expect((res) => { + expect(res.body.users.length).toBe(0); + }); + }); + + test('creates and returns all users', async () => { + const createUserRequests = [...Array(10).keys()].map((i) => + app.request + .post('/api/admin/user-admin') + .send({ + email: `some${i}@getunleash.ai`, + name: `Some Name ${i}`, + rootRole: editorRole.id, + }) + .set('Content-Type', 'application/json'), + ); + + await Promise.all(createUserRequests); + + return app.request + .get('/api/admin/user-admin') + .expect('Content-Type', /json/) + .expect(200) + .expect((res) => { + expect(res.body.users.length).toBe(10); + expect(res.body.users[2].rootRole).toBe(editorRole.id); + }); + }); + + test('creates editor-user without password', async () => { + return app.request + .post('/api/admin/user-admin') + .send({ + email: 'some@getunelash.ai', + name: 'Some Name', + rootRole: editorRole.id, }) - .returning('id') - )[0].id; + .set('Content-Type', 'application/json') + .expect(201) + .expect((res) => { + expect(res.body.email).toBe('some@getunelash.ai'); + expect(res.body.rootRole).toBe(editorRole.id); + expect(res.body.id).toBeTruthy(); + }); + }); - await app.request.delete('/api/admin/user-admin/scim-users').expect(200); - const response = await app.request.get(`/api/admin/user-admin`).expect(200); - const users = response.body.users; + test('When invalid role is provided the user is not created', async () => { + const invalidRoleId = 0; + await app.request + .post('/api/admin/user-admin') + .send({ + email: 'should-not-exist@getunleash.ai', + name: 'I am the invisible man', + rootRole: invalidRoleId, + }) + .set('Content-Type', 'application/json') + .expect(400); - expect(users.length).toBe(2); - expect(users.every((u) => u.email !== 'made-by-scim@example.com')).toBe( - true, - ); + return app.request + .get('/api/admin/user-admin') + .expect('Content-Type', /json/) + .expect(200) + .expect((res) => { + expect(res.body.users).not.toContainEqual( + expect.objectContaining({ + email: 'should-not-exist@getunleash.ai', + }), + ); + }); + }); + + test('creates admin-user with password', async () => { + const { body } = await app.request + .post('/api/admin/user-admin') + .send({ + email: 'some@getunelash.ai', + name: 'Some Name', + password: 'some-strange-pass-123-GH', + rootRole: adminRole.id, + }) + .set('Content-Type', 'application/json') + .expect(201); + + expect(body.rootRole).toBe(adminRole.id); + + const user = await userStore.getByQuery({ id: body.id }); + expect(user.email).toBe('some@getunelash.ai'); + expect(user.name).toBe('Some Name'); + + const passwordHash = userStore.getPasswordHash(body.id); + expect(passwordHash).toBeTruthy(); + + const roles = await stores.accessStore.getRolesForUserId(body.id); + expect(roles.length).toBe(1); + expect(roles[0].name).toBe(RoleName.ADMIN); + }); + + test('requires known root role', async () => { + return app.request + .post('/api/admin/user-admin') + .send({ + email: 'some@getunelash.ai', + name: 'Some Name', + rootRole: 'Unknown', + }) + .set('Content-Type', 'application/json') + .expect(400); + }); + + test('should require username or email on create', async () => { + await app.request + .post('/api/admin/user-admin') + .send({ rootRole: adminRole.id }) + .set('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body.details[0].message).toEqual( + 'You must specify username or email', + ); + }); + }); + + test('update user name', async () => { + const { body } = await app.request + .post('/api/admin/user-admin') + .send({ + email: 'some@getunelash.ai', + name: 'Some Name', + rootRole: editorRole.id, + }) + .set('Content-Type', 'application/json'); + + return app.request + .put(`/api/admin/user-admin/${body.id}`) + .send({ + name: 'New name', + }) + .set('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body.email).toBe('some@getunelash.ai'); + expect(res.body.name).toBe('New name'); + expect(res.body.id).toBe(body.id); + }); + }); + + test('should not require any fields on update', async () => { + const { body: created } = await app.request + .post('/api/admin/user-admin') + .send({ + email: `${randomId()}@example.com`, + rootRole: editorRole.id, + }) + .set('Content-Type', 'application/json') + .expect(201); + + const { body: updated } = await app.request + .put(`/api/admin/user-admin/${created.id}`) + .send({}) + .set('Content-Type', 'application/json') + .expect(200); + + expect(updated).toEqual( + omitKeys(created, 'emailSent', 'inviteLink', 'rootRole'), + ); + }); + + test('get a single user', async () => { + const { body } = await app.request + .post('/api/admin/user-admin') + .send({ + email: 'some2@getunelash.ai', + name: 'Some Name 2', + rootRole: editorRole.id, + }) + .set('Content-Type', 'application/json'); + + const { body: user } = await app.request + .get(`/api/admin/user-admin/${body.id}`) + .expect(200); + + expect(user.email).toBe('some2@getunelash.ai'); + expect(user.name).toBe('Some Name 2'); + expect(user.id).toBe(body.id); + }); + + test('should delete user', async () => { + const user = await userStore.insert({ email: 'some@mail.com' }); + + await app.request + .delete(`/api/admin/user-admin/${user.id}`) + .expect(200); + await app.request.get(`/api/admin/user-admin/${user.id}`).expect(404); + }); + + test('validator should require strong password', async () => { + return app.request + .post('/api/admin/user-admin/validate-password') + .send({ password: 'simple' }) + .expect(400); + }); + + test('validator should accept strong password', async () => { + return app.request + .post('/api/admin/user-admin/validate-password') + .send({ password: 'simple123-_ASsad' }) + .expect(200); + }); + + test('should change password', async () => { + const user = await userStore.insert({ email: 'some@mail.com' }); + await sessionStore.insertSession({ + sid: '1', + sess: { user: { id: user.id } }, + }); + expect(await sessionStore.getSessionsForUser(user.id)).toHaveLength(1); + + await app.request + .post(`/api/admin/user-admin/${user.id}/change-password`) + .send({ password: 'simple123-_ASsad' }) + .expect(200); + expect(await sessionStore.getSessionsForUser(user.id)).toHaveLength(0); + }); + + test('should search for users', async () => { + await userStore.insert({ email: 'some@mail.com' }); + await userStore.insert({ email: 'another@mail.com' }); + await userStore.insert({ email: 'another2@mail.com' }); + + return app.request + .get('/api/admin/user-admin/search?q=another') + .expect(200) + .expect((res) => { + expect(res.body.length).toBe(2); + expect( + res.body.some((u) => u.email === 'another@mail.com'), + ).toBe(true); + }); + }); + + test('Creates a user and includes inviteLink and emailConfigured', async () => { + return app.request + .post('/api/admin/user-admin') + .send({ + email: 'some@getunelash.ai', + name: 'Some Name', + rootRole: editorRole.id, + }) + .set('Content-Type', 'application/json') + .expect(201) + .expect((res) => { + expect(res.body.email).toBe('some@getunelash.ai'); + expect(res.body.rootRole).toBe(editorRole.id); + expect(res.body.inviteLink).toBeTruthy(); + expect(res.body.emailSent).toBeFalsy(); + expect(res.body.id).toBeTruthy(); + }); + }); + + test('generates USER_CREATED event', async () => { + const email = 'some@getunelash.ai'; + const name = 'Some Name'; + + const { body } = await app.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(); + + expect(events[0].type).toBe(USER_CREATED); + expect(events[0].data.email).toBe(email); + expect(events[0].data.name).toBe(name); + expect(events[0].data.id).toBe(body.id); + expect(events[0].data.password).toBeFalsy(); + }); + + test('generates USER_DELETED event', async () => { + const user = await userStore.insert({ email: 'some@mail.com' }); + await app.request + .delete(`/api/admin/user-admin/${user.id}`) + .expect(200); + + const events = await eventStore.getEvents(); + expect(events[0].type).toBe(USER_DELETED); + expect(events[0].preData.id).toBe(user.id); + expect(events[0].preData.email).toBe(user.email); + }); + + test('generates USER_UPDATED event', async () => { + const { body } = await app.request + .post('/api/admin/user-admin') + .send({ + email: 'some@getunelash.ai', + name: 'Some Name', + rootRole: editorRole.id, + }) + .set('Content-Type', 'application/json'); + + await app.request + .put(`/api/admin/user-admin/${body.id}`) + .send({ + name: 'New name', + }) + .set('Content-Type', 'application/json'); + + const events = await eventStore.getEvents(); + expect(events[0].type).toBe(USER_UPDATED); + expect(events[0].data.id).toBe(body.id); + expect(events[0].data.name).toBe('New name'); + }); + + test('Anonymises name, username and email fields if anonymiseEventLog flag is set', async () => { + const anonymisedApp = await setupAppWithCustomConfig( + stores, + { experimental: { flags: { anonymiseEventLog: true } } }, + db.rawDatabase, + ); + await anonymisedApp.request + .post('/api/admin/user-admin') + .send({ + email: 'some@getunleash.ai', + name: 'Some Name', + rootRole: editorRole.id, + }) + .set('Content-Type', 'application/json'); + const response = await anonymisedApp.request.get( + '/api/admin/user-admin/access', + ); + const body = response.body; + expect(body.users[0].email).toEqual('aeb83743e@unleash.run'); + expect(body.users[0].name).toEqual('3a8b17647@unleash.run'); + expect(body.users[0].username).toEqual(''); // Not set, so anonymise should return the empty string. + }); + + test('creates user with email sha256 hash', async () => { + await app.request + .post('/api/admin/user-admin') + .send({ + email: `hasher@getunleash.ai`, + name: `Some Name Hash`, + rootRole: editorRole.id, + }) + .set('Content-Type', 'application/json') + .expect(201); + + const user = await db + .rawDatabase('users') + .where({ email: 'hasher@getunleash.ai' }) + .first(['email_hash']); + + const expectedHash = createHash('sha256') + .update('hasher@getunleash.ai') + .digest('hex'); + + expect(user.email_hash).toBe(expectedHash); + }); + + test('should return number of sessions per user', async () => { + const user = await userStore.insert({ email: 'tester@example.com' }); + await sessionStore.insertSession({ + sid: '1', + sess: { user: { id: user.id } }, + }); + await sessionStore.insertSession({ + sid: '2', + sess: { user: { id: user.id } }, + }); + + const user2 = await userStore.insert({ email: 'tester2@example.com' }); + await sessionStore.insertSession({ + sid: '3', + sess: { user: { id: user2.id } }, + }); + + const response = await app.request + .get(`/api/admin/user-admin`) + .expect(200); + + expect(response.body).toMatchObject({ + users: expect.arrayContaining([ + expect.objectContaining({ + email: 'tester@example.com', + activeSessions: 2, + }), + expect.objectContaining({ + email: 'tester2@example.com', + activeSessions: 1, + }), + ]), + }); + }); + + test('should only delete scim users', async () => { + userStore.insert({ + email: 'boring@example.com', + }); + + await userStore.insert({ + email: 'really-boring@example.com', + }); + + const scimUser = ( + await db + .rawDatabase('users') + .insert({ + email: 'made-by-scim@example.com', + scim_id: 'some-random-scim-id', + }) + .returning('id') + )[0].id; + + await app.request + .delete('/api/admin/user-admin/scim-users') + .expect(200); + const response = await app.request + .get(`/api/admin/user-admin`) + .expect(200); + const users = response.body.users; + + expect(users.length).toBe(2); + expect(users.every((u) => u.email !== 'made-by-scim@example.com')).toBe( + true, + ); + }); });