Skip to content

Commit ad7c2b2

Browse files
authored
test(gl): add tests for iCaptcha transparent retry flow (#167) (#168)
* fix(node): gate GET /ipfs/{cid} on reachable allowed-set, not deny-set (#126) The IPFS visibility gate used withheld_blob_oids (a deny-set enumerating only reachable blobs), so a dangling/unreachable blob was absent from the set and served in cleartext to anonymous callers. Flip to an allowed-set (allowed_blob_set_for_caller) that enumerates reachable blobs the caller may read: a dangling blob has no path, is never in the set, and 404s. * perf(ipfs): check object existence before allowed-blob walk Move store::read_object before the allowed_blob_set_for_caller spawn_blocking call so random-CID spray against repos with path-scoped rules cannot trigger full-history git walks on repos that don't carry the object. * refactor(ipfs): improve formatting and readability in get_by_cid function ✓ P3 blocker fixed: cargo fmt applied — the format gate will pass. ✓ P3 cleanup resolved: withheld_blob_oids is still used by replication code in repos.rs, so it stays. • P2 follow-up: Tree/commit disclosure tracked in #135 — out of scope here. * refactor(ipfs): streamline object retrieval by separating type and content reading * docs(ipfs): update get_by_cid comment to reflect split object retrieval * Run cargo fmt on store.rs * test(gl): add tests for iCaptcha transparent retry flow (#167) * fix(clippy): remove redundant borrow in format! arg * fix(review): use runtime GITLAWB_ICAPTCHA_INSECURE flag instead of cfg(test), add mock assertions * fix(review): set mock expectations at creation time, tighten insecure scheme check * fix(test): serialize icaptcha integration tests with global Mutex to prevent env-var race * fix(icaptcha): compare origins (scheme+host+port) in resolve_solver_url; tighten proof assertion in test * fix(test): save/restore inherited iCaptcha env vars instead of unconditional remove; share lock in icaptcha-client tests * fix(test): constrain negative send_once mocks with Matcher::Missing so they actually protect the contract --------- Co-authored-by: Gravirei <gravirei@users.noreply.github.com>
1 parent c98b503 commit ad7c2b2

2 files changed

Lines changed: 474 additions & 16 deletions

File tree

‎crates/gl/src/http.rs‎

Lines changed: 386 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,3 +185,389 @@ async fn obtain_proof(cfg: IcaptchaCfg) -> Result<String> {
185185
.await
186186
.context("iCaptcha solver task panicked")?
187187
}
188+
189+
#[cfg(test)]
190+
mod tests {
191+
use super::*;
192+
use gitlawb_core::identity::Keypair;
193+
use mockito::Server;
194+
use std::ffi::OsString;
195+
use std::sync::{Mutex, MutexGuard};
196+
197+
/// Serializes the two integration tests that touch the process-global
198+
/// `GITLAWB_ICAPTCHA_URL` / `GITLAWB_ICAPTCHA_INSECURE` env vars so they
199+
/// never race.
200+
static ICAPTCHA_ENV_LOCK: Mutex<()> = Mutex::new(());
201+
202+
fn test_keypair() -> Keypair {
203+
Keypair::generate()
204+
}
205+
206+
fn headers_from_pairs(pairs: &[(&str, &str)]) -> reqwest::header::HeaderMap {
207+
let mut h = reqwest::header::HeaderMap::new();
208+
for (k, v) in pairs {
209+
h.insert(
210+
k.parse::<reqwest::header::HeaderName>().unwrap(),
211+
v.parse::<reqwest::header::HeaderValue>().unwrap(),
212+
);
213+
}
214+
h
215+
}
216+
217+
// ── icaptcha_cfg ────────────────────────────────────────────────────
218+
219+
#[test]
220+
fn icaptcha_cfg_returns_some_when_both_headers_present() {
221+
let kp = test_keypair();
222+
let client = NodeClient::new("http://localhost", Some(kp.clone()));
223+
let headers = headers_from_pairs(&[
224+
("x-icaptcha-url", "https://icaptcha.gitlawb.com"),
225+
("x-icaptcha-level", "3"),
226+
]);
227+
let cfg = client.icaptcha_cfg(&headers).unwrap().unwrap();
228+
assert_eq!(cfg.did, kp.did().to_string());
229+
assert_eq!(cfg.level, 3);
230+
}
231+
232+
#[test]
233+
fn icaptcha_cfg_defaults_level_when_only_url_present() {
234+
let kp = test_keypair();
235+
let client = NodeClient::new("http://localhost", Some(kp));
236+
let headers = headers_from_pairs(&[("x-icaptcha-url", "https://icaptcha.gitlawb.com")]);
237+
let cfg = client.icaptcha_cfg(&headers).unwrap().unwrap();
238+
assert_eq!(cfg.level, icaptcha_client::DEFAULT_LEVEL);
239+
}
240+
241+
#[test]
242+
fn icaptcha_cfg_defaults_url_when_only_level_present() {
243+
let kp = test_keypair();
244+
let client = NodeClient::new("http://localhost", Some(kp));
245+
let headers = headers_from_pairs(&[("x-icaptcha-level", "5")]);
246+
let cfg = client.icaptcha_cfg(&headers).unwrap().unwrap();
247+
assert_eq!(cfg.level, 5);
248+
}
249+
250+
#[test]
251+
fn icaptcha_cfg_returns_none_without_icaptcha_headers() {
252+
let client = NodeClient::new("http://localhost", Some(test_keypair()));
253+
let headers = reqwest::header::HeaderMap::new();
254+
assert!(client.icaptcha_cfg(&headers).unwrap().is_none());
255+
}
256+
257+
#[test]
258+
fn icaptcha_cfg_returns_none_with_unrelated_headers() {
259+
let client = NodeClient::new("http://localhost", Some(test_keypair()));
260+
let headers = headers_from_pairs(&[("content-type", "application/json")]);
261+
assert!(client.icaptcha_cfg(&headers).unwrap().is_none());
262+
}
263+
264+
#[test]
265+
fn icaptcha_cfg_errors_when_no_keypair() {
266+
let client = NodeClient::new("http://localhost", None);
267+
let headers = headers_from_pairs(&[("x-icaptcha-level", "3")]);
268+
let err = client.icaptcha_cfg(&headers).unwrap_err();
269+
assert!(err.to_string().contains("identity keypair"));
270+
}
271+
272+
#[test]
273+
fn icaptcha_cfg_ignores_unparseable_level() {
274+
let client = NodeClient::new("http://localhost", Some(test_keypair()));
275+
let headers = headers_from_pairs(&[
276+
("x-icaptcha-url", "https://icaptcha.gitlawb.com"),
277+
("x-icaptcha-level", "not-a-number"),
278+
]);
279+
let cfg = client.icaptcha_cfg(&headers).unwrap().unwrap();
280+
assert_eq!(cfg.level, icaptcha_client::DEFAULT_LEVEL);
281+
}
282+
283+
// ── send_once ───────────────────────────────────────────────────────
284+
285+
#[tokio::test]
286+
async fn send_once_attaches_proof_header_when_provided() {
287+
let mut server = Server::new_async().await;
288+
let m = server
289+
.mock("POST", "/api/test")
290+
.match_header("x-icaptcha-proof", "test.proof.token")
291+
.with_status(200)
292+
.with_body("ok")
293+
.create_async()
294+
.await;
295+
let client = NodeClient::new(server.url(), None);
296+
let resp = client
297+
.send_once("POST", "/api/test", b"{}", Some("test.proof.token"))
298+
.await
299+
.unwrap();
300+
assert_eq!(resp.status(), 200);
301+
m.assert();
302+
}
303+
304+
#[tokio::test]
305+
async fn send_once_omits_proof_header_when_not_provided() {
306+
let mut server = Server::new_async().await;
307+
let m = server
308+
.mock("POST", "/api/test")
309+
.match_header("x-icaptcha-proof", mockito::Matcher::Missing)
310+
.with_status(200)
311+
.with_body("ok")
312+
.create_async()
313+
.await;
314+
let client = NodeClient::new(server.url(), None);
315+
let resp = client
316+
.send_once("POST", "/api/test", b"{}", None)
317+
.await
318+
.unwrap();
319+
assert_eq!(resp.status(), 200);
320+
m.assert();
321+
}
322+
323+
#[tokio::test]
324+
async fn send_once_signs_request_when_keypair_present() {
325+
let mut server = Server::new_async().await;
326+
let m = server
327+
.mock("POST", "/api/test")
328+
.match_header("Signature", mockito::Matcher::Any)
329+
.match_header("Signature-Input", mockito::Matcher::Any)
330+
.match_header("Content-Digest", mockito::Matcher::Any)
331+
.with_status(200)
332+
.with_body("ok")
333+
.create_async()
334+
.await;
335+
let client = NodeClient::new(server.url(), Some(test_keypair()));
336+
let resp = client
337+
.send_once("POST", "/api/test", b"{}", None)
338+
.await
339+
.unwrap();
340+
assert_eq!(resp.status(), 200);
341+
m.assert();
342+
}
343+
344+
#[tokio::test]
345+
async fn send_once_does_not_sign_when_no_keypair() {
346+
let mut server = Server::new_async().await;
347+
let m = server
348+
.mock("POST", "/api/test")
349+
.match_header("Signature", mockito::Matcher::Missing)
350+
.match_header("Signature-Input", mockito::Matcher::Missing)
351+
.match_header("Content-Digest", mockito::Matcher::Missing)
352+
.with_status(200)
353+
.with_body("ok")
354+
.create_async()
355+
.await;
356+
let client = NodeClient::new(server.url(), None);
357+
let resp = client
358+
.send_once("POST", "/api/test", b"{}", None)
359+
.await
360+
.unwrap();
361+
assert_eq!(resp.status(), 200);
362+
m.assert();
363+
}
364+
365+
// ── send_signed ─────────────────────────────────────────────────────
366+
367+
#[tokio::test]
368+
async fn send_signed_returns_non_icaptcha_403_without_retry() {
369+
let mut server = Server::new_async().await;
370+
let m = server
371+
.mock("POST", "/api/register")
372+
.with_status(403)
373+
.with_header("content-type", "application/json")
374+
.with_body(r#"{"error":"forbidden"}"#)
375+
.create_async()
376+
.await;
377+
let client = NodeClient::new(server.url(), Some(test_keypair()));
378+
let resp = client
379+
.send_signed("POST", "/api/register", b"{}")
380+
.await
381+
.unwrap();
382+
assert_eq!(resp.status(), 403);
383+
m.assert();
384+
}
385+
386+
#[tokio::test]
387+
async fn send_signed_returns_first_response_on_success() {
388+
let mut server = Server::new_async().await;
389+
let m = server
390+
.mock("POST", "/api/register")
391+
.with_status(201)
392+
.with_header("content-type", "application/json")
393+
.with_body(r#"{"status":"created"}"#)
394+
.create_async()
395+
.await;
396+
let client = NodeClient::new(server.url(), Some(test_keypair()));
397+
let resp = client
398+
.send_signed("POST", "/api/register", b"{}")
399+
.await
400+
.unwrap();
401+
assert_eq!(resp.status(), 201);
402+
m.assert();
403+
}
404+
405+
#[tokio::test]
406+
async fn send_signed_handles_405_not_icaptcha() {
407+
let mut server = Server::new_async().await;
408+
let m = server
409+
.mock("POST", "/api/register")
410+
.with_status(405)
411+
.with_body(r#"{"error":"method not allowed"}"#)
412+
.create_async()
413+
.await;
414+
let client = NodeClient::new(server.url(), Some(test_keypair()));
415+
let resp = client
416+
.send_signed("POST", "/api/register", b"{}")
417+
.await
418+
.unwrap();
419+
assert_eq!(resp.status(), 405);
420+
m.assert();
421+
}
422+
423+
// ── send_signed iCaptcha retry (full integration) ────────────────────
424+
425+
/// Set GITLAWB_ICAPTCHA_URL and GITLAWB_ICAPTCHA_INSECURE so the iCaptcha
426+
/// client trusts a local mockito HTTP server, restoring any prior values on
427+
/// drop so a test run launched with those variables keeps working.
428+
/// Holds [`ICAPTCHA_ENV_LOCK`] for its lifetime so concurrent tests don't
429+
/// race on the process-global env vars.
430+
struct IcaptchaEnv {
431+
_lock: MutexGuard<'static, ()>,
432+
prev_url: Option<OsString>,
433+
prev_insecure: Option<OsString>,
434+
}
435+
436+
impl IcaptchaEnv {
437+
fn new(url: &str) -> Self {
438+
let lock = ICAPTCHA_ENV_LOCK.lock().unwrap();
439+
let prev_url = std::env::var_os("GITLAWB_ICAPTCHA_URL");
440+
let prev_insecure = std::env::var_os("GITLAWB_ICAPTCHA_INSECURE");
441+
std::env::set_var("GITLAWB_ICAPTCHA_URL", url);
442+
std::env::set_var("GITLAWB_ICAPTCHA_INSECURE", "1");
443+
IcaptchaEnv {
444+
_lock: lock,
445+
prev_url,
446+
prev_insecure,
447+
}
448+
}
449+
}
450+
451+
impl Drop for IcaptchaEnv {
452+
fn drop(&mut self) {
453+
match self.prev_url.take() {
454+
Some(v) => std::env::set_var("GITLAWB_ICAPTCHA_URL", v),
455+
None => std::env::remove_var("GITLAWB_ICAPTCHA_URL"),
456+
}
457+
match self.prev_insecure.take() {
458+
Some(v) => std::env::set_var("GITLAWB_ICAPTCHA_INSECURE", v),
459+
None => std::env::remove_var("GITLAWB_ICAPTCHA_INSECURE"),
460+
}
461+
}
462+
}
463+
464+
/// Set up a mock iCaptcha server that responds to challenge + answer.
465+
/// `hits` sets the expected call count for both endpoints so the test can
466+
/// verify the solve loop was entered the correct number of times.
467+
struct MockIcaptcha {
468+
challenge: mockito::Mock,
469+
answer: mockito::Mock,
470+
_guard: IcaptchaEnv,
471+
url: String,
472+
}
473+
474+
impl MockIcaptcha {
475+
async fn new(server: &mut mockito::ServerGuard, hits: usize) -> Self {
476+
let url = server.url();
477+
let guard = IcaptchaEnv::new(&url);
478+
let challenge = server
479+
.mock("POST", "/v1/challenge")
480+
.with_status(200)
481+
.with_header("content-type", "application/json")
482+
.with_body(
483+
r#"{"challengeId":"c1","type":"arithmetic","difficulty":1,"prompt":"What is 1 + 1?","token":"tk1"}"#,
484+
)
485+
.expect(hits)
486+
.create_async()
487+
.await;
488+
let answer = server
489+
.mock("POST", "/v1/answer")
490+
.with_status(200)
491+
.with_header("content-type", "application/json")
492+
.with_body(r#"{"status":"passed","proof":"mock.proof"}"#)
493+
.expect(hits)
494+
.create_async()
495+
.await;
496+
Self {
497+
challenge,
498+
answer,
499+
_guard: guard,
500+
url,
501+
}
502+
}
503+
}
504+
505+
#[tokio::test]
506+
async fn send_signed_solves_icaptcha_and_retries_to_success() {
507+
let mut node = Server::new_async().await;
508+
let mut icaptcha = Server::new_async().await;
509+
let ic = MockIcaptcha::new(&mut icaptcha, 1).await;
510+
511+
let n1 = node
512+
.mock("POST", "/api/register")
513+
.with_status(403)
514+
.with_header("content-type", "application/json")
515+
.with_header("x-icaptcha-url", &ic.url)
516+
.with_header("x-icaptcha-level", "3")
517+
.with_body(r#"{"error":"icaptcha_proof_required"}"#)
518+
.expect(1)
519+
.create_async()
520+
.await;
521+
let n2 = node
522+
.mock("POST", "/api/register")
523+
.match_header("x-icaptcha-proof", "mock.proof")
524+
.with_status(201)
525+
.with_header("content-type", "application/json")
526+
.with_body(r#"{"status":"created"}"#)
527+
.expect(1)
528+
.create_async()
529+
.await;
530+
531+
let client = NodeClient::new(node.url(), Some(test_keypair()));
532+
let resp = client
533+
.send_signed("POST", "/api/register", b"{}")
534+
.await
535+
.unwrap();
536+
assert_eq!(resp.status(), 201);
537+
n1.assert();
538+
n2.assert();
539+
ic.challenge.assert();
540+
ic.answer.assert();
541+
}
542+
543+
#[tokio::test]
544+
async fn send_signed_returns_403_after_icaptcha_retries_exhausted() {
545+
let mut node = Server::new_async().await;
546+
let mut icaptcha = Server::new_async().await;
547+
// MAX_ICAPTCHA_RETRIES = 2, so with every call returning 403 with
548+
// iCaptcha headers the solve loop runs twice (2 challenge + 2 answer).
549+
let ic = MockIcaptcha::new(&mut icaptcha, 2).await;
550+
551+
// The original + 2 retries = 3 node calls before the loop gives up.
552+
let n = node
553+
.mock("POST", "/api/register")
554+
.with_status(403)
555+
.with_header("content-type", "application/json")
556+
.with_header("x-icaptcha-url", &ic.url)
557+
.with_header("x-icaptcha-level", "3")
558+
.with_body(r#"{"error":"icaptcha_proof_required"}"#)
559+
.expect(3)
560+
.create_async()
561+
.await;
562+
563+
let client = NodeClient::new(node.url(), Some(test_keypair()));
564+
let resp = client
565+
.send_signed("POST", "/api/register", b"{}")
566+
.await
567+
.unwrap();
568+
assert_eq!(resp.status(), 403);
569+
n.assert();
570+
ic.challenge.assert();
571+
ic.answer.assert();
572+
}
573+
}

0 commit comments

Comments
 (0)