From 0a53c8029f043767dc6967911fde5a42373fc446 Mon Sep 17 00:00:00 2001 From: zeroshotmind Date: Tue, 7 Jul 2026 14:45:35 -0700 Subject: [PATCH] test(redteam): cover RedTeamCase metadata sync and _build_system_prompt assembly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RedTeamCase._sync_metadata_from_config (case.py) and _build_system_prompt (strategies/_common.py) had no direct unit tests — only incidental exercise via fixture setup in other strategy test files. Neither behavior was actually asserted: - RedTeamCase: metadata=None population, setdefault-style preservation of caller-provided keys over attack_goal values, partial backfill, and the empty-dict (falsy but not None) branch. - _build_system_prompt: that it includes and correctly orders all six shared prompt blocks, joins them with a blank line, and leaves the {actor_profile}/{max_turns} placeholders for callers to fill in later. --- .../experimental/redteam/test_case.py | 59 +++++++++++++++++++ .../experimental/redteam/test_common.py | 47 +++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 tests/strands_evals/experimental/redteam/test_case.py create mode 100644 tests/strands_evals/experimental/redteam/test_common.py diff --git a/tests/strands_evals/experimental/redteam/test_case.py b/tests/strands_evals/experimental/redteam/test_case.py new file mode 100644 index 00000000..c4fac1f5 --- /dev/null +++ b/tests/strands_evals/experimental/redteam/test_case.py @@ -0,0 +1,59 @@ +"""Tests for RedTeamCase metadata sync behavior.""" + +from strands_evals.experimental.redteam.case import RedTeamCase +from strands_evals.experimental.redteam.types import AttackGoal, RedTeamConfig + + +def _config(**attack_goal_kwargs) -> RedTeamConfig: + kwargs = {"risk_category": "guideline_bypass", "actor_goal": "goal"} + kwargs.update(attack_goal_kwargs) + return RedTeamConfig(attack_goal=AttackGoal(**kwargs)) + + +def test_metadata_none_is_populated_from_attack_goal(): + """A case with no metadata gets the full attack_goal dump as its metadata.""" + case = RedTeamCase(name="c0", input="hello", config=_config()) + + assert case.metadata == case.config.attack_goal.model_dump() + assert case.metadata["risk_category"] == "guideline_bypass" + assert case.metadata["actor_goal"] == "goal" + assert case.metadata["severity"] == "medium" + + +def test_existing_metadata_keys_are_preserved_not_overwritten(): + """Caller-provided metadata wins over the attack_goal dump for overlapping keys.""" + case = RedTeamCase( + name="c0", + input="hello", + config=_config(severity="critical"), + metadata={"severity": "low", "custom": "value"}, + ) + + # setdefault semantics: caller's "severity" survives, config's does not override it. + assert case.metadata["severity"] == "low" + # Caller-only keys are untouched. + assert case.metadata["custom"] == "value" + # Keys absent from caller metadata are still filled in from attack_goal. + assert case.metadata["risk_category"] == "guideline_bypass" + assert case.metadata["actor_goal"] == "goal" + + +def test_partial_metadata_is_filled_in_from_attack_goal(): + """Keys the caller didn't set are backfilled from attack_goal without touching the rest.""" + case = RedTeamCase( + name="c0", + input="hello", + config=_config(context="ctx", success_criteria="criteria"), + metadata={"unrelated": "kept"}, + ) + + assert case.metadata["unrelated"] == "kept" + assert case.metadata["context"] == "ctx" + assert case.metadata["success_criteria"] == "criteria" + + +def test_empty_dict_metadata_is_filled_in_from_attack_goal(): + """An explicit empty dict (falsy but not None) still gets backfilled, exercising the else branch.""" + case = RedTeamCase(name="c0", input="hello", config=_config(), metadata={}) + + assert case.metadata == case.config.attack_goal.model_dump() diff --git a/tests/strands_evals/experimental/redteam/test_common.py b/tests/strands_evals/experimental/redteam/test_common.py new file mode 100644 index 00000000..ef046e1f --- /dev/null +++ b/tests/strands_evals/experimental/redteam/test_common.py @@ -0,0 +1,47 @@ +"""Tests for shared prompt-block assembly in strategies/_common.py.""" + +from strands_evals.experimental.redteam.strategies._common import ( + EXIT_BLOCK, + FORMAT_BLOCK, + PROFILE_BLOCK, + ROLE_BLOCK, + RULES_BLOCK, + _build_system_prompt, +) + + +def test_build_system_prompt_includes_all_common_blocks(): + prompt = _build_system_prompt("PLAYBOOK CONTENT") + + assert ROLE_BLOCK in prompt + assert PROFILE_BLOCK in prompt + assert "PLAYBOOK CONTENT" in prompt + assert RULES_BLOCK in prompt + assert EXIT_BLOCK in prompt + assert FORMAT_BLOCK in prompt + + +def test_build_system_prompt_orders_blocks_role_profile_playbook_rules_exit_format(): + """Downstream .format() calls fill {actor_goal}/{max_turns} placeholders positionally + within this fixed layout, so the block order is load-bearing, not incidental.""" + prompt = _build_system_prompt("PLAYBOOK") + + positions = [ + prompt.index(ROLE_BLOCK), + prompt.index(PROFILE_BLOCK), + prompt.index("PLAYBOOK"), + prompt.index(RULES_BLOCK), + prompt.index(EXIT_BLOCK), + prompt.index(FORMAT_BLOCK), + ] + + assert positions == sorted(positions) + + +def test_profile_and_exit_blocks_retain_format_placeholders(): + """These blocks are filled in later via str.format(actor_profile=..., max_turns=...); + _build_system_prompt itself must not consume those placeholders.""" + prompt = _build_system_prompt("PLAYBOOK") + + assert "{actor_profile}" in prompt + assert "{max_turns}" in prompt