Skip to content

Commit bb85720

Browse files
jchampioCommitfest Bot
authored andcommitted
WIP: pytest: Add some SSL client tests
This is a sample client-only test suite. It tests some handshake failures against a mock server, as well as a full SSL handshake + empty query + response. pyca/cryptography is added as a new package dependency. Certificates for testing are generated on the fly. The `pg` test package contains some helpers and fixtures (as well as some self-tests for more complicated behavior). Of note: - pg.require_test_extra() lets you mark a test/class/module as skippable if PG_TEST_EXTRA does not contain the necessary strings. - pg.remaining_timeout() is a function which can be repeatedly called to determine how much of the PG_TEST_TIMEOUT_DEFAULT remains for the current test item. - pg.libpq is a fixture that wraps libpq.so in a more friendly, but still low-level, ctypes FFI. Allocated resources are unwound and released during test teardown. The mock design is threaded: the server socket is listening on a background thread, and the test provides the server logic via a callback. There is some additional work still needed to make this production-ready; see the notes for _TCPServer.background(). (Currently, an exception in the wrong place could result in a hang-until-timeout rather than an immediate failure.) TODOs: - local_server and tcp_server_class are nearly identical and should share code. - fix exception-related timeouts for .background() - figure out the proper use of "session" vs "module" scope - ensure that pq.libpq unwinds (to close connections) before tcp_server; see comment in test_server_with_ssl_disabled()
1 parent 370c5ba commit bb85720

File tree

13 files changed

+885
-6
lines changed

13 files changed

+885
-6
lines changed

.cirrus.tasks.yml

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,7 @@ task:
228228
sysctl kern.corefile='/tmp/cores/%N.%P.core'
229229
setup_additional_packages_script: |
230230
pkg install -y \
231+
py311-cryptography \
231232
py311-packaging \
232233
py311-pytest
233234
@@ -322,6 +323,7 @@ task:
322323
323324
setup_additional_packages_script: |
324325
pkgin -y install \
326+
py312-cryptography \
325327
py312-packaging \
326328
py312-test
327329
ln -s /usr/pkg/bin/pytest-3.12 /usr/pkg/bin/pytest
@@ -346,8 +348,9 @@ task:
346348
347349
setup_additional_packages_script: |
348350
pkg_add -I \
349-
py3-test \
350-
py3-packaging
351+
py3-cryptography \
352+
py3-packaging \
353+
py3-test
351354
# Always core dump to ${CORE_DUMP_DIR}
352355
set_core_dump_script: sysctl -w kern.nosuidcoredump=2
353356
<<: *openbsd_task_template
@@ -508,8 +511,9 @@ task:
508511
setup_additional_packages_script: |
509512
apt-get update
510513
DEBIAN_FRONTEND=noninteractive apt-get -y install \
511-
python3-pytest \
512-
python3-packaging
514+
python3-cryptography \
515+
python3-packaging \
516+
python3-pytest
513517
514518
matrix:
515519
# SPECIAL:
@@ -658,6 +662,7 @@ task:
658662
CIRRUS_WORKING_DIR: ${HOME}/pgsql/
659663
CCACHE_DIR: ${HOME}/ccache
660664
MACPORTS_CACHE: ${HOME}/macports-cache
665+
PYTEST_DEBUG_TEMPROOT: /tmp # default is too long for UNIX sockets on Mac
661666

662667
MESON_FEATURES: >-
663668
-Dbonjour=enabled
@@ -678,6 +683,7 @@ task:
678683
p5.34-io-tty
679684
p5.34-ipc-run
680685
python312
686+
py312-cryptography
681687
py312-packaging
682688
py312-pytest
683689
tcl
@@ -816,7 +822,7 @@ task:
816822
# XXX Does Chocolatey really not have any Python package installers?
817823
setup_additional_packages_script: |
818824
REM choco install -y --no-progress ...
819-
pip3 install --user packaging pytest
825+
pip3 install --user cryptography packaging pytest
820826
821827
setup_hosts_file_script: |
822828
echo 127.0.0.1 pg-loadbalancetest >> c:\Windows\System32\Drivers\etc\hosts
@@ -879,7 +885,7 @@ task:
879885
folder: ${CCACHE_DIR}
880886

881887
setup_additional_packages_script: |
882-
C:\msys64\usr\bin\pacman.exe -S --noconfirm mingw-w64-ucrt-x86_64-python-packaging mingw-w64-ucrt-x86_64-python-pytest
888+
C:\msys64\usr\bin\pacman.exe -S --noconfirm mingw-w64-ucrt-x86_64-python-cryptography mingw-w64-ucrt-x86_64-python-packaging mingw-w64-ucrt-x86_64-python-pytest
883889
884890
mingw_info_script: |
885891
%BASH% -c "where gcc"

config/pytest-requirements.txt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,13 @@ pytest >= 7.0, < 9
1919

2020
# packaging is used by check_pytest.py at configure time.
2121
packaging
22+
23+
# Notes on the cryptography package:
24+
# - 3.3.2 is shipped on Debian bullseye.
25+
# - 3.4.x drops support for Python 2, making it a version of note for older LTS
26+
# distros.
27+
# - 35.x switched versioning schemes and moved to Rust parsing.
28+
# - 40.x is the last version supporting Python 3.6.
29+
# XXX Is it appropriate to require cryptography, or should we simply skip
30+
# dependent tests?
31+
cryptography >= 3.3.2

pytest.ini

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,6 @@ minversion = 7.0
44
# Ignore ./config (which contains the configure-time check_pytest.py tests) by
55
# default.
66
addopts = --ignore ./config
7+
8+
# Common test code can be found here.
9+
pythonpath = src/test/pytest

src/test/pytest/meson.build

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ tests += {
1111
'pytest': {
1212
'tests': [
1313
'pyt/test_something.py',
14+
'pyt/test_libpq.py',
1415
],
1516
},
1617
}

src/test/pytest/pg/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Copyright (c) 2025, PostgreSQL Global Development Group
2+
3+
from ._env import has_test_extra, require_test_extra

src/test/pytest/pg/_env.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Copyright (c) 2025, PostgreSQL Global Development Group
2+
3+
import logging
4+
import os
5+
from typing import List, Optional
6+
7+
import pytest
8+
9+
logger = logging.getLogger(__name__)
10+
11+
12+
def has_test_extra(key: str) -> bool:
13+
"""
14+
Returns True if the PG_TEST_EXTRA environment variable contains the given
15+
key.
16+
"""
17+
extra = os.getenv("PG_TEST_EXTRA", "")
18+
return key in extra.split()
19+
20+
21+
def require_test_extra(*keys: str) -> bool:
22+
"""
23+
A convenience annotation which will skip tests if all of the required keys
24+
are not present in PG_TEST_EXTRA.
25+
26+
To skip a particular test function or class:
27+
28+
@pg.require_test_extra("ldap")
29+
def test_some_ldap_feature():
30+
...
31+
32+
To skip an entire module:
33+
34+
pytestmark = pg.require_test_extra("ssl", "kerberos")
35+
"""
36+
return pytest.mark.skipif(
37+
not all([has_test_extra(k) for k in keys]),
38+
reason="requires {} to be set in PG_TEST_EXTRA".format(", ".join(keys)),
39+
)
40+
41+
42+
def test_timeout_default() -> int:
43+
"""
44+
Returns the value of the PG_TEST_TIMEOUT_DEFAULT environment variable, in
45+
seconds, or 180 if one was not provided.
46+
"""
47+
default = os.getenv("PG_TEST_TIMEOUT_DEFAULT", "")
48+
if not default:
49+
return 180
50+
51+
try:
52+
return int(default)
53+
except ValueError as v:
54+
logger.warning("PG_TEST_TIMEOUT_DEFAULT could not be parsed: " + str(v))
55+
return 180

src/test/pytest/pg/fixtures.py

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
# Copyright (c) 2025, PostgreSQL Global Development Group
2+
3+
import contextlib
4+
import ctypes
5+
import platform
6+
import time
7+
from typing import Any, Callable, Dict
8+
9+
import pytest
10+
11+
from ._env import test_timeout_default
12+
13+
14+
@pytest.fixture
15+
def remaining_timeout():
16+
"""
17+
This fixture provides a function that returns how much of the
18+
PG_TEST_TIMEOUT_DEFAULT remains for the current test, in fractional seconds.
19+
This value is never less than zero.
20+
21+
This fixture is per-test, so the deadline is also reset on a per-test basis.
22+
"""
23+
now = time.monotonic()
24+
deadline = now + test_timeout_default()
25+
26+
return lambda: max(deadline - time.monotonic(), 0)
27+
28+
29+
class _PGconn(ctypes.Structure):
30+
pass
31+
32+
33+
class _PGresult(ctypes.Structure):
34+
pass
35+
36+
37+
_PGconn_p = ctypes.POINTER(_PGconn)
38+
_PGresult_p = ctypes.POINTER(_PGresult)
39+
40+
41+
@pytest.fixture(scope="session")
42+
def libpq_handle():
43+
"""
44+
Loads a ctypes handle for libpq. Some common function prototypes are
45+
initialized for general use.
46+
"""
47+
system = platform.system()
48+
49+
if system in ("Linux", "FreeBSD", "NetBSD", "OpenBSD"):
50+
name = "libpq.so.5"
51+
elif system == "Darwin":
52+
name = "libpq.5.dylib"
53+
elif system == "Windows":
54+
name = "libpq.dll"
55+
else:
56+
assert False, f"the libpq fixture must be updated for {system}"
57+
58+
# XXX ctypes.CDLL() is a little stricter with load paths on Windows. The
59+
# preferred way around that is to know the absolute path to libpq.dll, but
60+
# that doesn't seem to mesh well with the current test infrastructure. For
61+
# now, enable "standard" LoadLibrary behavior.
62+
loadopts = {}
63+
if system == "Windows":
64+
loadopts["winmode"] = 0
65+
66+
lib = ctypes.CDLL(name, **loadopts)
67+
68+
#
69+
# Function Prototypes
70+
#
71+
72+
lib.PQconnectdb.restype = _PGconn_p
73+
lib.PQconnectdb.argtypes = [ctypes.c_char_p]
74+
75+
lib.PQstatus.restype = ctypes.c_int
76+
lib.PQstatus.argtypes = [_PGconn_p]
77+
78+
lib.PQexec.restype = _PGresult_p
79+
lib.PQexec.argtypes = [_PGconn_p, ctypes.c_char_p]
80+
81+
lib.PQresultStatus.restype = ctypes.c_int
82+
lib.PQresultStatus.argtypes = [_PGresult_p]
83+
84+
lib.PQclear.restype = None
85+
lib.PQclear.argtypes = [_PGresult_p]
86+
87+
lib.PQerrorMessage.restype = ctypes.c_char_p
88+
lib.PQerrorMessage.argtypes = [_PGconn_p]
89+
90+
lib.PQfinish.restype = None
91+
lib.PQfinish.argtypes = [_PGconn_p]
92+
93+
return lib
94+
95+
96+
class PGresult(contextlib.AbstractContextManager):
97+
"""Wraps a raw _PGresult_p with a more friendly interface."""
98+
99+
def __init__(self, lib: ctypes.CDLL, res: _PGresult_p):
100+
self._lib = lib
101+
self._res = res
102+
103+
def __exit__(self, *exc):
104+
self._lib.PQclear(self._res)
105+
self._res = None
106+
107+
def status(self):
108+
return self._lib.PQresultStatus(self._res)
109+
110+
111+
class PGconn(contextlib.AbstractContextManager):
112+
"""
113+
Wraps a raw _PGconn_p with a more friendly interface. This is just a
114+
stub; it's expected to grow.
115+
"""
116+
117+
def __init__(
118+
self,
119+
lib: ctypes.CDLL,
120+
handle: _PGconn_p,
121+
stack: contextlib.ExitStack,
122+
):
123+
self._lib = lib
124+
self._handle = handle
125+
self._stack = stack
126+
127+
def __exit__(self, *exc):
128+
self._lib.PQfinish(self._handle)
129+
self._handle = None
130+
131+
def exec(self, query: str) -> PGresult:
132+
"""
133+
Executes a query via PQexec() and returns a PGresult.
134+
"""
135+
res = self._lib.PQexec(self._handle, query.encode())
136+
return self._stack.enter_context(PGresult(self._lib, res))
137+
138+
139+
@pytest.fixture
140+
def libpq(libpq_handle, remaining_timeout):
141+
"""
142+
Provides a ctypes-based API wrapped around libpq.so. This fixture keeps
143+
track of allocated resources and cleans them up during teardown. See
144+
_Libpq's public API for details.
145+
"""
146+
147+
class _Libpq(contextlib.ExitStack):
148+
CONNECTION_OK = 0
149+
150+
PGRES_EMPTY_QUERY = 0
151+
152+
class Error(RuntimeError):
153+
"""
154+
libpq.Error is the exception class for application-level errors that
155+
are encountered during libpq operations.
156+
"""
157+
158+
pass
159+
160+
def __init__(self):
161+
super().__init__()
162+
self.lib = libpq_handle
163+
164+
def _connstr(self, opts: Dict[str, Any]) -> str:
165+
"""
166+
Flattens the provided options into a libpq connection string. Values
167+
are converted to str and quoted/escaped as necessary.
168+
"""
169+
settings = []
170+
171+
for k, v in opts.items():
172+
v = str(v)
173+
if not v:
174+
v = "''"
175+
else:
176+
v = v.replace("\\", "\\\\")
177+
v = v.replace("'", "\\'")
178+
179+
if " " in v:
180+
v = f"'{v}'"
181+
182+
settings.append(f"{k}={v}")
183+
184+
return " ".join(settings)
185+
186+
def must_connect(self, **opts) -> PGconn:
187+
"""
188+
Connects to a server, using the given connection options, and
189+
returns a libpq.PGconn object wrapping the connection handle. A
190+
failure will raise libpq.Error.
191+
192+
Connections honor PG_TEST_TIMEOUT_DEFAULT unless connect_timeout is
193+
explicitly overridden in opts.
194+
"""
195+
196+
if "connect_timeout" not in opts:
197+
t = int(remaining_timeout())
198+
opts["connect_timeout"] = max(t, 1)
199+
200+
conn_p = self.lib.PQconnectdb(self._connstr(opts).encode())
201+
202+
# Ensure the connection handle is always closed at the end of the
203+
# test.
204+
conn = self.enter_context(PGconn(self.lib, conn_p, stack=self))
205+
206+
if self.lib.PQstatus(conn_p) != self.CONNECTION_OK:
207+
raise self.Error(self.lib.PQerrorMessage(conn_p).decode())
208+
209+
return conn
210+
211+
with _Libpq() as lib:
212+
yield lib

src/test/pytest/pyt/conftest.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Copyright (c) 2025, PostgreSQL Global Development Group
2+
3+
from pg.fixtures import *

0 commit comments

Comments
 (0)