Repository: solid/community-server (ts)

Issue:
Allow POSTing resources to a container with Append access
Currently, a request to create a new file in a container configured with the `acl:Append` mode results in an `UnauthorizedHttpError`. 


```bash
$ cat ./data/.acl 
@prefix acl: <http://www.w3.org/ns/auth/acl#>.
@prefix foaf: <http://xmlns.com/foaf/0.1/>.

<#authorization>
    a               acl:Authorization;
    acl:agentClass  foaf:Agent;
    acl:mode        acl:Append;
    acl:accessTo    <./>;
    acl:default     <./>.
```

```bash
curl -X POST -H "Content-Type: text/plain" -d "test" http://localhost:3000/
UnauthorizedHttpError
    at WebAclAuthorizer.checkPermission (...) 
   ....

```
As described in the [spec](https://solid.github.io/web-access-control-spec/#aclappend), the expected behaviour should result in the file being created if it does not yet exist.


Pull request description:
fix: Only require append permissions on POST requests
Closes #552 .

Patch:
diff --git a/config/presets/ldp/response-writer.json b/config/presets/ldp/response-writer.json
index 6582754d2..17ba44c49 100644
--- a/config/presets/ldp/response-writer.json
+++ b/config/presets/ldp/response-writer.json
@@ -3,8 +3,8 @@
   "@graph": [
     {
       "@id": "urn:solid-server:default:MetadataSerializer",
-      "@type": "SequenceHandler",
-      "SequenceHandler:_handlers": [
+      "@type": "ParallelHandler",
+      "ParallelHandler:_handlers": [
         {
           "@type": "ConstantMetadataWriter",
           "ConstantMetadataWriter:_headers": [
diff --git a/config/presets/middleware.json b/config/presets/middleware.json
index 6aa2c9be3..b0bbad02e 100644
--- a/config/presets/middleware.json
+++ b/config/presets/middleware.json
@@ -3,8 +3,8 @@
   "@graph": [
     {
       "@id": "urn:solid-server:default:Middleware",
-      "@type": "SequenceHandler",
-      "SequenceHandler:_handlers": [
+      "@type": "ParallelHandler",
+      "ParallelHandler:_handlers": [
         {
           "@type": "CorsHandler",
           "CorsHandler:_options_methods": [
diff --git a/src/authentication/CredentialsExtractor.ts b/src/authentication/CredentialsExtractor.ts
index 12fafa265..1d7294b2a 100644
--- a/src/authentication/CredentialsExtractor.ts
+++ b/src/authentication/CredentialsExtractor.ts
@@ -1,5 +1,5 @@
 import type { HttpRequest } from '../server/HttpRequest';
-import { AsyncHandler } from '../util/AsyncHandler';
+import { AsyncHandler } from '../util/handlers/AsyncHandler';
 import type { Credentials } from './Credentials';
 
 /**
diff --git a/src/authorization/Authorizer.ts b/src/authorization/Authorizer.ts
index 02f603dcd..8e1dd8e05 100644
--- a/src/authorization/Authorizer.ts
+++ b/src/authorization/Authorizer.ts
@@ -1,7 +1,7 @@
 import type { Credentials } from '../authentication/Credentials';
 import type { PermissionSet } from '../ldp/permissions/PermissionSet';
 import type { ResourceIdentifier } from '../ldp/representation/ResourceIdentifier';
-import { AsyncHandler } from '../util/AsyncHandler';
+import { AsyncHandler } from '../util/handlers/AsyncHandler';
 
 /**
  * Verifies if the given credentials have access to the given permissions on the given resource.
diff --git a/src/index.ts b/src/index.ts
index b86bbaef1..2c18b7a37 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -187,6 +187,13 @@ export * from './util/errors/SystemError';
 export * from './util/errors/UnauthorizedHttpError';
 export * from './util/errors/UnsupportedMediaTypeHttpError';
 
+// Util/Handlers
+export * from './util/handlers/AsyncHandler';
+export * from './util/handlers/ParallelHandler';
+export * from './util/handlers/SequenceHandler';
+export * from './util/handlers/UnsupportedAsyncHandler';
+export * from './util/handlers/WaterfallHandler';
+
 // Util/Identifiers
 export * from './util/identifiers/IdentifierStrategy';
 export * from './util/identifiers/SingleRootIdentifierStrategy';
@@ -198,16 +205,12 @@ export * from './util/locking/SingleThreadedResourceLocker';
 export * from './util/locking/WrappedExpiringResourceLocker';
 
 // Util
-export * from './util/AsyncHandler';
 export * from './util/ContentTypes';
 export * from './util/GuardedStream';
 export * from './util/HeaderUtil';
 export * from './util/PathUtil';
 export * from './util/QuadUtil';
 export * from './util/RecordObject';
-export * from './util/SequenceHandler';
 export * from './util/StreamUtil';
 export * from './util/TermUtil';
-export * from './util/UnsupportedAsyncHandler';
 export * from './util/Vocabularies';
-export * from './util/WaterfallHandler';
diff --git a/src/init/Initializer.ts b/src/init/Initializer.ts
index 9348beb2e..502475eba 100644
--- a/src/init/Initializer.ts
+++ b/src/init/Initializer.ts
@@ -1,3 +1,3 @@
-import { AsyncHandler } from '../util/AsyncHandler';
+import { AsyncHandler } from '../util/handlers/AsyncHandler';
 
 export abstract class Initializer extends AsyncHandler {}
diff --git a/src/ldp/http/BodyParser.ts b/src/ldp/http/BodyParser.ts
index 0d0a774c4..0e36aff2b 100644
--- a/src/ldp/http/BodyParser.ts
+++ b/src/ldp/http/BodyParser.ts
@@ -1,5 +1,5 @@
 import type { HttpRequest } from '../../server/HttpRequest';
-import { AsyncHandler } from '../../util/AsyncHandler';
+import { AsyncHandler } from '../../util/handlers/AsyncHandler';
 import type { Representation } from '../representation/Representation';
 import type { RepresentationMetadata } from '../representation/RepresentationMetadata';
 
diff --git a/src/ldp/http/PreferenceParser.ts b/src/ldp/http/PreferenceParser.ts
index d8e9c912a..f0000881a 100644
--- a/src/ldp/http/PreferenceParser.ts
+++ b/src/ldp/http/PreferenceParser.ts
@@ -1,5 +1,5 @@
 import type { HttpRequest } from '../../server/HttpRequest';
-import { AsyncHandler } from '../../util/AsyncHandler';
+import { AsyncHandler } from '../../util/handlers/AsyncHandler';
 import type { RepresentationPreferences } from '../representation/RepresentationPreferences';
 
 /**
diff --git a/src/ldp/http/RequestParser.ts b/src/ldp/http/RequestParser.ts
index d0dd1dfe6..281793dc4 100644
--- a/src/ldp/http/RequestParser.ts
+++ b/src/ldp/http/RequestParser.ts
@@ -1,5 +1,5 @@
 import type { HttpRequest } from '../../server/HttpRequest';
-import { AsyncHandler } from '../../util/AsyncHandler';
+import { AsyncHandler } from '../../util/handlers/AsyncHandler';
 import type { Operation } from '../operations/Operation';
 
 /**
diff --git a/src/ldp/http/ResponseWriter.ts b/src/ldp/http/ResponseWriter.ts
index ba7d722ab..926c320c5 100644
--- a/src/ldp/http/ResponseWriter.ts
+++ b/src/ldp/http/ResponseWriter.ts
@@ -1,5 +1,5 @@
 import type { HttpResponse } from '../../server/HttpResponse';
-import { AsyncHandler } from '../../util/AsyncHandler';
+import { AsyncHandler } from '../../util/handlers/AsyncHandler';
 import type { ResponseDescription } from './response/ResponseDescription';
 
 /**
diff --git a/src/ldp/http/TargetExtractor.ts b/src/ldp/http/TargetExtractor.ts
index d6d74b368..1ce24fe0d 100644
--- a/src/ldp/http/TargetExtractor.ts
+++ b/src/ldp/http/TargetExtractor.ts
@@ -1,5 +1,5 @@
 import type { HttpRequest } from '../../server/HttpRequest';
-import { AsyncHandler } from '../../util/AsyncHandler';
+import { AsyncHandler } from '../../util/handlers/AsyncHandler';
 import type { ResourceIdentifier } from '../representation/ResourceIdentifier';
 
 /**
diff --git a/src/ldp/http/metadata/MetadataExtractor.ts b/src/ldp/http/metadata/MetadataExtractor.ts
index 2af766480..0c5f94f33 100644
--- a/src/ldp/http/metadata/MetadataExtractor.ts
+++ b/src/ldp/http/metadata/MetadataExtractor.ts
@@ -1,5 +1,5 @@
 import type { HttpRequest } from '../../../server/HttpRequest';
-import { AsyncHandler } from '../../../util/AsyncHandler';
+import { AsyncHandler } from '../../../util/handlers/AsyncHandler';
 import type { RepresentationMetadata } from '../../representation/RepresentationMetadata';
 import type { ResourceIdentifier } from '../../representation/ResourceIdentifier';
 
diff --git a/src/ldp/http/metadata/MetadataWriter.ts b/src/ldp/http/metadata/MetadataWriter.ts
index 46dd8aa3f..8b91f7830 100644
--- a/src/ldp/http/metadata/MetadataWriter.ts
+++ b/src/ldp/http/metadata/MetadataWriter.ts
@@ -1,5 +1,5 @@
 import type { HttpResponse } from '../../../server/HttpResponse';
-import { AsyncHandler } from '../../../util/AsyncHandler';
+import { AsyncHandler } from '../../../util/handlers/AsyncHandler';
 import type { RepresentationMetadata } from '../../representation/RepresentationMetadata';
 
 /**
diff --git a/src/ldp/operations/OperationHandler.ts b/src/ldp/operations/OperationHandler.ts
index 014336d70..724cd3d3e 100644
--- a/src/ldp/operations/OperationHandler.ts
+++ b/src/ldp/operations/OperationHandler.ts
@@ -1,4 +1,4 @@
-import { AsyncHandler } from '../../util/AsyncHandler';
+import { AsyncHandler } from '../../util/handlers/AsyncHandler';
 import type { ResponseDescription } from '../http/response/ResponseDescription';
 import type { Operation } from './Operation';
 
diff --git a/src/ldp/permissions/MethodPermissionsExtractor.ts b/src/ldp/permissions/MethodPermissionsExtractor.ts
index 2a06c3db8..7fad3d8c0 100644
--- a/src/ldp/permissions/MethodPermissionsExtractor.ts
+++ b/src/ldp/permissions/MethodPermissionsExtractor.ts
@@ -4,8 +4,9 @@ import type { PermissionSet } from './PermissionSet';
 import { PermissionsExtractor } from './PermissionsExtractor';
 
 const READ_METHODS = new Set([ 'GET', 'HEAD' ]);
-const WRITE_METHODS = new Set([ 'POST', 'PUT', 'DELETE' ]);
-const SUPPORTED_METHODS = new Set([ ...READ_METHODS, ...WRITE_METHODS ]);
+const WRITE_METHODS = new Set([ 'PUT', 'DELETE' ]);
+const APPEND_METHODS = new Set([ 'POST' ]);
+const SUPPORTED_METHODS = new Set([ ...READ_METHODS, ...WRITE_METHODS, ...APPEND_METHODS ]);
 
 /**
  * Generates permissions for the base set of methods that always require the same permissions.
@@ -21,6 +22,7 @@ export class MethodPermissionsExtractor extends PermissionsExtractor {
   public async handle({ method }: Operation): Promise<PermissionSet> {
     const read = READ_METHODS.has(method);
     const write = WRITE_METHODS.has(method);
-    return { read, write, append: write };
+    const append = write || APPEND_METHODS.has(method);
+    return { read, write, append };
   }
 }
diff --git a/src/ldp/permissions/PermissionsExtractor.ts b/src/ldp/permissions/PermissionsExtractor.ts
index ea562b107..168ad919d 100644
--- a/src/ldp/permissions/PermissionsExtractor.ts
+++ b/src/ldp/permissions/PermissionsExtractor.ts
@@ -1,4 +1,4 @@
-import { AsyncHandler } from '../../util/AsyncHandler';
+import { AsyncHandler } from '../../util/handlers/AsyncHandler';
 import type { Operation } from '../operations/Operation';
 import type { PermissionSet } from './PermissionSet';
 
diff --git a/src/pods/agent/AgentParser.ts b/src/pods/agent/AgentParser.ts
index f1b3c64d3..4098fa6cf 100644
--- a/src/pods/agent/AgentParser.ts
+++ b/src/pods/agent/AgentParser.ts
@@ -1,5 +1,5 @@
 import type { Representation } from '../../ldp/representation/Representation';
-import { AsyncHandler } from '../../util/AsyncHandler';
+import { AsyncHandler } from '../../util/handlers/AsyncHandler';
 import type { Agent } from './Agent';
 
 /**
diff --git a/src/server/HttpHandler.ts b/src/server/HttpHandler.ts
index 7505b232a..a48314ae9 100644
--- a/src/server/HttpHandler.ts
+++ b/src/server/HttpHandler.ts
@@ -1,4 +1,4 @@
-import { AsyncHandler } from '../util/AsyncHandler';
+import { AsyncHandler } from '../util/handlers/AsyncHandler';
 import type { HttpRequest } from './HttpRequest';
 import type { HttpResponse } from './HttpResponse';
 
diff --git a/src/server/WebSocketHandler.ts b/src/server/WebSocketHandler.ts
index 32ab66cc8..ec7c40f5a 100644
--- a/src/server/WebSocketHandler.ts
+++ b/src/server/WebSocketHandler.ts
@@ -1,5 +1,5 @@
 import type WebSocket from 'ws';
-import { AsyncHandler } from '../util/AsyncHandler';
+import { AsyncHandler } from '../util/handlers/AsyncHandler';
 import type { HttpRequest } from './HttpRequest';
 
 /**
diff --git a/src/storage/conversion/IfNeededConverter.ts b/src/storage/conversion/IfNeededConverter.ts
index 4a00d2d27..ffcdcfd9f 100644
--- a/src/storage/conversion/IfNeededConverter.ts
+++ b/src/storage/conversion/IfNeededConverter.ts
@@ -1,7 +1,7 @@
 import type { Representation } from '../../ldp/representation/Representation';
 import { getLoggerFor } from '../../logging/LogUtil';
 import { InternalServerError } from '../../util/errors/InternalServerError';
-import { UnsupportedAsyncHandler } from '../../util/UnsupportedAsyncHandler';
+import { UnsupportedAsyncHandler } from '../../util/handlers/UnsupportedAsyncHandler';
 import { hasMatchingMediaTypes } from './ConversionUtil';
 import { RepresentationConverter } from './RepresentationConverter';
 import type { RepresentationConverterArgs } from './RepresentationConverter';
diff --git a/src/storage/conversion/RepresentationConverter.ts b/src/storage/conversion/RepresentationConverter.ts
index 9197ce173..a9bc02d94 100644
--- a/src/storage/conversion/RepresentationConverter.ts
+++ b/src/storage/conversion/RepresentationConverter.ts
@@ -1,7 +1,7 @@
 import type { Representation } from '../../ldp/representation/Representation';
 import type { RepresentationPreferences } from '../../ldp/representation/RepresentationPreferences';
 import type { ResourceIdentifier } from '../../ldp/representation/ResourceIdentifier';
-import { AsyncHandler } from '../../util/AsyncHandler';
+import { AsyncHandler } from '../../util/handlers/AsyncHandler';
 
 export interface RepresentationConverterArgs {
   /**
diff --git a/src/storage/patch/PatchHandler.ts b/src/storage/patch/PatchHandler.ts
index e03ec21f1..0a44d8be3 100644
--- a/src/storage/patch/PatchHandler.ts
+++ b/src/storage/patch/PatchHandler.ts
@@ -1,5 +1,5 @@
 import type { Patch } from '../../ldp/http/Patch';
 import type { ResourceIdentifier } from '../../ldp/representation/ResourceIdentifier';
-import { AsyncHandler } from '../../util/AsyncHandler';
+import { AsyncHandler } from '../../util/handlers/AsyncHandler';
 
 export abstract class PatchHandler extends AsyncHandler<{identifier: ResourceIdentifier; patch: Patch}> {}
diff --git a/src/storage/routing/RouterRule.ts b/src/storage/routing/RouterRule.ts
index 83cadd8e6..409cd610f 100644
--- a/src/storage/routing/RouterRule.ts
+++ b/src/storage/routing/RouterRule.ts
@@ -1,6 +1,6 @@
 import type { Representation } from '../../ldp/representation/Representation';
 import type { ResourceIdentifier } from '../../ldp/representation/ResourceIdentifier';
-import { AsyncHandler } from '../../util/AsyncHandler';
+import { AsyncHandler } from '../../util/handlers/AsyncHandler';
 import type { ResourceStore } from '../ResourceStore';
 
 /**
diff --git a/src/util/AsyncHandler.ts b/src/util/handlers/AsyncHandler.ts
similarity index 100%
rename from src/util/AsyncHandler.ts
rename to src/util/handlers/AsyncHandler.ts
diff --git a/src/util/handlers/ParallelHandler.ts b/src/util/handlers/ParallelHandler.ts
new file mode 100644
index 000000000..3d78fb88b
--- /dev/null
+++ b/src/util/handlers/ParallelHandler.ts
@@ -0,0 +1,23 @@
+import { AsyncHandler } from './AsyncHandler';
+
+/**
+ * A composite handler that executes handlers in parallel.
+ */
+export class ParallelHandler<TIn = void, TOut = void> extends AsyncHandler<TIn, TOut[]> {
+  private readonly handlers: AsyncHandler<TIn, TOut>[];
+
+  public constructor(handlers: AsyncHandler<TIn, TOut>[]) {
+    super();
+    this.handlers = [ ...handlers ];
+  }
+
+  public async canHandle(input: TIn): Promise<void> {
+    // eslint-disable-next-line @typescript-eslint/promise-function-async
+    await Promise.all(this.handlers.map((handler): Promise<void> => handler.canHandle(input)));
+  }
+
+  public async handle(input: TIn): Promise<TOut[]> {
+    // eslint-disable-next-line @typescript-eslint/promise-function-async
+    return Promise.all(this.handlers.map((handler): Promise<TOut> => handler.handle(input)));
+  }
+}
diff --git a/src/util/SequenceHandler.ts b/src/util/handlers/SequenceHandler.ts
similarity index 100%
rename from src/util/SequenceHandler.ts
rename to src/util/handlers/SequenceHandler.ts
diff --git a/src/util/UnsupportedAsyncHandler.ts b/src/util/handlers/UnsupportedAsyncHandler.ts
similarity index 87%
rename from src/util/UnsupportedAsyncHandler.ts
rename to src/util/handlers/UnsupportedAsyncHandler.ts
index 936d433e2..7099ea5d8 100644
--- a/src/util/UnsupportedAsyncHandler.ts
+++ b/src/util/handlers/UnsupportedAsyncHandler.ts
@@ -1,5 +1,5 @@
+import { NotImplementedHttpError } from '../errors/NotImplementedHttpError';
 import { AsyncHandler } from './AsyncHandler';
-import { NotImplementedHttpError } from './errors/NotImplementedHttpError';
 
 /**
  * Handler that does not support any input and will always throw an error.
diff --git a/src/util/WaterfallHandler.ts b/src/util/handlers/WaterfallHandler.ts
similarity index 92%
rename from src/util/WaterfallHandler.ts
rename to src/util/handlers/WaterfallHandler.ts
index 4ab45b10e..50a5dc5d0 100644
--- a/src/util/WaterfallHandler.ts
+++ b/src/util/handlers/WaterfallHandler.ts
@@ -1,9 +1,9 @@
-import { getLoggerFor } from '../logging/LogUtil';
+import { getLoggerFor } from '../../logging/LogUtil';
+import { BadRequestHttpError } from '../errors/BadRequestHttpError';
+import { isNativeError } from '../errors/ErrorUtil';
+import { HttpError } from '../errors/HttpError';
+import { InternalServerError } from '../errors/InternalServerError';
 import type { AsyncHandler } from './AsyncHandler';
-import { BadRequestHttpError } from './errors/BadRequestHttpError';
-import { isNativeError } from './errors/ErrorUtil';
-import { HttpError } from './errors/HttpError';
-import { InternalServerError } from './errors/InternalServerError';
 
 /**
  * A composite handler that tries multiple handlers one by one


Test patch:
diff --git a/test/integration/LdpHandlerWithAuth.test.ts b/test/integration/LdpHandlerWithAuth.test.ts
index 1e0b42313..661325bb7 100644
--- a/test/integration/LdpHandlerWithAuth.test.ts
+++ b/test/integration/LdpHandlerWithAuth.test.ts
@@ -117,4 +117,24 @@ describe.each(stores)('An LDP handler with auth using %s', (name, { storeUrn, se
     response = await resourceHelper.deleteResource('http://test.com/permanent.txt', true);
     expect(response.statusCode).toBe(401);
   });
+
+  it('can add files but not write to them if append is allowed.', async(): Promise<void> => {
+    // Set acl
+    await aclHelper.setSimpleAcl({ read: true, write: false, append: true }, 'agent');
+
+    // Add a file
+    let response = await resourceHelper.createResource(
+      '../assets/testfile2.txt', 'testfile2.txt', 'text/plain', true,
+    );
+    expect(response.statusCode).toBe(201);
+
+    const id = response._getHeaders().location;
+    response = await resourceHelper.performRequestWithBody(
+      new URL(id),
+      'PUT',
+      { 'content-type': 'text/plain', 'transfer-encoding': 'chunked' },
+      Buffer.from('data'),
+    );
+    expect(response.statusCode).toBe(401);
+  });
 });
diff --git a/test/unit/ldp/permissions/BasePermissionsExtractor.test.ts b/test/unit/ldp/permissions/MethodPermissionsExtractor.test.ts
similarity index 95%
rename from test/unit/ldp/permissions/BasePermissionsExtractor.test.ts
rename to test/unit/ldp/permissions/MethodPermissionsExtractor.test.ts
index 4475df98b..0aa455218 100644
--- a/test/unit/ldp/permissions/BasePermissionsExtractor.test.ts
+++ b/test/unit/ldp/permissions/MethodPermissionsExtractor.test.ts
@@ -30,11 +30,11 @@ describe('A MethodPermissionsExtractor', (): void => {
     });
   });
 
-  it('requires write for POST operations.', async(): Promise<void> => {
+  it('requires append for POST operations.', async(): Promise<void> => {
     await expect(extractor.handle({ method: 'POST' } as Operation)).resolves.toEqual({
       read: false,
       append: true,
-      write: true,
+      write: false,
     });
   });
 
diff --git a/test/unit/util/AsyncHandler.test.ts b/test/unit/util/handlers/AsyncHandler.test.ts
similarity index 91%
rename from test/unit/util/AsyncHandler.test.ts
rename to test/unit/util/handlers/AsyncHandler.test.ts
index e22770d3b..30fe80a3f 100644
--- a/test/unit/util/AsyncHandler.test.ts
+++ b/test/unit/util/handlers/AsyncHandler.test.ts
@@ -1,5 +1,5 @@
-import { AsyncHandler } from '../../../src/util/AsyncHandler';
-import { StaticAsyncHandler } from '../../util/StaticAsyncHandler';
+import { AsyncHandler } from '../../../../src/util/handlers/AsyncHandler';
+import { StaticAsyncHandler } from '../../../util/StaticAsyncHandler';
 
 describe('An AsyncHandler', (): void => {
   it('supports any input by default.', async(): Promise<void> => {
diff --git a/test/unit/util/handlers/ParallelHandler.test.ts b/test/unit/util/handlers/ParallelHandler.test.ts
new file mode 100644
index 000000000..3d8792ba7
--- /dev/null
+++ b/test/unit/util/handlers/ParallelHandler.test.ts
@@ -0,0 +1,100 @@
+import type { AsyncHandler } from '../../../../src/util/handlers/AsyncHandler';
+import { ParallelHandler } from '../../../../src/util/handlers/ParallelHandler';
+
+describe('A ParallelHandler', (): void => {
+  const handlers: jest.Mocked<AsyncHandler<string, string>>[] = [
+    {
+      canHandle: jest.fn(),
+      handle: jest.fn().mockResolvedValue('0'),
+    },
+    {
+      canHandle: jest.fn(),
+      handle: jest.fn().mockResolvedValue('1'),
+    },
+    {
+      canHandle: jest.fn(),
+      handle: jest.fn().mockResolvedValue('2'),
+    },
+  ] as any;
+  const composite: ParallelHandler<string, string> = new ParallelHandler<string, string>(handlers);
+
+  afterEach(jest.clearAllMocks);
+
+  describe('canHandle', (): void => {
+    it('succeeds if all handlers succeed.', async(): Promise<void> => {
+      await expect(composite.canHandle('abc')).resolves.toBeUndefined();
+
+      expect(handlers[0].canHandle).toHaveBeenCalledTimes(1);
+      expect(handlers[1].canHandle).toHaveBeenCalledTimes(1);
+      expect(handlers[2].canHandle).toHaveBeenCalledTimes(1);
+
+      expect(handlers[0].canHandle).toHaveBeenCalledWith('abc');
+      expect(handlers[1].canHandle).toHaveBeenCalledWith('abc');
+      expect(handlers[2].canHandle).toHaveBeenCalledWith('abc');
+    });
+
+    it('fails if one handler fails.', async(): Promise<void> => {
+      const error = new Error('failure');
+      handlers[1].canHandle.mockRejectedValueOnce(error);
+      await expect(composite.canHandle('abc')).rejects.toThrow(error);
+    });
+  });
+
+  describe('handle', (): void => {
+    it('succeeds if all handlers succeed.', async(): Promise<void> => {
+      await expect(composite.handle('abc')).resolves.toEqual([ '0', '1', '2' ]);
+
+      expect(handlers[0].handle).toHaveBeenCalledTimes(1);
+      expect(handlers[1].handle).toHaveBeenCalledTimes(1);
+      expect(handlers[2].handle).toHaveBeenCalledTimes(1);
+
+      expect(handlers[0].handle).toHaveBeenCalledWith('abc');
+      expect(handlers[1].handle).toHaveBeenCalledWith('abc');
+      expect(handlers[2].handle).toHaveBeenCalledWith('abc');
+    });
+
+    it('fails if one handler fails.', async(): Promise<void> => {
+      const error = new Error('failure');
+      handlers[1].handle.mockRejectedValueOnce(error);
+      await expect(composite.handle('abc')).rejects.toThrow(error);
+    });
+  });
+
+  describe('handleSafe', (): void => {
+    it('succeeds if all handlers succeed.', async(): Promise<void> => {
+      await expect(composite.handleSafe('abc')).resolves.toEqual([ '0', '1', '2' ]);
+
+      expect(handlers[0].canHandle).toHaveBeenCalledTimes(1);
+      expect(handlers[1].canHandle).toHaveBeenCalledTimes(1);
+      expect(handlers[2].canHandle).toHaveBeenCalledTimes(1);
+
+      expect(handlers[0].canHandle).toHaveBeenCalledWith('abc');
+      expect(handlers[1].canHandle).toHaveBeenCalledWith('abc');
+      expect(handlers[2].canHandle).toHaveBeenCalledWith('abc');
+
+      expect(handlers[0].handle).toHaveBeenCalledTimes(1);
+      expect(handlers[1].handle).toHaveBeenCalledTimes(1);
+      expect(handlers[2].handle).toHaveBeenCalledTimes(1);
+
+      expect(handlers[0].handle).toHaveBeenCalledWith('abc');
+      expect(handlers[1].handle).toHaveBeenCalledWith('abc');
+      expect(handlers[2].handle).toHaveBeenCalledWith('abc');
+    });
+
+    it('fails if one canHandle fails.', async(): Promise<void> => {
+      const error = new Error('failure');
+      handlers[1].canHandle.mockRejectedValueOnce(error);
+      await expect(composite.handleSafe('abc')).rejects.toThrow(error);
+
+      expect(handlers[0].handle).toHaveBeenCalledTimes(0);
+      expect(handlers[1].handle).toHaveBeenCalledTimes(0);
+      expect(handlers[2].handle).toHaveBeenCalledTimes(0);
+    });
+
+    it('fails if one handle fails.', async(): Promise<void> => {
+      const error = new Error('failure');
+      handlers[1].handle.mockRejectedValueOnce(error);
+      await expect(composite.handleSafe('abc')).rejects.toThrow(error);
+    });
+  });
+});
diff --git a/test/unit/util/SequenceHandler.test.ts b/test/unit/util/handlers/SequenceHandler.test.ts
similarity index 93%
rename from test/unit/util/SequenceHandler.test.ts
rename to test/unit/util/handlers/SequenceHandler.test.ts
index 8c6be9959..b13f4983c 100644
--- a/test/unit/util/SequenceHandler.test.ts
+++ b/test/unit/util/handlers/SequenceHandler.test.ts
@@ -1,5 +1,5 @@
-import type { AsyncHandler } from '../../../src/util/AsyncHandler';
-import { SequenceHandler } from '../../../src/util/SequenceHandler';
+import type { AsyncHandler } from '../../../../src/util/handlers/AsyncHandler';
+import { SequenceHandler } from '../../../../src/util/handlers/SequenceHandler';
 
 describe('A SequenceHandler', (): void => {
   const handlers: jest.Mocked<AsyncHandler<string, string>>[] = [
diff --git a/test/unit/util/UnsupportedAsyncHandler.test.ts b/test/unit/util/handlers/UnsupportedAsyncHandler.test.ts
similarity index 80%
rename from test/unit/util/UnsupportedAsyncHandler.test.ts
rename to test/unit/util/handlers/UnsupportedAsyncHandler.test.ts
index 7866b9c0e..ae46cd2c5 100644
--- a/test/unit/util/UnsupportedAsyncHandler.test.ts
+++ b/test/unit/util/handlers/UnsupportedAsyncHandler.test.ts
@@ -1,5 +1,5 @@
-import { NotImplementedHttpError } from '../../../src/util/errors/NotImplementedHttpError';
-import { UnsupportedAsyncHandler } from '../../../src/util/UnsupportedAsyncHandler';
+import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError';
+import { UnsupportedAsyncHandler } from '../../../../src/util/handlers/UnsupportedAsyncHandler';
 
 describe('An UnsupportedAsyncHandler', (): void => {
   it('throws a default error when no message is set.', async(): Promise<void> => {
diff --git a/test/unit/util/WaterfallHandler.test.ts b/test/unit/util/handlers/WaterfallHandler.test.ts
similarity index 92%
rename from test/unit/util/WaterfallHandler.test.ts
rename to test/unit/util/handlers/WaterfallHandler.test.ts
index d86d4ff5e..1bb20f4b2 100644
--- a/test/unit/util/WaterfallHandler.test.ts
+++ b/test/unit/util/handlers/WaterfallHandler.test.ts
@@ -1,8 +1,8 @@
-import type { AsyncHandler } from '../../../src/util/AsyncHandler';
-import { BadRequestHttpError } from '../../../src/util/errors/BadRequestHttpError';
-import { HttpError } from '../../../src/util/errors/HttpError';
-import { WaterfallHandler } from '../../../src/util/WaterfallHandler';
-import { StaticAsyncHandler } from '../../util/StaticAsyncHandler';
+import { BadRequestHttpError } from '../../../../src/util/errors/BadRequestHttpError';
+import { HttpError } from '../../../../src/util/errors/HttpError';
+import type { AsyncHandler } from '../../../../src/util/handlers/AsyncHandler';
+import { WaterfallHandler } from '../../../../src/util/handlers/WaterfallHandler';
+import { StaticAsyncHandler } from '../../../util/StaticAsyncHandler';
 
 describe('A WaterfallHandler', (): void => {
   describe('with no handlers', (): void => {
diff --git a/test/util/StaticAsyncHandler.ts b/test/util/StaticAsyncHandler.ts
index 2e3946411..c482285d5 100644
--- a/test/util/StaticAsyncHandler.ts
+++ b/test/util/StaticAsyncHandler.ts
@@ -1,4 +1,4 @@
-import { AsyncHandler } from '../../src/util/AsyncHandler';
+import { AsyncHandler } from '../../src/util/handlers/AsyncHandler';
 
 export class StaticAsyncHandler<TOut> extends AsyncHandler<any, TOut> {
   private readonly canHandleStatic: boolean;