"""Workspace löschen und eigene Run-Ansicht.""" from pathlib import Path import pytest from sqlalchemy import func, select from app.approvals.models import ApprovalRequest from app.audit.models import AuditEvent from app.chat.models import ChatSession, Message from app.data_sources.models import DataSource from app.identity.models import User from app.tasks.models import TaskRun from app.tasks.worker import Worker from app.uploads.models import Upload from app.usage.models import UsageRecord from app.workspaces.models import Workspace, WorkspaceMembership from helpers import connect_repo, create_workspace CSV = b"a,b\n1,2\n" @pytest.fixture def ws(browser, people, db, git_repo, settings): with db.session() as session: session.get(User, people["anna"]).is_admin = True anna = browser("anna") workspace_id = create_workspace(anna, people["team_demo"], "Alt") connect_repo(anna, workspace_id, git_repo) anna.post(f"/workspaces/{workspace_id}/members", {"username": "ben", "role": "member"}) anna.post(f"/workspaces/{workspace_id}/uploads", files={"file": ("daten.csv", CSV)}) chat_id = int(anna.post(f"/workspaces/{workspace_id}/chats").headers["location"].rsplit("/", 1)[1]) return {"anna": anna, "ben": browser("ben"), "dora": browser("dora"), "id": workspace_id, "chat_id": chat_id} def count(db, model) -> int: with db.session() as session: return session.scalar(select(func.count()).select_from(model)) def test_papierkorb_und_endgueltig_loeschen(ws, db, settings, fake_provider): ws["anna"].post(f"/chats/{ws['chat_id']}/messages", {"content": "Hallo"}, headers={"HX-Request": "true"}) Worker(db, settings).run_once() with db.session() as session: source_id = session.scalar(select(DataSource.id)) # Git-Klon und DuckDB-Datei liegen auf der Platte Path(settings.git_cache_dir, f"{source_id}.git").mkdir(parents=True, exist_ok=True) upload_dir = Path(settings.upload_dir, str(ws["id"])) assert any(upload_dir.iterdir()) response = ws["anna"].post(f"/workspaces/{ws['id']}/delete", {"confirmation": "Alt"}) assert response.status_code == 303 and response.headers["location"].startswith("/workspaces?trashed=Alt") # Im Papierkorb: für alle unsichtbar, Daten bleiben for b in ("anna", "ben"): assert ws[b].get(f"/workspaces/{ws['id']}").status_code == 404 assert ws[b].get(f"/chats/{ws['chat_id']}").status_code == 404 overview = ws["anna"].get("/workspaces").text assert "Papierkorb" in overview and "Wiederherstellen" in overview assert "Papierkorb" not in ws["ben"].get("/workspaces").text assert count(db, ChatSession) == 1 and upload_dir.exists() # Wiederherstellen assert ws["ben"].post(f"/workspaces/{ws['id']}/restore").status_code == 403 assert ws["anna"].post(f"/workspaces/{ws['id']}/restore").headers["location"] == f"/workspaces/{ws['id']}" assert ws["ben"].get(f"/workspaces/{ws['id']}").status_code == 200 # Erneut löschen, dann endgültig ws["anna"].post(f"/workspaces/{ws['id']}/delete", {"confirmation": "Alt"}) response = ws["anna"].post(f"/workspaces/{ws['id']}/purge") assert response.status_code == 303 and response.headers["location"].startswith("/workspaces?deleted=Alt") assert "Workspace „Alt“ endgültig gelöscht" in ws["anna"].get(response.headers["location"]).text for model in (Workspace, WorkspaceMembership, DataSource, ChatSession, Message, TaskRun, Upload, ApprovalRequest): assert count(db, model) == 0, model assert not upload_dir.exists() and not Path(settings.git_cache_dir, f"{source_id}.git").exists() # Verbrauch und Audit bleiben, ohne Workspace-Bezug with db.session() as session: assert session.scalar(select(func.count()).select_from(UsageRecord)) == 1 assert session.scalar(select(UsageRecord.workspace_id)) is None actions = set(session.scalars(select(AuditEvent.action))) event = session.scalar(select(AuditEvent).where(AuditEvent.action == "workspace.deleted")) assert {"workspace.trashed", "workspace.restored", "workspace.deleted"} <= actions assert event.target_id == str(ws["id"]) and event.data["name"] == "Alt" assert ws["anna"].post(f"/workspaces/{ws['id']}/purge").status_code == 404 def test_purge_nur_aus_dem_papierkorb(ws, db): assert ws["anna"].post(f"/workspaces/{ws['id']}/purge").status_code == 404 assert count(db, Workspace) == 1 def test_worker_loescht_nach_ablauf_der_frist(ws, db, settings): from datetime import timedelta from app.core.database import now ws["anna"].post(f"/workspaces/{ws['id']}/delete", {"confirmation": "Alt"}) Worker(db, settings).run_once() assert count(db, Workspace) == 1 # Frist läuft noch with db.session() as session: session.get(Workspace, ws["id"]).deleted = now() - timedelta(days=settings.workspace_trash_days, hours=1) Worker(db, settings).run_once() assert count(db, Workspace) == 0 and count(db, ChatSession) == 0 with db.session() as session: event = session.scalar(select(AuditEvent).where(AuditEvent.action == "workspace.deleted")) assert event.data["automatic"] is True and event.user_id is None def test_loeschen_braucht_bestaetigung_und_rechte(ws, db): assert ws["anna"].post(f"/workspaces/{ws['id']}/delete", {"confirmation": "alt"}).status_code == 400 assert ws["ben"].post(f"/workspaces/{ws['id']}/delete", {"confirmation": "Alt"}).status_code == 403 assert ws["dora"].post(f"/workspaces/{ws['id']}/delete", {"confirmation": "Alt"}).status_code == 404 with db.session() as session: assert session.scalar(select(Workspace.deleted)) is None assert "In den Papierkorb" in ws["anna"].get(f"/workspaces/{ws['id']}/settings").text assert ws["ben"].get(f"/workspaces/{ws['id']}/settings").status_code == 403 def test_workspace_umbenennen_und_rolle_aendern(ws, db, people): base = f"/workspaces/{ws['id']}" response = ws["anna"].post(f"{base}/settings", {"name": " Neu Name ", "description": "Für Tests"}) assert response.headers["location"] == f"{base}/settings?tab=general&saved=1" page = ws["anna"].get(base).text assert "

Neu Name

" in page and "Für Tests" in page assert ws["ben"].post(f"{base}/settings", {"name": "X"}).status_code == 403 assert ws["anna"].post(f"{base}/settings", {"name": " "}).status_code == 400 ws["anna"].post(f"{base}/members/{people['ben']}/role", {"role": "admin"}) assert ws["ben"].get(f"{base}/settings").status_code == 200 ws["ben"].post(f"{base}/members/{people['ben']}/role", {"role": "member"}) assert ws["ben"].get(f"{base}/settings").status_code == 403 with db.session() as session: assert session.scalar(select(AuditEvent).where(AuditEvent.action == "workspace.updated")).data["name"] == "Neu Name" def test_nicht_waehrend_ein_run_laeuft(ws, db): ws["anna"].post(f"/chats/{ws['chat_id']}/messages", {"content": "Hallo"}, headers={"HX-Request": "true"}) response = ws["anna"].post(f"/workspaces/{ws['id']}/delete", {"confirmation": "Alt"}) assert response.status_code == 400 and "erst abbrechen" in response.text with db.session() as session: assert session.scalar(select(Workspace.deleted)) is None def test_runs_als_eigener_menuepunkt(ws): page = ws["anna"].get("/admin/runs").text assert 'href="/admin/runs" class="active">Runs' in page assert 'href="/admin/usage" class="active"' not in page assert "Runs ansehen" not in ws["anna"].get("/admin/usage").text page = ws["anna"].get(f"/workspaces/{ws['id']}").text assert f'href="/workspaces/{ws["id"]}/runs"' in page and f'href="/workspaces/{ws["id"]}/settings"' in page member = ws["ben"].get(f"/workspaces/{ws['id']}").text assert f'href="/workspaces/{ws["id"]}/runs"' not in member and "Einstellungen" not in member def test_daten_als_eigener_bereich(ws): base = f"/workspaces/{ws['id']}" overview = ws["ben"].get(base).text # Übersicht: nur eine Zeile zum Kontext, Verwaltung unter Daten assert "Kontext:" in overview and f'href="{base}/data"' in overview and "Hochladen" not in overview page = ws["ben"].get(f"{base}/data").text assert "Datenquellen" in page and "Hochladen" in page and "daten.csv" in page assert "Verbinden" not in page # Mitglieder verbinden keine Quellen assert "Verbinden" in ws["anna"].get(f"{base}/data").text assert ws["dora"].get(f"{base}/data").status_code == 404 assert ws["anna"].post(f"{base}/uploads", files={"file": ("mehr.csv", CSV)}).headers["location"] == f"{base}/data#uploads"