wop-python-sdk 0.1.0__tar.gz → 0.1.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.
Files changed (38) hide show
  1. {wop_python_sdk-0.1.0/src/wop_python_sdk.egg-info → wop_python_sdk-0.1.1}/PKG-INFO +1 -1
  2. {wop_python_sdk-0.1.0 → wop_python_sdk-0.1.1}/pyproject.toml +1 -1
  3. {wop_python_sdk-0.1.0 → wop_python_sdk-0.1.1/src/wop_python_sdk.egg-info}/PKG-INFO +1 -1
  4. {wop_python_sdk-0.1.0 → wop_python_sdk-0.1.1}/src/wop_sdk/__init__.py +1 -1
  5. {wop_python_sdk-0.1.0 → wop_python_sdk-0.1.1}/src/wop_sdk/encoding.py +19 -3
  6. {wop_python_sdk-0.1.0 → wop_python_sdk-0.1.1}/src/wop_sdk/envelope.py +5 -1
  7. {wop_python_sdk-0.1.0 → wop_python_sdk-0.1.1}/src/wop_sdk/transports/__init__.py +26 -3
  8. {wop_python_sdk-0.1.0 → wop_python_sdk-0.1.1}/src/wop_sdk/transports/httpx_transport.py +15 -8
  9. {wop_python_sdk-0.1.0 → wop_python_sdk-0.1.1}/src/wop_sdk/transports/requests_transport.py +14 -4
  10. {wop_python_sdk-0.1.0 → wop_python_sdk-0.1.1}/src/wop_sdk/transports/urllib_transport.py +19 -5
  11. {wop_python_sdk-0.1.0 → wop_python_sdk-0.1.1}/tests/test_digest.py +26 -23
  12. {wop_python_sdk-0.1.0 → wop_python_sdk-0.1.1}/tests/test_encoding.py +33 -7
  13. {wop_python_sdk-0.1.0 → wop_python_sdk-0.1.1}/tests/test_envelope.py +9 -0
  14. wop_python_sdk-0.1.1/tests/test_transports.py +307 -0
  15. {wop_python_sdk-0.1.0 → wop_python_sdk-0.1.1}/tests/test_transports_real.py +45 -8
  16. wop_python_sdk-0.1.0/tests/test_transports.py +0 -144
  17. {wop_python_sdk-0.1.0 → wop_python_sdk-0.1.1}/LICENSE +0 -0
  18. {wop_python_sdk-0.1.0 → wop_python_sdk-0.1.1}/README.md +0 -0
  19. {wop_python_sdk-0.1.0 → wop_python_sdk-0.1.1}/setup.cfg +0 -0
  20. {wop_python_sdk-0.1.0 → wop_python_sdk-0.1.1}/src/wop_python_sdk.egg-info/SOURCES.txt +0 -0
  21. {wop_python_sdk-0.1.0 → wop_python_sdk-0.1.1}/src/wop_python_sdk.egg-info/dependency_links.txt +0 -0
  22. {wop_python_sdk-0.1.0 → wop_python_sdk-0.1.1}/src/wop_python_sdk.egg-info/requires.txt +0 -0
  23. {wop_python_sdk-0.1.0 → wop_python_sdk-0.1.1}/src/wop_python_sdk.egg-info/top_level.txt +0 -0
  24. {wop_python_sdk-0.1.0 → wop_python_sdk-0.1.1}/src/wop_sdk/canonical.py +0 -0
  25. {wop_python_sdk-0.1.0 → wop_python_sdk-0.1.1}/src/wop_sdk/client.py +0 -0
  26. {wop_python_sdk-0.1.0 → wop_python_sdk-0.1.1}/src/wop_sdk/digest.py +0 -0
  27. {wop_python_sdk-0.1.0 → wop_python_sdk-0.1.1}/src/wop_sdk/errors.py +0 -0
  28. {wop_python_sdk-0.1.0 → wop_python_sdk-0.1.1}/src/wop_sdk/keys.py +0 -0
  29. {wop_python_sdk-0.1.0 → wop_python_sdk-0.1.1}/src/wop_sdk/signature.py +0 -0
  30. {wop_python_sdk-0.1.0 → wop_python_sdk-0.1.1}/src/wop_sdk/sm2crypto.py +0 -0
  31. {wop_python_sdk-0.1.0 → wop_python_sdk-0.1.1}/src/wop_sdk/sm4gcm.py +0 -0
  32. {wop_python_sdk-0.1.0 → wop_python_sdk-0.1.1}/src/wop_sdk/suites.py +0 -0
  33. {wop_python_sdk-0.1.0 → wop_python_sdk-0.1.1}/tests/test_canonical.py +0 -0
  34. {wop_python_sdk-0.1.0 → wop_python_sdk-0.1.1}/tests/test_client.py +0 -0
  35. {wop_python_sdk-0.1.0 → wop_python_sdk-0.1.1}/tests/test_coverage_gaps.py +0 -0
  36. {wop_python_sdk-0.1.0 → wop_python_sdk-0.1.1}/tests/test_keys.py +0 -0
  37. {wop_python_sdk-0.1.0 → wop_python_sdk-0.1.1}/tests/test_signature.py +0 -0
  38. {wop_python_sdk-0.1.0 → wop_python_sdk-0.1.1}/tests/test_suites.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wop-python-sdk
3
- Version: 0.1.0
3
+ Version: 0.1.1
4
4
  Summary: WOP 商户侧官方 Python SDK:协议核心(签名/摘要/数字信封/验签解密)+ 可插拔 HTTP 适配层
5
5
  Author: wop-platform
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "wop-python-sdk"
7
- version = "0.1.0"
7
+ version = "0.1.1"
8
8
  description = "WOP 商户侧官方 Python SDK:协议核心(签名/摘要/数字信封/验签解密)+ 可插拔 HTTP 适配层"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wop-python-sdk
3
- Version: 0.1.0
3
+ Version: 0.1.1
4
4
  Summary: WOP 商户侧官方 Python SDK:协议核心(签名/摘要/数字信封/验签解密)+ 可插拔 HTTP 适配层
5
5
  Author: wop-platform
6
6
  License: MIT
@@ -17,7 +17,7 @@ from .errors import (
17
17
  )
18
18
  from .suites import Suite, parse_suite
19
19
 
20
- __version__ = "0.1.0"
20
+ __version__ = "0.1.1"
21
21
 
22
22
  __all__ = [
23
23
  "WopClient",
@@ -10,6 +10,14 @@ from typing import Optional
10
10
 
11
11
  _B64URL_ALPHABET = re.compile(r"^[A-Za-z0-9_-]+$")
12
12
 
13
+ # b64url 字符 → 6bit 索引(RFC 4648 §4 字母表:A-Z=0-25, a-z=26-51, 0-9=52-61, '-'=62, '_'=63)
14
+ _B64URL_INDEX = {
15
+ ch: i
16
+ for i, ch in enumerate(
17
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
18
+ )
19
+ }
20
+
13
21
  # Java URLEncoder 的保留集:字母数字与 . - * _(空格单独处理为 %20)
14
22
  _SAFE_CHARS = frozenset(
15
23
  "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._*-"
@@ -24,11 +32,13 @@ def b64url_encode(data: bytes) -> str:
24
32
  def b64url_decode(text: str) -> bytes:
25
33
  """base64url 无填充字符串 → 字节。
26
34
 
27
- 严格模式(F7/D10):
35
+ 严格模式(F7/D10,语义锚 = Go base64.RawURLEncoding.Strict(),RFC 4648 §3.5):
28
36
  - 拒绝空串;
29
37
  - 拒绝 '=' 填充字符;
30
38
  - 拒绝字母表外字符(含 '+' '/' 空白等);
31
- - 拒绝长度 % 4 == 1(不可能的 base64 长度)。
39
+ - 拒绝长度 % 4 == 1(不可能的 base64 长度);
40
+ - 拒绝非 canonical 尾随位:len % 4 == 2 时尾字符低 4 位须为零,
41
+ len % 4 == 3 时尾字符低 2 位须为零(宽容解码会静默丢位,须显式拒绝)。
32
42
  """
33
43
  if not text:
34
44
  raise ValueError("base64url 输入为空")
@@ -36,8 +46,14 @@ def b64url_decode(text: str) -> bytes:
36
46
  raise ValueError("base64url 严格模式:拒绝 '=' 填充")
37
47
  if not _B64URL_ALPHABET.match(text):
38
48
  raise ValueError("base64url 字母表外字符")
39
- if len(text) % 4 == 1:
49
+ rem = len(text) % 4
50
+ if rem == 1:
40
51
  raise ValueError("base64url 长度非法(% 4 == 1)")
52
+ # RFC 4648 §3.5:尾字符低位是"丢弃位",非零即非 canonical 编码
53
+ if rem == 2 and _B64URL_INDEX[text[-1]] & 0xF:
54
+ raise ValueError("base64url 尾随位非 canonical(len % 4 == 2,尾字符低 4 位须为零)")
55
+ if rem == 3 and _B64URL_INDEX[text[-1]] & 0x3:
56
+ raise ValueError("base64url 尾随位非 canonical(len % 4 == 3,尾字符低 2 位须为零)")
41
57
  return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
42
58
 
43
59
 
@@ -151,7 +151,11 @@ def open_l2(suite: Suite, wrap_priv: KeyMaterial, wire_body: bytes, dek_b64u: st
151
151
  wrapped = b64url_decode(dek_b64u)
152
152
  except ValueError:
153
153
  raise DecryptError() from None
154
- payload = unwrap_dek(suite, wrap_priv, wrapped).decode("utf-8", errors="strict")
154
+ try:
155
+ payload = unwrap_dek(suite, wrap_priv, wrapped).decode("utf-8", errors="strict")
156
+ except UnicodeDecodeError:
157
+ # 解包成功但载荷非 UTF-8 → 与解包失败同归模糊(I7),不得向商户层逃逸
158
+ raise DecryptError() from None
155
159
  key, iv = parse_dek_payload(suite, payload)
156
160
  try:
157
161
  obj = json.loads(wire_body)
@@ -3,14 +3,37 @@
3
3
 
4
4
  - ``Transport``:协议接口,商户自带栈时可直接实现或消费 RequestDraft;
5
5
  - ``send_draft``:RequestDraft → Transport(URL 拼接归此,适配器只面对完整请求);
6
+ - ``MAX_RESPONSE_BYTES`` / ``read_capped``:响应体 11MB 上限,流式读取中生效;
6
7
  - stdlib urllib 适配器随主包;httpx / requests 适配器为 peer 依赖(extras)。
7
8
  """
8
9
  from dataclasses import dataclass
9
- from typing import Dict, Optional, Protocol, runtime_checkable
10
+ from typing import Dict, Iterable, Optional, Protocol, runtime_checkable
10
11
 
11
12
  from ..client import RequestDraft
12
-
13
- __all__ = ["HttpResponse", "Transport", "UrllibTransport", "send_draft"]
13
+ from ..errors import ProtocolFormatError
14
+
15
+ __all__ = ["HttpResponse", "MAX_RESPONSE_BYTES", "Transport", "UrllibTransport", "read_capped", "send_draft"]
16
+
17
+ # 响应体上限 11MB:与网关 maxContentLength 及各语言 SDK(dotnet/Go 11<<20)对齐;
18
+ # 必须在读取过程中生效(流式计数),而非整体缓冲后检查
19
+ MAX_RESPONSE_BYTES = 11 << 20
20
+ _READ_CHUNK = 1 << 16
21
+
22
+
23
+ def read_capped(chunks: Iterable[bytes]) -> bytes:
24
+ """流式消费响应体分块并累计;累计越上限即刻抛 ProtocolFormatError。
25
+
26
+ 逐块检查(读取过程中生效):任何一 chunk 使累计超过 MAX_RESPONSE_BYTES 即中断,
27
+ 不再把无限/超大响应整体缓冲进内存。
28
+ """
29
+ buf = bytearray()
30
+ for chunk in chunks:
31
+ buf += chunk
32
+ if len(buf) > MAX_RESPONSE_BYTES:
33
+ raise ProtocolFormatError(
34
+ "响应体超过传输层上限 %d 字节" % MAX_RESPONSE_BYTES
35
+ )
36
+ return bytes(buf)
14
37
 
15
38
 
16
39
  @dataclass
@@ -2,11 +2,15 @@
2
2
  """httpx peer 适配器(extras:``pip install 'wop-sdk[httpx]'``)。"""
3
3
  from typing import Dict, Optional
4
4
 
5
- from . import HttpResponse
5
+ from . import HttpResponse, _READ_CHUNK, read_capped
6
6
 
7
7
 
8
8
  class HttpxTransport:
9
- """httpx.Client 适配器;惰性导入,未安装时给出安装指引。"""
9
+ """httpx.Client 适配器;惰性导入,未安装时给出安装指引。
10
+
11
+ 响应走 ``Client.stream`` + ``iter_bytes`` 流式读取,累计超
12
+ MAX_RESPONSE_BYTES(11MB)时即刻中断,不整体缓冲。
13
+ """
10
14
 
11
15
  def __init__(self, client=None):
12
16
  try:
@@ -20,12 +24,15 @@ class HttpxTransport:
20
24
  def send(
21
25
  self, method: str, url: str, headers: Dict[str, str], body: Optional[bytes]
22
26
  ) -> HttpResponse:
23
- resp = self._client.request(method, url, headers=headers, content=body)
24
- return HttpResponse(
25
- resp.status_code,
26
- {k.lower(): v for k, v in resp.headers.items()},
27
- resp.content,
28
- )
27
+ with self._client.stream(
28
+ method, url, headers=headers, content=body
29
+ ) as resp:
30
+ data = read_capped(resp.iter_bytes(_READ_CHUNK))
31
+ return HttpResponse(
32
+ resp.status_code,
33
+ {k.lower(): v for k, v in resp.headers.items()},
34
+ data,
35
+ )
29
36
 
30
37
  def close(self) -> None:
31
38
  self._client.close()
@@ -2,11 +2,15 @@
2
2
  """requests peer 适配器(extras:``pip install 'wop-sdk[requests]'``)。"""
3
3
  from typing import Dict, Optional
4
4
 
5
- from . import HttpResponse
5
+ from . import HttpResponse, _READ_CHUNK, read_capped
6
6
 
7
7
 
8
8
  class RequestsTransport:
9
- """requests 适配器;惰性导入,未安装时给出安装指引。"""
9
+ """requests 适配器;惰性导入,未安装时给出安装指引。
10
+
11
+ 请求带 ``stream=True``,响应经 ``iter_content`` 流式读取,累计超
12
+ MAX_RESPONSE_BYTES(11MB)时即刻中断,不整体缓冲。
13
+ """
10
14
 
11
15
  def __init__(self, session=None):
12
16
  try:
@@ -20,11 +24,17 @@ class RequestsTransport:
20
24
  def send(
21
25
  self, method: str, url: str, headers: Dict[str, str], body: Optional[bytes]
22
26
  ) -> HttpResponse:
23
- resp = self._session.request(method, url, headers=headers, data=body)
27
+ resp = self._session.request(
28
+ method, url, headers=headers, data=body, stream=True
29
+ )
30
+ try:
31
+ data = read_capped(resp.iter_content(_READ_CHUNK))
32
+ finally:
33
+ resp.close()
24
34
  return HttpResponse(
25
35
  resp.status_code,
26
36
  {k.lower(): v for k, v in resp.headers.items()},
27
- resp.content,
37
+ data,
28
38
  )
29
39
 
30
40
  def close(self) -> None:
@@ -2,13 +2,25 @@
2
2
  """stdlib urllib 适配器(零依赖,随主包交付)。"""
3
3
  import urllib.error
4
4
  import urllib.request
5
- from typing import Dict, Optional
5
+ from typing import Dict, Iterator, Optional
6
6
 
7
- from . import HttpResponse
7
+ from . import HttpResponse, _READ_CHUNK, read_capped
8
+
9
+
10
+ def _urllib_chunks(resp) -> Iterator[bytes]:
11
+ """resp.read(n) 循环取块,直至 EOF;配合 read_capped 流式限量。"""
12
+ while True:
13
+ chunk = resp.read(_READ_CHUNK)
14
+ if not chunk:
15
+ break
16
+ yield chunk
8
17
 
9
18
 
10
19
  class UrllibTransport:
11
- """urllib.request 适配器;4xx/5xx 返回响应体而非抛异常(协议错误也在 body 里)。"""
20
+ """urllib.request 适配器;4xx/5xx 返回响应体而非抛异常(协议错误也在 body 里)。
21
+
22
+ 响应体按块流式读取并在累计超 MAX_RESPONSE_BYTES(11MB)时即刻中断。
23
+ """
12
24
 
13
25
  def send(
14
26
  self, method: str, url: str, headers: Dict[str, str], body: Optional[bytes]
@@ -21,9 +33,11 @@ class UrllibTransport:
21
33
  return HttpResponse(
22
34
  resp.status,
23
35
  {k.lower(): v for k, v in resp.headers.items()},
24
- resp.read(),
36
+ read_capped(_urllib_chunks(resp)),
25
37
  )
26
38
  except urllib.error.HTTPError as exc:
27
39
  return HttpResponse(
28
- exc.code, {k.lower(): v for k, v in exc.headers.items()}, exc.read()
40
+ exc.code,
41
+ {k.lower(): v for k, v in exc.headers.items()},
42
+ read_capped(_urllib_chunks(exc)),
29
43
  )
@@ -2,6 +2,7 @@
2
2
  """摘要测试:D2 格式钉(恰一空格/小写 hex/跨族拒绝/长度)、向量字节级、formatRules 全套。"""
3
3
  import pytest
4
4
 
5
+ import conftest
5
6
  from wop_sdk.digest import (
6
7
  build_digest_header,
7
8
  check_digest_header,
@@ -41,29 +42,31 @@ class TestBuildHeader:
41
42
 
42
43
 
43
44
  class TestFormatRules:
44
- """formatRules 全套(spec:A2/F8):accept = 格式层接受;reject = 拒绝。"""
45
-
46
- @pytest.mark.parametrize("rule_id", ["header-rsa-ok", "header-sm2-ok"])
47
- def test_accept_format(self, vectors, rule_id):
48
- rule = next(r for r in vectors["formatRules"] if r["id"] == rule_id)
49
- check_digest_header(parse_suite(rule["suite"]), rule["value"])
50
-
51
- @pytest.mark.parametrize(
52
- "rule_id",
53
- [
54
- "header-crossfamily", # spec:I5 跨族
55
- "header-double-space", # spec:D2 恰一空格
56
- "header-uppercase-hex", # spec:F5 小写
57
- "header-wrong-hex-len", # 必须 64 hex
58
- "b64url-with-padding",
59
- "b64url-illegal-char",
60
- ],
61
- )
62
- def test_reject(self, vectors, rule_id):
63
- rule = next(r for r in vectors["formatRules"] if r["id"] == rule_id)
64
- suite = parse_suite(rule.get("suite", "WOP-RSA3072-SHA256"))
65
- with pytest.raises((ProtocolFormatError, UnsupportedSuiteError, ValueError)):
66
- check_digest_header(suite, rule["value"])
45
+ """formatRules 三件套消费(spec:A2/F8):循环全量 + 未知 id 哨兵 + 条数哨兵。
46
+
47
+ 本类消费 header-* 子集(check_digest_header 格式层:accept = 格式层接受;
48
+ reject = 拒绝);b64url-* 子集由 test_encoding.py 消费(b64url_decode)。
49
+ """
50
+
51
+ def test_sentinels(self, vectors): # spec:A2 条数哨兵 + 未知 id 哨兵
52
+ rules = vectors["formatRules"]
53
+ assert len(rules) == conftest.FORMAT_RULES_COUNT # 真源向量增删即炸
54
+ ids = {r["id"] for r in rules}
55
+ assert ids == conftest.ALL_FORMAT_RULE_IDS # 新增 id 未显式接入即炸
56
+
57
+ def test_header_rules_full_loop(self, vectors): # spec:A2 全量循环
58
+ seen = set()
59
+ for rule in vectors["formatRules"]: # 循环真源全量,禁止按 id 点名消费
60
+ if rule["id"] not in conftest.HEADER_RULE_IDS:
61
+ continue
62
+ seen.add(rule["id"])
63
+ suite = parse_suite(rule.get("suite", "WOP-RSA3072-SHA256"))
64
+ if rule["expect"] == "accept":
65
+ check_digest_header(suite, rule["value"]) # 正向断言:格式层通过
66
+ else:
67
+ with pytest.raises((ProtocolFormatError, UnsupportedSuiteError)):
68
+ check_digest_header(suite, rule["value"])
69
+ assert seen == conftest.HEADER_RULE_IDS # 子集完备:header-* 一条不漏
67
70
 
68
71
  def test_crossfamily_is_support_error(self):
69
72
  # 跨族标签(I5)单独归为支持类语义(明确拒绝)
@@ -2,6 +2,7 @@
2
2
  """编码层测试:base64url 严格无填充(F7/F6)、小写 hex(F5)、Java URLEncoder 语义(F2)。"""
3
3
  import pytest
4
4
 
5
+ import conftest
5
6
  from wop_sdk.encoding import b64url_decode, b64url_encode, hex_lower, java_urlencode, trimall
6
7
 
7
8
 
@@ -20,13 +21,6 @@ class TestB64urlStrict:
20
21
  def test_encode_uses_url_alphabet(self):
21
22
  # 0xFB 0xFF 0xBF → 标准字母表出 "+/+"/,URL 字母表必须是 "-_-_"
22
23
  assert b64url_encode(b"\xfb\xff\xbf") == "-_-_"
23
- def test_reject_padding_char(self): # spec:F7 formatRules:b64url-with-padding
24
- with pytest.raises(ValueError):
25
- b64url_decode("abc=")
26
-
27
- def test_reject_standard_alphabet_plus(self): # spec:F7 formatRules:b64url-illegal-char
28
- with pytest.raises(ValueError):
29
- b64url_decode("ab+c")
30
24
 
31
25
  def test_reject_standard_alphabet_slash(self):
32
26
  with pytest.raises(ValueError):
@@ -97,3 +91,35 @@ class TestTrimall:
97
91
 
98
92
  def test_tabs_newlines_collapse(self):
99
93
  assert trimall("a\t\n b") == "a b"
94
+
95
+
96
+ class TestFormatRulesB64url:
97
+ """formatRules b64url 子集三件套消费(spec:A2/F7/D10):
98
+ 循环全量 + 未知 id 哨兵 + 条数哨兵;语义锚 = Go base64.RawURLEncoding.Strict()
99
+ (RFC 4648 §3.5 尾随位 canonical)。header-* 子集由 test_digest.py 消费。
100
+ """
101
+
102
+ # accept 向量的正向字节断言(真源 note 标注的解码结果)
103
+ _ACCEPT_BYTES = {
104
+ "b64url-trailing-bits-canonical-2": b"\x00", # AA → 1 字节 0x00
105
+ "b64url-trailing-bits-canonical-3": b"Ma", # TWE → 2 字节 0x4D 0x61
106
+ }
107
+
108
+ def test_sentinels(self, vectors): # spec:A2 条数哨兵 + 未知 id 哨兵
109
+ rules = vectors["formatRules"]
110
+ assert len(rules) == conftest.FORMAT_RULES_COUNT # 真源向量增删即炸
111
+ ids = {r["id"] for r in rules}
112
+ assert ids == conftest.ALL_FORMAT_RULE_IDS # 新增 id 未显式接入即炸
113
+
114
+ def test_b64url_rules_full_loop(self, vectors): # spec:A2 全量循环
115
+ seen = set()
116
+ for rule in vectors["formatRules"]: # 循环真源全量,禁止按 id 点名消费
117
+ if rule["id"] not in conftest.B64URL_RULE_IDS:
118
+ continue
119
+ seen.add(rule["id"])
120
+ if rule["expect"] == "accept":
121
+ assert b64url_decode(rule["value"]) == self._ACCEPT_BYTES[rule["id"]]
122
+ else:
123
+ with pytest.raises(ValueError):
124
+ b64url_decode(rule["value"])
125
+ assert seen == conftest.B64URL_RULE_IDS # 子集完备:b64url-* 一条不漏
@@ -259,6 +259,15 @@ class TestSealOpenL2:
259
259
  with pytest.raises(DecryptError):
260
260
  open_l2(RSA3072, rsa_pair[1], wire, "abc=") # F7 严格无填充
261
261
 
262
+ def test_open_dek_payload_invalid_utf8_blurred(self, rsa_pair):
263
+ # I7:解包成功但 DEK 载荷非 UTF-8 → 与解包失败同归模糊,不得向商户层逃逸
264
+ dek_b64u = b64url_encode(
265
+ wrap_dek(RSA3072, rsa_pair[0], b"\xff\xfe\x80", csprng=lambda n: b"\xab" * n)
266
+ )
267
+ wire = json.dumps({"encrypted": b64url_encode(b"x" * 48)}).encode()
268
+ with pytest.raises(DecryptError):
269
+ open_l2(RSA3072, rsa_pair[1], wire, dek_b64u)
270
+
262
271
  def test_open_cross_family_dek_consistent_error(self, sm2_pair, vectors):
263
272
  # RSA 套件 DEK 被塞进 SM 载荷:解包成功、alg 比对在 bulk 解密前明确拒绝
264
273
  v = _vec(vectors, "dekPayload", "dek-sm2")
@@ -0,0 +1,307 @@
1
+ # -*- coding: utf-8 -*-
2
+ """传输层测试:Transport 协议、urllib 适配器(monkeypatch urlopen)、
3
+ httpx/requests peer 适配器(fake 模块注入)、未装依赖时的清晰报错、
4
+ 11MB 响应体上限(边界可过 / 越界即拒 / 流式中断)。"""
5
+ import io
6
+ import sys
7
+ import types
8
+ import urllib.error
9
+ from unittest import mock
10
+
11
+ import pytest
12
+
13
+ from wop_sdk.client import RequestDraft
14
+ from wop_sdk.errors import ProtocolFormatError
15
+ from wop_sdk.transports import (
16
+ MAX_RESPONSE_BYTES,
17
+ _READ_CHUNK,
18
+ HttpResponse,
19
+ UrllibTransport,
20
+ send_draft,
21
+ )
22
+ from wop_sdk.transports.httpx_transport import HttpxTransport
23
+ from wop_sdk.transports.requests_transport import RequestsTransport
24
+
25
+
26
+ class TestSendDraft:
27
+ def test_url_join_and_pass_through(self):
28
+ draft = RequestDraft("POST", "/gateway/x", {"x-wop-appkey": "ak"}, b"{}", "L0")
29
+ captured = {}
30
+
31
+ class FakeTransport:
32
+ def send(self, method, url, headers, body):
33
+ captured.update(method=method, url=url, headers=headers, body=body)
34
+ return HttpResponse(200, {}, b"ok")
35
+
36
+ resp = send_draft(FakeTransport(), "https://gw.example.com/", draft)
37
+ assert resp.status == 200 and resp.body == b"ok"
38
+ assert captured["url"] == "https://gw.example.com/gateway/x"
39
+ assert captured["method"] == "POST"
40
+ assert captured["headers"] is draft.headers
41
+ assert captured["body"] == b"{}"
42
+
43
+ def test_no_body_sends_none(self):
44
+ draft = RequestDraft("GET", "/q", {}, None, "L0")
45
+
46
+ class FakeTransport:
47
+ def send(self, method, url, headers, body):
48
+ assert body is None
49
+ return HttpResponse(204, {}, b"")
50
+
51
+ send_draft(FakeTransport(), "https://gw", draft)
52
+
53
+
54
+ def _ok_response(status=200, body=b'{"ok":1}', headers=None):
55
+ resp_obj = mock.MagicMock()
56
+ resp_obj.status = status
57
+ resp_obj.headers = headers if headers is not None else {}
58
+ # 流式语义:read(n) 返回至多 n 字节,EOF 返回 b""
59
+ remaining = memoryview(body)
60
+
61
+ def _read(size):
62
+ nonlocal remaining
63
+ chunk = bytes(remaining[:size])
64
+ remaining = remaining[size:]
65
+ return chunk
66
+
67
+ resp_obj.read.side_effect = _read
68
+ resp_obj.__enter__.return_value = resp_obj
69
+ resp_obj.__exit__.return_value = False
70
+ return resp_obj
71
+
72
+
73
+ class TestUrllibTransport:
74
+ def test_success(self):
75
+ resp_obj = _ok_response(200, b'{"ok":1}', {"Content-Type": "application/json", "X-Wop-Sign": "sig"})
76
+ with mock.patch("urllib.request.urlopen", return_value=resp_obj):
77
+ resp = UrllibTransport().send("POST", "https://gw/p", {"a": "b"}, b"body")
78
+ assert resp.status == 200
79
+ assert resp.body == b'{"ok":1}'
80
+ assert resp.headers == {"content-type": "application/json", "x-wop-sign": "sig"}
81
+
82
+ def test_http_error_returns_body(self):
83
+ err = urllib.error.HTTPError(
84
+ "url", 404, "Not Found", hdrs={"X-E": "1"}, fp=io.BytesIO(b'{"err":1}')
85
+ )
86
+ with mock.patch("urllib.request.urlopen", side_effect=err):
87
+ resp = UrllibTransport().send("GET", "https://gw/p", {}, None)
88
+ assert resp.status == 404
89
+ assert resp.body == b'{"err":1}'
90
+ assert resp.headers == {"x-e": "1"}
91
+
92
+ def test_request_headers_and_body_applied(self):
93
+ resp_obj = _ok_response()
94
+ with mock.patch("urllib.request.urlopen", return_value=resp_obj) as urlopen:
95
+ UrllibTransport().send("POST", "https://gw/p", {"x-wop-appkey": "ak"}, b"b")
96
+ req = urlopen.call_args[0][0]
97
+ assert req.get_header("X-wop-appkey") == "ak"
98
+ assert req.data == b"b"
99
+
100
+
101
+ class _UrllibLimit:
102
+ """urllib 上限边界:sized(有限总量)/ endless(无限流)两种 read 侧写。"""
103
+
104
+ @staticmethod
105
+ def _sized_read(total):
106
+ state = {"remaining": total}
107
+
108
+ def read(size):
109
+ take = min(size, state["remaining"])
110
+ state["remaining"] -= take
111
+ return b"x" * take if take else b""
112
+
113
+ return read
114
+
115
+ @staticmethod
116
+ def _endless_read(counter):
117
+ def read(size):
118
+ counter["count"] += 1
119
+ return b"x" * size
120
+
121
+ return read
122
+
123
+
124
+ class TestUrllibTransportLimit:
125
+ def test_at_limit_passes(self):
126
+ # 恰 11MB:等于上限不算越界
127
+ resp_obj = _ok_response(200, b"", {})
128
+ resp_obj.read.side_effect = _UrllibLimit._sized_read(MAX_RESPONSE_BYTES)
129
+ with mock.patch("urllib.request.urlopen", return_value=resp_obj):
130
+ resp = UrllibTransport().send("GET", "https://gw/p", {}, None)
131
+ assert len(resp.body) == MAX_RESPONSE_BYTES
132
+
133
+ def test_over_limit_rejected_immediately(self):
134
+ reads = {"count": 0}
135
+ resp_obj = _ok_response(200, b"", {})
136
+ resp_obj.read.side_effect = _UrllibLimit._endless_read(reads)
137
+ with mock.patch("urllib.request.urlopen", return_value=resp_obj):
138
+ with pytest.raises(ProtocolFormatError):
139
+ UrllibTransport().send("GET", "https://gw/p", {}, None)
140
+ # 无限流也能返回 → 读取中流式计数生效;恰在累计首次越界的 chunk 处中断
141
+ assert (reads["count"] - 1) * _READ_CHUNK <= MAX_RESPONSE_BYTES
142
+ assert reads["count"] * _READ_CHUNK > MAX_RESPONSE_BYTES
143
+
144
+
145
+ class _FakeHttpxStream:
146
+ def __init__(self, status_code=201, headers=None, body=b"resp"):
147
+ self.status_code = status_code
148
+ self.headers = headers if headers is not None else {"X-Wop-Sign": "s"}
149
+ self._body = body
150
+
151
+ def iter_bytes(self, chunk_size=None):
152
+ size = chunk_size or _READ_CHUNK
153
+ mv = memoryview(self._body)
154
+ for i in range(0, len(mv), size):
155
+ yield bytes(mv[i:i + size])
156
+
157
+ def __enter__(self):
158
+ return self
159
+
160
+ def __exit__(self, *exc_info):
161
+ return False
162
+
163
+
164
+ class _FakeHttpx:
165
+ def __init__(self):
166
+ self.calls = []
167
+
168
+ def close(self):
169
+ pass
170
+
171
+ def stream(self, method, url, headers=None, content=None):
172
+ self.calls.append((method, url, headers, content))
173
+ return _FakeHttpxStream()
174
+
175
+
176
+ class TestHttpxTransport:
177
+ def test_send(self, monkeypatch):
178
+ fake_mod = types.ModuleType("httpx")
179
+ fake_client = _FakeHttpx()
180
+ fake_mod.Client = lambda: fake_client
181
+ monkeypatch.setitem(sys.modules, "httpx", fake_mod)
182
+ with HttpxTransport() as t:
183
+ resp = t.send("POST", "https://gw/p", {"h": "1"}, b"body")
184
+ assert (resp.status, resp.body, resp.headers) == (201, b"resp", {"x-wop-sign": "s"})
185
+ assert fake_client.calls[0][2] == {"h": "1"}
186
+
187
+ def test_missing_dependency_clear_error(self, monkeypatch):
188
+ monkeypatch.setitem(sys.modules, "httpx", None) # import httpx → ImportError
189
+ with pytest.raises(ImportError, match="httpx"):
190
+ HttpxTransport()
191
+
192
+
193
+ class TestHttpxTransportLimit:
194
+ def _transport_with(self, monkeypatch, chunks_factory):
195
+ fake_mod = types.ModuleType("httpx")
196
+ fake_client = _FakeHttpx()
197
+ stream = _FakeHttpxStream(body=b"")
198
+
199
+ def iter_bytes(chunk_size=None):
200
+ yield from chunks_factory(chunk_size or _READ_CHUNK)
201
+
202
+ stream.iter_bytes = iter_bytes
203
+ fake_client.stream = lambda *a, **k: stream
204
+ fake_mod.Client = lambda: fake_client
205
+ monkeypatch.setitem(sys.modules, "httpx", fake_mod)
206
+ return HttpxTransport()
207
+
208
+ def test_at_limit_passes(self, monkeypatch):
209
+ t = self._transport_with(monkeypatch, lambda size: [b"x" * MAX_RESPONSE_BYTES])
210
+ resp = t.send("GET", "https://gw/p", {}, None)
211
+ assert len(resp.body) == MAX_RESPONSE_BYTES
212
+
213
+ def test_over_limit_rejected_immediately(self, monkeypatch):
214
+ reads = {"count": 0}
215
+
216
+ def endless_chunks(size):
217
+ while True:
218
+ reads["count"] += 1
219
+ yield b"x" * size
220
+
221
+ t = self._transport_with(monkeypatch, endless_chunks)
222
+ with pytest.raises(ProtocolFormatError):
223
+ t.send("GET", "https://gw/p", {}, None)
224
+ assert (reads["count"] - 1) * _READ_CHUNK <= MAX_RESPONSE_BYTES
225
+ assert reads["count"] * _READ_CHUNK > MAX_RESPONSE_BYTES
226
+
227
+
228
+ class _FakeRequestsResponse:
229
+ def __init__(self, status_code=200, headers=None, body=b"r"):
230
+ self.status_code = status_code
231
+ self.headers = headers if headers is not None else {"X-Wop-Sign": "s"}
232
+ self._body = body
233
+ self.closed = False
234
+
235
+ def iter_content(self, chunk_size=1):
236
+ mv = memoryview(self._body)
237
+ for i in range(0, len(mv), chunk_size):
238
+ yield bytes(mv[i:i + chunk_size])
239
+
240
+ def close(self):
241
+ self.closed = True
242
+
243
+
244
+ class _FakeRequests:
245
+ def __init__(self, response=None):
246
+ self.calls = []
247
+ self.stream_flags = []
248
+ self._response = response if response is not None else _FakeRequestsResponse()
249
+
250
+ def close(self):
251
+ pass
252
+
253
+ def request(self, method, url, headers=None, data=None, stream=False):
254
+ self.calls.append((method, url, headers, data))
255
+ self.stream_flags.append(stream)
256
+ return self._response
257
+
258
+
259
+ class TestRequestsTransport:
260
+ def test_send(self, monkeypatch):
261
+ fake_mod = types.ModuleType("requests")
262
+ fake = _FakeRequests()
263
+ fake_mod.Session = lambda: fake
264
+ fake_mod.request = fake.request
265
+ monkeypatch.setitem(sys.modules, "requests", fake_mod)
266
+ with RequestsTransport() as t:
267
+ resp = t.send("GET", "https://gw/q", {"h": "1"}, None)
268
+ assert (resp.status, resp.body) == (200, b"r")
269
+ assert fake.calls[0][3] is None
270
+ assert fake.stream_flags == [True] # 流式拉取,不整体缓冲
271
+
272
+ def test_missing_dependency_clear_error(self, monkeypatch):
273
+ monkeypatch.setitem(sys.modules, "requests", None)
274
+ with pytest.raises(ImportError, match="requests"):
275
+ RequestsTransport()
276
+
277
+
278
+ class TestRequestsTransportLimit:
279
+ def test_at_limit_passes(self, monkeypatch):
280
+ fake_mod = types.ModuleType("requests")
281
+ fake = _FakeRequests(response=_FakeRequestsResponse(body=b"x" * MAX_RESPONSE_BYTES))
282
+ fake_mod.Session = lambda: fake
283
+ monkeypatch.setitem(sys.modules, "requests", fake_mod)
284
+ with RequestsTransport() as t:
285
+ resp = t.send("GET", "https://gw/q", {}, None)
286
+ assert len(resp.body) == MAX_RESPONSE_BYTES
287
+
288
+ def test_over_limit_rejected_and_response_closed(self, monkeypatch):
289
+ reads = {"count": 0}
290
+
291
+ def endless_chunks(size):
292
+ while True:
293
+ reads["count"] += 1
294
+ yield b"x" * size
295
+
296
+ response = _FakeRequestsResponse()
297
+ response.iter_content = lambda chunk_size=1: endless_chunks(chunk_size)
298
+ fake_mod = types.ModuleType("requests")
299
+ fake = _FakeRequests(response=response)
300
+ fake_mod.Session = lambda: fake
301
+ monkeypatch.setitem(sys.modules, "requests", fake_mod)
302
+ t = RequestsTransport()
303
+ with pytest.raises(ProtocolFormatError):
304
+ t.send("GET", "https://gw/q", {}, None)
305
+ assert response.closed # 越界中断也释放连接
306
+ assert (reads["count"] - 1) * _READ_CHUNK <= MAX_RESPONSE_BYTES
307
+ assert reads["count"] * _READ_CHUNK > MAX_RESPONSE_BYTES
@@ -1,18 +1,21 @@
1
1
  # -*- coding: utf-8 -*-
2
2
  """真实库 transport 兼容性测试(区别于 test_transports.py 的 fake 模块注入)。
3
3
 
4
- - httpx:真 ``httpx.Client.request`` 全链路,仅以官方 ``MockTransport`` 打桩 socket 层;
5
- - requests:真 ``Session.request`` → HTTPAdapter.mount 分发,仅覆写 ``HTTPAdapter.send``
6
- 返回真实 ``requests.models.Response``。
4
+ - httpx:真 ``httpx.Client.stream`` 全链路,仅以官方 ``MockTransport`` 打桩 socket 层;
5
+ - requests:真 ``Session.request(stream=True)`` → HTTPAdapter.mount 分发,仅覆写
6
+ ``HTTPAdapter.send`` 返回真实 ``requests.models.Response``(raw 提供流式分块)。
7
7
 
8
8
  被测 API 面 = 适配器实际调用的稳定接口:
9
- httpx ``Client()/Client.request(method, url, headers=, content=)/close()``(区间 0.24–0.28);
10
- requests ``Session()/Session.request(method, url, headers=, data=)/close()``(区间 2.28–最新)。
9
+ httpx ``Client()/Client.stream(method, url, headers=, content=)/Response.iter_bytes(n)/close()``
10
+ (区间 0.24–0.28);requests ``Session()/Session.request(method, url, headers=, data=,
11
+ stream=)/Response.iter_content(n)/close()``(区间 2.28–最新)。
11
12
  CI 覆盖两端:test 矩阵跑最新,oldest-deps 跑下界(httpx 0.24.0 / requests 2.28.0)。
12
13
  本地未安装时 importorskip 跳过(fake 模块测试仍兜底结构正确性)。
13
14
  """
14
15
  import pytest
15
16
 
17
+ from wop_sdk.errors import ProtocolFormatError
18
+ from wop_sdk.transports import MAX_RESPONSE_BYTES
16
19
  from wop_sdk.transports.httpx_transport import HttpxTransport
17
20
  from wop_sdk.transports.requests_transport import RequestsTransport
18
21
 
@@ -40,6 +43,16 @@ class TestRealHttpx:
40
43
  assert captured["body"] == b"body"
41
44
  assert captured["headers"]["x-wop-appkey"] == "ak" # 真实 Headers 小写键
42
45
 
46
+ def test_over_limit_rejected_while_streaming(self):
47
+ # 真库 Client.stream + iter_bytes 全链路:11MB+1 的响应在流式读取中被拒
48
+ def handler(request: httpx.Request) -> httpx.Response:
49
+ return httpx.Response(200, content=b"x" * (MAX_RESPONSE_BYTES + 1))
50
+
51
+ client = httpx.Client(transport=httpx.MockTransport(handler))
52
+ with HttpxTransport(client=client) as t:
53
+ with pytest.raises(ProtocolFormatError):
54
+ t.send("GET", "https://gw/q", {}, None)
55
+
43
56
  def test_get_without_body(self):
44
57
  captured = {}
45
58
 
@@ -56,13 +69,29 @@ class TestRealHttpx:
56
69
  requests_lib = pytest.importorskip("requests", reason="真库测试需要 requests(CI extras 已装)")
57
70
 
58
71
 
72
+ class _BytesRaw:
73
+ """urllib3 风格 raw 流:具备 stream()/close() 即可被 requests.iter_content 消费。"""
74
+
75
+ def __init__(self, data):
76
+ self._data = data
77
+
78
+ def stream(self, chunk_size, decode_content=False):
79
+ for i in range(0, len(self._data), chunk_size):
80
+ yield self._data[i:i + chunk_size]
81
+
82
+ def close(self):
83
+ pass
84
+
85
+
59
86
  class _StubAdapter(requests_lib.adapters.HTTPAdapter):
60
87
  """覆写 send() 返回真实 Response 对象;Session.request 的 headers/data 合并、
61
- adapter mount 分发、close() 全部走 requests 真码。"""
88
+ adapter mount 分发、close() 全部走 requests 真码。响应体经 raw 流式供给
89
+ (适配器以 stream=True + iter_content 消费)。"""
62
90
 
63
- def __init__(self, captured):
91
+ def __init__(self, captured, body=b"r"):
64
92
  super().__init__()
65
93
  self._captured = captured
94
+ self._body = body
66
95
 
67
96
  def send(self, request, **kwargs):
68
97
  self._captured["method"] = request.method
@@ -72,7 +101,7 @@ class _StubAdapter(requests_lib.adapters.HTTPAdapter):
72
101
  resp = requests_lib.models.Response()
73
102
  resp.status_code = 200
74
103
  resp.headers["X-Wop-Sign"] = "sig"
75
- resp._content = b"r"
104
+ resp.raw = _BytesRaw(self._body)
76
105
  resp.request = request
77
106
  return resp
78
107
 
@@ -99,3 +128,11 @@ class TestRealRequests:
99
128
  resp = t.send("GET", "https://gw/q", {}, None)
100
129
  assert resp.status == 200
101
130
  assert captured["body"] is None
131
+
132
+ def test_over_limit_rejected_while_streaming(self):
133
+ # 真库 Session.request(stream=True) + iter_content 全链路:越界响应被拒
134
+ session = requests_lib.Session()
135
+ session.mount("https://", _StubAdapter({}, body=b"x" * (MAX_RESPONSE_BYTES + 1)))
136
+ with RequestsTransport(session=session) as t:
137
+ with pytest.raises(ProtocolFormatError):
138
+ t.send("GET", "https://gw/q", {}, None)
@@ -1,144 +0,0 @@
1
- # -*- coding: utf-8 -*-
2
- """传输层测试:Transport 协议、urllib 适配器(monkeypatch urlopen)、
3
- httpx/requests peer 适配器(fake 模块注入)、未装依赖时的清晰报错。"""
4
- import io
5
- import sys
6
- import types
7
- import urllib.error
8
- from unittest import mock
9
-
10
- import pytest
11
-
12
- from wop_sdk.client import RequestDraft
13
- from wop_sdk.transports import HttpResponse, UrllibTransport, send_draft
14
- from wop_sdk.transports.httpx_transport import HttpxTransport
15
- from wop_sdk.transports.requests_transport import RequestsTransport
16
-
17
-
18
- class TestSendDraft:
19
- def test_url_join_and_pass_through(self):
20
- draft = RequestDraft("POST", "/gateway/x", {"x-wop-appkey": "ak"}, b"{}", "L0")
21
- captured = {}
22
-
23
- class FakeTransport:
24
- def send(self, method, url, headers, body):
25
- captured.update(method=method, url=url, headers=headers, body=body)
26
- return HttpResponse(200, {}, b"ok")
27
-
28
- resp = send_draft(FakeTransport(), "https://gw.example.com/", draft)
29
- assert resp.status == 200 and resp.body == b"ok"
30
- assert captured["url"] == "https://gw.example.com/gateway/x"
31
- assert captured["method"] == "POST"
32
- assert captured["headers"] is draft.headers
33
- assert captured["body"] == b"{}"
34
-
35
- def test_no_body_sends_none(self):
36
- draft = RequestDraft("GET", "/q", {}, None, "L0")
37
-
38
- class FakeTransport:
39
- def send(self, method, url, headers, body):
40
- assert body is None
41
- return HttpResponse(204, {}, b"")
42
-
43
- send_draft(FakeTransport(), "https://gw", draft)
44
-
45
-
46
- def _ok_response(status=200, body=b'{"ok":1}', headers=None):
47
- resp_obj = mock.MagicMock()
48
- resp_obj.status = status
49
- resp_obj.headers = headers if headers is not None else {}
50
- resp_obj.read.return_value = body
51
- resp_obj.__enter__.return_value = resp_obj
52
- resp_obj.__exit__.return_value = False
53
- return resp_obj
54
-
55
-
56
- class TestUrllibTransport:
57
- def test_success(self):
58
- resp_obj = _ok_response(200, b'{"ok":1}', {"Content-Type": "application/json", "X-Wop-Sign": "sig"})
59
- with mock.patch("urllib.request.urlopen", return_value=resp_obj):
60
- resp = UrllibTransport().send("POST", "https://gw/p", {"a": "b"}, b"body")
61
- assert resp.status == 200
62
- assert resp.body == b'{"ok":1}'
63
- assert resp.headers == {"content-type": "application/json", "x-wop-sign": "sig"}
64
-
65
- def test_http_error_returns_body(self):
66
- err = urllib.error.HTTPError(
67
- "url", 404, "Not Found", hdrs={"X-E": "1"}, fp=io.BytesIO(b'{"err":1}')
68
- )
69
- with mock.patch("urllib.request.urlopen", side_effect=err):
70
- resp = UrllibTransport().send("GET", "https://gw/p", {}, None)
71
- assert resp.status == 404
72
- assert resp.body == b'{"err":1}'
73
- assert resp.headers == {"x-e": "1"}
74
-
75
- def test_request_headers_and_body_applied(self):
76
- resp_obj = _ok_response()
77
- with mock.patch("urllib.request.urlopen", return_value=resp_obj) as urlopen:
78
- UrllibTransport().send("POST", "https://gw/p", {"x-wop-appkey": "ak"}, b"b")
79
- req = urlopen.call_args[0][0]
80
- assert req.get_header("X-wop-appkey") == "ak"
81
- assert req.data == b"b"
82
-
83
-
84
- class _FakeHttpx:
85
- def __init__(self):
86
- self.calls = []
87
-
88
- def close(self):
89
- pass
90
-
91
- def request(self, method, url, headers=None, content=None):
92
- self.calls.append((method, url, headers, content))
93
- return types.SimpleNamespace(
94
- status_code=201, headers={"X-Wop-Sign": "s"}, content=b"resp"
95
- )
96
-
97
-
98
- class TestHttpxTransport:
99
- def test_send(self, monkeypatch):
100
- fake_mod = types.ModuleType("httpx")
101
- fake_client = _FakeHttpx()
102
- fake_mod.Client = lambda: fake_client
103
- monkeypatch.setitem(sys.modules, "httpx", fake_mod)
104
- with HttpxTransport() as t:
105
- resp = t.send("POST", "https://gw/p", {"h": "1"}, b"body")
106
- assert (resp.status, resp.body, resp.headers) == (201, b"resp", {"x-wop-sign": "s"})
107
- assert fake_client.calls[0][2] == {"h": "1"}
108
-
109
- def test_missing_dependency_clear_error(self, monkeypatch):
110
- monkeypatch.setitem(sys.modules, "httpx", None) # import httpx → ImportError
111
- with pytest.raises(ImportError, match="httpx"):
112
- HttpxTransport()
113
-
114
-
115
- class _FakeRequests:
116
- def __init__(self):
117
- self.calls = []
118
-
119
- def close(self):
120
- pass
121
-
122
- def request(self, method, url, headers=None, data=None):
123
- self.calls.append((method, url, headers, data))
124
- return types.SimpleNamespace(
125
- status_code=200, headers={"X-Wop-Sign": "s"}, content=b"r"
126
- )
127
-
128
-
129
- class TestRequestsTransport:
130
- def test_send(self, monkeypatch):
131
- fake_mod = types.ModuleType("requests")
132
- fake = _FakeRequests()
133
- fake_mod.Session = lambda: fake
134
- fake_mod.request = fake.request
135
- monkeypatch.setitem(sys.modules, "requests", fake_mod)
136
- with RequestsTransport() as t:
137
- resp = t.send("GET", "https://gw/q", {"h": "1"}, None)
138
- assert (resp.status, resp.body) == (200, b"r")
139
- assert fake.calls[0][3] is None
140
-
141
- def test_missing_dependency_clear_error(self, monkeypatch):
142
- monkeypatch.setitem(sys.modules, "requests", None)
143
- with pytest.raises(ImportError, match="requests"):
144
- RequestsTransport()
File without changes
File without changes
File without changes