From d1803dddb73101af63c93a57a4f1bfebdaec9401 Mon Sep 17 00:00:00 2001 From: Paul Fitzpatrick Date: Wed, 9 Oct 2024 10:59:09 -0400 Subject: [PATCH] (core) move access rule UI tests to core Summary: Moving some tests in preparation for some new development work. Test Plan: moving tests Reviewers: jordigh Reviewed By: jordigh Differential Revision: https://phab.getgrist.com/D4374 --- test/nbrowser/AccessRules1.ts | 505 +++++++++++++++ test/nbrowser/AccessRules2.ts | 862 +++++++++++++++++++++++++ test/nbrowser/AccessRules3.ts | 290 +++++++++ test/nbrowser/AccessRules4.ts | 89 +++ test/nbrowser/AccessRulesSchemaEdit.ts | 232 +++++++ 5 files changed, 1978 insertions(+) create mode 100644 test/nbrowser/AccessRules1.ts create mode 100644 test/nbrowser/AccessRules2.ts create mode 100644 test/nbrowser/AccessRules3.ts create mode 100644 test/nbrowser/AccessRules4.ts create mode 100644 test/nbrowser/AccessRulesSchemaEdit.ts diff --git a/test/nbrowser/AccessRules1.ts b/test/nbrowser/AccessRules1.ts new file mode 100644 index 00000000..4dcd78fe --- /dev/null +++ b/test/nbrowser/AccessRules1.ts @@ -0,0 +1,505 @@ +/** + * Test of the UI for Granular Access Control, part 1. + */ +import { assert, driver, Key, stackWrapFunc } from 'mocha-webdriver'; +import { enterRulePart, findDefaultRuleSet, findRuleSet, + findTable, triggerAutoComplete } from 'test/nbrowser/aclTestUtils'; +import * as gu from 'test/nbrowser/gristUtils'; +import { server } from 'test/nbrowser/testServer'; +import { setupTestSuite } from 'test/nbrowser/testUtils'; + +describe('AccessRules1', function() { + this.timeout(20000); + const cleanup = setupTestSuite(); + let docId: string; + + before(async function() { + // Import a test document we've set up for this. + const mainSession = await gu.session().teamSite.user('user1').login(); + docId = (await mainSession.tempDoc(cleanup, 'ACL-Test.grist', {load: false})).id; + + // Share it with a few users. + const api = mainSession.createHomeApi(); + await api.updateDocPermissions(docId, { users: { + [gu.translateUser("user2").email]: 'owners', + [gu.translateUser("user3").email]: 'editors', + } }); + return docId; + }); + + afterEach(() => gu.checkForErrors()); + + const getTableNamesToAddWidget = stackWrapFunc(async function(): Promise { + await gu.openAddWidgetToPage(); + const options = await driver.findAll('.test-wselect-table', e => e.getText()); + // Close add widget popup + await driver.sendKeys(Key.ESCAPE); + + assert.equal(options[0], "New Table"); + return options.slice(1); + }); + + const checkFullView = stackWrapFunc(async function(user: gu.TestUser) { + const session = await gu.session().teamSite.user(user).login(); + await session.loadDoc(`/doc/${docId}`); + + // Check that we can see and add widgets for FinancialsTable. + assert.deepEqual(await gu.getPageNames(), ['ClientsTable', 'FinancialsTable']); + assert.deepEqual(await getTableNamesToAddWidget(), ['ClientsTable', 'FinancialsTable']); + await gu.openPage('FinancialsTable'); + await gu.waitForServer(); + assert.deepEqual(await gu.getVisibleGridCells({cols: ['Year', 'Income', 'Expenses'], rowNums: [1, 2, 3]}), [ + '2010', '$123.40', '$540,000.00', + '2011', '$1,234.50', '$640,000.00', + '2022', '$1,234,567.00', '$0.55', + ]); + + // Check that we can see RumorsColumn of ClientsTable. + await gu.openPage('ClientsTable'); + await gu.waitForServer(); + assert.equal(await driver.findContent('.column_name', /RumorsColumn/).isPresent(), true); + assert.deepEqual(await gu.getVisibleGridCells({cols: ['RumorsColumn'], rowNums: [1, 3, 9, 13]}), [ + 'Secrets', '', 'Dark rumors', 'Buzz' + ]); + + // Check that we can all rows of ClientsTable + assert.deepEqual(await gu.getVisibleGridCells({cols: ['First Name', 'Shared'], rowNums: [1, 3, 9, 13]}), [ + 'Deina', 'false', + 'Terrence', 'true', + 'Rachele', 'true', + 'Erny', 'false', + ]); + }); + + const checkLimitedView = stackWrapFunc(async function(user: gu.TestUser) { + const session = await gu.session().teamSite.user(user).login(); + await session.loadDoc(`/doc/${docId}`); + + assert.deepEqual(await gu.getPageNames(), ['ClientsTable']); + assert.deepEqual(await getTableNamesToAddWidget(), ['ClientsTable']); + await gu.openPage('ClientsTable'); + await gu.waitForServer(); + assert.equal(await driver.findContent('.column_name', /RumorsColumn/).isPresent(), false); + }); + + const checkLimitedUpdateMemo = stackWrapFunc(async function(user: gu.TestUser, memo: string = '') { + const session = await gu.session().teamSite.user(user).login(); + await session.loadDoc(`/doc/${docId}`); + await gu.openPage('ClientsTable'); + await gu.waitForServer(); + await gu.getCell(0, 1).click(); + await gu.sendKeys('Will it work?', Key.ENTER); + await gu.waitForServer(); + if (memo === '') { + assert.isFalse(await driver.find('.test-notifier-toast-memo').isPresent()); + } else { + await driver.findContent('.test-notifier-toast-memo', memo); + } + }); + + it('should support rules for tables and columns', async function() { + this.timeout(40000); + // Check that users 1 and 3 can see everything initially. + await checkFullView('user1'); + await checkFullView('user3'); + + // Load Access Rules UI. + const mainSession = await gu.session().teamSite.user('user1').login(); + await mainSession.loadDoc(`/doc/${docId}`); + await driver.find('.test-tools-access-rules').click(); + await driver.findWait('.test-rule-set', 2000); // Wait for initialization fetch to complete. + + // Make FinancialsTable private to user1. + await driver.findContentWait('button', /Add Table Rules/, 2000).click(); + await driver.findContentWait('.grist-floating-menu li', /FinancialsTable/, 3000).click(); + let ruleSet = findDefaultRuleSet(/FinancialsTable/); + await enterRulePart(ruleSet, 1, null, 'Deny All'); + await ruleSet.find('.test-rule-part .test-rule-add').click(); + await enterRulePart(ruleSet, 1, `user.Email == '${gu.translateUser('user1').email}'`, 'Allow All'); + + // Make RumorsColumn of ClientsTable private to user1. + await driver.findContentWait('button', /Add Table Rules/, 2000).click(); + await driver.findContent('.grist-floating-menu li', /ClientsTable/).click(); + await findTable(/ClientsTable/).find('.test-rule-table-menu-btn').click(); + await driver.findContent('.grist-floating-menu li', /Add Column Rule/).click(); + ruleSet = findRuleSet(/ClientsTable/, 1); + + await ruleSet.find('.test-rule-resource .test-select-open').click(); + assert.deepEqual( + await driver.findAll('.test-select-menu li', el => el.getText()), + [ + 'Agent_Email', + 'Email', + 'First_Name', + 'Last_Name', + 'Phone', + 'RumorsColumn', + 'Shared', + ] + ); + await driver.findContent('.test-select-menu li', 'RumorsColumn').click(); + await enterRulePart(ruleSet, 1, null, 'Deny All'); + await ruleSet.find('.test-rule-part .test-rule-add').click(); + await enterRulePart(ruleSet, 1, `user.Email == '${gu.translateUser('user1').email}'`, 'Allow All'); + + // Make rows of ClientsTable only visible to the team if assigned to them, or marked as shared. + ruleSet = findDefaultRuleSet(/ClientsTable/); + await ruleSet.find('.test-rule-part .test-rule-add').click(); + await ruleSet.find('.test-rule-part .test-rule-add').click(); + await ruleSet.find('.test-rule-part .test-rule-add').click(); + await enterRulePart(ruleSet, 1, `user.Email == '${gu.translateUser('user1').email}'`, 'Allow All'); + await enterRulePart(ruleSet, 2, `rec.Shared`, {R: 'allow'}); + await enterRulePart(ruleSet, 3, `user.Email == rec.Agent_Email`, {R: 'allow'}); + await enterRulePart(ruleSet, 4, null, 'Deny All'); + + await driver.find('.test-rules-save').click(); + await gu.waitForServer(); + + // Check that user1 can see FinancialsTable, RumorsColumn, and all rows of ClientsTable. + await checkFullView('user1'); + + // Check that user2 cannot see FinancialsTable or RumorsColumn. + await checkLimitedView('user2'); + + // Check that user2 only sees certain rows of ClientsTable. + assert.deepEqual(await gu.getVisibleGridCells( + {cols: ['First Name', 'Agent Email', 'Shared'], rowNums: [1, 3, 6, 7, 8, 9, 10]} + ), [ + // Everyone assigned to charon is visible. + 'Deina', 'gristoid+charon@gmail.com', 'false', + 'Terrence', 'gristoid+charon@gmail.com', 'true', + 'Wyndham', 'gristoid+charon@gmail.com', 'false', + // And everyone with Shared = true is visible. + 'Rachele', 'gristoid+chimpy@gmail.com', 'true', + 'Elianore', 'gristoid+kiwi@gmail.com', 'true', + 'Ellsworth', 'gristoid+kiwi@gmail.com', 'true', + '', '', '' + ]); + + // Check that user3 cannot see FinancialsTable or RumorsColumn. + await checkLimitedView('user3'); + + // Check that user3 only sees certain rows of ClientsTable. + assert.deepEqual(await gu.getVisibleGridCells( + {cols: ['First Name', 'Agent Email', 'Shared'], rowNums: [1, 2, 3, 4, 9, 11, 12]} + ), [ + // And everyone with Shared = true is visible. + 'Siobhan', 'gristoid+charon@gmail.com', 'true', + 'Terrence', 'gristoid+charon@gmail.com', 'true', + 'Rachele', 'gristoid+chimpy@gmail.com', 'true', + // Everyone assigned to kiwi is visible. + 'Kettie', 'gristoid+kiwi@gmail.com', 'false', + 'Neely', 'gristoid+kiwi@gmail.com', 'false', + 'Ellsworth', 'gristoid+kiwi@gmail.com', 'true', + '', '', '', + ]); + }); + + it('should support rules with rec.id in formula', async function() { + // This tests behavior that broke after a bug was introduced. + const mainSession = await gu.session().teamSite.user('user1').login(); + await mainSession.loadDoc(`/doc/${docId}`); + await driver.find('.test-tools-access-rules').click(); + await driver.findWait('.test-rule-set', 2000); + const ruleSet = findDefaultRuleSet(/FinancialsTable/); + await enterRulePart(ruleSet, 1, `rec.id == 1`, 'Allow All'); + await driver.find('.test-rules-save').click(); + await gu.waitForServer(); + await gu.openPage('FinancialsTable'); + await gu.waitForServer(); + assert.deepEqual( + await gu.getVisibleGridCells({cols: ['Expenses', 'Income', 'Year'], rowNums: [1, 2]}), + [ + '$540,000.00', '$123.40', '2010', + '', '', '' + ] + ); + await gu.undo(); + }); + + it('should support adding memos', async function() { + const mainSession = await gu.session().teamSite.user('user1').login(); + await mainSession.loadDoc(`/doc/${docId}`); + await driver.find('.test-tools-access-rules').click(); + await driver.findWait('.test-rule-set', 2000); // Wait for initialization fetch to complete. + + // Change the catch-all rule on ClientsTable to read-only permission, and give it a memo. + const ruleSet = findDefaultRuleSet(/ClientsTable/); + await enterRulePart(ruleSet, 4, null, 'Read Only', 'Sorry, this table is read-only.'); + await driver.find('.test-rules-save').click(); + await gu.waitForServer(); + + // Check that user2 sees the memo when trying to update ClientsTable. + await checkLimitedUpdateMemo('user2', 'Sorry, this table is read-only'); + }); + + it('should support removing memos', async function() { + const mainSession = await gu.session().teamSite.user('user1').login(); + await mainSession.loadDoc(`/doc/${docId}`); + await driver.find('.test-tools-access-rules').click(); + await driver.findWait('.test-rule-set', 2000); // Wait for initialization fetch to complete. + + // Delete the memo for the ClientsTable catch-all rule. + await gu.session().teamSite.user('user1').login(); + await driver.find('.test-tools-access-rules').click(); + await driver.findWait('.test-rule-set', 2000); + const ruleSet = findDefaultRuleSet(/ClientsTable/); + await ruleSet.find('.test-rule-part-and-memo:nth-child(4) .test-rule-memo-remove').click(); + await driver.find('.test-rules-save').click(); + await gu.waitForServer(); + + // Check that user2 no longer sees the memo. + await checkLimitedUpdateMemo('user2'); + }); + + it('should not produce unnecessary user actions when changing rules', async function() { + const mainSession = await gu.session().teamSite.user('user1').login(); + await mainSession.loadDoc(`/doc/${docId}`); + await driver.find('.test-tools-access-rules').click(); + await driver.findWait('.test-rule-set', 2000); // Wait for initialization fetch to complete. + + // Revert a rule that was modified in an earlier test. + let ruleSet = findDefaultRuleSet(/ClientsTable/); + await enterRulePart(ruleSet, 4, null, 'Deny All'); + await driver.find('.test-rules-save').click(); + await gu.waitForServer(); + assert.equal(await driver.find('.test-rules-save').isDisplayed(), false); + + // Add/remove a rule; unchanged AccessRules should still show as Saved. + ruleSet = findDefaultRuleSet(/ClientsTable/); + await ruleSet.find('.test-rule-part:nth-child(1) .test-rule-add').click(); + await ruleSet.find('.test-rule-part:nth-child(1) .test-rule-remove').click(); + assert.equal(await driver.find('.test-rules-save').isDisplayed(), false); + + // Add a rule part, remove another rule part, and save. + ruleSet = findDefaultRuleSet(/ClientsTable/); + await ruleSet.find('.test-rule-part:nth-child(1) .test-rule-add').click(); + // Add a rule giving user2 (charon) access. + await enterRulePart(ruleSet, 1, `user.Email == '${gu.translateUser('user2').email}'`, {R: 'allow'}); + // Remove the rule giving everyone access to Shared rows. + await ruleSet.find('.test-rule-part-and-memo:nth-child(3) .test-rule-remove').click(); + + // Check that the Save button is enabled, and save. + assert.equal(await driver.find('.test-rules-save').isDisplayed(), true); + await gu.userActionsCollect(); + await driver.find('.test-rules-save').click(); + await gu.waitForServer(); + + await gu.userActionsVerify([ + ["BulkRemoveRecord", "_grist_ACLRules", [6]], + ["BulkAddRecord", "_grist_ACLRules", [-1], { + aclFormula: ["user.Email == 'gristoid+charon@gmail.com'"], + permissionsText: ["+R"], + resource: [5], + rulePos: [4.5], + memo: [""], + }], + ]); + assert.equal(await driver.find('.test-rules-save').isDisplayed(), false); + + // Check that the rule has an effect: user3 has limited access, and cannot see Shared rows except their own. + await checkLimitedView('user3'); + assert.deepEqual(await gu.getVisibleGridCells( + {cols: ['First Name', 'Agent Email', 'Shared'], rowNums: [1, 6, 8, 9]} + ), [ + 'Kettie', 'gristoid+kiwi@gmail.com', 'false', + 'Neely', 'gristoid+kiwi@gmail.com', 'false', + 'Ellsworth', 'gristoid+kiwi@gmail.com', 'true', + '', '', '', + ]); + }); + + it('should report errors when typed-in rule is invalid', async function() { + const mainSession = await gu.session().teamSite.user('user1').login(); + await mainSession.loadDoc(`/doc/${docId}`); + await driver.find('.test-tools-access-rules').click(); + await driver.findWait('.test-rule-set', 2000); + const ruleSet = findDefaultRuleSet(/ClientsTable/); + await ruleSet.find('.test-rule-part:nth-child(1) .test-rule-add').click(); + await enterRulePart(ruleSet, 1, `user.Access === 'owners'`, {R: 'allow'}); + await gu.waitForServer(); + + // Check that an error is shown, and save button is disabled. + assert.match(await ruleSet.find('.test-rule-part:nth-child(1) .test-rule-error').getText(), + /^SyntaxError/); + assert.equal(await driver.find('.test-rules-save').isDisplayed(), false); + assert.equal(await driver.find('.test-rules-non-save').isDisplayed(), true); + assert.equal(await driver.find('.test-rules-non-save').getText(), 'Invalid'); + + // Check that invalid names are also detected. + await enterRulePart(ruleSet, 1, `fuser.Access == 'owners'`, {R: 'allow'}); + await gu.waitForServer(); + assert.match(await ruleSet.find('.test-rule-part:nth-child(1) .test-rule-error').getText(), + /Unknown variable 'fuser'/); + assert.equal(await driver.find('.test-rules-save').isDisplayed(), false); + assert.equal(await driver.find('.test-rules-non-save').isDisplayed(), true); + assert.equal(await driver.find('.test-rules-non-save').getText(), 'Invalid'); + + // Check that invalid colIds are detected. + await enterRulePart(ruleSet, 1, `rec.Shared == True or rec.Sharedd == True`, {R: 'allow'}); + await gu.waitForServer(); + assert.match(await ruleSet.find('.test-rule-part:nth-child(1) .test-rule-error').getText(), + /Invalid columns: Sharedd/); + assert.equal(await driver.find('.test-rules-save').isDisplayed(), false); + assert.equal(await driver.find('.test-rules-non-save').isDisplayed(), true); + assert.equal(await driver.find('.test-rules-non-save').getText(), 'Invalid'); + + // Check that saving is also disabled while checking rules. + await server.pauseUntil(async () => { + await enterRulePart(ruleSet, 1, `user.Access == 'owners'`, {R: 'allow'}); + assert.equal(await ruleSet.find('.test-rule-part:nth-child(1) .test-rule-error').isPresent(), false); + assert.equal(await driver.find('.test-rules-save').isDisplayed(), false); + assert.equal(await driver.find('.test-rules-non-save').isDisplayed(), true); + assert.equal(await driver.find('.test-rules-non-save').getText(), 'Checking…'); + }); + + // Once rules are checked to be valid, the Save button is shown. + await gu.waitForServer(); + assert.equal(await ruleSet.find('.test-rule-part:nth-child(1) .test-rule-error').isPresent(), false); + assert.equal(await driver.find('.test-rules-save').isDisplayed(), true); + assert.equal(await driver.find('.test-rules-non-save').isDisplayed(), false); + + // Revert the changes in this test case. + await driver.find('.test-rules-revert').click(); + await gu.waitForServer(); + }); + + it('should allow read control for columns with rec in formula', async function() { + const mainSession = await gu.session().teamSite.user('user1').login(); + await mainSession.loadDoc(`/doc/${docId}`); + await driver.find('.test-tools-access-rules').click(); + await driver.findWait('.test-rule-set', 2000); + + // Add a column rule that uses rec but doesn't set read permission. + await findTable(/FinancialsTable/).find('.test-rule-table-menu-btn').click(); + await driver.findContent('.grist-floating-menu li', /Add Column Rule/).click(); + let ruleSet = findRuleSet(/FinancialsTable/, 1); + await ruleSet.find('.test-rule-resource .test-select-open').click(); + assert.deepEqual( + await driver.findAll('.test-select-menu li', el => el.getText()), + ['Expenses', 'Income', 'Year'] + ); + await driver.findContent('.test-select-menu li', 'Year').click(); + await enterRulePart(ruleSet, 1, 'rec.Year == "yore"', {U: 'deny'}); + await gu.waitForServer(); + await driver.find('.test-rules-save').click(); + await gu.waitForServer(); + + // Attempting to set read bit should no longer result in a notification. + await driver.findWait('.test-rule-set', 2000); + ruleSet = findRuleSet(/FinancialsTable/, 1); + await assert.isFulfilled(enterRulePart(ruleSet, 1, null, {R: 'deny'})); + await gu.checkForErrors(); + + // Remove rule. + await ruleSet.find('.test-rule-part:nth-child(1) .test-rule-remove').click(); + await driver.find('.test-rules-save').click(); + await gu.waitForServer(); + }); + + it('should report possible order-dependencies', async function() { + // Make a rule for FinancialsTable.Year and FinancialsTable.Income that denies something. + const mainSession = await gu.session().teamSite.user('user1').login(); + await mainSession.loadDoc(`/doc/${docId}/p/acl`, {wait: false}); + await driver.findWait('.test-rule-set', 2000); + await findTable(/FinancialsTable/).find('.test-rule-table-menu-btn').click(); + await driver.findContent('.grist-floating-menu li', /Add Column Rule/).click(); + let ruleSet = findRuleSet(/FinancialsTable/, 1); + await ruleSet.find('.test-rule-resource .test-select-open').click(); + assert.deepEqual( + await driver.findAll('.test-select-menu li', el => el.getText()), + ['Expenses', 'Income', 'Year'] + ); + await driver.findContent('.test-select-menu li', 'Year').click(); + await ruleSet.find('.test-rule-resource .test-select-open').click(); + await driver.findContent('.test-select-menu li', 'Income').click(); + await enterRulePart(ruleSet, 1, 'user.Email == "noone1"', {R: 'deny'}); + + // Make a rule for FinancialsTable.Year and FinancialsTable.Expenses that allows something. + await findTable(/FinancialsTable/).find('.test-rule-table-menu-btn').click(); + await driver.findContent('.grist-floating-menu li', /Add Column Rule/).click(); + ruleSet = findRuleSet(/FinancialsTable/, 2); + await ruleSet.find('.test-rule-resource .test-select-open').click(); + assert.deepEqual( + await driver.findAll('.test-select-menu li', el => el.getText()), + ['Expenses', 'Income', 'Year'] + ); + await driver.findContent('.test-select-menu li', 'Year').click(); + await ruleSet.find('.test-rule-resource .test-select-open').click(); + await driver.findContent('.test-select-menu li', 'Expenses').click(); + await enterRulePart(ruleSet, 1, 'user.Email == "noone2"', {R: 'allow'}); + + // Check that trying to save throws an error. + await driver.find('.test-rules-save').click(); + await gu.waitForServer(); + await driver.findContent('.test-notifier-toast-wrapper', + /Column Year appears .* table FinancialsTable .* might be order-dependent/); + await gu.wipeToasts(); + + // Tweak one rule to be compatible (no order dependency) with the other, and recheck. + await enterRulePart(ruleSet, 1, 'user.Email == "noone2"', {R: 'deny'}); + await gu.waitForServer(); + assert.lengthOf(await gu.getToasts(), 0); + }); + + it("'Add Widget to Page' should be disabled", async() => { + const mainSession = await gu.session().teamSite.user('user1').login(); + await mainSession.loadDoc(`/doc/${docId}/p/acl`, {wait: false}); + await driver.findWait('.test-rule-set', 2000); + await driver.find('.test-dp-add-new').doClick(); + assert.includeMembers(await driver.findAll('.test-dp-add-new-menu > li.disabled', (imp) => imp.getText()), [ + "Add Widget to Page", + ]); + await driver.sendKeys(Key.ESCAPE); + await gu.waitAppFocus(); + }); + + it('should support dollar syntax in the editor', async function() { + const mainSession = await gu.session().teamSite.user('user1').login(); + await mainSession.loadDoc(`/doc/${docId}/p/acl`, {wait: false}); + // Wait for ACL page to load. + await driver.findWait('.test-rule-set', 2000); + // Add rules for FinancialsTable. + const ruleSet = findRuleSet(/FinancialsTable/, 1); + // Use new syntax to hide Year 2010. + + // First make sure we have autocomplete working. + await triggerAutoComplete(ruleSet, 1, '$'); + await gu.waitToPass(async () => { + const completions = await driver.findAll('.ace_autocomplete .ace_line', el => el.getText()); + assert.deepEqual(completions, [ + '$\nExpenses\n ', + '$\nid\n ', + '$\nIncome\n ', + '$\nYear\n ', + ]); + }); + await driver.sendKeys(Key.ESCAPE); + + // Next test that column is understood. + await enterRulePart(ruleSet, 1, '1 == 1 and (rec.Year != "2" and $Year2 == "2010")', {R: 'deny'}); + await gu.waitForServer(); + assert.match(await ruleSet.find('.test-rule-part:nth-child(1) .test-rule-error').getText(), + /Invalid columns: Year2/); + assert.equal(await driver.find('.test-rules-save').isDisplayed(), false); + assert.equal(await driver.find('.test-rules-non-save').isDisplayed(), true); + assert.equal(await driver.find('.test-rules-non-save').getText(), 'Invalid'); + + // Now apply the rule. + await enterRulePart(ruleSet, 1, '($Year == "2010" or rec.Year == "2011")', {R: 'deny'}); + await gu.waitForServer(); + // Remove second rule that hides table for everyone. + await ruleSet.find('.test-rule-part-and-memo:nth-child(2) .test-rule-remove').click(); + + await driver.find('.test-rules-save').click(); + await gu.waitForServer(); + // Test that this rule works. + await gu.openPage("FinancialsTable"); + assert.deepEqual(await gu.getVisibleGridCells( + {cols: ['Year'], rowNums: [1, 2]} + ), [ + '2022', + '', + ]); + }); +}); diff --git a/test/nbrowser/AccessRules2.ts b/test/nbrowser/AccessRules2.ts new file mode 100644 index 00000000..a19065a6 --- /dev/null +++ b/test/nbrowser/AccessRules2.ts @@ -0,0 +1,862 @@ +/** + * Test of the UI for Granular Access Control, part 2. + */ +import escapeRegExp from 'lodash/escapeRegExp'; +import { assert, driver, Key, stackWrapFunc, WebElement } from 'mocha-webdriver'; +import { enterRulePart, findDefaultRuleSet, findRuleSet, findTable, + getRuleText } from 'test/nbrowser/aclTestUtils'; +import * as gu from 'test/nbrowser/gristUtils'; +import { setupTestSuite } from 'test/nbrowser/testUtils'; + +const isChecked = stackWrapFunc(async function(el: WebElement): Promise { + return await el.getAttribute('checked') !== null; +}); + +const isDisabled = stackWrapFunc(async function(el: WebElement): Promise { + return await el.getAttribute('disabled') !== null; +}); + +describe("AccessRules2", function() { + this.timeout(40000); + const cleanup = setupTestSuite(); + let docId: string; + + before(async function() { + // Import a test document we've set up for this. + const mainSession = await gu.session().teamSite.user('user1').login(); + docId = (await mainSession.tempDoc(cleanup, 'ACL-Test.grist', {load: false})).id; + + // Share it with a few users. + const api = mainSession.createHomeApi(); + await api.updateDocPermissions(docId, { users: { + [gu.translateUser("user2").email]: 'owners', + [gu.translateUser("user3").email]: 'editors', + } }); + return docId; + }); + + afterEach(() => gu.checkForErrors()); + + let viewAsUrl: string; + + it('should list users with access to the document, plus examples', async function() { + // Open AccessRules page, and click Users button. + const mainSession = await gu.session().teamSite.user('user1').login(); + await mainSession.loadDoc(`/doc/${docId}`); + await driver.find('.test-tools-access-rules').click(); + await driver.findContentWait('button', /View As/, 3000).click(); + + const [email1, email2, email3] = ['user1', 'user2', 'user3'].map(u => gu.translateUser(u as any).email); + // All users the doc is shared with should be listed, with correct Access. + assert.equal(await driver.findContent('.test-acl-user-item', email1).isPresent(), false); + assert.equal(await driver.findContent('.test-acl-user-item', email2) + .find('.test-acl-user-access').getText(), '(Owner)'); + assert.equal(await driver.findContent('.test-acl-user-item', email3) + .find('.test-acl-user-access').getText(), '(Editor)'); + + // Check examples are present. + assert.deepEqual( + (await driver.findAll('.test-acl-user-item span', e => e.getText())) + .filter(txt => txt.includes('@example')), + ['owner@example.com', 'editor1@example.com', 'editor2@example.com', 'viewer@example.com', 'unknown@example.com']); + + // Add a user attribute table. + await mainSession.createHomeApi().applyUserActions(docId, [ + ['AddTable', 'Zones', [{id: 'Email'}, {id: 'City'}]], + ['AddRecord', 'Zones', null, {Email: email1, City: 'Seattle'}], + ['AddRecord', 'Zones', null, {Email: email2, City: 'Boston'}], + ['AddRecord', 'Zones', null, {Email: 'fast@speed.com', City: 'Cambridge'}], + ['AddRecord', 'Zones', null, {Email: 'slow@speed.com', City: 'Springfield'}], + ]); + const records = await mainSession.createHomeApi().applyUserActions(docId, [ + ['AddRecord', '_grist_ACLResources', -1, {tableId: '*', colIds: '*'}], + ['AddRecord', '_grist_ACLRules', null, { + resource: -1, userAttributes: JSON.stringify({ + name: 'Zone', + tableId: 'Zones', + charId: 'Email', + lookupColId: 'Email', + }), + }], + ]); + + // Refresh list. + await mainSession.loadDoc(`/doc/${docId}`); + await driver.find('.test-tools-access-rules').click(); + await driver.findContentWait('button', /View As/, 3000).click(); + + // Check users from attribute table are present. + assert.deepEqual( + (await driver.findAll('.test-acl-user-item span', e => e.getText())) + .filter(txt => txt.includes('@speed')), + ['fast@speed.com', 'slow@speed.com']); + + // 'View As' is present, except for current user. + assert.equal(await driver.findContent('.test-acl-user-item', email1) + .isPresent(), false); + assert.equal(await driver.findContent('.test-acl-user-item', email2) + .isPresent(), true); + + // Click "View As", and wait for doc to reload. + await driver.findContent('.test-acl-user-item', email3).click(); + await gu.waitForUrl(/aclAsUser/); + await gu.waitForDocToLoad(); + // Make sure we are not on ACL page. + viewAsUrl = await driver.getCurrentUrl(); + assert.notMatch(viewAsUrl, /\/p\/acl/); + + // Check for a tag in the doc header. + assert.equal(await driver.findWait('.test-view-as-banner', 2000).isPresent(), true); + assert.match(await driver.find('.test-view-as-banner .test-select-open').getText(), + new RegExp(gu.translateUser('user3').name, 'i')); + + // check the aclAsUser parameter on the url persists after navigating to another page + await gu.getPageItem('FinancialsTable').click(); + await gu.waitForDocToLoad(); + assert.equal(await gu.getActiveSectionTitle(), 'FINANCIALSTABLE'); + assert.match(await driver.getCurrentUrl(), /aclAsUser/); + + // Revert. + await driver.find('.test-view-as-banner .test-revert').click(); + await gu.waitForDocToLoad(); + + // Delete user attribute table. + await mainSession.createHomeApi().applyUserActions(docId, [ + ['RemoveRecord', '_grist_ACLRules', records.retValues[1]], + ['RemoveRecord', '_grist_ACLResources', records.retValues[0]], + ['RemoveTable', 'Zones'], + ]); + }); + + it('should allow returning from view-as mode via View As banner', async function() { + // Check that we can revert view-as and visit Access Rules page with the button next to the + // page item. + await driver.get(viewAsUrl); + await gu.waitForUrl(/aclAsUser/); + await gu.waitForDocToLoad(); + + await driver.find('.test-view-as-banner .test-revert').click(); + await gu.waitForDocToLoad(); + assert.equal(await driver.find('.test-view-as-banner').isPresent(), false); + }); + + it('should allow switching users in view-as mode', async function() { + + // open doc in view-as mode + await driver.get(viewAsUrl); + await gu.waitForUrl(/aclAsUser/); + await gu.waitForDocToLoad(); + + // check name + assert.match(await driver.find('.test-view-as-banner .test-select-open').getText(), + new RegExp(gu.translateUser('user3').name, 'i')); + + // select other user + await driver.find('.test-view-as-banner .test-select-open').click(); + await driver.findContent('.test-acl-user-item', gu.translateUser('user1').name).click(); + + // check name changed + await gu.waitForDocToLoad(); + + // check name updated correctly + assert.match(await driver.find('.test-view-as-banner .test-select-open').getText(), /Chimpy/); + }); + + it('should make all tables/columns available to editor of ACL rules', async function() { + const mainSession = await gu.session().teamSite.user('user1').login(); + await mainSession.loadDoc(`/doc/${docId}`); + await driver.find('.test-tools-access-rules').click(); + await driver.findWait('.test-rule-set', 2000); + + // Add table rules for ClientsTable. + await driver.findContentWait('button', /Add Table Rules/, 2000).click(); + const options = await driver.findAll('.grist-floating-menu li', e => e.getText()); + assert.deepEqual(options, ["ClientsTable", "ClientsTable [by Shared]", "FinancialsTable"]); + await driver.findContent('.grist-floating-menu li', /ClientsTable/).click(); + + // Add rules hiding First_Name, Last_Name columns. + await findTable(/ClientsTable/).find('.test-rule-table-menu-btn').click(); + await driver.findContent('.grist-floating-menu li', /Add Column Rule/).click(); + let ruleSet = findRuleSet(/ClientsTable/, 1); + await ruleSet.find('.test-rule-resource .test-select-open').click(); + await driver.findContent('.test-select-menu li', 'First_Name').click(); + await ruleSet.find('.test-rule-resource .test-select-open').click(); + await driver.findContent('.test-select-menu li', 'Last_Name').click(); + await enterRulePart(ruleSet, 1, null, {R: 'deny'}); + + // Add table rule entirely hiding FinancialsTable. + await driver.findContentWait('button', /Add Table Rules/, 2000).click(); + await driver.findContent('.grist-floating-menu li', /FinancialsTable/).click(); + ruleSet = findDefaultRuleSet(/FinancialsTable/); + await enterRulePart(ruleSet, 1, null, {R: 'deny'}); + + // Save and reload. + await driver.find('.test-rules-save').click(); + await gu.waitForServer(); + await driver.navigate().refresh(); + await driver.findWait('.test-rule-set', 5000); + + // Now this user isn't aware of the inaccessible columns and table, but ACL rules should + // still list them. + + // Remove Last_Name column from being blocked. Check that it's now available in dropdown. + await driver.findWait('.test-rule-set', 2000); + ruleSet = findRuleSet(/ClientsTable/, 1); + await ruleSet.find('.test-rule-resource').click(); + await ruleSet.findContent('.test-acl-column', 'Last_Name').find('.test-acl-col-remove').click(); + await ruleSet.find('.test-rule-resource .test-select-open').click(); + assert.equal(await driver.findContent('.test-select-menu li', 'Last_Name').isPresent(), true); + await driver.sendKeys(Key.ESCAPE); // Close menu. + await driver.sendKeys(Key.ESCAPE); // Close editing of columns. + + // Remove FinancialsTable from being blocked. Check that it's now available in dropdown. + ruleSet = findRuleSet(/FinancialsTable/, 1); + await ruleSet.find('.test-rule-part-and-memo:nth-child(1) .test-rule-remove').click(); + await driver.findContentWait('button', /Add Table Rules/, 2000).click(); + assert.equal(await driver.findContent('.grist-floating-menu li', /FinancialsTable/).isPresent(), true); + assert.equal(await driver.findContent('.grist-floating-menu li', /FinancialsTable/).matches('.disabled'), false); + await driver.sendKeys(Key.ESCAPE); + + // Save + await driver.find('.test-rules-save').click(); + await gu.waitForServer(); + + // Remove First_Name column from being blocked, and add back Last_Name column. + ruleSet = findRuleSet(/ClientsTable/, 1); + await ruleSet.find('.test-rule-resource').click(); + await ruleSet.findContent('.test-acl-column', 'First_Name').find('.test-acl-col-remove').click(); + await ruleSet.find('.test-rule-resource .test-select-open').click(); + await driver.findContent('.test-select-menu li', 'Last_Name').click(); + + // Add back FinancialsTable to be blocked. + assert.equal(await findRuleSet(/FinancialsTable/, 1).isPresent(), false); + await driver.findContentWait('button', /Add Table Rules/, 2000).click(); + await driver.findContent('.grist-floating-menu li', /FinancialsTable/).click(); + ruleSet = findDefaultRuleSet(/FinancialsTable/); + await enterRulePart(ruleSet, 1, null, {R: 'deny'}); + + // Save and reload. + await driver.find('.test-rules-save').click(); + await gu.waitForServer(); + await driver.navigate().refresh(); + await driver.findWait('.test-rule-set', 5000); + + // Verify the results. + ruleSet = findRuleSet(/ClientsTable/, 1); + assert.deepEqual(await ruleSet.findAll('.test-acl-column', el => el.getText()), ['Last_Name']); + assert.equal(await findDefaultRuleSet(/FinancialsTable/).isPresent(), true); + + // Remove the installed "Deny" rules to restore the initial state. + await findRuleSet(/ClientsTable/, 1).find('.test-rule-remove').click(); + await findRuleSet(/FinancialsTable/, 1).find('.test-rule-remove').click(); + await driver.find('.test-rules-save').click(); + await gu.waitForServer(); + }); + + it('should support user-attribute rules', async function() { + // Add a table to user for a user-attribute rule. User the API for test simplicity. + const mainSession = await gu.session().teamSite.user('user1').login(); + const api = mainSession.createHomeApi(); + await api.applyUserActions(docId, [ + ['AddTable', 'Access', [{id: 'Email'}, {id: 'SharedOnly', type: 'Bool'}]], + ['AddRecord', 'Access', null, {Email: gu.translateUser('user1').email, SharedOnly: true}], + ['AddRecord', 'Access', null, {Email: gu.translateUser('user2').email, SharedOnly: true}], + ['AddRecord', 'Access', null, {Email: gu.translateUser('user3').email, SharedOnly: true}], + ]); + + // Now use the UI to add a user-attribute rule. + await mainSession.loadDoc(`/doc/${docId}`); + await driver.find('.test-tools-access-rules').click(); + await gu.waitForServer(); + await driver.findContentWait('button', /Add User Attributes/, 2000).click(); + const userAttrRule = await driver.find('.test-rule-userattr'); + await userAttrRule.find('.test-rule-userattr-name').click(); + await driver.sendKeys("MyAccess", Key.ENTER); + + // Type 'Email' into the attribute ace editor, which has 'user.' prefilled. + await userAttrRule.find('.test-rule-userattr-attr').click(); + await driver.sendKeys("Email", Key.ENTER); + + // Check that Table field offers a dropdown. + await userAttrRule.find('.test-rule-userattr-table').click(); + assert.deepEqual(await driver.findAll('.test-select-menu li', el => el.getText()), + ['Access', 'ClientsTable', 'ClientsTable [by Shared]', 'FinancialsTable']); + + // Select a table and check that the Column field offers a dropdown. + await driver.findContent('.test-select-menu li', 'ClientsTable').click(); + await userAttrRule.find('.test-rule-userattr-col').click(); + assert.includeMembers(await driver.findAll('.test-select-menu li', el => el.getText()), + ['Agent_Email', 'Email', 'First_Name']); + + // Select a different table, and check that the Column field dropdown gets updated. + await userAttrRule.find('.test-rule-userattr-table').click(); + await driver.sendKeys("Access", Key.ENTER); + await userAttrRule.find('.test-rule-userattr-col').click(); + assert.deepEqual(await driver.findAll('.test-select-menu li', el => el.getText()), + ['Email', 'SharedOnly', 'id']); + await driver.sendKeys("Email", Key.ENTER); + + // Remove ClientTable rules, and add a new one using the new UserAttribute. + if (await findTable(/ClientsTable/).isPresent()) { + await findTable(/ClientsTable/).find('.test-rule-table-menu-btn').click(); + await driver.findContent('.grist-floating-menu li', /Delete Table Rules/).click(); + } + await driver.findContentWait('button', /Add Table Rules/, 2000).click(); + await driver.findContent('.grist-floating-menu li', /ClientsTable/).click(); + const ruleSet = findDefaultRuleSet(/ClientsTable/); + await ruleSet.find('.test-rule-part .test-rule-add').click(); + // newRec term in the following does nothing, it is just there to test renaming later. + await enterRulePart(ruleSet, 1, `not user.MyAccess.SharedOnly or rec.Shared or newRec.Shared`, + {R: 'allow'}); + await enterRulePart(ruleSet, 2, null, 'Deny All'); + await driver.find('.test-rules-save').click(); + await gu.waitToPass(async () => { + await gu.openPage('ClientsTable'); + assert.equal(await gu.getGridRowCount(), 6); + }); + + // Now toggle the value in the Access table. + await gu.getPageItem('Access').click(); + await gu.getCell({col: 'SharedOnly', rowNum: 1}).find('.widget_checkbox').click(); + await gu.waitToPass(async () => { + await gu.openPage('ClientsTable'); + assert.equal(await gu.getGridRowCount(), 20); + }); + }); + + it('should allow adding an "Everyone Else" rule if one does not exist', async function() { + // Load the page with rules. + const mainSession = await gu.session().teamSite.user('user1').login(); + await mainSession.loadDoc(`/doc/${docId}`); + await driver.find('.test-tools-access-rules').click(); + await driver.findWait('.test-rule-set', 2000); + + // ClientsTable rules, based on previous tests, includes an "Everyone Else" setting. + assert.isTrue(await findTable(/ClientsTable/).isPresent()); + let ruleSet = findDefaultRuleSet(/ClientsTable/); + assert.lengthOf(await ruleSet.findAll('.test-rule-part'), 2); + assert.lengthOf(await ruleSet.findAll('.test-rule-add'), 2); + assert.equal( + await ruleSet.find('.test-rule-part-and-memo:nth-child(2) .test-rule-acl-formula').getText(), + "Everyone Else" + ); + assert.equal(await ruleSet.find('.test-rule-extra-add').isPresent(), false); + + // Delete it, and check that a plus button appears. + await ruleSet.find('.test-rule-part-and-memo:nth-child(2) .test-rule-remove').click(); + assert.lengthOf(await ruleSet.findAll('.test-rule-part'), 1); + assert.lengthOf(await ruleSet.findAll('.test-rule-add'), 2); + assert.equal(await ruleSet.find('.test-rule-extra-add').isPresent(), true); + + // Click "+" button, check that a rule appears. + await ruleSet.find('.test-rule-extra-add .test-rule-add').click(); + assert.lengthOf(await ruleSet.findAll('.test-rule-part'), 2); + assert.lengthOf(await ruleSet.findAll('.test-rule-add'), 2); + assert.equal( + await ruleSet.find('.test-rule-part-and-memo:nth-child(2) .test-rule-acl-formula').getText(), + "Everyone Else" + ); + assert.equal(await ruleSet.find('.test-rule-extra-add').isPresent(), false); + + // Enter a condition into the new default rule. + await enterRulePart(ruleSet, 2, `True`, 'Deny All'); + + // A new "+" button should appear. + assert.lengthOf(await ruleSet.findAll('.test-rule-part'), 2); + assert.lengthOf(await ruleSet.findAll('.test-rule-add'), 3); + assert.equal( + await ruleSet.find('.test-rule-part-and-memo:nth-child(2) .test-rule-acl-formula').getText(), + "True" + ); + assert.equal(await ruleSet.find('.test-rule-extra-add').isPresent(), true); + + // Save + await driver.find('.test-rules-save').click(); + await gu.waitForServer(); + await driver.findWait('.test-rule-set', 2000); + + // Check that the final "+" still appears. + ruleSet = findDefaultRuleSet(/ClientsTable/); + assert.lengthOf(await ruleSet.findAll('.test-rule-part'), 2); + assert.lengthOf(await ruleSet.findAll('.test-rule-add'), 3); + assert.equal( + await ruleSet.find('.test-rule-part-and-memo:nth-child(2) .test-rule-acl-formula').getText(), + "True" + ); + assert.equal(await ruleSet.find('.test-rule-extra-add').isPresent(), true); + + await gu.undo(); + await driver.findWait('.test-rule-set', 2000); + + ruleSet = findDefaultRuleSet(/ClientsTable/); + assert.lengthOf(await ruleSet.findAll('.test-rule-part'), 2); + assert.lengthOf(await ruleSet.findAll('.test-rule-add'), 2); + assert.equal( + await ruleSet.find('.test-rule-part-and-memo:nth-child(2) .test-rule-acl-formula').getText(), + "Everyone Else" + ); + assert.equal(await ruleSet.find('.test-rule-extra-add').isPresent(), false); + }); + + it('should support renames and refresh rules when they get updated', async function() { + // Prepare to use the API. + const mainSession = await gu.session().teamSite.user('user1').login(); + const api = mainSession.createHomeApi(); + + // Load the page with rules. + await mainSession.loadDoc(`/doc/${docId}`); + await driver.find('.test-tools-access-rules').click(); + await driver.findWait('.test-rule-set', 2000); + + // After the previous test case, we have these rules: + // - UserAttribute rule "MyAccess" that looks up user.Email in ClientsTable.Email. + // - On ClientsTable, rule allowing read when "not user.MyAccess.SharedOnly or rec.Shared ...". + // - On ClientsTable, default rule to Deny All. + + // Check that it's what we see. + assert.deepEqual(await driver.findAll('.test-rule-userattr-attr', getRuleText), + ['user.Email']); + assert.deepEqual(await driver.findAll('.test-rule-userattr .test-select-open', el => el.getText()), + ['Access', 'Email']); + assert.match(await driver.find('.test-rule-table-header').getText(), / ClientsTable$/); + assert.lengthOf(await driver.findAll('.test-rule-set'), 2); + assert.deepEqual(await driver.find('.test-rule-set').findAll('.test-rule-acl-formula', getRuleText), + ["not user.MyAccess.SharedOnly or rec.Shared or newRec.Shared", "Everyone Else"]); + + // Rename some tables (raw view sections) and columns. + await api.applyUserActions(docId, [ + ['UpdateRecord', '_grist_Views_section', 3, {title: 'CLIENT LIST'}], + ['UpdateRecord', '_grist_Views_section', 10, {title: 'ACCESS2'}], + ['RenameColumn', 'CLIENT_LIST', 'Shared', 'PUBLIC'], + ['RenameColumn', 'ACCESS2', 'Email', 'EMAIL_ADDR'], + ['RenameColumn', 'ACCESS2', 'SharedOnly', 'ONLY_SHARED'], + ]); + await gu.waitForServer(); + + // Rules should auto-update. + assert.deepEqual(await driver.findAll('.test-rule-userattr .test-select-open', el => el.getText()), + ['ACCESS2', 'EMAIL_ADDR']); + assert.match(await driver.find('.test-rule-table-header').getText(), / CLIENT LIST/); + assert.lengthOf(await driver.findAll('.test-rule-set'), 2); + assert.deepEqual(await driver.find('.test-rule-set').findAll('.test-rule-acl-formula', getRuleText), + ["not user.MyAccess.ONLY_SHARED or rec.PUBLIC or newRec.PUBLIC", "Everyone Else"]); + + // Table options should update + await driver.findContentWait('button', /Add Table Rules/, 2000).click(); + const options = await driver.findAll('.grist-floating-menu li', e => e.getText()); + assert.deepEqual(options, ["ACCESS2", "CLIENT LIST", "CLIENT LIST [by Shared]", "FinancialsTable"]); + await driver.sendKeys(Key.ESCAPE); // Close menu. + + // Make an unsaved change to the rules, e.g. add a new one. + const ruleSet = findDefaultRuleSet(/CLIENT LIST/); + await ruleSet.find('.test-rule-part .test-rule-add').click(); + await enterRulePart(ruleSet, 1, `True`, {R: 'allow'}); + + // Partially undo the renames. + await api.applyUserActions(docId, [ + ['UpdateRecord', '_grist_Views_section', 3, {title: 'ClientsTable'}], + ['RenameColumn', 'ClientsTable', 'PUBLIC', 'Shared'], + ]); + await gu.waitForServer(); + + // Rules should NOT auto-update, but show a message instead. + assert.match(await driver.find('.test-rule-table-header').getText(), / CLIENT LIST/); + assert.match(await driver.find('.test-access-rules-error').getText(), /Access rules have changed/); + + // Click "Reset" to update them. + await driver.find('.test-rules-revert').click(); + await gu.waitForServer(); + + // Finish undoing the renames. Fiddling with a user attribute table currently forces + // a reload. + await api.applyUserActions(docId, [ + ['UpdateRecord', '_grist_Views_section', 10, {title: 'Access'}], + ['RenameColumn', 'Access', 'EMAIL_ADDR', 'Email'], + ['RenameColumn', 'Access', 'ONLY_SHARED', 'SharedOnly'], + ]); + await gu.waitForServer(); + + // Check results; it should be what we started with. + await gu.waitToPass(async () => + assert.deepEqual(await driver.findAll('.test-rule-userattr .test-select-open', el => el.getText()), + ['Access', 'Email'])); + assert.match(await driver.find('.test-rule-table-header').getText(), / ClientsTable$/); + assert.lengthOf(await driver.findAll('.test-rule-set'), 2); + assert.deepEqual(await driver.find('.test-rule-set').findAll('.test-rule-acl-formula', getRuleText), + ["not user.MyAccess.SharedOnly or rec.Shared or newRec.Shared", "Everyone Else"]); + }); + + it('should support special rules', async function() { + // Prepare to use the API. + const mainSession = await gu.session().teamSite.user('user1').login(); + + // Load the page with rules. + await mainSession.loadDoc(`/doc/${docId}`); + await driver.find('.test-tools-access-rules').click(); + await driver.findWait('.test-rule-set', 2000); + + // Check that the special checkboxes are unchecked, and advanced UI is hidden. + assert.deepEqual(await driver.findAll('.test-rule-special-checkbox', isChecked), [false, true, false, false]); + assert.deepEqual(await driver.findAll('.test-rule-special', el => el.find('.test-rule-set').isPresent()), + [false, false, false, false]); + + // Mark the 'Allow everyone to view Access Rules' checkbox and save. + await gu.scrollIntoView(driver.find('.test-rule-special-AccessRules .test-rule-special-checkbox')).click(); + await driver.find('.test-rules-save').click(); + await gu.waitForServer(); + + // Verify that it's checked after saving. + assert.deepEqual(await driver.findAll('.test-rule-special-checkbox', isChecked), [false, true, true, false]); + + // Expand advanced UI and delete the added rule there. + await gu.scrollIntoView(driver.find('.test-rule-special-AccessRules .test-rule-special-expand')).click(); + await driver.find('.test-rule-special-AccessRules .test-rule-part-and-memo:nth-child(1) .test-rule-remove').click(); + + // The checkbox should now be unchecked. + assert.deepEqual(await driver.findAll('.test-rule-special-checkbox', isChecked), [false, true, false, false]); + + // Add a new non-standard rule + const ruleSet = await driver.find('.test-rule-special-AccessRules .test-rule-set'); + await ruleSet.find('.test-rule-part .test-rule-add').click(); + await enterRulePart(ruleSet, 1, 'user.Access == EDITOR', {R: 'allow'}); + + // The checkbox should now be disabled. + assert.deepEqual(await driver.findAll('.test-rule-special-checkbox', isChecked), [false, true, false, false]); + assert.deepEqual(await driver.findAll('.test-rule-special-checkbox', isDisabled), [false, false, true, false]); + + // Mark the checkbox for the other rule. + await gu.scrollIntoView(driver.find('.test-rule-special-FullCopies .test-rule-special-checkbox')).click(); + + // Save and reload the page. + await driver.find('.test-rules-save').click(); + await gu.waitForServer(); + await driver.navigate().refresh(); + await driver.findWait('.test-rule-set', 5000); + + // Verify the state of the checkboxes. + assert.deepEqual(await driver.findAll('.test-rule-special-checkbox', isChecked), [false, true, false, true]); + assert.deepEqual(await driver.findAll('.test-rule-special-checkbox', isDisabled), [false, false, true, false]); + + // Check that the advanced UI is shown for only the non-standard rule. + assert.deepEqual(await driver.findAll('.test-rule-special', el => el.find('.test-rule-set').isPresent()), + [false, false, true, false]); + }); + + it('should allow opening rules when they refer to a deleted table', async function() { + // After deleting a table, an invalid rule will remain. This test is NOT saying that this is + // the desired behavior; it only checks that in the presence of such an invalid rule, we can + // still open the Access Rules page. + + const mainSession = await gu.session().teamSite.user('user1').login(); + await mainSession.loadDoc(`/doc/${docId}`); + await driver.find('.test-tools-access-rules').click(); + await driver.findWait('.test-rule-set', 2000); + assert.match(await driver.find('.test-rule-table-header').getText(), / ClientsTable$/); + + // TODO: Something seems wrong about being able to remove a table to which one has no + // update-record or delete-record access. On the other hand, in this situation, an undo of the + // delete does get blocked. + await gu.removePage('ClientsTable', {withData: true}); + await gu.waitForServer(); + await gu.checkForErrors(); + await driver.navigate().refresh(); + await driver.findWait('.test-rule-set', 5000); + assert.match(await driver.find('.test-rule-table-header').getText(), / #Invalid \(ClientsTable\)$/); + + // Remove access rule + await driver.findContent('.test-rule-table-header', / #Invalid \(ClientsTable\)$/) + .find('.test-rule-table-menu-btn').click(); + await driver.findContent('.grist-floating-menu li', /Delete/).click(); + await driver.find('.test-rules-save').click(); + await gu.waitForServer(); + assert.isTrue(await driver.find('.test-rules-non-save').isPresent()); + }); + + it('should offer fixes for rules referring to deleted tables/columns', async function() { + // Create some temporary tables. + const mainSession = await gu.session().teamSite.user('user1').login(); + await mainSession.loadDoc(`/doc/${docId}`); + await gu.addNewTable('TmpTable1'); + await gu.addNewTable('TmpTable2'); + + // Add a user attribute referencing a column we will soon delete. + const api = mainSession.createHomeApi(); + await api.applyUserActions(docId, [ + ['AddRecord', '_grist_ACLResources', -1, {tableId: '*', colIds: '*'}], + ['AddRecord', '_grist_ACLRules', null, { + resource: -1, userAttributes: JSON.stringify({ + name: 'Zig', + tableId: 'TmpTable2', + charId: 'Email', + lookupColId: 'B', + }), + }], + ]); + + // Open access control rules. + await mainSession.loadDoc(`/doc/${docId}`); + await driver.find('.test-tools-access-rules').click(); + await driver.findWait('.test-rule-set', 2000); + + // Add a rule for TmpTable1. + await driver.findContentWait('button', /Add Table Rules/, 2000).click(); + await driver.findContentWait('.grist-floating-menu li', /TmpTable1/, 3000).click(); + let ruleSet = findDefaultRuleSet(/TmpTable1/); + await enterRulePart(ruleSet, 1, null, 'Allow All'); + + // Add a rule for columns of TmpTable2. + await driver.findContentWait('button', /Add Table Rules/, 2000).click(); + await driver.findContentWait('.grist-floating-menu li', /TmpTable2/, 3000).click(); + ruleSet = findDefaultRuleSet(/TmpTable2/); + await enterRulePart(ruleSet, 1, null, 'Allow All'); + await findTable(/TmpTable2/).find('.test-rule-table-menu-btn').click(); + await driver.findContent('.grist-floating-menu li', /Add Column Rule/).click(); + ruleSet = findRuleSet(/TmpTable2/, 1); + await ruleSet.find('.test-rule-resource .test-select-open').click(); + await driver.findContent('.test-select-menu li', 'A').click(); + await ruleSet.find('.test-rule-resource .test-select-open').click(); + await driver.findContent('.test-select-menu li', 'B').click(); + await ruleSet.find('.test-rule-resource .test-select-open').click(); + await driver.findContent('.test-select-menu li', 'C').click(); + await enterRulePart(ruleSet, 1, 'True', {R: 'allow'}); + + // Save the rules. + await driver.find('.test-rules-save').click(); + await gu.waitForServer(); + + // Now remove TmpTable1 and some columns of TmpTable2. + await gu.removePage('TmpTable1', {withData: true}); + await api.applyUserActions(docId, [ + ['RemoveColumn', 'TmpTable2', 'B'], + ['RemoveColumn', 'TmpTable2', 'C'], + ]); + await driver.find('.test-tools-access-rules').click(); + await driver.findWait('.test-rule-set', 5000); + await driver.navigate().refresh(); + + // Check the list of rules looks plausible. + await driver.findWait('.test-rule-set', 5000); + assert.deepEqual(await driver.findAll('.test-rule-table-header', el => el.getText()), + ['Rules for table #Invalid (TmpTable1)', + 'Rules for table TmpTable2', + 'Default Rules', 'Special Rules']); + + // Press the buttons we expect to be offered for clean-up. + await driver.findContentWait('button', /Remove TmpTable1 rules/, 5000).click(); + await driver.findContentWait('button', /Remove column B from TmpTable2 rules/, 5000).click(); + await driver.findContentWait('button', /Remove column C from TmpTable2 rules/, 5000).click(); + await driver.findContentWait('button', /Remove Zig user attribute/, 5000).click(); + await driver.find('.test-rules-save').click(); + await gu.waitForServer(); + + // Check the list of rules looks cleaner. + assert.deepEqual(await driver.findAll('.test-rule-table-header', el => el.getText()), + ['Rules for table TmpTable2', 'Default Rules', 'Special Rules']); + + // Check only the remaining column is mentioned. + assert.deepEqual(await driver.findAll('.test-acl-column', el => el.getText()), + ['A']); + + // Remove TmpTable2. + await gu.removePage('TmpTable2', {withData: true}); + await driver.find('.test-tools-access-rules').click(); + await driver.findWait('.test-rule-set', 5000); + await driver.navigate().refresh(); + + // Press the clean-up button we are offered, then check that "reset" works, + // then press and save. + await driver.findWait('.test-rule-set', 5000); + await driver.findContentWait('button', /Remove TmpTable2 rules/, 5000).click(); + await driver.find('.test-rules-revert').click(); + await gu.waitForServer(); + await driver.findContentWait('button', /Remove TmpTable2 rules/, 5000).click(); + await driver.find('.test-rules-save').click(); + await gu.waitForServer(); + + // Check the list of rules looks cleaner. + assert.deepEqual(await driver.findAll('.test-rule-table-header', el => el.getText()), + ['Default Rules', 'Special Rules']); + }); + + it.skip('should prevent combination of Public Edit access with granular ACLs', async function() { + // Share doc with everyone as viewer. + const mainSession = await gu.session().teamSite.user('user1').login(); + const doc = await mainSession.tempDoc(cleanup, 'ACL-Test.grist', {load: false}); + const api = mainSession.createHomeApi(); + await api.updateDocPermissions(doc.id, { users: { 'everyone@getgrist.com': 'viewers' } }); + + // Open rules. There should be no warning message. + await mainSession.loadDoc(`/doc/${doc.id}`); + await driver.find('.test-tools-access-rules').click(); + await driver.findWait('.test-rule-set', 2000); + assert.equal(await driver.find('.test-access-rules-error').getText(), ''); + + // Add a rule, and save. + await driver.find('.test-rule-special-AccessRules .test-rule-special-checkbox').click(); + await driver.find('.test-rules-save').click(); + await gu.waitForServer(); + + // Check that it worked. + assert.isTrue(await isChecked(await driver.find('.test-rule-special-AccessRules .test-rule-special-checkbox'))); + assert.equal(await driver.find('.test-rules-save').isDisplayed(), false); + assert.equal(await driver.find('.test-rules-non-save').getText(), 'Saved'); + + // Try to change public access to editor. It should downgrade to viewer with a warning toast. + await driver.find('.test-tb-share').click(); + await driver.findContent('.test-tb-share-option', /Manage Users/).doClick(); + await driver.findWait('.test-um-public-access', 3000).click(); + assert.match(await driver.find('.test-um-public-member .test-um-member-role').getText(), /Viewer/); + await driver.find('.test-um-public-member .test-um-member-role').click(); + await driver.findContent('.test-um-role-option', /Editor/).click(); + await gu.saveAcls(); + await gu.waitForServer(); + + const toast = driver.findWait('.test-notifier-toast-wrapper', 500); + assert.match(await toast.getText(), /incompatible.*Reduced to "Viewer"/i); + await toast.find('.test-notifier-toast-close').click(); // Close the toast. + + // Remove the rule we added, and save. + await driver.find('.test-rule-special-AccessRules .test-rule-special-checkbox').click(); + await driver.find('.test-rules-save').click(); + await gu.waitForServer(); + assert.isFalse(await isChecked(await driver.find('.test-rule-special-AccessRules .test-rule-special-checkbox'))); + + // Now change public access to editor. There should be no warning notifications. + await driver.find('.test-tb-share').click(); + await driver.findContent('.test-tb-share-option', /Manage Users/).doClick(); + await driver.findWait('.test-um-public-access', 3000).click(); + assert.match(await driver.find('.test-um-public-member .test-um-member-role').getText(), /Viewer/); + await driver.find('.test-um-public-member .test-um-member-role').click(); + await driver.findContent('.test-um-role-option', /Editor/).click(); + await gu.saveAcls(); + await gu.waitForServer(); + assert.lengthOf(await gu.getToasts(), 0); + + // Open rules. There should be a warning message. + await driver.find('.test-tools-access-rules').click(); + assert.match(await driver.find('.test-access-rules-error').getText(), + /Public "Editor".*incompatible.*remove it or reduce to "Viewer"/i); + + // Try to add a rule. We should not be able to save. + await driver.find('.test-rule-special-AccessRules .test-rule-special-checkbox').click(); + assert.equal(await driver.find('.test-rules-save').isDisplayed(), false); + assert.equal(await driver.find('.test-rules-non-save').getText(), 'Invalid'); + }); + + it("Should update save button every 1 second when editing a formula", async function() { + // Prepare to use the API. + const mainSession = await gu.session().teamSite.user('user1').login(); + + // Load the page with rules. + await mainSession.loadDoc(`/doc/${docId}`); + await driver.find('.test-tools-access-rules').click(); + await driver.findWait('.test-rule-set', 2000); + + // check the save button is in 'saved' state + assert.equal(await driver.find('.test-rules-non-save').getText(), 'Saved'); + + // add new rule + const ruleSet = findDefaultRuleSet('*'); + await ruleSet.find('.test-rule-add').click(); + + // check the save button is still in 'saved' state + // (nothing useful to save yet) + assert.equal(await driver.find('.test-rules-non-save').getText(), 'Saved'); + + // start typing invalid formula + const part = ruleSet.find(`.test-rule-part`); + await part.findWait('.test-rule-acl-formula .ace_editor', 500); + await part.find('.test-rule-acl-formula').doClick(); + await driver.findWait('.test-rule-acl-formula .ace_focus', 500); + await gu.sendKeys('fdsa'); + + // check save button is still in 'saved' state + // (UI hasn't caught up with user yet) + assert.equal(await driver.find('.test-rules-non-save').getText(), 'Saved'); + + // wait 1 second + await driver.sleep(1000); + + // check the save button is now enabled + assert.equal(await driver.find('.test-rules-non-save').getText(), 'Invalid'); + + // click reset + await driver.find('.test-rules-revert').click(); + }); + + it("should have a working dots menu", async function() { + const mainSession = await gu.session().teamSite.user('user1').login(); + await mainSession.loadDoc(`/doc/${docId}/p/3`); + let url = ''; + + // check page is correct + url = await driver.getCurrentUrl(); + assert.isTrue(url.includes('p/3')); + assert.equal(await gu.getCurrentPageName(), "Access"); + + await gu.openAccessRulesDropdown(); + + const [name1, name2, name3] = ['user1', 'user2', 'user3'].map(u => gu.translateUser(u as any).name) + .map(name => new RegExp(escapeRegExp(name), 'i')); + + // All users the doc is shared with should be listed, with correct Access. + assert.equal(await driver.findContent('.grist-floating-menu a', name1).isPresent(), false); + assert.include(await driver.findContent('.grist-floating-menu a', name2).getText(), '(Owner)'); + assert.include(await driver.findContent('.grist-floating-menu a', name3).getText(), '(Editor)'); + + await driver.findContent('.grist-floating-menu a', name3).click(); + await gu.waitForUrl(/aclAsUser/); + await gu.waitForDocToLoad(); + + // Check for a tag in the doc header. + assert.equal(await driver.findWait('.test-view-as-banner', 2000).isPresent(), true); + + // check doc is still on same page + url = await driver.getCurrentUrl(); + assert.isTrue(url.includes('p/3')); + assert.equal(await gu.getCurrentPageName(), "Access"); + + // Revert. + await driver.find('.test-view-as-banner .test-revert').click(); + await gu.waitForDocToLoad(); + }); + + it('should keep dots menu in sync', async function() { + const checkAccess = async (role: string) => { + await gu.openAccessRulesDropdown(); + await gu.waitToPass(async () => { + assert.include(await driver.findContent('.grist-floating-menu a', /kiwi/i).getText(), role); + }); + await driver.sendKeys(Key.ESCAPE); + }; + + // open doc + const mainSession = await gu.session().teamSite.user('user1').login(); + await mainSession.loadDoc(`/doc/${docId}`); + + // check kiwi matches (Editor) + await checkAccess('(Editor)'); + + // update kiwi access to viewers + const api = mainSession.createHomeApi(); + await api.updateDocPermissions(docId, { users: { [gu.translateUser('user3').email]: 'viewers' } }); + + // check kiwi matches (Viewer) + await checkAccess('(Viewer)'); + }); + + it('should hide dots menu for users without ACL access', async function() { + // Login as user3 and open doc + const mainSession = gu.session().teamSite.user('user1'); + await mainSession.teamSite.user('user3').login(); + await mainSession.loadDoc(`/doc/${docId}`); + + // Check absence of dots menu + await driver.find('.test-tools-access-rules').mouseMove(); + await gu.waitToPass(async () => { + assert.equal(await driver.find('.test-tools-access-rules-trigger').isPresent(), true); + }); + assert.equal(await driver.find('.test-tools-access-rules-trigger').isDisplayed(), false); + }); + +}); diff --git a/test/nbrowser/AccessRules3.ts b/test/nbrowser/AccessRules3.ts new file mode 100644 index 00000000..678009ac --- /dev/null +++ b/test/nbrowser/AccessRules3.ts @@ -0,0 +1,290 @@ +/** + * Test of the UI for Granular Access Control, part 3. + */ +import { assert, driver } from 'mocha-webdriver'; +import { assertChanged, assertSaved, enterRulePart, findDefaultRuleSet, + findRuleSet, findTable, getRules, hasExtraAdd, removeRules, + removeTable } from 'test/nbrowser/aclTestUtils'; +import * as gu from 'test/nbrowser/gristUtils'; +import { setupTestSuite } from 'test/nbrowser/testUtils'; + +describe("AccessRules3", function() { + this.timeout(40000); + const cleanup = setupTestSuite(); + let docId: string; + + before(async function() { + // Import a test document we've set up for this. + const mainSession = await gu.session().teamSite.user('user1').login(); + docId = (await mainSession.tempDoc(cleanup, 'ACL-Test.grist', {load: false})).id; + + // Share it with a few users. + const api = mainSession.createHomeApi(); + await api.updateDocPermissions(docId, { users: { + [gu.translateUser("user2").email]: 'owners', + [gu.translateUser("user3").email]: 'editors', + } }); + return docId; + }); + + afterEach(() => gu.checkForErrors()); + + describe('SeedRule special', function() { + + // When a tooltip is present, it introduces this extra text into getText() result. + const tooltipMarker = "\n?"; + + it('can add initial rules based on SeedRule special', async function() { + // Open Access Rules page. + const mainSession = await gu.session().teamSite.user('user1').login(); + await mainSession.loadDoc(`/doc/${docId}`); + await driver.find('.test-tools-access-rules').click(); + await driver.findWait('.test-rule-set', 2000); + + // Check seed rule checkbox is unselected. + const seedRule = await driver.find('div.test-rule-special-SeedRule'); + const checkbox = seedRule.find('input[type=checkbox]'); + assert.equal(await checkbox.isSelected(), false); + + // Expand and check there's an empty rule. + await driver.find('.test-rule-special-SeedRule .test-rule-special-expand').click(); + await assertSaved(); + await getRules(seedRule); + assert.deepEqual(await getRules(seedRule), + [{ formula: 'Everyone', perm: '' }]); + assert.equal(await hasExtraAdd(seedRule), false); + + // Adding rules for a new table/column should look the same as always. + await driver.findContentWait('button', /Add Table Rules/, 2000).click(); + await driver.findContentWait('.grist-floating-menu li', /FinancialsTable/, 3000).click(); + await assertChanged(); + let fin = findTable(/FinancialsTable/); + assert.deepEqual(await getRules(fin), + [{ formula: 'Everyone', perm: '', res: 'All' }]); + await fin.find('.test-rule-table-menu-btn').click(); + await driver.findContent('.grist-floating-menu li', /Add Column Rule/).click(); + assert.deepEqual(await getRules(fin), + [{ formula: 'Everyone', perm: '', res: '[Add Column]' }, + { formula: 'Everyone', perm: '', res: 'All' + tooltipMarker }]); + await removeTable(/FinancialsTable/); + await assertSaved(); + + // Now check the box, and see we get the rule we expect. + await checkbox.click(); + await assertChanged(); + assert.deepEqual(await getRules(seedRule), + [{ formula: 'user.Access in [OWNER]', perm: '+R+U+C+D' }]); + assert.equal(await hasExtraAdd(seedRule), true); + + // New table rules should start off with that rule. + await driver.findContentWait('button', /Add Table Rules/, 2000).click(); + await driver.findContentWait('.grist-floating-menu li', /FinancialsTable/, 3000).click(); + fin = findTable(/FinancialsTable/); + assert.deepEqual(await getRules(fin), + [{ formula: 'user.Access in [OWNER]', perm: '+R+U+C+D', res: 'All' }, + { formula: 'Everyone Else', perm: '', res: 'All' }]); + assert.equal(await hasExtraAdd(fin), false); + + // New column rules should start off with that rule. + await fin.find('.test-rule-table-menu-btn').click(); + await driver.findContent('.grist-floating-menu li', /Add Column Rule/).click(); + assert.deepEqual(await getRules(fin), + [{ formula: 'user.Access in [OWNER]', perm: '+R+U', res: '[Add Column]' }, + { formula: 'Everyone Else', perm: '', res: '[Add Column]' }, + { formula: 'user.Access in [OWNER]', perm: '+R+U+C+D', res: 'All' + tooltipMarker }, + { formula: 'Everyone Else', perm: '', res: 'All' + tooltipMarker }]); + + // Make sure that removing and re-adding default rules works as expected. + await removeRules(findDefaultRuleSet(/FinancialsTable/)); + assert.equal(await findDefaultRuleSet(/FinancialsTable/).isPresent(), false); + assert.deepEqual(await getRules(fin), + [{ formula: 'user.Access in [OWNER]', perm: '+R+U', res: '[Add Column]' }, + { formula: 'Everyone Else', perm: '', res: '[Add Column]' }]); + await fin.find('.test-rule-table-menu-btn').click(); + await driver.findContent('.grist-floating-menu li', /Add Table-wide Rule/).click(); + assert.deepEqual(await getRules(fin), + [{ formula: 'user.Access in [OWNER]', perm: '+R+U', res: '[Add Column]' }, + { formula: 'Everyone Else', perm: '', res: '[Add Column]' }, + { formula: 'user.Access in [OWNER]', perm: '+R+U+C+D', res: 'All' + tooltipMarker }, + { formula: 'Everyone Else', perm: '', res: 'All' + tooltipMarker }]); + await removeTable(/FinancialsTable/); + + // Check that we can tweak the seed rules if we want. + await seedRule.find('.test-rule-part .test-rule-add').click(); + await enterRulePart(seedRule, 1, 'user.Access in [EDITOR]', 'Deny All', 'memo1'); + assert.equal(await checkbox.getAttribute('disabled'), 'true'); + + // New table rules should include the seed rules. + await driver.findContentWait('button', /Add Table Rules/, 2000).click(); + await driver.findContentWait('.grist-floating-menu li', /FinancialsTable/, 3000).click(); + fin = findTable(/FinancialsTable/); + assert.deepEqual(await getRules(fin), + [{ formula: 'user.Access in [EDITOR]', perm: '-R-U-C-D', res: 'All', memo: 'memo1'}, + { formula: 'user.Access in [OWNER]', perm: '+R+U+C+D', res: 'All' }, + { formula: 'Everyone Else', perm: '', res: 'All' }]); + assert.equal(await hasExtraAdd(fin), false); + await removeTable(/FinancialsTable/); + + // Check that returning to the single OWNER rule gets us back to an uncomplicated + // selected checkbox. + await assertChanged(); + assert.equal(await checkbox.getAttribute('disabled'), 'true'); + assert.equal(await checkbox.isSelected(), false); + await seedRule.find('.test-rule-part .test-rule-remove').click(); + assert.equal(await checkbox.getAttribute('disabled'), null); + assert.equal(await checkbox.isSelected(), true); + + // Check that removing that rule deselected the checkbox and collapses rule list. + await seedRule.find('.test-rule-part .test-rule-remove').click(); + assert.equal(await checkbox.getAttribute('disabled'), null); + assert.equal(await checkbox.isSelected(), false); + await assertSaved(); + assert.lengthOf(await seedRule.findAll('.test-rule-set'), 0); + + // Expand again, and make sure we are back to default. + await driver.find('.test-rule-special-SeedRule .test-rule-special-expand').click(); + assert.lengthOf(await seedRule.findAll('.test-rule-set'), 1); + assert.deepEqual(await getRules(seedRule), + [{ formula: 'Everyone', perm: '' }]); + await assertSaved(); + }); + + it('can save and reload SeedRule special', async function() { + const mainSession = await gu.session().teamSite.user('user1').login(); + await mainSession.loadDoc(`/doc/${docId}`); + await driver.find('.test-tools-access-rules').click(); + await driver.findWait('.test-rule-set', 2000); + + // Initially nothing is selected and all is saved. + let seedRule = await driver.find('div.test-rule-special-SeedRule'); + let checkbox = seedRule.find('input[type=checkbox]'); + assert.equal(await checkbox.isSelected(), false); + await assertSaved(); + + // Clicking the checkbox is immediately save-able. + await checkbox.click(); + await assertChanged(); + + // Save, and check state is correctly persisted. + await driver.find('.test-rules-save').click(); + await gu.waitForServer(); + seedRule = await driver.findWait('div.test-rule-special-SeedRule', 2000); + checkbox = seedRule.find('input[type=checkbox]'); + assert.equal(await checkbox.isSelected(), true); + await assertSaved(); + + // Expand and ensure we see the expected rule. + await driver.find('.test-rule-special-SeedRule .test-rule-special-expand').click(); + assert.deepEqual(await getRules(seedRule), + [{ formula: 'user.Access in [OWNER]', perm: '+R+U+C+D' }]); + assert.equal(await hasExtraAdd(seedRule), true); + + // Now unselect the checkbox, and make sure that we can save+reload. + await checkbox.click(); + await assertChanged(); + await driver.find('.test-rules-save').click(); + await gu.waitForServer(); + seedRule = await driver.findWait('div.test-rule-special-SeedRule', 2000); + checkbox = seedRule.find('input[type=checkbox]'); + assert.equal(await checkbox.isSelected(), false); + await assertSaved(); + await driver.find('.test-rule-special-SeedRule .test-rule-special-expand').click(); + assert.deepEqual(await getRules(seedRule), + [{ formula: 'Everyone', perm: '' }]); + assert.equal(await hasExtraAdd(seedRule), false); + + // Select the checkbox again, and save. Then make a custom change. + await checkbox.click(); + await assertChanged(); + await driver.find('.test-rules-save').click(); + await gu.waitForServer(); + seedRule = await driver.findWait('div.test-rule-special-SeedRule', 2000); + checkbox = seedRule.find('input[type=checkbox]'); + assert.equal(await checkbox.isSelected(), true); + await driver.find('.test-rule-special-SeedRule .test-rule-special-expand').click(); + await seedRule.find('.test-rule-part .test-rule-add').click(); + await enterRulePart(seedRule, 1, 'user.Access in [EDITOR]', 'Deny All', 'memo2'); + assert.equal(await checkbox.getAttribute('disabled'), 'true'); + assert.deepEqual(await getRules(seedRule), + [{ formula: 'user.Access in [EDITOR]', perm: '-R-U-C-D', memo: 'memo2' }, + { formula: 'user.Access in [OWNER]', perm: '+R+U+C+D' }]); + await assertChanged(); + + // Save the custom change, and make sure we can reload it. + await driver.find('.test-rules-save').click(); + await gu.waitForServer(); + seedRule = await driver.findWait('div.test-rule-special-SeedRule', 2000); + checkbox = seedRule.find('input[type=checkbox]'); + assert.equal(await checkbox.isSelected(), false); + assert.equal(await checkbox.getAttribute('disabled'), 'true'); + assert.deepEqual(await getRules(seedRule), + [{ formula: 'user.Access in [EDITOR]', perm: '-R-U-C-D', memo: 'memo2' }, + { formula: 'user.Access in [OWNER]', perm: '+R+U+C+D' }]); + await assertSaved(); + + // Undo; should now again have the simple checked checkbox for seed rules. + await gu.undo(); + seedRule = await driver.findWait('div.test-rule-special-SeedRule', 2000); + checkbox = seedRule.find('input[type=checkbox]'); + assert.equal(await checkbox.isSelected(), true); + }); + + it('does not include unavailable bits when saving', async function() { + // Open Access Rules page. + const mainSession = await gu.session().teamSite.user('user1').login(); + await mainSession.loadDoc(`/doc/${docId}`); + await driver.find('.test-tools-access-rules').click(); + await driver.findWait('.test-rule-set', 2000); + + // Click the seed rule checkbox. + const seedRule = await driver.find('div.test-rule-special-SeedRule'); + assert.equal(await seedRule.find('input[type=checkbox]').isSelected(), true); + + // New table AND column rules should start off with that rule. + await driver.findContentWait('button', /Add Table Rules/, 2000).click(); + await driver.findContentWait('.grist-floating-menu li', /FinancialsTable/, 3000).click(); + let fin = findTable(/FinancialsTable/); + await fin.find('.test-rule-table-menu-btn').click(); + await driver.findContent('.grist-floating-menu li', /Add Column Rule/).click(); + const ruleSet = findRuleSet(/FinancialsTable/, 1); + await ruleSet.find('.test-rule-resource .test-select-open').click(); + await driver.findContent('.test-select-menu li', 'Year').click(); + assert.deepEqual(await getRules(fin), + [{ formula: 'user.Access in [OWNER]', perm: '+R+U', res: 'Year\n[Add Column]' }, + { formula: 'Everyone Else', perm: '', res: 'Year\n[Add Column]' }, + { formula: 'user.Access in [OWNER]', perm: '+R+U+C+D', res: 'All' + tooltipMarker }, + { formula: 'Everyone Else', perm: '', res: 'All' + tooltipMarker }]); + + // Check that the Save button is enabled, and save. + await gu.userActionsCollect(); + await assertChanged(); + await driver.find('.test-rules-save').click(); + await gu.waitForServer(); + + // This is the important check of this test: that for a column rule, we only save the "read" + // and "update" bits. + await gu.userActionsVerify([ + ["BulkAddRecord", "_grist_ACLResources", [-1, -2], { + colIds: ["Year", "*"], + tableId: ["FinancialsTable", "FinancialsTable"], + }], + ["BulkAddRecord", "_grist_ACLRules", [-1, -2], { + resource: [-1, -2], + aclFormula: ["user.Access in [OWNER]", "user.Access in [OWNER]"], + // Specifically, we care that this permissionsText includes only RU bits for column rules. + permissionsText: ["+RU", "+CRUD"], + rulePos: [1/3, 2/3], + memo: ["", ""], + }], + ]); + await assertSaved(); + + // Rules still look correct after saving. + fin = findTable(/FinancialsTable/); + assert.deepEqual(await getRules(fin), + [{ formula: 'user.Access in [OWNER]', perm: '+R+U', res: 'Year' }, + { formula: 'user.Access in [OWNER]', perm: '+R+U+C+D', res: 'All' + tooltipMarker }]); + }); + }); + +}); diff --git a/test/nbrowser/AccessRules4.ts b/test/nbrowser/AccessRules4.ts new file mode 100644 index 00000000..7bef33ca --- /dev/null +++ b/test/nbrowser/AccessRules4.ts @@ -0,0 +1,89 @@ +/** + * Test of the UI for Granular Access Control, part 3. + */ +import { assert } from 'mocha-webdriver'; +import * as gu from 'test/nbrowser/gristUtils'; +import { setupTestSuite } from 'test/nbrowser/testUtils'; + +describe("AccessRules4", function() { + this.timeout('20s'); + const cleanup = setupTestSuite(); + + afterEach(() => gu.checkForErrors()); + + it('allows editor to toggle a column', async function() { + const ownerSession = await gu.session().teamSite.user('user1').login(); + const docId = await ownerSession.tempNewDoc(cleanup, undefined, {load: false}); + + // Create editor for this document. + const api = ownerSession.createHomeApi(); + await api.updateDocPermissions(docId, { users: { + [gu.translateUser("user2").email]: 'editors', + }}); + + await api.applyUserActions(docId, [ + // Now create a structure. + ['RemoveTable', 'Table1'], + ['AddTable', 'Table1', [ + {id: 'Toggle', type: 'Bool'}, + {id: 'Another', type: 'Text'}, + {id: 'User_Access', type: 'Text', formula: 'user.Email', isFormula: false}, + ]], + // Now add access rules for Table2 + ['AddRecord', '_grist_ACLResources', -1, {tableId: 'Table1', colIds: '*'}], + // Owner can do anything + ['AddRecord', '_grist_ACLRules', null, { + resource: -1, aclFormula: 'user.Access == OWNER', permissionsText: 'all', + }], + // User with an his email address in the User_Access column can do anything + ['AddRecord', '_grist_ACLRules', null, { + resource: -1, aclFormula: 'user.Email == rec.User_Access', permissionsText: 'all', + }], + // Otherwise no access + ['AddRecord', '_grist_ACLRules', null, { + resource: -1, aclFormula: '', permissionsText: 'none', + }], + ]); + await ownerSession.loadDoc(`/doc/${docId}`); + + // Make sure we can edit this as an owner. + await gu.sendCommand('insertRecordAfter'); + + assert.isEmpty(await gu.getCell('Another', 1).getText()); + assert.equal(await gu.getCell('User_Access', 1).getText(), gu.translateUser('user1').email); + assert.isFalse(await gu.getCell('Toggle', 1).find('.widget_checkmark').isDisplayed()); + + + await gu.getCell('Another', 1).click(); + await gu.enterCell('owner'); + await gu.getCell('Toggle', 1).mouseMove(); + await gu.getCell('Toggle', 1).find('.widget_checkbox').click(); + await gu.waitForServer(); + + assert.equal(await gu.getCell('Another', 1).getText(), 'owner'); + assert.equal(await gu.getCell('User_Access', 1).getText(), gu.translateUser('user1').email); + assert.isTrue(await gu.getCell('Toggle', 1).find('.widget_checkmark').isDisplayed()); + + + // Now login as user2. + const userSession = await gu.session().teamSite.user('user2').login(); + await userSession.loadDoc(`/doc/${docId}`); + + // Make sure we can edit this as an user2 + await gu.sendCommand('insertRecordAfter'); + + assert.isEmpty(await gu.getCell('Another', 1).getText()); + assert.equal(await gu.getCell('User_Access', 1).getText(), gu.translateUser('user2').email); + assert.isFalse(await gu.getCell('Toggle', 1).find('.widget_checkmark').isDisplayed()); + + await gu.getCell('Another', 1).click(); + await gu.enterCell('user2'); + await gu.getCell('Toggle', 1).mouseMove(); + await gu.getCell('Toggle', 1).find('.widget_checkbox').click(); + await gu.waitForServer(); + + assert.equal(await gu.getCell('Another', 1).getText(), 'user2'); + assert.equal(await gu.getCell('User_Access', 1).getText(), gu.translateUser('user2').email); + assert.isTrue(await gu.getCell('Toggle', 1).find('.widget_checkmark').isDisplayed()); + }); +}); diff --git a/test/nbrowser/AccessRulesSchemaEdit.ts b/test/nbrowser/AccessRulesSchemaEdit.ts new file mode 100644 index 00000000..448e1167 --- /dev/null +++ b/test/nbrowser/AccessRulesSchemaEdit.ts @@ -0,0 +1,232 @@ +/** + * Test of the UI for the SchemaEdit permission in Granular Access Control. + */ +import { UserAPI } from 'app/common/UserAPI'; +import { TableRecordValue } from 'app/common/DocActions'; +import { assert, driver } from 'mocha-webdriver'; +import { assertChanged, assertSaved, enterRulePart, + findDefaultRuleSet, findTable, getRules } from 'test/nbrowser/aclTestUtils'; +import * as gu from 'test/nbrowser/gristUtils'; +import { setupTestSuite } from 'test/nbrowser/testUtils'; +import pick = require('lodash/pick'); + +describe("AccessRulesSchemaEdit", function() { + this.timeout(40000); + const cleanup = setupTestSuite(); + let docId: string; + let mainSession: gu.Session; + let api: UserAPI; + let editorApi: UserAPI; + + before(async function() { + const editorSession = await gu.session().teamSite.user('user2').login(); + editorApi = editorSession.createHomeApi(); + + // Import a test document we've set up for this. + mainSession = await gu.session().teamSite.user('user1').login(); + docId = await mainSession.tempNewDoc(cleanup, 'ACL-SchemaEdit', {load: false}); + + // Share it with a few users. + api = mainSession.createHomeApi(); + await api.updateDocPermissions(docId, { users: { + [gu.translateUser("user2").email]: 'editors', + } }); + return docId; + }); + + afterEach(() => gu.checkForErrors()); + + it('should allow disabling non-owner schemaEdit via checkbox', async function() { + const mainDocApi = api.getDocAPI(docId); + + // Open Access Rules page. + await loadAccessRulesPage(mainSession, docId); + + // Check the schemaEdit checkbox is checked (editors allowed) + assert.equal(await getSchemaEditCheckbox().isSelected(), true); + + // Check that a warning is present. + assert.equal(await driver.find('.test-rule-schema-edit-warning').isDisplayed(), true); + + // Check that default rules don't show the schemaEdit bit. + assert.deepEqual((await getRules(findTable('*')))[0], + {res: 'All', formula: 'user.Access in [EDITOR, OWNER]', perm: '+R+U+C+D'}); + + // Check that an editor CAN make structure changes (default behavior is unchanged). + await assert.isFulfilled(editorApi.applyUserActions(docId, [["RenameTable", 'Table1', 'Renamed1']])); + + // Uncheck the box. + await getSchemaEditCheckbox().click(); + assert.equal(await getSchemaEditCheckbox().isSelected(), false); + + // Check the warning is now absent. + assert.equal(await driver.find('.test-rule-schema-edit-warning').isPresent(), false); + await assertChanged(); + + // Save the changes. + await saveRules(); + + // Check that it works: an editor cannot make structure changes. + await assert.isRejected(editorApi.applyUserActions(docId, [["RenameTable", 'Renamed1', 'Table1']]), + /Blocked by table structure access rules/); + + // Check that after reload, the box is unchecked and the warning is gone. + await reloadAccessRulesPage(); + assert.equal(await getSchemaEditCheckbox().isSelected(), false); + assert.equal(await driver.find('.test-rule-schema-edit-warning').isPresent(), false); + assert.equal(await getSchemaEditRuleSet().isPresent(), false); + + // Check what the rules are on the default resource. + const rules = await mainDocApi.getRecords('_grist_ACLRules'); + const defaultResourceRef = (await getDefaultResourceRec(api, docId))!.id; + assert.deepEqual(rules.map(r => pick(r.fields, "resource", "aclFormula", "permissionsText")), + [{resource: defaultResourceRef, aclFormula: 'user.Access != OWNER', permissionsText: '-S'}]); + + // Revert by checking the box again. + await getSchemaEditCheckbox().click(); + assert.equal(await getSchemaEditCheckbox().isSelected(), true); + + // Check the warning is now present. + assert.equal(await driver.find('.test-rule-schema-edit-warning').isDisplayed(), true); + + // Save the changes. + await saveRules(); + + // Check that it works: an editor can make structure changes again. + await assert.isFulfilled(editorApi.applyUserActions(docId, [["RenameTable", 'Renamed1', 'Table1']])); + + // Check there are no rules left. + assert.lengthOf(await mainDocApi.getRecords('_grist_ACLRules'), 0); + }); + + it('should allow dismissing the warning', async function() { + const mainDocApi = api.getDocAPI(docId); + await loadAccessRulesPage(mainSession, docId); + + // Check we are in the expected default state: checkbox is checked and a warning is present. + assert.equal(await getSchemaEditCheckbox().isSelected(), true); + assert.equal(await driver.find('.test-rule-schema-edit-warning').isPresent(), true); + + // Click "Dismiss", and save. + await driver.findContent('.test-rule-schema-edit-warning a', /Dismiss/).click(); + await assertChanged(); + await saveRules(); + + // Check that an editor can still make structure changes. + await assert.isFulfilled(editorApi.applyUserActions(docId, [["RenameTable", 'Table1', 'Renamed2']])); + + // Check that after reload, the box is checked and warning is gone. + await reloadAccessRulesPage(); + assert.equal(await getSchemaEditCheckbox().isSelected(), true); + assert.equal(await driver.find('.test-rule-schema-edit-warning').isPresent(), false); + assert.equal(await getSchemaEditRuleSet().isPresent(), false); + + // Check what the rules are on the default resource. + const rules = await mainDocApi.getRecords('_grist_ACLRules'); + const defaultResourceRef = (await getDefaultResourceRec(api, docId))!.id; + assert.deepEqual(rules.map(r => pick(r.fields, "resource", "aclFormula", "permissionsText")), + [{resource: defaultResourceRef, aclFormula: 'user.Access == EDITOR', permissionsText: '+S'}]); + + // Revert the rule change; wait for page to reload. + await gu.undo(); + await driver.findWait('.test-rule-set', 2000); + + assert.equal(await getSchemaEditCheckbox().isSelected(), true); + assert.equal(await driver.find('.test-rule-schema-edit-warning').isPresent(), true); + assert.lengthOf(await mainDocApi.getRecords('_grist_ACLRules'), 0); + + // Let's revert also the table rename, to keep test cases independent. + await assert.isFulfilled(editorApi.applyUserActions(docId, [["RenameTable", 'Renamed2', 'Table1']])); + }); + + it('should handle existing rules that mix schemaEdit and other permissions', async function() { + const editorEmailAddr = gu.translateUser("user2").email; + const customAclFormula = `user.Email == "${editorEmailAddr}"`; + + // Use the API to add a mixed rule, with both a 'schemaEdit' and a 'delete' permission. + const defaultResourceRef = (await getDefaultResourceRec(api, docId))!.id; + await api.applyUserActions(docId, [ + ['AddRecord', '_grist_ACLRules', null, { + resource: defaultResourceRef, + aclFormula: customAclFormula, + permissionsText: '-DS', + memo: 'Memo MIXED', + }], + ]); + + // Load the Access Rules page. + await loadAccessRulesPage(mainSession, docId); + + // The new rule should be visible in both the default section, and in SchemaEdit section + // (which should be expanded). + assert.deepEqual((await getRules(findTable('*')))[0], + {formula: customAclFormula, perm: '-D', memo: 'Memo MIXED', res: 'All'}); + + assert.equal(await getSchemaEditRuleSet().isDisplayed(), true); + assert.deepEqual((await getRules(driver.find('.test-rule-special-SchemaEdit')))[0], + {formula: customAclFormula, perm: '-S', memo: 'Memo MIXED'}); + + // Check that the checkbox is disabled (since non-standard state). + assert.equal(await getSchemaEditCheckbox().getAttribute('disabled'), 'true'); + + // Check that the rule works. + let error = await editorApi.applyUserActions(docId, [["RenameTable", 'Table1', 'Renamed3']]) + .then(() => null).catch(err => err); + assert.match(error?.message, /Blocked by table structure access rules/); + assert.deepInclude(error?.details, {memos: ['Memo MIXED']}); + + // Change the memos on both copies of the rule. + await enterRulePart(findDefaultRuleSet('*'), 1, null, {}, 'Memo DDD'); + await enterRulePart(getSchemaEditRuleSet(), 1, null, {}, 'Memo SSS'); + + // Save. + await saveRules(); + + // The rules should look as before, only the memo is different. + assert.deepEqual((await getRules(findTable('*')))[0], + {formula: customAclFormula, perm: '-D', memo: 'Memo DDD', res: 'All'}); + assert.deepEqual((await getRules(driver.find('.test-rule-special-SchemaEdit')))[0], + {formula: customAclFormula, perm: '-S', memo: 'Memo SSS'}); + + // Check that the changed rule works. + error = await editorApi.applyUserActions(docId, [["RenameTable", 'Table1', 'Renamed3']]) + .then(() => null).catch(err => err); + assert.match(error?.message, /Blocked by table structure access rules/); + assert.deepInclude(error?.details, {memos: ['Memo SSS']}); + + // Check what the rules are on the default resource. + const mainDocApi = api.getDocAPI(docId); + const rules = await mainDocApi.getRecords('_grist_ACLRules'); + assert.sameDeepMembers(rules.map(r => pick(r.fields, "resource", "aclFormula", "permissionsText")), [ + {resource: defaultResourceRef, aclFormula: customAclFormula, permissionsText: '-S'}, + {resource: defaultResourceRef, aclFormula: customAclFormula, permissionsText: '-D'} + ]); + }); +}); + +function getSchemaEditCheckbox() { + return driver.find('.test-rule-special-SchemaEdit input[type=checkbox]'); +} +function getSchemaEditRuleSet() { + return driver.find('.test-rule-special-SchemaEdit .test-rule-set'); +} +function getSaveButton() { + return driver.find('.test-rules-save'); +} +async function saveRules() { + await getSaveButton().click(); + await gu.waitForServer(); + await assertSaved(); +} +async function loadAccessRulesPage(session: gu.Session, docId: string) { + await session.loadRelPath(`/doc/${docId}/p/acl`); + await driver.findWait('.test-rule-set', 5000); +} +async function reloadAccessRulesPage() { + await driver.navigate().refresh(); + await driver.findWait('.test-rule-set', 5000); +} +async function getDefaultResourceRec(api: UserAPI, docId: string): Promise { + const records = await api.getDocAPI(docId).getRecords('_grist_ACLResources', {filters: {tableId: ['*']}}); + return records[0]; +}