(core) Add support for auto-copying docs on signup

Summary:
The new "copyDoc" query parameter on the login page sets a short-lived cookie, which is
then read when welcoming a new user to copy that document to their Home workspace, and
redirect to it. Currently, only templates and bare forks set this parameter.

A new API endpoint for copying a document to a workspace was also added.

Test Plan: Browser tests.

Reviewers: paulfitz

Reviewed By: paulfitz

Differential Revision: https://phab.getgrist.com/D3992
This commit is contained in:
George Gevoian
2023-09-06 14:35:46 -04:00
parent 90fb4434cc
commit 3dadf93c98
26 changed files with 1057 additions and 276 deletions

View File

@@ -152,7 +152,7 @@ describe('CellColor', function() {
const forkId = await gu.getCurrentUrlId();
// Compare with the original
await mainSession.loadDoc(`/doc/${doc}?compare=${forkId}`);
await mainSession.loadDoc(`/doc/${doc}?compare=${forkId}`, {skipAlert: true});
// check that colors for diffing cells are ok
cell = gu.getCell('A', 1).find('.field_clip');

View File

@@ -57,7 +57,7 @@ describe('DocTutorial', function () {
});
it('redirects user to log in', async function() {
await viewerSession.loadDoc(`/doc/${doc.id}`, false);
await viewerSession.loadDoc(`/doc/${doc.id}`, {wait: false});
await gu.checkLoginPage();
});
});

View File

@@ -8,7 +8,7 @@ import uuidv4 from "uuid/v4";
describe("Fork", function() {
// this is a relatively slow test in staging.
this.timeout(40000);
this.timeout(60000);
const cleanup = setupTestSuite();
let doc: DocCreationInfo;
@@ -45,6 +45,7 @@ describe("Fork", function() {
assert.equal(await gu.getCell({rowNum: 1, col: 0}).getText(), '1');
const forkUrl = await driver.getCurrentUrl();
assert.match(forkUrl, isLoggedIn ? /^[^~]*~[^~]+~\d+$/ : /^[^~]*~[^~]*$/);
await gu.refreshDismiss();
await session.loadDoc((new URL(forkUrl)).pathname + '/m/fork');
await gu.getCell({rowNum: 1, col: 0}).click();
await gu.enterCell('2');
@@ -57,6 +58,7 @@ describe("Fork", function() {
assert.equal(await gu.getCell({rowNum: 1, col: 0}).getText(), '1');
assert.equal(await driver.getCurrentUrl(), `${forkUrl}/m/fork`);
await gu.wipeToasts();
await gu.refreshDismiss();
}
// Run tests with both regular docId and a custom urlId in URL, to make sure
@@ -120,6 +122,7 @@ describe("Fork", function() {
await gu.enterCell('123');
await gu.waitForServer();
assert.equal(await gu.getCell({rowNum: 1, col: 0}).getText(), '123');
await gu.refreshDismiss();
// edits should persist across reloads
await personal.loadDoc(`/doc/${id}`);
assert.equal(await gu.getCell({rowNum: 1, col: 0}).getText(), '123');
@@ -128,16 +131,17 @@ describe("Fork", function() {
await gu.enterCell('234');
await gu.waitForServer();
assert.equal(await gu.getCell({rowNum: 1, col: 0}).getText(), '234');
await gu.refreshDismiss();
if (mode !== 'anonymous') {
// if we log out, access should now be denied
const anonSession = await personal.anon.login();
await anonSession.loadDoc(`/doc/${id}`, false);
await anonSession.loadDoc(`/doc/${id}`, {wait: false});
assert.match(await driver.findWait('.test-error-header', 2000).getText(), /Access denied/);
// if we log in as a different user, access should be denied
const altSession = await personal.user('user2').login();
await altSession.loadDoc(`/doc/${id}`, false);
await altSession.loadDoc(`/doc/${id}`, {wait: false});
assert.match(await driver.findWait('.test-error-header', 2000).getText(), /Access denied/);
}
});
@@ -179,6 +183,7 @@ describe("Fork", function() {
assert.match(forkUrl, /~/);
// check that the tag is there
assert.equal(await driver.find('.test-unsaved-tag').isPresent(), true);
await gu.refreshDismiss();
// Open the original url and make sure the change we made is not there
await anonSession.loadDoc(`/doc/${doc.id}`);
@@ -202,6 +207,7 @@ describe("Fork", function() {
await gu.waitForServer();
assert.equal(await gu.getCell({rowNum: 1, col: 0}).getText(), '1');
const fork1 = await driver.getCurrentUrl();
await gu.refreshDismiss();
await anonSession.loadDoc(`/doc/${doc.id}/m/fork`);
await gu.getCell({rowNum: 1, col: 0}).click();
@@ -209,6 +215,7 @@ describe("Fork", function() {
await gu.waitForServer();
assert.equal(await gu.getCell({rowNum: 1, col: 0}).getText(), '2');
const fork2 = await driver.getCurrentUrl();
await gu.refreshDismiss();
await anonSession.loadDoc((new URL(fork1)).pathname);
assert.equal(await gu.getCell({rowNum: 1, col: 0}).getText(), '1');
@@ -229,6 +236,7 @@ describe("Fork", function() {
const urlId1 = await gu.getCurrentUrlId();
assert.match(urlId1!, /~/);
assert.match(await driver.find('.test-treeview-itemHeader.selected').getText(), /Table2/);
await gu.refreshDismiss();
});
for (const user of [{access: 'viewers', name: 'user3'},
@@ -258,6 +266,7 @@ describe("Fork", function() {
const fork = forks.find(f => f.id === forkId);
assert.isDefined(fork);
assert.equal(fork!.trunkId, doc.id);
await gu.refreshDismiss();
// Open the original url and make sure the change we made is not there
await userSession.loadDoc(`/doc/${doc.id}`);
@@ -274,6 +283,7 @@ describe("Fork", function() {
await gu.getCell({rowNum: 1, col: 0}).click();
await gu.enterCell('1234');
await gu.waitForServer();
await gu.refreshDismiss();
await userSession.loadDoc((new URL(forkUrl)).pathname);
assert.equal(await gu.getCell({rowNum: 1, col: 0}).getText(), '1234');
@@ -286,6 +296,7 @@ describe("Fork", function() {
await gu.getCell({rowNum: 1, col: 0}).click();
await gu.enterCell('12345');
await gu.waitForServer();
await gu.refreshDismiss();
await team.loadDoc((new URL(forkUrl)).pathname);
assert.equal(await gu.getCell({rowNum: 1, col: 0}).getText(), '1234');
@@ -296,6 +307,7 @@ describe("Fork", function() {
await gu.getCell({rowNum: 1, col: 0}).click();
await gu.enterCell('12345');
await gu.waitForServer();
await gu.refreshDismiss();
await anonSession.loadDoc((new URL(forkUrl)).pathname);
assert.equal(await gu.getCell({rowNum: 1, col: 0}).getText(), '1234');
});
@@ -314,6 +326,7 @@ describe("Fork", function() {
// The url of the doc should now be that of a fork
const forkUrl = await driver.getCurrentUrl();
assert.match(forkUrl, /~/);
await gu.refreshDismiss();
// Open the original url and make sure the change we made is not there
await team.loadDoc(`/doc/${doc2.id}`);
@@ -327,6 +340,7 @@ describe("Fork", function() {
await gu.getCell({rowNum: 1, col: 0}).click();
await gu.enterCell('1234');
await gu.waitForServer();
await gu.refreshDismiss();
await team.loadDoc((new URL(forkUrl)).pathname);
assert.equal(await gu.getCell({rowNum: 1, col: 0}).getText(), '1234');
@@ -350,7 +364,7 @@ describe("Fork", function() {
await team.login();
const userId = await team.getUserId();
const altSession = await team.user('user2').login();
await altSession.loadDoc(`/doc/${doc.id}~${forkId}~${userId}`, false);
await altSession.loadDoc(`/doc/${doc.id}~${forkId}~${userId}`, {wait: false});
assert.equal(await driver.findWait('.test-modal-dialog', 2000).isDisplayed(), true);
assert.match(await driver.find('.test-modal-dialog').getText(), /Document fork not found/);
@@ -365,30 +379,30 @@ describe("Fork", function() {
// would have some access to fork via acls on trunk, but for a
// new doc user2 has no access granted via the doc, or
// workspace, or org).
await altSession.loadDoc(`/doc/new~${forkId}~${userId}`, false);
await altSession.loadDoc(`/doc/new~${forkId}~${userId}`, {wait: false});
assert.match(await driver.findWait('.test-error-header', 2000).getText(), /Access denied/);
assert.equal(await driver.find('.test-dm-logo').isDisplayed(), true);
// Same, but as an anonymous user.
const anonSession = await altSession.anon.login();
await anonSession.loadDoc(`/doc/${doc.id}~${forkId}~${userId}`, false);
await anonSession.loadDoc(`/doc/${doc.id}~${forkId}~${userId}`, {wait: false});
assert.equal(await driver.findWait('.test-modal-dialog', 2000).isDisplayed(), true);
assert.match(await driver.find('.test-modal-dialog').getText(), /Document fork not found/);
// A new doc cannot be created either (because of access mismatch).
await altSession.loadDoc(`/doc/new~${forkId}~${userId}`, false);
await altSession.loadDoc(`/doc/new~${forkId}~${userId}`, {wait: false});
assert.match(await driver.findWait('.test-error-header', 2000).getText(), /Access denied/);
assert.equal(await driver.find('.test-dm-logo').isDisplayed(), true);
// Now as a user who *is* allowed to create the fork.
// But doc forks cannot be casually created this way anymore, so it still doesn't work.
await team.login();
await team.loadDoc(`/doc/${doc.id}~${forkId}~${userId}`, false);
await team.loadDoc(`/doc/${doc.id}~${forkId}~${userId}`, {wait: false});
assert.equal(await driver.findWait('.test-modal-dialog', 2000).isDisplayed(), true);
assert.match(await driver.find('.test-modal-dialog').getText(), /Document fork not found/);
// New document can no longer be casually created this way anymore either.
await team.loadDoc(`/doc/new~${forkId}~${userId}`, false);
await team.loadDoc(`/doc/new~${forkId}~${userId}`, {wait: false});
assert.equal(await driver.findWait('.test-modal-dialog', 2000).isDisplayed(), true);
assert.match(await driver.find('.test-modal-dialog').getText(), /Document fork not found/);
await gu.wipeToasts();
@@ -406,6 +420,7 @@ describe("Fork", function() {
await gu.waitForServer();
const forkUrl = await driver.getCurrentUrl();
assert.match(forkUrl, /~/);
await gu.refreshDismiss();
// check that there is no tag on trunk
await team.loadDoc(`/doc/${trunk.id}`);
@@ -444,6 +459,7 @@ describe("Fork", function() {
await gu.waitForDocToLoad();
assert.equal(await gu.getCell({rowNum: 1, col: 0}).getText(), '2');
assert.equal(await driver.find('.test-unsaved-tag').isPresent(), true);
await gu.refreshDismiss();
});
it('disables document renaming for forks', async function() {
@@ -456,6 +472,7 @@ describe("Fork", function() {
await gu.waitToPass(async () => {
assert.equal(await driver.find('.test-bc-doc').getAttribute('disabled'), 'true');
});
await gu.refreshDismiss();
});
it('navigating browser history play well with the add new menu', async function() {
@@ -506,6 +523,7 @@ describe("Fork", function() {
// check we're on a fork
await gu.waitForUrl(/~/);
const urlId = await gu.getCurrentUrlId();
await gu.refreshDismiss();
// open trunk again, to test that page is reloaded after replacement
await team.loadDoc(`/doc/${doc.id}`);
@@ -606,6 +624,7 @@ describe("Fork", function() {
assert.equal(await confirmButton.getText(), 'Update');
assert.match(await driver.find('.test-modal-dialog').getText(),
/already identical/);
await gu.refreshDismiss();
});
it('gives an error when replacing without write access via UI', async function() {
@@ -638,6 +657,7 @@ describe("Fork", function() {
await driver.find('.test-replace-original').click();
assert.equal(await driver.find('.grist-floating-menu').isDisplayed(), true);
await assert.isRejected(driver.findWait('.test-modal-dialog', 500), /Waiting for element/);
await gu.refreshDismiss();
} finally {
await api.updateDocPermissions(doc.id, {users: {[altSession.email]: null}});
}

44
test/nbrowser/GridView.ts Normal file
View File

@@ -0,0 +1,44 @@
import {assert, driver} from 'mocha-webdriver';
import * as gu from 'test/nbrowser/gristUtils';
import {setupTestSuite} from 'test/nbrowser/testUtils';
import {DocCreationInfo} from "app/common/DocListAPI";
describe('GridView', function() {
this.timeout(20000);
const cleanup = setupTestSuite();
let session: gu.Session, doc: DocCreationInfo, api;
it('should show tables with no columns without errors', async function() {
session = await gu.session().login();
doc = await session.tempDoc(cleanup, 'Hello.grist');
api = session.createHomeApi();
// Create and open a new table with no columns
await api.applyUserActions(doc.id, [
['AddTable', 'Empty', []],
]);
await gu.getPageItem(/Empty/).click();
// The only 'column' should be the button to add a column
const columnNames = await driver.findAll('.column_name', e => e.getText());
assert.deepEqual(columnNames, ['+']);
// There should be no errors
assert.lengthOf(await driver.findAll('.test-notifier-toast-wrapper'), 0);
});
// When a grid is scrolled, and then data is changed (due to click in a linked section), some
// records are not rendered or the position of the scroll container is corrupted.
it('should render list with wrapped choices correctly', async function() {
await session.tempDoc(cleanup, 'Teams.grist');
await gu.selectSectionByTitle("PROJECTS");
await gu.getCell(0, 1).click();
await gu.selectSectionByTitle("TODO");
await gu.scrollActiveView(0, 300);
await gu.selectSectionByTitle("PROJECTS");
await gu.getCell(0, 2).click();
await gu.selectSectionByTitle("TODO");
// This throws an error, as the cell is not rendered.
assert.equal(await gu.getCell(0, 2).getText(), "2021-09-27 Mo\n2021-10-04 Mo");
});
});

View File

@@ -323,6 +323,7 @@ async function checkDocAndRestore(
await docChecker();
for (let i = 0; i < stepsBackToDocMenu; i++) {
await driver.navigate().back();
if (await gu.isAlertShown()) { await gu.acceptAlert(); }
}
await gu.waitForDocMenuToLoad();
// If not logged in, we create docs "unsaved" and don't see them in doc-menu.

View File

@@ -0,0 +1,212 @@
/**
* Parsing strings as references when importing into an existing table
*/
import {assert, driver, Key, WebElement} from 'mocha-webdriver';
import * as gu from 'test/nbrowser/gristUtils';
import {openSource as openSourceMenu, waitForColumnMapping} from 'test/nbrowser/importerTestUtils';
import {setupTestSuite} from 'test/nbrowser/testUtils';
describe('ImportReferences', function() {
this.timeout(30000);
const cleanup = setupTestSuite();
before(async function() {
// Log in and import a sample document.
const session = await gu.session().teamSite.user('user1').login();
await session.tempDoc(cleanup, 'ImportReferences.grist');
});
afterEach(() => gu.checkForErrors());
it('should convert strings to references', async function() {
// Import a CSV file containing strings representing references
await gu.importFileDialog('./uploads/name_references.csv');
assert.equal(await driver.findWait('.test-importer-preview', 2000).isPresent(), true);
// Change the destination to the existing table
await driver.findContent('.test-importer-target-existing-table', /Table1/).click();
await gu.waitForServer();
// Finish import, and verify the import succeeded.
await driver.find('.test-modal-confirm').click();
await gu.waitForServer();
// Verify data was imported to Names correctly.
assert.deepEqual(
await gu.getVisibleGridCells({rowNums: [1, 2, 3, 4, 5], cols: [0, 1, 2]}),
[
// Previously existing data in the fixture document
'Alice', '', '',
'Bob', '', '',
// Imported data from the CSV file
// The second column is references which have been successfully parsed from strings
// The third column is a formula equal to the second column to demonstrate the references
'Charlie', 'Alice', 'Table1[1]',
'Dennis', 'Bob', 'Table1[2]',
// 'add new' row
'', '', '',
]
);
// TODO this test relies on the imported data referring to names (Alice,Bob)
// already existing in the table before the import, and not being changed by the import
});
it('should support importing into any reference columns and show preview', async function() {
// Switch to page showing Projects and Tasks.
await gu.getPageItem('Projects').click();
await gu.waitForServer(); // wait for table load
// Load up a CSV file that matches the structure of the Tasks table.
await gu.importFileDialog('./uploads/ImportReferences-Tasks.csv');
// The default import into "New Table" just shows the content of the file.
assert.equal(await driver.findWait('.test-importer-preview', 2000).isPresent(), true);
assert.deepEqual(await gu.getPreviewContents([0, 1, 2, 3, 4, 5, 6], [1, 2, 3, 4], mapper), [
'Foo2', 'Clean', '1000', '1,000', '27 Mar 2023', '', '0',
'Bar2', 'Wash', '3000', '2,000', '', 'Projects[2]', '2',
'Baz2', 'Build2', '', '2', '20 Mar 2023', 'Projects[1]', '1',
'Zoo2', 'Clean', '2000', '4,000', '24 Apr 2023', 'Projects[3]', '3',
]);
await driver.findContent('.test-importer-target-existing-table', /Tasks/).click();
await gu.waitForServer();
// See that preview works, and cells that should be valid are valid.
assert.deepEqual(await gu.getPreviewContents([0, 1, 2, 3, 4], [1, 2, 3, 4], mapper), [
// Label, PName, PIndex, PDate, PRowID
'Foo2', 'Clean', '1,000', '27 Mar 2023', '',
'Bar2', 'Wash', '3,000', '', '!Projects[2]',
'Baz2', '!Build2', '', '!2023-03-20', '!Projects[1]',
'Zoo2', 'Clean', '2,000', '24 Apr 2023', '!Projects[3]',
]);
await driver.find('.test-modal-confirm').click();
await gu.waitForServer();
// Verify data was imported to Tasks correctly.
assert.deepEqual(
await gu.getVisibleGridCells({section: 'TASKS', cols: [0, 1, 2, 3, 4], rowNums: [4, 5, 6, 7, 8, 9], mapper}), [
// Label, PName, PIndex, PDate, PRowID
// Previous data in the fixture, in row 4
'Zoo', 'Clean', '2,000', '27 Mar 2023', 'Projects[3]',
// New rows (values like "!Project[2]" are invalid, which may be fixed in the future).
'Foo2', 'Clean', '1,000', '27 Mar 2023', '',
'Bar2', 'Wash', '3,000', '', '!Projects[2]',
'Baz2', '!Build2', '', '!2023-03-20', '!Projects[1]',
'Zoo2', 'Clean', '2,000', '24 Apr 2023', '!Projects[3]',
// 'Add New' row
'', '', '', '', '',
]);
await gu.undo();
});
it('should support importing numeric columns as lookups or rowIDs', async function() {
// Load up the same CSV file again, with Tasks as the destination.
await gu.importFileDialog('./uploads/ImportReferences-Tasks.csv');
await driver.findContent('.test-importer-target-existing-table', /Tasks/).click();
await gu.waitForServer();
await waitForColumnMapping();
// Check that preview works, and cells are valid.
assert.deepEqual(await gu.getPreviewContents([0, 1, 2, 3, 4], [1, 2, 3, 4], mapper), [
// Label, PName, PIndex, PDate, PRowID
'Foo2', 'Clean', '1,000', '27 Mar 2023', '',
'Bar2', 'Wash', '3,000', '', '!Projects[2]',
'Baz2', '!Build2', '', '!2023-03-20', '!Projects[1]',
'Zoo2', 'Clean', '2,000', '24 Apr 2023', '!Projects[3]',
]);
// Check that dropdown for Label does not include "(as row ID)" entries, but the dropdown for
// PName (a reference column) does.
await openSourceMenu('Label');
assert.equal(await findColumnMenuItem('PIndex').isPresent(), true);
assert.equal(await findColumnMenuItem(/as row ID/).isPresent(), false);
await driver.sendKeys(Key.ESCAPE);
await openSourceMenu('PName');
assert.equal(await findColumnMenuItem('PIndex').isPresent(), true);
assert.equal(await findColumnMenuItem('PIndex (as row ID)').isPresent(), true);
await driver.sendKeys(Key.ESCAPE);
// Change PIndex column from lookup to row ID.
await openSourceMenu('PIndex');
await findColumnMenuItem('PIndex (as row ID)').click();
await gu.waitForServer();
// The values become invalid because there are no such rowIDs.
assert.deepEqual(await gu.getPreviewContents([0, 1, 2, 3, 4], [1, 2, 3, 4], mapper), [
// Label, PName, PIndex, PDate, PRowID
'Foo2', 'Clean', '!1000', '27 Mar 2023', '',
'Bar2', 'Wash', '!3000', '', '!Projects[2]',
'Baz2', '!Build2', '', '!2023-03-20', '!Projects[1]',
'Zoo2', 'Clean', '!2000', '24 Apr 2023', '!Projects[3]',
]);
// Try a lookup using PIndex2. It is differently formatted, one value is invalid, and one is a
// valid row ID (but shouldn't be seen as a rowID for a lookup)
await openSourceMenu('PIndex');
await findColumnMenuItem('PIndex2').click();
await gu.waitForServer();
// Note: two PIndex values are different, and two are invalid.
assert.deepEqual(await gu.getPreviewContents([0, 1, 2, 3, 4], [1, 2, 3, 4], mapper), [
// Label, PName, PIndex, PDate, PRowID
'Foo2', 'Clean', '1,000', '27 Mar 2023', '',
'Bar2', 'Wash', '2,000', '', '!Projects[2]',
'Baz2', '!Build2', '!2.0', '!2023-03-20', '!Projects[1]',
'Zoo2', 'Clean', '!4000.0', '24 Apr 2023', '!Projects[3]',
]);
// Change PRowID column to use "PID (as row ID)". It has 3 valid rowIDs.
await openSourceMenu('PRowID');
await findColumnMenuItem('PID (as row ID)').click();
await gu.waitForServer();
// Note: PRowID values are now valid.
assert.deepEqual(await gu.getPreviewContents([0, 1, 2, 3, 4], [1, 2, 3, 4], mapper), [
// Label, PName, PIndex, PDate, PRowID
'Foo2', 'Clean', '1,000', '27 Mar 2023', '',
'Bar2', 'Wash', '2,000', '', 'Projects[2]',
'Baz2', '!Build2', '!2.0', '!2023-03-20', 'Projects[1]',
'Zoo2', 'Clean', '!4000.0', '24 Apr 2023', 'Projects[3]',
]);
await driver.find('.test-modal-confirm').click();
await gu.waitForServer();
// Verify data was imported to Tasks correctly.
assert.deepEqual(
await gu.getVisibleGridCells({section: 'TASKS', cols: [0, 1, 2, 3, 4], rowNums: [4, 5, 6, 7, 8, 9], mapper}), [
// Label, PName, PIndex, PDate, PRowID
// Previous data in the fixture, in row 4
'Zoo', 'Clean', '2,000', '27 Mar 2023', 'Projects[3]',
// New rows; PRowID values are valid.
'Foo2', 'Clean', '1,000', '27 Mar 2023', '',
'Bar2', 'Wash', '2,000', '', 'Projects[2]',
'Baz2', '!Build2', '!2.0', '!2023-03-20', 'Projects[1]',
'Zoo2', 'Clean', '!4000.0', '24 Apr 2023', 'Projects[3]',
// 'Add New' row
'', '', '', '', '',
]);
await gu.undo();
});
});
// mapper for getVisibleGridCells and getPreviewContents to get both text and whether the cell is
// invalid (pink). Invalid cells prefixed with "!".
async function mapper(el: WebElement) {
let text = await el.getText();
if (await el.find(".field_clip").matches(".invalid")) {
text = "!" + text;
}
return text;
}
function findColumnMenuItem(label: RegExp|string) {
return driver.findContent('.test-importer-column-match-menu-item', label);
}

View File

@@ -36,8 +36,7 @@ describe("Smoke", function() {
await gu.dismissWelcomeTourIfNeeded();
await gu.getCell('A', 1).click();
await gu.enterCell('123');
await driver.navigate().refresh();
await gu.waitForDocToLoad();
await gu.refreshDismiss();
assert.equal(await gu.getCell('A', 1).getText(), '123');
});
});

View File

@@ -0,0 +1,143 @@
/**
* Test of the importing logic in the DocMenu page.
*/
import * as fs from 'fs';
import {assert, driver, Key} from 'mocha-webdriver';
import * as tmp from 'tmp-promise';
import * as util from 'util';
import { SQLiteDB } from 'app/server/lib/SQLiteDB';
import * as gu from 'test/nbrowser/gristUtils';
import {setupTestSuite} from 'test/nbrowser/testUtils';
import { copyFixtureDoc } from 'test/server/testUtils';
const write = util.promisify(fs.write);
describe('UploadLimits', function() {
this.timeout(20000);
const cleanup = setupTestSuite();
const cleanupCbs: Array<() => void> = [];
async function generateFile(postfix: string, size: number): Promise<string> {
const obj = await tmp.file({postfix, mode: 0o644});
await write(obj.fd, Buffer.alloc(size, 't'));
cleanupCbs.push(obj.cleanup);
return obj.path;
}
// Create a valid Grist file of at least the desired length. The file may be
// slightly larger than requested.
async function generateGristFile(minSize: number): Promise<string> {
const obj = await tmp.file({postfix: '.grist', mode: 0o644});
await copyFixtureDoc('Hello.grist', obj.path);
const size = fs.statSync(obj.path).size;
const db = await SQLiteDB.openDBRaw(obj.path);
// Make a string that is long enough to push the doc over the required size.
const longString = 'x'.repeat(Math.max(1, minSize - size));
// Add the string somewhere in the doc. For now we place it in a separate
// table - this may eventually become invalid, but it works for now.
// There'll be a little overhead so we'll overshoot the target length a bit,
// but that's fine.
await db.exec('CREATE TABLE _gristsys_extra(txt)');
await db.run('INSERT INTO _gristsys_extra(txt) VALUES(?)', [longString]);
await db.close();
const size2 = fs.statSync(obj.path).size;
if (size2 < minSize || size2 > minSize * 1.2) {
throw new Error(`generateGristFile size is off, wanted ${minSize}, got ${size2}`);
}
cleanupCbs.push(obj.cleanup);
return obj.path;
}
after(function() {
for (const cleanup of cleanupCbs) {
cleanup();
}
});
afterEach(async function() {
await gu.checkForErrors();
});
const maxImport = 1024 * 1024; // See GRIST_MAX_UPLOAD_IMPORT_MB = 1 in testServer.ts
const maxAttachment = 2 * 1024 * 1024; // See GRIST_MAX_UPLOAD_ATTACHMENT_MB = 2 in testServer.ts
it('should prevent large uploads for imports', async function() {
const session = await gu.session().teamSite.login();
await session.loadDocMenu('/');
// Generate and upload a large csv file. It should by blocked on the client side.
const largeFilePath = await generateFile(".csv", maxImport + 1000);
await gu.docMenuImport(largeFilePath);
// Ensure an error is shown.
assert.match(await driver.findWait('.test-notifier-toast-message', 1000).getText(),
/Imported files may not exceed 1.0MB/);
// Now try to import directly to server, and verify that the server enforces this limit too.
const p = gu.importFixturesDoc('Chimpy', 'nasa', 'Horizon', largeFilePath, {load: false});
await assert.isRejected(p, /Payload Too Large/);
const err = await p.catch((e) => e);
assert.equal(err.status, 413);
assert.isObject(err.details);
assert.match(err.details.userError, /Imported files must not exceed 1.0MB/);
});
it('should allow large uploads of .grist docs', async function() {
const session = await gu.session().teamSite.login();
await session.loadDocMenu('/');
// Generate and upload a large .grist file. It should not be subject to limits.
const largeFilePath = await generateGristFile(maxImport * 2 + 1000);
await gu.docMenuImport(largeFilePath);
await gu.waitForDocToLoad();
assert.equal(await gu.getCell(0, 1).getText(), 'hello');
});
it('should prevent large uploads for attachments', async function() {
const session = await gu.session().teamSite.login();
await session.tempDoc(cleanup, 'Hello.grist');
// Clear the first cell.
await gu.getCell(0, 1).click();
await driver.sendKeys(Key.DELETE);
await gu.waitForServer();
// Change column to Attachments.
await gu.toggleSidePanel('right', 'open');
await driver.find('.test-right-tab-field').click();
await gu.setType(/Attachment/);
await driver.findWait('.test-type-transform-apply', 1000).click();
await gu.waitForServer();
// We can upload multiple smaller files (the limit is per-file here).
const largeFilePath1 = await generateFile(".png", maxAttachment - 1000);
const largeFilePath2 = await generateFile(".jpg", maxAttachment - 1000);
await gu.fileDialogUpload([largeFilePath1, largeFilePath2].join(","),
() => gu.getCell(0, 1).find('.test-attachment-icon').click());
await gu.getCell(0, 1).findWait('.test-attachment-widget > [class*=test-pw-]', 1000);
// We don't expect any errors here.
assert.lengthOf(await driver.findAll('.test-notifier-toast-wrapper'), 0);
// Expect to find two attachments in the cell.
assert.lengthOf(await gu.getCell(0, 1).findAll('.test-attachment-widget > [class*=test-pw-]'), 2);
// But we can't upload larger files, even one at a time.
const largeFilePath3 = await generateFile(".jpg", maxAttachment + 1000);
await gu.fileDialogUpload(largeFilePath3,
() => gu.getCell(0, 2).find('.test-attachment-icon').click());
await driver.sleep(200);
await gu.waitForServer();
// Check that there is a warning and the cell hasn't changed.
assert.match(await driver.findWait('.test-notifier-toast-message', 1000).getText(),
/Attachments may not exceed 2.0MB/);
assert.lengthOf(await gu.getCell(0, 2).findAll('.test-attachment-widget > [class*=test-pw-]'), 0);
// TODO We should try to add attachment via API and verify that the server enforces the limit
// too, but at the moment we don't have an endpoint to add attachments via the API.
});
});

View File

@@ -1959,8 +1959,16 @@ export class Session {
}
// Load a document on a site.
public async loadDoc(relPath: string, wait: boolean = true) {
public async loadDoc(
relPath: string,
options: {
wait?: boolean,
skipAlert?: boolean,
} = {}
) {
const {wait = true, skipAlert = false} = options;
await this.loadRelPath(relPath);
if (skipAlert && await isAlertShown()) { await acceptAlert(); }
if (wait) { await waitForDocToLoad(); }
}
@@ -2395,6 +2403,7 @@ async function addSamples() {
await templatesApi.updateDoc(
exampleDocId,
{
type: 'template',
isPinned: true,
options: {
description: 'CRM template and example for linking data, and creating productive layouts.',
@@ -2411,6 +2420,7 @@ async function addSamples() {
await templatesApi.updateDoc(
investmentDocId,
{
type: 'template',
isPinned: true,
options: {
description: 'Example for analyzing and visualizing with summary tables and linked charts.',
@@ -2425,6 +2435,7 @@ async function addSamples() {
await templatesApi.updateDoc(
afterschoolDocId,
{
type: 'template',
isPinned: true,
options: {
description: 'Example for how to model business data, use formulas, and manage complexity.',
@@ -2870,10 +2881,29 @@ export async function getFilterMenuState(): Promise<FilterMenuValue[]> {
*/
export async function refreshDismiss() {
await driver.navigate().refresh();
await (await driver.switchTo().alert()).accept();
await acceptAlert();
await waitForDocToLoad();
}
/**
* Accepts an alert.
*/
export async function acceptAlert() {
await (await driver.switchTo().alert()).accept();
}
/**
* Returns whether an alert is shown.
*/
export async function isAlertShown() {
try {
await driver.switchTo().alert();
return true;
} catch {
return false;
}
}
/**
* Dismisses any tutorial card that might be active.
*/

View File

@@ -112,6 +112,7 @@ export class HomeUtil {
}
// Make sure we revisit page in case login is changing.
await this.driver.get('about:blank');
await this._acceptAlertIfPresent();
// 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()) {
@@ -157,6 +158,7 @@ export class HomeUtil {
if (sid) { await testingHooks.setLoginSessionProfile(sid, null, org); }
} else {
await this.driver.get(`${this.server.getHost()}/logout`);
await this._acceptAlertIfPresent();
}
}
@@ -251,6 +253,7 @@ export class HomeUtil {
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`);
await this._acceptAlertIfPresent();
const cookie = await this.driver.manage().getCookie(process.env.GRIST_SESSION_COOKIE || 'grist_sid');
if (!cookie) { return null; }
return decodeURIComponent(cookie.value);
@@ -475,4 +478,12 @@ export class HomeUtil {
`);
}
}
private async _acceptAlertIfPresent() {
try {
await (await this.driver.switchTo().alert()).accept();
} catch {
/* There was no alert to accept. */
}
}
}

View File

@@ -0,0 +1,74 @@
/**
* Testing utilities used in Importer test suites.
*/
import {driver, stackWrapFunc, WebElementPromise} from 'mocha-webdriver';
import * as gu from 'test/nbrowser/gristUtils';
// Helper to get the input of a matching parse option in the ParseOptions dialog.
export const getParseOptionInput = stackWrapFunc((labelRE: RegExp): WebElementPromise =>
driver.findContent('.test-parseopts-opt', labelRE).find('input'));
type CellDiff = string|[string|undefined, string|undefined, string|undefined];
/**
* Returns preview diff cell values when the importer is updating existing records.
*
* If a cell has no diff, just the cell value is returned. Otherwise, a 3-tuple
* containing the parent, remote, and common values (in that order) is returned.
*/
export const getPreviewDiffCellValues = stackWrapFunc(async (cols: number[], rowNums: number[]) => {
return gu.getPreviewContents<CellDiff>(cols, rowNums, async (cell) => {
const hasParentDiff = await cell.find('.diff-parent').isPresent();
const hasRemoteDiff = await cell.find('.diff-remote').isPresent();
const hasCommonDiff = await cell.find('.diff-common').isPresent();
const isDiff = hasParentDiff || hasRemoteDiff || hasCommonDiff;
return !isDiff ? await cell.getText() :
[
hasParentDiff ? await cell.find('.diff-parent').getText() : undefined,
hasRemoteDiff ? await cell.find('.diff-remote').getText() : undefined,
hasCommonDiff ? await cell.find('.diff-common').getText() : undefined,
];
});
});
// Helper that waits for the diff preview to finish loading.
export const waitForDiffPreviewToLoad = stackWrapFunc(async (): Promise<void> => {
await driver.wait(() => driver.find('.test-importer-preview').isPresent(), 5000);
});
// Helper that gets the list of visible column matching rows to the left of the preview.
export const getColumnMatchingRows = stackWrapFunc(async (): Promise<{source: string, destination: string}[]> => {
return await driver.findAll('.test-importer-column-match-source-destination', async (el) => {
const source = await el.find('.test-importer-column-match-formula').getText();
const destination = await el.find('.test-importer-column-match-destination').getText();
return {source, destination};
});
});
export async function waitForColumnMapping() {
await driver.wait(() => driver.find(".test-importer-column-match-options").isDisplayed(), 300);
}
export async function openColumnMapping() {
const selected = driver.find('.test-importer-target-selected');
await selected.find('.test-importer-target-column-mapping').click();
await driver.sleep(200); // animation
await waitForColumnMapping();
}
export async function openTableMapping() {
await driver.find('.test-importer-table-mapping').click();
await driver.sleep(200); // animation
await driver.wait(() => driver.find(".test-importer-target").isDisplayed(), 300);
}
/**
* Opens the menu for the destination column, by clicking the source.
*/
export async function openSource(text: string|RegExp) {
await driver.findContent('.test-importer-column-match-destination', text)
.findClosest('.test-importer-column-match-source-destination')
.find('.test-importer-column-match-source').click();
}