test_print_confirmation.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605
  1. """Integration tests for the post-print outcome confirmation (#1898).
  2. Covers the verdict PATCH (incl. the #1444-style mirror to the latest
  3. PrintLogEntry and token retirement), the unauthenticated capability-token
  4. endpoint, response defaults, and the verdict-aware statistics.
  5. """
  6. import re
  7. import pytest
  8. from httpx import AsyncClient
  9. from sqlalchemy import select
  10. from backend.app.models.print_log import PrintLogEntry
  11. class TestOutcomeVerdictPatch:
  12. @pytest.mark.asyncio
  13. @pytest.mark.integration
  14. async def test_defaults_present_on_response(self, async_client: AsyncClient, archive_factory, printer_factory):
  15. """Archives created without any verdict expose the new fields with
  16. their defaults — no verdict, no pending confirmation."""
  17. printer = await printer_factory()
  18. archive = await archive_factory(printer.id)
  19. response = await async_client.get(f"/api/v1/archives/{archive.id}")
  20. assert response.status_code == 200
  21. body = response.json()
  22. assert body["user_verdict"] is None
  23. assert body["confirm_requested"] is False
  24. @pytest.mark.asyncio
  25. @pytest.mark.integration
  26. async def test_patch_verdict_mirrors_to_latest_log_entry(
  27. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  28. ):
  29. """Setting the verdict via PATCH lands on the archive AND the latest
  30. PrintLogEntry (verdict-aware statistics read the log), and retires a
  31. pending confirmation token."""
  32. printer = await printer_factory()
  33. archive = await archive_factory(printer.id, confirm_requested=True, confirm_token="test-token-mirror")
  34. response = await async_client.patch(f"/api/v1/archives/{archive.id}", json={"user_verdict": "reject"})
  35. assert response.status_code == 200
  36. assert response.json()["user_verdict"] == "reject"
  37. entry = await db_session.scalar(
  38. select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id).order_by(PrintLogEntry.id.desc())
  39. )
  40. assert entry is not None
  41. assert entry.user_verdict == "reject"
  42. await db_session.refresh(archive)
  43. assert archive.user_verdict == "reject"
  44. # Retired by stamping, not by dropping the value: the link stays
  45. # resolvable so a later tap can be told it is already answered.
  46. assert archive.confirm_token == "test-token-mirror"
  47. assert archive.confirm_token_used_at is not None
  48. assert archive.user_verdict_source == "api"
  49. @pytest.mark.asyncio
  50. @pytest.mark.integration
  51. async def test_patch_rejects_unknown_verdict(self, async_client: AsyncClient, archive_factory, printer_factory):
  52. printer = await printer_factory()
  53. archive = await archive_factory(printer.id)
  54. response = await async_client.patch(f"/api/v1/archives/{archive.id}", json={"user_verdict": "meh"})
  55. assert response.status_code == 422
  56. class TestConfirmTokenEndpoint:
  57. @pytest.mark.asyncio
  58. @pytest.mark.integration
  59. async def test_token_records_verdict_and_retires_token(
  60. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  61. ):
  62. """The one-tap link from a push notification records the verdict
  63. without auth, mirrors it to the log entry, and single-uses the token."""
  64. printer = await printer_factory()
  65. archive = await archive_factory(printer.id, confirm_requested=True, confirm_token="test-token-good")
  66. response = await async_client.post("/api/v1/archives/confirm/test-token-good/good")
  67. assert response.status_code == 200
  68. assert "text/html" in response.headers["content-type"]
  69. await db_session.refresh(archive)
  70. assert archive.user_verdict == "good"
  71. assert archive.user_verdict_source == "link"
  72. assert archive.confirm_token == "test-token-good"
  73. assert archive.confirm_token_used_at is not None
  74. entry = await db_session.scalar(
  75. select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id).order_by(PrintLogEntry.id.desc())
  76. )
  77. assert entry is not None
  78. assert entry.user_verdict == "good"
  79. # Second use of the same token: spent, and said so rather than 404.
  80. response = await async_client.get("/api/v1/archives/confirm/test-token-good/reject")
  81. assert response.status_code == 200
  82. assert "Already answered" in response.text
  83. await db_session.refresh(archive)
  84. assert archive.user_verdict == "good"
  85. @pytest.mark.asyncio
  86. @pytest.mark.integration
  87. async def test_spent_link_reports_the_recorded_verdict_without_changing_it(
  88. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  89. ):
  90. """The live-farm case (#1898): the plate-clear default answered the
  91. prompt, then the Telegram button was tapped. The link must name the
  92. verdict on file, say how it got there, and leave it alone."""
  93. from backend.app.services.print_confirmation import resolve_pending_confirmation_as_good
  94. printer = await printer_factory()
  95. archive = await archive_factory(printer.id, confirm_requested=True, confirm_token="plate-cleared-token")
  96. assert await resolve_pending_confirmation_as_good(db_session, printer.id) == archive.id
  97. await db_session.commit()
  98. response = await async_client.get("/api/v1/archives/confirm/plate-cleared-token/reject")
  99. assert response.status_code == 200
  100. body = response.text
  101. assert "Already answered" in body
  102. assert "Good part" in body
  103. assert "plate was cleared" in body
  104. assert f"/archives?confirm={archive.id}" in body
  105. await db_session.refresh(archive)
  106. assert archive.user_verdict == "good"
  107. assert archive.user_verdict_source == "plate_clear"
  108. @pytest.mark.asyncio
  109. @pytest.mark.integration
  110. async def test_spent_link_after_the_verdict_was_cleared_again(
  111. self, async_client: AsyncClient, archive_factory, printer_factory
  112. ):
  113. """Clearing the verdict in the app does not un-spend the link: the
  114. capability was used, so the page explains rather than re-opening it."""
  115. printer = await printer_factory()
  116. archive = await archive_factory(printer.id, confirm_requested=True, confirm_token="cleared-again-token")
  117. assert (await async_client.post("/api/v1/archives/confirm/cleared-again-token/good")).status_code == 200
  118. assert (
  119. await async_client.patch(f"/api/v1/archives/{archive.id}", json={"user_verdict": None})
  120. ).status_code == 200
  121. response = await async_client.get("/api/v1/archives/confirm/cleared-again-token/good")
  122. assert response.status_code == 200
  123. assert "Already answered" in response.text
  124. assert (await async_client.get(f"/api/v1/archives/{archive.id}")).json()["user_verdict"] is None
  125. @pytest.mark.asyncio
  126. @pytest.mark.integration
  127. async def test_unknown_token_and_garbage_verdict(self, async_client: AsyncClient):
  128. assert (await async_client.get("/api/v1/archives/confirm/no-such-token/good")).status_code == 404
  129. assert (await async_client.get("/api/v1/archives/confirm/whatever/maybe")).status_code == 400
  130. assert (await async_client.post("/api/v1/archives/confirm/no-such-token/good")).status_code == 404
  131. assert (await async_client.post("/api/v1/archives/confirm/whatever/maybe")).status_code == 400
  132. @pytest.mark.asyncio
  133. @pytest.mark.integration
  134. async def test_a_get_records_nothing_whoever_sends_it(
  135. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  136. ):
  137. """The blocker. Telegram and Slack GET the URLs in a message to build a
  138. preview card, mail gateways detonate them before delivery, proxies and
  139. browsers prefetch. While GET was the route that recorded, any of those
  140. settled the outcome before the operator read the question — always
  141. towards 'good', because good_url came first — and spent the token, so
  142. the real tap landed on "already answered". A scrap part counted as a
  143. success for good, in the statistics this branch adds.
  144. The heuristic below decides how the page behaves, not whether the
  145. verdict is written: nothing a GET can say records anything.
  146. """
  147. printer = await printer_factory()
  148. archive = await archive_factory(printer.id, confirm_requested=True, confirm_token="unfurler-token")
  149. for agent in (
  150. "TelegramBot (like TwitterBot)",
  151. "Slackbot-LinkExpanding 1.0",
  152. "Mimecast-Link-Protect",
  153. # The one that used to be allowed straight through to the write.
  154. "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 "
  155. "(KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1",
  156. ):
  157. response = await async_client.get(
  158. "/api/v1/archives/confirm/unfurler-token/good", headers={"user-agent": agent}
  159. )
  160. assert response.status_code == 200, agent
  161. assert "Confirm this outcome" in response.text, agent
  162. assert "<form method='post'" in response.text, agent
  163. await db_session.refresh(archive)
  164. assert archive.user_verdict is None, agent
  165. assert archive.confirm_token_used_at is None, f"{agent} must leave the capability spendable"
  166. @pytest.mark.asyncio
  167. @pytest.mark.integration
  168. async def test_the_forms_post_records_the_verdict(
  169. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  170. ):
  171. """What the page's form does when it is submitted. Unfurlers, scanners
  172. and prefetchers issue GET; none of them POSTs."""
  173. printer = await printer_factory()
  174. archive = await archive_factory(printer.id, confirm_requested=True, confirm_token="unfurler-then-tap")
  175. response = await async_client.post("/api/v1/archives/confirm/unfurler-then-tap/reject")
  176. assert response.status_code == 200
  177. assert "Rejected" in response.text
  178. await db_session.refresh(archive)
  179. assert archive.user_verdict == "reject"
  180. assert archive.user_verdict_source == "link"
  181. assert archive.confirm_token_used_at is not None
  182. @pytest.mark.asyncio
  183. @pytest.mark.integration
  184. async def test_a_phone_browser_still_records_in_one_tap(
  185. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  186. ):
  187. """The split must not cost the feature its point. The page opened from
  188. a notification button submits itself, so the tap on the notification is
  189. still the only tap — and the script carries the CSP nonce, without
  190. which the policy in main.py would block it and cost the operator a
  191. second tap.
  192. The ``?tap=1`` is what the Telegram inline keyboard carries. This test
  193. used to load the URL without it and still expect the script, which was
  194. the remaining hole rather than the feature: that unmarked URL is the
  195. one a scanner gets out of a message body.
  196. """
  197. printer = await printer_factory()
  198. archive = await archive_factory(printer.id, confirm_requested=True, confirm_token="phone-token")
  199. response = await async_client.get(
  200. "/api/v1/archives/confirm/phone-token/good?tap=1",
  201. headers={
  202. "user-agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) "
  203. "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1"
  204. },
  205. )
  206. assert response.status_code == 200
  207. assert "getElementById('confirm-form').submit()" in response.text
  208. nonce = re.search(r"<script nonce='([^']+)'", response.text)
  209. assert nonce, "the inline script needs the per-request nonce or the CSP blocks it"
  210. assert f"'nonce-{nonce.group(1)}'" in response.headers["content-security-policy"]
  211. # And the submit that script performs is the thing that records. The
  212. # form has no action attribute, so a browser POSTs back to the URL it
  213. # loaded — query string and all, which the route must not mind.
  214. await db_session.refresh(archive)
  215. assert archive.user_verdict is None
  216. assert (await async_client.post("/api/v1/archives/confirm/phone-token/good?tap=1")).status_code == 200
  217. await db_session.refresh(archive)
  218. assert archive.user_verdict == "good"
  219. assert archive.confirm_token_used_at is not None
  220. @pytest.mark.asyncio
  221. @pytest.mark.integration
  222. @pytest.mark.parametrize(
  223. "headers",
  224. [
  225. {"user-agent": "TelegramBot (like TwitterBot)"},
  226. {
  227. "user-agent": "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 "
  228. "(KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36",
  229. "purpose": "prefetch",
  230. },
  231. ],
  232. )
  233. async def test_an_unattended_fetch_gets_no_self_submitting_script(
  234. self, async_client: AsyncClient, archive_factory, printer_factory, headers
  235. ):
  236. """Second layer, for the scanners that do run JavaScript: a request
  237. that already looks automated is served the button without the script
  238. that would press it."""
  239. printer = await printer_factory()
  240. archive = await archive_factory(printer.id, confirm_requested=True, confirm_token="no-script-token")
  241. assert archive.confirm_token == "no-script-token"
  242. response = await async_client.get("/api/v1/archives/confirm/no-script-token/good?tap=1", headers=headers)
  243. assert response.status_code == 200
  244. assert "<form method='post'" in response.text
  245. assert "<script" not in response.text
  246. @pytest.mark.asyncio
  247. @pytest.mark.integration
  248. @pytest.mark.parametrize(
  249. "agent",
  250. [
  251. # A mail-security sandbox detonating the link: renders HTML, runs
  252. # JavaScript, and says it is Chrome — because as far as it is
  253. # concerned it is.
  254. "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) "
  255. "Chrome/126.0.0.0 Safari/537.36",
  256. # Not even hiding, and the User-Agent list still has no word for it.
  257. "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) "
  258. "HeadlessChrome/126.0.0.0 Safari/537.36",
  259. ],
  260. )
  261. async def test_a_link_out_of_the_message_body_never_submits_itself(
  262. self, async_client: AsyncClient, archive_factory, printer_factory, db_session, agent
  263. ):
  264. """The residual after taking the write off GET, now closed.
  265. The User-Agent list catches the fetchers that announce themselves, and
  266. none of those run JavaScript anyway. The ones that do run it — Safe
  267. Links detonation, click-time sandboxing, a browser-isolation proxy —
  268. send an ordinary Chrome string, so the list cannot name them and the
  269. page used to press its own button for them.
  270. What it can never have is the ``?tap=1`` off a notification button: a
  271. verdict URL only reaches a scanner by travelling in message text, where
  272. the marker is not. An install that kept ``{good_url}`` in its prompt
  273. body (the migration leaves an edited body alone, on purpose) is covered
  274. by this, which is the case the plain URL below stands for.
  275. """
  276. printer = await printer_factory()
  277. archive = await archive_factory(printer.id, confirm_requested=True, confirm_token="detonated-token")
  278. response = await async_client.get(
  279. "/api/v1/archives/confirm/detonated-token/good", headers={"user-agent": agent}
  280. )
  281. assert response.status_code == 200
  282. assert "Confirm this outcome" in response.text
  283. assert "<form method='post'" in response.text
  284. assert "submit()" not in response.text, "a body link must never press its own button"
  285. await db_session.refresh(archive)
  286. assert archive.user_verdict is None
  287. assert archive.confirm_token_used_at is None, "the operator's tap must still be worth something"
  288. # And the same browser, arriving from the button, is still one tap.
  289. marked = await async_client.get(
  290. "/api/v1/archives/confirm/detonated-token/good?tap=1", headers={"user-agent": agent}
  291. )
  292. assert "getElementById('confirm-form').submit()" in marked.text
  293. @pytest.mark.asyncio
  294. @pytest.mark.integration
  295. async def test_a_head_request_records_nothing(
  296. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  297. ):
  298. """Mail scanners probe with HEAD. FastAPI's APIRoute does not widen a
  299. GET route to HEAD the way a plain Starlette Route does, so the probe is
  300. refused outright — and the GET it would widen to writes nothing now."""
  301. printer = await printer_factory()
  302. archive = await archive_factory(printer.id, confirm_requested=True, confirm_token="head-token")
  303. assert (await async_client.head("/api/v1/archives/confirm/head-token/good")).status_code == 405
  304. await db_session.refresh(archive)
  305. assert archive.user_verdict is None
  306. assert archive.confirm_token_used_at is None
  307. def test_confirm_links_exempt_from_auth_middleware(self):
  308. """The one-tap links are tapped on a phone with no session, so the
  309. global auth middleware must step aside for them — the capability token
  310. in the path is the credential. Pins the PUBLIC_API_PREFIXES entry;
  311. without it, enabling authentication 401s every verdict link."""
  312. from backend.app.main import PUBLIC_API_PREFIXES
  313. assert "/api/v1/archives/confirm/" in PUBLIC_API_PREFIXES
  314. class TestDefaultGoodOnPlateClear:
  315. @pytest.mark.asyncio
  316. @pytest.mark.integration
  317. async def test_helper_resolves_only_the_latest_pending(self, archive_factory, printer_factory, db_session):
  318. """Releasing the plate refers to the print that just came off it: only
  319. the LATEST pending archive flips to good; older unanswered prompts and
  320. already-answered ones stay untouched. The verdict mirrors to the run."""
  321. from backend.app.services.print_confirmation import resolve_pending_confirmation_as_good
  322. printer = await printer_factory()
  323. older = await archive_factory(printer.id, confirm_requested=True)
  324. newest_pending = await archive_factory(printer.id, confirm_requested=True, confirm_token="pending-token")
  325. answered = await archive_factory(printer.id, confirm_requested=True, user_verdict="reject")
  326. resolved = await resolve_pending_confirmation_as_good(db_session, printer.id)
  327. await db_session.commit()
  328. assert resolved == newest_pending.id
  329. await db_session.refresh(newest_pending)
  330. assert newest_pending.user_verdict == "good"
  331. assert newest_pending.user_verdict_source == "plate_clear"
  332. assert newest_pending.confirm_token == "pending-token"
  333. assert newest_pending.confirm_token_used_at is not None
  334. await db_session.refresh(older)
  335. assert older.user_verdict is None
  336. await db_session.refresh(answered)
  337. assert answered.user_verdict == "reject"
  338. entry = await db_session.scalar(
  339. select(PrintLogEntry).where(PrintLogEntry.archive_id == newest_pending.id).order_by(PrintLogEntry.id.desc())
  340. )
  341. assert entry is not None and entry.user_verdict == "good"
  342. @pytest.mark.asyncio
  343. @pytest.mark.integration
  344. async def test_helper_noop_without_pending(self, archive_factory, printer_factory, db_session):
  345. from backend.app.services.print_confirmation import resolve_pending_confirmation_as_good
  346. printer = await printer_factory()
  347. await archive_factory(printer.id) # completed, never asked
  348. assert await resolve_pending_confirmation_as_good(db_session, printer.id) is None
  349. class TestVerdictStatistics:
  350. @pytest.mark.asyncio
  351. @pytest.mark.integration
  352. async def test_failure_analysis_reports_rejects_separately(
  353. self, async_client: AsyncClient, archive_factory, printer_factory
  354. ):
  355. """A completed-but-rejected print stays OUT of the machine failure
  356. rate but shows up as rejected_prints and lowers the yield rate."""
  357. printer = await printer_factory()
  358. good = await archive_factory(printer.id, print_name="Good Part")
  359. rejected = await archive_factory(printer.id, print_name="Scrap Part")
  360. await archive_factory(printer.id, print_name="Machine Failure", status="failed", run_status="failed")
  361. # Confirm one good, reject one — through the API so the mirror runs.
  362. assert (
  363. await async_client.patch(f"/api/v1/archives/{good.id}", json={"user_verdict": "good"})
  364. ).status_code == 200
  365. assert (
  366. await async_client.patch(
  367. f"/api/v1/archives/{rejected.id}", json={"user_verdict": "reject", "failure_reason": "other"}
  368. )
  369. ).status_code == 200
  370. response = await async_client.get("/api/v1/archives/analysis/failures?days=30")
  371. assert response.status_code == 200
  372. body = response.json()
  373. # Machine numbers unchanged by the quality dimension:
  374. assert body["failed_prints"] == 1
  375. assert body["failure_rate"] == pytest.approx(33.3, abs=0.1)
  376. # Quality dimension:
  377. assert body["rejected_prints"] == 1
  378. assert body["yield_rate"] == pytest.approx(33.3, abs=0.1)
  379. assert body["rejects_by_reason"] == {"other": 1}
  380. class TestVerdictSource:
  381. """How a verdict came in (#1898) — every writer on this branch stamps it."""
  382. def test_every_source_has_a_phrase_for_the_already_answered_page(self):
  383. """Including 'reaction', which the Telegram reaction work (#3046) will
  384. write from its own branch: the page that explains a spent link must not
  385. fall silent the day it lands."""
  386. from backend.app.api.routes.archives import _VERDICT_SOURCE_PHRASES
  387. from backend.app.services.print_confirmation import VERDICT_SOURCES
  388. assert set(VERDICT_SOURCES) == set(_VERDICT_SOURCE_PHRASES)
  389. @pytest.mark.asyncio
  390. @pytest.mark.integration
  391. async def test_response_exposes_the_source(self, async_client: AsyncClient, archive_factory, printer_factory):
  392. printer = await printer_factory()
  393. archive = await archive_factory(printer.id, confirm_requested=True)
  394. assert (await async_client.get(f"/api/v1/archives/{archive.id}")).json()["user_verdict_source"] is None
  395. assert (
  396. await async_client.patch(
  397. f"/api/v1/archives/{archive.id}", json={"user_verdict": "good", "user_verdict_source": "dialog"}
  398. )
  399. ).status_code == 200
  400. body = (await async_client.get(f"/api/v1/archives/{archive.id}")).json()
  401. assert body["user_verdict"] == "good"
  402. assert body["user_verdict_source"] == "dialog"
  403. @pytest.mark.asyncio
  404. @pytest.mark.integration
  405. async def test_printer_card_and_bare_patch_sources(
  406. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  407. ):
  408. printer = await printer_factory()
  409. from_card = await archive_factory(printer.id, confirm_requested=True)
  410. from_script = await archive_factory(printer.id, confirm_requested=True)
  411. await async_client.patch(
  412. f"/api/v1/archives/{from_card.id}", json={"user_verdict": "good", "user_verdict_source": "printer_card"}
  413. )
  414. # No source claimed: some script or integration did it.
  415. await async_client.patch(f"/api/v1/archives/{from_script.id}", json={"user_verdict": "reject"})
  416. await db_session.refresh(from_card)
  417. await db_session.refresh(from_script)
  418. assert from_card.user_verdict_source == "printer_card"
  419. assert from_script.user_verdict_source == "api"
  420. @pytest.mark.asyncio
  421. @pytest.mark.integration
  422. async def test_server_owned_sources_cannot_be_claimed_over_the_api(
  423. self, async_client: AsyncClient, archive_factory, printer_factory
  424. ):
  425. """'link', 'plate_clear' and 'reaction' are stamped by the paths that
  426. own them — a PATCH may not forge them."""
  427. printer = await printer_factory()
  428. archive = await archive_factory(printer.id, confirm_requested=True)
  429. for forged in ("link", "plate_clear", "reaction", "nonsense"):
  430. response = await async_client.patch(
  431. f"/api/v1/archives/{archive.id}", json={"user_verdict": "good", "user_verdict_source": forged}
  432. )
  433. assert response.status_code == 422, forged
  434. @pytest.mark.asyncio
  435. @pytest.mark.integration
  436. async def test_the_page_dates_the_verdict_on_file_not_the_spent_token(
  437. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  438. ):
  439. """A verdict changed later must not be reported with the older decision's time.
  440. The live case: the plate-clear default answers a print, the operator
  441. changes the verdict in the app an hour later, then the old Telegram link
  442. is tapped. Reporting the new verdict with the old timestamp would
  443. describe two different events as one.
  444. """
  445. from datetime import datetime, timedelta, timezone
  446. printer = await printer_factory()
  447. archive = await archive_factory(printer.id, confirm_requested=True, confirm_token="dates-token")
  448. await async_client.patch(
  449. f"/api/v1/archives/{archive.id}", json={"user_verdict": "good", "user_verdict_source": "dialog"}
  450. )
  451. await db_session.refresh(archive)
  452. first_at = archive.user_verdict_at
  453. assert first_at is not None
  454. # Age the first decision so the two timestamps cannot coincide.
  455. archive.user_verdict_at = first_at - timedelta(hours=1)
  456. archive.confirm_token_used_at = first_at - timedelta(hours=1)
  457. await db_session.commit()
  458. await async_client.patch(
  459. f"/api/v1/archives/{archive.id}", json={"user_verdict": "reject", "user_verdict_source": "dialog"}
  460. )
  461. await db_session.refresh(archive)
  462. assert archive.user_verdict == "reject"
  463. assert archive.user_verdict_at > archive.confirm_token_used_at
  464. page = await async_client.get(f"/api/v1/archives/confirm/{archive.confirm_token}/good")
  465. assert page.status_code == 200
  466. # SQLite hands back naive datetimes; they are already UTC, which is how
  467. # the route formats them too.
  468. def _as_shown(value):
  469. if value.tzinfo is not None:
  470. value = value.astimezone(timezone.utc)
  471. return value.strftime("%Y-%m-%d %H:%M UTC")
  472. shown = _as_shown(archive.user_verdict_at)
  473. stale = _as_shown(archive.confirm_token_used_at)
  474. assert shown in page.text
  475. assert stale not in page.text
  476. # And the link still changed nothing.
  477. await db_session.refresh(archive)
  478. assert archive.user_verdict == "reject"
  479. async def test_a_re_sent_prompt_carries_a_live_token(self, archive_factory, printer_factory, db_session):
  480. """A spent token must never be re-used for a new prompt.
  481. Since a verdict keeps the token value on the row, "has a token" stopped
  482. meaning "answerable": without minting a fresh one, every button in the
  483. new message would land on the already-answered page.
  484. """
  485. from backend.app.main import dispatch_outcome_confirmation
  486. printer = await printer_factory()
  487. archive = await archive_factory(printer.id, confirm_requested=True, confirm_token="spent-token")
  488. from backend.app.services.print_confirmation import retire_confirm_token
  489. retire_confirm_token(archive)
  490. await db_session.commit()
  491. assert archive.confirm_token_used_at is not None
  492. await dispatch_outcome_confirmation(db_session, printer.id, printer.name, {}, archive.id, None)
  493. await db_session.refresh(archive)
  494. assert archive.confirm_token != "spent-token"
  495. assert archive.confirm_token_used_at is None
  496. async def test_clearing_the_verdict_drops_the_source(
  497. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  498. ):
  499. printer = await printer_factory()
  500. archive = await archive_factory(printer.id, confirm_requested=True, confirm_token="drop-source-token")
  501. await async_client.patch(
  502. f"/api/v1/archives/{archive.id}", json={"user_verdict": "good", "user_verdict_source": "dialog"}
  503. )
  504. await async_client.patch(f"/api/v1/archives/{archive.id}", json={"user_verdict": None})
  505. await db_session.refresh(archive)
  506. assert archive.user_verdict is None
  507. assert archive.user_verdict_source is None
  508. # ...but the capability stays spent.
  509. assert archive.confirm_token_used_at is not None