@@ -211,6 +211,23 @@ def test_expected_targets_in_configs(self):
211211 assert "groq" in registry_names
212212 assert "google_gemini" in registry_names
213213
214+ def test_target_configs_have_unique_registry_names (self ):
215+ """Guard against typos: every ``registry_name`` in ``ENV_TARGET_CONFIGS`` must be unique.
216+
217+ Duplicate names would silently overwrite each other when
218+ ``TargetInitializer`` registers them (per ``BaseInstanceRegistry.register``
219+ semantics, characterized in ``test_target_registry.py``). Only the
220+ second entry would survive in the registry, which breaks downstream
221+ fan-out (``BenchmarkInitializer``) and is hard to diagnose. Tracked
222+ as ``duplicate-registry-name`` in failure_mode_followups.
223+ """
224+ registry_names = [config .registry_name for config in TARGET_CONFIGS ]
225+ seen : dict [str , int ] = {}
226+ for name in registry_names :
227+ seen [name ] = seen .get (name , 0 ) + 1
228+ duplicates = {name : count for name , count in seen .items () if count > 1 }
229+ assert not duplicates , f"Duplicate registry_name(s) in TARGET_CONFIGS: { duplicates } "
230+
214231
215232class TestTargetInitializerGetInfo :
216233 """Tests for TargetInitializer.get_info_async method."""
@@ -500,3 +517,148 @@ async def test_register_target_default_objective_tag_still_applied(self) -> None
500517 assert any (entry .name == "openai_chat" for entry in default_entries ), (
501518 "openai_chat's config.tags=[DEFAULT] must propagate even when default_objective_target=True"
502519 )
520+
521+
522+ ADVERSARIAL_CHAT_VARIANTS : list [tuple [str , str ]] = [
523+ ("adversarial_chat_singleturn" , "ADVERSARIAL_CHAT_SINGLETURN" ),
524+ ("adversarial_chat_multiturn" , "ADVERSARIAL_CHAT_MULTITURN" ),
525+ ("adversarial_chat_reasoning" , "ADVERSARIAL_CHAT_REASONING" ),
526+ ]
527+
528+
529+ @pytest .mark .usefixtures ("patch_central_database" )
530+ class TestTargetInitializerAdversarialChatVariants :
531+ """Tests for the ``ADVERSARIAL_CHAT_{SINGLETURN,MULTITURN,REASONING}_*`` env-driven variants."""
532+
533+ def setup_method (self ) -> None :
534+ """Reset registry and clear variant env vars."""
535+ TargetRegistry .reset_instance ()
536+ self ._clear_variant_env_vars ()
537+
538+ def teardown_method (self ) -> None :
539+ """Reset registry and clear variant env vars."""
540+ TargetRegistry .reset_instance ()
541+ self ._clear_variant_env_vars ()
542+
543+ @staticmethod
544+ def _clear_variant_env_vars () -> None :
545+ for _ , prefix in ADVERSARIAL_CHAT_VARIANTS :
546+ for suffix in ("ENDPOINT" , "KEY" , "MODEL" ):
547+ os .environ .pop (f"{ prefix } _{ suffix } " , None )
548+
549+ @staticmethod
550+ def _set_variant_env_vars (prefix : str ) -> None :
551+ os .environ [f"{ prefix } _ENDPOINT" ] = "https://variant.openai.azure.com/openai/v1"
552+ os .environ [f"{ prefix } _KEY" ] = "test_key"
553+ os .environ [f"{ prefix } _MODEL" ] = "deployment-name"
554+
555+ @pytest .mark .parametrize (("registry_name" , "env_prefix" ), ADVERSARIAL_CHAT_VARIANTS )
556+ async def test_variant_registers_with_default_and_adversarial_tags (
557+ self , registry_name : str , env_prefix : str
558+ ) -> None :
559+ """Each variant registers with ``[DEFAULT, ADVERSARIAL]`` tags when its env vars are set."""
560+ from pyrit .setup .initializers .components .targets import TargetInitializerTags
561+
562+ self ._set_variant_env_vars (env_prefix )
563+
564+ init = TargetInitializer ()
565+ await init .initialize_async ()
566+
567+ registry = TargetRegistry .get_registry_singleton ()
568+ assert registry_name in registry
569+
570+ adversarial_entries = registry .get_by_tag (tag = TargetInitializerTags .ADVERSARIAL )
571+ assert any (entry .name == registry_name for entry in adversarial_entries )
572+
573+ default_entries = registry .get_by_tag (tag = TargetInitializerTags .DEFAULT )
574+ assert any (entry .name == registry_name for entry in default_entries )
575+
576+ @pytest .mark .parametrize (("registry_name" , "env_prefix" ), ADVERSARIAL_CHAT_VARIANTS )
577+ async def test_variant_skips_when_env_vars_missing (self , registry_name : str , env_prefix : str ) -> None :
578+ """Variants skip gracefully when their env vars are missing (matches existing adversarial_chat behavior)."""
579+ init = TargetInitializer ()
580+ await init .initialize_async ()
581+
582+ registry = TargetRegistry .get_registry_singleton ()
583+ assert registry_name not in registry
584+
585+ @pytest .mark .parametrize (("registry_name" , "env_prefix" ), ADVERSARIAL_CHAT_VARIANTS )
586+ async def test_variant_skips_when_model_env_var_missing (
587+ self , registry_name : str , env_prefix : str , caplog : pytest .LogCaptureFixture
588+ ) -> None :
589+ """Endpoint+key set but _MODEL unset must skip with a warning, not silently fall back to OPENAI_CHAT_MODEL."""
590+ import logging
591+
592+ os .environ [f"{ env_prefix } _ENDPOINT" ] = "https://variant.openai.azure.com/openai/v1"
593+ os .environ [f"{ env_prefix } _KEY" ] = "test_key"
594+
595+ try :
596+ with caplog .at_level (logging .WARNING , logger = "pyrit.setup.initializers.components.targets" ):
597+ init = TargetInitializer ()
598+ await init .initialize_async ()
599+
600+ registry = TargetRegistry .get_registry_singleton ()
601+ assert registry_name not in registry
602+
603+ captured_messages = [r .message for r in caplog .records ]
604+ assert any (f"{ env_prefix } _MODEL" in m for m in captured_messages ), (
605+ f"Expected a warning naming the missing { env_prefix } _MODEL env var; got: { captured_messages } "
606+ )
607+ finally :
608+ os .environ .pop (f"{ env_prefix } _ENDPOINT" , None )
609+ os .environ .pop (f"{ env_prefix } _KEY" , None )
610+
611+ async def test_all_variants_discoverable_via_adversarial_tag_query (self ) -> None :
612+ """End-to-end: variants + ``adversarial_chat`` are returned by adversarial-tag ``get_by_tag_query``."""
613+ from pyrit .registry .tag_query import TagQuery
614+
615+ os .environ ["ADVERSARIAL_CHAT_ENDPOINT" ] = "https://parent.openai.azure.com/openai/v1"
616+ os .environ ["ADVERSARIAL_CHAT_KEY" ] = "test_key"
617+ os .environ ["ADVERSARIAL_CHAT_MODEL" ] = "deployment-name"
618+
619+ for _ , prefix in ADVERSARIAL_CHAT_VARIANTS :
620+ self ._set_variant_env_vars (prefix )
621+
622+ try :
623+ init = TargetInitializer ()
624+ await init .initialize_async ()
625+
626+ registry = TargetRegistry .get_registry_singleton ()
627+ matches = registry .get_by_tag_query (query = TagQuery .all ("adversarial" ))
628+ match_names = {entry .name for entry in matches }
629+
630+ expected = {"adversarial_chat" } | {name for name , _ in ADVERSARIAL_CHAT_VARIANTS }
631+ assert expected <= match_names , (
632+ f"Missing variants from tag query result. Expected superset: { expected } , got: { match_names } "
633+ )
634+ finally :
635+ for var in ("ADVERSARIAL_CHAT_ENDPOINT" , "ADVERSARIAL_CHAT_KEY" , "ADVERSARIAL_CHAT_MODEL" ):
636+ os .environ .pop (var , None )
637+
638+ async def test_double_initialize_async_is_idempotent (self ) -> None :
639+ """Re-running ``initialize_async`` with the same env state produces the same registry contents.
640+
641+ Regression guard for the duplicate-registration silent-overwrite path:
642+ because env vars haven't changed between calls, the rebuilt entries
643+ carry identical configuration. If anyone introduces non-idempotent
644+ side-effects (e.g. tag accumulation, instance leaks) into
645+ ``_register_target``, this test will catch it. Tracked as
646+ ``duplicate-registry-name`` in failure_mode_followups.
647+ """
648+ from pyrit .setup .initializers .components .targets import TargetInitializerTags
649+
650+ for _ , prefix in ADVERSARIAL_CHAT_VARIANTS :
651+ self ._set_variant_env_vars (prefix )
652+
653+ init = TargetInitializer ()
654+ await init .initialize_async ()
655+ registry = TargetRegistry .get_registry_singleton ()
656+ first_names = sorted (registry .get_names ())
657+ first_adversarial_count = len (registry .get_by_tag (tag = TargetInitializerTags .ADVERSARIAL ))
658+
659+ await init .initialize_async ()
660+ second_names = sorted (registry .get_names ())
661+ second_adversarial_count = len (registry .get_by_tag (tag = TargetInitializerTags .ADVERSARIAL ))
662+
663+ assert first_names == second_names
664+ assert first_adversarial_count == second_adversarial_count
0 commit comments