"""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"]