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) }); });