Initial commit

This commit is contained in:
2026-09-09 16:21:13 +03:00
commit 2067d8c298
17 changed files with 1887 additions and 0 deletions
View File
+217
View File
@@ -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()
+181
View File
@@ -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()