left4me/l4d2web/tests/test_url_addressable_modals.py
mwiegand 294b5b8489
feat(files): add binary-file support to edit route + template
Step 7/12 of docs/superpowers/plans/2026-05-17-files-overlay-rewrite.md.

overlay_file_edit_page now renders the binary-replace UI instead of
returning 415 for non-editable files. Two cases bridge to the same
binary template: the up-front is_editable() check and the late-binding
UnicodeDecodeError on read (8-KiB sniff said yes but the tail had
non-UTF-8 bytes). Both paths route through a small _render_binary_editor
helper that fills in:
  * is_binary=True
  * content="" (no inline editor)
  * byte_count=target.stat().st_size
  * mime_type from mimetypes.guess_type, falling back to
    application/octet-stream

Template (overlay_file_editor.html) gains an is_binary branch in the
modal body: hides .files-editor-text (CM6 textarea + language
dropdown), renders .files-editor-binary instead with file-info note,
replace-zone (drop area + browse button + hidden file input), and
the per-state idle/queued labels. The Save button reads "Replace"
in binary mode and starts disabled — Step 8 enables it via JS when
a replacement file is queued.

Delete and Download stay visible in binary mode (binary files can be
deleted and downloaded the same way text files can).

The /files/new route gets is_binary=False + mime_type="" passed
explicitly so the template's binary branch never fires there.

Server-side only — no JS changes in Step 7. Step 8 wires up the
binary-replace handlers (replace-zone drag, browse/clear clicks,
Replace button → POST /files/replace).

Tests:
  * Removed test_edit_route_415s_for_non_editable_file — the route
    no longer returns 415 for non-editable files; the binary template
    is the new contract.
  * Added test_edit_route_renders_binary_template_for_non_editable
    (asserts 200 + is_binary markup + Save button label + disabled
    state + .files-editor-text absent + byte count rendered).
  * Added test_binary_template_has_replace_zone (asserts the replace-
    zone markup: drop zone, idle/queued labels, browse button, hidden
    file input, and that Download stays visible).

pytest: 578 → 579 passed, 1 skipped, 3 deselected. The pre-existing
"still 404 / still 400" cases the plan asks for are covered by the
existing test_edit_route_404s_for_missing_file and
test_edit_route_400s_for_path_traversal tests — left alone rather
than duplicated.

Verified live: GET /overlays/2/files/edit?path=test.png (an actual
binary file in the demo overlay) returns the binary template; DOM
inspection confirms .files-editor-binary present with data-rel-path,
"Replace" save button starts disabled, Download href points at the
file's download endpoint, MIME type and byte count match, .files-
editor-text is absent.

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

245 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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_renders_binary_template_for_non_editable(tmp_path, monkeypatch):
"""Phase B Step 7: non-editable files no longer return 415; they render
the editor template with is_binary=True so the modal shows the
replace-zone UI instead of an error."""
client, overlay_id = _auth_client_with_files_overlay(tmp_path, monkeypatch, "edit-binary.db")
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")
text = response.get_data(as_text=True)
assert response.status_code == 200
assert 'class="files-editor-binary"' in text
# Save button is labeled "Replace" and starts disabled (no file queued).
assert ">Replace</button>" in text
assert 'class="files-editor-save" disabled' in text
# Byte count + MIME type rendered.
assert "4096 bytes" in text # 4 bytes × 1024
# The CM6 text editor isn't rendered in binary mode.
assert 'class="files-editor-text"' not in text
def test_binary_template_has_replace_zone(tmp_path, monkeypatch):
"""Spot-check the binary panel's replace-zone markup: drop zone,
idle and queued labels, browse button, and a hidden file input."""
client, overlay_id = _auth_client_with_files_overlay(tmp_path, monkeypatch, "edit-binary-zone.db")
from pathlib import Path
overlay_root = tmp_path / "overlays" / str(overlay_id)
Path(overlay_root).joinpath("blob.bin").write_bytes(b"\xff" * 100)
response = client.get(f"/overlays/{overlay_id}/files/edit?path=blob.bin")
text = response.get_data(as_text=True)
assert response.status_code == 200
assert 'class="files-editor-replace-zone"' in text
assert 'class="files-editor-replace-idle"' in text
assert 'class="files-editor-replace-queued"' in text
assert 'class="link-button files-editor-replace-browse"' in text
assert 'class="files-editor-replace-input" hidden' in text
# Download link stays visible in binary mode (it's how the user
# gets the existing file out).
assert "files-editor-download" in text
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