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:
@@ -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)
|
||||
Reference in New Issue
Block a user