Repository: solid/community-server
Pull request #550 (2021-01-26 09:16:36)

Run CI on first and last of each stable version


Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 17eb29b41..b9709708d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -17,7 +17,7 @@ jobs:
       - uses: actions/checkout@v2
       - uses: actions/setup-node@v2
         with:
-          node-version: 14.x
+          node-version: '14.x'
       - run: npm ci --ignore-scripts
       - run: npm run lint
 
@@ -26,8 +26,10 @@ jobs:
     strategy:
       matrix:
         node-version:
-          - 12.x
-          - 14.x
+          - '12.17'
+          - '12.x'
+          - '14.0'
+          - '14.x'
     env:
       TEST_DOCKER: true
     services:
@@ -70,8 +72,8 @@ jobs:
     strategy:
       matrix:
         node-version:
-          - 12.x
-          - 14.x
+          - '12.x'
+          - '14.x'
     steps:
       - name: Use Node.js ${{ matrix.node-version }}
         uses: actions/setup-node@v2
@@ -108,7 +110,7 @@ jobs:
       - uses: actions/checkout@v2
       - uses: actions/setup-node@v2
         with:
-          node-version: 14.x
+          node-version: '14.x'
       - run: npm ci --ignore-scripts
       - run: npm run docs
       - name: Save docs
diff --git a/config/presets/ldp/response-writer.json b/config/presets/ldp/response-writer.json
index 71bb9f175..6582754d2 100644
--- a/config/presets/ldp/response-writer.json
+++ b/config/presets/ldp/response-writer.json
@@ -5,16 +5,29 @@
       "@id": "urn:solid-server:default:MetadataSerializer",
       "@type": "SequenceHandler",
       "SequenceHandler:_handlers": [
+        {
+          "@type": "ConstantMetadataWriter",
+          "ConstantMetadataWriter:_headers": [
+            {
+              "ConstantMetadataWriter:_headers_key": "Accept-Patch",
+              "ConstantMetadataWriter:_headers_value": "application/sparql-update"
+            },
+            {
+              "ConstantMetadataWriter:_headers_key": "MS-Author-Via",
+              "ConstantMetadataWriter:_headers_value": "SPARQL"
+            }
+          ]
+        },
         {
           "@type": "MappedMetadataWriter",
           "MappedMetadataWriter:_headerMap": [
             {
               "MappedMetadataWriter:_headerMap_key": "http://www.w3.org/ns/ma-ont#format",
-              "MappedMetadataWriter:_headerMap_value": "content-type"
+              "MappedMetadataWriter:_headerMap_value": "Content-Type"
             },
             {
               "MappedMetadataWriter:_headerMap_key": "urn:solid:http:location",
-              "MappedMetadataWriter:_headerMap_value": "location"
+              "MappedMetadataWriter:_headerMap_value": "Location"
             }
           ]
         },
@@ -26,6 +39,12 @@
               "LinkRelMetadataWriter:_linkRelMap_value": "type"
             }
           ]
+        },
+        {
+          "@type": "AclLinkMetadataWriter",
+          "AclLinkMetadataWriter:_aclManager": {
+            "@id": "urn:solid-server:default:AclManager"
+          }
         }
       ]
     },
diff --git a/config/presets/middleware.json b/config/presets/middleware.json
index e1aa24a0e..6aa2c9be3 100644
--- a/config/presets/middleware.json
+++ b/config/presets/middleware.json
@@ -18,7 +18,9 @@
           ],
           "CorsHandler:_options_credentials": true,
           "CorsHandler:_options_exposedHeaders": [
+            "Accept-Patch",
             "Location",
+            "MS-Author-Via",
             "Updates-Via"
           ]
         },
diff --git a/src/index.ts b/src/index.ts
index 6a4c5d7e7..b86bbaef1 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -23,7 +23,9 @@ export * from './init/RootContainerInitializer';
 export * from './init/ServerInitializer';
 
 // LDP/HTTP/Metadata
+export * from './ldp/http/metadata/AclLinkMetadataWriter';
 export * from './ldp/http/metadata/BasicMetadataExtractor';
+export * from './ldp/http/metadata/ConstantMetadataWriter';
 export * from './ldp/http/metadata/ContentTypeParser';
 export * from './ldp/http/metadata/LinkRelMetadataWriter';
 export * from './ldp/http/metadata/LinkTypeParser';
diff --git a/src/ldp/http/metadata/AclLinkMetadataWriter.ts b/src/ldp/http/metadata/AclLinkMetadataWriter.ts
new file mode 100644
index 000000000..2d9bfba43
--- /dev/null
+++ b/src/ldp/http/metadata/AclLinkMetadataWriter.ts
@@ -0,0 +1,25 @@
+import type { AclManager } from '../../../authorization/AclManager';
+import type { HttpResponse } from '../../../server/HttpResponse';
+import { addHeader } from '../../../util/HeaderUtil';
+import type { RepresentationMetadata } from '../../representation/RepresentationMetadata';
+import { MetadataWriter } from './MetadataWriter';
+
+/**
+ * A MetadataWriter that always adds a rel="acl" link header to a response.
+ * The `rel` parameter can be used if a different `rel` value is needed (such as http://www.w3.org/ns/solid/terms#acl).
+ */
+export class AclLinkMetadataWriter extends MetadataWriter {
+  private readonly aclManager: AclManager;
+  private readonly rel: string;
+
+  public constructor(aclManager: AclManager, rel = 'acl') {
+    super();
+    this.aclManager = aclManager;
+    this.rel = rel;
+  }
+
+  public async handle(input: { response: HttpResponse; metadata: RepresentationMetadata }): Promise<void> {
+    const identifier = await this.aclManager.getAclDocument({ path: input.metadata.identifier.value });
+    addHeader(input.response, 'Link', `<${identifier.path}>; rel="${this.rel}"`);
+  }
+}
diff --git a/src/ldp/http/metadata/ConstantMetadataWriter.ts b/src/ldp/http/metadata/ConstantMetadataWriter.ts
new file mode 100644
index 000000000..58ceebc7d
--- /dev/null
+++ b/src/ldp/http/metadata/ConstantMetadataWriter.ts
@@ -0,0 +1,21 @@
+import type { HttpResponse } from '../../../server/HttpResponse';
+import { addHeader } from '../../../util/HeaderUtil';
+import { MetadataWriter } from './MetadataWriter';
+
+/**
+ * A {@link MetadataWriter} that takes a constant map of header names and values.
+ */
+export class ConstantMetadataWriter extends MetadataWriter {
+  private readonly headers: [string, string][];
+
+  public constructor(headers: Record<string, string>) {
+    super();
+    this.headers = Object.entries(headers);
+  }
+
+  public async handle({ response }: { response: HttpResponse }): Promise<void> {
+    for (const [ key, value ] of this.headers) {
+      addHeader(response, key, value);
+    }
+  }
+}
diff --git a/src/ldp/http/metadata/LinkRelMetadataWriter.ts b/src/ldp/http/metadata/LinkRelMetadataWriter.ts
index e0d590616..fc0ec7658 100644
--- a/src/ldp/http/metadata/LinkRelMetadataWriter.ts
+++ b/src/ldp/http/metadata/LinkRelMetadataWriter.ts
@@ -24,7 +24,7 @@ export class LinkRelMetadataWriter extends MetadataWriter {
       const values = input.metadata.getAll(key).map((term): string => `<${term.value}>; rel="${this.linkRelMap[key]}"`);
       if (values.length > 0) {
         this.logger.debug(`Adding Link header ${values}`);
-        addHeader(input.response, 'link', values);
+        addHeader(input.response, 'Link', values);
       }
     }
   }
diff --git a/src/ldp/http/metadata/MappedMetadataWriter.ts b/src/ldp/http/metadata/MappedMetadataWriter.ts
index af4efb84a..c76e82ed6 100644
--- a/src/ldp/http/metadata/MappedMetadataWriter.ts
+++ b/src/ldp/http/metadata/MappedMetadataWriter.ts
@@ -8,18 +8,18 @@ import { MetadataWriter } from './MetadataWriter';
  * The header value(s) will be the same as the corresponding object value(s).
  */
 export class MappedMetadataWriter extends MetadataWriter {
-  private readonly headerMap: Record<string, string>;
+  private readonly headerMap: [string, string][];
 
   public constructor(headerMap: Record<string, string>) {
     super();
-    this.headerMap = headerMap;
+    this.headerMap = Object.entries(headerMap);
   }
 
   public async handle(input: { response: HttpResponse; metadata: RepresentationMetadata }): Promise<void> {
-    for (const key of Object.keys(this.headerMap)) {
-      const values = input.metadata.getAll(key).map((term): string => term.value);
-      if (values.length > 0) {
-        addHeader(input.response, this.headerMap[key], values);
+    for (const [ predicate, header ] of this.headerMap) {
+      const terms = input.metadata.getAll(predicate);
+      if (terms.length > 0) {
+        addHeader(input.response, header, terms.map((term): string => term.value));
       }
     }
   }
diff --git a/src/server/middleware/WebSocketAdvertiser.ts b/src/server/middleware/WebSocketAdvertiser.ts
index c1fa4e1fb..aa8bc470c 100644
--- a/src/server/middleware/WebSocketAdvertiser.ts
+++ b/src/server/middleware/WebSocketAdvertiser.ts
@@ -16,6 +16,6 @@ export class WebSocketAdvertiser extends HttpHandler {
   }
 
   public async handle({ response }: { response: HttpResponse }): Promise<void> {
-    addHeader(response, 'updates-via', this.socketUrl);
+    addHeader(response, 'Updates-Via', this.socketUrl);
   }
 }


Test patch:
diff --git a/test/integration/LdpHandlerWithAuth.test.ts b/test/integration/LdpHandlerWithAuth.test.ts
index 0690e3ffc..1e0b42313 100644
--- a/test/integration/LdpHandlerWithAuth.test.ts
+++ b/test/integration/LdpHandlerWithAuth.test.ts
@@ -76,7 +76,8 @@ describe.each(stores)('An LDP handler with auth using %s', (name, { storeUrn, se
     response = await resourceHelper.getResource(id);
     expect(response.statusCode).toBe(200);
     expect(response._getBuffer().toString()).toContain('TESTFILE2');
-    expect(response.getHeaders().link).toBe(`<${LDP.Resource}>; rel="type"`);
+    expect(response.getHeaders().link).toContain(`<${LDP.Resource}>; rel="type"`);
+    expect(response.getHeaders().link).toContain(`<${id}.acl>; rel="acl"`);
 
     // DELETE file
     await resourceHelper.deleteResource(id);
@@ -109,7 +110,8 @@ describe.each(stores)('An LDP handler with auth using %s', (name, { storeUrn, se
     // GET permanent file
     response = await resourceHelper.getResource('http://test.com/permanent.txt');
     expect(response._getBuffer().toString()).toContain('TEST');
-    expect(response.getHeaders().link).toBe(`<${LDP.Resource}>; rel="type"`);
+    expect(response.getHeaders().link).toContain(`<${LDP.Resource}>; rel="type"`);
+    expect(response.getHeaders().link).toContain(`<http://test.com/permanent.txt.acl>; rel="acl"`);
 
     // Try to delete permanent file
     response = await resourceHelper.deleteResource('http://test.com/permanent.txt', true);
diff --git a/test/integration/LdpHandlerWithoutAuth.test.ts b/test/integration/LdpHandlerWithoutAuth.test.ts
index f8cf0b109..92ecd9355 100644
--- a/test/integration/LdpHandlerWithoutAuth.test.ts
+++ b/test/integration/LdpHandlerWithoutAuth.test.ts
@@ -66,6 +66,7 @@ describe.each(stores)('An LDP handler without auth using %s', (name, { storeUrn,
     const data = response._getData().toString();
     expect(data).toContain(`<> a ldp:Container`);
     expect(response.getHeaders().link).toContain(`<${LDP.Container}>; rel="type"`);
+    expect(response.getHeaders().link).toContain(`<${BASE}/.acl>; rel="acl"`);
   });
 
   it('can add a file to the store, read it and delete it.', async():
@@ -80,7 +81,10 @@ describe.each(stores)('An LDP handler without auth using %s', (name, { storeUrn,
     response = await resourceHelper.getResource(id);
     expect(response.statusCode).toBe(200);
     expect(response._getBuffer().toString()).toContain('TESTFILE0');
-    expect(response.getHeaders().link).toBe(`<${LDP.Resource}>; rel="type"`);
+    expect(response.getHeaders().link).toContain(`<${LDP.Resource}>; rel="type"`);
+    expect(response.getHeaders().link).toContain(`<${id}.acl>; rel="acl"`);
+    expect(response.getHeaders()['accept-patch']).toBe('application/sparql-update');
+    expect(response.getHeaders()['ms-author-via']).toBe('SPARQL');
 
     // DELETE
     await resourceHelper.deleteResource(id);
@@ -97,7 +101,8 @@ describe.each(stores)('An LDP handler without auth using %s', (name, { storeUrn,
     response = await resourceHelper.getResource(id);
     expect(response.statusCode).toBe(200);
     expect(response._getBuffer().toString()).toContain('TESTFILE0');
-    expect(response.getHeaders().link).toBe(`<${LDP.Resource}>; rel="type"`);
+    expect(response.getHeaders().link).toContain(`<${LDP.Resource}>; rel="type"`);
+    expect(response.getHeaders().link).toContain(`<${id}.acl>; rel="acl"`);
 
     // PUT
     response = await resourceHelper.replaceResource(
@@ -108,7 +113,8 @@ describe.each(stores)('An LDP handler without auth using %s', (name, { storeUrn,
     response = await resourceHelper.getResource(id);
     expect(response.statusCode).toBe(200);
     expect(response._getBuffer().toString()).toContain('TESTFILE1');
-    expect(response.getHeaders().link).toBe(`<${LDP.Resource}>; rel="type"`);
+    expect(response.getHeaders().link).toContain(`<${LDP.Resource}>; rel="type"`);
+    expect(response.getHeaders().link).toContain(`<${id}.acl>; rel="acl"`);
 
     // DELETE
     await resourceHelper.deleteResource(id);
@@ -123,9 +129,10 @@ describe.each(stores)('An LDP handler without auth using %s', (name, { storeUrn,
     // GET
     response = await resourceHelper.getContainer(id);
     expect(response.statusCode).toBe(200);
-    expect(response.getHeaders().link).toEqual(
-      [ `<${LDP.Container}>; rel="type"`, `<${LDP.BasicContainer}>; rel="type"`, `<${LDP.Resource}>; rel="type"` ],
-    );
+    expect(response.getHeaders().link).toContain(`<${LDP.Container}>; rel="type"`);
+    expect(response.getHeaders().link).toContain(`<${LDP.BasicContainer}>; rel="type"`);
+    expect(response.getHeaders().link).toContain(`<${LDP.Resource}>; rel="type"`);
+    expect(response.getHeaders().link).toContain(`<${id}.acl>; rel="acl"`);
 
     // DELETE
     await resourceHelper.deleteResource(id);
@@ -145,7 +152,8 @@ describe.each(stores)('An LDP handler without auth using %s', (name, { storeUrn,
     // GET File
     response = await resourceHelper.getResource(id);
     expect(response.statusCode).toBe(200);
-    expect(response.getHeaders().link).toBe(`<${LDP.Resource}>; rel="type"`);
+    expect(response.getHeaders().link).toContain(`<${LDP.Resource}>; rel="type"`);
+    expect(response.getHeaders().link).toContain(`<${id}.acl>; rel="acl"`);
 
     // DELETE
     await resourceHelper.deleteResource(id);
@@ -216,9 +224,10 @@ describe.each(stores)('An LDP handler without auth using %s', (name, { storeUrn,
     expect(response.statusCode).toBe(200);
     expect(response._getData()).toContain('<http://www.w3.org/ns/ldp#contains> <http://test.com/testfolder3/subfolder0/> .');
     expect(response._getData()).toContain('<http://www.w3.org/ns/ldp#contains> <http://test.com/testfolder3/testfile0.txt> .');
-    expect(response.getHeaders().link).toEqual(
-      [ `<${LDP.Container}>; rel="type"`, `<${LDP.BasicContainer}>; rel="type"`, `<${LDP.Resource}>; rel="type"` ],
-    );
+    expect(response.getHeaders().link).toContain(`<${LDP.Container}>; rel="type"`);
+    expect(response.getHeaders().link).toContain(`<${LDP.BasicContainer}>; rel="type"`);
+    expect(response.getHeaders().link).toContain(`<${LDP.Resource}>; rel="type"`);
+    expect(response.getHeaders().link).toContain(`<${folderId}.acl>; rel="acl"`);
 
     // DELETE
     await resourceHelper.deleteResource(fileId);
diff --git a/test/integration/LockingResourceStore.test.ts b/test/integration/LockingResourceStore.test.ts
index 48220c828..05f04265f 100644
--- a/test/integration/LockingResourceStore.test.ts
+++ b/test/integration/LockingResourceStore.test.ts
@@ -1,4 +1,3 @@
-import type { Readable } from 'stream';
 import { RootContainerInitializer } from '../../src/init/RootContainerInitializer';
 import { BasicRepresentation } from '../../src/ldp/representation/BasicRepresentation';
 import type { Representation } from '../../src/ldp/representation/Representation';
@@ -18,12 +17,6 @@ import { BASE } from './Config';
 
 jest.useFakeTimers();
 
-async function readOnce(stream: Readable): Promise<any> {
-  return await new Promise((resolve): void => {
-    stream.once('data', resolve);
-  });
-}
-
 describe('A LockingResourceStore', (): void => {
   let path: string;
   let store: LockingResourceStore;
@@ -82,11 +75,13 @@ describe('A LockingResourceStore', (): void => {
 
     // Wait 750ms and read
     jest.advanceTimersByTime(750);
-    await expect(readOnce(representation.data)).resolves.toBe(1);
+    expect(representation.data.destroyed).toBe(false);
+    representation.data.read();
 
     // Wait 750ms and read
     jest.advanceTimersByTime(750);
-    await expect(readOnce(representation.data)).resolves.toBe(2);
+    expect(representation.data.destroyed).toBe(false);
+    representation.data.read();
 
     // Wait 1000ms and watch the stream be destroyed
     jest.advanceTimersByTime(1000);
diff --git a/test/integration/LpdHandlerOperations.test.ts b/test/integration/LpdHandlerOperations.test.ts
index 5d1aa45c0..c4254d952 100644
--- a/test/integration/LpdHandlerOperations.test.ts
+++ b/test/integration/LpdHandlerOperations.test.ts
@@ -47,7 +47,8 @@ describe('An integrated AuthenticatedLdpHandler', (): void => {
     expect(response._getData()).toContain(
       '<http://test.com/s> <http://test.com/p> <http://test.com/o>.',
     );
-    expect(response.getHeaders().link).toBe(`<${LDP.Resource}>; rel="type"`);
+    expect(response.getHeaders().link).toContain(`<${LDP.Resource}>; rel="type"`);
+    expect(response.getHeaders().link).toContain(`<${id}.acl>; rel="acl"`);
 
     // DELETE
     response = await performRequest(handler, requestUrl, 'DELETE', {}, []);
@@ -103,7 +104,8 @@ describe('An integrated AuthenticatedLdpHandler', (): void => {
     expect(response._getData()).toContain(
       '<http://test.com/s2> <http://test.com/p2> <http://test.com/o2>.',
     );
-    expect(response.getHeaders().link).toBe(`<${LDP.Resource}>; rel="type"`);
+    expect(response.getHeaders().link).toContain(`<${LDP.Resource}>; rel="type"`);
+    expect(response.getHeaders().link).toContain(`<${id}.acl>; rel="acl"`);
     const parser = new Parser();
     let triples = parser.parse(response._getData());
     expect(triples).toBeRdfIsomorphic([
@@ -190,7 +192,8 @@ describe('An integrated AuthenticatedLdpHandler', (): void => {
       [],
     );
     expect(response.statusCode).toBe(200);
-    expect(response.getHeaders().link).toBe(`<${LDP.Resource}>; rel="type"`);
+    expect(response.getHeaders().link).toContain(`<${LDP.Resource}>; rel="type"`);
+    expect(response.getHeaders().link).toContain(`<${id}.acl>; rel="acl"`);
     const parser = new Parser();
     const triples = parser.parse(response._getData());
     expect(triples).toBeRdfIsomorphic([
diff --git a/test/integration/Middleware.test.ts b/test/integration/Middleware.test.ts
index ad73836ad..323260043 100644
--- a/test/integration/Middleware.test.ts
+++ b/test/integration/Middleware.test.ts
@@ -86,12 +86,30 @@ describe('An Express server with middleware', (): void => {
     expect(res.header).toEqual(expect.objectContaining({ 'access-control-allow-origin': 'test.com' }));
   });
 
+  it('exposes the Accept-Patch header via CORS.', async(): Promise<void> => {
+    const res = await request(server).get('/').expect(200);
+    const exposed = res.header['access-control-expose-headers'];
+    expect(exposed.split(/\s*,\s*/u)).toContain('Accept-Patch');
+  });
+
   it('exposes the Location header via CORS.', async(): Promise<void> => {
     const res = await request(server).get('/').expect(200);
     const exposed = res.header['access-control-expose-headers'];
     expect(exposed.split(/\s*,\s*/u)).toContain('Location');
   });
 
+  it('exposes the MS-Author-Via header via CORS.', async(): Promise<void> => {
+    const res = await request(server).get('/').expect(200);
+    const exposed = res.header['access-control-expose-headers'];
+    expect(exposed.split(/\s*,\s*/u)).toContain('MS-Author-Via');
+  });
+
+  it('exposes the Updates-Via header via CORS.', async(): Promise<void> => {
+    const res = await request(server).get('/').expect(200);
+    const exposed = res.header['access-control-expose-headers'];
+    expect(exposed.split(/\s*,\s*/u)).toContain('Updates-Via');
+  });
+
   it('sends incoming requests to the handler.', async(): Promise<void> => {
     const response = request(server).get('/').set('Host', 'test.com');
     expect(response).toBeDefined();
diff --git a/test/integration/config/server-without-auth.json b/test/integration/config/server-without-auth.json
index 096fb874d..6e167c41b 100644
--- a/test/integration/config/server-without-auth.json
+++ b/test/integration/config/server-without-auth.json
@@ -46,6 +46,11 @@
       "PassthroughStore:_source": {
         "@id": "urn:solid-server:default:MemoryResourceStore"
       }
+    },
+    {
+      "@id": "urn:solid-server:default:AclManager",
+      "@type": "UrlBasedAclManager",
+      "comment": "Needed for AclLinkMetadataWriter"
     }
   ]
 }
diff --git a/test/unit/ldp/http/metadata/AclLinkMetadataWriter.test.ts b/test/unit/ldp/http/metadata/AclLinkMetadataWriter.test.ts
new file mode 100644
index 000000000..1a022fa0b
--- /dev/null
+++ b/test/unit/ldp/http/metadata/AclLinkMetadataWriter.test.ts
@@ -0,0 +1,28 @@
+import { createResponse } from 'node-mocks-http';
+import type { AclManager } from '../../../../../src/authorization/AclManager';
+import { AclLinkMetadataWriter } from '../../../../../src/ldp/http/metadata/AclLinkMetadataWriter';
+import { RepresentationMetadata } from '../../../../../src/ldp/representation/RepresentationMetadata';
+import type { ResourceIdentifier } from '../../../../../src/ldp/representation/ResourceIdentifier';
+
+describe('An AclLinkMetadataWriter', (): void => {
+  const manager = {
+    getAclDocument: async(id: ResourceIdentifier): Promise<ResourceIdentifier> => ({ path: `${id.path}.acl` }),
+  } as AclManager;
+  const identifier = { path: 'http://test.com/foo' };
+
+  it('adds the acl link header.', async(): Promise<void> => {
+    const writer = new AclLinkMetadataWriter(manager);
+    const response = createResponse();
+    const metadata = new RepresentationMetadata(identifier);
+    await expect(writer.handle({ response, metadata })).resolves.toBeUndefined();
+    expect(response.getHeaders()).toEqual({ link: `<${identifier.path}.acl>; rel="acl"` });
+  });
+
+  it('can use a custom rel attribute.', async(): Promise<void> => {
+    const writer = new AclLinkMetadataWriter(manager, 'http://www.w3.org/ns/solid/terms#acl');
+    const response = createResponse();
+    const metadata = new RepresentationMetadata(identifier);
+    await expect(writer.handle({ response, metadata })).resolves.toBeUndefined();
+    expect(response.getHeaders()).toEqual({ link: `<${identifier.path}.acl>; rel="http://www.w3.org/ns/solid/terms#acl"` });
+  });
+});
diff --git a/test/unit/ldp/http/metadata/ConstantMetadataWriter.test.ts b/test/unit/ldp/http/metadata/ConstantMetadataWriter.test.ts
new file mode 100644
index 000000000..2323b6791
--- /dev/null
+++ b/test/unit/ldp/http/metadata/ConstantMetadataWriter.test.ts
@@ -0,0 +1,23 @@
+import { createResponse } from 'node-mocks-http';
+import { ConstantMetadataWriter } from '../../../../../src/ldp/http/metadata/ConstantMetadataWriter';
+
+describe('A ConstantMetadataWriter', (): void => {
+  const writer = new ConstantMetadataWriter({ 'custom-Header': 'X', other: 'Y' });
+
+  it('adds new headers.', async(): Promise<void> => {
+    const response = createResponse();
+
+    await expect(writer.handle({ response })).resolves.toBeUndefined();
+
+    expect(response.getHeaders()).toEqual({ 'custom-header': 'X', other: 'Y' });
+  });
+
+  it('extends existing headers.', async(): Promise<void> => {
+    const response = createResponse();
+    response.setHeader('Other', 'A');
+
+    await expect(writer.handle({ response })).resolves.toBeUndefined();
+
+    expect(response.getHeaders()).toEqual({ 'custom-header': 'X', other: [ 'A', 'Y' ]});
+  });
+});