Initial commit
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import requests
|
||||
|
||||
from common.client import IklimClient
|
||||
from common.config import Settings
|
||||
from common.security import create_signed_headers
|
||||
|
||||
|
||||
def jwt(expiration: int) -> str:
|
||||
def encode(value: dict[str, object]) -> str:
|
||||
return base64.urlsafe_b64encode(
|
||||
json.dumps(value, separators=(",", ":")).encode()
|
||||
).decode().rstrip("=")
|
||||
|
||||
return f"{encode({'alg': 'none'})}.{encode({'exp': expiration})}.signature"
|
||||
|
||||
|
||||
def response(status: int, body: object, headers: dict[str, str] | None = None):
|
||||
result = requests.Response()
|
||||
result.status_code = status
|
||||
result._content = json.dumps(body).encode()
|
||||
result.headers.update(headers or {})
|
||||
result.reason = "test response"
|
||||
return result
|
||||
|
||||
|
||||
class FakeSession:
|
||||
def __init__(self, responses: list[requests.Response]) -> None:
|
||||
self.responses = iter(responses)
|
||||
self.calls: list[dict[str, object]] = []
|
||||
|
||||
def request(self, method, url, **kwargs):
|
||||
self.calls.append({"method": method, "url": url, **kwargs})
|
||||
return next(self.responses)
|
||||
|
||||
|
||||
class ClientTest(unittest.TestCase):
|
||||
@staticmethod
|
||||
def settings(env_file: Path, *, max_retries: int = 0) -> Settings:
|
||||
return Settings(
|
||||
env_file=env_file,
|
||||
base_url="https://api-test.iklim.co",
|
||||
hmac_secret="test-secret",
|
||||
accept_language="tr",
|
||||
timeout_seconds=30,
|
||||
token_expiry_skew_seconds=60,
|
||||
max_retries=max_retries,
|
||||
retry_base_seconds=2,
|
||||
)
|
||||
|
||||
def test_retry_reuses_idempotency_and_regenerates_nonce(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
env_file = Path(directory) / ".env"
|
||||
env_file.write_text("", encoding="utf-8")
|
||||
settings = self.settings(env_file, max_retries=3)
|
||||
session = FakeSession(
|
||||
[response(500, {"message": "temporary"}), response(200, {"ok": True})]
|
||||
)
|
||||
waits: list[float] = []
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{"IKLIM_ACCESS_TOKEN": jwt(int(time.time()) + 3600)},
|
||||
clear=True,
|
||||
):
|
||||
client = IklimClient(settings, session=session, sleep=waits.append)
|
||||
result = client.request_json("POST", "/v1/test", json_body={"x": 1})
|
||||
|
||||
self.assertEqual(result.data, {"ok": True})
|
||||
self.assertEqual(waits, [2])
|
||||
first_headers = session.calls[0]["headers"]
|
||||
second_headers = session.calls[1]["headers"]
|
||||
self.assertEqual(
|
||||
first_headers["X-Idempotency-Key"],
|
||||
second_headers["X-Idempotency-Key"],
|
||||
)
|
||||
self.assertNotEqual(first_headers["X-Nonce"], second_headers["X-Nonce"])
|
||||
self.assertTrue(first_headers["Authorization"].startswith("Bearer "))
|
||||
|
||||
def test_hmac_matches_postman_formula(self) -> None:
|
||||
body = '{"username":"name@domain.com","password":"password"}'
|
||||
timestamp = "1752751106704"
|
||||
with patch("common.security.time.time", return_value=1752751106.704), patch(
|
||||
"common.security.uuid.uuid4",
|
||||
return_value="684a0dca-bd6a-4056-a449-2567f9847f9c",
|
||||
):
|
||||
headers = create_signed_headers(
|
||||
method="POST",
|
||||
path_with_query="/v1/auth/login",
|
||||
body=body,
|
||||
secret="test-secret",
|
||||
accept_language="tr",
|
||||
idempotency_key="777edc03-ad49-4c17-be6b-9baf05a1b9e0",
|
||||
)
|
||||
expected = hmac.new(
|
||||
b"test-secret",
|
||||
f"POST|/v1/auth/login|{timestamp}|{body}".encode(),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
self.assertEqual(headers["X-Signature"], expected)
|
||||
self.assertEqual(headers["X-Timestamp"], timestamp)
|
||||
|
||||
def test_unauthorized_refreshes_token_and_retries_request(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
env_file = Path(directory) / ".env"
|
||||
env_file.write_text("", encoding="utf-8")
|
||||
settings = self.settings(env_file)
|
||||
now = int(time.time())
|
||||
old_access = jwt(now + 3600)
|
||||
old_refresh = jwt(now + 7200)
|
||||
new_access = jwt(now + 10800)
|
||||
new_refresh = jwt(now + 14400)
|
||||
session = FakeSession(
|
||||
[
|
||||
response(401, {"message": "expired on server"}),
|
||||
response(
|
||||
200,
|
||||
{"accessToken": new_access, "refreshToken": new_refresh},
|
||||
),
|
||||
response(200, {"ok": True}),
|
||||
]
|
||||
)
|
||||
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"IKLIM_ACCESS_TOKEN": old_access,
|
||||
"IKLIM_REFRESH_TOKEN": old_refresh,
|
||||
},
|
||||
clear=True,
|
||||
):
|
||||
client = IklimClient(settings, session=session, sleep=lambda _: None)
|
||||
result = client.request_json("GET", "/v1/protected")
|
||||
self.assertEqual(os.environ["IKLIM_ACCESS_TOKEN"], new_access)
|
||||
|
||||
self.assertEqual(result.data, {"ok": True})
|
||||
self.assertTrue(session.calls[0]["headers"]["Authorization"].endswith(old_access))
|
||||
self.assertNotIn("Authorization", session.calls[1]["headers"])
|
||||
self.assertTrue(session.calls[2]["headers"]["Authorization"].endswith(new_access))
|
||||
self.assertTrue(session.calls[1]["url"].endswith("/v1/auth/refresh"))
|
||||
|
||||
def test_expired_access_token_is_refreshed_before_request(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
env_file = Path(directory) / ".env"
|
||||
env_file.write_text("", encoding="utf-8")
|
||||
now = int(time.time())
|
||||
new_access = jwt(now + 3600)
|
||||
new_refresh = jwt(now + 7200)
|
||||
session = FakeSession(
|
||||
[
|
||||
response(
|
||||
200,
|
||||
{"accessToken": new_access, "refreshToken": new_refresh},
|
||||
),
|
||||
response(200, {"ok": True}),
|
||||
]
|
||||
)
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"IKLIM_ACCESS_TOKEN": jwt(now - 1),
|
||||
"IKLIM_REFRESH_TOKEN": jwt(now + 3600),
|
||||
},
|
||||
clear=True,
|
||||
):
|
||||
client = IklimClient(
|
||||
self.settings(env_file), session=session, sleep=lambda _: None
|
||||
)
|
||||
result = client.request_json("GET", "/v1/protected")
|
||||
|
||||
self.assertEqual(result.data, {"ok": True})
|
||||
self.assertTrue(session.calls[0]["url"].endswith("/v1/auth/refresh"))
|
||||
self.assertNotIn("Authorization", session.calls[0]["headers"])
|
||||
self.assertTrue(session.calls[1]["headers"]["Authorization"].endswith(new_access))
|
||||
|
||||
def test_missing_tokens_trigger_automatic_login(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
env_file = Path(directory) / ".env"
|
||||
env_file.write_text("", encoding="utf-8")
|
||||
now = int(time.time())
|
||||
access = jwt(now + 3600)
|
||||
refresh = jwt(now + 7200)
|
||||
session = FakeSession(
|
||||
[
|
||||
response(200, {"accessToken": access, "refreshToken": refresh}),
|
||||
response(200, {"ok": True}),
|
||||
]
|
||||
)
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{"IKLIM_USERNAME": "user@example.com", "IKLIM_PASSWORD": "secret"},
|
||||
clear=True,
|
||||
):
|
||||
client = IklimClient(
|
||||
self.settings(env_file), session=session, sleep=lambda _: None
|
||||
)
|
||||
result = client.request_json("GET", "/v1/protected")
|
||||
|
||||
self.assertEqual(result.data, {"ok": True})
|
||||
self.assertTrue(session.calls[0]["url"].endswith("/v1/auth/login"))
|
||||
self.assertNotIn("Authorization", session.calls[0]["headers"])
|
||||
self.assertTrue(session.calls[1]["headers"]["Authorization"].endswith(access))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user