Files
hermes-agent/tests/plugins/test_kanban_attachments.py
Teknium b47cb1bbf2 feat(kanban): file attachments on tasks (#35395)
Tasks can now carry file attachments (PDFs, images, source docs) that
workers read directly — closes the gap where source material had to be
pasted as a path into the task body.

- kanban_db: task_attachments table (additive), Attachment dataclass,
  add/list/get/delete accessors, attachments_root/task_attachments_dir
  path helpers (per-board, HERMES_KANBAN_ATTACHMENTS_ROOT override)
- build_worker_context: surfaces each attachment's absolute path so the
  worker (full file/terminal tool access) reads it via read_file/pdftotext
- dashboard API: POST/GET/DELETE attachment routes (multipart upload,
  25MB cap, traversal-safe filenames, root-containment check on download)
- dashboard UI: Attachments section in the task drawer — upload button,
  list with download, per-row remove
- docs + tests (13 cases: DB accessors, REST round-trip, traversal
  rejection, collision suffixing, worker-context surfacing)

Closes #35338
2026-05-30 07:41:04 -07:00

292 lines
9.5 KiB
Python

"""Tests for Kanban task file attachments (#35338).
Covers three layers:
* ``hermes_cli.kanban_db`` accessors (add/list/get/delete + path helpers)
* the dashboard REST surface (upload / list / download / delete)
* worker-context surfacing so a kanban worker sees the absolute paths
The plugin router is attached to a bare FastAPI app — same approach as
``test_kanban_dashboard_plugin.py`` — so we exercise the real HTTP path
(multipart upload, streaming download) without the whole dashboard.
"""
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from hermes_cli import kanban_db as kb
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
def _load_plugin_router():
repo_root = Path(__file__).resolve().parents[2]
plugin_file = repo_root / "plugins" / "kanban" / "dashboard" / "plugin_api.py"
assert plugin_file.exists(), f"plugin file missing: {plugin_file}"
spec = importlib.util.spec_from_file_location(
"hermes_dashboard_plugin_kanban_attach_test", plugin_file,
)
assert spec is not None and spec.loader is not None
mod = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = mod
spec.loader.exec_module(mod)
return mod.router
@pytest.fixture
def kanban_home(tmp_path, monkeypatch):
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
kb.init_db()
return home
@pytest.fixture
def client(kanban_home):
app = FastAPI()
app.include_router(_load_plugin_router(), prefix="/api/plugins/kanban")
return TestClient(app)
def _make_task(conn, title="t") -> str:
return kb.create_task(conn, title=title)
# ---------------------------------------------------------------------------
# DB-layer accessors
# ---------------------------------------------------------------------------
def test_add_list_get_delete_attachment(kanban_home, tmp_path):
conn = kb.connect()
try:
task_id = _make_task(conn)
# Write a real blob under the per-task dir so delete can unlink it.
dest_dir = kb.task_attachments_dir(task_id)
dest_dir.mkdir(parents=True, exist_ok=True)
blob = dest_dir / "source.pdf"
blob.write_bytes(b"%PDF-1.4 fake")
att_id = kb.add_attachment(
conn,
task_id,
filename="source.pdf",
stored_path=str(blob),
content_type="application/pdf",
size=blob.stat().st_size,
uploaded_by="tester",
)
assert att_id > 0
atts = kb.list_attachments(conn, task_id)
assert len(atts) == 1
a = atts[0]
assert a.filename == "source.pdf"
assert a.content_type == "application/pdf"
assert a.size == len(b"%PDF-1.4 fake")
assert a.uploaded_by == "tester"
assert a.stored_path == str(blob)
got = kb.get_attachment(conn, att_id)
assert got is not None and got.id == att_id
removed = kb.delete_attachment(conn, att_id)
assert removed is not None and removed.id == att_id
assert kb.list_attachments(conn, task_id) == []
assert not blob.exists(), "delete should unlink the on-disk blob"
assert kb.get_attachment(conn, att_id) is None
finally:
conn.close()
def test_add_attachment_rejects_unknown_task(kanban_home):
conn = kb.connect()
try:
with pytest.raises(ValueError):
kb.add_attachment(
conn, "t_doesnotexist", filename="x.txt", stored_path="/tmp/x.txt"
)
finally:
conn.close()
def test_add_attachment_appends_event(kanban_home):
conn = kb.connect()
try:
task_id = _make_task(conn)
kb.add_attachment(
conn, task_id, filename="a.txt", stored_path="/tmp/a.txt", size=3
)
kinds = [e.kind for e in kb.list_events(conn, task_id)]
assert "attached" in kinds
finally:
conn.close()
def test_delete_attachment_missing_returns_none(kanban_home):
conn = kb.connect()
try:
assert kb.delete_attachment(conn, 999999) is None
finally:
conn.close()
def test_attachments_root_is_per_board(kanban_home, monkeypatch):
# default board uses <root>/kanban/attachments
default_root = kb.attachments_root(board="default")
assert default_root.name == "attachments"
# a named board nests under its board dir
monkeypatch.delenv("HERMES_KANBAN_ATTACHMENTS_ROOT", raising=False)
named = kb.attachments_root(board="default")
assert named == default_root
def test_attachments_root_env_override(kanban_home, monkeypatch, tmp_path):
override = tmp_path / "custom-attach"
monkeypatch.setenv("HERMES_KANBAN_ATTACHMENTS_ROOT", str(override))
assert kb.attachments_root() == override
assert kb.task_attachments_dir("t_abc") == override / "t_abc"
# ---------------------------------------------------------------------------
# Worker context surfacing
# ---------------------------------------------------------------------------
def test_worker_context_lists_attachments_with_absolute_path(kanban_home):
conn = kb.connect()
try:
task_id = _make_task(conn, title="translate PDF")
dest_dir = kb.task_attachments_dir(task_id)
dest_dir.mkdir(parents=True, exist_ok=True)
blob = dest_dir / "manual.pdf"
blob.write_bytes(b"data")
kb.add_attachment(
conn,
task_id,
filename="manual.pdf",
stored_path=str(blob.resolve()),
content_type="application/pdf",
size=4,
)
ctx = kb.build_worker_context(conn, task_id)
assert "## Attachments" in ctx
assert "manual.pdf" in ctx
# The absolute path must appear so the worker can read_file it.
assert str(blob.resolve()) in ctx
finally:
conn.close()
def test_worker_context_no_attachments_section_when_empty(kanban_home):
conn = kb.connect()
try:
task_id = _make_task(conn)
ctx = kb.build_worker_context(conn, task_id)
assert "## Attachments" not in ctx
finally:
conn.close()
# ---------------------------------------------------------------------------
# REST surface — upload / list / download / delete round-trip
# ---------------------------------------------------------------------------
def _create_task_via_api(client) -> str:
r = client.post("/api/plugins/kanban/tasks", json={"title": "x"})
assert r.status_code == 200, r.text
return r.json()["task"]["id"]
def test_upload_list_download_delete_roundtrip(client):
task_id = _create_task_via_api(client)
content = b"hello attachment world"
# Upload
r = client.post(
f"/api/plugins/kanban/tasks/{task_id}/attachments",
files={"file": ("notes.txt", content, "text/plain")},
)
assert r.status_code == 200, r.text
att = r.json()["attachment"]
assert att["filename"] == "notes.txt"
assert att["size"] == len(content)
att_id = att["id"]
# List (drawer also embeds it in GET /tasks/:id)
r = client.get(f"/api/plugins/kanban/tasks/{task_id}/attachments")
assert r.status_code == 200
assert [a["filename"] for a in r.json()["attachments"]] == ["notes.txt"]
detail = client.get(f"/api/plugins/kanban/tasks/{task_id}").json()
assert "attachments" in detail
assert len(detail["attachments"]) == 1
# Download streams the exact bytes back
r = client.get(f"/api/plugins/kanban/attachments/{att_id}")
assert r.status_code == 200
assert r.content == content
# Delete removes the row and the file
r = client.delete(f"/api/plugins/kanban/attachments/{att_id}")
assert r.status_code == 200
assert client.get(f"/api/plugins/kanban/attachments/{att_id}").status_code == 404
assert client.get(
f"/api/plugins/kanban/tasks/{task_id}/attachments"
).json()["attachments"] == []
def test_upload_sanitizes_traversal_filename(client):
task_id = _create_task_via_api(client)
r = client.post(
f"/api/plugins/kanban/tasks/{task_id}/attachments",
files={"file": ("../../../../etc/passwd", b"x", "text/plain")},
)
assert r.status_code == 200, r.text
stored_path = r.json()["attachment"]["stored_path"]
# The leaf name only; never escapes the per-task attachments dir.
assert Path(stored_path).name == "passwd"
task_dir = kb.task_attachments_dir(task_id).resolve()
assert Path(stored_path).resolve().is_relative_to(task_dir)
def test_upload_name_collision_gets_suffixed(client):
task_id = _create_task_via_api(client)
for _ in range(2):
r = client.post(
f"/api/plugins/kanban/tasks/{task_id}/attachments",
files={"file": ("dup.txt", b"a", "text/plain")},
)
assert r.status_code == 200, r.text
names = sorted(
a["filename"]
for a in client.get(
f"/api/plugins/kanban/tasks/{task_id}/attachments"
).json()["attachments"]
)
assert names == ["dup (1).txt", "dup.txt"]
def test_upload_unknown_task_404(client):
r = client.post(
"/api/plugins/kanban/tasks/t_nope/attachments",
files={"file": ("x.txt", b"x", "text/plain")},
)
assert r.status_code == 404
def test_download_unknown_attachment_404(client):
assert client.get("/api/plugins/kanban/attachments/424242").status_code == 404