test: Reorganize test/ folder into one subdirectory for each test type
This commit is contained in:
committed by
Wolfgang Walther
parent
8060fe3559
commit
8b63d928ae
@@ -0,0 +1,10 @@
|
||||
db-anon-role = "required"
|
||||
db-uri = "required"
|
||||
|
||||
db-schema = "provided_through_alias"
|
||||
max-rows = 1000
|
||||
pre-request = "check_alias"
|
||||
role-claim-key = ".aliased"
|
||||
root-spec = "open_alias"
|
||||
secret-is-base64 = true
|
||||
db-config = false
|
||||
@@ -0,0 +1,2 @@
|
||||
app.settings.external_api_secret = "0123456789abcdef"
|
||||
db-config = false
|
||||
@@ -0,0 +1,4 @@
|
||||
# Read secret from a file: /dev/stdin (alias for standard input)
|
||||
jwt-secret = "@/dev/stdin"
|
||||
jwt-secret-is-base64 = true
|
||||
db-config = false
|
||||
@@ -0,0 +1,8 @@
|
||||
db-uri = "required"
|
||||
db-schemas = "required"
|
||||
db-anon-role = "required"
|
||||
|
||||
db-channel-enabled = "1"
|
||||
db-prepared-statements = "0"
|
||||
jwt-secret-is-base64 = "2"
|
||||
db-config = false
|
||||
@@ -0,0 +1,8 @@
|
||||
db-uri = "required"
|
||||
db-schemas = "required"
|
||||
db-anon-role = "required"
|
||||
|
||||
db-channel-enabled = "true"
|
||||
db-prepared-statements = "FALSE"
|
||||
jwt-secret-is-base64 = "\"true\""
|
||||
db-config = false
|
||||
@@ -0,0 +1,3 @@
|
||||
db-uri = "@/dev/stdin"
|
||||
jwt-secret = "reallyreallyreallyreallyverysafe"
|
||||
db-config = false
|
||||
@@ -0,0 +1,5 @@
|
||||
db-uri = "required"
|
||||
db-schemas = "required"
|
||||
db-anon-role = "required"
|
||||
# Not the default, but only works with proper db-uri
|
||||
db-config = false
|
||||
@@ -0,0 +1,28 @@
|
||||
db-anon-role = "required"
|
||||
db-channel = "pgrst"
|
||||
db-channel-enabled = true
|
||||
db-extra-search-path = "public"
|
||||
db-max-rows = 1000
|
||||
db-pool = 10
|
||||
db-pool-timeout = 10
|
||||
db-pre-request = "check_alias"
|
||||
db-prepared-statements = true
|
||||
db-root-spec = "open_alias"
|
||||
db-schemas = "provided_through_alias"
|
||||
db-config = false
|
||||
db-tx-end = "commit"
|
||||
db-uri = "required"
|
||||
db-use-legacy-gucs = true
|
||||
jwt-aud = ""
|
||||
jwt-role-claim-key = ".\"aliased\""
|
||||
jwt-secret = ""
|
||||
jwt-secret-is-base64 = true
|
||||
log-level = "error"
|
||||
openapi-mode = "follow-privileges"
|
||||
openapi-server-proxy-uri = ""
|
||||
raw-media-types = ""
|
||||
server-host = "!4"
|
||||
server-port = 3000
|
||||
server-unix-socket = ""
|
||||
server-unix-socket-mode = "660"
|
||||
admin-server-port = ""
|
||||
@@ -0,0 +1,28 @@
|
||||
db-anon-role = "required"
|
||||
db-channel = "pgrst"
|
||||
db-channel-enabled = true
|
||||
db-extra-search-path = "public"
|
||||
db-max-rows = ""
|
||||
db-pool = 10
|
||||
db-pool-timeout = 10
|
||||
db-pre-request = ""
|
||||
db-prepared-statements = false
|
||||
db-root-spec = ""
|
||||
db-schemas = "required"
|
||||
db-config = false
|
||||
db-tx-end = "commit"
|
||||
db-uri = "required"
|
||||
db-use-legacy-gucs = true
|
||||
jwt-aud = ""
|
||||
jwt-role-claim-key = ".\"role\""
|
||||
jwt-secret = ""
|
||||
jwt-secret-is-base64 = true
|
||||
log-level = "error"
|
||||
openapi-mode = "follow-privileges"
|
||||
openapi-server-proxy-uri = ""
|
||||
raw-media-types = ""
|
||||
server-host = "!4"
|
||||
server-port = 3000
|
||||
server-unix-socket = ""
|
||||
server-unix-socket-mode = "660"
|
||||
admin-server-port = ""
|
||||
@@ -0,0 +1,28 @@
|
||||
db-anon-role = "required"
|
||||
db-channel = "pgrst"
|
||||
db-channel-enabled = true
|
||||
db-extra-search-path = "public"
|
||||
db-max-rows = ""
|
||||
db-pool = 10
|
||||
db-pool-timeout = 10
|
||||
db-pre-request = ""
|
||||
db-prepared-statements = false
|
||||
db-root-spec = ""
|
||||
db-schemas = "required"
|
||||
db-config = false
|
||||
db-tx-end = "commit"
|
||||
db-uri = "required"
|
||||
db-use-legacy-gucs = true
|
||||
jwt-aud = ""
|
||||
jwt-role-claim-key = ".\"role\""
|
||||
jwt-secret = ""
|
||||
jwt-secret-is-base64 = true
|
||||
log-level = "error"
|
||||
openapi-mode = "follow-privileges"
|
||||
openapi-server-proxy-uri = ""
|
||||
raw-media-types = ""
|
||||
server-host = "!4"
|
||||
server-port = 3000
|
||||
server-unix-socket = ""
|
||||
server-unix-socket-mode = "660"
|
||||
admin-server-port = ""
|
||||
@@ -0,0 +1,28 @@
|
||||
db-anon-role = "required"
|
||||
db-channel = "pgrst"
|
||||
db-channel-enabled = true
|
||||
db-extra-search-path = "public"
|
||||
db-max-rows = ""
|
||||
db-pool = 10
|
||||
db-pool-timeout = 10
|
||||
db-pre-request = ""
|
||||
db-prepared-statements = true
|
||||
db-root-spec = ""
|
||||
db-schemas = "required"
|
||||
db-config = false
|
||||
db-tx-end = "commit"
|
||||
db-uri = "required"
|
||||
db-use-legacy-gucs = true
|
||||
jwt-aud = ""
|
||||
jwt-role-claim-key = ".\"role\""
|
||||
jwt-secret = ""
|
||||
jwt-secret-is-base64 = false
|
||||
log-level = "error"
|
||||
openapi-mode = "follow-privileges"
|
||||
openapi-server-proxy-uri = ""
|
||||
raw-media-types = ""
|
||||
server-host = "!4"
|
||||
server-port = 3000
|
||||
server-unix-socket = ""
|
||||
server-unix-socket-mode = "660"
|
||||
admin-server-port = ""
|
||||
@@ -0,0 +1,30 @@
|
||||
db-anon-role = "postgrest_test_anonymous"
|
||||
db-channel = "postgrest"
|
||||
db-channel-enabled = false
|
||||
db-extra-search-path = "public,extensions,other"
|
||||
db-max-rows = 100
|
||||
db-pool = 1
|
||||
db-pool-timeout = 100
|
||||
db-pre-request = "test.other_custom_headers"
|
||||
db-prepared-statements = false
|
||||
db-root-spec = "other_root"
|
||||
db-schemas = "test,other_tenant1,other_tenant2"
|
||||
db-config = true
|
||||
db-tx-end = "rollback-allow-override"
|
||||
db-uri = "<REPLACED_WITH_DB_URI>"
|
||||
db-use-legacy-gucs = false
|
||||
jwt-aud = "https://otherexample.org"
|
||||
jwt-role-claim-key = ".\"other\".\"role\""
|
||||
jwt-secret = "ODERREALLYREALLYREALLYREALLYVERYSAFE"
|
||||
jwt-secret-is-base64 = true
|
||||
log-level = "info"
|
||||
openapi-mode = "disabled"
|
||||
openapi-server-proxy-uri = "https://otherexample.org/api"
|
||||
raw-media-types = "application/vnd.pgrst.other-db-config"
|
||||
server-host = "0.0.0.0"
|
||||
server-port = 80
|
||||
server-unix-socket = "/tmp/pgrst_io_test.sock"
|
||||
server-unix-socket-mode = "777"
|
||||
admin-server-port = 3001
|
||||
app.settings.test = "test"
|
||||
app.settings.test2 = "test"
|
||||
@@ -0,0 +1,30 @@
|
||||
db-anon-role = "postgrest_test_anonymous"
|
||||
db-channel = "postgrest"
|
||||
db-channel-enabled = false
|
||||
db-extra-search-path = "public,extensions,private"
|
||||
db-max-rows = 1000
|
||||
db-pool = 1
|
||||
db-pool-timeout = 100
|
||||
db-pre-request = "test.custom_headers"
|
||||
db-prepared-statements = false
|
||||
db-root-spec = "root"
|
||||
db-schemas = "test,tenant1,tenant2"
|
||||
db-config = true
|
||||
db-tx-end = "commit-allow-override"
|
||||
db-uri = "<REPLACED_WITH_DB_URI>"
|
||||
db-use-legacy-gucs = false
|
||||
jwt-aud = "https://example.org"
|
||||
jwt-role-claim-key = ".\"a\".\"role\""
|
||||
jwt-secret = "OVERRIDEREALLYREALLYREALLYREALLYVERYSAFE"
|
||||
jwt-secret-is-base64 = true
|
||||
log-level = "info"
|
||||
openapi-mode = "ignore-privileges"
|
||||
openapi-server-proxy-uri = "https://example.org/api"
|
||||
raw-media-types = "application/vnd.pgrst.db-config"
|
||||
server-host = "0.0.0.0"
|
||||
server-port = 80
|
||||
server-unix-socket = "/tmp/pgrst_io_test.sock"
|
||||
server-unix-socket-mode = "777"
|
||||
admin-server-port = 3001
|
||||
app.settings.test = "test"
|
||||
app.settings.test2 = "test"
|
||||
@@ -0,0 +1,30 @@
|
||||
db-anon-role = "root"
|
||||
db-channel = "postgrest"
|
||||
db-channel-enabled = false
|
||||
db-extra-search-path = "public,test"
|
||||
db-max-rows = 1000
|
||||
db-pool = 1
|
||||
db-pool-timeout = 100
|
||||
db-pre-request = "please_run_fast"
|
||||
db-prepared-statements = false
|
||||
db-root-spec = "openapi_v3"
|
||||
db-schemas = "multi,tenant,setup"
|
||||
db-config = false
|
||||
db-tx-end = "rollback-allow-override"
|
||||
db-uri = "tmp_db"
|
||||
db-use-legacy-gucs = false
|
||||
jwt-aud = "https://postgrest.org"
|
||||
jwt-role-claim-key = ".\"user\"[0].\"real-role\""
|
||||
jwt-secret = "c2VjdXJpdHl0aHJvdWdob2JzY3VyaXR5"
|
||||
jwt-secret-is-base64 = true
|
||||
log-level = "info"
|
||||
openapi-mode = "ignore-privileges"
|
||||
openapi-server-proxy-uri = "https://postgrest.org"
|
||||
raw-media-types = "application/vnd.pgrst.config"
|
||||
server-host = "0.0.0.0"
|
||||
server-port = 80
|
||||
server-unix-socket = "/tmp/pgrst_io_test.sock"
|
||||
server-unix-socket-mode = "777"
|
||||
admin-server-port = 3001
|
||||
app.settings.test = "test"
|
||||
app.settings.test2 = "test"
|
||||
@@ -0,0 +1,29 @@
|
||||
db-anon-role = "required"
|
||||
db-channel = "pgrst"
|
||||
db-channel-enabled = true
|
||||
db-extra-search-path = "public"
|
||||
db-max-rows = ""
|
||||
db-pool = 10
|
||||
db-pool-timeout = 10
|
||||
db-pre-request = ""
|
||||
db-prepared-statements = true
|
||||
db-root-spec = ""
|
||||
db-schemas = "required"
|
||||
db-config = true
|
||||
db-tx-end = "commit"
|
||||
db-uri = "required"
|
||||
db-use-legacy-gucs = true
|
||||
jwt-aud = ""
|
||||
jwt-role-claim-key = ".\"role\""
|
||||
jwt-secret = ""
|
||||
jwt-secret-is-base64 = false
|
||||
log-level = "error"
|
||||
openapi-mode = "follow-privileges"
|
||||
openapi-server-proxy-uri = ""
|
||||
raw-media-types = ""
|
||||
server-host = "!4"
|
||||
server-port = 3000
|
||||
server-unix-socket = ""
|
||||
server-unix-socket-mode = "660"
|
||||
admin-server-port = ""
|
||||
app.settings.test = "Bool False"
|
||||
@@ -0,0 +1,4 @@
|
||||
yaml:
|
||||
is:
|
||||
not:
|
||||
supported:
|
||||
@@ -0,0 +1,31 @@
|
||||
PGRST_APP_SETTINGS_test2: test
|
||||
PGRST_APP_SETTINGS_test: test
|
||||
PGRST_DB_ANON_ROLE: root
|
||||
PGRST_DB_CHANNEL: postgrest
|
||||
PGRST_DB_CHANNEL_ENABLED: false
|
||||
PGRST_DB_EXTRA_SEARCH_PATH: public, test
|
||||
PGRST_DB_MAX_ROWS: 1000
|
||||
PGRST_DB_POOL: 1
|
||||
PGRST_DB_POOL_TIMEOUT: 100
|
||||
PGRST_DB_PREPARED_STATEMENTS: false
|
||||
PGRST_DB_PRE_REQUEST: please_run_fast
|
||||
PGRST_DB_ROOT_SPEC: openapi_v3
|
||||
PGRST_DB_SCHEMAS: multi, tenant,setup
|
||||
PGRST_DB_CONFIG: false
|
||||
PGRST_DB_TX_END: rollback-allow-override
|
||||
PGRST_DB_URI: tmp_db
|
||||
PGRST_DB_EMBED_DEFAULT_JOIN: inner
|
||||
PGRST_DB_USE_LEGACY_GUCS: false
|
||||
PGRST_JWT_AUD: 'https://postgrest.org'
|
||||
PGRST_JWT_ROLE_CLAIM_KEY: '.user[0]."real-role"'
|
||||
PGRST_JWT_SECRET: c2VjdXJpdHl0aHJvdWdob2JzY3VyaXR5
|
||||
PGRST_JWT_SECRET_IS_BASE64: true
|
||||
PGRST_LOG_LEVEL: info
|
||||
PGRST_OPENAPI_MODE: 'ignore-privileges'
|
||||
PGRST_OPENAPI_SERVER_PROXY_URI: 'https://postgrest.org'
|
||||
PGRST_RAW_MEDIA_TYPES: application/vnd.pgrst.config
|
||||
PGRST_SERVER_HOST: 0.0.0.0
|
||||
PGRST_SERVER_PORT: 80
|
||||
PGRST_SERVER_UNIX_SOCKET: /tmp/pgrst_io_test.sock
|
||||
PGRST_SERVER_UNIX_SOCKET_MODE: 777
|
||||
PGRST_ADMIN_SERVER_PORT: 3001
|
||||
@@ -0,0 +1,30 @@
|
||||
db-anon-role = "root"
|
||||
db-channel = "postgrest"
|
||||
db-channel-enabled = false
|
||||
db-extra-search-path = "public, test"
|
||||
db-max-rows = 1000
|
||||
db-pool = 1
|
||||
db-pool-timeout = 100
|
||||
db-pre-request = "please_run_fast"
|
||||
db-prepared-statements = false
|
||||
db-root-spec = "openapi_v3"
|
||||
db-schemas = "multi, tenant,setup"
|
||||
db-config = false
|
||||
db-tx-end = "rollback-allow-override"
|
||||
db-uri = "tmp_db"
|
||||
db-use-legacy-gucs = false
|
||||
jwt-aud = "https://postgrest.org"
|
||||
jwt-role-claim-key = ".user[0].\"real-role\""
|
||||
jwt-secret = "c2VjdXJpdHl0aHJvdWdob2JzY3VyaXR5"
|
||||
jwt-secret-is-base64 = true
|
||||
log-level = "info"
|
||||
openapi-mode = "ignore-privileges"
|
||||
openapi-server-proxy-uri = "https://postgrest.org"
|
||||
raw-media-types = "application/vnd.pgrst.config"
|
||||
server-host = "0.0.0.0"
|
||||
server-port = 80
|
||||
server-unix-socket = "/tmp/pgrst_io_test.sock"
|
||||
server-unix-socket-mode = "777"
|
||||
admin-server-port = 3001
|
||||
app.settings.test = "test"
|
||||
app.settings.test2 = "test"
|
||||
@@ -0,0 +1,3 @@
|
||||
jwt-role-claim-key = "$(ROLE_CLAIM_KEY)"
|
||||
jwt-secret = "reallyreallyreallyreallyverysafe"
|
||||
db-config = false
|
||||
@@ -0,0 +1,4 @@
|
||||
# Read secret from a file: /dev/stdin (alias for standard input)
|
||||
jwt-secret = "@/dev/stdin"
|
||||
jwt-secret-is-base64 = false
|
||||
db-config = false
|
||||
@@ -0,0 +1,3 @@
|
||||
jwt-secret = "$(JWT_SECRET_FILE)"
|
||||
jwt-secret-is-base64 = false
|
||||
db-config = false
|
||||
@@ -0,0 +1,5 @@
|
||||
db-schemas = "public"
|
||||
|
||||
app.settings.name_var = "John"
|
||||
jwt-secret = "invalidinvalidinvalidinvalidinvalid"
|
||||
db-config = false
|
||||
@@ -0,0 +1,2 @@
|
||||
jwt-secret = "reallyreallyreallyreallyverysafe"
|
||||
db-config = false
|
||||
@@ -0,0 +1,16 @@
|
||||
# tests how config options fall back with invalid types
|
||||
db-anon-role = "required"
|
||||
db-schemas = "required"
|
||||
db-uri = "required"
|
||||
|
||||
# expects string
|
||||
app.settings.test = false
|
||||
|
||||
# expects boolean or string
|
||||
db-channel-enabled = 13
|
||||
|
||||
# expects integer or string
|
||||
db-max-rows = true
|
||||
|
||||
# expects string
|
||||
raw-media-types = true
|
||||
@@ -0,0 +1,63 @@
|
||||
CREATE ROLE db_config_authenticator LOGIN NOINHERIT;
|
||||
|
||||
-- reloadable config options
|
||||
ALTER ROLE db_config_authenticator SET pgrst.jwt_aud = 'https://example.org';
|
||||
ALTER ROLE db_config_authenticator SET pgrst.openapi_server_proxy_uri = 'https://example.org/api';
|
||||
ALTER ROLE db_config_authenticator SET pgrst.raw_media_types = 'application/vnd.pgrst.db-config';
|
||||
ALTER ROLE db_config_authenticator SET pgrst.jwt_secret = 'REALLYREALLYREALLYREALLYVERYSAFE';
|
||||
ALTER ROLE db_config_authenticator SET pgrst.jwt_secret_is_base64 = 'true';
|
||||
ALTER ROLE db_config_authenticator SET pgrst.jwt_role_claim_key = '."a"."role"';
|
||||
ALTER ROLE db_config_authenticator SET pgrst.db_tx_end = 'commit-allow-override';
|
||||
ALTER ROLE db_config_authenticator SET pgrst.db_schemas = 'test, tenant1, tenant2';
|
||||
ALTER ROLE db_config_authenticator SET pgrst.db_root_spec = 'root';
|
||||
ALTER ROLE db_config_authenticator SET pgrst.db_prepared_statements = 'false';
|
||||
ALTER ROLE db_config_authenticator SET pgrst.db_pre_request = 'test.custom_headers';
|
||||
ALTER ROLE db_config_authenticator SET pgrst.db_max_rows = '1000';
|
||||
ALTER ROLE db_config_authenticator SET pgrst.db_extra_search_path = 'public, extensions';
|
||||
|
||||
-- override with database specific setting
|
||||
ALTER ROLE db_config_authenticator IN DATABASE :DBNAME SET pgrst.jwt_secret = 'OVERRIDEREALLYREALLYREALLYREALLYVERYSAFE';
|
||||
ALTER ROLE db_config_authenticator IN DATABASE :DBNAME SET pgrst.db_extra_search_path = 'public, extensions, private';
|
||||
|
||||
-- other database settings that should be ignored
|
||||
CREATE DATABASE other;
|
||||
ALTER ROLE db_config_authenticator IN DATABASE other SET pgrst.db_max_rows = '1111';
|
||||
|
||||
-- non-reloadable configs
|
||||
ALTER ROLE db_config_authenticator SET pgrst.server_host = 'ignored';
|
||||
ALTER ROLE db_config_authenticator SET pgrst.server_port = 'ignored';
|
||||
ALTER ROLE db_config_authenticator SET pgrst.server_unix_socket = 'ignored';
|
||||
ALTER ROLE db_config_authenticator SET pgrst.server_unix_socket_mode = 'ignored';
|
||||
ALTER ROLE db_config_authenticator SET pgrst.log_level = 'ignored';
|
||||
ALTER ROLE db_config_authenticator SET pgrst.db_anon_role = 'ignored';
|
||||
ALTER ROLE db_config_authenticator SET pgrst.db_uri = 'postgresql://ignored';
|
||||
ALTER ROLE db_config_authenticator SET pgrst.db_channel_enabled = 'ignored';
|
||||
ALTER ROLE db_config_authenticator SET pgrst.db_channel = 'ignored';
|
||||
ALTER ROLE db_config_authenticator SET pgrst.db_pool = 'ignored';
|
||||
ALTER ROLE db_config_authenticator SET pgrst.db_pool_timeout = 'ignored';
|
||||
ALTER ROLE db_config_authenticator SET pgrst.db_config = 'ignored';
|
||||
|
||||
-- other authenticator reloadable config options
|
||||
CREATE ROLE other_authenticator LOGIN NOINHERIT;
|
||||
ALTER ROLE other_authenticator SET pgrst.jwt_aud = 'https://otherexample.org';
|
||||
ALTER ROLE other_authenticator SET pgrst.openapi_server_proxy_uri = 'https://otherexample.org/api';
|
||||
ALTER ROLE other_authenticator SET pgrst.raw_media_types = 'application/vnd.pgrst.other-db-config';
|
||||
ALTER ROLE other_authenticator SET pgrst.jwt_secret = 'ODERREALLYREALLYREALLYREALLYVERYSAFE';
|
||||
ALTER ROLE other_authenticator SET pgrst.jwt_secret_is_base64 = 'true';
|
||||
ALTER ROLE other_authenticator SET pgrst.jwt_role_claim_key = '."other"."role"';
|
||||
ALTER ROLE other_authenticator SET pgrst.db_tx_end = 'rollback-allow-override';
|
||||
ALTER ROLE other_authenticator SET pgrst.db_schemas = 'test, other_tenant1, other_tenant2';
|
||||
ALTER ROLE other_authenticator SET pgrst.db_root_spec = 'other_root';
|
||||
ALTER ROLE other_authenticator SET pgrst.db_prepared_statements = 'false';
|
||||
ALTER ROLE other_authenticator SET pgrst.db_pre_request = 'test.other_custom_headers';
|
||||
ALTER ROLE other_authenticator SET pgrst.db_max_rows = '100';
|
||||
ALTER ROLE other_authenticator SET pgrst.db_extra_search_path = 'public, extensions, other';
|
||||
ALTER ROLE other_authenticator SET pgrst.openapi_mode = 'disabled';
|
||||
|
||||
-- limited authenticator used for failed schema cache loads
|
||||
CREATE ROLE limited_authenticator LOGIN NOINHERIT;
|
||||
|
||||
create or replace function no_schema_cache_for_limited_authenticator() returns void as $_$
|
||||
begin
|
||||
ALTER ROLE limited_authenticator SET statement_timeout to 1;
|
||||
end $_$ volatile security definer language plpgsql ;
|
||||
@@ -0,0 +1,81 @@
|
||||
\ir db_config.sql
|
||||
|
||||
CREATE ROLE postgrest_test_anonymous;
|
||||
CREATE ROLE postgrest_test_author;
|
||||
|
||||
GRANT postgrest_test_anonymous, postgrest_test_author TO :USER;
|
||||
|
||||
CREATE SCHEMA v1;
|
||||
GRANT USAGE ON SCHEMA v1 TO postgrest_test_anonymous;
|
||||
|
||||
CREATE TABLE authors_only ();
|
||||
GRANT SELECT ON authors_only TO postgrest_test_author;
|
||||
|
||||
CREATE TABLE projects AS SELECT FROM generate_series(1,5);
|
||||
GRANT SELECT ON projects TO postgrest_test_anonymous;
|
||||
|
||||
create function get_guc_value(name text) returns text as $$
|
||||
select nullif(current_setting(name), '')::text;
|
||||
$$ language sql;
|
||||
|
||||
create function v1.get_guc_value(name text) returns text as $$
|
||||
select nullif(current_setting(name), '')::text;
|
||||
$$ language sql;
|
||||
|
||||
create function uses_prepared_statements() returns bool as $$
|
||||
select count(name) > 0 from pg_catalog.pg_prepared_statements
|
||||
$$ language sql;
|
||||
|
||||
create function change_max_rows_config(val int, notify bool default false) returns void as $_$
|
||||
begin
|
||||
execute format($$
|
||||
alter role postgrest_test_authenticator set pgrst.db_max_rows = %L;
|
||||
$$, val);
|
||||
if notify then
|
||||
perform pg_notify('pgrst', 'reload config');
|
||||
end if;
|
||||
end $_$ volatile security definer language plpgsql ;
|
||||
|
||||
create function reset_max_rows_config() returns void as $_$
|
||||
begin
|
||||
alter role postgrest_test_authenticator reset pgrst.db_max_rows;
|
||||
end $_$ volatile security definer language plpgsql ;
|
||||
|
||||
create function change_db_schema_and_full_reload(schemas text) returns void as $_$
|
||||
begin
|
||||
execute format($$
|
||||
alter role postgrest_test_authenticator set pgrst.db_schemas = %L;
|
||||
$$, schemas);
|
||||
perform pg_notify('pgrst', 'reload config');
|
||||
perform pg_notify('pgrst', 'reload schema');
|
||||
end $_$ volatile security definer language plpgsql ;
|
||||
|
||||
create function v1.reset_db_schema_config() returns void as $_$
|
||||
begin
|
||||
alter role postgrest_test_authenticator reset pgrst.db_schemas;
|
||||
perform pg_notify('pgrst', 'reload config');
|
||||
perform pg_notify('pgrst', 'reload schema');
|
||||
end $_$ volatile security definer language plpgsql ;
|
||||
|
||||
create function invalid_role_claim_key_reload() returns void as $_$
|
||||
begin
|
||||
alter role postgrest_test_authenticator set pgrst.jwt_role_claim_key = 'test';
|
||||
perform pg_notify('pgrst', 'reload config');
|
||||
end $_$ volatile security definer language plpgsql ;
|
||||
|
||||
create function reset_invalid_role_claim_key() returns void as $_$
|
||||
begin
|
||||
alter role postgrest_test_authenticator reset pgrst.jwt_role_claim_key;
|
||||
perform pg_notify('pgrst', 'reload config');
|
||||
end $_$ volatile security definer language plpgsql ;
|
||||
|
||||
create function reload_pgrst_config() returns void as $_$
|
||||
begin
|
||||
perform pg_notify('pgrst', 'reload config');
|
||||
end $_$ language plpgsql ;
|
||||
|
||||
create or replace function raise_bad_pt() returns void as $$
|
||||
begin
|
||||
raise sqlstate 'PT40A' using message = 'Wrong';
|
||||
end;
|
||||
$$ language plpgsql;
|
||||
@@ -0,0 +1,191 @@
|
||||
cli:
|
||||
# success: valid commands
|
||||
- name: help long
|
||||
args: ['--help']
|
||||
- name: help short
|
||||
args: ['-h']
|
||||
- name: example long
|
||||
args: ['--example']
|
||||
- name: example short
|
||||
args: ['-e']
|
||||
- name: dump config
|
||||
args: ['--dump-config']
|
||||
use_defaultenv: true
|
||||
- name: dump schema
|
||||
args: ['--dump-schema']
|
||||
use_defaultenv: true
|
||||
# failures: config files
|
||||
- name: no config
|
||||
expect: error
|
||||
- name: non-existant config file
|
||||
expect: error
|
||||
args: ['does_not_exist.conf']
|
||||
- name: invalid config file
|
||||
expect: error
|
||||
args: ['test/io-tests/configs/invalid.yaml']
|
||||
# failures: required config options
|
||||
- name: missing db-anon-role
|
||||
expect: error
|
||||
env:
|
||||
PGRST_DB_URI: required
|
||||
PGRST_DB_SCHEMAS: required
|
||||
- name: missing db-schemas
|
||||
expect: error
|
||||
env:
|
||||
PGRST_DB_ANON_ROLE: required
|
||||
PGRST_DB_URI: required
|
||||
- name: missing db-uri
|
||||
expect: error
|
||||
env:
|
||||
PGRST_DB_ANON_ROLE: required
|
||||
PGRST_DB_SCHEMAS: required
|
||||
# failures: wrong config values
|
||||
- name: invalid server-unix-socket-mode not octal
|
||||
expect: error
|
||||
use_defaultenv: true
|
||||
env:
|
||||
PGRST_SERVER_UNIX_SOCKET_MODE: '800'
|
||||
- name: invalid server-unix-socket-mode < 600
|
||||
expect: error
|
||||
use_defaultenv: true
|
||||
env:
|
||||
PGRST_SERVER_UNIX_SOCKET_MODE: '599'
|
||||
- name: invalid server-unix-socket-mode > 777
|
||||
expect: error
|
||||
use_defaultenv: true
|
||||
env:
|
||||
PGRST_SERVER_UNIX_SOCKET_MODE: '778'
|
||||
# TODO: Bug needs to be fixed
|
||||
# - name: invalid jwt-aud
|
||||
# expect: error
|
||||
# use_defaultenv: true
|
||||
# env:
|
||||
# PGRST_JWT_AUD: 'htp:/@@localhorst.invalid'
|
||||
- name: invalid log-level
|
||||
expect: error
|
||||
use_defaultenv: true
|
||||
env:
|
||||
PGRST_LOG_LEVEL: never
|
||||
- name: invalid db-tx-end
|
||||
expect: error
|
||||
use_defaultenv: true
|
||||
env:
|
||||
PGRST_DB_TX_END: random
|
||||
- name: invalid openapi-server-proxy-uri
|
||||
expect: error
|
||||
use_defaultenv: true
|
||||
env:
|
||||
PGRST_OPENAPI_SERVER_PROXY_URI: 'htp:/@@localhorst.invalid'
|
||||
- name: invalid jwt-secret not base64
|
||||
expect: error
|
||||
use_defaultenv: true
|
||||
env:
|
||||
PGRST_JWT_SECRET_IS_BASE64: 'true'
|
||||
PGRST_JWT_SECRET: 'no base-64!'
|
||||
# success: parsing config values
|
||||
- name: log-level=
|
||||
expect: 'log-level = "error"'
|
||||
use_defaultenv: true
|
||||
env:
|
||||
PGRST_LOG_LEVEL: ""
|
||||
- name: log-level=crit
|
||||
expect: 'log-level = "crit"'
|
||||
use_defaultenv: true
|
||||
env:
|
||||
PGRST_LOG_LEVEL: crit
|
||||
- name: log-level=error
|
||||
expect: 'log-level = "error"'
|
||||
use_defaultenv: true
|
||||
env:
|
||||
PGRST_LOG_LEVEL: error
|
||||
- name: log-level=warn
|
||||
expect: 'log-level = "warn"'
|
||||
use_defaultenv: true
|
||||
env:
|
||||
PGRST_LOG_LEVEL: warn
|
||||
- name: log-level=info
|
||||
expect: 'log-level = "info"'
|
||||
use_defaultenv: true
|
||||
env:
|
||||
PGRST_LOG_LEVEL: info
|
||||
- name: db-tx-end=
|
||||
expect: 'db-tx-end = "commit"'
|
||||
use_defaultenv: true
|
||||
env:
|
||||
PGRST_DB_TX_END: ""
|
||||
- name: db-tx-end=commit
|
||||
expect: 'db-tx-end = "commit"'
|
||||
use_defaultenv: true
|
||||
env:
|
||||
PGRST_DB_TX_END: commit
|
||||
- name: db-tx-end=commit-allow-override
|
||||
expect: 'db-tx-end = "commit-allow-override"'
|
||||
use_defaultenv: true
|
||||
env:
|
||||
PGRST_DB_TX_END: commit-allow-override
|
||||
- name: db-tx-end=rollback-allow-override
|
||||
expect: 'db-tx-end = "rollback-allow-override"'
|
||||
use_defaultenv: true
|
||||
env:
|
||||
PGRST_DB_TX_END: rollback-allow-override
|
||||
- name: db-tx-end=rollback
|
||||
expect: 'db-tx-end = "rollback"'
|
||||
use_defaultenv: true
|
||||
env:
|
||||
PGRST_DB_TX_END: rollback
|
||||
- name: raw-media-types list
|
||||
expect: 'raw-media-types = "image/png,image/jpeg"'
|
||||
use_defaultenv: true
|
||||
env:
|
||||
PGRST_RAW_MEDIA_TYPES: ' image/png , image/jpeg '
|
||||
|
||||
roleclaims:
|
||||
- key: '.postgrest.a_role'
|
||||
data:
|
||||
postgrest:
|
||||
a_role: postgrest_test_author
|
||||
other: claims
|
||||
expected_status: 200
|
||||
- key: '.customObject.manyRoles[1]'
|
||||
data:
|
||||
customObject:
|
||||
manyRoles:
|
||||
- other
|
||||
- postgrest_test_author
|
||||
other: {}
|
||||
expected_status: 200
|
||||
- key: '."https://www.example.com/roles"[0].value'
|
||||
data:
|
||||
'https://www.example.com/roles':
|
||||
- value: postgrest_test_author
|
||||
other: 666
|
||||
expected_status: 200
|
||||
- key: '.myDomain[3]'
|
||||
data:
|
||||
myDomain:
|
||||
- other
|
||||
- postgrest_test_author
|
||||
other: 1.23
|
||||
expected_status: 401
|
||||
- key: '.myRole'
|
||||
data:
|
||||
role: postgrest_test_author
|
||||
other: true
|
||||
expected_status: 401
|
||||
|
||||
invalidroleclaimkeys:
|
||||
- 'role.other'
|
||||
- '.role##'
|
||||
- '.my_role;;domain'
|
||||
- '.#$$%&$%/'
|
||||
- '1234'
|
||||
|
||||
invalidopenapimodes:
|
||||
- 'follow-'
|
||||
- 'ignore-'
|
||||
- '.#$$%&$%/'
|
||||
|
||||
invalidjointypes:
|
||||
- 'left!'
|
||||
- 'right'
|
||||
- '.#$$%&$%/'
|
||||
@@ -0,0 +1 @@
|
||||
QUJDCkVhc3kgYXMKMTIzCk9yIHNpbXBsZSBhcwpEbyByZSBtaQ==
|
||||
@@ -0,0 +1 @@
|
||||
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIn0.p__cvqgrtteKUhxrJwTwqxeuFmlEm3hEj1mk7yO15M4
|
||||
@@ -0,0 +1,5 @@
|
||||
ABC
|
||||
Easy as
|
||||
123
|
||||
Or simple as
|
||||
Do re mi
|
||||
@@ -0,0 +1,5 @@
|
||||
ABC
|
||||
Easy as
|
||||
123
|
||||
Or simple as
|
||||
Do re mi
|
||||
@@ -0,0 +1 @@
|
||||
RTwSHLM0/PWM2YCOyBiyChMQQamZLTZGrXdzGk61o5A=
|
||||
@@ -0,0 +1,2 @@
|
||||
E<³4üõŒÙ€ŽÈ²
|
||||
A©™-6FwsNµ£�
|
||||
@@ -0,0 +1 @@
|
||||
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIn0.zyXYJKXgVYaWqMYgUXMk4zqXSU5cwA_vK2z9_5lRlqA
|
||||
@@ -0,0 +1,2 @@
|
||||
E<³4üõŒÙ€ŽÈ²
|
||||
A©™-6FwsNµ£�
|
||||
@@ -0,0 +1 @@
|
||||
4pqg77iPIOKaoO+4jiBVbmljb2RlIOKYoO+4jyDimKA=
|
||||
@@ -0,0 +1 @@
|
||||
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIn0.VFfaoJohnDeJMA-awM6Y7blbOjXDNwmtx48Z-0bIKrE
|
||||
@@ -0,0 +1 @@
|
||||
⚠️ ⚠︎ Unicode ☠️ ☠
|
||||
@@ -0,0 +1 @@
|
||||
⚠️ ⚠︎ Unicode ☠️ ☠
|
||||
@@ -0,0 +1 @@
|
||||
QUJDRWFzeUFzT25lVHdvVGhyZWVPclNpbXBsZUFzRG9SZU1p
|
||||
@@ -0,0 +1 @@
|
||||
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIn0.1oyYyosD3_z4YzBE1DB-Pxf7wC0SCuZ2u1zLzPYU_nQ
|
||||
@@ -0,0 +1 @@
|
||||
ABCEasyAsOneTwoThreeOrSimpleAsDoReMi
|
||||
@@ -0,0 +1 @@
|
||||
ABCEasyAsOneTwoThreeOrSimpleAsDoReMi
|
||||
@@ -0,0 +1,873 @@
|
||||
"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 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 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."
|
||||
return os.getenv("PGRST_DB_URI").encode()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def defaultenv():
|
||||
"Default environment for PostgREST."
|
||||
return {
|
||||
"PGRST_DB_URI": os.environ["PGRST_DB_URI"],
|
||||
"PGRST_DB_SCHEMAS": "public",
|
||||
"PGRST_DB_ANON_ROLE": os.environ["PGRST_DB_ANON_ROLE"],
|
||||
"PGRST_DB_CONFIG": "false",
|
||||
"PGRST_LOG_LEVEL": "info",
|
||||
}
|
||||
|
||||
|
||||
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):
|
||||
"Run PostgREST and yield an endpoint that is ready for connections."
|
||||
env = env or {}
|
||||
env["PGRST_DB_POOL"] = "1"
|
||||
env["PGRST_DB_POOL_TIMEOUT"] = "1"
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
if port:
|
||||
env["PGRST_SERVER_PORT"] = str(port)
|
||||
env["PGRST_SERVER_HOST"] = "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:
|
||||
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 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(defaultenv, role, expectedconfig):
|
||||
"Config should be overriden from database settings"
|
||||
|
||||
config = CONFIGSDIR / "no-defaults.config"
|
||||
|
||||
db_uri = defaultenv["PGRST_DB_URI"].replace(
|
||||
"user=postgrest_test_authenticator", f"user={role}"
|
||||
)
|
||||
env = {
|
||||
**defaultenv,
|
||||
"PGRST_DB_URI": db_uri,
|
||||
"PGRST_DB_CONFIG": "true",
|
||||
}
|
||||
expected = (
|
||||
(CONFIGSDIR / "expected" / expectedconfig)
|
||||
.read_text()
|
||||
.replace("<REPLACED_WITH_DB_URI>", env["PGRST_DB_URI"])
|
||||
)
|
||||
|
||||
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):
|
||||
"Connections via a port on localhost should work."
|
||||
with run(env=defaultenv, port=freeport()):
|
||||
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, defaultenv):
|
||||
"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, env=defaultenv) as postgrest:
|
||||
response = postgrest.session.get("/authors_only", headers=headers)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_read_dburi_from_file_without_eol(dburi, defaultenv):
|
||||
"Reading the dburi from a file with a single line should work."
|
||||
config = CONFIGSDIR / "dburi-from-file.config"
|
||||
env = {key: value for key, value in defaultenv.items() if key != "PGRST_DB_URI"}
|
||||
with run(config, env=env, stdin=dburi):
|
||||
pass
|
||||
|
||||
|
||||
def test_read_dburi_from_file_with_eol(dburi, defaultenv):
|
||||
"Reading the dburi from a file containing a newline should work."
|
||||
config = CONFIGSDIR / "dburi-from-file.config"
|
||||
env = {key: value for key, value in defaultenv.items() if key != "PGRST_DB_URI"}
|
||||
with run(config, env=env, stdin=dburi + b"\n"):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"roleclaim", FIXTURES["roleclaims"], ids=lambda claim: claim["key"]
|
||||
)
|
||||
def test_role_claim_key(roleclaim, defaultenv):
|
||||
"Authorization should depend on a correct role-claim-key and JWT claim."
|
||||
env = {
|
||||
**defaultenv,
|
||||
"ROLE_CLAIM_KEY": roleclaim["key"],
|
||||
}
|
||||
headers = jwtauthheader(roleclaim["data"], SECRET)
|
||||
|
||||
with run(CONFIGSDIR / "role-claim-key.config", env=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, defaultenv):
|
||||
"Given an invalid role-claim-key, Postgrest should exit with a non-zero exit code."
|
||||
env = {
|
||||
**defaultenv,
|
||||
"ROLE_CLAIM_KEY": invalidroleclaimkey,
|
||||
}
|
||||
|
||||
with pytest.raises(PostgrestError):
|
||||
dump = dumpconfig(CONFIGSDIR / "role-claim-key.config", 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.
|
||||
|
||||
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", env=defaultenv) as postgrest:
|
||||
for _ in range(10):
|
||||
response = postgrest.session.get("/authors_only", headers=headers)
|
||||
assert response.status_code == 200
|
||||
|
||||
time.sleep(0.1)
|
||||
|
||||
|
||||
def test_app_settings(defaultenv):
|
||||
"""
|
||||
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", env=defaultenv) 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.text == '"0123456789abcdef"'
|
||||
|
||||
|
||||
def test_app_settings_reload(tmp_path, defaultenv):
|
||||
"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, env=defaultenv) as postgrest:
|
||||
response = postgrest.session.get(uri)
|
||||
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.text == '"Jane"'
|
||||
|
||||
|
||||
def test_jwt_secret_reload(tmp_path, defaultenv):
|
||||
"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, env=defaultenv) 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_jwt_secret_external_file_reload(tmp_path, defaultenv):
|
||||
"JWT secret external file should be reloaded when PostgREST is sent a SIGUSR2 or a NOTIFY."
|
||||
config = CONFIGSDIR / "sigusr2-settings-external-secret.config"
|
||||
|
||||
headers = jwtauthheader({"role": "postgrest_test_author"}, SECRET)
|
||||
|
||||
external_secret_file = tmp_path / "jwt-secret-config"
|
||||
external_secret_file.write_text("invalid" * 5)
|
||||
|
||||
env = {
|
||||
**defaultenv,
|
||||
"JWT_SECRET_FILE": f"@{external_secret_file}",
|
||||
"PGRST_DB_CHANNEL_ENABLED": "true",
|
||||
}
|
||||
|
||||
with run(config, env=env) as postgrest:
|
||||
response = postgrest.session.get("/authors_only", headers=headers)
|
||||
assert response.status_code == 401
|
||||
|
||||
# change external file
|
||||
external_secret_file.write_text(SECRET)
|
||||
|
||||
# SIGUSR1 doesn't reload external files
|
||||
postgrest.process.send_signal(signal.SIGUSR1)
|
||||
time.sleep(0.1)
|
||||
|
||||
response = postgrest.session.get("/authors_only", headers=headers)
|
||||
assert response.status_code == 401
|
||||
|
||||
# reload config and external file with SIGUSR2
|
||||
postgrest.process.send_signal(signal.SIGUSR2)
|
||||
time.sleep(0.1)
|
||||
|
||||
response = postgrest.session.get("/authors_only", headers=headers)
|
||||
assert response.status_code == 200
|
||||
|
||||
# change external file to wrong value again
|
||||
external_secret_file.write_text("invalid" * 5)
|
||||
|
||||
# reload config and external file with NOTIFY
|
||||
postgrest.session.post("/rpc/reload_pgrst_config")
|
||||
time.sleep(0.1)
|
||||
|
||||
response = postgrest.session.get("/authors_only", headers=headers)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_db_schema_reload(tmp_path, defaultenv):
|
||||
"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)
|
||||
|
||||
env = {key: value for key, value in defaultenv.items() if key != "PGRST_DB_SCHEMAS"}
|
||||
|
||||
with run(configfile, env=env) as postgrest:
|
||||
response = postgrest.session.get("/rpc/get_guc_value?name=search_path")
|
||||
assert response.text == '"public, public"'
|
||||
|
||||
# change setting
|
||||
configfile.write_text(
|
||||
config.replace('db-schemas = "public"', 'db-schemas = "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("/rpc/get_guc_value?name=search_path")
|
||||
assert response.text == '"v1, public"'
|
||||
|
||||
|
||||
def test_db_schema_notify_reload(defaultenv):
|
||||
"DB schema and config should be reloaded when PostgREST is sent a NOTIFY"
|
||||
|
||||
env = {**defaultenv, "PGRST_DB_CONFIG": "true", "PGRST_DB_CHANNEL_ENABLED": "true"}
|
||||
|
||||
with run(env=env) as postgrest:
|
||||
response = postgrest.session.get("/rpc/get_guc_value?name=search_path")
|
||||
assert response.text == '"public, public"'
|
||||
|
||||
# change db-schemas config on the db and reload config and cache with notify
|
||||
postgrest.session.post(
|
||||
"/rpc/change_db_schema_and_full_reload", data={"schemas": "v1"}
|
||||
)
|
||||
|
||||
time.sleep(0.1)
|
||||
|
||||
response = postgrest.session.get("/rpc/get_guc_value?name=search_path")
|
||||
assert response.text == '"v1, public"'
|
||||
|
||||
# reset db-schemas config on the db
|
||||
response = postgrest.session.post("/rpc/reset_db_schema_config")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_max_rows_reload(defaultenv):
|
||||
"max-rows should be reloaded from role settings when PostgREST receives a SIGUSR2."
|
||||
config = CONFIGSDIR / "sigusr2-settings.config"
|
||||
|
||||
env = {
|
||||
**defaultenv,
|
||||
"PGRST_DB_CONFIG": "true",
|
||||
}
|
||||
|
||||
with run(config, env=env) as postgrest:
|
||||
response = postgrest.session.head("/projects")
|
||||
assert response.status_code == 200
|
||||
assert response.headers["Content-Range"] == "0-4/*"
|
||||
|
||||
# change max-rows config on the db
|
||||
postgrest.session.post("/rpc/change_max_rows_config", data={"val": 1})
|
||||
|
||||
# reload config
|
||||
postgrest.process.send_signal(signal.SIGUSR2)
|
||||
|
||||
time.sleep(0.1)
|
||||
|
||||
response = postgrest.session.head("/projects")
|
||||
assert response.status_code == 200
|
||||
assert response.headers["Content-Range"] == "0-0/*"
|
||||
|
||||
# reset max-rows config on the db
|
||||
response = postgrest.session.post("/rpc/reset_max_rows_config")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_max_rows_notify_reload(defaultenv):
|
||||
"max-rows should be reloaded from role settings when PostgREST receives a NOTIFY"
|
||||
|
||||
env = {
|
||||
**defaultenv,
|
||||
"PGRST_DB_CONFIG": "true",
|
||||
"PGRST_DB_CHANNEL_ENABLED": "true",
|
||||
}
|
||||
|
||||
with run(env=env) as postgrest:
|
||||
response = postgrest.session.head("/projects")
|
||||
assert response.status_code == 200
|
||||
assert response.headers["Content-Range"] == "0-4/*"
|
||||
|
||||
# change max-rows config on the db and reload with notify
|
||||
postgrest.session.post(
|
||||
"/rpc/change_max_rows_config", data={"val": 1, "notify": True}
|
||||
)
|
||||
|
||||
time.sleep(0.1)
|
||||
|
||||
response = postgrest.session.head("/projects")
|
||||
assert response.status_code == 200
|
||||
assert response.headers["Content-Range"] == "0-0/*"
|
||||
|
||||
# reset max-rows config on the db
|
||||
response = postgrest.session.post("/rpc/reset_max_rows_config")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_invalid_role_claim_key_notify_reload(defaultenv):
|
||||
"NOTIFY reload config should show an error if role-claim-key is invalid"
|
||||
|
||||
env = {
|
||||
**defaultenv,
|
||||
"PGRST_DB_CONFIG": "true",
|
||||
"PGRST_DB_CHANNEL_ENABLED": "true",
|
||||
"PGRST_LOG_LEVEL": "crit",
|
||||
}
|
||||
|
||||
with run(env=env) as postgrest:
|
||||
postgrest.session.post("/rpc/invalid_role_claim_key_reload")
|
||||
|
||||
output = None
|
||||
for _ in range(10):
|
||||
output = postgrest.process.stdout.readline()
|
||||
if output:
|
||||
break
|
||||
time.sleep(0.1)
|
||||
|
||||
assert "failed to parse role-claim-key value" in output.decode()
|
||||
|
||||
response = postgrest.session.post("/rpc/reset_invalid_role_claim_key")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_db_prepared_statements_enable(defaultenv):
|
||||
"Should use prepared statements when the setting is enabled."
|
||||
|
||||
with run(env=defaultenv) as postgrest:
|
||||
response = postgrest.session.post("/rpc/uses_prepared_statements")
|
||||
assert response.text == "true"
|
||||
|
||||
|
||||
def test_db_prepared_statements_disable(defaultenv):
|
||||
"Should not use any prepared statements when the setting is disabled."
|
||||
|
||||
env = {
|
||||
**defaultenv,
|
||||
"PGRST_DB_PREPARED_STATEMENTS": "false",
|
||||
}
|
||||
|
||||
with run(env=env) as postgrest:
|
||||
response = postgrest.session.post("/rpc/uses_prepared_statements")
|
||||
assert response.text == "false"
|
||||
|
||||
|
||||
def test_admin_ready_w_channel(defaultenv):
|
||||
"Should get a success response from the admin server ready endpoint when the LISTEN channel is enabled"
|
||||
|
||||
env = {
|
||||
**defaultenv,
|
||||
"PGRST_DB_CHANNEL_ENABLED": "true",
|
||||
}
|
||||
|
||||
with run(env=env) as postgrest:
|
||||
response = postgrest.admin.get("/ready")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_admin_ready_wo_channel(defaultenv):
|
||||
"Should get a success response from the admin server ready endpoint when the LISTEN channel is disabled"
|
||||
|
||||
env = {
|
||||
**defaultenv,
|
||||
"PGRST_DB_CHANNEL_ENABLED": "false",
|
||||
}
|
||||
|
||||
with run(env=env) as postgrest:
|
||||
response = postgrest.admin.get("/ready")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_admin_ready_includes_schema_cache_state(defaultenv):
|
||||
"Should get a failed response from the admin server ready endpoint when the schema cache is not loaded"
|
||||
|
||||
db_uri = defaultenv["PGRST_DB_URI"].replace(
|
||||
"postgrest_test_authenticator", "limited_authenticator"
|
||||
)
|
||||
env = {
|
||||
**defaultenv,
|
||||
"PGRST_DB_URI": db_uri,
|
||||
"PGRST_DB_ANON_ROLE": "limited_authenticator",
|
||||
}
|
||||
|
||||
with run(env=env) as postgrest:
|
||||
|
||||
# make it impossible to load the schema cache
|
||||
response = postgrest.session.post(
|
||||
"/rpc/no_schema_cache_for_limited_authenticator"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
# force a reconnection so the new role setting is picked up
|
||||
postgrest.process.send_signal(signal.SIGUSR1)
|
||||
time.sleep(0.1)
|
||||
|
||||
response = postgrest.admin.get("/ready")
|
||||
assert response.status_code == 503
|
||||
|
||||
response = postgrest.session.get("/projects")
|
||||
assert response.status_code == 503
|
||||
|
||||
|
||||
def test_admin_not_found(defaultenv):
|
||||
"Should get a not found from a undefined endpoint on the admin server"
|
||||
|
||||
with run(env=defaultenv) as postgrest:
|
||||
response = postgrest.admin.get("/notfound")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_admin_ready_dependent_on_main_app(defaultenv):
|
||||
"Should get a failure from the admin ready endpoint if the main app also fails"
|
||||
|
||||
with run(env=defaultenv) as postgrest:
|
||||
# delete the unix socket to make the main app fail
|
||||
os.remove(defaultenv["PGRST_SERVER_UNIX_SOCKET"])
|
||||
response = postgrest.admin.get("/ready")
|
||||
assert response.status_code == 503
|
||||
|
||||
|
||||
def test_admin_live_good(defaultenv):
|
||||
"Should get a success from the admin live endpoint if the main app is running"
|
||||
|
||||
with run(env=defaultenv, port=freeport()) as postgrest:
|
||||
response = postgrest.admin.get("/live")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_admin_live_dependent_on_main_app(defaultenv):
|
||||
"Should get a failure from the admin live endpoint if the main app also fails"
|
||||
|
||||
with run(env=defaultenv) as postgrest:
|
||||
# delete the unix socket to make the main app fail
|
||||
os.remove(defaultenv["PGRST_SERVER_UNIX_SOCKET"])
|
||||
response = postgrest.admin.get("/live")
|
||||
assert response.status_code == 503
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"level, has_output",
|
||||
[
|
||||
("info", [True, True, True]),
|
||||
("warn", [False, True, True]),
|
||||
("error", [False, False, True]),
|
||||
("crit", [False, False, False]),
|
||||
],
|
||||
)
|
||||
def test_log_level(level, has_output, defaultenv):
|
||||
"log_level should filter request logging"
|
||||
|
||||
env = {**defaultenv, "PGRST_LOG_LEVEL": level}
|
||||
|
||||
with run(env=env) as postgrest:
|
||||
response = postgrest.session.get("/")
|
||||
assert response.status_code == 200
|
||||
if has_output[0]:
|
||||
assert re.match(
|
||||
r'unknownSocket - - \[.+\] "GET / HTTP/1.1" 200 - "" "python-requests/.+"',
|
||||
postgrest.process.stdout.readline().decode(),
|
||||
)
|
||||
|
||||
response = postgrest.session.get("/unknown")
|
||||
assert response.status_code == 404
|
||||
if has_output[1]:
|
||||
assert re.match(
|
||||
r'unknownSocket - - \[.+\] "GET /unknown HTTP/1.1" 404 - "" "python-requests/.+"',
|
||||
postgrest.process.stdout.readline().decode(),
|
||||
)
|
||||
|
||||
response = postgrest.session.get("/rpc/raise_bad_pt")
|
||||
assert response.status_code == 500
|
||||
if has_output[2]:
|
||||
assert re.match(
|
||||
r'unknownSocket - - \[.+\] "GET /rpc/raise_bad_pt HTTP/1.1" 500 - "" "python-requests/.+"',
|
||||
postgrest.process.stdout.readline().decode(),
|
||||
)
|
||||
Reference in New Issue
Block a user