left4me/l4d2web/tests/test_overlays.py
mwiegand ffc4cdbd7d
refactor(l4d2-web): remove legacy external overlay type
The workshop + managed-global overlay surface fully covers the
admin-SFTP flow that 'external' was a placeholder for. Drop the type
from the model defaults, builder registry, routes, template, and
tests, and add migration 0004 that deletes any leftover external
rows along with their blueprint and job references.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:31:04 +02:00

430 lines
15 KiB
Python

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 Blueprint, BlueprintOverlay, GlobalOverlaySource, Overlay, User
from l4d2web.services.security import validate_overlay_ref
@pytest.fixture
def admin_client(tmp_path, monkeypatch):
db_url = f"sqlite:///{tmp_path/'admin_overlay.db'}"
monkeypatch.setenv("DATABASE_URL", db_url)
monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path))
app = create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"})
init_db()
with session_scope() as session:
admin = User(username="admin", password_digest=hash_password("secret"), admin=True)
session.add(admin)
session.flush()
admin_id = admin.id
client = app.test_client()
with client.session_transaction() as sess:
sess["user_id"] = admin_id
sess["csrf_token"] = "test-token"
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)
monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path))
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)
# System overlay (managed-global, no user_id), pre-existing.
session.add(
Overlay(name="standard", path="standard", type="l4d2center_maps", user_id=None)
)
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
# Non-admin users can create workshop overlays, so the Create button shows.
assert "Create overlay" in text
def test_non_admin_can_view_managed_global_system_overlay(user_client_with_overlay) -> None:
_create_managed_global_overlay()
response = user_client_with_overlay.get("/overlays")
text = response.get_data(as_text=True)
assert response.status_code == 200
assert "l4d2center-maps" 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 "Create overlay" in text
assert 'action="/overlays"' in text
def test_admin_can_create_workshop_overlay_via_route(admin_client) -> None:
response = admin_client.post(
"/overlays",
data={"name": "standard", "type": "workshop"},
headers={"X-CSRF-Token": "test-token"},
)
assert response.status_code == 302
# Redirect to the new detail page now that paths are auto-generated.
assert response.headers["Location"].startswith("/overlays/")
def test_admin_cannot_create_managed_global_overlay_type(admin_client) -> None:
response = admin_client.post(
"/overlays",
data={"name": "managed", "type": "l4d2center_maps"},
headers={"X-CSRF-Token": "test-token"},
)
assert response.status_code == 400
assert "unknown overlay type" in response.get_data(as_text=True)
@pytest.mark.parametrize("overlay_ref", [" standard", "standard ", "a//b", "a/", "./a", "a/.", "."])
def test_overlay_ref_rejects_unsafe_components(overlay_ref: str) -> None:
with pytest.raises(ValueError):
validate_overlay_ref(overlay_ref)
def test_user_can_create_workshop_overlay(user_client_with_overlay) -> None:
response = user_client_with_overlay.post(
"/overlays",
data={"name": "my-maps", "type": "workshop"},
headers={"X-CSRF-Token": "test-token"},
)
assert response.status_code == 302
with session_scope() as session:
overlay = session.query(Overlay).filter_by(name="my-maps").one()
assert overlay.type == "workshop"
assert overlay.user_id is not None
assert overlay.path == str(overlay.id)
def test_workshop_overlay_directory_is_created_on_disk(user_client_with_overlay, tmp_path) -> None:
response = user_client_with_overlay.post(
"/overlays",
data={"name": "my-maps", "type": "workshop"},
headers={"X-CSRF-Token": "test-token"},
)
assert response.status_code == 302
with session_scope() as session:
overlay = session.query(Overlay).filter_by(name="my-maps").one()
overlay_id = overlay.id
assert (tmp_path / "overlays" / str(overlay_id)).is_dir()
def test_two_users_can_have_workshop_overlay_with_same_name(tmp_path, monkeypatch) -> None:
# Set up a fresh app with two users.
db_url = f"sqlite:///{tmp_path/'shared.db'}"
monkeypatch.setenv("DATABASE_URL", db_url)
monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path))
app = create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"})
init_db()
with session_scope() as session:
for username in ("alice", "bob"):
session.add(User(username=username, password_digest=hash_password("x"), admin=False))
session.flush()
alice_id, bob_id = (
session.query(User).filter_by(username="alice").one().id,
session.query(User).filter_by(username="bob").one().id,
)
def client_for(uid):
c = app.test_client()
with c.session_transaction() as sess:
sess["user_id"] = uid
sess["csrf_token"] = "test-token"
return c
for uid in (alice_id, bob_id):
r = client_for(uid).post(
"/overlays",
data={"name": "my-maps", "type": "workshop"},
headers={"X-CSRF-Token": "test-token"},
)
assert r.status_code == 302
with session_scope() as session:
rows = session.query(Overlay).filter_by(name="my-maps").all()
assert {r.user_id for r in rows} == {alice_id, bob_id}
def test_admin_can_update_and_delete_overlay(admin_client) -> None:
create = admin_client.post(
"/overlays",
data={"name": "standard", "type": "workshop"},
headers={"X-CSRF-Token": "test-token"},
)
assert create.status_code == 302
with session_scope() as session:
overlay_id = session.query(Overlay).filter_by(name="standard").one().id
update = admin_client.post(
f"/overlays/{overlay_id}",
data={"name": "edited"},
headers={"X-CSRF-Token": "test-token"},
)
assert update.status_code == 302
delete = admin_client.post(
f"/overlays/{overlay_id}/delete",
headers={"X-CSRF-Token": "test-token"},
)
assert delete.status_code == 302
def _create_managed_global_overlay() -> int:
with session_scope() as session:
overlay = Overlay(
name="l4d2center-maps",
path="managed-l4d2center",
type="l4d2center_maps",
user_id=None,
)
session.add(overlay)
session.flush()
session.add(
GlobalOverlaySource(
overlay_id=overlay.id,
source_key="l4d2center-maps",
source_type="l4d2center_csv",
source_url="https://l4d2center.com/maps/servers/index.csv",
)
)
return overlay.id
def test_admin_cannot_update_managed_global_overlay(admin_client) -> None:
overlay_id = _create_managed_global_overlay()
response = admin_client.post(
f"/overlays/{overlay_id}",
data={"name": "renamed"},
headers={"X-CSRF-Token": "test-token"},
)
assert response.status_code == 403
def test_admin_cannot_delete_managed_global_overlay(admin_client) -> None:
overlay_id = _create_managed_global_overlay()
response = admin_client.post(
f"/overlays/{overlay_id}/delete",
headers={"X-CSRF-Token": "test-token"},
)
assert response.status_code == 403
def test_admin_overlay_detail_hides_edit_for_managed_global_overlay(admin_client) -> None:
overlay_id = _create_managed_global_overlay()
response = admin_client.get(f"/overlays/{overlay_id}")
text = response.get_data(as_text=True)
assert response.status_code == 200
assert f'action="/overlays/{overlay_id}"' not in text
assert "delete-overlay-modal" not in text
def test_update_overlay_rejects_duplicate_name(admin_client) -> None:
ids: list[int] = []
for name in ("standard", "competitive"):
response = admin_client.post(
"/overlays",
data={"name": name, "type": "workshop"},
headers={"X-CSRF-Token": "test-token"},
)
assert response.status_code == 302
with session_scope() as session:
ids = [
session.query(Overlay).filter_by(name="standard").one().id,
session.query(Overlay).filter_by(name="competitive").one().id,
]
response = admin_client.post(
f"/overlays/{ids[1]}",
data={"name": "standard"},
headers={"X-CSRF-Token": "test-token"},
)
assert response.status_code == 409
def test_overlay_detail_page_lists_using_blueprints(admin_client) -> None:
create = admin_client.post(
"/overlays",
data={"name": "shared", "type": "workshop"},
headers={"X-CSRF-Token": "test-token"},
)
assert create.status_code == 302
with session_scope() as session:
overlay_id = session.query(Overlay).filter_by(name="shared").one().id
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=overlay_id, position=0))
session.add(BlueprintOverlay(blueprint_id=bp_two.id, overlay_id=overlay_id, position=0))
response = admin_client.get(f"/overlays/{overlay_id}")
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_non_admin_overlay_detail_only_lists_own_using_blueprints(user_client_with_overlay) -> None:
overlay_id = _create_managed_global_overlay()
with session_scope() as session:
alice = session.query(User).filter_by(username="alice").one()
other = User(username="mallory", password_digest=hash_password("secret"), admin=False)
session.add(other)
session.flush()
own_bp = Blueprint(user_id=alice.id, name="own-bp", arguments="[]", config="[]")
other_bp = Blueprint(user_id=other.id, name="other-private-bp", arguments="[]", config="[]")
session.add_all([own_bp, other_bp])
session.flush()
session.add(BlueprintOverlay(blueprint_id=own_bp.id, overlay_id=overlay_id, position=0))
session.add(BlueprintOverlay(blueprint_id=other_bp.id, overlay_id=overlay_id, position=0))
response = user_client_with_overlay.get(f"/overlays/{overlay_id}")
text = response.get_data(as_text=True)
assert response.status_code == 200
assert "own-bp" in text
assert "other-private-bp" not in text
def test_blueprint_edit_lists_system_and_owned_overlays_only(user_client_with_overlay) -> None:
system_overlay_id = _create_managed_global_overlay()
with session_scope() as session:
alice = session.query(User).filter_by(username="alice").one()
other = User(username="mallory", password_digest=hash_password("secret"), admin=False)
session.add(other)
session.flush()
foreign_overlay = Overlay(
name="other-private-workshop",
path="other-private-workshop",
type="workshop",
user_id=other.id,
)
blueprint = Blueprint(user_id=alice.id, name="alice-bp", arguments="[]", config="[]")
session.add_all([foreign_overlay, blueprint])
session.flush()
blueprint_id = blueprint.id
response = user_client_with_overlay.get(f"/blueprints/{blueprint_id}")
text = response.get_data(as_text=True)
assert response.status_code == 200
assert "l4d2center-maps" in text
assert f'value="{system_overlay_id}"' in text
assert "other-private-workshop" not 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_managed_global(user_client_with_overlay) -> None:
# The seeded "standard" managed-global overlay (id=1, user_id=NULL) is read-only for non-admins.
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_managed_global_overlay_detail_shows_source_url(admin_client) -> None:
overlay_id = _create_managed_global_overlay()
response = admin_client.get(f"/overlays/{overlay_id}")
text = response.get_data(as_text=True)
assert response.status_code == 200
assert "https://l4d2center.com/maps/servers/index.csv" in text
def test_overlay_update_redirects_to_detail(admin_client) -> None:
create = admin_client.post(
"/overlays",
data={"name": "standard", "type": "workshop"},
headers={"X-CSRF-Token": "test-token"},
)
assert create.status_code == 302
with session_scope() as session:
overlay_id = session.query(Overlay).filter_by(name="standard").one().id
response = admin_client.post(
f"/overlays/{overlay_id}",
data={"name": "renamed"},
headers={"X-CSRF-Token": "test-token"},
)
assert response.status_code == 302
assert response.headers["Location"] == f"/overlays/{overlay_id}"
def test_delete_overlay_rejects_in_use_overlay(admin_client) -> None:
create = admin_client.post(
"/overlays",
data={"name": "standard", "type": "workshop"},
headers={"X-CSRF-Token": "test-token"},
)
assert create.status_code == 302
with session_scope() as session:
overlay_id = session.query(Overlay).filter_by(name="standard").one().id
with session_scope() as session:
user = session.query(User).filter_by(username="admin").one()
blueprint = Blueprint(user_id=user.id, name="bp", arguments="[]", config="[]")
session.add(blueprint)
session.flush()
session.add(BlueprintOverlay(blueprint_id=blueprint.id, overlay_id=overlay_id, position=0))
response = admin_client.post(
f"/overlays/{overlay_id}/delete",
headers={"X-CSRF-Token": "test-token"},
)
assert response.status_code == 409
def test_admin_can_enqueue_refresh_global_overlays(admin_client):
response = admin_client.post("/admin/global-overlays/refresh", headers={"X-CSRF-Token": "test-token"})
assert response.status_code == 302
assert response.headers["Location"] == "/admin/jobs"