diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index a041646..fdaf228 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -249,6 +249,26 @@ def integration_client(): yield client +@pytest.fixture +def auth_client(): + """ + Create a function-scoped test client for authentication tests. + + Unlike integration_client (session-scoped), this creates a fresh client + for each test. Use this for tests that manipulate authentication state + (login, logout, cookie clearing) to avoid polluting other tests. + + Environment variables: + ORCHARD_TEST_URL: Base URL of the Orchard server (default: http://localhost:8080) + """ + import httpx + + base_url = os.environ.get("ORCHARD_TEST_URL", "http://localhost:8080") + + with httpx.Client(base_url=base_url, timeout=30.0) as client: + yield client + + @pytest.fixture def unique_test_id(): """Generate a unique ID for test isolation.""" diff --git a/backend/tests/integration/test_auth_api.py b/backend/tests/integration/test_auth_api.py index 20ef881..b44c2fd 100644 --- a/backend/tests/integration/test_auth_api.py +++ b/backend/tests/integration/test_auth_api.py @@ -17,9 +17,9 @@ class TestAuthLogin: """Tests for login endpoint.""" @pytest.mark.integration - def test_login_success(self, integration_client): + def test_login_success(self, auth_client): """Test successful login with default admin credentials.""" - response = integration_client.post( + response = auth_client.post( "/api/v1/auth/login", json={"username": "admin", "password": "changeme123"}, ) @@ -30,9 +30,9 @@ class TestAuthLogin: assert "orchard_session" in response.cookies @pytest.mark.integration - def test_login_invalid_password(self, integration_client): + def test_login_invalid_password(self, auth_client): """Test login with wrong password.""" - response = integration_client.post( + response = auth_client.post( "/api/v1/auth/login", json={"username": "admin", "password": "wrongpassword"}, ) @@ -40,9 +40,9 @@ class TestAuthLogin: assert "Invalid username or password" in response.json()["detail"] @pytest.mark.integration - def test_login_nonexistent_user(self, integration_client): + def test_login_nonexistent_user(self, auth_client): """Test login with non-existent user.""" - response = integration_client.post( + response = auth_client.post( "/api/v1/auth/login", json={"username": "nonexistent", "password": "password"}, ) @@ -53,24 +53,24 @@ class TestAuthLogout: """Tests for logout endpoint.""" @pytest.mark.integration - def test_logout_success(self, integration_client): + def test_logout_success(self, auth_client): """Test successful logout.""" # First login - login_response = integration_client.post( + login_response = auth_client.post( "/api/v1/auth/login", json={"username": "admin", "password": "changeme123"}, ) assert login_response.status_code == 200 # Then logout - logout_response = integration_client.post("/api/v1/auth/logout") + logout_response = auth_client.post("/api/v1/auth/logout") assert logout_response.status_code == 200 assert "Logged out successfully" in logout_response.json()["message"] @pytest.mark.integration - def test_logout_without_session(self, integration_client): + def test_logout_without_session(self, auth_client): """Test logout without being logged in.""" - response = integration_client.post("/api/v1/auth/logout") + response = auth_client.post("/api/v1/auth/logout") # Should succeed even without session assert response.status_code == 200 @@ -79,15 +79,15 @@ class TestAuthMe: """Tests for get current user endpoint.""" @pytest.mark.integration - def test_get_me_authenticated(self, integration_client): + def test_get_me_authenticated(self, auth_client): """Test getting current user when authenticated.""" # Login first - integration_client.post( + auth_client.post( "/api/v1/auth/login", json={"username": "admin", "password": "changeme123"}, ) - response = integration_client.get("/api/v1/auth/me") + response = auth_client.get("/api/v1/auth/me") assert response.status_code == 200 data = response.json() assert data["username"] == "admin" @@ -96,12 +96,12 @@ class TestAuthMe: assert "created_at" in data @pytest.mark.integration - def test_get_me_unauthenticated(self, integration_client): + def test_get_me_unauthenticated(self, auth_client): """Test getting current user without authentication.""" # Clear any existing cookies - integration_client.cookies.clear() + auth_client.cookies.clear() - response = integration_client.get("/api/v1/auth/me") + response = auth_client.get("/api/v1/auth/me") assert response.status_code == 401 assert "Not authenticated" in response.json()["detail"] @@ -110,53 +110,53 @@ class TestAuthChangePassword: """Tests for change password endpoint.""" @pytest.mark.integration - def test_change_password_success(self, integration_client): + def test_change_password_success(self, auth_client): """Test successful password change.""" # Login first - integration_client.post( + auth_client.post( "/api/v1/auth/login", json={"username": "admin", "password": "changeme123"}, ) # Change password - response = integration_client.post( + response = auth_client.post( "/api/v1/auth/change-password", json={"current_password": "changeme123", "new_password": "newpassword123"}, ) assert response.status_code == 200 # Verify old password no longer works - integration_client.cookies.clear() - response = integration_client.post( + auth_client.cookies.clear() + response = auth_client.post( "/api/v1/auth/login", json={"username": "admin", "password": "changeme123"}, ) assert response.status_code == 401 # Verify new password works - response = integration_client.post( + response = auth_client.post( "/api/v1/auth/login", json={"username": "admin", "password": "newpassword123"}, ) assert response.status_code == 200 # Reset password back to original for other tests - reset_response = integration_client.post( + reset_response = auth_client.post( "/api/v1/auth/change-password", json={"current_password": "newpassword123", "new_password": "changeme123"}, ) assert reset_response.status_code == 200, "Failed to reset admin password back to default" @pytest.mark.integration - def test_change_password_wrong_current(self, integration_client): + def test_change_password_wrong_current(self, auth_client): """Test password change with wrong current password.""" # Login first - integration_client.post( + auth_client.post( "/api/v1/auth/login", json={"username": "admin", "password": "changeme123"}, ) - response = integration_client.post( + response = auth_client.post( "/api/v1/auth/change-password", json={"current_password": "wrongpassword", "new_password": "newpassword"}, ) @@ -168,16 +168,16 @@ class TestAPIKeys: """Tests for API key management endpoints.""" @pytest.mark.integration - def test_create_and_list_api_key(self, integration_client): + def test_create_and_list_api_key(self, auth_client): """Test creating and listing API keys.""" # Login first - integration_client.post( + auth_client.post( "/api/v1/auth/login", json={"username": "admin", "password": "changeme123"}, ) # Create API key - create_response = integration_client.post( + create_response = auth_client.post( "/api/v1/auth/keys", json={"name": "test-key", "description": "Test API key"}, ) @@ -191,23 +191,23 @@ class TestAPIKeys: api_key = data["key"] # List API keys - list_response = integration_client.get("/api/v1/auth/keys") + list_response = auth_client.get("/api/v1/auth/keys") assert list_response.status_code == 200 keys = list_response.json() assert any(k["id"] == key_id for k in keys) # Clean up - delete the key - integration_client.delete(f"/api/v1/auth/keys/{key_id}") + auth_client.delete(f"/api/v1/auth/keys/{key_id}") @pytest.mark.integration - def test_use_api_key_for_auth(self, integration_client): + def test_use_api_key_for_auth(self, auth_client): """Test using API key for authentication.""" # Login and create API key - integration_client.post( + auth_client.post( "/api/v1/auth/login", json={"username": "admin", "password": "changeme123"}, ) - create_response = integration_client.post( + create_response = auth_client.post( "/api/v1/auth/keys", json={"name": "auth-test-key"}, ) @@ -215,8 +215,8 @@ class TestAPIKeys: key_id = create_response.json()["id"] # Clear cookies and use API key - integration_client.cookies.clear() - response = integration_client.get( + auth_client.cookies.clear() + response = auth_client.get( "/api/v1/auth/me", headers={"Authorization": f"Bearer {api_key}"}, ) @@ -224,21 +224,21 @@ class TestAPIKeys: assert response.json()["username"] == "admin" # Clean up - integration_client.post( + auth_client.post( "/api/v1/auth/login", json={"username": "admin", "password": "changeme123"}, ) - integration_client.delete(f"/api/v1/auth/keys/{key_id}") + auth_client.delete(f"/api/v1/auth/keys/{key_id}") @pytest.mark.integration - def test_delete_api_key(self, integration_client): + def test_delete_api_key(self, auth_client): """Test revoking an API key.""" # Login and create API key - integration_client.post( + auth_client.post( "/api/v1/auth/login", json={"username": "admin", "password": "changeme123"}, ) - create_response = integration_client.post( + create_response = auth_client.post( "/api/v1/auth/keys", json={"name": "delete-test-key"}, ) @@ -246,12 +246,12 @@ class TestAPIKeys: api_key = create_response.json()["key"] # Delete the key - delete_response = integration_client.delete(f"/api/v1/auth/keys/{key_id}") + delete_response = auth_client.delete(f"/api/v1/auth/keys/{key_id}") assert delete_response.status_code == 200 # Verify key no longer works - integration_client.cookies.clear() - response = integration_client.get( + auth_client.cookies.clear() + response = auth_client.get( "/api/v1/auth/me", headers={"Authorization": f"Bearer {api_key}"}, ) @@ -262,32 +262,32 @@ class TestAdminUserManagement: """Tests for admin user management endpoints.""" @pytest.mark.integration - def test_list_users(self, integration_client): + def test_list_users(self, auth_client): """Test listing users as admin.""" # Login as admin - integration_client.post( + auth_client.post( "/api/v1/auth/login", json={"username": "admin", "password": "changeme123"}, ) - response = integration_client.get("/api/v1/admin/users") + response = auth_client.get("/api/v1/admin/users") assert response.status_code == 200 users = response.json() assert len(users) >= 1 assert any(u["username"] == "admin" for u in users) @pytest.mark.integration - def test_create_user(self, integration_client): + def test_create_user(self, auth_client): """Test creating a new user as admin.""" # Login as admin - integration_client.post( + auth_client.post( "/api/v1/auth/login", json={"username": "admin", "password": "changeme123"}, ) # Create new user test_username = f"testuser_{uuid4().hex[:8]}" - response = integration_client.post( + response = auth_client.post( "/api/v1/admin/users", json={ "username": test_username, @@ -302,31 +302,31 @@ class TestAdminUserManagement: assert data["is_admin"] is False # Verify new user can login - integration_client.cookies.clear() - login_response = integration_client.post( + auth_client.cookies.clear() + login_response = auth_client.post( "/api/v1/auth/login", json={"username": test_username, "password": "testpassword"}, ) assert login_response.status_code == 200 @pytest.mark.integration - def test_update_user(self, integration_client): + def test_update_user(self, auth_client): """Test updating a user as admin.""" # Login as admin - integration_client.post( + auth_client.post( "/api/v1/auth/login", json={"username": "admin", "password": "changeme123"}, ) # Create a test user test_username = f"updateuser_{uuid4().hex[:8]}" - integration_client.post( + auth_client.post( "/api/v1/admin/users", json={"username": test_username, "password": "password"}, ) # Update the user - response = integration_client.put( + response = auth_client.put( f"/api/v1/admin/users/{test_username}", json={"email": "updated@example.com", "is_admin": True}, ) @@ -336,59 +336,59 @@ class TestAdminUserManagement: assert data["is_admin"] is True @pytest.mark.integration - def test_reset_user_password(self, integration_client): + def test_reset_user_password(self, auth_client): """Test resetting a user's password as admin.""" # Login as admin - integration_client.post( + auth_client.post( "/api/v1/auth/login", json={"username": "admin", "password": "changeme123"}, ) # Create a test user test_username = f"resetuser_{uuid4().hex[:8]}" - integration_client.post( + auth_client.post( "/api/v1/admin/users", json={"username": test_username, "password": "oldpassword"}, ) # Reset password - response = integration_client.post( + response = auth_client.post( f"/api/v1/admin/users/{test_username}/reset-password", json={"new_password": "newpassword"}, ) assert response.status_code == 200 # Verify new password works - integration_client.cookies.clear() - login_response = integration_client.post( + auth_client.cookies.clear() + login_response = auth_client.post( "/api/v1/auth/login", json={"username": test_username, "password": "newpassword"}, ) assert login_response.status_code == 200 @pytest.mark.integration - def test_non_admin_cannot_access_admin_endpoints(self, integration_client): + def test_non_admin_cannot_access_admin_endpoints(self, auth_client): """Test that non-admin users cannot access admin endpoints.""" # Login as admin and create non-admin user - integration_client.post( + auth_client.post( "/api/v1/auth/login", json={"username": "admin", "password": "changeme123"}, ) test_username = f"nonadmin_{uuid4().hex[:8]}" - integration_client.post( + auth_client.post( "/api/v1/admin/users", json={"username": test_username, "password": "password", "is_admin": False}, ) # Login as non-admin - integration_client.cookies.clear() - integration_client.post( + auth_client.cookies.clear() + auth_client.post( "/api/v1/auth/login", json={"username": test_username, "password": "password"}, ) # Try to access admin endpoints - response = integration_client.get("/api/v1/admin/users") + response = auth_client.get("/api/v1/admin/users") assert response.status_code == 403 assert "Admin privileges required" in response.json()["detail"] @@ -397,28 +397,28 @@ class TestSecurityEdgeCases: """Tests for security edge cases and validation.""" @pytest.mark.integration - def test_login_inactive_user(self, integration_client): + def test_login_inactive_user(self, auth_client): """Test that inactive users cannot login.""" # Login as admin and create a user - integration_client.post( + auth_client.post( "/api/v1/auth/login", json={"username": "admin", "password": "changeme123"}, ) test_username = f"inactive_{uuid4().hex[:8]}" - integration_client.post( + auth_client.post( "/api/v1/admin/users", json={"username": test_username, "password": "password123"}, ) # Deactivate the user - integration_client.put( + auth_client.put( f"/api/v1/admin/users/{test_username}", json={"is_active": False}, ) # Try to login as inactive user - integration_client.cookies.clear() - response = integration_client.post( + auth_client.cookies.clear() + response = auth_client.post( "/api/v1/auth/login", json={"username": test_username, "password": "password123"}, ) @@ -426,14 +426,14 @@ class TestSecurityEdgeCases: assert "Invalid username or password" in response.json()["detail"] @pytest.mark.integration - def test_password_too_short_on_create(self, integration_client): + def test_password_too_short_on_create(self, auth_client): """Test that short passwords are rejected when creating users.""" - integration_client.post( + auth_client.post( "/api/v1/auth/login", json={"username": "admin", "password": "changeme123"}, ) - response = integration_client.post( + response = auth_client.post( "/api/v1/admin/users", json={"username": f"shortpw_{uuid4().hex[:8]}", "password": "short"}, ) @@ -441,14 +441,14 @@ class TestSecurityEdgeCases: assert "at least 8 characters" in response.json()["detail"] @pytest.mark.integration - def test_password_too_short_on_change(self, integration_client): + def test_password_too_short_on_change(self, auth_client): """Test that short passwords are rejected when changing password.""" - integration_client.post( + auth_client.post( "/api/v1/auth/login", json={"username": "admin", "password": "changeme123"}, ) - response = integration_client.post( + response = auth_client.post( "/api/v1/auth/change-password", json={"current_password": "changeme123", "new_password": "short"}, ) @@ -456,21 +456,21 @@ class TestSecurityEdgeCases: assert "at least 8 characters" in response.json()["detail"] @pytest.mark.integration - def test_password_too_short_on_reset(self, integration_client): + def test_password_too_short_on_reset(self, auth_client): """Test that short passwords are rejected when resetting password.""" - integration_client.post( + auth_client.post( "/api/v1/auth/login", json={"username": "admin", "password": "changeme123"}, ) # Create a test user first test_username = f"resetshort_{uuid4().hex[:8]}" - integration_client.post( + auth_client.post( "/api/v1/admin/users", json={"username": test_username, "password": "password123"}, ) - response = integration_client.post( + response = auth_client.post( f"/api/v1/admin/users/{test_username}/reset-password", json={"new_password": "short"}, ) @@ -478,23 +478,23 @@ class TestSecurityEdgeCases: assert "at least 8 characters" in response.json()["detail"] @pytest.mark.integration - def test_duplicate_username_rejected(self, integration_client): + def test_duplicate_username_rejected(self, auth_client): """Test that duplicate usernames are rejected.""" - integration_client.post( + auth_client.post( "/api/v1/auth/login", json={"username": "admin", "password": "changeme123"}, ) test_username = f"duplicate_{uuid4().hex[:8]}" # Create user first time - response1 = integration_client.post( + response1 = auth_client.post( "/api/v1/admin/users", json={"username": test_username, "password": "password123"}, ) assert response1.status_code == 200 # Try to create same username again - response2 = integration_client.post( + response2 = auth_client.post( "/api/v1/admin/users", json={"username": test_username, "password": "password456"}, ) @@ -502,14 +502,14 @@ class TestSecurityEdgeCases: assert "already exists" in response2.json()["detail"] @pytest.mark.integration - def test_cannot_delete_other_users_api_key(self, integration_client): + def test_cannot_delete_other_users_api_key(self, auth_client): """Test that users cannot delete API keys owned by other users.""" # Login as admin and create an API key - integration_client.post( + auth_client.post( "/api/v1/auth/login", json={"username": "admin", "password": "changeme123"}, ) - create_response = integration_client.post( + create_response = auth_client.post( "/api/v1/auth/keys", json={"name": "admin-key"}, ) @@ -517,65 +517,65 @@ class TestSecurityEdgeCases: # Create a non-admin user test_username = f"nonadmin_{uuid4().hex[:8]}" - integration_client.post( + auth_client.post( "/api/v1/admin/users", json={"username": test_username, "password": "password123"}, ) # Login as non-admin - integration_client.cookies.clear() - integration_client.post( + auth_client.cookies.clear() + auth_client.post( "/api/v1/auth/login", json={"username": test_username, "password": "password123"}, ) # Try to delete admin's API key - response = integration_client.delete(f"/api/v1/auth/keys/{admin_key_id}") + response = auth_client.delete(f"/api/v1/auth/keys/{admin_key_id}") assert response.status_code == 403 assert "Cannot delete another user's API key" in response.json()["detail"] # Cleanup: login as admin and delete the key - integration_client.cookies.clear() - integration_client.post( + auth_client.cookies.clear() + auth_client.post( "/api/v1/auth/login", json={"username": "admin", "password": "changeme123"}, ) - integration_client.delete(f"/api/v1/auth/keys/{admin_key_id}") + auth_client.delete(f"/api/v1/auth/keys/{admin_key_id}") @pytest.mark.integration - def test_sessions_invalidated_on_password_change(self, integration_client): + def test_sessions_invalidated_on_password_change(self, auth_client): """Test that all sessions are invalidated when password is changed.""" # Create a test user - integration_client.post( + auth_client.post( "/api/v1/auth/login", json={"username": "admin", "password": "changeme123"}, ) test_username = f"sessiontest_{uuid4().hex[:8]}" - integration_client.post( + auth_client.post( "/api/v1/admin/users", json={"username": test_username, "password": "password123"}, ) # Login as test user - integration_client.cookies.clear() - login_response = integration_client.post( + auth_client.cookies.clear() + login_response = auth_client.post( "/api/v1/auth/login", json={"username": test_username, "password": "password123"}, ) assert login_response.status_code == 200 # Verify session works - me_response = integration_client.get("/api/v1/auth/me") + me_response = auth_client.get("/api/v1/auth/me") assert me_response.status_code == 200 # Change password - integration_client.post( + auth_client.post( "/api/v1/auth/change-password", json={"current_password": "password123", "new_password": "newpassword123"}, ) # Old session should be invalidated - try to access /me # (note: the change-password call itself may have cleared the session cookie) - me_response2 = integration_client.get("/api/v1/auth/me") + me_response2 = auth_client.get("/api/v1/auth/me") # This should fail because all sessions were invalidated assert me_response2.status_code == 401