feat: verify local external service boundaries

This commit is contained in:
SimpleTest 2026-07-19 08:33:24 +03:00
parent 1b60685454
commit 0ad71f67c3
21 changed files with 1666 additions and 26 deletions

View File

@ -40,6 +40,12 @@ CODEX_SESSION_ID=local-e2e
RATE_LIMIT_POLICIES_JSON={} RATE_LIMIT_POLICIES_JSON={}
GITHUB_OAUTH_CLIENT_ID= GITHUB_OAUTH_CLIENT_ID=
GITHUB_OAUTH_CLIENT_SECRET= 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_BASE_URL=http://proxy
E2E_MAILPIT_URL=http://mailpit:8025 E2E_MAILPIT_URL=http://mailpit:8025

View File

@ -35,6 +35,14 @@ MAP_TILE_URL=https://tile.openstreetmap.org/{z}/{x}/{y}.png
# https://YOUR_PHX_HOST/auth/social/github/callback # https://YOUR_PHX_HOST/auth/social/github/callback
GITHUB_OAUTH_CLIENT_ID= GITHUB_OAUTH_CLIENT_ID=
GITHUB_OAUTH_CLIENT_SECRET= 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_DB=who_need_help
POSTGRES_USER=postgres POSTGRES_USER=postgres

View File

@ -41,6 +41,12 @@ RATE_LIMIT_POLICIES_JSON={}
MAP_TILE_URL=https://tile.openstreetmap.org/{z}/{x}/{y}.png 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_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, # These are reproducible experiment inputs, not capacity requirements,
# production traffic forecasts, alert thresholds, or recommended limits. # production traffic forecasts, alert thresholds, or recommended limits.

View File

@ -35,6 +35,17 @@ jobs:
- name: Run isolated browser end-to-end tests - name: Run isolated browser end-to-end tests
run: ./scripts/e2e-run.sh 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: android-build:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:

View File

@ -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 failure paths, removes those temporary buckets, and retains the successful
encrypted bucket in local MinIO. It never writes a plaintext dump to the host. 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 The commands, boundaries, and unclaimed production properties are documented
in [the operations runbook](docs/operations.md). 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 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 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. 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 ## 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 profile, the rendered Helm chart, tracked-source secrets and infrastructure
misconfigurations, Elixir formatting/compilation/xref/Credo/Sobelow/Dialyzer, misconfigurations, Elixir formatting/compilation/xref/Credo/Sobelow/Dialyzer,
retired Hex packages, locked npm dependencies, all Phoenix tests, the pinned retired Hex packages, locked npm dependencies, all Phoenix tests, the pinned
backup-tool, MinIO server/client, and production release images. Its generated backup-tool, MinIO server/client, external-boundary mock, and production
database credentials are random and exist only for that run. The exact database release images. Its generated database credentials are random and exist only
volume, networks, temporary source snapshot, and one-run images are removed for that run. The exact database volume, networks, temporary source snapshot,
automatically. 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 The cursor-pagination database benchmark also creates a one-run Compose
project, random database credentials, and a separate PostgreSQL volume: 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 pre-created Secret through required `existingSecret`; the chart never renders
credentials from tracked values. The Secret must contain `DATABASE_URL`, credentials from tracked values. The Secret must contain `DATABASE_URL`,
`SECRET_KEY_BASE`, `HANDOVER_SECRET`, `RELEASE_COOKIE`, and `METRICS_TOKEN`, and `SECRET_KEY_BASE`, `HANDOVER_SECRET`, `RELEASE_COOKIE`, and `METRICS_TOKEN`, and
may additionally contain both GitHub OAuth variables described above. The chart may additionally contain the GitHub OAuth client ID and client secret described
intentionally has no invented CPU/RAM limits or HPA thresholds; measure this above. The chart intentionally has no invented CPU/RAM limits or HPA
application in the target environment before setting them. thresholds; measure this application in the target environment before setting
them.
## Local Codex category review ## Local Codex category review

View File

@ -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

View File

@ -27,6 +27,12 @@ x-app-environment: &app-environment
MAP_TILE_URL: ${MAP_TILE_URL:-https://tile.openstreetmap.org/{z}/{x}/{y}.png} 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_ID: ${GITHUB_OAUTH_CLIENT_ID:-}
GITHUB_OAUTH_CLIENT_SECRET: ${GITHUB_OAUTH_CLIENT_SECRET:-} 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: services:
proxy: proxy:

View File

@ -23,6 +23,54 @@ config :who_need_help,
json -> Jason.decode!(json) json -> Jason.decode!(json)
end) 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 = github_oauth =
case { case {
System.get_env("GITHUB_OAUTH_CLIENT_ID"), System.get_env("GITHUB_OAUTH_CLIENT_ID"),
@ -34,7 +82,20 @@ github_oauth =
%{ %{
github: [ github: [
client_id: client_id, 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}
] ]
} }

View File

@ -41,7 +41,7 @@ package checksums are in `mix.lock` and `assets/package-lock.json`.
| Prometheus | 3.13.1 | | Prometheus | 3.13.1 |
| Alertmanager | 0.33.1 | | Alertmanager | 0.33.1 |
| Grafana | 13.1.0 | | 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 | | 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 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 | | 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/android-instrumentation-test.sh
./scripts/observability-run.sh local-observability ./scripts/observability-run.sh local-observability
./scripts/backup-s3-drill.sh local-encrypted-backup ./scripts/backup-s3-drill.sh local-encrypted-backup
./scripts/external-boundaries-run.sh local-boundaries
``` ```

View File

@ -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 | | 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 | | 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 | | 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 | | 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 | | 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 | | 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 | | 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 | | 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 | 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 | | 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. 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, - The containerized `scripts/quality.sh` gate passes ShellCheck, Hadolint,
actionlint, all Compose renders, Helm lint, a Trivy scan of tracked source and actionlint, all Compose renders, Helm lint, a Trivy scan of tracked source and
the rendered Kubernetes manifest, compiler/xref/Credo/Sobelow/Dialyzer/Hex the rendered Kubernetes manifest, compiler/xref/Credo/Sobelow/Dialyzer/Hex
checks, 163 Phoenix tests, both npm audits, and a Trivy scan of the production checks, 167 Phoenix tests, both npm audits, and Trivy scans of the backup,
release image. It creates random one-run database credentials and removes its MinIO server/client, external-mock, and production release images. It creates
exact volume, networks, images, and source snapshot. 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 - 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 gate, browser E2E suite, and Android unit/lint/APK build. The workflow itself
passes actionlint and uses a commit-pinned checkout action without persisted 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, after an encrypted object reached MinIO, published no interrupted snapshot,
pruned the unreferenced packs, removed both temporary buckets and the pruned the unreferenced packs, removed both temporary buckets and the
temporary restore database, and left source table counts unchanged. temporary restore database, and left source table counts unchanged.
- The remaining rows above are still pending; this document is not a - The isolated external-service drill uses the release's configured Assent/Req
completion claim for the entire hardening goal. 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.

View File

@ -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, workstation is not an off-site backup and does not establish production RPO,
RTO, retention, capacity, key custody, object locking, or database HA. 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 ## Isolated restore drill
Run a real restore into a uniquely named temporary database: Run a real restore into a uniquely named temporary database:

View File

@ -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. | | 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. | | 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. |
| 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. | | 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. | | 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. | | 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. | | 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. | | 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 ## 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 tests and 0 failures after cursor pagination, database aggregation, and the
full localization changes full localization changes
on Elixir 1.20.2 and Erlang/OTP 29.0.3. on Elixir 1.20.2 and Erlang/OTP 29.0.3.
- `mix compile --force --warnings-as-errors` and - `mix compile --force --warnings-as-errors` and
`mix format --check-formatted`: passed against the same final source. `mix format --check-formatted`: passed against the same final source.
- `./scripts/quality.sh` passed ShellCheck 0.11.0, Hadolint 2.14.0 at warning - `./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 source/rendered-manifest scanning, xref, Credo high-priority checks, Sobelow
strict/private checks, Hex audit, 163 Phoenix tests, both npm audits, and the strict/private checks, Hex audit, 167 Phoenix tests, both npm audits, and the
backup/MinIO/mc/release-image vulnerability scans. The rendered Helm manifest backup/MinIO/mc/external-mock/release-image vulnerability scans. The rendered
reported zero HIGH/CRITICAL misconfigurations; the Alpine backup, MinIO, and Helm manifest reported zero HIGH/CRITICAL misconfigurations; the Alpine
mc images and the Debian 13.6 release image each reported zero HIGH/CRITICAL backup, MinIO, mc, and external-mock images and the Debian 13.6 release image
vulnerabilities under the configured gates. The backup binary reported 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 Restic 0.19.1 compiled with Go 1.26.5; MinIO and mc reported their pinned
commits and Go 1.26.5. commits and Go 1.26.5.
- Dialyzer passed with three path- and warning-specific documented filters and - 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 empty. The obsolete successful-run bucket was then removed; only the final
canonical bucket remains. Evidence is retained at canonical bucket remains. Evidence is retained at
`output/backups-s3/backup-canonical-20260719d`. `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 - 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 specs against a fresh PostGIS volume with two web and two worker replicas on
2026-07-19. The retained successful-run artifact directory is 2026-07-19. The retained successful-run artifact directory is

View File

@ -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

66
lib/who_need_help/push.ex Normal file
View File

@ -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

View File

@ -0,0 +1,8 @@
defmodule WhoNeedHelp.Push.DisabledAdapter do
@moduledoc false
@behaviour WhoNeedHelp.Push
@impl true
def deliver(_notification, _opts), do: {:error, :disabled}
end

View File

@ -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

View File

@ -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"]

View File

@ -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 <CRLF>.<CRLF>")
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()

View File

@ -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"

View File

@ -21,6 +21,7 @@ release_image="who-need-help:security-$run_id"
backup_image="who-need-help:backup-audit-$run_id" backup_image="who-need-help:backup-audit-$run_id"
minio_image="who-need-help:minio-audit-$run_id" minio_image="who-need-help:minio-audit-$run_id"
mc_image="who-need-help:mc-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_dir=$(mktemp -d "${TMPDIR:-/tmp}/wnh-quality-scan.XXXXXX")
scan_list="${scan_dir}.files" scan_list="${scan_dir}.files"
scan_tar="${scan_dir}.tar" scan_tar="${scan_dir}.tar"
@ -36,6 +37,7 @@ cleanup() {
$compose down --volumes --remove-orphans >/dev/null 2>&1 || true $compose down --volumes --remove-orphans >/dev/null 2>&1 || true
docker image rm "$quality_image" "$assets_image" "$e2e_image" "$release_image" \ docker image rm "$quality_image" "$assets_image" "$e2e_image" "$release_image" \
"$backup_image" "$minio_image" "$mc_image" \ "$backup_image" "$minio_image" "$mc_image" \
"$boundary_mock_image" \
>/dev/null 2>&1 || true >/dev/null 2>&1 || true
rm -rf "$scan_dir" "$scan_list" "$scan_tar" rm -rf "$scan_dir" "$scan_list" "$scan_tar"
} }
@ -54,7 +56,7 @@ docker run --rm \
echo "Checking Dockerfiles with Hadolint 2.14.0" echo "Checking Dockerfiles with Hadolint 2.14.0"
for dockerfile in Dockerfile Dockerfile.backup Dockerfile.minio \ 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" \ docker run --rm --interactive "$HADOLINT_IMAGE" \
hadolint --failure-threshold warning - <"$dockerfile" hadolint --failure-threshold warning - <"$dockerfile"
done done
@ -86,6 +88,19 @@ BACKUP_HOST_GID="$(id -g)" \
-f compose.yaml -f compose.load.yaml -f compose.backup.yaml \ -f compose.yaml -f compose.load.yaml -f compose.backup.yaml \
--profile backup config --quiet --profile backup config --quiet
docker compose -p "$project" -f compose.quality.yaml 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" echo "Validating local observability configuration"
sed \ sed \
@ -112,6 +127,10 @@ docker run --rm \
--volume "$ROOT/scripts/alert-receiver.py:/src/alert-receiver.py:ro" \ --volume "$ROOT/scripts/alert-receiver.py:/src/alert-receiver.py:ro" \
"$PYTHON_IMAGE" python -c \ "$PYTHON_IMAGE" python -c \
'import py_compile; py_compile.compile("/src/alert-receiver.py", cfile="/tmp/alert-receiver.pyc", doraise=True)' '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 \ jq --exit-status \
'type == "object" and .uid == "wnh-overview" and (.panels | length) == 4' \ 'type == "object" and .uid == "wnh-overview" and (.panels | length) == 4' \
ops/observability/grafana/dashboards/who-need-help-overview.json \ ops/observability/grafana/dashboards/who-need-help-overview.json \
@ -243,6 +262,23 @@ for image in "$minio_image" "$mc_image"; do
"$image" "$image"
done 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" echo "Building and scanning the production release image"
docker build --target release --tag "$release_image" . docker build --target release --tag "$release_image" .
docker run --rm \ docker run --rm \

View File

@ -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