# -*- coding: utf-8 -*- """Offline process-level end-to-end tests for the DDNS CLI and dashboard.""" from __future__ import unicode_literals import io import json import os import shutil import signal import subprocess import sys import tempfile import threading import time from __init__ import unittest try: from http.client import HTTPConnection from http.server import BaseHTTPRequestHandler, HTTPServer from socketserver import ThreadingMixIn from urllib.parse import parse_qs, urlparse except ImportError: # Python 2 from BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer from httplib import HTTPConnection from SocketServer import ThreadingMixIn from urlparse import parse_qs, urlparse PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir)) RUN_SCRIPT = os.path.join(PROJECT_ROOT, "run.py") E2E_EXECUTABLE = os.environ.get("DDNS_E2E_EXECUTABLE") if E2E_EXECUTABLE: E2E_EXECUTABLE = os.path.abspath(E2E_EXECUTABLE) PROCESS_TIMEOUT = 60 if E2E_EXECUTABLE else 20 STARTUP_TIMEOUT = 60 if E2E_EXECUTABLE else 10 TEST_IPV4 = "192.0.2.44" TEST_IPV6 = "2001:db8::44" PROXY_ENVIRONMENT_KEYS = {"ALL_PROXY", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY"} def _popen_group_options(): if os.name == "nt": return {"creationflags": getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)} return {"start_new_session": True} def _signal_process_group(process, signal_value): try: if os.name == "nt": process.send_signal(signal_value) else: os.killpg(process.pid, signal_value) except (AttributeError, OSError): if process.poll() is None: process.send_signal(signal_value) def _kill_process_group(process): try: if os.name == "nt": process.kill() else: os.killpg(process.pid, signal.SIGKILL) except OSError: if process.poll() is None: process.kill() class _ProcessResult(object): """Captured result from a completed DDNS process.""" def __init__(self, returncode, stdout, stderr): self.returncode = returncode self.stdout = stdout self.stderr = stderr class _FixtureState(object): """Thread-safe configuration and request state for the local HTTP fixture.""" def __init__(self): self._lock = threading.Lock() self.reset() def reset(self): with self._lock: self._requests = [] self._remote_documents = {} def record(self, request): with self._lock: self._requests.append(request) def set_remote_document(self, path, document): with self._lock: self._remote_documents[path] = document def remote_document(self, path): with self._lock: return self._remote_documents.get(path) def requests_for(self, path): with self._lock: return [request.copy() for request in self._requests if request["path"] == path] class _ThreadingHTTPServer(ThreadingMixIn, HTTPServer): """Small concurrent HTTP server used by subprocess-based tests.""" daemon_threads = True class _FixtureHandler(BaseHTTPRequestHandler): """Serve deterministic IP, remote config, and callback endpoints.""" protocol_version = "HTTP/1.1" def log_message(self, format_string, *args): pass def _read_body(self): content_length = int(self.headers.get("Content-Length", "0")) body = self.rfile.read(content_length) if content_length else b"" return body.decode("utf-8") def _record(self, body=""): parsed = urlparse(self.path) self.server.state.record( { "method": self.command, "path": parsed.path, "query": parse_qs(parsed.query, keep_blank_values=True), "headers": dict(self.headers.items()), "body": body, } ) return parsed.path def _send(self, status, body, content_type="text/plain; charset=utf-8"): if not isinstance(body, bytes): body = body.encode("utf-8") self.send_response(status) self.send_header("Content-Type", content_type) self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def do_GET(self): path = self._record() remote_document = self.server.state.remote_document(path) if remote_document is not None: body = ( remote_document if isinstance(remote_document, str) else json.dumps(remote_document, ensure_ascii=False, separators=(",", ":")) ) self._send(200, body, "application/json; charset=utf-8") elif path == "/ip/invalid": self._send(200, "address unavailable") elif path == "/ip/v4": self._send(200, "current address: {}\n".format(TEST_IPV4)) elif path == "/ip/v6": self._send(200, "{}\n".format(TEST_IPV6)) elif path == "/ip/error-address": self._send(403, "upstream 198.51.100.20 unavailable") elif path == "/ip/mixed-v6": self._send(200, "2001:db8::192.0.2.10") elif path == "/ip/compact-v6": self._send(200, "IP:{}".format(TEST_IPV6)) elif path in ("/callback/fail", "/callback/fail.example.com"): self._send(400, "callback rejected") elif path.startswith("/callback/"): self._send(200, "callback accepted") else: self._send(404, "not found") def do_POST(self): body = self._read_body() path = self._record(body) if path in ("/callback/fail", "/callback/fail.example.com"): self._send(400, "callback rejected") elif path.startswith("/callback/"): self._send(200, "callback accepted") else: self._send(404, "not found") class _BackgroundProcess(object): """Run and continuously drain a long-lived child process.""" def __init__(self, command, cwd, env): self.process = subprocess.Popen( command, cwd=cwd, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, encoding="utf-8", errors="replace", bufsize=1, **_popen_group_options(), ) self._lock = threading.Lock() self._stdout = [] self._stderr = [] self._threads = [ threading.Thread(target=self._drain, args=(self.process.stdout, self._stdout)), threading.Thread(target=self._drain, args=(self.process.stderr, self._stderr)), ] for thread in self._threads: thread.daemon = True thread.start() def _drain(self, stream, output): for line in iter(stream.readline, ""): with self._lock: output.append(line) stream.close() @property def stdout(self): with self._lock: return "".join(self._stdout) @property def stderr(self): with self._lock: return "".join(self._stderr) def wait_for_stdout(self, text, timeout=10): deadline = time.time() + timeout while time.time() < deadline: output = self.stdout if text in output: return output if self.process.poll() is not None: break time.sleep(0.05) raise AssertionError( "Process did not write {!r}.\nstdout:\n{}\nstderr:\n{}".format(text, self.stdout, self.stderr) ) def stop(self, timeout=5): if self.process.poll() is None: if os.name == "nt": _signal_process_group(self.process, signal.CTRL_C_EVENT) else: _signal_process_group(self.process, signal.SIGINT) deadline = time.time() + timeout while self.process.poll() is None and time.time() < deadline: time.sleep(0.05) if self.process.poll() is None: if os.name == "nt": self.process.terminate() else: _signal_process_group(self.process, signal.SIGTERM) deadline = time.time() + 2 while self.process.poll() is None and time.time() < deadline: time.sleep(0.05) if self.process.poll() is None: _kill_process_group(self.process) self.process.wait() for thread in self._threads: thread.join(2) return self.process.returncode class OfflineE2ETestCase(unittest.TestCase): """Shared isolated process and HTTP helpers.""" @classmethod def setUpClass(cls): if E2E_EXECUTABLE and not os.path.isfile(E2E_EXECUTABLE): raise RuntimeError("DDNS E2E executable does not exist: {}".format(E2E_EXECUTABLE)) cls.fixture_state = _FixtureState() cls.fixture_server = _ThreadingHTTPServer(("127.0.0.1", 0), _FixtureHandler) cls.fixture_server.state = cls.fixture_state cls.fixture_thread = threading.Thread(target=cls.fixture_server.serve_forever) cls.fixture_thread.daemon = True cls.fixture_thread.start() cls.fixture_url = "http://127.0.0.1:{}".format(cls.fixture_server.server_address[1]) @classmethod def tearDownClass(cls): cls.fixture_server.shutdown() cls.fixture_server.server_close() cls.fixture_thread.join(2) def setUp(self): self.fixture_state.reset() self.temp_dir = tempfile.mkdtemp(prefix="ddns-e2e-") self.addCleanup(shutil.rmtree, self.temp_dir, ignore_errors=True) def _environment(self, overrides=None): env = os.environ.copy() for key in list(env): upper_key = key.upper() if ( upper_key.startswith("DDNS_") or upper_key in PROXY_ENVIRONMENT_KEYS or upper_key in ("PYTHONHOME", "PYTHONPATH") ): del env[key] env.update( { "HOME": self.temp_dir, "USERPROFILE": self.temp_dir, "TMPDIR": self.temp_dir, "TEMP": self.temp_dir, "TMP": self.temp_dir, "PYTHONIOENCODING": "utf-8", "PYTHONUNBUFFERED": "1", } ) if not E2E_EXECUTABLE: env["PYTHONPATH"] = PROJECT_ROOT if overrides: env.update(overrides) return env def _command(self, arguments, entrypoint="module"): if E2E_EXECUTABLE: return [E2E_EXECUTABLE] + arguments if entrypoint == "script": return [sys.executable, RUN_SCRIPT] + arguments return [sys.executable, "-m", "ddns"] + arguments def _run(self, arguments, entrypoint="module", env=None, timeout=PROCESS_TIMEOUT, input_text=None): process = subprocess.Popen( self._command(arguments, entrypoint), cwd=self.temp_dir, env=env or self._environment(), stdin=subprocess.PIPE if input_text is not None else None, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, encoding="utf-8", errors="replace", **_popen_group_options(), ) timed_out = threading.Event() def kill_on_timeout(): if process.poll() is None: timed_out.set() _kill_process_group(process) timer = threading.Timer(timeout, kill_on_timeout) timer.daemon = True timer.start() try: stdout, stderr = process.communicate(input_text) finally: timer.cancel() if timed_out.is_set(): self.fail("DDNS process timed out.\nstdout:\n{}\nstderr:\n{}".format(stdout, stderr)) return _ProcessResult(process.returncode, stdout, stderr) def _start(self, arguments, env=None): process = _BackgroundProcess(self._command(arguments), self.temp_dir, env or self._environment()) self.addCleanup(process.stop) return process def _write_config(self, name, config): path = os.path.join(self.temp_dir, name) with io.open(path, "w", encoding="utf-8") as config_file: json.dump(config, config_file, ensure_ascii=False, indent=2) return path def _callback_config( self, callback_path, domains=None, ipv6_domains=None, cache=False, token="", ttl=300, line="default" ): return { "dns": "callback", "id": self.fixture_url + callback_path, "token": token, "ipv4": domains or [], "index4": ["url:" + self.fixture_url + "/ip/v4"] if domains else False, "ipv6": ipv6_domains or [], "index6": ["url:" + self.fixture_url + "/ip/v6"] if ipv6_domains else False, "ttl": ttl, "line": line, "proxy": ["DIRECT"], "cache": cache, "ssl": False, } def assert_process_success(self, result): self.assertEqual( result.returncode, 0, "Process failed with {}.\nstdout:\n{}\nstderr:\n{}".format(result.returncode, result.stdout, result.stderr), ) def _http_request(self, port, path, method="GET", payload=None, headers=None): request_headers = dict(headers or {}) body = None if payload is not None: body = json.dumps(payload, ensure_ascii=False).encode("utf-8") request_headers.setdefault("Content-Type", "application/json") connection = HTTPConnection("127.0.0.1", port, timeout=5) try: connection.request(method, path, body=body, headers=request_headers) response = connection.getresponse() content = response.read().decode("utf-8") return response.status, dict(response.getheaders()), content finally: connection.close() class TestCliE2E(OfflineE2ETestCase): """Exercise complete DDNS update flows through public process entrypoints.""" def test_ipv6_runs_when_ipv4_is_disabled_or_fails(self): """Update the working family independently of a disabled or unavailable IPv4 source.""" for name, index4, expected_exit in ( ("disabled", False, 0), ("unavailable", ["url:" + self.fixture_url + "/ip/invalid"], 1), ): self.fixture_state.reset() config = self._callback_config( "/callback/dual-failure?type=__RECORDTYPE__&ip=__IP__", ["v4.example.com"], ["v6.example.com"] ) config["index4"] = index4 config_path = self._write_config(name + ".json", config) result = self._run(["--config", config_path]) self.assertEqual(result.returncode, expected_exit, result.stderr) requests = self.fixture_state.requests_for("/callback/dual-failure") self.assertEqual(len(requests), 1, result.stderr) self.assertEqual(requests[0]["query"], {"type": ["AAAA"], "ip": [TEST_IPV6]}) self.assertEqual(len(self.fixture_state.requests_for("/ip/v6")), 1) def test_partial_domain_failure_preserves_only_successful_cache(self): """Report partial failure and retry the rejected domain, while retaining successful cached records.""" config = self._callback_config("/callback/__DOMAIN__", ["ok.example.com", "fail.example.com"], cache=True) config_path = self._write_config("partial-domains.json", config) for attempt in (1, 2): result = self._run(["--config", config_path]) self.assertEqual(result.returncode, 1, result.stderr) self.assertEqual(len(self.fixture_state.requests_for("/callback/ok.example.com")), 1) self.assertEqual(len(self.fixture_state.requests_for("/callback/fail.example.com")), attempt) def test_http_error_address_is_ignored(self): """Do not update DNS from the address contained in an IP endpoint error page.""" config = self._callback_config("/callback/error-address?ip=__IP__", ["ip.example.com"]) config["index4"] = ["url:" + self.fixture_url + "/ip/error-address", "url:" + self.fixture_url + "/ip/v4"] config_path = self._write_config("error-address.json", config) result = self._run(["--config", config_path]) self.assert_process_success(result) requests = self.fixture_state.requests_for("/callback/error-address") self.assertEqual(len(requests), 1) self.assertEqual(requests[0]["query"]["ip"], [TEST_IPV4]) self.assertEqual(len(self.fixture_state.requests_for("/ip/error-address")), 1) self.assertEqual(len(self.fixture_state.requests_for("/ip/v4")), 1) def test_mixed_ipv6_address_is_preserved(self): """Forward a complete mixed-notation IPv6 address to the callback.""" config = self._callback_config("/callback/mixed-v6?ip=__IP__", ipv6_domains=["mixed.example.com"]) config["index6"] = ["url:" + self.fixture_url + "/ip/mixed-v6"] config_path = self._write_config("mixed-v6.json", config) result = self._run(["--config", config_path]) self.assert_process_success(result) requests = self.fixture_state.requests_for("/callback/mixed-v6") self.assertEqual(len(requests), 1) self.assertEqual(requests[0]["query"]["ip"], ["2001:db8::192.0.2.10"]) def test_compact_ipv6_label_is_preserved(self): """Resolve a compact text response over real HTTP and forward its IPv6 address.""" config = self._callback_config("/callback/compact-v6?ip=__IP__", ipv6_domains=["label.example.com"]) config["index6"] = ["url:" + self.fixture_url + "/ip/compact-v6"] config_path = self._write_config("compact-v6.json", config) result = self._run(["--config", config_path]) self.assert_process_success(result) requests = self.fixture_state.requests_for("/callback/compact-v6") self.assertEqual(len(requests), 1) self.assertEqual(requests[0]["query"]["ip"], [TEST_IPV6]) self.assertEqual(len(self.fixture_state.requests_for("/ip/compact-v6")), 1) def test_callback_http_failure_is_not_cached(self): """Retry a missing callback endpoint instead of caching its error body as a successful update.""" config = self._callback_config("/missing", ["callback.example.com"], cache=True) config_path = self._write_config("missing-callback.json", config) for attempt in (1, 2): result = self._run(["--config", config_path]) self.assertEqual(result.returncode, 1, result.stderr) self.assertEqual(len(self.fixture_state.requests_for("/missing")), attempt) def test_cli_only_dual_stack_and_rule_fallback(self): """Resolve both address families and fall back after an unusable rule.""" result = self._run( [ "--dns", "debug", "--no-cache", "--proxy", "DIRECT", "--index4", "url:" + self.fixture_url + "/ip/invalid", "url:" + self.fixture_url + "/ip/v4", "--ipv4", "First.Example.com", "second.example.com", "--index6", "url:" + self.fixture_url + "/ip/v6", "--ipv6", "v6.example.com", ], entrypoint="script", ) self.assert_process_success(result) self.assertEqual(result.stdout.count("[IPv4] {}".format(TEST_IPV4)), 2) self.assertEqual(result.stdout.count("[IPv6] {}".format(TEST_IPV6)), 1) self.assertIn("first.example.com", result.stderr) self.assertIn("second.example.com", result.stderr) self.assertEqual(len(self.fixture_state.requests_for("/ip/invalid")), 1) self.assertEqual(len(self.fixture_state.requests_for("/ip/v4")), 1) self.assertEqual(len(self.fixture_state.requests_for("/ip/v6")), 1) def test_local_config_callback_post_and_cli_precedence(self): """Send a real callback request with CLI values overriding file values.""" token = json.dumps( { "domain": "__DOMAIN__", "ip": "__IP__", "record_type": "__RECORDTYPE__", "ttl": "__TTL__", "line": "__LINE__", }, separators=(",", ":"), ) config = self._callback_config("/callback/local", ["file.example.com"], token=token, ttl=300, line="file") config_path = self._write_config("local.json", config) result = self._run(["-c", config_path, "--ipv4", "CLI.Example.com", "--ttl", "600"]) self.assert_process_success(result) requests = self.fixture_state.requests_for("/callback/local") self.assertEqual(len(requests), 1) self.assertEqual(requests[0]["method"], "POST") self.assertEqual( json.loads(requests[0]["body"]), {"domain": "cli.example.com", "ip": TEST_IPV4, "record_type": "A", "ttl": "600", "line": "file"}, ) self.assertNotIn("file.example.com", requests[0]["body"]) def test_environment_configuration_with_cli_override(self): """Load a callback entirely from DDNS_* variables and override its domain.""" callback_path = "/callback/environment" env = self._environment( { "DDNS_DNS": "callback", "DDNS_ID": self.fixture_url + callback_path + "?domain=__DOMAIN__&ip=__IP__&type=__RECORDTYPE__&ttl=__TTL__", "DDNS_TOKEN": "", "DDNS_IPV4": '["env.example.com"]', "DDNS_INDEX4": '["url:' + self.fixture_url + '/ip/v4"]', "DDNS_IPV6": "false", "DDNS_INDEX6": "false", "DDNS_TTL": "120", "DDNS_PROXY": '["DIRECT"]', "DDNS_CACHE": "false", "DDNS_SSL": "false", } ) result = self._run(["--ipv4", "cli-env.example.com"], env=env) self.assert_process_success(result) requests = self.fixture_state.requests_for(callback_path) self.assertEqual(len(requests), 1) self.assertEqual( requests[0]["query"], {"domain": ["cli-env.example.com"], "ip": [TEST_IPV4], "type": ["A"], "ttl": ["120"]} ) def test_generate_config_with_realistic_cli_options_and_reuse_it(self): """Generate a user-style config with a spaced path, then execute it.""" config_path = os.path.join(self.temp_dir, "generated user config.json") log_path = os.path.join(self.temp_dir, "generated user.log") callback_path = "/callback/generated" callback_url = ( self.fixture_url + callback_path + "?domain=__DOMAIN__&ip=__IP__&type=__RECORDTYPE__&ttl=__TTL__&line=__LINE__" ) generate = self._run( [ "--dns", "callback", "--id", callback_url, "--token", "{}", "--index4", "url:" + self.fixture_url + "/ip/v4", "--ipv4", "Generated.Example.com", "--ttl", "900", "--line", "generated", "--proxy", "DIRECT", "--no-cache", "--no-ssl", "--log-level", "DEBUG", "--log-file", log_path, "--new-config", config_path, ] ) self.assert_process_success(generate) self.assertTrue(os.path.isfile(config_path)) with io.open(config_path, "r", encoding="utf-8") as generated_file: generated_config = json.load(generated_file) self.assertEqual(generated_config["dns"], "callback") self.assertEqual(generated_config["token"], "{}") self.assertEqual(generated_config["ipv4"], ["Generated.Example.com"]) self.assertEqual(generated_config["index4"], ["url:" + self.fixture_url + "/ip/v4"]) self.assertEqual(generated_config["proxy"], ["DIRECT"]) self.assertFalse(generated_config["cache"]) self.assertFalse(generated_config["ssl"]) result = self._run(["-c", config_path]) self.assert_process_success(result) requests = self.fixture_state.requests_for(callback_path) self.assertEqual(len(requests), 1) self.assertEqual( requests[0]["query"], { "domain": ["generated.example.com"], "ip": [TEST_IPV4], "type": ["A"], "ttl": ["900"], "line": ["generated"], }, ) with io.open(log_path, "r", encoding="utf-8") as log_file: self.assertIn("generated.example.com", log_file.read()) def test_remote_multi_provider_configuration(self): """Load a remote v4.1 document and execute every expanded provider.""" second_token = json.dumps( { "domain": "__DOMAIN__", "ip": "__IP__", "record_type": "__RECORDTYPE__", "ttl": "__TTL__", "line": "__LINE__", }, separators=(",", ":"), ) remote_config = { "$schema": "https://ddns.newfuture.cc/schema/v4.1.json", "proxy": ["DIRECT"], "ssl": False, "cache": False, "ttl": 300, "line": "global", "providers": [ { "provider": "callback", "id": self.fixture_url + "/callback/remote-v4?domain=__DOMAIN__&ip=__IP__&type=__RECORDTYPE__&ttl=__TTL__&line=__LINE__", "token": "", "ipv4": ["remote-v4.example.com"], "index4": ["url:" + self.fixture_url + "/ip/v4"], "ipv6": [], "index6": False, }, { "provider": "callback", "id": self.fixture_url + "/callback/remote-v6", "token": second_token, "ipv4": [], "index4": False, "ipv6": ["remote-v6.example.com"], "index6": ["url:" + self.fixture_url + "/ip/v6"], "ttl": 600, "line": "provider", }, ], } remote_path = "/config/multi.json" self.fixture_state.set_remote_document(remote_path, remote_config) result = self._run(["-c", self.fixture_url + remote_path, "--proxy", "DIRECT"]) self.assert_process_success(result) self.assertEqual(len(self.fixture_state.requests_for(remote_path)), 1) first = self.fixture_state.requests_for("/callback/remote-v4") second = self.fixture_state.requests_for("/callback/remote-v6") self.assertEqual(len(first), 1) self.assertEqual( first[0]["query"], {"domain": ["remote-v4.example.com"], "ip": [TEST_IPV4], "type": ["A"], "ttl": ["300"], "line": ["global"]}, ) self.assertEqual(len(second), 1) self.assertEqual( json.loads(second[0]["body"]), { "domain": "remote-v6.example.com", "ip": TEST_IPV6, "record_type": "AAAA", "ttl": "600", "line": "provider", }, ) def test_cache_skips_unchanged_callback_on_second_process(self): """Persist an address and avoid a duplicate provider update.""" cache_path = os.path.join(self.temp_dir, "records.cache") config = self._callback_config( "/callback/cache?domain=__DOMAIN__&ip=__IP__&type=__RECORDTYPE__", ["cache.example.com"], cache=cache_path ) config["cache_max_age"] = 3600 config_path = self._write_config("cache.json", config) first = self._run(["-c", config_path]) second = self._run(["-c", config_path]) self.assert_process_success(first) self.assert_process_success(second) self.assertEqual(len(self.fixture_state.requests_for("/callback/cache")), 1) self.assertIn("using cache", second.stderr) with io.open(cache_path, "r", encoding="utf-8") as cache_file: self.assertEqual(json.load(cache_file), {"cache.example.com:A": TEST_IPV4}) def test_multiple_configs_continue_after_failure_and_exit_nonzero(self): """Run all configurations but fail the process when one callback fails.""" success_path = self._write_config( "success.json", self._callback_config( "/callback/success?domain=__DOMAIN__&ip=__IP__", ["success.example.com"], cache=False ), ) failure_path = self._write_config( "failure.json", self._callback_config("/callback/fail?domain=__DOMAIN__&ip=__IP__", ["failure.example.com"], cache=False), ) result = self._run(["-c", success_path, "-c", failure_path]) self.assertEqual(result.returncode, 1) self.assertEqual(len(self.fixture_state.requests_for("/callback/success")), 1) self.assertEqual(len(self.fixture_state.requests_for("/callback/fail")), 1) self.assertIn("Configuration 2 failed", result.stderr) self.assertIn("Some configurations failed", result.stderr) class TestMcpE2E(OfflineE2ETestCase): """Exercise both supported MCP stdio lifecycles through a real process.""" MODERN_VERSION = "2026-07-28" LEGACY_VERSION = "2025-11-25" def _modern_request(self, request_id, method, params=None): params = dict(params or {}) params["_meta"] = { "io.modelcontextprotocol/protocolVersion": self.MODERN_VERSION, "io.modelcontextprotocol/clientCapabilities": {"sampling": {}}, "io.modelcontextprotocol/clientInfo": {"name": "ddns-e2e-client", "version": "1.0"}, } return {"jsonrpc": "2.0", "id": request_id, "method": method, "params": params} @staticmethod def _request_lines(requests): return "".join(json.dumps(request, ensure_ascii=False, separators=(",", ":")) + "\n" for request in requests) def _run_mcp(self, config_path, requests): result = self._run(["mcp", "-c", config_path], input_text=self._request_lines(requests)) self.assert_process_success(result) lines = result.stdout.splitlines() self.assertEqual( len(lines), len([request for request in requests if "id" in request]), "Unexpected MCP stdout.\nstdout:\n{}\nstderr:\n{}".format(result.stdout, result.stderr), ) return result, [json.loads(line) for line in lines] def test_partial_domain_failure_is_a_tool_error(self): """Report a rejected domain through MCP and retry it without repeating successful writes.""" config = self._callback_config("/callback/__DOMAIN__", ["ok.example.com", "fail.example.com"], cache=True) config_path = self._write_config("partial-mcp.json", config) requests = [ self._modern_request(request_id, "tools/call", {"name": "update_dns_records", "arguments": {}}) for request_id in (1, 2) ] _, responses = self._run_mcp(config_path, requests) for response in responses: self.assertTrue(response["result"]["isError"]) self.assertIn("Synchronization failed", response["result"]["content"][0]["text"]) self.assertEqual(len(self.fixture_state.requests_for("/callback/ok.example.com")), 1) self.assertEqual(len(self.fixture_state.requests_for("/callback/fail.example.com")), 2) def test_modern_discovery_tools_update_and_cached_status(self): """Discover tools, synchronize dual-stack records, and read cached status.""" token = json.dumps( { "api_key": "mcp-secret", "domain": "__DOMAIN__", "ip": "__IP__", "record_type": "__RECORDTYPE__", "ttl": "__TTL__", "line": "__LINE__", }, separators=(",", ":"), ) config = { "$schema": "https://ddns.newfuture.cc/schema/v4.1.json", "proxy": ["DIRECT"], "ssl": False, "cache": True, "cache_max_age": 3600, "providers": [ { "provider": "callback", "id": self.fixture_url + "/callback/mcp", "token": token, "ipv4": ["mcp-v4.example.com"], "index4": ["url:" + self.fixture_url + "/ip/v4"], "ipv6": ["mcp-v6.example.com"], "index6": ["url:" + self.fixture_url + "/ip/v6"], "ttl": 720, "line": "mcp", }, { "provider": "debug", "ipv4": ["mcp-debug.example.com"], "index4": ["url:" + self.fixture_url + "/ip/v4"], "ipv6": [], "index6": False, "ttl": 300, }, ], } config_path = self._write_config("mcp.json", config) requests = [ self._modern_request(1, "server/discover"), self._modern_request(2, "tools/list"), self._modern_request(3, "tools/call", {"name": "update_dns_records", "arguments": {}}), self._modern_request(4, "tools/call", {"name": "get_ddns_status", "arguments": {}}), ] result, responses = self._run_mcp(config_path, requests) self.assertEqual([response["id"] for response in responses], [1, 2, 3, 4]) self.assertEqual(responses[0]["result"]["supportedVersions"], [self.MODERN_VERSION, self.LEGACY_VERSION]) tools = responses[1]["result"]["tools"] self.assertEqual([tool["name"] for tool in tools], ["get_ddns_status", "update_dns_records"]) self.assertTrue(tools[0]["annotations"]["readOnlyHint"]) self.assertTrue(tools[1]["annotations"]["destructiveHint"]) update = responses[2]["result"] self.assertFalse(update["isError"]) self.assertEqual(update["structuredContent"]["state"], "synced") expected_records = [ ("mcp-debug.example.com", "A", TEST_IPV4, "debug"), ("mcp-v4.example.com", "A", TEST_IPV4, "callback"), ("mcp-v6.example.com", "AAAA", TEST_IPV6, "callback"), ] self.assertEqual( [ (record["domain"], record["type"], record["value"], record["provider"]) for record in update["structuredContent"]["records"] ], expected_records, ) status = responses[3]["result"] self.assertFalse(status["isError"]) self.assertEqual(status["structuredContent"]["state"], "synced") self.assertEqual( [ (record["domain"], record["type"], record["value"], record["provider"]) for record in status["structuredContent"]["records"] ], expected_records, ) self.assertNotIn("mcp-secret", result.stdout) self.assertIn("[IPv4] {}".format(TEST_IPV4), result.stderr) callback_requests = self.fixture_state.requests_for("/callback/mcp") self.assertEqual(len(callback_requests), 2) self.assertEqual( sorted((json.loads(request["body"]) for request in callback_requests), key=lambda body: body["domain"]), [ { "api_key": "mcp-secret", "domain": "mcp-v4.example.com", "ip": TEST_IPV4, "record_type": "A", "ttl": "720", "line": "mcp", }, { "api_key": "mcp-secret", "domain": "mcp-v6.example.com", "ip": TEST_IPV6, "record_type": "AAAA", "ttl": "720", "line": "mcp", }, ], ) def test_legacy_copilot_initialize_ping_list_and_status(self): """Run the lifecycle used by GitHub Copilot CLI over stdio.""" config_path = self._write_config( "legacy-mcp.json", self._callback_config("/callback/legacy-mcp", ["legacy.example.com"], cache=False) ) requests = [ { "jsonrpc": "2.0", "id": 0, "method": "initialize", "params": { "protocolVersion": self.LEGACY_VERSION, "capabilities": {"sampling": {}}, "clientInfo": {"name": "github-copilot-developer", "version": "1.0.80"}, }, }, {"jsonrpc": "2.0", "method": "notifications/initialized"}, {"jsonrpc": "2.0", "id": 1, "method": "ping"}, {"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}, {"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "get_ddns_status", "arguments": {}}}, ] _, responses = self._run_mcp(config_path, requests) self.assertEqual([response["id"] for response in responses], [0, 1, 2, 3]) self.assertEqual(responses[0]["result"]["protocolVersion"], self.LEGACY_VERSION) self.assertEqual(responses[1]["result"], {}) self.assertEqual( [tool["name"] for tool in responses[2]["result"]["tools"]], ["get_ddns_status", "update_dns_records"] ) self.assertFalse(responses[3]["result"]["isError"]) self.assertNotIn("resultType", responses[3]["result"]) def test_modern_streamable_http_tools(self): """Run modern MCP over the standalone HTTP transport.""" config_path = self._write_config( "http-mcp.json", self._callback_config("/callback/http-mcp", ["http-mcp.example.com"], cache=True) ) process = self._start( [ "mcp", "--transport", "http", "--config", config_path, "--host", "127.0.0.1", "--port", "0", "--http-token", "http-mcp-secret", ] ) output = process.wait_for_stdout("DDNS MCP HTTP:", timeout=STARTUP_TIMEOUT) endpoint = next( line.split("DDNS MCP HTTP:", 1)[1].strip() for line in output.splitlines() if "DDNS MCP HTTP:" in line ) port = urlparse(endpoint).port self.assertIsNotNone(port) requests = [ self._modern_request(1, "server/discover"), self._modern_request(2, "tools/call", {"name": "update_dns_records", "arguments": {}}), self._modern_request(3, "tools/call", {"name": "get_ddns_status", "arguments": {}}), ] responses = [] for request in requests: headers = { "Authorization": "Bearer http-mcp-secret", "MCP-Protocol-Version": self.MODERN_VERSION, "Mcp-Method": request["method"], "Accept": "application/json, text/event-stream", } if request["method"] == "tools/call": headers["Mcp-Name"] = request["params"]["name"] status, _, body = self._http_request(port, "/mcp", method="POST", payload=request, headers=headers) self.assertEqual(status, 200, body) responses.append(json.loads(body)) self.assertEqual(responses[0]["result"]["supportedVersions"], [self.MODERN_VERSION]) self.assertEqual(responses[1]["result"]["structuredContent"]["state"], "synced") self.assertEqual(responses[2]["result"]["structuredContent"]["records"][0]["domain"], "http-mcp.example.com") self.assertNotIn("http-mcp-secret", json.dumps(responses)) self.assertEqual(len(self.fixture_state.requests_for("/callback/http-mcp")), 1) returncode = process.stop() if os.name == "nt": self.assertIsNotNone(returncode) elif E2E_EXECUTABLE: self.assertIn( returncode, (0, 130), "MCP HTTP did not stop after SIGINT.\nstderr:\n{}".format(process.stderr) ) else: self.assertEqual(returncode, 0, "MCP HTTP did not stop cleanly.\nstderr:\n{}".format(process.stderr)) class TestWebE2E(OfflineE2ETestCase): """Exercise the dashboard process and protected JSON API.""" def test_dashboard_config_sync_and_scheduler_lifecycle(self): """Start the real dashboard, synchronize, and manage its scheduler.""" config_path = os.path.join(self.temp_dir, "dashboard.json") process = self._start( [ "web", "--config", config_path, "--host", "127.0.0.1", "--port", "0", "--http-token", "web-http-secret", "--interval", "30", ] ) output = process.wait_for_stdout("DDNS dashboard:", timeout=STARTUP_TIMEOUT) launch_url = next( line.split("DDNS dashboard:", 1)[1].strip() for line in output.splitlines() if "DDNS dashboard:" in line ) parsed_launch_url = urlparse(launch_url) port = parsed_launch_url.port launch_path = parsed_launch_url.path self.assertIsNotNone(port) status, _, index = self._http_request(port, "/") asset_status, _, script = self._http_request(port, "/assets/dashboard.js") unauthorized_status, _, unauthorized_body = self._http_request(port, "/api/dashboard") launch_status, launch_headers, _ = self._http_request(port, launch_path) reused_status, _, reused_body = self._http_request(port, launch_path) self.assertEqual(status, 200) self.assertIn("= 2 ): scheduled_dashboard = dashboard break self.assertIsNotNone( scheduled_dashboard, "Web scheduler did not synchronize.\nstdout:\n{}\nstderr:\n{}".format(process.stdout, process.stderr), ) self.assertEqual(len(self.fixture_state.requests_for("/callback/web")), 2) self.assertEqual(scheduled_dashboard["state"], "synced") self.assertIsNone(scheduled_dashboard["scheduler"]["last_error"]) returncode = process.stop() if os.name == "nt": self.assertIsNotNone(returncode) else: self.assertEqual(returncode, 0, "Dashboard did not stop cleanly.\nstderr:\n{}".format(process.stderr)) if __name__ == "__main__": unittest.main()