mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 03:09:57 +00:00
feat(folder_library): Folder Library core plugin (#610)
Adds the bundled Folder Library plugin (browse the DLC library by its on-disk folder tree, in-app folder CRUD, drag-and-drop + dialog song moves, sort/filter, live search), wired into the classic v2 toolbar and the v3 Songs page. Includes the screen.js IIFE dedup (unified surface factory) and review fixes: path-traversal guard on /song/move, folder-delete data-loss fix, plural /api/plugins/<id> namespace, loose-folder song recognition, error-text escaping, v3 setLibView null-guard, and tests. Co-authored-by: Kyle <kyle.j.t@live.co.uk> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Kyle
Claude Opus 4.8
parent
d1f7f12293
commit
3b2d83d406
@@ -0,0 +1,208 @@
|
||||
"""Tests for the folder_library plugin backend.
|
||||
|
||||
Covers the pure path-safety helpers and end-to-end behaviour of the two
|
||||
filesystem-mutating endpoints whose bugs this guards against:
|
||||
* /song/move must reject path traversal in `filename` (no escaping DLC_DIR).
|
||||
* /folder/delete must relocate EVERY song to the root, never destroy a song
|
||||
whose name collides with an existing root song.
|
||||
|
||||
The plugin's routes.py is loaded under a unique module name via importlib so it
|
||||
does not collide in sys.modules with other bundled plugins' routes.py.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
_ROUTES_PATH = (
|
||||
Path(__file__).resolve().parents[3]
|
||||
/ "plugins" / "folder_library" / "routes.py"
|
||||
)
|
||||
_spec = importlib.util.spec_from_file_location("folder_library_routes", _ROUTES_PATH)
|
||||
fl = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(fl)
|
||||
|
||||
|
||||
# ── Pure helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
class TestSafeName:
|
||||
@pytest.mark.parametrize("name", ["Rock", "Folder 1", "A-B_C", "über", "AC.DC"])
|
||||
def test_accepts_ordinary_names(self, name):
|
||||
assert fl._safe_name(name) is True
|
||||
|
||||
@pytest.mark.parametrize("name", [
|
||||
"", "..", ".", "../x", "a/b", "a\\b", "a:b", "a*b", "a?b",
|
||||
'a"b', "a<b", "a>b", "a|b", " lead", "lead ",
|
||||
])
|
||||
def test_rejects_unsafe_names(self, name):
|
||||
assert fl._safe_name(name) is False
|
||||
|
||||
|
||||
class TestSafePath:
|
||||
@pytest.mark.parametrize("path", ["A", "A/B", "A/B/C", "Rock/Sub Folder"])
|
||||
def test_accepts_safe_paths(self, path):
|
||||
assert fl._safe_path(path) is True
|
||||
|
||||
@pytest.mark.parametrize("path", [
|
||||
"", "..", "../x", "A/../B", "A/..", "/A", "A//B", "A/b\\c",
|
||||
])
|
||||
def test_rejects_traversal_and_empty(self, path):
|
||||
assert fl._safe_path(path) is False
|
||||
|
||||
|
||||
class TestIsWithin:
|
||||
def test_inside(self, tmp_path):
|
||||
assert fl._is_within(tmp_path, tmp_path / "a" / "b") is True
|
||||
|
||||
def test_traversal_escapes(self, tmp_path):
|
||||
root = tmp_path / "dlc"
|
||||
root.mkdir()
|
||||
assert fl._is_within(root, root / ".." / "secret") is False
|
||||
|
||||
def test_sibling_prefix_not_within(self, tmp_path):
|
||||
root = tmp_path / "dlc"
|
||||
root.mkdir()
|
||||
(tmp_path / "dlc-evil").mkdir()
|
||||
assert fl._is_within(root, tmp_path / "dlc-evil" / "x") is False
|
||||
|
||||
|
||||
class TestIsSong:
|
||||
@pytest.mark.parametrize("name", ["a.sloppak", "a.feedpak", "A.SLOPPAK"])
|
||||
def test_song_extensions(self, name, tmp_path):
|
||||
assert fl._is_song(tmp_path / name) is True
|
||||
|
||||
@pytest.mark.parametrize("name", ["a.txt", "a", "a.zip"])
|
||||
def test_non_song(self, name, tmp_path):
|
||||
assert fl._is_song(tmp_path / name) is False
|
||||
|
||||
|
||||
# ── Endpoint behaviour ──────────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture
|
||||
def env(tmp_path):
|
||||
dlc = tmp_path / "dlc"
|
||||
dlc.mkdir()
|
||||
app = FastAPI()
|
||||
fl.setup(app, {
|
||||
"log": logging.getLogger("folder_library_test"),
|
||||
"get_dlc_dir": lambda: str(dlc),
|
||||
"extract_meta": lambda p: {},
|
||||
})
|
||||
return TestClient(app), dlc, tmp_path
|
||||
|
||||
|
||||
def _song(path: Path, content: str):
|
||||
path.write_text(content)
|
||||
|
||||
|
||||
def _loose_song(folder: Path):
|
||||
"""Minimal valid loose-folder song: audio + an arrangement XML (<song> root)."""
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
(folder / "audio.wem").write_bytes(b"\x00")
|
||||
(folder / "lead.xml").write_text("<song><title>Loose</title></song>")
|
||||
|
||||
|
||||
class TestLooseFolderRecognition:
|
||||
def test_is_song_detects_loose_folder_dir(self, tmp_path):
|
||||
loose = tmp_path / "MyLoose"
|
||||
_loose_song(loose)
|
||||
assert fl._is_song(loose) is True
|
||||
|
||||
def test_plain_folder_is_not_a_song(self, tmp_path):
|
||||
plain = tmp_path / "Plain"
|
||||
plain.mkdir()
|
||||
(plain / "notes.txt").write_text("x")
|
||||
assert fl._is_song(plain) is False
|
||||
|
||||
def test_loose_folder_surfaces_as_song_not_child_folder(self, env):
|
||||
client, dlc, _ = env
|
||||
_loose_song(dlc / "Rock" / "LooseSong")
|
||||
r = client.get("/api/plugins/folder_library/tree")
|
||||
assert r.status_code == 200, r.text
|
||||
rock = next(f for f in r.json()["folders"] if f["name"] == "Rock")
|
||||
assert "LooseSong" in {s["title"] for s in rock["songs"]}
|
||||
assert "LooseSong" not in {c["name"] for c in rock["children"]}
|
||||
|
||||
|
||||
class TestMoveTraversal:
|
||||
def test_rejects_parent_traversal_and_does_not_move(self, env):
|
||||
client, dlc, tmp = env
|
||||
secret = tmp / "secret.sloppak"
|
||||
_song(secret, "TOP SECRET")
|
||||
r = client.post("/api/plugins/folder_library/song/move",
|
||||
json={"filename": "../secret.sloppak", "folder": ""})
|
||||
assert r.status_code == 400
|
||||
# The external file must NOT have been moved into the served library.
|
||||
assert secret.exists()
|
||||
assert not (dlc / "secret.sloppak").exists()
|
||||
|
||||
def test_rejects_absolute_style_traversal(self, env):
|
||||
client, dlc, tmp = env
|
||||
r = client.post("/api/plugins/folder_library/song/move",
|
||||
json={"filename": "../../etc/passwd", "folder": ""})
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_valid_move_succeeds(self, env):
|
||||
client, dlc, _ = env
|
||||
_song(dlc / "A.sloppak", "a")
|
||||
(dlc / "Dest").mkdir()
|
||||
r = client.post("/api/plugins/folder_library/song/move",
|
||||
json={"filename": "A.sloppak", "folder": "Dest"})
|
||||
assert r.status_code == 200
|
||||
assert not (dlc / "A.sloppak").exists()
|
||||
assert (dlc / "Dest" / "A.sloppak").read_text() == "a"
|
||||
|
||||
|
||||
class TestDeleteFolderNoDataLoss:
|
||||
def test_colliding_song_is_relocated_not_destroyed(self, env):
|
||||
client, dlc, _ = env
|
||||
# A root song and a same-named song inside the folder being deleted.
|
||||
_song(dlc / "song.sloppak", "ROOT")
|
||||
(dlc / "F").mkdir()
|
||||
_song(dlc / "F" / "song.sloppak", "INSIDE")
|
||||
|
||||
r = client.post("/api/plugins/folder_library/folder/delete",
|
||||
json={"name": "F"})
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# Folder gone, original root song intact, and the colliding song
|
||||
# survived under a de-duplicated name (NOT destroyed by rmtree).
|
||||
assert not (dlc / "F").exists()
|
||||
assert (dlc / "song.sloppak").read_text() == "ROOT"
|
||||
survivors = {p.read_text() for p in dlc.glob("*.sloppak")}
|
||||
assert "INSIDE" in survivors
|
||||
assert len(list(dlc.glob("*.sloppak"))) == 2
|
||||
|
||||
def test_nested_songs_all_relocated(self, env):
|
||||
client, dlc, _ = env
|
||||
(dlc / "F" / "Sub").mkdir(parents=True)
|
||||
_song(dlc / "F" / "a.sloppak", "a")
|
||||
_song(dlc / "F" / "Sub" / "b.sloppak", "b")
|
||||
r = client.post("/api/plugins/folder_library/folder/delete",
|
||||
json={"name": "F"})
|
||||
assert r.status_code == 200, r.text
|
||||
assert not (dlc / "F").exists()
|
||||
names = {p.name for p in dlc.glob("*.sloppak")}
|
||||
assert names == {"a.sloppak", "b.sloppak"}
|
||||
|
||||
|
||||
class TestFolderOpsValidation:
|
||||
def test_create_rejects_unsafe_name(self, env):
|
||||
client, _, _ = env
|
||||
r = client.post("/api/plugins/folder_library/folder/create",
|
||||
json={"name": "../evil"})
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_create_and_rename_roundtrip(self, env):
|
||||
client, dlc, _ = env
|
||||
assert client.post("/api/plugins/folder_library/folder/create",
|
||||
json={"name": "New"}).status_code == 200
|
||||
assert (dlc / "New").is_dir()
|
||||
assert client.post("/api/plugins/folder_library/folder/rename",
|
||||
json={"old": "New", "new": "Renamed"}).status_code == 200
|
||||
assert (dlc / "Renamed").is_dir()
|
||||
assert not (dlc / "New").exists()
|
||||
Reference in New Issue
Block a user