mirror of
https://github.com/himanshudongre/smriti.git
synced 2026-08-28 05:14:59 +00:00
The deletion-safety thread guarded the CLI (`--force`) and the MCP tool
(`confirm_space`), but `DELETE /api/v2/repos/{id}` itself still cascade-
deleted a fully populated space — every checkpoint, session, and turn —
with no server-side check. Any client that bypassed the CLI/MCP guards
(a direct API call, the web UI, a future client) reopened the incident
path.
Gate the route: `delete_repo` now counts the space's checkpoints and
refuses with 409 plus a structured detail (`message`, `checkpoint_count`,
`requires_force`) unless `?force=true` is passed — the same shape as the
`checkpoint delete` dependent-guard. An empty space still deletes with no
force.
Wire the existing clients through the new signal so their behavior is
unchanged: the CLI passes `force=args.force` (its own `--force` gate),
the MCP tool passes `force=true` after its `confirm_space` gate, and
quickstart passes `force=true` to tear down its own demo space.
273 lines
9.1 KiB
Python
273 lines
9.1 KiB
Python
from __future__ import annotations
|
|
|
|
import argparse
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from smriti_cli import attachment
|
|
from smriti_cli import main as cli_main
|
|
from smriti_cli.client import SmritiClient
|
|
|
|
|
|
def _space_dict(**overrides):
|
|
base = {
|
|
"id": "space-uuid",
|
|
"name": "my-project",
|
|
"description": "",
|
|
"project_root": "/tmp/project",
|
|
}
|
|
base.update(overrides)
|
|
return base
|
|
|
|
|
|
def test_space_create_parser_wiring():
|
|
parser = cli_main._build_parser()
|
|
|
|
args = parser.parse_args(
|
|
[
|
|
"space",
|
|
"create",
|
|
"my-project",
|
|
"--description",
|
|
"Test",
|
|
"--project-root",
|
|
"/tmp/project",
|
|
]
|
|
)
|
|
|
|
assert args.command == "space"
|
|
assert args.subcommand == "create"
|
|
assert args.name == "my-project"
|
|
assert args.description == "Test"
|
|
assert args.project_root == "/tmp/project"
|
|
assert args.no_project_root is False
|
|
assert args.func is cli_main.cmd_space_create
|
|
|
|
|
|
def test_space_create_parser_supports_no_project_root():
|
|
parser = cli_main._build_parser()
|
|
|
|
args = parser.parse_args(["space", "create", "my-project", "--no-project-root"])
|
|
|
|
assert args.project_root is None
|
|
assert args.no_project_root is True
|
|
|
|
|
|
def test_space_set_project_root_parser_wiring():
|
|
parser = cli_main._build_parser()
|
|
|
|
args = parser.parse_args(
|
|
["space", "set-project-root", "my-project", "/tmp/project"]
|
|
)
|
|
|
|
assert args.command == "space"
|
|
assert args.subcommand == "set-project-root"
|
|
assert args.space == "my-project"
|
|
assert args.path == "/tmp/project"
|
|
assert args.here is False
|
|
assert args.func is cli_main.cmd_space_set_project_root
|
|
|
|
|
|
def test_space_set_project_root_parser_supports_here_flag():
|
|
parser = cli_main._build_parser()
|
|
|
|
args = parser.parse_args(["space", "set-project-root", "my-project", "--here"])
|
|
|
|
assert args.path is None
|
|
assert args.here is True
|
|
|
|
|
|
def test_cmd_space_create_defaults_project_root_to_cwd(
|
|
tmp_path,
|
|
monkeypatch,
|
|
capsys: pytest.CaptureFixture[str],
|
|
):
|
|
monkeypatch.chdir(tmp_path)
|
|
client = MagicMock(spec=SmritiClient)
|
|
client.create_space.return_value = _space_dict(project_root=str(tmp_path))
|
|
args = argparse.Namespace(
|
|
name="my-project",
|
|
description="",
|
|
project_root=None,
|
|
no_project_root=False,
|
|
json=False,
|
|
)
|
|
|
|
cli_main.cmd_space_create(client, args)
|
|
|
|
out = capsys.readouterr().out
|
|
assert "Created space: my-project" in out
|
|
assert f"Project root: {tmp_path}" in out
|
|
client.create_space.assert_called_once_with(
|
|
name="my-project",
|
|
description="",
|
|
project_root=str(tmp_path),
|
|
)
|
|
|
|
|
|
def test_cmd_space_create_passes_explicit_project_root():
|
|
client = MagicMock(spec=SmritiClient)
|
|
client.create_space.return_value = _space_dict(project_root="/tmp/explicit")
|
|
args = argparse.Namespace(
|
|
name="my-project",
|
|
description="Test",
|
|
project_root="/tmp/explicit",
|
|
no_project_root=False,
|
|
json=False,
|
|
)
|
|
|
|
cli_main.cmd_space_create(client, args)
|
|
|
|
client.create_space.assert_called_once_with(
|
|
name="my-project",
|
|
description="Test",
|
|
project_root="/tmp/explicit",
|
|
)
|
|
|
|
|
|
def test_cmd_space_create_can_leave_project_root_null(
|
|
capsys: pytest.CaptureFixture[str],
|
|
):
|
|
client = MagicMock(spec=SmritiClient)
|
|
client.create_space.return_value = _space_dict(project_root=None)
|
|
args = argparse.Namespace(
|
|
name="my-project",
|
|
description="",
|
|
project_root=None,
|
|
no_project_root=True,
|
|
json=False,
|
|
)
|
|
|
|
cli_main.cmd_space_create(client, args)
|
|
|
|
out = capsys.readouterr().out
|
|
assert "Project root:" not in out
|
|
client.create_space.assert_called_once_with(
|
|
name="my-project",
|
|
description="",
|
|
project_root=None,
|
|
)
|
|
|
|
|
|
def test_cmd_space_set_project_root_calls_patch(capsys: pytest.CaptureFixture[str]):
|
|
client = MagicMock(spec=SmritiClient)
|
|
client.resolve_space.return_value = _space_dict(id="space-uuid", name="my-project")
|
|
client.set_project_root.return_value = _space_dict(
|
|
id="space-uuid",
|
|
name="my-project",
|
|
project_root="/tmp/project",
|
|
)
|
|
args = argparse.Namespace(
|
|
space="my-project",
|
|
path="/tmp/project",
|
|
here=False,
|
|
json=False,
|
|
)
|
|
|
|
cli_main.cmd_space_set_project_root(client, args)
|
|
|
|
assert (
|
|
capsys.readouterr().out == "Set project_root for 'my-project' to /tmp/project\n"
|
|
)
|
|
client.resolve_space.assert_called_once_with("my-project")
|
|
client.set_project_root.assert_called_once_with("space-uuid", "/tmp/project")
|
|
|
|
|
|
def test_cmd_space_set_project_root_dot_resolves_cwd(tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
client = MagicMock(spec=SmritiClient)
|
|
client.resolve_space.return_value = _space_dict(id="space-uuid")
|
|
client.set_project_root.return_value = _space_dict(project_root=str(tmp_path))
|
|
args = argparse.Namespace(space="my-project", path=".", here=False, json=False)
|
|
|
|
cli_main.cmd_space_set_project_root(client, args)
|
|
|
|
client.set_project_root.assert_called_once_with("space-uuid", str(tmp_path))
|
|
|
|
|
|
def test_cmd_space_set_project_root_here_flag_resolves_cwd(tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
client = MagicMock(spec=SmritiClient)
|
|
client.resolve_space.return_value = _space_dict(id="space-uuid")
|
|
client.set_project_root.return_value = _space_dict(project_root=str(tmp_path))
|
|
args = argparse.Namespace(space="my-project", path=None, here=True, json=False)
|
|
|
|
cli_main.cmd_space_set_project_root(client, args)
|
|
|
|
client.set_project_root.assert_called_once_with("space-uuid", str(tmp_path))
|
|
|
|
|
|
# ── space delete: destructive-delete guard ───────────────────────────────────
|
|
#
|
|
# Incident: `smriti space delete <space> -y` cascade-deleted a populated space.
|
|
# A non-empty or attached space must now require an explicit --force.
|
|
|
|
|
|
def _delete_args(space="my-project", yes=False, force=False, json=False):
|
|
return argparse.Namespace(space=space, yes=yes, force=force, json=json)
|
|
|
|
|
|
def test_space_delete_parser_has_force_flag():
|
|
parser = cli_main._build_parser()
|
|
args = parser.parse_args(["space", "delete", "my-project", "--force", "-y"])
|
|
assert args.force is True
|
|
assert args.yes is True
|
|
# --force defaults off
|
|
assert parser.parse_args(["space", "delete", "my-project"]).force is False
|
|
|
|
|
|
def test_cmd_space_delete_empty_unattached_space_allows_yes(tmp_path, monkeypatch):
|
|
"""Empty, unattached space: -y still deletes it (convenience retained)."""
|
|
monkeypatch.chdir(tmp_path)
|
|
client = MagicMock(spec=SmritiClient)
|
|
client.resolve_space.return_value = _space_dict()
|
|
client.list_commits.return_value = [] # empty
|
|
cli_main.cmd_space_delete(client, _delete_args(yes=True))
|
|
client.delete_space.assert_called_once_with("space-uuid", force=False)
|
|
|
|
|
|
def test_cmd_space_delete_nonempty_refused_without_force(tmp_path, monkeypatch, capsys):
|
|
"""The incident path: -y alone must NOT delete a populated space."""
|
|
monkeypatch.chdir(tmp_path)
|
|
client = MagicMock(spec=SmritiClient)
|
|
client.resolve_space.return_value = _space_dict()
|
|
client.list_commits.return_value = [{"id": "c1"}, {"id": "c2"}] # non-empty
|
|
with pytest.raises(SystemExit):
|
|
cli_main.cmd_space_delete(client, _delete_args(yes=True, force=False))
|
|
client.delete_space.assert_not_called()
|
|
err = capsys.readouterr().err
|
|
assert "Refusing to delete" in err
|
|
assert "--force" in err
|
|
|
|
|
|
def test_cmd_space_delete_nonempty_allowed_with_force(tmp_path, monkeypatch):
|
|
"""A populated space deletes only with --force (in addition to -y)."""
|
|
monkeypatch.chdir(tmp_path)
|
|
client = MagicMock(spec=SmritiClient)
|
|
client.resolve_space.return_value = _space_dict()
|
|
client.list_commits.return_value = [{"id": "c1"}, {"id": "c2"}]
|
|
cli_main.cmd_space_delete(client, _delete_args(yes=True, force=True))
|
|
client.delete_space.assert_called_once_with("space-uuid", force=True)
|
|
|
|
|
|
def test_cmd_space_delete_attached_space_refused_without_force(tmp_path, monkeypatch):
|
|
"""An attached space is force-gated even when it is empty."""
|
|
monkeypatch.chdir(tmp_path)
|
|
attachment.write_attachment(tmp_path, "my-project", "space-uuid")
|
|
client = MagicMock(spec=SmritiClient)
|
|
client.resolve_space.return_value = _space_dict()
|
|
client.list_commits.return_value = [] # empty, but attached
|
|
with pytest.raises(SystemExit):
|
|
cli_main.cmd_space_delete(client, _delete_args(yes=True, force=False))
|
|
client.delete_space.assert_not_called()
|
|
|
|
|
|
def test_cmd_space_delete_attached_space_allowed_with_force(tmp_path, monkeypatch):
|
|
monkeypatch.chdir(tmp_path)
|
|
attachment.write_attachment(tmp_path, "my-project", "space-uuid")
|
|
client = MagicMock(spec=SmritiClient)
|
|
client.resolve_space.return_value = _space_dict()
|
|
client.list_commits.return_value = []
|
|
cli_main.cmd_space_delete(client, _delete_args(yes=True, force=True))
|
|
client.delete_space.assert_called_once_with("space-uuid", force=True)
|