"""Image annotation routes: observe, capture preview and tag save. Every request names its file through the listing identity ``{root_index, root_scope, path}``; the scope is checked before any file access, tag reads and saves are byte-exact (the capture's digest, re-hashed at Save) and no response ever carries a server path or a locator. """ import asyncio import hashlib import json import os import sqlite3 import sys import tempfile import threading import types import unittest import uuid from pathlib import Path from unittest.mock import AsyncMock, patch from aiohttp.test_utils import make_mocked_request # Discovery imports this module before tests/test_api_autotag.py, whose # ``folder_paths`` stub (a real module, so ``patch(..., create=True)`` can add # and remove attributes) the later modules rely on. Install the same stub # before test_api_images would install a MagicMock in its place. if "folder_paths" not in sys.modules: _stub = types.ModuleType("folder_paths") _stub.models_dir = tempfile.gettempdir() _stub.base_path = tempfile.gettempdir() _stub.get_output_directory = tempfile.gettempdir sys.modules["folder_paths"] = _stub try: from test_api_images import ImageAPITestCase, make_png from open_handles import assert_closed, open_handles_under except ImportError: from tests.test_api_images import ImageAPITestCase, make_png from tests.open_handles import assert_closed, open_handles_under from database.execution_history_schema import rotate_history_epoch from utils import media_capture from utils.media_capture import CaptureStore try: from py.api import annotations as annotations_module except ImportError: # the module under test is not there yet (RED) annotations_module = None OBSERVE = "/prompt_manager/image-annotations/observe" TAGS = "/prompt_manager/image-annotations/tags" CAPTURE = "/prompt_manager/image-annotations/capture/" MAX_BODY = 64 * 1024 WAIT = 30.0 # ceiling for cross-thread handoffs, never a short clock # Columns that an observe may legitimately refresh (S1) or that never matter # for "nothing changed" comparisons. _SNAPSHOT_TABLES = { "prompts": "*", "generated_images": "*", "tags": "*", "prompt_tags": "*", "prompt_pair_tags": "*", "image_favorites": "*", "media_annotation_files": "id, locator", "media_annotation_bytes": "id, file_id, sha256, first_seen_at, tag_revision", "media_image_tags": "*", } class FakeClock: def __init__(self, start=1000.0): self.now = start def __call__(self): return self.now class AnnotationAPITestCase(ImageAPITestCase): """The image API app plus helpers for the annotation routes.""" async def get_application(self): app = await super().get_application() self.assertIsNotNone( annotations_module, "py.api.annotations is not implemented" ) self.epoch = self.api.db.get_execution_epoch() return app async def tearDownAsync(self): store = getattr(self.api, "_captures", None) if store is not None: # Every preview handle must already be closed (Windows unlink). assert_closed(self, store.directory) self.api.close_annotation_captures() if store is not None: self.assertFalse(os.path.exists(store.directory)) await super().tearDownAsync() # -- helpers --------------------------------------------------------- def scope(self, index=0, roots=None): roots = roots if roots is not None else self.api._get_all_output_dirs() return self.api._gallery_root_scope(roots[index], index) def target(self, path="sub/a.png", **changes): return {"root_index": 0, "root_scope": self.scope(), "path": path, **changes} async def json_of(self, response): """The JSON envelope; an unrouted path (no JSON) fails as an assertion.""" self.assertEqual( response.content_type, "application/json", f"{response.method} {response.url.path} answered {response.status}", ) return await response.json() async def observe(self, body): response = await self.client.post(OBSERVE, json=body) return response.status, await self.json_of(response) async def observe_ok(self, path="sub/a.png"): status, body = await self.observe({"target": self.target(path)}) self.assertEqual(status, 200, body) return body async def put_tags(self, body): response = await self.client.put(TAGS, json=body) return response.status, await self.json_of(response) def save_body(self, observed, tags, **changes): return { "target": dict(observed["target"]), "capture_token": observed["capture"]["token"], "sha256": observed["capture"]["sha256"], "expected_epoch": observed["library_epoch"], "expected_tag_revision": observed["tag_revision"], "tags": list(tags), **changes, } def locator(self, path): return os.path.normcase(os.path.realpath(str(self.output_dir / path))) def write_bytes(self, path, data, *, keep_mtime=False): """Rewrite *path* in place, optionally restoring its ``mtime_ns``.""" full = self.output_dir / path before = full.stat() full.write_bytes(data) if keep_mtime: os.utime(full, ns=(before.st_atime_ns, before.st_mtime_ns)) return full def db_snapshot(self): conn = sqlite3.connect(self._temp_db.name) try: snapshot = {} for table, columns in _SNAPSHOT_TABLES.items(): exists = conn.execute( "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,), ).fetchone() if exists is None: continue rows = conn.execute(f"SELECT {columns} FROM {table}").fetchall() snapshot[table] = sorted(map(repr, rows)) return snapshot finally: conn.close() def bytes_rows(self): conn = sqlite3.connect(self._temp_db.name) try: return conn.execute( "SELECT sha256, size, mtime_ns, tag_revision FROM " "media_annotation_bytes ORDER BY first_seen_at" ).fetchall() finally: conn.close() def saved_set(self, path, data, tags): """Store *tags* for the exact *data* bytes of *path*; return the receipt.""" full = self.output_dir / path stat = full.stat() return self.api.db.replace_image_tags( locator=self.locator(path), sha256=hashlib.sha256(data).hexdigest(), size=stat.st_size, mtime_ns=stat.st_mtime_ns, expected_epoch=self.epoch, expected_tag_revision=0, tags=tags, ) def use_fake_clock(self): clock = FakeClock() self.api.close_annotation_captures() self.api._captures = CaptureStore(clock=clock) return clock def assert_no_paths(self, body): text = json.dumps(body) self.assertNotIn(str(self.comfy_dir), text) self.assertNotIn(str(self.output_dir), text) self.assertNotIn(os.path.realpath(self.output_dir), text) store = getattr(self.api, "_captures", None) if store is not None: self.assertNotIn(store.directory, text) self.assertNotIn("\\\\", text.replace('\\"', "")) self.assertNotIn(os.sep + "output" + os.sep, text) class TestObserve(AnnotationAPITestCase): async def test_observe_requires_valid_scope_before_any_file_access(self): make_png(self.output_dir / "sub" / "a.png") other = self.comfy_dir / "other" other.mkdir() make_png(other / "sub" / "a.png") stale_scope = self.api._gallery_root_scope(other, 0) reordered = self.api._gallery_root_scope(self.output_dir, 1) with patch.object( media_capture.CaptureStore, "capture", autospec=True ) as capture: capture.side_effect = AssertionError("capture must not run") for name, changes, status in ( ("stale root", {"root_scope": stale_scope}, 409), ("reordered root", {"root_scope": reordered}, 409), ("removed root", {"root_index": 1, "root_scope": reordered}, 409), ("wrong scope", {"root_scope": "0" * 64}, 409), ("uppercase scope", {"root_scope": "A" * 64}, 400), ("short scope", {"root_scope": "abc"}, 400), ("bool index", {"root_index": True}, 400), ("negative index", {"root_index": -1}, 400), ("string index", {"root_index": "0"}, 400), ("traversal", {"path": "../a.png"}, 403), ("dot segment", {"path": "./a.png"}, 403), ("empty segment", {"path": "sub//a.png"}, 403), ("absolute", {"path": "/etc/a.png"}, 403), ("backslash", {"path": "sub\\a.png"}, 403), ("drive", {"path": "C:/a.png"}, 403), ("drive segment", {"path": "sub/C:a.png"}, 403), ("nul", {"path": "a\x00.png"}, 403), ("non-media", {"path": "notes.txt"}, 403), ("empty path", {"path": ""}, 400), ("long path", {"path": "a/" * 600 + "a.png"}, 400), ("int path", {"path": 7}, 400), ): with self.subTest(name): status_, body = await self.observe( {"target": self.target(**changes)} ) self.assertEqual(status_, status, body) self.assertFalse(body["success"]) if status == 409: self.assertEqual(body["code"], "source-changed") self.assert_no_paths(body) for name, body in ( ("missing target", {}), ("extra field", {"target": self.target(), "extra": 1}), ("target not an object", {"target": "sub/a.png"}), ("target missing path", {"target": {"root_index": 0}}), ("target extra field", {"target": self.target(sha256="x")}), ): with self.subTest(name): status_, body_ = await self.observe(body) self.assertEqual(status_, 400, body_) self.assertFalse(body_["success"]) with self.subTest("body over 64 KiB"): padding = "x" * MAX_BODY response = await self.client.post( OBSERVE, data=json.dumps({"target": self.target(), "pad": padding}), headers={"Content-Type": "application/json"}, ) self.assertEqual(response.status, 413) with self.subTest("not json"): response = await self.client.post( OBSERVE, data=b"{oops", headers={"Content-Type": "application/json"} ) self.assertEqual(response.status, 400) capture.assert_not_called() async def test_observe_denies_case_spellings_of_the_listed_file(self): make_png(self.output_dir / "sub" / "a.png") for alias in ("sub/A.PNG", "SUB/a.png"): with self.subTest(alias=alias): status, body = await self.observe({"target": self.target(alias)}) # Denied as an alias, like a symlink to the listed file. self.assertEqual((status, body["code"]), (409, "source-changed")) self.assert_no_paths(body) store = self.api._captures self.assertFalse(any(Path(store.directory).iterdir())) await self.observe_ok("sub/a.png") async def test_observe_returns_saved_tags_for_exact_bytes_only(self): path = make_png(self.output_dir / "sub" / "a.png") original = path.read_bytes() receipt = self.saved_set("sub/a.png", original, ["Forest", "mist"]) self.assertEqual(receipt["tag_revision"], 1) body = await self.observe_ok() self.assertEqual(body["tags"], ["Forest", "mist"]) self.assertEqual(body["tag_revision"], 1) self.assertEqual(body["library_epoch"], self.epoch) self.assertEqual(body["filename"], "a.png") self.assertIsNone(body["title"]) self.assertEqual( body["target"], {"root_index": 0, "root_scope": self.scope(), "path": "sub/a.png"}, ) capture = body["capture"] self.assertEqual( sorted(capture), sorted( [ "token", "expires_at", "preview_url", "sha256", "size", "width", "height", "media_type", "generate", ] ), ) self.assertEqual(capture["sha256"], hashlib.sha256(original).hexdigest()) self.assertEqual(capture["size"], len(original)) self.assertEqual((capture["width"], capture["height"]), (4, 4)) self.assertEqual(capture["media_type"], "image") self.assertEqual(capture["generate"], {"supported": True, "reason": None}) self.assertEqual(capture["preview_url"], CAPTURE + capture["token"]) self.assertTrue(capture["expires_at"].endswith("Z")) # Different bytes at the same path: an empty set at revision 0. changed = make_png(path, color=(0, 255, 0)).read_bytes() self.assertNotEqual(changed, original) body = await self.observe_ok() self.assertEqual((body["tags"], body["tag_revision"]), ([], 0)) self.assertEqual(body["capture"]["sha256"], hashlib.sha256(changed).hexdigest()) # The original bytes again (new mtime): the saved set, and S1 refreshes # the record's verified fingerprint so cheap reads see it as current. self.write_bytes("sub/a.png", original) stat = path.stat() current = self.api.db.current_image_tags( {self.locator("sub/a.png"): (stat.st_size, stat.st_mtime_ns)} ) body = await self.observe_ok() self.assertEqual((body["tags"], body["tag_revision"]), (["Forest", "mist"], 1)) refreshed = self.api.db.current_image_tags( {self.locator("sub/a.png"): (stat.st_size, stat.st_mtime_ns)} ) if stat.st_mtime_ns != self.bytes_rows()[0][2]: self.assertEqual(current, {}) self.assertEqual(refreshed, {self.locator("sub/a.png"): ["Forest", "mist"]}) async def test_observe_changes_no_tags_revisions_prompts_or_links(self): path = make_png(self.output_dir / "sub" / "a.png") data = path.read_bytes() self.saved_set("sub/a.png", data, ["forest"]) make_png(self.output_dir / "sub" / "b.png", color=(1, 2, 3)) self._link_image(self.output_dir / "sub" / "a.png", "a prompt") prompt_id = self._save_prompt("tagged prompt", tags=["forest", "night"]) self.assertTrue(prompt_id) before = self.db_snapshot() for _ in range(2): await self.observe_ok("sub/a.png") await self.observe_ok("sub/b.png") self.assertEqual(self.db_snapshot(), before) # Unseen bytes (b.png) never create a record. self.assertEqual(len(self.bytes_rows()), 1) self.assertEqual(self.bytes_rows()[0][3], 1) async def test_observe_title_follows_the_card_rule(self): make_png(self.output_dir / "sub" / "a.png") prompt_id = self._save_prompt("Same words\nrest") self.api.db.link_image_to_prompt(prompt_id, str(self.output_dir / "sub/a.png")) body = await self.observe_ok() # A first-line title is withheld unless the file's own records confirm # it (the card rule); this PNG carries no prompt metadata. self.assertIsNone(body["title"]) with self.api.db.model.get_connection() as connection: connection.execute( "INSERT INTO prompt_library_state(prompt_id,title) VALUES(?,?)", (prompt_id, "Same words"), ) # A stored title is user data and always shows. body = await self.observe_ok() self.assertEqual(body["title"], "Same words") async def test_observe_video_is_manual_only(self): clip = self.output_dir / "clip.mp4" clip.parent.mkdir(exist_ok=True) clip.write_bytes(b"\x00\x00\x00\x18ftypmp42" + b"\x00" * 64) body = await self.observe_ok("clip.mp4") capture = body["capture"] self.assertEqual(capture["media_type"], "video") self.assertEqual((capture["width"], capture["height"]), (None, None)) self.assertEqual( capture["generate"], {"supported": False, "reason": media_capture.VIDEO_ONLY_REASON}, ) self.assertIsNone(body["title"]) response = await self.client.get(capture["preview_url"]) self.assertEqual(response.status, 200) self.assertEqual(response.headers["Content-Type"], "video/mp4") self.assertEqual(await response.read(), clip.read_bytes()) async def test_observe_maps_capture_outcomes_to_codes(self): make_png(self.output_dir / "sub" / "a.png") outside = make_png(self.comfy_dir / "outside.png") self._make_symlink(outside, self.output_dir / "sub" / "alias.png") status, body = await self.observe({"target": self.target("sub/alias.png")}) self.assertEqual((status, body["code"]), (409, "source-changed")) self.assert_no_paths(body) status, body = await self.observe({"target": self.target("sub/missing.png")}) self.assertEqual((status, body["code"]), (409, "file-changed")) self.assert_no_paths(body) self.api.close_annotation_captures() self.api._captures = CaptureStore(max_file=16) status, body = await self.observe({"target": self.target("sub/a.png")}) self.assertEqual((status, body["code"]), (415, "unsupported-media")) self.assert_no_paths(body) self.api.close_annotation_captures() self.api._captures = CaptureStore(max_captures=1) await self.observe_ok("sub/a.png") status, body = await self.observe({"target": self.target("sub/a.png")}) self.assertEqual((status, body["code"]), (429, "review-capacity")) self.assert_no_paths(body) class TestCaptureRoutes(AnnotationAPITestCase): async def test_capture_route_serves_exact_bytes_no_store_nosniff(self): path = make_png(self.output_dir / "sub" / "a.png") source_bytes = path.read_bytes() body = await self.observe_ok() token = body["capture"]["token"] # The source may change after the capture; the preview never does. make_png(path, color=(9, 9, 9)) resp = await self.client.get(body["capture"]["preview_url"]) self.assertEqual(resp.status, 200) self.assertEqual(resp.headers["Cache-Control"], "no-store") self.assertEqual(resp.headers["X-Content-Type-Options"], "nosniff") self.assertEqual(resp.headers["Content-Type"], "image/png") self.assertEqual(resp.headers["Content-Length"], str(len(source_bytes))) self.assertEqual(await resp.read(), source_bytes) store = self.api._captures entry = store._entries[token] self.assertEqual(entry.uses, 0) self.assertEqual(open_handles_under(store.directory), []) self.assertIsNotNone(store.get(token)) async def test_capture_route_rejects_unknown_expired_and_rescoped_tokens(self): make_png(self.output_dir / "sub" / "a.png") clock = self.use_fake_clock() for token in ("nope", "A" * 300, "x.y", "%2e%2e%2fx", "a b"): with self.subTest(token=token): resp = await self.client.get(CAPTURE + token) self.assertEqual(resp.status, 404) data = await self.json_of(resp) self.assertEqual(data["code"], "capture-expired") self.assertEqual(resp.headers["Cache-Control"], "no-store") body = await self.observe_ok() url = body["capture"]["preview_url"] self.assertEqual((await self.client.get(url)).status, 200) # Released explicitly: gone, and releasing again stays successful. resp = await self.client.delete(url) self.assertEqual( (resp.status, await self.json_of(resp)), (200, {"success": True}) ) self.assertEqual((await self.client.get(url)).status, 404) resp = await self.client.delete(url) self.assertEqual( (resp.status, await self.json_of(resp)), (200, {"success": True}) ) # Idle expiry. body = await self.observe_ok() url = body["capture"]["preview_url"] clock.now += media_capture.DEFAULT_IDLE_SECONDS + 1 resp = await self.client.get(url) self.assertEqual(resp.status, 404) self.assertEqual((await self.json_of(resp))["code"], "capture-expired") # The roots changed under a live token: denied and released. body = await self.observe_ok() url = body["capture"]["preview_url"] moved = self.comfy_dir / "moved" moved.mkdir() original_roots = self.api._get_all_output_dirs self.api._get_all_output_dirs = lambda: [moved] try: resp = await self.client.get(url) self.assertEqual(resp.status, 409) data = await self.json_of(resp) self.assertEqual(data["code"], "source-changed") self.assert_no_paths(data) finally: self.api._get_all_output_dirs = original_roots resp = await self.client.get(url) self.assertEqual(resp.status, 404) self.assertFalse(any(Path(self.api._captures.directory).iterdir())) async def test_preview_pins_the_capture_while_it_streams(self): path = make_png(self.output_dir / "sub" / "a.png", size=(64, 64)) source_bytes = path.read_bytes() body = await self.observe_ok() token = body["capture"]["token"] store = self.api._captures capture_path = store.get(token).path stream = annotations_module.AnnotationRoutesMixin._stream_capture seen = {} async def release_mid_stream(request, capture, handle): # A concurrent DELETE (or sweep) lands while the bytes stream. self.assertEqual(store._entries[token].uses, 1) self.assertTrue(store.release(token)) seen["kept"] = os.path.exists(capture_path) return await stream(request, capture, handle) with ( patch.object( annotations_module.AnnotationRoutesMixin, "_stream_capture", staticmethod(release_mid_stream), ), patch.object(annotations_module, "_PREVIEW_CHUNK", 16), ): resp = await self.client.get(body["capture"]["preview_url"]) self.assertEqual(resp.status, 200) self.assertEqual(await resp.read(), source_bytes) self.assertTrue(seen["kept"]) # Removed with the last use, after its handle closed. self.assertFalse(os.path.exists(capture_path)) self.assertEqual( (await self.client.get(body["capture"]["preview_url"])).status, 404 ) async def test_preview_answers_byte_ranges(self): # Safari and WebKit play