# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements.  See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership.  The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License.  You may obtain a copy of the License at
#
#   http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied.  See the License for the
# specific language governing permissions and limitations
# under the License.
"""Tests for the CLI surface and `gh`-shelling-out helpers.

These cover the parts of `generate_cve_json.cve_json` that don't have
existing unit tests in `test_generate_cve_json.py`:

- `parse_args` (CLI argument shape);
- `main` (orchestration, all error paths, --stdin / --output / --attach
  / --no-envelope flags);
- `fetch_issue`, `_gh_api_json`, `_fetch_issue` (gh subprocess wrappers
  with mocked `subprocess.run`);
- `_splice_attachment_into_body` (in-place attachment splicing);
- `attach_to_issue` (idempotent attachment with mocked gh).
"""

from __future__ import annotations

import io
import json
import subprocess
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest

from generate_cve_json import cve_json


def _issue_body(
    *,
    summary: str = "An RCE in the FooOperator.",
    affected: str = ">= 3.0.0, < 3.1.0",
    cwe: str = "CWE-352",
    severity: str = "high",
    credited: str = "Alice <alice@example.com>",
    cve_tool: str = "CVE-2026-12345",
    extra: dict[str, str] | None = None,
) -> str:
    """Build a minimal issue body with the standard tracker fields."""
    fields = {
        "Short public summary for publish": summary,
        "Affected versions": affected,
        "Security mailing list thread": "https://lists.example.org/thread.html/abc",
        "Public advisory URL": "",
        "Reporter credited as": credited,
        "PR with the fix": "https://github.com/upstream/repo/pull/4242",
        "Remediation developer": "",
        "CWE": cwe,
        "Severity": severity,
        "CVE tool link": cve_tool,
    }
    if extra:
        fields.update(extra)
    return "\n".join(f"### {k}\n{v}\n" for k, v in fields.items())


# --- parse_args ------------------------------------------------------------


class TestParseArgs:
    def test_minimal_positional(self):
        args = cve_json.parse_args(["123"])
        assert args.issue == "123"
        assert args.stdin is False
        assert args.attach is False
        assert args.no_envelope is False
        assert args.advisory_url == []
        assert args.remediation_developer == []
        assert args.product_for == []
        assert args.config is None

    def test_stdin_no_positional(self):
        args = cve_json.parse_args(["--stdin"])
        assert args.issue is None
        assert args.stdin is True

    def test_repeatable_flags_accumulate(self):
        args = cve_json.parse_args(
            [
                "123",
                "--advisory-url",
                "https://a.example/1",
                "--advisory-url",
                "https://a.example/2",
                "--remediation-developer",
                "Carol",
                "--product-for",
                "pkg-a=Display A",
                "--product-for",
                "pkg-b=Display B",
            ]
        )
        assert args.advisory_url == ["https://a.example/1", "https://a.example/2"]
        assert args.remediation_developer == ["Carol"]
        assert args.product_for == ["pkg-a=Display A", "pkg-b=Display B"]

    def test_overrides(self, tmp_path):
        cfg = tmp_path / "c.toml"
        args = cve_json.parse_args(
            [
                "42",
                "--config",
                str(cfg),
                "--repo",
                "owner/repo",
                "--cve-id",
                "CVE-2099-0001",
                "--title",
                "Example Title",
                "--vendor",
                "Foo",
                "--product",
                "Bar",
                "--package-name",
                "bar-py",
                "--collection-url",
                "https://pypi.org/project/bar",
                "--org-id",
                "org-1",
                "--version-start",
                "1.0.0",
                "--discovery",
                "INTERNAL",
                "--no-envelope",
                "--attach",
                "--output",
                str(tmp_path / "out.json"),
            ]
        )
        assert args.config == cfg
        assert args.repo == "owner/repo"
        assert args.cve_id == "CVE-2099-0001"
        assert args.no_envelope is True
        assert args.attach is True
        assert args.output == tmp_path / "out.json"


# --- _splice_attachment_into_body -----------------------------------------


class TestSpliceAttachment:
    cve_id = "CVE-2026-99999"

    def _markers(self):
        return (
            cve_json._attachment_marker_begin(self.cve_id),
            cve_json._attachment_marker_end(self.cve_id),
        )

    def _attachment(self):
        begin, end = self._markers()
        return f"{begin}\n## CVE JSON\n```json\n{{}}\n```\n{end}\n"

    def test_replaces_existing_attachment_block(self):
        begin, end = self._markers()
        body = f"### Body before\nstuff\n\n{begin}\nOLD CONTENT\n{end}\ntrailing line\n"
        result = cve_json._splice_attachment_into_body(body, self._attachment(), self.cve_id)
        assert "OLD CONTENT" not in result
        assert "## CVE JSON" in result
        # Markers are present and bracket the new content.
        assert begin in result
        assert end in result

    def test_legacy_single_marker_path(self):
        begin, _ = self._markers()
        body = f"### Body\n\n{begin}\nleftover legacy content with no end marker\n"
        result = cve_json._splice_attachment_into_body(body, self._attachment(), self.cve_id)
        assert "leftover legacy content" not in result
        assert "## CVE JSON" in result

    def test_appends_after_cve_tool_link_when_no_existing_attachment(self):
        body = _issue_body()
        result = cve_json._splice_attachment_into_body(body, self._attachment(), self.cve_id)
        assert "## CVE JSON" in result
        # The attachment lands after the CVE-tool-link section.
        cve_tool_idx = result.index("### CVE tool link")
        attach_idx = result.index("## CVE JSON")
        assert attach_idx > cve_tool_idx

    def test_appends_at_end_when_no_cve_tool_link_field(self):
        body = "### Body\nstuff\n"
        result = cve_json._splice_attachment_into_body(body, self._attachment(), self.cve_id)
        assert result.endswith(self._markers()[1] + "\n")


# --- fetch_issue (gh subprocess wrapper) ----------------------------------


class TestFetchIssue:
    def test_returns_title_body_and_labels(self):
        completed = MagicMock(
            stdout=json.dumps(
                {
                    "title": "T",
                    "body": "B",
                    "labels": [
                        {"name": "rc voting"},
                        {"name": "airflow"},
                    ],
                }
            )
        )
        with patch("generate_cve_json.cve_json.subprocess.run", return_value=completed) as run:
            title, body, labels = cve_json.fetch_issue(42, "owner/repo")
        assert (title, body) == ("T", "B")
        assert labels == ["rc voting", "airflow"]
        # Verify the gh call shape.
        cmd = run.call_args.args[0]
        assert cmd[:3] == ["gh", "issue", "view"]
        assert "--repo" in cmd
        assert "owner/repo" in cmd
        # Verify we ask gh for labels in addition to title and body.
        json_arg_index = cmd.index("--json")
        assert "labels" in cmd[json_arg_index + 1]

    def test_returns_empty_labels_when_field_missing(self):
        # gh returns no `labels` key when the issue has no labels — we
        # default to an empty list rather than blowing up.
        completed = MagicMock(stdout=json.dumps({"title": "T", "body": "B"}))
        with patch("generate_cve_json.cve_json.subprocess.run", return_value=completed):
            title, body, labels = cve_json.fetch_issue(42, "owner/repo")
        assert (title, body, labels) == ("T", "B", [])

    def test_gh_missing_raises_runtime_error(self):
        with patch(
            "generate_cve_json.cve_json.subprocess.run",
            side_effect=FileNotFoundError,
        ):
            with pytest.raises(RuntimeError, match="`gh` CLI not found"):
                cve_json.fetch_issue(1, "o/r")

    def test_gh_failure_raises_runtime_error(self):
        err = subprocess.CalledProcessError(returncode=1, cmd=["gh"], stderr="not found")
        with patch("generate_cve_json.cve_json.subprocess.run", side_effect=err):
            with pytest.raises(RuntimeError, match="not found"):
                cve_json.fetch_issue(1, "o/r")


# --- _gh_api_json ----------------------------------------------------------


class TestGhApiJson:
    def test_returns_parsed_json(self):
        completed = MagicMock(stdout='{"id": 7}')
        with patch("generate_cve_json.cve_json.subprocess.run", return_value=completed) as run:
            result = cve_json._gh_api_json(["repos/o/r/issues/1"])
        assert result == {"id": 7}
        cmd = run.call_args.args[0]
        assert cmd[:2] == ["gh", "api"]
        assert cmd[2] == "repos/o/r/issues/1"

    def test_returns_empty_dict_on_empty_stdout(self):
        completed = MagicMock(stdout="   \n")
        with patch("generate_cve_json.cve_json.subprocess.run", return_value=completed):
            assert cve_json._gh_api_json(["repos/o/r"]) == {}

    def test_writes_body_payload_to_temp_file_and_uses_input_flag(self, tmp_path):
        completed = MagicMock(stdout="{}")
        captured_args: dict = {}

        def fake_run(cmd, **_kw):
            captured_args["cmd"] = cmd
            # Read back the temp file we wrote, prove it has our payload.
            input_idx = cmd.index("--input")
            captured_args["payload"] = json.loads(Path(cmd[input_idx + 1]).read_text())
            return completed

        with patch("generate_cve_json.cve_json.subprocess.run", side_effect=fake_run):
            cve_json._gh_api_json(
                ["-X", "PATCH", "repos/o/r/issues/1"],
                body_payload={"body": "new"},
            )
        assert captured_args["payload"] == {"body": "new"}
        assert "--input" in captured_args["cmd"]

    def test_gh_missing_raises_runtime_error(self):
        with patch(
            "generate_cve_json.cve_json.subprocess.run",
            side_effect=FileNotFoundError,
        ):
            with pytest.raises(RuntimeError, match="`gh` CLI not found"):
                cve_json._gh_api_json(["x"])

    def test_gh_failure_raises_runtime_error(self):
        err = subprocess.CalledProcessError(returncode=22, cmd=["gh"], stderr="rate limited\n")
        with patch("generate_cve_json.cve_json.subprocess.run", side_effect=err):
            with pytest.raises(RuntimeError, match="rate limited"):
                cve_json._gh_api_json(["x"])


# --- _fetch_issue (REST wrapper) ------------------------------------------


class TestFetchIssueRest:
    def test_returns_dict(self):
        with patch(
            "generate_cve_json.cve_json._gh_api_json",
            return_value={"body": "x", "html_url": "u"},
        ):
            assert cve_json._fetch_issue("o/r", "42") == {
                "body": "x",
                "html_url": "u",
            }

    def test_raises_when_response_not_a_dict(self):
        with patch("generate_cve_json.cve_json._gh_api_json", return_value=[]):
            with pytest.raises(RuntimeError, match="Unexpected response shape"):
                cve_json._fetch_issue("o/r", "42")


# --- attach_to_issue -------------------------------------------------------


def _attach_kwargs() -> dict:
    """Common keyword arguments for `attach_to_issue` calls in this module."""
    return {
        "issue_number": "42",
        "repo": "o/r",
        "cve_id": "CVE-2026-12345",
        "json_text": '{"a": 1}',
        "cna": {"title": "T"},
        "cna_private_state": "DRAFT",
    }


class TestAttachToIssue:
    def test_appends_attachment_when_body_has_no_existing_marker(self):
        with (
            patch(
                "generate_cve_json.cve_json._fetch_issue",
                return_value={"body": _issue_body(), "html_url": "https://x/42"},
            ),
            patch("generate_cve_json.cve_json._gh_api_json", return_value={}) as patch_call,
        ):
            url, was_update = cve_json.attach_to_issue(**_attach_kwargs())
        assert was_update is False
        assert url.endswith("#cve-json--paste-ready-for-cve-2026-12345")
        # PATCH was called with a body that contains the begin marker.
        body_payload = patch_call.call_args.kwargs["body_payload"]["body"]
        assert cve_json._attachment_marker_begin("CVE-2026-12345") in body_payload

    def test_replaces_existing_attachment_idempotently(self):
        cve_id = "CVE-2026-12345"
        begin = cve_json._attachment_marker_begin(cve_id)
        end = cve_json._attachment_marker_end(cve_id)
        body_with_existing = _issue_body() + f"\n{begin}\nstale content\n{end}\n"
        with (
            patch(
                "generate_cve_json.cve_json._fetch_issue",
                return_value={"body": body_with_existing, "html_url": "https://x/42"},
            ),
            patch("generate_cve_json.cve_json._gh_api_json", return_value={}),
        ):
            url, was_update = cve_json.attach_to_issue(**_attach_kwargs())
        assert was_update is True
        assert url.endswith("#cve-json--paste-ready-for-cve-2026-12345")

    def test_skips_patch_when_body_unchanged(self):
        # Pre-build the attachment that _splice would produce, so the
        # spliced result is byte-identical to the existing body.
        cve_id = "CVE-2026-12345"
        attachment = cve_json._build_attachment_body(
            cve_id=cve_id,
            json_text='{"a": 1}',
            cna={"title": "T"},
            cna_private_state="DRAFT",
        )
        existing = cve_json._splice_attachment_into_body(_issue_body(), attachment, cve_id)
        with (
            patch(
                "generate_cve_json.cve_json._fetch_issue",
                return_value={"body": existing, "html_url": "https://x/42"},
            ),
            patch("generate_cve_json.cve_json._gh_api_json") as patch_call,
        ):
            url, was_update = cve_json.attach_to_issue(**_attach_kwargs())
        assert was_update is True  # had_existing was True
        patch_call.assert_not_called()
        assert url == "https://x/42"


# --- main: error paths -----------------------------------------------------


class TestMainErrors:
    def test_attach_with_stdin_returns_2(self, capsys):
        rc = cve_json.main(["--stdin", "--attach"])
        assert rc == 2
        assert "--attach cannot be combined with --stdin" in capsys.readouterr().err

    def test_attach_without_issue_returns_2(self, capsys):
        rc = cve_json.main(["--attach"])
        assert rc == 2
        assert "--attach requires the positional issue argument" in capsys.readouterr().err

    def test_missing_issue_without_stdin_returns_2(self, capsys):
        rc = cve_json.main([])
        assert rc == 2
        assert "issue number is required unless --stdin" in capsys.readouterr().err

    def test_fetch_issue_failure_returns_1(self, capsys):
        with patch(
            "generate_cve_json.cve_json.fetch_issue",
            side_effect=RuntimeError("gh boom"),
        ):
            rc = cve_json.main(["123"])
        assert rc == 1
        assert "gh boom" in capsys.readouterr().err

    def test_product_for_without_equals_returns_2(self, capsys, monkeypatch):
        monkeypatch.setattr("sys.stdin", io.StringIO(_issue_body()))
        rc = cve_json.main(["--stdin", "--product-for", "no-equals-sign"])
        assert rc == 2
        assert "PACKAGE=PRODUCT" in capsys.readouterr().err

    def test_product_for_with_empty_value_returns_2(self, capsys, monkeypatch):
        monkeypatch.setattr("sys.stdin", io.StringIO(_issue_body()))
        rc = cve_json.main(["--stdin", "--product-for", "pkg="])
        assert rc == 2
        assert "non-empty PACKAGE and PRODUCT" in capsys.readouterr().err

    def test_config_not_found_returns_2(self, tmp_path, capsys):
        rc = cve_json.main(["--stdin", "--config", str(tmp_path / "nope.toml")])
        assert rc == 2
        assert "error:" in capsys.readouterr().err

    def test_unparsable_affected_versions_returns_2(self, capsys, monkeypatch):
        # parse_affected_versions now raises ValueError on un-parseable
        # input rather than emitting a malformed `version` string. main
        # catches that and exits 2 with a clean error message — no
        # traceback shown to the user.
        body = _issue_body(affected=">= 3.0.0 (reporter verified against 3.2.1)")
        monkeypatch.setattr("sys.stdin", io.StringIO(body))
        rc = cve_json.main(["--stdin"])
        assert rc == 2
        captured = capsys.readouterr()
        assert "Could not parse" in captured.err
        assert "Affected versions" in captured.err


# --- main: happy paths -----------------------------------------------------


class TestMainHappyPath:
    def test_stdin_writes_full_envelope_to_stdout(self, monkeypatch, capsys):
        monkeypatch.setattr("sys.stdin", io.StringIO(_issue_body()))
        rc = cve_json.main(["--stdin"])
        assert rc == 0
        out = capsys.readouterr().out
        record = json.loads(out)
        # Full CVE 5.x record envelope: cveMetadata + containers.
        assert "cveMetadata" in record
        assert "containers" in record
        assert record["cveMetadata"]["cveId"] == "CVE-2026-12345"

    def test_stdin_no_envelope_emits_cna_only(self, monkeypatch, capsys):
        monkeypatch.setattr("sys.stdin", io.StringIO(_issue_body()))
        rc = cve_json.main(["--stdin", "--no-envelope"])
        assert rc == 0
        record = json.loads(capsys.readouterr().out)
        # Bare CNA container — no top-level cveMetadata.
        assert "cveMetadata" not in record
        assert "title" in record
        assert "affected" in record

    def test_output_to_file(self, monkeypatch, tmp_path, capsys):
        monkeypatch.setattr("sys.stdin", io.StringIO(_issue_body()))
        out_file = tmp_path / "cve.json"
        rc = cve_json.main(["--stdin", "--output", str(out_file)])
        assert rc == 0
        # File written, not stdout.
        record = json.loads(out_file.read_text())
        assert "cveMetadata" in record
        # Friendly post-write print includes the byte count.
        assert "Wrote " in capsys.readouterr().out

    def test_fetch_path_uses_gh(self, capsys):
        with patch(
            "generate_cve_json.cve_json.fetch_issue",
            return_value=("Issue title", _issue_body(), []),
        ) as fetch:
            rc = cve_json.main(["123"])
        assert rc == 0
        fetch.assert_called_once()
        record = json.loads(capsys.readouterr().out)
        assert record["cveMetadata"]["cveId"] == "CVE-2026-12345"

    def test_attach_happy_path(self, capsys):
        with (
            patch(
                "generate_cve_json.cve_json.fetch_issue",
                return_value=("Issue title", _issue_body(), []),
            ),
            patch(
                "generate_cve_json.cve_json.attach_to_issue",
                return_value=("https://x/42#cve-json--paste-ready-for-cve-2026-12345", False),
            ) as attach,
        ):
            rc = cve_json.main(["123", "--attach"])
        assert rc == 0
        attach.assert_called_once()
        out = capsys.readouterr().out
        assert "Embedded CVE JSON" in out

    def test_attach_replace_path(self, capsys):
        with (
            patch(
                "generate_cve_json.cve_json.fetch_issue",
                return_value=("Issue title", _issue_body(), []),
            ),
            patch(
                "generate_cve_json.cve_json.attach_to_issue",
                return_value=("https://x/42#anchor", True),
            ),
        ):
            rc = cve_json.main(["123", "--attach"])
        assert rc == 0
        assert "Replaced CVE JSON" in capsys.readouterr().out

    def test_attach_failure_returns_1(self, capsys):
        with (
            patch(
                "generate_cve_json.cve_json.fetch_issue",
                return_value=("T", _issue_body(), []),
            ),
            patch(
                "generate_cve_json.cve_json.attach_to_issue",
                side_effect=RuntimeError("attach boom"),
            ),
        ):
            rc = cve_json.main(["123", "--attach"])
        assert rc == 1
        assert "attach boom" in capsys.readouterr().err


# --- main: release-vote gating --------------------------------------------


class TestReleaseVoteGating:
    """End-to-end coverage for the [workflow].release_vote_gating switch
    and the --review / --draft CLI overrides.

    The state machine has three modes:

      1. Gating off (default): fully-populated CNA ⇒ REVIEW. Legacy
         behaviour preserved for non-ASF adopters.
      2. Gating on, no label / no override: CNA stays at DRAFT.
      3. Gating on + rc-voting label *or* --review: CNA ⇒ REVIEW.

    The --review / --draft flags also work in gating-off mode (manual
    overrides remain available regardless of config).
    """

    @staticmethod
    def _write_gating_on_config(tmp_path, label: str = "rc voting") -> str:
        """Write a config that mirrors the fixture but with
        release_vote_gating = true. Returns its path as a string
        suitable for the --config CLI flag.
        """
        fixture = (Path(__file__).resolve().parent / "fixtures" / "cve-json-config.toml").read_text()
        # Flip the gating flag and (optionally) the label name.
        patched = fixture.replace(
            "release_vote_gating = false",
            "release_vote_gating = true",
        ).replace(
            'rc_voting_label = "rc voting"',
            f'rc_voting_label = "{label}"',
        )
        cfg_path = tmp_path / "cve-json-config-gating-on.toml"
        cfg_path.write_text(patched)
        return str(cfg_path)

    def test_default_gating_off_emits_review(self, capsys):
        # Default config has release_vote_gating = false, so a ready
        # CNA without any vote signal still advances to REVIEW.
        with patch(
            "generate_cve_json.cve_json.fetch_issue",
            return_value=("Issue title", _issue_body(), []),
        ):
            rc = cve_json.main(["123"])
        assert rc == 0
        record = json.loads(capsys.readouterr().out)
        assert record["CNA_private"]["state"] == "REVIEW"

    def test_gating_on_without_label_stays_draft(self, capsys, tmp_path):
        cfg = self._write_gating_on_config(tmp_path)
        with patch(
            "generate_cve_json.cve_json.fetch_issue",
            return_value=("Issue title", _issue_body(), ["airflow"]),
        ):
            rc = cve_json.main(["123", "--config", cfg])
        assert rc == 0
        record = json.loads(capsys.readouterr().out)
        assert record["CNA_private"]["state"] == "DRAFT"

    def test_gating_on_with_label_emits_review(self, capsys, tmp_path):
        cfg = self._write_gating_on_config(tmp_path)
        with patch(
            "generate_cve_json.cve_json.fetch_issue",
            return_value=("Issue title", _issue_body(), ["airflow", "rc voting"]),
        ):
            rc = cve_json.main(["123", "--config", cfg])
        assert rc == 0
        record = json.loads(capsys.readouterr().out)
        assert record["CNA_private"]["state"] == "REVIEW"

    def test_review_flag_overrides_gating_with_no_label(self, capsys, tmp_path):
        cfg = self._write_gating_on_config(tmp_path)
        with patch(
            "generate_cve_json.cve_json.fetch_issue",
            return_value=("Issue title", _issue_body(), []),
        ):
            rc = cve_json.main(["123", "--config", cfg, "--review"])
        assert rc == 0
        record = json.loads(capsys.readouterr().out)
        assert record["CNA_private"]["state"] == "REVIEW"

    def test_draft_flag_overrides_gating_with_label(self, capsys, tmp_path):
        # --draft beats a "rc voting" label — used to walk a record
        # back when the RC vote failed but the label is still on the
        # tracker.
        cfg = self._write_gating_on_config(tmp_path)
        with patch(
            "generate_cve_json.cve_json.fetch_issue",
            return_value=("Issue title", _issue_body(), ["rc voting"]),
        ):
            rc = cve_json.main(["123", "--config", cfg, "--draft"])
        assert rc == 0
        record = json.loads(capsys.readouterr().out)
        assert record["CNA_private"]["state"] == "DRAFT"

    def test_forward_state_labels_keep_state_at_review_when_rc_voting_removed(self, capsys, tmp_path):
        # After the `pr merged → fix released` transition (per the sync
        # skill's convention), the `rc voting` label is removed and
        # `fix released` is added. Without the forward-state-label
        # check, the state would walk back from REVIEW to DRAFT —
        # exactly the wrong direction for a record that is about to
        # be published. Verify every default forward-state label
        # individually keeps the state at REVIEW.
        cfg = self._write_gating_on_config(tmp_path)
        for forward_label in [
            "fix released",
            "announced - emails sent",
            "announced",
            "vendor-advisory ready",
        ]:
            with patch(
                "generate_cve_json.cve_json.fetch_issue",
                return_value=("Issue title", _issue_body(), ["airflow", forward_label]),
            ):
                rc = cve_json.main(["123", "--config", cfg])
            assert rc == 0, f"non-zero exit on label={forward_label!r}"
            record = json.loads(capsys.readouterr().out)
            assert record["CNA_private"]["state"] == "REVIEW", (
                f"forward-state label {forward_label!r} should keep state at REVIEW, "
                f"got {record['CNA_private']['state']!r}"
            )

    def test_custom_forward_state_labels_from_config(self, capsys, tmp_path):
        # Adopters can extend / replace the default forward-state-label
        # set via [workflow].forward_state_labels in the TOML config.
        fixture = (Path(__file__).resolve().parent / "fixtures" / "cve-json-config.toml").read_text()
        patched = fixture.replace(
            "release_vote_gating = false",
            'release_vote_gating = true\nforward_state_labels = ["shipped", "advisory-published"]',
        )
        cfg_path = tmp_path / "cve-json-config-custom-forward.toml"
        cfg_path.write_text(patched)
        # The default `fix released` is no longer in the set, so a
        # ready CNA with only `fix released` walks back to DRAFT.
        with patch(
            "generate_cve_json.cve_json.fetch_issue",
            return_value=("Issue title", _issue_body(), ["airflow", "fix released"]),
        ):
            rc = cve_json.main(["123", "--config", str(cfg_path)])
        assert rc == 0
        record = json.loads(capsys.readouterr().out)
        assert record["CNA_private"]["state"] == "DRAFT"
        # `shipped` is in the configured set → REVIEW.
        with patch(
            "generate_cve_json.cve_json.fetch_issue",
            return_value=("Issue title", _issue_body(), ["airflow", "shipped"]),
        ):
            rc = cve_json.main(["123", "--config", str(cfg_path)])
        assert rc == 0
        record = json.loads(capsys.readouterr().out)
        assert record["CNA_private"]["state"] == "REVIEW"

    def test_review_and_draft_are_mutually_exclusive(self):
        # argparse raises SystemExit on mutually-exclusive group conflict;
        # the exit code is 2.
        with pytest.raises(SystemExit) as exc_info:
            cve_json.main(["--stdin", "--review", "--draft"])
        assert exc_info.value.code == 2

    def test_custom_rc_voting_label_from_config(self, capsys, tmp_path):
        # The label name is configurable; if a project sets a custom
        # rc_voting_label, only that label gates REVIEW. The default
        # "rc voting" label on a tracker for such a project is just
        # an unrelated tag.
        cfg = self._write_gating_on_config(tmp_path, label="release-vote-in-progress")
        with patch(
            "generate_cve_json.cve_json.fetch_issue",
            return_value=(
                "Issue title",
                _issue_body(),
                ["rc voting"],  # wrong label name — should NOT trigger
            ),
        ):
            rc = cve_json.main(["123", "--config", cfg])
        assert rc == 0
        record = json.loads(capsys.readouterr().out)
        assert record["CNA_private"]["state"] == "DRAFT"
