mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
Introduce APP_HOME_INTERNAL_URL and fix duplicate docs (#915)
Context: On self-hosted instances, some places in the code rely on the fact that we resolves public domains while being behind reverse proxies. This leads to cases where features are not available, such as the "Duplicate document" one. Bugs that are solved - n self-hosted instances: Impossible to open templates and tutorials right after having converted them; Impossible to submit forms since version 1.1.13; Impossible to restore a previous version of a document (snapshot); Impossible to copy a document; Solution: Introduce the APP_HOME_INTERNAL_URL env variable, which is quite the same as APP_DOC_INTERNAL_URL except that it may point to any home worker; Make /api/worker/:assignmentId([^/]+)/?* return not only the doc worker public url but also the internal one, and adapt the call points like fetchDocs; Ensure that the home and doc worker internal urls are trusted by trustOrigin; --------- Co-authored-by: jordigh <jordigh@octave.org>
This commit is contained in:
@@ -35,19 +35,13 @@ 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');
|
||||
import pick = require('lodash/pick');
|
||||
import { getDatabase } from 'test/testUtils';
|
||||
|
||||
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);
|
||||
@@ -4974,18 +5099,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
|
||||
@@ -5217,6 +5350,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();
|
||||
@@ -5235,6 +5369,7 @@ function setup(name: string, cb: () => Promise<void>) {
|
||||
// stop all servers
|
||||
await home.stop();
|
||||
await docs.stop();
|
||||
extraHeadersForConfig = {};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5263,3 +5398,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