mirror of
https://github.com/NewFuture/DDNS.git
synced 2026-10-20 00:16:45 +00:00
* feat(mcp): add Streamable HTTP transport Add a modern MCP 2026-07-28 HTTP endpoint for standalone and Web modes, with shared listener configuration, authentication, schema, tests, and bilingual documentation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(tests): support Python 2 HTTP assertions Normalize response header names and call handler methods through their underlying functions so the HTTP tests behave consistently on Python 2.7 and Python 3. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(mcp): harden HTTP request handling Validate mirrored request IDs, align Config Studio listener checks, bound socket reads, and accept the binary wrapper's documented SIGINT exit status. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(mcp): align HTTP negotiation validation Reject unavailable Accept media types by quality and keep schema host validation consistent with runtime listener checks. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(tests): validate host schema behavior Assert accepted and rejected host samples instead of comparing Unicode escape representations that differ on Python 2.7. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(tests): avoid cross-engine schema regex Keep the schema pattern contract without executing its ECMA-262 Unicode escape through Python 2's incompatible regex engine. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(mcp): validate HTTP request boundaries Reject empty tokens and malformed JSON-RPC request shapes, and keep Config Studio bracket validation aligned with runtime listener handling. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(mcp): tighten HTTP input validation Reject legacy notifications, nested host brackets, and document the required JSON content type so validation remains consistent across runtime, schema, and Config Studio. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(mcp): validate Origin before method errors Apply the existing Origin policy to non-POST MCP requests and return CORS headers for configured allowed origins. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * docs(mcp): clarify proxy Origin allowlist Explain that browser clients behind TLS termination must allowlist the public HTTPS origin even when the public page appears same-origin. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * docs(mcp): add standalone server guide Move MCP client setup, transports, tools, security, and protocol limits into dedicated Chinese and English pages with matching navigation and index links. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(mcp): reject duplicate routing headers Reject repeated MCP protocol, method, and name headers before body consistency checks across Python 2 and Python 3. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(mcp): validate named request headers Validate Mcp-Name for tools/call, prompts/get, and resources/read before dispatching unsupported methods. 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>
1274 lines
56 KiB
Python
1274 lines
56 KiB
Python
# coding=utf-8
|
|
"""Tests for the embedded DDNS dashboard service and HTTP server."""
|
|
|
|
from __future__ import unicode_literals
|
|
|
|
import copy
|
|
import io
|
|
import json
|
|
import logging
|
|
import os
|
|
import shutil
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
|
|
from __init__ import MagicMock, patch, unittest
|
|
|
|
try:
|
|
from urllib.error import HTTPError
|
|
from urllib.request import Request, urlopen
|
|
except ImportError: # Python 2
|
|
from urllib2 import HTTPError, Request, urlopen
|
|
|
|
from ddns.provider import get_provider_class
|
|
from ddns.mcp import CLIENT_CAPABILITIES_KEY, PROTOCOL_VERSION, PROTOCOL_VERSION_KEY
|
|
from ddns.web.server import DashboardRequestHandler, _resource_bytes, _write_stdout, create_server, serve
|
|
from ddns.web.scheduler import WebScheduler
|
|
from ddns.web.service import (
|
|
CONFIG_MODEL,
|
|
ConfigValidationError,
|
|
DashboardOperationError,
|
|
DashboardService,
|
|
_replace_file,
|
|
resolve_config_path,
|
|
validate_document,
|
|
)
|
|
|
|
|
|
def _valid_config(line="default"):
|
|
"""Return a small valid dashboard configuration."""
|
|
return {
|
|
"$schema": "https://ddns.newfuture.cc/schema/v4.1.json",
|
|
"ssl": "auto",
|
|
"proxy": "DIRECT",
|
|
"cache": True,
|
|
"cache_max_age": 259200,
|
|
"log": {"level": "INFO"},
|
|
"providers": [
|
|
{
|
|
"provider": "debug",
|
|
"id": "",
|
|
"token": "",
|
|
"ipv4": ["home.example.com"],
|
|
"ipv6": [],
|
|
"index4": "public",
|
|
"index6": False,
|
|
"ttl": 300,
|
|
"line": line,
|
|
}
|
|
],
|
|
}
|
|
|
|
|
|
class TestDashboardAssets(unittest.TestCase):
|
|
"""Test the standalone source and packaged asset loading paths."""
|
|
|
|
def test_frontend_sources_live_in_top_level_web_directory(self):
|
|
"""Keep one canonical frontend source outside the Python package."""
|
|
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir))
|
|
for asset_name in ("index.html", "dashboard.css", "dashboard.js", "ddns.svg"):
|
|
self.assertTrue(os.path.isfile(os.path.join(project_root, "web", asset_name)))
|
|
self.assertFalse(os.path.isfile(os.path.join(project_root, "ddns", "web", "static", asset_name)))
|
|
|
|
@patch("ddns.web.server.pkgutil.get_data")
|
|
def test_resource_loader_prefers_standalone_source(self, mock_get_data):
|
|
"""Read live frontend sources directly during repository development."""
|
|
self.assertIn(b"<html", _resource_bytes("index.html"))
|
|
mock_get_data.assert_not_called()
|
|
|
|
@patch("ddns.web.server.SOURCE_ASSET_ROOT", "source")
|
|
@patch("ddns.web.server.PACKAGED_ASSET_ROOT", "packaged")
|
|
@patch("ddns.web.server._resource_file_bytes")
|
|
def test_resource_loader_prefers_packaged_assets(self, mock_file_bytes):
|
|
"""Do not let an unrelated sibling Web directory shadow installed assets."""
|
|
mock_file_bytes.side_effect = lambda root, _name: root.encode("ascii")
|
|
|
|
self.assertEqual(_resource_bytes("index.html"), b"packaged")
|
|
mock_file_bytes.assert_called_once_with("packaged", "index.html")
|
|
|
|
@patch("ddns.web.server.pkgutil.get_data", return_value=b"packaged asset")
|
|
@patch("ddns.web.server._resource_file_bytes", return_value=None)
|
|
def test_resource_loader_falls_back_to_package_data(self, mock_file_bytes, mock_get_data):
|
|
"""Read materialized package data after installation or freezing."""
|
|
self.assertEqual(_resource_bytes("index.html"), b"packaged asset")
|
|
self.assertEqual(mock_file_bytes.call_count, 2)
|
|
mock_get_data.assert_called_once_with("ddns.web", "static/index.html")
|
|
|
|
|
|
class TestDashboardService(unittest.TestCase):
|
|
"""Test configuration persistence and runtime projections."""
|
|
|
|
def setUp(self):
|
|
"""Create an isolated local configuration path."""
|
|
self.temp_dir = tempfile.mkdtemp()
|
|
self.addCleanup(shutil.rmtree, self.temp_dir, ignore_errors=True)
|
|
self.config_path = os.path.join(self.temp_dir, "config.json")
|
|
self.scheduler_patcher = patch("ddns.web.service.get_schedulers")
|
|
self.mock_get_schedulers = self.scheduler_patcher.start()
|
|
self.addCleanup(self.scheduler_patcher.stop)
|
|
self.mock_scheduler = MagicMock()
|
|
self.mock_get_schedulers.return_value = [self.mock_scheduler]
|
|
self.mock_scheduler.get_status.return_value = {"scheduler": "test", "installed": False, "enabled": False}
|
|
self.env_patcher = patch("ddns.web.service.load_env_config", return_value={})
|
|
self.mock_load_env_config = self.env_patcher.start()
|
|
self.addCleanup(self.env_patcher.stop)
|
|
self.service = DashboardService(config_path=self.config_path)
|
|
|
|
def test_validate_accepts_supported_compact_values(self):
|
|
"""Canonicalize scalar address sources and proxy strings."""
|
|
config = _valid_config()
|
|
config["providers"][0]["ipv4"] = "home.example.com, nas.example.com"
|
|
validated = validate_document(config)
|
|
|
|
self.assertEqual(validated["proxy"], ["DIRECT"])
|
|
self.assertEqual(validated["providers"][0]["ipv4"], ["home.example.com", "nas.example.com"])
|
|
self.assertEqual(validated["providers"][0]["index4"], ["public"])
|
|
self.assertFalse(validated["providers"][0]["index6"])
|
|
|
|
def test_shared_field_model_matches_public_schema(self):
|
|
"""Keep both configuration UIs aligned with schema v4.1."""
|
|
schema_path = os.path.join(os.path.dirname(__file__), "..", "schema", "v4.1.json")
|
|
with io.open(schema_path, "r", encoding="utf-8") as schema_file:
|
|
schema = json.load(schema_file)
|
|
|
|
provider_properties = schema["properties"]["providers"]["items"]["properties"]
|
|
provider_ids = [provider["id"] for provider in CONFIG_MODEL["providers"]]
|
|
self.assertEqual(CONFIG_MODEL["schema"]["url"], schema["$id"])
|
|
self.assertEqual(CONFIG_MODEL["schema"]["values"], schema["properties"]["$schema"]["enum"])
|
|
self.assertEqual(provider_ids, provider_properties["provider"]["enum"])
|
|
self.assertEqual(CONFIG_MODEL["rules"]["domainPattern"], schema["properties"]["ipv4"]["items"]["pattern"])
|
|
self.assertEqual(CONFIG_MODEL["rules"]["proxyPattern"], schema["properties"]["proxy"]["items"]["pattern"])
|
|
self.assertEqual(CONFIG_MODEL["rules"]["logLevels"], schema["properties"]["log"]["properties"]["level"]["enum"])
|
|
self.assertTrue(all(get_provider_class(provider_id) is not None for provider_id in provider_ids))
|
|
|
|
def test_validate_canonicalizes_runtime_boolean_strings(self):
|
|
"""Preserve existing false-source and string-boolean semantics."""
|
|
config = _valid_config()
|
|
config["ssl"] = "false"
|
|
config["cache"] = "no"
|
|
config["providers"][0]["index4"] = "false"
|
|
config["providers"][0]["index6"] = "none"
|
|
|
|
validated = validate_document(config)
|
|
|
|
self.assertFalse(validated["ssl"])
|
|
self.assertFalse(validated["cache"])
|
|
self.assertFalse(validated["providers"][0]["index4"])
|
|
self.assertFalse(validated["providers"][0]["index6"])
|
|
|
|
def test_validate_preserves_zero_interface_index(self):
|
|
"""Keep interface index zero as a valid scalar address source."""
|
|
config = _valid_config()
|
|
config["providers"][0]["index4"] = 0
|
|
|
|
validated = validate_document(config)
|
|
|
|
self.assertEqual(validated["providers"][0]["index4"], [0])
|
|
|
|
def test_validate_rejects_malformed_provider_container(self):
|
|
"""Do not silently replace an explicit malformed providers value."""
|
|
with self.assertRaises(ConfigValidationError) as context:
|
|
validate_document({"providers": {}})
|
|
|
|
self.assertIn("providers must be an array", str(context.exception))
|
|
|
|
def test_validate_preserves_ssl_paths_and_null_lines(self):
|
|
"""Keep custom CA paths and default DNS-line semantics intact."""
|
|
config = _valid_config(line=None)
|
|
config["ssl"] = "/etc/ssl/private/ddns-ca.pem"
|
|
|
|
validated = validate_document(config)
|
|
|
|
self.assertEqual(validated["ssl"], "/etc/ssl/private/ddns-ca.pem")
|
|
self.assertIsNone(validated["providers"][0]["line"])
|
|
|
|
def test_legacy_provider_extensions_are_migrated(self):
|
|
"""Move legacy unknown provider fields into the v4.1 extra object."""
|
|
validated = validate_document(
|
|
{
|
|
"dns": "debug",
|
|
"proxied": True,
|
|
"extra_comment": "managed",
|
|
"custom": {"setting": "value"},
|
|
"ssl": {"verify": True},
|
|
}
|
|
)
|
|
|
|
self.assertEqual(validated["providers"][0]["extra"]["proxied"], True)
|
|
self.assertEqual(validated["providers"][0]["extra"]["comment"], "managed")
|
|
self.assertEqual(validated["providers"][0]["extra"]["custom_setting"], "value")
|
|
self.assertTrue(validated["providers"][0]["extra"]["ssl_verify"])
|
|
|
|
def test_legacy_single_provider_promotes_global_fields_once(self):
|
|
"""Keep promoted legacy settings out of hidden provider overrides."""
|
|
validated = validate_document(
|
|
{
|
|
"dns": "debug",
|
|
"ipv4": ["legacy.example.com"],
|
|
"ssl": False,
|
|
"proxy": "DIRECT",
|
|
"cache": False,
|
|
"cache_max_age": 42,
|
|
"log_level": "ERROR",
|
|
}
|
|
)
|
|
|
|
provider = validated["providers"][0]
|
|
for key in ("ssl", "proxy", "cache", "cache_max_age", "log"):
|
|
self.assertNotIn(key, provider)
|
|
self.assertFalse(validated["ssl"])
|
|
self.assertEqual(validated["proxy"], ["DIRECT"])
|
|
self.assertFalse(validated["cache"])
|
|
self.assertEqual(validated["cache_max_age"], 42)
|
|
self.assertEqual(validated["log"], {"level": "ERROR"})
|
|
|
|
def test_interval_is_validated_and_kept_global(self):
|
|
"""Preserve a bounded root interval without leaking it to providers."""
|
|
config = _valid_config()
|
|
config["interval"] = 7
|
|
|
|
validated = validate_document(config)
|
|
|
|
self.assertEqual(validated["interval"], 7)
|
|
self.assertNotIn("interval", validated["providers"][0])
|
|
for invalid in (True, 0, 1441, 1.5, "5"):
|
|
config["interval"] = invalid
|
|
with self.assertRaises(ConfigValidationError):
|
|
validate_document(config)
|
|
|
|
def test_http_settings_are_validated_and_kept_global(self):
|
|
"""Canonicalize the root listener without leaking it into providers."""
|
|
config = _valid_config()
|
|
config["http"] = {
|
|
"host": "0.0.0.0",
|
|
"port": "9877",
|
|
"token": "x",
|
|
"origins": ["https://CLIENT.example/", "https://client.example"],
|
|
}
|
|
|
|
validated = validate_document(config)
|
|
|
|
self.assertEqual(
|
|
validated["http"], {"host": "0.0.0.0", "port": 9877, "token": "x", "origins": ["https://client.example"]}
|
|
)
|
|
self.assertNotIn("http", validated["providers"][0])
|
|
|
|
def test_partial_http_settings_preserve_environment_fallbacks(self):
|
|
"""Do not materialize omitted fields that should inherit at startup."""
|
|
config = _valid_config()
|
|
config["http"] = {"port": "9877"}
|
|
|
|
validated = validate_document(config)
|
|
|
|
self.assertEqual(validated["http"], {"port": 9877})
|
|
|
|
def test_http_settings_reject_provider_scope_and_null_object(self):
|
|
"""Keep listener settings root-only and structurally valid."""
|
|
provider_config = _valid_config()
|
|
provider_config["providers"][0]["http"] = {"host": "127.0.0.1"}
|
|
lan_config = _valid_config()
|
|
lan_config["http"] = {"host": "0.0.0.0"}
|
|
null_config = _valid_config()
|
|
null_config["http"] = None
|
|
|
|
with self.assertRaises(ConfigValidationError):
|
|
validate_document(provider_config)
|
|
self.assertEqual(validate_document(lan_config)["http"], {"host": "0.0.0.0"})
|
|
with self.assertRaises(ConfigValidationError):
|
|
validate_document(null_config)
|
|
|
|
def test_provider_interval_is_rejected(self):
|
|
"""Require scheduling metadata at the document root."""
|
|
config = _valid_config()
|
|
config["providers"][0]["interval"] = 7
|
|
|
|
with self.assertRaises(ConfigValidationError):
|
|
validate_document(config)
|
|
|
|
def test_runtime_config_uses_existing_v41_inheritance(self):
|
|
"""Apply global fields and provider overrides like the normal file loader."""
|
|
config = _valid_config()
|
|
config["ttl"] = 120
|
|
config["index4"] = ["public"]
|
|
config["extra"] = {"proxied": True}
|
|
del config["providers"][0]["ttl"]
|
|
del config["providers"][0]["index4"]
|
|
config["providers"][0]["ssl"] = "/etc/ssl/provider-ca.pem"
|
|
|
|
runtime = self.service._runtime_configs(validate_document(config))[0]
|
|
|
|
self.assertEqual(runtime.ttl, 120)
|
|
self.assertEqual(runtime.index4, ["public"])
|
|
self.assertEqual(runtime.ssl, "/etc/ssl/provider-ca.pem")
|
|
self.assertTrue(runtime.extra["proxied"])
|
|
|
|
def test_runtime_config_inherits_environment_values(self):
|
|
"""Use the same environment fallback as the normal configuration loader."""
|
|
config = _valid_config()
|
|
provider = config["providers"][0]
|
|
for key in ("id", "token", "ipv4"):
|
|
del provider[key]
|
|
self.mock_load_env_config.return_value = {
|
|
"id": "environment-id",
|
|
"token": "environment-token",
|
|
"ipv4": "environment.example.com",
|
|
}
|
|
|
|
runtime = self.service._runtime_configs(validate_document(config))[0]
|
|
|
|
self.assertEqual(runtime.id, "environment-id")
|
|
self.assertEqual(runtime.token, "environment-token")
|
|
self.assertEqual(runtime.ipv4, ["environment.example.com"])
|
|
|
|
def test_omitted_global_fields_continue_to_inherit_environment(self):
|
|
"""Do not materialize defaults that would override environment values."""
|
|
config = {"providers": [{"provider": "debug", "ipv4": ["home.example.com"]}]}
|
|
self.mock_load_env_config.return_value = {
|
|
"ssl": "false",
|
|
"proxy": "DIRECT",
|
|
"cache": "false",
|
|
"cache_max_age": "42",
|
|
"log_level": "ERROR",
|
|
}
|
|
|
|
validated = self.service.validate(config)
|
|
runtime = self.service._runtime_configs(validated)[0]
|
|
|
|
for key in ("ssl", "proxy", "cache", "cache_max_age", "log"):
|
|
self.assertNotIn(key, validated)
|
|
self.assertFalse(runtime.ssl)
|
|
self.assertEqual(runtime.proxy, ["DIRECT"])
|
|
self.assertFalse(runtime.cache)
|
|
self.assertEqual(runtime.cache_max_age, 42)
|
|
self.assertEqual(runtime.log_level, 40)
|
|
|
|
def test_legacy_config_uses_environment_provider_without_losing_fields(self):
|
|
"""Normalize a legacy file whose provider is selected by DDNS_DNS."""
|
|
self.mock_load_env_config.return_value = {"dns": "debug"}
|
|
legacy = {"token": "environment-selected-token", "ipv4": ["legacy.example.com"], "index4": "public"}
|
|
|
|
validated = self.service.validate(legacy)
|
|
|
|
self.assertEqual(validated["providers"][0]["provider"], "debug")
|
|
self.assertEqual(validated["providers"][0]["token"], "environment-selected-token")
|
|
self.assertEqual(validated["providers"][0]["ipv4"], ["legacy.example.com"])
|
|
|
|
def test_legacy_config_without_provider_is_recoverable(self):
|
|
"""Report a legacy file that needs DDNS_DNS instead of discarding it."""
|
|
with io.open(self.config_path, "w", encoding="utf-8") as config_file:
|
|
config_file.write(json.dumps({"token": "keep-me", "ipv4": ["legacy.example.com"]}, ensure_ascii=False))
|
|
|
|
state = self.service.config_state()
|
|
|
|
self.assertIn("DDNS_DNS", state["validation_error"])
|
|
self.assertIn("keep-me", state["raw"])
|
|
self.assertEqual(state["config"]["providers"], [])
|
|
|
|
def test_legacy_list_preserves_independent_environment_fallbacks(self):
|
|
"""Do not turn the first legacy entry's settings into global overrides."""
|
|
self.mock_load_env_config.return_value = {"dns": "debug", "proxy": "SYSTEM"}
|
|
legacy = [{"ipv4": ["first.example.com"], "proxy": "DIRECT"}, {"ipv4": ["second.example.com"]}]
|
|
|
|
validated = self.service.validate(legacy)
|
|
runtime = self.service._runtime_configs(validated)
|
|
|
|
self.assertNotIn("proxy", validated)
|
|
self.assertEqual(validated["providers"][0]["proxy"], ["DIRECT"])
|
|
self.assertNotIn("proxy", validated["providers"][1])
|
|
self.assertEqual(runtime[0].proxy, ["DIRECT"])
|
|
self.assertEqual(runtime[1].proxy, ["SYSTEM"])
|
|
|
|
def test_legacy_list_rejects_non_object_entries(self):
|
|
"""Reject malformed legacy entries rather than silently dropping them."""
|
|
with self.assertRaises(ConfigValidationError) as context:
|
|
validate_document([{"dns": "debug"}, "invalid"])
|
|
|
|
self.assertIn("Provider 2", str(context.exception))
|
|
|
|
def test_invalid_global_provider_field_is_rejected(self):
|
|
"""Reject invalid inherited values before they can break dashboard reads."""
|
|
config = _valid_config()
|
|
config["ttl"] = "not-a-number"
|
|
|
|
with self.assertRaises(ConfigValidationError) as context:
|
|
validate_document(config)
|
|
self.assertIn("Global TTL", str(context.exception))
|
|
|
|
def test_shared_validation_rules_reject_invalid_values(self):
|
|
"""Apply the same domain, proxy, and source rules as Config Studio."""
|
|
invalid_values = [
|
|
("ipv4", ["not a domain"]),
|
|
("index4", ["unsupported"]),
|
|
("proxy", ["socks5://127.0.0.1:1080"]),
|
|
]
|
|
for key, value in invalid_values:
|
|
config = _valid_config()
|
|
target = config if key == "proxy" else config["providers"][0]
|
|
target[key] = value
|
|
with self.assertRaises(ConfigValidationError):
|
|
validate_document(config)
|
|
|
|
def test_providers_reject_global_legacy_fields(self):
|
|
"""Prevent top-level provider credentials from leaking across providers."""
|
|
values = {
|
|
"dns": "debug",
|
|
"provider": "debug",
|
|
"id": "global-id",
|
|
"token": "global-token",
|
|
"ipv4": ["global.example.com"],
|
|
"ipv6": ["global.example.com"],
|
|
"endpoint": "https://api.example.com",
|
|
}
|
|
for key, value in values.items():
|
|
config = _valid_config()
|
|
config[key] = value
|
|
with self.assertRaises(ConfigValidationError) as context:
|
|
validate_document(config)
|
|
self.assertIn(key, str(context.exception))
|
|
|
|
def test_provider_rejects_hidden_dns_override(self):
|
|
"""Prevent a provider entry from overriding its validated provider at runtime."""
|
|
config = _valid_config()
|
|
config["providers"][0]["dns"] = "cloudflare"
|
|
|
|
with self.assertRaises(ConfigValidationError) as context:
|
|
validate_document(config)
|
|
|
|
self.assertIn("cannot contain dns", str(context.exception))
|
|
|
|
def test_reserved_extra_fields_are_rejected(self):
|
|
"""Reject custom fields that collide with runtime record arguments."""
|
|
for key in CONFIG_MODEL["rules"]["reservedExtraKeys"]:
|
|
config = _valid_config()
|
|
config["providers"][0]["extra"] = {key: "conflict"}
|
|
with self.assertRaises(ConfigValidationError) as context:
|
|
validate_document(config)
|
|
self.assertIn(key, str(context.exception))
|
|
|
|
def test_numeric_validation_does_not_truncate_values(self):
|
|
"""Reject fractional cache ages while preserving valid numeric TTL values."""
|
|
config = _valid_config()
|
|
config["cache_max_age"] = 12.5
|
|
with self.assertRaises(ConfigValidationError):
|
|
validate_document(config)
|
|
|
|
config = _valid_config()
|
|
config["providers"][0]["ttl"] = 12.5
|
|
validated = validate_document(config)
|
|
self.assertEqual(validated["providers"][0]["ttl"], 12.5)
|
|
|
|
def test_save_and_restore_unicode_configuration(self):
|
|
"""Write UTF-8 config atomically and swap to its backup."""
|
|
first = self.service.save(_valid_config(line="默认"))
|
|
second = self.service.save(_valid_config(line="telecom"))
|
|
|
|
self.assertEqual(first["config"]["providers"][0]["line"], "默认")
|
|
self.assertTrue(second["backup_available"])
|
|
with io.open(self.config_path, "r", encoding="utf-8") as config_file:
|
|
self.assertIn("telecom", config_file.read())
|
|
|
|
restored = self.service.restore_backup()
|
|
|
|
self.assertEqual(restored["config"]["providers"][0]["line"], "默认")
|
|
with io.open(self.config_path, "r", encoding="utf-8") as config_file:
|
|
self.assertIn("默认", config_file.read())
|
|
|
|
def test_failed_save_preserves_current_file_and_previous_backup(self):
|
|
"""Roll back both generations when installing the new file fails."""
|
|
self.service.save(_valid_config(line="first"))
|
|
self.service.save(_valid_config(line="second"))
|
|
real_replace = _replace_file
|
|
|
|
def fail_new_file(source, destination):
|
|
"""Fail only while installing the newly written temporary file."""
|
|
if os.path.basename(source).startswith(".ddns-config-") and destination == self.config_path:
|
|
raise OSError("simulated install failure")
|
|
return real_replace(source, destination)
|
|
|
|
with patch("ddns.web.service._replace_file", side_effect=fail_new_file):
|
|
with self.assertRaises(DashboardOperationError):
|
|
self.service.save(_valid_config(line="third"))
|
|
|
|
with io.open(self.config_path, "r", encoding="utf-8") as config_file:
|
|
current = json.load(config_file)
|
|
with io.open(self.config_path + ".bak", "r", encoding="utf-8") as backup_file:
|
|
backup = json.load(backup_file)
|
|
self.assertEqual(current["providers"][0]["line"], "second")
|
|
self.assertEqual(backup["providers"][0]["line"], "first")
|
|
self.assertFalse(any(name.startswith(".ddns-backup-") for name in os.listdir(self.temp_dir)))
|
|
|
|
def test_save_replaces_active_configuration_without_missing_path(self):
|
|
"""Keep the active path readable until the atomic replacement."""
|
|
self.service.save(_valid_config(line="first"))
|
|
observations = []
|
|
|
|
def observe_replace(source, destination):
|
|
"""Record active-path availability immediately before replacement."""
|
|
if os.path.basename(source).startswith(".ddns-config-") and destination == self.config_path:
|
|
observations.append(os.path.exists(self.config_path))
|
|
return _replace_file(source, destination)
|
|
|
|
with patch("ddns.web.service._replace_file", side_effect=observe_replace):
|
|
self.service.save(_valid_config(line="second"))
|
|
|
|
self.assertEqual(observations, [True])
|
|
|
|
def test_restore_replaces_active_configuration_without_missing_path(self):
|
|
"""Keep the active path readable until a backup is restored."""
|
|
self.service.save(_valid_config(line="first"))
|
|
self.service.save(_valid_config(line="second"))
|
|
observations = []
|
|
|
|
def observe_replace(source, destination):
|
|
"""Record active-path availability immediately before replacement."""
|
|
if os.path.basename(source).startswith(".ddns-restore-") and destination == self.config_path:
|
|
observations.append(os.path.exists(self.config_path))
|
|
return _replace_file(source, destination)
|
|
|
|
with patch("ddns.web.service._replace_file", side_effect=observe_replace):
|
|
restored = self.service.restore_backup()
|
|
|
|
self.assertEqual(observations, [True])
|
|
self.assertEqual(restored["config"]["providers"][0]["line"], "first")
|
|
|
|
def test_restore_valid_backup_over_invalid_active_configuration(self):
|
|
"""Allow recovery even when the current file cannot be validated."""
|
|
self.service.save(_valid_config(line="first"))
|
|
self.service.save(_valid_config(line="second"))
|
|
with io.open(self.config_path, "w", encoding="utf-8") as config_file:
|
|
config_file.write("{ invalid")
|
|
|
|
restored = self.service.restore_backup()
|
|
|
|
self.assertEqual(restored["config"]["providers"][0]["line"], "first")
|
|
|
|
def test_save_preserves_configuration_symlink(self):
|
|
"""Write through a configuration symlink without replacing the link."""
|
|
if not hasattr(os, "symlink"):
|
|
self.skipTest("Symbolic links are unavailable")
|
|
target_path = os.path.join(self.temp_dir, "target.json")
|
|
link_path = os.path.join(self.temp_dir, "linked.json")
|
|
with io.open(target_path, "w", encoding="utf-8") as config_file:
|
|
config_file.write(json.dumps(_valid_config(line="first"), ensure_ascii=False))
|
|
try:
|
|
os.symlink(target_path, link_path)
|
|
except (IOError, OSError) as error:
|
|
self.skipTest("Cannot create symbolic link: {}".format(error))
|
|
service = DashboardService(config_path=link_path)
|
|
|
|
state = service.save(_valid_config(line="second"))
|
|
|
|
self.assertTrue(os.path.islink(link_path))
|
|
with io.open(target_path, "r", encoding="utf-8") as config_file:
|
|
self.assertEqual(json.load(config_file)["providers"][0]["line"], "second")
|
|
with io.open(target_path + ".bak", "r", encoding="utf-8") as backup_file:
|
|
self.assertEqual(json.load(backup_file)["providers"][0]["line"], "first")
|
|
self.assertTrue(state["backup_available"])
|
|
|
|
def test_non_ascii_byte_config_path_is_normalized(self):
|
|
"""Decode command-line paths once before Unicode path operations."""
|
|
unicode_path = os.path.join(self.temp_dir, "配置.json")
|
|
encoded_path = unicode_path.encode(sys.getfilesystemencoding() or "utf-8")
|
|
|
|
resolved = resolve_config_path(encoded_path)
|
|
service = DashboardService(config_path=encoded_path)
|
|
service.save(_valid_config())
|
|
|
|
self.assertEqual(resolved, unicode_path)
|
|
self.assertEqual(service.config_path, unicode_path)
|
|
self.assertTrue(os.path.exists(unicode_path))
|
|
|
|
def test_dashboard_reports_configured_records_without_secrets(self):
|
|
"""Project saved providers into dashboard status without exposing secrets."""
|
|
config = _valid_config()
|
|
config["providers"][0]["token"] = "dashboard-secret"
|
|
self.service.save(config)
|
|
|
|
dashboard = self.service.dashboard()
|
|
|
|
self.assertEqual(dashboard["state"], "ready")
|
|
self.assertEqual(dashboard["providers"][0]["records"], 1)
|
|
self.assertNotIn("provider_catalog", dashboard)
|
|
self.assertNotIn("config_model", dashboard)
|
|
self.assertNotIn("dashboard-secret", json.dumps(dashboard))
|
|
|
|
def test_dashboard_filters_records_from_shared_cache_by_provider_domains(self):
|
|
"""Do not duplicate or mislabel records from a shared cache file."""
|
|
cache_path = os.path.join(self.temp_dir, "shared.cache")
|
|
config = _valid_config()
|
|
config["cache"] = cache_path
|
|
config["providers"][0]["ipv4"] = ["first.example.com"]
|
|
second_provider = _valid_config()["providers"][0]
|
|
second_provider["provider"] = "callback"
|
|
second_provider["ipv4"] = ["second.example.com"]
|
|
config["providers"].append(second_provider)
|
|
self.service.save(config)
|
|
with io.open(cache_path, "w", encoding="utf-8") as cache_file:
|
|
cache_file.write(
|
|
json.dumps(
|
|
{
|
|
"first.example.com:A": "203.0.113.10",
|
|
"second.example.com:A": "203.0.113.20",
|
|
"unrelated.example.com:A": "203.0.113.30",
|
|
},
|
|
ensure_ascii=False,
|
|
)
|
|
)
|
|
now = time.time()
|
|
os.utime(cache_path, (now, now))
|
|
|
|
dashboard = self.service.dashboard()
|
|
|
|
records = {(record["domain"], record["provider"]) for record in dashboard["records"]}
|
|
self.assertEqual(records, {("first.example.com", "debug"), ("second.example.com", "callback")})
|
|
|
|
def test_dashboard_allows_small_cache_mtime_clock_skew(self):
|
|
"""Do not hide a freshly written cache when file mtime is slightly ahead."""
|
|
cache_path = os.path.join(self.temp_dir, "shared.cache")
|
|
config = _valid_config()
|
|
config["cache"] = cache_path
|
|
self.service.save(config)
|
|
with io.open(cache_path, "w", encoding="utf-8") as cache_file:
|
|
cache_file.write(json.dumps({"home.example.com:A": "203.0.113.10"}, ensure_ascii=False))
|
|
|
|
cache_time = os.path.getmtime(cache_path)
|
|
now = cache_time - 0.5
|
|
with patch("ddns.web.service.time.time", return_value=now):
|
|
dashboard = self.service.dashboard()
|
|
|
|
self.assertEqual(len(dashboard["records"]), 1)
|
|
self.assertEqual(dashboard["records"][0]["domain"], "home.example.com")
|
|
self.assertEqual(dashboard["records"][0]["updated"], now)
|
|
|
|
def test_missing_file_projects_environment_only_configuration(self):
|
|
"""Expose an environment-only provider without persisting default fields."""
|
|
self.mock_load_env_config.return_value = {
|
|
"dns": "debug",
|
|
"token": "environment-only-token",
|
|
"ipv4": "environment.example.com",
|
|
}
|
|
|
|
document = self.service.load_document()
|
|
runtime = self.service._runtime_configs(document)[0]
|
|
|
|
self.assertEqual(document["providers"], [{"provider": "debug"}])
|
|
self.assertEqual(runtime.token, "environment-only-token")
|
|
self.assertEqual(runtime.ipv4, ["environment.example.com"])
|
|
|
|
def test_invalid_config_remains_recoverable(self):
|
|
"""Return invalid source text for repair instead of breaking config API."""
|
|
with io.open(self.config_path, "w", encoding="utf-8") as config_file:
|
|
config_file.write('{"providers": [{"provider": "missing"}]}')
|
|
|
|
state = self.service.config_state()
|
|
|
|
self.assertIn("validation_error", state)
|
|
self.assertIn('"missing"', state["raw"])
|
|
self.assertEqual(state["config"]["providers"], [])
|
|
|
|
def test_unicode_validation_error_remains_api_safe(self):
|
|
"""Keep Unicode validation details intact on Python 2 and Python 3."""
|
|
with io.open(self.config_path, "w", encoding="utf-8") as config_file:
|
|
config_file.write("{}")
|
|
|
|
with patch.object(self.service, "_validate_document", side_effect=ConfigValidationError("配置无效")):
|
|
state = self.service.config_state()
|
|
|
|
self.assertEqual(state["validation_error"], "配置无效")
|
|
|
|
def test_unicode_provider_error_remains_recoverable(self):
|
|
"""Treat a non-ASCII unknown provider as a normal validation error."""
|
|
with io.open(self.config_path, "w", encoding="utf-8") as config_file:
|
|
config_file.write(json.dumps({"providers": [{"provider": "未知"}]}, ensure_ascii=False))
|
|
|
|
state = self.service.config_state()
|
|
|
|
self.assertIn("未知", state["validation_error"])
|
|
self.assertIn("未知", state["raw"])
|
|
|
|
@patch("ddns.__main__.run", return_value=True)
|
|
def test_sync_uses_runtime_config(self, mock_run):
|
|
"""Run the current normalized configuration through the DDNS engine."""
|
|
self.service.save(_valid_config())
|
|
|
|
dashboard = self.service.sync()
|
|
|
|
mock_run.assert_called_once()
|
|
runtime_config = mock_run.call_args[0][0]
|
|
self.assertEqual(runtime_config.dns, "debug")
|
|
self.assertEqual(runtime_config.index4, ["public"])
|
|
self.assertEqual(dashboard["activities"][0]["message"], "所有配置同步完成")
|
|
|
|
@patch("ddns.__main__.run")
|
|
def test_sync_applies_and_restores_runtime_logging(self, mock_run):
|
|
"""Honor persisted log output settings without replacing Web handlers."""
|
|
log_path = os.path.join(self.temp_dir, "runtime.log")
|
|
config = _valid_config()
|
|
config["cache"] = False
|
|
config["log"] = {"level": "WARNING", "file": log_path, "format": "SYNC:%(message)s", "datefmt": "%H:%M"}
|
|
self.service.save(config)
|
|
root_logger = logging.getLogger()
|
|
previous_level = root_logger.level
|
|
previous_handlers = list(root_logger.handlers)
|
|
previous_formatters = [handler.formatter for handler in previous_handlers]
|
|
|
|
def emit_runtime_log(_config):
|
|
"""Emit one message through the root logger used by providers."""
|
|
logging.getLogger("runtime-test").warning("runtime-message")
|
|
return True
|
|
|
|
mock_run.side_effect = emit_runtime_log
|
|
self.service.sync()
|
|
|
|
with io.open(log_path, "r", encoding="utf-8") as log_file:
|
|
self.assertIn("SYNC:runtime-message", log_file.read())
|
|
self.assertEqual(root_logger.level, previous_level)
|
|
self.assertEqual(root_logger.handlers, previous_handlers)
|
|
self.assertEqual([handler.formatter for handler in previous_handlers], previous_formatters)
|
|
|
|
@patch("ddns.__main__.run", return_value=True)
|
|
def test_sync_rejects_configuration_without_domains(self, mock_run):
|
|
"""Keep domain-less providers in first-run and reject a no-op sync."""
|
|
config = _valid_config()
|
|
config["providers"][0]["ipv4"] = []
|
|
config["providers"][0]["ipv6"] = []
|
|
self.service.save(config)
|
|
|
|
self.assertEqual(self.service.dashboard()["state"], "unconfigured")
|
|
with self.assertRaises(ConfigValidationError):
|
|
self.service.sync()
|
|
mock_run.assert_not_called()
|
|
|
|
@patch("ddns.__main__.run", return_value=True)
|
|
def test_sync_reports_success_when_cache_is_disabled(self, mock_run):
|
|
"""Project successful runtime state without inventing cache records."""
|
|
config = _valid_config()
|
|
config["cache"] = False
|
|
self.service.save(config)
|
|
|
|
dashboard = self.service.sync()
|
|
|
|
mock_run.assert_called_once()
|
|
self.assertEqual(dashboard["state"], "synced")
|
|
self.assertEqual(dashboard["message"], "最近一次同步已完成")
|
|
self.assertEqual(dashboard["providers"][0]["status"], "synced")
|
|
self.assertIsNotNone(dashboard["last_sync"])
|
|
self.assertEqual(dashboard["records"], [])
|
|
|
|
@patch("ddns.__main__.run", side_effect=[True, False])
|
|
def test_failed_sync_overrides_previous_success_state(self, mock_run):
|
|
"""Report the latest failed attempt instead of stale healthy state."""
|
|
config = _valid_config()
|
|
config["cache"] = False
|
|
self.service.save(config)
|
|
successful = self.service.sync()
|
|
|
|
with self.assertRaises(DashboardOperationError):
|
|
self.service.sync()
|
|
dashboard = self.service.dashboard()
|
|
|
|
self.assertEqual(mock_run.call_count, 2)
|
|
self.assertEqual(successful["state"], "synced")
|
|
self.assertEqual(dashboard["state"], "error")
|
|
self.assertEqual(dashboard["message"], "最近一次同步失败")
|
|
self.assertEqual(dashboard["providers"][0]["status"], "error")
|
|
self.assertEqual(dashboard["last_sync"], successful["last_sync"])
|
|
|
|
@patch("ddns.__main__.run", side_effect=[False, True])
|
|
def test_duplicate_provider_types_keep_independent_sync_status(self, mock_run):
|
|
"""Project failures by provider entry instead of provider type."""
|
|
config = _valid_config()
|
|
config["cache"] = False
|
|
second = copy.deepcopy(config["providers"][0])
|
|
second["ipv4"] = ["second.example.com"]
|
|
config["providers"].append(second)
|
|
self.service.save(config)
|
|
|
|
with self.assertRaises(DashboardOperationError):
|
|
self.service.sync()
|
|
dashboard = self.service.dashboard()
|
|
|
|
self.assertEqual(mock_run.call_count, 2)
|
|
self.assertEqual([provider["status"] for provider in dashboard["providers"]], ["error", "synced"])
|
|
|
|
@patch("ddns.__main__.run")
|
|
def test_sync_stops_before_next_provider_when_cancelled(self, mock_run):
|
|
"""Stop cooperative synchronization between configured providers."""
|
|
config = _valid_config()
|
|
second = copy.deepcopy(config["providers"][0])
|
|
second["ipv4"] = ["second.example.com"]
|
|
config["providers"].append(second)
|
|
self.service.save(config)
|
|
cancel_event = threading.Event()
|
|
|
|
def cancel_after_first(_config, cancelled=None):
|
|
"""Cancel after the first provider returns."""
|
|
self.assertTrue(callable(cancelled))
|
|
cancel_event.set()
|
|
return True
|
|
|
|
mock_run.side_effect = cancel_after_first
|
|
|
|
with self.assertRaises(DashboardOperationError) as context:
|
|
self.service.sync(source="MCP", cancelled=cancel_event.is_set)
|
|
|
|
self.assertIn("cancelled", str(context.exception).lower())
|
|
mock_run.assert_called_once()
|
|
|
|
def test_scheduler_action_uses_selected_interval(self):
|
|
"""Update the interval for the current web process."""
|
|
result = self.service.configure_scheduler("configure", interval=12)
|
|
|
|
self.assertEqual(result["interval"], 12)
|
|
self.assertEqual(os.listdir(self.temp_dir), [])
|
|
|
|
def test_saving_config_interval_updates_current_scheduler(self):
|
|
"""Apply a newly persisted interval to the running Web process."""
|
|
config = _valid_config()
|
|
config["interval"] = 14
|
|
|
|
saved = self.service.save(config)
|
|
|
|
self.assertEqual(saved["config"]["interval"], 14)
|
|
self.assertEqual(self.service.dashboard()["scheduler"]["interval"], 14)
|
|
|
|
def test_scheduler_rejects_fractional_interval(self):
|
|
"""Reject intervals that would otherwise be silently truncated."""
|
|
with self.assertRaises(ConfigValidationError):
|
|
self.service.configure_scheduler("configure", interval=1.5)
|
|
|
|
def test_scheduler_state_resets_on_service_restart(self):
|
|
"""Use CLI startup defaults instead of a runtime sidecar."""
|
|
self.service.configure_scheduler("disable", interval=17)
|
|
|
|
restarted = DashboardService(config_path=self.config_path, scheduler_interval=9)
|
|
status = restarted.dashboard()["scheduler"]
|
|
|
|
self.assertTrue(status["enabled"])
|
|
self.assertEqual(status["interval"], 9)
|
|
|
|
def test_scheduler_reports_external_task_conflict(self):
|
|
"""Prevent web and system schedulers from updating the same records."""
|
|
self.mock_scheduler.get_status.return_value = {"scheduler": "test", "installed": True, "enabled": True}
|
|
|
|
status = self.service.dashboard()["scheduler"]
|
|
|
|
self.assertTrue(status["conflict"])
|
|
self.assertFalse(status["enabled"])
|
|
self.assertEqual(status["external_scheduler"], "test")
|
|
|
|
def test_scheduler_takeover_disables_external_task(self):
|
|
"""Disable the old system task before enabling web scheduling."""
|
|
self.mock_scheduler.get_status.side_effect = [
|
|
{"scheduler": "test", "installed": True, "enabled": True},
|
|
{"scheduler": "test", "installed": True, "enabled": False},
|
|
{"scheduler": "test", "installed": True, "enabled": False},
|
|
]
|
|
self.mock_scheduler.disable.return_value = True
|
|
|
|
status = self.service.configure_scheduler("takeover", interval=8)
|
|
|
|
self.mock_scheduler.disable.assert_called_once_with()
|
|
self.assertTrue(status["enabled"])
|
|
self.assertEqual(status["interval"], 8)
|
|
|
|
def test_scheduler_detects_and_disables_all_enabled_backends(self):
|
|
"""Guard against explicitly selected backends that differ from auto detection."""
|
|
systemd = MagicMock()
|
|
cron = MagicMock()
|
|
systemd.get_status.side_effect = [
|
|
{"scheduler": "systemd", "installed": True, "enabled": True},
|
|
{"scheduler": "systemd", "installed": True, "enabled": True},
|
|
{"scheduler": "systemd", "installed": True, "enabled": False},
|
|
{"scheduler": "systemd", "installed": True, "enabled": False},
|
|
]
|
|
cron.get_status.side_effect = [
|
|
{"scheduler": "cron", "installed": True, "enabled": True},
|
|
{"scheduler": "cron", "installed": True, "enabled": True},
|
|
{"scheduler": "cron", "installed": True, "enabled": False},
|
|
{"scheduler": "cron", "installed": True, "enabled": False},
|
|
]
|
|
systemd.disable.return_value = True
|
|
cron.disable.return_value = True
|
|
self.mock_get_schedulers.return_value = [systemd, cron]
|
|
|
|
conflict = self.service.dashboard()["scheduler"]
|
|
status = self.service.configure_scheduler("takeover", interval=8)
|
|
|
|
self.assertTrue(conflict["conflict"])
|
|
self.assertEqual(conflict["external_scheduler"], "systemd, cron")
|
|
systemd.disable.assert_called_once_with()
|
|
cron.disable.assert_called_once_with()
|
|
self.assertTrue(status["enabled"])
|
|
|
|
|
|
class TestWebScheduler(unittest.TestCase):
|
|
"""Test the dashboard-owned scheduler thread."""
|
|
|
|
@patch("ddns.web.scheduler.MINUTE_SECONDS", 0.01)
|
|
def test_runs_callback_and_stops_cleanly(self):
|
|
"""Run periodic work without an external task process."""
|
|
called = threading.Event()
|
|
scheduler = WebScheduler(called.set, interval=1)
|
|
|
|
scheduler.start()
|
|
self.assertTrue(called.wait(1))
|
|
scheduler.stop()
|
|
|
|
self.assertFalse(scheduler.status()["active"])
|
|
|
|
@patch("ddns.web.scheduler.MINUTE_SECONDS", 0.01)
|
|
def test_paused_scheduler_waits_until_enabled(self):
|
|
"""Keep a paused scheduler idle until explicitly resumed."""
|
|
called = threading.Event()
|
|
scheduler = WebScheduler(called.set, interval=1, enabled=False)
|
|
scheduler.start()
|
|
self.addCleanup(scheduler.stop)
|
|
|
|
time.sleep(0.05)
|
|
self.assertFalse(called.is_set())
|
|
scheduler.configure(enabled=True)
|
|
|
|
self.assertTrue(called.wait(1))
|
|
|
|
@patch("ddns.web.scheduler.MINUTE_SECONDS", 0.01)
|
|
def test_guard_blocks_duplicate_scheduler(self):
|
|
"""Skip runs while an external scheduler owns synchronization."""
|
|
called = threading.Event()
|
|
scheduler = WebScheduler(called.set, interval=1, guard=lambda: (False, "external task"))
|
|
scheduler.start()
|
|
self.addCleanup(scheduler.stop)
|
|
|
|
deadline = time.time() + 1
|
|
while not scheduler.status()["blocked_reason"] and time.time() < deadline:
|
|
time.sleep(0.01)
|
|
|
|
self.assertFalse(called.is_set())
|
|
self.assertEqual(scheduler.status()["blocked_reason"], "external task")
|
|
|
|
|
|
class TestDashboardStartup(unittest.TestCase):
|
|
"""Test safe dashboard process startup."""
|
|
|
|
def test_create_server_preserves_positional_logger_api(self):
|
|
"""Keep the pre-HTTP public create_server positional signature."""
|
|
service = MagicMock()
|
|
service.config_path = "config.json"
|
|
logger = MagicMock()
|
|
|
|
server = create_server(service, "127.0.0.1", 0, logger)
|
|
self.addCleanup(server.server_close)
|
|
|
|
self.assertIsNone(server.access_token)
|
|
logger.getChild.assert_called()
|
|
|
|
@patch("ddns.web.server.sys.stdout")
|
|
def test_status_output_is_flushed(self, mock_stdout):
|
|
"""Expose the launch URL immediately for long-running processes."""
|
|
_write_stdout("ready\n")
|
|
|
|
mock_stdout.write.assert_called_once_with("ready\n")
|
|
mock_stdout.flush.assert_called_once_with()
|
|
|
|
def test_access_log_redacts_launch_token_and_query(self):
|
|
"""Do not persist bootstrap or query credentials in access logs."""
|
|
handler = MagicMock()
|
|
handler.path = "/launch/single-use-secret?token=query-secret"
|
|
handler.address_string.return_value = "127.0.0.1"
|
|
|
|
log_message = getattr(DashboardRequestHandler.log_message, "im_func", DashboardRequestHandler.log_message)
|
|
log_message(handler, '"%s" %s', handler.path, 302)
|
|
|
|
logged = handler.server.logger.info.call_args[0][-1]
|
|
self.assertNotIn("single-use-secret", logged)
|
|
self.assertNotIn("query-secret", logged)
|
|
self.assertIn("/launch/[redacted]", logged)
|
|
|
|
def test_access_log_handles_request_parse_failures(self):
|
|
"""Allow the base handler to log errors before assigning a request path."""
|
|
|
|
class BareHandler(object):
|
|
server = MagicMock()
|
|
|
|
@staticmethod
|
|
def address_string():
|
|
"""Return a stable test peer."""
|
|
return "127.0.0.1"
|
|
|
|
handler = BareHandler()
|
|
log_message = getattr(DashboardRequestHandler.log_message, "im_func", DashboardRequestHandler.log_message)
|
|
log_message(handler, "%s", "bad request")
|
|
|
|
handler.server.logger.info.assert_called_once()
|
|
|
|
@patch("ddns.web.server.webbrowser.open")
|
|
@patch("ddns.web.server._write_stdout")
|
|
@patch("ddns.web.server.create_server")
|
|
@patch("ddns.web.server.DashboardService")
|
|
def test_startup_prints_single_use_launch_url(self, mock_service_class, mock_create_server, mock_stdout, mock_open):
|
|
"""Keep the long-lived API token out of terminal output."""
|
|
service = MagicMock()
|
|
service.config_path = "dashboard.json"
|
|
mock_service_class.return_value = service
|
|
server = MagicMock()
|
|
server.server_address = ("127.0.0.1", 4321)
|
|
server.access_token = "long-lived-secret"
|
|
server.issue_launch_token.return_value = "single-use-token"
|
|
server.serve_forever.side_effect = KeyboardInterrupt
|
|
mock_create_server.return_value = server
|
|
|
|
serve(config_path="dashboard.json", open_browser=True)
|
|
|
|
output = "".join(call[0][0] for call in mock_stdout.call_args_list)
|
|
launch_url = "http://127.0.0.1:4321/launch/single-use-token"
|
|
self.assertIn(launch_url, output)
|
|
self.assertNotIn(server.access_token, output)
|
|
server.issue_launch_token.assert_called_once_with()
|
|
mock_open.assert_called_once_with(launch_url)
|
|
service.start_scheduler.assert_called_once_with()
|
|
service.stop_scheduler.assert_called_once_with()
|
|
server.server_close.assert_called_once_with()
|
|
|
|
@patch("ddns.web.server.webbrowser.open")
|
|
@patch("ddns.web.server._write_stdout")
|
|
@patch("ddns.web.server.create_server")
|
|
@patch("ddns.web.server.DashboardService")
|
|
def test_tokenless_loopback_prints_direct_dashboard_url(
|
|
self, mock_service_class, mock_create_server, mock_stdout, mock_open
|
|
):
|
|
"""Skip the launch-token exchange when loopback authentication is disabled."""
|
|
service = MagicMock()
|
|
service.config_path = "dashboard.json"
|
|
mock_service_class.return_value = service
|
|
server = MagicMock()
|
|
server.server_address = ("127.0.0.1", 4321)
|
|
server.access_token = None
|
|
server.serve_forever.side_effect = KeyboardInterrupt
|
|
mock_create_server.return_value = server
|
|
|
|
serve(config_path="dashboard.json", open_browser=True)
|
|
|
|
output = "".join(call[0][0] for call in mock_stdout.call_args_list)
|
|
direct_url = "http://127.0.0.1:4321/#view=overview"
|
|
self.assertIn(direct_url, output)
|
|
server.issue_launch_token.assert_not_called()
|
|
mock_open.assert_called_once_with(direct_url)
|
|
|
|
@patch("ddns.web.server.create_server")
|
|
@patch("ddns.web.server.DashboardService")
|
|
def test_serve_preserves_original_positional_arguments(self, mock_service_class, mock_create_server):
|
|
"""Append HTTP options without reinterpreting existing positional callers."""
|
|
logger = MagicMock()
|
|
service = MagicMock()
|
|
service.config_path = "dashboard.json"
|
|
mock_service_class.return_value = service
|
|
server = MagicMock()
|
|
server.server_address = ("127.0.0.1", 4321)
|
|
server.access_token = None
|
|
server.serve_forever.side_effect = KeyboardInterrupt
|
|
mock_create_server.return_value = server
|
|
|
|
serve("dashboard.json", "127.0.0.1", 0, False, logger, 9)
|
|
|
|
mock_service_class.assert_called_once_with(config_path="dashboard.json", logger=logger, scheduler_interval=9)
|
|
mock_create_server.assert_called_once_with(
|
|
service=service, host="127.0.0.1", port=0, token=None, origins=None, logger=logger
|
|
)
|
|
|
|
|
|
class TestDashboardServer(unittest.TestCase):
|
|
"""Test local HTTP routing and write protections."""
|
|
|
|
def setUp(self):
|
|
"""Start an ephemeral dashboard server."""
|
|
self.temp_dir = tempfile.mkdtemp()
|
|
self.addCleanup(shutil.rmtree, self.temp_dir, ignore_errors=True)
|
|
config_path = os.path.join(self.temp_dir, "config.json")
|
|
self.scheduler_patcher = patch("ddns.web.service.get_schedulers")
|
|
self.mock_get_schedulers = self.scheduler_patcher.start()
|
|
self.addCleanup(self.scheduler_patcher.stop)
|
|
self.mock_scheduler = MagicMock()
|
|
self.mock_get_schedulers.return_value = [self.mock_scheduler]
|
|
self.mock_scheduler.get_status.return_value = {"scheduler": "test", "installed": False, "enabled": False}
|
|
self.env_patcher = patch("ddns.web.service.load_env_config", return_value={})
|
|
self.mock_load_env_config = self.env_patcher.start()
|
|
self.addCleanup(self.env_patcher.stop)
|
|
self.service = DashboardService(config_path=config_path)
|
|
self.server = create_server(
|
|
service=self.service, host="127.0.0.1", port=0, token="test-dashboard-token", logger=MagicMock()
|
|
)
|
|
self.assertFalse(self.server.allow_reuse_address)
|
|
self.assertEqual(self.server.connection_timeout, 15)
|
|
self.thread = threading.Thread(target=self.server.serve_forever)
|
|
self.thread.daemon = True
|
|
self.thread.start()
|
|
self.addCleanup(self._stop_server)
|
|
self.base_url = "http://127.0.0.1:{}".format(self.server.server_address[1])
|
|
status, _, index = self._request("/")
|
|
self.assertEqual(status, 200)
|
|
self.token = self.server.access_token
|
|
self.assertNotIn(self.token, index)
|
|
|
|
def _stop_server(self):
|
|
"""Stop the ephemeral HTTP server."""
|
|
self.server.shutdown()
|
|
self.server.server_close()
|
|
self.thread.join(2)
|
|
|
|
def _request(self, path, method="GET", payload=None, headers=None):
|
|
"""Issue an HTTP request and return status, headers, and text body."""
|
|
body = None if payload is None else json.dumps(payload).encode("utf-8")
|
|
request = Request(self.base_url + path, data=body, headers=headers or {})
|
|
request.get_method = lambda: method
|
|
try:
|
|
response = urlopen(request, timeout=5)
|
|
content = response.read().decode("utf-8")
|
|
return response.getcode(), response.headers, content
|
|
except HTTPError as error:
|
|
return error.code, error.headers, error.read().decode("utf-8")
|
|
|
|
def test_head_never_writes_response_body(self):
|
|
"""Suppress bodies centrally for HEAD errors and helper responses."""
|
|
handler = MagicMock()
|
|
handler.command = "HEAD"
|
|
send_bytes = getattr(DashboardRequestHandler._send_bytes, "im_func", DashboardRequestHandler._send_bytes)
|
|
|
|
send_bytes(handler, 403, b"error", "application/json")
|
|
|
|
handler.wfile.write.assert_not_called()
|
|
|
|
def test_serves_embedded_page_and_assets(self):
|
|
"""Serve the standalone dashboard without a documentation runtime."""
|
|
status, headers, index = self._request("/")
|
|
asset_status, _, script = self._request("/assets/dashboard.js")
|
|
|
|
self.assertEqual(status, 200)
|
|
self.assertIn("frame-ancestors 'none'", headers.get("Content-Security-Policy"))
|
|
self.assertIn("配置管理", index)
|
|
self.assertNotIn(self.server.access_token, index)
|
|
self.assertEqual(asset_status, 200)
|
|
self.assertIn('api("/api/dashboard")', script)
|
|
|
|
def test_read_api_requires_launch_token(self):
|
|
"""Protect status and credentials from other loopback users."""
|
|
status, _, content = self._request("/api/config")
|
|
|
|
self.assertEqual(status, 403)
|
|
self.assertEqual(json.loads(content)["error"]["code"], "invalid_token")
|
|
|
|
def test_dashboard_reports_invalid_runtime_environment(self):
|
|
"""Return a structured error when inherited environment values are invalid."""
|
|
config = _valid_config()
|
|
del config["cache_max_age"]
|
|
headers = {"Content-Type": "application/json", "X-DDNS-Token": self.token}
|
|
put_status, _, _ = self._request("/api/config", method="PUT", payload={"config": config}, headers=headers)
|
|
self.mock_load_env_config.return_value = {"cache_max_age": "not-a-number"}
|
|
|
|
status, _, content = self._request("/api/dashboard", headers={"X-DDNS-Token": self.token})
|
|
|
|
self.assertEqual(put_status, 200)
|
|
self.assertEqual(status, 400)
|
|
self.assertEqual(json.loads(content)["error"]["code"], "invalid_config")
|
|
|
|
def test_browser_bootstrap_token_is_single_use(self):
|
|
"""Exchange a short-lived launcher token without exposing the API token."""
|
|
launch_token = self.server.issue_launch_token()
|
|
|
|
status, _, index = self._request("/launch/" + launch_token)
|
|
reused_status, _, reused_content = self._request("/launch/" + launch_token)
|
|
|
|
self.assertEqual(status, 200)
|
|
self.assertIn("配置管理", index)
|
|
self.assertEqual(reused_status, 403)
|
|
self.assertEqual(json.loads(reused_content)["error"]["code"], "invalid_launch_token")
|
|
|
|
def test_write_requires_session_token(self):
|
|
"""Reject configuration writes without the page session token."""
|
|
status, _, content = self._request(
|
|
"/api/config",
|
|
method="PUT",
|
|
payload={"config": _valid_config()},
|
|
headers={"Content-Type": "application/json"},
|
|
)
|
|
|
|
self.assertEqual(status, 403)
|
|
self.assertEqual(json.loads(content)["error"]["code"], "invalid_token")
|
|
|
|
def test_authenticated_server_accepts_proxy_host_header(self):
|
|
"""Allow authenticated LAN/reverse-proxy host names."""
|
|
status, _, content = self._request("/", headers={"Host": "dashboard.example.com"})
|
|
|
|
self.assertEqual(status, 200)
|
|
self.assertIn("配置管理", content)
|
|
|
|
def test_config_api_saves_and_reads_normalized_document(self):
|
|
"""Persist configuration through the protected JSON API."""
|
|
headers = {"Content-Type": "application/json", "X-DDNS-Token": self.token}
|
|
put_status, _, put_content = self._request(
|
|
"/api/config", method="PUT", payload={"config": _valid_config()}, headers=headers
|
|
)
|
|
get_status, _, get_content = self._request("/api/config", headers=headers)
|
|
|
|
self.assertEqual(put_status, 200)
|
|
self.assertEqual(json.loads(put_content)["config"]["proxy"], ["DIRECT"])
|
|
self.assertEqual(get_status, 200)
|
|
get_payload = json.loads(get_content)
|
|
self.assertEqual(get_payload["config"]["providers"][0]["index4"], ["public"])
|
|
self.assertEqual(get_payload["model"]["defaults"]["provider"], "debug")
|
|
|
|
def test_dashboard_api_accepts_shared_bearer_token(self):
|
|
"""Use the configured HTTP token through either supported Web header."""
|
|
status, _, content = self._request("/api/config", headers={"Authorization": "Bearer " + self.token})
|
|
|
|
self.assertEqual(status, 200)
|
|
self.assertIn("config", json.loads(content))
|
|
|
|
def test_integrated_mcp_uses_shared_listener_and_token(self):
|
|
"""Serve modern MCP from the protected dashboard listener."""
|
|
message = {
|
|
"jsonrpc": "2.0",
|
|
"id": 1,
|
|
"method": "tools/list",
|
|
"params": {"_meta": {PROTOCOL_VERSION_KEY: PROTOCOL_VERSION, CLIENT_CAPABILITIES_KEY: {}}},
|
|
}
|
|
headers = {
|
|
"Authorization": "Bearer " + self.token,
|
|
"Content-Type": "application/json",
|
|
"Accept": "application/json, text/event-stream",
|
|
"MCP-Protocol-Version": PROTOCOL_VERSION,
|
|
"Mcp-Method": "tools/list",
|
|
}
|
|
|
|
status, _, content = self._request("/mcp", method="POST", payload=message, headers=headers)
|
|
|
|
self.assertEqual(status, 200)
|
|
self.assertEqual(
|
|
[tool["name"] for tool in json.loads(content)["result"]["tools"]], ["get_ddns_status", "update_dns_records"]
|
|
)
|
|
|
|
def test_tokenless_loopback_allows_api_but_rejects_rebound_host(self):
|
|
"""Honor the approved no-auth loopback mode without dropping Host checks."""
|
|
self._stop_server()
|
|
self.server = create_server(service=self.service, host="127.0.0.1", port=0, logger=MagicMock())
|
|
self.thread = threading.Thread(target=self.server.serve_forever)
|
|
self.thread.daemon = True
|
|
self.thread.start()
|
|
self.base_url = "http://127.0.0.1:{}".format(self.server.server_address[1])
|
|
self.token = None
|
|
|
|
status, _, content = self._request("/api/config")
|
|
rebound_status, _, rebound_content = self._request("/api/config", headers={"Host": "dashboard.example.com"})
|
|
malformed_status, _, _ = self._request("/api/config", headers={"Host": "attacker@127.0.0.1"})
|
|
|
|
self.assertEqual(status, 200)
|
|
self.assertIn("config", json.loads(content))
|
|
self.assertEqual(rebound_status, 421)
|
|
self.assertEqual(json.loads(rebound_content)["error"]["code"], "invalid_host")
|
|
self.assertEqual(malformed_status, 421)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|