From 99f08453afc0a50e49236f12047510e06d7b69e6 Mon Sep 17 00:00:00 2001 From: t Date: Fri, 10 Jul 2026 21:18:30 -0500 Subject: [PATCH 1/3] test(icaptcha): assert obtain_proof consumes the mocked service Add an integration test that runs obtain_proof against a mockito server and asserts both /v1/challenge and /v1/answer were consumed, so the flow is proven to call the service rather than short-circuit or make a live call. Adds mockito as a dev-dependency. --- Cargo.lock | 12 +++-- crates/icaptcha-client/Cargo.toml | 3 ++ .../tests/icaptcha_mock_consumed.rs | 53 +++++++++++++++++++ 3 files changed, 63 insertions(+), 5 deletions(-) create mode 100644 crates/icaptcha-client/tests/icaptcha_mock_consumed.rs diff --git a/Cargo.lock b/Cargo.lock index d12daa43..5dd2903d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3300,7 +3300,7 @@ dependencies = [ [[package]] name = "git-remote-gitlawb" -version = "0.5.0" +version = "0.5.1" dependencies = [ "anyhow", "gitlawb-core", @@ -3312,7 +3312,7 @@ dependencies = [ [[package]] name = "gitlawb-attest" -version = "0.5.0" +version = "0.5.1" dependencies = [ "base64", "ed25519-dalek", @@ -3329,7 +3329,7 @@ dependencies = [ [[package]] name = "gitlawb-core" -version = "0.5.0" +version = "0.5.1" dependencies = [ "anyhow", "base64", @@ -3356,7 +3356,7 @@ dependencies = [ [[package]] name = "gitlawb-node" -version = "0.5.0" +version = "0.5.1" dependencies = [ "alloy", "anyhow", @@ -3412,7 +3412,7 @@ dependencies = [ [[package]] name = "gl" -version = "0.5.0" +version = "0.5.1" dependencies = [ "alloy", "anyhow", @@ -3821,9 +3821,11 @@ version = "0.4.0" dependencies = [ "anyhow", "gitlawb-core", + "mockito", "reqwest", "serde", "serde_json", + "sha2", "thiserror 2.0.18", "tracing", ] diff --git a/crates/icaptcha-client/Cargo.toml b/crates/icaptcha-client/Cargo.toml index 0a3a9215..67378536 100644 --- a/crates/icaptcha-client/Cargo.toml +++ b/crates/icaptcha-client/Cargo.toml @@ -16,3 +16,6 @@ anyhow = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } sha2 = { workspace = true } + +[dev-dependencies] +mockito = "1" diff --git a/crates/icaptcha-client/tests/icaptcha_mock_consumed.rs b/crates/icaptcha-client/tests/icaptcha_mock_consumed.rs new file mode 100644 index 00000000..01cac589 --- /dev/null +++ b/crates/icaptcha-client/tests/icaptcha_mock_consumed.rs @@ -0,0 +1,53 @@ +//! INV-19 guard: the icaptcha flow must actually call the service (consume the +//! mock), not short-circuit or make a live call. If a future change let +//! `obtain_proof` skip the network (e.g. a stray `cfg(test)` relaxation, which +//! INV-19/INV-20 warn is inert across crates), the `.assert()` calls below go +//! RED because the mocked endpoints were never hit. + +use icaptcha_client::{obtain_proof, Challenge, IcaptchaCfg}; + +#[test] +fn obtain_proof_consumes_the_mocked_service_and_makes_no_live_call() { + let mut server = mockito::Server::new(); + + // The two endpoints the flow hits, each expected exactly once. + let challenge = server + .mock("POST", "/v1/challenge") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + r#"{"challengeId":"c1","type":"anagram","difficulty":1,"prompt":"listen","token":"tok-1"}"#, + ) + .expect(1) + .create(); + + let answer = server + .mock("POST", "/v1/answer") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"status":"passed","proof":"PROOF-XYZ"}"#) + .expect(1) + .create(); + + let cfg = IcaptchaCfg { + url: server.url(), + did: "did:key:zTEST".to_string(), + level: 1, + api_key: None, + }; + + // "anagram" is not a built-in solvable type, so the flow consults this + // solver callback. The answer body itself does not matter: the mocked + // /v1/answer returns "passed" regardless of what is submitted. + let solve = |_c: &Challenge| Some("silent".to_string()); + let solver: &dyn Fn(&Challenge) -> Option = &solve; + + let proof = obtain_proof(&cfg, Some(solver)) + .expect("obtain_proof should complete against the mocked service"); + assert_eq!(proof, "PROOF-XYZ"); + + // INV-19: prove the client actually called the mocked endpoints. Had it made + // a live call or skipped the network, these would fail (mock never hit). + challenge.assert(); + answer.assert(); +} From 8c25af64c4df24d778fad10615975ec86df25e75 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 13 Jul 2026 16:28:59 -0500 Subject: [PATCH 2/3] test(icaptcha): block non-mock egress so the no-live-call guard is real (#184) The mock-consumed assertions only prove the two mock endpoints were hit; they cannot see a request that also leaks the DID, answer, or API key to DEFAULT_URL or any other origin, so the test's stated "no live call" guarantee was untested. Blackhole every non-loopback destination for the flow: NO_PROXY lets the loopback mock through while ALL_PROXY routes any other host to a closed port, so the client contacting anything but the injected mock fails the request. Verified load-bearing: pointing cfg.url at DEFAULT_URL makes obtain_proof fail with a proxy tunnel error (RED), and the loopback-mock path still passes (GREEN). --- .../tests/icaptcha_mock_consumed.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/crates/icaptcha-client/tests/icaptcha_mock_consumed.rs b/crates/icaptcha-client/tests/icaptcha_mock_consumed.rs index 01cac589..fd75b13f 100644 --- a/crates/icaptcha-client/tests/icaptcha_mock_consumed.rs +++ b/crates/icaptcha-client/tests/icaptcha_mock_consumed.rs @@ -3,11 +3,29 @@ //! `obtain_proof` skip the network (e.g. a stray `cfg(test)` relaxation, which //! INV-19/INV-20 warn is inert across crates), the `.assert()` calls below go //! RED because the mocked endpoints were never hit. +//! +//! The mock-consumed assertions alone only prove the mock was hit; they cannot +//! see a request that ALSO leaks the DID / answer / API key to `DEFAULT_URL` or +//! any other origin (a different host the mock never observes). To make the +//! "no live call" half of the guard load-bearing, the test blackholes every +//! non-loopback destination: `NO_PROXY` lets the loopback mock through while +//! `ALL_PROXY` routes any other host to a closed port, so the client contacting +//! anything but the injected mock fails the request instead of silently +//! succeeding. Point `cfg.url` at `DEFAULT_URL` and this test goes RED. use icaptcha_client::{obtain_proof, Challenge, IcaptchaCfg}; #[test] fn obtain_proof_consumes_the_mocked_service_and_makes_no_live_call() { + // Blackhole every non-loopback origin: the loopback mock bypasses the proxy + // (NO_PROXY), any other host is forced through a closed port (ALL_PROXY) and + // fails. reqwest reads these at client-build time, and obtain_proof builds + // its client fresh, so this bounds the transport for the whole flow. This + // test binary runs a single test in its own process, so the global env is + // not racing a sibling test. + std::env::set_var("NO_PROXY", "127.0.0.1,localhost"); + std::env::set_var("ALL_PROXY", "http://127.0.0.1:1"); + let mut server = mockito::Server::new(); // The two endpoints the flow hits, each expected exactly once. From 593b83fa1de4ffef5605f638cfe94c3e6e556740 Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Tue, 14 Jul 2026 22:49:05 -0500 Subject: [PATCH 3/3] test(icaptcha): blackhole scheme-specific proxy vars so the no-live-call guard covers https The mock-consumed guard blackholed non-loopback egress with ALL_PROXY only, but reqwest honors HTTPS_PROXY/https_proxy (and the http equivalents) OVER ALL_PROXY for https URLs. On a runner whose environment has a working HTTPS_PROXY, a regression could still leak the DID/answer/API key to the https DEFAULT_URL while both loopback mock calls are consumed and the test stayed green. Override the scheme-specific proxy vars (both cases) to the closed port too; NO_PROXY keeps the loopback mock direct. Single #[test] per process, so the override needs no restore. Verified: a throwaway repro showed reqwest routing an https request to HTTPS_PROXY past the ALL_PROXY blackhole (the gap); with the fix the override sends that egress to the closed port even when a working HTTPS_PROXY is pre-set; the guard still goes RED when cfg.url points off-loopback. --- .../tests/icaptcha_mock_consumed.rs | 39 +++++++++++++++---- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/crates/icaptcha-client/tests/icaptcha_mock_consumed.rs b/crates/icaptcha-client/tests/icaptcha_mock_consumed.rs index fd75b13f..e2571dab 100644 --- a/crates/icaptcha-client/tests/icaptcha_mock_consumed.rs +++ b/crates/icaptcha-client/tests/icaptcha_mock_consumed.rs @@ -8,23 +8,46 @@ //! see a request that ALSO leaks the DID / answer / API key to `DEFAULT_URL` or //! any other origin (a different host the mock never observes). To make the //! "no live call" half of the guard load-bearing, the test blackholes every -//! non-loopback destination: `NO_PROXY` lets the loopback mock through while -//! `ALL_PROXY` routes any other host to a closed port, so the client contacting +//! non-loopback destination: `NO_PROXY` lets the loopback mock through while a +//! closed proxy port routes any other host nowhere, so the client contacting //! anything but the injected mock fails the request instead of silently //! succeeding. Point `cfg.url` at `DEFAULT_URL` and this test goes RED. +//! +//! `ALL_PROXY` alone is NOT enough: reqwest honors the scheme-specific +//! `HTTPS_PROXY` / `https_proxy` (and the http equivalents) OVER `ALL_PROXY`, so +//! a runner with a working `HTTPS_PROXY` in its environment would route an https +//! leak to `DEFAULT_URL` (which is https) through that proxy while the loopback +//! mock is still consumed and this test stayed green. So we blackhole the +//! scheme-specific variables too. No restore is needed: this file has a single +//! `#[test]`, so it runs in its own process and the override never races a +//! sibling test. use icaptcha_client::{obtain_proof, Challenge, IcaptchaCfg}; #[test] fn obtain_proof_consumes_the_mocked_service_and_makes_no_live_call() { // Blackhole every non-loopback origin: the loopback mock bypasses the proxy - // (NO_PROXY), any other host is forced through a closed port (ALL_PROXY) and - // fails. reqwest reads these at client-build time, and obtain_proof builds - // its client fresh, so this bounds the transport for the whole flow. This - // test binary runs a single test in its own process, so the global env is - // not racing a sibling test. + // (NO_PROXY); any other host is forced through a closed port and fails. + // reqwest reads these at client-build time, and obtain_proof builds its + // client fresh, so this bounds the transport for the whole flow. + // + // Set the scheme-specific vars too (both cases), not just ALL_PROXY: reqwest + // gives HTTPS_PROXY/https_proxy precedence over ALL_PROXY for https URLs, so + // an ALL_PROXY-only blackhole leaves an https leak to DEFAULT_URL open on a + // runner whose environment already has a working HTTPS_PROXY. + let blackhole = "http://127.0.0.1:1"; std::env::set_var("NO_PROXY", "127.0.0.1,localhost"); - std::env::set_var("ALL_PROXY", "http://127.0.0.1:1"); + std::env::set_var("no_proxy", "127.0.0.1,localhost"); + for var in [ + "ALL_PROXY", + "all_proxy", + "HTTPS_PROXY", + "https_proxy", + "HTTP_PROXY", + "http_proxy", + ] { + std::env::set_var(var, blackhole); + } let mut server = mockito::Server::new();