Files
ServerKit/backend/tests/test_dns_restore_points.py

656 lines
25 KiB
Python

"""Plan 81 M2: DNS guarded doors, complete ledger, and restore adapter."""
import json
import pytest
class FakeRecordClient:
def __init__(self, records=None, *, delete_result=None):
self.records = records or {}
self.delete_result = delete_result
self.calls = []
self._next_id = 1
@staticmethod
def _key(record_type, name):
return record_type.upper(), name.lower().rstrip('.')
def list_records(self, zone_id):
return {'success': True, 'records': list(self.records.values())}
def find_record_id(self, zone_id, record_type, name, caa=None):
self.calls.append(('find', zone_id, record_type, name))
row = self.records.get(self._key(record_type, name))
return row.get('id') if row else None
def upsert(self, zone_id, spec, record_id=None):
self.calls.append(('upsert', zone_id, spec, record_id))
record_id = record_id or f'R{self._next_id}'
self._next_id += 1
self.records[self._key(spec.record_type, spec.name)] = {
'id': record_id,
'type': spec.record_type,
'name': spec.name,
'content': spec.content,
'ttl': spec.ttl,
'priority': spec.priority,
'proxied': spec.proxied,
}
return {'success': True, 'record_id': record_id, 'message': 'set'}
def delete(self, zone_id, record_id=None, record_type=None, name=None):
self.calls.append(('delete', zone_id, record_id, record_type, name))
if self.delete_result is not None:
return self.delete_result
self.records.pop(self._key(record_type, name), None)
return {'success': True, 'message': 'deleted'}
class RejectStaleIdClient(FakeRecordClient):
"""Models Cloudflare/DO: PUT to a deleted record id fails with not-found."""
def upsert(self, zone_id, spec, record_id=None):
live_ids = {row.get('id') for row in self.records.values()}
if record_id is not None and record_id not in live_ids:
self.calls.append(('stale-upsert', zone_id, spec, record_id))
return {'success': False, 'error': 'record id not found'}
return super().upsert(zone_id, spec, record_id=record_id)
def _config(db, provider):
from app.models.email import DNSProviderConfig
row = DNSProviderConfig(
name=f'{provider}-connection', provider=provider,
api_key='key', api_secret='secret',
)
db.session.add(row)
db.session.commit()
return row
@pytest.mark.parametrize(
'provider', ['cloudflare', 'route53', 'digitalocean', 'godaddy'],
)
def test_provider_service_writes_all_providers_through_guarded_door(
app, monkeypatch, provider):
from app import db
from app.models.managed_dns_record import ManagedDnsRecord
from app.services import restore_point_service
from app.services.dns_provider_service import DNSProviderService
config = _config(db, provider)
client = FakeRecordClient()
scopes = []
monkeypatch.setattr(
DNSProviderService, 'record_client',
classmethod(lambda cls, cfg: client),
)
monkeypatch.setattr(
restore_point_service, 'auto_capture',
lambda scope_type, scope_id, action, **kwargs:
scopes.append((scope_type, scope_id, action)),
)
result = DNSProviderService.set_record(
config.id, 'zone-1', 'MX', 'example.com', 'mail.example.com',
ttl=120, priority=10, proxied=True,
)
assert result['success'] is True
row = ManagedDnsRecord.query.one()
assert (row.provider, row.dns_provider_config_id, row.provider_zone_id) == (
provider, config.id, 'zone-1',
)
assert (row.ttl, row.priority, row.proxied) == (120, 10, True)
assert scopes == [(
'dns', f'{provider}:{config.id}:zone-1',
'upsert MX example.com',
)]
@pytest.mark.parametrize(
'provider', ['cloudflare', 'route53', 'digitalocean', 'godaddy'],
)
def test_supported_clients_share_record_contract(provider):
from app.services.dns import get_record_client
from app.services.dns.base import DnsCredential
client = get_record_client(DnsCredential(
provider=provider, token='key', secret='secret',
))
assert all(callable(getattr(client, method)) for method in (
'list_records', 'find_record_id', 'upsert', 'delete',
))
def test_rrset_ownership_is_scoped_by_provider_connection(app):
from app import db
from app.models.managed_dns_record import ManagedDnsRecord
from app.services.dns_ownership_service import DnsOwnershipService as O
first = _config(db, 'cloudflare')
second = _config(db, 'cloudflare')
O.record_write(
'cloudflare', 'shared-zone-id', 'A', 'WWW.Example.com.',
provider_record_id='FIRST', content='1.1.1.1', config_id=first.id,
)
O.record_write(
'cloudflare', 'shared-zone-id', 'A', 'www.example.com',
provider_record_id='SECOND', content='2.2.2.2', config_id=second.id,
)
assert ManagedDnsRecord.query.count() == 2
assert O.owns(
'shared-zone-id', provider_record_id='FIRST',
provider='cloudflare', config_id=first.id,
)
assert not O.owns(
'shared-zone-id', provider_record_id='FIRST',
provider='cloudflare', config_id=second.id,
)
def test_change_log_keeps_complete_before_value(app, monkeypatch):
from app import db
from app.models.dns_change import DnsChange
from app.services import restore_point_service
from app.services.dns.base import DnsRecordSpec
from app.services.dns_ownership_service import DnsOwnershipService as O
config = _config(db, 'cloudflare')
client = FakeRecordClient()
monkeypatch.setattr(restore_point_service, 'auto_capture', lambda *a, **k: None)
O.guarded_upsert(
client, provider='cloudflare', provider_zone_id='zone',
spec=DnsRecordSpec('A', 'www.example.com', '1.1.1.1', 300,
priority=None, proxied=True),
source='zone', config_id=config.id,
)
O.guarded_upsert(
client, provider='cloudflare', provider_zone_id='zone',
spec=DnsRecordSpec('A', 'www.example.com', '2.2.2.2', 60,
priority=None, proxied=False),
source='zone', config_id=config.id,
)
change = DnsChange.query.filter_by(action='update').one()
assert json.loads(change.before_json) == {
'app_id': None,
'content': '1.1.1.1',
'name': 'www.example.com',
'priority': None,
'proxied': True,
'record_type': 'A',
'source': 'zone',
'ttl': 300,
}
def test_guarded_door_canonicalizes_embedded_mx_priority(app, monkeypatch):
from app import db
from app.models.managed_dns_record import ManagedDnsRecord
from app.services import restore_point_service
from app.services.dns.base import DnsRecordSpec
from app.services.dns_ownership_service import DnsOwnershipService as O
config = _config(db, 'route53')
monkeypatch.setattr(restore_point_service, 'auto_capture', lambda *a, **k: None)
O.guarded_upsert(
FakeRecordClient(), provider='route53', provider_zone_id='zone',
spec=DnsRecordSpec('MX', 'example.com', '10 mail.example.com', 300),
source='email', config_id=config.id,
)
row = ManagedDnsRecord.query.one()
assert row.content == 'mail.example.com'
assert row.priority == 10
def test_failed_delete_keeps_ownership_and_logs_preimage(app, monkeypatch):
from app import db
from app.models.dns_change import DnsChange
from app.models.managed_dns_record import ManagedDnsRecord
from app.services import restore_point_service
from app.services.dns_ownership_service import DnsOwnershipService as O
config = _config(db, 'digitalocean')
O.record_write(
'digitalocean', 'example.com', 'MX', 'example.com',
provider_record_id='77', content='mail.example.com', ttl=600,
priority=10, proxied=False, source='email', config_id=config.id,
)
monkeypatch.setattr(restore_point_service, 'auto_capture', lambda *a, **k: None)
result = O.guarded_delete(
FakeRecordClient(delete_result={'success': False, 'error': 'timeout'}),
provider='digitalocean', provider_zone_id='example.com',
record_type='MX', name='example.com', provider_record_id='77',
source='email', config_id=config.id,
)
assert result['success'] is False
assert ManagedDnsRecord.query.filter_by(provider_record_id='77').one()
change = DnsChange.query.filter_by(action='delete').one()
assert change.result == 'error'
assert json.loads(change.before_json)['priority'] == 10
def test_dns_capture_reads_managed_ledger_not_zone_mirror(app):
from app import db
from app.models.dns_zone import DNSRecord, DNSZone
from app.services.dns_ownership_service import DnsOwnershipService as O
from app.services.restore_point_adapter_dns import capture
config = _config(db, 'cloudflare')
zone = DNSZone(
domain='example.com', provider='cloudflare', provider_zone_id='zone',
dns_provider_config_id=config.id,
)
db.session.add(zone)
db.session.flush()
db.session.add(DNSRecord(
zone_id=zone.id, record_type='A', name='foreign.example.com',
content='9.9.9.9', ttl=60,
))
db.session.commit()
scope = f'cloudflare:{config.id}:zone'
assert capture(scope)['records'] == []
O.record_write(
'cloudflare', 'zone', 'A', 'owned.example.com',
provider_record_id='OWNED', content='1.2.3.4', ttl=120,
priority=None, proxied=True, source='zone', config_id=config.id,
)
payload = capture(scope)
assert [row['name'] for row in payload['records']] == ['owned.example.com']
assert payload['records'][0]['ttl'] == 120
assert payload['records'][0]['proxied'] is True
def test_dns_restore_round_trip_uses_guarded_calls(app, monkeypatch):
from app import db
from app.models.managed_dns_record import ManagedDnsRecord
from app.services import restore_point_adapter_dns as adapter
from app.services import restore_point_service
from app.services.dns.base import DnsRecordSpec
from app.services.dns_ownership_service import DnsOwnershipService as O
config = _config(db, 'cloudflare')
client = FakeRecordClient()
monkeypatch.setattr(restore_point_service, 'auto_capture', lambda *a, **k: None)
monkeypatch.setattr(adapter, '_client_for_scope', lambda *args: client)
scope = f'cloudflare:{config.id}:zone'
O.guarded_upsert(
client, provider='cloudflare', provider_zone_id='zone',
spec=DnsRecordSpec('A', 'www.example.com', '1.1.1.1', 300,
proxied=True),
source='zone', config_id=config.id,
)
checkpoint = adapter.capture(scope)
O.guarded_upsert(
client, provider='cloudflare', provider_zone_id='zone',
spec=DnsRecordSpec('A', 'www.example.com', '2.2.2.2', 60),
source='zone', config_id=config.id,
)
O.guarded_upsert(
client, provider='cloudflare', provider_zone_id='zone',
spec=DnsRecordSpec('TXT', 'extra.example.com', 'remove-me', 60),
source='zone', config_id=config.id,
)
result = adapter.restore(scope, checkpoint)
assert result['success'] is True
rows = ManagedDnsRecord.query.order_by(ManagedDnsRecord.record_type).all()
assert [(row.record_type, row.content) for row in rows] == [
('A', '1.1.1.1'),
]
assert rows[0].ttl == 300 and rows[0].proxied is True
assert ('TXT', 'extra.example.com') not in client.records
def test_dns_restore_refuses_provider_only_target(app, monkeypatch):
from app import db
from app.services import restore_point_adapter_dns as adapter
config = _config(db, 'cloudflare')
client = FakeRecordClient(records={
('A', 'foreign.example.com'): {
'id': 'FOREIGN', 'type': 'A', 'name': 'foreign.example.com',
'content': '9.9.9.9', 'ttl': 300, 'priority': None,
'proxied': False,
},
})
monkeypatch.setattr(adapter, '_client_for_scope', lambda *args: client)
scope = f'cloudflare:{config.id}:zone'
payload = {
'provider': 'cloudflare',
'config_id': config.id,
'provider_zone_id': 'zone',
'records': [{
'record_type': 'A', 'name': 'foreign.example.com',
'content': '1.2.3.4', 'ttl': 60, 'priority': None,
'proxied': False, 'source': 'restore', 'app_id': None,
}],
}
result = adapter.restore(scope, payload)
assert result['success'] is False and result['conflict'] is True
assert not any(call[0] == 'upsert' for call in client.calls)
assert client.records[('A', 'foreign.example.com')]['content'] == '9.9.9.9'
def test_dns_validate_restore_surfaces_foreign_conflict(app, monkeypatch):
from app import db
from app.services import restore_point_adapter_dns as adapter
config = _config(db, 'cloudflare')
client = FakeRecordClient(records={
('A', 'foreign.example.com'): {
'id': 'FOREIGN', 'type': 'A', 'name': 'foreign.example.com',
'content': '9.9.9.9', 'ttl': 300, 'priority': None,
'proxied': False,
},
})
monkeypatch.setattr(adapter, '_client_for_scope', lambda *args: client)
scope = f'cloudflare:{config.id}:zone'
payload = {
'provider': 'cloudflare', 'config_id': config.id,
'provider_zone_id': 'zone',
'records': [{
'record_type': 'A', 'name': 'foreign.example.com',
'content': '1.2.3.4', 'ttl': 60, 'priority': None,
'proxied': False, 'source': 'restore', 'app_id': None,
}],
}
refusals = adapter.validate_restore(scope, payload, {'records': []})
assert len(refusals) == 1
assert 'provider-only DNS record A foreign.example.com' in refusals[0]
def test_restore_point_preview_disables_foreign_dns_restore(app, monkeypatch):
from app import db
from app.services import restore_point_adapter_dns as adapter
from app.services import restore_point_service as service
from app.services.dns_ownership_service import DnsOwnershipService as O
config = _config(db, 'cloudflare')
scope = f'cloudflare:{config.id}:zone'
O.record_write(
'cloudflare', 'zone', 'A', 'foreign.example.com',
provider_record_id='FORMERLY-OURS', content='1.2.3.4', ttl=60,
source='zone', config_id=config.id,
)
monkeypatch.setitem(service.ADAPTERS, 'dns', adapter)
point = service.capture('dns', scope, 'manual')
O.record_delete(
'zone', provider_record_id='FORMERLY-OURS', provider='cloudflare',
config_id=config.id,
)
client = FakeRecordClient(records={
('A', 'foreign.example.com'): {
'id': 'FOREIGN', 'type': 'A', 'name': 'foreign.example.com',
'content': '9.9.9.9', 'ttl': 300, 'priority': None,
'proxied': False,
},
})
monkeypatch.setattr(adapter, '_client_for_scope', lambda *args: client)
preview = service.preview(point.id)
assert preview['can_restore'] is False
assert 'provider-only DNS record' in preview['refusals'][0]
def test_dns_restore_repairs_provider_when_ledger_already_matches(app, monkeypatch):
from app import db
from app.services import restore_point_adapter_dns as adapter
from app.services import restore_point_service
from app.services.dns_ownership_service import DnsOwnershipService as O
config = _config(db, 'cloudflare')
client = RejectStaleIdClient() # provider record drifted away / was deleted
monkeypatch.setattr(restore_point_service, 'auto_capture', lambda *a, **k: None)
monkeypatch.setattr(adapter, '_client_for_scope', lambda *args: client)
scope = f'cloudflare:{config.id}:zone'
O.record_write(
'cloudflare', 'zone', 'A', 'www.example.com',
provider_record_id='STALE', content='1.1.1.1', ttl=300,
proxied=True, source='zone', config_id=config.id,
)
checkpoint = adapter.capture(scope)
result = adapter.restore(scope, checkpoint)
assert result['success'] is True
assert any(call[0] == 'upsert' for call in client.calls)
assert not any(call[0] == 'stale-upsert' for call in client.calls)
assert client.records[('A', 'www.example.com')]['content'] == '1.1.1.1'
def test_legacy_dns_attributes_remain_unknown_and_restore_refuses_to_guess(
app, monkeypatch):
from app import db
from app.models.managed_dns_record import ManagedDnsRecord
from app.services import restore_point_adapter_dns as adapter
config = _config(db, 'cloudflare')
db.session.add(ManagedDnsRecord(
provider='cloudflare', dns_provider_config_id=config.id,
provider_zone_id='zone', provider_record_id='OLD',
record_type='A', name='legacy.example.com', content='1.2.3.4',
ttl=None, priority=None, proxied=None, source='zone',
))
db.session.commit()
scope = f'cloudflare:{config.id}:zone'
payload = adapter.capture(scope)
record = payload['records'][0]
assert record['ttl'] is None
assert record['priority'] is None
assert record['proxied'] is None
client = FakeRecordClient() # live record is also gone; no safe hydration
monkeypatch.setattr(adapter, '_client_for_scope', lambda *args: client)
refusals = adapter.validate_restore(scope, payload, payload)
assert any('unknown legacy TTL' in refusal for refusal in refusals)
assert any('unknown legacy proxy state' in refusal for refusal in refusals)
result = adapter.restore(scope, payload)
assert result['success'] is False and result['refused'] is True
assert not any(call[0] == 'upsert' for call in client.calls)
def test_legacy_dns_attributes_hydrate_from_live_provider(app, monkeypatch):
from app import db
from app.models.managed_dns_record import ManagedDnsRecord
from app.services import restore_point_adapter_dns as adapter
from app.services import restore_point_service
config = _config(db, 'cloudflare')
db.session.add(ManagedDnsRecord(
provider='cloudflare', dns_provider_config_id=config.id,
provider_zone_id='zone', provider_record_id='OLD',
record_type='A', name='legacy.example.com', content='1.2.3.4',
ttl=None, priority=None, proxied=None, source='zone',
))
db.session.commit()
scope = f'cloudflare:{config.id}:zone'
payload = adapter.capture(scope)
client = FakeRecordClient(records={
('A', 'legacy.example.com'): {
'id': 'OLD', 'type': 'A', 'name': 'legacy.example.com',
'content': '9.9.9.9', 'ttl': 120, 'priority': None,
'proxied': True,
},
})
monkeypatch.setattr(adapter, '_client_for_scope', lambda *args: client)
monkeypatch.setattr(restore_point_service, 'auto_capture', lambda *a, **k: None)
assert adapter.validate_restore(scope, payload, payload) == []
result = adapter.restore(scope, payload)
assert result['success'] is True
row = ManagedDnsRecord.query.one()
assert row.ttl == 120 and row.proxied is True
live = client.records[('A', 'legacy.example.com')]
assert live['content'] == '1.2.3.4'
assert live['ttl'] == 120 and live['proxied'] is True
@pytest.mark.parametrize('provider', ['route53', 'godaddy'])
def test_dns_restore_refuses_delete_of_synthetic_id_replacement(
app, monkeypatch, provider):
from app import db
from app.services import restore_point_adapter_dns as adapter
from app.services.dns_ownership_service import DnsOwnershipService as O
config = _config(db, provider)
zone_id = 'zone'
synthetic_id = 'A:old.example.com'
O.record_write(
provider, zone_id, 'A', 'old.example.com',
provider_record_id=synthetic_id, content='1.1.1.1', ttl=300,
proxied=False, source='zone', config_id=config.id,
)
client = FakeRecordClient(records={
('A', 'old.example.com'): {
'id': synthetic_id, 'type': 'A', 'name': 'old.example.com',
'content': '9.9.9.9', 'ttl': 300, 'priority': None,
'proxied': False,
},
})
monkeypatch.setattr(adapter, '_client_for_scope', lambda *args: client)
scope = f'{provider}:{config.id}:{zone_id}'
empty_target = {
'provider': provider, 'config_id': config.id,
'provider_zone_id': zone_id, 'records': [],
}
refusals = adapter.validate_restore(scope, empty_target, empty_target)
assert any('provider-only replacement' in item for item in refusals)
result = adapter.restore(scope, empty_target)
assert result['success'] is False and result['refused'] is True
assert not any(call[0] == 'delete' for call in client.calls)
def test_synthetic_mx_encoded_priority_is_same_owned_record(app, monkeypatch):
from app import db
from app.services import restore_point_adapter_dns as adapter
from app.services.dns_ownership_service import DnsOwnershipService as O
config = _config(db, 'route53')
O.record_write(
'route53', 'zone', 'MX', 'example.com',
provider_record_id='MX:example.com', content='10 mail.example.com.',
ttl=300, priority=None, proxied=False, source='legacy',
config_id=config.id,
)
client = FakeRecordClient(records={
('MX', 'example.com'): {
'id': 'MX:example.com', 'type': 'MX', 'name': 'example.com',
'content': 'mail.example.com', 'ttl': 300, 'priority': 10,
'proxied': False,
},
})
monkeypatch.setattr(adapter, '_client_for_scope', lambda *args: client)
scope = f'route53:{config.id}:zone'
empty_target = {
'provider': 'route53', 'config_id': config.id,
'provider_zone_id': 'zone', 'records': [],
}
assert adapter.validate_restore(scope, empty_target, empty_target) == []
result = adapter.restore(scope, empty_target)
assert result['success'] is True
assert any(call[0] == 'delete' for call in client.calls)
def test_dns_restore_forgets_stale_ledger_when_provider_already_absent(
app, monkeypatch):
from app import db
from app.models.managed_dns_record import ManagedDnsRecord
from app.services import restore_point_adapter_dns as adapter
from app.services.dns_ownership_service import DnsOwnershipService as O
config = _config(db, 'cloudflare')
O.record_write(
'cloudflare', 'zone', 'A', 'gone.example.com',
provider_record_id='STALE', content='1.1.1.1', ttl=300,
proxied=False, source='zone', config_id=config.id,
)
client = FakeRecordClient() # compliant delete treats missing id as success
monkeypatch.setattr(adapter, '_client_for_scope', lambda *args: client)
scope = f'cloudflare:{config.id}:zone'
empty_target = {
'provider': 'cloudflare', 'config_id': config.id,
'provider_zone_id': 'zone', 'records': [],
}
result = adapter.restore(scope, empty_target)
assert result['success'] is True
assert ManagedDnsRecord.query.count() == 0
def test_digitalocean_delete_stale_record_id_is_idempotent(monkeypatch):
from app.services.dns.base import DnsCredential
from app.services.dns.providers import DigitalOceanClient
from app.services.dns import providers
class MissingResponse:
status_code = 404
@staticmethod
def json():
return {'id': 'not_found', 'message': 'record not found'}
monkeypatch.setattr(
providers.requests, 'delete', lambda *a, **k: MissingResponse(),
)
client = DigitalOceanClient(DnsCredential(
provider='digitalocean', token='token',
))
result = client.delete('example.com', record_id='stale')
assert result['success'] is True
@pytest.mark.parametrize('provider', ['route53', 'godaddy'])
def test_rrset_providers_detect_different_caa_as_existing(monkeypatch, provider):
"""A typed RRset PUT would replace other values, so strict guards must see it."""
from app.services.dns.base import DnsCredential
from app.services.dns.providers import GoDaddyClient, Route53Client
from app.services.dns import providers
caa = {'flags': 0, 'tag': 'issue', 'value': 'letsencrypt.org'}
if provider == 'route53':
class FakeBoto:
@staticmethod
def list_resource_record_sets(**kwargs):
return {'ResourceRecordSets': [{
'Name': 'example.com.', 'Type': 'CAA', 'TTL': 300,
'ResourceRecords': [{'Value': '0 issue "digicert.com"'}],
}]}
client = Route53Client(
DnsCredential(provider='route53'), client=FakeBoto(),
)
else:
class Response:
status_code = 200
@staticmethod
def json():
return [{
'type': 'CAA', 'name': '@', 'data': 'digicert.com',
'flags': 0, 'tag': 'issue', 'ttl': 300,
}]
monkeypatch.setattr(
providers.requests, 'get', lambda *a, **k: Response(),
)
client = GoDaddyClient(DnsCredential(
provider='godaddy', token='key', secret='secret',
))
assert client.find_record_id(
'example.com', 'CAA', 'example.com', caa=caa,
) is not None