[7.5.0] - 2026-07-29
Security
- BREAKING: response templates can no longer reach arbitrary Java classes by default, closing the
template remote-code-execution path reported as
GHSA-7pwj-xvc2-hfpc.
A caller who can reach the management API can register an expectation, and a response template was able
to loadjava.lang.Runtimeand execute OS commands in the MockServer process. Both engines that could
do this are now sandboxed out of the box:velocityDisallowClassLoadingnow defaults totrue(wasfalse), installing Velocity's
SecureUberspectorso a template cannot reach classes through$request.class.classLoader.loadClass(...).
This is the more exposed half of the issue, and the half the report did not cover: Velocity ships in
the DEFAULT distribution, whereas the JavaScript engine does not.- JavaScript templates now resolve no Java classes unless an operator grants them. Previously an
emptyjavascriptAllowedClassesand emptyjavascriptDisallowedClassesmeant unrestricted
Java.type(...)access; that combination — the out-of-the-box state — now denies every class. - The GraalJS guest context no longer grants access to the members of
java.lang.Classor
java.lang.ClassLoader. Denying classes atJava.type(...)alone was not sufficient: real host
objects are bound into the context (fakerand the other built-in helpers), and under the previous
HostAccess.ALLa template could walk from one of them to a classloader —
faker.getClass().getClassLoader().loadClass('java.lang.Runtime')— reachingRuntimewithout the
class filter ever being consulted. That walk is now closed, so host-class lookup is the single complete
gate; a regression test drives four such walks (including throughrequest) and fails if any resolves.
Velocity'sSecureUberspectoralready blocked the equivalent walk through its own bound helpers, which
is now covered by a test too.
Both flips are fully reversible with one property and remove no functionality: set
mockserver.velocityDisallowClassLoading=false, or list the classes your templates need in
mockserver.javascriptAllowedClasses(the single entry*lets any class resolve again). Templates that
do not touch Java classes are unaffected, which is the overwhelming majority — JavaScript templates have
the full ES2023 standard library available regardless of this setting. A refused class is logged once at
WARN naming the class and the property to set, because GraalJS otherwise surfaces a refusal only as the
class being undefined ("... is not a function"); the log is bounded and de-duplicated so a hostile
template cannot flood it.mockserver.javascriptAllowedClassesis now also settable through the Spring
test listener's@MockServerTestproperties, which it was not before — it was a nice-to-have while the
default was unrestricted, and is the only way to grant a class now that it is not. The insecure-mode WARN
now fires when an operator has explicitly opened the
sandbox rather than when it is closed. Proven end-to-end by a Netty integration test that registers the
reported payload through the real management API and asserts the OS command creates no marker file, with
a negative control on a deliberately unsandboxed server that DOES create it — so a regression cannot pass
as an inert payload. This lands DEF-2 and DEF-3 ofdocs/plans/later/security-defaults.mdahead of the
other default flips listed there; JavaScript went further than that plan proposed (deny everything, not a
built-in "safe types" allow-list) because deny-by-default is the only form that stays safe as the JDK
grows new reachable classes.
Fixed
- A property file that cannot be read is now reported instead of ignored in silence
(#2358). When a
mockserver.propertyFilean operator had explicitly configured could not be read, MockServer applied
none of its properties and said nothing about it — at any log level. The only symptom was that every
property in the file appeared to be at its default, which surfaces far downstream as unexplained
behaviour: in the reported case an unreadable (but present) mounted file meantinitializationJsonPath
was never set, so no expectations loaded, noloading JSON initialization file:line appeared, and no
error was logged either. The message existed but was unreachable in practice — gated at DEBUG and
emitted during static initialisation, before any log level has been applied, so neither
-Dmockserver.logLevel=DEBUGnor a-logLevelargument could surface it. Such a file is now logged at
WARN, naming the path and the underlying reason verbatim; becauseFileNotFoundExceptioncovers "not
there" and "not allowed to read it" alike, that reason is usually the whole answer (Permission denied
in the reported case, typically SELinux labelling or a rootless/user-namespace UID mismatch). A property
file that is merely absent at its default location stays quiet, as does the Docker image's built-in
-Dmockserver.propertyFile=/config/mockserver.properties, which the entrypoint always passes and which
therefore expresses no intent — otherwise every container started without a mounted config would warn.
Inside the image, onlyMOCKSERVER_PROPERTY_FILEcan express that intent, and it does. - The
mockserver-nodelauncher suite no longer fails intermittently on a TLS handshake reset. The
two tests that exercisejvmOptionsdid so over HTTPS against a server started with
dynamicallyCreateCertificateAuthorityCertificate=true, and issued that HTTPS request as soon as
start_mockserverresolved.start_mockserveronly proves the HTTP control plane is answering — it
pollsPUT /mockserver/retrieveover plain HTTP — but with a dynamically created certificate
authority the server still has to generate a CA key pair and a leaf certificate before it can serve
TLS on that same (port-unified) port. A handshake arriving in that window was closed mid-negotiation
and surfaced asECONNRESET"Client network socket disconnected before secure TLS connection was
established", failing whichever of the two tests lost the race. This accounted for every
mockserver-nodefailure onmasterover the preceding 40 builds (5 of 40, ~12%), so it was the sole
cause of the pipeline's intermittent red. Both tests now wait for an actual TLS handshake to complete
before asserting, which gates them on the condition they really depend on rather than retrying the
assertions. The newwaitForTlsReadyhelper is verified to reject — not resolve — both when nothing
is listening and when a listener accepts the TCP connection then destroys it mid-handshake, which is
exactly the failure signature it exists to absorb. The readiness budget is deliberately generous
(120s): waiting costs nothing when the server is healthy, since a ready server completes the
handshake on the first attempt in milliseconds, so the limit only decides how much CI contention is
tolerated before a slow start is misreported as a fault. An earlier 30s budget went green five builds
running and then expired on a loaded agent — the same flake wearing a clearer error message. A start
that takes over 5s is now reported even when it passes, because readiness creeping towards the limit
is the signal that the next run will not make it. archiver.glob()works again in@mockserver/testcontainers(Node), and CVE-2026-14257 stays
closed. The previous remedy for thebrace-expansiondenial of service (GHSA-mh99-v99m-4gvg,
patched only in 5.0.8) was a blanket"brace-expansion": "^5.0.8"override. That resolved the whole
tree to a single hoisted 5.0.8 andnpm auditreported zero vulnerabilities — but 5.x changed the
CommonJS export from a callable function to an object ({ expand, EXPANSION_MAX, ... }), while the
minimatch copies actually installed (3.1.5, 5.1.9, 9.0.9) all call it asexpand(pattern). Every
glob containing a brace therefore threwTypeError: expand is not a function, crashing
archiver.glob(). The blast radius is narrower than it first looks —testcontainerscopies files
witharchiver.directory()/.append(), which pass no brace pattern and still work — so what broke
is brace globbing for anything in this module's runtime tree that does use it. The failure was
invisible because minimatch short-circuits patterns with no{, so plain globs kept working and the
unit suite stayed green. The override is now targeted:readdir-globandarchiver-utils'glob
takeminimatch@^10.2.5, which depends onbrace-expansion@^5.0.5and is written against the new
API, so both runtime copies land on the patched 5.0.8 with a matching minimatch. jest keeps its own
minimatch@3.1.5+brace-expansion@1.1.16pairing and is untouched.npm audit --omit=devstill
reports 0 vulnerabilities, and a newdependency-integrityunit test drives a brace pattern through
both runtime minimatch copies and through a realarchiver.glob()tar, plus asserts expansion stays
bounded — it fails against the blanket override, so the silent half of this cannot return.- A forward
responseOverridethat replaces the body no longer inherits the upstream response's
Content-Length, which truncated the response on the wire. The override swapped the body but left the
upstream header in place, so the client read only as many bytes as the body it replaced — a 34-byte
override behind an upstreamContent-Length: 13arrived as 13 bytes — or hung waiting for bytes that
never came. The stale header is now dropped so the encoder recomputes it from what is actually written;
aContent-Lengthset by the override itself, andconnectionOptions.contentLengthHeaderOverride, are
still honoured, and a header-only override (one that sets no body) is untouched. This affects every body
override, and it was the remaining reason aFILEresponse body returned from aresponseOverride
still reached the client wrong after
#2450: the file was materialised
correctly and then cut short by the stale length. Covered by a Netty integration test that drives a real
forward-with-override through a real upstream and asserts the bytes the client receives. - The JetBrains plugin's LLM tool window now sends a valid expectation
(#2455). "Load into Server" was
rejected with400 incorrect expectation json formatbecause the builder emitted a shape that never
existed on the server: a flatcompletionstring, a top-levelfinishReason,stream, andusage,
and aproviderofOPEN_AI. The completion text, streaming flag, stop reason, and token usage
belong INSIDE thecompletionobject (text,streaming,stopReason,usage.inputTokens/
usage.outputTokens), and providers are theProviderenum names (OPENAI,AZURE_OPENAI, …). The
provider and field catalogues shared with the VS Code extension are corrected the same way — they
offeredOPEN_AI,VERTEX_AI,messages,stream,finishReasonand a top-levelusage, none of
which the server accepts — and completion inside acompletionobject now offers the nested fields.
The plugin has always bundled the correct schema; it simply never validated its own output against it,
and the previous tests asserted the builder matched the same invented shape it produced. Both editors
now validate against the bundled schema in their test suites. httpLlmResponse.providernow accepts every provider MockServer implements. The JSON Schema enum
listed 9 of the 14org.mockserver.model.Providerconstants, soMISTRAL,XAI,DEEPSEEK,GROQ,
andOPENROUTERwere rejected with400 incorrect expectation json formateven though each has a
fully registered response codec. The five missing values are added to the core schema, the generated
VS Code and JetBrains schemas, and both copies of the OpenAPI specification, and a new parity test
fails if the enum andProviderever diverge again in either direction. The provider list on the
LLM response mocking documentation and in the Rust client's field docs is updated to match.- The cloud blob-store, async-broker and transparent-proxy CI steps no longer OOM-kill their own build
before any test runs. Each ran its Docker container with--memory=4g, butmockserver/.mvn/jvm.config
pins the Maven JVM to-Xmx6144mand the wrapper prepends it toMAVEN_OPTS, so the-amdependency
build was permitted a 6 GB heap inside a 4 GB cgroup and the kernel intermittently killed it with exit 137
— losing the very coverage those fail-closed steps exist to guarantee. Raised each to--memory=7g, the
value every other./mvnwstep already uses and which fits the single-agentc5.2xlarge/m5.2xlarge
default-queue instances with margin.
Added
-
A cassette is now auto-registered when a fixture is loaded or recorded via the MCP tools, so it
appears underGET /mockserver/cassetteswithout a separatePUT /mockserver/cassettescall.
Previously the server-side cassette registry was populated only by an explicit
PUT /mockserver/cassettes, so a fixture loaded with theload_expectations_from_fileMCP tool, or
written withrecord_llm_fixtures, never showed up in the dashboard's Cassettes tab unless the
caller also registered it by hand. Both MCP tool handlers now register the fixture in
CassetteRegistryat the point the file is loaded/written — the file path as the key, the loaded/
written expectation count, and anoriginofloadedorrecordedrespectively — so
GET /mockserver/cassettes(which serialises that registry) lists it automatically. Re-loading or
re-recording the same path updates the existing entry in place rather than duplicating it. -
Clustered (Infinispan) expectation reload-on-startup is now proven end-to-end. A new test
(ClusteredExpectationPersistenceReloadTestinmockserver-state-infinispan) forms an in-JVM
JGroups cluster consisting of a bare "fleet keeper"InfinispanStateBackendthat stays up for the
whole test plus a full MockServer node started withstateBackend=infinispan,
clusterEnabled=trueandpersistExpectations=true. An expectation is created on that node over
the wire, the persisted document is polled for through the keeper's backend (proving it really
replicated across the REPL_SYNC blob cache), the node is then stopped completely, and a fresh node
is started against the same cluster and the samepersistedExpectationsPath— which must restore
the expectation and MATCH a real HTTP request with it. The local persisted file is asserted to be
empty first, so the restore cannot be coming from the filesystem-initializer route. The reload path
inExpectationFileSystemPersistencewas already covered at unit level inmockserver-core
(ExpectationBlobStoreRestoreTest, against anInMemoryBlobStore, with no server and no cluster)
and end-to-end only against S3/MinIO behind a Docker gate; what no test proved is that a clustered
node'sInfinispanBlobStoreis the storeHttpStatewires into that restore, nor that a real
restarted member of a live cluster recovers the fleet's shared expectations. A second test sets
blobStoreRestoreTimeoutSeconds=0(the documented way to skip the restore) and asserts the fresh
node does NOT serve the expectation, which permanently pins the fact that no other mechanism —
JGroups state transfer of the expectations cache, a stray invalidation event, or the local file —
restores expectations when a node starts. Verified by a positive control: disabling the reload
path in production makes the restarted node answer with an empty body and turns the test red. -
The response-aware arm of the eviction false-green guard is now proven end-to-end over HTTP. A new
Netty integration test (EvictedResponseVerificationIntegrationTest) boots a real server with
maxLogEntries=2andfailVerificationOnEvictedLog=true, registers an expectation so aGET /was-respondedexchange is recorded as a realEXPECTATION_RESPONSErequest-response pair, then floods
the bounded event log with further unmatched traffic so that pair is evicted. A subsequent
verify(request("/was-responded"), response().withStatusCode(418), never())through the Java client must
throw anAssertionErrorsaying the response "could not be verified" because entries were discarded
after reachingmaxLogEntries.MockServerEventLogimplements this guard twice — once inverifyRequest
and once, through a completely separate counting path over recorded pairs, inverifyResponse— and only
the request arm had an*IntegrationTest; the response arm was covered solely by an engine-level test
against an in-process event log. The test usesnever()because it is the simplest shape that reaches
the guard: the guard sits on the PASS branch behind any asserted upper bound (getAtMost() != -1— so
atMost(n),between(0,n)andexactly(0)reach it too), whereas anatLeast(1)/once()verification
of an evicted pair fails earlier with an ordinary "Response not found" message and proves nothing.
never()is exactly the case a guard-less server would answer with a silent false green. The assertion pins the message toResponse could not be verifiedso it cannot be
satisfied by the request-side arm. Verified by a positive control (disabling only the response-side guard
in production makes the verification pass silently and turns the test red). -
The eviction false-green guard is now proven end-to-end over HTTP. A new Netty integration test
(EvictedLogVerificationIntegrationTest) boots a real server withmaxLogEntries=2and
failVerificationOnEvictedLog=true, records aGET /was-calledrequest, then floods the bounded
request-log ring with further traffic so the/was-calledentry is evicted. A subsequent
verify(request("/was-called"), never())through the Java client must throw anAssertionErrorwhose
message says the log "could not be verified" because entries were discarded after reaching
maxLogEntries— proving the guard refuses to certify absence it can no longer see, rather than
silently passing. Previously the guard was only covered by an engine-level test against an in-process
MockServerEventLogand no*IntegrationTestexercised it across the wire. Verified by a positive
control (disabling the guard in production makesverify(never())pass silently and turns the test
red). -
Custom gRPC response metadata and trailing metadata are now proven against a real
grpc-java
client. Two new tests inGrpcUnaryClientIntegrationTestregister an expectation whose gRPC
response carries both custom response metadata authored withwithHeader(...)and custom trailing
metadata authored withwithTrailer(...), drive it with a livegrpc-javaclient, and read the
values back off the realio.grpc.Metadataobjects the client receives (via a capturing
ClientInterceptor, and viaStatusRuntimeException.getTrailers()on the error path). The
assertions are deliberately discriminating: the response metadata must arrive in the initial
headers and not in the trailers, the trailing metadata must arrive in the trailers and not be
folded into the initial headers, and both values must round-trip byte-for-byte including a value
carrying=,;,,and spaces. Previously this behaviour was exercised only structurally
(EmbeddedChannel/ model-level assertions, which cannot tell a trailer emitted as a trailer from
one folded into the headers) and by the existing-binmetadata tests, which deliberately accept
the value from either side because a body-less unary response may legitimately collapse to
Trailers-Only. Verified by positive controls: dropping the user-authored trailers turns both tests
red, and dropping the user-authored response headers turns the header assertion red. -
The
maxResponseBodySizelimit is now proven behaviourally against a real upstream. A new
integration test (MaxResponseBodySizeIntegrationTest) boots a forwarding MockServer configured with a
4KBmaxResponseBodySize, points it at a raw upstream socket that returns a 64KB body, and drives it
over a plain client socket: the oversized body fails the forward and the client receives 502 Bad
Gateway with none of the payload relayed, while a control request whose body sits under the limit is
forwarded intact. A third case repeats the oversized body withTransfer-Encoding: chunkedand no
Content-Length, proving the cap is enforced against the bytes actually accumulated by the forward
client's aggregator rather than merely against a declared header. Previously this documented,
memory-protecting bound — read whenever a forward-client pipeline is built — had no behavioural
coverage at all, so a regression that dropped the wiring (or passed an unbounded value) would have
removed the limit silently; only the inbound analoguemaxRequestBodySizewas verified. The new test
covers the HTTP/1.1 forward aggregator; the HTTP/2 forward path reads the same property (for the
per-stream aggregator and to derive the client'smaxFrameSize) and remains uncovered.
maxResponseBodySizeaccordingly moves fromENFORCEMENT_EXEMPTtoENFORCEMENT_VERIFIEDin
ConfigurationEnforcementClassificationTest. Verified by a positive control (restoring an unbounded
aggregator lets the oversized body through with a 200 and turns both over-limit assertions red). -
The Ruby client now proves live SSE stream consumption over the wire. New integration examples
(spec/integration_spec.rb→SSE streaming) register anhttpSseResponseexpectation via the Ruby
client against a running MockServer, then open a real streaming HTTP consumer and assert everydata:
frame arrives in order, that the reconstructed multi-delta message matches, and that a multi-line
data:payload survives the framing intact (Content-Type: text/event-stream). Previously the Ruby
suite only asserted the JSON keys of a built streaming expectation (a2a_spec) and never consumed a
live SSE stream, so a silent server-emission or client-parsing drop would have gone uncaught. Verified
by a positive control (dropping events from the emitted stream turns the received-frames assertion red). -
The
assumeAllRequestsAreHttpprotocol-detection fallback now has direct unit coverage. Two
pairedEmbeddedChanneltests inDirectProxyUnificationHandlerTestdrive
PortUnificationHandler.decode()with an HTTP request using a non-standard method (PURGE, which is
not one of GET/POST/PUT/HEAD/OPTIONS/PATCH/DELETE/TRACE/CONNECT): with
assumeAllRequestsAreHttp=truethe full HTTP pipeline is added (rather than falling to binary request
proxying), and with the flag disabled the HTTP codec is not added — proving the flag is the only
difference. Previously the fallback branch was exercised only by a live-socket integration test and
the config getter's own unit test, so theEmbeddedChannelprotocol-detection path for the flag was
unexercised. -
HTTP/3 streaming response bodies are now proven end-to-end through the action pipeline with a real
QUIC client. A new integration test (Http3StreamingForwardIntegrationTest) registers aforward
expectation on the HTTP/3 port (withstreamingResponsesEnabled) pointing at an upstream Server-Sent
Events stream that serves an early event immediately and withholds the late event for 1.5s, then drives
it with a live Netty QUIC client and asserts both events arrive as SEPARATE DATA frames spread across
that delay — proving the streaming relay funnels throughHttpActionHandler->
ResponseWriter.writeResponse->Http3ResponseWriter.writeStreamingResponseand emits chunks
incrementally. PreviouslyHttp3StreamingIntegrationTestdroveHttp3ResponseWriterdirectly from a
hand-built QUIC server (bypassing expectation matching), andHttp3MockingMatrixIntegrationTest
exercised the real pipeline over QUIC but only with non-streaming actions, so incremental delivery of a
streamed body through the full pipeline was untested. QUIC-gated like the sibling HTTP/3 tests so it
skips cleanly where the native transport is unavailable. -
The dashboard's Monaco code editor is now proven in a real browser end-to-end. A new Playwright
e2e test (mockserver-ui/e2e/dashboard.spec.ts) drives the actual bundled Monaco editor in the
served dashboard's composer against a live MockServer: it asserts Monaco's own DOM
(.monaco-editor/.view-lines) renders, authors a JSON response body via real editor input,
raises and clears a live validation marker from Monaco's JSON language web worker, then registers
the mock and confirms the Monaco-authored body round-trips to the server (present in
PUT /mockserver/retrieveand served verbatim on the matching request). Previously the 178
jsdom/vitest specs globally replaced Monaco with a bare<textarea>, so nothing exercised the real
editor's tokenisation, web-worker validation, or DOM. -
SOCKS4 CONNECT tunnelling is now proven end-to-end over a real socket. A new socket-level
integration test (NettyHttpProxySOCKS4IntegrationTest) performs a raw SOCKS4 CONNECT handshake
against a bound MockServer targeting a loopbackEchoServer, then sends an HTTP GET through the
granted tunnel and asserts the EchoServer received the request and returned 200 (bytes relayed by
Socks4ConnectHandler). Previously SOCKS4 was only exercised by anEmbeddedChannelunit test
(Socks4ProxyHandlerTest, which asserts handler removal) while every real-socket proxy integration
test used SOCKS5, so nothing drove the SOCKS4 relay path over the wire. -
Per-host forward-proxy client-certificate selection is now proven at a real TLS handshake. A new
integration test (ForwardWithCustomClientCertificateByHostIntegrationTest) configures
forwardProxyClientCertificatesByHostto present two independent client certificates (each backed by
its own CA) keyed by host, then forwards through MockServer to two secure upstreamEchoServers that
eachREQUIREclient auth and trust only ONE of the two client CAs. Because the host string is the
cert-mapping key while the connection target is fixed independently by the forward port, the same
upstream is reached under both host keys: the mapped cert is accepted (200) and the mismatched cert is
rejected at the handshake (502). This makes the presented client certificate a load-bearing assertion,
verified by a positive control (degrading the mapping to always present cert A flips host B's accepted
case to 502). PreviouslyNettySslContextFactoryTestasserted onlySslContextidentity/distinctness
and the pure resolver -- nothing drove the per-host cert to an actual mTLS handshake. -
LLM streaming physics for Gemini, Ollama, and Bedrock are now proven over a real socket. Streaming
physics over the wire was previously e2e-tested only for Anthropic and OpenAI (both SSE); Gemini, Ollama,
and Bedrock rested on self-derived golden JSONL plus codec unit tests, with no socket streaming e2e. Three
new tests inLlmAgentLoopE2eTestserve a streaminghttpLlmResponsefor each provider, connect a real
socket client, and assert both that the wireContent-Typeis the provider's streaming media type
(text/event-streamfor Gemini SSE,application/x-ndjsonfor Ollama NDJSON,
application/vnd.amazon.eventstreamfor Bedrock AWS event-stream binary framing) and that the text
reconstructed by concatenating the streamed deltas — Geminicandidates[].content.parts[].text, Ollama
message.content, and Bedrock's base64-wrapped Anthropictext_deltafragments decoded from the
CRC32-validated binary event-stream messages — equals the completion text exactly. The Bedrock case
de-chunks the HTTP/1.1 chunked body and decodes the binary framing end to end. -
The PHP client's fidelity harness now gates the TYPED model, not just raw replay. The existing
RoundTripFidelityTestdeserialises each shared fixture withExpectation::fromArray(), which stores
the decoded array verbatim inrawDataand replays it unchanged -- so it records zero gaps for every
fixture BY CONSTRUCTION and can never detect a field the typed builders (HttpResponse,HttpForward,
HttpError,HttpRequest) fail to model. A newTypedRoundTripFidelityTestcloses that tautology:
for every shared fixture it reconstructs each action/matcher block THROUGH the typed model (reflection
copies across only the properties each class declares, recursing into declared nested typed objects,
then serialises via the class's owntoArray()) and diffs the rebuilt structure back against the
fixture -- the server-schema side of the contract -- so any server field the typed model drops surfaces
as a concrete diff path derived from the corpus, never from the client's own key list. This immediately
documented five real request-matcher gaps the raw harness hid (dnsClass/dnsName/dnsType,
pathParameters,protocol, plus NottableStringmethod/path), each pinned in a per-field gap
ledger with a stale-entry ratchet, while thehttpResponse/httpForward/httpErrormodels are proven
to cover their entire fixture surface. A positive-control test proves the gate fires when a typed
builder drops a field it is supposed to carry (removingstatusCodefromHttpResponse::toArray()
turns the suite red for every fixture carrying it, and green again on restore) -- the exact regression
the raw-replay harness cannot catch. Shared comparator logic is extracted toFidelityComparator. -
The Go client's FORWARD and ERROR response actions are now proven over the wire. New integration
tests (response_action_integration_test.go) register ahttpForwardand ahttpError
(dropConnection) action via the Go client and drive real requests that assert the SERVER actually
performs them: the forwarded request loops back through a match-once, higher-priority self-forward to
a distinct upstream response body (needing no externally-reachable upstream, so it runs identically in
the CI sibling-container harness and against a locally port-mapped server), and the error endpoint
drops the connection at the transport level (no HTTP response) while a control endpoint on the same
server still answers cleanly. Previously the Go client's response-action coverage was builder/JSON
only -- no test drove a forward or error action to completion over a socket. -
Rust client wire tests proving the server actually enforces negation matchers and performs
response actions. The Rust integration suite previously only asserted control-plane serialization
(matcher_value_tests.rs) and registered a forward expectation it never drove (test_forward_expectation
"won't actually forward"), so nothing proved a running MockServer acted on either. Three new
#[ignore]d integration tests drive a live server over the data plane via a dependency-free raw
socket:test_negation_matcher_enforced_over_wireregisters aNottableStringnegation (bare
"!foo", explicitMatcherValue::not_literal, and an escaped literal"!foo") and asserts the
server matches a non-foovalue (200) while excludingfoo(404) — and that an escaped"!foo"
matches literally rather than as a negation;test_forward_action_actually_forwardsregisters a
higher-priority run-once FORWARD that loops back to the server's own port plus a lower-priority
fall-through RESPOND, and asserts the distinctive fall-through body is returned only if the forward
genuinely executed (topology-independent — no external upstream); and
test_error_action_actually_returns_raw_bytesregisters an ERROR action and asserts the server
writes the configured raw bytes back. Run in CI by the existingrust-integration-teststep. -
Wire-level .NET client test coverage for NottableString header negation and for
forward/error response actions. The .NET integration suite asserted expectation creation but
never proved the server honours what the client sends: there was no test that a!fooheader
matcher (MatcherValue.NotLiteral) is transmitted and enforced over the wire, nor that a
registered forward or error action is actually performed. A newWireBehaviorTestsdrives real
requests through a running MockServer (reached via the existingMOCKSERVER_URLharness) to prove:
a "not foo" header matcher matches a non-foorequest (200) and rejects afoorequest (404); the
escaped literalMatcherValue.Literal("!foo")matches only a header whose value really is!foo;
a forward action is genuinely forwarded (the path is received twice — original plus the re-entered
forward — not merely served directly); and an error action actually drops the connection (a
transport failure, not an HTTP status). Each assertion was confirmed to redden when the
corresponding client behaviour is degraded. -
Live-broker test coverage proving Kafka SASL credentials reach and are enforced by a real
broker. Kafka SASL/SSL security was only asserted at the property-map level
(KafkaMessagePublisherSecurityTest) and every live-broker Kafka integration test used a plaintext
bootstrap, so nothing proved the configured credentials actually authenticate against a broker. A
newKafkaSecurityLiveBrokerIntegrationTeststarts a Testcontainers Kafka whose external listener
isSASL_PLAINTEXT/PLAINwith a broker-side JAAS config that knows a single credential, then
drives MockServer'sKafkaMessagePublisherwith aKafkaSecurity: a correctly-credentialed
publisher publishes successfully and the message is read back by a matching credentialed consumer,
while a wrong-password publisher is rejected with an authentication exception (proving enforcement,
not merely that plaintext works). Docker-gated so it SKIPS cleanly when Docker is unavailable. -
Credential-enforcement test coverage for MQTT security against a secured live broker. MQTT
MqttSecuritycredentials were only asserted at the options-carrier unit level
(MqttSecurityOptionsTest), while the sole live Mosquitto integration test ran an
allow_anonymousplaintext broker — so nothing proved credentials are actually applied and
enforced on the wire. A newMqttTlsLiveBrokerIntegrationTestdrives MockServer's MQTT publisher
against a Testcontainers Mosquitto broker configured with apassword_fileand
allow_anonymous false: it asserts that a publisher wired with the correctMqttSecurity
username/password authenticates and delivers a message to an authenticated subscriber, and that a
publisher with the wrong password (and one with no credentials) is rejected by the broker at
CONNECT. Docker-gated so it skips cleanly when Docker is unavailable. -
Live-broker test coverage for the AsyncAPI control-plane
load()→ publish-on-load path. The
control-plane's broker-connecting path (createBrokerConnections/publishOnLoad) was untested
against a real broker —AsyncApiControlPlaneImplTestloads without a reachable broker (asserting
publishers=0), and the endpoint IT covers only the broker-less endpoints. A new Docker-gated
AsyncApiControlPlaneLiveBrokerIntegrationTest(Testcontainers Kafka) drivesload()with a real
brokerConfig,publishOnLoad:trueandconsume:true, then proves the control-plane genuinely
connected and published by consuming the on-load message with a plain third-party Kafka client and
assertingstatus()reportspublishers>0, a subscriber, and the recorded round-tripped message. -
Over-the-wire test coverage for an LLM refusal preset served with a rate-limit quota. The LLM
refusal presets, provider-specific rate-limit headers, and stateful request-count quota were only
asserted at the body-builder / handler-unit level; nothing drove them through a running server. A
newLlmRefusalQuotaRateLimitIntegrationTestserves anhttpLlmResponseconfigured with an
Anthropic refusal preset and a 2-request quota, then asserts on the raw socket response that the
first two requests return a200refusal envelope (stop_reason:"refusal") carrying the
anthropic-ratelimit-requests-*headers, and that the third (over-quota) request flips to a429
rate_limit_errorenvelope with the exhausted rate-limit headers andRetry-After.
Fixed
- The
testcontainers-mockserver(Python) port assertions no longer break against testcontainers
4.15.0, which keysDockerContainer.portsbystr(port)rather thanint.with_exposed_ports
now storesself.ports[str(port)] = None(4.15.0 types the attribute as
dict[str, Optional[int]]), so the suite'sassert 1080 in container.portsstarted failing with
assert 1080 in {'1080': None}and took five tests — and the wholeMockServer Pythonpipeline,
and with it the umbrellaMockServerbuild — red onmaster. The tests now read the exposed ports
through a normaliser that parses each key to anint(tolerating a"1080/tcp"protocol suffix),
so they assert the same thing under either key style. This also closes a latent false green: the
assert MOCKSERVER_PORT not in container.portscheck intest_replaces_default_portpassed
trivially once the keys became strings, and so would no longer have caughtwith_server_port
failing to drop the previously exposed port. Only the tests changed —MockServerContaineritself
was already correct, asget_exposed_porttakes anintand normalises internally.
<blobStoreKeyPrefix>/<file name>instead of under the writing machine's absolute local path, and a
blobStoreKeyPrefixthat does not end in a separator is now treated as a folder-style prefix instead
of being glued straight onto the key (mockserver+x.jsonwasmockserverx.jsonand is now
mockserver/x.json). Anything persisted by an earlier version is stored under the OLD name and will
NOT be restored after upgrading — the one-line migration is below.** The blob key was the ABSOLUTE
localpersistedExpectationsPath(for example/var/folders/.../persistedExpectations.json) and the
configuredblobStoreKeyPrefixwas concatenated onto it with plain string addition. With the prefix
shape the documentation recommends —blobStoreKeyPrefix="mockserver/", with a trailing separator —
that composedmockserver//var/folders/.../persistedExpectations.json, and MinIO rejects the doubled
//outright with HTTP 400, "Object name contains unsupported characters", so under that one
configuration nothing was ever persisted and consequently nothing could be restored on restart. Under
every other prefix shape the write SUCCEEDED and restore worked — a leading/is a legal byte in an
S3 object name, and the read composed exactly the same name back — but the object was then named after
the writing container's local filesystem layout, so a second instance that resolved
persistedExpectationsPathto a different absolute path (started from a different working directory,
or in a different container) looked under a different name and silently restored nothing. The key is
now derived by the new sharedorg.mockserver.state.BlobKeyshelper inmockserver-core: for every
store other thanFilesystemBlobStorethe key is the FILE NAME ofpersistedExpectationsPathalone,
and prefix and key are joined with exactly one separator, with any leading separator dropped and any
repeated separators collapsed, so every prefix shape a user can configure — unset,mockserver,
mockserver/,/mockserver/— now produces the same valid object name
(mockserver/persistedExpectations.json). That normalisation is applied for all
put/get/list/deleteoperations whereverblobStoreKeyPrefixis applied, so it renames EVERY
blob key, not only the persisted-expectations document.FilesystemBlobStoreis unaffected: it
interprets the key as a file path, so it keeps the absolute path and writes exactly the file it always
did. Upgrading: because the old key always embedded the absolute local path and the new key is the
bare file name, the object name changes for every non-filesystem persistence user, under every prefix
shape and on every platform — there is no configuration in which the old name is preserved. On the
first start after upgrading, the restore looks under the new name, misses (logged atINFOwith the
name it looked for), and the instance starts with no restored expectations; the next expectation change
then writes a fresh object under the new name and leaves the old one behind. To carry existing state
across the upgrade, copy or rename the object once before starting the new version, for example
aws s3 mv s3://<bucket>/<prefix>/<old absolute path> s3://<bucket>/<prefix>/<file name>— otherwise
accept the miss and let the first expectation change re-create it. One long-standing footgun goes away
with the change: restoring after a restart no longer requires the two instances to resolve
persistedExpectationsPathto the same absolute path, only to the same file name. Deployments that
must NOT share state within one bucket should give each its ownblobStoreKeyPrefix(or its own file
name). Proven by a Docker-gated MinIO round trip that writes and then reads back an expectation with a
trailing-slashblobStoreKeyPrefix(the exact configuration that returned HTTP 400 before), a MinIO
put/get/list/delete round trip across all four prefix shapes, and Docker-free unit coverage of the key
composition and of the key the persistence layer derives. - The configuration enforcement-evidence guard no longer certifies evidence it cannot see.
ConfigurationEnforcementClassificationTestrecords, for every risky configuration property, the
Class#methodtest that proves an instance-set value changes observable behaviour. It validated those
pointers by loading the class — but it runs inmockserver-core, so any pointer naming a test in a
sibling module was silently skipped onClassNotFoundException. That exempted precisely the most
valuable evidence, the end-to-end Layer C pointers: renaming, moving or deleting the referenced test
left a dangling pointer and the guard still passed green, formaxRequestBodySize,
maxResponseBodySize,wasmEnabled,redactSecretsInLog,clusterEnabled,dnsEnabled,
grpcBidiStreamingEnabled,http3ConnectUdpEnabledandtransparentProxyEnabled. A class that
cannot be loaded is now resolved
by locating its.javasource under any module'ssrc/test/javaand asserting the file declares both
the class and the referenced method, so cross-module pointers are checked in a full reactor build and
when only some modules are built. The guard fails closed: a pointer resolvable by neither route is now
a failure naming the dangling pointer, never a silent skip. Anti-vacuity assertions in the spirit of
the siblingConfigurationCallSiteGuardTestkeep the scan honest — the set of pointers resolved by
source scan must match the declared cross-module ratchet exactly,mockserver-nettyand
mockserver-state-infinispanmust both have contributed, and classpath resolution must still cover
the bulk of the pointers — so a scan that resolves nothing cannot pass. Verified by degrading a real
mockserver-nettytest method name: the guard now fails with a message naming the dangling pointer,
where the previous version passed green with the identical defect in place. - gRPC trailing metadata is no longer silently dropped over HTTP/3. Every trailer an expectation
authored —response().withTrailer("x-request-cost", "42"), and the gRPC chaos profile's
customTrailers— reached an HTTP/1.1 or HTTP/2 client but never reached an HTTP/3 client at
all, in every branch of the HTTP/3 gRPC response path (with a body and body-less, and both with
and without proto descriptors loaded). The same expectation therefore produced different trailing
metadata depending only on which transport the client happened to use, and there was no error or
warning anywhere to indicate the loss — a test asserting on trailing metadata over HTTP/3 simply
saw nothing. The cause was thatHttp3GrpcResponseWriterbuilds its HTTP/3 frames by hand rather
than throughMockServerHttpResponseToFullHttpResponse.mapResponseWithTrailers(which is what
carries trailers on the other transports), andGrpcHttp3Adapter.buildTrailingHeadersFrame/
buildTrailersOnlyFramepopulated onlygrpc-statusandgrpc-message; the response's own
trailers were never read. They are now emitted on the terminal frame: on the trailing HEADERS
frame when the response has a body, and folded into the Trailers-Only frame when it does not,
which is the shape gRPC defines for that form (HTTP-Status Content-Type Trailers, where
Trailersincludes custom metadata) and leaves the framing unchanged — there is still exactly one
terminal frame, written withSHUTDOWN_OUTPUT, so an added trailer cannot cost the response its
end-of-stream marker the way it did on HTTP/2 before the fix above. A user-authored trailer cannot
override or spoof the transport's own status: the new shared
GrpcResponseStatusResolver.passThroughTrailers(the trailer twin ofpassThroughHeaders)
excludesgrpc-status,grpc-messageandgrpc-status-name, mirroring the exclusion the HTTP/2
path makes inremainingTrailers, and also excludes the connection-specific fields,
content-length/content-typeand pseudo-header names that RFC 9114 forbids in a trailer section.
Trailer field names are lower-cased and CR/LF stripped from values before reaching the frame,
because HTTP/3 field names must be lower-case and Netty'sDefaultHttp3Headersrejects an
upper-case one by throwing — so an expectation authoringwithTrailer("X-Request-Cost", …)would
otherwise have taken the client's entire response down rather than dropping a single field.
Verified over the wire by two new tests inHttp3GrpcIntegrationTestthat drive a live in-JVM
Netty QUIC client and read the metadata off the HEADERS frames it actually received, asserting
which side each value arrived on (a trailer must not be folded into the initial headers, a
response header must not be repeated as a trailer) and asserting the HEADERS-frame count so the
metadata cannot arrive at the cost of correct framing, plus adapter-level coverage in
GrpcHttp3AdapterTest. Positive control: neutering the trailer pass-through in production turns
all seven new assertions red with the trailer absent. - A FILE response body is now served verbatim (and templated) on every response path, and a FILE
request body is now actually matched (#2450). A response whose body is aFILE(afilePathwith
no template engine) was previously read on the static response action only; the same FILE body
returned from an object callback, a class callback, a response template, or a forward
responseOverridereached the wire unread, emitting the file path string instead of the file
contents. Materialisation now lives in a single sharedFileBodyMaterialiserinvoked from the two
response-write funnels (writeResponseActionResponse, covering the static, object-callback,
class-callback, response-template and SSE paths, andwriteForwardActionResponse, covering the
forwardresponseOverride), so all five producers — and the shared WAR/servlet path — serve the file
contents. Templated FILE bodies (aFileBodycarrying a Velocity/MustachetemplateType) are rendered
against the request on these paths too, not only verbatim ones; a text content type yields the decoded
string and a binary or absent content type yields the raw bytes intact. A missing or unreadable file
now produces a clean, logged500whose body does not leak the path, instead of a broken connection or
the path string. Separately, aFILEbody used for request matching had no case in
BodyMatcherBuilder, so the body constraint was silently ignored (it matched any body); it now matches
the request body against the exact file contents (string or binary). This resolves the earlier
"static response only" caveat. - A forward
responseOverridethat replaces the body is no longer truncated to the upstream
Content-Length. When a forwarded request's response is overridden with a new body,
HttpResponse.update()replaced the body but kept theContent-Lengthinherited from the upstream
response. A replacement body longer than the upstream body was therefore truncated on the wire (and a
shorter one could over-run) — visible only to a real client, since every layer above the encoder held
the full, correct response.update()now drops the inheritedContent-Lengthwhenever the override
supplies a new body (or agenerateFromSchema), unless the override itself sets an explicit
Content-Length(which is still honoured verbatim), so the encoder recomputes the length from the
actual body. This closes the residual on the forward-override path left by the FILE-body fix above. - A gRPC error response carrying custom trailing metadata no longer loses its status. On HTTP/2 a
body-less gRPC response is collapsed into the gRPC Trailers-Only form, which movesgrpc-status
into the initial HEADERS frame and relies on that frame being end-of-stream. When the expectation
also authored a custom trailer withwithTrailer(...), that trailer kept a separate trailing
HEADERS frame alive, so the initial frame was no longer end-of-stream: a real client read it as
ordinary headers (wheregrpc-statusis ignored) and then found no status at all in the terminal
frame, failing the call withUNKNOWN: missing GRPC status. In other words, adding a single
trailer to an error response destroyed the error — the caller lost both the status code and the
message.GrpcToHttpResponseHandler.asTrailersOnlyIfHttp2now skips the Trailers-Only collapse
whenever any user-authored trailer remains, keepinggrpc-status/grpc-messagein the trailing
HEADERS frame alongside the custom metadata, which is the correct shape in that case. The same fix
covers the gRPC chaos fault path, which produced the byte-identical broken shape: a fault response
configured withcustomTrailersemits them as real trailers alongsidegrpc-status/grpc-message
on a body-less response, so a chaos-injected error over HTTP/2 also reached the client as
UNKNOWN: missing GRPC statusinstead of the configured status. Found by the new real-client
trailing-metadata coverage described under Added. - gRPC-Web now re-frames matched-expectation responses correctly over a real HTTP/1.1 socket, and
is covered by an over-the-wire integration test. Every previous gRPC-Web test drove the handler
through anEmbeddedChanneland setx-grpc-web-content-typedirectly on the response, so none
exercised the actual mock-matching path: there the marker lives on the request only and was lost,
and a matched expectation went back to a browser client asapplication/grpcwithgrpc-statusin
HTTP trailers a gRPC-Web client cannot read. The original request content-type is now retained in
the per-streamGrpcPendingRequestsrecord alongside the resolved service/method, so
GrpcToHttpResponseHandlerre-frames the response as gRPC-Web (length-prefixed message frame + a
0x80trailer frame carryinggrpc-statusin the body, base64-encoded for the-textvariant).
A newGrpcWebOverTheWireIntegrationTestposts a realapplication/grpc-weband
application/grpc-web-textframed request to a running server over a raw socket and asserts on the
exact bytes a browser client would receive. - The AsyncAPI control-plane HTTP endpoints now have an over-the-wire integration test.
PUT /mockserver/asyncapi,GET /mockserver/asyncapiandPUT /mockserver/asyncapi/verifywere
only exercised at the orchestrator/control-plane level, so a regression in the Netty →
HttpState→AsyncApiControlPlaneRegistryrouting or response wiring would not have been caught.
A newAsyncApiControlPlaneIntegrationTestboots a real MockServer and drives all three endpoints
over a raw socket without any live broker: it asserts the load response (201,loaded:true,
channel count, zero publishers/subscribers), the status response (200, channels, counts, and the
empty/unloaded case), and the broker-less verify verdict (406with the "at least 1 … found 0"
failure detail) plus the blank-body400. - A
StreamingBodyresponse delivered to a real HTTP/2 inbound client is now covered end-to-end.
NettyResponseWriter.writeStreamingResponsere-stamps the request's HTTP/2 stream id onto the
streaming response head (the field is not part of the copied header multimap) and flushes each chunk
as it arrives, but no test drove that path with a real HTTP/2 client —Http2SseStreamingIntegrationTest
covered only the SSE sibling and explicitly noted theStreamingBodycase was untested, while the
existing streaming-relay tests drive an HTTP/1.1 inbound socket. A newHttp2StreamingBodyIntegrationTest
drives a real prior-knowledge h2c multiplex client through astreamingResponsesEnabledforward
MockServer to an SSE upstream and asserts on the frames the client receives on its OWN request stream:
both events arrive (proving the stream-id stamp — the #2419 class), and the early event's DATA frame
arrives promptly as one of at least two separate, in-order DATA frames rather than being buffered into
one. Degrading the write path to buffer chunks until stream completion was verified to make the
incremental-delivery assertion go red. - PROXY-protocol destination resolution is now proven to drive transparent-proxy forwarding over a real
socket.ProxyProtocolOriginalDestinationHandlerwas only exercised viaEmbeddedChannel, which asserts
the handler sets theREMOTE_SOCKETchannel attribute but never that this attribute actually chooses the
forward target end-to-end. A new non-privileged loopbackProxyProtocolForwardingIntegrationTestruns
MockServer withtransparentProxyEnabled=trueand no fixed remote, opens a raw socket, writes a valid
PROXY v1TCP4header naming a loopbackEchoServeras the destination followed by a plain GET whose
Hostheader points at an unrelated (closed) decoy port, and asserts the EchoServer reflects the request
back — proving the PROXY-protocolREMOTE_SOCKET, and not theHostheader, drives forwarding. The test
needs noNET_ADMIN/privileged capability because the PROXY-protocol header is an application-level byte
prefix. Verified as a genuine regression guard by a positive control: ignoring the PROXY-header
destination turns the forwarding assertions RED, restoring it returns them GREEN. - A FILE response body with no template engine now serves the file contents, not the file path (#2450).
A static response with a body of typeFILEand afilePathbut notemplateTypepreviously returned
the literal file-path string as the response body instead of the file's contents; only adding a
templateType(e.g.MUSTACHE) caused the file to actually be read.HttpResponseActionHandlernow
reads any FILE body that is not template-rendered — notemplateType, or an unsupported one such as
JavaScript — and serves its contents verbatim, preserving the declared content type. (A FILE body
returned from an object/class callback, from a response template, or as a forwardresponseOverride
bypasses this handler and is addressed separately.) Binary files (images, PDFs,
archives, identified via the content type) are served as raw bytes so they are not corrupted by
charset decoding; text files are served as-is with no template processing. A missing file fails the
same way as the templated path. - The VS Code extension (
mockserver-vscode) now compiles under TypeScript 7. TypeScript 7 no
longer auto-includes every installed@types/*package, so@types/node's ambient declarations
(thepath/fs/crypto/child_processmodule globals, theNodeJSnamespace,Buffer,
process,console) were dropped andtsc -p ./failed with 97 errors. The extension's
tsconfig.jsonnow explicitly opts@types/nodeback in via"types": ["node"]— the fix the
compiler itself suggests — with no change to the extension's source or its published output. This
is build tooling only and is not shipped to extension users. - The drift
responseTimeThresholdMsperformance-flag gate is now covered by behavioural tests.
DriftAnalyzer.checkPerformanceDriftraises aPERFORMANCEdrift record only when an expectation's
observed p95 latency exceeds the instance-setresponseTimeThresholdMs, but no test drove responses
straddling that threshold, so a regression that flagged everything (or nothing) would not have been
caught. A newDriftPerformanceThresholdTestfeeds the realPercentileTrackerlatencies under and
over the threshold and asserts on the productionDriftStoreoutcome: the over-threshold case flags
exactly onePERFORMANCErecord (withexpectedValue=<=thresholdand the actual p95), the
under-threshold and disabled (0) cases flag nothing, a slow-tail distribution whose p95 crosses the
threshold flips, and theDriftAlertNotifierwebhook fires only when the flag is raised and its
severity meets the notifier threshold. - LLM provider codecs now have their emitted token-usage counts asserted, not normalized away. The
LlmCodecGoldenFileTestgolden drift harness deliberately zeroes usage blocks before comparing (usage
counts are structural, not stable values), which meant the golden files alone could not prove a codec
emits the correct token counts — a codec that silently regressed usage to0, or swapped
input/output, would still have matched its golden. A new
LlmCodecGoldenFileTest.shouldEncodeCanonicalTokenUsageCountscloses that blind spot: for all seven
chat/completion providers (OpenAI, OpenAI-Responses, Anthropic, Gemini, Bedrock, Azure-OpenAI, Ollama)
it encodes the canonical text and tool-call completions and asserts the actual encoded token-count
fields — named per each provider's published usage schema (prompt_tokens/completion_tokens/
total_tokens,input_tokens/output_tokens,usageMetadata.promptTokenCount/candidatesTokenCount/
totalTokenCount, Ollama's top-levelprompt_eval_count/eval_count) — equal the hand-authored
canonicalUsagevalues (input 12 / output 8 for text, 25 / 15 for tool-call), usingasInt(-1)so a
dropped or missing field fails the equality rather than silently defaulting to0. - GenAI span emission on the LLM SERVE path is now covered end-to-end. When MockServer serves a
locally-mockedhttpLlmResponsecompletion,HttpLlmResponseActionHandleremits an OpenTelemetry
GenAI (gen_ai.*) span viaGenAiSpans.recordCompletion(...)— a distinct code path from the
forward/proxy-path emission already guarded byForwardPathGenAiSpanEmissionTest, and previously
untested end-to-end. A newServePathGenAiSpanEmissionTestinstalls anInMemorySpanExporter
through theGenAiSpanExporter.startWithProcessor(...)seam, drives a realMockServerserving an
OpenAI-shaped completion, and asserts exactly one GenAI span carryinggen_ai.request.model,
gen_ai.system, and the input/output usage-token attributes is produced by the production serve
path (read back from the exporter, not reconstructed). - The HTTP parser limit
maxHeaderSizeis now covered by a behavioural test. The three parser
limits (maxInitialLineLength,maxHeaderSize,maxChunkSize) are wired into the Netty
HttpServerCodecin the HTTP/1.1 request pipeline (PortUnificationHandler.switchToHttp), but no
test drove an over-limit request, so a regression that dropped the configured value and fell back to
Netty's 8192-byte default — or removed the wiring entirely — would have gone unnoticed. A new
HttpParserLimitsIntegrationTeststarts a server configured withmaxHeaderSize=1024and drives raw
HTTP/1.1 requests over a plainSocketagainst a header-conditional expectation. The
client-observable effect of the limit is header truncation: Netty's decoder stops parsing at the
byte that crosses the limit and drops every header after it (MockServer logs the decode failure but
still serves the request from the headers it did parse). A control request whose marker header sits
within the 1024-byte limit is parsed, matches, and returns the mocked200; an otherwise-identical
request with a ~2KB filler header inserted ahead of the marker pushes the marker past the boundary,
so it is dropped, the request no longer matches, and MockServer returns404. The filler size sits
strictly between the configured limit and Netty's 8192-byte default, so the test is a genuine
positive control: reverting the wiring to ignore the configuredmaxHeaderSize(using the default)
lets the whole header block through, the marker survives, and the over-limit request matches and
returns200— turning the test red (confirmed). - OpenAI Responses API
previous_response_idchaining andGET /v1/responses/{id}retrieval are now
covered end-to-end over a real socket. These stateful behaviours were previously exercised only at the
handler+store level (OpenAiResponsesStateTest), so a regression in the wire path — the automatic
storing of an issued response, the codec's reconstruction of a prior turn fromprevious_response_id,
or theGET-based retrieval — would not have been caught. A newOpenAiResponsesStateEndToEndTestboots
a real MockServer, POSTs a first/v1/responsesturn (defaultstore:true) and captures its response
id, POSTs a second turn carrying only the new input plusprevious_response_id, and asserts over the
wire that the chained turn matches (proved via awhenTurnIndex(1)predicate that can only match once
the prior assistant turn has been reconstructed) and thatGET /v1/responses/{id}returns the stored
response body. - The
outputMemoryUsageCsvmemory-usage CSV export is now covered by tests.MemoryMonitoring
builds a CSV header from thebuildStatistics()keys on construction and appends a data row on each
logMemoryMetrics()call, but this export path had no test anywhere. A newMemoryMonitoringTest
enables CSV output to a JUnitTemporaryFolderand asserts the file is created, its header row
exactly matches thebuildStatistics()column keys, a triggered data row has a matching column count
with a positive numericheapUsedvalue, and that NO file is written whenoutputMemoryUsageCsvis
disabled. TOKEN_BUCKETrate-limit enforcement is now covered end-to-end through the handler/wire path.
Every 429-rendering test (HttpActionHandlerRateLimitTest,RateLimitIntegrationTest) previously used
onlyFIXED_WINDOW;TOKEN_BUCKETwas exercised solely at the registry level, so a regression that
failed to render the synthetic 429 for a token-bucket limit would not have been caught. A new
HttpActionHandlerRateLimitTest.tokenBucketBurstOfOneAllowsBurstThenReturns429drives two immediate
requests against aTOKEN_BUCKETlimit withburst=1and a negligible refill through the real
HttpActionHandler, asserting the algorithm-specific behaviour: the burst of one is allowed (normal
response), the second request exhausts the bucket and returns a429carrying
X-RateLimit-Limit: 1(the bucket burst),X-RateLimit-Remaining: 0, and theRetry-After/reset headers.- WASM custom-rule host-isolation is now pinned by a test. A WASM rule module can only reach the
filesystem or any host/WASI capability through functions the host explicitly imports into the instance,
andWasmRuntimedeliberately wires NONE — it instantiates every module with a bare
Instance.builder(module).build(), neverwithImportValues(...). No test asserted this, so a
regression that started wiring host imports would have gone unnoticed. A new
WasmRuntimeHostIsolationTesthand-assembles a minimal-but-valid module whose import section declares
wasi_snapshot_preview1.fd_writeand whose exportedmatchactually calls it, then asserts chicory
refuses to instantiate it (UnlinkableException, mirroring the runtime's own build call), that
WasmRuntime.callMatchtherefore fails closed tofalse, and — as a positive control — that supplying
a stubfd_writehost import (the wiring MockServer omits) makes the identical module instantiate. The
refusal assertion was confirmed to go RED when the host import is wired and GREEN when it is not. - Request-side OpenAPI violations in
trafficValidateare now covered by an integration test. Both
existingTrafficValidateIntegrationTestcases only exercised response-schema violations, leaving the
request-validation half of the traffic-validation path (OpenApiTrafficValidator→
OpenAPIRequestValidator) unverified end-to-end. A new
shouldReportFailureWhenRecordedRequestViolatesSpecrecords aPOST /petswhose body omits the
requiredid/namefields (with a 201 response that conforms to the spec, isolating the failure to
the request side) and asserts the resultingContractReportsurfaces a failing result carrying
REQUEST validation errors. - The
driftDetectionEnabledmaster switch is now covered by a behavioural enforcement test. The
existingDriftDetectionConfigTestonly re-implemented the gate boolean inline and never exercised
the production code path, so a regression that ignored the flag would not have been caught. A new
HttpActionHandlerDriftDetectionTestdrives the real forward request path throughHttpActionHandler
— forwarding a request whose upstream response drifts (500) from a matching response-type stub (200) —
and asserts that a STATUSDriftRecordIS recorded into the sharedDriftStorewhen
driftDetectionEnabled(true), and that NONE is recorded whendriftDetectionEnabled(false)or the
sample rate is zero. This asserts on the real drift-recording outcome rather than a hand-mirrored
copy of the gate. - The transparent-proxy original-destination end-to-end suites are now collectable by CI. The three
privileged interception suites —SoOriginalDstEndToEndIntegrationTest(iptables REDIRECT +
SO_ORIGINAL_DST),TproxyEndToEndIntegrationTest(iptables TPROXY / IP_TRANSPARENT) and
EbpfOriginalDestinationEndToEndIntegrationTest(pinned BPF map read path) — were previously named
*EndToEndIT, a suffix that matches NEITHER Surefire's**/*Test.javainclude NOR Failsafe's
**/*IntegrationTest.javainclude, so they were never compiled into a run set and never executed on
any build. They are renamed to*EndToEndIntegrationTestso Failsafe collects them, and each now
additionally SKIPS cleanly (rather than erroring) when the Docker daemon refuses to start the required
NET_ADMIN /--privilegedsibling container (e.g. a user-namespace-remapped daemon), via
DockerCliTestSupport.containerStartRejected(...). A new opt-in CI step
(.buildkite/scripts/steps/java-transparent-proxy-test.sh,RUN_TRANSPARENT_PROXY_E2E=true) runs
them under the Docker socket and asserts viaassert-suite-ran.shthat they actually executed; by
default it prints a loud, visible notice that they were not run, because the standard build agents
lack thedockerCLI and reject--privilegedcontainers.
Added
- The mock-drift detection pipeline now has an end-to-end assembly test spanning the live forward
through to theGET /mockserver/driftretrieval endpoint. The individual pieces (DriftAnalyzer,
DriftStore, and thedriftDetectionEnabledgate inHttpActionHandler) were unit-tested, but no
test drove the assembled path the Drift dashboard actually depends on: a live forward whose upstream
response differs from a co-registered response stub → asynchronous drift analysis → the process-wide
DriftStore→ the real control-planeGET /mockserver/drifthandler that reads it back. A new
DriftEndToEndAssemblyTestforwards a request through the realHttpActionHandler(upstream 500 vs a
stub's 200, drift analysis forced to run synchronously), then servesGET /mockserver/driftthrough a
realHttpStateand asserts the returned JSON contains the recordedSTATUSdrift (both unfiltered
and via theexpectationIdquery filter the dashboard uses); a companion case proves a non-drifting
forward leaves the endpoint empty. Registered in the sequential Surefire phase because it mutates the
singletonDriftStoreandPercentileTracker. - The WAR servlet decoder's RFC 6265 cookie surrounding-quote stripping now has direct coverage.
HttpServletRequestToMockServerHttpRequestDecoderTestgains a test that feeds a
jakarta.servlet.http.Cookiewhose value carries surrounding double quotes ("quotedValue", as
Servlet 6 / Tomcat 11+ preserves) alongside an already-unquoted value, and asserts the mapped
HttpRequestcookies arequotedValue(quotes stripped) andplainValue(unchanged) — pinning the
stripSurroundingQuotes(...)behaviour that every prior fixture left unexercised because it only used
plain ASCII values a container never quotes. - The dashboard static-asset handler's default MIME-type fallback is now covered, extending the
#2358 null-Content-TypeNPE guard to unmapped file extensions. Every existingDashboardHandlerTest
serves a mapped extension (.js,.svg), soMIME_MAP.getOrDefault(extension, DEFAULT_MIME_TYPE)
never exercised its fallback arm — the exact branch that turns an unmapped extension into a valid,
non-nullapplication/octet-streamheader instead of the null value that crashes Netty's header
encoder. A new test serves a syntheticunmapped-fixture.webp(an extension deliberately absent from
bothMIME_MAPand the string-content list) and asserts the served response is found (not the 404
not-found response) and carriesContent-Type: application/octet-stream. - The
BCKeyAndCertificateFactoryIPv6 Subject-Alternative-Name branch is now covered, closing the gap
where only IPv4 SAN IPs were exercised.BCKeyAndCertificateFactoryBehaviourTestgains
shouldIncludeIPv6AddressesInSAN, which configuressslSubjectAlternativeNameIps("127.0.0.1", "::1", "2001:db8::1"), generates the leaf certificate, and asserts the generated cert's iPAddress SAN entries
(GeneralName type 7) contain both IPv6 addresses AND the IPv4 address in the same certificate. Assertions
compare viaInetAddressso they are independent of the JDK's canonical string form for IPv6 (e.g.
::1->0:0:0:0:0:0:0:1). This pins theIPAddress.isValidIPv6/isValidIPv6WithNetmaskbranch of the
SAN-IP handling, previously reachable only through IPv4 literals. Timesexhaustion now has a direct passive-removal assertion, mirroring the existing time-to-live
test.AbstractControlPlaneIntegrationTestgainsshouldRemoveExhaustedTimesFromActiveExpectations
next toshouldRemoveExpiredTimeToLiveFromActiveExpectations: it registers an expectation with
Times.exactly(1), assertsretrieveActiveExpectations(null)reports one active expectation, makes the
single matching request that exhausts theTimes, then asserts the active list is now empty — WITHOUT a
second request. Previously exhausted-Timesremoval from the active list was only observed indirectly
via the wire 404 (a second request no longer matching); this pins that an exhaustedTimesexpectation
is dropped from the active list itself.- The OpenAPI forward-validate action's
LOG_ONLYmode now has behavioural passthrough coverage,
closing the gap where only the getter was asserted.HttpForwardValidateActionHandlerTestgains two
tests that drivehandle(...)withvalidationMode = LOG_ONLY: one sends a schema-violating request
and proves the bad request is still forwarded upstream (verify(mockHttpClient).sendRequest(...)) and
the upstream 200 flows back unchanged (not a 400); the other stubs a schema-violating upstream response
and proves it is returned unmodified (not a 502). These pin the "validate and log, but forward
unmodified" behaviour that distinguishesLOG_ONLYfrom the already-coveredSTRICTreject branches. - The
Http2StreamIdAuditHandlersafety-net is now covered by a unit test, so the guard against the
"HTTP/2 response head written without anx-http2-stream-id" defect class (GitHub issue #2419 and its
SSE / streaming-body / metrics / MCP siblings) can no longer silently stop warning. The handler is
the only thing that turns a mis-routed, silently-dropped HTTP/2 response into a loud WARN, yet it had
no test anywhere. A newHttp2StreamIdAuditHandlerTestdrives the handler on anEmbeddedChannelwith
a capturing logger and asserts the observable behaviour across three cases: an unstamped response head
logs exactly one WARN naming the missing header, a correctly-stamped head logs nothing, and a second
unstamped head on the same connection does NOT warn again (the per-connection dedup that stops a
genuinely-broken write site from flooding the log). Suppressing the warn reddens the first and third
assertions. - The Velocity
velocityDisallowClassLoadingsandbox now has coverage for taking effect when toggled
on an ALREADY-CONSTRUCTED engine, not just when a fresh engine is built. The existing test flipped
the setting and then built a brand-newVelocityTemplateEngine, so the runtime rebuild-on-live-engine
path (currentEngineHolder()rebuilding the underlyingVelocityEnginewith theSecureUberspector
when the configured flag differs from the flag the current engine was built with) was never exercised
— meaning a regression that made the setter/system-property/PUT /mockserver/configurationtoggle
inert on a cached engine would have reddened nothing. A new test builds ONE engine with class loading
allowed, renders a class-loading template and asserts it genuinely EXECUTES the class-loading line
(reachingRuntime.exec), then flipsvelocityDisallowClassLoading(true)on the SAME configuration
and re-renders through the SAME engine, asserting the class-loading line is now BLOCKED (inert, empty
body, no exception) — proving the live rebuild applies the new restriction. - The metrics endpoint's ENABLED path is now proven end-to-end over the handler, not just its
disabled 404 and CORS behaviour. Previously the only enabled-path coverage was a mock-ctxunit
test (MetricsHandlerTest) that asserted the content-type header was non-null but never that
GET /mockserver/metricsreturns 200 with a real Prometheus exposition body. Two new tests in
HttpRequestHandlerTestdrive the request through the realHttpRequestHandlerrouting and
MetricsHandler, with metrics enabled and themock_server_requests_receivedcounter incremented:
they assert the response is 200 (mapping it through the same wire encoder the server uses, since the
handler writes a status-less response the encoder resolves to 200 OK) and that the body carries the
mock_server_requests_received_totalseries. A second case sends an OpenMetricsAcceptheader and
asserts the negotiated OpenMetrics content-type, complementing the existing
shouldReserveMetricsPathWithCORSWhenMetricsDisablednegative (disabled -> 404) so the enabled path
is provably the difference. - The reflective cloud-blob-store auto-discovery path in
StateBackendFactorynow has direct test
coverage. PreviouslyStateBackendFactoryTestonlyinstanceof-checked the filesystem/memory blob
stores, sodiscoverBlobStoreBackend(...)and theBLOB_STORE_REGISTRARSmap — the reflective
blobStoreType=s3→Class.forName(...S3BlobStoreRegistrar)→register()→ factorycreate()
chain — were exercised by no test, and a broken registrar-class name or map wiring would have redded
nothing. Two layers now cover it: a newS3BlobStoreDiscoveryTest(inmockserver-blob-s3, which has
the S3 module on its classpath) configuresblobStoreType=s3and callsStateBackendFactory.create(...)
with NO manualregister(), asserting the resulting backend'sblobs()is anS3BlobStore— provable
only if discovery loaded the module reflectively (no network/Docker; the S3 client is built lazily); and
StateBackendFactoryTestgains a core-only assertion thatblobStoreType=s3with the module ABSENT
fails hard with the documentedIllegalStateException("add the mockserver-blob-s3 dependency") plus a
case proving an unrecognised type is rejected with the supported-types guidance. - The dashboard WebSocket frame now has a cross-boundary STRUCTURAL contract test, closing the gap
where the server and the UI were tested against separately-authored payloads and could silently
drift apart. A single checked-in contract file (mockserver-ui/src/__fixtures__/dashboardFrameContract.json)
lists, for every one of the four dashboard panels (log messages, active expectations, received and
proxied requests), the fields the UI store/panels actually read and their JSON types. The server side
(DashboardWebSocketFrameContractTest) drives the REALDashboardWebSocketHandleracross all four
panels, captures the frame it emits, and asserts every required field is present with the correct type
and that the server-assigned key correlations hold (a received-request row and its originating log
entry share the same server log id). The UI side (dashboardFrameContract.test.ts) feeds the same
file's representative frame through the real storeapplyMessageand asserts the resulting items
expose those same fields. Because both read the one file, renaming or removing a field name reddens
both tests; a server-side field rename reddens the Java test. Unlike the previous byte-equal captured
golden (which passed locally but drifted in CI), the check is a per-field SUBSET assertion — immune to
non-deterministic emission ordering, to timestamp/UUID/port/hostname values, and to
environment-dependent extra fields — and a companion assertion captures the frame twice and proves a
value-blind, order-independent structural fingerprint is identical across the two captures. - The REAL built dashboard bundle is now proven to be packaged and served, not just synthetic
fixtures. A new integration test starts a live MockServer, GETs/mockserver/dashboard, and
asserts the response is the genuine React application shell (theid="root"mount point and the
MockServer Dashboardtitle) that references a hashed JS entry chunk (assets/index-<hash>.js),
then GETs that referenced asset and asserts it is served (200) with a JavaScript content-type.
Previously the only dashboard-serving coverage used synthetic test fixtures placed at the same
classpath path thebuild-uiMaven profile copies the real Vite output into, so a broken or missing
real bundle (for example a Monaco-worker regression) reddened nothing. The test fails closed when the
built bundle is present but broken (missing hashed reference, or the referenced asset is not served),
and skips with a clear message only when thebuild-uiprofile did not run and no real bundle is on
the classpath. - The served dashboard now has real-browser end-to-end coverage against a live MockServer. A new
Playwright suite (mockserver-ui/e2e/) boots the runnable netty JAR — which serves the dashboard,
the/mockserver/*control plane, and the_mockserver_ui_websocketfeed on one origin — loads the
dashboard in headless Chromium, and asserts real end-to-end behaviour: (1) an expectation authored in
the composer UI matches a request fired over the wire, which then streams into the log panel live over
the real WebSocket; and (2) expectation create, update, and clear driven through the dashboard change
the server's own active-expectation list, verified over real REST (PUT /mockserver/retrieve).
Previously the dashboard had no browser-level coverage at all — all 3,000+ UI tests run in jsdom with
a mockedfetchand a hand-written WebSocket, so expectation CRUD and the live log stream were never
exercised against the actual endpoints. Wired into the UI pipeline (.buildkite/pipeline-ui.yml) as a
fail-closed CI gate that builds the current JAR, boots it, and runs the suite in the Playwright image. - Enabling
tlsMutualAuthenticationRequiredat runtime is now proven to be enforced over a real TLS
socket. A new integration test starts a live MockServer with mutual TLS OFF, confirms a
certificateless client completes the handshake, then requires mutual authentication at runtime on the
already-listening instance and asserts that a new certificateless connection is refused at the
handshake (fatal alert), while a client presenting a certificate trusted by MockServer's CA still
connects — proving the runtime change appliesClientAuth.REQUIREselectively rather than being
silently ignored or breaking TLS altogether. Previously the runtime-reconfiguration path was covered
only by a unit test asserting the cachedSslContextinstance was replaced (which cannot assert the
resultingClientAuth), while the wire-level client-authentication tests all enabled mutual TLS at
startup, so the enforcement outcome of a runtime enable was never asserted over the wire. - The OpenAPI validation-proxy enforce path is now proven end-to-end over a real socket. A new
integration test stands up a validation proxy (validateProxyOpenAPISpec+validateProxyEnforce)
that forwards unmatched traffic to a second (upstream) MockServer, then drives real requests through
it and asserts on the bytes the client receives: a schema-invalidPOST /petsis rejected with400
("OpenAPI request validation failed") and never reaches the upstream, a conformant request is
forwarded and served normally, and a non-conformant upstream response is rejected with502
("OpenAPI response validation failed"). Previously the enforce branch was only re-implemented inline
in a unit test, so the production short-circuit inHttpActionHandler.validateProxyRequest/
validateProxyResponsewas never exercised over the wire. - The core mocking-action matrix is now proven over cleartext HTTP/2 (h2c) with a real client. A new
integration test drives a real prior-knowledge Netty HTTP/2 multiplex client over a socket against the
insecure port and asserts on the bytes the client receives on its own stream for each action: a
respondaction delivers its status and body, a classcallbackdelivers its produced body, a
forwardaction relays the upstream body back, and anerroraction resets the stream with the
configured HTTP/2 error code. Previously the full action matrix ran only over HTTP/1.1 and
h2-over-TLS; h2c was exercised only by anEmbeddedChannelpipeline-shape test and gRPC-unary, so no
test proved a real cleartext-HTTP/2 client actually received the response body for these actions (the
streaming / stream-id sibling of gRPC issue #2419). The shared integration harness cannot cover this
because its client has no h2c prior-knowledge path — an insecure request tagged HTTP/2 silently falls
back to HTTP/1.1. - The core mocking-action matrix is now proven over HTTP/3 (QUIC) with a real client. A new
integration test drives a live Netty HTTP/3 client over QUIC against an HTTP/3-enabled MockServer and
asserts on the bytes the client receives on its own request stream for each action: arespondaction
delivers its status and body, a classcallbackdelivers its produced body, aforwardaction relays
the upstream body back, aforwardOverride(overridden-forwarded-request) action rewrites the request
and relays the overridden body back, and anerroraction resets the QUIC request stream (RFC 9114
RESET_STREAM) instead of returning a response. Previously the HTTP/3 tests covered trace-context,
mTLS capture, gRPC, streaming, MCP and lifecycle but none drove the forward / forwardOverride /
callback / error matrix over QUIC or proved the forwarded/callback body reached the client over
HTTP/3. The shared integration harness cannot cover this because its client has no HTTP/3 request path.
Skips cleanly where the native QUIC transport (BoringSSL) is unavailable. - Interactive breakpoints are now proven end-to-end over a live transport. A new integration test
starts a running server, opens the real breakpoint callback WebSocket viaMockServerClient.addBreakpoint,
and drives a real JDK HTTP client through the full pause -> dispatch -> resolve loop: a RESPONSE-phase
breakpoint whose client handler rewrites the matched mock response has the originating caller receive the
modified status and body (not the original), and a REQUEST-phase breakpoint whose client handler returns a
response ABORTs before the mock is generated so the caller receives the abort response instead. Assertions
are made only on what the originating HTTP client reads back from the running server, so a pass proves the
pause/resume/modify actually happened server-side. Previously breakpoints were exercised only by client
unit tests (mocked HTTP client) and registry/handler tests overEmbeddedChannel; no test connected the
real callback WebSocket client to a running server and drove a live request through a pause-resolve cycle. - VCR cassette replay is now covered end-to-end at the data plane. A new integration test loads a
cassette (recordedrequest -> responsepairs) through theload_expectations_from_filetool into
a running server, then drives real requests over a socket and asserts on the bytes the client
receives: a matching request is served the recorded response body, a request matching no recorded
entry falls through to404rather than borrowing another entry's response, and when a volatile
request-body field (e.g.request_id) is normalised away a live request carrying a different
volatile value still matches and is served the recorded body. Previously the cassette tests loaded a
fixture and asserted only the control-planeACTIVE_EXPECTATIONSecho, never proving a recorded
response was actually served. - SNI-driven per-host server-certificate selection is now proven end-to-end over a real TLS
handshake. A new integration test opens an actual TLS connection presenting a chosen, non-default
SNIHostName, then reads the served peer certificate and asserts its Subject Alternative Names
contain that host — and repeats with a second distinct SNI host on the same running server to prove
the certificate is regenerated per host. Previously this path was exercised only through
SniHandlerTest(anEmbeddedChannelasserting the hostname was added to the SAN configuration
set); no handshake test connected with a chosen SNI host and inspected the certificate the server
actually served. - The forward/proxy-path GenAI span emission is now covered end-to-end through a running server.
A new test boots a real forwardingMockServerthat proxies a chat-completions POST to an upstream
MockServer stubbed as an OpenAI endpoint, and reads the emitted span back out of an in-process
InMemorySpanExporterwired into the process-wide tracer — so the assertion exercises production
HttpActionHandler.emitForwardGenAiSpan(provider sniffing, response parsing, span recording) rather
than reconstructing it. Previously the forward path's span emission was only guarded by a core test
that hand-mirrored the production logic and never drove the running server. - gRPC client-streaming and bidirectional-streaming are now covered by a real grpc-java client
end-to-end. A new integration test drives an actualio.grpcchannel over h2c and asserts on the
bytes the client deframes — a single collected response for client-streaming, and two interleaved
replies plus the terminalgrpc-statustrailer for bidi. Previously these two RPC shapes were
exercised only throughEmbeddedChannel, the same mocked seam that let issue #2419 ship for the
unary and server-streaming paths. - The SPY and CAPTURE operating modes are now covered end-to-end at the data plane. A new
integration test drives an unmatched request through each mode and asserts the documented
behaviour: the request is proxied to the real upstream (the client receives the upstream body) and
the exchange is recorded so it can be retrieved as an expectation. The test proves the operating
mode is the decisive factor — the same request returns 404 in SIMULATE mode and is only proxied
and recorded once the mode is switched to SPY or CAPTURE. - The breakpoint and verification forms accept the same search syntax as the Traffic view. A quick
scope box on both forms takesmethod:,path:andhost:terms and fills the matcher fields from
them, so the operator vocabulary learned in the Traffic search works when writing a breakpoint
condition or a verification. It only ever fills the form — every existing field, including full
regex paths and the header, query-parameter and cookie matchers, still works exactly as before, and
a term using an operator the form cannot express (such asstatus:) applies nothing rather than
half of itself. Path globs are translated to the regex form MockServer matches paths with, so
path:/api/*selects the same requests in the form as it does in the search box. - Chaos host targeting now rejects targets that could never fire. MockServer matches a chaos host
exactly (case-insensitively, ignoring the port), so a wildcard, a pastedhost:search operator, a
URL scheme or a path silently produced a registration that appeared active and never faulted a
request. All four places a chaos host can be entered — the HTTP and TCP register forms, the Quick
Chaos strip and each stage of a chaos experiment — now refuse those with an explanation. The
experiment case mattered most: a dead wildcard there produced a completed experiment reporting a
clean resilience verdict having injected no faults at all. - The dashboard supports multiple workspaces in one browser tab. Investigating two things at once
meant losing your filters every time you switched between them, because the whole window shared one
view and one set of search terms. A workspace now bundles the current view and the five panel search
terms, so you can keep a filtered Traffic investigation in one and a Log Messages search in another
and switch between them without either losing state. Workspaces can be named, and are restored on
reload. The switcher row appears only once a second workspace exists, so a single-workspace user
sees no change beyond one new app-bar icon, and existing persisted view and search settings carry
over into the first workspace on upgrade. Captured data, the connection target, the request filter
and the theme stay shared — a workspace is a lens over one server's data, not a second connection,
and targeting a different MockServer instance per workspace is not yet supported. - The dashboard recognises GraphQL operations in captured traffic. Every GraphQL request is a
POST /graphql, so the Traffic and Log views showed a wall of identical rows and the operation name
— the only thing distinguishing them — was buried in the body. Rows carrying a GraphQL request now
show the operation type and name as a chip, and the sharedoperation:search operator filters by
name (globs supported), sooperation:Get*narrows to the queries you care about. The name is read
from theoperationNamemember when present and otherwise parsed out of the query document itself,
which is where it usually lives. Detection is deliberately strict — an ordinary JSON body that
happens to carry aquerykey is not treated as GraphQL — and parsing is bounded and never throws,
so a large, binary or malformed body degrades to no chip rather than an error. - The dashboard Traffic view can focus on a single upstream host. In proxy mode a session can
capture traffic from dozens of hosts. A collapsible host list at the top of the traffic list shows
each distinct host with its request count, busiest first; clicking one pinshost:<value>into the
search box and narrows the list, and clicking it again unpins. Pinning composes with whatever else
is in the search box rather than replacing it, and because the pin is just a search term it persists
across a view switch and a reload like any other search. The list appears only when captured traffic
spans more than one host, so mock-only sessions — where everything targets localhost — are
unaffected. Hosts are grouped by the same value the row displays and thehost:operator matches. - The dashboard expectation composer can fire a real request against the draft matcher and show the
live response. A "Try It" button beside "Test Matcher" opens an inline panel that derives an
editable HTTP request from the expectation being authored, sends it to MockServer, and renders the
status, headers, body and round-trip time. Because a matcher is a pattern rather than a request,
only exact non-negated values are pre-filled: regex, glob, schema, JSON-path, XPath and negated
matcher forms — and the numeric-comparison and content-negotiation forms used by header and query
matchers — are left blank and listed as underivable, so a pattern is never fired verbatim as though
it were a literal. Headers the browser forbids a page from setting (Cookie,Host,
Content-Lengthand the rest of the Fetch forbidden list) are named as unexercisable from the
dashboard rather than silently stripped byfetch. The dashboard is served by the same listener
that serves mock traffic, so the default target is same-origin; selecting one of the server's other
bound ports raises a CORS warning up front and distinguishes a CORS block from an unreachable port
when a send fails. - The dashboard TCP chaos form offers named network-condition presets. Seven one-click presets —
dial-up, Slow/Fast 3G (throughput and latency variants), satellite and a fragmented link — fill the
TCP chaos latency, bandwidth or fragmentation field for the host being registered. Throughput and
latency figures are anchored to Chrome DevTools' throttling profiles and every preset shows its
concrete numbers in the picker, since names like "3G" carry era-dependent implicit values. Because
MockServer's TCP chaos engine applies only the highest-priority configured fault
(down > reset_peer > limit_data > slicer > bandwidth > latency) rather than composing them, each
preset sets exactly one fault, so the panel never advertises a number the engine would discard;
throughput presets also show the read size below which the bandwidth ceiling has no effect. The
panel notes that TCP faults shape inbound request bytes only, not the response, and that latency is
charged per read rather than per round trip. - The dashboard search operators are now a shared, extensible filter DSL, and a search box no longer
offers an operator it cannot honour. Thestatus:/method:/path:vocabulary was hard-coded into
the traffic/expectation/request search matcher; it is now a field registry (lib/filterDSL.ts) where
each field declares how to resolve its value and whether it supports numeric comparison or glob
matching. The three existing operators behave exactly as before. Two new fields ship with it —
host:(glob, from the requestHostheader, resolved identically to the Traffic view's own host
column) andoperation:(glob, from a request bodyoperationName). A call site can now declare
which subset of operators it supports: the Log Messages panel declares none, so its placeholder
advertises only/regex/, and typingstatus:>=400 errorthere marks the field invalid and explains
that no field operators apply, instead of silently returning an empty list. - The declarative
rateLimitexpectation clause is now enforced on streaming response actions.
Previously the general-purposerateLimitclause was applied only to bufferedRESPONSE/FORWARD
actions, so a matchedSSE_RESPONSE,GRPC_STREAM_RESPONSEorWEBSOCKET_RESPONSEwas never
throttled. The samerateLimitResponseOrNullcheck now runs once per matched request at the top of
each of those three stream cases, so an over-limit request receives the deterministic429(with
Retry-AfterandX-RateLimit-*headers) instead of opening the stream; within the limit the stream
proceeds unchanged. Reuses the existingRateLimitRegistry(no second implementation). The
LLM_RESPONSEaction keeps its own token-based TPM/TPD limiter and is unaffected. - The generic CRUD simulation GET-list endpoint now supports pagination, sorting and field filtering.
The list path accepts optional query parameters —filterField+filterValue(case-insensitive
equality on a dot-separated attribute path),sortBy+sortOrder(asc/desc, missing values sort
last, stable), andpage+size(0-based page,size≤0 means no limit) — applied in the order
filter → sort → paginate. Malformed parameters return a 400. When any list parameter is active the
response addsX-Total-Count,X-PageandX-Page-Sizeheaders; a plain list request with no
parameters returns the exact legacy response (unchanged body, no extra headers). This is the generic
CRUD store's own query surface and is independent of the SCIM list callback's sorting/filtering. - Interactive breakpoints support an optional
maxHitsone-shot / bounded budget. A breakpoint
registered with"maxHits": 1pauses once and then auto-deregisters, so the next matching request
is no longer intercepted;"maxHits": 3fires three times then removes itself. Only real pauses
count against the budget, somaxHitscomposes withskipCount(hits skipped by askipCount
window do not consume the budget). Absent (or0/negative) keeps the legacy behaviour of never
auto-deregistering.maxHitsis validated as a positive integer (400 otherwise) and is echoed by
PUT /mockserver/breakpoint/matcherand listed byGET /mockserver/breakpoint/matchers. - Sixteen implemented control-plane endpoints are now described by the OpenAPI specification, and
therefore by the published Postman and Bruno collections, which are generated from it:
GET /mockserver/ready,GET /mockserver/config,GET /mockserver/proxyConfiguration,
GET /mockserver/http3status,GET /mockserver/metrics,GET /mockserver/cluster,
GET /mockserver/chaosExperiment/history,PUT /mockserver/recordings/promote,
PUT /mockserver/pact/import,PUT /mockserver/baseline/compare,PUT /mockserver/trafficValidate,
GET /mockserver/llm/optimisationReport,PUT /mockserver/llm/diffRuns,POST /mockserver/wasm/test,
DELETE /mockserver/wasm/modulesand the MCP endpoint/mockserver/mcp. All of these were
implemented and reachable but documented nowhere, so they were absent from the collections and from
any client generated from the spec.GET /mockserver/http3statushad no documentation at all
anywhere. Each signature was verified against its handler rather than transcribed, which corrected
four things a plausible reading would have got wrong:GET /mockserver/metricsdeliberately has
no bare/metricsalias (unlike its siblings, because/metricsis a plausible path for a
user's own mocked API and reserving it would shadow their expectation);PUT /mockserver/trafficValidate
acceptsspecUrlOrPayloadas an alias forspecand can answer 403 and 503, not just 200/400;
PUT /mockserver/llm/diffRunstreats an empty body as an empty filter rather than rejecting it; and
the MCP endpoint reports a missing or invalid session as a JSON-RPC error inside a 200, with
GET /mockserver/mcpa hard 405 rather than an SSE stream. - The
Expectationschema now declares all eleven properties it was missing —httpLlmResponse,
grpcStreamResponse,grpcBidiResponse,binaryResponse,dnsResponse,
httpForwardValidateAction,httpForwardWithFallback,beforeActions,afterActions,stepsand
capture— together with the supporting component schemas. The published specification described a
substantially smaller API than MockServer implements, so the LLM, gRPC streaming, gRPC bidi, binary
and DNS actions could not be expressed by any client generated from it. Note this was a
documentation gap only: the OpenAPI document is served verbatim and never parsed at runtime, and
incoming expectation JSON is validated againstorg/mockserver/model/schema/expectation.json, which
already declared all eleven — so these expectations were always accepted on the wire. - New
OpenApiSpecExpectationSchemaTestguards the specification against the Java model. The
existingOpenApiSpecSyncTestasserts the two copies of the spec are byte-identical, which makes
them one document but is blind to both copies being wrong together — which is exactly how the eleven
properties above went missing. The new test drives the comparison fromExpectationDTO, using the
same JacksonObjectMapperthat serialises expectations at runtime, so it fails when the server
gains a property the spec does not declare. It deliberately does not enumerate the schema and look
for matching Java fields: a test whose cases come from the artefact it polices cannot detect an
omission in that artefact. The reverse direction is asserted too, which is the shape that would have
caughtHttpChaosProfile.connectionDrop— documented, implemented nowhere, and propagated into the
Go client where users set a property the server silently ignored. - New
OpenApiSpecEndpointCoverageTestasserts every control-plane route the server dispatches is
described by the specification. This is the guard whose absence let the sixteen endpoints above go
undocumented: nothing compared the routes to the document. It extracts the route literals from the
canonicalrequest.matches("METHOD", PATH_PREFIX + "/path", "/path")dispatch shape in
HttpStateandHttpRequestHandlerand checks each against the spec'spaths. Because the control
plane is dispatched by anif / else ifchain rather than a route registry, there is no structured
object to enumerate and the extraction has to read source text — so the test also asserts a floor on
the number of routes it finds. That floor is the point: a refactor that changes the call shape then
fails loudly, instead of silently extracting zero routes and passing while guarding nothing. Three
dispatch mechanisms are deliberately out of scope rather than approximated (/mockserver/metrics,
matched by regex;/mockserver/mcp, matched by prefix; and the four templated{name}routes); all
are documented, just not machine-checked. Covering them cleanly needs a route registry the
dispatcher and the test can both read, which is the durable fix. - New end-to-end tests for
PUT /mockserver/crudandPUT /mockserver/debugMismatch, the two
least-defended endpoints in the control plane, both of which previously had no test reaching the
server's HTTP dispatch at all./crudis covered behaviourally rather than by status code: the test
registers a resource and then drives POST/GET/PUT/PATCH/DELETE against the registered base path,
asserting auto-increment continues past seeded ids, PATCH merges without clobbering, insertion order
holds, deletes 404 afterwards, and the UUID strategy yields non-numeric ids under a customidField.
Both endpoints' bare aliases (/crud,/debugMismatch) are covered, as are their error paths. - CI now fires every generated API-collection example at a live MockServer. The existing
collections gate regenerates the Postman and Bruno collections and diffs them against the committed
copies, which proves the generator is deterministic and the artifacts are current — but proves
nothing about whether the documented examples actually work. An endpoint whoserequestBodyis
required: truewith noexamplegenerates a bodyless request; the committed collection contains
it, regeneration reproduces it exactly, and the gate is green while every user who imports the
collection gets a 400. That had happened to/mockserver/baseline/compareand
/mockserver/pact/import, both now fixed with examples.scripts/collections/test_collections.py
already existed and was wired into no pipeline; it now runs as its own step. The step starts
MockServer on the agent and runs the checker over--network hostrather than mounting the Docker
socket, becauserun-in-docker.shalways withholds the socket from PR builds — a socket-based
wiring would have silently degraded to "cannot start a server" on exactly the builds that most need
checking, which is the same defect as the cloud-storage contract suites that skipped on 100% of CI
builds while reporting green. Examples that are known to be rejected today are listed in
KNOWN_FAILINGas a ratchet rather than an exemption list: each entry carries a reason, and an entry
that stops failing fails the run, so the list can only shrink. - New
javascriptAllowedClasses— an ALLOW-list for the classes JavaScript templates may resolve via
Java.type(...). When set it takes precedence overjavascriptDisallowedClassesand nothing outside the
list can be resolved. Entries match a class name exactly or, when they end in.*, as a package prefix
(e.g.java.util.*). An allow-list is the only form that is safe by construction: the existing deny-list
matched class names by exact string equality, so denyingjava.lang.Runtimestill left
java.lang.ProcessBuilder— andClass.forNamereach-through — available. Both lists now also support
package prefixes. The default is unchanged (no restrictions) so existing templates keep working; setting
javascriptAllowedClassesis the recommended hardening step for any instance that renders templates from
a source you do not fully control. Behavioural tests cover all three semantics: only listed classes
resolve (java.lang.Runtime,java.lang.ProcessBuilder,java.lang.Class.forName(...)and the explicit
Java.type('java.lang.Runtime')form are all refused at render time), the allow-list wins when a class is
on both lists, and a.*/.package prefix matches the package it names without leaking into a sibling
package that merely shares its leading characters. - New
wasmExecutionTimeoutMillis(default 5000) — a wall-clock execution budget for WASM custom rules.
WASM modules ran with no fuel, timeout or interrupt, so a module containing an unbounded loop pinned the
calling thread permanently; because WASM rules are evaluated during request matching this could wedge
matcher threads. An invocation exceeding the budget is now aborted and fails closed (treated as a
non-match). Set to 0 to restore the previous unbounded behaviour. Both this and the existing
wasmMaxMemoryPagesare now read from the live configuration at the point of use, so setting either on a
Configurationinstance or viaPUT /mockserver/configurationtakes effect — previously both were read
from the static property store, so only the system-property route worked while the others were accepted
and ignored.wasmEnabledis read the same way for the same reason. - CI now guards that every client library pins the same MockServer binary version. Each client
decides for itself which server binary its launcher downloads, through seven different mechanisms,
and three of them had no release-time bump at all: the Python and PHP launchers sat at7.1.0and
the Rust crate at7.3.0while the project released7.4.0, so those clients silently downloaded
a three-minor-old server and the shared binary cache the documentation promises was never shared.
All three are now corrected to7.4.0,scripts/release/prepare.shbumps them (hard-failing if a
pattern no longer matches), and.buildkite/scripts/steps/clients-version-consistency.shasserts
agreement so drift is caught between releases rather than at the next one. The check is emitted
unconditionally bygenerate-pipeline.shrather than behind a changed-path filter: the pins it
guards live in per-client directories, so a commit that drifts one routes only to that client's own
pipeline and would never reach a path-filtered gate — the drift vector and the guard would never
meet. The expected version is read from the topmost releasedchangelog.mdheading, so it also
works on shallow, tagless CI checkouts. - Event-log eviction is now observable.
MockServerEventLog.getEvictedLogEntryCount()reports how many
entries have been discarded because the log reachedmaxLogEntries(ormaxEventLogSizeInBytes), a WARN is
logged once on the first eviction (naming the currentmaxLogEntriesand the fact that verifications are
affected), and the count is mirrored to themock_server_evicted_log_entriesPrometheus counter when
metrics are enabled. Previously eviction was completely silent — no counter, no log line, no metric.
The count includes only true evictions: an explicitreset()/clear()resets it to zero. - The cassette-registry control-plane endpoints now have end-to-end test coverage. A new
over-the-wire integration test drivesGET/PUT/DELETE /mockserver/cassettesagainst a running
server and pins the documented contract:PUTregisters a cassette and returns201with the stored
entry,GETlists cassettes most-recently-used first (and re-registering an existing cassette moves it
to the front without duplicating it),DELETE(by query parameter or JSON body) removes a cassette so a
laterGETno longer lists it, a server reset empties the registry, and — when control-plane
authentication is required — every verb is rejected with401. No production behaviour changed.
authentication is required — every verb is rejected with401. The bare/cassettesaliases are
exercised alongside the/mockserver-prefixed paths, each rejected-input branch (PUTwith no body,
PUTwith nopath,DELETEwith neither apathquery parameter nor a bodypath) is pinned to its
400and its message, and the CORS headers that let the dashboard call these endpoints cross-origin are
asserted. No production behaviour changed.
Changed
- Editor and dashboard package lockfiles refreshed to clear three open advisories.
dompurify
(3.4.11→3.4.12) inmockserver-ui, where the fix matters most: the dashboard renders
captured request and response bodies it did not author, so a sanitiser bypass through
CUSTOM_ELEMENT_HANDLINGis a cross-site-scripting vector rather than the low-severity issue its
rating suggests.monaco-editorpinsdompurifyto an exact version, so the existingoverrides
floor was raised to^3.4.12rather than downgrading the editor. Alsofast-uri
(3.1.2→3.1.4, host confusion from a literal backslash and failed international-domain
canonicalisation) andlinkify-it(5.0.1→5.0.2, quadratic-timemailto:validation) in
mockserver-vscode, both transitive build/packaging tooling that is not shipped to extension users,
and both reachable by a lockfile refresh with no manifest change. - Dependabot now watches the VS Code extension's npm dependencies.
mockserver-vscodehas a
package-lock.jsonbut was missing from the npmdirectorieslist, so unlike every other Node
project it never received routine minor and patch update pull requests and drifted until its
dependencies raised security alerts. - The S3 blob-store config-to-client wiring is now covered by a behavioural unit test. A new
network-free test exercisesS3BlobStoreRegistrar.createS3BlobStore(...)directly and asserts the
resulting client/store reflects the configuration: a missing bucket throws, the region defaults to
us-east-1when unset (and honours an explicit region), an explicit endpoint override is applied
(and left unset otherwise), static credentials are used when supplied (falling back to the default
AWS credential chain when not), and the bucket and key prefix are passed through. Previously only
registration idempotency and a Docker-gated MinIO contract test (which hand-built its own client)
were covered, so a mis-wired property could pass unnoticed. - Node package lockfiles refreshed to clear six open denial-of-service advisories.
brace-expansion
(1.1.15→1.1.16,2.1.1→2.1.2) inmockserver-client-node,mockserver-nodeand
mockserver-testcontainers/node, plusjs-yaml(4.2.0→4.3.0) andprotobufjs
(7.6.4→7.6.5) inmockserver-testcontainers/node. All are transitive dev/test-tooling
dependencies, and every fixed version was already inside the existing declared ranges, so this is a
lockfile refresh only — nopackage.jsondependency bump and no newoverridesentry was required. - Chaos testing doc navigation refreshed for the multi-stage experiment features. The "On this page"
feature map onchaos_testing.htmlnow surfaces the scheduled-experiment sub-capabilities that were
documented in the body but not linked from the top of the page: recurring/scheduled (cron and delayed)
starts, the steady-state baseline pre-check, and experiment history. Two missing section anchors were
added so the new links resolve, and a pre-existing broken in-page link (#tcp_chaos→
#tcp_layer_chaos) was fixed. - The control-plane trust anchor is now mutable at runtime rather than frozen at startup. The
control-plane authentication handler (mTLS CA chain, JWT JWK source, OIDC issuer/audience/JWKS) is derived
from the LIVE configuration on every request instead of being built once during server bootstrap. This is
what makes enabling, disabling or re-pointing control-plane authentication on an already-running instance
actually take effect, instead of returning success and being silently ignored — but it is a genuine
widening versus immutable-after-bootstrap and is worth understanding. Any configuration route can move
the trust anchor of a running server: a system property, aConfigurationsetter, or
PUT /mockserver/configuration. Critically, aConfigurationinstance reads through to the process-global
staticConfigurationPropertiesstore for any field it has not set itself, so a server whose CA chain was
never pinned on its own instance will follow later mutations of the global store — including mutations made
by unrelated code sharing the JVM. To pin a trust anchor that unrelated code cannot move, set it on the
Configurationinstance you start the server with (an explicitly-set instance field wins over the static
store); embedded and test usage should not treat the global store as a client-configuration vehicle. If the
control plane is reachable by parties who should not be able to change its own trust anchor, keep
control-plane authentication enabled —PUT /mockserver/configurationroutes through the same gate. See
tls-and-security.md. - WIRE FORMAT: a matcher value whose first character is
!or?is now sent as an object rather
than a bare string. In the plain-string form a leading!means "not" and a leading?means
"optional", and the receiver strips those markers unconditionally — so asking for "path is
!foo" was transmitted as"!foo"and read back as "path is NOTfoo", the exact opposite of
what was requested, with no way to escape it. Such values are now serialised as
{"not": false, "value": "!foo"}, which the server already read verbatim. This only affects
values that were previously impossible to express correctly; every value that round-tripped
before is byte-for-byte unchanged on the wire, so existing expectations, recordings and fixtures
are unaffected. The object form is already permitted by the published JSON schema
(stringOrJsonSchema), andhttpWebSocketResponse.matchers[].textMatcherand
grpcBidiResponse.rules[].matchJsonhave been updated to reference it. The negated direction was,
and remains, expressible as a string:!!foostill means "not!foo". Generated Java code is
fixed the same way, emittingstring("!foo", false)instead of a bare literal that would be
re-parsed as a negation when the generated code runs.
Scope: matcher values only, not header/parameter/cookie names. A name is a JSON field name,
which cannot carry the object form, soheader(string("!X-Foo", false), "bar")still inverts. That
is pre-existing rather than a regression, needs a schema change to fix, and is recorded with the
reasoning intest-fixtures/expectations/known-gaps.json. - A DNS record that cannot be encoded on the wire now returns
SERVFAILinstead of being silently dropped
or emitted as corrupt bytes. Previously an unparseable IP address dropped that one record and still
returnedNOERROR(so the client saw a successful, empty answer), and an over-long label or mismatched
address width was written to the wire unchecked. Configuration that cannot produce a conformant response is
now reported as a server failure, with the reason logged at ERROR. If a suite depended on a malformed
record being quietly skipped, it will now seeSERVFAIL— the record needs correcting. - DNS TXT values longer than 255 octets are now split across multiple character-strings rather than
truncated. Resolvers concatenate them, so the value a client reads is now the full configured value. A
test that asserted on the truncated 255-octet prefix will need updating — it was asserting on corruption. - BREAKING BEHAVIOUR:
verify(never())and other upper-bound verifications now FAIL instead of passing once
the event log has evicted entries. Suites that are green today may legitimately go red — that is the point.
Previously, when the event log rolled over, the entries proving a request had happened were silently
These release notes are truncated. This release's changelog entry exceeds GitHub's 125,000 character
limit for a release body. Read the complete entry in
changelog.md.