diff --git a/test/gen-server/lib/DocApiForwarder.ts b/test/gen-server/lib/DocApiForwarder.ts new file mode 100644 index 00000000..a9852a3d --- /dev/null +++ b/test/gen-server/lib/DocApiForwarder.ts @@ -0,0 +1,248 @@ +import { delay } from 'app/common/delay'; +import { createDummyGristServer } from 'app/server/lib/GristServer'; +import axios, { AxiosResponse } from 'axios'; +import { fromCallback } from "bluebird"; +import { assert } from 'chai'; +import express = require("express"); +import FormData from 'form-data'; +import { Server } from 'http'; +import defaultsDeep = require('lodash/defaultsDeep'); +import morganLogger from 'morgan'; +import { AddressInfo } from 'net'; +import sinon = require("sinon"); + +import { createInitialDb, removeConnection, setUpDB } from "test/gen-server/seed"; +import { configForUser } from 'test/gen-server/testUtils'; + +import { DocApiForwarder } from "app/gen-server/lib/DocApiForwarder"; +import { DocWorkerMap, getDocWorkerMap } from "app/gen-server/lib/DocWorkerMap"; +import { HomeDBManager } from "app/gen-server/lib/homedb/HomeDBManager"; +import { addRequestUser } from 'app/server/lib/Authorizer'; +import { jsonErrorHandler } from 'app/server/lib/expressWrap'; +import log from 'app/server/lib/log'; +import * as testUtils from 'test/server/testUtils'; + + +const chimpy = configForUser('Chimpy'); +const kiwi = configForUser('kiwi'); + +const logToConsole = false; + +async function createServer(app: express.Application, name: string) { + let server: Server; + if (logToConsole) { + app.use(morganLogger((...args: any[]) => { + return `${log.timestamp()} ${name} ${morganLogger.dev(...args)}`; + })); + } + app.set('port', 0); + await fromCallback((cb: any) => server = app.listen(app.get('port'), 'localhost', cb)); + log.info(`${name} listening ${getUrl(server!)}`); + return server!; +} + +function getUrl(server: Server) { + return `http://localhost:${(server.address() as AddressInfo).port}`; +} + +describe('DocApiForwarder', function() { + + testUtils.setTmpLogLevel('error'); + + let homeServer: Server; + let docWorker: Server; + let resp: AxiosResponse; + let homeUrl: string; + let dbManager: HomeDBManager; + const docWorkerStub = sinon.stub(); + + before(async function() { + setUpDB(this); + dbManager = new HomeDBManager(); + await dbManager.connect(); + await createInitialDb(dbManager.connection); + await dbManager.initializeSpecialIds(); + + // create cheap doc worker + let app = express(); + docWorker = await createServer(app, 'docw'); + app.use(express.json()); + app.use(docWorkerStub); + + // create cheap home server + app = express(); + homeServer = await createServer(app, 'home'); + homeUrl = getUrl(homeServer); + + // stubs doc worker map + const docWorkerMapStub = sinon.createStubInstance(DocWorkerMap); + docWorkerMapStub.assignDocWorker.returns(Promise.resolve({ + docWorker: { + internalUrl: getUrl(docWorker) + '/dw/foo', + publicUrl: '', + id: '', + }, + docMD5: null, + isActive: true, + })); + + // create and register forwarder + const docApiForwarder = new DocApiForwarder(docWorkerMapStub, dbManager, null as any); + app.use("/api", addRequestUser.bind(null, dbManager, getDocWorkerMap().getPermitStore('internal'), + {gristServer: createDummyGristServer()} as any)); + docApiForwarder.addEndpoints(app); + app.use('/api', jsonErrorHandler); + }); + + after(async function() { + await removeConnection(); + homeServer.close(); + docWorker.close(); + dbManager.flushDocAuthCache(); // To avoid hanging up exit from tests. + }); + + beforeEach(() => { + docWorkerStub.resetHistory(); + docWorkerStub.callsFake((req: any, res: any) => res.status(200).json('mango tree')); + }); + + it('should forward GET /api/docs/:did/tables/:tid/data', async function() { + resp = await axios.get(`${homeUrl}/api/docs/sampledocid_16/tables/table1/data`, chimpy); + assert.equal(resp.status, 200); + assert.equal(resp.data, 'mango tree'); + assert(docWorkerStub.calledOnce); + const req = docWorkerStub.getCall(0).args[0]; + assert.equal(req.get('Authorization'), 'Bearer api_key_for_chimpy'); + assert.equal(req.get('Content-Type'), 'application/json'); + assert.equal(req.originalUrl, '/dw/foo/api/docs/sampledocid_16/tables/table1/data'); + assert.equal(req.method, 'GET'); + }); + + it('should forward GET /api/docs/:did/tables/:tid/data?filter=<...>', async function() { + const filter = encodeURIComponent(JSON.stringify({FOO: ['bar']})); // => %7B%22FOO%22%3A%5B%22bar%22%5D%7D + resp = await axios.get(`${homeUrl}/api/docs/sampledocid_16/tables/table1/data?filter=${filter}`, chimpy); + assert.equal(resp.status, 200); + assert.equal(resp.data, 'mango tree'); + assert(docWorkerStub.calledOnce); + const req = docWorkerStub.getCall(0).args[0]; + assert.equal(req.get('Authorization'), 'Bearer api_key_for_chimpy'); + assert.equal(req.get('Content-Type'), 'application/json'); + assert.equal(req.originalUrl, + '/dw/foo/api/docs/sampledocid_16/tables/table1/data?filter=%7B%22FOO%22%3A%5B%22bar%22%5D%7D'); + assert.equal(req.method, 'GET'); + }); + + it('should deny user without view permissions', async function() { + resp = await axios.get(`${homeUrl}/api/docs/sampledocid_13/tables/table1/data`, kiwi); + assert.equal(resp.status, 403); + assert.deepEqual(resp.data, {error: 'No view access'}); + assert.equal(docWorkerStub.callCount, 0); + }); + + + it('should forward POST /api/docs/:did/tables/:tid/data', async function() { + resp = await axios.post(`${homeUrl}/api/docs/sampledocid_16/tables/table1/data`, {message: 'golden pears'}, chimpy); + assert.equal(resp.status, 200); + assert.equal(resp.data, 'mango tree'); + assert(docWorkerStub.calledOnce); + const req = docWorkerStub.getCall(0).args[0]; + assert.equal(req.get('Authorization'), 'Bearer api_key_for_chimpy'); + assert.equal(req.get('Content-Type'), 'application/json'); + assert.equal(req.originalUrl, '/dw/foo/api/docs/sampledocid_16/tables/table1/data'); + assert.equal(req.method, 'POST'); + assert.deepEqual(req.body, {message: 'golden pears'}); + }); + + + it('should forward PATCH /api/docs/:did/tables/:tid/data', async function() { + resp = await axios.patch(`${homeUrl}/api/docs/sampledocid_16/tables/table1/data`, + {message: 'golden pears'}, chimpy); + assert.equal(resp.status, 200); + assert.equal(resp.data, 'mango tree'); + assert(docWorkerStub.calledOnce); + const req = docWorkerStub.getCall(0).args[0]; + assert.equal(req.get('Authorization'), 'Bearer api_key_for_chimpy'); + assert.equal(req.get('Content-Type'), 'application/json'); + assert.equal(req.originalUrl, '/dw/foo/api/docs/sampledocid_16/tables/table1/data'); + assert.equal(req.method, 'PATCH'); + assert.deepEqual(req.body, {message: 'golden pears'}); + }); + + it('should forward POST /api/docs/:did/attachments', async function() { + const formData = new FormData(); + formData.append('upload', 'abcdef', "hello.png"); + resp = await axios.post(`${homeUrl}/api/docs/sampledocid_16/attachments`, formData, + defaultsDeep({headers: formData.getHeaders()}, chimpy)); + assert.equal(resp.status, 200); + assert.deepEqual(resp.headers['content-type'], 'application/json; charset=utf-8'); + assert.deepEqual(resp.data, 'mango tree'); + assert(docWorkerStub.calledOnce); + const req = docWorkerStub.getCall(0).args[0]; + assert.equal(req.get('Authorization'), 'Bearer api_key_for_chimpy'); + assert.match(req.get('Content-Type'), /^multipart\/form-data; boundary=/); + assert.equal(req.originalUrl, '/dw/foo/api/docs/sampledocid_16/attachments'); + assert.equal(req.method, 'POST'); + }); + + it('should forward GET /api/docs/:did/attachments/:attId/download', async function() { + docWorkerStub.callsFake((_req: any, res: any) => + res.status(200) + .type('.png') + .set('Content-Disposition', 'attachment; filename="hello.png"') + .set('Cache-Control', 'private, max-age=3600') + .send(Buffer.from('abcdef'))); + resp = await axios.get(`${homeUrl}/api/docs/sampledocid_16/attachments/123/download`, chimpy); + assert.equal(resp.status, 200); + assert.deepEqual(resp.headers['content-type'], 'image/png'); + assert.deepEqual(resp.headers['content-disposition'], 'attachment; filename="hello.png"'); + assert.deepEqual(resp.headers['cache-control'], 'private, max-age=3600'); + assert.deepEqual(resp.data, 'abcdef'); + assert(docWorkerStub.calledOnce); + const req = docWorkerStub.getCall(0).args[0]; + assert.equal(req.get('Authorization'), 'Bearer api_key_for_chimpy'); + assert.equal(req.get('Content-Type'), 'application/json'); + assert.equal(req.originalUrl, '/dw/foo/api/docs/sampledocid_16/attachments/123/download'); + assert.equal(req.method, 'GET'); + }); + + it('should forward error message on failure', async function() { + docWorkerStub.callsFake((_req: any, res: any) => res.status(500).send({error: 'internal error'})); + resp = await axios.get(`${homeUrl}/api/docs/sampledocid_16/tables/table1/data`, chimpy); + assert.equal(resp.status, 500); + assert.deepEqual(resp.data, {error: 'internal error'}); + assert(docWorkerStub.calledOnce); + const req = docWorkerStub.getCall(0).args[0]; + assert.equal(req.get('Authorization'), 'Bearer api_key_for_chimpy'); + assert.equal(req.get('Content-Type'), 'application/json'); + assert.equal(req.originalUrl, '/dw/foo/api/docs/sampledocid_16/tables/table1/data'); + assert.equal(req.method, 'GET'); + }); + + it('should notice aborted requests and cancel forwarded ones', async function() { + let requestReceived: Function; + let closeReceived: Function; + let requestDone: Function; + const checkIsClosed = sinon.spy(); + const promiseForRequestReceived = new Promise(r => { requestReceived = r; }); + const promiseForCloseReceived = new Promise(r => { closeReceived = r; }); + const promiseForRequestDone = new Promise(r => { requestDone = r; }); + docWorkerStub.callsFake(async (req: any, res: any) => { + req.on('close', closeReceived); + requestReceived(); + await Promise.race([promiseForCloseReceived, delay(100)]); + checkIsClosed(req.closed || req.aborted); + res.status(200).json('fig tree?'); + requestDone(); + }); + const CancelToken = axios.CancelToken; + const source = CancelToken.source(); + const response = axios.get(`${homeUrl}/api/docs/sampledocid_16/tables/table1/data`, + {...chimpy, cancelToken: source.token}); + await promiseForRequestReceived; + source.cancel('cancelled for testing'); + await assert.isRejected(response, /cancelled for testing/); + await promiseForRequestDone; + sinon.assert.calledOnce(checkIsClosed); + assert.deepEqual(checkIsClosed.args, [[true]]); + }); +}); diff --git a/test/gen-server/lib/DocWorkerMap.ts b/test/gen-server/lib/DocWorkerMap.ts index 56421f6f..73b7d21c 100644 --- a/test/gen-server/lib/DocWorkerMap.ts +++ b/test/gen-server/lib/DocWorkerMap.ts @@ -1,14 +1,507 @@ -// Test for DocWorkerMap.ts - -import { DocWorkerMap } from 'app/gen-server/lib/DocWorkerMap'; -import { DocWorkerInfo } from 'app/server/lib/DocWorkerMap'; -import {expect} from 'chai'; +import {DocWorkerMap, getDocWorkerMap} from 'app/gen-server/lib/DocWorkerMap'; +import {DocStatus, DocWorkerInfo, IDocWorkerMap} from 'app/server/lib/DocWorkerMap'; +import {FlexServer} from 'app/server/lib/FlexServer'; +import {Permit} from 'app/server/lib/Permit'; +import {main as mergedServerMain} from 'app/server/mergedServerMain'; +import {delay, promisifyAll} from 'bluebird'; +import {assert, expect} from 'chai'; +import {countBy, values} from 'lodash'; +import {createClient, RedisClient} from 'redis'; +import {TestSession} from 'test/gen-server/apiUtils'; +import {createInitialDb, removeConnection, setUpDB} from 'test/gen-server/seed'; import sinon from 'sinon'; +import * as testUtils from 'test/server/testUtils'; -describe('DocWorkerMap', () => { - const sandbox = sinon.createSandbox(); - afterEach(() => { - sandbox.restore(); +promisifyAll(RedisClient.prototype); + +describe('DocWorkerMap', function() { + + let cli: RedisClient; + + testUtils.setTmpLogLevel('error'); + + before(async function() { + if (!process.env.TEST_REDIS_URL) { this.skip(); } + cli = createClient(process.env.TEST_REDIS_URL); + await cli.flushdbAsync(); + }); + + after(async function() { + if (cli) { await cli.quitAsync(); } + }); + + beforeEach(async function() { + if (cli) { await cli.delAsync('groups'); } + }); + + afterEach(async function() { + if (cli) { await cli.flushdbAsync(); } + }); + + it('can assign a worker when available', async function() { + const workers = new DocWorkerMap([cli]); + + // No assignment without workers available + await assert.isRejected(workers.assignDocWorker('a-doc'), /no doc workers/); + + // Add a worker + await workers.addWorker({id: 'worker1', internalUrl: 'internal', publicUrl: 'public'}); + + // Still no assignment + await assert.isRejected(workers.assignDocWorker('a-doc'), /no doc workers/); + + // Make worker available + await workers.setWorkerAvailability('worker1', true); + + // That worker gets assigned + const worker = await workers.assignDocWorker('a-doc'); + assert.equal(worker.docWorker.id, 'worker1'); + + // That assignment is remembered + let w = await workers.getDocWorker('a-doc'); + assert.equal(w && w.docWorker.id, 'worker1'); + + // Make worker unavailable for assigment + await workers.setWorkerAvailability('worker1', false); + + // Existing assignment remains + w = await workers.getDocWorker('a-doc'); + assert.equal(w && w.docWorker.id, 'worker1'); + + // Remove worker + await workers.removeWorker('worker1'); + + // Assignment is gone away + w = await workers.getDocWorker('a-doc'); + assert.equal(w, null); + }); + + it('can release assignments', async function() { + const workers = new DocWorkerMap([cli]); + + await workers.addWorker({id: 'worker1', internalUrl: 'internal', publicUrl: 'public'}); + await workers.addWorker({id: 'worker2', internalUrl: 'internal', publicUrl: 'public'}); + + await workers.setWorkerAvailability('worker1', true); + + let assignment: DocStatus|null = await workers.assignDocWorker('a-doc'); + assert.equal(assignment.docWorker.id, 'worker1'); + + await workers.setWorkerAvailability('worker2', true); + await workers.setWorkerAvailability('worker1', false); + + assignment = await workers.getDocWorker('a-doc'); + assert.equal(assignment!.docWorker.id, 'worker1'); + + await workers.releaseAssignment('worker1', 'a-doc'); + + assignment = await workers.getDocWorker('a-doc'); + assert.equal(assignment, null); + + assignment = await workers.assignDocWorker('a-doc'); + assert.equal(assignment.docWorker.id, 'worker2'); + }); + + it('can assign multiple workers', async function() { + this.timeout(5000); // Be more generous than 2s default, since this normally takes over 1s. + + const workers = new DocWorkerMap([cli]); + + // Make some workers available + const W = 4; + for (let i = 0; i < W; i++) { + await workers.addWorker({id: `worker${i}`, internalUrl: 'internal', publicUrl: 'public'}); + await workers.setWorkerAvailability(`worker${i}`, true); + } + + // Assign some docs + const N = 100; + const docs: string[] = []; + const docWorkers: string[] = []; + for (let i = 0; i < N; i++) { + const name = `a-doc-${i}`; + docs.push(name); + const w = await workers.assignDocWorker(name); + docWorkers.push(w.docWorker.id); + } + + // Check assignment looks plausible (random, so will fail with low prob) + const counts = countBy(docWorkers); + // Say over half the workers got assigned + assert.isAbove(values(counts).length, W / 2); + // Say no worker got over half the work + const highs = values(counts).filter((k, v) => v > N / 2); + assert.equal(highs.length, 0); + + // Check assignments stick + for (let i = 0; i < N; i++) { + const name = docs[i]; + const w = await workers.getDocWorker(name); + assert.equal(w && w.docWorker.id, docWorkers[i]); + } + + // Check assignments drop out as workers are removed + let remaining = N; + for (const w of Object.keys(counts)) { + await workers.removeWorker(w); + remaining -= counts[w]; + let ct = 0; + for (const name of docs) { + if (null !== await workers.getDocWorker(name)) { ct++; } + } + assert.equal(remaining, ct); + } + assert.equal(remaining, 0); + }); + + it('can elect workers to groups', async function() { + this.timeout(5000); + + // Say we want one worker reserved for "blizzard" and two for "funkytown" + await cli.hmsetAsync('groups', { + blizzard: 1, + funkytown: 2, + }); + for (let i = 0; i < 20; i++) { + await cli.setAsync(`doc-blizzard${i}-group`, 'blizzard'); + await cli.setAsync(`doc-funkytown${i}-group`, 'funkytown'); + } + let workers = new DocWorkerMap([cli], 'ver1'); + for (let i = 0; i < 5; i++) { + await workers.addWorker({id: `worker${i}`, internalUrl: 'internal', publicUrl: 'public'}); + await workers.setWorkerAvailability(`worker${i}`, true); + } + let elections = await cli.hgetallAsync('elections-ver1'); + assert.deepEqual(elections, { blizzard: '["worker0"]', funkytown: '["worker1","worker2"]' }); + assert.sameMembers(await cli.smembersAsync('workers-available-blizzard'), ['worker0']); + assert.sameMembers(await cli.smembersAsync('workers-available-funkytown'), ['worker1', 'worker2']); + assert.sameMembers(await cli.smembersAsync('workers-available-default'), ['worker3', 'worker4']); + assert.sameMembers(await cli.smembersAsync('workers-available'), + ['worker0', 'worker1', 'worker2', 'worker3', 'worker4']); + for (let i = 0; i < 20; i++) { + const assignment = await workers.assignDocWorker(`blizzard${i}`); + assert.equal(assignment.docWorker.id, 'worker0'); + } + for (let i = 0; i < 20; i++) { + const assignment = await workers.assignDocWorker(`funkytown${i}`); + assert.include(['worker1', 'worker2'], assignment.docWorker.id); + } + for (let i = 0; i < 20; i++) { + const assignment = await workers.assignDocWorker(`random${i}`); + assert.include(['worker3', 'worker4'], assignment.docWorker.id); + } + + // suppose worker0 dies, and worker5 is added to replace it + await workers.removeWorker('worker0'); + await workers.addWorker({id: `worker5`, internalUrl: 'internal', publicUrl: 'public'}); + await workers.setWorkerAvailability('worker5', true); + for (let i = 0; i < 20; i++) { + const assignment = await workers.assignDocWorker(`blizzard${i}`); + assert.equal(assignment.docWorker.id, 'worker5'); + } + + // suppose worker1 dies, and worker6 is added to replace it + await workers.removeWorker('worker1'); + await workers.addWorker({id: `worker6`, internalUrl: 'internal', publicUrl: 'public'}); + await workers.setWorkerAvailability('worker6', true); + for (let i = 0; i < 20; i++) { + const assignment = await workers.assignDocWorker(`funkytown${i}`); + assert.include(['worker2', 'worker6'], assignment.docWorker.id); + } + + // suppose we add a new deployment... + workers = new DocWorkerMap([cli], 'ver2'); + for (let i = 0; i < 5; i++) { + await workers.addWorker({id: `worker${i}_v2`, internalUrl: 'internal', publicUrl: 'public'}); + await workers.setWorkerAvailability(`worker${i}_v2`, true); + } + assert.sameMembers(await cli.smembersAsync('workers-available-blizzard'), + ['worker5', 'worker0_v2']); + assert.sameMembers(await cli.smembersAsync('workers-available-funkytown'), + ['worker2', 'worker6', 'worker1_v2', 'worker2_v2']); + assert.sameMembers(await cli.smembersAsync('workers-available-default'), + ['worker3', 'worker4', 'worker3_v2', 'worker4_v2']); + assert.sameMembers(await cli.smembersAsync('workers-available'), + ['worker2', 'worker3', 'worker4', 'worker5', 'worker6', + 'worker0_v2', 'worker1_v2', 'worker2_v2', 'worker3_v2', 'worker4_v2']); + + // ...and then remove the old one + workers = new DocWorkerMap([cli], 'ver1'); + for (let i = 0; i < 7; i++) { + await workers.removeWorker(`worker${i}`); + } + + // check everything looks as expected + workers = new DocWorkerMap([cli], 'ver2'); + elections = await cli.hgetallAsync('elections-ver2'); + assert.deepEqual(elections, { blizzard: '["worker0_v2"]', + funkytown: '["worker1_v2","worker2_v2"]' }); + assert.sameMembers(await cli.smembersAsync('workers-available-blizzard'), ['worker0_v2']); + assert.sameMembers(await cli.smembersAsync('workers-available-funkytown'), ['worker1_v2', 'worker2_v2']); + assert.sameMembers(await cli.smembersAsync('workers-available-default'), ['worker3_v2', 'worker4_v2']); + assert.sameMembers(await cli.smembersAsync('workers-available'), + ['worker0_v2', 'worker1_v2', 'worker2_v2', 'worker3_v2', 'worker4_v2']); + for (let i = 0; i < 20; i++) { + const assignment = await workers.assignDocWorker(`blizzard${i}`); + assert.equal(assignment.docWorker.id, 'worker0_v2'); + } + for (let i = 0; i < 20; i++) { + const assignment = await workers.assignDocWorker(`funkytown${i}`); + assert.include(['worker1_v2', 'worker2_v2'], assignment.docWorker.id); + } + for (let i = 0; i < 20; i++) { + const assignment = await workers.assignDocWorker(`random${i}`); + assert.include(['worker3_v2', 'worker4_v2'], assignment.docWorker.id); + } + + // check everything about previous deployment got cleaned up + assert.equal(await cli.hgetallAsync('elections-ver1'), null); + }); + + it('can assign workers to groups', async function() { + this.timeout(5000); + const workers = new DocWorkerMap([cli], 'ver1'); + + // Register a few regular workers. + for (let i = 0; i < 3; i++) { + await workers.addWorker({id: `worker${i}`, internalUrl: 'internal', publicUrl: 'public'}); + await workers.setWorkerAvailability(`worker${i}`, true); + } + + // Register a worker in a special group. + await workers.addWorker({id: 'worker_secondary', internalUrl: 'internal', publicUrl: 'public', + group: 'secondary'}); + await workers.setWorkerAvailability('worker_secondary', true); + + // Check that worker lists look sane. + assert.sameMembers(await cli.smembersAsync('workers'), + ['worker0', 'worker1', 'worker2', 'worker_secondary']); + assert.sameMembers(await cli.smembersAsync('workers-available'), + ['worker0', 'worker1', 'worker2']); + assert.sameMembers(await cli.smembersAsync('workers-available-default'), + ['worker0', 'worker1', 'worker2']); + assert.sameMembers(await cli.smembersAsync('workers-available-secondary'), + ['worker_secondary']); + + // Check that worker-*-group keys are as expected. + assert.equal(await cli.getAsync('worker-worker_secondary-group'), 'secondary'); + assert.equal(await cli.getAsync('worker-worker0-group'), null); + + // Check that a doc for the special group is assigned to the correct worker. + await cli.setAsync('doc-funkydoc-group', 'secondary'); + assert.equal((await workers.assignDocWorker('funkydoc')).docWorker.id, 'worker_secondary'); + + // Check that other docs don't end up on the special group's worker. + for (let i = 0; i < 50; i++) { + assert.match((await workers.assignDocWorker(`normaldoc${i}`)).docWorker.id, + /^worker\d$/); + } + }); + + it('can manage task election nominations', async function() { + this.timeout(5000); + + const store = new DocWorkerMap([cli]); + // allocate two tasks + const task1 = await store.getElection('task1', 1000); + let task2 = await store.getElection('task2', 1000); + assert.notEqual(task1, null); + assert.notEqual(task2, null); + assert.notEqual(task1, task2); + + // check tasks cannot be immediately reallocated + assert.equal(await store.getElection('task1', 1000), null); + assert.equal(await store.getElection('task2', 1000), null); + + // try to remove both tasks with a key that is correct for just one of them. + await assert.isRejected(store.removeElection('task1', task2!), /could not remove/); + await store.removeElection('task2', task2!); + + // check task2 is freed up by reallocating it + task2 = await store.getElection('task2', 3000); + assert.notEqual(task2, null); + + await delay(1100); + + // task1 should be free now, but not task2 + const task1b = await store.getElection('task1', 1000); + assert.notEqual(task1b, null); + assert.notEqual(task1b, task1); + assert.equal(await store.getElection('task2', 1000), null); + }); + + it('can manage permits', async function() { + const store = new DocWorkerMap([cli], undefined, {permitMsec: 1000}).getPermitStore('1'); + + // Make a doc permit and a workspace permit + const permit1: Permit = {docId: 'docId1'}; + const key1 = await store.setPermit(permit1); + assert(key1.startsWith('permit-1-')); + const permit2: Permit = {workspaceId: 99}; + const key2 = await store.setPermit(permit2); + assert(key2.startsWith('permit-1-')); + assert.notEqual(key1, key2); + + // Check we can read the permits back + assert.deepEqual(await store.getPermit(key1), permit1); + assert.deepEqual(await store.getPermit(key2), permit2); + + // Check that random permit keys give nothing + await assert.isRejected(store.getPermit('dud'), /could not be read/); + assert.equal(await store.getPermit('permit-1-dud'), null); + + // Check that we can remove a permit + await store.removePermit(key1); + assert.equal(await store.getPermit(key1), null); + assert.deepEqual(await store.getPermit(key2), permit2); + + // Check that permits expire + await delay(1100); + assert.equal(await store.getPermit(key2), null); + + // make sure permit stores are distinct + const store2 = new DocWorkerMap([cli], undefined, {permitMsec: 1000}).getPermitStore('2'); + const key3 = await store2.setPermit(permit1); + assert(key3.startsWith('permit-2-')); + const fakeKey3 = key3.replace('permit-2-', 'permit-1-'); + assert(fakeKey3.startsWith('permit-1-')); + assert.equal(await store.getPermit(fakeKey3), null); + await assert.isRejected(store.getPermit(key3), /could not be read/); + assert.deepEqual(await store2.getPermit(key3), permit1); + await assert.isRejected(store2.getPermit(fakeKey3), /could not be read/); + }); + + describe('group assignment', function() { + let servers: {[key: string]: FlexServer}; + let workers: IDocWorkerMap; + before(async function() { + // Create a home server and some workers. + setUpDB(this); + await createInitialDb(); + const opts = {logToConsole: false, externalStorage: false}; + // We need to reset some environment variables - we do so + // naively, so throw if they are already set. + assert.equal(process.env.REDIS_URL, undefined); + assert.equal(process.env.GRIST_DOC_WORKER_ID, undefined); + assert.equal(process.env.GRIST_WORKER_GROUP, undefined); + process.env.REDIS_URL = process.env.TEST_REDIS_URL; + + // Make home server. + const home = await mergedServerMain(0, ['home'], opts); + + // Make a worker, not associated with any group. + process.env.GRIST_DOC_WORKER_ID = 'worker1'; + const docs1 = await mergedServerMain(0, ['docs'], opts); + + // Make a worker in "special" group. + process.env.GRIST_DOC_WORKER_ID = 'worker2'; + process.env.GRIST_WORKER_GROUP = 'special'; + const docs2 = await mergedServerMain(0, ['docs'], opts); + + // Make two worker in "other" group. + process.env.GRIST_DOC_WORKER_ID = 'worker3'; + process.env.GRIST_WORKER_GROUP = 'other'; + const docs3 = await mergedServerMain(0, ['docs'], opts); + process.env.GRIST_DOC_WORKER_ID = 'worker4'; + process.env.GRIST_WORKER_GROUP = 'other'; + const docs4 = await mergedServerMain(0, ['docs'], opts); + + servers = {home, docs1, docs2, docs3, docs4}; + workers = getDocWorkerMap(); + }); + + after(async function() { + if (servers) { + await Promise.all(Object.values(servers).map(server => server.close())); + await removeConnection(); + delete process.env.REDIS_URL; + delete process.env.GRIST_DOC_WORKER_ID; + delete process.env.GRIST_WORKER_GROUP; + await workers.close(); + } + }); + + it('can reassign documents between groups', async function() { + this.timeout(15000); + + // Create a test documment. + const session = new TestSession(servers.home!); + const api = await session.createHomeApi('chimpy', 'nasa'); + const supportApi = await session.createHomeApi('support', 'docs', true); + const ws1 = await api.newWorkspace({name: 'ws1'}, 'current'); + const doc1 = await api.newDoc({name: 'doc1'}, ws1); + + // Exercise it. + await api.getDocAPI(doc1).getRows('Table1'); + + // Check it is served by only unspecialized worker. + assert.equal((await workers.getDocWorker(doc1))?.docWorker.id, 'worker1'); + + // Set doc to "special" group. + await cli.setAsync(`doc-${doc1}-group`, 'special'); + + // Check doc gets reassigned to correct worker. + assert.equal(await (await api.testRequest(`${api.getBaseUrl()}/api/docs/${doc1}/assign`, { + method: 'POST' + })).json(), true); + await api.getDocAPI(doc1).getRows('Table1'); + assert.equal((await workers.getDocWorker(doc1))?.docWorker.id, 'worker2'); + + // Set doc to "other" group. + await cli.setAsync(`doc-${doc1}-group`, 'other'); + + // Check doc gets reassigned to one of the correct workers. + assert.equal(await (await api.testRequest(`${api.getBaseUrl()}/api/docs/${doc1}/assign`, { + method: 'POST' + })).json(), true); + await api.getDocAPI(doc1).getRows('Table1'); + assert.oneOf((await workers.getDocWorker(doc1))?.docWorker.id, ['worker3', 'worker4']); + + // Remove doc from groups. + await cli.delAsync(`doc-${doc1}-group`); + assert.equal(await (await api.testRequest(`${api.getBaseUrl()}/api/docs/${doc1}/assign`, { + method: 'POST' + })).json(), true); + await api.getDocAPI(doc1).getRows('Table1'); + + // Check doc is again served by only unspecialized worker. + assert.equal((await workers.getDocWorker(doc1))?.docWorker.id, 'worker1'); + + // Check that hitting /assign without a change of group is reported as no-op (false). + assert.equal(await (await api.testRequest(`${api.getBaseUrl()}/api/docs/${doc1}/assign`, { + method: 'POST' + })).json(), false); + + // Check that Chimpy can't use `group` param to update doc group prior to reassignment. + const urlWithGroup = new URL(`${api.getBaseUrl()}/api/docs/${doc1}/assign`); + urlWithGroup.searchParams.set('group', 'special'); + assert.equal(await (await api.testRequest(urlWithGroup.toString(), { + method: 'POST' + })).json(), false); + + // Check that support user can use `group` param in housekeeping endpoint to update + // doc group prior to reassignment. + const housekeepingUrl = new URL(`${api.getBaseUrl()}/api/housekeeping/docs/${doc1}/assign`); + housekeepingUrl.searchParams.set('group', 'special'); + assert.equal(await (await supportApi.testRequest(housekeepingUrl.toString(), { + method: 'POST' + })).json(), true); + await api.getDocAPI(doc1).getRows('Table1'); + assert.equal((await workers.getDocWorker(doc1))?.docWorker.id, 'worker2'); + + // Check that hitting housekeeping endpoint with the same group is reported as no-op (false). + assert.equal(await (await supportApi.testRequest(housekeepingUrl.toString(), { + method: 'POST' + })).json(), false); + + // Check that specifying a blank group reverts back to the unspecialized worker. + housekeepingUrl.searchParams.set('group', ''); + assert.equal(await (await supportApi.testRequest(housekeepingUrl.toString(), { + method: 'POST' + })).json(), true); + await api.getDocAPI(doc1).getRows('Table1'); + assert.equal((await workers.getDocWorker(doc1))?.docWorker.id, 'worker1'); + }); }); describe('isWorkerRegistered', () => { diff --git a/test/gen-server/lib/HealthCheck.ts b/test/gen-server/lib/HealthCheck.ts new file mode 100644 index 00000000..d2fa6441 --- /dev/null +++ b/test/gen-server/lib/HealthCheck.ts @@ -0,0 +1,116 @@ +import { assert } from 'chai'; +import fetch from 'node-fetch'; +import { TestServer } from 'test/gen-server/apiUtils'; +import { TcpForwarder } from 'test/server/tcpForwarder'; +import * as testUtils from 'test/server/testUtils'; +import { waitForIt } from 'test/server/wait'; + +describe('HealthCheck', function() { + testUtils.setTmpLogLevel('error'); + + for (const serverType of ['home', 'docs'] as Array<'home'|'docs'>) { + describe(serverType, function() { + let server: TestServer; + let oldEnv: testUtils.EnvironmentSnapshot; + let redisForwarder: TcpForwarder; + + before(async function() { + oldEnv = new testUtils.EnvironmentSnapshot(); + + // We set up Redis via a TcpForwarder, so that we can simulate disconnects. + if (!process.env.TEST_REDIS_URL) { + throw new Error("TEST_REDIS_URL is expected"); + } + const redisUrl = new URL(process.env.TEST_REDIS_URL); + const redisPort = parseInt(redisUrl.port, 10) || 6379; + redisForwarder = new TcpForwarder(redisPort, redisUrl.host); + const forwarderPort = await redisForwarder.pickForwarderPort(); + await redisForwarder.connect(); + + process.env.REDIS_URL = `redis://localhost:${forwarderPort}`; + server = new TestServer(this); + await server.start([serverType]); + }); + + after(async function() { + await server.stop(); + await redisForwarder.disconnect(); + oldEnv.restore(); + }); + + it('has a working simple /status endpoint', async function() { + const result = await fetch(server.server.getOwnUrl() + '/status'); + const text = await result.text(); + assert.match(text, /Grist server.*alive/); + assert.notMatch(text, /db|redis/); + assert.equal(result.ok, true); + assert.equal(result.status, 200); + }); + + it('allows asking for db and redis status', async function() { + const result = await fetch(server.server.getOwnUrl() + '/status?db=1&redis=1&timeout=500'); + assert.match(await result.text(), /Grist server.*alive.*db ok, redis ok/); + assert.equal(result.ok, true); + assert.equal(result.status, 200); + }); + + function blockPostgres(driver: any) { + // Make the database unhealthy by exausting the connection pool. This happens to be a way + // that has occurred in practice. + const blockers: Array> = []; + const resolvers: Array<() => void> = []; + for (let i = 0; i < driver.master.options.max; i++) { + const promise = new Promise((resolve) => { resolvers.push(resolve); }); + blockers.push(server.dbManager.connection.transaction((manager) => promise)); + } + return { + blockerPromise: Promise.all(blockers), + resolve: () => resolvers.forEach(resolve => resolve()), + }; + } + + it('reports error when database is unhealthy', async function() { + if (server.dbManager.connection.options.type !== 'postgres') { + // On postgres, we have a way to interfere with connections. Elsewhere (sqlite) it's not + // so obvious how to make DB unhealthy, so don't bother testing that. + this.skip(); + } + this.timeout(5000); + + const {blockerPromise, resolve} = blockPostgres(server.dbManager.connection.driver as any); + try { + const result = await fetch(server.server.getOwnUrl() + '/status?db=1&redis=1&timeout=500'); + assert.match(await result.text(), /Grist server.*unhealthy.*db not ok, redis ok/); + assert.equal(result.ok, false); + assert.equal(result.status, 500); + + // Plain /status endpoint should be unaffected. + assert.isTrue((await fetch(server.server.getOwnUrl() + '/status')).ok); + } finally { + resolve(); + await blockerPromise; + } + assert.isTrue((await fetch(server.server.getOwnUrl() + '/status?db=1&redis=1&timeout=100')).ok); + }); + + it('reports error when redis is unhealthy', async function() { + this.timeout(5000); + await redisForwarder.disconnect(); + try { + const result = await fetch(server.server.getOwnUrl() + '/status?db=1&redis=1&timeout=500'); + assert.match(await result.text(), /Grist server.*unhealthy.*db ok, redis not ok/); + assert.equal(result.ok, false); + assert.equal(result.status, 500); + + // Plain /status endpoint should be unaffected. + assert.isTrue((await fetch(server.server.getOwnUrl() + '/status')).ok); + } finally { + await redisForwarder.connect(); + } + await waitForIt(async () => + assert.isTrue((await fetch(server.server.getOwnUrl() + '/status?db=1&redis=1&timeout=100')).ok), + 2000); + }); + }); + } +}); diff --git a/test/gen-server/lib/HomeDBManager.ts b/test/gen-server/lib/HomeDBManager.ts new file mode 100644 index 00000000..77778fef --- /dev/null +++ b/test/gen-server/lib/HomeDBManager.ts @@ -0,0 +1,417 @@ +import {UserProfile} from 'app/common/LoginSessionAPI'; +import {HomeDBManager} from 'app/gen-server/lib/homedb/HomeDBManager'; +import {FREE_PLAN, STUB_PLAN, TEAM_PLAN} from 'app/common/Features'; +import {assert} from 'chai'; +import {TestServer} from 'test/gen-server/apiUtils'; +import * as testUtils from 'test/server/testUtils'; +import uuidv4 from 'uuid/v4'; +import omit = require('lodash/omit'); + +const charonProfile = {email: 'charon@getgrist.com', name: 'Charon'}; +const chimpyProfile = {email: 'chimpy@getgrist.com', name: 'Chimpy'}; +const kiwiProfile = {email: 'kiwi@getgrist.com', name: 'Kiwi'}; + +const teamOptions = { + setUserAsOwner: false, useNewPlan: true, product: TEAM_PLAN +}; + +describe('HomeDBManager', function() { + + let server: TestServer; + let home: HomeDBManager; + testUtils.setTmpLogLevel('error'); + + before(async function() { + server = new TestServer(this); + await server.start(); + home = server.dbManager; + }); + + after(async function() { + await server.stop(); + }); + + it('can find existing user by email', async function() { + const user = await home.getUserByLogin('chimpy@getgrist.com'); + assert.equal(user!.name, 'Chimpy'); + }); + + it('can create new user by email, with personal org', async function() { + const profile = {email: 'unseen@getgrist.com', name: 'Unseen'}; + const user = await home.getUserByLogin('unseen@getgrist.com', {profile}); + assert.equal(user!.name, 'Unseen'); + const orgs = await home.getOrgs(user!.id, null); + assert.isAtLeast(orgs.data!.length, 1); + assert.equal(orgs.data![0].name, 'Personal'); + assert.equal(orgs.data![0].owner.name, 'Unseen'); + }); + + it('parallel requests resulting in user creation give consistent results', async function() { + const profile = { + email: uuidv4() + "@getgrist.com", + name: "Testy McTestyTest" + }; + const queries = []; + for (let i = 0; i < 100; i++) { + queries.push(home.getUserByLoginWithRetry(profile.email, {profile})); + } + const result = await Promise.all(queries); + const refUser = result[0]; + assert(refUser && refUser.personalOrg && refUser.id && refUser.personalOrg.id); + result.forEach((user) => assert.deepEqual(refUser, user)); + }); + + it('can accumulate profile information', async function() { + // log in without a name + let user = await home.getUserByLogin('unseen2@getgrist.com'); + // name is blank + assert.equal(user!.name, ''); + // log in with a name + const profile: UserProfile = {email: 'unseen2@getgrist.com', name: 'Unseen2'}; + user = await home.getUserByLogin('unseen2@getgrist.com', {profile}); + // name is now set + assert.equal(user!.name, 'Unseen2'); + // log in without a name + user = await home.getUserByLogin('unseen2@getgrist.com'); + // name is still set + assert.equal(user!.name, 'Unseen2'); + // no picture yet + assert.equal(user!.picture, null); + // log in with picture link + profile.picture = 'http://picture.pic'; + user = await home.getUserByLogin('unseen2@getgrist.com', {profile}); + // now should have a picture link + assert.equal(user!.picture, 'http://picture.pic'); + // log in without picture + user = await home.getUserByLogin('unseen2@getgrist.com'); + // should still have picture link + assert.equal(user!.picture, 'http://picture.pic'); + }); + + it('can add an org', async function() { + const user = await home.getUserByLogin('chimpy@getgrist.com'); + const orgId = (await home.addOrg(user!, {name: 'NewOrg', domain: 'novel-org'}, teamOptions)).data!; + const org = await home.getOrg({userId: user!.id}, orgId); + assert.equal(org.data!.name, 'NewOrg'); + assert.equal(org.data!.domain, 'novel-org'); + assert.equal(org.data!.billingAccount.product.name, TEAM_PLAN); + await home.deleteOrg({userId: user!.id}, orgId); + }); + + it('creates default plan if defined', async function() { + const user = await home.getUserByLogin('chimpy@getgrist.com'); + const oldEnv = new testUtils.EnvironmentSnapshot(); + try { + // Set the default product to be the free plan. + process.env.GRIST_DEFAULT_PRODUCT = FREE_PLAN; + let orgId = (await home.addOrg(user!, {name: 'NewOrg', domain: 'novel-org'}, { + setUserAsOwner: false, + useNewPlan: true, + // omit plan, to use a default one (teamInitial) + // it will either be 'stub' or anything set in GRIST_DEFAULT_PRODUCT + })).data!; + let org = await home.getOrg({userId: user!.id}, orgId); + assert.equal(org.data!.name, 'NewOrg'); + assert.equal(org.data!.domain, 'novel-org'); + assert.equal(org.data!.billingAccount.product.name, FREE_PLAN); + await home.deleteOrg({userId: user!.id}, orgId); + + // Now remove the default product, and check that the default plan is used. + delete process.env.GRIST_DEFAULT_PRODUCT; + orgId = (await home.addOrg(user!, {name: 'NewOrg', domain: 'novel-org'}, { + setUserAsOwner: false, + useNewPlan: true, + })).data!; + + org = await home.getOrg({userId: user!.id}, orgId); + assert.equal(org.data!.billingAccount.product.name, STUB_PLAN); + await home.deleteOrg({userId: user!.id}, orgId); + } finally { + oldEnv.restore(); + } + }); + + it('cannot duplicate a domain', async function() { + const user = await home.getUserByLogin('chimpy@getgrist.com'); + const domain = 'repeated-domain'; + const result = await home.addOrg(user!, {name: `${domain}!`, domain}, teamOptions); + const orgId = result.data!; + assert.equal(result.status, 200); + await assert.isRejected(home.addOrg(user!, {name: `${domain}!`, domain}, teamOptions), + /Domain already in use/); + await home.deleteOrg({userId: user!.id}, orgId); + }); + + it('cannot add an org with a (blacklisted) dodgy domain', async function() { + const user = await home.getUserByLogin('chimpy@getgrist.com'); + const userId = user!.id; + const misses = [ + 'thing!', ' thing', 'ww', 'docs-999', 'o-99', '_domainkey', 'www', 'api', + 'thissubdomainiswaytoolongmyfriendyoushouldrethinkitoratleastsummarizeit', + 'google', 'login', 'doc-worker-1-1-1-1', 'a', 'bb', 'x_y', '1ogin' + ]; + const hits = [ + 'thing', 'jpl', 'xyz', 'appel', '123', '1google' + ]; + for (const domain of misses) { + const result = await home.addOrg(user!, {name: `${domain}!`, domain}, teamOptions); + assert.equal(result.status, 400); + const org = await home.getOrg({userId}, domain); + assert.equal(org.status, 404); + } + for (const domain of hits) { + const result = await home.addOrg(user!, {name: `${domain}!`, domain}, teamOptions); + assert.equal(result.status, 200); + const org = await home.getOrg({userId}, domain); + assert.equal(org.status, 200); + await home.deleteOrg({userId}, org.data!.id); + } + }); + + it('should allow setting doc metadata', async function() { + const beforeRun = new Date(); + const setDateISO1 = new Date(Date.UTC(1993, 3, 2)).toISOString(); + const setDateISO2 = new Date(Date.UTC(2004, 6, 18)).toISOString(); + const setUsage1 = {rowCount: {total: 123}, dataSizeBytes: 456, attachmentsSizeBytes: 789}; + const setUsage2 = {rowCount: {total: 0}, attachmentsSizeBytes: 0}; + + // Set the doc updatedAt time on Bananas. + const primatelyOrgId = await home.testGetId('Primately') as number; + const fishOrgId = await home.testGetId('Fish') as number; + const applesDocId = await home.testGetId('Apples') as string; + const bananasDocId = await home.testGetId('Bananas') as string; + const sharkDocId = await home.testGetId('Shark') as string; + await home.setDocsMetadata({ + [applesDocId]: {usage: setUsage1}, + [bananasDocId]: {updatedAt: setDateISO1}, + [sharkDocId]: {updatedAt: setDateISO2, usage: setUsage2}, + }); + + // Fetch the doc and check that the updatedAt value is as expected. + const kiwi = await home.getUserByLogin('kiwi@getgrist.com'); + const resp1 = await home.getOrgWorkspaces({userId: kiwi!.id}, primatelyOrgId); + assert.equal(resp1.status, 200); + + // Check that the apples metadata is as expected. updatedAt should have been set + // when the db was initialized before the update run - it should not have been updated + // to 1993. usage should be set. + const apples = resp1.data![0].docs.find((doc: any) => doc.name === 'Apples'); + const applesUpdate = new Date(apples!.updatedAt); + assert.isTrue(applesUpdate < beforeRun); + assert.isTrue(applesUpdate > new Date('2000-1-1')); + assert.deepEqual(apples!.usage, setUsage1); + + // Check that the bananas metadata is as expected. updatedAt should have been set + // to 1993. usage should be null. + const bananas = resp1.data![0].docs.find((doc: any) => doc.name === 'Bananas'); + assert.equal(bananas!.updatedAt.toISOString(), setDateISO1); + assert.equal(bananas!.usage, null); + + // Check that the shark metadata is as expected. updatedAt should have been set + // to 2004. usage should be set. + const resp2 = await home.getOrgWorkspaces({userId: kiwi!.id}, fishOrgId); + assert.equal(resp2.status, 200); + const shark = resp2.data![0].docs.find((doc: any) => doc.name === 'Shark'); + assert.equal(shark!.updatedAt.toISOString(), setDateISO2); + assert.deepEqual(shark!.usage, setUsage2); + }); + + it("can pool orgs for two users", async function() { + const charonOrgs = (await home.getOrgs([charonProfile], null)).data!; + const kiwiOrgs = (await home.getOrgs([kiwiProfile], null)).data!; + const pooledOrgs = (await home.getOrgs([charonProfile, kiwiProfile], null)).data!; + // test there is some overlap + assert.isAbove(pooledOrgs.length, charonOrgs.length); + assert.isAbove(pooledOrgs.length, kiwiOrgs.length); + assert.isBelow(pooledOrgs.length, charonOrgs.length + kiwiOrgs.length); + // check specific orgs returned + assert.sameDeepMembers(charonOrgs.map(org => org.name), + ['Abyss', 'Fish', 'NASA', 'Charonland', 'Chimpyland']); + assert.sameDeepMembers(kiwiOrgs.map(org => org.name), + ['Fish', 'Flightless', 'Kiwiland', 'Primately']); + assert.sameDeepMembers(pooledOrgs.map(org => org.name), + ['Abyss', 'Fish', 'Flightless', 'NASA', 'Primately', 'Charonland', 'Chimpyland', 'Kiwiland']); + + // make sure if there are no profiles that we get no orgs + const emptyOrgs = (await home.getOrgs([], null)).data!; + assert.lengthOf(emptyOrgs, 0); + }); + + it("can pool orgs for three users", async function() { + const pooledOrgs = (await home.getOrgs([charonProfile, chimpyProfile, kiwiProfile], null)).data!; + assert.sameDeepMembers(pooledOrgs.map(org => org.name), [ + 'Abyss', + 'EmptyOrg', + 'EmptyWsOrg', + 'Fish', + 'Flightless', + 'FreeTeam', + 'NASA', + 'Primately', + 'TestDailyApiLimit', + 'Charonland', + 'Chimpyland', + 'Kiwiland', + ]); + }); + + it("can pool orgs for multiple users with non-normalized emails", async function() { + const refOrgs = (await home.getOrgs([charonProfile, kiwiProfile], null)).data!; + // Profiles in sessions can have email addresses with arbitrary capitalization. + const oddCharonProfile = {email: 'CharON@getgrist.COM', name: 'charON'}; + const oddKiwiProfile = {email: 'KIWI@getgrist.COM', name: 'KIwi'}; + const orgs = (await home.getOrgs([oddCharonProfile, kiwiProfile, oddKiwiProfile], null)).data!; + assert.deepEqual(refOrgs, orgs); + }); + + it('can get best user for accessing org', async function() { + let suggestion = await home.getBestUserForOrg([charonProfile, kiwiProfile], + await home.testGetId('Fish') as number); + assert.deepEqual(suggestion, { + id: await home.testGetId('Kiwi') as number, + email: kiwiProfile.email, + name: kiwiProfile.name, + access: 'editors', + perms: 15 + }); + suggestion = await home.getBestUserForOrg([charonProfile, kiwiProfile], + await home.testGetId('Abyss') as number); + assert.equal(suggestion!.email, charonProfile.email); + suggestion = await home.getBestUserForOrg([charonProfile, kiwiProfile], + await home.testGetId('EmptyOrg') as number); + assert.equal(suggestion, null); + }); + + it('skips picking a user for merged personal org', async function() { + // There isn't any particular way to favor one user over another when accessing + // the merged personal org. + assert.equal(await home.getBestUserForOrg([charonProfile, kiwiProfile], 0), null); + }); + + it('can access billingAccount for org', async function() { + await server.addBillingManager('Chimpy', 'nasa'); + const chimpyScope = {userId: await home.testGetId('Chimpy') as number}; + const charonScope = {userId: await home.testGetId('Charon') as number}; + + // billing account without orgs+managers + let billingAccount = await home.getBillingAccount(chimpyScope, 'nasa', false); + assert.hasAllKeys(billingAccount, + ['id', 'individual', 'inGoodStanding', 'status', 'stripeCustomerId', + 'stripeSubscriptionId', 'stripePlanId', 'product', 'paid', 'isManager', + 'externalId', 'externalOptions', 'features', 'paymentLink']); + + // billing account with orgs+managers + billingAccount = await home.getBillingAccount(chimpyScope, 'nasa', true); + assert.hasAllKeys(billingAccount, + ['id', 'individual', 'inGoodStanding', 'status', 'stripeCustomerId', + 'stripeSubscriptionId', 'stripePlanId', 'product', 'orgs', 'managers', /* <-- here */ + 'paid', 'externalId', 'externalOptions', 'features', 'paymentLink']); + + await assert.isRejected(home.getBillingAccount(charonScope, 'nasa', true), + /User does not have access to billing account/); + }); + + // TypeORM does not handle parameter name reuse well, so we monkey-patch to detect it. + it('will fail on parameter collision', async function() { + // Check collision in a simple query. + // Note: it is query construction that fails, not query execution. + assert.throws(() => home.connection.createQueryBuilder().from('orgs', 'orgs') + .where('id = :id', {id: 1}).andWhere('id = :id', {id: 2}), + /parameter collision/); + + // Check collision between subqueries. + assert.throws( + () => home.connection.createQueryBuilder().from('orgs', 'orgs') + .select(q => q.subQuery().from('orgs', 'orgs').where('x IN :x', {x: ['five']})) + .addSelect(q => q.subQuery().from('orgs', 'orgs').where('x IN :x', {x: ['six']})), + /parameter collision/); + }); + + it('can get the product associated with a docId', async function() { + const urlId = 'sampledocid_6'; + const userId = await home.testGetId('Chimpy') as number; + const scope = {userId, urlId}; + const doc = await home.getDoc(scope); + const product = (await home.getDocProduct(urlId))!; + assert.equal(doc.workspace.org.billingAccount.product.id, product.id); + const features = await home.getDocFeatures(urlId); + assert.deepEqual(features, {workspaces: true, vanityDomain: true}); + }); + + it('can fork docs', async function() { + const user1 = await home.getUserByLogin('kiwi@getgrist.com'); + const user1Id = user1!.id; + const orgId = await home.testGetId('Fish') as number; + const doc1Id = await home.testGetId('Shark') as string; + const scope = {userId: user1Id, urlId: doc1Id}; + const doc1 = await home.getDoc(scope); + + // Document "Shark" should initially have no forks. + const resp1 = await home.getOrgWorkspaces({userId: user1Id}, orgId); + const resp1Doc = resp1.data![0].docs.find((d: any) => d.name === 'Shark'); + assert.deepEqual(resp1Doc!.forks, []); + + // Fork "Shark" as Kiwi and check that their fork is listed. + const fork1Id = `${doc1Id}_fork_1`; + await home.forkDoc(user1Id, doc1, fork1Id); + const resp2 = await home.getOrgWorkspaces({userId: user1Id}, orgId); + const resp2Doc = resp2.data![0].docs.find((d: any) => d.name === 'Shark'); + assert.deepEqual( + resp2Doc!.forks.map((fork: any) => omit(fork, 'updatedAt')), + [ + { + id: fork1Id, + trunkId: doc1Id, + createdBy: user1Id, + options: null, + }, + ] + ); + + // Fork "Shark" again and check that Kiwi can see both forks. + const fork2Id = `${doc1Id}_fork_2`; + await home.forkDoc(user1Id, doc1, fork2Id); + const resp3 = await home.getOrgWorkspaces({userId: user1Id}, orgId); + const resp3Doc = resp3.data![0].docs.find((d: any) => d.name === 'Shark'); + assert.sameDeepMembers( + resp3Doc!.forks.map((fork: any) => omit(fork, 'updatedAt')), + [ + { + id: fork1Id, + trunkId: doc1Id, + createdBy: user1Id, + options: null, + }, + { + id: fork2Id, + trunkId: doc1Id, + createdBy: user1Id, + options: null, + }, + ] + ); + + // Now fork "Shark" as Chimpy, and check that Kiwi's forks aren't listed. + const user2 = await home.getUserByLogin('chimpy@getgrist.com'); + const user2Id = user2!.id; + const resp4 = await home.getOrgWorkspaces({userId: user2Id}, orgId); + const resp4Doc = resp4.data![0].docs.find((d: any) => d.name === 'Shark'); + assert.deepEqual(resp4Doc!.forks, []); + + const fork3Id = `${doc1Id}_fork_3`; + await home.forkDoc(user2Id, doc1, fork3Id); + const resp5 = await home.getOrgWorkspaces({userId: user2Id}, orgId); + const resp5Doc = resp5.data![0].docs.find((d: any) => d.name === 'Shark'); + assert.deepEqual( + resp5Doc!.forks.map((fork: any) => omit(fork, 'updatedAt')), + [ + { + id: fork3Id, + trunkId: doc1Id, + createdBy: user2Id, + options: null, + }, + ] + ); + }); +}); diff --git a/test/gen-server/lib/Housekeeper.ts b/test/gen-server/lib/Housekeeper.ts new file mode 100644 index 00000000..929b2c8b --- /dev/null +++ b/test/gen-server/lib/Housekeeper.ts @@ -0,0 +1,207 @@ +import { TelemetryEvent, TelemetryMetadataByLevel } from 'app/common/Telemetry'; +import { Document } from 'app/gen-server/entity/Document'; +import { Workspace } from 'app/gen-server/entity/Workspace'; +import { Housekeeper } from 'app/gen-server/lib/Housekeeper'; +import { Telemetry } from 'app/server/lib/Telemetry'; +import { assert } from 'chai'; +import * as fse from 'fs-extra'; +import moment from 'moment'; +import * as sinon from 'sinon'; +import { TestServer } from 'test/gen-server/apiUtils'; +import { openClient } from 'test/server/gristClient'; +import * as testUtils from 'test/server/testUtils'; + +describe('Housekeeper', function() { + testUtils.setTmpLogLevel('error'); + this.timeout(60000); + + const org: string = 'testy'; + const sandbox = sinon.createSandbox(); + let home: TestServer; + let keeper: Housekeeper; + + before(async function() { + home = new TestServer(this); + await home.start(['home', 'docs']); + const api = await home.createHomeApi('chimpy', 'docs'); + await api.newOrg({name: org, domain: org}); + keeper = home.server.housekeeper; + await keeper.stop(); + }); + + after(async function() { + await home.stop(); + sandbox.restore(); + }); + + async function getDoc(docId: string) { + const manager = home.dbManager.connection.manager; + return manager.findOneOrFail(Document, {where: {id: docId}}); + } + + async function getWorkspace(wsId: number) { + const manager = home.dbManager.connection.manager; + return manager.findOneOrFail(Workspace, {where: {id: wsId}}); + } + + + function daysAgo(days: number): Date { + return moment().subtract(days, 'days').toDate(); + } + + async function ageDoc(docId: string, days: number) { + const dbDoc = await getDoc(docId); + dbDoc.removedAt = daysAgo(days); + await dbDoc.save(); + } + + async function ageWorkspace(wsId: number, days: number) { + const dbWorkspace = await getWorkspace(wsId); + dbWorkspace.removedAt = daysAgo(days); + await dbWorkspace.save(); + } + + async function ageFork(forkId: string, days: number) { + const dbFork = await getDoc(forkId); + dbFork.updatedAt = daysAgo(days); + await dbFork.save(); + } + + it('can delete old soft-deleted docs and workspaces', async function() { + // Make four docs in one workspace, two in another. + const api = await home.createHomeApi('chimpy', org); + const ws1 = await api.newWorkspace({name: 'ws1'}, 'current'); + const ws2 = await api.newWorkspace({name: 'ws2'}, 'current'); + const doc11 = await api.newDoc({name: 'doc11'}, ws1); + const doc12 = await api.newDoc({name: 'doc12'}, ws1); + const doc13 = await api.newDoc({name: 'doc13'}, ws1); + const doc14 = await api.newDoc({name: 'doc14'}, ws1); + const doc21 = await api.newDoc({name: 'doc21'}, ws2); + const doc22 = await api.newDoc({name: 'doc22'}, ws2); + + // Soft-delete some of the docs, and one workspace. + await api.softDeleteDoc(doc11); + await api.softDeleteDoc(doc12); + await api.softDeleteDoc(doc13); + await api.softDeleteWorkspace(ws2); + + // Check that nothing is deleted by housekeeper. + await keeper.deleteTrash(); + await assert.isFulfilled(getDoc(doc11)); + await assert.isFulfilled(getDoc(doc12)); + await assert.isFulfilled(getDoc(doc13)); + await assert.isFulfilled(getDoc(doc14)); + await assert.isFulfilled(getDoc(doc21)); + await assert.isFulfilled(getDoc(doc22)); + await assert.isFulfilled(getWorkspace(ws1)); + await assert.isFulfilled(getWorkspace(ws2)); + + // Age a doc and workspace somewhat, but not enough to trigger hard-deletion. + await ageDoc(doc11, 10); + await ageWorkspace(ws2, 20); + await keeper.deleteTrash(); + await assert.isFulfilled(getDoc(doc11)); + await assert.isFulfilled(getWorkspace(ws2)); + + // Prematurely age two of the soft-deleted docs, and the soft-deleted workspace. + await ageDoc(doc11, 40); + await ageDoc(doc12, 40); + await ageWorkspace(ws2, 40); + + // Make sure that exactly those docs are deleted by housekeeper. + await keeper.deleteTrash(); + await assert.isRejected(getDoc(doc11)); + await assert.isRejected(getDoc(doc12)); + await assert.isFulfilled(getDoc(doc13)); + await assert.isFulfilled(getDoc(doc14)); + await assert.isRejected(getDoc(doc21)); + await assert.isRejected(getDoc(doc22)); + await assert.isFulfilled(getWorkspace(ws1)); + await assert.isRejected(getWorkspace(ws2)); + }); + + it('enforces exclusivity of housekeeping', async function() { + const first = keeper.deleteTrashExclusively(); + const second = keeper.deleteTrashExclusively(); + assert.equal(await first, true); + assert.equal(await second, false); + assert.equal(await keeper.deleteTrashExclusively(), false); + await keeper.testClearExclusivity(); + assert.equal(await keeper.deleteTrashExclusively(), true); + }); + + it('can delete old forks', async function() { + // Make a document with some forks. + const api = await home.createHomeApi('chimpy', org); + const ws3 = await api.newWorkspace({name: 'ws3'}, 'current'); + const trunk = await api.newDoc({name: 'trunk'}, ws3); + const session = await api.getSessionActive(); + const client = await openClient(home.server, session.user.email, session.org?.domain || 'docs'); + await client.openDocOnConnect(trunk); + const forkResponse1 = await client.send('fork', 0); + const forkResponse2 = await client.send('fork', 0); + const forkPath1 = home.server.getStorageManager().getPath(forkResponse1.data.docId); + const forkPath2 = home.server.getStorageManager().getPath(forkResponse2.data.docId); + const forkId1 = forkResponse1.data.forkId; + const forkId2 = forkResponse2.data.forkId; + + // Age the forks somewhat, but not enough to trigger hard-deletion. + await ageFork(forkId1, 10); + await ageFork(forkId2, 20); + await keeper.deleteTrash(); + await assert.isFulfilled(getDoc(forkId1)); + await assert.isFulfilled(getDoc(forkId2)); + assert.equal(await fse.pathExists(forkPath1), true); + assert.equal(await fse.pathExists(forkPath2), true); + + // Age one of the forks beyond the cleanup threshold. + await ageFork(forkId2, 40); + + // Make sure that only that fork is deleted by housekeeper. + await keeper.deleteTrash(); + await assert.isFulfilled(getDoc(forkId1)); + await assert.isRejected(getDoc(forkId2)); + assert.equal(await fse.pathExists(forkPath1), true); + assert.equal(await fse.pathExists(forkPath2), false); + }); + + it('can log metrics about sites', async function() { + const logMessages: [TelemetryEvent, TelemetryMetadataByLevel?][] = []; + sandbox.stub(Telemetry.prototype, 'shouldLogEvent').callsFake((name) => true); + sandbox.stub(Telemetry.prototype, 'logEvent').callsFake((_, name, meta) => { + // Skip document usage events that could be arriving in the + // middle of this test. + if (name !== 'documentUsage') { + logMessages.push([name, meta]); + } + return Promise.resolve(); + }); + await keeper.logMetrics(); + assert.isNotEmpty(logMessages); + let [event, meta] = logMessages[0]; + assert.equal(event, 'siteUsage'); + assert.hasAllKeys(meta?.limited, [ + 'siteId', + 'siteType', + 'inGoodStanding', + 'numDocs', + 'numWorkspaces', + 'numMembers', + 'lastActivity', + 'earliestDocCreatedAt', + ]); + assert.hasAllKeys(meta?.full, [ + 'stripePlanId', + ]); + [event, meta] = logMessages[logMessages.length - 1]; + assert.equal(event, 'siteMembership'); + assert.hasAllKeys(meta?.limited, [ + 'siteId', + 'siteType', + 'numOwners', + 'numEditors', + 'numViewers', + ]); + assert.isUndefined(meta?.full); + }); +}); diff --git a/test/gen-server/lib/emails.ts b/test/gen-server/lib/emails.ts new file mode 100644 index 00000000..aae3957c --- /dev/null +++ b/test/gen-server/lib/emails.ts @@ -0,0 +1,160 @@ +import {PermissionData, PermissionDelta} from 'app/common/UserAPI'; +import axios from 'axios'; +import {assert} from 'chai'; +import {TestServer} from 'test/gen-server/apiUtils'; +import {configForUser} from 'test/gen-server/testUtils'; +import * as testUtils from 'test/server/testUtils'; + +describe('emails', function() { + + let server: TestServer; + let serverUrl: string; + testUtils.setTmpLogLevel('error'); + + const regular = 'chimpy@getgrist.com'; + const variant = 'Chimpy@GETgrist.com'; + const apiKey = configForUser('Chimpy'); + let ref: (email: string) => Promise; + + beforeEach(async function() { + this.timeout(5000); + server = new TestServer(this); + ref = (email: string) => server.dbManager.getUserByLogin(email).then((user) => user!.ref); + serverUrl = await server.start(); + }); + + afterEach(async function() { + await server.stop(); + }); + + it('email capitalization from provider is sticky', async function() { + let cookie = await server.getCookieLogin('nasa', {email: regular, name: 'Chimpy'}); + const userRef = await ref(regular); + // profile starts off with chimpy@ email + let resp = await axios.get(`${serverUrl}/o/nasa/api/profile/user`, cookie); + assert.equal(resp.status, 200); + assert.deepEqual(resp.data, { + id: 1, email: regular, name: "Chimpy", ref: userRef, picture: null, allowGoogleLogin: true + }); + + // now we log in with simulated provider giving a Chimpy@ capitalization. + cookie = await server.getCookieLogin('nasa', {email: variant, name: 'Chimpy'}); + resp = await axios.get(`${serverUrl}/o/nasa/api/profile/user`, cookie); + assert.equal(resp.status, 200); + // Chimpy@ is now what we see in our profile, but our id is still the same. + assert.deepEqual(resp.data, { + id: 1, email: variant, loginEmail: regular, name: "Chimpy", ref: userRef, picture: null, allowGoogleLogin: true + }); + + // read our profile with api key (no session involved) and make sure result is the same. + resp = await axios.get(`${serverUrl}/api/profile/user`, apiKey); + assert.equal(resp.status, 200); + assert.deepEqual(resp.data, { + id: 1, email: variant, loginEmail: regular, name: "Chimpy", ref: userRef, picture: null, allowGoogleLogin: true + }); + + }); + + it('access endpoints show and accept display emails', async function() { + // emails are used in access endpoints - make sure they provide the display email. + + const resources = [ + { type: 'orgs', id: await server.dbManager.testGetId('NASA') }, + { type: 'workspaces', id: await server.dbManager.testGetId('Horizon') }, + { type: 'docs', id: await server.dbManager.testGetId('Jupiter') }, + ] as const; + + for (const res of resources) { + // initially, should report regular chimpy address + const resp = await axios.get(`${serverUrl}/api/${res.type}/${res.id}/access`, apiKey); + assert.equal(resp.status, 200); + const delta: PermissionData = resp.data; + assert.notInclude(delta.users.map(u => u.email), variant); + assert.include(delta.users.map(u => u.email), regular); + } + + const cookie = await server.getCookieLogin('nasa', {email: variant, name: 'Chimpy'}); + await axios.get(`${serverUrl}/o/nasa/api/orgs`, cookie); + + for (const res of resources) { + // now, should report variant chimpy address + let resp = await axios.get(`${serverUrl}/api/${res.type}/${res.id}/access`, apiKey); + assert.equal(resp.status, 200); + const delta: PermissionData = resp.data; + assert.include(delta.users.map(u => u.email), variant); + assert.notInclude(delta.users.map(u => u.email), regular); + + // and make sure arbitrary capitalization is accepted and effective. + const delta2: {delta: PermissionDelta} = { + delta: { + users: { + 'chImPy@getGRIst.com': 'viewers' + } + } + }; + resp = await axios.patch(`${serverUrl}/api/${res.type}/${res.id}/access`, delta2, apiKey); + // expect an error complaining about not being able to change own permissions. + assert.match(resp.data.error, /own permissions/); + } + }); + + it('PATCH access endpoints behave reasonably when multiple versions of email given', async function() { + const orgId = await server.dbManager.testGetId('NASA'); + + let resp = await axios.get(`${serverUrl}/api/orgs/${orgId}/access`, apiKey); + assert.deepEqual(resp.data, { users: [ + { + id: 1, + name: 'Chimpy', + email: 'chimpy@getgrist.com', + ref: await ref('chimpy@getgrist.com'), + picture: null, + access: 'owners', + isMember: true, + }, + { + id: 3, + name: 'Charon', + email: 'charon@getgrist.com', + ref: await ref('charon@getgrist.com'), + picture: null, + access: 'guests', + isMember: false, + }, + ]}); + + const delta: {delta: PermissionDelta} = { + delta: { + users: { + 'kiWI@getGRIst.com': 'viewers', + 'KIwi@getgrist.com': 'editors', + 'charON@getgrist.com': null, + } + } + }; + resp = await axios.patch(`${serverUrl}/api/orgs/${orgId}/access`, delta, apiKey); + assert.equal(resp.status, 200); + + resp = await axios.get(`${serverUrl}/api/orgs/${orgId}/access`, apiKey); + assert.deepEqual(resp.data, { users: [ + { + id: 1, + name: 'Chimpy', + email: 'chimpy@getgrist.com', + ref: await ref('chimpy@getgrist.com'), + picture: null, + access: 'owners', + isMember: true, + }, + { + id: 2, + name: 'Kiwi', + email: 'kiwi@getgrist.com', + ref: await ref('kiwi@getgrist.com'), + picture: null, + access: 'editors', + isMember: true, + }, + ]}); + }); +}); diff --git a/test/gen-server/lib/everyone.ts b/test/gen-server/lib/everyone.ts new file mode 100644 index 00000000..d42635b1 --- /dev/null +++ b/test/gen-server/lib/everyone.ts @@ -0,0 +1,179 @@ +import {Workspace} from 'app/common/UserAPI'; +import {assert} from 'chai'; +import {TestServer} from 'test/gen-server/apiUtils'; +import * as testUtils from 'test/server/testUtils'; + +describe('everyone', function() { + let home: TestServer; + testUtils.setTmpLogLevel('error'); + + before(async function() { + home = new TestServer(this); + await home.start(); + }); + + after(async function() { + await home.stop(); + }); + + /** + * Assert that the specified workspaces and their material are public, + * and that all other workspaces are not. + */ + async function assertPublic(wss: Workspace[], publicWorkspaces: string[]) { + for (const ws of wss) { + const expectedPublic = publicWorkspaces.includes(ws.name) || undefined; + assert.equal(ws.public, expectedPublic); + for (const doc of ws.docs) { + assert.equal(doc.public, expectedPublic); + } + } + } + + it('support account can share a listed workspace with all users', async function() { + + // Share a workspace in support's personal org with everyone + let api = await home.createHomeApi('Support', 'docs'); + await home.upgradePersonalOrg('Support'); + const wsId = await api.newWorkspace({name: 'Samples'}, 'current'); + const docId = await api.newDoc({name: 'an example'}, wsId); + await api.updateWorkspacePermissions(wsId, { + users: {'everyone@getgrist.com': 'viewers', + 'anon@getgrist.com': 'viewers'} + }); + + // Check a fresh user can see that workspace + const altApi = await home.createHomeApi('testuser', 'docs'); + let wss = await altApi.getOrgWorkspaces('current'); + assert.deepEqual(wss.map(ws => ws.name), ['Home', 'Samples']); + assert.deepEqual(wss[1].docs.map(doc => doc.id), [docId]); + + // Check that public flag is set in everything the fresh user can see outside its Home. + await assertPublic(wss, ['Samples']); + + // Check existing users can see that workspace + const chimpyApi = await home.createHomeApi('Chimpy', 'docs'); + wss = await chimpyApi.getOrgWorkspaces('current'); + assert.deepEqual(wss.map(ws => ws.name), ['Private', 'Public', 'Samples']); + assert.deepEqual(wss.map(ws => ws.isSupportWorkspace), [false, false, true]); + // Public and Private could be in either order, but Samples should be last + // (api returns workspaces in chronological order). + assert.equal(wss[2].name, 'Samples'); + assert.deepEqual(wss[2].docs.map(doc => doc.id), [docId]); + await assertPublic(wss, ['Samples']); + + // Check that workspace also shows up in regular orgs + const nasaApi = await home.createHomeApi('Chimpy', 'nasa'); + wss = await nasaApi.getOrgWorkspaces('current'); + assert.deepEqual(wss.map(ws => ws.name), ['Horizon', 'Rovers', 'Samples']); + assert.deepEqual(wss.map(ws => ws.isSupportWorkspace), [false, false, true]); + await assertPublic(wss, ['Samples']); + + // Need to recreate api because of cookies + api = await home.createHomeApi('Support', 'docs'); + await api.deleteWorkspace(wsId); + }); + + it('can share unlisted docs in personal org with all users', async function() { + const api = await home.createHomeApi('Supportish', 'docs'); + await home.upgradePersonalOrg('Supportish'); + const wsId = await api.newWorkspace({name: 'Samples2'}, 'current'); + const docId = await api.newDoc({name: 'an example'}, wsId); + // Check other users cannot access the doc yet + const chimpyApi = await home.createHomeApi('Chimpy', 'docs', true); + await assert.isRejected(chimpyApi.getDoc(docId), /access denied/); + // Share doc with everyone + await api.updateDocPermissions(docId, { + users: {'everyone@getgrist.com': 'viewers'} + }); + // Check other users can access the doc now + assert.equal((await chimpyApi.getDoc(docId)).access, 'viewers'); + // Check that doc is marked as public + assert.equal((await chimpyApi.getDoc(docId)).public, true); + // Check they don't see doc listed + let wss = await chimpyApi.getOrgWorkspaces('current'); + assert.deepEqual(wss.map(ws => ws.name), ['Private', 'Public']); + + // Share every way possible via api + await api.updateWorkspacePermissions(wsId, { + users: {'everyone@getgrist.com': 'viewers'} + }); + await assert.isRejected(api.updateOrgPermissions(0, { + users: {'everyone@getgrist.com': 'viewers'} + }), /cannot share with everyone at top level/); + // Check existing users still don't see doc listed + wss = await chimpyApi.getOrgWorkspaces('current'); + assert.deepEqual(wss.map(ws => ws.name), ['Private', 'Public']); + }); + + it('can share unlisted docs in team sites with all users', async function() { + const chimpyApi = await home.createHomeApi('Chimpy', 'nasa', true); + const wsId = await chimpyApi.newWorkspace({name: 'Samples'}, 'current'); + const docId = await chimpyApi.newDoc({name: 'an example'}, wsId); + + // Check a fresh user cannot see that doc + const altApi = await home.createHomeApi('testuser', 'nasa', false, false); + await assert.isRejected(altApi.getDoc(docId), /access denied/i); + + // Share doc with everyone + await chimpyApi.updateDocPermissions(docId, { + users: {'everyone@getgrist.com': 'viewers'} + }); + + // Check a fresh user can now see that doc + await assert.isFulfilled(altApi.getDoc(docId)); + + // Check that doc is marked as public + assert.equal((await altApi.getDoc(docId)).public, true); + + // But can't list that doc in team site + await assert.isRejected(altApi.getOrgWorkspaces('current'), /access denied/); + + // Also can't list the doc in workspace + await assert.isRejected(altApi.getWorkspace(wsId), /access denied/); + }); + + it('can share public docs without them being listed indirectly', async function() { + const chimpyApi = await home.createHomeApi('Chimpy', 'nasa', true); + const wsId = await chimpyApi.newWorkspace({name: 'Samples'}, 'current'); + const docId = await chimpyApi.newDoc({name: 'an example'}, wsId); + const docId2 = await chimpyApi.newDoc({name: 'another example'}, wsId); + + // Share one doc with everyone + await chimpyApi.updateDocPermissions(docId, { + users: {'everyone@getgrist.com': 'viewers'} + }); + + // Share one doc with everyone, the other with a specific test user at the doc level + const altApi = await home.createHomeApi('testuser', 'nasa', false, false); + await chimpyApi.updateDocPermissions(docId, { + users: {'everyone@getgrist.com': 'viewers'} + }); + await chimpyApi.updateDocPermissions(docId2, { + users: {'testuser@getgrist.com': 'viewers'} + }); + + // Check test user can access both docs + await assert.isFulfilled(altApi.getDoc(docId)); + await assert.isFulfilled(altApi.getDoc(docId2)); + + // Check test user can only list the documents shared with them + // through a route other than public sharing + assert.deepEqual((await altApi.getOrgWorkspaces('current'))[0].docs.map(doc => doc.name), + ['another example']); + assert.deepEqual((await altApi.getWorkspace(wsId)).docs.map(doc => doc.name), + ['another example']); + + // Check that a viewer at org level can see all docs listed, and access them + // (there was a bug where a doc shared with everyone@ as viewer would get hidden + // from top-level viewers) + await chimpyApi.updateOrgPermissions('current', { + users: {'testuser2@getgrist.com': 'viewers'} + }); + const altApi2 = await home.createHomeApi('testuser2', 'nasa', false, false); + await assert.isFulfilled(altApi2.getDoc(docId)); + await assert.isFulfilled(altApi2.getDoc(docId2)); + assert.sameMembers((await altApi2.getWorkspace(wsId)).docs.map(doc => doc.name), + ['an example', 'another example']); + }); +}); diff --git a/test/gen-server/lib/limits.ts b/test/gen-server/lib/limits.ts new file mode 100644 index 00000000..669915f1 --- /dev/null +++ b/test/gen-server/lib/limits.ts @@ -0,0 +1,553 @@ +import {ApiError} from 'app/common/ApiError'; +import {Features} from 'app/common/Features'; +import {resetOrg} from 'app/common/resetOrg'; +import {UserAPI, UserAPIImpl} from 'app/common/UserAPI'; +import {BillingAccount} from 'app/gen-server/entity/BillingAccount'; +import {Organization} from 'app/gen-server/entity/Organization'; +import {Product} from 'app/gen-server/entity/Product'; +import {HomeDBManager} from 'app/gen-server/lib/homedb/HomeDBManager'; +import {GristObjCode} from 'app/plugin/GristData'; +import {assert} from 'chai'; +import { IOptions } from 'app/common/BaseAPI'; +import FormData from 'form-data'; +import fetch from 'node-fetch'; +import {TestServer} from 'test/gen-server/apiUtils'; +import {configForUser, createUser} from 'test/gen-server/testUtils'; +import * as testUtils from 'test/server/testUtils'; + +describe('limits', function() { + let home: TestServer; + let dbManager: HomeDBManager; + let homeUrl: string; + let product: Product; + let api: UserAPI; + let nasa: UserAPI; + + testUtils.setTmpLogLevel('error'); + + before(async function() { + home = new TestServer(this); + await home.start(["home", "docs"]); + + dbManager = home.dbManager; + homeUrl = home.serverUrl; + + // Create a test product + product = new Product(); + product.name = "test_product"; + product.features = {workspaces: true}; + await product.save(); + // Create a new user + const samHome = await createUser(dbManager, 'sam'); + // Overwrite default product + const billingId = samHome.billingAccount.id; + await dbManager.connection.createQueryBuilder() + .update(BillingAccount) + .set({product}) + .where('id = :billingId', {billingId}) + .execute(); + // Set up an api object tied to the user's personal org + api = new UserAPIImpl(`${homeUrl}/o/docs`, { + fetch: fetch as any, + newFormData: () => new FormData() as any, + ...configForUser('sam') as IOptions + }); + // Give chimpy access to this org + await api.updateOrgPermissions('current', {users: {'chimpy@getgrist.com': 'owners'}}); + // Set up an api object tied to nasa + nasa = new UserAPIImpl(`${homeUrl}/o/nasa`, { + fetch: fetch as any, + ...configForUser('chimpy') as IOptions + }); + }); + + after(async function() { + await home.stop(); + }); + + async function setFeatures(features: Features) { + product.features = features; + await product.save(); + } + + it('can enforce limits on number of workspaces', async function() { + await setFeatures({maxWorkspacesPerOrg: 2, workspaces: true}); + + // initially have just one workspace, the default workspace + // created for a new personal org. + assert.lengthOf(await api.getOrgWorkspaces('current'), 1); + await assert.isFulfilled(api.newWorkspace({name: 'work2'}, 'current')); + await assert.isRejected(api.newWorkspace({name: 'work3'}, 'current'), + /No more workspaces/); + + await setFeatures({maxWorkspacesPerOrg: 3, workspaces: true}); + await assert.isFulfilled(api.newWorkspace({name: 'work3'}, 'current')); + await assert.isRejected(api.newWorkspace({name: 'work4'}, 'current'), + /No more workspaces/); + + await setFeatures({workspaces: true}); + await assert.isFulfilled(api.newWorkspace({name: 'work4'}, 'current')); + + await setFeatures({maxWorkspacesPerOrg: 1, workspaces: true}); + await assert.isRejected(api.newWorkspace({name: 'work5'}, 'current'), + /No more workspaces/); + }); + + it('can enforce limits on number of workspace shares', async function() { + this.timeout(4000); + await setFeatures({maxSharesPerWorkspace: 3, workspaces: true}); + const wsId = await api.newWorkspace({name: 'work'}, 'docs'); + + // Adding 4 users would exceed 3 user limit + await assert.isRejected(api.updateWorkspacePermissions(wsId, { + users: { + 'user1@getgrist.com': 'owners', + 'user2@getgrist.com': 'viewers', + 'user3@getgrist.com': 'owners', + 'user4@getgrist.com': 'viewers', + } + }), /No more external workspace shares/); + + // Adding 1 user is ok + await assert.isFulfilled(api.updateWorkspacePermissions(wsId, { + users: {'user1@getgrist.com': 'owners'} + })); + + // Adding 2nd+3rd user is ok + await assert.isFulfilled(api.updateWorkspacePermissions(wsId, { + users: { + 'user2@getgrist.com': 'owners', + 'user3@getgrist.com': 'owners' + } + })); + + // Adding 4th user fails + await assert.isRejected(api.updateWorkspacePermissions(wsId, { + users: {'user4@getgrist.com': 'owners'} + }), /No more external workspace shares/); + + // Adding support user is ok + await assert.isFulfilled(api.updateWorkspacePermissions(wsId, { + users: {'support@getgrist.com': 'owners'} + })); + + // Replacing user is ok + await assert.isFulfilled(api.updateWorkspacePermissions(wsId, { + users: { + 'user2@getgrist.com': null, + 'user2b@getgrist.com': 'owners' + } + })); + + // Removing a user and adding another is ok + await assert.isFulfilled(api.updateWorkspacePermissions(wsId, { + users: {'user1@getgrist.com': null} + })); + await assert.isFulfilled(api.updateWorkspacePermissions(wsId, { + users: {'user1b@getgrist.com': 'owners'} + })); + await assert.isRejected(api.updateWorkspacePermissions(wsId, { + users: {'user5@getgrist.com': 'owners'} + }), /No more external workspace shares/); + + // Reduce to limit to allow just one share + await setFeatures({maxSharesPerWorkspace: 1, workspaces: true}); + + // Cannot add or replace users, since we are over limit + await assert.isRejected(api.updateWorkspacePermissions(wsId, { + users: { + 'user3@getgrist.com': null, + 'user3b@getgrist.com': 'owners' + } + }), /No more external workspace shares/); + + // Can remove a user, while still being over limit + await assert.isFulfilled(api.updateWorkspacePermissions(wsId, { + users: {'user1b@getgrist.com': null} + })); + await assert.isFulfilled(api.updateWorkspacePermissions(wsId, { + users: {'user2b@getgrist.com': null} + })); + await assert.isFulfilled(api.updateWorkspacePermissions(wsId, { + users: {'user3@getgrist.com': null} + })); + + // Finally ok to add a user again + await assert.isFulfilled(api.updateWorkspacePermissions(wsId, { + users: {'user1@getgrist.com': 'owners'} + })); + }); + + it('can enforce limits on number of docs', async function() { + await setFeatures({maxDocsPerOrg: 2, workspaces: true}); + const wsId = await api.newWorkspace({name: 'work'}, 'docs'); + + await assert.isFulfilled(api.newDoc({name: 'doc1'}, wsId)); + await assert.isFulfilled(api.newDoc({name: 'doc2'}, wsId)); + await assert.isRejected(api.newDoc({name: 'doc3'}, wsId), /No more documents/); + + await setFeatures({maxDocsPerOrg: 3, workspaces: true}); + await assert.isFulfilled(api.newDoc({name: 'doc3'}, wsId)); + await assert.isRejected(api.newDoc({name: 'doc4'}, wsId), /No more documents/); + + await setFeatures({workspaces: true}); + await assert.isFulfilled(api.newDoc({name: 'doc4'}, wsId)); + + await setFeatures({maxDocsPerOrg: 1, workspaces: true}); + await assert.isRejected(api.newDoc({name: 'doc5'}, wsId), /No more documents/); + + // check that smuggling in a document from another org doesn't work. + await assert.isRejected(nasa.moveDoc(await dbManager.testGetId('Jupiter') as string, wsId), + /No more documents/); + + // now make space for the document and try again. + await setFeatures({maxDocsPerOrg: 6, workspaces: true}); + await assert.isFulfilled(nasa.moveDoc(await dbManager.testGetId('Jupiter') as string, wsId)); + + // add a document in a workspace we are then going to make inaccessible. + const wsHiddenId = await api.newWorkspace({name: 'hidden'}, 'docs'); + await assert.isFulfilled(api.newDoc({name: 'doc6'}, wsHiddenId)); + await assert.isRejected(api.newDoc({name: 'doc7'}, wsHiddenId), /No more documents/); + + // transfer workspace ownership, and make inaccessible. + await api.updateWorkspacePermissions(wsHiddenId, {users: {'charon@getgrist.com': 'owners'}}); + const charon = await home.createHomeApi('charon', 'docs', true); + await charon.updateWorkspacePermissions(wsHiddenId, {maxInheritedRole: null}); + + // now try adding a document and make sure it is denied. + await assert.isRejected(api.newDoc({name: 'doc7'}, wsId), /No more documents/); + + // clean up workspace. + await charon.deleteWorkspace(wsHiddenId); + }); + + it('can enforce limits on number of doc shares', async function() { + this.timeout(4000); // This can exceed the default of 2s on Jenkins + + await setFeatures({maxSharesPerDoc: 3, workspaces: true}); + const wsId = await api.newWorkspace({name: 'shares'}, 'docs'); + const docId = await api.newDoc({name: 'doc'}, wsId); + + // Adding 4 users would exceed 3 user limit + await assert.isRejected(api.updateDocPermissions(docId, { + users: { + 'user1@getgrist.com': 'owners', + 'user2@getgrist.com': 'viewers', + 'user3@getgrist.com': 'owners', + 'user4@getgrist.com': 'viewers', + } + }), /No more external document shares/); + + // Adding 1 user is ok + await assert.isFulfilled(api.updateDocPermissions(docId, { + users: {'user1@getgrist.com': 'owners'} + })); + + // Adding 2nd+3rd user is ok + await assert.isFulfilled(api.updateDocPermissions(docId, { + users: { + 'user2@getgrist.com': 'owners', + 'user3@getgrist.com': 'owners' + } + })); + + // Adding 4th user fails + await assert.isRejected(api.updateDocPermissions(docId, { + users: {'user4@getgrist.com': 'owners'} + }), /No more external document shares/); + + // Adding support user is ok + await assert.isFulfilled(api.updateDocPermissions(docId, { + users: {'support@getgrist.com': 'owners'} + })); + + // Replacing user is ok + await assert.isFulfilled(api.updateDocPermissions(docId, { + users: { + 'user2@getgrist.com': null, + 'user2b@getgrist.com': 'owners' + } + })); + + // Removing a user and adding another is ok + await assert.isFulfilled(api.updateDocPermissions(docId, { + users: {'user1@getgrist.com': null} + })); + await assert.isFulfilled(api.updateDocPermissions(docId, { + users: {'user1b@getgrist.com': 'owners'} + })); + await assert.isRejected(api.updateDocPermissions(docId, { + users: {'user5@getgrist.com': 'owners'} + }), /No more external document shares/); + + // Reduce to limit to allow just one share + await setFeatures({maxSharesPerDoc: 1, workspaces: true}); + + // Cannot add or replace users, since we are over limit + await assert.isRejected(api.updateDocPermissions(docId, { + users: { + 'user3@getgrist.com': null, + 'user3b@getgrist.com': 'owners' + } + }), /No more external document shares/); + + // Can remove a user, while still being over limit + await assert.isFulfilled(api.updateDocPermissions(docId, { + users: {'user1b@getgrist.com': null} + })); + await assert.isFulfilled(api.updateDocPermissions(docId, { + users: {'user2b@getgrist.com': null} + })); + await assert.isFulfilled(api.updateDocPermissions(docId, { + users: {'user3@getgrist.com': null} + })); + + // Finally ok to add a user again + await assert.isFulfilled(api.updateDocPermissions(docId, { + users: {'user1@getgrist.com': 'owners'} + })); + + // Try smuggling in a doc that breaks the rules + // Tweak NASA's product to allow 4 shares per doc. + const db = dbManager.connection.manager; + const nasaOrg = await db.findOne(Organization, {where: {domain: 'nasa'}, + relations: ['billingAccount', + 'billingAccount.product']}); + if (!nasaOrg) { throw new Error('could not find nasa org'); } + const nasaProduct = nasaOrg.billingAccount.product; + const originalFeatures = nasaProduct.features; + + nasaProduct.features = {...originalFeatures, maxSharesPerDoc: 4}; + await nasaProduct.save(); + + const pluto = await dbManager.testGetId('Pluto') as string; + await nasa.updateDocPermissions(pluto, { + users: { + 'zig@getgrist.com': 'owners', + 'zag@getgrist.com': 'editors', + 'zog@getgrist.com': 'viewers', + } + }); + await assert.isRejected(nasa.moveDoc(pluto, wsId), /Too many external document shares/); + + // Increase the limit and try again + await setFeatures({maxSharesPerDoc: 100, workspaces: true}); + await assert.isFulfilled(nasa.moveDoc(pluto, wsId)); + }); + + it('can enforce limits on number of doc shares per role', async function() { + this.timeout(4000); // This can exceed the default of 2s on Jenkins + + await setFeatures({maxSharesPerDoc: 10, + maxSharesPerDocPerRole: { + owners: 1, + editors: 2 + }, + workspaces: true}); + const wsId = await api.newWorkspace({name: 'roleShares'}, 'docs'); + const docId = await api.newDoc({name: 'doc'}, wsId); + + // can add plenty of viewers + await assert.isFulfilled(api.updateDocPermissions(docId, { + users: { + 'viewer1@getgrist.com': 'viewers', + 'viewer2@getgrist.com': 'viewers', + 'viewer3@getgrist.com': 'viewers', + 'viewer4@getgrist.com': 'viewers' + } + })); + + // can add just one owner + await assert.isFulfilled(api.updateDocPermissions(docId, { + users: {'owner1@getgrist.com': 'owners'} + })); + await assert.isRejected(api.updateDocPermissions(docId, { + users: {'owner2@getgrist.com': 'owners'} + }), /No more external document owners/); + + // can add at most two editors + await assert.isRejected(api.updateDocPermissions(docId, { + users: { + 'editor1@getgrist.com': 'editors', + 'editor2@getgrist.com': 'editors', + 'editor3@getgrist.com': 'editors' + } + }), /No more external document editors/); + await assert.isFulfilled(api.updateDocPermissions(docId, { + users: { + 'editor1@getgrist.com': 'editors', + 'editor2@getgrist.com': 'editors' + } + })); + + // can convert an editor to a viewer and then add another editor + await assert.isFulfilled(api.updateDocPermissions(docId, { + users: { + 'editor1@getgrist.com': 'viewers', + 'editor3@getgrist.com': 'editors' + } + })); + + // we are at 8 shares, can make just two more + await assert.isFulfilled(api.updateDocPermissions(docId, { + users: {'viewer5@getgrist.com': 'viewers'} + })); + await assert.isFulfilled(api.updateDocPermissions(docId, { + users: {'viewer6@getgrist.com': 'viewers'} + })); + await assert.isRejected(api.updateDocPermissions(docId, { + users: {'viewer7@getgrist.com': 'viewers'} + }), /No more external document shares/); + + + // Try smuggling in a doc that exceeds limits + const beyond = await dbManager.testGetId('Beyond') as string; + await nasa.updateDocPermissions(beyond, { + users: { + 'zig@getgrist.com': 'owners', + 'zag@getgrist.com': 'owners' + } + }); + await assert.isRejected(nasa.moveDoc(beyond, wsId), /Too many external document owners/); + + // Increase the limit and try again + await setFeatures({maxSharesPerDoc: 10, + maxSharesPerDocPerRole: { + owners: 2, + editors: 2 + }, + workspaces: true}); + await assert.isFulfilled(nasa.moveDoc(beyond, wsId)); + }); + + it('can give good tips when exceeding doc shares', async function() { + await setFeatures({maxSharesPerDoc: 2, workspaces: true}); + const wsId = await api.newWorkspace({name: 'shares'}, 'docs'); + const docId = await api.newDoc({name: 'doc'}, wsId); + + await assert.isFulfilled(api.updateDocPermissions(docId, { + users: { + 'user1@getgrist.com': 'owners', + 'user2@getgrist.com': 'viewers', + } + })); + let err: ApiError = await api.updateDocPermissions(docId, { + users: { + 'user3@getgrist.com': 'owners', + } + }).catch(e => e); + // Advice should be to add users as members. + assert.sameMembers(err.details!.tips!.map(tip => tip.action), ['add-members']); + + // Now switch to a product that looks like a personal site + await setFeatures({maxSharesPerDoc: 2, workspaces: true, maxWorkspacesPerOrg: 1}); + err = await api.updateDocPermissions(docId, { + users: { + 'user3@getgrist.com': 'owners', + } + }).catch(e => e); + // Advice should be to upgrade. + assert.sameMembers(err.details!.tips!.map(tip => tip.action), ['upgrade']); + }); + + it('can give good tips when exceeding workspace shares', async function() { + await setFeatures({maxSharesPerWorkspace: 2, workspaces: true}); + const wsId = await api.newWorkspace({name: 'shares'}, 'docs'); + + await assert.isFulfilled(api.updateWorkspacePermissions(wsId, { + users: { + 'user1@getgrist.com': 'owners', + 'user2@getgrist.com': 'viewers', + } + })); + let err: ApiError = await api.updateWorkspacePermissions(wsId, { + users: { + 'user3@getgrist.com': 'owners', + } + }).catch(e => e); + // Advice should be to add users as members. + assert.sameMembers(err.details!.tips!.map(tip => tip.action), ['add-members']); + + // Now switch to a product that looks like a personal site (it should not + // be possible to share workspaces via UI in this case though) + await setFeatures({maxSharesPerWorkspace: 0, workspaces: true, maxWorkspacesPerOrg: 1}); + err = await api.updateWorkspacePermissions(wsId, { + users: { + 'user3@getgrist.com': 'owners', + } + }).catch(e => e); + // Advice should be to upgrade. + assert.sameMembers(err.details!.tips!.map(tip => tip.action), ['upgrade']); + }); + + it('discounts deleted and soft-deleted documents from quota', async function() { + this.timeout(3000); // This can exceed the default of 2s on Jenkins + + // Reset org to contain no docs, and set limit on docs to 2 + await resetOrg(api, 'docs'); + await setFeatures({maxDocsPerOrg: 2, workspaces: true}); + const wsId = await api.newWorkspace({name: 'work'}, 'docs'); + + // Create 2 docs. Then creating another will fail. + const doc1 = await api.newDoc({name: 'doc1'}, wsId); + const doc2 = await api.newDoc({name: 'doc2'}, wsId); + await assert.isRejected(api.newDoc({name: 'doc3'}, wsId), /No more documents/); + + // Hard-delete one doc, then we can add another. + await api.deleteDoc(doc1); + const doc3 = await api.newDoc({name: 'doc3'}, wsId); + + // Soft-delete one doc, then we can add another. + await api.softDeleteDoc(doc2); + await api.newDoc({name: 'doc4'}, wsId); + + // Check we can neither create nor recover a doc when full again. + await assert.isRejected(api.newDoc({name: 'doc5'}, wsId), /No more documents/); + await assert.isRejected(api.undeleteDoc(doc2), /No more documents/); + + // Check that if we make some space we can recover a doc. + await api.softDeleteDoc(doc3); + await api.undeleteDoc(doc2); + }); + + it('can enforce limits on total attachment file size', async function() { + this.timeout(4000); + + // Each attachment in this test will have one byte, so essentially we're limiting to two attachments + await setFeatures({baseMaxAttachmentsBytesPerDocument: 2}); + + const workspaces = await api.getOrgWorkspaces('current'); + const docId = await api.newDoc({name: 'doc1'}, workspaces[0].id); + await api.applyUserActions(docId, [["ModifyColumn", "Table1", "A", {type: "Attachments"}]]); + const docApi = api.getDocAPI(docId); + + // Add a cell referencing the attachments we're about to create. + // This ensures that they won't be immediately treated as soft-deleted and ignored in the total size calculation. + // Otherwise the uploads after this would succeed even if duplicate attachments were counted twice. + const rowIds = await docApi.addRows("Table1", {A: [[GristObjCode.List, 1, 2, 3]]}); + assert.deepEqual(rowIds, [1]); + + // We're limited to 2 attachments, but the attachment 'a' is duplicated so it's only counted once. + const attachmentIds = [ + await docApi.uploadAttachment('a', 'a.txt'), + await docApi.uploadAttachment('a', 'a.txt'), + await docApi.uploadAttachment('b', 'b.txt'), + ]; + assert.deepEqual(attachmentIds, [1, 2, 3]); + + // Now we're at the limit and trying to upload another attachment is rejected. + await assert.isRejected(docApi.uploadAttachment('c', 'c.txt')); + + // Delete one reference to 'a', but there's still another one so we're still at the limit and can't upload more. + await docApi.updateRows("Table1", {id: rowIds, A: [[GristObjCode.List, 2, 3]]}); + await assert.isRejected(docApi.uploadAttachment('c', 'c.txt')); + + // Delete the other reference to 'a' so now there's only one referenced attachment 'b' and we can upload again. + await docApi.updateRows("Table1", {id: rowIds, A: [[GristObjCode.List, 3, 4]]}); + assert.equal(await docApi.uploadAttachment('c', 'c.txt'), 4); + + // Now we're at the limit again with 'b' and 'c' and can't upload further. + await assert.isRejected(docApi.uploadAttachment('d', 'd.txt')); + }); + +}); diff --git a/test/gen-server/lib/listing.ts b/test/gen-server/lib/listing.ts new file mode 100644 index 00000000..ee1fde04 --- /dev/null +++ b/test/gen-server/lib/listing.ts @@ -0,0 +1,196 @@ +import {UserAPI} from 'app/common/UserAPI'; +import {assert} from 'chai'; +import {TestServer} from 'test/gen-server/apiUtils'; +import * as testUtils from 'test/server/testUtils'; + +/** + * Tests details of listing workspaces or documents via API. + */ +describe('listing', function() { + this.timeout(10000); + let home: TestServer; + testUtils.setTmpLogLevel('error'); + + const org: string = 'testy'; + let api: UserAPI; + let viewer: UserAPI; + let editor: UserAPI; + let ws1: number; + let ws2: number; + let ws3: number; + let doc12: string; + let doc13: string; + + before(async function() { + home = new TestServer(this); + await home.start(['home', 'docs']); + + // Create a test org with some workspaces and docs + api = await home.createHomeApi('chimpy', 'docs', true); + await api.newOrg({name: org, domain: org}); + api = await home.createHomeApi('chimpy', org, true); + ws1 = await api.newWorkspace({name: 'ws1'}, 'current'); + ws2 = await api.newWorkspace({name: 'ws2'}, 'current'); + ws3 = await api.newWorkspace({name: 'ws3'}, 'current'); + await api.newDoc({name: 'doc11'}, ws1); + doc12 = await api.newDoc({name: 'doc12'}, ws1); + doc13 = await api.newDoc({name: 'doc13'}, ws1); + const doc21 = await api.newDoc({name: 'doc21'}, ws2); + + // add an editor and a viewer to the org. + await api.updateOrgPermissions('current', { + users: { + 'kiwi@getgrist.com': 'viewers', + 'support@getgrist.com': 'editors', + } + }); + viewer = await home.createHomeApi('kiwi', org, true); + editor = await home.createHomeApi('support', org, true); + + // add another user as an owner of two docs and two workspaces. + await api.updateDocPermissions(doc12, { + users: {'charon@getgrist.com': 'owners'} + }); + await api.updateDocPermissions(doc13, { + users: {'charon@getgrist.com': 'owners'} + }); + await api.updateWorkspacePermissions(ws2, { + users: {'charon@getgrist.com': 'owners'} + }); + await api.updateWorkspacePermissions(ws3, { + users: {'charon@getgrist.com': 'owners'} + }); + + // Have that user remove or limit everyone else's access to those docs and workspaces. + const charon = await home.createHomeApi('charon', org, true); + await charon.updateWorkspacePermissions(ws2, { + users: {'chimpy@getgrist.com': null} // remove chimpy from ws2 + }); + await charon.updateDocPermissions(doc12, { + maxInheritedRole: null, + users: {'chimpy@getgrist.com': null} // remove chimpy's direct access + }); + await charon.updateDocPermissions(doc13, { + maxInheritedRole: 'viewers', + users: {'chimpy@getgrist.com': null} // remove chimpy's direct access + }); + await charon.updateDocPermissions(doc21, { + users: {'chimpy@getgrist.com': null} // remove chimpy's direct access + }); + await charon.updateWorkspacePermissions(ws2, { + maxInheritedRole: null, + users: {'chimpy@getgrist.com': null} // remove chimpy's direct access + }); + await charon.updateWorkspacePermissions(ws3, { + maxInheritedRole: 'viewers', + users: {'chimpy@getgrist.com': null} // remove chimpy's direct access + }); + }); + + after(async function() { + await api.deleteOrg('testy'); + await home.stop(); + }); + + // Check lists acquired via getWorkspace or via getOrgWorkspaces. + for (const method of ['getWorkspace', 'getOrgWorkspaces'] as const) { + + it(`editors and owners can list docs they cannot view with ${method}`, async function() { + async function list(user: UserAPI) { + if (method === 'getWorkspace') { return user.getWorkspace(ws1); } + return (await user.getOrgWorkspaces('current')).find(ws => ws.name === 'ws1')!; + } + + // Check owner of a workspace can see a doc they don't have access to listed (doc12). + let listing = await list(api); + assert.lengthOf(listing.docs, 3); + assert.equal(listing.docs[0].name, 'doc11'); + assert.equal(listing.docs[0].access, 'owners'); + assert.equal(listing.docs[1].name, 'doc12'); + assert.equal(listing.docs[1].access, null); + assert.equal(listing.docs[2].name, 'doc13'); + assert.equal(listing.docs[2].access, 'viewers'); + + // Editor's perspective should be like the owner. + listing = await list(editor); + assert.lengthOf(listing.docs, 3); + assert.equal(listing.docs[0].name, 'doc11'); + assert.equal(listing.docs[0].access, 'editors'); + assert.equal(listing.docs[1].name, 'doc12'); + assert.equal(listing.docs[1].access, null); + assert.equal(listing.docs[2].name, 'doc13'); + assert.equal(listing.docs[2].access, 'viewers'); + + // Viewer's perspective should omit docs user has no access to. + listing = await list(viewer); + assert.lengthOf(listing.docs, 2); + assert.equal(listing.docs[0].name, 'doc11'); + assert.equal(listing.docs[0].access, 'viewers'); + assert.equal(listing.docs[1].name, 'doc13'); + assert.equal(listing.docs[1].access, 'viewers'); + }); + } + + it('editors and owners CANNOT list workspaces they cannot view', async function() { + async function list(user: UserAPI) { + return (await user.getOrgWorkspaces('current')).filter(ws => ws.name.startsWith('ws')); + } + + // Check owner of an org CANNOT see a workspace they don't have access to listed (ws2). + let listing = await list(api); + assert.lengthOf(listing, 2); + assert.equal(listing[0].name, 'ws1'); + assert.equal(listing[0].access, 'owners'); + assert.equal(listing[1].name, 'ws3'); + assert.equal(listing[1].access, 'viewers'); + + // Viewer's perspective should be similar. + listing = await list(viewer); + assert.lengthOf(listing, 2); + assert.equal(listing[0].name, 'ws1'); + assert.equal(listing[0].access, 'viewers'); + assert.equal(listing[1].name, 'ws3'); + assert.equal(listing[1].access, 'viewers'); + }); + + // Make sure empty workspaces do not get filtered out of listings. + it('lists empty workspaces', async function() { + // We'll need a second user for some operations. + const charon = await home.createHomeApi('charon', org, true); + + // Make an empty workspace. + await api.newWorkspace({name: 'wsEmpty'}, 'current'); + + // Make a workspace with a single, inaccessible doc. + const wsWithDoc = await api.newWorkspace({name: 'wsWithDoc'}, 'current'); + const docInaccessible = await api.newDoc({name: 'inaccessible'}, wsWithDoc); + // Add another user as an owner of the doc. + await api.updateDocPermissions(docInaccessible, { + users: {'charon@getgrist.com': 'owners'} + }); + // Now remove everyone else's access. + await charon.updateDocPermissions(docInaccessible, { + maxInheritedRole: null + }); + + // Make an inaccessible workspace. + const wsInaccessible = await api.newWorkspace({name: 'wsInaccessible'}, 'current'); + // Add another user as an owner of the workspace. + await api.updateWorkspacePermissions(wsInaccessible, { + users: {'charon@getgrist.com': 'owners'} + }); + // Now remove everyone else's access. + await charon.updateWorkspacePermissions(wsInaccessible, { + maxInheritedRole: null + }); + + for (const user of [api, editor, viewer]) { + // Make sure both accessible workspaces are present in getOrgWorkspaces list, + // and don't get filtered out just because they are empty. + const listing = await user.getOrgWorkspaces('current'); + const names = listing.map(ws => ws.name); + assert.includeMembers(names, ['wsEmpty', 'wsWithDoc']); + assert.notInclude(names, ['wsInaccessible']); + } + }); +}); diff --git a/test/gen-server/lib/mergedOrgs.ts b/test/gen-server/lib/mergedOrgs.ts new file mode 100644 index 00000000..b66860fc --- /dev/null +++ b/test/gen-server/lib/mergedOrgs.ts @@ -0,0 +1,104 @@ +import {Workspace} from 'app/common/UserAPI'; +import {HomeDBManager} from 'app/gen-server/lib/homedb/HomeDBManager'; +import {FlexServer} from 'app/server/lib/FlexServer'; +import {main as mergedServerMain} from 'app/server/mergedServerMain'; +import axios from 'axios'; +import {assert} from 'chai'; +import {createInitialDb, removeConnection, setUpDB} from 'test/gen-server/seed'; +import {configForUser, createUser, setPlan} from 'test/gen-server/testUtils'; +import * as testUtils from 'test/server/testUtils'; + +describe('mergedOrgs', function() { + let home: FlexServer; + let dbManager: HomeDBManager; + let homeUrl: string; + let sharedOrgDomain: string; + let sharedDocId: string; + + testUtils.setTmpLogLevel('error'); + + before(async function() { + setUpDB(this); + await createInitialDb(); + home = await mergedServerMain(0, ["home", "docs"], + {logToConsole: false, externalStorage: false}); + dbManager = home.getHomeDBManager(); + homeUrl = home.getOwnUrl(); + }); + + after(async function() { + await home.close(); + await removeConnection(); + }); + + it('can list all shared workspaces from personal orgs', async function() { + // Org "0" or "docs" is a special pseudo-org, with the merged results of all + // workspaces in personal orgs that user has access to. + let resp = await axios.get(`${homeUrl}/api/orgs/0/workspaces`, configForUser('chimpy')); + assert.equal(resp.status, 200); + // See only workspaces in Chimpy's personal org so far. + assert.sameMembers(resp.data.map((w: Workspace) => w.name), ['Public', 'Private']); + // Grant Chimpy access to Kiwi's personal org, and add a workspace to it. + const kiwilandOrgId = await dbManager.testGetId('Kiwiland'); + resp = await axios.patch(`${homeUrl}/api/orgs/${kiwilandOrgId}/access`, { + delta: {users: {'chimpy@getgrist.com': 'editors'}} + }, configForUser('kiwi')); + resp = await axios.post(`${homeUrl}/api/orgs/${kiwilandOrgId}/workspaces`, { + name: 'Kiwidocs' + }, configForUser('kiwi')); + resp = await axios.get(`${homeUrl}/api/orgs/0/workspaces`, configForUser('chimpy')); + assert.sameMembers(resp.data.map((w: Workspace) => w.name), ['Private', 'Public', 'Kiwidocs']); + + // Create a new user with two workspaces, add chimpy to a document within + // one of them, and make sure chimpy sees that workspace. + const samHome = await createUser(dbManager, 'Sam'); + await setPlan(dbManager, samHome, 'Free'); + sharedOrgDomain = samHome.domain; + // A private workspace/doc that Sam won't share. + resp = await axios.post(`${homeUrl}/api/orgs/${samHome.id}/workspaces`, { + name: 'SamPrivateStuff' + }, configForUser('sam')); + assert.equal(resp.status, 200); + let wsId = resp.data; + resp = await axios.post(`${homeUrl}/api/workspaces/${wsId}/docs`, { + name: 'SamPrivateDoc' + }, configForUser('sam')); + assert.equal(resp.status, 200); + // A workspace/doc that Sam will share with Chimpy. + resp = await axios.post(`${homeUrl}/api/orgs/${samHome.id}/workspaces`, { + name: 'SamStuff' + }, configForUser('sam')); + assert.equal(resp.status, 200); + wsId = resp.data!; + resp = await axios.post(`${homeUrl}/api/workspaces/${wsId}/docs`, { + name: 'SamDoc' + }, configForUser('sam')); + assert.equal(resp.status, 200); + sharedDocId = resp.data!; + resp = await axios.patch(`${homeUrl}/api/docs/${sharedDocId}/access`, { + delta: {users: {'chimpy@getgrist.com': 'viewers'}} + }, configForUser('sam')); + assert.equal(resp.status, 200); + resp = await axios.get(`${homeUrl}/api/orgs/0/workspaces`, configForUser('chimpy')); + const sharedWss = ['Private', 'Public', 'Kiwidocs', 'SamStuff']; + assert.sameMembers(resp.data.map((w: Workspace) => w.name), sharedWss); + + // Check that all this is visible from docs domain expressed in different ways. + resp = await axios.get(`${homeUrl}/o/docs/api/orgs/current/workspaces`, configForUser('chimpy')); + assert.sameMembers(resp.data.map((w: Workspace) => w.name), sharedWss); + resp = await axios.get(`${homeUrl}/api/orgs/docs/workspaces`, configForUser('chimpy')); + assert.sameMembers(resp.data.map((w: Workspace) => w.name), sharedWss); + }); + + it('can access a document under merged domain', async function() { + let resp = await axios.get(`${homeUrl}/o/docs/api/docs/${sharedDocId}/tables/Table1/data`, + configForUser('chimpy')); + assert.equal(resp.status, 200); + resp = await axios.get(`${homeUrl}/o/${sharedOrgDomain}/api/docs/${sharedDocId}/tables/Table1/data`, + configForUser('chimpy')); + assert.equal(resp.status, 200); + resp = await axios.get(`${homeUrl}/o/nasa/api/docs/${sharedDocId}/tables/Table1/data`, + configForUser('chimpy')); + assert.equal(resp.status, 404); + }); +}); diff --git a/test/gen-server/lib/prefs.ts b/test/gen-server/lib/prefs.ts new file mode 100644 index 00000000..3428405b --- /dev/null +++ b/test/gen-server/lib/prefs.ts @@ -0,0 +1,132 @@ +import {UserAPI, UserAPIImpl} from 'app/common/UserAPI'; +import {assert} from 'chai'; +import {TestServer} from 'test/gen-server/apiUtils'; +import * as testUtils from 'test/server/testUtils'; + +describe('prefs', function() { + this.timeout(60000); + let home: TestServer; + testUtils.setTmpLogLevel('error'); + let owner: UserAPIImpl; + let guest: UserAPI; + let stranger: UserAPI; + + before(async function() { + home = new TestServer(this); + await home.start(['home', 'docs']); + const api = await home.createHomeApi('chimpy', 'docs', true); + await api.newOrg({name: 'testy', domain: 'testy'}); + owner = await home.createHomeApi('chimpy', 'testy', true); + const ws = await owner.newWorkspace({name: 'ws'}, 'current'); + await owner.updateWorkspacePermissions(ws, { users: { 'charon@getgrist.com': 'viewers' } }); + guest = await home.createHomeApi('charon', 'testy', true); + stranger = await home.createHomeApi('support', 'testy', true, false); + }); + + after(async function() { + const api = await home.createHomeApi('chimpy', 'docs'); + await api.deleteOrg('testy'); + await home.stop(); + }); + + it('can be set as combo orgUserPrefs when owner or guest', async function() { + await owner.updateOrg('current', { + userOrgPrefs: {placeholder: 'for owner'}, + }); + await guest.updateOrg('current', { + userOrgPrefs: {placeholder: 'for guest'}, + }); + await assert.isRejected(stranger.updateOrg('current', { + userOrgPrefs: {placeholder: 'for stranger'}, + }), /access denied/); + assert.equal((await owner.getOrg('current')).userOrgPrefs?.placeholder, 'for owner'); + assert.equal((await guest.getOrg('current')).userOrgPrefs?.placeholder, 'for guest'); + await assert.isRejected(stranger.getOrg('current'), /access denied/); + }); + + it('can be updated as combo orgUserPrefs when owner or guest', async function() { + await owner.updateOrg('current', { + userOrgPrefs: {placeholder: 'for owner2'}, + }); + await guest.updateOrg('current', { + userOrgPrefs: {placeholder: 'for guest2'}, + }); + await assert.isRejected(stranger.updateOrg('current', { + userOrgPrefs: {placeholder: 'for stranger2'}, + }), /access denied/); + assert.equal((await owner.getOrg('current')).userOrgPrefs?.placeholder, 'for owner2'); + assert.equal((await guest.getOrg('current')).userOrgPrefs?.placeholder, 'for guest2'); + await assert.isRejected(stranger.getOrg('current'), /access denied/); + }); + + it('can be set as orgPrefs when owner', async function() { + await owner.updateOrg('current', { + orgPrefs: {placeholder: 'general'}, + }); + await assert.isRejected(guest.updateOrg('current', { + orgPrefs: {placeholder: 'general!'}, + }), /access denied/); + await assert.isRejected(stranger.updateOrg('current', { + orgPrefs: {placeholder: 'general!'}, + }), /access denied/); + assert.equal((await owner.getOrg('current')).orgPrefs?.placeholder, 'general'); + assert.equal((await guest.getOrg('current')).orgPrefs?.placeholder, 'general'); + await assert.isRejected(stranger.getOrg('current'), /access denied/); + }); + + it('can be updated as orgPrefs when owner', async function() { + await owner.updateOrg('current', { + orgPrefs: {placeholder: 'general2'}, + }); + await assert.isRejected(guest.updateOrg('current', { + orgPrefs: {placeholder: 'general2!'}, + }), /access denied/); + await assert.isRejected(stranger.updateOrg('current', { + orgPrefs: {placeholder: 'general2!'}, + }), /access denied/); + assert.equal((await owner.getOrg('current')).orgPrefs?.placeholder, 'general2'); + assert.equal((await guest.getOrg('current')).orgPrefs?.placeholder, 'general2'); + await assert.isRejected(stranger.getOrg('current'), /access denied/); + }); + + it('can set as userPrefs when owner or guest', async function() { + await owner.updateOrg('current', { + userPrefs: {placeholder: 'userPrefs for owner'}, + }); + await guest.updateOrg('current', { + userPrefs: {placeholder: 'userPrefs for guest'}, + }); + await assert.isRejected(stranger.updateOrg('current', { + userPrefs: {placeholder: 'for stranger'}, + }), /access denied/); + assert.equal((await owner.getOrg('current')).userPrefs?.placeholder, 'userPrefs for owner'); + assert.equal((await guest.getOrg('current')).userPrefs?.placeholder, 'userPrefs for guest'); + await assert.isRejected(stranger.getOrg('current'), /access denied/); + }); + + it('can be accessed as userPrefs on other orgs', async function() { + const owner2 = await home.createHomeApi('chimpy', 'docs', true); + const guest2 = await home.createHomeApi('charon', 'docs', true); + const stranger2 = await home.createHomeApi('support', 'docs', true); + assert.equal((await owner2.getOrg('current')).userPrefs?.placeholder, 'userPrefs for owner'); + assert.equal((await owner2.getOrg('current')).userOrgPrefs?.placeholder, undefined); + assert.equal((await owner2.getOrg('current')).orgPrefs?.placeholder, undefined); + + assert.equal((await guest2.getOrg('current')).userPrefs?.placeholder, 'userPrefs for guest'); + assert.equal((await guest2.getOrg('current')).userOrgPrefs?.placeholder, undefined); + assert.equal((await guest2.getOrg('current')).orgPrefs?.placeholder, undefined); + + assert.equal((await stranger2.getOrg('current')).userPrefs?.placeholder, undefined); + assert.equal((await stranger2.getOrg('current')).userOrgPrefs?.placeholder, undefined); + assert.equal((await stranger2.getOrg('current')).orgPrefs?.placeholder, undefined); + }); + + it('can be accessed as prefs from active session', async function() { + const owner3 = await home.createHomeApi('chimpy', 'docs', true); + const guest3 = await home.createHomeApi('charon', 'docs', true); + const stranger3 = await home.createHomeApi('support', 'docs', true); + assert.equal((await owner3.getSessionActive()).user.prefs?.placeholder, 'userPrefs for owner'); + assert.equal((await guest3.getSessionActive()).user.prefs?.placeholder, 'userPrefs for guest'); + assert.equal((await stranger3.getSessionActive()).user.prefs?.placeholder, undefined); + }); +}); diff --git a/test/gen-server/lib/previewer.ts b/test/gen-server/lib/previewer.ts new file mode 100644 index 00000000..6671c384 --- /dev/null +++ b/test/gen-server/lib/previewer.ts @@ -0,0 +1,135 @@ +import {Organization} from 'app/gen-server/entity/Organization'; +import {HomeDBManager} from 'app/gen-server/lib/homedb/HomeDBManager'; +import axios from 'axios'; +import {AxiosRequestConfig} from 'axios'; +import {assert} from 'chai'; +import {TestServer} from 'test/gen-server/apiUtils'; +import {configForUser} from 'test/gen-server/testUtils'; +import * as testUtils from 'test/server/testUtils'; + + +const previewer = configForUser('thumbnail'); + +function permit(permitKey: string): AxiosRequestConfig { + return { + responseType: 'json', + validateStatus: (status: number) => true, + headers: { + Permit: permitKey + } + }; +} + +describe('previewer', function() { + + let home: TestServer; + let dbManager: HomeDBManager; + let homeUrl: string; + + testUtils.setTmpLogLevel('error'); + + before(async function() { + home = new TestServer(this); + await home.start(['home', 'docs']); + dbManager = home.dbManager; + homeUrl = home.serverUrl; + // for these tests, give the previewer an api key. + await dbManager.connection.query(`update users set api_key = 'api_key_for_thumbnail' where name = 'Preview'`); + }); + + after(async function() { + await home.stop(); + }); + + it('has view access to all orgs', async function() { + const resp = await axios.get(`${homeUrl}/api/orgs`, previewer); + assert.equal(resp.status, 200); + const orgs: any[] = resp.data; + assert.lengthOf(orgs, await Organization.count()); + orgs.forEach((org: any) => assert.equal(org.access, 'viewers')); + }); + + it('has view access to workspaces and docs', async function() { + const oid = await dbManager.testGetId('NASA'); + const resp = await axios.get(`${homeUrl}/api/orgs/${oid}/workspaces`, previewer); + assert.equal(resp.status, 200); + const workspaces: any[] = resp.data; + assert.lengthOf(workspaces, 2); + workspaces.forEach((ws: any) => { + assert.equal(ws.access, 'viewers'); + const docs: any[] = ws.docs; + docs.forEach((doc: any) => assert.equal(doc.access, 'viewers')); + }); + }); + + it('cannot delete or update docs and workspaces', async function() { + const oid = await dbManager.testGetId('NASA'); + let resp = await axios.get(`${homeUrl}/api/orgs/${oid}/workspaces`, previewer); + assert.equal(resp.status, 200); + + const wsId = resp.data[0].id; + const docId = resp.data[0].docs[0].id; + + resp = await axios.get(`${homeUrl}/api/docs/${docId}`, previewer); + assert.equal(resp.status, 200); + resp = await axios.delete(`${homeUrl}/api/docs/${docId}`, previewer); + assert.equal(resp.status, 403); + resp = await axios.patch(`${homeUrl}/api/docs/${docId}`, {name: 'diff'}, previewer); + assert.equal(resp.status, 403); + + resp = await axios.get(`${homeUrl}/api/workspaces/${wsId}`, previewer); + assert.equal(resp.status, 200); + resp = await axios.delete(`${homeUrl}/api/workspaces/${wsId}`, previewer); + assert.equal(resp.status, 403); + resp = await axios.patch(`${homeUrl}/api/workspaces/${wsId}`, {name: 'diff'}, previewer); + assert.equal(resp.status, 403); + }); + + it('can delete workspaces and docs using permits', async function() { + const oid = await dbManager.testGetId('NASA'); + let resp = await axios.get(`${homeUrl}/api/orgs/${oid}/workspaces`, previewer); + assert.equal(resp.status, 200); + + const wsId = resp.data[0].id; + const docId = resp.data[0].docs[0].id; + + const store = home.getWorkStore().getPermitStore('internal'); + const goodDocPermit = await store.setPermit({docId}); + const badDocPermit = await store.setPermit({docId: 'dud'}); + const goodWsPermit = await store.setPermit({workspaceId: wsId}); + const badWsPermit = await store.setPermit({workspaceId: wsId + 1}); + + // Check that external store is no good for internal use. + const externalStore = home.getWorkStore().getPermitStore('external'); + const externalDocPermit = await externalStore.setPermit({docId}); + resp = await axios.get(`${homeUrl}/api/docs/${docId}`, permit(externalDocPermit)); + //assert.equal(resp.status, 401); + + resp = await axios.get(`${homeUrl}/api/docs/${docId}`, permit(badDocPermit)); + assert.equal(resp.status, 403); + resp = await axios.delete(`${homeUrl}/api/docs/${docId}`, permit(badDocPermit)); + assert.equal(resp.status, 403); + resp = await axios.delete(`${homeUrl}/api/docs/${docId}`, permit(goodWsPermit)); + assert.equal(resp.status, 403); + resp = await axios.get(`${homeUrl}/api/docs/${docId}`, permit(goodDocPermit)); + assert.equal(resp.status, 403); + resp = await axios.patch(`${homeUrl}/api/docs/${docId}`, {name: 'diff'}, permit(goodDocPermit)); + assert.equal(resp.status, 403); + resp = await axios.delete(`${homeUrl}/api/docs/${docId}`, permit(goodDocPermit)); + assert.equal(resp.status, 200); + + resp = await axios.get(`${homeUrl}/api/workspaces/${wsId}`, permit(badWsPermit)); + assert.equal(resp.status, 403); + resp = await axios.delete(`${homeUrl}/api/workspaces/${wsId}`, permit(badWsPermit)); + assert.equal(resp.status, 403); + resp = await axios.delete(`${homeUrl}/api/workspaces/${wsId}`, permit(goodDocPermit)); + assert.equal(resp.status, 403); + resp = await axios.get(`${homeUrl}/api/workspaces/${wsId}`, permit(goodWsPermit)); + assert.equal(resp.status, 403); + resp = await axios.patch(`${homeUrl}/api/workspaces/${wsId}`, {name: 'diff'}, permit(goodWsPermit)); + assert.equal(resp.status, 403); + resp = await axios.delete(`${homeUrl}/api/workspaces/${wsId}`, permit(goodWsPermit)); + assert.equal(resp.status, 200); + + }); +}); diff --git a/test/gen-server/lib/removedAt.ts b/test/gen-server/lib/removedAt.ts new file mode 100644 index 00000000..2e990707 --- /dev/null +++ b/test/gen-server/lib/removedAt.ts @@ -0,0 +1,440 @@ +import {BaseAPI} from 'app/common/BaseAPI'; +import {UserAPI, Workspace} from 'app/common/UserAPI'; +import {assert} from 'chai'; +import flatten = require('lodash/flatten'); +import sortBy = require('lodash/sortBy'); +import {TestServer} from 'test/gen-server/apiUtils'; +import * as testUtils from 'test/server/testUtils'; + +describe('removedAt', function() { + let home: TestServer; + testUtils.setTmpLogLevel('error'); + + before(async function() { + home = new TestServer(this); + await home.start(['home', 'docs']); + const api = await home.createHomeApi('chimpy', 'docs'); + await api.newOrg({name: 'testy', domain: 'testy'}); + }); + + after(async function() { + this.timeout(100000); + const api = await home.createHomeApi('chimpy', 'docs'); + await api.deleteOrg('testy'); + await home.stop(); + }); + + function docNames(data: Workspace|Workspace[]) { + if ('docs' in data) { + return data.docs.map(doc => doc.name).sort(); + } + return flatten( + sortBy(data, 'name') + .map(ws => ws.docs.map(d => `${ws.name}:${d.name}`).sort())); + } + + function workspaceNames(data: Workspace[]) { + return data.map(ws => ws.name).sort(); + } + + describe('scenario', function() { + const org: string = 'testy'; + let api: UserAPI; // regular api + let xapi: UserAPI; // api for soft-deleted resources + let bapi: BaseAPI; // api cast to allow custom requests + let ws1: number; + let ws2: number; + let ws3: number; + let ws4: number; + let doc11: string; + let doc21: string; + let doc12: string; + let doc31: string; + + before(async function() { + api = await home.createHomeApi('chimpy', org); + xapi = api.forRemoved(); + bapi = api as unknown as BaseAPI; + // Get rid of home workspace + for (const ws of await api.getOrgWorkspaces('current')) { + await api.deleteWorkspace(ws.id); + } + // Make two workspaces with two docs apiece, one workspace with one doc, + // and one empty workspace + ws1 = await api.newWorkspace({name: 'ws1'}, 'current'); + ws2 = await api.newWorkspace({name: 'ws2'}, 'current'); + ws3 = await api.newWorkspace({name: 'ws3'}, 'current'); + ws4 = await api.newWorkspace({name: 'ws4'}, 'current'); + doc11 = await api.newDoc({name: 'doc11'}, ws1); + doc12 = await api.newDoc({name: 'doc12'}, ws1); + doc21 = await api.newDoc({name: 'doc21'}, ws2); + await api.newDoc({name: 'doc22'}, ws2); + doc31 = await api.newDoc({name: 'doc31'}, ws3); + }); + + it('hides soft-deleted docs from regular api', async function() { + // This can be too low for running this test directly (when database needs to be created). + this.timeout(10000); + + // Check that doc11 is visible via regular api + assert.equal((await api.getDoc(doc11)).name, 'doc11'); + assert.typeOf((await api.getDoc(doc11)).removedAt, 'undefined'); + assert.deepEqual(docNames(await api.getOrgWorkspaces('current')), + ['ws1:doc11', 'ws1:doc12', 'ws2:doc21', 'ws2:doc22', 'ws3:doc31']); + assert.deepEqual(workspaceNames(await api.getOrgWorkspaces('current')), + ['ws1', 'ws2', 'ws3', 'ws4']); + assert.deepEqual(docNames(await api.getWorkspace(ws1)), ['doc11', 'doc12']); + assert.deepEqual(docNames(await api.getWorkspace(ws2)), ['doc21', 'doc22']); + assert.deepEqual(docNames(await api.getWorkspace(ws3)), ['doc31']); + assert.deepEqual(docNames(await api.getWorkspace(ws4)), []); + + // Soft-delete doc11, leaving one doc in ws1 + await api.softDeleteDoc(doc11); + + // Check that doc11 is no longer visible via regular api + await assert.isRejected(api.getDoc(doc11), /not found/); + assert.deepEqual(docNames(await api.getWorkspace(ws1)), ['doc12']); + assert.deepEqual(docNames(await api.getOrgWorkspaces('current')), + ['ws1:doc12', 'ws2:doc21', 'ws2:doc22', 'ws3:doc31']); + + // Check that various related endpoints are forbidden + let docApi = api.getDocAPI(doc11); + await assert.isRejected(docApi.getSnapshots(), /404.*document is deleted/i); + await assert.isRejected(docApi.getRows('Table1'), /404.*document is deleted/i); + await assert.isRejected(docApi.forceReload(), /404.*document is deleted/i); + + // Check that doc11 is visible via forRemoved api + assert.equal((await xapi.getDoc(doc11)).name, 'doc11'); + assert.typeOf((await xapi.getDoc(doc11)).removedAt, 'string'); + assert.deepEqual(docNames(await xapi.getWorkspace(ws1)), ['doc11']); + await assert.isRejected(xapi.getWorkspace(ws2), /not found/); + assert.deepEqual(docNames(await xapi.getOrgWorkspaces('current')), + ['ws1:doc11']); + assert.deepEqual(workspaceNames(await xapi.getOrgWorkspaces('current')), + ['ws1']); + + docApi = xapi.getDocAPI(doc11); + await assert.isFulfilled(docApi.getSnapshots()); + await assert.isFulfilled(docApi.getRows('Table1')); + await assert.isFulfilled(docApi.forceReload()); + }); + + it('lists workspaces even with all docs soft-deleted', async function() { + // Soft-delete doc12, leaving ws1 empty + await api.softDeleteDoc(doc12); + // Soft-delete doc31, leaving ws3 empty + await api.softDeleteDoc(doc31); + + // Check docs are not visible, but workspaces are + await assert.isRejected(api.getDoc(doc12), /not found/); + await assert.isRejected(api.getDoc(doc31), /not found/); + assert.deepEqual(docNames(await api.getOrgWorkspaces('current')), ['ws2:doc21', 'ws2:doc22']); + assert.deepEqual(workspaceNames(await api.getOrgWorkspaces('current')), + ['ws1', 'ws2', 'ws3', 'ws4']); + assert.deepEqual(docNames(await api.getWorkspace(ws1)), []); + assert.deepEqual(docNames(await api.getWorkspace(ws3)), []); + + // Check docs are visible via forRemoved api + assert.equal((await xapi.getDoc(doc12)).name, 'doc12'); + assert.typeOf((await xapi.getDoc(doc12)).removedAt, 'string'); + assert.equal((await xapi.getDoc(doc31)).name, 'doc31'); + assert.typeOf((await xapi.getDoc(doc31)).removedAt, 'string'); + assert.equal((await xapi.getWorkspace(ws1)).name, 'ws1'); + assert.typeOf((await xapi.getWorkspace(ws1)).removedAt, 'undefined'); + assert.deepEqual(docNames(await xapi.getWorkspace(ws1)), ['doc11', 'doc12']); + assert.deepEqual(docNames(await xapi.getWorkspace(ws3)), ['doc31']); + await assert.isRejected(xapi.getWorkspace(ws2), /not found/); + assert.deepEqual(docNames(await xapi.getOrgWorkspaces('current')), + ['ws1:doc11', 'ws1:doc12', 'ws3:doc31']); + assert.deepEqual(workspaceNames(await xapi.getOrgWorkspaces('current')), + ['ws1', 'ws3']); + }); + + it('can revert soft-deleted docs', async function() { + // Undelete docs + await api.undeleteDoc(doc11); + await api.undeleteDoc(doc12); + await api.undeleteDoc(doc31); + + // Check that doc11 is visible via regular api again + assert.equal((await api.getDoc(doc11)).name, 'doc11'); + assert.typeOf((await api.getDoc(doc11)).removedAt, 'undefined'); + assert.deepEqual(docNames(await api.getOrgWorkspaces('current')), + ['ws1:doc11', 'ws1:doc12', 'ws2:doc21', 'ws2:doc22', 'ws3:doc31']); + assert.deepEqual(docNames(await api.getWorkspace(ws1)), ['doc11', 'doc12']); + + // Check that no "trash" is visible anymore + assert.deepEqual(docNames(await xapi.getOrgWorkspaces('current')), []); + await assert.isRejected(xapi.getWorkspace(ws1), /not found/); + await assert.isRejected(xapi.getWorkspace(ws2), /not found/); + await assert.isRejected(xapi.getWorkspace(ws3), /not found/); + await assert.isRejected(xapi.getWorkspace(ws4), /not found/); + }); + + it('hides soft-deleted workspaces from regular api', async function() { + // Soft-delete ws1, ws3, and ws4 + await api.softDeleteWorkspace(ws1); + await api.softDeleteWorkspace(ws3); + await api.softDeleteWorkspace(ws4); + + // Check that workspaces are no longer visible via regular api + await assert.isRejected(api.getDoc(doc11), /not found/); + await assert.isRejected(api.getWorkspace(ws1), /not found/); + assert.deepEqual(docNames(await api.getWorkspace(ws2)), ['doc21', 'doc22']); + await assert.isRejected(api.getDoc(doc31), /not found/); + await assert.isRejected(api.getWorkspace(ws3), /not found/); + await assert.isRejected(api.getWorkspace(ws4), /not found/); + assert.deepEqual(docNames(await api.getOrgWorkspaces('current')), + ['ws2:doc21', 'ws2:doc22']); + + // Check that workspaces are visible via forRemoved api + assert.equal((await xapi.getWorkspace(ws1)).name, 'ws1'); + assert.typeOf((await xapi.getWorkspace(ws1)).removedAt, 'string'); + assert.equal((await xapi.getDoc(doc11)).name, 'doc11'); + assert.equal((await xapi.getWorkspace(ws3)).name, 'ws3'); + assert.typeOf((await xapi.getWorkspace(ws3)).removedAt, 'string'); + assert.equal((await xapi.getWorkspace(ws4)).name, 'ws4'); + assert.typeOf((await xapi.getWorkspace(ws4)).removedAt, 'string'); + // we may not want the following - may want to explicitly set removedAt + // on docs within a soft-deleted workspace + assert.typeOf((await xapi.getDoc(doc11)).removedAt, 'undefined'); + assert.typeOf((await xapi.getDoc(doc12)).removedAt, 'undefined'); + assert.typeOf((await xapi.getDoc(doc31)).removedAt, 'undefined'); + assert.deepEqual(docNames(await xapi.getWorkspace(ws1)), ['doc11', 'doc12']); + await assert.isRejected(xapi.getWorkspace(ws2), /not found/); + assert.deepEqual(docNames(await xapi.getWorkspace(ws3)), ['doc31']); + assert.deepEqual(docNames(await xapi.getWorkspace(ws4)), []); + assert.deepEqual(docNames(await xapi.getOrgWorkspaces('current')), + ['ws1:doc11', 'ws1:doc12', 'ws3:doc31']); + }); + + it('can combine soft-deleted workspaces and soft-deleted docs', async function() { + // Delete a doc in an undeleted workspace, and in a soft-deleted workspace. + await api.softDeleteDoc(doc21); + await xapi.softDeleteDoc(doc11); + assert.deepEqual(docNames(await api.getOrgWorkspaces('current')), + ['ws2:doc22']); + assert.deepEqual(docNames(await xapi.getOrgWorkspaces('current')), + ['ws1:doc11', 'ws1:doc12', 'ws2:doc21', 'ws3:doc31']); + }); + + it('can revert soft-deleted workspaces', async function() { + // Undelete workspaces and docs + await api.undeleteWorkspace(ws1); + await api.undeleteWorkspace(ws3); + await api.undeleteWorkspace(ws4); + await api.undeleteDoc(doc21); + await api.undeleteDoc(doc11); + + // Check that docs are visible via regular api again + assert.equal((await api.getDoc(doc11)).name, 'doc11'); + assert.typeOf((await api.getDoc(doc11)).removedAt, 'undefined'); + assert.deepEqual(docNames(await api.getOrgWorkspaces('current')), + ['ws1:doc11', 'ws1:doc12', 'ws2:doc21', 'ws2:doc22', 'ws3:doc31']); + assert.deepEqual(docNames(await api.getWorkspace(ws1)), ['doc11', 'doc12']); + + // Check that no "trash" is visible anymore + assert.deepEqual(docNames(await xapi.getOrgWorkspaces('current')), []); + }); + + + // This checks that the following problem is fixed: + // If a document is deleted in a workspace with many other documents, the + // deletion used to take an unreasonable length of time. + it('deletes documents reasonably quickly', async function() { + this.timeout(15000); + const ws = await api.newWorkspace({name: 'speedTest'}, 'testy'); + // Create a batch of many documents. + const docIds = await Promise.all(new Array(50).fill(0).map(() => api.newDoc({name: 'doc'}, ws))); + // Explicitly set users on some of the documents. + await api.updateDocPermissions(docIds[5], { + users: { + 'test1@getgrist.com': 'viewers', + } + }); + await api.updateDocPermissions(docIds[10], { + users: { + 'test2@getgrist.com': 'owners', + 'test3@getgrist.com': 'editors', + } + }); + const userRef = (email: string) => home.dbManager.getUserByLogin(email).then((user) => user!.ref); + const idTest1 = (await home.dbManager.getUserByLogin("test1@getgrist.com"))!.id; + const idTest2 = (await home.dbManager.getUserByLogin("test2@getgrist.com"))!.id; + const idTest3 = (await home.dbManager.getUserByLogin("test3@getgrist.com"))!.id; + // Create one extra document, with one extra user. + const extraDocId = await api.newDoc({name: 'doc'}, ws); + await api.updateDocPermissions(extraDocId, { + users: { 'kiwi@getgrist.com': 'viewers' } + }); + assert.deepEqual(await api.getWorkspaceAccess(ws), { + "maxInheritedRole": "owners", + "users": [ + { + "id": 1, + "name": "Chimpy", + "email": "chimpy@getgrist.com", + "ref": await userRef("chimpy@getgrist.com"), + "picture": null, + "access": "owners", + "parentAccess": "owners", + "isMember": true, + }, + { + "id": 2, + "name": "Kiwi", + "email": "kiwi@getgrist.com", + "ref": await userRef("kiwi@getgrist.com"), + "picture": null, + "access": "guests", + "parentAccess": null, + "isMember": false, + }, + { + "id": idTest1, + "name": "", + "email": "test1@getgrist.com", + "ref": await userRef("test1@getgrist.com"), + "picture": null, + "access": "guests", + "parentAccess": null, + "isMember": false, + }, + { + "id": idTest2, + "name": "", + "email": "test2@getgrist.com", + "ref": await userRef("test2@getgrist.com"), + "picture": null, + "access": "guests", + "parentAccess": null, + "isMember": false, + }, + { + "id": idTest3, + "name": "", + "email": "test3@getgrist.com", + "ref": await userRef("test3@getgrist.com"), + "picture": null, + "access": "guests", + "parentAccess": null, + "isMember": false, + } + ] + }); + // Delete the batch of documents, retaining the one extra. + await Promise.all(docIds.map(docId => api.deleteDoc(docId))); + // Make sure the guest from the extra doc is retained, while all others evaporate. + assert.deepEqual(await api.getWorkspaceAccess(ws), { + "maxInheritedRole": "owners", + "users": [ + { + "id": 1, + "name": "Chimpy", + "email": "chimpy@getgrist.com", + "ref": await userRef("chimpy@getgrist.com"), + "picture": null, + "access": "owners", + "parentAccess": "owners", + "isMember": true, + }, + { + "id": 2, + "name": "Kiwi", + "email": "kiwi@getgrist.com", + "ref": await userRef("kiwi@getgrist.com"), + "picture": null, + "access": "guests", + "parentAccess": null, + "isMember": false, + } + ] + }); + }); + + it('does not interfere with DocAuthKey-based caching', async function() { + const info = await api.getSessionActive(); + + // Flush cache, then try to access doc11 as a removed doc. + home.dbManager.flushDocAuthCache(); + await assert.isRejected(xapi.getDoc(doc11), /not found/); + + // Check that cached authentication is correct. + const auth = await home.dbManager.getDocAuthCached({urlId: doc11, userId: info.user.id, org}); + assert.equal(auth.access, 'owners'); + }); + + it('respects permanent flag on /api/docs/:did/remove', async function() { + await bapi.testRequest(`${api.getBaseUrl()}/api/docs/${doc11}/remove`, + {method: 'POST'}); + await bapi.testRequest(`${api.getBaseUrl()}/api/docs/${doc12}/remove?permanent=1`, + {method: 'POST'}); + await api.undeleteDoc(doc11); + await assert.isRejected(api.undeleteDoc(doc12), /not found/); + }); + + it('respects permanent flag on /api/workspaces/:wid/remove', async function() { + await bapi.testRequest(`${api.getBaseUrl()}/api/workspaces/${ws1}/remove`, + {method: 'POST'}); + await bapi.testRequest(`${api.getBaseUrl()}/api/workspaces/${ws2}/remove?permanent=1`, + {method: 'POST'}); + await api.undeleteWorkspace(ws1); + await assert.isRejected(api.undeleteWorkspace(ws2), /not found/); + }); + + it('can hard-delete a soft-deleted document', async function() { + const tmp1 = await api.newDoc({name: 'tmp1'}, ws1); + const tmp2 = await api.newDoc({name: 'tmp2'}, ws1); + await api.softDeleteDoc(tmp1); + await api.deleteDoc(tmp1); + await api.softDeleteDoc(tmp2); + await bapi.testRequest(`${api.getBaseUrl()}/api/docs/${tmp2}/remove?permanent=1`, + {method: 'POST'}); + await assert.isRejected(api.undeleteDoc(tmp1)); + await assert.isRejected(api.undeleteDoc(tmp2)); + }); + + it('can hard-delete a soft-deleted workspace', async function() { + const tmp1 = await api.newWorkspace({name: 'tmp1'}, 'current'); + const tmp2 = await api.newWorkspace({name: 'tmp2'}, 'current'); + await api.softDeleteWorkspace(tmp1); + await api.deleteWorkspace(tmp1); + await api.softDeleteWorkspace(tmp2); + await bapi.testRequest(`${api.getBaseUrl()}/api/workspaces/${tmp2}/remove?permanent=1`, + {method: 'POST'}); + await assert.isRejected(api.undeleteWorkspace(tmp1)); + await assert.isRejected(api.undeleteWorkspace(tmp2)); + }); + + // This checks that the following problem is fixed: + // If I shared a doc with a friend, and then soft-deleted a doc in the same workspace, + // that friend used to see the workspace in their trash (empty, but there). + it('does not show workspaces for docs user does not have access to', async function() { + // Make two docs in a workspace, and share one with a friend. + const ws = await api.newWorkspace({name: 'wsWithSharing'}, 'testy'); + const shared = await api.newDoc({name: 'shared'}, ws); + const unshared = await api.newDoc({name: 'unshared'}, ws); + await api.updateDocPermissions(shared, { + users: { 'charon@getgrist.com': 'viewers' } + }); + + // Check the friend sees nothing in their trash. + const charon = await home.createHomeApi('charon', 'docs'); + let result = await charon.forRemoved().getOrgWorkspaces('testy'); + assert.lengthOf(result, 0); + + // Deleted the unshared doc, and check the friend still sees nothing in their trash. + await api.softDeleteDoc(unshared); + result = await charon.forRemoved().getOrgWorkspaces('testy'); + assert.lengthOf(result, 0); + + // Deleted the shared doc, and check the friend sees it in trash. + // (There might be a case for only owners seeing it? i.e. you see something in + // trash if you have the power to restore it? But in a team site it might be + // useful to have some insight into what happened to a doc you can view.) + await api.softDeleteDoc(shared); + result = await charon.forRemoved().getOrgWorkspaces('testy'); + assert.deepEqual(docNames(result), ['wsWithSharing:shared']); + }); + }); +}); diff --git a/test/gen-server/lib/scrubUserFromOrg.ts b/test/gen-server/lib/scrubUserFromOrg.ts new file mode 100644 index 00000000..c670a0f7 --- /dev/null +++ b/test/gen-server/lib/scrubUserFromOrg.ts @@ -0,0 +1,453 @@ +import {Role} from 'app/common/roles'; +import {PermissionData} from 'app/common/UserAPI'; +import {assert} from 'chai'; +import {TestServer} from 'test/gen-server/apiUtils'; +import * as testUtils from 'test/server/testUtils'; + +describe('scrubUserFromOrg', function() { + + let server: TestServer; + testUtils.setTmpLogLevel('error'); + + beforeEach(async function() { + this.timeout(5000); + server = new TestServer(this); + await server.start(); + // Use an empty org called "org1" created by "user1" for these tests. + const user1 = (await server.dbManager.getUserByLogin('user1@getgrist.com'))!; + await server.dbManager.addOrg(user1, {name: 'org1', domain: 'org1'}, { + setUserAsOwner: false, + useNewPlan: true + }); + }); + + afterEach(async function() { + await server.stop(); + }); + + // count how many rows there are in the group_users table, for sanity checks. + async function countGroupUsers() { + return await server.dbManager.connection.manager.count('group_users'); + } + + // get the home api, making sure the user's api key is set. + async function getApi(userName: string, orgName: string) { + const user = (await server.dbManager.getUserByLogin(`${userName}@getgrist.com`))!; + user.apiKey = `api_key_for_${userName}`; + await user.save(); + return server.createHomeApi(userName, orgName, true); + } + + // check what role is listed for the given user in the results of an ACL endpoint. + function getRole(access: PermissionData, email: string): string|null|undefined { + const row = access.users.find(u => u.email === email); + if (!row) { return undefined; } + return row.access; + } + + // list emails of all users with the given role for the given org. + async function listOrg(domain: string, role: Role|null): Promise { + return (await server.listOrgMembership(domain, role)) + .map(user => user.logins[0].email); + } + + // list emails of all users with the given role for the given workspace, via + // directly granted access to the workspace (inherited access not considered). + async function listWs(wsId: number, role: Role|null): Promise { + return (await server.listWorkspaceMembership(wsId, role)) + .map(user => user.logins[0].email); + } + + // list all resources a user has directly been granted access to, as a list + // of strings, each of the form "role:resource-name", such as "guests:org1". + async function listUser(email: string) { + return (await server.listUserMemberships(email)) + .map(membership => `${membership.role}:${membership.res.name}`).sort(); + } + + it('can remove users from orgs while preserving doc access', async function() { + this.timeout(5000); // takes about a second locally, so give more time to + // avoid occasional slow runs on jenkins. + // In test org "org1", create a test workspace "ws1" and a test document "doc1" + const user1 = await getApi('user1', 'org1'); + const wsId = await user1.newWorkspace({name: 'ws1'}, 'current'); + const docId = await user1.newDoc({name: 'doc1'}, wsId); + + // Initially the org has only 1 guest - the creator. + assert.sameMembers(await listOrg('org1', 'guests'), ['user1@getgrist.com']); + + // Add a set of users to doc1 + await user1.updateDocPermissions(docId, { + maxInheritedRole: 'viewers', + users: { + 'user2@getgrist.com': 'owners', + 'user3@getgrist.com': 'owners', + 'user4@getgrist.com': 'editors', + 'user5@getgrist.com': 'owners', + } + }); + + // Check that the org now has the expected guests. Even user1, who has + // direct access to the org, will be listed as a guest as well. + assert.sameMembers(await listOrg('org1', 'guests'), + ['user1@getgrist.com', 'user2@getgrist.com', 'user3@getgrist.com', + 'user4@getgrist.com', 'user5@getgrist.com']); + // Check the the workspace also has the expected guests. + assert.sameMembers(await listWs(wsId, 'guests'), + ['user1@getgrist.com', 'user2@getgrist.com', 'user3@getgrist.com', + 'user4@getgrist.com', 'user5@getgrist.com']); + + // Get the home api from user2's perspective (so we can tweak user1's access to doc1). + const user2 = await getApi('user2', 'org1'); + + // Confirm that user3's maximal role on the org currently is as a guest. + let access = await user1.getOrgAccess('current'); + assert.equal(getRole(access, 'user3@getgrist.com'), 'guests'); + + // Check that user1 is an owner on the doc (this happens when the doc's permissions + // were updated by user1, since the user changing access must remain an owner). + access = await user1.getDocAccess(docId); + assert.equal(getRole(access, 'user1@getgrist.com'), 'owners'); + + // Lower user1's access to the doc. + await user2.updateDocPermissions(docId, { + users: { 'user1@getgrist.com': 'viewers' } + }); + access = await user2.getDocAccess(docId); + assert.equal(getRole(access, 'user1@getgrist.com'), 'viewers'); + + // Have user1 change user3's access to the org. + await user1.updateOrgPermissions('current', { + users: { 'user3@getgrist.com': 'viewers' } + }); + access = await user2.getDocAccess(docId); + assert.equal(getRole(access, 'user1@getgrist.com'), 'viewers'); + assert.equal(getRole(access, 'user3@getgrist.com'), 'owners'); + + // Ok, that has all been preamble. Now to test user removal. + // Have user1 remove user3's access to the org, checking user1+user3's access before and after. + let countBefore = await countGroupUsers(); + assert.sameMembers(await listUser('user3@getgrist.com'), + ['viewers:org1', 'guests:org1', 'guests:ws1', 'owners:Personal', 'owners:doc1']); + assert.sameMembers(await listUser('user1@getgrist.com'), + ['owners:org1', 'guests:org1', 'owners:ws1', 'guests:ws1', 'owners:Personal', 'viewers:doc1']); + await user1.updateOrgPermissions('current', { + users: { 'user3@getgrist.com': null } + }); + let countAfter = await countGroupUsers(); + // The only resource user3 has access to now is their personal org. + assert.sameMembers(await listUser('user3@getgrist.com'), ['owners:Personal']); + assert.sameMembers(await listUser('user1@getgrist.com'), + ['owners:org1', 'guests:org1', 'guests:ws1', 'owners:ws1', 'owners:Personal', 'owners:doc1']); + assert.sameMembers(await listOrg('org1', 'guests'), + ['user1@getgrist.com', 'user2@getgrist.com', + 'user4@getgrist.com', 'user5@getgrist.com']); + assert.sameMembers(await listWs(wsId, 'guests'), + ['user1@getgrist.com', 'user2@getgrist.com', + 'user4@getgrist.com', 'user5@getgrist.com']); + // For overall count of rows in group_users table, here are the changes: + // - Drops: user3 as owner of doc, editor on org, guest on ws and org. + // - Changes: user1 from editor to owner of doc. + assert.equal(countAfter, countBefore - 4); + + // Check view API that user3 is removed from the doc, and Owner1 promoted to owner. + access = await user2.getDocAccess(docId); + assert.equal(getRole(access, 'user3@getgrist.com'), undefined); + assert.equal(getRole(access, 'user1@getgrist.com'), 'owners'); + + // Lower user1's access to the doc again. + await user2.updateDocPermissions(docId, { + users: { 'user1@getgrist.com': 'viewers' } + }); + access = await user2.getDocAccess(docId); + assert.equal(getRole(access, 'user1@getgrist.com'), 'viewers'); + + // Now have user1 remove user4's access to the org. + countBefore = await countGroupUsers(); + await user1.updateOrgPermissions('current', { + users: { 'user4@getgrist.com': null } + }); + countAfter = await countGroupUsers(); + + // Drops: user4 as editor of doc, guest on ws and org. + // Adds: nothing. + assert.equal(countAfter, countBefore - 3); + assert.sameMembers(await listOrg('org1', 'guests'), + ['user1@getgrist.com', 'user2@getgrist.com', 'user5@getgrist.com']); + + // User4 should be removed from the doc, and user1's access unchanged (since user4 was + // not an owner) + access = await user2.getDocAccess(docId); + assert.equal(getRole(access, 'user4@getgrist.com'), undefined); + assert.equal(getRole(access, 'user1@getgrist.com'), 'viewers'); + + // Now have a fresh user remove user5's access to the org. + await user1.updateOrgPermissions('current', { + users: { + 'user6@getgrist.com': 'owners', + } + }); + const user6 = await getApi('user6', 'org1'); + countBefore = await countGroupUsers(); + await user6.updateOrgPermissions('current', { + users: { 'user5@getgrist.com': null } + }); + countAfter = await countGroupUsers(); + + // Drops: user5 as owner of doc, guest on ws and org. + // Adds: user6 as owner of doc, guest on ws and org. + assert.equal(countAfter, countBefore); + assert.sameMembers(await listOrg('org1', 'guests'), + ['user1@getgrist.com', 'user2@getgrist.com', 'user6@getgrist.com']); + assert(getRole(await user1.getWorkspaceAccess(wsId), 'user6@getgrist.com'), 'guests'); + }); + + it('can remove users from orgs while preserving workspace access', async function() { + this.timeout(5000); // takes about a second locally, so give more time to + // avoid occasional slow runs on jenkins. + // In test org "org1", create a test workspace "ws1" + const user1 = await getApi('user1', 'org1'); + const wsId = await user1.newWorkspace({name: 'ws1'}, 'current'); + + // Initially the org has 1 guest - the creator. + assert.sameMembers(await listOrg('org1', 'guests'), ['user1@getgrist.com' ]); + + // Add a set of users to ws1 + await user1.updateWorkspacePermissions(wsId, { + maxInheritedRole: 'viewers', + users: { + 'user2@getgrist.com': 'owners', + 'user3@getgrist.com': 'owners', + 'user4@getgrist.com': 'editors', + 'user5@getgrist.com': 'owners', + } + }); + + // Check that the org now has the expected guests. Even user1, who has + // direct access to the org, will be listed as a guest as well. + assert.sameMembers(await listOrg('org1', 'guests'), + ['user1@getgrist.com', 'user2@getgrist.com', 'user3@getgrist.com', + 'user4@getgrist.com', 'user5@getgrist.com']); + // Check the the workspace has no guests. + assert.sameMembers(await listWs(wsId, 'guests'), []); + + // Get the home api from user2's perspective (so we can tweak user1's access to ws1). + const user2 = await getApi('user2', 'org1'); + + // Confirm that user3's maximal role on the org currently is as a guest. + let access = await user1.getOrgAccess('current'); + assert.equal(getRole(access, 'user3@getgrist.com'), 'guests'); + + // Check that user1 is an owner on ws1 (this happens when the workspace's permissions + // were updated by user1, since the user changing access must remain an owner). + access = await user1.getWorkspaceAccess(wsId); + assert.equal(getRole(access, 'user1@getgrist.com'), 'owners'); + + // Lower user1's access to the workspace. + await user2.updateWorkspacePermissions(wsId, { + users: { 'user1@getgrist.com': 'viewers' } + }); + access = await user2.getWorkspaceAccess(wsId); + assert.equal(getRole(access, 'user1@getgrist.com'), 'viewers'); + + // Have user1 change user3's access to the org. + await user1.updateOrgPermissions('current', { + users: { 'user3@getgrist.com': 'viewers' } + }); + access = await user2.getWorkspaceAccess(wsId); + assert.equal(getRole(access, 'user1@getgrist.com'), 'viewers'); + assert.equal(getRole(access, 'user3@getgrist.com'), 'owners'); + + // Ok, that has all been preamble. Now to test user removal. + // Have user1 remove user3's access to the org, checking user1+user3's access before and after. + let countBefore = await countGroupUsers(); + assert.sameMembers(await listUser('user3@getgrist.com'), + ['viewers:org1', 'guests:org1', 'owners:Personal', 'owners:ws1']); + assert.sameMembers(await listUser('user1@getgrist.com'), + ['owners:org1', 'guests:org1', 'owners:Personal', 'viewers:ws1']); + await user1.updateOrgPermissions('current', { + users: { 'user3@getgrist.com': null } + }); + let countAfter = await countGroupUsers(); + // The only resource user3 has access to now is their personal org. + assert.sameMembers(await listUser('user3@getgrist.com'), ['owners:Personal']); + assert.sameMembers(await listUser('user1@getgrist.com'), + ['owners:org1', 'guests:org1', 'owners:Personal', 'owners:ws1']); + assert.sameMembers(await listOrg('org1', 'guests'), + ['user1@getgrist.com', 'user2@getgrist.com', + 'user4@getgrist.com', 'user5@getgrist.com']); + assert.sameMembers(await listWs(wsId, 'guests'), []); + // For overall count of rows in group_users table, here are the changes: + // - Drops: user3 as owner of ws, editor on org, guest on org. + // - Changes: user1 from editor to owner of ws. + assert.equal(countAfter, countBefore - 3); + + // Check view API that user3 is removed from the workspace, and Owner1 promoted to owner. + access = await user2.getWorkspaceAccess(wsId); + assert.equal(getRole(access, 'user3@getgrist.com'), undefined); + assert.equal(getRole(access, 'user1@getgrist.com'), 'owners'); + + // Lower user1's access to the workspace again. + await user2.updateWorkspacePermissions(wsId, { + users: { 'user1@getgrist.com': 'viewers' } + }); + access = await user2.getWorkspaceAccess(wsId); + assert.equal(getRole(access, 'user1@getgrist.com'), 'viewers'); + + // Now have user1 remove user4's access to the org. + countBefore = await countGroupUsers(); + await user1.updateOrgPermissions('current', { + users: { 'user4@getgrist.com': null } + }); + countAfter = await countGroupUsers(); + + // Drops: user4 as editor of ws, guest on org. + // Adds: nothing. + assert.equal(countAfter, countBefore - 2); + assert.sameMembers(await listOrg('org1', 'guests'), + ['user1@getgrist.com', 'user2@getgrist.com', 'user5@getgrist.com']); + + // User4 should be removed from the workspace, and user1's access unchanged (since user4 was + // not an owner) + access = await user2.getWorkspaceAccess(wsId); + assert.equal(getRole(access, 'user4@getgrist.com'), undefined); + assert.equal(getRole(access, 'user1@getgrist.com'), 'viewers'); + + // Now have a fresh user remove user5's access to the org. + await user1.updateOrgPermissions('current', { + users: { + 'user6@getgrist.com': 'owners', + } + }); + const user6 = await getApi('user6', 'org1'); + countBefore = await countGroupUsers(); + await user6.updateOrgPermissions('current', { + users: { 'user5@getgrist.com': null } + }); + countAfter = await countGroupUsers(); + + // Drops: user5 as owner of workspace, guest on org. + // Adds: user6 as owner of workspace, guest on org. + assert.equal(countAfter, countBefore); + assert.sameMembers(await listOrg('org1', 'guests'), + ['user1@getgrist.com', 'user2@getgrist.com', 'user6@getgrist.com']); + }); + + it('cannot remove users from orgs without permission', async function() { + // In test org "org1", create a test workspace "ws1" and a test document "doc1". + const user1 = await getApi('user1', 'org1'); + const wsId = await user1.newWorkspace({name: 'ws1'}, 'current'); + const docId = await user1.newDoc({name: 'doc1'}, wsId); + + // Add user2 and user3 as owners of doc1 + await user1.updateDocPermissions(docId, { + users: { + 'user2@getgrist.com': 'owners', + 'user3@getgrist.com': 'owners', + } + }); + + // Add user2 and user3 as owners of ws1 + await user1.updateWorkspacePermissions(wsId, { + users: { + 'user2@getgrist.com': 'owners', + 'user3@getgrist.com': 'owners', + } + }); + + // Add user2 as member of org, add user3 as editor of org + await user1.updateOrgPermissions('current', { + users: { + 'user2@getgrist.com': 'members', + 'user3@getgrist.com': 'editors', + } + }); + + // user3 should not have the right to remove user2 from org + const user3 = await getApi('user3', 'org1'); + await assert.isRejected(user3.updateOrgPermissions('current', { + users: { 'user2@getgrist.com': null } + })); + + // user2 should not have the right to remove user3 from org + const user2 = await getApi('user2', 'org1'); + await assert.isRejected(user2.updateOrgPermissions('current', { + users: { 'user3@getgrist.com': null } + })); + + // user2 and user3 should still have same access as before + assert.sameMembers(await listUser('user2@getgrist.com'), + ['owners:Personal', 'members:org1', 'owners:ws1', 'owners:doc1', + 'guests:org1', 'guests:ws1']); + assert.sameMembers(await listUser('user3@getgrist.com'), + ['owners:Personal', 'editors:org1', 'owners:ws1', 'owners:doc1', + 'guests:org1', 'guests:ws1']); + }); + + it('does not scrub user for removal from workspace or doc', async function() { + // In test org "org1", create a test workspace "ws1" and a test document "doc1". + const user1 = await getApi('user1', 'org1'); + const wsId = await user1.newWorkspace({name: 'ws1'}, 'current'); + const docId = await user1.newDoc({name: 'doc1'}, wsId); + + // Add user2 and user3 as owners of doc1 + await user1.updateDocPermissions(docId, { + users: { + 'user2@getgrist.com': 'owners', + 'user3@getgrist.com': 'owners', + } + }); + + // Add user2 and user3 as owners of ws1 + await user1.updateWorkspacePermissions(wsId, { + users: { + 'user2@getgrist.com': 'owners', + 'user3@getgrist.com': 'owners', + } + }); + + // Add user2 as member of org, add user3 as editor of org + await user1.updateOrgPermissions('current', { + users: { + 'user2@getgrist.com': 'members', + 'user3@getgrist.com': 'editors', + } + }); + + // user3 can removed user2 from workspace + const user3 = await getApi('user3', 'org1'); + await user3.updateWorkspacePermissions(wsId, { + users: { 'user2@getgrist.com': null } + }); + + // user3's access should be unchanged + assert.sameMembers(await listUser('user3@getgrist.com'), + ['owners:Personal', 'editors:org1', 'owners:ws1', 'owners:doc1', + 'guests:org1', 'guests:ws1']); + // user2's access should be changed just as requested + assert.sameMembers(await listUser('user2@getgrist.com'), + ['owners:Personal', 'members:org1', 'owners:doc1', + 'guests:org1', 'guests:ws1']); + + // put user2 back in workspace + await user3.updateWorkspacePermissions(wsId, { + users: { 'user2@getgrist.com': 'owners' } + }); + assert.sameMembers(await listUser('user2@getgrist.com'), + ['owners:Personal', 'members:org1', 'owners:ws1', 'owners:doc1', + 'guests:org1', 'guests:ws1']); + + // user3 can removed user2 from doc + await user3.updateDocPermissions(docId, { + users: { 'user2@getgrist.com': null } + }); + + // user3's access should be unchanged + assert.sameMembers(await listUser('user3@getgrist.com'), + ['owners:Personal', 'editors:org1', 'owners:ws1', 'owners:doc1', + 'guests:org1', 'guests:ws1']); + // user2's access should be changed just as requested + assert.sameMembers(await listUser('user2@getgrist.com'), + ['owners:Personal', 'members:org1', 'owners:ws1', 'guests:org1']); + }); +}); diff --git a/test/gen-server/lib/suspension.ts b/test/gen-server/lib/suspension.ts new file mode 100644 index 00000000..3d2465c3 --- /dev/null +++ b/test/gen-server/lib/suspension.ts @@ -0,0 +1,55 @@ +import {Organization} from 'app/common/UserAPI'; +import {assert} from 'chai'; +import {TestServer} from 'test/gen-server/apiUtils'; +import {setPlan} from 'test/gen-server/testUtils'; +import {createTmpDir} from 'test/server/docTools'; +import * as testUtils from 'test/server/testUtils'; + +describe('suspension', function() { + let home: TestServer; + let nasa: Organization; + testUtils.setTmpLogLevel('error'); + + before(async function() { + const tmpDir = await createTmpDir(); + home = new TestServer(this); + await home.start(["home", "docs"], {dataDir: tmpDir}); + const nasaApi = await home.createHomeApi('Chimpy', 'nasa'); + nasa = await nasaApi.getOrg('current'); + }); + + after(async function() { + await setPlan(home.dbManager, nasa, nasa.billingAccount!.product.name); + await home.stop(); + }); + + it('limits user to read-only access', async function() { + this.timeout(4000); + + // Open nasa as chimpy (an owner) + const nasaApi = await home.createHomeApi('Chimpy', 'nasa'); + // Set up Jupiter document to have some content + const docId = await home.dbManager.testGetId('Jupiter') as string; + await home.copyFixtureDoc('Hello.grist', docId); + assert((await nasaApi.getDoc(docId)).access, 'owners'); + + // Confirm that user can edit docs + const docApi = nasaApi.getDocAPI(docId); + await assert.isFulfilled(docApi.getRows('Table1')); + await assert.isFulfilled(docApi.updateRows('Table1', { id: [1], A: ['v1'] })); + await assert.isFulfilled(docApi.addRows('Table1', { A: ['v1'] })); + + // Now suspend org + await setPlan(home.dbManager, nasa, 'suspended'); + + // User should no longer be able to edit, but can view and download + // Note a bit of cheating here: the call to getDoc() invalidates docAuthCache; without it, it + // would be a few seconds before the change in access level is visible. + assert((await nasaApi.getDoc(docId)).access, 'viewers'); + await assert.isFulfilled(docApi.getRows('Table1')); + await assert.isRejected(docApi.updateRows('Table1', { id: [1], A: ['v1'] }), /No write access/); + await assert.isRejected(docApi.addRows('Table1', { A: ['v1'] }), /No write access/); + const worker = await nasaApi.getWorkerAPI(docId); + assert(await worker.downloadDoc(docId)); // download still works + }); +}); diff --git a/test/gen-server/lib/urlIds.ts b/test/gen-server/lib/urlIds.ts new file mode 100644 index 00000000..d15fa46f --- /dev/null +++ b/test/gen-server/lib/urlIds.ts @@ -0,0 +1,131 @@ +import {UserAPI} from 'app/common/UserAPI'; +import {Document} from 'app/gen-server/entity/Document'; +import {assert} from 'chai'; +import {TestServer} from 'test/gen-server/apiUtils'; +import * as testUtils from 'test/server/testUtils'; + +describe('urlIds', function() { + let home: TestServer; + let supportWorkspaceId: number; + testUtils.setTmpLogLevel('error'); + + before(async function() { + home = new TestServer(this); + await home.start(["home", "docs"]); + const api = await home.createHomeApi('chimpy', 'nasa'); + await api.updateOrgPermissions('current', {users: { + 'testuser1@getgrist.com': 'owners', + 'testuser2@getgrist.com': 'owners', + }}); + + // Share a workspace in support's personal org with everyone + const support = await home.newSession().createHomeApi('Support', 'docs'); + await home.upgradePersonalOrg('Support'); + supportWorkspaceId = await support.newWorkspace({name: 'Examples & Templates'}, 'current'); + await support.newDoc({name: 'an example', urlId: 'example'}, supportWorkspaceId); + await support.updateWorkspacePermissions(supportWorkspaceId, { + users: {'everyone@getgrist.com': 'viewers', + 'anon@getgrist.com': 'viewers'} + }); + // Update special workspace informationn + await home.dbManager.initializeSpecialIds(); + }); + + after(async function() { + // Undo test-specific configuration + const api = await home.createHomeApi('chimpy', 'nasa'); + await api.updateOrgPermissions('current', {users: { + 'testuser1@getgrist.com': null, + 'testuser2@getgrist.com': null, + }}); + const support = await home.newSession().createHomeApi('Support', 'docs'); + await support.deleteWorkspace(supportWorkspaceId); + await home.dbManager.initializeSpecialIds(); + + await home.stop(); + }); + + for (const org of ['docs', 'nasa']) { + it(`cannot set two docs to the same urlId in ${org}`, async function() { + const api1 = await home.newSession().createHomeApi('testuser1', org); + const api2 = await home.newSession().createHomeApi('testuser2', org); + const ws1 = await getAnyWorkspace(api1); + const ws2 = await getAnyWorkspace(api2); + const doc1 = await api1.newDoc({name: 'testdoc1', urlId: 'urlid-common'}, ws1); + await assert.isRejected(api2.newDoc({name: 'testdoc2', urlId: 'urlid-common'}, ws2), + /urlId already in use/); + assert((await api1.getDoc('urlid-common')).id, doc1); + assert((await api1.getDoc('urlid-common')).urlId, 'urlid-common'); + await api1.deleteDoc(doc1); + }); + + it(`can set two docs to different urlIds in ${org}`, async function() { + const api1 = await home.newSession().createHomeApi('testuser1', org); + const api2 = await home.newSession().createHomeApi('testuser2', org); + const ws1 = await getAnyWorkspace(api1); + const ws2 = await getAnyWorkspace(api2); + const doc1 = await api1.newDoc({name: 'testdoc1', urlId: 'urlid1'}, ws1); + const doc2 = await api2.newDoc({name: 'testdoc2', urlId: 'urlid2'}, ws2); + assert((await api1.getDoc('urlid1')).id, doc1); + assert((await api1.getDoc('urlid1')).urlId, 'urlid1'); + assert((await api2.getDoc('urlid2')).id, doc2); + assert((await api2.getDoc('urlid2')).urlId, 'urlid2'); + }); + + it(`cannot reuse example urlIds in ${org}`, async function() { + const api1 = await home.newSession().createHomeApi('testuser1', org); + const ws1 = await getAnyWorkspace(api1); + await assert.isRejected(api1.newDoc({name: 'my example', urlId: 'example'}, ws1), + /urlId already in use/); + }); + + it(`cannot use an existing docId as a urlId in ${org}`, async function() { + const doc = await home.dbManager.connection.manager.findOneOrFail(Document, {where: {}}); + const prevDocId = doc.id; + try { + // Change doc id to ensure it has characters permitted for a urlId. + // Not all docIds are like that (test doc ids have underscores; current + // style doc ids typically have capital letters in them). + doc.id = 'doc-id'; + await doc.save(); + const api1 = await home.newSession().createHomeApi('testuser1', org); + const ws1 = await getAnyWorkspace(api1); + await assert.isRejected(api1.newDoc({name: 'my example', urlId: doc.id}, ws1), + /urlId already in use as document id/); + } finally { + doc.id = prevDocId; + await doc.save(); + } + }); + + it(`cannot reuse urlIds from ${org} in examples`, async function() { + const api1 = await home.newSession().createHomeApi('testuser1', org); + const ws1 = await getAnyWorkspace(api1); + await api1.newDoc({name: 'my example', urlId: `urlid-${org}`}, ws1); + const support = await home.newSession().createHomeApi('Support', 'docs'); + await assert.isRejected(support.newDoc({name: 'my conflicting example', + urlId: `urlid-${org}`}, supportWorkspaceId), + /urlId already in use/); + }); + } + + it(`correctly uses org information for urlId disambiguation`, async function() { + const api1 = await home.newSession().createHomeApi('testuser1', 'docs'); + const api2 = await home.newSession().createHomeApi('testuser2', 'nasa'); + const ws1 = await getAnyWorkspace(api1); + const ws2 = await getAnyWorkspace(api2); + const doc1 = await api1.newDoc({name: 'testdoc1', urlId: 'urlid-common'}, ws1); + const doc2 = await api2.newDoc({name: 'testdoc2', urlId: 'urlid-common'}, ws2); + assert.equal((await api1.getDoc('urlid-common')).id, doc1); + assert.equal((await api2.getDoc('urlid-common')).id, doc2); + await api1.updateDoc('urlid-common', {name: 'testdoc1-updated'}); + await api2.updateDoc('urlid-common', {name: 'testdoc2-updated'}); + assert.equal((await api1.getDoc('urlid-common')).name, 'testdoc1-updated'); + assert.equal((await api2.getDoc('urlid-common')).name, 'testdoc2-updated'); + }); + + async function getAnyWorkspace(api: UserAPI) { + const workspaces = await api.getOrgWorkspaces('current'); + return workspaces[0]!.id; + } +});