🚨 Optimize UM tests (#3066)

*  Declutter test logs

* 🐛 Fix random passwords length

* 🐛 Fix password hashing in test user creation

* 🐛 Hash leftover password

*  Improve error message for `compare`

*  Restore `randomInvalidPassword` contant

*  Mock Telemetry module to prevent `--forceExit`

* 🔥 Remove unused imports

* 🔥 Remove unused import

*  Add util for configuring test SMTP

*  Isolate user creation

* 🔥 De-duplicate `createFullUser`

*  Centralize hashing

* 🔥 Remove superfluous arg

* 🔥 Remove outdated comment

*  Prioritize shared tables during trucation

* 🧪 Add login tests

*  Use token helper

* ✏️ Improve naming

*  Make `createMemberShell` consistent

* 🔥 Remove unneeded helper

* 🔥 De-duplicate `beforeEach`

* ✏️ Improve naming

* 🚚 Move `categorize` to utils

* ✏️ Update comment

* 🧪 Simplify test

* 📘 Improve `User.password` type

*  Silence logger

*  Simplify condition

*  Unhash password in payload

* 🐛 Fix comparison against unhashed password

*  Increase timeout for fake SMTP service

* 🔥 Remove unneeded import

*  Use `isNull()`

* 🧪 Use `Promise.all()` in creds tests

* 🧪 Use `Promise.all()` in me tests

* 🧪 Use `Promise.all()` in owner tests

* 🧪 Use `Promise.all()` in password tests

* 🧪 Use `Promise.all()` in users tests

*  Re-set cookie if UM disabled

* 🔥 Remove repeated line

*  Refactor out shared owner data

* 🔥 Remove unneeded import

* 🔥 Remove repeated lines

*  Organize imports

*  Reuse helper

* 🚚 Rename tests to match routers

* 🚚 Rename `createFullUser()` to `createUser()`

*  Consolidate user shell creation

*  Make hashing async

*  Add email to user shell

*  Optimize array building

* 🛠 refactor user shell factory

* 🐛 Fix MySQL tests

*  Silence logger in other DBs

Co-authored-by: Ben Hesseldieck <b.hesseldieck@gmail.com>
This commit is contained in:
Iván Ovejero 2022-04-08 18:37:07 +02:00 committed by GitHub
parent e78bf15ba9
commit 1e2d6daaa3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 831 additions and 674 deletions

View file

@ -31,8 +31,8 @@
"start:windows": "cd bin && n8n",
"test": "npm run test:sqlite",
"test:sqlite": "export N8N_LOG_LEVEL='silent'; export DB_TYPE=sqlite; jest",
"test:postgres": "export DB_TYPE=postgresdb && jest",
"test:mysql": "export DB_TYPE=mysqldb && jest",
"test:postgres": "export N8N_LOG_LEVEL='silent'; export DB_TYPE=postgresdb && jest",
"test:mysql": "export N8N_LOG_LEVEL='silent'; export DB_TYPE=mysqldb && jest",
"watch": "tsc --watch",
"typeorm": "ts-node ../../node_modules/typeorm/cli.js"
},

View file

@ -4,7 +4,7 @@
import { Workflow } from 'n8n-workflow';
import { In, IsNull, Not } from 'typeorm';
import express = require('express');
import { compare } from 'bcryptjs';
import { compare, genSaltSync, hash } from 'bcryptjs';
import { PublicUser } from './Interfaces';
import { Db, ResponseHelper } from '..';
@ -63,11 +63,6 @@ export function getInstanceBaseUrl(): string {
return n8nBaseUrl.endsWith('/') ? n8nBaseUrl.slice(0, n8nBaseUrl.length - 1) : n8nBaseUrl;
}
export async function isInstanceOwnerSetup(): Promise<boolean> {
const users = await Db.collections.User!.find({ email: Not(IsNull()) });
return users.length !== 0;
}
// TODO: Enforce at model level
export function validatePassword(password?: string): string {
if (!password) {
@ -223,9 +218,12 @@ export function isAuthenticatedRequest(request: express.Request): request is Aut
// hashing
// ----------------------------------
export async function compareHash(str: string, hash: string): Promise<boolean | undefined> {
export const hashPassword = async (validPassword: string): Promise<string> =>
hash(validPassword, genSaltSync(10));
export async function compareHash(plaintext: string, hashed: string): Promise<boolean | undefined> {
try {
return await compare(str, hash);
return await compare(plaintext, hashed);
} catch (error) {
if (error instanceof Error && error.message.includes('Invalid salt version')) {
error.message +=

View file

@ -8,9 +8,10 @@ import { Db, ResponseHelper } from '../..';
import { AUTH_COOKIE_NAME } from '../../constants';
import { issueCookie, resolveJwt } from '../auth/jwt';
import { N8nApp, PublicUser } from '../Interfaces';
import { compareHash, isInstanceOwnerSetup, sanitizeUser } from '../UserManagementHelper';
import { compareHash, sanitizeUser } from '../UserManagementHelper';
import { User } from '../../databases/entities/User';
import type { LoginRequest } from '../../requests';
import config = require('../../../config');
export function authenticationMethods(this: N8nApp): void {
/**
@ -71,13 +72,18 @@ export function authenticationMethods(this: N8nApp): void {
// If logged in, return user
try {
user = await resolveJwt(cookieContents);
if (!config.get('userManagement.isInstanceOwnerSetUp')) {
res.cookie(AUTH_COOKIE_NAME, cookieContents);
}
return sanitizeUser(user);
} catch (error) {
res.clearCookie(AUTH_COOKIE_NAME);
}
}
if (await isInstanceOwnerSetup()) {
if (config.get('userManagement.isInstanceOwnerSetUp')) {
const error = new Error('Not logged in');
// @ts-ignore
error.httpStatusCode = 401;

View file

@ -1,7 +1,6 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
/* eslint-disable import/no-cycle */
import { compare, genSaltSync, hashSync } from 'bcryptjs';
import express = require('express');
import validator from 'validator';
import { LoggerProxy as Logger } from 'n8n-workflow';
@ -9,7 +8,7 @@ import { LoggerProxy as Logger } from 'n8n-workflow';
import { Db, InternalHooksManager, ResponseHelper } from '../..';
import { issueCookie } from '../auth/jwt';
import { N8nApp, PublicUser } from '../Interfaces';
import { validatePassword, sanitizeUser } from '../UserManagementHelper';
import { validatePassword, sanitizeUser, compareHash, hashPassword } from '../UserManagementHelper';
import type { AuthenticatedRequest, MeRequest } from '../../requests';
import { validateEntity } from '../../GenericHelpers';
import { User } from '../../databases/entities/User';
@ -87,7 +86,7 @@ export function meNamespace(this: N8nApp): void {
throw new ResponseHelper.ResponseError('Requesting user not set up.');
}
const isCurrentPwCorrect = await compare(currentPassword, req.user.password);
const isCurrentPwCorrect = await compareHash(currentPassword, req.user.password);
if (!isCurrentPwCorrect) {
throw new ResponseHelper.ResponseError(
'Provided current password is incorrect.',
@ -98,7 +97,7 @@ export function meNamespace(this: N8nApp): void {
const validPassword = validatePassword(newPassword);
req.user.password = hashSync(validPassword, genSaltSync(10));
req.user.password = await hashPassword(validPassword);
const user = await Db.collections.User!.save(req.user);
Logger.info('Password updated successfully', { userId: user.id });

View file

@ -1,6 +1,5 @@
/* eslint-disable import/no-cycle */
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { hashSync, genSaltSync } from 'bcryptjs';
import * as express from 'express';
import validator from 'validator';
import { LoggerProxy as Logger } from 'n8n-workflow';
@ -11,7 +10,7 @@ import { validateEntity } from '../../GenericHelpers';
import { AuthenticatedRequest, OwnerRequest } from '../../requests';
import { issueCookie } from '../auth/jwt';
import { N8nApp } from '../Interfaces';
import { sanitizeUser, validatePassword } from '../UserManagementHelper';
import { hashPassword, sanitizeUser, validatePassword } from '../UserManagementHelper';
export function ownerNamespace(this: N8nApp): void {
/**
@ -74,7 +73,7 @@ export function ownerNamespace(this: N8nApp): void {
email,
firstName,
lastName,
password: hashSync(validPassword, genSaltSync(10)),
password: await hashPassword(validPassword),
});
await validateEntity(owner);

View file

@ -4,14 +4,13 @@
import express = require('express');
import { v4 as uuid } from 'uuid';
import { URL } from 'url';
import { genSaltSync, hashSync } from 'bcryptjs';
import validator from 'validator';
import { IsNull, MoreThanOrEqual, Not } from 'typeorm';
import { LoggerProxy as Logger } from 'n8n-workflow';
import { Db, InternalHooksManager, ResponseHelper } from '../..';
import { N8nApp } from '../Interfaces';
import { getInstanceBaseUrl, validatePassword } from '../UserManagementHelper';
import { getInstanceBaseUrl, hashPassword, validatePassword } from '../UserManagementHelper';
import * as UserManagementMailer from '../email';
import type { PasswordResetRequest } from '../../requests';
import { issueCookie } from '../auth/jwt';
@ -206,7 +205,7 @@ export function passwordResetNamespace(this: N8nApp): void {
}
await Db.collections.User!.update(userId, {
password: hashSync(validPassword, genSaltSync(10)),
password: await hashPassword(validPassword),
resetPasswordToken: null,
resetPasswordTokenExpiration: null,
});

View file

@ -3,7 +3,6 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { Response } from 'express';
import { In } from 'typeorm';
import { genSaltSync, hashSync } from 'bcryptjs';
import validator from 'validator';
import { LoggerProxy as Logger } from 'n8n-workflow';
@ -12,6 +11,7 @@ import { N8nApp, PublicUser } from '../Interfaces';
import { UserRequest } from '../../requests';
import {
getInstanceBaseUrl,
hashPassword,
isEmailSetUp,
sanitizeUser,
validatePassword,
@ -349,7 +349,7 @@ export function usersNamespace(this: N8nApp): void {
invitee.firstName = firstName;
invitee.lastName = lastName;
invitee.password = hashSync(validPassword, genSaltSync(10));
invitee.password = await hashPassword(validPassword);
const updatedUser = await Db.collections.User!.save(invitee);

View file

@ -62,7 +62,7 @@ export class User {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ length: 254 })
@Column({ length: 254, nullable: true })
@Index({ unique: true })
@IsEmail()
email: string;
@ -81,7 +81,7 @@ export class User {
@Column({ nullable: true })
@IsString({ message: 'Password must be of type string.' })
password?: string;
password: string;
@Column({ type: String, nullable: true })
resetPasswordToken?: string | null;

View file

@ -0,0 +1,283 @@
import express = require('express');
import validator from 'validator';
import config = require('../../config');
import * as utils from './shared/utils';
import { LOGGED_OUT_RESPONSE_BODY } from './shared/constants';
import { Db } from '../../src';
import type { Role } from '../../src/databases/entities/Role';
import { randomValidPassword } from './shared/random';
import * as testDb from './shared/testDb';
import { AUTH_COOKIE_NAME } from '../../src/constants';
jest.mock('../../src/telemetry');
let app: express.Application;
let testDbName = '';
let globalOwnerRole: Role;
let globalMemberRole: Role;
beforeAll(async () => {
app = utils.initTestServer({ endpointGroups: ['auth'], applyAuth: true });
const initResult = await testDb.init();
testDbName = initResult.testDbName;
globalOwnerRole = await testDb.getGlobalOwnerRole();
globalMemberRole = await testDb.getGlobalMemberRole();
utils.initTestLogger();
utils.initTestTelemetry();
});
beforeEach(async () => {
await testDb.truncate(['User'], testDbName);
config.set('userManagement.isInstanceOwnerSetUp', true);
await Db.collections.Settings!.update(
{ key: 'userManagement.isInstanceOwnerSetUp' },
{ value: JSON.stringify(true) },
);
});
afterAll(async () => {
await testDb.terminate(testDbName);
});
test('POST /login should log user in', async () => {
const ownerPassword = randomValidPassword();
const owner = await testDb.createUser({
password: ownerPassword,
globalRole: globalOwnerRole,
});
const authlessAgent = utils.createAgent(app);
const response = await authlessAgent.post('/login').send({
email: owner.email,
password: ownerPassword,
});
expect(response.statusCode).toBe(200);
const {
id,
email,
firstName,
lastName,
password,
personalizationAnswers,
globalRole,
resetPasswordToken,
} = response.body.data;
expect(validator.isUUID(id)).toBe(true);
expect(email).toBe(owner.email);
expect(firstName).toBe(owner.firstName);
expect(lastName).toBe(owner.lastName);
expect(password).toBeUndefined();
expect(personalizationAnswers).toBeNull();
expect(password).toBeUndefined();
expect(resetPasswordToken).toBeUndefined();
expect(globalRole).toBeDefined();
expect(globalRole.name).toBe('owner');
expect(globalRole.scope).toBe('global');
const authToken = utils.getAuthToken(response);
expect(authToken).toBeDefined();
});
test('GET /login should return 401 Unauthorized if no cookie', async () => {
const authlessAgent = utils.createAgent(app);
const response = await authlessAgent.get('/login');
expect(response.statusCode).toBe(401);
const authToken = utils.getAuthToken(response);
expect(authToken).toBeUndefined();
});
test('GET /login should return cookie if UM is disabled', async () => {
const ownerShell = await testDb.createUserShell(globalOwnerRole);
config.set('userManagement.isInstanceOwnerSetUp', false);
await Db.collections.Settings!.update(
{ key: 'userManagement.isInstanceOwnerSetUp' },
{ value: JSON.stringify(false) },
);
const authOwnerShellAgent = utils.createAgent(app, { auth: true, user: ownerShell });
const response = await authOwnerShellAgent.get('/login');
expect(response.statusCode).toBe(200);
const authToken = utils.getAuthToken(response);
expect(authToken).toBeDefined();
});
test('GET /login should return 401 Unauthorized if invalid cookie', async () => {
const invalidAuthAgent = utils.createAgent(app);
invalidAuthAgent.jar.setCookie(`${AUTH_COOKIE_NAME}=invalid`);
const response = await invalidAuthAgent.get('/login');
expect(response.statusCode).toBe(401);
const authToken = utils.getAuthToken(response);
expect(authToken).toBeUndefined();
});
test('GET /login should return logged-in owner shell', async () => {
const ownerShell = await testDb.createUserShell(globalOwnerRole);
const authMemberAgent = utils.createAgent(app, { auth: true, user: ownerShell });
const response = await authMemberAgent.get('/login');
expect(response.statusCode).toBe(200);
const {
id,
email,
firstName,
lastName,
password,
personalizationAnswers,
globalRole,
resetPasswordToken,
} = response.body.data;
expect(validator.isUUID(id)).toBe(true);
expect(email).toBeDefined();
expect(firstName).toBeNull();
expect(lastName).toBeNull();
expect(password).toBeUndefined();
expect(personalizationAnswers).toBeNull();
expect(password).toBeUndefined();
expect(resetPasswordToken).toBeUndefined();
expect(globalRole).toBeDefined();
expect(globalRole.name).toBe('owner');
expect(globalRole.scope).toBe('global');
const authToken = utils.getAuthToken(response);
expect(authToken).toBeUndefined();
});
test('GET /login should return logged-in member shell', async () => {
const memberShell = await testDb.createUserShell(globalMemberRole);
const authMemberAgent = utils.createAgent(app, { auth: true, user: memberShell });
const response = await authMemberAgent.get('/login');
expect(response.statusCode).toBe(200);
const {
id,
email,
firstName,
lastName,
password,
personalizationAnswers,
globalRole,
resetPasswordToken,
} = response.body.data;
expect(validator.isUUID(id)).toBe(true);
expect(email).toBeDefined();
expect(firstName).toBeNull();
expect(lastName).toBeNull();
expect(password).toBeUndefined();
expect(personalizationAnswers).toBeNull();
expect(password).toBeUndefined();
expect(resetPasswordToken).toBeUndefined();
expect(globalRole).toBeDefined();
expect(globalRole.name).toBe('member');
expect(globalRole.scope).toBe('global');
const authToken = utils.getAuthToken(response);
expect(authToken).toBeUndefined();
});
test('GET /login should return logged-in owner', async () => {
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner });
const response = await authOwnerAgent.get('/login');
expect(response.statusCode).toBe(200);
const {
id,
email,
firstName,
lastName,
password,
personalizationAnswers,
globalRole,
resetPasswordToken,
} = response.body.data;
expect(validator.isUUID(id)).toBe(true);
expect(email).toBe(owner.email);
expect(firstName).toBe(owner.firstName);
expect(lastName).toBe(owner.lastName);
expect(password).toBeUndefined();
expect(personalizationAnswers).toBeNull();
expect(password).toBeUndefined();
expect(resetPasswordToken).toBeUndefined();
expect(globalRole).toBeDefined();
expect(globalRole.name).toBe('owner');
expect(globalRole.scope).toBe('global');
const authToken = utils.getAuthToken(response);
expect(authToken).toBeUndefined();
});
test('GET /login should return logged-in member', async () => {
const member = await testDb.createUser({ globalRole: globalMemberRole });
const authMemberAgent = utils.createAgent(app, { auth: true, user: member });
const response = await authMemberAgent.get('/login');
expect(response.statusCode).toBe(200);
const {
id,
email,
firstName,
lastName,
password,
personalizationAnswers,
globalRole,
resetPasswordToken,
} = response.body.data;
expect(validator.isUUID(id)).toBe(true);
expect(email).toBe(member.email);
expect(firstName).toBe(member.firstName);
expect(lastName).toBe(member.lastName);
expect(password).toBeUndefined();
expect(personalizationAnswers).toBeNull();
expect(password).toBeUndefined();
expect(resetPasswordToken).toBeUndefined();
expect(globalRole).toBeDefined();
expect(globalRole.name).toBe('member');
expect(globalRole.scope).toBe('global');
const authToken = utils.getAuthToken(response);
expect(authToken).toBeUndefined();
});
test('POST /logout should log user out', async () => {
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner });
const response = await authOwnerAgent.post('/logout');
expect(response.statusCode).toBe(200);
expect(response.body).toEqual(LOGGED_OUT_RESPONSE_BODY);
const authToken = utils.getAuthToken(response);
expect(authToken).toBeUndefined();
});

View file

@ -1,149 +0,0 @@
import { hashSync, genSaltSync } from 'bcryptjs';
import express = require('express');
import validator from 'validator';
import { v4 as uuid } from 'uuid';
import config = require('../../config');
import * as utils from './shared/utils';
import { LOGGED_OUT_RESPONSE_BODY } from './shared/constants';
import { Db } from '../../src';
import { Role } from '../../src/databases/entities/Role';
import { randomEmail, randomValidPassword, randomName } from './shared/random';
import { getGlobalOwnerRole } from './shared/testDb';
import * as testDb from './shared/testDb';
jest.mock('../../src/telemetry');
let globalOwnerRole: Role;
let app: express.Application;
let testDbName = '';
beforeAll(async () => {
app = utils.initTestServer({ endpointGroups: ['auth'], applyAuth: true });
const initResult = await testDb.init();
testDbName = initResult.testDbName;
await testDb.truncate(['User'], testDbName);
globalOwnerRole = await getGlobalOwnerRole();
utils.initTestLogger();
utils.initTestTelemetry();
});
beforeEach(async () => {
await testDb.createUser({
id: uuid(),
email: TEST_USER.email,
firstName: TEST_USER.firstName,
lastName: TEST_USER.lastName,
password: TEST_USER.password,
globalRole: globalOwnerRole,
});
config.set('userManagement.isInstanceOwnerSetUp', true);
await Db.collections.Settings!.update(
{ key: 'userManagement.isInstanceOwnerSetUp' },
{ value: JSON.stringify(true) },
);
});
afterEach(async () => {
await testDb.truncate(['User'], testDbName);
});
afterAll(async () => {
await testDb.terminate(testDbName);
});
test('POST /login should log user in', async () => {
const authlessAgent = utils.createAgent(app);
const response = await authlessAgent.post('/login').send({
email: TEST_USER.email,
password: TEST_USER.password,
});
expect(response.statusCode).toBe(200);
const {
id,
email,
firstName,
lastName,
password,
personalizationAnswers,
globalRole,
resetPasswordToken,
} = response.body.data;
expect(validator.isUUID(id)).toBe(true);
expect(email).toBe(TEST_USER.email);
expect(firstName).toBe(TEST_USER.firstName);
expect(lastName).toBe(TEST_USER.lastName);
expect(password).toBeUndefined();
expect(personalizationAnswers).toBeNull();
expect(password).toBeUndefined();
expect(resetPasswordToken).toBeUndefined();
expect(globalRole).toBeDefined();
expect(globalRole.name).toBe('owner');
expect(globalRole.scope).toBe('global');
const authToken = utils.getAuthToken(response);
expect(authToken).toBeDefined();
});
test('GET /login should receive logged in user', async () => {
const owner = await Db.collections.User!.findOneOrFail();
const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner });
const response = await authOwnerAgent.get('/login');
expect(response.statusCode).toBe(200);
const {
id,
email,
firstName,
lastName,
password,
personalizationAnswers,
globalRole,
resetPasswordToken,
} = response.body.data;
expect(validator.isUUID(id)).toBe(true);
expect(email).toBe(TEST_USER.email);
expect(firstName).toBe(TEST_USER.firstName);
expect(lastName).toBe(TEST_USER.lastName);
expect(password).toBeUndefined();
expect(personalizationAnswers).toBeNull();
expect(password).toBeUndefined();
expect(resetPasswordToken).toBeUndefined();
expect(globalRole).toBeDefined();
expect(globalRole.name).toBe('owner');
expect(globalRole.scope).toBe('global');
expect(response.headers['set-cookie']).toBeUndefined();
});
test('POST /logout should log user out', async () => {
const owner = await Db.collections.User!.findOneOrFail();
const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner });
const response = await authOwnerAgent.post('/logout');
expect(response.statusCode).toBe(200);
expect(response.body).toEqual(LOGGED_OUT_RESPONSE_BODY);
const authToken = utils.getAuthToken(response);
expect(authToken).toBeUndefined();
});
const TEST_USER = {
email: randomEmail(),
password: randomValidPassword(),
firstName: randomName(),
lastName: randomName(),
};

View file

@ -8,11 +8,13 @@ import {
} from './shared/constants';
import * as utils from './shared/utils';
import * as testDb from './shared/testDb';
import type { Role } from '../../src/databases/entities/Role';
jest.mock('../../src/telemetry');
let app: express.Application;
let testDbName = '';
let globalMemberRole: Role;
beforeAll(async () => {
app = utils.initTestServer({
@ -21,6 +23,9 @@ beforeAll(async () => {
});
const initResult = await testDb.init();
testDbName = initResult.testDbName;
globalMemberRole = await testDb.getGlobalMemberRole();
utils.initTestLogger();
utils.initTestTelemetry();
});
@ -43,12 +48,9 @@ ROUTES_REQUIRING_AUTHORIZATION.forEach(async (route) => {
const [method, endpoint] = getMethodAndEndpoint(route);
test(`${route} should return 403 Forbidden for member`, async () => {
const member = await testDb.createUser();
const member = await testDb.createUser({ globalRole: globalMemberRole });
const authMemberAgent = utils.createAgent(app, { auth: true, user: member });
const response = await authMemberAgent[method](endpoint);
if (response.statusCode === 500) {
console.log(response);
}
expect(response.statusCode).toBe(403);
});

View file

@ -4,14 +4,17 @@ import { Db } from '../../src';
import { randomName, randomString } from './shared/random';
import * as utils from './shared/utils';
import type { CredentialPayload, SaveCredentialFunction } from './shared/types';
import { Role } from '../../src/databases/entities/Role';
import { User } from '../../src/databases/entities/User';
import type { Role } from '../../src/databases/entities/Role';
import type { User } from '../../src/databases/entities/User';
import * as testDb from './shared/testDb';
import { CredentialsEntity } from '../../src/databases/entities/CredentialsEntity';
jest.mock('../../src/telemetry');
let app: express.Application;
let testDbName = '';
let globalOwnerRole: Role;
let globalMemberRole: Role;
let saveCredential: SaveCredentialFunction;
beforeAll(async () => {
@ -24,19 +27,17 @@ beforeAll(async () => {
utils.initConfigFile();
globalOwnerRole = await testDb.getGlobalOwnerRole();
globalMemberRole = await testDb.getGlobalMemberRole();
const credentialOwnerRole = await testDb.getCredentialOwnerRole();
saveCredential = affixRoleToSaveCredential(credentialOwnerRole);
utils.initTestLogger();
utils.initTestTelemetry();
});
beforeEach(async () => {
await testDb.createOwnerShell();
});
afterEach(async () => {
// do not combine calls - shared table must be cleared first and separately
await testDb.truncate(['SharedCredentials'], testDbName);
await testDb.truncate(['User', 'Credentials'], testDbName);
await testDb.truncate(['User', 'SharedCredentials', 'Credentials'], testDbName);
});
afterAll(async () => {
@ -44,8 +45,9 @@ afterAll(async () => {
});
test('POST /credentials should create cred', async () => {
const owner = await Db.collections.User!.findOneOrFail();
const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner });
const ownerShell = await testDb.createUserShell(globalOwnerRole);
const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell });
const payload = credentialPayload();
const response = await authOwnerAgent.post('/credentials').send(payload);
@ -71,26 +73,28 @@ test('POST /credentials should create cred', async () => {
where: { credentials: credential },
});
expect(sharedCredential.user.id).toBe(owner.id);
expect(sharedCredential.user.id).toBe(ownerShell.id);
expect(sharedCredential.credentials.name).toBe(payload.name);
});
test('POST /credentials should fail with invalid inputs', async () => {
const owner = await Db.collections.User!.findOneOrFail();
const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner });
const ownerShell = await testDb.createUserShell(globalOwnerRole);
const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell });
for (const invalidPayload of INVALID_PAYLOADS) {
const response = await authOwnerAgent.post('/credentials').send(invalidPayload);
expect(response.statusCode).toBe(400);
}
await Promise.all(
INVALID_PAYLOADS.map(async (invalidPayload) => {
const response = await authOwnerAgent.post('/credentials').send(invalidPayload);
expect(response.statusCode).toBe(400);
}),
);
});
test('POST /credentials should fail with missing encryption key', async () => {
const mock = jest.spyOn(UserSettings, 'getEncryptionKey');
mock.mockResolvedValue(undefined);
const owner = await Db.collections.User!.findOneOrFail();
const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner });
const ownerShell = await testDb.createUserShell(globalOwnerRole);
const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell });
const response = await authOwnerAgent.post('/credentials').send(credentialPayload());
@ -100,8 +104,8 @@ test('POST /credentials should fail with missing encryption key', async () => {
});
test('POST /credentials should ignore ID in payload', async () => {
const owner = await Db.collections.User!.findOneOrFail();
const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner });
const ownerShell = await testDb.createUserShell(globalOwnerRole);
const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell });
const firstResponse = await authOwnerAgent
.post('/credentials')
@ -117,9 +121,9 @@ test('POST /credentials should ignore ID in payload', async () => {
});
test('DELETE /credentials/:id should delete owned cred for owner', async () => {
const owner = await Db.collections.User!.findOneOrFail();
const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner });
const savedCredential = await saveCredential(credentialPayload(), { user: owner });
const ownerShell = await testDb.createUserShell(globalOwnerRole);
const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell });
const savedCredential = await saveCredential(credentialPayload(), { user: ownerShell });
const response = await authOwnerAgent.delete(`/credentials/${savedCredential.id}`);
@ -136,9 +140,9 @@ test('DELETE /credentials/:id should delete owned cred for owner', async () => {
});
test('DELETE /credentials/:id should delete non-owned cred for owner', async () => {
const owner = await Db.collections.User!.findOneOrFail();
const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner });
const member = await testDb.createUser();
const ownerShell = await testDb.createUserShell(globalOwnerRole);
const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell });
const member = await testDb.createUser({ globalRole: globalMemberRole });
const savedCredential = await saveCredential(credentialPayload(), { user: member });
const response = await authOwnerAgent.delete(`/credentials/${savedCredential.id}`);
@ -156,7 +160,7 @@ test('DELETE /credentials/:id should delete non-owned cred for owner', async ()
});
test('DELETE /credentials/:id should delete owned cred for member', async () => {
const member = await testDb.createUser();
const member = await testDb.createUser({ globalRole: globalMemberRole });
const authMemberAgent = utils.createAgent(app, { auth: true, user: member });
const savedCredential = await saveCredential(credentialPayload(), { user: member });
@ -175,10 +179,10 @@ test('DELETE /credentials/:id should delete owned cred for member', async () =>
});
test('DELETE /credentials/:id should not delete non-owned cred for member', async () => {
const owner = await Db.collections.User!.findOneOrFail();
const member = await testDb.createUser();
const ownerShell = await testDb.createUserShell(globalOwnerRole);
const member = await testDb.createUser({ globalRole: globalMemberRole });
const authMemberAgent = utils.createAgent(app, { auth: true, user: member });
const savedCredential = await saveCredential(credentialPayload(), { user: owner });
const savedCredential = await saveCredential(credentialPayload(), { user: ownerShell });
const response = await authMemberAgent.delete(`/credentials/${savedCredential.id}`);
@ -194,8 +198,8 @@ test('DELETE /credentials/:id should not delete non-owned cred for member', asyn
});
test('DELETE /credentials/:id should fail if cred not found', async () => {
const owner = await Db.collections.User!.findOneOrFail();
const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner });
const ownerShell = await testDb.createUserShell(globalOwnerRole);
const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell });
const response = await authOwnerAgent.delete('/credentials/123');
@ -203,9 +207,9 @@ test('DELETE /credentials/:id should fail if cred not found', async () => {
});
test('PATCH /credentials/:id should update owned cred for owner', async () => {
const owner = await Db.collections.User!.findOneOrFail();
const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner });
const savedCredential = await saveCredential(credentialPayload(), { user: owner });
const ownerShell = await testDb.createUserShell(globalOwnerRole);
const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell });
const savedCredential = await saveCredential(credentialPayload(), { user: ownerShell });
const patchPayload = credentialPayload();
const response = await authOwnerAgent
@ -237,9 +241,9 @@ test('PATCH /credentials/:id should update owned cred for owner', async () => {
});
test('PATCH /credentials/:id should update non-owned cred for owner', async () => {
const owner = await Db.collections.User!.findOneOrFail();
const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner });
const member = await testDb.createUser();
const ownerShell = await testDb.createUserShell(globalOwnerRole);
const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell });
const member = await testDb.createUser({ globalRole: globalMemberRole });
const savedCredential = await saveCredential(credentialPayload(), { user: member });
const patchPayload = credentialPayload();
@ -272,7 +276,7 @@ test('PATCH /credentials/:id should update non-owned cred for owner', async () =
});
test('PATCH /credentials/:id should update owned cred for member', async () => {
const member = await testDb.createUser();
const member = await testDb.createUser({ globalRole: globalMemberRole });
const authMemberAgent = utils.createAgent(app, { auth: true, user: member });
const savedCredential = await saveCredential(credentialPayload(), { user: member });
const patchPayload = credentialPayload();
@ -306,10 +310,10 @@ test('PATCH /credentials/:id should update owned cred for member', async () => {
});
test('PATCH /credentials/:id should not update non-owned cred for member', async () => {
const owner = await Db.collections.User!.findOneOrFail();
const member = await testDb.createUser();
const ownerShell = await testDb.createUserShell(globalOwnerRole);
const member = await testDb.createUser({ globalRole: globalMemberRole });
const authMemberAgent = utils.createAgent(app, { auth: true, user: member });
const savedCredential = await saveCredential(credentialPayload(), { user: owner });
const savedCredential = await saveCredential(credentialPayload(), { user: ownerShell });
const patchPayload = credentialPayload();
const response = await authMemberAgent
@ -324,22 +328,24 @@ test('PATCH /credentials/:id should not update non-owned cred for member', async
});
test('PATCH /credentials/:id should fail with invalid inputs', async () => {
const owner = await Db.collections.User!.findOneOrFail();
const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner });
const savedCredential = await saveCredential(credentialPayload(), { user: owner });
const ownerShell = await testDb.createUserShell(globalOwnerRole);
const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell });
const savedCredential = await saveCredential(credentialPayload(), { user: ownerShell });
for (const invalidPayload of INVALID_PAYLOADS) {
const response = await authOwnerAgent
.patch(`/credentials/${savedCredential.id}`)
.send(invalidPayload);
await Promise.all(
INVALID_PAYLOADS.map(async (invalidPayload) => {
const response = await authOwnerAgent
.patch(`/credentials/${savedCredential.id}`)
.send(invalidPayload);
expect(response.statusCode).toBe(400);
}
expect(response.statusCode).toBe(400);
}),
);
});
test('PATCH /credentials/:id should fail if cred not found', async () => {
const owner = await Db.collections.User!.findOneOrFail();
const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner });
const ownerShell = await testDb.createUserShell(globalOwnerRole);
const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell });
const response = await authOwnerAgent.patch('/credentials/123').send(credentialPayload());
@ -350,8 +356,8 @@ test('PATCH /credentials/:id should fail with missing encryption key', async ()
const mock = jest.spyOn(UserSettings, 'getEncryptionKey');
mock.mockResolvedValue(undefined);
const owner = await Db.collections.User!.findOneOrFail();
const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner });
const ownerShell = await testDb.createUserShell(globalOwnerRole);
const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell });
const response = await authOwnerAgent.post('/credentials').send(credentialPayload());
@ -361,14 +367,14 @@ test('PATCH /credentials/:id should fail with missing encryption key', async ()
});
test('GET /credentials should retrieve all creds for owner', async () => {
const owner = await Db.collections.User!.findOneOrFail();
const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner });
const ownerShell = await testDb.createUserShell(globalOwnerRole);
const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell });
for (let i = 0; i < 3; i++) {
await saveCredential(credentialPayload(), { user: owner });
await saveCredential(credentialPayload(), { user: ownerShell });
}
const member = await testDb.createUser();
const member = await testDb.createUser({ globalRole: globalMemberRole });
await saveCredential(credentialPayload(), { user: member });
@ -377,18 +383,20 @@ test('GET /credentials should retrieve all creds for owner', async () => {
expect(response.statusCode).toBe(200);
expect(response.body.data.length).toBe(4); // 3 owner + 1 member
for (const credential of response.body.data) {
const { name, type, nodesAccess, data: encryptedData } = credential;
await Promise.all(
response.body.data.map(async (credential: CredentialsEntity) => {
const { name, type, nodesAccess, data: encryptedData } = credential;
expect(typeof name).toBe('string');
expect(typeof type).toBe('string');
expect(typeof nodesAccess[0].nodeType).toBe('string');
expect(encryptedData).toBeUndefined();
}
expect(typeof name).toBe('string');
expect(typeof type).toBe('string');
expect(typeof nodesAccess[0].nodeType).toBe('string');
expect(encryptedData).toBeUndefined();
}),
);
});
test('GET /credentials should retrieve owned creds for member', async () => {
const member = await testDb.createUser();
const member = await testDb.createUser({ globalRole: globalMemberRole });
const authMemberAgent = utils.createAgent(app, { auth: true, user: member });
for (let i = 0; i < 3; i++) {
@ -400,23 +408,25 @@ test('GET /credentials should retrieve owned creds for member', async () => {
expect(response.statusCode).toBe(200);
expect(response.body.data.length).toBe(3);
for (const credential of response.body.data) {
const { name, type, nodesAccess, data: encryptedData } = credential;
await Promise.all(
response.body.data.map(async (credential: CredentialsEntity) => {
const { name, type, nodesAccess, data: encryptedData } = credential;
expect(typeof name).toBe('string');
expect(typeof type).toBe('string');
expect(typeof nodesAccess[0].nodeType).toBe('string');
expect(encryptedData).toBeUndefined();
}
expect(typeof name).toBe('string');
expect(typeof type).toBe('string');
expect(typeof nodesAccess[0].nodeType).toBe('string');
expect(encryptedData).toBeUndefined();
}),
);
});
test('GET /credentials should not retrieve non-owned creds for member', async () => {
const owner = await Db.collections.User!.findOneOrFail();
const member = await testDb.createUser();
const ownerShell = await testDb.createUserShell(globalOwnerRole);
const member = await testDb.createUser({ globalRole: globalMemberRole });
const authMemberAgent = utils.createAgent(app, { auth: true, user: member });
for (let i = 0; i < 3; i++) {
await saveCredential(credentialPayload(), { user: owner });
await saveCredential(credentialPayload(), { user: ownerShell });
}
const response = await authMemberAgent.get('/credentials');
@ -426,9 +436,9 @@ test('GET /credentials should not retrieve non-owned creds for member', async ()
});
test('GET /credentials/:id should retrieve owned cred for owner', async () => {
const owner = await Db.collections.User!.findOneOrFail();
const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner });
const savedCredential = await saveCredential(credentialPayload(), { user: owner });
const ownerShell = await testDb.createUserShell(globalOwnerRole);
const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell });
const savedCredential = await saveCredential(credentialPayload(), { user: ownerShell });
const firstResponse = await authOwnerAgent.get(`/credentials/${savedCredential.id}`);
@ -451,7 +461,7 @@ test('GET /credentials/:id should retrieve owned cred for owner', async () => {
});
test('GET /credentials/:id should retrieve owned cred for member', async () => {
const member = await testDb.createUser();
const member = await testDb.createUser({ globalRole: globalMemberRole });
const authMemberAgent = utils.createAgent(app, { auth: true, user: member });
const savedCredential = await saveCredential(credentialPayload(), { user: member });
@ -477,10 +487,10 @@ test('GET /credentials/:id should retrieve owned cred for member', async () => {
});
test('GET /credentials/:id should not retrieve non-owned cred for member', async () => {
const owner = await Db.collections.User!.findOneOrFail();
const member = await testDb.createUser();
const ownerShell = await testDb.createUserShell(globalOwnerRole);
const member = await testDb.createUser({ globalRole: globalMemberRole });
const authMemberAgent = utils.createAgent(app, { auth: true, user: member });
const savedCredential = await saveCredential(credentialPayload(), { user: owner });
const savedCredential = await saveCredential(credentialPayload(), { user: ownerShell });
const response = await authMemberAgent.get(`/credentials/${savedCredential.id}`);
@ -489,9 +499,9 @@ test('GET /credentials/:id should not retrieve non-owned cred for member', async
});
test('GET /credentials/:id should fail with missing encryption key', async () => {
const owner = await Db.collections.User!.findOneOrFail();
const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner });
const savedCredential = await saveCredential(credentialPayload(), { user: owner });
const ownerShell = await testDb.createUserShell(globalOwnerRole);
const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell });
const savedCredential = await saveCredential(credentialPayload(), { user: ownerShell });
const mock = jest.spyOn(UserSettings, 'getEncryptionKey');
mock.mockResolvedValue(undefined);
@ -506,8 +516,8 @@ test('GET /credentials/:id should fail with missing encryption key', async () =>
});
test('GET /credentials/:id should return 404 if cred not found', async () => {
const owner = await Db.collections.User!.findOneOrFail();
const authMemberAgent = utils.createAgent(app, { auth: true, user: owner });
const ownerShell = await testDb.createUserShell(globalOwnerRole);
const authMemberAgent = utils.createAgent(app, { auth: true, user: ownerShell });
const response = await authMemberAgent.get('/credentials/789');

View file

@ -1,12 +1,12 @@
import { hashSync, genSaltSync } from 'bcryptjs';
import express = require('express');
import validator from 'validator';
import { IsNull } from 'typeorm';
import config = require('../../config');
import * as utils from './shared/utils';
import { SUCCESS_RESPONSE_BODY } from './shared/constants';
import { Db } from '../../src';
import { Role } from '../../src/databases/entities/Role';
import type { Role } from '../../src/databases/entities/Role';
import { randomValidPassword, randomEmail, randomName, randomString } from './shared/random';
import * as testDb from './shared/testDb';
@ -15,6 +15,7 @@ jest.mock('../../src/telemetry');
let app: express.Application;
let testDbName = '';
let globalOwnerRole: Role;
let globalMemberRole: Role;
beforeAll(async () => {
app = utils.initTestServer({ endpointGroups: ['me'], applyAuth: true });
@ -22,6 +23,7 @@ beforeAll(async () => {
testDbName = initResult.testDbName;
globalOwnerRole = await testDb.getGlobalOwnerRole();
globalMemberRole = await testDb.getGlobalMemberRole();
utils.initTestLogger();
utils.initTestTelemetry();
});
@ -32,15 +34,11 @@ afterAll(async () => {
describe('Owner shell', () => {
beforeEach(async () => {
await testDb.createOwnerShell();
});
afterEach(async () => {
await testDb.truncate(['User'], testDbName);
});
test('GET /me should return sanitized owner shell', async () => {
const ownerShell = await Db.collections.User!.findOneOrFail();
const ownerShell = await testDb.createUserShell(globalOwnerRole);
const authOwnerShellAgent = utils.createAgent(app, { auth: true, user: ownerShell });
const response = await authOwnerShellAgent.get('/me');
@ -72,7 +70,7 @@ describe('Owner shell', () => {
});
test('PATCH /me should succeed with valid inputs', async () => {
const ownerShell = await Db.collections.User!.findOneOrFail();
const ownerShell = await testDb.createUserShell(globalOwnerRole);
const authOwnerShellAgent = utils.createAgent(app, { auth: true, user: ownerShell });
for (const validPayload of VALID_PATCH_ME_PAYLOADS) {
@ -112,7 +110,7 @@ describe('Owner shell', () => {
});
test('PATCH /me should fail with invalid inputs', async () => {
const ownerShell = await Db.collections.User!.findOneOrFail();
const ownerShell = await testDb.createUserShell(globalOwnerRole);
const authOwnerShellAgent = utils.createAgent(app, { auth: true, user: ownerShell });
for (const invalidPayload of INVALID_PATCH_ME_PAYLOADS) {
@ -127,7 +125,7 @@ describe('Owner shell', () => {
});
test('PATCH /me/password should fail for shell', async () => {
const ownerShell = await Db.collections.User!.findOneOrFail();
const ownerShell = await testDb.createUserShell(globalOwnerRole);
const authOwnerShellAgent = utils.createAgent(app, { auth: true, user: ownerShell });
const validPasswordPayload = {
@ -135,40 +133,46 @@ describe('Owner shell', () => {
newPassword: randomValidPassword(),
};
const payloads = [validPasswordPayload, ...INVALID_PASSWORD_PAYLOADS];
const validPayloads = [validPasswordPayload, ...INVALID_PASSWORD_PAYLOADS];
for (const payload of payloads) {
const response = await authOwnerShellAgent.patch('/me/password').send(payload);
expect([400, 500].includes(response.statusCode)).toBe(true);
await Promise.all(
validPayloads.map(async (payload) => {
const response = await authOwnerShellAgent.patch('/me/password').send(payload);
expect([400, 500].includes(response.statusCode)).toBe(true);
const storedMember = await Db.collections.User!.findOneOrFail();
const storedMember = await Db.collections.User!.findOneOrFail();
if (payload.newPassword) {
expect(storedMember.password).not.toBe(payload.newPassword);
}
if (payload.currentPassword) {
expect(storedMember.password).not.toBe(payload.currentPassword);
}
}
if (payload.newPassword) {
expect(storedMember.password).not.toBe(payload.newPassword);
}
if (payload.currentPassword) {
expect(storedMember.password).not.toBe(payload.currentPassword);
}
}),
);
const storedOwnerShell = await Db.collections.User!.findOneOrFail();
expect(storedOwnerShell.password).toBeNull();
});
test('POST /me/survey should succeed with valid inputs', async () => {
const ownerShell = await Db.collections.User!.findOneOrFail();
const ownerShell = await testDb.createUserShell(globalOwnerRole);
const authOwnerShellAgent = utils.createAgent(app, { auth: true, user: ownerShell });
const validPayloads = [SURVEY, {}];
for (const validPayload of validPayloads) {
const response = await authOwnerShellAgent.post('/me/survey').send(validPayload);
expect(response.statusCode).toBe(200);
expect(response.body).toEqual(SUCCESS_RESPONSE_BODY);
const { personalizationAnswers: storedAnswers } = await Db.collections.User!.findOneOrFail();
const storedShellOwner = await Db.collections.User!.findOneOrFail({
where: { email: IsNull() },
});
expect(storedAnswers).toEqual(validPayload);
expect(storedShellOwner.personalizationAnswers).toEqual(validPayload);
}
});
});
@ -188,7 +192,7 @@ describe('Member', () => {
});
test('GET /me should return sanitized member', async () => {
const member = await testDb.createUser();
const member = await testDb.createUser({ globalRole: globalMemberRole });
const authMemberAgent = utils.createAgent(app, { auth: true, user: member });
const response = await authMemberAgent.get('/me');
@ -220,7 +224,7 @@ describe('Member', () => {
});
test('PATCH /me should succeed with valid inputs', async () => {
const member = await testDb.createUser();
const member = await testDb.createUser({ globalRole: globalMemberRole });
const authMemberAgent = utils.createAgent(app, { auth: true, user: member });
for (const validPayload of VALID_PATCH_ME_PAYLOADS) {
@ -260,7 +264,7 @@ describe('Member', () => {
});
test('PATCH /me should fail with invalid inputs', async () => {
const member = await testDb.createUser();
const member = await testDb.createUser({ globalRole: globalMemberRole });
const authMemberAgent = utils.createAgent(app, { auth: true, user: member });
for (const invalidPayload of INVALID_PATCH_ME_PAYLOADS) {
@ -278,6 +282,7 @@ describe('Member', () => {
const memberPassword = randomValidPassword();
const member = await testDb.createUser({
password: memberPassword,
globalRole: globalMemberRole,
});
const authMemberAgent = utils.createAgent(app, { auth: true, user: member });
@ -296,7 +301,7 @@ describe('Member', () => {
});
test('PATCH /me/password should fail with invalid inputs', async () => {
const member = await testDb.createUser();
const member = await testDb.createUser({ globalRole: globalMemberRole });
const authMemberAgent = utils.createAgent(app, { auth: true, user: member });
for (const payload of INVALID_PASSWORD_PAYLOADS) {
@ -315,7 +320,7 @@ describe('Member', () => {
});
test('POST /me/survey should succeed with valid inputs', async () => {
const member = await testDb.createUser();
const member = await testDb.createUser({ globalRole: globalMemberRole });
const authMemberAgent = utils.createAgent(app, { auth: true, user: member });
const validPayloads = [SURVEY, {}];

View file

@ -11,26 +11,25 @@ import {
randomValidPassword,
randomInvalidPassword,
} from './shared/random';
import type { Role } from '../../src/databases/entities/Role';
jest.mock('../../src/telemetry');
let app: express.Application;
let testDbName = '';
let globalOwnerRole: Role;
beforeAll(async () => {
app = utils.initTestServer({ endpointGroups: ['owner'], applyAuth: true });
const initResult = await testDb.init();
testDbName = initResult.testDbName;
globalOwnerRole = await testDb.getGlobalOwnerRole();
utils.initTestLogger();
utils.initTestTelemetry();
});
beforeEach(async () => {
await testDb.createOwnerShell();
});
afterEach(async () => {
await testDb.truncate(['User'], testDbName);
});
@ -39,10 +38,17 @@ afterAll(async () => {
});
test('POST /owner should create owner and enable isInstanceOwnerSetUp', async () => {
const owner = await Db.collections.User!.findOneOrFail();
const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner });
const ownerShell = await testDb.createUserShell(globalOwnerRole);
const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell });
const response = await authOwnerAgent.post('/owner').send(TEST_USER);
const newOwnerData = {
email: randomEmail(),
firstName: randomName(),
lastName: randomName(),
password: randomValidPassword(),
};
const response = await authOwnerAgent.post('/owner').send(newOwnerData);
expect(response.statusCode).toBe(200);
@ -59,9 +65,9 @@ test('POST /owner should create owner and enable isInstanceOwnerSetUp', async ()
} = response.body.data;
expect(validator.isUUID(id)).toBe(true);
expect(email).toBe(TEST_USER.email);
expect(firstName).toBe(TEST_USER.firstName);
expect(lastName).toBe(TEST_USER.lastName);
expect(email).toBe(newOwnerData.email);
expect(firstName).toBe(newOwnerData.firstName);
expect(lastName).toBe(newOwnerData.lastName);
expect(personalizationAnswers).toBeNull();
expect(password).toBeUndefined();
expect(isPending).toBe(false);
@ -70,10 +76,10 @@ test('POST /owner should create owner and enable isInstanceOwnerSetUp', async ()
expect(globalRole.scope).toBe('global');
const storedOwner = await Db.collections.User!.findOneOrFail(id);
expect(storedOwner.password).not.toBe(TEST_USER.password);
expect(storedOwner.email).toBe(TEST_USER.email);
expect(storedOwner.firstName).toBe(TEST_USER.firstName);
expect(storedOwner.lastName).toBe(TEST_USER.lastName);
expect(storedOwner.password).not.toBe(newOwnerData.password);
expect(storedOwner.email).toBe(newOwnerData.email);
expect(storedOwner.firstName).toBe(newOwnerData.firstName);
expect(storedOwner.lastName).toBe(newOwnerData.lastName);
const isInstanceOwnerSetUpConfig = config.get('userManagement.isInstanceOwnerSetUp');
expect(isInstanceOwnerSetUpConfig).toBe(true);
@ -83,18 +89,20 @@ test('POST /owner should create owner and enable isInstanceOwnerSetUp', async ()
});
test('POST /owner should fail with invalid inputs', async () => {
const owner = await Db.collections.User!.findOneOrFail();
const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner });
const ownerShell = await testDb.createUserShell(globalOwnerRole);
const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell });
for (const invalidPayload of INVALID_POST_OWNER_PAYLOADS) {
const response = await authOwnerAgent.post('/owner').send(invalidPayload);
expect(response.statusCode).toBe(400);
}
await Promise.all(
INVALID_POST_OWNER_PAYLOADS.map(async (invalidPayload) => {
const response = await authOwnerAgent.post('/owner').send(invalidPayload);
expect(response.statusCode).toBe(400);
}),
);
});
test('POST /owner/skip-setup should persist skipping setup to the DB', async () => {
const owner = await Db.collections.User!.findOneOrFail();
const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner });
const ownerShell = await testDb.createUserShell(globalOwnerRole);
const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell });
const response = await authOwnerAgent.post('/owner/skip-setup').send();
@ -109,13 +117,6 @@ test('POST /owner/skip-setup should persist skipping setup to the DB', async ()
expect(value).toBe('true');
});
const TEST_USER = {
email: randomEmail(),
firstName: randomName(),
lastName: randomName(),
password: randomValidPassword(),
};
const INVALID_POST_OWNER_PAYLOADS = [
{
email: '',

View file

@ -11,51 +11,35 @@ import {
randomName,
randomValidPassword,
} from './shared/random';
import { Role } from '../../src/databases/entities/Role';
import * as testDb from './shared/testDb';
import type { Role } from '../../src/databases/entities/Role';
jest.mock('../../src/telemetry');
let app: express.Application;
let globalOwnerRole: Role;
let testDbName = '';
let globalOwnerRole: Role;
beforeAll(async () => {
app = utils.initTestServer({ endpointGroups: ['passwordReset'], applyAuth: true });
const initResult = await testDb.init();
testDbName = initResult.testDbName;
await testDb.truncate(['User'], testDbName);
globalOwnerRole = await Db.collections.Role!.findOneOrFail({
name: 'owner',
scope: 'global',
});
globalOwnerRole = await testDb.getGlobalOwnerRole();
utils.initTestTelemetry();
utils.initTestLogger();
});
beforeEach(async () => {
jest.isolateModules(() => {
jest.mock('../../config');
});
await testDb.truncate(['User'], testDbName);
jest.mock('../../config');
config.set('userManagement.isInstanceOwnerSetUp', true);
config.set('userManagement.emails.mode', '');
await testDb.createUser({
id: INITIAL_TEST_USER.id,
email: INITIAL_TEST_USER.email,
password: INITIAL_TEST_USER.password,
firstName: INITIAL_TEST_USER.firstName,
lastName: INITIAL_TEST_USER.lastName,
globalRole: globalOwnerRole,
});
});
afterEach(async () => {
await testDb.truncate(['User'], testDbName);
jest.setTimeout(30000); // fake SMTP service might be slow
});
afterAll(async () => {
@ -63,47 +47,38 @@ afterAll(async () => {
});
test('POST /forgot-password should send password reset email', async () => {
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
const authlessAgent = utils.createAgent(app);
const {
user,
pass,
smtp: { host, port, secure },
} = await utils.getSmtpTestAccount();
await utils.configureSmtp();
config.set('userManagement.emails.mode', 'smtp');
config.set('userManagement.emails.smtp.host', host);
config.set('userManagement.emails.smtp.port', port);
config.set('userManagement.emails.smtp.secure', secure);
config.set('userManagement.emails.smtp.auth.user', user);
config.set('userManagement.emails.smtp.auth.pass', pass);
const response = await authlessAgent
.post('/forgot-password')
.send({ email: INITIAL_TEST_USER.email });
const response = await authlessAgent.post('/forgot-password').send({ email: owner.email });
expect(response.statusCode).toBe(200);
expect(response.body).toEqual({});
const owner = await Db.collections.User!.findOneOrFail({ email: INITIAL_TEST_USER.email });
expect(owner.resetPasswordToken).toBeDefined();
expect(owner.resetPasswordTokenExpiration).toBeGreaterThan(Math.ceil(Date.now() / 1000));
const storedOwner = await Db.collections.User!.findOneOrFail({ email: owner.email });
expect(storedOwner.resetPasswordToken).toBeDefined();
expect(storedOwner.resetPasswordTokenExpiration).toBeGreaterThan(Math.ceil(Date.now() / 1000));
});
test('POST /forgot-password should fail if emailing is not set up', async () => {
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
const authlessAgent = utils.createAgent(app);
const response = await authlessAgent
.post('/forgot-password')
.send({ email: INITIAL_TEST_USER.email });
const response = await authlessAgent.post('/forgot-password').send({ email: owner.email });
expect(response.statusCode).toBe(500);
const owner = await Db.collections.User!.findOneOrFail({ email: INITIAL_TEST_USER.email });
expect(owner.resetPasswordToken).toBeNull();
const storedOwner = await Db.collections.User!.findOneOrFail({ email: owner.email });
expect(storedOwner.resetPasswordToken).toBeNull();
});
test('POST /forgot-password should fail with invalid inputs', async () => {
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
const authlessAgent = utils.createAgent(app);
config.set('userManagement.emails.mode', 'smtp');
@ -116,13 +91,15 @@ test('POST /forgot-password should fail with invalid inputs', async () => {
[{ email: randomName() }],
];
for (const invalidPayload of invalidPayloads) {
const response = await authlessAgent.post('/forgot-password').send(invalidPayload);
expect(response.statusCode).toBe(400);
await Promise.all(
invalidPayloads.map(async (invalidPayload) => {
const response = await authlessAgent.post('/forgot-password').send(invalidPayload);
expect(response.statusCode).toBe(400);
const owner = await Db.collections.User!.findOneOrFail({ email: INITIAL_TEST_USER.email });
expect(owner.resetPasswordToken).toBeNull();
}
const storedOwner = await Db.collections.User!.findOneOrFail({ email: owner.email });
expect(storedOwner.resetPasswordToken).toBeNull();
}),
);
});
test('POST /forgot-password should fail if user is not found', async () => {
@ -132,38 +109,39 @@ test('POST /forgot-password should fail if user is not found', async () => {
const response = await authlessAgent.post('/forgot-password').send({ email: randomEmail() });
// response should have 200 to not provide any information to the requester
expect(response.statusCode).toBe(200);
expect(response.statusCode).toBe(200); // expect 200 to remain vague
});
test('GET /resolve-password-token should succeed with valid inputs', async () => {
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
const authlessAgent = utils.createAgent(app);
const resetPasswordToken = uuid();
const resetPasswordTokenExpiration = Math.floor(Date.now() / 1000) + 100;
await Db.collections.User!.update(INITIAL_TEST_USER.id, {
await Db.collections.User!.update(owner.id, {
resetPasswordToken,
resetPasswordTokenExpiration,
});
const response = await authlessAgent
.get('/resolve-password-token')
.query({ userId: INITIAL_TEST_USER.id, token: resetPasswordToken });
.query({ userId: owner.id, token: resetPasswordToken });
expect(response.statusCode).toBe(200);
});
test('GET /resolve-password-token should fail with invalid inputs', async () => {
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
const authlessAgent = utils.createAgent(app);
config.set('userManagement.emails.mode', 'smtp');
const first = await authlessAgent.get('/resolve-password-token').query({ token: uuid() });
const second = await authlessAgent
.get('/resolve-password-token')
.query({ userId: INITIAL_TEST_USER.id });
const second = await authlessAgent.get('/resolve-password-token').query({ userId: owner.id });
for (const response of [first, second]) {
expect(response.statusCode).toBe(400);
@ -171,24 +149,28 @@ test('GET /resolve-password-token should fail with invalid inputs', async () =>
});
test('GET /resolve-password-token should fail if user is not found', async () => {
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
const authlessAgent = utils.createAgent(app);
config.set('userManagement.emails.mode', 'smtp');
const response = await authlessAgent
.get('/resolve-password-token')
.query({ userId: INITIAL_TEST_USER.id, token: uuid() });
.query({ userId: owner.id, token: uuid() });
expect(response.statusCode).toBe(404);
});
test('GET /resolve-password-token should fail if token is expired', async () => {
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
const authlessAgent = utils.createAgent(app);
const resetPasswordToken = uuid();
const resetPasswordTokenExpiration = Math.floor(Date.now() / 1000) - 1;
await Db.collections.User!.update(INITIAL_TEST_USER.id, {
await Db.collections.User!.update(owner.id, {
resetPasswordToken,
resetPasswordTokenExpiration,
});
@ -197,18 +179,20 @@ test('GET /resolve-password-token should fail if token is expired', async () =>
const response = await authlessAgent
.get('/resolve-password-token')
.query({ userId: INITIAL_TEST_USER.id, token: resetPasswordToken });
.query({ userId: owner.id, token: resetPasswordToken });
expect(response.statusCode).toBe(404);
});
test('POST /change-password should succeed with valid inputs', async () => {
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
const authlessAgent = utils.createAgent(app);
const resetPasswordToken = uuid();
const resetPasswordTokenExpiration = Math.floor(Date.now() / 1000) + 100;
await Db.collections.User!.update(INITIAL_TEST_USER.id, {
await Db.collections.User!.update(owner.id, {
resetPasswordToken,
resetPasswordTokenExpiration,
});
@ -217,7 +201,7 @@ test('POST /change-password should succeed with valid inputs', async () => {
const response = await authlessAgent.post('/change-password').send({
token: resetPasswordToken,
userId: INITIAL_TEST_USER.id,
userId: owner.id,
password: passwordToStore,
});
@ -226,63 +210,65 @@ test('POST /change-password should succeed with valid inputs', async () => {
const authToken = utils.getAuthToken(response);
expect(authToken).toBeDefined();
const { password: storedPassword } = await Db.collections.User!.findOneOrFail(
INITIAL_TEST_USER.id,
);
const { password: storedPassword } = await Db.collections.User!.findOneOrFail(owner.id);
const comparisonResult = await compare(passwordToStore, storedPassword!);
const comparisonResult = await compare(passwordToStore, storedPassword);
expect(comparisonResult).toBe(true);
expect(storedPassword).not.toBe(passwordToStore);
});
test('POST /change-password should fail with invalid inputs', async () => {
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
const authlessAgent = utils.createAgent(app);
const resetPasswordToken = uuid();
const resetPasswordTokenExpiration = Math.floor(Date.now() / 1000) + 100;
await Db.collections.User!.update(INITIAL_TEST_USER.id, {
await Db.collections.User!.update(owner.id, {
resetPasswordToken,
resetPasswordTokenExpiration,
});
const invalidPayloads = [
{ token: uuid() },
{ id: INITIAL_TEST_USER.id },
{ id: owner.id },
{ password: randomValidPassword() },
{ token: uuid(), id: INITIAL_TEST_USER.id },
{ token: uuid(), id: owner.id },
{ token: uuid(), password: randomValidPassword() },
{ id: INITIAL_TEST_USER.id, password: randomValidPassword() },
{ id: owner.id, password: randomValidPassword() },
{
id: INITIAL_TEST_USER.id,
id: owner.id,
password: randomInvalidPassword(),
token: resetPasswordToken,
},
{
id: INITIAL_TEST_USER.id,
id: owner.id,
password: randomValidPassword(),
token: uuid(),
},
];
const { password: originalHashedPassword } = await Db.collections.User!.findOneOrFail();
await Promise.all(
invalidPayloads.map(async (invalidPayload) => {
const response = await authlessAgent.post('/change-password').query(invalidPayload);
expect(response.statusCode).toBe(400);
for (const invalidPayload of invalidPayloads) {
const response = await authlessAgent.post('/change-password').query(invalidPayload);
expect(response.statusCode).toBe(400);
const { password: fetchedHashedPassword } = await Db.collections.User!.findOneOrFail();
expect(originalHashedPassword).toBe(fetchedHashedPassword);
}
const { password: storedPassword } = await Db.collections.User!.findOneOrFail();
expect(owner.password).toBe(storedPassword);
}),
);
});
test('POST /change-password should fail when token has expired', async () => {
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
const authlessAgent = utils.createAgent(app);
const resetPasswordToken = uuid();
const resetPasswordTokenExpiration = Math.floor(Date.now() / 1000) - 1;
await Db.collections.User!.update(INITIAL_TEST_USER.id, {
await Db.collections.User!.update(owner.id, {
resetPasswordToken,
resetPasswordTokenExpiration,
});
@ -291,17 +277,9 @@ test('POST /change-password should fail when token has expired', async () => {
const response = await authlessAgent.post('/change-password').send({
token: resetPasswordToken,
userId: INITIAL_TEST_USER.id,
userId: owner.id,
password: passwordToStore,
});
expect(response.statusCode).toBe(404);
});
const INITIAL_TEST_USER = {
id: uuid(),
email: randomEmail(),
firstName: randomName(),
lastName: randomName(),
password: randomValidPassword(),
};

View file

@ -1,4 +1,4 @@
import { createConnection, getConnection, ConnectionOptions } from 'typeorm';
import { createConnection, getConnection, ConnectionOptions, Connection } from 'typeorm';
import { Credentials, UserSettings } from 'n8n-core';
import config = require('../../../config');
@ -6,17 +6,17 @@ import { BOOTSTRAP_MYSQL_CONNECTION_NAME, BOOTSTRAP_POSTGRES_CONNECTION_NAME } f
import { DatabaseType, Db, ICredentialsDb, IDatabaseCollections } from '../../../src';
import { randomEmail, randomName, randomString, randomValidPassword } from './random';
import { CredentialsEntity } from '../../../src/databases/entities/CredentialsEntity';
import { hashPassword } from '../../../src/UserManagement/UserManagementHelper';
import { RESPONSE_ERROR_MESSAGES } from '../../../src/constants';
import { entities } from '../../../src/databases/entities';
import { mysqlMigrations } from '../../../src/databases/mysqldb/migrations';
import { postgresMigrations } from '../../../src/databases/postgresdb/migrations';
import { sqliteMigrations } from '../../../src/databases/sqlite/migrations';
import { categorize } from './utils';
import type { Role } from '../../../src/databases/entities/Role';
import type { User } from '../../../src/databases/entities/User';
import type { CredentialPayload } from './types';
import { genSaltSync, hashSync } from 'bcryptjs';
import type { CollectionName, CredentialPayload } from './types';
/**
* Initialize one test DB per suite run, with bootstrap connection if needed.
@ -97,22 +97,49 @@ export async function terminate(testDbName: string) {
}
/**
* Truncate DB tables for specified entities.
* Truncate DB tables for collections.
*
* @param entities Array of entity names whose tables to truncate.
* @param collections Array of entity names whose tables to truncate.
* @param testDbName Name of the test DB to truncate tables in.
*/
export async function truncate(entities: Array<keyof IDatabaseCollections>, testDbName: string) {
export async function truncate(collections: CollectionName[], testDbName: string) {
const dbType = config.get('database.type');
const testDb = getConnection(testDbName);
if (dbType === 'sqlite') {
const testDb = getConnection(testDbName);
await testDb.query('PRAGMA foreign_keys=OFF');
await Promise.all(entities.map((entity) => Db.collections[entity]!.clear()));
await Promise.all(collections.map((collection) => Db.collections[collection]!.clear()));
return testDb.query('PRAGMA foreign_keys=ON');
}
const map: { [K in keyof IDatabaseCollections]: string } = {
if (dbType === 'postgresdb') {
return Promise.all(
collections.map((collection) => {
const tableName = toTableName(collection);
testDb.query(`TRUNCATE TABLE "${tableName}" RESTART IDENTITY CASCADE;`);
}),
);
}
/**
* MySQL `TRUNCATE` requires enabling and disabling the global variable `foreign_key_checks`,
* which cannot be safely manipulated by parallel tests, so use `DELETE` and `AUTO_INCREMENT`.
* Clear shared tables first to avoid deadlock: https://stackoverflow.com/a/41174997
*/
if (dbType === 'mysqldb') {
const { pass: isShared, fail: isNotShared } = categorize(
collections,
(collectionName: CollectionName) => collectionName.toLowerCase().startsWith('shared'),
);
await truncateMySql(testDb, isShared);
await truncateMySql(testDb, isNotShared);
}
}
function toTableName(collectionName: CollectionName) {
return {
Credentials: 'credentials_entity',
Workflow: 'workflow_entity',
Execution: 'execution_entity',
@ -123,27 +150,17 @@ export async function truncate(entities: Array<keyof IDatabaseCollections>, test
SharedCredentials: 'shared_credentials',
SharedWorkflow: 'shared_workflow',
Settings: 'settings',
};
}[collectionName];
}
if (dbType === 'postgresdb') {
return Promise.all(
entities.map((entity) =>
getConnection(testDbName).query(
`TRUNCATE TABLE "${map[entity]}" RESTART IDENTITY CASCADE;`,
),
),
);
}
// MySQL truncation requires globals, which cannot be safely manipulated by parallel tests
if (dbType === 'mysqldb') {
await Promise.all(
entities.map(async (entity) => {
await Db.collections[entity]!.delete({});
await getConnection(testDbName).query(`ALTER TABLE ${map[entity]} AUTO_INCREMENT = 1;`);
}),
);
}
function truncateMySql(connection: Connection, collections: Array<keyof IDatabaseCollections>) {
return Promise.all(
collections.map(async (collection) => {
const tableName = toTableName(collection);
await connection.query(`DELETE FROM ${tableName};`);
await connection.query(`ALTER TABLE ${tableName} AUTO_INCREMENT = 1;`);
}),
);
}
// ----------------------------------
@ -179,63 +196,65 @@ export async function saveCredential(
}
// ----------------------------------
// user creation
// user creation
// ----------------------------------
/**
* Store a user in the DB, defaulting to a `member`.
*/
export async function createUser(attributes: Partial<User> = {}): Promise<User> {
export async function createUser(attributes: Partial<User> & { globalRole: Role }): Promise<User> {
const { email, password, firstName, lastName, globalRole, ...rest } = attributes;
const user = {
email: email ?? randomEmail(),
password: hashSync(password ?? randomValidPassword(), genSaltSync(10)),
password: await hashPassword(password ?? randomValidPassword()),
firstName: firstName ?? randomName(),
lastName: lastName ?? randomName(),
globalRole: globalRole ?? (await getGlobalMemberRole()),
globalRole,
...rest,
};
return Db.collections.User!.save(user);
}
export async function createOwnerShell() {
const globalRole = await getGlobalOwnerRole();
return Db.collections.User!.save({ globalRole });
}
export function createUserShell(globalRole: Role): Promise<User> {
if (globalRole.scope !== 'global') {
throw new Error(`Invalid role received: ${JSON.stringify(globalRole)}`);
}
export async function createMemberShell() {
const globalRole = await getGlobalMemberRole();
return Db.collections.User!.save({ globalRole });
const shell: Partial<User> = { globalRole };
if (globalRole.name !== 'owner') {
shell.email = randomEmail();
}
return Db.collections.User!.save(shell);
}
// ----------------------------------
// role fetchers
// ----------------------------------
export async function getGlobalOwnerRole() {
return await Db.collections.Role!.findOneOrFail({
export function getGlobalOwnerRole() {
return Db.collections.Role!.findOneOrFail({
name: 'owner',
scope: 'global',
});
}
export async function getGlobalMemberRole() {
return await Db.collections.Role!.findOneOrFail({
export function getGlobalMemberRole() {
return Db.collections.Role!.findOneOrFail({
name: 'member',
scope: 'global',
});
}
export async function getWorkflowOwnerRole() {
return await Db.collections.Role!.findOneOrFail({
export function getWorkflowOwnerRole() {
return Db.collections.Role!.findOneOrFail({
name: 'owner',
scope: 'workflow',
});
}
export async function getCredentialOwnerRole() {
return await Db.collections.Role!.findOneOrFail({
export function getCredentialOwnerRole() {
return Db.collections.Role!.findOneOrFail({
name: 'owner',
scope: 'credential',
});

View file

@ -1,8 +1,10 @@
import type { ICredentialDataDecryptedObject, ICredentialNodeAccess } from 'n8n-workflow';
import type { ICredentialsDb } from '../../../src';
import type { ICredentialsDb, IDatabaseCollections } from '../../../src';
import type { CredentialsEntity } from '../../../src/databases/entities/CredentialsEntity';
import type { User } from '../../../src/databases/entities/User';
export type CollectionName = keyof IDatabaseCollections;
export type SmtpTestAccount = {
user: string;
pass: string;

View file

@ -23,9 +23,7 @@ import { passwordResetNamespace as passwordResetEndpoints } from '../../../src/U
import { issueJWT } from '../../../src/UserManagement/auth/jwt';
import { getLogger } from '../../../src/Logger';
import { credentialsController } from '../../../src/api/credentials.api';
import type { User } from '../../../src/databases/entities/User';
import { Telemetry } from '../../../src/telemetry';
import type { EndpointGroup, SmtpTestAccount } from './types';
import type { N8nApp } from '../../../src/UserManagement/Interfaces';
@ -182,9 +180,7 @@ export function prefix(pathSegment: string) {
export function getAuthToken(response: request.Response, authCookieName = AUTH_COOKIE_NAME) {
const cookies: string[] = response.headers['set-cookie'];
if (!cookies) {
throw new Error("No 'set-cookie' header found in response");
}
if (!cookies) return undefined;
const authCookie = cookies.find((c) => c.startsWith(`${authCookieName}=`));
@ -216,5 +212,37 @@ export async function isInstanceOwnerSetUp() {
/**
* Get an SMTP test account from https://ethereal.email to test sending emails.
*/
export const getSmtpTestAccount = util.promisify<SmtpTestAccount>(createTestAccount);
const getSmtpTestAccount = util.promisify<SmtpTestAccount>(createTestAccount);
export async function configureSmtp() {
const {
user,
pass,
smtp: { host, port, secure },
} = await getSmtpTestAccount();
config.set('userManagement.emails.mode', 'smtp');
config.set('userManagement.emails.smtp.host', host);
config.set('userManagement.emails.smtp.port', port);
config.set('userManagement.emails.smtp.secure', secure);
config.set('userManagement.emails.smtp.auth.user', user);
config.set('userManagement.emails.smtp.auth.pass', pass);
}
// ----------------------------------
// misc
// ----------------------------------
/**
* Categorize array items into two groups based on whether they pass a test.
*/
export const categorize = <T>(arr: T[], test: (str: T) => boolean) => {
return arr.reduce<{ pass: T[]; fail: T[] }>(
(acc, cur) => {
test(cur) ? acc.pass.push(cur) : acc.fail.push(cur);
return acc;
},
{ pass: [], fail: [] },
);
};

View file

@ -1,12 +1,10 @@
import express = require('express');
import validator from 'validator';
import { v4 as uuid } from 'uuid';
import { compare, genSaltSync, hashSync } from 'bcryptjs';
import { Db } from '../../src';
import config = require('../../config');
import { SUCCESS_RESPONSE_BODY } from './shared/constants';
import { Role } from '../../src/databases/entities/Role';
import {
randomEmail,
randomValidPassword,
@ -15,15 +13,18 @@ import {
} from './shared/random';
import { CredentialsEntity } from '../../src/databases/entities/CredentialsEntity';
import { WorkflowEntity } from '../../src/databases/entities/WorkflowEntity';
import type { Role } from '../../src/databases/entities/Role';
import type { User } from '../../src/databases/entities/User';
import * as utils from './shared/utils';
import * as testDb from './shared/testDb';
import { compareHash } from '../../src/UserManagement/UserManagementHelper';
jest.mock('../../src/telemetry');
let app: express.Application;
let testDbName = '';
let globalOwnerRole: Role;
let globalMemberRole: Role;
let globalOwnerRole: Role;
let workflowOwnerRole: Role;
let credentialOwnerRole: Role;
@ -46,25 +47,17 @@ beforeAll(async () => {
utils.initTestTelemetry();
utils.initTestLogger();
jest.setTimeout(30000); // fake SMTP service might be slow
});
beforeEach(async () => {
// do not combine calls - shared tables must be cleared first and separately
await testDb.truncate(['SharedCredentials', 'SharedWorkflow'], testDbName);
await testDb.truncate(['User', 'Workflow', 'Credentials'], testDbName);
await testDb.truncate(
['User', 'SharedCredentials', 'SharedWorkflow', 'Workflow', 'Credentials'],
testDbName,
);
jest.isolateModules(() => {
jest.mock('../../config');
});
await testDb.createUser({
id: INITIAL_TEST_USER.id,
email: INITIAL_TEST_USER.email,
password: INITIAL_TEST_USER.password,
firstName: INITIAL_TEST_USER.firstName,
lastName: INITIAL_TEST_USER.lastName,
globalRole: globalOwnerRole,
});
jest.mock('../../config');
config.set('userManagement.disabled', false);
config.set('userManagement.isInstanceOwnerSetUp', true);
@ -76,46 +69,48 @@ afterAll(async () => {
});
test('GET /users should return all users', async () => {
const owner = await Db.collections.User!.findOneOrFail();
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner });
await testDb.createUser();
await testDb.createUser({ globalRole: globalMemberRole });
const response = await authOwnerAgent.get('/users');
expect(response.statusCode).toBe(200);
expect(response.body.data.length).toBe(2);
for (const user of response.body.data) {
const {
id,
email,
firstName,
lastName,
personalizationAnswers,
globalRole,
password,
resetPasswordToken,
isPending,
} = user;
await Promise.all(
response.body.data.map(async (user: User) => {
const {
id,
email,
firstName,
lastName,
personalizationAnswers,
globalRole,
password,
resetPasswordToken,
isPending,
} = user;
expect(validator.isUUID(id)).toBe(true);
expect(email).toBeDefined();
expect(firstName).toBeDefined();
expect(lastName).toBeDefined();
expect(personalizationAnswers).toBeUndefined();
expect(password).toBeUndefined();
expect(resetPasswordToken).toBeUndefined();
expect(isPending).toBe(false);
expect(globalRole).toBeDefined();
}
expect(validator.isUUID(id)).toBe(true);
expect(email).toBeDefined();
expect(firstName).toBeDefined();
expect(lastName).toBeDefined();
expect(personalizationAnswers).toBeUndefined();
expect(password).toBeUndefined();
expect(resetPasswordToken).toBeUndefined();
expect(isPending).toBe(false);
expect(globalRole).toBeDefined();
}),
);
});
test('DELETE /users/:id should delete the user', async () => {
const owner = await Db.collections.User!.findOneOrFail();
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner });
const userToDelete = await testDb.createUser();
const userToDelete = await testDb.createUser({ globalRole: globalMemberRole });
const newWorkflow = new WorkflowEntity();
@ -181,7 +176,7 @@ test('DELETE /users/:id should delete the user', async () => {
});
test('DELETE /users/:id should fail to delete self', async () => {
const owner = await Db.collections.User!.findOneOrFail();
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner });
const response = await authOwnerAgent.delete(`/users/${owner.id}`);
@ -193,10 +188,10 @@ test('DELETE /users/:id should fail to delete self', async () => {
});
test('DELETE /users/:id should fail if user to delete is transferee', async () => {
const owner = await Db.collections.User!.findOneOrFail();
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner });
const { id: idToDelete } = await testDb.createUser();
const { id: idToDelete } = await testDb.createUser({ globalRole: globalMemberRole });
const response = await authOwnerAgent.delete(`/users/${idToDelete}`).query({
transferId: idToDelete,
@ -209,7 +204,7 @@ test('DELETE /users/:id should fail if user to delete is transferee', async () =
});
test('DELETE /users/:id with transferId should perform transfer', async () => {
const owner = await Db.collections.User!.findOneOrFail();
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner });
const userToDelete = await Db.collections.User!.save({
@ -281,36 +276,34 @@ test('DELETE /users/:id with transferId should perform transfer', async () => {
});
test('GET /resolve-signup-token should validate invite token', async () => {
const owner = await Db.collections.User!.findOneOrFail();
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner });
const { id: inviteeId } = await testDb.createMemberShell();
const memberShell = await testDb.createUserShell(globalMemberRole);
const response = await authOwnerAgent
.get('/resolve-signup-token')
.query({ inviterId: INITIAL_TEST_USER.id })
.query({ inviteeId });
.query({ inviterId: owner.id })
.query({ inviteeId: memberShell.id });
expect(response.statusCode).toBe(200);
expect(response.body).toEqual({
data: {
inviter: {
firstName: INITIAL_TEST_USER.firstName,
lastName: INITIAL_TEST_USER.lastName,
firstName: owner.firstName,
lastName: owner.lastName,
},
},
});
});
test('GET /resolve-signup-token should fail with invalid inputs', async () => {
const owner = await Db.collections.User!.findOneOrFail();
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner });
const { id: inviteeId } = await testDb.createUser();
const { id: inviteeId } = await testDb.createUser({ globalRole: globalMemberRole });
const first = await authOwnerAgent
.get('/resolve-signup-token')
.query({ inviterId: INITIAL_TEST_USER.id });
const first = await authOwnerAgent.get('/resolve-signup-token').query({ inviterId: owner.id });
const second = await authOwnerAgent.get('/resolve-signup-token').query({ inviteeId });
@ -322,14 +315,14 @@ test('GET /resolve-signup-token should fail with invalid inputs', async () => {
// user is already set up, so call should error
const fourth = await authOwnerAgent
.get('/resolve-signup-token')
.query({ inviterId: INITIAL_TEST_USER.id })
.query({ inviterId: owner.id })
.query({ inviteeId });
// cause inconsistent DB state
await Db.collections.User!.update(owner.id, { email: '' });
const fifth = await authOwnerAgent
.get('/resolve-signup-token')
.query({ inviterId: INITIAL_TEST_USER.id })
.query({ inviterId: owner.id })
.query({ inviteeId });
for (const response of [first, second, third, fourth, fifth]) {
@ -338,21 +331,20 @@ test('GET /resolve-signup-token should fail with invalid inputs', async () => {
});
test('POST /users/:id should fill out a user shell', async () => {
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
const memberShell = await testDb.createUserShell(globalMemberRole);
const memberData = {
inviterId: owner.id,
firstName: randomName(),
lastName: randomName(),
password: randomValidPassword(),
};
const authlessAgent = utils.createAgent(app);
const userToFillOut = await Db.collections.User!.save({
email: randomEmail(),
globalRole: globalMemberRole,
});
const newPassword = randomValidPassword();
const response = await authlessAgent.post(`/users/${userToFillOut.id}`).send({
inviterId: INITIAL_TEST_USER.id,
firstName: INITIAL_TEST_USER.firstName,
lastName: INITIAL_TEST_USER.lastName,
password: newPassword,
});
const response = await authlessAgent.post(`/users/${memberShell.id}`).send(memberData);
const {
id,
@ -368,8 +360,8 @@ test('POST /users/:id should fill out a user shell', async () => {
expect(validator.isUUID(id)).toBe(true);
expect(email).toBeDefined();
expect(firstName).toBe(INITIAL_TEST_USER.firstName);
expect(lastName).toBe(INITIAL_TEST_USER.lastName);
expect(firstName).toBe(memberData.firstName);
expect(lastName).toBe(memberData.lastName);
expect(personalizationAnswers).toBeNull();
expect(password).toBeUndefined();
expect(resetPasswordToken).toBeUndefined();
@ -379,69 +371,98 @@ test('POST /users/:id should fill out a user shell', async () => {
const authToken = utils.getAuthToken(response);
expect(authToken).toBeDefined();
const filledOutUser = await Db.collections.User!.findOneOrFail(userToFillOut.id);
expect(filledOutUser.firstName).toBe(INITIAL_TEST_USER.firstName);
expect(filledOutUser.lastName).toBe(INITIAL_TEST_USER.lastName);
expect(filledOutUser.password).not.toBe(newPassword);
const member = await Db.collections.User!.findOneOrFail(memberShell.id);
expect(member.firstName).toBe(memberData.firstName);
expect(member.lastName).toBe(memberData.lastName);
expect(member.password).not.toBe(memberData.password);
});
test('POST /users/:id should fail with invalid inputs', async () => {
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
const authlessAgent = utils.createAgent(app);
const emailToStore = randomEmail();
const memberShellEmail = randomEmail();
const userToFillOut = await Db.collections.User!.save({
email: emailToStore,
const memberShell = await Db.collections.User!.save({
email: memberShellEmail,
globalRole: globalMemberRole,
});
for (const invalidPayload of INVALID_FILL_OUT_USER_PAYLOADS) {
const response = await authlessAgent.post(`/users/${userToFillOut.id}`).send(invalidPayload);
expect(response.statusCode).toBe(400);
const invalidPayloads = [
{
firstName: randomName(),
lastName: randomName(),
password: randomValidPassword(),
},
{
inviterId: owner.id,
firstName: randomName(),
password: randomValidPassword(),
},
{
inviterId: owner.id,
firstName: randomName(),
password: randomValidPassword(),
},
{
inviterId: owner.id,
firstName: randomName(),
lastName: randomName(),
},
{
inviterId: owner.id,
firstName: randomName(),
lastName: randomName(),
password: randomInvalidPassword(),
},
];
const user = await Db.collections.User!.findOneOrFail({ where: { email: emailToStore } });
expect(user.firstName).toBeNull();
expect(user.lastName).toBeNull();
expect(user.password).toBeNull();
}
await Promise.all(
invalidPayloads.map(async (invalidPayload) => {
const response = await authlessAgent.post(`/users/${memberShell.id}`).send(invalidPayload);
expect(response.statusCode).toBe(400);
const storedUser = await Db.collections.User!.findOneOrFail({
where: { email: memberShellEmail },
});
expect(storedUser.firstName).toBeNull();
expect(storedUser.lastName).toBeNull();
expect(storedUser.password).toBeNull();
}),
);
});
test('POST /users/:id should fail with already accepted invite', async () => {
const authlessAgent = utils.createAgent(app);
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
const member = await testDb.createUser({ globalRole: globalMemberRole });
const globalMemberRole = await Db.collections.Role!.findOneOrFail({
name: 'member',
scope: 'global',
});
const shell = await Db.collections.User!.save({
email: randomEmail(),
password: hashSync(randomValidPassword(), genSaltSync(10)), // simulate accepted invite
globalRole: globalMemberRole,
});
const newPassword = randomValidPassword();
const response = await authlessAgent.post(`/users/${shell.id}`).send({
inviterId: INITIAL_TEST_USER.id,
const newMemberData = {
inviterId: owner.id,
firstName: randomName(),
lastName: randomName(),
password: randomValidPassword(),
});
};
const authlessAgent = utils.createAgent(app);
const response = await authlessAgent.post(`/users/${member.id}`).send(newMemberData);
expect(response.statusCode).toBe(400);
const fetchedShell = await Db.collections.User!.findOneOrFail({ where: { email: shell.email } });
expect(fetchedShell.firstName).toBeNull();
expect(fetchedShell.lastName).toBeNull();
const storedMember = await Db.collections.User!.findOneOrFail({
where: { email: member.email },
});
expect(storedMember.firstName).not.toBe(newMemberData.firstName);
expect(storedMember.lastName).not.toBe(newMemberData.lastName);
const comparisonResult = await compare(shell.password, newPassword);
const comparisonResult = await compareHash(member.password, storedMember.password);
expect(comparisonResult).toBe(false);
expect(newPassword).not.toBe(fetchedShell.password);
expect(storedMember.password).not.toBe(newMemberData.password);
});
test('POST /users should fail if emailing is not set up', async () => {
const owner = await Db.collections.User!.findOneOrFail();
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner });
const response = await authOwnerAgent.post('/users').send([{ email: randomEmail() }]);
@ -450,7 +471,7 @@ test('POST /users should fail if emailing is not set up', async () => {
});
test('POST /users should fail if user management is disabled', async () => {
const owner = await Db.collections.User!.findOneOrFail();
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner });
config.set('userManagement.disabled', true);
@ -461,54 +482,47 @@ test('POST /users should fail if user management is disabled', async () => {
});
test('POST /users should email invites and create user shells', async () => {
const owner = await Db.collections.User!.findOneOrFail();
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner });
const {
user,
pass,
smtp: { host, port, secure },
} = await utils.getSmtpTestAccount();
await utils.configureSmtp();
config.set('userManagement.emails.mode', 'smtp');
config.set('userManagement.emails.smtp.host', host);
config.set('userManagement.emails.smtp.port', port);
config.set('userManagement.emails.smtp.secure', secure);
config.set('userManagement.emails.smtp.auth.user', user);
config.set('userManagement.emails.smtp.auth.pass', pass);
const testEmails = [randomEmail(), randomEmail(), randomEmail()];
const payload = TEST_EMAILS_TO_CREATE_USER_SHELLS.map((e) => ({ email: e }));
const payload = testEmails.map((e) => ({ email: e }));
const response = await authOwnerAgent.post('/users').send(payload);
expect(response.statusCode).toBe(200);
for (const {
user: { id, email: receivedEmail },
error,
} of response.body.data) {
expect(validator.isUUID(id)).toBe(true);
expect(TEST_EMAILS_TO_CREATE_USER_SHELLS.some((e) => e === receivedEmail)).toBe(true);
if (error) {
expect(error).toBe('Email could not be sent');
}
await Promise.all(
response.body.data.map(async ({ user, error }: { user: User; error: Error }) => {
const { id, email: receivedEmail } = user;
const user = await Db.collections.User!.findOneOrFail(id);
const { firstName, lastName, personalizationAnswers, password, resetPasswordToken } = user;
expect(validator.isUUID(id)).toBe(true);
expect(testEmails.some((e) => e === receivedEmail)).toBe(true);
if (error) {
expect(error).toBe('Email could not be sent');
}
expect(firstName).toBeNull();
expect(lastName).toBeNull();
expect(personalizationAnswers).toBeNull();
expect(password).toBeNull();
expect(resetPasswordToken).toBeNull();
}
const storedUser = await Db.collections.User!.findOneOrFail(id);
const { firstName, lastName, personalizationAnswers, password, resetPasswordToken } =
storedUser;
expect(firstName).toBeNull();
expect(lastName).toBeNull();
expect(personalizationAnswers).toBeNull();
expect(password).toBeNull();
expect(resetPasswordToken).toBeNull();
}),
);
});
test('POST /users should fail with invalid inputs', async () => {
const owner = await Db.collections.User!.findOneOrFail();
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner });
config.set('userManagement.emails.mode', 'smtp');
await utils.configureSmtp();
const invalidPayloads = [
randomEmail(),
@ -518,20 +532,22 @@ test('POST /users should fail with invalid inputs', async () => {
[{ email: randomName() }],
];
for (const invalidPayload of invalidPayloads) {
const response = await authOwnerAgent.post('/users').send(invalidPayload);
expect(response.statusCode).toBe(400);
await Promise.all(
invalidPayloads.map(async (invalidPayload) => {
const response = await authOwnerAgent.post('/users').send(invalidPayload);
expect(response.statusCode).toBe(400);
const users = await Db.collections.User!.find();
expect(users.length).toBe(1); // DB unaffected
}
const users = await Db.collections.User!.find();
expect(users.length).toBe(1); // DB unaffected
}),
);
});
test('POST /users should ignore an empty payload', async () => {
const owner = await Db.collections.User!.findOneOrFail();
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner });
config.set('userManagement.emails.mode', 'smtp');
await utils.configureSmtp();
const response = await authOwnerAgent.post('/users').send([]);
@ -561,42 +577,3 @@ test('POST /users should ignore an empty payload', async () => {
// expect(response.statusCode).toBe(500);
// });
const INITIAL_TEST_USER = {
id: uuid(),
email: randomEmail(),
firstName: randomName(),
lastName: randomName(),
password: randomValidPassword(),
};
const INVALID_FILL_OUT_USER_PAYLOADS = [
{
firstName: randomName(),
lastName: randomName(),
password: randomValidPassword(),
},
{
inviterId: INITIAL_TEST_USER.id,
firstName: randomName(),
password: randomValidPassword(),
},
{
inviterId: INITIAL_TEST_USER.id,
firstName: randomName(),
password: randomValidPassword(),
},
{
inviterId: INITIAL_TEST_USER.id,
firstName: randomName(),
lastName: randomName(),
},
{
inviterId: INITIAL_TEST_USER.id,
firstName: randomName(),
lastName: randomName(),
password: randomInvalidPassword(),
},
];
const TEST_EMAILS_TO_CREATE_USER_SHELLS = [randomEmail(), randomEmail(), randomEmail()];