diff --git a/pyproject.toml b/pyproject.toml index 466ca5e..822685d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,6 +53,7 @@ version-file = "src/apkit/_version.py" dev = [ "coverage>=7.10.7", "pytest>=8.4.1", + "pytest-asyncio>=1.3.0", "pytest-cov>=7.0.0", ] docs = [ diff --git a/tests/helper/test_inbox.py b/tests/helper/test_inbox.py new file mode 100644 index 0000000..54d7472 --- /dev/null +++ b/tests/helper/test_inbox.py @@ -0,0 +1,71 @@ +import json + +import pytest +from apkit.config import AppConfig +from apkit.helper.inbox import InboxVerifier +from apsig import draft +from cryptography.hazmat.primitives import serialization as crypto_serialization +from cryptography.hazmat.primitives.asymmetric import rsa + + +def _prepare_signed_request(): + # prepare private and public keys + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + public_key_pem = ( + private_key.public_key() + .public_bytes( + encoding=crypto_serialization.Encoding.PEM, + format=crypto_serialization.PublicFormat.SubjectPublicKeyInfo, + ) + .decode("utf-8") + ) + + # create an activity with an embedded Actor + body_json = { + "@context": "https://www.w3.org/ns/activitystreams", + "id": "https://example.com/likes", + "type": "Like", + "actor": { + "id": "https://example.com/actor", + "type": "Person", + "publicKey": { + "id": "http://example.com/actor#key", + "owner": "https://example.com/actor", + "publicKeyPem": public_key_pem, + }, + }, + "object": "https://example.com/5", + } + + body = json.dumps(body_json).encode("utf-8") + + url = "http://example.com/ap" + method = "POST" + headers = {"host": "example.com"} + + # sign the request + signer = draft.Signer( + headers=headers, + method=method, + url=url, + key_id="http://example.com/actor#key", + private_key=private_key, + body=body, + ) + headers = signer.sign() + + # the verifier expects all HTTP header names in lower case + headers["signature"] = headers["Signature"] + + return (body, url, method, headers) + + +@pytest.mark.asyncio +async def test_verify_draft_http_signature(): + (body, url, method, headers) = _prepare_signed_request() + + config = AppConfig() + inbox_verifier = InboxVerifier(config) + + result = await inbox_verifier.verify(body, url, method, headers) + assert result diff --git a/tests/test_cache.py b/tests/test_cache.py new file mode 100644 index 0000000..b675959 --- /dev/null +++ b/tests/test_cache.py @@ -0,0 +1,128 @@ +import time +from typing import Any +import pytest + +from apkit.kv import KeyValueStore +from apkit.cache import Cache + + +class FakeKeyValueStore(KeyValueStore[Any, Any]): + """Minimal in-memory KeyValueStore for tests.""" + + def __init__(self): + self._data = {} + + def get(self, key): + return self._data.get(key) + + def set(self, key, value, ttl_seconds = None): + self._data[key] = value + + def delete(self, key): + self._data.pop(key, None) + + def exists(self, key): + return key in self._data + + async def async_get(self, key): + return self.get(key) + + async def async_set(self, key, value, ttl_seconds = None): + self.set(key, value) + + async def async_delete(self, key): + self.delete(key) + + async def async_exists(self, key): + return self.exists(key) + +@pytest.fixture +def store(): + return FakeKeyValueStore() + + +@pytest.fixture +def cache(store): + return Cache(store) + + +def test_set_and_get_without_ttl(cache): + cache.set("a", "value", ttl=None) + assert cache.get("a") == "value" + + +def test_set_and_get_with_ttl_not_expired(cache, monkeypatch): + monkeypatch.setattr(time, "time", lambda: 1000.0) + cache.set("a", "value", ttl=10) + + monkeypatch.setattr(time, "time", lambda: 1005.0) + assert cache.get("a") == "value" + + +def test_get_expired_item(cache, monkeypatch): + monkeypatch.setattr(time, "time", lambda: 1000.0) + cache.set("a", "value", ttl=5) + + monkeypatch.setattr(time, "time", lambda: 1006.0) + assert cache.get("a") is None + assert not cache.exists("a") + + +def test_set_with_non_positive_ttl_deletes(cache): + cache.set("a", "value", ttl=0) + assert cache.get("a") is None + + cache.set("b", "value", ttl=-5) + assert cache.get("b") is None + + +def test_delete(cache): + cache.set("a", "value", ttl=None) + cache.delete("a") + assert cache.get("a") is None + + +def test_exists_true(cache): + cache.set("a", "value", ttl=None) + assert cache.exists("a") is True + + +def test_exists_false_for_missing_key(cache): + assert cache.exists("missing") is False + + +def test_exists_false_for_expired_item(cache, monkeypatch): + monkeypatch.setattr(time, "time", lambda: 1000.0) + cache.set("a", "value", ttl=1) + + monkeypatch.setattr(time, "time", lambda: 1002.0) + assert cache.exists("a") is False + + +@pytest.mark.asyncio +async def test_async_set_and_get(cache): + await cache.async_set("a", "value", ttl=None) + result = await cache.async_get("a") + assert result == "value" + + +@pytest.mark.asyncio +async def test_async_get_expired(cache, monkeypatch): + monkeypatch.setattr(time, "time", lambda: 1000.0) + await cache.async_set("a", "value", ttl=1) + + monkeypatch.setattr(time, "time", lambda: 1002.0) + assert await cache.async_get("a") is None + + +@pytest.mark.asyncio +async def test_async_exists(cache): + await cache.async_set("a", "value", ttl=None) + assert await cache.async_exists("a") is True + + +@pytest.mark.asyncio +async def test_async_delete(cache): + await cache.async_set("a", "value", ttl=None) + await cache.async_delete("a") + assert await cache.async_get("a") is None diff --git a/uv.lock b/uv.lock index b00e690..a7b43af 100644 --- a/uv.lock +++ b/uv.lock @@ -185,6 +185,7 @@ server = [ dev = [ { name = "coverage" }, { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "pytest-cov" }, ] docs = [ @@ -216,6 +217,7 @@ provides-extras = ["redis", "server"] dev = [ { name = "coverage", specifier = ">=7.10.7" }, { name = "pytest", specifier = ">=8.4.1" }, + { name = "pytest-asyncio", specifier = ">=1.3.0" }, { name = "pytest-cov", specifier = ">=7.0.0" }, ] docs = [ @@ -1682,6 +1684,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, ] +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + [[package]] name = "pytest-cov" version = "7.0.0"