push.py 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550
  1. """The push bundle (moved verbatim from the pre-split web_stack_test.py;
  2. see test/harness.py for the shared machinery and the runner)."""
  3. from harness import * # noqa: F401,F403 — the shared wire helpers
  4. # =============================================================================
  5. # Bundle: push — plan 11 task E: real Web Push over VAPID. VAPID
  6. # persistence, the service worker, subscription CRUD + validation,
  7. # notify-me prefs, the block/lifecycle dispatch matrix (global vs
  8. # opted-in), 410 pruning, retry-next-tick delivery and protocol-level
  9. # payload verification (RFC 8291 decryption + ES256 JWT) against the
  10. # fake push service.
  11. # =============================================================================
  12. def run(ctx):
  13. auth, fake, fake_id, fake_token = ctx.auth, ctx.fake, ctx.fake_id, ctx.fake_token
  14. def subscription_count(endpoint):
  15. conn = db()
  16. try:
  17. return conn.execute(
  18. "select count(*) from push_subscriptions where endpoint = ?",
  19. (endpoint,)).fetchone()[0]
  20. finally:
  21. conn.close()
  22. print("== task E: service worker asset ==")
  23. status, sw = http("GET", "/sw.js", timeout=15)
  24. check(status == 200 and "addEventListener('push'" in sw
  25. and "notificationclick" in sw and "clients.openWindow" in sw,
  26. "the service worker serves at the origin root with push + click routing")
  27. print("== task E: VAPID keypair generation + persistence ==")
  28. config = json.load(open(os.path.join(WORK, "web-config.json")))
  29. vapid = config.get("vapid") or {}
  30. check(bool(vapid.get("public_key")) and bool(vapid.get("private_key")),
  31. "the VAPID keypair persists into web-config.json's vapid section")
  32. vapid_private_key = ec.derive_private_key(
  33. int.from_bytes(b64url_dec(vapid["private_key"]), "big"), ec.SECP256R1())
  34. derived_public = vapid_private_key.public_key().public_bytes(
  35. Encoding.X962, PublicFormat.UncompressedPoint)
  36. check(b64url_dec(vapid["public_key"]) == derived_public,
  37. "the persisted public key matches the private scalar (a valid P-256 pair)")
  38. dash = slot_public(auth, "/", "dashboard")
  39. check(dash.get("push_vapid_key") == vapid["public_key"],
  40. "the dashboard exposes the same VAPID key to subscribers")
  41. check(dash.get("push_subscribed") is False and bool(dash.get("push_subscribe")),
  42. "the bell starts unsubscribed and carries the subscribe action")
  43. print("== task E: subscription CRUD + auth validation ==")
  44. browser = ec.generate_private_key(ec.SECP256R1())
  45. browser_public = browser.public_key().public_bytes(
  46. Encoding.X962, PublicFormat.UncompressedPoint)
  47. browser_auth = os.urandom(16)
  48. subscribe_fields = {"p256dh": b64url(browser_public), "auth": b64url(browser_auth)}
  49. valid_endpoint = "http://127.0.0.1:1/x"
  50. check("absolute http(s)" in (action_notify(act(auth, dash["push_subscribe"],
  51. {"endpoint": "ftp://example.com/push", **subscribe_fields})) or ""),
  52. "a non-http endpoint is rejected")
  53. check("p256dh" in (action_notify(act(auth, dash["push_subscribe"],
  54. {"endpoint": valid_endpoint, "p256dh": b64url(os.urandom(64)),
  55. "auth": subscribe_fields["auth"]})) or ""),
  56. "a malformed p256dh point is rejected")
  57. check("auth" in (action_notify(act(auth, dash["push_subscribe"],
  58. {"endpoint": valid_endpoint, "p256dh": subscribe_fields["p256dh"],
  59. "auth": b64url(os.urandom(15))})) or ""),
  60. "a malformed auth secret is rejected")
  61. push = FakePushService()
  62. push.serve()
  63. endpoint_main = push.url("/main")
  64. act(auth, dash["push_subscribe"],
  65. {"endpoint": endpoint_main, **subscribe_fields})
  66. conn = db()
  67. rows = conn.execute("select user_id, p256dh, auth from push_subscriptions"
  68. " where endpoint = ?", (endpoint_main,)).fetchall()
  69. admin_id = conn.execute("select id from spry_users where username = 'admin'"
  70. ).fetchone()[0]
  71. conn.close()
  72. check(len(rows) == 1 and rows[0][0] == admin_id,
  73. "a valid subscription stores against the signed-in user")
  74. # Re-subscribing the same endpoint replaces (never duplicates).
  75. rotated_auth = os.urandom(16)
  76. act(auth, dash["push_subscribe"],
  77. {"endpoint": endpoint_main, "p256dh": subscribe_fields["p256dh"],
  78. "auth": b64url(rotated_auth)})
  79. conn = db()
  80. rows = conn.execute("select auth from push_subscriptions where endpoint = ?",
  81. (endpoint_main,)).fetchall()
  82. conn.close()
  83. check(len(rows) == 1 and rows[0][0] == b64url(rotated_auth),
  84. "re-subscribing an endpoint replaces its keys in place")
  85. print("== task E: notify-me pref persistence ==")
  86. act(auth, slot_public(auth, "/tasks", "tasks")["create"],
  87. {"project_id": "1", "title": "Push pref task", "priority": "5"})
  88. pref_task = task_id_of("Push pref task")
  89. act(auth, task_state(auth, pref_task)["toggle_notify"], {})
  90. conn = db()
  91. check(conn.execute("select count(*) from task_notify_prefs where task_id = ?"
  92. " and user_id = ?", (pref_task, admin_id)).fetchone()[0] == 1,
  93. "Notify me persists a task_notify_prefs row for the user")
  94. conn.close()
  95. check(task_state(auth, pref_task).get("notify_me") is True,
  96. "the task slot reflects notify_me after the toggle")
  97. act(auth, task_state(auth, pref_task)["toggle_notify"], {})
  98. conn = db()
  99. check(conn.execute("select count(*) from task_notify_prefs where task_id = ?",
  100. (pref_task,)).fetchone()[0] == 0,
  101. "Don't notify me removes the pref row")
  102. conn.close()
  103. check(task_state(auth, pref_task).get("notify_me") is False,
  104. "the task slot reflects the opt-out")
  105. print("== task E: dispatch matrix — global blocks vs opted-in lifecycle ==")
  106. # Two "browsers" for admin (/main, /gone) and one for another user
  107. # (id 999, rows seeded directly — push targeting is per user).
  108. endpoint_gone = push.url("/gone")
  109. push.status_for["/gone"] = 410
  110. act(auth, dash["push_subscribe"],
  111. {"endpoint": endpoint_gone, **subscribe_fields})
  112. other_browser = ec.generate_private_key(ec.SECP256R1())
  113. other_public = other_browser.public_key().public_bytes(
  114. Encoding.X962, PublicFormat.UncompressedPoint)
  115. other_auth = os.urandom(16)
  116. endpoint_other = push.url("/other")
  117. conn = db()
  118. conn.execute("insert into push_subscriptions (user_id, endpoint, p256dh, auth,"
  119. " created_at) values (999, ?, ?, ?, strftime('%s','now'))",
  120. (endpoint_other, b64url(other_public), b64url(other_auth)))
  121. conn.commit()
  122. conn.close()
  123. # Task 1: admin opted in via the UI toggle; the other user is not.
  124. # Baselines are captured BEFORE the enqueue: the first-start push
  125. # fires at the allocation, the instant the dispatch lands.
  126. act(auth, slot_public(auth, "/tasks", "tasks")["create"],
  127. {"project_id": "1", "title": "Push lifecycle task", "priority": "5"})
  128. task1 = task_id_of("Push lifecycle task")
  129. act(auth, task_state(auth, task1)["toggle_notify"], {})
  130. main_before = len(push.hits("/main"))
  131. other_before = len(push.hits("/other"))
  132. gone_before = len(push.hits("/gone"))
  133. enqueue_task(auth, task1)
  134. check(wait_for(lambda: fake.running_task == task1 or False, timeout=30,
  135. what="task 1 assignment") is not None,
  136. "task 1 dispatches to the fake daemon")
  137. task1_session = fake.session_id
  138. check(wait_for(lambda: len(push.hits("/main")) > main_before, timeout=15,
  139. what="first-start push to the opted-in admin") is not None,
  140. "first start notifies the opted-in user's subscription")
  141. check(wait_for(lambda: len(push.hits("/gone")) > gone_before, timeout=15,
  142. what="first-start push to the gone endpoint") is not None,
  143. "first start reaches every subscription of the opted-in user")
  144. check(quiet_window(quiet_seconds(),
  145. lambda: len(push.hits("/other")) == other_before,
  146. "no first-start push to the not-opted-in user"),
  147. "first start never reaches users without the notify pref")
  148. check(wait_task_done(auth, task1, timeout=60) is not None,
  149. "task 1 completes")
  150. _, done_payload = decrypt_webpush(push.hits("/main")[-1], browser,
  151. browser_public, rotated_auth)
  152. check(done_payload["title"] == "Push lifecycle task — completed"
  153. and done_payload["url"] == "/tasks/%d" % task1,
  154. "completion notifies the opted-in user (decrypted payload)")
  155. check(len(push.hits("/other")) == other_before,
  156. "completion stays with the opted-in user only")
  157. check(wait_for(lambda: subscription_count(endpoint_gone) == 0, timeout=15,
  158. what="410 endpoint pruned") is not None,
  159. "a 410 Gone endpoint is pruned from push_subscriptions")
  160. # Block events go to EVERY push-subscribed user globally. The
  161. # poll-era /update raise is gone: a live (non-terminal) host task
  162. # with an open session on the fake carries the block through the
  163. # results push — task1 is done and terminal pushes are consumed
  164. # silently, so the host is its own fixture row.
  165. conn = db()
  166. conn.execute("insert into tasks (project_id, title, status, priority,"
  167. " created_at) values (1, 'Push block host', 'working', 5,"
  168. " strftime('%s','now'))")
  169. push_host = conn.execute("select max(id) from tasks").fetchone()[0]
  170. conn.execute("insert into sessions (task_id, saddle_id, started_at)"
  171. " values (?, ?, strftime('%s','now'))", (push_host, fake_id))
  172. push_host_session = conn.execute(
  173. "select max(id) from sessions").fetchone()[0]
  174. conn.commit()
  175. conn.close()
  176. def raise_global_block(question):
  177. return saddle_api("POST", "/api/saddle/results", fake_token, {
  178. "session_id": push_host_session, "state": "blocked",
  179. "block": {"question": question, "type": "question"}})
  180. main_before = len(push.hits("/main"))
  181. other_before = len(push.hits("/other"))
  182. raise_global_block("Global question for everyone?")
  183. check(wait_for(lambda: len(push.hits("/main")) > main_before
  184. and len(push.hits("/other")) > other_before, timeout=15,
  185. what="global block push") is not None,
  186. "a raised block notifies every push-subscribed user globally")
  187. block_hit = push.hits("/other")[-1]
  188. _, payload = decrypt_webpush(block_hit, other_browser, other_public, other_auth)
  189. check(payload["title"] == "Push block host — question raised"
  190. and payload["body"] == "Global question for everyone?"
  191. and payload["url"] == "/tasks/%d" % push_host,
  192. "the block payload carries task title + kind, question and click URL (%s)"
  193. % payload)
  194. ciphertext_len = len(block_hit["body"]) - (21 + 65)
  195. envelope = block_hit["body"]
  196. record_size = int.from_bytes(envelope[16:20], "big")
  197. # rs = plaintext + delimiter (1) + tag (16) == ciphertext-with-tag len.
  198. check(record_size == ciphertext_len,
  199. "the RFC 8291 envelope record size covers payload + delimiter + tag")
  200. claims = verify_vapid(block_hit, vapid["public_key"], vapid["private_key"])
  201. check(claims.get("aud") == "http://127.0.0.1:%d" % push.port
  202. and claims.get("sub", "").startswith("mailto:")
  203. and claims.get("exp", 0) > time.time(),
  204. "the VAPID JWT verifies: aud = the endpoint origin, mailto sub, future exp")
  205. check(block_hit["headers"].get("ttl") == "60"
  206. and block_hit["headers"].get("content-type") == "application/octet-stream",
  207. "the push POST carries TTL and the encrypted content type")
  208. # RFC 8291 section 4: the aes128gcm body MUST announce its encoding
  209. # — push services answer HTTP 400 without it (the test-notification
  210. # 400 regression).
  211. check(block_hit["headers"].get("content-encoding") == "aes128gcm",
  212. "the push POST carries Content-Encoding: aes128gcm (RFC 8291)")
  213. check(block_hit["headers"].get("urgency") == "high"
  214. and block_hit["headers"].get("authorization", "")
  215. .startswith("vapid t="),
  216. "the push POST carries Urgency and the VAPID Authorization")
  217. # Task 2: only the OTHER user opted in — start + failure reach them
  218. # alone. Baseline again before the enqueue (first start fires at
  219. # the allocation).
  220. fake.fail_next = True
  221. act(auth, slot_public(auth, "/tasks", "tasks")["create"],
  222. {"project_id": "1", "title": "Push failure task", "priority": "5"})
  223. task2 = task_id_of("Push failure task")
  224. conn = db()
  225. conn.execute("insert into task_notify_prefs (task_id, user_id) values (?, 999)",
  226. (task2,))
  227. conn.commit()
  228. conn.close()
  229. main_before2 = len(push.hits("/main"))
  230. other_before2 = len(push.hits("/other"))
  231. enqueue_task(auth, task2)
  232. check(wait_for(lambda: fake.running_task == task2 or False, timeout=30,
  233. what="task 2 assignment") is not None,
  234. "task 2 dispatches (scripted to fail)")
  235. check(wait_for(lambda: task_state(auth, task2)["status"] == "failed"
  236. or False, timeout=30, what="task 2 failed") is not None,
  237. "task 2 fails")
  238. check(wait_for(lambda: len(push.hits("/other")) >= other_before2 + 2,
  239. timeout=15, what="opted-in user's started+failed pushes"),
  240. "the opted-in user gets first start AND failure for the task")
  241. started_hit = [hit for hit in push.hits("/other")[other_before2:]][0]
  242. _, started_payload = decrypt_webpush(started_hit, other_browser,
  243. other_public, other_auth)
  244. check(started_payload["title"] == "Push failure task — started",
  245. "the first-start payload names task + kind (%s)" % started_payload)
  246. _, failure_payload = decrypt_webpush(push.hits("/other")[-1], other_browser,
  247. other_public, other_auth)
  248. check(failure_payload["title"] == "Push failure task — failed"
  249. and failure_payload["url"] == "/tasks/%d" % task2,
  250. "the failure payload names the task and links its page")
  251. check(len(push.hits("/main")) == main_before2,
  252. "an agent failure never reaches users without the pref")
  253. print("== task E: retry next tick (best-effort delivery) ==")
  254. endpoint_flaky = push.url("/flaky")
  255. act(auth, dash["push_subscribe"],
  256. {"endpoint": endpoint_flaky, **subscribe_fields})
  257. push.fail_next["/flaky"] = 1
  258. flaky_before = len(push.hits("/flaky"))
  259. raise_global_block("Flaky delivery question?")
  260. check(wait_for(lambda: len(push.hits("/flaky")) >= flaky_before + 2,
  261. timeout=20, what="flaky endpoint retried") is not None,
  262. "a failed send retries on the next dispatcher tick and lands")
  263. print("== plan 12: goal notifications — blocks/success/failure always ==")
  264. # Goal blocks, completion approval and failure push to EVERY
  265. # subscribed user (the mirror on the goal-run task stays quiet).
  266. push_goal = None
  267. act(auth, slot_public(auth, "/goals", "goals")["create"], {
  268. "project_id": "1", "title": "Push goal", "brief": "Notifies everyone.",
  269. "branch_name": "goal/push"})
  270. conn = db()
  271. row = conn.execute("select id from goals where title = 'Push goal'").fetchone()
  272. conn.close()
  273. push_goal = int(row[0])
  274. fake.goal_script = [("plan", "# Push plan\n\n1. Ring the bells")]
  275. main_before = len(push.hits("/main"))
  276. other_before = len(push.hits("/other"))
  277. act(auth, goal_state(push_goal)["start"], {})
  278. check(wait_for(lambda: goal_state(push_goal)["status"] == "plan_review" or False,
  279. timeout=60, what="push goal enters plan review") is not None,
  280. "the push goal reaches plan review")
  281. check(wait_for(lambda: len(push.hits("/main")) > main_before
  282. and len(push.hits("/other")) > other_before, timeout=15,
  283. what="goal plan-review push") is not None,
  284. "a goal plan review notifies every subscribed user globally")
  285. _, plan_payload = decrypt_webpush(push.hits("/other")[-1], other_browser,
  286. other_public, other_auth)
  287. check("Push goal" in plan_payload["title"] and "plan" in plan_payload["title"]
  288. and plan_payload["url"] == "/goals/%d" % push_goal,
  289. "the goal block payload carries the goal title + click URL (%s)"
  290. % plan_payload)
  291. main_before = len(push.hits("/main"))
  292. fake.goal_script = [("suspend",)]
  293. act(auth, goal_state(push_goal)["plan_pending_block"]["answer_action"],
  294. {"answer": "Plan approved. Proceed with implementation."})
  295. check(wait_for(lambda: goal_state(push_goal)["status"] == "active" or False,
  296. timeout=30, what="push goal active") is not None,
  297. "the push goal activates")
  298. check(quiet_window(3, lambda: len(push.hits("/main")) == main_before,
  299. "no push for an internal resume"),
  300. "the goal agent's own park/resume does not notify (internal)")
  301. # `saddle failure` in goal context fails the goal — global push.
  302. fake.goal_script = [("fail", "the harness collapsed")]
  303. other_before2 = len(push.hits("/other"))
  304. main_before2 = len(push.hits("/main"))
  305. conn = db()
  306. conn.execute("insert into tasks (project_id, goal_id, kind, title, status,"
  307. " priority, completed_at, created_at) values (1, ?, 'task',"
  308. " 'Warm', 'done', 5,"
  309. " strftime('%s','now'),"
  310. " strftime('%s','now','-30 seconds'))", (push_goal,))
  311. conn.commit()
  312. conn.close()
  313. check(wait_for(lambda: goal_state(push_goal)["status"] == "failed" or False,
  314. timeout=60, what="goal failure lands") is not None,
  315. "the goal agent's failure fails the goal")
  316. check(wait_for(lambda: len(push.hits("/other")) > other_before2
  317. and len(push.hits("/main")) > main_before2, timeout=15,
  318. what="goal failure push") is not None,
  319. "a goal failure notifies every subscribed user globally")
  320. _, fail_payload = decrypt_webpush(push.hits("/other")[-1], other_browser,
  321. other_public, other_auth)
  322. check("Push goal" in fail_payload["title"] and "failed" in fail_payload["title"]
  323. and fail_payload["url"] == "/goals/%d" % push_goal,
  324. "the goal failure payload carries the goal title + click URL (%s)"
  325. % fail_payload)
  326. # Completion approval pushes "complete" globally (success always).
  327. act(auth, slot_public(auth, "/goals", "goals")["create"], {
  328. "project_id": "1", "title": "Push done goal", "brief": "Completes.",
  329. "branch_name": "goal/push-done"})
  330. conn = db()
  331. row = conn.execute("select id from goals where title = 'Push done goal'").fetchone()
  332. conn.close()
  333. done_goal = int(row[0])
  334. fake.goal_script = [("plan", "# Done plan")]
  335. act(auth, goal_state(done_goal)["start"], {})
  336. check(wait_for(lambda: goal_state(done_goal)["status"] == "plan_review" or False,
  337. timeout=60, what="done goal plan review") is not None,
  338. "the done goal reaches plan review")
  339. fake.goal_script = [("done", "# Completion\n\nShipped.")]
  340. act(auth, goal_state(done_goal)["plan_pending_block"]["answer_action"],
  341. {"answer": "Plan approved. Proceed with implementation."})
  342. check(wait_for(lambda: goal_state(done_goal)["status"] == "completion_review"
  343. or False, timeout=60, what="done goal completion review") is not None,
  344. "the approved done goal reaches completion review")
  345. main_before = len(push.hits("/main"))
  346. act(auth, goal_state(done_goal)["completion_pending_block"]["answer_action"],
  347. {"answer": "Review approved. Continue with the task."})
  348. check(wait_for(lambda: goal_state(done_goal)["status"] == "complete" or False,
  349. timeout=30, what="done goal completes") is not None,
  350. "the done goal completes")
  351. check(wait_for(lambda: len(push.hits("/main")) > main_before, timeout=15,
  352. what="goal success push") is not None,
  353. "a goal completion notifies every subscribed user globally")
  354. _, done_goal_payload = decrypt_webpush(push.hits("/main")[-1], browser,
  355. browser_public, rotated_auth)
  356. check("Push done goal" in done_goal_payload["title"]
  357. and "complete" in done_goal_payload["title"]
  358. and done_goal_payload["url"] == "/goals/%d" % done_goal,
  359. "the goal success payload carries the goal title + click URL (%s)"
  360. % done_goal_payload)
  361. print("== task E: unsubscribe removes the subscription ==")
  362. dash = slot_public(auth, "/", "dashboard")
  363. check(dash.get("push_subscribed") is True,
  364. "the bell shows subscribed while endpoints exist")
  365. act(auth, dash["push_unsubscribe"], {"endpoint": endpoint_main})
  366. check(subscription_count(endpoint_main) == 0,
  367. "unsubscribing removes the endpoint's row")
  368. # ------------------------------------------------------------------
  369. # Per-browser bell + truthful delivery + test notification
  370. # (the notifications-QoL task): the user-global flag lied to a
  371. # second browser, refused deliveries were silently "delivered",
  372. # and there was no end-to-end probe.
  373. # ------------------------------------------------------------------
  374. def fingerprints_of(auth_handle):
  375. state = slot_public(auth_handle, "/", "dashboard")
  376. return state.get("push_endpoints") or []
  377. def fingerprint_of(endpoint):
  378. return hashlib.sha256(endpoint.encode()).hexdigest()[:16]
  379. print("== per-browser bell: fingerprint state shape ==")
  380. # The retry fixture left admin subscribed to /flaky; drop it so
  381. # this section owns exactly the subscriptions it asserts on.
  382. act(auth, dash["push_unsubscribe"], {"endpoint": endpoint_flaky})
  383. # Fresh admin subscriptions on TWO fake endpoints = two browsers.
  384. push2 = FakePushService()
  385. push2.serve()
  386. endpoint_a = push2.url("/a")
  387. endpoint_b = push2.url("/b")
  388. browser_a = ec.generate_private_key(ec.SECP256R1())
  389. browser_a_public = browser_a.public_key().public_bytes(
  390. Encoding.X962, PublicFormat.UncompressedPoint)
  391. browser_a_auth = os.urandom(16)
  392. act(auth, dash["push_subscribe"],
  393. {"endpoint": endpoint_a, "p256dh": b64url(browser_a_public),
  394. "auth": b64url(browser_a_auth)})
  395. browser_b = ec.generate_private_key(ec.SECP256R1())
  396. browser_b_public = browser_b.public_key().public_bytes(
  397. Encoding.X962, PublicFormat.UncompressedPoint)
  398. browser_b_auth = os.urandom(16)
  399. act(auth, dash["push_subscribe"],
  400. {"endpoint": endpoint_b, "p256dh": b64url(browser_b_public),
  401. "auth": b64url(browser_b_auth)})
  402. dash = slot_public(auth, "/", "dashboard")
  403. prints = dash.get("push_endpoints") or []
  404. check(isinstance(prints, list) and len(prints) == 2,
  405. "two subscriptions => both listed for the user (%s)" % prints)
  406. check(fingerprint_of(endpoint_a) in prints
  407. and fingerprint_of(endpoint_b) in prints,
  408. "each entry is the sha256/16-hex fingerprint of its endpoint")
  409. check(endpoint_a not in json.dumps(dash),
  410. "the raw endpoint URL never leaves the server")
  411. check(bool(dash.get("push_test")) and bool(dash.get("push_subscribe")),
  412. "the dashboard carries the test + subscribe actions")
  413. print("== per-browser bell: second browser still offered Enable ==")
  414. # The old user-global flag showed "Turn off" in EVERY browser once
  415. # any subscription existed; the per-browser client check keys on
  416. # the fingerprint list instead. A third endpoint the server does
  417. # NOT know = an unsubscribed browser of the same user: the state
  418. # that drives the client verdict must not contain its fingerprint.
  419. endpoint_c = push2.url("/c")
  420. check(fingerprint_of(endpoint_c) not in fingerprints_of(auth),
  421. "an unknown browser's fingerprint is absent — the bell shows Enable there")
  422. # ...while the subscribed browsers still see themselves present.
  423. check(fingerprint_of(endpoint_a) in fingerprints_of(auth)
  424. and fingerprint_of(endpoint_b) in fingerprints_of(auth),
  425. "the two subscribed browsers each see themselves in the list")
  426. print("== test notification: real end-to-end round-trip ==")
  427. a_before = len(push2.hits("/a"))
  428. b_before = len(push2.hits("/b"))
  429. result = act(auth, dash["push_test"], {})
  430. message = action_notify(result) or ""
  431. check(wait_for(lambda: len(push2.hits("/a")) > a_before
  432. and len(push2.hits("/b")) > b_before, timeout=15,
  433. what="test notification POSTs") is not None,
  434. "push_test fires a REAL push to both of the user's endpoints")
  435. test_hit = push2.hits("/a")[-1]
  436. _, test_payload = decrypt_webpush(test_hit, browser_a,
  437. browser_a_public, browser_a_auth)
  438. check(test_payload["title"] == "Test notification"
  439. and test_payload["url"] == "/",
  440. "the test payload decrypts with title + url (goes to the dashboard)"
  441. " (%s)" % test_payload)
  442. check(test_hit["headers"].get("content-encoding") == "aes128gcm"
  443. and test_hit["headers"].get("ttl") == "60",
  444. "the test-notification POST carries Content-Encoding: aes128gcm"
  445. " + TTL (the HTTP 400 fix)")
  446. check("2 browser" in message,
  447. "the action response reports both deliveries (%s)" % message)
  448. set_types = [d.get("type") for d in result if d.get("type") == "set"]
  449. check(len(set_types) >= 1, "the action also refreshes the dashboard slot")
  450. print("== test notification: failure surfaces the real reason ==")
  451. push2.status_for["/b"] = 401
  452. b_before = len(push2.hits("/b"))
  453. result = act(auth, dash["push_test"], {})
  454. message = action_notify(result) or ""
  455. check("reached 1 browser" in message and "HTTP 401" in message,
  456. "a refused endpoint reports reached/failed + the status (%s)" % message)
  457. check(len(push2.hits("/b")) > b_before, "the refused endpoint was still POSTed")
  458. print("== truthful delivery: 4xx is refused, retried, never delivered ==")
  459. # A refused (401) endpoint must NOT count as delivered; the queue
  460. # retries on later ticks and — after MAX_ATTEMPTS — gives up WITH a
  461. # warning instead of silently claiming success. The subscription
  462. # row survives (a VAPID key rotation may heal it).
  463. row_before = subscription_count(endpoint_b)
  464. b_before = len(push2.hits("/b"))
  465. raise_global_block("Refused delivery question?")
  466. check(wait_for(lambda: len(push2.hits("/b")) > b_before, timeout=15,
  467. what="first attempt at the refused endpoint") is not None,
  468. "the event still reaches the refused endpoint")
  469. def give_up_in_log():
  470. try:
  471. with open(os.path.join(WORK, "app.log")) as handle:
  472. return "gave up after %d attempts" % 5 in handle.read()
  473. except IOError:
  474. return False
  475. check(wait_for(give_up_in_log, timeout=30, what="give-up warning in app.log")
  476. is not None,
  477. "after MAX_ATTEMPTS the give-up warning lands in the log (never silent)")
  478. check(subscription_count(endpoint_b) == row_before,
  479. "a refused endpoint is NOT pruned (it may recover)")
  480. check(len(fingerprints_of(auth)) == 2,
  481. "the bell state still lists both endpoints after give-up")
  482. print("== truthful delivery: 404/410 pruning regression ==")
  483. push2.status_for["/b"] = 410
  484. b_before = len(push2.hits("/b"))
  485. raise_global_block("Prune after refusal question?")
  486. check(wait_for(lambda: len(push2.hits("/b")) > b_before, timeout=15,
  487. what="POST to the now-gone endpoint") is not None,
  488. "the event reaches the endpoint before its 410")
  489. check(wait_for(lambda: subscription_count(endpoint_b) == 0, timeout=15,
  490. what="410 endpoint pruned after refusal episode") is not None,
  491. "a 410 endpoint is pruned even after an earlier refusal episode")
  492. check(wait_for(lambda: len(fingerprints_of(auth)) == 1, timeout=15,
  493. what="bell state drops the pruned endpoint") is not None,
  494. "the refreshed bell state drops the pruned endpoint")
  495. print("== per-browser bell: markup + client verdict wiring ==")
  496. status, home_page = http("GET", "/", timeout=15)
  497. check(status == 200 and "data-push-test" in home_page
  498. and "data-bell-on" in home_page and "data-bell-off" in home_page,
  499. "the bell card ships the per-browser buttons + test button")
  500. # Goal 41 task 3: the fingerprint engine moved from home.html's
  501. # inline script into app.js (window.initPushBell) so the dashboard's
  502. # rail strip and /profile's settings card share it; the home page
  503. # wires it and still reads the dashboard slot's endpoint list.
  504. app_js = ""
  505. try:
  506. with open(os.path.join(REPO, "src", "pages", "app.js"), "r",
  507. errors="replace") as handle:
  508. app_js = handle.read()
  509. except OSError:
  510. pass
  511. check("initPushBell" in home_page and "stateType" in home_page,
  512. "the home page wires the shared bell engine on the dashboard slot")
  513. check("crypto.subtle.digest" in app_js
  514. and "endpoint_fingerprint" in app_js,
  515. "the shared engine fingerprints this browser's own endpoint")
  516. print("== cleanup: unsubscribe the probe fixtures ==")
  517. act(auth, dash["push_unsubscribe"], {"endpoint": endpoint_a})
  518. check(subscription_count(endpoint_a) == 0,
  519. "unsubscribing removes the probe's endpoint row")