push.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378
  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. # Task 2: only the OTHER user opted in — start + failure reach them
  209. # alone. Baseline again before the enqueue (first start fires at
  210. # the allocation).
  211. fake.fail_next = True
  212. act(auth, slot_public(auth, "/tasks", "tasks")["create"],
  213. {"project_id": "1", "title": "Push failure task", "priority": "5"})
  214. task2 = task_id_of("Push failure task")
  215. conn = db()
  216. conn.execute("insert into task_notify_prefs (task_id, user_id) values (?, 999)",
  217. (task2,))
  218. conn.commit()
  219. conn.close()
  220. main_before2 = len(push.hits("/main"))
  221. other_before2 = len(push.hits("/other"))
  222. enqueue_task(auth, task2)
  223. check(wait_for(lambda: fake.running_task == task2 or False, timeout=30,
  224. what="task 2 assignment") is not None,
  225. "task 2 dispatches (scripted to fail)")
  226. check(wait_for(lambda: task_state(auth, task2)["status"] == "failed"
  227. or False, timeout=30, what="task 2 failed") is not None,
  228. "task 2 fails")
  229. check(wait_for(lambda: len(push.hits("/other")) >= other_before2 + 2,
  230. timeout=15, what="opted-in user's started+failed pushes"),
  231. "the opted-in user gets first start AND failure for the task")
  232. started_hit = [hit for hit in push.hits("/other")[other_before2:]][0]
  233. _, started_payload = decrypt_webpush(started_hit, other_browser,
  234. other_public, other_auth)
  235. check(started_payload["title"] == "Push failure task — started",
  236. "the first-start payload names task + kind (%s)" % started_payload)
  237. _, failure_payload = decrypt_webpush(push.hits("/other")[-1], other_browser,
  238. other_public, other_auth)
  239. check(failure_payload["title"] == "Push failure task — failed"
  240. and failure_payload["url"] == "/tasks/%d" % task2,
  241. "the failure payload names the task and links its page")
  242. check(len(push.hits("/main")) == main_before2,
  243. "an agent failure never reaches users without the pref")
  244. print("== task E: retry next tick (best-effort delivery) ==")
  245. endpoint_flaky = push.url("/flaky")
  246. act(auth, dash["push_subscribe"],
  247. {"endpoint": endpoint_flaky, **subscribe_fields})
  248. push.fail_next["/flaky"] = 1
  249. flaky_before = len(push.hits("/flaky"))
  250. raise_global_block("Flaky delivery question?")
  251. check(wait_for(lambda: len(push.hits("/flaky")) >= flaky_before + 2,
  252. timeout=20, what="flaky endpoint retried") is not None,
  253. "a failed send retries on the next dispatcher tick and lands")
  254. print("== plan 12: goal notifications — blocks/success/failure always ==")
  255. # Goal blocks, completion approval and failure push to EVERY
  256. # subscribed user (the mirror on the goal-run task stays quiet).
  257. push_goal = None
  258. act(auth, slot_public(auth, "/goals", "goals")["create"], {
  259. "project_id": "1", "title": "Push goal", "brief": "Notifies everyone.",
  260. "branch_name": "goal/push"})
  261. conn = db()
  262. row = conn.execute("select id from goals where title = 'Push goal'").fetchone()
  263. conn.close()
  264. push_goal = int(row[0])
  265. fake.goal_script = [("plan", "# Push plan\n\n1. Ring the bells")]
  266. main_before = len(push.hits("/main"))
  267. other_before = len(push.hits("/other"))
  268. act(auth, goal_state(push_goal)["start"], {})
  269. check(wait_for(lambda: goal_state(push_goal)["status"] == "plan_review" or False,
  270. timeout=60, what="push goal enters plan review") is not None,
  271. "the push goal reaches plan review")
  272. check(wait_for(lambda: len(push.hits("/main")) > main_before
  273. and len(push.hits("/other")) > other_before, timeout=15,
  274. what="goal plan-review push") is not None,
  275. "a goal plan review notifies every subscribed user globally")
  276. _, plan_payload = decrypt_webpush(push.hits("/other")[-1], other_browser,
  277. other_public, other_auth)
  278. check("Push goal" in plan_payload["title"] and "plan" in plan_payload["title"]
  279. and plan_payload["url"] == "/goals/%d" % push_goal,
  280. "the goal block payload carries the goal title + click URL (%s)"
  281. % plan_payload)
  282. main_before = len(push.hits("/main"))
  283. fake.goal_script = [("suspend",)]
  284. act(auth, goal_state(push_goal)["plan_pending_block"]["answer_action"],
  285. {"answer": "Plan approved. Proceed with implementation."})
  286. check(wait_for(lambda: goal_state(push_goal)["status"] == "active" or False,
  287. timeout=30, what="push goal active") is not None,
  288. "the push goal activates")
  289. check(quiet_window(3, lambda: len(push.hits("/main")) == main_before,
  290. "no push for an internal resume"),
  291. "the goal agent's own park/resume does not notify (internal)")
  292. # `saddle failure` in goal context fails the goal — global push.
  293. fake.goal_script = [("fail", "the harness collapsed")]
  294. other_before2 = len(push.hits("/other"))
  295. main_before2 = len(push.hits("/main"))
  296. conn = db()
  297. conn.execute("insert into tasks (project_id, goal_id, kind, title, status,"
  298. " priority, completed_at, created_at) values (1, ?, 'task',"
  299. " 'Warm', 'done', 5,"
  300. " strftime('%s','now'),"
  301. " strftime('%s','now','-30 seconds'))", (push_goal,))
  302. conn.commit()
  303. conn.close()
  304. check(wait_for(lambda: goal_state(push_goal)["status"] == "failed" or False,
  305. timeout=60, what="goal failure lands") is not None,
  306. "the goal agent's failure fails the goal")
  307. check(wait_for(lambda: len(push.hits("/other")) > other_before2
  308. and len(push.hits("/main")) > main_before2, timeout=15,
  309. what="goal failure push") is not None,
  310. "a goal failure notifies every subscribed user globally")
  311. _, fail_payload = decrypt_webpush(push.hits("/other")[-1], other_browser,
  312. other_public, other_auth)
  313. check("Push goal" in fail_payload["title"] and "failed" in fail_payload["title"]
  314. and fail_payload["url"] == "/goals/%d" % push_goal,
  315. "the goal failure payload carries the goal title + click URL (%s)"
  316. % fail_payload)
  317. # Completion approval pushes "complete" globally (success always).
  318. act(auth, slot_public(auth, "/goals", "goals")["create"], {
  319. "project_id": "1", "title": "Push done goal", "brief": "Completes.",
  320. "branch_name": "goal/push-done"})
  321. conn = db()
  322. row = conn.execute("select id from goals where title = 'Push done goal'").fetchone()
  323. conn.close()
  324. done_goal = int(row[0])
  325. fake.goal_script = [("plan", "# Done plan")]
  326. act(auth, goal_state(done_goal)["start"], {})
  327. check(wait_for(lambda: goal_state(done_goal)["status"] == "plan_review" or False,
  328. timeout=60, what="done goal plan review") is not None,
  329. "the done goal reaches plan review")
  330. fake.goal_script = [("done", "# Completion\n\nShipped.")]
  331. act(auth, goal_state(done_goal)["plan_pending_block"]["answer_action"],
  332. {"answer": "Plan approved. Proceed with implementation."})
  333. check(wait_for(lambda: goal_state(done_goal)["status"] == "completion_review"
  334. or False, timeout=60, what="done goal completion review") is not None,
  335. "the approved done goal reaches completion review")
  336. main_before = len(push.hits("/main"))
  337. act(auth, goal_state(done_goal)["completion_pending_block"]["answer_action"],
  338. {"answer": "Review approved. Continue with the task."})
  339. check(wait_for(lambda: goal_state(done_goal)["status"] == "complete" or False,
  340. timeout=30, what="done goal completes") is not None,
  341. "the done goal completes")
  342. check(wait_for(lambda: len(push.hits("/main")) > main_before, timeout=15,
  343. what="goal success push") is not None,
  344. "a goal completion notifies every subscribed user globally")
  345. _, done_goal_payload = decrypt_webpush(push.hits("/main")[-1], browser,
  346. browser_public, rotated_auth)
  347. check("Push done goal" in done_goal_payload["title"]
  348. and "complete" in done_goal_payload["title"]
  349. and done_goal_payload["url"] == "/goals/%d" % done_goal,
  350. "the goal success payload carries the goal title + click URL (%s)"
  351. % done_goal_payload)
  352. print("== task E: unsubscribe removes the subscription ==")
  353. dash = slot_public(auth, "/", "dashboard")
  354. check(dash.get("push_subscribed") is True,
  355. "the bell shows subscribed while endpoints exist")
  356. act(auth, dash["push_unsubscribe"], {"endpoint": endpoint_main})
  357. check(subscription_count(endpoint_main) == 0,
  358. "unsubscribing removes the endpoint's row")