diff --git a/test/io/config.py b/test/io/config.py new file mode 100644 index 000000000..6cb63af14 --- /dev/null +++ b/test/io/config.py @@ -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") diff --git a/test/io/postgrest.py b/test/io/postgrest.py new file mode 100644 index 000000000..30a4c1ec9 --- /dev/null +++ b/test/io/postgrest.py @@ -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 diff --git a/test/io/test_cli.py b/test/io/test_cli.py new file mode 100644 index 000000000..ae8130f4b --- /dev/null +++ b/test/io/test_cli.py @@ -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 arguments and / environment variabales + it should return. Exit code should be according to . + """ + # 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) diff --git a/test/io/test_io.py b/test/io/test_io.py index 07bd6bb0f..9b551b693 100644 --- a/test/io/test_io.py +++ b/test/io/test_io.py @@ -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 arguments and / environment variabales - it should return. Exit code should be according to . - """ - # 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. diff --git a/test/io/util.py b/test/io/util.py new file mode 100644 index 000000000..7d677c086 --- /dev/null +++ b/test/io/util.py @@ -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))