Files
mdmem/tests/test_ui_flows.py
T
Mdmem Agent 8fdec33037 Fix stale references to deleted files in README and source
- Remove references to PLAN.md, SECURITY_ANALYSIS.md, deploy/ paths
- Delete test_deploy.py (tests deleted K8s manifests)
- Clean up eval/ references in comments and docstrings
2026-09-14 13:40:09 +00:00

520 lines
21 KiB
Python

"""End-to-end review flows through the UI (§10, P4)."""
from dataclasses import replace
import httpx
import pytest
from mdmem.embed import FakeEmbedder
from mdmem.service import MemoryService
from mdmem.ui.app import build_ui_app
from mdmem.ui.auth import SESSION_COOKIE, Principal, SessionCodec
@pytest.fixture()
async def ui(settings):
s = replace(
settings,
session_secret="x" * 32,
ui_base_url="https://memory.test",
ui_allowed_origins=("https://memory.test",),
)
svc = MemoryService.build(s, embedder=FakeEmbedder(s.embed_dim))
await svc.start(watch=False)
app = build_ui_app(svc)
cookie = SessionCodec(s.session_secret, 12).issue(Principal("u1", "Markus", ""))
client = httpx.AsyncClient(
transport=httpx.ASGITransport(app=app),
base_url="https://memory.test",
cookies={SESSION_COOKIE: cookie},
follow_redirects=False,
# A real browser sends Origin on every state-changing fetch/XHR/form POST;
# the CSRF guard now fails closed without it (§10, §14.21).
headers={"origin": "https://memory.test"},
)
yield client, svc
await client.aclose()
await svc.stop()
async def test_inbox_lists_unreviewed_notes(ui):
client, svc = ui
await svc.write_note(title="Neue Erkenntnis", body="Inhalt", type="fact", summary="kurz")
r = await client.get("/ui/inbox")
assert r.status_code == 200
assert "Neue Erkenntnis" in r.text and "kurz" in r.text
async def test_approve_moves_the_note_to_active(ui):
client, svc = ui
res = await svc.write_note(title="Zu prüfen", body="Inhalt", type="fact")
r = await client.post(f"/ui/n/{res['id']}/approve", data={})
assert r.status_code == 303
assert svc.get_note(res["id"]).state == "active"
assert (await svc.search("Inhalt")).results[0].verified is True
async def test_edit_and_approve_saves_the_edited_body(ui):
client, svc = ui
res = await svc.write_note(title="Roh", body="alter Text", type="fact")
await client.post(f"/ui/n/{res['id']}/approve", data={"body": "geprüfter Text"})
note = svc.get_note(res["id"])
assert note.state == "active" and note.body.strip() == "geprüfter Text"
async def test_reject_archives_with_a_reason(ui):
client, svc = ui
res = await svc.write_note(title="Unsinn", body="nope", type="note")
await client.post(f"/ui/n/{res['id']}/reject", data={"reason": "falsch"})
note = svc.get_note(res["id"])
assert note.state == "archive"
assert note.fm["rejected"] is True and note.fm["rejected_reason"] == "falsch"
assert (await svc.search("nope")).results == []
async def test_revision_shows_a_diff_and_merges_into_the_target(ui):
client, svc = ui
base = await svc.write_note(title="Fakt", body="Der Wert ist 10", type="fact")
await svc.approve(base["id"])
version = svc.get_note(base["id"]).file_hash
proposal = await svc.update_note(base["id"], version, body="Der Wert ist 12")
page = await client.get("/ui/inbox")
assert "Revision of" in page.text
assert "-Der Wert ist 10" in page.text and "+Der Wert ist 12" in page.text
r = await client.post(f"/ui/n/{proposal['id']}/approve", data={})
assert r.status_code == 303
assert svc.get_note(base["id"]).body.strip() == "Der Wert ist 12"
assert svc.get_note(proposal["id"]).state == "archive"
async def test_duplicates_are_offered_for_merging(ui):
client, svc = ui
first = await svc.write_note(
title="Bifrost ist das Gateway", body="Bifrost ist das Gateway für Modelle", type="fact"
)
await svc.approve(first["id"])
second = await svc.write_note(
title="Bifrost ist das Gateway", body="Bifrost ist das Gateway für Modelle", type="fact"
)
assert second["possible_duplicates"], "an identical note should be flagged as a duplicate"
page = await client.get("/ui/inbox")
assert "Possible duplicates" in page.text
await client.post(f"/ui/n/{second['id']}/merge", data={"target": first["id"]})
assert svc.get_note(second["id"]).state == "archive"
assert svc.get_note(first["id"]).body.count("Bifrost ist das Gateway") == 2
async def test_note_page_shows_links_backlinks_and_history(ui):
client, svc = ui
target = await svc.write_note(title="evo-x2", body="die Kiste", type="entity")
src = await svc.write_note(title="llama-swap", body="läuft auf [[evo-x2]]", type="fact")
await svc.edit(
src["id"], body="läuft auf [[evo-x2]] (v2)", expected_version=svc.get_note(src["id"]).file_hash
)
page = await client.get(f"/ui/n/{src['id']}")
assert "evo-x2" in page.text and "Backlinks" in page.text
assert "History" in page.text and "no earlier versions" not in page.text
back = await client.get(f"/ui/n/{target['id']}")
assert "llama-swap" in back.text
async def test_history_diff_for_a_note_whose_file_vanished_returns_404(ui):
"""A note can have history but no current file if it was unlinked outside of
hard_delete (e.g. by hand, or a bug elsewhere). svc.get_note then raises
NotFound — the route must turn that into 404, not an uncaught 500 (§10)."""
client, svc = ui
res = await svc.write_note(title="Verschwunden", body="v1", type="note")
await svc.edit(res["id"], body="v2", expected_version=svc.get_note(res["id"]).file_hash)
stamps = svc.vault.history(res["id"])
assert stamps, "editing an existing note must snapshot the previous version"
path = svc.vault.find(res["id"])
assert path is not None
path.unlink()
r = await client.get(f"/ui/n/{res['id']}/history/{stamps[0]}")
assert r.status_code == 404
async def test_edit_conflict_shows_a_diff_and_keeps_both_versions(ui):
client, svc = ui
res = await svc.write_note(title="Konflikt", body="original", type="note")
stale = svc.get_note(res["id"]).file_hash
await svc.edit(res["id"], body="von woanders geändert", expected_version=stale)
r = await client.post(
f"/ui/n/{res['id']}/edit",
data={"body": "meine Fassung", "frontmatter": "", "expected_version": stale},
)
assert r.status_code == 409
assert "Conflict" in r.text
assert "meine Fassung" in r.text and "von woanders geändert" in r.text
assert svc.get_note(res["id"]).body.strip() == "von woanders geändert"
async def test_edit_saves_body_and_frontmatter(ui):
client, svc = ui
res = await svc.write_note(title="Alt", body="alt", type="note", keywords=["a"])
version = svc.get_note(res["id"]).file_hash
page = await client.get(f"/ui/n/{res['id']}/edit")
assert page.status_code == 200 and "Frontmatter" in page.text
r = await client.post(
f"/ui/n/{res['id']}/edit",
data={
"body": "neuer Text",
"frontmatter": f"id: {res['id']}\ntitle: Neu\ntype: note\nkeywords: [a, b]\n",
"expected_version": version,
},
)
assert r.status_code == 303
note = svc.get_note(res["id"])
assert note.title == "Neu" and note.keywords == ["a", "b"] and note.body.strip() == "neuer Text"
async def test_invalid_frontmatter_is_rejected_with_400(ui):
client, svc = ui
res = await svc.write_note(title="T", body="b", type="note")
version = svc.get_note(res["id"]).file_hash
r = await client.post(
f"/ui/n/{res['id']}/edit",
data={"body": "x", "frontmatter": "title: [unclosed", "expected_version": version},
)
assert r.status_code == 400
async def test_live_preview_renders_markdown_without_html(ui):
client, _ = ui
r = await client.post("/ui/preview", data={"body": "**fett** <script>x</script>"})
assert "<strong>fett</strong>" in r.text and "<script>" not in r.text
async def test_archive_and_restore(ui):
client, svc = ui
res = await svc.write_note(title="Hin und her", body="x", type="note")
await svc.approve(res["id"])
await client.post(f"/ui/n/{res['id']}/state", data={"state": "archive"})
assert svc.get_note(res["id"]).state == "archive"
await client.post(f"/ui/n/{res['id']}/state", data={"state": "active"})
assert svc.get_note(res["id"]).state == "active"
async def test_hard_delete_requires_the_id_and_removes_everything(ui):
client, svc = ui
res = await svc.write_note(title="Weg", body="v1", type="note")
await svc.edit(res["id"], body="v2", expected_version=svc.get_note(res["id"]).file_hash)
bad = await client.post(f"/ui/n/{res['id']}/delete", data={"confirm": "nope"})
assert bad.status_code == 400 and svc.vault.find(res["id"]) is not None
ok = await client.post(f"/ui/n/{res['id']}/delete", data={"confirm": res["id"]})
assert ok.status_code == 303
assert svc.vault.find(res["id"]) is None
assert svc.vault.history(res["id"]) == []
assert svc.db.read().execute("SELECT count(*) FROM notes").fetchone()[0] == 0
async def test_browse_filters_by_tag_and_shows_the_cloud(ui):
client, svc = ui
a = await svc.write_note(title="Mit Tag", body="x", type="note", tags=["homelab"])
await svc.write_note(title="Ohne Tag", body="y", type="note")
r = await client.get("/ui/browse?tag=homelab")
assert "Mit Tag" in r.text and "Ohne Tag" not in r.text
assert "#homelab" in r.text
assert a["id"] in r.text
async def test_search_page_shows_debug_scores(ui):
client, svc = ui
await svc.write_note(title="Suchbar", body="Bifrost Gateway", type="fact")
r = await client.get("/ui/search?q=Bifrost")
assert "Suchbar" in r.text and "via" in r.text and "fused" in r.text
async def test_health_page_reports_state_and_reindexes(ui):
client, svc = ui
await svc.write_note(title="Eins", body="x", type="note")
r = await client.get("/ui/health")
assert "Embeddings" in r.text and svc.settings.embed_model in r.text
r2 = await client.post("/ui/health/reindex", data={"full": "1"})
assert r2.status_code == 303
r3 = await client.post("/ui/health/reembed", data={})
assert r3.status_code == 303
assert svc.db.read().execute("SELECT count(*) FROM chunks WHERE vec_pending=1").fetchone()[0] == 0
async def test_note_body_cannot_inject_script_into_the_page(ui):
client, svc = ui
await svc.write_note(
title="Böse", body="<script>alert('xss')</script>\n\n<img src=x onerror=alert(1)>", type="note"
)
r = await client.get("/ui/inbox")
assert "<script>alert" not in r.text
assert "<img" not in r.text # escaped, so no live tag reaches the DOM
assert "&lt;img src=x onerror=alert(1)&gt;" in r.text
async def test_unknown_note_returns_404(ui):
client, _ = ui
r = await client.get("/ui/n/01JZZZZZZZZZZZZZZZZZZZZZZZ")
assert r.status_code == 404 and "NOT_FOUND" in r.text
# -- M-17: required Prometheus signals ---------------------------------------
async def test_metrics_exports_required_families_with_bounded_labels(ui):
client, svc = ui
note = await svc.write_note(title="Geheimer Titel", body="streng geheimer Inhalt", type="fact")
await svc.approve(note["id"])
await client.get("/ui/search?q=Geheimer")
svc.audit.record(tool="memory_search", args="deadbeef", source="mcp", ok=True, ids=[note["id"]], ms=1.2)
svc.audit.record(tool="memory_write", args="c0ffee", source="mcp", ok=False, error="INVALID", ms=0.5)
r = await client.get("/metrics")
assert r.status_code == 200
body = r.text
for line in (
"# TYPE mdmem_notes gauge",
"# TYPE mdmem_searches_total counter",
"# TYPE mdmem_writes_total counter",
"# TYPE mdmem_embed_errors_total counter",
"# TYPE mdmem_search_latency_seconds histogram",
"# TYPE mdmem_mcp_calls_total counter",
):
assert line in body, line
assert "mdmem_search_latency_seconds_count 1.0" in body
assert 'mdmem_mcp_calls_total{outcome="ok",tool="memory_search"} 1.0' in body
assert 'mdmem_mcp_calls_total{outcome="error",tool="memory_write"} 1.0' in body
# bounded cardinality: exactly the (tool, outcome) pairs recorded above, never a per-note label
assert body.count("mdmem_mcp_calls_total{") == 2
assert "Geheimer Titel" not in body and "streng geheimer Inhalt" not in body
assert note["id"] not in body
async def test_metrics_totals_only_ever_advance_across_scrapes(ui):
client, svc = ui
await client.get("/metrics")
await svc.write_note(title="Noch einer", body="x", type="note")
body = (await client.get("/metrics")).text
assert "mdmem_writes_total 1.0" in body
# -- M-18 (UI half): search state filter exactness ---------------------------
async def test_search_state_filter_returns_exactly_that_lifecycle_state(ui):
client, svc = ui
await svc.write_note(title="Ampel Inbox", body="Ampel steht", type="note")
active_note = await svc.write_note(title="Ampel Aktiv", body="Ampel steht", type="note")
await svc.approve(active_note["id"])
archive_note = await svc.write_note(title="Ampel Archiv", body="Ampel steht", type="note")
await svc.approve(archive_note["id"])
await svc.set_state(archive_note["id"], "archive")
r_inbox = await client.get("/ui/search?q=Ampel&state=inbox")
assert "Ampel Inbox" in r_inbox.text
assert "Ampel Aktiv" not in r_inbox.text and "Ampel Archiv" not in r_inbox.text
r_active = await client.get("/ui/search?q=Ampel&state=active")
assert "Ampel Aktiv" in r_active.text
assert "Ampel Inbox" not in r_active.text and "Ampel Archiv" not in r_active.text
r_archive = await client.get("/ui/search?q=Ampel&state=archive")
assert "Ampel Archiv" in r_archive.text
assert "Ampel Inbox" not in r_archive.text and "Ampel Aktiv" not in r_archive.text
r_default = await client.get("/ui/search?q=Ampel")
assert "Ampel Inbox" in r_default.text and "Ampel Aktiv" in r_default.text
assert "Ampel Archiv" not in r_default.text
# -- lower-severity plan drift ------------------------------------------------
async def test_edit_and_approve_with_emptied_body_saves_empty_body(ui):
client, svc = ui
res = await svc.write_note(title="Leerbar", body="ursprünglicher Text", type="note")
r = await client.post(f"/ui/n/{res['id']}/approve", data={"body": ""})
assert r.status_code == 303
note = svc.get_note(res["id"])
assert note.state == "active"
assert note.body.strip() == ""
async def test_approve_without_a_body_field_keeps_the_original_body(ui):
client, svc = ui
res = await svc.write_note(title="Unverändert", body="bleibt", type="note")
r = await client.post(f"/ui/n/{res['id']}/approve", data={})
assert r.status_code == 303
assert svc.get_note(res["id"]).body.strip() == "bleibt"
async def test_history_compare_between_any_two_revisions(ui):
client, svc = ui
res = await svc.write_note(title="Verlaufstest", body="v1", type="note")
await svc.edit(res["id"], body="v2", expected_version=svc.get_note(res["id"]).file_hash)
await svc.edit(res["id"], body="v3", expected_version=svc.get_note(res["id"]).file_hash)
stamps = svc.vault.history(res["id"])
assert len(stamps) == 2
r = await client.get(f"/ui/n/{res['id']}/history/{stamps[0]}?other={stamps[1]}")
assert r.status_code == 200
assert "-v1" in r.text and "+v2" in r.text
r2 = await client.get(f"/ui/n/{res['id']}/history-diff?a={stamps[0]}&b={stamps[1]}")
assert r2.status_code == 200
assert "-v1" in r2.text and "+v2" in r2.text
page = await client.get(f"/ui/n/{res['id']}")
assert 'name="a"' in page.text and 'name="b"' in page.text and "history-diff" in page.text
async def test_browse_sorts_by_tags(ui):
client, svc = ui
await svc.write_note(title="Zeta Note", body="x", type="note", tags=["zzz"])
await svc.write_note(title="Beta Note", body="x", type="note", tags=["aaa"])
await svc.write_note(title="Kein Tag", body="x", type="note")
r = await client.get("/ui/browse?order=tags")
text = r.text
assert text.index("Kein Tag") < text.index("Beta Note") < text.index("Zeta Note")
async def test_health_page_shows_the_audit_source_and_eval_action(ui):
client, svc = ui
svc.audit.record(tool="memory_write", args="deadbeef", source="mcp", ok=True, ids=[], ms=3.0)
r = await client.get("/ui/health")
assert "mcp" in r.text
assert "Run eval" in r.text
async def test_run_eval_is_auth_and_csrf_protected_like_the_other_health_actions(ui):
client, svc = ui
ok = await client.post("/ui/health/eval", data={})
assert ok.status_code == 303
app = client._transport.app
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app),
base_url="https://memory.test",
follow_redirects=False,
headers={"origin": "https://memory.test"},
) as anon:
no_session = await anon.post("/ui/health/eval", data={})
assert no_session.status_code == 302 and "/ui/auth/login" in no_session.headers["location"]
cookie = SessionCodec(svc.settings.session_secret, 12).issue(Principal("u1", "M", ""))
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app),
base_url="https://memory.test",
cookies={SESSION_COOKIE: cookie},
follow_redirects=False,
) as bad_origin:
refused = await bad_origin.post(
"/ui/health/eval", data={}, headers={"origin": "https://evil.example.com"}
)
assert refused.status_code == 403
async def test_run_eval_keeps_its_files_on_the_data_volume_and_reports_status(ui, monkeypatch):
"""The pod runs with readOnlyRootFilesystem, so a relative results path
would fail at the write with only a log line. Both sides of the run must sit
next to index.db on the mounted volume."""
client, svc = ui
data_dir = svc.settings.index_db.parent
# no labelled query set yet: say so on the page instead of starting a job
# that exits 2 into the log
missing = await client.post("/ui/health/eval", data={})
assert missing.status_code == 303
assert "eval=missing" in missing.headers["location"]
assert str(data_dir) in missing.headers["location"]
queries = data_dir / "eval" / "queries.yaml"
queries.parent.mkdir(parents=True, exist_ok=True)
queries.write_text("- q: irgendwas\n relevant: []\n", encoding="utf-8")
called: dict[str, object] = {}
monkeypatch.setattr(
"mdmem.evalharness.run_eval_cli",
lambda settings, args: called.setdefault("args", args) and 0,
)
started = await client.post("/ui/health/eval", data={})
assert started.status_code == 303
assert "eval=started" in started.headers["location"]
args = called["args"]
assert args.queries == str(queries)
assert args.out is not None and args.out.startswith(str(data_dir))
# and the health page renders the status rather than swallowing it
page = await client.get("/ui/health", params={"eval": f"started:{data_dir.name}.json"})
assert page.status_code == 200 and "Eval started" in page.text
async def test_search_state_filter_still_finds_matches_below_the_first_page(ui):
"""Filtering a 20-row page down to one lifecycle state would hide a matching
inbox note whenever 20 active notes outrank it — the filter has to narrow a
wider candidate pool, not the page (M-18)."""
client, svc = ui
for i in range(25):
w = await svc.write_note(title=f"Bifrost Notiz {i}", body="Bifrost routet Modelle", type="fact")
await svc.approve(w["id"])
needle = await svc.write_note(title="Bifrost Posteingang", body="Bifrost routet Modelle", type="fact")
r = await client.get("/ui/search", params={"q": "Bifrost routet Modelle", "state": "inbox"})
assert r.status_code == 200
assert needle["id"] in r.text
async def test_an_inbox_note_can_be_reviewed_from_its_own_page(ui):
"""Fixing an LLM's frontmatter happens on the note page; without approve here
the owner has to navigate back to the inbox to act on what they just fixed."""
client, svc = ui
w = await svc.write_note(title="Needs a fix", body="v1", type="fact")
page = await client.get(f"/ui/n/{w['id']}")
assert "Approve" in page.text and "Reject" in page.text
r = await client.post(f"/ui/n/{w['id']}/approve", data={})
assert r.status_code == 303
assert svc.get_note(w["id"]).state == "active"
# and an active note offers no review buttons, only archive/edit/delete
page = await client.get(f"/ui/n/{w['id']}")
assert "/approve" not in page.text and "/reject" not in page.text
async def test_frontmatter_fixed_on_the_note_page_survives_approval(ui):
"""The whole point of the button: correct what the agent got wrong, then
accept it, without the fix being lost on the way."""
client, svc = ui
w = await svc.write_note(title="Wrong type", body="v1", type="note", tags=["bogus"])
note = svc.get_note(w["id"])
fm = note.to_text().split("---\n")[1].replace("type: note", "type: fact")
fm = fm.replace("- bogus", "- homelab")
saved = await client.post(
f"/ui/n/{w['id']}/edit",
data={"body": "v1 corrected", "frontmatter": fm, "expected_version": note.file_hash},
)
assert saved.status_code in (200, 303)
assert (await client.post(f"/ui/n/{w['id']}/approve", data={})).status_code == 303
approved = svc.get_note(w["id"])
assert approved.state == "active"
assert approved.type == "fact"
assert approved.tags == ["homelab"]
assert approved.body.strip() == "v1 corrected"