236 lines
12 KiB
Python
236 lines
12 KiB
Python
"""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"]
|