push.py 32 KB

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