(core) test: move gen-server tests into core

Summary:
These are tests that we just never moved into the public
repo. It's just a small chore to make them public.

Test Plan: Make sure the tests still pass

Reviewers: jarek

Reviewed By: jarek

Differential Revision: https://phab.getgrist.com/D4311
pull/1150/head
Jordi Gutiérrez Hermoso 1 month ago
parent e70c294e3d
commit 3e70a77729

@ -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]]);
});
});

@ -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';
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);
}
describe('DocWorkerMap', () => {
const sandbox = sinon.createSandbox();
afterEach(() => {
sandbox.restore();
// 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', () => {

@ -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<Promise<void>> = [];
const resolvers: Array<() => void> = [];
for (let i = 0; i < driver.master.options.max; i++) {
const promise = new Promise<void>((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);
});
});
}
});

@ -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,
},
]
);
});
});

@ -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);
});
});

@ -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<string>;
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,
},
]});
});
});

@ -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']);
});
});

@ -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'));
});
});

@ -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']);
}
});
});

@ -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);
});
});

@ -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);
});
});

@ -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);
});
});

@ -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']);
});
});
});

@ -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<string[]> {
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<string[]> {
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']);
});
});

@ -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
});
});

@ -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;
}
});
Loading…
Cancel
Save