diff --git a/limacharlie/commands/mailsec.py b/limacharlie/commands/mailsec.py index 31e4eb91..9b463ffb 100644 --- a/limacharlie/commands/mailsec.py +++ b/limacharlie/commands/mailsec.py @@ -149,13 +149,13 @@ _EXPLAIN_CAMPAIGN_ACTION = """\ Sweep an action across every member of a campaign. Requires mailsec.act. -PREVIEWS BY DEFAULT. --confirm is what turns the preview -into an execution, which is the right default for an operation whose -blast radius is every mailbox that received the attack. +PREVIEWS BY DEFAULT. Copy the preview's member-bound `confirm` token into +--confirm to execute exactly the reviewed member set. A campaign id is not +a confirmation token and is refused by the server. Examples: limacharlie mailsec campaign action ec7e273b-... --action quarantine_message - limacharlie mailsec campaign action ec7e273b-... --action quarantine_message --confirm ec7e273b-... + limacharlie mailsec campaign action ec7e273b-... --action quarantine_message --confirm 3c514e... """ _EXPLAIN_SENDER_GET = """\ @@ -666,7 +666,7 @@ def campaign_get(ctx, campaign_id) -> None: @click.argument("campaign_id") @click.option("--action", "action_name", required=True, help="The typed action to sweep.") @click.option("--confirm", default=None, - help="Pass the campaign id to EXECUTE. Omit to preview — previewing is the default.") + help="Pass the member-bound token returned by the preview to EXECUTE. Omit to preview.") @click.option("--reason", default=None, help="Recorded on every resulting audit row.") @pass_context def campaign_action(ctx, campaign_id, action_name, confirm, reason) -> None: @@ -677,7 +677,7 @@ def campaign_action(ctx, campaign_id, action_name, confirm, reason) -> None: \b Example: - limacharlie mailsec campaign action ec7e273b-... --action quarantine_message --confirm ec7e273b-... + limacharlie mailsec campaign action ec7e273b-... --action quarantine_message --confirm 3c514e... """ ms = _get_mailsec(ctx) _output(ctx, ms.act_on_campaign(campaign_id, action_name, confirm=confirm, reason=reason)) diff --git a/limacharlie/sdk/mailsec.py b/limacharlie/sdk/mailsec.py index 9349b5b4..63733e61 100644 --- a/limacharlie/sdk/mailsec.py +++ b/limacharlie/sdk/mailsec.py @@ -384,7 +384,8 @@ def act_on_campaign( Args: campaign_id: The campaign to sweep. action: The typed action, as for :meth:`act_on_message`. - confirm: Pass the campaign id to execute. Omit to preview. + confirm: Pass the member-bound token returned by the preview to + execute. Omit to preview. reason: Recorded on every resulting audit row. actor: Ignored if supplied — the gateway stamps the acting identity from the authenticated claims, so an audit trail's diff --git a/tests/unit/test_cli_mailsec.py b/tests/unit/test_cli_mailsec.py index 516cf3c9..e5d2dca6 100644 --- a/tests/unit/test_cli_mailsec.py +++ b/tests/unit/test_cli_mailsec.py @@ -23,6 +23,27 @@ def invoke_connection(*args: str): return result, mailsec +def invoke_campaign_action(*args: str): + with ( + patch("limacharlie.commands.mailsec.Client"), + patch("limacharlie.commands.mailsec.Organization"), + patch("limacharlie.commands.mailsec.Mailsec") as mailsec_cls, + ): + mailsec = MagicMock() + mailsec.act_on_campaign.return_value = {"preview": False, "succeeded": 1} + mailsec_cls.return_value = mailsec + result = CliRunner().invoke( + cli, + [ + "--oid", "11111111-2222-3333-4444-555555555555", + "--output", "json", + "mailsec", "campaign", "action", "campaign-1", + *args, + ], + ) + return result, mailsec + + def test_connection_diagnostic_is_read_only_by_default(): result, mailsec = invoke_connection("workspace") assert result.exit_code == 0, result.output @@ -33,3 +54,25 @@ def test_include_watch_reaches_the_public_sdk_call(): result, mailsec = invoke_connection("workspace", "--include-watch") assert result.exit_code == 0, result.output mailsec.test_connection.assert_called_once_with("workspace", include_watch=True) + + +def test_campaign_action_help_requires_the_preview_token(): + result = CliRunner().invoke(cli, ["mailsec", "campaign", "action", "--help"]) + assert result.exit_code == 0, result.output + assert "member-bound token returned by the preview" in result.output + assert "Pass the campaign id" not in result.output + + +def test_campaign_action_forwards_the_preview_token_unchanged(): + result, mailsec = invoke_campaign_action( + "--action", "quarantine_message", + "--confirm", "member-bound-token", + "--reason", "reviewed current set", + ) + assert result.exit_code == 0, result.output + mailsec.act_on_campaign.assert_called_once_with( + "campaign-1", + "quarantine_message", + confirm="member-bound-token", + reason="reviewed current set", + ) diff --git a/tests/unit/test_sdk_mailsec.py b/tests/unit/test_sdk_mailsec.py index 065b94fd..50aa7c7a 100644 --- a/tests/unit/test_sdk_mailsec.py +++ b/tests/unit/test_sdk_mailsec.py @@ -159,9 +159,9 @@ def test_campaign_action_previews_without_confirm(self, ms, mock_org): assert "confirm" not in body def test_campaign_action_confirm_is_forwarded(self, ms, mock_org): - ms.act_on_campaign("cmp-1", "quarantine_message", confirm="cmp-1") + ms.act_on_campaign("cmp-1", "quarantine_message", confirm="member-bound-token") _, body = _post_call(mock_org) - assert body["confirm"] == "cmp-1" + assert body["confirm"] == "member-bound-token" class TestConnectionDiagnostics: