gristlabs_grist-core/test/gen-server/ApiServerAccess.ts
Florent 866ec66096
Optimize sql query for workspace acl (#824)
Without this optimization, we fetched loads of entries from the database, which led to database and nodejs overloads.

We could go further, this is a modest patch towards better performance.

We use two queries: one fetches the workspaces, the second the organization that the workspace belongs to.

---------

Co-authored-by: Florent FAYOLLE <florent.fayolle@beta.gouv.fr>
2024-01-31 14:04:22 -05:00

1715 lines
64 KiB
TypeScript

import {Role} from 'app/common/roles';
import {PermissionData, PermissionDelta} from 'app/common/UserAPI';
import {Deps} from 'app/gen-server/ApiServer';
import {Organization} from 'app/gen-server/entity/Organization';
import {Product} from 'app/gen-server/entity/Product';
import {User} from 'app/gen-server/entity/User';
import {HomeDBManager, UserChange} from 'app/gen-server/lib/HomeDBManager';
import {SendGridConfig, SendGridMail} from 'app/gen-server/lib/NotifierTypes';
import axios, {AxiosResponse} from 'axios';
import {delay} from 'bluebird';
import * as chai from 'chai';
import fromPairs = require('lodash/fromPairs');
import pick = require('lodash/pick');
import * as sinon from 'sinon';
import {TestServer} from 'test/gen-server/apiUtils';
import {configForUser} from 'test/gen-server/testUtils';
import * as testUtils from 'test/server/testUtils';
const assert = chai.assert;
let server: TestServer;
let dbManager: HomeDBManager;
let homeUrl: string;
let userCountUpdates: {[orgId: number]: number[]} = {};
let lastMail: SendGridMail|null = null;
let lastMailDesc: string|null = null;
const sandbox = sinon.createSandbox();
const chimpy = configForUser('Chimpy');
const kiwi = configForUser('Kiwi');
const charon = configForUser('Charon');
const nobody = configForUser('Anonymous');
const chimpyEmail = 'chimpy@getgrist.com';
const kiwiEmail = 'kiwi@getgrist.com';
const charonEmail = 'charon@getgrist.com';
const supportEmail = 'support@getgrist.com';
const everyoneEmail = 'everyone@getgrist.com';
let chimpyRef = '';
let kiwiRef = '';
let charonRef = '';
// Test concerns only access-related functions of the ApiServer. Created to help break up the
// large amount of tests on the ApiServer.
describe('ApiServerAccess', function() {
testUtils.setTmpLogLevel('error');
let notificationsConfig: SendGridConfig|undefined;
before(async function() {
server = new TestServer(this);
homeUrl = await server.start(['home', 'docs']);
notificationsConfig = server.server.getNotifier().testSetSendMessageCallback(
async (payload, desc) => {
// Filter for invite emails only - ignore any other categories of email
if (desc.includes('invite')) {
lastMail = payload;
lastMailDesc = desc;
}
}
);
dbManager = server.dbManager;
chimpyRef = await dbManager.getUserByLogin(chimpyEmail).then((user) => user!.ref);
kiwiRef = await dbManager.getUserByLogin(kiwiEmail).then((user) => user!.ref);
charonRef = await dbManager.getUserByLogin(charonEmail).then((user) => user!.ref);
// Listen to user count updates and add them to an array.
dbManager.on('userChange', ({org, countBefore, countAfter}: UserChange) => {
if (countBefore === countAfter) { return; }
userCountUpdates[org.id] = userCountUpdates[org.id] || [];
userCountUpdates[org.id].push(countAfter);
});
});
afterEach(async function() {
userCountUpdates = {};
await server.sanityCheck();
});
after(async function() {
await server.stop();
sandbox.restore();
});
async function getLastMail(maxWait: number = 1000) {
const start = Date.now();
while (Date.now() - start < maxWait) {
if (!server.server.getNotifier().testPending) {
const result = {payload: lastMail, description: lastMailDesc};
lastMailDesc = null;
lastMail = null;
return result;
}
await delay(1);
}
throw new Error('getMessages timed out');
}
async function assertLastMail(maxWait: number = 1000) {
const {payload, description} = await getLastMail(maxWait);
if (payload === null || description === null) {
throw new Error('no mail available');
}
return {payload, description};
}
it('PATCH /api/orgs/{oid}/access is operational', async function() {
const oid = await dbManager.testGetId('Chimpyland');
const nasaOrgId = await dbManager.testGetId('NASA');
// Assert that Charon is NOT allowed to rename a workspace in Chimpyland
const wid = await dbManager.testGetId('Private');
const charonResp1 = await axios.patch(`${homeUrl}/api/workspaces/${wid}`, {
name: 'Charon-Illegal-Rename'
}, charon);
assert.equal(charonResp1.status, 403);
// Move Charon from 'viewers' to 'editors'.
const delta1 = {
users: {
[charonEmail]: 'editors'
}
};
const resp1 = await axios.patch(`${homeUrl}/api/orgs/${oid}/access`, {delta: delta1}, chimpy);
assert.equal(resp1.status, 200);
if (notificationsConfig) {
// Assert that no mail would be sent (Charon already had access).
assert.equal((await getLastMail()).payload, null);
}
// Assert that the number of users in the org has not been updated (Charon role modified only).
assert.deepEqual(userCountUpdates[oid as number], undefined);
// Assert that Charon is now allowed to rename workspaces in Chimpyland
const charonResp2 = await axios.patch(`${homeUrl}/api/workspaces/${wid}`, {
name: 'Charon-Rename'
}, charon);
assert.equal(charonResp2.status, 200);
// Move Charon back to 'viewers' and add Kiwi to 'editors' (from no permission).
const delta2 = {
users: {
[charonEmail]: 'viewers',
[kiwiEmail]: 'editors'
}
};
const resp2 = await axios.patch(`${homeUrl}/api/orgs/${oid}/access`, {delta: delta2}, chimpy);
assert.equal(resp2.status, 200);
// We should send mail about this one, since Kiwi had no access previously.
if (notificationsConfig) {
const mail = await assertLastMail();
assert.match(mail.description, /^invite kiwi@getgrist.com to http.*\/o\/docs\/\?utm_id=invite-org$/);
const env = mail.payload.personalizations[0].dynamic_template_data;
assert.deepEqual(pick(env, ['resource.name', 'resource.kind', 'resource.kindUpperFirst',
'resource.isTeamSite', 'resource.isWorkspace', 'resource.isDocument',
'host.name', 'host.email',
'user.name', 'user.email',
'access.role', 'access.canEdit', 'access.canView']), {
resource: {
name: 'Chimpyland', kind: 'team site', kindUpperFirst: 'Team site',
isTeamSite: true, isWorkspace: false, isDocument: false
},
host: {name: 'Chimpy', email: 'chimpy@getgrist.com'},
user: {name: 'Kiwi', email: 'kiwi@getgrist.com'},
access: {role: 'editors', canEdit: true, canView: true}
} as any);
assert.match(env.resource.url, /^http.*\/o\/docs\/\?utm_id=invite-org$/);
assert.deepEqual(mail.payload.personalizations[0].to[0], {email: 'kiwi@getgrist.com', name: 'Kiwi'});
assert.deepEqual(mail.payload.from, {email: 'support@getgrist.com', name: 'Chimpy (via Grist)'});
assert.deepEqual(mail.payload.reply_to, {email: 'chimpy@getgrist.com', name: 'Chimpy'});
assert.deepEqual(mail.payload.template_id, notificationsConfig.template.invite);
}
// Assert that the number of users in the org has updated (Kiwi was added).
assert.deepEqual(userCountUpdates[oid as number], [3]);
// Assert that Charon is once again NOT allowed to rename workspaces in Chimpyland
const charonResp3 = await axios.patch(`${homeUrl}/api/workspaces/${wid}`, {
name: 'Charon-Illegal-Rename-2'
}, charon);
assert.equal(charonResp3.status, 403);
// Assert that Kiwi is now allowed to rename workspaces in Chimpyland
const kiwiResp1 = await axios.patch(`${homeUrl}/api/workspaces/${wid}`, {
name: 'Private'
}, kiwi);
assert.equal(kiwiResp1.status, 200);
// Revert the changes and check that behavior is expected once more for good measure.
const delta3 = {
users: {
[kiwiEmail]: null
}
};
const resp3 = await axios.patch(`${homeUrl}/api/orgs/${oid}/access`, {delta: delta3}, chimpy);
assert.equal(resp3.status, 200);
if (notificationsConfig) {
assert.equal((await getLastMail()).description, null);
}
// Assert that the number of users in the org has updated (Kiwi was removed).
assert.deepEqual(userCountUpdates[oid as number], [3, 2]);
// Assert that Kiwi is NOT allowed to rename workspaces in Chimpyland
const kiwiResp2 = await axios.patch(`${homeUrl}/api/workspaces/${wid}`, {
name: 'Kiwi-Illegal-Rename-2'
}, kiwi);
assert.equal(kiwiResp2.status, 403);
// Give Kiwi access to NASA as an editor.
// NOTE: This tests a bug with adding users to orgs that contain guests. The bug caused existing
// guests of the org to be removed on any access update.
const delta4 = {
users: {
[kiwiEmail]: 'editors'
}
};
const resp4 = await axios.patch(`${homeUrl}/api/orgs/${nasaOrgId}/access`, {delta: delta4}, chimpy);
assert.equal(resp4.status, 200);
if (notificationsConfig) {
assert.match(
(await assertLastMail()).description,
/^invite kiwi@getgrist.com to http.*\/o\/nasa\/\?utm_id=invite-org$/
);
}
// Assert that the number of users in the org has updated (Kiwi was added).
assert.deepEqual(userCountUpdates[nasaOrgId as number], [2]);
// Check that access to NASA is as expected.
const resp5 = await axios.get(`${homeUrl}/api/orgs/${nasaOrgId}/access`, chimpy);
assert.equal(resp5.status, 200);
assert.deepEqual(resp5.data, {
users: [{
id: 1,
name: 'Chimpy',
email: chimpyEmail,
ref: chimpyRef,
picture: null,
access: "owners",
isMember: true,
}, {
id: 2,
name: 'Kiwi',
email: kiwiEmail,
ref: kiwiRef,
picture: null,
access: "editors",
isMember: true,
}, {
id: 3,
name: 'Charon',
email: charonEmail,
ref: charonRef,
picture: null,
access: "guests",
isMember: false,
}]
});
// Revoke Kiwi's access to NASA.
const delta6 = {
users: {
[kiwiEmail]: null
}
};
const resp6 = await axios.patch(`${homeUrl}/api/orgs/${nasaOrgId}/access`, {delta: delta6}, chimpy);
assert.equal(resp6.status, 200);
if (notificationsConfig) {
assert.equal((await getLastMail()).description, null);
}
// Assert that the number of users in the org has updated (Kiwi was removed).
assert.deepEqual(userCountUpdates[nasaOrgId as number], [2, 1]);
// Check that access to NASA is again as expected, this time without Kiwi present.
const resp7 = await axios.get(`${homeUrl}/api/orgs/${nasaOrgId}/access`, chimpy);
assert.equal(resp7.status, 200);
assert.deepEqual(resp7.data, {
users: [{
id: 1,
name: 'Chimpy',
email: chimpyEmail,
ref: chimpyRef,
picture: null,
access: "owners",
isMember: true,
}, {
id: 3,
name: 'Charon',
email: charonEmail,
ref: charonRef,
picture: null,
access: "guests",
isMember: false,
}]
});
});
it('PATCH /api/orgs/{oid}/access allows non-owners to remove themselves', async function() {
const oid = await dbManager.testGetId('NASA');
const url = `${homeUrl}/api/orgs/${oid}/access`;
await testAllowNonOwnersToRemoveThemselves(url);
});
it('PATCH /api/orgs/{oid}/access returns 404 appropriately', async function() {
const delta = {
users: {
[charonEmail]: null
}
};
const resp = await axios.patch(`${homeUrl}/api/orgs/9999/access`, {delta}, chimpy);
assert.equal(resp.status, 404);
});
it('PATCH /api/orgs/{oid}/access returns 403 appropriately', async function() {
// Attempt to set access with a user that does not have ACL_EDIT permissions.
const oid = await dbManager.testGetId('Chimpyland');
const delta = {
users: {
[kiwiEmail]: 'viewers'
}
};
const resp = await axios.patch(`${homeUrl}/api/orgs/${oid}/access`, {delta}, charon);
assert.equal(resp.status, 403);
});
it('PATCH /api/orgs/{oid}/access returns 400 appropriately', async function() {
// Omit the delta and check that the operation fails with 400.
const oid = await dbManager.testGetId('Chimpyland');
const resp1 = await axios.patch(`${homeUrl}/api/orgs/${oid}/access`, {}, chimpy);
assert.equal(resp1.status, 400);
// Omit the users object and check that the operation fails with 400.
const resp2 = await axios.patch(`${homeUrl}/api/orgs/${oid}/access`, {delta: {}}, chimpy);
assert.equal(resp2.status, 400);
// Include a maxInheritedRole value and check that the operation fails with 400.
const delta1 = {maxInheritedRole: null};
const resp3 = await axios.patch(`${homeUrl}/api/orgs/${oid}/access`, {delta: delta1}, chimpy);
assert.equal(resp3.status, 400);
// Attempt to update own permissions check that the operation fails with 400.
const delta2 = {
users: {
[chimpyEmail]: 'viewers'
}
};
const resp4 = await axios.patch(`${homeUrl}/api/orgs/${oid}/access`, {delta: delta2}, chimpy);
assert.equal(resp4.status, 400);
});
it('PATCH /api/workspaces/{wid}/access is operational', async function() {
const oid = await dbManager.testGetId('Chimpyland');
const wid = await dbManager.testGetId('Private');
// Assert that Kiwi is unable to GET the org, since Kiwi has no permissions on the org.
const kiwiResp1 = await axios.get(`${homeUrl}/api/orgs/${oid}`, kiwi);
assert.equal(kiwiResp1.status, 403);
const delta0 = {
users: {[kiwiEmail]: 'members'}
};
const resp0 = await axios.patch(`${homeUrl}/api/orgs/${oid}/access`, {delta: delta0}, chimpy);
assert.equal(resp0.status, 200);
// Make Kiwi an editor of the workspace
const delta1 = {
users: {[kiwiEmail]: 'editors'}
};
const resp2 = await axios.patch(`${homeUrl}/api/workspaces/${wid}/access`, {delta: delta1}, chimpy);
assert.equal(resp2.status, 200);
// Check we would sent an email to Kiwi about this
if (notificationsConfig) {
const mail = await assertLastMail();
assert.match(mail.description, /^invite kiwi@getgrist.com to http.*\/o\/docs\/ws\/[0-9]+\/\?utm_id=invite-ws$/);
const env = mail.payload.personalizations[0].dynamic_template_data;
assert.match(env.resource.url, /^http.*\/o\/docs\/ws\/[0-9]+\/\?utm_id=invite-ws$/);
assert.equal(env.resource.kind, 'workspace');
assert.equal(env.resource.kindUpperFirst, 'Workspace');
assert.equal(env.resource.isTeamSite, false);
assert.equal(env.resource.isWorkspace, true);
assert.equal(env.resource.isDocument, false);
assert.equal(env.resource.name, 'Private');
}
// Assert that the number of users in Chimpyland has updated (Kiwi was added).
assert.deepEqual(userCountUpdates[oid as number], [3]);
// Assert that Kiwi is now allowed to rename workspace 'Private' in Chimpyland
const kiwiResp2 = await axios.patch(`${homeUrl}/api/workspaces/${wid}`, {
name: 'Kiwi-Rename'
}, kiwi);
assert.equal(kiwiResp2.status, 200);
// Assert that Kiwi is also now able to GET the org, since Kiwi is now a guest of the org.
const kiwiResp3 = await axios.get(`${homeUrl}/api/orgs/${oid}`, kiwi);
assert.equal(kiwiResp3.status, 200);
// Set the maxInheritedRole to 'viewers'
const delta2 = {
maxInheritedRole: 'viewers'
};
const resp3 = await axios.patch(`${homeUrl}/api/workspaces/${wid}/access`, {delta: delta2}, chimpy);
assert.equal(resp3.status, 200);
if (notificationsConfig) {
assert.equal((await getLastMail()).description, null);
}
// Assert that Kiwi is still allowed to rename the workspace.
const kiwiResp4 = await axios.patch(`${homeUrl}/api/workspaces/${wid}`, {
name: 'Kiwi-Rename2'
}, kiwi);
assert.equal(kiwiResp4.status, 200);
// Assert that Charon is still allowed to GET the workspace.
const charonResp1 = await axios.get(`${homeUrl}/api/workspaces/${wid}`, charon);
assert.equal(charonResp1.status, 200);
// Assert that as the owner, Chimpy can still rename the workspace.
const resp4 = await axios.patch(`${homeUrl}/api/workspaces/${wid}`, {
name: 'Chimpy-Rename'
}, chimpy);
assert.equal(resp4.status, 200);
// Remove inheritance and also update Kiwi's role to viewer.
const delta3 = {
maxInheritedRole: null,
users: {
[kiwiEmail]: 'viewers'
}
};
const resp5 = await axios.patch(`${homeUrl}/api/workspaces/${wid}/access`, {delta: delta3}, chimpy);
assert.equal(resp5.status, 200);
if (notificationsConfig) {
assert.equal((await getLastMail()).description, null);
}
// Assert that Kiwi can still GET the workspace.
const kiwiResp5 = await axios.get(`${homeUrl}/api/workspaces/${wid}`, kiwi);
assert.equal(kiwiResp5.status, 200);
// Assert that Charon can NOT GET the workspace.
const charonResp2 = await axios.get(`${homeUrl}/api/workspaces/${wid}`, charon);
assert.equal(charonResp2.status, 403);
// Assert that as the owner, Chimpy can still rename the workspace.
const resp6 = await axios.patch(`${homeUrl}/api/workspaces/${wid}`, {
name: 'Chimpy-Rename2'
}, chimpy);
assert.equal(resp6.status, 200);
// Add Charon as an editor to 'Public', and make sure it does NOT affect org
// guest access for Kiwi.
const wid2 = await dbManager.testGetId('Public');
const delta4 = {
users: {
[charonEmail]: 'editors'
}
};
const resp7 = await axios.patch(`${homeUrl}/api/workspaces/${wid2}/access`, {delta: delta4}, chimpy);
assert.equal(resp7.status, 200);
if (notificationsConfig) {
assert.match((await assertLastMail()).description, /^invite charon@getgrist.com /);
}
// Assert that Kiwi is still able to GET the org, since Kiwi is still a guest
// of the org.
const kiwiResp6 = await axios.get(`${homeUrl}/api/orgs/${oid}`, kiwi);
assert.equal(kiwiResp6.status, 200);
// Remove Charon's custom permissions to 'Public'
const delta5 = {
users: {
[charonEmail]: null
}
};
const resp8 = await axios.patch(`${homeUrl}/api/workspaces/${wid2}/access`, {delta: delta5}, chimpy);
assert.equal(resp8.status, 200);
if (notificationsConfig) {
assert.equal((await getLastMail()).description, null);
}
// Reset inheritance and remove Kiwi's custom permissions
const delta6 = {
maxInheritedRole: 'owners'
};
const resp9 = await axios.patch(`${homeUrl}/api/workspaces/${wid}/access`, {delta: delta6}, chimpy);
assert.equal(resp9.status, 200);
if (notificationsConfig) {
assert.equal((await getLastMail()).description, null);
}
const removeKiwiDelta = {
users: {[kiwiEmail]: null}
};
const removeKiwiResp = await axios.patch(`${homeUrl}/api/orgs/${oid}/access`,
{delta: removeKiwiDelta}, chimpy);
assert.equal(removeKiwiResp.status, 200);
// TODO: Unnecessary once removing from org removes from all.
const removeKiwiResp2 = await axios.patch(`${homeUrl}/api/workspaces/${wid}/access`,
{delta: removeKiwiDelta}, chimpy);
assert.equal(removeKiwiResp2.status, 200);
// Assert that the number of users in the org has updated (Kiwi was removed).
assert.deepEqual(userCountUpdates[oid as number], [3, 2]);
// Assert that Kiwi is NOT allowed to GET the workspace
const kiwiResp7 = await axios.get(`${homeUrl}/api/workspaces/${wid}`, kiwi);
assert.equal(kiwiResp7.status, 403);
// Assert that Charon can once again GET the workspace
const charonResp3 = await axios.get(`${homeUrl}/api/workspaces/${wid}`, charon);
assert.equal(charonResp3.status, 200);
// Assert that as the owner, Chimpy can still rename the workspace.
const resp10 = await axios.patch(`${homeUrl}/api/workspaces/${wid}`, {
name: 'Private'
}, chimpy);
assert.equal(resp10.status, 200);
// Assert that Kiwi is no longer able to GET the org, since Kiwi is no longer a guest
// of the org.
const kiwiResp8 = await axios.get(`${homeUrl}/api/orgs/${oid}`, kiwi);
assert.equal(kiwiResp8.status, 403);
});
it('PATCH /api/workspaces/{wid}/access allows non-owners to remove themselves', async function() {
const wid = await dbManager.testGetId('Private');
const url = `${homeUrl}/api/workspaces/${wid}/access`;
await testAllowNonOwnersToRemoveThemselves(url);
});
it('PATCH /api/workspaces/{wid}/access returns 404 appropriately', async function() {
const delta = {
users: {
[charonEmail]: null
}
};
const resp = await axios.patch(`${homeUrl}/api/workspaces/9999/access`, {delta}, chimpy);
assert.equal(resp.status, 404);
});
it('PATCH /api/workspaces/{wid}/access returns 403 appropriately', async function() {
// Attempt to set access with a user that does not have ACL_EDIT permissions.
const wid = await dbManager.testGetId('Private');
const delta = {
users: {
[kiwiEmail]: 'viewers'
}
};
const resp = await axios.patch(`${homeUrl}/api/workspaces/${wid}/access`, {delta}, charon);
assert.equal(resp.status, 403);
});
it('PATCH /api/workspaces/{wid}/access returns 400 appropriately', async function() {
// Omit the delta and check that the operation fails with 400.
const wid = await dbManager.testGetId('Private');
const resp1 = await axios.patch(`${homeUrl}/api/workspaces/${wid}/access`, {}, chimpy);
assert.equal(resp1.status, 400);
// Omit the content and check that the operation fails with 400.
const resp2 = await axios.patch(`${homeUrl}/api/workspaces/${wid}/access`, {delta: {}}, chimpy);
assert.equal(resp2.status, 400);
// Attempt to update own permissions check that the operation fails with 400.
const delta = {
users: {
[chimpyEmail]: 'viewers'
}
};
const resp3 = await axios.patch(`${homeUrl}/api/workspaces/${wid}/access`, {delta}, chimpy);
assert.equal(resp3.status, 400);
});
it('PATCH /api/docs/{did}/access is operational', async function() {
const oid = await dbManager.testGetId('Chimpyland');
const wid = await dbManager.testGetId('Private');
const did = await dbManager.testGetId('Timesheets');
// Assert that Kiwi is unable to GET the workspace, since Kiwi has no permissions on
// the org/workspace.
const kiwiResp1 = await axios.get(`${homeUrl}/api/workspaces/${wid}`, kiwi);
assert.equal(kiwiResp1.status, 403);
// Make Kiwi a member of the org.
const delta0 = {
users: {[kiwiEmail]: 'members'}
};
const resp1 = await axios.patch(`${homeUrl}/api/orgs/${oid}/access`, {delta: delta0}, chimpy);
assert.equal(resp1.status, 200);
// Make Kiwi a doc editor for Timesheets
const delta1 = {
users: {[kiwiEmail]: 'editors'}
};
const resp2 = await axios.patch(`${homeUrl}/api/docs/${did}/access`, {delta: delta1}, chimpy);
assert.equal(resp2.status, 200);
// Check we would sent an email to Kiwi about this
if (notificationsConfig) {
const mail = await assertLastMail();
assert.match(mail.description, /^invite kiwi@getgrist.com to http.*\/o\/docs\/doc\/.*$/);
const env = mail.payload.personalizations[0].dynamic_template_data;
assert.match(env.resource.url, /^http.*\/o\/docs\/doc\/.*$/);
assert.equal(env.resource.kind, 'document');
assert.equal(env.resource.kindUpperFirst, 'Document');
assert.equal(env.resource.isTeamSite, false);
assert.equal(env.resource.isWorkspace, false);
assert.equal(env.resource.isDocument, true);
assert.equal(env.resource.name, 'Timesheets');
}
// Assert that the number of users in Chimpyland has updated (Kiwi was added).
assert.deepEqual(userCountUpdates[oid as number], [3]);
// Assert that Kiwi is not allowed to rename doc 'Timesheets' in Chimpyland
const kiwiResp2 = await axios.patch(`${homeUrl}/api/docs/${did}`, {
name: 'Kiwi-Rename'
}, kiwi);
assert.equal(kiwiResp2.status, 403);
// Assert that Kiwi is also now able to GET the workspace, since Kiwi is now a guest of
// the workspace.
const kiwiResp3 = await axios.get(`${homeUrl}/api/workspaces/${wid}`, kiwi);
assert.equal(kiwiResp3.status, 200);
// Assert that Kiwi is also now able to GET the org, since Kiwi is now a guest/member of the org.
const kiwiResp4 = await axios.get(`${homeUrl}/api/orgs/${oid}`, kiwi);
assert.equal(kiwiResp4.status, 200);
// Set the maxInheritedRole to null
const delta2 = {maxInheritedRole: null};
const resp3 = await axios.patch(`${homeUrl}/api/docs/${did}/access`, {delta: delta2}, chimpy);
assert.equal(resp3.status, 200);
if (notificationsConfig) {
assert.equal((await getLastMail()).description, null);
}
// Assert that Kiwi is still not allowed to rename the doc.
const kiwiResp5 = await axios.patch(`${homeUrl}/api/docs/${did}`, {
name: 'Kiwi-Rename2'
}, kiwi);
assert.equal(kiwiResp5.status, 403);
// Assert that Charon cannot view 'Timesheets'.
const charonResp1 = await axios.get(`${homeUrl}/api/workspaces/${wid}`, charon);
assert.equal(charonResp1.status, 200);
assert.deepEqual(charonResp1.data.docs.map((doc: any) => doc.name), ['Appointments']);
// Assert that as the owner, Chimpy can still rename the doc.
const resp4 = await axios.patch(`${homeUrl}/api/docs/${did}`, {
name: 'Chimpy-Rename'
}, chimpy);
assert.equal(resp4.status, 200);
// Add inheritance for viewers and also update Kiwi's role to viewer.
const delta3 = {
maxInheritedRole: 'viewers',
users: {
[kiwiEmail]: 'viewers'
}
};
const resp5 = await axios.patch(`${homeUrl}/api/docs/${did}/access`, {delta: delta3}, chimpy);
assert.equal(resp5.status, 200);
if (notificationsConfig) {
assert.equal((await getLastMail()).description, null);
}
// Assert that Kiwi can still view the doc.
const kiwiResp6 = await axios.get(`${homeUrl}/api/workspaces/${wid}`, kiwi);
assert.equal(kiwiResp6.status, 200);
assert.deepEqual(kiwiResp6.data.docs.map((doc: any) => doc.name),
['Chimpy-Rename']);
// Assert that Charon can now view the doc.
const charonResp2 = await axios.get(`${homeUrl}/api/workspaces/${wid}`, charon);
assert.equal(charonResp2.status, 200);
assert.deepEqual(charonResp2.data.docs.map((doc: any) => doc.name),
['Chimpy-Rename', 'Appointments']);
// Assert that Charon can NOT rename the doc.
const charonResp3 = await axios.patch(`${homeUrl}/api/docs/${did}`, {
name: 'Charon-Invalid-Rename',
}, charon);
assert.equal(charonResp3.status, 403);
// Assert that as the owner, Chimpy can still rename the doc.
const resp6 = await axios.patch(`${homeUrl}/api/docs/${did}`, {
name: 'Timesheets'
}, chimpy);
assert.equal(resp6.status, 200);
// Add Charon as an editor to 'Appointments', and make sure it does NOT affect org
// or workspace guest access for Kiwi.
const did2 = await dbManager.testGetId('Appointments');
const delta4 = {
users: {
[charonEmail]: 'editors'
}
};
const resp7 = await axios.patch(`${homeUrl}/api/docs/${did2}/access`, {delta: delta4}, chimpy);
assert.equal(resp7.status, 200);
if (notificationsConfig) {
assert.match((await assertLastMail()).description, /^invite charon@getgrist.com /);
}
// Assert that Kiwi is still able to GET the workspace, since Kiwi is still a
// guest of the workspace.
const kiwiResp7 = await axios.get(`${homeUrl}/api/workspaces/${wid}`, kiwi);
assert.equal(kiwiResp7.status, 200);
// Assert that Kiwi is still able to GET the org, since Kiwi is still a guest
// of the org.
const kiwiResp8 = await axios.get(`${homeUrl}/api/orgs/${oid}`, kiwi);
assert.equal(kiwiResp8.status, 200);
// Remove Charon's custom permissions to 'Appointments'
const delta5 = {
users: {
[charonEmail]: null
}
};
const resp8 = await axios.patch(`${homeUrl}/api/docs/${did2}/access`, {delta: delta5}, chimpy);
assert.equal(resp8.status, 200);
// Reset doc inheritance setting
const delta6 = {
maxInheritedRole: 'owners',
};
const resp9 = await axios.patch(`${homeUrl}/api/docs/${did}/access`, {delta: delta6}, chimpy);
assert.equal(resp9.status, 200);
if (notificationsConfig) {
assert.equal((await getLastMail()).description, null);
}
// Remove Kiwi from the org.
const removeKiwiDelta = {
users: {[kiwiEmail]: null}
};
const resp10 = await axios.patch(`${homeUrl}/api/orgs/${oid}/access`,
{delta: removeKiwiDelta}, chimpy);
assert.equal(resp10.status, 200);
// TODO: Unnecessary once removing from org removes from all.
const resp11 = await axios.patch(`${homeUrl}/api/docs/${did}/access`,
{delta: removeKiwiDelta}, chimpy);
assert.equal(resp11.status, 200);
// Assert that the number of users in Chimpyland has updated (Kiwi was removed).
assert.deepEqual(userCountUpdates[oid as number], [3, 2]);
// Assert that Kiwi is no longer able to GET the workspace, since Kiwi is no longer a
// guest of the workspace.
const kiwiResp9 = await axios.get(`${homeUrl}/api/workspaces/${wid}`, kiwi);
assert.equal(kiwiResp9.status, 403);
// Assert that Kiwi is no longer able to GET the org, since Kiwi is no longer a guest/member
// of the org.
const kiwiResp10 = await axios.get(`${homeUrl}/api/orgs/${oid}`, kiwi);
assert.equal(kiwiResp10.status, 403);
});
it('PATCH /api/docs/{did}/access allows non-owners to remove themselves', async function() {
const did = await dbManager.testGetId('Timesheets');
const url = `${homeUrl}/api/docs/${did}/access`;
await testAllowNonOwnersToRemoveThemselves(url);
});
it('PATCH /api/docs/{did}/access can send multiple invites', async function() {
const did = await dbManager.testGetId('Timesheets');
let delta: PermissionDelta = {
users: {
'user1@getgrist.com': 'editors',
'user2@getgrist.com': 'viewers',
'user3@getgrist.com': 'viewers',
}
};
let resp = await axios.patch(`${homeUrl}/api/docs/${did}/access`, {delta}, chimpy);
assert.equal(resp.status, 200);
if (notificationsConfig) {
const mail = await assertLastMail();
assert.lengthOf(mail.payload.personalizations, 3);
assert.sameMembers(mail.payload.personalizations.map(p => p.to[0].email),
['user1@getgrist.com', 'user2@getgrist.com', 'user3@getgrist.com']);
assert.deepEqual(mail.payload.personalizations.map(p => p.dynamic_template_data.access),
[{role: 'editors', canEdit: true, canView: true, canEditAccess: false},
{role: 'viewers', canEdit: false, canView: true, canEditAccess: false},
{role: 'viewers', canEdit: false, canView: true, canEditAccess: false}]);
}
delta = {
users: {
'user2@getgrist.com': null,
'user3@getgrist.com': 'editors',
'user4@getgrist.com': 'viewers',
}
};
resp = await axios.patch(`${homeUrl}/api/docs/${did}/access`, {delta}, chimpy);
assert.equal(resp.status, 200);
if (notificationsConfig) {
const mail = await assertLastMail();
assert.lengthOf(mail.payload.personalizations, 1);
assert.deepEqual(mail.payload.personalizations[0].to, [{
email: 'user4@getgrist.com',
name: '', // name is blank since this user has never logged in.
}]);
}
delta = {
users: {
'user1@getgrist.com': null,
'user3@getgrist.com': null,
'user4@getgrist.com': null,
}
};
resp = await axios.patch(`${homeUrl}/api/docs/${did}/access`, {delta}, chimpy);
assert.equal(resp.status, 200);
if (notificationsConfig) {
assert.equal((await getLastMail()).payload, null);
}
});
it('PATCH /api/docs/{did}/access returns 404 appropriately', async function() {
const delta = {
users: {
[charonEmail]: null
}
};
const resp = await axios.patch(`${homeUrl}/api/docs/9999/access`, {delta}, chimpy);
assert.equal(resp.status, 404);
});
it('PATCH /api/docs/{did}/access returns 403 appropriately', async function() {
// Attempt to set access with a user that does not have ACL_EDIT permissions.
const did = await dbManager.testGetId('Timesheets');
const delta = {
users: {
[kiwiEmail]: 'viewers'
}
};
const resp = await axios.patch(`${homeUrl}/api/docs/${did}/access`, {delta}, charon);
assert.equal(resp.status, 403);
});
it('PATCH /api/docs/{did}/access returns 400 appropriately', async function() {
// Omit the delta and check that the operation fails with 400.
const did = await dbManager.testGetId('Timesheets');
const resp1 = await axios.patch(`${homeUrl}/api/docs/${did}/access`, {}, chimpy);
assert.equal(resp1.status, 400);
// Omit the content and check that the operation fails with 400.
const resp2 = await axios.patch(`${homeUrl}/api/docs/${did}/access`, {delta: {}}, chimpy);
assert.equal(resp2.status, 400);
// Attempt to update own permissions check that the operation fails with 400.
const delta = {
users: {
[chimpyEmail]: 'viewers'
}
};
const resp3 = await axios.patch(`${homeUrl}/api/docs/${did}/access`, {delta}, chimpy);
assert.equal(resp3.status, 400);
});
it('GET /api/orgs/{oid}/access is operational', async function() {
const oid = await dbManager.testGetId('Chimpyland');
const resp = await axios.get(`${homeUrl}/api/orgs/${oid}/access`, chimpy);
assert.equal(resp.status, 200);
assert.deepEqual(resp.data, {
users: [{
id: 1,
name: 'Chimpy',
email: chimpyEmail,
ref: chimpyRef,
picture: null,
access: "owners",
isMember: true,
}, {
id: 3,
name: 'Charon',
email: charonEmail,
ref: charonRef,
picture: null,
access: "viewers",
isMember: true,
}]
});
});
it('GET /api/orgs/{oid}/access returns 404 appropriately', async function() {
const resp = await axios.get(`${homeUrl}/api/orgs/9999/access`, chimpy);
assert.equal(resp.status, 404);
});
it('GET /api/orgs/{oid}/access returns 403 appropriately', async function() {
const oid = await dbManager.testGetId('Chimpyland');
const resp = await axios.get(`${homeUrl}/api/orgs/${oid}/access`, kiwi);
assert.equal(resp.status, 403);
});
it('GET /api/workspaces/{wid}/access is operational', async function() {
// Run a simple case on a Chimpyland workspace
const oid = await dbManager.testGetId('Chimpyland');
const wid = await dbManager.testGetId('Public');
const resp1 = await axios.get(`${homeUrl}/api/workspaces/${wid}/access`, chimpy);
assert.equal(resp1.status, 200);
assert.deepEqual(resp1.data, {
maxInheritedRole: "owners",
users: [{
id: 1,
name: 'Chimpy',
email: chimpyEmail,
ref: chimpyRef,
picture: null,
access: null,
parentAccess: "owners",
isMember: true,
}, {
id: 3,
name: 'Charon',
email: charonEmail,
ref: charonRef,
picture: null,
access: null,
parentAccess: "viewers",
isMember: true,
}]
});
// Run a complex case by modifying maxInheritedRole and individual roles on the workspace,
// then querying for access
// Set the maxInheritedRole to null
const kiwiMemberDelta = {
users: {[kiwiEmail]: "members"}
};
const orgPatchResp = await axios.patch(`${homeUrl}/api/orgs/${oid}/access`,
{delta: kiwiMemberDelta}, chimpy);
assert.equal(orgPatchResp.status, 200);
const delta = {
maxInheritedRole: null,
users: {
[kiwiEmail]: "editors"
}
};
const patchResp = await axios.patch(`${homeUrl}/api/workspaces/${wid}/access`, {delta}, chimpy);
assert.equal(patchResp.status, 200);
const resp2 = await axios.get(`${homeUrl}/api/workspaces/${wid}/access`, chimpy);
assert.equal(resp2.status, 200);
assert.deepEqual(resp2.data, {
maxInheritedRole: null,
users: [{
id: 1,
name: 'Chimpy',
email: chimpyEmail,
ref: chimpyRef,
picture: null,
// Note that chimpy's access has been elevated to "owners"
access: "owners",
parentAccess: "owners",
isMember: true,
}, {
id: 2,
name: 'Kiwi',
email: kiwiEmail,
ref: kiwiRef,
picture: null,
access: "editors",
parentAccess: null,
isMember: true,
}, {
id: 3,
name: 'Charon',
email: charonEmail,
ref: charonRef,
picture: null,
access: null,
parentAccess: "viewers",
isMember: true,
}]
});
const deltaOrg = {
users: {
[kiwiEmail]: "owners",
}
};
const respDeltaOrg = await axios.patch(`${homeUrl}/api/orgs/${oid}/access`, {delta: deltaOrg}, chimpy);
assert.equal(respDeltaOrg.status, 200);
const resp3 = await axios.get(`${homeUrl}/api/workspaces/${wid}/access`, chimpy);
assert.include(resp3.data.users.find((user: any) => user.email === kiwiEmail), {
access: "editors",
parentAccess: "owners"
});
// Reset the access settings
const resetDelta = {
maxInheritedRole: "owners",
users: {
[kiwiEmail]: null
}
};
const resetResp = await axios.patch(`${homeUrl}/api/workspaces/${wid}/access`, {delta: resetDelta}, chimpy);
assert.equal(resetResp.status, 200);
const resetOrgDelta = {
users: {
[kiwiEmail]: "members",
}
};
const resetOrgResp = await axios.patch(`${homeUrl}/api/orgs/${oid}/access`, {delta: resetOrgDelta}, chimpy);
assert.equal(resetOrgResp.status, 200);
// Assert that ws guests are properly displayed.
// Tests a minor bug that showed ws guests as having null access.
// Add a doc to 'Public', and add Kiwi to the doc.
// Add a doc to 'Public'
const addDocResp = await axios.post(`${homeUrl}/api/workspaces/${wid}/docs`, {
name: 'PublicDoc'
}, chimpy);
// Assert that the response is successful
assert.equal(addDocResp.status, 200);
const did = addDocResp.data;
// Add Kiwi to the doc
const docAccessResp = await axios.patch(`${homeUrl}/api/docs/${did}/access`, {delta}, chimpy);
assert.equal(docAccessResp.status, 200);
// Assert that Kiwi is now a guest of public.
const wsResp = await axios.get(`${homeUrl}/api/workspaces/${wid}/access`, chimpy);
assert.equal(wsResp.status, 200);
assert.deepEqual(wsResp.data, {
maxInheritedRole: "owners",
users: [{
id: 1,
name: 'Chimpy',
email: chimpyEmail,
ref: chimpyRef,
picture: null,
access: "owners",
parentAccess: "owners",
isMember: true,
}, {
id: 2,
name: 'Kiwi',
email: kiwiEmail,
ref: kiwiRef,
picture: null,
access: "guests",
parentAccess: null,
isMember: true,
}, {
id: 3,
name: 'Charon',
email: charonEmail,
ref: charonRef,
picture: null,
access: null,
parentAccess: "viewers",
isMember: true,
}]
});
// Remove the doc.
const deleteResp = await axios.delete(`${homeUrl}/api/docs/${did}`, chimpy);
assert.equal(deleteResp.status, 200);
// Assert that Kiwi is no longer a guest of public.
const wsResp2 = await axios.get(`${homeUrl}/api/workspaces/${wid}/access`, chimpy);
assert.equal(wsResp2.status, 200);
assert.deepEqual(wsResp2.data, {
maxInheritedRole: "owners",
users: [{
id: 1,
name: 'Chimpy',
email: chimpyEmail,
ref: chimpyRef,
picture: null,
access: "owners",
parentAccess: "owners",
isMember: true,
}, {
id: 2,
name: "Kiwi",
email: kiwiEmail,
ref: kiwiRef,
picture: null,
access: null,
parentAccess: null,
isMember: true,
}, {
id: 3,
name: 'Charon',
email: charonEmail,
ref: charonRef,
picture: null,
access: null,
parentAccess: "viewers",
isMember: true,
}]
});
// Remove Kiwi from the org to reset initial settings
const kiwiResetDelta = {
users: {[kiwiEmail]: null}
};
const orgPatchResp2 = await axios.patch(`${homeUrl}/api/orgs/${oid}/access`,
{delta: kiwiResetDelta}, chimpy);
assert.equal(orgPatchResp2.status, 200);
});
it('GET /api/workspaces/{wid}/access returns 404 appropriately', async function() {
const resp = await axios.get(`${homeUrl}/api/workspaces/9999/access`, chimpy);
assert.equal(resp.status, 404);
});
it('GET /api/workspaces/{wid}/access returns 403 appropriately', async function() {
const wid = await dbManager.testGetId('Private');
const resp = await axios.get(`${homeUrl}/api/workspaces/${wid}/access`, kiwi);
assert.equal(resp.status, 403);
});
it('GET /api/docs/{did}/access is operational', async function() {
// Run a simple case on a Chimpyland doc
const oid = await dbManager.testGetId('Chimpyland');
const did = await dbManager.testGetId('Timesheets');
const resp1 = await axios.get(`${homeUrl}/api/docs/${did}/access`, chimpy);
assert.equal(resp1.status, 200);
assert.deepEqual(resp1.data, {
maxInheritedRole: "owners",
users: [{
id: 1,
name: 'Chimpy',
email: chimpyEmail,
ref: chimpyRef,
picture: null,
// Note that Chimpy explicitly has owners access to the doc from a previous test.
access: "owners",
parentAccess: "owners",
isMember: true,
}, {
id: 3,
name: 'Charon',
email: charonEmail,
ref: charonRef,
picture: null,
access: null,
parentAccess: "viewers",
isMember: true,
}]
});
// Add kiwi as a member of Chimpyland
const kiwiMemberDelta = {
users: {[kiwiEmail]: "members"}
};
const kiwiMemberResp = await axios.patch(`${homeUrl}/api/orgs/${oid}/access`,
{delta: kiwiMemberDelta}, chimpy);
assert.equal(kiwiMemberResp.status, 200);
// Run a complex case by modifying maxInheritedRole and individual roles on a doc then querying
// for access
// Set the maxInheritedRole to null
const delta = {
maxInheritedRole: null,
users: {[kiwiEmail]: "editors"}
};
const patchResp = await axios.patch(`${homeUrl}/api/docs/${did}/access`, {delta}, chimpy);
assert.equal(patchResp.status, 200);
const resp2 = await axios.get(`${homeUrl}/api/docs/${did}/access`, chimpy);
assert.equal(resp2.status, 200);
assert.deepEqual(resp2.data, {
maxInheritedRole: null,
users: [{
id: 1,
name: 'Chimpy',
email: chimpyEmail,
ref: chimpyRef,
picture: null,
access: "owners",
parentAccess: "owners",
isMember: true,
}, {
id: 2,
name: 'Kiwi',
email: kiwiEmail,
ref: kiwiRef,
picture: null,
access: "editors",
parentAccess: null,
isMember: true,
}, {
id: 3,
name: 'Charon',
email: charonEmail,
ref: charonRef,
picture: null,
access: null,
parentAccess: "viewers",
isMember: true,
}]
});
// Reset the access settings
const kiwiResetDelta = {
users: {[kiwiEmail]: null}
};
const kiwiResetResp = await axios.patch(`${homeUrl}/api/orgs/${oid}/access`,
{delta: kiwiResetDelta}, chimpy);
assert.equal(kiwiResetResp.status, 200);
// TODO: Unnecessary once removing from org removes from all.
const resetDelta = {
maxInheritedRole: "owners",
users: {
[kiwiEmail]: null
}
};
const resetResp = await axios.patch(`${homeUrl}/api/docs/${did}/access`, {delta: resetDelta}, chimpy);
assert.equal(resetResp.status, 200);
// Run another complex case by modifying maxInheritedRole and individual roles of the workspace
// the doc is in then querying for access.
const shark = await dbManager.testGetId('Shark');
const sharkWs = await dbManager.testGetId('Big');
const wsDelta = {
maxInheritedRole: "viewers"
};
const patchResp2 = await axios.patch(`${homeUrl}/api/workspaces/${sharkWs}/access`, {delta: wsDelta}, chimpy);
assert.equal(patchResp2.status, 200);
const resp3 = await axios.get(`${homeUrl}/api/docs/${shark}/access`, chimpy);
assert.equal(resp3.status, 200);
// Assert that the maxInheritedRole of the workspace limits inherited access from the org.
assert.deepEqual(resp3.data, {
maxInheritedRole: 'owners',
users: [{
id: 1,
name: 'Chimpy',
email: chimpyEmail,
ref: chimpyRef,
picture: null,
// Note that Chimpy's access to shark is inherited from the workspace, of which he is
// explicitly an owner.
access: null,
parentAccess: "owners",
isMember: true,
}, {
id: 2,
name: 'Kiwi',
email: kiwiEmail,
ref: kiwiRef,
picture: null,
access: null,
parentAccess: "viewers",
isMember: true,
}, {
id: 3,
name: 'Charon',
email: charonEmail,
ref: charonRef,
picture: null,
access: null,
parentAccess: "viewers",
isMember: true,
}]
});
// Reset the access settings
const resetDelta2 = {
maxInheritedRole: "owners"
};
const resetResp2 = await axios.patch(`${homeUrl}/api/workspaces/${sharkWs}/access`, {delta: resetDelta2}, chimpy);
assert.equal(resetResp2.status, 200);
});
it('GET /api/docs/{did}/access returns 404 appropriately', async function() {
const resp = await axios.get(`${homeUrl}/api/docs/9999/access`, chimpy);
assert.equal(resp.status, 404);
});
it('GET /api/docs/{did}/access returns 403 appropriately', async function() {
const did = await dbManager.testGetId('Timesheets');
const resp = await axios.get(`${homeUrl}/api/docs/${did}/access`, kiwi);
assert.equal(resp.status, 403);
});
it('should show special users if they are added', async function() {
// TODO We may want to expose special flags in requests and responses rather than allow adding
// and retrieving special email addresses. For now, just make sure that if we succeed adding a
// a special user, that we can also retrieve it.
const wid = await dbManager.testGetId('Private');
const did = await dbManager.testGetId('Timesheets'); // This is inside workspace `wid`
// Turns users from PermissionData into a mapping from email address to [access, parentAccess],
// for more concise comparisons below.
function compactAccess(data: PermissionData): {[email: string]: [Role|null, Role|null]} {
return fromPairs(data.users.map((u) => [u.email, [u.access, u.parentAccess || null]]));
}
let resp = await axios.patch(`${homeUrl}/api/workspaces/${wid}/access`,
{delta: {users: {[everyoneEmail]: 'viewers'}}}, chimpy);
assert.equal(resp.status, 200);
// The special user should be visible when we get the access list.
resp = await axios.get(`${homeUrl}/api/workspaces/${wid}/access`, chimpy);
assert.deepEqual(compactAccess(resp.data), {
[chimpyEmail]: ['owners', 'owners'],
[charonEmail]: [null, 'viewers'],
[everyoneEmail]: ['viewers', null],
});
// The special user should be visible on the doc too, since it's inherited.
resp = await axios.get(`${homeUrl}/api/docs/${did}/access`, chimpy);
assert.deepEqual(compactAccess(resp.data), {
[chimpyEmail]: ['owners', 'owners'],
[charonEmail]: [null, 'viewers'],
[everyoneEmail]: [null, 'viewers'],
});
// Remove the special user; it should no longer be visible on either.
resp = await axios.patch(`${homeUrl}/api/workspaces/${wid}/access`,
{delta: {users: {[everyoneEmail]: null}}}, chimpy);
resp = await axios.get(`${homeUrl}/api/workspaces/${wid}/access`, chimpy);
assert.deepEqual(compactAccess(resp.data), {
[chimpyEmail]: ['owners', 'owners'],
[charonEmail]: [null, 'viewers'],
});
resp = await axios.get(`${homeUrl}/api/docs/${did}/access`, chimpy);
assert.deepEqual(compactAccess(resp.data), {
[chimpyEmail]: ['owners', 'owners'],
[charonEmail]: [null, 'viewers'],
});
// Add special user to the doc.
resp = await axios.patch(`${homeUrl}/api/docs/${did}/access`,
{delta: {users: {[everyoneEmail]: 'editors'}}}, chimpy);
assert.equal(resp.status, 200);
resp = await axios.get(`${homeUrl}/api/docs/${did}/access`, chimpy);
assert.deepEqual(compactAccess(resp.data), {
[chimpyEmail]: ['owners', 'owners'],
[charonEmail]: [null, 'viewers'],
[everyoneEmail]: ['editors', null],
});
// But it should not be visible on the workspace.
resp = await axios.get(`${homeUrl}/api/workspaces/${wid}/access`, chimpy);
assert.deepEqual(compactAccess(resp.data), {
[chimpyEmail]: ['owners', 'owners'],
[charonEmail]: [null, 'viewers'],
});
// Remove the special user.
resp = await axios.patch(`${homeUrl}/api/docs/${did}/access`,
{delta: {users: {[everyoneEmail]: null}}}, chimpy);
resp = await axios.get(`${homeUrl}/api/docs/${did}/access`, chimpy);
assert.deepEqual(compactAccess(resp.data), {
[chimpyEmail]: ['owners', 'owners'],
[charonEmail]: [null, 'viewers'],
});
});
it('should allow setting member role', async function() {
const oid = await dbManager.testGetId('Chimpyland');
const wid = await dbManager.testGetId('Private');
const did = await dbManager.testGetId('Timesheets');
const addDelta = {
users: { [kiwiEmail]: "members" }
};
const removeDelta = {
users: { [kiwiEmail]: null }
};
// Set Kiwi as a member of org 'Chimpyland'.
const addKiwiToOrg = await axios.patch(`${homeUrl}/api/orgs/${oid}/access`,
{delta: addDelta}, chimpy);
assert.equal(addKiwiToOrg.status, 200);
// Fetch workspace permissions and check that Kiwi has and inherits no access.
const kiwiWsAccess = await axios.get(`${homeUrl}/api/workspaces/${wid}/access`, chimpy);
assert.equal(kiwiWsAccess.status, 200);
assert.deepEqual(kiwiWsAccess.data, {
maxInheritedRole: 'owners',
users: [{
id: 1,
name: 'Chimpy',
email: chimpyEmail,
ref: chimpyRef,
picture: null,
// Note that Chimpy already has ownership access to the workspace.
access: "owners",
parentAccess: "owners",
isMember: true,
}, {
id: 2,
name: 'Kiwi',
email: kiwiEmail,
ref: kiwiRef,
picture: null,
access: null,
parentAccess: null,
isMember: true,
}, {
id: 3,
name: 'Charon',
email: charonEmail,
ref: charonRef,
picture: null,
access: null,
parentAccess: "viewers",
isMember: true,
}]
});
// Fetch org permissions and check that Kiwi is a member.
const kiwiOrgAccess = await axios.get(`${homeUrl}/api/orgs/${oid}/access`, chimpy);
assert.equal(kiwiOrgAccess.status, 200);
assert.deepEqual(kiwiOrgAccess.data, {
users: [{
id: 1,
name: 'Chimpy',
email: chimpyEmail,
ref: chimpyRef,
picture: null,
access: "owners",
isMember: true,
}, {
id: 2,
name: 'Kiwi',
email: kiwiEmail,
ref: kiwiRef,
picture: null,
access: "members",
isMember: true,
}, {
id: 3,
name: 'Charon',
email: charonEmail,
ref: charonRef,
picture: null,
access: "viewers",
isMember: true,
}]
});
// Unset Kiwi as a member of org 'Chimpyland'.
const removeKiwiFromOrg = await axios.patch(`${homeUrl}/api/orgs/${oid}/access`,
{delta: removeDelta}, chimpy);
assert.equal(removeKiwiFromOrg.status, 200);
// Assert that updating a workspace user to "members" throws with status 400.
const invalidResp1 = await axios.patch(`${homeUrl}/api/workspaces/${wid}/access`,
{delta: addDelta}, chimpy);
assert.equal(invalidResp1.status, 400);
// Assert that updating a doc user to "members" throws with status 400.
const invalidResp2 = await axios.patch(`${homeUrl}/api/docs/${did}/access`,
{delta: addDelta}, chimpy);
assert.equal(invalidResp2.status, 400);
// Assert that updating the maxInheritedRole to "members" throws with status 400.
const invalidDelta = { maxInheritedRole: "members" };
const invalidResp3 = await axios.patch(`${homeUrl}/api/workspaces/${wid}/access`,
{delta: invalidDelta}, chimpy);
assert.equal(invalidResp3.status, 400);
});
describe('team plan', function() {
let nasaOrg: Organization;
let oldProduct: Product;
before(async function() {
// Set NASA to be specifically on a team plan, with team plan restrictions.
const db = dbManager.connection.manager;
nasaOrg = (await db.findOne(Organization, {where: {domain: 'nasa'},
relations: ['billingAccount',
'billingAccount.product']}))!;
oldProduct = nasaOrg.billingAccount.product;
nasaOrg.billingAccount.product = (await db.findOne(Product, {where: {name: 'team'}}))!;
await nasaOrg.billingAccount.save();
});
after(async function() {
nasaOrg.billingAccount.product = oldProduct;
await nasaOrg.billingAccount.save();
});
it('should prevent adding non-org-members to workspaces', async function() {
// Add Kiwi to Horizon
const horizonWs = await dbManager.testGetId('Horizon');
const addDelta = {
users: {[kiwiEmail]: 'viewers'}
};
const errorResp = await axios.patch(`${homeUrl}/api/workspaces/${horizonWs}/access`,
{delta: addDelta}, chimpy);
assert.equal(errorResp.status, 403);
assert.equal(errorResp.data.error, 'No external workspace shares permitted');
});
it('should prevent adding more than n non-org-members to docs', async function() {
// Add Kiwi to Apathy
const apathyDoc = await dbManager.testGetId('Apathy');
let resp = await axios.patch(`${homeUrl}/api/docs/${apathyDoc}/access`,
{delta: {users: {[kiwiEmail]: 'viewers'}}}, chimpy);
assert.equal(resp.status, 200);
// Add Support to Apathy, should not count
resp = await axios.patch(`${homeUrl}/api/docs/${apathyDoc}/access`,
{delta: {users: {[supportEmail]: 'viewers'}}}, chimpy);
assert.equal(resp.status, 200);
// Add Ella to Apathy
resp = await axios.patch(`${homeUrl}/api/docs/${apathyDoc}/access`,
{delta: {users: {'ella@getgrist.com': 'editors'}}}, chimpy);
assert.equal(resp.status, 200);
// Add Charon to Apathy
resp = await axios.patch(`${homeUrl}/api/docs/${apathyDoc}/access`,
{delta: {users: {[charonEmail]: 'viewers'}}}, chimpy);
assert.equal(resp.status, 403);
assert.equal(resp.data.error, 'No more external document shares permitted');
// Remove added users
const removeDelta = {
users: {
[kiwiEmail]: null,
[supportEmail]: null,
}
};
resp = await axios.patch(`${homeUrl}/api/docs/${apathyDoc}/access`,
{delta: removeDelta}, chimpy);
assert.equal(resp.status, 200);
});
});
it('should emit userChange events when expected', async function() {
// Change org permissions ==>
const fishOrgId = await dbManager.testGetId('Fish');
// Remove charon and kiwi from org
const removeCharonKiwi = {
users: { [charonEmail]: null, [kiwiEmail]: null }
};
const fishResp1 = await axios.patch(`${homeUrl}/api/orgs/${fishOrgId}/access`,
{delta: removeCharonKiwi}, chimpy);
assert.equal(fishResp1.status, 200);
assert.deepEqual(userCountUpdates[fishOrgId as number], [1]);
// Re-add charon
const addCharon = {
users: { [charonEmail]: 'viewers' }
};
const fishResp2 = await axios.patch(`${homeUrl}/api/orgs/${fishOrgId}/access`,
{delta: addCharon}, chimpy);
assert.equal(fishResp2.status, 200);
assert.deepEqual(userCountUpdates[fishOrgId as number], [1, 2]);
// Re-add kiwi
const addKiwi = {
users: { [kiwiEmail]: 'editors' }
};
const fishResp3 = await axios.patch(`${homeUrl}/api/orgs/${fishOrgId}/access`,
{delta: addKiwi}, chimpy);
assert.equal(fishResp3.status, 200);
assert.deepEqual(userCountUpdates[fishOrgId as number], [1, 2, 3]);
// Change workspace permissions ==>
const clOrgId = await dbManager.testGetId('Chimpyland');
const publicWsId = await dbManager.testGetId('Public');
// Add charon to ws
const publicResp1 = await axios.patch(`${homeUrl}/api/workspaces/${publicWsId}/access`,
{delta: addCharon}, chimpy);
assert.equal(publicResp1.status, 200);
// Remove charon
const removeCharon = {
users: {[charonEmail]: null}
};
const publicResp2 = await axios.patch(`${homeUrl}/api/workspaces/${publicWsId}/access`,
{delta: removeCharon}, chimpy);
assert.equal(publicResp2.status, 200);
// Assert that workspace user changes have no effect on userCount.
assert.deepEqual(userCountUpdates[clOrgId as number], undefined);
});
it('GET /api/profile/apikey gives user\'s api key', async function() {
const resp = await axios.get(`${homeUrl}/api/profile/apikey`, chimpy);
assert.equal(resp.status, 200);
assert.equal(resp.data, 'api_key_for_chimpy');
});
it('POST /api/profile/apiKey fails for anonymous', async function() {
const resp = await axios.post(`${homeUrl}/api/profile/apikey`, null, nobody);
assert.equal(resp.status, 401);
assert.deepEqual(resp.data, {error: "user not authorized"});
});
it('DELETE /api/profile/apiKey fails for anonymous', async function() {
const resp = await axios.delete(`${homeUrl}/api/profile/apikey`, nobody);
assert.equal(resp.status, 401);
assert.deepEqual(resp.data, {error: "user not authorized"});
});
it('DELETE /api/profile/apikey delete api key', async function() {
let resp: AxiosResponse;
resp = await axios.delete(`${homeUrl}/api/profile/apikey`, chimpy);
assert.equal(resp.status, 200);
// check that chimpy's apikey does not work any more
resp = await axios.get(`${homeUrl}/api/orgs`, chimpy);
assert.equal(resp.status, 401);
assert.deepEqual(resp.data, "Bad request: invalid API key");
// check that the apikey '' does not work either
resp = await axios.get(`${homeUrl}/api/orgs`, {
responseType: 'json',
validateStatus: () => true,
headers: {Authorization: "Bearer "}
});
assert.equal(resp.status, 401);
assert.deepEqual(resp.data, "Bad request: invalid API key");
// check that db encoded null for the apikey
const chimpyUser = (await User.findOne({where: {name: 'Chimpy'}}))!;
assert.deepEqual(chimpyUser.apiKey, null);
// restore api key for chimpy
chimpyUser.apiKey = 'api_key_for_chimpy';
await chimpyUser.save();
});
describe('POST /api/profile/apikey', function() {
let resp: AxiosResponse;
it ('fails if apiKey already set', async function() {
resp = await axios.post(`${homeUrl}/api/profile/apikey`, null, kiwi);
assert.equal(resp.status, 400);
assert.match(resp.data.error, /apikey is already set/);
});
it('succeed if apiKey already set but force flag is used', async function() {
resp = await axios.post(`${homeUrl}/api/profile/apikey`, {force: true}, kiwi);
assert.equal(resp.status, 200);
const apiKey = resp.data;
// check that old apikey does not work any more
resp = await axios.get(`${homeUrl}/api/orgs`, kiwi);
assert.equal(resp.status, 401);
assert.deepEqual(resp.data, "Bad request: invalid API key");
// check that the new api key works
kiwi.headers = {Authorization: 'Bearer ' + apiKey};
resp = await axios.get(`${homeUrl}/api/orgs`, kiwi);
assert.equal(resp.status, 200);
assert.deepEqual(resp.data.map((org: any) => org.name),
['Kiwiland', 'Fish', 'Flightless', 'Primately']);
});
describe('force flag is not needed if apiKey is not set', function() {
before(function() {
// turn off api key access for chimpy
return dbManager.connection.query(`update users set api_key = null where name = 'Chimpy'`);
});
after(function() {
// bring back api key access for chimpy
return dbManager.connection.query(`update users set api_key = 'api_key_for_chimpy' where name = 'Chimpy'`);
});
it('force flag is not needed', async function() {
// make sure api key access is off
resp = await axios.get(`${homeUrl}/api/orgs`, chimpy);
assert.equal(resp.status, 401);
const cookie = await server.getCookieLogin('nasa', {email: 'chimpy@getgrist.com',
name: 'Chimpy'});
// let's create an apikey
resp = await axios.post(`${homeUrl}/o/nasa/api/profile/apikey`, {}, cookie);
// check call was successful
assert.equal(resp.status, 200);
// check that new api key works
chimpy.headers = {Authorization: 'Bearer ' + resp.data};
resp = await axios.get(`${homeUrl}/api/orgs`, chimpy);
assert.equal(resp.status, 200);
assert.deepEqual(resp.data.map((org: any) => org.name),
['Chimpyland', 'EmptyOrg', 'EmptyWsOrg', 'Fish', 'Flightless',
'FreeTeam', 'NASA', 'Primately', 'TestDailyApiLimit']);
});
});
describe('generates a unique key', function() {
let apiKeyGenerator: sinon.SinonStub;
let apiKeyGeneratorReturns: string[];
before(function() {
apiKeyGenerator = sinon.stub(Deps, 'apiKeyGenerator');
apiKeyGenerator.callsFake(() => apiKeyGeneratorReturns.shift()!);
});
after(function() {
apiKeyGenerator.restore();
});
it('retries until the generated key is unique', async function() {
apiKeyGeneratorReturns = ['api_key_for_charon', 'santa1'];
resp = await axios.post(`${homeUrl}/api/profile/apikey`, {force: true}, kiwi);
assert.equal(resp.status, 200);
assert.equal(resp.data, 'santa1');
assert.equal(apiKeyGenerator.callCount, 2);
apiKeyGenerator.resetHistory();
kiwi.headers = {Authorization: 'Bearer ' + resp.data};
apiKeyGeneratorReturns = ['api_key_for_charon', 'api_key_for_charon', 'santa2'];
resp = await axios.post(`${homeUrl}/api/profile/apikey`, {force: true}, kiwi);
assert.equal(resp.status, 200);
assert.equal(resp.data, 'santa2');
assert.equal(apiKeyGenerator.callCount, 3);
apiKeyGenerator.resetHistory();
kiwi.headers = {Authorization: 'Bearer ' + resp.data};
// after 5 attempts throws
apiKeyGeneratorReturns = ['api_key_for_charon', 'api_key_for_charon', 'api_key_for_charon',
'api_key_for_charon', 'api_key_for_charon', 'santa3'];
resp = await axios.post(`${homeUrl}/api/profile/apikey`, {force: true}, kiwi);
assert.equal(resp.status, 500);
assert.deepEqual(resp.data, {error: 'Could not generate a valid api key.'});
});
});
});
});
async function testAllowNonOwnersToRemoveThemselves(url: string) {
// Add a viewer and an editor.
let resp = await axios.patch(url, {
delta: {
users: {
[charonEmail]: 'editors',
[kiwiEmail]: 'viewers',
}
}
}, chimpy);
assert.equal(resp.status, 200);
// One cannot remove the other.
resp = await axios.patch(url, {
delta: {
users: {
[kiwiEmail]: null,
}
}
}, charon);
assert.equal(resp.status, 403);
// But they can remove themselves.
resp = await axios.patch(url, {
delta: {
users: {
[charonEmail]: null,
}
}
}, charon);
assert.equal(resp.status, 200);
resp = await axios.patch(url, {
delta: {
users: {
[kiwiEmail]: null,
}
}
}, kiwi);
assert.equal(resp.status, 200);
}