mailsocket 0.1.1__tar.gz → 0.1.3__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.
- {mailsocket-0.1.1 → mailsocket-0.1.3}/PKG-INFO +10 -4
- {mailsocket-0.1.1 → mailsocket-0.1.3}/README.md +9 -3
- {mailsocket-0.1.1 → mailsocket-0.1.3}/mailsocket/__init__.py +1 -1
- {mailsocket-0.1.1 → mailsocket-0.1.3}/mailsocket/client.py +50 -8
- {mailsocket-0.1.1 → mailsocket-0.1.3}/mailsocket.egg-info/PKG-INFO +10 -4
- {mailsocket-0.1.1 → mailsocket-0.1.3}/pyproject.toml +1 -1
- {mailsocket-0.1.1 → mailsocket-0.1.3}/tests/test_client.py +221 -2
- {mailsocket-0.1.1 → mailsocket-0.1.3}/LICENSE +0 -0
- {mailsocket-0.1.1 → mailsocket-0.1.3}/mailsocket/errors.py +0 -0
- {mailsocket-0.1.1 → mailsocket-0.1.3}/mailsocket.egg-info/SOURCES.txt +0 -0
- {mailsocket-0.1.1 → mailsocket-0.1.3}/mailsocket.egg-info/dependency_links.txt +0 -0
- {mailsocket-0.1.1 → mailsocket-0.1.3}/mailsocket.egg-info/requires.txt +0 -0
- {mailsocket-0.1.1 → mailsocket-0.1.3}/mailsocket.egg-info/top_level.txt +0 -0
- {mailsocket-0.1.1 → mailsocket-0.1.3}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mailsocket
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: Official Python client for the mailsocket REST API — one line to wait for an OTP.
|
|
5
5
|
Author: mailsocket
|
|
6
6
|
License-Expression: MIT
|
|
@@ -112,9 +112,15 @@ Semantics:
|
|
|
112
112
|
## Develop
|
|
113
113
|
|
|
114
114
|
```bash
|
|
115
|
-
cd
|
|
115
|
+
cd python
|
|
116
116
|
python -m pytest -q
|
|
117
117
|
```
|
|
118
118
|
|
|
119
|
-
Tests
|
|
120
|
-
scripted responder,
|
|
119
|
+
Tests make no external or live-API requests. Most monkeypatch
|
|
120
|
+
`urllib.request.urlopen` with a scripted responder, and a few start a real local
|
|
121
|
+
`http.server` on `127.0.0.1` to check actual request behaviour, e.g. that an
|
|
122
|
+
invalid id never reaches the network.
|
|
123
|
+
|
|
124
|
+
## Changelog
|
|
125
|
+
|
|
126
|
+
- **0.1.3** — `_seg` now rejects an empty id or a bare `.`/`..` id with a `MailsocketError` (`code="invalid_id"`) before any request is made, matching the TypeScript SDK's contract.
|
|
@@ -86,9 +86,15 @@ Semantics:
|
|
|
86
86
|
## Develop
|
|
87
87
|
|
|
88
88
|
```bash
|
|
89
|
-
cd
|
|
89
|
+
cd python
|
|
90
90
|
python -m pytest -q
|
|
91
91
|
```
|
|
92
92
|
|
|
93
|
-
Tests
|
|
94
|
-
scripted responder,
|
|
93
|
+
Tests make no external or live-API requests. Most monkeypatch
|
|
94
|
+
`urllib.request.urlopen` with a scripted responder, and a few start a real local
|
|
95
|
+
`http.server` on `127.0.0.1` to check actual request behaviour, e.g. that an
|
|
96
|
+
invalid id never reaches the network.
|
|
97
|
+
|
|
98
|
+
## Changelog
|
|
99
|
+
|
|
100
|
+
- **0.1.3** — `_seg` now rejects an empty id or a bare `.`/`..` id with a `MailsocketError` (`code="invalid_id"`) before any request is made, matching the TypeScript SDK's contract.
|
|
@@ -29,6 +29,24 @@ def _sleep(seconds: float) -> None:
|
|
|
29
29
|
_time.sleep(seconds)
|
|
30
30
|
|
|
31
31
|
|
|
32
|
+
def _seg(value) -> str:
|
|
33
|
+
"""Percent-encode an id as ONE path segment (``/``, ``?``, ``#``, ``..`` safe).
|
|
34
|
+
|
|
35
|
+
An empty id and the bare dot segments ``.`` / ``..`` are rejected with a
|
|
36
|
+
:class:`MailsocketError` before any request is made (same contract as the
|
|
37
|
+
TypeScript SDK's ``seg``): urllib's request machinery would otherwise let
|
|
38
|
+
them collapse into a different path than the caller intended. No real id
|
|
39
|
+
is ever one of these.
|
|
40
|
+
"""
|
|
41
|
+
raw = str(value)
|
|
42
|
+
if raw in ("", ".", ".."):
|
|
43
|
+
raise MailsocketError(
|
|
44
|
+
f"Invalid id {raw!r}: ids must be non-empty and not \".\" or \"..\".",
|
|
45
|
+
code="invalid_id",
|
|
46
|
+
)
|
|
47
|
+
return urllib.parse.quote(raw, safe="")
|
|
48
|
+
|
|
49
|
+
|
|
32
50
|
class Page:
|
|
33
51
|
"""A page of results plus its pagination metadata.
|
|
34
52
|
|
|
@@ -94,12 +112,32 @@ class Client:
|
|
|
94
112
|
base_url: str = DEFAULT_BASE_URL,
|
|
95
113
|
*,
|
|
96
114
|
request_timeout: float = 30.0,
|
|
115
|
+
extra_headers: dict[str, str] | None = None,
|
|
116
|
+
retry_wait_on_429: bool = True,
|
|
97
117
|
):
|
|
118
|
+
"""Create a client.
|
|
119
|
+
|
|
120
|
+
``extra_headers`` (optional) are sent on every request, e.g. a fixed
|
|
121
|
+
``Host`` when calling the API through an internal address. They can
|
|
122
|
+
never override ``Authorization``; that header always carries
|
|
123
|
+
``api_key``.
|
|
124
|
+
|
|
125
|
+
``retry_wait_on_429`` (default True): the ``wait*`` helpers sleep for
|
|
126
|
+
``Retry-After`` and retry on HTTP 429 until their deadline. Pass False
|
|
127
|
+
to raise :class:`RateLimited` immediately instead (used by the shared
|
|
128
|
+
remote MCP server so a rate-limited caller doesn't hold a worker slot).
|
|
129
|
+
"""
|
|
98
130
|
if not api_key:
|
|
99
131
|
raise ValueError("api_key is required")
|
|
100
132
|
self._api_key = api_key
|
|
101
133
|
self._base_url = base_url.rstrip("/")
|
|
102
134
|
self._request_timeout = request_timeout
|
|
135
|
+
self._retry_wait_on_429 = retry_wait_on_429
|
|
136
|
+
self._extra_headers = {
|
|
137
|
+
str(name): str(value)
|
|
138
|
+
for name, value in (extra_headers or {}).items()
|
|
139
|
+
if str(name).lower() != "authorization"
|
|
140
|
+
}
|
|
103
141
|
|
|
104
142
|
# -- public API -----------------------------------------------------------
|
|
105
143
|
|
|
@@ -113,11 +151,11 @@ class Client:
|
|
|
113
151
|
return Page(payload["data"], payload.get("pagination"))
|
|
114
152
|
|
|
115
153
|
def get_inbox(self, inbox_id: str) -> dict:
|
|
116
|
-
payload = self._request("GET", f"/inboxes/{inbox_id}")
|
|
154
|
+
payload = self._request("GET", f"/inboxes/{_seg(inbox_id)}")
|
|
117
155
|
return payload["data"]
|
|
118
156
|
|
|
119
157
|
def delete_inbox(self, inbox_id: str) -> None:
|
|
120
|
-
self._request("DELETE", f"/inboxes/{inbox_id}")
|
|
158
|
+
self._request("DELETE", f"/inboxes/{_seg(inbox_id)}")
|
|
121
159
|
|
|
122
160
|
def list_messages(
|
|
123
161
|
self,
|
|
@@ -135,15 +173,15 @@ class Client:
|
|
|
135
173
|
params["subject_contains"] = subject_contains
|
|
136
174
|
if sender:
|
|
137
175
|
params["from"] = sender # the API names this filter ``from``
|
|
138
|
-
payload = self._request("GET", f"/inboxes/{inbox_id}/messages", params=params)
|
|
176
|
+
payload = self._request("GET", f"/inboxes/{_seg(inbox_id)}/messages", params=params)
|
|
139
177
|
return Page(payload["data"], payload.get("pagination"))
|
|
140
178
|
|
|
141
179
|
def get_latest(self, inbox_id: str) -> dict:
|
|
142
|
-
payload = self._request("GET", f"/inboxes/{inbox_id}/messages/latest")
|
|
180
|
+
payload = self._request("GET", f"/inboxes/{_seg(inbox_id)}/messages/latest")
|
|
143
181
|
return payload["data"]
|
|
144
182
|
|
|
145
183
|
def get_message(self, message_id: str) -> dict:
|
|
146
|
-
payload = self._request("GET", f"/messages/{message_id}")
|
|
184
|
+
payload = self._request("GET", f"/messages/{_seg(message_id)}")
|
|
147
185
|
return payload["data"]
|
|
148
186
|
|
|
149
187
|
# -- the moat -------------------------------------------------------------
|
|
@@ -215,7 +253,7 @@ class Client:
|
|
|
215
253
|
|
|
216
254
|
status, headers, body = self._request_http(
|
|
217
255
|
"GET",
|
|
218
|
-
f"/inboxes/{inbox_id}/messages/wait",
|
|
256
|
+
f"/inboxes/{_seg(inbox_id)}/messages/wait",
|
|
219
257
|
params=params,
|
|
220
258
|
socket_timeout=server_timeout + WAIT_SOCKET_BUFFER,
|
|
221
259
|
)
|
|
@@ -225,6 +263,8 @@ class Client:
|
|
|
225
263
|
if status == 204:
|
|
226
264
|
continue
|
|
227
265
|
if status == 429:
|
|
266
|
+
if not self._retry_wait_on_429:
|
|
267
|
+
self._raise_for_status(status, headers, payload)
|
|
228
268
|
retry_after = self._retry_after(headers)
|
|
229
269
|
sleep_for = retry_after if retry_after is not None else DEFAULT_RETRY_AFTER
|
|
230
270
|
if _monotonic() + sleep_for > deadline:
|
|
@@ -247,10 +287,12 @@ class Client:
|
|
|
247
287
|
if params:
|
|
248
288
|
url += "?" + urllib.parse.urlencode(params, doseq=True)
|
|
249
289
|
headers = {
|
|
250
|
-
"Authorization": f"Bearer {self._api_key}",
|
|
251
290
|
"Accept": "application/json",
|
|
252
|
-
"User-Agent": "mailsocket-python/0.1.
|
|
291
|
+
"User-Agent": "mailsocket-python/0.1.3",
|
|
253
292
|
}
|
|
293
|
+
headers.update(self._extra_headers)
|
|
294
|
+
# Set last so nothing in extra_headers can ever replace it.
|
|
295
|
+
headers["Authorization"] = f"Bearer {self._api_key}"
|
|
254
296
|
data = None
|
|
255
297
|
if json_body is not None:
|
|
256
298
|
data = json.dumps(json_body).encode("utf-8")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mailsocket
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: Official Python client for the mailsocket REST API — one line to wait for an OTP.
|
|
5
5
|
Author: mailsocket
|
|
6
6
|
License-Expression: MIT
|
|
@@ -112,9 +112,15 @@ Semantics:
|
|
|
112
112
|
## Develop
|
|
113
113
|
|
|
114
114
|
```bash
|
|
115
|
-
cd
|
|
115
|
+
cd python
|
|
116
116
|
python -m pytest -q
|
|
117
117
|
```
|
|
118
118
|
|
|
119
|
-
Tests
|
|
120
|
-
scripted responder,
|
|
119
|
+
Tests make no external or live-API requests. Most monkeypatch
|
|
120
|
+
`urllib.request.urlopen` with a scripted responder, and a few start a real local
|
|
121
|
+
`http.server` on `127.0.0.1` to check actual request behaviour, e.g. that an
|
|
122
|
+
invalid id never reaches the network.
|
|
123
|
+
|
|
124
|
+
## Changelog
|
|
125
|
+
|
|
126
|
+
- **0.1.3** — `_seg` now rejects an empty id or a bare `.`/`..` id with a `MailsocketError` (`code="invalid_id"`) before any request is made, matching the TypeScript SDK's contract.
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "mailsocket"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.3"
|
|
8
8
|
description = "Official Python client for the mailsocket REST API — one line to wait for an OTP."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -199,6 +199,89 @@ def test_wait_429_parses_subcode_and_retries(monkeypatch):
|
|
|
199
199
|
assert len(transport.calls) == 2
|
|
200
200
|
|
|
201
201
|
|
|
202
|
+
HOSTILE_IDS = [
|
|
203
|
+
("in?x=1", "in%3Fx%3D1"),
|
|
204
|
+
("in#frag", "in%23frag"),
|
|
205
|
+
("../../admin", "..%2F..%2Fadmin"),
|
|
206
|
+
("a/b", "a%2Fb"),
|
|
207
|
+
("sp ce&%", "sp%20ce%26%25"),
|
|
208
|
+
]
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@pytest.mark.parametrize("raw, encoded", HOSTILE_IDS)
|
|
212
|
+
def test_ids_are_quoted_as_one_path_segment_everywhere(monkeypatch, raw, encoded):
|
|
213
|
+
"""opus P3-2: every id interpolated into a URL path is quote(..., safe="")."""
|
|
214
|
+
import urllib.parse
|
|
215
|
+
|
|
216
|
+
page = {"data": [], "pagination": {}}
|
|
217
|
+
responses = [
|
|
218
|
+
(200, {}, {"data": {"id": "x"}}), # get_inbox
|
|
219
|
+
(204, {}, None), # delete_inbox
|
|
220
|
+
(200, {}, page), # list_messages
|
|
221
|
+
(200, {}, {"data": {"id": "x"}}), # get_latest
|
|
222
|
+
(200, {}, {"data": {"id": "x"}}), # get_message
|
|
223
|
+
(200, {}, {"data": OTP_MESSAGE}), # wait_for_otp
|
|
224
|
+
(200, {}, {"data": LINK_MESSAGE}), # wait_for_link
|
|
225
|
+
(200, {}, {"data": OTP_MESSAGE}), # wait
|
|
226
|
+
]
|
|
227
|
+
client, transport = make_client(responses)
|
|
228
|
+
_monkeypatch_transport(monkeypatch, client, transport)
|
|
229
|
+
|
|
230
|
+
client.get_inbox(raw)
|
|
231
|
+
client.delete_inbox(raw)
|
|
232
|
+
client.list_messages(raw, has_otp=True)
|
|
233
|
+
client.get_latest(raw)
|
|
234
|
+
client.get_message(raw)
|
|
235
|
+
client.wait_for_otp(raw, timeout=5)
|
|
236
|
+
client.wait_for_link(raw, timeout=5)
|
|
237
|
+
client.wait(raw, timeout=5)
|
|
238
|
+
|
|
239
|
+
expected_paths = [
|
|
240
|
+
f"/api/v1/inboxes/{encoded}",
|
|
241
|
+
f"/api/v1/inboxes/{encoded}",
|
|
242
|
+
f"/api/v1/inboxes/{encoded}/messages",
|
|
243
|
+
f"/api/v1/inboxes/{encoded}/messages/latest",
|
|
244
|
+
f"/api/v1/messages/{encoded}",
|
|
245
|
+
f"/api/v1/inboxes/{encoded}/messages/wait",
|
|
246
|
+
f"/api/v1/inboxes/{encoded}/messages/wait",
|
|
247
|
+
f"/api/v1/inboxes/{encoded}/messages/wait",
|
|
248
|
+
]
|
|
249
|
+
assert len(transport.calls) == len(expected_paths)
|
|
250
|
+
for (_, url), expected in zip(transport.calls, expected_paths):
|
|
251
|
+
parts = urllib.parse.urlsplit(url)
|
|
252
|
+
assert parts.netloc == "example.test"
|
|
253
|
+
assert parts.path == expected, url
|
|
254
|
+
assert parts.fragment == ""
|
|
255
|
+
# the id never leaks into the query string
|
|
256
|
+
assert "x=1" not in parts.query and "frag" not in parts.query
|
|
257
|
+
# a normal id is unchanged
|
|
258
|
+
assert mailsocket.client._seg("inbox_abc123") == "inbox_abc123"
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def test_wait_429_raises_immediately_when_retry_disabled(monkeypatch):
|
|
262
|
+
"""retry_wait_on_429=False (remote MCP): no sleep, one call, RateLimited."""
|
|
263
|
+
slept = []
|
|
264
|
+
monkeypatch.setattr(mailsocket.client, "_sleep", lambda s: slept.append(s))
|
|
265
|
+
responses = [
|
|
266
|
+
(
|
|
267
|
+
429,
|
|
268
|
+
{"Retry-After": "2"},
|
|
269
|
+
{"error": {"code": "too_many_wait_requests", "message": "Too many", "fields": {}}},
|
|
270
|
+
),
|
|
271
|
+
(200, {}, {"data": OTP_MESSAGE}),
|
|
272
|
+
]
|
|
273
|
+
client, transport = make_client(responses, retry_wait_on_429=False)
|
|
274
|
+
_monkeypatch_transport(monkeypatch, client, transport)
|
|
275
|
+
|
|
276
|
+
with pytest.raises(RateLimited) as exc_info:
|
|
277
|
+
client.wait_for_otp("inbox_abc", timeout=60)
|
|
278
|
+
|
|
279
|
+
assert exc_info.value.subcode == "too_many_wait_requests"
|
|
280
|
+
assert exc_info.value.retry_after == 2.0
|
|
281
|
+
assert len(transport.calls) == 1
|
|
282
|
+
assert slept == []
|
|
283
|
+
|
|
284
|
+
|
|
202
285
|
def test_wait_429_raises_rate_limited_with_subcode_on_non_wait_endpoint(monkeypatch):
|
|
203
286
|
responses = [
|
|
204
287
|
(
|
|
@@ -352,18 +435,88 @@ def test_error_types_are_mailsocket_subclasses():
|
|
|
352
435
|
assert issubclass(WaitTimeout, MailsocketError)
|
|
353
436
|
|
|
354
437
|
|
|
438
|
+
# -- F2: _seg rejects "" / "." / ".." before any request (parity with TS) -----
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def test_seg_rejects_empty_and_dot_segments():
|
|
442
|
+
for raw in ("", ".", ".."):
|
|
443
|
+
with pytest.raises(MailsocketError) as exc_info:
|
|
444
|
+
mailsocket.client._seg(raw)
|
|
445
|
+
assert exc_info.value.code == "invalid_id"
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
INVALID_IDS = ["", ".", ".."]
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
@pytest.mark.parametrize("raw", INVALID_IDS)
|
|
452
|
+
def test_invalid_id_rejected_before_any_request_hits_real_server(raw):
|
|
453
|
+
"""Nothing reaches the network: a real local http.server never sees a request."""
|
|
454
|
+
import http.server
|
|
455
|
+
import threading
|
|
456
|
+
|
|
457
|
+
seen = []
|
|
458
|
+
|
|
459
|
+
class Handler(http.server.BaseHTTPRequestHandler):
|
|
460
|
+
def _record(self):
|
|
461
|
+
seen.append(self.path)
|
|
462
|
+
self.send_response(200)
|
|
463
|
+
self.send_header("Content-Type", "application/json")
|
|
464
|
+
self.end_headers()
|
|
465
|
+
self.wfile.write(json.dumps({"data": {"id": "x"}}).encode())
|
|
466
|
+
|
|
467
|
+
def do_GET(self):
|
|
468
|
+
self._record()
|
|
469
|
+
|
|
470
|
+
def do_DELETE(self):
|
|
471
|
+
self._record()
|
|
472
|
+
|
|
473
|
+
def do_POST(self):
|
|
474
|
+
self._record()
|
|
475
|
+
|
|
476
|
+
def log_message(self, format, *args):
|
|
477
|
+
pass # keep test output clean
|
|
478
|
+
|
|
479
|
+
server = http.server.HTTPServer(("127.0.0.1", 0), Handler)
|
|
480
|
+
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
481
|
+
thread.start()
|
|
482
|
+
try:
|
|
483
|
+
base_url = f"http://127.0.0.1:{server.server_port}/api/v1"
|
|
484
|
+
client = Client("ms_live_test1234567890", base_url=base_url)
|
|
485
|
+
|
|
486
|
+
calls = [
|
|
487
|
+
lambda: client.get_inbox(raw),
|
|
488
|
+
lambda: client.delete_inbox(raw),
|
|
489
|
+
lambda: client.list_messages(raw),
|
|
490
|
+
lambda: client.get_latest(raw),
|
|
491
|
+
lambda: client.get_message(raw),
|
|
492
|
+
lambda: client.wait_for_otp(raw, timeout=5),
|
|
493
|
+
lambda: client.wait_for_link(raw, timeout=5),
|
|
494
|
+
lambda: client.wait(raw, timeout=5),
|
|
495
|
+
]
|
|
496
|
+
for call in calls:
|
|
497
|
+
with pytest.raises(MailsocketError) as exc_info:
|
|
498
|
+
call()
|
|
499
|
+
assert exc_info.value.code == "invalid_id"
|
|
500
|
+
assert not isinstance(exc_info.value, WaitTimeout)
|
|
501
|
+
|
|
502
|
+
assert seen == [] # nothing ever reached the network
|
|
503
|
+
finally:
|
|
504
|
+
server.shutdown()
|
|
505
|
+
thread.join(timeout=5)
|
|
506
|
+
|
|
507
|
+
|
|
355
508
|
def test_version_exported():
|
|
356
509
|
assert hasattr(mailsocket, "__version__")
|
|
357
510
|
|
|
358
511
|
|
|
359
512
|
def test_metadata_consistency_across_files():
|
|
360
|
-
"""0.1.
|
|
513
|
+
"""0.1.3 must match everywhere: pyproject, __version__, user agent."""
|
|
361
514
|
import re
|
|
362
515
|
import tomllib
|
|
363
516
|
from pathlib import Path
|
|
364
517
|
|
|
365
518
|
root = Path(__file__).resolve().parents[1]
|
|
366
|
-
expected = "0.1.
|
|
519
|
+
expected = "0.1.3"
|
|
367
520
|
|
|
368
521
|
pyproject = tomllib.loads((root / "pyproject.toml").read_text())
|
|
369
522
|
assert pyproject["project"]["version"] == expected
|
|
@@ -373,3 +526,69 @@ def test_metadata_consistency_across_files():
|
|
|
373
526
|
match = re.search(r'"mailsocket-python/([^"]+)"', client_src)
|
|
374
527
|
assert match, "User-Agent literal not found in client.py"
|
|
375
528
|
assert match.group(1) == expected
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
# -- extra_headers ------------------------------------------------------------
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
class _HeaderCapture:
|
|
535
|
+
def __init__(self):
|
|
536
|
+
self.headers = []
|
|
537
|
+
|
|
538
|
+
def __call__(self, request, timeout=None):
|
|
539
|
+
self.headers.append({k.lower(): v for k, v in request.header_items()})
|
|
540
|
+
|
|
541
|
+
class _Resp:
|
|
542
|
+
headers = {}
|
|
543
|
+
|
|
544
|
+
def getcode(self):
|
|
545
|
+
return 200
|
|
546
|
+
|
|
547
|
+
def read(self):
|
|
548
|
+
return json.dumps({"data": {"id": "inbox_1"}}).encode()
|
|
549
|
+
|
|
550
|
+
def __enter__(self):
|
|
551
|
+
return self
|
|
552
|
+
|
|
553
|
+
def __exit__(self, *exc_info):
|
|
554
|
+
return False
|
|
555
|
+
|
|
556
|
+
return _Resp()
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def test_extra_headers_are_sent(monkeypatch):
|
|
560
|
+
capture = _HeaderCapture()
|
|
561
|
+
monkeypatch.setattr("urllib.request.urlopen", capture)
|
|
562
|
+
client = Client(
|
|
563
|
+
"ms_live_test1234567890",
|
|
564
|
+
base_url="http://web:8000/api/v1",
|
|
565
|
+
extra_headers={"Host": "dash.mailsocket.app", "X-Forwarded-Proto": "https"},
|
|
566
|
+
)
|
|
567
|
+
client.get_inbox("inbox_1")
|
|
568
|
+
sent = capture.headers[0]
|
|
569
|
+
assert sent["host"] == "dash.mailsocket.app"
|
|
570
|
+
assert sent["x-forwarded-proto"] == "https"
|
|
571
|
+
assert sent["authorization"] == "Bearer ms_live_test1234567890"
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
@pytest.mark.parametrize("name", ["Authorization", "authorization", "AUTHORIZATION"])
|
|
575
|
+
def test_extra_headers_cannot_override_authorization(monkeypatch, name):
|
|
576
|
+
capture = _HeaderCapture()
|
|
577
|
+
monkeypatch.setattr("urllib.request.urlopen", capture)
|
|
578
|
+
client = Client(
|
|
579
|
+
"ms_live_test1234567890",
|
|
580
|
+
base_url="https://example.test/api/v1",
|
|
581
|
+
extra_headers={name: "Bearer ms_live_ATTACKER_KEY_000"},
|
|
582
|
+
)
|
|
583
|
+
client.get_inbox("inbox_1")
|
|
584
|
+
auths = [v for k, v in capture.headers[0].items() if k == "authorization"]
|
|
585
|
+
assert auths == ["Bearer ms_live_test1234567890"]
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def test_no_extra_headers_is_backwards_compatible(monkeypatch):
|
|
589
|
+
capture = _HeaderCapture()
|
|
590
|
+
monkeypatch.setattr("urllib.request.urlopen", capture)
|
|
591
|
+
Client("ms_live_test1234567890", base_url="https://example.test/api/v1").get_inbox("i")
|
|
592
|
+
sent = capture.headers[0]
|
|
593
|
+
assert set(sent) >= {"authorization", "accept", "user-agent"}
|
|
594
|
+
assert "x-forwarded-proto" not in sent
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|