Security
--------
ComfyUI's PromptServer.add_routes() mirrors every registered route under a
second "/api" prefix, so /mjr/am/<mutation> is also served at
/api/mjr/am/<mutation>. The auth middleware matched only the canonical
prefix, so the mirrored path reached the same handler with the
authentication check skipped entirely, and without the hardening headers:
POST /mjr/am/assets/delete -> requires_auth = True
POST /api/mjr/am/assets/delete -> requires_auth = False
Replaced the prefix test with a shared _is_majoor_path() boundary covering
the canonical prefixes, ComfyUI's /api mirror, and the /api/v2/assets
compat surface. Verified it does not capture ComfyUI's own routes. CSRF,
rate limiting and safe-mode gates are per-handler and were unaffected.
Licensing
---------
PrimeVue 5 is no longer MIT: PrimeTek moved it to the commercial "PrimeUI"
license, which requires a key, shows a license notice without one, and
forbids redistributing it "as a component library or development tool"
without a separate OEM License. Dependabot had auto-merged 4.5.5 -> 5.0.1.
This project is GPL-3.0 and commits dist/ to the repo, so shipping it would
nag every user and conflict with the GPL's redistribution requirement.
Pinned back to 4.5.5 (last MIT release). The 5.x bump had touched only
package.json/package-lock.json, so no source depended on v5 APIs. The
revert drops @primeui/license-manager and its @noble/ed25519 key-signature
stack; the primevue chunk shrinks 555.70 -> 520.61 kB. Dependabot is now
configured to ignore primevue major bumps.
Correctness
-----------
- api_v2_assets: stop returning the DB's absolute filepath on this
network-reachable surface; subfolder + root_id already convey location
without disclosing local disk layout.
- watcher: enforce _RECENT_GENERATED_HARD_CAP after inserting a batch;
pruning only beforehand let a large batch overshoot the cap.
- routes/registry: track registered route groups individually so a group
failing part-way through does not re-register earlier groups on retry.
- fs_walker/scanner: apply the viewer's root check to symlinked files, so
the scanner stops indexing symlinks whose target escapes every allowed
root ("ghost assets" the viewer then refuses to open). Consulted only for
actual symlinks, so ordinary scans pay no extra I/O.
- routes/registry_app: skip the redundant app.add_routes() on the live
PromptServer app; ComfyUI adds the shared table itself after node
loading, so every core and Majoor route was registered twice at startup.
- Result: unwrap()/unwrap_or()/map() gated on `data is not None`, treating
a legitimate Ok(None) as failure; gate on `ok` instead.
- custom_roots: replace a leftover debug print() that wrote user file paths
to stderr on every move request with logger.debug.
- schema: drop the redundant UNIQUE(filepath, source, root_id) index
(migration m022). assets.filepath is already UNIQUE, so it could never
reject a row the column constraint does not, nor narrow a lookup - it
only cost a second B-tree write per asset on the scan hot path.
Frontend
--------
- entry.ts: app.registerExtension() throws on a duplicate name and ComfyUI
exposes no unregisterExtension, so a live re-import threw before setup()
ran and the reload-safe teardown never executed. Catch it and re-run
setup() directly.
- Rebuilt dist/ so both the reload fix and the PrimeVue revert ship.
CI / tooling
------------
- frontend-compat-nightly: the types step died with a bare "cp: cannot
stat" (upstream's build has not emitted dist/index.d.ts since Aug 26).
Check both candidate paths and dump dist/ with an actionable message.
- package.json: lenient checks that swallowed their exit code are now
suffixed :advisory, the plain names are strict, and quality:strict runs
the full set.
Verified: 1841 Python tests pass (5 skipped, symlink privileges), ruff
clean; tsc --noEmit clean, 831 JS tests pass, eslint clean, build succeeds.
454 lines
16 KiB
Python
454 lines
16 KiB
Python
"""Tests for the ``/api/v2/assets/*`` OpenAPI compat layer."""
|
|
|
|
import json
|
|
|
|
import pytest
|
|
from aiohttp import web
|
|
from aiohttp.test_utils import make_mocked_request
|
|
from mjr_am_backend.routes.handlers import api_v2_assets
|
|
from mjr_am_backend.shared import Result
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Helpers
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def _build_app() -> web.Application:
|
|
app = web.Application()
|
|
routes = web.RouteTableDef()
|
|
api_v2_assets.register_api_v2_asset_routes(routes)
|
|
app.add_routes(routes)
|
|
return app
|
|
|
|
|
|
class _FakeDB:
|
|
"""Tiny stand-in for the SQLite facade used by the compat layer."""
|
|
|
|
def __init__(self, query_handler) -> None:
|
|
self._handler = query_handler
|
|
|
|
async def aquery(self, sql, params=None):
|
|
return self._handler(sql, params)
|
|
|
|
|
|
def _row(**overrides):
|
|
base = {
|
|
"id": 1,
|
|
"filename": "pic.png",
|
|
"filepath": "/tmp/pic.png",
|
|
"subfolder": "",
|
|
"source": "output",
|
|
"root_id": None,
|
|
"kind": "image",
|
|
"ext": ".png",
|
|
"size": 1024,
|
|
"mtime": 1_700_000_000,
|
|
"width": 512,
|
|
"height": 512,
|
|
"duration": None,
|
|
"created_at": "2026-05-01 12:00:00",
|
|
"updated_at": "2026-05-02 12:00:00",
|
|
"indexed_at": "2026-05-02 12:00:00",
|
|
"content_hash": "blake3:" + "a" * 64,
|
|
"phash": None,
|
|
"hash_algo": "blake3",
|
|
"enrichment_level": 2,
|
|
"job_id": "job-xyz",
|
|
"stack_id": None,
|
|
"workflow_id": "wf-1",
|
|
"source_node_id": "9",
|
|
"source_node_type": "SaveImage",
|
|
"tags": '["cat","cute"]',
|
|
"metadata_raw": '{"foo":"bar"}',
|
|
"workflow_type": "T2I",
|
|
"metadata_quality": "full",
|
|
"has_workflow": 1,
|
|
"has_generation_data": 1,
|
|
"generation_time_ms": 4200,
|
|
}
|
|
base.update(overrides)
|
|
return base
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# _row_to_asset (unit, no app)
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_row_to_asset_shapes_fields_correctly():
|
|
asset = api_v2_assets._row_to_asset(_row())
|
|
assert asset["id"] == "1"
|
|
assert asset["name"] == "pic.png"
|
|
assert asset["size"] == 1024
|
|
assert asset["width"] == 512
|
|
assert asset["height"] == 512
|
|
assert asset["mime_type"] == "image/png"
|
|
assert asset["tags"] == ["cat", "cute"]
|
|
assert asset["user_metadata"] == {"foo": "bar"}
|
|
assert asset["hash"] == "blake3:" + "a" * 64
|
|
assert asset["asset_hash"] == asset["hash"]
|
|
assert asset["job_id"] == "job-xyz"
|
|
assert asset["prompt_id"] == "job-xyz" # deprecated alias
|
|
assert asset["is_immutable"] is False
|
|
assert asset["created_at"].startswith("2026-05-01")
|
|
assert asset["metadata"]["workflow_id"] == "wf-1"
|
|
assert asset["metadata"]["source_node_type"] == "SaveImage"
|
|
|
|
|
|
def test_row_to_asset_does_not_leak_absolute_filepath():
|
|
# This is a network-reachable compat surface; the DB-recorded absolute
|
|
# filepath (local disk layout, drive letters, project/client folder
|
|
# names) must never appear in the response, only relative location info.
|
|
secret_path = "C:/Users/alice/output/client-secret/shot.png"
|
|
asset = api_v2_assets._row_to_asset(_row(filepath=secret_path))
|
|
assert "filepath" not in asset["metadata"]
|
|
assert secret_path not in json.dumps(asset)
|
|
assert "alice" not in json.dumps(asset)
|
|
assert "client-secret" not in json.dumps(asset)
|
|
|
|
|
|
def test_row_to_asset_omits_hash_when_algo_not_blake3():
|
|
asset = api_v2_assets._row_to_asset(
|
|
_row(hash_algo="sha256", content_hash="deadbeef")
|
|
)
|
|
assert "hash" not in asset
|
|
assert "asset_hash" not in asset
|
|
|
|
|
|
def test_resolve_asset_id_numeric_vs_blake3():
|
|
where, params = api_v2_assets._resolve_asset_id("42")
|
|
assert "a.id = ?" in where
|
|
assert params == (42,)
|
|
|
|
where, params = api_v2_assets._resolve_asset_id("blake3:" + "b" * 64)
|
|
assert "content_hash" in where
|
|
assert params == ("b" * 64, "blake3:" + "b" * 64)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Route-level
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_endpoint_returns_paginated_payload(monkeypatch):
|
|
def _handle(sql, params):
|
|
if "COUNT(*)" in sql:
|
|
return Result.Ok([{"n": 3}])
|
|
return Result.Ok([_row(id=1), _row(id=2, filename="other.jpg", ext=".jpg")])
|
|
|
|
async def _services():
|
|
return {"db": _FakeDB(_handle)}, None
|
|
|
|
monkeypatch.setattr(api_v2_assets, "_require_services", _services)
|
|
|
|
app = _build_app()
|
|
req = make_mocked_request("GET", "/api/v2/assets?limit=2&offset=0", app=app)
|
|
match = await app.router.resolve(req)
|
|
resp = await match.handler(req)
|
|
payload = json.loads(resp.text)
|
|
assert resp.status == 200
|
|
assert payload["total"] == 3
|
|
assert payload["has_more"] is True
|
|
assert len(payload["assets"]) == 2
|
|
assert payload["assets"][0]["name"] == "pic.png"
|
|
assert payload["assets"][1]["mime_type"] == "image/jpeg"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_endpoint_returns_404_when_missing(monkeypatch):
|
|
def _handle(sql, params):
|
|
return Result.Ok([])
|
|
|
|
async def _services():
|
|
return {"db": _FakeDB(_handle)}, None
|
|
|
|
monkeypatch.setattr(api_v2_assets, "_require_services", _services)
|
|
|
|
app = _build_app()
|
|
req = make_mocked_request("GET", "/api/v2/assets/999", app=app)
|
|
req.match_info["id"] = "999"
|
|
resp = await api_v2_assets._get_asset(req)
|
|
assert resp.status == 404
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_endpoint_returns_asset(monkeypatch):
|
|
def _handle(sql, params):
|
|
return Result.Ok([_row()])
|
|
|
|
async def _services():
|
|
return {"db": _FakeDB(_handle)}, None
|
|
|
|
monkeypatch.setattr(api_v2_assets, "_require_services", _services)
|
|
req = make_mocked_request("GET", "/api/v2/assets/1")
|
|
req.match_info["id"] = "1"
|
|
resp = await api_v2_assets._get_asset(req)
|
|
assert resp.status == 200
|
|
body = json.loads(resp.text)
|
|
assert body["id"] == "1"
|
|
assert body["tags"] == ["cat", "cute"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_hash_check_returns_200_on_match(monkeypatch):
|
|
def _handle(sql, params):
|
|
return Result.Ok([{"1": 1}])
|
|
|
|
async def _services():
|
|
return {"db": _FakeDB(_handle)}, None
|
|
|
|
monkeypatch.setattr(api_v2_assets, "_require_services", _services)
|
|
req = make_mocked_request("HEAD", f"/api/v2/assets/hash/{'a' * 64}")
|
|
req.match_info["hash"] = "a" * 64
|
|
resp = await api_v2_assets._check_asset_by_hash(req)
|
|
assert resp.status == 200
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_hash_check_returns_404_when_missing(monkeypatch):
|
|
def _handle(sql, params):
|
|
return Result.Ok([])
|
|
|
|
async def _services():
|
|
return {"db": _FakeDB(_handle)}, None
|
|
|
|
monkeypatch.setattr(api_v2_assets, "_require_services", _services)
|
|
req = make_mocked_request("HEAD", f"/api/v2/assets/hash/blake3:{'b' * 64}")
|
|
req.match_info["hash"] = "blake3:" + "b" * 64
|
|
resp = await api_v2_assets._check_asset_by_hash(req)
|
|
assert resp.status == 404
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_hash_check_rejects_malformed_input(monkeypatch):
|
|
async def _services():
|
|
return {"db": _FakeDB(lambda *_: Result.Ok([]))}, None
|
|
|
|
monkeypatch.setattr(api_v2_assets, "_require_services", _services)
|
|
req = make_mocked_request("HEAD", "/api/v2/assets/hash/notahash")
|
|
req.match_info["hash"] = "notahash"
|
|
resp = await api_v2_assets._check_asset_by_hash(req)
|
|
assert resp.status == 404
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tags_endpoint_returns_tags(monkeypatch):
|
|
def _handle(sql, params):
|
|
return Result.Ok([_row()])
|
|
|
|
async def _services():
|
|
return {"db": _FakeDB(_handle)}, None
|
|
|
|
monkeypatch.setattr(api_v2_assets, "_require_services", _services)
|
|
req = make_mocked_request("GET", "/api/v2/assets/1/tags")
|
|
req.match_info["id"] = "1"
|
|
resp = await api_v2_assets._get_asset_tags(req)
|
|
assert resp.status == 200
|
|
body = json.loads(resp.text)
|
|
assert body == {"tags": ["cat", "cute"]}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_content_endpoint_returns_404_when_path_disallowed(monkeypatch):
|
|
def _handle(sql, params):
|
|
return Result.Ok([_row(filepath="/nowhere/missing.png")])
|
|
|
|
async def _services():
|
|
return {"db": _FakeDB(_handle)}, None
|
|
|
|
monkeypatch.setattr(api_v2_assets, "_require_services", _services)
|
|
monkeypatch.setattr(api_v2_assets, "_is_path_allowed", lambda *a, **kw: False)
|
|
|
|
req = make_mocked_request("GET", "/api/v2/assets/1/content")
|
|
req.match_info["id"] = "1"
|
|
resp = await api_v2_assets._get_asset_content(req)
|
|
assert resp.status == 404
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# workflow_id hoist (top-level + user_metadata fallback)
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_row_to_asset_hoists_workflow_id_to_top_level():
|
|
asset = api_v2_assets._row_to_asset(_row(workflow_id="wf-direct"))
|
|
assert asset["workflow_id"] == "wf-direct"
|
|
assert asset["metadata"]["workflow_id"] == "wf-direct"
|
|
|
|
|
|
def test_row_to_asset_falls_back_to_user_metadata_workflow_id():
|
|
raw = json.dumps({"workflow": {"id": "wf-from-meta"}, "foo": "bar"})
|
|
asset = api_v2_assets._row_to_asset(_row(workflow_id="", metadata_raw=raw))
|
|
assert asset["workflow_id"] == "wf-from-meta"
|
|
|
|
|
|
def test_row_to_asset_omits_workflow_id_when_absent_everywhere():
|
|
asset = api_v2_assets._row_to_asset(_row(workflow_id="", metadata_raw="{}"))
|
|
assert "workflow_id" not in asset
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# /api/v2/assets/seed/status
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_seed_status_reports_totals_and_breakdown(monkeypatch):
|
|
def _handle(sql, params):
|
|
if "GROUP BY enrichment_level" in sql:
|
|
return Result.Ok([
|
|
{"lvl": 0, "n": 4},
|
|
{"lvl": 1, "n": 7},
|
|
{"lvl": 2, "n": 11},
|
|
])
|
|
if "COUNT(*)" in sql:
|
|
return Result.Ok([{"n": 22}])
|
|
return Result.Ok([])
|
|
|
|
async def _services():
|
|
return {"db": _FakeDB(_handle)}, None
|
|
|
|
monkeypatch.setattr(api_v2_assets, "_require_services", _services)
|
|
req = make_mocked_request("GET", "/api/v2/assets/seed/status")
|
|
resp = await api_v2_assets._seed_status(req)
|
|
body = json.loads(resp.text)
|
|
if resp.status != 200:
|
|
pytest.fail(f"expected 200, got {resp.status}: {body}")
|
|
if body["state"] != "idle":
|
|
pytest.fail(f"expected state=idle, got {body['state']}")
|
|
if body["total"] != 22:
|
|
pytest.fail(f"expected total=22, got {body['total']}")
|
|
if body["enriched"] != 18:
|
|
pytest.fail(f"expected enriched=18 (lvl>=1 = 7+11), got {body['enriched']}")
|
|
if body["pending"] != 4:
|
|
pytest.fail(f"expected pending=4 (lvl 0), got {body['pending']}")
|
|
if body["by_enrichment_level"] != {"0": 4, "1": 7, "2": 11}:
|
|
pytest.fail(f"unexpected breakdown: {body['by_enrichment_level']}")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_seed_status_handles_empty_db(monkeypatch):
|
|
def _handle(sql, params):
|
|
if "GROUP BY enrichment_level" in sql:
|
|
return Result.Ok([])
|
|
if "COUNT(*)" in sql:
|
|
return Result.Ok([{"n": 0}])
|
|
return Result.Ok([])
|
|
|
|
async def _services():
|
|
return {"db": _FakeDB(_handle)}, None
|
|
|
|
monkeypatch.setattr(api_v2_assets, "_require_services", _services)
|
|
req = make_mocked_request("GET", "/api/v2/assets/seed/status")
|
|
resp = await api_v2_assets._seed_status(req)
|
|
body = json.loads(resp.text)
|
|
if resp.status != 200:
|
|
pytest.fail(f"expected 200, got {resp.status}: {body}")
|
|
if body["total"] != 0 or body["enriched"] != 0 or body["pending"] != 0:
|
|
pytest.fail(f"expected zeros, got {body}")
|
|
if body["by_enrichment_level"] != {}:
|
|
pytest.fail(f"expected empty dict, got {body['by_enrichment_level']}")
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# /api/v2/assets/tags/refine
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tags_refine_returns_histogram(monkeypatch):
|
|
captured: dict = {}
|
|
|
|
def _handle(sql, params):
|
|
captured["sql"] = sql
|
|
captured["params"] = params
|
|
return Result.Ok([
|
|
{"name": "cat", "count": 12},
|
|
{"name": "cute", "count": 9},
|
|
{"name": "dog", "count": 4},
|
|
])
|
|
|
|
async def _services():
|
|
return {"db": _FakeDB(_handle)}, None
|
|
|
|
monkeypatch.setattr(api_v2_assets, "_require_services", _services)
|
|
req = make_mocked_request(
|
|
"GET",
|
|
"/api/v2/assets/tags/refine?include_tags=cat&exclude_tags=blurry&limit=10",
|
|
)
|
|
resp = await api_v2_assets._tags_refine(req)
|
|
body = json.loads(resp.text)
|
|
if resp.status != 200:
|
|
pytest.fail(f"expected 200, got {resp.status}: {body}")
|
|
if body["total"] != 3:
|
|
pytest.fail(f"expected total=3, got {body['total']}")
|
|
if body["tags"][0] != {"name": "cat", "count": 12}:
|
|
pytest.fail(f"unexpected top tag: {body['tags'][0]}")
|
|
# SQL should reference the normalized tables and pass the limit last.
|
|
if "asset_tags at" not in captured["sql"] or "tags t" not in captured["sql"]:
|
|
pytest.fail(f"SQL missing normalized joins: {captured['sql']}")
|
|
if captured["params"][-1] != 10:
|
|
pytest.fail(f"limit not appended, got params={captured['params']}")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tags_refine_handles_no_filters(monkeypatch):
|
|
def _handle(sql, params):
|
|
return Result.Ok([])
|
|
|
|
async def _services():
|
|
return {"db": _FakeDB(_handle)}, None
|
|
|
|
monkeypatch.setattr(api_v2_assets, "_require_services", _services)
|
|
req = make_mocked_request("GET", "/api/v2/assets/tags/refine")
|
|
resp = await api_v2_assets._tags_refine(req)
|
|
body = json.loads(resp.text)
|
|
if resp.status != 200:
|
|
pytest.fail(f"expected 200, got {resp.status}: {body}")
|
|
if body != {"tags": [], "total": 0}:
|
|
pytest.fail(f"unexpected empty payload: {body}")
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Route registration: static paths must resolve before /{id}
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_seed_status_is_not_eaten_by_id_route(monkeypatch):
|
|
"""Regression guard: /seed/status must hit _seed_status, not _get_asset."""
|
|
|
|
async def _services():
|
|
return {"db": _FakeDB(lambda *_: Result.Ok([{"n": 0}]))}, None
|
|
|
|
monkeypatch.setattr(api_v2_assets, "_require_services", _services)
|
|
app = _build_app()
|
|
req = make_mocked_request("GET", "/api/v2/assets/seed/status", app=app)
|
|
match = await app.router.resolve(req)
|
|
if match.handler is not api_v2_assets._seed_status:
|
|
pytest.fail(f"seed/status routed to {match.handler.__name__}")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tags_refine_is_not_eaten_by_id_route(monkeypatch):
|
|
async def _services():
|
|
return {"db": _FakeDB(lambda *_: Result.Ok([]))}, None
|
|
|
|
monkeypatch.setattr(api_v2_assets, "_require_services", _services)
|
|
app = _build_app()
|
|
req = make_mocked_request("GET", "/api/v2/assets/tags/refine", app=app)
|
|
match = await app.router.resolve(req)
|
|
if match.handler is not api_v2_assets._tags_refine:
|
|
pytest.fail(f"tags/refine routed to {match.handler.__name__}")
|
|
# Versioned API errors use HTTP semantics even though legacy /mjr/am routes
|
|
# retain their historical HTTP-200 envelope for compatibility.
|
|
def test_api_error_response_maps_stable_codes_to_http_statuses():
|
|
invalid = api_v2_assets._api_error_response(Result.Err("INVALID_INPUT", "Bad request"))
|
|
unavailable = api_v2_assets._api_error_response(
|
|
Result.Err("SERVICE_UNAVAILABLE", "Database unavailable")
|
|
)
|
|
|
|
assert invalid.status == 400
|
|
assert unavailable.status == 503
|