Files
MajoorWaldi-ComfyUI-Majoor-…/tests/features/test_workflows_routes.py
T
Ewald 17db2fe9d7 fix(security): harden workflow path containment and bump brace-expansion
- Resolve CodeQL py/path-injection alerts: enforce lexical containment
  (normpath + prefix check) before any filesystem access in
  list_workflow_versions, diff_workflow_versions,
  list_workflow_thumbnail_candidates and _resolve_path_under_roots,
  with post-resolve symlink re-validation. Also removes a file
  existence oracle for paths outside the allowed roots.
- npm audit fix: brace-expansion DoS (GHSA-3jxr-9vmj-r5cp, dev-only).
2026-07-24 16:16:50 +02:00

435 lines
15 KiB
Python

import json
import tempfile
import pytest
from aiohttp import web
from aiohttp.test_utils import make_mocked_request
from mjr_am_backend.features.workflows import service as workflows_service
from mjr_am_backend.routes.handlers import workflows as workflows_routes
from mjr_am_backend.shared import Result
def _build_app() -> web.Application:
app = web.Application()
routes = web.RouteTableDef()
workflows_routes.register_workflow_routes(routes)
app.add_routes(routes)
return app
@pytest.mark.asyncio
async def test_workflow_content_rejects_invalid_path(monkeypatch, tmp_path):
workflow_dir = tmp_path / "workflows"
workflow_dir.mkdir()
outside = tmp_path / "outside.json"
outside.write_text(json.dumps({"nodes": []}), encoding="utf-8")
monkeypatch.setenv("MJR_AM_WORKFLOW_DIRECTORY", str(workflow_dir))
app = _build_app()
req = make_mocked_request(
"GET",
f"/mjr/am/workflows/content?filepath={outside.as_posix()}",
app=app,
)
match = await app.router.resolve(req)
resp = await match.handler(req)
body = json.loads(resp.text)
assert body.get("ok") is False
assert body.get("code") == "FORBIDDEN"
@pytest.mark.asyncio
async def test_workflow_validate_returns_diagnostics(monkeypatch, tmp_path):
workflow_dir = tmp_path / "workflows"
workflow_dir.mkdir()
workflow_path = workflow_dir / "validate.json"
workflow_path.write_text(
json.dumps(
{
"name": "Validate",
"nodes": [
{"id": 1, "type": "CheckpointLoaderSimple", "widgets_values": ["model.safetensors"]},
{"id": 2, "type": "RemoteRunApi"},
{"id": 3, "type": "GroupNode", "subgraph": {"nodes": [{"id": 1, "type": "KSampler"}]}},
],
"links": [],
}
),
encoding="utf-8",
)
monkeypatch.setenv("MJR_AM_WORKFLOW_DIRECTORY", str(workflow_dir))
app = _build_app()
req = make_mocked_request(
"GET",
f"/mjr/am/workflows/validate?filepath={workflow_path.as_posix()}",
app=app,
)
match = await app.router.resolve(req)
resp = await match.handler(req)
body = json.loads(resp.text)
assert body.get("ok") is True
data = body.get("data", {})
assert data["valid"] is True
assert data["node_count"] == 4
assert data["subgraph_count"] == 1
assert "RemoteRunApi" in data["api_nodes"]
assert "model.safetensors" in data["required_models"]
assert any(item.endswith("::1") for item in data["qualified_node_ids"])
@pytest.mark.asyncio
async def test_workflow_save_rejects_bad_json(monkeypatch):
monkeypatch.setattr(workflows_routes, "_csrf_error", lambda request: None)
monkeypatch.setattr(workflows_routes, "_require_write_access", lambda request: Result.Ok(True))
async def _bad_json(_request):
return Result.Err("INVALID_JSON", "Invalid JSON body")
monkeypatch.setattr(workflows_routes, "_read_json", _bad_json)
app = _build_app()
req = make_mocked_request("POST", "/mjr/am/workflows/save", app=app)
match = await app.router.resolve(req)
resp = await match.handler(req)
body = json.loads(resp.text)
assert body.get("ok") is False
assert body.get("code") == "INVALID_JSON"
@pytest.mark.asyncio
async def test_workflow_save_forwards_info_fields(monkeypatch):
monkeypatch.setattr(workflows_routes, "_csrf_error", lambda request: None)
monkeypatch.setattr(workflows_routes, "_require_write_access", lambda request: Result.Ok(True))
async def _json(_request):
return Result.Ok(
{
"workflow": {"nodes": []},
"name": "demo",
"task": "I2V",
"model_family": "Wan",
"provider": "local",
"runs_on": "local",
"notes": "Keep camera path locked.",
}
)
captured = {}
def _save_workflow(**kwargs):
captured.update(kwargs)
return Result.Ok({"saved": True, "workflow": {"filename": "demo.json"}})
async def _audit(*args, **kwargs):
return None
monkeypatch.setattr(workflows_routes, "_read_json", _json)
monkeypatch.setattr(workflows_routes, "save_workflow", _save_workflow)
monkeypatch.setattr(workflows_routes, "_audit_workflow_write", _audit)
app = _build_app()
req = make_mocked_request("POST", "/mjr/am/workflows/save", app=app)
match = await app.router.resolve(req)
resp = await match.handler(req)
body = json.loads(resp.text)
assert body.get("ok") is True
assert captured["name"] == "demo"
assert captured["category"] == "I2V"
assert captured["info"] == {
"task": "I2V",
"model_family": "Wan",
"provider": "local",
"runs_on": "local",
"notes": "Keep camera path locked.",
}
@pytest.mark.asyncio
async def test_workflow_thumbnail_missing_returns_not_found(monkeypatch, tmp_path):
workflow_dir = tmp_path / "workflows"
workflow_dir.mkdir()
missing = workflow_dir / "missing.png"
monkeypatch.setenv("MJR_AM_WORKFLOW_DIRECTORY", str(workflow_dir))
monkeypatch.setattr(workflows_routes, "is_workflow_thumbnail_path", lambda _path: True)
app = _build_app()
req = make_mocked_request(
"GET",
f"/mjr/am/workflows/thumbnail?filepath={missing.as_posix()}",
app=app,
)
match = await app.router.resolve(req)
resp = await match.handler(req)
body = json.loads(resp.text)
assert body.get("ok") is False
assert body.get("code") == "NOT_FOUND"
@pytest.mark.asyncio
async def test_workflow_graph_map_thumbnail_returns_svg(monkeypatch, tmp_path):
workflow_dir = tmp_path / "workflows"
workflow_dir.mkdir()
workflow_path = workflow_dir / "graph-map.json"
workflow_path.write_text(json.dumps({"name": "Graph Map", "nodes": [{"id": 1, "type": "KSampler"}]}), encoding="utf-8")
monkeypatch.setenv("MJR_AM_WORKFLOW_DIRECTORY", str(workflow_dir))
app = _build_app()
req = make_mocked_request(
"GET",
f"/mjr/am/workflows/graph-map-thumbnail?filepath={workflow_path.as_posix()}",
app=app,
)
match = await app.router.resolve(req)
resp = await match.handler(req)
assert resp.content_type == "image/svg+xml"
assert "<svg" in resp.text
assert resp.headers.get("ETag")
assert resp.headers.get("Cache-Control") == "private, max-age=300"
@pytest.mark.asyncio
async def test_workflow_graph_map_thumbnail_supports_conditional_cache(monkeypatch, tmp_path):
workflow_dir = tmp_path / "workflows"
workflow_dir.mkdir()
workflow_path = workflow_dir / "graph-map.json"
workflow_path.write_text(json.dumps({"name": "Graph Map", "nodes": [{"id": 1, "type": "KSampler"}]}), encoding="utf-8")
monkeypatch.setenv("MJR_AM_WORKFLOW_DIRECTORY", str(workflow_dir))
app = _build_app()
first_req = make_mocked_request(
"GET",
f"/mjr/am/workflows/graph-map-thumbnail?filepath={workflow_path.as_posix()}",
app=app,
)
first_match = await app.router.resolve(first_req)
first_resp = await first_match.handler(first_req)
etag = first_resp.headers.get("ETag")
assert etag
cached_req = make_mocked_request(
"GET",
f"/mjr/am/workflows/graph-map-thumbnail?filepath={workflow_path.as_posix()}",
headers={"If-None-Match": etag},
app=app,
)
cached_match = await app.router.resolve(cached_req)
cached_resp = await cached_match.handler(cached_req)
assert cached_resp.status == 304
assert cached_resp.headers.get("ETag") == etag
assert cached_resp.headers.get("Cache-Control") == "private, max-age=300"
@pytest.mark.asyncio
async def test_workflow_graph_map_thumbnail_supports_if_modified_since(monkeypatch, tmp_path):
workflow_dir = tmp_path / "workflows"
workflow_dir.mkdir()
workflow_path = workflow_dir / "graph-map.json"
workflow_path.write_text(
json.dumps({"name": "Graph Map", "nodes": [{"id": 1, "type": "KSampler"}]}),
encoding="utf-8",
)
monkeypatch.setenv("MJR_AM_WORKFLOW_DIRECTORY", str(workflow_dir))
app = _build_app()
first_req = make_mocked_request(
"GET",
f"/mjr/am/workflows/graph-map-thumbnail?filepath={workflow_path.as_posix()}",
app=app,
)
first_match = await app.router.resolve(first_req)
first_resp = await first_match.handler(first_req)
last_modified = first_resp.headers.get("Last-Modified")
assert last_modified
cached_req = make_mocked_request(
"GET",
f"/mjr/am/workflows/graph-map-thumbnail?filepath={workflow_path.as_posix()}",
headers={"If-Modified-Since": last_modified},
app=app,
)
cached_match = await app.router.resolve(cached_req)
cached_resp = await cached_match.handler(cached_req)
assert cached_resp.status == 304
assert cached_resp.headers.get("Last-Modified") == last_modified
assert cached_resp.headers.get("Cache-Control") == "private, max-age=300"
@pytest.mark.asyncio
async def test_workflow_model_families_returns_indexed_values(monkeypatch, tmp_path):
workflow_dir = tmp_path / "workflows"
workflow_dir.mkdir()
(workflow_dir / "flux.json").write_text(
json.dumps({"name": "Flux Demo", "model_family": "Flux", "nodes": [{"id": 1, "type": "KSampler"}]}),
encoding="utf-8",
)
monkeypatch.setenv("MJR_AM_WORKFLOW_DIRECTORY", str(workflow_dir))
monkeypatch.setattr(workflows_routes, "list_workflow_model_families", lambda: Result.Ok({"model_families": [{"label": "Flux", "value": "Flux", "count": 1}]}))
app = _build_app()
req = make_mocked_request("GET", "/mjr/am/workflows/model-families", app=app)
match = await app.router.resolve(req)
resp = await match.handler(req)
body = json.loads(resp.text)
assert body.get("ok") is True
families = body.get("data", {}).get("model_families", [])
assert families and families[0]["value"] == "Flux"
@pytest.mark.asyncio
async def test_workflow_graph_map_thumbnail_rejects_outside_path(monkeypatch, tmp_path):
workflow_dir = tmp_path / "workflows"
workflow_dir.mkdir()
outside = tmp_path / "outside.json"
outside.write_text(json.dumps({"name": "Outside", "nodes": []}), encoding="utf-8")
monkeypatch.setenv("MJR_AM_WORKFLOW_DIRECTORY", str(workflow_dir))
app = _build_app()
req = make_mocked_request(
"GET",
f"/mjr/am/workflows/graph-map-thumbnail?filepath={outside.as_posix()}",
app=app,
)
match = await app.router.resolve(req)
resp = await match.handler(req)
body = json.loads(resp.text)
assert body.get("ok") is False
assert body.get("code") == "FORBIDDEN"
@pytest.mark.asyncio
async def test_workflow_write_remote_denial(monkeypatch):
monkeypatch.setattr(workflows_routes, "_csrf_error", lambda request: None)
monkeypatch.setattr(
workflows_routes,
"_require_write_access",
lambda request: Result.Err("AUTH_REQUIRED", "Write access denied"),
)
app = _build_app()
req = make_mocked_request("POST", "/mjr/am/workflows/delete", app=app)
match = await app.router.resolve(req)
resp = await match.handler(req)
body = json.loads(resp.text)
assert body.get("ok") is False
assert body.get("code") == "AUTH_REQUIRED"
@pytest.mark.asyncio
async def test_workflow_favorite_rejects_missing_filepath(monkeypatch):
monkeypatch.setattr(workflows_routes, "_csrf_error", lambda request: None)
monkeypatch.setattr(workflows_routes, "_require_write_access", lambda request: Result.Ok(True))
async def _json_without_filepath(_request):
return Result.Ok({"favorite": True})
monkeypatch.setattr(workflows_routes, "_read_json", _json_without_filepath)
app = _build_app()
req = make_mocked_request("POST", "/mjr/am/workflows/favorite", app=app)
match = await app.router.resolve(req)
resp = await match.handler(req)
body = json.loads(resp.text)
assert body.get("ok") is False
assert body.get("code") == "INVALID_INPUT"
@pytest.mark.asyncio
async def test_workflow_tags_rejects_missing_filepath(monkeypatch):
monkeypatch.setattr(workflows_routes, "_csrf_error", lambda request: None)
monkeypatch.setattr(workflows_routes, "_require_write_access", lambda request: Result.Ok(True))
async def _json_without_filepath(_request):
return Result.Ok({"tags": ["cinematic"]})
monkeypatch.setattr(workflows_routes, "_read_json", _json_without_filepath)
app = _build_app()
req = make_mocked_request("POST", "/mjr/am/workflows/tags", app=app)
match = await app.router.resolve(req)
resp = await match.handler(req)
body = json.loads(resp.text)
assert body.get("ok") is False
assert body.get("code") == "INVALID_INPUT"
@pytest.mark.asyncio
async def test_workflow_tags_accepts_valid_payload(monkeypatch, tmp_path):
workflow_dir = tmp_path / "workflows"
index_db = tmp_path / "index.sqlite"
workflow_dir.mkdir()
workflow_path = workflow_dir / "taggable.json"
original = json.dumps({"name": "Taggable", "nodes": [{"id": 1, "type": "KSampler"}]})
workflow_path.write_text(original, encoding="utf-8")
monkeypatch.setenv("MJR_AM_WORKFLOW_DIRECTORY", str(workflow_dir))
monkeypatch.setattr(workflows_service, "get_runtime_index_db_path", lambda: index_db)
monkeypatch.setattr(workflows_routes, "_csrf_error", lambda request: None)
monkeypatch.setattr(workflows_routes, "_require_write_access", lambda request: Result.Ok(True))
async def _json_with_tags(_request):
return Result.Ok({"filepath": str(workflow_path), "tags": ["cinematic", "flux"]})
monkeypatch.setattr(workflows_routes, "_read_json", _json_with_tags)
app = _build_app()
req = make_mocked_request("POST", "/mjr/am/workflows/tags", app=app)
match = await app.router.resolve(req)
resp = await match.handler(req)
body = json.loads(resp.text)
assert body.get("ok") is True
assert body.get("data", {}).get("tags") == ["cinematic", "flux"]
assert workflow_path.read_text(encoding="utf-8") == original
@pytest.mark.asyncio
async def test_workflow_thumbnail_candidates_rejects_missing_filepath(monkeypatch):
app = _build_app()
req = make_mocked_request("GET", "/mjr/am/workflows/thumbnail-candidates", app=app)
match = await app.router.resolve(req)
resp = await match.handler(req)
body = json.loads(resp.text)
assert body.get("ok") is False
assert body.get("code") == "INVALID_INPUT"
@pytest.mark.asyncio
async def test_workflow_thumbnail_rejects_missing_source(monkeypatch):
workflow_dir = tempfile.mkdtemp()
monkeypatch.setenv("MJR_AM_WORKFLOW_DIRECTORY", workflow_dir)
monkeypatch.setattr(workflows_routes, "_csrf_error", lambda request: None)
monkeypatch.setattr(workflows_routes, "_require_write_access", lambda request: Result.Ok(True))
workflow_path = f"{workflow_dir}/demo.json"
with open(workflow_path, "w", encoding="utf-8") as handle:
json.dump({"name": "Demo", "nodes": []}, handle)
async def _json_without_source(_request):
return Result.Ok({"filepath": workflow_path})
monkeypatch.setattr(workflows_routes, "_read_json", _json_without_source)
app = _build_app()
req = make_mocked_request("POST", "/mjr/am/workflows/thumbnail/set", app=app)
match = await app.router.resolve(req)
resp = await match.handler(req)
body = json.loads(resp.text)
assert body.get("ok") is False
assert body.get("code") == "INVALID_INPUT"