mirror of
https://github.com/NewFuture/DDNS.git
synced 2026-10-20 17:39:36 +00:00
* fix: correct DNS sync results and configuration retention Keep address families independent, reject failed update responses, preserve complete IPv6 addresses, and retain Studio drafts and effective extra settings. Add offline regressions and wire Studio tests into documentation builds. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * test(provider): make disk cache regressions deterministic Align the cache fixture wall clock and file mtime so Windows Python 3.12 clock granularity cannot turn a freshly saved cache into a future-dated file. Keep real disk reads, all cache assertions, production expiry policy and CI matrices unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: NewFuture <6290356+NewFuture@users.noreply.github.com> Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1231 lines
49 KiB
Python
1231 lines
49 KiB
Python
# -*- 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("<html", index)
|
|
self.assertEqual(asset_status, 200)
|
|
self.assertIn("/api/dashboard", script)
|
|
self.assertEqual(unauthorized_status, 403)
|
|
self.assertEqual(json.loads(unauthorized_body)["error"]["code"], "invalid_token")
|
|
self.assertEqual(launch_status, 302)
|
|
self.assertEqual(reused_status, 403)
|
|
self.assertEqual(json.loads(reused_body)["error"]["code"], "invalid_launch_token")
|
|
|
|
location = launch_headers["Location"]
|
|
access_token = parse_qs(urlparse(location).fragment)["token"][0]
|
|
self.assertEqual(access_token, "web-http-secret")
|
|
api_headers = {"X-DDNS-Token": access_token}
|
|
config_headers = {"X-DDNS-Token": access_token, "Content-Type": "application/json"}
|
|
callback_token = json.dumps(
|
|
{
|
|
"domain": "__DOMAIN__",
|
|
"ip": "__IP__",
|
|
"record_type": "__RECORDTYPE__",
|
|
"ttl": "__TTL__",
|
|
"line": "__LINE__",
|
|
},
|
|
separators=(",", ":"),
|
|
)
|
|
config = {
|
|
"$schema": "https://ddns.newfuture.cc/schema/v4.1.json",
|
|
"ssl": False,
|
|
"proxy": ["DIRECT"],
|
|
"cache": True,
|
|
"cache_max_age": 3600,
|
|
"interval": 30,
|
|
"log": {"level": "INFO"},
|
|
"providers": [
|
|
{
|
|
"provider": "callback",
|
|
"id": self.fixture_url + "/callback/web",
|
|
"token": callback_token,
|
|
"ipv4": ["web.example.com"],
|
|
"index4": ["url:" + self.fixture_url + "/ip/v4"],
|
|
"ipv6": [],
|
|
"index6": False,
|
|
"ttl": 180,
|
|
"line": "web",
|
|
}
|
|
],
|
|
}
|
|
|
|
validate_status, _, validate_body = self._http_request(
|
|
port, "/api/config/validate", method="POST", payload={"config": config}, headers=config_headers
|
|
)
|
|
save_status, _, save_body = self._http_request(
|
|
port, "/api/config", method="PUT", payload={"config": config}, headers=config_headers
|
|
)
|
|
sync_status, _, sync_body = self._http_request(
|
|
port, "/api/sync", method="POST", payload={}, headers=config_headers
|
|
)
|
|
dashboard_status, _, dashboard_body = self._http_request(port, "/api/dashboard", headers=api_headers)
|
|
|
|
self.assertEqual(validate_status, 200, validate_body)
|
|
self.assertEqual(
|
|
json.loads(validate_body)["config"]["providers"][0]["index4"], ["url:" + self.fixture_url + "/ip/v4"]
|
|
)
|
|
self.assertEqual(save_status, 200, save_body)
|
|
self.assertTrue(os.path.isfile(config_path))
|
|
self.assertEqual(sync_status, 200, sync_body)
|
|
dashboard = json.loads(dashboard_body)
|
|
self.assertEqual(dashboard_status, 200)
|
|
self.assertEqual(dashboard["state"], "synced")
|
|
self.assertEqual(dashboard["providers"][0]["status"], "synced")
|
|
self.assertEqual(dashboard["records"][0]["domain"], "web.example.com")
|
|
self.assertEqual(dashboard["records"][0]["value"], TEST_IPV4)
|
|
|
|
mcp_request = {
|
|
"jsonrpc": "2.0",
|
|
"id": 1,
|
|
"method": "tools/call",
|
|
"params": {
|
|
"name": "get_ddns_status",
|
|
"arguments": {},
|
|
"_meta": {
|
|
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
|
|
"io.modelcontextprotocol/clientCapabilities": {},
|
|
},
|
|
},
|
|
}
|
|
mcp_headers = {
|
|
"Authorization": "Bearer " + access_token,
|
|
"MCP-Protocol-Version": "2026-07-28",
|
|
"Mcp-Method": "tools/call",
|
|
"Mcp-Name": "get_ddns_status",
|
|
"Accept": "application/json, text/event-stream",
|
|
}
|
|
mcp_status, _, mcp_body = self._http_request(
|
|
port, "/mcp", method="POST", payload=mcp_request, headers=mcp_headers
|
|
)
|
|
self.assertEqual(mcp_status, 200, mcp_body)
|
|
self.assertEqual(json.loads(mcp_body)["result"]["structuredContent"]["state"], "synced")
|
|
|
|
callback_requests = self.fixture_state.requests_for("/callback/web")
|
|
self.assertEqual(len(callback_requests), 1)
|
|
self.assertEqual(
|
|
json.loads(callback_requests[0]["body"]),
|
|
{"domain": "web.example.com", "ip": TEST_IPV4, "record_type": "A", "ttl": "180", "line": "web"},
|
|
)
|
|
|
|
config["cache"] = False
|
|
cache_disable_status, _, cache_disable_body = self._http_request(
|
|
port, "/api/config", method="PUT", payload={"config": config}, headers=config_headers
|
|
)
|
|
self.assertEqual(cache_disable_status, 200, cache_disable_body)
|
|
|
|
configure_status, _, configure_body = self._http_request(
|
|
port,
|
|
"/api/scheduler",
|
|
method="POST",
|
|
payload={"action": "configure", "scheduler": "web", "interval": 1},
|
|
headers=config_headers,
|
|
)
|
|
disable_status, _, disable_body = self._http_request(
|
|
port,
|
|
"/api/scheduler",
|
|
method="POST",
|
|
payload={"action": "disable", "scheduler": "web", "interval": 1},
|
|
headers=config_headers,
|
|
)
|
|
enable_status, _, enable_body = self._http_request(
|
|
port,
|
|
"/api/scheduler",
|
|
method="POST",
|
|
payload={"action": "enable", "scheduler": "web", "interval": 1},
|
|
headers=config_headers,
|
|
)
|
|
|
|
self.assertEqual(configure_status, 200, configure_body)
|
|
self.assertEqual(json.loads(configure_body)["scheduler"]["interval"], 1)
|
|
self.assertEqual(disable_status, 200, disable_body)
|
|
self.assertFalse(json.loads(disable_body)["scheduler"]["enabled"])
|
|
self.assertEqual(enable_status, 200, enable_body)
|
|
self.assertTrue(json.loads(enable_body)["scheduler"]["enabled"])
|
|
|
|
deadline = time.time() + 75
|
|
scheduled_dashboard = None
|
|
while time.time() < deadline:
|
|
time.sleep(1)
|
|
dashboard_status, _, dashboard_body = self._http_request(port, "/api/dashboard", headers=api_headers)
|
|
dashboard = json.loads(dashboard_body)
|
|
if (
|
|
dashboard_status == 200
|
|
and dashboard["scheduler"]["last_run"] is not None
|
|
and len(self.fixture_state.requests_for("/callback/web")) >= 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()
|