import 'jest-rdf'; import { fetch } from 'cross-fetch'; import { Parser } from 'n3'; import type { AclPermission } from '../../src/authorization/permissions/AclPermission'; import { BasicRepresentation } from '../../src/http/representation/BasicRepresentation'; import type { App } from '../../src/init/App'; import type { ResourceStore } from '../../src/storage/ResourceStore'; import { joinUrl } from '../../src/util/PathUtil'; import { AclHelper } from '../util/AclHelper'; import { getPort } from '../util/Util'; import { getDefaultVariables, getPresetConfigPath, getTestConfigPath, instantiateFromConfig, } from './Config'; const port = getPort('N3Patch'); const baseUrl = `http://localhost:${port}/`; let store: ResourceStore; let aclHelper: AclHelper; async function expectPatch( input: { path: string; contentType?: string; body: string }, expected: { status: number; message?: string; turtle?: string }, ): Promise { const message = expected.message ?? ''; const contentType = input.contentType ?? 'text/n3'; const body = `@prefix solid: . ${input.body}`; const url = joinUrl(baseUrl, input.path); const res = await fetch(url, { method: 'PATCH', headers: { 'content-type': contentType }, body, }); await expect(res.text()).resolves.toContain(message); expect(res.status).toBe(expected.status); // Verify if the resource has the expected RDF data if (expected.turtle) { // Might not have read permissions so need to update await aclHelper.setSimpleAcl(url, { permissions: { read: true }, agentClass: 'agent', accessTo: true }); const get = await fetch(url, { method: 'GET', headers: { accept: 'text/turtle' }, }); const expectedTurtle = `@prefix solid: . ${expected.turtle}`; expect(get.status).toBe(200); const parser = new Parser({ format: 'text/turtle', baseIRI: url }); const actualTriples = parser.parse(await get.text()); expect(actualTriples).toBeRdfIsomorphic(parser.parse(expectedTurtle)); } } // Creates/updates a resource with the given data and permissions async function setResource(path: string, turtle: string, permissions: AclPermission): Promise { const url = joinUrl(baseUrl, path); await store.setRepresentation({ path: url }, new BasicRepresentation(turtle, 'text/turtle')); await aclHelper.setSimpleAcl(url, { permissions, agentClass: 'agent', accessTo: true }); } describe('A Server supporting N3 Patch', (): void => { let app: App; beforeAll(async(): Promise => { // Create and start the server const instances = await instantiateFromConfig( 'urn:solid-server:test:Instances', [ getPresetConfigPath('storage/backend/memory.json'), getTestConfigPath('ldp-with-auth.json'), ], getDefaultVariables(port, baseUrl), ) as Record; ({ app, store } = instances); await app.start(); // Create test helper for manipulating acl aclHelper = new AclHelper(store); }); afterAll(async(): Promise => { await app.stop(); }); describe('with an invalid patch document', (): void => { it('requires text/n3 content-type.', async(): Promise => { await expectPatch( { path: '/invalid', contentType: 'text/other', body: '' }, { status: 415 }, ); }); it('requires valid syntax.', async(): Promise => { await expectPatch( { path: '/invalid', body: 'invalid syntax' }, { status: 400, message: 'Invalid N3' }, ); }); it('requires a solid:InsertDeletePatch.', async(): Promise => { await expectPatch( { path: '/invalid', body: '<> a solid:Patch.' }, { status: 422, message: 'This patcher only supports N3 Patch documents with exactly 1 solid:InsertDeletePatch entry', }, ); }); }); describe('inserting data', (): void => { it('succeeds if there is no resource.', async(): Promise => { await expectPatch( { path: '/new-insert', body: `<> a solid:InsertDeletePatch; solid:inserts { . }.` }, { status: 201, turtle: ' .' }, ); }); it('fails if there is only read access.', async(): Promise => { await setResource('/read-only', ' .', { read: true }); await expectPatch( { path: '/read-only', body: `<> a solid:InsertDeletePatch; solid:inserts { . }.` }, { status: 401 }, ); }); it('succeeds if there is only read access.', async(): Promise => { await setResource('/append-only', ' .', { append: true }); await expectPatch( { path: '/append-only', body: `<> a solid:InsertDeletePatch; solid:inserts { . }.` }, { status: 205, turtle: ' . .' }, ); }); it('succeeds if there is only write access.', async(): Promise => { await setResource('/write-only', ' .', { write: true }); await expectPatch( { path: '/write-only', body: `<> a solid:InsertDeletePatch; solid:inserts { . }.` }, { status: 205, turtle: ' . .' }, ); }); }); describe('inserting conditional data', (): void => { it('fails if there is no resource.', async(): Promise => { await expectPatch( { path: '/new-insert-where', body: `<> a solid:InsertDeletePatch; solid:inserts { ?a . }; solid:where { ?a . }.` }, { status: 409, message: 'The document does not contain any matches for the N3 Patch solid:where condition.' }, ); }); it('fails if there is only read access.', async(): Promise => { await setResource('/read-only', ' .', { read: true }); await expectPatch( { path: '/read-only', body: `<> a solid:InsertDeletePatch; solid:inserts { ?a . }; solid:where { ?a . }.` }, { status: 401 }, ); }); it('fails if there is only append access.', async(): Promise => { await setResource('/append-only', ' .', { append: true }); await expectPatch( { path: '/append-only', body: `<> a solid:InsertDeletePatch; solid:inserts { ?a . }; solid:where { ?a . }.` }, { status: 401 }, ); }); it('fails if there is only write access.', async(): Promise => { await setResource('/write-only', ' .', { write: true }); await expectPatch( { path: '/write-only', body: `<> a solid:InsertDeletePatch; solid:inserts { ?a . }; solid:where { ?a . }.` }, { status: 401 }, ); }); describe('with read/append access', (): void => { it('succeeds if the conditions match.', async(): Promise => { await setResource('/read-append', ' .', { read: true, append: true }); await expectPatch( { path: '/read-append', body: `<> a solid:InsertDeletePatch; solid:inserts { ?a . }; solid:where { ?a . }.` }, { status: 205, turtle: ' . .' }, ); }); it('rejects if there is no match.', async(): Promise => { await setResource('/read-append', ' .', { read: true, append: true }); await expectPatch( { path: '/read-append', body: `<> a solid:InsertDeletePatch; solid:inserts { ?a . }; solid:where { ?a . }.` }, { status: 409, message: 'The document does not contain any matches for the N3 Patch solid:where condition.' }, ); }); it('rejects if there are multiple matches.', async(): Promise => { await setResource('/read-append', ' . .', { read: true, append: true }); await expectPatch( { path: '/read-append', body: `<> a solid:InsertDeletePatch; solid:inserts { ?a . }; solid:where { ?a . }.` }, { status: 409, message: 'The document contains multiple matches for the N3 Patch solid:where condition' }, ); }); }); describe('with read/write access', (): void => { it('succeeds if the conditions match.', async(): Promise => { await setResource('/read-write', ' .', { read: true, write: true }); await expectPatch( { path: '/read-write', body: `<> a solid:InsertDeletePatch; solid:inserts { ?a . }; solid:where { ?a . }.` }, { status: 205, turtle: ' . .' }, ); }); }); }); describe('deleting data', (): void => { it('fails if there is no resource.', async(): Promise => { await expectPatch( { path: '/new-delete', body: `<> a solid:InsertDeletePatch; solid:deletes { . }.` }, { status: 409, message: 'The document does not contain all triples the N3 Patch requests to delete' }, ); }); it('fails if there is only append access.', async(): Promise => { await setResource('/append-only', ' .', { append: true }); await expectPatch( { path: '/append-only', body: `<> a solid:InsertDeletePatch; solid:deletes { . }.` }, { status: 401 }, ); }); it('fails if there is only write access.', async(): Promise => { await setResource('/write-only', ' .', { write: true }); await expectPatch( { path: '/write-only', body: `<> a solid:InsertDeletePatch; solid:deletes { . }.` }, { status: 401 }, ); }); it('fails if there is only read/append access.', async(): Promise => { await setResource('/read-append', ' .', { read: true, append: true }); await expectPatch( { path: '/read-append', body: `<> a solid:InsertDeletePatch; solid:deletes { . }.` }, { status: 401 }, ); }); describe('with read/write access', (): void => { it('succeeds if the delete triples exist.', async(): Promise => { await setResource('/read-write', ' . .', { read: true, write: true }); await expectPatch( { path: '/read-write', body: `<> a solid:InsertDeletePatch; solid:deletes { . }.` }, { status: 205, turtle: ' .' }, ); }); it('fails if the delete triples do not exist.', async(): Promise => { await setResource('/read-write', ' . .', { read: true, write: true }); await expectPatch( { path: '/read-write', body: `<> a solid:InsertDeletePatch; solid:deletes { . }.` }, { status: 409, message: 'The document does not contain all triples the N3 Patch requests to delete' }, ); }); it('succeeds if the conditions match.', async(): Promise => { await setResource('/read-write', ' . .', { read: true, write: true }); await expectPatch( { path: '/read-write', body: `<> a solid:InsertDeletePatch; solid:where { ?a . }; solid:deletes { ?a . }.` }, { status: 205, turtle: ' .' }, ); }); it('fails if the conditions do not match.', async(): Promise => { await setResource('/read-write', ' .', { read: true, write: true }); await expectPatch( { path: '/read-write', body: `<> a solid:InsertDeletePatch; solid:where { ?a . }; solid:deletes { ?a . }.` }, { status: 409, message: 'The document does not contain any matches for the N3 Patch solid:where condition.' }, ); }); }); }); describe('deleting and inserting data', (): void => { it('fails if there is no resource.', async(): Promise => { await expectPatch( { path: '/new-delete-insert', body: `<> a solid:InsertDeletePatch; solid:inserts { . }; solid:deletes { . }.` }, { status: 409, message: 'The document does not contain all triples the N3 Patch requests to delete' }, ); }); it('fails if there is only append access.', async(): Promise => { await setResource('/append-only', ' .', { append: true }); await expectPatch( { path: '/append-only', body: `<> a solid:InsertDeletePatch; solid:inserts { . }; solid:deletes { . }.` }, { status: 401 }, ); }); it('fails if there is only write access.', async(): Promise => { await setResource('/write-only', ' .', { write: true }); await expectPatch( { path: '/write-only', body: `<> a solid:InsertDeletePatch; solid:inserts { . }; solid:deletes { . }.` }, { status: 401 }, ); }); it('fails if there is only read/append access.', async(): Promise => { await setResource('/read-append', ' .', { read: true, append: true }); await expectPatch( { path: '/read-append', body: `<> a solid:InsertDeletePatch; solid:inserts { . }; solid:deletes { . }.` }, { status: 401 }, ); }); describe('with read/write access', (): void => { it('executes deletes before inserts.', async(): Promise => { await setResource('/read-write', ' .', { read: true, write: true }); await expectPatch( { path: '/read-write', body: `<> a solid:InsertDeletePatch; solid:inserts { . }; solid:deletes { . }.` }, { status: 409, message: 'The document does not contain all triples the N3 Patch requests to delete' }, ); }); it('succeeds if the delete triples exist.', async(): Promise => { await setResource('/read-write', ' .', { read: true, write: true }); await expectPatch( { path: '/read-write', body: `<> a solid:InsertDeletePatch; solid:inserts { . }; solid:deletes { . }.` }, { status: 205, turtle: ' .' }, ); }); it('succeeds if the conditions match.', async(): Promise => { await setResource('/read-write', ' .', { read: true, write: true }); await expectPatch( { path: '/read-write', body: `<> a solid:InsertDeletePatch; solid:where { ?a . }; solid:inserts { ?a . }; solid:deletes { ?a . }.` }, { status: 205, turtle: ' .' }, ); }); it('fails if the conditions do not match.', async(): Promise => { await setResource('/read-write', ' .', { read: true, write: true }); await expectPatch( { path: '/read-write', body: `<> a solid:InsertDeletePatch; solid:where { ?a . }; solid:inserts { ?a . }; solid:deletes { ?a . }.` }, { status: 409, message: 'The document does not contain any matches for the N3 Patch solid:where condition.' }, ); }); }); }); });