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={}
|
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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
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
|
- 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:
|
||||||
|
|
|
||||||
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
|
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
|
||||||
|
|
||||||
|
|
|
||||||
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}
|
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:
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
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"
|
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 \
|
||||||
|
|
|
||||||
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