feat: Split up server creation and request handling

This allows us to decouple the WebSocket listening from the HTTP configs,
making these features completely orthogonal.
This commit is contained in:
Joachim Van Herwegen
2022-09-29 16:34:38 +02:00
parent 764ce3cc28
commit 4223dcf8a4
64 changed files with 949 additions and 694 deletions

View File

@@ -4,7 +4,7 @@ import type { App } from '../../src/init/App';
import { getPort } from '../util/Util';
import { getDefaultVariables, getTestConfigPath, instantiateFromConfig } from './Config';
const port = getPort('WebSocketsProtocol');
const port = getPort('LegacyWebSocketsProtocol');
const serverUrl = `http://localhost:${port}/`;
const headers = { forwarded: 'host=example.pod;proto=https' };
@@ -14,7 +14,7 @@ describe('A server with the Solid WebSockets API behind a proxy', (): void => {
beforeAll(async(): Promise<void> => {
app = await instantiateFromConfig(
'urn:solid-server:default:App',
getTestConfigPath('server-without-auth.json'),
getTestConfigPath('legacy-websockets.json'),
getDefaultVariables(port, 'https://example.pod/'),
) as App;

View File

@@ -1,39 +1,36 @@
import type { Server } from 'http';
import request from 'supertest';
import type { BaseHttpServerFactory } from '../../src/server/BaseHttpServerFactory';
import type { HttpHandlerInput } from '../../src/server/HttpHandler';
import { HttpHandler } from '../../src/server/HttpHandler';
import type { App } from '../../src/init/App';
import type { HttpServerFactory } from '../../src/server/HttpServerFactory';
import { splitCommaSeparated } from '../../src/util/StringUtil';
import { getPort } from '../util/Util';
import { getTestConfigPath, instantiateFromConfig } from './Config';
import { getDefaultVariables, getTestConfigPath, instantiateFromConfig } from './Config';
const port = getPort('Middleware');
class SimpleHttpHandler extends HttpHandler {
public async handle(input: HttpHandlerInput): Promise<void> {
input.response.writeHead(200, { location: '/' });
input.response.end('Hello World');
}
}
describe('An http server with middleware', (): void => {
let app: App;
let server: Server;
beforeAll(async(): Promise<void> => {
const factory = await instantiateFromConfig(
'urn:solid-server:default:HttpServerFactory',
getTestConfigPath('server-middleware.json'),
{
'urn:solid-server:default:LdpHandler': new SimpleHttpHandler(),
'urn:solid-server:default:variable:baseUrl': 'https://example.pod/',
'urn:solid-server:default:variable:showStackTrace': true,
},
) as BaseHttpServerFactory;
server = factory.startServer(port);
const instances = await instantiateFromConfig(
'urn:solid-server:test:Instances',
[
getTestConfigPath('server-middleware.json'),
],
getDefaultVariables(port),
) as { app: App; factory: HttpServerFactory };
({ app } = instances);
server = await instances.factory.createServer();
server.listen(port);
});
afterAll(async(): Promise<void> => {
server.close();
// Even though the server was started separately, there might still be finalizers that need to be stopped
await app.stop();
});
it('sets a Vary header containing Accept.', async(): Promise<void> => {
@@ -75,7 +72,6 @@ describe('An http server with middleware', (): void => {
expect(res.header).toEqual(expect.objectContaining({
'access-control-allow-origin': '*',
'access-control-allow-headers': 'content-type',
'updates-via': 'wss://example.pod/',
'x-powered-by': 'Community Solid Server',
}));
const { vary } = res.header;
@@ -90,12 +86,12 @@ describe('An http server with middleware', (): void => {
});
it('specifies CORS origin header if an origin was supplied.', async(): Promise<void> => {
const res = await request(server).get('/').set('origin', 'test.com').expect(200);
const res = await request(server).options('/').set('origin', 'test.com').expect(204);
expect(res.header).toEqual(expect.objectContaining({ 'access-control-allow-origin': 'test.com' }));
});
it('exposes the Accept-[Method] header via CORS.', async(): Promise<void> => {
const res = await request(server).get('/').expect(200);
const res = await request(server).options('/').expect(204);
const exposed = res.header['access-control-expose-headers'];
expect(splitCommaSeparated(exposed)).toContain('Accept-Patch');
expect(splitCommaSeparated(exposed)).toContain('Accept-Post');
@@ -103,45 +99,39 @@ describe('An http server with middleware', (): void => {
});
it('exposes the Last-Modified and ETag headers via CORS.', async(): Promise<void> => {
const res = await request(server).get('/').expect(200);
const res = await request(server).options('/').expect(204);
const exposed = res.header['access-control-expose-headers'];
expect(splitCommaSeparated(exposed)).toContain('ETag');
expect(splitCommaSeparated(exposed)).toContain('Last-Modified');
});
it('exposes the Link header via CORS.', async(): Promise<void> => {
const res = await request(server).get('/').expect(200);
const res = await request(server).options('/').expect(204);
const exposed = res.header['access-control-expose-headers'];
expect(splitCommaSeparated(exposed)).toContain('Link');
});
it('exposes the Location header via CORS.', async(): Promise<void> => {
const res = await request(server).get('/').expect(200);
const res = await request(server).options('/').expect(204);
const exposed = res.header['access-control-expose-headers'];
expect(splitCommaSeparated(exposed)).toContain('Location');
});
it('exposes the WAC-Allow header via CORS.', async(): Promise<void> => {
const res = await request(server).get('/').expect(200);
const res = await request(server).options('/').expect(204);
const exposed = res.header['access-control-expose-headers'];
expect(splitCommaSeparated(exposed)).toContain('WAC-Allow');
});
it('exposes the Updates-Via header via CORS.', async(): Promise<void> => {
const res = await request(server).get('/').expect(200);
const res = await request(server).options('/').expect(204);
const exposed = res.header['access-control-expose-headers'];
expect(splitCommaSeparated(exposed)).toContain('Updates-Via');
});
it('exposes the Www-Authenticate header via CORS.', async(): Promise<void> => {
const res = await request(server).get('/').expect(200);
const res = await request(server).options('/').expect(204);
const exposed = res.header['access-control-expose-headers'];
expect(splitCommaSeparated(exposed)).toContain('Www-Authenticate');
});
it('sends incoming requests to the handler.', async(): Promise<void> => {
const response = request(server).get('/').set('Host', 'test.com');
expect(response).toBeDefined();
await response.expect(200).expect('Hello World');
});
});

View File

@@ -6,8 +6,8 @@
"css:config/app/setup/disabled.json",
"css:config/http/handler/simple.json",
"css:config/http/middleware/no-websockets.json",
"css:config/http/server-factory/no-websockets.json",
"css:config/http/middleware/default.json",
"css:config/http/server-factory/http.json",
"css:config/http/static/default.json",
"css:config/identity/access/public.json",

View File

@@ -5,19 +5,23 @@
"css:config/app/init/initialize-root.json",
"css:config/app/setup/disabled.json",
"css:config/http/handler/simple.json",
"css:config/http/middleware/no-websockets.json",
"css:config/http/server-factory/no-websockets.json",
"css:config/http/middleware/default.json",
"css:config/http/notifications/disabled.json",
"css:config/http/server-factory/http.json",
"css:config/http/static/default.json",
"css:config/identity/access/public.json",
"css:config/identity/handler/default.json",
"css:config/identity/ownership/token.json",
"css:config/identity/pod/static.json",
"css:config/identity/registration/disabled.json",
"css:config/ldp/authentication/debug-auth-header.json",
"css:config/ldp/authorization/webacl.json",
"css:config/ldp/handler/default.json",
"css:config/ldp/metadata-parser/default.json",
"css:config/ldp/metadata-writer/default.json",
"css:config/ldp/modes/default.json",
"css:config/storage/key-value/memory.json",
"css:config/storage/middleware/default.json",
"css:config/util/auxiliary/acl.json",

View File

@@ -0,0 +1,37 @@
{
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^5.0.0/components/context.jsonld",
"import": [
"css:config/app/main/default.json",
"css:config/app/init/initialize-root.json",
"css:config/app/setup/disabled.json",
"css:config/http/handler/default.json",
"css:config/http/middleware/default.json",
"css:config/http/notifications/legacy-websockets.json",
"css:config/http/server-factory/http.json",
"css:config/http/static/default.json",
"css:config/identity/access/public.json",
"css:config/identity/email/default.json",
"css:config/identity/handler/default.json",
"css:config/identity/ownership/token.json",
"css:config/identity/pod/static.json",
"css:config/identity/registration/enabled.json",
"css:config/ldp/authentication/dpop-bearer.json",
"css:config/ldp/authorization/webacl.json",
"css:config/ldp/handler/default.json",
"css:config/ldp/metadata-parser/default.json",
"css:config/ldp/metadata-writer/default.json",
"css:config/ldp/modes/default.json",
"css:config/storage/backend/memory.json",
"css:config/storage/key-value/resource-store.json",
"css:config/storage/middleware/default.json",
"css:config/util/auxiliary/acl.json",
"css:config/util/identifiers/suffix.json",
"css:config/util/index/default.json",
"css:config/util/logging/winston.json",
"css:config/util/representation-conversion/default.json",
"css:config/util/resource-locker/memory.json",
"css:config/util/variables/default.json"
],
"@graph": [
]
}

View File

@@ -6,8 +6,8 @@
"css:config/app/setup/disabled.json",
"css:config/http/handler/simple.json",
"css:config/http/middleware/no-websockets.json",
"css:config/http/server-factory/no-websockets.json",
"css:config/http/middleware/default.json",
"css:config/http/server-factory/http.json",
"css:config/http/static/default.json",
"css:config/identity/access/public.json",

View File

@@ -5,8 +5,9 @@
"css:config/app/init/initialize-root.json",
"css:config/app/setup/disabled.json",
"css:config/http/handler/default.json",
"css:config/http/middleware/websockets.json",
"css:config/http/server-factory/websockets.json",
"css:config/http/middleware/default.json",
"css:config/http/notifications/legacy-websockets.json",
"css:config/http/server-factory/http.json",
"css:config/http/static/default.json",
"css:config/identity/access/public.json",
"css:config/identity/email/default.json",

View File

@@ -5,8 +5,9 @@
"css:config/app/init/initialize-root.json",
"css:config/app/setup/disabled.json",
"css:config/http/handler/default.json",
"css:config/http/middleware/websockets.json",
"css:config/http/server-factory/websockets.json",
"css:config/http/middleware/default.json",
"css:config/http/notifications/legacy-websockets.json",
"css:config/http/server-factory/http.json",
"css:config/http/static/default.json",
"css:config/identity/access/public.json",
"css:config/identity/email/default.json",

View File

@@ -5,8 +5,9 @@
"css:config/app/init/default.json",
"css:config/app/setup/disabled.json",
"css:config/http/handler/default.json",
"css:config/http/middleware/websockets.json",
"css:config/http/server-factory/websockets.json",
"css:config/http/middleware/default.json",
"css:config/http/notifications/legacy-websockets.json",
"css:config/http/server-factory/http.json",
"css:config/http/static/default.json",
"css:config/identity/access/restricted.json",
"css:config/identity/email/default.json",

View File

@@ -5,8 +5,9 @@
"css:config/app/init/initialize-root.json",
"css:config/app/setup/disabled.json",
"css:config/http/handler/default.json",
"css:config/http/middleware/no-websockets.json",
"css:config/http/server-factory/no-websockets.json",
"css:config/http/middleware/default.json",
"css:config/http/notifications/disabled.json",
"css:config/http/server-factory/http.json",
"css:config/http/static/default.json",
"css:config/identity/access/public.json",
"css:config/identity/email/default.json",

View File

@@ -5,10 +5,12 @@
"css:config/app/init/initialize-root.json",
"css:config/app/setup/disabled.json",
"css:config/http/handler/default.json",
"css:config/http/middleware/websockets.json",
"css:config/http/server-factory/websockets.json",
"css:config/http/middleware/default.json",
"css:config/http/notifications/legacy-websockets.json",
"css:config/http/server-factory/http.json",
"css:config/http/static/default.json",
"css:config/identity/access/public.json",
"css:config/identity/handler/default.json",
"css:config/identity/ownership/token.json",
"css:config/identity/pod/static.json",

View File

@@ -5,10 +5,12 @@
"css:config/app/init/initialize-root.json",
"css:config/app/setup/disabled.json",
"css:config/http/handler/default.json",
"css:config/http/middleware/websockets.json",
"css:config/http/server-factory/websockets.json",
"css:config/http/middleware/default.json",
"css:config/http/notifications/legacy-websockets.json",
"css:config/http/server-factory/http.json",
"css:config/http/static/default.json",
"css:config/identity/access/public.json",
"css:config/identity/handler/default.json",
"css:config/identity/ownership/token.json",
"css:config/identity/pod/static.json",

View File

@@ -1,16 +1,51 @@
{
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^5.0.0/components/context.jsonld",
"import": [
"css:config/app/main/default.json",
"css:config/app/init/initialize-root.json",
"css:config/app/setup/disabled.json",
"css:config/http/handler/simple.json",
"css:config/http/middleware/websockets.json",
"css:config/http/server-factory/websockets.json",
"css:config/http/middleware/default.json",
"css:config/http/notifications/legacy-websockets.json",
"css:config/http/server-factory/http.json",
"css:config/http/static/default.json",
"css:config/identity/access/public.json",
"css:config/identity/email/default.json",
"css:config/identity/handler/default.json",
"css:config/identity/ownership/token.json",
"css:config/identity/pod/static.json",
"css:config/identity/registration/enabled.json",
"css:config/ldp/authentication/dpop-bearer.json",
"css:config/ldp/authorization/webacl.json",
"css:config/ldp/handler/default.json",
"css:config/ldp/metadata-parser/default.json",
"css:config/ldp/metadata-writer/default.json",
"css:config/ldp/modes/default.json",
"css:config/storage/backend/memory.json",
"css:config/storage/key-value/resource-store.json",
"css:config/storage/middleware/default.json",
"css:config/util/auxiliary/acl.json",
"css:config/util/identifiers/suffix.json",
"css:config/util/index/default.json",
"css:config/util/logging/winston.json",
"css:config/util/representation-conversion/default.json",
"css:config/util/resource-locker/memory.json",
"css:config/util/variables/default.json"
],
"@graph": [
{
"@id": "urn:solid-server:default:LdpHandler",
"@type": "Variable"
"@id": "urn:solid-server:test:Instances",
"@type": "RecordObject",
"record": [
{
"RecordObject:_record_key": "app",
"RecordObject:_record_value": { "@id": "urn:solid-server:default:App" }
},
{
"RecordObject:_record_key": "factory",
"RecordObject:_record_value": { "@id": "urn:solid-server:default:ServerFactory" }
}
]
}
]
}

View File

@@ -5,12 +5,16 @@
"css:config/app/init/initialize-root.json",
"css:config/app/setup/disabled.json",
"css:config/http/handler/simple.json",
"css:config/http/middleware/no-websockets.json",
"css:config/http/server-factory/no-websockets.json",
"css:config/http/middleware/default.json",
"css:config/http/notifications/disabled.json",
"css:config/http/server-factory/http.json",
"css:config/http/static/default.json",
"css:config/identity/handler/account-store/default.json",
"css:config/identity/ownership/unsafe-no-check.json",
"css:config/identity/pod/static.json",
"css:config/ldp/authentication/debug-auth-header.json",
"css:config/ldp/authorization/allow-all.json",
"css:config/ldp/handler/default.json",
@@ -26,7 +30,10 @@
"css:config/util/logging/winston.json",
"css:config/util/representation-conversion/default.json",
"css:config/util/resource-locker/redis.json",
"css:config/util/variables/default.json"
"css:config/util/variables/default.json",
"css:config/identity/handler/account-store/default.json",
"css:config/identity/ownership/unsafe-no-check.json"
],
"@graph": [
{

View File

@@ -5,8 +5,9 @@
"css:config/app/init/initialize-root.json",
"css:config/app/setup/disabled.json",
"css:config/http/handler/default.json",
"css:config/http/middleware/no-websockets.json",
"css:config/http/server-factory/no-websockets.json",
"css:config/http/middleware/default.json",
"css:config/http/notifications/disabled.json",
"css:config/http/server-factory/http.json",
"css:config/http/static/default.json",
"css:config/identity/access/public.json",
"css:config/identity/email/default.json",
@@ -20,6 +21,7 @@
"css:config/ldp/metadata-parser/default.json",
"css:config/ldp/metadata-writer/default.json",
"css:config/ldp/modes/default.json",
"css:config/storage/key-value/memory.json",
"css:config/storage/middleware/default.json",
"css:config/util/auxiliary/acl.json",

View File

@@ -5,12 +5,16 @@
"css:config/app/init/initialize-root.json",
"css:config/app/setup/disabled.json",
"css:config/http/handler/simple.json",
"css:config/http/middleware/websockets.json",
"css:config/http/server-factory/websockets.json",
"css:config/http/middleware/default.json",
"css:config/http/notifications/legacy-websockets.json",
"css:config/http/server-factory/http.json",
"css:config/http/static/default.json",
"css:config/identity/handler/account-store/default.json",
"css:config/identity/ownership/unsafe-no-check.json",
"css:config/identity/pod/static.json",
"css:config/identity/registration/disabled.json",
"css:config/ldp/authentication/dpop-bearer.json",
"css:config/ldp/authorization/allow-all.json",
"css:config/ldp/handler/default.json",
@@ -26,7 +30,9 @@
"css:config/util/logging/winston.json",
"css:config/util/representation-conversion/default.json",
"css:config/util/resource-locker/memory.json",
"css:config/util/variables/default.json"
"css:config/util/variables/default.json",
"css:config/identity/handler/account-store/default.json"
],
"@graph": [
]

View File

@@ -5,8 +5,9 @@
"css:config/app/init/default.json",
"css:config/app/setup/required.json",
"css:config/http/handler/default.json",
"css:config/http/middleware/websockets.json",
"css:config/http/server-factory/websockets.json",
"css:config/http/middleware/default.json",
"css:config/http/notifications/legacy-websockets.json",
"css:config/http/server-factory/http.json",
"css:config/http/static/default.json",
"css:config/identity/access/public.json",
"css:config/identity/email/default.json",

View File

@@ -1,6 +1,18 @@
import { EventEmitter } from 'events';
import type { Server } from 'http';
import { UnsecureWebSocketsProtocol } from '../../../src/http/UnsecureWebSocketsProtocol';
import type { HttpRequest } from '../../../src/server/HttpRequest';
import { BaseActivityEmitter } from '../../../src/server/notifications/ActivityEmitter';
import { AS } from '../../../src/util/Vocabularies';
jest.mock('ws', (): any => ({
// eslint-disable-next-line @typescript-eslint/naming-convention
WebSocketServer: jest.fn().mockImplementation((): any => ({
handleUpgrade(upgradeRequest: any, socket: any, head: any, callback: any): void {
callback(socket, upgradeRequest);
},
})),
}));
class DummySocket extends EventEmitter {
public readonly messages = new Array<string>();
@@ -12,13 +24,18 @@ class DummySocket extends EventEmitter {
}
describe('An UnsecureWebSocketsProtocol', (): void => {
const source = new EventEmitter();
const protocol = new UnsecureWebSocketsProtocol(source);
let server: Server;
let webSocket: DummySocket;
const source = new BaseActivityEmitter();
let protocol: UnsecureWebSocketsProtocol;
describe('after registering a socket', (): void => {
const webSocket = new DummySocket();
beforeAll(async(): Promise<void> => {
server = new EventEmitter() as any;
webSocket = new DummySocket();
protocol = new UnsecureWebSocketsProtocol(source);
await protocol.handle(server);
const upgradeRequest = {
headers: {
host: 'mypod.example',
@@ -28,11 +45,7 @@ describe('An UnsecureWebSocketsProtocol', (): void => {
encrypted: true,
},
} as any as HttpRequest;
await protocol.handle({ webSocket, upgradeRequest } as any);
});
afterEach((): void => {
webSocket.messages.length = 0;
server.emit('upgrade', upgradeRequest, webSocket);
});
it('sends a protocol message.', (): void => {
@@ -54,7 +67,7 @@ describe('An UnsecureWebSocketsProtocol', (): void => {
describe('before subscribing to resources', (): void => {
it('does not emit pub messages.', (): void => {
source.emit('changed', { path: 'https://mypod.example/foo/bar' });
source.emit('changed', { path: 'https://mypod.example/foo/bar' }, AS.terms.Update);
expect(webSocket.messages).toHaveLength(0);
});
});
@@ -70,7 +83,7 @@ describe('An UnsecureWebSocketsProtocol', (): void => {
});
it('emits pub messages for that resource.', (): void => {
source.emit('changed', { path: 'https://mypod.example/foo/bar' });
source.emit('changed', { path: 'https://mypod.example/foo/bar' }, AS.terms.Update);
expect(webSocket.messages).toHaveLength(1);
expect(webSocket.messages.shift()).toBe('pub https://mypod.example/foo/bar');
});
@@ -87,7 +100,7 @@ describe('An UnsecureWebSocketsProtocol', (): void => {
});
it('emits pub messages for that resource.', (): void => {
source.emit('changed', { path: 'https://mypod.example/relative/foo' });
source.emit('changed', { path: 'https://mypod.example/relative/foo' }, AS.terms.Update);
expect(webSocket.messages).toHaveLength(1);
expect(webSocket.messages.shift()).toBe('pub https://mypod.example/relative/foo');
});
@@ -118,84 +131,83 @@ describe('An UnsecureWebSocketsProtocol', (): void => {
});
});
it('unsubscribes when a socket closes.', async(): Promise<void> => {
const webSocket = new DummySocket();
await protocol.handle({ webSocket, upgradeRequest: { headers: {}, socket: {}}} as any);
expect(webSocket.listenerCount('message')).toBe(1);
webSocket.emit('close');
expect(webSocket.listenerCount('message')).toBe(0);
expect(webSocket.listenerCount('close')).toBe(0);
expect(webSocket.listenerCount('error')).toBe(0);
});
describe('handling other situations', (): void => {
beforeEach(async(): Promise<void> => {
server = new EventEmitter() as any;
webSocket = new DummySocket();
protocol = new UnsecureWebSocketsProtocol(source);
await protocol.handle(server);
});
it('unsubscribes when a socket errors.', async(): Promise<void> => {
const webSocket = new DummySocket();
await protocol.handle({ webSocket, upgradeRequest: { headers: {}, socket: {}}} as any);
expect(webSocket.listenerCount('message')).toBe(1);
webSocket.emit('error');
expect(webSocket.listenerCount('message')).toBe(0);
expect(webSocket.listenerCount('close')).toBe(0);
expect(webSocket.listenerCount('error')).toBe(0);
});
it('unsubscribes when a socket closes.', async(): Promise<void> => {
server.emit('upgrade', { headers: {}, socket: {}} as any, webSocket);
expect(webSocket.listenerCount('message')).toBe(1);
webSocket.emit('close');
expect(webSocket.listenerCount('message')).toBe(0);
expect(webSocket.listenerCount('close')).toBe(0);
expect(webSocket.listenerCount('error')).toBe(0);
});
it('emits a warning when no Sec-WebSocket-Protocol is supplied.', async(): Promise<void> => {
const webSocket = new DummySocket();
const upgradeRequest = {
headers: {},
socket: {},
} as any as HttpRequest;
await protocol.handle({ webSocket, upgradeRequest } as any);
expect(webSocket.messages).toHaveLength(2);
expect(webSocket.messages.pop())
.toBe('warning Missing Sec-WebSocket-Protocol header, expected value \'solid-0.1\'');
expect(webSocket.close).toHaveBeenCalledTimes(0);
});
it('unsubscribes when a socket errors.', async(): Promise<void> => {
server.emit('upgrade', { headers: {}, socket: {}} as any, webSocket);
expect(webSocket.listenerCount('message')).toBe(1);
webSocket.emit('error');
expect(webSocket.listenerCount('message')).toBe(0);
expect(webSocket.listenerCount('close')).toBe(0);
expect(webSocket.listenerCount('error')).toBe(0);
});
it('emits an error and closes the connection with the wrong Sec-WebSocket-Protocol.', async(): Promise<void> => {
const webSocket = new DummySocket();
const upgradeRequest = {
headers: {
'sec-websocket-protocol': 'solid/1.0.0, other',
},
socket: {},
} as any as HttpRequest;
await protocol.handle({ webSocket, upgradeRequest } as any);
expect(webSocket.messages).toHaveLength(2);
expect(webSocket.messages.pop()).toBe('error Client does not support protocol solid-0.1');
expect(webSocket.close).toHaveBeenCalledTimes(1);
expect(webSocket.listenerCount('message')).toBe(0);
expect(webSocket.listenerCount('close')).toBe(0);
expect(webSocket.listenerCount('error')).toBe(0);
});
it('emits a warning when no Sec-WebSocket-Protocol is supplied.', async(): Promise<void> => {
server.emit('upgrade', { headers: {}, socket: {}} as any, webSocket);
expect(webSocket.messages).toHaveLength(2);
expect(webSocket.messages.pop())
.toBe('warning Missing Sec-WebSocket-Protocol header, expected value \'solid-0.1\'');
expect(webSocket.close).toHaveBeenCalledTimes(0);
});
it('respects the Forwarded header.', async(): Promise<void> => {
const webSocket = new DummySocket();
const upgradeRequest = {
headers: {
forwarded: 'proto=https;host=other.example',
'sec-websocket-protocol': 'solid-0.1',
},
socket: {},
} as any as HttpRequest;
await protocol.handle({ webSocket, upgradeRequest } as any);
webSocket.emit('message', 'sub https://other.example/protocol/foo');
expect(webSocket.messages).toHaveLength(2);
expect(webSocket.messages.pop()).toBe('ack https://other.example/protocol/foo');
});
it('emits an error and closes the connection with the wrong Sec-WebSocket-Protocol.', async(): Promise<void> => {
const upgradeRequest = {
headers: {
'sec-websocket-protocol': 'solid/1.0.0, other',
},
socket: {},
} as any as HttpRequest;
server.emit('upgrade', upgradeRequest, webSocket);
expect(webSocket.messages).toHaveLength(2);
expect(webSocket.messages.pop()).toBe('error Client does not support protocol solid-0.1');
expect(webSocket.close).toHaveBeenCalledTimes(1);
expect(webSocket.listenerCount('message')).toBe(0);
expect(webSocket.listenerCount('close')).toBe(0);
expect(webSocket.listenerCount('error')).toBe(0);
});
it('respects the X-Forwarded-* headers if Forwarded header is not present.', async(): Promise<void> => {
const webSocket = new DummySocket();
const upgradeRequest = {
headers: {
'x-forwarded-host': 'other.example',
'x-forwarded-proto': 'https',
'sec-websocket-protocol': 'solid-0.1',
},
socket: {},
} as any as HttpRequest;
await protocol.handle({ webSocket, upgradeRequest } as any);
webSocket.emit('message', 'sub https://other.example/protocol/foo');
expect(webSocket.messages).toHaveLength(2);
expect(webSocket.messages.pop()).toBe('ack https://other.example/protocol/foo');
it('respects the Forwarded header.', async(): Promise<void> => {
const upgradeRequest = {
headers: {
forwarded: 'proto=https;host=other.example',
'sec-websocket-protocol': 'solid-0.1',
},
socket: {},
} as any as HttpRequest;
server.emit('upgrade', upgradeRequest, webSocket);
webSocket.emit('message', 'sub https://other.example/protocol/foo');
expect(webSocket.messages).toHaveLength(2);
expect(webSocket.messages.pop()).toBe('ack https://other.example/protocol/foo');
});
it('respects the X-Forwarded-* headers if Forwarded header is not present.', async(): Promise<void> => {
const upgradeRequest = {
headers: {
'x-forwarded-host': 'other.example',
'x-forwarded-proto': 'https',
'sec-websocket-protocol': 'solid-0.1',
},
socket: {},
} as any as HttpRequest;
server.emit('upgrade', upgradeRequest, webSocket);
webSocket.emit('message', 'sub https://other.example/protocol/foo');
expect(webSocket.messages).toHaveLength(2);
expect(webSocket.messages.pop()).toBe('ack https://other.example/protocol/foo');
});
});
});

View File

@@ -1,25 +1,52 @@
import type { Server } from 'http';
import { Server as HttpsServer } from 'https';
import { ServerInitializer } from '../../../src/init/ServerInitializer';
import type { Logger } from '../../../src/logging/Logger';
import { getLoggerFor } from '../../../src/logging/LogUtil';
import type { HttpServerFactory } from '../../../src/server/HttpServerFactory';
// Mock so we don't create an actual HTTPS server in the test below
jest.mock('https');
jest.mock('../../../src/logging/LogUtil');
describe('ServerInitializer', (): void => {
let logger: jest.Mocked<Logger>;
let server: Server;
let serverFactory: jest.Mocked<HttpServerFactory>;
let initializer: ServerInitializer;
beforeEach(async(): Promise<void> => {
logger = { info: jest.fn() } as any;
(getLoggerFor as jest.MockedFn<() => Logger>).mockReturnValue(logger);
server = {
listen: jest.fn(),
close: jest.fn((fn: () => void): void => fn()),
} as any;
serverFactory = {
startServer: jest.fn().mockReturnValue(server),
createServer: jest.fn().mockReturnValue(server),
};
initializer = new ServerInitializer(serverFactory, 3000);
});
it('starts an HTTP server.', async(): Promise<void> => {
await initializer.handle();
expect(serverFactory.startServer).toHaveBeenCalledWith(3000);
expect(serverFactory.createServer).toHaveBeenCalledTimes(1);
expect(server.listen).toHaveBeenCalledTimes(1);
expect(server.listen).toHaveBeenLastCalledWith(3000);
expect(logger.info).toHaveBeenCalledTimes(1);
expect(logger.info).toHaveBeenLastCalledWith(`Listening to server at http://localhost:3000/`);
});
it('correctly logs the protocol in case of an HTTPS server.', async(): Promise<void> => {
server = new HttpsServer();
serverFactory.createServer.mockResolvedValue(server);
await initializer.handle();
expect(serverFactory.createServer).toHaveBeenCalledTimes(1);
expect(server.listen).toHaveBeenCalledTimes(1);
expect(server.listen).toHaveBeenLastCalledWith(3000);
expect(logger.info).toHaveBeenCalledTimes(1);
expect(logger.info).toHaveBeenLastCalledWith(`Listening to server at https://localhost:3000/`);
});
it('can stop the server.', async(): Promise<void> => {

View File

@@ -1,128 +0,0 @@
import type { Server } from 'http';
import request from 'supertest';
import type { BaseHttpServerOptions } from '../../../src/server/BaseHttpServerFactory';
import { BaseHttpServerFactory } from '../../../src/server/BaseHttpServerFactory';
import type { HttpHandler } from '../../../src/server/HttpHandler';
import type { HttpResponse } from '../../../src/server/HttpResponse';
import { joinFilePath } from '../../../src/util/PathUtil';
import { getPort } from '../../util/Util';
const port = getPort('BaseHttpServerFactory');
const handler: jest.Mocked<HttpHandler> = {
handleSafe: jest.fn(async(input: { response: HttpResponse }): Promise<void> => {
input.response.writeHead(200);
input.response.end();
}),
} as any;
describe('A BaseHttpServerFactory', (): void => {
let server: Server;
const options: [string, BaseHttpServerOptions | undefined][] = [
[ 'http', undefined ],
[ 'https', {
https: true,
key: joinFilePath(__dirname, '../../assets/https/server.key'),
cert: joinFilePath(__dirname, '../../assets/https/server.cert'),
}],
];
describe.each(options)('with %s', (protocol, httpOptions): void => {
let rejectTls: string | undefined;
beforeAll(async(): Promise<void> => {
// Allow self-signed certificate
rejectTls = process.env.NODE_TLS_REJECT_UNAUTHORIZED;
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
const factory = new BaseHttpServerFactory(handler, httpOptions);
server = factory.startServer(port);
});
beforeEach(async(): Promise<void> => {
jest.clearAllMocks();
});
afterAll(async(): Promise<void> => {
process.env.NODE_TLS_REJECT_UNAUTHORIZED = rejectTls;
server.close();
});
it('sends incoming requests to the handler.', async(): Promise<void> => {
await request(server).get('/').set('Host', 'test.com').expect(200);
expect(handler.handleSafe).toHaveBeenCalledTimes(1);
expect(handler.handleSafe).toHaveBeenLastCalledWith({
request: expect.objectContaining({
headers: expect.objectContaining({ host: 'test.com' }),
}),
response: expect.objectContaining({}),
});
});
it('returns a 404 when the handler does not do anything.', async(): Promise<void> => {
handler.handleSafe.mockResolvedValueOnce(undefined);
await expect(request(server).get('/').expect(404)).resolves.toBeDefined();
});
it('writes an error to the HTTP response without the stack trace.', async(): Promise<void> => {
handler.handleSafe.mockRejectedValueOnce(new Error('dummyError'));
const res = await request(server).get('/').expect(500);
expect(res.headers['content-type']).toBe('text/plain; charset=utf-8');
expect(res.text).toBe('Error: dummyError\n');
});
it('does not write an error if the response had been started.', async(): Promise<void> => {
handler.handleSafe.mockImplementationOnce(async(input: { response: HttpResponse }): Promise<void> => {
input.response.write('content');
throw new Error('dummyError');
});
const res = await request(server).get('/');
expect(res.text).not.toContain('dummyError');
});
it('throws unknown errors if its handler throw non-Error objects.', async(): Promise<void> => {
handler.handleSafe.mockRejectedValueOnce('apple');
const res = await request(server).get('/').expect(500);
expect(res.text).toContain('Unknown error: apple.');
});
it('can handle errors on the HttpResponse.', async(): Promise<void> => {
// This just makes sure the logging line is covered.
// Actually destroying the request to trigger an error causes issues for supertest
handler.handleSafe.mockImplementationOnce(async(input): Promise<void> => {
input.request.emit('error', new Error('bad request'));
});
await request(server).get('/').expect(404);
});
});
describe('with showStackTrace enabled', (): void => {
const httpOptions = {
http: true,
showStackTrace: true,
};
beforeAll(async(): Promise<void> => {
const factory = new BaseHttpServerFactory(handler, httpOptions);
server = factory.startServer(port);
});
afterAll(async(): Promise<void> => {
server.close();
});
it('does not print the stack if that option is disabled.', async(): Promise<void> => {
const error = new Error('dummyError');
handler.handleSafe.mockRejectedValueOnce(error);
const res = await request(server).get('/').expect(500);
expect(res.headers['content-type']).toBe('text/plain; charset=utf-8');
expect(res.text).toBe(`${error.stack}\n`);
});
});
});

View File

@@ -0,0 +1,75 @@
import type { RequestListener, Server } from 'http';
import request from 'supertest';
import type { BaseServerFactoryOptions } from '../../../src/server/BaseServerFactory';
import { BaseServerFactory } from '../../../src/server/BaseServerFactory';
import type { ServerConfigurator } from '../../../src/server/ServerConfigurator';
import { joinFilePath } from '../../../src/util/PathUtil';
import { getPort } from '../../util/Util';
const port = getPort('BaseServerFactory');
describe('A BaseServerFactory', (): void => {
let server: Server;
const options: [string, BaseServerFactoryOptions | undefined][] = [
[ 'http', undefined ],
[ 'https', {
https: true,
key: joinFilePath(__dirname, '../../assets/https/server.key'),
cert: joinFilePath(__dirname, '../../assets/https/server.cert'),
}],
];
describe.each(options)('with %s', (protocol, httpOptions): void => {
let rejectTls: string | undefined;
let configurator: ServerConfigurator;
let mockRequestHandler: jest.MockedFn<RequestListener>;
beforeAll(async(): Promise<void> => {
// Allow self-signed certificate
rejectTls = process.env.NODE_TLS_REJECT_UNAUTHORIZED;
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
mockRequestHandler = jest.fn();
configurator = {
async handleSafe(serv: Server): Promise<void> {
serv.on('request', mockRequestHandler);
},
} as any;
const factory = new BaseServerFactory(configurator, httpOptions);
server = await factory.createServer();
server.listen(port);
});
beforeEach(async(): Promise<void> => {
jest.clearAllMocks();
});
afterAll(async(): Promise<void> => {
process.env.NODE_TLS_REJECT_UNAUTHORIZED = rejectTls;
server.close();
});
it('emits a request event on requests.', async(): Promise<void> => {
let resolveProm: (value: unknown) => void;
const requestProm = new Promise((resolve): void => {
resolveProm = resolve;
});
server.on('request', (req, res): void => {
resolveProm(req);
res.writeHead(200);
res.end();
});
await request(server).get('/').set('Host', 'test.com').expect(200);
await expect(requestProm).resolves.toEqual(expect.objectContaining({
headers: expect.objectContaining({ host: 'test.com' }),
}));
expect(mockRequestHandler).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -0,0 +1,143 @@
import { EventEmitter } from 'events';
import type { ServerResponse, IncomingMessage, Server } from 'http';
import { Readable } from 'stream';
import type { Logger } from '../../../src/logging/Logger';
import { getLoggerFor } from '../../../src/logging/LogUtil';
import { HandlerServerConfigurator } from '../../../src/server/HandlerServerConfigurator';
import type { HttpHandler } from '../../../src/server/HttpHandler';
import { flushPromises } from '../../util/Util';
jest.mock('../../../src/logging/LogUtil', (): any => {
const logger: Logger =
{ error: jest.fn(), info: jest.fn() } as any;
return { getLoggerFor: (): Logger => logger };
});
describe('A HandlerServerConfigurator', (): void => {
const logger: jest.Mocked<Logger> = getLoggerFor('mock') as any;
let request: jest.Mocked<IncomingMessage>;
let response: jest.Mocked<ServerResponse>;
let server: Server;
let handler: jest.Mocked<HttpHandler>;
let listener: HandlerServerConfigurator;
beforeEach(async(): Promise<void> => {
// Clearing the logger mock
jest.clearAllMocks();
request = Readable.from('') as any;
request.method = 'GET';
request.url = '/';
response = {
headersSent: false,
end: jest.fn(),
setHeader: jest.fn(),
writeHead: jest.fn(),
} as any;
response.end.mockImplementation((): any => {
response.headersSent = true;
});
response.writeHead.mockReturnValue(response);
server = new EventEmitter() as any;
handler = {
handleSafe: jest.fn((): void => {
response.headersSent = true;
}),
} as any;
listener = new HandlerServerConfigurator(handler);
await listener.handle(server);
});
it('sends incoming requests to the handler.', async(): Promise<void> => {
server.emit('request', request, response);
await flushPromises();
expect(handler.handleSafe).toHaveBeenCalledTimes(1);
expect(handler.handleSafe).toHaveBeenLastCalledWith({ request, response });
expect(response.setHeader).toHaveBeenCalledTimes(0);
expect(response.writeHead).toHaveBeenCalledTimes(0);
expect(response.end).toHaveBeenCalledTimes(0);
});
it('returns a 404 when the handler does not do anything.', async(): Promise<void> => {
handler.handleSafe.mockImplementation(jest.fn());
server.emit('request', request, response);
await flushPromises();
expect(response.setHeader).toHaveBeenCalledTimes(0);
expect(response.writeHead).toHaveBeenCalledTimes(1);
expect(response.writeHead).toHaveBeenLastCalledWith(404);
expect(response.end).toHaveBeenCalledTimes(1);
expect(response.end).toHaveBeenLastCalledWith();
});
it('writes an error to the HTTP response without the stack trace.', async(): Promise<void> => {
handler.handleSafe.mockRejectedValueOnce(new Error('dummyError'));
server.emit('request', request, response);
await flushPromises();
expect(response.setHeader).toHaveBeenCalledTimes(1);
expect(response.setHeader).toHaveBeenLastCalledWith('Content-Type', 'text/plain; charset=utf-8');
expect(response.writeHead).toHaveBeenCalledTimes(1);
expect(response.writeHead).toHaveBeenLastCalledWith(500);
expect(response.end).toHaveBeenCalledTimes(1);
expect(response.end).toHaveBeenLastCalledWith('Error: dummyError\n');
});
it('does not write an error if the response had been started.', async(): Promise<void> => {
response.headersSent = true;
handler.handleSafe.mockRejectedValueOnce(new Error('dummyError'));
server.emit('request', request, response);
await flushPromises();
expect(response.setHeader).toHaveBeenCalledTimes(0);
expect(response.writeHead).toHaveBeenCalledTimes(0);
expect(response.end).toHaveBeenCalledTimes(1);
expect(response.end).toHaveBeenLastCalledWith();
});
it('throws unknown errors if its handler throw non-Error objects.', async(): Promise<void> => {
handler.handleSafe.mockRejectedValueOnce('apple');
server.emit('request', request, response);
await flushPromises();
expect(response.setHeader).toHaveBeenCalledTimes(1);
expect(response.setHeader).toHaveBeenLastCalledWith('Content-Type', 'text/plain; charset=utf-8');
expect(response.writeHead).toHaveBeenCalledTimes(1);
expect(response.writeHead).toHaveBeenLastCalledWith(500);
expect(response.end).toHaveBeenCalledTimes(1);
expect(response.end).toHaveBeenLastCalledWith('Unknown error: apple.\n');
});
it('can handle errors on the HttpResponse.', async(): Promise<void> => {
handler.handleSafe.mockImplementationOnce(async(input): Promise<void> => {
input.request.emit('error', new Error('bad request'));
});
server.emit('request', request, response);
await flushPromises();
expect(logger.error).toHaveBeenCalledTimes(1);
expect(logger.error).toHaveBeenLastCalledWith('Request error: bad request');
});
it('prints the stack trace if that option is enabled.', async(): Promise<void> => {
server.removeAllListeners();
listener = new HandlerServerConfigurator(handler, true);
await listener.handle(server);
const error = new Error('dummyError');
handler.handleSafe.mockRejectedValueOnce(error);
server.emit('request', request, response);
await flushPromises();
expect(response.setHeader).toHaveBeenCalledTimes(1);
expect(response.setHeader).toHaveBeenLastCalledWith('Content-Type', 'text/plain; charset=utf-8');
expect(response.writeHead).toHaveBeenCalledTimes(1);
expect(response.writeHead).toHaveBeenLastCalledWith(500);
expect(response.end).toHaveBeenCalledTimes(1);
expect(response.end).toHaveBeenLastCalledWith(`${error.stack}\n`);
});
});

View File

@@ -0,0 +1,80 @@
import { EventEmitter } from 'events';
import type { Server } from 'http';
import type { WebSocket } from 'ws';
import type { Logger } from '../../../src/logging/Logger';
import { getLoggerFor } from '../../../src/logging/LogUtil';
import type { HttpRequest } from '../../../src/server/HttpRequest';
import { WebSocketServerConfigurator } from '../../../src/server/WebSocketServerConfigurator';
import { flushPromises } from '../../util/Util';
jest.mock('ws', (): any => ({
// eslint-disable-next-line @typescript-eslint/naming-convention
WebSocketServer: jest.fn().mockImplementation((): any => ({
handleUpgrade(upgradeRequest: any, socket: any, head: any, callback: any): void {
callback(socket, upgradeRequest);
},
})),
}));
jest.mock('../../../src/logging/LogUtil', (): any => {
const logger: Logger =
{ error: jest.fn(), info: jest.fn() } as any;
return { getLoggerFor: (): Logger => logger };
});
class SimpleWebSocketConfigurator extends WebSocketServerConfigurator {
public async handleConnection(): Promise<void> {
// Will be overwritten
}
}
describe('A WebSocketServerConfigurator', (): void => {
const logger: jest.Mocked<Logger> = getLoggerFor('mock') as any;
let server: Server;
let webSocket: WebSocket;
let upgradeRequest: HttpRequest;
let listener: jest.Mocked<SimpleWebSocketConfigurator>;
beforeEach(async(): Promise<void> => {
// Clearing the logger mock
jest.clearAllMocks();
server = new EventEmitter() as any;
webSocket = new EventEmitter() as any;
webSocket.send = jest.fn();
webSocket.close = jest.fn();
upgradeRequest = { url: `/foo` } as any;
listener = new SimpleWebSocketConfigurator() as any;
listener.handleConnection = jest.fn().mockResolvedValue('');
await listener.handle(server);
});
it('attaches an upgrade listener to any server it gets.', async(): Promise<void> => {
server = new EventEmitter() as any;
expect(server.listenerCount('upgrade')).toBe(0);
await listener.handle(server);
expect(server.listenerCount('upgrade')).toBe(1);
});
it('calls the handleConnection function when there is a new WebSocket.', async(): Promise<void> => {
server.emit('upgrade', upgradeRequest, webSocket);
await flushPromises();
expect(listener.handleConnection).toHaveBeenCalledTimes(1);
expect(listener.handleConnection).toHaveBeenLastCalledWith(webSocket, upgradeRequest);
expect(logger.error).toHaveBeenCalledTimes(0);
});
it('logs an error if something went wrong handling the connection.', async(): Promise<void> => {
listener.handleConnection.mockRejectedValue(new Error('bad input'));
server.emit('upgrade', upgradeRequest, webSocket);
await flushPromises();
expect(listener.handleConnection).toHaveBeenCalledTimes(1);
expect(logger.error).toHaveBeenCalledTimes(1);
expect(logger.error).toHaveBeenLastCalledWith('Something went wrong handling a WebSocket connection: bad input');
});
});

View File

@@ -1,54 +0,0 @@
import type { Server } from 'http';
import request from 'supertest';
import { WebSocket } from 'ws';
import { BaseHttpServerFactory } from '../../../src/server/BaseHttpServerFactory';
import type { HttpHandlerInput } from '../../../src/server/HttpHandler';
import { HttpHandler } from '../../../src/server/HttpHandler';
import type { HttpRequest } from '../../../src/server/HttpRequest';
import { WebSocketHandler } from '../../../src/server/WebSocketHandler';
import { WebSocketServerFactory } from '../../../src/server/WebSocketServerFactory';
class SimpleHttpHandler extends HttpHandler {
public async handle(input: HttpHandlerInput): Promise<void> {
input.response.end('SimpleHttpHandler');
}
}
class SimpleWebSocketHandler extends WebSocketHandler {
public host: any;
public async handle(input: { webSocket: WebSocket; upgradeRequest: HttpRequest }): Promise<void> {
input.webSocket.send('SimpleWebSocketHandler');
input.webSocket.close();
this.host = input.upgradeRequest.headers.host;
}
}
describe('SimpleWebSocketHandler', (): void => {
let webSocketHandler: SimpleWebSocketHandler;
let server: Server;
beforeAll(async(): Promise<void> => {
const httpHandler = new SimpleHttpHandler();
webSocketHandler = new SimpleWebSocketHandler();
const httpServerFactory = new BaseHttpServerFactory(httpHandler);
const webSocketServerFactory = new WebSocketServerFactory(httpServerFactory, webSocketHandler);
server = webSocketServerFactory.startServer(5556);
});
afterAll(async(): Promise<void> => {
server.close();
});
it('has a functioning HTTP interface.', async(): Promise<void> => {
const result = await request(server).get('/').expect('SimpleHttpHandler');
expect(result).toBeDefined();
});
it('has a functioning WebSockets interface.', async(): Promise<void> => {
const client = new WebSocket('ws://localhost:5556');
const buffer = await new Promise<Buffer>((resolve): any => client.on('message', resolve));
expect(buffer.toString()).toBe('SimpleWebSocketHandler');
expect(webSocketHandler.host).toBe('localhost:5556');
});
});

View File

@@ -13,6 +13,7 @@ const portNames = [
'FileBackend',
'GlobalQuota',
'Identity',
'LegacyWebSocketsProtocol',
'LpdHandlerWithAuth',
'LpdHandlerWithoutAuth',
'Middleware',
@@ -28,10 +29,9 @@ const portNames = [
'SetupMemory',
'SparqlStorage',
'Subdomains',
'WebSocketsProtocol',
// Unit
'BaseHttpServerFactory',
'BaseServerFactory',
] as const;
export function getPort(name: typeof portNames[number]): number {