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

160 lines
8.5 KiB
Python

"""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 "<h1>Neu Name</h1>" 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</a>' 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</button>" not in overview
page = ws["ben"].get(f"{base}/data").text
assert "Datenquellen" in page and "Hochladen</button>" in page and "daten.csv" in page
assert "Verbinden</summary>" not in page # Mitglieder verbinden keine Quellen
assert "Verbinden</summary>" 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"