fix(workflow): centralize catalog-install cleanup across all failure branches

_install_workflow_from_catalog is new in this PR and has seven failure
branches after the mkdir/download step, each independently rmtree'ing
workflow_dir: redirect-to-non-HTTPS rejection, a generic download
exception, invalid downloaded YAML, a validate_workflow failure, a
workflow-id/catalog-key mismatch, a version mismatch, and (fixed in the
prior commit) a registry.add() OSError. Only the last one had been
special-cased to spare a prior working install on reinstall; the other
six still unconditionally deleted the whole directory, so re-adding an
already-installed catalog workflow and hitting any of those six earlier
failures destroyed the working install even though nothing about it had
actually changed.

Replaced all seven ad hoc rmtree call sites with a single local
_cleanup_failed_install() helper that closes over the existed_before /
prior_workflow_bytes captured once at the top of the function: restore
the prior workflow.yml for a reinstall, or rmtree only a directory that
this attempt itself created. Every failure branch now calls this one
helper, so the fix is structural rather than duplicated, and every
existing error message/exit code is unchanged -- only the cleanup
performed before each message is different.

Added a parametrized regression test covering the four early-failure
trigger points reachable from plain workflow add (redirect rejection,
download exception, invalid YAML, ID mismatch): each installs a catalog
workflow, re-adds it while forcing that specific failure, and asserts a
clean error plus the original workflow.yml surviving byte-for-byte.
Confirmed red against the unfixed code (all four raised FileNotFoundError
reading the deleted file) before applying the helper, green after.

Assisted-by: GitHub Copilot (model: Claude Sonnet 5, autonomous)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
marcelsafin
2026-07-11 08:27:06 +02:00
parent fb64ddd48c
commit 49f6fb6279
2 changed files with 100 additions and 27 deletions

View File

@@ -7847,6 +7847,80 @@ steps:
assert registry.is_installed("align-wf")
assert registry.get("align-wf")["version"] == "1.0.0"
@pytest.mark.parametrize(
"mode", ["redirect_rejected", "download_exception", "invalid_yaml", "id_mismatch"]
)
def test_add_catalog_reinstall_early_failure_restores_prior_file(
self, project_dir, monkeypatch, mode
):
"""Every _install_workflow_from_catalog failure branch that runs after
the mkdir/download step -- not just the registry.add() OSError case
-- must route through the same existed-before/backup-aware cleanup:
on a reinstall, a redirect rejection, a download exception, invalid
YAML, or a workflow-id mismatch must restore the prior working
workflow.yml rather than deleting the whole directory. One shared
root cause (the cleanup helper), so parametrized over trigger point."""
from typer.testing import CliRunner
from specify_cli import app
from specify_cli.workflows.catalog import WorkflowCatalog, WorkflowRegistry
monkeypatch.chdir(project_dir)
monkeypatch.setattr(
WorkflowCatalog,
"get_workflow_info",
lambda self, wid: {
"id": wid,
"name": "Align Workflow",
"version": "1.0.0",
"url": "https://example.com/workflow.yml",
"_install_allowed": True,
"_catalog_name": "test-catalog",
},
)
original_data = self.WORKFLOW_YAML.format(version="1.0.0").encode()
runner = CliRunner()
with pytest.MonkeyPatch.context() as mp:
mp.setattr(
"specify_cli.authentication.http.open_url",
lambda url, timeout=None, extra_headers=None, redirect_validator=None: self._FakeResponse(
original_data, url
),
)
result = runner.invoke(app, ["workflow", "add", "align-wf"])
assert result.exit_code == 0, result.output
dest_file = project_dir / ".specify" / "workflows" / "align-wf" / "workflow.yml"
assert dest_file.read_bytes() == original_data
if mode == "redirect_rejected":
def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None):
return self._FakeResponse(b"irrelevant", "http://evil.example.com/workflow.yml")
elif mode == "download_exception":
def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None):
raise OSError("network down")
elif mode == "invalid_yaml":
def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None):
return self._FakeResponse(b": : not valid yaml: [", url)
else: # id_mismatch
mismatched_yaml = self.WORKFLOW_YAML.format(version="2.0.0").replace(
'id: "align-wf"', 'id: "different-workflow"'
)
def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None):
return self._FakeResponse(mismatched_yaml.encode(), url)
with pytest.MonkeyPatch.context() as mp:
mp.setattr("specify_cli.authentication.http.open_url", fake_open_url)
result = runner.invoke(app, ["workflow", "add", "align-wf"])
assert result.exit_code != 0
assert result.exception is None or isinstance(result.exception, SystemExit)
assert result.output.strip() != ""
assert dest_file.read_bytes() == original_data
registry = WorkflowRegistry(project_dir)
assert registry.is_installed("align-wf")
assert registry.get("align-wf")["version"] == "1.0.0"
def test_download_redirect_validator_rejects_http_before_follow(self):
import urllib.error