Compare commits
6
Commits
5b78ee0ffb
...
8ea4d4d2e4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ea4d4d2e4 | ||
|
|
55accd0e65 | ||
|
|
a56a853319 | ||
|
|
b642d8fe30 | ||
|
|
278e66049f | ||
|
|
c737ebb472 |
@@ -124,17 +124,19 @@ jobs:
|
||||
# The platform-only image (ADR 0044): a checkout has no `modules/`, so this proves the image
|
||||
# builds, validates, and assembles with zero modules. Build only — no registry login, no
|
||||
# credentials, no push, no tag anywhere but this runner's local image store.
|
||||
# Tagged per run and attempt, not only per commit: a pull request and a push of the same commit
|
||||
# can build at once on one runner's Docker daemon, and neither may remove the other's image.
|
||||
- name: Build the production image without modules
|
||||
run: >-
|
||||
docker build
|
||||
--file deploy/Dockerfile
|
||||
--build-arg REVISION="$GITHUB_SHA"
|
||||
--tag ekklesia-ci:"$GITHUB_SHA"
|
||||
--tag ekklesia-ci:"$GITHUB_SHA-${GITHUB_RUN_ID:-0}-${GITHUB_RUN_ATTEMPT:-1}"
|
||||
.
|
||||
|
||||
- name: Remove the image from the runner
|
||||
if: always()
|
||||
run: docker image rm --force ekklesia-ci:"$GITHUB_SHA" || true
|
||||
run: docker image rm --force ekklesia-ci:"$GITHUB_SHA-${GITHUB_RUN_ID:-0}-${GITHUB_RUN_ATTEMPT:-1}" || true
|
||||
|
||||
deploy-smoke:
|
||||
needs: verify
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
# 0045: The Dashboard and the API may live on separate origins
|
||||
|
||||
- Status: Proposed
|
||||
- Date: 2026-10-03
|
||||
- Amends: [0034](0034-host-owned-browser-authentication.md)
|
||||
- Extends: [0044](0044-self-hosted-reference-deployment.md)
|
||||
- Related: [ADR 0011](0011-identity-http-transport-and-public-enrollment.md),
|
||||
[ADR 0012](0012-identity-origin-csrf-protection-under-test.md),
|
||||
[ADR 0018](0018-identity-session-resolution-and-database-transport.md),
|
||||
[ADR 0025](0025-trusted-module-ui-asset-publication.md),
|
||||
[ADR 0032](0032-native-module-realtime-transport.md),
|
||||
[ADR 0033](0033-display-kiosk-host-contract.md),
|
||||
[ADR 0036](0036-managed-device-identity-and-pairing.md),
|
||||
[ADR 0038](0038-email-verification-and-password-recovery.md),
|
||||
[ADR 0043](0043-display-offline-code-fallback.md)
|
||||
|
||||
## Context
|
||||
|
||||
ADR 0044's reference deployment serves everything a browser needs from one origin: the Dashboard at
|
||||
`/`, the API at `/api/*`, and the display host at `/display/*`. ADR 0034 kept a deliberately split
|
||||
browser/API origin possible through the exact trusted-origin allowlist and credentialed CORS of
|
||||
ADR 0011, ADR 0012, and ADR 0018, but left that topology undesigned: "any eventual choice to place
|
||||
the API and Dashboard on different sibling subdomains must be designed and tested rather than
|
||||
assumed to share a session."
|
||||
|
||||
The first production installation needs exactly that: the Dashboard and the API on sibling
|
||||
hostnames (for example `dash.example.org` and `api.example.org`), plus the existing storage
|
||||
hostname. The choice has to be made before the first deployment, because a display's bookmark and a
|
||||
paired device's cookies are bound to a hostname: moving them later re-pairs every screen.
|
||||
|
||||
Most of the platform was already written for this. The shell sends every platform and module request
|
||||
with `credentials: "include"` and opens realtime with `withCredentials: true`; CORS on `/api/auth/*`
|
||||
and `/api/v1/*` reflects only exact trusted origins with credentials; module artifacts are anonymous
|
||||
by design so that cross-origin `import()` works (ADR 0025); emailed links already pass through the
|
||||
API and land on `EKKLESIA_DASHBOARD_ORIGIN` (ADR 0038); and display page URLs are resolved against the
|
||||
API's origin. Four things were not:
|
||||
|
||||
- the Dashboard learns its API origin at **build** time (`EKKLESIA_API_BASE_URL`), so one image
|
||||
cannot serve both topologies;
|
||||
- the API's own set of trusted browser origins is exactly `EKKLESIA_IDENTITY_TRUSTED_ORIGINS`, while
|
||||
Better Auth implicitly adds the identity base URL to its own: a page served by the API itself would
|
||||
fail the API's origin check unless an operator listed the API's own origin too;
|
||||
- the reference proxy has no routing for two browser-facing hostnames;
|
||||
- nothing proves a split deployment end to end, and a Node.js client cannot prove what matters most
|
||||
here: what a real browser sends across the two origins.
|
||||
|
||||
## Decision
|
||||
|
||||
### Two topologies, one image
|
||||
|
||||
An installation runs in exactly one of two shapes, chosen by configuration alone:
|
||||
|
||||
| Topology | Dashboard | API and display host | Storage |
|
||||
| --- | --- | --- | --- |
|
||||
| **Same-origin** (default) | `https://ekklesia.example.org/` | `https://ekklesia.example.org/api/*`, `/display/*` | its own hostname |
|
||||
| **Split** | `https://dash.example.org/` | `https://api.example.org/api/*`, `/display/*` | its own hostname |
|
||||
|
||||
Same-origin remains the default and keeps working unchanged for every installation that does not
|
||||
opt in. The same image and the same built Dashboard serve both.
|
||||
|
||||
In both, the API process serves the Dashboard's bytes (ADR 0044 is unchanged in that). In the split
|
||||
topology the proxy forwards the Dashboard hostname's two Dashboard paths to the same `api` service;
|
||||
the API does not inspect `Host`, and which hostname serves which path is the proxy's job, as every
|
||||
other perimeter rule already is.
|
||||
|
||||
### The Dashboard learns its API origin at run time
|
||||
|
||||
The API writes its own origin into the Dashboard page it serves, as one meta element:
|
||||
|
||||
```html
|
||||
<meta name="ekklesia-api-origin" content="https://api.example.org" />
|
||||
```
|
||||
|
||||
The value is the identity base URL's origin (`EKKLESIA_IDENTITY_BASE_URL`), which is by definition
|
||||
where `/api` lives; no new setting is introduced. The built page carries the element with an empty
|
||||
value; the API fills it once at startup, before computing the page's strong `ETag`, and refuses to
|
||||
start if the built page does not contain exactly one such element. The shell reads it before
|
||||
anything renders, accepts only an absolute `http(s)` origin with no path, and otherwise fails closed
|
||||
with its existing sanitized bootstrap failure. An empty value (a static development server with no
|
||||
API) means the page's own origin.
|
||||
|
||||
A meta element rather than an inline script keeps the page free of inline script, so a future strict
|
||||
`script-src` needs no exception. The build-time `EKKLESIA_API_BASE_URL` is removed; the development
|
||||
server injects the same element instead. The display host needs neither: it is always served from
|
||||
the API's origin (below) and uses its own.
|
||||
|
||||
### The display host lives on the API's origin
|
||||
|
||||
In the split topology the display host stays where it is: `https://api.example.org/display/<module>`,
|
||||
with its script under `/assets/ekklesia-display/` and its service worker at `/display/sw.js`. A
|
||||
screen's bookmark names the API hostname.
|
||||
|
||||
- **Device cookies are already bound to the API's hostname.** They are host-only, `HttpOnly`, and
|
||||
`SameSite=Strict` (ADR 0036), and they must accompany device requests to the API, so they live on
|
||||
whichever hostname answers those requests. Serving the display page from that same origin makes it
|
||||
the only hostname a screen depends on; serving it anywhere else adds a second one.
|
||||
- **Offline code fallback is unchanged.** The display's service worker never caches a cross-origin
|
||||
response (ADR 0043), which is what keeps signed storage URLs, bearer capabilities, out of the
|
||||
cache. On the API's origin, every artifact it must cache is same-origin. On any other origin, every
|
||||
artifact would be cross-origin, and the offline guarantee would need a weaker rule.
|
||||
- **No device request crosses an origin.** Pairing, heartbeats, descriptors, module routes, and
|
||||
realtime from an unattended screen stay same-origin, with no CORS dependence.
|
||||
|
||||
The Dashboard keeps linking to display pages exactly as now: their URLs are resolved against the API
|
||||
origin.
|
||||
|
||||
### The API trusts its own origin
|
||||
|
||||
The API's set of trusted browser origins, used for CORS on `/api/auth/*` and `/api/v1/*` and by every
|
||||
mutation route's origin check, becomes `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` **plus the identity base
|
||||
URL's origin**, matching what Better Auth already does for its own checks. In the split topology the
|
||||
display page on the API's origin therefore passes the API's origin check without an operator having
|
||||
to list it. Trusted origins remain exact: no wildcard, no pattern, and no parent domain.
|
||||
|
||||
### Cookies and identity: unchanged, with one stated requirement
|
||||
|
||||
The session cookie keeps every property ADR 0034 gave it: `HttpOnly`, `SameSite=Lax`, `Secure` under
|
||||
HTTPS, `Path=/`, and **host-only on the API's hostname**. No `Domain` attribute is set, and the
|
||||
Dashboard's hostname never holds a session credential. The Dashboard reaches the API with credentialed
|
||||
CORS, and browsers send the cookie because the two hostnames are different origins of the **same
|
||||
site**.
|
||||
|
||||
That is a requirement of the split topology: the Dashboard and API hostnames must share a registrable
|
||||
domain (`dash.example.org` and `api.example.org` do; `dash.example.org` and `api.example.net` do not).
|
||||
Across sites, browsers withhold `SameSite=Lax` cookies from cross-origin requests and may block or
|
||||
partition third-party cookies altogether. The documentation states it, and Doctor reports the
|
||||
topology and warns when the two hostnames are not siblings under one parent domain, because Doctor
|
||||
cannot see the Public Suffix List and says so.
|
||||
|
||||
In the split topology `EKKLESIA_IDENTITY_BASE_URL` is the API's origin, `EKKLESIA_DASHBOARD_ORIGIN` is
|
||||
the Dashboard's, and `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` lists the Dashboard's. Email verification and
|
||||
password reset links already point at the API and land on `EKKLESIA_DASHBOARD_ORIGIN` (ADR 0038);
|
||||
nothing about them changes.
|
||||
|
||||
### CORS stays narrow, and that limit is part of the module contract
|
||||
|
||||
Credentialed CORS keeps its current shape: exact reflected origin, `Access-Control-Allow-Credentials:
|
||||
true`, the existing method list, `Content-Type` as the only allowed request header beyond the
|
||||
CORS-safelisted ones, and no exposed response headers beyond the safelisted ones. The API does not
|
||||
reflect arbitrary requested headers.
|
||||
|
||||
A module UI's requests go through the host's `api.request` and inherit this policy. The module UI
|
||||
contract therefore states it: portable module UI code uses only CORS-safelisted request headers plus
|
||||
`Content-Type`, and reads only CORS-safelisted response headers. That is already true of every
|
||||
existing module, and it keeps one module artifact working under either topology. Widening it for a
|
||||
real module need is a later, explicit change.
|
||||
|
||||
### Routing per hostname
|
||||
|
||||
The reference proxy gains a second configuration for the split topology, beside the existing one:
|
||||
|
||||
| Hostname | Forwarded to `api` | Refused with `404` |
|
||||
| --- | --- | --- |
|
||||
| Dashboard | `/` (emailed links add only a query) and `/assets/ekklesia-dashboard/*` | everything else, including `/api/*` |
|
||||
| API | `/api/*` (realtime flushed per event, as now), `/display/*`, `/assets/ekklesia-display/*` | everything else, including `/` and `/assets/ekklesia-dashboard/*` |
|
||||
| Storage | unchanged (ADR 0031) | unchanged |
|
||||
|
||||
The Dashboard hostname refuses `/api/*` so that the session cookie has exactly one home: a Dashboard
|
||||
cannot fall back to a same-origin API on its own hostname. The API hostname answers nothing at `/`.
|
||||
Both hostnames keep every forwarding-header rule ADR 0044 established (one overwritten
|
||||
`X-Forwarded-For`, the stripped client-chosen headers, and tunnel trust only from the connector's
|
||||
address). The proxy overlay takes the Dashboard hostname from one more setting, which the split
|
||||
configuration requires and the same-origin configuration ignores.
|
||||
|
||||
### What a future Content-Security-Policy must allow
|
||||
|
||||
This decision does not add a Content-Security-Policy; the Dashboard page keeps `frame-ancestors
|
||||
'none'` and its other headers. When a full policy is designed, the split topology requires the
|
||||
Dashboard's to allow the API origin in `script-src` (module artifacts and contributions are imported
|
||||
from it) and `connect-src` (platform and module requests, realtime), and the storage origin in
|
||||
`img-src`. The display page, being same-origin with the API, needs only its own origin and the
|
||||
storage origin.
|
||||
|
||||
### Both topologies are proven end to end
|
||||
|
||||
The deployment smoke test runs in either topology, and CI runs both. The split run proves, through
|
||||
the proxy, the routing table above on every hostname, the API origin in the Dashboard page, exact
|
||||
CORS reflection with credentials for the Dashboard origin and none for an untrusted origin, and the
|
||||
host-only session cookie.
|
||||
|
||||
Because those are browser behaviors, the split run also drives a **real browser**: a headless
|
||||
Chromium from an image pinned by digest, controlled over the Chrome DevTools Protocol by the
|
||||
smoke-test client with no added dependency. In it, a person signs in through the real form on the
|
||||
Dashboard hostname, the session cookie is present only for the API hostname, the Dashboard imports a
|
||||
module's UI from the API hostname and the module's own credentialed requests succeed under CORS, no
|
||||
request is refused, the module's display page mounts on the API hostname with its service worker, and
|
||||
sign-out ends the session. The split run installs the repository's module template as a test-only
|
||||
module so these paths exist; installations' own smoke tests cover their real modules. Contribution
|
||||
artifacts are imported exactly as UI artifacts are, and the API's tests cover their CORS headers and
|
||||
those of realtime streams.
|
||||
|
||||
## Consequences
|
||||
|
||||
- One image serves both topologies; an installation chooses with configuration and the matching proxy
|
||||
configuration.
|
||||
- Same-origin installations change nothing.
|
||||
- A split installation depends on exactly two browser-facing hostnames plus storage. Screens and
|
||||
paired devices depend only on the API hostname, which therefore must not change once screens are
|
||||
paired.
|
||||
- No credential is broadened: the session cookie stays host-only on the API hostname, device cookies
|
||||
stay host-only and strict, and trusted origins stay exact.
|
||||
- Module UI code is held to CORS-safe headers in both topologies, so a module that works in one
|
||||
works in the other.
|
||||
- CI gains a second deployment smoke run and a browser in it.
|
||||
|
||||
## Alternatives
|
||||
|
||||
- **A parent-domain session cookie (`Domain=example.org`).** Rejected, as ADR 0034 already rejected
|
||||
it: it sends the credential to every sibling host and is unnecessary between same-site origins.
|
||||
- **The display host on the Dashboard hostname, or on its own hostname.** Rejected: device cookies
|
||||
are bound to the API's hostname regardless, so either adds a second hostname every screen depends
|
||||
on, puts every device request behind CORS, and breaks ADR 0043's same-origin-only offline cache.
|
||||
- **The API serving the display host and the device API on a second hostname.** Rejected: two API
|
||||
origins for one identity base URL, with the same cookies split between them.
|
||||
- **Inject the API origin with an inline script, or a configuration request.** Rejected: an inline
|
||||
script needs a CSP exception, and a request is a round trip and a failure mode before the first
|
||||
render; a meta element is static and inert.
|
||||
- **Keep the API origin a build-time setting.** Rejected: the image would have to be built per
|
||||
installation and per topology.
|
||||
- **Require operators to list the API's own origin in the trusted origins.** Rejected: an omission
|
||||
silently breaks every display mutation; Better Auth already trusts its own base URL.
|
||||
- **Reflect any requested CORS header.** Rejected for now: no module needs it, and a module that
|
||||
relied on it would be tied to its host's CORS policy.
|
||||
- **Prove split mode with HTTP checks only.** Rejected: whether a browser sends the cookie, imports
|
||||
a cross-origin module, and opens a credentialed stream is exactly what is at stake.
|
||||
|
||||
## Deferred
|
||||
|
||||
- A full Content-Security-Policy, with the requirements above.
|
||||
- A broader CORS header policy, when a module needs one.
|
||||
- More than one Dashboard origin per installation.
|
||||
- Moving an installation between topologies, and the re-pairing that moving the API hostname
|
||||
implies.
|
||||
@@ -0,0 +1,110 @@
|
||||
# 0046: Ekklesia's branding is never forced on an installation
|
||||
|
||||
- Status: Proposed
|
||||
- Date: 2026-10-03
|
||||
- Related: [ADR 0021](0021-modules-as-hostable-products.md),
|
||||
[ADR 0034](0034-host-owned-browser-authentication.md),
|
||||
[ADR 0037](0037-host-provided-outbound-email.md),
|
||||
[ADR 0038](0038-email-verification-and-password-recovery.md),
|
||||
[ADR 0039](0039-shared-design-tokens-and-elements.md),
|
||||
[ADR 0043](0043-display-offline-code-fallback.md),
|
||||
[ADR 0045](0045-split-dashboard-and-api-origins.md)
|
||||
|
||||
## Context
|
||||
|
||||
Ekklesia is software a church runs for its own people. The people who use an installation (members,
|
||||
visitors, volunteers running a screen) know the church, not the software behind it, and the church
|
||||
decides how it presents itself to them. Today the platform names itself in places those people see:
|
||||
the sign-in screen asks them to "Sign in to Ekklesia", the Dashboard's heading and page title read
|
||||
"Ekklesia", emails ask them to verify "your Ekklesia email address", and an offline screen in the
|
||||
sanctuary says it is "waiting for Ekklesia".
|
||||
|
||||
None of those were decided; they accumulated. This records the rule they should have followed, so
|
||||
every later screen, message, and module is held to it.
|
||||
|
||||
## Decision
|
||||
|
||||
### The principle
|
||||
|
||||
**Ekklesia's branding is never forced on an installation.** Anything a member, visitor, or volunteer
|
||||
can see is configurable by the installation and removable entirely. That includes, and is not
|
||||
limited to:
|
||||
|
||||
- page titles, headings, and the name of the application in a browser tab, an installed web app, or
|
||||
a bookmark;
|
||||
- sign-in, verification, recovery, and every other authentication screen;
|
||||
- emails: the sender's display name, the subject, and the body;
|
||||
- display screens, pairing screens, and offline and failure screens on a display;
|
||||
- error pages and error messages a person reads;
|
||||
- application names and icons, and any "powered by" or attribution text.
|
||||
|
||||
"Removable entirely" means an installation can show its own name, or no product name at all, and in
|
||||
either case no screen or message a person sees names Ekklesia.
|
||||
|
||||
### What is exempt
|
||||
|
||||
Internal identifiers are not branding and stay as they are: package names, module ids, URL paths
|
||||
(`/api/v1/…`, `/assets/ekklesia-dashboard/…`), HTTP header names, environment variable names, cookie
|
||||
names, CSS custom property names, element ids and `data-` attributes, storage, cache, and database
|
||||
names, and log field names. A person can find them only by opening developer tools or reading
|
||||
configuration, and renaming them would break installations for no one's benefit.
|
||||
|
||||
Tooling an operator runs to administer the installation (the command-line tools, Doctor, server logs,
|
||||
and startup messages) names the software being operated, as a package name does, and is exempt too.
|
||||
So are messages written only for developers: thrown errors that reach a console and never a page, and
|
||||
build-time diagnostics for module authors.
|
||||
|
||||
### How installations configure it
|
||||
|
||||
The platform takes the installation's name from configuration, once, and uses it everywhere a
|
||||
person-facing string would otherwise have said "Ekklesia". When no name is configured, the wording is
|
||||
neutral ("Sign in", "Verify your email address", "Waiting for the server") rather than falling back to
|
||||
"Ekklesia". The default installation therefore names nothing, which is what makes the principle
|
||||
checkable: a test can render every person-facing screen and message with no name configured and fail
|
||||
on any occurrence of "Ekklesia".
|
||||
|
||||
Browser pages receive the name the way they receive the API origin (ADR 0045): the API renders it
|
||||
into the page it serves at startup, so the image stays generic and nothing is decided at build time.
|
||||
An email's sender display name is the existing `EKKLESIA_SMTP_FROM_NAME` (ADR 0037); its subject and
|
||||
body use the same installation name.
|
||||
|
||||
Visual identity follows the same rule. The platform ships no logo, favicon, or icon today; any it
|
||||
ships later must be replaceable by the installation and removable entirely, and must not appear by
|
||||
default. Colours and type are the installation's theme (ADR 0039), not a mark.
|
||||
|
||||
### It applies to modules
|
||||
|
||||
A module's user-visible text follows the same principle. A module that names its host ("Open the
|
||||
Ekklesia Dashboard") also ties itself to one host, which ADR 0021 already asks modules not to do.
|
||||
Modules refer to the host generically ("the Dashboard") or not at all.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Every person-facing string that names the platform today becomes either the configured name or
|
||||
neutral wording. An implementation slice makes that change and adds the test that keeps it true.
|
||||
- New screens, emails, and modules are reviewed against this rule; "Ekklesia" in a person-facing
|
||||
string is a defect.
|
||||
- Internal identifiers, operator tooling, and developer diagnostics keep the name, so nothing
|
||||
operational is renamed.
|
||||
- An installation that wants to credit Ekklesia can do so in its own words; the platform never
|
||||
inserts that credit.
|
||||
|
||||
## Alternatives
|
||||
|
||||
- **Keep "Ekklesia" as the default and let installations override it.** Rejected: an installation
|
||||
that never thought to configure it would show the software's name to its people, which is forcing
|
||||
it by default, and "no occurrence of Ekklesia" could not be tested in the default state.
|
||||
- **Rename internal identifiers too.** Rejected: invisible to the people this protects, and costly
|
||||
to every installation and module.
|
||||
- **A configurable "powered by" line, on by default.** Rejected for the same reason as the first
|
||||
alternative.
|
||||
- **Per-string customization of every message.** Not needed for this principle: one installation
|
||||
name and neutral wording remove the product's name everywhere. Richer copy customization, if
|
||||
wanted, is separate work.
|
||||
|
||||
## Deferred
|
||||
|
||||
- The implementation: the setting, its delivery to pages and emails, the rewording, and the
|
||||
regression test.
|
||||
- Installation-supplied logos, icons, and favicons, and a web app manifest.
|
||||
- Translation and per-string copy customization.
|
||||
@@ -53,3 +53,5 @@ ADRs capture durable choices that affect more than one package or phase.
|
||||
- [0042: Explicit, bounded streaming request bodies](0042-bounded-streaming-request-bodies.md) — Accepted
|
||||
- [0043: The display caches its shell and declared code, and nothing else](0043-display-offline-code-fallback.md) — Accepted
|
||||
- [0044: Ekklesia ships one self-hosted reference deployment, composed from pinned sources](0044-self-hosted-reference-deployment.md) — Accepted
|
||||
- [0045: The Dashboard and the API may live on separate origins](0045-split-dashboard-and-api-origins.md) — Proposed
|
||||
- [0046: Ekklesia's branding is never forced on an installation](0046-installation-owned-branding.md) — Proposed
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env bash
|
||||
# Why a disposable Compose project did not become ready: `docker compose ps` and the last log lines
|
||||
# of every service, so a CI failure says what went wrong (a port already allocated, a container that
|
||||
# exited, a health check that never passed) instead of only that something did.
|
||||
#
|
||||
# source scripts/lib/compose-diagnostics.sh
|
||||
# COMPOSE_DIAGNOSTICS_SECRETS=("$password" "$secret_key") # literal values to mask, optional
|
||||
# compose_report_not_ready "$up_output" "${compose[@]}"
|
||||
#
|
||||
# Everything printed passes through `compose_diagnostics_redact` first. The projects these scripts
|
||||
# start hold only disposable, generated test credentials, but output is redacted anyway: every
|
||||
# literal in COMPOSE_DIAGNOSTICS_SECRETS, the value of any `NAME=value` or `NAME: value` whose name
|
||||
# looks like a credential, the user-information part of any URL, and the rest of any Authorization
|
||||
# header. Terminal control sequences are stripped. Output goes to standard error.
|
||||
|
||||
COMPOSE_DIAGNOSTICS_SECRETS=()
|
||||
COMPOSE_DIAGNOSTICS_TAIL_LINES=40
|
||||
|
||||
# compose_diagnostics_redact: standard input to standard output, with credentials masked.
|
||||
compose_diagnostics_redact() {
|
||||
local secrets=""
|
||||
if ((${#COMPOSE_DIAGNOSTICS_SECRETS[@]} > 0)); then
|
||||
secrets="$(printf '%s\n' "${COMPOSE_DIAGNOSTICS_SECRETS[@]}")"
|
||||
fi
|
||||
# Literal values first, by plain substring rather than as patterns. Very short values are skipped:
|
||||
# masking every occurrence of a two-letter word would only make the output unreadable.
|
||||
COMPOSE_DIAGNOSTICS_LITERALS="$secrets" awk '
|
||||
BEGIN { count = split(ENVIRON["COMPOSE_DIAGNOSTICS_LITERALS"], secret, "\n") }
|
||||
{
|
||||
line = $0
|
||||
for (i = 1; i <= count; i++) {
|
||||
if (length(secret[i]) < 6) {
|
||||
continue
|
||||
}
|
||||
masked = ""
|
||||
while ((at = index(line, secret[i])) > 0) {
|
||||
masked = masked substr(line, 1, at - 1) "[redacted]"
|
||||
line = substr(line, at + length(secret[i]))
|
||||
}
|
||||
line = masked line
|
||||
}
|
||||
print line
|
||||
}
|
||||
' | sed -E \
|
||||
-e 's/\x1b\[[0-9;?]*[A-Za-z]//g' \
|
||||
-e 's#([Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn][[:space:]]*[:=]).*#\1 [redacted]#' \
|
||||
-e 's#([A-Za-z0-9_.-]*([Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Pp][Aa][Ss][Ss][Ww][Dd]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Tt][Oo][Kk][Ee][Nn]|[_.-][Kk][Ee][Yy])[A-Za-z0-9_.-]*"?[[:space:]]*[=:][[:space:]]*)("[^"]*"|[^[:space:],;"]+)#\1[redacted]#g' \
|
||||
-e 's#([A-Za-z][A-Za-z0-9+.-]*://)[^/@[:space:]]+@#\1[redacted]@#g'
|
||||
}
|
||||
|
||||
# compose_report_not_ready <up output> <compose command...>: the failed command's own output, then
|
||||
# the project's containers and the tail of each service's logs, all redacted.
|
||||
compose_report_not_ready() {
|
||||
local up_output="$1"
|
||||
shift
|
||||
local service services
|
||||
{
|
||||
if [[ -n "$up_output" ]]; then
|
||||
echo "--- docker compose up (last 20 lines)"
|
||||
printf '%s\n' "$up_output" | tail -n 20 | compose_diagnostics_redact
|
||||
fi
|
||||
echo "--- docker compose ps"
|
||||
"$@" ps --all 2>&1 | compose_diagnostics_redact
|
||||
services="$("$@" config --services 2>/dev/null || true)"
|
||||
for service in $services; do
|
||||
echo "--- docker compose logs ${service} (last ${COMPOSE_DIAGNOSTICS_TAIL_LINES} lines)"
|
||||
"$@" logs --no-color --tail "$COMPOSE_DIAGNOSTICS_TAIL_LINES" "$service" 2>&1 |
|
||||
compose_diagnostics_redact
|
||||
done
|
||||
echo "--- end of diagnostics"
|
||||
} >&2
|
||||
}
|
||||
+57
-11
@@ -22,6 +22,9 @@ set -Eeuo pipefail
|
||||
repository_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$repository_root"
|
||||
|
||||
# shellcheck source=scripts/lib/compose-diagnostics.sh
|
||||
source "$repository_root/scripts/lib/compose-diagnostics.sh"
|
||||
|
||||
for command in docker openssl awk; do
|
||||
command -v "$command" >/dev/null || {
|
||||
echo "smoke-deploy: $command is required." >&2
|
||||
@@ -45,15 +48,34 @@ built_image=""
|
||||
hostname="ekklesia.smoke.test"
|
||||
storage_hostname="storage.smoke.test"
|
||||
|
||||
# A per-run edge subnet, so concurrent runs and the host's own networks do not collide. Fixed
|
||||
# addresses come from the upper half, outside Docker's dynamic range, exactly as documented.
|
||||
octet=$((RANDOM % 200 + 20))
|
||||
edge_subnet="10.249.${octet}.0/24"
|
||||
edge_range="10.249.${octet}.0/25"
|
||||
proxy_address="10.249.${octet}.254"
|
||||
tunnel_address="10.249.${octet}.253"
|
||||
# A test-only client address, also outside the dynamic range, so its limiter bucket is its own.
|
||||
session_client_address="10.249.${octet}.252"
|
||||
# choose_edge_network: a per-run edge subnet that no network on this Docker daemon already uses, so
|
||||
# concurrent runs sharing a runner's daemon do not collide. Two runs can still pick the same free
|
||||
# subnet at the same moment; the first `up` below detects that and chooses again. Fixed addresses come
|
||||
# from the upper half, outside Docker's dynamic range, exactly as documented.
|
||||
choose_edge_network() {
|
||||
local in_use candidate attempt
|
||||
in_use="$(
|
||||
docker network ls --quiet |
|
||||
xargs -r docker network inspect --format '{{range .IPAM.Config}}{{println .Subnet}}{{end}}' 2>/dev/null |
|
||||
sed -n 's#^10\.249\.\([0-9]*\)\.0/24$#\1#p'
|
||||
)" || in_use=""
|
||||
for attempt in $(seq 1 100); do
|
||||
candidate=$((RANDOM % 200 + 20))
|
||||
if ! grep -qx "$candidate" <<<"$in_use"; then
|
||||
octet="$candidate"
|
||||
edge_subnet="10.249.${octet}.0/24"
|
||||
edge_range="10.249.${octet}.0/25"
|
||||
proxy_address="10.249.${octet}.254"
|
||||
tunnel_address="10.249.${octet}.253"
|
||||
# A test-only client address, also outside the dynamic range, so its limiter bucket is its own.
|
||||
session_client_address="10.249.${octet}.252"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
echo "smoke-deploy: no free 10.249.x.0/24 edge subnet on this Docker daemon." >&2
|
||||
exit 1
|
||||
}
|
||||
choose_edge_network
|
||||
|
||||
# daemon_work: where the Docker daemon sees this run's mounted files. The same directory as `work`
|
||||
# on a workstation; a staged copy on the Docker host when this script runs in a job container.
|
||||
@@ -154,6 +176,8 @@ postgres_password="$(secret POSTGRES_PASSWORD)"
|
||||
s3_access_key="$(secret S3_ACCESS_KEY)"
|
||||
s3_secret_key="$(secret S3_SECRET_KEY)"
|
||||
identity_secret="$(secret EKKLESIA_IDENTITY_SECRET)"
|
||||
# Masked in any diagnostics below, along with anything else that looks like a credential.
|
||||
COMPOSE_DIAGNOSTICS_SECRETS=("$postgres_password" "$s3_access_key" "$s3_secret_key" "$identity_secret")
|
||||
|
||||
mkdir -p "$work/env"
|
||||
# set_env <file> <key> <value>: replaces an uncommented KEY= line, keeping the value byte-exact.
|
||||
@@ -207,8 +231,28 @@ EKKLESIA_TUNNEL_ADDRESS=${tunnel_address}
|
||||
EOF
|
||||
|
||||
step "Starting PostgreSQL and object storage"
|
||||
"${compose[@]}" up --detach --wait --wait-timeout 180 postgres object-storage >/dev/null 2>&1 ||
|
||||
# This `up` creates the edge network. If another run claimed the same subnet since it was chosen,
|
||||
# Docker refuses with an address-pool overlap: choose another and start again, a bounded number of
|
||||
# times. Any other failure is reported with the project's containers and logs.
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if up_output="$(
|
||||
"${compose[@]}" up --detach --wait --wait-timeout 180 postgres object-storage 2>&1
|
||||
)"; then
|
||||
break
|
||||
fi
|
||||
if [[ "$up_output" == *"overlap"* ]] && ((attempt < 5)); then
|
||||
echo " edge subnet ${edge_subnet} was taken by a concurrent run; choosing another"
|
||||
"${compose[@]}" down --volumes --remove-orphans --timeout 10 >/dev/null 2>&1 || true
|
||||
choose_edge_network
|
||||
set_env "$work/compose.env" EKKLESIA_EDGE_SUBNET "$edge_subnet"
|
||||
set_env "$work/compose.env" EKKLESIA_EDGE_DYNAMIC_RANGE "$edge_range"
|
||||
set_env "$work/compose.env" EKKLESIA_PROXY_ADDRESS "$proxy_address"
|
||||
set_env "$work/compose.env" EKKLESIA_TUNNEL_ADDRESS "$tunnel_address"
|
||||
continue
|
||||
fi
|
||||
compose_report_not_ready "$up_output" "${compose[@]}"
|
||||
fail "PostgreSQL and object storage did not become healthy."
|
||||
done
|
||||
|
||||
step "Planning, then explicitly applying, migrations against this run's fresh database"
|
||||
"${compose[@]}" run --rm --no-deps migrate >/dev/null || fail "migrate (plan) failed."
|
||||
@@ -216,8 +260,10 @@ step "Planning, then explicitly applying, migrations against this run's fresh da
|
||||
fail "migrate (apply) failed."
|
||||
|
||||
step "Starting the API and the proxy"
|
||||
"${compose[@]}" up --detach --wait --wait-timeout 180 >/dev/null 2>&1 ||
|
||||
if ! up_output="$("${compose[@]}" up --detach --wait --wait-timeout 180 2>&1)"; then
|
||||
compose_report_not_ready "$up_output" "${compose[@]}"
|
||||
fail "the API and proxy did not become healthy."
|
||||
fi
|
||||
|
||||
# Caddy creates its internal CA on first start. The test clients trust it, plus the installation CA
|
||||
# for their direct connection to object-storage.
|
||||
|
||||
@@ -7,6 +7,8 @@ cd "$repository_root"
|
||||
|
||||
# shellcheck source=scripts/lib/compose-job-network.sh
|
||||
source "$repository_root/scripts/lib/compose-job-network.sh"
|
||||
# shellcheck source=scripts/lib/compose-diagnostics.sh
|
||||
source "$repository_root/scripts/lib/compose-diagnostics.sh"
|
||||
|
||||
command -v docker >/dev/null || {
|
||||
echo "Docker is required for the infrastructure smoke test." >&2
|
||||
@@ -28,7 +30,9 @@ if [[ "$curl_help" != *"--aws-sigv4"* ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
raw_project="ekklesia-smoke-${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-0}-$$"
|
||||
# Unique per invocation, not only per run: several runs can share a runner's Docker daemon at once,
|
||||
# and the shell's PID is often identical across job containers.
|
||||
raw_project="ekklesia-smoke-${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-0}-$$-${RANDOM}${RANDOM}"
|
||||
project="${raw_project//[^a-zA-Z0-9_-]/-}"
|
||||
project="${project,,}"
|
||||
temporary_directory="$(mktemp -d)"
|
||||
@@ -46,6 +50,8 @@ export S3_SECRET_KEY="ekklesia-smoke-secret-key"
|
||||
export S3_BUCKET="ekklesia-smoke"
|
||||
export S3_REGION="us-east-1"
|
||||
export S3_PORT="${EKKLESIA_SMOKE_S3_PORT:-0}"
|
||||
COMPOSE_DIAGNOSTICS_SECRETS=("$POSTGRES_PASSWORD" "$S3_ACCESS_KEY" "$S3_SECRET_KEY")
|
||||
up_output=""
|
||||
|
||||
compose=(
|
||||
docker compose
|
||||
@@ -63,7 +69,7 @@ cleanup() {
|
||||
local cleanup_output
|
||||
trap - EXIT INT TERM
|
||||
if ((exit_status != 0)); then
|
||||
"${compose[@]}" logs --no-color >&2 || true
|
||||
compose_report_not_ready "$up_output" "${compose[@]}" || true
|
||||
fi
|
||||
if ! compose_job_network_disconnect; then
|
||||
echo "Infrastructure smoke network cleanup failed." >&2
|
||||
@@ -88,8 +94,15 @@ trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
"${compose[@]}" config --quiet
|
||||
"${compose[@]}" up --detach --wait --wait-timeout 120
|
||||
"${compose[@]}" up --detach --wait --wait-timeout 120
|
||||
# Started twice: the second `up` proves the project is idempotent. A failure's own output is kept for
|
||||
# the cleanup's diagnostics, which also print every container and the tail of its logs.
|
||||
for _ in 1 2; do
|
||||
if ! up_output="$("${compose[@]}" up --detach --wait --wait-timeout 120 2>&1)"; then
|
||||
echo "Disposable PostgreSQL and object storage did not become ready." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
up_output=""
|
||||
|
||||
database_result="$(
|
||||
"${compose[@]}" exec -T \
|
||||
|
||||
@@ -7,6 +7,8 @@ cd "$repository_root"
|
||||
|
||||
# shellcheck source=scripts/lib/compose-job-network.sh
|
||||
source "$repository_root/scripts/lib/compose-job-network.sh"
|
||||
# shellcheck source=scripts/lib/compose-diagnostics.sh
|
||||
source "$repository_root/scripts/lib/compose-diagnostics.sh"
|
||||
|
||||
command -v docker >/dev/null || {
|
||||
echo "Docker is required for the PostgreSQL migration integration suite." >&2
|
||||
@@ -25,14 +27,20 @@ if ! compose_job_network_initialize; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
raw_project="ekklesia-migration-${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-0}-${GITHUB_JOB:-job}-$$"
|
||||
# Unique per invocation, not only per run: several runs (and several jobs of one run) can share a
|
||||
# runner's Docker daemon at once, and the shell's PID is often identical across job containers.
|
||||
raw_project="ekklesia-migration-${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-0}-${GITHUB_JOB:-job}-$$-${RANDOM}${RANDOM}"
|
||||
project="${raw_project//[^a-zA-Z0-9_-]/-}"
|
||||
project="${project,,}"
|
||||
|
||||
export POSTGRES_DB="ekklesia_migration_test"
|
||||
export POSTGRES_USER="ekklesia_migration_test"
|
||||
export POSTGRES_PASSWORD="temporary-$project-${RANDOM}-${RANDOM}"
|
||||
# Both host ports are ephemeral: concurrent runs on one Docker daemon must never contend for a
|
||||
# fixed one. `.env.example` pins the storage port for local development, so it is overridden here
|
||||
# exactly like the database's. The bound ports are discovered below.
|
||||
export POSTGRES_PORT="0"
|
||||
export S3_PORT="0"
|
||||
|
||||
compose=(
|
||||
docker compose
|
||||
@@ -72,8 +80,17 @@ fi
|
||||
# and CI already knows how to provision. SeaweedFS is the intended deployment target (ADR 0004), and
|
||||
# proving the storage contract against a mock instead would have hidden the provider quirks this
|
||||
# suite exists to catch.
|
||||
if ! "${compose[@]}" up --detach --wait --wait-timeout 180 postgres object-storage >/dev/null 2>&1; then
|
||||
# Masked in any diagnostics below, along with anything else that looks like a credential.
|
||||
COMPOSE_DIAGNOSTICS_SECRETS=(
|
||||
"$POSTGRES_PASSWORD"
|
||||
"$(grep -E '^S3_ACCESS_KEY=' .env.example | cut -d= -f2-)"
|
||||
"$(grep -E '^S3_SECRET_KEY=' .env.example | cut -d= -f2-)"
|
||||
)
|
||||
if ! up_output="$(
|
||||
"${compose[@]}" up --detach --wait --wait-timeout 180 postgres object-storage 2>&1
|
||||
)"; then
|
||||
echo "Disposable PostgreSQL and object storage did not become ready." >&2
|
||||
compose_report_not_ready "$up_output" "${compose[@]}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
/**
|
||||
* The scripts CI runs against a shared Docker daemon. Several runs, and several jobs of one run, can
|
||||
* be active on one runner at once (and the inventoried Gitea ignores workflow `concurrency`), so
|
||||
* nothing they start may claim a fixed host port, a fixed project or network name, or a fixed
|
||||
* subnet. And when a disposable project does not become ready, the failure must say why without
|
||||
* printing a credential.
|
||||
*/
|
||||
|
||||
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const ciScripts = [
|
||||
"scripts/test-migrations-integration.sh",
|
||||
"scripts/smoke-infrastructure.sh",
|
||||
"scripts/smoke-deploy.sh",
|
||||
];
|
||||
|
||||
async function source(relativePath: string): Promise<string> {
|
||||
return readFile(path.join(repositoryRoot, relativePath), "utf8");
|
||||
}
|
||||
|
||||
/** Runs the real helper under bash with the given literal secrets and input. */
|
||||
async function redact(input: string, secrets: readonly string[] = []): Promise<string> {
|
||||
const helper = path.join(repositoryRoot, "scripts", "lib", "compose-diagnostics.sh");
|
||||
const script = `source "$1"; shift; COMPOSE_DIAGNOSTICS_SECRETS=("$@"); printf '%s' "$INPUT" | compose_diagnostics_redact`;
|
||||
const { stdout } = await execFileAsync("bash", ["-c", script, "redact", helper, ...secrets], {
|
||||
env: { ...process.env, INPUT: input },
|
||||
});
|
||||
return stdout;
|
||||
}
|
||||
|
||||
describe("CI scripts on a shared Docker daemon", () => {
|
||||
it("publish every host port ephemerally, never a fixed one", async () => {
|
||||
const migrations = await source("scripts/test-migrations-integration.sh");
|
||||
// `.env.example` pins both ports for local development; the suite must override both.
|
||||
expect(migrations).toContain('export POSTGRES_PORT="0"');
|
||||
expect(migrations).toContain('export S3_PORT="0"');
|
||||
|
||||
const infrastructure = await source("scripts/smoke-infrastructure.sh");
|
||||
expect(infrastructure).toContain('export POSTGRES_PORT="${EKKLESIA_SMOKE_POSTGRES_PORT:-0}"');
|
||||
expect(infrastructure).toContain('export S3_PORT="${EKKLESIA_SMOKE_S3_PORT:-0}"');
|
||||
|
||||
const deploy = await source("scripts/smoke-deploy.sh");
|
||||
expect(deploy).toContain("EKKLESIA_HTTP_PORT=0");
|
||||
expect(deploy).toContain("EKKLESIA_HTTPS_PORT=0");
|
||||
|
||||
for (const relativePath of ciScripts) {
|
||||
const text = await source(relativePath);
|
||||
// No `-p 8333:…`, `127.0.0.1:5432:…`, or `PORT=<non-zero>` anywhere.
|
||||
expect(text, relativePath).not.toMatch(/\b(?:-p|--publish)\s+[^\s]*\d{2,5}:\d/u);
|
||||
expect(text, relativePath).not.toMatch(/\b[A-Z_]*PORT="?[1-9]\d*"?\s*$/mu);
|
||||
}
|
||||
});
|
||||
|
||||
it("give every Compose project a name unique to the invocation, not only the run", async () => {
|
||||
expect(await source("scripts/test-migrations-integration.sh")).toMatch(
|
||||
/raw_project="ekklesia-migration-[^"\n]*\$\$-\$\{RANDOM\}\$\{RANDOM\}"/u,
|
||||
);
|
||||
expect(await source("scripts/smoke-infrastructure.sh")).toMatch(
|
||||
/raw_project="ekklesia-smoke-[^"\n]*\$\$-\$\{RANDOM\}\$\{RANDOM\}"/u,
|
||||
);
|
||||
const deploy = await source("scripts/smoke-deploy.sh");
|
||||
expect(deploy).toContain('run_id="$(openssl rand -hex 4)"');
|
||||
expect(deploy).toContain('project="ekklesia-deploy-smoke-${run_id}"');
|
||||
for (const relativePath of ciScripts) {
|
||||
expect(await source(relativePath), relativePath).toContain('--project-name "$project"');
|
||||
}
|
||||
});
|
||||
|
||||
it("choose the deploy smoke's edge subnet from those not in use, and choose again on a race", async () => {
|
||||
const deploy = await source("scripts/smoke-deploy.sh");
|
||||
expect(deploy).not.toMatch(/^octet=\$\(\(RANDOM/mu);
|
||||
expect(deploy).toContain("choose_edge_network() {");
|
||||
expect(deploy).toContain("docker network inspect --format '{{range .IPAM.Config}}");
|
||||
expect(deploy).toContain('[[ "$up_output" == *"overlap"* ]]');
|
||||
});
|
||||
|
||||
it("tag the CI image per run and attempt, so concurrent runs of one commit cannot remove it", async () => {
|
||||
const workflow = await source(".github/workflows/ci.yml");
|
||||
expect(workflow).toContain(
|
||||
'ekklesia-ci:"$GITHUB_SHA-${GITHUB_RUN_ID:-0}-${GITHUB_RUN_ATTEMPT:-1}"',
|
||||
);
|
||||
expect(workflow).not.toContain('ekklesia-ci:"$GITHUB_SHA"');
|
||||
});
|
||||
|
||||
it("report every readiness failure with the project's containers and logs, through the redactor only", async () => {
|
||||
for (const relativePath of ciScripts) {
|
||||
const text = await source(relativePath);
|
||||
expect(text, relativePath).toContain(
|
||||
'source "$repository_root/scripts/lib/compose-diagnostics.sh"',
|
||||
);
|
||||
expect(text, relativePath).toContain("compose_report_not_ready");
|
||||
expect(text, relativePath).toContain("COMPOSE_DIAGNOSTICS_SECRETS=(");
|
||||
// Compose output is never printed raw: logs are only ever captured (the deploy smoke scans
|
||||
// the API's for secrets), and a readiness failure's own output is never discarded.
|
||||
for (const line of text
|
||||
.split("\n")
|
||||
.filter((candidate) => candidate.includes('compose[@]}" logs'))) {
|
||||
expect(line, relativePath).toMatch(/="\$\("\$\{compose\[@\]\}" logs /u);
|
||||
}
|
||||
expect(text, relativePath).not.toMatch(/up --detach --wait[^\n]*>\/dev\/null 2>&1/u);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("compose diagnostics redaction", () => {
|
||||
it("masks credential-looking values, URL user information, and Authorization headers", async () => {
|
||||
const output = await redact(
|
||||
[
|
||||
"POSTGRES_PASSWORD=hunter2hunter2",
|
||||
' "AWS_SECRET_ACCESS_KEY": "abc/def+ghi"',
|
||||
"S3_ACCESS_KEY=AKIAEXAMPLE other=fine",
|
||||
"api_key: xyz123",
|
||||
"connecting to postgresql://ekklesia:s3cr3t@postgres:5432/ekklesia",
|
||||
"Authorization: Bearer eyJhbGciOi.payload.sig",
|
||||
"EKKLESIA_IDENTITY_SECRET=0123456789abcdef",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
for (const secret of [
|
||||
"hunter2hunter2",
|
||||
"abc/def+ghi",
|
||||
"AKIAEXAMPLE",
|
||||
"xyz123",
|
||||
"s3cr3t",
|
||||
"eyJhbGciOi",
|
||||
"0123456789abcdef",
|
||||
]) {
|
||||
expect(output).not.toContain(secret);
|
||||
}
|
||||
expect(output).toContain("POSTGRES_PASSWORD=[redacted]");
|
||||
expect(output).toContain("other=fine");
|
||||
expect(output).toContain("postgresql://[redacted]@postgres:5432/ekklesia");
|
||||
});
|
||||
|
||||
it("masks every occurrence of a supplied literal, wherever it appears", async () => {
|
||||
const output = await redact("started with temporary-pw-12345 and again temporary-pw-12345", [
|
||||
"temporary-pw-12345",
|
||||
]);
|
||||
expect(output).toBe("started with [redacted] and again [redacted]\n");
|
||||
});
|
||||
|
||||
it("keeps the diagnosis itself readable", async () => {
|
||||
const diagnosis =
|
||||
"Error response from daemon: Bind for 127.0.0.1:8333 failed: port is already allocated";
|
||||
expect(await redact(diagnosis)).toBe(`${diagnosis}\n`);
|
||||
expect(await redact("duplicate key value violates unique constraint")).toBe(
|
||||
"duplicate key value violates unique constraint\n",
|
||||
);
|
||||
expect(await redact('password authentication failed for user "ekklesia"')).toBe(
|
||||
'password authentication failed for user "ekklesia"\n',
|
||||
);
|
||||
});
|
||||
|
||||
it("strips terminal control sequences from container logs", async () => {
|
||||
expect(await redact("\u001b[2Kpostgres-1 | ready")).toBe("postgres-1 | ready\n");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user