diff --git a/.gitignore b/.gitignore index 620f7c30a..fd5e22ee2 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ result* dist-newstyle postgrest.hp postgrest.prof +__pycache__ diff --git a/.travis.yml b/.travis.yml index 3ebaf5fbb..7bb84333d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -87,6 +87,8 @@ jobs: update: true packages: - postgresql-12 + - python3 + - python3-pip cache: yarn: true timeout: 1000 @@ -107,17 +109,18 @@ jobs: fi travis_wait stack --no-terminal setup travis_wait stack --no-terminal install hpc + pip3 install pytest pyyaml requests requests-unixsocket pyjwt script: | travis_wait 50 stack --no-terminal build --fast -j1 --coverage travis_wait 50 stack --no-terminal build --fast -j1 --coverage --test --no-run-tests test/with_tmp_db stack --no-terminal test --coverage - test/with_tmp_db stack --no-terminal exec test/io-tests.sh + test/with_tmp_db stack --no-terminal exec -- pytest -v test/io-tests after_script: | export _HPC_DIR=$(stack path --local-hpc-root) export _MIX_DIR=$(stack path --dist-dir) export _PKG_NAME=$(stack exec -- ghc-pkg field postgrest key --simple-output) # merge the results from `stack test` and the io tests and exclude Paths_postgrest - stack --no-terminal exec hpc -- sum --union --exclude=Paths_postgrest --output=/tmp/all.tix $_HPC_DIR/combined/all/all.tix test/io-tests/postgrest.tix + stack --no-terminal exec hpc -- sum --union --exclude=Paths_postgrest --output=/tmp/all.tix $_HPC_DIR/combined/all/all.tix postgrest.tix # fix a bug in stack-hpc-coveralls mv $_MIX_DIR/hpc/Main.mix $_MIX_DIR/hpc/$_PKG_NAME/Main.mix mv $_MIX_DIR/hpc/UnixSocket.mix $_MIX_DIR/hpc/$_PKG_NAME/UnixSocket.mix diff --git a/nix/style.nix b/nix/style.nix index 9f2a7165e..33fc65852 100644 --- a/nix/style.nix +++ b/nix/style.nix @@ -1,4 +1,5 @@ -{ buildEnv +{ black +, buildEnv , checkedShellScript , git , hlint @@ -19,6 +20,9 @@ let # --vimgrep fixes a bug in ag: https://github.com/ggreer/the_silver_searcher/issues/753 ${silver-searcher}/bin/ag -l --vimgrep -g '\.l?hs$' . "$rootdir" \ | xargs ${stylish-haskell}/bin/stylish-haskell -i + + # Format Python files + ${black}/bin/black "$rootdir" 2> /dev/null ''; # Script to check whether any uncommited changes result from postgrest-style diff --git a/nix/tests.nix b/nix/tests.nix index 767c815c7..7eb23d058 100644 --- a/nix/tests.nix +++ b/nix/tests.nix @@ -15,6 +15,7 @@ , postgrestStatic , postgrestProfiled , procps +, python3 , runtimeShell }: let @@ -74,19 +75,29 @@ let checkedShellScript "postgrest-test-spec-all" (lib.concatStringsSep "\n" testRunners); + ioTestPython = + python3.withPackages (ps: [ + ps.pytest + ps.requests + ps.requests-unixsocket + ps.pyjwt + ps.pyyaml + ]); + testIO = name: postgresql: checkedShellScript name '' env="$(cat ${postgrest.env})" - export PATH="$env/bin:${curl}/bin:${procps}/bin:${diffutils}/bin:$PATH" + export PATH="$env/bin:$PATH" rootdir="$(${git}/bin/git rev-parse --show-toplevel)" cd "$rootdir" ${cabal-install}/bin/cabal v2-build ${devCabalOptions} - ${cabal-install}/bin/cabal v2-exec ${withTmpDb postgresql} "$rootdir"/test/io-tests.sh + ${cabal-install}/bin/cabal v2-exec ${withTmpDb postgresql} \ + ${ioTestPython}/bin/pytest -- -v "$rootdir"/test/io-tests "$@" ''; testMemory = @@ -117,7 +128,7 @@ buildEnv ] ++ testSpecVersions; } # The memory tests have large dependencies (a profiled build of PostgREST) - # and are run less often than the spec tests, so we don't include them in + # and are run less often than the spec tests, so we don't include them in # the default test environment. We make them available through a separate attribute: // { memoryTests = diff --git a/test/io-tests.sh b/test/io-tests.sh deleted file mode 100755 index 5ff60430e..000000000 --- a/test/io-tests.sh +++ /dev/null @@ -1,427 +0,0 @@ -#!/usr/bin/env bash -# Run unit tests for Input/Ouput of PostgREST seen as a black box -# with test output in Test Anything Protocol format. -# -# These tests expect that `postgrest` is on the PATH, as well as `curl` -# -# References: -# [1] Test Anything Protocol -# https://testanything.org/ -# -# [2] TAP Specification -# https://testanything.org/tap-specification.html -# -# [3] List of TCP and UDP port numbers -# https://en.wikipedia.org/wiki/List_of_TCP_and_UDP_port_numbers -# -set -eu - -export POSTGREST_TEST_CONNECTION=${POSTGREST_TEST_CONNECTION:-"postgres:///postgrest_test"} - -cd "$(dirname "$0")" -cd io-tests - -cleanup() { - # clean up trap to avoid bash segmentation fault - trap - sigint sigterm exit - - # kill without output - ps=$(pgrep -g0 | sed -e "1,/$$/d") - kill $ps 2> /dev/null - wait $ps 2> /dev/null -} - -trap cleanup sigint sigterm exit - -# Port for Test PostgREST Server (must match config) -pgrPort=49421 # in range 49152–65535: for private or temporary use - -# Colors -NC='\033[0m' # no color -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' - -# TAP utilities -currentTest=1 -failedTests=0 -bailOut(){ echo "Bail out! $1"; exit 1; } -result(){ echo -e "$1 $currentTest $2${NC}"; currentTest=$(( $currentTest + 1 )); } -todo(){ result "${YELLOW}ok" "# TODO: $*"; } -skip(){ result "${YELLOW}ok" "# SKIP: $*"; } -ok(){ result "${GREEN}ok" "- $1"; } -ko(){ result "${RED}not ok" "- $1"; failedTests=$(( $failedTests + 1 )); } -comment(){ echo "# $1"; } - -######################## -# SYNCHRONOUS IO TESTS # -######################## - -dumpedConfigMatchesExpectation(){ - # This test compares the dumped config vs. the corresponding file in ./configs/expected. - # To be used to test default values, config aliases and environment variables. - dump="$(mktemp)" - tap(){ - if test $1 -eq 0; then - ok "dump of config file $2 does match expectation" - else - ko "dump of config file $2 does not match expectation" - fi - rm -f "$dump" - } - trap 'tap $? $1; trap - RETURN; return 0' ERR RETURN - postgrest --dump-config "$1" > "$dump" - diff --color "$dump" "$2" -} - -dumpedConfigIsValid(){ - # This test compares the dumped config vs. the dumped-reread-redumped config. - # Re-reading the dumped config tests the validity of the config format. - # Re-dumping this config should yield no difference to the first dump, showing - # that the semantics have not changed by dumping. - # Note: only dump vs redump must be equal, the original config file can be different, - # because of default values, whitespace, and quoting - dump="$(mktemp)" - redump="$(mktemp)" - tap(){ - if test $1 -eq 0; then - ok "dump of config file $2 is valid" - else - ko "dump of config file $2 is invalid" - fi - rm -f "$dump" "$redump" - } - trap 'tap $? $1; trap - RETURN; return 0' ERR RETURN - postgrest --dump-config "$1" > "$dump" - postgrest --dump-config "$dump" > "$redump" - diff --color "$dump" "$redump" -} - -#################### -# BACKGROUND TESTS # -#################### - -# Utilities to start/stop test PostgREST server running in the background -pgrStart(){ - # stderr is not piped to /dev/null to catch errors on startup. - # to keep $! reference the correct pid, stderr is piped to a subshell and - # then filtered for FatalError. Those are part of the tests and expected. - postgrest $1 >/dev/null 2> >(grep -v 'FatalError' 1>&2) & pgrPID="$!"; -} -pgrStartRead(){ postgrest $1 <$2 >/dev/null & pgrPID="$!"; } -pgrStartStdin(){ postgrest $1 >/dev/null <<< "$2" & pgrPID="$!"; } -pgrStarted(){ kill -0 "$pgrPID" 2>/dev/null; } -pgrStop(){ kill "$pgrPID" 2>/dev/null; pgrPID=""; sleep 0.1; } - -# Utilities to send HTTP requests to the PostgREST server -rootStatus(){ - curl -s -o /dev/null -w '%{http_code}' "http://localhost:$pgrPort/" -} - -authorsStatus(){ - curl -s -o /dev/null -w '%{http_code}' \ - -H "Authorization: Bearer $1" \ - "http://localhost:$pgrPort/authors_only" -} - -v1SchemaParentsStatus(){ - curl -s -o /dev/null -w '%{http_code}' \ - -H "Accept-Profile: v1" \ - "http://localhost:$pgrPort/parents" -} - -# Unit Test Templates -readSecretFromFile(){ - case "$1" in - *.b64) - pgrConfig="base64-secret-from-file.config";; - *) - pgrConfig="secret-from-file.config";; - esac - pgrStartRead "./configs/$pgrConfig" "./secrets/$1" - while pgrStarted && test "$( rootStatus )" -ne 200 - do - # wait for the server to start - sleep 0.1 - done - if pgrStarted - then - authorsJwt="./secrets/${1%.*}.jwt" - httpStatus="$( authorsStatus $(cat "$authorsJwt") )" - if test "$httpStatus" -eq 200 - then - ok "authentication with $2 secret read from a file" - else - ko "authentication with $2 secret read from a file: $httpStatus" - fi - else - ko "failed to read $2 secret from a file" - fi - pgrStop -} - -readDbUriFromStdin(){ - pgrConfig="dburi-from-file.config" - pgrStartStdin "./configs/$pgrConfig" "$1" - while pgrStarted && test "$( rootStatus )" -ne 200 - do - # wait for the server to start - sleep 0.1 - done - if pgrStarted - then - ok "connection with $2 dburi read from stdin / a file" - else - ko "connection with $2 dburi read from stdin / a file" - fi - pgrStop -} - -reqWithRoleClaimKey(){ - export ROLE_CLAIM_KEY=$1 - pgrStart "./configs/role-claim-key.config" - while pgrStarted && test "$( rootStatus )" -ne 200 - do - # wait for the server to start - sleep 0.1 - done - authorsJwt=$(psql -qtAX "$POSTGREST_TEST_CONNECTION" -c "select jwt.sign('$2', 'reallyreallyreallyreallyverysafe');") - httpStatus="$( authorsStatus "$authorsJwt" )" - if test "$httpStatus" -eq $3 - then - ok "request with \"$1\" role-claim-key for $2 jwt: $httpStatus" - else - ko "request with \"$1\" role-claim-key for $2 jwt: $httpStatus" - fi - pgrStop -} - -invalidRoleClaimKey(){ - export ROLE_CLAIM_KEY=$1 - pgrStart "./configs/role-claim-key.config" - while pgrStarted && test "$( rootStatus )" -ne 200 - do - # wait for the server to start - sleep 0.1 - done - if pgrStarted - then - ko "invalid jspath \"$1\": accepted" - pgrStop - else - ok "invalid jspath \"$1\": rejected" - fi -} - -# ensure iat claim is successful in the presence of pgrst time cache, see https://github.com/PostgREST/postgrest/issues/1139 -ensureIatClaimWorks(){ - pgrStart "./configs/simple.config" - while pgrStarted && test "$( rootStatus )" -ne 200 - do - # wait for the server to start - sleep 0.1 - done - for i in {1..10}; do \ - iatJwt=$(psql -qtAX "$POSTGREST_TEST_CONNECTION" -c "select jwt.sign(row_to_json(r), 'reallyreallyreallyreallyverysafe') from ( select 'postgrest_test_author' as role, extract(epoch from now()) as iat) r") - httpStatus="$( authorsStatus $iatJwt )" - if test "$httpStatus" -ne 200 - then - ko "iat claim rejected: $httpStatus" - return - fi - sleep .5;\ - done - ok "iat claim accepted" - pgrStop -} - -# ensure app settings don't reset on pool timeout, see https://github.com/PostgREST/postgrest/issues/1141 -# pool timeout set to 1s to shorten runtime -ensureAppSettings(){ - pgrStart "./configs/app-settings.config" - while pgrStarted && test "$( rootStatus )" -ne 200 - do - # wait for the server to start - sleep 0.1 - done - sleep 2 - response=$(curl -s "http://localhost:$pgrPort/rpc/get_guc_value?name=app.settings.external_api_secret") - if test "$response" = "\"0123456789abcdef\"" - then - ok "GET /rpc/get_guc_value: $response" - else - ko "GET /rpc/get_guc_value: $response" - fi - pgrStop -} - -checkAppSettingsReload(){ - configFile=$(mktemp) - trap "rm -f $configFile" ERR RETURN - cat "./configs/sigusr2-settings.config" > "$configFile" - pgrStart "$configFile" - while pgrStarted && test "$( rootStatus )" -ne 200 - do - # wait for the server to start - sleep 0.1 - done - # change setting - replaceConfigValue "app.settings.name_var" "Jane" "$configFile" - # reload - kill -s SIGUSR2 $pgrPID - response=$(curl -s "http://localhost:$pgrPort/rpc/get_guc_value?name=app.settings.name_var") - if test "$response" = "\"Jane\"" - then - ok "app.settings.name_var config reloaded with SIGUSR2" - else - ko "app.settings.name_var config not reloaded with SIGUSR2. Got: $response" - fi - pgrStop -} - -checkJwtSecretReload(){ - configFile=$(mktemp) - trap "rm -f $configFile" ERR RETURN - cat "./configs/sigusr2-settings.config" > "$configFile" - pgrStart "$configFile" - while pgrStarted && test "$( rootStatus )" -ne 200 - do - # wait for the server to start - sleep 0.1 - done - secret="reallyreallyreallyreallyverysafe" - # change setting - replaceConfigValue "jwt-secret" "$secret" "$configFile" - # reload - kill -s SIGUSR2 $pgrPID - payload='{"role":"postgrest_test_author"}' - authorsJwt=$(psql -qtAX "$POSTGREST_TEST_CONNECTION" -c "select jwt.sign('$payload', '$secret');") - httpStatus="$( authorsStatus "$authorsJwt" )" - if test "$httpStatus" -eq 200 - then - ok "jwt-secret config reloaded with SIGUSR2" - else - ko "jwt-secret config not reloaded with SIGUSR2. Got: $httpStatus" - fi - pgrStop -} - -checkDbSchemaReload(){ - configFile=$(mktemp) - trap "rm -f $configFile" ERR RETURN - cat "./configs/sigusr2-settings.config" > "$configFile" - pgrStart "$configFile" - while pgrStarted && test "$( rootStatus )" -ne 200 - do - # wait for the server to start - sleep 0.1 - done - # add v1 schema to db-schemas - replaceConfigValue "db-schemas" "test, v1" "$configFile" - # reload - kill -s SIGUSR2 $pgrPID - kill -s SIGUSR1 $pgrPID - httpStatus="$(v1SchemaParentsStatus)" - if test "$httpStatus" -eq 200 - then - ok "db-schemas config reloaded with SIGUSR2" - else - ko "db-schemas config not reloaded with SIGUSR2. Got: $httpStatus" - fi - pgrStop -} - -replaceConfigValue(){ - sed -i "s/.*$1.*/$1 = \"$2\"/g" $3 -} - -getSocketStatus() { - curl -sL -w "%{http_code}\\n" -o /dev/null --unix-socket /tmp/postgrest.sock http://localhost/ -} - -socketConnection(){ - pgrStart "./configs/unix-socket.config" - while pgrStarted && test "$( getSocketStatus )" -ne 200 - do - # wait for the server to start - sleep 0.1 - done - if test $( getSocketStatus ) -eq 200 - then - ok "Succesfully connected through unix socket" - else - ko "Failed to connect through unix socket" - fi - pgrStop -} - -# PRE: curl must be available -test -n "$(command -v curl)" || bailOut 'curl is not available' - -# PRE: postgres must be running -psql -l "$POSTGREST_TEST_CONNECTION" 1>/dev/null 2>/dev/null || bailOut 'postgres is not running' - -echo "Running IO tests.." - -# run dumpConfigIsValid with as many inputs as possible -for cfg in configs/*.config -do - # ROLE_CLAIM_KEY is only used in one of the config files - # using a complex example here, to make sure the quoting works - ROLE_CLAIM_KEY='."https://www.example.com/roles"[0].value' \ - dumpedConfigIsValid "$cfg" \ - <<< "Y29ubmVjdGlvbl9zdHJpbmc=" # /dev/stdin is read by some config files, one of them expects Base64 -done - -# run dumpConfigMatchesExpectation with all expectations -for exp in configs/expected/*.config -do - cfg="$(sed -e 's|expected/||' <(echo $exp))" - dumpedConfigMatchesExpectation "$cfg" "$exp" -done - -socketConnection - -readSecretFromFile word.noeol 'simple (no EOL)' -readSecretFromFile word.txt 'simple' -readSecretFromFile ascii.noeol 'ASCII (no EOL)' -readSecretFromFile ascii.txt 'ASCII' -readSecretFromFile utf8.noeol 'UTF-8 (no EOL)' -readSecretFromFile utf8.txt 'UTF-8' -readSecretFromFile binary.noeol 'binary' -readSecretFromFile binary.eol 'binary (+EOL)' - -readSecretFromFile word.b64 'Base64 (simple)' -readSecretFromFile ascii.b64 'Base64 (ASCII)' -readSecretFromFile utf8.b64 'Base64 (UTF-8)' -readSecretFromFile binary.b64 'Base64 (binary)' - -eol=$'\x0a' - -readDbUriFromStdin "$POSTGREST_TEST_CONNECTION" "(no EOL)" -readDbUriFromStdin "$POSTGREST_TEST_CONNECTION$eol" "(EOL)" - -reqWithRoleClaimKey '.postgrest.a_role' '{"postgrest":{"a_role":"postgrest_test_author"}}' 200 -reqWithRoleClaimKey '.customObject.manyRoles[1]' '{"customObject":{"manyRoles": ["other", "postgrest_test_author"]}}' 200 -reqWithRoleClaimKey '."https://www.example.com/roles"[0].value' '{"https://www.example.com/roles":[{"value":"postgrest_test_author"}]}' 200 -reqWithRoleClaimKey '.myDomain[3]' '{"myDomain":["other","postgrest_test_author"]}' 401 -reqWithRoleClaimKey '.myRole' '{"role":"postgrest_test_author"}' 401 - -invalidRoleClaimKey 'role.other' -invalidRoleClaimKey '.role##' -invalidRoleClaimKey '.my_role;;domain' -invalidRoleClaimKey '.#$%&$%/' -invalidRoleClaimKey '' -invalidRoleClaimKey 1234 - -ensureIatClaimWorks -ensureAppSettings - -checkAppSettingsReload -checkJwtSecretReload -checkDbSchemaReload -# TODO: SIGUSR2 tests for other config options - -trap - sigint sigterm exit - -exit $failedTests diff --git a/test/io-tests/configs/unix-socket.config b/test/io-tests/configs/unix-socket.config index 48dfb981d..4d3c4ac10 100644 --- a/test/io-tests/configs/unix-socket.config +++ b/test/io-tests/configs/unix-socket.config @@ -3,5 +3,5 @@ db-schemas = "test" db-anon-role = "postgrest_test_anonymous" db-pool = 1 server-host = "127.0.0.1" -server-unix-socket = "/tmp/postgrest.sock" +server-unix-socket = "$(POSTGREST_TEST_SOCKET)" jwt-secret = "reallyreallyreallyreallyverysafe" diff --git a/test/io-tests/fixtures.yaml b/test/io-tests/fixtures.yaml new file mode 100644 index 000000000..6e3a0f495 --- /dev/null +++ b/test/io-tests/fixtures.yaml @@ -0,0 +1,35 @@ +roleclaims: + - key: '.postgrest.a_role' + data: + postgrest: + a_role: postgrest_test_author + expected_status: 200 + - key: '.customObject.manyRoles[1]' + data: + customObject: + manyRoles: + - other + - postgrest_test_author + expected_status: 200 + - key: '."https://www.example.com/roles"[0].value' + data: + 'https://www.example.com/roles': + - value: postgrest_test_author + expected_status: 200 + - key: '.myDomain[3]' + data: + myDomain: + - other + - postgrest_test_author + expected_status: 401 + - key: '.myRole' + data: + role: postgrest_test_author + expected_status: 401 +invalidroleclaimkeys: + - 'role.other' + - '.role##' + - '.my_role;;domain' + - '.#$$%&$%/' + - '' + - '1234' diff --git a/test/io-tests/test_io.py b/test/io-tests/test_io.py new file mode 100644 index 000000000..2628c479b --- /dev/null +++ b/test/io-tests/test_io.py @@ -0,0 +1,358 @@ +"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=0.1) + + if response.status_code == 200: + return + except requests.ConnectionError: + 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