@@ -426,3 +426,109 @@ async def test_status_transition_happens_regardless_of_caddy_outcome(self):
426426 # update_status was called with REVOKED first
427427 first_call = repo .update_status .call_args_list [0 ]
428428 assert first_call .args [1 ] == DomainStatus .REVOKED
429+
430+
431+ class TestSuspendNotFound :
432+ @pytest .mark .asyncio
433+ async def test_suspend_missing_domain_is_noop (self ):
434+ # Worker may race with a concurrent delete — domain disappears
435+ # between scan and suspend. Must silently noop, not crash.
436+ svc , repo , _ , edge , _ = _build_service ()
437+ repo .find_by_id = AsyncMock (return_value = None )
438+
439+ # Must not raise
440+ await svc .suspend (DOMAIN_OID , reason = "missing_domain_ok" )
441+
442+ repo .update_status .assert_not_called ()
443+ edge .announce_revoked .assert_not_called ()
444+
445+
446+ class TestVerifyAttemptsQuota :
447+ @pytest .mark .asyncio
448+ async def test_quota_increments_redis_counter (self ):
449+ redis = AsyncMock ()
450+ redis .incr = AsyncMock (return_value = 1 )
451+ redis .expire = AsyncMock ()
452+ svc , repo , _ , _ , _ = _build_service (redis = redis )
453+ starting = _doc (status = DomainStatus .PENDING )
454+ repo .find_by_id = AsyncMock (side_effect = [starting , starting ])
455+
456+ await svc .verify (DOMAIN_OID , _user ())
457+
458+ redis .incr .assert_awaited_once ()
459+ # First incr → expire is set so the counter rolls over after the window
460+ redis .expire .assert_awaited_once ()
461+
462+ @pytest .mark .asyncio
463+ async def test_quota_exceeded_raises_quota_error (self ):
464+ redis = AsyncMock ()
465+ redis .incr = AsyncMock (return_value = 99 ) # well over default cap of 5
466+ redis .expire = AsyncMock ()
467+ svc , repo , _ , _ , _ = _build_service (redis = redis )
468+ repo .find_by_id = AsyncMock (return_value = _doc (status = DomainStatus .PENDING ))
469+
470+ with pytest .raises (DomainQuotaExceededError ):
471+ await svc .verify (DOMAIN_OID , _user ())
472+
473+ @pytest .mark .asyncio
474+ async def test_quota_fails_open_when_redis_errors (self ):
475+ # If Redis is down we degrade to "no quota enforcement" rather than
476+ # blocking all verifies — staff allowlist + per-user-per-domain
477+ # natural rate-limit cover the abuse vector.
478+ redis = AsyncMock ()
479+ redis .incr = AsyncMock (side_effect = Exception ("redis down" ))
480+ svc , repo , _ , _ , _ = _build_service (redis = redis )
481+ starting = _doc (status = DomainStatus .PENDING )
482+ repo .find_by_id = AsyncMock (side_effect = [starting , starting ])
483+
484+ # Must not raise — verify proceeds normally despite Redis fault.
485+ await svc .verify (DOMAIN_OID , _user ())
486+
487+
488+ class TestReverifyActive :
489+ @pytest .mark .asyncio
490+ async def test_success_bumps_last_verified_and_clears_error (self ):
491+ svc , repo , _ , _ , _ = _build_service ()
492+ d = _doc (status = DomainStatus .ACTIVE )
493+ repo .find_stale_active = AsyncMock (return_value = [d ])
494+
495+ result_pairs = await svc .reverify_active (batch_size = 10 )
496+
497+ assert len (result_pairs ) == 1
498+ ok_call = repo .update_status .call_args
499+ # ACTIVE remains, last_verified_at bumped, error cleared
500+ assert ok_call .args [1 ] == DomainStatus .ACTIVE
501+ assert ok_call .kwargs ["bump_last_verified_at" ] is True
502+ assert ok_call .kwargs ["last_verification_error" ] is None
503+
504+ @pytest .mark .asyncio
505+ async def test_failure_records_reason_keeps_status (self ):
506+ svc , repo , verifiers , _ , _ = _build_service ()
507+ verifiers [VerificationMethod .CNAME ].verify = AsyncMock (
508+ return_value = VerificationResult (False , reason = "DNS NXDOMAIN" )
509+ )
510+ d = _doc (status = DomainStatus .ACTIVE )
511+ repo .find_stale_active = AsyncMock (return_value = [d ])
512+
513+ await svc .reverify_active (batch_size = 10 )
514+
515+ # Status unchanged (worker doesn't auto-suspend on a single fail —
516+ # that's the consecutive-failure counter's job, lives in the worker)
517+ call = repo .update_status .call_args
518+ assert call .args [1 ] == DomainStatus .ACTIVE
519+ # bump_last_verified_at omitted on failure → default False applies.
520+ assert call .kwargs .get ("bump_last_verified_at" , False ) is False
521+ assert call .kwargs ["last_verification_error" ] == "DNS NXDOMAIN"
522+
523+ @pytest .mark .asyncio
524+ async def test_skips_doc_when_verifier_missing (self ):
525+ # Defensive: if a doc references a verification_method that isn't
526+ # wired (legacy data), the loop must skip it without crashing.
527+ svc , repo , _ , _ , _ = _build_service (verifiers = {}) # no verifiers
528+ d = _doc (status = DomainStatus .ACTIVE )
529+ repo .find_stale_active = AsyncMock (return_value = [d ])
530+
531+ result_pairs = await svc .reverify_active (batch_size = 10 )
532+
533+ assert result_pairs == []
534+ repo .update_status .assert_not_called ()
0 commit comments