Skip to content
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,18 @@ jobs:
runs-on: ubuntu-latest
if: ${{ !contains(github.event.pull_request.title, '[skip tests]') }}

env:
MONGODB_URI: mongodb://db:27017
DB_NAME: todo-app
GOOGLE_JWT_SECRET_KEY: "test-secret-key-for-jwt"
GOOGLE_JWT_ACCESS_LIFETIME: "3600"
GOOGLE_JWT_REFRESH_LIFETIME: "604800"
GOOGLE_OAUTH_CLIENT_ID: "test-client-id"
GOOGLE_OAUTH_CLIENT_SECRET: "test-client-secret"
GOOGLE_OAUTH_REDIRECT_URI: "http://localhost:3000/auth/callback"
COOKIE_SECURE: "False"
COOKIE_SAMESITE: "Lax"

steps:
- name: Checkout code
uses: actions/checkout@v3
Expand Down
4 changes: 2 additions & 2 deletions todo/exceptions/exception_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
GoogleRefreshTokenExpiredError,
GoogleAPIException,
GoogleUserNotFoundException,
GoogleTokenMissingError
GoogleTokenMissingError,
)


Expand Down Expand Up @@ -89,7 +89,7 @@ def handle_exception(exc, context):
authenticated=False,
)
return Response(data=final_response_data.model_dump(mode="json", exclude_none=True), status=status_code)

elif isinstance(exc, GoogleTokenMissingError):
status_code = status.HTTP_401_UNAUTHORIZED
error_list.append(
Expand Down
46 changes: 26 additions & 20 deletions todo/middlewares/jwt_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,16 @@ def __call__(self, request):
error_response = ApiErrorResponse(
statusCode=status.HTTP_401_UNAUTHORIZED,
message=AuthErrorMessages.AUTHENTICATION_REQUIRED,
errors=[ApiErrorDetail(
title=ApiErrors.AUTHENTICATION_FAILED.format(""),
detail=AuthErrorMessages.AUTHENTICATION_REQUIRED
)],
errors=[
ApiErrorDetail(
title=ApiErrors.AUTHENTICATION_FAILED.format(""),
detail=AuthErrorMessages.AUTHENTICATION_REQUIRED,
)
],
)
return JsonResponse(
data=error_response.model_dump(mode="json", exclude_none=True), status=status.HTTP_401_UNAUTHORIZED
)
return JsonResponse(data=error_response.model_dump(mode="json", exclude_none=True), status=status.HTTP_401_UNAUTHORIZED)

except (TokenMissingError, TokenExpiredError, TokenInvalidError) as e:
return self._handle_rds_auth_error(e)
Expand All @@ -45,12 +49,16 @@ def __call__(self, request):
error_response = ApiErrorResponse(
statusCode=status.HTTP_401_UNAUTHORIZED,
message=ApiErrors.AUTHENTICATION_FAILED.format(""),
errors=[ApiErrorDetail(
title=ApiErrors.AUTHENTICATION_FAILED.format(""),
detail=AuthErrorMessages.AUTHENTICATION_REQUIRED
)],
errors=[
ApiErrorDetail(
title=ApiErrors.AUTHENTICATION_FAILED.format(""),
detail=AuthErrorMessages.AUTHENTICATION_REQUIRED,
)
],
)
return JsonResponse(
data=error_response.model_dump(mode="json", exclude_none=True), status=status.HTTP_401_UNAUTHORIZED
)
return JsonResponse(data=error_response.model_dump(mode="json", exclude_none=True), status=status.HTTP_401_UNAUTHORIZED)

def _try_authentication(self, request) -> bool:
if self._try_google_auth(request):
Expand Down Expand Up @@ -111,23 +119,21 @@ def _handle_rds_auth_error(self, exception):
error_response = ApiErrorResponse(
statusCode=status.HTTP_401_UNAUTHORIZED,
message=str(exception),
errors=[ApiErrorDetail(
title=ApiErrors.AUTHENTICATION_FAILED.format(""),
detail=str(exception)
)],
errors=[ApiErrorDetail(title=ApiErrors.AUTHENTICATION_FAILED.format(""), detail=str(exception))],
)
return JsonResponse(
data=error_response.model_dump(mode="json", exclude_none=True), status=status.HTTP_401_UNAUTHORIZED
)
return JsonResponse(data=error_response.model_dump(mode="json", exclude_none=True), status=status.HTTP_401_UNAUTHORIZED)

def _handle_google_auth_error(self, exception):
error_response = ApiErrorResponse(
statusCode=status.HTTP_401_UNAUTHORIZED,
message=str(exception),
errors=[ApiErrorDetail(
title=ApiErrors.AUTHENTICATION_FAILED.format(""),
detail=str(exception)
)],
errors=[ApiErrorDetail(title=ApiErrors.AUTHENTICATION_FAILED.format(""), detail=str(exception))],
)
return JsonResponse(
data=error_response.model_dump(mode="json", exclude_none=True), status=status.HTTP_401_UNAUTHORIZED
)
return JsonResponse(data=error_response.model_dump(mode="json", exclude_none=True), status=status.HTTP_401_UNAUTHORIZED)


def is_google_user(request) -> bool:
Expand Down
18 changes: 18 additions & 0 deletions todo/tests/fixtures/user.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from datetime import datetime, timezone

users_db_data = [
{
"google_id": "123456789",
"email_id": "[email protected]",
"name": "Test User",
"created_at": datetime.now(timezone.utc),
"updated_at": datetime.now(timezone.utc),
},
{
"google_id": "987654321",
"email_id": "[email protected]",
"name": "Another User",
"created_at": datetime.now(timezone.utc),
"updated_at": datetime.now(timezone.utc),
},
]
25 changes: 21 additions & 4 deletions todo/tests/integration/test_task_detail_api.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,31 @@
from http import HTTPStatus
from bson import ObjectId
from django.urls import reverse
from rest_framework.test import APIClient
from bson import ObjectId

from todo.tests.fixtures.task import tasks_db_data
from todo.tests.integration.base_mongo_test import BaseMongoTestCase
from todo.constants.messages import ApiErrors, ValidationErrors
from todo.utils.google_jwt_utils import generate_google_token_pair


class AuthenticatedMongoTestCase(BaseMongoTestCase):
def setUp(self):
super().setUp()
self._setup_auth_cookies()

def _setup_auth_cookies(self):
user_data = {
"user_id": str(ObjectId()),
"google_id": "test_google_id",
"email": "[email protected]",
"name": "Test User",
}
tokens = generate_google_token_pair(user_data)
self.client.cookies["ext-access"] = tokens["access_token"]
self.client.cookies["ext-refresh"] = tokens["refresh_token"]


class TaskDetailAPIIntegrationTest(BaseMongoTestCase):
class TaskDetailAPIIntegrationTest(AuthenticatedMongoTestCase):
def setUp(self):
super().setUp()
self.db.tasks.delete_many({}) # Clear tasks to avoid DuplicateKeyError
Expand All @@ -17,7 +35,6 @@ def setUp(self):
self.existing_task_id = str(self.task_doc["_id"])
self.non_existent_id = str(ObjectId())
self.invalid_task_id = "invalid-task-id"
self.client = APIClient()

def test_get_task_by_id_success(self):
url = reverse("task_detail", args=[self.existing_task_id])
Expand Down
25 changes: 21 additions & 4 deletions todo/tests/integration/test_tasks_delete.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,31 @@
from http import HTTPStatus
from bson import ObjectId
from django.urls import reverse
from rest_framework.test import APIClient
from bson import ObjectId

from todo.tests.fixtures.task import tasks_db_data
from todo.tests.integration.base_mongo_test import BaseMongoTestCase
from todo.constants.messages import ValidationErrors, ApiErrors
from todo.utils.google_jwt_utils import generate_google_token_pair


class AuthenticatedMongoTestCase(BaseMongoTestCase):
def setUp(self):
super().setUp()
self._setup_auth_cookies()

def _setup_auth_cookies(self):
user_data = {
"user_id": str(ObjectId()),
"google_id": "test_google_id",
"email": "[email protected]",
"name": "Test User",
}
tokens = generate_google_token_pair(user_data)
self.client.cookies["ext-access"] = tokens["access_token"]
self.client.cookies["ext-refresh"] = tokens["refresh_token"]


class TaskDeleteAPIIntegrationTest(BaseMongoTestCase):
class TaskDeleteAPIIntegrationTest(AuthenticatedMongoTestCase):
def setUp(self):
super().setUp()
self.db.tasks.delete_many({})
Expand All @@ -17,7 +35,6 @@ def setUp(self):
self.existing_task_id = str(task_doc["_id"])
self.non_existent_id = str(ObjectId())
self.invalid_task_id = "invalid-task-id"
self.client = APIClient()

def test_delete_task_success(self):
url = reverse("task_detail", args=[self.existing_task_id])
Expand Down
39 changes: 20 additions & 19 deletions todo/tests/unit/exceptions/test_exception_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,23 +14,24 @@
class ExceptionHandlerTests(TestCase):
@patch("todo.exceptions.exception_handler.format_validation_errors")
def test_returns_400_for_validation_error(self, mock_format_validation_errors: Mock):
validation_error = DRFValidationError(detail={"field": ["error message"]})
mock_format_validation_errors.return_value = [
ApiErrorDetail(detail="error message", source={ApiErrorSource.PARAMETER: "field"})
]

response = handle_exception(validation_error, {})

self.assertIsInstance(response, Response)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
expected_response = {
"statusCode": 400,
"message": "Invalid request",
"errors": [{"source": {"parameter": "field"}, "detail": "error message"}],
}
self.assertDictEqual(response.data, expected_response)

mock_format_validation_errors.assert_called_once_with(validation_error.detail)
error_detail = {"field": ["error message"]}
exception = DRFValidationError(detail=error_detail)
request = Mock()

with patch("todo.exceptions.exception_handler.format_validation_errors") as mock_format:
mock_format.return_value = [
ApiErrorDetail(detail="error message", source={ApiErrorSource.PARAMETER: "field"})
]
response = handle_exception(exception, {"request": request})

self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
expected_response = {
"statusCode": 400,
"message": "error message",
"errors": [{"source": {"parameter": "field"}, "detail": "error message"}],
}
self.assertDictEqual(response.data, expected_response)
mock_format.assert_called_once_with(error_detail)

def test_custom_handler_formats_generic_exception(self):
request = None
Expand All @@ -51,9 +52,9 @@ def test_custom_handler_formats_generic_exception(self):

expected_detail_obj_in_list = ApiErrorDetail(
detail=error_message if settings.DEBUG else ApiErrors.INTERNAL_SERVER_ERROR,
title=ApiErrors.UNEXPECTED_ERROR_OCCURRED,
title=error_message,
)
expected_main_message = ApiErrors.UNEXPECTED_ERROR_OCCURRED
expected_main_message = ApiErrors.INTERNAL_SERVER_ERROR

self.assertEqual(response.data.get("statusCode"), status.HTTP_500_INTERNAL_SERVER_ERROR)
self.assertEqual(response.data.get("message"), expected_main_message)
Expand Down
1 change: 1 addition & 0 deletions todo/tests/unit/middlewares/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# This file is required for Python to recognize this directory as a package
Loading