Skip to content

Commit 0ae519e

Browse files
committed
smoke: add end-to-end test suite driving the pw binary
The Go unit tests exercise packages in isolation, but nothing runs the compiled pw binary the way a real deployment does. Add a smoke suite that drives it as a black box against a live SQLite database, covering the admin commands, the mail ingress paths, the async webhook bus and the REST API together. A shared _init.sh, sourced by every test, re-executes itself into a rootless user and network namespace so the ingress, http and webhook sink daemons bind fixed loopback ports without clashing with the host or a concurrent run, and falls back to a non-isolated run when unprivileged user namespaces are unavailable. It also provides logging, assertions, service lifecycle and a small Go webhook sink, launched with go run, that checks the HMAC-SHA256 signature of every delivery. Each test sets up its own data through pw admin commands, poking into the database only to insert an API token, which the admin CLI cannot create. admin_test walks the pw admin, config and db subcommands, ingress_test covers the single message, mbox and SMTP ingestion paths, and api_test drives anonymous and authenticated API reads and writes. The suite runs from make test. Signed-off-by: Robin Jarry <robin@jarry.cc>
1 parent d732729 commit 0ae519e

8 files changed

Lines changed: 611 additions & 2 deletions

File tree

.github/workflows/ci.yml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,11 @@ jobs:
2727
- uses: actions/setup-go@v6
2828
with:
2929
go-version-file: go.mod
30-
- run: make test
30+
- run: |
31+
set -xe
32+
sudo add-apt-repository -y ppa:git-core/ppa
33+
sudo apt-get install --no-install-recommends -qy git-email libemail-valid-perl libmailtools-perl
34+
make test
3135
3236
lint:
3337
runs-on: ubuntu-latest

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/patchwork.toml
22
/pw
3+
/.testdata
34
*_templ.go
45
/docs/.venv
56
/docs/_build

Makefile

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,9 +88,13 @@ golangci_lint ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2
8888
license_exclude = *.md *.asc *.yaml docs/requirements.txt *.service CONTRIBUTORS LICENSE .* go.mod go.sum pkg/mail/testdata docs/deployment/nginx.conf docs/deployment/js_challenge.lua
8989

9090
.PHONY: test
91-
test:
91+
test: pw
9292
$(GO) generate ./...
9393
$(GO) test ./...
94+
$Q for t in smoke/*_test.sh; do \
95+
echo "[smoke] $$t"; \
96+
"$$t" || exit 1; \
97+
done
9498

9599
.PHONY: lint
96100
lint:

smoke/_init.sh

Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
# Patchwork - automated patch tracking system
2+
# Copyright (C) The Patchwork Contributors (see CONTRIBUTORS)
3+
#
4+
# SPDX-License-Identifier: GPL-2.0-or-later
5+
6+
# Shared library sourced by every smoke test. It re-executes the test inside a
7+
# rootless network namespace, exposes reusable helpers (logging, assertions,
8+
# service lifecycle, webhook verification) and installs a cleanup trap. It is
9+
# not meant to be executed on its own.
10+
11+
# Re-exec the test into a private user+network namespace so that the ingress,
12+
# http and webhook-sink daemons bind fixed loopback ports without clashing with
13+
# the host or a concurrent run. Fall back to a non-isolated run when
14+
# unprivileged user namespaces are unavailable.
15+
if [ -z "${SMOKE_UNSHARED:-}" ]; then
16+
if unshare --user --map-root-user --net true 2>/dev/null; then
17+
export SMOKE_UNSHARED=1
18+
exec unshare --user --map-root-user --net -- "$0" "$@"
19+
else
20+
export SMOKE_UNSHARED=0
21+
echo "warning: unprivileged user namespaces unavailable," \
22+
"running without network isolation" >&2
23+
fi
24+
fi
25+
26+
set -e -o pipefail
27+
trap '' PIPE
28+
29+
if [ "${SMOKE_UNSHARED:-0}" = 1 ]; then
30+
ip link set lo up
31+
fi
32+
33+
REPO=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
34+
export PATH="$REPO:$PATH"
35+
36+
NAME=$(basename "$0" .sh)
37+
: "${WORKDIR:=$REPO/.testdata/$NAME}"
38+
: "${DB:=$WORKDIR/smoke.db}"
39+
: "${INGRESS_ADDR:=127.0.0.1:2525}"
40+
: "${HTTP_ADDR:=127.0.0.1:8080}"
41+
: "${SINK_ADDR:=127.0.0.1:9099}"
42+
: "${API:=http://$HTTP_ADDR/api/1.4}"
43+
: "${LISTID:=smoke.lists.example.com}"
44+
: "${SECRET:=smoke-secret}"
45+
: "${TOKEN:=0123456789abcdef0123456789abcdef01234567}"
46+
: "${PASSWORD:=smoke-pass}"
47+
48+
PIDS=()
49+
HTTP_CODE=""
50+
HTTP_BODY=""
51+
52+
if [ -t 1 ]; then
53+
C_SECTION='\033[1;35m'
54+
C_INFO='\033[32m'
55+
C_LOG='\033[34m'
56+
C_TRACE='\033[36m'
57+
C_OK='\033[1;32m'
58+
C_ERR='\033[1;31m'
59+
C_OFF='\033[0m'
60+
else
61+
C_SECTION=""
62+
C_INFO=""
63+
C_LOG=""
64+
C_TRACE=""
65+
C_OK=""
66+
C_ERR=""
67+
C_OFF=""
68+
fi
69+
70+
section() { printf '%b==> %s%b\n' "$C_SECTION" "$*" "$C_OFF"; }
71+
info() { printf '%b--- %s%b\n' "$C_INFO" "$*" "$C_OFF"; }
72+
ok() { printf '%bok: %s%b\n' "$C_OK" "$*" "$C_OFF"; }
73+
fail() { printf '%bfail: %s%b\n' "$C_ERR" "$*" "$C_OFF" >&2; return 1; }
74+
75+
xtrace() {
76+
printf '%b+ %s%b\n' "$C_TRACE" "$*" "$C_OFF" >&2
77+
command "$@"
78+
}
79+
80+
assert_eq() {
81+
if [ "$1" != "$2" ]; then
82+
fail "${3:-assertion}: expected '$1', got '$2'"
83+
fi
84+
}
85+
86+
assert_contains() {
87+
case "$1" in
88+
*"$2"*) ;;
89+
*) fail "${3:-assertion}: '$2' not found" ;;
90+
esac
91+
}
92+
93+
assert_http() {
94+
assert_eq "$1" "$HTTP_CODE" "HTTP status"
95+
}
96+
97+
# Kill a process and all of its descendants, children before parents, so that
98+
# "go run" wrappers take their compiled child down with them.
99+
kill_tree() {
100+
local pid="$1" sig="${2:-TERM}" child
101+
for child in $(pgrep -P "$pid" 2>/dev/null); do
102+
kill_tree "$child" "$sig"
103+
done
104+
kill "-$sig" "$pid" 2>/dev/null || true
105+
}
106+
107+
# Terminate a process tree, escalating to SIGKILL after a grace period, and
108+
# reap the leader.
109+
kill_wait() {
110+
local pid="$1" secs="${2:-10}" i=0
111+
kill -0 "$pid" 2>/dev/null || return 0
112+
kill_tree "$pid" TERM
113+
while kill -0 "$pid" 2>/dev/null; do
114+
if [ "$i" -ge "$((secs * 10))" ]; then
115+
kill_tree "$pid" KILL
116+
break
117+
fi
118+
sleep 0.1
119+
i=$((i + 1))
120+
done
121+
wait "$pid" 2>/dev/null || true
122+
}
123+
124+
# Wait until something is listening on the given TCP port.
125+
wait_port() {
126+
local port="$1" timeout="${2:-10}"
127+
SECONDS=0
128+
while ! ss -tlnH sport = ":$port" | grep -q .; do
129+
if [ "$SECONDS" -gt "$timeout" ]; then
130+
fail "nothing listening on port $port after ${timeout}s"
131+
fi
132+
sleep 0.1
133+
done
134+
}
135+
136+
require_tools() {
137+
local tool missing=0
138+
for tool in pw go git jq curl ss sqlite3; do
139+
if ! command -v "$tool" >/dev/null 2>&1; then
140+
echo "error: required tool not found: $tool" >&2
141+
missing=1
142+
fi
143+
done
144+
if ! git send-email --help >/dev/null 2>&1; then
145+
echo "error: git send-email is not available" >&2
146+
missing=1
147+
fi
148+
if [ "$missing" -ne 0 ]; then
149+
fail "missing required tools"
150+
fi
151+
}
152+
153+
# Wipe the per-test work directory, write its config and create the schema.
154+
db_init() {
155+
require_tools
156+
rm -rf "$WORKDIR"
157+
mkdir -p "$WORKDIR"
158+
cat >"$WORKDIR/patchwork.toml" <<-EOF
159+
[database]
160+
url = "sqlite://$DB"
161+
auto-sync = true
162+
163+
[ingress]
164+
listen = "$INGRESS_ADDR"
165+
166+
[http]
167+
listen = "$HTTP_ADDR"
168+
EOF
169+
export PATCHWORK_TOML="$WORKDIR/patchwork.toml"
170+
pw db sync
171+
}
172+
173+
db_count() {
174+
sqlite3 "$DB" "SELECT count(*) FROM $1;"
175+
}
176+
177+
pw() {
178+
xtrace pw "$@"
179+
}
180+
181+
set_password() {
182+
printf '%s\n' "$PASSWORD"
183+
sleep 0.1
184+
printf '%s\n' "$PASSWORD"
185+
}
186+
187+
ingress_start() {
188+
pw ingress -l "$LISTID" &
189+
INGRESS_PID=$!
190+
PIDS+=("$INGRESS_PID")
191+
wait_port "${INGRESS_ADDR##*:}"
192+
}
193+
194+
http_start() {
195+
pw http &
196+
HTTP_PID=$!
197+
PIDS+=("$HTTP_PID")
198+
wait_port "${HTTP_ADDR##*:}"
199+
}
200+
201+
# --- webhook sink ---------------------------------------------------------
202+
203+
sink_start() {
204+
xtrace go run "$REPO/smoke/webhook-sink.go" \
205+
-addr "$SINK_ADDR" -secret "$SECRET" -log "$WORKDIR/webhook.log" \
206+
> >(awk "{print \"$C_LOG\" \$0 \"$C_OFF\"}") &
207+
SINK_PID=$!
208+
PIDS+=("$SINK_PID")
209+
wait_port "${SINK_ADDR##*:}"
210+
}
211+
212+
webhook_count() {
213+
local category="${1:-}"
214+
if [ ! -f "$WORKDIR/webhook.log" ]; then
215+
echo 0
216+
return
217+
fi
218+
if [ -n "$category" ]; then
219+
grep -c "\"event\":\"$category\"" "$WORKDIR/webhook.log" || true
220+
else
221+
grep -c . "$WORKDIR/webhook.log" || true
222+
fi
223+
}
224+
225+
wait_webhook() {
226+
local category="$1" count="${2:-1}" timeout="${3:-10}"
227+
SECONDS=0
228+
while [ "$(webhook_count "$category")" -lt "$count" ]; do
229+
if [ "$SECONDS" -gt "$timeout" ]; then
230+
fail "expected >=$count '$category' webhooks, got $(webhook_count "$category")"
231+
fi
232+
sleep 0.2
233+
done
234+
}
235+
236+
assert_webhook_signed() {
237+
if grep -q '"sig_ok":false' "$WORKDIR/webhook.log" 2>/dev/null; then
238+
fail "some webhook deliveries had an invalid signature"
239+
fi
240+
}
241+
242+
# --- HTTP API -------------------------------------------------------------
243+
244+
# http_json <method> <path> [token] [json body]. Sets HTTP_CODE and HTTP_BODY.
245+
http_json() {
246+
local method="$1" path="$2" token="${3:-}" body="${4:-}"
247+
local args=(-sS -X "$method" -H 'Content-Type: application/json' -w $'\n%{http_code}')
248+
if [ -n "$token" ]; then
249+
args+=(-H "Authorization: Bearer $token")
250+
fi
251+
if [ -n "$body" ]; then
252+
args+=(-d "$body")
253+
fi
254+
local out
255+
out=$(curl "${args[@]}" "$API/$path")
256+
HTTP_CODE=${out##*$'\n'}
257+
HTTP_BODY=${out%$'\n'*}
258+
}
259+
260+
api_get() { http_json GET "$1" "${2:-}"; }
261+
api_post() { http_json POST "$1" "$2" "$3"; }
262+
api_patch() { http_json PATCH "$1" "$2" "$3"; }
263+
264+
# --- cleanup --------------------------------------------------------------
265+
266+
cleanup() {
267+
local status=$? pid
268+
set +e
269+
for pid in "${PIDS[@]}"; do
270+
kill_wait "$pid" 15
271+
done
272+
if [ "$status" -eq 0 ]; then
273+
ok "$NAME passed"
274+
else
275+
printf '%bfail: %s failed (status=%s)%b\n' \
276+
"$C_ERR" "$NAME" "$status" "$C_OFF" >&2
277+
fi
278+
exit "$status"
279+
}
280+
trap cleanup EXIT
281+
282+
section "$NAME"

0 commit comments

Comments
 (0)