Harden launch verification and update LiveView

This commit is contained in:
SimpleTest 2026-08-10 16:45:10 +03:00
parent 8449ee696f
commit ed4d85dcab
8 changed files with 548 additions and 25 deletions

View File

@ -83,6 +83,10 @@
- [x] Four current 1080×1920 physical-phone screenshots captured and reviewed. - [x] Four current 1080×1920 physical-phone screenshots captured and reviewed.
- [x] English, Ukrainian, and Russian listing copy prepared. - [x] English, Ukrainian, and Russian listing copy prepared.
- [x] Alt-text copy (≤140 characters) prepared in `store-assets/README.md`. - [x] Alt-text copy (≤140 characters) prepared in `store-assets/README.md`.
- [x] Revalidate the exact prepared listing text and visual assets immediately
before Console entry. On 2026-08-10 the repository validator passed all
three localized text limits, the 512×512 icon, the 1024×500 RGB feature
graphic, and all four 1080×1920 RGB phone screenshots.
- [ ] Enter the prepared alt text when the assets are uploaded in Play Console. - [ ] Enter the prepared alt text when the assets are uploaded in Play Console.
- [ ] Choose category/tags in the current Console options. - [ ] Choose category/tags in the current Console options.
- [ ] Complete **Select an app category and provide contact details** in Play - [ ] Complete **Select an app category and provide contact details** in Play
@ -96,7 +100,10 @@
contact, verify that contact can access the urgent queue, and complete contact, verify that contact can access the urgent queue, and complete
the Play child-safety self-certification only from observed operational the Play child-safety self-certification only from observed operational
evidence. Adult-only positioning does not remove this requirement for an evidence. Adult-only positioning does not remove this requirement for an
app declared as Social. app declared as Social. A fresh public check on 2026-08-10 returned
`404` from `https://whoneedhelp.com/child-safety`; do not save the Social
category or self-certify until the later local route is released and
verified on production.
## App content ## App content
@ -132,6 +139,11 @@
its exact path and checksum are recorded in its exact path and checksum are recorded in
`location-and-fgs-declaration.md`. Hosting it as an unlisted video and `location-and-fgs-declaration.md`. Hosting it as an unlisted video and
saving the resulting URL in Play Console remain incomplete. saving the resulting URL in Play Console remain incomplete.
Immediately before upload on 2026-08-10 the exact final file was
revalidated as H.264, 720×1600, 21.379802 seconds, SHA-256
`3c5f52019bf8a42ff42e1d9232afc126b62627390d74eed75084d82ecb33ac56`;
a fresh contact-sheet review still showed the disclosure, Android prompt,
minimized persistent notification with Stop, and returned stopped state.
- [ ] Reconcile any target-SDK-37 persistent precise-location declaration shown - [ ] Reconcile any target-SDK-37 persistent precise-location declaration shown
by Play Console with the exact artifact. The app uses a user-started by Play Console with the exact artifact. The app uses a user-started
location foreground service and does not declare location foreground service and does not declare
@ -163,6 +175,10 @@
then deleted and both production and frozen-test readiness remained then deleted and both production and frozen-test readiness remained
healthy. This verifies observed app behavior; it does not complete the healthy. This verifies observed app behavior; it does not complete the
separate Play Console policy declarations or the final video above. separate Play Console policy declarations or the final video above.
The strict verifier passed again on 2026-08-10 after the physical phone
was reconnected: Google Play installer, version `0.1.2 (3)`, Play signing
identity, verified production App Link, and `MainActivity` resolution all
matched the protected production identity record.
- [ ] Closed track created and opt-in link tested. - [ ] Closed track created and opt-in link tested.
- [ ] At least 12 testers continuously opted in for 14 days. - [ ] At least 12 testers continuously opted in for 14 days.
- [ ] Tester feedback and fixes documented. - [ ] Tester feedback and fixes documented.

View File

@ -10,6 +10,9 @@ does not authorize saving fields in Play Console or publishing a release.
- Tags are not selected. - Tags are not selected.
- Store-listing contact email, phone, and website are empty. - Store-listing contact email, phone, and website are empty.
- No default store listing has been created. - No default store listing has been created.
- A second read-only inspection found the default English listing still empty
except for an existing unsaved app-name value `Who Need Help`. The unsaved
value was preserved; no field was entered, discarded, saved, or published.
- The Dashboard showed 9 of 11 setup tasks complete. The remaining setup tasks - The Dashboard showed 9 of 11 setup tasks complete. The remaining setup tasks
were category/contact details and the store listing. were category/contact details and the store listing.
@ -31,6 +34,11 @@ Run `./scripts/android-store-assets-validate.sh` immediately before upload.
The validator checks image dimensions/formats and listing-length constraints; The validator checks image dimensions/formats and listing-length constraints;
it does not prove Play policy approval or visual quality. it does not prove Play policy approval or visual quality.
The validator passed again on 2026-08-10, and a fresh combined visual review
confirmed that the approved icon, feature graphic, and four phone screenshots
contain no visible email, private message, handover code, or real precise
location.
## Contact fields ## Contact fields
- Public support email candidate: `contact@whoneedhelp.com` - Public support email candidate: `contact@whoneedhelp.com`

View File

@ -37,6 +37,12 @@ and full structured results are recorded in `docs/verification.md`. This is
local isolated evidence only and does not mark the production support/legal or local isolated evidence only and does not mark the production support/legal or
SMTP checklist items complete. SMTP checklist items complete.
The latest complete local rerun on 2026-08-10 passed 459 ExUnit tests,
fourteen browser-asset tests, every configured quality/security gate, and the
final image scans after updating Phoenix LiveView from advisory-affected
`1.2.8` to patched `1.2.9`. Exact unit, timing, memory, cleanup, and dependency
evidence are recorded in `docs/verification.md`.
## 2. Verify production configuration without exposing secrets ## 2. Verify production configuration without exposing secrets
Run both checks against the single ignored production `.env`. The first reports Run both checks against the single ignored production `.env`. The first reports
@ -140,9 +146,15 @@ as forward-only rather than receiving an invented database rollback.
`assetlinks.json` return the expected production identity. `assetlinks.json` return the expected production identity.
- [ ] Registration, returning-user login, and settings linking complete against - [ ] Registration, returning-user login, and settings linking complete against
the production Google OAuth client and exact callback origin. the production Google OAuth client and exact callback origin.
- [ ] A production-generated authentication email reaches an external mailbox; - [x] A production-generated authentication email reaches an external mailbox
sender identity and every URL use the production domain. and is DKIM-signed by the production domain.
- [ ] Browser Web Push reaches a real subscribed browser. - [ ] Authentication-email action URLs use the production domain directly.
Brevo currently rewrites the action href through its tracking domain even
though the visible fallback origin is `https://whoneedhelp.com/`.
- [x] The Web Push provider accepts a production notification for a real active
browser subscription without disabling the device.
- [ ] A person has observed the resulting operating-system browser notification
and its navigation target on the subscribed workstation.
- [x] The production Android build signs in, opens verified App Links, receives - [x] The production Android build signs in, opens verified App Links, receives
FCM, and performs user-started foreground location sharing on a physical FCM, and performs user-started foreground location sharing on a physical
device. device.

View File

@ -3,6 +3,39 @@
Observed through 2026-08-10 in the local workspace. This report separates observed Observed through 2026-08-10 in the local workspace. This report separates observed
results from product limits and unknown production properties. results from product limits and unknown production properties.
## Current local quality and dependency-security proof on 2026-08-10
- The complete isolated `scripts/quality.sh` pipeline passed in user-systemd
unit
`codex-heavy-wnh-quality-final-20260810-20260810-163515-639199.service`.
It completed successfully in 6 minutes 46.460 seconds with a measured
230.7 MiB memory peak. ExUnit reported 459 passing tests with seed `280346`,
and the browser asset suite reported fourteen passes.
- The run passed the configured repository policy, ShellCheck, Hadolint,
actionlint, Compose, Helm, migration/rollback, observability, formatting,
compiler, xref, Credo, Sobelow, Dialyzer, Hex audit, npm audit, and container
image gates. The final Debian 13.6 application image and the pinned
infrastructure images reported zero detected vulnerabilities.
- The initially locked Phoenix LiveView `1.2.8` was affected by
`GHSA-36m4-rm57-3prf` / `CVE-2026-64941`. The lock now selects the patched
`1.2.9` release; the subsequent Hex audit reported no retired or advisory
packages.
- The generated Gettext template was refreshed with the repository's official
extractor. Its changes are source-line references only; no message identifiers
were added or removed.
- Exact-name inspection after completion found no image, container, network,
or volume belonging to scope `20260810133515-639775` /
`wnh_quality_20260810133515639775`.
- This was local verification only. It did not deploy production, modify the
frozen hackathon-test checkout, change shared Caddy, save Google Play Console
fields, install an Android package, or push the public Git remote.
- After the physical phone was reconnected, the strict installed-build verifier
again observed `org.whoneedhelp.mobile` version `0.1.2 (3)`, installer
`com.android.vending`, a supplied Play App Signing identity, a verified
`whoneedhelp.com` App Link resolving to `MainActivity`, and no Android claim
on the browser-only Google OAuth callback. The verifier did not reinstall the
package, clear its data, or change device permissions.
## External-monitor SMTP isolation proof on 2026-08-09 ## External-monitor SMTP isolation proof on 2026-08-09
- The local change set based on revision `360c7a5` adds an optional, - The local change set based on revision `360c7a5` adds an optional,
@ -1034,8 +1067,8 @@ this audit.
| Consent-driven live tracking | Implemented and cross-client verified | On API 37, Android started `TrackingService` as a location foreground service with a persistent Stop notification. The Play-delivered production build repeated the disclosure, foreground permission, minimized-app sampling, notification Stop, raw-position deletion, offline recovery, and process-recreation paths on a physical phone. | Browsers stop with the page. Android has no `ACCESS_BACKGROUND_LOCATION`, unattended start, or route history. | | Consent-driven live tracking | Implemented and cross-client verified | On API 37, Android started `TrackingService` as a location foreground service with a persistent Stop notification. The Play-delivered production build repeated the disclosure, foreground permission, minimized-app sampling, notification Stop, raw-position deletion, offline recovery, and process-recreation paths on a physical phone. | Browsers stop with the page. Android has no `ACCESS_BACKGROUND_LOCATION`, unattended start, or route history. |
| 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. | | 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. | | 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 fresh production authentication-email delivery to an external mailbox remains a separate launch check. | | 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. | Brevo rewrites the action href through its tracking domain; direct production-domain action URLs remain an open deliverability/privacy check. |
| Notifications and nearby alerts | Implemented and browser/physical-device verified | Users can configure push/email preferences, quiet hours, category/urgency/day/time filters, a private matching center, and 1/3/5/10/25 km radii. Durable inbox notifications and Oban delivery jobs are tested; public notification payloads omit chat text, exact coordinates, and the private saved-area label. 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. | Production browser Web Push remains unverified. | | Notifications and nearby alerts | Implemented and browser/physical-device verified | Users can configure push/email preferences, quiet hours, category/urgency/day/time filters, a private matching center, and 1/3/5/10/25 km radii. Durable inbox notifications and Oban delivery jobs are tested; public notification payloads omit chat text, exact coordinates, and the private saved-area label. 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. | Provider acceptance and the still-active subscription are verified; visible operating-system presentation and click navigation were not programmatically observed. |
| 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. | | 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. | | 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. | | 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. |
@ -1043,7 +1076,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. | | 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. | | 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. | | 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. The Play-delivered build additionally completed production Google OIDC and production 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, fresh production authentication-email delivery, production browser Web Push, 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, 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, direct production-domain authentication action URL, visible browser OS-notification interaction, and APNs remain unverified. SMTP exactly-once delivery is not claimed. |
## Reproducible checks ## Reproducible checks
@ -2782,3 +2815,33 @@ promoted.
reporting action without submitting the form and observed the expected reporting action without submitting the form and observed the expected
`child_sexual_abuse_material` selection, no horizontal overflow, and zero `child_sexual_abuse_material` selection, no horizontal overflow, and zero
console errors or warnings. console errors or warnings.
# 2026-08-10 production authentication-email and browser Web Push checks
- A production passwordless sign-in request generated an authentication email
that arrived in the external operator mailbox at 10:48 EEST.
Gmail reported `Who Need Help <contact@whoneedhelp.com>` as the sender,
`whoneedhelp.com` as the signing domain, Brevo infrastructure as the mailing
domain, and TLS transport. The message states a 15-minute lifetime and shows
`https://whoneedhelp.com/` as its fallback origin. The action href itself is
currently rewritten through a Brevo tracking domain, so the separate
direct-production-domain URL gate remains open. The inspected message was
restored to unread state after verification.
- A guarded production Web Push smoke targeted the newest active browser-only
Web Push device for the confirmed production account. It created one scoped
notification and one `DeviceDeliveryWorker` job, did not enqueue email or
target Android FCM, and verified the exact job as `completed` on attempt 1
while the device remained active. Exact cleanup removed one job and one
notification; the local state file and remote temporary files were absent
afterward. Provider acceptance to a real subscription is therefore verified;
operating-system notification presentation and click navigation were not
directly observed.
- The smoke wrapper now preserves its mode-`0600` state file plus the remote
manifest and script whenever exact record cleanup does not finish. Temporary
files are removed only after the cleanup action has deleted and rechecked the
run-scoped records. This avoids losing recovery identifiers on an interrupted
or failed cleanup.
- A read-only ADB recheck observed the connected Xiaomi `23122PCD1G` and the
Play-installed `org.whoneedhelp.mobile` package at `0.1.2 (3)`, target SDK 37,
with installer `com.android.vending`. No package reinstall, data clear, or
permission mutation was performed.

View File

@ -41,7 +41,7 @@
"phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"}, "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"}, "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"},
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.7.0", "fb1e429f6d8778ce3a6962debdc5e555428a05a6e7b058d6dbad13d281a2c31f", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "dc9f44271aa6fc4ab7797f2aa374ba096ef2c87520586280eb095626b7387a68"}, "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.7.0", "fb1e429f6d8778ce3a6962debdc5e555428a05a6e7b058d6dbad13d281a2c31f", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "dc9f44271aa6fc4ab7797f2aa374ba096ef2c87520586280eb095626b7387a68"},
"phoenix_live_view": {:hex, :phoenix_live_view, "1.2.8", "5006fd7b429c42489600fbc1600c750d0f0e5b5ea4965d9758e429b979392991", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b05ffe21f43c0ff219da62948b482c324aa5b8873e17b0c0cac58289a178af38"}, "phoenix_live_view": {:hex, :phoenix_live_view, "1.2.9", "d35ddac2fab4480e6bdbf712ff104fd9e969a2bbe81b578ee80f066b371f56db", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2f9528c3d7046edabbb30a91710ca33988f8d8bc20a964a1fc48b32134572afa"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
"plug": {:hex, :plug, "1.20.3", "56c480c633ec2ce10140e236e15233bf576e1d323887d7c96711bd02ab5160db", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "be266aee1b8536ef6409d58cf39a3121319f0ec47cfa1b24024485aa0e76ad76"}, "plug": {:hex, :plug, "1.20.3", "56c480c633ec2ce10140e236e15233bf576e1d323887d7c96711bd02ab5160db", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "be266aee1b8536ef6409d58cf39a3121319f0ec47cfa1b24024485aa0e76ad76"},

View File

@ -3122,7 +3122,7 @@ msgid "Removal notice updated."
msgstr "" msgstr ""
#: lib/who_need_help_web/components/layouts.ex:201 #: lib/who_need_help_web/components/layouts.ex:201
#: lib/who_need_help_web/controllers/content_removal_controller.ex:137 #: lib/who_need_help_web/controllers/content_removal_controller.ex:136
#: lib/who_need_help_web/controllers/support_html/new.html.heex:16 #: lib/who_need_help_web/controllers/support_html/new.html.heex:16
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Report content" msgid "Report content"
@ -3256,7 +3256,7 @@ msgstr ""
msgid "TAKE IT DOWN" msgid "TAKE IT DOWN"
msgstr "" msgstr ""
#: lib/who_need_help_web/controllers/content_removal_controller.ex:136 #: lib/who_need_help_web/controllers/content_removal_controller.ex:135
#: lib/who_need_help_web/controllers/support_html/new.html.heex:19 #: lib/who_need_help_web/controllers/support_html/new.html.heex:19
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "TAKE IT DOWN request" msgid "TAKE IT DOWN request"
@ -3284,7 +3284,7 @@ msgstr ""
msgid "Threat to life or safety" msgid "Threat to life or safety"
msgstr "" msgstr ""
#: lib/who_need_help_web/controllers/content_removal_controller.ex:104 #: lib/who_need_help_web/controllers/content_removal_controller.ex:103
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Too many removal notices. Please try again later." msgid "Too many removal notices. Please try again later."
msgstr "" msgstr ""
@ -5274,19 +5274,19 @@ msgstr ""
msgid "Case status" msgid "Case status"
msgstr "" msgstr ""
#: lib/who_need_help_web/controllers/page_html/child_safety.html.heex:39 #: lib/who_need_help_web/controllers/page_html/child_safety.html.heex:42
#: lib/who_need_help_web/controllers/support_controller.ex:227 #: lib/who_need_help_web/controllers/support_controller.ex:227
#: lib/who_need_help_web/live/report_live.ex:50 #: lib/who_need_help_web/live/report_live.ex:50
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Contact support" msgid "Contact support"
msgstr "" msgstr ""
#: lib/who_need_help_web/controllers/content_removal_controller.ex:57 #: lib/who_need_help_web/controllers/content_removal_controller.ex:56
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Content-removal notice %{reference}" msgid "Content-removal notice %{reference}"
msgstr "" msgstr ""
#: lib/who_need_help_web/controllers/content_removal_controller.ex:112 #: lib/who_need_help_web/controllers/content_removal_controller.ex:111
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Content-removal notice received" msgid "Content-removal notice received"
msgstr "" msgstr ""
@ -7720,7 +7720,7 @@ msgstr ""
msgid "You do not have permission for this action. Role changes also require a recent sign-in." msgid "You do not have permission for this action. Role changes also require a recent sign-in."
msgstr "" msgstr ""
#: lib/who_need_help_web/controllers/content_removal_controller.ex:71 #: lib/who_need_help_web/controllers/content_removal_controller.ex:70
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Your email was confirmed and the notice was sent for review." msgid "Your email was confirmed and the notice was sent for review."
msgstr "" msgstr ""
@ -7776,22 +7776,22 @@ msgstr ""
msgid "Child safety standards" msgid "Child safety standards"
msgstr "" msgstr ""
#: lib/who_need_help_web/controllers/page_html/child_safety.html.heex:67 #: lib/who_need_help_web/controllers/page_html/child_safety.html.heex:70
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Child-safety contact" msgid "Child-safety contact"
msgstr "" msgstr ""
#: lib/who_need_help_web/controllers/page_html/child_safety.html.heex:54 #: lib/who_need_help_web/controllers/page_html/child_safety.html.heex:57
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Child-safety reports enter an urgent restricted review queue. Who Need Help may restrict access, remove or disable content, suspend accounts, preserve the minimum information needed for investigation, and cooperate with lawful requests. After obtaining actual knowledge of confirmed CSAM, the operator reports it to the National Center for Missing & Exploited Children or the relevant regional authority when required by applicable law." msgid "Child-safety reports enter an urgent restricted review queue. Who Need Help may restrict access, remove or disable content, suspend accounts, preserve the minimum information needed for investigation, and cooperate with lawful requests. After obtaining actual knowledge of confirmed CSAM, the operator reports it to the National Center for Missing & Exploited Children or the relevant regional authority when required by applicable law."
msgstr "" msgstr ""
#: lib/who_need_help_web/controllers/page_html/child_safety.html.heex:43 #: lib/who_need_help_web/controllers/page_html/child_safety.html.heex:46
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Do not upload, reproduce, download, forward, or email suspected CSAM. Provide only the minimum information and exact in-service URL needed to locate it. If a child is in immediate danger, contact local emergency services or law enforcement first." msgid "Do not upload, reproduce, download, forward, or email suspected CSAM. Provide only the minimum information and exact in-service URL needed to locate it. If a child is in immediate danger, contact local emergency services or law enforcement first."
msgstr "" msgstr ""
#: lib/who_need_help_web/controllers/page_html/child_safety.html.heex:51 #: lib/who_need_help_web/controllers/page_html/child_safety.html.heex:54
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "How Who Need Help responds" msgid "How Who Need Help responds"
msgstr "" msgstr ""
@ -7801,7 +7801,7 @@ msgstr ""
msgid "How to report" msgid "How to report"
msgstr "" msgstr ""
#: lib/who_need_help_web/controllers/page_html/child_safety.html.heex:74 #: lib/who_need_help_web/controllers/page_html/child_safety.html.heex:77
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Open the support form" msgid "Open the support form"
msgstr "" msgstr ""
@ -7811,22 +7811,22 @@ msgstr ""
msgid "Prohibited conduct and content" msgid "Prohibited conduct and content"
msgstr "" msgstr ""
#: lib/who_need_help_web/controllers/page_html/child_safety.html.heex:36 #: lib/who_need_help_web/controllers/page_html/child_safety.html.heex:39
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Report child-safety content" msgid "Report child-safety content"
msgstr "" msgstr ""
#: lib/who_need_help_web/controllers/page_html/child_safety.html.heex:59 #: lib/who_need_help_web/controllers/page_html/child_safety.html.heex:62
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Reports are reviewed by authorised staff. Automated signals may prioritize a case but are not treated as proof by themselves. A report does not replace contacting emergency services when someone is in immediate danger." msgid "Reports are reviewed by authorised staff. Automated signals may prioritize a case but are not treated as proof by themselves. A report does not replace contacting emergency services when someone is in immediate danger."
msgstr "" msgstr ""
#: lib/who_need_help_web/controllers/page_html/child_safety.html.heex:81 #: lib/who_need_help_web/controllers/page_html/child_safety.html.heex:84
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Scope and updates" msgid "Scope and updates"
msgstr "" msgstr ""
#: lib/who_need_help_web/controllers/page_html/child_safety.html.heex:84 #: lib/who_need_help_web/controllers/page_html/child_safety.html.heex:87
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "These standards supplement the Terms of Service and Safety Rules. They apply even though Who Need Help is limited to adults and currently does not provide user media uploads. The standards will be updated if product capabilities, reporting duties, or applicable law change." msgid "These standards supplement the Terms of Service and Safety Rules. They apply even though Who Need Help is limited to adults and currently does not provide user media uploads. The standards will be updated if product capabilities, reporting duties, or applicable law change."
msgstr "" msgstr ""
@ -7856,7 +7856,7 @@ msgstr ""
msgid "ZERO TOLERANCE FOR CHILD SEXUAL ABUSE AND EXPLOITATION" msgid "ZERO TOLERANCE FOR CHILD SEXUAL ABUSE AND EXPLOITATION"
msgstr "" msgstr ""
#: lib/who_need_help_web/controllers/page_html/child_safety.html.heex:70 #: lib/who_need_help_web/controllers/page_html/child_safety.html.heex:73
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Child-safety reports are routed through the protected support and content-removal queues. Google Play, authorities, and users can contact the operator through the support form." msgid "Child-safety reports are routed through the protected support and content-removal queues. Google Play, authorities, and users can contact the operator through the support form."
msgstr "" msgstr ""

View File

@ -0,0 +1,217 @@
defmodule WhoNeedHelp.ProductionWebPushSmoke do
import Ecto.Query
alias Oban.Job
alias WhoNeedHelp.Accounts.User
alias WhoNeedHelp.Notifications.{Notification, PushDevice}
alias WhoNeedHelp.Push.DeviceDeliveryWorker
alias WhoNeedHelp.Repo
@allowed_actions ~w(prepare verify cleanup)
def run(action, options) when action in @allowed_actions and is_map(options) do
context = verified_context(options)
case action do
"prepare" -> prepare(context)
"verify" -> verify(context)
"cleanup" -> cleanup(context)
end
end
def run(_action, _options), do: raise("unsupported Web Push smoke action")
defp verified_context(options) do
run_id = required_option!(options, :run_id)
expected_database = required_option!(options, :expected_database)
user_email = required_option!(options, :user_email) |> String.downcase()
manifest_path = required_option!(options, :manifest_path)
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
raise "invalid manifest path"
end
%Postgrex.Result{rows: [[actual_database]]} =
Repo.query!("SELECT current_database()", [], log: false)
unless actual_database == expected_database, do: raise("database identity mismatch")
%{
run_id: run_id,
database: actual_database,
user_email: user_email,
manifest_path: manifest_path,
idempotency_key: "production-web-push-smoke:#{run_id}"
}
end
defp prepare(context) do
if File.exists?(context.manifest_path), do: raise("manifest already exists")
user = Repo.get_by(User, email: context.user_email)
unless match?(%User{confirmed_at: %DateTime{}, moderation_status: :active}, user) do
raise "target user is missing, unconfirmed, or inactive"
end
device =
PushDevice
|> where(
[device],
device.user_id == ^user.id and device.platform == :web and
device.provider == :web_push and is_nil(device.disabled_at)
)
|> order_by([device], desc: device.last_seen_at, desc: device.inserted_at)
|> limit(1)
|> Repo.one()
unless match?(%PushDevice{}, device), do: raise("no active Web Push device exists")
if Repo.exists?(
from(notification in Notification,
where: notification.idempotency_key == ^context.idempotency_key
)
) 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!()
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,
"idempotency_key" => context.idempotency_key,
"notification_id" => fixture.notification.id,
"device_id" => fixture.device.id,
"job_id" => fixture.job.id
}
File.write!(context.manifest_path, Jason.encode_to_iodata!(manifest, pretty: true))
File.chmod!(context.manifest_path, 0o600)
IO.puts("web_push_smoke_prepared=true")
IO.puts("job_id=#{fixture.job.id}")
IO.puts("delivery_scope=latest active Web Push device only")
IO.puts("email_enqueued=false")
end
defp verify(context) do
fixture = load_and_validate_manifest!(context)
notification = Repo.get(Notification, fixture["notification_id"])
device = Repo.get(PushDevice, fixture["device_id"])
job = Repo.get(Job, fixture["job_id"])
unless match?(%Notification{}, notification) and
notification.user_id == device.user_id and
notification.idempotency_key == context.idempotency_key and
match?(%PushDevice{platform: :web, provider: :web_push, disabled_at: nil}, device) and
match?(%Job{state: "completed", attempt: 1}, job) and
job.args["notification_id"] == notification.id and
job.args["device_id"] == device.id do
raise "Web Push smoke has not completed successfully on the exact target"
end
IO.puts("web_push_provider_delivery_verified=true")
IO.puts("job_state=#{job.state}")
IO.puts("job_attempt=#{job.attempt}")
IO.puts("device_still_active=true")
end
defp cleanup(context) do
fixture = load_and_validate_manifest!(context)
job = Repo.get(Job, fixture["job_id"])
notification = Repo.get(Notification, fixture["notification_id"])
unless match?(%Job{}, job) and match?(%Notification{}, notification) and
notification.idempotency_key == context.idempotency_key and
job.args["notification_id"] == notification.id and
job.args["device_id"] == fixture["device_id"] do
raise "run-scoped records no longer match the manifest"
end
{:ok, deleted} =
Repo.transaction(fn ->
{jobs, _} = Repo.delete_all(from(candidate in Job, where: candidate.id == ^job.id))
{notifications, _} =
Repo.delete_all(
from(candidate in Notification,
where:
candidate.id == ^notification.id and
candidate.idempotency_key == ^context.idempotency_key
)
)
%{jobs: jobs, notifications: notifications}
end)
unless deleted == %{jobs: 1, notifications: 1} do
raise "exact Web Push smoke cleanup failed"
end
File.rm!(context.manifest_path)
if Repo.exists?(
from(candidate in Notification,
where: candidate.idempotency_key == ^context.idempotency_key
)
) do
raise "run-scoped notification remains after cleanup"
end
IO.puts("web_push_smoke_cleanup_verified=true")
IO.puts("deleted_jobs=1")
IO.puts("deleted_notifications=1")
end
defp load_and_validate_manifest!(context) do
manifest = context.manifest_path |> File.read!() |> Jason.decode!()
valid? =
manifest["schema_version"] == 1 and manifest["run_id"] == context.run_id and
manifest["database"] == context.database and
manifest["user_email"] == context.user_email and
manifest["idempotency_key"] == context.idempotency_key and
uuid?(manifest["notification_id"]) and uuid?(manifest["device_id"]) and
is_integer(manifest["job_id"])
unless valid?, do: raise("manifest does not match this exact run")
manifest
end
defp required_option!(options, name) do
case Map.get(options, name) do
value when is_binary(value) and value != "" -> value
_missing -> raise("#{name} is required")
end
end
defp uuid?(value) when is_binary(value), do: match?({:ok, _}, Ecto.UUID.cast(value))
defp uuid?(_value), do: false
end

View File

@ -0,0 +1,207 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
SSH_TARGET=${PRODUCTION_SSH_TARGET:-whoneedhelp}
REMOTE_ROOT=/srv/who_need_help-production
REMOTE_ENV=$REMOTE_ROOT/.env
EXPECTED_PROJECT=who_need_help_production
EXPECTED_ORIGIN=https://whoneedhelp.com
STATE_FILE="$ROOT/output/runtime/production-web-push-smoke.env"
LOCAL_SCRIPT="$ROOT/scripts/production-web-push-smoke.exs"
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
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.
EOF
exit 1
}
read_remote_env() {
local key=$1
ssh -o BatchMode=yes "$SSH_TARGET" \
"awk -F= -v key='$key' '\$1 == key {value = substr(\$0, index(\$0, \"=\") + 1); sub(/\\r\$/, \"\", value); if ((value ~ /^\".*\"\$/) || (value ~ /^\\047.*\\047\$/)) value = substr(value, 2, length(value) - 2); count++} END {if (count == 1) print value; else exit 1}' '$REMOTE_ENV'"
}
encode() {
printf %s "$1" | base64 | tr -d '\n'
}
if [[ $# -ne 4 ]]; then
usage
fi
ACTION=$1
USER_EMAIL=${2,,}
CONFIRMATION=$3
HOST=$4
case "$ACTION" in
plan) [[ "$CONFIRMATION" == --check-only ]] || usage ;;
run) [[ "$CONFIRMATION" == --confirm ]] || usage ;;
*) usage ;;
esac
[[ "$HOST" == whoneedhelp.com ]] || usage
if [[ ! "$USER_EMAIL" =~ ^[^[:space:]@]+@[^[:space:]@]+$ ]]; then
echo "USER_EMAIL is invalid." >&2
exit 1
fi
if [[ ! -f "$LOCAL_SCRIPT" ]]; then
echo "Local smoke script is missing: $LOCAL_SCRIPT" >&2
exit 1
fi
DEPLOYMENT_ENV=$(read_remote_env DEPLOYMENT_ENV)
DEPLOYMENT_TARGET=$(read_remote_env DEPLOYMENT_TARGET)
PROJECT=$(read_remote_env COMPOSE_PROJECT_NAME)
ORIGIN=$(read_remote_env WNH_BASE_URL)
EXPECTED_DATABASE=$(read_remote_env POSTGRES_DB)
EXPECTED_IMAGE=$(read_remote_env APP_IMAGE)
if [[ "$DEPLOYMENT_ENV" != production || "$DEPLOYMENT_TARGET" != compose ||
"$PROJECT" != "$EXPECTED_PROJECT" || "$ORIGIN" != "$EXPECTED_ORIGIN" ||
-z "$EXPECTED_DATABASE" ]]; then
echo "Remote deployment identity does not match the production target." >&2
exit 1
fi
CONTAINER=$(ssh -o BatchMode=yes "$SSH_TARGET" \
"cd '$REMOTE_ROOT' && ./scripts/compose.sh '$REMOTE_ENV' ps -q app | head -n 1")
if [[ -z "$CONTAINER" ]]; then
echo "No running production app container was found." >&2
exit 1
fi
read -r OBSERVED_IMAGE CONTAINER_STATE CONTAINER_HEALTH < <(
ssh -o BatchMode=yes "$SSH_TARGET" \
"docker inspect --format '{{.Config.Image}} {{.State.Status}} {{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' '$CONTAINER'"
)
if [[ "$OBSERVED_IMAGE" != "$EXPECTED_IMAGE" || "$CONTAINER_STATE" != running ||
"$CONTAINER_HEALTH" != healthy ]]; then
echo "Production container identity or health does not match the environment." >&2
exit 1
fi
ACTUAL_DATABASE=$(ssh -o BatchMode=yes "$SSH_TARGET" \
"docker exec '$CONTAINER' /app/bin/who_need_help rpc '%Postgrex.Result{rows: [[database]]} = WhoNeedHelp.Repo.query!(\"SELECT current_database()\", [], log: false); IO.puts(database)'" | tail -n 1)
if [[ "$ACTUAL_DATABASE" != "$EXPECTED_DATABASE" ]]; then
echo "Production database identity mismatch." >&2
exit 1
fi
if [[ "$ACTION" == plan ]]; then
printf 'scope=one production notification and one browser-only Web Push delivery job\n'
printf 'target=%s\ndatabase=%s\nimage=%s\ncontainer=%s\n' \
"$USER_EMAIL" "$ACTUAL_DATABASE" "$OBSERVED_IMAGE" "$CONTAINER"
printf 'excluded=email delivery, Android FCM, frozen test project, Caddy, public Git\n'
printf 'cleanup=exact run-scoped notification, job, manifest, and temporary script\n'
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 - </proc/sys/kernel/random/uuid | cut -c1-12)"
REMOTE_SCRIPT="/tmp/wnh-production-web-push-$RUN_ID.exs"
REMOTE_MANIFEST="/tmp/wnh-production-web-push-$RUN_ID.json"
cat >"$STATE_FILE" <<EOF
schema_version=1
run_id=$RUN_ID
ssh_target=$SSH_TARGET
container=$CONTAINER
remote_script=$REMOTE_SCRIPT
remote_manifest=$REMOTE_MANIFEST
EOF
chmod 600 "$STATE_FILE"
cleanup_complete=false
remove_temporary_files() {
ssh -o BatchMode=yes "$SSH_TARGET" \
"docker exec '$CONTAINER' rm -f '$REMOTE_SCRIPT' '$REMOTE_MANIFEST'" >/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
fi
exit "$status"
}
trap 'preserve_failed_run $?' EXIT
trap 'preserve_failed_run 130' INT
trap 'preserve_failed_run 143' TERM
ssh -o BatchMode=yes "$SSH_TARGET" \
"docker exec -i '$CONTAINER' sh -c 'umask 077; cat >\"$REMOTE_SCRIPT\"'" <"$LOCAL_SCRIPT"
RUN=$(encode "$RUN_ID")
DATABASE=$(encode "$EXPECTED_DATABASE")
EMAIL=$(encode "$USER_EMAIL")
MANIFEST=$(encode "$REMOTE_MANIFEST")
rpc_action() {
local action=$1
local expression
expression="Code.require_file(\"$REMOTE_SCRIPT\"); WhoNeedHelp.ProductionWebPushSmoke.run(\"$action\", %{run_id: Base.decode64!(\"$RUN\"), expected_database: Base.decode64!(\"$DATABASE\"), user_email: Base.decode64!(\"$EMAIL\"), manifest_path: Base.decode64!(\"$MANIFEST\")})"
ssh -o BatchMode=yes "$SSH_TARGET" \
"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"