19. Test Suite¶
Pepsi ships an end-to-end test suite under tests/ that exercises a live
deployment over SMTP, asserting the observable behaviour of the whole pipeline
rather than any single function in isolation. It is deliberately a black-box
suite: messages are sent with the ordinary mail command or by raw SMTP, and
the assertions read the recipients’ mailboxes and the pepsi.ingress queue.
This complements — it does not replace — the in-tree unit tests
(cargo test --workspace), which cover the pure logic (SRS, MIME downgrade,
DSN construction, language scoring, DNS classification, …) without a network.
Two things are covered in between, by integration tests that need neither a
network nor a database. The migration suites
(pepsi-setup/tests/import_*.rs and tests/cli_import.rs) run each MTA
importer against a fixture /etc tree and then run the real pepsi-setup
binary end to end, up to a --wizard --import that must produce a
configuration passing Pepsi’s own validation; see
the extending chapter for their layout. The
dispatcher lifecycle suite (pepsi-test-stages) needs a PostgreSQL and is
run by make check.
19.1. The documentation builds are checks too¶
make docs runs all of them for the source language, and is the one
documentation target in the general quality gate. It is an alias for
make docs-en; make docs-de and make docs-fr build the same artifacts
from the translated catalogs and make docs-all builds every language. Four
separate builds sit under it, and passing one says little about the others:
make docs-html-enSphinx HTML. Catches broken cross-references, unknown roles and malformed tables.
make manThe section-1/5 man pages through
docs/rst2man.py. Catches title over/underlines that do not match the title length — a docutils error the HTML build tolerates. Source language only: the roff pages are rendered straight fromdocs/*.rstand never go through Sphinx, so they have no translation catalog.make infoThe GNU info manual, through Sphinx’s texinfo builder and
makeinfo. Source language only, for the same reasoninstall-inforegisters onepepsi.infoin one infodirindex.make docs-pdf-enLaTeX. The one most easily forgotten, and the one that fails hardest. pdflatex has no glyph for an arbitrary Unicode character and does not degrade when it meets one: it aborts with “Unicode character … not set up for use with LaTeX” and produces no PDF at all. HTML and man render the same character without complaint, so a new arrow, box-drawing character or Greek letter in the prose passes both and breaks only this build.
Every non-ASCII character used in the manual must be declared in
latex_elements["preamble"]indocs/manual/conf.py. The box-drawing set used by the ASCII-art pipeline diagrams is mapped to plain ASCII on purpose: those diagrams sit insidecode-block:: text, so the replacement is expanded in a verbatim environment where math mode is unavailable.The second failure mode is a table that does not fit on a page. Sphinx renders a
list-tableinto LaTeX’slongtable— which splits across pages and repeats the header — only past a row-count threshold; below it the table becomes atabular, which cannot break at all. The heuristic counts rows, but what overflows a page is height, and those diverge badly for a wide table whose cells wrap: the 113-row RFC summary table splits happily while the 17-row, seven-column comparison table in Introduction did not, and ran 397 pt off the bottom of the page.The fix is to say so explicitly rather than rely on the heuristic:
.. list-table:: :header-rows: 1 :widths: 20 14 14 14 14 14 14 :class: longtable
Unlike a missing character this does not fail the build — it is an
Overfull \vboxwarning and a PDF with content off the page — so grep the log forOverfull \vboxand treat anything beyond a point or two as a real defect. Sub-point overshoots are ordinary typographic rounding.When reading its output, ignore the
Hyper reference … undefinedwarnings from the first passes — latexmk resolves forward references on later ones. The final pass, indocs/manual/_build/pdf/en/latex/pepsi.log, is the one that must be clean.
The translations are checked the same way, and the check is not redundant: a
character a translator introduces — a guillemet, a German quotation mark, a
non-breaking space — fails the same pdflatex build in the same way, and no
English-only run would ever meet it. make docs-all is therefore a release
gate rather than a per-commit one; each language’s PDF lands in
docs/manual/_build/pdf/<lang>/latex/pepsi.pdf, and a German or French build
additionally needs that language’s babel support installed (Debian:
texlive-lang-german, texlive-lang-french).
Keeping the translations current is make update-po, which re-extracts the
message templates and merges them into docs/manual/locale/<lang>/LC_MESSAGES
so that changed paragraphs come back marked fuzzy and new ones untranslated.
Only the .po files are under version control; Sphinx compiles them at build
time.
19.2. Topology¶
The suite drives three real mail accounts across three hosts, described in a
small INI file (tests/test-accounts.ini):
Role |
Account (ssh target = e-mail) |
Function |
|---|---|---|
|
|
Runs Pepsi (ingress, dispatch, httpd), PostgreSQL and systemd; also the smarthost the served domain relays through. |
|
|
Local accounts on the served domain. Their MTA relays through ADMIN and
is trusted by it ( |
|
|
An account on an independent host. Her mail reaches ADMIN’s public MX on port 25 as a foreign MTA — the inbound path. |
|
|
A local unix user on the ADMIN host that receives mail by direct
local delivery into its Maildir (the local-delivery path; no onward
relay). Unlike the others it is never ssh’d into directly — its mailbox is
read as root over the ADMIN connection. Only |
Every host is reached over passwordless ssh; the harness installs nothing.
ALICE/BOB use an mbox (/var/mail/<user>); CAROL and DAVE use a
Maildir. The mailbox helpers are format-agnostic and locate a message by a
run-unique token across both layouts.
The same file carries one non-mail account, in a [demobank] section:
[demobank]
URL = https://bank.demo.taler.net
USER = pepsi-ci
PASS = pepsi-pass
This is a bank account at the public GNU Taler demo bank, whose currency
(KUDOS) is play money. 12-wallet-topup-test.sh needs it because a manual
wallet top-up only tells the user where to wire money — nothing credits the wallet
until that bank transfer actually happens. The harness therefore plays the user’s
bank: it logs in and makes the transfer the e-mailed instructions ask for, which
is the only way the test can assert that the withdrawal completes rather than
merely that instructions were sent. Register an account at
https://bank.demo.taler.net/ (a new one starts with KUDOS:100 and may go
KUDOS:500 into debt — roughly 120 runs of that case); DEMOBANK_USER,
DEMOBANK_PASS and DEMOBANK_URL in the environment override the file. No
other script uses it, and with the section absent only that one credit assertion
skips.
19.3. The scripts¶
tests/lib.shShared library sourced by every numbered script. It parses the accounts INI, provides the
ssh/dependency/database/service probes used by the common pre-flight, the result counters and colouredok/no/skipoutput, the ADMIN-host SQL and reset helpers, the mailbox send/receive helpers, the Taler wallet helpers, the RFC 3464 DSN assertion, and — for the extended scripts —send_smtp(a raw-SMTP sender that can set the ESMTP and DSN parameters themailcommand cannot) andhop_caps(an EHLO capability probe). It is not executable on its own.01-deploy.shProvisions a complete Pepsi deployment on the ADMIN host from a clean checkout: service users, PostgreSQL role/database, the configuration file (including the full stage pipeline),
pepsi-setup(TLS, schema, DKIM keys, DNS records), the demo payment merchant, and the systemd units. It only verifies required tools and bails with a precise list if any are missing — it never installs OS dependencies.02-pipeline-test.shThe core happy-path suite (T1–T6): the pay-to-send anti-spam stage (unpaid → bounce, paid → delivered, paying centrally from the runner’s Taler wallet), the language filter (blacklisted French → bounce), the outbound signing/relay path (DKIM + ARC verified at the recipient), whitelist learning with SRS envelope rewriting, and a second local user.
03-dsn-test.shRFC 3461 DSN extensions and bounce variants, using
send_smtp:NOTIFY=NEVERsuppresses the DSN (D1);ENVID/ORCPTare echoed back (D2);RET=HDRSreturns headers only (D3);NOTIFY=SUCCESSwithORIGINATE_SUCCESS_DSNyields a positive DSN (D4); an NXDOMAIN recipient routes to the default bounce (D5); an unreachable relay produces a DELAY DSN (D6); and a null-sender bounce to anSRS0=…address is reverse-decoded by ingress back to the original sender (D7).04-settings-test.shThe per-address override layer: a
pepsi-settingsrow changes one recipient’s outcome but not another’s (S1); an account edits its own overrides by e-mail and is acknowledged (S2); an edit to a non-EDITABLE_STAGESsection is rejected and stores nothing (S3); and oneDATAwith divergent per-recipient overrides is split into separate ingress rows (S4).05-mime-test.sh8BITMIME / SMTPUTF8 handling: an 8-bit body and a UTF-8
Subjectsurvive the relay intact when the delivery hop advertises the extension, are downgraded (quoted-printable/base64, RFC 2047) when it does not, and a non-ASCII recipient address that the hop cannot represent is permanently failed. The script probes the hop’s EHLO first and takes the matching branch.06-cli-test.shThe operator CLIs, run directly over
ssh:pepsi-config(get/dump/pathsub),pepsi-whitelist(add/list/remove and the dkim/signature flags),pepsi-settings(set/get/list/unset/remove),pepsi-tlsrpt(report--dry-run/ prune), and thepepsi-httpd/metricsendpoint.07-maildir-test.shDirect (local Maildir) delivery via the
pepsi-stage-relay-to-maildirstage, which drives the setuid-rootpepsi-helper-maildir-writer. A message to the localDAVEuser lands in its Maildir with the prependedReturn-Path/Delivered-To/Receivedheaders (L1); a single message to one local and one remote recipient is split, deliveringdavelocally whilealiceis relayed onward (L2); and a message to a deliberately undeliverable account (dave-broken, no usable home) gives up pastMAX_LIFETIMEand returns a failure DSN to the sender (L3). Because[stage-local]sits after the inbound filters (… → anti-spam → local → srs), the sender is whitelisted first to clear the pay-to-send gate. The whole script skips cleanly whenDAVEis unset, the deployment predates[stage-local], or the target users are missing.12-wallet-topup-test.shThe by-e-mail wallet self-service half of
pepsi-stage-auto-pay(the[stage-wallet]section): a local account tops up its own per-user GNU Taler wallet by sendingpepsi-wallet@<served-domain>aSubject:command. All three methods are exercised end-to-end against the demo exchange (KUDOS), with the funded localtaler-wallet-clias the genuine peer:withdraw(manual bank-wire instructions, the wire then actually made from the[demobank]account),pull(ataler://pay-pull/URI the harness settles) andpush(the harness pushes; the user accepts thetaler://pay-push/URI). Each case asserts the control message is consumed and the wallet balance grows.13-auto-pay-test.shThe demand-paying half:
pepsi-stage-auto-paysettles a returning pay-to-send demand from the sender’s own wallet. The demand is produced genuinely — a local user sends to a paywalled local recipient (DAVE), the outbound is internet-relayed (stamping a realPepsi-Origin), loops back into this host’s inbound MX wherepepsi-stage-anti-spamholds it and e-mails the sender a real demand. That demand is replayed through the auto-payworker. The two cases use separate senders (hence separate per-user wallets/orders):BOB’s wallet is left empty, so auto-pay forwards the demand and charges nothing (A1);ALICE’s wallet is pre-funded ahead of time (so the slow withdrawal is out of the paywall’s short 30s payment window), so auto-pay settles the demand within it — the order flips to paid, the wallet is debited, and the held message is released and delivered toDAVE(A2).14-multi-recipient-test.shRecipient fan-out, the failure mode where a message addressed to several people reaches only some of them. Both cases assert positive delivery to every intended recipient. T1 sends one envelope to two remote recipients and requires both to arrive — a relay that takes
rcpt_to.first()and then terminates the row passes nothing else on. T2 covers the~/.forwardfan-out: one envelope addressed to a local user who forwards (DAVE) and a second plain recipient, where the forwarded copy and the passthrough must each arrive exactly once — a stage that reduces the database row server-side without mutating the in-memory message leaves a fused successor working from the stale recipient list. T2 needs the optionalDAVEaccount and a deployed~/.forwardstage.16-whitelist-import-test.shThe setuid
pepsi-whitelistand its mailbox importer, against the installed deployment — everything about that program that only exists once it is installed. It checks the installation itself (mode4755ownedpepsi-whitelist, the scan helper carrying no privilege bits, the table-scoped database role, the hoster list); the namespace rule, by creating two throwaway accounts and confirming one may use<login>/…and nothing else, thatlistdoes not disclose the operator’s shared whitelist, and that-cis refused; the import itself, which must yield exactly the recipients of the mail that user sent and ignore received mail; the wildcard decision, where a busy domain collapses to one*@domainrow whilegmail.comwith just as many correspondents does not; the privilege drop, by substituting a helper that reports its own credentials and asserting the caller’s uid appears in all three id slots;--userseeding another account’s own namespace; and finally thatpepsi-stage-check-whitelistconsults the per-user name its{localpart}placeholder expands to. Individual checks skip when the deployment predates the feature.
Beside the numbered scripts, tests/whitelist-suid.sh covers the same setuid
contract without SSH or a deployment: run as root on any host with a local
PostgreSQL, it creates a throwaway database, installs the binary setuid into a
suid-honouring directory and runs the same namespace/privilege-drop assertions.
make check runs it (and the other *-suid.sh scripts) best-effort — they
SKIP rather than fail when they cannot get root, a database or a suid-capable
filesystem.
19.4. Running¶
# One-time, on the MX host (from a clean checkout):
ssh root@<mx-host> 'bash -s' < tests/01-deploy.sh
# Then, repeatedly, from the test runner:
tests/02-pipeline-test.sh tests/test-accounts.ini
tests/03-dsn-test.sh tests/test-accounts.ini
tests/04-settings-test.sh tests/test-accounts.ini
tests/05-mime-test.sh tests/test-accounts.ini
tests/06-cli-test.sh tests/test-accounts.ini
tests/07-maildir-test.sh tests/test-accounts.ini # needs the optional DAVE account
tests/12-wallet-topup-test.sh tests/test-accounts.ini # needs taler-wallet-cli on the MX host
tests/13-auto-pay-test.sh tests/test-accounts.ini # needs DAVE + taler-wallet-cli
tests/16-whitelist-import-test.sh tests/test-accounts.ini # needs root on the MX host
Each script is self-managing: it runs a pre-flight (ssh reachability with the
real error on failure, required remote tools, a database probe, and the three
services’ state with the journal tail of any that is down), ensures a short
PAYMENT_DEADLINE and brisk POLL_INTERVAL for fast turnaround, and resets
the whitelist/queue/settings it depends on. A missing assumption fails the
pre-flight with an actionable message; a message that does not arrive triggers a
diagnostics dump (the live ingress queue plus the dispatcher journal). Tunables
such as PAYMENT_DEADLINE_SECS, DELIVER_TIMEOUT and WITHDRAW_AMOUNT are
environment overrides. Each script exits 0 only when no case failed (skips
are allowed).
19.5. Skips and gated cases¶
A case skips (rather than fails) when a precondition it cannot satisfy is absent, always with a one-line reason. The common ones:
No usable Taler wallet. The paid leg of
02skips if the localtaler-wallet-cliis missing or incompatible with the demo exchange. All payments are made centrally from the runner’s wallet (in production each sender would pay for their own message).Deployment predates a feature.
03/04skip the cases that need pipeline features added later to01-deploy.sh(ORIGINATE_SUCCESS_DSN, a[stage-internet]BOUNCE_STAGE, the[stage-delaytest]blackhole relay, the[stage-edit-settings]stage) when run against an older deployment — re-run01-deploy.shto enable them.06skips thepepsi-tlsrptcase unless a[pepsi-tlsrpt]section is configured.07skips entirely when the optionalDAVEaccount is unset, the deployment predates[stage-local], or thedave/dave-brokentarget users are missing on the ADMIN host.14’s T2 skips whenDAVEis unset or the deployment has no~/.forwardstage.Hop capability.
05takes its pass-through, downgrade or unrepresentable-address branch according to whether the delivery hop advertises 8BITMIME / SMTPUTF8; the cases that require the hop to lack an extension skip when it advertises it.Wallet / auto-pay not deployed.
12/13skip entirely when the[stage-wallet]/[stage-pay]sections,taler-wallet-clior thepepsi-walletsaccount are absent (re-run01-deploy.sh).12’swithdrawcredit leg skips without a[demobank]account (see above); itspull/pushcases skip without a usable local wallet.13skips whenDAVEis unset, the genuine demand is not produced (paywalled hop or merchant down), or — for A2 — the per-user wallet cannot be funded.
19.6. Constraints¶
Testing must never send real mail to third-party MX hosts: the suite uses only
the operators’ own domains and reserved/.invalid names (e.g. the DELAY test
relays to an unrouteable 192.0.2.0/24 TEST-NET-1 address via a recipient in
blackhole.invalid). The development uid cannot bind the privileged ports
25/53, so the privileged listeners are exercised only on the deployed host.