mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
16ebc32611
Context HomeDBManager lacks of direct tests, which makes hard to make rework or refactorations. Proposed solution Specifically here, I introduce tests which call exposed UsersManager methods directly and check their result. Also: I removed updateUserName which seems to me useless (updateUser does the same work) Taking a look at the getUserByLogin methods, it appears that Typescirpt infers it returns a Promise<User|null> while in no case it may resolve a nullish value, therefore I have forced to return a Promise<User> and have changed the call sites to reflect the change. Related issues I make this change for then working on #870
1034 lines
39 KiB
TypeScript
1034 lines
39 KiB
TypeScript
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),
|
|
});
|
|
}
|
|
});
|
|
});
|
|
});
|
|
});
|