import io import os import tempfile import unittest from contextlib import redirect_stderr, redirect_stdout from pathlib import Path from types import SimpleNamespace from unittest.mock import patch from dns_sync.adapters import ( DNS_RECORDS, PROXY_HOSTS, Capability, CapabilityOperation, DiscoveredObject, Endpoint, ) from dns_sync.cli import build_parser, run_reconcile class FakeAdapter: def __init__( self, endpoint, capabilities, discovered, ): self.endpoint = endpoint self.capabilities = capabilities self.discovered = discovered self.apply_count = 0 self.verify_count = 0 def connect(self): pass def discover(self, object_type): return list(self.discovered.get(object_type, [])) def apply(self, action): self.apply_count += 1 raise AssertionError("read-only CLI called apply") def verify(self, object): self.verify_count += 1 raise AssertionError("read-only CLI called verify") class ReconcileCliTests(unittest.TestCase): def test_parser_exposes_read_only_reconcile_command(self): args = build_parser().parse_args( [ "reconcile", "--state-db", "/tmp/test-state.sqlite3", "--include-disabled", ] ) self.assertEqual(args.command, "reconcile") self.assertEqual( args.state_db, "/tmp/test-state.sqlite3", ) self.assertTrue(args.include_disabled) def test_reconcile_builds_plan_without_applying_it(self): npm_endpoint = Endpoint("npm", "192.168.1.3:81") verizon_endpoint = Endpoint( "verizon", "192.168.1.1:443", ) npm = FakeAdapter( npm_endpoint, frozenset( { Capability( PROXY_HOSTS, CapabilityOperation.DISCOVER, ) } ), { PROXY_HOSTS: [ DiscoveredObject( object_type=PROXY_HOSTS, local_key="12", payload={ "domain_names": [ "app.example.com" ], "forward_scheme": "http", "forward_host": "192.168.1.50", "forward_port": 8080, "enabled": True, }, ) ] }, ) verizon = FakeAdapter( verizon_endpoint, frozenset( { Capability( DNS_RECORDS, CapabilityOperation.DISCOVER, ), Capability( DNS_RECORDS, CapabilityOperation.APPLY, ), } ), { DNS_RECORDS: [ DiscoveredObject( object_type=DNS_RECORDS, local_key="41", payload={ "hostname": "app.example.com", "ip": "192.168.1.50", "record_type": "A", }, ) ] }, ) adapters = { npm_endpoint.identity: npm, verizon_endpoint.identity: verizon, } def loader(name, config, endpoint): return adapters[endpoint.identity] with tempfile.TemporaryDirectory() as directory: state_path = Path(directory) / "state.sqlite3" stdout = io.StringIO() stderr = io.StringIO() with patch.dict( os.environ, { "NPM_ENDPOINTS": "192.168.1.3", "VERIZON_ENDPOINTS": "192.168.1.1", "DNS_TARGET_IP": "192.168.1.101", }, clear=True, ), patch( "dns_sync.cli.load_adapter", side_effect=loader, ), redirect_stdout(stdout), redirect_stderr(stderr): result = run_reconcile( SimpleNamespace( state_db=str(state_path), include_disabled=False, ) ) self.assertTrue(state_path.exists()) output = stdout.getvalue() self.assertEqual(result, 0) self.assertEqual(stderr.getvalue(), "") self.assertIn( "Generic reconciliation mode: READ ONLY", output, ) self.assertIn( "UPDATE dns_records app.example.com " "-> 192.168.1.101", output, ) self.assertIn( "READ ONLY: no endpoint changes were applied.", output, ) self.assertEqual(npm.apply_count, 0) self.assertEqual(verizon.apply_count, 0) self.assertEqual(npm.verify_count, 0) self.assertEqual(verizon.verify_count, 0) if __name__ == "__main__": unittest.main()