Add tests for UsersManager (#1149)

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
This commit is contained in:
Florent 2024-09-05 22:30:04 +02:00 committed by GitHub
parent 356f0b423e
commit 16ebc32611
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 1217 additions and 169 deletions

View File

@ -429,7 +429,7 @@ export class ApiServer {
throw new ApiError('Name expected in the body', 400); throw new ApiError('Name expected in the body', 400);
} }
const name = req.body.name; const name = req.body.name;
await this._dbManager.updateUserName(userId, name); await this._dbManager.updateUser(userId, { name });
res.sendStatus(200); res.sendStatus(200);
})); }));

View File

@ -56,7 +56,7 @@ import {
readJson readJson
} from 'app/gen-server/sqlUtils'; } from 'app/gen-server/sqlUtils';
import {appSettings} from 'app/server/lib/AppSettings'; import {appSettings} from 'app/server/lib/AppSettings';
import {getOrCreateConnection} from 'app/server/lib/dbUtils'; import {createNewConnection, getOrCreateConnection} from 'app/server/lib/dbUtils';
import {makeId} from 'app/server/lib/idUtils'; import {makeId} from 'app/server/lib/idUtils';
import log from 'app/server/lib/log'; import log from 'app/server/lib/log';
import {Permit} from 'app/server/lib/Permit'; import {Permit} from 'app/server/lib/Permit';
@ -70,6 +70,7 @@ import {
Brackets, Brackets,
Connection, Connection,
DatabaseType, DatabaseType,
DataSourceOptions,
EntityManager, EntityManager,
ObjectLiteral, ObjectLiteral,
SelectQueryBuilder, SelectQueryBuilder,
@ -248,7 +249,6 @@ export type BillingOptions = Partial<Pick<BillingAccount,
export class HomeDBManager extends EventEmitter { export class HomeDBManager extends EventEmitter {
private _usersManager = new UsersManager(this, this._runInTransaction.bind(this)); private _usersManager = new UsersManager(this, this._runInTransaction.bind(this));
private _connection: Connection; private _connection: Connection;
private _dbType: DatabaseType;
private _exampleWorkspaceId: number; private _exampleWorkspaceId: number;
private _exampleOrgId: number; private _exampleOrgId: number;
private _idPrefix: string = ""; // Place this before ids in subdomains, used in routing to private _idPrefix: string = ""; // Place this before ids in subdomains, used in routing to
@ -258,6 +258,10 @@ export class HomeDBManager extends EventEmitter {
// In restricted mode, documents should be read-only. // In restricted mode, documents should be read-only.
private _restrictedMode: boolean = false; private _restrictedMode: boolean = false;
private get _dbType(): DatabaseType {
return this._connection.driver.options.type;
}
/** /**
* Five aclRules, each with one group (with the names 'owners', 'editors', 'viewers', * Five aclRules, each with one group (with the names 'owners', 'editors', 'viewers',
* 'guests', and 'members') are created by default on every new entity (Organization, * 'guests', and 'members') are created by default on every new entity (Organization,
@ -348,7 +352,10 @@ export class HomeDBManager extends EventEmitter {
public async connect(): Promise<void> { public async connect(): Promise<void> {
this._connection = await getOrCreateConnection(); this._connection = await getOrCreateConnection();
this._dbType = this._connection.driver.options.type; }
public async createNewConnection(overrideConf?: Partial<DataSourceOptions>): Promise<void> {
this._connection = await createNewConnection(overrideConf);
} }
// make sure special users and workspaces are available // make sure special users and workspaces are available
@ -461,10 +468,6 @@ export class HomeDBManager extends EventEmitter {
} }
} }
public async updateUserName(userId: number, name: string) {
return this._usersManager.updateUserName(userId, name);
}
public async updateUserOptions(userId: number, props: Partial<UserOptions>) { public async updateUserOptions(userId: number, props: Partial<UserOptions>) {
return this._usersManager.updateUserOptions(userId, props); return this._usersManager.updateUserOptions(userId, props);
} }
@ -472,14 +475,14 @@ export class HomeDBManager extends EventEmitter {
/** /**
* @see UsersManager.prototype.getUserByLoginWithRetry * @see UsersManager.prototype.getUserByLoginWithRetry
*/ */
public async getUserByLoginWithRetry(email: string, options: GetUserOptions = {}): Promise<User|undefined> { public async getUserByLoginWithRetry(email: string, options: GetUserOptions = {}): Promise<User> {
return this._usersManager.getUserByLoginWithRetry(email, options); return this._usersManager.getUserByLoginWithRetry(email, options);
} }
/** /**
* @see UsersManager.prototype.getUserByLogin * @see UsersManager.prototype.getUserByLogin
*/ */
public async getUserByLogin(email: string, options: GetUserOptions = {}): Promise<User|undefined> { public async getUserByLogin(email: string, options: GetUserOptions = {}): Promise<User> {
return this._usersManager.getUserByLogin(email, options); return this._usersManager.getUserByLogin(email, options);
} }
@ -4362,7 +4365,6 @@ export class HomeDBManager extends EventEmitter {
}); });
return verifyEntity(orgQuery); return verifyEntity(orgQuery);
} }
} }
// Return a QueryResult reflecting the output of a query builder. // Return a QueryResult reflecting the output of a query builder.

View File

@ -34,7 +34,7 @@ export type NonGuestGroup = Group & { name: roles.NonGuestRole };
export type Resource = Organization|Workspace|Document; export type Resource = Organization|Workspace|Document;
export type RunInTransaction = ( export type RunInTransaction = <T>(
transaction: EntityManager|undefined, transaction: EntityManager|undefined,
op: ((manager: EntityManager) => Promise<any>) op: ((manager: EntityManager) => Promise<T>)
) => Promise<any>; ) => Promise<T>;

View File

@ -97,7 +97,7 @@ export class UsersManager {
public async testClearUserPrefs(emails: string[]) { public async testClearUserPrefs(emails: string[]) {
return await this._connection.transaction(async manager => { return await this._connection.transaction(async manager => {
for (const email of emails) { for (const email of emails) {
const user = await this.getUserByLogin(email, {manager}); const user = await this.getExistingUserByLogin(email, manager);
if (user) { if (user) {
await manager.delete(Pref, {userId: user.id}); await manager.delete(Pref, {userId: user.id});
} }
@ -116,7 +116,7 @@ export class UsersManager {
*/ */
public getAnonymousUserId(): number { public getAnonymousUserId(): number {
const id = this._specialUserIds[ANONYMOUS_USER_EMAIL]; const id = this._specialUserIds[ANONYMOUS_USER_EMAIL];
if (!id) { throw new Error("Anonymous user not available"); } if (!id) { throw new Error("'Anonymous' user not available"); }
return id; return id;
} }
@ -125,7 +125,7 @@ export class UsersManager {
*/ */
public getPreviewerUserId(): number { public getPreviewerUserId(): number {
const id = this._specialUserIds[PREVIEWER_EMAIL]; const id = this._specialUserIds[PREVIEWER_EMAIL];
if (!id) { throw new Error("Previewer user not available"); } if (!id) { throw new Error("'Previewer' user not available"); }
return id; return id;
} }
@ -134,7 +134,7 @@ export class UsersManager {
*/ */
public getEveryoneUserId(): number { public getEveryoneUserId(): number {
const id = this._specialUserIds[EVERYONE_EMAIL]; const id = this._specialUserIds[EVERYONE_EMAIL];
if (!id) { throw new Error("'everyone' user not available"); } if (!id) { throw new Error("'Everyone' user not available"); }
return id; return id;
} }
@ -143,7 +143,7 @@ export class UsersManager {
*/ */
public getSupportUserId(): number { public getSupportUserId(): number {
const id = this._specialUserIds[SUPPORT_EMAIL]; const id = this._specialUserIds[SUPPORT_EMAIL];
if (!id) { throw new Error("'support' user not available"); } if (!id) { throw new Error("'Support' user not available"); }
return id; return id;
} }
@ -221,9 +221,6 @@ export class UsersManager {
profile, profile,
manager manager
}); });
if (!newUser) {
throw new ApiError("Unable to create user", 500);
}
// No need to survey this user. // No need to survey this user.
newUser.isFirstTimeUser = false; newUser.isFirstTimeUser = false;
await newUser.save(); await newUser.save();
@ -286,13 +283,7 @@ export class UsersManager {
return { user, isWelcomed }; return { user, isWelcomed };
} }
public async updateUserName(userId: number, name: string) { // TODO: rather use the updateUser() method, if that makes sense?
const user = await User.findOne({where: {id: userId}});
if (!user) { throw new ApiError("unable to find user", 400); }
user.name = name;
await user.save();
}
public async updateUserOptions(userId: number, props: Partial<UserOptions>) { public async updateUserOptions(userId: number, props: Partial<UserOptions>) {
const user = await User.findOne({where: {id: userId}}); const user = await User.findOne({where: {id: userId}});
if (!user) { throw new ApiError("unable to find user", 400); } if (!user) { throw new ApiError("unable to find user", 400); }
@ -321,7 +312,7 @@ export class UsersManager {
// for an email key conflict failure. This is in case our transaction conflicts with a peer // for an email key conflict failure. This is in case our transaction conflicts with a peer
// doing the same thing. This is quite likely if the first page visited by a previously // doing the same thing. This is quite likely if the first page visited by a previously
// unseen user fires off multiple api calls. // unseen user fires off multiple api calls.
public async getUserByLoginWithRetry(email: string, options: GetUserOptions = {}): Promise<User|undefined> { public async getUserByLoginWithRetry(email: string, options: GetUserOptions = {}): Promise<User> {
try { try {
return await this.getUserByLogin(email, options); return await this.getUserByLogin(email, options);
} catch (e) { } catch (e) {
@ -361,10 +352,10 @@ export class UsersManager {
* unset/outdated fields of an existing record. * unset/outdated fields of an existing record.
* *
*/ */
public async getUserByLogin(email: string, options: GetUserOptions = {}): Promise<User|undefined> { public async getUserByLogin(email: string, options: GetUserOptions = {}) {
const {manager: transaction, profile, userOptions} = options; const {manager: transaction, profile, userOptions} = options;
const normalizedEmail = normalizeEmail(email); const normalizedEmail = normalizeEmail(email);
const userByLogin = await this._runInTransaction(transaction, async manager => { return await this._runInTransaction(transaction, async manager => {
let needUpdate = false; let needUpdate = false;
const userQuery = manager.createQueryBuilder() const userQuery = manager.createQueryBuilder()
.select('user') .select('user')
@ -473,9 +464,8 @@ export class UsersManager {
// In principle this could be optimized, but this is simpler to maintain. // In principle this could be optimized, but this is simpler to maintain.
user = await userQuery.getOne(); user = await userQuery.getOne();
} }
return user; return user!;
}); });
return userByLogin;
} }
/** /**
@ -520,6 +510,63 @@ export class UsersManager {
}; };
} }
public async initializeSpecialIds(): Promise<void> {
await this._maybeCreateSpecialUserId({
email: ANONYMOUS_USER_EMAIL,
name: "Anonymous"
});
await this._maybeCreateSpecialUserId({
email: PREVIEWER_EMAIL,
name: "Preview"
});
await this._maybeCreateSpecialUserId({
email: EVERYONE_EMAIL,
name: "Everyone"
});
await this._maybeCreateSpecialUserId({
email: SUPPORT_EMAIL,
name: "Support"
});
}
/**
*
* Take a list of user profiles coming from the client's session, correlate
* them with Users and Logins in the database, and construct full profiles
* with user ids, standardized display emails, pictures, and anonymous flags.
*
*/
public async completeProfiles(profiles: UserProfile[]): Promise<FullUser[]> {
if (profiles.length === 0) { return []; }
const qb = this._connection.createQueryBuilder()
.select('logins')
.from(Login, 'logins')
.leftJoinAndSelect('logins.user', 'user')
.where('logins.email in (:...emails)', {emails: profiles.map(profile => normalizeEmail(profile.email))});
const completedProfiles: {[email: string]: FullUser} = {};
for (const login of await qb.getMany()) {
completedProfiles[login.email] = {
id: login.user.id,
email: login.displayEmail,
name: login.user.name,
picture: login.user.picture,
anonymous: login.user.id === this.getAnonymousUserId(),
locale: login.user.options?.locale
};
}
return profiles.map(profile => completedProfiles[normalizeEmail(profile.email)])
.filter(fullProfile => fullProfile);
}
/**
* ==================================
*
* Below methods are public but not exposed by HomeDBManager
*
* They are meant to be used internally (i.e. by homedb/ modules)
*
*/
// Looks up the emails in the permission delta and adds them to the users map in // Looks up the emails in the permission delta and adds them to the users map in
// the delta object. // the delta object.
// Returns a QueryResult based on the validity of the passed in PermissionDelta object. // Returns a QueryResult based on the validity of the passed in PermissionDelta object.
@ -589,25 +636,6 @@ export class UsersManager {
}; };
} }
public async initializeSpecialIds(): Promise<void> {
await this._maybeCreateSpecialUserId({
email: ANONYMOUS_USER_EMAIL,
name: "Anonymous"
});
await this._maybeCreateSpecialUserId({
email: PREVIEWER_EMAIL,
name: "Preview"
});
await this._maybeCreateSpecialUserId({
email: EVERYONE_EMAIL,
name: "Everyone"
});
await this._maybeCreateSpecialUserId({
email: SUPPORT_EMAIL,
name: "Support"
});
}
/** /**
* Check for anonymous user, either encoded directly as an id, or as a singular * Check for anonymous user, either encoded directly as an id, or as a singular
* profile (this case arises during processing of the session/access/all endpoint * profile (this case arises during processing of the session/access/all endpoint
@ -684,34 +712,6 @@ export class UsersManager {
return members; return members;
} }
/**
*
* Take a list of user profiles coming from the client's session, correlate
* them with Users and Logins in the database, and construct full profiles
* with user ids, standardized display emails, pictures, and anonymous flags.
*
*/
public async completeProfiles(profiles: UserProfile[]): Promise<FullUser[]> {
if (profiles.length === 0) { return []; }
const qb = this._connection.createQueryBuilder()
.select('logins')
.from(Login, 'logins')
.leftJoinAndSelect('logins.user', 'user')
.where('logins.email in (:...emails)', {emails: profiles.map(profile => normalizeEmail(profile.email))});
const completedProfiles: {[email: string]: FullUser} = {};
for (const login of await qb.getMany()) {
completedProfiles[login.email] = {
id: login.user.id,
email: login.displayEmail,
name: login.user.name,
picture: login.user.picture,
anonymous: login.user.id === this.getAnonymousUserId(),
locale: login.user.options?.locale
};
}
return profiles.map(profile => completedProfiles[normalizeEmail(profile.email)])
.filter(profile => profile);
}
// For the moment only the support user can add both everyone@ and anon@ to a // For the moment only the support user can add both everyone@ and anon@ to a
// resource, since that allows spam. TODO: enhance or remove. // resource, since that allows spam. TODO: enhance or remove.
@ -735,7 +735,7 @@ export class UsersManager {
// user if a bunch of servers start simultaneously and the user doesn't exist // user if a bunch of servers start simultaneously and the user doesn't exist
// yet. // yet.
const user = await this.getUserByLoginWithRetry(profile.email, {profile}); const user = await this.getUserByLoginWithRetry(profile.email, {profile});
if (user) { id = this._specialUserIds[profile.email] = user.id; } id = this._specialUserIds[profile.email] = user.id;
} }
if (!id) { throw new Error(`Could not find or create user ${profile.email}`); } if (!id) { throw new Error(`Could not find or create user ${profile.email}`); }
return id; return id;

View File

@ -166,10 +166,6 @@ export function addSiteCommand(program: commander.Command,
const profile = {email, name: email}; const profile = {email, name: email};
const db = await getHomeDBManager(); const db = await getHomeDBManager();
const user = await db.getUserByLogin(email, {profile}); const user = await db.getUserByLogin(email, {profile});
if (!user) {
// This should not happen.
throw new Error('failed to create user');
}
db.unwrapQueryResult(await db.addOrg(user, { db.unwrapQueryResult(await db.addOrg(user, {
name: domain, name: domain,
domain, domain,

View File

@ -25,10 +25,8 @@ export async function getTestLoginSystem(): Promise<GristLoginSystem> {
if (process.env.TEST_SUPPORT_API_KEY) { if (process.env.TEST_SUPPORT_API_KEY) {
const dbManager = gristServer.getHomeDBManager(); const dbManager = gristServer.getHomeDBManager();
const user = await dbManager.getUserByLogin(SUPPORT_EMAIL); const user = await dbManager.getUserByLogin(SUPPORT_EMAIL);
if (user) { user.apiKey = process.env.TEST_SUPPORT_API_KEY;
user.apiKey = process.env.TEST_SUPPORT_API_KEY; await user.save();
await user.save();
}
} }
return "test-login"; return "test-login";
}, },

View File

@ -45,38 +45,53 @@ export async function updateDb(connection?: Connection) {
await synchronizeProducts(connection, true); await synchronizeProducts(connection, true);
} }
export function getConnectionName() {
return process.env.TYPEORM_NAME || 'default';
}
/** /**
* Get a connection to db if one exists, or create one. Serialized to * Get a connection to db if one exists, or create one. Serialized to
* avoid duplication. * avoid duplication.
*/ */
const connectionMutex = new Mutex(); const connectionMutex = new Mutex();
async function buildConnection(overrideConf?: Partial<DataSourceOptions>) {
const settings = getTypeORMSettings(overrideConf);
const connection = await createConnection(settings);
// When using Sqlite, set a busy timeout of 3s to tolerate a little
// interference from connections made by tests. Logging doesn't show
// any particularly slow queries, but bad luck is possible.
// This doesn't affect when Postgres is in use. It also doesn't have
// any impact when there is a single connection to the db, as is the
// case when Grist is run as a single process.
if (connection.driver.options.type === 'sqlite') {
await connection.query('PRAGMA busy_timeout = 3000');
}
return connection;
}
export async function getOrCreateConnection(): Promise<Connection> { export async function getOrCreateConnection(): Promise<Connection> {
return connectionMutex.runExclusive(async() => { return connectionMutex.runExclusive(async () => {
try { try {
// If multiple servers are started within the same process, we // If multiple servers are started within the same process, we
// share the database connection. This saves locking trouble // share the database connection. This saves locking trouble
// with Sqlite. // with Sqlite.
const connection = getConnection(); return getConnection(getConnectionName());
return connection;
} catch (e) { } catch (e) {
if (!String(e).match(/ConnectionNotFoundError/)) { if (!String(e).match(/ConnectionNotFoundError/)) {
throw e; throw e;
} }
const connection = await createConnection(getTypeORMSettings()); return buildConnection();
// When using Sqlite, set a busy timeout of 3s to tolerate a little
// interference from connections made by tests. Logging doesn't show
// any particularly slow queries, but bad luck is possible.
// This doesn't affect when Postgres is in use. It also doesn't have
// any impact when there is a single connection to the db, as is the
// case when Grist is run as a single process.
if (connection.driver.options.type === 'sqlite') {
await connection.query('PRAGMA busy_timeout = 3000');
}
return connection;
} }
}); });
} }
export async function createNewConnection(overrideConf?: Partial<DataSourceOptions>): Promise<Connection> {
return connectionMutex.runExclusive(async () => {
return buildConnection(overrideConf);
});
}
export async function runMigrations(connection: Connection) { export async function runMigrations(connection: Connection) {
// on SQLite, migrations fail if we don't temporarily disable foreign key // on SQLite, migrations fail if we don't temporarily disable foreign key
// constraint checking. This is because for sqlite typeorm copies each // constraint checking. This is because for sqlite typeorm copies each
@ -103,7 +118,7 @@ export async function undoLastMigration(connection: Connection) {
// Replace the old janky ormconfig.js file, which was always a source of // Replace the old janky ormconfig.js file, which was always a source of
// pain to use since it wasn't properly integrated into the typescript // pain to use since it wasn't properly integrated into the typescript
// project. // project.
export function getTypeORMSettings(): DataSourceOptions { export function getTypeORMSettings(overrideConf?: Partial<DataSourceOptions>): DataSourceOptions {
// If we have a redis server available, tell typeorm. Then any queries built with // If we have a redis server available, tell typeorm. Then any queries built with
// .cache() called on them will be cached via redis. // .cache() called on them will be cached via redis.
// We use a separate environment variable for the moment so that we don't have to // We use a separate environment variable for the moment so that we don't have to
@ -120,7 +135,7 @@ export function getTypeORMSettings(): DataSourceOptions {
} : undefined; } : undefined;
return { return {
"name": process.env.TYPEORM_NAME || "default", "name": getConnectionName(),
"type": (process.env.TYPEORM_TYPE as any) || "sqlite", // officially, TYPEORM_CONNECTION - "type": (process.env.TYPEORM_TYPE as any) || "sqlite", // officially, TYPEORM_CONNECTION -
// but if we use that, this file will never // but if we use that, this file will never
// be read, and we can't configure // be read, and we can't configure
@ -144,5 +159,6 @@ export function getTypeORMSettings(): DataSourceOptions {
], ],
...JSON.parse(process.env.TYPEORM_EXTRA || "{}"), ...JSON.parse(process.env.TYPEORM_EXTRA || "{}"),
...cache, ...cache,
...overrideConf,
}; };
} }

View File

@ -79,10 +79,6 @@ async function setupDb() {
} }
const profile = {email, name: email}; const profile = {email, name: email};
const user = await db.getUserByLogin(email, {profile}); const user = await db.getUserByLogin(email, {profile});
if (!user) {
// This should not happen.
throw new Error('failed to create GRIST_DEFAULT_EMAIL user');
}
db.unwrapQueryResult(await db.addOrg(user, { db.unwrapQueryResult(await db.addOrg(user, {
name: org, name: org,
domain: org, domain: org,

View File

@ -49,9 +49,9 @@ describe('ApiServer', function() {
homeUrl = await server.start(['home', 'docs']); homeUrl = await server.start(['home', 'docs']);
dbManager = server.dbManager; dbManager = server.dbManager;
chimpyRef = await dbManager.getUserByLogin(chimpyEmail).then((user) => user!.ref); chimpyRef = await dbManager.getUserByLogin(chimpyEmail).then((user) => user.ref);
kiwiRef = await dbManager.getUserByLogin(kiwiEmail).then((user) => user!.ref); kiwiRef = await dbManager.getUserByLogin(kiwiEmail).then((user) => user.ref);
charonRef = await dbManager.getUserByLogin(charonEmail).then((user) => user!.ref); charonRef = await dbManager.getUserByLogin(charonEmail).then((user) => user.ref);
// Listen to user count updates and add them to an array. // Listen to user count updates and add them to an array.
dbManager.on('userChange', ({org, countBefore, countAfter}: UserChange) => { dbManager.on('userChange', ({org, countBefore, countAfter}: UserChange) => {
@ -2070,8 +2070,7 @@ describe('ApiServer', function() {
// create a new user // create a new user
const profile = {email: 'meep@getgrist.com', name: 'Meep'}; const profile = {email: 'meep@getgrist.com', name: 'Meep'};
const user = await dbManager.getUserByLogin('meep@getgrist.com', {profile}); const user = await dbManager.getUserByLogin('meep@getgrist.com', {profile});
assert(user); const userId = user.id;
const userId = user!.id;
// set up an api key // set up an api key
await dbManager.connection.query("update users set api_key = 'api_key_for_meep' where id = $1", [userId]); await dbManager.connection.query("update users set api_key = 'api_key_for_meep' where id = $1", [userId]);
@ -2111,11 +2110,10 @@ describe('ApiServer', function() {
const userBlank = await dbManager.getUserByLogin('blank@getgrist.com', const userBlank = await dbManager.getUserByLogin('blank@getgrist.com',
{profile: {email: 'blank@getgrist.com', {profile: {email: 'blank@getgrist.com',
name: ''}}); name: ''}});
assert(userBlank); await dbManager.connection.query("update users set api_key = 'api_key_for_blank' where id = $1", [userBlank.id]);
await dbManager.connection.query("update users set api_key = 'api_key_for_blank' where id = $1", [userBlank!.id]);
// check that user can delete themselves // check that user can delete themselves
resp = await axios.delete(`${homeUrl}/api/users/${userBlank!.id}`, resp = await axios.delete(`${homeUrl}/api/users/${userBlank.id}`,
{data: {name: ""}, ...configForUser("blank")}); {data: {name: ""}, ...configForUser("blank")});
assert.equal(resp.status, 200); assert.equal(resp.status, 200);

View File

@ -61,9 +61,9 @@ describe('ApiServerAccess', function() {
} }
); );
dbManager = server.dbManager; dbManager = server.dbManager;
chimpyRef = await dbManager.getUserByLogin(chimpyEmail).then((user) => user!.ref); chimpyRef = await dbManager.getUserByLogin(chimpyEmail).then((user) => user.ref);
kiwiRef = await dbManager.getUserByLogin(kiwiEmail).then((user) => user!.ref); kiwiRef = await dbManager.getUserByLogin(kiwiEmail).then((user) => user.ref);
charonRef = await dbManager.getUserByLogin(charonEmail).then((user) => user!.ref); charonRef = await dbManager.getUserByLogin(charonEmail).then((user) => user.ref);
// Listen to user count updates and add them to an array. // Listen to user count updates and add them to an array.
dbManager.on('userChange', ({org, countBefore, countAfter}: UserChange) => { dbManager.on('userChange', ({org, countBefore, countAfter}: UserChange) => {
if (countBefore === countAfter) { return; } if (countBefore === countAfter) { return; }

View File

@ -32,7 +32,7 @@ describe('ApiServerBugs', function() {
server = new TestServer(this); server = new TestServer(this);
homeUrl = await server.start(); homeUrl = await server.start();
dbManager = server.dbManager; dbManager = server.dbManager;
userRef = (email) => server.dbManager.getUserByLogin(email).then((user) => user!.ref); userRef = (email) => server.dbManager.getUserByLogin(email).then((user) => user.ref);
}); });
after(async function() { after(async function() {

View File

@ -33,14 +33,14 @@ describe('HomeDBManager', function() {
it('can find existing user by email', async function() { it('can find existing user by email', async function() {
const user = await home.getUserByLogin('chimpy@getgrist.com'); const user = await home.getUserByLogin('chimpy@getgrist.com');
assert.equal(user!.name, 'Chimpy'); assert.equal(user.name, 'Chimpy');
}); });
it('can create new user by email, with personal org', async function() { it('can create new user by email, with personal org', async function() {
const profile = {email: 'unseen@getgrist.com', name: 'Unseen'}; const profile = {email: 'unseen@getgrist.com', name: 'Unseen'};
const user = await home.getUserByLogin('unseen@getgrist.com', {profile}); const user = await home.getUserByLogin('unseen@getgrist.com', {profile});
assert.equal(user!.name, 'Unseen'); assert.equal(user.name, 'Unseen');
const orgs = await home.getOrgs(user!.id, null); const orgs = await home.getOrgs(user.id, null);
assert.isAtLeast(orgs.data!.length, 1); assert.isAtLeast(orgs.data!.length, 1);
assert.equal(orgs.data![0].name, 'Personal'); assert.equal(orgs.data![0].name, 'Personal');
assert.equal(orgs.data![0].owner.name, 'Unseen'); assert.equal(orgs.data![0].owner.name, 'Unseen');
@ -65,37 +65,37 @@ describe('HomeDBManager', function() {
// log in without a name // log in without a name
let user = await home.getUserByLogin('unseen2@getgrist.com'); let user = await home.getUserByLogin('unseen2@getgrist.com');
// name is blank // name is blank
assert.equal(user!.name, ''); assert.equal(user.name, '');
// log in with a name // log in with a name
const profile: UserProfile = {email: 'unseen2@getgrist.com', name: 'Unseen2'}; const profile: UserProfile = {email: 'unseen2@getgrist.com', name: 'Unseen2'};
user = await home.getUserByLogin('unseen2@getgrist.com', {profile}); user = await home.getUserByLogin('unseen2@getgrist.com', {profile});
// name is now set // name is now set
assert.equal(user!.name, 'Unseen2'); assert.equal(user.name, 'Unseen2');
// log in without a name // log in without a name
user = await home.getUserByLogin('unseen2@getgrist.com'); user = await home.getUserByLogin('unseen2@getgrist.com');
// name is still set // name is still set
assert.equal(user!.name, 'Unseen2'); assert.equal(user.name, 'Unseen2');
// no picture yet // no picture yet
assert.equal(user!.picture, null); assert.equal(user.picture, null);
// log in with picture link // log in with picture link
profile.picture = 'http://picture.pic'; profile.picture = 'http://picture.pic';
user = await home.getUserByLogin('unseen2@getgrist.com', {profile}); user = await home.getUserByLogin('unseen2@getgrist.com', {profile});
// now should have a picture link // now should have a picture link
assert.equal(user!.picture, 'http://picture.pic'); assert.equal(user.picture, 'http://picture.pic');
// log in without picture // log in without picture
user = await home.getUserByLogin('unseen2@getgrist.com'); user = await home.getUserByLogin('unseen2@getgrist.com');
// should still have picture link // should still have picture link
assert.equal(user!.picture, 'http://picture.pic'); assert.equal(user.picture, 'http://picture.pic');
}); });
it('can add an org', async function() { it('can add an org', async function() {
const user = await home.getUserByLogin('chimpy@getgrist.com'); const user = await home.getUserByLogin('chimpy@getgrist.com');
const orgId = (await home.addOrg(user!, {name: 'NewOrg', domain: 'novel-org'}, teamOptions)).data!; const orgId = (await home.addOrg(user, {name: 'NewOrg', domain: 'novel-org'}, teamOptions)).data!;
const org = await home.getOrg({userId: user!.id}, orgId); const org = await home.getOrg({userId: user.id}, orgId);
assert.equal(org.data!.name, 'NewOrg'); assert.equal(org.data!.name, 'NewOrg');
assert.equal(org.data!.domain, 'novel-org'); assert.equal(org.data!.domain, 'novel-org');
assert.equal(org.data!.billingAccount.product.name, TEAM_PLAN); assert.equal(org.data!.billingAccount.product.name, TEAM_PLAN);
await home.deleteOrg({userId: user!.id}, orgId); await home.deleteOrg({userId: user.id}, orgId);
}); });
it('creates default plan if defined', async function() { it('creates default plan if defined', async function() {
@ -104,28 +104,28 @@ describe('HomeDBManager', function() {
try { try {
// Set the default product to be the free plan. // Set the default product to be the free plan.
process.env.GRIST_DEFAULT_PRODUCT = FREE_PLAN; process.env.GRIST_DEFAULT_PRODUCT = FREE_PLAN;
let orgId = (await home.addOrg(user!, {name: 'NewOrg', domain: 'novel-org'}, { let orgId = (await home.addOrg(user, {name: 'NewOrg', domain: 'novel-org'}, {
setUserAsOwner: false, setUserAsOwner: false,
useNewPlan: true, useNewPlan: true,
// omit plan, to use a default one (teamInitial) // omit plan, to use a default one (teamInitial)
// it will either be 'stub' or anything set in GRIST_DEFAULT_PRODUCT // it will either be 'stub' or anything set in GRIST_DEFAULT_PRODUCT
})).data!; })).data!;
let org = await home.getOrg({userId: user!.id}, orgId); let org = await home.getOrg({userId: user.id}, orgId);
assert.equal(org.data!.name, 'NewOrg'); assert.equal(org.data!.name, 'NewOrg');
assert.equal(org.data!.domain, 'novel-org'); assert.equal(org.data!.domain, 'novel-org');
assert.equal(org.data!.billingAccount.product.name, FREE_PLAN); assert.equal(org.data!.billingAccount.product.name, FREE_PLAN);
await home.deleteOrg({userId: user!.id}, orgId); await home.deleteOrg({userId: user.id}, orgId);
// Now remove the default product, and check that the default plan is used. // Now remove the default product, and check that the default plan is used.
delete process.env.GRIST_DEFAULT_PRODUCT; delete process.env.GRIST_DEFAULT_PRODUCT;
orgId = (await home.addOrg(user!, {name: 'NewOrg', domain: 'novel-org'}, { orgId = (await home.addOrg(user, {name: 'NewOrg', domain: 'novel-org'}, {
setUserAsOwner: false, setUserAsOwner: false,
useNewPlan: true, useNewPlan: true,
})).data!; })).data!;
org = await home.getOrg({userId: user!.id}, orgId); org = await home.getOrg({userId: user.id}, orgId);
assert.equal(org.data!.billingAccount.product.name, STUB_PLAN); assert.equal(org.data!.billingAccount.product.name, STUB_PLAN);
await home.deleteOrg({userId: user!.id}, orgId); await home.deleteOrg({userId: user.id}, orgId);
} finally { } finally {
oldEnv.restore(); oldEnv.restore();
} }
@ -134,17 +134,17 @@ describe('HomeDBManager', function() {
it('cannot duplicate a domain', async function() { it('cannot duplicate a domain', async function() {
const user = await home.getUserByLogin('chimpy@getgrist.com'); const user = await home.getUserByLogin('chimpy@getgrist.com');
const domain = 'repeated-domain'; const domain = 'repeated-domain';
const result = await home.addOrg(user!, {name: `${domain}!`, domain}, teamOptions); const result = await home.addOrg(user, {name: `${domain}!`, domain}, teamOptions);
const orgId = result.data!; const orgId = result.data!;
assert.equal(result.status, 200); assert.equal(result.status, 200);
await assert.isRejected(home.addOrg(user!, {name: `${domain}!`, domain}, teamOptions), await assert.isRejected(home.addOrg(user, {name: `${domain}!`, domain}, teamOptions),
/Domain already in use/); /Domain already in use/);
await home.deleteOrg({userId: user!.id}, orgId); await home.deleteOrg({userId: user.id}, orgId);
}); });
it('cannot add an org with a (blacklisted) dodgy domain', async function() { it('cannot add an org with a (blacklisted) dodgy domain', async function() {
const user = await home.getUserByLogin('chimpy@getgrist.com'); const user = await home.getUserByLogin('chimpy@getgrist.com');
const userId = user!.id; const userId = user.id;
const misses = [ const misses = [
'thing!', ' thing', 'ww', 'docs-999', 'o-99', '_domainkey', 'www', 'api', 'thing!', ' thing', 'ww', 'docs-999', 'o-99', '_domainkey', 'www', 'api',
'thissubdomainiswaytoolongmyfriendyoushouldrethinkitoratleastsummarizeit', 'thissubdomainiswaytoolongmyfriendyoushouldrethinkitoratleastsummarizeit',
@ -154,13 +154,13 @@ describe('HomeDBManager', function() {
'thing', 'jpl', 'xyz', 'appel', '123', '1google' 'thing', 'jpl', 'xyz', 'appel', '123', '1google'
]; ];
for (const domain of misses) { for (const domain of misses) {
const result = await home.addOrg(user!, {name: `${domain}!`, domain}, teamOptions); const result = await home.addOrg(user, {name: `${domain}!`, domain}, teamOptions);
assert.equal(result.status, 400); assert.equal(result.status, 400);
const org = await home.getOrg({userId}, domain); const org = await home.getOrg({userId}, domain);
assert.equal(org.status, 404); assert.equal(org.status, 404);
} }
for (const domain of hits) { for (const domain of hits) {
const result = await home.addOrg(user!, {name: `${domain}!`, domain}, teamOptions); const result = await home.addOrg(user, {name: `${domain}!`, domain}, teamOptions);
assert.equal(result.status, 200); assert.equal(result.status, 200);
const org = await home.getOrg({userId}, domain); const org = await home.getOrg({userId}, domain);
assert.equal(org.status, 200); assert.equal(org.status, 200);
@ -189,7 +189,7 @@ describe('HomeDBManager', function() {
// Fetch the doc and check that the updatedAt value is as expected. // Fetch the doc and check that the updatedAt value is as expected.
const kiwi = await home.getUserByLogin('kiwi@getgrist.com'); const kiwi = await home.getUserByLogin('kiwi@getgrist.com');
const resp1 = await home.getOrgWorkspaces({userId: kiwi!.id}, primatelyOrgId); const resp1 = await home.getOrgWorkspaces({userId: kiwi.id}, primatelyOrgId);
assert.equal(resp1.status, 200); assert.equal(resp1.status, 200);
// Check that the apples metadata is as expected. updatedAt should have been set // Check that the apples metadata is as expected. updatedAt should have been set
@ -209,7 +209,7 @@ describe('HomeDBManager', function() {
// Check that the shark metadata is as expected. updatedAt should have been set // Check that the shark metadata is as expected. updatedAt should have been set
// to 2004. usage should be set. // to 2004. usage should be set.
const resp2 = await home.getOrgWorkspaces({userId: kiwi!.id}, fishOrgId); const resp2 = await home.getOrgWorkspaces({userId: kiwi.id}, fishOrgId);
assert.equal(resp2.status, 200); assert.equal(resp2.status, 200);
const shark = resp2.data![0].docs.find((doc: any) => doc.name === 'Shark'); const shark = resp2.data![0].docs.find((doc: any) => doc.name === 'Shark');
assert.equal(shark!.updatedAt.toISOString(), setDateISO2); assert.equal(shark!.updatedAt.toISOString(), setDateISO2);
@ -340,7 +340,7 @@ describe('HomeDBManager', function() {
it('can fork docs', async function() { it('can fork docs', async function() {
const user1 = await home.getUserByLogin('kiwi@getgrist.com'); const user1 = await home.getUserByLogin('kiwi@getgrist.com');
const user1Id = user1!.id; const user1Id = user1.id;
const orgId = await home.testGetId('Fish') as number; const orgId = await home.testGetId('Fish') as number;
const doc1Id = await home.testGetId('Shark') as string; const doc1Id = await home.testGetId('Shark') as string;
const scope = {userId: user1Id, urlId: doc1Id}; const scope = {userId: user1Id, urlId: doc1Id};
@ -393,7 +393,7 @@ describe('HomeDBManager', function() {
// Now fork "Shark" as Chimpy, and check that Kiwi's forks aren't listed. // Now fork "Shark" as Chimpy, and check that Kiwi's forks aren't listed.
const user2 = await home.getUserByLogin('chimpy@getgrist.com'); const user2 = await home.getUserByLogin('chimpy@getgrist.com');
const user2Id = user2!.id; const user2Id = user2.id;
const resp4 = await home.getOrgWorkspaces({userId: user2Id}, orgId); const resp4 = await home.getOrgWorkspaces({userId: user2Id}, orgId);
const resp4Doc = resp4.data![0].docs.find((d: any) => d.name === 'Shark'); const resp4Doc = resp4.data![0].docs.find((d: any) => d.name === 'Shark');
assert.deepEqual(resp4Doc!.forks, []); assert.deepEqual(resp4Doc!.forks, []);

View File

@ -19,7 +19,7 @@ describe('emails', function() {
beforeEach(async function() { beforeEach(async function() {
this.timeout(5000); this.timeout(5000);
server = new TestServer(this); server = new TestServer(this);
ref = (email: string) => server.dbManager.getUserByLogin(email).then((user) => user!.ref); ref = (email: string) => server.dbManager.getUserByLogin(email).then((user) => user.ref);
serverUrl = await server.start(); serverUrl = await server.start();
}); });

File diff suppressed because it is too large Load Diff

View File

@ -258,7 +258,7 @@ describe('removedAt', function() {
'test3@getgrist.com': 'editors', 'test3@getgrist.com': 'editors',
} }
}); });
const userRef = (email: string) => home.dbManager.getUserByLogin(email).then((user) => user!.ref); const userRef = (email: string) => home.dbManager.getUserByLogin(email).then((user) => user.ref);
const idTest1 = (await home.dbManager.getUserByLogin("test1@getgrist.com"))!.id; const idTest1 = (await home.dbManager.getUserByLogin("test1@getgrist.com"))!.id;
const idTest2 = (await home.dbManager.getUserByLogin("test2@getgrist.com"))!.id; const idTest2 = (await home.dbManager.getUserByLogin("test2@getgrist.com"))!.id;
const idTest3 = (await home.dbManager.getUserByLogin("test3@getgrist.com"))!.id; const idTest3 = (await home.dbManager.getUserByLogin("test3@getgrist.com"))!.id;

View File

@ -42,7 +42,9 @@ import {User} from "app/gen-server/entity/User";
import {Workspace} from "app/gen-server/entity/Workspace"; import {Workspace} from "app/gen-server/entity/Workspace";
import {EXAMPLE_WORKSPACE_NAME} from 'app/gen-server/lib/homedb/HomeDBManager'; import {EXAMPLE_WORKSPACE_NAME} from 'app/gen-server/lib/homedb/HomeDBManager';
import {Permissions} from 'app/gen-server/lib/Permissions'; import {Permissions} from 'app/gen-server/lib/Permissions';
import {getOrCreateConnection, runMigrations, undoLastMigration, updateDb} from 'app/server/lib/dbUtils'; import {
getConnectionName, getOrCreateConnection, runMigrations, undoLastMigration, updateDb
} from 'app/server/lib/dbUtils';
import {FlexServer} from 'app/server/lib/FlexServer'; import {FlexServer} from 'app/server/lib/FlexServer';
import * as fse from 'fs-extra'; import * as fse from 'fs-extra';
@ -527,16 +529,27 @@ class Seed {
// When running mocha on several test files at once, we need to reset our database connection // When running mocha on several test files at once, we need to reset our database connection
// if it exists. This is a little ugly since it is stored globally. // if it exists. This is a little ugly since it is stored globally.
export async function removeConnection() { export async function removeConnection() {
if (getConnectionManager().connections.length > 0) { const connections = getConnectionManager().connections;
if (getConnectionManager().connections.length > 1) { if (connections.length > 0) {
if (connections.length > 1) {
throw new Error("unexpected number of connections"); throw new Error("unexpected number of connections");
} }
await getConnectionManager().connections[0].close(); await connections[0].destroy();
// There is still no official way to delete connections that I've found. dereferenceConnection(getConnectionName());
(getConnectionManager() as any).connectionMap = new Map();
} }
} }
export function dereferenceConnection(name: string) {
// There seem to be no official way to delete connections.
// Also we should probably get rid of the use of connectionManager, which is deprecated
const connectionMgr = getConnectionManager();
const connectionMap = (connectionMgr as any).connectionMap as Map<string, Connection>;
if (!connectionMap.has(name)) {
throw new Error('connection with this name not found: ' + name);
}
connectionMap.delete(name);
}
export async function createInitialDb(connection?: Connection, migrateAndSeedData: boolean = true) { export async function createInitialDb(connection?: Connection, migrateAndSeedData: boolean = true) {
// In jenkins tests, we may want to reset the database to a clean // In jenkins tests, we may want to reset the database to a clean
// state. If so, TEST_CLEAN_DATABASE will have been set. How to // state. If so, TEST_CLEAN_DATABASE will have been set. How to

View File

@ -46,7 +46,6 @@ export async function createUser(dbManager: HomeDBManager, name: string): Promis
const username = name.toLowerCase(); const username = name.toLowerCase();
const email = `${username}@getgrist.com`; const email = `${username}@getgrist.com`;
const user = await dbManager.getUserByLogin(email, {profile: {email, name}}); const user = await dbManager.getUserByLogin(email, {profile: {email, name}});
if (!user) { throw new Error('failed to create user'); }
user.apiKey = `api_key_for_${username}`; user.apiKey = `api_key_for_${username}`;
await user.save(); await user.save();
const userHome = (await dbManager.getOrg({userId: user.id}, null)).data; const userHome = (await dbManager.getOrg({userId: user.id}, null)).data;

View File

@ -420,7 +420,7 @@ export class HomeUtil {
if (this.server.isExternalServer()) { throw new Error('not supported'); } if (this.server.isExternalServer()) { throw new Error('not supported'); }
const dbManager = await this.server.getDatabase(); const dbManager = await this.server.getDatabase();
const user = await dbManager.getUserByLogin(email); const user = await dbManager.getUserByLogin(email);
if (user) { await dbManager.deleteUser({userId: user.id}, user.id, user.name); } await dbManager.deleteUser({userId: user.id}, user.id, user.name);
} }
// Set whether this is the user's first time logging in. Requires access to the database. // Set whether this is the user's first time logging in. Requires access to the database.
@ -428,10 +428,8 @@ export class HomeUtil {
if (this.server.isExternalServer()) { throw new Error('not supported'); } if (this.server.isExternalServer()) { throw new Error('not supported'); }
const dbManager = await this.server.getDatabase(); const dbManager = await this.server.getDatabase();
const user = await dbManager.getUserByLogin(email); const user = await dbManager.getUserByLogin(email);
if (user) { user.isFirstTimeUser = isFirstLogin;
user.isFirstTimeUser = isFirstLogin; await user.save();
await user.save();
}
} }
private async _initShowGristTour(email: string, showGristTour: boolean) { private async _initShowGristTour(email: string, showGristTour: boolean) {
@ -463,7 +461,6 @@ export class HomeUtil {
const dbManager = await this.server.getDatabase(); const dbManager = await this.server.getDatabase();
const user = await dbManager.getUserByLogin(email); const user = await dbManager.getUserByLogin(email);
if (!user) { return; }
if (user.personalOrg) { if (user.personalOrg) {
const org = await dbManager.getOrg({userId: user.id}, user.personalOrg.id); const org = await dbManager.getOrg({userId: user.id}, user.personalOrg.id);

View File

@ -122,7 +122,7 @@ describe('fixSiteProducts', function() {
assert.equal(getDefaultProductNames().teamInitial, 'stub'); assert.equal(getDefaultProductNames().teamInitial, 'stub');
const db = server.dbManager; const db = server.dbManager;
const user = await db.getUserByLogin(email, {profile}) as any; const user = await db.getUserByLogin(email, {profile});
const orgId = db.unwrapQueryResult(await db.addOrg(user, { const orgId = db.unwrapQueryResult(await db.addOrg(user, {
name: 'sanity-check-org', name: 'sanity-check-org',
domain: 'sanity-check-org', domain: 'sanity-check-org',

View File

@ -3279,7 +3279,7 @@ describe('GranularAccess', function() {
cliOwner.flush(); cliOwner.flush();
let perm: PermissionDataWithExtraUsers = (await cliOwner.send("getUsersForViewAs", 0)).data; let perm: PermissionDataWithExtraUsers = (await cliOwner.send("getUsersForViewAs", 0)).data;
const getId = (name: string) => home.dbManager.testGetId(name) as Promise<number>; const getId = (name: string) => home.dbManager.testGetId(name) as Promise<number>;
const getRef = (email: string) => home.dbManager.getUserByLogin(email).then(user => user!.ref); const getRef = (email: string) => home.dbManager.getUserByLogin(email).then(user => user.ref);
assert.deepEqual(perm.users, [ assert.deepEqual(perm.users, [
{ id: await getId('Chimpy'), email: 'chimpy@getgrist.com', name: 'Chimpy', { id: await getId('Chimpy'), email: 'chimpy@getgrist.com', name: 'Chimpy',
ref: await getRef('chimpy@getgrist.com'), ref: await getRef('chimpy@getgrist.com'),

View File

@ -2,13 +2,13 @@ import path from "path";
import * as testUtils from "test/server/testUtils"; import * as testUtils from "test/server/testUtils";
import {execFileSync} from "child_process"; import {execFileSync} from "child_process";
export async function prepareDatabase(tempDirectory: string) { export async function prepareDatabase(tempDirectory: string, filename: string = 'landing.db') {
// Let's create a sqlite db that we can share with servers that run in other processes, hence // Let's create a sqlite db that we can share with servers that run in other processes, hence
// not an in-memory db. Running seed.ts directly might not take in account the most recent value // not an in-memory db. Running seed.ts directly might not take in account the most recent value
// for TYPEORM_DATABASE, because ormconfig.js may already have been loaded with a different // for TYPEORM_DATABASE, because ormconfig.js may already have been loaded with a different
// configuration (in-memory for instance). Spawning a process is one way to make sure that the // configuration (in-memory for instance). Spawning a process is one way to make sure that the
// latest value prevail. // latest value prevail.
process.env.TYPEORM_DATABASE = path.join(tempDirectory, 'landing.db'); process.env.TYPEORM_DATABASE = path.join(tempDirectory, filename);
const seed = await testUtils.getBuildFile('test/gen-server/seed.js'); const seed = await testUtils.getBuildFile('test/gen-server/seed.js');
execFileSync('node', [seed, 'init'], { execFileSync('node', [seed, 'init'], {
env: process.env, env: process.env,