diff --git a/api/src/pcapi/routes/public/collective/__init__.py b/api/src/pcapi/routes/public/collective/__init__.py index 6e67df88506..430323c938e 100644 --- a/api/src/pcapi/routes/public/collective/__init__.py +++ b/api/src/pcapi/routes/public/collective/__init__.py @@ -1,5 +1,7 @@ from flask import Flask +from pcapi import settings + def install_routes(app: Flask) -> None: # pylint: disable=unused-import @@ -12,3 +14,8 @@ def install_routes(app: Flask) -> None: from .endpoints import offers from .endpoints import students_levels from .endpoints import venues + + if not settings.IS_PROD: + # do not import this route when inside production environment. + # it should not be exposed by automatic documentation tools. + from .endpoints.simulate_adage_steps import bookings as simulate_adage_bookings diff --git a/api/src/pcapi/routes/public/collective/endpoints/simulate_adage_steps/__init__.py b/api/src/pcapi/routes/public/collective/endpoints/simulate_adage_steps/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/api/src/pcapi/routes/public/collective/endpoints/simulate_adage_steps/bookings.py b/api/src/pcapi/routes/public/collective/endpoints/simulate_adage_steps/bookings.py new file mode 100644 index 00000000000..ae3d9893e21 --- /dev/null +++ b/api/src/pcapi/routes/public/collective/endpoints/simulate_adage_steps/bookings.py @@ -0,0 +1,56 @@ +from pcapi.core.bookings import exceptions as bookings_exceptions +from pcapi.core.educational import exceptions +from pcapi.core.educational.api import booking as booking_api +from pcapi.models.api_errors import ForbiddenError +from pcapi.models.api_errors import ResourceNotFoundError +from pcapi.routes.adage.v1.serialization import constants +from pcapi.routes.public import blueprints +from pcapi.routes.public import spectree_schemas +from pcapi.routes.public.collective.endpoints.simulate_adage_steps import utils +from pcapi.routes.public.documentation_constants import http_responses +from pcapi.routes.public.documentation_constants import tags +from pcapi.serialization.decorator import spectree_serialize +from pcapi.serialization.spec_tree import ExtendResponse as SpectreeResponse +from pcapi.validation.routes.users_authentifications import api_key_required + + +@blueprints.public_api.route("/v2/collective/adage_mock/bookings//confirm", methods=["POST"]) +@utils.exclude_prod_environment +@api_key_required +@spectree_serialize( + api=spectree_schemas.public_api_schema, + on_success_status=204, + tags=[tags.COLLECTIVE_ADAGE_MOCK], + resp=SpectreeResponse( + **( + http_responses.HTTP_204_COLLECTIVE_BOOKING_STATUS_UPDATE + | http_responses.HTTP_40X_SHARED_BY_API_ENDPOINTS + | http_responses.HTTP_403_COLLECTIVE_BOOKING_STATUS_UPDATE_REFUSED + | http_responses.HTTP_404_COLLECTIVE_OFFER_NOT_FOUND + ) + ), +) +def confirm_collective_booking(booking_id: int) -> None: + """ + Mock collective booking confirmation + + Like this could happen within the Adage platform. + + Warning: not available for production nor integration environments + """ + try: + booking_api.confirm_collective_booking(booking_id) + except exceptions.InsufficientFund: + raise ForbiddenError({"code": "INSUFFICIENT_FUND"}) + except exceptions.InsufficientMinistryFund: + raise ForbiddenError({"code": "INSUFFICIENT_MINISTRY_FUND"}) + except exceptions.InsufficientTemporaryFund: + raise ForbiddenError({"code": "INSUFFICIENT_FUND_DEPOSIT_NOT_FINAL"}) + except exceptions.BookingIsCancelled: + raise ForbiddenError({"code": "EDUCATIONAL_BOOKING_IS_CANCELLED"}) + except bookings_exceptions.ConfirmationLimitDateHasPassed: + raise ForbiddenError({"code": "CONFIRMATION_LIMIT_DATE_HAS_PASSED"}) + except exceptions.EducationalBookingNotFound: + raise ResourceNotFoundError({"code": constants.EDUCATIONAL_BOOKING_NOT_FOUND}) + except exceptions.EducationalDepositNotFound: + raise ResourceNotFoundError({"code": "DEPOSIT_NOT_FOUND"}) diff --git a/api/src/pcapi/routes/public/collective/endpoints/simulate_adage_steps/errors.py b/api/src/pcapi/routes/public/collective/endpoints/simulate_adage_steps/errors.py new file mode 100644 index 00000000000..b076b216c3a --- /dev/null +++ b/api/src/pcapi/routes/public/collective/endpoints/simulate_adage_steps/errors.py @@ -0,0 +1,17 @@ +from flask import current_app as app + +from pcapi import settings +from pcapi.models.api_errors import ApiErrors +from pcapi.routes.error_handlers.generic_error_handlers import ApiErrorResponse + + +class UnauthorizedEnvironment(Exception): + pass + + +@app.errorhandler(UnauthorizedEnvironment) +def handle_unauthorized_env(_: UnauthorizedEnvironment) -> ApiErrorResponse: + msg = f"unauthorized action from {settings.ENV}" + error = ApiErrors(status_code=403, errors={"msg": msg}) + + return app.generate_error_response(error.errors), error.status_code diff --git a/api/src/pcapi/routes/public/collective/endpoints/simulate_adage_steps/utils.py b/api/src/pcapi/routes/public/collective/endpoints/simulate_adage_steps/utils.py new file mode 100644 index 00000000000..93a0f4065f0 --- /dev/null +++ b/api/src/pcapi/routes/public/collective/endpoints/simulate_adage_steps/utils.py @@ -0,0 +1,24 @@ +from functools import wraps +import typing + +from pcapi import settings + +from .errors import UnauthorizedEnvironment + + +def exclude_prod_environment(func: typing.Callable) -> typing.Callable: + """Ensure a route it not executed from production environment. + + The only goal is to ensure that a critical route cannot be used + from the production environment. It wont prevent it from being + documented by automatic tools, to avoid this, ensure that the whole + route or module is not imported. + """ + + @wraps(func) + def decorated_function(*args: typing.Sequence, **kwargs: typing.Mapping) -> typing.Any: + if settings.IS_PROD: + raise UnauthorizedEnvironment() + return func(*args, **kwargs) + + return decorated_function diff --git a/api/src/pcapi/routes/public/documentation_constants/http_responses.py b/api/src/pcapi/routes/public/documentation_constants/http_responses.py index a141b1b3397..d397ca90c8c 100644 --- a/api/src/pcapi/routes/public/documentation_constants/http_responses.py +++ b/api/src/pcapi/routes/public/documentation_constants/http_responses.py @@ -19,6 +19,10 @@ "HTTP_204": (None, "This collective booking has been successfully cancelled") } +HTTP_204_COLLECTIVE_BOOKING_STATUS_UPDATE = { + "HTTP_204": (None, "This collective booking's status has been successfully updated") +} + # Client errors HTTP_400_BAD_REQUEST = { "HTTP_400": (None, "The request is invalid. The response body contains a list of errors."), @@ -73,6 +77,10 @@ "HTTP_403": (None, "You don't have enough rights to access or edit the collective offer"), } +HTTP_403_COLLECTIVE_BOOKING_STATUS_UPDATE_REFUSED = { + "HTTP_403": (None, "Collective booking status updated has been refused"), +} + # Specific 410 HTTP_410_BOOKING_CANCELED_OR_VALIDATED = { "HTTP_410": (None, "You cannot perform this action because the booking has either been validated or canceled.") diff --git a/api/src/pcapi/routes/public/documentation_constants/tags.py b/api/src/pcapi/routes/public/documentation_constants/tags.py index 0519922c184..1735b7b9937 100644 --- a/api/src/pcapi/routes/public/documentation_constants/tags.py +++ b/api/src/pcapi/routes/public/documentation_constants/tags.py @@ -37,6 +37,9 @@ COLLECTIVE_BOOKINGS = Tag(name="Collective bookings") COLLECTIVE_OFFER_ATTRIBUTES = Tag(name="Collective offer attributes") +# COLLECTIVE BOOKINGS ADAGE MOCK +COLLECTIVE_ADAGE_MOCK = Tag(name="Collective bookings Adage mock") + # COLLECTIVE OFFERS --- Deprecated COLLECTIVE_CATEGORIES = Tag(name="[DEPRECATED] Collective categories") COLLECTIVE_VENUES = Tag(name="[DEPRECATED] Collective venues") diff --git a/api/tests/routes/public/collective/endpoints/simulate_adage_steps/__init__.py b/api/tests/routes/public/collective/endpoints/simulate_adage_steps/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/api/tests/routes/public/collective/endpoints/simulate_adage_steps/test_bookings.py b/api/tests/routes/public/collective/endpoints/simulate_adage_steps/test_bookings.py new file mode 100644 index 00000000000..9c5088a7837 --- /dev/null +++ b/api/tests/routes/public/collective/endpoints/simulate_adage_steps/test_bookings.py @@ -0,0 +1,175 @@ +import contextlib +import datetime + +import pytest + +from pcapi.core.educational import factories +from pcapi.core.educational import models +from pcapi.core.testing import override_features +from pcapi.models import db +from pcapi.routes.adage.v1.serialization import constants + +from tests.routes.public.helpers import PublicAPIRestrictedEnvEndpointHelper + + +pytestmark = pytest.mark.usefixtures("db_session") + + +@pytest.fixture(name="current_year") +def current_year_fixture(): + return factories.EducationalYearFactory( + beginningDate=datetime.datetime(factories._get_current_educational_year(), 9, 1), + expirationDate=datetime.datetime(factories._get_current_educational_year() + 1, 8, 31, 23, 59), + ) + + +@pytest.fixture(name="institution") +def institution_fixture(current_year): + return factories.EducationalDepositFactory(educationalYear=current_year).educationalInstitution + + +@pytest.fixture(name="pending_booking") +def pending_booking_fixture(current_year, institution): + return factories.PendingCollectiveBookingFactory(educationalYear=current_year, educationalInstitution=institution) + + +@pytest.fixture(name="confirmed_booking") +def confirmed_booking_fixture(current_year, institution): + return factories.ConfirmedCollectiveBookingFactory(educationalYear=current_year, educationalInstitution=institution) + + +@pytest.fixture(name="used_booking") +def used_booking_fixture(current_year, institution): + return factories.UsedCollectiveBookingFactory( + educationalYear=current_year, + educationalInstitution=institution, + ) + + +@pytest.fixture(name="reimbursed_booking") +def reimbursed_booking_fixture(current_year, institution): + return factories.ReimbursedCollectiveBookingFactory( + educationalYear=current_year, educationalInstitution=institution + ) + + +@pytest.fixture(name="cancelled_booking") +def cancelled_booking_fixture(current_year, institution): + return factories.CancelledCollectiveBookingFactory(educationalYear=current_year, educationalInstitution=institution) + + +@contextlib.contextmanager +def assert_status_changes_to(booking, expected_status): + previous_status = booking.status + + yield + + db.session.refresh(booking) + assert booking.status != previous_status + assert booking.status == expected_status + + +@contextlib.contextmanager +def assert_status_does_not_change(booking): + previous_status = booking.status + + yield + + db.session.refresh(booking) + assert booking.status == previous_status + + +class ConfirmCollectiveBookingTest(PublicAPIRestrictedEnvEndpointHelper): + endpoint_url = "/v2/collective/adage_mock/bookings/{booking_id}/confirm" + endpoint_method = "post" + default_path_params = {"booking_id": 1} + + def setup_method(self): + self.plain_api_key, _ = self.setup_provider() + + def get_authenticated_client(self, client): + if not hasattr(self, "_authenticated_client"): + self._authenticated_client = client.with_explicit_token(self.plain_api_key) + return self._authenticated_client + + def test_confirm_pending_booking(self, client, pending_booking): + with assert_status_changes_to(pending_booking, models.CollectiveBookingStatus.CONFIRMED): + self.confirm_booking(client, pending_booking.id, status_code=204) + + def test_confirm_confirmed_booking(self, client, confirmed_booking): + with assert_status_does_not_change(confirmed_booking): + self.confirm_booking(client, confirmed_booking.id, status_code=204) + + def test_confirm_used_booking(self, client, used_booking): + error = {"code": "CONFIRMATION_LIMIT_DATE_HAS_PASSED"} + + with assert_status_does_not_change(used_booking): + self.confirm_booking(client, used_booking.id, status_code=403, json_error=error) + + def test_confirm_reimbursed_booking(self, client, reimbursed_booking): + error = {"code": "CONFIRMATION_LIMIT_DATE_HAS_PASSED"} + + with assert_status_does_not_change(reimbursed_booking): + self.confirm_booking(client, reimbursed_booking.id, status_code=403, json_error=error) + + def test_confirm_cancelled_booking(self, client, cancelled_booking): + error = {"code": "EDUCATIONAL_BOOKING_IS_CANCELLED"} + + with assert_status_does_not_change(cancelled_booking): + self.confirm_booking(client, cancelled_booking.id, status_code=403, json_error=error) + + def test_confirm_when_insufficient_fund(self, client, institution, pending_booking): + for deposit in institution.deposits: + deposit.amount = 0 + + error = {"code": "INSUFFICIENT_FUND"} + with assert_status_does_not_change(pending_booking): + self.confirm_booking(client, pending_booking.id, status_code=403, json_error=error) + + @override_features(ENABLE_EAC_FINANCIAL_PROTECTION=True) + def test_confirm_when_insufficient_ministry_fund(self, client, used_booking, pending_booking): + # ensure offer's stock start between september and december + # because this validation is not ran after and before that. + start = pending_booking.collectiveStock.startDatetime.replace(month=10) + pending_booking.collectiveStock.startDatetime = start + pending_booking.collectiveStock.beginningDatetime = start + + # pending booking price is within the the institution's budget + # but some special rules apply at the end of the year: the + # overall used budget must be at most 1/3 of the total. + institution = used_booking.educationalInstitution + deposit_amount = sum(deposit.amount for deposit in institution.deposits) + used_booking.collectiveStock.price = deposit_amount / 3 + + error = {"code": "INSUFFICIENT_MINISTRY_FUND"} + with assert_status_does_not_change(pending_booking): + self.confirm_booking(client, pending_booking.id, status_code=403, json_error=error) + + def test_confirm_when_insufficient_temporary_fund(self, client, institution, pending_booking): + for deposit in institution.deposits: + deposit.amount = 0 + deposit.isFinal = False + + error = {"code": "INSUFFICIENT_FUND_DEPOSIT_NOT_FINAL"} + with assert_status_does_not_change(pending_booking): + self.confirm_booking(client, pending_booking.id, status_code=403, json_error=error) + + def test_confirm_unknown_booking(self, client): + error = {"code": constants.EDUCATIONAL_BOOKING_NOT_FOUND} + self.confirm_booking(client, 0, status_code=404, json_error=error) + + def test_confirm_unknown_deposit(self, client, institution, pending_booking): + for deposit in institution.deposits: + deposit.educationalYear = factories.EducationalYearFactory() + + error = {"code": "DEPOSIT_NOT_FOUND"} + with assert_status_does_not_change(pending_booking): + self.confirm_booking(client, pending_booking.id, status_code=404, json_error=error) + + def confirm_booking(self, client, booking_id, status_code, json_error=None): + response = self.send_request(self.get_authenticated_client(client), url_params={"booking_id": booking_id}) + + assert response.status_code == status_code + if json_error: + for key, msg in json_error.items(): + assert response.json.get(key) == msg diff --git a/api/tests/routes/public/expected_openapi.json b/api/tests/routes/public/expected_openapi.json index bf960380ec5..8980c4a3b11 100644 --- a/api/tests/routes/public/expected_openapi.json +++ b/api/tests/routes/public/expected_openapi.json @@ -10791,6 +10791,60 @@ ] } }, + "/v2/collective/adage_mock/bookings/{booking_id}/confirm": { + "post": { + "description": "Like this could happen within the Adage platform.\n\nWarning: not available for production nor integration environments", + "operationId": "ConfirmCollectiveBooking", + "parameters": [ + { + "description": "", + "in": "path", + "name": "booking_id", + "required": true, + "schema": { + "format": "int32", + "type": "integer" + } + } + ], + "responses": { + "204": { + "description": "This collective booking's status has been successfully updated" + }, + "401": { + "description": "Authentication is necessary to use this API." + }, + "403": { + "description": "Collective booking status updated has been refused" + }, + "404": { + "description": "The collective offer could not be found." + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationError" + } + } + }, + "description": "Unprocessable Entity" + }, + "429": { + "description": "You have made too many requests. (**rate limit: 200 requests/minute**)" + } + }, + "security": [ + { + "ApiKeyAuth": [] + } + ], + "summary": "Mock collective booking confirmation", + "tags": [ + "Collective bookings Adage mock" + ] + } + }, "/v2/collective/bookings/{booking_id}": { "patch": { "description": "Cancel an collective event booking.", diff --git a/api/tests/routes/public/helpers.py b/api/tests/routes/public/helpers.py index f82229c59f6..a8634181bc2 100644 --- a/api/tests/routes/public/helpers.py +++ b/api/tests/routes/public/helpers.py @@ -10,6 +10,7 @@ from pcapi.core.offers import models as offers_models from pcapi.core.providers import factories as providers_factories from pcapi.core.providers import models as providers_models +from pcapi.core.testing import override_settings from tests.conftest import TestClient @@ -130,6 +131,36 @@ def setup_active_venue_provider( return plain_api_key, venue_provider +class PublicAPIRestrictedEnvEndpointHelper(PublicAPIEndpointBaseHelper): + @override_settings(IS_PROD=True) + def test_should_not_be_usable_from_production_env(self, client): + plain_api_key, _ = self.setup_provider() + authenticated_client = client.with_explicit_token(plain_api_key) + + url = self.endpoint_url + + if self.default_path_params: + url = url.format(**self.default_path_params) + + client_method = getattr(authenticated_client, self.endpoint_method) + response = client_method(url) + + assert response.status_code == 403 + assert "unauthorized action" in response.json["msg"] + + def send_request(self, client, url_params=None, **kwargs): + client_func = getattr(client, self.endpoint_method) + url = self.endpoint_url + + if self.default_path_params or url_params: + default = self.default_path_params if self.default_path_params else {} + extra = url_params if url_params else {} + params = {**default, **extra} + url = url.format(**params) + + return client_func(url, **kwargs) + + class ProductEndpointHelper: @staticmethod def create_base_product(