diff --git a/src/govoplan_core/devserver.py b/src/govoplan_core/devserver.py index f3c41d4..4c33b67 100644 --- a/src/govoplan_core/devserver.py +++ b/src/govoplan_core/devserver.py @@ -10,6 +10,9 @@ from collections.abc import Iterable, Sequence from pathlib import Path from typing import Any +from sqlalchemy.engine import make_url +from sqlalchemy.exc import ArgumentError + from govoplan_core.core.discovery import iter_module_entry_points from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids from govoplan_core.core.modules import ModuleManifest @@ -112,6 +115,14 @@ def validate_sqlite_database_url(database_url: str) -> None: ) +def redacted_database_url(database_url: str) -> str: + try: + return make_url(database_url).render_as_string(hide_password=True) + except (ArgumentError, TypeError, ValueError): + scheme = database_url.partition(":")[0].strip() + return f"{scheme}:" if scheme else "" + + def _env_truthy(value: str | None) -> bool: return value is not None and value.strip().lower() in {"1", "true", "yes", "on"} @@ -297,11 +308,11 @@ def print_devserver_summary(state: DevserverState, *, app: str, no_reload: bool) print(f"Config: {state.config_path or DEFAULT_CONFIG}") print(f"Runtime root: {state.runtime_root}") if state.database_url: - print(f"Database: {state.database_url}") + print(f"Database: {redacted_database_url(state.database_url)}") if state.database_url.startswith("postgresql"): pgtools_url = os.getenv("GOVOPLAN_DATABASE_URL_PGTOOLS") if pgtools_url: - print(f"PostgreSQL tools URL: {pgtools_url}") + print(f"PostgreSQL tools URL: {redacted_database_url(pgtools_url)}") if state.bootstrap_db_path is not None: bootstrap_state = "enabled" if getattr(state.config.settings, "dev_bootstrap_enabled", False) else "disabled by DEV_BOOTSTRAP_ENABLED" print(f"Dev bootstrap for missing SQLite DB: {bootstrap_state} ({state.bootstrap_db_path})") diff --git a/tests/test_devserver.py b/tests/test_devserver.py index 07100b6..a2d4fc3 100644 --- a/tests/test_devserver.py +++ b/tests/test_devserver.py @@ -7,8 +7,22 @@ import tempfile import unittest from pathlib import Path +from govoplan_core.devserver import redacted_database_url + class DevserverSmokeTests(unittest.TestCase): + def test_database_url_redaction_hides_passwords(self) -> None: + rendered = redacted_database_url( + "postgresql+psycopg://govoplan:database-secret@db.example.test/govoplan" + ) + + self.assertEqual( + rendered, + "postgresql+psycopg://govoplan:***@db.example.test/govoplan", + ) + self.assertNotIn("database-secret", rendered) + self.assertEqual(redacted_database_url("://database-secret"), "") + def test_smoke_mode_bootstraps_missing_local_sqlite_database(self) -> None: repo_root = Path(__file__).resolve().parents[1] src_root = repo_root / "src"