You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
gristlabs_grist-core/test/gen-server/lib/homedb/UsersManager.ts

1034 lines
39 KiB

import { isAffirmative } from 'app/common/gutil';
import { FullUser, UserProfile } from 'app/common/LoginSessionAPI';
import { ANONYMOUS_USER_EMAIL, EVERYONE_EMAIL, PREVIEWER_EMAIL, UserOptions } from 'app/common/UserAPI';
import { AclRuleOrg } from 'app/gen-server/entity/AclRule';
import { Document } from 'app/gen-server/entity/Document';
import { Group } from 'app/gen-server/entity/Group';
import { Login } from 'app/gen-server/entity/Login';
import { Organization } from 'app/gen-server/entity/Organization';
import { Pref } from 'app/gen-server/entity/Pref';
import { User } from 'app/gen-server/entity/User';
import { Workspace } from 'app/gen-server/entity/Workspace';
import { HomeDBManager } from 'app/gen-server/lib/homedb/HomeDBManager';
import { GetUserOptions, NonGuestGroup, Resource } from 'app/gen-server/lib/homedb/Interfaces';
import { SUPPORT_EMAIL, UsersManager } from 'app/gen-server/lib/homedb/UsersManager';
import { updateDb } from 'app/server/lib/dbUtils';
import { prepareDatabase } from 'test/server/lib/helpers/PrepareDatabase';
import { EnvironmentSnapshot } from 'test/server/testUtils';
import { getDatabase } from 'test/testUtils';
import log from 'app/server/lib/log';
import { assert } from 'chai';
import Sinon, { SinonSandbox, SinonSpy } from 'sinon';
import { EntityManager } from 'typeorm';
import winston from 'winston';
import fs from 'fs/promises';
import { tmpdir } from 'os';
import path from 'path';
import { dereferenceConnection } from 'test/gen-server/seed';
const username = process.env.USER || "nobody";
const tmpDirPrefix = path.join(tmpdir(), `grist_test_${username}_userendpoint_`);
// it is sometimes useful in debugging to turn off automatic cleanup of sqlite databases.
const noCleanup = isAffirmative(process.env.NO_CLEANUP);
describe('UsersManager', function () {
this.timeout('30s');
let tmpDir: string;
before(async function () {
tmpDir = await fs.mkdtemp(tmpDirPrefix);
});
after(async function () {
if (!noCleanup) {
await fs.rm(tmpDir, {recursive: true});
}
});
describe('static method', function () {
/**
* Create a simple iterator of integer starting from 0 which is incremented every time we call next()
*/
function* makeUserIdIterator() {
for (let i = 0; i < 500; i++) {
yield i;
}
}
/**
* Create a table of users.
* @param nbUsers The number of users to create
* @param [userIdIterator=makeIdxIterator()] An iterator used to create users' id,
* which keep track of the increment accross calls.
* Pass your own iterator if you want to call this methods several times and keep the id unique.
* If omitted, create its own iterator that starts from 0.
*/
function makeUsers(nbUsers: number, userIdIterator = makeUserIdIterator()): User[] {
return Array(nbUsers).fill(null).map(() => {
const user = new User();
const itItem = userIdIterator.next();
if (itItem.done) {
throw new Error('Excessive number of users created');
}
user.id = itItem.value;
user.name = `User ${itItem.value}`;
return user;
});
}
/**
* Populate passed resources with members
* @param resources The Resources
* @param nbUsersByResource The number of users to create for each resources (each one is unique)
* @returns The Resources and their respective members
*/
function populateResourcesWithMembers(
resources: Resource[], nbUsersByResource: number, makeResourceGrpName?: (idx: number) => string
): Map<Resource, User[]> {
const membersByResource = new Map<Resource, User[]>();
const idxIterator = makeUserIdIterator();
for (const [idx, resource] of resources.entries()) {
const aclRule = new AclRuleOrg();
const group = new Group();
if (makeResourceGrpName) {
group.name = makeResourceGrpName(idx);
}
const members = makeUsers(nbUsersByResource, idxIterator);
group.memberUsers = members;
aclRule.group = group;
resource.aclRules = [
aclRule
];
membersByResource.set(resource, members);
}
return membersByResource;
}
/**
* Populate a resource with members and return the members
*/
function populateSingleResourceWithMembers(resource: Resource, nbUsers: number) {
const membersByResource = populateResourcesWithMembers([resource], nbUsers);
return membersByResource.get(resource)!;
}
describe('getResourceUsers()', function () {
it('should return all users from a single organization ACL', function () {
const resource = new Organization();
const expectedUsers = populateSingleResourceWithMembers(resource, 5);
const result = UsersManager.getResourceUsers(resource);
assert.deepEqual(result, expectedUsers);
});
it('should return all users from all resources ACL', function () {
const resources: Resource[] = [new Organization(), new Workspace(), new Document()];
const membersByResource = populateResourcesWithMembers(resources, 5);
const result = UsersManager.getResourceUsers(resources);
assert.deepEqual(result, [...membersByResource.values()].flat());
});
it('should deduplicate the results', function () {
const resources: Resource[] = [new Organization(), new Workspace()];
const membersByResource = populateResourcesWithMembers(resources, 1);
const usersList = [...membersByResource.values()];
const expectedResult = usersList.flat();
const duplicateUser = new User();
duplicateUser.id = usersList[1][0].id;
usersList[0].unshift(duplicateUser);
const result = UsersManager.getResourceUsers(resources);
assert.deepEqual(result, expectedResult);
});
it('should return users matching group names from all resources ACL', function () {
const someOrg = new Organization();
const someWorkspace = new Workspace();
const someDoc = new Document();
const resources: Resource[] = [someOrg, someWorkspace, someDoc];
const allGroupNames = ['OrgGrp', 'WorkspaceGrp', 'DocGrp'];
const membersByResource = populateResourcesWithMembers(resources, 5, i => allGroupNames[i]);
const filteredGroupNames = [ 'WorkspaceGrp', 'DocGrp' ];
const result = UsersManager.getResourceUsers(resources, filteredGroupNames);
const expectedResult = [...membersByResource.get(someWorkspace)!, ...membersByResource.get(someDoc)!];
assert.deepEqual(result, expectedResult, 'should discard the users from the first resource');
});
});
describe('getUsersWithRole()', function () {
function makeGroups(groupDefinition: {[k in NonGuestGroup['name']]?: User[] | undefined}){
const entries = Object.entries(groupDefinition) as [NonGuestGroup['name'], User[] | undefined][];
return entries.map(([groupName, users], index) => {
const group = new Group() as NonGuestGroup;
group.id = index;
group.name = groupName;
if (users) {
group.memberUsers = users;
}
return group;
});
}
it('should retrieve no users if passed groups do not contain any', function () {
const groups = makeGroups({
'members': undefined
});
const result = UsersManager.getUsersWithRole(groups);
assert.deepEqual(result, new Map([['members', undefined]] as any));
});
it('should retrieve users of passed groups', function () {
const idxIt = makeUserIdIterator();
const groupsUsersMap = {
'editors': makeUsers(3, idxIt),
'owners': makeUsers(4, idxIt),
'members': makeUsers(5, idxIt),
'viewers': []
};
const groups = makeGroups(groupsUsersMap);
const result = UsersManager.getUsersWithRole(groups);
assert.deepEqual(result, new Map(Object.entries(groupsUsersMap)));
});
it('should exclude users of given IDs', function () {
const groupUsersMap = {
'editors': makeUsers(5),
};
const excludedUsersId = [1, 2, 3, 4];
const expectedUsers = [groupUsersMap.editors[0]];
const groups = makeGroups(groupUsersMap);
const result = UsersManager.getUsersWithRole(groups, excludedUsersId);
assert.deepEqual(result, new Map([ ['editors', expectedUsers] ]));
});
});
});
describe('class method', function () {
const NON_EXISTING_USER_ID = 10001337;
let env: EnvironmentSnapshot;
let db: HomeDBManager;
let sandbox: SinonSandbox;
const uniqueLocalPart = new Set<string>();
/**
* Works around lacks of type narrowing after asserting the value is defined.
* This is fixed in latest versions of @types/chai
*
* FIXME: once upgrading @types/chai to 4.3.17 or higher, remove this function which would not be usefull anymore
*/
function assertExists<T>(value?: T, message?: string): asserts value is T {
assert.exists(value, message);
}
function ensureUnique(localPart: string) {
if (uniqueLocalPart.has(localPart)) {
throw new Error('passed localPart is already used elsewhere');
}
uniqueLocalPart.add(localPart);
return localPart;
}
function createUniqueUser(uniqueEmailLocalPart: string, options?: GetUserOptions) {
ensureUnique(uniqueEmailLocalPart);
return getOrCreateUser(uniqueEmailLocalPart, options);
}
async function getOrCreateUser(localPart: string, options?: GetUserOptions) {
return db.getUserByLogin(makeEmail(localPart), options);
}
function makeEmail(localPart: string) {
return localPart + '@getgrist.com';
}
async function getPersonalOrg(user: User) {
return db.getOrg({userId: user.id}, user.personalOrg.id);
}
async function withDataBase(dbName: string, cb: (db: HomeDBManager) => Promise<void>) {
const database = path.join(tmpDir, dbName + '.db');
const localDb = new HomeDBManager();
await localDb.createNewConnection({ name: dbName, database });
await updateDb(localDb.connection);
try {
await cb(localDb);
} finally {
await localDb.connection.destroy();
// HACK: This is a weird case, we have established a different connection
// but the existing connection is also impacted.
// A found workaround consist in destroying and creating again the connection.
//
// TODO: Check whether using DataSource would help and avoid this hack.
await db.connection.destroy();
await db.createNewConnection();
dereferenceConnection(dbName);
}
}
function disableLoggingLevel<T extends keyof winston.LoggerInstance>(method: T) {
return sandbox.stub(log, method);
}
/**
* Make a user profile.
* @param localPart A unique local part of the email (also used for the other fields).
*/
function makeProfile(localPart: string): UserProfile {
ensureUnique(localPart);
return {
email: makeEmail(localPart),
name: `NewUser ${localPart}`,
connectId: `ConnectId-${localPart}`,
picture: `https://mypic.com/${localPart}.png`
};
}
before(async function () {
if (process.env.TYPEORM_TYPE === "postgres") {
this.skip();
}
env = new EnvironmentSnapshot();
await prepareDatabase(tmpDir);
db = await getDatabase();
});
after(async function () {
env?.restore();
});
beforeEach(function () {
sandbox = Sinon.createSandbox();
});
afterEach(function () {
sandbox.restore();
});
describe('Special User Ids', function () {
const ANONYMOUS_USER_ID = 6;
const PREVIEWER_USER_ID = 7;
const EVERYONE_USER_ID = 8;
const SUPPORT_USER_ID = 5;
it('getAnonymousUserId() should retrieve anonymous user id', function () {
assert.strictEqual(db.getAnonymousUserId(), ANONYMOUS_USER_ID);
});
it('getPreviewerUserId() should retrieve previewer user id', function () {
assert.strictEqual(db.getPreviewerUserId(), PREVIEWER_USER_ID);
});
it("getEveryoneUserId() should retrieve 'everyone' user id", function () {
assert.strictEqual(db.getEveryoneUserId(), EVERYONE_USER_ID);
});
it("getSupportUserId() should retrieve 'support' user id", function () {
assert.strictEqual(db.getSupportUserId(), SUPPORT_USER_ID);
});
describe('Without special id initialization', function () {
it('should throw an error', async function () {
await withDataBase('without-special-ids', async function (localDb) {
assert.throws(() => localDb.getAnonymousUserId(), "'Anonymous' user not available");
assert.throws(() => localDb.getPreviewerUserId(), "'Previewer' user not available");
assert.throws(() => localDb.getEveryoneUserId(), "'Everyone' user not available");
assert.throws(() => localDb.getSupportUserId(), "'Support' user not available");
});
});
});
});
describe('getUserByKey()', function () {
it('should return the user given their API Key', async function () {
const user = await db.getUserByKey('api_key_for_chimpy');
assert.strictEqual(user?.name, 'Chimpy', 'should retrieve Chimpy by their API key');
assert.strictEqual(user?.logins?.[0].email, 'chimpy@getgrist.com');
});
it('should return undefined if no user matches the API key', async function () {
const user = await db.getUserByKey('non-existing API key');
assert.strictEqual(user, undefined);
});
});
describe('getUser()', async function () {
it('should retrieve a user by their ID', async function () {
const user = await db.getUser(db.getSupportUserId());
assertExists(user, 'Should have returned a user');
assert.strictEqual(user.name, 'Support');
assert.strictEqual(user.loginEmail, SUPPORT_EMAIL);
assert.notExists(user.prefs, "should not have retrieved user's prefs");
});
it('should retrieve a user along with their prefs with `includePrefs` set to true', async function () {
const expectedUser = await createUniqueUser('getuser-userwithprefs');
const user = await db.getUser(expectedUser.id, {includePrefs: true});
assertExists(user, "Should have retrieved the user");
assertExists(user.loginEmail);
assert.strictEqual(user.loginEmail, expectedUser.loginEmail);
assert.isTrue(Array.isArray(user.prefs), "should not have retrieved user's prefs");
assert.deepEqual(user.prefs, [{
userId: expectedUser.id,
orgId: expectedUser.personalOrg.id,
prefs: { showGristTour: true } as any
}]);
});
it('should return undefined when the id is not found', async function () {
assert.isUndefined(await db.getUser(NON_EXISTING_USER_ID));
});
});
describe('getFullUser()', function () {
it('should return the support user', async function () {
const supportId = db.getSupportUserId();
const user = await db.getFullUser(supportId);
const expectedResult: FullUser = {
isSupport: true,
email: SUPPORT_EMAIL,
id: supportId,
name: 'Support'
};
assert.deepInclude(user, expectedResult);
assert.notOk(user.anonymous, 'anonymous property should be falsy');
});
it('should return the anonymous user', async function () {
const anonId = db.getAnonymousUserId();
const user = await db.getFullUser(anonId);
const expectedResult: FullUser = {
anonymous: true,
email: ANONYMOUS_USER_EMAIL,
id: anonId,
name: 'Anonymous',
};
assert.deepInclude(user, expectedResult);
assert.notOk(user.isSupport, 'support property should be falsy');
});
it('should reject when user is not found', async function () {
await assert.isRejected(db.getFullUser(NON_EXISTING_USER_ID), "unable to find user");
});
});
describe('makeFullUser()', function () {
const someUserDisplayEmail = 'SomeUser@getgrist.com';
const normalizedSomeUserEmail = 'someuser@getgrist.com';
const someUserLocale = 'en-US';
const SOME_USER_ID = 42;
const prefWithOrg: Pref = {
prefs: {placeholder: 'pref-with-org'},
orgId: 43,
user: new User(),
userId: SOME_USER_ID,
};
const prefWithoutOrg: Pref = {
prefs: {placeholder: 'pref-without-org'},
orgId: null,
user: new User(),
userId: SOME_USER_ID
};
function makeSomeUser() {
return User.create({
id: SOME_USER_ID,
ref: 'some ref',
name: 'some user',
picture: 'https://grist.com/mypic',
options: {
locale: someUserLocale
},
logins: [
Login.create({
userId: SOME_USER_ID,
email: normalizedSomeUserEmail,
displayEmail: someUserDisplayEmail,
}),
],
prefs: [
prefWithOrg,
prefWithoutOrg
]
});
}
it('creates a FullUser from a User entity', function () {
const input = makeSomeUser();
const fullUser = db.makeFullUser(input);
assert.deepEqual(fullUser, {
id: SOME_USER_ID,
email: someUserDisplayEmail,
loginEmail: normalizedSomeUserEmail,
name: input.name,
picture: input.picture,
ref: input.ref,
locale: someUserLocale,
prefs: prefWithoutOrg.prefs
});
});
it('sets `anonymous` property to true for anon@getgrist.com', function () {
const anon = db.getAnonymousUser();
const fullUser = db.makeFullUser(anon);
assert.isTrue(fullUser.anonymous, "`anonymous` property should be set to true");
assert.notOk(fullUser.isSupport, "`isSupport` should be falsy");
});
it('sets `isSupport` property to true for support account', async function () {
const support = await db.getUser(db.getSupportUserId());
const fullUser = db.makeFullUser(support!);
assert.isTrue(fullUser.isSupport, "`isSupport` property should be set to true");
assert.notOk(fullUser.anonymous, "`anonymouse` should be falsy");
});
it('should throw when no displayEmail exist for this user', function () {
const input = makeSomeUser();
input.logins[0].displayEmail = '';
assert.throws(() => db.makeFullUser(input), "unable to find mandatory user email");
input.logins = [];
assert.throws(() => db.makeFullUser(input), "unable to find mandatory user email");
});
});
describe('ensureExternalUser()', function () {
let managerSaveSpy: SinonSpy;
let userSaveSpy: SinonSpy;
beforeEach(function () {
managerSaveSpy = sandbox.spy(EntityManager.prototype, 'save');
userSaveSpy = sandbox.spy(User.prototype, 'save');
});
async function checkUserInfo(profile: UserProfile) {
const user = await db.getExistingUserByLogin(profile.email);
assertExists(user, "the new user should be in database");
assert.deepInclude(user, {
isFirstTimeUser: false,
name: profile.name,
picture: profile.picture
});
assert.exists(user.logins?.[0]);
assert.deepInclude(user.logins[0], {
email: profile.email.toLowerCase(),
displayEmail: profile.email
});
return user;
}
it('should not do anything if the user already exists and is up to date', async function () {
await db.ensureExternalUser({
name: 'Chimpy',
email: 'chimpy@getgrist.com',
});
assert.isFalse(userSaveSpy.called, 'user.save() should not have been called');
assert.isFalse(managerSaveSpy.called, 'manager.save() should not have been called');
});
it('should save an unknown user', async function () {
const profile = makeProfile('ensureExternalUser-saves-an-unknown-user');
await db.ensureExternalUser(profile);
assert.isTrue(userSaveSpy.called, 'user.save() should have been called');
assert.isTrue(managerSaveSpy.called, 'manager.save() should have been called');
await checkUserInfo(profile);
});
it('should update a user if they already exist in database', async function () {
const oldProfile = makeProfile('ensureexternaluser-updates-an-existing-user_old');
await db.ensureExternalUser(oldProfile);
let oldUser = await db.getExistingUserByLogin(oldProfile.email);
assertExists(oldUser);
const newProfile = {
...makeProfile('ensureexternaluser-updates-an-existing-user_new'),
connectId: oldProfile.connectId,
};
await db.ensureExternalUser(newProfile);
oldUser = await db.getExistingUserByLogin(oldProfile.email);
assert.notExists(oldUser, 'we should not retrieve the user given their old email address');
await checkUserInfo(newProfile);
});
it('should normalize email address', async function() {
const profile = makeProfile('ENSUREEXTERNALUSER-NORMALIZES-email-address');
await db.ensureExternalUser(profile);
const user = await checkUserInfo(profile);
assert.equal(user.logins[0].email, profile.email.toLowerCase(), 'the email should be lowercase');
assert.equal(user.logins[0].displayEmail, profile.email, 'the display email should keep the original case');
});
});
describe('updateUser()', function () {
let emitSpy: SinonSpy;
before(function () {
emitSpy = Sinon.spy();
db.on('firstLogin', emitSpy);
});
after(function () {
db.off('firstLogin', emitSpy);
});
afterEach(function () {
emitSpy.resetHistory();
});
function checkNoEventEmitted() {
assert.equal(emitSpy.callCount, 0, 'No event should have been emitted');
}
it('should reject when user is not found', async function () {
disableLoggingLevel('debug');
const promise = db.updateUser(NON_EXISTING_USER_ID, {name: 'foobar'});
await assert.isRejected(promise, 'unable to find user');
checkNoEventEmitted();
});
it('should update a user name', async function () {
const emailLocalPart = 'updateUser-should-update-user-name';
const createdUser = await createUniqueUser(emailLocalPart);
assert.equal(createdUser.name, '');
const userName = 'user name';
await db.updateUser(createdUser.id, {name: userName});
checkNoEventEmitted();
const updatedUser = await getOrCreateUser(emailLocalPart);
assert.equal(updatedUser.name, userName);
});
it('should not emit any event when isFirstTimeUser value has not changed', async function () {
const localPart = 'updateuser-should-not-emit-when-isfirsttimeuser-not-changed';
const createdUser = await createUniqueUser(localPart);
assert.equal(createdUser.isFirstTimeUser, true);
await db.updateUser(createdUser.id, {isFirstTimeUser: true});
checkNoEventEmitted();
});
it('should emit "firstLogin" event when isFirstTimeUser value has been toggled to false', async function () {
const localPart = 'updateuser-emits-firstlogin';
const userName = 'user name';
const newUser = await createUniqueUser(localPart);
assert.equal(newUser.isFirstTimeUser, true);
await db.updateUser(newUser.id, {isFirstTimeUser: false, name: userName});
assert.equal(emitSpy.callCount, 1, '"firstLogin" event should have been emitted');
const fullUserFromEvent = emitSpy.firstCall.args[0];
assertExists(fullUserFromEvent, 'a FullUser object should be passed with the "firstLogin" event');
assert.equal(fullUserFromEvent.name, userName);
assert.equal(fullUserFromEvent.email, makeEmail(localPart));
const updatedUser = await getOrCreateUser(localPart);
assert.equal(updatedUser.isFirstTimeUser, false, 'the user is not considered as being first time user anymore');
});
});
describe('updateUserOptions()', function () {
it('should reject when user is not found', async function () {
disableLoggingLevel('debug');
const promise = db.updateUserOptions(NON_EXISTING_USER_ID, {});
await assert.isRejected(promise, 'unable to find user');
});
it('should update user options', async function () {
const localPart = 'updateuseroptions-updates-user-options';
const createdUser = await createUniqueUser(localPart);
assert.notExists(createdUser.options);
const options: UserOptions = {locale: 'fr', authSubject: 'subject', isConsultant: true, allowGoogleLogin: true};
await db.updateUserOptions(createdUser.id, options);
const updatedUser = await getOrCreateUser(localPart);
assertExists(updatedUser.options);
assert.deepEqual(updatedUser.options, options);
});
});
describe('getExistingUserByLogin()', function () {
it('should return an existing user', async function () {
const retrievedUser = await db.getExistingUserByLogin(PREVIEWER_EMAIL);
assertExists(retrievedUser);
assert.equal(retrievedUser.id, db.getPreviewerUserId());
assert.equal(retrievedUser.name, 'Preview');
});
it('should normalize the passed user email', async function () {
const retrievedUser = await db.getExistingUserByLogin(PREVIEWER_EMAIL.toUpperCase());
assertExists(retrievedUser);
});
it('should return undefined when the user is not found', async function () {
const nonExistingEmail = 'i-dont-exist@getgrist.com';
const retrievedUser = await db.getExistingUserByLogin(nonExistingEmail);
assert.isUndefined(retrievedUser);
});
});
describe('getUserByLogin()', function () {
it('should create a user when none exist with the corresponding email', async function () {
const localPart = ensureUnique('getuserbylogin-creates-user-when-not-already-exists');
const email = makeEmail(localPart);
sandbox.useFakeTimers(42_000);
assert.notExists(await db.getExistingUserByLogin(email));
const user = await db.getUserByLogin(makeEmail(localPart.toUpperCase()));
assert.isTrue(user.isFirstTimeUser, 'should be marked as first time user');
assert.equal(user.loginEmail, email);
assert.equal(user.logins[0].displayEmail, makeEmail(localPart.toUpperCase()));
assert.equal(user.name, '');
// FIXME: why is user.lastConnectionAt actually a string and not a Date?
// FIXME: is it consistent that user.lastConnectionAt updated here and not firstLoginAt?
assert.equal(String(user.lastConnectionAt), '1970-01-01 00:00:42.000');
});
it('should create a personnal organization for the new user', async function () {
const localPart = ensureUnique('getuserbylogin-creates-personnal-org');
const user = await db.getUserByLogin(makeEmail(localPart));
const org = await getPersonalOrg(user);
assertExists(org.data, 'should have retrieved personnal org data');
assert.equal(org.data.name, 'Personal');
});
it('should not create organizations for non-login emails', async function () {
const user = await db.getUserByLogin(EVERYONE_EMAIL);
assert.notExists(user.personalOrg);
});
it('should not update user information when no profile is passed', async function () {
const localPart = ensureUnique('getuserbylogin-does-not-update-without-profile');
const userFirstCall = await db.getUserByLogin(makeEmail(localPart));
const userSecondCall = await db.getUserByLogin(makeEmail(localPart));
assert.deepEqual(userFirstCall, userSecondCall);
});
// FIXME: why using Sinon.useFakeTimers() makes user.lastConnectionAt a string instead of a Date
it.skip('should update lastConnectionAt only for different days', async function () {
const fakeTimer = sandbox.useFakeTimers(0);
const localPart = ensureUnique('getuserbylogin-updates-last_connection_at-for-different-days');
let user = await db.getUserByLogin(makeEmail(localPart));
const epochDateTime = '1970-01-01 00:00:00.000';
assert.equal(String(user.lastConnectionAt), epochDateTime);
await fakeTimer.tickAsync(42_000);
user = await db.getUserByLogin(makeEmail(localPart));
assert.equal(String(user.lastConnectionAt), epochDateTime);
await fakeTimer.tickAsync('1d');
user = await db.getUserByLogin(makeEmail(localPart));
assert.match(String(user.lastConnectionAt), /^1970-01-02/);
});
describe('when passing information to update (using `profile`)', function () {
it('should populate the firstTimeLogin and deduce the name from the email', async function () {
sandbox.useFakeTimers(42_000);
const localPart = ensureUnique('getuserbylogin-with-profile-populates-first_time_login-and-name');
const user = await db.getUserByLogin(makeEmail(localPart), {
profile: {name: '', email: makeEmail(localPart)}
});
assert.equal(user.name, localPart);
// FIXME: why using Sinon.useFakeTimers() makes user.firstLoginAt a string instead of a Date
assert.equal(String(user.firstLoginAt), '1970-01-01 00:00:42.000');
});
it('should populate user with any passed information', async function () {
const localPart = ensureUnique('getuserbylogin-with-profile-populates-user-with-passed-info_OLD');
await db.getUserByLogin(makeEmail(localPart));
const originalNormalizedLoginEmail = makeEmail(localPart.toLowerCase());
const profile = makeProfile(makeEmail('getuserbylogin-with-profile-populates-user-with-passed-info_NEW'));
const userOptions: UserOptions = {authSubject: 'my-auth-subject'};
const updatedUser = await db.getUserByLogin(makeEmail(localPart), { profile, userOptions });
assert.deepInclude(updatedUser, {
name: profile.name,
connectId: profile.connectId,
picture: profile.picture,
});
assert.deepInclude(updatedUser.logins[0], {
displayEmail: profile.email,
email: originalNormalizedLoginEmail,
});
assert.deepInclude(updatedUser.options, {
authSubject: userOptions.authSubject,
});
});
});
});
describe('getUserByLoginWithRetry()', async function () {
async function ensureGetUserByLoginWithRetryWorks(localPart: string) {
const email = makeEmail(localPart);
const user = await db.getUserByLoginWithRetry(email);
assertExists(user);
assert.equal(user.loginEmail, email);
}
function makeQueryFailedError() {
const error = new Error() as any;
error.name = 'QueryFailedError';
error.detail = 'Key (email)=whatever@getgrist.com already exists';
return error;
}
it('should work just like getUserByLogin', async function () {
await ensureGetUserByLoginWithRetryWorks( ensureUnique('getuserbyloginwithretry-works-like-getuserbylogin'));
});
it('should make a second attempt on special error', async function () {
sandbox.stub(UsersManager.prototype, 'getUserByLogin')
.onFirstCall().throws(makeQueryFailedError())
.callThrough();
await ensureGetUserByLoginWithRetryWorks('getuserbyloginwithretry-makes-a-single-retry');
});
it('should reject after 2 attempts', async function () {
const secondError = makeQueryFailedError();
sandbox.stub(UsersManager.prototype, 'getUserByLogin')
.onFirstCall().throws(makeQueryFailedError())
.onSecondCall().throws(secondError)
.callThrough();
const email = makeEmail(ensureUnique('getuserbyloginwithretry-rejects-after-2-attempts'));
const promise = db.getUserByLoginWithRetry(email);
await assert.isRejected(promise);
await promise.catch(err => assert.equal(err, secondError));
});
it('should reject immediately if the error is not a QueryFailedError', async function () {
const errorMsg = 'my error';
sandbox.stub(UsersManager.prototype, 'getUserByLogin')
.onFirstCall().rejects(new Error(errorMsg))
.callThrough();
const email = makeEmail(ensureUnique('getuserbyloginwithretry-rejects-immediately-when-not-queryfailederror'));
const promise = db.getUserByLoginWithRetry(email);
await assert.isRejected(promise, errorMsg);
});
});
describe('deleteUser()', function () {
function userHasPrefs(userId: number, manager: EntityManager) {
return manager.exists(Pref, { where: { userId: userId }});
}
function userHasGroupUsers(userId: number, manager: EntityManager) {
return manager.exists('group_users', { where: {user_id: userId} });
}
async function assertUserStillExistsInDb(userId: number) {
assert.exists(await db.getUser(userId));
}
it('should refuse to delete the account of someone else', async function () {
const userToDelete = await createUniqueUser('deleteuser-refuses-for-someone-else');
const promise = db.deleteUser({userId: 2}, userToDelete.id);
await assert.isRejected(promise, 'not permitted to delete this user');
await assertUserStillExistsInDb(userToDelete.id);
});
it('should refuse to delete a non existing account', async function () {
disableLoggingLevel('debug');
const promise = db.deleteUser({userId: NON_EXISTING_USER_ID}, NON_EXISTING_USER_ID);
await assert.isRejected(promise, 'user not found');
});
it('should refuse to delete the account if the passed name is not matching', async function () {
disableLoggingLevel('debug');
const localPart = 'deleteuser-refuses-if-name-not-matching';
const userToDelete = await createUniqueUser(localPart, {
profile: {
name: 'someone to delete',
email: makeEmail(localPart),
}
});
const promise = db.deleteUser({userId: userToDelete.id}, userToDelete.id, 'wrong name');
await assert.isRejected(promise);
await promise.catch(e => assert.match(e.message, /user name did not match/));
await assertUserStillExistsInDb(userToDelete.id);
});
it('should remove the user and cleanup their info and personal organization', async function () {
const localPart = 'deleteuser-removes-user-and-cleanups-info';
const userToDelete = await createUniqueUser(localPart, {
profile: {
name: 'someone to delete',
email: makeEmail(localPart),
}
});
assertExists(await getPersonalOrg(userToDelete));
await db.connection.transaction(async (manager) => {
assert.isTrue(await userHasGroupUsers(userToDelete.id, manager));
assert.isTrue(await userHasPrefs(userToDelete.id, manager));
});
await db.deleteUser({userId: userToDelete.id}, userToDelete.id);
assert.notExists(await db.getUser(userToDelete.id));
assert.deepEqual(await getPersonalOrg(userToDelete), { errMessage: 'organization not found', status: 404 });
await db.connection.transaction(async (manager) => {
assert.isFalse(await userHasGroupUsers(userToDelete.id, manager));
assert.isFalse(await userHasPrefs(userToDelete.id, manager));
});
});
it("should remove the user when passed name corresponds to the user's name", async function () {
const userName = 'someone to delete';
const localPart = 'deleteuser-removes-user-when-name-matches';
const userToDelete = await createUniqueUser(localPart, {
profile: {
name: userName,
email: makeEmail(localPart),
}
});
const promise = db.deleteUser({userId: userToDelete.id}, userToDelete.id, userName);
await assert.isFulfilled(promise);
});
});
describe('initializeSpecialIds()', function () {
it('should initialize special ids', async function () {
return withDataBase('test-special-ids', async (localDb) => {
const specialAccounts = [
{name: "Support", email: SUPPORT_EMAIL},
{name: "Anonymous", email: ANONYMOUS_USER_EMAIL},
{name: "Preview", email: PREVIEWER_EMAIL},
{name: "Everyone", email: EVERYONE_EMAIL}
];
for (const {email} of specialAccounts) {
assert.notExists(await localDb.getExistingUserByLogin(email));
}
await localDb.initializeSpecialIds();
for (const {name, email} of specialAccounts) {
const res = await localDb.getExistingUserByLogin(email);
assertExists(res);
assert.equal(res.name, name);
}
});
});
});
describe('completeProfiles()', function () {
it('should return an empty array if no profiles are provided', async function () {
const res = await db.completeProfiles([]);
assert.deepEqual(res, []);
});
it("should complete a single user profile with looking by normalized address", async function () {
const localPart = ensureUnique('completeprofiles-with-single-profile');
const email = makeEmail(localPart);
const emailUpperCase = email.toUpperCase();
const profile = {
name: 'completeprofiles-with-single-profile-username',
email: makeEmail(localPart),
picture: 'https://mypic.com/me.png',
};
const someLocale = 'fr-FR';
const userCreated = await getOrCreateUser(localPart, { profile });
await db.updateUserOptions(userCreated.id, {locale: someLocale});
const res = await db.completeProfiles([{name: 'whatever', email: emailUpperCase}]);
assert.deepEqual(res, [{
...profile,
id: userCreated.id,
locale: someLocale,
anonymous: false
}]);
});
it('should complete several user profiles', async function () {
const localPartPrefix = ensureUnique('completeprofiles-with-several-profiles');
const seq = Array(10).fill(null).map((_, i) => i+1);
const localParts = seq.map(i => `${localPartPrefix}_${i}`);
const usersCreated = await Promise.all(
localParts.map(localPart => getOrCreateUser(localPart))
);
const res = await db.completeProfiles(
localParts.map(
localPart => ({name: 'whatever', email: makeEmail(localPart)})
)
);
assert.lengthOf(res, localParts.length);
for (const [index, localPart] of localParts.entries()) {
assert.deepInclude(res[index], {
id: usersCreated[index].id,
email: makeEmail(localPart),
});
}
});
});
});
});