Files
talemate/tests/test_delete_scene_project.py
veguAI 3ca8f0a550 Refuse deleting the save file of the currently loaded scene (#114) (#121)
* fix: refuse deleting the save file of the currently loaded scene (#114)

* refactor: extract shared active-scene realpath helper, pin full_path-None delete behavior (PR #121 review)

* docs: note that the loaded scene's save file and project cannot be deleted; broaden delete test module docstring (PR #121 review)
2026-07-20 16:00:12 +03:00

282 lines
9.0 KiB
Python

"""
Tests for the ``delete_scene_project`` and ``delete_scene`` config websocket
actions.
``delete_scene_project`` removes an entire scene project directory (saves,
assets, nodes, changelog) and cleans up recent-scene entries pointing into
it. It must refuse anything that is not a direct child of the scenes
directory. ``delete_scene`` removes a single save file and its changelog
artifacts. Both must refuse targets belonging to the currently loaded scene.
"""
import json
import os
import pytest
import talemate.config.state as config_state
import talemate.emit.async_signals as async_signals
import talemate.files
from talemate.config import Config
from talemate.config.schema import RecentScene
from talemate.server.config import ConfigPlugin
@pytest.fixture(autouse=True)
def isolated_config_signals():
"""
set_dirty() broadcasts config.changed to whatever receivers other test
modules left connected (e.g. mock agents) - isolate the signal here.
"""
saved = {}
for name in ("config.changed", "config.changed.follow"):
signal = async_signals.get(name)
saved[signal] = signal.receivers
signal.receivers = []
yield
for signal, receivers in saved.items():
signal.receivers = receivers
class _MockWebsocketHandler:
def __init__(self):
self.messages: list[dict] = []
self.scene = None
def queue_put(self, data):
self.messages.append(data)
def by_action(self, action):
return [m for m in self.messages if m.get("action") == action]
@pytest.fixture
def scenes_root(tmp_path, monkeypatch):
root = tmp_path / "scenes"
root.mkdir()
monkeypatch.setattr(talemate.files, "SCENES_DIR", root)
return root
@pytest.fixture
def app_config(monkeypatch):
config = Config(recent_scenes={"scenes": []})
monkeypatch.setattr(config_state, "CONFIG", config)
return config
@pytest.fixture
def plugin():
return ConfigPlugin(_MockWebsocketHandler())
def _make_project(scenes_root, name="adventure"):
project = scenes_root / name
(project / "assets").mkdir(parents=True)
(project / "changelog").mkdir()
(project / "save.json").write_text(json.dumps({"name": name}))
(project / "assets" / "cover.png").write_bytes(b"png")
(project / "changelog" / "save.json.base.json").write_text("{}")
return project
@pytest.mark.asyncio
async def test_delete_scene_project(scenes_root, app_config, plugin):
project = _make_project(scenes_root)
other = _make_project(scenes_root, "other")
app_config.recent_scenes.scenes = [
RecentScene(
name="Adventure",
path=str(project / "save.json"),
filename="save.json",
date="2026-07-19T00:00:00",
),
RecentScene(
name="Other",
path=str(other / "save.json"),
filename="save.json",
date="2026-07-19T00:00:00",
),
]
await plugin.handle_delete_scene_project({"path": str(project)})
assert not project.exists()
assert other.exists()
# recents entry for the deleted project removed, the other kept
assert [scene.name for scene in app_config.recent_scenes.scenes] == ["Other"]
completed = plugin.websocket_handler.by_action("delete_scene_project_complete")
assert len(completed) == 1
assert completed[0]["data"]["name"] == "adventure"
# app_config broadcast follows
assert any(
message.get("type") == "app_config"
for message in plugin.websocket_handler.messages
)
@pytest.mark.asyncio
async def test_delete_rejects_paths_outside_scenes_dir(
tmp_path, scenes_root, app_config, plugin
):
outside = tmp_path / "not-scenes" / "project"
outside.mkdir(parents=True)
with pytest.raises(ValueError, match="Not a scene project"):
await plugin.handle_delete_scene_project({"path": str(outside)})
assert outside.exists()
@pytest.mark.asyncio
async def test_delete_rejects_scenes_root_itself(scenes_root, app_config, plugin):
with pytest.raises(ValueError, match="Not a scene project"):
await plugin.handle_delete_scene_project({"path": str(scenes_root)})
assert scenes_root.exists()
@pytest.mark.asyncio
async def test_delete_rejects_reserved_dirs(scenes_root, app_config, plugin):
for reserved in ("characters", "assets"):
(scenes_root / reserved).mkdir()
with pytest.raises(ValueError, match="Not a scene project"):
await plugin.handle_delete_scene_project(
{"path": str(scenes_root / reserved)}
)
assert (scenes_root / reserved).exists()
@pytest.mark.asyncio
async def test_delete_rejects_traversal(tmp_path, scenes_root, app_config, plugin):
victim = tmp_path / "victim"
victim.mkdir()
traversal = str(scenes_root / "project" / ".." / ".." / "victim")
with pytest.raises(ValueError, match="Not a scene project"):
await plugin.handle_delete_scene_project({"path": traversal})
assert victim.exists()
@pytest.mark.asyncio
async def test_delete_rejects_symlink_escape(tmp_path, scenes_root, app_config, plugin):
victim = tmp_path / "victim"
victim.mkdir()
link = scenes_root / "linked-project"
os.symlink(str(victim), str(link))
with pytest.raises(ValueError, match="Not a scene project"):
await plugin.handle_delete_scene_project({"path": str(link)})
assert victim.exists()
@pytest.mark.asyncio
async def test_delete_rejects_missing_project(scenes_root, app_config, plugin):
with pytest.raises(ValueError, match="not found"):
await plugin.handle_delete_scene_project(
{"path": str(scenes_root / "does-not-exist")}
)
@pytest.mark.asyncio
async def test_delete_rejects_active_scene_project(scenes_root, app_config, plugin):
project = _make_project(scenes_root)
class _SceneStub:
full_path = str(project / "save.json")
plugin.websocket_handler.scene = _SceneStub()
with pytest.raises(ValueError, match="currently loaded scene"):
await plugin.handle_delete_scene_project({"path": str(project)})
assert project.exists()
# a different loaded scene does not block the delete
other = _make_project(scenes_root, "other")
plugin.websocket_handler.scene = type(
"_OtherScene", (), {"full_path": str(other / "save.json")}
)()
await plugin.handle_delete_scene_project({"path": str(project)})
assert not project.exists()
@pytest.mark.asyncio
async def test_delete_scene_file_contained(tmp_path, scenes_root, app_config, plugin):
project = _make_project(scenes_root)
await plugin.handle_delete_scene({"path": str(project / "save.json")})
assert not (project / "save.json").exists()
outside = tmp_path / "outside.json"
outside.write_text("{}")
with pytest.raises(ValueError, match="Not a scene file"):
await plugin.handle_delete_scene({"path": str(outside)})
assert outside.exists()
@pytest.mark.asyncio
async def test_delete_scene_rejects_active_scene_save(scenes_root, app_config, plugin):
project = _make_project(scenes_root)
save_path = project / "save.json"
app_config.recent_scenes.scenes = [
RecentScene(
name="Adventure",
path=str(save_path),
filename="save.json",
date="2026-07-20T00:00:00",
),
]
class _SceneStub:
full_path = str(save_path)
plugin.websocket_handler.scene = _SceneStub()
with pytest.raises(ValueError, match="currently loaded scene"):
await plugin.handle_delete_scene({"path": str(save_path)})
# file, recents entry and changelog artifacts all untouched
assert save_path.exists()
assert [scene.name for scene in app_config.recent_scenes.scenes] == ["Adventure"]
assert (project / "changelog" / "save.json.base.json").exists()
assert plugin.websocket_handler.by_action("delete_scene_complete") == []
# a symlink resolving to the active save is refused as well
link = project / "linked-save.json"
os.symlink(str(save_path), str(link))
with pytest.raises(ValueError, match="currently loaded scene"):
await plugin.handle_delete_scene({"path": str(link)})
assert save_path.exists()
link.unlink()
# a different save file of the same loaded project still deletes
sibling = project / "other-save.json"
sibling.write_text("{}")
await plugin.handle_delete_scene({"path": str(sibling)})
assert not sibling.exists()
assert save_path.exists()
# a loaded scene without a full_path (immutable/demo save after an
# autosave attempt cleared its filename) does not block the delete -
# nothing can resurrect the file once filename is unset
unsaved = project / "unsaved-save.json"
unsaved.write_text("{}")
plugin.websocket_handler.scene = type("_UnsavedScene", (), {"full_path": None})()
await plugin.handle_delete_scene({"path": str(unsaved)})
assert not unsaved.exists()
# with the scene unloaded the save file deletes normally
plugin.websocket_handler.scene = None
await plugin.handle_delete_scene({"path": str(save_path)})
assert not save_path.exists()