-
Notifications
You must be signed in to change notification settings - Fork 24
Add authentication tests and GitHub Actions workflow #6
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
93e4252
feat: Add authentication tests for email signup and login
google-labs-jules[bot] 5371217
feat: Add GitHub Actions workflow to run backend tests
Devasy 42f7017
fix(tests): update test command to run specific auth test file
Devasy e071630
fix(tests): update test command to run all backend tests
Devasy 609d074
fix: remove duplicate entry for .vscode in .gitignore
Devasy c91c105
fix: update Python setup action to version 5 in GitHub Actions workflow
Devasy File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| name: Run Tests | ||
|
|
||
| on: | ||
| pull_request: | ||
| branches: [ main, master, feature/*] | ||
| push: | ||
| branches: [ main, master ] | ||
|
|
||
| jobs: | ||
| test-backend: | ||
| runs-on: ubuntu-latest | ||
|
|
||
| steps: | ||
| - uses: actions/checkout@v4 | ||
|
|
||
| - name: Set up Python | ||
| uses: actions/setup-python@v5 | ||
| with: | ||
| python-version: '3.12' | ||
|
|
||
| - name: Install dependencies | ||
| run: | | ||
| python -m pip install --upgrade pip | ||
| cd backend | ||
| pip install -r requirements.txt | ||
|
|
||
| - name: Run tests | ||
| run: | | ||
| cd $GITHUB_WORKSPACE | ||
| export PYTHONPATH=$PYTHONPATH:./backend | ||
| pytest backend/tests/ |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -72,6 +72,7 @@ service-account*.json | |
| # IDE | ||
| .vscode/settings.json | ||
| .idea/ | ||
| .vscode/ | ||
|
|
||
| # Logs | ||
| *.log | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| [pytest] | ||
| env = | ||
| SECRET_KEY=test_secret_key_for_pytest_1234567890abcdef | ||
| TESTING=True # Example: if the app ever needs to know it's in test mode | ||
|
|
||
| asyncio_mode = auto | ||
|
|
||
| python_files = test_*.py tests_*.py *_test.py *_tests.py | ||
| python_classes = Test* Tests* | ||
| python_functions = test_* | ||
|
|
||
| # Optional: Add default command line options if desired | ||
| # addopts = --verbose |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,240 @@ | ||
| import pytest | ||
| from httpx import AsyncClient, ASGITransport | ||
| from fastapi import FastAPI, status | ||
| from main import app # Assuming your FastAPI app instance is here | ||
| from app.config import settings # To potentially override settings if needed, or check values | ||
| from app.auth.security import verify_password, get_password_hash # For checking hashed password if necessary | ||
| from datetime import datetime | ||
| from bson import ObjectId | ||
|
|
||
| # It's good practice to set a specific test secret key if not relying on external env vars | ||
| # For now, we assume 'your-super-secret-jwt-key-change-this-in-production' from config.py is used, | ||
| # or an environment variable overrides it. | ||
| # Ensure settings.secret_key is sufficiently long/random for HS256, the default is. | ||
|
|
||
| # Helper to get the mock_db if direct interaction is needed (though often not preferred) | ||
| # from app.database import get_database | ||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_signup_with_email_success(mock_db): # mock_db fixture is auto-used | ||
| async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac: | ||
| signup_data = { | ||
| "email": "[email protected]", | ||
| "password": "securepassword123", | ||
| "name": "Test User" | ||
| } | ||
| response = await ac.post("/auth/signup/email", json=signup_data) | ||
| print(f"Response text for test_signup_with_email_success: {response.text}") # Print response text | ||
| assert response.status_code == status.HTTP_200_OK # Or the actual success code used by the app | ||
| response_data = response.json() | ||
| assert "access_token" in response_data | ||
| assert "refresh_token" in response_data | ||
| assert "user" in response_data | ||
| assert response_data["user"]["email"] == signup_data["email"] | ||
| assert response_data["user"]["name"] == signup_data["name"] | ||
| assert "_id" in response_data["user"] # Changed 'id' to '_id' | ||
|
|
||
| # Verify user creation in the mock database | ||
| # db = get_database() # This will be the mock_db instance due to the fixture | ||
| # Directly using mock_db fixture passed to test function | ||
| created_user = await mock_db.users.find_one({"email": signup_data["email"]}) | ||
| assert created_user is not None | ||
| assert created_user["name"] == signup_data["name"] | ||
| assert verify_password(signup_data["password"], created_user["hashed_password"]) | ||
|
|
||
| # Verify refresh token creation | ||
| refresh_token_record = await mock_db.refresh_tokens.find_one({"user_id": created_user["_id"]}) | ||
| assert refresh_token_record is not None | ||
| assert not refresh_token_record["revoked"] | ||
| assert response_data["refresh_token"] == refresh_token_record["token"] | ||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_signup_with_existing_email(mock_db): | ||
| # Pre-populate with a user | ||
| existing_email = "[email protected]" | ||
| await mock_db.users.insert_one({ | ||
| "email": existing_email, | ||
| "hashed_password": "hashedpassword", | ||
| "name": "Existing User", | ||
| "created_at": "sometime" # Simplified for mock | ||
| }) | ||
|
|
||
| async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac: | ||
| signup_data = { | ||
| "email": existing_email, | ||
| "password": "newpassword123", | ||
| "name": "New User" | ||
| } | ||
| response = await ac.post("/auth/signup/email", json=signup_data) | ||
|
|
||
| assert response.status_code == status.HTTP_400_BAD_REQUEST | ||
| response_data = response.json() | ||
| assert "detail" in response_data | ||
| assert "User with this email already exists" in response_data["detail"] | ||
|
|
||
| @pytest.mark.asyncio | ||
| @pytest.mark.parametrize( | ||
| "payload_modifier, affected_field, description", | ||
| [ | ||
| (lambda p: p.pop("email"), "email", "missing_email"), | ||
| (lambda p: p.pop("password"), "password", "missing_password"), | ||
| (lambda p: p.pop("name"), "name", "missing_name"), | ||
| (lambda p: p.update({"password": "short"}), "password", "short_password"), | ||
| (lambda p: p.update({"email": "invalidemail"}), "email", "invalid_email"), | ||
| ] | ||
| ) | ||
| async def test_signup_invalid_input_refined(mock_db, payload_modifier, affected_field, description): | ||
| base_payload = { | ||
| "email": "[email protected]", | ||
| "password": "securepassword123", | ||
| "name": "Test User" | ||
| } | ||
| payload_modifier(base_payload) # Modify the payload based on the current test case | ||
|
|
||
| async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac: | ||
| response = await ac.post("/auth/signup/email", json=base_payload) | ||
|
|
||
| assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY | ||
| response_data = response.json() | ||
| assert "detail" in response_data | ||
|
|
||
| error_found = False | ||
| for error_item in response_data["detail"]: | ||
| # Check if the 'loc' array (location of the error) contains the affected_field | ||
| if affected_field in error_item.get("loc", []): | ||
| error_type = error_item.get("type", "") | ||
| # Specific checks for error types for Pydantic v2 | ||
| if description == "short_password" and error_type == "string_too_short": | ||
| error_found = True | ||
| break | ||
| elif description == "invalid_email" and error_type == "value_error": # Simpler check, msg gives more detail | ||
| error_found = True | ||
| break | ||
| elif "missing" in description and error_type == "missing": | ||
| error_found = True | ||
| break | ||
| assert error_found, f"Validation error for '{description}' (field: {affected_field}) not found or not specific enough in {response_data['detail']}" | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_login_with_email_success(mock_db): | ||
| user_email = "[email protected]" | ||
| user_password = "loginpassword123" | ||
| hashed_password = get_password_hash(user_password) | ||
|
|
||
| # Pre-populate user in mock_db | ||
| # Ensure _id is an ObjectId if other parts of the code expect it, | ||
| # though for mock_db string usually works fine unless there's specific BSON type checking. | ||
| # For consistency with how AuthService creates user_id for refresh tokens (ObjectId(user_id)), | ||
| # let's store _id as ObjectId here. | ||
| user_obj_id = ObjectId() | ||
| await mock_db.users.insert_one({ | ||
| "_id": user_obj_id, | ||
| "email": user_email, | ||
| "hashed_password": hashed_password, | ||
| "name": "Login User", | ||
| "avatar": None, | ||
| "currency": "USD", | ||
| "created_at": datetime.utcnow(), # Ensure datetime is used | ||
| "auth_provider": "email", | ||
| "firebase_uid": None | ||
| }) | ||
|
|
||
| async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac: | ||
| login_data = { | ||
| "email": user_email, | ||
| "password": user_password | ||
| } | ||
| response = await ac.post("/auth/login/email", json=login_data) | ||
|
|
||
| assert response.status_code == status.HTTP_200_OK | ||
| response_data = response.json() | ||
| assert "access_token" in response_data | ||
| assert "refresh_token" in response_data | ||
| assert "user" in response_data | ||
| assert response_data["user"]["email"] == user_email | ||
| assert response_data["user"]["_id"] == str(user_obj_id) # Changed 'id' to '_id' | ||
|
|
||
| # Verify refresh token creation for this user | ||
| # Refresh token service stores user_id as ObjectId | ||
| refresh_token_record = await mock_db.refresh_tokens.find_one({"user_id": user_obj_id}) | ||
| assert refresh_token_record is not None | ||
| assert not refresh_token_record["revoked"] | ||
| assert response_data["refresh_token"] == refresh_token_record["token"] | ||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_login_with_incorrect_password(mock_db): | ||
| user_email = "[email protected]" | ||
| correct_password = "correctpassword" | ||
| incorrect_password = "incorrectpassword" | ||
|
|
||
| await mock_db.users.insert_one({ | ||
| "_id": ObjectId(), | ||
| "email": user_email, | ||
| "hashed_password": get_password_hash(correct_password), | ||
| "name": "Wrong Pass User", | ||
| "created_at": datetime.utcnow() | ||
| }) | ||
|
|
||
| async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac: | ||
| login_data = { | ||
| "email": user_email, | ||
| "password": incorrect_password | ||
| } | ||
| response = await ac.post("/auth/login/email", json=login_data) | ||
|
|
||
| assert response.status_code == status.HTTP_401_UNAUTHORIZED | ||
| response_data = response.json() | ||
| assert "detail" in response_data | ||
| assert "Incorrect email or password" in response_data["detail"] | ||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_login_with_non_existent_email(mock_db): | ||
| async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac: | ||
| login_data = { | ||
| "email": "[email protected]", | ||
| "password": "anypassword" | ||
| } | ||
| response = await ac.post("/auth/login/email", json=login_data) | ||
|
|
||
| assert response.status_code == status.HTTP_401_UNAUTHORIZED | ||
| response_data = response.json() | ||
| assert "detail" in response_data | ||
| assert "Incorrect email or password" in response_data["detail"] # Same message for both cases | ||
|
|
||
| @pytest.mark.asyncio | ||
| @pytest.mark.parametrize( | ||
| "payload_modifier, affected_field, description", | ||
| [ | ||
| (lambda p: p.pop("email"), "email", "missing_email"), | ||
| (lambda p: p.pop("password"), "password", "missing_password"), | ||
| (lambda p: p.update({"email": "invalidemailformat"}), "email", "invalid_email_format"), | ||
| ] | ||
| ) | ||
| async def test_login_invalid_input(mock_db, payload_modifier, affected_field, description): | ||
| base_payload = { | ||
| "email": "[email protected]", | ||
| "password": "validpassword123" | ||
| } | ||
| # It doesn't matter if the user exists or not for input validation, | ||
| # as validation happens before DB lookup for these kinds of errors. | ||
| payload_modifier(base_payload) | ||
|
|
||
| async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac: | ||
| response = await ac.post("/auth/login/email", json=base_payload) | ||
|
|
||
| assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY | ||
| response_data = response.json() | ||
| assert "detail" in response_data | ||
|
|
||
| error_found = False | ||
| for error_item in response_data["detail"]: | ||
| if affected_field in error_item.get("loc", []): | ||
| error_type = error_item.get("type", "") | ||
| if description == "invalid_email_format" and error_type == "value_error": # Simpler check | ||
| error_found = True | ||
| break | ||
| elif "missing" in description and error_type == "missing": | ||
| error_found = True | ||
| break | ||
| assert error_found, f"Validation error for '{description}' (field: {affected_field}) not found in {response_data['detail']}" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| import pytest | ||
| import pytest_asyncio | ||
| from unittest.mock import patch, MagicMock | ||
| import firebase_admin # Added | ||
| import os # Added | ||
| from mongomock_motor import AsyncMongoMockClient | ||
|
|
||
| @pytest.fixture(scope="session", autouse=True) | ||
| def mock_firebase_admin(request): | ||
| # Check if we're in a test session that might use firebase, | ||
| # otherwise this mock might be too broad. | ||
| # For now, apply session-wide for simplicity as auth_service imports firebase_admin. | ||
|
|
||
| # Mock firebase_admin.credentials.Certificate | ||
| # Create a mock object that can be called and returns another mock | ||
| mock_certificate = MagicMock() | ||
| # When firebase_admin.credentials.Certificate(path) is called, it returns a dummy object | ||
| mock_certificate.return_value = MagicMock() | ||
|
|
||
| # Mock firebase_admin.initialize_app | ||
| mock_initialize_app = MagicMock() | ||
|
|
||
| # Mock firebase_admin.auth for verify_id_token if Google login tests were being added | ||
| mock_firebase_auth = MagicMock() | ||
| mock_firebase_auth.verify_id_token.return_value = { | ||
| "uid": "test_firebase_uid", | ||
| "email": "[email protected]", | ||
| "name": "Firebase User", | ||
| "picture": None | ||
| } # Dummy decoded token | ||
|
|
||
| patches = [ | ||
| patch("firebase_admin.credentials.Certificate", mock_certificate), | ||
| patch("firebase_admin.initialize_app", mock_initialize_app), | ||
| patch("firebase_admin.auth", mock_firebase_auth) # Mock auth module | ||
| ] | ||
|
|
||
| for p in patches: | ||
| p.start() | ||
| request.addfinalizer(p.stop) | ||
|
|
||
| # Also, to prevent the "Firebase service account not found" print, | ||
| # we can temporarily set one of the expected firebase env vars | ||
| # so the code thinks it's configured, but initialize_app being mocked means nothing happens. | ||
| # This is optional and depends on whether the print is problematic. | ||
| # with patch.dict(os.environ, {"FIREBASE_PROJECT_ID": "test-project"}, clear=True): | ||
| # yield | ||
|
|
||
| # If not using the os.environ patch, just yield: | ||
| yield | ||
|
|
||
| @pytest_asyncio.fixture(scope="function", autouse=True) | ||
| async def mock_db(): | ||
| print("mock_db fixture: Creating AsyncMongoMockClient") | ||
| mock_mongo_client = AsyncMongoMockClient() | ||
| print(f"mock_db fixture: mock_mongo_client type: {type(mock_mongo_client)}") | ||
| mock_database_instance = mock_mongo_client["test_db"] | ||
| print(f"mock_db fixture: mock_database_instance type: {type(mock_database_instance)}, is None: {mock_database_instance is None}") | ||
|
|
||
| # Ensure we are patching the correct target | ||
| # 'app.database.get_database' is where the function is defined. | ||
| # 'app.auth.service.get_database' is where it's imported and looked up by AuthService. | ||
| # Patching where it's looked up can be more robust. | ||
|
|
||
| with patch("app.auth.service.get_database", return_value=mock_database_instance) as mock_get_database_function: | ||
| print(f"mock_db fixture: Patching app.auth.service.get_database. Patched object: {mock_get_database_function}") | ||
| print(f"mock_db fixture: Patched return_value: {mock_get_database_function.return_value}, type: {type(mock_get_database_function.return_value)}") | ||
| yield mock_database_instance # yield the same instance for direct use if needed | ||
| print("mock_db fixture: Restoring app.auth.service.get_database") | ||
|
|
||
| # Optional: clear all collections in the mock_database after each test | ||
| # This ensures test isolation. | ||
| # mongomock doesn't have a straightforward way to list all collections like a real DB, | ||
| # so we might need to clear known collections if necessary, or rely on new client per test. | ||
| # For now, a new AsyncMongoMockClient per function scope should provide good isolation. |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.