feat: verify local external service boundaries
This commit is contained in:
parent
1b60685454
commit
0ad71f67c3
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
11
.github/workflows/quality.yml
vendored
11
.github/workflows/quality.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
37
README.md
37
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
|
||||
|
||||
|
|
|
|||
87
compose.external-boundaries.yaml
Normal file
87
compose.external-boundaries.yaml
Normal 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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
433
lib/who_need_help/external_boundary_drill.ex
Normal file
433
lib/who_need_help/external_boundary_drill.ex
Normal 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
66
lib/who_need_help/push.ex
Normal 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
|
||||
8
lib/who_need_help/push/disabled_adapter.ex
Normal file
8
lib/who_need_help/push/disabled_adapter.ex
Normal 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
|
||||
99
lib/who_need_help/push/http_adapter.ex
Normal file
99
lib/who_need_help/push/http_adapter.ex
Normal 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
|
||||
9
ops/external-boundaries/Dockerfile
Normal file
9
ops/external-boundaries/Dockerfile
Normal 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"]
|
||||
478
ops/external-boundaries/mock_server.py
Normal file
478
ops/external-boundaries/mock_server.py
Normal 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()
|
||||
159
scripts/external-boundaries-run.sh
Executable file
159
scripts/external-boundaries-run.sh
Executable 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"
|
||||
|
|
@ -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 \
|
||||
|
|
|
|||
75
test/who_need_help/push_test.exs
Normal file
75
test/who_need_help/push_test.exs
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user