From 52a62f13f653320e17169f4658590df19b44c668 Mon Sep 17 00:00:00 2001 From: nuno <59452877+komen205@users.noreply.github.com> Date: Thu, 11 Jun 2026 16:20:43 +0100 Subject: [PATCH 1/3] Add a 'header value containing' matcher for substring header matching The existing 'with headers including' matcher requires the entire header value to match exactly, which makes it impossible to match one cookie within a multi-cookie Cookie header. This adds a new matcher that checks whether a given header's value contains a given substring, built on Mockttp's callback matcher so it needs no server-side changes. Co-Authored-By: Claude Fable 5 --- src/components/modify/matcher-config.tsx | 92 +++++++++++++++++++ .../definitions/http-rule-definitions.ts | 46 +++++++++- src/model/rules/rule-descriptions.ts | 2 + .../model/header-contains-matcher.spec.ts | 79 ++++++++++++++++ 4 files changed, 218 insertions(+), 1 deletion(-) create mode 100644 test/unit/model/header-contains-matcher.spec.ts diff --git a/src/components/modify/matcher-config.tsx b/src/components/modify/matcher-config.tsx index f1dec1e4..f1b1702b 100644 --- a/src/components/modify/matcher-config.tsx +++ b/src/components/modify/matcher-config.tsx @@ -28,6 +28,9 @@ import { isHiddenMatcherKey } from '../../model/rules/rules'; import { summarizeMatcherClass } from '../../model/rules/rule-descriptions'; +import { + HeaderContainsMatcher +} from '../../model/rules/definitions/http-rule-definitions'; import { WebSocketMethodMatcher } from '../../model/rules/definitions/websocket-rule-definitions'; @@ -163,6 +166,8 @@ export function AdditionalMatcherConfiguration(props: return ; case 'header': return ; + case 'header-contains': + return ; case 'raw-body': return ; case 'raw-body-includes': @@ -826,6 +831,93 @@ class HeaderMatcherConfig extends MatcherConfig { } } +const HeaderContainsConfigRow = styled.div` + display: flex; + flex-direction: row; + + > :first-child { + flex: 1 1 40%; + margin-right: 5px; + } + + > :last-child { + flex: 1 1 60%; + } +`; + +@observer +class HeaderContainsMatcherConfig extends MatcherConfig { + + private fieldId = _.uniqueId(); + + @observable + private headerName = ''; + + @observable + private headerValue = ''; + + componentDidMount() { + disposeOnUnmount(this, autorun(() => { + const headerName = this.props.matcher?.headerName ?? ''; + const headerValue = this.props.matcher?.headerValue ?? ''; + + runInAction(() => { + this.headerName = headerName; + this.headerValue = headerValue; + }); + })); + } + + render() { + const { matcherIndex } = this.props; + + return + { matcherIndex !== undefined && + + { matcherIndex !== 0 && 'and ' } with a header value containing + + } + + + + + ; + } + + @action.bound + onNameChange(event: React.ChangeEvent) { + this.headerName = event.target.value; + this.updateMatcher(); + } + + @action.bound + onValueChange(event: React.ChangeEvent) { + this.headerValue = event.target.value; + this.updateMatcher(); + } + + private updateMatcher() { + if (this.headerName && this.headerValue) { + this.props.onChange(new HeaderContainsMatcher(this.headerName, this.headerValue)); + } else { + this.props.onInvalidState(); + } + } +} + const BodyContainer = styled.div<{ error?: boolean }>` > div { border-radius: 4px; diff --git a/src/model/rules/definitions/http-rule-definitions.ts b/src/model/rules/definitions/http-rule-definitions.ts index 8c8d59e2..202cf207 100644 --- a/src/model/rules/definitions/http-rule-definitions.ts +++ b/src/model/rules/definitions/http-rule-definitions.ts @@ -77,6 +77,48 @@ export class AmIUsingMatcher extends httpMatchers.RegexPathMatcher { } } +// Matches requests whose given header's value contains the given substring. Useful +// for cases that full-value header matching can't handle, like matching one cookie +// within a Cookie header. Built on Mockttp's callback matcher, so the actual +// matching runs here in the UI, called back from the server for each request. +export class HeaderContainsMatcher extends httpMatchers.CallbackMatcher { + + readonly uiType = 'header-contains'; + + constructor( + public readonly headerName: string, + public readonly headerValue: string + ) { + // Headers are always keyed lowercase in Mockttp's request data: + const lowerCasedName = headerName.toLowerCase(); + + super((request) => { + const headerOrHeaders = request.headers[lowerCasedName]; + if (!headerOrHeaders) return false; + + return ( + Array.isArray(headerOrHeaders) + ? headerOrHeaders + : [headerOrHeaders] + ).some((value) => value.includes(headerValue)); + }); + } + + explain() { + return `with a '${this.headerName}' header containing ${JSON.stringify(this.headerValue)}`; + } +} + +serializr.createModelSchema(HeaderContainsMatcher, { + uiType: serializeAsTag(() => 'header-contains'), + type: serializr.primitive(), + headerName: serializr.primitive(), + headerValue: serializr.primitive() +}, (context) => new HeaderContainsMatcher( + context.json.headerName, + context.json.headerValue +)); + export class StaticResponseStep extends httpSteps.FixedResponseStep { explain() { return `respond with status ${this.status}${ @@ -363,7 +405,9 @@ export const HttpMatcherLookup = { 'wildcard': WildcardMatcher, // Add special types for our built-in matcher explanation overrides: 'default-wildcard': DefaultWildcardMatcher, - 'am-i-using': AmIUsingMatcher + 'am-i-using': AmIUsingMatcher, + // Our own UI-only matcher types, built on Mockttp's callback matcher: + 'header-contains': HeaderContainsMatcher }; export const HttpInitialMatcherClasses = [ diff --git a/src/model/rules/rule-descriptions.ts b/src/model/rules/rule-descriptions.ts index 2e452043..ecd478e2 100644 --- a/src/model/rules/rule-descriptions.ts +++ b/src/model/rules/rule-descriptions.ts @@ -47,6 +47,8 @@ export function summarizeMatcherClass(key: MatcherClassKey): string { return "With exact query string"; case 'header': return "Including headers"; + case 'header-contains': + return "With a header value containing"; case 'cookie': return "With cookie"; case 'raw-body': diff --git a/test/unit/model/header-contains-matcher.spec.ts b/test/unit/model/header-contains-matcher.spec.ts new file mode 100644 index 00000000..d6eb26e5 --- /dev/null +++ b/test/unit/model/header-contains-matcher.spec.ts @@ -0,0 +1,79 @@ +import * as serializr from 'serializr'; + +import { expect } from '../../test-setup'; + +import { HeaderContainsMatcher } from '../../../src/model/rules/definitions/http-rule-definitions'; + +const requestWithHeaders = (headers: { [key: string]: string | string[] }) => + ({ headers }) as any; + +describe("Header-contains matcher", () => { + + it("matches a substring within a single-value header", async () => { + const matcher = new HeaderContainsMatcher('Cookie', 'iamSessionIdExpiryDateInUtc'); + + expect(await matcher.callback(requestWithHeaders({ + 'cookie': 'a=1; iamSessionIdExpiryDateInUtc=2026-06-12T00:00:00Z; b=2' + }))).to.equal(true); + }); + + it("does not match if the header value doesn't contain the text", async () => { + const matcher = new HeaderContainsMatcher('Cookie', 'iamSessionIdExpiryDateInUtc'); + + expect(await matcher.callback(requestWithHeaders({ + 'cookie': 'a=1; b=2' + }))).to.equal(false); + }); + + it("does not match if the header isn't present at all", async () => { + const matcher = new HeaderContainsMatcher('Cookie', 'iamSessionIdExpiryDateInUtc'); + + expect(await matcher.callback(requestWithHeaders({ + 'host': 'example.com' + }))).to.equal(false); + }); + + it("matches header names case-insensitively", async () => { + const matcher = new HeaderContainsMatcher('X-Custom-Header', 'value'); + + // Mockttp always provides headers keyed lowercase: + expect(await matcher.callback(requestWithHeaders({ + 'x-custom-header': 'some-value-here' + }))).to.equal(true); + }); + + it("matches multi-value headers if any value contains the text", async () => { + const matcher = new HeaderContainsMatcher('Set-Cookie', 'session'); + + expect(await matcher.callback(requestWithHeaders({ + 'set-cookie': ['other=1', 'session=abc'] + }))).to.equal(true); + }); + + it("serializes and deserializes back to a working matcher", async () => { + const matcher = new HeaderContainsMatcher('Cookie', 'mySession'); + + const data = serializr.serialize(matcher); + + expect(data).to.deep.equal({ + uiType: 'header-contains', + type: 'callback', + headerName: 'Cookie', + headerValue: 'mySession' + }); + + const restored = serializr.deserialize(HeaderContainsMatcher, data); + + expect(restored.headerName).to.equal('Cookie'); + expect(restored.headerValue).to.equal('mySession'); + expect(restored.explain()).to.equal(matcher.explain()); + + expect(await restored.callback(requestWithHeaders({ + 'cookie': 'mySession=123' + }))).to.equal(true); + expect(await restored.callback(requestWithHeaders({ + 'cookie': 'other=123' + }))).to.equal(false); + }); + +}); From cde486b35a4fb800369c788478dde9f48b1cbac4 Mon Sep 17 00:00:00 2001 From: nuno <59452877+komen205@users.noreply.github.com> Date: Thu, 11 Jun 2026 16:44:19 +0100 Subject: [PATCH 2/3] Use mockttp's new header-includes matcher instead of a callback matcher Replaces the UI-side callback-based matcher with the proper pattern: a real 'header-includes' matcher implemented in mockttp itself, evaluated server-side like all other matchers. This removes the need for the UI to stay connected for matching, and the per-request round-trip. Uses a locally-built mockttp 4.4.2 until that change is released. Co-Authored-By: Claude Fable 5 --- package-lock.json | 179 ++++++++++++++++-- package.json | 2 +- src/components/modify/matcher-config.tsx | 115 ++--------- .../definitions/http-rule-definitions.ts | 46 +---- src/model/rules/rule-descriptions.ts | 4 +- .../model/header-contains-matcher.spec.ts | 79 -------- .../model/header-includes-matcher.spec.ts | 105 ++++++++++ 7 files changed, 290 insertions(+), 240 deletions(-) delete mode 100644 test/unit/model/header-contains-matcher.spec.ts create mode 100644 test/unit/model/header-includes-matcher.spec.ts diff --git a/package-lock.json b/package-lock.json index 1c8518e6..8b0ce897 100644 --- a/package-lock.json +++ b/package-lock.json @@ -80,7 +80,7 @@ "mobx-shallow-undo": "^1.0.0", "mobx-utils": "^5.1.0", "mockrtc": "^0.5.0", - "mockttp": "^4.4.0", + "mockttp": "file:../mockttp/mockttp-4.4.2.tgz", "monaco-editor": "^0.27.0", "node-forge": "^1.4.0", "openapi-directory": "^1.3.0", @@ -2541,9 +2541,9 @@ } }, "node_modules/@httptoolkit/httpolyglot": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@httptoolkit/httpolyglot/-/httpolyglot-3.0.1.tgz", - "integrity": "sha512-fU9psZBRc49PfdrxFZ8W19SwVQ4+rrc0EYVxmy7H41y6/elaIu5p68wly4uLVt1DPD0K92MdN65GqP3N1ofZKg==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@httptoolkit/httpolyglot/-/httpolyglot-3.1.0.tgz", + "integrity": "sha512-Y+1gebmcMZMjDepn2e+9TBM4D+t3jYYbOwbXL9/PKmJEcaN/sbpv/00skN9KLXs43+BQvHTZc+5J0AnC5+9qDg==", "license": "MIT", "dependencies": { "@types/node": "*" @@ -13213,21 +13213,22 @@ } }, "node_modules/mockttp": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/mockttp/-/mockttp-4.4.0.tgz", - "integrity": "sha512-aDZHIFr++a4QJQdZFKEwSJacAJBGVd4OigNM7emhtD5QAo3NvxS1UoSKi9XkSXCWlNwsppVh/ylT4NnSdPCIUg==", + "version": "4.4.2", + "resolved": "file:../mockttp/mockttp-4.4.2.tgz", + "integrity": "sha512-CIgqi/11soPR/K5B+hRjYGebYGXjHhyLVhsT0BQbYpJesCX2t9aE+5HMf/Dv39Q4u7Ke06NXkIyawGMmqaiBUw==", "license": "Apache-2.0", "dependencies": { "@graphql-tools/schema": "^10.0.31", "@graphql-tools/utils": "^11.0.0", - "@httptoolkit/httpolyglot": "^3.0.1", + "@httptoolkit/httpolyglot": "^3.1.0", "@httptoolkit/subscriptions-transport-ws": "^0.11.2", "@httptoolkit/util": "^0.1.7", "@httptoolkit/websocket-stream": "^6.0.1", "@peculiar/asn1-cms": "<2.7.0", "@peculiar/asn1-csr": "<2.7.0", "@peculiar/asn1-ecc": "<2.7.0", - "@peculiar/asn1-pkcs8": "^2.5.0 <2.7.0", + "@peculiar/asn1-pfx": "<2.7.0", + "@peculiar/asn1-pkcs8": "<2.7.0", "@peculiar/asn1-pkcs9": "<2.7.0", "@peculiar/asn1-rsa": "<2.7.0", "@peculiar/asn1-schema": "^2.3.15 <2.7.0", @@ -13291,6 +13292,80 @@ "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, + "node_modules/mockttp/node_modules/@peculiar/asn1-pfx": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.6.1.tgz", + "integrity": "sha512-nB5jVQy3MAAWvq0KY0R2JUZG8bO/bTLpnwyOzXyEh/e54ynGTatAR+csOnXkkVD9AFZ2uL8Z7EV918+qB1qDvw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.6.1", + "@peculiar/asn1-pkcs8": "^2.6.1", + "@peculiar/asn1-rsa": "^2.6.1", + "@peculiar/asn1-schema": "^2.6.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/mockttp/node_modules/@peculiar/asn1-pfx/node_modules/@peculiar/asn1-cms": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.7.0.tgz", + "integrity": "sha512-hew63shtzzvBcSHbhm+cyAmKe6AIfinT9hzEqSPjDC6opTTMKmTkQ0gHuN2KsWlvqiKw1S/fS94fhag/FJkioQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/asn1-x509": "^2.7.0", + "@peculiar/asn1-x509-attr": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/mockttp/node_modules/@peculiar/asn1-pfx/node_modules/@peculiar/asn1-rsa": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.7.0.tgz", + "integrity": "sha512-/qvENQrXyTZURjMqSeofHul0JJt2sNSzSwk36pl2olkHbaioMQgrASDZAlHXl0xUlnVbHj0uGgOrBMTb5x2aJQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/asn1-x509": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/mockttp/node_modules/@peculiar/asn1-pfx/node_modules/@peculiar/asn1-schema": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.7.0.tgz", + "integrity": "sha512-W8ZfWzLmQnrcky+eh3tni4IozMdqBDiHWU0N+vve/UGjMaUs8c0L7A2oEdkBXS8rTpWDpK/aoI3DG/L/hxmxPg==", + "license": "MIT", + "dependencies": { + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/mockttp/node_modules/@peculiar/asn1-pfx/node_modules/@peculiar/asn1-x509": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.7.0.tgz", + "integrity": "sha512-mUn9RRrkGDnG4ALfunDmzyRW5dg+sWCj/pfnCCqEHYbkGxEpvUt6iVJv8Yw1cyp6SWZ26ZE5oSmI5SqEaen15g==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/mockttp/node_modules/@peculiar/asn1-pfx/node_modules/@peculiar/asn1-x509-attr": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.7.0.tgz", + "integrity": "sha512-NS8e7SOgXipkzUPLF/sce7ukpMpWjhxYsH0n6Y+bHYo4TTxOb95Zv7hqwSuL212mj5YxovjdOKQOgH1As3E94w==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/asn1-x509": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, "node_modules/mockttp/node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -22870,9 +22945,9 @@ } }, "@httptoolkit/httpolyglot": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@httptoolkit/httpolyglot/-/httpolyglot-3.0.1.tgz", - "integrity": "sha512-fU9psZBRc49PfdrxFZ8W19SwVQ4+rrc0EYVxmy7H41y6/elaIu5p68wly4uLVt1DPD0K92MdN65GqP3N1ofZKg==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@httptoolkit/httpolyglot/-/httpolyglot-3.1.0.tgz", + "integrity": "sha512-Y+1gebmcMZMjDepn2e+9TBM4D+t3jYYbOwbXL9/PKmJEcaN/sbpv/00skN9KLXs43+BQvHTZc+5J0AnC5+9qDg==", "requires": { "@types/node": "*" } @@ -30846,20 +30921,20 @@ } }, "mockttp": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/mockttp/-/mockttp-4.4.0.tgz", - "integrity": "sha512-aDZHIFr++a4QJQdZFKEwSJacAJBGVd4OigNM7emhtD5QAo3NvxS1UoSKi9XkSXCWlNwsppVh/ylT4NnSdPCIUg==", + "version": "file:../mockttp/mockttp-4.4.2.tgz", + "integrity": "sha512-CIgqi/11soPR/K5B+hRjYGebYGXjHhyLVhsT0BQbYpJesCX2t9aE+5HMf/Dv39Q4u7Ke06NXkIyawGMmqaiBUw==", "requires": { "@graphql-tools/schema": "^10.0.31", "@graphql-tools/utils": "^11.0.0", - "@httptoolkit/httpolyglot": "^3.0.1", + "@httptoolkit/httpolyglot": "^3.1.0", "@httptoolkit/subscriptions-transport-ws": "^0.11.2", "@httptoolkit/util": "^0.1.7", "@httptoolkit/websocket-stream": "^6.0.1", "@peculiar/asn1-cms": "<2.7.0", "@peculiar/asn1-csr": "<2.7.0", "@peculiar/asn1-ecc": "<2.7.0", - "@peculiar/asn1-pkcs8": "^2.5.0 <2.7.0", + "@peculiar/asn1-pfx": "<2.7.0", + "@peculiar/asn1-pkcs8": "<2.7.0", "@peculiar/asn1-pkcs9": "<2.7.0", "@peculiar/asn1-rsa": "<2.7.0", "@peculiar/asn1-schema": "^2.3.15 <2.7.0", @@ -30910,6 +30985,76 @@ "tslib": "^2.4.0" } }, + "@peculiar/asn1-pfx": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.6.1.tgz", + "integrity": "sha512-nB5jVQy3MAAWvq0KY0R2JUZG8bO/bTLpnwyOzXyEh/e54ynGTatAR+csOnXkkVD9AFZ2uL8Z7EV918+qB1qDvw==", + "requires": { + "@peculiar/asn1-cms": "^2.6.1", + "@peculiar/asn1-pkcs8": "^2.6.1", + "@peculiar/asn1-rsa": "^2.6.1", + "@peculiar/asn1-schema": "^2.6.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + }, + "dependencies": { + "@peculiar/asn1-cms": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.7.0.tgz", + "integrity": "sha512-hew63shtzzvBcSHbhm+cyAmKe6AIfinT9hzEqSPjDC6opTTMKmTkQ0gHuN2KsWlvqiKw1S/fS94fhag/FJkioQ==", + "requires": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/asn1-x509": "^2.7.0", + "@peculiar/asn1-x509-attr": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "@peculiar/asn1-rsa": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.7.0.tgz", + "integrity": "sha512-/qvENQrXyTZURjMqSeofHul0JJt2sNSzSwk36pl2olkHbaioMQgrASDZAlHXl0xUlnVbHj0uGgOrBMTb5x2aJQ==", + "requires": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/asn1-x509": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "@peculiar/asn1-schema": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.7.0.tgz", + "integrity": "sha512-W8ZfWzLmQnrcky+eh3tni4IozMdqBDiHWU0N+vve/UGjMaUs8c0L7A2oEdkBXS8rTpWDpK/aoI3DG/L/hxmxPg==", + "requires": { + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "@peculiar/asn1-x509": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.7.0.tgz", + "integrity": "sha512-mUn9RRrkGDnG4ALfunDmzyRW5dg+sWCj/pfnCCqEHYbkGxEpvUt6iVJv8Yw1cyp6SWZ26ZE5oSmI5SqEaen15g==", + "requires": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "@peculiar/asn1-x509-attr": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.7.0.tgz", + "integrity": "sha512-NS8e7SOgXipkzUPLF/sce7ukpMpWjhxYsH0n6Y+bHYo4TTxOb95Zv7hqwSuL212mj5YxovjdOKQOgH1As3E94w==", + "requires": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/asn1-x509": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + } + } + }, "accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", diff --git a/package.json b/package.json index ec60165f..b35f3797 100644 --- a/package.json +++ b/package.json @@ -108,7 +108,7 @@ "mobx-shallow-undo": "^1.0.0", "mobx-utils": "^5.1.0", "mockrtc": "^0.5.0", - "mockttp": "^4.4.0", + "mockttp": "file:../mockttp/mockttp-4.4.2.tgz", "monaco-editor": "^0.27.0", "node-forge": "^1.4.0", "openapi-directory": "^1.3.0", diff --git a/src/components/modify/matcher-config.tsx b/src/components/modify/matcher-config.tsx index f1b1702b..de02e094 100644 --- a/src/components/modify/matcher-config.tsx +++ b/src/components/modify/matcher-config.tsx @@ -28,9 +28,6 @@ import { isHiddenMatcherKey } from '../../model/rules/rules'; import { summarizeMatcherClass } from '../../model/rules/rule-descriptions'; -import { - HeaderContainsMatcher -} from '../../model/rules/definitions/http-rule-definitions'; import { WebSocketMethodMatcher } from '../../model/rules/definitions/websocket-rule-definitions'; @@ -165,9 +162,17 @@ export function AdditionalMatcherConfiguration(props: case 'exact-query-string': return ; case 'header': - return ; - case 'header-contains': - return ; + return ; + case 'header-includes': + return ; case 'raw-body': return ; case 'raw-body-includes': @@ -790,7 +795,12 @@ class ExactQueryMatcherConfig extends MatcherConfig } @observer -class HeaderMatcherConfig extends MatcherConfig { +class HeaderMatcherConfig< + M extends (typeof matchers.HeaderMatcher | typeof matchers.HeaderValueIncludesMatcher) +> extends MatcherConfig, { + matcherClass: M, + description: string +}> { render() { const { matcherIndex } = this.props; @@ -800,7 +810,7 @@ class HeaderMatcherConfig extends MatcherConfig { return { matcherIndex !== undefined && - { matcherIndex !== 0 && 'and ' } with headers including + { matcherIndex !== 0 && 'and ' } { this.props.description } } @@ -826,94 +836,7 @@ class HeaderMatcherConfig extends MatcherConfig { if (Object.keys(headers).length === 0) { this.props.onChange(); } else { - this.props.onChange(new matchers.HeaderMatcher(headers)); - } - } -} - -const HeaderContainsConfigRow = styled.div` - display: flex; - flex-direction: row; - - > :first-child { - flex: 1 1 40%; - margin-right: 5px; - } - - > :last-child { - flex: 1 1 60%; - } -`; - -@observer -class HeaderContainsMatcherConfig extends MatcherConfig { - - private fieldId = _.uniqueId(); - - @observable - private headerName = ''; - - @observable - private headerValue = ''; - - componentDidMount() { - disposeOnUnmount(this, autorun(() => { - const headerName = this.props.matcher?.headerName ?? ''; - const headerValue = this.props.matcher?.headerValue ?? ''; - - runInAction(() => { - this.headerName = headerName; - this.headerValue = headerValue; - }); - })); - } - - render() { - const { matcherIndex } = this.props; - - return - { matcherIndex !== undefined && - - { matcherIndex !== 0 && 'and ' } with a header value containing - - } - - - - - ; - } - - @action.bound - onNameChange(event: React.ChangeEvent) { - this.headerName = event.target.value; - this.updateMatcher(); - } - - @action.bound - onValueChange(event: React.ChangeEvent) { - this.headerValue = event.target.value; - this.updateMatcher(); - } - - private updateMatcher() { - if (this.headerName && this.headerValue) { - this.props.onChange(new HeaderContainsMatcher(this.headerName, this.headerValue)); - } else { - this.props.onInvalidState(); + this.props.onChange(new this.props.matcherClass(headers) as InstanceType); } } } diff --git a/src/model/rules/definitions/http-rule-definitions.ts b/src/model/rules/definitions/http-rule-definitions.ts index 202cf207..8c8d59e2 100644 --- a/src/model/rules/definitions/http-rule-definitions.ts +++ b/src/model/rules/definitions/http-rule-definitions.ts @@ -77,48 +77,6 @@ export class AmIUsingMatcher extends httpMatchers.RegexPathMatcher { } } -// Matches requests whose given header's value contains the given substring. Useful -// for cases that full-value header matching can't handle, like matching one cookie -// within a Cookie header. Built on Mockttp's callback matcher, so the actual -// matching runs here in the UI, called back from the server for each request. -export class HeaderContainsMatcher extends httpMatchers.CallbackMatcher { - - readonly uiType = 'header-contains'; - - constructor( - public readonly headerName: string, - public readonly headerValue: string - ) { - // Headers are always keyed lowercase in Mockttp's request data: - const lowerCasedName = headerName.toLowerCase(); - - super((request) => { - const headerOrHeaders = request.headers[lowerCasedName]; - if (!headerOrHeaders) return false; - - return ( - Array.isArray(headerOrHeaders) - ? headerOrHeaders - : [headerOrHeaders] - ).some((value) => value.includes(headerValue)); - }); - } - - explain() { - return `with a '${this.headerName}' header containing ${JSON.stringify(this.headerValue)}`; - } -} - -serializr.createModelSchema(HeaderContainsMatcher, { - uiType: serializeAsTag(() => 'header-contains'), - type: serializr.primitive(), - headerName: serializr.primitive(), - headerValue: serializr.primitive() -}, (context) => new HeaderContainsMatcher( - context.json.headerName, - context.json.headerValue -)); - export class StaticResponseStep extends httpSteps.FixedResponseStep { explain() { return `respond with status ${this.status}${ @@ -405,9 +363,7 @@ export const HttpMatcherLookup = { 'wildcard': WildcardMatcher, // Add special types for our built-in matcher explanation overrides: 'default-wildcard': DefaultWildcardMatcher, - 'am-i-using': AmIUsingMatcher, - // Our own UI-only matcher types, built on Mockttp's callback matcher: - 'header-contains': HeaderContainsMatcher + 'am-i-using': AmIUsingMatcher }; export const HttpInitialMatcherClasses = [ diff --git a/src/model/rules/rule-descriptions.ts b/src/model/rules/rule-descriptions.ts index ecd478e2..5fbd2176 100644 --- a/src/model/rules/rule-descriptions.ts +++ b/src/model/rules/rule-descriptions.ts @@ -47,8 +47,8 @@ export function summarizeMatcherClass(key: MatcherClassKey): string { return "With exact query string"; case 'header': return "Including headers"; - case 'header-contains': - return "With a header value containing"; + case 'header-includes': + return "With header values including"; case 'cookie': return "With cookie"; case 'raw-body': diff --git a/test/unit/model/header-contains-matcher.spec.ts b/test/unit/model/header-contains-matcher.spec.ts deleted file mode 100644 index d6eb26e5..00000000 --- a/test/unit/model/header-contains-matcher.spec.ts +++ /dev/null @@ -1,79 +0,0 @@ -import * as serializr from 'serializr'; - -import { expect } from '../../test-setup'; - -import { HeaderContainsMatcher } from '../../../src/model/rules/definitions/http-rule-definitions'; - -const requestWithHeaders = (headers: { [key: string]: string | string[] }) => - ({ headers }) as any; - -describe("Header-contains matcher", () => { - - it("matches a substring within a single-value header", async () => { - const matcher = new HeaderContainsMatcher('Cookie', 'iamSessionIdExpiryDateInUtc'); - - expect(await matcher.callback(requestWithHeaders({ - 'cookie': 'a=1; iamSessionIdExpiryDateInUtc=2026-06-12T00:00:00Z; b=2' - }))).to.equal(true); - }); - - it("does not match if the header value doesn't contain the text", async () => { - const matcher = new HeaderContainsMatcher('Cookie', 'iamSessionIdExpiryDateInUtc'); - - expect(await matcher.callback(requestWithHeaders({ - 'cookie': 'a=1; b=2' - }))).to.equal(false); - }); - - it("does not match if the header isn't present at all", async () => { - const matcher = new HeaderContainsMatcher('Cookie', 'iamSessionIdExpiryDateInUtc'); - - expect(await matcher.callback(requestWithHeaders({ - 'host': 'example.com' - }))).to.equal(false); - }); - - it("matches header names case-insensitively", async () => { - const matcher = new HeaderContainsMatcher('X-Custom-Header', 'value'); - - // Mockttp always provides headers keyed lowercase: - expect(await matcher.callback(requestWithHeaders({ - 'x-custom-header': 'some-value-here' - }))).to.equal(true); - }); - - it("matches multi-value headers if any value contains the text", async () => { - const matcher = new HeaderContainsMatcher('Set-Cookie', 'session'); - - expect(await matcher.callback(requestWithHeaders({ - 'set-cookie': ['other=1', 'session=abc'] - }))).to.equal(true); - }); - - it("serializes and deserializes back to a working matcher", async () => { - const matcher = new HeaderContainsMatcher('Cookie', 'mySession'); - - const data = serializr.serialize(matcher); - - expect(data).to.deep.equal({ - uiType: 'header-contains', - type: 'callback', - headerName: 'Cookie', - headerValue: 'mySession' - }); - - const restored = serializr.deserialize(HeaderContainsMatcher, data); - - expect(restored.headerName).to.equal('Cookie'); - expect(restored.headerValue).to.equal('mySession'); - expect(restored.explain()).to.equal(matcher.explain()); - - expect(await restored.callback(requestWithHeaders({ - 'cookie': 'mySession=123' - }))).to.equal(true); - expect(await restored.callback(requestWithHeaders({ - 'cookie': 'other=123' - }))).to.equal(false); - }); - -}); diff --git a/test/unit/model/header-includes-matcher.spec.ts b/test/unit/model/header-includes-matcher.spec.ts new file mode 100644 index 00000000..168a09ed --- /dev/null +++ b/test/unit/model/header-includes-matcher.spec.ts @@ -0,0 +1,105 @@ +import { matchers } from 'mockttp'; + +import { expect } from '../../test-setup'; + +import { + serializeRules, + deserializeRules +} from '../../../src/model/rules/rule-serialization'; + +const requestWithHeaders = (headers: { [key: string]: string | string[] }) => + ({ headers }) as any; + +describe("Header value includes matcher", () => { + + it("matches a substring within a header value", () => { + const matcher = new matchers.HeaderValueIncludesMatcher({ + 'Cookie': 'iamSessionIdExpiryDateInUtc' + }); + + expect(matcher.matches(requestWithHeaders({ + 'cookie': 'a=1; iamSessionIdExpiryDateInUtc=2026-06-12T00:00:00Z; b=2' + }))).to.equal(true); + }); + + it("does not match if the header value doesn't contain the text", () => { + const matcher = new matchers.HeaderValueIncludesMatcher({ + 'Cookie': 'iamSessionIdExpiryDateInUtc' + }); + + expect(matcher.matches(requestWithHeaders({ + 'cookie': 'a=1; b=2' + }))).to.equal(false); + }); + + it("does not match if the header isn't present at all", () => { + const matcher = new matchers.HeaderValueIncludesMatcher({ + 'Cookie': 'iamSessionIdExpiryDateInUtc' + }); + + expect(matcher.matches(requestWithHeaders({ + 'host': 'example.com' + }))).to.equal(false); + }); + + it("matches multi-value headers if any value contains the text", () => { + const matcher = new matchers.HeaderValueIncludesMatcher({ + 'Set-Cookie': 'session' + }); + + expect(matcher.matches(requestWithHeaders({ + 'set-cookie': ['other=1', 'session=abc'] + }))).to.equal(true); + }); + + it("requires all given headers to match", () => { + const matcher = new matchers.HeaderValueIncludesMatcher({ + 'Cookie': 'mySession', + 'User-Agent': 'Chrome' + }); + + expect(matcher.matches(requestWithHeaders({ + 'cookie': 'mySession=123', + 'user-agent': 'Mozilla/5.0 Chrome/120.0' + }))).to.equal(true); + + expect(matcher.matches(requestWithHeaders({ + 'cookie': 'mySession=123', + 'user-agent': 'Mozilla/5.0 Firefox/120.0' + }))).to.equal(false); + }); + + it("survives a rule serialization round-trip", () => { + const rules = { + id: 'root', + title: "Rules", + isRoot: true, + items: [{ + id: 'rule-1', + type: 'http', + activated: true, + matchers: [ + new matchers.HeaderValueIncludesMatcher({ 'Cookie': 'mySession' }) + ], + steps: [{ type: 'simple', status: 200, data: 'mock response' }] + }] + } as any; + + const restoredRules = deserializeRules( + JSON.parse(JSON.stringify(serializeRules(rules))), + { rulesStore: {} as any } + ); + + const restoredMatcher = (restoredRules.items[0] as any).matchers[0]; + + expect(restoredMatcher).to.be.instanceOf(matchers.HeaderValueIncludesMatcher); + expect(restoredMatcher.headers).to.deep.equal({ 'cookie': 'mySession' }); + expect(restoredMatcher.matches(requestWithHeaders({ + 'cookie': 'mySession=123' + }))).to.equal(true); + expect(restoredMatcher.explain()).to.equal( + `with header values including {"cookie":"mySession"}` + ); + }); + +}); From d8e90ad6fdc3b9554694a42150b911b8512cd068 Mon Sep 17 00:00:00 2001 From: nuno <59452877+komen205@users.noreply.github.com> Date: Thu, 11 Jun 2026 19:28:27 +0100 Subject: [PATCH 3/3] Add UI for match & replace within header values in transform rules Adds a third option to the headers transform dropdown: 'Match & replace text in a header value', configuring mockttp's new matchReplaceHeaders transform. This rewrites patterns within a header's value (e.g. one cookie's value inside a multi-cookie Cookie header) without replacing the whole header. Co-Authored-By: Claude Fable 5 --- package-lock.json | 4 +- src/components/modify/step-config.tsx | 68 +++++++++++++++++-- .../definitions/http-rule-definitions.ts | 17 +++++ src/model/rules/rules.ts | 3 +- .../model/transform-serialization.spec.ts | 59 ++++++++++++++++ 5 files changed, 141 insertions(+), 10 deletions(-) create mode 100644 test/unit/model/transform-serialization.spec.ts diff --git a/package-lock.json b/package-lock.json index 8b0ce897..f4507dd5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13215,7 +13215,7 @@ "node_modules/mockttp": { "version": "4.4.2", "resolved": "file:../mockttp/mockttp-4.4.2.tgz", - "integrity": "sha512-CIgqi/11soPR/K5B+hRjYGebYGXjHhyLVhsT0BQbYpJesCX2t9aE+5HMf/Dv39Q4u7Ke06NXkIyawGMmqaiBUw==", + "integrity": "sha512-bA1MPjJp4UVjh8j9YuYn/IEPF0Ou9+Hwf1aE9lPomdTM8psRrqlui2U6nHsDuBzW/tR5BKEN3FHl6Or8BYZfzg==", "license": "Apache-2.0", "dependencies": { "@graphql-tools/schema": "^10.0.31", @@ -30922,7 +30922,7 @@ }, "mockttp": { "version": "file:../mockttp/mockttp-4.4.2.tgz", - "integrity": "sha512-CIgqi/11soPR/K5B+hRjYGebYGXjHhyLVhsT0BQbYpJesCX2t9aE+5HMf/Dv39Q4u7Ke06NXkIyawGMmqaiBUw==", + "integrity": "sha512-bA1MPjJp4UVjh8j9YuYn/IEPF0Ou9+Hwf1aE9lPomdTM8psRrqlui2U6nHsDuBzW/tR5BKEN3FHl6Or8BYZfzg==", "requires": { "@graphql-tools/schema": "^10.0.31", "@graphql-tools/utils": "^11.0.0", diff --git a/src/components/modify/step-config.tsx b/src/components/modify/step-config.tsx index 46def3b1..5dac06b3 100644 --- a/src/components/modify/step-config.tsx +++ b/src/components/modify/step-config.tsx @@ -39,7 +39,8 @@ import { getRulePartKey, AvailableStepKey, isHttpCompatibleType, - MatchReplacePairs + MatchReplacePairs, + MatchReplaceHeaders } from '../../model/rules/rules'; import { StaticResponseStep, @@ -1293,7 +1294,8 @@ class HeadersTransformConfig ext private static readonly FIELDS = [ 'replaceHeaders', - 'updateHeaders' + 'updateHeaders', + 'matchReplaceHeaders' ] as const; @computed @@ -1304,8 +1306,8 @@ class HeadersTransformConfig ext } @computed - get headers() { - if (this.selected === 'none') return {}; + get headers(): Headers { + if (this.selected === 'none' || this.selected === 'matchReplaceHeaders') return {}; return this.props.transform[this.selected] || {}; } @@ -1315,7 +1317,8 @@ class HeadersTransformConfig ext selected, convertResultFromRawHeaders, onTransformTypeChange, - setHeadersValue + setHeadersValue, + setMatchReplaceHeadersValue } = this; return @@ -1327,9 +1330,15 @@ class HeadersTransformConfig ext + { - selected !== 'none' && + selected === 'matchReplaceHeaders' + ? + : selected !== 'none' && ext @action.bound setHeadersValue(value: Headers) { this.clearValues(); - if (this.selected !== 'none') { + if (this.selected === 'updateHeaders' || this.selected === 'replaceHeaders') { this.props.onChange(this.selected)(value); } } + @action.bound + setMatchReplaceHeadersValue(value: MatchReplaceHeaders) { + this.clearValues(); + this.props.onChange('matchReplaceHeaders')(value); + } + @action.bound onTransformTypeChange = (event: React.ChangeEvent) => { const value = event.currentTarget.value as 'none' | typeof HeadersTransformConfig.FIELDS[number]; @@ -1735,6 +1750,45 @@ const validateRegexMatcher = (value: string): true | string => { } } +const HeaderMatchReplaceTransformConfig = (props: { + value: MatchReplaceHeaders, + onChange: (value: MatchReplaceHeaders) => void +}) => { + // We edit the match/replace config for a single header here: that's by far the + // most common case, and keeps the UI simple. The underlying Mockttp transform + // does support transforming multiple headers at once, if configured directly. + const initialHeaderName = Object.keys(props.value)[0] ?? ''; + + const [headerName, setHeaderName] = React.useState(initialHeaderName); + const [replacements, setReplacements] = React.useState( + props.value[initialHeaderName] ?? [] + ); + + React.useEffect(() => { + if (headerName) { + props.onChange({ [headerName]: replacements }); + } else { + props.onChange({}); + } + }, [headerName, replacements]); + + return <> + + Header name + ) => setHeaderName(e.target.value)} + placeholder='The name of the header to modify, e.g. cookie' + spellCheck={false} + /> + + + ; +}; + @observer class WebhookStepConfig extends StepConfig { diff --git a/src/model/rules/definitions/http-rule-definitions.ts b/src/model/rules/definitions/http-rule-definitions.ts index 8c8d59e2..7f7773d9 100644 --- a/src/model/rules/definitions/http-rule-definitions.ts +++ b/src/model/rules/definitions/http-rule-definitions.ts @@ -227,6 +227,21 @@ const MatchReplaceSerializer = serializr.list( ) ); +const MatchReplaceHeadersSerializer = serializr.custom( + (headersConfig: { [headerName: string]: Array<[RegExp, string]> }) => + _.mapValues(headersConfig, (pairs) => + pairs.map(([key, value]) => + [{ source: key.source, flags: key.flags }, value] + ) + ), + (headersConfig: { [headerName: string]: Array<[{ source: string, flags: string }, string]> }) => + _.mapValues(headersConfig, (pairs) => + pairs.map(([key, value]) => + [new RegExp(key.source, key.flags), value] + ) + ) +); + serializr.createModelSchema(TransformingStep, { uiType: serializeAsTag(() => 'req-res-transformer'), transformRequest: serializr.object( @@ -240,6 +255,7 @@ serializr.createModelSchema(TransformingStep, { matchReplacePath: MatchReplaceSerializer, matchReplaceQuery: MatchReplaceSerializer, updateHeaders: serializeWithUndefineds, + matchReplaceHeaders: MatchReplaceHeadersSerializer, updateJsonBody: serializeWithUndefineds, replaceBody: serializeBuffer, matchReplaceBody: MatchReplaceSerializer, @@ -249,6 +265,7 @@ serializr.createModelSchema(TransformingStep, { transformResponse: serializr.object( serializr.createSimpleSchema({ updateHeaders: serializeWithUndefineds, + matchReplaceHeaders: MatchReplaceHeadersSerializer, updateJsonBody: serializeWithUndefineds, replaceBody: serializeBuffer, matchReplaceBody: serializr.list( diff --git a/src/model/rules/rules.ts b/src/model/rules/rules.ts index e5ede9df..8b47b288 100644 --- a/src/model/rules/rules.ts +++ b/src/model/rules/rules.ts @@ -56,7 +56,8 @@ import { } from './definitions/rtc-rule-definitions'; export type { - MatchReplacePairs + MatchReplacePairs, + MatchReplaceHeaders } from 'mockttp'; /// --- Part-generic logic --- diff --git a/test/unit/model/transform-serialization.spec.ts b/test/unit/model/transform-serialization.spec.ts new file mode 100644 index 00000000..f5479b88 --- /dev/null +++ b/test/unit/model/transform-serialization.spec.ts @@ -0,0 +1,59 @@ +import { expect } from '../../test-setup'; + +import { TransformingStep } from '../../../src/model/rules/definitions/http-rule-definitions'; +import { + serializeRules, + deserializeRules +} from '../../../src/model/rules/rule-serialization'; + +const fakeRulesStore = { activePassthroughOptions: {} } as any; + +describe("Transforming step serialization", () => { + + it("survives a round-trip with a matchReplaceHeaders transform", () => { + const step = new TransformingStep(fakeRulesStore, { + matchReplaceHeaders: { + 'cookie': [ + [/iamSessionIdExpiryDateInUtc=[^;]+/g, 'iamSessionIdExpiryDateInUtc=123231232'] + ] + } + }, {}); + + const rules = { + id: 'root', + title: "Rules", + isRoot: true, + items: [{ + id: 'rule-1', + type: 'http', + activated: true, + matchers: [], + steps: [step] + }] + } as any; + + const restoredRules = deserializeRules( + JSON.parse(JSON.stringify(serializeRules(rules))), + { rulesStore: fakeRulesStore } + ); + + const restoredStep = (restoredRules.items[0] as any).steps[0]; + + expect(restoredStep).to.be.instanceOf(TransformingStep); + + const pairs = restoredStep.transformRequest!.matchReplaceHeaders!['cookie']; + expect(pairs).to.have.length(1); + + const [pattern, replacement] = pairs[0]; + expect(pattern).to.be.instanceOf(RegExp); + expect(pattern.source).to.equal('iamSessionIdExpiryDateInUtc=[^;]+'); + expect(pattern.flags).to.equal('g'); + expect(replacement).to.equal('iamSessionIdExpiryDateInUtc=123231232'); + + // The restored regex actually works as expected: + expect( + 'a=1; iamSessionIdExpiryDateInUtc=2026-06-12T00:00:00Z; b=2'.replace(pattern, replacement) + ).to.equal('a=1; iamSessionIdExpiryDateInUtc=123231232; b=2'); + }); + +});