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()
|
||||
@@ -0,0 +1,181 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from common.security import TokenStore
|
||||
from common import ApiError
|
||||
from geo_alarm import CsvRegistrationProcessor, normalize_turkish_name
|
||||
|
||||
|
||||
REGISTRATION_ONE = "f7587d9e-2481-4b4c-818d-c8d1946851b7"
|
||||
REGISTRATION_TWO = "57d1e2ba-bf2d-4ef3-b078-523060b2000c"
|
||||
ACCOUNT_ID = "2f04f1b5-1c0a-4c4e-b0a7-0ba6e9b7f2e1"
|
||||
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, env_file: Path) -> None:
|
||||
self.token_store = TokenStore(env_file)
|
||||
|
||||
|
||||
class FakeService:
|
||||
def __init__(self, env_file: Path) -> None:
|
||||
self.client = FakeClient(env_file)
|
||||
self.payloads = []
|
||||
|
||||
def get_account_id(self):
|
||||
return ACCOUNT_ID
|
||||
|
||||
def list_cities(self):
|
||||
return [{"id": 6, "name": "Ankara"}, {"id": 34, "name": "İstanbul"}]
|
||||
|
||||
def list_districts(self, city_id):
|
||||
return {
|
||||
6: [{"id": 557, "name": "Çankaya"}],
|
||||
34: [{"id": 347, "name": "Kadıköy"}],
|
||||
}[city_id]
|
||||
|
||||
def create_registration(self, payload, idempotency_key):
|
||||
self.payloads.append(payload)
|
||||
registration_id = REGISTRATION_ONE if len(self.payloads) == 1 else REGISTRATION_TWO
|
||||
warnings = ["test warning"] if len(self.payloads) == 1 else []
|
||||
return 200, registration_id, warnings
|
||||
|
||||
|
||||
class RecoveringService(FakeService):
|
||||
def create_registration(self, payload, idempotency_key):
|
||||
self.payloads.append(payload)
|
||||
raise ApiError("duplicate", status_code=409)
|
||||
|
||||
def find_by_recipient_id(self, recipient_id):
|
||||
return 200, REGISTRATION_ONE, []
|
||||
|
||||
|
||||
class GeoAlarmTest(unittest.TestCase):
|
||||
def test_turkish_exact_normalization(self) -> None:
|
||||
self.assertEqual(normalize_turkish_name(" İSTANBUL "), "istanbul")
|
||||
self.assertEqual(normalize_turkish_name("IĞDIR"), "ığdır")
|
||||
self.assertEqual(normalize_turkish_name("ÇANKAYA"), "çankaya")
|
||||
self.assertNotEqual(normalize_turkish_name("I"), normalize_turkish_name("İ"))
|
||||
|
||||
def test_csv_processing_and_webhook_transition(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
env_file = root / ".env"
|
||||
env_file.write_text("IKLIM_WEBHOOK_INITIALIZED=false\n", encoding="utf-8")
|
||||
csv_file = root / "nowcast-geo-alarm-registrations.csv"
|
||||
csv_file.write_text(
|
||||
"city;district;recipientId\n"
|
||||
" anKARA ; ÇANKAYA ;recipient-001\n"
|
||||
"Ankara;Olmayan;recipient-invalid\n"
|
||||
"İSTANBUL;KADIKÖY;recipient-002\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
webhook_file = root / "webhook.json"
|
||||
webhook_file.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"url": "https://customer.example/webhook",
|
||||
"httpMethod": "POST",
|
||||
"contentType": "application/json",
|
||||
"authentication": {
|
||||
"selected": "BASIC",
|
||||
"options": {
|
||||
"BASIC": {"username": "user", "password": "pass"}
|
||||
},
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
nowcast_filter_file = root / "nowcast-geo-alarm-filter.json"
|
||||
nowcast_filter_file.write_text(
|
||||
json.dumps({"precipitation": {"intensities": ["LIGHT"]}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
service = FakeService(env_file)
|
||||
|
||||
with patch.dict(
|
||||
os.environ, {"IKLIM_WEBHOOK_INITIALIZED": "false"}, clear=True
|
||||
):
|
||||
CsvRegistrationProcessor(
|
||||
service,
|
||||
csv_path=csv_file,
|
||||
webhook_config_path=webhook_file,
|
||||
nowcast_filter_config_path=nowcast_filter_file,
|
||||
).run()
|
||||
|
||||
with csv_file.open("r", encoding="utf-8-sig", newline="") as handle:
|
||||
rows = list(csv.DictReader(handle, delimiter=";"))
|
||||
|
||||
self.assertEqual(
|
||||
list(rows[0]),
|
||||
["city", "district", "recipientId", "status", "httpStatus", "registrationId"],
|
||||
)
|
||||
self.assertEqual(rows[0]["status"], "SUCCESS")
|
||||
self.assertEqual(rows[0]["registrationId"], REGISTRATION_ONE)
|
||||
self.assertEqual(rows[1]["status"], "FAILED")
|
||||
self.assertEqual(rows[1]["httpStatus"], "NOT_SENT")
|
||||
self.assertEqual(rows[2]["status"], "SUCCESS")
|
||||
self.assertEqual(service.payloads[0]["webhook"]["url"], "https://customer.example/webhook")
|
||||
self.assertNotIn("accountId", service.payloads[0]["webhook"])
|
||||
self.assertEqual(service.payloads[1]["webhook"], {"accountId": ACCOUNT_ID})
|
||||
self.assertIn("IKLIM_WEBHOOK_INITIALIZED=true", env_file.read_text())
|
||||
|
||||
def test_conflict_is_recovered_by_recipient_id(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
env_file = root / ".env"
|
||||
env_file.write_text("IKLIM_WEBHOOK_INITIALIZED=false\n", encoding="utf-8")
|
||||
csv_file = root / "nowcast-geo-alarm-registrations.csv"
|
||||
csv_file.write_text(
|
||||
"city;district;recipientId\nAnkara;Çankaya;recipient-001\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
webhook_file = root / "webhook.json"
|
||||
webhook_file.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"url": "https://customer.example/webhook",
|
||||
"httpMethod": "POST",
|
||||
"contentType": "application/json",
|
||||
"authentication": {
|
||||
"selected": "BASIC",
|
||||
"options": {
|
||||
"BASIC": {"username": "user", "password": "pass"}
|
||||
},
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
nowcast_filter_file = root / "nowcast-geo-alarm-filter.json"
|
||||
nowcast_filter_file.write_text(
|
||||
json.dumps({"precipitation": {"intensities": ["LIGHT"]}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with patch.dict(
|
||||
os.environ, {"IKLIM_WEBHOOK_INITIALIZED": "false"}, clear=True
|
||||
):
|
||||
CsvRegistrationProcessor(
|
||||
RecoveringService(env_file),
|
||||
csv_path=csv_file,
|
||||
webhook_config_path=webhook_file,
|
||||
nowcast_filter_config_path=nowcast_filter_file,
|
||||
).run()
|
||||
|
||||
with csv_file.open("r", encoding="utf-8-sig", newline="") as handle:
|
||||
row = next(csv.DictReader(handle, delimiter=";"))
|
||||
self.assertEqual(row["status"], "SUCCESS")
|
||||
self.assertEqual(row["httpStatus"], "HTTP_409")
|
||||
self.assertEqual(row["registrationId"], REGISTRATION_ONE)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user