Sample: the ledger pattern (issue #7)¶
A walkthrough of a real-world consumer's shape — fixed-port TLS intercept with a CA, imposter datafile hot-swap, revision-bump polling, and recorded-request verification — composed entirely from shipped rift-scala APIs.
Runnable spec: zio-testkit/src/test/scala/rift/zio/testkit/LedgerPatternSampleSpec.scala.
The scenario¶
A payments SUT reads account balances from a downstream HTTPS "ledger" API
(https://ledger.internal) and, on some paths, fires an async reconcile write back to it. In tests
we don't stand up a real ledger and we don't rewire the SUT's HTTP client to point somewhere else —
instead we intercept its TLS egress on a fixed port and redirect ledger.internal traffic to a
rift imposter that plays the ledger's part.
Six moves, all against shipped APIs:
- Mock the ledger. Create an imposter with
.recordon, carrying a scenario-A stub. - Intercept on a fixed port.
rift.intercept(InterceptConfig(port = 18443)), default generated CA, then a single unqualified rule that redirects everything forledger.internalto the imposter. - Prove the read path. A
java.net.http.HttpClientbuilt fromic.sslContext(trusts the intercept's minted leaf certs) and routed throughic.addressas an explicit proxy hitshttps://ledger.internal/accounts/42and gets scenario A's balance back. - Hot-swap the datafile.
ledgerImposter.replaceStubs(...)swaps in scenario B (frozen account) — same imposter, same intercept rule, same SUT config, only the stub set changes. - Revision-bump poll. After firing an async reconcile write,
eventuallyReceivedpolls the imposter's request log until it lands, instead of assuming synchronous delivery. - Verify.
verify(matching, Times.AtLeast(2))asserts the read landed at least twice (scenario A + scenario B);recorded(matching)reads the matching requests back out directly.
Step by step¶
1 — mock the ledger (scenario A)¶
val ledgerImposter = rift.create(
imposter("ledger").record.stub(
get("/accounts/42").reply(ok.json("""{"accountId":"42","balance":100,"status":"active"}"""))
)
)
(No .port(...): an absent port means "let the engine assign one" — passing 0 is rejected. The
imposter is reached through the intercept's redirectTo, so its port never matters here.)
2 — fixed-port TLS intercept, default CA, host-wide redirect¶
val ic = rift.intercept(InterceptConfig(port = 18443))
ic.rule("ledger.internal").redirectTo(ledgerImposter)
No .when(...) is chained on the rule: an unqualified .rule(host) redirects every request for
that host, so the same rule covers both the balance read (GET /accounts/42) and the later
reconcile write (POST /accounts/42/reconcile) — see InterceptRuleBuilder.redirectTo's own
scaladoc in rift.bridge.InterceptConnector, which calls this out as exactly the datafile-hot-swap
shape issue #7 needs.
Naming the host is right here, because this sample owns it. When the upstream host is not yours to
name — a third-party SDK with the host compiled in, or a JVM proxied wholesale — use the no-arg
ic.rule() instead: it leaves the facade's host unset, which the engine reads as a catch-all
matching every intercepted host. Everything downstream of the rule (.when, serve, forward,
redirectTo) is identical.
redirectTo earns its place here because this pattern hot-swaps a whole imposter's stubs. It is
not required merely to attach a behavior or a fault: serve carries the _behaviors/_rift
constructs the engine's IsSpec can express, so a slow gateway or a dropped connection is a
one-liner —
ic.rule().serve(ok.json(datafile).after(30.seconds)) // slow gateway
ic.rule().serve(ok.withTcpFault(TcpFaultKind.ConnectionResetByPeer)) // reset
Reach for redirectTo when you need what serve still refuses to guess at: copy/lookup
behaviors, an embedded _rift.script, or a forward-compatible behavior key this SDK does not model.
Those reject loudly with the offending key named, rather than being silently dropped.
3 — the SUT's client, routed through the intercept¶
val client = HttpClient.newBuilder()
.proxy(ProxySelector.of(proxyAddr)) // proxyAddr <- ic.address
.sslContext(sslCtx) // sslCtx <- ic.sslContext
.build()
ic.sslContext already trusts the intercept's CA (generated or committed — see below), so no
separate truststore file is needed for this in-process client.
4 — datafile hot-swap¶
ledgerImposter.replaceStubs(
Chunk(get("/accounts/42").reply(ok.json("""{"accountId":"42","balance":0,"status":"frozen"}""")).build)
)
The intercept rule and the SUT's proxy config never change — only the imposter's stub set does.
That's the whole value of replaceStubs: swap scenarios between test phases without tearing
anything down.
5 — revision-bump poll¶
ledgerImposter.replaceStubs(Chunk(/* scenario B GET stub */, post("/accounts/42/reconcile").reply(accepted).build))
// fire the SUT's async write in the background, then:
eventuallyReceived(ledgerImposter, post("/accounts/42/reconcile"), timeout = 5.seconds)
eventuallyReceived (from rift.zio.testkit.assertions) re-polls verify on a schedule until it
passes or the timeout elapses, returning the last observed result either way — so a timeout still
carries a near-miss diff, not a bare "timed out".
6 — verification¶
ledgerImposter.verify(get("/accounts/42"), Times.AtLeast(2))
val recordedGets = ledgerImposter.recorded(get("/accounts/42"))
On a mismatch, verify fails with the typed RiftError.VerificationFailed, whose
VerificationReport renders a near-miss diff — the closest recorded request and which predicate on
it failed — rather than a bare "not found" (D5; rendered by
rift.zio.testkit.assertions.renderVerificationFailure, exercised directly in AssertionsSpec).
CA note: generated vs. committed¶
The runnable sample uses the default generated CA (InterceptConfig(port = 18443), no ca
argument): the engine mints an ephemeral CA per run, and the SUT trusts it via the SSLContext
ic.sslContext returns — already carrying that CA in its trust chain. That is functionally
identical to a committed CA for everything this sample demonstrates.
A committed CA only buys you one extra thing: a stable root across process restarts — useful
when something outside this process (an OS trust store, a mounted truststore file, a separate SUT
process) needs to trust the intercept and can't just ask it for a fresh SSLContext each run. For
that case, a consumer supplies their own cert+key PEM pair:
def committedCaConfig(certPem: String, keyPem: String): InterceptConfig =
InterceptConfig(host = "127.0.0.1", port = 18443, ca = Some(CaMaterial(certPem, keyPem)))
This helper is defined (compile-checked) in LedgerPatternSampleSpec but never invoked from the
runnable path — this repo does not commit certificate/key material to source control. A real
consumer sources that PEM pair from a secrets manager or CI secret, not from the repo.
Why the test is guarded, not skipped in a suite layer¶
Rift.embedded acquires the embedded native engine, which isn't present on a bare CI JVM. The spec
checks Rift.isEmbeddedAvailable before building any layer, and
only calls Rift.embedded.build (inside ZIO.scoped) once that check has already passed — so CI
skips the test instead of failing it, without ever paying the acquisition cost. The same guard shape
is used by bridge.EmbeddedSmokeSpec and cats.EmbeddedSmokeSpec elsewhere in this repo.