test_server.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244
  1. # This file is part of Radicale - CalDAV and CardDAV server
  2. # Copyright © 2018-2019 Unrud <unrud@outlook.com>
  3. #
  4. # This library is free software: you can redistribute it and/or modify
  5. # it under the terms of the GNU General Public License as published by
  6. # the Free Software Foundation, either version 3 of the License, or
  7. # (at your option) any later version.
  8. #
  9. # This library is distributed in the hope that it will be useful,
  10. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  11. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  12. # GNU General Public License for more details.
  13. #
  14. # You should have received a copy of the GNU General Public License
  15. # along with Radicale. If not, see <http://www.gnu.org/licenses/>.
  16. """
  17. Test the internal server.
  18. """
  19. import errno
  20. import os
  21. import socket
  22. import ssl
  23. import subprocess
  24. import sys
  25. import threading
  26. import time
  27. from configparser import RawConfigParser
  28. from typing import Callable, Dict, NoReturn, Optional, Tuple, cast
  29. from urllib import request
  30. from urllib.error import HTTPError, URLError
  31. import pytest
  32. from radicale import config, server
  33. from radicale.tests import BaseTest
  34. from radicale.tests.helpers import configuration_to_dict, get_file_path
  35. class DisabledRedirectHandler(request.HTTPRedirectHandler):
  36. # HACK: typeshed annotation are wrong for `fp` and `msg`
  37. # (https://github.com/python/typeshed/pull/5728)
  38. # `headers` is incompatible with `http.client.HTTPMessage`
  39. # (https://github.com/python/typeshed/issues/5729)
  40. def http_error_301(self, req: request.Request, fp, code: int,
  41. msg, headers) -> NoReturn:
  42. raise HTTPError(req.full_url, code, msg, headers, fp)
  43. def http_error_302(self, req: request.Request, fp, code: int,
  44. msg, headers) -> NoReturn:
  45. raise HTTPError(req.full_url, code, msg, headers, fp)
  46. def http_error_303(self, req: request.Request, fp, code: int,
  47. msg, headers) -> NoReturn:
  48. raise HTTPError(req.full_url, code, msg, headers, fp)
  49. def http_error_307(self, req: request.Request, fp, code: int,
  50. msg, headers) -> NoReturn:
  51. raise HTTPError(req.full_url, code, msg, headers, fp)
  52. class TestBaseServerRequests(BaseTest):
  53. """Test the internal server."""
  54. shutdown_socket: socket.socket
  55. thread: threading.Thread
  56. opener: request.OpenerDirector
  57. def setup(self) -> None:
  58. super().setup()
  59. self.shutdown_socket, shutdown_socket_out = socket.socketpair()
  60. with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
  61. # Find available port
  62. sock.bind(("127.0.0.1", 0))
  63. self.sockname = sock.getsockname()
  64. self.configure({"server": {"hosts": "[%s]:%d" % self.sockname},
  65. # Enable debugging for new processes
  66. "logging": {"level": "debug"}})
  67. self.thread = threading.Thread(target=server.serve, args=(
  68. self.configuration, shutdown_socket_out))
  69. ssl_context = ssl.create_default_context()
  70. ssl_context.check_hostname = False
  71. ssl_context.verify_mode = ssl.CERT_NONE
  72. self.opener = request.build_opener(
  73. request.HTTPSHandler(context=ssl_context),
  74. DisabledRedirectHandler)
  75. def teardown(self) -> None:
  76. self.shutdown_socket.close()
  77. try:
  78. self.thread.join()
  79. except RuntimeError: # Thread never started
  80. pass
  81. super().teardown()
  82. def request(self, method: str, path: str, data: Optional[str] = None,
  83. **kwargs) -> Tuple[int, Dict[str, str], str]:
  84. """Send a request."""
  85. login = kwargs.pop("login", None)
  86. if login is not None and not isinstance(login, str):
  87. raise TypeError("login argument must be %r, not %r" %
  88. (str, type(login)))
  89. if login:
  90. raise NotImplementedError
  91. is_alive_fn: Optional[Callable[[], bool]] = kwargs.pop(
  92. "is_alive_fn", None)
  93. headers: Dict[str, str] = kwargs
  94. for k, v in headers.items():
  95. if not isinstance(v, str):
  96. raise TypeError("type of %r is %r, expected %r" %
  97. (k, type(v), str))
  98. if is_alive_fn is None:
  99. is_alive_fn = self.thread.is_alive
  100. encoding: str = self.configuration.get("encoding", "request")
  101. scheme = "https" if self.configuration.get("server", "ssl") else "http"
  102. data_bytes = None
  103. if data:
  104. data_bytes = data.encode(encoding)
  105. req = request.Request(
  106. "%s://[%s]:%d%s" % (scheme, *self.sockname, path),
  107. data=data_bytes, headers=headers, method=method)
  108. while True:
  109. assert is_alive_fn()
  110. try:
  111. with self.opener.open(req) as f:
  112. return f.getcode(), dict(f.info()), f.read().decode()
  113. except HTTPError as e:
  114. return e.code, dict(e.headers), e.read().decode()
  115. except URLError as e:
  116. if not isinstance(e.reason, ConnectionRefusedError):
  117. raise
  118. time.sleep(0.1)
  119. def test_root(self) -> None:
  120. self.thread.start()
  121. self.get("/", check=302)
  122. def test_ssl(self) -> None:
  123. self.configure({"server": {"ssl": "True",
  124. "certificate": get_file_path("cert.pem"),
  125. "key": get_file_path("key.pem")}})
  126. self.thread.start()
  127. self.get("/", check=302)
  128. def test_bind_fail(self) -> None:
  129. for address_family, address in [(socket.AF_INET, "::1"),
  130. (socket.AF_INET6, "127.0.0.1")]:
  131. with socket.socket(address_family, socket.SOCK_STREAM) as sock:
  132. if address_family == socket.AF_INET6:
  133. # Only allow IPv6 connections to the IPv6 socket
  134. sock.setsockopt(server.COMPAT_IPPROTO_IPV6,
  135. socket.IPV6_V6ONLY, 1)
  136. with pytest.raises(OSError) as exc_info:
  137. sock.bind((address, 0))
  138. # See ``radicale.server.serve``
  139. assert (isinstance(exc_info.value, socket.gaierror) and
  140. exc_info.value.errno in (
  141. socket.EAI_NONAME, server.COMPAT_EAI_ADDRFAMILY,
  142. server.COMPAT_EAI_NODATA) or
  143. str(exc_info.value) == "address family mismatched" or
  144. exc_info.value.errno in (
  145. errno.EADDRNOTAVAIL, errno.EAFNOSUPPORT,
  146. errno.EPROTONOSUPPORT))
  147. def test_ipv6(self) -> None:
  148. try:
  149. with socket.socket(socket.AF_INET6, socket.SOCK_STREAM) as sock:
  150. # Only allow IPv6 connections to the IPv6 socket
  151. sock.setsockopt(
  152. server.COMPAT_IPPROTO_IPV6, socket.IPV6_V6ONLY, 1)
  153. # Find available port
  154. sock.bind(("::1", 0))
  155. self.sockname = sock.getsockname()[:2]
  156. except OSError as e:
  157. if e.errno in (errno.EADDRNOTAVAIL, errno.EAFNOSUPPORT,
  158. errno.EPROTONOSUPPORT):
  159. pytest.skip("IPv6 not supported")
  160. raise
  161. self.configure({"server": {"hosts": "[%s]:%d" % self.sockname}})
  162. self.thread.start()
  163. self.get("/", check=302)
  164. def test_command_line_interface(self, with_bool_options=False) -> None:
  165. self.configure({"headers": {"Test-Server": "test"}})
  166. config_args = []
  167. for section in self.configuration.sections():
  168. if section.startswith("_"):
  169. continue
  170. for option in self.configuration.options(section):
  171. if option.startswith("_"):
  172. continue
  173. long_name = "--%s-%s" % (section, option.replace("_", "-"))
  174. if with_bool_options and config.DEFAULT_CONFIG_SCHEMA.get(
  175. section, {}).get(option, {}).get("type") == bool:
  176. if not cast(bool, self.configuration.get(section, option)):
  177. long_name = "--no%s" % long_name[1:]
  178. config_args.append(long_name)
  179. else:
  180. config_args.append(long_name)
  181. raw_value = self.configuration.get_raw(section, option)
  182. assert isinstance(raw_value, str)
  183. config_args.append(raw_value)
  184. config_args.append("--headers-Test-Header=test")
  185. p = subprocess.Popen(
  186. [sys.executable, "-m", "radicale"] + config_args,
  187. env={**os.environ, "PYTHONPATH": os.pathsep.join(sys.path)})
  188. try:
  189. status, headers, _ = self.request(
  190. "GET", "/", is_alive_fn=lambda: p.poll() is None)
  191. self._check_status(status, 302)
  192. for key in self.configuration.options("headers"):
  193. assert headers.get(key) == self.configuration.get(
  194. "headers", key)
  195. finally:
  196. p.terminate()
  197. p.wait()
  198. if os.name == "posix":
  199. assert p.returncode == 0
  200. def test_command_line_interface_with_bool_options(self) -> None:
  201. self.test_command_line_interface(with_bool_options=True)
  202. def test_wsgi_server(self) -> None:
  203. config_path = os.path.join(self.colpath, "config")
  204. parser = RawConfigParser()
  205. parser.read_dict(configuration_to_dict(self.configuration))
  206. with open(config_path, "w") as f:
  207. parser.write(f)
  208. env = os.environ.copy()
  209. env["PYTHONPATH"] = os.pathsep.join(sys.path)
  210. env["RADICALE_CONFIG"] = config_path
  211. raw_server_hosts = self.configuration.get_raw("server", "hosts")
  212. assert isinstance(raw_server_hosts, str)
  213. p = subprocess.Popen([
  214. sys.executable, "-m", "waitress", "--listen", raw_server_hosts,
  215. "radicale:application"], env=env)
  216. try:
  217. self.get("/", is_alive_fn=lambda: p.poll() is None, check=302)
  218. finally:
  219. p.terminate()
  220. p.wait()