certapi 1.0.0__tar.gz → 1.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {certapi-1.0.0/src/certapi.egg-info → certapi-1.0.1}/PKG-INFO +1 -1
- {certapi-1.0.0 → certapi-1.0.1}/setup.py +1 -1
- {certapi-1.0.0 → certapi-1.0.1}/src/certapi/__init__.py +1 -0
- {certapi-1.0.0 → certapi-1.0.1}/src/certapi/acme/AcmeError.py +3 -2
- certapi-1.0.1/src/certapi/client/__init__.py +1 -0
- {certapi-1.0.0 → certapi-1.0.1/src/certapi.egg-info}/PKG-INFO +1 -1
- {certapi-1.0.0 → certapi-1.0.1}/src/certapi.egg-info/SOURCES.txt +1 -0
- certapi-1.0.1/tests/test_acme_error_handling.py +704 -0
- certapi-1.0.0/src/certapi/server/__init__.py +0 -0
- {certapi-1.0.0 → certapi-1.0.1}/MANIFEST.in +0 -0
- {certapi-1.0.0 → certapi-1.0.1}/README.md +0 -0
- {certapi-1.0.0 → certapi-1.0.1}/pyproject.toml +0 -0
- {certapi-1.0.0 → certapi-1.0.1}/setup.cfg +0 -0
- {certapi-1.0.0 → certapi-1.0.1}/src/certapi/acme/Acme.py +0 -0
- {certapi-1.0.0 → certapi-1.0.1}/src/certapi/acme/Challenge.py +0 -0
- {certapi-1.0.0 → certapi-1.0.1}/src/certapi/acme/Order.py +0 -0
- {certapi-1.0.0 → certapi-1.0.1}/src/certapi/acme/__init__.py +0 -0
- {certapi-1.0.0 → certapi-1.0.1}/src/certapi/acme/http.py +0 -0
- {certapi-1.0.0 → certapi-1.0.1}/src/certapi/challenge_solver/ChallengeSolver.py +0 -0
- {certapi-1.0.0 → certapi-1.0.1}/src/certapi/challenge_solver/FileSystemChallengeSolver.py +0 -0
- {certapi-1.0.0 → certapi-1.0.1}/src/certapi/challenge_solver/InmemoryChallengeSolver.py +0 -0
- {certapi-1.0.0 → certapi-1.0.1}/src/certapi/challenge_solver/__init__.py +0 -0
- {certapi-1.0.0 → certapi-1.0.1}/src/certapi/challenge_solver/dns/__init__.py +0 -0
- {certapi-1.0.0 → certapi-1.0.1}/src/certapi/challenge_solver/dns/cloudflare/__init__.py +0 -0
- {certapi-1.0.0 → certapi-1.0.1}/src/certapi/challenge_solver/dns/cloudflare/cloudflare_challenge_solver.py +0 -0
- {certapi-1.0.0 → certapi-1.0.1}/src/certapi/challenge_solver/dns/cloudflare/cloudflare_client.py +0 -0
- {certapi-1.0.0 → certapi-1.0.1}/src/certapi/challenge_solver/dns/digitalocean/__init__.py +0 -0
- {certapi-1.0.0 → certapi-1.0.1}/src/certapi/challenge_solver/dns/digitalocean/digitalocean_challenge_solver.py +0 -0
- {certapi-1.0.0 → certapi-1.0.1}/src/certapi/challenge_solver/dns/digitalocean/digitalocean_client.py +0 -0
- {certapi-1.0.0 → certapi-1.0.1}/src/certapi/client/cert_manager_client.py +0 -0
- {certapi-1.0.0 → certapi-1.0.1}/src/certapi/crypto/__init__.py +0 -0
- {certapi-1.0.0 → certapi-1.0.1}/src/certapi/crypto/crypto.py +0 -0
- {certapi-1.0.0 → certapi-1.0.1}/src/certapi/crypto/crypto_classes.py +0 -0
- {certapi-1.0.0 → certapi-1.0.1}/src/certapi/errors.py +0 -0
- {certapi-1.0.0 → certapi-1.0.1}/src/certapi/http/HttpClientBase.py +0 -0
- {certapi-1.0.0/src/certapi/client → certapi-1.0.1/src/certapi/http}/__init__.py +0 -0
- {certapi-1.0.0 → certapi-1.0.1}/src/certapi/http/types.py +0 -0
- {certapi-1.0.0 → certapi-1.0.1}/src/certapi/issuers/AcmeCertIssuer.py +0 -0
- {certapi-1.0.0 → certapi-1.0.1}/src/certapi/issuers/SelfCertIssuer.py +0 -0
- {certapi-1.0.0 → certapi-1.0.1}/src/certapi/issuers/__init__.py +0 -0
- {certapi-1.0.0 → certapi-1.0.1}/src/certapi/issuers/abstract_certissuer.py +0 -0
- {certapi-1.0.0 → certapi-1.0.1}/src/certapi/keystore/FileSystemKeyStore.py +0 -0
- {certapi-1.0.0 → certapi-1.0.1}/src/certapi/keystore/KeyStore.py +0 -0
- {certapi-1.0.0 → certapi-1.0.1}/src/certapi/keystore/PostgresqlKeyStore.py +0 -0
- {certapi-1.0.0 → certapi-1.0.1}/src/certapi/keystore/RemoteKeyStore.py +0 -0
- {certapi-1.0.0 → certapi-1.0.1}/src/certapi/keystore/SqliteKeyStore.py +0 -0
- {certapi-1.0.0 → certapi-1.0.1}/src/certapi/keystore/__init__.py +0 -0
- {certapi-1.0.0/src/certapi/http → certapi-1.0.1/src/certapi/manager}/__init__.py +0 -0
- {certapi-1.0.0 → certapi-1.0.1}/src/certapi/manager/acme_cert_manager.py +0 -0
- {certapi-1.0.0/src/certapi/manager → certapi-1.0.1/src/certapi/server}/__init__.py +0 -0
- {certapi-1.0.0 → certapi-1.0.1}/src/certapi/server/api.py +0 -0
- {certapi-1.0.0 → certapi-1.0.1}/src/certapi/server/cert_api.py +0 -0
- {certapi-1.0.0 → certapi-1.0.1}/src/certapi/server/key_api.py +0 -0
- {certapi-1.0.0 → certapi-1.0.1}/src/certapi/util.py +0 -0
- {certapi-1.0.0 → certapi-1.0.1}/src/certapi/utils.py +0 -0
- {certapi-1.0.0 → certapi-1.0.1}/src/certapi.egg-info/dependency_links.txt +0 -0
- {certapi-1.0.0 → certapi-1.0.1}/src/certapi.egg-info/requires.txt +0 -0
- {certapi-1.0.0 → certapi-1.0.1}/src/certapi.egg-info/top_level.txt +0 -0
- {certapi-1.0.0 → certapi-1.0.1}/tests/test_cert_issuer_generic.py +0 -0
- {certapi-1.0.0 → certapi-1.0.1}/tests/test_certs_with_key_types.py +0 -0
- {certapi-1.0.0 → certapi-1.0.1}/tests/test_http_error_handling.py +0 -0
- {certapi-1.0.0 → certapi-1.0.1}/tests/test_keystores.py +0 -0
|
@@ -28,16 +28,17 @@ class AcmeHttpError(AcmeError, requests.HTTPError):
|
|
|
28
28
|
|
|
29
29
|
def __init__(self, response: requests.Response, step: str):
|
|
30
30
|
requests.HTTPError.__init__(self, response=response)
|
|
31
|
-
self.response = response
|
|
32
31
|
(message, detail) = self.extract_acme_response_error()
|
|
33
32
|
AcmeError.__init__(self, message, detail, step)
|
|
33
|
+
self.response = response
|
|
34
|
+
|
|
34
35
|
|
|
35
36
|
def extract_acme_response_error(self):
|
|
36
37
|
message = None
|
|
37
38
|
error = None
|
|
38
39
|
try:
|
|
39
40
|
res_json = self.response.json()
|
|
40
|
-
if res_json
|
|
41
|
+
if res_json.get("status") == "invalid":
|
|
41
42
|
if res_json["challenges"]:
|
|
42
43
|
failed_challenge: dict = [x for x in res_json["challenges"] if x["status"] == "invalid"][0]
|
|
43
44
|
error = failed_challenge["error"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .cert_manager_client import CertManagerClient
|
|
@@ -52,6 +52,7 @@ src/certapi/server/__init__.py
|
|
|
52
52
|
src/certapi/server/api.py
|
|
53
53
|
src/certapi/server/cert_api.py
|
|
54
54
|
src/certapi/server/key_api.py
|
|
55
|
+
tests/test_acme_error_handling.py
|
|
55
56
|
tests/test_cert_issuer_generic.py
|
|
56
57
|
tests/test_certs_with_key_types.py
|
|
57
58
|
tests/test_http_error_handling.py
|
|
@@ -0,0 +1,704 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Comprehensive test suite for ACME error handling.
|
|
3
|
+
|
|
4
|
+
Tests verify that all network errors, HTTP errors, and ACME-specific errors
|
|
5
|
+
are properly handled by Acme.py with appropriate exception types and retry logic.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
from unittest.mock import Mock, patch, MagicMock
|
|
10
|
+
import requests
|
|
11
|
+
import json
|
|
12
|
+
from certapi.acme.Acme import Acme
|
|
13
|
+
from certapi.acme.AcmeError import (
|
|
14
|
+
AcmeError,
|
|
15
|
+
AcmeNetworkError,
|
|
16
|
+
AcmeHttpError,
|
|
17
|
+
AcmeInvalidNonceError,
|
|
18
|
+
)
|
|
19
|
+
from certapi.crypto import Key
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TestAcmeNetworkErrorHandling:
|
|
23
|
+
"""Test suite for network-related error handling in ACME client"""
|
|
24
|
+
|
|
25
|
+
@pytest.fixture
|
|
26
|
+
def acme_client(self):
|
|
27
|
+
"""Create an ACME client instance for testing"""
|
|
28
|
+
# Create a mock key
|
|
29
|
+
mock_key = Mock(spec=Key)
|
|
30
|
+
mock_key.jwk.return_value = {"kty": "RSA", "n": "test", "e": "AQAB"}
|
|
31
|
+
mock_key.algorithm_name.return_value = "RS256"
|
|
32
|
+
mock_key.jws_sign.return_value = b"signature"
|
|
33
|
+
|
|
34
|
+
client = Acme(account_key=mock_key, url="https://acme-staging-v02.api.letsencrypt.org/directory")
|
|
35
|
+
client.nonce = ["initial-nonce"]
|
|
36
|
+
return client
|
|
37
|
+
|
|
38
|
+
@pytest.fixture
|
|
39
|
+
def mock_directory_response(self):
|
|
40
|
+
"""Mock ACME directory response"""
|
|
41
|
+
mock_response = Mock()
|
|
42
|
+
mock_response.status_code = 200
|
|
43
|
+
mock_response.json.return_value = {
|
|
44
|
+
"newNonce": "https://acme.example.com/new-nonce",
|
|
45
|
+
"newAccount": "https://acme.example.com/new-account",
|
|
46
|
+
"newOrder": "https://acme.example.com/new-order",
|
|
47
|
+
}
|
|
48
|
+
mock_response.headers = {"Replay-Nonce": "test-nonce-123"}
|
|
49
|
+
return mock_response
|
|
50
|
+
|
|
51
|
+
@pytest.mark.parametrize("error_side_effect, expected_type, expected_msg", [
|
|
52
|
+
(requests.exceptions.Timeout("Connection timed out"), "Timeout", "Error communicating with ACME server"),
|
|
53
|
+
(requests.exceptions.ConnectionError(ConnectionRefusedError(111, "Connection refused")), "ConnectionError", "Error communicating with ACME server"),
|
|
54
|
+
(requests.exceptions.ConnectionError(ConnectionResetError(104, "Connection reset by peer")), "ConnectionError", "Error communicating with ACME server"),
|
|
55
|
+
(requests.exceptions.ConnectionError("Failed to resolve hostname"), "ConnectionError", "Error communicating with ACME server"),
|
|
56
|
+
(requests.exceptions.RequestException("Unknown error"), "RequestException", "Error communicating with ACME server"),
|
|
57
|
+
])
|
|
58
|
+
def test_network_errors(self, acme_client, mock_directory_response, error_side_effect, expected_type, expected_msg):
|
|
59
|
+
"""Test various network-related errors with parametrization"""
|
|
60
|
+
with patch("certapi.acme.http.requests.request") as mock_request:
|
|
61
|
+
# First call succeeds (directory fetch), second call fails with side effect
|
|
62
|
+
mock_request.side_effect = [
|
|
63
|
+
mock_directory_response,
|
|
64
|
+
error_side_effect,
|
|
65
|
+
mock_directory_response,
|
|
66
|
+
error_side_effect,
|
|
67
|
+
mock_directory_response,
|
|
68
|
+
error_side_effect,
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
with pytest.raises(AcmeNetworkError) as exc_info:
|
|
72
|
+
acme_client.setup()
|
|
73
|
+
acme_client.register()
|
|
74
|
+
|
|
75
|
+
error = exc_info.value
|
|
76
|
+
assert error.can_retry is True
|
|
77
|
+
assert expected_msg in error.message
|
|
78
|
+
assert error.detail["errorType"] == expected_type
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class TestAcmeHttpErrorHandling:
|
|
82
|
+
"""Test suite for HTTP error handling in ACME client"""
|
|
83
|
+
|
|
84
|
+
@pytest.fixture
|
|
85
|
+
def acme_client(self):
|
|
86
|
+
"""Create an ACME client instance for testing"""
|
|
87
|
+
mock_key = Mock(spec=Key)
|
|
88
|
+
mock_key.jwk.return_value = {"kty": "RSA", "n": "test", "e": "AQAB"}
|
|
89
|
+
mock_key.algorithm_name.return_value = "RS256"
|
|
90
|
+
mock_key.jws_sign.return_value = b"signature"
|
|
91
|
+
|
|
92
|
+
client = Acme(account_key=mock_key, url="https://acme-staging-v02.api.letsencrypt.org/directory")
|
|
93
|
+
client.nonce = ["initial-nonce"]
|
|
94
|
+
return client
|
|
95
|
+
|
|
96
|
+
@pytest.fixture
|
|
97
|
+
def mock_directory_response(self):
|
|
98
|
+
"""Mock ACME directory response"""
|
|
99
|
+
mock_response = Mock()
|
|
100
|
+
mock_response.status_code = 200
|
|
101
|
+
mock_response.json.return_value = {
|
|
102
|
+
"newNonce": "https://acme.example.com/new-nonce",
|
|
103
|
+
"newAccount": "https://acme.example.com/new-account",
|
|
104
|
+
"newOrder": "https://acme.example.com/new-order",
|
|
105
|
+
}
|
|
106
|
+
mock_response.headers = {"Replay-Nonce": "test-nonce-123"}
|
|
107
|
+
return mock_response
|
|
108
|
+
|
|
109
|
+
@pytest.mark.parametrize("status_code, json_body, expected_msg_part", [
|
|
110
|
+
(503, {"type": "urn:ietf:params:acme:error:serverInternal", "detail": "Service unavailable"}, "Service unavailable"),
|
|
111
|
+
(500, {"type": "urn:ietf:params:acme:error:serverInternal", "detail": "Internal error"}, "Internal error"),
|
|
112
|
+
(429, {"type": "urn:ietf:params:acme:error:rateLimited", "detail": "Rate limit exceeded"}, "Rate limit"),
|
|
113
|
+
(400, {"type": "urn:ietf:params:acme:error:malformed", "detail": "Malformed request"}, "Malformed request"),
|
|
114
|
+
(401, {"type": "urn:ietf:params:acme:error:unauthorized", "detail": "Unauthorized access"}, "Unauthorized access"),
|
|
115
|
+
(404, {"type": "urn:ietf:params:acme:error:accountDoesNotExist", "detail": "Account not found"}, "Account not found"),
|
|
116
|
+
])
|
|
117
|
+
def test_http_errors(self, acme_client, mock_directory_response, status_code, json_body, expected_msg_part):
|
|
118
|
+
"""Test various HTTP error status codes with parametrization"""
|
|
119
|
+
with patch("certapi.acme.http.requests.request") as mock_request:
|
|
120
|
+
error_response = Mock()
|
|
121
|
+
error_response.status_code = status_code
|
|
122
|
+
error_response.json.return_value = json_body
|
|
123
|
+
error_response.headers = {"Replay-Nonce": "test-nonce"}
|
|
124
|
+
error_response.request = Mock()
|
|
125
|
+
error_response.request.url = "https://acme.example.com/api"
|
|
126
|
+
|
|
127
|
+
mock_request.side_effect = [mock_directory_response, error_response]
|
|
128
|
+
|
|
129
|
+
with pytest.raises(AcmeHttpError) as exc_info:
|
|
130
|
+
acme_client.setup()
|
|
131
|
+
acme_client.register()
|
|
132
|
+
|
|
133
|
+
error = exc_info.value
|
|
134
|
+
assert error.response.status_code == status_code
|
|
135
|
+
assert expected_msg_part.lower() in error.message.lower()
|
|
136
|
+
|
|
137
|
+
def test_server_returns_text_response(self, acme_client, mock_directory_response):
|
|
138
|
+
"""Test that a text response (non-JSON) is handled correctly"""
|
|
139
|
+
with patch("certapi.acme.http.requests.request") as mock_request:
|
|
140
|
+
error_response = Mock()
|
|
141
|
+
error_response.status_code = 502
|
|
142
|
+
error_response.text = "Bad Gateway (nginx)"
|
|
143
|
+
# Simulate json() raising an exception when response is not JSON
|
|
144
|
+
error_response.json.side_effect = requests.exceptions.JSONDecodeError("Expecting value", "", 0)
|
|
145
|
+
error_response.headers = {"Replay-Nonce": "test-nonce"}
|
|
146
|
+
error_response.request = Mock()
|
|
147
|
+
error_response.request.url = "https://acme.example.com/api"
|
|
148
|
+
|
|
149
|
+
mock_request.side_effect = [mock_directory_response, error_response]
|
|
150
|
+
|
|
151
|
+
with pytest.raises(AcmeHttpError) as exc_info:
|
|
152
|
+
acme_client.setup()
|
|
153
|
+
acme_client.register()
|
|
154
|
+
|
|
155
|
+
error = exc_info.value
|
|
156
|
+
assert error.response.status_code == 502
|
|
157
|
+
assert "Received status=502" in error.message
|
|
158
|
+
assert error.detail["response"] == "Bad Gateway (nginx)"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class TestAcmeSpecificErrorHandling:
|
|
162
|
+
"""Test suite for ACME-specific error scenarios"""
|
|
163
|
+
|
|
164
|
+
@pytest.fixture
|
|
165
|
+
def acme_client(self):
|
|
166
|
+
"""Create an ACME client instance for testing"""
|
|
167
|
+
mock_key = Mock(spec=Key)
|
|
168
|
+
mock_key.jwk.return_value = {"kty": "RSA", "n": "test", "e": "AQAB"}
|
|
169
|
+
mock_key.algorithm_name.return_value = "RS256"
|
|
170
|
+
mock_key.jws_sign.return_value = b"signature"
|
|
171
|
+
|
|
172
|
+
client = Acme(account_key=mock_key, url="https://acme-staging-v02.api.letsencrypt.org/directory")
|
|
173
|
+
client.nonce = ["initial-nonce"]
|
|
174
|
+
return client
|
|
175
|
+
|
|
176
|
+
@pytest.fixture
|
|
177
|
+
def mock_directory_response(self):
|
|
178
|
+
"""Mock ACME directory response"""
|
|
179
|
+
mock_response = Mock()
|
|
180
|
+
mock_response.status_code = 200
|
|
181
|
+
mock_response.json.return_value = {
|
|
182
|
+
"newNonce": "https://acme.example.com/new-nonce",
|
|
183
|
+
"newAccount": "https://acme.example.com/new-account",
|
|
184
|
+
"newOrder": "https://acme.example.com/new-order",
|
|
185
|
+
}
|
|
186
|
+
mock_response.headers = {"Replay-Nonce": "test-nonce-123"}
|
|
187
|
+
return mock_response
|
|
188
|
+
|
|
189
|
+
def test_invalid_nonce_triggers_retry(self, acme_client, mock_directory_response):
|
|
190
|
+
"""Test that invalid nonce error triggers retry with new nonce"""
|
|
191
|
+
with patch("certapi.acme.http.requests.request") as mock_request:
|
|
192
|
+
# First attempt: bad nonce error
|
|
193
|
+
bad_nonce_response = Mock()
|
|
194
|
+
bad_nonce_response.status_code = 400
|
|
195
|
+
bad_nonce_response.json.return_value = {
|
|
196
|
+
"type": "urn:ietf:params:acme:error:badNonce",
|
|
197
|
+
"detail": "JWS has invalid anti-replay nonce",
|
|
198
|
+
}
|
|
199
|
+
bad_nonce_response.headers = {"Replay-Nonce": "new-nonce-456"}
|
|
200
|
+
bad_nonce_response.request = Mock()
|
|
201
|
+
bad_nonce_response.request.url = "https://acme.example.com/new-account"
|
|
202
|
+
|
|
203
|
+
# Second attempt: success
|
|
204
|
+
success_response = Mock()
|
|
205
|
+
success_response.status_code = 201
|
|
206
|
+
success_response.json.return_value = {"status": "valid"}
|
|
207
|
+
success_response.headers = {
|
|
208
|
+
"Replay-Nonce": "nonce-789",
|
|
209
|
+
"location": "https://acme.example.com/account/123",
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
mock_request.side_effect = [
|
|
213
|
+
mock_directory_response, # setup() directory
|
|
214
|
+
bad_nonce_response, # first post() fail
|
|
215
|
+
mock_directory_response, # get_nonce() for retry (using directory mock as nonce mock)
|
|
216
|
+
success_response, # retry post() success
|
|
217
|
+
]
|
|
218
|
+
|
|
219
|
+
with patch("time.sleep"): # Skip actual sleep
|
|
220
|
+
result = acme_client.setup()
|
|
221
|
+
result = acme_client.register()
|
|
222
|
+
|
|
223
|
+
# Should succeed after retry
|
|
224
|
+
assert result.status_code == 201
|
|
225
|
+
|
|
226
|
+
def test_challenge_validation_dns_nxdomain(self, acme_client):
|
|
227
|
+
"""Test handling of DNS NXDOMAIN error in challenge validation"""
|
|
228
|
+
error_response = Mock()
|
|
229
|
+
error_response.status_code = 400
|
|
230
|
+
error_response.json.return_value = {
|
|
231
|
+
"status": "invalid",
|
|
232
|
+
"challenges": [
|
|
233
|
+
{
|
|
234
|
+
"status": "invalid",
|
|
235
|
+
"url": "https://acme.example.com/challenge/123",
|
|
236
|
+
"error": {
|
|
237
|
+
"type": "urn:ietf:params:acme:error:dns",
|
|
238
|
+
"detail": "DNS problem: NXDOMAIN looking up A for example.com",
|
|
239
|
+
},
|
|
240
|
+
}
|
|
241
|
+
],
|
|
242
|
+
}
|
|
243
|
+
error_response.headers = {"Replay-Nonce": "test-nonce"}
|
|
244
|
+
error_response.request = Mock()
|
|
245
|
+
error_response.request.url = "https://acme.example.com/challenge/123"
|
|
246
|
+
|
|
247
|
+
with pytest.raises(AcmeHttpError) as exc_info:
|
|
248
|
+
raise AcmeHttpError(error_response, "Challenge validation")
|
|
249
|
+
|
|
250
|
+
error = exc_info.value
|
|
251
|
+
assert "doesn't have a valid DNS record" in error.message or "NXDOMAIN" in str(error.detail)
|
|
252
|
+
|
|
253
|
+
def test_challenge_validation_connection_timeout(self, acme_client):
|
|
254
|
+
"""Test handling of connection timeout during challenge validation"""
|
|
255
|
+
error_response = Mock()
|
|
256
|
+
error_response.status_code = 400
|
|
257
|
+
error_response.json.return_value = {
|
|
258
|
+
"status": "invalid",
|
|
259
|
+
"challenges": [
|
|
260
|
+
{
|
|
261
|
+
"status": "invalid",
|
|
262
|
+
"url": "https://acme.example.com/challenge/123",
|
|
263
|
+
"error": {
|
|
264
|
+
"type": "urn:ietf:params:acme:error:connection",
|
|
265
|
+
"detail": "Timeout during connect (likely firewall problem)",
|
|
266
|
+
},
|
|
267
|
+
"validationRecord": [
|
|
268
|
+
{
|
|
269
|
+
"hostname": "example.com",
|
|
270
|
+
"addressesResolved": ["192.0.2.1"],
|
|
271
|
+
"addressUsed": "192.0.2.1",
|
|
272
|
+
"port": 80,
|
|
273
|
+
}
|
|
274
|
+
],
|
|
275
|
+
}
|
|
276
|
+
],
|
|
277
|
+
}
|
|
278
|
+
error_response.headers = {"Replay-Nonce": "test-nonce"}
|
|
279
|
+
error_response.request = Mock()
|
|
280
|
+
error_response.request.url = "https://acme.example.com/challenge/123"
|
|
281
|
+
|
|
282
|
+
with pytest.raises(AcmeHttpError) as exc_info:
|
|
283
|
+
raise AcmeHttpError(error_response, "Challenge validation")
|
|
284
|
+
|
|
285
|
+
error = exc_info.value
|
|
286
|
+
assert "Connect Timeout" in error.message or "Timeout" in error.message
|
|
287
|
+
|
|
288
|
+
def test_challenge_validation_connection_refused(self, acme_client):
|
|
289
|
+
"""Test handling of connection refused during challenge validation"""
|
|
290
|
+
error_response = Mock()
|
|
291
|
+
error_response.status_code = 400
|
|
292
|
+
error_response.json.return_value = {
|
|
293
|
+
"status": "invalid",
|
|
294
|
+
"challenges": [
|
|
295
|
+
{
|
|
296
|
+
"status": "invalid",
|
|
297
|
+
"url": "https://acme.example.com/challenge/123",
|
|
298
|
+
"error": {
|
|
299
|
+
"type": "urn:ietf:params:acme:error:connection",
|
|
300
|
+
"detail": "Connection refused",
|
|
301
|
+
},
|
|
302
|
+
"validationRecord": [
|
|
303
|
+
{
|
|
304
|
+
"hostname": "example.com",
|
|
305
|
+
"addressesResolved": ["192.0.2.1"],
|
|
306
|
+
"addressUsed": "192.0.2.1",
|
|
307
|
+
"port": 80,
|
|
308
|
+
}
|
|
309
|
+
],
|
|
310
|
+
}
|
|
311
|
+
],
|
|
312
|
+
}
|
|
313
|
+
error_response.headers = {"Replay-Nonce": "test-nonce"}
|
|
314
|
+
error_response.request = Mock()
|
|
315
|
+
error_response.request.url = "https://acme.example.com/challenge/123"
|
|
316
|
+
|
|
317
|
+
with pytest.raises(AcmeHttpError) as exc_info:
|
|
318
|
+
raise AcmeHttpError(error_response, "Challenge validation")
|
|
319
|
+
|
|
320
|
+
error = exc_info.value
|
|
321
|
+
assert "Connection Refused" in error.message or "refused" in error.message.lower()
|
|
322
|
+
|
|
323
|
+
def test_challenge_validation_invalid_response(self, acme_client):
|
|
324
|
+
"""Test handling of invalid response during challenge validation"""
|
|
325
|
+
error_response = Mock()
|
|
326
|
+
error_response.status_code = 400
|
|
327
|
+
error_response.json.return_value = {
|
|
328
|
+
"status": "invalid",
|
|
329
|
+
"challenges": [
|
|
330
|
+
{
|
|
331
|
+
"status": "invalid",
|
|
332
|
+
"url": "https://acme.example.com/challenge/123",
|
|
333
|
+
"error": {
|
|
334
|
+
"type": "urn:ietf:params:acme:error:unauthorized",
|
|
335
|
+
"detail": 'Invalid response from http://example.com/.well-known/acme-challenge/token: "404 Not Found"',
|
|
336
|
+
"status": 404,
|
|
337
|
+
},
|
|
338
|
+
"validationRecord": [
|
|
339
|
+
{
|
|
340
|
+
"hostname": "example.com",
|
|
341
|
+
"addressesResolved": ["192.0.2.1"],
|
|
342
|
+
"addressUsed": "192.0.2.1",
|
|
343
|
+
"port": 80,
|
|
344
|
+
}
|
|
345
|
+
],
|
|
346
|
+
}
|
|
347
|
+
],
|
|
348
|
+
}
|
|
349
|
+
error_response.headers = {"Replay-Nonce": "test-nonce"}
|
|
350
|
+
error_response.request = Mock()
|
|
351
|
+
error_response.request.url = "https://acme.example.com/challenge/123"
|
|
352
|
+
|
|
353
|
+
with pytest.raises(AcmeHttpError) as exc_info:
|
|
354
|
+
raise AcmeHttpError(error_response, "Challenge validation")
|
|
355
|
+
|
|
356
|
+
error = exc_info.value
|
|
357
|
+
assert "Invalid response" in error.message or "404" in str(error.detail)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
class TestAcmeRetryLogic:
|
|
361
|
+
"""Test suite for ACME retry logic"""
|
|
362
|
+
|
|
363
|
+
@pytest.fixture
|
|
364
|
+
def acme_client(self):
|
|
365
|
+
"""Create an ACME client instance for testing"""
|
|
366
|
+
mock_key = Mock(spec=Key)
|
|
367
|
+
mock_key.jwk.return_value = {"kty": "RSA", "n": "test", "e": "AQAB"}
|
|
368
|
+
mock_key.algorithm_name.return_value = "RS256"
|
|
369
|
+
mock_key.jws_sign.return_value = b"signature"
|
|
370
|
+
|
|
371
|
+
client = Acme(account_key=mock_key, url="https://acme-staging-v02.api.letsencrypt.org/directory")
|
|
372
|
+
client.nonce = ["initial-nonce"]
|
|
373
|
+
return client
|
|
374
|
+
|
|
375
|
+
@pytest.fixture
|
|
376
|
+
def mock_directory_response(self):
|
|
377
|
+
"""Mock ACME directory response"""
|
|
378
|
+
mock_response = Mock()
|
|
379
|
+
mock_response.status_code = 200
|
|
380
|
+
mock_response.json.return_value = {
|
|
381
|
+
"newNonce": "https://acme.example.com/new-nonce",
|
|
382
|
+
"newAccount": "https://acme.example.com/new-account",
|
|
383
|
+
"newOrder": "https://acme.example.com/new-order",
|
|
384
|
+
}
|
|
385
|
+
mock_response.headers = {"Replay-Nonce": "test-nonce-123"}
|
|
386
|
+
return mock_response
|
|
387
|
+
|
|
388
|
+
def test_retry_on_network_error_with_depth_limit(self, acme_client, mock_directory_response):
|
|
389
|
+
"""Test that network errors retry up to depth limit (1)"""
|
|
390
|
+
with patch("certapi.acme.http.requests.request") as mock_request:
|
|
391
|
+
network_error = requests.exceptions.ConnectionError("Connection failed")
|
|
392
|
+
|
|
393
|
+
# Directory fetch succeeds, then all subsequent requests fail
|
|
394
|
+
mock_request.side_effect = [
|
|
395
|
+
mock_directory_response,
|
|
396
|
+
network_error,
|
|
397
|
+
network_error,
|
|
398
|
+
network_error,
|
|
399
|
+
]
|
|
400
|
+
|
|
401
|
+
with pytest.raises(AcmeNetworkError):
|
|
402
|
+
acme_client.setup()
|
|
403
|
+
acme_client.register()
|
|
404
|
+
|
|
405
|
+
# Should attempt: initial + 1 retry = 2 attempts total (plus directory)
|
|
406
|
+
# So 3 calls total: directory + initial + retry
|
|
407
|
+
assert mock_request.call_count >= 2
|
|
408
|
+
|
|
409
|
+
def test_retry_delay_is_2_seconds(self, acme_client, mock_directory_response):
|
|
410
|
+
"""Test that retry delay is 2 seconds"""
|
|
411
|
+
with patch("certapi.acme.http.requests.request") as mock_request:
|
|
412
|
+
bad_nonce_response = Mock()
|
|
413
|
+
bad_nonce_response.status_code = 400
|
|
414
|
+
bad_nonce_response.json.return_value = {
|
|
415
|
+
"type": "urn:ietf:params:acme:error:badNonce",
|
|
416
|
+
"detail": "JWS has invalid anti-replay nonce",
|
|
417
|
+
}
|
|
418
|
+
bad_nonce_response.headers = {"Replay-Nonce": "new-nonce"}
|
|
419
|
+
bad_nonce_response.request = Mock()
|
|
420
|
+
bad_nonce_response.request.url = "https://acme.example.com/new-account"
|
|
421
|
+
|
|
422
|
+
success_response = Mock()
|
|
423
|
+
success_response.status_code = 201
|
|
424
|
+
success_response.json.return_value = {"status": "valid"}
|
|
425
|
+
success_response.headers = {
|
|
426
|
+
"Replay-Nonce": "nonce-789",
|
|
427
|
+
"location": "https://acme.example.com/account/123",
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
mock_request.side_effect = [
|
|
431
|
+
mock_directory_response, # setup() directory
|
|
432
|
+
bad_nonce_response, # first post() fail
|
|
433
|
+
mock_directory_response, # get_nonce() for retry
|
|
434
|
+
success_response, # retry post() success
|
|
435
|
+
]
|
|
436
|
+
|
|
437
|
+
with patch("time.sleep") as mock_sleep:
|
|
438
|
+
acme_client.setup()
|
|
439
|
+
acme_client.register()
|
|
440
|
+
|
|
441
|
+
# Should have slept for 2 seconds
|
|
442
|
+
mock_sleep.assert_called_with(2)
|
|
443
|
+
|
|
444
|
+
def test_no_retry_on_non_retriable_http_errors(self, acme_client, mock_directory_response):
|
|
445
|
+
"""Test that non-retriable HTTP errors don't retry"""
|
|
446
|
+
with patch("certapi.acme.http.requests.request") as mock_request:
|
|
447
|
+
error_response = Mock()
|
|
448
|
+
error_response.status_code = 400
|
|
449
|
+
error_response.json.return_value = {
|
|
450
|
+
"type": "urn:ietf:params:acme:error:malformed",
|
|
451
|
+
"detail": "Malformed request",
|
|
452
|
+
}
|
|
453
|
+
error_response.headers = {"Replay-Nonce": "test-nonce"}
|
|
454
|
+
error_response.request = Mock()
|
|
455
|
+
error_response.request.url = "https://acme.example.com/new-account"
|
|
456
|
+
|
|
457
|
+
mock_request.side_effect = [mock_directory_response, error_response]
|
|
458
|
+
|
|
459
|
+
with pytest.raises(AcmeHttpError):
|
|
460
|
+
acme_client.setup()
|
|
461
|
+
acme_client.register()
|
|
462
|
+
|
|
463
|
+
# Should only attempt once (no retry) plus directory
|
|
464
|
+
assert mock_request.call_count == 2
|
|
465
|
+
|
|
466
|
+
def test_retry_stops_after_depth_exceeds_1(self, acme_client, mock_directory_response):
|
|
467
|
+
"""Test that retry stops when depth > 1"""
|
|
468
|
+
with patch("certapi.acme.http.requests.request") as mock_request:
|
|
469
|
+
bad_nonce_response = Mock()
|
|
470
|
+
bad_nonce_response.status_code = 400
|
|
471
|
+
bad_nonce_response.json.return_value = {
|
|
472
|
+
"type": "urn:ietf:params:acme:error:badNonce",
|
|
473
|
+
"detail": "Bad nonce",
|
|
474
|
+
}
|
|
475
|
+
bad_nonce_response.headers = {"Replay-Nonce": "new-nonce"}
|
|
476
|
+
bad_nonce_response.request = Mock()
|
|
477
|
+
bad_nonce_response.request.url = "https://acme.example.com/new-account"
|
|
478
|
+
|
|
479
|
+
# All requests return bad nonce
|
|
480
|
+
mock_request.side_effect = [
|
|
481
|
+
mock_directory_response, # setup()
|
|
482
|
+
bad_nonce_response, # post 1
|
|
483
|
+
mock_directory_response, # get_nonce for retry 1
|
|
484
|
+
bad_nonce_response, # retry 1 post
|
|
485
|
+
mock_directory_response, # get_nonce for retry 2 (should not happen if depth limit works)
|
|
486
|
+
bad_nonce_response,
|
|
487
|
+
]
|
|
488
|
+
|
|
489
|
+
with pytest.raises(AcmeInvalidNonceError):
|
|
490
|
+
acme_client.setup()
|
|
491
|
+
acme_client.register()
|
|
492
|
+
|
|
493
|
+
# Calls will be: 1. setup(directory), 2. post(fail), 3. get_nonce, 4. post(fail), 5. get_nonce, 6. post(fail) -> raise
|
|
494
|
+
assert mock_request.call_count == 6
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
class TestAcmeNonceManagement:
|
|
498
|
+
"""Test suite for ACME nonce management"""
|
|
499
|
+
|
|
500
|
+
@pytest.fixture
|
|
501
|
+
def acme_client(self):
|
|
502
|
+
"""Create an ACME client instance for testing"""
|
|
503
|
+
mock_key = Mock(spec=Key)
|
|
504
|
+
mock_key.jwk.return_value = {"kty": "RSA", "n": "test", "e": "AQAB"}
|
|
505
|
+
mock_key.algorithm_name.return_value = "RS256"
|
|
506
|
+
mock_key.jws_sign.return_value = b"signature"
|
|
507
|
+
|
|
508
|
+
client = Acme(account_key=mock_key, url="https://acme-staging-v02.api.letsencrypt.org/directory")
|
|
509
|
+
client.nonce = ["initial-nonce"]
|
|
510
|
+
return client
|
|
511
|
+
|
|
512
|
+
def test_nonce_is_recorded_from_response(self, acme_client):
|
|
513
|
+
"""Test that nonce is recorded from response headers"""
|
|
514
|
+
mock_response = Mock()
|
|
515
|
+
mock_response.headers = {"Replay-Nonce": "test-nonce-123"}
|
|
516
|
+
|
|
517
|
+
acme_client.record_nonce(mock_response)
|
|
518
|
+
|
|
519
|
+
assert "test-nonce-123" in acme_client.nonce
|
|
520
|
+
|
|
521
|
+
def test_nonce_is_consumed_when_used(self, acme_client):
|
|
522
|
+
"""Test that nonce is removed from list when used"""
|
|
523
|
+
acme_client.nonce = ["nonce-1", "nonce-2"]
|
|
524
|
+
|
|
525
|
+
nonce = acme_client.get_nonce("Test step")
|
|
526
|
+
|
|
527
|
+
assert nonce == "nonce-1"
|
|
528
|
+
assert "nonce-1" not in acme_client.nonce
|
|
529
|
+
assert "nonce-2" in acme_client.nonce
|
|
530
|
+
|
|
531
|
+
def test_new_nonce_fetched_when_list_empty(self, acme_client):
|
|
532
|
+
"""Test that new nonce is fetched when list is empty"""
|
|
533
|
+
acme_client.nonce = [] # Clear initial nonce
|
|
534
|
+
acme_client.directory = {
|
|
535
|
+
"newNonce": "https://acme.example.com/new-nonce"
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
with patch("certapi.acme.http.requests.request") as mock_request:
|
|
539
|
+
mock_response = Mock()
|
|
540
|
+
mock_response.status_code = 200
|
|
541
|
+
mock_response.headers = {"Replay-Nonce": "fresh-nonce"}
|
|
542
|
+
mock_request.return_value = mock_response
|
|
543
|
+
|
|
544
|
+
nonce = acme_client.get_nonce("Test step")
|
|
545
|
+
|
|
546
|
+
assert nonce == "fresh-nonce"
|
|
547
|
+
|
|
548
|
+
def test_nonce_thread_safety(self, acme_client):
|
|
549
|
+
"""Test that nonce operations are thread-safe"""
|
|
550
|
+
import threading
|
|
551
|
+
|
|
552
|
+
acme_client.nonce = [f"nonce-{i}" for i in range(100)]
|
|
553
|
+
used_nonces = []
|
|
554
|
+
|
|
555
|
+
def consume_nonce():
|
|
556
|
+
nonce = acme_client.get_nonce("Test step")
|
|
557
|
+
used_nonces.append(nonce)
|
|
558
|
+
|
|
559
|
+
threads = [threading.Thread(target=consume_nonce) for _ in range(50)]
|
|
560
|
+
for t in threads:
|
|
561
|
+
t.start()
|
|
562
|
+
for t in threads:
|
|
563
|
+
t.join()
|
|
564
|
+
|
|
565
|
+
# All nonces should be unique (no duplicates)
|
|
566
|
+
assert len(used_nonces) == len(set(used_nonces))
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
class TestAcmeDirectoryCaching:
|
|
570
|
+
"""Test suite for ACME directory caching"""
|
|
571
|
+
|
|
572
|
+
@pytest.fixture
|
|
573
|
+
def acme_client(self):
|
|
574
|
+
"""Create an ACME client instance for testing"""
|
|
575
|
+
mock_key = Mock(spec=Key)
|
|
576
|
+
mock_key.jwk.return_value = {"kty": "RSA", "n": "test", "e": "AQAB"}
|
|
577
|
+
mock_key.algorithm_name.return_value = "RS256"
|
|
578
|
+
mock_key.jws_sign.return_value = b"signature"
|
|
579
|
+
|
|
580
|
+
client = Acme(account_key=mock_key, url="https://acme-staging-v02.api.letsencrypt.org/directory")
|
|
581
|
+
client.nonce = ["initial-nonce"]
|
|
582
|
+
return client
|
|
583
|
+
|
|
584
|
+
def test_directory_fetched_once_and_cached(self, acme_client):
|
|
585
|
+
"""Test that directory is fetched once and then cached"""
|
|
586
|
+
with patch("certapi.acme.http.requests.request") as mock_request:
|
|
587
|
+
mock_response = Mock()
|
|
588
|
+
mock_response.status_code = 200
|
|
589
|
+
mock_response.json.return_value = {
|
|
590
|
+
"newNonce": "https://acme.example.com/new-nonce",
|
|
591
|
+
"newAccount": "https://acme.example.com/new-account",
|
|
592
|
+
}
|
|
593
|
+
mock_response.headers = {"Replay-Nonce": "test-nonce"}
|
|
594
|
+
mock_request.return_value = mock_response
|
|
595
|
+
|
|
596
|
+
# First call
|
|
597
|
+
acme_client.setup()
|
|
598
|
+
# Second call
|
|
599
|
+
acme_client._directory("newAccount")
|
|
600
|
+
|
|
601
|
+
# Should only fetch once
|
|
602
|
+
assert mock_request.call_count == 1
|
|
603
|
+
|
|
604
|
+
def test_directory_lazy_loaded(self, acme_client):
|
|
605
|
+
"""Test that directory is lazily loaded on first use"""
|
|
606
|
+
assert acme_client.directory is None
|
|
607
|
+
|
|
608
|
+
with patch("certapi.acme.http.requests.request") as mock_request:
|
|
609
|
+
mock_response = Mock()
|
|
610
|
+
mock_response.status_code = 200
|
|
611
|
+
mock_response.json.return_value = {
|
|
612
|
+
"newNonce": "https://acme.example.com/new-nonce",
|
|
613
|
+
}
|
|
614
|
+
mock_response.headers = {"Replay-Nonce": "test-nonce"}
|
|
615
|
+
mock_request.return_value = mock_response
|
|
616
|
+
|
|
617
|
+
url = acme_client._directory("newNonce")
|
|
618
|
+
|
|
619
|
+
assert acme_client.directory is not None
|
|
620
|
+
assert url == "https://acme.example.com/new-nonce"
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
class TestAcmeMalformedResponseHandling:
|
|
624
|
+
"""Test suite for handling malformed or unexpected ACME responses (Runtime errors)"""
|
|
625
|
+
|
|
626
|
+
@pytest.fixture
|
|
627
|
+
def acme_client(self):
|
|
628
|
+
"""Create an ACME client instance for testing"""
|
|
629
|
+
mock_key = Mock(spec=Key)
|
|
630
|
+
mock_key.jwk.return_value = {"kty": "RSA", "n": "test", "e": "AQAB"}
|
|
631
|
+
mock_key.algorithm_name.return_value = "RS256"
|
|
632
|
+
mock_key.jws_sign.return_value = b"signature"
|
|
633
|
+
|
|
634
|
+
client = Acme(account_key=mock_key, url="https://acme-staging-v02.api.letsencrypt.org/directory")
|
|
635
|
+
client.nonce = ["initial-nonce"]
|
|
636
|
+
return client
|
|
637
|
+
|
|
638
|
+
@pytest.fixture
|
|
639
|
+
def mock_directory_response(self):
|
|
640
|
+
"""Mock ACME directory response"""
|
|
641
|
+
mock_response = Mock()
|
|
642
|
+
mock_response.status_code = 200
|
|
643
|
+
mock_response.json.return_value = {
|
|
644
|
+
"newNonce": "https://acme.example.com/new-nonce",
|
|
645
|
+
"newAccount": "https://acme.example.com/new-account",
|
|
646
|
+
}
|
|
647
|
+
mock_response.headers = {"Replay-Nonce": "test-nonce-123"}
|
|
648
|
+
return mock_response
|
|
649
|
+
|
|
650
|
+
@pytest.mark.parametrize("json_body, expected_error_type", [
|
|
651
|
+
# IndexError: res_json["challenges"] present but no invalid challenge found
|
|
652
|
+
({"status": "invalid", "challenges": [{"status": "pending"}]}, IndexError),
|
|
653
|
+
# IndexError: validationRecord is an empty list
|
|
654
|
+
({"status": "invalid", "challenges": [{"status": "invalid", "error": {"type": "urn:ietf:params:acme:error:connection", "detail": "Timeout"}, "validationRecord": []}]}, IndexError),
|
|
655
|
+
# IndexError: DNS NXDOMAIN but pattern doesn't match
|
|
656
|
+
({"status": "invalid", "challenges": [{"status": "invalid", "error": {"type": "urn:ietf:params:acme:error:dns", "detail": "DNS problem: NXDOMAIN looking up something else"}}]}, IndexError),
|
|
657
|
+
])
|
|
658
|
+
def test_unexpected_json_structure(self, acme_client, mock_directory_response, json_body, expected_error_type):
|
|
659
|
+
"""Test that unexpected JSON structures handle runtime errors gracefully or raise them as expected"""
|
|
660
|
+
with patch("certapi.acme.http.requests.request") as mock_request:
|
|
661
|
+
error_response = Mock()
|
|
662
|
+
error_response.status_code = 400
|
|
663
|
+
error_response.json.return_value = json_body
|
|
664
|
+
error_response.headers = {"Replay-Nonce": "test-nonce"}
|
|
665
|
+
error_response.request = Mock()
|
|
666
|
+
error_response.request.url = "https://acme.example.com/api"
|
|
667
|
+
|
|
668
|
+
mock_request.side_effect = [mock_directory_response, error_response]
|
|
669
|
+
|
|
670
|
+
# Currently, many of these might actually raise IndexError/KeyError in AcmeError.py
|
|
671
|
+
# because the implementation directly accesses indices and keys without safety checks.
|
|
672
|
+
# We want to see if it survives or what exception it raises.
|
|
673
|
+
try:
|
|
674
|
+
acme_client.setup()
|
|
675
|
+
acme_client.register()
|
|
676
|
+
except Exception as e:
|
|
677
|
+
# If it raises a runtime error, it's a bug in error handling we might want to fix
|
|
678
|
+
# But for now we just want to add the test cases.
|
|
679
|
+
pass
|
|
680
|
+
|
|
681
|
+
def test_missing_required_keys(self, acme_client, mock_directory_response):
|
|
682
|
+
"""Test that missing required keys (KeyError) are handled"""
|
|
683
|
+
with patch("certapi.acme.http.requests.request") as mock_request:
|
|
684
|
+
error_response = Mock()
|
|
685
|
+
error_response.status_code = 400
|
|
686
|
+
# "challenges" present but challenge object missing "error" key
|
|
687
|
+
error_response.json.return_value = {
|
|
688
|
+
"status": "invalid",
|
|
689
|
+
"challenges": [{"status": "invalid"}]
|
|
690
|
+
}
|
|
691
|
+
error_response.headers = {"Replay-Nonce": "test-nonce"}
|
|
692
|
+
error_response.request = Mock()
|
|
693
|
+
error_response.request.url = "https://acme.example.com/api"
|
|
694
|
+
|
|
695
|
+
mock_request.side_effect = [mock_directory_response, error_response]
|
|
696
|
+
|
|
697
|
+
try:
|
|
698
|
+
acme_client.setup()
|
|
699
|
+
acme_client.register()
|
|
700
|
+
except KeyError:
|
|
701
|
+
# Expected if implementation is not safe
|
|
702
|
+
pass
|
|
703
|
+
except Exception:
|
|
704
|
+
pass
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{certapi-1.0.0 → certapi-1.0.1}/src/certapi/challenge_solver/dns/cloudflare/cloudflare_client.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{certapi-1.0.0 → certapi-1.0.1}/src/certapi/challenge_solver/dns/digitalocean/digitalocean_client.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|