Files
ComfyAssets-ComfyUI_PromptM…/tests/test_api_review_link.py
T
Vito Sansevero 4a25db545c test(trash): classify the Needs review link route and prove it refuses a trashed file
POST /review/items/{id}/link needs a capture of the item's bytes. A capture is refused for a path in a trash folder, and a capture taken earlier stops working when its file moves to a trash folder or its folder becomes a link to one, so no code change is needed. The decision route takes the same path, so it moves to the same audit group.
2026-10-30 16:54:50 -07:00

395 lines
16 KiB
Python

"""Needs review: link an image to a library prompt by hand (#536).
The link is the reviewer's choice, never file evidence: it needs the same
capture of the same bytes as Apply, keeps the one-prompt-per-file rules, and
records provenance that cannot pose as what the file says.
"""
import json
import os
import unittest
try:
from tests.test_api_review import ITEMS_URL, ReviewRoutesTestCase
from tests.test_review import digest, unlinkable_graph, write_png
except ImportError: # discovered with tests/ as the top-level directory
from test_api_review import ITEMS_URL, ReviewRoutesTestCase
from test_review import digest, unlinkable_graph, write_png
from utils.hashing import generate_prompt_hash
class LinkTestCase(ReviewRoutesTestCase):
def _pair(self, positive="harbor", negative="blurry"):
pair_id = self.api.db.save_prompt_pair(positive, negative)
pair = self.api.db.get_prompt_pair(pair_id)
return pair["positive_prompt_id"], pair_id
def _link_body(self, detail, observed, positive_id, pair_id):
return {
"expected_epoch": observed["library_epoch"],
"expected_revision": detail["revision"],
"capture_token": observed["capture"]["token"],
"sha256": observed["capture"]["sha256"],
"dependency_fingerprint": detail["dependency_fingerprint"],
"positive_id": positive_id,
"pair_id": pair_id,
}
async def _link(self, item_id, body):
return await self._json("POST", f"{ITEMS_URL}/{item_id}/link", json=body)
async def _queued(self):
self._record(self._ambiguous("two.png"))
return await self._open()
async def _refused(self, item, body, status, code):
before = self._snapshot()
got, data = await self._link(item["id"], body)
self.assertEqual(got, status, data)
self.assertEqual(data.get("code"), code, data)
self.assertEqual(self._snapshot(), before)
return data
class TestLink(LinkTestCase):
async def test_links_the_chosen_recipe_and_records_a_manual_decision(self):
positive_id, pair_id = self._pair()
path = self.output_dir / "two.png"
item, detail, observed = await self._queued()
revision = self._review_revision()
status, data = await self._link(
item["id"], self._link_body(detail, observed, positive_id, pair_id)
)
self.assertEqual(status, 200, data)
self.assertEqual(data["total"], 0)
self.assertEqual(data["item"]["state"], "selected")
self.assertEqual(data["pair"]["id"], pair_id)
conn = self.api.db.model.get_connection()
image = conn.execute("SELECT id, prompt_id FROM generated_images").fetchone()
self.assertEqual(image[1], positive_id)
(link,) = self.api.db.get_image_prompt_pairs(image[0])
(decision,) = self._decisions()
self.assertEqual(link["pair_id"], pair_id)
self.assertEqual(
link["provenance"],
{
"kind": "manual-link",
"decision_id": decision["id"],
"reviewed_digest": digest(path),
"resolver_version": detail_version(self, item),
"positive_id": positive_id,
"pair_id": pair_id,
},
)
self.assertEqual(decision["outcome"], "selected")
self.assertRegex(decision["candidate_hash"], r"^[0-9a-f]{64}$")
self.assertNotIn(
decision["candidate_hash"], [c["hash"] for c in detail["candidates"]]
)
self.assertEqual(decision["pair_id"], pair_id)
evidence = json.loads(decision["evidence"])
self.assertEqual(evidence["kind"], "manual-link")
self.assertIsNone(evidence["candidate"])
self.assertEqual(
evidence["manual"], {"positive_id": positive_id, "pair_id": pair_id}
)
self.assertEqual(self._review_revision(), revision + 1)
self._no_paths()
async def test_a_manual_link_does_not_pose_as_file_evidence(self):
positive_id, pair_id = self._pair()
path = self.output_dir / "two.png"
item, detail, observed = await self._queued()
status, data = await self._link(
item["id"], self._link_body(detail, observed, positive_id, pair_id)
)
self.assertEqual(status, 200, data)
saved = self.api.db.get_image_prompt_info(str(path))
self.assertEqual(saved["text"], "harbor")
self.assertIsNone(saved.get("pair_context"))
async def test_no_negative_links_a_pair_without_one(self):
positive_id, _pair_id = self._pair()
item, detail, observed = await self._queued()
status, data = await self._link(
item["id"], self._link_body(detail, observed, positive_id, None)
)
self.assertEqual(status, 200, data)
pair = self.api.db.get_prompt_pair(data["pair"]["id"])
self.assertEqual(pair["positive_prompt_id"], positive_id)
self.assertIsNone(pair["negative_prompt_id"])
decision = self._decisions()[0]
self.assertEqual(
json.loads(decision["evidence"])["manual"]["pair_id"], data["pair"]["id"]
)
async def test_a_prompt_without_recipes_can_be_linked(self):
prompt_id = self.api.db.save_prompt(
"lone", prompt_hash=generate_prompt_hash("lone")
)
item, detail, observed = await self._queued()
status, data = await self._link(
item["id"], self._link_body(detail, observed, prompt_id, None)
)
self.assertEqual(status, 200, data)
async def test_an_existing_row_of_the_same_prompt_is_reused(self):
positive_id, pair_id = self._pair()
path = self._ambiguous("two.png")
image_id = self.api.db.link_image_to_prompt(positive_id, str(path))
self._record(path)
item, detail, observed = await self._open()
status, data = await self._link(
item["id"], self._link_body(detail, observed, positive_id, pair_id)
)
self.assertEqual(status, 200, data)
self.assertEqual(data["pair"]["image_id"], image_id)
async def test_a_candidates_own_item_can_still_be_linked_after_a_refusal(self):
positive_id, pair_id = self._pair()
item, detail, observed = await self._queued()
body = self._link_body(detail, observed, positive_id, pair_id)
await self._refused(
item, {**body, "pair_id": pair_id + 99}, 409, "prompt-missing"
)
status, data = await self._link(item["id"], body)
self.assertEqual(status, 200, data)
async def test_a_later_scan_leaves_the_link_and_does_not_queue_it(self):
positive_id, pair_id = self._pair()
path = self.output_dir / "two.png"
item, detail, observed = await self._queued()
status, data = await self._link(
item["id"], self._link_body(detail, observed, positive_id, pair_id)
)
self.assertEqual(status, 200, data)
before = self._snapshot()
self.assertIn(self._record_raw(path), ("decided", "duplicate"))
self.assertEqual(self._snapshot(), before)
self.assertEqual((await self._list())["total"], 0)
class TestLinkRefusals(LinkTestCase):
async def test_another_positive_on_the_file_is_an_association_conflict(self):
positive_id, pair_id = self._pair()
path = self._ambiguous("two.png")
other = self.api.db.save_prompt(
"other", prompt_hash=generate_prompt_hash("other")
)
self.api.db.link_image_to_prompt(other, str(path))
self._record(path)
item, detail, observed = await self._open()
body = self._link_body(detail, observed, positive_id, pair_id)
data = await self._refused(item, body, 409, "association-conflict")
self.assertIn("linked", data["error"])
async def test_a_prior_link_with_another_negative_is_an_association_conflict(self):
positive_id, pair_id = self._pair()
_same, other_pair = self._pair(negative="grainy")
path = self._ambiguous("two.png")
image_id = self.api.db.link_image_to_prompt(positive_id, str(path))
self.api.db.link_image_to_pair(other_pair, image_id, {"kind": "test"})
self._record(path)
item, detail, observed = await self._open()
body = self._link_body(detail, observed, positive_id, pair_id)
await self._refused(item, body, 409, "association-conflict")
async def test_changed_links_since_opening_are_refused(self):
positive_id, pair_id = self._pair()
path = self.output_dir / "two.png"
item, detail, observed = await self._queued()
other = self.api.db.save_prompt(
"other", prompt_hash=generate_prompt_hash("other")
)
self.api.db.link_image_to_prompt(other, str(path))
body = self._link_body(detail, observed, positive_id, pair_id)
await self._refused(item, body, 409, "association-conflict")
async def test_unusable_choices_are_refused_with_zero_writes(self):
positive_id, pair_id = self._pair()
foreign_positive, foreign_pair = self._pair("elsewhere", "bad")
negative_only = self.api.db.get_prompt_pair(pair_id)["negative_prompt_id"]
archived_positive, archived_pair = self._pair("old", "stale")
self.api.db.archive_prompt_pair(archived_pair)
item, detail, observed = await self._queued()
for chosen_positive, chosen_pair in (
(positive_id, foreign_pair),
(positive_id, pair_id + 99),
(positive_id + 99, None),
(negative_only, None),
(archived_positive, archived_pair),
):
body = self._link_body(detail, observed, chosen_positive, chosen_pair)
await self._refused(item, body, 409, "prompt-missing")
self.assertTrue(foreign_positive)
async def test_an_archived_prompt_is_refused(self):
positive_id, pair_id = self._pair()
conn = self.api.db.model.get_connection()
with conn:
conn.execute(
"INSERT INTO prompt_archive(prompt_id,archived_at) "
"VALUES(?,'2026-01-01T00:00:00Z')",
(positive_id,),
)
item, detail, observed = await self._queued()
body = self._link_body(detail, observed, positive_id, pair_id)
await self._refused(item, body, 409, "prompt-missing")
async def test_a_stale_stored_hash_never_links_another_row(self):
positive_id, _pair_id = self._pair()
other = self.api.db.save_prompt(
"other", prompt_hash=generate_prompt_hash("other")
)
conn = self.api.db.model.get_connection()
with conn:
conn.execute(
"UPDATE prompts SET hash=? WHERE id=?",
(generate_prompt_hash("elsewhere"), other),
)
conn.execute(
"UPDATE prompts SET hash=? WHERE id=?",
(generate_prompt_hash("other"), positive_id),
)
item, detail, observed = await self._queued()
body = self._link_body(detail, observed, other, None)
await self._refused(item, body, 409, "prompt-missing")
async def test_an_archived_bare_pair_is_refused(self):
positive_id, pair_id = self._pair()
bare = self.api.db.save_prompt_pair("harbor", "")
self.api.db.archive_prompt_pair(bare)
item, detail, observed = await self._queued()
body = self._link_body(detail, observed, positive_id, None)
await self._refused(item, body, 409, "prompt-missing")
self.assertTrue(pair_id)
async def test_body_validation(self):
positive_id, pair_id = self._pair()
item, detail, observed = await self._queued()
good = self._link_body(detail, observed, positive_id, pair_id)
before = self._snapshot()
for bad in (
{**good, "extra": 1},
{k: v for k, v in good.items() if k != "pair_id"},
{**good, "positive_id": "1"},
{**good, "positive_id": True},
{**good, "positive_id": 0},
{**good, "pair_id": -1},
{**good, "capture_token": None},
{**good, "expected_revision": -1},
{**good, "sha256": "x"},
):
status, data = await self._link(item["id"], bad)
self.assertEqual(status, 400, (bad, data))
self.assertEqual(self._snapshot(), before)
async def test_stale_revision_and_decided_item_are_refused(self):
positive_id, pair_id = self._pair()
item, detail, observed = await self._queued()
good = self._link_body(detail, observed, positive_id, pair_id)
await self._refused(
item, {**good, "expected_revision": 5}, 409, "review-conflict"
)
status, data = await self._link(item["id"], good)
self.assertEqual(status, 200, data)
again = await self._observe(item["target"])
await self._refused(
item,
{**good, "capture_token": again["capture"]["token"]},
409,
"review-conflict",
)
async def test_missing_item_expired_capture_and_changed_file_are_refused(self):
positive_id, pair_id = self._pair()
item, detail, observed = await self._queued()
good = self._link_body(detail, observed, positive_id, pair_id)
status, data = await self._link("00000000-0000-4000-8000-000000000000", good)
self.assertEqual((status, data["code"]), (404, "review-missing"))
await self._refused(
item, {**good, "capture_token": "a" * 43}, 409, "capture-expired"
)
write_png(
self.output_dir / "two.png", prompt=unlinkable_graph(), color=(1, 2, 3)
)
with_fresh = await self._observe(item["target"])
await self._refused(
item,
{
**good,
"capture_token": with_fresh["capture"]["token"],
"sha256": with_fresh["capture"]["sha256"],
},
409,
"file-changed",
)
async def test_restored_storage_is_refused(self):
positive_id, pair_id = self._pair()
item, detail, observed = await self._queued()
body = {
**self._link_body(detail, observed, positive_id, pair_id),
"expected_epoch": "00000000-0000-4000-8000-000000000000",
}
await self._refused(item, body, 409, "storage-changed")
def detail_version(case, item):
conn = case.api.db.model.get_connection()
return conn.execute(
"SELECT resolver_version FROM media_review_items WHERE id=?", (item["id"],)
).fetchone()[0]
class TestLinkNeverActsOnATrashedFile(LinkTestCase):
"""#543's gate: a link needs a capture, and neither way to one reaches a trash."""
TRASH = ".Trash-1000"
async def _captured_in_sub(self):
path = self._ambiguous("sub/two.png")
self._record(path)
item, detail, observed = await self._open()
positive_id, pair_id = self._pair()
return path, item, self._link_body(detail, observed, positive_id, pair_id)
async def _assert_refused(self, item, body):
before = self._snapshot()
status, data = await self._link(item["id"], body)
self.assertGreaterEqual(status, 400, data)
self.assertFalse(data.get("success"), data)
self.assertEqual(self._snapshot(), before)
async def test_a_file_moved_to_a_trash_folder_after_its_capture_is_refused(self):
path, item, body = await self._captured_in_sub()
trash = self.output_dir / self.TRASH / "files"
trash.mkdir(parents=True)
os.rename(path, trash / path.name)
await self._assert_refused(item, body)
async def test_a_folder_swapped_for_a_link_into_a_trash_folder_is_refused(self):
path, item, body = await self._captured_in_sub()
trash = self.output_dir / self.TRASH / "files"
trash.parent.mkdir(parents=True)
os.rename(path.parent, trash)
try:
os.symlink(trash, path.parent, target_is_directory=True)
except OSError as exc:
raise unittest.SkipTest(f"no symlinks here: {exc}")
self.assertTrue(path.exists())
await self._assert_refused(item, body)
async def test_an_item_in_a_trash_folder_cannot_get_a_capture(self):
path = self._ambiguous(f"{self.TRASH}/files/two.png")
self._record(path)
item = (await self._list())["items"][0]
status, data = await self._json(
"POST",
"/prompt_manager/image-annotations/observe",
json={"target": item["target"]},
)
self.assertEqual(status, 404, data)
self.assertFalse(data.get("capture"), data)