diff --git a/.gitignore b/.gitignore index 85fbac4..65a4829 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,2 @@ -node_modules/ -radata/ coverage/ junit.xml diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 7de5830..8ceaedc 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,42 +1,36 @@ -# You can override the included template(s) by including variable overrides -# SAST customization: https://docs.gitlab.com/ee/user/application_security/sast/#customizing-the-sast-settings -# Secret Detection customization: https://docs.gitlab.com/ee/user/application_security/secret_detection/#customizing-settings -# Dependency Scanning customization: https://docs.gitlab.com/ee/user/application_security/dependency_scanning/#customizing-the-dependency-scanning-settings -# Note that environment variables can be set in several places -# See https://docs.gitlab.com/ee/ci/variables/#cicd-variable-precedence -cache: - key: "${CI_COMMIT_REF_SLUG}" - paths: - - node_modules/ - libresilient-test: - image: node:17.0 + image: denoland/deno:debian-1.37.0 stage: test script: - - npm ci --include=dev - - npx jest --collectCoverage --coverageDirectory="./coverage" --coverage --ci --reporters=default --reporters=jest-junit - coverage: "/All files[^|]*\\|[^|]*\\s+([\\d\\.]+)/" + # preparations + - apt update && apt-get install -y bsdextrautils + - deno install --allow-read --allow-write https://deno.land/x/lcov_cobertura/mod.ts + # run the tests! + - deno test --quiet --allow-read --importmap=./__tests__/importmap.json --coverage=./coverage/ --trace-ops + # + # sigh! we are loading the service-worker.js script once per each test, and Deno + # does not allow us to do it nicely - so we are adding a '?' at the end of + # the filename each time. + # + # this works but causes the coverage data to be generated as if these were actually + # different files, so we need this little hack here to fix that + - sed -i -r -e 's/service-worker\.js\?[0-9]+"/service-worker.js"/' coverage/*.json + # convert coverage to cobertura XML by way of LCOV + - deno coverage --lcov ./coverage/ > ./coverage/coverage.lcov + - /usr/local/bin/lcov_cobertura coverage/coverage.lcov > coverage/coverage.xml + # pretty-print some stats directly in the job log + - deno coverage ./coverage/ | grep '^cover' | column -t -R 4,5 + - egrep '^"' | tr '=' ' ' | awk '{ printf "%s %5.2f%%\n", $1, $2*100 }' - | sed -r -e 's/branch-rate/Branches/' -r -e 's/line-rate/Lines/' | column -t -R2 + coverage: '/^Lines +[0-9]+\.[0-9]{2}%$/' artifacts: when: always reports: - junit: - - junit.xml coverage_report: coverage_format: cobertura - path: coverage/cobertura-coverage.xml + path: coverage/coverage.xml tags: - docker - linux - -lrcli-test: - image: denoland/deno:1.28.3 - stage: test - script: - - deno test --quiet --allow-read --import-map=__denotests__/importmap.json __denotests__/ - tags: - - docker - - linux - stages: - test diff --git a/__denotests__/plugins/basic-integrity.test.js b/__denotests__/plugins/basic-integrity.test.js deleted file mode 100644 index 02d7abb..0000000 --- a/__denotests__/plugins/basic-integrity.test.js +++ /dev/null @@ -1,93 +0,0 @@ -import { - assert, - assertThrows, - assertRejects, - assertEquals -} from "https://deno.land/std@0.167.0/testing/asserts.ts"; - -Deno.test("plugin loads", async () => { - const bi = await import('../../plugins/basic-integrity/cli.js') - assert("name" in bi) - assert(bi.name == "basic-integrity") - assert("description" in bi) - assert("actions" in bi) -}); - -Deno.test("get-integrity action defined", async () => { - const bi = await import('../../plugins/basic-integrity/cli.js') - assert("get-integrity" in bi.actions) - const gi = bi.actions["get-integrity"] - assert("run" in gi) - assert("description" in gi) - assert("arguments" in gi) - const gia = gi.arguments - assert("_" in gia) - assert("algorithm" in gia) - assert("output" in gia) - assert("name" in gia._) - assert("description" in gia._) - assert("description" in gia.algorithm) - assert("collect" in gia.algorithm) - assert(gia.algorithm.collect) - assert("string" in gia.algorithm) - assert(gia.algorithm.string) - assert("description" in gia.output) - assert("collect" in gia.output) - assert(!gia.output.collect) - assert("string" in gia.output) - assert(gia.output.string) -}); - -// this is a separate test in order to catch any changing defaults -Deno.test("get-integrity action defaults", async () => { - const bi = await import('../../plugins/basic-integrity/cli.js') - const gia = bi.actions["get-integrity"].arguments - assert("default" in gia.algorithm) - assert(gia.algorithm.default == "SHA-256") - assert("default" in gia.output) - assert(gia.output.default == "json") -}); - -Deno.test("get-integrity verifies arguments are sane", async () => { - const bi = await import('../../plugins/basic-integrity/cli.js') - const gi = bi.actions["get-integrity"] - assertRejects(gi.run, Error, "Expected non-empty list of files to generate digests of.") - assertRejects(async ()=>{ - await gi.run(['no-such-file']) - }, Error, "No such file or directory") - assertRejects(async ()=>{ - await gi.run(['irrelevant'], []) - }, Error, "Expected non-empty list of algorithms to use.") - assertRejects(async ()=>{ - await gi.run(['irrelevant'], ['SHA-384'], false) - }, Error, "Expected either 'json' or 'text' as output type to generate.") -}); - -Deno.test("get-integrity handles paths in a sane way", async () => { - const bi = await import('../../plugins/basic-integrity/cli.js') - const gi = bi.actions["get-integrity"] - assertEquals(await gi.run(['./']), '{}') - assertEquals(await gi.run(['./__denotests__/mocks/hello.txt']), '{"./__denotests__/mocks/hello.txt":["sha256-uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek="]}') - assertEquals(await gi.run(['./', './__denotests__/mocks/hello.txt']), '{"./__denotests__/mocks/hello.txt":["sha256-uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek="]}') -}); - -Deno.test("get-integrity handles algos argument in a sane way", async () => { - const bi = await import('../../plugins/basic-integrity/cli.js') - const gi = bi.actions["get-integrity"] - assertRejects(async ()=>{ - await gi.run(['./__denotests__/mocks/hello.txt'], ['BAD-ALG']) - }, Error, 'Unrecognized algorithm name') - assertEquals(await gi.run(['./__denotests__/mocks/hello.txt'], ['SHA-256']), '{"./__denotests__/mocks/hello.txt":["sha256-uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek="]}') - assertEquals(await gi.run(['./__denotests__/mocks/hello.txt'], ['SHA-384']), '{"./__denotests__/mocks/hello.txt":["sha384-/b2OdaZ/KfcBpOBAOF4uI5hjA+oQI5IRr5B/y7g1eLPkF8txzmRu/QgZ3YwIjeG9"]}') - assertEquals(await gi.run(['./__denotests__/mocks/hello.txt'], ['SHA-512']), '{"./__denotests__/mocks/hello.txt":["sha512-MJ7MSJwS1utMxA9QyQLytNDtd+5RGnx6m808qG1M2G+YndNbxf9JlnDaNCVbRbDP2DDoH2Bdz33FVC6TrpzXbw=="]}') - assertEquals(await gi.run(['./__denotests__/mocks/hello.txt'], ['SHA-256', 'SHA-384', 'SHA-512']), '{"./__denotests__/mocks/hello.txt":["sha256-uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek=","sha384-/b2OdaZ/KfcBpOBAOF4uI5hjA+oQI5IRr5B/y7g1eLPkF8txzmRu/QgZ3YwIjeG9","sha512-MJ7MSJwS1utMxA9QyQLytNDtd+5RGnx6m808qG1M2G+YndNbxf9JlnDaNCVbRbDP2DDoH2Bdz33FVC6TrpzXbw=="]}') -}); - -Deno.test("get-integrity handles output argument in a sane way", async () => { - const bi = await import('../../plugins/basic-integrity/cli.js') - const gi = bi.actions["get-integrity"] - assertEquals(await gi.run(['./__denotests__/mocks/hello.txt'], ['SHA-256'], 'text'), './__denotests__/mocks/hello.txt: sha256-uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek=\n') - assertEquals(await gi.run(['./__denotests__/mocks/hello.txt'], ['SHA-384'], 'text'), './__denotests__/mocks/hello.txt: sha384-/b2OdaZ/KfcBpOBAOF4uI5hjA+oQI5IRr5B/y7g1eLPkF8txzmRu/QgZ3YwIjeG9\n') - assertEquals(await gi.run(['./__denotests__/mocks/hello.txt'], ['SHA-512'], 'text'), './__denotests__/mocks/hello.txt: sha512-MJ7MSJwS1utMxA9QyQLytNDtd+5RGnx6m808qG1M2G+YndNbxf9JlnDaNCVbRbDP2DDoH2Bdz33FVC6TrpzXbw==\n') - assertEquals(await gi.run(['./__denotests__/mocks/hello.txt'], ['SHA-256', 'SHA-384', 'SHA-512'], 'text'), './__denotests__/mocks/hello.txt: sha256-uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek= sha384-/b2OdaZ/KfcBpOBAOF4uI5hjA+oQI5IRr5B/y7g1eLPkF8txzmRu/QgZ3YwIjeG9 sha512-MJ7MSJwS1utMxA9QyQLytNDtd+5RGnx6m808qG1M2G+YndNbxf9JlnDaNCVbRbDP2DDoH2Bdz33FVC6TrpzXbw==\n') -}); diff --git a/__denotests__/cli/lrcli.test.js b/__tests__/cli/lrcli.test.js similarity index 100% rename from __denotests__/cli/lrcli.test.js rename to __tests__/cli/lrcli.test.js diff --git a/__denotests__/importmap.json b/__tests__/importmap.json similarity index 100% rename from __denotests__/importmap.json rename to __tests__/importmap.json diff --git a/__denotests__/mocks/simple-plugin.js b/__tests__/mocks/simple-plugin.js similarity index 100% rename from __denotests__/mocks/simple-plugin.js rename to __tests__/mocks/simple-plugin.js diff --git a/__tests__/plugins/alt-fetch/index.test.js b/__tests__/plugins/alt-fetch/index.test.js deleted file mode 100644 index 87d2a87..0000000 --- a/__tests__/plugins/alt-fetch/index.test.js +++ /dev/null @@ -1,297 +0,0 @@ -const makeServiceWorkerEnv = require('service-worker-mock'); - -global.fetch = require('node-fetch'); -jest.mock('node-fetch') - -/* - * we need a Promise.any() polyfill - * so here it is - * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/any - * - * TODO: remove once Promise.any() is implemented broadly - */ -if (typeof Promise.any === 'undefined') { - Promise.any = async (promises) => { - // Promise.all() is the polar opposite of Promise.any() - // in that it returns as soon as there is a first rejection - // but without it, it returns an array of resolved results - return Promise.all( - promises.map(p => { - return new Promise((resolve, reject) => - // swap reject and resolve, so that we can use Promise.all() - // and get the result we need - Promise.resolve(p).then(reject, resolve) - ); - }) - // now, swap errors and values back - ).then( - err => Promise.reject(err), - val => Promise.resolve(val) - ); - }; -} - -describe("plugin: alt-fetch", () => { - - beforeEach(() => { - Object.assign(global, makeServiceWorkerEnv()); - jest.resetModules(); - global.LibResilientPluginConstructors = new Map() - init = { - name: 'alt-fetch', - endpoints: [ - 'https://alt.resilient.is/test.json', - 'https://error.resilientis/test.json', - 'https://timeout.resilientis/test.json' - ]} - LR = { - log: (component, ...items)=>{ - console.debug(component + ' :: ', ...items) - } - } - }) - - test("it should register in LibResilientPluginConstructors", () => { - require("../../../plugins/alt-fetch/index.js"); - expect(LibResilientPluginConstructors.get('alt-fetch')().name).toEqual('alt-fetch'); - }); - - test("it should fail with bad config", () => { - init = { - name: 'alt-fetch', - endpoints: "this is incorrect" - } - require("../../../plugins/alt-fetch/index.js") - expect.assertions(1) - expect(()=>{ - LibResilientPluginConstructors.get('alt-fetch')(LR, init) - }).toThrow(Error); - }); - - test("it should fetch the content, trying all configured endpoints (if fewer or equal to concurrency setting)", async () => { - require("../../../plugins/alt-fetch/index.js"); - - global.fetch.mockImplementation((url, init) => { - const response = new Response( - new Blob( - [JSON.stringify({ test: "success" })], - {type: "application/json"} - ), - { - status: 200, - statusText: "OK", - headers: { - 'ETag': 'TestingETagHeader' - }, - url: url - }); - return Promise.resolve(response); - }); - - const response = await LibResilientPluginConstructors.get('alt-fetch')(LR, init).fetch('https://resilient.is/test.json'); - - expect(fetch).toHaveBeenCalledTimes(3); - expect(await response.json()).toEqual({test: "success"}) - expect(response.url).toEqual('https://resilient.is/test.json') - }) - - test("it should fetch the content, trying random endpoints out of all configured (if more than concurrency setting)", async () => { - - init = { - name: 'alt-fetch', - endpoints: [ - 'https://alt.resilient.is/', - 'https://error.resilient.is/', - 'https://timeout.resilient.is/', - 'https://alt2.resilient.is/', - 'https://alt3.resilient.is/', - 'https://alt4.resilient.is/' - ]} - - require("../../../plugins/alt-fetch/index.js"); - - global.fetch.mockImplementation((url, init) => { - const response = new Response( - new Blob( - [JSON.stringify({ test: "success" })], - {type: "application/json"} - ), - { - status: 200, - statusText: "OK", - headers: { - 'ETag': 'TestingETagHeader' - }, - url: url - }); - return Promise.resolve(response); - }); - - const response = await LibResilientPluginConstructors.get('alt-fetch')(LR, init).fetch('https://resilient.is/test.json'); - - expect(fetch).toHaveBeenCalledTimes(3); - expect(await response.json()).toEqual({test: "success"}) - expect(response.url).toEqual('https://resilient.is/test.json') - }) - - test("it should fetch the content, trying all endpoints (if fewer than concurrency setting)", async () => { - - init = { - name: 'alt-fetch', - endpoints: [ - 'https://alt.resilient.is/', - 'https://error.resilient.is/' - ]} - - require("../../../plugins/alt-fetch/index.js"); - - global.fetch.mockImplementation((url, init) => { - const response = new Response( - new Blob( - [JSON.stringify({ test: "success" })], - {type: "application/json"} - ), - { - status: 200, - statusText: "OK", - headers: { - 'ETag': 'TestingETagHeader' - }, - url: url - }); - return Promise.resolve(response); - }); - - const response = await LibResilientPluginConstructors.get('alt-fetch')(LR, init).fetch('https://resilient.is/test.json'); - - expect(fetch).toHaveBeenCalledTimes(2); - expect(fetch).toHaveBeenNthCalledWith(1, 'https://alt.resilient.is/test.json', {"cache": "reload"}); - expect(fetch).toHaveBeenNthCalledWith(2, 'https://error.resilient.is/test.json', {"cache": "reload"}); - expect(await response.json()).toEqual({test: "success"}) - expect(response.url).toEqual('https://resilient.is/test.json') - }) - - test("it should pass the Request() init data to fetch() for all used endpoints", async () => { - - init = { - name: 'alt-fetch', - endpoints: [ - 'https://alt.resilient.is/', - 'https://error.resilient.is/', - 'https://timeout.resilient.is/', - 'https://alt2.resilient.is/', - 'https://alt3.resilient.is/', - 'https://alt4.resilient.is/' - ]} - - require("../../../plugins/alt-fetch/index.js"); - - global.fetch.mockImplementation((url, init) => { - const response = new Response( - new Blob( - [JSON.stringify({ test: "success" })], - {type: "application/json"} - ), - { - status: 200, - statusText: "OK", - headers: { - 'ETag': 'TestingETagHeader' - }, - url: url - }); - return Promise.resolve(response); - }); - - var initTest = { - method: "GET", - headers: new Headers({"x-stub": "STUB"}), - mode: "mode-stub", - credentials: "credentials-stub", - cache: "cache-stub", - referrer: "referrer-stub", - // these are not implemented by service-worker-mock - // https://github.com/zackargyle/service-workers/blob/master/packages/service-worker-mock/models/Request.js#L20 - redirect: undefined, - integrity: undefined, - cache: undefined - } - - const response = await LibResilientPluginConstructors.get('alt-fetch')(LR, init).fetch('https://resilient.is/test.json', initTest); - - expect(fetch).toHaveBeenCalledTimes(3); - expect(fetch).toHaveBeenNthCalledWith(1, expect.stringContaining('/test.json'), initTest); - expect(fetch).toHaveBeenNthCalledWith(2, expect.stringContaining('/test.json'), initTest); - expect(fetch).toHaveBeenNthCalledWith(3, expect.stringContaining('/test.json'), initTest); - expect(await response.json()).toEqual({test: "success"}) - expect(response.url).toEqual('https://resilient.is/test.json') - }) - - test("it should set the LibResilient headers", async () => { - require("../../../plugins/alt-fetch/index.js"); - - const response = await LibResilientPluginConstructors.get('alt-fetch')(LR, init).fetch('https://resilient.is/test.json'); - - expect(fetch).toHaveBeenCalledTimes(3); - expect(await response.json()).toEqual({test: "success"}) - expect(response.url).toEqual('https://resilient.is/test.json') - expect(response.headers.has('X-LibResilient-Method')).toEqual(true) - expect(response.headers.get('X-LibResilient-Method')).toEqual('alt-fetch') - expect(response.headers.has('X-LibResilient-Etag')).toEqual(true) - expect(response.headers.get('X-LibResilient-ETag')).toEqual('TestingETagHeader') - }); - - test("it should set the LibResilient ETag based on Last-Modified header (if ETag is not available in the original response)", async () => { - require("../../../plugins/alt-fetch/index.js"); - - global.fetch.mockImplementation((url, init) => { - const response = new Response( - new Blob( - [JSON.stringify({ test: "success" })], - {type: "application/json"} - ), - { - status: 200, - statusText: "OK", - headers: { - 'Last-Modified': 'TestingLastModifiedHeader' - }, - url: url - }); - return Promise.resolve(response); - }); - - const response = await LibResilientPluginConstructors.get('alt-fetch')(LR, init).fetch('https://resilient.is/test.json'); - - expect(fetch).toHaveBeenCalledTimes(3); - expect(await response.json()).toEqual({test: "success"}) - expect(response.url).toEqual('https://resilient.is/test.json') - expect(response.headers.has('X-LibResilient-Method')).toEqual(true) - expect(response.headers.get('X-LibResilient-Method')).toEqual('alt-fetch') - expect(response.headers.has('X-LibResilient-Etag')).toEqual(true) - expect(response.headers.get('X-LibResilient-ETag')).toEqual('TestingLastModifiedHeader') - }); - - test("it should throw an error when HTTP status is >= 400", async () => { - - global.fetch.mockImplementation((url, init) => { - const response = new Response( - new Blob( - ["Not Found"], - {type: "text/plain"} - ), - { - status: 404, - statusText: "Not Found", - url: url - }); - return Promise.resolve(response); - }); - - require("../../../plugins/alt-fetch/index.js"); - - expect.assertions(1) - expect(LibResilientPluginConstructors.get('alt-fetch')(LR, init).fetch('https://resilient.is/test.json')).rejects.toThrow(Error) - }); - -}); diff --git a/__tests__/plugins/any-of/index.test.js b/__tests__/plugins/any-of/index.test.js deleted file mode 100644 index 1a7045e..0000000 --- a/__tests__/plugins/any-of/index.test.js +++ /dev/null @@ -1,168 +0,0 @@ -const makeServiceWorkerEnv = require('service-worker-mock'); - -global.fetch = require('node-fetch'); -jest.mock('node-fetch') - -global.fetch.mockImplementation((url, init) => { - const response = new Response( - new Blob( - [JSON.stringify({ test: "success" })], - {type: "application/json"} - ), - { - status: 200, - statusText: "OK", - headers: { - 'ETag': 'TestingETagHeader' - }, - url: url - }); - return Promise.resolve(response); - }); - -/* - * we need a Promise.any() polyfill - * so here it is - * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/any - * - * TODO: remove once Promise.any() is implemented broadly - */ -if (typeof Promise.any === 'undefined') { - Promise.any = async (promises) => { - // Promise.all() is the polar opposite of Promise.any() - // in that it returns as soon as there is a first rejection - // but without it, it returns an array of resolved results - return Promise.all( - promises.map(p => { - return new Promise((resolve, reject) => - // swap reject and resolve, so that we can use Promise.all() - // and get the result we need - Promise.resolve(p).then(reject, resolve) - ); - }) - // now, swap errors and values back - ).then( - err => Promise.reject(err), - val => Promise.resolve(val) - ); - }; -} - - -describe("plugin: any-of", () => { - - beforeEach(() => { - Object.assign(global, makeServiceWorkerEnv()); - jest.resetModules(); - global.LibResilientPluginConstructors = new Map() - LR = { - log: (component, ...items)=>{ - console.debug(component + ' :: ', ...items) - } - } - require("../../../plugins/fetch/index.js"); - init = { - name: 'any-of', - uses: [ - LibResilientPluginConstructors.get('fetch')(LR), - { - name: 'reject-all', - description: 'Rejects all', - version: '0.0.1', - fetch: url=>Promise.reject('Reject All!') - } - ] - } - self.log = function(component, ...items) { - console.debug(component + ' :: ', ...items) - } - }) - - test("it should register in LibResilientPluginConstructors", () => { - require("../../../plugins/any-of/index.js"); - expect(LibResilientPluginConstructors.get('any-of')(LR, init).name).toEqual('any-of'); - }); - - test("it should throw an error when there aren't any wrapped plugins configured", async () => { - require("../../../plugins/any-of/index.js"); - init = { - name: 'any-of', - uses: [] - } - - expect.assertions(2); - try { - await LibResilientPluginConstructors.get('any-of')(LR, init).fetch('https://resilient.is/test.json') - } catch (e) { - expect(e).toBeInstanceOf(Error) - expect(e.toString()).toMatch('No wrapped plugins configured!') - } - }); - - test("it should return data from a wrapped plugin", async () => { - require("../../../plugins/any-of/index.js"); - - const response = await LibResilientPluginConstructors.get('any-of')(LR, init).fetch('https://resilient.is/test.json'); - - expect(fetch).toHaveBeenCalled(); - expect(await response.json()).toEqual({test: "success"}) - expect(response.url).toEqual('https://resilient.is/test.json') - }); - - test("it should pass Request() init data onto wrapped plugins", async () => { - require("../../../plugins/any-of/index.js"); - - var initTest = { - method: "GET", - headers: new Headers({"x-stub": "STUB"}), - mode: "mode-stub", - credentials: "credentials-stub", - cache: "cache-stub", - referrer: "referrer-stub", - // these are not implemented by service-worker-mock - // https://github.com/zackargyle/service-workers/blob/master/packages/service-worker-mock/models/Request.js#L20 - redirect: undefined, - integrity: undefined, - cache: undefined - } - - const response = await LibResilientPluginConstructors.get('any-of')(LR, init).fetch('https://resilient.is/test.json', initTest); - - expect(fetch).toHaveBeenCalled(); - expect(fetch).toHaveBeenCalledWith('https://resilient.is/test.json', initTest); - expect(await response.json()).toEqual({test: "success"}) - expect(response.url).toEqual('https://resilient.is/test.json') - }); - - test("it should throw an error when HTTP status is >= 400", async () => { - - global.fetch.mockImplementation((url, init) => { - const response = new Response( - new Blob( - ["Not Found"], - {type: "text/plain"} - ), - { - status: 404, - statusText: "Not Found", - url: url - }); - return Promise.resolve(response); - }); - - require("../../../plugins/any-of/index.js"); - - expect.assertions(2); - try { - await LibResilientPluginConstructors.get('any-of')(LR, init).fetch('https://resilient.is/test.json') - } catch (e) { - if (e instanceof Array) { - expect(e[0].toString()).toMatch('Error') - } else { - expect(e).toBeInstanceOf(AggregateError) - } - } - expect(fetch).toHaveBeenCalled(); - }); - -}); diff --git a/__tests__/plugins/basic-integrity/index.test.js b/__tests__/plugins/basic-integrity/index.test.js deleted file mode 100644 index 78a9b1b..0000000 --- a/__tests__/plugins/basic-integrity/index.test.js +++ /dev/null @@ -1,181 +0,0 @@ -const makeServiceWorkerEnv = require('service-worker-mock'); - -describe("plugin: basic-integrity", () => { - - beforeEach(() => { - Object.assign(global, makeServiceWorkerEnv()); - jest.resetModules(); - - global.LibResilientPluginConstructors = new Map() - LR = { - log: (component, ...items)=>{ - console.debug(component + ' :: ', ...items) - } - } - - global.resolvingFetch = jest.fn((url, init)=>{ - return Promise.resolve( - new Response( - new Blob( - [JSON.stringify({ test: "success" })], - {type: "application/json"} - ), - { - status: 200, - statusText: "OK", - headers: { - 'ETag': 'TestingETagHeader' - }, - url: url - } - ) - ) - }) - - init = { - name: 'basic-integrity', - uses: [ - { - name: 'resolve-all', - description: 'Resolves all', - version: '0.0.1', - fetch: resolvingFetch - } - ], - integrity: { - "https://resilient.is/test.json": "sha384-kn5dhxz4RpBmx7xC7Dmq2N43PclV9U/niyh+4Km7oz5W0FaWdz3Op+3K0Qxz8y3z" - }, - requireIntegrity: true - } - self.log = function(component, ...items) { - console.debug(component + ' :: ', ...items) - } - }) - - test("it should register in LibResilientPluginConstructors", () => { - require("../../../plugins/basic-integrity/index.js"); - expect(LibResilientPluginConstructors.get('basic-integrity')(LR, init).name).toEqual('basic-integrity'); - }); - - test("it should throw an error when there aren't any wrapped plugins configured", async () => { - require("../../../plugins/basic-integrity/index.js"); - init = { - name: 'basic-integrity', - uses: [] - } - - expect.assertions(2); - try { - await LibResilientPluginConstructors.get('basic-integrity')(LR, init).fetch('https://resilient.is/test.json') - } catch (e) { - expect(e).toBeInstanceOf(Error) - expect(e.toString()).toMatch('Expected exactly one plugin to wrap') - } - }); - - test("it should throw an error when there are more than one wrapped plugins configured", async () => { - require("../../../plugins/basic-integrity/index.js"); - init = { - name: 'basic-integrity', - uses: [{ - name: 'plugin-1' - },{ - name: 'plugin-2' - }] - } - - expect.assertions(2); - try { - await LibResilientPluginConstructors.get('basic-integrity')(LR, init).fetch('https://resilient.is/test.json') - } catch (e) { - expect(e).toBeInstanceOf(Error) - expect(e.toString()).toMatch('Expected exactly one plugin to wrap') - } - }); - - - test("it should return data from the wrapped plugin", async () => { - require("../../../plugins/basic-integrity/index.js"); - - const response = await LibResilientPluginConstructors.get('basic-integrity')(LR, init).fetch('https://resilient.is/test.json'); - - expect(resolvingFetch).toHaveBeenCalled(); - expect(await response.json()).toEqual({test: "success"}) - expect(response.url).toEqual('https://resilient.is/test.json') - }); - - - test("it should provide the wrapped plugin with integrity data for a configured URL", async () => { - require("../../../plugins/basic-integrity/index.js"); - - const response = await LibResilientPluginConstructors.get('basic-integrity')(LR, init).fetch('https://resilient.is/test.json'); - - expect(resolvingFetch).toHaveBeenCalledWith( - 'https://resilient.is/test.json', - { - integrity: init.integrity['https://resilient.is/test.json'] - }); - expect(await response.json()).toEqual({test: "success"}) - expect(response.url).toEqual('https://resilient.is/test.json') - }); - - test("it should error out for an URL with no integrity data, when requireIntegrity is true", async () => { - require("../../../plugins/basic-integrity/index.js"); - - expect.assertions(3) - try { - const response = await LibResilientPluginConstructors.get('basic-integrity')(LR, init).fetch('https://resilient.is/test2.json'); - } catch (e) { - expect(e).toBeInstanceOf(Error) - expect(e.toString()).toMatch('Integrity data required but not provided for') - } - expect(resolvingFetch).not.toHaveBeenCalled() - }); - - test("it should return data from the wrapped plugin with no integrity data if requireIntegrity is false", async () => { - require("../../../plugins/basic-integrity/index.js"); - - init.integrity = {} - init.requireIntegrity = false - - const response = await LibResilientPluginConstructors.get('basic-integrity')(LR, init).fetch('https://resilient.is/test.json'); - - expect(resolvingFetch).toHaveBeenCalled(); - expect(resolvingFetch).toHaveBeenCalledWith('https://resilient.is/test.json', {}); - expect(await response.json()).toEqual({test: "success"}) - expect(response.url).toEqual('https://resilient.is/test.json') - }); - - test("it should return data from the wrapped plugin with no integrity data configured when requireIntegrity is true and integrity data is provided in Request() init data", async () => { - require("../../../plugins/basic-integrity/index.js"); - - init.integrity = {} - - const response = await LibResilientPluginConstructors - .get('basic-integrity')(LR, init) - .fetch('https://resilient.is/test.json', { - integrity: "sha256-Aj9x0DWq9GUL1L8HibLCMa8YLKnV7IYAfpYurqrFwiQ=" - }); - - expect(resolvingFetch).toHaveBeenCalled(); - expect(resolvingFetch).toHaveBeenCalledWith('https://resilient.is/test.json', {integrity: "sha256-Aj9x0DWq9GUL1L8HibLCMa8YLKnV7IYAfpYurqrFwiQ="}); - expect(await response.json()).toEqual({test: "success"}) - expect(response.url).toEqual('https://resilient.is/test.json') - }); - - test("it should return data from the wrapped plugin with integrity data both configured and coming from Request() init", async () => { - require("../../../plugins/basic-integrity/index.js"); - - const response = await LibResilientPluginConstructors - .get('basic-integrity')(LR, init) - .fetch('https://resilient.is/test.json', { - integrity: "sha256-Aj9x0DWq9GUL1L8HibLCMa8YLKnV7IYAfpYurqrFwiQ=" - }); - - expect(resolvingFetch).toHaveBeenCalled(); - expect(resolvingFetch).toHaveBeenCalledWith('https://resilient.is/test.json', {integrity: "sha256-Aj9x0DWq9GUL1L8HibLCMa8YLKnV7IYAfpYurqrFwiQ= sha384-kn5dhxz4RpBmx7xC7Dmq2N43PclV9U/niyh+4Km7oz5W0FaWdz3Op+3K0Qxz8y3z"}); - expect(await response.json()).toEqual({test: "success"}) - expect(response.url).toEqual('https://resilient.is/test.json') - }); - -}); diff --git a/__tests__/plugins/cache/index.test.js b/__tests__/plugins/cache/index.test.js deleted file mode 100644 index fcec83a..0000000 --- a/__tests__/plugins/cache/index.test.js +++ /dev/null @@ -1,279 +0,0 @@ -/** - * @jest-environment jsdom - */ - -const makeServiceWorkerEnv = require('service-worker-mock'); - -global.fetch = require('node-fetch'); -jest.mock('node-fetch') - -global.fetch.mockImplementation((url, init) => { - const response = new Response( - new Blob( - [JSON.stringify({ test: "success" })], - {type: "application/json"} - ), - { - status: 200, - statusText: "OK", - headers: { - 'ETag': 'TestingETagHeader' - }, - url: url - }); - return Promise.resolve(response); - }); - -describe("plugin: cache", () => { - beforeEach(() => { - Object.assign(global, makeServiceWorkerEnv()); - jest.resetModules(); - global.LibResilientPluginConstructors = new Map() - LR = { - log: (component, ...items)=>{ - console.debug(component + ' :: ', ...items) - } - } - }) - - test("it should register in LibResilientPluginConstructors", () => { - require("../../../plugins/cache/index.js"); - expect(LibResilientPluginConstructors.get('cache')().name).toEqual('cache'); - }); - - test("it should error out if resource is not found", () => { - require("../../../plugins/cache/index.js"); - - expect.assertions(1) - return expect(LibResilientPluginConstructors.get('cache')(LR).fetch('https://resilient.is/test.json')).rejects.toThrow(Error) - }); - - test("it should stash a url successfully", () => { - require("../../../plugins/cache/index.js"); - expect.assertions(7); - var cachePlugin = LibResilientPluginConstructors.get('cache')(LR) - return cachePlugin.stash('https://resilient.is/test.json').then((result)=>{ - expect(result).toEqual(undefined) - return cachePlugin.fetch('https://resilient.is/test.json') - }).then(fetchResult => { - expect(fetchResult.status).toEqual(200) - expect(fetchResult.statusText).toEqual('OK') - expect(fetchResult.url).toEqual('https://resilient.is/test.json') - expect(fetchResult.headers.has('Etag')).toEqual(true) - expect(fetchResult.headers.get('ETag')).toEqual('TestingETagHeader') - return fetchResult.json().then(json => { - expect(json).toEqual({ test: "success" }) - }) - }) - }); - - test("it should clear a url successfully", () => { - require("../../../plugins/cache/index.js"); - expect.assertions(3); - var cachePlugin = LibResilientPluginConstructors.get('cache')(LR) - return cachePlugin.stash('https://resilient.is/test.json').then((result)=>{ - expect(result).toBe(undefined) - return cachePlugin.unstash('https://resilient.is/test.json') - }).then(result => { - expect(result).toEqual(true) - return expect(cachePlugin.fetch('https://resilient.is/test.json')).rejects.toThrow(Error) - }) - }); - - test("it should stash an array of urls successfully", () => { - require("../../../plugins/cache/index.js"); - expect.assertions(13); - var cachePlugin = LibResilientPluginConstructors.get('cache')(LR) - return cachePlugin.stash(['https://resilient.is/test.json', 'https://resilient.is/test2.json']).then((result)=>{ - expect(result).toEqual([undefined, undefined]) - return cachePlugin.fetch('https://resilient.is/test.json') - }).then(fetchResult => { - expect(fetchResult.status).toEqual(200) - expect(fetchResult.statusText).toEqual('OK') - expect(fetchResult.url).toEqual('https://resilient.is/test.json') - expect(fetchResult.headers.has('Etag')).toEqual(true) - expect(fetchResult.headers.get('ETag')).toEqual('TestingETagHeader') - return fetchResult.json().then(json => { - expect(json).toEqual({ test: "success" }) - }) - }).then(() => { - return cachePlugin.fetch('https://resilient.is/test2.json') - }).then(fetchResult => { - expect(fetchResult.status).toEqual(200) - expect(fetchResult.statusText).toEqual('OK') - expect(fetchResult.url).toEqual('https://resilient.is/test2.json') - expect(fetchResult.headers.has('Etag')).toEqual(true) - expect(fetchResult.headers.get('ETag')).toEqual('TestingETagHeader') - return fetchResult.json().then(json => { - expect(json).toEqual({ test: "success" }) - }) - }) - }); - - test("it should clear an array of urls successfully", () => { - require("../../../plugins/cache/index.js"); - expect.assertions(4); - var cachePlugin = LibResilientPluginConstructors.get('cache')(LR) - return cachePlugin.stash(['https://resilient.is/test.json', 'https://resilient.is/test2.json']).then((result)=>{ - expect(result).toEqual([undefined, undefined]) - return cachePlugin.unstash(['https://resilient.is/test.json', 'https://resilient.is/test2.json']) - }).then(result => { - expect(result).toEqual([true, true]) - return expect(cachePlugin.fetch('https://resilient.is/test.json')).rejects.toThrow(Error) - }).then(()=>{ - return expect(cachePlugin.fetch('https://resilient.is/test2.json')).rejects.toThrow(Error) - }) - }); - - - test("it should error out when stashing a Response without a url/key", () => { - require("../../../plugins/cache/index.js"); - - const response = new Response( - new Blob( - [JSON.stringify({ test: "success" })], - {type: "application/json"} - ), - { - status: 200, - statusText: "OK", - headers: { - 'ETag': 'TestingETagHeader' - } - }); - response.url='' - - expect.assertions(1); - return expect(LibResilientPluginConstructors.get('cache')(LR).stash(response)).rejects.toThrow(Error) - }); - - test("it should stash a Response successfully", () => { - require("../../../plugins/cache/index.js"); - - const response = new Response( - new Blob( - [JSON.stringify({ test: "success" })], - {type: "application/json"} - ), - { - status: 200, - statusText: "OK", - headers: { - 'ETag': 'TestingETagHeader' - }, - url: 'https://resilient.is/test.json' - }); - - expect.assertions(7); - var cachePlugin = LibResilientPluginConstructors.get('cache')(LR) - return cachePlugin.stash(response).then((result)=>{ - expect(result).toEqual(undefined) - return cachePlugin.fetch('https://resilient.is/test.json') - }).then(fetchResult => { - expect(fetchResult.status).toEqual(200) - expect(fetchResult.statusText).toEqual('OK') - expect(fetchResult.url).toEqual('https://resilient.is/test.json') - expect(fetchResult.headers.has('Etag')).toEqual(true) - expect(fetchResult.headers.get('ETag')).toEqual('TestingETagHeader') - return fetchResult.json().then(json => { - expect(json).toEqual({ test: "success" }) - }) - }) - }); - - test("it should stash a Response with an explicit key successfully", () => { - require("../../../plugins/cache/index.js"); - - const response = new Response( - new Blob( - [JSON.stringify({ test: "success" })], - {type: "application/json"} - ), - { - status: 200, - statusText: "OK", - headers: { - 'ETag': 'TestingETagHeader' - }, - url: 'https://resilient.is/test.json' - }); - - expect.assertions(7); - var cachePlugin = LibResilientPluginConstructors.get('cache')(LR) - return cachePlugin.stash(response, 'special-key').then((result)=>{ - expect(result).toEqual(undefined) - return cachePlugin.fetch('special-key') - }).then(fetchResult => { - expect(fetchResult.status).toEqual(200) - expect(fetchResult.statusText).toEqual('OK') - expect(fetchResult.url).toEqual('https://resilient.is/test.json') - expect(fetchResult.headers.has('Etag')).toEqual(true) - expect(fetchResult.headers.get('ETag')).toEqual('TestingETagHeader') - return fetchResult.json().then(json => { - expect(json).toEqual({ test: "success" }) - }) - }) - }); - - test("it should stash a Response with no url set but with an explicit key successfully", () => { - require("../../../plugins/cache/index.js"); - - const response = new Response( - new Blob( - [JSON.stringify({ test: "success" })], - {type: "application/json"} - ), - { - status: 200, - statusText: "OK", - headers: { - 'ETag': 'TestingETagHeader' - }, - }); - response.url = '' - - expect.assertions(6); - var cachePlugin = LibResilientPluginConstructors.get('cache')(LR) - return cachePlugin.stash(response, 'special-key').then((result)=>{ - expect(result).toEqual(undefined) - return cachePlugin.fetch('special-key') - }).then(fetchResult => { - expect(fetchResult.status).toEqual(200) - expect(fetchResult.statusText).toEqual('OK') - expect(fetchResult.headers.has('Etag')).toEqual(true) - expect(fetchResult.headers.get('ETag')).toEqual('TestingETagHeader') - return fetchResult.json().then(json => { - expect(json).toEqual({ test: "success" }) - }) - }) - }); - - test("it should clear a Response successfully", () => { - require("../../../plugins/cache/index.js"); - - const response = new Response( - new Blob( - [JSON.stringify({ test: "success" })], - {type: "application/json"} - ), - { - status: 200, - statusText: "OK", - headers: { - 'ETag': 'TestingETagHeader' - }, - url: 'https://resilient.is/test.json' - }); - - expect.assertions(3); - var cachePlugin = LibResilientPluginConstructors.get('cache')(LR) - return cachePlugin.stash(response).then((result)=>{ - expect(result).toBe(undefined) - return cachePlugin.unstash(response) - }).then(result => { - expect(result).toEqual(true) - return expect(cachePlugin.fetch('https://resilient.is/test.json')).rejects.toThrow(Error) - }) - }); - -}); diff --git a/__tests__/plugins/dnslink-fetch/index.test.js b/__tests__/plugins/dnslink-fetch/index.test.js deleted file mode 100644 index 554ee9a..0000000 --- a/__tests__/plugins/dnslink-fetch/index.test.js +++ /dev/null @@ -1,343 +0,0 @@ -const makeServiceWorkerEnv = require('service-worker-mock'); - -global.fetch = require('node-fetch'); -jest.mock('node-fetch') - -/* - * we need a Promise.any() polyfill - * so here it is - * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/any - * - * TODO: remove once Promise.any() is implemented broadly - */ -if (typeof Promise.any === 'undefined') { - Promise.any = async (promises) => { - // Promise.all() is the polar opposite of Promise.any() - // in that it returns as soon as there is a first rejection - // but without it, it returns an array of resolved results - return Promise.all( - promises.map(p => { - return new Promise((resolve, reject) => - // swap reject and resolve, so that we can use Promise.all() - // and get the result we need - Promise.resolve(p).then(reject, resolve) - ); - }) - // now, swap errors and values back - ).then( - err => Promise.reject(err), - val => Promise.resolve(val) - ); - }; -} - -describe("plugin: dnslink-fetch", () => { - - beforeEach(() => { - Object.assign(global, makeServiceWorkerEnv()); - jest.resetModules(); - global.LibResilientPluginConstructors = new Map() - init = { - name: 'dnslink-fetch' - } - LR = { - log: jest.fn((component, ...items)=>{ - console.debug(component + ' :: ', ...items) - }) - } - global.fetchResponse = [] - global.fetch.mockImplementation((url, init) => { - const response = new Response( - new Blob( - [JSON.stringify(fetchResponse[0])], - {type: fetchResponse[1]} - ), - { - status: 200, - statusText: "OK", - headers: { - 'Last-Modified': 'TestingLastModifiedHeader' - }, - url: url - }); - return Promise.resolve(response); - }); - }) - - test("it should register in LibResilientPluginConstructors", () => { - require("../../../plugins/dnslink-fetch/index.js"); - expect(LibResilientPluginConstructors.get('dnslink-fetch')().name).toEqual('dnslink-fetch'); - }); - - test("it should fail with bad config", () => { - init = { - name: 'dnslink-fetch', - dohProvider: false - } - require("../../../plugins/dnslink-fetch/index.js") - expect.assertions(1) - expect(()=>{ - LibResilientPluginConstructors.get('dnslink-fetch')(LR, init) - }).toThrow(Error); - }); - - test("it should perform a fetch against the default dohProvider endpoint, with default ECS settings", async () => { - require("../../../plugins/dnslink-fetch/index.js"); - - try { - const response = await LibResilientPluginConstructors.get('dnslink-fetch')(LR, init).fetch('https://resilient.is/test.json'); - } catch(e) {} - - expect(global.fetch).toHaveBeenCalledWith("https://dns.hostux.net/dns-query?name=_dnslink.resilient.is&type=TXT&edns_client_subnet=0.0.0.0/0", {"headers": {"accept": "application/json"}}) - }) - - test("it should perform a fetch against the configured dohProvider endpoint, with configured ECS settings", async () => { - require("../../../plugins/dnslink-fetch/index.js"); - - let init = { - name: 'dnslink-fetch', - dohProvider: 'https://doh.example.org/resolve-example', - ecsMasked: false - } - - try { - const response = await LibResilientPluginConstructors.get('dnslink-fetch')(LR, init).fetch('https://resilient.is/test.json'); - } catch(e) {} - - expect(global.fetch).toHaveBeenCalledWith("https://doh.example.org/resolve-example?name=_dnslink.resilient.is&type=TXT", {"headers": {"accept": "application/json"}}) - }) - - test("it should throw an error if the DoH response is not a valid JSON", async () => { - require("../../../plugins/dnslink-fetch/index.js"); - - global.fetchResponse = ["not-json", "text/plain"] - expect.assertions(1) - try { - const response = await LibResilientPluginConstructors.get('dnslink-fetch')(LR, init).fetch('https://resilient.is/test.json'); - } catch(e) { - expect(e).toEqual(new Error('Response is not a valid JSON')) - } - }) - - test("it should throw an error if the DoH response is does not have a Status field", async () => { - require("../../../plugins/dnslink-fetch/index.js"); - - global.fetchResponse = [{test: "success"}, "application/json"] - expect.assertions(1) - try { - const response = await LibResilientPluginConstructors.get('dnslink-fetch')(LR, init).fetch('https://resilient.is/test.json'); - } catch(e) { - expect(e).toEqual(new Error('DNS request failure, status code: undefined')) - } - }) - - test("it should throw an error if the DoH response has Status other than 0", async () => { - require("../../../plugins/dnslink-fetch/index.js"); - - global.fetchResponse = [{Status: 999}, "application/json"] - expect.assertions(1) - try { - const response = await LibResilientPluginConstructors.get('dnslink-fetch')(LR, init).fetch('https://resilient.is/test.json'); - } catch(e) { - expect(e).toEqual(new Error('DNS request failure, status code: 999')) - } - }) - - test("it should throw an error if the DoH response does not have an Answer field", async () => { - require("../../../plugins/dnslink-fetch/index.js"); - - global.fetchResponse = [{Status: 0}, "application/json"] - expect.assertions(1) - try { - const response = await LibResilientPluginConstructors.get('dnslink-fetch')(LR, init).fetch('https://resilient.is/test.json'); - } catch(e) { - expect(e).toEqual(new Error('DNS response did not contain a valid Answer section')) - } - }) - - test("it should throw an error if the DoH response's Answer field is not an object", async () => { - require("../../../plugins/dnslink-fetch/index.js"); - - global.fetchResponse = [{Status: 0, Answer: 'invalid'}, "application/json"] - expect.assertions(1) - try { - const response = await LibResilientPluginConstructors.get('dnslink-fetch')(LR, init).fetch('https://resilient.is/test.json'); - } catch(e) { - expect(e).toEqual(new Error('DNS response did not contain a valid Answer section')) - } - }) - - test("it should throw an error if the DoH response's Answer field is not an Array", async () => { - require("../../../plugins/dnslink-fetch/index.js"); - - global.fetchResponse = [{Status: 0, Answer: {}}, "application/json"] - expect.assertions(1) - try { - const response = await LibResilientPluginConstructors.get('dnslink-fetch')(LR, init).fetch('https://resilient.is/test.json'); - } catch(e) { - expect(e).toEqual(new Error('DNS response did not contain a valid Answer section')) - } - }) - - test("it should throw an error if the DoH response's Answer field does not contain TXT records", async () => { - require("../../../plugins/dnslink-fetch/index.js"); - - global.fetchResponse = [{Status: 0, Answer: ['aaa', 'bbb']}, "application/json"] - expect.assertions(1) - try { - const response = await LibResilientPluginConstructors.get('dnslink-fetch')(LR, init).fetch('https://resilient.is/test.json'); - } catch(e) { - expect(e).toEqual(new Error('Answer section of the DNS response did not contain any TXT records')) - } - }) - - test("it should throw an error if the DoH response's Answer elements do not contain valid endpoint data", async () => { - require("../../../plugins/dnslink-fetch/index.js"); - - global.fetchResponse = [{Status: 0, Answer: [{type: 16}, {type: 16}]}, "application/json"] - expect.assertions(1) - try { - const response = await LibResilientPluginConstructors.get('dnslink-fetch')(LR, init).fetch('https://resilient.is/test.json'); - } catch(e) { - expect(e).toEqual(new Error('No TXT record contained http or https endpoint definition')) - } - }) - - test("it should throw an error if the DoH response's Answer elements do not contain valid endpoints", async () => { - require("../../../plugins/dnslink-fetch/index.js"); - - global.fetchResponse = [{Status: 0, Answer: [{type: 16, data: 'aaa'}, {type: 16, data: 'bbb'}]}, "application/json"] - expect.assertions(1) - try { - const response = await LibResilientPluginConstructors.get('dnslink-fetch')(LR, init).fetch('https://resilient.is/test.json'); - } catch(e) { - expect(e).toEqual(new Error('No TXT record contained http or https endpoint definition')) - } - }) - - test("it should successfully resolve if the DoH response contains endpoint data", async () => { - require("../../../plugins/dnslink-fetch/index.js"); - - global.fetchResponse = [{Status: 0, Answer: [{type: 16, data: 'dnslink=/https/example.org'}, {type: 16, data: 'dnslink=/http/example.net/some/path'}]}, "application/json"] - try { - const response = await LibResilientPluginConstructors.get('dnslink-fetch')(LR, init).fetch('https://resilient.is/test.json'); - } catch(e) {} - expect(LR.log).toHaveBeenCalledWith("dnslink-fetch", "+-- alternative endpoints from DNSLink:\n - ", "https://example.org\n - http://example.net/some/path") - }) - - test("it should fetch the content, trying all DNSLink-resolved endpoints (if fewer or equal to concurrency setting)", async () => { - require("../../../plugins/dnslink-fetch/index.js"); - - global.fetchResponse = [{Status: 0, Answer: [{type: 16, data: 'dnslink=/https/example.org'}, {type: 16, data: 'dnslink=/http/example.net/some/path'}]}, "application/json"] - const response = await LibResilientPluginConstructors.get('dnslink-fetch')(LR, init).fetch('https://resilient.is/test.json'); - - expect(fetch).toHaveBeenCalledTimes(3); // 1 fetch to resolve DNSLink, then 2 fetch requests to the two DNSLink-resolved endpoints - expect(fetch).toHaveBeenNthCalledWith(2, 'https://example.org/test.json', {"cache": "reload"}); - expect(fetch).toHaveBeenNthCalledWith(3, 'http://example.net/some/path/test.json', {"cache": "reload"}); - expect(await response.json()).toEqual(global.fetchResponse[0]) - expect(response.url).toEqual('https://resilient.is/test.json') - }) - - test("it should fetch the content, trying random endpoints out of all DNSLink-resolved endpoints (if more than concurrency setting)", async () => { - - let init = { - name: 'dnslink-fetch', - concurrency: 2 - } - - require("../../../plugins/dnslink-fetch/index.js"); - - global.fetchResponse = [{Status: 0, Answer: [{type: 16, data: 'dnslink=/https/example.org'}, {type: 16, data: 'dnslink=/http/example.net/some/path'}, {type: 16, data: 'dnslink=/https/example.net/some/path'}, {type: 16, data: 'dnslink=/https/example.net/some/other/path'}]}, "application/json"] - const response = await LibResilientPluginConstructors.get('dnslink-fetch')(LR, init).fetch('https://resilient.is/test.json'); - - expect(fetch).toHaveBeenCalledTimes(3); // 1 fetch to resolve DNSLink, then fetch requests to the two DNSLink-resolved endpoints - expect(await response.json()).toEqual(global.fetchResponse[0]) - expect(response.url).toEqual('https://resilient.is/test.json') - }) - - test("it should pass the Request() init data to fetch() for all used endpoints", async () => { - - require("../../../plugins/dnslink-fetch/index.js"); - - var initTest = { - method: "GET", - headers: new Headers({"x-stub": "STUB"}), - mode: "mode-stub", - credentials: "credentials-stub", - cache: "cache-stub", - referrer: "referrer-stub", - // these are not implemented by service-worker-mock - // https://github.com/zackargyle/service-workers/blob/master/packages/service-worker-mock/models/Request.js#L20 - redirect: undefined, - integrity: undefined, - cache: undefined - } - - global.fetchResponse = [{Status: 0, Answer: [{type: 16, data: 'dnslink=/https/example.org'}, {type: 16, data: 'dnslink=/http/example.net/some/path'}, {type: 16, data: 'dnslink=/https/example.net/some/path'}, {type: 16, data: 'dnslink=/https/example.net/some/other/path'}]}, "application/json"] - - const response = await LibResilientPluginConstructors.get('dnslink-fetch')(LR, init).fetch('https://resilient.is/test.json', initTest); - - expect(fetch).toHaveBeenCalledTimes(4); // 1 fetch to resolve DNSLink, then (default: 3) fetch requests to the two DNSLink-resolved endpoints - expect(await response.json()).toEqual(global.fetchResponse[0]) - expect(response.url).toEqual('https://resilient.is/test.json') - - expect(fetch).toHaveBeenNthCalledWith(2, expect.stringContaining('/test.json'), initTest); - expect(fetch).toHaveBeenNthCalledWith(3, expect.stringContaining('/test.json'), initTest); - expect(fetch).toHaveBeenNthCalledWith(4, expect.stringContaining('/test.json'), initTest); - }) - - test("it should set the LibResilient headers, setting X-LibResilient-ETag based on Last-Modified (if ETag is unavailable in the original response)", async () => { - require("../../../plugins/dnslink-fetch/index.js"); - - global.fetchResponse = [{Status: 0, Answer: [{type: 16, data: 'dnslink=/https/example.org'}, {type: 16, data: 'dnslink=/http/example.net/some/path'}]}, "application/json"] - const response = await LibResilientPluginConstructors.get('dnslink-fetch')(LR, init).fetch('https://resilient.is/test.json'); - - expect(fetch).toHaveBeenCalledTimes(3); - expect(await response.json()).toEqual(global.fetchResponse[0]) - expect(response.url).toEqual('https://resilient.is/test.json') - expect(response.headers.has('X-LibResilient-Method')).toEqual(true) - expect(response.headers.get('X-LibResilient-Method')).toEqual('dnslink-fetch') - expect(response.headers.has('X-LibResilient-Etag')).toEqual(true) - expect(response.headers.get('X-LibResilient-ETag')).toEqual('TestingLastModifiedHeader') - }); - - test("it should throw an error when HTTP status is >= 400", async () => { - - global.fetch.mockImplementation((url, init) => { - if (url.startsWith('https://dns.google/resolve')) { - const response = new Response( - new Blob( - [JSON.stringify(fetchResponse[0])], - {type: fetchResponse[1]} - ), - { - status: 200, - statusText: "OK", - headers: { - 'Last-Modified': 'TestingLastModifiedHeader' - }, - url: url - }); - return Promise.resolve(response); - } else { - const response = new Response( - new Blob( - ["Not Found"], - {type: "text/plain"} - ), - { - status: 404, - statusText: "Not Found", - url: url - }); - return Promise.resolve(response); - } - }); - - require("../../../plugins/dnslink-fetch/index.js"); - global.fetchResponse = [{Status: 0, Answer: [{type: 16, data: 'dnslink=/https/example.org'}, {type: 16, data: 'dnslink=/http/example.net/some/path'}]}, "application/json"] - expect.assertions(1) - expect(LibResilientPluginConstructors.get('dnslink-fetch')(LR, init).fetch('https://resilient.is/test.json')).rejects.toThrow(Error) - }); - -}); diff --git a/__tests__/plugins/dnslink-ipfs/index.test.js b/__tests__/plugins/dnslink-ipfs/index.test.js deleted file mode 100644 index cac8f07..0000000 --- a/__tests__/plugins/dnslink-ipfs/index.test.js +++ /dev/null @@ -1,206 +0,0 @@ -const makeServiceWorkerEnv = require('service-worker-mock'); - -describe("plugin: dnslink-ipfs", () => { - beforeEach(() => { - Object.assign(global, makeServiceWorkerEnv()); - jest.resetModules(); - init = { - name: 'dnslink-ipfs', - gunPubkey: 'stub' - } - global.LibResilientPluginConstructors = new Map() - LR = { - log: jest.fn((component, ...items)=>{ - console.debug(component + ' :: ', ...items) - }) - } - - global.Ipfs = { - ipfsFixtureAddress: 'QmiPFSiPFSiPFSiPFSiPFSiPFSiPFSiPFSiPFSiPFSiPFS', - create: ()=>{ - return Promise.resolve({ - cat: (path)=>{ - return { - sourceUsed: false, - next: ()=>{ - if (path.endsWith('nonexistent.path')) { - throw new Error('Error: file does not exist') - } - var prevSourceUsed = self.sourceUsed - self.sourceUsed = true - var val = undefined - if (!prevSourceUsed) { - var val = Uint8Array.from( - Array - .from(JSON.stringify({ - test: "success", - path: path - })) - .map( - letter => letter.charCodeAt(0) - ) - ) - } - return Promise.resolve({ - done: prevSourceUsed, - value: val - }) - } - } - }, - name: { - resolve: (path)=>{ - var result = path.replace( - '/ipns/' + self.location.origin.replace('https://', ''), - '/ipfs/' + Ipfs.ipfsFixtureAddress - ) - return { - next: ()=> { - return Promise.resolve({ - done: false, - value: result - }) - } - } - } - } - }) - } - } - self.Ipfs = global.Ipfs - - }) - - test("it should register in LibResilientPlugins", () => { - require("../../../plugins/dnslink-ipfs/index.js"); - expect(LibResilientPluginConstructors.get('dnslink-ipfs')(LR, init).name).toEqual('dnslink-ipfs'); - }); - - test("IPFS setup should be initiated", async ()=>{ - self.importScripts = jest.fn() - require("../../../plugins/dnslink-ipfs/index.js"); - try { - await LibResilientPluginConstructors.get('dnslink-ipfs')(LR, init).fetch('/test.json') - } catch {} - expect(self.importScripts).toHaveBeenNthCalledWith(1, './lib/ipfs.js') - }) - - test("fetching should error out for unpublished content", async ()=>{ - require("../../../plugins/dnslink-ipfs/index.js"); - - expect.assertions(1) - try { - await LibResilientPluginConstructors.get('dnslink-ipfs')(LR, init).fetch(self.location.origin + '/nonexistent.path') - } catch(e) { - expect(e).toEqual(new Error('Error: file does not exist')) - } - }) - - // TODO: probably not necessary in the long run? - test("fetching a path ending in / should instead fetch /index.html", async ()=>{ - require("../../../plugins/dnslink-ipfs/index.js"); - - try { - var response = await LibResilientPluginConstructors.get('dnslink-ipfs')(LR, init).fetch(self.location.origin + '/test/') - } catch(e) { - } - var blob = await response.blob() - expect(JSON.parse(new TextDecoder().decode(blob.parts[0]))).toEqual({test: "success", path: "/ipfs/" + global.Ipfs.ipfsFixtureAddress + '/test/index.html'}) - }) - - test("content types should be guessed correctly when fetching", async ()=>{ - require("../../../plugins/dnslink-ipfs/index.js"); - var dnslinkIpfsPlugin = LibResilientPluginConstructors.get('dnslink-ipfs')(LR, init) - try { - await dnslinkIpfsPlugin.fetch(self.location.origin + '/test/') - } catch(e) {} - expect(LR.log).toHaveBeenCalledWith('dnslink-ipfs', " +-- guessed contentType : text/html") - LR.log.mockClear() - - try { - await dnslinkIpfsPlugin.fetch(self.location.origin + '/test.htm') - } catch(e) {} - expect(LR.log).toHaveBeenCalledWith('dnslink-ipfs', " +-- guessed contentType : text/html") - LR.log.mockClear() - - try { - await dnslinkIpfsPlugin.fetch(self.location.origin + '/test.css') - } catch(e) {} - expect(LR.log).toHaveBeenCalledWith('dnslink-ipfs', " +-- guessed contentType : text/css") - LR.log.mockClear() - - try { - await dnslinkIpfsPlugin.fetch(self.location.origin + '/test.js') - } catch(e) {} - expect(LR.log).toHaveBeenCalledWith('dnslink-ipfs', " +-- guessed contentType : text/javascript") - LR.log.mockClear() - - try { - await dnslinkIpfsPlugin.fetch(self.location.origin + '/test.json') - } catch(e) {} - expect(LR.log).toHaveBeenCalledWith('dnslink-ipfs', " +-- guessed contentType : application/json") - LR.log.mockClear() - - try { - await dnslinkIpfsPlugin.fetch(self.location.origin + '/test.svg') - } catch(e) {} - expect(LR.log).toHaveBeenCalledWith('dnslink-ipfs', " +-- guessed contentType : image/svg+xml") - LR.log.mockClear() - - try { - await dnslinkIpfsPlugin.fetch(self.location.origin + '/test.ico') - } catch(e) {} - expect(LR.log).toHaveBeenCalledWith('dnslink-ipfs', " +-- guessed contentType : image/x-icon") - }) - - test("fetching should work", async ()=>{ - require("../../../plugins/dnslink-ipfs/index.js"); - - let response = await LibResilientPluginConstructors.get('dnslink-ipfs')(LR, init).fetch(self.location.origin + '/test.json') - expect(response.body.type).toEqual('application/json') - var blob = await response.blob() - expect(JSON.parse(new TextDecoder().decode(blob.parts[0]))).toEqual({test: "success", path: "/ipfs/" + global.Ipfs.ipfsFixtureAddress + '/test.json'}) - }) - - test("publish() should throw an error", async ()=>{ - require("../../../plugins/dnslink-ipfs/index.js"); - - expect.assertions(1) - try { - LibResilientPluginConstructors.get('dnslink-ipfs')(LR, init).publish() - } catch(e) { - expect(e).toEqual(new Error("Not implemented yet.")) - } - }) - - test("IPFS load error should be handled", async ()=>{ - - global.Ipfs.create = ()=>{ - throw new Error('Testing IPFS loading failure') - } - require("../../../plugins/dnslink-ipfs/index.js"); - - expect.assertions(1) - try { - await LibResilientPluginConstructors.get('dnslink-ipfs')(LR, init).fetch('/test.json') - } catch(e) { - expect(e).toEqual(new Error("Error: Testing IPFS loading failure")) - } - }) - - test("importScripts being undefined should be handled", async ()=>{ - self.importScripts = undefined - require("../../../plugins/dnslink-ipfs/index.js"); - - try { - await LibResilientPluginConstructors.get('dnslink-ipfs')(LR, init).fetch('/test.json') - } catch(e) { - } - - expect(LR.log).toHaveBeenCalledWith("dnslink-ipfs", "Importing IPFS-related libraries...") - expect(LR.log).toHaveBeenCalledWith("dnslink-ipfs", "assuming these scripts are already included:") - expect(LR.log).toHaveBeenCalledWith("dnslink-ipfs", "+--", "./lib/ipfs.js") - }) - - -}); diff --git a/__tests__/plugins/fetch/index.test.js b/__tests__/plugins/fetch/index.test.js deleted file mode 100644 index 60201ef..0000000 --- a/__tests__/plugins/fetch/index.test.js +++ /dev/null @@ -1,109 +0,0 @@ -const makeServiceWorkerEnv = require('service-worker-mock'); - -global.fetch = require('node-fetch'); -jest.mock('node-fetch') - -describe("plugin: fetch", () => { - beforeEach(() => { - global.fetch.mockImplementation((url, init) => { - const response = new Response( - new Blob( - [JSON.stringify({ test: "success" })], - {type: "application/json"} - ), - { - status: 200, - statusText: "OK", - headers: { - 'ETag': 'TestingETagHeader' - }, - url: url - }); - return Promise.resolve(response); - }); - - Object.assign(global, makeServiceWorkerEnv()); - jest.resetModules(); - global.LibResilientPluginConstructors = new Map() - LR = { - log: (component, ...items)=>{ - console.debug(component + ' :: ', ...items) - } - } - }) - - test("it should register in LibResilientPluginConstructors", () => { - require("../../../plugins/fetch/index.js"); - expect(LibResilientPluginConstructors.get('fetch')().name).toEqual('fetch'); - }); - - test("it should return data from fetch()", async () => { - require("../../../plugins/fetch/index.js"); - - const response = await LibResilientPluginConstructors.get('fetch')(LR).fetch('https://resilient.is/test.json'); - - expect(fetch).toHaveBeenCalled(); - expect(await response.json()).toEqual({test: "success"}) - expect(response.url).toEqual('https://resilient.is/test.json') - }); - - test("it should pass the Request() init data to fetch()", async () => { - require("../../../plugins/fetch/index.js"); - - var initTest = { - method: "GET", - headers: new Headers({"x-stub": "STUB"}), - mode: "mode-stub", - credentials: "credentials-stub", - cache: "cache-stub", - referrer: "referrer-stub", - // these are not implemented by service-worker-mock - // https://github.com/zackargyle/service-workers/blob/master/packages/service-worker-mock/models/Request.js#L20 - redirect: undefined, - integrity: undefined, - cache: undefined - } - - const response = await LibResilientPluginConstructors.get('fetch')(LR).fetch('https://resilient.is/test.json', initTest); - - expect(fetch).toHaveBeenCalledWith('https://resilient.is/test.json', initTest); - expect(await response.json()).toEqual({test: "success"}) - expect(response.url).toEqual('https://resilient.is/test.json') - }); - - test("it should set the LibResilient headers", async () => { - require("../../../plugins/fetch/index.js"); - - const response = await LibResilientPluginConstructors.get('fetch')(LR).fetch('https://resilient.is/test.json'); - - expect(fetch).toHaveBeenCalled(); - expect(await response.json()).toEqual({test: "success"}) - expect(response.url).toEqual('https://resilient.is/test.json') - expect(response.headers.has('X-LibResilient-Method')).toEqual(true) - expect(response.headers.get('X-LibResilient-Method')).toEqual('fetch') - expect(response.headers.has('X-LibResilient-Etag')).toEqual(true) - expect(response.headers.get('X-LibResilient-ETag')).toEqual('TestingETagHeader') - }); - - test("it should throw an error when HTTP status is >= 400", async () => { - - global.fetch.mockImplementation((url, init) => { - const response = new Response( - new Blob( - ["Not Found"], - {type: "text/plain"} - ), - { - status: 404, - statusText: "Not Found", - url: url - }); - return Promise.resolve(response); - }); - - require("../../../plugins/fetch/index.js"); - - expect.assertions(1) - expect(LibResilientPluginConstructors.get('fetch')(LR).fetch('https://resilient.is/test.json')).rejects.toThrow(Error) - }); -}); diff --git a/__tests__/plugins/gun-ipfs/index.test.js b/__tests__/plugins/gun-ipfs/index.test.js deleted file mode 100644 index 0e2c99a..0000000 --- a/__tests__/plugins/gun-ipfs/index.test.js +++ /dev/null @@ -1,198 +0,0 @@ -const makeServiceWorkerEnv = require('service-worker-mock'); - -describe("plugin: gun-ipfs", () => { - beforeEach(() => { - Object.assign(global, makeServiceWorkerEnv()); - jest.resetModules(); - init = { - name: 'gun-ipfs', - gunPubkey: 'stub' - } - global.LibResilientPluginConstructors = new Map() - LR = { - log: jest.fn((component, ...items)=>{ - console.debug(component + ' :: ', ...items) - }) - } - global.Ipfs = { - create: ()=>{ - return Promise.resolve({ - get: ()=>{ - return { - next: ()=>{ - sourceUsed = true - return Promise.resolve({ - value: { - path: 'some-ipfs-looking-address', - content: { - next: ()=>{ - sourceUsed = !sourceUsed - return Promise.resolve({ - done: sourceUsed, - value: Uint8Array.from( - Array - .from('{test: "success"}') - .map( - letter => letter.charCodeAt(0) - ) - ) - }) - } - } - } - }) - } - } - } - }) - } - } - self.Ipfs = global.Ipfs - self.gunUser = jest.fn(()=>{ - return { - get: () => { - return { - get: ()=>{ - return { - once: (arg)=>{ arg(undefined) } - } - } - } - } - } - }) - global.Gun = jest.fn((nodes)=>{ - return { - user: self.gunUser - } - }) - }) - - test("it should register in LibResilientPlugins", () => { - require("../../../plugins/gun-ipfs/index.js"); - expect(LibResilientPluginConstructors.get('gun-ipfs')(LR, init).name).toEqual('gun-ipfs'); - }); - - test("IPFS setup should be initiated", async ()=>{ - self.importScripts = jest.fn() - require("../../../plugins/gun-ipfs/index.js"); - try { - await LibResilientPluginConstructors.get('gun-ipfs')(LR, init).fetch('/test.json') - } catch {} - expect(self.importScripts).toHaveBeenNthCalledWith(1, './lib/ipfs.js') - }) - - test("Gun setup should be initiated", async ()=>{ - self.importScripts = jest.fn() - require("../../../plugins/gun-ipfs/index.js"); - try { - await LibResilientPluginConstructors.get('gun-ipfs')(LR, init).fetch('/test.json') - } catch {} - expect(self.importScripts).toHaveBeenNthCalledWith(2, "./lib/gun.js", "./lib/sea.js", "./lib/webrtc.js") - }) - - test("fetching should error out for unpublished content", async ()=>{ - require("../../../plugins/gun-ipfs/index.js"); - - expect.assertions(1) - try { - await LibResilientPluginConstructors.get('gun-ipfs')(LR, init).fetch(self.location.origin + '/test.json') - } catch(e) { - expect(e).toEqual(new Error('IPFS address is undefined for: /test.json')) - } - }) - - test("fetching a path ending in / should instead fetch /index.html", async ()=>{ - require("../../../plugins/gun-ipfs/index.js"); - - expect.assertions(1) - try { - await LibResilientPluginConstructors.get('gun-ipfs')(LR, init).fetch(self.location.origin + '/test/') - } catch(e) { - expect(e).toEqual(new Error('IPFS address is undefined for: /test/index.html')) - } - }) - - test("content types should be guessed correctly when fetching", async ()=>{ - require("../../../plugins/gun-ipfs/index.js"); - var gunipfsPlugin = LibResilientPluginConstructors.get('gun-ipfs')(LR, init) - try { - await gunipfsPlugin.fetch(self.location.origin + '/test/') - } catch(e) {} - expect(LR.log).toHaveBeenCalledWith('gun-ipfs', " +-- guessed contentType : text/html") - LR.log.mockClear() - - try { - await gunipfsPlugin.fetch(self.location.origin + '/test.htm') - } catch(e) {} - expect(LR.log).toHaveBeenCalledWith('gun-ipfs', " +-- guessed contentType : text/html") - LR.log.mockClear() - - try { - await gunipfsPlugin.fetch(self.location.origin + '/test.css') - } catch(e) {} - expect(LR.log).toHaveBeenCalledWith('gun-ipfs', " +-- guessed contentType : text/css") - LR.log.mockClear() - - try { - await gunipfsPlugin.fetch(self.location.origin + '/test.js') - } catch(e) {} - expect(LR.log).toHaveBeenCalledWith('gun-ipfs', " +-- guessed contentType : text/javascript") - LR.log.mockClear() - - try { - await gunipfsPlugin.fetch(self.location.origin + '/test.json') - } catch(e) {} - expect(LR.log).toHaveBeenCalledWith('gun-ipfs', " +-- guessed contentType : application/json") - LR.log.mockClear() - - try { - await gunipfsPlugin.fetch(self.location.origin + '/test.svg') - } catch(e) {} - expect(LR.log).toHaveBeenCalledWith('gun-ipfs', " +-- guessed contentType : image/svg+xml") - LR.log.mockClear() - - try { - await gunipfsPlugin.fetch(self.location.origin + '/test.ico') - } catch(e) {} - expect(LR.log).toHaveBeenCalledWith('gun-ipfs', " +-- guessed contentType : image/x-icon") - }) - - test("fetching should work (stub!)", async ()=>{ - self.gunUser = jest.fn(()=>{ - return { - get: () => { - return { - get: ()=>{ - return { - once: (arg)=>{ arg('some-ipfs-looking-address') } - } - } - } - } - } - }) - require("../../../plugins/gun-ipfs/index.js"); - - //await self.Ipfs.create() - let response = await LibResilientPluginConstructors.get('gun-ipfs')(LR, init).fetch(self.location.origin + '/test.json') - expect(response.body.type).toEqual('application/json') - expect(String.fromCharCode.apply(null, response.body.parts[0])).toEqual('{test: "success"}') - }) - - test("publishContent should error out if passed anything else than string or array of string", async ()=>{ - require("../../../plugins/gun-ipfs/index.js"); - var gunipfsPlugin = LibResilientPluginConstructors.get('gun-ipfs')(LR, init) - expect(()=>{ - gunipfsPlugin.publish({ - url: self.location.origin + '/test.json' - }) - }).toThrow('Handling a Response: not implemented yet') - expect(()=>{ - gunipfsPlugin.publish(true) - }).toThrow('Only accepts: string, Array of string, Response.') - expect(()=>{ - gunipfsPlugin.publish([true, 5]) - }).toThrow('Only accepts: string, Array of string, Response.') - }) -}); diff --git a/__tests__/plugins/integrity-check/index.test.js b/__tests__/plugins/integrity-check/index.test.js deleted file mode 100644 index 36aab56..0000000 --- a/__tests__/plugins/integrity-check/index.test.js +++ /dev/null @@ -1,208 +0,0 @@ -describe("plugin: integrity-check", () => { - - beforeEach(() => { - global.nodeFetch = require('node-fetch') - global.Request = global.nodeFetch.Request - global.Response = global.nodeFetch.Response - global.crypto = require('crypto').webcrypto - global.Blob = require('buffer').Blob; - jest.resetModules(); - self = global - global.btoa = (bin) => { - return Buffer.from(bin, 'binary').toString('base64') - } - - global.LibResilientPluginConstructors = new Map() - LR = { - log: (component, ...items)=>{ - console.debug(component + ' :: ', ...items) - } - } - - global.resolvingFetch = jest.fn((url, init)=>{ - return Promise.resolve( - new Response( - ['{"test": "success"}'], - { - type: "application/json", - status: 200, - statusText: "OK", - headers: { - 'ETag': 'TestingETagHeader' - }, - url: url - } - ) - ) - }) - - init = { - name: 'integrity-check', - uses: [ - { - name: 'resolve-all', - description: 'Resolves all', - version: '0.0.1', - fetch: resolvingFetch - } - ], - requireIntegrity: false - } - requestInit = { - integrity: "sha256-eiMrFuthzteJuj8fPwUMyNQMb2SMW7VITmmt2oAxGj0=" - } - self.log = function(component, ...items) { - console.debug(component + ' :: ', ...items) - } - }) - - test("it should register in LibResilientPluginConstructors", () => { - require("../../../plugins/integrity-check/index.js"); - expect(LibResilientPluginConstructors.get('integrity-check')(LR, init).name).toEqual('integrity-check'); - }); - - test("it should throw an error when there aren't any wrapped plugins configured", async () => { - require("../../../plugins/integrity-check/index.js"); - init = { - name: 'integrity-check', - uses: [] - } - - expect.assertions(2); - try { - await LibResilientPluginConstructors.get('integrity-check')(LR, init).fetch('https://resilient.is/test.json') - } catch (e) { - expect(e).toBeInstanceOf(Error) - expect(e.toString()).toMatch('Expected exactly one plugin to wrap') - } - }); - - test("it should throw an error when there are more than one wrapped plugins configured", async () => { - require("../../../plugins/integrity-check/index.js"); - init = { - name: 'integrity-check', - uses: [{ - name: 'plugin-1' - },{ - name: 'plugin-2' - }] - } - - expect.assertions(2); - try { - await LibResilientPluginConstructors.get('integrity-check')(LR, init).fetch('https://resilient.is/test.json') - } catch (e) { - expect(e).toBeInstanceOf(Error) - expect(e.toString()).toMatch('Expected exactly one plugin to wrap') - } - }); - - test("it should throw an error when an unsupported digest algorithm is used", async () => { - require("../../../plugins/integrity-check/index.js"); - - expect.assertions(1); - try { - await LibResilientPluginConstructors.get('integrity-check')(LR, init).fetch('https://resilient.is/test.json', { - integrity: "sha000-eiMrFuthzteJuj8fPwUMyNQMb2SMW7VITmmt2oAxGj0=" - }) - } catch (e) { - expect(e.toString()).toMatch('No digest matched') - } - }); - - test("it should return data from the wrapped plugin when no integrity data is available and requireIntegrity is false", async () => { - require("../../../plugins/integrity-check/index.js"); - - const response = await LibResilientPluginConstructors.get('integrity-check')(LR, init).fetch('https://resilient.is/test.json'); - - expect(resolvingFetch).toHaveBeenCalled(); - expect(await response.json()).toEqual({test: "success"}) - expect(response.url).toEqual('https://resilient.is/test.json') - }); - - test("it should reject no integrity data is available but requireIntegrity is true", async () => { - require("../../../plugins/integrity-check/index.js"); - init.requireIntegrity = true - - expect.assertions(2); - try { - await LibResilientPluginConstructors.get('integrity-check')(LR, init).fetch('https://resilient.is/test.json') - } catch (e) { - expect(e).toBeInstanceOf(Error) - expect(e.toString()).toMatch('Integrity data required but not provided for') - } - }); - - test("it should check integrity and return data from the wrapped plugin if SHA-256 integrity data matches", async () => { - require("../../../plugins/integrity-check/index.js"); - - const response = await LibResilientPluginConstructors.get('integrity-check')(LR, init).fetch('https://resilient.is/test.json', requestInit); - - expect(resolvingFetch).toHaveBeenCalled(); - expect(await response.json()).toEqual({test: "success"}) - expect(response.url).toEqual('https://resilient.is/test.json') - }); - - test("it should check integrity and return data from the wrapped plugin if SHA-384 integrity data matches", async () => { - require("../../../plugins/integrity-check/index.js"); - - const response = await LibResilientPluginConstructors.get('integrity-check')(LR, init).fetch('https://resilient.is/test.json', { - integrity: "sha384-x4iqiH3PIPD51TibGEhTju/WhidcIEcnrpdklYEtIS87f96c4nLyj6CuwUp8kyOo" - }); - - expect(resolvingFetch).toHaveBeenCalled(); - expect(await response.json()).toEqual({test: "success"}) - expect(response.url).toEqual('https://resilient.is/test.json') - }); - - test("it should check integrity and return data from the wrapped plugin if SHA-512 integrity data matches", async () => { - require("../../../plugins/integrity-check/index.js"); - - const response = await LibResilientPluginConstructors.get('integrity-check')(LR, init).fetch('https://resilient.is/test.json', { - integrity: "sha512-o+J3lPk7DU8xOJaNfZI5T4Upmaoc9XOVxOWPCFAy4pTgvS8LrJZ8iNis/2ZaryU4bB33cNSXQBxUDvwDxknEBQ==" - }); - - expect(resolvingFetch).toHaveBeenCalled(); - expect(await response.json()).toEqual({test: "success"}) - expect(response.url).toEqual('https://resilient.is/test.json') - }); - - test("it should check integrity of the data returned from the wrapped plugin and reject if it doesn't match", async () => { - require("../../../plugins/integrity-check/index.js"); - - expect.assertions(1); - try { - await LibResilientPluginConstructors.get('integrity-check')(LR, init).fetch('https://resilient.is/test.json', { - integrity: "sha256-INCORRECTINCORRECTINCORRECTINCORRECTINCORREC" - }); - } catch(e) { - expect(e.toString()).toMatch('No digest matched') - } - }); - - test("it should check integrity of the data returned from the wrapped plugin and resolve if at least one of multiple integrity hash matches", async () => { - require("../../../plugins/integrity-check/index.js"); - - const response = await LibResilientPluginConstructors.get('integrity-check')(LR, init).fetch('https://resilient.is/test.json', { - integrity: "sha256-INCORRECTINCORRECTINCORRECTINCORRECTINCORREC sha256-eiMrFuthzteJuj8fPwUMyNQMb2SMW7VITmmt2oAxGj0=" - }); - - expect(resolvingFetch).toHaveBeenCalled(); - expect(await response.json()).toEqual({test: "success"}) - expect(response.url).toEqual('https://resilient.is/test.json') - }); - - test("it should check integrity of the data returned from the wrapped plugin and reject if all out of multiple integrity hash do not match", async () => { - require("../../../plugins/integrity-check/index.js"); - - expect.assertions(1); - try { - await LibResilientPluginConstructors.get('integrity-check')(LR, init).fetch('https://resilient.is/test.json', { - integrity: "sha256-INCORRECTINCORRECTINCORRECTINCORRECTINCORREC sha256-WRONGWRONGWRONGWRONGWRONGWRONGWRONGWRONGWRON" - }); - } catch(e) { - expect(e.toString()).toMatch('No digest matched') - } - }); - -}); diff --git a/__tests__/plugins/ipns-ipfs/index.test.js b/__tests__/plugins/ipns-ipfs/index.test.js deleted file mode 100644 index 0fda008..0000000 --- a/__tests__/plugins/ipns-ipfs/index.test.js +++ /dev/null @@ -1,22 +0,0 @@ -const makeServiceWorkerEnv = require('service-worker-mock'); - -describe("plugin: ipns-ipfs", () => { - beforeEach(() => { - Object.assign(global, makeServiceWorkerEnv()); - jest.resetModules(); - global.LibResilientPluginConstructors = new Map() - init = { - name: 'ipns-ipfs', - ipnsPubkey: 'stub' - } - LR = { - log: (component, ...items)=>{ - console.debug(component + ' :: ', ...items) - } - } - }) - test("it should register in LibResilientPlugins", () => { - require("../../../plugins/ipns-ipfs/index.js"); - expect(LibResilientPluginConstructors.get('ipns-ipfs')(LR, init).name).toEqual('ipns-ipfs'); - }); -}); diff --git a/__tests__/plugins/redirect/index.test.js b/__tests__/plugins/redirect/index.test.js deleted file mode 100644 index 5414ba0..0000000 --- a/__tests__/plugins/redirect/index.test.js +++ /dev/null @@ -1,102 +0,0 @@ -const makeServiceWorkerEnv = require('service-worker-mock'); - -global.fetch = require('node-fetch'); -jest.mock('node-fetch') - -describe("plugin: redirect", () => { - - beforeEach(() => { - Object.assign(global, makeServiceWorkerEnv()); - jest.resetModules(); - global.LibResilientPluginConstructors = new Map() - init = { - name: 'redirect', - redirectStatus: 302, - redirectStatusText: "Found", - redirectTo: "https://redirected.example.org/subdir/" - } - LR = { - log: (component, ...items)=>{ - console.debug(component + ' :: ', ...items) - } - } - }) - - test("it should register in LibResilientPluginConstructors", () => { - init = { - name: 'redirect', - redirectTo: 'https://example.org/' - } - require("../../../plugins/redirect/index.js"); - expect(LibResilientPluginConstructors.get('redirect')(LR, init).name).toEqual('redirect'); - }); - - test("it should fail with incorrect redirectTo config value", () => { - init = { - name: 'redirect', - redirectTo: false - } - require("../../../plugins/redirect/index.js") - expect.assertions(1) - expect(()=>{ - LibResilientPluginConstructors.get('redirect')(LR, init) - }).toThrow(Error); - }); - - test("it should fail with incorrect redirectStatus config value", () => { - init = { - name: 'redirect', - redirectTo: 'https://example.org/', - redirectStatus: 'incorrect' - } - require("../../../plugins/redirect/index.js") - expect.assertions(1) - expect(()=>{ - LibResilientPluginConstructors.get('redirect')(LR, init) - }).toThrow(Error); - }); - - test("it should fail with incorrect redirectStatusText config value", () => { - init = { - name: 'redirect', - redirectTo: 'https://example.org/', - redirectStatusText: false - } - require("../../../plugins/redirect/index.js") - expect.assertions(1) - expect(()=>{ - LibResilientPluginConstructors.get('redirect')(LR, init) - }).toThrow(Error); - }); - - test("it should register in LibResilientPluginConstructors without error even if all config data is incorrect, as long as enabled is false", () => { - init = { - name: 'redirect', - redirectTo: false, - redirectStatus: "incorrect", - redirectStatusText: false, - enabled: false - } - require("../../../plugins/redirect/index.js"); - expect(LibResilientPluginConstructors.get('redirect')(LR, init).name).toEqual('redirect'); - }); - - test("it should return a 302 Found redirect for any request", async () => { - init = { - name: 'redirect', - redirectTo: "https://redirected.example.org/subdirectory/" - } - - require("../../../plugins/redirect/index.js"); - - const response = await LibResilientPluginConstructors.get('redirect')(LR, init).fetch('https://resilient.is/test.json'); - - - //expect().toEqual() - expect(response.url).toEqual('https://resilient.is/test.json') - expect(response.status).toEqual(302) - expect(response.statusText).toEqual('Found') - expect(response.headers.get('location')).toEqual('https://redirected.example.org/subdirectory/test.json') - }) - -}); diff --git a/__tests__/plugins/signed-integrity/index.test.js b/__tests__/plugins/signed-integrity/index.test.js deleted file mode 100644 index a1e3cde..0000000 --- a/__tests__/plugins/signed-integrity/index.test.js +++ /dev/null @@ -1,366 +0,0 @@ -const { subtle } = require('crypto').webcrypto; - -describe("plugin: signed-integrity", () => { - - var keypair = null - - async function generateECDSAKeypair() { - if (keypair == null) { - keypair = await subtle.generateKey({ - name: "ECDSA", - namedCurve: "P-384" - }, - true, - ["sign", "verify"] - ); - } - - return keypair; - } - - async function getArmouredKey(key) { - return JSON.stringify(await subtle.exportKey('jwk', key)) - } - - beforeEach(async () => { - global.nodeFetch = require('node-fetch') - global.Request = global.nodeFetch.Request - global.Response = global.nodeFetch.Response - global.crypto = require('crypto').webcrypto - global.Blob = require('buffer').Blob; - jest.resetModules(); - self = global - global.subtle = subtle - global.btoa = (bin) => { - return Buffer.from(bin, 'binary').toString('base64') - } - global.atob = (ascii) => { - return Buffer.from(ascii, 'base64').toString('binary') - } - - global.LibResilientPluginConstructors = new Map() - LR = { - log: (component, ...items)=>{ - console.debug(component + ' :: ', ...items) - } - } - - // debug - console.log("pubkey: ", await getArmouredKey((await generateECDSAKeypair()).publicKey)) - - // ES384: ECDSA using P-384 and SHA-384 - var header = btoa('{"alg": "ES384"}').replace(/\//g, '_').replace(/\+/g, '-').replace(/=/g, '') - var payload = btoa('{"integrity": "sha256-eiMrFuthzteJuj8fPwUMyNQMb2SMW7VITmmt2oAxGj0="}').replace(/\//g, '_').replace(/\+/g, '-').replace(/=/g, '') - - // get a signature - var signature = await subtle.sign( - { - name: "ECDSA", - hash: {name: "SHA-384"} - }, - (await generateECDSAKeypair()).privateKey, - (header + '.' + payload) - ) - // prepare it for inclusion in the JWT - signature = btoa(signature).replace(/\//g, '_').replace(/\+/g, '-').replace(/=/g, '') - - // need to test with bad algo! - var noneHeader = btoa('{"alg": "none"}').replace(/\//g, '_').replace(/\+/g, '-').replace(/=/g, '') - - // get an invalid signature - // an ECDSA signature for {alg: none} header makes zero sense - var noneSignature = await subtle.sign( - { - name: "ECDSA", - hash: {name: "SHA-384"} - }, - (await generateECDSAKeypair()).privateKey, - (noneHeader + '.' + payload) - ) - // prepare it for inclusion in the JWT - noneSignature = btoa(noneSignature).replace(/\//g, '_').replace(/\+/g, '-').replace(/=/g, '') - - // prepare stuff for invalid JWT JSON test - var invalidPayload = btoa('not a valid JSON string').replace(/\//g, '_').replace(/\+/g, '-').replace(/=/g, '') - // get an valid signature for invalid payload - var invalidPayloadSignature = await subtle.sign( - { - name: "ECDSA", - hash: {name: "SHA-384"} - }, - (await generateECDSAKeypair()).privateKey, - (header + '.' + invalidPayload) - ) - // prepare it for inclusion in the JWT - invalidPayloadSignature = btoa(invalidPayloadSignature).replace(/\//g, '_').replace(/\+/g, '-').replace(/=/g, '') - - // prepare stuff for JWT payload without integrity test - var noIntegrityPayload = btoa('{"no": "integrity"}').replace(/\//g, '_').replace(/\+/g, '-').replace(/=/g, '') - // get an valid signature for invalid payload - var noIntegrityPayloadSignature = await subtle.sign( - { - name: "ECDSA", - hash: {name: "SHA-384"} - }, - (await generateECDSAKeypair()).privateKey, - (header + '.' + noIntegrityPayload) - ) - // prepare it for inclusion in the JWT - noIntegrityPayloadSignature = btoa(noIntegrityPayloadSignature).replace(/\//g, '_').replace(/\+/g, '-').replace(/=/g, '') - - global.resolvingFetch = jest.fn((url, init)=>{ - var content = '{"test": "success"}' - var status = 200 - var statusText = "OK" - - if (url == 'https://resilient.is/test.json.integrity') { - content = header + '.' + payload + '.' + signature - // testing 404 not found on the integrity URL - } else if (url == 'https://resilient.is/not-found.json.integrity') { - content = '{"test": "fail"}' - status = 404 - statusText = "Not Found" - // testing invalid base64-encoded data - } else if (url == 'https://resilient.is/invalid-base64.json.integrity') { - // for this test to work correctly the length must be (n*4)+1 - content = header + '.' + payload + '.' + 'badbase64' - // testing "alg: none" on the integrity JWT - } else if (url == 'https://resilient.is/alg-none.json.integrity') { - content = noneHeader + '.' + payload + '.' - // testing bad signature on the integrity JWT - } else if (url == 'https://resilient.is/bad-signature.json.integrity') { - content = header + '.' + payload + '.' + noneSignature - // testing invalid payload - } else if (url == 'https://resilient.is/invalid-payload.json.integrity') { - content = header + '.' + invalidPayload + '.' + invalidPayloadSignature - // testing payload without integrity data - } else if (url == 'https://resilient.is/no-integrity.json.integrity') { - content = header + '.' + noIntegrityPayload + '.' + noIntegrityPayloadSignature - } - - return Promise.resolve( - new Response( - [content], - { - type: "application/json", - status: status, - statusText: statusText, - headers: { - 'ETag': 'TestingETagHeader' - }, - url: url - } - ) - ) - }) - - init = { - name: 'signed-integrity', - uses: [ - { - name: 'resolve-all', - description: 'Resolves all', - version: '0.0.1', - fetch: resolvingFetch - } - ], - requireIntegrity: false, - publicKey: await subtle.exportKey('jwk', (await generateECDSAKeypair()).publicKey) - } - requestInit = { - integrity: "sha256-eiMrFuthzteJuj8fPwUMyNQMb2SMW7VITmmt2oAxGj0=" - } - self.log = function(component, ...items) { - console.debug(component + ' :: ', ...items) - } - }) - - test("it should register in LibResilientPluginConstructors", () => { - require("../../../plugins/signed-integrity/index.js"); - expect(LibResilientPluginConstructors.get('signed-integrity')(LR, init).name).toEqual('signed-integrity'); - }); - - test("it should throw an error when there aren't any wrapped plugins configured", async () => { - require("../../../plugins/signed-integrity/index.js"); - init = { - name: 'signed-integrity', - uses: [] - } - - expect.assertions(2); - try { - await LibResilientPluginConstructors.get('signed-integrity')(LR, init).fetch('https://resilient.is/test.json') - } catch (e) { - expect(e).toBeInstanceOf(Error) - expect(e.toString()).toMatch('Expected exactly one plugin to wrap') - } - }); - - test("it should throw an error if the configured public key is impossible to load", async () => { - require("../../../plugins/signed-integrity/index.js"); - - init.publicKey = 'NOTAKEY' - - expect.assertions(2); - try { - await LibResilientPluginConstructors.get('signed-integrity')(LR, init).fetch('https://resilient.is/test.json') - } catch (e) { - expect(e).toBeInstanceOf(Error) - expect(e.toString()).toMatch('Unable to load the public key') - } - }); - - test("it should throw an error when there are more than one wrapped plugins configured", async () => { - require("../../../plugins/signed-integrity/index.js"); - init = { - name: 'signed-integrity', - uses: [{ - name: 'plugin-1' - },{ - name: 'plugin-2' - }] - } - - expect.assertions(2); - try { - await LibResilientPluginConstructors.get('signed-integrity')(LR, init).fetch('https://resilient.is/test.json') - } catch (e) { - expect(e).toBeInstanceOf(Error) - expect(e.toString()).toMatch('Expected exactly one plugin to wrap') - } - }); - - test("it should fetch content when integrity data provided without trying to fetch the integrity data URL", async () => { - require("../../../plugins/signed-integrity/index.js"); - - const response = await LibResilientPluginConstructors.get('signed-integrity')(LR, init).fetch('https://resilient.is/test.json', { - integrity: "sha384-x4iqiH3PIPD51TibGEhTju/WhidcIEcnrpdklYEtIS87f96c4nLyj6CuwUp8kyOo" - }); - - expect(resolvingFetch).toHaveBeenCalledTimes(1); - expect(await response.json()).toEqual({test: "success"}) - expect(response.url).toEqual('https://resilient.is/test.json') - }); - - test("it should fetch content when integrity data not provided, by also fetching the integrity data URL", async () => { - require("../../../plugins/signed-integrity/index.js"); - - const response = await LibResilientPluginConstructors.get('signed-integrity')(LR, init).fetch('https://resilient.is/test.json', {}); - - expect(resolvingFetch).toHaveBeenCalledTimes(2); - expect(resolvingFetch).toHaveBeenNthCalledWith(1, 'https://resilient.is/test.json.integrity') - expect(await response.json()).toEqual({test: "success"}) - expect(response.url).toEqual('https://resilient.is/test.json') - }); - - test("it should fetch content when integrity data not provided, and integrity data URL 404s", async () => { - require("../../../plugins/signed-integrity/index.js"); - - const response = await LibResilientPluginConstructors.get('signed-integrity')(LR, init).fetch('https://resilient.is/not-found.json', {}); - - expect(resolvingFetch).toHaveBeenCalledTimes(2); - expect(resolvingFetch).toHaveBeenNthCalledWith(1, 'https://resilient.is/not-found.json.integrity') - expect(await response.json()).toEqual({test: "success"}) - expect(response.url).toEqual('https://resilient.is/not-found.json') - }); - - test("it should refuse to fetch content when integrity data not provided and integrity data URL 404s, but requireIntegrity is set to true", async () => { - require("../../../plugins/signed-integrity/index.js"); - - var newInit = init - newInit.requireIntegrity = true - - expect.assertions(4); - try { - const response = await LibResilientPluginConstructors.get('signed-integrity')(LR, newInit).fetch('https://resilient.is/not-found.json', {}); - } catch (e) { - expect(resolvingFetch).toHaveBeenCalledTimes(1); - expect(resolvingFetch).toHaveBeenCalledWith('https://resilient.is/not-found.json.integrity') - expect(e).toBeInstanceOf(Error) - expect(e.toString()).toMatch('No integrity data available, though required.') - } - }); - - test("it should refuse to fetch content when integrity data not provided and integrity data URL is fetched, but JWT is invalid", async () => { - require("../../../plugins/signed-integrity/index.js"); - - expect.assertions(4); - try { - const response = await LibResilientPluginConstructors.get('signed-integrity')(LR, init).fetch('https://resilient.is/invalid-base64.json', {}); - } catch (e) { - expect(resolvingFetch).toHaveBeenCalledTimes(1); - expect(resolvingFetch).toHaveBeenCalledWith('https://resilient.is/invalid-base64.json.integrity') - expect(e).toBeInstanceOf(Error) - expect(e.toString()).toMatch('Invalid base64-encoded string') - } - }); - - test("it should refuse to fetch content when integrity data not provided and integrity data URL is fetched, but JWT uses alg: none", async () => { - require("../../../plugins/signed-integrity/index.js"); - - expect.assertions(4); - try { - const response = await LibResilientPluginConstructors.get('signed-integrity')(LR, init).fetch('https://resilient.is/alg-none.json', {}); - } catch (e) { - expect(resolvingFetch).toHaveBeenCalledTimes(1); - expect(resolvingFetch).toHaveBeenCalledWith('https://resilient.is/alg-none.json.integrity') - expect(e).toBeInstanceOf(Error) - expect(e.toString()).toMatch('JWT seems invalid (one or more sections are empty)') - } - }); - - test("it should refuse to fetch content when integrity data not provided and integrity data URL is fetched, but JWT signature check fails", async () => { - require("../../../plugins/signed-integrity/index.js"); - - expect.assertions(4); - try { - const response = await LibResilientPluginConstructors.get('signed-integrity')(LR, init).fetch('https://resilient.is/bad-signature.json', {}); - } catch (e) { - expect(resolvingFetch).toHaveBeenCalledTimes(1); - expect(resolvingFetch).toHaveBeenCalledWith('https://resilient.is/bad-signature.json.integrity') - expect(e).toBeInstanceOf(Error) - expect(e.toString()).toMatch('JWT signature validation failed') - } - }); - - test("it should refuse to fetch content when integrity data not provided and integrity data URL is fetched, but JWT payload is unparseable", async () => { - require("../../../plugins/signed-integrity/index.js"); - - expect.assertions(4); - try { - const response = await LibResilientPluginConstructors.get('signed-integrity')(LR, init).fetch('https://resilient.is/invalid-payload.json', {}); - } catch (e) { - expect(resolvingFetch).toHaveBeenCalledTimes(1); - expect(resolvingFetch).toHaveBeenCalledWith('https://resilient.is/invalid-payload.json.integrity') - expect(e).toBeInstanceOf(Error) - expect(e.toString()).toMatch('JWT payload parsing failed') - } - }); - - test("it should refuse to fetch content when integrity data not provided and integrity data URL is fetched, but JWT payload does not contain integrity data", async () => { - require("../../../plugins/signed-integrity/index.js"); - - expect.assertions(4); - try { - const response = await LibResilientPluginConstructors.get('signed-integrity')(LR, init).fetch('https://resilient.is/no-integrity.json', {}); - } catch (e) { - expect(resolvingFetch).toHaveBeenCalledTimes(1); - expect(resolvingFetch).toHaveBeenCalledWith('https://resilient.is/no-integrity.json.integrity') - expect(e).toBeInstanceOf(Error) - expect(e.toString()).toMatch('JWT payload did not contain integrity data') - } - }); - - test("it should fetch and verify content, when integrity data not provided, by fetching the integrity data URL and using integrity data from it", async () => { - require("../../../plugins/signed-integrity/index.js"); - - const response = await LibResilientPluginConstructors.get('signed-integrity')(LR, init).fetch('https://resilient.is/test.json', {}); - - expect(resolvingFetch).toHaveBeenCalledTimes(2); - expect(resolvingFetch).toHaveBeenNthCalledWith(1, 'https://resilient.is/test.json.integrity') - expect(resolvingFetch).toHaveBeenNthCalledWith(2, 'https://resilient.is/test.json', {integrity: "sha256-eiMrFuthzteJuj8fPwUMyNQMb2SMW7VITmmt2oAxGj0="}) - expect(await response.json()).toEqual({test: "success"}) - expect(response.url).toEqual('https://resilient.is/test.json') - }); - -}); diff --git a/__tests__/service-worker.test.js b/__tests__/service-worker.test.js deleted file mode 100644 index abb8b55..0000000 --- a/__tests__/service-worker.test.js +++ /dev/null @@ -1,1860 +0,0 @@ -const makeServiceWorkerEnv = require('service-worker-mock'); - -global.fetch = require('node-fetch'); -jest.mock('node-fetch') - - -describe("service-worker", () => { - beforeEach(() => { - - global.fetch.mockImplementation((url, init) => { - return Promise.resolve( - new Response( - new Blob( - [JSON.stringify({ test: "success" })], - {type: "application/json"} - ), - { - status: 200, - statusText: "OK", - headers: { - 'ETag': 'TestingETagHeader' - }, - url: url - }) - ); - }); - - Object.assign(global, makeServiceWorkerEnv()); - global.self = new ServiceWorkerGlobalScope() - jest.resetModules(); - self.LibResilientPlugins = new Array() - self.importScripts = jest.fn((url)=>{ - console.debug(`importScripts('../${url}')`) - try { - require('../' + url); - } catch(e) {} - }) - // TODO: pretty ugly, but necessary for some reason... - global.LibResilientPluginConstructors = new Map() - self.LibResilientPluginConstructors = global.LibResilientPluginConstructors - }) - - test("reality-check: Promise.any() polyfill should work", async () => { - self.LibResilientPlugins = false - self.LibResilientConfig = { - plugins: [], - loggedComponents: [ - 'service-worker' - ] - } - expect.assertions(4) - // we want to make sure to actually test the polyfill - Promise.any = undefined - expect(typeof Promise.any).toEqual('undefined') - require("../service-worker.js"); - expect(typeof Promise.any).toEqual('function') - expect(await Promise.any([ - Promise.resolve('test resolve 1'), - Promise.reject('test reject 2') - ])).toEqual('test resolve 1') - try { - await Promise.any([ - Promise.reject('test reject 1'), - Promise.reject('test reject 2') - ]) - } catch (e) { - expect(e).toEqual([ - "test reject 1", - "test reject 2" - ]) - } - }) - - test("basic set-up: LibResilientPlugins", async () => { - self.LibResilientPlugins = false - self.LibResilientConfig = { - plugins: [], - loggedComponents: [ - 'service-worker' - ] - } - require("../service-worker.js"); - expect(self.LibResilientPlugins).toBeInstanceOf(Array) - }) - - test("basic set-up: use default LibResilientConfig values when config.json missing", async () => { - self.LibResilientConfig = null - - global.fetch.mockImplementation((url, init) => { - return Promise.resolve( - new Response( - new Blob( - [JSON.stringify({ test: "fail" })], - {type: "application/json"} - ), - { - status: 404, - statusText: "Not Found", - headers: { - 'ETag': 'TestingETagHeader' - }, - url: url - }) - ); - }); - - try { - require("../service-worker.js"); - } catch(e) {} - await self.trigger('install') - // this is silly but works, and is necessary because - // event.waitUntil() in the install event handler is not handled correctly in NodeJS - await new Promise(resolve => setTimeout(resolve, 100)); - await self.trigger('activate') - expect(typeof self.LibResilientConfig).toEqual('object') - expect(self.LibResilientConfig.defaultPluginTimeout).toBe(10000) - expect(self.LibResilientConfig.plugins).toStrictEqual([{name: "fetch"},{name: "cache"}]) - expect(self.LibResilientConfig.loggedComponents).toStrictEqual(['service-worker', 'fetch', 'cache']) - expect(fetch).toHaveBeenCalled(); - }) - - test("basic set-up: use default LibResilientConfig values when config.json not valid JSON", async () => { - self.LibResilientConfig = null - - global.fetch.mockImplementation((url, init) => { - return Promise.resolve( - new Response( - new Blob( - ["not a JSON"], - {type: "application/json"} - ), - { - status: 200, - statusText: "OK", - headers: { - 'ETag': 'TestingETagHeader' - }, - url: url - }) - ); - }); - - try { - require("../service-worker.js"); - } catch(e) {} - await self.trigger('install') - // this is silly but works, and is necessary because - // event.waitUntil() in the install event handler is not handled correctly in NodeJS - await new Promise(resolve => setTimeout(resolve, 100)); - await self.trigger('activate') - expect(typeof self.LibResilientConfig).toEqual('object') - expect(self.LibResilientConfig.defaultPluginTimeout).toBe(10000) - expect(self.LibResilientConfig.plugins).toStrictEqual([{name: "fetch"},{name: "cache"}]) - expect(self.LibResilientConfig.loggedComponents).toStrictEqual(['service-worker', 'fetch', 'cache']) - expect(fetch).toHaveBeenCalled(); - }) - - test("basic set-up: use default LibResilientConfig values when no valid 'plugins' field in config.json", async () => { - self.LibResilientConfig = null - - global.fetch.mockImplementation((url, init) => { - return Promise.resolve( - new Response( - new Blob( - [JSON.stringify({loggedComponents: ['service-worker', 'fetch'], plugins: 'not a valid array'})], - {type: "application/json"} - ), - { - status: 200, - statusText: "OK", - headers: { - 'ETag': 'TestingETagHeader' - }, - url: url - }) - ); - }); - - try { - require("../service-worker.js"); - } catch(e) {} - await self.trigger('install') - // this is silly but works, and is necessary because - // event.waitUntil() in the install event handler is not handled correctly in NodeJS - await new Promise(resolve => setTimeout(resolve, 100)); - await self.trigger('activate') - expect(typeof self.LibResilientConfig).toEqual('object') - expect(self.LibResilientConfig.defaultPluginTimeout).toBe(10000) - expect(self.LibResilientConfig.plugins).toStrictEqual([{name: "fetch"},{name: "cache"}]) - expect(self.LibResilientConfig.loggedComponents).toStrictEqual(['service-worker', 'fetch', 'cache']) - expect(fetch).toHaveBeenCalled(); - }) - - test("basic set-up: use default LibResilientConfig values when no valid 'loggedComponents' field in config.json", async () => { - self.LibResilientConfig = null - - global.fetch.mockImplementation((url, init) => { - return Promise.resolve( - new Response( - new Blob( - [JSON.stringify({loggedComponents: 'not a valid array', plugins: [{name: "fetch"}]})], - {type: "application/json"} - ), - { - status: 200, - statusText: "OK", - headers: { - 'ETag': 'TestingETagHeader' - }, - url: url - }) - ); - }); - - try { - require("../service-worker.js"); - } catch(e) {} - await self.trigger('install') - // this is silly but works, and is necessary because - // event.waitUntil() in the install event handler is not handled correctly in NodeJS - await new Promise(resolve => setTimeout(resolve, 100)); - await self.trigger('activate') - expect(typeof self.LibResilientConfig).toEqual('object') - expect(self.LibResilientConfig.defaultPluginTimeout).toBe(10000) - expect(self.LibResilientConfig.plugins).toStrictEqual([{name: "fetch"},{name: "cache"}]) - expect(self.LibResilientConfig.loggedComponents).toStrictEqual(['service-worker', 'fetch', 'cache']) - expect(fetch).toHaveBeenCalled(); - }) - - test("basic set-up: use default LibResilientConfig values when 'defaultPluginTimeout' field in config.json contains an invalid value", async () => { - self.LibResilientConfig = null - - global.fetch.mockImplementation((url, init) => { - return Promise.resolve( - new Response( - new Blob( - [JSON.stringify({loggedComponents: ['service-worker', 'fetch'], plugins: [{name: "fetch"}], defaultPluginTimeout: 'not an integer'})], - {type: "application/json"} - ), - { - status: 200, - statusText: "OK", - headers: { - 'ETag': 'TestingETagHeader' - }, - url: url - }) - ); - }); - - try { - require("../service-worker.js"); - } catch(e) {} - await self.trigger('install') - // this is silly but works, and is necessary because - // event.waitUntil() in the install event handler is not handled correctly in NodeJS - await new Promise(resolve => setTimeout(resolve, 100)); - await self.trigger('activate') - expect(typeof self.LibResilientConfig).toEqual('object') - expect(self.LibResilientConfig.defaultPluginTimeout).toBe(10000) - expect(self.LibResilientConfig.plugins).toStrictEqual([{name: "fetch"},{name: "cache"}]) - expect(self.LibResilientConfig.loggedComponents).toStrictEqual(['service-worker', 'fetch', 'cache']) - expect(fetch).toHaveBeenCalled(); - }) - - test("basic set-up: use config values from a valid config.json file", async () => { - self.LibResilientConfig = null - - global.fetch.mockImplementation((url, init) => { - return Promise.resolve( - new Response( - new Blob( - [JSON.stringify({loggedComponents: ['service-worker', 'cache'], plugins: [{name: "cache"}], defaultPluginTimeout: 5000})], - {type: "application/json"} - ), - { - status: 200, - statusText: "OK", - headers: { - 'ETag': 'TestingETagHeader' - }, - url: url - }) - ); - }); - - try { - require("../service-worker.js"); - } catch(e) {} - await self.trigger('install') - // this is silly but works, and is necessary because - // event.waitUntil() in the install event handler is not handled correctly in NodeJS - await new Promise(resolve => setTimeout(resolve, 100)); - await self.trigger('activate') - expect(typeof self.LibResilientConfig).toEqual('object') - expect(self.LibResilientConfig.defaultPluginTimeout).toBe(5000) - expect(self.LibResilientConfig.plugins).toStrictEqual([{name: "cache"}]) - expect(self.LibResilientConfig.loggedComponents).toStrictEqual(['service-worker', 'cache']) - expect(fetch).toHaveBeenCalled(); - }) - - - test("basic set-up: instantiate a complex tree of configured plugins", async () => { - self.LibResilientConfig = { - plugins: [{ - name: 'plugin-1', - uses: [{ - name: 'plugin-2', - uses: [{ - name: 'plugin-3' - }] - },{ - name: 'plugin-3' - }] - },{ - name: 'plugin-2', - uses: [{ - name: 'plugin-3' - }] - },{ - name: 'plugin-3', - uses: [{ - name: 'plugin-1' - },{ - name: 'plugin-2', - uses: [{ - name: 'plugin-1', - uses: [{ - name: 'plugin-4' - }] - }] - }] - },{ - name: 'plugin-4' - } - ] - } - - global.LibResilientPluginConstructors.set('plugin-1', (LRPC, config)=>{ - return { - name: 'plugin-1', - description: 'Plugin Type 1', - version: '0.0.1', - fetch: (url)=>{return true}, - uses: config.uses || [] - } - }) - global.LibResilientPluginConstructors.set('plugin-2', (LRPC, config)=>{ - return { - name: 'plugin-2', - description: 'Plugin Type 2', - version: '0.0.1', - fetch: (url)=>{return true}, - uses: config.uses || [] - } - }) - global.LibResilientPluginConstructors.set('plugin-3', (LRPC, config)=>{ - return { - name: 'plugin-3', - description: 'Plugin Type 3', - version: '0.0.1', - fetch: (url)=>{return true}, - uses: config.uses || [] - } - }) - global.LibResilientPluginConstructors.set('plugin-4', (LRPC, config)=>{ - return { - name: 'plugin-4', - description: 'Plugin Type 4', - version: '0.0.1', - fetch: (url)=>{return true}, - uses: config.uses || [] - } - }) - - - try { - require("../service-worker.js"); - } catch(e) {} - await self.trigger('install') - // this is silly but works, and is necessary because - // event.waitUntil() in the install event handler is not handled correctly in NodeJS - await new Promise(resolve => setTimeout(resolve, 100)); - await self.trigger('activate') - expect(typeof self.LibResilientConfig).toEqual('object') - // basic stuff - expect(self.LibResilientPlugins.length).toEqual(4) - expect(self.LibResilientPlugins[0].name).toEqual('plugin-1') - expect(self.LibResilientPlugins[1].name).toEqual('plugin-2') - expect(self.LibResilientPlugins[2].name).toEqual('plugin-3') - expect(self.LibResilientPlugins[3].name).toEqual('plugin-4') - // first plugin dependencies - expect(self.LibResilientPlugins[0].uses.length).toEqual(2) - expect(self.LibResilientPlugins[0].uses[0].name).toEqual('plugin-2') - expect(self.LibResilientPlugins[0].uses[0].uses.length).toEqual(1) - expect(self.LibResilientPlugins[0].uses[0].uses[0].name).toEqual('plugin-3') - expect(self.LibResilientPlugins[0].uses[0].uses[0].uses.length).toEqual(0) - expect(self.LibResilientPlugins[0].uses[1].name).toEqual('plugin-3') - expect(self.LibResilientPlugins[0].uses[1].uses.length).toEqual(0) - // second plugin dependencies - expect(self.LibResilientPlugins[1].uses.length).toEqual(1) - expect(self.LibResilientPlugins[1].uses[0].name).toEqual('plugin-3') - expect(self.LibResilientPlugins[1].uses[0].uses.length).toEqual(0) - // third plugin dependencies - expect(self.LibResilientPlugins[2].uses.length).toEqual(2) - expect(self.LibResilientPlugins[2].uses[0].name).toEqual('plugin-1') - expect(self.LibResilientPlugins[2].uses[0].uses.length).toEqual(0) - expect(self.LibResilientPlugins[2].uses[1].name).toEqual('plugin-2') - expect(self.LibResilientPlugins[2].uses[1].uses.length).toEqual(1) - expect(self.LibResilientPlugins[2].uses[1].uses[0].name).toEqual('plugin-1') - expect(self.LibResilientPlugins[2].uses[1].uses[0].uses.length).toEqual(1) - expect(self.LibResilientPlugins[2].uses[1].uses[0].uses[0].name).toEqual('plugin-4') - expect(self.LibResilientPlugins[2].uses[1].uses[0].uses[0].uses.length).toEqual(0) - // fourth plugin dependencies - expect(self.LibResilientPlugins[3].uses.length).toEqual(0) - }) - - - test("basic set-up: instantiate configured plugins; explicitly disabled plugins should not be instantiated", async () => { - self.LibResilientConfig = { - plugins: [{ - name: 'plugin-enabled' - },{ - name: 'plugin-disabled', - enabled: false - },{ - name: 'plugin-enabled', - enabled: true - }] - } - - global.LibResilientPluginConstructors.set('plugin-enabled', ()=>{ - return { - name: 'plugin-enabled', - description: 'Enabled plugin', - version: '0.0.1', - fetch: (url)=>{return true} - } - }) - global.LibResilientPluginConstructors.set('plugin-disabled', ()=>{ - return { - name: 'plugin-disabled', - description: 'Disabled plugin', - version: '0.0.1', - fetch: (url)=>{return true} - } - }) - - try { - require("../service-worker.js"); - } catch(e) {} - await self.trigger('install') - // this is silly but works, and is necessary because - // event.waitUntil() in the install event handler is not handled correctly in NodeJS - await new Promise(resolve => setTimeout(resolve, 100)); - await self.trigger('activate') - expect(typeof self.LibResilientConfig).toEqual('object') - expect(self.LibResilientPlugins.length).toEqual(2) - expect(self.LibResilientPlugins[0].name).toEqual('plugin-enabled') - expect(self.LibResilientPlugins[1].name).toEqual('plugin-enabled') - }) - - - test("basic set-up: instantiate configured plugins; explicitly disabled dependencies should not be instantiated", async () => { - self.LibResilientConfig = { - plugins: [{ - name: 'plugin-disabled', - enabled: false, - uses: [{ - name: 'dependency-enabled' - }] - },{ - name: 'plugin-enabled', - uses: [{ - name: 'dependency-disabled', - enabled: false - }] - },{ - name: 'plugin-enabled', - uses: [{ - name: 'dependency-enabled', - enabled: true - }] - }], - loggedComponents: ['service-worker'] - } - - global.LibResilientPluginConstructors.set('plugin-enabled', (LRPC, config)=>{ - return { - name: 'plugin-enabled', - description: 'Enabled plugin', - version: '0.0.1', - fetch: (url)=>{return true}, - uses: config.uses || [] - } - }) - global.LibResilientPluginConstructors.set('plugin-disabled', (LRPC, config)=>{ - return { - name: 'plugin-disabled', - description: 'Disabled plugin', - version: '0.0.1', - fetch: (url)=>{return true}, - uses: config.uses || [] - } - }) - global.LibResilientPluginConstructors.set('dependency-disabled', (LRPC, config)=>{ - return { - name: 'dependency-disabled', - description: 'Disabled dependency plugin', - version: '0.0.1', - fetch: (url)=>{return true}, - uses: config.uses || [] - } - }) - global.LibResilientPluginConstructors.set('dependency-enabled', (LRPC, config)=>{ - return { - name: 'dependency-enabled', - description: 'Enabled dependency plugin', - version: '0.0.1', - fetch: (url)=>{return true}, - uses: config.uses || [] - } - }) - - try { - require("../service-worker.js"); - } catch(e) {} - await self.trigger('install') - // this is silly but works, and is necessary because - // event.waitUntil() in the install event handler is not handled correctly in NodeJS - await new Promise(resolve => setTimeout(resolve, 100)); - await self.trigger('activate') - expect(typeof self.LibResilientConfig).toEqual('object') - expect(self.LibResilientPlugins.length).toEqual(2) - expect(self.LibResilientPlugins[0].name).toEqual('plugin-enabled') - expect(self.LibResilientPlugins[0].uses.length).toEqual(0) - expect(self.LibResilientPlugins[1].name).toEqual('plugin-enabled') - expect(self.LibResilientPlugins[1].uses.length).toEqual(1) - expect(self.LibResilientPlugins[1].uses[0].name).toEqual('dependency-enabled') - expect(self.LibResilientPlugins[1].uses[0].uses.length).toEqual(0) - }) - - - test("basic set-up: a valid config.json file gets cached", async () => { - self.LibResilientConfig = null - - var configData = {loggedComponents: ['service-worker', 'cache'], plugins: [{name: "cache"}], defaultPluginTimeout: 5000} - global.fetch.mockImplementation((url, init) => { - return Promise.resolve( - new Response( - new Blob( - [JSON.stringify(configData)], - {type: "application/json"} - ), - { - status: 200, - statusText: "OK", - headers: { - 'ETag': 'TestingETagHeader' - }, - url: url - }) - ); - }); - - try { - require("../service-worker.js"); - } catch(e) {} - await self.trigger('install') - // this is silly but works, and is necessary because - // event.waitUntil() in the install event handler is not handled correctly in NodeJS - await new Promise(resolve => setTimeout(resolve, 100)); - await self.trigger('activate') - expect(typeof self.LibResilientConfig).toEqual('object') - expect(self.LibResilientConfig.defaultPluginTimeout).toBe(5000) - expect(self.LibResilientConfig.plugins).toStrictEqual([{name: "cache"}]) - expect(self.LibResilientConfig.loggedComponents).toStrictEqual(['service-worker', 'cache']) - expect(fetch).toHaveBeenCalled(); - expect (await caches.open('v1').then((cache)=>{ - return cache.match(self.location.origin + '/config.json') - }).then((response)=>{ - return response.json() - }).then((json)=>{ - return json - })).toStrictEqual({loggedComponents: ['service-worker', 'cache'], plugins: [{name: "cache"}], defaultPluginTimeout: 5000}) - }) - - test("basic set-up: a cached valid config.json file gets used, no fetch happens", async () => { - self.LibResilientConfig = null - - var configData = {loggedComponents: ['service-worker', 'cache'], plugins: [{name: "cache"}], defaultPluginTimeout: 5000} - var configUrl = '/config.json' - var configResponse = new Response( - new Blob( - [JSON.stringify(configData)], - {type: "application/json"} - ), - { - status: 200, - statusText: "OK", - headers: { - 'ETag': 'TestingETagHeader' - }, - url: configUrl - }) - await caches - .open('v1') - .then((cache)=>{ - return cache.put(configUrl, configResponse) - }) - - try { - require("../service-worker.js"); - } catch(e) {} - await self.trigger('install') - // this is silly but works, and is necessary because - // event.waitUntil() in the install event handler is not handled correctly in NodeJS - await new Promise(resolve => setTimeout(resolve, 100)); - await self.trigger('activate') - expect(typeof self.LibResilientConfig).toEqual('object') - expect(self.LibResilientConfig.defaultPluginTimeout).toBe(5000) - expect(self.LibResilientConfig.plugins).toStrictEqual([{name: "cache"}]) - expect(self.LibResilientConfig.loggedComponents).toStrictEqual(['service-worker', 'cache']) - expect(fetch).not.toHaveBeenCalled(); - }) - - - test("basic set-up: a stale cached valid config.json file gets used, no fetch happens, fresh config.json is retrieved using the configured plugins and cached", async () => { - self.LibResilientConfig = null - - var configData = {loggedComponents: ['service-worker', 'cache', 'fetch'], plugins: [{name: "fetch"},{name: "cache"}], defaultPluginTimeout: 5000} - var configUrl = '/config.json' - var configResponse = new Response( - new Blob( - [JSON.stringify(configData)], - {type: "application/json"} - ), - { - status: 200, - statusText: "OK", - headers: { - 'ETag': 'TestingETagHeader', - // very stale date - 'Date': new Date(0).toUTCString() - }, - url: configUrl - }) - await caches - .open('v1') - .then((cache)=>{ - return cache.put(configUrl, configResponse) - }) - - - var newConfigData = {loggedComponents: ['service-worker', 'fetch'], plugins: [{name: "fetch"}], defaultPluginTimeout: 2000} - let resolveConfigFetch = jest.fn((request, init)=>{ - return Promise.resolve( - new Response( - new Blob( - [JSON.stringify(newConfigData)], - {type: "application/json"} - ), - { - status: 200, - statusText: "OK", - headers: { - 'ETag': 'TestingETagHeader', - // very current date - 'Date': new Date().toUTCString() - }, - url: configUrl - }) - ) - }) - global.LibResilientPluginConstructors.set('fetch', ()=>{ - return { - name: 'fetch', - description: 'Resolve with config data (pretending to be fetch).', - version: '0.0.1', - fetch: resolveConfigFetch - } - }) - - try { - require("../service-worker.js"); - } catch(e) {} - await self.trigger('install') - // this is silly but works, and is necessary because - // event.waitUntil() in the install event handler is not handled correctly in NodeJS - await new Promise(resolve => setTimeout(resolve, 100)); - await self.trigger('activate') - - // verify current config (the one from the pre-cached stale `config.json`) - expect(typeof self.LibResilientConfig).toEqual('object') - expect(self.LibResilientConfig.defaultPluginTimeout).toBe(5000) - expect(self.LibResilientConfig.plugins).toStrictEqual([{name: "fetch"},{name: "cache"}]) - expect(self.LibResilientConfig.loggedComponents).toStrictEqual(['service-worker', 'cache', 'fetch']) - expect(fetch).not.toHaveBeenCalled(); - expect(resolveConfigFetch).toHaveBeenCalled(); - - // verify that the *new* config got cached - cdata = await caches - .open('v1') - .then((cache)=>{ - return cache.match(configUrl) - }) - .then((cresponse)=>{ - return cresponse.json() - }) - expect(cdata).toStrictEqual(newConfigData) - }) - - - test("basic set-up: a stale cached valid config.json file gets used, no fetch happens; invalid config.json retrieved using the configured plugins is not cached", async () => { - self.LibResilientConfig = null - - var configData = {loggedComponents: ['service-worker', 'cache', 'resolve-config'], plugins: [{name: "cache"}, {name: "resolve-config"}], defaultPluginTimeout: 5000} - var configUrl = '/config.json' - var configResponse = new Response( - new Blob( - [JSON.stringify(configData)], - {type: "application/json"} - ), - { - status: 200, - statusText: "OK", - headers: { - 'ETag': 'TestingETagHeader', - // very stale date - 'Date': new Date(0).toUTCString() - }, - url: configUrl - }) - await caches - .open('v1') - .then((cache)=>{ - return cache.put(configUrl, configResponse) - }) - - - var newConfigData = {loggedComponentsInvalid: ['service-worker', 'resolve-config'], pluginsInvalid: [{name: "resolve-config"}], defaultPluginTimeoutInvalid: 2000} - let resolveConfigFetch = jest.fn((request, init)=>{ - return Promise.resolve( - new Response( - new Blob( - [JSON.stringify(newConfigData)], - {type: "application/json"} - ), - { - status: 200, - statusText: "OK", - headers: { - 'ETag': 'TestingETagHeader', - // very current date - 'Date': new Date().toUTCString() - }, - url: configUrl - }) - ) - }) - global.LibResilientPluginConstructors.set('resolve-config', ()=>{ - return { - name: 'resolve-config', - description: 'Resolve with config data.', - version: '0.0.1', - fetch: resolveConfigFetch - } - }) - - try { - require("../service-worker.js"); - } catch(e) {} - await self.trigger('install') - // this is silly but works, and is necessary because - // event.waitUntil() in the install event handler is not handled correctly in NodeJS - await new Promise(resolve => setTimeout(resolve, 100)); - await self.trigger('activate') - - // verify current config (the one from the pre-cached stale `config.json`) - expect(typeof self.LibResilientConfig).toEqual('object') - expect(self.LibResilientConfig.defaultPluginTimeout).toBe(5000) - expect(self.LibResilientConfig.plugins).toStrictEqual([{name: "cache"}, {name: "resolve-config"}]) - expect(self.LibResilientConfig.loggedComponents).toStrictEqual(['service-worker', 'cache', 'resolve-config']) - expect(fetch).not.toHaveBeenCalled(); - expect(resolveConfigFetch).toHaveBeenCalled(); - - // verify that the *new* config got cached - cdata = await caches - .open('v1') - .then((cache)=>{ - return cache.match(configUrl) - }) - .then((cresponse)=>{ - return cresponse.json() - }) - expect(cdata).toStrictEqual(configData) - }) - - - test("basic set-up: a stale cached valid config.json file gets used, no fetch happens; valid config.json (configuring additional plugins) is retrieved using the configured plugins other than fetch, and is not cached", async () => { - self.LibResilientConfig = null - - var configData = {loggedComponents: ['service-worker', 'resolve-config'], plugins: [{name: "resolve-config"}], defaultPluginTimeout: 5000} - var configUrl = '/config.json' - var configResponse = new Response( - new Blob( - [JSON.stringify(configData)], - {type: "application/json"} - ), - { - status: 200, - statusText: "OK", - headers: { - 'ETag': 'TestingETagHeader', - // very stale date - 'Date': new Date(0).toUTCString() - }, - url: configUrl - }) - await caches - .open('v1') - .then((cache)=>{ - return cache.put(configUrl, configResponse) - }) - - - var newConfigData = {loggedComponents: ['service-worker', 'resolve-config', 'cache'], plugins: [{name: "resolve-config"}, {name: "cache"}], defaultPluginTimeout: 2000} - let resolveConfigFetch = jest.fn((request, init)=>{ - return Promise.resolve( - new Response( - new Blob( - [JSON.stringify(newConfigData)], - {type: "application/json"} - ), - { - status: 200, - statusText: "OK", - headers: { - 'ETag': 'TestingETagHeader', - // very current date - 'Date': new Date().toUTCString() - }, - url: configUrl - }) - ) - }) - global.LibResilientPluginConstructors.set('resolve-config', ()=>{ - return { - name: 'resolve-config', - description: 'Resolve with config data.', - version: '0.0.1', - fetch: resolveConfigFetch - } - }) - - try { - require("../service-worker.js"); - } catch(e) {} - await self.trigger('install') - // this is silly but works, and is necessary because - // event.waitUntil() in the install event handler is not handled correctly in NodeJS - await new Promise(resolve => setTimeout(resolve, 100)); - await self.trigger('activate') - - // verify current config (the one from the pre-cached stale `config.json`) - expect(typeof self.LibResilientConfig).toEqual('object') - expect(self.LibResilientConfig.defaultPluginTimeout).toBe(configData.defaultPluginTimeout) - expect(self.LibResilientConfig.plugins).toStrictEqual(configData.plugins) - expect(self.LibResilientConfig.loggedComponents).toStrictEqual(configData.loggedComponents) - expect(fetch).not.toHaveBeenCalled(); - expect(resolveConfigFetch).toHaveBeenCalled(); - - // verify that the *new* config got cached - cdata = await caches - .open('v1') - .then((cache)=>{ - return cache.match(configUrl) - }) - .then((cresponse)=>{ - return cresponse.json() - }) - expect(cdata).toStrictEqual(configData) - }) - - test("failed fetch by first configured plugin should not affect a successful fetch by a second one", async () => { - self.LibResilientConfig = { - plugins: [{ - name: 'reject-all' - },{ - name: 'resolve-all' - }], - loggedComponents: [ - 'service-worker' - ] - } - let rejectingFetch = jest.fn((request, init)=>{ return Promise.reject(request); }) - let resolvingFetch = jest.fn((request, init)=>{ - return Promise.resolve( - new Response( - new Blob( - [JSON.stringify({ test: "success" })], - {type: "application/json"} - ), - { - status: 200, - statusText: "OK", - headers: { - 'ETag': 'TestingETagHeader' - }, - url: self.location.origin + '/test.json' - }) - ) - }) - global.LibResilientPluginConstructors.set('reject-all', ()=>{ - return { - name: 'reject-all', - description: 'Reject all requests.', - version: '0.0.1', - fetch: rejectingFetch - } - }) - global.LibResilientPluginConstructors.set('resolve-all', ()=>{ - return { - name: 'resolve-all', - description: 'Resolve all requests.', - version: '0.0.1', - fetch: resolvingFetch - } - }) - - require("../service-worker.js"); - - await self.trigger('install') - // this is silly but works, and is necessary because - // event.waitUntil() in the install event handler is not handled correctly in NodeJS - await new Promise(resolve => setTimeout(resolve, 100)); - await self.trigger('activate') - - var response = await self.trigger('fetch', new Request('/test.json')) - expect(rejectingFetch).toHaveBeenCalled(); - expect(resolvingFetch).toHaveBeenCalled(); - expect(await response.json()).toEqual({ test: "success" }) - }); - - test("plugins should receive the Request() init data", async () => { - self.LibResilientConfig = { - plugins: [{ - name: 'reject-all' - },{ - name: 'resolve-all' - }], - loggedComponents: [ - 'service-worker' - ] - } - let rejectingFetch = jest.fn((request, init)=>{ return Promise.reject(request); }) - let resolvingFetch = jest.fn((request, init)=>{ - return Promise.resolve( - new Response( - new Blob( - [JSON.stringify({ test: "success" })], - {type: "application/json"} - ), - { - status: 200, - statusText: "OK", - headers: { - 'ETag': 'TestingETagHeader' - }, - url: self.location.origin + '/test.json' - }) - ) - }) - global.LibResilientPluginConstructors.set('reject-all', ()=>{ - return { - name: 'reject-all', - description: 'Reject all requests.', - version: '0.0.1', - fetch: rejectingFetch - } - }) - global.LibResilientPluginConstructors.set('resolve-all', ()=>{ - return { - name: 'resolve-all', - description: 'Resolve all requests.', - version: '0.0.1', - fetch: resolvingFetch - } - }) - - var initTest = { - method: "GET", - // TODO: ref. https://gitlab.com/rysiekpl/libresilient/-/issues/23 - //headers: new Headers({"x-stub": "STUB"}), - //mode: "mode-stub", - //credentials: "credentials-stub", - cache: "cache-stub", - referrer: "referrer-stub", - // these are not implemented by service-worker-mock - // https://github.com/zackargyle/service-workers/blob/master/packages/service-worker-mock/models/Request.js#L20 - redirect: undefined, - integrity: undefined, - cache: undefined - } - - require("../service-worker.js"); - - await self.trigger('install') - // this is silly but works, and is necessary because - // event.waitUntil() in the install event handler is not handled correctly in NodeJS - await new Promise(resolve => setTimeout(resolve, 100)); - await self.trigger('activate') - - var response = await self.trigger('fetch', new Request('/test.json', initTest)) - expect(rejectingFetch).toHaveBeenCalled(); - expect(resolvingFetch).toHaveBeenCalled(); - expect(await response.json()).toEqual({ test: "success" }) - expect(rejectingFetch).toHaveBeenCalledWith('https://www.test.com/test.json', initTest) - expect(resolvingFetch).toHaveBeenCalledWith('https://www.test.com/test.json', initTest) - }); - - test("defaultPluginTimeout should be respected", async () => { - self.LibResilientConfig = { - defaultPluginTimeout: 100, - plugins: [{ - name: 'resolve-with-timeout' - }], - loggedComponents: [ - 'service-worker', - ] - } - let rwtCallback = jest.fn() - global.LibResilientPluginConstructors.set('resolve-with-timeout', ()=>{ - return { - name: 'resolve-with-timeout', - description: 'Resolve all requests after a timeout.', - version: '0.0.1', - fetch: (request, init)=>{ - return new Promise((resolve, reject)=>{ - setTimeout(rwtCallback, 300) - }) - } - } - }) - - require("../service-worker.js"); - - await self.trigger('install') - // this is silly but works, and is necessary because - // event.waitUntil() in the install event handler is not handled correctly in NodeJS - await new Promise(resolve => setTimeout(resolve, 100)); - await self.trigger('activate') - - var response = self.trigger('fetch', new Request('/test.json')) - expect.assertions(2) - try { - await response - } catch(e) { - expect(e.toString()).toBe("Error: LibResilient request using resolve-with-timeout timed out after 100ms.") - } - expect(rwtCallback).not.toHaveBeenCalled() - }); - - test("making an external request should work and not go through the plugins", async () => { - global.fetch.mockImplementation((request, init) => { - return Promise.resolve( - new Response( - new Blob( - [JSON.stringify({ test: "success" })], - {type: "application/json"} - ), - { - status: 200, - statusText: "OK", - headers: { - 'ETag': 'TestingETagHeader' - }, - method: 'GET', - url: request.url - }) - ); - }); - self.LibResilientConfig = { - plugins: [{ - name: 'reject-all' - }], - loggedComponents: [ - 'service-worker' - ] - } - global.LibResilientPluginConstructors.set('reject-all', ()=>{ - return { - name: 'reject-all', - description: 'Reject all requests.', - version: '0.0.1', - fetch: (request, init)=>{ return Promise.reject(request); } - } - }) - require("../service-worker.js"); - var response = await self.trigger('fetch', new Request('https://example.com/test.json')) - expect(await response.json()).toEqual({ test: "success" }) - }) - - test("making a POST request should work and not go through the plugins", async () => { - global.fetch.mockImplementation((request, init) => { - return Promise.resolve( - new Response( - new Blob( - [JSON.stringify({ test: "success" })], - {type: "application/json"} - ), - { - status: 200, - statusText: "OK", - headers: { - 'ETag': 'TestingETagHeader' - }, - method: 'POST', - url: request.url - }) - ); - }); - self.LibResilientConfig = { - plugins: [{ - name: 'reject-all' - }], - loggedComponents: [ - 'service-worker' - ] - } - global.LibResilientPluginConstructors.set('reject-all', ()=>{ - return { - name: 'reject-all', - description: 'Reject all requests.', - version: '0.0.1', - fetch: (request, init)=>{ return Promise.reject(request); } - } - }) - require("../service-worker.js"); - var response = await self.trigger('fetch', new Request('/test.json', {method: "POST"})) - expect(response.method).toEqual('POST') - expect(await response.json()).toEqual({ test: "success" }) - }) - - test("stashing content after a successful fetch should work", async () => { - self.LibResilientConfig = { - plugins: [{ - name: 'fetch' - },{ - name: 'cache' - }], - loggedComponents: [ - 'service-worker', 'fetch', 'cache' - ] - } - require("../service-worker.js"); - - await self.trigger('install') - // this is silly but works, and is necessary because - // event.waitUntil() in the install event handler is not handled correctly in NodeJS - await new Promise(resolve => setTimeout(resolve, 100)); - await self.trigger('activate') - - var response = await self.trigger('fetch', new Request('/test.json')) - expect(await response.json()).toEqual({ test: "success" }) - expect (await caches.open('v1').then((cache)=>{ - return cache.keys() - }).then((keys)=>{ - return keys[0].url - })).toEqual(self.location.origin + '/test.json') - expect (await caches.open('v1').then((cache)=>{ - return cache.match(self.location.origin + '/test.json') - }).then((response)=>{ - return response.json() - }).then((json)=>{ - return json - })).toEqual({ test: "success" }) - }); - - test("stashing should be skipped if content was retrieved from a stashing plugin", async () => { - self.LibResilientConfig = { - plugins: [{ - name: 'stashing-test' - },{ - name: 'reject-all' - }], - loggedComponents: [ - 'service-worker' - ] - } - let resolvingFetch = jest.fn((request, init)=>{ - return Promise.resolve( - new Response( - new Blob( - [JSON.stringify({ test: "success" })], - {type: "application/json"} - ), - { - status: 200, - statusText: "OK", - headers: { - 'X-LibResilient-Method': 'resolve-all', - 'X-LibResilient-ETag': 'TestingETagHeader' - }, - url: self.location.origin + '/test.json' - }) - ) - }) - let rejectingFetch = jest.fn((request, init)=>{ return Promise.reject(request); }) - let stashingStash = jest.fn() - - global.LibResilientPluginConstructors.set('stashing-test', ()=>{ - return { - name: 'stashing-test', - description: 'Mock stashing plugin.', - version: '0.0.1', - fetch: resolvingFetch, - stash: stashingStash - } - }) - global.LibResilientPluginConstructors.set('reject-all', ()=>{ - return { - name: 'reject-all', - description: 'Reject all requests.', - version: '0.0.1', - fetch: rejectingFetch - } - }) - - require("../service-worker.js"); - - await self.trigger('install') - // this is silly but works, and is necessary because - // event.waitUntil() in the install event handler is not handled correctly in NodeJS - await new Promise(resolve => setTimeout(resolve, 100)); - await self.trigger('activate') - - var response = await self.trigger('fetch', new Request('/test.json')) - expect(resolvingFetch).toHaveBeenCalled(); - expect(stashingStash).not.toHaveBeenCalled(); - expect(rejectingFetch).toHaveBeenCalled(); - expect(await response.json()).toEqual({ test: "success" }) - }); - - test("content should be stashed if it was retrieved from a job after retrieval from a stashing plugin, and it differs from the stashed version", async () => { - self.LibResilientConfig = { - plugins: [{ - name: 'stashing-test' - },{ - name: 'resolve-all' - }], - loggedComponents: [ - 'service-worker' - ] - } - let resolvingFetch = jest.fn((request, init)=>{ - return Promise.resolve( - new Response( - new Blob( - [JSON.stringify({ test: "success" })], - {type: "application/json"} - ), - { - status: 200, - statusText: "OK", - headers: { - 'X-LibResilient-Method': 'resolve-all', - 'X-LibResilient-ETag': 'TestingETagHeader' - }, - url: self.location.origin + '/test.json' - }) - ) - }) - let resolvingFetch2 = jest.fn((request, init)=>{ - return Promise.resolve( - new Response( - new Blob( - [JSON.stringify({ test: "success2" })], - {type: "application/json"} - ), - { - status: 200, - statusText: "OK", - headers: { - 'ETag': 'NewTestingETagHeader' - }, - url: self.location.origin + '/test.json' - }) - ) - }) - let stashingStash = jest.fn(async (response, url)=>{ - expect(await response.json()).toEqual({ test: "success2" }) - expect(response.headers.get('ETag')).toEqual('NewTestingETagHeader') - }) - - global.LibResilientPluginConstructors.set('stashing-test', ()=>{ - return { - name: 'stashing-test', - description: 'Mock stashing plugin.', - version: '0.0.1', - fetch: resolvingFetch, - stash: stashingStash - } - }) - global.LibResilientPluginConstructors.set('resolve-all', ()=>{ - return { - name: 'resolve-all', - description: 'Resolve all requests.', - version: '0.0.1', - fetch: resolvingFetch2 - } - }) - - var testClient = new Client() - self.clients.clients.push(testClient) - var fetchedDiffersFound = false - testClient.addEventListener('message', event => { - if (event.data.fetchedDiffers) { - fetchedDiffersFound = true - } - }) - - require("../service-worker.js"); - - await self.trigger('install') - // this is silly but works, and is necessary because - // event.waitUntil() in the install event handler is not handled correctly in NodeJS - await new Promise(resolve => setTimeout(resolve, 100)); - await self.trigger('activate') - - var response = await self.trigger('fetch', { - request: new Request('/test.json'), - clientId: testClient.id - }) - expect(resolvingFetch).toHaveBeenCalled(); - expect(await response.json()).toEqual({ test: "success" }) - expect(resolvingFetch2).toHaveBeenCalled(); - expect(stashingStash).toHaveBeenCalled(); - expect(fetchedDiffersFound).toEqual(true) - }); - - test("content should be stashed if it was retrieved from a job after retrieval from a stashing plugin, even it does not differ from the stashed version", async () => { - self.LibResilientConfig = { - plugins: [{ - name: 'stashing-test' - },{ - name: 'resolve-all' - }], - loggedComponents: [ - 'service-worker' - ] - } - let resolvingFetch = jest.fn((request, init)=>{ - return Promise.resolve( - new Response( - new Blob( - [JSON.stringify({ test: "success" })], - {type: "application/json"} - ), - { - status: 200, - statusText: "OK", - headers: { - 'X-LibResilient-Method': 'resolve-all', - 'X-LibResilient-ETag': 'TestingETagHeader' - }, - url: self.location.origin + '/test.json' - }) - ) - }) - let stashingStash = jest.fn() - - global.LibResilientPluginConstructors.set('stashing-test', ()=>{ - return { - name: 'stashing-test', - description: 'Mock stashing plugin.', - version: '0.0.1', - fetch: resolvingFetch, - stash: stashingStash - } - }) - global.LibResilientPluginConstructors.set('resolve-all', ()=>{ - return { - name: 'resolve-all', - description: 'Resolve all requests.', - version: '0.0.1', - fetch: resolvingFetch - } - }) - - require("../service-worker.js"); - - await self.trigger('install') - // this is silly but works, and is necessary because - // event.waitUntil() in the install event handler is not handled correctly in NodeJS - await new Promise(resolve => setTimeout(resolve, 100)); - await self.trigger('activate') - - var response = await self.trigger('fetch', new Request('/test.json')) - expect(resolvingFetch).toHaveBeenCalledTimes(2); - expect(await response.json()).toEqual({ test: "success" }) - expect(stashingStash).toHaveBeenCalled(); - }); - - test("stashing content explicitly should work", async () => { - self.LibResilientConfig = { - plugins: [{ - name: 'cache' - }], - loggedComponents: [ - 'service-worker', 'cache' - ] - } - require("../service-worker.js"); - - await self.trigger('install') - // this is silly but works, and is necessary because - // event.waitUntil() in the install event handler is not handled correctly in NodeJS - await new Promise(resolve => setTimeout(resolve, 100)); - await self.trigger('activate') - - await self.trigger( - 'message', - { - data:{ - stash: [new Response( - new Blob( - [JSON.stringify({ test: "success" })], - {type: "application/json"} - ), - { - status: 200, - statusText: "OK", - headers: { - 'ETag': 'TestingETagHeader' - }, - url: self.location.origin + '/test.json' - })] - } - }) - // needed here also - await new Promise(resolve => setTimeout(resolve, 100)); - - expect (await caches.open('v1').then((cache)=>{ - return cache.keys() - }).then((keys)=>{ - return keys[0].url - })).toEqual(self.location.origin + '/test.json') - expect (await caches.open('v1').then((cache)=>{ - return cache.match(self.location.origin + '/test.json') - }).then((response)=>{ - return response.json() - }).then((json)=>{ - return json - })).toEqual({ test: "success" }) - }); - - test("after a retrieval from a stashing plugin, background plugin should receive the Request() init data", async () => { - self.LibResilientConfig = { - plugins: [{ - name: 'stashing-test' - },{ - name: 'resolve-all' - }], - loggedComponents: [ - 'service-worker' - ] - } - let resolvingFetch = jest.fn((request, init)=>{ - return Promise.resolve( - new Response( - new Blob( - [JSON.stringify({ test: "success" })], - {type: "application/json"} - ), - { - status: 200, - statusText: "OK", - headers: { - 'X-LibResilient-Method': 'resolve-all', - 'X-LibResilient-ETag': 'TestingETagHeader' - }, - url: self.location.origin + '/test.json' - }) - ) - }) - let resolvingFetch2 = jest.fn((request, init)=>{ - return Promise.resolve( - new Response( - new Blob( - [JSON.stringify({ test: "success2" })], - {type: "application/json"} - ), - { - status: 200, - statusText: "OK", - headers: { - 'ETag': 'NewTestingETagHeader' - }, - url: self.location.origin + '/test.json' - }) - ) - }) - let stashingStash = jest.fn(async (response, url)=>{ - expect(await response.json()).toEqual({ test: "success2" }) - expect(response.headers.get('ETag')).toEqual('NewTestingETagHeader') - }) - - global.LibResilientPluginConstructors.set('stashing-test', ()=>{ - return { - name: 'stashing-test', - description: 'Mock stashing plugin.', - version: '0.0.1', - fetch: resolvingFetch, - stash: stashingStash - } - }) - global.LibResilientPluginConstructors.set('resolve-all', ()=>{ - return { - name: 'resolve-all', - description: 'Resolve all requests.', - version: '0.0.1', - fetch: resolvingFetch2 - } - }) - - var testClient = new Client() - self.clients.clients.push(testClient) - var fetchedDiffersFound = false - testClient.addEventListener('message', event => { - if (event.data.fetchedDiffers) { - fetchedDiffersFound = true - } - }) - - require("../service-worker.js"); - - await self.trigger('install') - // this is silly but works, and is necessary because - // event.waitUntil() in the install event handler is not handled correctly in NodeJS - await new Promise(resolve => setTimeout(resolve, 100)); - await self.trigger('activate') - - var initTest = { - method: "GET", - // TODO: ref. https://gitlab.com/rysiekpl/libresilient/-/issues/23 - //headers: new Headers({"x-stub": "STUB"}), - //mode: "mode-stub", - //credentials: "credentials-stub", - cache: "cache-stub", - referrer: "referrer-stub", - // these are not implemented by service-worker-mock - // https://github.com/zackargyle/service-workers/blob/master/packages/service-worker-mock/models/Request.js#L20 - redirect: undefined, - integrity: undefined, - cache: undefined - } - - var response = await self.trigger('fetch', { - request: new Request('/test.json', initTest), - clientId: testClient.id - }) - expect(resolvingFetch).toHaveBeenCalled(); - expect(await response.json()).toEqual({ test: "success" }) - expect(resolvingFetch).toHaveBeenCalledWith('https://www.test.com/test.json', initTest); - expect(resolvingFetch2).toHaveBeenCalledWith('https://www.test.com/test.json', initTest); - }); - - test("unstashing content explicitly should work", async () => { - self.LibResilientConfig = { - plugins: [{ - name: 'cache' - }], - loggedComponents: [ - 'service-worker', 'cache' - ] - } - require("../service-worker.js"); - - await self.trigger('install') - // this is silly but works, and is necessary because - // event.waitUntil() in the install event handler is not handled correctly in NodeJS - await new Promise(resolve => setTimeout(resolve, 100)); - await self.trigger('activate') - - await self.trigger( - 'message', - { - data:{ - stash: [new Response( - new Blob( - [JSON.stringify({ test: "success" })], - {type: "application/json"} - ), - { - status: 200, - statusText: "OK", - headers: { - 'ETag': 'TestingETagHeader' - }, - url: self.location.origin + '/test.json' - })] - } - }) - // needed here also - await new Promise(resolve => setTimeout(resolve, 100)); - - expect (await caches.open('v1').then((cache)=>{ - return cache.keys() - }).then((keys)=>{ - return keys[0].url - })).toEqual(self.location.origin + '/test.json') - expect (await caches.open('v1').then((cache)=>{ - return cache.match(self.location.origin + '/test.json') - }).then((response)=>{ - return response.json() - }).then((json)=>{ - return json - })).toEqual({ test: "success" }) - - // now unstash - await self.trigger( - 'message', - { - data:{ - unstash: [self.location.origin + '/test.json'] - } - }) - expect (await caches.open('v1').then((cache)=>{ - return cache.keys() - })).toEqual([]) - }); - - test("publishing content explicitly should work (stub)", async () => { - self.LibResilientConfig = { - plugins: [{ - name: 'publish-test' - }], - loggedComponents: [ - 'service-worker' - ] - } - var result = false - global.LibResilientPluginConstructors.set('publish-test', ()=>{ - return { - name: 'publish-test', - description: 'Publish plugin fixture.', - version: '0.0.1', - publish: (request)=>{ - result = 'publish-test success: ' + request.url - } - } - }) - require("../service-worker.js"); - - await self.trigger('install') - // this is silly but works, and is necessary because - // event.waitUntil() in the install event handler is not handled correctly in NodeJS - await new Promise(resolve => setTimeout(resolve, 100)); - await self.trigger('activate') - - await self.trigger( - 'message', - { - data:{ - publish: [new Response( - new Blob( - [JSON.stringify({ test: "success" })], - {type: "application/json"} - ), - { - status: 200, - statusText: "OK", - headers: { - 'ETag': 'TestingETagHeader' - }, - url: self.location.origin + '/test.json' - })] - } - }) - expect(result).toEqual('publish-test success: ' + self.location.origin + '/test.json') - }) - - test("using plugins with dependencies should work", async () => { - self.LibResilientConfig = { - plugins: [{ - name: 'dependent-test', - uses: [{ - name: 'dependency1-test' - },{ - name: 'dependency2-test' - }] - }], - loggedComponents: [ - 'service-worker' - ] - } - global.LibResilientPluginConstructors.set('dependent-test', ()=>{ - return { - name: 'dependent-test', - description: 'Dependent plugin fixture.', - version: '0.0.1', - uses: [{ - name: 'dependency1-test' - },{ - name: 'dependency2-test' - }] - } - }) - global.LibResilientPluginConstructors.set('dependency1-test', ()=>{ - return { - name: 'dependency1-test', - description: 'First dependency plugin fixture.', - version: '0.0.1' - } - }) - global.LibResilientPluginConstructors.set('dependency2-test', ()=>{ - return { - name: 'dependency2-test', - description: 'Second dependency plugin fixture.', - version: '0.0.1' - } - }) - require("../service-worker.js"); - await self.trigger('install') - // this is silly but works, and is necessary because - // event.waitUntil() in the install event handler is not handled correctly in NodeJS - await new Promise(resolve => setTimeout(resolve, 100)); - await self.trigger('activate') - expect(self.LibResilientPlugins.map(p=>p.name)).toEqual(['dependent-test']) - expect(self.LibResilientPlugins[0].uses.map(p=>p.name)).toEqual(['dependency1-test', 'dependency2-test']) - }) - - test("using multiple instances of the same plugin should work", async () => { - self.LibResilientConfig = { - plugins: [{ - name: 'plugin-test', - },{ - name: 'plugin-test', - },{ - name: 'plugin-test', - }], - loggedComponents: [ - 'service-worker' - ] - } - var pver = 0 - global.LibResilientPluginConstructors.set('plugin-test', ()=>{ - pver += 1 - return { - name: 'plugin-test', - description: 'Simple plugin stub.', - version: '0.0.' + pver - } - }) - require("../service-worker.js"); - await self.trigger('install') - // this is silly but works, and is necessary because - // event.waitUntil() in the install event handler is not handled correctly in NodeJS - await new Promise(resolve => setTimeout(resolve, 100)); - await self.trigger('activate') - expect(self.LibResilientPlugins.map(p=>p.name)).toEqual(['plugin-test', 'plugin-test', 'plugin-test']) - expect(self.LibResilientPlugins.map(p=>p.version)).toEqual(['0.0.1', '0.0.2', '0.0.3']) - }) - - test("should error out if all plugins fail", async () => { - self.LibResilientConfig = { - plugins: [{ - name: 'reject-all' - }], - loggedComponents: [ - 'service-worker' - ] - } - global.LibResilientPluginConstructors.set('reject-all', ()=>{ - return { - name: 'reject-all', - description: 'Reject all requests.', - version: '0.0.1', - fetch: (request, init)=>{ return Promise.reject(request); } - } - }) - require("../service-worker.js"); - await self.trigger('install') - // this is silly but works, and is necessary because - // event.waitUntil() in the install event handler is not handled correctly in NodeJS - await new Promise(resolve => setTimeout(resolve, 100)); - await self.trigger('activate') - expect.assertions(1) - try { - await self.trigger('fetch', new Request('/test.json', {method: "GET"})) - } catch(e) { - expect(e).toEqual(self.location.origin + '/test.json') - } - }) - - test("should send clientId back if event.resultingClientId is set", async () => { - self.LibResilientConfig = { - plugins: [{ - name: 'resolve-all' - }], - loggedComponents: [ - 'service-worker' - ] - } - global.LibResilientPluginConstructors.set('resolve-all', ()=>{ - return { - name: 'resolve-all', - description: 'Resolve all requests.', - version: '0.0.1', - fetch: (request, init)=>{ - return Promise.resolve( - new Response( - new Blob( - [JSON.stringify({ test: "success" })], - {type: "application/json"} - ), - { - status: 200, - statusText: "OK", - headers: { - 'ETag': 'TestingETagHeader' - }, - url: self.location.origin + '/test.json' - }) - ) - } - } - }) - var testClient = new Client() - self.clients.clients.push(testClient) - // monkey-patching addEventListener so that we can add - // the resultingClientId field to the event in a fetch callback - self.oldAddEventListener = self.addEventListener - self.addEventListener = (eventName, callback) => { - if (eventName === 'fetch') { - return self.oldAddEventListener(eventName, event => { - event.resultingClientId = testClient.id - return callback(event) - }) - } else { - return self.oldAddEventListener(eventName, callback) - } - } - expect.hasAssertions() - testClient.addEventListener('message', event => { - if (event.data.clientId) { - expect(event.data.clientId).toEqual(testClient.id) - } - }) - require("../service-worker.js"); - await self.trigger('install') - // this is silly but works, and is necessary because - // event.waitUntil() in the install event handler is not handled correctly in NodeJS - await new Promise(resolve => setTimeout(resolve, 100)); - await self.trigger('activate') - await self.trigger('fetch', new Request('/test.json')) - }) - -}); diff --git a/__tests__/service-worker/service-worker.test.js b/__tests__/service-worker/service-worker.test.js new file mode 100644 index 0000000..696a315 --- /dev/null +++ b/__tests__/service-worker/service-worker.test.js @@ -0,0 +1,1664 @@ +import { + describe, + it, + beforeEach, + beforeAll, + afterEach +} from "https://deno.land/std@0.183.0/testing/bdd.ts"; + +import { + assert, + assertThrows, + assertRejects, + assertEquals +} from "https://deno.land/std@0.183.0/testing/asserts.ts"; + +import { + assertSpyCall, + assertSpyCalls, + spy, +} from "https://deno.land/std@0.183.0/testing/mock.ts"; + +/* + * mocking the FetchEvent class + * https://developer.mozilla.org/en-US/docs/Web/API/FetchEvent + */ +class FetchEvent extends Event { + request = null + response = null + constructor(request, init=null) { + super('fetch') + if (typeof request == "string") { + if (request.indexOf('http') != 0) { + request = window.location.origin + request + } + if (init == null) { + request = new Request(request) + } else { + request = new Request(request, init) + } + } + this.request = request + } + clientId = 'libresilient-tests' + respondWith(a) { + this.response = a + } + waitForResponse() { + return new Promise(async (resolve, reject)=>{ + while (this.response === null) { + await new Promise(resolve => setTimeout(resolve, 1)) + } + resolve(this.response) + }) + } +} + +beforeAll(async ()=>{ + + // default mocked response data + let responseMockedData = { + data: JSON.stringify({test: "success"}), + type: "application/json", + status: 200, + statusText: "OK", + headers: { + 'Last-Modified': 'TestingLastModifiedHeader', + 'ETag': 'TestingETagHeader' + } + } + // get a Promise resolvint to a mocked Response object built based on supplied data + window.getMockedResponse = (url, init, response_data={}) => { + let rdata = { + ...responseMockedData, + ...response_data + } + let response = new Response( + new Blob( + [rdata.data], + {type: rdata.type} + ), + { + status: rdata.status, + statusText: rdata.statusText, + headers: rdata.headers + }); + // Response.url is read-only, so we have + Object.defineProperty( + response, + "url", + { value: url } + ); + return Promise.resolve(response); + } + // get a mocked fetch()-like function that returns a Promise resolving to the above + window.getMockedFetch = (response_data={}) => { + return (url, init)=>{ + return window.getMockedResponse(url, init, response_data) + } + } + + /* + * prototype of the plugin init object + */ + window.initPrototype = { + name: 'cache' + } + + /* + * mocking our own ServiceWorker API, sigh! + * https://github.com/denoland/deno/issues/5957#issuecomment-985494969 + */ + window.registration = { + scope: "https://test.resilient.is/", + unregister: ()=>{} + } + + /* + * mocking caches.match() + * https://developer.mozilla.org/en-US/docs/Web/API/CacheStorage/match#browser_compatibility + */ + caches.match = async (url) => { + let cache = await caches.open('v1') + return cache.match(url) + } + + /* + * mocking Event.waitUntil() + * https://developer.mozilla.org/en-US/docs/Web/API/ExtendableEvent/waitUntil#browser_compatibility + */ + Event.prototype.waitUntil = async (promise) => { + await promise + } + + /* + * mocking importScripts + */ + window.importScripts = (script) => { + let plugin = script.split('/')[2] + window + .LibResilientPluginConstructors + .set( + plugin, + window.LibResilientPluginConstructorsPrototype.get(plugin) + ) + } + + /* + * mocking window.clients + */ + window.clients = { + get: async (id) => { + // always return the same client, we care only about postMessage() working + // and getting the messages + return { + // that's the only thing we need + // this allows us to spy on client.postMessage() calls issued by the service worker + postMessage: window.clients.prototypePostMessage + } + }, + // the actual spy function must be possible to reference + // but we want spy data per test, so we set it properly in beforeEach() + prototypePostMessage: null + } + + // we need to be able to reliably wait for SW installation + // which is triggered by an "install" Event + window.sw_install_ran = false + + // override addEventListener in order to override the callback + // and to keep track of event listeners that we need to remove in afterEach() + window.event_listeners = new Array() + window.addEventListenerOrig = window.addEventListener + window.addEventListener = async (evtype, func) => { + // normally we want the handler to be what it says on the packaging + let handler = func + // but for "install" type event… we actually want to wrap it such that + // we can then await for it + if (evtype == 'install') { + handler = async (ev) => { + let result = await func(ev); + window.sw_install_ran = true; + return result; + } + } + // adding to the list of installed event listeners + window.event_listeners.push([evtype, handler]) + // we're done + return await window.addEventListenerOrig(evtype, handler) + } + + // wait for SW installation + window.waitForSWInstall = () => { + return new Promise(async (resolve, reject)=>{ + while (!window.sw_install_ran) { + await new Promise(resolve => setTimeout(resolve, 1)) + } + resolve(true) + }) + } + + // wait for caching of a URL, looped up to `tries` times + window.waitForCacheAction = (url, action="add", tries=100) => { + if (action != "add" && action != "remove") { + throw new Error('waitForCacheAction()\'s action parameter can only be "add" or "remove".') + } + console.log('*** WAITING FOR CACHE ACTION:', action, '\n - url:', url) + return new Promise(async (resolve, reject)=>{ + // get the cache object + let cache = await caches.open('v1') + // try to match until we succeed, or run out of tries + for (let i=0; i A "CacheResponseResource" resource (rid 7) was created during + // > the test, but not cleaned up during the test. Close the resource + // > before the end of the test. + return resolve(await cache_result.text()) + } + // waiting for content to be removed from cache? + } else if (action === "remove") { + if (cache_result === undefined) { + return resolve(undefined) + } + // as above, we need to "use" the resource + await cache_result.text() + } + await new Promise(resolve => setTimeout(resolve, 1)) + } + return reject("Ran out of tries"); + }) + } + + /* + * importScripts mock relies on all plugins being loaded here + * TODO: automagically load the list from the plugins directory + */ + let plugins = [ + "alt-fetch", + "any-of", + "basic-integrity", + "cache", + "dnslink-fetch", + "dnslink-ipfs", + "fetch", + "gun-ipfs", + "integrity-check", + "ipns-ipfs", + "redirect", + "signed-integrity", + ] + await Promise.all( + plugins.map(async (plugin)=>{ + await import(`../../plugins/${plugin}/index.js`) + }) + ) + window.LibResilientPluginConstructorsPrototype = window.LibResilientPluginConstructors + window.LibResilientPluginConstructors = new Map() +}) + +/** + * we need to do all of this before each test in order to reset the fetch() use counter + * and make sure window.init is clean and not modified by previous tests + */ +beforeEach(async ()=>{ + window.fetch = spy(window.getMockedFetch()) + window.init = { + ...window.initPrototype + } + // clear the caches + await caches + .has('v1') + .then(async (hasv1) => { + if (hasv1) { + await caches.delete('v1') + } + }) + // make sure we're starting with a clean slate in LibResilientPluginConstructors + window.LibResilientPluginConstructors = new Map() + // keeping track of whether the SW got installed + window.sw_install_ran = false + // cleanup + self.LibResilientConfig = null + self.LibResilientPlugins = null + // postMessage spy + window.clients.prototypePostMessage = spy((msg)=>{console.log('*** got message', msg)}) +}) + +/** + * after each test we need to do a bit of cleanup + * + * specifically, since we need to load the service-worker.js module anew + * we want to clean up any side-effects of having loaded it for the previous test + * and any side-effects of the previous test itself + */ +afterEach(async ()=>{ + while (window.event_listeners.length) { + await window.removeEventListener(...window.event_listeners.pop()); + } + window.test_id += 1; +}) + +describe('service-worker', async () => { + + // mocking window.location + // https://developer.mozilla.org/en-US/docs/Web/API/Window/location + window.location = { + origin: "https://test.resilient.is/" + } + + window.LibResilientPluginConstructors = new Map() + window.LR = { + log: (component, ...items)=>{ + console.debug(component + ' :: ', ...items) + } + } + + window.fetch = null + + window.test_id = 0 + + it("should set-up LibResilientPlugins", async () => { + // we cannot import the same module multiple times: + // https://github.com/denoland/deno/issues/6946 + // + // ...so we have to use a query-param hack, sigh + await import("../../service-worker.js?" + window.test_id); + assert(self.LibResilientPlugins instanceof Array) + }) + + it("should use default LibResilientConfig values when config.json is missing", async () => { + + let mock_response_data = { + data: JSON.stringify({text: "success"}), + status: 404, + statusText: "Not Found" + } + window.fetch = spy(window.getMockedFetch(mock_response_data)) + + await import("../../service-worker.js?" + window.test_id); + await self.dispatchEvent(new Event('install')) + await self.waitForSWInstall() + + assertEquals(typeof self.LibResilientConfig, "object") + assertEquals(self.LibResilientConfig.defaultPluginTimeout, 10000) + assertEquals(self.LibResilientConfig.plugins, [{name: "fetch"},{name: "cache"}]) + assertEquals(self.LibResilientConfig.loggedComponents, ['service-worker', 'fetch', 'cache']) + assertSpyCalls(self.fetch, 1) + }) + + it("should use default LibResilientConfig values when config.json not valid JSON", async () => { + + let mock_response_data = { + data: "Not JSON" + } + window.fetch = spy(window.getMockedFetch(mock_response_data)) + + await import("../../service-worker.js?" + window.test_id); + await self.dispatchEvent(new Event('install')) + await self.waitForSWInstall() + + assertEquals(typeof self.LibResilientConfig, "object") + assertEquals(self.LibResilientConfig.defaultPluginTimeout, 10000) + assertEquals(self.LibResilientConfig.plugins, [{name: "fetch"},{name: "cache"}]) + assertEquals(self.LibResilientConfig.loggedComponents, ['service-worker', 'fetch', 'cache']) + assertSpyCalls(self.fetch, 1) + }) + + it("should use default LibResilientConfig values when no valid 'plugins' field in config.json", async () => { + + let mock_response_data = { + data: JSON.stringify({loggedComponents: ['service-worker', 'fetch'], plugins: 'not a valid array'}) + } + window.fetch = spy(window.getMockedFetch(mock_response_data)) + + await import("../../service-worker.js?" + window.test_id); + await self.dispatchEvent(new Event('install')) + await self.waitForSWInstall() + + assertEquals(typeof self.LibResilientConfig, "object") + assertEquals(self.LibResilientConfig.defaultPluginTimeout, 10000) + assertEquals(self.LibResilientConfig.plugins, [{name: "fetch"},{name: "cache"}]) + assertEquals(self.LibResilientConfig.loggedComponents, ['service-worker', 'fetch', 'cache']) + assertSpyCalls(self.fetch, 1) + }) + + it("should use default LibResilientConfig values when no valid 'loggedComponents' field in config.json", async () => { + + let mock_response_data = { + data: JSON.stringify({loggedComponents: 'not a valid array', plugins: [{name: "fetch"}]}) + } + window.fetch = spy(window.getMockedFetch(mock_response_data)) + + await import("../../service-worker.js?" + window.test_id); + await self.dispatchEvent(new Event('install')) + await self.waitForSWInstall() + + assertEquals(typeof self.LibResilientConfig, "object") + assertEquals(self.LibResilientConfig.defaultPluginTimeout, 10000) + assertEquals(self.LibResilientConfig.plugins, [{name: "fetch"},{name: "cache"}]) + assertEquals(self.LibResilientConfig.loggedComponents, ['service-worker', 'fetch', 'cache']) + assertSpyCalls(self.fetch, 1) + }) + + it("should use default LibResilientConfig values when 'defaultPluginTimeout' field in config.json contains an invalid value", async () => { + + let mock_response_data = { + data: JSON.stringify({loggedComponents: ['service-worker', 'fetch'], plugins: [{name: "fetch"}], defaultPluginTimeout: 'not an integer'}) + } + window.fetch = spy(window.getMockedFetch(mock_response_data)) + + await import("../../service-worker.js?" + window.test_id); + await self.dispatchEvent(new Event('install')) + await self.waitForSWInstall() + + assertEquals(typeof self.LibResilientConfig, "object") + assertEquals(self.LibResilientConfig.defaultPluginTimeout, 10000) + assertEquals(self.LibResilientConfig.plugins, [{name: "fetch"},{name: "cache"}]) + assertEquals(self.LibResilientConfig.loggedComponents, ['service-worker', 'fetch', 'cache']) + assertSpyCalls(self.fetch, 1) + }) + + it("should use config values from a valid fetched config.json file, caching it", async () => { + let mock_response_data = { + data: JSON.stringify({loggedComponents: ['service-worker', 'cache'], plugins: [{name: "cache"}], defaultPluginTimeout: 5000}) + } + window.fetch = spy(window.getMockedFetch(mock_response_data)) + + await import("../../service-worker.js?" + window.test_id); + await self.dispatchEvent(new Event('install')) + await self.waitForSWInstall() + + assertEquals(typeof self.LibResilientConfig, "object") + assertEquals(self.LibResilientConfig.defaultPluginTimeout, 5000) + assertEquals(self.LibResilientConfig.plugins, [{name: "cache"}]) + assertEquals(self.LibResilientConfig.loggedComponents, ['service-worker', 'cache']) + assertSpyCalls(self.fetch, 1) + + // cacheConfigJSON() is called asynchronously in the Service Worker, + // if we don't make sure that the caching has completed, we will get an error. + // so we wait until config.json is cached, and use that to verify it is in fact cached + assertEquals( + await window.waitForCacheAction(window.location.origin + 'config.json'), + mock_response_data.data + ); + }) + + it("should instantiate a complex tree of configured plugins", async () => { + + self.LibResilientConfig = { + plugins: [{ + name: 'plugin-1', + uses: [{ + name: 'plugin-2', + uses: [{ + name: 'plugin-3' + }] + },{ + name: 'plugin-3' + }] + },{ + name: 'plugin-2', + uses: [{ + name: 'plugin-3' + }] + },{ + name: 'plugin-3', + uses: [{ + name: 'plugin-1' + },{ + name: 'plugin-2', + uses: [{ + name: 'plugin-1', + uses: [{ + name: 'plugin-4' + }] + }] + }] + },{ + name: 'plugin-4' + }], + loggedComponents: ['service-worker'] + } + + window.LibResilientPluginConstructors.set('plugin-1', (LRPC, config)=>{ + return { + name: 'plugin-1', + description: 'Plugin Type 1', + version: '0.0.1', + fetch: (url)=>{return true}, + uses: config.uses || [] + } + }) + window.LibResilientPluginConstructors.set('plugin-2', (LRPC, config)=>{ + return { + name: 'plugin-2', + description: 'Plugin Type 2', + version: '0.0.1', + fetch: (url)=>{return true}, + uses: config.uses || [] + } + }) + window.LibResilientPluginConstructors.set('plugin-3', (LRPC, config)=>{ + return { + name: 'plugin-3', + description: 'Plugin Type 3', + version: '0.0.1', + fetch: (url)=>{return true}, + uses: config.uses || [] + } + }) + window.LibResilientPluginConstructors.set('plugin-4', (LRPC, config)=>{ + return { + name: 'plugin-4', + description: 'Plugin Type 4', + version: '0.0.1', + fetch: (url)=>{return true}, + uses: config.uses || [] + } + }) + + await import("../../service-worker.js?" + window.test_id); + await self.dispatchEvent(new Event('install')) + await self.waitForSWInstall() + + assertEquals(typeof self.LibResilientConfig, 'object') + // basic stuff + assertEquals(self.LibResilientPlugins.length, 4) + assertEquals(self.LibResilientPlugins[0].name, 'plugin-1') + assertEquals(self.LibResilientPlugins[1].name, 'plugin-2') + assertEquals(self.LibResilientPlugins[2].name, 'plugin-3') + assertEquals(self.LibResilientPlugins[3].name, 'plugin-4') + // first plugin dependencies + assertEquals(self.LibResilientPlugins[0].uses.length, 2) + assertEquals(self.LibResilientPlugins[0].uses[0].name, 'plugin-2') + assertEquals(self.LibResilientPlugins[0].uses[0].uses.length, 1) + assertEquals(self.LibResilientPlugins[0].uses[0].uses[0].name, 'plugin-3') + assertEquals(self.LibResilientPlugins[0].uses[0].uses[0].uses.length, 0) + assertEquals(self.LibResilientPlugins[0].uses[1].name, 'plugin-3') + assertEquals(self.LibResilientPlugins[0].uses[1].uses.length, 0) + // second plugin dependencies + assertEquals(self.LibResilientPlugins[1].uses.length, 1) + assertEquals(self.LibResilientPlugins[1].uses[0].name, 'plugin-3') + assertEquals(self.LibResilientPlugins[1].uses[0].uses.length, 0) + // third plugin dependencies + assertEquals(self.LibResilientPlugins[2].uses.length, 2) + assertEquals(self.LibResilientPlugins[2].uses[0].name, 'plugin-1') + assertEquals(self.LibResilientPlugins[2].uses[0].uses.length, 0) + assertEquals(self.LibResilientPlugins[2].uses[1].name, 'plugin-2') + assertEquals(self.LibResilientPlugins[2].uses[1].uses.length, 1) + assertEquals(self.LibResilientPlugins[2].uses[1].uses[0].name, 'plugin-1') + assertEquals(self.LibResilientPlugins[2].uses[1].uses[0].uses.length, 1) + assertEquals(self.LibResilientPlugins[2].uses[1].uses[0].uses[0].name, 'plugin-4') + assertEquals(self.LibResilientPlugins[2].uses[1].uses[0].uses[0].uses.length, 0) + // fourth plugin dependencies + assertEquals(self.LibResilientPlugins[3].uses.length, 0) + }) + + it("should instantiate configured plugins; explicitly disabled plugins should not be instantiated", async () => { + self.LibResilientConfig = { + plugins: [{ + name: 'plugin-enabled' + },{ + name: 'plugin-disabled', + enabled: false + },{ + name: 'plugin-enabled', + enabled: true + }] + } + + window.LibResilientPluginConstructors.set('plugin-enabled', ()=>{ + return { + name: 'plugin-enabled', + description: 'Enabled plugin', + version: '0.0.1', + fetch: (url)=>{return true} + } + }) + window.LibResilientPluginConstructors.set('plugin-disabled', ()=>{ + return { + name: 'plugin-disabled', + description: 'Disabled plugin', + version: '0.0.1', + fetch: (url)=>{return true} + } + }) + + await import("../../service-worker.js?" + window.test_id); + await self.dispatchEvent(new Event('install')) + await self.waitForSWInstall() + + assertEquals(typeof self.LibResilientConfig, 'object') + assertEquals(self.LibResilientPlugins.length, 2) + assertEquals(self.LibResilientPlugins[0].name, 'plugin-enabled') + assertEquals(self.LibResilientPlugins[1].name, 'plugin-enabled') + }) + + it("should instantiate configured plugins; explicitly disabled dependencies should not be instantiated", async () => { + self.LibResilientConfig = { + plugins: [{ + name: 'plugin-disabled', + enabled: false, + uses: [{ + name: 'dependency-enabled' + }] + },{ + name: 'plugin-enabled', + uses: [{ + name: 'dependency-disabled', + enabled: false + }] + },{ + name: 'plugin-enabled', + uses: [{ + name: 'dependency-enabled', + enabled: true + }] + }], + loggedComponents: ['service-worker'] + } + + window.LibResilientPluginConstructors.set('plugin-enabled', (LRPC, config)=>{ + return { + name: 'plugin-enabled', + description: 'Enabled plugin', + version: '0.0.1', + fetch: (url)=>{return true}, + uses: config.uses || [] + } + }) + window.LibResilientPluginConstructors.set('plugin-disabled', (LRPC, config)=>{ + return { + name: 'plugin-disabled', + description: 'Disabled plugin', + version: '0.0.1', + fetch: (url)=>{return true}, + uses: config.uses || [] + } + }) + window.LibResilientPluginConstructors.set('dependency-disabled', (LRPC, config)=>{ + return { + name: 'dependency-disabled', + description: 'Disabled dependency plugin', + version: '0.0.1', + fetch: (url)=>{return true}, + uses: config.uses || [] + } + }) + window.LibResilientPluginConstructors.set('dependency-enabled', (LRPC, config)=>{ + return { + name: 'dependency-enabled', + description: 'Enabled dependency plugin', + version: '0.0.1', + fetch: (url)=>{return true}, + uses: config.uses || [] + } + }) + + await import("../../service-worker.js?" + window.test_id); + await self.dispatchEvent(new Event('install')) + await self.waitForSWInstall() + + assertEquals(typeof self.LibResilientConfig, 'object') + assertEquals(self.LibResilientPlugins.length, 2) + assertEquals(self.LibResilientPlugins[0].name, 'plugin-enabled') + assertEquals(self.LibResilientPlugins[0].uses.length, 0) + assertEquals(self.LibResilientPlugins[1].name, 'plugin-enabled') + assertEquals(self.LibResilientPlugins[1].uses.length, 1) + assertEquals(self.LibResilientPlugins[1].uses[0].name, 'dependency-enabled') + assertEquals(self.LibResilientPlugins[1].uses[0].uses.length, 0) + }) + + it("should use a cached valid config.json file, with no fetch happening", async () => { + + // prepare the config request/response + let mock_response_data = { + data: JSON.stringify({loggedComponents: ['service-worker', 'cache'], plugins: [{name: "cache"}], defaultPluginTimeout: 5000}) + } + var config_url = window.location.origin + 'config.json' + + // get the response containing config to cache + var config_response = await window.getMockedResponse(config_url, {}, mock_response_data) + + // cache it once you get it + await caches + .open('v1') + .then(async (cache)=>{ + await cache + .put( + config_url, + await window.getMockedResponse(config_url, {}, mock_response_data) + ) + }) + + // service worker is a go! + await import("../../service-worker.js?" + window.test_id); + await self.dispatchEvent(new Event('install')) + await self.waitForSWInstall() + + assertEquals(typeof self.LibResilientConfig, 'object') + assertEquals(self.LibResilientConfig.defaultPluginTimeout, 5000) + assertEquals(self.LibResilientConfig.plugins, [{name: "cache"}]) + assertEquals(self.LibResilientConfig.loggedComponents, ['service-worker', 'cache']) + assertSpyCalls(self.fetch, 0) + }) + + it("should use a stale cached valid config.json file without a fetch, then retrieve and cache a fresh config.json using the configured plugins", async () => { + + // this does not change + var config_url = window.location.origin + 'config.json' + + // prepare the stale config request/response + let mock_response_data = { + data: JSON.stringify({loggedComponents: ['service-worker', 'cache', 'fetch'], plugins: [{name: "fetch"},{name: "cache"}], defaultPluginTimeout: 5000}), + headers: { + // very stale date + 'Date': new Date(0).toUTCString() + } + } + + // cache it once you get it + await caches + .open('v1') + .then(async (cache)=>{ + await cache + .put( + config_url, + await window.getMockedResponse(config_url, {}, mock_response_data) + ) + let resp = await cache + .match(config_url) + console.log(resp) + console.log(await resp.text()) + }) + + // prepare the fresh config request/response + let mock_response_data2 = { + data: JSON.stringify({loggedComponents: ['service-worker', 'fetch'], plugins: [{name: "fetch"}], defaultPluginTimeout: 2000}), + headers: { + // very stale date + 'Date': new Date(0).toUTCString() + } + } + + // we need to be able to spy on the function that "fetches" the config + let resolveConfigFetch = spy(window.getMockedFetch(mock_response_data2)) + window.LibResilientPluginConstructors.set('fetch', ()=>{ + return { + name: 'fetch', + description: 'Resolve with config data (pretending to be fetch).', + version: '0.0.1', + fetch: resolveConfigFetch + } + }) + + // service worker is a go! + await import("../../service-worker.js?" + window.test_id); + await self.dispatchEvent(new Event('install')) + await self.waitForSWInstall() + + // verify current config (the one from the pre-cached stale `config.json`) + assertEquals(typeof self.LibResilientConfig, 'object') + assertEquals(self.LibResilientConfig.defaultPluginTimeout, 5000) + assertEquals(self.LibResilientConfig.plugins, [{name: "fetch"},{name: "cache"}]) + assertEquals(self.LibResilientConfig.loggedComponents, ['service-worker', 'cache', 'fetch']) + assertSpyCalls(self.fetch, 0) + assertSpyCalls(resolveConfigFetch, 1) + + // wait until verify the *new* config got cached + // running waitForCacheAdd only once might not be enough, as the cache + // already contained config.json! + // + // we have try to get it a few times, but limit how many times we try + // so as not to end up in a forever loop + for (let i=0; i<100; i++) { + // did we get the new config? + if (await window.waitForCacheAction(config_url) === mock_response_data2.data) { + // we did! we're done + return true; + } + } + fail('New config failed to cache, apparently!') + }) + + it("should use a stale cached valid config.json file without a fetch; invalid config.json retrieved using the configured plugins should not be cached", async () => { + + // this does not change + var config_url = window.location.origin + 'config.json' + + // prepare the stale config request/response + let mock_response_data = { + data: JSON.stringify({loggedComponents: ['service-worker', 'cache', 'resolve-config'], plugins: [{name: "cache"}, {name: "resolve-config"}], defaultPluginTimeout: 5000}), + headers: { + // very stale date + 'Date': new Date(0).toUTCString() + } + } + + // cache it once you get it + await caches + .open('v1') + .then(async (cache)=>{ + await cache + .put( + config_url, + await window.getMockedResponse(config_url, {}, mock_response_data) + ) + let resp = await cache + .match(config_url) + console.log(resp) + console.log(await resp.text()) + }) + + // prepare the fresh invalid config request/response + let mock_response_data2 = { + data: JSON.stringify({loggedComponentsInvalid: ['service-worker', 'resolve-config'], pluginsInvalid: [{name: "resolve-config"}], defaultPluginTimeoutInvalid: 2000}), + headers: { + // very stale date + 'Date': new Date(0).toUTCString() + } + } + + // we need to be able to spy on the function that "fetches" the config + let resolveConfigFetch = spy(window.getMockedFetch(mock_response_data2)) + window.LibResilientPluginConstructors.set('resolve-config', ()=>{ + return { + name: 'resolve-config', + description: 'Resolve with config data.', + version: '0.0.1', + fetch: resolveConfigFetch + } + }) + + // service worker is a go! + await import("../../service-worker.js?" + window.test_id); + await self.dispatchEvent(new Event('install')) + await self.waitForSWInstall() + + // verify current config (the one from the pre-cached stale `config.json`) + assertEquals(typeof self.LibResilientConfig, 'object') + assertEquals(self.LibResilientConfig.defaultPluginTimeout, 5000) + assertEquals(self.LibResilientConfig.plugins, [{name: "cache"}, {name: "resolve-config"}]) + assertEquals(self.LibResilientConfig.loggedComponents, ['service-worker', 'cache', 'resolve-config']) + assertSpyCalls(self.fetch, 0) + assertSpyCalls(resolveConfigFetch, 1) + + // waiting for potential caching of the "new" config + for (let i=0; i<100; i++) { + // did we get the new config? + if (await window.waitForCacheAction(config_url) === mock_response_data2.data) { + // we did! that's a paddling! + fail('New config failed to cache, apparently!') + } + } + }) + + it("should use a stale cached valid config.json file without a fetch; valid config.json retrieved using the configured plugins other than fetch should not be cached", async () => { + + // this does not change + var config_url = window.location.origin + 'config.json' + + // prepare the stale config request/response + let mock_response_data = { + data: JSON.stringify({loggedComponents: ['service-worker', 'resolve-config'], plugins: [{name: "resolve-config"}], defaultPluginTimeout: 5000}), + headers: { + // very stale date + 'Date': new Date(0).toUTCString() + } + } + + // cache it once you get it + await caches + .open('v1') + .then(async (cache)=>{ + await cache + .put( + config_url, + await window.getMockedResponse(config_url, {}, mock_response_data) + ) + let resp = await cache + .match(config_url) + console.log(resp) + console.log(await resp.text()) + }) + + // prepare the fresh invalid config request/response + let mock_response_data2 = { + data: JSON.stringify({loggedComponents: ['service-worker', 'resolve-config', 'cache'], plugins: [{name: "resolve-config"}, {name: "cache"}], defaultPluginTimeout: 2000}), + headers: { + // very stale date + 'Date': new Date(0).toUTCString() + } + } + + // we need to be able to spy on the function that "fetches" the config + let resolveConfigFetch = spy(window.getMockedFetch(mock_response_data2)) + window.LibResilientPluginConstructors.set('resolve-config', ()=>{ + return { + name: 'resolve-config', + description: 'Resolve with config data.', + version: '0.0.1', + fetch: resolveConfigFetch + } + }) + + // service worker is a go! + await import("../../service-worker.js?" + window.test_id); + await self.dispatchEvent(new Event('install')) + await self.waitForSWInstall() + + // verify current config (the one from the pre-cached stale `config.json`) + assertEquals(typeof self.LibResilientConfig, 'object') + assertEquals(self.LibResilientConfig.defaultPluginTimeout, 5000) + assertEquals(self.LibResilientConfig.plugins, [{name: "resolve-config"}]) + assertEquals(self.LibResilientConfig.loggedComponents, ['service-worker', 'resolve-config']) + assertSpyCalls(self.fetch, 0) + assertSpyCalls(resolveConfigFetch, 1) + + // waiting for potential caching of the "new" config + for (let i=0; i<100; i++) { + // did we get the new config? + if (await window.waitForCacheAction(config_url) === mock_response_data2.data) { + // we did! that's a paddling + fail('New config failed to cache, apparently!') + } + } + }) + + it("should ignore failed fetch by first configured plugin if followed by a successful fetch by a second one", async () => { + window.LibResilientConfig = { + plugins: [{ + name: 'reject-all' + },{ + name: 'resolve-all' + }], + loggedComponents: [ + 'service-worker' + ] + } + + let rejectingFetch = spy( + (request, init)=>{ return Promise.reject('reject-all rejecting a request for: ' + request); } + ) + + window.LibResilientPluginConstructors.set('reject-all', ()=>{ + return { + name: 'reject-all', + description: 'Reject all requests.', + version: '0.0.1', + fetch: rejectingFetch + } + }) + window.LibResilientPluginConstructors.set('resolve-all', ()=>{ + return { + name: 'resolve-all', + description: 'Resolve all requests.', + version: '0.0.1', + fetch: fetch + } + }) + + await import("../../service-worker.js?" + window.test_id); + await self.dispatchEvent(new Event('install')) + await self.waitForSWInstall() + + let fetch_event = new FetchEvent('test.json') + window.dispatchEvent(fetch_event) + let response = await fetch_event.waitForResponse() + + assertSpyCalls(window.fetch, 2); // two, because the first one is for config.json + assertSpyCalls(rejectingFetch, 1); + assertSpyCall(window.fetch, 1, { args: [ + "https://test.resilient.is/test.json", + { + cache: undefined, + integrity: undefined, + method: "GET", + redirect: "follow", + referrer: undefined, + }] + }) + assertEquals(await response.json(), { test: "success" }) + }); + + it("should pass the Request() init data to plugins", async () => { + self.LibResilientConfig = { + plugins: [{ + name: 'reject-all' + },{ + name: 'resolve-all' + }], + loggedComponents: [ + 'service-worker' + ] + } + let rejectingFetch = spy( + (request, init)=>{ return Promise.reject('reject-all rejecting a request for: ' + request); } + ) + + window.LibResilientPluginConstructors.set('reject-all', ()=>{ + return { + name: 'reject-all', + description: 'Reject all requests.', + version: '0.0.1', + fetch: rejectingFetch + } + }) + window.LibResilientPluginConstructors.set('resolve-all', ()=>{ + return { + name: 'resolve-all', + description: 'Resolve all requests.', + version: '0.0.1', + fetch: fetch + } + }) + + let initTest = { + method: "GET", + // TODO: ref. https://gitlab.com/rysiekpl/libresilient/-/issues/23 + //headers: new Headers({"x-stub": "STUB"}), + //mode: "mode-stub", + //credentials: "credentials-stub", + //cache: "cache-stub", + //referrer: "referrer-stub", + redirect: "error", + //integrity: "" + } + + await import("../../service-worker.js?" + window.test_id); + await self.dispatchEvent(new Event('install')) + await self.waitForSWInstall() + + let fetch_event = new FetchEvent('test.json', initTest) + window.dispatchEvent(fetch_event) + let response = await fetch_event.waitForResponse() + + assertSpyCalls(rejectingFetch, 1); + assertSpyCalls(window.fetch, 2); // two, because the first one is for config.json + assertEquals(await response.json(), { test: "success" }) + + assertSpyCall(rejectingFetch, 0, { args: [ + "https://test.resilient.is/test.json", + { + cache: undefined, + integrity: undefined, + method: "GET", + redirect: "error", + referrer: undefined, + }] + }) + + assertSpyCall(window.fetch, 1, { args: [ + "https://test.resilient.is/test.json", + { + cache: undefined, + integrity: undefined, + method: "GET", + redirect: "error", + referrer: undefined, + }] + }) + }); + + it("should respect defaultPluginTimeout", async () => { + window.LibResilientConfig = { + defaultPluginTimeout: 100, + plugins: [{ + name: 'resolve-with-timeout' + }], + loggedComponents: [ + 'service-worker', + ] + } + let rwtCallback = spy() + let rwt_timeout_id = null + window.LibResilientPluginConstructors.set('resolve-with-timeout', ()=>{ + return { + name: 'resolve-with-timeout', + description: 'Resolve all requests after a timeout.', + version: '0.0.1', + fetch: (request, init)=>{ + return new Promise((resolve, reject)=>{ + rwt_timeout_id = setTimeout(rwtCallback, 300) + }) + } + } + }) + + await import("../../service-worker.js?" + window.test_id); + await self.dispatchEvent(new Event('install')) + await self.waitForSWInstall() + + let fetch_event = new FetchEvent('test.json') + window.dispatchEvent(fetch_event) + + let err = null + try { + let response = await fetch_event.waitForResponse() + } catch(e) { + err = e + } + clearTimeout(rwt_timeout_id) + assertEquals(err.toString(), "Error: LibResilient request using resolve-with-timeout timed out after 100ms.") + }); + + it("external request should work and not go through the plugins", async () => { + self.LibResilientConfig = { + plugins: [{ + name: 'reject-all' + }], + loggedComponents: [ + 'service-worker' + ] + } + window.LibResilientPluginConstructors.set('reject-all', ()=>{ + return { + name: 'reject-all', + description: 'Reject all requests.', + version: '0.0.1', + fetch: (request, init)=>{ return Promise.reject(request); } + } + }) + + await import("../../service-worker.js?" + window.test_id); + await self.dispatchEvent(new Event('install')) + await self.waitForSWInstall() + + let fetch_event = new FetchEvent('https://example.com/test.json') + window.dispatchEvent(fetch_event) + let response = await fetch_event.waitForResponse() + + assertEquals(await response.json(), { test: "success" }) + }) + + it("should make POST requests not go through the plugins", async () => { + self.LibResilientConfig = { + plugins: [{ + name: 'reject-all' + }], + loggedComponents: [ + 'service-worker' + ] + } + window.LibResilientPluginConstructors.set('reject-all', ()=>{ + return { + name: 'reject-all', + description: 'Reject all requests.', + version: '0.0.1', + fetch: (request, init)=>{ return Promise.reject(request); } + } + }) + + await import("../../service-worker.js?" + window.test_id); + await self.dispatchEvent(new Event('install')) + await self.waitForSWInstall() + + let fetch_event = new FetchEvent(window.location.origin + 'test.json', {method: "POST"}) + window.dispatchEvent(fetch_event) + let response = await fetch_event.waitForResponse() + assertEquals(await response.json(), { test: "success" }) + }) + + it("should stash content after a successful fetch", async () => { + self.LibResilientConfig = { + plugins: [{ + name: 'fetch' + },{ + name: 'cache' + }], + loggedComponents: [ + 'service-worker', 'fetch', 'cache' + ] + } + + await import("../../service-worker.js?" + window.test_id); + await self.dispatchEvent(new Event('install')) + await self.waitForSWInstall() + + let fetch_event = new FetchEvent(window.location.origin + 'test.json') + window.dispatchEvent(fetch_event) + let response = await fetch_event.waitForResponse() + + assertEquals(await response.json(), { test: "success" }) + + // stashing plugin's stash() is called asynchronously in the Service Worker, + // if we don't make sure that the caching has completed, we will get an error. + // so we wait until config.json is cached, and use that to verify it is in fact + // cached + assertEquals( + JSON.parse( + await window.waitForCacheAction(window.location.origin + 'test.json')), + { test: "success" } + ); + }); + + it("should skip stashing should if content was retrieved from a stashing plugin", async () => { + self.LibResilientConfig = { + plugins: [{ + name: 'stashing-test' + },{ + name: 'reject-all' + }], + loggedComponents: [ + 'service-worker' + ] + } + + // three little mocks + let resolvingFetch = spy(window.getMockedFetch()) + let rejectingFetch = spy((request, init)=>{ return Promise.reject(request); }) + let stashingStash = spy() + + // two little plugins + window.LibResilientPluginConstructors.set('stashing-test', ()=>{ + return { + name: 'stashing-test', + description: 'Mock stashing plugin.', + version: '0.0.1', + fetch: resolvingFetch, + stash: stashingStash + } + }) + window.LibResilientPluginConstructors.set('reject-all', ()=>{ + return { + name: 'reject-all', + description: 'Reject all requests.', + version: '0.0.1', + fetch: rejectingFetch + } + }) + + await import("../../service-worker.js?" + window.test_id); + await self.dispatchEvent(new Event('install')) + await self.waitForSWInstall() + + let fetch_event = new FetchEvent(window.location.origin + 'test.json') + window.dispatchEvent(fetch_event) + let response = await fetch_event.waitForResponse() + + assertEquals(await response.json(), { test: "success" }) + assertSpyCalls(resolvingFetch, 1) + assertSpyCalls(stashingStash, 0) + assertSpyCalls(rejectingFetch, 1) + }); + + it("should stash content if it was retrieved from a job after retrieval from a stashing plugin, and it differs from the stashed version", async () => { + self.LibResilientConfig = { + plugins: [{ + name: 'stashing-test' + },{ + name: 'resolve-all' + }], + loggedComponents: [ + 'service-worker' + ] + } + // three little mocks + let resolvingFetch = spy(window.getMockedFetch()) + let resolvingFetch2 = spy(window.getMockedFetch({ + data: JSON.stringify({ test: "success2" }), + headers: { 'X-LibResilient-ETag': 'NewTestingETagHeader' } + })) + + let stashingStash = spy(async (response, url)=>{ + assertEquals(await response.json(), { test: "success2" }) + assertEquals(response.headers.get('X-LibResilient-ETag'), 'NewTestingETagHeader') + }) + + window.LibResilientPluginConstructors.set('stashing-test', ()=>{ + return { + name: 'stashing-test', + description: 'Mock stashing plugin.', + version: '0.0.1', + fetch: resolvingFetch, + stash: stashingStash + } + }) + window.LibResilientPluginConstructors.set('resolve-all', ()=>{ + return { + name: 'resolve-all', + description: 'Resolve all requests.', + version: '0.0.1', + fetch: resolvingFetch2 + } + }) + + await import("../../service-worker.js?" + window.test_id); + await self.dispatchEvent(new Event('install')) + await self.waitForSWInstall() + + let fetch_event = new FetchEvent(window.location.origin + 'test.json') + window.dispatchEvent(fetch_event) + let response = await fetch_event.waitForResponse() + + assertEquals(await response.json(), { test: "success" }) + assertSpyCalls(resolvingFetch, 1) + assertSpyCalls(stashingStash, 1) + assertSpyCalls(resolvingFetch2, 1) + assertSpyCall( + window.clients.prototypePostMessage, + 6, + { args: [{ + url: "https://test.resilient.is/test.json", + fetchedDiffers: true + }]} + ) + }); + + it("should stash content when explicitly asked to", async () => { + self.LibResilientConfig = { + plugins: [{ + name: 'cache' + }], + loggedComponents: [ + 'service-worker', 'cache' + ] + } + + await import("../../service-worker.js?" + window.test_id); + await self.dispatchEvent(new Event('install')) + await self.waitForSWInstall() + + let stashEvent = new Event('message') + stashEvent.data = { + stash: [await window.getMockedResponse(window.location.origin + 'test.json')] + } + + // stash it! + await self.dispatchEvent(stashEvent) + + // let's see if it got added to cache + assertEquals( + JSON.parse(await window.waitForCacheAction(window.location.origin + 'test.json')), + { test: "success" } + ); + }); + + it("should pass the Request() init data to a background plugin after a retrieval from a stashing plugin", async () => { + self.LibResilientConfig = { + plugins: [{ + name: 'stashing-test' + },{ + name: 'resolve-all' + }], + loggedComponents: [ + 'service-worker' + ] + } + let resolvingFetch = spy(window.getMockedFetch({ + headers: { + 'X-LibResilient-Method': 'resolve-all', + 'X-LibResilient-ETag': 'TestingETagHeader' + } + })) + let resolvingFetch2 = spy(window.getMockedFetch({ + data: JSON.stringify({ test: "success2" }), + headers: { + 'ETag': 'NewTestingETagHeader' + } + })) + let stashingStash = spy(async (response, url)=>{ + assertEquals(await response.json(), { test: "success2" }) + assertEquals(response.headers.get('ETag'), 'NewTestingETagHeader') + }) + + window.LibResilientPluginConstructors.set('stashing-test', ()=>{ + return { + name: 'stashing-test', + description: 'Mock stashing plugin.', + version: '0.0.1', + fetch: resolvingFetch, + stash: stashingStash + } + }) + window.LibResilientPluginConstructors.set('resolve-all', ()=>{ + return { + name: 'resolve-all', + description: 'Resolve all requests.', + version: '0.0.1', + fetch: resolvingFetch2 + } + }) + + await import("../../service-worker.js?" + window.test_id); + await self.dispatchEvent(new Event('install')) + await self.waitForSWInstall() + + let initTest = { + method: "GET", + // TODO: ref. https://gitlab.com/rysiekpl/libresilient/-/issues/23 + //headers: new Headers({"x-stub": "STUB"}), + //mode: "mode-stub", + //credentials: "same-origin", + cache: undefined, + referrer: undefined, + redirect: "error", // this is the only signal we get here, really! + integrity: undefined + } + + let fetch_event = new FetchEvent(window.location.origin + 'test.json', initTest) + window.dispatchEvent(fetch_event) + let response = await fetch_event.waitForResponse() + + assertSpyCalls(resolvingFetch, 1); + assertSpyCalls(resolvingFetch2, 1); + assertEquals(await response.json(), { test: "success" }) + assertSpyCall( + resolvingFetch, + 0, + { args: [ + window.location.origin + 'test.json', + initTest + ]} + ) + assertSpyCall( + resolvingFetch2, + 0, + { args: [ + window.location.origin + 'test.json', + initTest + ]} + ) + }); + + it("should unstash content when explicitly asked to", async () => { + self.LibResilientConfig = { + plugins: [{ + name: 'cache' + }], + loggedComponents: [ + 'service-worker', 'cache' + ] + } + + await import("../../service-worker.js?" + window.test_id); + await self.dispatchEvent(new Event('install')) + await self.waitForSWInstall() + + let stashEvent = new Event('message') + stashEvent.data = { + stash: [await window.getMockedResponse(window.location.origin + 'test.json')] + } + + // stash it! + await self.dispatchEvent(stashEvent) + + // let's see if it got added to cache + assertEquals( + JSON.parse(await window.waitForCacheAction(window.location.origin + 'test.json')), + { test: "success" } + ); + + let unstashEvent = new Event("message") + unstashEvent.data = { + unstash: [window.location.origin + 'test.json'] + } + + // unstash it! + await self.dispatchEvent(unstashEvent) + + // let's see if it got removed from cache + assertEquals( + await window.waitForCacheAction(window.location.origin + 'test.json', "remove"), + undefined + ); + }); + + it("should handle publishing content explicitly", async () => { + self.LibResilientConfig = { + plugins: [{ + name: 'publish-test' + }], + loggedComponents: [ + 'service-worker' + ] + } + + let publishMock = spy() + + window.LibResilientPluginConstructors.set('publish-test', ()=>{ + return { + name: 'publish-test', + description: 'Publish plugin fixture.', + version: '0.0.1', + publish: publishMock + } + }) + + await import("../../service-worker.js?" + window.test_id); + await self.dispatchEvent(new Event('install')) + await self.waitForSWInstall() + + let publishEvent = new Event('message') + publishEvent.data = { + publish: [await window.getMockedResponse(window.location.origin + 'test.json')] + } + + // publish it! + await self.dispatchEvent(publishEvent) + + assertSpyCall(publishMock, 0, { + args: [publishEvent.data.publish[0]] + }) + }) + + it("should be able to use plugins with dependencies correctly", async () => { + self.LibResilientConfig = { + plugins: [{ + name: 'dependent-test', + uses: [{ + name: 'dependency1-test' + },{ + name: 'dependency2-test' + }] + }], + loggedComponents: [ + 'service-worker' + ] + } + window.LibResilientPluginConstructors.set('dependent-test', ()=>{ + return { + name: 'dependent-test', + description: 'Dependent plugin fixture.', + version: '0.0.1', + uses: [{ + name: 'dependency1-test' + },{ + name: 'dependency2-test' + }] + } + }) + window.LibResilientPluginConstructors.set('dependency1-test', ()=>{ + return { + name: 'dependency1-test', + description: 'First dependency plugin fixture.', + version: '0.0.1' + } + }) + window.LibResilientPluginConstructors.set('dependency2-test', ()=>{ + return { + name: 'dependency2-test', + description: 'Second dependency plugin fixture.', + version: '0.0.1' + } + }) + + await import("../../service-worker.js?" + window.test_id); + await self.dispatchEvent(new Event('install')) + await self.waitForSWInstall() + + assertEquals(self.LibResilientPlugins.map(p=>p.name), ['dependent-test']) + assertEquals(self.LibResilientPlugins[0].uses.map(p=>p.name), ['dependency1-test', 'dependency2-test']) + }) + + it("should be able to use multiple instances of the same plugin", async () => { + self.LibResilientConfig = { + plugins: [{ + name: 'plugin-test', + },{ + name: 'plugin-test', + },{ + name: 'plugin-test', + }], + loggedComponents: [ + 'service-worker' + ] + } + var pver = 0 + window.LibResilientPluginConstructors.set('plugin-test', ()=>{ + pver += 1 + return { + name: 'plugin-test', + description: 'Simple plugin stub.', + version: '0.0.' + pver + } + }) + + await import("../../service-worker.js?" + window.test_id); + await self.dispatchEvent(new Event('install')) + await self.waitForSWInstall() + + assertEquals(self.LibResilientPlugins.map(p=>p.name), ['plugin-test', 'plugin-test', 'plugin-test']) + assertEquals(self.LibResilientPlugins.map(p=>p.version), ['0.0.1', '0.0.2', '0.0.3']) + }) + + it("should error out if all plugins fail", async () => { + self.LibResilientConfig = { + plugins: [{ + name: 'reject-all' + }], + loggedComponents: [ + 'service-worker' + ] + } + window.LibResilientPluginConstructors.set('reject-all', ()=>{ + return { + name: 'reject-all', + description: 'Reject all requests.', + version: '0.0.1', + fetch: (request, init)=>{ return Promise.reject(request); } + } + }) + + await import("../../service-worker.js?" + window.test_id); + await self.dispatchEvent(new Event('install')) + await self.waitForSWInstall() + + let fetch_event = new FetchEvent('test.json') + window.dispatchEvent(fetch_event) + + assertRejects( + ()=>{ + return fetch_event.waitForResponse() + }, + fetch_event.request + ) + }) + + it("should send clientId back if event.resultingClientId is set", async () => { + self.LibResilientConfig = { + plugins: [{ + name: 'resolve-all' + }], + loggedComponents: [ + 'service-worker' + ] + } + window.LibResilientPluginConstructors.set('resolve-all', ()=>{ + return { + name: 'resolve-all', + description: 'Resolve all requests.', + version: '0.0.1', + fetch: window.fetch + } + }) + + await import("../../service-worker.js?" + window.test_id); + await self.dispatchEvent(new Event('install')) + await self.waitForSWInstall() + + // we need a FetchEvent with a resultingClientId field set + let fetch_event = new FetchEvent('test.json') + fetch_event.resultingClientId = 'resulting-client-id-test' + + // do the fetch and wait for the result that we don't really care about + window.dispatchEvent(fetch_event) + await fetch_event.waitForResponse() + + // assert that resulting-client-id-test shows up in messages + // posted from the service worker + assertSpyCall(window.clients.prototypePostMessage, 0, { + args: [{ + clientId: "resulting-client-id-test", + plugins: [ "resolve-all" ], + serviceWorker: "COMMIT_UNKNOWN" + }] + }) + }) +}) diff --git a/__tests__/test.json b/__tests__/test.json deleted file mode 100644 index 1d2b32c..0000000 --- a/__tests__/test.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "test": true -} diff --git a/plugins/alt-fetch/__tests__/browser.test.js b/plugins/alt-fetch/__tests__/browser.test.js new file mode 100644 index 0000000..97523fa --- /dev/null +++ b/plugins/alt-fetch/__tests__/browser.test.js @@ -0,0 +1,237 @@ +import { + describe, + it, + afterEach, + beforeEach +} from "https://deno.land/std@0.183.0/testing/bdd.ts"; + +import { + assert, + assertThrows, + assertRejects, + assertEquals +} from "https://deno.land/std@0.183.0/testing/asserts.ts"; + +import { + assertSpyCall, + assertSpyCalls, + spy, +} from "https://deno.land/std@0.183.0/testing/mock.ts"; + +beforeEach(()=>{ + window.fetch = spy(window.resolvingFetch) + window.init = { + name: 'alt-fetch', + endpoints: [ + 'https://alt.resilient.is/', + 'https://error.resilient.is/', + 'https://timeout.resilient.is/' + ]} +}) + +afterEach(()=>{ + window.fetch = null + window.init = null +}) + +describe('browser: alt-fetch plugin', async () => { + window.LibResilientPluginConstructors = new Map() + window.LR = { + log: (component, ...items)=>{ + console.debug(component + ' :: ', ...items) + } + } + window.resolvingFetch = (url, init) => { + return Promise.resolve( + new Response( + new Blob( + [JSON.stringify({ test: "success" })], + {type: "application/json"} + ), + { + status: 200, + statusText: "OK", + headers: { + 'ETag': 'TestingETagHeader' + } + } + ) + ) + } + window.fetch = null + window.init = null + await import("../../../plugins/alt-fetch/index.js"); + + it("should register in LibResilientPluginConstructors", async () => { + assertEquals( + LibResilientPluginConstructors + .get('alt-fetch')(LR, init) + .name, + 'alt-fetch'); + }); + + it("should fail with bad config", () => { + init = { + name: 'alt-fetch', + endpoints: "this is incorrect" + } + assertThrows( + ()=>{ + return LibResilientPluginConstructors.get('alt-fetch')(LR, init) + }, + Error, + 'endpoints not confgured' + ) + }); + + it("should fetch the content, trying all configured endpoints (if fewer or equal to concurrency setting)", async () => { + + const response = await LibResilientPluginConstructors.get('alt-fetch')(LR, init).fetch('https://resilient.is/test.json'); + + // default concurrency setting is 3 + assertSpyCalls(fetch, 3); + assertEquals(await response.json(), {test: "success"}) + }) + + it("should fetch the content, trying random endpoints out of all configured (if more than concurrency setting)", async () => { + + init = { + name: 'alt-fetch', + endpoints: [ + 'https://alt.resilient.is/', + 'https://error.resilient.is/', + 'https://timeout.resilient.is/', + 'https://alt2.resilient.is/', + 'https://alt3.resilient.is/', + 'https://alt4.resilient.is/' + ]} + + const response = await LibResilientPluginConstructors.get('alt-fetch')(LR, init).fetch('https://resilient.is/test.json'); + + // default concurrency setting is 3 + assertSpyCalls(fetch, 3); + assertEquals(await response.json(), {test: "success"}) + }) + + it("should fetch the content, trying all endpoints (if fewer than concurrency setting)", async () => { + + init = { + name: 'alt-fetch', + endpoints: [ + 'https://alt.resilient.is/', + 'https://error.resilient.is/' + ]} + + const response = await LibResilientPluginConstructors.get('alt-fetch')(LR, init).fetch('https://resilient.is/test.json'); + + // default concurrency setting is 3 + assertSpyCalls(fetch, 2); + assertSpyCall(fetch, 0, { + args: ['https://alt.resilient.is/test.json', {"cache": "reload"}] + }) + assertSpyCall(fetch, 1, { + args: ['https://error.resilient.is/test.json', {"cache": "reload"}] + }) + assertEquals(await response.json(), {test: "success"}) + }) + + it("should pass the Request() init data to fetch() for all used endpoints", async () => { + + var initTest = { + method: "GET", + headers: new Headers({"x-stub": "STUB"}), + mode: "mode-stub", + credentials: "credentials-stub", + cache: "cache-stub", + referrer: "referrer-stub", + redirect: "follow-stub", + integrity: "integrity-stub" + } + + const response = await LibResilientPluginConstructors.get('alt-fetch')(LR, init).fetch('https://resilient.is/test.json', initTest); + + // default concurrency setting is 3 + assertSpyCalls(fetch, 3); + + assertSpyCall(fetch, 0, { + args: ['https://alt.resilient.is/test.json', initTest] + }) + assertSpyCall(fetch, 1, { + args: ['https://error.resilient.is/test.json', initTest] + }) + assertSpyCall(fetch, 2, { + args: ['https://timeout.resilient.is/test.json', initTest] + }) + + assertEquals(await response.json(), {test: "success"}) + }) + + it("should set the LibResilient headers", async () => { + const response = await LibResilientPluginConstructors.get('alt-fetch')(LR, init).fetch('https://resilient.is/test.json'); + + // default concurrency setting is 3 + assertSpyCalls(fetch, 3); + assertEquals(await response.json(), {test: "success"}) + + assertEquals(response.headers.has('X-LibResilient-Method'), true) + assertEquals(response.headers.get('X-LibResilient-Method'), 'alt-fetch') + assertEquals(response.headers.has('X-LibResilient-Etag'), true) + assertEquals(response.headers.get('X-LibResilient-ETag'), 'TestingETagHeader') + }); + + it("should set the LibResilient ETag based on Last-Modified header (if ETag is not available in the original response)", async () => { + window.fetch = spy((url, init) => { + return Promise.resolve( + new Response( + new Blob( + [JSON.stringify({ test: "success" })], + {type: "application/json"} + ), + { + status: 200, + statusText: "OK", + headers: { + 'Last-Modified': 'TestingLastModifiedHeader' + } + } + ) + ) + }); + + const response = await LibResilientPluginConstructors.get('alt-fetch')(LR, init).fetch('https://resilient.is/test.json'); + + assertSpyCalls(fetch, 3); + assertEquals(await response.json(), {test: "success"}) + + assertEquals(response.headers.has('X-LibResilient-Method'), true) + assertEquals(response.headers.get('X-LibResilient-Method'), 'alt-fetch') + assertEquals(response.headers.has('X-LibResilient-Etag'), true) + assertEquals(response.headers.get('X-LibResilient-ETag'), 'TestingLastModifiedHeader') + }); + + it("should throw an error when HTTP status is >= 400", async () => { + window.fetch = spy((url, init) => { + return Promise.resolve( + new Response( + new Blob( + ["Not Found"], + {type: "text/plain"} + ), + { + status: 404, + statusText: "Not Found" + } + ) + ) + }); + + assertRejects( + async () => { + return await LibResilientPluginConstructors + .get('alt-fetch')(LR) + .fetch('https://resilient.is/test.json') }, + Error, + 'All promises were rejected' + ) + }); +}) diff --git a/plugins/any-of/__tests__/browser.test.js b/plugins/any-of/__tests__/browser.test.js new file mode 100644 index 0000000..1a9bb8d --- /dev/null +++ b/plugins/any-of/__tests__/browser.test.js @@ -0,0 +1,153 @@ +import { + describe, + it, + afterEach, + beforeEach +} from "https://deno.land/std@0.183.0/testing/bdd.ts"; + +import { + assert, + assertThrows, + assertRejects, + assertEquals +} from "https://deno.land/std@0.183.0/testing/asserts.ts"; + +import { + assertSpyCall, + assertSpyCalls, + spy, +} from "https://deno.land/std@0.183.0/testing/mock.ts"; + +beforeEach(()=>{ + window.fetch = spy(window.resolvingFetch) + window.init = { + name: 'any-of', + uses: [ + LibResilientPluginConstructors.get('fetch')(LR), + { + name: 'reject-all', + description: 'Rejects all', + version: '0.0.1', + fetch: url=>Promise.reject('Reject All!') + } + ] + } +}) + +afterEach(()=>{ + window.fetch = null + window.init = null +}) + +describe('browser: any-of plugin', async () => { + window.LibResilientPluginConstructors = new Map() + window.LR = { + log: (component, ...items)=>{ + console.debug(component + ' :: ', ...items) + } + } + window.resolvingFetch = (url, init) => { + return Promise.resolve( + new Response( + new Blob( + [JSON.stringify({ test: "success" })], + {type: "application/json"} + ), + { + status: 200, + statusText: "OK", + headers: { + 'ETag': 'TestingETagHeader' + } + } + ) + ) + } + window.fetch = null + window.init = null + await import("../../../plugins/any-of/index.js"); + // we need the fetch plugin in our testing + await import("../../../plugins/fetch/index.js"); + + it("should register in LibResilientPluginConstructors", async () => { + assertEquals( + LibResilientPluginConstructors + .get('any-of')(LR, init) + .name, + 'any-of'); + }); + + it("should throw an error when there aren't any wrapped plugins configured", async () => { + init = { + name: 'any-of', + uses: [] + } + + assertThrows(()=>{ + return LibResilientPluginConstructors + .get('any-of')(LR, init) + .fetch('https://resilient.is/test.json') + }, + Error, + 'No wrapped plugins configured!' + ) + }); + + it("should return data from a wrapped plugin", async () => { + + const response = await LibResilientPluginConstructors.get('any-of')(LR, init).fetch('https://resilient.is/test.json'); + + assertSpyCalls(fetch, 1) + assertEquals(await response.json(), {test: "success"}) + }); + + it("should pass Request() init data onto wrapped plugins", async () => { + + var initTest = { + method: "GET", + headers: new Headers({"x-stub": "STUB"}), + mode: "mode-stub", + credentials: "credentials-stub", + cache: "cache-stub", + referrer: "referrer-stub", + redirect: "follow-stub", + integrity: "integrity-stub" + } + + const response = await LibResilientPluginConstructors.get('any-of')(LR, init).fetch('https://resilient.is/test.json', initTest); + + assertSpyCalls(fetch, 1); + assertSpyCall(fetch, 0, { + args: ['https://resilient.is/test.json', initTest] + }) + assertEquals(await response.json(), {test: "success"}) + }); + + it("should throw an error when HTTP status is >= 400", async () => { + + window.fetch = spy((url, init) => { + return Promise.resolve( + new Response( + new Blob( + ["Not Found"], + {type: "text/plain"} + ), + { + status: 404, + statusText: "Not Found" + } + ) + ) + }); + + assertRejects( + async () => { + return await LibResilientPluginConstructors + .get('any-of')(LR, init) + .fetch('https://resilient.is/test.json') }, + AggregateError, + 'All promises were rejected' + ) + assertSpyCalls(fetch, 1); + }); +}) diff --git a/plugins/basic-integrity/__tests__/browser.test.js b/plugins/basic-integrity/__tests__/browser.test.js new file mode 100644 index 0000000..ca1c03e --- /dev/null +++ b/plugins/basic-integrity/__tests__/browser.test.js @@ -0,0 +1,238 @@ +import { + describe, + it, + afterEach, + beforeEach +} from "https://deno.land/std@0.183.0/testing/bdd.ts"; + +import { + assert, + assertThrows, + assertRejects, + assertEquals +} from "https://deno.land/std@0.183.0/testing/asserts.ts"; + +import { + assertSpyCall, + assertSpyCalls, + spy, +} from "https://deno.land/std@0.183.0/testing/mock.ts"; + +beforeEach(()=>{ + window.resolvingFetchSpy = spy(window.resolvingFetch) + window.init = { + name: 'basic-integrity', + uses: [ + { + name: 'resolve-all', + description: 'Resolves all', + version: '0.0.1', + fetch: window.resolvingFetchSpy + } + ], + integrity: { + "https://resilient.is/test.json": "sha384-kn5dhxz4RpBmx7xC7Dmq2N43PclV9U/niyh+4Km7oz5W0FaWdz3Op+3K0Qxz8y3z" + }, + requireIntegrity: true + } +}) + +afterEach(()=>{ + window.init = null + window.resolvingFetchSpy = null +}) + +describe('browser: basic-integrity plugin', async () => { + window.LibResilientPluginConstructors = new Map() + window.LR = { + log: (component, ...items)=>{ + console.debug(component + ' :: ', ...items) + } + } + window.resolvingFetch = (url, init) => { + return Promise.resolve( + new Response( + new Blob( + [JSON.stringify({ test: "success" })], + {type: "application/json"} + ), + { + status: 200, + statusText: "OK", + headers: { + 'ETag': 'TestingETagHeader' + } + } + ) + ) + } + window.resolvingFetchSpy = null + await import("../../../plugins/basic-integrity/index.js"); + + it("should register in LibResilientPluginConstructors", async () => { + assertEquals( + LibResilientPluginConstructors + .get('basic-integrity')(LR, init) + .name, + 'basic-integrity'); + }); + + it("should throw an error when there aren't any wrapped plugins configured", async () => { + init = { + name: 'basic-integrity', + uses: [] + } + assertThrows( () => { + return LibResilientPluginConstructors + .get('basic-integrity')(LR, init) + .fetch('https://resilient.is/test.json') + }, + Error, + 'Expected exactly one plugin to wrap' + ) + }); + + it("should throw an error when there are more than one wrapped plugins configured", async () => { + init = { + name: 'basic-integrity', + uses: [{ + name: 'plugin-1' + },{ + name: 'plugin-2' + }] + } + + assertThrows( () => { + return LibResilientPluginConstructors + .get('basic-integrity')(LR, init) + .fetch('https://resilient.is/test.json') + }, + Error, + 'Expected exactly one plugin to wrap' + ) + }); + + it("should return data from the wrapped plugin", async () => { + const response = await LibResilientPluginConstructors.get('basic-integrity')(LR, init).fetch('https://resilient.is/test.json'); + assertSpyCalls(resolvingFetchSpy, 1); + assertEquals(await response.json(), {test: "success"}) + }); + + it("should provide the wrapped plugin with integrity data for a configured URL", async () => { + const response = await LibResilientPluginConstructors.get('basic-integrity')(LR, init).fetch('https://resilient.is/test.json'); + assertSpyCall( + resolvingFetchSpy, + 0, + { + args: [ + 'https://resilient.is/test.json', + { + integrity: init.integrity['https://resilient.is/test.json'] + }] + }) + assertEquals(await response.json(), {test: "success"}) + }); + + it("should error out for an URL with no integrity data, when requireIntegrity is true", async () => { + assertThrows( () => { + return LibResilientPluginConstructors + .get('basic-integrity')(LR, init) + .fetch('https://resilient.is/test2.json') + }, + Error, + 'Integrity data required but not provided for' + ) + assertSpyCalls(resolvingFetchSpy, 0) + }); + + it("should return data from the wrapped plugin with no integrity data if requireIntegrity is false", async () => { + init.integrity = {} + init.requireIntegrity = false + + const response = await LibResilientPluginConstructors.get('basic-integrity')(LR, init).fetch('https://resilient.is/test.json'); + + assertSpyCalls(resolvingFetchSpy, 1) + assertSpyCall( + resolvingFetchSpy, + 0, + { + args: [ + 'https://resilient.is/test.json', + {} + ] + }) + assertEquals(await response.json(), {test: "success"}) + }); + + it("should return data from the wrapped plugin with no integrity data configured when requireIntegrity is true and integrity data is provided in Request() init data", async () => { + init.integrity = {} + + const response = await LibResilientPluginConstructors + .get('basic-integrity')(LR, init) + .fetch('https://resilient.is/test.json', { + integrity: "sha256-Aj9x0DWq9GUL1L8HibLCMa8YLKnV7IYAfpYurqrFwiQ=" + }); + + assertSpyCalls(resolvingFetchSpy, 1) + assertSpyCall( + resolvingFetchSpy, + 0, + { + args: [ + 'https://resilient.is/test.json', + { + integrity: "sha256-Aj9x0DWq9GUL1L8HibLCMa8YLKnV7IYAfpYurqrFwiQ=" + } + ] + }) + assertEquals(await response.json(), {test: "success"}) + }); + + it("should return data from the wrapped plugin with integrity data both configured and coming from Request() init", async () => { + const response = await LibResilientPluginConstructors + .get('basic-integrity')(LR, init) + .fetch('https://resilient.is/test.json', { + integrity: "sha256-Aj9x0DWq9GUL1L8HibLCMa8YLKnV7IYAfpYurqrFwiQ=" + }); + + assertSpyCalls(resolvingFetchSpy, 1) + assertSpyCall( + resolvingFetchSpy, + 0, + { + args: [ + 'https://resilient.is/test.json', + { + integrity: "sha256-Aj9x0DWq9GUL1L8HibLCMa8YLKnV7IYAfpYurqrFwiQ= sha384-kn5dhxz4RpBmx7xC7Dmq2N43PclV9U/niyh+4Km7oz5W0FaWdz3Op+3K0Qxz8y3z" + } + ] + }) + assertEquals(await response.json(), {test: "success"}) + }); + + // as per documentation, this plugin is not supposed to actualy *verify* integrity of fetched resources! + it("should return data from the wrapped plugin even with incorrect integrity data provided", async () => { + init.integrity = {} + + const response = await LibResilientPluginConstructors + .get('basic-integrity')(LR, init) + .fetch('https://resilient.is/test.json', { + integrity: "sha256-INCORRECTINCORRECTINCORRECTINCORRECTINCORRECT" + }); + + assertSpyCalls(resolvingFetchSpy, 1) + assertSpyCall( + resolvingFetchSpy, + 0, + { + args: [ + 'https://resilient.is/test.json', + { + integrity: "sha256-INCORRECTINCORRECTINCORRECTINCORRECTINCORRECT" + } + ] + }) + assertEquals(await response.json(), {test: "success"}) + }); + +}) diff --git a/plugins/basic-integrity/__tests__/cli.test.js b/plugins/basic-integrity/__tests__/cli.test.js new file mode 100644 index 0000000..656843c --- /dev/null +++ b/plugins/basic-integrity/__tests__/cli.test.js @@ -0,0 +1,95 @@ +import { + assert, + assertRejects, + assertEquals +} from "https://deno.land/std@0.167.0/testing/asserts.ts"; + +Deno.test("plugin loads", async () => { + const bi = await import('../cli.js') + assert("name" in bi) + assert(bi.name == "basic-integrity") + assert("description" in bi) + assert("actions" in bi) +}); + +Deno.test("get-integrity action defined", async () => { + const bi = await import('../cli.js') + assert("get-integrity" in bi.actions) + const gi = bi.actions["get-integrity"] + assert("run" in gi) + assert("description" in gi) + assert("arguments" in gi) + const gia = gi.arguments + assert("_" in gia) + assert("algorithm" in gia) + assert("output" in gia) + assert("name" in gia._) + assert("description" in gia._) + assert("description" in gia.algorithm) + assert("collect" in gia.algorithm) + assert(gia.algorithm.collect) + assert("string" in gia.algorithm) + assert(gia.algorithm.string) + assert("description" in gia.output) + assert("collect" in gia.output) + assert(!gia.output.collect) + assert("string" in gia.output) + assert(gia.output.string) +}); + +// this is a separate test in order to catch any changing defaults +Deno.test("get-integrity action defaults", async () => { + const bi = await import('../cli.js') + const gia = bi.actions["get-integrity"].arguments + assert("default" in gia.algorithm) + assert(gia.algorithm.default == "SHA-256") + assert("default" in gia.output) + assert(gia.output.default == "json") +}); + +Deno.test("get-integrity verifies arguments are sane", async () => { + const bi = await import('../cli.js') + const gi = bi.actions["get-integrity"] + assertRejects(gi.run, Error, "Expected non-empty list of files to generate digests of.") + assertRejects(async ()=>{ + await gi.run(['no-such-file']) + }, Error, "No such file or directory") + assertRejects(async ()=>{ + await gi.run(['irrelevant'], []) + }, Error, "Expected non-empty list of algorithms to use.") + assertRejects(async ()=>{ + await gi.run(['irrelevant'], ['SHA-384'], false) + }, Error, "Expected either 'json' or 'text' as output type to generate.") +}); + +Deno.test("get-integrity handles paths in a sane way", async () => { + const bi = await import('../cli.js') + const gi = bi.actions["get-integrity"] + const mh = import.meta.resolve('./mocks/hello.txt').replace(/^file:\/\//gi, "") + assertEquals(await gi.run(['./']), '{}') + assertEquals(await gi.run([mh]), '{"' + mh + '":["sha256-uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek="]}') + assertEquals(await gi.run(['./', mh]), '{"' + mh + '":["sha256-uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek="]}') +}, ); + +Deno.test("get-integrity handles algos argument in a sane way", async () => { + const bi = await import('../cli.js') + const gi = bi.actions["get-integrity"] + const mh = import.meta.resolve('./mocks/hello.txt').replace(/^file:\/\//gi, "") + assertRejects(async ()=>{ + await gi.run([mh], ['BAD-ALG']) + }, Error, 'Unrecognized algorithm name') + assertEquals(await gi.run([mh], ['SHA-256']), '{"' + mh + '":["sha256-uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek="]}') + assertEquals(await gi.run([mh], ['SHA-384']), '{"' + mh + '":["sha384-/b2OdaZ/KfcBpOBAOF4uI5hjA+oQI5IRr5B/y7g1eLPkF8txzmRu/QgZ3YwIjeG9"]}') + assertEquals(await gi.run([mh], ['SHA-512']), '{"' + mh + '":["sha512-MJ7MSJwS1utMxA9QyQLytNDtd+5RGnx6m808qG1M2G+YndNbxf9JlnDaNCVbRbDP2DDoH2Bdz33FVC6TrpzXbw=="]}') + assertEquals(await gi.run([mh], ['SHA-256', 'SHA-384', 'SHA-512']), '{"' + mh + '":["sha256-uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek=","sha384-/b2OdaZ/KfcBpOBAOF4uI5hjA+oQI5IRr5B/y7g1eLPkF8txzmRu/QgZ3YwIjeG9","sha512-MJ7MSJwS1utMxA9QyQLytNDtd+5RGnx6m808qG1M2G+YndNbxf9JlnDaNCVbRbDP2DDoH2Bdz33FVC6TrpzXbw=="]}') +}); + +Deno.test("get-integrity handles output argument in a sane way", async () => { + const bi = await import('../cli.js') + const gi = bi.actions["get-integrity"] + const mh = import.meta.resolve('./mocks/hello.txt').replace(/^file:\/\//gi, "") + assertEquals(await gi.run([mh], ['SHA-256'], 'text'), '' + mh + ': sha256-uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek=\n') + assertEquals(await gi.run([mh], ['SHA-384'], 'text'), '' + mh + ': sha384-/b2OdaZ/KfcBpOBAOF4uI5hjA+oQI5IRr5B/y7g1eLPkF8txzmRu/QgZ3YwIjeG9\n') + assertEquals(await gi.run([mh], ['SHA-512'], 'text'), '' + mh + ': sha512-MJ7MSJwS1utMxA9QyQLytNDtd+5RGnx6m808qG1M2G+YndNbxf9JlnDaNCVbRbDP2DDoH2Bdz33FVC6TrpzXbw==\n') + assertEquals(await gi.run([mh], ['SHA-256', 'SHA-384', 'SHA-512'], 'text'), '' + mh + ': sha256-uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek= sha384-/b2OdaZ/KfcBpOBAOF4uI5hjA+oQI5IRr5B/y7g1eLPkF8txzmRu/QgZ3YwIjeG9 sha512-MJ7MSJwS1utMxA9QyQLytNDtd+5RGnx6m808qG1M2G+YndNbxf9JlnDaNCVbRbDP2DDoH2Bdz33FVC6TrpzXbw==\n') +}); diff --git a/__denotests__/mocks/hello.txt b/plugins/basic-integrity/__tests__/mocks/hello.txt similarity index 100% rename from __denotests__/mocks/hello.txt rename to plugins/basic-integrity/__tests__/mocks/hello.txt diff --git a/plugins/cache/__tests__/browser.test.js b/plugins/cache/__tests__/browser.test.js new file mode 100644 index 0000000..11de212 --- /dev/null +++ b/plugins/cache/__tests__/browser.test.js @@ -0,0 +1,442 @@ +import { + describe, + it, + beforeEach, + beforeAll +} from "https://deno.land/std@0.183.0/testing/bdd.ts"; + +import { + assert, + assertThrows, + assertRejects, + assertEquals +} from "https://deno.land/std@0.183.0/testing/asserts.ts"; + +import { + assertSpyCall, + assertSpyCalls, + spy, +} from "https://deno.land/std@0.183.0/testing/mock.ts"; + +beforeAll(async ()=>{ + window.fetchResponse = [] + window.resolvingFetch = (url, init) => { + const response = new Response( + new Blob( + [JSON.stringify(window.fetchResponse[0])], + {type: window.fetchResponse[1]} + ), + { + status: 200, + statusText: "OK", + headers: { + 'Last-Modified': 'TestingLastModifiedHeader', + 'ETag': 'TestingETagHeader' + }, + url: url + }); + return Promise.resolve(response); + } + + /* + * prototype of the plugin init object + */ + window.initPrototype = { + name: 'cache' + } +}) + +/** + * we need to do all of this before each test in order to reset the fetch() use counter + * and make sure window.init is clean and not modified by previous tests + */ +beforeEach(async ()=>{ + window.fetch = spy(window.resolvingFetch) + window.fetchResponse = [ + {test: "success"}, + "application/json" + ] + window.init = { + ...window.initPrototype + } + // clear the caches + await caches + .has('v1') + .then(async (hasv1) => { + if (hasv1) { + await caches.delete('v1') + } + }) +}) + +describe('browser: cache plugin', async () => { + window.LibResilientPluginConstructors = new Map() + window.LR = { + log: (component, ...items)=>{ + console.debug(component + ' :: ', ...items) + } + } + + window.fetchResponse = [] + window.resolvingFetch = null + window.fetch = null + + await import("../../../plugins/cache/index.js"); + + it("should register in LibResilientPluginConstructors", () => { + assertEquals( + LibResilientPluginConstructors + .get('cache')(LR, init) + .name, + 'cache' + ); + }); + + it("should error out if resource is not found in cache", async () => { + // nothing got stashed so nothing is there to be retrieved + await assertRejects( + ()=>{ + return LibResilientPluginConstructors + .get('cache')(LR) + .fetch('https://resilient.is/test.json') + }, + Error, + 'Resource not found in cache' + ) + }); + + it("should stash and retrieve an url successfully", async () => { + + let cachePlugin = LibResilientPluginConstructors.get('cache')(LR) + + // stash + let stashResult = await cachePlugin + .stash('https://resilient.is/test.json') + assertEquals(stashResult, undefined, "stashResult should be undefined"); + + // retrieve and verify + let fetchResult = await cachePlugin + .fetch('https://resilient.is/test.json') + + assert(fetchResult.ok, "fetchResult.ok is false") + assertEquals(fetchResult.status, 200, "fetchResult.status is not 200") + assertEquals(fetchResult.statusText, 'OK', "fetchResult.statusText is not 'OK'") + + assert(fetchResult.headers.has('etag'), "fetchResult.headers does not contain 'ETag'") + assertEquals(fetchResult.headers.get('ETag'), 'TestingETagHeader') + + let fetchedJSON = await fetchResult.json() + assertEquals( + fetchedJSON, + { test: "success" }, + "fetchedJSON is incorrect: " + JSON.stringify(fetchedJSON)) + }); + + it("should clear a url from cache successfully", async () => { + + let cachePlugin = LibResilientPluginConstructors.get('cache')(LR) + + // stash + let stashResult = await cachePlugin + .stash('https://resilient.is/test.json') + assertEquals(stashResult, undefined, "stashResult should be undefined"); + + // unstash + let unstashResult = await cachePlugin + .unstash('https://resilient.is/test.json') + assertEquals(unstashResult, true, "unstashResult should be true") + + // verify + await assertRejects( + ()=>{ + return LibResilientPluginConstructors + .get('cache')(LR) + .fetch('https://resilient.is/test.json') + }, + Error, + 'Resource not found in cache' + ) + }); + + it("should stash an array of urls successfully", async () => { + + let cachePlugin = LibResilientPluginConstructors.get('cache')(LR) + let urls = [ + 'https://resilient.is/test.json', + 'https://resilient.is/test2.json' + ] + + // stash + let stashResult = await cachePlugin.stash(urls) + assertEquals( + stashResult, + undefined, + "stashResult should be undefined"); + + // retrieve and verify + await Promise.all( + urls.map(async (url)=>{ + let fetchResult = await cachePlugin.fetch(url) + assert(fetchResult.ok, "fetchResult.ok is false") + assertEquals(fetchResult.status, 200, "fetchResult.status is not 200") + assertEquals(fetchResult.statusText, 'OK', "fetchResult.statusText is not 'OK'") + assert(fetchResult.headers.has('etag'), "fetchResult.headers does not contain 'ETag'") + assertEquals(fetchResult.headers.get('ETag'), 'TestingETagHeader') + + let fetchedJSON = await fetchResult.json() + assertEquals( + fetchedJSON, + { test: "success" }, + "fetchedJSON is incorrect: " + JSON.stringify(fetchedJSON)) + }) + ) + }); + + it("should clear an array of urls successfully", async () => { + + var cachePlugin = LibResilientPluginConstructors.get('cache')(LR) + let urls = [ + 'https://resilient.is/test.json', + 'https://resilient.is/test2.json' + ] + + // stash + let stashResult = await cachePlugin.stash(urls) + assertEquals( + stashResult, + undefined, + "stashResult should be undefined"); + + // unstash + let unstashResult = await cachePlugin + .unstash(urls) + assertEquals(unstashResult, [true, true], "unstashResult should be [true, true]") + + // verify + await Promise.all( + urls.map(async (url)=>{ + await assertRejects( + ()=>{ + return LibResilientPluginConstructors + .get('cache')(LR) + .fetch(url) + }, + Error, + 'Resource not found in cache', + 'url should not have been in cache: ' + url + ) + }) + ) + }); + + it("should error out when stashing a Response without a url/key", async () => { + + const response = new Response( + new Blob( + [JSON.stringify({ test: "success" })], + {type: "application/json"} + ), + { + status: 200, + statusText: "OK", + headers: { + 'ETag': 'TestingETagHeader' + } + }); + + await assertRejects( + ()=>{ + return LibResilientPluginConstructors + .get('cache')(LR) + .stash(response) + }, + Error, + 'No URL to work with!' + ) + + }); + + it("should stash a Response successfully", async () => { + + const response = new Response( + new Blob( + [JSON.stringify({ test: "success" })], + {type: "application/json"} + ), + { + status: 200, + statusText: "OK", + headers: { + 'ETag': 'TestingETagHeader' + } + }); + // Response.url is read-only, so we need to hack around that + Object.defineProperty( + response, + "url", + { value: 'https://resilient.is/test.json' }); + + var cachePlugin = LibResilientPluginConstructors.get('cache')(LR) + + let stashResult = await cachePlugin.stash(response) + assertEquals( + stashResult, + undefined, + "stashResult should be undefined"); + + // retrieve and verify + let fetchResult = await cachePlugin + .fetch('https://resilient.is/test.json') + + assert(fetchResult.ok, "fetchResult.ok is false") + assertEquals(fetchResult.status, 200, "fetchResult.status is not 200") + assertEquals(fetchResult.statusText, 'OK', "fetchResult.statusText is not 'OK'") + // assertEquals(fetchResult.url, 'https://resilient.is/test.json', "fetchResult.url is not correct") TODO + + assert(fetchResult.headers.has('etag'), "fetchResult.headers does not contain 'ETag'") + assertEquals(fetchResult.headers.get('ETag'), 'TestingETagHeader') + + let fetchedJSON = await fetchResult.json() + assertEquals( + fetchedJSON, + { test: "success" }, + "fetchedJSON is incorrect: " + JSON.stringify(fetchedJSON)) + }); + + it("should stash a Response with an explicit key successfully", async () => { + + const response = new Response( + new Blob( + [JSON.stringify({ test: "success" })], + {type: "application/json"} + ), + { + status: 200, + statusText: "OK", + headers: { + 'ETag': 'TestingETagHeader' + } + }); + // Response.url is read-only, so we need to hack around that + Object.defineProperty( + response, + "url", + { value: 'https://resilient.is/test.json' }); + + var cachePlugin = LibResilientPluginConstructors.get('cache')(LR) + + let stashResult = await cachePlugin + .stash(response, 'https://example.com/special-key') + assertEquals( + stashResult, + undefined, + "stashResult should be undefined"); + + // retrieve and verify + let fetchResult = await cachePlugin + .fetch('https://example.com/special-key') + + assert(fetchResult.ok, "fetchResult.ok is false") + assertEquals(fetchResult.status, 200, "fetchResult.status is not 200") + assertEquals(fetchResult.statusText, 'OK', "fetchResult.statusText is not 'OK'") + // assertEquals(fetchResult.url, 'https://resilient.is/test.json', "fetchResult.url is not correct") TODO + + assert(fetchResult.headers.has('etag'), "fetchResult.headers does not contain 'ETag'") + assertEquals(fetchResult.headers.get('ETag'), 'TestingETagHeader') + + let fetchedJSON = await fetchResult.json() + assertEquals( + fetchedJSON, + { test: "success" }, + "fetchedJSON is incorrect: " + JSON.stringify(fetchedJSON)) + }); + + it("it should stash a Response with no url set but with an explicit key successfully", async () => { + + const response = new Response( + new Blob( + [JSON.stringify({ test: "success" })], + {type: "application/json"} + ), + { + status: 200, + statusText: "OK", + headers: { + 'ETag': 'TestingETagHeader' + } + }); + + var cachePlugin = LibResilientPluginConstructors.get('cache')(LR) + + let stashResult = await cachePlugin + .stash(response, 'https://example.com/special-key') + assertEquals( + stashResult, + undefined, + "stashResult should be undefined"); + + // retrieve and verify + let fetchResult = await cachePlugin + .fetch('https://example.com/special-key') + + assert(fetchResult.ok, "fetchResult.ok is false") + assertEquals(fetchResult.status, 200, "fetchResult.status is not 200") + assertEquals(fetchResult.statusText, 'OK', "fetchResult.statusText is not 'OK'") + // assertEquals(fetchResult.url, 'https://resilient.is/test.json', "fetchResult.url is not correct") TODO + + assert(fetchResult.headers.has('etag'), "fetchResult.headers does not contain 'ETag'") + assertEquals(fetchResult.headers.get('ETag'), 'TestingETagHeader') + + let fetchedJSON = await fetchResult.json() + assertEquals( + fetchedJSON, + { test: "success" }, + "fetchedJSON is incorrect: " + JSON.stringify(fetchedJSON)) + }); + + it("should clear a Response successfully", async () => { + + const response = new Response( + new Blob( + [JSON.stringify({ test: "success" })], + {type: "application/json"} + ), + { + status: 200, + statusText: "OK", + headers: { + 'ETag': 'TestingETagHeader' + } + }); + // Response.url is read-only, so we need to hack around that + Object.defineProperty( + response, + "url", + { value: 'https://resilient.is/test.json' }); + + var cachePlugin = LibResilientPluginConstructors.get('cache')(LR) + + let stashResult = await cachePlugin + .stash(response) + assertEquals( + stashResult, + undefined, + "stashResult should be undefined"); + + // unstash + let unstashResult = await cachePlugin + .unstash('https://resilient.is/test.json') + assertEquals(unstashResult, true, "unstashResult should be true") + + // verify + await assertRejects( + ()=>{ + return LibResilientPluginConstructors + .get('cache')(LR) + .fetch('https://resilient.is/test.json') + }, + Error, + 'Resource not found in cache' + ) + }); +}) diff --git a/plugins/cache/index.js b/plugins/cache/index.js index 80b1154..1af09ba 100644 --- a/plugins/cache/index.js +++ b/plugins/cache/index.js @@ -54,6 +54,36 @@ let cacheContent = (resource, key) => { return caches.open('v1') .then((cache) => { + + // polyfill cache.add() + // needed for tests and CLI, until Deno implements it natively + if (!("add" in cache)) { + cache.add = (url) => { + LR.log(pluginName, 'hit cache.add polyfill') + var result = fetch(url).then((response) => { + if (!response.ok) { + throw new Error("bad response!"); + } + return cache.put(url, response); + }); + return result + } + } + + // polyfill cache.addAll() + // needed for tests and CLI, until Deno implements it natively + if (!("addAll" in cache)) { + cache.addAll = async (urls)=>{ + LR.log(pluginName, 'hit cache.addAll polyfill') + await Promise.all( + urls.map((url)=>{ + return cache.add(url) + }) + ) + return Promise.resolve(undefined) + } + } + if (typeof resource === 'string') { // assume URL LR.log(pluginName, "caching an URL: " + resource) diff --git a/plugins/dnslink-fetch/__tests__/browser.test.js b/plugins/dnslink-fetch/__tests__/browser.test.js new file mode 100644 index 0000000..f0f00d8 --- /dev/null +++ b/plugins/dnslink-fetch/__tests__/browser.test.js @@ -0,0 +1,516 @@ +import { + describe, + it, + beforeEach, + beforeAll +} from "https://deno.land/std@0.183.0/testing/bdd.ts"; + +import { + assert, + assertThrows, + assertRejects, + assertEquals +} from "https://deno.land/std@0.183.0/testing/asserts.ts"; + +import { + assertSpyCall, + assertSpyCalls, + spy, +} from "https://deno.land/std@0.183.0/testing/mock.ts"; + +beforeAll(async ()=>{ + window.fetchResponse = [] + window.resolvingFetch = (url, init) => { + const response = new Response( + new Blob( + [JSON.stringify(window.fetchResponse[0])], + {type: window.fetchResponse[1]} + ), + { + status: 200, + statusText: "OK", + headers: { + 'Last-Modified': 'TestingLastModifiedHeader' + }, + url: url + }); + return Promise.resolve(response); + } + + /* + * prototype of the plugin init object + */ + window.initPrototype = { + name: 'dnslink-fetch' + } +}) + +/** + * we need to do all of this before each test in order to reset the fetch() use counter + * and make sure window.init is clean and not modified by previous tests + */ +beforeEach(()=>{ + window.fetch = spy(window.resolvingFetch) + window.fetchResponse = [ + {test: "success"}, + "application/json" + ] + window.init = { + ...window.initPrototype + } +}) + +describe('browser: dnslink-fetch plugin', async () => { + window.LibResilientPluginConstructors = new Map() + window.LR = { + log: (component, ...items)=>{ + console.debug(component + ' :: ', ...items) + } + } + + window.fetchResponse = [] + window.resolvingFetch = null + window.fetch = null + + await import("../../../plugins/dnslink-fetch/index.js"); + + it("should register in LibResilientPluginConstructors", () => { + assertEquals( + LibResilientPluginConstructors.get('dnslink-fetch')(LR, init).name, + 'dnslink-fetch' + ); + }); + + it("should fail with bad config", () => { + init = { + name: 'dnslink-fetch', + dohProvider: false + } + assertThrows( + ()=>{ + return LibResilientPluginConstructors.get('dnslink-fetch')(LR, init) + }, + Error, + 'dohProvider not confgured' + ) + }); + + it("should perform a fetch against the default dohProvider endpoint, with default ECS settings", async () => { + + // this will fail after the fetch() is done + // but we only care about the fetch() being done in this test + try { + await LibResilientPluginConstructors + .get('dnslink-fetch')(LR, init) + .fetch('https://resilient.is/test.json'); + } catch (e) {} + + assertSpyCall( + fetch, + 0, + { + args: [ + "https://dns.hostux.net/dns-query?name=_dnslink.resilient.is&type=TXT&edns_client_subnet=0.0.0.0/0", + {"headers": {"accept": "application/json"}} + ] + }) + }) + + it("should perform a fetch against the configured dohProvider endpoint, with configured ECS settings", async () => { + + init.dohProvider = 'https://doh.example.org/resolve-example' + init.ecsMasked = false + + // this will fail after the fetch() is done + // but we only care about the fetch() being done in this test + try { + await LibResilientPluginConstructors + .get('dnslink-fetch')(LR, init) + .fetch('https://resilient.is/test.json'); + } catch(e) {} + + assertSpyCall( + fetch, + 0, + { + args: [ + "https://doh.example.org/resolve-example?name=_dnslink.resilient.is&type=TXT", + {"headers": {"accept": "application/json"}} + ] + }) + }) + + it("should throw an error if the DoH response is not a valid JSON", async () => { + + window.fetchResponse = ["not-json", "text/plain"] + + assertRejects( + async ()=>{ + return await LibResilientPluginConstructors + .get('dnslink-fetch')(LR, init) + .fetch('https://resilient.is/test.json'); + }, + Error, + 'Response is not a valid JSON' + ) + }) + + it("should throw an error if the DoH response is does not have a Status field", async () => { + + + window.fetchResponse = [{test: "success"}, "application/json"] + + assertRejects( + async ()=>{ + return await LibResilientPluginConstructors + .get('dnslink-fetch')(LR, init) + .fetch('https://resilient.is/test.json'); + }, + Error, + 'DNS request failure, status code: undefined' + ) + }) + + it("should throw an error if the DoH response has Status other than 0", async () => { + + window.fetchResponse = [{Status: 999}, "application/json"] + + assertRejects( + async ()=>{ + return await LibResilientPluginConstructors + .get('dnslink-fetch')(LR, init) + .fetch('https://resilient.is/test.json'); + }, + Error, + 'DNS request failure, status code: 999' + ) + }) + + it("should throw an error if the DoH response does not have an Answer field", async () => { + + window.fetchResponse = [{Status: 0}, "application/json"] + + assertRejects( + async ()=>{ + return await LibResilientPluginConstructors + .get('dnslink-fetch')(LR, init) + .fetch('https://resilient.is/test.json'); + }, + Error, + 'DNS response did not contain a valid Answer section' + ) + }) + + it("should throw an error if the DoH response's Answer field is not an object", async () => { + + window.fetchResponse = [{Status: 0, Answer: 'invalid'}, "application/json"] + + assertRejects( + async ()=>{ + return await LibResilientPluginConstructors + .get('dnslink-fetch')(LR, init) + .fetch('https://resilient.is/test.json'); + }, + Error, + 'DNS response did not contain a valid Answer section' + ) + }) + + it("should throw an error if the DoH response's Answer field is not an Array", async () => { + + window.fetchResponse = [{Status: 0, Answer: {}}, "application/json"] + + assertRejects( + async ()=>{ + return await LibResilientPluginConstructors + .get('dnslink-fetch')(LR, init) + .fetch('https://resilient.is/test.json'); + }, + Error, + 'DNS response did not contain a valid Answer section' + ) + }) + + it("should throw an error if the DoH response's Answer field does not contain TXT records", async () => { + + window.fetchResponse = [{Status: 0, Answer: ['aaa', 'bbb']}, "application/json"] + + assertRejects( + async ()=>{ + return await LibResilientPluginConstructors + .get('dnslink-fetch')(LR, init) + .fetch('https://resilient.is/test.json'); + }, + Error, + 'Answer section of the DNS response did not contain any TXT records' + ) + }) + + it("should throw an error if the DoH response's Answer elements do not contain valid endpoint data", async () => { + + window.fetchResponse = [{Status: 0, Answer: [{type: 16}, {type: 16}]}, "application/json"] + + assertRejects( + async ()=>{ + return await LibResilientPluginConstructors + .get('dnslink-fetch')(LR, init) + .fetch('https://resilient.is/test.json'); + }, + Error, + 'No TXT record contained http or https endpoint definition' + ) + }) + + it("should throw an error if the DoH response's Answer elements do not contain valid endpoints", async () => { + + window.fetchResponse = [{Status: 0, Answer: [{type: 16, data: 'aaa'}, {type: 16, data: 'bbb'}]}, "application/json"] + + assertRejects( + async ()=>{ + return await LibResilientPluginConstructors + .get('dnslink-fetch')(LR, init) + .fetch('https://resilient.is/test.json'); + }, + Error, + 'No TXT record contained http or https endpoint definition' + ) + }) + + it("should successfully resolve if the DoH response contains endpoint data", async () => { + + window.fetchResponse = [ + {Status: 0, Answer: [ + {type: 16, data: 'dnslink=/https/example.org'}, + {type: 16, data: 'dnslink=/http/example.net/some/path'} + ]}, + "application/json" + ] + + // this might fail after the fetch() is done + // but we only care about the fetch() being done in this test + try { + await LibResilientPluginConstructors + .get('dnslink-fetch')(LR, init) + .fetch('https://resilient.is/test.json'); + } catch(e) {} + + // 1 fetch to resolve DNSLink, + // then 2 fetch requests to the two DNSLink-resolved endpoints + assertSpyCalls(fetch, 3) + assertSpyCall( + fetch, + 1, + { + args: [ + "https://example.org/test.json", + {cache: 'reload'} + ] + }) + assertSpyCall( + fetch, + 2, + { + args: [ + "http://example.net/some/path/test.json", + {cache: 'reload'} + ] + }) + }) + + it("should fetch the content, trying all DNSLink-resolved endpoints (if fewer or equal to concurrency setting)", async () => { + + window.fetchResponse = [ + {Status: 0, Answer: [ + {type: 16, data: 'dnslink=/https/example.org'}, + {type: 16, data: 'dnslink=/http/example.net/some/path'} + ]}, + "application/json" + ] + const response = await LibResilientPluginConstructors.get('dnslink-fetch')(LR, init).fetch('https://resilient.is/test.json'); + + // 1 fetch to resolve DNSLink, + // then 2 fetch requests to the two DNSLink-resolved endpoints + assertSpyCalls(fetch, 3) + assertEquals(await response.json(), window.fetchResponse[0]) + assertSpyCall( + fetch, + 1, + { + args: [ + "https://example.org/test.json", + {cache: 'reload'} + ] + }) + assertSpyCall( + fetch, + 2, + { + args: [ + "http://example.net/some/path/test.json", + {cache: 'reload'} + ] + }) + }) + + it("should fetch the content, trying random endpoints out of all DNSLink-resolved endpoints (if more than concurrency setting)", async () => { + + init.concurrency = 3 + + window.fetchResponse = [ + {Status: 0, Answer: [ + {type: 16, data: 'dnslink=/https/example.org'}, + {type: 16, data: 'dnslink=/http/example.net/some/path'}, + {type: 16, data: 'dnslink=/https/example.net/some/path'}, + {type: 16, data: 'dnslink=/https/example.net/some/other/path'} + ]}, + "application/json" + ] + const response = await LibResilientPluginConstructors + .get('dnslink-fetch')(LR, init) + .fetch('https://resilient.is/test.json'); + + // 1 fetch to resolve DNSLink, + // then 3 fetch requests to random three of the five DNSLink-resolved endpoints + assertSpyCalls(fetch, 4) + assertEquals(await response.json(), window.fetchResponse[0]) + }) + + it("should pass the Request() init data to fetch() for all used endpoints", async () => { + + var initTest = { + method: "GET", + headers: new Headers({"x-stub": "STUB"}), + mode: "mode-stub", + credentials: "credentials-stub", + cache: "cache-stub", + referrer: "referrer-stub", + // these are not implemented by service-worker-mock + // https://github.com/zackargyle/service-workers/blob/master/packages/service-worker-mock/models/Request.js#L20 + redirect: undefined, + integrity: undefined, + cache: undefined + } + + window.fetchResponse = [ + {Status: 0, Answer: [ + {type: 16, data: 'dnslink=/https/example.org'}, + {type: 16, data: 'dnslink=/http/example.net/some/path'}, + {type: 16, data: 'dnslink=/https/example.net/some/path'} + ]}, + "application/json" + ] + + const response = await LibResilientPluginConstructors + .get('dnslink-fetch')(LR, init) + .fetch('https://resilient.is/test.json', initTest); + + // 1 fetch to resolve DNSLink, + // then 3 fetch requests to the three DNSLink-resolved endpoints + assertSpyCalls(fetch, 4) + assertEquals(await response.json(), window.fetchResponse[0]) + + assertSpyCall( + fetch, + 1, + { + args: [ + "https://example.org/test.json", + initTest + ] + }) + assertSpyCall( + fetch, + 2, + { + args: [ + "http://example.net/some/path/test.json", + initTest + ] + }) + assertSpyCall( + fetch, + 3, + { + args: [ + "https://example.net/some/path/test.json", + initTest + ] + }) + }) + + + it("should set the LibResilient headers, setting X-LibResilient-ETag based on Last-Modified (if ETag is unavailable in the original response)", async () => { + + window.fetchResponse = [ + {Status: 0, Answer: [ + {type: 16, data: 'dnslink=/https/example.org'}, + {type: 16, data: 'dnslink=/http/example.net/some/path'} + ]}, + "application/json"] + + const response = await LibResilientPluginConstructors.get('dnslink-fetch')(LR, init).fetch('https://resilient.is/test.json'); + + // 1 fetch to resolve DNSLink, + // then 3 fetch requests to the three DNSLink-resolved endpoints + assertSpyCalls(fetch, 3) + assertEquals(await response.json(), window.fetchResponse[0]) + assert(response.headers.has('X-LibResilient-Method')) + assert(response.headers.has('X-LibResilient-Etag')) + assertEquals(response.headers.get('X-LibResilient-Method'), 'dnslink-fetch') + assertEquals(response.headers.get('X-LibResilient-Etag'), 'TestingLastModifiedHeader') + }); + + it("should throw an error when HTTP status is >= 400", async () => { + + window.resolvingFetch = (url, init) => { + if (url.startsWith('https://dns.hostux.net/dns-query')) { + const response = new Response( + new Blob( + [JSON.stringify(fetchResponse[0])], + {type: fetchResponse[1]} + ), + { + status: 200, + statusText: "OK", + headers: { + 'Last-Modified': 'TestingLastModifiedHeader' + }, + url: url + }); + return Promise.resolve(response); + } else { + const response = new Response( + new Blob( + ["Not Found"], + {type: "text/plain"} + ), + { + status: 404, + statusText: "Not Found", + url: url + }); + return Promise.resolve(response); + } + } + window.fetch = spy(window.resolvingFetch) + + window.fetchResponse = [ + {Status: 0, Answer: [ + {type: 16, data: 'dnslink=/https/example.org'}, + {type: 16, data: 'dnslink=/http/example.net/some/path'} + ]}, + "application/json" + ] + + assertRejects( + async ()=>{ + const response = await LibResilientPluginConstructors + .get('dnslink-fetch')(LR, init) + .fetch('https://resilient.is/test.json') + console.log(response) + }, + Error, + 'HTTP Error:' + ) + }); +}) diff --git a/plugins/dnslink-fetch/index.js b/plugins/dnslink-fetch/index.js index ee891d3..5f5e79c 100644 --- a/plugins/dnslink-fetch/index.js +++ b/plugins/dnslink-fetch/index.js @@ -140,7 +140,7 @@ // remove the https://original.domain/ bit to get the relative path // TODO: this assumes that URLs we handle are always relative to the root // TODO: of the original domain, this needs to be documented - urlData = url.replace(/https?:\/\//, '').split('/') + var urlData = url.replace(/https?:\/\//, '').split('/') var domain = urlData.shift() var path = urlData.join('/') LR.log(pluginName, '+-- fetching:\n', diff --git a/plugins/dnslink-ipfs/__tests__/browser.test.js b/plugins/dnslink-ipfs/__tests__/browser.test.js new file mode 100644 index 0000000..19e0650 --- /dev/null +++ b/plugins/dnslink-ipfs/__tests__/browser.test.js @@ -0,0 +1,255 @@ +import { + describe, + it, + beforeEach, + beforeAll +} from "https://deno.land/std@0.183.0/testing/bdd.ts"; + +import { + assert, + assertThrows, + assertRejects, + assertEquals +} from "https://deno.land/std@0.183.0/testing/asserts.ts"; + +import { + assertSpyCall, + assertSpyCalls, + spy, +} from "https://deno.land/std@0.183.0/testing/mock.ts"; + +beforeAll(async ()=>{ + window.fetchResponse = [] + window.resolvingFetch = (url, init) => { + const response = new Response( + new Blob( + [JSON.stringify(window.fetchResponse[0])], + {type: window.fetchResponse[1]} + ), + { + status: 200, + statusText: "OK", + headers: { + 'Last-Modified': 'TestingLastModifiedHeader' + }, + url: url + }); + return Promise.resolve(response); + } + + /* + * prototype of the plugin init object + */ + window.initPrototype = { + name: 'dnslink-ipfs' + } + + window.ipfsPrototype = { + ipfsFixtureAddress: 'QmiPFSiPFSiPFSiPFSiPFSiPFSiPFSiPFSiPFSiPFSiPFS', + create: ()=>{ + var sourceUsed = false + return Promise.resolve({ + cat: (path)=>{ + return { + next: ()=>{ + if (path.endsWith('nonexistent.path')) { + throw new Error('Error: file does not exist') + } + let prevSourceUsed = sourceUsed + sourceUsed = true + var val = undefined + if (!prevSourceUsed) { + var val = Uint8Array.from( + Array + .from(JSON.stringify({ + test: "success", + path: path + })) + .map( + letter => letter.charCodeAt(0) + ) + ) + } + return Promise.resolve({ + done: prevSourceUsed, + value: val + }) + } + } + }, + name: { + resolve: (path)=>{ + var result = path.replace( + '/ipns/resilient.is', + '/ipfs/' + Ipfs.ipfsFixtureAddress + ) + return { + next: ()=> { + return Promise.resolve({ + done: false, + value: result + }) + } + } + } + } + }) + } + } +}) + +/** + * we need to do all of this before each test in order to reset the fetch() use counter + * and make sure window.init is clean and not modified by previous tests + */ +beforeEach(()=>{ + window.fetch = spy(window.resolvingFetch) + window.fetchResponse = [ + {test: "success"}, + "application/json" + ] + window.init = { + ...window.initPrototype + } + window.Ipfs = { + ...window.ipfsPrototype + } + self.Ipfs = window.Ipfs +}) + +describe('browser: dnslink-ipfs plugin', async () => { + window.LibResilientPluginConstructors = new Map() + window.LR = { + log: (component, ...items)=>{ + console.debug(component + ' :: ', ...items) + } + } + window.fetchResponse = [] + window.resolvingFetch = null + window.fetch = null + window.Ipfs = null + + await import("../../../plugins/dnslink-ipfs/index.js"); + + it("should register in LibResilientPluginConstructors", () => { + assertEquals( + LibResilientPluginConstructors.get('dnslink-ipfs')(LR, init).name, + 'dnslink-ipfs' + ); + }); + + it("should initiate IPFS setup", async ()=>{ + self.importScripts = spy(()=>{}) + try { + await LibResilientPluginConstructors.get('dnslink-ipfs')(LR, init).fetch('/test.json') + } catch {} + + assertSpyCall( + importScripts, + 0, + { + args: ['./lib/ipfs.js'] + }) + }) + + it("should error out when fetching unpublished content", async ()=>{ + assertRejects( + async ()=>{ + return await LibResilientPluginConstructors + .get('dnslink-ipfs')(LR, init) + .fetch('https://resilient.is/nonexistent.path') + }, + Error, + 'Error: file does not exist' + ) + }) + + // TODO: probably not necessary in the long run? + it("should fetch /index.html instead of a path ending in /", async ()=>{ + var response = await LibResilientPluginConstructors + .get('dnslink-ipfs')(LR, init) + .fetch('https://resilient.is/test/') + assertEquals( + await response.json(), + { + test: "success", + path: "/ipfs/" + window.Ipfs.ipfsFixtureAddress + '/test/index.html' + }) + }) + + it("should correctly guess content types when fetching", async ()=>{ + + let dnslinkIpfsPlugin = LibResilientPluginConstructors.get('dnslink-ipfs')(LR, init) + let response = await dnslinkIpfsPlugin.fetch('https://resilient.is/test/') + assertEquals(response.headers.get("content-type"), 'text/html') + + dnslinkIpfsPlugin = LibResilientPluginConstructors.get('dnslink-ipfs')(LR, init) + response = await dnslinkIpfsPlugin.fetch('https://resilient.is/test.htm') + assertEquals(response.headers.get("content-type"), 'text/html') + + dnslinkIpfsPlugin = LibResilientPluginConstructors.get('dnslink-ipfs')(LR, init) + response = await dnslinkIpfsPlugin.fetch('https://resilient.is/test.css') + assertEquals(response.headers.get("content-type"), 'text/css') + + dnslinkIpfsPlugin = LibResilientPluginConstructors.get('dnslink-ipfs')(LR, init) + response = await dnslinkIpfsPlugin.fetch('https://resilient.is/test.js') + assertEquals(response.headers.get("content-type"), 'text/javascript') + + dnslinkIpfsPlugin = LibResilientPluginConstructors.get('dnslink-ipfs')(LR, init) + response = await dnslinkIpfsPlugin.fetch('https://resilient.is/test.json') + assertEquals(response.headers.get("content-type"), 'application/json') + + dnslinkIpfsPlugin = LibResilientPluginConstructors.get('dnslink-ipfs')(LR, init) + response = await dnslinkIpfsPlugin.fetch('https://resilient.is/test.svg') + assertEquals(response.headers.get("content-type"), 'image/svg+xml') + + dnslinkIpfsPlugin = LibResilientPluginConstructors.get('dnslink-ipfs')(LR, init) + response = await dnslinkIpfsPlugin.fetch('https://resilient.is/test.ico') + assertEquals(response.headers.get("content-type"), 'image/x-icon') + }) + + it("should fetch content", async ()=>{ + let response = await LibResilientPluginConstructors + .get('dnslink-ipfs')(LR, init) + .fetch('https://resilient.is/test.json') + + assertEquals(response.headers.get("content-type"), 'application/json') + assertEquals( + await response.json(), + { + test: "success", + path: "/ipfs/" + window.Ipfs.ipfsFixtureAddress + '/test.json'}) + }) + + it("should throw an error on publish()", async ()=>{ + assertThrows( + ()=>{ + LibResilientPluginConstructors.get('dnslink-ipfs')(LR, init).publish() + }, + Error, + "Not implemented yet." + ) + }) + + it("should handle IPFS load error correctly", async ()=>{ + + window.Ipfs.create = ()=>{ + throw new Error('Testing IPFS loading failure') + } + + assertRejects( + ()=>{ + return LibResilientPluginConstructors + .get('dnslink-ipfs')(LR, init) + .fetch('/test.json') + }, + Error, + "Error: Testing IPFS loading failure" + ) + }) + + it("should handle importScripts being undefined ", async ()=>{ + self.importScripts = undefined + await LibResilientPluginConstructors.get('dnslink-ipfs')(LR, init).fetch('/test.json') + }) +}) diff --git a/plugins/fetch/__tests__/browser.test.js b/plugins/fetch/__tests__/browser.test.js new file mode 100644 index 0000000..612290f --- /dev/null +++ b/plugins/fetch/__tests__/browser.test.js @@ -0,0 +1,126 @@ +import { + describe, + it, + afterEach, + beforeEach +} from "https://deno.land/std@0.183.0/testing/bdd.ts"; + +import { + assert, + assertRejects, + assertEquals +} from "https://deno.land/std@0.183.0/testing/asserts.ts"; + +import { + assertSpyCall, + assertSpyCalls, + spy, +} from "https://deno.land/std@0.183.0/testing/mock.ts"; + +beforeEach(()=>{ + window.fetch = spy(window.resolvingFetch) +}) + +afterEach(()=>{ + window.fetch = null +}) + +describe('browser: fetch plugin', async () => { + window.LibResilientPluginConstructors = new Map() + window.LR = { + log: (component, ...items)=>{ + console.debug(component + ' :: ', ...items) + } + } + window.resolvingFetch = (url, init) => { + return Promise.resolve( + new Response( + new Blob( + [JSON.stringify({ test: "success" })], + {type: "application/json"} + ), + { + status: 200, + statusText: "OK", + headers: { + 'ETag': 'TestingETagHeader' + } + } + ) + ) + } + window.fetch = null + await import("../../../plugins/fetch/index.js"); + + it("should register in LibResilientPluginConstructors", () => { + assertEquals(LibResilientPluginConstructors.get('fetch')(LR).name, 'fetch'); + }); + + it("should return data from fetch()", async () => { + const response = await LibResilientPluginConstructors.get('fetch')(LR).fetch('https://resilient.is/test.json'); + + assertSpyCalls(fetch, 1); + assertEquals(await response.json(), {test: "success"}) + }); + + it("should pass the Request() init data to fetch()", async () => { + var initTest = { + method: "GET", + headers: new Headers({"x-stub": "STUB"}), + mode: "mode-stub", + credentials: "credentials-stub", + cache: "cache-stub", + referrer: "referrer-stub", + redirect: "redirect-stub", + integrity: "integrity-stub" + } + const response = await LibResilientPluginConstructors.get('fetch')(LR).fetch('https://resilient.is/test.json', initTest); + assertSpyCall( + fetch, + 0, + { + args: [ + 'https://resilient.is/test.json', + initTest // TODO: does the initTest actually properly work here? + ] + }) + assertEquals(await response.json(), {test: "success"}) + }); + + it("should set the LibResilient headers", async () => { + const response = await LibResilientPluginConstructors.get('fetch')(LR).fetch('https://resilient.is/test.json'); + + assertSpyCalls(fetch, 1); + assertEquals(await response.json(), {test: "success"}) + + assertEquals(response.headers.has('X-LibResilient-Method'), true) + assertEquals(response.headers.get('X-LibResilient-Method'), 'fetch') + assertEquals(response.headers.has('X-LibResilient-Etag'), true) + assertEquals(response.headers.get('X-LibResilient-ETag'), 'TestingETagHeader') + }); + + it("should throw an error when HTTP status is >= 400", async () => { + window.fetch = (url, init) => { + const response = new Response( + new Blob( + ["Not Found"], + {type: "text/plain"} + ), + { + status: 404, + statusText: "Not Found", + url: url + }); + return Promise.resolve(response); + } + assertRejects( + async () => { + return await LibResilientPluginConstructors + .get('fetch')(LR) + .fetch('https://resilient.is/test.json') }, + Error, + 'HTTP Error: 404 Not Found' + ) + }); + +}) diff --git a/plugins/gun-ipfs/__tests__/browser.test.js b/plugins/gun-ipfs/__tests__/browser.test.js new file mode 100644 index 0000000..4c7e31b --- /dev/null +++ b/plugins/gun-ipfs/__tests__/browser.test.js @@ -0,0 +1,362 @@ +import { + describe, + it, + beforeEach, + beforeAll +} from "https://deno.land/std@0.183.0/testing/bdd.ts"; + +import { + assertEquals, + assertRejects, + assertThrows +} from "https://deno.land/std@0.183.0/testing/asserts.ts"; + +import { + assertSpyCall, + assertSpyCalls, + spy +} from "https://deno.land/std@0.183.0/testing/mock.ts"; + +beforeAll(async ()=>{ + window.fetchResponse = [] + window.resolvingFetch = (url, init) => { + const response = new Response( + new Blob( + [JSON.stringify(window.fetchResponse[0])], + {type: window.fetchResponse[1]} + ), + { + status: 200, + statusText: "OK", + headers: { + 'Last-Modified': 'TestingLastModifiedHeader' + }, + url: url + }); + return Promise.resolve(response); + } + + /* + * prototype of the plugin init object + */ + window.initPrototype = { + name: 'gun-ipfs', + gunPubkey: 'stub' + } + + window.ipfsPrototype = { + ipfsFixtureAddress: 'QmiPFSiPFSiPFSiPFSiPFSiPFSiPFSiPFSiPFSiPFSiPFS', + create: ()=>{ + var sourceUsed = false + return Promise.resolve({ + get: (path)=>{ + return { + next: ()=>{ + if (path.endsWith('nonexistent.path')) { + throw new Error('Error: file does not exist') + } + let prevSourceUsed = sourceUsed + sourceUsed = true + var val = undefined + if (!prevSourceUsed) { + var val = Uint8Array.from( + Array + .from(JSON.stringify({ + test: "success", + path: path + })) + .map( + letter => letter.charCodeAt(0) + ) + ) + } + return Promise.resolve({ + done: prevSourceUsed, + value: val + }) + } + } + }, + name: { + resolve: (path)=>{ + var result = path.replace( + '/ipns/resilient.is', + '/ipfs/' + Ipfs.ipfsFixtureAddress + ) + return { + next: ()=> { + return Promise.resolve({ + done: false, + value: result + }) + } + } + } + } + }) + } + } + + window.gunUserFunction = ()=>{ + return { + get: () => { + return { + get: (path)=>{ + return { + once: (arg)=>{ arg('/ipfs/' + Ipfs.ipfsFixtureAddress + path) } + } + } + } + } + } + } + window.GunFunction = (nodes)=>{ + return { + user: self.gunUser + } + } +}) + +/** + * we need to do all of this before each test in order to reset the fetch() use counter + * and make sure window.init is clean and not modified by previous tests + */ +beforeEach(()=>{ + window.fetch = spy(window.resolvingFetch) + window.fetchResponse = [ + {test: "success"}, + "application/json" + ] + window.init = { + ...window.initPrototype + } + window.Ipfs = { + ...window.ipfsPrototype + } + self.Ipfs = window.Ipfs + window.gunUser = spy(window.gunUserFunction) + window.Gun = spy(window.GunFunction) + + window.LR = { + log: spy((component, ...items)=>{ + console.debug(component + ' :: ', ...items) + }) + } +}) + +describe('browser: gun-ipfs plugin', async () => { + window.LibResilientPluginConstructors = new Map() + window.fetchResponse = [] + window.resolvingFetch = null + window.fetch = null + window.Ipfs = null + window.Gun = null + window.gunUser = null + + await import("../../../plugins/gun-ipfs/index.js"); + + it("should register in LibResilientPluginConstructors", () => { + assertEquals( + LibResilientPluginConstructors.get('gun-ipfs')(LR, init).name, + 'gun-ipfs' + ); + }); + + it("should initiate IPFS setup", async ()=>{ + self.importScripts = spy(()=>{}) + try { + await LibResilientPluginConstructors.get('gun-ipfs')(LR, init).fetch('/test.json') + } catch {} + + assertSpyCall( + importScripts, + 0, + { + args: ['./lib/ipfs.js'] + }) + }) + + it("should initiate Gun setup", async ()=>{ + self.importScripts = spy(()=>{}) + try { + await LibResilientPluginConstructors.get('gun-ipfs')(LR, init).fetch('/test.json') + } catch {} + + assertSpyCall( + importScripts, + 1, + { + args: ['./lib/gun.js', "./lib/sea.js", "./lib/webrtc.js"] + }) + }) + + it("should error out when fetching unpublished content", async ()=>{ + assertRejects( + async ()=>{ + return await LibResilientPluginConstructors + .get('gun-ipfs')(LR, init) + .fetch('https://resilient.is/nonexistent.path') + }, + Error, + 'Error: file does not exist' + ) + }) + + // TODO: probably not necessary in the long run? + it("should fetch /index.html instead of a path ending in /", async ()=>{ + await LibResilientPluginConstructors + .get('gun-ipfs')(LR, init) + .fetch('https://resilient.is/test/') + assertSpyCall( + window.LR.log, + 15, + { + args: [ + "gun-ipfs", + "path ends in '/', assuming 'index.html' should be appended." + ]}) + }) + + it("should correctly guess content types when fetching", async ()=>{ + + let gunIpfsPlugin = LibResilientPluginConstructors.get('gun-ipfs')(LR, init) + let response = await gunIpfsPlugin.fetch('https://resilient.is/test/') + assertSpyCall( + window.LR.log, + 17, + { + args: [ + "gun-ipfs", + " +-- guessed contentType : text/html" + ]}) + + window.LR.log = spy((component, ...items)=>{ + console.debug(component + ' :: ', ...items) + }) + gunIpfsPlugin = LibResilientPluginConstructors.get('gun-ipfs')(LR, init) + response = await gunIpfsPlugin.fetch('https://resilient.is/test.htm') + assertSpyCall( + window.LR.log, + 16, + { + args: [ + "gun-ipfs", + " +-- guessed contentType : text/html" + ]}) + + window.LR.log = spy((component, ...items)=>{ + console.debug(component + ' :: ', ...items) + }) + gunIpfsPlugin = LibResilientPluginConstructors.get('gun-ipfs')(LR, init) + response = await gunIpfsPlugin.fetch('https://resilient.is/test.css') + assertSpyCall( + window.LR.log, + 16, + { + args: [ + "gun-ipfs", + " +-- guessed contentType : text/css" + ]}) + + window.LR.log = spy((component, ...items)=>{ + console.debug(component + ' :: ', ...items) + }) + gunIpfsPlugin = LibResilientPluginConstructors.get('gun-ipfs')(LR, init) + response = await gunIpfsPlugin.fetch('https://resilient.is/test.js') + assertSpyCall( + window.LR.log, + 16, + { + args: [ + "gun-ipfs", + " +-- guessed contentType : text/javascript" + ]}) + + window.LR.log = spy((component, ...items)=>{ + console.debug(component + ' :: ', ...items) + }) + gunIpfsPlugin = LibResilientPluginConstructors.get('gun-ipfs')(LR, init) + response = await gunIpfsPlugin.fetch('https://resilient.is/test.json') + assertSpyCall( + window.LR.log, + 16, + { + args: [ + "gun-ipfs", + " +-- guessed contentType : application/json" + ]}) + + window.LR.log = spy((component, ...items)=>{ + console.debug(component + ' :: ', ...items) + }) + gunIpfsPlugin = LibResilientPluginConstructors.get('gun-ipfs')(LR, init) + response = await gunIpfsPlugin.fetch('https://resilient.is/test.svg') + assertSpyCall( + window.LR.log, + 16, + { + args: [ + "gun-ipfs", + " +-- guessed contentType : image/svg+xml" + ]}) + + window.LR.log = spy((component, ...items)=>{ + console.debug(component + ' :: ', ...items) + }) + gunIpfsPlugin = LibResilientPluginConstructors.get('gun-ipfs')(LR, init) + response = await gunIpfsPlugin.fetch('https://resilient.is/test.ico') + assertSpyCall( + window.LR.log, + 16, + { + args: [ + "gun-ipfs", + " +-- guessed contentType : image/x-icon" + ]}) + }) + + it("should fetch content (stub!)", async ()=>{ + + let response = await LibResilientPluginConstructors + .get('gun-ipfs')(LR, init) + .fetch('https://resilient.is/test.json') + + assertSpyCall( + window.LR.log, + 16, + { + args: [ + "gun-ipfs", + " +-- guessed contentType : application/json" + ]}) + // TODO: implement actual content check once gun-ipfs plugin gets updated + // to work with current IPFS version + }) + + it("should error out if publishContent() is passed anything else than string or array of string", async ()=>{ + var gunipfsPlugin = LibResilientPluginConstructors.get('gun-ipfs')(LR, init) + + assertThrows( + ()=>{ + gunipfsPlugin.publish({ + url: 'https://resilient.is/test.json' + }) + }, + Error, + 'Handling a Response: not implemented yet') + + assertThrows( + ()=>{ + gunipfsPlugin.publish(true) + }, + Error, + 'Only accepts: string, Array of string, Response.') + + assertThrows( + ()=>{ + gunipfsPlugin.publish([true, 5]) + }, + Error, + 'Only accepts: string, Array of string, Response.') + }) +}) diff --git a/plugins/integrity-check/__tests__/browser.test.js b/plugins/integrity-check/__tests__/browser.test.js new file mode 100644 index 0000000..0f975dc --- /dev/null +++ b/plugins/integrity-check/__tests__/browser.test.js @@ -0,0 +1,253 @@ +import { + describe, + it, + beforeEach, + beforeAll +} from "https://deno.land/std@0.183.0/testing/bdd.ts"; + +import { + assertThrows, + assertRejects, + assertEquals +} from "https://deno.land/std@0.183.0/testing/asserts.ts"; + +import { + assertSpyCall, + assertSpyCalls, + spy, +} from "https://deno.land/std@0.183.0/testing/mock.ts"; + +beforeAll(async ()=>{ + + window.resolvingFetch = (url, init)=>{ + return Promise.resolve( + new Response( + ['{"test": "success"}'], + { + type: "application/json", + status: 200, + statusText: "OK", + headers: { + 'ETag': 'TestingETagHeader' + }, + url: url + } + ) + ) + } + + /* + * prototype of the plugin init object + */ + window.initPrototype = { + name: 'integrity-check', + uses: [ + { + name: 'resolve-all', + description: 'Resolves all', + version: '0.0.1', + fetch: null + } + ] + } + + /* + * integrity data in init object to be passed to fetch() + * for the plugin to verify + */ + window.requestInit = { + sha256: { + integrity: "sha256-eiMrFuthzteJuj8fPwUMyNQMb2SMW7VITmmt2oAxGj0=" + }, + sha384: { + integrity: "sha384-x4iqiH3PIPD51TibGEhTju/WhidcIEcnrpdklYEtIS87f96c4nLyj6CuwUp8kyOo" + }, + sha512: { + integrity: "sha512-o+J3lPk7DU8xOJaNfZI5T4Upmaoc9XOVxOWPCFAy4pTgvS8LrJZ8iNis/2ZaryU4bB33cNSXQBxUDvwDxknEBQ==" + } + } +}) + +/** + * we need to do all of this before each test in order to reset the fetch() use counter + * and make sure window.init is clean and not modified by previous tests + */ +beforeEach(()=>{ + window.fetch = spy(window.resolvingFetch) + window.init = { + ...window.initPrototype + } + window.init.uses[0].fetch = window.fetch +}) + +describe('browser: integrity-check plugin', async () => { + window.LibResilientPluginConstructors = new Map() + window.LR = { + log: (component, ...items)=>{ + console.debug(component + ' :: ', ...items) + } + } + + window.resolvingFetch = null + window.fetch = null + window.subtle = crypto.subtle + + await import("../../../plugins/integrity-check/index.js"); + + it("should register in LibResilientPluginConstructors", () => { + assertEquals( + LibResilientPluginConstructors + .get('integrity-check')(LR, init).name, + 'integrity-check'); + }); + + it("should throw an error when there aren't any wrapped plugins configured", () => { + init = { + name: 'integrity-check', + uses: [] + } + assertThrows( + ()=>{ + return LibResilientPluginConstructors.get('integrity-check')(LR, init) + }, + Error, + 'Expected exactly one plugin to wrap, but 0 configured.' + ) + }); + + it("should throw an error when there are more than one wrapped plugins configured", () => { + init = { + name: 'integrity-check', + uses: ['plugin-one', 'plugin-two'] + } + assertThrows( + ()=>{ + return LibResilientPluginConstructors.get('integrity-check')(LR, init) + }, + Error, + 'Expected exactly one plugin to wrap, but 2 configured.' + ) + }); + + it("should throw an error when an unsupported digest algorithm is used", async () => { + + assertRejects(async ()=>{ + return await LibResilientPluginConstructors + .get('integrity-check')(LR, init) + .fetch('https://resilient.is/test.json', { + integrity: "sha000-eiMrFuthzteJuj8fPwUMyNQMb2SMW7VITmmt2oAxGj0=" + }) + }, + Error, + 'No digest matched for:' + ) + }); + + it("it should return data from the wrapped plugin when no integrity data is available and requireIntegrity is false (default)", async () => { + + const response = await LibResilientPluginConstructors.get('integrity-check')(LR, init).fetch('https://resilient.is/test.json'); + + assertEquals(await response.json(), {test: "success"}) + assertSpyCalls(fetch, 1) + assertSpyCall(fetch, 0, { + args: ['https://resilient.is/test.json', {}] + }) + }); + + it("should reject no integrity data is available but requireIntegrity is true", async () => { + + init.requireIntegrity = true + + assertRejects(async ()=>{ + return await LibResilientPluginConstructors + .get('integrity-check')(LR, init) + .fetch('https://resilient.is/test.json') + }, + Error, + 'Integrity data required but not provided for:' + ) + }); + + it("should check integrity and return data from the wrapped plugin if SHA-256 integrity data matches", async () => { + + const response = await LibResilientPluginConstructors + .get('integrity-check')(LR, init) + .fetch('https://resilient.is/test.json', requestInit.sha256); + + assertEquals(await response.json(), {test: "success"}) + assertSpyCalls(fetch, 1) + assertSpyCall(fetch, 0, { + args: ['https://resilient.is/test.json', requestInit.sha256] + }) + }); + + it("should check integrity and return data from the wrapped plugin if SHA-384 integrity data matches", async () => { + + const response = await LibResilientPluginConstructors + .get('integrity-check')(LR, init) + .fetch('https://resilient.is/test.json', requestInit.sha384); + + assertEquals(await response.json(), {test: "success"}) + assertSpyCalls(fetch, 1) + assertSpyCall(fetch, 0, { + args: ['https://resilient.is/test.json', requestInit.sha384] + }) + }); + + it("should check integrity and return data from the wrapped plugin if SHA-512 integrity data matches", async () => { + + const response = await LibResilientPluginConstructors + .get('integrity-check')(LR, init) + .fetch('https://resilient.is/test.json', requestInit.sha512); + + assertEquals(await response.json(), {test: "success"}) + assertSpyCalls(fetch, 1) + assertSpyCall(fetch, 0, { + args: ['https://resilient.is/test.json', requestInit.sha512] + }) + }); + + it("should check integrity of the data returned from the wrapped plugin and reject if it doesn't match", async () => { + + assertRejects(async ()=>{ + return await LibResilientPluginConstructors + .get('integrity-check')(LR, init) + .fetch('https://resilient.is/test.json', { + integrity: "sha256-INCORRECTINCORRECTINCORRECTINCORRECTINCORREC" + }); + }, + Error, + 'No digest matched for:' + ) + }); + + it("should check integrity of the data returned from the wrapped plugin and resolve if at least one of multiple integrity hash matches", async () => { + + const response = await LibResilientPluginConstructors.get('integrity-check')(LR, init).fetch('https://resilient.is/test.json', { + integrity: "sha256-INCORRECTINCORRECTINCORRECTINCORRECTINCORREC sha256-eiMrFuthzteJuj8fPwUMyNQMb2SMW7VITmmt2oAxGj0=" + }); + + assertEquals(await response.json(), {test: "success"}) + assertSpyCalls(fetch, 1) + assertSpyCall(fetch, 0, { + args: ['https://resilient.is/test.json', + { + integrity: "sha256-INCORRECTINCORRECTINCORRECTINCORRECTINCORREC sha256-eiMrFuthzteJuj8fPwUMyNQMb2SMW7VITmmt2oAxGj0=" + }] + }) + }); + + it("should check integrity of the data returned from the wrapped plugin and reject if all out of multiple integrity hash do not match", async () => { + + assertRejects(async ()=>{ + return await LibResilientPluginConstructors + .get('integrity-check')(LR, init) + .fetch('https://resilient.is/test.json', { + integrity: "sha256-INCORRECTINCORRECTINCORRECTINCORRECTINCORREC sha256-WRONGWRONGWRONGWRONGWRONGWRONGWRONGWRONGWRON" + }); + }, + Error, + 'No digest matched for:' + ) + }); +}) diff --git a/plugins/integrity-check/index.js b/plugins/integrity-check/index.js index 9cc2a8f..1735baf 100644 --- a/plugins/integrity-check/index.js +++ b/plugins/integrity-check/index.js @@ -114,7 +114,7 @@ } // fetch data using the configured wrapped plugin - responsePromise = config.uses[0].fetch(url, init) + var responsePromise = config.uses[0].fetch(url, init) // if we have no integrity data, we really do not have anything to do // apart from returning the response @@ -144,7 +144,11 @@ // it's a string, we need an array nextIntegrityHash = nextIntegrityHash.split('-') // make sure the algo name is compatible with SubtleCrypto digest algo names - nextIntegrityHash[0] = getAlgo(nextIntegrityHash[0]) + try { + nextIntegrityHash[0] = getAlgo(nextIntegrityHash[0]) + } catch (e) { + return Promise.reject(e) + } LR.log(pluginName, `verifying integrity for: ${url}\n- algo: ${nextIntegrityHash[0]}\n- hash: ${nextIntegrityHash[1]}`) return crypto .subtle @@ -157,7 +161,7 @@ LR.log(pluginName, `successfully verified integrity for: ${url}\n- algo: ${nextIntegrityHash[0]}\n- hash: ${nextIntegrityHash[1]}`) return responsePromise } else { - return Promise.reject('Digest does not match.') + return Promise.reject(Error('Digest does not match.')) } }) }, @@ -165,7 +169,7 @@ Promise.reject()) }) .catch((err)=>{ - return Promise.reject(`No digest matched for: ${url}`) + return Promise.reject(Error(`No digest matched for: ${url}`)) }) } diff --git a/plugins/ipns-ipfs/__tests__/browser.test.js b/plugins/ipns-ipfs/__tests__/browser.test.js new file mode 100644 index 0000000..dee7990 --- /dev/null +++ b/plugins/ipns-ipfs/__tests__/browser.test.js @@ -0,0 +1,137 @@ +import { + describe, + it, + beforeEach, + beforeAll +} from "https://deno.land/std@0.183.0/testing/bdd.ts"; + +import { + assertEquals +} from "https://deno.land/std@0.183.0/testing/asserts.ts"; + +import { + spy +} from "https://deno.land/std@0.183.0/testing/mock.ts"; + +beforeAll(async ()=>{ + window.fetchResponse = [] + window.resolvingFetch = (url, init) => { + const response = new Response( + new Blob( + [JSON.stringify(window.fetchResponse[0])], + {type: window.fetchResponse[1]} + ), + { + status: 200, + statusText: "OK", + headers: { + 'Last-Modified': 'TestingLastModifiedHeader' + }, + url: url + }); + return Promise.resolve(response); + } + + /* + * prototype of the plugin init object + */ + window.initPrototype = { + name: 'ipns-ipfs', + ipnsPubkey: 'stub' + } + + window.ipfsPrototype = { + ipfsFixtureAddress: 'QmiPFSiPFSiPFSiPFSiPFSiPFSiPFSiPFSiPFSiPFSiPFS', + create: ()=>{ + console.log('*** Ipfs.create()') + var sourceUsed = false + return Promise.resolve({ + cat: (path)=>{ + return { + next: ()=>{ + if (path.endsWith('nonexistent.path')) { + throw new Error('Error: file does not exist') + } + let prevSourceUsed = sourceUsed + sourceUsed = true + var val = undefined + if (!prevSourceUsed) { + var val = Uint8Array.from( + Array + .from(JSON.stringify({ + test: "success", + path: path + })) + .map( + letter => letter.charCodeAt(0) + ) + ) + } + return Promise.resolve({ + done: prevSourceUsed, + value: val + }) + } + } + }, + name: { + resolve: (path)=>{ + var result = path.replace( + '/ipns/resilient.is', + '/ipfs/' + Ipfs.ipfsFixtureAddress + ) + return { + next: ()=> { + return Promise.resolve({ + done: false, + value: result + }) + } + } + } + } + }) + } + } +}) + +/** + * we need to do all of this before each test in order to reset the fetch() use counter + * and make sure window.init is clean and not modified by previous tests + */ +beforeEach(()=>{ + window.fetch = spy(window.resolvingFetch) + window.fetchResponse = [ + {test: "success"}, + "application/json" + ] + window.init = { + ...window.initPrototype + } + window.Ipfs = { + ...window.ipfsPrototype + } + self.Ipfs = window.Ipfs +}) + +describe('browser: ipns-ipfs plugin', async () => { + window.LibResilientPluginConstructors = new Map() + window.LR = { + log: (component, ...items)=>{ + console.debug(component + ' :: ', ...items) + } + } + window.fetchResponse = [] + window.resolvingFetch = null + window.fetch = null + window.Ipfs = null + + await import("../../../plugins/ipns-ipfs/index.js"); + + it("should register in LibResilientPluginConstructors", () => { + assertEquals( + LibResilientPluginConstructors.get('ipns-ipfs')(LR, init).name, + 'ipns-ipfs' + ); + }); +}) diff --git a/plugins/redirect/__tests__/browser.test.js b/plugins/redirect/__tests__/browser.test.js new file mode 100644 index 0000000..629bab2 --- /dev/null +++ b/plugins/redirect/__tests__/browser.test.js @@ -0,0 +1,136 @@ +import { + describe, + it, + afterEach, + beforeEach +} from "https://deno.land/std@0.183.0/testing/bdd.ts"; + +import { + assert, + assertThrows, + assertRejects, + assertEquals +} from "https://deno.land/std@0.183.0/testing/asserts.ts"; + +import { + assertSpyCall, + assertSpyCalls, + spy, +} from "https://deno.land/std@0.183.0/testing/mock.ts"; + +beforeEach(()=>{ + window.resolvingFetchSpy = spy(window.resolvingFetch) + window.init = { + name: 'redirect', + redirectStatus: 302, + redirectStatusText: "Found", + redirectTo: "https://redirected.example.org/subdir/" + } +}) + +afterEach(()=>{ + window.init = null + window.resolvingFetchSpy = null +}) + +describe('browser: redirect plugin', async () => { + window.LibResilientPluginConstructors = new Map() + window.LR = { + log: (component, ...items)=>{ + console.debug(component + ' :: ', ...items) + } + } + window.resolvingFetch = (url, init) => { + return Promise.resolve( + new Response( + new Blob( + [JSON.stringify({ test: "success" })], + {type: "application/json"} + ), + { + status: 200, + statusText: "OK", + headers: { + 'ETag': 'TestingETagHeader' + } + } + ) + ) + } + window.resolvingFetchSpy = null + await import("../../../plugins/redirect/index.js"); + + it("should register in LibResilientPluginConstructors", () => { + init = { + name: 'redirect', + redirectTo: 'https://example.org/' + } + assertEquals(LibResilientPluginConstructors.get('redirect')(LR, init).name, 'redirect'); + }); + + it("should fail with incorrect redirectTo config value", () => { + init = { + name: 'redirect', + redirectTo: false + } + assertThrows( + ()=>{ + LibResilientPluginConstructors.get('redirect')(LR, init) + }, + Error, + "redirectTo should be a string" + ) + }); + + it("should fail with incorrect redirectStatus config value", () => { + init = { + name: 'redirect', + redirectTo: 'https://example.org/', + redirectStatus: 'incorrect' + } + assertThrows( + ()=>{ + LibResilientPluginConstructors.get('redirect')(LR, init) + }, + Error, + "redirectStatus should be a number" + ) + }); + + it("should fail with incorrect redirectStatusText config value", () => { + init = { + name: 'redirect', + redirectTo: 'https://example.org/', + redirectStatusText: false + } + assertThrows( + ()=>{ + LibResilientPluginConstructors.get('redirect')(LR, init) + }, + Error, + "redirectStatusText should be a string" + ) + }); + + it("should register in LibResilientPluginConstructors without error even if all config data is incorrect, as long as enabled is false", () => { + init = { + name: 'redirect', + redirectTo: false, + redirectStatus: "incorrect", + redirectStatusText: false, + enabled: false + } + assertEquals(LibResilientPluginConstructors.get('redirect')(LR, init).name, 'redirect'); + }); + + it("should return a 302 Found redirect to a configured location for any request", async () => { + init = { + name: 'redirect', + redirectTo: "https://redirected.example.org/subdirectory/" + } + const response = await LibResilientPluginConstructors.get('redirect')(LR, init).fetch('https://resilient.is/test.json'); + assertEquals(response.status, 302) + assertEquals(response.statusText, 'Found') + assertEquals(response.headers.get('location'), 'https://redirected.example.org/subdirectory/test.json') + }) +}) diff --git a/plugins/signed-integrity/__tests__/browser.test.js b/plugins/signed-integrity/__tests__/browser.test.js new file mode 100644 index 0000000..b9800df --- /dev/null +++ b/plugins/signed-integrity/__tests__/browser.test.js @@ -0,0 +1,411 @@ +import { + describe, + it, + beforeEach, + beforeAll +} from "https://deno.land/std@0.183.0/testing/bdd.ts"; + +import { + assertThrows, + assertRejects, + assertEquals +} from "https://deno.land/std@0.183.0/testing/asserts.ts"; + +import { + assertSpyCall, + assertSpyCalls, + spy, +} from "https://deno.land/std@0.183.0/testing/mock.ts"; + +async function generateECDSAKeypair() { + return await crypto.subtle.generateKey({ + name: "ECDSA", + namedCurve: "P-384" + }, + true, + ["sign", "verify"] + ); +} + +async function getArmouredKey(key) { + return JSON.stringify(await crypto.subtle.exportKey('jwk', key)) +} + +function jwtize(str) { + return btoa(str) + .replace(/\//g, '_') + .replace(/\+/g, '-') + .replace(/=/g, '') +} + +/** + * k - keypair + * h - header + * p - payload + */ +async function getSignature(k, h, p) { + + // we need a TextEncoder + var tenc = new TextEncoder() + var tencoded = tenc.encode(h + '.' + p) + + // prepare a signature + var sig = new Uint8Array(await crypto.subtle.sign( + { + name: "ECDSA", + hash: {name: "SHA-384"} + }, + k.privateKey, + tencoded + )) + + // prepare it for inclusion in a JWT + var sig = sig.reduce((str, cur)=>{return (str + String.fromCharCode(cur)) }, '') + return jwtize(sig) +} + +beforeAll(async ()=>{ + // our keypair + var keypair = await generateECDSAKeypair() + + // ES384: ECDSA using P-384 and SHA-384 + var header = jwtize('{"alg": "ES384"}') + var payload = jwtize('{"integrity": "sha256-eiMrFuthzteJuj8fPwUMyNQMb2SMW7VITmmt2oAxGj0="}') + + // get a signature + var signature = await getSignature(keypair, header, payload) + + // need to test with bad algo! + var noneHeader = jwtize('{"alg": "none"}') + + // get an invalid signature + // an ECDSA signature for {alg: none} header makes zero sense + var noneSignature = await getSignature(keypair, noneHeader, payload) + + // prepare stuff for invalid JWT JSON test + var invalidPayload = jwtize('not a valid JSON string') + // get an valid signature for invalid payload + var invalidPayloadSignature = await getSignature(keypair, header, invalidPayload) + + // prepare stuff for JWT payload without integrity test + var noIntegrityPayload = jwtize('{"no": "integrity"}') + // get an valid signature for invalid payload + var noIntegrityPayloadSignature = await getSignature(keypair, header, noIntegrityPayload) + + window.resolvingFetch = (url, init)=>{ + var content = '{"test": "success"}' + var status = 200 + var statusText = "OK" + + if (url == 'https://resilient.is/test.json.integrity') { + content = header + '.' + payload + '.' + signature + // testing 404 not found on the integrity URL + } else if (url == 'https://resilient.is/not-found.json.integrity') { + content = '{"test": "fail"}' + status = 404 + statusText = "Not Found" + // testing invalid base64-encoded data + } else if (url == 'https://resilient.is/invalid-base64.json.integrity') { + // for this test to work correctly the length must be (n*4)+1 + content = header + '.' + payload + '.' + 'badbase64' + // testing "alg: none" on the integrity JWT + } else if (url == 'https://resilient.is/alg-none.json.integrity') { + content = noneHeader + '.' + payload + '.' + // testing bad signature on the integrity JWT + } else if (url == 'https://resilient.is/bad-signature.json.integrity') { + content = header + '.' + payload + '.' + noneSignature + // testing invalid payload + } else if (url == 'https://resilient.is/invalid-payload.json.integrity') { + content = header + '.' + invalidPayload + '.' + invalidPayloadSignature + // testing payload without integrity data + } else if (url == 'https://resilient.is/no-integrity.json.integrity') { + content = header + '.' + noIntegrityPayload + '.' + noIntegrityPayloadSignature + } + + return Promise.resolve( + new Response( + [content], + { + type: "application/json", + status: status, + statusText: statusText, + headers: { + 'ETag': 'TestingETagHeader' + }, + url: url + } + ) + ) + } + + window.initPrototype = { + name: 'signed-integrity', + uses: [ + { + name: 'resolve-all', + description: 'Resolves all', + version: '0.0.1', + fetch: null + } + ], + integrity: { + "https://resilient.is/test.json": "sha384-kn5dhxz4RpBmx7xC7Dmq2N43PclV9U/niyh+4Km7oz5W0FaWdz3Op+3K0Qxz8y3z" + }, + //requireIntegrity: false, // default is false + publicKey: await crypto.subtle.exportKey('jwk', keypair.publicKey) + } +}) + +/** + * we need to do all of this before each test in order to reset the fetch() use counter + * and make sure window.init is clean and not modified by previous tests + */ +beforeEach(()=>{ + window.fetch = spy(window.resolvingFetch) + window.init = { + ...window.initPrototype + } + window.init.uses[0].fetch = window.fetch +}) + +describe('browser: signed-integrity plugin', async () => { + window.LibResilientPluginConstructors = new Map() + window.LR = { + log: (component, ...items)=>{ + console.debug(component + ' :: ', ...items) + } + } + + window.resolvingFetch = null + window.fetch = null + window.subtle = crypto.subtle + + await import("../../../plugins/signed-integrity/index.js"); + + it("should register in LibResilientPluginConstructors", async () => { + assertEquals( + LibResilientPluginConstructors + .get('signed-integrity')(LR, init) + .name, + 'signed-integrity'); + }); + + it("should throw an error when there aren't any wrapped plugins configured", () => { + init = { + name: 'signed-integrity', + uses: [] + } + assertThrows( + ()=>{ + return LibResilientPluginConstructors.get('signed-integrity')(LR, init) + }, + Error, + 'Expected exactly one plugin to wrap, but 0 configured.' + ) + }); + + it("should throw an error when there are more than one wrapped plugins configured", () => { + init = { + name: 'signed-integrity', + uses: ['plugin-one', 'plugin-two'] + } + assertThrows( + ()=>{ + return LibResilientPluginConstructors.get('signed-integrity')(LR, init) + }, + Error, + 'Expected exactly one plugin to wrap, but 2 configured.' + ) + }); + + it("should throw an error if the configured public key is impossible to load", async () => { + + var testInit = { + ...window.init + } + testInit.publicKey = 'NOTAKEY' + + assertRejects( + async ()=>{ + return await LibResilientPluginConstructors.get('signed-integrity')(LR, testInit).fetch('https://resilient.is/test.json') + }, + Error, + 'Unable to load the public key' + ) + }); + + /* + * we're only testing if signed-integrity plugin accepts that integrity data is set + * without pulling the .integrity file + * + * this will *not* result in the resource integrity *actually* being checked! + */ + it("should fetch content when integrity data provided without trying to fetch the integrity data URL", async () => { + + const response = await LibResilientPluginConstructors.get('signed-integrity')(LR, init).fetch('https://resilient.is/test.json', { + integrity: "sha384-x4iqiH3PIPD51TibGEhTju/WhidcIEcnrpdklYEtIS87f96c4nLyj6CuwUp8kyOo" + }); + + assertEquals(await response.json(), {test: "success"}) + assertSpyCalls(fetch, 1); + }); + + it("should fetch content when integrity data not provided, by also fetching the integrity data URL", async () => { + + const response = await LibResilientPluginConstructors.get('signed-integrity')(LR, init).fetch('https://resilient.is/test.json', {}); + + assertEquals(await response.json(), {test: "success"}) + assertSpyCalls(fetch, 2) + // the integrity file fetch has to happen first + assertSpyCall(fetch, 0, { + args: ['https://resilient.is/test.json.integrity'] + }) + // the content fetch needs to have integrity data available + assertSpyCall(fetch, 1, { + args: [ + "https://resilient.is/test.json", + { + integrity: "sha256-eiMrFuthzteJuj8fPwUMyNQMb2SMW7VITmmt2oAxGj0=", + } + ] + }) + }); + + it("should fetch content when integrity data not provided, and integrity data URL 404s", async () => { + + const response = await LibResilientPluginConstructors.get('signed-integrity')(LR, init).fetch('https://resilient.is/not-found.json', {}); + + assertEquals(await response.json(), {test: "success"}) + assertSpyCalls(fetch, 2) + // the integrity file fetch has to happen first + assertSpyCall(fetch, 0, { + args: ['https://resilient.is/not-found.json.integrity'] + }) + // the content fetch needs to have integrity data available + assertSpyCall(fetch, 1, { + args: [ + "https://resilient.is/not-found.json", + {} + ] + }) + }); + + it("should refuse to fetch content when integrity data not provided and integrity data URL 404s, but requireIntegrity is set to true", async () => { + + init.requireIntegrity = true + + assertRejects( + ()=>{ + return LibResilientPluginConstructors.get('signed-integrity')(LR, init).fetch('https://resilient.is/not-found.json', {}) + }, + Error, + 'No integrity data available, though required.' + ) + assertSpyCalls(fetch, 1) + assertSpyCall(fetch, 0, { + args: ['https://resilient.is/not-found.json.integrity'] + }) + + //expect(e.toString()).toMatch('No integrity data available, though required.') + }); + + it("should refuse to fetch content when integrity data not provided and integrity data URL is fetched, but JWT is invalid", async () => { + + assertRejects( + ()=>{ + return LibResilientPluginConstructors.get('signed-integrity')(LR, init).fetch('https://resilient.is/invalid-base64.json', {}) + }, + Error, + 'Invalid base64-encoded string!' + ) + assertSpyCalls(fetch, 1) + assertSpyCall(fetch, 0, { + args: ['https://resilient.is/invalid-base64.json.integrity'] + }) + }); + + it("should refuse to fetch content when integrity data not provided and integrity data URL is fetched, but JWT uses alg: none", async () => { + + assertRejects( + ()=>{ + return LibResilientPluginConstructors.get('signed-integrity')(LR, init).fetch('https://resilient.is/alg-none.json', {}) + }, + Error, + 'JWT seems invalid (one or more sections are empty).' + ) + + assertSpyCalls(fetch, 1) + assertSpyCall(fetch, 0, { + args: ['https://resilient.is/alg-none.json.integrity'] + }) + }); + + it("should refuse to fetch content when integrity data not provided and integrity data URL is fetched, but JWT signature check fails", async () => { + + assertRejects( + ()=>{ + return LibResilientPluginConstructors.get('signed-integrity')(LR, init).fetch('https://resilient.is/bad-signature.json', {}) + }, + Error, + 'JWT signature validation failed! Somebody might be doing something nasty!' + ) + + assertSpyCalls(fetch, 1) + assertSpyCall(fetch, 0, { + args: ['https://resilient.is/bad-signature.json.integrity'] + }) + }); + + it("should refuse to fetch content when integrity data not provided and integrity data URL is fetched, but JWT payload is unparseable", async () => { + + assertRejects( + ()=>{ + return LibResilientPluginConstructors.get('signed-integrity')(LR, init).fetch('https://resilient.is/invalid-payload.json', {}) + }, + Error, + 'JWT payload parsing failed' + ) + + assertSpyCalls(fetch, 1) + assertSpyCall(fetch, 0, { + args: ['https://resilient.is/invalid-payload.json.integrity'] + }) + }); + + it("should refuse to fetch content when integrity data not provided and integrity data URL is fetched, but JWT payload does not contain integrity data", async () => { + + assertRejects( + ()=>{ + return LibResilientPluginConstructors.get('signed-integrity')(LR, init).fetch('https://resilient.is/no-integrity.json', {}) + }, + Error, + 'JWT payload did not contain integrity data' + ) + + assertSpyCalls(fetch, 1) + assertSpyCall(fetch, 0, { + args: ['https://resilient.is/no-integrity.json.integrity'] + }) + }); + + it("should fetch and verify content, when integrity data not provided, by fetching the integrity data URL and using integrity data from it", async () => { + + const response = await LibResilientPluginConstructors.get('signed-integrity')(LR, init).fetch('https://resilient.is/test.json', {}); + + assertEquals(await response.json(), {test: "success"}) + assertSpyCalls(fetch, 2) + // the integrity file fetch has to happen first + assertSpyCall(fetch, 0, { + args: ['https://resilient.is/test.json.integrity'] + }) + // the content fetch needs to have integrity data available + assertSpyCall(fetch, 1, { + args: [ + "https://resilient.is/test.json", + { + integrity: "sha256-eiMrFuthzteJuj8fPwUMyNQMb2SMW7VITmmt2oAxGj0=", + } + ] + }) + }); +}) diff --git a/__denotests__/plugins/signed-integrity.test.js b/plugins/signed-integrity/__tests__/cli.test.js similarity index 83% rename from __denotests__/plugins/signed-integrity.test.js rename to plugins/signed-integrity/__tests__/cli.test.js index 3181628..6e51a53 100644 --- a/__denotests__/plugins/signed-integrity.test.js +++ b/plugins/signed-integrity/__tests__/cli.test.js @@ -1,6 +1,5 @@ import { assert, - assertThrows, assertRejects, assertEquals, assertStringIncludes, @@ -83,7 +82,7 @@ let verifySignedJWT = async (jwt, key) => { Deno.test("plugin loads", async () => { - const bi = await import('../../plugins/signed-integrity/cli.js') + const bi = await import('../cli.js') assert("name" in bi) assert(bi.name == "signed-integrity") assert("description" in bi) @@ -91,7 +90,7 @@ Deno.test("plugin loads", async () => { }); Deno.test("gen-integrity action defined", async () => { - const bi = await import('../../plugins/signed-integrity/cli.js') + const bi = await import('../cli.js') assert("gen-integrity" in bi.actions) const gi = bi.actions["gen-integrity"] @@ -133,7 +132,7 @@ Deno.test("gen-integrity action defined", async () => { // this is a separate test in order to catch any changing defaults Deno.test("gen-integrity action defaults", async () => { - const bi = await import('../../plugins/signed-integrity/cli.js') + const bi = await import('../cli.js') const gia = bi.actions["gen-integrity"].arguments assert("default" in gia.algorithm) assert(gia.algorithm.default == "SHA-256") @@ -144,7 +143,7 @@ Deno.test("gen-integrity action defaults", async () => { }); Deno.test("gen-integrity verifies arguments are sane", async () => { - const bi = await import('../../plugins/signed-integrity/cli.js') + const bi = await import('../cli.js') const gi = bi.actions["gen-integrity"] assertRejects(gi.run, Error, "Expected non-empty list of paths to process.") assertRejects(async ()=>{ @@ -172,39 +171,43 @@ Deno.test("gen-integrity verifies arguments are sane", async () => { }); Deno.test("gen-integrity handles paths correctly", async () => { - const bi = await import('../../plugins/signed-integrity/cli.js') + const bi = await import('../cli.js') const gi = bi.actions["gen-integrity"] + const mh = import.meta.resolve('./mocks/hello.txt').replace(/^file:\/\//gi, "") + const mk = import.meta.resolve('./mocks/keyfile.json').replace(/^file:\/\//gi, "") assertRejects(async ()=>{ await gi.run(['./'], 'non-existent') }, Error, "Failed to load private key from 'non-existent'") assertRejects(async ()=>{ await gi.run(['./'], './') - }, Error, "ailed to load private key from './': Is a directory") + }, Error, "Failed to load private key from './': Is a directory") assertEquals( - await gi.run(['./'], './__denotests__/mocks/keyfile.json'), + await gi.run(['./'], mk), '{}' ) assertStringIncludes( - await gi.run(['./__denotests__/mocks/hello.txt'], './__denotests__/mocks/keyfile.json'), - '"./__denotests__/mocks/hello.txt":"eyJhbGciOiAiRVMzODQifQ.eyJpbnRlZ3JpdHkiOiAic2hhMjU2LXVVMG51Wk5OUGdpbExsTFgybjJyK3NTRTcrTjZVNER1a0lqM3JPTHZ6ZWs9In0.' + await gi.run([mh], mk), + '"' + mh + '":"eyJhbGciOiAiRVMzODQifQ.eyJpbnRlZ3JpdHkiOiAic2hhMjU2LXVVMG51Wk5OUGdpbExsTFgybjJyK3NTRTcrTjZVNER1a0lqM3JPTHZ6ZWs9In0.' ) }); Deno.test("gen-integrity handles algos argument correctly", async () => { - const bi = await import('../../plugins/signed-integrity/cli.js') + const bi = await import('../cli.js') const gi = bi.actions["gen-integrity"] + const mh = import.meta.resolve('./mocks/hello.txt').replace(/^file:\/\//gi, "") + const mk = import.meta.resolve('./mocks/keyfile.json').replace(/^file:\/\//gi, "") assertRejects(async ()=>{ - await gi.run(['./__denotests__/mocks/hello.txt'], './__denotests__/mocks/keyfile.json', ['BAD-ALG']) + await gi.run([mh], mk, ['BAD-ALG']) }, Error, 'Unrecognized algorithm name') // helper function let getGeneratedTestIntegrity = async (algos) => { let integrity = JSON.parse(await gi.run( - ['./__denotests__/mocks/hello.txt'], - './__denotests__/mocks/keyfile.json', + [mh], + mk, algos) ) - integrity = b64urlDecode(integrity["./__denotests__/mocks/hello.txt"].split('.')[1]) + integrity = b64urlDecode(integrity[mh].split('.')[1]) return integrity } @@ -215,13 +218,15 @@ Deno.test("gen-integrity handles algos argument correctly", async () => { }); Deno.test("gen-integrity text output is correct", async () => { - const bi = await import('../../plugins/signed-integrity/cli.js') + const bi = await import('../cli.js') const gi = bi.actions["gen-integrity"] + const mh = import.meta.resolve('./mocks/hello.txt').replace(/^file:\/\//gi, "") + const mk = import.meta.resolve('./mocks/keyfile.json').replace(/^file:\/\//gi, "") let getGeneratedTestIntegrity = async (algos) => { let result = await gi.run( - ['./__denotests__/mocks/hello.txt'], - './__denotests__/mocks/keyfile.json', + [mh], + mk, algos, 'text' ) @@ -232,21 +237,21 @@ Deno.test("gen-integrity text output is correct", async () => { assertEquals( await getGeneratedTestIntegrity(['SHA-256']), [ - "./__denotests__/mocks/hello.txt:", + mh + ":", '{"integrity": "sha256-uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek="}' ] ) assertEquals( await getGeneratedTestIntegrity(['SHA-384']), [ - "./__denotests__/mocks/hello.txt:", + mh + ":", '{"integrity": "sha384-/b2OdaZ/KfcBpOBAOF4uI5hjA+oQI5IRr5B/y7g1eLPkF8txzmRu/QgZ3YwIjeG9"}' ] ) assertEquals( await getGeneratedTestIntegrity(['SHA-512']), [ - "./__denotests__/mocks/hello.txt:", + mh + ":", '{"integrity": "sha512-MJ7MSJwS1utMxA9QyQLytNDtd+5RGnx6m808qG1M2G+YndNbxf9JlnDaNCVbRbDP2DDoH2Bdz33FVC6TrpzXbw=="}' ] ) @@ -255,7 +260,7 @@ Deno.test("gen-integrity text output is correct", async () => { assertEquals( await getGeneratedTestIntegrity(['SHA-256', 'SHA-384', 'SHA-512']), [ - "./__denotests__/mocks/hello.txt:", + mh + ":", '{"integrity": "sha256-uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek= sha384-/b2OdaZ/KfcBpOBAOF4uI5hjA+oQI5IRr5B/y7g1eLPkF8txzmRu/QgZ3YwIjeG9 sha512-MJ7MSJwS1utMxA9QyQLytNDtd+5RGnx6m808qG1M2G+YndNbxf9JlnDaNCVbRbDP2DDoH2Bdz33FVC6TrpzXbw=="}' ] ) @@ -264,18 +269,21 @@ Deno.test("gen-integrity text output is correct", async () => { // TODO: "files" output mode, which will require mocking file writing routines Deno.test("gen-integrity signs the data correctly", async () => { - const bi = await import('../../plugins/signed-integrity/cli.js') + const bi = await import('../cli.js') const gi = bi.actions["gen-integrity"] - let jwt = JSON.parse(await gi.run(['./__denotests__/mocks/hello.txt'], './__denotests__/mocks/keyfile.json')) + const mh = import.meta.resolve('./mocks/hello.txt').replace(/^file:\/\//gi, "") + const mk = import.meta.resolve('./mocks/keyfile.json').replace(/^file:\/\//gi, "") + let jwt = JSON.parse(await gi.run([mh], mk)) assert( await verifySignedJWT( - jwt['./__denotests__/mocks/hello.txt'], + jwt[mh], pubkey)) }) Deno.test("get-pubkey works correctly", async () => { - const bi = await import('../../plugins/signed-integrity/cli.js') + const bi = await import('../cli.js') const gp = bi.actions["get-pubkey"] + const mk = import.meta.resolve('./mocks/keyfile.json').replace(/^file:\/\//gi, "") assertRejects(gp.run, Error, "No keyfile provided.") assertRejects(async ()=>{ await gp.run('no-such-file') @@ -284,17 +292,17 @@ Deno.test("get-pubkey works correctly", async () => { await gp.run(['no-such-file']) }, Error, "No such file or directory") assertEquals( - await gp.run('./__denotests__/mocks/keyfile.json'), + await gp.run(mk), '{"kty":"EC","crv":"P-384","alg":"ES384","x":"rrFawYTuFo8ZjoDxaztUU-c_RAwjw1Y9Tp3j4nH4WsY2Zlizf40Mvz_0BUkVVZCw","y":"HaFct6PVK2CQ7ZT2SHClnN-knmGfjY_DFwc6qrAu1s0DFZ8fEUuNdmkTlj9T4NQw","key_ops":["verify"],"ext":true}' ) assertEquals( - await gp.run(['./__denotests__/mocks/keyfile.json', 'irrelevant']), + await gp.run([mk, 'irrelevant']), '{"kty":"EC","crv":"P-384","alg":"ES384","x":"rrFawYTuFo8ZjoDxaztUU-c_RAwjw1Y9Tp3j4nH4WsY2Zlizf40Mvz_0BUkVVZCw","y":"HaFct6PVK2CQ7ZT2SHClnN-knmGfjY_DFwc6qrAu1s0DFZ8fEUuNdmkTlj9T4NQw","key_ops":["verify"],"ext":true}' ) }); Deno.test("gen-keypair works correctly", async () => { - const bi = await import('../../plugins/signed-integrity/cli.js') + const bi = await import('../cli.js') const gk = bi.actions["gen-keypair"] const keypair = JSON.parse(await gk.run()) assert('privateKey' in keypair) diff --git a/plugins/signed-integrity/__tests__/mocks/hello.txt b/plugins/signed-integrity/__tests__/mocks/hello.txt new file mode 100644 index 0000000..95d09f2 --- /dev/null +++ b/plugins/signed-integrity/__tests__/mocks/hello.txt @@ -0,0 +1 @@ +hello world \ No newline at end of file diff --git a/__denotests__/mocks/keyfile.json b/plugins/signed-integrity/__tests__/mocks/keyfile.json similarity index 100% rename from __denotests__/mocks/keyfile.json rename to plugins/signed-integrity/__tests__/mocks/keyfile.json diff --git a/plugins/signed-integrity/index.js b/plugins/signed-integrity/index.js index 7b2e5d8..3ee1dc3 100644 --- a/plugins/signed-integrity/index.js +++ b/plugins/signed-integrity/index.js @@ -92,6 +92,8 @@ + '='.repeat(pad) } + // needed for making ArrayBuffers out of strings + let tenc = new TextEncoder() /** * getting content using the configured plugin, @@ -123,7 +125,10 @@ let k = await getJWTPublicKey() // reality check: all parts of the JWT should be non-empty - if ( (jwt[0].length == 0) || (jwt[1].length == 0) || (jwt[2].length == 0) ) { + if ( (jwt.length < 3) + || (jwt[0].length == 0) + || (jwt[1].length == 0) + || (jwt[2].length == 0) ) { throw new Error('JWT seems invalid (one or more sections are empty).') } @@ -134,8 +139,9 @@ Array .from(signature) .map(e=>e.charCodeAt(0)) - ).buffer - + ) + + signature = signature.buffer // verify the JWT if (await subtle .verify( @@ -145,7 +151,7 @@ }, k, signature, - (jwt[0] + '.' + jwt[1]) + tenc.encode(jwt[0] + '.' + jwt[1]) )) { // unpack it var header = atob(b64urlDecode(jwt[0])) diff --git a/service-worker.js b/service-worker.js index 5e93ec4..1783136 100644 --- a/service-worker.js +++ b/service-worker.js @@ -2,35 +2,6 @@ * LibResilient Service Worker. */ - -/* - * we need a Promise.any() polyfill - * so here it is - * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/any - * - * TODO: remove once Promise.any() is implemented broadly - */ -if (typeof Promise.any === 'undefined') { - Promise.any = async (promises) => { - // Promise.all() is the polar opposite of Promise.any() - // in that it returns as soon as there is a first rejection - // but without it, it returns an array of resolved results - return Promise.all( - promises.map(p => { - return new Promise((resolve, reject) => - // swap reject and resolve, so that we can use Promise.all() - // and get the result we need - Promise.resolve(p).then(reject, resolve) - ); - }) - // now, swap errors and values back - ).then( - err => Promise.reject(err), - val => Promise.resolve(val) - ); - }; -} - // initialize the LibResilientPlugins array if (!Array.isArray(self.LibResilientPlugins)) { self.LibResilientPlugins = new Array() @@ -214,7 +185,7 @@ let initServiceWorker = async () => { cacheConfigJSON(configURL, cresponse) } else { - // we ain't got nothing useful -- justs set cdata to an empty object + // we ain't got nothing useful -- just set cdata to an empty object cdata = {} self.log('service-worker', 'ignoring invalid config, using defaults.') } @@ -361,7 +332,7 @@ let initServiceWorker = async () => { // i.e. if the plugin scripts have already been loaded // FIXME: this does not currently dive into dependencies! // FIXME: https://gitlab.com/rysiekpl/libresilient/-/issues/48 - for (p in cdata.plugins) { + for (let p in cdata.plugins) { var pname = cdata.plugins[p].name // plugin constructor not available, meaning: we'd have to importScripts() it @@ -447,21 +418,29 @@ let decrementActiveFetches = (clientId) => { * time - the timeout (in ms) * timeout_resolves - whether the Promise should resolve() or reject() when hitting the timeout (default: false (reject)) * error_message - optional error message to use when rejecting (default: false (no error message)) + * + * returns an array containing: + * - timeout-related Promise as element 0 + * - timeoutID as element 1 */ let promiseTimeout = (time, timeout_resolves=false, error_message=false) => { - return new Promise((resolve, reject)=>{ - setTimeout(()=>{ - if (timeout_resolves) { - resolve(time); - } else { - if (error_message) { - reject(new Error(error_message)) - } else { - reject(time) - } - } - },time); + let timeout_id = null + let timeout_promise = new Promise((resolve, reject)=>{ + timeout_id = setTimeout(()=>{ + if (timeout_resolves) { + resolve(time); + } else { + if (error_message) { + reject(new Error(error_message)) + } else { + reject(time) + } + } + }, time); }); + // we need both the promise and the timeout ID + // so that we can clearTimeout() if/when needed + return [timeout_promise, timeout_id] }; @@ -503,6 +482,7 @@ let LibResilientResourceInfo = class { this.values.url = url this.values.clientId = clientId // we might not have a non-empty clientId if it's a cross-origin fetch + if (clientId) { // get the client from Client API based on clientId self.clients.get(clientId).then((client)=>{ @@ -644,15 +624,34 @@ let libresilientFetch = (plugin, url, init, reqInfo) => { '\n+-- using method(s):', plugin.name ) + let timeout_promise, timeout_id + [timeout_promise, timeout_id] = promiseTimeout( + self.LibResilientConfig.defaultPluginTimeout, + false, + `LibResilient request using ${plugin.name} timed out after ${self.LibResilientConfig.defaultPluginTimeout}ms.` + ) + // race the plugin(s) vs. a timeout - return Promise.race([ - plugin.fetch(url, init), - promiseTimeout( - self.LibResilientConfig.defaultPluginTimeout, - false, - `LibResilient request using ${plugin.name} timed out after ${self.LibResilientConfig.defaultPluginTimeout}ms.` - ) - ]) + let race_promise = Promise + .race([ + plugin.fetch(url, init), + timeout_promise + ]) + + // making sure there are no dangling promises etc + // + // this should happen asynchronously + race_promise + // make sure the timeout is cancelled as soon as the promise resolves + // we do not want any dangling promises/timeouts after all! + .then(()=>{ + clearTimeout(timeout_id) + }) + // no-op to make sure we don't end up with dangling rejected premises + .catch((e)=>{}) + + // return the racing promise + return race_promise; } @@ -664,7 +663,7 @@ let libresilientFetch = (plugin, url, init, reqInfo) => { */ let callOnLibResilientPlugin = (call, args) => { // find the first plugin implementing the method - for (i=0; ip.name===reqInfo.method) + let plugin = self.LibResilientPlugins.find(p=>p.name===reqInfo.method) // if it's a stashing plugin... if (typeof plugin.stash === 'function') { @@ -801,7 +800,7 @@ let getResourceThroughLibResilient = (url, init, clientId, useStashed=true, doSt // do we want to stash? if (doStash) { // find the first stashing plugin - for (i=0; i { - event.waitUntil(initServiceWorker()) + await event.waitUntil( + initServiceWorker() + ) // "COMMIT_UNKNOWN" will be replaced with commit ID self.log('service-worker', "0. Installed LibResilient Service Worker (commit: COMMIT_UNKNOWN)."); }); @@ -924,12 +925,13 @@ self.addEventListener('fetch', async event => { // External requests go through a regular fetch() if (!event.request.url.startsWith(self.location.origin)) { - self.log('service-worker', 'External request; current origin: ' + self.location.origin) + self.log('service-worker', 'External request, using standard fetch(); current origin: ' + self.location.origin) return fetch(event.request); } // Non-GET requests go through a regular fetch() if (event.request.method !== 'GET') { + self.log('service-worker', 'Non-GET request, using standard fetch()') return fetch(event.request); }