diff --git a/app/gen-server/lib/NotifierTypes.ts b/app/gen-server/lib/NotifierTypes.ts new file mode 100644 index 00000000..cb717d47 --- /dev/null +++ b/app/gen-server/lib/NotifierTypes.ts @@ -0,0 +1,173 @@ +/** + * + * Grist notifications are currently half-baked. + * There is a sendgrid based implementation for Grist Lab's SaaS, but + * nothing self-hostable yet. + * + */ + +import {FullUser} from 'app/common/LoginSessionAPI'; +import {StringUnion} from 'app/common/StringUnion'; + +/** + * Structure of sendgrid email requests. Each request references a template + * (stored on sendgrid site) and a list of people to send a copy of that template + * to, along with the relevant values to use for template variables. + */ +export interface SendGridMail { + personalizations: SendGridPersonalization[]; + from: SendGridAddress; + reply_to: SendGridAddress; + template_id: string; + asm?: { // unsubscribe settings + group_id: number; + }; + mail_settings?: { + bypass_list_management?: { + enable: boolean; + } + }; +} + +export interface SendGridContact { + contacts: [{ + email: string; + first_name: string; + last_name: string; + custom_fields?: Record; + }], + list_ids?: string[]; +} + +export interface SendGridAddress { + email: string; + name: string; +} + +export interface SendGridPersonalization { + to: SendGridAddress[]; + dynamic_template_data: {[key: string]: any}; +} + +/** + * Structure of sendgrid invite template. This is entirely under our control, it + * is the information we choose to send to an email template for invites. + */ + +export interface SendGridInviteTemplate { + user: FullUser; + host: FullUser; + resource: SendGridInviteResource; + access: SendGridInviteAccess; +} + +export interface SendGridInviteResource { + kind: SendGridInviteResourceKind; + kindUpperFirst: string; + name: string; + url: string; +} + +export type SendGridInviteResourceKind = 'team site' | 'workspace' | 'document'; + +export interface SendGridInviteAccess { + role: string; + canEditAccess?: boolean; + canEdit?: boolean; + canView?: boolean; + canManageBilling?: boolean; +} + +// Common parameters included in emails to active billing managers. +export interface SendGridBillingTemplate { + org: {id: number, name: string}; + orgUrl: string; + billingUrl: string; +} + +export interface SendGridMemberChangeTemplate extends SendGridBillingTemplate { + initiatingUser: FullUser; + added: FullUser[]; + removed: FullUser[]; + org: {id: number, name: string}; + countBefore: number; + countAfter: number; + orgUrl: string; + billingUrl: string; + paidPlan: boolean; +} + +/** + * Format of sendgrid responses when looking up a user by email address using + * SENDGRID.search + */ +export interface SendGridSearchResult { + contact_count: number; + result: SendGridSearchHit[]; +} + +export interface SendGridSearchHit { + id: string; + email: string; + list_ids: string[]; +} + +/** + * Alternative format of sendgrid responses when looking up a user by email + * address using SENDGRID.searchByEmail + * https://docs.sendgrid.com/api-reference/contacts/get-contacts-by-emails + */ +export interface SendGridSearchResultVariant { + result: Record; +} + +/** + * Documentation is contradictory on format of results when contacts not found, but if + * something is found there should be a contact field. + */ +export interface SendGridSearchPossibleHit { + contact?: SendGridSearchHit; +} + +export interface SendGridConfig { + address: { + from: { + email: string; + name: string; + } + }, + template: { + invite?: string; + billingManagerInvite?: string; + memberChange?: string; + trialPeriodEndingSoon?: string; + twoFactorMethodAdded?: string; + twoFactorMethodRemoved?: string; + twoFactorPhoneNumberChanged?: string; + twoFactorEnabled?: string; + twoFactorDisabled?: string; + }, + list: { + singleUserOnboarding?: string; + appSumoSignUps?: string; + trial?: string; + }, + unsubscribeGroup: { + invites?: number; + billingManagers?: number; + }, + field?: { + callScheduled?: string; + userRef?: string; + }, +} + +export const TwoFactorEvents = StringUnion( + 'twoFactorMethodAdded', + 'twoFactorMethodRemoved', + 'twoFactorPhoneNumberChanged', + 'twoFactorEnabled', + 'twoFactorDisabled', +); + +export type TwoFactorEvent = typeof TwoFactorEvents.type; diff --git a/app/server/lib/ICreate.ts b/app/server/lib/ICreate.ts index fc7a8d2c..9c534447 100644 --- a/app/server/lib/ICreate.ts +++ b/app/server/lib/ICreate.ts @@ -97,6 +97,7 @@ export function makeSimpleCreator(opts: { return notifier?.create(dbManager, gristConfig) ?? { get testPending() { return false; }, async deleteUser() { /* do nothing */ }, + testSetSendMessageCallback() { return undefined; }, }; }, ExternalStorage(purpose, extraPrefix) { diff --git a/app/server/lib/INotifier.ts b/app/server/lib/INotifier.ts index c7e6f65b..34740b1a 100644 --- a/app/server/lib/INotifier.ts +++ b/app/server/lib/INotifier.ts @@ -1,6 +1,12 @@ +import {SendGridConfig, SendGridMail} from 'app/gen-server/lib/NotifierTypes'; + export interface INotifier { // for test purposes, check if any notifications are in progress readonly testPending: boolean; deleteUser(userId: number): Promise; + + // Intercept outgoing messages for test purposes. + // Return undefined if no notification system is available. + testSetSendMessageCallback(op: (body: SendGridMail, description: string) => Promise): SendGridConfig|undefined; } diff --git a/package.json b/package.json index 056eb49d..3dc4f86e 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "test:nbrowser": "TEST_SUITE=nbrowser TEST_SUITE_FOR_TIMINGS=nbrowser TIMINGS_FILE=test/timings/nbrowser.txt GRIST_SESSION_COOKIE=grist_test_cookie GRIST_TEST_LOGIN=1 TEST_SUPPORT_API_KEY=api_key_for_support TEST_CLEAN_DATABASE=true LANGUAGE=en_US mocha ${DEBUG:+-b --no-exit} $([ -z $DEBUG ] && echo --forbid-only) -g \"${GREP_TESTS}\" --slow 8000 -R test/xunit-file '_build/test/nbrowser/**/*.js'", "test:client": "GRIST_SESSION_COOKIE=grist_test_cookie mocha ${DEBUG:+'-b'} '_build/test/client/**/*.js'", "test:common": "GRIST_SESSION_COOKIE=grist_test_cookie mocha ${DEBUG:+'-b'} '_build/test/common/**/*.js'", - "test:server": "TEST_SUITE=server TEST_SUITE_FOR_TIMINGS=server TIMINGS_FILE=test/timings/server.txt GRIST_SESSION_COOKIE=grist_test_cookie mocha ${DEBUG:+'-b'} -R test/xunit-file '_build/test/server/**/*.js' '_build/test/gen-server/**/*.js'", + "test:server": "TEST_SUITE=server TEST_SUITE_FOR_TIMINGS=server TIMINGS_FILE=test/timings/server.txt GRIST_SESSION_COOKIE=grist_test_cookie mocha ${DEBUG:+'-b'} -g \"${GREP_TESTS}\" -R test/xunit-file '_build/test/server/**/*.js' '_build/test/gen-server/**/*.js'", "test:smoke": "mocha _build/test/nbrowser/Smoke.js", "test:docker": "./test/test_under_docker.sh", "test:python": "sandbox_venv3/bin/python sandbox/grist/runtests.py ${GREP_TESTS:+discover -p \"test*${GREP_TESTS}*.py\"}", diff --git a/test/gen-server/ApiServer.ts b/test/gen-server/ApiServer.ts new file mode 100644 index 00000000..48b75f6f --- /dev/null +++ b/test/gen-server/ApiServer.ts @@ -0,0 +1,2343 @@ +import axios, {AxiosRequestConfig, AxiosResponse} from 'axios'; +import * as chai from 'chai'; +import omit = require('lodash/omit'); +import {configForUser, configWithPermit, getRowCounts as getRowCountsForDb} from 'test/gen-server/testUtils'; +import * as testUtils from 'test/server/testUtils'; + +import {createEmptyOrgUsageSummary, OrgUsageSummary} from 'app/common/DocUsage'; +import {Document, Workspace} from 'app/common/UserAPI'; +import {Organization} from 'app/gen-server/entity/Organization'; +import {Product} from 'app/gen-server/entity/Product'; +import {HomeDBManager, UserChange} from 'app/gen-server/lib/HomeDBManager'; +import {TestServer} from 'test/gen-server/apiUtils'; + +const assert = chai.assert; + +let server: TestServer; +let dbManager: HomeDBManager; +let homeUrl: string; +let userCountUpdates: {[orgId: number]: number[]} = {}; + +const chimpy = configForUser('Chimpy'); +const kiwi = configForUser('Kiwi'); +const charon = configForUser('Charon'); +const support = configForUser('Support'); +const nobody = configForUser('Anonymous'); + +const chimpyEmail = 'chimpy@getgrist.com'; +const kiwiEmail = 'kiwi@getgrist.com'; +const charonEmail = 'charon@getgrist.com'; + +let chimpyRef = ''; +let kiwiRef = ''; +let charonRef = ''; + +async function getRowCounts() { + return getRowCountsForDb(dbManager); +} + +describe('ApiServer', function() { + let oldEnv: testUtils.EnvironmentSnapshot; + + testUtils.setTmpLogLevel('error'); + + before(async function() { + oldEnv = new testUtils.EnvironmentSnapshot(); + process.env.GRIST_TEMPLATE_ORG = 'templates'; + server = new TestServer(this); + homeUrl = await server.start(['home', 'docs']); + 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() { + oldEnv.restore(); + await server.stop(); + }); + + it('GET /api/orgs reports nothing for anonymous without org in url', async function() { + const resp = await axios.get(`${homeUrl}/api/orgs`, nobody); + assert.equal(resp.status, 200); + assert.deepEqual(resp.data, []); + }); + + it('GET /api/orgs reports nothing for anonymous with unavailable org', async function() { + const resp = await axios.get(`${homeUrl}/o/deep/api/orgs`, nobody); + assert.equal(resp.status, 200); + assert.deepEqual(resp.data, []); + }); + + for (const users of [['anon'], ['anon', 'everyone'], ['everyone']]) { + it(`GET /api/orgs reports something for anonymous with org available to ${users.join(', ')}`, async function() { + const addUsers: {[key: string]: 'viewers'|'owners'} = {}; + const removeUsers: {[key: string]: null} = {}; + for (const user of users) { + const email = `${user}@getgrist.com`; + addUsers[email] = 'viewers'; + removeUsers[email] = null; + } + + // Get id of "Abyss" org (domain name: "deep") + const oid = await dbManager.testGetId('Abyss'); + + try { + // Only support user has right currently to add/remove everyone@ + let resp = await axios.patch(`${homeUrl}/api/orgs/${oid}/access`, { + delta: { users: { 'support@getgrist.com': 'owners' }} + }, charon); + assert.equal(resp.status, 200); + + // Make anon@/everyone@ a viewer of Abyss org + resp = await axios.patch(`${homeUrl}/api/orgs/${oid}/access`, { + delta: {users: addUsers} + }, support); + assert.equal(resp.status, 200); + + // Confirm that anon now sees this org when using a url mentioning the org + resp = await axios.get(`${homeUrl}/o/deep/api/orgs`, nobody); + assert.equal(resp.status, 200); + assert.deepEqual(["Abyss"], resp.data.map((o: any) => o.name)); + // Org is marked as public + assert.equal(resp.data[0].public, true); + + // Confirm that anon doesn't see this org from other urls + resp = await axios.get(`${homeUrl}/o/nasa/api/orgs`, nobody); + assert.equal(resp.status, 200); + assert.deepEqual([], resp.data.map((o: any) => o.name)); + + // Confirm that anon doesn't see this org from /session/access/all + resp = await axios.get(`${homeUrl}/api/session/access/all`, + await server.getCookieLogin('nasa', null)); + assert.equal(resp.status, 200); + assert.deepEqual([], resp.data.orgs.map((o: any) => o.name)); + + // Confirm that regular users don't see this org listed from other domains, + // either via api/orgs or api/session/access/all. + resp = await axios.get(`${homeUrl}/o/nasa/api/orgs`, chimpy); + assert.equal(resp.status, 200); + let orgs = resp.data.map((o: any) => o.name); + assert.notInclude(orgs, 'Abyss'); + resp = await axios.get(`${homeUrl}/o/nasa/api/session/access/all`, + await server.getCookieLogin('nasa', {email: 'chimpy@getgrist.com', + name: 'Chimpy'})); + assert.equal(resp.status, 200); + orgs = resp.data.orgs.map((o: any) => o.name); + assert.notInclude(orgs, 'Abyss'); + + // Confirm that regular users see this org only via api/orgs, + // and only when on the right domain, and only when shared with "everyone@". + resp = await axios.get(`${homeUrl}/o/deep/api/orgs`, chimpy); + assert.equal(resp.status, 200); + orgs = resp.data.map((o: any) => o.name); + if (users.includes('everyone')) { + assert.include(orgs, 'Abyss'); + } else { + assert.notInclude(orgs, 'Abyss'); + } + resp = await axios.get(`${homeUrl}/o/deep/api/session/access/all`, + await server.getCookieLogin('deep', {email: 'chimpy@getgrist.com', + name: 'Chimpy'})); + assert.equal(resp.status, 200); + orgs = resp.data.orgs.map((o: any) => o.name); + assert.notInclude(orgs, 'Abyss'); + } finally { + // Cleanup: remove anon from org + let resp = await axios.patch(`${homeUrl}/api/orgs/${oid}/access`, { + delta: {users: removeUsers} + }, support); + assert.equal(resp.status, 200); + resp = await axios.patch(`${homeUrl}/api/orgs/${oid}/access`, { + delta: { users: { 'support@getgrist.com': null }} + }, charon); + assert.equal(resp.status, 200); + + // Confirm that access has gone away + resp = await axios.get(`${homeUrl}/o/deep/api/orgs`, nobody); + assert.equal(resp.status, 200); + assert.deepEqual([], resp.data.map((o: any) => o.name)); + } + }); + } + + it('GET /api/orgs is operational', async function() { + const 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']); + // personal orgs should have an owner and no domain + // createdAt and updatedAt are omitted since exact times cannot be predicted. + assert.deepEqual(omit(resp.data[0], 'createdAt', 'updatedAt'), { + id: await dbManager.testGetId('Chimpyland'), + name: 'Chimpyland', + access: 'owners', + // public is not set. + domain: 'docs-1', + host: null, + owner: { + id: await dbManager.testGetId('Chimpy'), + ref: await dbManager.testGetRef('Chimpy'), + name: 'Chimpy', + picture: null + } + }); + assert.isNotNull(resp.data[0].updatedAt); + // regular orgs should have a domain and no owner + assert.equal(resp.data[1].domain, 'blankiest'); + assert.equal(resp.data[1].owner, null); + }); + + it('GET /api/orgs respects permissions', async function() { + const resp = await axios.get(`${homeUrl}/api/orgs`, kiwi); + assert.equal(resp.status, 200); + assert.equal(resp.data[0].name, 'Kiwiland'); + assert.equal(resp.data[0].owner.name, 'Kiwi'); + assert.deepEqual(resp.data.map((org: any) => org.name), + ['Kiwiland', 'Fish', 'Flightless', 'Primately']); + }); + + it('GET /api/orgs/{oid} is operational', async function() { + const oid = await dbManager.testGetId('NASA'); + const resp = await axios.get(`${homeUrl}/api/orgs/${oid}`, chimpy); + assert.equal(resp.status, 200); + assert.equal(resp.data.name, 'NASA'); + }); + + it('GET /api/orgs/{oid} accepts domains', async function() { + const resp = await axios.get(`${homeUrl}/api/orgs/nasa`, chimpy); + assert.equal(resp.status, 200); + assert.equal(resp.data.name, 'NASA'); + }); + + it('GET /api/orgs/{oid} accepts current keyword', async function() { + const resp = await axios.get(`${homeUrl}/o/nasa/api/orgs/current`, chimpy); + assert.equal(resp.status, 200); + assert.equal(resp.data.name, 'NASA'); + }); + + it('GET /api/orgs/{oid} fails with current keyword if no domain active', async function() { + const resp = await axios.get(`${homeUrl}/api/orgs/current`, chimpy); + assert.equal(resp.status, 400); + }); + + it('GET /api/orgs/{oid} returns owner when available', async function() { + const oid = await dbManager.testGetId('Chimpyland'); + const resp = await axios.get(`${homeUrl}/api/orgs/${oid}`, chimpy); + assert.equal(resp.status, 200); + // billingAccount is omitted since it isn't focus of this test. + assert.deepEqual(omit(resp.data, 'createdAt', 'updatedAt', 'billingAccount'), { + id: oid, + name: 'Chimpyland', + domain: 'docs-1', + host: null, + access: 'owners', + owner: { + id: await dbManager.testGetId('Chimpy'), + ref: await dbManager.testGetRef('Chimpy'), + name: 'Chimpy', + picture: null + } + }); + assert.isNotNull(resp.data.updatedAt); + }); + + it('GET /api/orgs/{oid} respects permissions', async function() { + const oid = await dbManager.testGetId('Kiwiland'); + const resp = await axios.get(`${homeUrl}/api/orgs/${oid}`, chimpy); + assert.equal(resp.status, 403); + assert.deepEqual(resp.data, {error: "access denied"}); + }); + + it('GET /api/orgs/{oid} returns 404 appropriately', async function() { + const resp = await axios.get(`${homeUrl}/api/orgs/9999`, chimpy); + assert.equal(resp.status, 404); + assert.deepEqual(resp.data, {error: "organization not found"}); + }); + + it('GET /api/orgs/{oid}/workspaces is operational', async function() { + const oid = await dbManager.testGetId('NASA'); + const resp = await axios.get(`${homeUrl}/api/orgs/${oid}/workspaces`, chimpy); + assert.equal(resp.status, 200); + assert.lengthOf(resp.data, 2); + assert.deepEqual(resp.data.map((ws: any) => ws.name), ['Horizon', 'Rovers']); + assert.equal(resp.data[0].id, await dbManager.testGetId('Horizon')); + assert.equal(resp.data[1].id, await dbManager.testGetId('Rovers')); + assert.deepEqual(resp.data[0].docs.map((doc: any) => doc.name), + ['Jupiter', 'Pluto', 'Beyond']); + // Check that Primately access is as expected. + const oid2 = await dbManager.testGetId('Primately'); + const resp2 = await axios.get(`${homeUrl}/api/orgs/${oid2}/workspaces`, kiwi); + assert.equal(resp2.status, 200); + assert.lengthOf(resp2.data, 2); + assert.deepEqual(resp2.data.map((ws: any) => ws.name), ['Fruit', 'Trees']); + assert.deepEqual(resp2.data[0].docs.map((doc: any) => omit(doc, 'createdAt', 'updatedAt')), [{ + access: "viewers", + // public is not set + id: "sampledocid_6", + name: "Bananas", + isPinned: false, + urlId: null, + trunkId: null, + type: null, + forks: [], + }, { + access: "viewers", + // public is not set + id: "sampledocid_7", + name: "Apples", + isPinned: false, + urlId: null, + trunkId: null, + type: null, + forks: [], + }]); + assert.deepEqual(resp2.data[1].docs.map((doc: any) => omit(doc, 'createdAt', 'updatedAt')), [{ + access: "viewers", + id: "sampledocid_8", + name: "Tall", + isPinned: false, + urlId: null, + trunkId: null, + type: null, + forks: [], + }, { + access: "viewers", + id: "sampledocid_9", + name: "Short", + isPinned: false, + urlId: null, + trunkId: null, + type: null, + forks: [], + }]); + // Assert that updatedAt values are present. + resp2.data[0].docs.map((doc: any) => assert.isNotNull(doc.updatedAt)); + resp2.data[1].docs.map((doc: any) => assert.isNotNull(doc.updatedAt)); + }); + + it('GET /api/orgs/{oid}/workspaces accepts domains', async function() { + const resp = await axios.get(`${homeUrl}/api/orgs/nasa/workspaces`, chimpy); + assert.equal(resp.status, 200); + assert.lengthOf(resp.data, 2); + assert.deepEqual(resp.data.map((ws: any) => ws.name), ['Horizon', 'Rovers']); + }); + + it('GET /api/orgs/{oid}/workspaces accepts current keyword', async function() { + const resp = await axios.get(`${homeUrl}/o/nasa/api/orgs/current/workspaces`, chimpy); + assert.equal(resp.status, 200); + assert.lengthOf(resp.data, 2); + assert.deepEqual(resp.data.map((ws: any) => ws.name), ['Horizon', 'Rovers']); + }); + + it('GET /api/orgs/{oid}/workspaces returns 403 appropriately', async function() { + const oid = await dbManager.testGetId('Kiwiland'); + const resp = await axios.get(`${homeUrl}/api/orgs/${oid}/workspaces`, chimpy); + assert.equal(resp.status, 403); + }); + + it('GET /api/orgs/{oid}/workspaces lists individually shared workspaces', async function() { + const oid = await dbManager.testGetId('Primately'); + const resp = await axios.get(`${homeUrl}/api/orgs/${oid}/workspaces`, chimpy); + assert.equal(resp.status, 200); + assert.lengthOf(resp.data, 1); // 1 of 2 workspaces should be present + assert.equal(resp.data[0].name, 'Fruit'); + }); + + it('GET /api/orgs/{oid}/workspaces lists individually shared docs', async function() { + const oid = await dbManager.testGetId('Flightless'); + const resp = await axios.get(`${homeUrl}/api/orgs/${oid}/workspaces`, chimpy); + assert.equal(resp.status, 200); + assert.lengthOf(resp.data, 1); + assert.equal(resp.data[0].name, 'Media'); + assert.lengthOf(resp.data[0].docs, 1); // 1 of 2 docs should be available + assert.equal(resp.data[0].docs[0].name, 'Antartic'); + }); + + it('GET /api/orgs/{wid}/workspaces gives results when workspace is empty', async function() { + const oid = await dbManager.testGetId('EmptyWsOrg'); + const resp = await axios.get(`${homeUrl}/api/orgs/${oid}/workspaces`, chimpy); + assert.equal(resp.status, 200); + assert.equal(resp.data[0].name, 'Vacuum'); + assert.lengthOf(resp.data[0].docs, 0); // No docs present + }); + + it('GET /api/workspaces/{wid} is operational', async function() { + const wid = await dbManager.testGetId('Horizon'); + const resp = await axios.get(`${homeUrl}/api/workspaces/${wid}`, chimpy); + assert.deepEqual(resp.data.docs.map((doc: any) => doc.name), + ['Jupiter', 'Pluto', 'Beyond']); + assert.equal(resp.data.org.name, 'NASA'); + assert.equal(resp.data.org.owner, null); + }); + + it('GET /api/workspaces/{wid} lists individually shared docs', async function() { + const wid = await dbManager.testGetId('Media'); + const resp = await axios.get(`${homeUrl}/api/workspaces/${wid}`, chimpy); + assert.equal(resp.status, 200); + assert.lengthOf(resp.data.docs, 1); // 1 of 2 docs should be available + assert.equal(resp.data.docs[0].name, 'Antartic'); + }); + + it('GET /api/workspaces/{wid} gives results when empty', async function() { + const wid = await dbManager.testGetId('Vacuum'); + const resp = await axios.get(`${homeUrl}/api/workspaces/${wid}`, chimpy); + assert.equal(resp.status, 200); + assert.equal(resp.data.name, 'Vacuum'); + assert.lengthOf(resp.data.docs, 0); // No docs present + }); + + it('GET /api/workspaces/{wid} respects permissions', async function() { + const wid = await dbManager.testGetId('Deep'); + const resp = await axios.get(`${homeUrl}/api/workspaces/${wid}`, chimpy); + assert.equal(resp.status, 403); + }); + + it('GET /api/workspaces/{wid} returns 404 appropriately', async function() { + const resp = await axios.get(`${homeUrl}/api/workspaces/9999`, chimpy); + assert.equal(resp.status, 404); + }); + + it('GET /api/workspaces/{wid} gives owner of org', async function() { + const wid = await dbManager.testGetId('Private'); + const resp = await axios.get(`${homeUrl}/api/workspaces/${wid}`, charon); + assert.equal(resp.data.org.owner.name, 'Chimpy'); + }); + + it('POST /api/orgs/{oid}/workspaces is operational', async function() { + // Add a 'Planets' workspace to the 'NASA' org. + const oid = await dbManager.testGetId('NASA'); + const wid = await getNextId(dbManager, 'workspaces'); + const resp = await axios.post(`${homeUrl}/api/orgs/${oid}/workspaces`, { + name: 'Planets' + }, chimpy); + // Assert that the response is successful and contains the next available workspace id. + assert.equal(resp.status, 200); + assert.equal(resp.data, wid); + // Assert that the added workspace can be fetched and returns as expected. + const fetchResp = await axios.get(`${homeUrl}/api/workspaces/${resp.data}`, chimpy); + const workspace = omit(fetchResp.data, 'createdAt', 'updatedAt'); + workspace.org = omit(workspace.org, 'createdAt', 'updatedAt'); + assert.deepEqual(workspace, { + id: wid, + name: 'Planets', + access: 'owners', + docs: [], + isSupportWorkspace: false, + org: { + id: 1, + name: 'NASA', + domain: 'nasa', + host: null, + owner: null + } + }); + }); + + it('POST /api/orgs/{oid}/workspaces returns 404 appropriately', async function() { + // Attempt to add to an org that doesn't exist. + const resp = await axios.post(`${homeUrl}/api/orgs/9999/workspaces`, { + name: 'Planets' + }, chimpy); + assert.equal(resp.status, 404); + }); + + it('POST /api/orgs/{oid}/workspaces returns 403 appropriately', async function() { + // Attempt to add to an org that chimpy doesn't have write permission on. + const oid = await dbManager.testGetId('Primately'); + const resp = await axios.post(`${homeUrl}/api/orgs/${oid}/workspaces`, { + name: 'Apes' + }, chimpy); + assert.equal(resp.status, 403); + }); + + it('POST /api/orgs/{oid}/workspaces returns 400 appropriately', async function() { + // Use an unknown property and check that the operation fails with status 400. + const oid = await dbManager.testGetId('NASA'); + const resp = await axios.post(`${homeUrl}/api/orgs/${oid}/workspaces`, {x: 1}, chimpy); + assert.equal(resp.status, 400); + }); + + it('PATCH /api/workspaces/{wid} is operational', async function() { + // Rename the 'Horizons' workspace to 'Horizons2'. + const wid = await dbManager.testGetId('Horizon'); + const resp = await axios.patch(`${homeUrl}/api/workspaces/${wid}`, { + name: 'Horizon2' + }, chimpy); + // Assert that the response is successful. + assert.equal(resp.status, 200); + // Assert that the workspace was renamed as expected. + const fetchResp = await axios.get(`${homeUrl}/api/workspaces/${wid}`, chimpy); + const workspace = fetchResp.data; + assert.equal(workspace.name, 'Horizon2'); + + // Change the name back. + const wid2 = await dbManager.testGetId('Horizon2'); + const resp2 = await axios.patch(`${homeUrl}/api/workspaces/${wid2}`, { + name: 'Horizon' + }, chimpy); + assert.equal(resp2.status, 200); + }); + + it('PATCH /api/workspaces/{wid} returns 404 appropriately', async function() { + // Attempt to rename a workspace that doesn't exist. + const resp = await axios.patch(`${homeUrl}/api/workspaces/9999`, { + name: 'Rename' + }, chimpy); + assert.equal(resp.status, 404); + }); + + it('PATCH /api/workspaces/{wid} returns 403 appropriately', async function() { + // Attempt to rename a workspace without UPDATE access. + const wid = await dbManager.testGetId('Fruit'); + const resp = await axios.patch(`${homeUrl}/api/workspaces/${wid}`, { + name: 'Fruit2' + }, chimpy); + assert.equal(resp.status, 403); + }); + + it('PATCH /api/workspaces/{wid} returns 400 appropriately', async function() { + // Use an unavailable property and check that the operation fails with 400. + const wid = await dbManager.testGetId('Rovers'); + const resp = await axios.patch(`${homeUrl}/api/workspaces/${wid}`, {x: 1}, chimpy); + assert.equal(resp.status, 400); + }); + + it('DELETE /api/workspaces/{wid} is operational', async function() { + // Add Kiwi to 'Public' workspace. + const oid = await dbManager.testGetId('Chimpyland'); + let wid = await dbManager.testGetId('Public'); + + // Assert that the number of users in the org has not been updated. + assert.deepEqual(userCountUpdates[oid as number], undefined); + + const delta = { + users: {[kiwiEmail]: 'viewers'} + }; + const accessResp = await axios.patch(`${homeUrl}/api/workspaces/${wid}/access`, {delta}, chimpy); + assert.equal(accessResp.status, 200); + + // Assert that Kiwi is a guest of the org. + const orgResp = await axios.get(`${homeUrl}/api/orgs/${oid}/access`, chimpy); + assert.equal(orgResp.status, 200); + assert.deepEqual(orgResp.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: "guests", + isMember: false, + }, { + id: 3, + name: 'Charon', + email: charonEmail, + ref: charonRef, + picture: null, + access: "viewers", + isMember: true, + }] + }); + + // Assert that the number of non-guest users in the org is unchanged. + assert.deepEqual(userCountUpdates[oid as number], undefined); + + const beforeDelCount = await getRowCounts(); + + // Delete 'Public' workspace. + const resp = await axios.delete(`${homeUrl}/api/workspaces/${wid}`, chimpy); + // Assert that the response is successful. + assert.equal(resp.status, 200); + // Assert that the workspace is no longer in the database. + const fetchResp = await axios.get(`${homeUrl}/api/workspaces/${wid}`, chimpy); + assert.equal(fetchResp.status, 404); + + // Assert that Kiwi is no longer a guest of the org. + const orgResp2 = await axios.get(`${homeUrl}/api/orgs/${oid}/access`, chimpy); + assert.equal(orgResp2.status, 200); + assert.deepEqual(orgResp2.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, + }] + }); + + // Assert that the number of non-guest users in the org remains unchanged. + assert.deepEqual(userCountUpdates[oid as number], undefined); + + const afterDelCount1 = await getRowCounts(); + // Assert that one workspace was removed. + assert.equal(afterDelCount1.workspaces, beforeDelCount.workspaces - 1); + // Assert that Kiwi's workspace viewer and org guest items were removed. + assert.equal(afterDelCount1.groupUsers, beforeDelCount.groupUsers - 2); + + // Re-add 'Public' + const addWsResp = await axios.post(`${homeUrl}/api/orgs/${oid}/workspaces`, { + name: 'Public' + }, chimpy); + // Assert that the response is successful + assert.equal(addWsResp.status, 200); + wid = addWsResp.data; + + // Add a doc to 'Public' + const addDocResp1 = await axios.post(`${homeUrl}/api/workspaces/${wid}/docs`, { + name: 'PublicDoc1' + }, chimpy); + + // Add another workspace, 'Public2' + const addWsResp2 = await axios.post(`${homeUrl}/api/orgs/${oid}/workspaces`, { + name: 'Public2' + }, chimpy); + assert.equal(addWsResp2.status, 200); + + // Get 'Public2' workspace. + const wid2 = addWsResp2.data; + + // Add a doc to 'Public2' + const addDocResp2 = await axios.post(`${homeUrl}/api/workspaces/${wid2}/docs`, { + name: 'PublicDoc2' + }, chimpy); + assert.equal(addDocResp2.status, 200); + + // Get both doc's ids. + const did1 = addDocResp1.data; + const did2 = addDocResp2.data; + + const beforeAddCount = await getRowCounts(); + + // Add Kiwi to the docs + const docAccessResp1 = await axios.patch(`${homeUrl}/api/docs/${did1}/access`, {delta}, chimpy); + assert.equal(docAccessResp1.status, 200); + const docAccessResp2 = await axios.patch(`${homeUrl}/api/docs/${did2}/access`, {delta}, chimpy); + assert.equal(docAccessResp2.status, 200); + + // Assert that Kiwi is a guest of the org. + const orgResp3 = await axios.get(`${homeUrl}/api/orgs/${oid}/access`, chimpy); + assert.equal(orgResp3.status, 200); + assert.deepEqual(orgResp3.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: "guests", + isMember: false, + }, { + id: 3, + name: 'Charon', + email: charonEmail, + ref: charonRef, + picture: null, + access: "viewers", + isMember: true, + }] + }); + + // Assert that the number of non-guest users in the org remains unchanged. + assert.deepEqual(userCountUpdates[oid as number], undefined); + + const afterAddCount = await getRowCounts(); + // Assert that Kiwi's 2 doc viewer, 2 workspace guest and 1 org guest items were added. + assert.equal(afterAddCount.groupUsers, beforeAddCount.groupUsers + 5); + + // Delete 'Public2' workspace. + const deleteResp2 = await axios.delete(`${homeUrl}/api/workspaces/${wid2}`, chimpy); + assert.equal(deleteResp2.status, 200); + + const afterDelCount2 = await getRowCounts(); + // Assert that one workspace was removed. + assert.equal(afterDelCount2.workspaces, afterAddCount.workspaces - 1); + // Assert that one doc was removed. + assert.equal(afterDelCount2.docs, afterAddCount.docs - 1); + // Assert that one of Kiwi's doc viewer items and one of Kiwi's workspace guest items were removed + // and guest chimpy assignment to org and chimpy owner assignment to doc and ws. + assert.equal(afterDelCount2.groupUsers, afterAddCount.groupUsers - 2 - 3); + + // Assert that Kiwi is STILL a guest of the org, since Kiwi is still in the 'Public' doc. + const orgResp4 = await axios.get(`${homeUrl}/api/orgs/${oid}/access`, chimpy); + assert.equal(orgResp4.status, 200); + assert.deepEqual(orgResp4.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: "guests", + isMember: false, + }, { + id: 3, + name: 'Charon', + email: charonEmail, + ref: charonRef, + picture: null, + access: "viewers", + isMember: true, + }] + }); + + // Assert that the number of non-guest users in the org remains unchanged. + assert.deepEqual(userCountUpdates[oid as number], undefined); + // Delete 'Public' workspace. + const deleteResp3 = await axios.delete(`${homeUrl}/api/workspaces/${wid}`, chimpy); + // Assert that the response is successful. + assert.equal(deleteResp3.status, 200); + + const afterDelCount3 = await getRowCounts(); + // Assert that another workspace was removed. + assert.equal(afterDelCount3.workspaces, afterDelCount2.workspaces - 1); + // Assert that another doc was removed. + assert.equal(afterDelCount3.docs, afterDelCount2.docs - 1); + // Assert that Kiwi's doc viewer item, workspace guest and org guest items were removed, and Chimpy was + // removed from doc as owner, ws as guest and owner, and org as guest. + assert.equal(afterDelCount3.groupUsers, afterDelCount2.groupUsers - 7); + + // Assert that Kiwi is no longer a guest of the org. + const orgResp5 = await axios.get(`${homeUrl}/api/orgs/${oid}/access`, chimpy); + assert.equal(orgResp5.status, 200); + assert.deepEqual(orgResp5.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, + }] + }); + + // Assert that the number of non-guest users in the org remains unchanged. + assert.deepEqual(userCountUpdates[oid as number], undefined); + + // Re-add 'Public' finally + const addWsResp3 = await axios.post(`${homeUrl}/api/orgs/${oid}/workspaces`, { + name: 'Public' + }, chimpy); + // Assert that the response is successful + assert.equal(addWsResp3.status, 200); + }); + + it('DELETE /api/workspaces/{wid} returns 404 appropriately', async function() { + // Attempt to delete a workspace that doesn't exist. + const resp = await axios.delete(`${homeUrl}/api/workspaces/9999`, chimpy); + assert.equal(resp.status, 404); + }); + + it('DELETE /api/workspaces/{wid} returns 403 appropriately', async function() { + // Attempt to delete a workspace without REMOVE access. + const wid = await dbManager.testGetId('Fruit'); + const resp = await axios.delete(`${homeUrl}/api/workspaces/${wid}`, chimpy); + assert.equal(resp.status, 403); + }); + + it('POST /api/workspaces/{wid}/docs is operational', async function() { + // Add a 'Surprise' doc to the 'Rovers' workspace. + const wid = await dbManager.testGetId('Rovers'); + const resp = await axios.post(`${homeUrl}/api/workspaces/${wid}/docs`, { + name: 'Surprise' + }, chimpy); + // Assert that the response is successful and contains the doc id. + assert.equal(resp.status, 200); + assert.equal(resp.data.length, 22); // The length of a short-uuid string + // Assert that the added doc can be fetched and returns as expected. + const fetchResp = await axios.get(`${homeUrl}/api/workspaces/${wid}`, chimpy); + const workspace = fetchResp.data; + assert.deepEqual(workspace.name, 'Rovers'); + assert.deepEqual(workspace.docs.map((d: any) => d.name), + ['Curiosity', 'Apathy', 'Surprise']); + }); + + it('POST /api/workspaces/{wid}/docs handles urlIds', async function() { + // Add a 'Boredom' doc to the 'Rovers' workspace. + const wid = await dbManager.testGetId('Rovers'); + let resp = await axios.post(`${homeUrl}/api/workspaces/${wid}/docs`, { + name: 'Boredom', + urlId: 'Hohum' + }, chimpy); + // Assert that the response is successful + assert.equal(resp.status, 200); + assert.equal(resp.data.length, 22); // The length of a short-uuid string + const docId = resp.data; + // Assert that the added doc can be fetched and returns as expected using urlId. + resp = await axios.get(`${homeUrl}/api/docs/Hohum`, chimpy); + assert.equal(resp.status, 200); + assert.equal(resp.data.id, docId); + // Adding a new doc with the same urlId should fail. + resp = await axios.post(`${homeUrl}/api/workspaces/${wid}/docs`, { + name: 'NonEnthusiasm', + urlId: 'Hohum' + }, chimpy); + assert.equal(resp.status, 400); + // Change Boredom doc to use a different urlId + // Also, use the existing urlId in the endpoint just to check that works + resp = await axios.patch(`${homeUrl}/api/docs/Hohum`, { + urlId: 'sigh' + }, chimpy); + assert.equal(resp.status, 200); + // Hohum still resolves to Boredom for the moment + resp = await axios.get(`${homeUrl}/api/docs/Hohum`, chimpy); + assert.equal(resp.data.id, docId); + // Adding a new doc with the Hohum urlId should now succeed. + resp = await axios.post(`${homeUrl}/api/workspaces/${wid}/docs`, { + name: 'NonEnthusiasm', + urlId: 'Hohum' + }, chimpy); + assert.equal(resp.status, 200); + const docId2 = resp.data; + // Hohum now resolves to the new doc. + resp = await axios.get(`${homeUrl}/api/docs/Hohum`, chimpy); + assert.equal(resp.data.id, docId2); + // Delete the new doc. Use urlId to check that works. + await axios.delete(`${homeUrl}/api/docs/Hohum`, chimpy); + }); + + it('POST /api/workspaces/{wid}/docs returns 404 appropriately', async function() { + // Attempt to add to an workspace that doesn't exist. + const resp = await axios.post(`${homeUrl}/api/workspaces/9999/docs`, { + name: 'Mercury' + }, chimpy); + assert.equal(resp.status, 404); + }); + + it('POST /api/workspaces/{wid}/docs returns 403 without access', async function() { + // Attempt to add to a workspace that chimpy doesn't have any access to. + const wid = await dbManager.testGetId('Trees'); + const resp = await axios.post(`${homeUrl}/api/workspaces/${wid}/docs`, { + name: 'Bushy' + }, chimpy); + assert.equal(resp.status, 403); + }); + + it('POST /api/workspaces/{wid}/docs returns 403 with view access', async function() { + // Attempt to add to a workspace that chimpy has only view access to. + const wid = await dbManager.testGetId('Fruit'); + const resp = await axios.post(`${homeUrl}/api/workspaces/${wid}/docs`, { + name: 'Oranges' + }, chimpy); + assert.equal(resp.status, 403); + }); + + it('POST /api/workspaces/{wid}/docs returns 400 appropriately', async function() { + // Omit the new doc name and check that the operation fails with status 400. + const wid = await dbManager.testGetId('Rovers'); + const resp = await axios.post(`${homeUrl}/api/workspaces/${wid}/docs`, {}, chimpy); + assert.equal(resp.status, 400); + }); + + it('POST /api/orgs is operational', async function() { + const oid = await getNextId(dbManager, 'orgs'); + // Add a 'Magic' org. + const resp = await axios.post(`${homeUrl}/api/orgs`, { + name: 'Magic', + domain: 'magic', + }, chimpy); + // Assert that the response is successful and contains the next available org id. + assert.equal(resp.status, 200); + assert.equal(resp.data, oid); + // Assert that the added org can be fetched and returns as expected. + let fetchResp = await axios.get(`${homeUrl}/api/orgs/${resp.data}`, chimpy); + const org = fetchResp.data; + assert.deepEqual(omit(org, 'createdAt', 'updatedAt'), { + id: oid, + name: 'Magic', + access: 'owners', + domain: `o-${oid}`, // default product suppresses vanity domains + host: null, + owner: null, + billingAccount: { + id: oid, + inGoodStanding: true, + individual: false, + isManager: true, + paid: false, + product: { + id: await dbManager.testGetId('stub'), + features: {}, + name: 'stub', + }, + status: null, + externalId: null, + externalOptions: null, + }, + }); + assert.isNotNull(org.updatedAt); + + // Upgrade this org to a fancier plan, and check that vanity domain starts working + const db = dbManager.connection.manager; + const dbOrg = await db.findOne(Organization, + {where: {name: 'Magic'}, + relations: ['billingAccount', 'billingAccount.product']}); + if (!dbOrg) { throw new Error('cannot find Magic'); } + const product = await db.findOne(Product, {where: {name: 'team'}}); + if (!product) { throw new Error('cannot find product'); } + dbOrg.billingAccount.product = product; + await dbOrg.billingAccount.save(); + // Check that we now get the vanity domain + fetchResp = await axios.get(`${homeUrl}/api/orgs/${oid}`, chimpy); + assert.equal(fetchResp.data.domain, 'magic'); + }); + + it('POST /api/orgs returns 400 appropriately', async function() { + // Omit the new org name and check that the operation fails with status 400. + const resp = await axios.post(`${homeUrl}/api/orgs`, { + domain: 'invalid-req' + }, chimpy); + assert.equal(resp.status, 400); + }); + + it('PATCH /api/orgs/{oid} is operational', async function() { + // Rename the 'Magic' org to 'Holiday' with domain 'holiday'. + const oid = await dbManager.testGetId('Magic'); + const resp = await axios.patch(`${homeUrl}/api/orgs/${oid}`, { + name: 'Holiday', + domain: 'holiday' + }, chimpy); + // Assert that the response is successful. + assert.equal(resp.status, 200); + // Assert that the org was renamed as expected. + const fetchResp = await axios.get(`${homeUrl}/api/orgs/${oid}`, chimpy); + const org = fetchResp.data; + assert.equal(org.name, 'Holiday'); + assert.equal(org.domain, 'holiday'); + // Update the org domain to 'holiday2'. + const resp2 = await axios.patch(`${homeUrl}/api/orgs/${oid}`, { + domain: 'holiday2' + }, chimpy); + // Assert that the response is successful. + assert.equal(resp2.status, 200); + // Assert that the org was updated as expected. + const fetchResp2 = await axios.get(`${homeUrl}/api/orgs/${oid}`, chimpy); + assert.equal(fetchResp2.data.name, 'Holiday'); + assert.equal(fetchResp2.data.domain, 'holiday2'); + }); + + it('PATCH /api/orgs/{oid} returns 404 appropriately', async function() { + // Attempt to rename an org that doesn't exist. + const resp = await axios.patch(`${homeUrl}/api/orgs/9999`, { + name: 'Rename' + }, chimpy); + assert.equal(resp.status, 404); + }); + + it('PATCH /api/orgs/{oid} returns 403 appropriately', async function() { + // Attempt to rename an org without UPDATE access. + const oid = await dbManager.testGetId('Primately'); + const resp = await axios.patch(`${homeUrl}/api/orgs/${oid}`, { + name: 'Primately2' + }, chimpy); + assert.equal(resp.status, 403); + }); + + it('PATCH /api/orgs/{oid} returns 400 appropriately', async function() { + // Use an unavailable property and check that the operation fails with 400. + const oid = await dbManager.testGetId('Holiday'); + const resp = await axios.patch(`${homeUrl}/api/orgs/${oid}`, {x: 1}, chimpy); + assert.equal(resp.status, 400); + assert.match(resp.data.error, /unrecognized property/); + }); + + it('DELETE /api/orgs/{oid} is operational', async function() { + // Delete the 'Holiday' org. + const oid = await dbManager.testGetId('Holiday'); + const resp = await axios.delete(`${homeUrl}/api/orgs/${oid}`, chimpy); + // Assert that the response is successful. + assert.equal(resp.status, 200); + // Assert that the org is no longer in the database. + const fetchResp = await axios.get(`${homeUrl}/api/orgs/${oid}`, chimpy); + assert.equal(fetchResp.status, 404); + }); + + it('DELETE /api/orgs/{oid} returns 404 appropriately', async function() { + // Attempt to delete an org that doesn't exist. + const resp = await axios.delete(`${homeUrl}/api/orgs/9999`, chimpy); + assert.equal(resp.status, 404); + }); + + it('DELETE /api/orgs/{oid} returns 403 appropriately', async function() { + // Attempt to delete an org without REMOVE access. + const oid = await dbManager.testGetId('Primately'); + const resp = await axios.delete(`${homeUrl}/api/orgs/${oid}`, chimpy); + assert.equal(resp.status, 403); + }); + + it('GET /api/docs/{did} is operational', async function() { + const did = await dbManager.testGetId('Jupiter'); + const resp = await axios.get(`${homeUrl}/api/docs/${did}`, chimpy); + assert.equal(resp.status, 200); + const doc: Document = resp.data; + assert.equal(doc.name, 'Jupiter'); + assert.equal(doc.workspace.name, 'Horizon'); + assert.equal(doc.workspace.org.name, 'NASA'); + assert.equal(doc.public, undefined); + }); + + it('GET /api/docs/{did} returns 404 for nonexistent doc', async function() { + const resp = await axios.get(`${homeUrl}/api/docs/typotypotypo`, chimpy); + assert.equal(resp.status, 404); + }); + + it('GET /api/docs/{did} returns 403 without access', async function() { + const did = await dbManager.testGetId('Jupiter'); + const resp = await axios.get(`${homeUrl}/api/docs/${did}`, kiwi); + assert.equal(resp.status, 403); + }); + + // Unauthorized folks can currently check if a document uuid exists and that's ok, + // arguably, because uuids don't leak anything sensitive. + it('GET /api/docs/{did} returns 404 without org access for nonexistent doc', async function() { + const resp = await axios.get(`${homeUrl}/api/docs/typotypotypo`, kiwi); + assert.equal(resp.status, 404); + }); + + it('GET /api/docs/{did} returns 404 for doc accessed from wrong org', async function() { + const did = await dbManager.testGetId('Jupiter'); + const resp = await axios.get(`${homeUrl}/o/pr/api/docs/${did}`, chimpy); + assert.equal(resp.status, 404); + }); + + it('GET /api/docs/{did} works for doc accessed from correct org', async function() { + const did = await dbManager.testGetId('Jupiter'); + const resp = await axios.get(`${homeUrl}/o/nasa/api/docs/${did}`, chimpy); + assert.equal(resp.status, 200); + }); + + it('PATCH /api/docs/{did} is operational', async function() { + // Rename the 'Surprise' doc to 'Surprise2'. + const did = await dbManager.testGetId('Surprise'); + const resp = await axios.patch(`${homeUrl}/api/docs/${did}`, { + name: 'Surprise2' + }, chimpy); + // Assert that the response is successful. + assert.equal(resp.status, 200); + // Assert that the doc was renamed as expected. + const wid = await dbManager.testGetId('Rovers'); + const fetchResp = await axios.get(`${homeUrl}/api/workspaces/${wid}`, chimpy); + const workspace = fetchResp.data; + assert.deepEqual(workspace.name, 'Rovers'); + assert.deepEqual(workspace.docs.map((d: any) => d.name), + ['Curiosity', 'Apathy', 'Surprise2', 'Boredom']); + }); + + it('PATCH /api/docs/{did} works for urlIds', async function() { + // Check that 'curio' is not yet a valid id for anything + let resp = await axios.get(`${homeUrl}/api/docs/curio`, chimpy); + assert.equal(resp.status, 404); + // Make 'curio' a urlId for document named 'Curiosity' + const did = await dbManager.testGetId('Curiosity'); + resp = await axios.patch(`${homeUrl}/api/docs/${did}`, { + urlId: 'curio' + }, chimpy); + // Assert that the response is successful. + assert.equal(resp.status, 200); + // Check we can now access same doc via urlId. + resp = await axios.get(`${homeUrl}/api/docs/curio`, chimpy); + assert.equal(resp.status, 200); + assert.equal(resp.data.id, did); + assert.equal(resp.data.urlId, 'curio'); + // Check that we still have access via docId. + resp = await axios.get(`${homeUrl}/api/docs/${did}`, chimpy); + assert.equal(resp.status, 200); + assert.equal(resp.data.id, did); + assert.equal(resp.data.urlId, 'curio'); + // Add another urlId for the same doc + resp = await axios.patch(`${homeUrl}/api/docs/${did}`, { + urlId: 'hmm' + }, chimpy); + // Check we can now access same doc via this new urlId. + resp = await axios.get(`${homeUrl}/api/docs/hmm`, chimpy); + assert.equal(resp.status, 200); + assert.equal(resp.data.id, did); + assert.equal(resp.data.urlId, 'hmm'); + // Check that urlIds accumulate, and previous urlId still works. + resp = await axios.get(`${homeUrl}/api/docs/curio`, chimpy); + assert.equal(resp.status, 200); + assert.equal(resp.data.id, did); + assert.equal(resp.data.urlId, 'hmm'); + // Check that we still have access via docId. + resp = await axios.get(`${homeUrl}/api/docs/${did}`, chimpy); + assert.equal(resp.status, 200); + assert.equal(resp.data.id, did); + assert.equal(resp.data.urlId, 'hmm'); + }); + + it('PATCH /api/docs/{did} handles urlIds for different orgs independently', async function() { + // set a urlId with the same name on two docs in different orgs + const did = await dbManager.testGetId('Curiosity'); // part of NASA org + const did2 = await dbManager.testGetId('Herring'); // part of Fish org + let resp = await axios.patch(`${homeUrl}/api/docs/${did}`, { + urlId: 'example' + }, chimpy); + assert.equal(resp.status, 200); + resp = await axios.patch(`${homeUrl}/api/docs/${did2}`, { + urlId: 'example' + }, chimpy); + assert.equal(resp.status, 200); + // Check that we get the right doc in the right org. + resp = await axios.get(`${homeUrl}/o/nasa/api/docs/example`, chimpy); + assert.equal(resp.data.id, did); + resp = await axios.get(`${homeUrl}/o/fish/api/docs/example`, chimpy); + assert.equal(resp.data.id, did2); + // For a url that isn't associated with an org, the result is ambiguous for this user. + resp = await axios.get(`${homeUrl}/api/docs/example`, chimpy); + assert.equal(resp.status, 400); + // For user Kiwi, who has no NASA access, the result is not ambiguous. + resp = await axios.get(`${homeUrl}/api/docs/example`, kiwi); + assert.equal(resp.status, 200); + }); + + it('PATCH /api/docs/{did} can reuse urlIds within an org', async function() { + // Make 'puzzler' a urlId for document named 'Curiosity' + const did = await dbManager.testGetId('Curiosity'); + let resp = await axios.patch(`${homeUrl}/api/docs/${did}`, {urlId: 'puzzler'}, chimpy); + // Assert that the response is successful. + assert.equal(resp.status, 200); + // Check we can now access same doc via urlId. + resp = await axios.get(`${homeUrl}/api/docs/puzzler`, chimpy); + assert.equal(resp.status, 200); + assert.equal(resp.data.id, did); + assert.equal(resp.data.urlId, 'puzzler'); + // Try to make 'puzzler' a urlId for document within same org. + const did2 = await dbManager.testGetId('Apathy'); + resp = await axios.patch(`${homeUrl}/api/docs/${did2}`, {urlId: 'puzzler'}, chimpy); + // Not allowed, since there's a live doc in the org using this urlId. + assert.equal(resp.status, 400); + // Remove the urlId from first doc + resp = await axios.patch(`${homeUrl}/api/docs/${did}`, {urlId: null}, chimpy); + assert.equal(resp.status, 200); + // The urlId should still forward (until we reuse it later). + resp = await axios.get(`${homeUrl}/api/docs/puzzler`, chimpy); + assert.equal(resp.status, 200); + assert.equal(resp.data.id, did); + // Try to make 'puzzler' a urlId for second document again, it should work this time. + resp = await axios.patch(`${homeUrl}/api/docs/${did2}`, {urlId: 'puzzler'}, chimpy); + assert.equal(resp.status, 200); + // Check we can now access new doc via urlId. + resp = await axios.get(`${homeUrl}/api/docs/puzzler`, chimpy); + assert.equal(resp.status, 200); + assert.equal(resp.data.id, did2); + assert.equal(resp.data.urlId, 'puzzler'); + // Check that the first doc is accessible via its docId. + resp = await axios.get(`${homeUrl}/api/docs/${did}`, chimpy); + assert.equal(resp.status, 200); + assert.equal(resp.data.id, did); + assert.equal(resp.data.urlId, null); + }); + + it('PATCH /api/docs/{did} forbids funky urlIds', async function() { + const badUrlIds = new Set(['sp/ace', 'sp ace', 'space!', 'spa.ce', '']); + const goodUrlIds = new Set(['sp-ace', 'spAace', 'SPac3', 's']); + const did = await dbManager.testGetId('Curiosity'); + let resp; + for (const urlId of [...badUrlIds, ...goodUrlIds]) { + resp = await axios.patch(`${homeUrl}/api/docs/${did}`, {urlId}, chimpy); + assert.equal(resp.status, goodUrlIds.has(urlId) ? 200 : 400); + } + for (const urlId of [...badUrlIds, ...goodUrlIds]) { + resp = await axios.get(`${homeUrl}/api/docs/${urlId}`, chimpy); + assert.equal(resp.status, goodUrlIds.has(urlId) ? 200 : 404); + } + // It is permissible to reset urlId to null + resp = await axios.patch(`${homeUrl}/api/docs/${did}`, {urlId: null}, chimpy); + assert.equal(resp.status, 200); + resp = await axios.get(`${homeUrl}/api/docs/sp-ace`, chimpy); + assert.equal(resp.status, 200); + assert.equal(resp.data.urlId, null); + }); + + it('PATCH /api/docs/{did} supports options', async function() { + // Set some options on the 'Surprise2' doc. + const did = await dbManager.testGetId('Surprise2'); + let resp = await axios.patch(`${homeUrl}/api/docs/${did}`, { + options: { description: 'boo', openMode: 'fork' } + }, chimpy); + assert.equal(resp.status, 200); + + // Check they show up in a workspace request. + const wid = await dbManager.testGetId('Rovers'); + resp = await axios.get(`${homeUrl}/api/workspaces/${wid}`, chimpy); + const workspace: Workspace = resp.data; + const doc = workspace.docs.find(d => d.name === 'Surprise2'); + assert.deepEqual(doc?.options, {description: 'boo', openMode: 'fork'}); + + // Check setting one option preserves others. + resp = await axios.patch(`${homeUrl}/api/docs/${did}`, { + options: { description: 'boo!' } + }, chimpy); + assert.equal(resp.status, 200); + resp = await axios.get(`${homeUrl}/api/docs/${did}`, chimpy); + assert.deepEqual(resp.data?.options, {description: 'boo!', openMode: 'fork'}); + + // Check setting to null removes an option. + resp = await axios.patch(`${homeUrl}/api/docs/${did}`, { + options: { openMode: null } + }, chimpy); + assert.equal(resp.status, 200); + resp = await axios.get(`${homeUrl}/api/docs/${did}`, chimpy); + assert.deepEqual(resp.data?.options, {description: 'boo!'}); + + // Check setting options object to null wipes it completely. + resp = await axios.patch(`${homeUrl}/api/docs/${did}`, { + options: null + }, chimpy); + assert.equal(resp.status, 200); + resp = await axios.get(`${homeUrl}/api/docs/${did}`, chimpy); + assert.deepEqual(resp.data?.options, undefined); + + // Check setting icon works. + resp = await axios.patch(`${homeUrl}/api/docs/${did}`, { + options: { icon: 'https://grist-static.com/icons/foo.png' } + }, chimpy); + assert.equal(resp.status, 200); + resp = await axios.get(`${homeUrl}/api/docs/${did}`, chimpy); + assert.deepEqual(resp.data?.options, {icon: 'https://grist-static.com/icons/foo.png'}); + + // Check random urls are not supported. + resp = await axios.patch(`${homeUrl}/api/docs/${did}`, { + options: { icon: 'https://not-grist-static.com/icons/evil.exe' } + }, chimpy); + assert.equal(resp.status, 400); + resp = await axios.get(`${homeUrl}/api/docs/${did}`, chimpy); + assert.deepEqual(resp.data?.options, {icon: 'https://grist-static.com/icons/foo.png'}); + + // Check removing icon works. + resp = await axios.patch(`${homeUrl}/api/docs/${did}`, { + options: { icon: null }, + }, chimpy); + assert.equal(resp.status, 200); + resp = await axios.get(`${homeUrl}/api/docs/${did}`, chimpy); + assert.deepEqual(resp.data?.options, undefined); + }); + + it('PATCH /api/docs/{did} returns 404 appropriately', async function() { + // Attempt to rename a doc that doesn't exist. + const resp = await axios.patch(`${homeUrl}/api/docs/9999`, { + name: 'Rename' + }, chimpy); + assert.equal(resp.status, 404); + }); + + it('PATCH /api/docs/{did} returns 403 with view access', async function() { + // Attempt to rename a doc without UPDATE access. + const did = await dbManager.testGetId('Bananas'); + const resp = await axios.patch(`${homeUrl}/api/docs/${did}`, { + name: 'Bananas2' + }, chimpy); + assert.equal(resp.status, 403); + }); + + it('PATCH /api/docs/{did} returns 400 appropriately', async function() { + // Use an unavailable property and check that the operation fails with 400. + const did = await dbManager.testGetId('Surprise2'); + const resp = await axios.patch(`${homeUrl}/api/docs/${did}`, {x: 1}, chimpy); + assert.equal(resp.status, 400); + }); + + it('DELETE /api/docs/{did} is operational', async function() { + const oid = await dbManager.testGetId('NASA'); + const wid = await dbManager.testGetId('Rovers'); + const did = await dbManager.testGetId('Surprise2'); + + // Assert that the number of users in the org has not been updated. + assert.deepEqual(userCountUpdates[oid as number], undefined); + + // Add Kiwi to the 'Surprise2' doc. + const delta = { + users: {[kiwiEmail]: 'viewers'} + }; + const accessResp = await axios.patch(`${homeUrl}/api/docs/${did}/access`, {delta}, chimpy); + assert.equal(accessResp.status, 200); + + // Assert that Kiwi is a guest of the ws. + 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: 'guests', + parentAccess: "owners", + isMember: true, + }, { + id: 2, + name: 'Kiwi', + email: kiwiEmail, + ref: kiwiRef, + picture: null, + access: "guests", + parentAccess: null, + isMember: false, + }, { + // Note that Charon is listed despite lacking access since Charon is a guest of the org. + id: 3, + name: 'Charon', + email: charonEmail, + ref: charonRef, + picture: null, + access: null, + parentAccess: null, + isMember: false, + }] + }); + + // Assert that the number of non-guest users in the org is unchanged. + assert.deepEqual(userCountUpdates[oid as number], undefined); + + // Assert that Kiwi is a guest of the org. + const orgResp = await axios.get(`${homeUrl}/api/orgs/${oid}/access`, chimpy); + assert.equal(orgResp.status, 200); + assert.deepEqual(orgResp.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: "guests", + isMember: false, + }, { + id: 3, + name: 'Charon', + email: charonEmail, + ref: charonRef, + picture: null, + access: "guests", + isMember: false, + }] + }); + + const beforeDelCount = await getRowCounts(); + + // Delete the 'Surprise2' doc. + const deleteResp = await axios.delete(`${homeUrl}/api/docs/${did}`, chimpy); + // Assert that the response is successful. + assert.equal(deleteResp.status, 200); + // Assert that the doc is no longer in the database. + const fetchResp = await axios.get(`${homeUrl}/api/workspaces/${wid}`, chimpy); + const workspace = fetchResp.data; + assert.deepEqual(workspace.name, 'Rovers'); + assert.deepEqual(workspace.docs.map((d: any) => d.name), + ['Curiosity', 'Apathy', 'Boredom']); + + // Assert that Kiwi is no longer a guest of the ws. + 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: 'guests', + parentAccess: "owners", + isMember: true, + }, { + // Note that Charon is listed despite lacking access since Charon is a guest of the org. + id: 3, + email: charonEmail, + ref: charonRef, + name: "Charon", + picture: null, + access: null, + parentAccess: null, + isMember: false, + }] + }); + + // Assert that Kiwi is no longer a guest of the org. + const orgResp2 = await axios.get(`${homeUrl}/api/orgs/${oid}/access`, chimpy); + assert.equal(orgResp2.status, 200); + assert.deepEqual(orgResp2.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, + }] + }); + + // Assert that the number of non-guest users in the org is unchanged. + assert.deepEqual(userCountUpdates[oid as number], undefined); + + const afterDelCount = await getRowCounts(); + // Assert that the doc was removed. + assert.equal(afterDelCount.docs, beforeDelCount.docs - 1); + // Assert that Chimpy doc owner item, Kiwi's doc viewer item, workspace guest and org guest items were removed. + assert.equal(afterDelCount.groupUsers, beforeDelCount.groupUsers - 4); + }); + + it('DELETE /api/docs/{did} returns 404 appropriately', async function() { + // Attempt to delete a doc that doesn't exist. + const resp = await axios.delete(`${homeUrl}/api/docs/9999`, chimpy); + assert.equal(resp.status, 404); + }); + + it('DELETE /api/docs/{did} returns 403 with view access', async function() { + // Attempt to delete a doc without REMOVE access. + const did = await dbManager.testGetId('Bananas'); + const resp = await axios.delete(`${homeUrl}/api/docs/${did}`, chimpy); + assert.equal(resp.status, 403); + }); + + it('GET /api/zig is a 404', async function() { + const resp = await axios.get(`${homeUrl}/api/zig`, chimpy); + assert.equal(resp.status, 404); + assert.deepEqual(resp.data, {error: "not found: /api/zig"}); + }); + + it('PATCH /api/docs/{did}/move is operational within the same org', async function() { + const did = await dbManager.testGetId('Jupiter'); + const wsId1 = await dbManager.testGetId('Horizon'); + const wsId2 = await dbManager.testGetId('Rovers'); + const orgId = await dbManager.testGetId('NASA'); + + // Check that move returns 200 + const resp1 = await axios.patch(`${homeUrl}/api/docs/${did}/move`, + {workspace: wsId2}, chimpy); + assert.equal(resp1.status, 200); + // Check that the doc is removed from the source workspace + const verifyResp1 = await axios.get(`${homeUrl}/api/workspaces/${wsId1}`, chimpy); + assert.deepEqual(verifyResp1.data.docs.map((doc: any) => doc.name), ['Pluto', 'Beyond']); + // Check that the doc is added to the dest workspace + const verifyResp2 = await axios.get(`${homeUrl}/api/workspaces/${wsId2}`, chimpy); + assert.deepEqual(verifyResp2.data.docs.map((doc: any) => doc.name), + ['Jupiter', 'Curiosity', 'Apathy', 'Boredom']); + + // Try a complex case - give a user special access to the doc then move it back + // Make Kiwi a doc editor for Jupiter + const delta1 = { + users: {[kiwiEmail]: 'editors'} + }; + const accessResp1 = await axios.patch(`${homeUrl}/api/docs/${did}/access`, {delta: delta1}, chimpy); + assert.equal(accessResp1.status, 200); + // Check that Kiwi is a guest of the workspace/org + const kiwiResp1 = await axios.get(`${homeUrl}/api/workspaces/${wsId2}`, kiwi); + assert.equal(kiwiResp1.status, 200); + const kiwiResp2 = await axios.get(`${homeUrl}/api/orgs/${orgId}`, kiwi); + assert.equal(kiwiResp2.status, 200); + // Assert that the number of non-guest users in the org is unchanged. + assert.deepEqual(userCountUpdates[orgId as number], undefined); + // Move the doc back to Horizon + const resp2 = await axios.patch(`${homeUrl}/api/docs/${did}/move`, + {workspace: wsId1}, chimpy); + assert.equal(resp2.status, 200); + // Assert that the number of non-guest users in the org is unchanged. + assert.deepEqual(userCountUpdates[orgId as number], undefined); + // Check that the doc is removed from the source workspace + const verifyResp3 = await axios.get(`${homeUrl}/api/workspaces/${wsId2}`, chimpy); + assert.deepEqual(verifyResp3.data.docs.map((doc: any) => doc.name), + ['Curiosity', 'Apathy', 'Boredom']); + // Check that the doc is added to the dest workspace + const verifyResp4 = await axios.get(`${homeUrl}/api/workspaces/${wsId1}`, chimpy); + assert.deepEqual(verifyResp4.data.docs.map((doc: any) => doc.name), + ['Jupiter', 'Pluto', 'Beyond']); + // Check that Kiwi is NO LONGER a guest of the source workspace + const kiwiResp3 = await axios.get(`${homeUrl}/api/workspaces/${wsId2}`, kiwi); + assert.equal(kiwiResp3.status, 403); + // Check that Kiwi is a guest of the destination workspace + const kiwiResp5 = await axios.get(`${homeUrl}/api/workspaces/${wsId1}`, kiwi); + assert.equal(kiwiResp5.status, 200); + // Finish by revoking Kiwi's access + const delta2 = { + users: {[kiwiEmail]: null} + }; + const accessResp2 = await axios.patch(`${homeUrl}/api/docs/${did}/access`, {delta: delta2}, chimpy); + assert.equal(accessResp2.status, 200); + // Assert that the number of non-guest users in the org is unchanged. + assert.deepEqual(userCountUpdates[orgId as number], undefined); + + // Test adding a doc and moving it to a workspace with less access + const fishOrg = await dbManager.testGetId('Fish'); + const bigWs = await dbManager.testGetId('Big'); + const resp = await axios.post(`${homeUrl}/api/workspaces/${bigWs}/docs`, { + name: 'Magic' + }, chimpy); + // Assert that the response is successful and contains the doc id. + assert.equal(resp.status, 200); + const magicDocId = resp.data; + // Remove chimpy's direct owner permission on this document. Chimpy is added directly as an owner. + // We need to do it as a different user. + assert.equal((await axios.patch(`${homeUrl}/api/docs/${magicDocId}/access`, {delta: { + users: {[charonEmail]: 'owners'} + }}, chimpy)).status, 200); + assert.equal((await axios.patch(`${homeUrl}/api/docs/${magicDocId}/access`, {delta: { + users: {[chimpyEmail]: null} + }}, charon)).status, 200); + // Create a workspace and limit Chimpy's access to that workspace. + const addMediumWsResp = await axios.post(`${homeUrl}/api/orgs/${fishOrg}/workspaces`, { + name: 'Medium' + }, chimpy); + assert.equal(addMediumWsResp.status, 200); + const mediumWs = addMediumWsResp.data; + // Limit all access to it expect for Kiwi. + const delta3 = { + maxInheritedRole: null, + users: {[kiwiEmail]: 'owners'} + }; + const accessResp3 = await axios.patch(`${homeUrl}/api/workspaces/${mediumWs}/access`, + {delta: delta3}, chimpy); + assert.equal(accessResp3.status, 200); + // Chimpy's access must be removed by Kiwi, since Chimpy would have been granted access + // by being unable to limit his own access. + const delta4 = { + users: {[chimpyEmail]: 'editors'} + }; + const accessResp4 = await axios.patch(`${homeUrl}/api/workspaces/${mediumWs}/access`, + {delta: delta4}, kiwi); + assert.equal(accessResp4.status, 200); + + // Move the doc to the new 'Medium' workspace. + const moveMagicResp = await axios.patch(`${homeUrl}/api/docs/${magicDocId}/move`, + {workspace: mediumWs}, chimpy); + assert.equal(moveMagicResp.status, 200); + // Check that doc access on magic can no longer be edited by chimpy + const delta = { + users: { + [kiwiEmail]: 'editors' + } + }; + const accessResp = await axios.patch(`${homeUrl}/api/docs/${magicDocId}/access`, + {delta}, chimpy); + assert.equal(accessResp.status, 403); + // Finish by removing the added workspace + const removeResp = await axios.delete(`${homeUrl}/api/workspaces/${mediumWs}`, chimpy); + // Assert that the response is successful. + assert.equal(removeResp.status, 200); + }); + + it('PATCH /api/docs/{did}/move is operational between orgs', async function() { + const did = await dbManager.testGetId('Jupiter'); + const srcWsId = await dbManager.testGetId('Horizon'); + const srcOrgId = await dbManager.testGetId('NASA'); + const dstWsId = await dbManager.testGetId('Private'); + const dstOrgId = await dbManager.testGetId('Chimpyland'); + + // Check that move returns 200 + const resp1 = await axios.patch(`${homeUrl}/api/docs/${did}/move`, + {workspace: dstWsId}, chimpy); + assert.equal(resp1.status, 200); + // Check that the doc is removed from the source workspace + const verifyResp1 = await axios.get(`${homeUrl}/api/workspaces/${srcWsId}`, chimpy); + assert.deepEqual(verifyResp1.data.docs.map((doc: any) => doc.name), ['Pluto', 'Beyond']); + // Check that the doc is added to the dest workspace + const verifyResp2 = await axios.get(`${homeUrl}/api/workspaces/${dstWsId}`, chimpy); + assert.deepEqual(verifyResp2.data.docs.map((doc: any) => doc.name), + ['Jupiter', 'Timesheets', 'Appointments']); + + // Assert that the number of non-guest users in the org is unchanged. + assert.deepEqual(userCountUpdates[srcOrgId as number], undefined); + assert.deepEqual(userCountUpdates[dstOrgId as number], undefined); + + // Try a complex case - give a user special access to the doc then move it back + // Make Kiwi a doc editor for Jupiter + const delta1 = { + users: {[kiwiEmail]: 'editors'} + }; + const accessResp1 = await axios.patch(`${homeUrl}/api/docs/${did}/access`, {delta: delta1}, chimpy); + assert.equal(accessResp1.status, 200); + // Check that Kiwi is a guest of the workspace/org + const kiwiResp1 = await axios.get(`${homeUrl}/api/workspaces/${dstWsId}`, kiwi); + assert.equal(kiwiResp1.status, 200); + const kiwiResp2 = await axios.get(`${homeUrl}/api/orgs/${dstOrgId}`, kiwi); + assert.equal(kiwiResp2.status, 200); + // Assert that the number of non-guest users in 'Chimpyland' is unchanged. + assert.deepEqual(userCountUpdates[srcOrgId as number], undefined); + assert.deepEqual(userCountUpdates[dstOrgId as number], undefined); + // Move the doc back to Horizon + const resp2 = await axios.patch(`${homeUrl}/api/docs/${did}/move`, + {workspace: srcWsId}, chimpy); + assert.equal(resp2.status, 200); + // Check that the doc is removed from the source workspace + const verifyResp3 = await axios.get(`${homeUrl}/api/workspaces/${dstWsId}`, chimpy); + assert.deepEqual(verifyResp3.data.docs.map((doc: any) => doc.name), + ['Timesheets', 'Appointments']); + // Check that the doc is added to the dest workspace + const verifyResp4 = await axios.get(`${homeUrl}/api/workspaces/${srcWsId}`, chimpy); + assert.deepEqual(verifyResp4.data.docs.map((doc: any) => doc.name), + ['Jupiter', 'Pluto', 'Beyond']); + // Check that Kiwi is NO LONGER a guest of the workspace/org + const kiwiResp3 = await axios.get(`${homeUrl}/api/workspaces/${dstWsId}`, kiwi); + assert.equal(kiwiResp3.status, 403); + const kiwiResp4 = await axios.get(`${homeUrl}/api/orgs/${dstOrgId}`, kiwi); + assert.equal(kiwiResp4.status, 403); + // Check that Kiwi is a guest of the new workspace/org + const kiwiResp5 = await axios.get(`${homeUrl}/api/workspaces/${srcWsId}`, kiwi); + assert.equal(kiwiResp5.status, 200); + const kiwiResp6 = await axios.get(`${homeUrl}/api/orgs/${srcOrgId}`, kiwi); + assert.equal(kiwiResp6.status, 200); + // Assert that the number of non-guest users in the orgs have not changed. + assert.deepEqual(userCountUpdates[srcOrgId as number], undefined); + assert.deepEqual(userCountUpdates[dstOrgId as number], undefined); + // Finish by revoking Kiwi's access + const delta2 = { + users: {[kiwiEmail]: null} + }; + const accessResp2 = await axios.patch(`${homeUrl}/api/docs/${did}/access`, {delta: delta2}, chimpy); + assert.equal(accessResp2.status, 200); + // Assert that the number of non-guest users in the orgs have not changed. + assert.deepEqual(userCountUpdates[srcOrgId as number], undefined); + assert.deepEqual(userCountUpdates[dstOrgId as number], undefined); + + // Add a doc and move it to a workspace with less access + const publicWs = await dbManager.testGetId('Public'); + const resp = await axios.post(`${homeUrl}/api/workspaces/${publicWs}/docs`, { + name: 'Magic' + }, chimpy); + // Assert that the response is successful and contains the doc id. + assert.equal(resp.status, 200); + const magicDocId = resp.data; + // Remove chimpy's direct owner permission on this document. Chimpy is added directly as an owner. + // We need to do it as a different user. + assert.equal((await axios.patch(`${homeUrl}/api/docs/${magicDocId}/access`, {delta: { + users: {[charonEmail]: 'owners'} + }}, chimpy)).status, 200); + assert.equal((await axios.patch(`${homeUrl}/api/docs/${magicDocId}/access`, {delta: { + users: {[chimpyEmail]: null} + }}, charon)).status, 200); + // Move the doc to Vacuum + const vacuum = await dbManager.testGetId('Vacuum'); + const moveMagicResp = await axios.patch(`${homeUrl}/api/docs/${magicDocId}/move`, + {workspace: vacuum}, chimpy); + assert.equal(moveMagicResp.status, 200); + // Check that doc access on magic can no longer be edited by chimpy + const delta = { + users: { + [kiwiEmail]: 'editors' + } + }; + const accessResp = await axios.patch(`${homeUrl}/api/docs/${magicDocId}/access`, + {delta}, chimpy); + assert.equal(accessResp.status, 403); + // Finish by removing the added doc + let removeResp = await axios.delete(`${homeUrl}/api/docs/${magicDocId}`, chimpy); + // Assert that the response is a failure - we are only editors. + assert.equal(removeResp.status, 403); + const store = server.getWorkStore().getPermitStore('internal'); + const goodDocPermit = await store.setPermit({docId: magicDocId}); + removeResp = await axios.delete(`${homeUrl}/api/docs/${magicDocId}`, configWithPermit(chimpy, goodDocPermit)); + assert.equal(removeResp.status, 200); + }); + + it('PATCH /api/docs/{did}/move returns 404 appropriately', async function() { + const workspace = await dbManager.testGetId('Private'); + const resp = await axios.patch(`${homeUrl}/api/docs/9999/move`, {workspace}, chimpy); + assert.equal(resp.status, 404); + }); + + it('PATCH /api/docs/{did}/move returns 403 appropriately', async function() { + // Attempt moving a doc that the caller does not own, assert that it fails. + const did = await dbManager.testGetId('Bananas'); + const workspace = await dbManager.testGetId('Private'); + const resp = await axios.patch(`${homeUrl}/api/docs/${did}/move`, {workspace}, chimpy); + assert.equal(resp.status, 403); + // Attempt moving a doc that the caller owns to a workspace to which they do not + // have ADD access. Assert that it fails. + const did2 = await dbManager.testGetId('Timesheets'); + const workspace2 = await dbManager.testGetId('Fruit'); + const resp2 = await axios.patch(`${homeUrl}/api/docs/${did2}/move`, + {workspace: workspace2}, chimpy); + assert.equal(resp2.status, 403); + }); + + it('PATCH /api/docs/{did}/move returns 400 appropriately', async function() { + // Assert that attempting to move a doc to the workspace it starts in + // returns 400 + const did = await dbManager.testGetId('Jupiter'); + const srcWsId = await dbManager.testGetId('Horizon'); + const resp = await axios.patch(`${homeUrl}/api/docs/${did}/move`, + {workspace: srcWsId}, chimpy); + assert.equal(resp.status, 400); + }); + + it('PATCH /api/docs/:did/pin is operational', async function() { + const nasaOrgId = await dbManager.testGetId('NASA'); + const chimpylandOrgId = await dbManager.testGetId('Chimpyland'); + const plutoDocId = await dbManager.testGetId('Pluto'); + const timesheetsDocId = await dbManager.testGetId('Timesheets'); + const appointmentsDocId = await dbManager.testGetId('Appointments'); + // Pin 3 docs in 2 different orgs. + const resp1 = await axios.patch(`${homeUrl}/api/docs/${plutoDocId}/pin`, {}, chimpy); + assert.equal(resp1.status, 200); + const resp2 = await axios.patch(`${homeUrl}/api/docs/${timesheetsDocId}/pin`, {}, chimpy); + assert.equal(resp2.status, 200); + const resp3 = await axios.patch(`${homeUrl}/api/docs/${appointmentsDocId}/pin`, {}, chimpy); + assert.equal(resp3.status, 200); + // Assert that the docs are set as pinned when retrieved. + const fetchResp1 = await axios.get(`${homeUrl}/api/orgs/${nasaOrgId}/workspaces`, charon); + assert.equal(fetchResp1.status, 200); + assert.deepEqual(fetchResp1.data[0].docs.map((doc: any) => omit(doc, 'createdAt', 'updatedAt')), [{ + id: await dbManager.testGetId('Pluto'), + name: 'Pluto', + access: 'viewers', + isPinned: true, + urlId: null, + trunkId: null, + type: null, + forks: [], + }]); + const fetchResp2 = await axios.get(`${homeUrl}/api/orgs/${chimpylandOrgId}/workspaces`, charon); + assert.equal(fetchResp2.status, 200); + const privateWs = fetchResp2.data.find((ws: any) => ws.name === 'Private'); + assert.deepEqual(privateWs.docs.map((doc: any) => omit(doc, 'createdAt', 'updatedAt')), [{ + id: timesheetsDocId, + name: 'Timesheets', + access: 'viewers', + isPinned: true, + urlId: null, + trunkId: null, + type: null, + forks: [], + }, { + id: appointmentsDocId, + name: 'Appointments', + access: 'viewers', + isPinned: true, + urlId: null, + trunkId: null, + type: null, + forks: [], + }]); + // Pin a doc that is already pinned and assert that it returns 200. + const resp4 = await axios.patch(`${homeUrl}/api/docs/${appointmentsDocId}/pin`, {}, chimpy); + assert.equal(resp4.status, 200); + }); + + it('PATCH /api/docs/:did/pin returns 404 appropriately', async function() { + // Attempt to pin a doc that doesn't exist. + const resp1 = await axios.patch(`${homeUrl}/api/docs/9999/pin`, {}, charon); + assert.equal(resp1.status, 404); + }); + + it('PATCH /api/docs/:did/pin returns 403 appropriately', async function() { + const antarticDocId = await dbManager.testGetId('Antartic'); + const sharkDocId = await dbManager.testGetId('Shark'); + + // Attempt to pin a doc with only view access (should fail). + const resp1 = await axios.patch(`${homeUrl}/api/docs/${antarticDocId}/pin`, {}, chimpy); + assert.equal(resp1.status, 403); + + // Attempt to pin a doc with org edit access but no doc access (should succeed). + const delta1 = { maxInheritedRole: 'viewers' }; + const setupResp1 = await axios.patch(`${homeUrl}/api/docs/${sharkDocId}/access`, {delta: delta1}, chimpy); + assert.equal(setupResp1.status, 200); + // Check that access to shark is as expected. + const setupResp2 = await axios.get(`${homeUrl}/api/docs/${sharkDocId}/access`, chimpy); + assert.equal(setupResp2.status, 200); + assert.deepEqual(setupResp2.data, { + maxInheritedRole: 'viewers', + users: [{ + id: 1, + name: 'Chimpy', + email: chimpyEmail, + ref: chimpyRef, + picture: null, + access: "owners", // Chimpy's access is explicit since he is the owner who set access. + parentAccess: "owners", + isMember: true, + }, { + id: 2, + name: 'Kiwi', + email: kiwiEmail, + ref: kiwiRef, + picture: null, + access: null, + parentAccess: "editors", + isMember: true, + }, { + id: 3, + name: 'Charon', + email: charonEmail, + ref: charonRef, + picture: null, + access: null, + parentAccess: "viewers", + isMember: true, + }] + }); + // Perform the pin. + const resp2 = await axios.patch(`${homeUrl}/api/docs/${sharkDocId}/pin`, {}, kiwi); + assert.equal(resp2.status, 200); + // And unpin and restore access to keep the state consistent. + const setupResp3 = await axios.patch(`${homeUrl}/api/docs/${sharkDocId}/unpin`, {}, kiwi); + assert.equal(setupResp3.status, 200); + + // Attempt to pin a doc with viewer org access but edit doc access (should fail). + const delta2 = { + maxInheritedRole: 'owners', + users: { + [charonEmail]: 'editors' + } + }; + const setupResp4 = await axios.patch(`${homeUrl}/api/docs/${sharkDocId}/access`, {delta: delta2}, chimpy); + assert.equal(setupResp4.status, 200); + // Check that access to shark is as expected. + const setupResp5 = await axios.get(`${homeUrl}/api/docs/${sharkDocId}/access`, chimpy); + assert.equal(setupResp5.status, 200); + assert.deepEqual(setupResp5.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: 'editors', + isMember: true, + }, { + id: 3, + name: 'Charon', + email: charonEmail, + ref: charonRef, + picture: null, + access: 'editors', + parentAccess: 'viewers', + isMember: true, + }] + }); + // Attempt the pin. + const resp3 = await axios.patch(`${homeUrl}/api/docs/${sharkDocId}/pin`, {}, charon); + assert.equal(resp3.status, 403); + // Restore access to keep the state consistent. + const delta4 = { + users: { + [charonEmail]: null + } + }; + const setupResp6 = await axios.patch(`${homeUrl}/api/docs/${sharkDocId}/access`, {delta: delta4}, chimpy); + assert.equal(setupResp6.status, 200); + }); + + it('PATCH /api/docs/:did/unpin is operational', async function() { + const chimpylandOrgId = await dbManager.testGetId('Chimpyland'); + const plutoDocId = await dbManager.testGetId('Pluto'); + const timesheetsDocId = await dbManager.testGetId('Timesheets'); + const appointmentsDocId = await dbManager.testGetId('Appointments'); + + // Unpin 3 previously pinned docs. + const resp1 = await axios.patch(`${homeUrl}/api/docs/${plutoDocId}/unpin`, {}, chimpy); + assert.equal(resp1.status, 200); + const resp2 = await axios.patch(`${homeUrl}/api/docs/${timesheetsDocId}/unpin`, {}, chimpy); + assert.equal(resp2.status, 200); + const resp3 = await axios.patch(`${homeUrl}/api/docs/${appointmentsDocId}/unpin`, {}, chimpy); + assert.equal(resp3.status, 200); + + // Fetch pinned docs to ensure the docs are no longer pinned. + const fetchResp1 = await axios.get(`${homeUrl}/api/orgs/${chimpylandOrgId}/workspaces`, charon); + assert.equal(fetchResp1.status, 200); + const privateWs = fetchResp1.data.find((ws: any) => ws.name === 'Private'); + assert.deepEqual(privateWs.docs.map((doc: any) => omit(doc, 'createdAt', 'updatedAt')), [{ + id: timesheetsDocId, + name: 'Timesheets', + access: 'viewers', + isPinned: false, + urlId: null, + trunkId: null, + type: null, + forks: [], + }, { + id: appointmentsDocId, + name: 'Appointments', + access: 'viewers', + isPinned: false, + urlId: null, + trunkId: null, + type: null, + forks: [], + }]); + // Unpin doc that is already not pinned and assert that it returns 200. + const resp4 = await axios.patch(`${homeUrl}/api/docs/${plutoDocId}/unpin`, {}, chimpy); + assert.equal(resp4.status, 200); + }); + + it('PATCH /api/docs/:did/unpin returns 404 appropriately', async function() { + // Attempt to unpin a doc that doesn't exist. + const resp1 = await axios.patch(`${homeUrl}/api/docs/9999/unpin`, {}, charon); + assert.equal(resp1.status, 404); + }); + + it('PATCH /api/docs/:did/unpin returns 403 appropriately', async function() { + const antarticDocId = await dbManager.testGetId('Antartic'); + // Attempt to pin a doc with only view access (should fail). + const resp1 = await axios.patch(`${homeUrl}/api/docs/${antarticDocId}/pin`, {}, chimpy); + assert.equal(resp1.status, 403); + }); + + it('GET /api/profile/user returns user info', async function() { + const resp = await axios.get(`${homeUrl}/api/profile/user`, chimpy); + assert.equal(resp.status, 200); + assert.deepEqual(resp.data, { + id: 1, + email: chimpyEmail, + ref: chimpyRef, + name: "Chimpy", + picture: null, + allowGoogleLogin: true, + }); + }); + + it('GET /api/profile/user can return anonymous user', async function() { + const resp = await axios.get(`${homeUrl}/api/profile/user`, nobody); + assert.equal(resp.status, 200); + assert.equal(resp.data.email, "anon@getgrist.com"); + assert.equal(resp.data.anonymous, true); + }); + + it('POST /api/profile/user/name updates user\' name', async function() { + + let resp: AxiosResponse; + async function getName(config: AxiosRequestConfig = chimpy) { + return (await axios.get(`${homeUrl}/api/profile/user`, config)).data.name; + } + + // name should 'Chimpy' initially + assert.equal(await getName(), 'Chimpy'); + + // let's change it + resp = await axios.post(`${homeUrl}/api/profile/user/name`, {name: 'babaganoush'}, chimpy); + assert.equal(resp.status, 200); + + + // check + assert.equal(await getName(), "babaganoush"); + + // revert to 'Chimpy' + resp = await axios.post(`${homeUrl}/api/profile/user/name`, {name: 'Chimpy'}, chimpy); + assert.equal(resp.status, 200); + assert.equal(await getName(), "Chimpy"); + + // requests treated as bad unless it has name provided + resp = await axios.post(`${homeUrl}/api/profile/user/name`, null, chimpy); + assert.equal(resp.status, 400); + assert.match(resp.data.error, /name expected/i); + + // anonymous user not allowed to set name + resp = await axios.post(`${homeUrl}/api/profile/user/name`, {name: 'Testy'}, nobody); + assert.equal(resp.status, 401); + assert.match(resp.data.error, /not authorized/i); + assert.equal(await getName(nobody), "Anonymous"); + }); + + it('POST /api/profile/allowGoogleLogin updates Google login preference', async function() { + let loginCookie: AxiosRequestConfig = await server.getCookieLogin('nasa', { + email: 'chimpy@getgrist.com', + name: 'Chimpy', + loginMethod: 'Email + Password', + }); + + async function isGoogleLoginAllowed() { + return (await axios.get(`${homeUrl}/api/profile/user`, loginCookie)).data.allowGoogleLogin; + } + + // Should be set to true by default. + assert(await isGoogleLoginAllowed()); + + // Setting it via an email/password session should work. + let resp = await axios.post(`${homeUrl}/api/profile/allowGoogleLogin`, {allowGoogleLogin: false}, loginCookie); + assert.equal(resp.status, 200); + assert.equal(await isGoogleLoginAllowed(), false); + + // Setting it without the body param should fail. + resp = await axios.post(`${homeUrl}/api/profile/allowGoogleLogin`, null, loginCookie); + assert.equal(resp.status, 400); + assert.equal(resp.data.error, 'Missing body param: allowGoogleLogin'); + + // Setting it via API or a Google login session should fail. + loginCookie = chimpy; + resp = await axios.post(`${homeUrl}/api/profile/allowGoogleLogin`, {allowGoogleLogin: false}, loginCookie); + assert.equal(resp.status, 401); + assert.equal(resp.data.error, 'Only users signed in via email can enable/disable Google login'); + assert.equal(await isGoogleLoginAllowed(), false); + + loginCookie = await server.getCookieLogin('nasa', { + email: 'chimpy@getgrist.com', + name: 'Chimpy', + loginMethod: 'Google', + }); + resp = await axios.post(`${homeUrl}/api/profile/allowGoogleLogin`, {allowGoogleLogin: false}, loginCookie); + assert.equal(resp.status, 401); + assert.equal(resp.data.error, 'Only users signed in via email can enable/disable Google login'); + assert.equal(await isGoogleLoginAllowed(), false); + + // Setting it as an anonymous user should fail. + resp = await axios.post(`${homeUrl}/api/profile/allowGoogleLogin`, {allowGoogleLogin: false}, nobody); + assert.equal(resp.status, 401); + assert.match(resp.data.error, /not authorized/i); + }); + + it('DELETE /api/user/:uid can delete a user', async function() { + const countsBefore = await getRowCounts(); + + // create a new user + const profile = {email: 'meep@getgrist.com', name: 'Meep'}; + const user = await dbManager.getUserByLogin('meep@getgrist.com', {profile}); + assert(user); + const userId = user!.id; + // set up an api key + await dbManager.connection.query("update users set api_key = 'api_key_for_meep' where id = $1", [userId]); + + // make sure we have at least one extra user, a login, an org, a group_user entry, + // a billing account, a billing account manager. + const countsWithMeep = await getRowCounts(); + assert.isAbove(countsWithMeep.users, countsBefore.users); + assert.isAbove(countsWithMeep.logins, countsBefore.logins); + assert.isAbove(countsWithMeep.orgs, countsBefore.orgs); + assert.isAbove(countsWithMeep.groupUsers, countsBefore.groupUsers); + assert.isAbove(countsWithMeep.billingAccounts, countsBefore.billingAccounts); + assert.isAbove(countsWithMeep.billingAccountManagers, countsBefore.billingAccountManagers); + + // requests treated as bad unless it has name provided + let resp = await axios.delete(`${homeUrl}/api/users/${userId}`, configForUser("meep")); + assert.equal(resp.status, 400); + assert.match(resp.data.error, /provide their name/); + + // others cannot delete this user + resp = await axios.delete(`${homeUrl}/api/users/${userId}`, + {data: {name: "Meep"}, ...configForUser("chimpy")}); + assert.equal(resp.status, 403); + assert.match(resp.data.error, /not permitted/); + + // user cannot delete themselves if they get their name wrong + resp = await axios.delete(`${homeUrl}/api/users/${userId}`, + {data: {name: "Moop"}, ...configForUser("meep")}); + assert.equal(resp.status, 400); + assert.match(resp.data.error, /user name did not match/); + + // user can delete themselves if they get the name right + resp = await axios.delete(`${homeUrl}/api/users/${userId}`, + {data: {name: "Meep"}, ...configForUser("meep")}); + assert.equal(resp.status, 200); + + // create a user with a blank name + const userBlank = await dbManager.getUserByLogin('blank@getgrist.com', + {profile: {email: 'blank@getgrist.com', + name: ''}}); + assert(userBlank); + await dbManager.connection.query("update users set api_key = 'api_key_for_blank' where id = $1", [userBlank!.id]); + + // check that user can delete themselves + resp = await axios.delete(`${homeUrl}/api/users/${userBlank!.id}`, + {data: {name: ""}, ...configForUser("blank")}); + assert.equal(resp.status, 200); + + const countsAfter = await getRowCounts(); + assert.deepEqual(countsAfter, countsBefore); + }); + + describe('GET /api/orgs/{oid}/usage', function() { + let freeTeamOrgId: number; + let freeTeamWorkspaceId: number; + + async function assertOrgUsage( + orgId: string | number, + user: AxiosRequestConfig, + expected: OrgUsageSummary | 'denied' + ) { + const resp = await axios.get(`${homeUrl}/api/orgs/${orgId}/usage`, user); + if (expected === 'denied') { + assert.equal(resp.status, 403); + assert.deepEqual(resp.data, {error: 'access denied'}); + } else { + assert.equal(resp.status, 200); + assert.deepEqual(resp.data, expected); + } + } + + before(async () => { + // Set up a free team site for testing usage. Avoid using billing endpoints, + // which may not be available in all test environments. + await axios.post(`${homeUrl}/api/orgs`, { + name: 'best-friends-squad', + domain: 'best-friends-squad', + }, chimpy); + freeTeamOrgId = await dbManager.testGetId('best-friends-squad') as number; + const prevAccount = await dbManager.getBillingAccount( + {userId: dbManager.getPreviewerUserId()}, + 'best-friends-squad', false); + await dbManager.connection.query( + 'update billing_accounts set product_id = (select id from products where name = $1) where id = $2', + ['teamFree', prevAccount.id] + ); + + const resp = await axios.post(`${homeUrl}/api/orgs/${freeTeamOrgId}/workspaces`, { + name: 'TestUsage' + }, chimpy); + freeTeamWorkspaceId = resp.data; + }); + + after(async () => { + // Remove the free team site. + await axios.delete(`${homeUrl}/api/orgs/${freeTeamOrgId}`, chimpy); + }); + + it('is operational', async function() { + await assertOrgUsage(freeTeamOrgId, chimpy, createEmptyOrgUsageSummary()); + + const nasaOrgId = await dbManager.testGetId('NASA'); + await assertOrgUsage(nasaOrgId, chimpy, createEmptyOrgUsageSummary()); + }); + + it('requires owners access', async function() { + await assertOrgUsage(freeTeamOrgId, kiwi, 'denied'); + + const kiwilandOrgId = await dbManager.testGetId('Kiwiland'); + await assertOrgUsage(kiwilandOrgId, kiwi, createEmptyOrgUsageSummary()); + }); + + it('fails if user is anon', async function() { + await assertOrgUsage(freeTeamOrgId, nobody, 'denied'); + + const primatelyOrgId = await dbManager.testGetId('Primately'); + await assertOrgUsage(primatelyOrgId, nobody, 'denied'); + }); + + it('reports count of docs approaching/exceeding limits', async function() { + // Add a handful of documents to the TestUsage workspace. + const promises = []; + for (const name of ['GoodStanding', 'ApproachingLimits', 'GracePeriod', 'DeleteOnly']) { + promises.push(axios.post(`${homeUrl}/api/workspaces/${freeTeamWorkspaceId}/docs`, {name}, chimpy)); + } + const docIds: string[] = (await Promise.all(promises)).map(resp => resp.data); + + // Prepare one of each usage type. + const goodStanding = {rowCount: {total: 100}, dataSizeBytes: 1024, attachmentsSizeBytes: 4096}; + const approachingLimits = {rowCount: {total: 4501}, dataSizeBytes: 4501 * 2 * 1024, attachmentsSizeBytes: 4096}; + const gracePeriod = {rowCount: {total: 5001}, dataSizeBytes: 5001 * 2 * 1024, attachmentsSizeBytes: 4096}; + const deleteOnly = gracePeriod; + + // Set usage for each document. (This is normally done by ActiveDoc, but we + // facilitate here to keep the tests simple.) + const docUsage = [goodStanding, approachingLimits, gracePeriod, deleteOnly]; + const idsAndUsage = docIds.map((id, i) => [id, docUsage[i]] as const); + for (const [id, usage] of idsAndUsage) { + await server.dbManager.setDocsMetadata({[id]: {usage}}); + } + await server.dbManager.setDocGracePeriodStart(docIds[docIds.length - 1], new Date(2000, 1, 1)); + + // Check that what's reported by /usage is accurate. + await assertOrgUsage(freeTeamOrgId, chimpy, { + approachingLimit: 1, + gracePeriod: 1, + deleteOnly: 1, + }); + }); + + it('only counts documents from org in path', async function() { + // Check NASA's usage once more, and make sure everything is still 0. This test is mostly + // a sanity check that results are in fact scoped by org. + const nasaOrgId = await dbManager.testGetId('NASA'); + await assertOrgUsage(nasaOrgId, chimpy, createEmptyOrgUsageSummary()); + }); + + it('excludes soft-deleted documents from count', async function() { + // Add another document that's exceeding limits. + const docId: string = (await axios.post(`${homeUrl}/api/workspaces/${freeTeamWorkspaceId}/docs`, { + name: 'SoftDeleted' + }, chimpy)).data; + await server.dbManager.setDocsMetadata({[docId]: {usage: { + rowCount: {total: 9999}, + dataSizeBytes: 999999999, + attachmentsSizeBytes: 999999999, + }}}); + + // Check that /usage includes that document in the count. + await assertOrgUsage(freeTeamOrgId, chimpy, { + approachingLimit: 1, + gracePeriod: 2, + deleteOnly: 1, + }); + + // Now soft-delete the newly added document; make sure /usage no longer counts it. + await axios.post(`${homeUrl}/api/docs/${docId}/remove`, {}, chimpy); + await assertOrgUsage(freeTeamOrgId, chimpy, { + approachingLimit: 1, + gracePeriod: 1, + deleteOnly: 1, + }); + }); + + it('excludes soft-deleted workspaces from count', async function() { + // Remove the workspace containing all docs. + await axios.post(`${homeUrl}/api/workspaces/${freeTeamWorkspaceId}/remove`, {}, chimpy); + + // Check that /usage now reports a count of zero for all status types. + await assertOrgUsage(freeTeamOrgId, chimpy, createEmptyOrgUsageSummary()); + }); + }); + + // Template test moved to the end, since it deletes an org and makes + // predicting ids a little trickier. + it('GET /api/templates is operational', async function() { + let oid; + + try { + // Add a 'Grist Templates' org. + await axios.post(`${homeUrl}/api/orgs`, { + name: 'Grist Templates', + domain: 'templates', + }, support); + oid = await dbManager.testGetId('Grist Templates'); + // Add some workspaces and templates (documents) to Grist Templates. + const crmWsId = (await axios.post(`${homeUrl}/api/orgs/${oid}/workspaces`, {name: 'CRM'}, support)).data; + const invoiceWsId = (await axios.post(`${homeUrl}/api/orgs/${oid}/workspaces`, {name: 'Invoice'}, support)).data; + const crmDocId = (await axios.post(`${homeUrl}/api/workspaces/${crmWsId}/docs`, + {name: 'Lightweight CRM', isPinned: true}, support)).data; + const reportDocId = (await axios.post(`${homeUrl}/api/workspaces/${invoiceWsId}/docs`, + {name: 'Expense Report'}, support)).data; + const timesheetDocId = (await axios.post(`${homeUrl}/api/workspaces/${invoiceWsId}/docs`, + {name: 'Timesheet'}, support)).data; + // Make anon@/everyone@ a viewer of the public docs on Grist Templates. + for (const id of [crmDocId, reportDocId, timesheetDocId]) { + await axios.patch(`${homeUrl}/api/docs/${id}/access`, { + delta: {users: {'anon@getgrist.com': 'viewers', 'everyone@getgrist.com': 'viewers'}} + }, support); + } + + // Make a request to retrieve all templates as an anonymous user. + const resp = await axios.get(`${homeUrl}/api/templates`, nobody); + // Assert that the response contains the right workspaces and template documents. + assert.equal(resp.status, 200); + assert.lengthOf(resp.data, 2); + assert.deepEqual(resp.data.map((ws: any) => ws.name), ['CRM', 'Invoice']); + assert.deepEqual(resp.data[0].docs.map((doc: any) => doc.name), ['Lightweight CRM']); + assert.deepEqual(resp.data[1].docs.map((doc: any) => doc.name), ['Expense Report', 'Timesheet']); + + // Make a request to retrieve only the featured (pinned) templates. + const resp2 = await axios.get(`${homeUrl}/api/templates/?onlyFeatured=1`, nobody); + // Assert that the response includes only pinned documents. + assert.equal(resp2.status, 200); + assert.lengthOf(resp2.data, 2); + assert.deepEqual(resp.data.map((ws: any) => ws.name), ['CRM', 'Invoice']); + assert.deepEqual(resp2.data[0].docs.map((doc: any) => doc.name), ['Lightweight CRM']); + assert.deepEqual(resp2.data[1].docs, []); + + // Add a new document to the CRM workspace, but don't share it with everyone. + await axios.post(`${homeUrl}/api/workspaces/${crmWsId}/docs`, + {name: 'Draft CRM Template', isPinned: true}, support); + // Make another request to retrieve all templates as an anonymous user. + const resp3 = await axios.get(`${homeUrl}/api/templates`, nobody); + // Assert that the response does not include the new document. + assert.lengthOf(resp3.data, 2); + assert.deepEqual(resp3.data[0].docs.map((doc: any) => doc.name), ['Lightweight CRM']); + } finally { + // Remove the 'Grist Templates' org. + if (oid) { + await axios.delete(`${homeUrl}/api/orgs/${oid}`, support); + } + } + }); + + it('GET /api/templates returns 404 appropriately', async function() { + // The 'Grist Templates' org currently doesn't exist. + const resp = await axios.get(`${homeUrl}/api/templates`, nobody); + // Assert that the response status is 404 because the templates org doesn't exist. + assert.equal(resp.status, 404); + }); +}); + + +// Predict the next id that will be used for a table. +// Only reliable if we haven't been deleting records in that table. +// Could make reliable by using sqlite_sequence in sqlite and the equivalent +// in postgres. +async function getNextId(dbManager: HomeDBManager, table: 'orgs'|'workspaces') { + // Check current top org id. + const row = await dbManager.connection.query(`select max(id) as id from ${table}`); + const id = row[0]['id']; + return id + 1; +} diff --git a/test/gen-server/ApiServerAccess.ts b/test/gen-server/ApiServerAccess.ts new file mode 100644 index 00000000..3cac5617 --- /dev/null +++ b/test/gen-server/ApiServerAccess.ts @@ -0,0 +1,1689 @@ +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\/$/); + 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\/$/); + 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\/$/); + } + // 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]+\/$/); + const env = mail.payload.personalizations[0].dynamic_template_data; + assert.match(env.resource.url, /^http.*\/o\/docs\/ws\/[0-9]+\/$/); + 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, + }] + }); + // 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); + + // 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); +} diff --git a/test/gen-server/ApiServerBenchmark.ts b/test/gen-server/ApiServerBenchmark.ts new file mode 100644 index 00000000..22591d6f --- /dev/null +++ b/test/gen-server/ApiServerBenchmark.ts @@ -0,0 +1,69 @@ +import axios from 'axios'; +import {configForUser} from 'test/gen-server/testUtils'; +import * as testUtils from 'test/server/testUtils'; + +import {assert} from 'chai'; + +import {FlexServer} from 'app/server/lib/FlexServer'; + +import {createBenchmarkServer, removeConnection, setUpDB} from 'test/gen-server/seed'; + +let home: FlexServer; +let homeUrl: string; + +const chimpy = configForUser('Chimpy'); + +describe('ApiServerBenchmark', function() { + + testUtils.setTmpLogLevel('error'); + + before(async function() { + if (!process.env.ENABLE_BENCHMARKS) { + this.skip(); + return; + } + this.timeout(600000); + setUpDB(this); + home = await createBenchmarkServer(0); + homeUrl = home.getOwnUrl(); + }); + + after(async function() { + if (home) { + await home.stopListening(); + await removeConnection(); + } + }); + + it('GET /orgs returns in a timely manner', async function() { + this.timeout(600000); + for (let i = 0; i < 10; i++) { + const resp = await axios.get(`${homeUrl}/api/orgs`, chimpy); + assert(resp.data.length === 100); + } + }); + + // Note the organization id which is being fetched. + it('GET /orgs/{oid} returns in a timely manner', async function() { + this.timeout(600000); + for (let i = 0; i < 100; i++) { + await axios.get(`${homeUrl}/api/orgs/1`, chimpy); + } + }); + + // Note the organization id which is being fetched. + it('GET /orgs/{oid}/workspaces returns in a timely manner', async function() { + this.timeout(600000); + for (let i = 0; i < 100; i++) { + await axios.get(`${homeUrl}/api/orgs/1/workspaces`, chimpy); + } + }); + + // Note the workspace ids which are being fetched. + it('GET /workspaces/{wid} returns in a timely manner', async function() { + this.timeout(600000); + for (let wid = 0; wid < 100; wid++) { + await axios.get(`${homeUrl}/api/workspaces/${wid}`, chimpy); + } + }); +}); diff --git a/test/gen-server/ApiServerBugs.ts b/test/gen-server/ApiServerBugs.ts new file mode 100644 index 00000000..020b09c7 --- /dev/null +++ b/test/gen-server/ApiServerBugs.ts @@ -0,0 +1,174 @@ +import axios from 'axios'; +import * as chai from 'chai'; + +import {configForUser} from 'test/gen-server/testUtils'; +import * as testUtils from 'test/server/testUtils'; + +import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager'; + +import {TestServer} from 'test/gen-server/apiUtils'; + +const assert = chai.assert; + +let server: TestServer; +let dbManager: HomeDBManager; +let homeUrl: string; + +const charon = configForUser('Charon'); +const chimpy = configForUser('Chimpy'); +const kiwi = configForUser('Kiwi'); + +const chimpyEmail = 'chimpy@getgrist.com'; +const kiwiEmail = 'kiwi@getgrist.com'; +const charonEmail = 'charon@getgrist.com'; + +// Tests specific complex scenarios that may have previously resulted in wrong behavior. +describe('ApiServerBugs', function() { + + testUtils.setTmpLogLevel('error'); + let userRef: (email: string) => Promise; + + before(async function() { + server = new TestServer(this); + homeUrl = await server.start(); + dbManager = server.dbManager; + userRef = (email) => server.dbManager.getUserByLogin(email).then((user) => user!.ref); + }); + + after(async function() { + await server.stop(); + }); + + // Re-create a bug scenario in which users being in normal groups and guests groups at the + // same time resulted in them being dropped from groups arbitrarily on subsequent patches. + it('should properly handle users in multiple groups at once', async function() { + // Add Chimpy/Charon/Kiwi to 'Herring' doc and set inheritance to none. They + // will become guests in the 'Fish' org along with their owner/viewer roles. + const fishOrg = await dbManager.testGetId('Fish'); + const herringDoc = await dbManager.testGetId('Herring'); + const delta1 = { + maxInheritedRole: null, + users: { + [kiwiEmail]: 'editors', + [charonEmail]: 'viewers' + } + }; + let resp = await axios.patch(`${homeUrl}/api/docs/${herringDoc}/access`, { + delta: delta1 + }, chimpy); + assert.equal(resp.status, 200); + // Ensure that the doc access is as expected. + resp = await axios.get(`${homeUrl}/api/docs/${herringDoc}/access`, chimpy); + assert.equal(resp.status, 200); + assert.deepEqual(resp.data, { + maxInheritedRole: null, + users: [{ + id: 1, + name: 'Chimpy', + email: chimpyEmail, + ref: await userRef(chimpyEmail), + picture: null, + parentAccess: "owners", + access: "owners", + isMember: true, + }, { + id: 2, + name: 'Kiwi', + email: kiwiEmail, + ref: await userRef(kiwiEmail), + picture: null, + parentAccess: "editors", + access: "editors", + isMember: true, + }, { + id: 3, + name: 'Charon', + email: charonEmail, + ref: await userRef(charonEmail), + picture: null, + parentAccess: "viewers", + access: "viewers", + isMember: true, + }] + }); + + // Remove Charon from the 'Fish' org and ensure that Chimpy and Kiwi still have + // owner/editor roles on 'Fish'. Charon should no longer have guest access to the org. + const delta2 = { + users: { + [charonEmail]: null + } + }; + resp = await axios.patch(`${homeUrl}/api/orgs/${fishOrg}/access`, { + delta: delta2 + }, chimpy); + assert.equal(resp.status, 200); + resp = await axios.get(`${homeUrl}/api/orgs/${fishOrg}/access`, chimpy); + assert.equal(resp.status, 200); + assert.deepEqual(resp.data, { + users: [{ + id: 1, + name: 'Chimpy', + email: chimpyEmail, + ref: await userRef(chimpyEmail), + picture: null, + access: "owners", + isMember: true, + }, { + id: 2, + name: 'Kiwi', + email: kiwiEmail, + ref: await userRef(kiwiEmail), + picture: null, + access: "editors", + isMember: true, + }] + }); + + // Charon should no longer have access to the 'Herring' doc, now that user access + // is wiped entirely when removed from org. + resp = await axios.get(`${homeUrl}/api/docs/${herringDoc}`, charon); + assert.equal(resp.status, 403); + resp = await axios.get(`${homeUrl}/api/orgs/${fishOrg}`, charon); + assert.equal(resp.status, 403); + + // Remove Kiwi as an editor from the 'Fish' org and ensure that Kiwi no longer has + // access to 'Fish' or 'Herring' + const delta3 = { + users: { + [kiwiEmail]: null + } + }; + resp = await axios.patch(`${homeUrl}/api/orgs/${fishOrg}/access`, { + delta: delta3 + }, chimpy); + assert.equal(resp.status, 200); + resp = await axios.get(`${homeUrl}/api/docs/${herringDoc}`, kiwi); + assert.equal(resp.status, 403); + resp = await axios.get(`${homeUrl}/api/orgs/${fishOrg}`, kiwi); + assert.equal(resp.status, 403); + + // Restore initial access. + const delta4 = { + maxInheritedRole: "owners", + users: { + [charonEmail]: null, + [kiwiEmail]: null + } + }; + resp = await axios.patch(`${homeUrl}/api/docs/${herringDoc}/access`, { + delta: delta4 + }, chimpy); + assert.equal(resp.status, 200); + const delta5 = { + users: { + [kiwiEmail]: "editors", + [charonEmail]: "viewers" + } + }; + resp = await axios.patch(`${homeUrl}/api/orgs/${fishOrg}/access`, { + delta: delta5 + }, chimpy); + assert.equal(resp.status, 200); + }); +}); diff --git a/test/gen-server/ApiSession.ts b/test/gen-server/ApiSession.ts new file mode 100644 index 00000000..cdba7603 --- /dev/null +++ b/test/gen-server/ApiSession.ts @@ -0,0 +1,182 @@ +import {UserProfile} from 'app/common/LoginSessionAPI'; +import {AccessOptionWithRole} from 'app/gen-server/entity/Organization'; +import axios from 'axios'; +import {AxiosRequestConfig} from 'axios'; +import {assert} from 'chai'; +import omit = require('lodash/omit'); +import {TestServer} from 'test/gen-server/apiUtils'; +import * as testUtils from 'test/server/testUtils'; + +const nobody: AxiosRequestConfig = { + responseType: 'json', + validateStatus: (status: number) => true +}; + +describe('ApiSession', function() { + + let server: TestServer; + let serverUrl: string; + testUtils.setTmpLogLevel('error'); + + const regular = 'chimpy@getgrist.com'; + + beforeEach(async function() { + this.timeout(5000); + server = new TestServer(this); + serverUrl = await server.start(); + }); + + afterEach(async function() { + await server.stop(); + }); + + it('GET /api/session/access/active returns user and org (with access)', async function() { + const cookie = await server.getCookieLogin('nasa', {email: regular, name: 'Chimpy'}); + + const resp = await axios.get(`${serverUrl}/o/nasa/api/session/access/active`, cookie); + assert.equal(resp.status, 200); + assert.sameMembers(['user', 'org'], Object.keys(resp.data)); + assert.deepEqual(omit(resp.data.user, ['helpScoutSignature', 'ref']), { + id: await server.dbManager.testGetId("Chimpy"), + email: "chimpy@getgrist.com", + name: "Chimpy", + picture: null, + }); + assert.deepEqual(omit(resp.data.org, ['billingAccount', 'createdAt', 'updatedAt']), { + id: await server.dbManager.testGetId("NASA"), + name: "NASA", + access: "owners", + domain: "nasa", + host: null, + owner: null + }); + }); + + it('GET /api/session/access/active returns org with billing account information', async function() { + const cookie = await server.getCookieLogin('nasa', {email: regular, name: 'Chimpy'}); + + // Make Chimpy a billing account manager for NASA. + await server.addBillingManager('Chimpy', 'nasa'); + + const resp = await axios.get(`${serverUrl}/o/nasa/api/session/access/active`, cookie); + assert.equal(resp.status, 200); + assert.hasAllKeys(resp.data.org, ['id', 'name', 'access', 'domain', 'owner', 'billingAccount', + 'createdAt', 'updatedAt', 'host']); + assert.deepEqual(resp.data.org.billingAccount, + { id: 1, individual: false, inGoodStanding: true, status: null, + externalId: null, externalOptions: null, + isManager: true, paid: false, + product: { id: 1, name: 'Free', features: {workspaces: true, vanityDomain: true} } }); + + // Check that internally we have access to stripe ids. + const userId = await server.dbManager.testGetId('Chimpy') as number; + const org2 = await server.dbManager.getOrg({userId}, 'nasa'); + assert.hasAllKeys(org2.data!.billingAccount, + ['id', 'individual', 'inGoodStanding', 'status', 'stripeCustomerId', + 'stripeSubscriptionId', 'stripePlanId', 'product', 'paid', 'isManager', + 'externalId', 'externalOptions']); + }); + + it('GET /api/session/access/active returns orgErr when org is forbidden', async function() { + const cookie = await server.getCookieLogin('nasa', {email: 'kiwi@getgrist.com', name: 'Kiwi'}); + + const resp = await axios.get(`${serverUrl}/o/nasa/api/session/access/active`, cookie); + assert.equal(resp.status, 200); + assert.sameMembers(['user', 'org', 'orgError'], Object.keys(resp.data)); + assert.deepEqual(omit(resp.data.user, ['helpScoutSignature', 'ref']), { + id: await server.dbManager.testGetId("Kiwi"), + email: "kiwi@getgrist.com", + name: "Kiwi", + picture: null, + }); + assert.equal(resp.data.org, null); + assert.deepEqual(resp.data.orgError, { + status: 403, + error: 'access denied' + }); + }); + + it('GET /api/session/access/active returns orgErr when org is non-existent', async function() { + const cookie = await server.getCookieLogin('nasa', {email: 'kiwi@getgrist.com', name: 'Kiwi'}); + + const resp = await axios.get(`${serverUrl}/o/boing/api/session/access/active`, cookie); + assert.equal(resp.status, 200); + assert.sameMembers(['user', 'org', 'orgError'], Object.keys(resp.data)); + assert.deepEqual(omit(resp.data.user, ['helpScoutSignature', 'ref']), { + id: await server.dbManager.testGetId("Kiwi"), + email: "kiwi@getgrist.com", + name: "Kiwi", + picture: null, + }); + assert.equal(resp.data.org, null); + assert.deepEqual(resp.data.orgError, { + status: 404, + error: 'organization not found' + }); + }); + + it('POST /api/session/access/active can change user', async function() { + // add two profiles + const cookie = await server.getCookieLogin('nasa', {email: 'charon@getgrist.com', name: 'Charon'}); + await server.getCookieLogin('pr', {email: 'kiwi@getgrist.com', name: 'Kiwi'}); + + // pick kiwi profile for fish org + let resp = await axios.post(`${serverUrl}/o/fish/api/session/access/active`, { + email: 'kiwi@getgrist.com' + }, cookie); + assert.equal(resp.status, 200); + + // check kiwi profile stuck + resp = await axios.get(`${serverUrl}/o/fish/api/session/access/active`, cookie); + assert.equal(resp.data.user.email, 'kiwi@getgrist.com'); + + // ... and that it didn't affect other org + resp = await axios.get(`${serverUrl}/o/nasa/api/session/access/active`, cookie); + assert.equal(resp.data.user.email, 'charon@getgrist.com'); + + // pick charon profile for fish org + resp = await axios.post(`${serverUrl}/o/fish/api/session/access/active`, { + email: 'charon@getgrist.com' + }, cookie); + assert.equal(resp.status, 200); + + // check charon profile stuck + resp = await axios.get(`${serverUrl}/o/fish/api/session/access/active`, cookie); + assert.equal(resp.data.user.email, 'charon@getgrist.com'); + + // make sure bogus profile for fish org fails + resp = await axios.post(`${serverUrl}/o/fish/api/session/access/active`, { + email: 'nonexistent@getgrist.com' + }, cookie); + assert.equal(resp.status, 403); + }); + + it('GET /api/session/access/all returns users and orgs', async function() { + const cookie = await server.getCookieLogin('nasa', {email: 'charon@getgrist.com', name: 'Charon'}); + await server.getCookieLogin('pr', {email: 'kiwi@getgrist.com', name: 'Kiwi'}); + const resp = await axios.get(`${serverUrl}/o/pr/api/session/access/all`, cookie); + assert.equal(resp.status, 200); + assert.sameMembers(['users', 'orgs'], Object.keys(resp.data)); + assert.sameMembers(resp.data.users.map((user: UserProfile) => user.name), + ['Charon', 'Kiwi']); + // In following list, 'Kiwiland' is the the merged personal org, and Chimpyland is not + // listed explicitly. + assert.sameMembers(resp.data.orgs.map((org: any) => org.name), + ['Abyss', 'Fish', 'Flightless', 'Kiwiland', 'NASA', 'Primately']); + const fish = resp.data.orgs.find((org: any) => org.name === 'Fish'); + const accessOptions: AccessOptionWithRole[] = fish.accessOptions; + assert.lengthOf(accessOptions, 2); + assert.equal('editors', accessOptions.find(opt => opt.name === 'Kiwi')!.access); + assert.equal('viewers', accessOptions.find(opt => opt.name === 'Charon')!.access); + }); + + it('GET /api/session/access/all functions with anonymous access', async function() { + const resp = await axios.get(`${serverUrl}/o/pr/api/session/access/all`, nobody); + assert.equal(resp.status, 200); + // No orgs listed without access + assert.lengthOf(resp.data.orgs, 0); + // A single anonymous user + assert.lengthOf(resp.data.users, 1); + assert.equal(resp.data.users[0].anonymous, true); + }); +}); diff --git a/test/gen-server/AuthCaching.ts b/test/gen-server/AuthCaching.ts new file mode 100644 index 00000000..5789d1ce --- /dev/null +++ b/test/gen-server/AuthCaching.ts @@ -0,0 +1,400 @@ +import {delay} from 'app/common/delay'; +import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager'; +import {FlexServer} from 'app/server/lib/FlexServer'; +import log from 'app/server/lib/log'; +import {main as mergedServerMain} from 'app/server/mergedServerMain'; +import axios from 'axios'; +import {assert} from 'chai'; +import * as fse from 'fs-extra'; +import {tmpdir} from 'os'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import {TestSession} from 'test/gen-server/apiUtils'; +import {createInitialDb, removeConnection, setUpDB} from 'test/gen-server/seed'; +import {configForUser, getGristConfig} from 'test/gen-server/testUtils'; +import {openClient} from 'test/server/gristClient'; +import * as testUtils from 'test/server/testUtils'; + +async function createTestDir(ident: string): Promise { + // Create a testDir of the form grist_test_{USER}_{SERVER_NAME}, removing any previous one. + const username = process.env.USER || "nobody"; + const testDir = path.join(tmpdir(), `grist_test_${username}_${ident}`); + await fse.remove(testDir); + return testDir; +} + +const chimpy = configForUser('Chimpy'); +const kiwi = configForUser('Kiwi'); +const charon = configForUser('Charon'); +const chimpyEmail = 'chimpy@getgrist.com'; +const kiwiEmail = 'kiwi@getgrist.com'; +const charonEmail = 'charon@getgrist.com'; + + +describe('AuthCaching', function() { + this.timeout(10000); + testUtils.setTmpLogLevel('error'); + + let homeServer: FlexServer, docsServer: FlexServer; + let session: TestSession; + let homeUrl: string; + let helloDocId: string; + + const sandbox = sinon.createSandbox(); + + before(async function() { + const testDir = process.env.TESTDIR || await createTestDir('authcaching'); + const testDocDir = path.join(testDir, "data"); + await fse.mkdirs(testDocDir); + log.warn(`Test logs and data are at: ${testDir}/`); + setUpDB(); + await createInitialDb(); + process.env.GRIST_DATA_DIR = testDocDir; + homeServer = await mergedServerMain(0, ['home'], + {logToConsole: false, externalStorage: false}); + homeUrl = homeServer.getOwnUrl(); + process.env.APP_HOME_URL = homeUrl; + docsServer = await mergedServerMain(0, ['docs'], + {logToConsole: false, externalStorage: false}); + + // Helpers for getting cookie-based logins. + session = new TestSession(homeServer); + + // Copy a fixture doc to make it accessible with the given docId. + helloDocId = (await homeServer.getHomeDBManager().testGetId('Jupiter')) as string; + const srcPath = path.resolve(testUtils.fixturesRoot, 'docs', 'Hello.grist'); + await fse.copy(srcPath, path.resolve(docsServer.docsRoot, `${helloDocId}.grist`), + { dereference: true }); + + // Add Kiwi to 'viewers' for this doc. + const resp = await axios.patch(`${homeUrl}/api/docs/${helloDocId}/access`, + {delta: {users: {[kiwiEmail]: 'viewers'}}}, + chimpy); + assert.equal(resp.status, 200); + }); + + after(async function() { + delete process.env.GRIST_DATA_DIR; + delete process.env.APP_HOME_URL; + sandbox.restore(); + await testUtils.captureLog('warn', async () => { + await docsServer.close(); + await homeServer.close(); + await removeConnection(); + }); + }); + + afterEach(async function() { + sandbox.restore(); + }); + + function getDocTracker(dbManager: HomeDBManager) { + const forced = sandbox.spy(dbManager, "getDoc"); + const cached = sandbox.spy(dbManager, "getDocAuthCached"); + const impl = sandbox.spy(dbManager, "getDocImpl"); + function getCallCounts() { + return { + forced: forced.callCount, + misses: impl.callCount - forced.callCount, + hits: cached.callCount - (impl.callCount - forced.callCount), + }; + } + function reset() { + forced.resetHistory(); + cached.resetHistory(); + impl.resetHistory(); + } + function getAndReset() { + const res = getCallCounts(); + reset(); + return res; + } + return {getCallCounts, reset, getAndReset}; + } + + function flushCache() { + homeServer.getHomeDBManager().flushDocAuthCache(); + docsServer.getHomeDBManager().flushDocAuthCache(); + } + + function getDocCallTracker() { + return { + home: getDocTracker(homeServer.getHomeDBManager()), + docs: getDocTracker(docsServer.getHomeDBManager()), + }; + } + + it('should not cache direct call for doc metadata', async function() { + flushCache(); + const getDocCalls = getDocCallTracker(); + + const resp = await axios.get(`${homeUrl}/api/docs/${helloDocId}`, chimpy); + assert.equal(resp.data.name, 'Jupiter'); + + // This is a metadata-only call, so only home server is involved. + assert.deepEqual(getDocCalls.home.getAndReset(), {forced: 1, misses: 0, hits: 0}); + assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 0, hits: 0}); + + const resp2 = await axios.get(`${homeUrl}/api/docs/${helloDocId}`, chimpy); + assert.deepEqual(resp2.data, resp.data); + assert.deepEqual(getDocCalls.home.getAndReset(), {forced: 1, misses: 0, hits: 0}); + assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 0, hits: 0}); + }); + + it('should cache DocApi + DocApiForwarder calls', async function() { + flushCache(); + const getDocCalls = getDocCallTracker(); + const resp = await axios.get(`${homeUrl}/api/docs/${helloDocId}/tables/Table1/data`, chimpy); + assert.deepInclude(resp.data, {E: ["HELLO", "", "", ""]}); + + assert.deepEqual(getDocCalls.home.getAndReset(), {forced: 0, misses: 1, hits: 0}); + assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 1, hits: 0}); + + // Try an endpoint requiring editing permissions. + const resp2 = await axios.post(`${homeUrl}/api/docs/${helloDocId}/tables/Table1/data`, {A: ['Foo']}, chimpy); + assert.equal(resp2.status, 200); + assert.deepEqual(getDocCalls.home.getAndReset(), {forced: 0, misses: 0, hits: 1}); + assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 0, hits: 1}); + + const resp3 = await axios.get(`${homeUrl}/api/docs/${helloDocId}/tables/Table1/data`, chimpy); + assert.deepInclude(resp3.data, {E: ["HELLO", "", "", "", "FOO"]}); + + assert.deepEqual(getDocCalls.home.getAndReset(), {forced: 0, misses: 0, hits: 1}); + assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 0, hits: 1}); + }); + + it('should cache DocAPI + DocApiForwarder no-access calls', async function() { + flushCache(); + const getDocCalls = getDocCallTracker(); + + // Kiwi has view-only access. Check that it's checked, and is cached too. + let resp = await axios.post(`${homeUrl}/api/docs/${helloDocId}/tables/Table1/data`, {A: ['Bar']}, kiwi); + assert.equal(resp.status, 403); + assert.match(resp.data.error, /No write access/); + assert.deepEqual(getDocCalls.home.getAndReset(), {forced: 0, misses: 1, hits: 0}); + assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 1, hits: 0}); + + // Second call is cached, but otherwise identical. + resp = await axios.post(`${homeUrl}/api/docs/${helloDocId}/tables/Table1/data`, {A: ['Bar']}, kiwi); + assert.equal(resp.status, 403); + assert.match(resp.data.error, /No write access/); + // The read/write distinction isn't checked by DocApiForwarder, so docsServer sees the request. + assert.deepEqual(getDocCalls.home.getAndReset(), {forced: 0, misses: 0, hits: 1}); + assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 0, hits: 1}); + + // View access works. + resp = await axios.get(`${homeUrl}/api/docs/${helloDocId}/tables/Table1/data`, kiwi); + assert.deepInclude(resp.data, {E: ["HELLO", "", "", "", "FOO"]}); + assert.deepEqual(getDocCalls.home.getAndReset(), {forced: 0, misses: 0, hits: 1}); + assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 0, hits: 1}); + + // Charon has no access. + resp = await axios.get(`${homeUrl}/api/docs/${helloDocId}/tables/Table1/data`, charon); + assert.equal(resp.status, 403); + assert.match(resp.data.error, /No view access/); + assert.deepEqual(getDocCalls.home.getAndReset(), {forced: 0, misses: 1, hits: 0}); + assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 0, hits: 0}); + + // ...or write access (but the check is cached). + resp = await axios.post(`${homeUrl}/api/docs/${helloDocId}/tables/Table1/data`, {A: ['Bar']}, charon); + assert.equal(resp.status, 403); + assert.match(resp.data.error, /No view access/); + assert.deepEqual(getDocCalls.home.getAndReset(), {forced: 0, misses: 0, hits: 1}); + // docsServer never sees the request. + assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 0, hits: 0}); + }); + + it('should not cache app.html endpoint', async function() { + flushCache(); + const getDocCalls = getDocCallTracker(); + const cookie = await session.getCookieLogin('nasa', {email: chimpyEmail, name: 'Chimpy'}); + + const resp1 = await axios.get(`${homeUrl}/o/nasa/doc/${helloDocId}`, cookie); + + // gristConfig should include results of the getDoc call. + const gristConfig = getGristConfig(resp1.data); + assert.hasAnyKeys(gristConfig.getDoc, [helloDocId]); + assert.deepInclude(gristConfig.getDoc![helloDocId], {name: 'Jupiter', id: helloDocId}); + + // All authentication and getDoc() call are made by homeServer, docsServer not yet in play + assert.deepEqual(getDocCalls.home.getAndReset(), {forced: 1, misses: 0, hits: 1}); + assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 0, hits: 0}); + + // No caching on subsequent call because we force a fresh fetch for this endpoint. + const resp2 = await axios.get(`${homeUrl}/o/nasa/doc/${helloDocId}`, cookie); + assert.deepEqual(getGristConfig(resp2.data).getDoc, gristConfig.getDoc); + assert.deepEqual(getDocCalls.home.getAndReset(), {forced: 1, misses: 0, hits: 1}); + assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 0, hits: 0}); + }); + + it('should cache openDoc and websocket methods', async function() { + flushCache(); + const getDocCalls = getDocCallTracker(); + + const cli = await openClient(docsServer, chimpyEmail, 'nasa'); + assert.equal((await cli.readMessage()).type, 'clientConnect'); + const openDoc = await cli.send("openDoc", helloDocId); + assert.equal(openDoc.error, undefined); + assert.match(JSON.stringify(openDoc.data), /Table1/); + + assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 1, hits: 0}); + + // Read access + const table = await cli.send("fetchTable", 0, "Table1"); + assert.includeMembers(table.data.tableData, ['TableData', 'Table1']); + assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 0, hits: 1}); + + // Write access + const auaResult = await cli.send("applyUserActions", 0, + [["UpdateRecord", "Table1", 1, {A: "auth-caching1"}]]); + await delay(200); // give a little time for change broadcast. + assert.isNumber(auaResult.data.actionNum); + assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 0, hits: 2}); + + await cli.close(); + }); + + it('should cache openDoc and websocket methods with access failures', async function() { + flushCache(); + const getDocCalls = getDocCallTracker(); + + // Repeat with a view-only user (Kiwi) + let cli = await openClient(docsServer, kiwiEmail, 'nasa'); + assert.equal((await cli.readMessage()).type, 'clientConnect'); + let openDoc = await cli.send("openDoc", helloDocId); + assert.equal(openDoc.error, undefined); + assert.match(JSON.stringify(openDoc.data), /Table1/); + + assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 1, hits: 0}); + + // Kiwi has read access + const table = await cli.send("fetchTable", 0, "Table1"); + assert.includeMembers(table.data.tableData, ['TableData', 'Table1']); + assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 0, hits: 1}); + + // Kiwi has NO write access. + const auaResult = await cli.send("applyUserActions", 0, + [["UpdateRecord", "Table1", 1, {A: "auth-caching2"}]]); + assert.deepEqual(auaResult.error, 'No write access'); + assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 0, hits: 1}); + + // Charon has no access at all + cli = await openClient(docsServer, charonEmail, 'nasa'); + assert.equal((await cli.readMessage()).type, 'clientConnect'); + openDoc = await cli.send("openDoc", helloDocId); + assert.equal(openDoc.error, 'No view access'); + + assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 1, hits: 0}); + await cli.send("openDoc", helloDocId); + assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 0, hits: 1}); + + // Home server wasn't involved in this test case at all. + assert.deepEqual(getDocCalls.home.getAndReset(), {forced: 0, misses: 0, hits: 0}); + }); + + it('should cache across different kinds of calls', async function() { + // Fetch the document endpoint and follow with openDoc. Caching should apply. + flushCache(); + const getDocCalls = getDocCallTracker(); + const cookie = await session.getCookieLogin('nasa', {email: chimpyEmail, name: 'Chimpy'}); + + // app.html endpoint warms the cache for the home server. + const resp1 = await axios.get(`${homeUrl}/o/nasa/doc/${helloDocId}`, cookie); + const gristConfig = getGristConfig(resp1.data); + assert.hasAnyKeys(gristConfig.getDoc, [helloDocId]); + assert.deepInclude(gristConfig.getDoc![helloDocId], {name: 'Jupiter', id: helloDocId}); + + assert.deepEqual(getDocCalls.home.getAndReset(), {forced: 1, misses: 0, hits: 1}); + assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 0, hits: 0}); + + // openDoc call warms the cache for the doc-worker. + const cli = await openClient(docsServer, chimpyEmail, 'nasa'); + assert.equal((await cli.readMessage()).type, 'clientConnect'); + const openDoc = await cli.send("openDoc", helloDocId); + assert.equal(openDoc.error, undefined); + assert.match(JSON.stringify(openDoc.data), /Table1/); + + assert.deepEqual(getDocCalls.home.getAndReset(), {forced: 0, misses: 0, hits: 0}); + assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 1, hits: 0}); + + // the caching applies to API calls for the same doc/user/org combination. + const resp = await axios.get(`${homeUrl}/o/nasa/api/docs/${helloDocId}/tables/Table1/data`, chimpy); + assert.equal(resp.status, 200); + assert.deepEqual(getDocCalls.home.getAndReset(), {forced: 0, misses: 0, hits: 1}); + assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 0, hits: 1}); + }); + + it('should expire the cache after a timeout', async function() { + this.timeout(10000); + + // Make an API call; change access; check that after a while, the change is noticed. + flushCache(); + const getDocCalls = getDocCallTracker(); + + // Connect up websockets for Kiwi and Charon. + const kiwiCli = await openClient(docsServer, kiwiEmail, 'nasa'); + assert.equal((await kiwiCli.readMessage()).type, 'clientConnect'); + const charonCli = await openClient(docsServer, charonEmail, 'nasa'); + assert.equal((await charonCli.readMessage()).type, 'clientConnect'); + + // Kiwi has access, Charon doesn't. + let resp1 = await axios.get(`${homeUrl}/o/nasa/api/docs/${helloDocId}/tables/Table1/data`, kiwi); + let resp2 = await axios.get(`${homeUrl}/o/nasa/api/docs/${helloDocId}/tables/Table1/data`, charon); + assert.equal(resp1.status, 200); + assert.equal(resp2.status, 403); + + // home server sees both calls, but only forwards one to the doc-worker. + assert.deepEqual(getDocCalls.home.getAndReset(), {forced: 0, misses: 2, hits: 0}); + assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 1, hits: 0}); + + assert.equal((await kiwiCli.send("openDoc", helloDocId)).error, undefined); + assert.equal((await charonCli.send("openDoc", helloDocId)).error, 'No view access'); + assert.deepEqual(getDocCalls.home.getAndReset(), {forced: 0, misses: 0, hits: 0}); + assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 1, hits: 1}); + + // Use Chimpy's access to change access for both. + const resp = await axios.patch(`${homeUrl}/o/nasa/api/docs/${helloDocId}/access`, + {delta: {users: {[kiwiEmail]: null, [charonEmail]: 'viewers'}}}, + chimpy); + assert.equal(resp.status, 200); + + // Home's UserAPI methods don't call to getDoc() to check doc-level access, so access checks + // for Chimpy's patch-access call do not affect our counts. + assert.deepEqual(getDocCalls.home.getAndReset(), {forced: 0, misses: 0, hits: 0}); + assert.deepEqual(getDocCalls.docs.getAndReset(), {forced: 0, misses: 0, hits: 0}); + + // The change isn't visible immediately. + resp1 = await axios.get(`${homeUrl}/o/nasa/api/docs/${helloDocId}/tables/Table1/data`, kiwi); + resp2 = await axios.get(`${homeUrl}/o/nasa/api/docs/${helloDocId}/tables/Table1/data`, charon); + assert.equal(resp1.status, 200); + assert.equal(resp2.status, 403); + + // But eventually it is. Should be within 5 seconds, we try up to 10. + let passed = false; + for (let i = 0; i < 50; i++) { + await delay(200); + try { + // Check if access changes are visible yet. + resp1 = await axios.get(`${homeUrl}/o/nasa/api/docs/${helloDocId}/tables/Table1/data`, kiwi); + resp2 = await axios.get(`${homeUrl}/o/nasa/api/docs/${helloDocId}/tables/Table1/data`, charon); + assert.equal(resp1.status, 403); + assert.equal(resp2.status, 200); + assert.equal((await kiwiCli.send("openDoc", helloDocId)).error, 'No view access'); + assert.equal((await charonCli.send("openDoc", helloDocId)).error, undefined); + passed = true; + break; + } catch (err) { + continue; + } + } + assert.isTrue(passed); + + const homeCalls = getDocCalls.home.getAndReset(); + const docsCalls = getDocCalls.docs.getAndReset(); + // There are many cache hits, but one set of misses that discovers the access changes. + assert.deepInclude(homeCalls, {forced: 0, misses: 2}); + assert.deepInclude(docsCalls, {forced: 0, misses: 2}); + assert.isAbove(homeCalls.hits, 10); + assert.isAbove(docsCalls.hits, 10); + }); +}); diff --git a/test/gen-server/migrations.ts b/test/gen-server/migrations.ts new file mode 100644 index 00000000..2ab2623f --- /dev/null +++ b/test/gen-server/migrations.ts @@ -0,0 +1,172 @@ +import {QueryRunner} from "typeorm"; +import * as roles from "app/common/roles"; +import {Organization} from 'app/gen-server/entity/Organization'; +import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager'; +import {Permissions} from 'app/gen-server/lib/Permissions'; +import {assert} from 'chai'; +import {addSeedData, createInitialDb, removeConnection, setUpDB} from 'test/gen-server/seed'; + +import {Initial1536634251710 as Initial} from 'app/gen-server/migration/1536634251710-Initial'; +import {Login1539031763952 as Login} from 'app/gen-server/migration/1539031763952-Login'; +import {PinDocs1549313797109 as PinDocs} from 'app/gen-server/migration/1549313797109-PinDocs'; +import {UserPicture1549381727494 as UserPicture} from 'app/gen-server/migration/1549381727494-UserPicture'; +import {LoginDisplayEmail1551805156919 as DisplayEmail} from 'app/gen-server/migration/1551805156919-LoginDisplayEmail'; +import {LoginDisplayEmailNonNull1552416614755 + as DisplayEmailNonNull} from 'app/gen-server/migration/1552416614755-LoginDisplayEmailNonNull'; +import {Indexes1553016106336 as Indexes} from 'app/gen-server/migration/1553016106336-Indexes'; +import {Billing1556726945436 as Billing} from 'app/gen-server/migration/1556726945436-Billing'; +import {Aliases1561589211752 as Aliases} from 'app/gen-server/migration/1561589211752-Aliases'; +import {TeamMembers1568238234987 as TeamMembers} from 'app/gen-server/migration/1568238234987-TeamMembers'; +import {FirstLogin1569593726320 as FirstLogin} from 'app/gen-server/migration/1569593726320-FirstLogin'; +import {FirstTimeUser1569946508569 as FirstTimeUser} from 'app/gen-server/migration/1569946508569-FirstTimeUser'; +import {CustomerIndex1573569442552 as CustomerIndex} from 'app/gen-server/migration/1573569442552-CustomerIndex'; +import {ExtraIndexes1579559983067 as ExtraIndexes} from 'app/gen-server/migration/1579559983067-ExtraIndexes'; +import {OrgHost1591755411755 as OrgHost} from 'app/gen-server/migration/1591755411755-OrgHost'; +import {DocRemovedAt1592261300044 as DocRemovedAt} from 'app/gen-server/migration/1592261300044-DocRemovedAt'; +import {Prefs1596456522124 as Prefs} from 'app/gen-server/migration/1596456522124-Prefs'; +import {ExternalBilling1623871765992 as ExternalBilling} from 'app/gen-server/migration/1623871765992-ExternalBilling'; +import {DocOptions1626369037484 as DocOptions} from 'app/gen-server/migration/1626369037484-DocOptions'; +import {Secret1631286208009 as Secret} from 'app/gen-server/migration/1631286208009-Secret'; +import {UserOptions1644363380225 as UserOptions} from 'app/gen-server/migration/1644363380225-UserOptions'; +import {GracePeriodStart1647883793388 + as GracePeriodStart} from 'app/gen-server/migration/1647883793388-GracePeriodStart'; +import {DocumentUsage1651469582887 as DocumentUsage} from 'app/gen-server/migration/1651469582887-DocumentUsage'; +import {Activations1652273656610 as Activations} from 'app/gen-server/migration/1652273656610-Activations'; +import {UserConnectId1652277549983 as UserConnectId} from 'app/gen-server/migration/1652277549983-UserConnectId'; +import {UserUUID1663851423064 as UserUUID} from 'app/gen-server/migration/1663851423064-UserUUID'; +import {UserRefUnique1664528376930 as UserUniqueRefUUID} from 'app/gen-server/migration/1664528376930-UserRefUnique'; +import {Forks1673051005072 as Forks} from 'app/gen-server/migration/1673051005072-Forks'; +import {ForkIndexes1678737195050 as ForkIndexes} from 'app/gen-server/migration/1678737195050-ForkIndexes'; +import {ActivationPrefs1682636695021 as ActivationPrefs} from 'app/gen-server/migration/1682636695021-ActivationPrefs'; +import {AssistantLimit1685343047786 as AssistantLimit} from 'app/gen-server/migration/1685343047786-AssistantLimit'; + +const home: HomeDBManager = new HomeDBManager(); + +const migrations = [Initial, Login, PinDocs, UserPicture, DisplayEmail, DisplayEmailNonNull, + Indexes, Billing, Aliases, TeamMembers, FirstLogin, FirstTimeUser, + CustomerIndex, ExtraIndexes, OrgHost, DocRemovedAt, Prefs, + ExternalBilling, DocOptions, Secret, UserOptions, GracePeriodStart, + DocumentUsage, Activations, UserConnectId, UserUUID, UserUniqueRefUUID, + Forks, ForkIndexes, ActivationPrefs, AssistantLimit]; + +// Assert that the "members" acl rule and group exist (or not). +function assertMembersGroup(org: Organization, exists: boolean) { + const memberAcl = org.aclRules.find(_aclRule => _aclRule.group.name === roles.MEMBER); + if (!exists) { + assert.isUndefined(memberAcl); + } else { + assert.isDefined(memberAcl); + assert.equal(memberAcl!.permissions, Permissions.VIEW); + assert.isDefined(memberAcl!.group); + assert.equal(memberAcl!.group.name, roles.MEMBER); + } +} + +describe('migrations', function() { + + before(function() { + setUpDB(this); + }); + + beforeEach(async function() { + await home.connect(); + await createInitialDb(home.connection, false); + }); + + afterEach(async function() { + await removeConnection(); + }); + + // a test to exercise the rollback scripts a bit + it('can migrate, do full rollback, and migrate again', async function() { + this.timeout(60000); + const runner = home.connection.createQueryRunner(); + for (const migration of migrations) { + await (new migration()).up(runner); + } + for (const migration of migrations.slice().reverse()) { + await (new migration()).down(runner); + } + for (const migration of migrations) { + await (new migration()).up(runner); + } + await addSeedData(home.connection); + // if we made it this far without an exception, then the rollback scripts must + // be doing something. + }); + + it('can correctly switch display_email column to non-null with data', async function() { + this.timeout(60000); + const sqlite = home.connection.driver.options.type === 'sqlite'; + // sqlite migrations need foreign keys turned off temporarily + if (sqlite) { await home.connection.query("PRAGMA foreign_keys = OFF;"); } + const runner = home.connection.createQueryRunner(); + for (const migration of migrations) { + await (new migration()).up(runner); + } + await addSeedData(home.connection); + // migrate back until just before display_email column added, so we have no + // display_emails + for (const migration of migrations.slice().reverse()) { + await (new migration()).down(runner); + if (migration.name === DisplayEmail.name) { break; } + } + // now check DisplayEmail and DisplayEmailNonNull succeed with data in the db. + await (new DisplayEmail()).up(runner); + await (new DisplayEmailNonNull()).up(runner); + if (sqlite) { await home.connection.query("PRAGMA foreign_keys = ON;"); } + }); + + // a test to ensure the TeamMember migration works on databases with existing content + it('can perform TeamMember migration with seed data set', async function() { + this.timeout(30000); + const runner = home.connection.createQueryRunner(); + // Perform full up migration and add the seed data. + for (const migration of migrations) { + await (new migration()).up(runner); + } + await addSeedData(home.connection); + const initAclCount = await getAclRowCount(runner); + const initGroupCount = await getGroupRowCount(runner); + + // Assert that members groups are present to start. + for (const org of (await getAllOrgs(runner))) { assertMembersGroup(org, true); } + + // Perform down TeamMembers migration with seed data and assert members groups are removed. + await (new TeamMembers()).down(runner); + const downMigratedOrgs = await getAllOrgs(runner); + for (const org of downMigratedOrgs) { assertMembersGroup(org, false); } + // Assert that the correct number of ACLs and groups were removed. + assert.equal(await getAclRowCount(runner), initAclCount - downMigratedOrgs.length); + assert.equal(await getGroupRowCount(runner), initGroupCount - downMigratedOrgs.length); + + // Perform up TeamMembers migration with seed data and assert members groups are added. + await (new TeamMembers()).up(runner); + for (const org of (await getAllOrgs(runner))) { assertMembersGroup(org, true); } + // Assert that the correct number of ACLs and groups were re-added. + assert.equal(await getAclRowCount(runner), initAclCount); + assert.equal(await getGroupRowCount(runner), initGroupCount); + }); +}); + +/** + * Returns all orgs in the database with aclRules and groups joined. + */ +function getAllOrgs(queryRunner: QueryRunner): Promise { + const orgQuery = queryRunner.manager.createQueryBuilder() + .select('orgs') + .from(Organization, 'orgs') + .leftJoinAndSelect('orgs.aclRules', 'org_acl_rules') + .leftJoinAndSelect('org_acl_rules.group', 'org_groups'); + return orgQuery.getMany(); +} + +async function getAclRowCount(queryRunner: QueryRunner): Promise { + const rows = await queryRunner.query(`SELECT id FROM acl_rules`); + return rows.length; +} + +async function getGroupRowCount(queryRunner: QueryRunner): Promise { + const rows = await queryRunner.query(`SELECT id FROM groups`); + return rows.length; +}