Two pieces of the "port v4's exact UI onto v5's backend" plan: - core/contract.py: new optional ModuleSpec.category field (closed set: continuity, guidance, conditioning, sampling, post, system), validated in core/schema.py, threaded through core/registry.py's FIELDS and to_manifest(). Presentation grouping, independent of STAGE (execution order) -- a module can be stage="sampling" and category="guidance" at once. Only the 4 existing modules that actually render a settings panel (alg, sharpen, appearance, full_control) got a category assigned; the other 12 have none to categorize (no SETTINGS block) and stay uncategorised, which is valid. - v4's frontend (movie_editor/frontend/*, ~29k lines, 61 files) copied verbatim into app/legacy/. core/routes.py's "/" route now serves legacy/index.html instead of the composer entry point; a <base> tag in that file resolves its own relative script/link tags against /funpack/app/legacy/ regardless of what URL it's served at, so every file moved as a unit with zero per-file path edits. Verified live: all 61 assets load 200 OK, the real v4 onboarding screen renders pixel-for- pixel (it's the actual DOM/CSS, not a reimplementation), and several v4 API calls (/api/media, /api/projects, /api/git/status) already succeed against v5's real backend by coincidence of shared route naming -- the rest 404/405 as expected, since api.js's ~90 functions haven't been rewired to v5's actual routes yet (next piece of this plan). The old composer entry point (app/index.html, app/composer/**) stays in the tree, unreferenced, until a later cleanup pass once nothing imports it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
218 lines
8.3 KiB
Python
218 lines
8.3 KiB
Python
"""The routes, exercised through a real aiohttp router over a real socket.
|
|
|
|
test_serve_allowlist.py calls serve() with literal strings and cannot see what a
|
|
router does to a path before the handler runs, so routes.py had no coverage at
|
|
all. This closes that.
|
|
|
|
Requests are written onto the socket by hand rather than through aiohttp's
|
|
TestClient, because TestClient normalises the URL BEFORE sending: ask it for
|
|
`/funpack/modules/%2e%2e/app/boot.js` and the server receives a plain
|
|
`/funpack/app/boot.js`. A test written that way silently checks nothing about
|
|
traversal -- it looks like a routing bypass when it is only the client tidying up
|
|
after itself.
|
|
|
|
The loop is driven by hand rather than with pytest-asyncio: ComfyUI brings
|
|
aiohttp, and the suite should not need a plugin users would have to install.
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
|
|
import pytest
|
|
|
|
pytest.importorskip("aiohttp")
|
|
from aiohttp import web # noqa: E402
|
|
from aiohttp.test_utils import TestServer # noqa: E402
|
|
|
|
from core import config, routes as routes_mod # noqa: E402
|
|
|
|
P = config.UI_PREFIX
|
|
|
|
|
|
@pytest.fixture()
|
|
def app(tmp_path, monkeypatch):
|
|
app_dir = tmp_path / "app"
|
|
(app_dir / "composer").mkdir(parents=True)
|
|
(app_dir / "boot.js").write_text("export const app = 1;")
|
|
(app_dir / "composer" / "composer.css").write_text(":root{}")
|
|
(app_dir / "index.html").write_text("<p>shell</p>")
|
|
(app_dir / "legacy").mkdir(parents=True)
|
|
(app_dir / "legacy" / "index.html").write_text("<p>legacy shell</p>")
|
|
|
|
mod_dir = tmp_path / "modules" / "timing" / "audio_clock"
|
|
mod_dir.mkdir(parents=True)
|
|
(mod_dir / "ui.js").write_text("export default 1;")
|
|
(mod_dir / "sneaky.css").write_text(".x{}")
|
|
|
|
monkeypatch.setattr(config, "APP_DIR", app_dir)
|
|
monkeypatch.setattr(config, "MODULES_DIR", tmp_path / "modules")
|
|
|
|
# The registry caches its scan for the whole session, so anything that
|
|
# triggers one WHILE MODULES_DIR points here caches this temp directory --
|
|
# one fake module -- as the installed set, and every later test in the
|
|
# session runs against it. That happened, and cost twenty-three failures in
|
|
# files that have nothing to do with routes. Whoever redirects the directory
|
|
# owns putting the cache back.
|
|
from core import registry as registry_mod
|
|
monkeypatch.setattr(registry_mod, "_current", registry_mod._current, raising=False)
|
|
|
|
# A builder, not an Application: aiohttp binds an app to the loop that first
|
|
# ran it, and each request here gets its own asyncio.run().
|
|
def build():
|
|
table = web.RouteTableDef()
|
|
routes_mod.register(table)
|
|
application = web.Application()
|
|
application.add_routes(table)
|
|
return application
|
|
|
|
return build
|
|
|
|
|
|
def get(app, target):
|
|
"""(status, body) for a request line written verbatim onto the socket."""
|
|
async def go():
|
|
server = TestServer(app())
|
|
await server.start_server()
|
|
try:
|
|
reader, writer = await asyncio.open_connection("127.0.0.1", server.port)
|
|
writer.write(
|
|
f"GET {target} HTTP/1.1\r\nHost: test\r\nConnection: close\r\n\r\n".encode()
|
|
)
|
|
await writer.drain()
|
|
raw = await reader.read()
|
|
writer.close()
|
|
finally:
|
|
await server.close()
|
|
head, _, body = raw.partition(b"\r\n\r\n")
|
|
status = int(head.split(b"\r\n", 1)[0].split()[1])
|
|
return status, body.decode("utf-8", "replace")
|
|
return asyncio.run(go())
|
|
|
|
|
|
# --- traversal, as it actually arrives on the wire -------------------------
|
|
|
|
@pytest.mark.parametrize("target", [
|
|
P + "/modules/../app/boot.js",
|
|
P + "/modules/%2e%2e/app/boot.js",
|
|
P + "/modules/%2e%2e/app/composer/composer.css",
|
|
P + "/modules/%2e%2e%2f%2e%2e/app/boot.js",
|
|
P + "/app/../../etc/passwd",
|
|
P + "/app/%2e%2e/%2e%2e/etc/passwd",
|
|
])
|
|
def test_traversal_never_leaves_its_root(app, target):
|
|
status, body = get(app, target)
|
|
assert status in (403, 404), f"{target} -> {status} {body[:80]!r}"
|
|
|
|
|
|
def test_a_modules_url_cannot_reach_app_content(app):
|
|
# The route that matched must be the root that serves: a .css is reachable
|
|
# under /app/ and must stay unreachable under /modules/.
|
|
assert get(app, P + "/modules/../app/composer/composer.css")[0] in (403, 404)
|
|
assert get(app, P + "/app/composer/composer.css")[0] == 200
|
|
|
|
|
|
# --- the allowlist on ordinary URLs ----------------------------------------
|
|
|
|
def test_module_css_is_refused(app):
|
|
assert get(app, P + "/modules/timing/audio_clock/sneaky.css")[0] == 404
|
|
|
|
|
|
def test_module_js_is_served(app):
|
|
status, body = get(app, P + "/modules/timing/audio_clock/ui.js")
|
|
assert status == 200 and "export default" in body
|
|
|
|
|
|
def test_app_asset_is_served(app):
|
|
status, body = get(app, P + "/app/boot.js")
|
|
assert status == 200 and "export const app" in body
|
|
|
|
|
|
def test_query_string_does_not_change_the_target(app):
|
|
assert get(app, P + "/app/boot.js?v=2")[0] == 200
|
|
|
|
|
|
def test_index_is_served(app):
|
|
# The legacy (v4-derived) frontend is the served app now.
|
|
status, body = get(app, P + "/")
|
|
assert status == 200 and "legacy shell" in body
|
|
|
|
|
|
def test_health(app):
|
|
status, body = get(app, P + "/api/health")
|
|
assert status == 200 and '"ok"' in body
|
|
|
|
|
|
|
|
# --- the nodes route -------------------------------------------------------
|
|
|
|
def _nodes(app, target):
|
|
status, body = get(app, target)
|
|
return status, json.loads(body)
|
|
|
|
|
|
def test_the_nodes_route_describes_what_a_pipeline_points_at(app, comfyui):
|
|
"""The window that edits a pipeline needs the widgets, and asks by name.
|
|
|
|
ComfyUI's own /object_info answers with every installed node, which on a
|
|
machine with a few packs is megabytes for a question about a dozen.
|
|
"""
|
|
# ComfyUI's own registry only. Installing FunPack's nodes here would call
|
|
# collect(), which scans MODULES_DIR -- and the `app` fixture has pointed
|
|
# that at a temp directory holding one fake module. The scan is CACHED, so
|
|
# every later test in the session then ran against an empty module registry:
|
|
# twenty-three failures in files that never mention routes. What this test
|
|
# needs from the registry is a node, and KSampler is one.
|
|
import nodes # noqa: F401
|
|
|
|
status, body = _nodes(app, P + "/api/nodes?classes=KSampler,NoSuchNodeAnywhere")
|
|
assert status == 200
|
|
assert body["nodes"]["KSampler"]["widgets"], "a node came back with nothing to edit"
|
|
assert body["nodes"]["NoSuchNodeAnywhere"] is None, (
|
|
"an absent node was dropped from the answer rather than reported as absent")
|
|
|
|
|
|
def test_asking_about_nothing_is_not_an_error(app):
|
|
status, body = _nodes(app, P + "/api/nodes")
|
|
assert status == 200 and body["nodes"] == {}
|
|
|
|
|
|
def test_asking_about_a_thousand_nodes_is_refused(app):
|
|
"""The query string is the caller's, and describing a thousand nodes one by
|
|
one on the event loop stops the server answering anything else."""
|
|
many = ",".join(f"Node{i}" for i in range(1000))
|
|
status, body = _nodes(app, P + f"/api/nodes?classes={many}")
|
|
assert status == 400
|
|
assert any("200" in problem for problem in body["problems"])
|
|
|
|
|
|
def test_the_search_route_finds_a_node_by_name(app, comfyui):
|
|
"""Adding a node to a group needs the name before it can be described, and
|
|
the describe route only answers about names it is given."""
|
|
import nodes # noqa: F401 -- see the note above; the scan must stay unwarmed
|
|
|
|
status, body = _nodes(app, P + "/api/nodes/search?q=KSampler")
|
|
assert status == 200
|
|
assert body["nodes"][0]["node"] == "KSampler"
|
|
assert body["total"] >= len(body["nodes"])
|
|
|
|
|
|
def test_the_search_route_is_bounded_however_the_caller_asks(app, comfyui):
|
|
import nodes # noqa: F401
|
|
|
|
_status, body = _nodes(app, P + "/api/nodes/search?q=&limit=100000")
|
|
assert len(body["nodes"]) <= 200
|
|
|
|
# The count is of everything, not of what was returned -- a picker that says
|
|
# "showing 5 of 5" when there are hundreds is a picker that lies about
|
|
# whether it is worth typing anything.
|
|
_status, few = _nodes(app, P + "/api/nodes/search?q=&limit=5")
|
|
assert len(few["nodes"]) == 5
|
|
assert few["total"] == body["total"] > 5
|
|
|
|
|
|
def test_a_search_limit_that_is_not_a_number_does_not_500(app, comfyui):
|
|
import nodes # noqa: F401
|
|
|
|
status, body = _nodes(app, P + "/api/nodes/search?q=KSampler&limit=lots")
|
|
assert status == 200 and body["nodes"]
|