mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(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
This commit is contained in:
parent
e70c294e3d
commit
3e70a77729
248
test/gen-server/lib/DocApiForwarder.ts
Normal file
248
test/gen-server/lib/DocApiForwarder.ts
Normal file
@ -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, getDocWorkerMap} from 'app/gen-server/lib/DocWorkerMap';
|
||||||
|
import {DocStatus, DocWorkerInfo, IDocWorkerMap} from 'app/server/lib/DocWorkerMap';
|
||||||
import { DocWorkerMap } from 'app/gen-server/lib/DocWorkerMap';
|
import {FlexServer} from 'app/server/lib/FlexServer';
|
||||||
import { DocWorkerInfo } from 'app/server/lib/DocWorkerMap';
|
import {Permit} from 'app/server/lib/Permit';
|
||||||
import {expect} from 'chai';
|
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 sinon from 'sinon';
|
||||||
|
import * as testUtils from 'test/server/testUtils';
|
||||||
|
|
||||||
describe('DocWorkerMap', () => {
|
promisifyAll(RedisClient.prototype);
|
||||||
const sandbox = sinon.createSandbox();
|
|
||||||
afterEach(() => {
|
describe('DocWorkerMap', function() {
|
||||||
sandbox.restore();
|
|
||||||
|
let cli: RedisClient;
|
||||||
|
|
||||||
|
testUtils.setTmpLogLevel('error');
|
||||||
|
|
||||||
|
before(async function() {
|
||||||
|
if (!process.env.TEST_REDIS_URL) { this.skip(); }
|
||||||
|
cli = createClient(process.env.TEST_REDIS_URL);
|
||||||
|
await cli.flushdbAsync();
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async function() {
|
||||||
|
if (cli) { await cli.quitAsync(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async function() {
|
||||||
|
if (cli) { await cli.delAsync('groups'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async function() {
|
||||||
|
if (cli) { await cli.flushdbAsync(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can assign a worker when available', async function() {
|
||||||
|
const workers = new DocWorkerMap([cli]);
|
||||||
|
|
||||||
|
// No assignment without workers available
|
||||||
|
await assert.isRejected(workers.assignDocWorker('a-doc'), /no doc workers/);
|
||||||
|
|
||||||
|
// Add a worker
|
||||||
|
await workers.addWorker({id: 'worker1', internalUrl: 'internal', publicUrl: 'public'});
|
||||||
|
|
||||||
|
// Still no assignment
|
||||||
|
await assert.isRejected(workers.assignDocWorker('a-doc'), /no doc workers/);
|
||||||
|
|
||||||
|
// Make worker available
|
||||||
|
await workers.setWorkerAvailability('worker1', true);
|
||||||
|
|
||||||
|
// That worker gets assigned
|
||||||
|
const worker = await workers.assignDocWorker('a-doc');
|
||||||
|
assert.equal(worker.docWorker.id, 'worker1');
|
||||||
|
|
||||||
|
// That assignment is remembered
|
||||||
|
let w = await workers.getDocWorker('a-doc');
|
||||||
|
assert.equal(w && w.docWorker.id, 'worker1');
|
||||||
|
|
||||||
|
// Make worker unavailable for assigment
|
||||||
|
await workers.setWorkerAvailability('worker1', false);
|
||||||
|
|
||||||
|
// Existing assignment remains
|
||||||
|
w = await workers.getDocWorker('a-doc');
|
||||||
|
assert.equal(w && w.docWorker.id, 'worker1');
|
||||||
|
|
||||||
|
// Remove worker
|
||||||
|
await workers.removeWorker('worker1');
|
||||||
|
|
||||||
|
// Assignment is gone away
|
||||||
|
w = await workers.getDocWorker('a-doc');
|
||||||
|
assert.equal(w, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can release assignments', async function() {
|
||||||
|
const workers = new DocWorkerMap([cli]);
|
||||||
|
|
||||||
|
await workers.addWorker({id: 'worker1', internalUrl: 'internal', publicUrl: 'public'});
|
||||||
|
await workers.addWorker({id: 'worker2', internalUrl: 'internal', publicUrl: 'public'});
|
||||||
|
|
||||||
|
await workers.setWorkerAvailability('worker1', true);
|
||||||
|
|
||||||
|
let assignment: DocStatus|null = await workers.assignDocWorker('a-doc');
|
||||||
|
assert.equal(assignment.docWorker.id, 'worker1');
|
||||||
|
|
||||||
|
await workers.setWorkerAvailability('worker2', true);
|
||||||
|
await workers.setWorkerAvailability('worker1', false);
|
||||||
|
|
||||||
|
assignment = await workers.getDocWorker('a-doc');
|
||||||
|
assert.equal(assignment!.docWorker.id, 'worker1');
|
||||||
|
|
||||||
|
await workers.releaseAssignment('worker1', 'a-doc');
|
||||||
|
|
||||||
|
assignment = await workers.getDocWorker('a-doc');
|
||||||
|
assert.equal(assignment, null);
|
||||||
|
|
||||||
|
assignment = await workers.assignDocWorker('a-doc');
|
||||||
|
assert.equal(assignment.docWorker.id, 'worker2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can assign multiple workers', async function() {
|
||||||
|
this.timeout(5000); // Be more generous than 2s default, since this normally takes over 1s.
|
||||||
|
|
||||||
|
const workers = new DocWorkerMap([cli]);
|
||||||
|
|
||||||
|
// Make some workers available
|
||||||
|
const W = 4;
|
||||||
|
for (let i = 0; i < W; i++) {
|
||||||
|
await workers.addWorker({id: `worker${i}`, internalUrl: 'internal', publicUrl: 'public'});
|
||||||
|
await workers.setWorkerAvailability(`worker${i}`, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign some docs
|
||||||
|
const N = 100;
|
||||||
|
const docs: string[] = [];
|
||||||
|
const docWorkers: string[] = [];
|
||||||
|
for (let i = 0; i < N; i++) {
|
||||||
|
const name = `a-doc-${i}`;
|
||||||
|
docs.push(name);
|
||||||
|
const w = await workers.assignDocWorker(name);
|
||||||
|
docWorkers.push(w.docWorker.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check assignment looks plausible (random, so will fail with low prob)
|
||||||
|
const counts = countBy(docWorkers);
|
||||||
|
// Say over half the workers got assigned
|
||||||
|
assert.isAbove(values(counts).length, W / 2);
|
||||||
|
// Say no worker got over half the work
|
||||||
|
const highs = values(counts).filter((k, v) => v > N / 2);
|
||||||
|
assert.equal(highs.length, 0);
|
||||||
|
|
||||||
|
// Check assignments stick
|
||||||
|
for (let i = 0; i < N; i++) {
|
||||||
|
const name = docs[i];
|
||||||
|
const w = await workers.getDocWorker(name);
|
||||||
|
assert.equal(w && w.docWorker.id, docWorkers[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check assignments drop out as workers are removed
|
||||||
|
let remaining = N;
|
||||||
|
for (const w of Object.keys(counts)) {
|
||||||
|
await workers.removeWorker(w);
|
||||||
|
remaining -= counts[w];
|
||||||
|
let ct = 0;
|
||||||
|
for (const name of docs) {
|
||||||
|
if (null !== await workers.getDocWorker(name)) { ct++; }
|
||||||
|
}
|
||||||
|
assert.equal(remaining, ct);
|
||||||
|
}
|
||||||
|
assert.equal(remaining, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can elect workers to groups', async function() {
|
||||||
|
this.timeout(5000);
|
||||||
|
|
||||||
|
// Say we want one worker reserved for "blizzard" and two for "funkytown"
|
||||||
|
await cli.hmsetAsync('groups', {
|
||||||
|
blizzard: 1,
|
||||||
|
funkytown: 2,
|
||||||
|
});
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
await cli.setAsync(`doc-blizzard${i}-group`, 'blizzard');
|
||||||
|
await cli.setAsync(`doc-funkytown${i}-group`, 'funkytown');
|
||||||
|
}
|
||||||
|
let workers = new DocWorkerMap([cli], 'ver1');
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
await workers.addWorker({id: `worker${i}`, internalUrl: 'internal', publicUrl: 'public'});
|
||||||
|
await workers.setWorkerAvailability(`worker${i}`, true);
|
||||||
|
}
|
||||||
|
let elections = await cli.hgetallAsync('elections-ver1');
|
||||||
|
assert.deepEqual(elections, { blizzard: '["worker0"]', funkytown: '["worker1","worker2"]' });
|
||||||
|
assert.sameMembers(await cli.smembersAsync('workers-available-blizzard'), ['worker0']);
|
||||||
|
assert.sameMembers(await cli.smembersAsync('workers-available-funkytown'), ['worker1', 'worker2']);
|
||||||
|
assert.sameMembers(await cli.smembersAsync('workers-available-default'), ['worker3', 'worker4']);
|
||||||
|
assert.sameMembers(await cli.smembersAsync('workers-available'),
|
||||||
|
['worker0', 'worker1', 'worker2', 'worker3', 'worker4']);
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
const assignment = await workers.assignDocWorker(`blizzard${i}`);
|
||||||
|
assert.equal(assignment.docWorker.id, 'worker0');
|
||||||
|
}
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
const assignment = await workers.assignDocWorker(`funkytown${i}`);
|
||||||
|
assert.include(['worker1', 'worker2'], assignment.docWorker.id);
|
||||||
|
}
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
const assignment = await workers.assignDocWorker(`random${i}`);
|
||||||
|
assert.include(['worker3', 'worker4'], assignment.docWorker.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// suppose worker0 dies, and worker5 is added to replace it
|
||||||
|
await workers.removeWorker('worker0');
|
||||||
|
await workers.addWorker({id: `worker5`, internalUrl: 'internal', publicUrl: 'public'});
|
||||||
|
await workers.setWorkerAvailability('worker5', true);
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
const assignment = await workers.assignDocWorker(`blizzard${i}`);
|
||||||
|
assert.equal(assignment.docWorker.id, 'worker5');
|
||||||
|
}
|
||||||
|
|
||||||
|
// suppose worker1 dies, and worker6 is added to replace it
|
||||||
|
await workers.removeWorker('worker1');
|
||||||
|
await workers.addWorker({id: `worker6`, internalUrl: 'internal', publicUrl: 'public'});
|
||||||
|
await workers.setWorkerAvailability('worker6', true);
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
const assignment = await workers.assignDocWorker(`funkytown${i}`);
|
||||||
|
assert.include(['worker2', 'worker6'], assignment.docWorker.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// suppose we add a new deployment...
|
||||||
|
workers = new DocWorkerMap([cli], 'ver2');
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
await workers.addWorker({id: `worker${i}_v2`, internalUrl: 'internal', publicUrl: 'public'});
|
||||||
|
await workers.setWorkerAvailability(`worker${i}_v2`, true);
|
||||||
|
}
|
||||||
|
assert.sameMembers(await cli.smembersAsync('workers-available-blizzard'),
|
||||||
|
['worker5', 'worker0_v2']);
|
||||||
|
assert.sameMembers(await cli.smembersAsync('workers-available-funkytown'),
|
||||||
|
['worker2', 'worker6', 'worker1_v2', 'worker2_v2']);
|
||||||
|
assert.sameMembers(await cli.smembersAsync('workers-available-default'),
|
||||||
|
['worker3', 'worker4', 'worker3_v2', 'worker4_v2']);
|
||||||
|
assert.sameMembers(await cli.smembersAsync('workers-available'),
|
||||||
|
['worker2', 'worker3', 'worker4', 'worker5', 'worker6',
|
||||||
|
'worker0_v2', 'worker1_v2', 'worker2_v2', 'worker3_v2', 'worker4_v2']);
|
||||||
|
|
||||||
|
// ...and then remove the old one
|
||||||
|
workers = new DocWorkerMap([cli], 'ver1');
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
await workers.removeWorker(`worker${i}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// check everything looks as expected
|
||||||
|
workers = new DocWorkerMap([cli], 'ver2');
|
||||||
|
elections = await cli.hgetallAsync('elections-ver2');
|
||||||
|
assert.deepEqual(elections, { blizzard: '["worker0_v2"]',
|
||||||
|
funkytown: '["worker1_v2","worker2_v2"]' });
|
||||||
|
assert.sameMembers(await cli.smembersAsync('workers-available-blizzard'), ['worker0_v2']);
|
||||||
|
assert.sameMembers(await cli.smembersAsync('workers-available-funkytown'), ['worker1_v2', 'worker2_v2']);
|
||||||
|
assert.sameMembers(await cli.smembersAsync('workers-available-default'), ['worker3_v2', 'worker4_v2']);
|
||||||
|
assert.sameMembers(await cli.smembersAsync('workers-available'),
|
||||||
|
['worker0_v2', 'worker1_v2', 'worker2_v2', 'worker3_v2', 'worker4_v2']);
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
const assignment = await workers.assignDocWorker(`blizzard${i}`);
|
||||||
|
assert.equal(assignment.docWorker.id, 'worker0_v2');
|
||||||
|
}
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
const assignment = await workers.assignDocWorker(`funkytown${i}`);
|
||||||
|
assert.include(['worker1_v2', 'worker2_v2'], assignment.docWorker.id);
|
||||||
|
}
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
const assignment = await workers.assignDocWorker(`random${i}`);
|
||||||
|
assert.include(['worker3_v2', 'worker4_v2'], assignment.docWorker.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// check everything about previous deployment got cleaned up
|
||||||
|
assert.equal(await cli.hgetallAsync('elections-ver1'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can assign workers to groups', async function() {
|
||||||
|
this.timeout(5000);
|
||||||
|
const workers = new DocWorkerMap([cli], 'ver1');
|
||||||
|
|
||||||
|
// Register a few regular workers.
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
await workers.addWorker({id: `worker${i}`, internalUrl: 'internal', publicUrl: 'public'});
|
||||||
|
await workers.setWorkerAvailability(`worker${i}`, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register a worker in a special group.
|
||||||
|
await workers.addWorker({id: 'worker_secondary', internalUrl: 'internal', publicUrl: 'public',
|
||||||
|
group: 'secondary'});
|
||||||
|
await workers.setWorkerAvailability('worker_secondary', true);
|
||||||
|
|
||||||
|
// Check that worker lists look sane.
|
||||||
|
assert.sameMembers(await cli.smembersAsync('workers'),
|
||||||
|
['worker0', 'worker1', 'worker2', 'worker_secondary']);
|
||||||
|
assert.sameMembers(await cli.smembersAsync('workers-available'),
|
||||||
|
['worker0', 'worker1', 'worker2']);
|
||||||
|
assert.sameMembers(await cli.smembersAsync('workers-available-default'),
|
||||||
|
['worker0', 'worker1', 'worker2']);
|
||||||
|
assert.sameMembers(await cli.smembersAsync('workers-available-secondary'),
|
||||||
|
['worker_secondary']);
|
||||||
|
|
||||||
|
// Check that worker-*-group keys are as expected.
|
||||||
|
assert.equal(await cli.getAsync('worker-worker_secondary-group'), 'secondary');
|
||||||
|
assert.equal(await cli.getAsync('worker-worker0-group'), null);
|
||||||
|
|
||||||
|
// Check that a doc for the special group is assigned to the correct worker.
|
||||||
|
await cli.setAsync('doc-funkydoc-group', 'secondary');
|
||||||
|
assert.equal((await workers.assignDocWorker('funkydoc')).docWorker.id, 'worker_secondary');
|
||||||
|
|
||||||
|
// Check that other docs don't end up on the special group's worker.
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
assert.match((await workers.assignDocWorker(`normaldoc${i}`)).docWorker.id,
|
||||||
|
/^worker\d$/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can manage task election nominations', async function() {
|
||||||
|
this.timeout(5000);
|
||||||
|
|
||||||
|
const store = new DocWorkerMap([cli]);
|
||||||
|
// allocate two tasks
|
||||||
|
const task1 = await store.getElection('task1', 1000);
|
||||||
|
let task2 = await store.getElection('task2', 1000);
|
||||||
|
assert.notEqual(task1, null);
|
||||||
|
assert.notEqual(task2, null);
|
||||||
|
assert.notEqual(task1, task2);
|
||||||
|
|
||||||
|
// check tasks cannot be immediately reallocated
|
||||||
|
assert.equal(await store.getElection('task1', 1000), null);
|
||||||
|
assert.equal(await store.getElection('task2', 1000), null);
|
||||||
|
|
||||||
|
// try to remove both tasks with a key that is correct for just one of them.
|
||||||
|
await assert.isRejected(store.removeElection('task1', task2!), /could not remove/);
|
||||||
|
await store.removeElection('task2', task2!);
|
||||||
|
|
||||||
|
// check task2 is freed up by reallocating it
|
||||||
|
task2 = await store.getElection('task2', 3000);
|
||||||
|
assert.notEqual(task2, null);
|
||||||
|
|
||||||
|
await delay(1100);
|
||||||
|
|
||||||
|
// task1 should be free now, but not task2
|
||||||
|
const task1b = await store.getElection('task1', 1000);
|
||||||
|
assert.notEqual(task1b, null);
|
||||||
|
assert.notEqual(task1b, task1);
|
||||||
|
assert.equal(await store.getElection('task2', 1000), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can manage permits', async function() {
|
||||||
|
const store = new DocWorkerMap([cli], undefined, {permitMsec: 1000}).getPermitStore('1');
|
||||||
|
|
||||||
|
// Make a doc permit and a workspace permit
|
||||||
|
const permit1: Permit = {docId: 'docId1'};
|
||||||
|
const key1 = await store.setPermit(permit1);
|
||||||
|
assert(key1.startsWith('permit-1-'));
|
||||||
|
const permit2: Permit = {workspaceId: 99};
|
||||||
|
const key2 = await store.setPermit(permit2);
|
||||||
|
assert(key2.startsWith('permit-1-'));
|
||||||
|
assert.notEqual(key1, key2);
|
||||||
|
|
||||||
|
// Check we can read the permits back
|
||||||
|
assert.deepEqual(await store.getPermit(key1), permit1);
|
||||||
|
assert.deepEqual(await store.getPermit(key2), permit2);
|
||||||
|
|
||||||
|
// Check that random permit keys give nothing
|
||||||
|
await assert.isRejected(store.getPermit('dud'), /could not be read/);
|
||||||
|
assert.equal(await store.getPermit('permit-1-dud'), null);
|
||||||
|
|
||||||
|
// Check that we can remove a permit
|
||||||
|
await store.removePermit(key1);
|
||||||
|
assert.equal(await store.getPermit(key1), null);
|
||||||
|
assert.deepEqual(await store.getPermit(key2), permit2);
|
||||||
|
|
||||||
|
// Check that permits expire
|
||||||
|
await delay(1100);
|
||||||
|
assert.equal(await store.getPermit(key2), null);
|
||||||
|
|
||||||
|
// make sure permit stores are distinct
|
||||||
|
const store2 = new DocWorkerMap([cli], undefined, {permitMsec: 1000}).getPermitStore('2');
|
||||||
|
const key3 = await store2.setPermit(permit1);
|
||||||
|
assert(key3.startsWith('permit-2-'));
|
||||||
|
const fakeKey3 = key3.replace('permit-2-', 'permit-1-');
|
||||||
|
assert(fakeKey3.startsWith('permit-1-'));
|
||||||
|
assert.equal(await store.getPermit(fakeKey3), null);
|
||||||
|
await assert.isRejected(store.getPermit(key3), /could not be read/);
|
||||||
|
assert.deepEqual(await store2.getPermit(key3), permit1);
|
||||||
|
await assert.isRejected(store2.getPermit(fakeKey3), /could not be read/);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('group assignment', function() {
|
||||||
|
let servers: {[key: string]: FlexServer};
|
||||||
|
let workers: IDocWorkerMap;
|
||||||
|
before(async function() {
|
||||||
|
// Create a home server and some workers.
|
||||||
|
setUpDB(this);
|
||||||
|
await createInitialDb();
|
||||||
|
const opts = {logToConsole: false, externalStorage: false};
|
||||||
|
// We need to reset some environment variables - we do so
|
||||||
|
// naively, so throw if they are already set.
|
||||||
|
assert.equal(process.env.REDIS_URL, undefined);
|
||||||
|
assert.equal(process.env.GRIST_DOC_WORKER_ID, undefined);
|
||||||
|
assert.equal(process.env.GRIST_WORKER_GROUP, undefined);
|
||||||
|
process.env.REDIS_URL = process.env.TEST_REDIS_URL;
|
||||||
|
|
||||||
|
// Make home server.
|
||||||
|
const home = await mergedServerMain(0, ['home'], opts);
|
||||||
|
|
||||||
|
// Make a worker, not associated with any group.
|
||||||
|
process.env.GRIST_DOC_WORKER_ID = 'worker1';
|
||||||
|
const docs1 = await mergedServerMain(0, ['docs'], opts);
|
||||||
|
|
||||||
|
// Make a worker in "special" group.
|
||||||
|
process.env.GRIST_DOC_WORKER_ID = 'worker2';
|
||||||
|
process.env.GRIST_WORKER_GROUP = 'special';
|
||||||
|
const docs2 = await mergedServerMain(0, ['docs'], opts);
|
||||||
|
|
||||||
|
// Make two worker in "other" group.
|
||||||
|
process.env.GRIST_DOC_WORKER_ID = 'worker3';
|
||||||
|
process.env.GRIST_WORKER_GROUP = 'other';
|
||||||
|
const docs3 = await mergedServerMain(0, ['docs'], opts);
|
||||||
|
process.env.GRIST_DOC_WORKER_ID = 'worker4';
|
||||||
|
process.env.GRIST_WORKER_GROUP = 'other';
|
||||||
|
const docs4 = await mergedServerMain(0, ['docs'], opts);
|
||||||
|
|
||||||
|
servers = {home, docs1, docs2, docs3, docs4};
|
||||||
|
workers = getDocWorkerMap();
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async function() {
|
||||||
|
if (servers) {
|
||||||
|
await Promise.all(Object.values(servers).map(server => server.close()));
|
||||||
|
await removeConnection();
|
||||||
|
delete process.env.REDIS_URL;
|
||||||
|
delete process.env.GRIST_DOC_WORKER_ID;
|
||||||
|
delete process.env.GRIST_WORKER_GROUP;
|
||||||
|
await workers.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can reassign documents between groups', async function() {
|
||||||
|
this.timeout(15000);
|
||||||
|
|
||||||
|
// Create a test documment.
|
||||||
|
const session = new TestSession(servers.home!);
|
||||||
|
const api = await session.createHomeApi('chimpy', 'nasa');
|
||||||
|
const supportApi = await session.createHomeApi('support', 'docs', true);
|
||||||
|
const ws1 = await api.newWorkspace({name: 'ws1'}, 'current');
|
||||||
|
const doc1 = await api.newDoc({name: 'doc1'}, ws1);
|
||||||
|
|
||||||
|
// Exercise it.
|
||||||
|
await api.getDocAPI(doc1).getRows('Table1');
|
||||||
|
|
||||||
|
// Check it is served by only unspecialized worker.
|
||||||
|
assert.equal((await workers.getDocWorker(doc1))?.docWorker.id, 'worker1');
|
||||||
|
|
||||||
|
// Set doc to "special" group.
|
||||||
|
await cli.setAsync(`doc-${doc1}-group`, 'special');
|
||||||
|
|
||||||
|
// Check doc gets reassigned to correct worker.
|
||||||
|
assert.equal(await (await api.testRequest(`${api.getBaseUrl()}/api/docs/${doc1}/assign`, {
|
||||||
|
method: 'POST'
|
||||||
|
})).json(), true);
|
||||||
|
await api.getDocAPI(doc1).getRows('Table1');
|
||||||
|
assert.equal((await workers.getDocWorker(doc1))?.docWorker.id, 'worker2');
|
||||||
|
|
||||||
|
// Set doc to "other" group.
|
||||||
|
await cli.setAsync(`doc-${doc1}-group`, 'other');
|
||||||
|
|
||||||
|
// Check doc gets reassigned to one of the correct workers.
|
||||||
|
assert.equal(await (await api.testRequest(`${api.getBaseUrl()}/api/docs/${doc1}/assign`, {
|
||||||
|
method: 'POST'
|
||||||
|
})).json(), true);
|
||||||
|
await api.getDocAPI(doc1).getRows('Table1');
|
||||||
|
assert.oneOf((await workers.getDocWorker(doc1))?.docWorker.id, ['worker3', 'worker4']);
|
||||||
|
|
||||||
|
// Remove doc from groups.
|
||||||
|
await cli.delAsync(`doc-${doc1}-group`);
|
||||||
|
assert.equal(await (await api.testRequest(`${api.getBaseUrl()}/api/docs/${doc1}/assign`, {
|
||||||
|
method: 'POST'
|
||||||
|
})).json(), true);
|
||||||
|
await api.getDocAPI(doc1).getRows('Table1');
|
||||||
|
|
||||||
|
// Check doc is again served by only unspecialized worker.
|
||||||
|
assert.equal((await workers.getDocWorker(doc1))?.docWorker.id, 'worker1');
|
||||||
|
|
||||||
|
// Check that hitting /assign without a change of group is reported as no-op (false).
|
||||||
|
assert.equal(await (await api.testRequest(`${api.getBaseUrl()}/api/docs/${doc1}/assign`, {
|
||||||
|
method: 'POST'
|
||||||
|
})).json(), false);
|
||||||
|
|
||||||
|
// Check that Chimpy can't use `group` param to update doc group prior to reassignment.
|
||||||
|
const urlWithGroup = new URL(`${api.getBaseUrl()}/api/docs/${doc1}/assign`);
|
||||||
|
urlWithGroup.searchParams.set('group', 'special');
|
||||||
|
assert.equal(await (await api.testRequest(urlWithGroup.toString(), {
|
||||||
|
method: 'POST'
|
||||||
|
})).json(), false);
|
||||||
|
|
||||||
|
// Check that support user can use `group` param in housekeeping endpoint to update
|
||||||
|
// doc group prior to reassignment.
|
||||||
|
const housekeepingUrl = new URL(`${api.getBaseUrl()}/api/housekeeping/docs/${doc1}/assign`);
|
||||||
|
housekeepingUrl.searchParams.set('group', 'special');
|
||||||
|
assert.equal(await (await supportApi.testRequest(housekeepingUrl.toString(), {
|
||||||
|
method: 'POST'
|
||||||
|
})).json(), true);
|
||||||
|
await api.getDocAPI(doc1).getRows('Table1');
|
||||||
|
assert.equal((await workers.getDocWorker(doc1))?.docWorker.id, 'worker2');
|
||||||
|
|
||||||
|
// Check that hitting housekeeping endpoint with the same group is reported as no-op (false).
|
||||||
|
assert.equal(await (await supportApi.testRequest(housekeepingUrl.toString(), {
|
||||||
|
method: 'POST'
|
||||||
|
})).json(), false);
|
||||||
|
|
||||||
|
// Check that specifying a blank group reverts back to the unspecialized worker.
|
||||||
|
housekeepingUrl.searchParams.set('group', '');
|
||||||
|
assert.equal(await (await supportApi.testRequest(housekeepingUrl.toString(), {
|
||||||
|
method: 'POST'
|
||||||
|
})).json(), true);
|
||||||
|
await api.getDocAPI(doc1).getRows('Table1');
|
||||||
|
assert.equal((await workers.getDocWorker(doc1))?.docWorker.id, 'worker1');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('isWorkerRegistered', () => {
|
describe('isWorkerRegistered', () => {
|
||||||
|
116
test/gen-server/lib/HealthCheck.ts
Normal file
116
test/gen-server/lib/HealthCheck.ts
Normal file
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
417
test/gen-server/lib/HomeDBManager.ts
Normal file
417
test/gen-server/lib/HomeDBManager.ts
Normal file
@ -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,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
207
test/gen-server/lib/Housekeeper.ts
Normal file
207
test/gen-server/lib/Housekeeper.ts
Normal file
@ -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);
|
||||||
|
});
|
||||||
|
});
|
160
test/gen-server/lib/emails.ts
Normal file
160
test/gen-server/lib/emails.ts
Normal file
@ -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,
|
||||||
|
},
|
||||||
|
]});
|
||||||
|
});
|
||||||
|
});
|
179
test/gen-server/lib/everyone.ts
Normal file
179
test/gen-server/lib/everyone.ts
Normal file
@ -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']);
|
||||||
|
});
|
||||||
|
});
|
553
test/gen-server/lib/limits.ts
Normal file
553
test/gen-server/lib/limits.ts
Normal file
@ -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'));
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
196
test/gen-server/lib/listing.ts
Normal file
196
test/gen-server/lib/listing.ts
Normal file
@ -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']);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
104
test/gen-server/lib/mergedOrgs.ts
Normal file
104
test/gen-server/lib/mergedOrgs.ts
Normal file
@ -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);
|
||||||
|
});
|
||||||
|
});
|
132
test/gen-server/lib/prefs.ts
Normal file
132
test/gen-server/lib/prefs.ts
Normal file
@ -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);
|
||||||
|
});
|
||||||
|
});
|
135
test/gen-server/lib/previewer.ts
Normal file
135
test/gen-server/lib/previewer.ts
Normal file
@ -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);
|
||||||
|
|
||||||
|
});
|
||||||
|
});
|
440
test/gen-server/lib/removedAt.ts
Normal file
440
test/gen-server/lib/removedAt.ts
Normal file
@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
453
test/gen-server/lib/scrubUserFromOrg.ts
Normal file
453
test/gen-server/lib/scrubUserFromOrg.ts
Normal file
@ -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']);
|
||||||
|
});
|
||||||
|
});
|
55
test/gen-server/lib/suspension.ts
Normal file
55
test/gen-server/lib/suspension.ts
Normal file
@ -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
|
||||||
|
});
|
||||||
|
});
|
131
test/gen-server/lib/urlIds.ts
Normal file
131
test/gen-server/lib/urlIds.ts
Normal file
@ -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…
Reference in New Issue
Block a user