bifroest/tests/test_documents.py
2026-10-27 08:34:17 +01:00

236 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Abgeglichene Ordner: Abgleich aus dem Browser, Auslese über den Dokumentendienst (MCP), Sichtbarkeit, Tools."""
import base64
import hashlib
import io
import re
import pytest
from mcp.server.mcpserver import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
from sqlalchemy import select
from app.documents.models import DocFile, DocFolder
from app.identity import Principal
from app.identity.models import User
from app.tasks.worker import Worker
from app.tools import service as tools
from app.uploads import service as uploads
from app.workspaces.models import Workspace
from helpers import McpTestServer, create_workspace
class FakeReader:
"""Dokumentendienst: nimmt den Inhalt als Base64 und liefert Text."""
def __init__(self):
self.calls: list[dict] = []
server = MCPServer("reader", version="1.0")
@server.tool(description="Liest ein Dokument aus.")
def extract(data: str, name: str = "") -> str:
self.calls.append({"name": name, "size": len(data)})
if "kaputt" in name:
raise ToolError("Format nicht erkannt")
return f"Ausgelesen aus {name}:\n" + base64.b64decode(data).decode()
self.http = McpTestServer(server)
@pytest.fixture
def reader():
fake = FakeReader()
with fake.http:
yield fake
@pytest.fixture
def settings(settings):
return settings.model_copy(update={"mcp_allow_local": True, "mcp_timeout_seconds": 5, "doc_max_mb": 1})
@pytest.fixture
def ws(browser, people, db):
"""Workspace ohne Git; anna Plattform- und Workspace-Admin, ben Mitglied."""
with db.session() as session:
session.get(User, people["anna"]).is_admin = True
anna = browser("anna")
workspace_id = create_workspace(anna, people["team_demo"])
anna.post(f"/workspaces/{workspace_id}/members", {"username": "ben", "role": "member"})
admin = Principal(user_id=people["anna"], username="anna", display_name="Anna", is_admin=True,
team_ids=frozenset({people["team_demo"]}))
with db.session() as session:
tools.classify_suggested(session, admin)
return {"anna": anna, "ben": browser("ben"), "dora": browser("dora"), "id": workspace_id, "people": people}
def configure_reader(ws, reader) -> int:
anna = ws["anna"]
response = anna.post("/admin/mcp", {"name": "reader", "label": "Dokumente", "url": reader.http.url,
"role": "documents"})
server_id = int(response.headers["location"].rsplit("/", 1)[1])
page = anna.post(f"/admin/mcp/{server_id}/discover").text
assert "Dokumenten-Auslese" in page and "Noch nicht festgelegt" in page
bad = anna.post(f"/admin/mcp/{server_id}/documents", {"tool": "extract", "content": "gibts-nicht"})
assert bad.status_code == 400 and "keinen Parameter" in bad.text
page = anna.post(f"/admin/mcp/{server_id}/documents", {"tool": "extract", "content": "data", "filename": "name"})
assert "Dokumenten-Auslese gespeichert" in page.text
return server_id
def folder(b, ws, name="Unterlagen", scope="private", **extra) -> int:
response = b.post(f"/workspaces/{ws['id']}/folders", {"name": name, "scope": scope, **extra})
assert response.status_code == 303, response.text
return int(response.headers["location"].rsplit("/", 1)[1])
def put(b, folder_id, path, content: bytes, modified=1000):
csrf = re.search(r'data-csrf="([^"]+)"', b.get(f"/folders/{folder_id}").text)
headers = {"X-CSRF-Token": csrf.group(1)} if csrf else {}
return b.post(f"/folders/{folder_id}/files", {"path": path, "modified": str(modified)},
files={"file": (path.rsplit("/", 1)[-1], io.BytesIO(content))}, headers=headers)
def prune(b, folder_id, paths):
csrf = re.search(r'data-csrf="([^"]+)"', b.get(f"/folders/{folder_id}").text).group(1)
return b.post(f"/folders/{folder_id}/prune", content=__import__("json").dumps({"paths": paths}),
headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"})
def file_row(db, folder_id, path) -> DocFile:
with db.session() as session:
row = session.scalar(select(DocFile).where(DocFile.folder_id == folder_id, DocFile.path == path))
session.expunge(row)
return row
def test_abgleich_text_sofort_office_ueber_den_dienst(ws, db, settings, reader):
configure_reader(ws, reader)
ben = ws["ben"]
folder_id = folder(ben, ws)
assert ben.get(f"/folders/{folder_id}/manifest").json()["files"] == {}
assert put(ben, folder_id, "Notizen/plan.md", b"# Plan\nDeployment am Freitag.\n").json()["status"] == "ok"
assert put(ben, folder_id, "Berichte/q3.docx", "Umsätze im dritten Quartal gestiegen.".encode()).json() == \
{"path": "Berichte/q3.docx", "status": "pending"}
Worker(db, settings).run_once()
q3 = file_row(db, folder_id, "Berichte/q3.docx")
assert q3.status == "ok" and "Umsätze im dritten Quartal" in q3.text and "Ausgelesen aus q3.docx" in q3.text
assert reader.calls == [{"name": "q3.docx", "size": len(base64.b64encode("Umsätze im dritten Quartal gestiegen.".encode()))}]
q3, plan = "Umsätze im dritten Quartal gestiegen.".encode(), b"# Plan\nDeployment am Freitag.\n"
assert ben.get(f"/folders/{folder_id}/manifest").json()["files"] == {
"Berichte/q3.docx": [len(q3), 1000, hashlib.sha256(q3).hexdigest()],
"Notizen/plan.md": [len(plan), 1000, hashlib.sha256(plan).hexdigest()]}
# Gleicher Inhalt mit neuer Änderungszeit: nicht erneut ausgelesen
assert put(ben, folder_id, "Berichte/q3.docx", "Umsätze im dritten Quartal gestiegen.".encode(),
modified=2000).json()["status"] == "ok"
Worker(db, settings).run_once()
assert len(reader.calls) == 1
# Geänderter Inhalt: erneut ausgelesen
put(ben, folder_id, "Berichte/q3.docx", b"Neue Fassung", modified=3000)
Worker(db, settings).run_once()
assert "Neue Fassung" in file_row(db, folder_id, "Berichte/q3.docx").text and len(reader.calls) == 2
# Abschluss: was im Ordner fehlt, wird entfernt
assert prune(ben, folder_id, ["Berichte/q3.docx"]).json() == {"removed": 1}
with db.session() as session:
assert session.get(DocFolder, folder_id).synced is not None
assert session.scalar(select(DocFile.path).where(DocFile.folder_id == folder_id)) == "Berichte/q3.docx"
page = ben.get(f"/folders/{folder_id}").text
assert "lesbar" in page and "abgeglichen" in page and "data-doc-folder" in page
def test_fehler_und_ohne_dienst(ws, db, settings, reader):
ben = ws["ben"]
folder_id = folder(ben, ws)
# Ohne eingerichtete Auslese: Hinweis an der Datei
put(ben, folder_id, "a.pdf", b"%PDF")
Worker(db, settings).run_once()
assert file_row(db, folder_id, "a.pdf").status == "unsupported"
assert "Keine Dokumenten-Auslese eingerichtet" in ben.get(f"/folders/{folder_id}").text
configure_reader(ws, reader)
put(ben, folder_id, "kaputt.pptx", b"x")
Worker(db, settings).run_once()
broken = file_row(db, folder_id, "kaputt.pptx")
assert broken.status == "error" and "Format nicht erkannt" in broken.error
# Ungültige Pfade, Formate und Größen
assert put(ben, folder_id, "../x.md", b"x").status_code == 400
assert "Format wird nicht abgeglichen" in put(ben, folder_id, "programm.exe", b"x").json()["error"]
assert "Format wird nicht abgeglichen" in put(ben, folder_id, "~$sperre.docx", b"x").json()["error"]
assert "größer als 1 MB" in put(ben, folder_id, "gross.txt", b"x" * (1024 * 1024 + 1)).json()["error"]
# Ohne CSRF-Token kein Upload
assert ben.post(f"/folders/{folder_id}/files", {"path": "a.md", "_csrf": ""},
files={"file": ("a.md", io.BytesIO(b"x"))}).status_code == 403
def test_sichtbarkeit_nur_ich_und_workspace(ws, db):
ben, anna = ws["ben"], ws["anna"]
private = folder(ben, ws, "Privat")
assert anna.get(f"/folders/{private}").status_code == 404
assert "Privat" not in anna.get(f"/workspaces/{ws['id']}/data").text
assert "Privat" in ben.get(f"/workspaces/{ws['id']}/data").text
assert put(anna, private, "a.md", b"x").status_code in (403, 404)
assert ws["dora"].get(f"/folders/{private}").status_code == 404
# Workspace-Ordner nur mit Bestätigung; sichtbar für alle, abgleichen nur, wer ihn angelegt hat
response = ben.post(f"/workspaces/{ws['id']}/folders", {"name": "Team", "scope": "workspace"})
assert response.status_code == 400 and "bestätigen" in response.text
shared = folder(ben, ws, "Team", "workspace", exposure_confirmed="1")
page = anna.get(f"/folders/{shared}").text
assert "Team" in page and "data-doc-folder" not in page
assert anna.get(f"/folders/{shared}/manifest").status_code == 403
# Löschen: Workspace-Admin darf Workspace-Ordner, aber keine privaten fremden
assert anna.post(f"/folders/{private}/delete").status_code == 404
assert anna.post(f"/folders/{shared}/delete").status_code == 303
def chat_of(b, ws) -> int:
return int(b.post(f"/workspaces/{ws['id']}/chats").headers["location"].rsplit("/", 1)[1])
def ask(b, chat_id, text="Was steht in den Unterlagen?"):
return b.post(f"/chats/{chat_id}/messages", {"content": text}, headers={"HX-Request": "true"})
def outputs(fake_provider) -> list[str]:
return [i["output"] for i in fake_provider.calls[-1]["input"] if i.get("type") == "function_call_output"]
def test_tools_suchen_lesen_und_nur_im_eigenen_chat(ws, db, settings, fake_provider):
ben, anna = ws["ben"], ws["anna"]
folder_id = folder(ben, ws, "Unterlagen")
put(ben, folder_id, "Vertrag.md", "Kündigungsfrist: drei Monate zum Quartalsende.\nZeile zwei\nZeile drei".encode())
put(ben, folder_id, "Sonstiges.txt", b"Nichts Wichtiges.")
prune(ben, folder_id, ["Vertrag.md", "Sonstiges.txt"])
chat = chat_of(ben, ws)
fake_provider.script = [[("docs_search", {"query": "Kündigungsfristen"}),
("docs_read", {"folder": "Unterlagen", "path": "Vertrag.md", "start_line": 2,
"max_lines": 1}),
("docs_list", {})], "Drei Monate."]
ask(ben, chat)
Worker(db, settings).run_once()
call = fake_provider.calls[-1]
assert {"docs_search", "docs_read", "docs_list"} <= {t["name"] for t in fake_provider.calls[0]["tools"]}
assert "Abgeglichene Ordner" in call["instructions"] and "Unterlagen" in call["instructions"]
found, read, listed = outputs(fake_provider)
assert "Unterlagen / Vertrag.md" in found and "«Kündigungsfrist»" in found and "Sonstiges" not in found
assert "Zeilen 2–2 von 3" in read and "Zeile zwei" in read and "start_line=3" in read
assert "Vertrag.md" in listed and "Sonstiges.txt" in listed and "fremdinhalt" in listed
# Privater Ordner: nicht in Chats anderer und nicht, wenn jemand anderes in bens Chat fragt
fake_provider.calls.clear()
fake_provider.script = ["Ok."]
ask(anna, chat_of(anna, ws))
Worker(db, settings).run_once()
assert not {t["name"] for t in fake_provider.calls[-1]["tools"] or []} & {"docs_search", "docs_read"}
ask(anna, chat)
Worker(db, settings).run_once()
assert "Abgeglichene Ordner" not in fake_provider.calls[-1]["instructions"]
def test_text_zaehlt_aufs_kontingent(ws, db, settings):
ben = ws["ben"]
folder_id = folder(ben, ws)
put(ben, folder_id, "a.txt", b"x" * 5000)
with db.session() as session:
workspace = session.get(Workspace, ws["id"])
assert uploads.storage(session, settings, workspace).used == 5000
workspace.storage_limit_mb = 0
assert "Speicherkontingent" in put(ben, folder_id, "b.txt", b"y").json()["error"]