tests: split test_io.py
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
import os
|
||||
import pathlib
|
||||
import shutil
|
||||
import signal
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
|
||||
BASEDIR = pathlib.Path(os.path.realpath(__file__)).parent
|
||||
CONFIGSDIR = BASEDIR / "configs"
|
||||
FIXTURES = yaml.load((BASEDIR / "fixtures.yaml").read_text(), Loader=yaml.Loader)
|
||||
POSTGREST_BIN = shutil.which("postgrest")
|
||||
SECRET = "reallyreallyreallyreallyverysafe"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dburi():
|
||||
"Postgres database connection URI."
|
||||
dbname = os.environ["PGDATABASE"]
|
||||
host = os.environ["PGHOST"]
|
||||
user = os.environ["PGUSER"]
|
||||
return f"postgresql://?dbname={dbname}&host={host}&user={user}".encode()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def baseenv():
|
||||
"Base environment to connect to PostgreSQL"
|
||||
return {
|
||||
"PGDATABASE": os.environ["PGDATABASE"],
|
||||
"PGHOST": os.environ["PGHOST"],
|
||||
"PGUSER": os.environ["PGUSER"],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def defaultenv(baseenv):
|
||||
"Default environment for PostgREST."
|
||||
return {
|
||||
**baseenv,
|
||||
"PGRST_DB_CONFIG": "true",
|
||||
"PGRST_LOG_LEVEL": "info",
|
||||
"PGRST_DB_POOL": "1",
|
||||
}
|
||||
|
||||
|
||||
def hpctixfile():
|
||||
"Returns an individual filename for each test, if the HPCTIXFILE environment variable is set."
|
||||
if "HPCTIXFILE" not in os.environ:
|
||||
return ""
|
||||
|
||||
tixfile = pathlib.Path(os.environ["HPCTIXFILE"])
|
||||
test = hash(os.environ["PYTEST_CURRENT_TEST"])
|
||||
return tixfile.with_suffix(f".{test}.tix")
|
||||
@@ -0,0 +1,170 @@
|
||||
"Fixtures to run PostgREST as a server."
|
||||
|
||||
import contextlib
|
||||
import dataclasses
|
||||
import os
|
||||
import pathlib
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.parse
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
import requests_unixsocket
|
||||
|
||||
from config import *
|
||||
|
||||
|
||||
class PostgrestTimedOut(Exception):
|
||||
"Connecting to PostgREST endpoint timed out."
|
||||
|
||||
|
||||
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):
|
||||
# Not using urllib.parse.urljoin to compose the url, as it doesn't play
|
||||
# well with our 'http+unix://' unix domain socket urls.
|
||||
fullurl = self.baseurl + url
|
||||
return super(PostgrestSession, self).request(method, fullurl, *args, **kwargs)
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class PostgrestProcess:
|
||||
"Running PostgREST process and its corresponding main and admin endpoints."
|
||||
admin: object
|
||||
process: object
|
||||
session: object
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def run(
|
||||
configpath=None,
|
||||
stdin=None,
|
||||
env=None,
|
||||
port=None,
|
||||
host=None,
|
||||
no_pool_connection_available=False,
|
||||
):
|
||||
"Run PostgREST and yield an endpoint that is ready for connections."
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
if port:
|
||||
env["PGRST_SERVER_PORT"] = str(port)
|
||||
env["PGRST_SERVER_HOST"] = host or "localhost"
|
||||
baseurl = f"http://localhost:{port}"
|
||||
else:
|
||||
socketfile = pathlib.Path(tmpdir) / "postgrest.sock"
|
||||
env["PGRST_SERVER_UNIX_SOCKET"] = str(socketfile)
|
||||
baseurl = "http+unix://" + urllib.parse.quote_plus(str(socketfile))
|
||||
|
||||
adminport = freeport()
|
||||
env["PGRST_ADMIN_SERVER_PORT"] = str(adminport)
|
||||
adminurl = f"http://localhost:{adminport}"
|
||||
|
||||
command = [POSTGREST_BIN]
|
||||
env["HPCTIXFILE"] = hpctixfile()
|
||||
|
||||
if configpath:
|
||||
command.append(configpath)
|
||||
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
env=env,
|
||||
)
|
||||
|
||||
os.set_blocking(process.stdout.fileno(), False)
|
||||
|
||||
try:
|
||||
process.stdin.write(stdin or b"")
|
||||
process.stdin.close()
|
||||
|
||||
wait_until_ready(adminurl + "/ready")
|
||||
|
||||
process.stdout.read()
|
||||
|
||||
yield PostgrestProcess(
|
||||
process=process,
|
||||
session=PostgrestSession(baseurl),
|
||||
admin=PostgrestSession(adminurl),
|
||||
)
|
||||
finally:
|
||||
if no_pool_connection_available:
|
||||
sleep_pool_connection(baseurl, 10)
|
||||
|
||||
remaining_output = process.stdout.read()
|
||||
if remaining_output:
|
||||
print(remaining_output.decode())
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=1)
|
||||
except:
|
||||
process.kill()
|
||||
process.wait()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def metapostgrest():
|
||||
"A shared postgrest instance to use for interacting with the database independently of the instance under test"
|
||||
role = "meta_authenticator"
|
||||
env = {
|
||||
"PGDATABASE": os.environ["PGDATABASE"],
|
||||
"PGHOST": os.environ["PGHOST"],
|
||||
"PGUSER": role,
|
||||
"PGRST_DB_ANON_ROLE": role,
|
||||
"PGRST_DB_CONFIG": "true",
|
||||
"PGRST_LOG_LEVEL": "info",
|
||||
"PGRST_DB_POOL": "1",
|
||||
}
|
||||
with run(env=env) as postgrest:
|
||||
yield postgrest
|
||||
|
||||
|
||||
def freeport():
|
||||
"Find a free port on localhost."
|
||||
with contextlib.closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s:
|
||||
s.bind(("", 0))
|
||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
def wait_until_ready(url):
|
||||
"Wait for the given HTTP endpoint to return a status of 200."
|
||||
session = requests_unixsocket.Session()
|
||||
|
||||
response = None
|
||||
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)
|
||||
|
||||
if response:
|
||||
raise PostgrestTimedOut(f"{response.status_code}: {response.text}")
|
||||
else:
|
||||
raise PostgrestTimedOut()
|
||||
|
||||
|
||||
def sleep_pool_connection(url, seconds):
|
||||
"Sleep a pool connection by calling an RPC that uses pg_sleep"
|
||||
session = requests_unixsocket.Session()
|
||||
|
||||
# The try/except is a hack for not waiting for the response,
|
||||
# taken from https://stackoverflow.com/a/45601591/4692662
|
||||
try:
|
||||
session.get(url + f"/rpc/sleep?seconds={seconds}", timeout=0.1)
|
||||
except requests.exceptions.ReadTimeout:
|
||||
pass
|
||||
@@ -0,0 +1,227 @@
|
||||
"Unit tests for Input/Ouput of PostgREST seen as a black box."
|
||||
|
||||
import contextlib
|
||||
import dataclasses
|
||||
from datetime import datetime
|
||||
from itertools import repeat
|
||||
from operator import attrgetter
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
import requests
|
||||
import requests_unixsocket
|
||||
import yaml
|
||||
|
||||
from config import *
|
||||
|
||||
|
||||
def itemgetter(*items):
|
||||
"operator.itemgetter with None as fallback when key does not exist"
|
||||
if len(items) == 1:
|
||||
item = items[0]
|
||||
|
||||
def g(obj):
|
||||
return obj.get(item)
|
||||
|
||||
else:
|
||||
|
||||
def g(obj):
|
||||
return tuple(obj.get(item) for item in items)
|
||||
|
||||
return g
|
||||
|
||||
|
||||
class PostgrestError(Exception):
|
||||
"Postgrest exited with a non-zero return code."
|
||||
|
||||
|
||||
def cli(args, env=None, stdin=None):
|
||||
"Run PostgREST and return stdout."
|
||||
env = env or {}
|
||||
|
||||
command = [POSTGREST_BIN] + args
|
||||
env["HPCTIXFILE"] = hpctixfile()
|
||||
|
||||
process = subprocess.Popen(
|
||||
command, env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE
|
||||
)
|
||||
|
||||
process.stdin.write(stdin or b"")
|
||||
try:
|
||||
result = process.communicate(timeout=5)[0]
|
||||
if process.returncode != 0:
|
||||
raise PostgrestError()
|
||||
return result.decode()
|
||||
finally:
|
||||
process.kill()
|
||||
process.wait()
|
||||
|
||||
|
||||
def dumpconfig(configpath=None, env=None, stdin=None):
|
||||
"Dump the config as parsed by PostgREST."
|
||||
args = ["--dump-config"]
|
||||
|
||||
if configpath:
|
||||
args.append(configpath)
|
||||
|
||||
return cli(args, env=env, stdin=stdin)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"args,env,use_defaultenv,expect",
|
||||
map(itemgetter("args", "env", "use_defaultenv", "expect"), FIXTURES["cli"]),
|
||||
ids=map(itemgetter("name"), FIXTURES["cli"]),
|
||||
)
|
||||
def test_cli(args, env, use_defaultenv, expect, defaultenv):
|
||||
"""
|
||||
When PostgREST is run with <args> arguments and <env>/<defaultenv> environment variabales
|
||||
it should return. Exit code should be according to <expect_error>.
|
||||
"""
|
||||
# use --dump-config by default to make sure that the postgrest process will terminate for sure
|
||||
args = args or ["--dump-config"]
|
||||
|
||||
env = env or {}
|
||||
if use_defaultenv:
|
||||
env = {**defaultenv, **env}
|
||||
|
||||
if expect == "error":
|
||||
with pytest.raises(PostgrestError):
|
||||
print(cli(args, env=env))
|
||||
else:
|
||||
dump = cli(args, env=env).split("\n")
|
||||
if expect:
|
||||
assert expect in dump
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"expectedconfig",
|
||||
[
|
||||
expectedconfig
|
||||
for expectedconfig in (CONFIGSDIR / "expected").iterdir()
|
||||
if (CONFIGSDIR / expectedconfig.name).exists()
|
||||
],
|
||||
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()
|
||||
config = CONFIGSDIR / expectedconfig.name
|
||||
|
||||
assert dumpconfig(config) == expected
|
||||
|
||||
|
||||
def test_expected_config_from_environment():
|
||||
"Config should be read directly from environment without config file."
|
||||
|
||||
envfile = (CONFIGSDIR / "no-defaults-env.yaml").read_text()
|
||||
env = {k: str(v) for k, v in yaml.load(envfile, Loader=yaml.Loader).items()}
|
||||
|
||||
expected = (CONFIGSDIR / "expected" / "no-defaults.config").read_text()
|
||||
assert dumpconfig(env=env) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"role, expectedconfig",
|
||||
[
|
||||
("db_config_authenticator", "no-defaults-with-db.config"),
|
||||
("other_authenticator", "no-defaults-with-db-other-authenticator.config"),
|
||||
],
|
||||
)
|
||||
def test_expected_config_from_db_settings(baseenv, role, expectedconfig):
|
||||
"Config should be overriden from database settings"
|
||||
|
||||
config = CONFIGSDIR / "no-defaults.config"
|
||||
|
||||
env = {
|
||||
**baseenv,
|
||||
"PGUSER": role,
|
||||
"PGRST_DB_URI": "postgresql://",
|
||||
"PGRST_DB_CONFIG": "true",
|
||||
}
|
||||
|
||||
expected = (CONFIGSDIR / "expected" / expectedconfig).read_text()
|
||||
assert dumpconfig(configpath=config, env=env) == 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, defaultenv):
|
||||
"""
|
||||
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 = {
|
||||
**defaultenv,
|
||||
"ROLE_CLAIM_KEY": '."https://www.example.com/roles"[0].value',
|
||||
"POSTGREST_TEST_SOCKET": "/tmp/postgrest.sock",
|
||||
"POSTGREST_TEST_PORT": "80",
|
||||
"JWT_SECRET_FILE": "a_file",
|
||||
}
|
||||
|
||||
# Some configs expect input from stdin, at least on base64.
|
||||
stdin = b"Y29ubmVjdGlvbl9zdHJpbmc="
|
||||
|
||||
dumped = dumpconfig(config, env=env, stdin=stdin)
|
||||
|
||||
tmpconfigpath = tmp_path / "config"
|
||||
tmpconfigpath.write_text(dumped)
|
||||
redumped = dumpconfig(tmpconfigpath, env=env)
|
||||
|
||||
assert dumped == redumped
|
||||
|
||||
|
||||
@pytest.mark.parametrize("invalidroleclaimkey", FIXTURES["invalidroleclaimkeys"])
|
||||
def test_invalid_role_claim_key(invalidroleclaimkey, defaultenv):
|
||||
"Given an invalid role-claim-key, Postgrest should exit with a non-zero exit code."
|
||||
env = {
|
||||
**defaultenv,
|
||||
"PGRST_JWT_ROLE_CLAIM_KEY": invalidroleclaimkey,
|
||||
}
|
||||
|
||||
with pytest.raises(PostgrestError):
|
||||
dump = dumpconfig(env=env)
|
||||
for line in dump.split("\n"):
|
||||
if line.startswith("jwt-role-claim-key"):
|
||||
print(line)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("invalidopenapimodes", FIXTURES["invalidopenapimodes"])
|
||||
def test_invalid_openapi_mode(invalidopenapimodes, defaultenv):
|
||||
"Given an invalid openapi-mode, Postgrest should exit with a non-zero exit code."
|
||||
env = {
|
||||
**defaultenv,
|
||||
"PGRST_OPENAPI_MODE": invalidopenapimodes,
|
||||
}
|
||||
|
||||
with pytest.raises(PostgrestError):
|
||||
dump = dumpconfig(CONFIGSDIR / "defaults.config", env=env)
|
||||
for line in dump.split("\n"):
|
||||
if line.startswith("openapi-mode"):
|
||||
print(line)
|
||||
+3
-442
@@ -1,427 +1,18 @@
|
||||
"Unit tests for Input/Ouput of PostgREST seen as a black box."
|
||||
|
||||
import contextlib
|
||||
import dataclasses
|
||||
from datetime import datetime
|
||||
from itertools import repeat
|
||||
from operator import attrgetter
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
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)
|
||||
POSTGREST_BIN = shutil.which("postgrest")
|
||||
SECRET = "reallyreallyreallyreallyverysafe"
|
||||
|
||||
|
||||
def itemgetter(*items):
|
||||
"operator.itemgetter with None as fallback when key does not exist"
|
||||
if len(items) == 1:
|
||||
item = items[0]
|
||||
|
||||
def g(obj):
|
||||
return obj.get(item)
|
||||
|
||||
else:
|
||||
|
||||
def g(obj):
|
||||
return tuple(obj.get(item) for item in items)
|
||||
|
||||
return g
|
||||
|
||||
|
||||
class Thread(threading.Thread):
|
||||
"Variant of threading.Thread that re-raises any exceptions when joining the thread"
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self._exception = None
|
||||
super(Thread, self).__init__(*args, **kwargs)
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
super(Thread, self).run()
|
||||
except Exception as e:
|
||||
self._exception = e
|
||||
|
||||
def join(self):
|
||||
super(Thread, self).join()
|
||||
if self._exception is not None:
|
||||
raise self._exception
|
||||
|
||||
|
||||
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):
|
||||
# Not using urllib.parse.urljoin to compose the url, as it doesn't play
|
||||
# well with our 'http+unix://' unix domain socket urls.
|
||||
fullurl = self.baseurl + url
|
||||
return super(PostgrestSession, self).request(method, fullurl, *args, **kwargs)
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class PostgrestProcess:
|
||||
"Running PostgREST process and its corresponding main and admin endpoints."
|
||||
admin: object
|
||||
process: object
|
||||
session: object
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dburi():
|
||||
"Postgres database connection URI."
|
||||
dbname = os.environ["PGDATABASE"]
|
||||
host = os.environ["PGHOST"]
|
||||
user = os.environ["PGUSER"]
|
||||
return f"postgresql://?dbname={dbname}&host={host}&user={user}".encode()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def baseenv():
|
||||
"Base environment to connect to PostgreSQL"
|
||||
return {
|
||||
"PGDATABASE": os.environ["PGDATABASE"],
|
||||
"PGHOST": os.environ["PGHOST"],
|
||||
"PGUSER": os.environ["PGUSER"],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def defaultenv(baseenv):
|
||||
"Default environment for PostgREST."
|
||||
return {
|
||||
**baseenv,
|
||||
"PGRST_DB_CONFIG": "true",
|
||||
"PGRST_LOG_LEVEL": "info",
|
||||
"PGRST_DB_POOL": "1",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def metapostgrest():
|
||||
"A shared postgrest instance to use for interacting with the database independently of the instance under test"
|
||||
role = "meta_authenticator"
|
||||
env = {
|
||||
"PGDATABASE": os.environ["PGDATABASE"],
|
||||
"PGHOST": os.environ["PGHOST"],
|
||||
"PGUSER": role,
|
||||
"PGRST_DB_ANON_ROLE": role,
|
||||
"PGRST_DB_CONFIG": "true",
|
||||
"PGRST_LOG_LEVEL": "info",
|
||||
"PGRST_DB_POOL": "1",
|
||||
}
|
||||
with run(env=env) as postgrest:
|
||||
yield postgrest
|
||||
|
||||
|
||||
def hpctixfile():
|
||||
"Returns an individual filename for each test, if the HPCTIXFILE environment variable is set."
|
||||
if "HPCTIXFILE" not in os.environ:
|
||||
return ""
|
||||
|
||||
tixfile = pathlib.Path(os.environ["HPCTIXFILE"])
|
||||
test = hash(os.environ["PYTEST_CURRENT_TEST"])
|
||||
return tixfile.with_suffix(f".{test}.tix")
|
||||
|
||||
|
||||
def cli(args, env=None, stdin=None):
|
||||
"Run PostgREST and return stdout."
|
||||
env = env or {}
|
||||
|
||||
command = [POSTGREST_BIN] + args
|
||||
env["HPCTIXFILE"] = hpctixfile()
|
||||
|
||||
process = subprocess.Popen(
|
||||
command, env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE
|
||||
)
|
||||
|
||||
process.stdin.write(stdin or b"")
|
||||
try:
|
||||
result = process.communicate(timeout=5)[0]
|
||||
if process.returncode != 0:
|
||||
raise PostgrestError()
|
||||
return result.decode()
|
||||
finally:
|
||||
process.kill()
|
||||
process.wait()
|
||||
|
||||
|
||||
def dumpconfig(configpath=None, env=None, stdin=None):
|
||||
"Dump the config as parsed by PostgREST."
|
||||
args = ["--dump-config"]
|
||||
|
||||
if configpath:
|
||||
args.append(configpath)
|
||||
|
||||
return cli(args, env=env, stdin=stdin)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def run(
|
||||
configpath=None,
|
||||
stdin=None,
|
||||
env=None,
|
||||
port=None,
|
||||
host=None,
|
||||
no_pool_connection_available=False,
|
||||
):
|
||||
"Run PostgREST and yield an endpoint that is ready for connections."
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
if port:
|
||||
env["PGRST_SERVER_PORT"] = str(port)
|
||||
env["PGRST_SERVER_HOST"] = host or "localhost"
|
||||
baseurl = f"http://localhost:{port}"
|
||||
else:
|
||||
socketfile = pathlib.Path(tmpdir) / "postgrest.sock"
|
||||
env["PGRST_SERVER_UNIX_SOCKET"] = str(socketfile)
|
||||
baseurl = "http+unix://" + urllib.parse.quote_plus(str(socketfile))
|
||||
|
||||
adminport = freeport()
|
||||
env["PGRST_ADMIN_SERVER_PORT"] = str(adminport)
|
||||
adminurl = f"http://localhost:{adminport}"
|
||||
|
||||
command = [POSTGREST_BIN]
|
||||
env["HPCTIXFILE"] = hpctixfile()
|
||||
|
||||
if configpath:
|
||||
command.append(configpath)
|
||||
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
env=env,
|
||||
)
|
||||
|
||||
os.set_blocking(process.stdout.fileno(), False)
|
||||
|
||||
try:
|
||||
process.stdin.write(stdin or b"")
|
||||
process.stdin.close()
|
||||
|
||||
wait_until_ready(adminurl + "/ready")
|
||||
|
||||
process.stdout.read()
|
||||
|
||||
yield PostgrestProcess(
|
||||
process=process,
|
||||
session=PostgrestSession(baseurl),
|
||||
admin=PostgrestSession(adminurl),
|
||||
)
|
||||
finally:
|
||||
if no_pool_connection_available:
|
||||
sleep_pool_connection(baseurl, 10)
|
||||
|
||||
remaining_output = process.stdout.read()
|
||||
if remaining_output:
|
||||
print(remaining_output.decode())
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=1)
|
||||
except:
|
||||
process.kill()
|
||||
process.wait()
|
||||
|
||||
|
||||
def freeport():
|
||||
"Find a free port on localhost."
|
||||
with contextlib.closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s:
|
||||
s.bind(("", 0))
|
||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
def wait_until_ready(url):
|
||||
"Wait for the given HTTP endpoint to return a status of 200."
|
||||
session = requests_unixsocket.Session()
|
||||
|
||||
response = None
|
||||
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)
|
||||
|
||||
if response:
|
||||
raise PostgrestTimedOut(f"{response.status_code}: {response.text}")
|
||||
else:
|
||||
raise PostgrestTimedOut()
|
||||
|
||||
|
||||
def sleep_pool_connection(url, seconds):
|
||||
"Sleep a pool connection by calling an RPC that uses pg_sleep"
|
||||
session = requests_unixsocket.Session()
|
||||
|
||||
# The try/except is a hack for not waiting for the response,
|
||||
# taken from https://stackoverflow.com/a/45601591/4692662
|
||||
try:
|
||||
session.get(url + f"/rpc/sleep?seconds={seconds}", timeout=0.1)
|
||||
except requests.exceptions.ReadTimeout:
|
||||
pass
|
||||
|
||||
|
||||
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))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"args,env,use_defaultenv,expect",
|
||||
map(itemgetter("args", "env", "use_defaultenv", "expect"), FIXTURES["cli"]),
|
||||
ids=map(itemgetter("name"), FIXTURES["cli"]),
|
||||
)
|
||||
def test_cli(args, env, use_defaultenv, expect, defaultenv):
|
||||
"""
|
||||
When PostgREST is run with <args> arguments and <env>/<defaultenv> environment variabales
|
||||
it should return. Exit code should be according to <expect_error>.
|
||||
"""
|
||||
# use --dump-config by default to make sure that the postgrest process will terminate for sure
|
||||
args = args or ["--dump-config"]
|
||||
|
||||
env = env or {}
|
||||
if use_defaultenv:
|
||||
env = {**defaultenv, **env}
|
||||
|
||||
if expect == "error":
|
||||
with pytest.raises(PostgrestError):
|
||||
print(cli(args, env=env))
|
||||
else:
|
||||
dump = cli(args, env=env).split("\n")
|
||||
if expect:
|
||||
assert expect in dump
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"expectedconfig",
|
||||
[
|
||||
expectedconfig
|
||||
for expectedconfig in (CONFIGSDIR / "expected").iterdir()
|
||||
if (CONFIGSDIR / expectedconfig.name).exists()
|
||||
],
|
||||
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()
|
||||
config = CONFIGSDIR / expectedconfig.name
|
||||
|
||||
assert dumpconfig(config) == expected
|
||||
|
||||
|
||||
def test_expected_config_from_environment():
|
||||
"Config should be read directly from environment without config file."
|
||||
|
||||
envfile = (CONFIGSDIR / "no-defaults-env.yaml").read_text()
|
||||
env = {k: str(v) for k, v in yaml.load(envfile, Loader=yaml.Loader).items()}
|
||||
|
||||
expected = (CONFIGSDIR / "expected" / "no-defaults.config").read_text()
|
||||
assert dumpconfig(env=env) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"role, expectedconfig",
|
||||
[
|
||||
("db_config_authenticator", "no-defaults-with-db.config"),
|
||||
("other_authenticator", "no-defaults-with-db-other-authenticator.config"),
|
||||
],
|
||||
)
|
||||
def test_expected_config_from_db_settings(baseenv, role, expectedconfig):
|
||||
"Config should be overriden from database settings"
|
||||
|
||||
config = CONFIGSDIR / "no-defaults.config"
|
||||
|
||||
env = {
|
||||
**baseenv,
|
||||
"PGUSER": role,
|
||||
"PGRST_DB_URI": "postgresql://",
|
||||
"PGRST_DB_CONFIG": "true",
|
||||
}
|
||||
|
||||
expected = (CONFIGSDIR / "expected" / expectedconfig).read_text()
|
||||
assert dumpconfig(configpath=config, env=env) == 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, defaultenv):
|
||||
"""
|
||||
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 = {
|
||||
**defaultenv,
|
||||
"ROLE_CLAIM_KEY": '."https://www.example.com/roles"[0].value',
|
||||
"POSTGREST_TEST_SOCKET": "/tmp/postgrest.sock",
|
||||
"POSTGREST_TEST_PORT": "80",
|
||||
"JWT_SECRET_FILE": "a_file",
|
||||
}
|
||||
|
||||
# Some configs expect input from stdin, at least on base64.
|
||||
stdin = b"Y29ubmVjdGlvbl9zdHJpbmc="
|
||||
|
||||
dumped = dumpconfig(config, env=env, stdin=stdin)
|
||||
|
||||
tmpconfigpath = tmp_path / "config"
|
||||
tmpconfigpath.write_text(dumped)
|
||||
redumped = dumpconfig(tmpconfigpath, env=env)
|
||||
|
||||
assert dumped == redumped
|
||||
from config import *
|
||||
from util import *
|
||||
from postgrest import *
|
||||
|
||||
|
||||
def test_port_connection(defaultenv):
|
||||
@@ -536,36 +127,6 @@ def test_role_claim_key(roleclaim, defaultenv):
|
||||
assert response.status_code == roleclaim["expected_status"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("invalidroleclaimkey", FIXTURES["invalidroleclaimkeys"])
|
||||
def test_invalid_role_claim_key(invalidroleclaimkey, defaultenv):
|
||||
"Given an invalid role-claim-key, Postgrest should exit with a non-zero exit code."
|
||||
env = {
|
||||
**defaultenv,
|
||||
"PGRST_JWT_ROLE_CLAIM_KEY": invalidroleclaimkey,
|
||||
}
|
||||
|
||||
with pytest.raises(PostgrestError):
|
||||
dump = dumpconfig(env=env)
|
||||
for line in dump.split("\n"):
|
||||
if line.startswith("jwt-role-claim-key"):
|
||||
print(line)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("invalidopenapimodes", FIXTURES["invalidopenapimodes"])
|
||||
def test_invalid_openapi_mode(invalidopenapimodes, defaultenv):
|
||||
"Given an invalid openapi-mode, Postgrest should exit with a non-zero exit code."
|
||||
env = {
|
||||
**defaultenv,
|
||||
"PGRST_OPENAPI_MODE": invalidopenapimodes,
|
||||
}
|
||||
|
||||
with pytest.raises(PostgrestError):
|
||||
dump = dumpconfig(CONFIGSDIR / "defaults.config", env=env)
|
||||
for line in dump.split("\n"):
|
||||
if line.startswith("openapi-mode"):
|
||||
print(line)
|
||||
|
||||
|
||||
def test_iat_claim(defaultenv):
|
||||
"""
|
||||
A claim with an 'iat' (issued at) attribute should be successful.
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import contextlib
|
||||
import socket
|
||||
import threading
|
||||
|
||||
import jwt
|
||||
|
||||
|
||||
class Thread(threading.Thread):
|
||||
"Variant of threading.Thread that re-raises any exceptions when joining the thread"
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self._exception = None
|
||||
super(Thread, self).__init__(*args, **kwargs)
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
super(Thread, self).run()
|
||||
except Exception as e:
|
||||
self._exception = e
|
||||
|
||||
def join(self):
|
||||
super(Thread, self).join()
|
||||
if self._exception is not None:
|
||||
raise self._exception
|
||||
|
||||
|
||||
def freeport():
|
||||
"Find a free port on localhost."
|
||||
with contextlib.closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s:
|
||||
s.bind(("", 0))
|
||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
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))
|
||||
Reference in New Issue
Block a user