diff --git a/__tests__/plugins/dnslink-fetch/index.test.js b/__tests__/plugins/dnslink-fetch/index.test.js new file mode 100644 index 0000000..f57f48a --- /dev/null +++ b/__tests__/plugins/dnslink-fetch/index.test.js @@ -0,0 +1,378 @@ +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", + 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.google/resolve?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.skip("it should pass the Request() init data to fetch() for all used endpoints", async () => { + + init = { + name: 'dnslink-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/dnslink-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('dnslink-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.skip("it should set the LibResilient headers", async () => { + require("../../../plugins/dnslink-fetch/index.js"); + + const response = await LibResilientPluginConstructors.get('dnslink-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('dnslink-fetch') + expect(response.headers.has('X-LibResilient-Etag')).toEqual(true) + expect(response.headers.get('X-LibResilient-ETag')).toEqual('TestingETagHeader') + }); + + test.skip("it should set the LibResilient ETag basd on Last-Modified header (if ETag is not available in the original response)", async () => { + require("../../../plugins/dnslink-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('dnslink-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('dnslink-fetch') + expect(response.headers.has('X-LibResilient-Etag')).toEqual(true) + expect(response.headers.get('X-LibResilient-ETag')).toEqual('TestingLastModifiedHeader') + }); + + test.skip("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/dnslink-fetch/index.js"); + + expect.assertions(1) + expect(LibResilientPluginConstructors.get('dnslink-fetch')(LR, init).fetch('https://resilient.is/test.json')).rejects.toThrow(Error) + }); + +}); diff --git a/plugins/dnslink-fetch/index.js b/plugins/dnslink-fetch/index.js index 9184f8e..7f105c3 100644 --- a/plugins/dnslink-fetch/index.js +++ b/plugins/dnslink-fetch/index.js @@ -44,6 +44,13 @@ // merge the defaults with settings from the init var let config = {...defaultConfig, ...init} + + // reality check: dohProvider must be a string + if (typeof(config.dohProvider) !== "string" || (config.dohProvider == '')) { + let err = new Error("dohProvider not confgured") + console.error(err) + throw err + } /** * retrieving the alternative endpoints list from dnslink @@ -73,16 +80,21 @@ } }) .then(r=>r.json()) - + + // we need an object here + if (typeof response !== 'object') { + throw new Error('Response is not a valid JSON') + } + // only Status == 0 is acceptable // https://www.iana.org/assignments/dns-parameters/dns-parameters.xhtml#dns-parameters-6 - if (response.Status != 0) { + if (!('Status' in response) || response.Status != 0) { throw new Error(`DNS request failure, status code: ${response.Status}`) } // we also do need the Answer section please - if (!('Answer' in response)) { - throw new Error(`DNS response did not contain an Answer section`) + if (!('Answer' in response) || (typeof response.Answer !== 'object') || (!Array.isArray(response.Answer))) { + throw new Error(`DNS response did not contain a valid Answer section`) } // only get TXT records, and extract the data from them @@ -122,9 +134,9 @@ // 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 - url = url.replace(/https?:\/\//, '').split('/') - var domain = url.shift() - var path = url.join('/') + urlData = url.replace(/https?:\/\//, '').split('/') + var domain = urlData.shift() + var path = urlData.join('/') LR.log(pluginName, '+-- fetching:\n', ` - domain: ${domain}\n`, ` - path: ${path}\n`