python-siseli 0.1.2__tar.gz → 0.2.0__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.
- {python_siseli-0.1.2 → python_siseli-0.2.0}/PKG-INFO +2 -1
- {python_siseli-0.1.2 → python_siseli-0.2.0}/pyproject.toml +2 -1
- {python_siseli-0.1.2 → python_siseli-0.2.0}/python_siseli.egg-info/PKG-INFO +2 -1
- {python_siseli-0.1.2 → python_siseli-0.2.0}/python_siseli.egg-info/SOURCES.txt +2 -0
- {python_siseli-0.1.2 → python_siseli-0.2.0}/python_siseli.egg-info/requires.txt +1 -0
- {python_siseli-0.1.2 → python_siseli-0.2.0}/siseli/__init__.py +2 -0
- python_siseli-0.2.0/siseli/const.py +9 -0
- python_siseli-0.2.0/siseli/open_auth.py +177 -0
- python_siseli-0.2.0/tests/test_open_auth.py +137 -0
- python_siseli-0.1.2/siseli/const.py +0 -5
- {python_siseli-0.1.2 → python_siseli-0.2.0}/README.md +0 -0
- {python_siseli-0.1.2 → python_siseli-0.2.0}/python_siseli.egg-info/dependency_links.txt +0 -0
- {python_siseli-0.1.2 → python_siseli-0.2.0}/python_siseli.egg-info/top_level.txt +0 -0
- {python_siseli-0.1.2 → python_siseli-0.2.0}/setup.cfg +0 -0
- {python_siseli-0.1.2 → python_siseli-0.2.0}/siseli/_utils.py +0 -0
- {python_siseli-0.1.2 → python_siseli-0.2.0}/siseli/alarms.py +0 -0
- {python_siseli-0.1.2 → python_siseli-0.2.0}/siseli/auth.py +0 -0
- {python_siseli-0.1.2 → python_siseli-0.2.0}/siseli/client.py +0 -0
- {python_siseli-0.1.2 → python_siseli-0.2.0}/siseli/config.py +0 -0
- {python_siseli-0.1.2 → python_siseli-0.2.0}/siseli/dashboard.py +0 -0
- {python_siseli-0.1.2 → python_siseli-0.2.0}/siseli/device.py +0 -0
- {python_siseli-0.1.2 → python_siseli-0.2.0}/siseli/dictionary.py +0 -0
- {python_siseli-0.1.2 → python_siseli-0.2.0}/siseli/exceptions.py +0 -0
- {python_siseli-0.1.2 → python_siseli-0.2.0}/siseli/history.py +0 -0
- {python_siseli-0.1.2 → python_siseli-0.2.0}/siseli/models/__init__.py +0 -0
- {python_siseli-0.1.2 → python_siseli-0.2.0}/siseli/models/alarm.py +0 -0
- {python_siseli-0.1.2 → python_siseli-0.2.0}/siseli/models/auth.py +0 -0
- {python_siseli-0.1.2 → python_siseli-0.2.0}/siseli/models/common.py +0 -0
- {python_siseli-0.1.2 → python_siseli-0.2.0}/siseli/models/config.py +0 -0
- {python_siseli-0.1.2 → python_siseli-0.2.0}/siseli/models/dashboard.py +0 -0
- {python_siseli-0.1.2 → python_siseli-0.2.0}/siseli/models/device.py +0 -0
- {python_siseli-0.1.2 → python_siseli-0.2.0}/siseli/models/dictionary.py +0 -0
- {python_siseli-0.1.2 → python_siseli-0.2.0}/siseli/models/history.py +0 -0
- {python_siseli-0.1.2 → python_siseli-0.2.0}/siseli/models/state.py +0 -0
- {python_siseli-0.1.2 → python_siseli-0.2.0}/siseli/models/station.py +0 -0
- {python_siseli-0.1.2 → python_siseli-0.2.0}/siseli/state.py +0 -0
- {python_siseli-0.1.2 → python_siseli-0.2.0}/siseli/station.py +0 -0
- {python_siseli-0.1.2 → python_siseli-0.2.0}/tests/test_auth.py +0 -0
- {python_siseli-0.1.2 → python_siseli-0.2.0}/tests/test_phase3.py +0 -0
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-siseli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Python SDK for Siseli Cloud
|
|
5
5
|
License: MIT
|
|
6
6
|
Requires-Python: >=3.11
|
|
7
7
|
Description-Content-Type: text/markdown
|
|
8
8
|
Requires-Dist: httpx>=0.27
|
|
9
|
+
Requires-Dist: cryptography>=42
|
|
9
10
|
Provides-Extra: dev
|
|
10
11
|
Requires-Dist: pytest>=8; extra == "dev"
|
|
11
12
|
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
@@ -4,13 +4,14 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "python-siseli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "Python SDK for Siseli Cloud"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
11
11
|
license = { text = "MIT" }
|
|
12
12
|
dependencies = [
|
|
13
13
|
"httpx>=0.27",
|
|
14
|
+
"cryptography>=42",
|
|
14
15
|
]
|
|
15
16
|
|
|
16
17
|
[project.optional-dependencies]
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-siseli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Python SDK for Siseli Cloud
|
|
5
5
|
License: MIT
|
|
6
6
|
Requires-Python: >=3.11
|
|
7
7
|
Description-Content-Type: text/markdown
|
|
8
8
|
Requires-Dist: httpx>=0.27
|
|
9
|
+
Requires-Dist: cryptography>=42
|
|
9
10
|
Provides-Extra: dev
|
|
10
11
|
Requires-Dist: pytest>=8; extra == "dev"
|
|
11
12
|
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
@@ -17,6 +17,7 @@ siseli/device.py
|
|
|
17
17
|
siseli/dictionary.py
|
|
18
18
|
siseli/exceptions.py
|
|
19
19
|
siseli/history.py
|
|
20
|
+
siseli/open_auth.py
|
|
20
21
|
siseli/state.py
|
|
21
22
|
siseli/station.py
|
|
22
23
|
siseli/models/__init__.py
|
|
@@ -31,4 +32,5 @@ siseli/models/history.py
|
|
|
31
32
|
siseli/models/state.py
|
|
32
33
|
siseli/models/station.py
|
|
33
34
|
tests/test_auth.py
|
|
35
|
+
tests/test_open_auth.py
|
|
34
36
|
tests/test_phase3.py
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""python-siseli — Python SDK for Siseli Cloud."""
|
|
2
2
|
|
|
3
3
|
from .client import SiseliClient
|
|
4
|
+
from .open_auth import attach_open_auth
|
|
4
5
|
from .exceptions import ApiError, AuthenticationError, NetworkError, SiseliError, TokenExpiredError
|
|
5
6
|
from .models import (
|
|
6
7
|
Alarm,
|
|
@@ -25,6 +26,7 @@ from .models import (
|
|
|
25
26
|
|
|
26
27
|
__all__ = [
|
|
27
28
|
"SiseliClient",
|
|
29
|
+
"attach_open_auth",
|
|
28
30
|
"SiseliError",
|
|
29
31
|
"AuthenticationError",
|
|
30
32
|
"TokenExpiredError",
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
BASE_URL = "https://solar.siseli.com"
|
|
2
|
+
DEFAULT_TIMEOUT = 30.0
|
|
3
|
+
DEFAULT_PAGE_SIZE = 20
|
|
4
|
+
# dataSource=1 observed in all telemetry requests in HAR captures
|
|
5
|
+
DEFAULT_DATA_SOURCE = 1
|
|
6
|
+
|
|
7
|
+
# Production Siseli Open API application credentials from the official web client.
|
|
8
|
+
SISELI_APP_ID = "rBrTRfAPXz"
|
|
9
|
+
SISELI_APP_SECRET_ENCRYPTED = "I4D0KRr2339z3pQ/at91V9BpFAOe54DaTafwSm6suIQ="
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Siseli Open API request signing helpers.
|
|
2
|
+
|
|
3
|
+
Every API request must carry IOT-Open-* headers that prove the caller holds a
|
|
4
|
+
valid application credential. The signing algorithm mirrors the official web
|
|
5
|
+
client (CryptoJS-based) exactly:
|
|
6
|
+
|
|
7
|
+
1. Decrypt the base64-encoded app secret with AES-128-CBC.
|
|
8
|
+
Key and IV are derived from MD5(app_id) as ASCII hex chars.
|
|
9
|
+
2. Compute a body hash: empty string for GET; SHA-256 hex for everything else.
|
|
10
|
+
3. Collect query params, inject the three open-auth fields, sort by key,
|
|
11
|
+
serialise without URL-encoding, hex-encode the UTF-8 bytes.
|
|
12
|
+
4. Sign: HMAC-SHA256(hex_string, secret) → MD5 → lowercase hex.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import hashlib
|
|
18
|
+
import hmac
|
|
19
|
+
import uuid
|
|
20
|
+
from typing import TYPE_CHECKING, Any
|
|
21
|
+
from urllib.parse import parse_qs
|
|
22
|
+
|
|
23
|
+
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
24
|
+
|
|
25
|
+
from .const import SISELI_APP_ID, SISELI_APP_SECRET_ENCRYPTED
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
import httpx
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def decrypt_open_secret(app_id: str, encrypted_secret_b64: str) -> str:
|
|
32
|
+
"""Decrypt the base64-encoded app secret using AES-128-CBC.
|
|
33
|
+
|
|
34
|
+
The AES key and IV are derived from MD5(app_id).lower():
|
|
35
|
+
- first 16 hex chars → 16 ASCII bytes → AES key
|
|
36
|
+
- last 16 hex chars → 16 ASCII bytes → IV
|
|
37
|
+
|
|
38
|
+
ZeroPadding is assumed: trailing null bytes are stripped from the output.
|
|
39
|
+
"""
|
|
40
|
+
import base64
|
|
41
|
+
|
|
42
|
+
md5_hex = hashlib.md5(app_id.encode()).hexdigest() # noqa: S324
|
|
43
|
+
key = md5_hex[:16].encode("ascii")
|
|
44
|
+
iv = md5_hex[16:].encode("ascii")
|
|
45
|
+
|
|
46
|
+
encrypted_bytes = base64.b64decode(encrypted_secret_b64)
|
|
47
|
+
cipher = Cipher(algorithms.AES(key), modes.CBC(iv))
|
|
48
|
+
decryptor = cipher.decryptor()
|
|
49
|
+
plaintext = decryptor.update(encrypted_bytes) + decryptor.finalize()
|
|
50
|
+
return plaintext.rstrip(b"\x00").decode("utf-8")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _sha256_hex(data: bytes) -> str:
|
|
54
|
+
"""Return the lowercase hex SHA-256 digest of *data*."""
|
|
55
|
+
return hashlib.sha256(data).hexdigest()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def build_open_headers(
|
|
59
|
+
*,
|
|
60
|
+
method: str,
|
|
61
|
+
query: str,
|
|
62
|
+
body: bytes | None,
|
|
63
|
+
app_id: str,
|
|
64
|
+
app_secret: str,
|
|
65
|
+
timezone: str,
|
|
66
|
+
nonce: str,
|
|
67
|
+
) -> dict[str, str]:
|
|
68
|
+
"""Build the IOT-Open-* headers (and IOT-Time-Zone) for a single request.
|
|
69
|
+
|
|
70
|
+
Parameters
|
|
71
|
+
----------
|
|
72
|
+
method:
|
|
73
|
+
HTTP method ("GET", "POST", …).
|
|
74
|
+
query:
|
|
75
|
+
The raw query string portion of the URL (without the leading "?").
|
|
76
|
+
body:
|
|
77
|
+
Raw request body bytes, or *None* / empty for GET requests.
|
|
78
|
+
app_id:
|
|
79
|
+
The application ID (``IOT-Open-AppID``).
|
|
80
|
+
app_secret:
|
|
81
|
+
The *decrypted* application secret used as the HMAC key.
|
|
82
|
+
timezone:
|
|
83
|
+
The client timezone string, e.g. "UTC" or "Europe/Moscow".
|
|
84
|
+
nonce:
|
|
85
|
+
A unique string per request (UUID hex recommended).
|
|
86
|
+
"""
|
|
87
|
+
# --- body hash -----------------------------------------------------------
|
|
88
|
+
if method.upper() == "GET":
|
|
89
|
+
body_hash = ""
|
|
90
|
+
else:
|
|
91
|
+
body_hash = _sha256_hex(body if body else b"")
|
|
92
|
+
|
|
93
|
+
# --- params for the sign input -------------------------------------------
|
|
94
|
+
params: dict[str, str] = {}
|
|
95
|
+
if query:
|
|
96
|
+
for key, values in parse_qs(query, keep_blank_values=True).items():
|
|
97
|
+
params[key] = values[0]
|
|
98
|
+
|
|
99
|
+
# Remove any stale open-auth fields so they cannot influence the new sign.
|
|
100
|
+
for field in ("IOT-Open-AppID", "IOT-Open-Nonce", "IOT-Open-Sign", "IOT-Open-Body-Hash"):
|
|
101
|
+
params.pop(field, None)
|
|
102
|
+
|
|
103
|
+
params["IOT-Open-AppID"] = app_id
|
|
104
|
+
params["IOT-Open-Nonce"] = nonce
|
|
105
|
+
params["IOT-Open-Body-Hash"] = body_hash
|
|
106
|
+
|
|
107
|
+
# Sort alphabetically (case-sensitive, matching JS default sort order).
|
|
108
|
+
query_str = "&".join(f"{k}={v}" for k, v in sorted(params.items()))
|
|
109
|
+
|
|
110
|
+
# --- sign ----------------------------------------------------------------
|
|
111
|
+
# CryptoJS: Utf8.parse(query_str) → Hex.stringify → hex_string
|
|
112
|
+
# Then: HmacSHA256(hex_string, secret) → MD5 → hex
|
|
113
|
+
hex_str = query_str.encode("utf-8").hex()
|
|
114
|
+
hmac_digest = hmac.new(
|
|
115
|
+
app_secret.encode("utf-8"),
|
|
116
|
+
hex_str.encode("utf-8"),
|
|
117
|
+
hashlib.sha256,
|
|
118
|
+
).digest()
|
|
119
|
+
sign = hashlib.md5(hmac_digest).hexdigest() # noqa: S324
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
"IOT-Time-Zone": timezone,
|
|
123
|
+
"IOT-Open-AppID": app_id,
|
|
124
|
+
"IOT-Open-Nonce": nonce,
|
|
125
|
+
"IOT-Open-Body-Hash": body_hash,
|
|
126
|
+
"IOT-Open-Sign": sign,
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def attach_open_auth(
|
|
131
|
+
client: Any,
|
|
132
|
+
*,
|
|
133
|
+
app_id: str = SISELI_APP_ID,
|
|
134
|
+
encrypted_secret: str = SISELI_APP_SECRET_ENCRYPTED,
|
|
135
|
+
) -> None:
|
|
136
|
+
"""Attach an httpx request hook that adds Open API signing headers.
|
|
137
|
+
|
|
138
|
+
The hook is appended to ``client._http.event_hooks["request"]`` and runs
|
|
139
|
+
before every outgoing request, injecting:
|
|
140
|
+
|
|
141
|
+
- ``IOT-Time-Zone``
|
|
142
|
+
- ``IOT-Open-AppID``
|
|
143
|
+
- ``IOT-Open-Nonce``
|
|
144
|
+
- ``IOT-Open-Body-Hash``
|
|
145
|
+
- ``IOT-Open-Sign``
|
|
146
|
+
|
|
147
|
+
Parameters
|
|
148
|
+
----------
|
|
149
|
+
client:
|
|
150
|
+
A :class:`~siseli.SiseliClient` instance (or any object with
|
|
151
|
+
``._http.event_hooks`` and ``._timezone``).
|
|
152
|
+
app_id:
|
|
153
|
+
Override the default production App ID.
|
|
154
|
+
encrypted_secret:
|
|
155
|
+
Override the default production encrypted secret.
|
|
156
|
+
"""
|
|
157
|
+
secret = decrypt_open_secret(app_id, encrypted_secret)
|
|
158
|
+
timezone = getattr(client, "_timezone", "UTC")
|
|
159
|
+
|
|
160
|
+
async def _sign_request(request: "httpx.Request") -> None:
|
|
161
|
+
nonce = uuid.uuid4().hex
|
|
162
|
+
query = request.url.query
|
|
163
|
+
if isinstance(query, bytes):
|
|
164
|
+
query = query.decode("utf-8")
|
|
165
|
+
body = request.content if request.method.upper() != "GET" else None
|
|
166
|
+
headers = build_open_headers(
|
|
167
|
+
method=request.method,
|
|
168
|
+
query=query,
|
|
169
|
+
body=body,
|
|
170
|
+
app_id=app_id,
|
|
171
|
+
app_secret=secret,
|
|
172
|
+
timezone=timezone,
|
|
173
|
+
nonce=nonce,
|
|
174
|
+
)
|
|
175
|
+
request.headers.update(headers)
|
|
176
|
+
|
|
177
|
+
client._http.event_hooks["request"].append(_sign_request)
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Tests for Siseli Open API signing helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from siseli.const import SISELI_APP_ID, SISELI_APP_SECRET_ENCRYPTED
|
|
11
|
+
from siseli.open_auth import (
|
|
12
|
+
_sha256_hex,
|
|
13
|
+
attach_open_auth,
|
|
14
|
+
build_open_headers,
|
|
15
|
+
decrypt_open_secret,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _MockHttpClient:
|
|
20
|
+
def __init__(self) -> None:
|
|
21
|
+
self.event_hooks = {"request": []}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class _MockSiseliClient:
|
|
25
|
+
_timezone = "Europe/Moscow"
|
|
26
|
+
|
|
27
|
+
def __init__(self) -> None:
|
|
28
|
+
self._http = _MockHttpClient()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_decrypt_open_secret_with_app_id() -> None:
|
|
32
|
+
"""Encrypted Open secret is decrypted with AppID-derived key/IV."""
|
|
33
|
+
assert (
|
|
34
|
+
decrypt_open_secret(SISELI_APP_ID, SISELI_APP_SECRET_ENCRYPTED)
|
|
35
|
+
== "CJbrtLtqFES62bJ3ZW7c"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_get_request_signing_uses_empty_body_hash() -> None:
|
|
40
|
+
"""GET requests must sign with an empty body hash."""
|
|
41
|
+
secret = decrypt_open_secret(SISELI_APP_ID, SISELI_APP_SECRET_ENCRYPTED)
|
|
42
|
+
headers = build_open_headers(
|
|
43
|
+
method="GET",
|
|
44
|
+
query="b=2&a=1",
|
|
45
|
+
body=None,
|
|
46
|
+
app_id=SISELI_APP_ID,
|
|
47
|
+
app_secret=secret,
|
|
48
|
+
timezone="UTC",
|
|
49
|
+
nonce="nonce-123",
|
|
50
|
+
)
|
|
51
|
+
assert headers["IOT-Open-Body-Hash"] == ""
|
|
52
|
+
assert headers["IOT-Open-Sign"] == "bee1c1c32629baf72be698fbe53fdc11"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_post_request_signing_with_json_body() -> None:
|
|
56
|
+
"""POST requests must hash and sign the raw JSON body bytes."""
|
|
57
|
+
secret = decrypt_open_secret(SISELI_APP_ID, SISELI_APP_SECRET_ENCRYPTED)
|
|
58
|
+
body = b'{"name":"abc","value":123}'
|
|
59
|
+
headers = build_open_headers(
|
|
60
|
+
method="POST",
|
|
61
|
+
query="z=9",
|
|
62
|
+
body=body,
|
|
63
|
+
app_id=SISELI_APP_ID,
|
|
64
|
+
app_secret=secret,
|
|
65
|
+
timezone="UTC",
|
|
66
|
+
nonce="nonce-123",
|
|
67
|
+
)
|
|
68
|
+
assert headers["IOT-Open-Body-Hash"] == hashlib.sha256(body).hexdigest()
|
|
69
|
+
assert headers["IOT-Open-Sign"] == "1f3525cce7ea5fa378409070c17f0076"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_unicode_json_body_signing() -> None:
|
|
73
|
+
"""Unicode body content must be hashed/signatured as UTF-8 bytes."""
|
|
74
|
+
secret = decrypt_open_secret(SISELI_APP_ID, SISELI_APP_SECRET_ENCRYPTED)
|
|
75
|
+
body = '{"text":"Привет 🌞"}'.encode()
|
|
76
|
+
headers = build_open_headers(
|
|
77
|
+
method="POST",
|
|
78
|
+
query="",
|
|
79
|
+
body=body,
|
|
80
|
+
app_id=SISELI_APP_ID,
|
|
81
|
+
app_secret=secret,
|
|
82
|
+
timezone="UTC",
|
|
83
|
+
nonce="nonce-123",
|
|
84
|
+
)
|
|
85
|
+
assert headers["IOT-Open-Body-Hash"] == hashlib.sha256(body).hexdigest()
|
|
86
|
+
assert headers["IOT-Open-Sign"] == "8d4c8f2771f9f7f419fd4cbec1d0f1e6"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_empty_post_body_signing() -> None:
|
|
90
|
+
"""Empty POST body must use SHA256 of empty bytes."""
|
|
91
|
+
secret = decrypt_open_secret(SISELI_APP_ID, SISELI_APP_SECRET_ENCRYPTED)
|
|
92
|
+
headers = build_open_headers(
|
|
93
|
+
method="POST",
|
|
94
|
+
query="x=",
|
|
95
|
+
body=b"",
|
|
96
|
+
app_id=SISELI_APP_ID,
|
|
97
|
+
app_secret=secret,
|
|
98
|
+
timezone="UTC",
|
|
99
|
+
nonce="nonce-123",
|
|
100
|
+
)
|
|
101
|
+
assert (
|
|
102
|
+
headers["IOT-Open-Body-Hash"]
|
|
103
|
+
== "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
|
104
|
+
)
|
|
105
|
+
assert headers["IOT-Open-Sign"] == "9f8506bc2a8f3da76eebc4f99691d271"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@pytest.mark.asyncio
|
|
109
|
+
async def test_header_generation_includes_required_iot_open_values() -> None:
|
|
110
|
+
"""Request hook injects required IOT-Open-* and timezone headers."""
|
|
111
|
+
secret = decrypt_open_secret(SISELI_APP_ID, SISELI_APP_SECRET_ENCRYPTED)
|
|
112
|
+
|
|
113
|
+
client = _MockSiseliClient()
|
|
114
|
+
attach_open_auth(client)
|
|
115
|
+
assert len(client._http.event_hooks["request"]) == 1
|
|
116
|
+
|
|
117
|
+
request = httpx.Request(
|
|
118
|
+
"POST",
|
|
119
|
+
"https://solar.siseli.com/apis/login/account",
|
|
120
|
+
content=b'{"account":"u","password":"p"}',
|
|
121
|
+
)
|
|
122
|
+
await client._http.event_hooks["request"][0](request)
|
|
123
|
+
|
|
124
|
+
for key in (
|
|
125
|
+
"IOT-Time-Zone",
|
|
126
|
+
"IOT-Open-AppID",
|
|
127
|
+
"IOT-Open-Nonce",
|
|
128
|
+
"IOT-Open-Body-Hash",
|
|
129
|
+
"IOT-Open-Sign",
|
|
130
|
+
):
|
|
131
|
+
assert key in request.headers
|
|
132
|
+
assert request.headers["IOT-Time-Zone"] == "Europe/Moscow"
|
|
133
|
+
assert request.headers["IOT-Open-AppID"] == SISELI_APP_ID
|
|
134
|
+
expected_hash = _sha256_hex(request.content)
|
|
135
|
+
assert request.headers["IOT-Open-Body-Hash"] == expected_hash
|
|
136
|
+
assert request.headers["IOT-Open-Sign"]
|
|
137
|
+
assert request.headers["IOT-Open-Sign"] != secret
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|