Skip to content

Commit

Permalink
handlers + tests, basic crypto, ...
Browse files Browse the repository at this point in the history
  • Loading branch information
jensens committed Dec 17, 2024
1 parent cea4352 commit 4569515
Show file tree
Hide file tree
Showing 8 changed files with 307 additions and 29 deletions.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@ __pycache__/
.vscode/
node_modules/
sandbox/
htmlcov/

# venv / buildout related
# venv related
bin/
develop-eggs/
eggs/
Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ develop = [
requires = ["hatchling"]
build-backend = "hatchling.build"

[project.scripts]
generate-fernet-key = "edutap.wallet_google.utils:generate_fernet_key"

[tool.hatch.build.targets.wheel]
packages = ["src/edutap"]

Expand Down
91 changes: 73 additions & 18 deletions src/edutap/wallet_google/handlers/fastapi.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
from ..models.handlers import CallbackData
from ..plugins import get_callback_handlers
from ..plugins import get_image_handlers
from ..plugins import get_image_providers
from ..session import session_manager
from .validate import verified_signed_message
from fastapi import APIRouter
from fastapi import Request
from fastapi import Response
from fastapi.exceptions import HTTPException
from fastapi.logger import logger
from fastapi.responses import JSONResponse

import asyncio

Expand All @@ -27,7 +29,7 @@ async def handle_callback(request: Request, callback_data: CallbackData):
# get the registered callback handlers
try:
handlers = get_callback_handlers()
except NotImplementedError:``
except NotImplementedError:
raise HTTPException(
status_code=500, detail="No callback handlers were registered."
)
Expand All @@ -38,25 +40,41 @@ async def handle_callback(request: Request, callback_data: CallbackData):

# call each handler asynchronously
try:
await asyncio.gather(
*(
handler.handle(
callback_message.classId,
callback_message.objectId,
callback_message.eventType.value,
callback_message.expTimeMillis,
callback_message.count,
callback_message.nonce,
)
for handler in handlers
results: list = (
# this could be replaced by async with asyncio.timeout(5.0) in Py 3.11
# see also https://hynek.me/articles/waiting-in-asyncio/
await asyncio.wait_for(
asyncio.gather(
*(
handler.handle(
callback_message.classId,
callback_message.objectId,
callback_message.eventType.value,
callback_message.expTimeMillis,
callback_message.count,
callback_message.nonce,
)
for handler in handlers
),
return_exceptions=True,
),
timeout=session_manager.settings.handlers_callback_timeout,
)
)
except Exception:
logger.exception("Error while handling a callback")
except asyncio.TimeoutError:
logger.exception(
f"Timeout after {session_manager.settings.handlers_callback_timeout}s while handling the callbacks.",
)
raise HTTPException(
status_code=500, detail="Error while handling the callback."
status_code=500, detail="Error while handling the callbacks (timeout)."
)
return {"status": "success"}
# results is a list of exceptions or None
if any(results):
logger.error("Error while handling a callbacks.")
raise HTTPException(
status_code=500, detail="Error while handling the callbacks (exception)."
)
return JSONResponse(content={"status": "success"})


@router.get("/images/{image_id}")
Expand All @@ -67,8 +85,45 @@ async def handle_image(request: Request, image_id: str):
"""
# get the registered image providers
try:
providers = get_image_providers()
handlers = get_image_providers()
except NotImplementedError:
raise HTTPException(
status_code=500, detail="No image providers were registered."
)
if len(handlers) > 1:
logger.error("Multiple image providers found, abort.")
raise HTTPException(
status_code=500, detail="Multiple image providers found, abort."
)

handler = handlers[0]

try:
result = await asyncio.wait_for(
handler.image_by_id(image_id),
timeout=session_manager.settings.handlers_image_timeout,
)
except asyncio.TimeoutError:
logger.exception(
"Timeout Timeout after {session_manager.settings.handlers_image_timeout}s while handling the image.",
)
raise HTTPException(
status_code=500, detail="Error while handling the image (timeout)."
)
except asyncio.CancelledError:
logger.exception(
"Cancelled while handling the image.",
)
raise HTTPException(
status_code=500, detail="Error while handling the image (cancel)."
)
except LookupError:
raise HTTPException(status_code=404, detail="Image not found.")
except Exception:
logger.exception(
"Error while handling a image.",
)
raise HTTPException(
status_code=500, detail="Error while handling the image (exception)."
)
return Response(content=result.data, media_type=result.mimetype)
13 changes: 9 additions & 4 deletions src/edutap/wallet_google/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,10 @@ class Settings(BaseSettings):

fernet_encryption_key: str = ""

environment: Literal["production", "testing"] = "testing"
handlers_callback_timeout: float = 5.0
handlers_image_timeout: float = 5.0

google_environment: Literal["production", "testing"] = "testing"

cached_credentials_info: dict[str, str] = {}

Expand All @@ -66,12 +69,14 @@ def google_root_signing_public_keys(self) -> RootSigningPublicKeys:
Fetch Googles root signing keys once for the configured environment and return them or the cached value.
"""
if (
GOOGLE_ROOT_SIGNING_PUBLIC_KEYS_VALUE.get(self.environment, None)
GOOGLE_ROOT_SIGNING_PUBLIC_KEYS_VALUE.get(self.google_environment, None)
is not None
):
return GOOGLE_ROOT_SIGNING_PUBLIC_KEYS_VALUE[self.environment]
return GOOGLE_ROOT_SIGNING_PUBLIC_KEYS_VALUE[self.google_environment]
# fetch once
resp = requests.get(GOOGLE_ROOT_SIGNING_PUBLIC_KEYS_URL[self.environment])
resp = requests.get(
GOOGLE_ROOT_SIGNING_PUBLIC_KEYS_URL[self.google_environment]
)
resp.raise_for_status()
return RootSigningPublicKeys.model_validate_json(resp.text)

Expand Down
27 changes: 27 additions & 0 deletions src/edutap/wallet_google/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from .session import session_manager
from cryptography.fernet import Fernet


def encrypt_data(data: bytes) -> bytes:
"""Encrypt bytes using the Fernet symmetric encryption algorithm.
see https://cryptography.io/en/latest/fernet/
"""
key = session_manager.settings.fernet_encryption_key.encode("utf8")
fernet = Fernet(key)
return fernet.encrypt(data)


def decrypt_data(data: bytes) -> bytes:
"""Decrypt bytes using the Fernet symmetric decryption algorithm.
see https://cryptography.io/en/latest/fernet/
"""
key = session_manager.settings.fernet_encryption_key.encode("utf8")
fernet = Fernet(key)
return fernet.decrypt(data)


def generate_fernet_key():
"""Create a new Fernet key."""
print(Fernet.generate_key().decode("utf8"))
20 changes: 18 additions & 2 deletions tests/data/test_wallet_google_plugins/plugins.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,24 @@
from edutap.wallet_google.models.handlers import ImageData

import asyncio


class TestImageProvider:
"""
Implementation of edutap.wallet_google.protocols.ImageProvider
"""

async def image_by_id(self, image_id: str) -> ImageData:
return ImageData(mimetype="image/jpeg", data=b"mock-a-jepg")
# return some predictable data for unit testing
if image_id == "OK":
return ImageData(mimetype="image/jpeg", data=b"mock-a-jepg")
if image_id == "ERROR":
raise LookupError("Image not found.")
if image_id == "CANCEL":
raise asyncio.CancelledError("Cancelled")
if image_id == "TIMEOUT":
await asyncio.sleep(0.5)
raise Exception("Unexpected image_id")


class TestCallbackHandler:
Expand All @@ -23,4 +34,9 @@ async def handle(
exp_time_millis: int,
count: int,
nonce: str,
) -> None: ...
) -> None:
if class_id == "TIMEOUT":
await asyncio.sleep(exp_time_millis / 1000)
elif class_id:
return
raise ValueError("class_id is required")
Loading

0 comments on commit 4569515

Please sign in to comment.