Files
bongo/src/apps/config/tests/test_storage.py
T
Mario Fetka 732300efd6
Debian Trixie package bundle / packages (push) Successful in 15m41s
Verify complete configuration rollback
2026-07-22 02:23:21 +02:00

466 lines
20 KiB
Python

# /****************************************************************************
# * <Novell-copyright>
# * Copyright (c) 2001 Novell, Inc. All Rights Reserved.
# *
# * This program is free software; you can redistribute it and/or
# * modify it under the terms of version 2 of the GNU General Public License
# * as published by the Free Software Foundation.
# *
# * This program is distributed in the hope that it will be useful,
# * but WITHOUT ANY WARRANTY; without even the implied warranty of
# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# * GNU General Public License for more details.
# *
# * You should have received a copy of the GNU General Public License
# * along with this program; if not, contact Novell, Inc.
# *
# * To contact Novell about this file by physical or electronic mail, you
# * may find current contact information at www.novell.com.
# * </Novell-copyright>
# ****************************************************************************/
from __future__ import annotations
import copy
import json
import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
SOURCE = Path(__file__).resolve().parents[4]
sys.path.insert(0, str(SOURCE / "src" / "libs" / "python"))
from bongo.configuration.model import ( # noqa: E402
Scenario, STORE_DOCUMENTS, apply_scenario, json_bytes, merge_defaults,
)
from bongo.configuration.probes import probe_clamav, probe_spamd # noqa: E402
from bongo.configuration.storage import ( # noqa: E402
ConfigurationError, ConfigurationPaths, ConfigurationStore,
)
class FakeStore(ConfigurationStore):
def __init__(self, paths):
super().__init__(paths)
scenario = Scenario(
hostname="mail.example.test", bind_address="192.0.2.10",
domains=["example.test"], admin_password="0123456789ab",
web_mode="direct",
web_public_url="http://mail.example.test:8080",
)
self.live = apply_scenario(self.templates, scenario)
self.aliases = {
"default_config": copy.deepcopy(self.alias_template),
"example.test": copy.deepcopy(self.alias_template),
}
self.domain_aliases = {}
self.username_mappings = {}
self.fail_name = None
def read_store_document(self, name):
value = copy.deepcopy(self.live[name])
return value, json_bytes(value)
def write_store_document(self, name, content):
if name == self.fail_name:
self.fail_name = None
raise ConfigurationError("injected Store failure")
self.live[name] = json.loads(content)
def read_store_path(self, name):
if name.startswith("aliases/"):
value = {
"domainalias": self.domain_aliases.get(
name.split("/", 1)[1], ""),
"aliases": copy.deepcopy(
self.aliases[name.split("/", 1)[1]]["aliases"]),
"username-mapping": self.username_mappings.get(
name.split("/", 1)[1], 0),
}
return value, json_bytes(value)
return self.read_store_document(name)
class FakeSocket:
def __init__(self, response):
self.response = response
self.sent = b""
def __enter__(self):
return self
def __exit__(self, *_args):
return False
def settimeout(self, _timeout):
pass
def sendall(self, content):
self.sent += content
def recv(self, _maximum):
response, self.response = self.response, b""
return response
class ConfigurationStorageTest(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
root = Path(self.temporary.name)
self.paths = ConfigurationPaths(
templates=SOURCE / "src" / "apps" / "config" / "config",
config_dir=root / "etc" / "config.d",
aliases_dir=root / "etc" / "aliases.d",
ssl_dir=root / "etc" / "ssl.d",
dkim_dir=root / "etc" / "dkim.d",
dmarc_dir=root / "etc" / "dmarc.d",
acme_dir=root / "etc" / "acme.d",
acme_provider_config=root / "etc" / "acme.d" / "providers",
legacy_certificate=root / "var" / "lib" / "bongo" / "dbf" /
"osslcert.pem",
legacy_key=root / "var" / "lib" / "bongo" / "dbf" /
"osslpriv.pem",
legacy_web=root / "etc" / "web",
legacy_web_json=root / "etc" / "bongo-web.json",
legacy_authentication=root / "etc" / "bongo.conf",
migration_backup=root / "etc" / "migration-backup" / "pre-0.7",
admin=root / "sbin" / "bongo-admin",
config=root / "sbin" / "bongo-config",
lock=root / "run" / "bongo-config.lock",
)
self.store = FakeStore(self.paths)
def tearDown(self):
self.temporary.cleanup()
def test_save_writes_local_documents(self):
configurations = copy.deepcopy(self.store.live)
configurations["web"]["port"] = 8088
configurations["authentication"]["AuthLdapOfflineCacheHours"] = 72
self.store.save(configurations)
self.assertEqual(
json.loads(self.paths.document("web").read_text())["port"], 8088)
self.assertEqual(json.loads(
self.paths.document("authentication").read_text()
)["AuthLdapOfflineCacheHours"], 72)
def test_save_canonicalizes_internationalized_configuration_domains(self):
configurations = copy.deepcopy(self.store.live)
configurations["global"]["hostname"] = "mail.bücher.example"
configurations["global"]["tls_hostnames"] = [
"mail.bücher.example"]
configurations["queue"]["domains"] = ["bücher.example"]
configurations["queue"]["hosteddomains"] = ["bücher.example"]
self.store.save(configurations)
global_config = json.loads(
self.paths.document("global").read_text())
queue = json.loads(self.paths.document("queue").read_text())
self.assertEqual(global_config["hostname"],
"mail.xn--bcher-kva.example")
self.assertEqual(queue["domains"], ["xn--bcher-kva.example"])
def test_install_is_repeatable_and_preserves_existing_aliases(self):
configurations = copy.deepcopy(self.store.live)
invocations = []
def completed(command, *, pass_fds, check):
self.assertFalse(check)
password_fd, bundle_fd = pass_fds
os.lseek(password_fd, 0, os.SEEK_SET)
os.lseek(bundle_fd, 0, os.SEEK_SET)
password = os.read(password_fd, 4096)
bundle = json.loads(os.read(bundle_fd, 1024 * 1024))
invocations.append((list(command), password, bundle))
return subprocess.CompletedProcess(command, 0)
with mock.patch("subprocess.run", side_effect=completed):
self.store.install(
configurations, "mail.example.test", "192.0.2.10",
["example.test"], "0123456789ab", ["mail.example.test"])
first_documents = {
name: self.paths.document(name).read_bytes()
for name in STORE_DOCUMENTS
}
aliases = json.loads(
(self.paths.aliases_dir / "default_config").read_text())
aliases["aliases"]["abuse"] = "security"
(self.paths.aliases_dir / "default_config").write_bytes(
json_bytes(aliases))
self.store.install(
configurations, "mail.example.test", "192.0.2.10",
["example.test"], "0123456789ab", ["mail.example.test"])
self.assertEqual(len(invocations), 2)
self.assertEqual(invocations[0], invocations[1])
self.assertEqual(invocations[0][1], b"0123456789ab")
self.assertEqual(
set(invocations[0][2]), STORE_DOCUMENTS)
self.assertEqual(first_documents, {
name: self.paths.document(name).read_bytes()
for name in STORE_DOCUMENTS
})
aliases = json.loads(
(self.paths.aliases_dir / "default_config").read_text())
self.assertEqual(aliases["aliases"]["abuse"], "security")
self.assertEqual(aliases["aliases"]["dmarc-reports"], "admin")
def test_worker_defaults_append_new_registered_jobs(self):
configurations = copy.deepcopy(self.store.live)
configurations["worker"]["jobs"] = [
job for job in configurations["worker"]["jobs"]
if job["name"] != "scanner-health"
]
result = merge_defaults(configurations, self.store.templates)
self.assertEqual(
[job["name"] for job in result["worker"]["jobs"]],
["acme-renew", "tlsrpt-delivery", "scanner-health"],
)
def test_migration_moves_historical_files_and_writes_all_documents(self):
self.paths.legacy_web_json.parent.mkdir(parents=True)
self.paths.legacy_web_json.write_bytes(json_bytes(self.store.live["web"]))
self.paths.legacy_authentication.write_text(
"AuthLdapEnabled = false\nAuthLdapOfflineCacheHours = 72\n")
moved = self.store.migrate_legacy()
self.assertEqual(len(moved), 2)
self.assertFalse(self.paths.legacy_web_json.exists())
self.assertFalse(self.paths.legacy_authentication.exists())
self.assertTrue((self.paths.migration_backup / "bongo-web.json").exists())
self.assertEqual(
set(path.name for path in self.paths.config_dir.iterdir()),
set(STORE_DOCUMENTS))
self.assertEqual(
set(path.name for path in self.paths.aliases_dir.iterdir()),
{"default_config"})
self.assertEqual(json.loads(
(self.paths.aliases_dir / "default_config").read_text()
)["aliases"]["dmarc-reports"], "admin")
self.assertEqual(json.loads(
self.paths.document("authentication").read_text()
)["AuthLdapOfflineCacheHours"], 72)
def test_migration_refuses_ambiguous_historical_web_files(self):
self.paths.legacy_web.parent.mkdir(parents=True)
content = json_bytes(self.store.live["web"])
self.paths.legacy_web.write_bytes(content)
self.paths.legacy_web_json.write_bytes(content)
with self.assertRaisesRegex(ConfigurationError, "multiple historical"):
self.store.migrate_legacy()
def test_migration_moves_complete_legacy_tls_pair(self):
self.paths.legacy_certificate.parent.mkdir(parents=True)
self.paths.legacy_certificate.write_text("certificate")
self.paths.legacy_key.write_text("private key")
moved = self.store.migrate_legacy()
self.assertIn((self.paths.legacy_certificate,
self.paths.ssl_dir / "server.crt"), moved)
self.assertIn((self.paths.legacy_key,
self.paths.ssl_dir / "server.key"), moved)
self.assertEqual(
(self.paths.ssl_dir / "server.key").stat().st_mode & 0o777,
0o640)
def test_migration_rejects_incomplete_legacy_tls_pair(self):
self.paths.legacy_key.parent.mkdir(parents=True)
self.paths.legacy_key.write_text("private key")
with self.assertRaisesRegex(ConfigurationError, "requires both"):
self.store.migrate_legacy()
def test_alias_fragments_allow_either_section_or_both(self):
self.paths.aliases_dir.mkdir(parents=True)
(self.paths.aliases_dir / "default_config").write_bytes(json_bytes({
"aliases": {"postmaster": "admin"},
}))
(self.paths.aliases_dir / "domains").write_bytes(json_bytes({
"domains": {"old.example.test": "example.test"},
}))
(self.paths.aliases_dir / "special.example.test").write_bytes(
json_bytes({
"aliases": {"dmarc-reports@example.test": "dmarc"},
"domains": {
"special.example.test": {
"target": "example.test",
"recipient_mapping": "full-address",
},
},
}))
aliases = self.store.load_aliases()
self.assertEqual(
aliases["special.example.test"]["domains"]
["special.example.test"]["recipient_mapping"],
"full-address")
def test_alias_domains_compare_by_idna2008_alabel(self):
self.paths.aliases_dir.mkdir(parents=True)
(self.paths.aliases_dir / "default_config").write_bytes(json_bytes({
"aliases": {"postmaster@bücher.example": "admin"},
"domains": {"bücher.example": "xn--bcher-kva.example"},
}))
aliases = self.store.load_aliases(["xn--bcher-kva.example"])
self.assertIn("postmaster@bücher.example",
aliases["default_config"]["aliases"])
def test_alias_fragments_reject_numeric_recipient_mapping(self):
with self.assertRaisesRegex(ConfigurationError, "invalid options"):
self.store._validate_alias_document("legacy", {
"domains": {
"old.example.test": {
"target": "example.test",
"recipient_mapping": 1,
},
},
})
def test_alias_fragments_reject_cross_file_conflicts(self):
self.paths.aliases_dir.mkdir(parents=True)
(self.paths.aliases_dir / "default_config").write_bytes(json_bytes({
"aliases": {"dmarc-reports": "admin"},
}))
(self.paths.aliases_dir / "conflict").write_bytes(json_bytes({
"aliases": {"dmarc-reports": "dmarc"},
}))
with self.assertRaisesRegex(ConfigurationError, "conflicting alias"):
self.store.load_aliases(["example.test"])
def test_alias_fragments_reject_unhosted_domain_override(self):
self.paths.aliases_dir.mkdir(parents=True)
(self.paths.aliases_dir / "default_config").write_bytes(json_bytes({
"aliases": {"postmaster": "admin"},
}))
(self.paths.aliases_dir / "override").write_bytes(json_bytes({
"aliases": {"dmarc-reports@outside.test": "dmarc"},
}))
with self.assertRaisesRegex(ConfigurationError, "unhosted domain"):
self.store.load_aliases(["example.test"])
def test_dmarc_tasks_follow_domain_specific_alias_targets(self):
self.paths.aliases_dir.mkdir(parents=True)
(self.paths.aliases_dir / "default_config").write_bytes(json_bytes({
"aliases": {"dmarc-reports": "admin"},
}))
(self.paths.aliases_dir / "reports").write_bytes(json_bytes({
"aliases": {
"dmarc-reports@second.example.test":
"reports@second.example.test",
},
}))
self.assertEqual(self.store.dmarc_report_users([
"example.test", "second.example.test",
]), {
"example.test": "admin",
"second.example.test": "reports",
})
def test_migration_names_legacy_recipient_mapping(self):
self.store.username_mappings["example.test"] = 2
self.store.migrate_legacy()
migrated = json.loads(
(self.paths.aliases_dir / "example.test").read_text())
self.assertEqual(
migrated["domains"]["example.test"]["recipient_mapping"],
"domain-name")
def test_migration_rejects_unknown_legacy_recipient_mapping(self):
self.store.username_mappings["example.test"] = 3
with self.assertRaisesRegex(ConfigurationError, "invalid structure"):
self.store.migrate_legacy()
def test_retained_alias_transaction_commits_persistent_mapping(self):
self.paths.aliases_dir.mkdir(parents=True)
(self.paths.aliases_dir / "default_config").write_bytes(json_bytes({
"aliases": {"postmaster": "admin"},
}))
self.store.prepare_retained_alias("old-user", "new-user", "101")
aliases = json.loads(
(self.paths.aliases_dir / "account-renames").read_text())
self.assertEqual(aliases["aliases"]["old-user"], "new-user")
self.store.finish_retained_alias("101", True)
self.assertFalse(
(self.paths.lock.parent / "bongo-alias-rename-101").exists())
self.assertEqual(json.loads(
(self.paths.aliases_dir / "account-renames").read_text()
)["aliases"]["old-user"], "new-user")
def test_retained_alias_transaction_rolls_back_exact_file(self):
self.paths.aliases_dir.mkdir(parents=True)
(self.paths.aliases_dir / "default_config").write_bytes(json_bytes({
"aliases": {"postmaster": "admin"},
}))
self.store.prepare_retained_alias("old-user", "new-user", "102")
self.store.finish_retained_alias("102", False)
self.assertFalse((self.paths.aliases_dir / "account-renames").exists())
def test_retained_alias_prepare_rejects_conflicting_mapping(self):
self.paths.aliases_dir.mkdir(parents=True)
(self.paths.aliases_dir / "default_config").write_bytes(json_bytes({
"aliases": {"old-user": "someone-else"},
}))
with self.assertRaisesRegex(ConfigurationError, "already points"):
self.store.prepare_retained_alias("old-user", "new-user", "103")
def test_retained_alias_rejects_control_characters(self):
with self.assertRaisesRegex(ConfigurationError, "invalid retained"):
self.store.prepare_retained_alias(
"old\x7fuser", "new-user", "104")
def test_store_failure_rolls_back_earlier_documents(self):
configurations = copy.deepcopy(self.store.live)
original_smtp = copy.deepcopy(self.store.live["smtp"])
original_imap = copy.deepcopy(self.store.live["imap"])
configurations["smtp"]["socket_timeout"] += 1
configurations["imap"]["threads_max"] += 1
self.store.fail_name = "imap"
with self.assertRaises(ConfigurationError):
self.store.save(configurations)
self.assertEqual(self.store.live["smtp"], original_smtp)
self.assertEqual(self.store.live["imap"], original_imap)
self.assertTrue(all(
not self.paths.document(name).exists()
for name in STORE_DOCUMENTS
))
def test_store_helper_timeout_names_operation_and_document(self):
store = ConfigurationStore(self.paths)
with mock.patch("subprocess.run", side_effect=subprocess.TimeoutExpired(
[str(self.paths.admin), "__config-read", "sieve"], 30)):
with self.assertRaisesRegex(
ConfigurationError,
r"timed out during __config-read for sieve"):
store.read_store_document("sieve")
def test_scanner_ping_probes_require_expected_reply(self):
clam = FakeSocket(b"PONG\0")
spam = FakeSocket(b"SPAMD/1.5 0 PONG\r\n")
with mock.patch("socket.create_connection",
side_effect=[clam, spam]):
self.assertTrue(probe_clamav().available)
self.assertTrue(probe_spamd().available)
self.assertEqual(clam.sent, b"zPING\0")
self.assertEqual(spam.sent, b"PING SPAMC/1.5\r\n\r\n")
def test_scanner_probe_fails_closed_on_protocol_mismatch(self):
with mock.patch("socket.create_connection",
return_value=FakeSocket(b"unexpected\n")), \
mock.patch("shutil.which", return_value="/usr/bin/clamd"):
result = probe_clamav()
self.assertFalse(result.available)
self.assertTrue(result.installed)
def test_scanner_probe_reports_installed_but_unreachable(self):
with mock.patch("socket.create_connection",
side_effect=ConnectionRefusedError("refused")), \
mock.patch("shutil.which", return_value="/usr/bin/spamd"):
result = probe_spamd()
self.assertFalse(result.available)
self.assertTrue(result.installed)
self.assertEqual(result.endpoint, "127.0.0.1:783")
if __name__ == "__main__":
unittest.main()