mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-11-03 02:26:43 +09:00
Cluster C of plan 38 (plan 31). dns_cutover_service reconstructed from readable
string fragments + plan doc + the frontend contract; SSH importer from the
surviving wiring tests.
- dns_cutover_service: server-sourced snapshots, dry-run + real cutover via the
guarded provider client, world-restoring revert that deletes cutover-created
records (uses restored DnsCutoverSnapshot) — 20 tests green
- api/dns_cutover: 7 JWT/admin-gated endpoints matching dnsCutover.js
- site_importers/ssh: GenericSshImporter + parse_ssh_source, keyfile-only,
credential-scrubbing; wired into site_import_service ('ssh' source) — 33 tests
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
238 lines
9.8 KiB
Python
238 lines
9.8 KiB
Python
"""Proving tests for the reversible DNS cutover SERVICE (plan 31 #1/#2/#3/#4).
|
|
|
|
Covers: TTL guidance, server-sourced snapshots with name filtering, dry-run vs
|
|
real cutover, created-record tracking, world-restoring revert (delete created +
|
|
re-apply captured), verify annotation, and the provider-registry 501.
|
|
|
|
The provider client is stubbed (a fake Cloudflare-shaped client) so nothing
|
|
touches the network; the ownership ledger writes go through the real
|
|
``DnsOwnershipService`` against the in-memory test DB.
|
|
"""
|
|
import pytest
|
|
|
|
from app import db
|
|
from app.models.dns_cutover_snapshot import DnsCutoverSnapshot
|
|
from app.services.dns_cutover_service import DnsCutoverService, DnsCutoverError
|
|
|
|
|
|
class FakeConfig:
|
|
id = 1
|
|
provider = 'cloudflare'
|
|
name = 'test-cf'
|
|
|
|
|
|
class FakeClient:
|
|
"""A minimal Cloudflare-shaped client backed by an in-memory record list."""
|
|
|
|
def __init__(self, records=None):
|
|
self.records = [dict(r) for r in (records or [])]
|
|
self.upserts = []
|
|
self.deletes = []
|
|
self._seq = 100
|
|
|
|
def list_records(self, zone_id):
|
|
return {'success': True, 'records': [dict(r) for r in self.records]}
|
|
|
|
def find_record_id(self, zone_id, record_type, name, caa=None):
|
|
for r in self.records:
|
|
if r['type'] == record_type and r['name'] == name:
|
|
return r['id']
|
|
return None
|
|
|
|
def upsert(self, zone_id, spec, record_id=None):
|
|
self._seq += 1
|
|
rid = record_id or f'rec{self._seq}'
|
|
self.upserts.append({'type': spec.record_type, 'name': spec.name,
|
|
'content': spec.content, 'record_id': record_id})
|
|
self.records = [r for r in self.records
|
|
if not (r['type'] == spec.record_type and r['name'] == spec.name)]
|
|
self.records.append({'id': rid, 'type': spec.record_type, 'name': spec.name,
|
|
'content': spec.content, 'ttl': spec.ttl,
|
|
'proxied': spec.proxied, 'priority': spec.priority})
|
|
return {'success': True, 'record_id': rid}
|
|
|
|
def delete(self, zone_id, record_id=None, record_type=None, name=None):
|
|
self.deletes.append({'record_id': record_id, 'type': record_type, 'name': name})
|
|
self.records = [r for r in self.records if r['id'] != record_id]
|
|
return {'success': True}
|
|
|
|
|
|
def _install_client(monkeypatch, client, config=None):
|
|
config = config or FakeConfig()
|
|
monkeypatch.setattr(
|
|
DnsCutoverService, '_resolve_dns_client',
|
|
lambda provider=None, provider_zone_id=None: (client, config))
|
|
return config
|
|
|
|
|
|
DOMAIN = 'site.example.com'
|
|
ZONE = 'zone123'
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# TTL guidance
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
def test_ttl_guidance_flags_records_needing_lowering(app):
|
|
guidance = DnsCutoverService.ttl_guidance([
|
|
{'name': DOMAIN, 'type': 'A', 'ttl': 3600},
|
|
{'name': DOMAIN, 'type': 'MX', 'ttl': 120},
|
|
])
|
|
assert guidance['recommended_ttl'] == 300
|
|
assert guidance['current_max_ttl'] == 3600
|
|
assert guidance['propagation_wait_seconds'] == 3600
|
|
flags = {r['type']: r['needs_lowering'] for r in guidance['records']}
|
|
assert flags['A'] is True and flags['MX'] is False
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# server-sourced snapshots (Decision 2)
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
def test_snapshot_reads_live_records_and_filters_by_name(app, monkeypatch):
|
|
client = FakeClient([
|
|
{'id': 'a1', 'type': 'A', 'name': DOMAIN, 'content': '203.0.113.1',
|
|
'ttl': 3600, 'proxied': False, 'priority': None},
|
|
{'id': 'w1', 'type': 'A', 'name': f'www.{DOMAIN}', 'content': '203.0.113.1',
|
|
'ttl': 3600, 'proxied': False, 'priority': None},
|
|
])
|
|
_install_client(monkeypatch, client)
|
|
|
|
snap = DnsCutoverService.create_snapshot(DOMAIN, ZONE, provider='cloudflare',
|
|
names=[DOMAIN])
|
|
assert snap.status == 'captured'
|
|
assert snap.provider == 'cloudflare'
|
|
records = snap.get_records()
|
|
assert len(records) == 1 and records[0]['name'] == DOMAIN # www filtered out
|
|
|
|
|
|
def test_snapshot_requires_zone(app, monkeypatch):
|
|
_install_client(monkeypatch, FakeClient())
|
|
with pytest.raises(DnsCutoverError):
|
|
DnsCutoverService.create_snapshot(DOMAIN, None)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# cutover (Decision 1) — dry-run vs real, created-record tracking
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
def test_cutover_dry_run_returns_ops_without_writing(app, monkeypatch):
|
|
client = FakeClient()
|
|
_install_client(monkeypatch, client)
|
|
snap = DnsCutoverSnapshot(domain=DOMAIN, provider='cloudflare',
|
|
provider_zone_id=ZONE, status='captured')
|
|
snap.set_records([])
|
|
db.session.add(snap)
|
|
db.session.commit()
|
|
|
|
result = DnsCutoverService.cutover(snap, target='198.51.100.9', dry_run=True)
|
|
assert result['dry_run'] is True
|
|
assert result['ops'][0] == {'action': 'create', 'type': 'A', 'name': DOMAIN,
|
|
'old': None, 'new': '198.51.100.9'}
|
|
assert client.upserts == [] # nothing written
|
|
assert snap.status == 'captured' # snapshot untouched
|
|
|
|
|
|
def test_cutover_creates_and_tracks_created_records(app, monkeypatch):
|
|
client = FakeClient() # zone has no A record yet
|
|
_install_client(monkeypatch, client)
|
|
snap = DnsCutoverSnapshot(domain=DOMAIN, provider='cloudflare',
|
|
provider_zone_id=ZONE, status='captured')
|
|
snap.set_records([])
|
|
db.session.add(snap)
|
|
db.session.commit()
|
|
|
|
result = DnsCutoverService.cutover(snap, target='198.51.100.9')
|
|
assert result['success'] is True
|
|
assert snap.status == 'cutover'
|
|
created = snap.get_created_records()
|
|
assert len(created) == 1 and created[0]['type'] == 'A'
|
|
assert created[0]['content'] == '198.51.100.9'
|
|
assert len(client.upserts) == 1
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# revert (Decision 3) — world-restoring
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
def test_revert_of_create_only_cutover_deletes_and_reverts_to_empty(app, monkeypatch):
|
|
client = FakeClient()
|
|
_install_client(monkeypatch, client)
|
|
snap = DnsCutoverSnapshot(domain=DOMAIN, provider='cloudflare',
|
|
provider_zone_id=ZONE, status='captured')
|
|
snap.set_records([])
|
|
db.session.add(snap)
|
|
db.session.commit()
|
|
|
|
DnsCutoverService.cutover(snap, target='198.51.100.9')
|
|
assert client.records # the created A exists on the provider
|
|
|
|
result = DnsCutoverService.revert(snap)
|
|
assert result['deleted_count'] == 1
|
|
assert snap.status == 'reverted_with_deletions'
|
|
assert result['deletions'][0]['deleted'] is True
|
|
assert client.records == [] # world restored to empty
|
|
|
|
|
|
def test_revert_reapplies_captured_values_byte_for_byte(app, monkeypatch):
|
|
# Zone already has the operator's A record → cutover UPDATES it (no create).
|
|
original = {'id': 'a1', 'type': 'A', 'name': DOMAIN, 'content': '203.0.113.1',
|
|
'ttl': 3600, 'proxied': False, 'priority': None}
|
|
client = FakeClient([dict(original)])
|
|
_install_client(monkeypatch, client)
|
|
snap = DnsCutoverSnapshot(domain=DOMAIN, provider='cloudflare',
|
|
provider_zone_id=ZONE, status='captured')
|
|
snap.set_records([dict(original)])
|
|
db.session.add(snap)
|
|
db.session.commit()
|
|
|
|
DnsCutoverService.cutover(snap, target='198.51.100.9')
|
|
assert client.records[0]['content'] == '198.51.100.9' # flipped
|
|
assert snap.get_created_records() == [] # nothing created
|
|
|
|
result = DnsCutoverService.revert(snap)
|
|
assert result['deleted_count'] == 0
|
|
assert snap.status == 'reverted'
|
|
assert client.records[0]['content'] == '203.0.113.1' # old value restored
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# verify (plan 31 #1)
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
def test_verify_annotates_matches_expected(app, monkeypatch):
|
|
from app.services.dns_zone_service import DNSZoneService
|
|
monkeypatch.setattr(DNSZoneService, 'check_propagation', staticmethod(
|
|
lambda domain, record_type='A': [
|
|
{'nameserver': 'Google', 'result': ['198.51.100.9'], 'propagated': True},
|
|
{'nameserver': 'Cloudflare', 'result': ['198.51.100.9'], 'propagated': True},
|
|
]))
|
|
res = DnsCutoverService.verify(DOMAIN, record_type='A', expected='198.51.100.9')
|
|
assert res['propagated'] is True
|
|
assert res['matches_expected'] is True
|
|
assert res['answered_count'] == 2
|
|
|
|
res2 = DnsCutoverService.verify(DOMAIN, record_type='A', expected='203.0.113.1')
|
|
assert res2['matches_expected'] is False
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# provider registry resolution (Decision 4)
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
def test_resolve_client_no_provider_is_clean_501(app):
|
|
with pytest.raises(DnsCutoverError) as ei:
|
|
DnsCutoverService._resolve_dns_client('cloudflare', ZONE)
|
|
assert ei.value.status_code == 501 and ei.value.code == 'NO_PROVIDER'
|
|
|
|
|
|
def test_resolve_client_unsupported_provider_is_clean_501(app):
|
|
from app.models.email import DNSProviderConfig
|
|
db.session.add(DNSProviderConfig(name='r53', provider='route53',
|
|
api_key='x', is_default=True))
|
|
db.session.commit()
|
|
with pytest.raises(DnsCutoverError) as ei:
|
|
DnsCutoverService._resolve_dns_client('route53', ZONE)
|
|
assert ei.value.status_code == 501 and ei.value.code == 'NO_PROVIDER'
|
|
assert 'route53' in ei.value.message
|