left4me/l4d2web/tests/test_url_addressable_modals.py
mwiegand 6b0231970c
feat(files): add GET /overlays/<id>/files/new + extend editor template
Step 5/12 of docs/superpowers/plans/2026-05-17-files-overlay-rewrite.md.

Adds a server-rendered editor page for creating a new file in a target
folder. Renders the same overlay_file_editor.html as the edit route,
but with is_new=True so the template:
  * Hides the Delete button
  * Hides the Download link (no file to download yet)
  * Changes the Save button label to "Create"
  * Emits data-at-folder on the textarea for the JS save handler to
    compose path = at_folder + "/" + filename
  * Updates the title block (browser tab + heading) to "New file" /
    "<at_folder>/…new file"

The existing edit route now passes is_new=False and at_folder=""
explicitly so both call sites are explicit about the contract.

Path validation: ?at may be empty (overlay root) or a relative folder
path. safe_resolve_for_listing rejects traversal attempts (400). A
missing or non-directory at returns 404. Reused the helper to keep
the safety story identical to the listing / edit routes.

Phase B Step 5 is server-side only — no JS changes here. Step 6
migrates openEditorTextNew in editor.js to use this route via
window.modals.openRouted().

Tests added (tests/test_url_addressable_modals.py):
  * test_new_route_renders_with_empty_content
  * test_new_route_renders_with_target_folder_attribute
  * test_new_route_renders_create_button_not_save
  * test_new_route_400s_for_invalid_at_path
  * test_new_route_404s_for_non_files_overlay

pytest: 573 → 578 passed, 1 skipped, 3 deselected.

Verified live: curl /overlays/2/files/new returns markup with
data-at-folder="", "UTF-8 · 0 bytes" byte count, save button label
"Create", and no Delete / Download buttons.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 16:03:01 +02:00

211 lines
8.9 KiB
Python

from datetime import UTC, datetime
from flask import render_template_string
from l4d2web.app import create_app
from l4d2web.auth import hash_password
from l4d2web.db import init_db, session_scope
from l4d2web.models import Overlay, User
def _make_app(tmp_path, monkeypatch, db_name: str):
db_url = f"sqlite:///{tmp_path/db_name}"
monkeypatch.setenv("DATABASE_URL", db_url)
return create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"})
def test_base_layout_is_modal_partial_when_hx_modal_header_set(tmp_path, monkeypatch):
app = _make_app(tmp_path, monkeypatch, "layout-modal.db")
with app.test_request_context("/", headers={"HX-Modal": "1"}):
assert render_template_string("{{ base_layout }}") == "_modal_partial.html"
def test_base_layout_is_base_html_for_normal_request(tmp_path, monkeypatch):
app = _make_app(tmp_path, monkeypatch, "layout-default.db")
with app.test_request_context("/"):
assert render_template_string("{{ base_layout }}") == "base.html"
def test_base_layout_does_not_react_to_plain_hx_request_header(tmp_path, monkeypatch):
# HTMX sets HX-Request on every request including the build-status poll;
# only HX-Modal should switch the layout.
app = _make_app(tmp_path, monkeypatch, "layout-hxreq.db")
with app.test_request_context("/", headers={"HX-Request": "true"}):
assert render_template_string("{{ base_layout }}") == "base.html"
def _auth_client_with_files_overlay(tmp_path, monkeypatch, db_name: str):
db_url = f"sqlite:///{tmp_path/db_name}"
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)
session.flush()
overlay = Overlay(name="cfgs", path="", type="files", user_id=user.id)
session.add(overlay)
session.flush()
overlay.path = str(overlay.id)
overlay_root = tmp_path / "overlays" / str(overlay.id)
overlay_root.mkdir(parents=True)
(overlay_root / "server.cfg").write_text("hostname \"left4me\"\nrcon_password \"hunter2\"\n", encoding="utf-8")
user_id = user.id
overlay_id = overlay.id
client = app.test_client()
with client.session_transaction() as sess:
sess["user_id"] = user_id
sess["pw_changed_at"] = datetime.now(UTC).isoformat()
return client, overlay_id
def test_edit_route_renders_full_page_without_modal_header(tmp_path, monkeypatch):
client, overlay_id = _auth_client_with_files_overlay(tmp_path, monkeypatch, "edit-full.db")
response = client.get(f"/overlays/{overlay_id}/files/edit?path=server.cfg")
text = response.get_data(as_text=True)
assert response.status_code == 200
assert "<!doctype html>" in text.lower() # full base.html rendered
assert 'href="/dashboard"' in text # nav present
assert 'class="files-editor-content"' in text
assert 'rcon_password' in text # content pre-filled
def test_edit_route_renders_fragment_with_modal_header(tmp_path, monkeypatch):
client, overlay_id = _auth_client_with_files_overlay(tmp_path, monkeypatch, "edit-fragment.db")
response = client.get(
f"/overlays/{overlay_id}/files/edit?path=server.cfg",
headers={"HX-Modal": "1"},
)
text = response.get_data(as_text=True)
assert response.status_code == 200
assert "<html" not in text # layoutless
assert 'class="primary-nav"' not in text
assert 'class="files-editor-content"' in text
assert "hostname" in text # content pre-filled
def test_edit_route_404s_for_missing_file(tmp_path, monkeypatch):
client, overlay_id = _auth_client_with_files_overlay(tmp_path, monkeypatch, "edit-404.db")
response = client.get(f"/overlays/{overlay_id}/files/edit?path=nonexistent.cfg")
assert response.status_code == 404
def test_edit_route_415s_for_non_editable_file(tmp_path, monkeypatch):
client, overlay_id = _auth_client_with_files_overlay(tmp_path, monkeypatch, "edit-415.db")
# Forge a non-editable file by writing binary garbage.
from pathlib import Path
overlay_root = tmp_path / "overlays" / str(overlay_id)
Path(overlay_root).joinpath("blob.bin").write_bytes(b"\x00\x01\x02\x03" * 1024)
response = client.get(f"/overlays/{overlay_id}/files/edit?path=blob.bin")
assert response.status_code == 415
def test_edit_route_400s_for_path_traversal(tmp_path, monkeypatch):
client, overlay_id = _auth_client_with_files_overlay(tmp_path, monkeypatch, "edit-400.db")
response = client.get(f"/overlays/{overlay_id}/files/edit?path=../../etc/passwd")
assert response.status_code == 400
def test_edit_route_404s_for_non_files_overlay(tmp_path, monkeypatch):
db_url = f"sqlite:///{tmp_path/'edit-script-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 s:
user = User(username="alice", password_digest=hash_password("secret"), admin=False)
s.add(user)
s.flush()
overlay = Overlay(name="scripted", path="", type="script", user_id=user.id)
s.add(overlay)
s.flush()
overlay.path = str(overlay.id)
(tmp_path / "overlays" / str(overlay.id)).mkdir(parents=True)
user_id = user.id
overlay_id = overlay.id
client = app.test_client()
with client.session_transaction() as sess:
sess["user_id"] = user_id
sess["pw_changed_at"] = datetime.now(UTC).isoformat()
response = client.get(f"/overlays/{overlay_id}/files/edit?path=anything.cfg")
assert response.status_code == 404
# ---------------------------------------------------------------- /files/new
def test_new_route_renders_with_empty_content(tmp_path, monkeypatch):
client, overlay_id = _auth_client_with_files_overlay(tmp_path, monkeypatch, "new-empty.db")
response = client.get(f"/overlays/{overlay_id}/files/new")
text = response.get_data(as_text=True)
assert response.status_code == 200
# Empty content + empty filename + zero byte count.
assert 'class="files-editor-content"' in text
assert 'value=""' in text # filename input
assert "UTF-8 · 0 bytes" in text
def test_new_route_renders_with_target_folder_attribute(tmp_path, monkeypatch):
client, overlay_id = _auth_client_with_files_overlay(tmp_path, monkeypatch, "new-atfolder.db")
# Create a subfolder to target.
(tmp_path / "overlays" / str(overlay_id) / "cfg").mkdir()
response = client.get(f"/overlays/{overlay_id}/files/new?at=cfg")
text = response.get_data(as_text=True)
assert response.status_code == 200
assert 'data-at-folder="cfg"' in text
# Title text reflects the target folder.
assert "cfg/…new file" in text
def test_new_route_renders_create_button_not_save(tmp_path, monkeypatch):
client, overlay_id = _auth_client_with_files_overlay(tmp_path, monkeypatch, "new-button.db")
response = client.get(f"/overlays/{overlay_id}/files/new")
text = response.get_data(as_text=True)
assert response.status_code == 200
# Save button shows "Create" instead of "Save"; Delete + Download
# buttons are absent on a new file.
assert 'class="files-editor-save">Create</button>' in text
assert 'class="danger-outline files-editor-delete"' not in text
assert 'class="button-secondary files-editor-download"' not in text
def test_new_route_400s_for_invalid_at_path(tmp_path, monkeypatch):
client, overlay_id = _auth_client_with_files_overlay(tmp_path, monkeypatch, "new-400.db")
response = client.get(f"/overlays/{overlay_id}/files/new?at=../../etc")
assert response.status_code == 400
def test_new_route_404s_for_non_files_overlay(tmp_path, monkeypatch):
db_url = f"sqlite:///{tmp_path/'new-script-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 s:
user = User(username="alice", password_digest=hash_password("secret"), admin=False)
s.add(user)
s.flush()
overlay = Overlay(name="scripted", path="", type="script", user_id=user.id)
s.add(overlay)
s.flush()
overlay.path = str(overlay.id)
(tmp_path / "overlays" / str(overlay.id)).mkdir(parents=True)
user_id = user.id
overlay_id = overlay.id
client = app.test_client()
with client.session_transaction() as sess:
sess["user_id"] = user_id
sess["pw_changed_at"] = datetime.now(UTC).isoformat()
response = client.get(f"/overlays/{overlay_id}/files/new")
assert response.status_code == 404