import os import tempfile import unittest from pathlib import Path from unittest.mock import patch from archive_clients.config import ClientConfig, ConfigError, RootMapping class ConfigTests(unittest.TestCase): def test_strict_config_mode_override_secrets_and_mapping(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) for name in ("token", "qb-password", "syncthing-key"): path = root / name path.write_text(name, encoding="utf-8") os.chmod(path, 0o600) (root / "qb").mkdir() (root / "sync").mkdir() config_path = root / "client.toml" config_path.write_text( _config(root, role="cache").replace( 'username = "admin"', 'username = "${QB_USER}"' ), encoding="utf-8", ) with patch.dict(os.environ, {"QB_USER": "admin"}): config = ClientConfig.load(config_path, "archive") self.assertEqual(config.role, "archive") self.assertEqual(config.qbittorrent.username, "admin") self.assertEqual(config.read_shared_token(), "token") self.assertEqual( config.qbittorrent.roots.api_to_local("/downloads/a/b"), root / "qb/a/b", ) self.assertEqual(config.jobs.stall_after, 30 * 60) self.assertEqual( config.syncthing.advertised_addresses, ("dynamic",) ) def test_unknown_key_and_unsafe_secret_fail(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) for name in ("token", "qb-password", "syncthing-key"): path = root / name path.write_text(name, encoding="utf-8") os.chmod(path, 0o600) config_path = root / "client.toml" invalid = _config(root).replace( "[qbittorrent]", "typo = true\n[qbittorrent]" ) config_path.write_text(invalid, encoding="utf-8") with self.assertRaisesRegex(ConfigError, "unknown root"): ClientConfig.load(config_path) os.chmod(root / "token", 0o640) config_path.write_text(_config(root), encoding="utf-8") with self.assertRaisesRegex(ConfigError, "permissions"): ClientConfig.load(config_path).read_shared_token() def test_mapping_rejects_escape(self): mapping = RootMapping(Path("/api"), Path("/local")) with self.assertRaises(ConfigError): mapping.api_to_local("/elsewhere/file") def test_endpoint_scheme_and_job_keys_are_strict(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) invalid_endpoint = _config(root).replace( 'control_endpoint = "ws://control/archive_control"', 'control_endpoint = "http://control/archive_control"', ) path = root / "client.toml" path.write_text(invalid_endpoint, encoding="utf-8") with self.assertRaisesRegex(ConfigError, "ws/wss"): ClientConfig.load(path) path.write_text( _config(root) + "\n[jobs]\nunknown = true\n", encoding="utf-8" ) with self.assertRaisesRegex(ConfigError, "unknown jobs"): ClientConfig.load(path) def test_state_and_backups_cannot_live_under_data_roots(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) path = root / "client.toml" nested = _config(root).replace( f'state_db = "{root / "state.db"}"', f'state_db = "{root / "qb/state.db"}"', ) path.write_text(nested, encoding="utf-8") with self.assertRaisesRegex(ConfigError, "outside data roots"): ClientConfig.load(path) def _config(root: Path, role: str = "cache") -> str: return f'''client_id = "cache-1" display_name = "Cache 1" role = "{role}" control_endpoint = "ws://control/archive_control" shared_token_file = "{root / 'token'}" state_db = "{root / 'state.db'}" backup_dir = "{root / 'backups'}" [qbittorrent] endpoint = "http://qb" username = "admin" password_file = "{root / 'qb-password'}" api_root = "/downloads" local_root = "{root / 'qb'}" [syncthing] endpoint = "http://syncthing" api_key_file = "{root / 'syncthing-key'}" api_root = "/sync" local_root = "{root / 'sync'}" advertised_addresses = ["dynamic"] ''' if __name__ == "__main__": unittest.main()