Repository: solid/community-server
Pull request #431 (2020-12-17 21:41:23)

Add constant WebID extractor
Closes https://github.com/solid/community-server/issues/397


Patch:
diff --git a/src/authentication/UnsecureConstantCredentialsExtractor.ts b/src/authentication/UnsecureConstantCredentialsExtractor.ts
new file mode 100644
index 000000000..0911a7386
--- /dev/null
+++ b/src/authentication/UnsecureConstantCredentialsExtractor.ts
@@ -0,0 +1,22 @@
+import { getLoggerFor } from '../logging/LogUtil';
+import type { Credentials } from './Credentials';
+import { CredentialsExtractor } from './CredentialsExtractor';
+
+/**
+ * Credentials extractor that authenticates a constant agent
+ * (useful for development or debugging purposes).
+ */
+export class UnsecureConstantCredentialsExtractor extends CredentialsExtractor {
+  private readonly agent: Credentials;
+  private readonly logger = getLoggerFor(this);
+
+  public constructor(agent: string | Credentials) {
+    super();
+    this.agent = typeof agent === 'string' ? { webId: agent } : agent;
+  }
+
+  public async handle(): Promise<Credentials> {
+    this.logger.info(`Agent unsecurely claims to be ${this.agent.webId}`);
+    return this.agent;
+  }
+}
diff --git a/src/storage/accessors/InMemoryDataAccessor.ts b/src/storage/accessors/InMemoryDataAccessor.ts
index 33c38aba6..99ee86a35 100644
--- a/src/storage/accessors/InMemoryDataAccessor.ts
+++ b/src/storage/accessors/InMemoryDataAccessor.ts
@@ -23,14 +23,16 @@ type CacheEntry = DataEntry | ContainerEntry;
 
 export class InMemoryDataAccessor implements DataAccessor {
   private readonly base: string;
-  private readonly store: ContainerEntry;
+  // A dummy container with one entry which corresponds to the base
+  private readonly store: { entries: { ''?: ContainerEntry } };
 
   public constructor(base: string) {
     this.base = ensureTrailingSlash(base);
 
     const metadata = new RepresentationMetadata({ path: this.base });
     metadata.addQuads(generateResourceQuads(DataFactory.namedNode(this.base), true));
-    this.store = { entries: {}, metadata };
+    const rootContainer = { entries: {}, metadata };
+    this.store = { entries: { '': rootContainer }};
   }
 
   public async canHandle(): Promise<void> {
@@ -96,12 +98,16 @@ export class InMemoryDataAccessor implements DataAccessor {
   }
 
   private getParentEntry(identifier: ResourceIdentifier): { parent: ContainerEntry; name: string } {
-    const parts = identifier.path.slice(this.base.length).split('/').filter((part): boolean => part.length > 0);
-
-    if (parts.length === 0) {
-      throw new Error('Root container has no parent.');
+    if (identifier.path === this.base) {
+      // Casting is fine here as the parent should never be used as a real container
+      return { parent: this.store as any, name: '' };
+    }
+    if (!this.store.entries['']) {
+      throw new NotFoundHttpError();
     }
 
+    const parts = identifier.path.slice(this.base.length).split('/').filter((part): boolean => part.length > 0);
+
     // Name of the resource will be the last entry in the path
     const name = parts[parts.length - 1];
 
@@ -109,7 +115,8 @@ export class InMemoryDataAccessor implements DataAccessor {
     const containers = parts.slice(0, -1);
 
     // Step through the parts of the path up to the end
-    let parent = this.store;
+    // First entry is guaranteed to be a ContainerEntry
+    let parent = this.store.entries[''];
     for (const container of containers) {
       const child = parent.entries[container];
       if (!child) {
@@ -124,9 +131,6 @@ export class InMemoryDataAccessor implements DataAccessor {
   }
 
   private getEntry(identifier: ResourceIdentifier): CacheEntry {
-    if (identifier.path === this.base) {
-      return this.store;
-    }
     const { parent, name } = this.getParentEntry(identifier);
     const entry = parent.entries[name];
     if (!entry) {
diff --git a/src/storage/accessors/SparqlDataAccessor.ts b/src/storage/accessors/SparqlDataAccessor.ts
index 785d14dae..3bb9f13fe 100644
--- a/src/storage/accessors/SparqlDataAccessor.ts
+++ b/src/storage/accessors/SparqlDataAccessor.ts
@@ -113,7 +113,7 @@ export class SparqlDataAccessor implements DataAccessor {
    */
   public async writeContainer(identifier: ResourceIdentifier, metadata: RepresentationMetadata): Promise<void> {
     const { name, parent } = this.getRelatedNames(identifier);
-    return this.sendSparqlUpdate(this.sparqlInsert(name, parent, metadata));
+    return this.sendSparqlUpdate(this.sparqlInsert(name, metadata, parent));
   }
 
   /**
@@ -135,7 +135,7 @@ export class SparqlDataAccessor implements DataAccessor {
     // Not relevant since all content is triples
     metadata.removeAll(CONTENT_TYPE);
 
-    return this.sendSparqlUpdate(this.sparqlInsert(name, parent, metadata, triples));
+    return this.sendSparqlUpdate(this.sparqlInsert(name, metadata, parent, triples));
   }
 
   /**
@@ -148,10 +148,17 @@ export class SparqlDataAccessor implements DataAccessor {
 
   /**
    * Helper function to get named nodes corresponding to the identifier and its parent container.
+   * In case of a root container only the name will be returned.
    */
-  private getRelatedNames(identifier: ResourceIdentifier): { name: NamedNode; parent: NamedNode } {
-    const parentIdentifier = this.identifierStrategy.getParentContainer(identifier);
+  private getRelatedNames(identifier: ResourceIdentifier): { name: NamedNode; parent?: NamedNode } {
     const name = namedNode(identifier.path);
+
+    // Root containers don't have a parent
+    if (this.identifierStrategy.isRootContainer(identifier)) {
+      return { name };
+    }
+
+    const parentIdentifier = this.identifierStrategy.getParentContainer(identifier);
     const parent = namedNode(parentIdentifier.path);
     return { name, parent };
   }
@@ -219,14 +226,15 @@ export class SparqlDataAccessor implements DataAccessor {
    * @param metadata - New metadata of the resource.
    * @param triples - New data of the resource.
    */
-  private sparqlInsert(name: NamedNode, parent: NamedNode, metadata: RepresentationMetadata, triples?: Quad[]): Update {
+  private sparqlInsert(name: NamedNode, metadata: RepresentationMetadata, parent?: NamedNode, triples?: Quad[]):
+  Update {
     const metaName = this.getMetadataNode(name);
 
     // Insert new metadata and containment triple
-    const insert: GraphQuads[] = [
-      this.sparqlUpdateGraph(metaName, metadata.quads()),
-      this.sparqlUpdateGraph(parent, [ quad(parent, toNamedNode(LDP.contains), name) ]),
-    ];
+    const insert: GraphQuads[] = [ this.sparqlUpdateGraph(metaName, metadata.quads()) ];
+    if (parent) {
+      insert.push(this.sparqlUpdateGraph(parent, [ quad(parent, toNamedNode(LDP.contains), name) ]));
+    }
 
     // Necessary updates: delete metadata and insert new data
     const updates: UpdateOperation[] = [
@@ -256,21 +264,26 @@ export class SparqlDataAccessor implements DataAccessor {
   /**
    * Creates a query that deletes everything related to the given name.
    * @param name - Name of resource to delete.
-   * @param parent - Parent of the resource to delete so containment triple can be removed.
+   * @param parent - Parent of the resource to delete so the containment triple can be removed (unless root).
    */
-  private sparqlDelete(name: NamedNode, parent: NamedNode): Update {
-    return {
+  private sparqlDelete(name: NamedNode, parent?: NamedNode): Update {
+    const update: Update = {
       updates: [
         this.sparqlUpdateDeleteAll(name),
         this.sparqlUpdateDeleteAll(this.getMetadataNode(name)),
-        {
-          updateType: 'delete',
-          delete: [ this.sparqlUpdateGraph(parent, [ quad(parent, toNamedNode(LDP.contains), name) ]) ],
-        },
       ],
       type: 'update',
       prefixes: {},
     };
+
+    if (parent) {
+      update.updates.push({
+        updateType: 'delete',
+        delete: [ this.sparqlUpdateGraph(parent, [ quad(parent, toNamedNode(LDP.contains), name) ]) ],
+      });
+    }
+
+    return update;
   }
 
   /**


Test patch:
diff --git a/test/unit/authentication/UnsecureConstantCredentialsExtractor.test.ts b/test/unit/authentication/UnsecureConstantCredentialsExtractor.test.ts
new file mode 100644
index 000000000..67e20e08a
--- /dev/null
+++ b/test/unit/authentication/UnsecureConstantCredentialsExtractor.test.ts
@@ -0,0 +1,15 @@
+import { UnsecureConstantCredentialsExtractor } from '../../../src/authentication/UnsecureConstantCredentialsExtractor';
+
+describe('An UnsecureConstantCredentialsExtractor', (): void => {
+  it('extracts a constant WebID.', async(): Promise<void> => {
+    const agent = 'http://alice.example/card#me';
+    const extractor = new UnsecureConstantCredentialsExtractor(agent);
+    await expect(extractor.handle()).resolves.toEqual({ webId: agent });
+  });
+
+  it('extracts constant credentials.', async(): Promise<void> => {
+    const agent = {};
+    const extractor = new UnsecureConstantCredentialsExtractor(agent);
+    await expect(extractor.handle()).resolves.toBe(agent);
+  });
+});
diff --git a/test/unit/storage/accessors/FileDataAccessor.test.ts b/test/unit/storage/accessors/FileDataAccessor.test.ts
index 4e87a6aaa..769313d74 100644
--- a/test/unit/storage/accessors/FileDataAccessor.test.ts
+++ b/test/unit/storage/accessors/FileDataAccessor.test.ts
@@ -287,6 +287,12 @@ describe('A FileDataAccessor', (): void => {
       await expect(accessor.writeContainer({ path: `${base}container/` }, metadata)).resolves.toBeUndefined();
       expect(cache.data.container).toEqual({});
     });
+
+    it('can write to the root container.', async(): Promise<void> => {
+      metadata = new RepresentationMetadata({ path: `${base}` }, { likes: 'apples' });
+      await expect(accessor.writeContainer({ path: `${base}` }, metadata)).resolves.toBeUndefined();
+      expect(cache.data).toEqual({ '.meta': expect.stringMatching(`<${base}> <likes> "apples".`) });
+    });
   });
 
   describe('deleting a resource', (): void => {
@@ -334,5 +340,11 @@ describe('A FileDataAccessor', (): void => {
       await expect(accessor.deleteResource({ path: `${base}container/` })).resolves.toBeUndefined();
       expect(cache.data.container).toBeUndefined();
     });
+
+    it('can delete the root container.', async(): Promise<void> => {
+      cache.data = { };
+      await expect(accessor.deleteResource({ path: `${base}` })).resolves.toBeUndefined();
+      expect(cache.data).toBeUndefined();
+    });
   });
 });
diff --git a/test/unit/storage/accessors/InMemoryDataAccessor.test.ts b/test/unit/storage/accessors/InMemoryDataAccessor.test.ts
index cfecdf0e3..8c61102d9 100644
--- a/test/unit/storage/accessors/InMemoryDataAccessor.test.ts
+++ b/test/unit/storage/accessors/InMemoryDataAccessor.test.ts
@@ -38,12 +38,12 @@ describe('An InMemoryDataAccessor', (): void => {
     });
 
     it('throws an error if part of the path matches a document.', async(): Promise<void> => {
-      await accessor.writeDocument({ path: `${base}resource` }, data, metadata);
+      await expect(accessor.writeDocument({ path: `${base}resource` }, data, metadata)).resolves.toBeUndefined();
       await expect(accessor.getData({ path: `${base}resource/resource2` })).rejects.toThrow(new Error('Invalid path.'));
     });
 
     it('returns the corresponding data every time.', async(): Promise<void> => {
-      await accessor.writeDocument({ path: `${base}resource` }, data, metadata);
+      await expect(accessor.writeDocument({ path: `${base}resource` }, data, metadata)).resolves.toBeUndefined();
 
       // Run twice to make sure the data is stored correctly
       await expect(readableToString(await accessor.getData({ path: `${base}resource` }))).resolves.toBe('data');
@@ -56,29 +56,25 @@ describe('An InMemoryDataAccessor', (): void => {
       await expect(accessor.getMetadata({ path: `${base}resource` })).rejects.toThrow(NotFoundHttpError);
     });
 
-    it('errors when trying to access the parent of root.', async(): Promise<void> => {
-      await expect(accessor.writeDocument({ path: `${base}` }, data, metadata))
-        .rejects.toThrow(new Error('Root container has no parent.'));
-    });
-
     it('throws a 404 if the trailing slash does not match its type.', async(): Promise<void> => {
-      await accessor.writeDocument({ path: `${base}resource` }, data, metadata);
+      await expect(accessor.writeDocument({ path: `${base}resource` }, data, metadata)).resolves.toBeUndefined();
       await expect(accessor.getMetadata({ path: `${base}resource/` })).rejects.toThrow(NotFoundHttpError);
-      await accessor.writeContainer({ path: `${base}container/` }, metadata);
+      await expect(accessor.writeContainer({ path: `${base}container/` }, metadata)).resolves.toBeUndefined();
       await expect(accessor.getMetadata({ path: `${base}container` })).rejects.toThrow(NotFoundHttpError);
     });
 
     it('returns empty metadata if there was none stored.', async(): Promise<void> => {
       metadata = new RepresentationMetadata();
-      await accessor.writeDocument({ path: `${base}resource` }, data, metadata);
+      await expect(accessor.writeDocument({ path: `${base}resource` }, data, metadata)).resolves.toBeUndefined();
       metadata = await accessor.getMetadata({ path: `${base}resource` });
       expect(metadata.quads()).toHaveLength(0);
     });
 
     it('generates the containment metadata for a container.', async(): Promise<void> => {
-      await accessor.writeContainer({ path: `${base}container/` }, metadata);
-      await accessor.writeDocument({ path: `${base}container/resource` }, data, metadata);
-      await accessor.writeContainer({ path: `${base}container/container2` }, metadata);
+      await expect(accessor.writeContainer({ path: `${base}container/` }, metadata)).resolves.toBeUndefined();
+      await expect(accessor.writeDocument({ path: `${base}container/resource` }, data, metadata))
+        .resolves.toBeUndefined();
+      await expect(accessor.writeContainer({ path: `${base}container/container2` }, metadata)).resolves.toBeUndefined();
       metadata = await accessor.getMetadata({ path: `${base}container/` });
       expect(metadata.getAll(LDP.contains)).toEqualRdfTermArray(
         [ toNamedNode(`${base}container/resource`), toNamedNode(`${base}container/container2/`) ],
@@ -88,7 +84,7 @@ describe('An InMemoryDataAccessor', (): void => {
     it('adds stored metadata when requesting document metadata.', async(): Promise<void> => {
       const identifier = { path: `${base}resource` };
       const inputMetadata = new RepresentationMetadata(identifier, { [RDF.type]: toNamedNode(LDP.Resource) });
-      await accessor.writeDocument(identifier, data, inputMetadata);
+      await expect(accessor.writeDocument(identifier, data, inputMetadata)).resolves.toBeUndefined();
       metadata = await accessor.getMetadata(identifier);
       expect(metadata.identifier.value).toBe(`${base}resource`);
       const quads = metadata.quads();
@@ -99,7 +95,7 @@ describe('An InMemoryDataAccessor', (): void => {
     it('adds stored metadata when requesting container metadata.', async(): Promise<void> => {
       const identifier = { path: `${base}container/` };
       const inputMetadata = new RepresentationMetadata(identifier, { [RDF.type]: toNamedNode(LDP.Container) });
-      await accessor.writeContainer(identifier, inputMetadata);
+      await expect(accessor.writeContainer(identifier, inputMetadata)).resolves.toBeUndefined();
 
       metadata = await accessor.getMetadata(identifier);
       expect(metadata.identifier.value).toBe(`${base}container/`);
@@ -111,15 +107,15 @@ describe('An InMemoryDataAccessor', (): void => {
     it('can overwrite the metadata of an existing container without overwriting children.', async(): Promise<void> => {
       const identifier = { path: `${base}container/` };
       const inputMetadata = new RepresentationMetadata(identifier, { [RDF.type]: toNamedNode(LDP.Container) });
-      await accessor.writeContainer(identifier, inputMetadata);
+      await expect(accessor.writeContainer(identifier, inputMetadata)).resolves.toBeUndefined();
       const resourceMetadata = new RepresentationMetadata();
-      await accessor.writeDocument(
+      await expect(accessor.writeDocument(
         { path: `${base}container/resource` }, data, resourceMetadata,
-      );
+      )).resolves.toBeUndefined();
 
       const newMetadata = new RepresentationMetadata(inputMetadata);
       newMetadata.add(RDF.type, toNamedNode(LDP.BasicContainer));
-      await accessor.writeContainer(identifier, newMetadata);
+      await expect(accessor.writeContainer(identifier, newMetadata)).resolves.toBeUndefined();
 
       metadata = await accessor.getMetadata(identifier);
       expect(metadata.identifier.value).toBe(`${base}container/`);
@@ -134,8 +130,29 @@ describe('An InMemoryDataAccessor', (): void => {
       expect(await readableToString(await accessor.getData({ path: `${base}container/resource` }))).toBe('data');
     });
 
+    it('can write to the root container without overriding its children.', async(): Promise<void> => {
+      const identifier = { path: `${base}` };
+      const inputMetadata = new RepresentationMetadata(identifier, { [RDF.type]: toNamedNode(LDP.Container) });
+      await expect(accessor.writeContainer(identifier, inputMetadata)).resolves.toBeUndefined();
+      const resourceMetadata = new RepresentationMetadata();
+      await expect(accessor.writeDocument(
+        { path: `${base}resource` }, data, resourceMetadata,
+      )).resolves.toBeUndefined();
+
+      metadata = await accessor.getMetadata(identifier);
+      expect(metadata.identifier.value).toBe(`${base}`);
+      const quads = metadata.quads();
+      expect(quads).toHaveLength(2);
+      expect(metadata.getAll(RDF.type)).toHaveLength(1);
+      expect(metadata.getAll(LDP.contains)).toHaveLength(1);
+
+      await expect(accessor.getMetadata({ path: `${base}resource` }))
+        .resolves.toBeInstanceOf(RepresentationMetadata);
+      expect(await readableToString(await accessor.getData({ path: `${base}resource` }))).toBe('data');
+    });
+
     it('errors when writing to an invalid container path..', async(): Promise<void> => {
-      await accessor.writeDocument({ path: `${base}resource` }, data, metadata);
+      await expect(accessor.writeDocument({ path: `${base}resource` }, data, metadata)).resolves.toBeUndefined();
 
       await expect(accessor.writeContainer({ path: `${base}resource/container` }, metadata))
         .rejects.toThrow(new Error('Invalid path.'));
@@ -148,12 +165,21 @@ describe('An InMemoryDataAccessor', (): void => {
     });
 
     it('removes the corresponding resource.', async(): Promise<void> => {
-      await accessor.writeDocument({ path: `${base}resource` }, data, metadata);
-      await accessor.writeContainer({ path: `${base}container/` }, metadata);
+      await expect(accessor.writeDocument({ path: `${base}resource` }, data, metadata)).resolves.toBeUndefined();
+      await expect(accessor.writeContainer({ path: `${base}container/` }, metadata)).resolves.toBeUndefined();
       await expect(accessor.deleteResource({ path: `${base}resource` })).resolves.toBeUndefined();
       await expect(accessor.deleteResource({ path: `${base}container/` })).resolves.toBeUndefined();
       await expect(accessor.getMetadata({ path: `${base}resource` })).rejects.toThrow(NotFoundHttpError);
       await expect(accessor.getMetadata({ path: `${base}container/` })).rejects.toThrow(NotFoundHttpError);
     });
+
+    it('can delete the root container and write to it again.', async(): Promise<void> => {
+      await expect(accessor.deleteResource({ path: `${base}` })).resolves.toBeUndefined();
+      await expect(accessor.getMetadata({ path: `${base}` })).rejects.toThrow(NotFoundHttpError);
+      await expect(accessor.getMetadata({ path: `${base}test/` })).rejects.toThrow(NotFoundHttpError);
+      await expect(accessor.writeContainer({ path: `${base}` }, metadata)).resolves.toBeUndefined();
+      const resultMetadata = await accessor.getMetadata({ path: `${base}` });
+      expect(resultMetadata.quads()).toBeRdfIsomorphic(metadata.quads());
+    });
   });
 });
diff --git a/test/unit/storage/accessors/SparqlDataAccessor.test.ts b/test/unit/storage/accessors/SparqlDataAccessor.test.ts
index 935c68780..4e4f3f8bb 100644
--- a/test/unit/storage/accessors/SparqlDataAccessor.test.ts
+++ b/test/unit/storage/accessors/SparqlDataAccessor.test.ts
@@ -172,6 +172,24 @@ describe('A SparqlDataAccessor', (): void => {
     ]));
   });
 
+  it('does not write containment triples when writing to a root container.', async(): Promise<void> => {
+    metadata = new RepresentationMetadata({ path: 'http://test.com/' },
+      { [RDF.type]: [ toNamedNode(LDP.Resource), toNamedNode(LDP.Container) ]});
+    await expect(accessor.writeContainer({ path: 'http://test.com/' }, metadata)).resolves.toBeUndefined();
+
+    expect(fetchUpdate).toHaveBeenCalledTimes(1);
+    expect(fetchUpdate.mock.calls[0][0]).toBe(endpoint);
+    expect(simplifyQuery(fetchUpdate.mock.calls[0][1])).toBe(simplifyQuery([
+      'DELETE WHERE { GRAPH <meta:http://test.com/> { ?s ?p ?o. } };',
+      'INSERT DATA {',
+      '  GRAPH <meta:http://test.com/> {',
+      '    <http://test.com/> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <http://www.w3.org/ns/ldp#Resource>.',
+      '    <http://test.com/> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <http://www.w3.org/ns/ldp#Container>.',
+      '  }',
+      '}',
+    ]));
+  });
+
   it('overwrites the data and metadata when writing a resource and updates parent.', async(): Promise<void> => {
     metadata = new RepresentationMetadata({ path: 'http://test.com/container/resource' },
       { [RDF.type]: [ toNamedNode(LDP.Resource) ]});
@@ -224,6 +242,19 @@ describe('A SparqlDataAccessor', (): void => {
     ]));
   });
 
+  it('does not try to remove containment triples when deleting a root container.', async(): Promise<void> => {
+    metadata = new RepresentationMetadata({ path: 'http://test.com/' },
+      { [RDF.type]: [ toNamedNode(LDP.Resource), toNamedNode(LDP.Container) ]});
+    await expect(accessor.deleteResource({ path: 'http://test.com/' })).resolves.toBeUndefined();
+
+    expect(fetchUpdate).toHaveBeenCalledTimes(1);
+    expect(fetchUpdate.mock.calls[0][0]).toBe(endpoint);
+    expect(simplifyQuery(fetchUpdate.mock.calls[0][1])).toBe(simplifyQuery([
+      'DELETE WHERE { GRAPH <http://test.com/> { ?s ?p ?o. } };',
+      'DELETE WHERE { GRAPH <meta:http://test.com/> { ?s ?p ?o. } }',
+    ]));
+  });
+
   it('errors when trying to write to a metadata document.', async(): Promise<void> => {
     await expect(accessor.writeDocument({ path: 'meta:http://test.com/container/resource' }, data, metadata))
       .rejects.toThrow(new ConflictHttpError('Not allowed to create NamedNodes with the metadata extension.'));