diff --git a/src/specify_cli/workflows/steps/gate/__init__.py b/src/specify_cli/workflows/steps/gate/__init__.py index a2e473244e..e07b6ebd62 100644 --- a/src/specify_cli/workflows/steps/gate/__init__.py +++ b/src/specify_cli/workflows/steps/gate/__init__.py @@ -194,7 +194,14 @@ def validate(self, config: dict[str, Any]) -> list[str]: f"Gate step {config.get('id', '?')!r}: 'on_reject' must be " f"'abort', 'skip', or 'retry'." ) - if on_reject in ("abort", "retry") and isinstance(options, list): + # Only inspect option text when every option is a string; otherwise the + # `o.lower()` below would raise AttributeError on a non-string option + # (already reported above) and break validate_workflow's never-raise contract. + if ( + on_reject in ("abort", "retry") + and isinstance(options, list) + and all(isinstance(o, str) for o in options) + ): reject_choices = {"reject", "abort"} if not any(o.lower() in reject_choices for o in options): errors.append( diff --git a/tests/test_workflows.py b/tests/test_workflows.py index cee02c46ba..6da3380808 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -1434,6 +1434,23 @@ def test_validate_invalid_on_reject(self): }) assert any("on_reject" in e for e in errors) + def test_validate_non_string_options_does_not_raise(self): + """Non-string options with on_reject=abort/retry must be REPORTED as an + error, not crash: the reject-choice check calls o.lower() on each option, + which previously raised AttributeError on a non-string option and broke + validate_workflow's 'return errors, never raise' contract.""" + from specify_cli.workflows.steps.gate import GateStep + + step = GateStep() + # on_reject defaults to "abort", which triggers the option-text check. + errors = step.validate({"id": "test", "message": "Review", "options": [123]}) + assert any("must be strings" in e for e in errors) + # also with an explicit retry on_reject + errors = step.validate( + {"id": "test", "message": "Review", "options": [True], "on_reject": "retry"} + ) + assert any("must be strings" in e for e in errors) + def test_interactive_prompt_renders_show_file(self, tmp_path, monkeypatch, capsys): from specify_cli.workflows.steps.gate import GateStep from specify_cli.workflows.base import StepContext, StepStatus