diff --git a/Cargo.lock b/Cargo.lock index d12daa4..5dd2903 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 0a3a921..6737853 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 0000000..01cac58 --- /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(); +}