Two related fixes to the overlay file manager, found in the same session. 1. Nested-file save was silently moving the file (fix). The Filename input is pre-filled with the full relative path (intentional: phone-friendly move-via-edit when drag-and-drop isn't an option). The save handler compared it against the basename only, so every save of a file in a subfolder built newPath = parent + "/" + editedFilename and re-routed the file into a doubly-nested path (e.g. cfg/tick60.cfg -> cfg/cfg/tick60.cfg). All three sites in editor.js now compare against relPath. Two e2e tests pin both directions: save-without- edit leaves the file untouched, edit-the-path performs the intended move. 2. Recursive directory delete + visible 409 errors (feature). GET /files/delete_preview enumerates what a recursive delete would remove (files + dirs + symlinks, capped at 500 entries, followlinks=False). POST /files/delete accepts an optional recursive=1 form param that uses shutil.rmtree (default still refuses non-empty dirs, preserving the historical safety guard). The delete confirm modal now opens an inline preview for non-empty folders, with a scrollable list and a count summary. The error handler falls back to r.rawText so the server's text bodies (like "directory is not empty") finally surface to the user instead of "HTTP 409". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1250 lines
44 KiB
Python
1250 lines
44 KiB
Python
"""HTTP-level tests for the overlay 'Files' tree-fragment + download routes."""
|
|
from __future__ import annotations
|
|
from datetime import UTC, datetime
|
|
|
|
import io
|
|
import os
|
|
from pathlib import Path
|
|
|
|
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, Overlay, Server, User
|
|
|
|
|
|
@pytest.fixture
|
|
def app(tmp_path, monkeypatch):
|
|
db_url = f"sqlite:///{tmp_path/'files-routes.db'}"
|
|
monkeypatch.setenv("DATABASE_URL", db_url)
|
|
monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path))
|
|
flask_app = create_app(
|
|
{"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"}
|
|
)
|
|
init_db()
|
|
return flask_app
|
|
|
|
|
|
@pytest.fixture
|
|
def left4me_root(tmp_path) -> Path:
|
|
return tmp_path
|
|
|
|
|
|
def _client_for(app, user_id: int):
|
|
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
|
|
|
|
|
|
def _make_user(*, username: str = "alice", admin: bool = False) -> int:
|
|
with session_scope() as s:
|
|
user = User(
|
|
username=username, password_digest=hash_password("x"), admin=admin
|
|
)
|
|
s.add(user)
|
|
s.flush()
|
|
return user.id
|
|
|
|
|
|
def _make_overlay(left4me_root: Path, *, user_id: int | None, name: str) -> int:
|
|
"""Create an Overlay row + the matching `LEFT4ME_ROOT/overlays/{id}/`
|
|
directory, mirroring what `overlay_creation.create_overlay_directory`
|
|
would do in production."""
|
|
with session_scope() as s:
|
|
overlay = Overlay(name=name, path="", type="script", user_id=user_id)
|
|
s.add(overlay)
|
|
s.flush()
|
|
overlay.path = str(overlay.id)
|
|
overlay_id = overlay.id
|
|
(left4me_root / "overlays" / str(overlay_id)).mkdir(parents=True)
|
|
return overlay_id
|
|
|
|
|
|
def _make_files_overlay(left4me_root: Path, *, user_id: int | None, name: str) -> int:
|
|
with session_scope() as s:
|
|
overlay = Overlay(name=name, path="", type="files", user_id=user_id)
|
|
s.add(overlay)
|
|
s.flush()
|
|
overlay.path = str(overlay.id)
|
|
overlay_id = overlay.id
|
|
(left4me_root / "overlays" / str(overlay_id)).mkdir(parents=True)
|
|
return overlay_id
|
|
|
|
|
|
def test_non_admin_cannot_mkdir_in_system_overlay(app, left4me_root: Path) -> None:
|
|
"""System overlays (user_id IS NULL) are readable by everyone but only
|
|
writable by admins. A regular user attempting to mutate gets 403."""
|
|
user_id = _make_user(username="bob", admin=False)
|
|
overlay_id = _make_files_overlay(left4me_root, user_id=None, name="system")
|
|
|
|
client = _client_for(app, user_id)
|
|
response = client.post(
|
|
f"/overlays/{overlay_id}/files/mkdir",
|
|
json={"path": "newdir"},
|
|
headers={"X-CSRF-Token": "test-token"},
|
|
)
|
|
assert response.status_code == 403
|
|
|
|
|
|
def test_non_admin_can_still_read_system_overlay(app, left4me_root: Path) -> None:
|
|
user_id = _make_user(username="bob", admin=False)
|
|
overlay_id = _make_files_overlay(left4me_root, user_id=None, name="system")
|
|
(left4me_root / "overlays" / str(overlay_id) / "config.cfg").write_text("ok")
|
|
|
|
client = _client_for(app, user_id)
|
|
response = client.get(f"/overlays/{overlay_id}/files")
|
|
assert response.status_code == 200
|
|
assert b"config.cfg" in response.data
|
|
|
|
|
|
def test_admin_can_mkdir_in_system_overlay(app, left4me_root: Path) -> None:
|
|
admin_id = _make_user(username="admin", admin=True)
|
|
overlay_id = _make_files_overlay(left4me_root, user_id=None, name="system")
|
|
|
|
client = _client_for(app, admin_id)
|
|
response = client.post(
|
|
f"/overlays/{overlay_id}/files/mkdir",
|
|
json={"path": "newdir"},
|
|
headers={"X-CSRF-Token": "test-token"},
|
|
)
|
|
assert response.status_code in (200, 201, 204)
|
|
assert (left4me_root / "overlays" / str(overlay_id) / "newdir").is_dir()
|
|
|
|
|
|
def test_owner_can_mkdir_in_own_overlay(app, left4me_root: Path) -> None:
|
|
user_id = _make_user(username="bob", admin=False)
|
|
overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="mine")
|
|
|
|
client = _client_for(app, user_id)
|
|
response = client.post(
|
|
f"/overlays/{overlay_id}/files/mkdir",
|
|
json={"path": "newdir"},
|
|
headers={"X-CSRF-Token": "test-token"},
|
|
)
|
|
assert response.status_code in (200, 201, 204)
|
|
|
|
|
|
def test_files_fragment_lists_root_directory(app, left4me_root: Path) -> None:
|
|
user_id = _make_user()
|
|
overlay_id = _make_overlay(left4me_root, user_id=user_id, name="my")
|
|
overlay_dir = left4me_root / "overlays" / str(overlay_id)
|
|
(overlay_dir / "left4dead2").mkdir()
|
|
(overlay_dir / "readme.txt").write_text("hi")
|
|
|
|
client = _client_for(app, user_id)
|
|
response = client.get(f"/overlays/{overlay_id}/files")
|
|
|
|
assert response.status_code == 200
|
|
text = response.get_data(as_text=True)
|
|
assert "left4dead2" in text
|
|
assert "readme.txt" in text
|
|
|
|
|
|
def test_files_fragment_lists_subdirectory(app, left4me_root: Path) -> None:
|
|
user_id = _make_user()
|
|
overlay_id = _make_overlay(left4me_root, user_id=user_id, name="my")
|
|
overlay_dir = left4me_root / "overlays" / str(overlay_id)
|
|
addons = overlay_dir / "left4dead2" / "addons"
|
|
addons.mkdir(parents=True)
|
|
(addons / "deathcraft.vpk").write_text("vpk")
|
|
|
|
client = _client_for(app, user_id)
|
|
response = client.get(
|
|
f"/overlays/{overlay_id}/files?path=left4dead2/addons"
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
text = response.get_data(as_text=True)
|
|
assert "deathcraft.vpk" in text
|
|
# Fragment, no full document.
|
|
assert "<html" not in text
|
|
|
|
|
|
def test_files_fragment_returns_400_on_dotdot(app, left4me_root: Path) -> None:
|
|
user_id = _make_user()
|
|
overlay_id = _make_overlay(left4me_root, user_id=user_id, name="my")
|
|
|
|
client = _client_for(app, user_id)
|
|
response = client.get(f"/overlays/{overlay_id}/files?path=../../etc")
|
|
|
|
assert response.status_code == 400
|
|
|
|
|
|
def test_files_fragment_returns_400_on_absolute_path(app, left4me_root: Path) -> None:
|
|
user_id = _make_user()
|
|
overlay_id = _make_overlay(left4me_root, user_id=user_id, name="my")
|
|
|
|
client = _client_for(app, user_id)
|
|
response = client.get(f"/overlays/{overlay_id}/files?path=/etc/passwd")
|
|
|
|
assert response.status_code == 400
|
|
|
|
|
|
def test_files_fragment_returns_404_for_unknown_overlay(app) -> None:
|
|
user_id = _make_user()
|
|
client = _client_for(app, user_id)
|
|
response = client.get("/overlays/9999/files")
|
|
|
|
assert response.status_code == 404
|
|
|
|
|
|
def test_files_fragment_returns_404_for_missing_subdir(app, left4me_root: Path) -> None:
|
|
user_id = _make_user()
|
|
overlay_id = _make_overlay(left4me_root, user_id=user_id, name="my")
|
|
|
|
client = _client_for(app, user_id)
|
|
response = client.get(f"/overlays/{overlay_id}/files?path=ghost")
|
|
|
|
assert response.status_code == 404
|
|
|
|
|
|
def test_files_fragment_returns_403_for_other_users_overlay(
|
|
app, left4me_root: Path
|
|
) -> None:
|
|
owner_id = _make_user(username="owner")
|
|
other_id = _make_user(username="other")
|
|
overlay_id = _make_overlay(left4me_root, user_id=owner_id, name="private")
|
|
|
|
client = _client_for(app, other_id)
|
|
response = client.get(f"/overlays/{overlay_id}/files")
|
|
|
|
assert response.status_code == 403
|
|
|
|
|
|
def test_admin_can_view_files_for_other_users_overlay(
|
|
app, left4me_root: Path
|
|
) -> None:
|
|
owner_id = _make_user(username="owner")
|
|
admin_id = _make_user(username="admin", admin=True)
|
|
overlay_id = _make_overlay(left4me_root, user_id=owner_id, name="private")
|
|
overlay_dir = left4me_root / "overlays" / str(overlay_id)
|
|
(overlay_dir / "secret.cfg").write_text("k=v")
|
|
|
|
client = _client_for(app, admin_id)
|
|
response = client.get(f"/overlays/{overlay_id}/files")
|
|
|
|
assert response.status_code == 200
|
|
assert "secret.cfg" in response.get_data(as_text=True)
|
|
|
|
|
|
def test_download_streams_regular_file(app, left4me_root: Path) -> None:
|
|
user_id = _make_user()
|
|
overlay_id = _make_overlay(left4me_root, user_id=user_id, name="my")
|
|
overlay_dir = left4me_root / "overlays" / str(overlay_id)
|
|
cfg = overlay_dir / "cfg" / "server.cfg"
|
|
cfg.parent.mkdir()
|
|
cfg.write_bytes(b"hostname test")
|
|
|
|
client = _client_for(app, user_id)
|
|
response = client.get(
|
|
f"/overlays/{overlay_id}/files/download?path=cfg/server.cfg"
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert response.headers["Content-Disposition"].startswith("attachment")
|
|
assert "filename=server.cfg" in response.headers["Content-Disposition"]
|
|
assert response.get_data() == b"hostname test"
|
|
|
|
|
|
def test_download_follows_workshop_cache_symlink(
|
|
app, left4me_root: Path
|
|
) -> None:
|
|
user_id = _make_user()
|
|
overlay_id = _make_overlay(left4me_root, user_id=user_id, name="my")
|
|
overlay_dir = left4me_root / "overlays" / str(overlay_id)
|
|
|
|
cache_file = left4me_root / "workshop_cache" / "12345.vpk"
|
|
cache_file.parent.mkdir(parents=True)
|
|
cache_file.write_bytes(b"vpk-content")
|
|
|
|
addons = overlay_dir / "left4dead2" / "addons"
|
|
addons.mkdir(parents=True)
|
|
(addons / "deathcraft.vpk").symlink_to(cache_file)
|
|
|
|
client = _client_for(app, user_id)
|
|
response = client.get(
|
|
f"/overlays/{overlay_id}/files/download?path=left4dead2/addons/deathcraft.vpk"
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert response.get_data() == b"vpk-content"
|
|
|
|
|
|
def test_download_rejects_symlink_outside_left4me_root(
|
|
app, left4me_root: Path, tmp_path_factory
|
|
) -> None:
|
|
user_id = _make_user()
|
|
overlay_id = _make_overlay(left4me_root, user_id=user_id, name="my")
|
|
overlay_dir = left4me_root / "overlays" / str(overlay_id)
|
|
|
|
outside = tmp_path_factory.mktemp("outside") / "secret.txt"
|
|
outside.write_text("nope")
|
|
(overlay_dir / "evil").symlink_to(outside)
|
|
|
|
client = _client_for(app, user_id)
|
|
response = client.get(f"/overlays/{overlay_id}/files/download?path=evil")
|
|
|
|
assert response.status_code == 400
|
|
|
|
|
|
def test_download_rejects_directory_target(app, left4me_root: Path) -> None:
|
|
user_id = _make_user()
|
|
overlay_id = _make_overlay(left4me_root, user_id=user_id, name="my")
|
|
overlay_dir = left4me_root / "overlays" / str(overlay_id)
|
|
(overlay_dir / "left4dead2").mkdir()
|
|
|
|
client = _client_for(app, user_id)
|
|
response = client.get(
|
|
f"/overlays/{overlay_id}/files/download?path=left4dead2"
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
|
|
|
|
def test_download_returns_404_for_missing_file(app, left4me_root: Path) -> None:
|
|
user_id = _make_user()
|
|
overlay_id = _make_overlay(left4me_root, user_id=user_id, name="my")
|
|
|
|
client = _client_for(app, user_id)
|
|
response = client.get(
|
|
f"/overlays/{overlay_id}/files/download?path=ghost.txt"
|
|
)
|
|
|
|
assert response.status_code == 404
|
|
|
|
|
|
def test_download_returns_403_for_other_users_overlay(
|
|
app, left4me_root: Path
|
|
) -> None:
|
|
owner_id = _make_user(username="owner")
|
|
other_id = _make_user(username="other")
|
|
overlay_id = _make_overlay(left4me_root, user_id=owner_id, name="private")
|
|
overlay_dir = left4me_root / "overlays" / str(overlay_id)
|
|
(overlay_dir / "secret.cfg").write_text("nope")
|
|
|
|
client = _client_for(app, other_id)
|
|
response = client.get(
|
|
f"/overlays/{overlay_id}/files/download?path=secret.cfg"
|
|
)
|
|
|
|
assert response.status_code == 403
|
|
|
|
|
|
def test_files_fragment_truncates_at_cap(app, left4me_root: Path, monkeypatch) -> None:
|
|
"""The cap default is 500 — exercise it via the public route by lowering
|
|
it for this test through the helper module."""
|
|
from l4d2web.services import overlay_files as overlay_files_module
|
|
|
|
monkeypatch.setattr(overlay_files_module, "DEFAULT_MAX_ENTRIES", 5)
|
|
|
|
user_id = _make_user()
|
|
overlay_id = _make_overlay(left4me_root, user_id=user_id, name="my")
|
|
overlay_dir = left4me_root / "overlays" / str(overlay_id)
|
|
for i in range(8):
|
|
(overlay_dir / f"f{i}.txt").write_text("x")
|
|
|
|
client = _client_for(app, user_id)
|
|
response = client.get(f"/overlays/{overlay_id}/files")
|
|
text = response.get_data(as_text=True)
|
|
|
|
assert response.status_code == 200
|
|
assert "+ 3 more" in text
|
|
|
|
|
|
def test_overlay_detail_renders_files_section_with_tree(
|
|
app, left4me_root: Path
|
|
) -> None:
|
|
user_id = _make_user()
|
|
overlay_id = _make_overlay(left4me_root, user_id=user_id, name="my")
|
|
overlay_dir = left4me_root / "overlays" / str(overlay_id)
|
|
(overlay_dir / "left4dead2").mkdir()
|
|
(overlay_dir / "readme.txt").write_text("hi")
|
|
|
|
client = _client_for(app, user_id)
|
|
response = client.get(f"/overlays/{overlay_id}")
|
|
text = response.get_data(as_text=True)
|
|
|
|
assert response.status_code == 200
|
|
assert "Files" in text
|
|
assert "left4dead2" in text
|
|
assert "readme.txt" in text
|
|
|
|
|
|
def test_overlay_detail_shows_empty_state_when_overlay_dir_missing(
|
|
app, left4me_root: Path
|
|
) -> None:
|
|
user_id = _make_user()
|
|
overlay_id = _make_overlay(left4me_root, user_id=user_id, name="my")
|
|
# Wipe the directory created by _make_overlay so the on-disk dir is gone.
|
|
overlay_dir = left4me_root / "overlays" / str(overlay_id)
|
|
overlay_dir.rmdir()
|
|
|
|
client = _client_for(app, user_id)
|
|
response = client.get(f"/overlays/{overlay_id}")
|
|
text = response.get_data(as_text=True)
|
|
|
|
assert response.status_code == 200
|
|
assert "No files yet" in text
|
|
|
|
|
|
def test_overlay_detail_shows_empty_state_when_overlay_dir_is_empty(
|
|
app, left4me_root: Path
|
|
) -> None:
|
|
"""A built overlay whose directory has been wiped (or seeded but never
|
|
built) should also fall back to the empty-state message — not render an
|
|
invisible empty <ul>."""
|
|
user_id = _make_user()
|
|
overlay_id = _make_overlay(left4me_root, user_id=user_id, name="my")
|
|
# _make_overlay leaves the directory in place but empty.
|
|
|
|
client = _client_for(app, user_id)
|
|
response = client.get(f"/overlays/{overlay_id}")
|
|
text = response.get_data(as_text=True)
|
|
|
|
assert response.status_code == 200
|
|
assert "No files yet" in text
|
|
|
|
|
|
def test_overlay_detail_files_section_present_for_workshop_overlays(
|
|
app, left4me_root: Path
|
|
) -> None:
|
|
user_id = _make_user()
|
|
# Create a workshop overlay manually since _make_overlay defaults to script.
|
|
with session_scope() as s:
|
|
overlay = Overlay(name="ws", path="", type="workshop", user_id=user_id)
|
|
s.add(overlay)
|
|
s.flush()
|
|
overlay.path = str(overlay.id)
|
|
overlay_id = overlay.id
|
|
(left4me_root / "overlays" / str(overlay_id)).mkdir(parents=True)
|
|
|
|
client = _client_for(app, user_id)
|
|
response = client.get(f"/overlays/{overlay_id}")
|
|
text = response.get_data(as_text=True)
|
|
|
|
assert response.status_code == 200
|
|
# Section heading present even when the overlay dir is empty.
|
|
assert "Files" in text
|
|
|
|
|
|
def test_files_fragment_renders_broken_symlink_without_download_link(
|
|
app, left4me_root: Path
|
|
) -> None:
|
|
user_id = _make_user()
|
|
overlay_id = _make_overlay(left4me_root, user_id=user_id, name="my")
|
|
overlay_dir = left4me_root / "overlays" / str(overlay_id)
|
|
(overlay_dir / "missing.vpk").symlink_to(overlay_dir / "does-not-exist.vpk")
|
|
|
|
client = _client_for(app, user_id)
|
|
response = client.get(f"/overlays/{overlay_id}/files")
|
|
text = response.get_data(as_text=True)
|
|
|
|
assert response.status_code == 200
|
|
assert "missing.vpk" in text
|
|
assert "broken" in text
|
|
# No download link for broken symlinks.
|
|
assert (
|
|
f'href="/overlays/{overlay_id}/files/download?path=missing.vpk"' not in text
|
|
)
|
|
|
|
|
|
def _make_server(left4me_root: Path, *, user_id: int, port: int = 27015) -> int:
|
|
with session_scope() as s:
|
|
bp = Blueprint(user_id=user_id, name="bp", arguments="[]", config="[]")
|
|
s.add(bp)
|
|
s.flush()
|
|
server = Server(user_id=user_id, blueprint_id=bp.id, name="alpha", port=port)
|
|
s.add(server)
|
|
s.flush()
|
|
return server.id
|
|
|
|
|
|
def test_server_files_fragment_lists_root(app, left4me_root: Path) -> None:
|
|
user_id = _make_user()
|
|
server_id = _make_server(left4me_root, user_id=user_id)
|
|
merged = left4me_root / "runtime" / str(server_id) / "merged"
|
|
(merged / "left4dead2").mkdir(parents=True)
|
|
(merged / "srcds_run").write_text("#!/bin/sh\n")
|
|
|
|
client = _client_for(app, user_id)
|
|
response = client.get(f"/servers/{server_id}/files")
|
|
|
|
assert response.status_code == 200
|
|
text = response.get_data(as_text=True)
|
|
assert "left4dead2" in text
|
|
assert "srcds_run" in text
|
|
# Download links are now rendered (mirrors overlay tree).
|
|
assert f"/servers/{server_id}/files/download?path=srcds_run" in text
|
|
|
|
|
|
def test_server_download_streams_regular_file(app, left4me_root: Path) -> None:
|
|
user_id = _make_user()
|
|
server_id = _make_server(left4me_root, user_id=user_id, port=27050)
|
|
merged = left4me_root / "runtime" / str(server_id) / "merged"
|
|
cfg = merged / "left4dead2" / "cfg" / "server.cfg"
|
|
cfg.parent.mkdir(parents=True)
|
|
cfg.write_bytes(b"hostname zonemod")
|
|
|
|
client = _client_for(app, user_id)
|
|
response = client.get(
|
|
f"/servers/{server_id}/files/download?path=left4dead2/cfg/server.cfg"
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert response.headers["Content-Disposition"].startswith("attachment")
|
|
assert "filename=server.cfg" in response.headers["Content-Disposition"]
|
|
assert response.get_data() == b"hostname zonemod"
|
|
|
|
|
|
def test_server_download_rejects_symlink_outside_left4me_root(
|
|
app, left4me_root: Path, tmp_path_factory
|
|
) -> None:
|
|
user_id = _make_user()
|
|
server_id = _make_server(left4me_root, user_id=user_id, port=27060)
|
|
merged = left4me_root / "runtime" / str(server_id) / "merged"
|
|
merged.mkdir(parents=True)
|
|
|
|
outside = tmp_path_factory.mktemp("outside-server") / "secret.txt"
|
|
outside.write_text("nope")
|
|
(merged / "evil").symlink_to(outside)
|
|
|
|
client = _client_for(app, user_id)
|
|
response = client.get(f"/servers/{server_id}/files/download?path=evil")
|
|
|
|
assert response.status_code == 400
|
|
|
|
|
|
def test_server_files_fragment_returns_404_when_merged_missing(app, left4me_root: Path) -> None:
|
|
user_id = _make_user()
|
|
server_id = _make_server(left4me_root, user_id=user_id, port=27020)
|
|
|
|
client = _client_for(app, user_id)
|
|
response = client.get(f"/servers/{server_id}/files")
|
|
|
|
assert response.status_code == 404
|
|
|
|
|
|
def test_server_files_fragment_returns_404_for_unknown_server(app) -> None:
|
|
user_id = _make_user()
|
|
client = _client_for(app, user_id)
|
|
response = client.get("/servers/9999/files")
|
|
|
|
assert response.status_code == 404
|
|
|
|
|
|
def test_server_files_fragment_returns_400_on_dotdot(app, left4me_root: Path) -> None:
|
|
user_id = _make_user()
|
|
server_id = _make_server(left4me_root, user_id=user_id, port=27030)
|
|
(left4me_root / "runtime" / str(server_id) / "merged").mkdir(parents=True)
|
|
|
|
client = _client_for(app, user_id)
|
|
response = client.get(f"/servers/{server_id}/files?path=../../etc")
|
|
|
|
assert response.status_code == 400
|
|
|
|
|
|
def test_server_detail_renders_files_section(app, left4me_root: Path) -> None:
|
|
user_id = _make_user()
|
|
server_id = _make_server(left4me_root, user_id=user_id, port=27040)
|
|
merged = left4me_root / "runtime" / str(server_id) / "merged"
|
|
(merged / "left4dead2").mkdir(parents=True)
|
|
|
|
client = _client_for(app, user_id)
|
|
response = client.get(f"/servers/{server_id}")
|
|
text = response.get_data(as_text=True)
|
|
|
|
assert response.status_code == 200
|
|
assert ">Files<" in text
|
|
assert "left4dead2" in text
|
|
|
|
|
|
# ============================================================================
|
|
# Files-overlay mutating endpoints
|
|
# ============================================================================
|
|
|
|
|
|
def _make_files_overlay(left4me_root: Path, *, user_id: int | None, name: str) -> int:
|
|
with session_scope() as s:
|
|
overlay = Overlay(name=name, path="", type="files", user_id=user_id, last_build_status="ok")
|
|
s.add(overlay)
|
|
s.flush()
|
|
overlay.path = str(overlay.id)
|
|
overlay_id = overlay.id
|
|
(left4me_root / "overlays" / str(overlay_id)).mkdir(parents=True)
|
|
return overlay_id
|
|
|
|
|
|
def _csrf_headers():
|
|
return {"X-CSRF-Token": "test-token"}
|
|
|
|
|
|
# ---- /save ---------------------------------------------------------------
|
|
|
|
|
|
def test_save_creates_new_file(app, left4me_root: Path) -> None:
|
|
user_id = _make_user()
|
|
overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files")
|
|
overlay_dir = left4me_root / "overlays" / str(overlay_id)
|
|
|
|
client = _client_for(app, user_id)
|
|
r = client.post(
|
|
f"/overlays/{overlay_id}/files/save",
|
|
json={"path": "left4dead2/cfg/admins.txt", "content": "STEAM_1:0:1\n"},
|
|
headers=_csrf_headers(),
|
|
)
|
|
|
|
assert r.status_code == 200
|
|
assert (overlay_dir / "left4dead2" / "cfg" / "admins.txt").read_text() == "STEAM_1:0:1\n"
|
|
|
|
|
|
def test_save_overwrites_existing_file(app, left4me_root: Path) -> None:
|
|
user_id = _make_user()
|
|
overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files")
|
|
overlay_dir = left4me_root / "overlays" / str(overlay_id)
|
|
(overlay_dir / "motd.txt").write_text("old")
|
|
|
|
client = _client_for(app, user_id)
|
|
r = client.post(
|
|
f"/overlays/{overlay_id}/files/save",
|
|
json={"path": "motd.txt", "content": "new"},
|
|
headers=_csrf_headers(),
|
|
)
|
|
|
|
assert r.status_code == 200
|
|
assert (overlay_dir / "motd.txt").read_text() == "new"
|
|
|
|
|
|
def test_save_with_new_path_renames_atomically(app, left4me_root: Path) -> None:
|
|
user_id = _make_user()
|
|
overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files")
|
|
overlay_dir = left4me_root / "overlays" / str(overlay_id)
|
|
(overlay_dir / "motd.txt").write_text("welcome")
|
|
|
|
client = _client_for(app, user_id)
|
|
r = client.post(
|
|
f"/overlays/{overlay_id}/files/save",
|
|
json={"path": "motd.txt", "new_path": "motd_de.txt", "content": "willkommen"},
|
|
headers=_csrf_headers(),
|
|
)
|
|
|
|
assert r.status_code == 200
|
|
assert not (overlay_dir / "motd.txt").exists()
|
|
assert (overlay_dir / "motd_de.txt").read_text() == "willkommen"
|
|
|
|
|
|
def test_save_with_new_path_moves_across_folders(app, left4me_root: Path) -> None:
|
|
"""Cross-folder new_path is a supported rename target. Pins the
|
|
contract the editor JS now relies on after the path-doubling fix:
|
|
the filename input holds a full relative path, so the JS may send
|
|
arbitrary inter-folder destinations as new_path. safe_resolve_for_move
|
|
already handles this — this test makes the dependency explicit."""
|
|
user_id = _make_user()
|
|
overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files")
|
|
overlay_dir = left4me_root / "overlays" / str(overlay_id)
|
|
(overlay_dir / "cfg").mkdir()
|
|
(overlay_dir / "cfg" / "server.cfg").write_text("hostname original\n")
|
|
(overlay_dir / "other").mkdir()
|
|
|
|
client = _client_for(app, user_id)
|
|
r = client.post(
|
|
f"/overlays/{overlay_id}/files/save",
|
|
json={
|
|
"path": "cfg/server.cfg",
|
|
"new_path": "other/server.cfg",
|
|
"content": "hostname moved\n",
|
|
},
|
|
headers=_csrf_headers(),
|
|
)
|
|
|
|
assert r.status_code == 200
|
|
assert not (overlay_dir / "cfg" / "server.cfg").exists()
|
|
assert (overlay_dir / "other" / "server.cfg").read_text() == "hostname moved\n"
|
|
|
|
|
|
def test_save_rejects_oversized_content(app, left4me_root: Path) -> None:
|
|
user_id = _make_user()
|
|
overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files")
|
|
|
|
big = "x" * (1024 * 1024 + 1)
|
|
client = _client_for(app, user_id)
|
|
r = client.post(
|
|
f"/overlays/{overlay_id}/files/save",
|
|
json={"path": "huge.txt", "content": big},
|
|
headers=_csrf_headers(),
|
|
)
|
|
|
|
assert r.status_code == 413
|
|
|
|
|
|
def test_save_rejects_dotdot_path(app, left4me_root: Path) -> None:
|
|
user_id = _make_user()
|
|
overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files")
|
|
|
|
client = _client_for(app, user_id)
|
|
r = client.post(
|
|
f"/overlays/{overlay_id}/files/save",
|
|
json={"path": "../escape.txt", "content": "x"},
|
|
headers=_csrf_headers(),
|
|
)
|
|
|
|
assert r.status_code == 422
|
|
|
|
|
|
def test_save_404_for_non_files_overlay(app, left4me_root: Path) -> None:
|
|
user_id = _make_user()
|
|
overlay_id = _make_overlay(left4me_root, user_id=user_id, name="ws")
|
|
|
|
client = _client_for(app, user_id)
|
|
r = client.post(
|
|
f"/overlays/{overlay_id}/files/save",
|
|
json={"path": "motd.txt", "content": "x"},
|
|
headers=_csrf_headers(),
|
|
)
|
|
|
|
assert r.status_code == 404
|
|
|
|
|
|
# ---- /upload -------------------------------------------------------------
|
|
|
|
|
|
def test_upload_writes_file_at_target_path(app, left4me_root: Path) -> None:
|
|
user_id = _make_user()
|
|
overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files")
|
|
overlay_dir = left4me_root / "overlays" / str(overlay_id)
|
|
|
|
client = _client_for(app, user_id)
|
|
r = client.post(
|
|
f"/overlays/{overlay_id}/files/upload",
|
|
data={"target_path": "addons", "file": (io.BytesIO(b"vpk-bytes"), "map.vpk")},
|
|
headers=_csrf_headers(),
|
|
content_type="multipart/form-data",
|
|
)
|
|
|
|
assert r.status_code == 200
|
|
assert (overlay_dir / "addons" / "map.vpk").read_bytes() == b"vpk-bytes"
|
|
|
|
|
|
def test_upload_with_relative_path_creates_intermediates(app, left4me_root: Path) -> None:
|
|
user_id = _make_user()
|
|
overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files")
|
|
overlay_dir = left4me_root / "overlays" / str(overlay_id)
|
|
|
|
client = _client_for(app, user_id)
|
|
r = client.post(
|
|
f"/overlays/{overlay_id}/files/upload",
|
|
data={
|
|
"target_path": "addons",
|
|
"relative_path": "sourcemod/configs/admins.cfg",
|
|
"file": (io.BytesIO(b"#admins"), "admins.cfg"),
|
|
},
|
|
headers=_csrf_headers(),
|
|
content_type="multipart/form-data",
|
|
)
|
|
|
|
assert r.status_code == 200
|
|
assert (
|
|
overlay_dir / "addons" / "sourcemod" / "configs" / "admins.cfg"
|
|
).read_bytes() == b"#admins"
|
|
|
|
|
|
def test_upload_409_on_existing_without_overwrite(app, left4me_root: Path) -> None:
|
|
user_id = _make_user()
|
|
overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files")
|
|
overlay_dir = left4me_root / "overlays" / str(overlay_id)
|
|
(overlay_dir / "motd.txt").write_bytes(b"existing")
|
|
|
|
client = _client_for(app, user_id)
|
|
r = client.post(
|
|
f"/overlays/{overlay_id}/files/upload",
|
|
data={"target_path": "", "file": (io.BytesIO(b"new"), "motd.txt")},
|
|
headers=_csrf_headers(),
|
|
content_type="multipart/form-data",
|
|
)
|
|
|
|
assert r.status_code == 409
|
|
assert (overlay_dir / "motd.txt").read_bytes() == b"existing"
|
|
|
|
|
|
def test_upload_overwrite_replaces_existing(app, left4me_root: Path) -> None:
|
|
user_id = _make_user()
|
|
overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files")
|
|
overlay_dir = left4me_root / "overlays" / str(overlay_id)
|
|
(overlay_dir / "motd.txt").write_bytes(b"existing")
|
|
|
|
client = _client_for(app, user_id)
|
|
r = client.post(
|
|
f"/overlays/{overlay_id}/files/upload",
|
|
data={"target_path": "", "overwrite": "1", "file": (io.BytesIO(b"new"), "motd.txt")},
|
|
headers=_csrf_headers(),
|
|
content_type="multipart/form-data",
|
|
)
|
|
|
|
assert r.status_code == 200
|
|
assert (overlay_dir / "motd.txt").read_bytes() == b"new"
|
|
|
|
|
|
# ---- /move ---------------------------------------------------------------
|
|
|
|
|
|
def test_move_renames_file(app, left4me_root: Path) -> None:
|
|
user_id = _make_user()
|
|
overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files")
|
|
overlay_dir = left4me_root / "overlays" / str(overlay_id)
|
|
(overlay_dir / "motd.txt").write_text("hi")
|
|
(overlay_dir / "addons").mkdir()
|
|
|
|
client = _client_for(app, user_id)
|
|
r = client.post(
|
|
f"/overlays/{overlay_id}/files/move",
|
|
json={"src": "motd.txt", "dst": "addons/motd.txt"},
|
|
headers=_csrf_headers(),
|
|
)
|
|
|
|
assert r.status_code == 200
|
|
assert not (overlay_dir / "motd.txt").exists()
|
|
assert (overlay_dir / "addons" / "motd.txt").read_text() == "hi"
|
|
|
|
|
|
def test_move_409_on_existing_dst(app, left4me_root: Path) -> None:
|
|
user_id = _make_user()
|
|
overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files")
|
|
overlay_dir = left4me_root / "overlays" / str(overlay_id)
|
|
(overlay_dir / "motd.txt").write_text("a")
|
|
(overlay_dir / "other.txt").write_text("b")
|
|
|
|
client = _client_for(app, user_id)
|
|
r = client.post(
|
|
f"/overlays/{overlay_id}/files/move",
|
|
json={"src": "motd.txt", "dst": "other.txt"},
|
|
headers=_csrf_headers(),
|
|
)
|
|
|
|
assert r.status_code == 409
|
|
assert (overlay_dir / "motd.txt").read_text() == "a"
|
|
assert (overlay_dir / "other.txt").read_text() == "b"
|
|
|
|
|
|
def test_move_rejects_cycle(app, left4me_root: Path) -> None:
|
|
user_id = _make_user()
|
|
overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files")
|
|
overlay_dir = left4me_root / "overlays" / str(overlay_id)
|
|
(overlay_dir / "addons" / "child").mkdir(parents=True)
|
|
|
|
client = _client_for(app, user_id)
|
|
r = client.post(
|
|
f"/overlays/{overlay_id}/files/move",
|
|
json={"src": "addons", "dst": "addons/child/addons"},
|
|
headers=_csrf_headers(),
|
|
)
|
|
|
|
assert r.status_code == 422
|
|
|
|
|
|
# ---- /mkdir --------------------------------------------------------------
|
|
|
|
|
|
def test_mkdir_creates_directory(app, left4me_root: Path) -> None:
|
|
user_id = _make_user()
|
|
overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files")
|
|
overlay_dir = left4me_root / "overlays" / str(overlay_id)
|
|
|
|
client = _client_for(app, user_id)
|
|
r = client.post(
|
|
f"/overlays/{overlay_id}/files/mkdir",
|
|
json={"path": "addons/sourcemod/configs"},
|
|
headers=_csrf_headers(),
|
|
)
|
|
|
|
assert r.status_code == 200
|
|
assert (overlay_dir / "addons" / "sourcemod" / "configs").is_dir()
|
|
|
|
|
|
def test_mkdir_idempotent_on_existing_dir(app, left4me_root: Path) -> None:
|
|
user_id = _make_user()
|
|
overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files")
|
|
overlay_dir = left4me_root / "overlays" / str(overlay_id)
|
|
(overlay_dir / "addons").mkdir()
|
|
|
|
client = _client_for(app, user_id)
|
|
r = client.post(
|
|
f"/overlays/{overlay_id}/files/mkdir",
|
|
json={"path": "addons"},
|
|
headers=_csrf_headers(),
|
|
)
|
|
|
|
assert r.status_code == 200
|
|
assert (overlay_dir / "addons").is_dir()
|
|
|
|
|
|
def test_mkdir_409_when_path_is_file(app, left4me_root: Path) -> None:
|
|
user_id = _make_user()
|
|
overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files")
|
|
overlay_dir = left4me_root / "overlays" / str(overlay_id)
|
|
(overlay_dir / "blocker").write_text("x")
|
|
|
|
client = _client_for(app, user_id)
|
|
r = client.post(
|
|
f"/overlays/{overlay_id}/files/mkdir",
|
|
json={"path": "blocker"},
|
|
headers=_csrf_headers(),
|
|
)
|
|
|
|
assert r.status_code == 409
|
|
|
|
|
|
# ---- /delete -------------------------------------------------------------
|
|
|
|
|
|
def test_delete_removes_file(app, left4me_root: Path) -> None:
|
|
user_id = _make_user()
|
|
overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files")
|
|
overlay_dir = left4me_root / "overlays" / str(overlay_id)
|
|
target = overlay_dir / "motd.txt"
|
|
target.write_text("hi")
|
|
|
|
client = _client_for(app, user_id)
|
|
r = client.post(
|
|
f"/overlays/{overlay_id}/files/delete",
|
|
data={"path": "motd.txt", "csrf_token": "test-token"},
|
|
)
|
|
|
|
assert r.status_code == 200
|
|
assert not target.exists()
|
|
|
|
|
|
def test_delete_removes_empty_dir(app, left4me_root: Path) -> None:
|
|
user_id = _make_user()
|
|
overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files")
|
|
overlay_dir = left4me_root / "overlays" / str(overlay_id)
|
|
target = overlay_dir / "empty-dir"
|
|
target.mkdir()
|
|
|
|
client = _client_for(app, user_id)
|
|
r = client.post(
|
|
f"/overlays/{overlay_id}/files/delete",
|
|
data={"path": "empty-dir", "csrf_token": "test-token"},
|
|
)
|
|
|
|
assert r.status_code == 200
|
|
assert not target.exists()
|
|
|
|
|
|
def test_delete_409_on_non_empty_dir(app, left4me_root: Path) -> None:
|
|
user_id = _make_user()
|
|
overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files")
|
|
overlay_dir = left4me_root / "overlays" / str(overlay_id)
|
|
(overlay_dir / "addons" / "file.txt").parent.mkdir()
|
|
(overlay_dir / "addons" / "file.txt").write_text("x")
|
|
|
|
client = _client_for(app, user_id)
|
|
r = client.post(
|
|
f"/overlays/{overlay_id}/files/delete",
|
|
data={"path": "addons", "csrf_token": "test-token"},
|
|
)
|
|
|
|
assert r.status_code == 409
|
|
assert (overlay_dir / "addons" / "file.txt").exists()
|
|
|
|
|
|
def test_delete_recursive_removes_populated_tree(app, left4me_root: Path) -> None:
|
|
"""recursive=1 turns POST /files/delete into an rmtree. The historical
|
|
no-recursive default still 409s — covered by test_delete_409_on_non_empty_dir
|
|
above. This is the opt-in branch that powers the GUI's preview-and-confirm
|
|
flow."""
|
|
user_id = _make_user()
|
|
overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files")
|
|
overlay_dir = left4me_root / "overlays" / str(overlay_id)
|
|
(overlay_dir / "addons").mkdir()
|
|
(overlay_dir / "addons" / "a.txt").write_text("one")
|
|
(overlay_dir / "addons" / "sub").mkdir()
|
|
(overlay_dir / "addons" / "sub" / "b.txt").write_text("two")
|
|
(overlay_dir / "addons" / "sub" / "c.txt").write_text("three")
|
|
# Sibling outside the deletion target — must survive.
|
|
(overlay_dir / "keep.txt").write_text("keep")
|
|
|
|
client = _client_for(app, user_id)
|
|
r = client.post(
|
|
f"/overlays/{overlay_id}/files/delete",
|
|
data={"path": "addons", "recursive": "1", "csrf_token": "test-token"},
|
|
)
|
|
|
|
assert r.status_code == 200
|
|
assert not (overlay_dir / "addons").exists()
|
|
assert (overlay_dir / "keep.txt").read_text() == "keep"
|
|
|
|
|
|
def test_delete_recursive_unlinks_symlink_without_following(
|
|
app, left4me_root: Path
|
|
) -> None:
|
|
"""A symlink inside the deletion target must be removed as a link —
|
|
the symlink target's contents must NOT be wiped. shutil.rmtree's
|
|
default (followlinks=False) gives us this; the test pins it so a
|
|
future careless rewrite doesn't introduce a wipe-the-world bug."""
|
|
user_id = _make_user()
|
|
overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files")
|
|
overlay_dir = left4me_root / "overlays" / str(overlay_id)
|
|
# Set up a "precious" file OUTSIDE the deletion target but still
|
|
# inside left4me_root so safe_resolve doesn't reject the symlink at
|
|
# resolve time. The recursive delete only operates on `target`'s
|
|
# contents — the symlink target must be untouched after the call.
|
|
precious = left4me_root / "precious.txt"
|
|
precious.write_text("do-not-delete")
|
|
(overlay_dir / "addons").mkdir()
|
|
(overlay_dir / "addons" / "inner.txt").write_text("doomed")
|
|
os.symlink(precious, overlay_dir / "addons" / "link-to-precious")
|
|
|
|
client = _client_for(app, user_id)
|
|
r = client.post(
|
|
f"/overlays/{overlay_id}/files/delete",
|
|
data={"path": "addons", "recursive": "1", "csrf_token": "test-token"},
|
|
)
|
|
|
|
assert r.status_code == 200
|
|
assert not (overlay_dir / "addons").exists()
|
|
assert precious.read_text() == "do-not-delete"
|
|
|
|
|
|
# ---- /delete_preview ----------------------------------------------------
|
|
|
|
|
|
def test_delete_preview_lists_paths_in_populated_tree(
|
|
app, left4me_root: Path
|
|
) -> None:
|
|
user_id = _make_user()
|
|
overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files")
|
|
overlay_dir = left4me_root / "overlays" / str(overlay_id)
|
|
(overlay_dir / "tree").mkdir()
|
|
(overlay_dir / "tree" / "a.txt").write_text("a")
|
|
(overlay_dir / "tree" / "sub").mkdir()
|
|
(overlay_dir / "tree" / "sub" / "b.txt").write_text("bb")
|
|
|
|
client = _client_for(app, user_id)
|
|
r = client.get(f"/overlays/{overlay_id}/files/delete_preview?path=tree")
|
|
|
|
assert r.status_code == 200
|
|
body = r.get_json()
|
|
paths = {e["path"] for e in body["entries"]}
|
|
assert paths == {"a.txt", "sub/", "sub/b.txt"}
|
|
assert body["file_count"] == 2
|
|
assert body["dir_count"] == 1
|
|
assert body["total_bytes"] == 3 # "a" + "bb"
|
|
assert body["truncated"] is False
|
|
|
|
|
|
def test_delete_preview_empty_dir(app, left4me_root: Path) -> None:
|
|
user_id = _make_user()
|
|
overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files")
|
|
overlay_dir = left4me_root / "overlays" / str(overlay_id)
|
|
(overlay_dir / "empty").mkdir()
|
|
|
|
client = _client_for(app, user_id)
|
|
r = client.get(f"/overlays/{overlay_id}/files/delete_preview?path=empty")
|
|
|
|
assert r.status_code == 200
|
|
body = r.get_json()
|
|
assert body == {
|
|
"entries": [],
|
|
"file_count": 0,
|
|
"dir_count": 0,
|
|
"total_bytes": 0,
|
|
"truncated": False,
|
|
}
|
|
|
|
|
|
def test_delete_preview_file_has_empty_payload(app, left4me_root: Path) -> None:
|
|
"""Files don't get a preview — the existing compact modal shows the
|
|
single-file confirm copy. Empty payload signals the JS to skip the
|
|
preview UI."""
|
|
user_id = _make_user()
|
|
overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files")
|
|
overlay_dir = left4me_root / "overlays" / str(overlay_id)
|
|
(overlay_dir / "motd.txt").write_text("hi")
|
|
|
|
client = _client_for(app, user_id)
|
|
r = client.get(f"/overlays/{overlay_id}/files/delete_preview?path=motd.txt")
|
|
|
|
assert r.status_code == 200
|
|
assert r.get_json()["entries"] == []
|
|
|
|
|
|
def test_delete_preview_caps_at_500_entries(app, left4me_root: Path) -> None:
|
|
"""Big trees still return — entries cap at 500 with truncated=True,
|
|
but file_count keeps the full total so the summary line is accurate."""
|
|
user_id = _make_user()
|
|
overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files")
|
|
overlay_dir = left4me_root / "overlays" / str(overlay_id)
|
|
(overlay_dir / "big").mkdir()
|
|
for i in range(600):
|
|
(overlay_dir / "big" / f"f{i:04d}.txt").write_text("x")
|
|
|
|
client = _client_for(app, user_id)
|
|
r = client.get(f"/overlays/{overlay_id}/files/delete_preview?path=big")
|
|
|
|
assert r.status_code == 200
|
|
body = r.get_json()
|
|
assert len(body["entries"]) == 500
|
|
assert body["truncated"] is True
|
|
assert body["file_count"] == 600
|
|
|
|
|
|
def test_delete_preview_does_not_follow_symlinks(
|
|
app, left4me_root: Path
|
|
) -> None:
|
|
"""Symlinks inside the tree show up as kind=symlink. The link's
|
|
target is NOT enumerated, so a huge external dir reachable via a
|
|
link can't bloat the preview."""
|
|
user_id = _make_user()
|
|
overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files")
|
|
overlay_dir = left4me_root / "overlays" / str(overlay_id)
|
|
external = left4me_root / "external"
|
|
external.mkdir()
|
|
(external / "unrelated.txt").write_text("unrelated")
|
|
(overlay_dir / "tree").mkdir()
|
|
os.symlink(external, overlay_dir / "tree" / "link")
|
|
|
|
client = _client_for(app, user_id)
|
|
r = client.get(f"/overlays/{overlay_id}/files/delete_preview?path=tree")
|
|
|
|
assert r.status_code == 200
|
|
body = r.get_json()
|
|
paths = {(e["path"], e["kind"]) for e in body["entries"]}
|
|
assert ("link", "symlink") in paths
|
|
# The link's target's contents must not be enumerated.
|
|
assert not any("unrelated" in e["path"] for e in body["entries"])
|
|
|
|
|
|
def test_delete_preview_rejects_dotdot(app, left4me_root: Path) -> None:
|
|
user_id = _make_user()
|
|
overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files")
|
|
|
|
client = _client_for(app, user_id)
|
|
r = client.get(
|
|
f"/overlays/{overlay_id}/files/delete_preview?path=../escape"
|
|
)
|
|
assert r.status_code == 422
|
|
|
|
|
|
def test_delete_preview_404_on_missing(app, left4me_root: Path) -> None:
|
|
user_id = _make_user()
|
|
overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files")
|
|
|
|
client = _client_for(app, user_id)
|
|
r = client.get(
|
|
f"/overlays/{overlay_id}/files/delete_preview?path=does-not-exist"
|
|
)
|
|
assert r.status_code == 404
|
|
|
|
|
|
# ---- /replace -------------------------------------------------------------
|
|
|
|
|
|
def test_replace_overwrites_file(app, left4me_root: Path) -> None:
|
|
user_id = _make_user()
|
|
overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files")
|
|
overlay_dir = left4me_root / "overlays" / str(overlay_id)
|
|
(overlay_dir / "map.vpk").write_bytes(b"old-bytes")
|
|
|
|
client = _client_for(app, user_id)
|
|
r = client.post(
|
|
f"/overlays/{overlay_id}/files/replace",
|
|
data={
|
|
"path": "map.vpk",
|
|
"csrf_token": "test-token",
|
|
"file": (io.BytesIO(b"new-bytes"), "map.vpk"),
|
|
},
|
|
content_type="multipart/form-data",
|
|
)
|
|
|
|
assert r.status_code == 200
|
|
assert (overlay_dir / "map.vpk").read_bytes() == b"new-bytes"
|
|
|
|
|
|
def test_replace_with_new_path_renames(app, left4me_root: Path) -> None:
|
|
user_id = _make_user()
|
|
overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files")
|
|
overlay_dir = left4me_root / "overlays" / str(overlay_id)
|
|
(overlay_dir / "map.vpk").write_bytes(b"old")
|
|
|
|
client = _client_for(app, user_id)
|
|
r = client.post(
|
|
f"/overlays/{overlay_id}/files/replace",
|
|
data={
|
|
"path": "map.vpk",
|
|
"new_path": "renamed.vpk",
|
|
"csrf_token": "test-token",
|
|
"file": (io.BytesIO(b"new"), "ignored"),
|
|
},
|
|
content_type="multipart/form-data",
|
|
)
|
|
|
|
assert r.status_code == 200
|
|
assert not (overlay_dir / "map.vpk").exists()
|
|
assert (overlay_dir / "renamed.vpk").read_bytes() == b"new"
|
|
|
|
|
|
# ---- /download_zip --------------------------------------------------------
|
|
|
|
|
|
def test_download_zip_streams_folder_contents(app, left4me_root: Path) -> None:
|
|
import zipfile
|
|
|
|
user_id = _make_user()
|
|
overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files")
|
|
overlay_dir = left4me_root / "overlays" / str(overlay_id)
|
|
(overlay_dir / "left4dead2" / "cfg").mkdir(parents=True)
|
|
(overlay_dir / "left4dead2" / "cfg" / "server.cfg").write_text("hostname x")
|
|
(overlay_dir / "left4dead2" / "cfg" / "motd.txt").write_text("welcome")
|
|
|
|
client = _client_for(app, user_id)
|
|
r = client.get(
|
|
f"/overlays/{overlay_id}/files/download_zip?path=left4dead2/cfg"
|
|
)
|
|
|
|
assert r.status_code == 200
|
|
assert r.headers["Content-Disposition"].startswith("attachment")
|
|
body = io.BytesIO(r.get_data())
|
|
with zipfile.ZipFile(body) as zf:
|
|
names = sorted(zf.namelist())
|
|
assert names == ["motd.txt", "server.cfg"]
|
|
assert zf.read("motd.txt") == b"welcome"
|
|
|
|
|
|
# ---- type-isolation across all new endpoints ------------------------------
|
|
|
|
|
|
def test_mutating_endpoints_404_for_workshop_overlay(app, left4me_root: Path) -> None:
|
|
user_id = _make_user()
|
|
with session_scope() as s:
|
|
overlay = Overlay(name="ws", path="", type="workshop", user_id=user_id)
|
|
s.add(overlay)
|
|
s.flush()
|
|
overlay.path = str(overlay.id)
|
|
overlay_id = overlay.id
|
|
(left4me_root / "overlays" / str(overlay_id)).mkdir(parents=True)
|
|
|
|
client = _client_for(app, user_id)
|
|
headers = _csrf_headers()
|
|
paths = [
|
|
("post", f"/overlays/{overlay_id}/files/save", {"json": {"path": "x.txt", "content": "x"}}),
|
|
("post", f"/overlays/{overlay_id}/files/replace",
|
|
{"data": {"path": "x.bin", "csrf_token": "test-token", "file": (io.BytesIO(b"y"), "x.bin")},
|
|
"content_type": "multipart/form-data"}),
|
|
("post", f"/overlays/{overlay_id}/files/upload",
|
|
{"data": {"target_path": "", "csrf_token": "test-token", "file": (io.BytesIO(b"y"), "x.bin")},
|
|
"content_type": "multipart/form-data"}),
|
|
("post", f"/overlays/{overlay_id}/files/move", {"json": {"src": "a", "dst": "b"}}),
|
|
("post", f"/overlays/{overlay_id}/files/mkdir", {"json": {"path": "x"}}),
|
|
("post", f"/overlays/{overlay_id}/files/delete",
|
|
{"data": {"path": "x", "csrf_token": "test-token"}}),
|
|
("get", f"/overlays/{overlay_id}/files/download_zip?path=", {}),
|
|
]
|
|
for method, url, kwargs in paths:
|
|
kwargs = dict(kwargs)
|
|
if method == "post" and "json" in kwargs:
|
|
kwargs["headers"] = headers
|
|
r = getattr(client, method)(url, **kwargs)
|
|
assert r.status_code == 404, f"{method.upper()} {url} returned {r.status_code}"
|