Repository: solid/community-server
Pull request #368 (2020-11-29 22:26:33)

Separate middleware from Express
CORS and other middleware is currently hardwired inside of the Express server.
This makes it hard to configure them.

This PR makes each piece of middleware a separate component that can be configured with Components.js.

Additionally, this PR adjusts some of that configuration.

Closes https://github.com/solid/community-server/issues/27


Patch:
diff --git a/config/config-default.json b/config/config-default.json
index 7041c27ae..0940ce089 100644
--- a/config/config-default.json
+++ b/config/config-default.json
@@ -12,6 +12,7 @@
     "files-scs:config/presets/ldp/request-parser.json",
     "files-scs:config/presets/ldp/websockets.json",
     "files-scs:config/presets/logging.json",
+    "files-scs:config/presets/middleware.json",
     "files-scs:config/presets/representation-conversion.json",
     "files-scs:config/presets/setup.json",
     "files-scs:config/presets/storage/backend/storage-memory.json",
diff --git a/config/config-file.json b/config/config-file.json
index fc34411bc..fe93fb6c2 100644
--- a/config/config-file.json
+++ b/config/config-file.json
@@ -12,6 +12,7 @@
     "files-scs:config/presets/ldp/request-parser.json",
     "files-scs:config/presets/ldp/websockets.json",
     "files-scs:config/presets/logging.json",
+    "files-scs:config/presets/middleware.json",
     "files-scs:config/presets/representation-conversion.json",
     "files-scs:config/presets/setup.json",
     "files-scs:config/presets/storage/backend/storage-filesystem.json",
diff --git a/config/config-path-routing.json b/config/config-path-routing.json
index 931df203b..7d9d4423a 100644
--- a/config/config-path-routing.json
+++ b/config/config-path-routing.json
@@ -12,6 +12,7 @@
     "files-scs:config/presets/ldp/request-parser.json",
     "files-scs:config/presets/ldp/websockets.json",
     "files-scs:config/presets/logging.json",
+    "files-scs:config/presets/middleware.json",
     "files-scs:config/presets/representation-conversion.json",
     "files-scs:config/presets/setup.json",
     "files-scs:config/presets/storage/backend/storage-filesystem.json",
diff --git a/config/config-rdf-to-sparql-endpoint.json b/config/config-rdf-to-sparql-endpoint.json
index c055e3787..8671d54fa 100644
--- a/config/config-rdf-to-sparql-endpoint.json
+++ b/config/config-rdf-to-sparql-endpoint.json
@@ -12,6 +12,7 @@
     "files-scs:config/presets/ldp/request-parser.json",
     "files-scs:config/presets/ldp/websockets.json",
     "files-scs:config/presets/logging.json",
+    "files-scs:config/presets/middleware.json",
     "files-scs:config/presets/representation-conversion.json",
     "files-scs:config/presets/setup.json",
     "files-scs:config/presets/storage/backend/storage-filesystem.json",
diff --git a/config/config-sparql-endpoint.json b/config/config-sparql-endpoint.json
index 9e5b8aea3..945f5b254 100644
--- a/config/config-sparql-endpoint.json
+++ b/config/config-sparql-endpoint.json
@@ -12,6 +12,7 @@
     "files-scs:config/presets/ldp/request-parser.json",
     "files-scs:config/presets/ldp/websockets.json",
     "files-scs:config/presets/logging.json",
+    "files-scs:config/presets/middleware.json",
     "files-scs:config/presets/representation-conversion.json",
     "files-scs:config/presets/setup.json",
     "files-scs:config/presets/storage/backend/storage-sparql-endpoint.json",
diff --git a/config/presets/http.json b/config/presets/http.json
index 5d3483a34..d960bbe1b 100644
--- a/config/presets/http.json
+++ b/config/presets/http.json
@@ -17,6 +17,18 @@
       "ExpressHttpServerFactory:_handler": {
         "@id": "urn:solid-server:default:HttpHandler"
       }
+    },
+    {
+      "@id": "urn:solid-server:default:HttpHandler",
+      "@type": "AllVoidCompositeHandler",
+      "AllVoidCompositeHandler:_handlers": [
+        {
+          "@id": "urn:solid-server:default:Middleware"
+        },
+        {
+          "@id": "urn:solid-server:default:LdpHandler"
+        }
+      ]
     }
   ]
 }
diff --git a/config/presets/ldp.json b/config/presets/ldp.json
index e5ca8f538..f0957d262 100644
--- a/config/presets/ldp.json
+++ b/config/presets/ldp.json
@@ -2,7 +2,7 @@
   "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^1.0.0/components/context.jsonld",
   "@graph": [
     {
-      "@id": "urn:solid-server:default:HttpHandler",
+      "@id": "urn:solid-server:default:LdpHandler",
       "@type": "AuthenticatedLdpHandler",
       "AuthenticatedLdpHandler:_args_requestParser": {
         "@id": "urn:solid-server:default:RequestParser"
diff --git a/config/presets/ldp/response-writer.json b/config/presets/ldp/response-writer.json
index 12d2c26b3..778e23658 100644
--- a/config/presets/ldp/response-writer.json
+++ b/config/presets/ldp/response-writer.json
@@ -26,12 +26,6 @@
               "LinkRelMetadataWriter:_headerMap_value": "type"
             }
           ]
-        },
-        {
-          "@type": "WebSocketMetadataWriter",
-          "WebSocketMetadataWriter:_settings_port": {
-            "@id": "urn:solid-server:default:variable:port"
-          }
         }
       ]
     },
diff --git a/config/presets/middleware.json b/config/presets/middleware.json
new file mode 100644
index 000000000..32a989809
--- /dev/null
+++ b/config/presets/middleware.json
@@ -0,0 +1,41 @@
+{
+  "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^1.0.0/components/context.jsonld",
+  "@graph": [
+    {
+      "@id": "urn:solid-server:default:Middleware",
+      "@type": "AllVoidCompositeHandler",
+      "AllVoidCompositeHandler:_handlers": [
+        {
+          "@type": "CorsHandler",
+          "CorsHandler:_options_methods": [
+            "GET",
+            "HEAD",
+            "OPTIONS",
+            "POST",
+            "PUT",
+            "PATCH",
+            "DELETE"
+          ],
+          "CorsHandler:_options_exposedHeaders": [
+            "Updates-Via"
+          ]
+        },
+        {
+          "@type": "HeaderHandler",
+          "HeaderHandler:_headers": [
+            {
+              "HeaderHandler:_headers_key": "X-Powered-By",
+              "HeaderHandler:_headers_value": "Community Solid Server"
+            }
+          ]
+        },
+        {
+          "@type": "WebSocketAdvertiser",
+          "WebSocketAdvertiser:_settings_port": {
+            "@id": "urn:solid-server:default:variable:port"
+          }
+        }
+      ]
+    }
+  ]
+}
diff --git a/index.ts b/index.ts
index 717720e72..05f856a64 100644
--- a/index.ts
+++ b/index.ts
@@ -26,7 +26,6 @@ export * from './src/ldp/http/metadata/MetadataExtractor';
 export * from './src/ldp/http/metadata/MetadataParser';
 export * from './src/ldp/http/metadata/MetadataWriter';
 export * from './src/ldp/http/metadata/SlugParser';
-export * from './src/ldp/http/metadata/WebSocketMetadataWriter';
 
 // LDP/HTTP/Response
 export * from './src/ldp/http/response/CreatedResponseDescription';
@@ -95,6 +94,11 @@ export * from './src/server/HttpResponse';
 export * from './src/server/WebSocketServerFactory';
 export * from './src/server/WebSocketHandler';
 
+// Server/Middleware
+export * from './src/server/middleware/CorsHandler';
+export * from './src/server/middleware/HeaderHandler';
+export * from './src/server/middleware/WebSocketAdvertiser';
+
 // Storage/Accessors
 export * from './src/storage/accessors/DataAccessor';
 export * from './src/storage/accessors/FileDataAccessor';
diff --git a/src/ldp/http/metadata/LinkRelMetadataWriter.ts b/src/ldp/http/metadata/LinkRelMetadataWriter.ts
index 8ea42656b..a87d86e74 100644
--- a/src/ldp/http/metadata/LinkRelMetadataWriter.ts
+++ b/src/ldp/http/metadata/LinkRelMetadataWriter.ts
@@ -10,9 +10,7 @@ import { MetadataWriter } from './MetadataWriter';
 export class LinkRelMetadataWriter extends MetadataWriter {
   private readonly linkRelMap: Record<string, string>;
 
-  // Not supported by Components.js yet
-  // eslint-disable-next-line @typescript-eslint/consistent-indexed-object-style
-  public constructor(linkRelMap: { [predicate: string]: string }) {
+  public constructor(linkRelMap: Record<string, string>) {
     super();
     this.linkRelMap = linkRelMap;
   }
diff --git a/src/ldp/http/metadata/MappedMetadataWriter.ts b/src/ldp/http/metadata/MappedMetadataWriter.ts
index 9a445b8b1..af4efb84a 100644
--- a/src/ldp/http/metadata/MappedMetadataWriter.ts
+++ b/src/ldp/http/metadata/MappedMetadataWriter.ts
@@ -10,9 +10,7 @@ import { MetadataWriter } from './MetadataWriter';
 export class MappedMetadataWriter extends MetadataWriter {
   private readonly headerMap: Record<string, string>;
 
-  // Not supported by Components.js yet
-  // eslint-disable-next-line @typescript-eslint/consistent-indexed-object-style
-  public constructor(headerMap: { [predicate: string]: string }) {
+  public constructor(headerMap: Record<string, string>) {
     super();
     this.headerMap = headerMap;
   }
diff --git a/src/server/ExpressHttpServerFactory.ts b/src/server/ExpressHttpServerFactory.ts
index cf4d3a1c7..3c0224189 100644
--- a/src/server/ExpressHttpServerFactory.ts
+++ b/src/server/ExpressHttpServerFactory.ts
@@ -1,5 +1,4 @@
 import type { Server } from 'http';
-import cors from 'cors';
 import type { Express } from 'express';
 import express from 'express';
 import { getLoggerFor } from '../logging/LogUtil';
@@ -17,28 +16,11 @@ export class ExpressHttpServerFactory implements HttpServerFactory {
   }
 
   public startServer(port: number): Server {
-    const app = express();
-    this.setup(app);
-    return app.listen(port);
+    return this.createApp().listen(port);
   }
 
-  protected setup(app: Express): void {
-    // Set up server identification
-    app.use((request, response, done): void => {
-      response.setHeader('X-Powered-By', 'Community Solid Server');
-      done();
-    });
-
-    // Set up Cross-Origin Resource Sharing (CORS)
-    app.use(cors({
-      // Based on https://github.com/solid/solid-spec/blob/master/recommendations-server.md#cors---cross-origin-resource-sharing
-      // By default origin is always '*', this forces it to be the origin header if there is one
-      origin: (origin, callback): void => callback(null, (origin ?? '*') as any),
-      methods: [ 'GET', 'HEAD', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE' ],
-    }));
-
-    // Delegate to the main handler
-    app.use(async(request, response, done): Promise<void> => {
+  protected createApp(): Express {
+    return express().use(async(request, response, done): Promise<void> => {
       try {
         this.logger.info(`Received request for ${request.url}`);
         await this.handler.handleSafe({ request: guardStream(request), response });
diff --git a/src/server/middleware/CorsHandler.ts b/src/server/middleware/CorsHandler.ts
new file mode 100644
index 000000000..f16a40a1d
--- /dev/null
+++ b/src/server/middleware/CorsHandler.ts
@@ -0,0 +1,40 @@
+import cors from 'cors';
+import type { CorsOptions } from 'cors';
+import type { RequestHandler } from 'express';
+import { HttpHandler } from '../HttpHandler';
+import type { HttpRequest } from '../HttpRequest';
+import type { HttpResponse } from '../HttpResponse';
+
+const defaultOptions: CorsOptions = {
+  origin: (origin: any, callback: any): void => callback(null, origin ?? '*'),
+};
+
+// Components.js does not support the full CorsOptions yet
+interface SimpleCorsOptions {
+  origin?: string;
+  methods?: string[];
+  allowedHeaders?: string[];
+  exposedHeaders?: string[];
+  credentials?: boolean;
+  maxAge?: number;
+  preflightContinue?: boolean;
+  optionsSuccessStatus?: number;
+}
+
+/**
+ * Handler that sets CORS options on the response.
+ */
+export class CorsHandler extends HttpHandler {
+  private readonly corsHandler: RequestHandler;
+
+  public constructor(options: SimpleCorsOptions = {}) {
+    super();
+    this.corsHandler = cors({ ...defaultOptions, ...options });
+  }
+
+  public async handle(input: { request: HttpRequest; response: HttpResponse }): Promise<void> {
+    return new Promise((resolve): void => {
+      this.corsHandler(input.request as any, input.response as any, (): void => resolve());
+    });
+  }
+}
diff --git a/src/server/middleware/HeaderHandler.ts b/src/server/middleware/HeaderHandler.ts
new file mode 100644
index 000000000..95d39af88
--- /dev/null
+++ b/src/server/middleware/HeaderHandler.ts
@@ -0,0 +1,20 @@
+import { HttpHandler } from '../HttpHandler';
+import type { HttpResponse } from '../HttpResponse';
+
+/**
+ * Handler that sets custom headers on the response.
+ */
+export class HeaderHandler extends HttpHandler {
+  private readonly headers: Record<string, string>;
+
+  public constructor(headers: Record<string, string>) {
+    super();
+    this.headers = { ...headers };
+  }
+
+  public async handle({ response }: { response: HttpResponse }): Promise<void> {
+    for (const header of Object.keys(this.headers)) {
+      response.setHeader(header, this.headers[header]);
+    }
+  }
+}
diff --git a/src/ldp/http/metadata/WebSocketMetadataWriter.ts b/src/server/middleware/WebSocketAdvertiser.ts
similarity index 64%
rename from src/ldp/http/metadata/WebSocketMetadataWriter.ts
rename to src/server/middleware/WebSocketAdvertiser.ts
index 9f0d85b2e..bb121175c 100644
--- a/src/ldp/http/metadata/WebSocketMetadataWriter.ts
+++ b/src/server/middleware/WebSocketAdvertiser.ts
@@ -1,6 +1,6 @@
-import type { HttpResponse } from '../../../server/HttpResponse';
-import { addHeader } from '../../../util/HeaderUtil';
-import { MetadataWriter } from './MetadataWriter';
+import { addHeader } from '../../util/HeaderUtil';
+import { HttpHandler } from '../HttpHandler';
+import type { HttpResponse } from '../HttpResponse';
 
 interface WebSocketSettings {
   hostname?: string;
@@ -9,9 +9,9 @@ interface WebSocketSettings {
 }
 
 /**
- * A {@link MetadataWriter} that advertises a WebSocket through the Updates-Via header.
+ * Handler that advertises a WebSocket through the Updates-Via header.
  */
-export class WebSocketMetadataWriter extends MetadataWriter {
+export class WebSocketAdvertiser extends HttpHandler {
   private readonly socketUrl: string;
 
   public constructor(settings: WebSocketSettings = {}) {
@@ -22,7 +22,7 @@ export class WebSocketMetadataWriter extends MetadataWriter {
     if (socketUrl.hostname !== hostname) {
       throw new Error(`Invalid hostname: ${hostname}`);
     }
-    this.socketUrl = socketUrl.href.slice(0, -1);
+    this.socketUrl = socketUrl.href;
   }
 
   public async handle({ response }: { response: HttpResponse }): Promise<void> {
diff --git a/src/storage/routing/RegexRouterRule.ts b/src/storage/routing/RegexRouterRule.ts
index 1494e14b1..bd2499b34 100644
--- a/src/storage/routing/RegexRouterRule.ts
+++ b/src/storage/routing/RegexRouterRule.ts
@@ -21,8 +21,7 @@ export class RegexRouterRule extends RouterRule {
   /**
    * The keys of the `storeMap` will be converted into actual RegExp objects that will be used for testing.
    */
-  // eslint-disable-next-line @typescript-eslint/consistent-indexed-object-style
-  public constructor(base: string, storeMap: { [ regex: string ]: ResourceStore }) {
+  public constructor(base: string, storeMap: Record<string, ResourceStore>) {
     super();
     this.base = trimTrailingSlashes(base);
     this.regexes = new Map(Object.keys(storeMap).map((regex): [ RegExp, ResourceStore ] =>


Test patch:
diff --git a/test/configs/middleware.json b/test/configs/middleware.json
new file mode 100644
index 000000000..e123dc0df
--- /dev/null
+++ b/test/configs/middleware.json
@@ -0,0 +1,14 @@
+{
+  "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^1.0.0/components/context.jsonld",
+  "import": [
+    "files-scs:config/presets/http.json",
+    "files-scs:config/presets/middleware.json",
+    "files-scs:config/presets/cli-params.json"
+  ],
+  "@graph": [
+    {
+      "@id": "urn:solid-server:default:LdpHandler",
+      "@type": "Variable"
+    }
+  ]
+}
diff --git a/test/configs/websockets.json b/test/configs/websockets.json
index 0d7f09c31..651a6cf1a 100644
--- a/test/configs/websockets.json
+++ b/test/configs/websockets.json
@@ -9,6 +9,7 @@
     "files-scs:config/presets/ldp/response-writer.json",
     "files-scs:config/presets/ldp/request-parser.json",
     "files-scs:config/presets/ldp/websockets.json",
+    "files-scs:config/presets/middleware.json",
     "files-scs:config/presets/representation-conversion.json",
     "files-scs:config/presets/storage/backend/storage-memory.json",
     "files-scs:config/presets/storage/routing/no-routing.json",
@@ -17,7 +18,7 @@
   ],
   "@graph": [
     {
-      "@id": "urn:solid-server:default:HttpHandler",
+      "@id": "urn:solid-server:default:LdpHandler",
       "@type": "AuthenticatedLdpHandler",
       "AuthenticatedLdpHandler:_args_requestParser": {
         "@id": "urn:solid-server:default:RequestParser"
diff --git a/test/integration/Middleware.test.ts b/test/integration/Middleware.test.ts
new file mode 100644
index 000000000..8d0710bb0
--- /dev/null
+++ b/test/integration/Middleware.test.ts
@@ -0,0 +1,70 @@
+import type { Server } from 'http';
+import request from 'supertest';
+import type { ExpressHttpServerFactory } from '../../src/server/ExpressHttpServerFactory';
+import { HttpHandler } from '../../src/server/HttpHandler';
+import type { HttpRequest } from '../../src/server/HttpRequest';
+import type { HttpResponse } from '../../src/server/HttpResponse';
+import { instantiateFromConfig } from '../configs/Util';
+
+const port = 6002;
+
+class SimpleHttpHandler extends HttpHandler {
+  public async handle(input: { request: HttpRequest; response: HttpResponse }): Promise<void> {
+    input.response.writeHead(200);
+    input.response.end('Hello World');
+  }
+}
+
+describe('An Express server with middleware', (): void => {
+  let server: Server;
+
+  beforeAll(async(): Promise<void> => {
+    const factory = await instantiateFromConfig(
+      'urn:solid-server:default:ExpressHttpServerFactory', 'middleware.json', {
+        'urn:solid-server:default:LdpHandler': new SimpleHttpHandler(),
+        'urn:solid-server:default:variable:port': port,
+      },
+    ) as ExpressHttpServerFactory;
+    server = factory.startServer(port);
+  });
+
+  afterEach(async(): Promise<void> => {
+    server.close();
+  });
+
+  it('sends server identification in the X-Powered-By header.', async(): Promise<void> => {
+    const res = await request(server).get('/');
+    expect(res.header).toEqual(expect.objectContaining({
+      'x-powered-by': 'Community Solid Server',
+    }));
+  });
+
+  it('returns CORS headers for an OPTIONS request.', async(): Promise<void> => {
+    const res = await request(server)
+      .options('/')
+      .set('Access-Control-Request-Headers', 'content-type')
+      .set('Access-Control-Request-Method', 'POST')
+      .set('Host', 'test.com')
+      .expect(204);
+    expect(res.header).toEqual(expect.objectContaining({
+      'access-control-allow-origin': '*',
+      'access-control-allow-headers': 'content-type',
+    }));
+    const corsMethods = res.header['access-control-allow-methods'].split(',')
+      .map((method: string): string => method.trim());
+    const allowedMethods = [ 'GET', 'HEAD', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE' ];
+    expect(corsMethods).toEqual(expect.arrayContaining(allowedMethods));
+    expect(corsMethods).toHaveLength(allowedMethods.length);
+  });
+
+  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);
+    expect(res.header).toEqual(expect.objectContaining({ 'access-control-allow-origin': 'test.com' }));
+  });
+
+  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');
+  });
+});
diff --git a/test/integration/WebSocketsProtocol.test.ts b/test/integration/WebSocketsProtocol.test.ts
index 80184eb81..d3d64ef1b 100644
--- a/test/integration/WebSocketsProtocol.test.ts
+++ b/test/integration/WebSocketsProtocol.test.ts
@@ -33,7 +33,13 @@ describe('A server with the Solid WebSockets API', (): void => {
 
   it('sets the Updates-Via header.', async(): Promise<void> => {
     const response = await fetch(baseUrl);
-    expect(response.headers.get('Updates-Via')).toBe(`ws://localhost:${port}`);
+    expect(response.headers.get('Updates-Via')).toBe(`ws://localhost:${port}/`);
+  });
+
+  it('exposes the Updates-Via header via CORS.', async(): Promise<void> => {
+    const response = await fetch(baseUrl);
+    expect(response.headers.get('Access-Control-Expose-Headers')!.split(','))
+      .toContain('Updates-Via');
   });
 
   describe('when a WebSocket client connects', (): void => {
diff --git a/test/unit/server/ExpressHttpServerFactory.test.ts b/test/unit/server/ExpressHttpServerFactory.test.ts
index eff416d19..335f41d11 100644
--- a/test/unit/server/ExpressHttpServerFactory.test.ts
+++ b/test/unit/server/ExpressHttpServerFactory.test.ts
@@ -42,7 +42,6 @@ describe('ExpressHttpServerFactory', (): void => {
   });
 
   afterEach(async(): Promise<void> => {
-    // Close server
     server.close();
   });
 
@@ -50,36 +49,6 @@ describe('ExpressHttpServerFactory', (): void => {
     mock.mockReset();
   });
 
-  it('sends server identification in the X-Powered-By header.', async(): Promise<void> => {
-    const res = await request(server).get('/');
-    expect(res.header).toEqual(expect.objectContaining({
-      'x-powered-by': 'Community Solid Server',
-    }));
-  });
-
-  it('returns CORS headers for an OPTIONS request.', async(): Promise<void> => {
-    const res = await request(server)
-      .options('/')
-      .set('Access-Control-Request-Headers', 'content-type')
-      .set('Access-Control-Request-Method', 'POST')
-      .set('Host', 'test.com')
-      .expect(204);
-    expect(res.header).toEqual(expect.objectContaining({
-      'access-control-allow-origin': '*',
-      'access-control-allow-headers': 'content-type',
-    }));
-    const corsMethods = res.header['access-control-allow-methods'].split(',')
-      .map((method: string): string => method.trim());
-    const allowedMethods = [ 'GET', 'HEAD', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE' ];
-    expect(corsMethods).toEqual(expect.arrayContaining(allowedMethods));
-    expect(corsMethods).toHaveLength(allowedMethods.length);
-  });
-
-  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);
-    expect(res.header).toEqual(expect.objectContaining({ 'access-control-allow-origin': 'test.com' }));
-  });
-
   it('sends incoming requests to the handler.', async(): Promise<void> => {
     await request(server).get('/').set('Host', 'test.com').expect(200);
     expect(canHandleJest).toHaveBeenCalledTimes(1);
diff --git a/test/unit/server/middleware/CorsHandler.test.ts b/test/unit/server/middleware/CorsHandler.test.ts
new file mode 100644
index 000000000..bbe168fb2
--- /dev/null
+++ b/test/unit/server/middleware/CorsHandler.test.ts
@@ -0,0 +1,47 @@
+import { createRequest, createResponse } from 'node-mocks-http';
+import { CorsHandler } from '../../../../src/server/middleware/CorsHandler';
+import { guardStream } from '../../../../src/util/GuardedStream';
+
+describe('a CorsHandler', (): void => {
+  it('sets regular CORS headers.', async(): Promise<void> => {
+    const handler = new CorsHandler();
+
+    const request = guardStream(createRequest());
+    const response = createResponse();
+    await handler.handleSafe({ request, response });
+
+    expect(response.getHeaders()).toEqual(expect.objectContaining({
+      'access-control-allow-origin': '*',
+    }));
+  });
+
+  it('echoes the origin when specified.', async(): Promise<void> => {
+    const handler = new CorsHandler();
+
+    const request = guardStream(createRequest({
+      headers: {
+        origin: 'example.org',
+      },
+    }));
+    const response = createResponse();
+    await handler.handleSafe({ request, response });
+
+    expect(response.getHeaders()).toEqual(expect.objectContaining({
+      'access-control-allow-origin': 'example.org',
+    }));
+  });
+
+  it('supports customizations.', async(): Promise<void> => {
+    const handler = new CorsHandler({
+      exposedHeaders: [ 'Custom-Header' ],
+    });
+
+    const request = guardStream(createRequest());
+    const response = createResponse();
+    await handler.handleSafe({ request, response });
+
+    expect(response.getHeaders()).toEqual(expect.objectContaining({
+      'access-control-expose-headers': 'Custom-Header',
+    }));
+  });
+});
diff --git a/test/unit/server/middleware/HeaderHandler.test.ts b/test/unit/server/middleware/HeaderHandler.test.ts
new file mode 100644
index 000000000..46b426624
--- /dev/null
+++ b/test/unit/server/middleware/HeaderHandler.test.ts
@@ -0,0 +1,16 @@
+import { createRequest, createResponse } from 'node-mocks-http';
+import { HeaderHandler } from '../../../../src/server/middleware/HeaderHandler';
+import { guardStream } from '../../../../src/util/GuardedStream';
+
+describe('a HeaderHandler', (): void => {
+  it('adds all configured headers.', async(): Promise<void> => {
+    const headers = { custom: 'Custom', other: 'Other' };
+    const handler = new HeaderHandler(headers);
+
+    const request = guardStream(createRequest());
+    const response = createResponse();
+    await handler.handleSafe({ request, response });
+
+    expect(response.getHeaders()).toEqual(expect.objectContaining(headers));
+  });
+});
diff --git a/test/unit/ldp/http/metadata/WebSocketMetadataWriter.test.ts b/test/unit/server/middleware/WebSocketAdvertiser.test.ts
similarity index 61%
rename from test/unit/ldp/http/metadata/WebSocketMetadataWriter.test.ts
rename to test/unit/server/middleware/WebSocketAdvertiser.test.ts
index f11ea5f5d..5421c5346 100644
--- a/test/unit/ldp/http/metadata/WebSocketMetadataWriter.test.ts
+++ b/test/unit/server/middleware/WebSocketAdvertiser.test.ts
@@ -1,37 +1,37 @@
 import { createResponse } from 'node-mocks-http';
-import { WebSocketMetadataWriter } from '../../../../../src/ldp/http/metadata/WebSocketMetadataWriter';
+import { WebSocketAdvertiser } from '../../../../src/server/middleware/WebSocketAdvertiser';
 
-describe('A WebSocketMetadataWriter', (): void => {
+describe('A WebSocketAdvertiser', (): void => {
   it('writes a default HTTP WebSocket.', async(): Promise<void> => {
-    const writer = new WebSocketMetadataWriter();
+    const writer = new WebSocketAdvertiser();
     const response = createResponse();
     await writer.handle({ response } as any);
-    expect(response.getHeaders()).toEqual({ 'updates-via': 'ws://localhost' });
+    expect(response.getHeaders()).toEqual({ 'updates-via': 'ws://localhost/' });
   });
 
   it('writes an HTTP WebSocket with port 80.', async(): Promise<void> => {
-    const writer = new WebSocketMetadataWriter({ hostname: 'test.example', port: 80, protocol: 'http' });
+    const writer = new WebSocketAdvertiser({ hostname: 'test.example', port: 80, protocol: 'http' });
     const response = createResponse();
     await writer.handle({ response } as any);
-    expect(response.getHeaders()).toEqual({ 'updates-via': 'ws://test.example' });
+    expect(response.getHeaders()).toEqual({ 'updates-via': 'ws://test.example/' });
   });
 
   it('writes an HTTP WebSocket with port 3000.', async(): Promise<void> => {
-    const writer = new WebSocketMetadataWriter({ hostname: 'test.example', port: 3000, protocol: 'http' });
+    const writer = new WebSocketAdvertiser({ hostname: 'test.example', port: 3000, protocol: 'http' });
     const response = createResponse();
     await writer.handle({ response } as any);
-    expect(response.getHeaders()).toEqual({ 'updates-via': 'ws://test.example:3000' });
+    expect(response.getHeaders()).toEqual({ 'updates-via': 'ws://test.example:3000/' });
   });
 
   it('writes an HTTPS WebSocket with port 443.', async(): Promise<void> => {
-    const writer = new WebSocketMetadataWriter({ hostname: 'test.example', port: 443, protocol: 'https' });
+    const writer = new WebSocketAdvertiser({ hostname: 'test.example', port: 443, protocol: 'https' });
     const response = createResponse();
     await writer.handle({ response } as any);
-    expect(response.getHeaders()).toEqual({ 'updates-via': 'wss://test.example' });
+    expect(response.getHeaders()).toEqual({ 'updates-via': 'wss://test.example/' });
   });
 
   it('rejects an invalid hostname.', (): void => {
-    expect((): any => new WebSocketMetadataWriter({ hostname: 'test.example/invalid' }))
+    expect((): any => new WebSocketAdvertiser({ hostname: 'test.example/invalid' }))
       .toThrow('Invalid hostname: test.example/invalid');
   });
 });