From 246da2f17107fd5d4597fdc2e6c3ada6459e23ec Mon Sep 17 00:00:00 2001 From: Mohsen Zainalpour Date: Tue, 27 Oct 2026 10:47:57 -0400 Subject: [PATCH] fix(model): read a null https ca as absent, as rift 0.24.0 prints it rift 0.24.0 and Mountebank print "ca": null on an https imposter without one (rift#1576). ImposterDefinition.read passed that JSON null to CaCertificates.read, which wanted an array, so definition(), stub reads and recordings of every https imposter threw WireFormatException on the pinned engine. A null ca now reads as absent and is not written back; null and absent decode the same in the engine. A ca of any other wrong type is still refused, and ciphers keeps riding extra unchanged. Closes #341 --- .../conformance/ImposterClientAuthIT.java | 2 + .../rift/model/ImposterDefinition.java | 6 ++- .../model/ImposterClientAuthModelTest.java | 43 +++++++++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/rift-java-conformance/src/test/java/io/github/achirdlabs/rift/conformance/ImposterClientAuthIT.java b/rift-java-conformance/src/test/java/io/github/achirdlabs/rift/conformance/ImposterClientAuthIT.java index 4b779ff..9b70ac0 100644 --- a/rift-java-conformance/src/test/java/io/github/achirdlabs/rift/conformance/ImposterClientAuthIT.java +++ b/rift-java-conformance/src/test/java/io/github/achirdlabs/rift/conformance/ImposterClientAuthIT.java @@ -91,6 +91,8 @@ Stream uriNamesTheHttpsScheme() { assertEquals("hello", get(imp, Optional.empty()).body()); assertEquals("https", rift.imposter(imp.port()).orElseThrow().uri().getScheme(), "a lookup must report the scheme too"); + // rift 0.24.0 prints "ca": null on an https imposter without one (rift#1576, #341). + assertEquals(Optional.empty(), imp.definition().ca(), "definition() reads the engine's https view"); } } }); diff --git a/rift-java-core/src/main/java/io/github/achirdlabs/rift/model/ImposterDefinition.java b/rift-java-core/src/main/java/io/github/achirdlabs/rift/model/ImposterDefinition.java index ad5de1f..39e3af9 100644 --- a/rift-java-core/src/main/java/io/github/achirdlabs/rift/model/ImposterDefinition.java +++ b/rift-java-core/src/main/java/io/github/achirdlabs/rift/model/ImposterDefinition.java @@ -34,6 +34,9 @@ * every client). The model validates no combination of them, so it can read whatever an engine or a * fixture holds; rift refuses the combinations that cannot take effect when the imposter is created. * {@code ImposterSpec.requireClientCertificate} builds only the two it accepts. + * A {@code ca} of JSON {@code null}, which rift 0.24.0 and Mountebank print on an https imposter + * without one, reads as absent and is not written back. {@code ciphers}, which the engine keeps but + * does not apply, is not modeled and rides {@link #extra()} unchanged. * *

{@code enabled} is {@code false} for an imposter that holds its port and stubs but answers no * traffic. The engine writes the key only when it is {@code false}, and so does this codec; absent @@ -197,7 +200,8 @@ static ImposterDefinition read(JsonObject obj) { Optional.ofNullable(obj.get("_rift")).map(v -> RiftConfig.read(JsonSupport.requireObject(v, "_rift"))), JsonSupport.optBool(obj, "mutualAuth", false), JsonSupport.optBool(obj, "rejectUnauthorized", false), - Optional.ofNullable(obj.get("ca")).map(CaCertificates::read), + // rift 0.24.0 and Mountebank print "ca": null on an https imposter without one (rift#1576). + Optional.ofNullable(obj.get("ca")).filter(ca -> !(ca instanceof JsonNull)).map(CaCertificates::read), JsonSupport.optBool(obj, "enabled", true), JsonSupport.extraFields(obj, MODELED_KEYS)); } diff --git a/rift-java-core/src/test/java/io/github/achirdlabs/rift/model/ImposterClientAuthModelTest.java b/rift-java-core/src/test/java/io/github/achirdlabs/rift/model/ImposterClientAuthModelTest.java index e7c8e4d..8a05a28 100644 --- a/rift-java-core/src/test/java/io/github/achirdlabs/rift/model/ImposterClientAuthModelTest.java +++ b/rift-java-core/src/test/java/io/github/achirdlabs/rift/model/ImposterClientAuthModelTest.java @@ -1,6 +1,7 @@ package io.github.achirdlabs.rift.model; import io.github.achirdlabs.rift.json.JsonNumber; +import io.github.achirdlabs.rift.json.JsonString; import io.github.achirdlabs.rift.json.JsonValue; import org.junit.jupiter.api.Test; @@ -64,6 +65,48 @@ void anEmptyCaArrayIsKeptRatherThanDropped() { assertEquals(JsonValue.parse(json), JsonValue.parse(def.toJson())); } + /** rift 0.24.0's {@code ?replayable=true} export of an https imposter, verbatim (rift#1576). */ + private static final String ENGINE_024_HTTPS_EXPORT = """ + {"port": 24501, "protocol": "https", "ciphers": "ECDHE-RSA-AES128-GCM-SHA256", "recordRequests": false, + "stubs": [{"responses": [{"is": {"statusCode": 200}}]}], + "mutualAuth": false, "rejectUnauthorized": false, "ca": null} + """; + + @Test + void aNullCaReadsAsAbsent() { + ImposterDefinition def = ImposterDefinition.fromJson(ENGINE_024_HTTPS_EXPORT); + assertEquals(Optional.empty(), def.ca()); + assertFalse(def.mutualAuth()); + assertFalse(def.rejectUnauthorized()); + assertEquals(Map.of("ciphers", new JsonString("ECDHE-RSA-AES128-GCM-SHA256")), def.extra(), + "ciphers is not modeled, so it rides extra unchanged"); + } + + @Test + void aNullCaIsNotWrittenBackAndCiphersIs() { + // null and absent mean the same to every engine and to Mountebank, so the write omits it. + assertEquals(JsonValue.parse(""" + {"port": 24501, "protocol": "https", + "stubs": [{"predicates": [], "responses": [{"is": {"statusCode": "200"}}]}], + "ciphers": "ECDHE-RSA-AES128-GCM-SHA256"} + """), JsonValue.parse(ImposterDefinition.fromJson(ENGINE_024_HTTPS_EXPORT).toJson())); + } + + @Test + void aWholeExportWithANullCaParses() { + ImposterDefinitions defs = ImposterDefinitions.fromJson("{\"imposters\": [" + ENGINE_024_HTTPS_EXPORT + + ", {\"port\": 24502, \"protocol\": \"http\", \"stubs\": []}]}"); + assertEquals(List.of(24501, 24502), defs.imposters().stream().map(d -> d.port().orElseThrow()).toList()); + assertEquals(Optional.empty(), defs.imposters().get(0).ca()); + } + + @Test + void aCaOfAnotherTypeIsStillRefused() { + assertThrows(WireFormatException.class, () -> ImposterDefinition.fromJson(""" + {"protocol": "https", "ca": 5, "stubs": []} + """)); + } + @Test void combinationsTheEngineRefusesStillParse() { // Valid Mountebank, refused by rift 0.18.0 at creation: the model reads it and writes it back.