Skip to content

Commit 6c5d227

Browse files
authored
fix: url quote passwords (#549)
**Changes** Updated DbContainer to fix #547 by using `urllib.parse.quote`. I referenced sqlalchemy's implementation, but have not imported the library. I have chosen to make this behaviour occur at all times (can't opt in / out), as it is common, if not the standard for these urls. **Tests** Since DbContainer can't be tested on its own, I put the tests across various database containers. I have pasted the below as comment in the test files for the listed modules: ```python # This is a feature in the generic DbContainer class # but it can't be tested on its own # so is tested in various database modules: # - mysql / mariadb # - postgresql # - sqlserver # - mongodb ``` Note the discussion recommended me to test with oracle, but I was unable to spin the container up locally (even with colima), so opted to replace it with mongodb. Is there a template for PRs for the core library? I am unable to find one so have opted the above format. Please let me know if I have missed anything in this PR. Thanks!
1 parent 056e48d commit 6c5d227

File tree

5 files changed

+122
-1
lines changed

5 files changed

+122
-1
lines changed

core/testcontainers/core/generic.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
# License for the specific language governing permissions and limitations
1212
# under the License.
1313
from typing import Optional
14+
from urllib.parse import quote
1415

1516
from testcontainers.core.container import DockerContainer
1617
from testcontainers.core.exceptions import ContainerStartException
@@ -60,7 +61,8 @@ def _create_connection_url(
6061
raise ContainerStartException("container has not been started")
6162
host = host or self.get_container_host_ip()
6263
port = self.get_exposed_port(port)
63-
url = f"{dialect}://{username}:{password}@{host}:{port}"
64+
quoted_password = quote(password, safe=" +")
65+
url = f"{dialect}://{username}:{quoted_password}@{host}:{port}"
6466
if dbname:
6567
url = f"{url}/{dbname}"
6668
return url

modules/mongodb/tests/test_mongodb.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,27 @@ def test_docker_run_mongodb(version: str):
2626

2727
cursor = db.restaurants.find({"borough": "Manhattan"})
2828
assert cursor.next()["restaurant_id"] == doc["restaurant_id"]
29+
30+
31+
# This is a feature in the generic DbContainer class
32+
# but it can't be tested on its own
33+
# so is tested in various database modules:
34+
# - mysql / mariadb
35+
# - postgresql
36+
# - sqlserver
37+
# - mongodb
38+
def test_quoted_password():
39+
user = "root"
40+
password = "p@$%25+0&%rd :/!=?"
41+
quoted_password = "p%40%24%2525+0%26%25rd %3A%2F%21%3D%3F"
42+
# driver = "pymongo"
43+
kwargs = {
44+
"username": user,
45+
"password": password,
46+
}
47+
with MongoDbContainer("mongo:7.0.7", **kwargs) as container:
48+
host = container.get_container_host_ip()
49+
port = container.get_exposed_port(27017)
50+
expected_url = f"mongodb://{user}:{quoted_password}@{host}:{port}"
51+
url = container.get_connection_url()
52+
assert url == expected_url

modules/mssql/tests/test_mssql.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,35 @@ def test_docker_run_azure_sql_edge():
2323
result = connection.execute(sqlalchemy.text("select @@servicename"))
2424
for row in result:
2525
assert row[0] == "MSSQLSERVER"
26+
27+
28+
# This is a feature in the generic DbContainer class
29+
# but it can't be tested on its own
30+
# so is tested in various database modules:
31+
# - mysql / mariadb
32+
# - postgresql
33+
# - sqlserver
34+
# - mongodb
35+
def test_quoted_password():
36+
user = "SA"
37+
# spaces seem to cause issues?
38+
password = "p@$%25+0&%rd:/!=?"
39+
quoted_password = "p%40%24%2525+0%26%25rd%3A%2F%21%3D%3F"
40+
driver = "pymssql"
41+
port = 1433
42+
expected_url = f"mssql+{driver}://{user}:{quoted_password}@localhost:{port}/tempdb"
43+
kwargs = {
44+
"username": user,
45+
"password": password,
46+
}
47+
with (
48+
SqlServerContainer("mcr.microsoft.com/azure-sql-edge:1.0.7", **kwargs)
49+
.with_env("ACCEPT_EULA", "Y")
50+
.with_env(
51+
"MSSQL_SA_PASSWORD", "{" + password + "}"
52+
) # special characters have to be quoted in braces in env vars
53+
) as container:
54+
exposed_port = container.get_exposed_port(container.port)
55+
expected_url = expected_url.replace(f":{port}", f":{exposed_port}")
56+
url = container.get_connection_url()
57+
assert url == expected_url

modules/mysql/tests/test_mysql.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,31 @@ def test_docker_env_variables():
4747
url = container.get_connection_url()
4848
pattern = r"mysql\+pymysql:\/\/demo:test@[\w,.]+:(3306|32785)\/custom_db"
4949
assert re.match(pattern, url)
50+
51+
52+
# This is a feature in the generic DbContainer class
53+
# but it can't be tested on its own
54+
# so is tested in various database modules:
55+
# - mysql / mariadb
56+
# - postgresql
57+
# - sqlserver
58+
# - mongodb
59+
def test_quoted_password():
60+
user = "root"
61+
password = "p@$%25+0&%rd :/!=?"
62+
quoted_password = "p%40%24%2525+0%26%25rd %3A%2F%21%3D%3F"
63+
driver = "pymysql"
64+
with MySqlContainer("mariadb:10.6.5", username=user, password=password) as container:
65+
host = container.get_container_host_ip()
66+
port = container.get_exposed_port(3306)
67+
expected_url = f"mysql+{driver}://{user}:{quoted_password}@{host}:{port}/test"
68+
url = container.get_connection_url()
69+
assert url == expected_url
70+
71+
with sqlalchemy.create_engine(expected_url).begin() as connection:
72+
connection.execute(sqlalchemy.text("select version()"))
73+
74+
raw_pass_url = f"mysql+{driver}://{user}:{password}@{host}:{port}/test"
75+
with pytest.raises(Exception):
76+
with sqlalchemy.create_engine(raw_pass_url).begin() as connection:
77+
connection.execute(sqlalchemy.text("select version()"))

modules/postgres/tests/test_postgres.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,38 @@ def test_docker_run_postgres_with_driver_pg8000():
4242
engine = sqlalchemy.create_engine(postgres.get_connection_url())
4343
with engine.begin() as connection:
4444
connection.execute(sqlalchemy.text("select 1=1"))
45+
46+
47+
# This is a feature in the generic DbContainer class
48+
# but it can't be tested on its own
49+
# so is tested in various database modules:
50+
# - mysql / mariadb
51+
# - postgresql
52+
# - sqlserver
53+
# - mongodb
54+
def test_quoted_password():
55+
user = "root"
56+
password = "p@$%25+0&%rd :/!=?"
57+
quoted_password = "p%40%24%2525+0%26%25rd %3A%2F%21%3D%3F"
58+
driver = "psycopg2"
59+
kwargs = {
60+
"driver": driver,
61+
"username": user,
62+
"password": password,
63+
}
64+
with PostgresContainer("postgres:16-alpine", **kwargs) as container:
65+
port = container.get_exposed_port(5432)
66+
host = container.get_container_host_ip()
67+
expected_url = f"postgresql+{driver}://{user}:{quoted_password}@{host}:{port}/test"
68+
69+
url = container.get_connection_url()
70+
assert url == expected_url
71+
72+
with sqlalchemy.create_engine(expected_url).begin() as connection:
73+
connection.execute(sqlalchemy.text("select 1=1"))
74+
75+
raw_pass_url = f"postgresql+{driver}://{user}:{password}@{host}:{port}/test"
76+
with pytest.raises(Exception):
77+
# it raises ValueError, but auth (OperationalError) = more interesting
78+
with sqlalchemy.create_engine(raw_pass_url).begin() as connection:
79+
connection.execute(sqlalchemy.text("select 1=1"))

0 commit comments

Comments
 (0)