wop-python-sdk 0.1.0__tar.gz → 0.1.6__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.
- {wop_python_sdk-0.1.0/src/wop_python_sdk.egg-info → wop_python_sdk-0.1.6}/PKG-INFO +1 -1
- {wop_python_sdk-0.1.0 → wop_python_sdk-0.1.6}/pyproject.toml +1 -1
- {wop_python_sdk-0.1.0 → wop_python_sdk-0.1.6/src/wop_python_sdk.egg-info}/PKG-INFO +1 -1
- {wop_python_sdk-0.1.0 → wop_python_sdk-0.1.6}/src/wop_sdk/__init__.py +1 -1
- {wop_python_sdk-0.1.0 → wop_python_sdk-0.1.6}/src/wop_sdk/encoding.py +19 -3
- {wop_python_sdk-0.1.0 → wop_python_sdk-0.1.6}/src/wop_sdk/envelope.py +5 -1
- {wop_python_sdk-0.1.0 → wop_python_sdk-0.1.6}/src/wop_sdk/transports/__init__.py +26 -3
- {wop_python_sdk-0.1.0 → wop_python_sdk-0.1.6}/src/wop_sdk/transports/httpx_transport.py +15 -8
- {wop_python_sdk-0.1.0 → wop_python_sdk-0.1.6}/src/wop_sdk/transports/requests_transport.py +14 -4
- {wop_python_sdk-0.1.0 → wop_python_sdk-0.1.6}/src/wop_sdk/transports/urllib_transport.py +19 -5
- {wop_python_sdk-0.1.0 → wop_python_sdk-0.1.6}/tests/test_digest.py +26 -23
- {wop_python_sdk-0.1.0 → wop_python_sdk-0.1.6}/tests/test_encoding.py +33 -7
- {wop_python_sdk-0.1.0 → wop_python_sdk-0.1.6}/tests/test_envelope.py +9 -0
- wop_python_sdk-0.1.6/tests/test_transports.py +307 -0
- {wop_python_sdk-0.1.0 → wop_python_sdk-0.1.6}/tests/test_transports_real.py +45 -8
- wop_python_sdk-0.1.0/tests/test_transports.py +0 -144
- {wop_python_sdk-0.1.0 → wop_python_sdk-0.1.6}/LICENSE +0 -0
- {wop_python_sdk-0.1.0 → wop_python_sdk-0.1.6}/README.md +0 -0
- {wop_python_sdk-0.1.0 → wop_python_sdk-0.1.6}/setup.cfg +0 -0
- {wop_python_sdk-0.1.0 → wop_python_sdk-0.1.6}/src/wop_python_sdk.egg-info/SOURCES.txt +0 -0
- {wop_python_sdk-0.1.0 → wop_python_sdk-0.1.6}/src/wop_python_sdk.egg-info/dependency_links.txt +0 -0
- {wop_python_sdk-0.1.0 → wop_python_sdk-0.1.6}/src/wop_python_sdk.egg-info/requires.txt +0 -0
- {wop_python_sdk-0.1.0 → wop_python_sdk-0.1.6}/src/wop_python_sdk.egg-info/top_level.txt +0 -0
- {wop_python_sdk-0.1.0 → wop_python_sdk-0.1.6}/src/wop_sdk/canonical.py +0 -0
- {wop_python_sdk-0.1.0 → wop_python_sdk-0.1.6}/src/wop_sdk/client.py +0 -0
- {wop_python_sdk-0.1.0 → wop_python_sdk-0.1.6}/src/wop_sdk/digest.py +0 -0
- {wop_python_sdk-0.1.0 → wop_python_sdk-0.1.6}/src/wop_sdk/errors.py +0 -0
- {wop_python_sdk-0.1.0 → wop_python_sdk-0.1.6}/src/wop_sdk/keys.py +0 -0
- {wop_python_sdk-0.1.0 → wop_python_sdk-0.1.6}/src/wop_sdk/signature.py +0 -0
- {wop_python_sdk-0.1.0 → wop_python_sdk-0.1.6}/src/wop_sdk/sm2crypto.py +0 -0
- {wop_python_sdk-0.1.0 → wop_python_sdk-0.1.6}/src/wop_sdk/sm4gcm.py +0 -0
- {wop_python_sdk-0.1.0 → wop_python_sdk-0.1.6}/src/wop_sdk/suites.py +0 -0
- {wop_python_sdk-0.1.0 → wop_python_sdk-0.1.6}/tests/test_canonical.py +0 -0
- {wop_python_sdk-0.1.0 → wop_python_sdk-0.1.6}/tests/test_client.py +0 -0
- {wop_python_sdk-0.1.0 → wop_python_sdk-0.1.6}/tests/test_coverage_gaps.py +0 -0
- {wop_python_sdk-0.1.0 → wop_python_sdk-0.1.6}/tests/test_keys.py +0 -0
- {wop_python_sdk-0.1.0 → wop_python_sdk-0.1.6}/tests/test_signature.py +0 -0
- {wop_python_sdk-0.1.0 → wop_python_sdk-0.1.6}/tests/test_suites.py +0 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
36
|
+
read_capped(_urllib_chunks(resp)),
|
|
25
37
|
)
|
|
26
38
|
except urllib.error.HTTPError as exc:
|
|
27
39
|
return HttpResponse(
|
|
28
|
-
exc.code,
|
|
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
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
[
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
"
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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.
|
|
5
|
-
- requests:真 ``Session.request`` → HTTPAdapter.mount 分发,仅覆写
|
|
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.
|
|
10
|
-
requests ``Session()/Session.request(method, url, headers=, data
|
|
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.
|
|
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
|
|
File without changes
|
{wop_python_sdk-0.1.0 → wop_python_sdk-0.1.6}/src/wop_python_sdk.egg-info/dependency_links.txt
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
|