The plan template (and verbatim implementation) listed five of the six editor asset URLs in the structural test — vendor/prism.css was omitted. If a future change drops the Prism stylesheet from the partial, syntax tokens lose their color rules silently and the test still passes. Add the missing assertion and update the plan to match. Addresses Minor #1 from the Task 6 code review. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
465 lines
16 KiB
Python
465 lines
16 KiB
Python
import json
|
|
from datetime import UTC, datetime
|
|
|
|
import pytest
|
|
from sqlalchemy import select
|
|
|
|
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, Overlay, Server, User
|
|
|
|
|
|
@pytest.fixture
|
|
def user_client(tmp_path, monkeypatch):
|
|
db_url = f"sqlite:///{tmp_path/'blueprint.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.flush()
|
|
user_id = user.id
|
|
session.add_all(
|
|
[
|
|
Overlay(name="o1", path="/opt/l4d2/overlays/o1"),
|
|
Overlay(name="o2", path="/opt/l4d2/overlays/o2"),
|
|
]
|
|
)
|
|
|
|
client = app.test_client()
|
|
with client.session_transaction() as sess:
|
|
sess["user_id"] = user_id
|
|
sess["pw_changed_at"] = datetime.now(UTC).isoformat()
|
|
sess["csrf_token"] = "test-token"
|
|
return client
|
|
|
|
|
|
@pytest.fixture
|
|
def linked_blueprint(tmp_path, monkeypatch):
|
|
db_url = f"sqlite:///{tmp_path/'linked.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="bob", password_digest=hash_password("secret"), admin=False)
|
|
session.add(user)
|
|
session.flush()
|
|
|
|
blueprint = Blueprint(user_id=user.id, name="linked", arguments="[]", config="[]")
|
|
session.add(blueprint)
|
|
session.flush()
|
|
|
|
session.add(Server(user_id=user.id, blueprint_id=blueprint.id, name="alpha", port=27015))
|
|
blueprint_id = blueprint.id
|
|
user_id = user.id
|
|
|
|
client = app.test_client()
|
|
with client.session_transaction() as sess:
|
|
sess["user_id"] = user_id
|
|
sess["pw_changed_at"] = datetime.now(UTC).isoformat()
|
|
sess["csrf_token"] = "test-token"
|
|
|
|
return client, blueprint_id
|
|
|
|
|
|
def test_user_can_create_private_blueprint(user_client) -> None:
|
|
payload = {
|
|
"name": "comp",
|
|
"arguments": ["-tickrate 100"],
|
|
"config": ["sv_consistency 1"],
|
|
"overlay_ids": [1, 2],
|
|
}
|
|
|
|
response = user_client.post(
|
|
"/blueprints",
|
|
data=json.dumps(payload),
|
|
content_type="application/json",
|
|
headers={"X-CSRF-Token": "test-token"},
|
|
)
|
|
assert response.status_code == 201
|
|
|
|
|
|
def _create_other_users_private_overlay() -> int:
|
|
with session_scope() as session:
|
|
other = User(username="mallory", password_digest=hash_password("secret"), admin=False)
|
|
session.add(other)
|
|
session.flush()
|
|
overlay = Overlay(
|
|
name="mallory-private",
|
|
path="mallory-private",
|
|
type="workshop",
|
|
user_id=other.id,
|
|
)
|
|
session.add(overlay)
|
|
session.flush()
|
|
return overlay.id
|
|
|
|
|
|
def test_user_cannot_create_blueprint_with_other_users_private_overlay(user_client) -> None:
|
|
foreign_overlay_id = _create_other_users_private_overlay()
|
|
payload = {
|
|
"name": "bad",
|
|
"arguments": [],
|
|
"config": [],
|
|
"overlay_ids": [foreign_overlay_id],
|
|
}
|
|
|
|
response = user_client.post(
|
|
"/blueprints",
|
|
data=json.dumps(payload),
|
|
content_type="application/json",
|
|
headers={"X-CSRF-Token": "test-token"},
|
|
)
|
|
|
|
assert response.status_code == 403
|
|
|
|
|
|
def test_user_cannot_update_blueprint_with_other_users_private_overlay(user_client) -> None:
|
|
foreign_overlay_id = _create_other_users_private_overlay()
|
|
create = user_client.post(
|
|
"/blueprints",
|
|
data={"name": "comp", "arguments": "", "config": "", "overlay_ids": ["1"]},
|
|
headers={"X-CSRF-Token": "test-token"},
|
|
)
|
|
assert create.status_code == 302
|
|
|
|
response = user_client.post(
|
|
"/blueprints/1",
|
|
data={
|
|
"name": "edited",
|
|
"arguments": "",
|
|
"config": "",
|
|
"overlay_ids": [str(foreign_overlay_id)],
|
|
},
|
|
headers={"X-CSRF-Token": "test-token"},
|
|
)
|
|
|
|
assert response.status_code == 403
|
|
|
|
|
|
def test_user_can_create_blueprint_with_system_overlay(user_client) -> None:
|
|
payload = {
|
|
"name": "system-ok",
|
|
"arguments": [],
|
|
"config": [],
|
|
"overlay_ids": [1],
|
|
}
|
|
|
|
response = user_client.post(
|
|
"/blueprints",
|
|
data=json.dumps(payload),
|
|
content_type="application/json",
|
|
headers={"X-CSRF-Token": "test-token"},
|
|
)
|
|
|
|
assert response.status_code == 201
|
|
blueprint_id = response.get_json()["id"]
|
|
with session_scope() as session:
|
|
link = session.query(BlueprintOverlay).filter_by(blueprint_id=blueprint_id, overlay_id=1).one()
|
|
assert link.position == 0
|
|
|
|
|
|
def test_delete_blueprint_blocked_when_in_use(linked_blueprint) -> None:
|
|
client, blueprint_id = linked_blueprint
|
|
response = client.delete(f"/blueprints/{blueprint_id}", headers={"X-CSRF-Token": "test-token"})
|
|
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",
|
|
data={
|
|
"name": "comp",
|
|
"arguments": "-tickrate 100",
|
|
"config": "sv_consistency 1",
|
|
"overlay_ids": ["2", "1"],
|
|
"overlay_position_1": "2",
|
|
"overlay_position_2": "1",
|
|
},
|
|
headers={"X-CSRF-Token": "test-token"},
|
|
)
|
|
assert create.status_code == 302
|
|
|
|
update = user_client.post(
|
|
"/blueprints/1",
|
|
data={
|
|
"name": "edited",
|
|
"arguments": "-tickrate 100\n+sv_lan 0",
|
|
"config": "sv_consistency 1\nsv_allow_lobby_connect_only 0",
|
|
"overlay_ids": ["1", "2"],
|
|
"overlay_position_1": "1",
|
|
"overlay_position_2": "2",
|
|
},
|
|
headers={"X-CSRF-Token": "test-token"},
|
|
)
|
|
|
|
assert update.status_code == 302
|
|
assert update.headers["Location"] == "/blueprints/1"
|
|
|
|
|
|
def test_blueprint_detail_renders_editor_assets(user_client) -> None:
|
|
with session_scope() as session:
|
|
user = session.scalar(select(User).where(User.username == "alice"))
|
|
blueprint = Blueprint(user_id=user.id, name="bp", arguments="[]", config="[]")
|
|
session.add(blueprint)
|
|
session.flush()
|
|
blueprint_id = blueprint.id
|
|
|
|
response = user_client.get(f"/blueprints/{blueprint_id}")
|
|
assert response.status_code == 200
|
|
body = response.get_data(as_text=True)
|
|
# Editor opts the textarea in via a data-attribute.
|
|
assert 'data-editor-language="srccfg"' in body
|
|
# All editor assets are referenced.
|
|
assert "static/vendor/prism.js" in body
|
|
assert "static/vendor/prism.css" in body
|
|
assert "static/vendor/codejar.js" in body
|
|
assert "static/js/srccfg-grammar.js" in body
|
|
assert "static/js/editor.js" in body
|
|
assert "static/css/editor.css" in body
|
|
# Scripts are nonce'd (CSP regression guard).
|
|
assert 'nonce="' in body
|
|
|
|
|
|
def test_blueprint_config_form_post_still_round_trips(user_client) -> None:
|
|
# The editor is a visual layer; the form POST contract must be
|
|
# unaffected. This guards against accidentally renaming `config` or
|
|
# dropping it from form serialization.
|
|
with session_scope() as session:
|
|
user = session.scalar(select(User).where(User.username == "alice"))
|
|
blueprint = Blueprint(user_id=user.id, name="bp", arguments="[]", config="[]")
|
|
session.add(blueprint)
|
|
session.flush()
|
|
blueprint_id = blueprint.id
|
|
|
|
update = user_client.post(
|
|
f"/blueprints/{blueprint_id}",
|
|
data={
|
|
"name": "bp",
|
|
"arguments": "",
|
|
"config": "sv_cheats 1\nmp_gamemode coop",
|
|
"overlay_ids": [],
|
|
},
|
|
headers={"X-CSRF-Token": "test-token"},
|
|
)
|
|
assert update.status_code == 302
|
|
|
|
with session_scope() as session:
|
|
bp = session.get(Blueprint, blueprint_id)
|
|
assert json.loads(bp.config) == ["sv_cheats 1", "mp_gamemode coop"]
|
|
|
|
|
|
def test_blueprint_detail_renders_picker_list_and_select(user_client) -> None:
|
|
with session_scope() as session:
|
|
session.add(Overlay(name="o3", path="/opt/l4d2/overlays/o3"))
|
|
user = session.scalar(select(User).where(User.username == "alice"))
|
|
blueprint = Blueprint(user_id=user.id, name="bp", arguments="[]", config="[]")
|
|
session.add(blueprint)
|
|
session.flush()
|
|
session.add(BlueprintOverlay(blueprint_id=blueprint.id, overlay_id=1, position=0))
|
|
blueprint_id = blueprint.id
|
|
|
|
response = user_client.get(f"/blueprints/{blueprint_id}")
|
|
assert response.status_code == 200
|
|
body = response.get_data(as_text=True)
|
|
assert "data-overlay-list" in body
|
|
assert "data-overlay-add" in body
|
|
assert body.count('data-overlay-id="1"') == 1
|
|
assert 'data-overlay-id="2"' not in body
|
|
assert '<option value="2"' in body
|
|
assert '<option value="3"' in body
|
|
assert '<option value="1"' not in body
|
|
|
|
|
|
def test_form_save_persists_expose_server_cfg_set(user_client) -> None:
|
|
create = user_client.post(
|
|
"/blueprints",
|
|
data={
|
|
"name": "comp",
|
|
"arguments": "",
|
|
"config": "",
|
|
"overlay_ids": ["1", "2"],
|
|
"overlay_position_1": "1",
|
|
"overlay_position_2": "2",
|
|
"expose_server_cfg_ids": ["2"],
|
|
},
|
|
headers={"X-CSRF-Token": "test-token"},
|
|
)
|
|
assert create.status_code == 302
|
|
|
|
with session_scope() as session:
|
|
rows = session.execute(
|
|
select(BlueprintOverlay.overlay_id, BlueprintOverlay.expose_server_cfg)
|
|
.order_by(BlueprintOverlay.position)
|
|
).all()
|
|
assert sorted(rows) == [(1, False), (2, True)]
|
|
|
|
update = user_client.post(
|
|
"/blueprints/1",
|
|
data={
|
|
"name": "comp",
|
|
"arguments": "",
|
|
"config": "",
|
|
"overlay_ids": ["1", "2"],
|
|
"overlay_position_1": "1",
|
|
"overlay_position_2": "2",
|
|
"expose_server_cfg_ids": ["1"],
|
|
},
|
|
headers={"X-CSRF-Token": "test-token"},
|
|
)
|
|
assert update.status_code == 302
|
|
|
|
with session_scope() as session:
|
|
rows = session.execute(
|
|
select(BlueprintOverlay.overlay_id, BlueprintOverlay.expose_server_cfg)
|
|
.order_by(BlueprintOverlay.position)
|
|
).all()
|
|
assert sorted(rows) == [(1, True), (2, False)]
|
|
|
|
|
|
def test_blueprint_detail_renders_expose_checkbox_compact_label(user_client) -> None:
|
|
with session_scope() as session:
|
|
user = session.scalar(select(User).where(User.username == "alice"))
|
|
blueprint = Blueprint(user_id=user.id, name="bp", arguments="[]", config="[]")
|
|
session.add(blueprint)
|
|
session.flush()
|
|
session.add(BlueprintOverlay(
|
|
blueprint_id=blueprint.id, overlay_id=1, position=0, expose_server_cfg=True,
|
|
))
|
|
session.add(BlueprintOverlay(
|
|
blueprint_id=blueprint.id, overlay_id=2, position=1, expose_server_cfg=False,
|
|
))
|
|
blueprint_id = blueprint.id
|
|
|
|
response = user_client.get(f"/blueprints/{blueprint_id}")
|
|
assert response.status_code == 200
|
|
body = response.get_data(as_text=True)
|
|
assert 'name="expose_server_cfg_ids" value="1"' in body
|
|
assert 'name="expose_server_cfg_ids" value="2"' in body
|
|
assert "exec <code>server.cfg</code>" in body
|
|
# The technical alias must NOT appear in the row label anymore.
|
|
assert "server_overlay_1" not in body
|
|
assert "server_overlay_2" not in body
|
|
assert "checked" in body # row 1 is exposed
|
|
|
|
|
|
def test_blueprint_detail_shows_config_preview_for_exposed_overlays(user_client) -> None:
|
|
with session_scope() as session:
|
|
user = session.scalar(select(User).where(User.username == "alice"))
|
|
comp = Overlay(name="comp", path="/opt/l4d2/overlays/comp", user_id=user.id)
|
|
plain = Overlay(name="plain", path="/opt/l4d2/overlays/plain", user_id=user.id)
|
|
session.add_all([comp, plain])
|
|
session.flush()
|
|
blueprint = Blueprint(user_id=user.id, name="bp", arguments="[]", config="[]")
|
|
session.add(blueprint)
|
|
session.flush()
|
|
session.add(BlueprintOverlay(
|
|
blueprint_id=blueprint.id, overlay_id=comp.id, position=0, expose_server_cfg=True,
|
|
))
|
|
session.add(BlueprintOverlay(
|
|
blueprint_id=blueprint.id, overlay_id=plain.id, position=1, expose_server_cfg=False,
|
|
))
|
|
blueprint_id = blueprint.id
|
|
|
|
response = user_client.get(f"/blueprints/{blueprint_id}")
|
|
assert response.status_code == 200
|
|
body = response.get_data(as_text=True)
|
|
assert 'class="config-preview"' in body
|
|
assert "exec comp.cfg" in body
|
|
assert "exec plain.cfg" not in body
|
|
|
|
|
|
def test_blueprint_detail_omits_preview_when_nothing_exposed(user_client) -> None:
|
|
with session_scope() as session:
|
|
user = session.scalar(select(User).where(User.username == "alice"))
|
|
blueprint = Blueprint(user_id=user.id, name="bp", arguments="[]", config="[]")
|
|
session.add(blueprint)
|
|
session.flush()
|
|
session.add(BlueprintOverlay(
|
|
blueprint_id=blueprint.id, overlay_id=1, position=0, expose_server_cfg=False,
|
|
))
|
|
blueprint_id = blueprint.id
|
|
|
|
response = user_client.get(f"/blueprints/{blueprint_id}")
|
|
assert response.status_code == 200
|
|
body = response.get_data(as_text=True)
|
|
assert 'class="config-preview"' not in body
|
|
|
|
|
|
def test_blueprint_detail_picker_emits_hidden_overlay_ids_in_order(user_client) -> None:
|
|
with session_scope() as session:
|
|
user = session.scalar(select(User).where(User.username == "alice"))
|
|
blueprint = Blueprint(user_id=user.id, name="bp", arguments="[]", config="[]")
|
|
session.add(blueprint)
|
|
session.flush()
|
|
session.add_all(
|
|
[
|
|
BlueprintOverlay(blueprint_id=blueprint.id, overlay_id=2, position=0),
|
|
BlueprintOverlay(blueprint_id=blueprint.id, overlay_id=1, position=1),
|
|
]
|
|
)
|
|
blueprint_id = blueprint.id
|
|
|
|
response = user_client.get(f"/blueprints/{blueprint_id}")
|
|
assert response.status_code == 200
|
|
body = response.get_data(as_text=True)
|
|
first = body.find('name="overlay_ids" value="2"')
|
|
second = body.find('name="overlay_ids" value="1"')
|
|
assert first != -1 and second != -1
|
|
assert first < second
|