160 lines
8.5 KiB
Python
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"
|