Add STQ-05 bounce/DSN live test and fix Store-package isolation

queue-bounce-expiry-check.py exercises the exhausted-retry half of
STQ-05: enqueue-remote to the 999 hold target, force expiry with
bongo-queuetool expire, and confirm exactly one correctly-formatted
RFC delivery-status DSN reaches the sender's INBOX. The
systemctl-restart management half of STQ-05
(queue-lifecycle-check.sh) is deliberately not wired in -- it targets
a real systemd-managed bongo.service, which is exactly the mistake
this suite must never repeat against a live-instance fixture.

Also: every Python live test that imports bongo.* was silently
resolving against whatever "bongo" package happened to already be on
the default sys.path (e.g. a stale production install), not this
scratch instance's own freshly-installed copy -- the same
isolation-breach class as the hardcoded production paths/ports fixed
earlier. BONGO_LIVE_TEST_ENVIRONMENT now sets PYTHONPATH to the
scratch prefix's PYTHON_SITEPACKAGES_PATH.
This commit is contained in:
Mario Fetka
2026-08-02 10:16:43 +02:00
parent e19cc43520
commit aebe2aa2bd
2 changed files with 230 additions and 1 deletions
+13 -1
View File
@@ -36,7 +36,7 @@ add_test(NAME live-instance-setup
--password "${BONGO_LIVE_TEST_PASSWORD}"
--user test1 --user test2
--user pop3test1 --user pop3test2
--user imapquota --user stqquota
--user imapquota --user stqquota --user stqbounce
setup)
set_tests_properties(live-instance-setup PROPERTIES
FIXTURES_SETUP live_instance
@@ -63,6 +63,16 @@ set(BONGO_LIVE_INSTANCE_PREFIX "/tmp/bongo-live-instance/prefix")
# Matches live-instance-fixture.py's fixed +10000 port offset (see ports()
# in that script) and the test1/test2 accounts setup provisions.
set(BONGO_LIVE_TEST_ENVIRONMENT
# Without this, `import bongo...` in every Python live test resolves
# against whatever "bongo" package happens to be on the system's
# default sys.path (e.g. a production install at
# /usr/lib/python3.*/site-packages/bongo) instead of this scratch
# instance's own freshly-installed copy at
# PYTHON_SITEPACKAGES_PATH -- silently running tests against
# mismatched, potentially older client code. Same isolation-breach
# class as the hardcoded production paths/ports fixed elsewhere in
# this file.
"PYTHONPATH=${PYTHON_SITEPACKAGES_PATH}"
"BONGO_ALLOW_LIVE_USER_TEST=1"
"BONGO_ALLOW_LIVE_SMTP_TEST=1"
"BONGO_TEST_PASSWORD=${BONGO_LIVE_TEST_PASSWORD}"
@@ -206,6 +216,8 @@ bongo_add_live_test_env(collector-quota-retry collector-quota-retry-check.py
"BONGO_TEST_USER=stqquota")
bongo_add_live_test(dkim-material dkim-material-check.py)
bongo_add_live_test(protocol-smoke protocol-smoke.py)
bongo_add_live_test_env(queue-bounce-expiry queue-bounce-expiry-check.py
"BONGO_TEST_USER=stqbounce")
bongo_add_live_test(queue-quota-partial queue-quota-partial-check.py)
bongo_add_live_test_env(sendmail-local-submission sendmail-local-submission-check.py
"BONGO_TEST_USER1=pop3test1;BONGO_TEST_USER2=pop3test2")
@@ -0,0 +1,217 @@
#!/usr/bin/env python3
# This program is free software, licensed under the terms of the GNU GPL.
# See the Bongo COPYING file for full details.
# Copyright (c) 2026 Bongo Project contributors
"""Verify that an exhausted-retry Queue entry produces exactly one RFC
delivery-status bounce in the sender's Store INBOX (STQ-05's expiry/DSN
half). The management half of STQ-05 (hold/restart/release/delete against a
real systemd-managed bongo.service) is deliberately not reproduced here --
it assumes a production-style installation this live-instance fixture does
not model, and issuing systemctl against a real service from a test is
exactly the mistake this suite must never repeat."""
from __future__ import annotations
import os
import re
import subprocess
import sys
import tempfile
import time
from pathlib import Path
from bongo.store.StoreClient import StoreClient
USER = os.environ.get("BONGO_TEST_USER", "stqbounce")
DOMAIN = os.environ.get("BONGO_TEST_DOMAIN", "bongo.test")
PASSWORD = os.environ.get("BONGO_TEST_PASSWORD", "")
HOST = os.environ.get("BONGO_TEST_STORE_HOST", "127.0.0.1")
STORE_PORT = int(os.environ.get("BONGO_TEST_STORE_PORT", "689"))
QUEUE_TOOL = os.environ.get(
"BONGO_TEST_QUEUE_TOOL", "/usr/bin/bongo-queuetool"
)
TIMEOUT = float(os.environ.get("BONGO_TEST_TIMEOUT", "60"))
ALLOW_LIVE = os.environ.get("BONGO_ALLOW_LIVE_USER_TEST") == "1"
RECIPIENT = "nobody@retry.invalid"
TOKEN = f"stq05-bounce-{os.getpid()}-{int(time.time())}"
class QueueBounceError(RuntimeError):
"""Raised when the exhausted-retry bounce contract is not met."""
def _bongo_queue_argv(*arguments: str) -> list[str]:
# bongo-admin's IsAdministrativeUser() (src/apps/admin/admin.c) accepts
# either real root or a euid matching the compiled BONGO_USER -- a
# live-instance-fixture.py instance compiles that as the invoking
# user's own name (CTEST_BUILD_NOROOT), so sudo is never actually
# required there; the queue tool needs no escalation either.
# bongo-queuetool's own --host/--port default to "localhost"/8670 --
# the *production* queue agent's port, not this instance's compiled
# BONGO_QUEUE_PORT.
host_port = [
"--host", os.environ.get("BONGO_TEST_QUEUE_HOST", "localhost"),
"--port", os.environ.get("BONGO_TEST_QUEUE_PORT", "8670"),
]
if os.geteuid() == 0 or os.environ.get("BONGO_TEST_ADMIN_UNPRIVILEGED") == "1":
return [QUEUE_TOOL, *host_port, *arguments]
return ["sudo", "-n", "-u", "bongo", QUEUE_TOOL, *host_port, *arguments]
def run_queue(*arguments: str, check: bool = True) -> subprocess.CompletedProcess:
completed = subprocess.run(
_bongo_queue_argv(*arguments),
check=False,
capture_output=True,
text=True,
)
if check and completed.returncode:
detail = completed.stderr.strip() or completed.stdout.strip()
raise QueueBounceError(f"{' '.join(arguments)} failed: {detail}")
return completed
def create_message() -> bytes:
return (
f"From: STQ-05 <{USER}@{DOMAIN}>\r\n"
f"To: {RECIPIENT}\r\n"
f"Subject: STQ-05 exhausted-retry bounce {TOKEN}\r\n"
"Date: Thu, 23 Jul 2026 12:34:56 +0200\r\n"
f"Message-ID: <{TOKEN}@{DOMAIN}>\r\n"
f"X-STQ-05-Token: {TOKEN}\r\n"
"\r\n"
f"Retry exhaustion and RFC delivery-status bounce {TOKEN}\r\n"
).encode()
def open_store() -> StoreClient:
return StoreClient(
USER, USER, authPassword=PASSWORD, host=HOST, port=STORE_PORT
)
def matching_documents(store: StoreClient) -> list[str]:
# List()'s ItemIterator is lazy and shares the same connection/stream
# as every other command; issuing Read() before it is fully consumed
# interleaves the READ response with the still-pending LIST response
# lines on the same stream and corrupts parsing. Collect the UIDs
# first, then issue Read() only after List() has been fully drained.
identifiers = [entry.uid for entry in store.List("/mail/INBOX")]
matches: list[str] = []
for identifier in identifiers:
try:
payload = store.Read(identifier)
except Exception:
continue
if TOKEN.encode() in payload:
matches.append(identifier)
return matches
def wait_for_one_bounce(store: StoreClient) -> tuple[str, bytes]:
deadline = time.monotonic() + TIMEOUT
while time.monotonic() < deadline:
matches = matching_documents(store)
if len(matches) == 1:
return matches[0], store.Read(matches[0])
if len(matches) > 1:
raise QueueBounceError(
f"expiry produced {len(matches)} DSNs instead of exactly one"
)
time.sleep(0.5)
raise QueueBounceError("expiry did not deliver a DSN within the timeout")
def require_safe_environment() -> None:
if not ALLOW_LIVE:
raise QueueBounceError(
"set BONGO_ALLOW_LIVE_USER_TEST=1 for the disposable live account"
)
if not PASSWORD:
raise QueueBounceError("BONGO_TEST_PASSWORD must be set")
def main() -> int:
require_safe_environment()
work = tempfile.mkdtemp(prefix="bongo-stq05-bounce-")
os.chmod(work, 0o755)
message_path = Path(work) / "message.eml"
message_path.write_bytes(create_message())
os.chmod(message_path, 0o644)
store = open_store()
queue_id: str | None = None
document: str | None = None
try:
# enqueue-remote creates the entry held in queue 999 (the
# unrouted-target sentinel), so the normal remote-delivery worker
# cannot race the deterministic "expire" command below.
queue_id = run_queue(
"enqueue-remote",
str(message_path),
f"{USER}@{DOMAIN}",
RECIPIENT,
"999",
).stdout.strip()
if not re.fullmatch(r"999-[0-9a-f]+", queue_id):
raise QueueBounceError(
f"unexpected held Queue ID: {queue_id!r}"
)
run_queue("expire", queue_id)
# expire's Queue-side entry is consumed by the expiry/DSN delivery
# itself; nothing is left to delete regardless of outcome.
queue_id = None
document, dsn = wait_for_one_bounce(store)
required = (
b"Subject: Returned mail: Delivery time exceeded",
f"Final-recipient: rfc822;{RECIPIENT}".encode(),
b"Action: failed",
b"Status: 5.4.7",
)
missing = [text for text in required if text not in dsn]
if missing:
raise QueueBounceError(
"generated DSN is missing: "
+ ", ".join(text.decode() for text in missing)
)
print(
"STQ-05 BOUNCE PASS "
f"recipient={RECIPIENT} status=5.4.7 action=failed "
f"document={document} duplicate=no"
)
return 0
finally:
if document is not None:
try:
store.Delete(document)
except Exception as error:
print(
f"STQ-05 bounce cleanup warning for {document}: {error}",
file=sys.stderr,
)
if queue_id is not None:
run_queue("delete", queue_id, check=False)
try:
store.Quit()
except Exception:
pass
try:
message_path.unlink()
Path(work).rmdir()
except OSError:
pass
if __name__ == "__main__":
try:
raise SystemExit(main())
except (OSError, QueueBounceError) as error:
print(f"STQ-05 bounce expiry check: {error}", file=sys.stderr)
raise SystemExit(1)