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
This commit is contained in:
@@ -52,12 +52,13 @@ kics:
|
|||||||
- pip install --index-url "$PIP_INDEX_URL" pytest pytest-asyncio httpx
|
- pip install --index-url "$PIP_INDEX_URL" pytest pytest-asyncio httpx
|
||||||
script:
|
script:
|
||||||
- cd backend
|
- 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
|
# ORCHARD_TEST_URL tells the tests which server to connect to
|
||||||
- |
|
- |
|
||||||
python -m pytest tests/integration/ -v \
|
python -m pytest tests/integration/ -v \
|
||||||
--junitxml=integration-report.xml \
|
--junitxml=integration-report.xml \
|
||||||
-m "not large and not slow" \
|
-m "not large and not slow and not auth_intensive" \
|
||||||
--tb=short
|
--tb=short
|
||||||
artifacts:
|
artifacts:
|
||||||
when: always
|
when: always
|
||||||
|
|||||||
@@ -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)
|
- Improved pod naming: Orchard pods now named `orchard-{env}-server-*` for clarity (#51)
|
||||||
|
|
||||||
### Fixed
|
### 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 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 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)
|
- Fixed deploy jobs running even when tests or security scans fail (changed rules from `when: always` to `when: on_success`) (#63)
|
||||||
|
|||||||
@@ -9,6 +9,33 @@ This module provides:
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import pytest
|
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
|
import io
|
||||||
from typing import Generator
|
from typing import Generator
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|||||||
@@ -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
|
import pytest
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
|
|
||||||
|
# Mark all tests in this module as auth_intensive
|
||||||
|
pytestmark = pytest.mark.auth_intensive
|
||||||
|
|
||||||
|
|
||||||
class TestAuthLogin:
|
class TestAuthLogin:
|
||||||
"""Tests for login endpoint."""
|
"""Tests for login endpoint."""
|
||||||
|
|
||||||
@@ -570,191 +579,3 @@ class TestSecurityEdgeCases:
|
|||||||
me_response2 = integration_client.get("/api/v1/auth/me")
|
me_response2 = integration_client.get("/api/v1/auth/me")
|
||||||
# This should fail because all sessions were invalidated
|
# This should fail because all sessions were invalidated
|
||||||
assert me_response2.status_code == 401
|
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
|
|
||||||
|
|||||||
Reference in New Issue
Block a user