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 "" 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 "Replace" 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_overlay_detail_no_longer_renders_legacy_editor_dialog(tmp_path, monkeypatch): """Phase B Step 9: the inline is gone. All editor flows route through the URL-addressable modal instead.""" client, overlay_id = _auth_client_with_files_overlay(tmp_path, monkeypatch, "no-legacy-dialog.db") response = client.get(f"/overlays/{overlay_id}") text = response.get_data(as_text=True) assert response.status_code == 200 assert 'id="files-editor-modal"' not in text # The other inline dialogs (new-folder, conflict, delete-confirm) are # still inline — only the editor dialog moved out. assert 'id="files-new-folder-modal"' in text 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' 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