"""Tests for kanban board export / import (``hermes_cli.kanban_transfer``). The contract these pin down is "a board survives the trip to another machine, and nothing that only made sense on the exporting machine comes with it": * Content round-trips — tasks, comments, links, events, attachment blobs. * Runtime state does not — claims, worker PIDs, heartbeats, session ids, and gateway chat subscriptions are gone on the far side. * Paths are re-anchored — attachment rows point into the importing board's tree, and tasks whose workspace cannot be rebuilt here are parked instead of being fed to the dispatcher. * An import never mutates a board that already exists. * A hostile archive cannot write outside the import destination. """ from __future__ import annotations import json import sys import tarfile import time from pathlib import Path import pytest # Ensure the worktree (not the stale global clone) is first on sys.path. _WORKTREE = Path(__file__).resolve().parents[2] if str(_WORKTREE) not in sys.path: sys.path.insert(0, str(_WORKTREE)) from hermes_cli import kanban_db as kb from hermes_cli import kanban_transfer as kt from hermes_cli.archive_safe import normalize_archive_parts, safe_extract_targz @pytest.fixture def kanban_root(tmp_path, monkeypatch): """Point kanban at an empty root, and hand back a switcher. Export and import have to run against two different machines' state. Calling the returned function re-points every kanban path helper at a fresh root, which is as close to "the other machine" as a unit test gets. """ def _use(name: str) -> Path: root = tmp_path / name root.mkdir(exist_ok=True) monkeypatch.setenv("HERMES_HOME", str(root)) monkeypatch.setenv("HERMES_KANBAN_HOME", str(root)) for var in ("HERMES_KANBAN_DB", "HERMES_KANBAN_WORKSPACES_ROOT", "HERMES_KANBAN_ATTACHMENTS_ROOT", "HERMES_KANBAN_BOARD"): monkeypatch.delenv(var, raising=False) kb._INITIALIZED_PATHS.clear() return root _use("source") return _use def _seed_board(slug: str = "alpha") -> dict[str, str]: """Create a board with one task of each interesting shape.""" kb.create_board(slug, name="Alpha Board") ids = {} with kb.connect_closing(board=slug) as conn: ids["scratch"] = kb.create_task( conn, title="scratch task", body="body", assignee="coder" ) ids["worktree"] = kb.create_task( conn, title="worktree task", assignee="coder", workspace_kind="worktree", workspace_path="/exporter/repo", ) kb.add_comment(conn, ids["scratch"], "brooklyn", "a comment") kb.link_tasks(conn, ids["scratch"], ids["worktree"]) kb.store_attachment_bytes( conn, ids["scratch"], "notes.txt", b"hello attachment", board=slug ) return ids def _claim(task_id: str, slug: str = "alpha") -> None: """Put a task into the state a live worker would leave behind.""" with kb.connect_closing(board=slug) as conn: with kb.write_txn(conn): conn.execute( "UPDATE tasks SET status='running', claim_lock='lock-1', " "claim_expires=?, worker_pid=4242, last_heartbeat_at=?, " "session_id='sess-xyz', consecutive_failures=2 WHERE id=?", (int(time.time()) + 600, int(time.time()), task_id), ) def _subscribe(task_id: str, slug: str = "alpha") -> None: with kb.connect_closing(board=slug) as conn: with kb.write_txn(conn): conn.execute( "INSERT INTO kanban_notify_subs " "(task_id, platform, chat_id, thread_id, created_at) " "VALUES (?, 'telegram', '12345', '', ?)", (task_id, int(time.time())), ) def _tasks_by_title(slug: str) -> dict[str, dict]: with kb.connect_closing(board=slug) as conn: return { row["title"]: dict(row) for row in conn.execute("SELECT * FROM tasks").fetchall() } # --------------------------------------------------------------------------- # Round trip # --------------------------------------------------------------------------- def test_round_trip_preserves_content(kanban_root, tmp_path): _seed_board() archive = kt.export_board("alpha", str(tmp_path / "alpha"))["archive"] kanban_root("target") result = kt.import_board(archive) assert result["counts"]["tasks"] == 2 assert result["counts"]["task_comments"] == 1 assert result["counts"]["task_links"] == 1 tasks = _tasks_by_title(result["board"]) assert set(tasks) == {"scratch task", "worktree task"} assert tasks["scratch task"]["body"] == "body" assert tasks["scratch task"]["assignee"] == "coder" def test_attachment_blob_travels_and_is_readable(kanban_root, tmp_path): _seed_board() archive = kt.export_board("alpha", str(tmp_path / "alpha"))["archive"] target_root = kanban_root("target") result = kt.import_board(archive) with kb.connect_closing(board=result["board"]) as conn: row = conn.execute( "SELECT filename, stored_path FROM task_attachments" ).fetchone() assert row["filename"] == "notes.txt" stored = Path(row["stored_path"]) # Re-anchored under the importing machine's board, not the exporter's. assert target_root in stored.parents assert stored.read_bytes() == b"hello attachment" def test_export_without_attachments_drops_the_rows(kanban_root, tmp_path): _seed_board() archive = kt.export_board( "alpha", str(tmp_path / "alpha"), include_attachments=False )["archive"] kanban_root("target") result = kt.import_board(archive) # A row whose blob never travelled would be a broken download link in # every UI that lists it, so it is dropped and reported. assert result["counts"]["task_attachments"] == 0 assert any("attachment" in w for w in result["warnings"]) def test_workspaces_are_never_exported(kanban_root, tmp_path): _seed_board() workspace = kb.workspaces_root("alpha") / "junk" workspace.mkdir(parents=True) (workspace / "huge.bin").write_bytes(b"x" * 1024) archive = kt.export_board("alpha", str(tmp_path / "alpha"))["archive"] with tarfile.open(archive, "r:gz") as tf: names = tf.getnames() assert not any("workspaces" in name for name in names) # --------------------------------------------------------------------------- # Machine-local state does not travel # --------------------------------------------------------------------------- def test_claimed_task_arrives_unclaimed_and_queued(kanban_root, tmp_path): ids = _seed_board() _claim(ids["scratch"]) archive = kt.export_board("alpha", str(tmp_path / "alpha"))["archive"] kanban_root("target") result = kt.import_board(archive) task = _tasks_by_title(result["board"])["scratch task"] # A claim held by a PID on the exporting machine must not survive, or # the importing dispatcher inherits a lock nothing will ever release. assert task["status"] == "ready" assert task["claim_lock"] is None assert task["claim_expires"] is None assert task["worker_pid"] is None assert task["last_heartbeat_at"] is None assert task["current_run_id"] is None assert task["session_id"] is None assert task["consecutive_failures"] == 0 def test_gateway_subscriptions_never_travel(kanban_root, tmp_path): ids = _seed_board() _subscribe(ids["scratch"]) archive = kt.export_board("alpha", str(tmp_path / "alpha"))["archive"] # Not merely dropped on import — the chat id must not be in the file # at all, because the archive is the thing that gets shared. kanban_root("target") result = kt.import_board(archive) with kb.connect_closing(board=result["board"]) as conn: assert conn.execute( "SELECT COUNT(*) FROM kanban_notify_subs" ).fetchone()[0] == 0 assert b"12345" not in Path(archive).read_bytes() def test_unresolvable_workspaces_are_parked_not_dispatched(kanban_root, tmp_path): _seed_board() archive = kt.export_board("alpha", str(tmp_path / "alpha"))["archive"] kanban_root("target") result = kt.import_board(archive) tasks = _tasks_by_title(result["board"]) # The worktree lived on the exporting machine's disk. Letting the # dispatcher claim this would fail workspace resolution twice and trip # the failure breaker, so it waits for a human instead. assert tasks["worktree task"]["status"] == "triage" assert tasks["worktree task"]["workspace_path"] is None assert result["tasks_parked"] == 1 # A scratch task needs no path — it regenerates one under this board. assert tasks["scratch task"]["status"] == "ready" assert tasks["scratch task"]["workspace_path"] is None def test_board_metadata_loses_exporter_local_paths(kanban_root, tmp_path): kb.create_board("alpha", name="Alpha Board", default_workdir="/exporter/repo", project_id="proj-1") archive = kt.export_board("alpha", str(tmp_path / "alpha"))["archive"] kanban_root("target") result = kt.import_board(archive) meta = kb.read_board_metadata(result["board"]) assert meta["name"] == "Alpha Board" assert meta["default_workdir"] is None assert meta["project_id"] is None # --------------------------------------------------------------------------- # Import never overwrites # --------------------------------------------------------------------------- def test_slug_collision_creates_a_new_board(kanban_root, tmp_path): _seed_board() archive = kt.export_board("alpha", str(tmp_path / "alpha"))["archive"] kanban_root("target") first = kt.import_board(archive) second = kt.import_board(archive) assert first["board"] == "alpha" assert second["board"] != first["board"] assert second["renamed"] is True assert second["requested_board"] == "alpha" # The board that was already there is untouched. assert len(_tasks_by_title(first["board"])) == 2 def test_import_never_targets_the_default_board(kanban_root, tmp_path): with kb.connect_closing(board="default") as conn: kb.create_task(conn, title="exported default task") archive = kt.export_board("default", str(tmp_path / "default"))["archive"] kanban_root("target") with kb.connect_closing(board="default") as conn: kb.create_task(conn, title="local default task") result = kt.import_board(archive) assert result["board"] != "default" assert set(_tasks_by_title("default")) == {"local default task"} assert set(_tasks_by_title(result["board"])) == {"exported default task"} def test_explicit_slug_is_honoured(kanban_root, tmp_path): _seed_board() archive = kt.export_board("alpha", str(tmp_path / "alpha"))["archive"] kanban_root("target") result = kt.import_board(archive, "renamed-board", activate=True) assert result["board"] == "renamed-board" assert result["renamed"] is False assert kb.get_current_board() == "renamed-board" # --------------------------------------------------------------------------- # Hostile / malformed input # --------------------------------------------------------------------------- @pytest.mark.parametrize("member", [ "../escape.txt", "board/../../escape.txt", "/etc/passwd", "C:\\Windows\\system32", "board\\..\\..\\escape.txt", ]) def test_traversal_members_are_rejected(member): with pytest.raises(ValueError): normalize_archive_parts(member) def test_extract_refuses_a_symlink_member(tmp_path): payload = tmp_path / "payload" payload.mkdir() (payload / "real.txt").write_text("fine") link = payload / "link" link.symlink_to("/etc/passwd") archive = tmp_path / "evil.tar.gz" with tarfile.open(archive, "w:gz") as tf: tf.add(payload, arcname="payload") with pytest.raises(ValueError, match="Unsupported archive member"): safe_extract_targz(archive, tmp_path / "out") def test_import_rejects_a_non_kanban_archive(kanban_root, tmp_path): payload = tmp_path / "notaboard" payload.mkdir() (payload / "readme.txt").write_text("hi") archive = tmp_path / "other.tar.gz" with tarfile.open(archive, "w:gz") as tf: tf.add(payload, arcname="notaboard") with pytest.raises(ValueError, match="manifest"): kt.import_board(str(archive)) def test_import_rejects_a_future_format_version(kanban_root, tmp_path): _seed_board() archive = Path(kt.export_board("alpha", str(tmp_path / "alpha"))["archive"]) staged = tmp_path / "restage" safe_extract_targz(archive, staged) manifest_path = staged / "alpha" / "manifest.json" manifest = json.loads(manifest_path.read_text()) manifest["format_version"] = kt.ARCHIVE_FORMAT_VERSION + 1 manifest_path.write_text(json.dumps(manifest)) bumped = tmp_path / "bumped.tar.gz" with tarfile.open(bumped, "w:gz") as tf: tf.add(staged / "alpha", arcname="alpha") kanban_root("target") with pytest.raises(ValueError, match="newer than this Hermes"): kt.import_board(str(bumped))