diff --git a/README.md b/README.md index 482b7ae..c9b9c49 100644 --- a/README.md +++ b/README.md @@ -165,8 +165,13 @@ Current runtime ownership is intentionally narrow and explicit: Restored databases that undergo sanitization get an active dummy outgoing server, following Odoo's neutralization behavior, so even configured SMTP fallback cannot send copied customer mail. Copied SMTP usernames/passwords are - cleared. Fresh bootstrap does not insert that dummy server, so an empty new - database can use the operator's explicitly supplied mail configuration. + cleared. Non-production instances (anything other than `prod`/`production`, + including an empty `PLATFORM_INSTANCE`) get the same dummy server on + bootstrap, every post-deploy maintenance run, and every restore, even with + `--no-sanitize`, so testing and preview lanes cannot send mail whatever SMTP + settings they receive. Production bootstrap does not insert the dummy server, + so a new production database can use the operator's supplied mail + configuration. - Restores onto a non-production instance also clear the copy's production integration credentials and signing keys (Shopify, PrintNode, map and media tokens, web push keys and devices, `database.secret`), and the copy's diff --git a/docker/scripts/run_odoo_data_workflows.py b/docker/scripts/run_odoo_data_workflows.py index b15100f..f43c27a 100644 --- a/docker/scripts/run_odoo_data_workflows.py +++ b/docker/scripts/run_odoo_data_workflows.py @@ -977,15 +977,11 @@ def sanitize_database(self, *, block_smtp_fallback: bool = True) -> None: sql_calls.append(SqlCall("ir.cron", KeyValuePair("active", False))) _logger.info("Sanitizing database...") - # An active dummy server also blocks Odoo's config/CLI SMTP fallback. - # Match Odoo's neutralization behavior and remove copied credentials. with self.connect_to_db().cursor() as cursor: - cursor.execute("UPDATE ir_mail_server SET active = false, smtp_user = NULL, smtp_pass = NULL") if block_smtp_fallback: - cursor.execute( - "INSERT INTO ir_mail_server (name, smtp_port, smtp_host, smtp_encryption, active, smtp_authentication) " - "VALUES ('neutralization - disable emails', 1025, 'invalid', 'none', true, 'login')" - ) + self._block_outgoing_mail(cursor) + else: + cursor.execute("UPDATE ir_mail_server SET active = false, smtp_user = NULL, smtp_pass = NULL") # noinspection PyUnresolvedReferences # call_odoo_sql exists on this class; PyCharm false positive. call_odoo_sql = self.call_odoo_sql for sql_call in sql_calls: @@ -999,6 +995,31 @@ def sanitize_database(self, *, block_smtp_fallback: bool = True) -> None: errors = "\n".join(f"- {cron[7]} (id: {cron[0]})" for cron in active_crons) raise OdooDatabaseUpdateError(f"Error: The following cron jobs are still active after sanitization:\n{errors}") + @staticmethod + def _block_outgoing_mail(cursor: Any) -> None: + # An active dummy server also blocks Odoo's config/CLI SMTP fallback. + # Match Odoo's neutralization behavior and remove copied credentials. + cursor.execute( + "UPDATE ir_mail_server SET active = false, smtp_user = NULL, smtp_pass = NULL " + "WHERE name <> 'neutralization - disable emails'" + ) + cursor.execute("UPDATE ir_mail_server SET active = true WHERE name = 'neutralization - disable emails'") + if cursor.rowcount == 0: + cursor.execute( + "INSERT INTO ir_mail_server (name, smtp_port, smtp_host, smtp_encryption, active, smtp_authentication) " + "VALUES ('neutralization - disable emails', 1025, 'invalid', 'none', true, 'login')" + ) + + def block_outgoing_mail_outside_production(self) -> None: + """Keep non-production lanes from sending mail, whatever SMTP settings they were given.""" + if self._is_production_instance(): + return + connection_ = self.connect_to_db() + with connection_.cursor() as cursor: + self._block_outgoing_mail(cursor) + connection_.commit() + _logger.info("Blocked outgoing mail on non-production instance '%s'.", self.local.platform_instance) + def _is_production_instance(self) -> bool: return self.local.platform_instance.strip().lower() in PRODUCTION_INSTANCE_NAMES @@ -1739,6 +1760,7 @@ def run_bootstrap(self, *, do_sanitize: bool) -> None: self.drop_database() raise + self.block_outgoing_mail_outside_production() self.ensure_admin_user() self.connect_to_db() self.assert_core_schema_healthy() @@ -1753,6 +1775,7 @@ def run_post_deploy_maintenance(self) -> None: self.reconcile_missing_manifest_install_queue() self.assert_install_queue_is_resolvable() self.apply_environment_overrides() + self.block_outgoing_mail_outside_production() self.ensure_admin_user() self.connect_to_db() self.assert_core_schema_healthy() @@ -2390,6 +2413,7 @@ def _restore_from_verified_dump(self, backup_path: Path, *, do_sanitize: bool) - # Clear credentials before any Odoo code (OpenUpgrade, install hooks) runs against the copy. self.fingerprint_restored_credentials() self.neutralize_production_credentials() + self.block_outgoing_mail_outside_production() filestore_waited = True filestore_returncode = filestore_process.wait() if filestore_returncode != 0: @@ -2442,6 +2466,7 @@ def _prepare_restored_database(self, target_owner: str | None, *, do_sanitize: b self.neutralize_production_credentials() self.verify_production_credentials_cleared() self.apply_environment_overrides() + self.block_outgoing_mail_outside_production() if __name__ == "__main__": # pragma: no cover diff --git a/tests/test_odoo_data_workflows.py b/tests/test_odoo_data_workflows.py index 161d88f..da23603 100644 --- a/tests/test_odoo_data_workflows.py +++ b/tests/test_odoo_data_workflows.py @@ -290,38 +290,54 @@ def test_failed_pg_restore_exits_non_zero_and_does_not_claim_the_target_is_intac class OdooDataWorkflowShellEnvironmentTests(unittest.TestCase): - def test_bootstrap_allows_configured_mail_but_sanitized_restores_block_it(self) -> None: - with closing(sqlite3.connect(":memory:")) as database: - database.execute( - "CREATE TABLE ir_mail_server (name TEXT, smtp_port INTEGER, smtp_host TEXT, smtp_encryption TEXT, " - "active BOOLEAN, smtp_authentication TEXT, smtp_user TEXT, smtp_pass TEXT)" - ) - runner = odoo_data_workflows.OdooDataWorkflowRunner(self._local_settings(), upstream=None, env_file=None) - runner.local.db_conn = types.SimpleNamespace(cursor=lambda: closing(database.cursor()), commit=database.commit) - with patch.multiple( - runner, - _resolve_filestore_owner=MagicMock(return_value=None), - database_exists=MagicMock(return_value=False), - _clean_filestore=MagicMock(), - normalize_filestore_permissions=MagicMock(), - create_database=MagicMock(), - _reset_db_connection=MagicMock(), - needs_base_install=MagicMock(return_value=False), - install_addons=MagicMock(), - update_addons=MagicMock(), - call_odoo_sql=MagicMock(return_value=[]), - assert_install_queue_is_resolvable=MagicMock(), - apply_environment_overrides=MagicMock(), - ensure_admin_user=MagicMock(), - assert_core_schema_healthy=MagicMock(), - ensure_gpt_users=MagicMock(), - ): - runner.run_bootstrap(do_sanitize=True) + @staticmethod + def _mail_database() -> sqlite3.Connection: + database = sqlite3.connect(":memory:") + database.execute( + "CREATE TABLE ir_mail_server (name TEXT, smtp_port INTEGER, smtp_host TEXT, smtp_encryption TEXT, " + "active BOOLEAN, smtp_authentication TEXT, smtp_user TEXT, smtp_pass TEXT)" + ) + return database + + @staticmethod + def _add_production_mail_server(database: sqlite3.Connection) -> None: + database.execute( + "INSERT INTO ir_mail_server VALUES ('Production', 587, 'smtp.example.test', 'starttls', true, 'login', " + "'mailbox@example.test', 'copied-secret')" + ) + + def _mail_runner(self, database: sqlite3.Connection, platform_instance: str) -> Any: + runner = odoo_data_workflows.OdooDataWorkflowRunner(self._local_settings(platform_instance), upstream=None, env_file=None) + runner.local.db_conn = types.SimpleNamespace(cursor=lambda: closing(database.cursor()), commit=database.commit) + return runner + + def _run_bootstrap(self, runner: Any) -> None: + with patch.multiple( + runner, + _resolve_filestore_owner=MagicMock(return_value=None), + database_exists=MagicMock(return_value=False), + _clean_filestore=MagicMock(), + normalize_filestore_permissions=MagicMock(), + create_database=MagicMock(), + _reset_db_connection=MagicMock(), + needs_base_install=MagicMock(return_value=False), + install_addons=MagicMock(), + update_addons=MagicMock(), + call_odoo_sql=MagicMock(return_value=[]), + assert_install_queue_is_resolvable=MagicMock(), + apply_environment_overrides=MagicMock(), + ensure_admin_user=MagicMock(), + assert_core_schema_healthy=MagicMock(), + ensure_gpt_users=MagicMock(), + ): + runner.run_bootstrap(do_sanitize=True) + + def test_production_bootstrap_allows_configured_mail_but_sanitized_restores_block_it(self) -> None: + with closing(self._mail_database()) as database: + runner = self._mail_runner(database, "prod") + self._run_bootstrap(runner) self.assertEqual(database.execute("SELECT count(*) FROM ir_mail_server WHERE active = true").fetchone()[0], 0) - database.execute( - "INSERT INTO ir_mail_server VALUES ('Production', 587, 'smtp.example.test', 'starttls', true, 'login', " - "'mailbox@example.test', 'copied-secret')" - ) + self._add_production_mail_server(database) with patch.object(runner, "call_odoo_sql", return_value=[]): runner.sanitize_database() runner.sanitize_database() @@ -336,9 +352,41 @@ def test_bootstrap_allows_configured_mail_but_sanitized_restores_block_it(self) 0, ) + def test_non_production_bootstrap_blocks_configured_mail(self) -> None: + with closing(self._mail_database()) as database: + self._run_bootstrap(self._mail_runner(database, "testing")) + self.assertEqual( + database.execute("SELECT smtp_host, smtp_port FROM ir_mail_server WHERE active = true").fetchall(), + [("invalid", 1025)], + ) + + def test_non_production_mail_block_is_repeatable_and_clears_credentials(self) -> None: + with closing(self._mail_database()) as database: + self._add_production_mail_server(database) + runner = self._mail_runner(database, "testing") + with patch.object(runner, "connect_to_db", return_value=runner.local.db_conn): + runner.block_outgoing_mail_outside_production() + runner.block_outgoing_mail_outside_production() + self.assertEqual( + database.execute("SELECT name, active, smtp_user, smtp_pass FROM ir_mail_server ORDER BY name").fetchall(), + [("Production", 0, None, None), ("neutralization - disable emails", 1, None, None)], + ) + + def test_production_mail_is_left_alone(self) -> None: + with closing(self._mail_database()) as database: + self._add_production_mail_server(database) + runner = self._mail_runner(database, "prod") + with patch.object(runner, "connect_to_db", return_value=runner.local.db_conn): + runner.block_outgoing_mail_outside_production() + self.assertEqual( + database.execute("SELECT name, active, smtp_user FROM ir_mail_server").fetchall(), + [("Production", 1, "mailbox@example.test")], + ) + @staticmethod - def _local_settings() -> object: + def _local_settings(platform_instance: str = "") -> object: return odoo_data_workflows.LocalServerSettings( + PLATFORM_INSTANCE=platform_instance, ODOO_DB_HOST="database", ODOO_DB_PORT="5432", ODOO_DB_USER="odoo", @@ -382,6 +430,11 @@ def test_post_deploy_maintenance_runs_overrides_and_service_user_provisioning(se "apply_environment_overrides", side_effect=lambda: calls.append("apply_environment_overrides"), ), + patch.object( + runner, + "block_outgoing_mail_outside_production", + side_effect=lambda: calls.append("block_outgoing_mail_outside_production"), + ), patch.object(runner, "ensure_admin_user", side_effect=lambda: calls.append("ensure_admin_user")), patch.object( runner, @@ -402,6 +455,7 @@ def test_post_deploy_maintenance_runs_overrides_and_service_user_provisioning(se "reconcile_missing_manifest_install_queue", "assert_install_queue_is_resolvable", "apply_environment_overrides", + "block_outgoing_mail_outside_production", "ensure_admin_user", "connect_to_db", "assert_core_schema_healthy", @@ -886,6 +940,10 @@ def __init__(self) -> None: CREATE TABLE ir_act_server (id INTEGER PRIMARY KEY, model_id INTEGER, code TEXT); CREATE TABLE ir_cron (id INTEGER PRIMARY KEY, cron_name TEXT, active BOOLEAN, ir_actions_server_id INTEGER); CREATE TABLE ir_model_data (id INTEGER PRIMARY KEY, module TEXT, name TEXT, model TEXT, res_id INTEGER); + CREATE TABLE ir_mail_server ( + name TEXT, smtp_port INTEGER, smtp_host TEXT, smtp_encryption TEXT, active BOOLEAN, + smtp_authentication TEXT, smtp_user TEXT, smtp_pass TEXT + ); """ ) self.tables = {row[0] for row in self.database.execute("SELECT name FROM sqlite_master WHERE type = 'table'")} @@ -929,6 +987,10 @@ def __init__(self) -> None: [(1, "Shopify Sync - Dispatcher", True, 1), (2, "Mail: send queue", True, 2), (3, "Shopify reconcile", True, 3)], ) self.database.execute("INSERT INTO ir_model_data VALUES (1, 'shopify_sync', 'ir_cron_shopify_sync_dispatch', 'ir.cron', 1)") + self.database.execute( + "INSERT INTO ir_mail_server VALUES ('Production', 587, 'smtp.example.test', 'starttls', true, 'login', " + "'mailbox@example.test', 'copied-secret')" + ) def cursor(self) -> closing: return closing(_ParamstyleCursor(self.database.cursor())) @@ -945,6 +1007,9 @@ def parameters(self) -> dict[str, str]: def scalar(self, query: str) -> object: return self.database.execute(query).fetchone()[0] + def active_mail_servers(self) -> list[tuple]: + return self.database.execute("SELECT smtp_host, smtp_user, smtp_pass FROM ir_mail_server WHERE active = true").fetchall() + class _ParamstyleCursor: """Runs the workflow's psycopg2-style (%s) SQL against SQLite.""" @@ -965,6 +1030,10 @@ def fetchall(self) -> list[tuple]: def description(self) -> object: return self._cursor.description + @property + def rowcount(self) -> int: + return self._cursor.rowcount + def close(self) -> None: self._cursor.close() @@ -1168,6 +1237,8 @@ def apply_launchplane_settings() -> None: self.assertNotIn(key, parameters) self.assertNotEqual(parameters["database.secret"], PRODUCTION_PARAMETERS["database.secret"]) self.assertEqual(self.copy.scalar("SELECT active FROM ir_cron WHERE id = 1"), 0) + self.assertEqual(self.copy.active_mail_servers(), [("invalid", None, None)]) + self.assertEqual(self.copy.scalar("SELECT count(*) FROM ir_mail_server WHERE smtp_pass IS NOT NULL"), 0) def test_restore_that_fails_after_pg_restore_drops_the_production_copy(self) -> None: runner = self._runner("testing")