diff --git a/Dockerfile b/Dockerfile index 1e71455..711581d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -164,6 +164,7 @@ COPY lib lib COPY test test COPY scripts/production-play-physical-fixture.exs scripts/production-play-physical-fixture.exs COPY scripts/production-android-fcm-smoke.exs scripts/production-android-fcm-smoke.exs +COPY scripts/production-web-push-smoke.exs scripts/production-web-push-smoke.exs RUN mix compile diff --git a/assets/js/service_worker.test.mjs b/assets/js/service_worker.test.mjs new file mode 100644 index 0000000..4e89d9e --- /dev/null +++ b/assets/js/service_worker.test.mjs @@ -0,0 +1,156 @@ +import assert from "node:assert/strict" +import {after, before, beforeEach, test} from "node:test" + +const originalSelf = globalThis.self +const listeners = new Map() +const shownNotifications = [] + +let windowClients = [] +let openedWindow = null + +before(async () => { + globalThis.self = { + addEventListener(type, handler) { + listeners.set(type, handler) + }, + location: {origin: "https://whoneedhelp.com"}, + registration: { + async showNotification(title, options) { + shownNotifications.push({title, options}) + } + }, + clients: { + async matchAll(options) { + assert.deepEqual(options, {type: "window", includeUncontrolled: true}) + return windowClients + }, + async openWindow(url) { + openedWindow = url + return {url} + } + } + } + + await import("../../priv/static/sw.js") +}) + +beforeEach(() => { + shownNotifications.length = 0 + windowClients = [] + openedWindow = null +}) + +after(() => { + globalThis.self = originalSelf +}) + +test("push keeps a safe same-origin path in the operating-system notification", async () => { + const event = pushEvent({ + title: "Request accepted", + body: "Open the private request chat.", + path: "/requests/7f84fc06-0fae-4e9d-b266-041ca87678d1?section=chat", + tag: "request-update" + }) + + listeners.get("push")(event) + await event.completion + + assert.deepEqual(shownNotifications, [ + { + title: "Request accepted", + options: { + body: "Open the private request chat.", + icon: "/images/pwa-192.png", + badge: "/images/favicon-48.png", + tag: "request-update", + data: { + path: "/requests/7f84fc06-0fae-4e9d-b266-041ca87678d1?section=chat" + } + } + } + ]) +}) + +test("push rejects an external path instead of leaving the application origin", async () => { + const event = pushEvent({ + title: "Unsafe target", + path: "//attacker.example/redirect" + }) + + listeners.get("push")(event) + await event.completion + + assert.equal(shownNotifications[0].options.data.path, "/notifications") +}) + +test("notification click navigates and focuses an existing application window", async () => { + const navigation = [] + let focused = false + + windowClients = [ + { + url: "https://whoneedhelp.com/notifications?section=settings", + async navigate(url) { + navigation.push(url) + }, + async focus() { + focused = true + } + }, + {url: "https://example.com/"} + ] + + const event = clickEvent("/requests/request-id") + listeners.get("notificationclick")(event) + await event.completion + + assert.equal(event.closed, true) + assert.deepEqual(navigation, ["https://whoneedhelp.com/requests/request-id"]) + assert.equal(focused, true) + assert.equal(openedWindow, null) +}) + +test("notification click opens the application when no application window exists", async () => { + windowClients = [{url: "https://example.com/"}] + + const event = clickEvent("/notifications") + listeners.get("notificationclick")(event) + await event.completion + + assert.equal(event.closed, true) + assert.equal(openedWindow, "https://whoneedhelp.com/notifications") +}) + +function pushEvent(payload) { + return waitableEvent({ + data: { + json() { + return payload + } + } + }) +} + +function clickEvent(path) { + const event = waitableEvent({ + closed: false, + notification: { + data: {path}, + close() { + event.closed = true + } + } + }) + + return event +} + +function waitableEvent(properties) { + return { + ...properties, + completion: Promise.resolve(), + waitUntil(completion) { + this.completion = completion + } + } +} diff --git a/docs/public-launch-checklist.md b/docs/public-launch-checklist.md index a62d413..6ea2b14 100644 --- a/docs/public-launch-checklist.md +++ b/docs/public-launch-checklist.md @@ -61,6 +61,18 @@ evidence. The frozen hackathon test deployment remained unchanged. Exact current-candidate and 2026-08-14 operations evidence is recorded in `docs/verification.md`. +The current local worktree was rechecked on 2026-08-26 after hardening the +run-scoped production browser Web Push verifier and adding tests for the exact +shipped service worker. The final isolated quality unit completed successfully +in 3 minutes 7.715 seconds with a measured 229 MiB memory peak and zero swap: +491 ExUnit tests and 23 JavaScript asset tests passed together with format, +compilation, xref, Credo, Sobelow, Dialyzer, Hex/npm audits, release and +recovery drills, and all configured image scans. The scans reported zero +detected vulnerabilities. Exact cleanup left no run-owned container, network, +volume, temporary image, or quality state. These changes remain local and +uncommitted; public Git, the frozen hackathon test deployment, shared Caddy, +and production were not modified by this quality rerun. + ## 2. Verify production configuration without exposing secrets Run both checks against the single ignored production `.env`. The first reports @@ -95,6 +107,20 @@ provider `MessageId` deduplication, confirmation-gated staff visibility, and a real reply. Do not advertise that address while either inbound setting is missing or before this external test passes. +The production rate-limit configuration was re-read from the running release +on 2026-08-26 without printing the surrounding environment. The healthy +`who_need_help_production` application had zero restarts and all twelve required +email/account and client-IP policies matched the documented pilot map: +registration and magic-link email 4/hour per address and 120/hour per IP; +password login 10/15 minutes per address and 300/15 minutes per IP; email +change 3/day per account and 60/hour per IP; support intake 5/day per account +and 120/hour per IP; and content-removal intake 20/day per account and 120/hour +per IP. The PostgreSQL implementation and cleanup have repeatable one-CPU +measurements in `docs/performance.md`. Those measurements verify the limiter, +not the suitability of the chosen pilot thresholds for real-user behaviour; +the representative-load and abuse-policy decision below therefore remains +unchecked. + ## 3. Record human and legal decisions The following decisions are intentionally not generated by code: @@ -225,9 +251,18 @@ as forward-only rather than receiving an invented database rollback. first attempt with exact job and notification cleanup. - [ ] A person has observed the resulting operating-system browser notification and its navigation target on the subscribed workstation. + Automated tests now verify the shipped service worker's same-origin path + validation and existing-window/new-window click-navigation contracts, + but they do not replace this human observation. - [x] The production Android build signs in, opens verified App Links, receives FCM, and performs user-started foreground location sharing on a physical - device. + device. On 2026-08-26 the retained `0.1.3 (4)` AAB checksum was rechecked + against `internal-release-v4.md`, and the connected physical phone again + passed the strict Play-delivery verifier: Google Play installer, protected + Play App Signing identity, exact version, verified production App Link, + and `MainActivity` resolution all matched. The running application process + exposed no fatal exception, ANR, out-of-memory, or native-crash record in + its current process log. - [x] The full two-person help flow passes: create, discover, accept, chat, optional tracking, start, handover code, both confirmations, blind review, report/block, and notification delivery. diff --git a/docs/verification.md b/docs/verification.md index 374ef81..2b69b25 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -1,8 +1,49 @@ # Who Need Help — implementation verification -Observed through 2026-08-25 in the local workspace. This report separates observed +Observed through 2026-08-26 in the local workspace. This report separates observed results from product limits and unknown production properties. +## Local candidate and operational recheck on 2026-08-26 + +- The uncommitted production Web Push verifier hardening and service-worker + navigation tests passed the complete isolated quality pipeline: 491 ExUnit + tests, 23 JavaScript asset tests, format, compilation, xref, Credo, Sobelow, + Dialyzer, dependency audits, release and recovery drills, and all configured + image scans. The final unit exited successfully after 3 minutes 7.715 seconds + with a measured 229 MiB memory peak and zero swap; image scans reported zero + detected vulnerabilities. Exact cleanup left no run-owned container, + network, volume, temporary image, or quality state. Public Git, the frozen + test deployment, shared Caddy, and production were not changed by this + quality run. +- A read-only RPC against the healthy production application on revision + `305bdebdd1912384e9774ef577b8e56c89713bee` observed zero container restarts + and all twelve required rate-limit policies. Their values matched the + documented pilot map: registration and magic-link email 4/hour per address + and 120/hour per IP; password login 10/15 minutes per address and 300/15 + minutes per IP; email change 3/day per account and 60/hour per IP; support + intake 5/day per account and 120/hour per IP; and content-removal intake + 20/day per account and 120/hour per IP. The retained one-CPU benchmark proves + atomic counter behaviour and exact cleanup, not real-user suitability of + those thresholds. +- The production application port was bound to host loopback rather than a + public interface. The public Caddy `v2.11.4` edge used an unmodified + `reverse_proxy` for both the production and frozen-test upstreams. Caddy's + documented default discards incoming client-supplied `X-Forwarded-*` values + when it constructs the upstream forwarding headers, so the application's + `Plug.RewriteOn` client-IP scope is not based on an arbitrary public request + header in this observed topology. No edge configuration was changed. +- The connected physical phone again passed the strict Play-delivery verifier + for `org.whoneedhelp.mobile` version `0.1.3 (4)`: Google Play installer, a + protected Play signing identity, verified production App Link, and + `MainActivity` resolution matched. The current application process log + contained no fatal exception, ANR, out-of-memory, or native-crash entry. +- The production backup timer and independent BuyVM monitor timer were loaded, + enabled, and active. Their latest completed services exited successfully; + the external state reported readiness, aggregate metrics, and backup + freshness `up` against the operator-selected 129,600-second alert threshold. + This is current mechanism evidence, not approval of retention, RPO, RTO, + capacity, key custody, or responsible owners. + ## Production authentication-email and invalid-link verification on 2026-08-25 - Local application candidate `305bdebdd1912384e9774ef577b8e56c89713bee` @@ -1417,7 +1458,7 @@ this audit. | Privacy settings | Implemented and browser-verified | The profile exposed hidden, approximate public, exact for active match, and explicit exact-public options. Blocking and current-position cleanup have automated tests. | Exact public location remains a user opt-in; legal privacy and retention text still requires jurisdiction-specific review before launch. | | Reputation and anti-abuse | Implemented at MVP level | Handover codes, two-party completion, double-blind reviews, unique-counterpart ranking, optional movement/proximity evidence, reports, blocks, abuse signals, and moderator audit paths have automated tests. | The system is not bot-proof and does not claim identity verification. No punitive numeric policy is enabled without measured and approved thresholds. | | Account registration and sign-in | Implemented and browser/physical-device verified | Email registration is a single passwordless flow: it records the display name and acceptance once, sends a confirmation link, and does not duplicate a user on subsequent sign-in. Confirmed users can keep using magic links or add a password in settings. Google OpenID Connect registration, sign-in, link, unlink, replay prevention, verified-email enforcement, and account-ownership rules are covered by the automated suite. Real headed Chrome exercised the development callback and identity-linking paths. The Play-delivered production build then completed production Google sign-in without a secondary ownership email or duplicate account. A production authentication email was also observed in the external Gmail mailbox with `whoneedhelp.com` DKIM signing. On 2026-08-25, a message generated after production release `dcac2fa` reached Gmail Inbox, Gmail detected one direct `https://whoneedhelp.com` link without a provider tracking host, and that link completed the explicit production reauthentication flow. | The application cannot guarantee how every mailbox provider chooses to auto-link visible text. SMTP exactly-once delivery is not claimed. | -| Notifications and nearby alerts | Implemented and browser/physical-device verified | Users can configure push, quiet hours, category/urgency/day/time filters, a private matching center, and 1/3/5/10/25 km radii. Durable inbox notifications and Oban push jobs are tested; public notification payloads omit chat text, exact coordinates, and the private saved-area label. Immediate per-request nearby email is retired. Development Web Push and FCM delivery were exercised. The Play-delivered production build registered its FCM device, received one run-scoped production notification, and routed its tap to the in-app inbox; exact cleanup removed that notification and its jobs. A guarded production smoke then delivered one browser-only job to the newest real active Web Push subscription on its first attempt and removed the exact job and notification. | A batched nearby email digest is not implemented; it requires a defined cadence and delivery cursor. Provider acceptance and the still-active subscription are verified; visible operating-system presentation and click navigation were not programmatically observed. | +| Notifications and nearby alerts | Implemented and browser/physical-device verified | Users can configure push, quiet hours, category/urgency/day/time filters, a private matching center, and 1/3/5/10/25 km radii. Durable inbox notifications and Oban push jobs are tested; public notification payloads omit chat text, exact coordinates, and the private saved-area label. Immediate per-request nearby email is retired. Development Web Push and FCM delivery were exercised. The Play-delivered production build registered its FCM device, received one run-scoped production notification, and routed its tap to the in-app inbox; exact cleanup removed that notification and its jobs. A guarded production smoke then delivered one browser-only job to the newest real active Web Push subscription on its first attempt. The registered production service worker returned the exact persistent notification with the privacy-safe title, body, tag, and `/notifications` target before exact cleanup removed the job, database notification, and browser notification. | A batched nearby email digest is not implemented; it requires a defined cadence and delivery cursor. Human observation of clicking the browser operating-system notification and following its target remains unverified. | | Social profiles | Manual links implemented; optional GitHub verification implemented and automated-tested | Manual links cannot set verification fields. The optional GitHub flow uses state, PKCE, a user-bound one-time session, unique provider ownership, and an audit record. The local protocol drill also performs real HTTP token/user exchanges without returning an access token to the application. | GitHub OAuth credentials are intentionally absent and are not required for registration or the help flow. The real external provider redirect/callback remains disabled and unverified; other providers remain manual/unverified. | | Support and content removal | Implemented and browser-verified | Public support, account deletion, general removal, and TAKE IT DOWN forms use separate audited workflows; public support remains pending and outside the staff queue until its private email link verifies the contact, while authenticated submissions use the account email immediately. Exact pending repeats are deduplicated, email/IP intake limits are independently configurable, and moderator-only operations can update verified cases. TAKE IT DOWN accepts URLs/text only and records a 48-hour review due time. Authenticated users can download an allowlisted JSON export, and moderators can run a read-only deletion relationship preflight. | The current product hosts no user media and does not claim TAKE IT DOWN coverage. Staffing, measured rate-limit thresholds, jurisdiction-specific legal classification, final retention rules, destructive account erasure/anonymisation, and identical-media-copy handling remain operational/legal work. | | Voluntary thanks | Implemented as an external optional link | A helper can expose an optional link after completion; the UI states that the platform does not process the payment. | The platform does not provide payments, escrow, refunds, tax reporting, or payment guarantees. | @@ -1425,7 +1466,7 @@ this audit. | Multiple web/worker instances | Implemented and locally failure/rollout-verified | The final isolated Compose drill passed BEAM crashes and sequential replacement with 3 web/2 worker replicas: all five nodes joined, PubSub passed, and 744/744 readiness requests succeeded. The project-owned kind cluster replaced all 2 web/2 worker pod UIDs under `maxUnavailable=0`; all four replacement pods joined, PubSub passed, and 363/363 samples ultimately succeeded. | Local PostGIS is a single instance. Production database HA, backups, and recovery are operator work and are not claimed complete. | | Local observability | Implemented and protocol-verified | Pinned Prometheus scraped the exact 3 web and 2 worker targets with a file Bearer credential; Grafana provisioned a healthy datasource and ten-panel web/worker/BEAM/Ecto/Oban dashboard; Alertmanager delivered firing and resolved webhooks for an induced scoped replica stop. | Local delivery does not establish production retention, notification-provider reliability, on-call policy, or measured alert thresholds. | | Encrypted local backup | Implemented and failure-verified | Pinned Restic streamed PostgreSQL custom format into pinned local MinIO with no host plaintext dump, passed full-data checking and a fresh-database restore, rejected a corrupted repository, and published no snapshot for an interrupted upload. The one-run MinIO project and volume were removed after retaining the non-secret evidence. | The drill proves the local mechanism, not off-site durability, database HA, or a production RPO/RTO/retention policy. | -| External protocol boundaries | Implemented and provider-verified for the current pilot paths | Assent/Req and Swoosh/gen_smtp paths have internal success/rejection/retry/replay/timeout coverage. Google OIDC discovery/authorization/token/JWKS with nonce and PKCE and the optional GitHub boundary are locally exercised through mocks. Real development checks covered Google, authenticated Brevo SMTP, Web Push, and FCM. Production checks covered Google OIDC, Brevo authentication-email receipt with a direct production-domain action, browser Web Push provider acceptance, and FCM delivery. The current push code includes provider-neutral HTTP delivery plus standards-based Web Push and FCM adapters with durable Oban retries and invalid-device cleanup. UniSender observations below are retained only as historical evidence and do not describe current wiring. | The real GitHub provider, visible browser OS-notification interaction, and APNs remain unverified. SMTP exactly-once delivery is not claimed. | +| External protocol boundaries | Implemented and provider-verified for the current pilot paths | Assent/Req and Swoosh/gen_smtp paths have internal success/rejection/retry/replay/timeout coverage. Google OIDC discovery/authorization/token/JWKS with nonce and PKCE and the optional GitHub boundary are locally exercised through mocks. Real development checks covered Google, authenticated Brevo SMTP, Web Push, and FCM. Production checks covered Google OIDC, Brevo authentication-email receipt with a direct production-domain action, browser Web Push provider acceptance and persistent-notification registration, and FCM delivery. The current push code includes provider-neutral HTTP delivery plus standards-based Web Push and FCM adapters with durable Oban retries and invalid-device cleanup. UniSender observations below are retained only as historical evidence and do not describe current wiring. | The real GitHub provider, a human click on the browser operating-system notification, and APNs remain unverified. SMTP exactly-once delivery is not claimed. | ## Reproducible checks @@ -3940,3 +3981,50 @@ promoted. `who-need-help:test-cf7bacdf61ff`, shared Caddy healthy on `who-need-help:caddy-production-a7412c65b51a`, and public Git main unchanged at `921e04b3608007675e22e7e26e0beb3975dbba58`. + +# 2026-08-26 production browser Web Push presentation replay + +- The authenticated production delivery-settings page showed browser push + enabled for the current device, an active `Linux x86_64 · Web Push` device, + the independent active Android FCM device, and enabled delivery preferences. + No preference was changed during this check. +- The read-only plan scoped the mutation to one production inbox notification + and one browser Web Push job for `simpletestxxx@gmail.com` in database + `who_need_help_production` on image + `who-need-help:production-305bdebdd191`. It excluded email, Android FCM, the + frozen test deployment, shared Caddy, and the published repository. +- Run `20260825222014-56db5679a12d` created job `51535`. Provider verification + recorded a completed job on attempt 1 and an active target device. The + production service worker then returned one persistent notification with + title `Support request updated`, privacy-safe body + `Open Who Need Help to review the latest update.`, tag + `production-web-push-smoke:20260825222014-56db5679a12d`, and navigation path + `/notifications`. +- Exact cleanup deleted one run-scoped job and one run-scoped database + notification and removed the run-owned server manifest, temporary script, + and local state file. A final service-worker query returned zero + notifications with the exact run tag. A human click on the operating-system + notification and the resulting navigation were not observed and are not + claimed. +- The smoke implementation now separates read-only planning, preparation, + provider verification, and cleanup so presentation can be inspected before + deletion. Its manifest is created with mode `0600`, includes a unique + ownership token, and is persisted in the same database transaction as the + notification and job. The local state reader treats the file as data rather + than sourcing shell code, and both the run identifier and manifest path are + validated against the exact run-owned scope. Three focused regression tests + cover manifest-write rollback, path rejection, and exact manifest-bound + cleanup; all three passed in an isolated temporary test image, which was + removed after the run. +- Four tests now execute the exact shipped `priv/static/sw.js` and verify that + safe same-origin paths are retained, protocol-relative external paths fall + back to `/notifications`, an existing application window is navigated and + focused, and a new application window is opened when necessary. They are + part of the asset gate; the final quality run reported 23 passing JavaScript + tests and zero failures. +- A second guarded provider replay created exact production job `51608` for + the active browser subscription. Provider verification completed on attempt + one without disabling the device. Cleanup then deleted exactly one job and + one database notification and removed the mode-`0600` local state file. The + existing Chrome tab remained at `/notifications?section=settings`, so a + human click on the operating-system surface still is not claimed. diff --git a/scripts/production-web-push-smoke.exs b/scripts/production-web-push-smoke.exs index fc6ec6d..3b49ccf 100644 --- a/scripts/production-web-push-smoke.exs +++ b/scripts/production-web-push-smoke.exs @@ -29,7 +29,7 @@ defmodule WhoNeedHelp.ProductionWebPushSmoke do unless Regex.match?(~r/^[a-z0-9-]+$/, run_id), do: raise("invalid run id") - unless String.starts_with?(manifest_path, "/tmp/wnh-production-web-push-") do + unless manifest_path == "/tmp/wnh-production-web-push-#{run_id}.json" do raise "invalid manifest path" end @@ -50,6 +50,8 @@ defmodule WhoNeedHelp.ProductionWebPushSmoke do defp prepare(context) do if File.exists?(context.manifest_path), do: raise("manifest already exists") + manifest_ownership_token = Ecto.UUID.generate() + user = Repo.get_by(User, email: context.user_email) unless match?(%User{confirmed_at: %DateTime{}, moderation_status: :active}, user) do @@ -77,42 +79,50 @@ defmodule WhoNeedHelp.ProductionWebPushSmoke do raise "run-scoped notification already exists" end - {:ok, fixture} = - Repo.transaction(fn -> - notification = - %Notification{} - |> Notification.changeset(%{ - user_id: user.id, - kind: :support_update, - title: "Who Need Help notification check", - body: "Production browser notifications are working.", - path: "/notifications", - data: %{"run_id" => context.run_id, "synthetic" => true}, - idempotency_key: context.idempotency_key - }) - |> Repo.insert!() + fixture = + try do + {:ok, committed_fixture} = + Repo.transaction(fn -> + notification = + %Notification{} + |> Notification.changeset(%{ + user_id: user.id, + kind: :support_update, + title: "Who Need Help notification check", + body: "Production browser notifications are working.", + path: "/notifications", + data: %{"run_id" => context.run_id, "synthetic" => true}, + idempotency_key: context.idempotency_key + }) + |> Repo.insert!() - job = - %{"notification_id" => notification.id, "device_id" => device.id} - |> DeviceDeliveryWorker.new() - |> Oban.insert!() + job = + %{"notification_id" => notification.id, "device_id" => device.id} + |> DeviceDeliveryWorker.new() + |> Oban.insert!() - %{notification: notification, device: device, job: job} - end) + manifest = %{ + "schema_version" => 1, + "run_id" => context.run_id, + "database" => context.database, + "user_email" => context.user_email, + "ownership_token" => manifest_ownership_token, + "idempotency_key" => context.idempotency_key, + "notification_id" => notification.id, + "device_id" => device.id, + "job_id" => job.id + } - manifest = %{ - "schema_version" => 1, - "run_id" => context.run_id, - "database" => context.database, - "user_email" => context.user_email, - "idempotency_key" => context.idempotency_key, - "notification_id" => fixture.notification.id, - "device_id" => fixture.device.id, - "job_id" => fixture.job.id - } + persist_manifest!(context.manifest_path, manifest) + %{notification: notification, device: device, job: job} + end) - File.write!(context.manifest_path, Jason.encode_to_iodata!(manifest, pretty: true)) - File.chmod!(context.manifest_path, 0o600) + committed_fixture + rescue + exception -> + remove_owned_manifest(context, manifest_ownership_token) + reraise exception, __STACKTRACE__ + end IO.puts("web_push_smoke_prepared=true") IO.puts("job_id=#{fixture.job.id}") @@ -197,6 +207,7 @@ defmodule WhoNeedHelp.ProductionWebPushSmoke do manifest["schema_version"] == 1 and manifest["run_id"] == context.run_id and manifest["database"] == context.database and manifest["user_email"] == context.user_email and + uuid?(manifest["ownership_token"]) and manifest["idempotency_key"] == context.idempotency_key and uuid?(manifest["notification_id"]) and uuid?(manifest["device_id"]) and is_integer(manifest["job_id"]) @@ -205,6 +216,36 @@ defmodule WhoNeedHelp.ProductionWebPushSmoke do manifest end + defp persist_manifest!(path, manifest) do + encoded = Jason.encode_to_iodata!(manifest, pretty: true) + + File.open!(path, [:write, :binary, :exclusive], fn file -> + IO.binwrite(file, encoded) + end) + + try do + File.chmod!(path, 0o600) + rescue + exception -> + File.rm(path) + reraise exception, __STACKTRACE__ + end + end + + defp remove_owned_manifest(context, ownership_token) do + with {:ok, encoded} <- File.read(context.manifest_path), + {:ok, manifest} <- Jason.decode(encoded), + true <- manifest["run_id"] == context.run_id, + true <- manifest["database"] == context.database, + true <- manifest["user_email"] == context.user_email, + true <- manifest["idempotency_key"] == context.idempotency_key, + true <- manifest["ownership_token"] == ownership_token do + File.rm(context.manifest_path) + else + _missing_or_different_manifest -> :ok + end + end + defp required_option!(options, name) do case Map.get(options, name) do value when is_binary(value) and value != "" -> value @@ -214,4 +255,4 @@ defmodule WhoNeedHelp.ProductionWebPushSmoke do defp uuid?(value) when is_binary(value), do: match?({:ok, _}, Ecto.UUID.cast(value)) defp uuid?(_value), do: false -end \ No newline at end of file +end diff --git a/scripts/production-web-push-smoke.sh b/scripts/production-web-push-smoke.sh index 5677475..aface33 100755 --- a/scripts/production-web-push-smoke.sh +++ b/scripts/production-web-push-smoke.sh @@ -14,12 +14,16 @@ usage() { cat >&2 <<'EOF' Usage: ./scripts/production-web-push-smoke.sh plan USER_EMAIL --check-only whoneedhelp.com - ./scripts/production-web-push-smoke.sh run USER_EMAIL --confirm whoneedhelp.com + ./scripts/production-web-push-smoke.sh prepare USER_EMAIL --confirm whoneedhelp.com + ./scripts/production-web-push-smoke.sh verify --from-state --confirm whoneedhelp.com + ./scripts/production-web-push-smoke.sh cleanup --from-state --confirm whoneedhelp.com -run sends one browser-only production Web Push notification to the newest active -Web Push device for USER_EMAIL, verifies the exact Oban delivery completed on its -first attempt, then removes the run-scoped notification, job, and temporary files. -It never invokes the notification email worker or the Android FCM device. +prepare sends one browser-only production Web Push notification to the newest +active Web Push device for USER_EMAIL. verify proves the exact Oban delivery +completed on its first attempt. cleanup removes only the run-scoped notification, +job, manifest, and temporary script. The separate phases leave time to inspect +and click the operating-system notification. No email worker, Android FCM device, +frozen test project, Caddy, or public Git is used. EOF exit 1 } @@ -35,6 +39,19 @@ encode() { printf %s "$1" | base64 | tr -d '\n' } +read_state_value() { + local key=$1 + + awk -F= -v key="$key" ' + $1 == key { + if (found) exit 2 + print substr($0, index($0, "=") + 1) + found = 1 + } + END {if (found != 1) exit 1} + ' "$STATE_FILE" +} + if [[ $# -ne 4 ]]; then usage fi @@ -46,15 +63,20 @@ HOST=$4 case "$ACTION" in plan) [[ "$CONFIRMATION" == --check-only ]] || usage ;; - run) [[ "$CONFIRMATION" == --confirm ]] || usage ;; + prepare) [[ "$CONFIRMATION" == --confirm ]] || usage ;; + verify | cleanup) + [[ "$USER_EMAIL" == --from-state && "$CONFIRMATION" == --confirm ]] || usage + ;; *) usage ;; esac [[ "$HOST" == whoneedhelp.com ]] || usage -if [[ ! "$USER_EMAIL" =~ ^[^[:space:]@]+@[^[:space:]@]+$ ]]; then - echo "USER_EMAIL is invalid." >&2 - exit 1 +if [[ "$ACTION" == plan || "$ACTION" == prepare ]]; then + if [[ ! "$USER_EMAIL" =~ ^[^[:space:]@]+@[^[:space:]@]+$ ]]; then + echo "USER_EMAIL is invalid." >&2 + exit 1 + fi fi if [[ ! -f "$LOCAL_SCRIPT" ]]; then @@ -112,62 +134,65 @@ if [[ "$ACTION" == plan ]]; then exit 0 fi -if [[ -e "$STATE_FILE" ]]; then - echo "A prior Web Push smoke state exists; inspect it before starting another run." >&2 - exit 1 -fi - mkdir -p "$ROOT/output/runtime" chmod 700 "$ROOT/output/runtime" umask 077 -RUN_ID="$(date -u +%Y%m%d%H%M%S)-$(tr -d - &2 + exit 1 + fi -cat >"$STATE_FILE" <"$STATE_FILE" </dev/null 2>&1 || true - rm -f "$STATE_FILE" -} - -preserve_failed_run() { - local status=$1 - - trap - EXIT INT TERM - - if [[ "$cleanup_complete" == true ]]; then - remove_temporary_files - else - printf '%s\n' \ - "Web Push smoke did not complete exact record cleanup." \ - "Run-scoped state was preserved for inspection:" \ - " local state: $STATE_FILE" \ - " remote manifest: $REMOTE_MANIFEST" \ - " remote script: $REMOTE_SCRIPT" >&2 + "docker exec -i '$CONTAINER' sh -c 'umask 077; cat >\"$REMOTE_SCRIPT\"'" <"$LOCAL_SCRIPT" +else + if [[ ! -f "$STATE_FILE" ]]; then + echo "No Web Push smoke state exists." >&2 + exit 1 fi - exit "$status" -} + schema_version=$(read_state_value schema_version) + run_id=$(read_state_value run_id) + ssh_target=$(read_state_value ssh_target) + container=$(read_state_value container) + user_email=$(read_state_value user_email) + remote_script=$(read_state_value remote_script) + remote_manifest=$(read_state_value remote_manifest) -trap 'preserve_failed_run $?' EXIT -trap 'preserve_failed_run 130' INT -trap 'preserve_failed_run 143' TERM + expected_remote_script="/tmp/wnh-production-web-push-$run_id.exs" + expected_remote_manifest="/tmp/wnh-production-web-push-$run_id.json" -ssh -o BatchMode=yes "$SSH_TARGET" \ - "docker exec -i '$CONTAINER' sh -c 'umask 077; cat >\"$REMOTE_SCRIPT\"'" <"$LOCAL_SCRIPT" + if [[ "${schema_version:-}" != 1 || "${ssh_target:-}" != "$SSH_TARGET" || + "${container:-}" != "$CONTAINER" || + ! "${run_id:-}" =~ ^[0-9]{14}-[a-f0-9]{12}$ || + ! "${user_email:-}" =~ ^[^[:space:]@]+@[^[:space:]@]+$ || + "${remote_script:-}" != "$expected_remote_script" || + "${remote_manifest:-}" != "$expected_remote_manifest" ]]; then + echo "Web Push smoke state does not match the current production target." >&2 + exit 1 + fi + + RUN_ID=$run_id + USER_EMAIL=$user_email + REMOTE_SCRIPT=$remote_script + REMOTE_MANIFEST=$remote_manifest +fi RUN=$(encode "$RUN_ID") DATABASE=$(encode "$EXPECTED_DATABASE") @@ -183,25 +208,22 @@ rpc_action() { "docker exec '$CONTAINER' /app/bin/who_need_help rpc '$expression'" } -rpc_action prepare - -verified=false -for _attempt in $(seq 1 20); do - if rpc_action verify; then - verified=true - break - fi - sleep 1 -done - -if [[ "$verified" != true ]]; then - echo "Web Push provider delivery did not verify within 20 seconds." >&2 - echo "Run-scoped state remains in production for inspection; automatic record deletion was not attempted." >&2 - exit 1 -fi - -rpc_action cleanup -cleanup_complete=true -remove_temporary_files -trap - EXIT INT TERM -echo "production_web_push_smoke_complete=true" +case "$ACTION" in + prepare) + if ! rpc_action prepare; then + printf 'Preparation failed; state is preserved for exact inspection: %s\n' "$STATE_FILE" >&2 + exit 1 + fi + printf 'state=%s\nnext=inspect and click the OS notification, then run verify and cleanup\n' "$STATE_FILE" + ;; + verify) + rpc_action verify + ;; + cleanup) + rpc_action cleanup + ssh -o BatchMode=yes "$SSH_TARGET" \ + "docker exec '$CONTAINER' rm -f '$REMOTE_SCRIPT' '$REMOTE_MANIFEST'" + rm -f "$STATE_FILE" + echo "production_web_push_smoke_cleanup_complete=true" + ;; +esac diff --git a/scripts/quality.sh b/scripts/quality.sh index daa6bbb..4f76f54 100755 --- a/scripts/quality.sh +++ b/scripts/quality.sh @@ -1852,6 +1852,7 @@ echo "Auditing locked browser dependencies" docker build --target node_deps --tag "$assets_image" . docker run --rm \ --volume "$ROOT/assets/js:/assets/js:ro" \ + --volume "$ROOT/priv/static/sw.js:/priv/static/sw.js:ro" \ "$assets_image" \ npm test docker run --rm "$assets_image" npm audit --audit-level=high diff --git a/test/scripts/production_web_push_smoke_test.exs b/test/scripts/production_web_push_smoke_test.exs new file mode 100644 index 0000000..97d4817 --- /dev/null +++ b/test/scripts/production_web_push_smoke_test.exs @@ -0,0 +1,123 @@ +Code.require_file(Path.expand("../../scripts/production-web-push-smoke.exs", __DIR__)) + +defmodule WhoNeedHelp.ProductionWebPushSmokeTest do + use WhoNeedHelp.DataCase, async: false + use Oban.Testing, repo: WhoNeedHelp.Repo + + import Ecto.Query + import ExUnit.CaptureIO + import WhoNeedHelp.AccountsFixtures + + alias WhoNeedHelp.Notifications + alias WhoNeedHelp.Notifications.Notification + alias WhoNeedHelp.Push.DeviceDeliveryWorker + alias WhoNeedHelp.Repo + + setup do + user = user_fixture(display_name: "Production Web Push smoke test user") + scope = user_scope_fixture(user) + + {:ok, device} = + Notifications.register_device(scope, %{ + "platform" => "web", + "provider" => "web_push", + "token" => "https://push.example/subscriptions/#{Ecto.UUID.generate()}", + "installation_id" => Ecto.UUID.generate(), + "p256dh" => "test-public-key", + "auth_secret" => "test-auth-secret", + "device_label" => "Production Web Push smoke test" + }) + + %Postgrex.Result{rows: [[database]]} = + Repo.query!("SELECT current_database()", [], log: false) + + %{user: user, device: device, database: database} + end + + test "manifest write failure rolls back the notification and exact delivery job", context do + run_id = run_id() + manifest_path = "/tmp/wnh-production-web-push-#{run_id}.json" + File.rm_rf!(manifest_path) + File.ln_s!("/tmp/wnh-production-web-push-missing-#{run_id}/manifest.json", manifest_path) + on_exit(fn -> File.rm_rf!(manifest_path) end) + + refute File.exists?(manifest_path) + + options = options(context, run_id, manifest_path) + worker = to_string(DeviceDeliveryWorker) + jobs_before = Repo.aggregate(from(job in Oban.Job, where: job.worker == ^worker), :count) + + assert_raise File.Error, fn -> + WhoNeedHelp.ProductionWebPushSmoke.run("prepare", options) + end + + refute Repo.get_by(Notification, + idempotency_key: "production-web-push-smoke:#{run_id}" + ) + + assert Repo.aggregate(from(job in Oban.Job, where: job.worker == ^worker), :count) == + jobs_before + + assert {:ok, %File.Stat{type: :symlink}} = File.lstat(manifest_path) + end + + test "manifest path must belong to the exact run", context do + run_id = run_id() + + assert_raise RuntimeError, "invalid manifest path", fn -> + WhoNeedHelp.ProductionWebPushSmoke.run( + "prepare", + options(context, run_id, "/tmp/wnh-production-web-push-another-run.json") + ) + end + end + + test "prepare and cleanup retain an exact manifest-bound scope", context do + run_id = run_id() + manifest_path = "/tmp/wnh-production-web-push-#{run_id}.json" + File.rm(manifest_path) + on_exit(fn -> File.rm(manifest_path) end) + options = options(context, run_id, manifest_path) + + assert capture_io(fn -> + WhoNeedHelp.ProductionWebPushSmoke.run("prepare", options) + end) =~ "web_push_smoke_prepared=true" + + assert {:ok, stat} = File.stat(manifest_path) + assert stat.mode |> Bitwise.band(0o777) == 0o600 + + manifest = manifest_path |> File.read!() |> Jason.decode!() + assert {:ok, _ownership_token} = Ecto.UUID.cast(manifest["ownership_token"]) + + assert %Notification{id: notification_id} = + Repo.get_by(Notification, + idempotency_key: "production-web-push-smoke:#{run_id}" + ) + + assert notification_id == manifest["notification_id"] + assert context.device.id == manifest["device_id"] + assert %Oban.Job{id: job_id} = Repo.get(Oban.Job, manifest["job_id"]) + assert job_id == manifest["job_id"] + + assert capture_io(fn -> + WhoNeedHelp.ProductionWebPushSmoke.run("cleanup", options) + end) =~ "web_push_smoke_cleanup_verified=true" + + refute Repo.get(Notification, notification_id) + refute Repo.get(Oban.Job, job_id) + refute File.exists?(manifest_path) + end + + defp options(context, run_id, manifest_path) do + %{ + run_id: run_id, + expected_database: context.database, + user_email: context.user.email, + manifest_path: manifest_path + } + end + + defp run_id do + "test-#{System.unique_integer([:positive, :monotonic])}-#{Ecto.UUID.generate()}" + end +end