Repository: solid/community-server
Pull request #809 (2021-07-14 15:06:14)

Add descriptions for generic HTTP errors


Patch:
diff --git a/config/storage/resource-store/dynamic.json b/config/storage/resource-store/dynamic.json
index 35601dde3..f36570e2e 100644
--- a/config/storage/resource-store/dynamic.json
+++ b/config/storage/resource-store/dynamic.json
@@ -14,14 +14,17 @@
       "@id": "urn:solid-server:default:ResourceStore",
       "@type": "MonitoringStore",
       "MonitoringStore:_source": {
-        "@id": "urn:solid-server:default:ResourceStore_Locking",
-        "LockingResourceStore:_source": {
-          "@id": "urn:solid-server:default:ResourceStore_Patching",
-          "PatchingStore:_source": {
-            "@id": "urn:solid-server:default:ResourceStore_Converting",
-            "RepresentationConvertingStore:_source": {
-              "@id": "urn:solid-server:default:ResourceStore_Routing",
-              "comment": "A router rule will be defined when activating dynamic pods."
+        "@id": "urn:solid-server:default:ResourceStore_Index",
+        "IndexRepresentationStore:_source": {
+          "@id": "urn:solid-server:default:ResourceStore_Locking",
+          "LockingResourceStore:_source": {
+            "@id": "urn:solid-server:default:ResourceStore_Patching",
+            "PatchingStore:_source": {
+              "@id": "urn:solid-server:default:ResourceStore_Converting",
+              "RepresentationConvertingStore:_source": {
+                "@id": "urn:solid-server:default:ResourceStore_Routing",
+                "comment": "A router rule will be defined when activating dynamic pods."
+              }
             }
           }
         }
diff --git a/config/storage/resource-store/file.json b/config/storage/resource-store/file.json
index ed1741165..2cfcce1a0 100644
--- a/config/storage/resource-store/file.json
+++ b/config/storage/resource-store/file.json
@@ -13,15 +13,18 @@
       "@id": "urn:solid-server:default:ResourceStore",
       "@type": "MonitoringStore",
       "MonitoringStore:_source": {
-        "@id": "urn:solid-server:default:ResourceStore_Locking",
-        "LockingResourceStore:_source": {
-          "@id": "urn:solid-server:default:ResourceStore_Patching",
-          "PatchingStore:_source": {
-            "@id": "urn:solid-server:default:ResourceStore_Converting",
-            "RepresentationConvertingStore:_source": {
-              "@id": "urn:solid-server:default:ResourceStore_DataAccessor",
-              "DataAccessorBasedStore:_accessor": {
-                "@id": "urn:solid-server:default:FileDataAccessor"
+        "@id": "urn:solid-server:default:ResourceStore_Index",
+        "IndexRepresentationStore:_source": {
+          "@id": "urn:solid-server:default:ResourceStore_Locking",
+          "LockingResourceStore:_source": {
+            "@id": "urn:solid-server:default:ResourceStore_Patching",
+            "PatchingStore:_source": {
+              "@id": "urn:solid-server:default:ResourceStore_Converting",
+              "RepresentationConvertingStore:_source": {
+                "@id": "urn:solid-server:default:ResourceStore_DataAccessor",
+                "DataAccessorBasedStore:_accessor": {
+                  "@id": "urn:solid-server:default:FileDataAccessor"
+                }
               }
             }
           }
diff --git a/config/storage/resource-store/memory.json b/config/storage/resource-store/memory.json
index 0801531b8..cdd84fdc3 100644
--- a/config/storage/resource-store/memory.json
+++ b/config/storage/resource-store/memory.json
@@ -13,15 +13,18 @@
       "@id": "urn:solid-server:default:ResourceStore",
       "@type": "MonitoringStore",
       "MonitoringStore:_source": {
-        "@id": "urn:solid-server:default:ResourceStore_Locking",
-        "LockingResourceStore:_source": {
-          "@id": "urn:solid-server:default:ResourceStore_Patching",
-          "PatchingStore:_source": {
-            "@id": "urn:solid-server:default:ResourceStore_Converting",
-            "RepresentationConvertingStore:_source": {
-              "@id": "urn:solid-server:default:ResourceStore_DataAccessor",
-              "DataAccessorBasedStore:_accessor": {
-                "@id": "urn:solid-server:default:MemoryDataAccessor"
+        "@id": "urn:solid-server:default:ResourceStore_Index",
+        "IndexRepresentationStore:_source": {
+          "@id": "urn:solid-server:default:ResourceStore_Locking",
+          "LockingResourceStore:_source": {
+            "@id": "urn:solid-server:default:ResourceStore_Patching",
+            "PatchingStore:_source": {
+              "@id": "urn:solid-server:default:ResourceStore_Converting",
+              "RepresentationConvertingStore:_source": {
+                "@id": "urn:solid-server:default:ResourceStore_DataAccessor",
+                "DataAccessorBasedStore:_accessor": {
+                  "@id": "urn:solid-server:default:MemoryDataAccessor"
+                }
               }
             }
           }
diff --git a/config/storage/resource-store/regex.json b/config/storage/resource-store/regex.json
index 0b9ea6533..19290ac19 100644
--- a/config/storage/resource-store/regex.json
+++ b/config/storage/resource-store/regex.json
@@ -15,13 +15,16 @@
       "@id": "urn:solid-server:default:ResourceStore",
       "@type": "MonitoringStore",
       "MonitoringStore:_source": {
-      "@id": "urn:solid-server:default:ResourceStore_Locking",
-      "LockingResourceStore:_source": {
-        "@id": "urn:solid-server:default:ResourceStore_Patching",
-        "PatchingStore:_source": {
-          "@id": "urn:solid-server:default:ResourceStore_Converting",
-          "RepresentationConvertingStore:_source": {
-              "@id": "urn:solid-server:default:ResourceStore_Routing"
+        "@id": "urn:solid-server:default:ResourceStore_Index",
+        "IndexRepresentationStore:_source": {
+          "@id": "urn:solid-server:default:ResourceStore_Locking",
+          "LockingResourceStore:_source": {
+            "@id": "urn:solid-server:default:ResourceStore_Patching",
+            "PatchingStore:_source": {
+              "@id": "urn:solid-server:default:ResourceStore_Converting",
+              "RepresentationConvertingStore:_source": {
+                "@id": "urn:solid-server:default:ResourceStore_Routing"
+              }
             }
           }
         }
diff --git a/config/storage/resource-store/sparql.json b/config/storage/resource-store/sparql.json
index 2b4fd5b8b..da64f60fd 100644
--- a/config/storage/resource-store/sparql.json
+++ b/config/storage/resource-store/sparql.json
@@ -13,18 +13,23 @@
       "@id": "urn:solid-server:default:ResourceStore",
       "@type": "MonitoringStore",
       "MonitoringStore:_source": {
-        "@id": "urn:solid-server:default:ResourceStore_Locking",
-        "LockingResourceStore:_source": {
-          "@id": "urn:solid-server:default:ResourceStore_Patching",
-          "PatchingStore:_source": {
-            "@id": "urn:solid-server:default:ResourceStore_Converting",
-            "comment": "This makes it so all incoming data is converted to quad objects.",
-            "RepresentationConvertingStore:_options_inConverter": { "@id": "urn:solid-server:default:RepresentationConverter" },
-            "RepresentationConvertingStore:_options_inType": "internal/quads",
-            "RepresentationConvertingStore:_source": {
-              "@id": "urn:solid-server:default:ResourceStore_DataAccessor",
-              "DataAccessorBasedStore:_accessor": {
-                "@id": "urn:solid-server:default:SparqlDataAccessor"
+        "@id": "urn:solid-server:default:ResourceStore_Index",
+        "IndexRepresentationStore:_source": {
+          "@id": "urn:solid-server:default:ResourceStore_Locking",
+          "LockingResourceStore:_source": {
+            "@id": "urn:solid-server:default:ResourceStore_Patching",
+            "PatchingStore:_source": {
+              "@id": "urn:solid-server:default:ResourceStore_Converting",
+              "comment": "This makes it so all incoming data is converted to quad objects.",
+              "RepresentationConvertingStore:_options_inConverter": {
+                "@id": "urn:solid-server:default:RepresentationConverter"
+              },
+              "RepresentationConvertingStore:_options_inType": "internal/quads",
+              "RepresentationConvertingStore:_source": {
+                "@id": "urn:solid-server:default:ResourceStore_DataAccessor",
+                "DataAccessorBasedStore:_accessor": {
+                  "@id": "urn:solid-server:default:SparqlDataAccessor"
+                }
               }
             }
           }
diff --git a/config/util/index/default.json b/config/util/index/default.json
index a771ddd84..b3dcb3535 100644
--- a/config/util/index/default.json
+++ b/config/util/index/default.json
@@ -1,9 +1,14 @@
 {
   "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^1.0.0/components/context.jsonld",
   "@graph": [
+    {
+      "comment": "When a container with an index.html document is accessed, serve that HTML document instead of the container.",
+      "@id": "urn:solid-server:default:ResourceStore_Index",
+      "@type": "IndexRepresentationStore"
+    },
     {
       "comment": "This value can be used to set a custom handler for index files. See the example file.",
-      "@id": "urn:solid-server:default:IndexConverter",
+      "@id": "urn:solid-server:default:DefaultUiConverter",
       "@type": "UnsupportedAsyncHandler"
     }
   ]
diff --git a/config/util/index/example.json b/config/util/index/example.json
index 7fdb4ca46..162902767 100644
--- a/config/util/index/example.json
+++ b/config/util/index/example.json
@@ -1,12 +1,17 @@
 {
   "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^1.0.0/components/context.jsonld",
   "@graph": [
+    {
+      "comment": "This an example on how to disable the routing to index.html when accessing a container.",
+      "@id": "urn:solid-server:default:ResourceStore_Index",
+      "@type": "PassthroughStore"
+    },
     {
       "comment": [
         "This converter replaces every browser request for text/html with the Databrowser UI (which then in turn loads a Turtle or other representation of the same resource).",
         "This is useful when you want to serve a UI for every file from the pod."
       ],
-      "@id": "urn:solid-server:default:IndexConverter",
+      "@id": "urn:solid-server:default:DefaultUiConverter",
       "@type": "ConstantConverter",
       "contentType": "text/html",
       "filePath": "./node_modules/mashlib/dist/databrowser.html",
diff --git a/config/util/representation-conversion/default.json b/config/util/representation-conversion/default.json
index 1d8cf1ca2..271827531 100644
--- a/config/util/representation-conversion/default.json
+++ b/config/util/representation-conversion/default.json
@@ -11,7 +11,7 @@
       "@id": "urn:solid-server:default:RepresentationConverter",
       "@type": "WaterfallHandler",
       "handlers": [
-        { "@id": "urn:solid-server:default:IndexConverter" },
+        { "@id": "urn:solid-server:default:DefaultUiConverter" },
         {
           "@type": "IfNeededConverter",
           "comment": "Only continue converting if the requester cannot accept the available content type"
diff --git a/src/index.ts b/src/index.ts
index d41c459d3..daaf77a53 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -265,6 +265,7 @@ export * from './storage/AtomicResourceStore';
 export * from './storage/BaseResourceStore';
 export * from './storage/Conditions';
 export * from './storage/DataAccessorBasedStore';
+export * from './storage/IndexRepresentationStore';
 export * from './storage/LockingResourceStore';
 export * from './storage/MonitoringStore';
 export * from './storage/PassthroughStore';
diff --git a/src/storage/IndexRepresentationStore.ts b/src/storage/IndexRepresentationStore.ts
new file mode 100644
index 000000000..928f72e95
--- /dev/null
+++ b/src/storage/IndexRepresentationStore.ts
@@ -0,0 +1,64 @@
+import assert from 'assert';
+import type { Representation } from '../ldp/representation/Representation';
+import type { RepresentationPreferences } from '../ldp/representation/RepresentationPreferences';
+import type { ResourceIdentifier } from '../ldp/representation/ResourceIdentifier';
+import { NotFoundHttpError } from '../util/errors/NotFoundHttpError';
+import { isContainerIdentifier } from '../util/PathUtil';
+import type { Conditions } from './Conditions';
+import { cleanPreferences, matchesMediaType } from './conversion/ConversionUtil';
+import { PassthroughStore } from './PassthroughStore';
+import type { ResourceStore } from './ResourceStore';
+
+/**
+ * Allow containers to have a custom representation.
+ * The index representation will be returned when the following conditions are fulfilled:
+ *  * The request targets a container.
+ *  * A resource with the given `indexName` exists in the container. (default: "index.html")
+ *  * The highest weighted preference matches the `mediaRange` (default: "text/html")
+ * Otherwise the request will be passed on to the source store.
+ * In case the index representation should always be returned when it exists,
+ * the `mediaRange` should be set to "\*∕\*".
+ *
+ * Note: this functionality is not yet part of the specification. Relevant issues are:
+ * - https://github.com/solid/specification/issues/69
+ * - https://github.com/solid/specification/issues/198
+ * - https://github.com/solid/specification/issues/109
+ * - https://github.com/solid/web-access-control-spec/issues/36
+ */
+export class IndexRepresentationStore extends PassthroughStore {
+  private readonly indexName: string;
+  private readonly mediaRange: string;
+
+  public constructor(source: ResourceStore, indexName = 'index.html', mediaRange = 'text/html') {
+    super(source);
+    assert(/^[\w.-]+$/u.test(indexName), 'Invalid index name');
+    this.indexName = indexName;
+    this.mediaRange = mediaRange;
+  }
+
+  public async getRepresentation(identifier: ResourceIdentifier, preferences: RepresentationPreferences,
+    conditions?: Conditions): Promise<Representation> {
+    if (isContainerIdentifier(identifier) && this.matchesPreferences(preferences)) {
+      try {
+        const indexIdentifier = { path: `${identifier.path}${this.indexName}` };
+        return await this.source.getRepresentation(indexIdentifier, preferences, conditions);
+      } catch (error: unknown) {
+        if (!NotFoundHttpError.isInstance(error)) {
+          throw error;
+        }
+      }
+    }
+
+    return this.source.getRepresentation(identifier, preferences, conditions);
+  }
+
+  /**
+   * Makes sure the stored media range matches the highest weight preference.
+   */
+  private matchesPreferences(preferences: RepresentationPreferences): boolean {
+    const cleaned = cleanPreferences(preferences.type);
+    const max = Math.max(...Object.values(cleaned));
+    return Object.entries(cleaned).some(([ range, weight ]): boolean =>
+      matchesMediaType(range, this.mediaRange) && weight === max);
+  }
+}
diff --git a/src/storage/conversion/ErrorToTemplateConverter.ts b/src/storage/conversion/ErrorToTemplateConverter.ts
index b78bd585c..083678756 100644
--- a/src/storage/conversion/ErrorToTemplateConverter.ts
+++ b/src/storage/conversion/ErrorToTemplateConverter.ts
@@ -57,7 +57,7 @@ export class ErrorToTemplateConverter extends TypedRepresentationConverter {
   }
 
   private async getErrorCodeMessage(error: Error): Promise<string | undefined> {
-    if (HttpError.isInstance(error) && error.errorCode) {
+    if (HttpError.isInstance(error)) {
       let template: string;
       try {
         const fileName = `${error.errorCode}${this.extension}`;
diff --git a/src/util/errors/HttpError.ts b/src/util/errors/HttpError.ts
index 022b98309..09ad058a5 100644
--- a/src/util/errors/HttpError.ts
+++ b/src/util/errors/HttpError.ts
@@ -14,7 +14,7 @@ export class HttpError extends Error implements HttpErrorOptions {
   protected static readonly statusCode: number;
   public readonly statusCode: number;
   public readonly cause?: unknown;
-  public readonly errorCode?: string;
+  public readonly errorCode: string;
   public readonly details?: NodeJS.Dict<unknown>;
 
   /**
@@ -29,7 +29,7 @@ export class HttpError extends Error implements HttpErrorOptions {
     this.statusCode = statusCode;
     this.name = name;
     this.cause = options.cause;
-    this.errorCode = options.errorCode;
+    this.errorCode = options.errorCode ?? `H${statusCode}`;
     this.details = options.details;
   }
 
diff --git a/templates/error/descriptions/E0001 b/templates/error/descriptions/E0001.md
similarity index 86%
rename from templates/error/descriptions/E0001
rename to templates/error/descriptions/E0001.md
index 6831798bc..95d394833 100644
--- a/templates/error/descriptions/E0001
+++ b/templates/error/descriptions/E0001.md
@@ -1,4 +1,4 @@
-### Requests to `{{ path }}` are not supported
+# Requests to `{{ path }}` are not supported
 The Community Solid Server received a request for `{{ path }}`, which is not configured.
 Here are some things you can try to fix this:
  - Have you started the server with the right hostname?
diff --git a/templates/error/descriptions/H400.md b/templates/error/descriptions/H400.md
new file mode 100644
index 000000000..3f187b145
--- /dev/null
+++ b/templates/error/descriptions/H400.md
@@ -0,0 +1,4 @@
+# Bad request
+The server could not process your request.
+
+You will need to adjust the request to continue.
diff --git a/templates/error/descriptions/H401.md b/templates/error/descriptions/H401.md
new file mode 100644
index 000000000..d932d6616
--- /dev/null
+++ b/templates/error/descriptions/H401.md
@@ -0,0 +1,4 @@
+# Not logged in
+This resource is only accessible by logged in agents.
+
+Try logging in and revisiting this resource.
diff --git a/templates/error/descriptions/H403.md b/templates/error/descriptions/H403.md
new file mode 100644
index 000000000..77dcf10c8
--- /dev/null
+++ b/templates/error/descriptions/H403.md
@@ -0,0 +1,4 @@
+# Access not allowed
+You are logged in, but not allowed to access this resource.
+
+Try requesting access or logging in with a different identity.
diff --git a/templates/error/descriptions/H404.md b/templates/error/descriptions/H404.md
new file mode 100644
index 000000000..f5df95add
--- /dev/null
+++ b/templates/error/descriptions/H404.md
@@ -0,0 +1,5 @@
+# Resource not found
+The requested resource was not found.
+
+Please check whether the URL is correct,
+or create a new resource at this URL.
diff --git a/templates/error/descriptions/H500.md b/templates/error/descriptions/H500.md
new file mode 100644
index 000000000..b7e2a5d68
--- /dev/null
+++ b/templates/error/descriptions/H500.md
@@ -0,0 +1,8 @@
+# Server error
+An error occurred on the server while processing your request.
+
+This could be due to a misconfiguration on the server side,
+or due to a bug in the server code.
+
+If you think this is a bug,
+please [file a bug report](https://github.com/solid/community-server/issues/new).
diff --git a/templates/error/descriptions/H501.md b/templates/error/descriptions/H501.md
new file mode 100644
index 000000000..9116877e4
--- /dev/null
+++ b/templates/error/descriptions/H501.md
@@ -0,0 +1,9 @@
+# Not implemented
+You are trying to use a feature that the server does not implement (yet).
+
+Possible causes include:
+- something is misconfigured on the server side
+- the behavior you are trying to access is not described by the Solid specifications
+
+If you think this behavior should be supported,
+you can [submit a bug report](https://github.com/solid/community-server/issues/new).
diff --git a/templates/error/main.md b/templates/error/main.md
index 3e30f35f3..54e946416 100644
--- a/templates/error/main.md
+++ b/templates/error/main.md
@@ -1,11 +1,15 @@
-# {{ name }}
-
 {{#if description}}
 {{{ description }}}
+{{else}}
+# {{ name }}
 {{/if}}
 
 ## Technical details
+{{#if message}}
 {{ message }}
+{{else}}
+_No further details available._
+{{/if}}
 
 {{#if stack}}
 ```  


Test patch:
diff --git a/test/unit/storage/IndexRepresentationstore.test.ts b/test/unit/storage/IndexRepresentationstore.test.ts
new file mode 100644
index 000000000..9c1d223d7
--- /dev/null
+++ b/test/unit/storage/IndexRepresentationstore.test.ts
@@ -0,0 +1,75 @@
+import { BasicRepresentation } from '../../../src/ldp/representation/BasicRepresentation';
+import type { ResourceIdentifier } from '../../../src/ldp/representation/ResourceIdentifier';
+import { IndexRepresentationStore } from '../../../src/storage/IndexRepresentationStore';
+import type { ResourceStore } from '../../../src/storage/ResourceStore';
+import { ConflictHttpError } from '../../../src/util/errors/ConflictHttpError';
+import { NotFoundHttpError } from '../../../src/util/errors/NotFoundHttpError';
+import { readableToString } from '../../../src/util/StreamUtil';
+
+describe('An IndexRepresentationStore', (): void => {
+  const baseUrl = 'http://test.com/';
+  const emptyContainer = { path: `${baseUrl}/container/` };
+  let source: jest.Mocked<ResourceStore>;
+  let store: IndexRepresentationStore;
+
+  beforeEach(async(): Promise<void> => {
+    source = {
+      getRepresentation: jest.fn((identifier: ResourceIdentifier): any => {
+        if (identifier.path === `${baseUrl}index.html`) {
+          return new BasicRepresentation('index data', 'text/html');
+        }
+        if (identifier.path.endsWith('/')) {
+          return new BasicRepresentation('container data', 'text/turtle');
+        }
+        throw new NotFoundHttpError();
+      }),
+    } as any;
+
+    store = new IndexRepresentationStore(source);
+  });
+
+  it('errors on invalid index names.', async(): Promise<void> => {
+    expect((): any => new IndexRepresentationStore(source, '../secretContainer/secret.key'))
+      .toThrow('Invalid index name');
+  });
+
+  it('retrieves the index resource if it exists.', async(): Promise<void> => {
+    const result = await store.getRepresentation({ path: baseUrl }, {});
+    await expect(readableToString(result.data)).resolves.toBe('index data');
+    expect(source.getRepresentation).toHaveBeenCalledTimes(1);
+  });
+
+  it('errors if a non-404 error was thrown when accessing the index resource.', async(): Promise<void> => {
+    source.getRepresentation.mockRejectedValueOnce(new ConflictHttpError('conflict!'));
+    await expect(store.getRepresentation({ path: baseUrl }, {})).rejects.toThrow('conflict!');
+    expect(source.getRepresentation).toHaveBeenCalledTimes(1);
+  });
+
+  it('requests the usual data if there is no index resource.', async(): Promise<void> => {
+    const result = await store.getRepresentation(emptyContainer, {});
+    await expect(readableToString(result.data)).resolves.toBe('container data');
+    expect(source.getRepresentation).toHaveBeenCalledTimes(2);
+    expect(source.getRepresentation).toHaveBeenCalledWith({ path: `${emptyContainer.path}index.html` }, {}, undefined);
+    expect(source.getRepresentation).toHaveBeenLastCalledWith(emptyContainer, {}, undefined);
+  });
+
+  it('requests the usual data if the index media range is not the most preferred.', async(): Promise<void> => {
+    const preferences = { type: { 'text/turtle': 0.8, 'text/html': 0.5 }};
+    const result = await store.getRepresentation({ path: baseUrl }, preferences);
+    await expect(readableToString(result.data)).resolves.toBe('container data');
+    expect(source.getRepresentation).toHaveBeenCalledTimes(1);
+    expect(source.getRepresentation).toHaveBeenLastCalledWith({ path: baseUrl }, preferences, undefined);
+  });
+
+  it('always returns the index resource if the media range is set to */*.', async(): Promise<void> => {
+    store = new IndexRepresentationStore(source, 'base.html', '*/*');
+    // Mocking because we also change the index name
+    source.getRepresentation.mockResolvedValueOnce(new BasicRepresentation('index data', 'text/html'));
+
+    const preferences = { type: { 'text/turtle': 0.8, 'text/html': 0.5 }};
+    const result = await store.getRepresentation({ path: baseUrl }, preferences);
+    await expect(readableToString(result.data)).resolves.toBe('index data');
+    expect(source.getRepresentation).toHaveBeenCalledTimes(1);
+    expect(source.getRepresentation).toHaveBeenLastCalledWith({ path: `${baseUrl}base.html` }, preferences, undefined);
+  });
+});
diff --git a/test/unit/storage/conversion/ErrorToTemplateConverter.test.ts b/test/unit/storage/conversion/ErrorToTemplateConverter.test.ts
index 7fc33ca50..d1502d360 100644
--- a/test/unit/storage/conversion/ErrorToTemplateConverter.test.ts
+++ b/test/unit/storage/conversion/ErrorToTemplateConverter.test.ts
@@ -41,7 +41,22 @@ describe('An ErrorToTemplateConverter', (): void => {
     await expect(prom).rejects.toThrow(InternalServerError);
   });
 
-  it('calls the template engine with all error fields.', async(): Promise<void> => {
+  it('works with non-HTTP errors.', async(): Promise<void> => {
+    const error = new Error('error text');
+    const representation = new BasicRepresentation([ error ], 'internal/error', false);
+    const prom = converter.handle({ identifier, representation, preferences });
+    await expect(prom).resolves.toBeDefined();
+    const result = await prom;
+    expect(result.binary).toBe(true);
+    expect(result.metadata.contentType).toBe('text/html');
+    await expect(readableToString(result.data)).resolves.toBe('<html>');
+    expect(engine.apply).toHaveBeenCalledTimes(1);
+    expect(engine.apply).toHaveBeenLastCalledWith(
+      '{{ template }}', { name: 'Error', message: 'error text', stack: error.stack },
+    );
+  });
+
+  it('calls the template engine with all HTTP error fields.', async(): Promise<void> => {
     const error = new BadRequestHttpError('error text');
     const representation = new BasicRepresentation([ error ], 'internal/error', false);
     const prom = converter.handle({ identifier, representation, preferences });
diff --git a/test/unit/util/errors/HttpError.test.ts b/test/unit/util/errors/HttpError.test.ts
index bccf5b493..e263e849a 100644
--- a/test/unit/util/errors/HttpError.test.ts
+++ b/test/unit/util/errors/HttpError.test.ts
@@ -63,6 +63,10 @@ describe('HttpError', (): void => {
       expect(instance.errorCode).toBe(options.errorCode);
     });
 
+    it('defaults to an HTTP-specific error code.', (): void => {
+      expect(new constructor().errorCode).toBe(`H${statusCode}`);
+    });
+
     it('sets the details.', (): void => {
       expect(instance.details).toBe(options.details);
     });