diff --git a/tests/mumble/PARITY.md b/tests/mumble/PARITY.md new file mode 100644 index 0000000..bcf9554 --- /dev/null +++ b/tests/mumble/PARITY.md @@ -0,0 +1,50 @@ +# mumble — recipe-maintainer → cc-ci parity (Phase 2 P2) + +Source corpus: `references/recipe-maintainer/recipe-info/mumble/tests/` (`/srv/recipe-maintainer/ +recipe-info/mumble/`). mumble is a TLS **voice** server (port 64738), not an HTTP app, plus an +optional `mumble-web` HTTP client overlay. cc-ci deploys it with +`COMPOSE_FILE=compose.yml:compose.mumbleweb.yml:compose.host-ports.yml` (recipe_meta.EXTRA_ENV): +the web overlay gives the generic harness an HTTP readiness/serving signal; the host-ports overlay +publishes 64738 on the cc-ci host so the on-host (cc-ci-run) protocol tests connect to +127.0.0.1:64738. Both overlays are shipped by the upstream recipe (documented deployment mode). + +## Parity port mapping (P2 — every recipe-maintainer test has a comparable cc-ci test) + +| recipe-maintainer test (`recipe-info/mumble/tests/`) | what it verifies | cc-ci test | same thing? | +|---|---|---|---| +| `health_check.py` | mumble server listening on TCP 64738 | `functional/test_tcp_health.py` | yes — TCP connect to 64738 (host-published) | +| `mumble_connect.py` | full TLS protocol handshake: TLS connect, server Version, auth accepted (no Reject), channel list present, ServerSync handshake completes, welcome text | `functional/test_protocol_handshake.py` (+ `functional/_mumble_proto.py`, adapted from the corpus's stdlib protobuf/protocol code) | yes — same handshake; asserts tls_connect + version + auth_accepted + channel presence + ServerSync | +| `web_client.py` | mumble-web client reachable over HTTPS, HTTP 200, page contains `Mumble` + `config.js`, valid HTML | `functional/test_web_client.py` | yes — same 200 + body markers (`Mumble`, `config.js`, ``) | + +No recipe-maintainer mumble test is omitted — all three are ported. No `DECISIONS.md` non-port +entry is needed for mumble. + +## Recipe-specific functional tests (P3 — ≥2 beyond parity) + +mumble has no REST "create-an-object" API; its characteristic behaviour is the voice-server control +protocol and its deploy-time server configuration. Both new tests are **config round-trips** that +prove our deploy-time configuration propagated into the running murmur server and is enforced/ +delivered over the real protocol (version-independent — they assert OUR configured markers, not +hard-coded upstream values): + +1. `functional/test_welcome_text_roundtrip.py` — deploys with a unique `WELCOME_TEXT` marker + (`recipe_meta.EXTRA_ENV` → `MUMBLE_CONFIG_WELCOMETEXT`); asserts that exact marker surfaces in the + server's ServerSync `welcome_text` delivered to a connecting client. (create config → read back.) +2. `functional/test_server_config_limits.py` — deploys with a distinctive non-default `USERS=42` + (max-users cap → `MUMBLE_CONFIG_USERS`); asserts the server's ServerConfig message reports + `max_users == 42` (and a well-formed `allow_html`), proving the recipe wires deploy-time + server-capacity policy into the running server. + +## Backup data-integrity (P4 — real, recipe-aware) + +`ops.py` + `test_backup.py` + `test_restore.py`: seed a `ci_marker` row into the recipe's own state +DB `/data/mumble-server.sqlite` (the exact file the recipe's backupbot hooks `.backup`/restore), +back up, drop the marker (mutate), restore, and assert the row returns as `original`. Writes use +`PRAGMA busy_timeout` to wait out the running murmur server's transient sqlite locks. The upgrade +tier seeds `upgrade-survives` and asserts it survives the prev→PR-head chaos crossover. + +## Browser flow (P6) + +Not applicable as a primary UX: mumble's core UX is the native desktop client over the voice +protocol (covered by the protocol handshake tests). The mumble-web HTTP UI is asserted via +`test_web_client.py` (HTTP, no interactive flow to drive). No Playwright test. diff --git a/tests/mumble/functional/_mumble_proto.py b/tests/mumble/functional/_mumble_proto.py new file mode 100644 index 0000000..a98846c --- /dev/null +++ b/tests/mumble/functional/_mumble_proto.py @@ -0,0 +1,252 @@ +"""Minimal Mumble protocol client — vendored/adapted from the recipe-maintainer corpus +`recipe-info/mumble/tests/mumble_connect.py` (zero external deps; stdlib ssl/socket/struct only). + +Mumble has no HTTP API for its voice server, so the canonical "is it really working" check is the +control-channel TLS handshake: connect, send Version + Authenticate, and read messages until the +server completes the handshake with a ServerSync. This module performs that handshake and returns a +structured result the cc-ci functional tests (parity port + recipe-specific) assert against. + +On cc-ci the recipe is deployed with `compose.host-ports.yml`, which publishes 64738 directly on the +cc-ci host (`mode: host`); tests run on-host via cc-ci-run, so they connect to 127.0.0.1:64738. +""" + +from __future__ import annotations + +import socket +import ssl +import struct +import time + +PORT = 64738 + +# Message types (Mumble.proto) +MSG_VERSION = 0 +MSG_AUTHENTICATE = 2 +MSG_REJECT = 4 +MSG_SERVERSYNC = 5 +MSG_CHANNELSTATE = 7 +MSG_USERSTATE = 9 +MSG_SERVERCONFIG = 24 + +REJECT_TYPES = { + 0: "None", 1: "WrongVersion", 2: "InvalidUsername", 3: "WrongUserPW", + 4: "WrongServerPW", 5: "UsernameInUse", 6: "ServerFull", 7: "NoCertificate", + 8: "AuthenticatorFail", +} + +_HEADER = ">HI" +_HEADER_SIZE = 6 + + +def _enc_varint(value: int) -> bytes: + out = bytearray() + while value > 0x7F: + out.append((value & 0x7F) | 0x80) + value >>= 7 + out.append(value & 0x7F) + return bytes(out) + + +def _enc_field_varint(field: int, value: int) -> bytes: + return _enc_varint(field << 3) + _enc_varint(value) + + +def _enc_field_string(field: int, value: str) -> bytes: + raw = value.encode("utf-8") + return _enc_varint((field << 3) | 2) + _enc_varint(len(raw)) + raw + + +def _dec_varint(data: bytes, off: int) -> tuple[int, int]: + result = shift = 0 + while off < len(data): + b = data[off] + result |= (b & 0x7F) << shift + off += 1 + if not (b & 0x80): + break + shift += 7 + return result, off + + +def _dec_fields(data: bytes) -> dict: + fields: dict = {} + off = 0 + while off < len(data): + tag, off = _dec_varint(data, off) + field, wire = tag >> 3, tag & 0x07 + if wire == 0: + value, off = _dec_varint(data, off) + elif wire == 1: + value = struct.unpack_from(" None: + sock.sendall(struct.pack(_HEADER, msg_type, len(payload)) + payload) + + +def _recv(sock, timeout: float) -> tuple[int, bytes]: + sock.settimeout(timeout) + header = b"" + while len(header) < _HEADER_SIZE: + chunk = sock.recv(_HEADER_SIZE - len(header)) + if not chunk: + raise ConnectionError("connection closed reading header") + header += chunk + msg_type, length = struct.unpack(_HEADER, header) + payload = b"" + while len(payload) < length: + chunk = sock.recv(length - len(payload)) + if not chunk: + raise ConnectionError("connection closed reading payload") + payload += chunk + return msg_type, payload + + +def _build_version() -> bytes: + v = (1 << 16) | (5 << 8) | 0 # pretend client 1.5.0 + return (_enc_field_varint(1, v) + + _enc_field_string(2, "cc-ci mumble probe 1.0") + + _enc_field_string(3, "Linux")) + + +def _build_authenticate(username: str, password: str = "") -> bytes: + payload = _enc_field_string(1, username) + if password: + payload += _enc_field_string(2, password) + payload += _enc_field_varint(5, 1) # opus = true + return payload + + +def handshake(host: str = "127.0.0.1", port: int = PORT, username: str = "cc-ci-probe", + password: str = "", timeout: float = 20.0) -> dict: + """Full Mumble control-channel handshake. Returns a result dict: + + tls_connect (bool), server_version (dict|None), auth_accepted (bool), channels (list[str]), + users (list[str]), server_sync (bool), welcome_text (str|None), server_config (dict), + error (str|None). + """ + result = { + "tls_connect": False, "server_version": None, "auth_accepted": False, + "channels": [], "users": [], "server_sync": False, "welcome_text": None, + "server_config": {}, "error": None, + } + raw = tls = None + try: + raw = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + raw.settimeout(timeout) + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + ctx.minimum_version = ssl.TLSVersion.TLSv1_2 + tls = ctx.wrap_socket(raw, server_hostname=host) + tls.connect((host, port)) + result["tls_connect"] = True + + _send(tls, MSG_VERSION, _build_version()) + _send(tls, MSG_AUTHENTICATE, _build_authenticate(username, password)) + + channels: dict = {} + users: dict = {} + deadline = time.time() + timeout + # Murmur usually sends ServerConfig around ServerSync; the exact order varies by version. + # So we don't stop at ServerSync — once it arrives we keep reading for a short grace window + # to also capture ServerConfig (used by the recipe-specific config round-trip tests), then + # stop. If both arrive we stop immediately. + grace_deadline = None + while time.time() < deadline: + if grace_deadline is not None and time.time() >= grace_deadline: + break + if result["server_sync"] and result["server_config"]: + break + remaining = deadline - time.time() + if grace_deadline is not None: + remaining = min(remaining, grace_deadline - time.time()) + if remaining <= 0: + break + try: + msg_type, payload = _recv(tls, timeout=remaining) + except (socket.timeout, ConnectionError): + break + if msg_type == MSG_VERSION: + f = _dec_fields(payload) + v1 = f.get(1, 0) + result["server_version"] = { + "string": f"{(v1 >> 16) & 0xFF}.{(v1 >> 8) & 0xFF}.{v1 & 0xFF}", + "release": f.get(2, ""), "os": f.get(3, ""), + } + elif msg_type == MSG_REJECT: + f = _dec_fields(payload) + result["error"] = (f"Rejected: {REJECT_TYPES.get(f.get(1, 0), 'Unknown')} " + f"— {f.get(2, '')}") + return result + elif msg_type == MSG_CHANNELSTATE: + f = _dec_fields(payload) + ch_id = f.get(1, 0) + channels[ch_id] = f.get(3, f"channel-{ch_id}") + elif msg_type == MSG_USERSTATE: + f = _dec_fields(payload) + name = f.get(3, "") + if name: + users[f.get(1, 0)] = name + elif msg_type == MSG_SERVERCONFIG: + f = _dec_fields(payload) + # ServerConfig fields: 1 max_bandwidth, 2 welcome_text, 3 allow_html, + # 4 message_length, 5 image_message_length, 6 max_users + result["server_config"] = { + "max_bandwidth": f.get(1), "welcome_text": f.get(2), + "allow_html": f.get(3), "message_length": f.get(4), + "image_message_length": f.get(5), "max_users": f.get(6), + } + elif msg_type == MSG_SERVERSYNC: + f = _dec_fields(payload) + result["welcome_text"] = f.get(3, "") + result["server_sync"] = True + result["auth_accepted"] = True + # keep reading a short grace window to catch a trailing ServerConfig + if grace_deadline is None: + grace_deadline = time.time() + 3.0 + + result["channels"] = list(channels.values()) + result["users"] = list(users.values()) + if not result["server_sync"] and not result["error"]: + result["error"] = "timed out waiting for ServerSync" + except (ConnectionError, ssl.SSLError, OSError) as e: + result["error"] = f"{type(e).__name__}: {e}" + finally: + if tls is not None: + try: + tls.shutdown(socket.SHUT_RDWR) + except OSError: + pass + tls.close() + elif raw is not None: + raw.close() + return result + + +def retry_handshake(attempts: int = 12, interval: float = 5.0, **kwargs) -> dict: + """Retry the handshake until ServerSync completes (cold-boot readiness) or attempts exhaust. + Returns the last result (caller asserts on it).""" + last: dict = {} + for _ in range(attempts): + last = handshake(**kwargs) + if last.get("server_sync"): + return last + time.sleep(interval) + return last diff --git a/tests/mumble/functional/test_protocol_handshake.py b/tests/mumble/functional/test_protocol_handshake.py new file mode 100644 index 0000000..a0aa49f --- /dev/null +++ b/tests/mumble/functional/test_protocol_handshake.py @@ -0,0 +1,31 @@ +"""mumble — protocol integration parity port (Phase 2 P2 + §4.3 recipe-specific depth). + +SOURCE: recipe-info/mumble/tests/mumble_connect.py — "connects via TLS, authenticates, verifies +server version, channel list, welcome text, and ServerSync handshake. Zero external dependencies." + +This is mumble's characteristic "is it really working" check: the voice server has no HTTP API, so +the meaningful liveness/behaviour proof is the control-channel handshake — a TLS connection that is +ACCEPTED (anonymous auth, no Reject), receives the server Version, sees the root channel +(channel presence — §4.3 "channel presence beyond TCP health"), and completes with ServerSync. +""" + +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.dirname(__file__)) +import _mumble_proto # noqa: E402 + + +def test_handshake_completes_with_channel_presence(live_app): + r = _mumble_proto.retry_handshake(attempts=12, interval=5.0) + + assert r["tls_connect"], f"TLS connection to 127.0.0.1:64738 failed — {r.get('error')}" + assert r["server_version"] is not None, "server did not send a Version message" + assert r["auth_accepted"], f"authentication not accepted — {r.get('error')}" + # Channel presence: the server must expose at least the root channel (beyond a bare TCP open). + assert len(r["channels"]) >= 1, ( + f"server reported no channels (expected >=1 root channel) — {r!r}" + ) + assert r["server_sync"], f"ServerSync handshake did not complete — {r.get('error')}" diff --git a/tests/mumble/functional/test_server_config_limits.py b/tests/mumble/functional/test_server_config_limits.py new file mode 100644 index 0000000..a6e2fbb --- /dev/null +++ b/tests/mumble/functional/test_server_config_limits.py @@ -0,0 +1,37 @@ +"""mumble — recipe-specific functional test #2 (Phase 2 P3, beyond parity). + +Server-limit config round-trip via the ServerConfig protocol message. The harness deploys with a +distinctive USERS value (recipe_meta.EXTRA_ENV -> MUMBLE_CONFIG_USERS, the server's max-users cap, +set to a non-default 42). A client that completes the handshake receives a ServerConfig message +carrying the server's enforced limits (max_users, allow_html, message_length, ...). Asserting +max_users == our configured value proves the recipe wires deploy-time limits into the running +server and enforces them — a distinctive, characteristic mumble behaviour (server capacity policy), +not health-only. Version-independent (asserts OUR configured value). +""" + +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.dirname(__file__)) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +import _mumble_proto # noqa: E402 +import recipe_meta # noqa: E402 + + +def test_configured_max_users_surfaces_in_serverconfig(live_app): + r = _mumble_proto.retry_handshake(attempts=12, interval=5.0) + + assert r["server_sync"], f"ServerSync handshake did not complete — {r.get('error')}" + cfg = r["server_config"] + assert cfg, f"server did not send a ServerConfig message — {r!r}" + assert cfg.get("max_users") == recipe_meta.MAX_USERS, ( + f"ServerConfig.max_users={cfg.get('max_users')!r} does not match the configured " + f"USERS={recipe_meta.MAX_USERS} — deploy-time server-limit config did not propagate" + ) + # allow_html defaults true in the recipe; assert it is present/boolean to prove the field set + # is the real ServerConfig (not an empty/garbled decode). + assert cfg.get("allow_html") in (0, 1), ( + f"ServerConfig.allow_html unexpected: {cfg.get('allow_html')!r}" + ) diff --git a/tests/mumble/functional/test_tcp_health.py b/tests/mumble/functional/test_tcp_health.py new file mode 100644 index 0000000..dd08171 --- /dev/null +++ b/tests/mumble/functional/test_tcp_health.py @@ -0,0 +1,26 @@ +"""mumble — TCP health parity port (Phase 2 P2). + +SOURCE: recipe-info/mumble/tests/health_check.py — "Confirms the mumble server is listening on +port 64738 via TCP connection test." The original SSHes to the server and probes localhost:64738; +here the cc-ci run executes on the cc-ci host (cc-ci-run) and the recipe is deployed with +compose.host-ports.yml, so 64738 is published on the host — we probe 127.0.0.1:64738 directly. +""" + +from __future__ import annotations + +import socket +import time + + +def test_mumble_listening_on_64738(live_app): + """The mumble voice server accepts a TCP connection on 64738 (host-published).""" + deadline = time.time() + 60 + last_err = None + while time.time() < deadline: + try: + with socket.create_connection(("127.0.0.1", 64738), timeout=10): + return # connected — server is listening + except OSError as e: # noqa: PERF203 + last_err = e + time.sleep(3) + raise AssertionError(f"mumble not listening on 127.0.0.1:64738 — last error: {last_err}") diff --git a/tests/mumble/functional/test_web_client.py b/tests/mumble/functional/test_web_client.py new file mode 100644 index 0000000..c566ce2 --- /dev/null +++ b/tests/mumble/functional/test_web_client.py @@ -0,0 +1,35 @@ +"""mumble — web client parity port (Phase 2 P2). + +SOURCE: recipe-info/mumble/tests/web_client.py — "Verifies the web client is reachable via HTTPS, +returns HTTP 200, and serves the Mumble Web UI with expected page content." Requires the +compose.mumbleweb.yml overlay (enabled via recipe_meta.EXTRA_ENV COMPOSE_FILE). +""" + +from __future__ import annotations + +import os +import ssl +import sys +import urllib.request + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "runner")) +from harness import http as harness_http # noqa: E402 + + +def _fetch_body(url: str) -> str: + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + with urllib.request.urlopen(urllib.request.Request(url), timeout=15, context=ctx) as resp: + return resp.read().decode("utf-8", errors="replace") + + +def test_web_client_serves_mumble_web_ui(live_app): + url = f"https://{live_app}/" + status, _ = harness_http.retry_http_get(url, expect_status=200, max_wait=120, interval=5) + assert status == 200, f"GET {url} HTTP {status} (expected 200)" + + body = _fetch_body(url) + assert "Mumble" in body, "web client page does not contain 'Mumble' content" + assert "config.js" in body, "web client page does not load config.js" + assert body.strip().startswith(""), "web client response is not valid HTML" diff --git a/tests/mumble/functional/test_welcome_text_roundtrip.py b/tests/mumble/functional/test_welcome_text_roundtrip.py new file mode 100644 index 0000000..62c60ab --- /dev/null +++ b/tests/mumble/functional/test_welcome_text_roundtrip.py @@ -0,0 +1,31 @@ +"""mumble — recipe-specific functional test #1 (Phase 2 P3, beyond parity). + +Config round-trip: the harness deploys the recipe with a unique WELCOME_TEXT marker +(recipe_meta.EXTRA_ENV -> MUMBLE_CONFIG_WELCOMETEXT). A client that completes the handshake +receives the server's welcome text in the ServerSync message. This proves our deploy-time +configuration actually propagated into the running murmur server AND is delivered to clients over +the real protocol — not just that a port is open. Version-independent (asserts OUR marker, not a +hard-coded upstream string). +""" + +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.dirname(__file__)) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +import _mumble_proto # noqa: E402 +import recipe_meta # noqa: E402 + + +def test_configured_welcome_text_surfaces_in_serversync(live_app): + marker = recipe_meta.WELCOME_TEXT_MARKER + r = _mumble_proto.retry_handshake(attempts=12, interval=5.0) + + assert r["server_sync"], f"ServerSync handshake did not complete — {r.get('error')}" + welcome = r["welcome_text"] or "" + assert marker in welcome, ( + f"configured welcome-text marker {marker!r} not present in the server's ServerSync " + f"welcome_text (got {welcome!r}) — deploy-time config did not propagate" + ) diff --git a/tests/mumble/ops.py b/tests/mumble/ops.py new file mode 100644 index 0000000..06c6ade --- /dev/null +++ b/tests/mumble/ops.py @@ -0,0 +1,51 @@ +"""mumble — pre-op seed hooks (Phase 1e HC3 / Phase 2 P4 backup data-integrity). + +The orchestrator runs these BEFORE each op; the matching test_.py asserts post-op (assertion +only). mumble persists its server state (registered users/channels/ACLs/config) in the sqlite DB +`/data/mumble-server.sqlite`, and the recipe's backupbot hooks dump/restore exactly that file +(`sqlite3 ... ".backup backup.sqlite"` pre-hook; `mv backup.sqlite mumble-server.sqlite` restore +post-hook). So real backup data-integrity = seed a marker row into that sqlite, back up, mutate, +restore, and prove the seeded row survived — the same DB the recipe actually backs up. + +The murmur server holds the DB open, so all writes use `PRAGMA busy_timeout` to wait out the +server's transient locks rather than failing with "database is locked". The marker lives in a +dedicated `ci_marker` table murmur never touches. +""" + +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner")) +from harness import lifecycle # noqa: E402 + +DB = "/data/mumble-server.sqlite" + + +def _sqlite(domain, sql): + cmd = f'sqlite3 {DB} "PRAGMA busy_timeout=20000; {sql}"' + return lifecycle.exec_in_app(domain, ["sh", "-c", cmd], service="app").strip() + + +def _seed(domain, value): + _sqlite( + domain, + "CREATE TABLE IF NOT EXISTS ci_marker(v TEXT); DELETE FROM ci_marker; " + f"INSERT INTO ci_marker VALUES('{value}');", + ) + got = _sqlite(domain, "SELECT v FROM ci_marker;") + assert got == value, f"seed did not commit (read back {got!r}, expected {value!r})" + + +def pre_upgrade(domain, meta): + _seed(domain, "upgrade-survives") + + +def pre_backup(domain, meta): + _seed(domain, "original") + + +def pre_restore(domain, meta): + # diverge from the backup so a successful restore is observable: drop the marker table. + _sqlite(domain, "DROP TABLE IF EXISTS ci_marker;") + got = _sqlite(domain, "SELECT name FROM sqlite_master WHERE type='table' AND name='ci_marker';") + assert got == "", f"drop did not take (sqlite_master still lists ci_marker: {got!r})" diff --git a/tests/mumble/recipe_meta.py b/tests/mumble/recipe_meta.py new file mode 100644 index 0000000..0e244f4 --- /dev/null +++ b/tests/mumble/recipe_meta.py @@ -0,0 +1,31 @@ +# Per-recipe harness config for mumble (Phase 2 Q4.2 — a TCP/voice recipe, not HTTP-native). +# +# Mumble's voice server speaks its own TLS protocol on 64738 (no HTTP API). To fit cc-ci's +# HTTP-readiness + on-host test model we deploy two recipe overlays: +# - compose.mumbleweb.yml -> a mumble-web HTTP client routed through Traefik on the app domain, +# giving the generic harness a real HTTP readiness/serving signal (HEALTH_PATH "/") AND the +# web_client.py parity surface. +# - compose.host-ports.yml -> publishes 64738 (tcp+udp) directly on the cc-ci host (mode: host). +# Tests run on-host (cc-ci-run), so the protocol tests connect to 127.0.0.1:64738. +# Both overlays are shipped by the upstream recipe; this is a documented deployment mode, not a fork. +# +# Distinctive config markers (read back by the recipe-specific functional tests, proving our config +# actually propagated into the running server — version-independent, not hard-coded upstream values): +# WELCOME_TEXT -> MUMBLE_CONFIG_WELCOMETEXT, surfaced in the ServerSync welcome_text. +# USERS -> MUMBLE_CONFIG_USERS (max users), surfaced in the ServerConfig.max_users. + +HEALTH_PATH = "/" # mumble-web client UI +HEALTH_OK = (200,) +DEPLOY_TIMEOUT = 900 # two images to pull (mumble-server + mumble-web) on a cold node +HTTP_TIMEOUT = 300 + +# A unique, stable welcome-text marker the round-trip test asserts surfaces over the protocol. +WELCOME_TEXT_MARKER = "cc-ci-mumble-welcome-7f3a9c" +# A distinctive max-users value (not the recipe default 100) the server_config test asserts. +MAX_USERS = 42 + +EXTRA_ENV = { + "COMPOSE_FILE": "compose.yml:compose.mumbleweb.yml:compose.host-ports.yml", + "WELCOME_TEXT": WELCOME_TEXT_MARKER, + "USERS": str(MAX_USERS), +} diff --git a/tests/mumble/test_backup.py b/tests/mumble/test_backup.py new file mode 100644 index 0000000..0982424 --- /dev/null +++ b/tests/mumble/test_backup.py @@ -0,0 +1,26 @@ +"""mumble — BACKUP overlay (Phase 1e HC3 / Phase 2 P4): assertion-only + additive. + +ops.pre_backup seeded ci_marker='original' into /data/mumble-server.sqlite before the backup op +(the recipe's backupbot pre-hook `.backup`s that exact file). The orchestrator performed the backup +once (generic tier asserted a snapshot artifact). This overlay ADDS: the seeded row is intact at +backup time. The backup→restore divergence (dropping the table) is in ops.pre_restore. +""" + +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner")) +from harness import lifecycle # noqa: E402 + +DB = "/data/mumble-server.sqlite" + + +def _sqlite(domain, sql): + cmd = f'sqlite3 {DB} "PRAGMA busy_timeout=20000; {sql}"' + return lifecycle.exec_in_app(domain, ["sh", "-c", cmd], service="app").strip() + + +def test_backup_captures_state(live_app): + assert _sqlite(live_app, "SELECT v FROM ci_marker;") == "original", ( + "the seeded mumble sqlite marker was not present at backup time" + ) diff --git a/tests/mumble/test_install.py b/tests/mumble/test_install.py new file mode 100644 index 0000000..ab24c65 --- /dev/null +++ b/tests/mumble/test_install.py @@ -0,0 +1,26 @@ +"""mumble — INSTALL overlay (Phase 1e HC3): assertion-only + additive, runs alongside the generic +install tier (which proves the mumble-web HTTP sidecar serves over Traefik — the readiness signal). + +This overlay ADDS the assertion that mumble's actual purpose — the voice server — is up: the murmur +control channel accepts a TLS connection on the host-published 64738 right after install. (The full +protocol handshake + channel presence is exercised in the custom tier; here we assert the install +produced a listening voice server, not only a web UI.) +""" + +import socket +import time + + +def test_voice_server_listening(live_app): + deadline = time.time() + 120 + last_err = None + while time.time() < deadline: + try: + with socket.create_connection(("127.0.0.1", 64738), timeout=10): + return + except OSError as e: # noqa: PERF203 + last_err = e + time.sleep(3) + raise AssertionError( + f"mumble voice server not listening on 127.0.0.1:64738 after install — {last_err}" + ) diff --git a/tests/mumble/test_restore.py b/tests/mumble/test_restore.py new file mode 100644 index 0000000..0dcb416 --- /dev/null +++ b/tests/mumble/test_restore.py @@ -0,0 +1,28 @@ +"""mumble — RESTORE overlay (Phase 1e HC3 / Phase 2 P4): data-integrity, assertion-only + additive. + +ops.pre_restore dropped the ci_marker table (diverge from the backup); the orchestrator restored +once (generic tier asserted healthy/serving; the recipe's restore.post-hook `mv`s the backed-up +sqlite back over /data/mumble-server.sqlite). This overlay ADDS: the restored DB carries the +pre-mutation 'original' marker — proving the seeded data actually survived backup→restore, not just +that the service came back up. Read via a fresh sqlite3 CLI in the app container (reads the restored +on-disk file). +""" + +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner")) +from harness import lifecycle # noqa: E402 + +DB = "/data/mumble-server.sqlite" + + +def _sqlite(domain, sql): + cmd = f'sqlite3 {DB} "PRAGMA busy_timeout=20000; {sql}"' + return lifecycle.exec_in_app(domain, ["sh", "-c", cmd], service="app").strip() + + +def test_restore_returns_state(live_app): + assert _sqlite(live_app, "SELECT v FROM ci_marker;") == "original", ( + "restore did not return the pre-mutation mumble sqlite marker (data-integrity failure)" + )