const { expect } = require('chai') const AuthenticationManager = require('../../../../app/src/Features/Authentication/AuthenticationManager') const Settings = require('@overleaf/settings') const InstitutionsAPI = require('../../../../app/src/Features/Institutions/InstitutionsAPI') const UserCreator = require('../../../../app/src/Features/User/UserCreator') const UserGetter = require('../../../../app/src/Features/User/UserGetter') const UserUpdater = require('../../../../app/src/Features/User/UserUpdater') const moment = require('moment') const request = require('request-promise-native') const { db } = require('../../../../app/src/infrastructure/mongodb') const { ObjectId } = require('mongodb') let globalUserNum = Settings.test.counterInit class UserHelper { /** * Create UserHelper * @param {object} [user] - Mongo User object */ constructor(user = null) { // used for constructing default emails, etc this.userNum = globalUserNum++ // initialize all internal state properties to defaults this.reset() // set user if passed in, may be null this.user = user } /* sync functions */ /** * Get auditLog, ignore the login * @return {object[]} */ getAuditLogWithoutNoise() { return (this.user.auditLog || []).filter(entry => { return entry.operation !== 'login' }) } /** * Generate default email from unique (per instantiation) user number * @returns {string} email */ getDefaultEmail() { return `test.user.${this.userNum}@example.com` } /** * Generate email, password args object. Default values will be used if * email and password are not passed in args. * @param {object} [userData] * @param {string} [userData.email] email to use * @param {string} [userData.password] password to use * @returns {object} email, password object */ getDefaultEmailPassword(userData = {}) { return { email: this.getDefaultEmail(), password: this.getDefaultPassword(), ...userData, } } /** * Generate default password from unique (per instantiation) user number * @returns {string} password */ getDefaultPassword() { return `New-Password-${this.userNum}!` } /** * (Re)set internal state of UserHelper object. */ reset() { // cached csrf token this._csrfToken = '' // used to store mongo user object once created/loaded this.user = null // cookie jar this.jar = request.jar() // create new request instance this.request = request.defaults({}) // initialize request instance with default options this.setRequestDefaults({ baseUrl: UserHelper.baseUrl(), followRedirect: false, jar: this.jar, resolveWithFullResponse: true, }) } /* Set defaults for request object. Applied over existing defaults. * @param {object} [defaults] */ setRequestDefaults(defaults = {}) { // request-promise instance for making requests this.request = this.request.defaults(defaults) } /** * Make a request for the user and run expectations on the response. * @param {object} [requestOptions] options to pass to request * @param {object} [responseExpectations] expectations: * - {Int} statusCode the expected status code * - {RegEx} message a matcher for the message */ async expectErrorOnRequest(requestOptions, responseExpectations) { let error try { await this.request(requestOptions) } catch (e) { error = e } expect(error).to.exist expect(error.statusCode).to.equal(responseExpectations.statusCode) expect(error.message).to.match(responseExpectations.message) } /* async http api call methods */ /** * Requests csrf token unless already cached in internal state */ async getCsrfToken() { // get csrf token from api and store const response = await this.request.get('/dev/csrf') this._csrfToken = response.body // use csrf token for requests this.setRequestDefaults({ headers: { 'x-csrf-token': this._csrfToken }, }) } /** * Make request to POST /logout * @param {object} [options] options to pass to request * @returns {object} http response */ async logout(options = {}) { // do not throw exception on 302 options.simple = false // post logout const response = await this.request.post('/logout', options) if ( response.statusCode !== 302 || !response.headers.location.includes('/login') ) { throw new Error('logout failed') } // after logout CSRF token becomes invalid this._csrfToken = '' // resolve with http request response return response } /* static sync methods */ /** * Generates base URL from env options * @returns {string} baseUrl */ static baseUrl() { return `http://${process.env.HTTP_TEST_HOST || 'localhost'}:23000` } /* static async instantiation methods */ /** * Create a new user via UserCreator and return UserHelper instance * @param {object} attributes user data for UserCreator * @param {object} options options for UserCreator * @returns {UserHelper} */ static async createUser(attributes = {}) { const userHelper = new UserHelper() attributes = userHelper.getDefaultEmailPassword(attributes) // hash password and delete plaintext if set if (attributes.password) { attributes.hashedPassword = await AuthenticationManager.promises.hashPassword(attributes.password) delete attributes.password } userHelper.user = await UserCreator.promises.createNewUser(attributes) return userHelper } /** * Get existing user via UserGetter and return UserHelper instance. * All args passed to UserGetter.getUser. * @returns {UserHelper} */ static async getUser(...args) { const user = await UserGetter.promises.getUser(...args) if (!user) { throw new Error(`no user found for args: ${JSON.stringify([...args])}`) } return new UserHelper(user) } /** * Update an existing user via UserUpdater and return the updated UserHelper * instance. * All args passed to UserUpdater.getUser. * @returns {UserHelper} */ static async updateUser(userId, update) { // TODO(das7pad): revert back to args pass-through after mongo upgrades const user = await UserUpdater.promises.updateUser( { _id: ObjectId(userId) }, update ) if (!user) { throw new Error(`no user found for args: ${JSON.stringify([userId])}`) } return new UserHelper(user) } /** * Login to existing account via request and return UserHelper instance * @param {object} userData * @param {string} userData.email * @param {string} userData.password * @returns {UserHelper} */ static async loginUser(userData) { if (!userData || !userData.email || !userData.password) { throw new Error('email and password required') } const userHelper = new UserHelper() const loginPath = Settings.enableLegacyLogin ? '/login/legacy' : '/login' await userHelper.getCsrfToken() const response = await userHelper.request.post(loginPath, { json: { 'g-recaptcha-response': 'valid', ...userData, }, }) if (response.statusCode !== 200 || response.body.redir !== '/project') { const error = new Error('login failed') error.response = response throw error } userHelper.user = await UserGetter.promises.getUser({ email: userData.email, }) if (!userHelper.user) { throw new Error(`user not found for email: ${userData.email}`) } await userHelper.getCsrfToken() return userHelper } /** * Check if user is logged in by requesting an endpoint behind authentication. * @returns {Boolean} */ async isLoggedIn() { const response = await this.request.get('/user/sessions', { followRedirect: true, }) return response.request.path === '/user/sessions' } /** * Register new account via request and return UserHelper instance. * If userData is not provided the default email and password will be used. * @param {object} [userData] * @param {string} [userData.email] * @param {string} [userData.password] * @returns {UserHelper} */ static async registerUser(userData, options = {}) { const userHelper = new UserHelper() await userHelper.getCsrfToken() userData = userHelper.getDefaultEmailPassword(userData) options.json = userData const { body } = await userHelper.request.post('/register', options) if (body.message && body.message.type === 'error') { throw new Error(`register api error: ${body.message.text}`) } if (body.redir === '/institutional-login') { throw new Error( `cannot register intitutional email: ${options.json.email}` ) } userHelper.user = await UserGetter.promises.getUser({ email: userData.email, }) if (!userHelper.user) { throw new Error(`user not found for email: ${userData.email}`) } await userHelper.getCsrfToken() return userHelper } async refreshMongoUser() { this.user = await UserGetter.promises.getUser({ _id: this.user._id, }) return this.user } async addEmail(email) { const response = await this.request.post({ form: { email, }, simple: false, uri: '/user/emails', }) expect(response.statusCode).to.equal(204) } async addEmailAndConfirm(userId, email) { await this.addEmail(email) await this.confirmEmail(userId, email) } async changeConfirmationDate(userId, email, date) { const query = { _id: userId, 'emails.email': email, } const update = { $set: { 'emails.$.confirmedAt': date, 'emails.$.reconfirmedAt': date, }, } await UserUpdater.promises.updateUser(query, update) await InstitutionsAPI.promises.addAffiliation(userId, email, { confirmedAt: date, }) } async changeConfirmedToNotificationPeriod( userId, email, maxConfirmationMonths ) { // set a user's confirmation date so that // it is within the notification period to reconfirm // but not older than the last day to reconfirm const notificationDays = Settings.reconfirmNotificationDays if (!notificationDays) return const middleOfNotificationPeriod = Math.ceil(notificationDays / 2) // use the middle of the notification rather than the start or end due to // variations in days in months. const lastDayToReconfirm = moment().subtract( maxConfirmationMonths, 'months' ) const notificationsStart = lastDayToReconfirm .add(middleOfNotificationPeriod, 'days') .toDate() await this.changeConfirmationDate(userId, email, notificationsStart) } async changeConfirmedToPastReconfirmation( userId, email, maxConfirmationMonths ) { // set a user's confirmation date so that they are past the reconfirmation window const date = moment() .subtract(maxConfirmationMonths, 'months') .subtract(1, 'week') .toDate() await this.changeConfirmationDate(userId, email, date) } async confirmEmail(userId, email) { let response // UserHelper.createUser does not create a confirmation token response = await this.request.post({ form: { email, }, simple: false, uri: '/user/emails/resend_confirmation', }) expect(response.statusCode).to.equal(200) const tokenData = await db.tokens .find({ use: 'email_confirmation', 'data.user_id': userId.toString(), 'data.email': email, usedAt: { $exists: false }, }) .next() response = await this.request.post({ form: { token: tokenData.token, }, simple: false, uri: '/user/emails/confirm', }) expect(response.statusCode).to.equal(200) } } module.exports = UserHelper