Implements all phases of test-improvement.md: - docker-compose.yml: postgres + aiohttp mocks + prosody + optional tester, using exact template placeholder names (fixes latent fcm_api_url/del_api_url envsubst bug). - tests/mocks: single aiohttp backend (auth, FCM, delfile, avatar, file-share) with /__requests capture API and /__fcm_error for prune tests. - tests/Dockerfile + tests/requirements.txt: tester image. - conftest: mock_client fixture, --mock-url; VNCXmppClient now honors XMPP_HOST/PORT (slixmpp was SRV-resolving the JID domain); RESTInjector sends Host: <domain> (prosody routes HTTP by Host header). - test_08_image_patches: docker-exec grep of every patched upstream file. - test_09_http_sideeffects: FCM (1:1 + MUC), delfile, vcard-avatar, receipts. - test_10_smacks: enable/ack, resume-replay, hibernation-expiry (offline variant xfail until fork is replaced by upstream mod_smacks_offline). - test_11_smokes: filter_chatstates, idlecompat, http_altconnect, webpresence, admin-telnet non-loopback. - test_12_image_runtime: http_upload slot handshake, healthcheck exit-2, no-residual-placeholder config check. - test_06_postgres: real kick-row assertion via unregister IQ. - test_02_muc: vcard_muc get/set; test_03_vnctalk: muc_hook non-joined affiliate. - startup.sh + template: SMACKS_HIBERNATION_TIME as a proper global config var (default 300) so SMACKS expiry tests can lower it. - Makefile (make up/test/down/logs) and run-tests.sh updated. Validated end-to-end against the compose stack: 81 passed, 1 xfailed, 1 pre-existing vcard_fallback failure (unrelated), 3 skipped. Part-of: <http://gitlab.vnc.biz/uxf/vnctalk-prosody/-/merge_requests/3>
138 lines
5.7 KiB
Python
138 lines
5.7 KiB
Python
"""Image/runtime verification (test-improvement.md Phase 6).
|
|
|
|
* mod_http_upload_external slot handshake + signed-URL PUT (live XMPP + mock)
|
|
* healthcheck.sh exit 2 on a tampered cert (docker compose exec)
|
|
* rendered config has no residual ${...} placeholders (docker compose exec)
|
|
|
|
The two docker-exec tests automate MANUAL §3.1 and the latent envsubst bug
|
|
documented in test-improvement.md §1.2. They skip when the compose stack is
|
|
not running.
|
|
"""
|
|
import asyncio
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import uuid
|
|
import xml.etree.ElementTree as ET
|
|
from urllib.parse import urlparse
|
|
|
|
import pytest
|
|
|
|
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
|
|
|
|
def _compose_available():
|
|
if not shutil.which("docker"):
|
|
return False
|
|
try:
|
|
res = subprocess.run(
|
|
["docker", "compose", "ps", "--services", "--filter", "status=running"],
|
|
cwd=REPO_ROOT, capture_output=True, text=True, timeout=20,
|
|
)
|
|
except (subprocess.SubprocessError, OSError):
|
|
return False
|
|
return res.returncode == 0 and "prosody" in res.stdout.split()
|
|
|
|
|
|
def _compose_exec(args, timeout=30, user=None):
|
|
cmd = ["docker", "compose", "exec"]
|
|
if user:
|
|
cmd += ["--user", user]
|
|
cmd += ["-T", "prosody"] + args
|
|
return subprocess.run(cmd, cwd=REPO_ROOT, capture_output=True, text=True, timeout=timeout)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestHttpUploadExternal:
|
|
"""mod_http_upload_external slot handshake (XEP-0363) + file PUT."""
|
|
|
|
async def test_http_upload_slot_handshake(self, xmpp_client, xmpp_config, mock_client):
|
|
domain = xmpp_config.get("domain")
|
|
if not domain:
|
|
pytest.skip("No XMPP domain configured")
|
|
|
|
# XEP-0363 slot request is an IQ-get to the vhost (http_upload_external
|
|
# is loaded on the VirtualHost and hooks iq/host/<ns>:request).
|
|
iq = xmpp_client.make_iq_get(ito=domain)
|
|
req = ET.SubElement(iq.xml, "{urn:xmpp:http:upload:0}request")
|
|
req.set("filename", "smoke.txt")
|
|
req.set("size", "4")
|
|
res = await iq.send(timeout=10)
|
|
|
|
slot = res.xml.find("{urn:xmpp:http:upload:0}slot")
|
|
assert slot is not None, "no <slot> in http_upload response — module not loaded"
|
|
put_el = slot.find("{urn:xmpp:http:upload:0}put")
|
|
get_el = slot.find("{urn:xmpp:http:upload:0}get")
|
|
assert put_el is not None and get_el is not None, "slot missing put/get urls"
|
|
put_url = put_el.text or put_el.get("url")
|
|
get_url = get_el.text or get_el.get("url")
|
|
assert put_url and "/share/" in put_url, f"put_url not on mock share: {put_url}"
|
|
|
|
# The returned URLs reference the internal `mocks` host. Re-PUT through
|
|
# the mock_client session (same container network) by path so the test
|
|
# works whether run from the tester container or the host.
|
|
put_path = urlparse(put_url).path
|
|
await mock_client.reset()
|
|
async with mock_client.session.put(
|
|
f"{mock_client.base_url}{put_path}", data=b"DATA"
|
|
) as r:
|
|
assert r.status == 201, f"PUT to slot target failed: {r.status}"
|
|
|
|
captured = await mock_client.wait_for(put_path, count=1, timeout=5)
|
|
assert captured, "slot PUT was not recorded by the mock"
|
|
assert captured[0]["method"] == "PUT"
|
|
|
|
|
|
class TestHealthcheckExecution:
|
|
"""Run healthcheck.sh inside the container with a tampered cert."""
|
|
|
|
def test_healthcheck_exit2_on_cert_mismatch(self):
|
|
if not _compose_available():
|
|
pytest.skip("compose stack not running")
|
|
# Backup current tls.crt (if any) so we can restore the original state.
|
|
script = (
|
|
"cp /etc/prosody/certs/prosody-ssl.pem /tmp/healthcheck_orig.pem 2>/dev/null; "
|
|
"mkdir -p /etc/tls-update; "
|
|
"echo tampered-cert-$(date +%s) > /etc/tls-update/tls.crt; "
|
|
"/vnc/config/healthcheck.sh >/dev/null 2>&1; echo $?; "
|
|
"cp /tmp/healthcheck_orig.pem /etc/tls-update/tls.crt 2>/dev/null"
|
|
)
|
|
res = _compose_exec(["sh", "-c", script], user="0:0", timeout=30)
|
|
out = res.stdout.strip().splitlines()
|
|
code = out[-1] if out else ""
|
|
assert code == "2", (
|
|
f"healthcheck exited {code!r} on tampered cert, expected 2. stdout={res.stdout!r}"
|
|
)
|
|
|
|
def test_healthcheck_not_exit2_after_restore(self):
|
|
if not _compose_available():
|
|
pytest.skip("compose stack not running")
|
|
# Ensure tls.crt matches the loaded cert (copy ssl.pem into tls.crt),
|
|
# then healthcheck must NOT exit 2 (it may exit 0/1 depending on telnet).
|
|
script = (
|
|
"mkdir -p /etc/tls-update; "
|
|
"cp /etc/prosody/certs/prosody-ssl.pem /etc/tls-update/tls.crt; "
|
|
"/vnc/config/healthcheck.sh >/dev/null 2>&1; echo $?"
|
|
)
|
|
res = _compose_exec(["sh", "-c", script], user="0:0", timeout=30)
|
|
out = res.stdout.strip().splitlines()
|
|
code = out[-1] if out else ""
|
|
assert code != "2", (
|
|
f"healthcheck exited 2 with matching certs, expected 0/1. stdout={res.stdout!r}"
|
|
)
|
|
|
|
|
|
class TestConfigRendering:
|
|
"""No residual ${...} placeholders in the rendered config."""
|
|
|
|
def test_config_rendering_no_residual_placeholders(self):
|
|
if not _compose_available():
|
|
pytest.skip("compose stack not running")
|
|
res = _compose_exec(["sh", "-c", "grep -c '\\${' /etc/prosody/prosody.cfg.lua || true"], timeout=20)
|
|
out = res.stdout.strip()
|
|
count = int(out) if out.isdigit() else 0
|
|
assert count == 0, (
|
|
f"rendered config has {count} residual ${{...}} placeholders — "
|
|
"an env var is missing or misnamed (see test-improvement.md §1.2)"
|
|
)
|