diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 2f861561b2..43f33b2fb4 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -48,6 +48,7 @@ RUN apt-get update \ libxss1 \ libasound2 \ libxtst6 \ + redis-tools \ xauth \ xvfb \ && apt-get autoremove -y \ @@ -66,5 +67,5 @@ COPY .devcontainer/scripts/notify-dev-entrypoint.sh /usr/local/bin/ ENV SHELL /bin/zsh -EXPOSE 8000 EXPOSE 6011 +EXPOSE 8000 diff --git a/app/annotations.py b/app/annotations.py index fae9bba48a..23104f4835 100644 --- a/app/annotations.py +++ b/app/annotations.py @@ -1,10 +1,45 @@ from functools import wraps -# from flask import current_app from inspect import signature -from app import signer_notification -from app.encryption import SignedNotification, SignedNotifications + +def sign_param(func): + """ + A decorator that signs parameters annotated with `PendingNotification` or `VerifiedNotification` + before passing them to the decorated function. + This decorator inspects the function's signature to find parameters annotated with + `PendingNotification` or `VerifiedNotification`. It then uses `signer_notification.sign` + to sign these parameters and replaces the original values with the signed values before + calling the decorated function. + Args: + func (Callable): The function to be decorated. + Returns: + Callable: The wrapped function with signed parameters. + """ + + @wraps(func) + def wrapper(*args, **kwargs): + from app import signer_notification + from app.queue import QueueMessage + + sig = signature(func) + + # Find the parameter annotated with VerifyAndSign + bound_args = sig.bind(*args, **kwargs) + bound_args.apply_defaults() + + for param_name, param in sig.parameters.items(): + if issubclass(param.annotation, QueueMessage): + unsigned: QueueMessage = bound_args.arguments[param_name] # type: ignore + signed_param = signer_notification.sign(unsigned.to_dict()) + # Replace the signed value with the verified value + bound_args.arguments[param_name] = signed_param + + # Call the decorated function with the signed value + result = func(*bound_args.args, **bound_args.kwargs) + return result + + return wrapper def unsign_params(func): @@ -22,6 +57,9 @@ def unsign_params(func): @wraps(func) def wrapper(*args, **kwargs): + from app import signer_notification + from app.types import SignedNotification, SignedNotifications + sig = signature(func) # Find the parameter annotated with VerifyAndSign @@ -59,6 +97,8 @@ def sign_return(func): @wraps(func) def wrapper(*args, **kwargs): + from app import signer_notification + # Call the decorated function with the verified value result = func(*args, **kwargs) diff --git a/app/celery/tasks.py b/app/celery/tasks.py index fc87a2a838..ba720291b5 100644 --- a/app/celery/tasks.py +++ b/app/celery/tasks.py @@ -60,7 +60,6 @@ from app.dao.services_dao import dao_fetch_service_by_id from app.dao.templates_dao import dao_get_template_by_id from app.email_limit_utils import fetch_todays_email_count -from app.encryption import SignedNotification from app.exceptions import DVLAException from app.models import ( BULK, @@ -87,7 +86,7 @@ ) from app.report.utils import generate_csv_from_notifications, send_requested_report_ready from app.sms_fragment_utils import fetch_todays_requested_sms_count -from app.types import VerifiedNotification +from app.types import SignedNotification, VerifiedNotification from app.utils import get_csv_max_rows, get_delivery_queue_for_template, get_fiscal_year from app.v2.errors import ( LiveServiceTooManyRequestsError, @@ -296,23 +295,25 @@ def save_smss(self, service_id: Optional[str], signed_notifications: List[Signed else: reply_to_text = service.get_default_sms_sender() # type: ignore - notification: VerifiedNotification = { - **_notification, # type: ignore - "notification_id": notification_id, - "reply_to_text": reply_to_text, - "service": service, - "key_type": _notification.get("key_type", KEY_TYPE_NORMAL), - "template_id": template.id, - "template_version": template.version, - "recipient": _notification.get("to"), - "personalisation": _notification.get("personalisation"), - "notification_type": SMS_TYPE, # type: ignore - "simulated": _notification.get("simulated", None), - "api_key_id": _notification.get("api_key", None), - "created_at": datetime.utcnow(), - "job_id": _notification.get("job", None), - "job_row_number": _notification.get("row_number", None), - } + notification = VerifiedNotification.from_dict( + { + **_notification, # type: ignore + "notification_id": notification_id, + "reply_to_text": reply_to_text, + "service": service, + "key_type": _notification.get("key_type", KEY_TYPE_NORMAL), + "template_id": template.id, + "template_version": template.version, + "recipient": _notification.get("to"), + "personalisation": _notification.get("personalisation"), + "notification_type": SMS_TYPE, # type: ignore + "simulated": _notification.get("simulated", None), + "api_key_id": _notification.get("api_key", None), + "created_at": datetime.utcnow(), + "job_id": _notification.get("job", None), + "job_row_number": _notification.get("row_number", None), + } + ) verified_notifications.append(notification) notification_id_queue[notification_id] = notification.get("queue") # type: ignore @@ -411,26 +412,29 @@ def save_emails(self, _service_id: Optional[str], signed_notifications: List[Sig else: reply_to_text = service.get_default_reply_to_email_address() - notification: VerifiedNotification = { - **_notification, # type: ignore - "notification_id": notification_id, - "reply_to_text": reply_to_text, - "service": service, - "key_type": _notification.get("key_type", KEY_TYPE_NORMAL), - "template_id": template.id, - "template_version": template.version, - "recipient": _notification.get("to"), - "personalisation": _notification.get("personalisation"), - "notification_type": EMAIL_TYPE, # type: ignore - "simulated": _notification.get("simulated", None), - "api_key_id": _notification.get("api_key", None), - "created_at": datetime.utcnow(), - "job_id": _notification.get("job", None), - "job_row_number": _notification.get("row_number", None), - } + verified_notification = VerifiedNotification.from_dict( + { + **_notification, # type: ignore + "notification_id": notification_id, + "reply_to_text": reply_to_text, + "service": service, + "key_type": _notification.get("key_type", KEY_TYPE_NORMAL), + "template_id": template.id, + "template_version": template.version, + "recipient": _notification.get("to"), + "personalisation": _notification.get("personalisation"), + "notification_type": EMAIL_TYPE, # type: ignore + "simulated": _notification.get("simulated", None), + "api_key_id": _notification.get("api_key", None), + "created_at": datetime.utcnow(), + "job_id": _notification.get("job", None), + "job_row_number": _notification.get("row_number", None), + "queue": _notification.get("queue", None), + } + ) - verified_notifications.append(notification) - notification_id_queue[notification_id] = notification.get("queue") # type: ignore + verified_notifications.append(verified_notification) + notification_id_queue[notification_id] = verified_notification.queue # type: ignore process_type = template.process_type try: @@ -755,6 +759,7 @@ def send_notify_no_reply(self, data): template = dao_get_template_by_id(current_app.config["NO_REPLY_TEMPLATE_ID"]) try: + # TODO: replace dict creation with VerifiedNotification.from_dict data_to_send = [ dict( template_id=template.id, diff --git a/app/encryption.py b/app/encryption.py index 642068333e..cabc1db996 100644 --- a/app/encryption.py +++ b/app/encryption.py @@ -1,31 +1,7 @@ -from typing import Any, List, NewType, Optional, TypedDict, cast +from typing import Any, List, cast from flask_bcrypt import check_password_hash, generate_password_hash from itsdangerous import URLSafeSerializer -from typing_extensions import NotRequired # type: ignore - -SignedNotification = NewType("SignedNotification", str) -SignedNotifications = NewType("SignedNotifications", List[SignedNotification]) - - -class NotificationDictToSign(TypedDict): - # todo: remove duplicate keys - # todo: remove all NotRequired and decide if key should be there or not - id: NotRequired[str] - template: str # actually template_id - service_id: NotRequired[str] - template_version: int - to: str # recipient - reply_to_text: NotRequired[str] - personalisation: Optional[dict] - simulated: NotRequired[bool] - api_key: str - key_type: str # should be ApiKeyType but I can't import that here - client_reference: Optional[str] - queue: Optional[str] - sender_id: Optional[str] - job: NotRequired[str] # actually job_id - row_number: Optional[Any] # should this be int or str? class CryptoSigner: @@ -42,22 +18,22 @@ def init_app(self, app: Any, secret_key: str | List[str], salt: str) -> None: self.serializer = URLSafeSerializer(secret_key) self.salt = salt - def sign(self, to_sign: str | NotificationDictToSign) -> str | bytes: + def sign(self, to_sign: str) -> str | bytes: """Sign a string or dict with the class secret key and salt. Args: - to_sign (str | NotificationDictToSign): The string or dict to sign. + to_sign (str): The string or dict to sign. Returns: str | bytes: The signed string or bytes. """ return self.serializer.dumps(to_sign, salt=self.salt) - def sign_with_all_keys(self, to_sign: str | NotificationDictToSign) -> List[str | bytes]: + def sign_with_all_keys(self, to_sign: str) -> List[str | bytes]: """Sign a string or dict with all the individual keys in the class secret key list, and the class salt. Args: - to_sign (str | NotificationDictToSign): The string or dict to sign. + to_sign (str): The string or dict to sign. Returns: List[str | bytes]: A list of signed values. diff --git a/app/notifications/process_notifications.py b/app/notifications/process_notifications.py index 1a58fa286c..f9b8cee0bf 100644 --- a/app/notifications/process_notifications.py +++ b/app/notifications/process_notifications.py @@ -12,7 +12,7 @@ ) from notifications_utils.timezones import convert_local_timezone_to_utc -from app import redis_store +from app import models, redis_store from app.celery import provider_tasks from app.celery.letters_pdf_tasks import create_letters_pdf from app.config import QueueNames @@ -295,7 +295,7 @@ def send_notification_to_queue(notification, research_mode, queue=None): ) -def persist_notifications(notifications: List[VerifiedNotification]) -> List[Notification]: +def persist_notifications(verifiedNotifications: List[VerifiedNotification]) -> List[Notification]: """ Persist Notifications takes a list of json objects and creates a list of Notifications that gets bulk inserted into the DB. @@ -303,32 +303,35 @@ def persist_notifications(notifications: List[VerifiedNotification]) -> List[Not lofnotifications = [] - for notification in notifications: - notification_created_at = notification.get("created_at") or datetime.utcnow() - notification_id = notification.get("notification_id", uuid.uuid4()) - notification_recipient = notification.get("recipient") or notification.get("to") - service_id = notification.get("service").id if notification.get("service") else None # type: ignore + for verifiedNotification in verifiedNotifications: + notification_created_at = verifiedNotification.created_at or datetime.utcnow() + notification_id = verifiedNotification.notification_id or uuid.uuid4() + notification_recipient = verifiedNotification.recipient or verifiedNotification.to + service_id = verifiedNotification.service.id if verifiedNotification.service else None # type: ignore # todo: potential bug. notification_obj is being created using some keys that don't exist on notification # reference, created_by_id, status, billable_units aren't keys on notification at this point notification_obj = Notification( id=notification_id, - template_id=notification.get("template_id"), - template_version=notification.get("template_version"), + template_id=verifiedNotification.template_id, + template_version=verifiedNotification.template_version, to=notification_recipient, service_id=service_id, - personalisation=notification.get("personalisation"), - notification_type=notification.get("notification_type"), - api_key_id=notification.get("api_key_id"), - key_type=notification.get("key_type"), + personalisation=verifiedNotification.personalisation, + notification_type=verifiedNotification.notification_type, + api_key_id=verifiedNotification.api_key_id, + key_type=verifiedNotification.key_type, created_at=notification_created_at, - job_id=notification.get("job_id"), - job_row_number=notification.get("job_row_number"), - client_reference=notification.get("client_reference"), - reference=notification.get("reference"), # type: ignore - created_by_id=notification.get("created_by_id"), # type: ignore - status=notification.get("status"), # type: ignore - reply_to_text=notification.get("reply_to_text"), - billable_units=notification.get("billable_units"), # type: ignore + job_id=verifiedNotification.job_id, + job_row_number=verifiedNotification.job_row_number, + client_reference=verifiedNotification.client_reference, + # REVIEW: We can remove these ones if possible, as these will be set later in the process: + # reference: this is the provider's reference and will be set on sending time + # created_by_id: this is the user who created the notification and will be set on sending time, used by one off or admin UI uploads + # reference=verifiedNotification.reference, # type: ignore + # created_by_id=verifiedNotification.created_by_id, # type: ignore + # billable_units=verifiedNotification.billable_units, # type: ignore + status=NOTIFICATION_CREATED, # type: ignore + reply_to_text=verifiedNotification.reply_to_text, ) template = dao_get_template_by_id(notification_obj.template_id, notification_obj.template_version, use_cache=True) service = dao_fetch_service_by_id(service_id, use_cache=True) @@ -336,29 +339,32 @@ def persist_notifications(notifications: List[VerifiedNotification]) -> List[Not notification=notification_obj, research_mode=service.research_mode, queue=get_delivery_queue_for_template(template) ) - if notification.get("notification_type") == SMS_TYPE: - formatted_recipient = validate_and_format_phone_number(notification_recipient, international=True) - recipient_info = get_international_phone_info(formatted_recipient) - notification_obj.normalised_to = formatted_recipient - notification_obj.international = recipient_info.international - notification_obj.phone_prefix = recipient_info.country_prefix - notification_obj.rate_multiplier = recipient_info.billable_units - elif notification.get("notification_type") == EMAIL_TYPE: - notification_obj.normalised_to = format_email_address(notification_recipient) - elif notification.get("notification_type") == LETTER_TYPE: - notification_obj.postage = notification.get("postage") or notification.get("template_postage") # type: ignore + match verifiedNotification.notification_type: + case models.SMS_TYPE: + formatted_recipient = validate_and_format_phone_number(notification_recipient, international=True) + recipient_info = get_international_phone_info(formatted_recipient) + notification_obj.normalised_to = formatted_recipient + notification_obj.international = recipient_info.international + notification_obj.phone_prefix = recipient_info.country_prefix + notification_obj.rate_multiplier = recipient_info.billable_units + case models.EMAIL_TYPE: + notification_obj.normalised_to = format_email_address(notification_recipient) + # case models.LETTER_TYPE: + # notification_obj.postage = verifiedNotification.postage # or verifiedNotification.template_postage + case _: + current_app.logger.debug(f"Notification type {verifiedNotification.notification_type} not handled") lofnotifications.append(notification_obj) - if notification.get("key_type") != KEY_TYPE_TEST: - service_id = notification.get("service").id # type: ignore + if verifiedNotification.key_type != KEY_TYPE_TEST: + service_id = verifiedNotification.service.id # type: ignore if redis_store.get(redis.daily_limit_cache_key(service_id)): redis_store.incr(redis.daily_limit_cache_key(service_id)) current_app.logger.info( "{} {} created at {}".format( - notification.get("notification_type"), - notification.get("notification_id"), - notification.get("notification_created_at"), # type: ignore + verifiedNotification.notification_type, + verifiedNotification.notification_id, + verifiedNotification.created_at, ) ) bulk_insert_notifications(lofnotifications) diff --git a/app/queue.py b/app/queue.py index 005c8fcb54..d5ff8ac09b 100644 --- a/app/queue.py +++ b/app/queue.py @@ -8,6 +8,7 @@ from flask import current_app from redis import Redis +from app.annotations import sign_param from app.aws.metrics import ( put_batch_saving_expiry_metric, put_batch_saving_inflight_metric, @@ -59,6 +60,31 @@ def inflight_name( return f"{self.inflight_prefix(suffix, process_type)}:{str(receipt)}" +class QueueMessage(ABC): + """ + Abstract base class for queue messages. + This class provides a template for creating queue messages that can be + converted to and from dictionaries, JSON strings, and signed strings. + Methods + ------- + to_dict() -> dict + Convert the object to a dictionary. Must be implemented by subclasses. + from_dict(cls, data: dict) + Create an object from a dictionary. Must be implemented by subclasses. + """ + + @abstractmethod + def to_dict(self) -> dict: + """Convert the object to a dictionary.""" + pass + + @classmethod + @abstractmethod + def from_dict(cls, data: dict): + """Create an object from a dictionary.""" + pass + + class Queue(ABC): """Queue interface for custom buffer. @@ -99,14 +125,14 @@ def acknowledge(self, receipt: UUID): pass @abstractmethod - def publish(self, message: str): + def publish(self, message: QueueMessage): """Publishes the message into the buffer queue. The message is put onto the back of the queue to be later processed in a FIFO order. Args: - message (str): Message to store into the queue. + message (QueueMessage): Message to store into the queue. """ pass @@ -193,8 +219,9 @@ def acknowledge(self, receipt: UUID) -> bool: put_batch_saving_inflight_processed(self.__metrics_logger, self, 1) return True - def publish(self, message: str): - self._redis_client.rpush(self._inbox, message) + @sign_param + def publish(self, message: QueueMessage): + self._redis_client.rpush(self._inbox, message) # type: ignore put_batch_saving_metric(self.__metrics_logger, self, 1) def __move_to_inflight(self, in_flight_key: str, count: int) -> list[str]: @@ -279,5 +306,5 @@ def poll(self, count=10) -> tuple[UUID, list[str]]: def acknowledge(self, receipt: UUID): pass - def publish(self, message: str): + def publish(self, message: QueueMessage): pass diff --git a/app/types.py b/app/types.py index 1bbb7c3fc7..d2e8073396 100644 --- a/app/types.py +++ b/app/types.py @@ -1,11 +1,42 @@ +from dataclasses import asdict, dataclass from datetime import datetime -from typing import Optional +from typing import List, NewType, Optional -from app.encryption import NotificationDictToSign from app.models import Job, NotificationType, Service +from app.queue import QueueMessage +SignedNotification = NewType("SignedNotification", str) +SignedNotifications = NewType("SignedNotifications", List[SignedNotification]) -class VerifiedNotification(NotificationDictToSign): + +@dataclass +class PendingNotification(QueueMessage): + # todo: remove duplicate keys + # todo: remove all NotRequired and decide if key should be there or not + id: str + template: str # actually template_id + service_id: str + template_version: int + to: str # recipient + personalisation: Optional[dict] + simulated: bool + api_key: str + key_type: str # should be ApiKeyType but can't import that here + client_reference: Optional[str] + reply_to_text: Optional[str] + + def to_dict(self) -> dict: + """Convert the object to a dictionary.""" + return asdict(self) + + @classmethod + def from_dict(cls, data: dict): + """Create an object from a dictionary.""" + return cls(**data) + + +@dataclass +class VerifiedNotification(PendingNotification): service: Service notification_id: str template_id: str @@ -15,3 +46,5 @@ class VerifiedNotification(NotificationDictToSign): created_at: datetime job_id: Optional[Job] job_row_number: Optional[int] + # postage: Optional[str] # for letters + # template_postage: Optional[str] # for letters diff --git a/app/v2/notifications/post_notifications.py b/app/v2/notifications/post_notifications.py index b3821e2147..2a70a16369 100644 --- a/app/v2/notifications/post_notifications.py +++ b/app/v2/notifications/post_notifications.py @@ -38,6 +38,7 @@ from app.dao.notifications_dao import update_notification_status_by_reference from app.dao.templates_dao import get_precompiled_letter_template from app.email_limit_utils import fetch_todays_email_count +from app.letters.utils import upload_letter_pdf from app.encryption import NotificationDictToSign from app.models import ( BULK, @@ -89,6 +90,7 @@ from app.schemas import job_schema from app.service.utils import safelisted_members from app.sms_fragment_utils import fetch_todays_requested_sms_count +from app.types import PendingNotification from app.utils import get_delivery_queue_for_template from app.v2.errors import BadRequestError from app.v2.notifications import v2_notification_blueprint @@ -379,7 +381,7 @@ def post_notification(notification_type: NotificationType): return jsonify(resp), 201 -def triage_notification_to_queues(notification_type: NotificationType, signed_notification_data, template: Template): +def triage_notification_to_queues(notification_type: NotificationType, notification: PendingNotification, template: Template): """Determine which queue to use based on notification_type and process_type Args: @@ -392,18 +394,18 @@ def triage_notification_to_queues(notification_type: NotificationType, signed_no """ if notification_type == SMS_TYPE: if template.process_type == PRIORITY: - sms_priority_publish.publish(signed_notification_data) + sms_priority_publish.publish(notification) elif template.process_type == NORMAL: - sms_normal_publish.publish(signed_notification_data) + sms_normal_publish.publish(notification) elif template.process_type == BULK: - sms_bulk_publish.publish(signed_notification_data) + sms_bulk_publish.publish(notification) elif notification_type == EMAIL_TYPE: if template.process_type == PRIORITY: - email_priority_publish.publish(signed_notification_data) + email_priority_publish.publish(notification) elif template.process_type == NORMAL: - email_normal_publish.publish(signed_notification_data) + email_normal_publish.publish(notification) elif template.process_type == BULK: - email_bulk_publish.publish(signed_notification_data) + email_bulk_publish.publish(notification) def process_sms_or_email_notification( @@ -423,22 +425,25 @@ def process_sms_or_email_notification( personalisation = process_document_uploads(form.get("personalisation"), service, simulated, template.id) - _notification: NotificationDictToSign = { - "id": create_uuid(), - "template": str(template.id), - "service_id": str(service.id), - "template_version": str(template.version), # type: ignore - "to": form_send_to, - "personalisation": personalisation, - "simulated": simulated, - "api_key": str(api_key.id), - "key_type": str(api_key.key_type), - "client_reference": form.get("reference", None), - "reply_to_text": reply_to_text, - } + _notification = PendingNotification.from_dict( + { + "id": create_uuid(), + "template": str(template.id), + "service_id": str(service.id), + "template_version": str(template.version), # type: ignore + "to": form_send_to, + "personalisation": personalisation, + "simulated": simulated, + "api_key": str(api_key.id), + "key_type": str(api_key.key_type), + "client_reference": form.get("reference", None), + "reply_to_text": reply_to_text, + # "queue": None, # review if necessary + # "sender_id": None, # review if necessary + } + ) - signed_notification_data = signer_notification.sign(_notification) - notification = {**_notification} + notification = _notification.to_dict() scheduled_for = form.get("scheduled_for", None) # Update the api_key last_used, we will only update this once per job @@ -463,7 +468,7 @@ def process_sms_or_email_notification( ) persist_scheduled_notification(notification.id, form["scheduled_for"]) elif not simulated: - triage_notification_to_queues(notification_type, signed_notification_data, template) + triage_notification_to_queues(notification_type, _notification, template) current_app.logger.info( f"Batch saving: {notification_type}/{template.process_type} {notification['id']} sent to buffer queue." diff --git a/tests/app/test_annotations.py b/tests/app/test_annotations.py index 26065c5f77..58ec2f0ab1 100644 --- a/tests/app/test_annotations.py +++ b/tests/app/test_annotations.py @@ -1,17 +1,50 @@ import pytest from itsdangerous.exc import BadSignature -from app import signer_notification -from app.annotations import sign_return, unsign_params -from app.encryption import CryptoSigner, SignedNotification, SignedNotifications +from app import create_uuid, signer_notification +from app.annotations import sign_param, sign_return, unsign_params +from app.encryption import CryptoSigner +from app.types import PendingNotification, SignedNotification, SignedNotifications + + +# This is defined outside of the test class because pymock cannot access an inlined +# function with a class' method easily. +@unsign_params +@sign_return +def func_to_unsign_and_return(signed_notification: SignedNotification): + return signed_notification class TestUnsignParamsAnnotation: @pytest.fixture(scope="class", autouse=True) - def setup_class(self, notify_api): + def setup_app(self, notify_api): # We just want to setup the notify_api flask app for tests within the class. pass + def test_non_signed_param(self): + @sign_param + def func_with_unsigned_param(signed_notification: PendingNotification): + return signed_notification + + non_signed = PendingNotification.from_dict( + { + "id": create_uuid(), + "template": create_uuid(), + "service_id": create_uuid(), + "template_version": 1, + "to": "mark.scout@lumon.com", + "personalisation": None, + "simulated": False, + "api_key": create_uuid(), + "key_type": "team", + "client_reference": None, + "reply_to_text": None, + } + ) + signed = func_with_unsigned_param(non_signed) + manually_signed = signer_notification.sign(non_signed.to_dict()) + assert signed == manually_signed + def test_unsign_with_bad_signature_notification(self, notify_api): @unsign_params def annotated_unsigned_function( @@ -101,3 +134,18 @@ def func_to_sign_return(): signed = func_to_sign_return() assert signed == 1 + + def test_unsign_and_return(self, mocker): + from tests.app import test_annotations + + ann_func_spy = mocker.spy(test_annotations, "func_to_unsign_and_return") + + msg = "raw notification" + signed = signer_notification.sign(msg) + unsigned = func_to_unsign_and_return(signed) + assert unsigned == signed + + # The annotated function should have been called with the signed value + # and return the same signed value as well. + ann_func_spy.assert_called_once_with(signed) + ann_func_spy.spy_return == signed