mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(core) updates from grist-core
This commit is contained in:
@@ -34,7 +34,7 @@ import {serveSomething, Serving} from 'test/server/customUtil';
|
||||
import {prepareDatabase} from 'test/server/lib/helpers/PrepareDatabase';
|
||||
import {prepareFilesystemDirectoryForTests} from 'test/server/lib/helpers/PrepareFilesystemDirectoryForTests';
|
||||
import {signal} from 'test/server/lib/helpers/Signal';
|
||||
import {TestServer} from 'test/server/lib/helpers/TestServer';
|
||||
import {TestServer, TestServerReverseProxy} from 'test/server/lib/helpers/TestServer';
|
||||
import * as testUtils from 'test/server/testUtils';
|
||||
import {waitForIt} from 'test/server/wait';
|
||||
import defaultsDeep = require('lodash/defaultsDeep');
|
||||
@@ -42,12 +42,6 @@ import pick = require('lodash/pick');
|
||||
import { getDatabase } from 'test/testUtils';
|
||||
import {testDailyApiLimitFeatures} from 'test/gen-server/seed';
|
||||
|
||||
const chimpy = configForUser('Chimpy');
|
||||
const kiwi = configForUser('Kiwi');
|
||||
const charon = configForUser('Charon');
|
||||
const nobody = configForUser('Anonymous');
|
||||
const support = configForUser('support');
|
||||
|
||||
// some doc ids
|
||||
const docIds: { [name: string]: string } = {
|
||||
ApiDataRecordsTest: 'sampledocid_7',
|
||||
@@ -68,6 +62,18 @@ let hasHomeApi: boolean;
|
||||
let home: TestServer;
|
||||
let docs: TestServer;
|
||||
let userApi: UserAPIImpl;
|
||||
let extraHeadersForConfig = {};
|
||||
|
||||
function makeConfig(username: string): AxiosRequestConfig {
|
||||
const originalConfig = configForUser(username);
|
||||
return {
|
||||
...originalConfig,
|
||||
headers: {
|
||||
...originalConfig.headers,
|
||||
...extraHeadersForConfig
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
describe('DocApi', function () {
|
||||
this.timeout(30000);
|
||||
@@ -77,12 +83,7 @@ describe('DocApi', function () {
|
||||
before(async function () {
|
||||
oldEnv = new testUtils.EnvironmentSnapshot();
|
||||
|
||||
// Clear redis test database if redis is in use.
|
||||
if (process.env.TEST_REDIS_URL) {
|
||||
const cli = createClient(process.env.TEST_REDIS_URL);
|
||||
await cli.flushdbAsync();
|
||||
await cli.quitAsync();
|
||||
}
|
||||
await flushAllRedis();
|
||||
|
||||
// Create the tmp dir removing any previous one
|
||||
await prepareFilesystemDirectoryForTests(tmpDir);
|
||||
@@ -136,6 +137,7 @@ describe('DocApi', function () {
|
||||
});
|
||||
|
||||
it('should not allow anonymous users to create new docs', async () => {
|
||||
const nobody = makeConfig('Anonymous');
|
||||
const resp = await axios.post(`${serverUrl}/api/docs`, null, nobody);
|
||||
assert.equal(resp.status, 403);
|
||||
});
|
||||
@@ -158,6 +160,95 @@ describe('DocApi', function () {
|
||||
testDocApi();
|
||||
});
|
||||
|
||||
describe('behind a reverse-proxy', function () {
|
||||
async function setupServersWithProxy(suitename: string, overrideEnvConf?: NodeJS.ProcessEnv) {
|
||||
const proxy = new TestServerReverseProxy();
|
||||
const additionalEnvConfiguration = {
|
||||
ALLOWED_WEBHOOK_DOMAINS: `example.com,localhost:${webhooksTestPort}`,
|
||||
GRIST_DATA_DIR: dataDir,
|
||||
APP_HOME_URL: await proxy.getServerUrl(),
|
||||
GRIST_ORG_IN_PATH: 'true',
|
||||
GRIST_SINGLE_PORT: '0',
|
||||
...overrideEnvConf
|
||||
};
|
||||
const home = await TestServer.startServer('home', tmpDir, suitename, additionalEnvConfiguration);
|
||||
const docs = await TestServer.startServer(
|
||||
'docs', tmpDir, suitename, additionalEnvConfiguration, home.serverUrl
|
||||
);
|
||||
proxy.requireFromOutsideHeader();
|
||||
|
||||
await proxy.start(home, docs);
|
||||
|
||||
homeUrl = serverUrl = await proxy.getServerUrl();
|
||||
hasHomeApi = true;
|
||||
extraHeadersForConfig = {
|
||||
Origin: serverUrl,
|
||||
...TestServerReverseProxy.FROM_OUTSIDE_HEADER,
|
||||
};
|
||||
|
||||
return {proxy, home, docs};
|
||||
}
|
||||
|
||||
async function tearDown(proxy: TestServerReverseProxy, servers: TestServer[]) {
|
||||
proxy.stop();
|
||||
for (const server of servers) {
|
||||
await server.stop();
|
||||
}
|
||||
await flushAllRedis();
|
||||
}
|
||||
|
||||
let proxy: TestServerReverseProxy;
|
||||
|
||||
describe('should run usual DocApi test', function () {
|
||||
setup('behind-proxy-testdocs', async () => {
|
||||
({proxy, home, docs} = await setupServersWithProxy(suitename));
|
||||
});
|
||||
|
||||
after(() => tearDown(proxy, [home, docs]));
|
||||
|
||||
testDocApi();
|
||||
});
|
||||
|
||||
async function testCompareDocs(proxy: TestServerReverseProxy, home: TestServer) {
|
||||
const chimpy = makeConfig('chimpy');
|
||||
const userApiServerUrl = await proxy.getServerUrl();
|
||||
const chimpyApi = home.makeUserApi(
|
||||
ORG_NAME, 'chimpy', { serverUrl: userApiServerUrl, headers: chimpy.headers as Record<string, string> }
|
||||
);
|
||||
const ws1 = (await chimpyApi.getOrgWorkspaces('current'))[0].id;
|
||||
const docId1 = await chimpyApi.newDoc({name: 'testdoc1'}, ws1);
|
||||
const docId2 = await chimpyApi.newDoc({name: 'testdoc2'}, ws1);
|
||||
const doc1 = chimpyApi.getDocAPI(docId1);
|
||||
|
||||
return doc1.compareDoc(docId2);
|
||||
}
|
||||
|
||||
describe('with APP_HOME_INTERNAL_URL', function () {
|
||||
setup('behind-proxy-with-apphomeinternalurl', async () => {
|
||||
// APP_HOME_INTERNAL_URL will be set by TestServer.
|
||||
({proxy, home, docs} = await setupServersWithProxy(suitename));
|
||||
});
|
||||
after(() => tearDown(proxy, [home, docs]));
|
||||
|
||||
it('should succeed to compare docs', async function () {
|
||||
const res = await testCompareDocs(proxy, home);
|
||||
assert.exists(res);
|
||||
});
|
||||
});
|
||||
|
||||
describe('without APP_HOME_INTERNAL_URL', function () {
|
||||
setup('behind-proxy-without-apphomeinternalurl', async () => {
|
||||
({proxy, home, docs} = await setupServersWithProxy(suitename, {APP_HOME_INTERNAL_URL: ''}));
|
||||
});
|
||||
after(() => tearDown(proxy, [home, docs]));
|
||||
|
||||
it('should succeed to compare docs', async function () {
|
||||
const promise = testCompareDocs(proxy, home);
|
||||
await assert.isRejected(promise, /TestServerReverseProxy: called public URL/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("should work directly with a docworker", async () => {
|
||||
setup('docs', async () => {
|
||||
const additionalEnvConfiguration = {
|
||||
@@ -233,6 +324,17 @@ describe('DocApi', function () {
|
||||
|
||||
// Contains the tests. This is where you want to add more test.
|
||||
function testDocApi() {
|
||||
let chimpy: AxiosRequestConfig, kiwi: AxiosRequestConfig,
|
||||
charon: AxiosRequestConfig, nobody: AxiosRequestConfig, support: AxiosRequestConfig;
|
||||
|
||||
before(function () {
|
||||
chimpy = makeConfig('Chimpy');
|
||||
kiwi = makeConfig('Kiwi');
|
||||
charon = makeConfig('Charon');
|
||||
nobody = makeConfig('Anonymous');
|
||||
support = makeConfig('support');
|
||||
});
|
||||
|
||||
async function generateDocAndUrl(docName: string = "Dummy") {
|
||||
const wid = (await userApi.getOrgWorkspaces('current')).find((w) => w.name === 'Private')!.id;
|
||||
const docId = await userApi.newDoc({name: docName}, wid);
|
||||
@@ -1341,7 +1443,7 @@ function testDocApi() {
|
||||
it(`GET /docs/{did}/tables/{tid}/data supports sorts and limits in ${mode}`, async function () {
|
||||
function makeQuery(sort: string[] | null, limit: number | null) {
|
||||
const url = new URL(`${serverUrl}/api/docs/${docIds.Timesheets}/tables/Table1/data`);
|
||||
const config = configForUser('chimpy');
|
||||
const config = makeConfig('chimpy');
|
||||
if (mode === 'url') {
|
||||
if (sort) {
|
||||
url.searchParams.append('sort', sort.join(','));
|
||||
@@ -2615,6 +2717,18 @@ function testDocApi() {
|
||||
await worker1.copyDoc(docId, undefined, 'copy');
|
||||
});
|
||||
|
||||
it("POST /docs/{did} with sourceDocId copies a document", async function () {
|
||||
const chimpyWs = await userApi.newWorkspace({name: "Chimpy's Workspace"}, ORG_NAME);
|
||||
const resp = await axios.post(`${serverUrl}/api/docs`, {
|
||||
sourceDocumentId: docIds.TestDoc,
|
||||
documentName: 'copy of TestDoc',
|
||||
asTemplate: false,
|
||||
workspaceId: chimpyWs
|
||||
}, chimpy);
|
||||
assert.equal(resp.status, 200);
|
||||
assert.isString(resp.data);
|
||||
});
|
||||
|
||||
it("GET /docs/{did}/download/csv serves CSV-encoded document", async function () {
|
||||
const resp = await axios.get(`${serverUrl}/api/docs/${docIds.Timesheets}/download/csv?tableId=Table1`, chimpy);
|
||||
assert.equal(resp.status, 200);
|
||||
@@ -2801,7 +2915,7 @@ function testDocApi() {
|
||||
});
|
||||
|
||||
it('POST /workspaces/{wid}/import handles empty filenames', async function () {
|
||||
if (!process.env.TEST_REDIS_URL) {
|
||||
if (!process.env.TEST_REDIS_URL || docs.proxiedServer) {
|
||||
this.skip();
|
||||
}
|
||||
const worker1 = await userApi.getWorkerAPI('import');
|
||||
@@ -2809,7 +2923,7 @@ function testDocApi() {
|
||||
const fakeData1 = await testUtils.readFixtureDoc('Hello.grist');
|
||||
const uploadId1 = await worker1.upload(fakeData1, '.grist');
|
||||
const resp = await axios.post(`${worker1.url}/api/workspaces/${wid}/import`, {uploadId: uploadId1},
|
||||
configForUser('Chimpy'));
|
||||
makeConfig('Chimpy'));
|
||||
assert.equal(resp.status, 200);
|
||||
assert.equal(resp.data.title, 'Untitled upload');
|
||||
assert.equal(typeof resp.data.id, 'string');
|
||||
@@ -2855,11 +2969,11 @@ function testDocApi() {
|
||||
});
|
||||
|
||||
it("document is protected during upload-and-import sequence", async function () {
|
||||
if (!process.env.TEST_REDIS_URL) {
|
||||
if (!process.env.TEST_REDIS_URL || home.proxiedServer) {
|
||||
this.skip();
|
||||
}
|
||||
// Prepare an API for a different user.
|
||||
const kiwiApi = new UserAPIImpl(`${home.serverUrl}/o/Fish`, {
|
||||
const kiwiApi = new UserAPIImpl(`${homeUrl}/o/Fish`, {
|
||||
headers: {Authorization: 'Bearer api_key_for_kiwi'},
|
||||
fetch: fetch as any,
|
||||
newFormData: () => new FormData() as any,
|
||||
@@ -2875,18 +2989,18 @@ function testDocApi() {
|
||||
// Check that kiwi only has access to their own upload.
|
||||
let wid = (await kiwiApi.getOrgWorkspaces('current')).find((w) => w.name === 'Big')!.id;
|
||||
let resp = await axios.post(`${worker2.url}/api/workspaces/${wid}/import`, {uploadId: uploadId1},
|
||||
configForUser('Kiwi'));
|
||||
makeConfig('Kiwi'));
|
||||
assert.equal(resp.status, 403);
|
||||
assert.deepEqual(resp.data, {error: "access denied"});
|
||||
|
||||
resp = await axios.post(`${worker2.url}/api/workspaces/${wid}/import`, {uploadId: uploadId2},
|
||||
configForUser('Kiwi'));
|
||||
makeConfig('Kiwi'));
|
||||
assert.equal(resp.status, 200);
|
||||
|
||||
// Check that chimpy has access to their own upload.
|
||||
wid = (await userApi.getOrgWorkspaces('current')).find((w) => w.name === 'Private')!.id;
|
||||
resp = await axios.post(`${worker1.url}/api/workspaces/${wid}/import`, {uploadId: uploadId1},
|
||||
configForUser('Chimpy'));
|
||||
makeConfig('Chimpy'));
|
||||
assert.equal(resp.status, 200);
|
||||
});
|
||||
|
||||
@@ -2963,10 +3077,11 @@ function testDocApi() {
|
||||
});
|
||||
|
||||
it('filters urlIds by org', async function () {
|
||||
if (home.proxiedServer) { this.skip(); }
|
||||
// Make two documents with same urlId
|
||||
const ws1 = (await userApi.getOrgWorkspaces('current'))[0].id;
|
||||
const doc1 = await userApi.newDoc({name: 'testdoc1', urlId: 'urlid'}, ws1);
|
||||
const nasaApi = new UserAPIImpl(`${home.serverUrl}/o/nasa`, {
|
||||
const nasaApi = new UserAPIImpl(`${homeUrl}/o/nasa`, {
|
||||
headers: {Authorization: 'Bearer api_key_for_chimpy'},
|
||||
fetch: fetch as any,
|
||||
newFormData: () => new FormData() as any,
|
||||
@@ -2995,9 +3110,10 @@ function testDocApi() {
|
||||
|
||||
it('allows docId access to any document from merged org', async function () {
|
||||
// Make two documents
|
||||
if (home.proxiedServer) { this.skip(); }
|
||||
const ws1 = (await userApi.getOrgWorkspaces('current'))[0].id;
|
||||
const doc1 = await userApi.newDoc({name: 'testdoc1'}, ws1);
|
||||
const nasaApi = new UserAPIImpl(`${home.serverUrl}/o/nasa`, {
|
||||
const nasaApi = new UserAPIImpl(`${homeUrl}/o/nasa`, {
|
||||
headers: {Authorization: 'Bearer api_key_for_chimpy'},
|
||||
fetch: fetch as any,
|
||||
newFormData: () => new FormData() as any,
|
||||
@@ -3125,11 +3241,17 @@ function testDocApi() {
|
||||
});
|
||||
|
||||
it("GET /docs/{did1}/compare/{did2} tracks changes between docs", async function () {
|
||||
const ws1 = (await userApi.getOrgWorkspaces('current'))[0].id;
|
||||
const docId1 = await userApi.newDoc({name: 'testdoc1'}, ws1);
|
||||
const docId2 = await userApi.newDoc({name: 'testdoc2'}, ws1);
|
||||
const doc1 = userApi.getDocAPI(docId1);
|
||||
const doc2 = userApi.getDocAPI(docId2);
|
||||
// Pass kiwi's headers as it contains both Authorization and Origin headers
|
||||
// if run behind a proxy, so we can ensure that the Origin header check is not made.
|
||||
const userApiServerUrl = docs.proxiedServer ? serverUrl : undefined;
|
||||
const chimpyApi = home.makeUserApi(
|
||||
ORG_NAME, 'chimpy', { serverUrl: userApiServerUrl, headers: chimpy.headers as Record<string, string> }
|
||||
);
|
||||
const ws1 = (await chimpyApi.getOrgWorkspaces('current'))[0].id;
|
||||
const docId1 = await chimpyApi.newDoc({name: 'testdoc1'}, ws1);
|
||||
const docId2 = await chimpyApi.newDoc({name: 'testdoc2'}, ws1);
|
||||
const doc1 = chimpyApi.getDocAPI(docId1);
|
||||
const doc2 = chimpyApi.getDocAPI(docId2);
|
||||
|
||||
// Stick some content in column A so it has a defined type
|
||||
// so diffs are smaller and simpler.
|
||||
@@ -3327,6 +3449,9 @@ function testDocApi() {
|
||||
});
|
||||
|
||||
it('doc worker endpoints ignore any /dw/.../ prefix', async function () {
|
||||
if (docs.proxiedServer) {
|
||||
this.skip();
|
||||
}
|
||||
const docWorkerUrl = docs.serverUrl;
|
||||
let resp = await axios.get(`${docWorkerUrl}/api/docs/${docIds.Timesheets}/tables/Table1/data`, chimpy);
|
||||
assert.equal(resp.status, 200);
|
||||
@@ -4970,18 +5095,26 @@ function testDocApi() {
|
||||
}
|
||||
});
|
||||
|
||||
const chimpyConfig = configForUser("Chimpy");
|
||||
const anonConfig = configForUser("Anonymous");
|
||||
const chimpyConfig = makeConfig("Chimpy");
|
||||
const anonConfig = makeConfig("Anonymous");
|
||||
delete chimpyConfig.headers!["X-Requested-With"];
|
||||
delete anonConfig.headers!["X-Requested-With"];
|
||||
|
||||
let allowedOrigin;
|
||||
|
||||
// Target a more realistic Host than "localhost:port"
|
||||
anonConfig.headers!.Host = chimpyConfig.headers!.Host = 'api.example.com';
|
||||
// (if behind a proxy, we already benefit from a custom and realistic host).
|
||||
if (!home.proxiedServer) {
|
||||
anonConfig.headers!.Host = chimpyConfig.headers!.Host =
|
||||
'api.example.com';
|
||||
allowedOrigin = 'http://front.example.com';
|
||||
} else {
|
||||
allowedOrigin = serverUrl;
|
||||
}
|
||||
|
||||
const url = `${serverUrl}/api/docs/${docId}/tables/Table1/records`;
|
||||
const data = { records: [{ fields: {} }] };
|
||||
|
||||
const allowedOrigin = 'http://front.example.com';
|
||||
const forbiddenOrigin = 'http://evil.com';
|
||||
|
||||
// Normal same origin requests
|
||||
@@ -5213,6 +5346,7 @@ function setup(name: string, cb: () => Promise<void>) {
|
||||
before(async function () {
|
||||
suitename = name;
|
||||
dataDir = path.join(tmpDir, `${suitename}-data`);
|
||||
await flushAllRedis();
|
||||
await fse.mkdirs(dataDir);
|
||||
await setupDataDir(dataDir);
|
||||
await cb();
|
||||
@@ -5231,6 +5365,7 @@ function setup(name: string, cb: () => Promise<void>) {
|
||||
// stop all servers
|
||||
await home.stop();
|
||||
await docs.stop();
|
||||
extraHeadersForConfig = {};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5259,3 +5394,12 @@ async function flushAuth() {
|
||||
await home.testingHooks.flushAuthorizerCache();
|
||||
await docs.testingHooks.flushAuthorizerCache();
|
||||
}
|
||||
|
||||
async function flushAllRedis() {
|
||||
// Clear redis test database if redis is in use.
|
||||
if (process.env.TEST_REDIS_URL) {
|
||||
const cli = createClient(process.env.TEST_REDIS_URL);
|
||||
await cli.flushdbAsync();
|
||||
await cli.quitAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ export class TestProxyServer {
|
||||
const server = new TestProxyServer();
|
||||
await server._prepare(portNumber);
|
||||
return server;
|
||||
|
||||
}
|
||||
|
||||
private _proxyCallsCounter: number = 0;
|
||||
@@ -38,7 +37,6 @@ export class TestProxyServer {
|
||||
}
|
||||
res.sendStatus(responseCode);
|
||||
res.end();
|
||||
//next();
|
||||
});
|
||||
}, portNumber);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import {connectTestingHooks, TestingHooksClient} from "app/server/lib/TestingHooks";
|
||||
import {ChildProcess, execFileSync, spawn} from "child_process";
|
||||
import * as http from "http";
|
||||
import FormData from 'form-data';
|
||||
import path from "path";
|
||||
import * as fse from "fs-extra";
|
||||
import * as testUtils from "test/server/testUtils";
|
||||
import {UserAPIImpl} from "app/common/UserAPI";
|
||||
import {exitPromise} from "app/server/lib/serverUtils";
|
||||
import {exitPromise, getAvailablePort} from "app/server/lib/serverUtils";
|
||||
import log from "app/server/lib/log";
|
||||
import {delay} from "bluebird";
|
||||
import fetch from "node-fetch";
|
||||
import {Writable} from "stream";
|
||||
import express from "express";
|
||||
import { AddressInfo } from "net";
|
||||
import { isAffirmative } from "app/common/gutil";
|
||||
import httpProxy from 'http-proxy';
|
||||
|
||||
/**
|
||||
* This starts a server in a separate process.
|
||||
@@ -24,18 +29,26 @@ export class TestServer {
|
||||
options: {output?: Writable} = {}, // Pipe server output to the given stream
|
||||
): Promise<TestServer> {
|
||||
|
||||
const server = new TestServer(serverTypes, tempDirectory, suitename);
|
||||
const server = new this(serverTypes, tempDirectory, suitename);
|
||||
await server.start(_homeUrl, customEnv, options);
|
||||
return server;
|
||||
}
|
||||
|
||||
public testingSocket: string;
|
||||
public testingHooks: TestingHooksClient;
|
||||
public serverUrl: string;
|
||||
public stopped = false;
|
||||
public get serverUrl() {
|
||||
if (this._proxiedServer) {
|
||||
throw new Error('Direct access to this test server is disallowed');
|
||||
}
|
||||
return this._serverUrl;
|
||||
}
|
||||
public get proxiedServer() { return this._proxiedServer; }
|
||||
|
||||
private _serverUrl: string;
|
||||
private _server: ChildProcess;
|
||||
private _exitPromise: Promise<number | string>;
|
||||
private _proxiedServer: boolean = false;
|
||||
|
||||
private readonly _defaultEnv;
|
||||
|
||||
@@ -44,9 +57,6 @@ export class TestServer {
|
||||
GRIST_INST_DIR: this.rootDir,
|
||||
GRIST_DATA_DIR: path.join(this.rootDir, "data"),
|
||||
GRIST_SERVERS: this._serverTypes,
|
||||
// with port '0' no need to hard code a port number (we can use testing hooks to find out what
|
||||
// port server is listening on).
|
||||
GRIST_PORT: '0',
|
||||
GRIST_DISABLE_S3: 'true',
|
||||
REDIS_URL: process.env.TEST_REDIS_URL,
|
||||
GRIST_TRIGGER_WAIT_DELAY: '100',
|
||||
@@ -68,9 +78,16 @@ export class TestServer {
|
||||
// Unix socket paths typically can't be longer than this. Who knew. Make the error obvious.
|
||||
throw new Error(`Path of testingSocket too long: ${this.testingSocket.length} (${this.testingSocket})`);
|
||||
}
|
||||
const env = {
|
||||
APP_HOME_URL: _homeUrl,
|
||||
|
||||
const port = await getAvailablePort();
|
||||
this._serverUrl = `http://localhost:${port}`;
|
||||
const homeUrl = _homeUrl ?? (this._serverTypes.includes('home') ? this._serverUrl : undefined);
|
||||
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
APP_HOME_URL: homeUrl,
|
||||
APP_HOME_INTERNAL_URL: homeUrl,
|
||||
GRIST_TESTING_SOCKET: this.testingSocket,
|
||||
GRIST_PORT: String(port),
|
||||
...this._defaultEnv,
|
||||
...customEnv
|
||||
};
|
||||
@@ -98,7 +115,7 @@ export class TestServer {
|
||||
.catch(() => undefined);
|
||||
|
||||
await this._waitServerReady();
|
||||
log.info(`server ${this._serverTypes} up and listening on ${this.serverUrl}`);
|
||||
log.info(`server ${this._serverTypes} up and listening on ${this._serverUrl}`);
|
||||
}
|
||||
|
||||
public async stop() {
|
||||
@@ -125,11 +142,9 @@ export class TestServer {
|
||||
|
||||
// create testing hooks and get own port
|
||||
this.testingHooks = await connectTestingHooks(this.testingSocket);
|
||||
const port: number = await this.testingHooks.getOwnPort();
|
||||
this.serverUrl = `http://localhost:${port}`;
|
||||
|
||||
// wait for check
|
||||
return (await fetch(`${this.serverUrl}/status/hooks`, {timeout: 1000})).ok;
|
||||
return (await fetch(`${this._serverUrl}/status/hooks`, {timeout: 1000})).ok;
|
||||
} catch (err) {
|
||||
log.warn("Failed to initialize server", err);
|
||||
return false;
|
||||
@@ -142,14 +157,32 @@ export class TestServer {
|
||||
// Returns the promise for the ChildProcess's signal or exit code.
|
||||
public getExitPromise(): Promise<string|number> { return this._exitPromise; }
|
||||
|
||||
public makeUserApi(org: string, user: string = 'chimpy'): UserAPIImpl {
|
||||
return new UserAPIImpl(`${this.serverUrl}/o/${org}`, {
|
||||
headers: {Authorization: `Bearer api_key_for_${user}`},
|
||||
public makeUserApi(
|
||||
org: string,
|
||||
user: string = 'chimpy',
|
||||
{
|
||||
headers = {Authorization: `Bearer api_key_for_${user}`},
|
||||
serverUrl = this._serverUrl,
|
||||
}: {
|
||||
headers?: Record<string, string>
|
||||
serverUrl?: string,
|
||||
} = { headers: undefined, serverUrl: undefined },
|
||||
): UserAPIImpl {
|
||||
return new UserAPIImpl(`${serverUrl}/o/${org}`, {
|
||||
headers,
|
||||
fetch: fetch as unknown as typeof globalThis.fetch,
|
||||
newFormData: () => new FormData() as any,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Assuming that the server is behind a reverse-proxy (like TestServerReverseProxy),
|
||||
* disallow access to the serverUrl to prevent the tests to join the server directly.
|
||||
*/
|
||||
public disallowDirectAccess() {
|
||||
this._proxiedServer = true;
|
||||
}
|
||||
|
||||
private async _waitServerReady() {
|
||||
// It's important to clear the timeout, because it can prevent node from exiting otherwise,
|
||||
// which is annoying when running only this test for debugging.
|
||||
@@ -170,3 +203,103 @@ export class TestServer {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a reverse-proxy for a home and a doc worker.
|
||||
*
|
||||
* The workers are then disallowed to be joined directly, the tests are assumed to
|
||||
* pass through this reverse-proxy.
|
||||
*
|
||||
* You may use it like follow:
|
||||
* ```ts
|
||||
* const proxy = new TestServerReverseProxy();
|
||||
* // Create here a doc and a home workers with their env variables
|
||||
* proxy.requireFromOutsideHeader(); // Optional
|
||||
* await proxy.start(home, docs);
|
||||
* ```
|
||||
*/
|
||||
export class TestServerReverseProxy {
|
||||
|
||||
// Use a different hostname for the proxy than the doc and home workers'
|
||||
// so we can ensure that either we omit the Origin header (so the internal calls to home and doc workers
|
||||
// are not considered as CORS requests), or otherwise we fail because the hostnames are different
|
||||
// https://github.com/gristlabs/grist-core/blob/24b39c651b9590cc360cc91b587d3e1b301a9c63/app/server/lib/requestUtils.ts#L85-L98
|
||||
public static readonly HOSTNAME: string = 'grist-test-proxy.127.0.0.1.nip.io';
|
||||
|
||||
public static FROM_OUTSIDE_HEADER = {"X-FROM-OUTSIDE": true};
|
||||
|
||||
private _app = express();
|
||||
private _proxyServer: http.Server;
|
||||
private _proxy: httpProxy = httpProxy.createProxy();
|
||||
private _address: Promise<AddressInfo>;
|
||||
private _requireFromOutsideHeader = false;
|
||||
|
||||
public get stopped() { return !this._proxyServer.listening; }
|
||||
|
||||
public constructor() {
|
||||
this._proxyServer = this._app.listen(0);
|
||||
this._address = new Promise((resolve) => {
|
||||
this._proxyServer.once('listening', () => {
|
||||
resolve(this._proxyServer.address() as AddressInfo);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Require the reverse-proxy to be called from the outside world.
|
||||
* This assumes that every requests to the proxy includes the header
|
||||
* provided in TestServerReverseProxy.FROM_OUTSIDE_HEADER
|
||||
*
|
||||
* If a call is done by a worker (assuming they don't include that header),
|
||||
* the proxy rejects with a FORBIDEN http status.
|
||||
*/
|
||||
public requireFromOutsideHeader() {
|
||||
this._requireFromOutsideHeader = true;
|
||||
}
|
||||
|
||||
public async start(homeServer: TestServer, docServer: TestServer) {
|
||||
this._app.all(['/dw/dw1', '/dw/dw1/*'], (oreq, ores) => this._getRequestHandlerFor(docServer));
|
||||
this._app.all('/*', this._getRequestHandlerFor(homeServer));
|
||||
|
||||
// Forbid now the use of serverUrl property, so we don't allow the tests to
|
||||
// call the workers directly
|
||||
homeServer.disallowDirectAccess();
|
||||
docServer.disallowDirectAccess();
|
||||
|
||||
log.info('proxy server running on ', await this.getServerUrl());
|
||||
}
|
||||
|
||||
public async getAddress() {
|
||||
return this._address;
|
||||
}
|
||||
|
||||
public async getServerUrl() {
|
||||
const address = await this.getAddress();
|
||||
return `http://${TestServerReverseProxy.HOSTNAME}:${address.port}`;
|
||||
}
|
||||
|
||||
public stop() {
|
||||
if (this.stopped) {
|
||||
return;
|
||||
}
|
||||
log.info("Stopping node TestServerReverseProxy");
|
||||
this._proxyServer.close();
|
||||
this._proxy.close();
|
||||
}
|
||||
|
||||
private _getRequestHandlerFor(server: TestServer) {
|
||||
const serverUrl = new URL(server.serverUrl);
|
||||
|
||||
return (oreq: express.Request, ores: express.Response) => {
|
||||
log.debug(`[proxy] Requesting (method=${oreq.method}): ${new URL(oreq.url, serverUrl).href}`);
|
||||
|
||||
// See the requireFromOutsideHeader() method for the explanation
|
||||
if (this._requireFromOutsideHeader && !isAffirmative(oreq.get("X-FROM-OUTSIDE"))) {
|
||||
log.error('TestServerReverseProxy: called public URL from internal');
|
||||
return ores.status(403).json({error: "TestServerReverseProxy: called public URL from internal "});
|
||||
}
|
||||
|
||||
this._proxy.web(oreq, ores, { target: serverUrl });
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user