(core) disentangle some server tests, release to core, add GRIST_PROXY_AUTH_HEADER test

Summary:
This shuffles some server tests to make them available in grist-core,
and adds a test for the `GRIST_PROXY_AUTH_HEADER` feature added in
https://github.com/gristlabs/grist-core/pull/165

It includes a fix for a header normalization issue for websocket connections.

Test Plan: added test

Reviewers: georgegevoian

Reviewed By: georgegevoian

Differential Revision: https://phab.getgrist.com/D3326
This commit is contained in:
Paul Fitzpatrick
2022-03-24 13:11:26 -04:00
parent 64c9717ac1
commit de703343d0
15 changed files with 4151 additions and 7 deletions

66
test/server/customUtil.ts Normal file
View File

@@ -0,0 +1,66 @@
import {getAppRoot} from 'app/server/lib/places';
import {fromCallback} from 'bluebird';
import * as express from 'express';
import * as http from 'http';
import {AddressInfo, Socket} from 'net';
import * as path from 'path';
import {fixturesRoot} from 'test/server/testUtils';
export interface Serving {
url: string;
shutdown: () => void;
}
// Adds static files from a directory.
// By default exposes /fixture/sites
export function addStatic(app: express.Express, rootDir?: string) {
// mix in a copy of the plugin api
app.use(/^\/(grist-plugin-api.js)$/, (req, res) =>
res.sendFile(req.params[0], {root:
path.resolve(getAppRoot(), "static")}));
app.use(express.static(rootDir || path.resolve(fixturesRoot, "sites"), {
setHeaders: (res) => {
res.set("Access-Control-Allow-Origin", "*");
}
}));
}
// Serve from a directory.
export async function serveStatic(rootDir: string): Promise<Serving> {
return serveSomething(app => addStatic(app, rootDir));
}
// Serve a string of html.
export async function serveSinglePage(html: string): Promise<Serving> {
return serveSomething(app => {
app.get('', (req, res) => res.send(html));
});
}
export function serveCustomViews(): Promise<Serving> {
return serveStatic(path.resolve(fixturesRoot, "sites"));
}
export async function serveSomething(setup: (app: express.Express) => void, port= 0): Promise<Serving> {
const app = express();
const server = http.createServer(app);
await fromCallback((cb: any) => server.listen(port, cb));
const connections = new Set<Socket>();
server.on('connection', (conn) => {
connections.add(conn);
conn.on('close', () => connections.delete(conn));
});
function shutdown() {
server.close();
for (const conn of connections) { conn.destroy(); }
}
port = (server.address() as AddressInfo).port;
app.set('port', port);
setup(app);
const url = `http://localhost:${port}`;
return {url, shutdown};
}

243
test/server/docTools.ts Normal file
View File

@@ -0,0 +1,243 @@
import {getDocWorkerMap} from 'app/gen-server/lib/DocWorkerMap';
import {ActiveDoc} from 'app/server/lib/ActiveDoc';
import {DummyAuthorizer} from 'app/server/lib/Authorizer';
import {create} from 'app/server/lib/create';
import {DocManager} from 'app/server/lib/DocManager';
import {DocSession, makeExceptionalDocSession} from 'app/server/lib/DocSession';
import {DocStorageManager} from 'app/server/lib/DocStorageManager';
import {GristServer} from 'app/server/lib/GristServer';
import {IDocStorageManager} from 'app/server/lib/IDocStorageManager';
import {getAppRoot} from 'app/server/lib/places';
import {PluginManager} from 'app/server/lib/PluginManager';
import {createTmpDir as createTmpUploadDir, FileUploadInfo, globalUploadSet} from 'app/server/lib/uploads';
import * as testUtils from 'test/server/testUtils';
import {assert} from 'chai';
import * as fse from 'fs-extra';
import {tmpdir} from 'os';
import * as path from 'path';
import * as tmp from 'tmp';
tmp.setGracefulCleanup();
// it is sometimes useful in debugging to turn off automatic cleanup of docs and workspaces.
const noCleanup = Boolean(process.env.NO_CLEANUP);
/**
* Use from a test suite to get an object with convenient methods for creating ActiveDocs:
*
* createDoc(docName): creates a new empty document.
* loadFixtureDoc(docName): loads a copy of a fixture document.
* loadDoc(docName): loads a given document, e.g. previously created with createDoc().
* createFakeSession(): creates a fake DocSession for use when applying user actions.
*
* Also available are accessors for the created "managers":
* getDocManager()
* getStorageManager()
* getPluginManager()
*
* It also takes care of cleaning up any created ActiveDocs.
* @param persistAcrossCases Don't shut down created ActiveDocs between test cases.
* @param useFixturePlugins Use the plugins in `test/fixtures/plugins`
*/
export function createDocTools(options: {persistAcrossCases?: boolean,
useFixturePlugins?: boolean,
storageManager?: IDocStorageManager,
server?: GristServer} = {}) {
let tmpDir: string;
let docManager: DocManager;
async function doBefore() {
tmpDir = await createTmpDir();
const pluginManager = options.useFixturePlugins ? await createFixturePluginManager() : undefined;
docManager = await createDocManager({tmpDir, pluginManager, storageManager: options.storageManager,
server: options.server});
}
async function doAfter() {
// Clean up at the end of the test suite (in addition to the optional per-test cleanup).
await testUtils.captureLog('info', () => docManager.shutdownAll());
assert.equal(docManager.numOpenDocs(), 0);
await globalUploadSet.cleanupAll();
// Clean up the temp directory.
if (!noCleanup) {
await fse.remove(tmpDir);
}
}
// Allow using outside of mocha
if (typeof before !== "undefined") {
before(doBefore);
after(doAfter);
// Check after each test case that all ActiveDocs got shut down.
afterEach(async function() {
if (!options.persistAcrossCases) {
await docManager.shutdownAll();
assert.equal(docManager.numOpenDocs(), 0);
}
});
}
const systemSession = makeExceptionalDocSession('system');
return {
/** create a fake session for use when applying user actions to a document */
createFakeSession(): DocSession {
return {client: null, authorizer: new DummyAuthorizer('editors', 'doc')} as any as DocSession;
},
/** create a throw-away, empty document for testing purposes */
async createDoc(docName: string): Promise<ActiveDoc> {
return docManager.createNewEmptyDoc(systemSession, docName);
},
/** load a copy of a fixture document for testing purposes */
async loadFixtureDoc(docName: string): Promise<ActiveDoc> {
const copiedDocName = await testUtils.useFixtureDoc(docName, docManager.storageManager);
return this.loadDoc(copiedDocName);
},
/** load a copy of a local document at an arbitrary path on disk for testing purposes */
async loadLocalDoc(srcPath: string): Promise<ActiveDoc> {
const copiedDocName = await testUtils.useLocalDoc(srcPath, docManager.storageManager);
return this.loadDoc(copiedDocName);
},
/** like `loadFixtureDoc`, but lets you rename the document on disk */
async loadFixtureDocAs(docName: string, alias: string): Promise<ActiveDoc> {
const copiedDocName = await testUtils.useFixtureDoc(docName, docManager.storageManager, alias);
return this.loadDoc(copiedDocName);
},
/** Loads a given document, e.g. previously created with createDoc() */
async loadDoc(docName: string): Promise<ActiveDoc> {
return docManager.fetchDoc(systemSession, docName);
},
getDocManager() { return docManager; },
getStorageManager() { return docManager.storageManager; },
getPluginManager() { return docManager.pluginManager; },
/** Setup that needs to be done before using the tools, typically called by mocha */
before() { return doBefore(); },
/** Teardown that needs to be done after using the tools, typically called by mocha */
after() { return doAfter(); },
};
}
/**
* Returns a DocManager for tests, complete with a PluginManager and DocStorageManager.
* @param options.pluginManager The PluginManager to use; defaults to using a real global singleton
* that loads built-in modules.
*/
export async function createDocManager(
options: {tmpDir?: string, pluginManager?: PluginManager,
storageManager?: IDocStorageManager,
server?: GristServer} = {}): Promise<DocManager> {
// Set Grist home to a temporary directory, and wipe it out on exit.
const tmpDir = options.tmpDir || await createTmpDir();
const docStorageManager = options.storageManager || new DocStorageManager(tmpDir);
const pluginManager = options.pluginManager || await getGlobalPluginManager();
const store = getDocWorkerMap();
const internalPermitStore = store.getPermitStore('1');
const externalPermitStore = store.getPermitStore('2');
return new DocManager(docStorageManager, pluginManager, null, options.server || {
...createDummyGristServer(),
getPermitStore() { return internalPermitStore; },
getExternalPermitStore() { return externalPermitStore; },
getStorageManager() { return docStorageManager; },
});
}
export function createDummyGristServer(): GristServer {
return {
create,
getHost() { return 'localhost:4242'; },
getHomeUrl() { return 'http://localhost:4242'; },
getHomeUrlByDocId() { return Promise.resolve('http://localhost:4242'); },
getMergedOrgUrl() { return 'http://localhost:4242'; },
getOwnUrl() { return 'http://localhost:4242'; },
getPermitStore() { throw new Error('no permit store'); },
getExternalPermitStore() { throw new Error('no external permit store'); },
getGristConfig() { return { homeUrl: '', timestampMs: 0 }; },
getOrgUrl() { return Promise.resolve(''); },
getResourceUrl() { return Promise.resolve(''); },
getSessions() { throw new Error('no sessions'); },
getComm() { throw new Error('no comms'); },
getHosts() { throw new Error('no hosts'); },
getHomeDBManager() { throw new Error('no db'); },
getStorageManager() { throw new Error('no storage manager'); },
getNotifier() { throw new Error('no notifier'); },
getDocTemplate() { throw new Error('no doc template'); },
getTag() { return 'tag'; },
sendAppPage() { return Promise.resolve(); },
};
}
export async function createTmpDir(): Promise<string> {
const tmpRootDir = process.env.TESTDIR || tmpdir();
await fse.mkdirs(tmpRootDir);
return fse.realpath(await tmp.dirAsync({
dir: tmpRootDir,
prefix: 'grist_test_',
unsafeCleanup: true,
keep: noCleanup,
}));
}
/**
* Creates a file with the given name (and simple dummy content) in dirPath, and returns
* FileUploadInfo for it.
*/
export async function createFile(dirPath: string, name: string): Promise<FileUploadInfo> {
const absPath = path.join(dirPath, name);
await fse.outputFile(absPath, `${name}:${name}\n`);
return {
absPath,
origName: name,
size: (await fse.stat(absPath)).size,
ext: path.extname(name),
};
}
/**
* Creates an upload with the given filenames (containg simple dummy content), in the
* globalUploadSet, and returns its uploadId. The upload is registered with the given accessId
* (userId), and the same id must be used to retrieve it.
*/
export async function createUpload(fileNames: string[], accessId: string|null): Promise<number> {
const {tmpDir, cleanupCallback} = await createTmpUploadDir({});
const files = await Promise.all(fileNames.map((name) => createFile(tmpDir, name)));
return globalUploadSet.registerUpload(files, tmpDir, cleanupCallback, accessId);
}
let _globalPluginManager: PluginManager|null = null;
// Helper to create a singleton PluginManager. This includes loading built-in plugins. Since most
// tests don't make any use of it, it's fine to reuse a single one. For tests that need a custom
// one, pass one into createDocManager().
export async function getGlobalPluginManager(): Promise<PluginManager> {
if (!_globalPluginManager) {
const appRoot = getAppRoot();
_globalPluginManager = new PluginManager(appRoot);
await _globalPluginManager.initialize();
}
return _globalPluginManager;
}
// Path to the folder where builtIn plugins leave in test/fixtures
export const builtInFolder = path.join(testUtils.fixturesRoot, 'plugins/builtInPlugins');
// Path to the folder where installed plugins leave in test/fixtures
export const installedFolder = path.join(testUtils.fixturesRoot, 'plugins/installedPlugins');
// Creates a plugin manager which loads the plugins in `test/fixtures/plugins`
async function createFixturePluginManager() {
const p = new PluginManager(builtInFolder, installedFolder);
p.appRoot = getAppRoot();
await p.initialize();
return p;
}

167
test/server/gristClient.ts Normal file
View File

@@ -0,0 +1,167 @@
import { DocAction } from 'app/common/DocActions';
import { FlexServer } from 'app/server/lib/FlexServer';
import axios from 'axios';
import pick = require('lodash/pick');
import * as WebSocket from 'ws';
interface GristRequest {
reqId: number;
method: string;
args: any[];
}
interface GristResponse {
reqId: number;
error?: string;
errorCode?: string;
data?: any;
}
interface GristMessage {
type: 'clientConnect' | 'docUserAction';
docFD: number;
data: any;
}
export class GristClient {
public messages: GristMessage[] = [];
private _requestId: number = 0;
private _pending: Array<GristResponse|GristMessage> = [];
private _consumer: () => void;
private _ignoreTrivialActions: boolean = false;
constructor(public ws: any) {
ws.onmessage = (data: any) => {
const msg = pick(JSON.parse(data.data),
['reqId', 'error', 'errorCode', 'data', 'type', 'docFD']);
if (this._ignoreTrivialActions && msg.type === 'docUserAction' &&
msg.data?.actionGroup?.internal === true &&
msg.data?.docActions?.length === 0) {
return;
}
this._pending.push(msg);
if (this._consumer) { this._consumer(); }
};
}
// After a document is opened, the sandbox recomputes its formulas and sends any changes.
// The client will receive an update even if there are no changes. This may be useful in
// the future to know that the document is up to date. But for testing, this asynchronous
// message can be awkward. Call this method to ignore it.
public ignoreTrivialActions() {
this._ignoreTrivialActions = true;
}
public flush() {
this._pending = [];
}
public shift() {
return this._pending.shift();
}
public count() {
return this._pending.length;
}
public async read(): Promise<any> {
for (;;) {
if (this._pending.length) {
return this._pending.shift();
}
await new Promise(resolve => this._consumer = resolve);
}
}
public async readMessage(): Promise<GristMessage> {
const result = await this.read();
if (!result.type) {
throw new Error(`message looks wrong: ${JSON.stringify(result)}`);
}
return result;
}
public async readResponse(): Promise<GristResponse> {
this.messages = [];
for (;;) {
const result = await this.read();
if (result.reqId === undefined) {
this.messages.push(result);
continue;
}
if (result.reqId !== this._requestId) {
throw new Error("unexpected request id");
}
return result;
}
}
// Helper to read the next docUserAction ignoring anything else (e.g. a duplicate clientConnect).
public async readDocUserAction(): Promise<DocAction[]> {
while (true) { // eslint-disable-line no-constant-condition
const msg = await this.readMessage();
if (msg.type === 'docUserAction') {
return msg.data.docActions;
}
}
}
public async send(method: string, ...args: any[]): Promise<GristResponse> {
const p = this.readResponse();
this._requestId++;
const req: GristRequest = {
reqId: this._requestId,
method,
args
};
this.ws.send(JSON.stringify(req));
const result = await p;
return result;
}
public async close() {
this.ws.terminate();
this.ws.close();
}
public async openDocOnConnect(docId: string) {
const msg = await this.readMessage();
if (msg.type !== 'clientConnect') { throw new Error('expected clientConnect'); }
const openDoc = await this.send('openDoc', docId);
if (openDoc.error) { throw new Error('error in openDocOnConnect'); }
return openDoc;
}
}
export async function openClient(server: FlexServer, email: string, org: string,
emailHeader?: string): Promise<GristClient> {
const headers: Record<string, string> = {};
if (!emailHeader) {
const resp = await axios.get(`${server.getOwnUrl()}/test/session`);
const cookie = resp.headers['set-cookie'][0];
if (email !== 'anon@getgrist.com') {
const cid = decodeURIComponent(cookie.split('=')[1].split(';')[0]);
const comm = server.getComm();
const sessionId = comm.getSessionIdFromCookie(cid);
const scopedSession = comm.getOrCreateSession(sessionId, {org});
const profile = { email, email_verified: true, name: "Someone" };
await scopedSession.updateUserProfile({} as any, profile);
}
headers.Cookie = cookie;
} else {
headers[emailHeader] = email;
}
const ws = new WebSocket('ws://localhost:' + server.getOwnPort() + `/o/${org}`, {
headers
});
await new Promise(function(resolve, reject) {
ws.on('open', function() {
resolve(ws);
});
ws.on('error', function(err: any) {
reject(err);
});
});
return new GristClient(ws);
}

View File

@@ -0,0 +1,305 @@
import {parseUrlId} from 'app/common/gristUrls';
import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
import {DocManager} from 'app/server/lib/DocManager';
import {FlexServer} from 'app/server/lib/FlexServer';
import axios from 'axios';
import {assert} from 'chai';
import {toPairs} from 'lodash';
import {createInitialDb, removeConnection, setUpDB} from 'test/gen-server/seed';
import {configForUser, getGristConfig} from 'test/gen-server/testUtils';
import {createDocTools} from 'test/server/docTools';
import {openClient} from 'test/server/gristClient';
import * as testUtils from 'test/server/testUtils';
import * as uuidv4 from 'uuid/v4';
let serverUrl: string;
let server: FlexServer;
let dbManager: HomeDBManager;
async function activateServer(home: FlexServer, docManager: DocManager) {
await home.initHomeDBManager();
home.addHosts();
home.addDocWorkerMap();
home.addAccessMiddleware();
dbManager = home.getHomeDBManager();
await home.loadConfig({});
home.addSessions();
home.addHealthCheck();
docManager.testSetHomeDbManager(dbManager);
home.testSetDocManager(docManager);
await home.start();
home.addAccessMiddleware();
home.addApiMiddleware();
home.addJsonSupport();
await home.addLandingPages();
home.addHomeApi();
await home.addDoc();
home.addApiErrorHandlers();
serverUrl = home.getOwnUrl();
}
const chimpy = configForUser('Chimpy');
const charon = configForUser('Charon');
const fixtures: {[docName: string]: string|null} = {
Bananas: 'Hello.grist',
Pluto: 'Hello.grist',
};
describe('Authorizer', function() {
testUtils.setTmpLogLevel('fatal');
server = new FlexServer(0, 'test docWorker');
const docTools = createDocTools({persistAcrossCases: true, useFixturePlugins: false,
server});
const docs: {[name: string]: {id: string}} = {};
// Loads the fixtures documents so that they are available to the doc worker under the correct
// names.
async function loadFixtureDocs() {
for (const [docName, fixtureDoc] of toPairs(fixtures)) {
const docId = String(await dbManager.testGetId(docName));
if (fixtureDoc) {
await docTools.loadFixtureDocAs(fixtureDoc, docId);
} else {
await docTools.createDoc(docId);
}
docs[docName] = {id: docId};
}
}
let oldEnv: testUtils.EnvironmentSnapshot;
before(async function() {
this.timeout(5000);
setUpDB(this);
oldEnv = new testUtils.EnvironmentSnapshot();
process.env.GRIST_PROXY_AUTH_HEADER = 'X-email';
await createInitialDb();
await activateServer(server, docTools.getDocManager());
await loadFixtureDocs();
});
after(async function() {
const messages = await testUtils.captureLog('warn', async () => {
await server.close();
await removeConnection();
});
assert.lengthOf(messages, 0);
oldEnv.restore();
});
// TODO XXX Is it safe to remove this support now?
// (It used to be implemented in getDocAccessInfo() in Authorizer.ts).
it.skip("viewer gets redirect by title", async function() {
const resp = await axios.get(`${serverUrl}/o/pr/doc/Bananas`, chimpy);
assert.equal(resp.status, 200);
assert.equal(getGristConfig(resp.data).assignmentId, 'sample_6');
assert.match(resp.request.res.responseUrl, /\/doc\/sample_6$/);
const resp2 = await axios.get(`${serverUrl}/o/nasa/doc/Pluto`, chimpy);
assert.equal(resp2.status, 200);
assert.equal(getGristConfig(resp2.data).assignmentId, 'sample_2');
assert.match(resp2.request.res.responseUrl, /\/doc\/sample_2$/);
});
it("stranger gets consistent refusal regardless of title", async function() {
const resp = await axios.get(`${serverUrl}/o/pr/doc/Bananas`, charon);
assert.equal(resp.status, 404);
assert.notMatch(resp.data, /sample_6/);
const resp2 = await axios.get(`${serverUrl}/o/pr/doc/Bananas2`, charon);
assert.equal(resp2.status, 404);
assert.notMatch(resp.data, /sample_6/);
assert.deepEqual(resp.data, resp2.data);
});
it("viewer can access title", async function() {
const resp = await axios.get(`${serverUrl}/o/pr/doc/sample_6`, chimpy);
assert.equal(resp.status, 200);
const config = getGristConfig(resp.data);
assert.equal(config.getDoc![config.assignmentId!].name, 'Bananas');
});
it("stranger cannot access title", async function() {
const resp = await axios.get(`${serverUrl}/o/pr/doc/sample_6`, charon);
assert.equal(resp.status, 403);
assert.notMatch(resp.data, /Bananas/);
});
it("viewer cannot access document from wrong org", async function() {
const resp = await axios.get(`${serverUrl}/o/nasa/doc/sample_6`, chimpy);
assert.equal(resp.status, 404);
});
it("websocket allows openDoc for viewer", async function() {
const cli = await openClient(server, 'chimpy@getgrist.com', 'pr');
cli.ignoreTrivialActions();
assert.equal((await cli.readMessage()).type, 'clientConnect');
const openDoc = await cli.send("openDoc", "sample_6");
assert.equal(openDoc.error, undefined);
assert.match(JSON.stringify(openDoc.data), /Table1/);
await cli.close();
});
it("websocket forbids openDoc for stranger", async function() {
const cli = await openClient(server, 'charon@getgrist.com', 'pr');
cli.ignoreTrivialActions();
assert.equal((await cli.readMessage()).type, 'clientConnect');
const openDoc = await cli.send("openDoc", "sample_6");
assert.match(openDoc.error!, /No view access/);
assert.equal(openDoc.data, undefined);
assert.match(openDoc.errorCode!, /AUTH_NO_VIEW/);
await cli.close();
});
it("websocket forbids applyUserActions for viewer", async function() {
const cli = await openClient(server, 'charon@getgrist.com', 'nasa');
cli.ignoreTrivialActions();
assert.equal((await cli.readMessage()).type, 'clientConnect');
const openDoc = await cli.openDocOnConnect("sample_2");
assert.equal(openDoc.error, undefined);
const nonce = uuidv4();
const applyUserActions = await cli.send("applyUserActions",
0,
[["UpdateRecord", "Table1", 1, {A: nonce}], {}]);
assert.lengthOf(cli.messages, 0); // no user actions pushed to client
assert.match(applyUserActions.error!, /No write access/);
assert.match(applyUserActions.errorCode!, /AUTH_NO_EDIT/);
const fetchTable = await cli.send("fetchTable", 0, "Table1");
assert.equal(fetchTable.error, undefined);
assert.notInclude(JSON.stringify(fetchTable.data), nonce);
await cli.close();
});
it("websocket allows applyUserActions for editor", async function() {
const cli = await openClient(server, 'chimpy@getgrist.com', 'nasa');
cli.ignoreTrivialActions();
assert.equal((await cli.readMessage()).type, 'clientConnect');
const openDoc = await cli.openDocOnConnect("sample_2");
assert.equal(openDoc.error, undefined);
const nonce = uuidv4();
const applyUserActions = await cli.send("applyUserActions",
0,
[["UpdateRecord", "Table1", 1, {A: nonce}]]);
assert.lengthOf(cli.messages, 1); // user actions pushed to client
assert.equal(applyUserActions.error, undefined);
const fetchTable = await cli.send("fetchTable", 0, "Table1");
assert.equal(fetchTable.error, undefined);
assert.include(JSON.stringify(fetchTable.data), nonce);
await cli.close();
});
it("can keep different simultaneous clients of a doc straight", async function() {
const editor = await openClient(server, 'chimpy@getgrist.com', 'nasa');
assert.equal((await editor.readMessage()).type, 'clientConnect');
const viewer = await openClient(server, 'charon@getgrist.com', 'nasa');
assert.equal((await viewer.readMessage()).type, 'clientConnect');
const stranger = await openClient(server, 'kiwi@getgrist.com', 'nasa');
assert.equal((await stranger.readMessage()).type, 'clientConnect');
editor.ignoreTrivialActions();
viewer.ignoreTrivialActions();
stranger.ignoreTrivialActions();
assert.equal((await editor.send("openDoc", "sample_2")).error, undefined);
assert.equal((await viewer.send("openDoc", "sample_2")).error, undefined);
assert.match((await stranger.send("openDoc", "sample_2")).error!, /No view access/);
const action = [0, [["UpdateRecord", "Table1", 1, {A: "foo"}]]];
assert.equal((await editor.send("applyUserActions", ...action)).error, undefined);
assert.match((await viewer.send("applyUserActions", ...action)).error!, /No write access/);
// Different message here because sending actions without a doc being open.
assert.match((await stranger.send("applyUserActions", ...action)).error!, /Invalid/);
});
it("previewer has view access to docs", async function() {
const cli = await openClient(server, 'thumbnail@getgrist.com', 'nasa');
cli.ignoreTrivialActions();
assert.equal((await cli.readMessage()).type, 'clientConnect');
const openDoc = await cli.send("openDoc", "sample_2");
assert.equal(openDoc.error, undefined);
const nonce = uuidv4();
const applyUserActions = await cli.send("applyUserActions",
0,
[["UpdateRecord", "Table1", 1, {A: nonce}], {}]);
assert.lengthOf(cli.messages, 0); // no user actions pushed to client
assert.match(applyUserActions.error!, /No write access/);
assert.match(applyUserActions.errorCode!, /AUTH_NO_EDIT/);
const fetchTable = await cli.send("fetchTable", 0, "Table1");
assert.equal(fetchTable.error, undefined);
assert.notInclude(JSON.stringify(fetchTable.data), nonce);
await cli.close();
});
it("viewer can fork doc", async function() {
const cli = await openClient(server, 'charon@getgrist.com', 'nasa');
cli.ignoreTrivialActions();
assert.equal((await cli.readMessage()).type, 'clientConnect');
const openDoc = await cli.send("openDoc", "sample_2");
assert.equal(openDoc.error, undefined);
const result = await cli.send("fork", 0);
assert.equal(result.data.docId, result.data.urlId);
const parts = parseUrlId(result.data.docId);
assert.equal(parts.trunkId, "sample_2");
assert.isAbove(parts.forkId!.length, 4);
assert.equal(parts.forkUserId, await dbManager.testGetId('Charon') as number);
});
it("anon can fork doc", async function() {
// anon does not have access to doc initially
const cli = await openClient(server, 'anon@getgrist.com', 'nasa');
cli.ignoreTrivialActions();
assert.equal((await cli.readMessage()).type, 'clientConnect');
let openDoc = await cli.send("openDoc", "sample_2");
assert.match(openDoc.error!, /No view access/);
// grant anon access to doc and retry
await dbManager.updateDocPermissions({
userId: await dbManager.testGetId('Chimpy') as number,
urlId: 'sample_2',
org: 'nasa'
}, {users: {"anon@getgrist.com": "viewers"}});
dbManager.flushDocAuthCache();
openDoc = await cli.send("openDoc", "sample_2");
assert.equal(openDoc.error, undefined);
// make a fork
const result = await cli.send("fork", 0);
assert.equal(result.data.docId, result.data.urlId);
const parts = parseUrlId(result.data.docId);
assert.equal(parts.trunkId, "sample_2");
assert.isAbove(parts.forkId!.length, 4);
assert.equal(parts.forkUserId, undefined);
});
it("can set user via GRIST_PROXY_AUTH_HEADER", async function() {
// User can access a doc by setting header.
const docUrl = `${serverUrl}/o/pr/api/docs/sample_6`;
const resp = await axios.get(docUrl, {
headers: {'X-email': 'chimpy@getgrist.com'}
});
assert.equal(resp.data.name, 'Bananas');
// Unknown user is denied.
await assert.isRejected(axios.get(docUrl, {
headers: {'X-email': 'notchimpy@getgrist.com'}
}));
// User can access a doc via websocket by setting header.
let cli = await openClient(server, 'chimpy@getgrist.com', 'pr', 'X-email');
cli.ignoreTrivialActions();
assert.equal((await cli.readMessage()).type, 'clientConnect');
let openDoc = await cli.send("openDoc", "sample_6");
assert.equal(openDoc.error, undefined);
assert.match(JSON.stringify(openDoc.data), /Table1/);
await cli.close();
// Unknown user is denied.
cli = await openClient(server, 'notchimpy@getgrist.com', 'pr', 'X-email');
cli.ignoreTrivialActions();
assert.equal((await cli.readMessage()).type, 'clientConnect');
openDoc = await cli.send("openDoc", "sample_6");
assert.match(openDoc.error!, /No view access/);
assert.equal(openDoc.data, undefined);
assert.match(openDoc.errorCode!, /AUTH_NO_VIEW/);
await cli.close();
});
});

2589
test/server/lib/DocApi.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -169,7 +169,7 @@ export function assertMatchArray(stringArray: string[], regexArray: RegExp[]) {
* @param {String} errCode - Error code to check against `err.code` from the caller.
* @param {RegExp} errRegexp - Regular expression to check against `err.message` from the caller.
*/
export function expectRejection(promise: Promise<any>, errCode: number, errRegexp: RegExp) {
export function expectRejection(promise: Promise<any>, errCode: number|string, errRegexp: RegExp) {
return promise
.then(function() {
assert(false, "Expected promise to return an error: " + errCode);
@@ -307,4 +307,11 @@ export class EnvironmentSnapshot {
}
}
export async function getBuildFile(relativePath: string): Promise<string> {
if (await fse.pathExists(path.join('_build', relativePath))) {
return path.join('_build', relativePath);
}
return path.join('_build', 'core', relativePath);
}
export { assert };