From 923a1840f4245ca76b9ad28c90edfed42c50448a Mon Sep 17 00:00:00 2001 From: mwiegand Date: Thu, 7 May 2026 01:30:33 +0200 Subject: [PATCH] feat(web): forms in modals, edit/delete on detail pages, port auto-assign - Native modal infra (CSS + ~30 LOC JS, no framework) used for create forms and delete confirmations. - Index pages become listing-only: + Create button opens a modal; the broken blueprint Actions column and inline overlay edit cells are gone. - Server detail gains a blueprint reassignment form; existing Delete button now opens a confirmation modal before tearing down the runtime. - Blueprint detail gains a Delete button + confirmation modal (was unreachable from the UI before). - New overlay detail page at /overlays/ with edit form, "Used by" blueprints list, and delete (admin only). - Server create: port field is now optional; backend auto-assigns the next free port from LEFT4ME_PORT_RANGE_START/_END (default 27015-27115). 409 on range exhaustion. - New routes: POST /blueprints//delete (form sentinel matching overlays pattern), POST /servers/ (form-friendly blueprint reassign), GET /overlays/. - Server delete operation now redirects to /servers; overlay update redirects to /overlays/. Server rename remains unsupported pending an id-vs-name design pass for l4d2host (the runtime directory is name-keyed; renaming would orphan files). Co-Authored-By: Claude Opus 4.7 (1M context) --- l4d2web/config.py | 4 + l4d2web/routes/blueprint_routes.py | 55 ++++++--- l4d2web/routes/overlay_routes.py | 2 +- l4d2web/routes/page_routes.py | 26 ++++ l4d2web/routes/server_routes.py | 74 +++++++++++- l4d2web/static/css/components.css | 96 +++++++++++++++ l4d2web/static/js/modal.js | 27 +++++ l4d2web/templates/base.html | 1 + l4d2web/templates/blueprint_detail.html | 22 +++- l4d2web/templates/blueprints.html | 41 ++++--- l4d2web/templates/overlay_detail.html | 64 ++++++++++ l4d2web/templates/overlays.html | 52 ++++---- l4d2web/templates/server_detail.html | 40 ++++++- l4d2web/templates/servers.html | 51 +++++--- l4d2web/tests/test_blueprints.py | 52 ++++++++ l4d2web/tests/test_overlays.py | 58 +++++++++ l4d2web/tests/test_servers.py | 153 ++++++++++++++++++++++++ 17 files changed, 733 insertions(+), 85 deletions(-) create mode 100644 l4d2web/static/js/modal.js create mode 100644 l4d2web/templates/overlay_detail.html diff --git a/l4d2web/config.py b/l4d2web/config.py index 8377f64..22f88e6 100644 --- a/l4d2web/config.py +++ b/l4d2web/config.py @@ -10,6 +10,8 @@ DEFAULT_CONFIG: dict[str, object] = { "JOB_WORKER_POLL_SECONDS": 1, "JOB_LOG_REPLAY_LIMIT": 2000, "JOB_LOG_LINE_MAX_CHARS": 4096, + "PORT_RANGE_START": 27015, + "PORT_RANGE_END": 27115, } @@ -27,4 +29,6 @@ def load_config() -> dict[str, object]: "JOB_WORKER_POLL_SECONDS": float(os.getenv("JOB_WORKER_POLL_SECONDS", "1")), "JOB_LOG_REPLAY_LIMIT": int(os.getenv("JOB_LOG_REPLAY_LIMIT", "2000")), "JOB_LOG_LINE_MAX_CHARS": int(os.getenv("JOB_LOG_LINE_MAX_CHARS", "4096")), + "PORT_RANGE_START": int(os.getenv("LEFT4ME_PORT_RANGE_START", "27015")), + "PORT_RANGE_END": int(os.getenv("LEFT4ME_PORT_RANGE_END", "27115")), } diff --git a/l4d2web/routes/blueprint_routes.py b/l4d2web/routes/blueprint_routes.py index a31bb71..11bf1c4 100644 --- a/l4d2web/routes/blueprint_routes.py +++ b/l4d2web/routes/blueprint_routes.py @@ -96,6 +96,27 @@ def update_blueprint_form(blueprint_id: int) -> Response: return redirect(f"/blueprints/{blueprint_id}") +def _delete_blueprint(db, user_id: int, blueprint_id: int) -> Response | None: + blueprint = db.scalar( + select(BlueprintModel).where( + BlueprintModel.id == blueprint_id, + BlueprintModel.user_id == user_id, + ) + ) + if blueprint is None: + return Response(status=404) + + linked_count = db.scalar( + select(func.count(Server.id)).where(Server.blueprint_id == blueprint.id) + ) or 0 + if linked_count > 0: + return Response("blueprint is in use", status=409) + + db.execute(delete(BlueprintOverlay).where(BlueprintOverlay.blueprint_id == blueprint.id)) + db.delete(blueprint) + return None + + @bp.delete("/blueprints/") @require_login def delete_blueprint(blueprint_id: int) -> Response: @@ -103,22 +124,22 @@ def delete_blueprint(blueprint_id: int) -> Response: assert user is not None with session_scope() as db: - blueprint = db.scalar( - select(BlueprintModel).where( - BlueprintModel.id == blueprint_id, - BlueprintModel.user_id == user.id, - ) - ) - if blueprint is None: - return Response(status=404) - - linked_count = db.scalar( - select(func.count(Server.id)).where(Server.blueprint_id == blueprint.id) - ) or 0 - if linked_count > 0: - return Response("blueprint is in use", status=409) - - db.execute(delete(BlueprintOverlay).where(BlueprintOverlay.blueprint_id == blueprint.id)) - db.delete(blueprint) + error = _delete_blueprint(db, user.id, blueprint_id) + if error is not None: + return error return Response(status=204) + + +@bp.post("/blueprints//delete") +@require_login +def delete_blueprint_form(blueprint_id: int) -> Response: + user = current_user() + assert user is not None + + with session_scope() as db: + error = _delete_blueprint(db, user.id, blueprint_id) + if error is not None: + return error + + return redirect("/blueprints") diff --git a/l4d2web/routes/overlay_routes.py b/l4d2web/routes/overlay_routes.py index f863904..3e0d145 100644 --- a/l4d2web/routes/overlay_routes.py +++ b/l4d2web/routes/overlay_routes.py @@ -55,7 +55,7 @@ def update_overlay(overlay_id: int) -> Response: overlay.name = name overlay.path = overlay_ref - return redirect("/overlays") + return redirect(f"/overlays/{overlay_id}") @bp.post("/overlays//delete") diff --git a/l4d2web/routes/page_routes.py b/l4d2web/routes/page_routes.py index 590074d..fb4bf5c 100644 --- a/l4d2web/routes/page_routes.py +++ b/l4d2web/routes/page_routes.py @@ -84,6 +84,11 @@ def server_detail(server_id: int): if server is None: return Response(status=404) blueprint = db.scalar(select(BlueprintModel).where(BlueprintModel.id == server.blueprint_id)) + blueprints = db.scalars( + select(BlueprintModel) + .where(BlueprintModel.user_id == user.id) + .order_by(BlueprintModel.name) + ).all() recent_job_rows = db.execute( select(Job, User, Server) .join(User, User.id == Job.user_id) @@ -97,6 +102,7 @@ def server_detail(server_id: int): "server_detail.html", server=server, blueprint=blueprint, + blueprints=blueprints, recent_job_rows=recent_job_rows, ) @@ -130,6 +136,26 @@ def overlays() -> str: return render_template("overlays.html", overlays=overlays) +@bp.get("/overlays/") +@require_login +def overlay_detail(overlay_id: int): + with session_scope() as db: + overlay = db.scalar(select(Overlay).where(Overlay.id == overlay_id)) + if overlay is None: + return Response(status=404) + using_blueprints = db.scalars( + select(BlueprintModel) + .join(BlueprintOverlay, BlueprintOverlay.blueprint_id == BlueprintModel.id) + .where(BlueprintOverlay.overlay_id == overlay.id) + .order_by(BlueprintModel.name) + ).all() + return render_template( + "overlay_detail.html", + overlay=overlay, + using_blueprints=using_blueprints, + ) + + @bp.get("/blueprints") @require_login def blueprints_page() -> str: diff --git a/l4d2web/routes/server_routes.py b/l4d2web/routes/server_routes.py index 59f9c86..82a8aeb 100644 --- a/l4d2web/routes/server_routes.py +++ b/l4d2web/routes/server_routes.py @@ -1,4 +1,4 @@ -from flask import Blueprint, Response, jsonify, redirect, request +from flask import Blueprint, Response, current_app, jsonify, redirect, request from sqlalchemy import select from sqlalchemy.exc import IntegrityError @@ -12,6 +12,36 @@ from l4d2web.services.security import validate_instance_name bp = Blueprint("server", __name__) +def _allocate_next_port(db) -> int | None: + start = int(current_app.config["PORT_RANGE_START"]) + end = int(current_app.config["PORT_RANGE_END"]) + used = set( + db.scalars( + select(Server.port).where(Server.port >= start, Server.port <= end) + ).all() + ) + for port in range(start, end + 1): + if port not in used: + return port + return None + + +def _resolve_port(payload, db) -> tuple[int | None, Response | None]: + raw = payload.get("port") + if raw is None or (isinstance(raw, str) and raw.strip() == ""): + port = _allocate_next_port(db) + if port is None: + return None, Response("no free port available", status=409) + return port, None + try: + port = int(raw) + except (TypeError, ValueError): + return None, Response("invalid port", status=400) + if not 1 <= port <= 65535: + return None, Response("invalid port", status=400) + return port, None + + @bp.post("/servers") @require_login def create_server() -> Response: @@ -35,23 +65,27 @@ def create_server() -> Response: if blueprint is None: return Response("blueprint not found", status=404) + port, error = _resolve_port(payload, db) + if error is not None: + return error + server = Server( user_id=user.id, blueprint_id=blueprint.id, name=name, - port=int(payload["port"]), + port=port, desired_state="stopped", actual_state="unknown", last_error="", ) db.add(server) - + try: db.flush() except IntegrityError: db.rollback() return Response("port already in use", status=409) - + server_id = server.id if json_response: @@ -85,6 +119,36 @@ def update_server(server_id: int) -> Response: return jsonify({"id": server_id}), 200 +@bp.post("/servers/") +@require_login +def update_server_form(server_id: int) -> Response: + user = current_user() + assert user is not None + + try: + blueprint_id = int(request.form["blueprint_id"]) + except (KeyError, TypeError, ValueError): + return Response("blueprint_id is required", status=400) + + with session_scope() as db: + server = db.scalar(select(Server).where(Server.id == server_id, Server.user_id == user.id)) + if server is None: + return Response(status=404) + + blueprint = db.scalar( + select(BlueprintModel).where( + BlueprintModel.id == blueprint_id, + BlueprintModel.user_id == user.id, + ) + ) + if blueprint is None: + return Response("blueprint not found", status=404) + + server.blueprint_id = blueprint.id + + return redirect(f"/servers/{server_id}") + + LIFECYCLE_OPERATIONS = {"initialize", "start", "stop", "delete"} @@ -106,4 +170,6 @@ def enqueue_server_operation(server_id: int, operation: str) -> Response: if operation in {"stop", "delete"}: server.desired_state = "stopped" + if operation == "delete": + return redirect("/servers") return redirect(f"/servers/{server_id}") diff --git a/l4d2web/static/css/components.css b/l4d2web/static/css/components.css index 7a0eaf8..6178eec 100644 --- a/l4d2web/static/css/components.css +++ b/l4d2web/static/css/components.css @@ -88,3 +88,99 @@ button.danger { .auth-panel { max-width: 28rem; } + +dialog.modal { + background: var(--color-surface); + color: var(--color-text); + border: var(--line); + border-radius: var(--radius-m); + padding: 0; + width: min(32rem, 90vw); + max-height: 90vh; +} + +dialog.modal::backdrop { + background: rgba(0, 0, 0, 0.45); +} + +.modal-header, +.modal-body, +.modal-footer { + padding: var(--space-l); +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: var(--line); +} + +.modal-header h2 { + margin: 0; + font-size: 1.1rem; +} + +.modal-body { + display: grid; + gap: var(--space-m); + overflow-y: auto; +} + +.modal-footer { + display: flex; + justify-content: flex-end; + gap: var(--space-m); + border-top: var(--line); +} + +.modal-close { + background: none; + color: var(--color-muted); + padding: var(--space-xs) var(--space-s); + font-size: 1.25rem; + line-height: 1; +} + +.button-secondary { + background: var(--color-surface); + color: var(--color-text); + border: var(--line); +} + +.field-hint { + color: var(--color-muted); + font-size: 0.875rem; +} + +.page-heading { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--space-m); + margin-bottom: var(--space-l); +} + +.page-heading h1, +.page-heading h2 { + margin: 0; +} + +.button-row { + display: flex; + gap: var(--space-s); + flex-wrap: wrap; +} + +.detail-grid { + display: grid; + gap: var(--space-l); +} + +.used-by-list { + list-style: none; + padding: 0; + margin: 0; + display: grid; + gap: var(--space-s); +} diff --git a/l4d2web/static/js/modal.js b/l4d2web/static/js/modal.js new file mode 100644 index 0000000..e354ce5 --- /dev/null +++ b/l4d2web/static/js/modal.js @@ -0,0 +1,27 @@ +document.addEventListener("DOMContentLoaded", () => { + document.querySelectorAll("[data-modal-open]").forEach((trigger) => { + trigger.addEventListener("click", (event) => { + const targetId = trigger.getAttribute("data-modal-open"); + const dialog = document.getElementById(targetId); + if (dialog && typeof dialog.showModal === "function") { + event.preventDefault(); + dialog.showModal(); + } + }); + }); + + document.querySelectorAll("dialog.modal").forEach((dialog) => { + dialog.querySelectorAll("[data-modal-close]").forEach((closer) => { + closer.addEventListener("click", (event) => { + event.preventDefault(); + dialog.close(); + }); + }); + + dialog.addEventListener("click", (event) => { + if (event.target === dialog) { + dialog.close(); + } + }); + }); +}); diff --git a/l4d2web/templates/base.html b/l4d2web/templates/base.html index 99f21ad..6f9e015 100644 --- a/l4d2web/templates/base.html +++ b/l4d2web/templates/base.html @@ -39,5 +39,6 @@ + diff --git a/l4d2web/templates/blueprint_detail.html b/l4d2web/templates/blueprint_detail.html index 2ea9cd8..0b82d78 100644 --- a/l4d2web/templates/blueprint_detail.html +++ b/l4d2web/templates/blueprint_detail.html @@ -4,7 +4,10 @@ {% block content %}
-

Blueprint: {{ blueprint.name }}

+
+

Blueprint: {{ blueprint.name }}

+ +
@@ -28,4 +31,21 @@
+ + + + + + {% endblock %} diff --git a/l4d2web/templates/blueprints.html b/l4d2web/templates/blueprints.html index 3bf5939..852f9e2 100644 --- a/l4d2web/templates/blueprints.html +++ b/l4d2web/templates/blueprints.html @@ -4,33 +4,42 @@ {% block content %}
-

Blueprints

-
- - - - - -
+
+

Blueprints

+ +
- + {% for blueprint in blueprints %} - {% else %} - + {% endfor %}
NameCreatedUpdatedActions
NameCreatedUpdated
{{ blueprint.name }} {{ blueprint.created_at }} {{ blueprint.updated_at }} -
- - -
-
No blueprints configured.
No blueprints configured.
+ + +
+ + + +
+
{% endblock %} diff --git a/l4d2web/templates/overlay_detail.html b/l4d2web/templates/overlay_detail.html new file mode 100644 index 0000000..439e250 --- /dev/null +++ b/l4d2web/templates/overlay_detail.html @@ -0,0 +1,64 @@ +{% extends "base.html" %} + +{% block title %}Overlay {{ overlay.name }} | left4me{% endblock %} + +{% block content %} +
+
+

Overlay: {{ overlay.name }}

+ {% if g.user.admin %} + + {% endif %} +
+ + {% if g.user.admin %} +
+ + + +
+ +
+
+ {% else %} + + + + + +
Name{{ overlay.name }}
Path{{ overlay.path }}
+ {% endif %} +
+ +
+

Used by

+ {% if using_blueprints %} + + {% else %} +

Not used by any blueprint.

+ {% endif %} +
+ +{% if g.user.admin %} + + + + + +{% endif %} +{% endblock %} diff --git a/l4d2web/templates/overlays.html b/l4d2web/templates/overlays.html index f4fe15c..83ee8ef 100644 --- a/l4d2web/templates/overlays.html +++ b/l4d2web/templates/overlays.html @@ -6,43 +6,43 @@

Overlays

+ {% if g.user.admin %} + + {% endif %}
- {% if g.user.admin %} -
- - - - -
- {% endif %} - - {% if g.user.admin %}{% endif %} + {% for overlay in overlays %} - + - {% if g.user.admin %} - - {% endif %} {% else %} - + {% endfor %}
NamePathActions
NamePath
{{ overlay.name }}{{ overlay.name }} {{ overlay.path }} -
- - - - -
-
- - -
-
No overlays configured.
No overlays configured.
+ +{% if g.user.admin %} + +
+ + + +
+
+{% endif %} {% endblock %} diff --git a/l4d2web/templates/server_detail.html b/l4d2web/templates/server_detail.html index 3e111d3..6b920a1 100644 --- a/l4d2web/templates/server_detail.html +++ b/l4d2web/templates/server_detail.html @@ -13,10 +13,7 @@ {% endfor %} -
- - -
+ @@ -32,6 +29,24 @@ +
+

Reassign blueprint

+
+ + +

Changes apply on the next server action.

+
+ +
+
+
+

Recent Jobs

@@ -49,4 +64,21 @@

Server Log


 
+ + + + + + {% endblock %} diff --git a/l4d2web/templates/servers.html b/l4d2web/templates/servers.html index 54f4c95..73f4d90 100644 --- a/l4d2web/templates/servers.html +++ b/l4d2web/templates/servers.html @@ -4,22 +4,11 @@ {% block content %}
-

Servers

- {% if blueprints %} -
- - - - - -
- {% else %} +
+

Servers

+ +
+ {% if not blueprints %}

Create a blueprint before adding servers.

{% endif %} @@ -39,4 +28,34 @@
+ +{% if blueprints %} + +
+ + + +
+
+{% endif %} {% endblock %} diff --git a/l4d2web/tests/test_blueprints.py b/l4d2web/tests/test_blueprints.py index eadbda2..5973a29 100644 --- a/l4d2web/tests/test_blueprints.py +++ b/l4d2web/tests/test_blueprints.py @@ -85,6 +85,58 @@ def test_delete_blueprint_blocked_when_in_use(linked_blueprint) -> None: assert response.status_code == 409 +def test_post_delete_blueprint_redirects_to_index(user_client) -> None: + create = user_client.post( + "/blueprints", + data={"name": "doomed", "arguments": "", "config": ""}, + headers={"X-CSRF-Token": "test-token"}, + ) + assert create.status_code == 302 + + from sqlalchemy import select + + from l4d2web.models import Blueprint as BlueprintModel + + with session_scope() as session: + blueprint_id = session.scalars(select(BlueprintModel.id)).one() + + response = user_client.post( + f"/blueprints/{blueprint_id}/delete", + headers={"X-CSRF-Token": "test-token"}, + ) + assert response.status_code == 302 + assert response.headers["Location"] == "/blueprints" + + with session_scope() as session: + assert session.scalars(select(BlueprintModel)).all() == [] + + +def test_post_delete_blueprint_blocked_when_in_use(linked_blueprint) -> None: + client, blueprint_id = linked_blueprint + response = client.post( + f"/blueprints/{blueprint_id}/delete", + headers={"X-CSRF-Token": "test-token"}, + ) + assert response.status_code == 409 + + +def test_post_delete_blueprint_returns_404_for_other_user(user_client, tmp_path) -> None: + with session_scope() as session: + other = User(username="mallory", password_digest=hash_password("secret"), admin=False) + session.add(other) + session.flush() + foreign = Blueprint(user_id=other.id, name="foreign", arguments="[]", config="[]") + session.add(foreign) + session.flush() + foreign_id = foreign.id + + response = user_client.post( + f"/blueprints/{foreign_id}/delete", + headers={"X-CSRF-Token": "test-token"}, + ) + assert response.status_code == 404 + + def test_form_update_preserves_ordered_overlays_and_multiline_fields(user_client) -> None: create = user_client.post( "/blueprints", diff --git a/l4d2web/tests/test_overlays.py b/l4d2web/tests/test_overlays.py index fa0f738..0a2e6bc 100644 --- a/l4d2web/tests/test_overlays.py +++ b/l4d2web/tests/test_overlays.py @@ -148,6 +148,64 @@ def test_update_overlay_rejects_duplicate_name(admin_client) -> None: assert response.status_code == 409 +def test_overlay_detail_page_lists_using_blueprints(admin_client) -> None: + create = admin_client.post( + "/overlays", + data={"name": "shared", "path": "shared"}, + headers={"X-CSRF-Token": "test-token"}, + ) + assert create.status_code == 302 + + with session_scope() as session: + admin = session.query(User).filter_by(username="admin").one() + bp_one = Blueprint(user_id=admin.id, name="alpha-bp", arguments="[]", config="[]") + bp_two = Blueprint(user_id=admin.id, name="beta-bp", arguments="[]", config="[]") + session.add_all([bp_one, bp_two]) + session.flush() + session.add(BlueprintOverlay(blueprint_id=bp_one.id, overlay_id=1, position=0)) + session.add(BlueprintOverlay(blueprint_id=bp_two.id, overlay_id=1, position=0)) + + response = admin_client.get("/overlays/1") + text = response.get_data(as_text=True) + + assert response.status_code == 200 + assert "alpha-bp" in text + assert "beta-bp" in text + assert "Used by" in text + + +def test_overlay_detail_page_404_when_missing(admin_client) -> None: + response = admin_client.get("/overlays/999") + assert response.status_code == 404 + + +def test_overlay_detail_hides_edit_for_non_admin(user_client_with_overlay) -> None: + response = user_client_with_overlay.get("/overlays/1") + text = response.get_data(as_text=True) + + assert response.status_code == 200 + assert "standard" in text + assert 'action="/overlays/1"' not in text + assert "delete-overlay-modal" not in text + + +def test_overlay_update_redirects_to_detail(admin_client) -> None: + create = admin_client.post( + "/overlays", + data={"name": "standard", "path": "standard"}, + headers={"X-CSRF-Token": "test-token"}, + ) + assert create.status_code == 302 + + response = admin_client.post( + "/overlays/1", + data={"name": "renamed", "path": "renamed"}, + headers={"X-CSRF-Token": "test-token"}, + ) + assert response.status_code == 302 + assert response.headers["Location"] == "/overlays/1" + + def test_delete_overlay_rejects_in_use_overlay(admin_client) -> None: create = admin_client.post( "/overlays", diff --git a/l4d2web/tests/test_servers.py b/l4d2web/tests/test_servers.py index 5ed24f3..fdb87c6 100644 --- a/l4d2web/tests/test_servers.py +++ b/l4d2web/tests/test_servers.py @@ -132,6 +132,159 @@ def test_create_server_rejects_unsafe_names(user_client_with_blueprints, bad_nam assert session.scalars(select(Server)).all() == [] +def test_create_server_with_empty_port_auto_assigns(user_client_with_blueprints) -> None: + client, data = user_client_with_blueprints + response = client.post( + "/servers", + data={"name": "alpha", "port": "", "blueprint_id": str(data["blueprint_id"])}, + headers={"X-CSRF-Token": "test-token"}, + ) + assert response.status_code == 302 + + from sqlalchemy import select + + from l4d2web.models import Server + + with session_scope() as session: + server = session.scalars(select(Server)).one() + assert server.port == 27015 + + +def test_create_server_auto_assign_skips_taken_ports(user_client_with_blueprints) -> None: + client, data = user_client_with_blueprints + first = client.post( + "/servers", + data={"name": "alpha", "port": "27015", "blueprint_id": str(data["blueprint_id"])}, + headers={"X-CSRF-Token": "test-token"}, + ) + assert first.status_code == 302 + + second = client.post( + "/servers", + data={"name": "beta", "blueprint_id": str(data["blueprint_id"])}, + headers={"X-CSRF-Token": "test-token"}, + ) + assert second.status_code == 302 + + from sqlalchemy import select + + from l4d2web.models import Server + + with session_scope() as session: + ports = sorted(session.scalars(select(Server.port)).all()) + assert ports == [27015, 27016] + + +def test_create_server_returns_409_when_port_range_exhausted(tmp_path, monkeypatch) -> None: + db_url = f"sqlite:///{tmp_path/'exhausted.db'}" + monkeypatch.setenv("DATABASE_URL", db_url) + monkeypatch.setenv("LEFT4ME_PORT_RANGE_START", "30000") + monkeypatch.setenv("LEFT4ME_PORT_RANGE_END", "30000") + 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.flush() + blueprint = Blueprint(user_id=user.id, name="bp", arguments="[]", config="[]") + session.add(blueprint) + session.flush() + user_id = user.id + blueprint_id = blueprint.id + + client = app.test_client() + with client.session_transaction() as sess: + sess["user_id"] = user_id + sess["csrf_token"] = "test-token" + + first = client.post( + "/servers", + data={"name": "alpha", "blueprint_id": str(blueprint_id)}, + headers={"X-CSRF-Token": "test-token"}, + ) + assert first.status_code == 302 + + second = client.post( + "/servers", + data={"name": "beta", "blueprint_id": str(blueprint_id)}, + headers={"X-CSRF-Token": "test-token"}, + ) + assert second.status_code == 409 + + +def test_update_server_form_reassigns_blueprint(user_client_with_blueprints) -> None: + client, data = user_client_with_blueprints + create = client.post( + "/servers", + data={"name": "alpha", "port": "27015", "blueprint_id": str(data["blueprint_id"])}, + headers={"X-CSRF-Token": "test-token"}, + ) + assert create.status_code == 302 + server_id = int(create.headers["Location"].rsplit("/", 1)[-1]) + + response = client.post( + f"/servers/{server_id}", + data={"blueprint_id": str(data["other_blueprint_id"])}, + headers={"X-CSRF-Token": "test-token"}, + ) + assert response.status_code == 302 + assert response.headers["Location"] == f"/servers/{server_id}" + + from sqlalchemy import select + + from l4d2web.models import Server + + with session_scope() as session: + server = session.scalars(select(Server)).one() + assert server.blueprint_id == data["other_blueprint_id"] + + +def test_update_server_form_rejects_foreign_blueprint(user_client_with_blueprints, tmp_path) -> None: + client, data = user_client_with_blueprints + create = client.post( + "/servers", + data={"name": "alpha", "port": "27015", "blueprint_id": str(data["blueprint_id"])}, + headers={"X-CSRF-Token": "test-token"}, + ) + server_id = int(create.headers["Location"].rsplit("/", 1)[-1]) + + with session_scope() as session: + other = User(username="bob", password_digest=hash_password("secret"), admin=False) + session.add(other) + session.flush() + foreign = Blueprint(user_id=other.id, name="foreign", arguments="[]", config="[]") + session.add(foreign) + session.flush() + foreign_id = foreign.id + + response = client.post( + f"/servers/{server_id}", + data={"blueprint_id": str(foreign_id)}, + headers={"X-CSRF-Token": "test-token"}, + ) + assert response.status_code == 404 + + +def test_delete_operation_redirects_to_index(user_client_with_blueprints) -> None: + client, data = user_client_with_blueprints + create = client.post( + "/servers", + data=json.dumps({"name": "alpha", "port": 27015, "blueprint_id": data["blueprint_id"]}), + content_type="application/json", + headers={"X-CSRF-Token": "test-token"}, + ) + server_id = create.get_json()["id"] + + response = client.post( + f"/servers/{server_id}/delete", + headers={"X-CSRF-Token": "test-token"}, + ) + + assert response.status_code == 302 + assert response.headers["Location"] == "/servers" + + def test_lifecycle_form_creates_queued_job(user_client_with_blueprints) -> None: client, data = user_client_with_blueprints create_response = client.post(