Repository: solid/community-server
Pull request #353 (2020-11-24 15:58:42)

Better CI support for GH Actions from remote forks
The initial commit is to test PRs from a remote fork. If CI runs successfully, just ignore this.

acoburn: It appears that PRs from remote forks will not trigger the required CI jobs
RubenVerborgh: Super, thanks @acoburn! Waiting for CI to complete now.
acoburn: I think there is a setting that needs to be adjusted on this repo to allow remote forks (currently CI is not running). This article may be useful: https://github.blog/2020-08-03-github-actions-improvements-for-fork-and-pull-request-workflows/
RubenVerborgh: @acoburn Could it be that this is something _you_ need to adjust on your fork?

> Workflows don't run on forked repositories by default. You must enable GitHub Actions in the Actions tab of the forked repository.

—https://docs.github.com/en/free-pro-team@latest/actions/reference/events-that-trigger-workflows#pull-request-events-for-forked-repositories
RubenVerborgh: I tried un-drafting to see if that triggers anything. Apparently it does not.

Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 5cbbf43a9..d60219aef 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,6 +1,12 @@
 name: CI
 
-on: [push]
+on:
+  push:
+    branches:
+      - master
+  pull_request:
+    branches:
+      - master
 
 jobs:
   test:
diff --git a/config/presets/storage-wrapper.json b/config/presets/storage-wrapper.json
index c278c1b80..205164566 100644
--- a/config/presets/storage-wrapper.json
+++ b/config/presets/storage-wrapper.json
@@ -34,13 +34,13 @@
       "RepresentationConvertingStore:_source": {
         "@id": "urn:solid-server:default:RoutingResourceStore"
       },
-      "RepresentationConvertingStore:_outConverter": {
+      "RepresentationConvertingStore:_options_outConverter": {
         "@id": "urn:solid-server:default:RepresentationConverter"
       },
-      "RepresentationConvertingStore:_inConverter": {
+      "RepresentationConvertingStore:_options_inConverter": {
         "@id": "urn:solid-server:default:RepresentationConverter"
       },
-      "RepresentationConvertingStore:_inType": "text/turtle"
+      "RepresentationConvertingStore:_options_inType": "text/turtle"
     },
 
     {
@@ -49,7 +49,7 @@
       "RepresentationConvertingStore:_source": {
         "@id": "urn:solid-server:default:RoutingResourceStore"
       },
-      "RepresentationConvertingStore:_outConverter": {
+      "RepresentationConvertingStore:_options_outConverter": {
         "@id": "urn:solid-server:default:RepresentationConverter"
       }
     }
diff --git a/config/presets/storage/backend/storage-filesystem.json b/config/presets/storage/backend/storage-filesystem.json
index d52d1ee6e..971d404c3 100644
--- a/config/presets/storage/backend/storage-filesystem.json
+++ b/config/presets/storage/backend/storage-filesystem.json
@@ -10,8 +10,8 @@
       "ExtensionBasedMapper:_rootFilepath": {
         "@id": "urn:solid-server:default:variable:rootFilePath"
       },
-      "ExtensionBasedMapper:_acl": "text/turtle",
-      "ExtensionBasedMapper:_meta": "text/turtle"
+      "ExtensionBasedMapper:_overrideTypes_acl": "text/turtle",
+      "ExtensionBasedMapper:_overrideTypes_meta": "text/turtle"
     },
     {
       "@id": "urn:solid-server:default:FileDataAccessor",
diff --git a/index.ts b/index.ts
index 4bfaf1b8b..b38d7958b 100644
--- a/index.ts
+++ b/index.ts
@@ -121,16 +121,12 @@ export * from './src/storage/AtomicResourceStore';
 export * from './src/storage/Conditions';
 export * from './src/storage/DataAccessorBasedStore';
 export * from './src/storage/FileIdentifierMapper';
-export * from './src/storage/Lock';
 export * from './src/storage/LockingResourceStore';
 export * from './src/storage/PassthroughStore';
 export * from './src/storage/PatchingStore';
 export * from './src/storage/RepresentationConvertingStore';
-export * from './src/storage/ResourceLocker';
 export * from './src/storage/ResourceStore';
 export * from './src/storage/RoutingResourceStore';
-export * from './src/storage/SingleThreadedResourceLocker';
-export * from './src/storage/WrappedExpiringResourceLocker';
 
 // Util/Errors
 export * from './src/util/errors/ConflictHttpError';
@@ -143,6 +139,12 @@ export * from './src/util/errors/UnauthorizedHttpError';
 export * from './src/util/errors/UnsupportedHttpError';
 export * from './src/util/errors/UnsupportedMediaTypeHttpError';
 
+// Util/Locking
+export * from './src/util/locking/Lock';
+export * from './src/util/locking/ResourceLocker';
+export * from './src/util/locking/SingleThreadedResourceLocker';
+export * from './src/util/locking/WrappedExpiringResourceLocker';
+
 // Util
 export * from './src/util/AllVoidCompositeHandler';
 export * from './src/util/AsyncHandler';
diff --git a/src/authorization/AclManager.ts b/src/authorization/AclManager.ts
index 1bf7b679d..274a5c5cb 100644
--- a/src/authorization/AclManager.ts
+++ b/src/authorization/AclManager.ts
@@ -1,26 +1,37 @@
 import type { ResourceIdentifier } from '../ldp/representation/ResourceIdentifier';
 
 /**
- * Handles where acl files are stored.
+ * Handles where acl resources are stored.
  */
 export interface AclManager {
   /**
-   * Returns the identifier of the acl file corresponding to the given resource.
-   * This does not guarantee that this acl file exists.
-   * In the case the input is already an acl file that will also be the response.
-   * @param id - The ResourceIdentifier of which we need the corresponding acl file.
+   * Returns the identifier of the acl resource corresponding to the given resource.
+   * This does not guarantee that this acl resource exists.
+   * In the case the input is already an acl resource that will also be the response.
+   * @param id - The ResourceIdentifier of which we need the corresponding acl resource.
    *
-   * @returns The ResourceIdentifier of the corresponding acl file.
+   * @returns The ResourceIdentifier of the corresponding acl resource.
    */
-  getAcl: (id: ResourceIdentifier) => Promise<ResourceIdentifier>;
+  getAclDocument: (id: ResourceIdentifier) => Promise<ResourceIdentifier>;
 
   /**
-   * Checks if the input identifier corresponds to an acl file.
-   * This does not check if that acl file exists,
-   * only if the identifier indicates that there could be an acl file there.
+   * Checks if the input identifier corresponds to an acl resource.
+   * This does not check if that acl resource exists,
+   * only if the identifier indicates that there could be an acl resource there.
    * @param id - Identifier to check.
    *
-   * @returns true if the input identifier points to an acl file.
+   * @returns true if the input identifier points to an acl resource.
    */
-  isAcl: (id: ResourceIdentifier) => Promise<boolean>;
+  isAclDocument: (id: ResourceIdentifier) => Promise<boolean>;
+
+  /**
+   * Returns the identifier of the resource on which the acl constraints are placed.
+   * In general, this is the resource identifier when the input is a normal resource,
+   * or the non-acl version if the input is an acl resource.
+   * This does not guarantee that this resource exists.
+   * @param aclId - Identifier of the acl resource.
+   *
+   * @returns The ResourceIdentifier of the corresponding resource.
+   */
+  getAclConstrainedResource: (id: ResourceIdentifier) => Promise<ResourceIdentifier>;
 }
diff --git a/src/authorization/UrlBasedAclManager.ts b/src/authorization/UrlBasedAclManager.ts
index d7b2c1f92..17bb133f1 100644
--- a/src/authorization/UrlBasedAclManager.ts
+++ b/src/authorization/UrlBasedAclManager.ts
@@ -4,23 +4,23 @@ import type { AclManager } from './AclManager';
 /**
  * Generates acl URIs by adding an .acl file extension.
  *
- * What actually should happen in getAcl:
- * 1. Return id if it isAcl
- * 2. Check store if id exists
- * 3a. (true) Close/destroy data stream! To prevent potential locking issues.
- * 4a. Check metadata if it is a container or a resource.
- * 3b. (false) Use input metadata/heuristic to check if container or resource.
- * 5. Generate the correct identifier (.acl right of / for containers, left for resources if there is a /)
- *
- * It is potentially possible that an agent wants to generate the acl file before generating the actual file.
- * (Unless this is not allowed by the spec, need to verify).
+ * Needs to be updated according to issue #113.
  */
 export class UrlBasedAclManager implements AclManager {
-  public async getAcl(id: ResourceIdentifier): Promise<ResourceIdentifier> {
-    return await this.isAcl(id) ? id : { path: `${id.path}.acl` };
+  public async getAclDocument(id: ResourceIdentifier): Promise<ResourceIdentifier> {
+    return await this.isAclDocument(id) ? id : { path: `${id.path}.acl` };
   }
 
-  public async isAcl(id: ResourceIdentifier): Promise<boolean> {
+  public async isAclDocument(id: ResourceIdentifier): Promise<boolean> {
     return /\.acl\/?/u.test(id.path);
   }
+
+  public async getAclConstrainedResource(id: ResourceIdentifier): Promise<ResourceIdentifier> {
+    if (!await this.isAclDocument(id)) {
+      return id;
+    }
+
+    // Slice off `.acl`
+    return { path: id.path.slice(0, -4) };
+  }
 }
diff --git a/src/authorization/WebAclAuthorizer.ts b/src/authorization/WebAclAuthorizer.ts
index cb3393c5b..c6efdddaa 100644
--- a/src/authorization/WebAclAuthorizer.ts
+++ b/src/authorization/WebAclAuthorizer.ts
@@ -40,7 +40,7 @@ export class WebAclAuthorizer extends Authorizer {
    */
   public async handle(input: AuthorizerArgs): Promise<void> {
     const store = await this.getAclRecursive(input.identifier);
-    if (await this.aclManager.isAcl(input.identifier)) {
+    if (await this.aclManager.isAclDocument(input.identifier)) {
       this.checkPermission(input.credentials, store, 'control');
     } else {
       (Object.keys(input.permissions) as (keyof PermissionSet)[]).forEach((key): void => {
@@ -117,11 +117,13 @@ export class WebAclAuthorizer extends Authorizer {
   private async getAclRecursive(id: ResourceIdentifier, recurse?: boolean): Promise<Store> {
     this.logger.debug(`Trying to read the direct ACL document of ${id.path}`);
     try {
-      const acl = await this.aclManager.getAcl(id);
+      const acl = await this.aclManager.getAclDocument(id);
       this.logger.debug(`Trying to read the ACL document ${acl.path}`);
       const data = await this.resourceStore.getRepresentation(acl, { type: [{ value: INTERNAL_QUADS, weight: 1 }]});
       this.logger.info(`Reading ACL statements from ${acl.path}`);
-      return this.filterData(data, recurse ? ACL.default : ACL.accessTo, id.path);
+
+      const resourceId = await this.aclManager.getAclConstrainedResource(id);
+      return this.filterData(data, recurse ? ACL.default : ACL.accessTo, resourceId.path);
     } catch (error: unknown) {
       if (error instanceof NotFoundHttpError) {
         this.logger.debug(`No direct ACL document found for ${id.path}`);
diff --git a/src/init/Setup.ts b/src/init/Setup.ts
index db799fc7e..a8437a096 100644
--- a/src/init/Setup.ts
+++ b/src/init/Setup.ts
@@ -59,7 +59,7 @@ export class Setup {
     acl:mode        acl:Control;
     acl:accessTo    <${this.base}>;
     acl:default     <${this.base}>.`;
-      const baseAclId = await this.aclManager.getAcl({ path: this.base });
+      const baseAclId = await this.aclManager.getAclDocument({ path: this.base });
       const metadata = new RepresentationMetadata(baseAclId.path, { [CONTENT_TYPE]: TEXT_TURTLE });
       await this.store.setRepresentation(
         baseAclId,
diff --git a/src/storage/LockingResourceStore.ts b/src/storage/LockingResourceStore.ts
index f654e0210..d67366a71 100644
--- a/src/storage/LockingResourceStore.ts
+++ b/src/storage/LockingResourceStore.ts
@@ -5,10 +5,10 @@ import type { RepresentationPreferences } from '../ldp/representation/Representa
 import type { ResourceIdentifier } from '../ldp/representation/ResourceIdentifier';
 import { getLoggerFor } from '../logging/LogUtil';
 import type { Guarded } from '../util/GuardedStream';
+import type { ExpiringLock } from '../util/locking/ExpiringLock';
+import type { ExpiringResourceLocker } from '../util/locking/ExpiringResourceLocker';
 import type { AtomicResourceStore } from './AtomicResourceStore';
 import type { Conditions } from './Conditions';
-import type { ExpiringLock } from './ExpiringLock';
-import type { ExpiringResourceLocker } from './ExpiringResourceLocker';
 import type { ResourceStore } from './ResourceStore';
 
 /**
diff --git a/src/storage/patch/SparqlUpdatePatchHandler.ts b/src/storage/patch/SparqlUpdatePatchHandler.ts
index 23b0b4924..9c5ff6ff1 100644
--- a/src/storage/patch/SparqlUpdatePatchHandler.ts
+++ b/src/storage/patch/SparqlUpdatePatchHandler.ts
@@ -12,8 +12,8 @@ import { getLoggerFor } from '../../logging/LogUtil';
 import { INTERNAL_QUADS } from '../../util/ContentTypes';
 import { UnsupportedHttpError } from '../../util/errors/UnsupportedHttpError';
 import { guardStream } from '../../util/GuardedStream';
+import type { ResourceLocker } from '../../util/locking/ResourceLocker';
 import { CONTENT_TYPE } from '../../util/UriConstants';
-import type { ResourceLocker } from '../ResourceLocker';
 import type { ResourceStore } from '../ResourceStore';
 import { PatchHandler } from './PatchHandler';
 
diff --git a/src/storage/ExpiringLock.ts b/src/util/locking/ExpiringLock.ts
similarity index 100%
rename from src/storage/ExpiringLock.ts
rename to src/util/locking/ExpiringLock.ts
diff --git a/src/storage/ExpiringResourceLocker.ts b/src/util/locking/ExpiringResourceLocker.ts
similarity index 100%
rename from src/storage/ExpiringResourceLocker.ts
rename to src/util/locking/ExpiringResourceLocker.ts
diff --git a/src/storage/Lock.ts b/src/util/locking/Lock.ts
similarity index 100%
rename from src/storage/Lock.ts
rename to src/util/locking/Lock.ts
diff --git a/src/storage/ResourceLocker.ts b/src/util/locking/ResourceLocker.ts
similarity index 83%
rename from src/storage/ResourceLocker.ts
rename to src/util/locking/ResourceLocker.ts
index 8558b9f98..68b36483e 100644
--- a/src/storage/ResourceLocker.ts
+++ b/src/util/locking/ResourceLocker.ts
@@ -1,4 +1,4 @@
-import type { ResourceIdentifier } from '../ldp/representation/ResourceIdentifier';
+import type { ResourceIdentifier } from '../../ldp/representation/ResourceIdentifier';
 import type { Lock } from './Lock';
 
 /**
diff --git a/src/storage/SingleThreadedResourceLocker.ts b/src/util/locking/SingleThreadedResourceLocker.ts
similarity index 89%
rename from src/storage/SingleThreadedResourceLocker.ts
rename to src/util/locking/SingleThreadedResourceLocker.ts
index 3a302e9f1..e981d25f5 100644
--- a/src/storage/SingleThreadedResourceLocker.ts
+++ b/src/util/locking/SingleThreadedResourceLocker.ts
@@ -1,6 +1,6 @@
 import AsyncLock from 'async-lock';
-import type { ResourceIdentifier } from '../ldp/representation/ResourceIdentifier';
-import { getLoggerFor } from '../logging/LogUtil';
+import type { ResourceIdentifier } from '../../ldp/representation/ResourceIdentifier';
+import { getLoggerFor } from '../../logging/LogUtil';
 import type { Lock } from './Lock';
 import type { ResourceLocker } from './ResourceLocker';
 
diff --git a/src/storage/WrappedExpiringLock.ts b/src/util/locking/WrappedExpiringLock.ts
similarity index 97%
rename from src/storage/WrappedExpiringLock.ts
rename to src/util/locking/WrappedExpiringLock.ts
index 375b55788..ef2f511af 100644
--- a/src/storage/WrappedExpiringLock.ts
+++ b/src/util/locking/WrappedExpiringLock.ts
@@ -1,5 +1,5 @@
 import { EventEmitter } from 'events';
-import { getLoggerFor } from '../logging/LogUtil';
+import { getLoggerFor } from '../../logging/LogUtil';
 import type { ExpiringLock } from './ExpiringLock';
 import type { Lock } from './Lock';
 
diff --git a/src/storage/WrappedExpiringResourceLocker.ts b/src/util/locking/WrappedExpiringResourceLocker.ts
similarity index 90%
rename from src/storage/WrappedExpiringResourceLocker.ts
rename to src/util/locking/WrappedExpiringResourceLocker.ts
index d32087bcc..e89afcb4d 100644
--- a/src/storage/WrappedExpiringResourceLocker.ts
+++ b/src/util/locking/WrappedExpiringResourceLocker.ts
@@ -1,5 +1,5 @@
-import type { ResourceIdentifier } from '../ldp/representation/ResourceIdentifier';
-import { getLoggerFor } from '../logging/LogUtil';
+import type { ResourceIdentifier } from '../../ldp/representation/ResourceIdentifier';
+import { getLoggerFor } from '../../logging/LogUtil';
 import type { ExpiringLock } from './ExpiringLock';
 import type { ExpiringResourceLocker } from './ExpiringResourceLocker';
 import type { ResourceLocker } from './ResourceLocker';


Test patch:
diff --git a/test/deploy/validate-package.sh b/test/deploy/validate-package.sh
index e9e0ac5ae..c70ae8611 100755
--- a/test/deploy/validate-package.sh
+++ b/test/deploy/validate-package.sh
@@ -17,7 +17,7 @@ if [ -z $PID ]; then
 else
   for i in {1..10}; do
     sleep 1
-    if curl -s localhost:8888; then
+    if curl -s -f localhost:8888; then
       echo "Server reached"
       FAILURE=0
       break
diff --git a/test/integration/LockingResourceStore.test.ts b/test/integration/LockingResourceStore.test.ts
new file mode 100644
index 000000000..a907555f3
--- /dev/null
+++ b/test/integration/LockingResourceStore.test.ts
@@ -0,0 +1,104 @@
+import streamifyArray from 'streamify-array';
+import type { Representation } from '../../src/ldp/representation/Representation';
+import { RepresentationMetadata } from '../../src/ldp/representation/RepresentationMetadata';
+import { InMemoryDataAccessor } from '../../src/storage/accessors/InMemoryDataAccessor';
+import { DataAccessorBasedStore } from '../../src/storage/DataAccessorBasedStore';
+import { LockingResourceStore } from '../../src/storage/LockingResourceStore';
+import type { ResourceStore } from '../../src/storage/ResourceStore';
+import { APPLICATION_OCTET_STREAM } from '../../src/util/ContentTypes';
+import type { ExpiringResourceLocker } from '../../src/util/locking/ExpiringResourceLocker';
+import type { ResourceLocker } from '../../src/util/locking/ResourceLocker';
+import { SingleThreadedResourceLocker } from '../../src/util/locking/SingleThreadedResourceLocker';
+import { WrappedExpiringResourceLocker } from '../../src/util/locking/WrappedExpiringResourceLocker';
+import { guardedStreamFrom } from '../../src/util/StreamUtil';
+import { CONTENT_TYPE } from '../../src/util/UriConstants';
+
+describe('A LockingResourceStore', (): void => {
+  let path: string;
+  let store: LockingResourceStore;
+  let locker: ResourceLocker;
+  let expiringLocker: ExpiringResourceLocker;
+  let source: ResourceStore;
+
+  beforeEach(async(): Promise<void> => {
+    jest.clearAllMocks();
+
+    const base = 'http://test.com/';
+    path = `${base}path`;
+    source = new DataAccessorBasedStore(new InMemoryDataAccessor(base), base);
+
+    locker = new SingleThreadedResourceLocker();
+    expiringLocker = new WrappedExpiringResourceLocker(locker, 1000);
+
+    store = new LockingResourceStore(source, expiringLocker);
+
+    // Make sure something is in the store before we read from it in our tests.
+    const metadata = new RepresentationMetadata({ [CONTENT_TYPE]: APPLICATION_OCTET_STREAM });
+    const data = guardedStreamFrom([ 1, 2, 3 ]);
+    await store.setRepresentation({ path }, { metadata, data, binary: true });
+  });
+
+  it('destroys the stream when nothing is read after 1000ms.', async(): Promise<void> => {
+    jest.useFakeTimers();
+
+    // Spy on a real ResourceLocker and ResourceStore instance
+    const acquireSpy = jest.spyOn(expiringLocker, 'acquire');
+    const getRepresentationSpy = jest.spyOn(source, 'getRepresentation');
+    getRepresentationSpy.mockReturnValue(new Promise((resolve): any => resolve({ data:
+        streamifyArray([ 1, 2, 3 ]) } as Representation)));
+
+    const representation = await store.getRepresentation({ path }, {});
+    const errorCallback = jest.fn();
+    representation.data.on('error', errorCallback);
+
+    // Wait 1000ms and read
+    jest.advanceTimersByTime(1000);
+    expect(representation.data.read()).toBeNull();
+
+    // Verify a timeout error was thrown
+    await new Promise((resolve): any => setImmediate(resolve));
+    expect(errorCallback).toHaveBeenCalledTimes(1);
+    expect(errorCallback).toHaveBeenLastCalledWith(new Error('Stream reading timout exceeded'));
+
+    // Verify the lock was acquired and released at the right time
+    expect(acquireSpy).toHaveBeenCalledTimes(1);
+    expect(acquireSpy).toHaveBeenLastCalledWith({ path });
+    expect(getRepresentationSpy).toHaveBeenCalledTimes(1);
+  });
+
+  it('destroys the stream when pauses between reads exceed 1000ms.', async(): Promise<void> => {
+    jest.useFakeTimers();
+
+    // Spy on a real ResourceLocker and ResourceStore instance
+    const acquireSpy = jest.spyOn(expiringLocker, 'acquire');
+    const getRepresentationSpy = jest.spyOn(source, 'getRepresentation');
+    getRepresentationSpy.mockReturnValue(new Promise((resolve): any => resolve({ data:
+        streamifyArray([ 1, 2, 3 ]) } as Representation)));
+
+    const representation = await store.getRepresentation({ path }, {});
+    const errorCallback = jest.fn();
+    representation.data.on('error', errorCallback);
+
+    // Wait 750ms and read
+    jest.advanceTimersByTime(750);
+    expect(representation.data.read()).toBe(1);
+
+    // Wait 750ms and read
+    jest.advanceTimersByTime(750);
+    expect(representation.data.read()).toBe(2);
+
+    // Wait 1000ms and watch the stream be destroyed
+    jest.advanceTimersByTime(1000);
+    expect(representation.data.read()).toBeNull();
+
+    // Verify a timeout error was thrown
+    await new Promise((resolve): any => setImmediate(resolve));
+    expect(errorCallback).toHaveBeenCalledTimes(1);
+    expect(errorCallback).toHaveBeenLastCalledWith(new Error('Stream reading timout exceeded'));
+
+    // Verify the lock was acquired and released at the right time
+    expect(acquireSpy).toHaveBeenCalledTimes(1);
+    expect(acquireSpy).toHaveBeenLastCalledWith({ path });
+    expect(getRepresentationSpy).toHaveBeenCalledTimes(1);
+  });
+});
diff --git a/test/unit/authorization/UrlBasedAclManager.test.ts b/test/unit/authorization/UrlBasedAclManager.test.ts
index 11b666f93..522579afb 100644
--- a/test/unit/authorization/UrlBasedAclManager.test.ts
+++ b/test/unit/authorization/UrlBasedAclManager.test.ts
@@ -3,18 +3,32 @@ import { UrlBasedAclManager } from '../../../src/authorization/UrlBasedAclManage
 describe('An UrlBasedAclManager', (): void => {
   const manager = new UrlBasedAclManager();
 
-  it('generates acl URLs by adding an .acl extension.', async(): Promise<void> => {
-    await expect(manager.getAcl({ path: '/foo/bar' })).resolves.toEqual({ path: '/foo/bar.acl' });
+  describe('#getAcl', (): void => {
+    it('generates acl URLs by adding an .acl extension.', async(): Promise<void> => {
+      await expect(manager.getAclDocument({ path: '/foo/bar' })).resolves.toEqual({ path: '/foo/bar.acl' });
+    });
+
+    it('returns the identifier if the input is already an acl resource.', async(): Promise<void> => {
+      await expect(manager.getAclDocument({ path: '/foo/bar.acl' })).resolves.toEqual({ path: '/foo/bar.acl' });
+    });
   });
 
-  it('returns the identifier if the input is already an acl file.', async(): Promise<void> => {
-    await expect(manager.getAcl({ path: '/foo/bar.acl' })).resolves.toEqual({ path: '/foo/bar.acl' });
+  describe('#isAcl', (): void => {
+    it('checks if a resource is an acl resource by looking at the extension.', async(): Promise<void> => {
+      await expect(manager.isAclDocument({ path: '/foo/bar' })).resolves.toBeFalsy();
+      await expect(manager.isAclDocument({ path: '/foo/bar/' })).resolves.toBeFalsy();
+      await expect(manager.isAclDocument({ path: '/foo/bar.acl' })).resolves.toBeTruthy();
+      await expect(manager.isAclDocument({ path: '/foo/bar.acl/' })).resolves.toBeTruthy();
+    });
   });
 
-  it('checks if a resource is an acl file by looking at the extension.', async(): Promise<void> => {
-    await expect(manager.isAcl({ path: '/foo/bar' })).resolves.toBeFalsy();
-    await expect(manager.isAcl({ path: '/foo/bar/' })).resolves.toBeFalsy();
-    await expect(manager.isAcl({ path: '/foo/bar.acl' })).resolves.toBeTruthy();
-    await expect(manager.isAcl({ path: '/foo/bar.acl/' })).resolves.toBeTruthy();
+  describe('#getResource', (): void => {
+    it('generates non-acl resource URLs by removing the .acl extension.', async(): Promise<void> => {
+      await expect(manager.getAclConstrainedResource({ path: '/foo/bar.acl' })).resolves.toEqual({ path: '/foo/bar' });
+    });
+
+    it('returns the identifier if the input is already a non-acl resource.', async(): Promise<void> => {
+      await expect(manager.getAclConstrainedResource({ path: '/foo/bar' })).resolves.toEqual({ path: '/foo/bar' });
+    });
   });
 });
diff --git a/test/unit/authorization/WebAclAuthorizer.test.ts b/test/unit/authorization/WebAclAuthorizer.test.ts
index e2364474e..fb9354cff 100644
--- a/test/unit/authorization/WebAclAuthorizer.test.ts
+++ b/test/unit/authorization/WebAclAuthorizer.test.ts
@@ -19,9 +19,11 @@ const acl = 'http://www.w3.org/ns/auth/acl#';
 describe('A WebAclAuthorizer', (): void => {
   let authorizer: WebAclAuthorizer;
   const aclManager: AclManager = {
-    getAcl: async(id: ResourceIdentifier): Promise<ResourceIdentifier> =>
+    getAclDocument: async(id: ResourceIdentifier): Promise<ResourceIdentifier> =>
       id.path.endsWith('.acl') ? id : { path: `${id.path}.acl` },
-    isAcl: async(id: ResourceIdentifier): Promise<boolean> => id.path.endsWith('.acl'),
+    isAclDocument: async(id: ResourceIdentifier): Promise<boolean> => id.path.endsWith('.acl'),
+    getAclConstrainedResource: async(id: ResourceIdentifier): Promise<ResourceIdentifier> =>
+      !id.path.endsWith('.acl') ? id : { path: id.path.slice(0, -4) },
   };
   let permissions: PermissionSet;
   let credentials: Credentials;
@@ -134,9 +136,9 @@ describe('A WebAclAuthorizer', (): void => {
         quad(nn('auth'), nn(`${acl}mode`), nn(`${acl}Control`)),
       ]) } as Representation),
     } as unknown as ResourceStore;
-    identifier = await aclManager.getAcl(identifier);
+    const aclIdentifier = await aclManager.getAclDocument(identifier);
     authorizer = new WebAclAuthorizer(aclManager, store);
-    await expect(authorizer.handle({ identifier, permissions, credentials })).resolves.toBeUndefined();
+    await expect(authorizer.handle({ identifier: aclIdentifier, permissions, credentials })).resolves.toBeUndefined();
   });
 
   it('errors if an agent tries to edit the acl file without control permissions.', async(): Promise<void> => {
@@ -149,7 +151,7 @@ describe('A WebAclAuthorizer', (): void => {
         quad(nn('auth'), nn(`${acl}mode`), nn(`${acl}Read`)),
       ]) } as Representation),
     } as unknown as ResourceStore;
-    identifier = await aclManager.getAcl(identifier);
+    identifier = await aclManager.getAclDocument(identifier);
     authorizer = new WebAclAuthorizer(aclManager, store);
     await expect(authorizer.handle({ identifier, permissions, credentials })).rejects.toThrow(ForbiddenHttpError);
   });
diff --git a/test/unit/init/Setup.test.ts b/test/unit/init/Setup.test.ts
index 724be4164..822cfa15c 100644
--- a/test/unit/init/Setup.test.ts
+++ b/test/unit/init/Setup.test.ts
@@ -1,3 +1,4 @@
+import type { AclManager } from '../../../src/authorization/AclManager';
 import { Setup } from '../../../src/init/Setup';
 import type { ResourceIdentifier } from '../../../src/ldp/representation/ResourceIdentifier';
 import { VoidLoggerFactory } from '../../../src/logging/VoidLoggerFactory';
@@ -5,15 +6,15 @@ import { VoidLoggerFactory } from '../../../src/logging/VoidLoggerFactory';
 describe('Setup', (): void => {
   let serverFactory: any;
   let store: any;
-  let aclManager: any;
+  let aclManager: AclManager;
   let setup: Setup;
   beforeEach(async(): Promise<void> => {
     store = {
       setRepresentation: jest.fn(async(): Promise<void> => undefined),
     };
     aclManager = {
-      getAcl: jest.fn(async(): Promise<ResourceIdentifier> => ({ path: 'http://test.com/.acl' })),
-    };
+      getAclDocument: jest.fn(async(): Promise<ResourceIdentifier> => ({ path: 'http://test.com/.acl' })),
+    } as any;
     serverFactory = {
       startServer: jest.fn(),
     };
@@ -27,7 +28,7 @@ describe('Setup', (): void => {
 
   it('invokes ACL initialization.', async(): Promise<void> => {
     await setup.setup();
-    expect(aclManager.getAcl).toHaveBeenCalledWith({ path: 'http://localhost:3000/' });
+    expect(aclManager.getAclDocument).toHaveBeenCalledWith({ path: 'http://localhost:3000/' });
     expect(store.setRepresentation).toHaveBeenCalledTimes(1);
   });
 });
diff --git a/test/unit/storage/LockingResourceStore.test.ts b/test/unit/storage/LockingResourceStore.test.ts
index cd14c0477..d53da2d55 100644
--- a/test/unit/storage/LockingResourceStore.test.ts
+++ b/test/unit/storage/LockingResourceStore.test.ts
@@ -2,11 +2,10 @@ import type { EventEmitter } from 'events';
 import streamifyArray from 'streamify-array';
 import type { Patch } from '../../../src/ldp/http/Patch';
 import type { Representation } from '../../../src/ldp/representation/Representation';
-import type { ExpiringLock } from '../../../src/storage/ExpiringLock';
-import type { ExpiringResourceLocker } from '../../../src/storage/ExpiringResourceLocker';
 import { LockingResourceStore } from '../../../src/storage/LockingResourceStore';
 import type { ResourceStore } from '../../../src/storage/ResourceStore';
-import { WrappedExpiringResourceLocker } from '../../../src/storage/WrappedExpiringResourceLocker';
+import type { ExpiringLock } from '../../../src/util/locking/ExpiringLock';
+import type { ExpiringResourceLocker } from '../../../src/util/locking/ExpiringResourceLocker';
 
 describe('A LockingResourceStore', (): void => {
   let store: LockingResourceStore;
@@ -190,65 +189,17 @@ describe('A LockingResourceStore', (): void => {
     expect(order).toEqual([ 'acquire', 'getRepresentation', 'end', 'close', 'release' ]);
   });
 
-  it('destroys the stream when nothing is read after 1000ms.', async(): Promise<void> => {
-    jest.useFakeTimers();
-
-    // Spy on a real ResourceLocker instance
-    const expiringLocker = new WrappedExpiringResourceLocker(locker, 1000);
-    store = new LockingResourceStore(source, expiringLocker);
-    const acquireSpy = jest.spyOn(expiringLocker, 'acquire');
-
+  it('releases the lock on the resource when readable times out.', async(): Promise<void> => {
+    // Make the representation time out
     const representation = await store.getRepresentation({ path: 'path' }, {});
-    const errorCallback = jest.fn();
-    representation.data.on('error', errorCallback);
-
-    // Wait 1000ms and read
-    jest.advanceTimersByTime(1000);
-    expect(representation.data.read()).toBeNull();
+    lock.emit('expired');
     await registerEventOrder(representation.data, 'close');
 
-    // Verify a timeout error was thrown
-    expect(errorCallback).toHaveBeenCalledTimes(1);
-    expect(errorCallback).toHaveBeenLastCalledWith(new Error('Stream reading timout exceeded'));
-
     // Verify the lock was acquired and released at the right time
-    expect(acquireSpy).toHaveBeenCalledTimes(1);
-    expect(acquireSpy).toHaveBeenLastCalledWith({ path: 'path' });
-    expect(source.getRepresentation).toHaveBeenCalledTimes(1);
-  });
-
-  it('destroys the stream when pauses between reads exceed 1000ms.', async(): Promise<void> => {
-    jest.useFakeTimers();
-
-    // Spy on a real ResourceLocker instance
-    const expiringLocker = new WrappedExpiringResourceLocker(locker, 1000);
-    store = new LockingResourceStore(source, expiringLocker);
-    const acquireSpy = jest.spyOn(expiringLocker, 'acquire');
-
-    const representation = await store.getRepresentation({ path: 'path' }, {});
-    const errorCallback = jest.fn();
-    representation.data.on('error', errorCallback);
-
-    // Wait 750ms and read
-    jest.advanceTimersByTime(750);
-    expect(representation.data.read()).toBe(1);
-
-    // Wait 750ms and read
-    jest.advanceTimersByTime(750);
-    expect(representation.data.read()).toBe(2);
-
-    // Wait 1000ms and watch the stream be destroyed
-    jest.advanceTimersByTime(1000);
-    expect(representation.data.read()).toBeNull();
-    await registerEventOrder(representation.data, 'close');
-
-    // Verify a timeout error was thrown
-    expect(errorCallback).toHaveBeenCalledTimes(1);
-    expect(errorCallback).toHaveBeenLastCalledWith(new Error('Stream reading timout exceeded'));
-
-    // Verify the lock was acquired and released at the right time
-    expect(acquireSpy).toHaveBeenCalledTimes(1);
-    expect(acquireSpy).toHaveBeenLastCalledWith({ path: 'path' });
+    expect(locker.acquire).toHaveBeenCalledTimes(1);
+    expect(locker.acquire).toHaveBeenLastCalledWith({ path: 'path' });
     expect(source.getRepresentation).toHaveBeenCalledTimes(1);
+    expect(lock.release).toHaveBeenCalledTimes(1);
+    expect(order).toEqual([ 'acquire', 'getRepresentation', 'close', 'release' ]);
   });
 });
diff --git a/test/unit/storage/WrappedExpiringResourceLocker.test.ts b/test/unit/storage/WrappedExpiringResourceLocker.test.ts
deleted file mode 100644
index f84e3aabd..000000000
--- a/test/unit/storage/WrappedExpiringResourceLocker.test.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import { WrappedExpiringResourceLocker } from '../../../src/storage/WrappedExpiringResourceLocker';
-
-describe('A WrappedExpiringResourceLocker', (): void => {
-  it('emits an error event when releasing the lock errors.', async(): Promise<void> => {
-    jest.useFakeTimers();
-
-    // Create a locker that fails upon release
-    const faultyLocker = {
-      acquire(): any {
-        return {
-          async release(): Promise<never> {
-            throw new Error('Release error');
-          },
-        };
-      },
-    };
-    const expiringLocker = new WrappedExpiringResourceLocker(faultyLocker, 1000);
-    const expiringLock = await expiringLocker.acquire({} as any);
-    const errorCallback = jest.fn();
-    expiringLock.on('error', errorCallback);
-
-    // Let the lock expire
-    jest.advanceTimersByTime(1000);
-    await Promise.resolve();
-
-    // Verify the error has been emitted
-    expect(errorCallback).toHaveBeenCalledTimes(1);
-    expect(errorCallback).toHaveBeenLastCalledWith(new Error('Release error'));
-  });
-});
diff --git a/test/unit/storage/patch/SparqlUpdatePatchHandler.test.ts b/test/unit/storage/patch/SparqlUpdatePatchHandler.test.ts
index 8ef80a311..42b4bb397 100644
--- a/test/unit/storage/patch/SparqlUpdatePatchHandler.test.ts
+++ b/test/unit/storage/patch/SparqlUpdatePatchHandler.test.ts
@@ -5,12 +5,12 @@ import { translate } from 'sparqlalgebrajs';
 import streamifyArray from 'streamify-array';
 import type { SparqlUpdatePatch } from '../../../../src/ldp/http/SparqlUpdatePatch';
 import { RepresentationMetadata } from '../../../../src/ldp/representation/RepresentationMetadata';
-import type { Lock } from '../../../../src/storage/Lock';
 import { SparqlUpdatePatchHandler } from '../../../../src/storage/patch/SparqlUpdatePatchHandler';
-import type { ResourceLocker } from '../../../../src/storage/ResourceLocker';
 import type { ResourceStore } from '../../../../src/storage/ResourceStore';
 import { INTERNAL_QUADS } from '../../../../src/util/ContentTypes';
 import { UnsupportedHttpError } from '../../../../src/util/errors/UnsupportedHttpError';
+import type { Lock } from '../../../../src/util/locking/Lock';
+import type { ResourceLocker } from '../../../../src/util/locking/ResourceLocker';
 
 describe('A SparqlUpdatePatchHandler', (): void => {
   let handler: SparqlUpdatePatchHandler;
diff --git a/test/unit/storage/SingleThreadedResourceLocker.test.ts b/test/unit/util/locking/SingleThreadedResourceLocker.test.ts
similarity index 95%
rename from test/unit/storage/SingleThreadedResourceLocker.test.ts
rename to test/unit/util/locking/SingleThreadedResourceLocker.test.ts
index 6cea9aeb2..8fbdb6cb7 100644
--- a/test/unit/storage/SingleThreadedResourceLocker.test.ts
+++ b/test/unit/util/locking/SingleThreadedResourceLocker.test.ts
@@ -1,4 +1,4 @@
-import { SingleThreadedResourceLocker } from '../../../src/storage/SingleThreadedResourceLocker';
+import { SingleThreadedResourceLocker } from '../../../../src/util/locking/SingleThreadedResourceLocker';
 
 describe('A SingleThreadedResourceLocker', (): void => {
   let locker: SingleThreadedResourceLocker;
diff --git a/test/unit/util/locking/WrappedExpiringResourceLocker.test.ts b/test/unit/util/locking/WrappedExpiringResourceLocker.test.ts
new file mode 100644
index 000000000..99b30a042
--- /dev/null
+++ b/test/unit/util/locking/WrappedExpiringResourceLocker.test.ts
@@ -0,0 +1,87 @@
+import type { EventEmitter } from 'events';
+import streamifyArray from 'streamify-array';
+import { WrappedExpiringResourceLocker } from '../../../../src/util/locking/WrappedExpiringResourceLocker';
+
+describe('A WrappedExpiringResourceLocker', (): void => {
+  let order: string[];
+
+  beforeEach(async(): Promise<void> => {
+    order = [];
+  });
+
+  const registerEventOrder = async(eventSource: EventEmitter, event: string): Promise<void> => {
+    await new Promise((resolve): any => {
+      eventSource.prependListener(event, (): any => {
+        order.push(event);
+        resolve();
+      });
+    });
+  };
+
+  it('emits an error event when releasing the lock errors.', async(): Promise<void> => {
+    jest.useFakeTimers();
+
+    // Create a locker that fails upon release
+    const faultyLocker = {
+      acquire(): any {
+        return {
+          async release(): Promise<never> {
+            throw new Error('Release error');
+          },
+        };
+      },
+    };
+    const expiringLocker = new WrappedExpiringResourceLocker(faultyLocker, 1000);
+    const expiringLock = await expiringLocker.acquire({} as any);
+    const errorCallback = jest.fn();
+    expiringLock.on('error', errorCallback);
+
+    // Let the lock expire
+    jest.advanceTimersByTime(1000);
+    await Promise.resolve();
+
+    // Verify the error has been emitted
+    expect(errorCallback).toHaveBeenCalledTimes(1);
+    expect(errorCallback).toHaveBeenLastCalledWith(new Error('Release error'));
+  });
+
+  it('releases the lock on the resource when data has been read.', async(): Promise<void> => {
+    // Mock the inner ResourceLocker.
+    const release = jest.fn(async(): Promise<any> => order.push('release'));
+    const lock = { release };
+    const locker = {
+      acquire: jest.fn(async(): Promise<any> => {
+        order.push('acquire');
+        return lock;
+      }),
+    };
+
+    const expiringLocker = new WrappedExpiringResourceLocker(locker, 1000);
+    const expiringLock = await expiringLocker.acquire({} as any);
+
+    // Mimic the behavior of a LockingResourceStore to test the expiringLock methods called.
+    const source = streamifyArray([ 1, 2, 3 ]);
+    // eslint-disable-next-line jest/valid-expect-in-promise
+    new Promise((resolve): void => {
+      source.on('end', resolve);
+      source.on('close', resolve);
+    }).then((): any => expiringLock.release(), null);
+    const readable = Object.create(source, {
+      read: {
+        value(size: number): any {
+          expiringLock.renew();
+          return source.read(size);
+        },
+      },
+    });
+
+    // Read all data from the "representation"
+    readable.on('data', (): any => true);
+    await registerEventOrder(readable, 'end');
+
+    // Verify the lock was acquired and released at the right time
+    expect(locker.acquire).toHaveBeenCalledTimes(1);
+    expect(lock.release).toHaveBeenCalledTimes(1);
+    expect(order).toEqual([ 'acquire', 'end', 'release' ]);
+  });
+});