mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 12:28:06 +08:00
* chore: update community catalog with latest extension versions
- Update memory-md from 0.7.9 to 0.8.0
- Update architecture-guard from 1.6.7 to 1.8.0
* fix(cli): harden extension registration with project-level tracking in extensions.yml
* test(cli): add comprehensive unit tests for extension registration logic
* chore: remove out-of-scope catalog changes
* refactor: address PR feedback for extension registration hardening
* fix: harden extension registration defensive logic and add comprehensive unregister_hooks tests
- Add dict guard to register_hooks() to handle corrupted extensions.yml (non-dict root)
- Add 5 comprehensive tests for unregister_hooks() workflow:
* Full workflow with hooks + installed list removal
* Resilience when config has no 'hooks' key
* Corrupted YAML handling
* Multiple extension scenarios
* All 11 tests passing
* fix: sanitize installed to strings, guard unregister_hooks dict, handle null hook values
- register_extension(): filter non-string entries from installed before sort
- register_hooks(): normalize hooks to {} when missing or not a dict
- unregister_hooks(): add isinstance(config, dict) guard before key checks
- unregister_hooks(): coerce null/scalar hook lists to [] before iteration
- tests: add 3 regression tests for no-hooks manifest, mixed-type installed, null hook values
- All 14 tests passing
* fix(cli): persist sanitization results and harden hook registration
* Harden extension registration to always persist sanitization results
* Hardening extension registration: support mapping entries, improve persistence, and fix update rollback
* fix(cli): harden extension update and unregistration workflows
* fix(cli): move update sentinels outside try block to prevent NameError on rollback
* fix(cli): sanitize hook event lists in register_hooks to prevent crashes
* fix(cli): deduplicate hook entries and harden rollback hooks-restore guards
* test(cli): add regression tests for extension update and rollback hardening
* fix(cli): deduplicate installed list by id in register_extension
* fix(cli): consolidate and harden extension update rollback logic
* fix(cli): initialize backup_registry_entry before try block to prevent UnboundLocalError on rollback
* fix(tests): return Path from download_extension mock and add Path import
* fix(cli): normalize get_project_config() return to dict; deduplicate in unregister_extension()
* fix(cli): normalize hooks/installed/settings in get_project_config(); use tmp_path-scoped zip in tests
* fix(cli): set modified=True on hook coercion in rollback; sanitize hook event values in get_project_config(); harden test assertions
* fix(cli): filter non-dict hook entries in get_project_config(); remove dead MISSING sentinel
* fix(cli): gate extensions.yml rollback on backup_hooks is not None; update stale comment
* fix(cli): move _AgentReg import outside try block; assert result.exception is None in tests
* fix(extensions): consistent key order in default config; deep-copy backup_installed
* test: fix misleading comment; assert exit_code==1 in rollback test
* test: clean up duplicate imports in hardening tests
* refactor(extensions): extract _sanitize_installed_list helper; strengthen hook unregister assertion
* fix(extensions): validate extension IDs in _sanitize_installed_list; clarify test comment
110 lines
5.6 KiB
Python
110 lines
5.6 KiB
Python
from specify_cli.extensions import ExtensionManager, ExtensionRegistry, ExtensionCatalog
|
|
import pytest
|
|
import yaml
|
|
from typer.testing import CliRunner
|
|
from specify_cli import app
|
|
|
|
runner = CliRunner()
|
|
|
|
@pytest.fixture
|
|
def project_dir(tmp_path):
|
|
"""Create a mock spec-kit project directory."""
|
|
proj_dir = tmp_path / "project"
|
|
proj_dir.mkdir()
|
|
(proj_dir / ".specify").mkdir()
|
|
# Create required files for a project
|
|
(proj_dir / ".specify" / "config.toml").write_text("ai = 'claude'")
|
|
return proj_dir
|
|
|
|
def test_extension_update_corrupted_config_root(project_dir, monkeypatch):
|
|
"""Regression: extension update must handle corrupted extensions.yml (root is scalar)."""
|
|
# chdir into project_dir so _require_specify_project() succeeds
|
|
monkeypatch.chdir(project_dir)
|
|
|
|
# Corrupt extensions.yml
|
|
config_path = project_dir / ".specify" / "extensions.yml"
|
|
config_path.write_text(yaml.dump(123))
|
|
|
|
# Mock ExtensionManager to return an installed extension for resolution
|
|
|
|
monkeypatch.setattr(ExtensionManager, "list_installed", lambda self: [{"id": "test-ext", "name": "Test Ext", "version": "1.0.0"}])
|
|
monkeypatch.setattr(ExtensionRegistry, "get", lambda self, ext_id: {"version": "1.0.0", "enabled": True})
|
|
monkeypatch.setattr(ExtensionCatalog, "get_extension_info", lambda self, ext_id: {"id": "test-ext", "name": "Test Ext", "version": "1.1.0", "download_url": "https://example.com/ext.zip"})
|
|
|
|
# Mock download_extension to avoid network calls; use tmp_path so the test is hermetic
|
|
# and returns a Path so zip_path.exists() / zip_path.unlink() work without AttributeError
|
|
mock_zip = project_dir / "mock.zip"
|
|
monkeypatch.setattr(ExtensionCatalog, "download_extension", lambda self, ext_id: mock_zip)
|
|
|
|
# Mock confirmation to true
|
|
monkeypatch.setattr("typer.confirm", lambda _: True)
|
|
|
|
# Run update
|
|
result = runner.invoke(app, ["extension", "update", "test-ext"], obj={"project_root": project_dir})
|
|
|
|
# extension_update() catches exceptions internally and exits with code 1 on failure.
|
|
assert result.exit_code == 1
|
|
assert "AttributeError" not in result.output
|
|
assert not isinstance(result.exception, AttributeError)
|
|
|
|
def test_extension_update_corrupted_hooks_value(project_dir, monkeypatch):
|
|
"""Regression: extension update must handle non-dict 'hooks' in extensions.yml."""
|
|
monkeypatch.chdir(project_dir)
|
|
|
|
config_path = project_dir / ".specify" / "extensions.yml"
|
|
config_path.write_text(yaml.dump({
|
|
"installed": ["test-ext"],
|
|
"hooks": ["not", "a", "dict"]
|
|
}))
|
|
|
|
monkeypatch.setattr(ExtensionManager, "list_installed", lambda self: [{"id": "test-ext", "name": "Test Ext", "version": "1.0.0"}])
|
|
monkeypatch.setattr(ExtensionRegistry, "get", lambda self, ext_id: {"version": "1.0.0", "enabled": True})
|
|
monkeypatch.setattr(ExtensionCatalog, "get_extension_info", lambda self, ext_id: {"id": "test-ext", "name": "Test Ext", "version": "1.1.0", "download_url": "https://example.com/ext.zip"})
|
|
# Use tmp_path-scoped zip so the test is hermetic and returns a Path for zip_path.exists()
|
|
mock_zip = project_dir / "mock.zip"
|
|
monkeypatch.setattr(ExtensionCatalog, "download_extension", lambda self, ext_id: mock_zip)
|
|
monkeypatch.setattr("typer.confirm", lambda _: True)
|
|
|
|
result = runner.invoke(app, ["extension", "update", "test-ext"], obj={"project_root": project_dir})
|
|
|
|
# extension_update() catches exceptions internally and exits with code 1 on failure.
|
|
assert result.exit_code == 1
|
|
assert "AttributeError" not in result.output
|
|
assert not isinstance(result.exception, AttributeError)
|
|
|
|
def test_extension_update_rollback_corrupted_config(project_dir, monkeypatch):
|
|
"""Regression: extension update rollback must handle corrupted extensions.yml."""
|
|
monkeypatch.chdir(project_dir)
|
|
|
|
config_path = project_dir / ".specify" / "extensions.yml"
|
|
# Write config with hooks: null; get_project_config() normalizes this to {}
|
|
# so the backup captures {} and the restored config will have hooks: {}.
|
|
config_path.write_text(yaml.dump({"installed": ["test-ext"], "hooks": None}))
|
|
|
|
# Mock update process to fail after backup
|
|
monkeypatch.setattr(ExtensionManager, "list_installed", lambda self: [{"id": "test-ext", "name": "Test Ext", "version": "1.0.0"}])
|
|
monkeypatch.setattr(ExtensionRegistry, "get", lambda self, ext_id: {"version": "1.0.0", "enabled": True})
|
|
|
|
# Force failure in download_extension to trigger rollback
|
|
def mock_download_fail(*args, **kwargs):
|
|
# Corrupt the config BEFORE rollback is triggered
|
|
config_path.write_text(yaml.dump("CORRUPTED"))
|
|
raise Exception("Download failed")
|
|
|
|
monkeypatch.setattr(ExtensionCatalog, "get_extension_info", lambda self, ext_id: {"id": "test-ext", "name": "Test Ext", "version": "1.1.0", "download_url": "https://example.com/ext.zip"})
|
|
monkeypatch.setattr(ExtensionCatalog, "download_extension", mock_download_fail)
|
|
monkeypatch.setattr("typer.confirm", lambda _: True)
|
|
|
|
result = runner.invoke(app, ["extension", "update", "test-ext"], obj={"project_root": project_dir})
|
|
|
|
# Should handle Exception and NOT crash with AttributeError during rollback
|
|
assert result.exit_code == 1
|
|
assert "Download failed" in result.output
|
|
assert not isinstance(result.exception, AttributeError)
|
|
|
|
# Verify hooks key was preserved (normalized to {} if it was null/corrupted)
|
|
restored_config = yaml.safe_load(config_path.read_text())
|
|
assert isinstance(restored_config, dict)
|
|
assert "hooks" in restored_config
|
|
assert restored_config["hooks"] == {}
|