diff --git a/src/specify_cli/extensions/catalog/command_remove.py b/src/specify_cli/extensions/catalog/command_remove.py index 23eb6d1214..80cc8cb33b 100644 --- a/src/specify_cli/extensions/catalog/command_remove.py +++ b/src/specify_cli/extensions/catalog/command_remove.py @@ -38,12 +38,19 @@ def catalog_remove( "[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list." ) raise typer.Exit(1) + for index, entry in enumerate(catalogs): + if not isinstance(entry, dict): + _commands.console.print( + f"[red]Error:[/red] Invalid catalog entry at index {index}: " + f"expected a mapping, got {type(entry).__name__}" + ) + raise typer.Exit(1) safe_name = _escape_markup(name) original_count = len(catalogs) catalogs = [ catalog for catalog in catalogs - if isinstance(catalog, dict) and catalog.get("name") != name + if catalog.get("name") != name ] if len(catalogs) == original_count: diff --git a/tests/specify_cli/extensions/catalog/test_command_remove.py b/tests/specify_cli/extensions/catalog/test_command_remove.py index 2892b09d20..d0c5f9e9ad 100644 --- a/tests/specify_cli/extensions/catalog/test_command_remove.py +++ b/tests/specify_cli/extensions/catalog/test_command_remove.py @@ -8,11 +8,47 @@ import os from pathlib import Path +import pytest +import typer import yaml from tests.integrations.test_cli import _normalize_cli_output +class TestExtensionCatalogRemoveValidation: + """Reject malformed entries before changing the user's catalog config.""" + + @pytest.mark.parametrize("invalid_entry", ["unfinished-entry", 0, False, None, []]) + @pytest.mark.parametrize("invalid_index", [0, 1]) + @pytest.mark.parametrize("target", ["keep", "does-not-exist"]) + def test_malformed_entries_preserve_config( + self, tmp_path, monkeypatch, capsys, invalid_entry, invalid_index, target + ): + from specify_cli.extensions import _commands + from specify_cli.extensions.catalog.command_remove import catalog_remove + + (tmp_path / ".specify").mkdir() + config_path = tmp_path / ".specify" / "extension-catalogs.yml" + entries = [{"name": "keep", "url": "https://example.com/team.json"}] + entries.insert(invalid_index, invalid_entry) + original = ( + "# Local settings: café\n" + + yaml.safe_dump({"catalogs": entries, "metadata": "keep me"}) + ).replace("\n", "\r\n").encode("utf-8") + config_path.write_bytes(original) + monkeypatch.setattr(_commands, "_require_specify_project", lambda: tmp_path) + + with pytest.raises(typer.Exit) as exc_info: + catalog_remove(target) + + assert exc_info.value.exit_code == 1 + assert config_path.read_bytes() == original + output = _normalize_cli_output(capsys.readouterr().out) + assert f"Invalid catalog entry at index {invalid_index}" in output + assert f"expected a mapping, got {type(invalid_entry).__name__}" in output + assert "Removed catalog" not in output + + class TestExtensionCatalogRemoveCLI: """Integration coverage for the extension catalog command.""" @@ -34,6 +70,40 @@ def _invoke(self, argv, cwd): finally: os.chdir(old) + @pytest.mark.parametrize("target", ["keep", "does-not-exist"]) + def test_remove_malformed_config_in_initialized_project( + self, tmp_path, target + ): + """A real initialized project keeps all config bytes on rejection.""" + project = tmp_path / "initialized" + project.mkdir() + initialized = self._invoke( + [ + "init", "--here", "--integration", "codex", + "--ignore-agent-tools", "--non-interactive", "--script", "py", + ], + project, + ) + assert initialized.exit_code == 0, initialized.output + config_path = project / ".specify" / "extension-catalogs.yml" + original = ( + "# Local settings: café\r\n" + "catalogs:\r\n" + " - name: keep\r\n" + " url: https://example.com/team.json\r\n" + " - https://example.com/private.json\r\n" + "metadata: keep me\r\n" + ).encode("utf-8") + config_path.write_bytes(original) + + result = self._invoke(["extension", "catalog", "remove", target], project) + + assert result.exit_code == 1, result.output + assert config_path.read_bytes() == original + output = _normalize_cli_output(result.output) + assert "Invalid catalog entry at index 1: expected a mapping, got str" in output + assert "Removed catalog" not in output + def test_extension_catalog_remove_rejects_non_mapping_config_root(self, tmp_path): project = self._make_project(tmp_path) cfg_path = project / ".specify" / "extension-catalogs.yml"