gristlabs_grist-core/test/nbrowser/homeUtil.ts

400 lines
16 KiB
TypeScript
Raw Normal View History

/**
* Contains some non-webdriver functionality needed by tests.
*/
import * as FormData from 'form-data';
import * as fse from 'fs-extra';
import defaults = require('lodash/defaults');
import {WebElement} from 'mocha-webdriver';
import fetch from 'node-fetch';
import {authenticator} from 'otplib';
import * as path from 'path';
import {WebDriver} from 'selenium-webdriver';
import {UserProfile} from 'app/common/LoginSessionAPI';
import {DocWorkerAPI, UserAPI, UserAPIImpl} from 'app/common/UserAPI';
import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
import * as log from 'app/server/lib/log';
import {TestingHooksClient} from 'app/server/lib/TestingHooks';
export interface Server {
driver: WebDriver;
getTestingHooks(): Promise<TestingHooksClient>;
getHost(): string;
getUrl(team: string, relPath: string): string;
getDatabase(): Promise<HomeDBManager>;
isExternalServer(): boolean;
}
export class HomeUtil {
// Cache api keys of test users. It is often convenient to have various instances
// of the home api available while making browser tests.
private _apiKey = new Map<string, string>();
constructor(public fixturesRoot: string, public server: Server) {}
public get driver(): WebDriver { return this.server.driver; }
/**
* Set current session to a simulated login with the given name and email. Available options
* include:
* - `loginMethod`: when provided will store in the database which method the
* user nominally logged in with (e.g. 'Email + Password' or 'Google').
* - `isFirstLogin`: when provided will cause user to be redirected or not to the
* welcome pages.
* - `freshAccount`: when true will cause the user account to be deleted and
* recreated if it already existed.
* - `cacheCredentials`: when true will result in the user's api key being stored
* (after having been created if necessary), so that their home api can be later
* instantiated without page loads.
* When testing against an external server, the simulated login is in fact genuine,
* done via the Grist login page.
*/
public async simulateLogin(name: string, email: string, org: string = "", options: {
loginMethod?: UserProfile['loginMethod'],
freshAccount?: boolean,
isFirstLogin?: boolean,
showGristTour?: boolean,
cacheCredentials?: boolean,
} = {}) {
const {loginMethod, isFirstLogin} = defaults(options, {loginMethod: 'Email + Password'});
const showGristTour = options.showGristTour ?? (options.freshAccount ?? isFirstLogin);
// For regular tests, we can log in through a testing hook.
if (!this.server.isExternalServer()) {
if (options.freshAccount) { await this._deleteUserByEmail(email); }
if (isFirstLogin !== undefined) { await this._setFirstLogin(email, isFirstLogin); }
if (showGristTour !== undefined) { await this._initShowGristTour(email, showGristTour); }
// TestingHooks communicates via JSON, so it's impossible to send an `undefined` value for org
// through it. Using the empty string happens to work though.
const testingHooks = await this.server.getTestingHooks();
(core) move more tests to grist-core Summary: * Tie build and run-time docker base images to a consistent version (buster) * Extend the test login system activated by GRIST_TEST_LOGIN to ease porting tests that currently rely on cognito (many) * Make org resets work in absence of billing endpoints * When in-memory session caches are used, add missing invalidation steps * Pass org information through sign-ups/sign-ins more carefully * For CORS, explicitly trust GRIST_HOST origin when set * Move some fixtures and tests to core, focussing on tests that cover existing failures or are in the set of tests run on deployments * Retain regular `test` target to run the test suite directly, without docker * Add a `test:smoke` target to run a single simple test without `GRIST_TEST_LOGIN` activated * Add a `test:docker` target to run the tests against a grist-core docker image - since tests rely on certain fixture teams/docs, added `TEST_SUPPORT_API_KEY` and `TEST_ADD_SAMPLES` flags to ease porting The tests ported were `nbrowser` tests: `ActionLog.ts` (the first test I tend to port to anything, out of habit), `Fork.ts` (exercises a lot of doc creation paths), `HomeIntro.ts` (a lot of DocMenu exercise), and `DuplicateDocument.ts` (covers a feature known to be failing prior to this diff, the CORS tweak resolves it). Test Plan: Manually tested via `buildtools/build_core.sh`. In follow up, I want to add running the `test:docker` target in grist-core's workflows. In jenkins, only the smoke test is run. There'd be an argument for running all tests, but they include particularly slow tests, and are duplicates of tests already run (in different configuration admittedly), so I'd like to try first just using them in grist-core to gate updates to any packaged version of Grist (the docker image currently). Reviewers: alexmojaki Reviewed By: alexmojaki Subscribers: alexmojaki Differential Revision: https://phab.getgrist.com/D3176
2021-12-10 22:42:54 +00:00
const sid = await this.getGristSid();
if (!sid) { throw new Error('no session available'); }
await testingHooks.setLoginSessionProfile(sid, {name, email, loginMethod}, org);
} else {
if (loginMethod && loginMethod !== 'Email + Password') {
throw new Error('only Email + Password logins supported for external server tests');
}
// Make sure we revisit page in case login is changing.
await this.driver.get('about:blank');
// When running against an external server, we log in through the Grist login page.
await this.driver.get(this.server.getUrl(org, ""));
if (!await this.isOnLoginPage()) {
// Explicitly click sign-in link if necessary.
await this.driver.findWait('.test-user-signin', 4000).click();
await this.driver.findContentWait('.grist-floating-menu a', 'Sign in', 500).click();
}
// Fill the login form (either test or Grist).
if (await this.isOnTestLoginPage()) {
await this.fillTestLoginForm(email, name);
} else {
await this.fillGristLoginForm(email);
}
if (!await this.isWelcomePage() && (options.freshAccount || options.isFirstLogin)) {
await this._recreateCurrentUser(email, org, name);
}
}
if (options.freshAccount) {
this._apiKey.delete(email);
}
if (options.cacheCredentials) {
// Take this opportunity to cache access info.
if (!this._apiKey.has(email)) {
await this.driver.get(this.server.getUrl(org, ''));
this._apiKey.set(email, await this._getApiKey());
}
}
}
/**
* Remove any simulated login from the current session (for the given org, if specified).
* For testing against an external server, all logins are removed, since there's no way
* to be more nuanced.
*/
public async removeLogin(org: string = "") {
if (!this.server.isExternalServer()) {
const testingHooks = await this.server.getTestingHooks();
(core) move more tests to grist-core Summary: * Tie build and run-time docker base images to a consistent version (buster) * Extend the test login system activated by GRIST_TEST_LOGIN to ease porting tests that currently rely on cognito (many) * Make org resets work in absence of billing endpoints * When in-memory session caches are used, add missing invalidation steps * Pass org information through sign-ups/sign-ins more carefully * For CORS, explicitly trust GRIST_HOST origin when set * Move some fixtures and tests to core, focussing on tests that cover existing failures or are in the set of tests run on deployments * Retain regular `test` target to run the test suite directly, without docker * Add a `test:smoke` target to run a single simple test without `GRIST_TEST_LOGIN` activated * Add a `test:docker` target to run the tests against a grist-core docker image - since tests rely on certain fixture teams/docs, added `TEST_SUPPORT_API_KEY` and `TEST_ADD_SAMPLES` flags to ease porting The tests ported were `nbrowser` tests: `ActionLog.ts` (the first test I tend to port to anything, out of habit), `Fork.ts` (exercises a lot of doc creation paths), `HomeIntro.ts` (a lot of DocMenu exercise), and `DuplicateDocument.ts` (covers a feature known to be failing prior to this diff, the CORS tweak resolves it). Test Plan: Manually tested via `buildtools/build_core.sh`. In follow up, I want to add running the `test:docker` target in grist-core's workflows. In jenkins, only the smoke test is run. There'd be an argument for running all tests, but they include particularly slow tests, and are duplicates of tests already run (in different configuration admittedly), so I'd like to try first just using them in grist-core to gate updates to any packaged version of Grist (the docker image currently). Reviewers: alexmojaki Reviewed By: alexmojaki Subscribers: alexmojaki Differential Revision: https://phab.getgrist.com/D3176
2021-12-10 22:42:54 +00:00
const sid = await this.getGristSid();
if (sid) { await testingHooks.setLoginSessionProfile(sid, null, org); }
} else {
await this.driver.get(`${this.server.getHost()}/logout`);
}
}
// Check if the url looks like a welcome page. The check is weak, but good enough
// for testing.
public async isWelcomePage() {
const url = await this.driver.getCurrentUrl();
return Boolean(url.match(/\/welcome\//));
}
/**
* Fill the Grist test login page.
*
* TEST_ACCOUNT_PASSWORD must be set.
*/
public async fillTestLoginForm(email: string, name?: string) {
const password = process.env.TEST_ACCOUNT_PASSWORD;
if (!password) { throw new Error('TEST_ACCOUNT_PASSWORD not set'); }
const form = await this.driver.find('div.modal-content-desktop');
await this.setValue(form.find('input[name="username"]'), email);
if (name) { await this.setValue(form.find('input[name="name"]'), name); }
await this.setValue(form.find('input[name="password"]'), password);
await form.find('input[name="signInSubmitButton"]').click();
}
/**
* Fill up the Grist login page form, and submit. If called with a user that
* has TOTP-based 2FA enabled, TEST_ACCOUNT_TOTP_SECRET must be set for a valid
* code to be submitted on the following form.
*
* Should be on the Grist login or sign-up page before calling this method. If
* `password` is not passed in, TEST_ACCOUNT_PASSWORD must be set.
*/
public async fillGristLoginForm(email: string, password?: string) {
if (!password) {
password = process.env.TEST_ACCOUNT_PASSWORD;
if (!password) {
throw new Error('TEST_ACCOUNT_PASSWORD not set');
}
}
await this.checkGristLoginPage();
if ((await this.driver.getCurrentUrl()).match(/signup\?/)) {
await this.driver.findWait('a[href*="login?"]', 4000).click();
}
await this.driver.findWait('input[name="email"]', 4000).sendKeys(email);
await this.driver.find('input[name="password"]').sendKeys(password);
await this.driver.find('.test-lp-sign-in').click();
await this.driver.wait(async () => !await this.isOnGristLoginPage(), 4000);
if (!await this.driver.findContent('.test-mfa-title', 'Almost there!').isPresent()) {
return;
}
const secret = process.env.TEST_ACCOUNT_TOTP_SECRET;
if (!secret) { throw new Error('TEST_ACCOUNT_TOTP_SECRET not set'); }
const code = authenticator.generate(secret);
await this.driver.find('input[name="verificationCode"]').sendKeys(code);
await this.driver.find('.test-mfa-submit').click();
await this.driver.wait(
async () => {
return !await this.driver.findContent('.test-mfa-title', 'Almost there!').isPresent();
},
4000,
'Possible reason: verification code is invalid or expired (i.e. was recently used to log in)'
);
}
/**
* Delete the currently logged in user.
*/
public async deleteCurrentUser() {
const apiKey = await this._getApiKey();
const api = this._createHomeApiUsingApiKey(apiKey);
const info = await api.getSessionActive();
await api.deleteUser(info.user.id, info.user.name);
}
/**
(core) move more tests to grist-core Summary: * Tie build and run-time docker base images to a consistent version (buster) * Extend the test login system activated by GRIST_TEST_LOGIN to ease porting tests that currently rely on cognito (many) * Make org resets work in absence of billing endpoints * When in-memory session caches are used, add missing invalidation steps * Pass org information through sign-ups/sign-ins more carefully * For CORS, explicitly trust GRIST_HOST origin when set * Move some fixtures and tests to core, focussing on tests that cover existing failures or are in the set of tests run on deployments * Retain regular `test` target to run the test suite directly, without docker * Add a `test:smoke` target to run a single simple test without `GRIST_TEST_LOGIN` activated * Add a `test:docker` target to run the tests against a grist-core docker image - since tests rely on certain fixture teams/docs, added `TEST_SUPPORT_API_KEY` and `TEST_ADD_SAMPLES` flags to ease porting The tests ported were `nbrowser` tests: `ActionLog.ts` (the first test I tend to port to anything, out of habit), `Fork.ts` (exercises a lot of doc creation paths), `HomeIntro.ts` (a lot of DocMenu exercise), and `DuplicateDocument.ts` (covers a feature known to be failing prior to this diff, the CORS tweak resolves it). Test Plan: Manually tested via `buildtools/build_core.sh`. In follow up, I want to add running the `test:docker` target in grist-core's workflows. In jenkins, only the smoke test is run. There'd be an argument for running all tests, but they include particularly slow tests, and are duplicates of tests already run (in different configuration admittedly), so I'd like to try first just using them in grist-core to gate updates to any packaged version of Grist (the docker image currently). Reviewers: alexmojaki Reviewed By: alexmojaki Subscribers: alexmojaki Differential Revision: https://phab.getgrist.com/D3176
2021-12-10 22:42:54 +00:00
* Returns the current Grist session-id (for the selenium browser accessing this server),
* or null if there is no session.
*/
(core) move more tests to grist-core Summary: * Tie build and run-time docker base images to a consistent version (buster) * Extend the test login system activated by GRIST_TEST_LOGIN to ease porting tests that currently rely on cognito (many) * Make org resets work in absence of billing endpoints * When in-memory session caches are used, add missing invalidation steps * Pass org information through sign-ups/sign-ins more carefully * For CORS, explicitly trust GRIST_HOST origin when set * Move some fixtures and tests to core, focussing on tests that cover existing failures or are in the set of tests run on deployments * Retain regular `test` target to run the test suite directly, without docker * Add a `test:smoke` target to run a single simple test without `GRIST_TEST_LOGIN` activated * Add a `test:docker` target to run the tests against a grist-core docker image - since tests rely on certain fixture teams/docs, added `TEST_SUPPORT_API_KEY` and `TEST_ADD_SAMPLES` flags to ease porting The tests ported were `nbrowser` tests: `ActionLog.ts` (the first test I tend to port to anything, out of habit), `Fork.ts` (exercises a lot of doc creation paths), `HomeIntro.ts` (a lot of DocMenu exercise), and `DuplicateDocument.ts` (covers a feature known to be failing prior to this diff, the CORS tweak resolves it). Test Plan: Manually tested via `buildtools/build_core.sh`. In follow up, I want to add running the `test:docker` target in grist-core's workflows. In jenkins, only the smoke test is run. There'd be an argument for running all tests, but they include particularly slow tests, and are duplicates of tests already run (in different configuration admittedly), so I'd like to try first just using them in grist-core to gate updates to any packaged version of Grist (the docker image currently). Reviewers: alexmojaki Reviewed By: alexmojaki Subscribers: alexmojaki Differential Revision: https://phab.getgrist.com/D3176
2021-12-10 22:42:54 +00:00
public async getGristSid(): Promise<string|null> {
// Load a cheap page on our server to get the session-id cookie from browser.
await this.driver.get(`${this.server.getHost()}/test/session`);
(core) move more tests to grist-core Summary: * Tie build and run-time docker base images to a consistent version (buster) * Extend the test login system activated by GRIST_TEST_LOGIN to ease porting tests that currently rely on cognito (many) * Make org resets work in absence of billing endpoints * When in-memory session caches are used, add missing invalidation steps * Pass org information through sign-ups/sign-ins more carefully * For CORS, explicitly trust GRIST_HOST origin when set * Move some fixtures and tests to core, focussing on tests that cover existing failures or are in the set of tests run on deployments * Retain regular `test` target to run the test suite directly, without docker * Add a `test:smoke` target to run a single simple test without `GRIST_TEST_LOGIN` activated * Add a `test:docker` target to run the tests against a grist-core docker image - since tests rely on certain fixture teams/docs, added `TEST_SUPPORT_API_KEY` and `TEST_ADD_SAMPLES` flags to ease porting The tests ported were `nbrowser` tests: `ActionLog.ts` (the first test I tend to port to anything, out of habit), `Fork.ts` (exercises a lot of doc creation paths), `HomeIntro.ts` (a lot of DocMenu exercise), and `DuplicateDocument.ts` (covers a feature known to be failing prior to this diff, the CORS tweak resolves it). Test Plan: Manually tested via `buildtools/build_core.sh`. In follow up, I want to add running the `test:docker` target in grist-core's workflows. In jenkins, only the smoke test is run. There'd be an argument for running all tests, but they include particularly slow tests, and are duplicates of tests already run (in different configuration admittedly), so I'd like to try first just using them in grist-core to gate updates to any packaged version of Grist (the docker image currently). Reviewers: alexmojaki Reviewed By: alexmojaki Subscribers: alexmojaki Differential Revision: https://phab.getgrist.com/D3176
2021-12-10 22:42:54 +00:00
const cookie = await this.driver.manage().getCookie(process.env.GRIST_SESSION_COOKIE || 'grist_sid');
if (!cookie) { return null; }
return decodeURIComponent(cookie.value);
}
/**
* Create a new document.
*/
public async createNewDoc(username: string, org: string, workspace: string, docName: string,
options: {email?: string} = {}) {
const homeApi = this.createHomeApi(username, org, options.email);
const workspaceId = await this.getWorkspaceId(homeApi, workspace);
return await homeApi.newDoc({name: docName}, workspaceId);
}
/**
* Import a fixture doc into a workspace.
*/
public async importFixturesDoc(username: string, org: string, workspace: string,
filename: string, options: {newName?: string, email?: string} = {}) {
const homeApi = this.createHomeApi(username, org, options.email);
const docWorker = await homeApi.getWorkerAPI('import');
const workspaceId = await this.getWorkspaceId(homeApi, workspace);
const uploadId = await this.uploadFixtureDoc(docWorker, filename, options.newName);
return docWorker.importDocToWorkspace(uploadId, workspaceId);
}
/**
* Create a copy of a doc. Similar to importFixturesDoc, but starts with an existing docId.
*/
public async copyDoc(username: string, org: string, workspace: string,
docId: string, options: {newName?: string} = {}) {
const homeApi = this.createHomeApi(username, org);
const docWorker = await homeApi.getWorkerAPI('import');
const workspaceId = await this.getWorkspaceId(homeApi, workspace);
const uploadId = await docWorker.copyDoc(docId);
return docWorker.importDocToWorkspace(uploadId, workspaceId);
}
// upload fixture document to the doc worker at url.
public async uploadFixtureDoc(docWorker: DocWorkerAPI, filename: string, newName: string = filename) {
const filepath = path.resolve(this.fixturesRoot, "docs", filename);
if (!await fse.pathExists(filepath)) {
throw new Error(`Can't find file: ${filepath}`);
}
const fileStream = fse.createReadStream(filepath);
// node-fetch can upload streams, although browser fetch can't
return docWorker.upload(fileStream as any, newName);
}
// A helper that find a workspace id by name for a given username and org.
public async getWorkspaceId(homeApi: UserAPIImpl, workspace: string): Promise<number> {
return (await homeApi.getOrgWorkspaces('current')).find((w) => w.name === workspace)!.id;
}
// A helper that returns the list of names of all documents within a workspace.
public async listDocs(homeApi: UserAPI, wid: number): Promise<string[]> {
const workspace = await homeApi.getWorkspace(wid);
return workspace.docs.map(d => d.name);
}
// A helper to create a UserAPI instance for a given useranme and org, that targets the home server
// Username can be null for anonymous access.
public createHomeApi(username: string|null, org: string, email?: string): UserAPIImpl {
const name = (username || '').toLowerCase();
const apiKey = username && ((email && this._apiKey.get(email)) || `api_key_for_${name}`);
return this._createHomeApiUsingApiKey(apiKey, org);
}
/**
* Set the value of an input element. This is to be used when the input element appears with its
* content already selected which can create some flakiness when using the normal approach based on
* `driver.sendKeys`. This is due to the fact that the implementation of such behaviour relies on a
* timeout that there is no easy way to listen to with selenium, so when sending keys, the
* `<element>.select()` could happens anytime on the client, which results in the value being
* truncated.
*/
public async setValue(inputEl: WebElement, value: string) {
await this.driver.executeScript(
(input: HTMLInputElement, val: string) => { input.value = val; },
inputEl, value
);
}
/**
* Returns whether we are currently on any login page (including the test page).
*/
public async isOnLoginPage() {
return await this.isOnGristLoginPage() || await this.isOnTestLoginPage();
}
/**
* Returns whether we are currently on a Grist login page.
*/
public async isOnGristLoginPage() {
const isOnSignupPage = await this.driver.find('.test-sp-heading').isPresent();
const isOnLoginPage = await this.driver.find('.test-lp-heading').isPresent();
return isOnSignupPage || isOnLoginPage;
}
/**
* Returns whether we are currently on the test login page.
*/
public isOnTestLoginPage() {
return this.driver.findContent('h1', 'A Very Credulous Login Page').isPresent();
}
/**
* Waits for browser to navigate to any login page (including the test page).
*/
public async checkLoginPage(waitMs: number = 2000) {
await this.driver.wait(this.isOnLoginPage.bind(this), waitMs);
}
/**
* Waits for browser to navigate to a Grist login page.
*/
public async checkGristLoginPage(waitMs: number = 2000) {
await this.driver.wait(this.isOnGristLoginPage.bind(this), waitMs);
}
/**
* Delete and recreate the user, via the specified org. The specified user must be
* currently logged in!
*/
private async _recreateCurrentUser(email: string, org: string, name?: string) {
await this.deleteCurrentUser();
await this.removeLogin(org);
await this.driver.get(this.server.getUrl(org, ""));
await this.driver.findWait('.test-user-signin', 4000).click();
await this.driver.findContentWait('.grist-floating-menu a', 'Sign in', 500).click();
await this.checkLoginPage();
// Fill the login form (either test or Grist).
if (await this.isOnTestLoginPage()) {
await this.fillTestLoginForm(email, name);
} else {
await this.fillGristLoginForm(email);
}
}
private async _getApiKey(): Promise<string> {
return this.driver.wait(() => this.driver.executeAsyncScript<string>((done: (key: string) => void) => {
const app = (window as any).gristApp;
if (!app) { done(""); return; }
const api: UserAPI = app.topAppModel.api;
return api.fetchApiKey().then(key => {
if (key) { return key; }
return api.createApiKey();
}).then(done).catch(() => done(""));
}), 4000);
}
// Delete a user using their email address. Requires access to the database.
private async _deleteUserByEmail(email: string) {
if (this.server.isExternalServer()) { throw new Error('not supported'); }
const dbManager = await this.server.getDatabase();
const user = await dbManager.getUserByLogin(email);
if (user) { 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.
private async _setFirstLogin(email: string, isFirstLogin: boolean) {
if (this.server.isExternalServer()) { throw new Error('not supported'); }
const dbManager = await this.server.getDatabase();
const user = await dbManager.getUserByLogin(email);
if (user) {
user.isFirstTimeUser = isFirstLogin;
await user.save();
}
}
private async _initShowGristTour(email: string, showGristTour: boolean) {
if (this.server.isExternalServer()) { throw new Error('not supported'); }
const dbManager = await this.server.getDatabase();
const user = await dbManager.getUserByLogin(email);
if (user && user.personalOrg) {
const userOrgPrefs = {showGristTour};
await dbManager.updateOrg({userId: user.id}, user.personalOrg.id, {userOrgPrefs});
}
}
// Make a home api instance with the given api key, for the specified org.
// If no api key given, work anonymously.
private _createHomeApiUsingApiKey(apiKey: string|null, org?: string): UserAPIImpl {
const headers = apiKey ? {Authorization: `Bearer ${apiKey}`} : undefined;
return new UserAPIImpl(org ? this.server.getUrl(org, '') : this.server.getHost(), {
headers,
fetch: fetch as any,
newFormData: () => new FormData() as any, // form-data isn't quite type compatible
logger: log});
}
}