Repository: solid/community-server
Pull request #401 (2020-12-07 16:28:20)

Add read-only store
Closes https://github.com/solid/community-server/issues/193
Closes https://github.com/solid/community-server/issues/323


Patch:
diff --git a/index.ts b/index.ts
index 9e219723b..5c42ec6f0 100644
--- a/index.ts
+++ b/index.ts
@@ -148,14 +148,15 @@ export * from './src/storage/routing/RouterRule';
 export * from './src/storage/AtomicResourceStore';
 export * from './src/storage/Conditions';
 export * from './src/storage/DataAccessorBasedStore';
-export * from './src/storage/mapping/FileIdentifierMapper';
 export * from './src/storage/LockingResourceStore';
 export * from './src/storage/MonitoringStore';
 export * from './src/storage/PassthroughStore';
 export * from './src/storage/PatchingStore';
+export * from './src/storage/ReadOnlyStore';
 export * from './src/storage/RepresentationConvertingStore';
 export * from './src/storage/ResourceStore';
 export * from './src/storage/RoutingResourceStore';
+export * from './src/storage/mapping/FileIdentifierMapper';
 
 // Util/Errors
 export * from './src/util/errors/BadRequestHttpError';
diff --git a/src/storage/ReadOnlyStore.ts b/src/storage/ReadOnlyStore.ts
new file mode 100644
index 000000000..86de0dce8
--- /dev/null
+++ b/src/storage/ReadOnlyStore.ts
@@ -0,0 +1,35 @@
+import type { Patch } from '../ldp/http/Patch';
+import type { Representation } from '../ldp/representation/Representation';
+import type { ResourceIdentifier } from '../ldp/representation/ResourceIdentifier';
+import { ForbiddenHttpError } from '../util/errors/ForbiddenHttpError';
+import type { Conditions } from './Conditions';
+import { PassthroughStore } from './PassthroughStore';
+import type { ResourceStore } from './ResourceStore';
+
+/**
+ * Store that only allow read operations on the underlying source.
+ */
+/* eslint-disable @typescript-eslint/no-unused-vars */
+export class ReadOnlyStore<T extends ResourceStore = ResourceStore> extends PassthroughStore<T> {
+  public constructor(source: T) {
+    super(source);
+  }
+
+  public async addResource(container: ResourceIdentifier, representation: Representation,
+    conditions?: Conditions): Promise<ResourceIdentifier> {
+    throw new ForbiddenHttpError();
+  }
+
+  public async deleteResource(identifier: ResourceIdentifier, conditions?: Conditions): Promise<void> {
+    throw new ForbiddenHttpError();
+  }
+
+  public async modifyResource(identifier: ResourceIdentifier, patch: Patch, conditions?: Conditions): Promise<void> {
+    throw new ForbiddenHttpError();
+  }
+
+  public async setRepresentation(identifier: ResourceIdentifier, representation: Representation,
+    conditions?: Conditions): Promise<void> {
+    throw new ForbiddenHttpError();
+  }
+}


Test patch:
diff --git a/test/unit/storage/ReadOnlyStore.test.ts b/test/unit/storage/ReadOnlyStore.test.ts
new file mode 100644
index 000000000..03fc794af
--- /dev/null
+++ b/test/unit/storage/ReadOnlyStore.test.ts
@@ -0,0 +1,50 @@
+import type { Patch } from '../../../src/ldp/http/Patch';
+import type { Representation } from '../../../src/ldp/representation/Representation';
+import { ReadOnlyStore } from '../../../src/storage/ReadOnlyStore';
+import type { ResourceStore } from '../../../src/storage/ResourceStore';
+import { ForbiddenHttpError } from '../../../src/util/errors/ForbiddenHttpError';
+
+describe('A ReadOnlyStore', (): void => {
+  const source: jest.Mocked<ResourceStore> = {
+    getRepresentation: jest.fn(async(): Promise<any> => 'get'),
+    addResource: jest.fn(),
+    setRepresentation: jest.fn(),
+    deleteResource: jest.fn(),
+    modifyResource: jest.fn(),
+  } as any;
+  let store: ReadOnlyStore;
+
+  beforeAll((): void => {
+    store = new ReadOnlyStore(source);
+  });
+
+  it('calls getRepresentation directly from the source.', async(): Promise<void> => {
+    await expect(store.getRepresentation({ path: 'getPath' }, {})).resolves.toBe('get');
+    expect(source.getRepresentation).toHaveBeenCalledTimes(1);
+    expect(source.getRepresentation).toHaveBeenLastCalledWith({ path: 'getPath' }, {}, undefined);
+  });
+
+  it('throws an error when calling addResource.', async(): Promise<void> => {
+    await expect(store.addResource({ path: 'addPath' }, {} as Representation))
+      .rejects.toThrow(ForbiddenHttpError);
+    expect(source.addResource).toHaveBeenCalledTimes(0);
+  });
+
+  it('throws an error when calling setRepresentation.', async(): Promise<void> => {
+    await expect(store.setRepresentation({ path: 'setPath' }, {} as Representation))
+      .rejects.toThrow(ForbiddenHttpError);
+    expect(source.setRepresentation).toHaveBeenCalledTimes(0);
+  });
+
+  it('throws an error when calling deleteResource.', async(): Promise<void> => {
+    await expect(store.deleteResource({ path: 'deletePath' }))
+      .rejects.toThrow(ForbiddenHttpError);
+    expect(source.deleteResource).toHaveBeenCalledTimes(0);
+  });
+
+  it('throws an error when calling modifyResource.', async(): Promise<void> => {
+    await expect(store.modifyResource({ path: 'modifyPath' }, {} as Patch))
+      .rejects.toThrow(ForbiddenHttpError);
+    expect(source.modifyResource).toHaveBeenCalledTimes(0);
+  });
+});