Skip to content

Commit

Permalink
feat: ahjo download link and callback url
Browse files Browse the repository at this point in the history
* feat: add basic auth class for ahjo integration

* feat: add api endpoint where Ahjo can download attachments

* feat: add permission class for whitelisting ip-addresses

* feat: switch to DRF token auth, bypass ip-restriction with mock flag

* feat: add audit_log entry when Ahjo downloads an attachment

* feat: add OpenApi documentation

* fix: remove unneeded BasicAuth subclassing and setting

* fix: code style

* feat: callback url for Ahjo

* refactor: reduce code, add status enums
  • Loading branch information
rikuke authored Nov 20, 2023
1 parent 17b7289 commit 33bf40b
Show file tree
Hide file tree
Showing 9 changed files with 341 additions and 1 deletion.
130 changes: 130 additions & 0 deletions backend/benefit/applications/api/v1/ahjo_integration_views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import logging

from django.http import FileResponse
from django.shortcuts import get_object_or_404
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema, OpenApiParameter
from rest_framework import status
from rest_framework.authentication import TokenAuthentication
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView

from applications.api.v1.serializers.ahjo_callback import AhjoCallbackSerializer
from applications.enums import AhjoCallBackStatus, AhjoStatus as AhjoStatusEnum
from applications.models import AhjoStatus, Application, Attachment
from common.permissions import SafeListPermission
from shared.audit_log import audit_logging
from shared.audit_log.enums import Operation

LOGGER = logging.getLogger(__name__)


class AhjoAttachmentView(APIView):
authentication_classes = [
TokenAuthentication,
]
permission_classes = [IsAuthenticated, SafeListPermission]

@extend_schema(
parameters=[
OpenApiParameter(
name="uuid",
description="UUID of the attachment",
required=True,
type=OpenApiTypes.UUID,
location=OpenApiParameter.PATH,
)
],
description="Returns the specified attachment file if it exists.",
)
def get(self, request, *args, **kwargs):
attachment_id = self.kwargs["uuid"]

attachment = get_object_or_404(Attachment, pk=attachment_id)
audit_logging.log(
request.user,
"", # Optional user backend
Operation.READ,
attachment,
additional_information="attachment was sent to AHJO!",
)
return self._prepare_file_response(attachment)

@staticmethod
def _prepare_file_response(attachment: Attachment) -> FileResponse:
file_handle = attachment.attachment_file.open()
response = FileResponse(file_handle, content_type=attachment.content_type)
response["Content-Length"] = attachment.attachment_file.size
response[
"Content-Disposition"
] = f"attachment; filename={attachment.attachment_file.name}"
return response


class AhjoCallbackView(APIView):
authentication_classes = [
TokenAuthentication,
]
permission_classes = [IsAuthenticated, SafeListPermission]

@extend_schema(
parameters=[
OpenApiParameter(
name="uuid",
description="UUID of the application",
required=True,
type=OpenApiTypes.UUID,
location=OpenApiParameter.PATH,
)
],
description="Callback endpoint for Ahjo to send updates to the application.",
request=AhjoCallbackSerializer,
responses={
(status.HTTP_200_OK, "text/json"): OpenApiTypes.OBJECT,
(status.HTTP_404_NOT_FOUND, "text/json"): OpenApiTypes.OBJECT,
},
)
def post(self, request, *args, **kwargs):
serializer = AhjoCallbackSerializer(data=request.data)

if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

callback_data = serializer.validated_data
application_id = self.kwargs["uuid"]

if callback_data["message"] == AhjoCallBackStatus.SUCCESS:
ahjo_request_id = callback_data["requestId"]
application = get_object_or_404(Application, pk=application_id)
application.ahjo_case_guid = callback_data["caseGuid"]
application.ahjo_case_id = callback_data["caseId"]
application.save()

AhjoStatus.objects.create(
application=application, status=AhjoStatusEnum.CASE_OPENED
)

audit_logging.log(
request.user,
"",
Operation.UPDATE,
application,
additional_information=f"""Application ahjo_case_guid and ahjo_case_id were updated
by Ahjo request id: {ahjo_request_id}""",
)

return Response(
{"message": "Callback received"},
status=status.HTTP_200_OK,
)

elif callback_data["message"] == AhjoCallBackStatus.FAILURE:
LOGGER.error(
f"""Error: Received unsuccessful callback from Ahjo
for application {application_id} with request_id {callback_data['requestId']}"""
)
return Response(
{"message": "Callback received but unsuccessful"},
status=status.HTTP_200_OK,
)
15 changes: 15 additions & 0 deletions backend/benefit/applications/api/v1/serializers/ahjo_callback.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from rest_framework import serializers


class AhjoCallbackSerializer(serializers.Serializer):
message = serializers.CharField()
requestId = serializers.UUIDField(format="hex_verbose")
caseId = serializers.CharField()
caseGuid = serializers.UUIDField(format="hex_verbose")

# You can add additional validation here if needed
def validate_message(self, message):
# Example of custom field validation
if message not in ["Success", "Failure"]:
raise serializers.ValidationError("Invalid message value.")
return message
5 changes: 5 additions & 0 deletions backend/benefit/applications/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,3 +167,8 @@ class ApplicationActions(models.TextChoices):
APPLICANT_TOGGLE_EDIT = "APPLICANT_TOGGLE_EDIT", _(
"Allow/disallow applicant's modifications"
)


class AhjoCallBackStatus(models.TextChoices):
SUCCESS = "Success", _("Success")
FAILURE = "Failure", _("Failure")
135 changes: 134 additions & 1 deletion backend/benefit/applications/tests/test_ahjo_integration.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
import io
import uuid
import zipfile
from datetime import date
from typing import List
from unittest.mock import patch

import pytest
from django.http import FileResponse
from django.urls import reverse

from applications.enums import ApplicationStatus, BenefitType
from applications.api.v1.ahjo_integration_views import AhjoAttachmentView
from applications.enums import (
AhjoCallBackStatus,
AhjoStatus,
ApplicationStatus,
BenefitType,
)
from applications.models import Application
from applications.services.ahjo_integration import (
ACCEPTED_TITLE,
Expand Down Expand Up @@ -277,3 +286,127 @@ def test_multiple_benefit_per_application(mock_pdf_convert):
"2493",
),
)


def test_prepare_ahjo_file_response(decided_application):
attachment = decided_application.attachments.first()
response = AhjoAttachmentView._prepare_file_response(attachment)

assert isinstance(response, FileResponse)

assert response["Content-Length"] == f"{attachment.attachment_file.size}"
assert (
response["Content-Disposition"]
== f"attachment; filename={attachment.attachment_file.name}"
)
assert response["Content-Type"] == f"{attachment.content_type}"


@pytest.fixture
def attachment(decided_application):
return decided_application.attachments.first()


def test_get_attachment_success(ahjo_client, attachment, ahjo_user_token, settings):
settings.NEXT_PUBLIC_MOCK_FLAG = True
url = reverse("ahjo_attachment_url", kwargs={"uuid": attachment.id})

auth_headers = {"HTTP_AUTHORIZATION": "Token " + ahjo_user_token.key}

response = ahjo_client.get(url, **auth_headers)

assert response.status_code == 200
assert response["Content-Type"] == f"{attachment.content_type}"
assert response["Content-Length"] == f"{attachment.attachment_file.size}"
assert (
response["Content-Disposition"]
== f"attachment; filename={attachment.attachment_file.name}"
)


def test_get_attachment_not_found(ahjo_client, ahjo_user_token, settings):
settings.NEXT_PUBLIC_MOCK_FLAG = True
id = uuid.uuid4()
url = reverse("ahjo_attachment_url", kwargs={"uuid": id})
auth_headers = {"HTTP_AUTHORIZATION": "Token " + ahjo_user_token.key}

response = ahjo_client.get(url, **auth_headers)

assert response.status_code == 404


def test_get_attachment_unauthorized_wrong_or_missing_credentials(
anonymous_client, attachment, settings
):
settings.NEXT_PUBLIC_MOCK_FLAG = True
# without any auth headers
url = reverse("ahjo_attachment_url", kwargs={"uuid": attachment.id})
response = anonymous_client.get(url)

assert response.status_code == 401
# with incorrect auth token
response = anonymous_client.get(
url,
headers={"Authorization": "Token 9944b09199c62bcf9418ad846dd0e4bbdfc6ee4b"},
)
assert response.status_code == 401


def test_get_attachment_unauthorized_ip_not_allowed(
ahjo_client, ahjo_user_token, attachment, settings
):
settings.NEXT_PUBLIC_MOCK_FLAG = False
url = reverse("ahjo_attachment_url", kwargs={"uuid": attachment.id})
auth_headers = {"HTTP_AUTHORIZATION": "Token " + ahjo_user_token.key}

response = ahjo_client.get(url, **auth_headers)
assert response.status_code == 403


@pytest.mark.django_db
def test_ahjo_callback_success(
ahjo_client, ahjo_user_token, decided_application, settings
):
settings.NEXT_PUBLIC_MOCK_FLAG = True
auth_headers = {"HTTP_AUTHORIZATION": "Token " + ahjo_user_token.key}
callback_payload = {
"message": AhjoCallBackStatus.SUCCESS,
"requestId": f"{uuid.uuid4()}",
"caseId": "HEL 2023-999999",
"caseGuid": f"{uuid.uuid4()}",
}
url = reverse("ahjo_callback_url", kwargs={"uuid": decided_application.id})
response = ahjo_client.post(url, **auth_headers, data=callback_payload)

decided_application.refresh_from_db()
assert response.status_code == 200
assert response.data == {"message": "Callback received"}
assert decided_application.ahjo_case_id == callback_payload["caseId"]
assert str(decided_application.ahjo_case_guid) == callback_payload["caseGuid"]
assert decided_application.ahjo_status.latest().status == AhjoStatus.CASE_OPENED


def test_ahjo_callback_unauthorized_wrong_or_missing_credentials(
anonymous_client, decided_application, settings
):
settings.NEXT_PUBLIC_MOCK_FLAG = True
url = reverse("ahjo_callback_url", kwargs={"uuid": decided_application.id})
response = anonymous_client.post(url)

assert response.status_code == 401
response = anonymous_client.post(
url,
headers={"Authorization": "Token 9944b09199c62bcf9418ad846dd0e4bbdfc6ee4b"},
)
assert response.status_code == 401


def test_ahjo_callback_unauthorized_ip_not_allowed(
ahjo_client, ahjo_user_token, decided_application, settings
):
settings.NEXT_PUBLIC_MOCK_FLAG = False
url = reverse("ahjo_callback_url", kwargs={"uuid": decided_application.id})
auth_headers = {"HTTP_AUTHORIZATION": "Token " + ahjo_user_token.key}

response = ahjo_client.post(url, **auth_headers)
assert response.status_code == 403
1 change: 1 addition & 0 deletions backend/benefit/common/authentications.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ class RobotBasicAuthentication(authentication.BaseAuthentication):
"""

www_authenticate_realm = "api"

credentials = settings.TALPA_ROBOT_AUTH_CREDENTIAL

def authenticate(self, request):
Expand Down
19 changes: 19 additions & 0 deletions backend/benefit/common/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,22 @@ def has_permission(self, request, view):
# proceed without a company.
raise PermissionDenied(_("Company information is not available"))
return not TermsOfServiceApproval.terms_approval_needed(user, company)


class SafeListPermission(permissions.BasePermission):
"""
Ensure the request's IP address is on the safe list configured in Django settings.
"""

message = _("Your IP address is not on the safe list.")

def has_permission(self, request, view):
remote_addr = request.META["REMOTE_ADDR"]
if settings.NEXT_PUBLIC_MOCK_FLAG:
# disable safe list check in mock mode
return True

for valid_ip in settings.REST_SAFE_LIST_IPS:
if remote_addr == valid_ip:
return True
return False
19 changes: 19 additions & 0 deletions backend/benefit/common/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from django.utils.translation import activate
from freezegun import freeze_time
from langdetect import DetectorFactory
from rest_framework.authtoken.models import Token
from rest_framework.test import APIClient

from shared.common.tests.conftest import * # noqa
Expand Down Expand Up @@ -63,3 +64,21 @@ def anonymous_client():

def get_client_user(api_client):
return api_client.handler._force_user


@pytest.fixture
def ahjo_user(user):
user.username = "ahjo_user"
return user


@pytest.fixture
def ahjo_user_token(ahjo_user):
token = Token.objects.create(user=ahjo_user)
return token


@pytest.fixture
def ahjo_client():
client = _api_client()
return client
4 changes: 4 additions & 0 deletions backend/benefit/helsinkibenefit/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@
AHJO_TOKEN_URL=(str, ""),
AHJO_REST_API_URL=(str, "https://ahjohyte.hel.fi:9802/ahjorest/v1"),
AHJO_REDIRECT_URL=(str, "https://helsinkilisa/dummyredirect.html"),
AHJO_ALLOWED_IP=(str, ""),
)
if os.path.exists(env_file):
env.read_env(env_file)
Expand Down Expand Up @@ -242,6 +243,7 @@
"django.contrib.staticfiles",
"corsheaders",
"rest_framework",
"rest_framework.authtoken",
"django_filters",
"drf_spectacular",
"phonenumber_field",
Expand Down Expand Up @@ -507,3 +509,5 @@
AHJO_TOKEN_URL = env("AHJO_TOKEN_URL")
AHJO_REST_API_URL = env("AHJO_REST_API_URL")
AHJO_REDIRECT_URL = env("AHJO_REDIRECT_URL")

REST_SAFE_LIST_IPS = (env("AHJO_ALLOWED_IP"),)
Loading

0 comments on commit 33bf40b

Please sign in to comment.