diff --git a/l4d2web/routes/overlay_routes.py b/l4d2web/routes/overlay_routes.py index 450fa45..1dadb0e 100644 --- a/l4d2web/routes/overlay_routes.py +++ b/l4d2web/routes/overlay_routes.py @@ -10,7 +10,7 @@ from l4d2web.services.security import validate_overlay_path bp = Blueprint("overlay", __name__) -@bp.post("/admin/overlays") +@bp.post("/overlays") @require_admin def create_overlay() -> Response: name = request.form.get("name", "").strip() @@ -29,4 +29,38 @@ def create_overlay() -> Response: return Response("overlay already exists", status=409) db.add(Overlay(name=name, path=str(validated_path))) - return redirect("/admin/overlays") + return redirect("/overlays") + + +@bp.post("/overlays/") +@require_admin +def update_overlay(overlay_id: int) -> Response: + name = request.form.get("name", "").strip() + raw_path = request.form.get("path", "").strip() + if not name or not raw_path: + return Response("missing fields", status=400) + + try: + validated_path = validate_overlay_path(raw_path) + except ValueError as exc: + return Response(str(exc), status=400) + + with session_scope() as db: + overlay = db.scalar(select(Overlay).where(Overlay.id == overlay_id)) + if overlay is None: + return Response(status=404) + overlay.name = name + overlay.path = str(validated_path) + + return redirect("/overlays") + + +@bp.post("/overlays//delete") +@require_admin +def delete_overlay(overlay_id: int) -> Response: + with session_scope() as db: + overlay = db.scalar(select(Overlay).where(Overlay.id == overlay_id)) + if overlay is None: + return Response(status=404) + db.delete(overlay) + return redirect("/overlays") diff --git a/l4d2web/routes/page_routes.py b/l4d2web/routes/page_routes.py index 36fe2f7..cdc312c 100644 --- a/l4d2web/routes/page_routes.py +++ b/l4d2web/routes/page_routes.py @@ -15,16 +15,15 @@ bp = Blueprint("pages", __name__) @bp.get("/dashboard") @require_login def dashboard() -> str: - user = current_user() - assert user is not None + return render_template("dashboard.html") + +@bp.get("/overlays") +@require_login +def overlays() -> str: with session_scope() as db: - servers = db.scalars(select(Server).where(Server.user_id == user.id).order_by(Server.name)).all() - return render_template( - "dashboard.html", - servers=servers, - refresh_seconds=current_app.config["STATUS_REFRESH_SECONDS"], - ) + overlays = db.scalars(select(Overlay).order_by(Overlay.name)).all() + return render_template("overlays.html", overlays=overlays) @bp.get("/blueprints/") @@ -69,10 +68,3 @@ def server_detail(server_id: int): return render_template("server_detail.html", server=server) - -@bp.get("/admin/overlays") -@require_admin -def admin_overlays() -> str: - with session_scope() as db: - overlays = db.scalars(select(Overlay).order_by(Overlay.name)).all() - return render_template("admin_overlays.html", overlays=overlays) diff --git a/l4d2web/templates/overlays.html b/l4d2web/templates/overlays.html new file mode 100644 index 0000000..f4fe15c --- /dev/null +++ b/l4d2web/templates/overlays.html @@ -0,0 +1,48 @@ +{% extends "base.html" %} + +{% block title %}Overlays | left4me{% endblock %} + +{% block content %} +
+
+

Overlays

+
+ + {% if g.user.admin %} +
+ + + + +
+ {% endif %} + + + {% if g.user.admin %}{% endif %} + + {% for overlay in overlays %} + + + + {% if g.user.admin %} + + {% endif %} + + {% else %} + + {% endfor %} + +
NamePathActions
{{ overlay.name }}{{ overlay.path }} +
+ + + + +
+
+ + +
+
No overlays configured.
+
+{% endblock %} diff --git a/l4d2web/tests/test_overlays.py b/l4d2web/tests/test_overlays.py index 9e77a2b..d7cb51b 100644 --- a/l4d2web/tests/test_overlays.py +++ b/l4d2web/tests/test_overlays.py @@ -1,14 +1,13 @@ import pytest - from l4d2web.app import create_app from l4d2web.auth import hash_password from l4d2web.db import init_db, session_scope -from l4d2web.models import User +from l4d2web.models import Overlay, User @pytest.fixture def admin_client(tmp_path, monkeypatch): - db_url = f"sqlite:///{tmp_path/'overlay.db'}" + db_url = f"sqlite:///{tmp_path/'admin_overlay.db'}" monkeypatch.setenv("DATABASE_URL", db_url) app = create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"}) init_db() @@ -26,19 +25,90 @@ def admin_client(tmp_path, monkeypatch): return client +@pytest.fixture +def user_client_with_overlay(tmp_path, monkeypatch): + db_url = f"sqlite:///{tmp_path/'user_overlay.db'}" + monkeypatch.setenv("DATABASE_URL", db_url) + app = create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"}) + init_db() + + with session_scope() as session: + user = User(username="alice", password_digest=hash_password("secret"), admin=False) + session.add(user) + session.add(Overlay(name="standard", path="/opt/l4d2/overlays/standard")) + session.flush() + user_id = user.id + + client = app.test_client() + with client.session_transaction() as sess: + sess["user_id"] = user_id + sess["csrf_token"] = "test-token" + return client + + +def test_user_can_view_overlay_catalog(user_client_with_overlay) -> None: + response = user_client_with_overlay.get("/overlays") + text = response.get_data(as_text=True) + + assert response.status_code == 200 + assert "standard" in text + assert "Add overlay" not in text + + +def test_admin_can_view_overlay_edit_controls(admin_client) -> None: + response = admin_client.get("/overlays") + text = response.get_data(as_text=True) + + assert response.status_code == 200 + assert "Add overlay" in text + assert 'action="/overlays"' in text + + def test_admin_can_create_overlay(admin_client) -> None: response = admin_client.post( - "/admin/overlays", + "/overlays", data={"name": "standard", "path": "/opt/l4d2/overlays/standard"}, headers={"X-CSRF-Token": "test-token"}, ) assert response.status_code == 302 + assert response.headers["Location"] == "/overlays" def test_overlay_path_must_be_under_root(admin_client) -> None: response = admin_client.post( - "/admin/overlays", + "/overlays", data={"name": "bad", "path": "/tmp/bad"}, headers={"X-CSRF-Token": "test-token"}, ) assert response.status_code == 400 + + +def test_non_admin_cannot_create_overlay(user_client_with_overlay) -> None: + response = user_client_with_overlay.post( + "/overlays", + data={"name": "bad", "path": "/opt/l4d2/overlays/bad"}, + headers={"X-CSRF-Token": "test-token"}, + ) + assert response.status_code == 403 + + +def test_admin_can_update_and_delete_overlay(admin_client) -> None: + create = admin_client.post( + "/overlays", + data={"name": "standard", "path": "/opt/l4d2/overlays/standard"}, + headers={"X-CSRF-Token": "test-token"}, + ) + assert create.status_code == 302 + + update = admin_client.post( + "/overlays/1", + data={"name": "edited", "path": "/opt/l4d2/overlays/edited"}, + headers={"X-CSRF-Token": "test-token"}, + ) + assert update.status_code == 302 + + delete = admin_client.post( + "/overlays/1/delete", + headers={"X-CSRF-Token": "test-token"}, + ) + assert delete.status_code == 302