Migrate io-tests from bash to pytest
Authored-by: monacoremo <monacoremo>
This commit is contained in:
@@ -17,3 +17,4 @@ result*
|
||||
dist-newstyle
|
||||
postgrest.hp
|
||||
postgrest.prof
|
||||
__pycache__
|
||||
|
||||
+5
-2
@@ -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
|
||||
|
||||
+5
-1
@@ -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
|
||||
|
||||
+14
-3
@@ -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 =
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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'
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user