Skip to content

Commit 3270fb2

Browse files
test: adds test cases for authentication (#83)
* feat(auth): Integrated RDS Auth * feat(auth): Integrated RDS Auth * fix: env.example * refactor: changes based on ai pr review * feat(auth): Implemented google authentication for todo-app * resolved pr comments * resolved pr comments * resolved bot comments * removed exceptions from views file * refactored auth views * refactored unused field * resolved review comments and added status code to api responses * resolved pr comments * resolved pr comments * Tests for Authentication feature (#80) * fix: add auth to pass tests * tests: auth * lint and format * add test creds in test.yml for testing purpose * auth view tests * wip * fix tests based on latest pull of test containers * fixed tests based on updated response structure * rebased on updated auth and fixed tests * fixed workflow file * refactor based on ai pr reviews
1 parent 5bb4ec1 commit 3270fb2

File tree

17 files changed

+979
-110
lines changed

17 files changed

+979
-110
lines changed

.github/workflows/test.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,18 @@ jobs:
99
runs-on: ubuntu-latest
1010
if: ${{ !contains(github.event.pull_request.title, '[skip tests]') }}
1111

12+
env:
13+
MONGODB_URI: mongodb://db:27017
14+
DB_NAME: todo-app
15+
GOOGLE_JWT_SECRET_KEY: "test-secret-key-for-jwt"
16+
GOOGLE_JWT_ACCESS_LIFETIME: "3600"
17+
GOOGLE_JWT_REFRESH_LIFETIME: "604800"
18+
GOOGLE_OAUTH_CLIENT_ID: "test-client-id"
19+
GOOGLE_OAUTH_CLIENT_SECRET: "test-client-secret"
20+
GOOGLE_OAUTH_REDIRECT_URI: "http://localhost:3000/auth/callback"
21+
COOKIE_SECURE: "False"
22+
COOKIE_SAMESITE: "Lax"
23+
1224
steps:
1325
- name: Checkout code
1426
uses: actions/checkout@v3

todo/exceptions/exception_handler.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
GoogleRefreshTokenExpiredError,
1919
GoogleAPIException,
2020
GoogleUserNotFoundException,
21-
GoogleTokenMissingError
21+
GoogleTokenMissingError,
2222
)
2323

2424

@@ -89,7 +89,7 @@ def handle_exception(exc, context):
8989
authenticated=False,
9090
)
9191
return Response(data=final_response_data.model_dump(mode="json", exclude_none=True), status=status_code)
92-
92+
9393
elif isinstance(exc, GoogleTokenMissingError):
9494
status_code = status.HTTP_401_UNAUTHORIZED
9595
error_list.append(

todo/middlewares/jwt_auth.py

Lines changed: 27 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,16 @@ def __call__(self, request):
3030
error_response = ApiErrorResponse(
3131
statusCode=status.HTTP_401_UNAUTHORIZED,
3232
message=AuthErrorMessages.AUTHENTICATION_REQUIRED,
33-
errors=[ApiErrorDetail(
34-
title=ApiErrors.AUTHENTICATION_FAILED.format(""),
35-
detail=AuthErrorMessages.AUTHENTICATION_REQUIRED
36-
)],
33+
errors=[
34+
ApiErrorDetail(
35+
title=ApiErrors.AUTHENTICATION_FAILED,
36+
detail=AuthErrorMessages.AUTHENTICATION_REQUIRED,
37+
)
38+
],
39+
)
40+
return JsonResponse(
41+
data=error_response.model_dump(mode="json", exclude_none=True), status=status.HTTP_401_UNAUTHORIZED
3742
)
38-
return JsonResponse(data=error_response.model_dump(mode="json", exclude_none=True), status=status.HTTP_401_UNAUTHORIZED)
3943

4044
except (TokenMissingError, TokenExpiredError, TokenInvalidError) as e:
4145
return self._handle_rds_auth_error(e)
@@ -44,13 +48,17 @@ def __call__(self, request):
4448
except Exception:
4549
error_response = ApiErrorResponse(
4650
statusCode=status.HTTP_401_UNAUTHORIZED,
47-
message=ApiErrors.AUTHENTICATION_FAILED.format(""),
48-
errors=[ApiErrorDetail(
49-
title=ApiErrors.AUTHENTICATION_FAILED.format(""),
50-
detail=AuthErrorMessages.AUTHENTICATION_REQUIRED
51-
)],
51+
message=ApiErrors.AUTHENTICATION_FAILED,
52+
errors=[
53+
ApiErrorDetail(
54+
title=ApiErrors.AUTHENTICATION_FAILED,
55+
detail=AuthErrorMessages.AUTHENTICATION_REQUIRED,
56+
)
57+
],
58+
)
59+
return JsonResponse(
60+
data=error_response.model_dump(mode="json", exclude_none=True), status=status.HTTP_401_UNAUTHORIZED
5261
)
53-
return JsonResponse(data=error_response.model_dump(mode="json", exclude_none=True), status=status.HTTP_401_UNAUTHORIZED)
5462

5563
def _try_authentication(self, request) -> bool:
5664
if self._try_google_auth(request):
@@ -111,23 +119,21 @@ def _handle_rds_auth_error(self, exception):
111119
error_response = ApiErrorResponse(
112120
statusCode=status.HTTP_401_UNAUTHORIZED,
113121
message=str(exception),
114-
errors=[ApiErrorDetail(
115-
title=ApiErrors.AUTHENTICATION_FAILED.format(""),
116-
detail=str(exception)
117-
)],
122+
errors=[ApiErrorDetail(title=ApiErrors.AUTHENTICATION_FAILED, detail=str(exception))],
123+
)
124+
return JsonResponse(
125+
data=error_response.model_dump(mode="json", exclude_none=True), status=status.HTTP_401_UNAUTHORIZED
118126
)
119-
return JsonResponse(data=error_response.model_dump(mode="json", exclude_none=True), status=status.HTTP_401_UNAUTHORIZED)
120127

121128
def _handle_google_auth_error(self, exception):
122129
error_response = ApiErrorResponse(
123130
statusCode=status.HTTP_401_UNAUTHORIZED,
124131
message=str(exception),
125-
errors=[ApiErrorDetail(
126-
title=ApiErrors.AUTHENTICATION_FAILED.format(""),
127-
detail=str(exception)
128-
)],
132+
errors=[ApiErrorDetail(title=ApiErrors.AUTHENTICATION_FAILED, detail=str(exception))],
133+
)
134+
return JsonResponse(
135+
data=error_response.model_dump(mode="json", exclude_none=True), status=status.HTTP_401_UNAUTHORIZED
129136
)
130-
return JsonResponse(data=error_response.model_dump(mode="json", exclude_none=True), status=status.HTTP_401_UNAUTHORIZED)
131137

132138

133139
def is_google_user(request) -> bool:

todo/tests/fixtures/user.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
from datetime import datetime, timezone
2+
3+
users_db_data = [
4+
{
5+
"google_id": "123456789",
6+
"email_id": "[email protected]",
7+
"name": "Test User",
8+
"created_at": datetime.now(timezone.utc),
9+
"updated_at": datetime.now(timezone.utc),
10+
},
11+
{
12+
"google_id": "987654321",
13+
"email_id": "[email protected]",
14+
"name": "Another User",
15+
"created_at": datetime.now(timezone.utc),
16+
"updated_at": datetime.now(timezone.utc),
17+
},
18+
]

todo/tests/integration/test_task_detail_api.py

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,31 @@
11
from http import HTTPStatus
2-
from bson import ObjectId
32
from django.urls import reverse
4-
from rest_framework.test import APIClient
3+
from bson import ObjectId
4+
55
from todo.tests.fixtures.task import tasks_db_data
66
from todo.tests.integration.base_mongo_test import BaseMongoTestCase
77
from todo.constants.messages import ApiErrors, ValidationErrors
8+
from todo.utils.google_jwt_utils import generate_google_token_pair
9+
10+
11+
class AuthenticatedMongoTestCase(BaseMongoTestCase):
12+
def setUp(self):
13+
super().setUp()
14+
self._setup_auth_cookies()
15+
16+
def _setup_auth_cookies(self):
17+
user_data = {
18+
"user_id": str(ObjectId()),
19+
"google_id": "test_google_id",
20+
"email": "[email protected]",
21+
"name": "Test User",
22+
}
23+
tokens = generate_google_token_pair(user_data)
24+
self.client.cookies["ext-access"] = tokens["access_token"]
25+
self.client.cookies["ext-refresh"] = tokens["refresh_token"]
826

927

10-
class TaskDetailAPIIntegrationTest(BaseMongoTestCase):
28+
class TaskDetailAPIIntegrationTest(AuthenticatedMongoTestCase):
1129
def setUp(self):
1230
super().setUp()
1331
self.db.tasks.delete_many({}) # Clear tasks to avoid DuplicateKeyError
@@ -17,7 +35,6 @@ def setUp(self):
1735
self.existing_task_id = str(self.task_doc["_id"])
1836
self.non_existent_id = str(ObjectId())
1937
self.invalid_task_id = "invalid-task-id"
20-
self.client = APIClient()
2138

2239
def test_get_task_by_id_success(self):
2340
url = reverse("task_detail", args=[self.existing_task_id])

todo/tests/integration/test_tasks_delete.py

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,31 @@
11
from http import HTTPStatus
2-
from bson import ObjectId
32
from django.urls import reverse
4-
from rest_framework.test import APIClient
3+
from bson import ObjectId
4+
55
from todo.tests.fixtures.task import tasks_db_data
66
from todo.tests.integration.base_mongo_test import BaseMongoTestCase
77
from todo.constants.messages import ValidationErrors, ApiErrors
8+
from todo.utils.google_jwt_utils import generate_google_token_pair
9+
10+
11+
class AuthenticatedMongoTestCase(BaseMongoTestCase):
12+
def setUp(self):
13+
super().setUp()
14+
self._setup_auth_cookies()
15+
16+
def _setup_auth_cookies(self):
17+
user_data = {
18+
"user_id": str(ObjectId()),
19+
"google_id": "test_google_id",
20+
"email": "[email protected]",
21+
"name": "Test User",
22+
}
23+
tokens = generate_google_token_pair(user_data)
24+
self.client.cookies["ext-access"] = tokens["access_token"]
25+
self.client.cookies["ext-refresh"] = tokens["refresh_token"]
826

927

10-
class TaskDeleteAPIIntegrationTest(BaseMongoTestCase):
28+
class TaskDeleteAPIIntegrationTest(AuthenticatedMongoTestCase):
1129
def setUp(self):
1230
super().setUp()
1331
self.db.tasks.delete_many({})
@@ -17,7 +35,6 @@ def setUp(self):
1735
self.existing_task_id = str(task_doc["_id"])
1836
self.non_existent_id = str(ObjectId())
1937
self.invalid_task_id = "invalid-task-id"
20-
self.client = APIClient()
2138

2239
def test_delete_task_success(self):
2340
url = reverse("task_detail", args=[self.existing_task_id])

todo/tests/unit/exceptions/test_exception_handler.py

Lines changed: 21 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -12,25 +12,25 @@
1212

1313

1414
class ExceptionHandlerTests(TestCase):
15-
@patch("todo.exceptions.exception_handler.format_validation_errors")
16-
def test_returns_400_for_validation_error(self, mock_format_validation_errors: Mock):
17-
validation_error = DRFValidationError(detail={"field": ["error message"]})
18-
mock_format_validation_errors.return_value = [
19-
ApiErrorDetail(detail="error message", source={ApiErrorSource.PARAMETER: "field"})
20-
]
21-
22-
response = handle_exception(validation_error, {})
23-
24-
self.assertIsInstance(response, Response)
25-
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
26-
expected_response = {
27-
"statusCode": 400,
28-
"message": "Invalid request",
29-
"errors": [{"source": {"parameter": "field"}, "detail": "error message"}],
30-
}
31-
self.assertDictEqual(response.data, expected_response)
32-
33-
mock_format_validation_errors.assert_called_once_with(validation_error.detail)
15+
def test_returns_400_for_validation_error(self):
16+
error_detail = {"field": ["error message"]}
17+
exception = DRFValidationError(detail=error_detail)
18+
request = Mock()
19+
20+
with patch("todo.exceptions.exception_handler.format_validation_errors") as mock_format:
21+
mock_format.return_value = [
22+
ApiErrorDetail(detail="error message", source={ApiErrorSource.PARAMETER: "field"})
23+
]
24+
response = handle_exception(exception, {"request": request})
25+
26+
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
27+
expected_response = {
28+
"statusCode": 400,
29+
"message": "error message",
30+
"errors": [{"source": {"parameter": "field"}, "detail": "error message"}],
31+
}
32+
self.assertDictEqual(response.data, expected_response)
33+
mock_format.assert_called_once_with(error_detail)
3434

3535
def test_custom_handler_formats_generic_exception(self):
3636
request = None
@@ -51,9 +51,9 @@ def test_custom_handler_formats_generic_exception(self):
5151

5252
expected_detail_obj_in_list = ApiErrorDetail(
5353
detail=error_message if settings.DEBUG else ApiErrors.INTERNAL_SERVER_ERROR,
54-
title=ApiErrors.UNEXPECTED_ERROR_OCCURRED,
54+
title=error_message,
5555
)
56-
expected_main_message = ApiErrors.UNEXPECTED_ERROR_OCCURRED
56+
expected_main_message = ApiErrors.INTERNAL_SERVER_ERROR
5757

5858
self.assertEqual(response.data.get("statusCode"), status.HTTP_500_INTERNAL_SERVER_ERROR)
5959
self.assertEqual(response.data.get("message"), expected_main_message)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# This file is required for Python to recognize this directory as a package

0 commit comments

Comments
 (0)