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."
|
"Unit tests for Input/Ouput of PostgREST seen as a black box."
|
||||||
|
|
||||||
import contextlib
|
|
||||||
import dataclasses
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from itertools import repeat
|
|
||||||
from operator import attrgetter
|
from operator import attrgetter
|
||||||
import os
|
import os
|
||||||
import pathlib
|
|
||||||
import re
|
import re
|
||||||
import shutil
|
|
||||||
import signal
|
import signal
|
||||||
import socket
|
import socket
|
||||||
import subprocess
|
|
||||||
import tempfile
|
|
||||||
import threading
|
|
||||||
import time
|
import time
|
||||||
import urllib.parse
|
|
||||||
|
|
||||||
import jwt
|
|
||||||
import pytest
|
import pytest
|
||||||
import requests
|
|
||||||
import requests_unixsocket
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
|
from config import *
|
||||||
BASEDIR = pathlib.Path(os.path.realpath(__file__)).parent
|
from util import *
|
||||||
CONFIGSDIR = BASEDIR / "configs"
|
from postgrest import *
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def test_port_connection(defaultenv):
|
def test_port_connection(defaultenv):
|
||||||
@@ -536,36 +127,6 @@ def test_role_claim_key(roleclaim, defaultenv):
|
|||||||
assert response.status_code == roleclaim["expected_status"]
|
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):
|
def test_iat_claim(defaultenv):
|
||||||
"""
|
"""
|
||||||
A claim with an 'iat' (issued at) attribute should be successful.
|
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