UNPKG

@specprotected/spec-proxy-service-worker

Version:

Server Worker API implementation for integrating with Spec Proxy from an Edge Worker

679 lines (621 loc) 23.1 kB
// Note that for the most part, everything has to be mocked to the // gills for tests here, so we do most of our testing in a separate // integration suite and predominantly test purer private functions // here. import { jest, describe, beforeEach, test, expect, it, beforeAll, } from "@jest/globals"; import rewire from "rewire"; import makeServiceWorkerEnv from "service-worker-mock"; import { parse as parseCookies } from "cookie"; import * as buffer from "node:buffer"; import * as stream from "node:stream/web"; import * as node_util from "node:util"; import { specProxyProcessRequest, specProxyProcessResponse, specMakeRequestWithFallback, } from "./index"; // Import the header constant for testing const HEADER_SPEC_ACTIVITY = "x-atvak-activity-count"; // Workers supply `crypto` globally, but it's not present in our testing environment // so let's fake it. import * as crypto from "crypto"; Object.defineProperty(globalThis, "crypto", { value: { randomUUID: () => crypto.randomUUID(), }, }); const TEST_URL = new URL("https://specprotected.com"); const TEST_SPEC_TRAFFIC_URL = new URL( "https://specprotected.com/spec_traffic/somewhere", ); describe("processing", () => { beforeEach(() => { // temporary workaround until https://github.com/nodejs/node/pull/46615#issuecomment-1604484636 // lands in node 18.x delete global.location; delete global.performance; delete global.navigator; // delete global.self; Object.assign(global, makeServiceWorkerEnv()); Object.assign(globalThis, { Blob: buffer.Blob, File: buffer.File, TextDecoder: node_util.TextDecoder, TextEncoder: node_util.TextEncoder, ReadableStream: stream.ReadableStream, TransformStream: stream.TransformStream, WritableStream: stream.WritableStream, }); // blow away the Promise from Fetch API global.fetch = (v: any) => v; // create a fake version of the ExtendableEvent to use, // also doesn't use Promises global.simple_event = { waitUntil: jest.fn((v: any) => v), request: new Request("https://spectrust.com", { method: "GET", headers: { "X-Forwarded-For": "127.0.0.1", Host: "spectrust.com", }, // ReadableStream is difficult to mock so can't test mirroring // which modifies the body object... // we also have to set it to null because service-worker-mock uses // a garbage implementation of this when it's not provided and disrupts // our use of the tee() function body: null, }), }; }); test("disabled proxy is noop", () => { expect( specProxyProcessRequest(global.simple_event, { disableSpecProxy: true }) .request, ).toBe(global.simple_event.request); expect( specProxyProcessRequest(global.simple_event, { disableSpecProxy: false }) .request, ).not.toBe(global.simple_event.request); }); test("traffic split", () => { expect( specProxyProcessRequest(global.simple_event, { percentageOfIPs: 0 }) .request, ).toBe(global.simple_event.request); expect( specProxyProcessRequest(global.simple_event, { percentageOfIPs: 100 }) .request, ).not.toBe(global.simple_event.request); }); test("proxy forks request in mirror split mode", () => { expect( specProxyProcessRequest(global.simple_event, { splitExchangeMirroring: true, }).request, ).toEqual(global.simple_event.request); expect(global.simple_event.waitUntil.mock.calls.length).toEqual(1); }); // Note: libraries that make the request will change the Host header // for us. Cloudflare does this automatically, Fastly does this through // the use of the `backend` configuration property on the request. test("in mirror split mode, Host header of mirrored request is unchanged", () => { expect( specProxyProcessRequest(global.simple_event, { splitExchangeMirroring: true, }).request, ).toEqual(global.simple_event.request); let specProxyRequest = global.simple_event.waitUntil.mock.calls[0][0]; console.log(specProxyRequest.headers, global.simple_event.request.headers); expect(specProxyRequest.headers.get("host")).toEqual( global.simple_event.request.headers.get("host"), ); }); test("proxy replaces request in inline mode", () => { let request = specProxyProcessRequest(global.simple_event, { inlineMode: true, }).request; expect(request).not.toEqual(global.simple_event.request); expect(request.url).toEqual("https://spectrust.com.spec-internal.com/"); expect(global.simple_event.waitUntil.mock.calls.length).toEqual(0); }); test("sending traffic to /spec_traffic in inline mode goes to spec proxy", () => { let event = global.simple_event; event.request.url = "https://spectrust.com/spec_traffic/somewhere"; let request = specProxyProcessRequest(global.simple_event, { inlineMode: true, }).request; expect(request.url).toEqual( "https://spectrust.com.spec-internal.com/spec_traffic/somewhere", ); expect(global.simple_event.waitUntil.mock.calls.length).toEqual(0); }); test("sending traffic to /spec_traffic in mirror mode goes to spec proxy", () => { let event = global.simple_event; event.request.url = "https://spectrust.com/spec_traffic/somewhere"; let request = specProxyProcessRequest(global.simple_event, { inlineMode: false, }).request; expect(request.url).toEqual( "https://spectrust.com.spec-internal.com/spec_traffic/somewhere", ); expect(global.simple_event.waitUntil.mock.calls.length).toEqual(0); }); test("sending traffic to /spec_traffic when it's disabled forwards traffic in mirror mode", () => { let event = global.simple_event; event.request.url = "https://spectrust.com/spec_traffic/somewhere"; let request = specProxyProcessRequest(global.simple_event, { disableSpecTraffic: true, splitExchangeMirroring: true, }).request; expect(request).toEqual(global.simple_event.request); expect(global.simple_event.waitUntil.mock.calls.length).toEqual(1); }); }); //--------------------------------------------- // Private Function Testing //--------------------------------------------- // "rewire" the library module to allow us to reach private functions let spec = rewire("./dist/index.js"); describe("shouldHandleRequest", () => { let shouldHandleRequest = spec.__get__("shouldHandleRequest"); test.each([ ["0.0.0.0", undefined, true], ["0.0.0.40", undefined, true], ["0.0.0.99", undefined, true], ["0.0.0.100", undefined, true], // "wraps" to 0 ["0.0.0.0", 100, true], ["0.0.0.40", 100, true], ["0.0.0.99", 100, true], ["0.0.0.0", 100, true], ["0.0.0.40", 50, true], ["0.0.0.49", 50, true], ["0.0.0.50", 50, false], ["0.0.0.60", 50, false], ["0.0.0.0", 0, false], ["0.0.0.40", 0, false], ["0.0.0.99", 0, false], ["0.0.0.100", 0, false], // some legit-looking IPs that don't exploit our algorithm for testing ["24.68.195.11", 50, false], ["74.232.255.255", 50, true], ["89.2.79.2", 50, false], ["67.67.67.67", 50, false], ["10.0.0.8", 50, true], ])("traffic split [ip=%s, split=%i]", (ip, split, expected) => { let headers = new Headers({ "x-forwarded-for": ip }); expect( shouldHandleRequest(TEST_URL, headers, { percentageOfIPs: split, }), ).toEqual(expected); }); test.each([ ["0.0.0.100", undefined, true], // "wraps" to 0 ["0.0.0.99", 100, true], ["0.0.0.0", 100, true], ["0.0.0.49", 50, true], ["0.0.0.50", 50, true], ["0.0.0.60", 50, true], ["0.0.0.0", 0, true], ["0.0.0.40", 0, true], ["0.0.0.99", 0, true], ["0.0.0.100", 0, true], // some legit-looking IPs that don't exploit our algorithm for testing ["24.68.195.11", 50, true], ["74.232.255.255", 50, true], ["89.2.79.2", 50, true], ["67.67.67.67", 50, true], ["10.0.0.8", 50, true], ])( "traffic split [ip=%s, split=%i], always send to /spec_traffic when enabled", (ip, split, expected) => { let headers = new Headers({ "x-forwarded-for": ip }); expect( shouldHandleRequest(TEST_SPEC_TRAFFIC_URL, headers, { percentageOfIPs: split, }), ).toEqual(expected); }, ); test.each([ ["0.0.0.100", undefined, true], // "wraps" to 0 ["0.0.0.99", 100, true], ["0.0.0.0", 100, true], ["0.0.0.49", 50, true], ["0.0.0.50", 50, false], ["0.0.0.60", 50, false], ["0.0.0.0", 0, false], ["0.0.0.40", 0, false], ["0.0.0.99", 0, false], ["0.0.0.100", 0, false], // some legit-looking IPs that don't exploit our algorithm for testing ["24.68.195.11", 50, false], ["74.232.255.255", 50, true], ["89.2.79.2", 50, false], ["67.67.67.67", 50, false], ["10.0.0.8", 50, true], ])( "traffic split [ip=%s, split=%i], normal behavior when /spec_traffic disabled", (ip, split, expected) => { let headers = new Headers({ "x-forwarded-for": ip }); expect( shouldHandleRequest(TEST_SPEC_TRAFFIC_URL, headers, { percentageOfIPs: split, disableSpecTraffic: true, }), ).toEqual(expected); }, ); test.each([ ["X-Forwarded-For", 50, "0.0.0.0", true], ["X-Forwarded-For", 50, "0.0.0.60", false], ["Not-Checked-Header", 50, "0.0.0.0", false], ["Not-Checked-Header", 50, "0.0.0.99", false], // if it's 100% split, we expect to handle the request even if we can't find the // IP address on this header ["X-Forwarded-For", 100, "0.0.0.60", true], ["Not-Checked-Header", 100, "0.0.0.99", true], ])( 'ip from header "%s" with %i%% split for ip %s"', (header, split, ip, expected) => { let headers = new Headers({}); headers.set(header, ip); expect( shouldHandleRequest(TEST_URL, headers, { percentageOfIPs: split, }), ).toEqual(expected); }, ); // Check NaN handling, just in case. test.each([ ["X-Forwarded-For", 50, "wtf", false], ["X-Forwarded-For", 99, "srs", false], // if it's 100% split, we expect to handle the request even if we can't find the // IP address on this header ["X-Forwarded-For", 100, "susfam", true], ["X-Forwarded-For", 100, ":ghost-shrug:", true], ])( 'ip from header "%s" with %i%% split for ip %s"', (header, split, ip, expected) => { let headers = new Headers({}); headers.set(header, ip); expect( shouldHandleRequest(TEST_URL, headers, { percentageOfIPs: split, }), ).toEqual(expected); }, ); }); describe("extractTopLevelDomain", () => { let extractTopLevelDomain = spec.__get__("extractTopLevelDomain"); test.each([ ["spec-trust.com", "spec-trust.com"], ["somewhere.spec-trust.com", "spec-trust.com"], ["somewhere.spec-trust.com.spec-internal.com", "spec-trust.com"], ["somewhere.spec-trust.edu", "spec-trust.edu"], ["somewhere.spec-trust.edu.spec-internal.com", "spec-trust.edu"], ["somewhere.spec-trust.gov", "spec-trust.gov"], ["somewhere.spec-trust.gov.spec-internal.com", "spec-trust.gov"], ["somewhere.spec-trust.int", "spec-trust.int"], ["somewhere.spec-trust.int.spec-internal.com", "spec-trust.int"], ["somewhere.spec-trust.net", "spec-trust.net"], ["somewhere.spec-trust.net.spec-internal.com", "spec-trust.net"], ["somewhere.spec-trust.org", "spec-trust.org"], ["somewhere.spec-trust.org.spec-internal.com", "spec-trust.org"], ["somewhere.spec-trust.uk", "spec-trust.uk"], ["somewhere.spec-trust.uk.spec-internal.com", "spec-trust.uk"], ["many.many.many.many.sub-domains.spec-trust.com", "spec-trust.com"], ["any.suffixes", "any.suffixes"], // if we don't match any known top-level domain suffix, we just get the whole thing [ "subdomains.irrelevant.any.suffixes", "subdomains.irrelevant.any.suffixes", ], ["invalid-top-level-though", "invalid-top-level-though"], // invalid top-level domain suffix, but with spec-internal on the end winds up // matching to spec-internal ["somewhere.fun.to.go.spec-internal.com", "spec-internal.com"], ["staging.special.somebody.co.uk.spec-internal.com", "somebody.co.uk"], ["staging.somebody.ac.uk.spec-internal.com", "somebody.ac.uk"], ["staging.somebody.co.uk.spec-internal.com", "somebody.co.uk"], ["staging.somebody.gov.uk.spec-internal.com", "somebody.gov.uk"], ["staging.somebody.ltd.uk.spec-internal.com", "somebody.ltd.uk"], ["staging.somebody.me.uk.spec-internal.com", "somebody.me.uk"], ["staging.somebody.net.uk.spec-internal.com", "somebody.net.uk"], ["staging.somebody.nhs.uk.spec-internal.com", "somebody.nhs.uk"], ["staging.somebody.plc.uk.spec-internal.com", "somebody.plc.uk"], ["staging.somebody.police.uk.spec-internal.com", "somebody.police.uk"], ])("extract top level domain from %s", (host, expected) => { let headers = new Headers({ Host: host }); expect(extractTopLevelDomain(headers)).toEqual(expected); }); }); describe("makeSpecUrl", () => { let makeSpecUrl = spec.__get__("makeSpecUrl"); let MessageType = spec.__get__("MessageType"); test.each([ [ "inline mode redirects to spec-internal w/unchanged path & query string", "https://www.customer.com/foobar?hello=world", MessageType.Request, { inlineMode: true }, "https://www.customer.com.spec-internal.com/foobar?hello=world", ], [ "default config (mirror mode) sends request to exchange endpoint", "https://www.customer.com/foobar?hello=world", MessageType.Request, {}, "https://www.customer.com.spec-internal.com/speclayer/api/mirror/exchanges", ], [ "default config (mirror mode) sends response to exchange endpoint", "https://www.customer.com/foobar?hello=world", MessageType.Response, {}, "https://www.customer.com.spec-internal.com/speclayer/api/mirror/exchanges", ], [ "split processing (mirror mode) sends request to request endpoint", "https://www.customer.com/foobar?hello=world", MessageType.Request, { splitExchangeMirroring: true }, "https://www.customer.com.spec-internal.com/speclayer/api/mirror/requests", ], [ "split processing (mirror mode) sends response to response endpoint", "https://www.customer.com/foobar?hello=world", MessageType.Response, { splitExchangeMirroring: true }, "https://www.customer.com.spec-internal.com/speclayer/api/mirror/responses", ], [ "domainOverride is applied if present", "https://www.customer.com/foobar?hello=world", MessageType.Request, { domainOverride: "alternative.com" }, "https://alternative.com.spec-internal.com/speclayer/api/mirror/exchanges", ], [ "localDev always directs to localhost:5000", "https://www.customer.com/foobar?hello=world", MessageType.Request, { localDev: true }, "http://localhost:5000/speclayer/api/mirror/exchanges", ], [ "we don't gaf about incoming protocol, it's always https", "http://www.customer.com/foobar?hello=world", MessageType.Request, {}, "https://www.customer.com.spec-internal.com/speclayer/api/mirror/exchanges", ], [ "we don't alter the path for spec-traffic", "http://www.customer.com/spec_traffic/foobar?hello=world", MessageType.Request, {}, "https://www.customer.com.spec-internal.com/spec_traffic/foobar?hello=world", ], [ "we don't change the domain if it already points to us", "http://www.customer.com.spec-internal.com/spec_traffic/foobar?hello=world", MessageType.Request, {}, "https://www.customer.com.spec-internal.com/spec_traffic/foobar?hello=world", ], ])("%s", (_caseName, url, messageType, config, exp) => { let specUrl = makeSpecUrl(new URL(url), config, messageType); expect(specUrl.toString()).toEqual(exp); }); }); describe("setCommonSpecHeaders", () => { let setCommonSpecHeaders = spec.__get__("setCommonSpecHeaders"); let setForwardingSpecHeaders = spec.__get__("setForwardingSpecHeaders"); let setMirrorRequestHeaders = spec.__get__("setMirrorRequestHeaders"); let setMirrorResponseHeaders = spec.__get__("setMirrorResponseHeaders"); let SPEC_HEADER_CUSTOMER_KEY = spec.__get__("SPEC_HEADER_CUSTOMER_KEY"); const FIXED_TS = "2026-04-30T00:00:00.000Z"; beforeAll(() => { const FixedDate = class extends Date { toISOString() { return FIXED_TS; } }; spec.__set__("Date", FixedDate); }); afterAll(() => { spec.__set__("Date", Date); }); test.each([ [ "sets customer key if present", new Headers(), { customerKey: "s3cr37" }, new Headers({ [SPEC_HEADER_CUSTOMER_KEY]: "s3cr37", "x-spec-origination-ts": FIXED_TS, }), ], [ "overrides any existing customer key if present", new Headers({ [SPEC_HEADER_CUSTOMER_KEY]: "f4k3rz" }), { customerKey: "s3cr37" }, new Headers({ [SPEC_HEADER_CUSTOMER_KEY]: "s3cr37", "x-spec-origination-ts": FIXED_TS, }), ], [ "does nothing if no customer key present", new Headers(), {}, new Headers({ "x-spec-origination-ts": FIXED_TS }), ], ])("%s", (_caseName, headers, config, expHeaders) => { setCommonSpecHeaders(headers, config); // can't use direct equality, can't convert to object, but this works. expect([...headers.entries()]).toEqual([...expHeaders.entries()]); }); }); describe("setForwardingSpecHeaders", () => { let setForwardingSpecHeaders = spec.__get__("setForwardingSpecHeaders"); let SPEC_HEADER_FORWARD_ORIGIN = spec.__get__("SPEC_HEADER_FORWARD_ORIGIN"); test.each([ [ "sets fwd-origin header to URL hostname", new Headers(), "https://customer.com/hewooo", {}, new Headers({ [SPEC_HEADER_FORWARD_ORIGIN]: "customer.com" }), ], [ "overrides fwd-origin header if present", new Headers({ [SPEC_HEADER_FORWARD_ORIGIN]: "fakerz.com" }), "https://customer.com/hewooo", {}, new Headers({ [SPEC_HEADER_FORWARD_ORIGIN]: "customer.com" }), ], [ "ignores port if present in URL", new Headers(), "https://customer.com:5000/hewooo", {}, new Headers({ [SPEC_HEADER_FORWARD_ORIGIN]: "customer.com" }), ], [ "includes http & port for localhost and localdev", new Headers(), "https://localhost:5000/hewooo", { localDev: true }, new Headers({ [SPEC_HEADER_FORWARD_ORIGIN]: "http://localhost:5000" }), ], ])(`makeSpecUrl: %s`, (_caseName, headers, url, config, expHeaders) => { setForwardingSpecHeaders(headers, new URL(url), config); expect([...headers.entries()]).toEqual([...expHeaders.entries()]); }); }); describe("setMirrorRequestHeaders", () => { let setMirrorRequestHeaders = spec.__get__("setMirrorRequestHeaders"); let SPEC_MIRROR_HEADER_REQ_METHOD = spec.__get__( "SPEC_MIRROR_HEADER_REQ_METHOD", ); let SPEC_MIRROR_HEADER_REQ_URI = spec.__get__("SPEC_MIRROR_HEADER_REQ_URI"); let SPEC_MIRROR_HEADER_REQ_CONTENT_LENGTH = spec.__get__( "SPEC_MIRROR_HEADER_REQ_CONTENT_LENGTH", ); // Allow us to make a Request Object.assign(global, makeServiceWorkerEnv()); test.each([ [ "sets request method & uri", new Headers(), new Request("https://www.customer.com/foo?hello=world"), new Headers({ [SPEC_MIRROR_HEADER_REQ_METHOD]: "GET", [SPEC_MIRROR_HEADER_REQ_URI]: "https://www.customer.com/foo?hello=world", }), ], [ "sets content length if present ", new Headers(), new Request("https://www.customer.com/foo?hello=world", { headers: { "content-length": "50" }, }), new Headers({ [SPEC_MIRROR_HEADER_REQ_CONTENT_LENGTH]: "50", [SPEC_MIRROR_HEADER_REQ_METHOD]: "GET", [SPEC_MIRROR_HEADER_REQ_URI]: "https://www.customer.com/foo?hello=world", }), ], [ "overrides all headers if already present", new Headers({ [SPEC_MIRROR_HEADER_REQ_CONTENT_LENGTH]: "0", [SPEC_MIRROR_HEADER_REQ_METHOD]: "POST", [SPEC_MIRROR_HEADER_REQ_URI]: "https://www.fakerz.com/foo?hello=world", }), new Request("https://www.customer.com/foo?hello=world", { headers: { "content-length": "50" }, }), new Headers({ [SPEC_MIRROR_HEADER_REQ_CONTENT_LENGTH]: "50", [SPEC_MIRROR_HEADER_REQ_METHOD]: "GET", [SPEC_MIRROR_HEADER_REQ_URI]: "https://www.customer.com/foo?hello=world", }), ], ])("%s", (_caseName, headers, req, expHeaders) => { setMirrorRequestHeaders(headers, req); // can't use direct equality, can't convert to object, but this works. expect([...headers.entries()]).toEqual([...expHeaders.entries()]); }); }); describe("setMirrorResponseHeaders", () => { let setMirrorResponseHeaders = spec.__get__("setMirrorResponseHeaders"); let SPEC_MIRROR_HEADER_RESP_STATUS = spec.__get__( "SPEC_MIRROR_HEADER_RESP_STATUS", ); // Allow us to make a Response Object.assign(global, makeServiceWorkerEnv()); test.each([ [ "sets response status header", new Headers(), new Response(null, { status: 200 }), new Headers({ [SPEC_MIRROR_HEADER_RESP_STATUS]: "200" }), ], [ "overrides response status header if present", new Headers({ [SPEC_MIRROR_HEADER_RESP_STATUS]: "302" }), new Response(null, { status: 200 }), new Headers({ [SPEC_MIRROR_HEADER_RESP_STATUS]: "200" }), ], [ "assigns other response headers from response", new Headers(), new Response(null, { status: 200, headers: { "content-length": "50", "x-foo": "foo" }, }), new Headers({ [SPEC_MIRROR_HEADER_RESP_STATUS]: "200", "x-spec-resp-head-content-length": "50", "x-spec-resp-head-x-foo": "foo", }), ], [ "strips and overrides any headers from request with our prefix", new Headers({ "x-spec-resp-head-content-length": "0", "x-spec-resp-head-fake": "fake", "x-spec-resp-head-x-foo": "foo", "x-spec-resp-head": "root removed", }), new Response(null, { status: 200, headers: { "content-length": "50", "x-foo": "foo" }, }), new Headers({ [SPEC_MIRROR_HEADER_RESP_STATUS]: "200", "x-spec-resp-head-content-length": "50", "x-spec-resp-head-x-foo": "foo", }), ], ])(`makeSpecUrl: %s`, (_caseName, headers, resp, expHeaders) => { setMirrorResponseHeaders(headers, resp); expect([...headers.entries()]).toEqual([...expHeaders.entries()]); }); });