From dcd043e9ba19cc617a369e6a80cee3d2096bb291 Mon Sep 17 00:00:00 2001 From: Mondo Diaz Date: Fri, 16 Jan 2026 21:08:47 +0000 Subject: [PATCH] Fix CI integration test rate limiting - Add auth_intensive marker for tests that make many login requests - Mark all tests in test_auth_api.py with auth_intensive - Exclude auth_intensive tests from CI integration runs against deployed environments (they trigger 429 rate limiting) - Remove duplicate TestSecurityEdgeCases class definition - Register auth_intensive, integration, large, slow markers in conftest.py --- .gitlab-ci.yml | 5 +- CHANGELOG.md | 2 + backend/tests/conftest.py | 27 +++ backend/tests/integration/test_auth_api.py | 199 ++------------------- 4 files changed, 42 insertions(+), 191 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 635f889..00682de 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -52,12 +52,13 @@ kics: - pip install --index-url "$PIP_INDEX_URL" pytest pytest-asyncio httpx script: - cd backend - # Run full integration test suite, excluding large/slow tests + # Run full integration test suite, excluding large/slow tests and auth-intensive tests + # Auth-intensive tests make many login requests which trigger rate limiting on deployed environments # ORCHARD_TEST_URL tells the tests which server to connect to - | python -m pytest tests/integration/ -v \ --junitxml=integration-report.xml \ - -m "not large and not slow" \ + -m "not large and not slow and not auth_intensive" \ --tb=short artifacts: when: always diff --git a/CHANGELOG.md b/CHANGELOG.md index 3725393..87fa0c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -81,6 +81,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Improved pod naming: Orchard pods now named `orchard-{env}-server-*` for clarity (#51) ### Fixed +- Fixed CI integration test rate limiting: added `auth_intensive` marker and excluded auth-heavy tests from deployed environments +- Fixed duplicate `TestSecurityEdgeCases` class definition in test_auth_api.py - Fixed integration tests auth: session-scoped client, configurable credentials via env vars, fail-fast on auth errors - Fixed Content-Disposition header encoding for non-ASCII filenames using RFC 5987 (#38) - Fixed deploy jobs running even when tests or security scans fail (changed rules from `when: always` to `when: on_success`) (#63) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 3e04096..a041646 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -9,6 +9,33 @@ This module provides: import os import pytest + + +# ============================================================================= +# Pytest Markers +# ============================================================================= + + +def pytest_configure(config): + """Register custom pytest markers.""" + config.addinivalue_line( + "markers", + "auth_intensive: marks tests that make many login requests (excluded from CI integration tests due to rate limiting)", + ) + config.addinivalue_line( + "markers", + "integration: marks tests as integration tests", + ) + config.addinivalue_line( + "markers", + "large: marks tests that handle large files (slow)", + ) + config.addinivalue_line( + "markers", + "slow: marks tests as slow running", + ) + + import io from typing import Generator from unittest.mock import MagicMock diff --git a/backend/tests/integration/test_auth_api.py b/backend/tests/integration/test_auth_api.py index 817694a..9abf6ac 100644 --- a/backend/tests/integration/test_auth_api.py +++ b/backend/tests/integration/test_auth_api.py @@ -1,9 +1,18 @@ -"""Integration tests for authentication API endpoints.""" +"""Integration tests for authentication API endpoints. + +Note: These tests are marked as auth_intensive because they make many login +requests which can trigger rate limiting on deployed environments. They are +excluded from CI integration tests but run in local and unit test suites. +""" import pytest from uuid import uuid4 +# Mark all tests in this module as auth_intensive +pytestmark = pytest.mark.auth_intensive + + class TestAuthLogin: """Tests for login endpoint.""" @@ -570,191 +579,3 @@ class TestSecurityEdgeCases: me_response2 = integration_client.get("/api/v1/auth/me") # This should fail because all sessions were invalidated assert me_response2.status_code == 401 - - -class TestSecurityEdgeCases: - """Tests for security edge cases and validation.""" - - @pytest.mark.integration - def test_login_inactive_user(self, integration_client): - """Test that inactive users cannot login.""" - # Login as admin and create a user - integration_client.post( - "/api/v1/auth/login", - json={"username": "admin", "password": "changeme123"}, - ) - test_username = f"inactive_{uuid4().hex[:8]}" - integration_client.post( - "/api/v1/admin/users", - json={"username": test_username, "password": "password123"}, - ) - - # Deactivate the user - integration_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( - "/api/v1/auth/login", - json={"username": test_username, "password": "password123"}, - ) - assert response.status_code == 401 - assert "Invalid username or password" in response.json()["detail"] - - @pytest.mark.integration - def test_password_too_short_on_create(self, integration_client): - """Test that short passwords are rejected when creating users.""" - integration_client.post( - "/api/v1/auth/login", - json={"username": "admin", "password": "changeme123"}, - ) - - response = integration_client.post( - "/api/v1/admin/users", - json={"username": f"shortpw_{uuid4().hex[:8]}", "password": "short"}, - ) - assert response.status_code == 400 - assert "at least 8 characters" in response.json()["detail"] - - @pytest.mark.integration - def test_password_too_short_on_change(self, integration_client): - """Test that short passwords are rejected when changing password.""" - integration_client.post( - "/api/v1/auth/login", - json={"username": "admin", "password": "changeme123"}, - ) - - response = integration_client.post( - "/api/v1/auth/change-password", - json={"current_password": "changeme123", "new_password": "short"}, - ) - assert response.status_code == 400 - assert "at least 8 characters" in response.json()["detail"] - - @pytest.mark.integration - def test_password_too_short_on_reset(self, integration_client): - """Test that short passwords are rejected when resetting password.""" - integration_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( - "/api/v1/admin/users", - json={"username": test_username, "password": "password123"}, - ) - - response = integration_client.post( - f"/api/v1/admin/users/{test_username}/reset-password", - json={"new_password": "short"}, - ) - assert response.status_code == 400 - assert "at least 8 characters" in response.json()["detail"] - - @pytest.mark.integration - def test_duplicate_username_rejected(self, integration_client): - """Test that duplicate usernames are rejected.""" - integration_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( - "/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( - "/api/v1/admin/users", - json={"username": test_username, "password": "password456"}, - ) - assert response2.status_code == 409 - assert "already exists" in response2.json()["detail"] - - @pytest.mark.integration - def test_cannot_delete_other_users_api_key(self, integration_client): - """Test that users cannot delete API keys owned by other users.""" - # Login as admin and create an API key - integration_client.post( - "/api/v1/auth/login", - json={"username": "admin", "password": "changeme123"}, - ) - create_response = integration_client.post( - "/api/v1/auth/keys", - json={"name": "admin-key"}, - ) - admin_key_id = create_response.json()["id"] - - # Create a non-admin user - test_username = f"nonadmin_{uuid4().hex[:8]}" - integration_client.post( - "/api/v1/admin/users", - json={"username": test_username, "password": "password123"}, - ) - - # Login as non-admin - integration_client.cookies.clear() - integration_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}") - 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( - "/api/v1/auth/login", - json={"username": "admin", "password": "changeme123"}, - ) - integration_client.delete(f"/api/v1/auth/keys/{admin_key_id}") - - @pytest.mark.integration - def test_sessions_invalidated_on_password_change(self, integration_client): - """Test that all sessions are invalidated when password is changed.""" - # Create a test user - integration_client.post( - "/api/v1/auth/login", - json={"username": "admin", "password": "changeme123"}, - ) - test_username = f"sessiontest_{uuid4().hex[:8]}" - integration_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( - "/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") - assert me_response.status_code == 200 - - # Change password - integration_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") - # This should fail because all sessions were invalidated - assert me_response2.status_code == 401