1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213
|
import pytest
from types import SimpleNamespace
from certbot_dns_hetzner_cloud.hetzner_cloud_helper import HetznerCloudHelper
import certbot_dns_hetzner_cloud.hetzner_cloud_helper as mod
# ---- Test-Doubles ----
class FakeRRSet:
def __init__(self, name, *values):
self.name = name
# Support multiple values: each can be a string or tuple (value, comment)
self.records = []
for v in values:
if isinstance(v, tuple):
value, comment = v
else:
value, comment = v, None
self.records.append(SimpleNamespace(value=value, comment=comment))
class FakeRRSetListResp:
def __init__(self, rrsets=None):
self.rrsets = rrsets or []
class FakeZonesAPI:
def __init__(self):
# Aufgerufen-Flags + zuletzt übergebene Argumente
self.calls = []
self.bound_zone = SimpleNamespace(id="Z1", name="example.com")
# Zonen
def get(self, zone_name):
self.calls.append(("get", zone_name))
assert zone_name == "example.com"
return self.bound_zone
# RRSet lesen
def get_rrset_list(self, *, zone, name, type):
self.calls.append(("get_rrset_list", zone.name, name, type))
# Rückgabe wird pro Test per Injection gesetzt
return self._rrset_list
# RRSet löschen
def delete_rrset(self, rrset):
self.calls.append(("delete_rrset", rrset.name))
# RRSet erstellen
def create_rrset(self, *, zone, name, type, records):
self.calls.append(("create_rrset", zone.name, name, type, tuple(r.value for r in records)))
# Minimal-Response mit rrset zurückgeben (ähnlich hcloud)
return SimpleNamespace(rrset=FakeRRSet(name, records[0].value))
class FakeClient:
def __init__(self):
self.zones = FakeZonesAPI()
class FakeBoundZone:
def __init__(self, name="example.com", id_="Z1"):
self.name = name
self.id = id_
# ---- Fixtures ----
@pytest.fixture
def helper(monkeypatch):
# 1) Hetzner-Client faken
def fake_init(self, api_key: str):
self.client = FakeClient()
monkeypatch.setattr(HetznerCloudHelper, "__init__", fake_init)
# 2) BoundZone-Klasse im Modul patchen, damit isinstance(...) True ist
monkeypatch.setattr(mod, "BoundZone", FakeBoundZone)
# 3) Instanz + Default-BoundZone setzen
h = HetznerCloudHelper("DUMMY")
h.client.zones.bound_zone = FakeBoundZone(name="example.com", id_="Z1")
# (optional) leere RRSet-Liste als Default
h.client.zones._rrset_list = FakeRRSetListResp([])
return h
# ---- Tests ----
def test_ensure_zone_with_string(helper):
zones = helper.client.zones
zones._rrset_list = FakeRRSetListResp([]) # default
z = helper._ensure_zone("example.com")
assert z.name == "example.com"
assert ("get", "example.com") in zones.calls
def test_ensure_zone_with_boundzone(helper, monkeypatch):
zones = helper.client.zones
zones._rrset_list = FakeRRSetListResp([])
# make BoundZone isinstance(...) pass
monkeypatch.setattr(mod, "BoundZone", FakeBoundZone)
bound = FakeBoundZone(name="example.com", id_="Z1")
zones.bound_zone = bound # our fake bound zone
z = helper._ensure_zone(bound)
assert z is bound
assert z.name == "example.com"
assert not any(c[0] == "get" for c in zones.calls)
def test_delete_txt_record_deletes_when_present(helper):
zones = helper.client.zones
# Simuliere vorhandenes RRSet
zones._rrset_list = FakeRRSetListResp([FakeRRSet("_acme-challenge", '"old"')])
helper.delete_txt_record("example.com", "_acme-challenge")
# Erwartung: get -> get_rrset_list -> delete_rrset
assert ("get", "example.com") in zones.calls
assert ("get_rrset_list", "example.com", "_acme-challenge", "TXT") in zones.calls
assert ("delete_rrset", "_acme-challenge") in zones.calls
def test_delete_txt_record_noop_when_absent(helper):
zones = helper.client.zones
zones._rrset_list = FakeRRSetListResp([])
helper.delete_txt_record("example.com", "_acme-challenge")
# Kein delete_rrset-Call
assert ("get_rrset_list", "example.com", "_acme-challenge", "TXT") in zones.calls
assert not [c for c in zones.calls if c[0] == "delete_rrset"]
def test_put_txt_record_quotes_value_and_replaces(helper):
zones = helper.client.zones
# Vorhandenes RRSet -> sollte erst gelöscht, dann neu erstellt werden
zones._rrset_list = FakeRRSetListResp([FakeRRSet("_acme-challenge", '"old"')])
resp = helper.put_txt_record("example.com", "_acme-challenge", value="abc123", comment="test")
# Reihenfolge prüfen: get → get_rrset_list → delete_rrset → create_rrset
names = [c[0] for c in zones.calls]
assert names[:4] == ["get", "get_rrset_list", "delete_rrset", "create_rrset"]
# create_rrset wurde mit gequotetem Value aufgerufen:
create_call = [c for c in zones.calls if c[0] == "create_rrset"][-1]
_, zone_name, rr_name, rr_type, values = create_call
assert zone_name == "example.com"
assert rr_name == "_acme-challenge"
assert rr_type == "TXT"
# Should preserve old value and add new one
assert values == ('"old"', '"abc123"')
# Response rrset contains records (first one is preserved old value)
assert len(resp.rrset.records) > 0
def test_put_txt_record_preserves_multiple_existing_records(helper):
zones = helper.client.zones
# Multiple existing records
zones._rrset_list = FakeRRSetListResp([FakeRRSet("_acme-challenge", '"token1"', '"token2"')])
helper.put_txt_record("example.com", "_acme-challenge", value="token3", comment="test")
# Should create with all three values
create_call = [c for c in zones.calls if c[0] == "create_rrset"][-1]
_, zone_name, rr_name, rr_type, values = create_call
assert values == ('"token1"', '"token2"', '"token3"')
def test_put_txt_record_avoids_duplicates(helper):
zones = helper.client.zones
# Record with same value already exists
zones._rrset_list = FakeRRSetListResp([FakeRRSet("_acme-challenge", '"token1"', '"abc123"')])
helper.put_txt_record("example.com", "_acme-challenge", value="abc123", comment="test")
# Should not duplicate, only have token1 and abc123
create_call = [c for c in zones.calls if c[0] == "create_rrset"][-1]
_, zone_name, rr_name, rr_type, values = create_call
assert values == ('"token1"', '"abc123"')
def test_delete_txt_record_with_value_removes_only_that_value(helper):
zones = helper.client.zones
# Multiple records exist
zones._rrset_list = FakeRRSetListResp([FakeRRSet("_acme-challenge", '"token1"', '"token2"')])
helper.delete_txt_record("example.com", "_acme-challenge", value="token1")
# Should delete and recreate with only token2
assert ("delete_rrset", "_acme-challenge") in zones.calls
create_call = [c for c in zones.calls if c[0] == "create_rrset"]
assert len(create_call) == 1
_, zone_name, rr_name, rr_type, values = create_call[0]
assert values == ('"token2"',)
def test_delete_txt_record_with_value_deletes_all_when_last_removed(helper):
zones = helper.client.zones
# Only one record exists
zones._rrset_list = FakeRRSetListResp([FakeRRSet("_acme-challenge", '"token1"')])
helper.delete_txt_record("example.com", "_acme-challenge", value="token1")
# Should delete but not recreate (no remaining records)
assert ("delete_rrset", "_acme-challenge") in zones.calls
create_calls = [c for c in zones.calls if c[0] == "create_rrset"]
assert len(create_calls) == 0
def test_delete_txt_record_without_value_deletes_all(helper):
zones = helper.client.zones
# Multiple records exist
zones._rrset_list = FakeRRSetListResp([FakeRRSet("_acme-challenge", '"token1"', '"token2"')])
helper.delete_txt_record("example.com", "_acme-challenge", value=None)
# Should delete entire rrset without recreation
assert ("delete_rrset", "_acme-challenge") in zones.calls
create_calls = [c for c in zones.calls if c[0] == "create_rrset"]
assert len(create_calls) == 0
|