From 0ad71f67c3a4a7a78210925aa834ac3f38246de9 Mon Sep 17 00:00:00 2001 From: SimpleTest Date: Sun, 19 Jul 2026 08:33:24 +0300 Subject: [PATCH] feat: verify local external service boundaries --- .env.e2e.example | 6 + .env.example | 8 + .env.load.example | 6 + .github/workflows/quality.yml | 11 + README.md | 37 +- compose.external-boundaries.yaml | 87 ++++ compose.yaml | 6 + config/runtime.exs | 63 ++- docs/dependency-baseline.md | 3 +- docs/local-hardening-plan.md | 26 +- docs/operations.md | 45 ++ docs/verification.md | 29 +- lib/who_need_help/external_boundary_drill.ex | 433 +++++++++++++++++ lib/who_need_help/push.ex | 66 +++ lib/who_need_help/push/disabled_adapter.ex | 8 + lib/who_need_help/push/http_adapter.ex | 99 ++++ ops/external-boundaries/Dockerfile | 9 + ops/external-boundaries/mock_server.py | 478 +++++++++++++++++++ scripts/external-boundaries-run.sh | 159 ++++++ scripts/quality.sh | 38 +- test/who_need_help/push_test.exs | 75 +++ 21 files changed, 1666 insertions(+), 26 deletions(-) create mode 100644 compose.external-boundaries.yaml create mode 100644 lib/who_need_help/external_boundary_drill.ex create mode 100644 lib/who_need_help/push.ex create mode 100644 lib/who_need_help/push/disabled_adapter.ex create mode 100644 lib/who_need_help/push/http_adapter.ex create mode 100644 ops/external-boundaries/Dockerfile create mode 100644 ops/external-boundaries/mock_server.py create mode 100755 scripts/external-boundaries-run.sh create mode 100644 test/who_need_help/push_test.exs diff --git a/.env.e2e.example b/.env.e2e.example index f2e0a01..0459a32 100644 --- a/.env.e2e.example +++ b/.env.e2e.example @@ -40,6 +40,12 @@ CODEX_SESSION_ID=local-e2e RATE_LIMIT_POLICIES_JSON={} GITHUB_OAUTH_CLIENT_ID= GITHUB_OAUTH_CLIENT_SECRET= +GITHUB_OAUTH_BASE_URL= +GITHUB_OAUTH_AUTHORIZE_URL= +GITHUB_OAUTH_TOKEN_URL= +GITHUB_OAUTH_USER_URL= +GITHUB_OAUTH_HTTP_CONNECT_TIMEOUT_MS= +GITHUB_OAUTH_HTTP_RECEIVE_TIMEOUT_MS= E2E_BASE_URL=http://proxy E2E_MAILPIT_URL=http://mailpit:8025 diff --git a/.env.example b/.env.example index 33ef921..46f0af3 100644 --- a/.env.example +++ b/.env.example @@ -35,6 +35,14 @@ MAP_TILE_URL=https://tile.openstreetmap.org/{z}/{x}/{y}.png # https://YOUR_PHX_HOST/auth/social/github/callback GITHUB_OAUTH_CLIENT_ID= GITHUB_OAUTH_CLIENT_SECRET= +# Optional endpoint and timeout overrides exist for the isolated boundary drill. +# Leave them empty for GitHub's official endpoints and Req's library timeouts. +GITHUB_OAUTH_BASE_URL= +GITHUB_OAUTH_AUTHORIZE_URL= +GITHUB_OAUTH_TOKEN_URL= +GITHUB_OAUTH_USER_URL= +GITHUB_OAUTH_HTTP_CONNECT_TIMEOUT_MS= +GITHUB_OAUTH_HTTP_RECEIVE_TIMEOUT_MS= POSTGRES_DB=who_need_help POSTGRES_USER=postgres diff --git a/.env.load.example b/.env.load.example index 0070935..73b78c9 100644 --- a/.env.load.example +++ b/.env.load.example @@ -41,6 +41,12 @@ RATE_LIMIT_POLICIES_JSON={} MAP_TILE_URL=https://tile.openstreetmap.org/{z}/{x}/{y}.png GITHUB_OAUTH_CLIENT_ID= GITHUB_OAUTH_CLIENT_SECRET= +GITHUB_OAUTH_BASE_URL= +GITHUB_OAUTH_AUTHORIZE_URL= +GITHUB_OAUTH_TOKEN_URL= +GITHUB_OAUTH_USER_URL= +GITHUB_OAUTH_HTTP_CONNECT_TIMEOUT_MS= +GITHUB_OAUTH_HTTP_RECEIVE_TIMEOUT_MS= # These are reproducible experiment inputs, not capacity requirements, # production traffic forecasts, alert thresholds, or recommended limits. diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index a900491..d0c0896 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -35,6 +35,17 @@ jobs: - name: Run isolated browser end-to-end tests run: ./scripts/e2e-run.sh + external-boundaries: + runs-on: ubuntu-24.04 + steps: + - name: Check out the repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Run isolated OAuth, SMTP, and push boundary drill + run: ./scripts/external-boundaries-run.sh ci-boundaries + android-build: runs-on: ubuntu-24.04 steps: diff --git a/README.md b/README.md index 4cb5057..649c787 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,19 @@ restores it into a new temporary database, checks corruption and interruption failure paths, removes those temporary buckets, and retains the successful encrypted bucket in local MinIO. It never writes a plaintext dump to the host. +Exercise the real OAuth/SMTP protocol clients and the future push adapter +boundary entirely inside an isolated Docker network: + +```bash +./scripts/external-boundaries-run.sh local-boundaries +``` + +The command publishes no host ports, generates independent one-run credentials +in an ignored mode-`0600` file, verifies success, rejection, retry, replay, and +timeout paths, retains only non-secret evidence, and removes its exact Compose +project and images. The HTTP push adapter is deliberately not connected to a +product workflow and is not presented as an FCM or APNs implementation. + The commands, boundaries, and unclaimed production properties are documented in [the operations runbook](docs/operations.md). @@ -143,6 +156,9 @@ If both values are empty, the feature stays disabled and the profile explains that state. A partial pair is rejected at startup. The flow requests only the public GitHub identity, protects callbacks with state and PKCE, binds the flow to the initiating signed-in user, and never persists the provider access token. +The optional `GITHUB_OAUTH_*_URL` and HTTP timeout variables exist only so the +isolated boundary drill can use its internal mock. Leave them empty for the +official GitHub endpoints and Req defaults. ## Tests @@ -166,10 +182,16 @@ It checks shell scripts, Dockerfiles, the GitHub Actions workflow, every Compose profile, the rendered Helm chart, tracked-source secrets and infrastructure misconfigurations, Elixir formatting/compilation/xref/Credo/Sobelow/Dialyzer, retired Hex packages, locked npm dependencies, all Phoenix tests, the pinned -backup-tool, MinIO server/client, and production release images. Its generated -database credentials are random and exist only for that run. The exact database -volume, networks, temporary source snapshot, and one-run images are removed -automatically. +backup-tool, MinIO server/client, external-boundary mock, and production +release images. Its generated database credentials are random and exist only +for that run. The exact database volume, networks, temporary source snapshot, +and one-run images are removed automatically. + +The same external-service protocol drill used by CI can be run independently: + +```bash +./scripts/external-boundaries-run.sh local-boundaries +``` The cursor-pagination database benchmark also creates a one-run Compose project, random database credentials, and a separate PostgreSQL volume: @@ -327,9 +349,10 @@ For an external cluster, provide a real PostgreSQL/PostGIS service and a pre-created Secret through required `existingSecret`; the chart never renders credentials from tracked values. The Secret must contain `DATABASE_URL`, `SECRET_KEY_BASE`, `HANDOVER_SECRET`, `RELEASE_COOKIE`, and `METRICS_TOKEN`, and -may additionally contain both GitHub OAuth variables described above. The chart -intentionally has no invented CPU/RAM limits or HPA thresholds; measure this -application in the target environment before setting them. +may additionally contain the GitHub OAuth client ID and client secret described +above. The chart intentionally has no invented CPU/RAM limits or HPA +thresholds; measure this application in the target environment before setting +them. ## Local Codex category review diff --git a/compose.external-boundaries.yaml b/compose.external-boundaries.yaml new file mode 100644 index 0000000..2be8ccb --- /dev/null +++ b/compose.external-boundaries.yaml @@ -0,0 +1,87 @@ +name: who_need_help_external_boundaries + +services: + external-mock: + image: ${EXTERNAL_BOUNDARY_MOCK_IMAGE:?Set EXTERNAL_BOUNDARY_MOCK_IMAGE} + build: + context: ops/external-boundaries + environment: + MOCK_OAUTH_CLIENT_ID: ${EXTERNAL_OAUTH_CLIENT_ID:?Set EXTERNAL_OAUTH_CLIENT_ID} + MOCK_OAUTH_CLIENT_SECRET: ${EXTERNAL_OAUTH_CLIENT_SECRET:?Set EXTERNAL_OAUTH_CLIENT_SECRET} + MOCK_PUSH_BEARER_TOKEN: ${EXTERNAL_PUSH_BEARER_TOKEN:?Set EXTERNAL_PUSH_BEARER_TOKEN} + healthcheck: + test: + - CMD + - python + - -c + - >- + import urllib.request; + urllib.request.urlopen('http://127.0.0.1:8080/healthz', timeout=1).read() + interval: 1s + timeout: 2s + retries: 30 + user: "10001:10001" + read_only: true + tmpfs: + - /tmp + cap_drop: [ALL] + security_opt: + - no-new-privileges:true + networks: [boundary] + restart: "no" + + boundary-check: + image: ${EXTERNAL_BOUNDARY_APP_IMAGE:?Set EXTERNAL_BOUNDARY_APP_IMAGE} + build: + context: . + target: release + command: + - /app/bin/who_need_help + - eval + - WhoNeedHelp.ExternalBoundaryDrill.run!() + environment: + APP_ROLE: migrate + DATABASE_URL: ${EXTERNAL_DATABASE_URL:?Set EXTERNAL_DATABASE_URL} + SECRET_KEY_BASE: ${EXTERNAL_SECRET_KEY_BASE:?Set EXTERNAL_SECRET_KEY_BASE} + HANDOVER_SECRET: ${EXTERNAL_HANDOVER_SECRET:?Set EXTERNAL_HANDOVER_SECRET} + PHX_HOST: boundary.local + PHX_SCHEME: http + PHX_URL_PORT: "80" + GITHUB_OAUTH_CLIENT_ID: ${EXTERNAL_OAUTH_CLIENT_ID:?Set EXTERNAL_OAUTH_CLIENT_ID} + GITHUB_OAUTH_CLIENT_SECRET: ${EXTERNAL_OAUTH_CLIENT_SECRET:?Set EXTERNAL_OAUTH_CLIENT_SECRET} + GITHUB_OAUTH_BASE_URL: http://external-mock:8080 + GITHUB_OAUTH_AUTHORIZE_URL: http://external-mock:8080/oauth/authorize + GITHUB_OAUTH_TOKEN_URL: http://external-mock:8080/oauth/token + GITHUB_OAUTH_USER_URL: http://external-mock:8080/oauth/user + GITHUB_OAUTH_HTTP_CONNECT_TIMEOUT_MS: "100" + GITHUB_OAUTH_HTTP_RECEIVE_TIMEOUT_MS: "100" + SMTP_RELAY: external-mock + SMTP_PORT: "2525" + SMTP_USERNAME: "" + SMTP_PASSWORD: "" + SMTP_AUTH: never + SMTP_TLS: never + SMTP_SSL: "false" + EMAIL_FROM_NAME: Who Need Help boundary + EMAIL_FROM_ADDRESS: boundary@example.invalid + EXTERNAL_MOCK_BASE_URL: http://external-mock:8080 + EXTERNAL_SMTP_RELAY: external-mock + EXTERNAL_PUSH_BEARER_TOKEN: ${EXTERNAL_PUSH_BEARER_TOKEN:?Set EXTERNAL_PUSH_BEARER_TOKEN} + depends_on: + external-mock: + condition: service_healthy + volumes: + - ${EXTERNAL_BOUNDARY_OUTPUT_DIR:?Set EXTERNAL_BOUNDARY_OUTPUT_DIR}:/output + user: "${EXTERNAL_BOUNDARY_HOST_UID:?Set EXTERNAL_BOUNDARY_HOST_UID}:${EXTERNAL_BOUNDARY_HOST_GID:?Set EXTERNAL_BOUNDARY_HOST_GID}" + read_only: true + tmpfs: + - /tmp + cap_drop: [ALL] + security_opt: + - no-new-privileges:true + networks: [boundary] + restart: "no" + +networks: + boundary: + internal: true diff --git a/compose.yaml b/compose.yaml index bc5af51..367e570 100644 --- a/compose.yaml +++ b/compose.yaml @@ -27,6 +27,12 @@ x-app-environment: &app-environment MAP_TILE_URL: ${MAP_TILE_URL:-https://tile.openstreetmap.org/{z}/{x}/{y}.png} GITHUB_OAUTH_CLIENT_ID: ${GITHUB_OAUTH_CLIENT_ID:-} GITHUB_OAUTH_CLIENT_SECRET: ${GITHUB_OAUTH_CLIENT_SECRET:-} + GITHUB_OAUTH_BASE_URL: ${GITHUB_OAUTH_BASE_URL:-} + GITHUB_OAUTH_AUTHORIZE_URL: ${GITHUB_OAUTH_AUTHORIZE_URL:-} + GITHUB_OAUTH_TOKEN_URL: ${GITHUB_OAUTH_TOKEN_URL:-} + GITHUB_OAUTH_USER_URL: ${GITHUB_OAUTH_USER_URL:-} + GITHUB_OAUTH_HTTP_CONNECT_TIMEOUT_MS: ${GITHUB_OAUTH_HTTP_CONNECT_TIMEOUT_MS:-} + GITHUB_OAUTH_HTTP_RECEIVE_TIMEOUT_MS: ${GITHUB_OAUTH_HTTP_RECEIVE_TIMEOUT_MS:-} services: proxy: diff --git a/config/runtime.exs b/config/runtime.exs index d007d77..874a2a2 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -23,6 +23,54 @@ config :who_need_help, json -> Jason.decode!(json) end) +optional_positive_integer = fn name -> + case System.get_env(name) do + value when value in [nil, ""] -> + nil + + value -> + case Integer.parse(value) do + {integer, ""} when integer > 0 -> + integer + + _other -> + raise "#{name} must be a positive integer when configured." + end + end +end + +oauth_endpoint = fn name, default -> + value = + case System.get_env(name) do + configured when configured in [nil, ""] -> default + configured -> configured + end + + case URI.parse(value) do + %URI{scheme: scheme, host: host} + when scheme in ["http", "https"] and is_binary(host) and host != "" -> + value + + _other -> + raise "#{name} must be an absolute HTTP or HTTPS URL." + end +end + +oauth_http_options = + [retry: false] + |> then(fn options -> + case optional_positive_integer.("GITHUB_OAUTH_HTTP_RECEIVE_TIMEOUT_MS") do + nil -> options + timeout -> Keyword.put(options, :receive_timeout, timeout) + end + end) + |> then(fn options -> + case optional_positive_integer.("GITHUB_OAUTH_HTTP_CONNECT_TIMEOUT_MS") do + nil -> options + timeout -> Keyword.put(options, :connect_options, timeout: timeout) + end + end) + github_oauth = case { System.get_env("GITHUB_OAUTH_CLIENT_ID"), @@ -34,7 +82,20 @@ github_oauth = %{ github: [ client_id: client_id, - client_secret: client_secret + client_secret: client_secret, + base_url: oauth_endpoint.("GITHUB_OAUTH_BASE_URL", "https://api.github.com"), + authorize_url: + oauth_endpoint.( + "GITHUB_OAUTH_AUTHORIZE_URL", + "https://github.com/login/oauth/authorize" + ), + token_url: + oauth_endpoint.( + "GITHUB_OAUTH_TOKEN_URL", + "https://github.com/login/oauth/access_token" + ), + user_url: oauth_endpoint.("GITHUB_OAUTH_USER_URL", "https://api.github.com/user"), + http_adapter: {Assent.HTTPAdapter.Req, oauth_http_options} ] } diff --git a/docs/dependency-baseline.md b/docs/dependency-baseline.md index a7e4b2f..a707d8d 100644 --- a/docs/dependency-baseline.md +++ b/docs/dependency-baseline.md @@ -41,7 +41,7 @@ package checksums are in `mix.lock` and `assets/package-lock.json`. | Prometheus | 3.13.1 | | Alertmanager | 0.33.1 | | Grafana | 13.1.0 | -| Python alert-boundary runtime | 3.14.6 / Alpine 3.23 | +| Python alert/external-mock runtime | 3.14.6 / Alpine 3.23 | | Restic | 0.19.1, rebuilt with Go 1.26.5 | | MinIO server | RELEASE.2025-10-15T17-29-55Z, rebuilt with Go 1.26.5 | | MinIO client | RELEASE.2025-08-13T08-35-41Z, rebuilt with Go 1.26.5 | @@ -154,4 +154,5 @@ docker run --rm who-need-help:node-deps npm outdated --json ./scripts/android-instrumentation-test.sh ./scripts/observability-run.sh local-observability ./scripts/backup-s3-drill.sh local-encrypted-backup +./scripts/external-boundaries-run.sh local-boundaries ``` diff --git a/docs/local-hardening-plan.md b/docs/local-hardening-plan.md index 60e3975..cf257a0 100644 --- a/docs/local-hardening-plan.md +++ b/docs/local-hardening-plan.md @@ -10,13 +10,13 @@ item below unless the evidence column explicitly describes a local mock. | Browser E2E | Manual headed-Chrome scenarios exist; no committed browser suite | A fresh uniquely named Compose project runs two-user urgent help, Activity, moderation, privacy, and error paths; traces are retained on failure; its exact volume is removed | | Android UI | Two JVM unit-test files; no `androidTest` source set | Emulator instrumentation covers deep links, permissions, foreground tracking, notification Stop, lifecycle, and network failure | | CI and quality | No tracked CI workflow or static/security analysis dependencies | The same containerized gates pass locally and are represented in a validated CI workflow | -| Localization and accessibility | Completed locally: product copy and custom validation messages are extracted; EN/UK/RU catalogs and localized category descriptions/structured values are implemented | 508 default and 40 error messages are current; RU/UK have no empty/fuzzy entries; 163 backend tests and all 8 browser specs pass, including locale persistence, keyboard, axe, themes, responsive widths, and reconnect | +| Localization and accessibility | Completed locally: product copy and custom validation messages are extracted; EN/UK/RU catalogs and localized category descriptions/structured values are implemented | 508 default and 40 error messages are current; RU/UK have no empty/fuzzy entries; 167 backend tests and all 8 browser specs pass, including locale persistence, keyboard, axe, themes, responsive widths, and reconnect | | Database scale | Core discovery/chat/moderation lists call unbounded `Repo.all()` | Cursor-bounded queries pass behavior tests and measured `EXPLAIN ANALYZE` checks on an isolated generated dataset | | Load and resilience | Public/readiness/heartbeat k6 profile exists | Authenticated writes, chat, tracking, reconnect, rolling replacement, and worker retry profiles pass without touching staging data | | Observability | Completed locally: protected per-process metrics feed a pinned Prometheus/Grafana/Alertmanager profile | All 3 direct web targets are up before/after the drill; a verified replica stop delivers firing and resolved webhooks; Grafana datasource/dashboard and an empty DB-count diff are retained | | Backup | Completed locally: a plaintext-free Restic stream is retained in pinned local MinIO | The encrypted repository passes full-data checking and fresh-database restore; corrupted configuration and an interrupted upload fail closed, leave no snapshot, and their temporary buckets are removed | -| External boundaries | Mailpit and a fake GitHub strategy cover parts of SMTP/OAuth | Local protocol-level SMTP/OAuth mocks and the applicable push adapter boundary cover success, rejection, retry, replay, and timeout | -| Final regression | 163 Phoenix tests plus reproducible browser and Android device suites | Browser, Android, API, DB, WebSocket, backup, monitoring, failure, cleanup, docs, and clean Git are verified from the final commits | +| External boundaries | Completed locally: real Assent/Req, Swoosh/gen_smtp, and the applicable future push adapter run against an internal protocol mock | Success, rejection, retry, replay, timeout, PKCE/state, one-time-code, and idempotency paths pass without host ports or retained secrets; remote push product integration is explicitly not implemented | +| Final regression | 167 Phoenix tests plus reproducible browser and Android device suites | Browser, Android, API, DB, WebSocket, backup, monitoring, failure, cleanup, docs, and clean Git are verified from the final commits | The goal remains open while any row lacks reproducible local evidence. @@ -55,9 +55,10 @@ The goal remains open while any row lacks reproducible local evidence. - The containerized `scripts/quality.sh` gate passes ShellCheck, Hadolint, actionlint, all Compose renders, Helm lint, a Trivy scan of tracked source and the rendered Kubernetes manifest, compiler/xref/Credo/Sobelow/Dialyzer/Hex - checks, 163 Phoenix tests, both npm audits, and a Trivy scan of the production - release image. It creates random one-run database credentials and removes its - exact volume, networks, images, and source snapshot. + checks, 167 Phoenix tests, both npm audits, and Trivy scans of the backup, + MinIO server/client, external-mock, and production release images. It creates + random one-run database credentials and removes its exact volume, networks, + images, and source snapshot. - The checked-in GitHub Actions workflow runs the same isolated backend/security gate, browser E2E suite, and Android unit/lint/APK build. The workflow itself passes actionlint and uses a commit-pinned checkout action without persisted @@ -101,5 +102,14 @@ The goal remains open while any row lacks reproducible local evidence. after an encrypted object reached MinIO, published no interrupted snapshot, pruned the unreferenced packs, removed both temporary buckets and the temporary restore database, and left source table counts unchanged. -- The remaining rows above are still pending; this document is not a - completion claim for the entire hardening goal. +- The isolated external-service drill uses the release's configured Assent/Req + and Swoosh/gen_smtp clients against an internal-only protocol mock. Its + canonical run passed OAuth success, PKCE/state rejection, provider denial, + one-time-code replay, temporary failure and timeout; SMTP success, permanent + rejection, one temporary retry, timeout and repeated submission; and the + future HTTP push boundary's disabled default, retry and idempotent replay and + timeout paths. It retained no credentials, published no host ports, removed + its exact project and one-run images, and records remote push workflow + integration as not implemented. +- The final-regression row remains pending; this document is not a completion + claim for the entire hardening goal. diff --git a/docs/operations.md b/docs/operations.md index 73e1a64..0c1b672 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -82,6 +82,51 @@ failure paths on the observed workstation. A MinIO volume on that same workstation is not an off-site backup and does not establish production RPO, RTO, retention, capacity, key custody, object locking, or database HA. +## Local external-service boundary drill + +Run the OAuth, SMTP, and future push-adapter protocol checks without public +credentials, a server, or host-published ports: + +```bash +./scripts/external-boundaries-run.sh local-boundaries +``` + +The script creates a uniquely named Compose project on an internal-only Docker +network. It generates independent one-run OAuth and push credentials in an +ignored mode-`0600` environment file, builds the production release plus a +non-root standard-library Python protocol mock, and then verifies: + +1. GitHub-compatible OAuth authorization, PKCE S256, token exchange, normalized + user lookup, state mismatch, provider rejection, one-time-code replay, + a fresh flow after a temporary token error, and a token timeout; +2. SMTP acceptance, permanent recipient rejection without retry, one retry + after a temporary greeting failure, a greeting timeout, and the result of + submitting the same message twice; +3. the disabled default push boundary plus HTTP success, permanent rejection, + one temporary retry, replay deduplication, and deduplication after an + ambiguous timeout using the same idempotency key. + +The evidence JSON and mock state contain only counters, booleans, normalized +identity fields, and payload digests. The script fails if any generated secret +appears in retained evidence. Its trap validates exact Compose labels, removes +only that project, volumes and one-run images, and deletes the temporary +credential file. Failure logs are retained under the same ignored evidence +directory. + +This drill uses the application's real Assent/Req and Swoosh/gen_smtp clients, +but the provider is local. It therefore verifies the client-side protocol +boundary, not GitHub availability or SMTP-provider reliability. SMTP permits +duplicate delivery after ambiguous outcomes, so the result explicitly makes no +exactly-once claim. The push HTTP adapter is not connected to request, chat, or +tracking workflows and is not an FCM or APNs implementation; remote push +product integration remains unimplemented. + +The implementation follows the configured adapter interfaces and protocol +semantics documented by +[Assent 0.3.1](https://hexdocs.pm/assent/0.3.1/Assent.HTTPAdapter.html), +[GitHub OAuth](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps), +and [SMTP RFC 5321](https://datatracker.ietf.org/doc/html/rfc5321). + ## Isolated restore drill Run a real restore into a uniquely named temporary database: diff --git a/docs/verification.md b/docs/verification.md index ff56118..756de3f 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -16,29 +16,31 @@ results from product limits and unknown production properties. | 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. After Home minimized the Activity, an emulator coordinate change reached PostGIS. Notification Stop removed the service, notification, active session, and raw position. | 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. | | 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. | -| Social profiles | Manual links implemented; 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; 163 tests pass, including callback replay/state checks. No access-token field exists and the controller receives only normalized identity attributes. | The staging operator has not supplied GitHub OAuth credentials, so the real external provider redirect/callback remains disabled and has not been browser-verified. Other providers remain manual/unverified. | +| Social profiles | Manual links implemented; 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; 167 tests pass, including callback replay/state checks. The local protocol drill also performs real HTTP token/user exchanges without returning an access token to the application. | The staging operator has not supplied GitHub OAuth credentials, so the real external provider redirect/callback remains disabled and has not been browser-verified. Other providers remain manual/unverified. | | 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. | | Android client | Local and public-staging clients implemented and emulator-verified | The native packages `org.whoneedhelp.mobile.debug` and `org.whoneedhelp.mobile.staging` launch the same authenticated LiveView app. Public HTTPS login, map, two-way chat, permission prompts, minimized foreground-service location updates, notification Stop, deep-link routing, and server cleanup were exercised on API 37. | Production signing, Play Store publication, verified Android App Links, unattended/background-permission tracking, and iOS are not implemented. | | Multiple web/worker instances | Implemented and locally failure/rollout-verified | The isolated Compose profile passed BEAM crashes and sequential replacement with 3 web/2 worker replicas, all five nodes joined, PubSub passed, and 743/743 readiness requests succeeded. The project-owned kind cluster replaced all 2 web/2 worker pod UIDs under `maxUnavailable=0`; all four replacement pods joined and PubSub passed. | 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 all 3 direct load web targets with a file Bearer credential; Grafana provisioned a healthy datasource and four-panel 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 retained MinIO volume is on the same workstation; this is not off-site storage, database HA, or a production RPO/RTO/retention claim. | +| External protocol boundaries | Implemented and locally failure-verified | The production release used its configured Assent/Req and Swoosh/gen_smtp clients against an internal-only mock; OAuth and SMTP success/rejection/retry/replay/timeout paths passed. The future HTTP push boundary passed disabled, retry, rejection and idempotency paths. | This does not verify external provider availability. Remote FCM/APNs delivery and product-workflow integration are not implemented; SMTP exactly-once delivery is not claimed. | ## Reproducible checks -- The isolated Phoenix suite completed on 2026-07-19 with 163 +- The isolated Phoenix suite completed on 2026-07-19 with 167 tests and 0 failures after cursor pagination, database aggregation, and the full localization changes on Elixir 1.20.2 and Erlang/OTP 29.0.3. - `mix compile --force --warnings-as-errors` and `mix format --check-formatted`: passed against the same final source. - `./scripts/quality.sh` passed ShellCheck 0.11.0, Hadolint 2.14.0 at warning - threshold, actionlint 1.7.12, all six Compose renders, Helm lint, Trivy + threshold, actionlint 1.7.12, all seven Compose renders, Helm lint, Trivy source/rendered-manifest scanning, xref, Credo high-priority checks, Sobelow - strict/private checks, Hex audit, 163 Phoenix tests, both npm audits, and the - backup/MinIO/mc/release-image vulnerability scans. The rendered Helm manifest - reported zero HIGH/CRITICAL misconfigurations; the Alpine backup, MinIO, and - mc images and the Debian 13.6 release image each reported zero HIGH/CRITICAL - vulnerabilities under the configured gates. The backup binary reported + strict/private checks, Hex audit, 167 Phoenix tests, both npm audits, and the + backup/MinIO/mc/external-mock/release-image vulnerability scans. The rendered + Helm manifest reported zero HIGH/CRITICAL misconfigurations; the Alpine + backup, MinIO, mc, and external-mock images and the Debian 13.6 release image + each reported zero HIGH/CRITICAL vulnerabilities under the configured gates. + The backup binary reported Restic 0.19.1 compiled with Go 1.26.5; MinIO and mc reported their pinned commits and Go 1.26.5. - Dialyzer passed with three path- and warning-specific documented filters and @@ -107,6 +109,17 @@ results from product limits and unknown production properties. empty. The obsolete successful-run bucket was then removed; only the final canonical bucket remains. Evidence is retained at `output/backups-s3/backup-canonical-20260719d`. +- The canonical external-boundary drill passed real client-side OAuth token and + user HTTP exchanges, PKCE/state checks, provider denial, one-time-code replay, + a fresh flow after a temporary token error, and timeout failure. It also + passed SMTP acceptance, permanent rejection without retry, one retry after a + temporary greeting, timeout, and repeated submission, plus the future push + adapter's disabled default, rejection, one retry, replay deduplication and + ambiguous-timeout deduplication. Every retained file is mode `0600`, the + evidence contains no generated credential, no host port was published, and + the exact Compose project, network, and one-run images were absent after + cleanup. Evidence is retained at + `output/external-boundaries/boundary-canonical-20260719d`. - The committed browser suite passed its 1/1 bootstrap and all 8/8 Chromium specs against a fresh PostGIS volume with two web and two worker replicas on 2026-07-19. The retained successful-run artifact directory is diff --git a/lib/who_need_help/external_boundary_drill.ex b/lib/who_need_help/external_boundary_drill.ex new file mode 100644 index 0000000..af9e04b --- /dev/null +++ b/lib/who_need_help/external_boundary_drill.ex @@ -0,0 +1,433 @@ +defmodule WhoNeedHelp.ExternalBoundaryDrill do + @moduledoc """ + Explicit local-only protocol drill for OAuth, SMTP, and the future push boundary. + + It is invoked by `scripts/external-boundaries-run.sh`; normal application + startup never calls it. + """ + + import Swoosh.Email + + alias WhoNeedHelp.Push + alias WhoNeedHelp.Push.HTTPAdapter, as: PushHTTPAdapter + alias WhoNeedHelp.SocialOAuth.AssentAdapter + + @oauth_redirect_uri "http://boundary.local/auth/social/github/callback" + @output_directory "/output" + @output_path "/output/summary.json" + + def run! do + ensure_started!(:req) + ensure_started!(:swoosh) + ensure_started!(:gen_smtp) + + base_url = fetch_env!("EXTERNAL_MOCK_BASE_URL") + + summary = %{ + status: "passed", + oauth: oauth_drill(base_url), + smtp: smtp_drill(base_url), + push: push_drill(base_url), + push_product_integration: "not_implemented" + } + + File.mkdir_p!(@output_directory) + File.write!(@output_path, Jason.encode_to_iodata!(summary, pretty: true)) + File.chmod!(@output_path, 0o600) + + IO.puts("External boundary evidence: #{@output_path}") + :ok + end + + defp oauth_drill(base_url) do + assert!(AssentAdapter.enabled?(:github), "runtime GitHub OAuth config is disabled") + + control!(base_url, "oauth", "success") + {success_session, success_params} = oauth_authorization!() + + {:ok, identity} = + AssentAdapter.callback( + :github, + @oauth_redirect_uri, + success_params, + success_session + ) + + assert!(identity.provider_uid == "4242", "OAuth identity UID was not normalized") + assert!(identity.handle == "@local-neighbor", "OAuth identity handle was not normalized") + assert!(not Map.has_key?(identity, :access_token), "OAuth token escaped adapter boundary") + + success_state = state!(base_url)["oauth"] + assert!(success_state["token_requests"] == 1, "OAuth token request was not observed") + assert!(success_state["user_requests"] == 1, "OAuth user request was not observed") + assert!(success_state["consumed_codes"] == 1, "OAuth code was not consumed") + + assert_error!( + AssentAdapter.callback( + :github, + @oauth_redirect_uri, + success_params, + success_session + ), + "OAuth code replay unexpectedly succeeded" + ) + + replay_state = state!(base_url)["oauth"] + assert!(replay_state["token_requests"] == 2, "OAuth replay did not reach token endpoint") + assert!(replay_state["user_requests"] == 1, "OAuth replay reached the user endpoint") + + control!(base_url, "oauth", "success") + {mismatch_session, mismatch_params} = oauth_authorization!() + + assert_error!( + AssentAdapter.callback( + :github, + @oauth_redirect_uri, + Map.put(mismatch_params, "state", "mismatched-state"), + mismatch_session + ), + "OAuth state mismatch unexpectedly succeeded" + ) + + mismatch_state = state!(base_url)["oauth"] + assert!(mismatch_state["token_requests"] == 0, "state mismatch reached token endpoint") + + control!(base_url, "oauth", "deny_authorize") + {denied_session, denied_params} = oauth_authorization!() + + assert_error!( + AssentAdapter.callback( + :github, + @oauth_redirect_uri, + denied_params, + denied_session + ), + "provider authorization rejection unexpectedly succeeded" + ) + + denied_state = state!(base_url)["oauth"] + assert!(denied_state["token_requests"] == 0, "provider rejection reached token endpoint") + + control!(base_url, "oauth", "token_temporary_once") + {temporary_session, temporary_params} = oauth_authorization!() + + assert_error!( + AssentAdapter.callback( + :github, + @oauth_redirect_uri, + temporary_params, + temporary_session + ), + "temporary OAuth token failure unexpectedly succeeded" + ) + + {retry_session, retry_params} = oauth_authorization!() + + {:ok, _retried_identity} = + AssentAdapter.callback( + :github, + @oauth_redirect_uri, + retry_params, + retry_session + ) + + retry_state = state!(base_url)["oauth"] + assert!(retry_state["token_requests"] == 2, "fresh OAuth retry count was not two") + assert!(retry_state["user_requests"] == 1, "fresh OAuth retry did not fetch user") + + control!(base_url, "oauth", "token_timeout_once") + {timeout_session, timeout_params} = oauth_authorization!() + + assert_error!( + AssentAdapter.callback( + :github, + @oauth_redirect_uri, + timeout_params, + timeout_session + ), + "OAuth timeout unexpectedly succeeded" + ) + + Process.sleep(400) + timeout_state = state!(base_url)["oauth"] + assert!(timeout_state["token_requests"] == 1, "OAuth timeout request was not observed") + assert!(timeout_state["user_requests"] == 0, "timed-out OAuth flow fetched user") + + %{ + success: "passed", + state_mismatch_blocked_before_token: true, + provider_rejection_blocked_before_token: true, + one_time_code_replay_rejected: true, + fresh_flow_retry_after_temporary_failure: "passed", + timeout_failed_closed: true, + access_token_returned_to_application: false + } + end + + defp oauth_authorization! do + {:ok, %{url: url, session_params: session_params}} = + AssentAdapter.authorize_url(:github, @oauth_redirect_uri) + + response = Req.get!(url, redirect: false, retry: false) + assert!(response.status == 302, "OAuth mock did not redirect") + + [location] = Req.Response.get_header(response, "location") + params = location |> URI.parse() |> Map.fetch!(:query) |> URI.decode_query() + + {session_params, params} + end + + defp smtp_drill(base_url) do + relay = fetch_env!("EXTERNAL_SMTP_RELAY") + control!(base_url, "smtp", "success") + + assert_smtp_success!( + smtp_email("success@example.invalid"), + smtp_options(relay, 2525, retries: 0) + ) + + success_state = state!(base_url)["smtp"] + assert!(success_state["messages"] == 1, "SMTP success message was not accepted") + + control!(base_url, "smtp", "success") + + assert_error!( + Swoosh.Adapters.SMTP.deliver( + smtp_email("reject@example.invalid"), + smtp_options(relay, 2525, retries: 1) + ), + "SMTP permanent rejection unexpectedly succeeded" + ) + + rejection_state = state!(base_url)["smtp"] + assert!(rejection_state["rejections"] == 1, "SMTP rejection was not observed") + assert!(rejection_state["messages"] == 0, "rejected SMTP message was accepted") + + assert!( + rejection_state["connections"]["2525"] == 1, + "permanent SMTP rejection was retried" + ) + + control!(base_url, "smtp", "success") + + assert_smtp_success!( + smtp_email("retry@example.invalid"), + smtp_options(relay, 2526, retries: 1) + ) + + retry_state = state!(base_url)["smtp"] + assert!(retry_state["connections"]["2526"] == 2, "SMTP temporary failure was not retried") + assert!(retry_state["messages"] == 1, "retried SMTP message was not accepted") + + control!(base_url, "smtp", "success") + + assert_error!( + Swoosh.Adapters.SMTP.deliver( + smtp_email("timeout@example.invalid"), + smtp_options(relay, 2527, retries: 0, timeout: 100) + ), + "SMTP timeout unexpectedly succeeded" + ) + + timeout_state = state!(base_url)["smtp"] + assert!(timeout_state["connections"]["2527"] == 1, "SMTP timeout connection was absent") + assert!(timeout_state["messages"] == 0, "timed-out SMTP message was accepted") + + control!(base_url, "smtp", "success") + replay_email = smtp_email("replay@example.invalid") + replay_options = smtp_options(relay, 2525, retries: 0) + assert_smtp_success!(replay_email, replay_options) + assert_smtp_success!(replay_email, replay_options) + + replay_state = state!(base_url)["smtp"] + assert!(replay_state["messages"] == 2, "SMTP replay behavior was not observed") + + %{ + success: "passed", + permanent_rejection_not_retried: true, + temporary_greeting_retried_once: true, + timeout_failed_closed: true, + repeated_submission_count: 2, + exactly_once_delivery_claimed: false + } + end + + defp smtp_email(recipient) do + new() + |> to(recipient) + |> from({"Who Need Help boundary", "boundary@example.invalid"}) + |> subject("External boundary drill") + |> text_body("Local protocol boundary payload") + end + + defp smtp_options(relay, port, overrides) do + [ + relay: relay, + port: port, + auth: :never, + tls: :never, + ssl: false, + no_mx_lookups: true, + timeout: 1_000, + retries: 0 + ] + |> Keyword.merge(overrides) + end + + defp assert_smtp_success!(email, options) do + case Swoosh.Adapters.SMTP.deliver(email, options) do + {:ok, receipt} when is_binary(receipt) -> :ok + other -> raise "SMTP delivery failed: #{inspect(error_shape(other))}" + end + end + + defp push_drill(base_url) do + endpoint = "#{base_url}/push" + bearer_token = fetch_env!("EXTERNAL_PUSH_BEARER_TOKEN") + + assert_error!( + Push.deliver(push_notification("disabled")), + "default push adapter unexpectedly delivered" + ) + + control!(base_url, "push", "success") + success = push_deliver!(endpoint, bearer_token, "push-success", max_attempts: 1) + assert!(success.duplicate == false, "first push was marked duplicate") + + success_state = state!(base_url)["push"] + assert!(success_state["attempts"] == 1, "push success attempt count was not one") + assert!(success_state["deliveries"] == 1, "push success delivery count was not one") + + control!(base_url, "push", "reject") + + assert_error!( + push_deliver(endpoint, bearer_token, "push-reject", max_attempts: 2), + "push rejection unexpectedly succeeded" + ) + + rejection_state = state!(base_url)["push"] + assert!(rejection_state["attempts"] == 1, "permanent push rejection was retried") + assert!(rejection_state["deliveries"] == 0, "rejected push was delivered") + + control!(base_url, "push", "temporary_once") + retry = push_deliver!(endpoint, bearer_token, "push-retry", max_attempts: 2) + assert!(retry.duplicate == false, "temporary push retry was marked duplicate") + + retry_state = state!(base_url)["push"] + assert!(retry_state["attempts"] == 2, "temporary push failure was not retried once") + assert!(retry_state["deliveries"] == 1, "temporary push retry duplicated delivery") + + control!(base_url, "push", "success") + first = push_deliver!(endpoint, bearer_token, "push-replay", max_attempts: 1) + replay = push_deliver!(endpoint, bearer_token, "push-replay", max_attempts: 1) + + assert!(first.id == replay.id, "push replay returned a different receipt") + assert!(replay.duplicate, "push replay was not marked duplicate") + + replay_state = state!(base_url)["push"] + assert!(replay_state["attempts"] == 2, "push replay attempt count was not two") + assert!(replay_state["deliveries"] == 1, "push replay created duplicate delivery") + + control!(base_url, "push", "timeout_after_accept") + + timeout = + push_deliver!( + endpoint, + bearer_token, + "push-timeout", + max_attempts: 2, + receive_timeout: 100 + ) + + assert!(timeout.duplicate, "ambiguous push timeout retry was not deduplicated") + + timeout_state = state!(base_url)["push"] + assert!(timeout_state["attempts"] == 2, "push timeout was not retried once") + assert!(timeout_state["deliveries"] == 1, "push timeout retry duplicated delivery") + + %{ + default_adapter_disabled: true, + success: "passed", + permanent_rejection_not_retried: true, + temporary_failure_retried_once: true, + replay_deduplicated: true, + timeout_after_accept_deduplicated: true, + domain_workflow_integration: "not_implemented" + } + end + + defp push_notification(idempotency_key) do + %{ + idempotency_key: idempotency_key, + recipient: "future-device-token", + title: "Help update", + body: "A local boundary event", + data: %{"kind" => "boundary_drill"} + } + end + + defp push_deliver!(endpoint, bearer_token, idempotency_key, overrides) do + case push_deliver(endpoint, bearer_token, idempotency_key, overrides) do + {:ok, receipt} -> receipt + other -> raise "push delivery failed: #{inspect(error_shape(other))}" + end + end + + defp push_deliver(endpoint, bearer_token, idempotency_key, overrides) do + options = + [ + adapter: PushHTTPAdapter, + endpoint: endpoint, + bearer_token: bearer_token, + max_attempts: 1, + receive_timeout: 1_000, + connect_timeout: 1_000, + retry_delay_ms: 0 + ] + |> Keyword.merge(overrides) + + Push.deliver(push_notification(idempotency_key), options) + end + + defp control!(base_url, component, mode) do + response = + Req.post!( + "#{base_url}/control", + json: %{component: component, mode: mode}, + retry: false + ) + + assert!(response.status == 200, "boundary mock control failed") + end + + defp state!(base_url) do + response = Req.get!("#{base_url}/state", retry: false) + assert!(response.status == 200, "boundary mock state request failed") + response.body + end + + defp assert_error!({:error, _error}, _message), do: :ok + defp assert_error!(_result, message), do: raise(message) + + defp assert!(true, _message), do: :ok + defp assert!(false, message), do: raise(message) + + defp error_shape({:error, {kind, status, _body}}), do: {:error, kind, status} + defp error_shape({:error, error}) when is_atom(error), do: {:error, error} + defp error_shape({:error, %{__struct__: module}}), do: {:error, module} + defp error_shape(other), do: other + + defp fetch_env!(name) do + case System.fetch_env(name) do + {:ok, value} when value != "" -> value + _other -> raise "#{name} is required for the external boundary drill." + end + end + + defp ensure_started!(application) do + case Application.ensure_all_started(application) do + {:ok, _started} -> :ok + {:error, reason} -> raise "could not start #{application}: #{inspect(reason)}" + end + end +end diff --git a/lib/who_need_help/push.ex b/lib/who_need_help/push.ex new file mode 100644 index 0000000..6fb8520 --- /dev/null +++ b/lib/who_need_help/push.ex @@ -0,0 +1,66 @@ +defmodule WhoNeedHelp.Push do + @moduledoc """ + Provider-neutral boundary for future remote push delivery. + + The product does not currently register FCM/APNs device tokens or invoke this + boundary from domain workflows. The default adapter is deliberately disabled. + A local HTTP adapter exists so delivery, retry, timeout, and idempotency + semantics can be verified before a provider is selected. + """ + + @type notification :: %{ + required(:idempotency_key) => String.t(), + required(:recipient) => String.t(), + required(:title) => String.t(), + required(:body) => String.t(), + optional(:data) => map() + } + + @type receipt :: %{id: String.t(), duplicate: boolean()} + @type delivery_error :: :disabled | {:invalid_notification, atom()} | term() + + @callback deliver(notification(), keyword()) :: + {:ok, receipt()} | {:error, delivery_error()} + + def deliver(notification, opts \\ []) do + adapter = + Keyword.get_lazy(opts, :adapter, fn -> + Application.get_env( + :who_need_help, + :push_adapter, + WhoNeedHelp.Push.DisabledAdapter + ) + end) + + with :ok <- validate(notification) do + adapter.deliver(notification, Keyword.delete(opts, :adapter)) + end + end + + defp validate(%{ + idempotency_key: idempotency_key, + recipient: recipient, + title: title, + body: body + }) + when is_binary(idempotency_key) and idempotency_key != "" and + is_binary(recipient) and recipient != "" and + is_binary(title) and title != "" and is_binary(body) and body != "" do + :ok + end + + defp validate(notification) when not is_map(notification), + do: {:error, {:invalid_notification, :not_a_map}} + + defp validate(notification) do + required = [:idempotency_key, :recipient, :title, :body] + + case Enum.find(required, fn key -> + value = Map.get(notification, key) + not (is_binary(value) and value != "") + end) do + nil -> :ok + key -> {:error, {:invalid_notification, key}} + end + end +end diff --git a/lib/who_need_help/push/disabled_adapter.ex b/lib/who_need_help/push/disabled_adapter.ex new file mode 100644 index 0000000..49fe8db --- /dev/null +++ b/lib/who_need_help/push/disabled_adapter.ex @@ -0,0 +1,8 @@ +defmodule WhoNeedHelp.Push.DisabledAdapter do + @moduledoc false + + @behaviour WhoNeedHelp.Push + + @impl true + def deliver(_notification, _opts), do: {:error, :disabled} +end diff --git a/lib/who_need_help/push/http_adapter.ex b/lib/who_need_help/push/http_adapter.ex new file mode 100644 index 0000000..0e8ca94 --- /dev/null +++ b/lib/who_need_help/push/http_adapter.ex @@ -0,0 +1,99 @@ +defmodule WhoNeedHelp.Push.HTTPAdapter do + @moduledoc """ + HTTP boundary used by the local external-service drill. + + It is not an FCM or APNs implementation. Every retry reuses the caller's + idempotency key so a compatible provider can deduplicate an ambiguous result. + """ + + @behaviour WhoNeedHelp.Push + + @retryable_statuses [408, 425, 429, 500, 502, 503, 504] + + @impl true + def deliver(notification, opts) do + with {:ok, endpoint} <- fetch_binary(opts, :endpoint), + {:ok, bearer_token} <- fetch_binary(opts, :bearer_token), + {:ok, max_attempts} <- fetch_positive_integer(opts, :max_attempts, 1), + {:ok, receive_timeout} <- fetch_positive_integer(opts, :receive_timeout), + {:ok, connect_timeout} <- fetch_positive_integer(opts, :connect_timeout), + {:ok, retry_delay_ms} <- fetch_non_negative_integer(opts, :retry_delay_ms, 0) do + request_opts = [ + url: endpoint, + json: notification, + headers: [ + {"authorization", "Bearer #{bearer_token}"}, + {"idempotency-key", notification.idempotency_key} + ], + retry: false, + receive_timeout: receive_timeout, + connect_options: [timeout: connect_timeout] + ] + + attempt(request_opts, max_attempts, retry_delay_ms, 1) + end + end + + defp attempt(request_opts, max_attempts, retry_delay_ms, attempt_number) do + case Req.post(request_opts) do + {:ok, %Req.Response{status: status, body: body}} when status in 200..299 -> + normalize_receipt(body) + + {:ok, %Req.Response{status: status, body: _body}} + when status in @retryable_statuses and attempt_number < max_attempts -> + wait(retry_delay_ms) + attempt(request_opts, max_attempts, retry_delay_ms, attempt_number + 1) + + {:ok, %Req.Response{status: status, body: body}} when status in @retryable_statuses -> + {:error, {:retry_exhausted, status, sanitize_body(body)}} + + {:ok, %Req.Response{status: status, body: body}} -> + {:error, {:rejected, status, sanitize_body(body)}} + + {:error, _error} when attempt_number < max_attempts -> + wait(retry_delay_ms) + attempt(request_opts, max_attempts, retry_delay_ms, attempt_number + 1) + + {:error, error} -> + {:error, {:transport, transport_reason(error)}} + end + end + + defp normalize_receipt(%{"id" => id} = body) when is_binary(id) and id != "" do + {:ok, %{id: id, duplicate: Map.get(body, "duplicate", false) == true}} + end + + defp normalize_receipt(_body), do: {:error, :invalid_receipt} + + defp sanitize_body(body) when is_map(body), + do: Map.take(body, ["error", "message"]) + + defp sanitize_body(_body), do: nil + + defp transport_reason(%Req.TransportError{reason: reason}), do: reason + defp transport_reason(%{__struct__: module}), do: module + + defp fetch_binary(opts, key) do + case Keyword.get(opts, key) do + value when is_binary(value) and value != "" -> {:ok, value} + _other -> {:error, {:invalid_option, key}} + end + end + + defp fetch_positive_integer(opts, key, default \\ nil) do + case Keyword.get(opts, key, default) do + value when is_integer(value) and value > 0 -> {:ok, value} + _other -> {:error, {:invalid_option, key}} + end + end + + defp fetch_non_negative_integer(opts, key, default) do + case Keyword.get(opts, key, default) do + value when is_integer(value) and value >= 0 -> {:ok, value} + _other -> {:error, {:invalid_option, key}} + end + end + + defp wait(0), do: :ok + defp wait(milliseconds), do: Process.sleep(milliseconds) +end diff --git a/ops/external-boundaries/Dockerfile b/ops/external-boundaries/Dockerfile new file mode 100644 index 0000000..f964216 --- /dev/null +++ b/ops/external-boundaries/Dockerfile @@ -0,0 +1,9 @@ +FROM python:3.14.6-alpine3.23@sha256:b165067c5afc37fa5608a3c05609cc3d51aafd808a30fbfd822ee594fef55ad4 + +WORKDIR /app + +COPY mock_server.py /app/mock_server.py + +USER 10001:10001 + +ENTRYPOINT ["python", "/app/mock_server.py"] diff --git a/ops/external-boundaries/mock_server.py b/ops/external-boundaries/mock_server.py new file mode 100644 index 0000000..d2e3760 --- /dev/null +++ b/ops/external-boundaries/mock_server.py @@ -0,0 +1,478 @@ +#!/usr/bin/env python3 +"""Protocol-level OAuth, push, and SMTP mocks for the isolated local drill.""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import os +import signal +import socketserver +import threading +import time +import urllib.parse +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Any + + +HTTP_PORT = 8080 +SMTP_PORT = 2525 +SMTP_RETRY_PORT = 2526 +SMTP_TIMEOUT_PORT = 2527 +DELAY_SECONDS = 0.35 + + +class BoundaryState: + def __init__(self) -> None: + self.lock = threading.Lock() + self.oauth_mode = "success" + self.oauth_authorize_requests = 0 + self.oauth_token_requests = 0 + self.oauth_user_requests = 0 + self.oauth_code_counter = 0 + self.oauth_codes: dict[str, dict[str, Any]] = {} + self.oauth_tokens: set[str] = set() + self.push_mode = "success" + self.push_attempts = 0 + self.push_receipts: dict[str, str] = {} + self.push_deliveries = 0 + self.smtp_connections = { + str(SMTP_PORT): 0, + str(SMTP_RETRY_PORT): 0, + str(SMTP_TIMEOUT_PORT): 0, + } + self.smtp_messages = 0 + self.smtp_rejections = 0 + self.smtp_digests: list[str] = [] + + def control(self, component: str, mode: str) -> None: + with self.lock: + if component == "oauth": + self.oauth_mode = mode + self.oauth_authorize_requests = 0 + self.oauth_token_requests = 0 + self.oauth_user_requests = 0 + self.oauth_codes = {} + self.oauth_tokens = set() + elif component == "push": + self.push_mode = mode + self.push_attempts = 0 + self.push_receipts = {} + self.push_deliveries = 0 + elif component == "smtp": + self.smtp_connections = { + str(SMTP_PORT): 0, + str(SMTP_RETRY_PORT): 0, + str(SMTP_TIMEOUT_PORT): 0, + } + self.smtp_messages = 0 + self.smtp_rejections = 0 + self.smtp_digests = [] + else: + raise ValueError("unsupported component") + + def snapshot(self) -> dict[str, Any]: + with self.lock: + return { + "oauth": { + "mode": self.oauth_mode, + "authorize_requests": self.oauth_authorize_requests, + "token_requests": self.oauth_token_requests, + "user_requests": self.oauth_user_requests, + "issued_codes": len(self.oauth_codes), + "consumed_codes": sum( + 1 for code in self.oauth_codes.values() if code["used"] + ), + }, + "push": { + "mode": self.push_mode, + "attempts": self.push_attempts, + "deliveries": self.push_deliveries, + "idempotency_keys": len(self.push_receipts), + }, + "smtp": { + "connections": dict(self.smtp_connections), + "messages": self.smtp_messages, + "rejections": self.smtp_rejections, + "message_digests": list(self.smtp_digests), + }, + } + + +STATE = BoundaryState() +OAUTH_CLIENT_ID = os.environ["MOCK_OAUTH_CLIENT_ID"] +OAUTH_CLIENT_SECRET = os.environ["MOCK_OAUTH_CLIENT_SECRET"] +PUSH_BEARER_TOKEN = os.environ["MOCK_PUSH_BEARER_TOKEN"] + + +def json_bytes(value: Any) -> bytes: + return json.dumps(value, separators=(",", ":"), sort_keys=True).encode() + + +class BoundaryHTTPServer(ThreadingHTTPServer): + daemon_threads = True + + +class BoundaryHTTPHandler(BaseHTTPRequestHandler): + server_version = "WhoNeedHelpBoundaryMock/1" + + def log_message(self, _format: str, *_args: Any) -> None: + return + + def send_json(self, status: int, value: Any) -> None: + payload = json_bytes(value) + try: + self.send_response(status) + self.send_header("content-type", "application/json") + self.send_header("content-length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + except (BrokenPipeError, ConnectionResetError): + return + + def read_body(self) -> bytes: + length = int(self.headers.get("content-length", "0")) + if length < 0 or length > 65_536: + raise ValueError("invalid body length") + return self.rfile.read(length) + + def do_GET(self) -> None: # noqa: N802 + parsed = urllib.parse.urlsplit(self.path) + + if parsed.path == "/healthz": + self.send_json(200, {"status": "ok"}) + elif parsed.path == "/state": + self.send_json(200, STATE.snapshot()) + elif parsed.path == "/oauth/authorize": + self.oauth_authorize(parsed) + elif parsed.path == "/oauth/user": + self.oauth_user() + else: + self.send_json(404, {"error": "not_found"}) + + def do_POST(self) -> None: # noqa: N802 + parsed = urllib.parse.urlsplit(self.path) + + try: + body = self.read_body() + except ValueError: + self.send_json(413, {"error": "invalid_body"}) + return + + if parsed.path == "/control": + self.control(body) + elif parsed.path == "/oauth/token": + self.oauth_token(body) + elif parsed.path == "/push": + self.push(body) + else: + self.send_json(404, {"error": "not_found"}) + + def control(self, body: bytes) -> None: + try: + command = json.loads(body) + component = command["component"] + mode = command.get("mode", "success") + STATE.control(component, mode) + except (json.JSONDecodeError, KeyError, TypeError, ValueError): + self.send_json(400, {"error": "invalid_control"}) + return + + self.send_json(200, {"status": "reset", "component": component, "mode": mode}) + + def oauth_authorize(self, parsed: urllib.parse.SplitResult) -> None: + params = urllib.parse.parse_qs(parsed.query) + required = { + "client_id", + "redirect_uri", + "state", + "code_challenge", + "code_challenge_method", + } + + if not required.issubset(params) or params["client_id"][0] != OAUTH_CLIENT_ID: + self.send_json(400, {"error": "invalid_authorization_request"}) + return + + if params["code_challenge_method"][0] != "S256": + self.send_json(400, {"error": "unsupported_challenge_method"}) + return + + redirect_uri = params["redirect_uri"][0] + state = params["state"][0] + + with STATE.lock: + STATE.oauth_authorize_requests += 1 + mode = STATE.oauth_mode + + if mode != "deny_authorize": + STATE.oauth_code_counter += 1 + code = f"boundary-code-{STATE.oauth_code_counter}" + STATE.oauth_codes[code] = { + "challenge": params["code_challenge"][0], + "redirect_uri": redirect_uri, + "used": False, + } + + if mode == "deny_authorize": + callback_params = { + "error": "access_denied", + "error_description": "local mock denial", + "state": state, + } + else: + callback_params = {"code": code, "state": state} + + separator = "&" if urllib.parse.urlsplit(redirect_uri).query else "?" + location = f"{redirect_uri}{separator}{urllib.parse.urlencode(callback_params)}" + self.send_response(302) + self.send_header("location", location) + self.send_header("content-length", "0") + self.end_headers() + + def oauth_token(self, body: bytes) -> None: + params = { + key: values[0] + for key, values in urllib.parse.parse_qs(body.decode()).items() + } + + with STATE.lock: + STATE.oauth_token_requests += 1 + attempt = STATE.oauth_token_requests + mode = STATE.oauth_mode + + if mode == "token_temporary_once" and attempt == 1: + self.send_json(503, {"error": "temporarily_unavailable"}) + return + + if mode == "token_timeout_once" and attempt == 1: + time.sleep(DELAY_SECONDS) + + if ( + params.get("client_id") != OAUTH_CLIENT_ID + or params.get("client_secret") != OAUTH_CLIENT_SECRET + or params.get("grant_type") != "authorization_code" + ): + self.send_json(401, {"error": "invalid_client"}) + return + + code_value = params.get("code") + verifier = params.get("code_verifier", "") + challenge = base64.urlsafe_b64encode( + hashlib.sha256(verifier.encode()).digest() + ).rstrip(b"=").decode() + + with STATE.lock: + code = STATE.oauth_codes.get(code_value or "") + + if ( + code is None + or code["used"] + or code["redirect_uri"] != params.get("redirect_uri") + or code["challenge"] != challenge + ): + valid = False + else: + valid = True + code["used"] = True + token = f"boundary-access-{STATE.oauth_code_counter}-{attempt}" + STATE.oauth_tokens.add(token) + + if not valid: + self.send_json(400, {"error": "invalid_grant"}) + return + + self.send_json( + 200, + {"access_token": token, "scope": "", "token_type": "bearer"}, + ) + + def oauth_user(self) -> None: + authorization = self.headers.get("authorization", "") + token = authorization.removeprefix("Bearer ") + + with STATE.lock: + STATE.oauth_user_requests += 1 + valid = token in STATE.oauth_tokens + + if not valid: + self.send_json(401, {"error": "unauthorized"}) + return + + self.send_json( + 200, + { + "id": 4242, + "login": "local-neighbor", + "name": "Local Neighbor", + "html_url": "https://github.com/local-neighbor", + "avatar_url": "https://avatars.example.invalid/4242", + }, + ) + + def push(self, body: bytes) -> None: + try: + notification = json.loads(body) + except json.JSONDecodeError: + self.send_json(400, {"error": "invalid_json"}) + return + + idempotency_key = self.headers.get("idempotency-key", "") + authorization_ok = ( + self.headers.get("authorization") == f"Bearer {PUSH_BEARER_TOKEN}" + ) + + if not authorization_ok or not idempotency_key: + self.send_json(401, {"error": "unauthorized"}) + return + + if not all( + isinstance(notification.get(key), str) and notification[key] + for key in ("recipient", "title", "body", "idempotency_key") + ) or notification["idempotency_key"] != idempotency_key: + self.send_json(400, {"error": "invalid_notification"}) + return + + with STATE.lock: + STATE.push_attempts += 1 + attempt = STATE.push_attempts + mode = STATE.push_mode + + if mode == "reject": + self.send_json(400, {"error": "invalid_recipient"}) + return + + if mode == "temporary_once" and attempt == 1: + self.send_json(503, {"error": "temporarily_unavailable"}) + return + + with STATE.lock: + receipt = STATE.push_receipts.get(idempotency_key) + duplicate = receipt is not None + + if receipt is None: + receipt = f"push-receipt-{len(STATE.push_receipts) + 1}" + STATE.push_receipts[idempotency_key] = receipt + STATE.push_deliveries += 1 + + if mode == "timeout_after_accept" and attempt == 1: + time.sleep(DELAY_SECONDS) + + self.send_json(202, {"id": receipt, "duplicate": duplicate}) + + +class BoundarySMTPServer(socketserver.ThreadingTCPServer): + allow_reuse_address = True + daemon_threads = True + + def __init__(self, address: tuple[str, int], mode: str): + self.mode = mode + super().__init__(address, BoundarySMTPHandler) + + +class BoundarySMTPHandler(socketserver.StreamRequestHandler): + def send_line(self, line: bytes) -> None: + self.wfile.write(line + b"\r\n") + self.wfile.flush() + + def handle(self) -> None: + port = self.server.server_address[1] + + with STATE.lock: + key = str(port) + STATE.smtp_connections[key] += 1 + connection_number = STATE.smtp_connections[key] + + if self.server.mode == "timeout": + time.sleep(DELAY_SECONDS) + return + + if self.server.mode == "retry" and connection_number == 1: + self.send_line(b"421 4.3.0 temporary local mock failure") + return + + self.send_line(b"220 boundary.local ESMTP") + recipients: list[str] = [] + + while True: + raw = self.rfile.readline(8192) + if not raw: + return + + command = raw.decode(errors="replace").strip() + upper = command.upper() + + if upper.startswith(("EHLO ", "HELO ")): + self.send_line(b"250-boundary.local") + self.send_line(b"250 SIZE 1048576") + elif upper.startswith("MAIL FROM:"): + self.send_line(b"250 2.1.0 sender ok") + elif upper.startswith("RCPT TO:"): + recipient = command.split(":", 1)[1].strip("<>") + + if "reject@" in recipient: + with STATE.lock: + STATE.smtp_rejections += 1 + self.send_line(b"550 5.1.1 recipient rejected") + else: + recipients.append(recipient) + self.send_line(b"250 2.1.5 recipient ok") + elif upper == "DATA": + self.send_line(b"354 end with .") + chunks: list[bytes] = [] + + while True: + line = self.rfile.readline(65_536) + if not line or line == b".\r\n": + break + chunks.append(line) + + digest = hashlib.sha256(b"".join(chunks)).hexdigest() + + with STATE.lock: + STATE.smtp_messages += 1 + STATE.smtp_digests.append(digest) + + self.send_line(b"250 2.0.0 queued-boundary") + elif upper == "RSET": + recipients = [] + self.send_line(b"250 2.0.0 reset") + elif upper == "QUIT": + self.send_line(b"221 2.0.0 bye") + return + else: + self.send_line(b"500 5.5.2 unsupported command") + + +def serve() -> None: + http_server = BoundaryHTTPServer(("0.0.0.0", HTTP_PORT), BoundaryHTTPHandler) + smtp_servers = [ + BoundarySMTPServer(("0.0.0.0", SMTP_PORT), "normal"), + BoundarySMTPServer(("0.0.0.0", SMTP_RETRY_PORT), "retry"), + BoundarySMTPServer(("0.0.0.0", SMTP_TIMEOUT_PORT), "timeout"), + ] + servers = [http_server, *smtp_servers] + threads = [ + threading.Thread(target=server.serve_forever, daemon=True) for server in servers + ] + + for thread in threads: + thread.start() + + stopped = threading.Event() + + def stop(_signum: int, _frame: Any) -> None: + stopped.set() + + signal.signal(signal.SIGTERM, stop) + signal.signal(signal.SIGINT, stop) + stopped.wait() + + for server in servers: + server.shutdown() + server.server_close() + + +if __name__ == "__main__": + serve() diff --git a/scripts/external-boundaries-run.sh b/scripts/external-boundaries-run.sh new file mode 100755 index 0000000..dedf45f --- /dev/null +++ b/scripts/external-boundaries-run.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash +set -euo pipefail +umask 077 + +ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +LABEL=${1:-"external-boundaries-$(date -u +%Y%m%d%H%M%S)"} + +if [[ ! "$LABEL" =~ ^[a-z0-9][a-z0-9-]{0,39}$ ]]; then + echo "Run label must be 1-40 lowercase letters, numbers, or dashes." >&2 + exit 1 +fi + +for command in chmod docker grep id jq openssl stat tr unlink; do + if ! command -v "$command" >/dev/null 2>&1; then + echo "Required command is unavailable: $command" >&2 + exit 1 + fi +done + +run_id="${LABEL}-$$" +project="wnh_boundaries_$(printf '%s' "$run_id" | tr '-' '_')" +runtime_dir="$ROOT/tmp/external-boundaries/$run_id" +output_dir="$ROOT/output/external-boundaries/$LABEL" +env_file="$runtime_dir/.env" + +if [[ -e "$output_dir" ]]; then + echo "Refusing to replace existing evidence directory: $output_dir" >&2 + exit 1 +fi + +mkdir -p "$runtime_dir" "$output_dir" +chmod 700 "$ROOT/tmp" "$ROOT/tmp/external-boundaries" "$runtime_dir" \ + "$ROOT/output" "$ROOT/output/external-boundaries" "$output_dir" + +oauth_client_id="local-$(openssl rand -hex 12)" +oauth_client_secret=$(openssl rand -hex 32) +push_bearer_token=$(openssl rand -hex 32) +database_password=$(openssl rand -hex 24) +secret_key_base=$(openssl rand -hex 64) +handover_secret=$(openssl rand -hex 32) + +external_boundary_app_image="who-need-help:boundary-app-$run_id" +external_boundary_mock_image="who-need-help:boundary-mock-$run_id" + +{ + printf 'EXTERNAL_BOUNDARY_APP_IMAGE=%s\n' "$external_boundary_app_image" + printf 'EXTERNAL_BOUNDARY_MOCK_IMAGE=%s\n' "$external_boundary_mock_image" + printf 'EXTERNAL_BOUNDARY_OUTPUT_DIR=%s\n' "$output_dir" + printf 'EXTERNAL_BOUNDARY_HOST_UID=%s\n' "$(id -u)" + printf 'EXTERNAL_BOUNDARY_HOST_GID=%s\n' "$(id -g)" + printf 'EXTERNAL_OAUTH_CLIENT_ID=%s\n' "$oauth_client_id" + printf 'EXTERNAL_OAUTH_CLIENT_SECRET=%s\n' "$oauth_client_secret" + printf 'EXTERNAL_PUSH_BEARER_TOKEN=%s\n' "$push_bearer_token" + printf 'EXTERNAL_DATABASE_URL=ecto://boundary:%s@unused/unused\n' "$database_password" + printf 'EXTERNAL_SECRET_KEY_BASE=%s\n' "$secret_key_base" + printf 'EXTERNAL_HANDOVER_SECRET=%s\n' "$handover_secret" +} >"$env_file" +chmod 600 "$env_file" + +compose=( + docker compose + --env-file "$env_file" + -p "$project" + -f "$ROOT/compose.external-boundaries.yaml" +) + +assert_project_scope() { + local container_id=$1 + local observed_project + + observed_project=$( + docker inspect --format '{{index .Config.Labels "com.docker.compose.project"}}' \ + "$container_id" + ) + + if [[ "$observed_project" != "$project" ]]; then + echo "Container scope mismatch for $container_id." >&2 + exit 1 + fi +} + +cleanup() { + local status=$? + trap - EXIT HUP INT TERM + + if ((status != 0)); then + "${compose[@]}" logs --no-color >"$output_dir/compose.log" 2>&1 || true + fi + + while IFS= read -r container_id; do + [[ -n "$container_id" ]] && assert_project_scope "$container_id" + done < <("${compose[@]}" ps --all --quiet 2>/dev/null || true) + + "${compose[@]}" down --volumes >/dev/null 2>&1 || true + docker image rm "$external_boundary_app_image" "$external_boundary_mock_image" \ + >/dev/null 2>&1 || true + + if [[ -f "$env_file" ]]; then + unlink "$env_file" + fi + + rmdir "$runtime_dir" >/dev/null 2>&1 || true + rmdir "$ROOT/tmp/external-boundaries" >/dev/null 2>&1 || true + + exit "$status" +} + +trap cleanup EXIT HUP INT TERM + +"${compose[@]}" config --quiet +"${compose[@]}" build external-mock boundary-check >"$output_dir/build.log" +"${compose[@]}" up --detach --wait external-mock >"$output_dir/mock-up.log" +"${compose[@]}" run --rm --no-deps -T boundary-check \ + >"$output_dir/drill.log" 2>&1 + +"${compose[@]}" exec -T external-mock \ + python -c \ + 'import json, urllib.request; print(json.dumps(json.load(urllib.request.urlopen("http://127.0.0.1:8080/state")), sort_keys=True))' \ + >"$output_dir/final-mock-state.json" + +jq -e ' + .status == "passed" and + .oauth.success == "passed" and + .oauth.state_mismatch_blocked_before_token == true and + .oauth.provider_rejection_blocked_before_token == true and + .oauth.one_time_code_replay_rejected == true and + .oauth.fresh_flow_retry_after_temporary_failure == "passed" and + .oauth.timeout_failed_closed == true and + .oauth.access_token_returned_to_application == false and + .smtp.success == "passed" and + .smtp.permanent_rejection_not_retried == true and + .smtp.temporary_greeting_retried_once == true and + .smtp.timeout_failed_closed == true and + .smtp.repeated_submission_count == 2 and + .smtp.exactly_once_delivery_claimed == false and + .push.default_adapter_disabled == true and + .push.success == "passed" and + .push.permanent_rejection_not_retried == true and + .push.temporary_failure_retried_once == true and + .push.replay_deduplicated == true and + .push.timeout_after_accept_deduplicated == true and + .push.domain_workflow_integration == "not_implemented" and + .push_product_integration == "not_implemented" +' "$output_dir/summary.json" >/dev/null + +for secret in "$oauth_client_secret" "$push_bearer_token" "$database_password" \ + "$secret_key_base" "$handover_secret"; do + if grep -R -F -q -- "$secret" "$output_dir"; then + echo "Generated secret appeared in retained boundary evidence." >&2 + exit 1 + fi +done + +test "$(stat -c '%a' "$output_dir/summary.json")" = "600" + +unset oauth_client_id oauth_client_secret push_bearer_token database_password \ + secret_key_base handover_secret + +printf 'External boundary evidence: %s\n' "$output_dir" diff --git a/scripts/quality.sh b/scripts/quality.sh index ca7b500..eeacb0a 100755 --- a/scripts/quality.sh +++ b/scripts/quality.sh @@ -21,6 +21,7 @@ release_image="who-need-help:security-$run_id" backup_image="who-need-help:backup-audit-$run_id" minio_image="who-need-help:minio-audit-$run_id" mc_image="who-need-help:mc-audit-$run_id" +boundary_mock_image="who-need-help:boundary-mock-audit-$run_id" scan_dir=$(mktemp -d "${TMPDIR:-/tmp}/wnh-quality-scan.XXXXXX") scan_list="${scan_dir}.files" scan_tar="${scan_dir}.tar" @@ -36,6 +37,7 @@ cleanup() { $compose down --volumes --remove-orphans >/dev/null 2>&1 || true docker image rm "$quality_image" "$assets_image" "$e2e_image" "$release_image" \ "$backup_image" "$minio_image" "$mc_image" \ + "$boundary_mock_image" \ >/dev/null 2>&1 || true rm -rf "$scan_dir" "$scan_list" "$scan_tar" } @@ -54,7 +56,7 @@ docker run --rm \ echo "Checking Dockerfiles with Hadolint 2.14.0" for dockerfile in Dockerfile Dockerfile.backup Dockerfile.minio \ - android/Dockerfile e2e/Dockerfile; do + android/Dockerfile e2e/Dockerfile ops/external-boundaries/Dockerfile; do docker run --rm --interactive "$HADOLINT_IMAGE" \ hadolint --failure-threshold warning - <"$dockerfile" done @@ -86,6 +88,19 @@ BACKUP_HOST_GID="$(id -g)" \ -f compose.yaml -f compose.load.yaml -f compose.backup.yaml \ --profile backup config --quiet docker compose -p "$project" -f compose.quality.yaml config --quiet +mkdir -p "$scan_dir/external-boundary-output" +EXTERNAL_BOUNDARY_APP_IMAGE=who-need-help:boundary-render \ +EXTERNAL_BOUNDARY_MOCK_IMAGE=who-need-help:boundary-mock-render \ +EXTERNAL_BOUNDARY_OUTPUT_DIR="$scan_dir/external-boundary-output" \ +EXTERNAL_BOUNDARY_HOST_UID="$(id -u)" \ +EXTERNAL_BOUNDARY_HOST_GID="$(id -g)" \ +EXTERNAL_OAUTH_CLIENT_ID=render-client \ +EXTERNAL_OAUTH_CLIENT_SECRET=render-secret \ +EXTERNAL_PUSH_BEARER_TOKEN=render-push-token \ +EXTERNAL_DATABASE_URL=ecto://render:render@unused/unused \ +EXTERNAL_SECRET_KEY_BASE=render-secret-key-base \ +EXTERNAL_HANDOVER_SECRET=render-handover-secret \ + docker compose -f compose.external-boundaries.yaml config --quiet echo "Validating local observability configuration" sed \ @@ -112,6 +127,10 @@ docker run --rm \ --volume "$ROOT/scripts/alert-receiver.py:/src/alert-receiver.py:ro" \ "$PYTHON_IMAGE" python -c \ 'import py_compile; py_compile.compile("/src/alert-receiver.py", cfile="/tmp/alert-receiver.pyc", doraise=True)' +docker run --rm \ + --volume "$ROOT/ops/external-boundaries/mock_server.py:/src/mock_server.py:ro" \ + "$PYTHON_IMAGE" python -c \ + 'import py_compile; py_compile.compile("/src/mock_server.py", cfile="/tmp/mock_server.pyc", doraise=True)' jq --exit-status \ 'type == "object" and .uid == "wnh-overview" and (.panels | length) == 4' \ ops/observability/grafana/dashboards/who-need-help-overview.json \ @@ -243,6 +262,23 @@ for image in "$minio_image" "$mc_image"; do "$image" done +echo "Building and scanning the pinned non-root external-boundary mock image" +docker build \ + --tag "$boundary_mock_image" \ + --file ops/external-boundaries/Dockerfile \ + ops/external-boundaries +test "$(docker image inspect --format '{{.Config.User}}' "$boundary_mock_image")" = \ + "10001:10001" +docker run --rm \ + --volume /var/run/docker.sock:/var/run/docker.sock \ + --volume "$ROOT/.tools/trivy-cache:/root/.cache/trivy" \ + "$TRIVY_IMAGE" image \ + --scanners vuln \ + --severity HIGH,CRITICAL \ + --ignore-unfixed \ + --exit-code 1 \ + "$boundary_mock_image" + echo "Building and scanning the production release image" docker build --target release --tag "$release_image" . docker run --rm \ diff --git a/test/who_need_help/push_test.exs b/test/who_need_help/push_test.exs new file mode 100644 index 0000000..32643b3 --- /dev/null +++ b/test/who_need_help/push_test.exs @@ -0,0 +1,75 @@ +defmodule WhoNeedHelp.PushTest do + use ExUnit.Case, async: false + + alias WhoNeedHelp.Push + alias WhoNeedHelp.Push.HTTPAdapter + + defmodule RecordingAdapter do + @behaviour Push + + @impl true + def deliver(notification, opts) do + send(Keyword.fetch!(opts, :test_pid), {:delivered, notification, opts}) + {:ok, %{id: "recorded", duplicate: false}} + end + end + + setup do + previous = Application.get_env(:who_need_help, :push_adapter) + Application.delete_env(:who_need_help, :push_adapter) + + on_exit(fn -> + case previous do + nil -> Application.delete_env(:who_need_help, :push_adapter) + adapter -> Application.put_env(:who_need_help, :push_adapter, adapter) + end + end) + end + + test "the unconfigured product boundary is disabled" do + assert Push.deliver(notification()) == {:error, :disabled} + end + + test "invalid notifications fail before invoking an adapter" do + assert Push.deliver( + Map.delete(notification(), :idempotency_key), + adapter: RecordingAdapter, + test_pid: self() + ) == {:error, {:invalid_notification, :idempotency_key}} + + refute_received {:delivered, _notification, _opts} + end + + test "a configured adapter receives validated data without the adapter option" do + assert Push.deliver( + notification(), + adapter: RecordingAdapter, + test_pid: self(), + boundary: :local + ) == {:ok, %{id: "recorded", duplicate: false}} + + assert_received {:delivered, delivered, opts} + assert delivered == notification() + assert opts[:boundary] == :local + refute Keyword.has_key?(opts, :adapter) + end + + test "the HTTP adapter rejects incomplete transport configuration without network access" do + assert Push.deliver( + notification(), + adapter: HTTPAdapter, + endpoint: "http://127.0.0.1:1/push", + bearer_token: "local-token" + ) == {:error, {:invalid_option, :receive_timeout}} + end + + defp notification do + %{ + idempotency_key: "event-1", + recipient: "future-device-token", + title: "Help update", + body: "A neighbor replied", + data: %{"kind" => "message"} + } + end +end