Files
postgrest/test/io-tests/test_io.py
T

359 lines
11 KiB
Python

"Unit tests for Input/Ouput of PostgREST seen as a black box."
import contextlib
import dataclasses
from datetime import datetime
import pathlib
import subprocess
from operator import attrgetter
import os
import signal
import time
import urllib.parse
import jwt
import pytest
import requests
import requests_unixsocket
import yaml
BASEDIR = pathlib.Path(os.path.realpath(__file__)).parent
CONFIGSDIR = BASEDIR / "configs"
FIXTURES = yaml.load((BASEDIR / "fixtures.yaml").read_text(), Loader=yaml.Loader)
BASEURL = "http://127.0.0.1:49421"
SECRET = "reallyreallyreallyreallyverysafe"
class PostgrestTimedOut(Exception):
"Connecting to PostgREST endpoint timed out."
class PostgrestError(Exception):
"Postgrest exited with a non-zero return code."
class PostgrestSession(requests_unixsocket.Session):
"HTTP client session directed at a PostgREST endpoint."
def __init__(self, baseurl, *args, **kwargs):
super(PostgrestSession, self).__init__(*args, **kwargs)
self.baseurl = baseurl
def request(self, method, url, *args, **kwargs):
fullurl = urllib.parse.urljoin(self.baseurl, url)
return super(PostgrestSession, self).request(method, fullurl, *args, **kwargs)
@dataclasses.dataclass
class PostgrestProcess:
"Running PostgREST process and its corresponding endpoint."
process: object
session: object
@pytest.fixture
def dburi():
"Postgres database connection URI."
return os.getenv("POSTGREST_TEST_CONNECTION").encode("utf-8")
def dumpconfig(configpath, moreenv=None, stdin=None):
"Dump the config as parsed by PostgREST."
env = {**os.environ, **(moreenv or {})}
command = ["postgrest", "--dump-config", configpath]
process = subprocess.Popen(
command, env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE
)
process.stdin.write(stdin or b"")
result = process.communicate()[0]
process.kill()
process.wait()
if process.returncode != 0:
raise PostgrestError()
return result.decode("utf-8")
@contextlib.contextmanager
def run(configpath, stdin=None, moreenv=None, socket=None):
"Run PostgREST and yield an endpoint that is ready for connections."
env = {**os.environ, **(moreenv or {})}
if socket:
baseurl = "http+unix://" + urllib.parse.quote_plus(str(socket))
else:
baseurl = BASEURL
command = ["postgrest", configpath]
process = subprocess.Popen(command, stdin=subprocess.PIPE, env=env)
try:
process.stdin.write(stdin or b"")
process.stdin.close()
wait_until_ready(baseurl)
yield PostgrestProcess(process=process, session=PostgrestSession(baseurl))
finally:
process.kill()
process.wait()
def wait_until_ready(url):
"Wait for the given HTTP endpoint to return a status of 200."
session = requests_unixsocket.Session()
for _ in range(10):
try:
response = session.get(url, timeout=1)
if response.status_code == 200:
return
except (requests.ConnectionError, requests.ReadTimeout):
pass
time.sleep(0.1)
raise PostgrestTimedOut()
def authheader(token):
"Bearer token HTTP authorization header."
return {"Authorization": f"Bearer {token}"}
def jwtauthheader(claim, secret):
"Authorization header with signed JWT."
return authheader(jwt.encode(claim, secret).decode("utf-8"))
@pytest.mark.parametrize(
"expectedconfig", (CONFIGSDIR / "expected").iterdir(), ids=attrgetter("name")
)
def test_expected_config(expectedconfig):
"""
Configs as dumped by PostgREST should match an expected output.
Used to test default values, config aliases and environment variables. The
expected output for each file in 'configs', if available, is found in the
'configs/expected' directory.
"""
expected = expectedconfig.read_text()
assert dumpconfig(CONFIGSDIR / expectedconfig.name) == expected
@pytest.mark.parametrize(
"config",
[conf for conf in CONFIGSDIR.iterdir() if conf.suffix == ".config"],
ids=attrgetter("name"),
)
def test_stable_config(tmp_path, config):
"""
A dumped, re-read and re-dumped config should match the dumped config.
Note: only dump vs. re-dump must be equal, as the original config file might
be different because of default values, whitespace, and quoting.
"""
# Set environment variables that some of the configs expect. Using a
# complex ROLE_CLAIM_KEY to make sure quoting works.
env = {
"ROLE_CLAIM_KEY": '."https://www.example.com/roles"[0].value',
"POSTGREST_TEST_SOCKET": "/tmp/postgrest.sock",
}
# Some configs expect input from stdin, at least on base64.
stdin = b"Y29ubmVjdGlvbl9zdHJpbmc="
dumped = dumpconfig(config, moreenv=env, stdin=stdin)
tmpconfigpath = tmp_path / "config"
tmpconfigpath.write_text(dumped)
redumped = dumpconfig(tmpconfigpath, moreenv=env)
assert dumped == redumped
def test_socket_connection(tmp_path):
"Connections via unix domain sockets should work."
socket = tmp_path / "postgrest.sock"
env = {
"POSTGREST_TEST_SOCKET": str(socket),
}
with run(CONFIGSDIR / "unix-socket.config", socket=socket, moreenv=env):
pass
@pytest.mark.parametrize(
"secretpath",
[path for path in (BASEDIR / "secrets").iterdir() if path.suffix != ".jwt"],
ids=attrgetter("name"),
)
def test_read_secret_from_file(secretpath):
"Authorization should succeed when the secret is read from a file."
if secretpath.suffix == ".b64":
configfile = CONFIGSDIR / "base64-secret-from-file.config"
else:
configfile = CONFIGSDIR / "secret-from-file.config"
secret = secretpath.read_bytes()
headers = authheader(secretpath.with_suffix(".jwt").read_text())
with run(configfile, stdin=secret) as postgrest:
response = postgrest.session.get("/authors_only", headers=headers)
assert response.status_code == 200
def test_read_dburi_from_file_without_eol(dburi):
"Reading the dburi from a file with a single line should work."
with run(CONFIGSDIR / "dburi-from-file.config", stdin=dburi):
pass
def test_read_dburi_from_file_with_eol(dburi):
"Reading the dburi from a file containing a newline should work."
with run(CONFIGSDIR / "dburi-from-file.config", stdin=dburi + b"\n"):
pass
@pytest.mark.parametrize(
"roleclaim", FIXTURES["roleclaims"], ids=lambda claim: claim["key"]
)
def test_role_claim_key(roleclaim):
"Authorization should depend on a correct role-claim-key and JWT claim."
env = {"ROLE_CLAIM_KEY": roleclaim["key"]}
headers = jwtauthheader(roleclaim["data"], SECRET)
with run(CONFIGSDIR / "role-claim-key.config", moreenv=env) as postgrest:
response = postgrest.session.get("/authors_only", headers=headers)
assert response.status_code == roleclaim["expected_status"]
@pytest.mark.parametrize("invalidroleclaimkey", FIXTURES["invalidroleclaimkeys"])
def test_invalid_role_claim_key(invalidroleclaimkey):
"Given an invalid role-claim-key, Postgrest should exit with a non-zero exit code."
env = {"ROLE_CLAIM_KEY": invalidroleclaimkey}
with pytest.raises(PostgrestError):
dump = dumpconfig(CONFIGSDIR / "role-claim-key.config", moreenv=env)
for line in dump.split("\n"):
if line.startswith("jwt-role-claim-key"):
print(line)
def test_iat_claim():
"""
A claim with an 'iat' (issued at) attribute should be successful.
The PostgREST time cache leads to issues here, see:
https://github.com/PostgREST/postgrest/issues/1139
"""
claim = {"role": "postgrest_test_author", "iat": datetime.utcnow()}
headers = jwtauthheader(claim, SECRET)
with run(CONFIGSDIR / "simple.config") as postgrest:
for _ in range(10):
response = postgrest.session.get("/authors_only", headers=headers)
assert response.status_code == 200
time.sleep(0.5)
def test_app_settings():
"""
App settings should not reset when the db pool times out.
See: https://github.com/PostgREST/postgrest/issues/1141
"""
with run(CONFIGSDIR / "app-settings.config") as postgrest:
# Wait for the db pool to time out, set to 1s in config
time.sleep(2)
uri = "/rpc/get_guc_value?name=app.settings.external_api_secret"
response = postgrest.session.get(uri)
assert response.status_code == 200
assert response.text == '"0123456789abcdef"'
def test_app_settings_reload(tmp_path):
"App settings should be reloaded when PostgREST is sent SIGUSR2."
config = (CONFIGSDIR / "sigusr2-settings.config").read_text()
configfile = tmp_path / "test.config"
configfile.write_text(config)
uri = "/rpc/get_guc_value?name=app.settings.name_var"
with run(configfile) as postgrest:
response = postgrest.session.get(uri)
assert response.status_code == 200
assert response.text == '"John"'
# change setting
configfile.write_text(config.replace("John", "Jane"))
# reload
postgrest.process.send_signal(signal.SIGUSR2)
time.sleep(0.1)
response = postgrest.session.get(uri)
assert response.status_code == 200
assert response.text == '"Jane"'
def test_jwt_secret_reload(tmp_path):
"JWT secret should be reloaded when PostgREST is sent SIGUSR2."
config = (CONFIGSDIR / "sigusr2-settings.config").read_text()
configfile = tmp_path / "test.config"
configfile.write_text(config)
headers = jwtauthheader({"role": "postgrest_test_author"}, SECRET)
with run(configfile) as postgrest:
response = postgrest.session.get("/authors_only", headers=headers)
assert response.status_code == 401
# change setting
configfile.write_text(config.replace("invalid" * 5, SECRET))
# reload config
postgrest.process.send_signal(signal.SIGUSR2)
time.sleep(0.1)
response = postgrest.session.get("/authors_only", headers=headers)
assert response.status_code == 200
def test_db_schema_reload(tmp_path):
"DB schema should be reloaded when PostgREST is sent SIGUSR2."
config = (CONFIGSDIR / "sigusr2-settings.config").read_text()
configfile = tmp_path / "test.config"
configfile.write_text(config)
headers = {"Accept-Profile": "v1"}
with run(configfile) as postgrest:
response = postgrest.session.get("/parents", headers=headers)
assert response.status_code == 404
# change setting
configfile.write_text(
config.replace('db-schemas = "test"', 'db-schemas = "test, v1"')
)
# reload config
postgrest.process.send_signal(signal.SIGUSR2)
# reload schema cache to verify that the config reload actually happened
postgrest.process.send_signal(signal.SIGUSR1)
time.sleep(0.1)
response = postgrest.session.get("/parents", headers=headers)
assert response.status_code == 200