@@ -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