From bda034a43edcc1fdd6c30a22e6022dd3787150d5 Mon Sep 17 00:00:00 2001 From: Rahul Gupta Date: Tue, 8 Oct 2024 08:10:09 +0530 Subject: [PATCH] feat: Add Tests for PREP --- package-lock.json | 69 ++++++++++ package.json | 6 +- test/integration/prep-test.js | 228 ++++++++++++++++++++++++++++++++++ 3 files changed, 302 insertions(+), 1 deletion(-) create mode 100644 test/integration/prep-test.js diff --git a/package-lock.json b/package-lock.json index 745f73e6..455826a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -70,6 +70,7 @@ "solid": "bin/solid" }, "devDependencies": { + "@cxres/structured-headers": "^2.0.0-alpha.1-nesting.0", "@solid/solid-auth-oidc": "0.3.0", "c8": "^10.1.2", "chai": "^4.4.1", @@ -82,6 +83,7 @@ "nock": "^13.5.4", "node-mocks-http": "^1.14.1", "pre-commit": "1.2.2", + "prep-fetch": "^0.1.0", "randombytes": "2.1.0", "sinon": "12.0.1", "sinon-chai": "3.7.0", @@ -2873,6 +2875,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@cxres/structured-headers": { + "version": "2.0.0-alpha.1-nesting.0", + "resolved": "https://registry.npmjs.org/@cxres/structured-headers/-/structured-headers-2.0.0-alpha.1-nesting.0.tgz", + "integrity": "sha512-MYHRF2oS3Zvumh3swXs//GDGNe6L+sYnwplitC8Ns8CFQro+WKCidDrgk+suLq2Wm6e0ohIvEWqB8VM1mqbR8g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18", + "npm": ">=6" + } + }, "node_modules/@digitalbazaar/http-client": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/@digitalbazaar/http-client/-/http-client-3.4.1.tgz", @@ -5859,6 +5872,13 @@ "react-native": "*" } }, + "node_modules/@sec-ant/readable-stream": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", + "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", + "dev": true, + "license": "MIT" + }, "node_modules/@segment/loosely-validate-event": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@segment/loosely-validate-event/-/loosely-validate-event-2.0.0.tgz", @@ -16129,6 +16149,21 @@ "resolved": "https://registry.npmjs.org/msrcrypto/-/msrcrypto-1.5.8.tgz", "integrity": "sha512-ujZ0TRuozHKKm6eGbKHfXef7f+esIhEckmThVnz7RNyiOJd7a6MXj2JGBoL9cnPDW+JMG16MoTUh5X+XXjI66Q==" }, + "node_modules/multipart-fetch": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/multipart-fetch/-/multipart-fetch-0.1.1.tgz", + "integrity": "sha512-CgkvfFI6owa28eK8ctdkyKauUwTMJUogwuiY7KOKZaXRxLmmBRaP9YJ2mFisYglKAxMZnoGrBfPJn+jDTCiOfA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "content-type": "^1.0.5", + "streamsearch-web": "^1.0.0" + }, + "engines": { + "node": ">20.6" + } + }, "node_modules/mute-stream": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", @@ -19976,6 +20011,31 @@ "node": ">= 0.8.0" } }, + "node_modules/prep-fetch": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/prep-fetch/-/prep-fetch-0.1.0.tgz", + "integrity": "sha512-11fKs96FHue4VOP2CeOxV5TPEOb0eVfC+2wQ6CbTQ79oG2lVUBZIp2WWxVKv27/iCWz93Fd2u53A71skFezyeQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "multipart-fetch": "^0.1.0", + "structured-headers": "^1.0.1" + }, + "engines": { + "node": ">20.6" + } + }, + "node_modules/prep-fetch/node_modules/structured-headers": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/structured-headers/-/structured-headers-1.0.1.tgz", + "integrity": "sha512-QYBxdBtA4Tl5rFPuqmbmdrS9kbtren74RTJTcs0VSQNVV5iRhJD4QlYTLD0+81SBwUQctjEQzjTRI3WG4DzICA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14", + "npm": ">=6" + } + }, "node_modules/pretty-bytes": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", @@ -22684,6 +22744,15 @@ "node": ">= 0.10.0" } }, + "node_modules/streamsearch-web": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/streamsearch-web/-/streamsearch-web-1.0.0.tgz", + "integrity": "sha512-KBBU/O/xSjbr1z+NPwLE9iTrE3Pc/Ue7HumjvjjP1t7oYIM35OOMYRy/lZBoIwsiSKTnQ+uF8QbaJEa7FdJIzA==", + "dev": true, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", diff --git a/package.json b/package.json index d5e14072..67448b57 100644 --- a/package.json +++ b/package.json @@ -117,6 +117,7 @@ "vhost": "^3.0.2" }, "devDependencies": { + "@cxres/structured-headers": "^2.0.0-alpha.1-nesting.0", "@solid/solid-auth-oidc": "0.3.0", "c8": "^10.1.2", "chai": "^4.4.1", @@ -129,6 +130,7 @@ "nock": "^13.5.4", "node-mocks-http": "^1.14.1", "pre-commit": "1.2.2", + "prep-fetch": "^0.1.0", "randombytes": "2.1.0", "sinon": "12.0.1", "sinon-chai": "3.7.0", @@ -177,7 +179,9 @@ "before", "beforeEach", "describe", - "it" + "it", + "fetch", + "AbortController" ] }, "bin": { diff --git a/test/integration/prep-test.js b/test/integration/prep-test.js new file mode 100644 index 00000000..dceb5a2b --- /dev/null +++ b/test/integration/prep-test.js @@ -0,0 +1,228 @@ +const fs = require('fs') +const path = require('path') +const { expect } = require('chai') +const { parseDictionary } = require('structured-headers') +const prepFetch = require('prep-fetch').default +const { createServer } = require('../utils') + +const samplePath = path.join(__dirname, '../resources', 'sampleContainer') +const sampleFile = fs.readFileSync(path.join(samplePath, 'example1.ttl')) + +describe('Per Resource Events Protocol', function () { + let server + + before((done) => { + server = createServer({ + live: true, + dataBrowserPath: 'default', + root: path.join(__dirname, '../resources'), + auth: 'oidc', + webid: false + }) + server.listen(8443, done) + }) + + after(() => { + server.close() + }) + + it('should set `Accept-Events` header on a GET response with "prep"', + async function () { + const response = await fetch('http://localhost:8443/sampleContainer/example1.ttl') + expect(response.headers.get('Accept-Events')).to.match(/^"prep"/) + expect(response.status).to.equal(200) + } + ) + + it('should send an ordinary response, if `Accept-Events` header is not specified', + async function () { + const response = await fetch('http://localhost:8443/sampleContainer/example1.ttl') + expect(response.headers.get('Content-Type')).to.match(/text\/turtle/) + expect(response.headers.has('Events')).to.equal(false) + expect(response.status).to.equal(200) + }) + + describe('with prep response on container', async function () { + let response + let prepResponse + const controller = new AbortController() + const { signal } = controller + + it('should set headers correctly', async function () { + response = await fetch('http://localhost:8443/sampleContainer/', { + headers: { + 'Accept-Events': '"prep";accept=application/ld+json', + Accept: 'text/turtle' + }, + signal + }) + expect(response.status).to.equal(200) + expect(response.headers.get('Vary')).to.match(/Accept-Events/) + const eventsHeader = parseDictionary(response.headers.get('Events')) + expect(eventsHeader.get('protocol')?.[0]).to.equal('prep') + expect(eventsHeader.get('status')?.[0]).to.equal(200) + expect(eventsHeader.get('expires')?.[0]).to.be.a('string') + expect(response.headers.get('Content-Type')).to.match(/^multipart\/mixed/) + }) + + it('should send a representation as the first part, matching the content size on disk', + async function () { + prepResponse = prepFetch(response) + const representation = await prepResponse.getRepresentation() + expect(representation.headers.get('Content-Type')).to.match(/text\/turtle/) + await representation.text() + }) + + describe('should send notifications in the second part', async function () { + let notifications + let notificationsIterator + + it('when a contained resource is created', async function () { + notifications = await prepResponse.getNotifications() + notificationsIterator = notifications.notifications() + await fetch('http://localhost:8443/sampleContainer/example-prep.ttl', { + method: 'PUT', + headers: { + 'Content-Type': 'text/turtle' + }, + body: sampleFile + }) + const { value } = await notificationsIterator.next() + expect(value.headers.get('content-type')).to.match(/application\/ld\+json/) + const notification = await value.json() + expect(notification).to.haveOwnProperty('published') + expect(notification.type).to.equal('Add') + expect(notification.target).to.match(/sampleContainer\/example-prep\.ttl$/) + expect(notification.object).to.match(/sampleContainer\/$/) + }) + + it('when contained resource is modified', async function () { + await fetch('http://localhost:8443/sampleContainer/example-prep.ttl', { + method: 'PATCH', + headers: { + 'Content-Type': 'text/n3' + }, + body: `@prefix solid: . +<> a solid:InsertDeletePatch; +solid:inserts { . }.` + }) + const { value } = await notificationsIterator.next() + expect(value.headers.get('content-type')).to.match(/application\/ld\+json/) + const notification = await value.json() + expect(notification).to.haveOwnProperty('published') + expect(notification.type).to.equal('Update') + expect(notification.object).to.match(/sampleContainer\/$/) + }) + + it('when contained resource is deleted', + async function () { + await fetch('http://localhost:8443/sampleContainer/example-prep.ttl', { + method: 'DELETE' + }) + const { value } = await notificationsIterator.next() + expect(value.headers.get('content-type')).to.match(/application\/ld\+json/) + const notification = await value.json() + expect(notification).to.haveOwnProperty('published') + expect(notification.type).to.equal('Remove') + expect(notification.object).to.match(/sampleContainer\/$/) + }) + + it('when resource is created by POST', + async function () { + await fetch('http://localhost:8443/sampleContainer/', { + method: 'POST', + headers: { + slug: 'example-prep.ttl', + 'content-type': 'text/turtle' + }, + body: sampleFile + }) + const { value } = await notificationsIterator.next() + expect(value.headers.get('content-type')).to.match(/application\/ld\+json/) + const notification = await value.json() + expect(notification).to.haveOwnProperty('published') + expect(notification.type).to.equal('Update') + expect(notification.object).to.match(/sampleContainer\/$/) + controller.abort() + }) + }) + }) + + describe('with prep response on RDF resource', async function () { + let response + let prepResponse + + it('should set headers correctly', async function () { + response = await fetch('http://localhost:8443/sampleContainer/example-prep.ttl', { + headers: { + 'Accept-Events': '"prep";accept=application/ld+json', + Accept: 'text/n3' + } + }) + expect(response.status).to.equal(200) + expect(response.headers.get('Vary')).to.match(/Accept-Events/) + const eventsHeader = parseDictionary(response.headers.get('Events')) + expect(eventsHeader.get('protocol')?.[0]).to.equal('prep') + expect(eventsHeader.get('status')?.[0]).to.equal(200) + expect(eventsHeader.get('expires')?.[0]).to.be.a('string') + expect(response.headers.get('Content-Type')).to.match(/^multipart\/mixed/) + }) + + it('should send a representation as the first part, matching the content size on disk', + async function () { + prepResponse = prepFetch(response) + const representation = await prepResponse.getRepresentation() + expect(representation.headers.get('Content-Type')).to.match(/text\/n3/) + const blob = await representation.blob() + expect(function (done) { + const size = fs.statSync(path.join(__dirname, + '../resources/sampleContainer/example-prep.ttl')).size + if (blob.size !== size) { + return done(new Error('files are not of the same size')) + } + }) + }) + + describe('should send notifications in the second part', async function () { + let notifications + let notificationsIterator + + it('when modified with PATCH', async function () { + notifications = await prepResponse.getNotifications() + notificationsIterator = notifications.notifications() + await fetch('http://localhost:8443/sampleContainer/example-prep.ttl', { + method: 'PATCH', + headers: { + 'content-type': 'text/n3' + }, + body: `@prefix solid: . +<> a solid:InsertDeletePatch; +solid:inserts { . }.` + }) + const { value } = await notificationsIterator.next() + expect(value.headers.get('content-type')).to.match(/application\/ld\+json/) + const notification = await value.json() + expect(notification).to.haveOwnProperty('published') + expect(notification).to.haveOwnProperty('state') + expect(notification.type).to.equal('Update') + expect(notification.object).to.match(/sampleContainer\/example-prep\.ttl$/) + }) + + it('when removed with DELETE, it should also close the connection', + async function () { + await fetch('http://localhost:8443/sampleContainer/example-prep.ttl', { + method: 'DELETE' + }) + const { value } = await notificationsIterator.next() + expect(value.headers.get('content-type')).to.match(/application\/ld\+json/) + const notification = await value.json() + expect(notification).to.haveOwnProperty('published') + expect(notification).to.haveOwnProperty('state') + expect(notification.type).to.equal('Delete') + expect(notification.object).to.match(/sampleContainer\/example-prep\.ttl$/) + const { done } = await notificationsIterator.next() + expect(done).to.equal(true) + }) + }) + }) +})