from __future__ import annotations import base64 import hashlib import hmac import json import time from unittest.mock import AsyncMock, MagicMock import httpx import pytest from yuxi.channel.common.crypto import ( hmac_sha256_base64, hmac_sha256_sign, sha1_sorted_sign, verify_hmac_digest, verify_timestamp, ) from yuxi.channel.common.fake_request import FakeRequest from yuxi.channel.common.http import ChannelHttpClient from yuxi.channel.common.schemas import COMMON_DM_POLICY_SCHEMA, COMMON_GROUP_POLICY_SCHEMA class TestCrypto: def test_hmac_sha256_sign_with_str_and_bytes(self): secret = "secret" message = "message" expected = hmac.new(secret.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).digest() assert hmac_sha256_sign(secret, message) == expected assert hmac_sha256_sign(secret.encode("utf-8"), message.encode("utf-8")) == expected def test_hmac_sha256_base64(self): signature = hmac_sha256_base64("secret", "message") expected = base64.b64encode(hmac_sha256_sign("secret", "message")).decode("utf-8") assert signature == expected def test_verify_hmac_digest_with_valid_signature(self): secret = "secret" message = "message" signature = hmac_sha256_sign(secret, message) assert verify_hmac_digest(secret, message, signature) is True def test_verify_hmac_digest_with_invalid_signature(self): assert verify_hmac_digest("secret", "message", b"wrong") is False def test_verify_hmac_digest_with_none_signature(self): assert verify_hmac_digest("secret", "message", None) is False def test_sha1_sorted_sign(self): expected = hashlib.sha1("".join(sorted(["b", "a", "c"])).encode("utf-8")).hexdigest() assert sha1_sorted_sign("a", "b", "c") == expected def test_verify_timestamp_within_tolerance(self): now = int(time.time()) assert verify_timestamp(now) is True assert verify_timestamp(str(now)) is True def test_verify_timestamp_outside_tolerance(self): now = int(time.time()) assert verify_timestamp(now - 600) is False assert verify_timestamp("invalid") is False class TestChannelHttpClient: async def test_internal_client_is_closed_on_exit(self): client = ChannelHttpClient() inner = client._client assert client._own_client is True async with client: pass assert inner.is_closed async def test_external_client_is_not_closed_on_exit(self): external = httpx.AsyncClient() client = ChannelHttpClient(client=external) assert client._own_client is False async with client: pass assert not external.is_closed await external.aclose() async def test_request_json_returns_parsed_response(self): mock_client = MagicMock() mock_response = MagicMock() mock_response.json.return_value = {"ok": True} mock_response.text = "" mock_client.request = AsyncMock(return_value=mock_response) client = ChannelHttpClient(client=mock_client) result = await client.request_json("GET", "https://example.com/api") assert result == {"ok": True} mock_response.raise_for_status.assert_called_once() async def test_request_json_raises_on_http_status_error(self): mock_client = MagicMock() mock_client.request = AsyncMock( side_effect=httpx.HTTPStatusError( "error", request=MagicMock(), response=MagicMock(status_code=500, text="boom") ) ) client = ChannelHttpClient(client=mock_client) with pytest.raises(httpx.HTTPStatusError): await client.request_json("GET", "https://example.com/api") async def test_request_json_raises_on_invalid_json(self): mock_client = MagicMock() mock_response = MagicMock() mock_response.json.side_effect = json.JSONDecodeError("boom", "not json", 0) mock_response.text = "not json" mock_client.request = AsyncMock(return_value=mock_response) client = ChannelHttpClient(client=mock_client) with pytest.raises(json.JSONDecodeError): await client.request_json("GET", "https://example.com/api") class TestFakeRequest: async def test_fake_request_carries_config_and_body(self): config = {"account_id": "acc"} body = {"event": {"type": "message"}} request = FakeRequest(config=config, body=body) assert request.state.channel_config == config assert request.state.channel_raw_body == body assert await request.json() == body assert await request.body() == json.dumps(body).encode("utf-8") async def test_fake_request_uses_channel_raw_body_when_provided(self): config = {"account_id": "acc"} body = {"normalized": True} raw = {"raw": True} request = FakeRequest(config=config, body=body, channel_raw_body=raw) assert await request.json() == body assert request.state.channel_raw_body == raw async def test_fake_request_defaults_empty_params(self): request = FakeRequest(config={}, body={}) assert request.headers == {} assert request.query_params == {} assert request.path_params == {} class TestSchemas: @pytest.fixture(scope="class") def validator(self): jsonschema = pytest.importorskip("jsonschema") return jsonschema.Draft202012Validator def test_dm_policy_schema_allows_known_fields(self, validator): instance = {"mode": "open", "allow_list": ["user1"], "deny_list": ["user2"]} assert validator(COMMON_DM_POLICY_SCHEMA).is_valid(instance) def test_dm_policy_schema_rejects_unknown_fields(self, validator): instance = {"mode": "open", "unknown": True} assert not validator(COMMON_DM_POLICY_SCHEMA).is_valid(instance) def test_group_policy_schema_allows_known_fields(self, validator): instance = {"require_mention": True, "allow_groups": ["g1"], "deny_groups": ["g2"]} assert validator(COMMON_GROUP_POLICY_SCHEMA).is_valid(instance) def test_group_policy_schema_rejects_unknown_fields(self, validator): instance = {"require_mention": False, "unknown": True} assert not validator(COMMON_GROUP_POLICY_SCHEMA).is_valid(instance)