Skip to content

fix: Imposter.uri() reports http:// for an https imposter #250

Description

@EtaCassiopeia

Found by: EtaCassiopeia/zio-bdd#343 (PR EtaCassiopeia/zio-bdd#345), rift-java 0.3.1.

Imposter.uri() always reports an http:// URI, even for an imposter created with "protocol": "https". ImposterImpl.uri() builds it with HostAuthority.httpUri(host, port), which hard-codes the scheme (HostAuthority.java:27-28). The hostResolver fallback also seems to assume http, though I haven't checked that path fully.

Effect: a caller that trusts uri() sends plain HTTP to a TLS listener, and the request fails. zio-bdd sets MockSpace.baseUri from this value, so its native https escape hatch was unreachable. It now rewrites the scheme itself from the protocol it created the imposter with. That workaround should go once this is fixed.

Proposed fix: derive the scheme from the imposter's protocol when building the URI. For example, add HostAuthority.uri(scheme, host, port) and pass https when the definition's protocol is https. Apply the same to the hostResolver path.

Repro: create an imposter from {"protocol":"https","cert":"<pem>","key":"<pem>","stubs":[]}, then call uri(). It returns http://localhost:<port>, where https://localhost:<port> is expected.

Should-question: settled. This is a correctness bug in an existing accessor, not new surface.


Triage — confirmed; design decided: the imposter's protocol is the scheme, and the resolver is told it

SDK refs are rift-java master at 88b8849 (0.3.2-SNAPSHOT); engine refs are ~/Projects/rift at 0.18.0.

Desirability: build — defect in an existing accessor. uri() is documented as where to send requests; for an https imposter it names a scheme the listener refuses.
Recommended model: Opus — small change, but with one real subtlety (the testcontainers gateway, below) and a public-seam change on ConnectOptions.

Root cause

The scheme is hard-coded in two independent places, and ImposterImpl never learns the protocol at all.

  1. Bound-host path — ImposterImpl.uri() (rift-java-core/.../ImposterImpl.java:73) calls HostAuthority.httpUri(host, port), which is literally "http://" + … (HostAuthority.java:27-28). This is the path a spawned/embedded engine takes for an imposter with a concrete host (fix(core): Imposter.uri() reports the admin host, not the host the imposter is bound to #246).
  2. Resolver path — every resolver the SDK ships bakes in a scheme unrelated to the imposter, because the seam is IntFunction<URI> and receives only a port:
    • ConnectOptions.defaultHostResolver (ConnectOptions.java:102-107) copies the admin URI's scheme. That is the wrong object: the admin listener's scheme says nothing about an imposter's. (It also means an https admin URI would report https for a plain-http imposter.)
    • RiftImpl.embedded (RiftImpl.java:121) and RiftContainer.client() direct mode (RiftContainer.java:240) use httpUri.
  3. ImposterImpl carries only port and boundHost. The protocol is available in every place the host is already read from — the posted JSON on create (RiftImpl.create(JsonValue)), the replayable list on a local lookup — and additionally in the single GET, which the connected-engine imposter(port) path currently discards (RiftImpl.java:341-342 passes JsonObject.of() to imposterAt).

Engine facts (checked, not inferred): protocol is a required String on the imposter config (crates/rift-mock-core/src/imposter/types.rs:1435) and is always serialized, so every GET and list item carries it; the engine accepts only "http" and "https" (manager.rs:813, 1587), so scheme == protocol with no mapping table. The SDK already defaults an absent protocol to http (ImposterDefinition.DEFAULT_PROTOCOL), matching the engine.

In-repo symptom: ImposterClientAuthIT.java:91 hard-codes "https://127.0.0.1:" + imp.port() instead of imp.uri() — the live test had to work around this bug the same way zio-bdd did.

The subtlety: the resolver's URI is not always the imposter's listener

The proposed fix in the issue ("derive the scheme from the protocol … apply the same to the hostResolver path") cannot be applied as a blanket scheme override on the resolver's result. RiftContainer.withGateway() resolves to http://<host>:<mappedAdminPort>/__rift/<port> (RiftContainer.java:239). There the SUT speaks plain HTTP to the admin gateway, which dispatches to the imposter in-process (crates/rift-http-proxy/src/gateway.rs:35-40); forcing https onto that URI would break gateway mode for every https imposter. So the resolver must be told the protocol and decide for itself whether it applies. A resolver that cannot see the protocol cannot produce a correct URI — the IntFunction<URI> type is part of the bug.

Design

HostAuthority — add public static URI uri(String scheme, String host, int port); httpUri(host, port) becomes uri("http", host, port) and stays (admin URIs, RiftProcess, InterceptImpl are genuinely http).

New seam type — io.github.achirdlabs.rift.HostResolver:

@FunctionalInterface
public interface HostResolver {
    /** The address the SUT should use for the imposter bound on {@code port}, whose engine protocol is {@code protocol} ("http" or "https"). */
    URI resolve(String protocol, int port);
}
  • ConnectOptions.hostResolver() returns HostResolver (was IntFunction<URI>; the only caller in the tree is ImposterImpl).
  • ConnectOptions.Builder.hostResolver(HostResolver) — the new form.
  • ConnectOptions.Builder.hostResolver(IntFunction<URI>) — kept, adapted as (protocol, port) -> legacy.apply(port). Its URI is used verbatim, scheme included; Javadoc says so and points at the two-arg form. Overload resolution is unambiguous: the lambdas differ in arity. (Not deprecated: a resolver that targets a TLS-terminating hop legitimately wants to own the scheme.)
  • defaultHostResolver(adminUri) → (protocol, port) -> HostAuthority.uri(protocol, adminUri.getHost(), port). getHost() keeps IPv6 brackets and bracketed() leaves a bracketed host alone, so no behaviour change on the host.
  • RiftImpl.embedded → (protocol, port) -> HostAuthority.uri(protocol, options.adminHost(), port).
  • RiftContainer.client() direct mode → (protocol, port) -> HostAuthority.uri(protocol, getHost(), getMappedPort(port)); gateway mode → (protocol, port) -> URI.create(admin + "/__rift/" + port) with a comment: the gateway is reached at the admin listener's scheme whatever the imposter speaks.

ImposterImpl — gains private final String protocol, set at construction like boundHost:

  • uri() = boundHost.map(h -> HostAuthority.uri(protocol, h, port)).orElseGet(() -> options.hostResolver().resolve(protocol, port)).
  • Constructor (port, transport, options, Optional<String> boundHost, String protocol); the 3-arg convenience constructor (used by five unit tests) passes Optional.empty(), ImposterDefinition.DEFAULT_PROTOCOL.
  • Caching is safe: protocol and host are fixed for an imposter's lifetime on a port (the engine has no per-imposter PUT; replaceAll/delete+recreate already leaves an old handle's host stale, and protocol joins it).

RiftImpl.imposterAt(port, definition) — read protocol from the JSON alongside host, defaulting to ImposterDefinition.DEFAULT_PROTOCOL when absent (raw-JSON creates may omit it; the engine defaults to http). Unlike host, protocol is read for connected engines too — it is an attribute of the imposter, not an address. The connected imposter(port) path passes the GET body to imposterAt instead of JsonObject.of().

Out of scope, noted: InterceptImpl.uri() is an HTTP proxy address and is correctly http:// even when the intercept does TLS MITM via CONNECT.

Tests

  • HostAuthorityTest: uri("https", "::1", 4545) → https://[::1]:4545; uri("http", "127.0.0.1", 2525) equals httpUri(...).
  • ImposterUriHostTest (existing fake transport; note its createImposter reply is {"port":4545} with no protocol, so these prove create reads the protocol from the posted JSON):
    • https imposter with a concrete host on spawned and embedded → https://127.0.0.2:4545, https://[::1]:4545;
    • https imposter with no host / wildcard host → resolver path → https://127.0.0.1:4545 (spawned default resolver) and https://[::1]:4545 (embedded with adminHost("::1"));
    • lookups: list item {"port":4545,"host":"::1","protocol":"https"} → https://[::1]:4545; an item without protocol → http://.
  • RemoteTransportGateTest / RemoteTransportCoverageTest (connected engine, FakeAdminServer):
    • default resolver, https imposter created → https://<adminHost>:4545; rift.imposter(4545) with a GET body {"port":4545,"protocol":"https"} → https://… (the discarded-body fix);
    • legacy IntFunction<URI> resolver returning http://sut-host:… for an https imposter → URI used verbatim (existing hostResolverControlsImposterUri keeps passing);
    • two-arg resolver receives "https" and can honour it.
  • Live, ImposterClientAuthIT: replace the hard-coded "https://127.0.0.1:" + imp.port() with imp.uri() — spawned engine, absent host, so this exercises the default resolver with https. Add one case with .host("127.0.0.1").https(...) to exercise the bound-host path live.
  • testcontainers (optional, IT only since client() needs a started container): direct mode reports https:// for an https imposter; gateway mode still reports http://…/__rift/<port> for the same imposter.

Docs

  • Imposter.uri() Javadoc: the scheme is the imposter's protocol; the host rule from fix(core): Imposter.uri() reports the admin host, not the host the imposter is bound to #246 is unchanged.
  • ConnectOptions.Builder.hostResolver(..) Javadoc for both overloads (verbatim-scheme contract of the legacy form).
  • docs/testcontainers.md (lines ~35 and ~50): direct-mode comment gains the scheme rule; gateway section states the URI is always the admin listener's scheme.
  • docs/design/sdk-api.md:166-167 (hostResolver type and contract) and :312-314 (uri() rule).

Order

Independent; no engine change needed. After merge, ping EtaCassiopeia/zio-bdd#343 so the scheme-rewrite workaround from PR #345 can be removed.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingready-to-implementTriaged: root cause confirmed, design and implementation steps recorded

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions