File: test_hetzner_cloud_helper.py

package info (click to toggle)
python-certbot-dns-hetzner-cloud 1.0.5-2
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 168 kB
  • sloc: python: 317; makefile: 8
file content (213 lines) | stat: -rw-r--r-- 8,112 bytes parent folder | download
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