Old project/workflow names and browser settings migrate on load; /funpack redirects to /fun. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016acRss69eH1235eRdFTZHY
212 lines
8.1 KiB
Python
212 lines
8.1 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
|
|
`/fun/modules/%2e%2e/app/boot.js` and the server receives a plain
|
|
`/fun/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 / "ui").mkdir(parents=True)
|
|
(app_dir / "ui" / "index.html").write_text('<script src="/fun/app/ui/boot.js"></script>')
|
|
|
|
mod_dir = tmp_path / "modules" / "timing" / "audio_clock"
|
|
mod_dir.mkdir(parents=True)
|
|
(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_modules_are_not_served(app):
|
|
assert get(app, P + "/modules/timing/audio_clock/sneaky.css")[0] == 404
|
|
assert get(app, P + "/modules/timing/audio_clock/ui.js")[0] == 404
|
|
|
|
|
|
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):
|
|
status, body = get(app, P + "/")
|
|
assert status == 200 and "/fun/app/ui/boot.js" 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 FunUI'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"]
|