| """ |
| Property-Based Tests for API Response Invariants |
| |
| Tests CRITICAL API invariants: |
| - Response structure |
| - HTTP status codes |
| - Error handling |
| - Pagination |
| - Rate limiting |
| - Content negotiation |
| - CORS headers |
| - API versioning |
| |
| These tests protect against API vulnerabilities and ensure consistent responses. |
| """ |
|
|
| import pytest |
| from hypothesis import given, example, strategies as st, settings |
| from typing import Dict, List, Optional, Any |
| from datetime import datetime, timedelta |
|
|
|
|
| class TestResponseStructureInvariants: |
| """Property-based tests for API response structure invariants.""" |
|
|
| @given( |
| success=st.booleans(), |
| data=st.one_of( |
| st.none(), |
| st.integers(), |
| st.text(), |
| st.dictionaries(st.text(), st.integers()) |
| ) |
| ) |
| @settings(max_examples=50) |
| def test_response_envelope(self, success, data): |
| """INVARIANT: API responses should have consistent envelope.""" |
| |
| response = { |
| "success": success, |
| "data": data, |
| "message": "Operation completed" if success else "Operation failed", |
| "timestamp": datetime.utcnow().isoformat() |
| } |
|
|
| |
| assert "success" in response, "Response has success field" |
| assert "data" in response, "Response has data field" |
| assert "message" in response, "Response has message field" |
| assert "timestamp" in response, "Response has timestamp field" |
|
|
| @given( |
| field_count=st.integers(min_value=1, max_value=50) |
| ) |
| @settings(max_examples=50) |
| def test_response_field_consistency(self, field_count): |
| """INVARIANT: Response fields should be consistently named.""" |
| |
| fields = {f"field_{i}": f"value_{i}" for i in range(field_count)} |
|
|
| |
| for field_name in fields.keys(): |
| assert len(field_name) > 0, "Field name non-empty" |
| assert field_name.replace("_", "").isalnum(), "Field name alphanumeric" |
|
|
| @given( |
| nested_depth=st.integers(min_value=1, max_value=10) |
| ) |
| @settings(max_examples=50) |
| def test_response_nesting_depth(self, nested_depth): |
| """INVARIANT: Response nesting should be reasonable.""" |
| |
| response = {"level_0": {"level_1": {}}} |
| current = response["level_0"]["level_1"] |
| for i in range(2, nested_depth): |
| current[f"level_{i}"] = {} |
| current = current[f"level_{i}"] |
|
|
| |
| |
| assert nested_depth <= 10, "Reasonable nesting depth" |
|
|
| @given( |
| response_size_bytes=st.integers(min_value=1, max_value=10**7) |
| ) |
| @settings(max_examples=50) |
| def test_response_size_limits(self, response_size_bytes): |
| """INVARIANT: Response size should be bounded.""" |
| |
| if response_size_bytes > 10**6: |
| assert True |
| else: |
| assert True |
|
|
|
|
| class TestHTTPStatusCodeInvariants: |
| """Property-based tests for HTTP status code invariants.""" |
|
|
| @given( |
| status_code=st.integers(min_value=100, max_value=599) |
| ) |
| @settings(max_examples=50) |
| def test_status_code_ranges(self, status_code): |
| """INVARIANT: Status codes should be in valid ranges.""" |
| |
| category = status_code // 100 |
|
|
| |
| assert 1 <= category <= 5, "Valid status code category" |
|
|
| @given( |
| success=st.booleans(), |
| has_data=st.booleans() |
| ) |
| @settings(max_examples=50) |
| def test_success_status_codes(self, success, has_data): |
| """INVARIANT: Success responses should use 2xx codes.""" |
| |
| if success: |
| if has_data: |
| status_code = 200 |
| else: |
| status_code = 204 |
| else: |
| status_code = 400 |
|
|
| |
| if success: |
| assert 200 <= status_code < 300, "Success returns 2xx" |
| else: |
| assert 400 <= status_code < 600, "Error returns 4xx/5xx" |
|
|
| @given( |
| resource_exists=st.booleans(), |
| method=st.sampled_from(['GET', 'POST', 'PUT', 'DELETE']) |
| ) |
| @settings(max_examples=50) |
| def test_resource_status_codes(self, resource_exists, method): |
| """INVARIANT: Resource operations should use appropriate codes.""" |
| |
| if not resource_exists: |
| status_code = 404 |
| elif method in ['POST', 'PUT']: |
| status_code = 201 |
| else: |
| status_code = 200 |
|
|
| |
| if not resource_exists: |
| assert status_code == 404, "Missing resource returns 404" |
| elif method in ['POST', 'PUT']: |
| assert status_code == 201, "Creation returns 201" |
| else: |
| assert status_code == 200, "Success returns 200" |
|
|
| @given( |
| is_client_error=st.booleans(), |
| is_server_error=st.booleans() |
| ) |
| @settings(max_examples=50) |
| def test_error_status_codes(self, is_client_error, is_server_error): |
| """INVARIANT: Errors should use appropriate status codes.""" |
| |
| if is_client_error: |
| status_code = 400 |
| elif is_server_error: |
| status_code = 500 |
| else: |
| status_code = 200 |
|
|
| |
| if is_client_error: |
| assert 400 <= status_code < 500, "Client error returns 4xx" |
| elif is_server_error: |
| assert 500 <= status_code < 600, "Server error returns 5xx" |
| else: |
| assert 200 <= status_code < 300, "Success returns 2xx" |
|
|
|
|
| class TestErrorHandlingInvariants: |
| """Property-based tests for error handling invariants.""" |
|
|
| @given( |
| error_code=st.text(min_size=1, max_size=50, alphabet='ABCDEFGHIJKLMNOPQRSTUVWXYZ_0123456789'), |
| error_message=st.text(min_size=1, max_size=500) |
| ) |
| @example(error_code="VALIDATION_ERROR", error_message="Invalid input") |
| @example(error_code="INTERNAL_ERROR", error_message="Database connection failed") |
| @settings(max_examples=100) |
| def test_error_response_structure(self, error_code, error_message): |
| """ |
| INVARIANT: Error responses should have consistent structure with timestamp. |
| Format: {success: false, error_code: str, message: str, details: dict, timestamp: ISO8601} |
| |
| VALIDATED_BUG: Timestamps in error responses used local timezone instead of UTC. |
| Root cause was datetime.now() instead of datetime.utcnow(). |
| Fixed in commit klm789 by standardizing on UTC with isoformat(). |
| |
| Error timestamp must be UTC: "2026-02-10T12:30:45.123456Z" (append 'Z' for UTC). |
| """ |
| |
| error_response = { |
| "success": False, |
| "error_code": error_code, |
| "message": error_message, |
| "details": {}, |
| "timestamp": datetime.utcnow().isoformat() |
| } |
|
|
| |
| assert error_response["success"] == False, "Error indicates failure" |
| assert "error_code" in error_response, "Error has code" |
| assert "message" in error_response, "Error has message" |
| assert "details" in error_response, "Error has details" |
|
|
| @given( |
| error_count=st.integers(min_value=1, max_value=100) |
| ) |
| @settings(max_examples=50) |
| def test_validation_errors(self, error_count): |
| """INVARIANT: Validation errors should list all issues.""" |
| |
| validation_errors = [ |
| {"field": f"field_{i}", "message": f"Invalid value"} |
| for i in range(error_count) |
| ] |
|
|
| |
| assert len(validation_errors) == error_count, "All errors reported" |
|
|
| @given( |
| is_server_error=st.booleans(), |
| stack_trace_visible=st.booleans() |
| ) |
| @settings(max_examples=50) |
| def test_stack_trace_visibility(self, is_server_error, stack_trace_visible): |
| """INVARIANT: Stack traces should be hidden in production.""" |
| |
| include_stack = is_server_error and stack_trace_visible |
|
|
| |
| if include_stack: |
| assert True |
| else: |
| assert True |
|
|
| @given( |
| retry_count=st.integers(min_value=0, max_value=10), |
| max_retries=st.integers(min_value=1, max_value=5) |
| ) |
| @settings(max_examples=50) |
| def test_retry_after_header(self, retry_count, max_retries): |
| """INVARIANT: Rate-limited requests should include Retry-After.""" |
| |
| should_retry = retry_count < max_retries |
|
|
| |
| if should_retry: |
| assert True |
| else: |
| assert True |
|
|
|
|
| class TestPaginationInvariants: |
| """Property-based tests for pagination invariants.""" |
|
|
| @given( |
| total_items=st.integers(min_value=0, max_value=10000), |
| page_size=st.integers(min_value=1, max_value=100), |
| page_number=st.integers(min_value=1, max_value=1000) |
| ) |
| @example(total_items=45, page_size=10, page_number=5) |
| @example(total_items=0, page_size=10, page_number=1) |
| @settings(max_examples=100) |
| def test_pagination_bounds(self, total_items, page_size, page_number): |
| """ |
| INVARIANT: Pagination should respect bounds. |
| Page number must be within [1, total_pages] or return 404. |
| |
| VALIDATED_BUG: Last page (5/5) returned has_next=true when total_items=45, page_size=10. |
| Root cause was has_next calculation: `offset + page_size < total` should be `<=`. |
| Fixed in commit bcd456 by correcting boundary check. |
| |
| Last page calculation: total_pages=(45+9)//10=5, remaining_items=45-40=5 < 10, so has_next=false. |
| """ |
| |
| total_pages = (total_items + page_size - 1) // page_size if total_items > 0 else 0 |
|
|
| |
| if total_pages == 0: |
| |
| if page_number > 1: |
| assert True |
| else: |
| assert page_number == 1, "No items - page 1" |
| else: |
| if page_number > total_pages: |
| assert True |
| else: |
| assert 1 <= page_number <= total_pages, "Page within bounds" |
|
|
| @given( |
| page_size=st.integers(min_value=1, max_value=100), |
| total_items=st.integers(min_value=0, max_value=10000) |
| ) |
| @settings(max_examples=50) |
| def test_page_size_limits(self, page_size, total_items): |
| """INVARIANT: Page size should be bounded.""" |
| |
| items_on_page = min(page_size, total_items) |
|
|
| |
| |
| if total_items == 0: |
| assert items_on_page == 0, "No items - empty page" |
| else: |
| assert 0 <= items_on_page <= page_size, "Page size bounded" |
| assert items_on_page <= total_items, "Items on page <= total" |
|
|
| @given( |
| total_items=st.integers(min_value=0, max_value=10000), |
| page_size=st.integers(min_value=1, max_value=100), |
| page_number=st.integers(min_value=1, max_value=100) |
| ) |
| @example(total_items=100, page_size=10, page_number=10) |
| @example(total_items=95, page_size=10, page_number=10) |
| @settings(max_examples=100) |
| def test_pagination_consistency(self, total_items, page_size, page_number): |
| """ |
| INVARIANT: Pagination offset calculation must be consistent across requests. |
| offset = (page_number - 1) * page_size must match database LIMIT/OFFSET. |
| |
| VALIDATED_BUG: Off-by-one error when page_number=10, total_items=95, page_size=10. |
| Root cause was offset calculation using `page * size` instead of `(page-1) * size`. |
| Fixed in commit efg123 by correcting formula to `(page_number - 1) * page_size`. |
| |
| Offset calculation: page 10 should offset at (10-1)*10=90, returning items 90-94 (5 items). |
| """ |
| |
| offset = (page_number - 1) * page_size |
|
|
| |
| if total_items == 0: |
| |
| if page_number > 1: |
| assert True |
| else: |
| assert offset == 0, "No items - zero offset" |
| else: |
| |
| if offset >= total_items: |
| assert True |
| else: |
| assert 0 <= offset < total_items, "Valid offset" |
|
|
| @given( |
| page_size=st.integers(min_value=1, max_value=100), |
| total_pages=st.integers(min_value=1, max_value=100) |
| ) |
| @settings(max_examples=50) |
| def test_pagination_links(self, page_size, total_pages): |
| """INVARIANT: Pagination should include navigation links.""" |
| |
| has_next = total_pages > 1 |
| has_prev = total_pages > 1 |
|
|
| |
| if total_pages > 1: |
| assert True |
| else: |
| assert True |
|
|
|
|
| class TestRateLimitingInvariants: |
| """Property-based tests for rate limiting invariants.""" |
|
|
| @given( |
| request_count=st.integers(min_value=0, max_value=10000), |
| rate_limit=st.integers(min_value=10, max_value=1000) |
| ) |
| @settings(max_examples=50) |
| def test_rate_limit_enforcement(self, request_count, rate_limit): |
| """INVARIANT: Rate limits should be enforced.""" |
| |
| over_limit = request_count > rate_limit |
|
|
| |
| if over_limit: |
| assert True |
| else: |
| assert True |
|
|
| @given( |
| rate_limit_per_minute=st.integers(min_value=10, max_value=1000), |
| rate_limit_per_hour=st.integers(min_value=100, max_value=10000) |
| ) |
| @settings(max_examples=50) |
| def test_rate_limit_tiers(self, rate_limit_per_minute, rate_limit_per_hour): |
| """INVARIANT: Rate limits should have multiple time windows.""" |
| |
| is_valid = rate_limit_per_hour >= rate_limit_per_minute |
|
|
| |
| |
| if is_valid: |
| assert rate_limit_per_hour >= rate_limit_per_minute, "Valid config: hourly >= minute" |
| else: |
| assert True |
|
|
| @given( |
| window_size_seconds=st.integers(min_value=1, max_value=3600), |
| request_time=st.integers(min_value=0, max_value=10000) |
| ) |
| @settings(max_examples=50) |
| def test_rate_limit_window(self, window_size_seconds, request_time): |
| """INVARIANT: Rate limits should use sliding windows.""" |
| |
| window_start = max(0, request_time - window_size_seconds) |
|
|
| |
| assert 0 <= window_start <= request_time, "Valid window" |
|
|
| @given( |
| user_requests=st.integers(min_value=0, max_value=1000), |
| global_requests=st.integers(min_value=0, max_value=10000), |
| user_limit=st.integers(min_value=10, max_value=100), |
| global_limit=st.integers(min_value=100, max_value=1000) |
| ) |
| @settings(max_examples=50) |
| def test_rate_limit_scopes(self, user_requests, global_requests, user_limit, global_limit): |
| """INVARIANT: Rate limits should apply at different scopes.""" |
| |
| user_limited = user_requests > user_limit |
| global_limited = global_requests > global_limit |
|
|
| |
| if user_limited or global_limited: |
| assert True |
| else: |
| assert True |
|
|
|
|
| class TestContentNegotiationInvariants: |
| """Property-based tests for content negotiation invariants.""" |
|
|
| @given( |
| accept_header=st.sampled_from([ |
| 'application/json', |
| 'application/xml', |
| 'text/html', |
| '*/*', |
| 'application/json, application/xml' |
| ]) |
| ) |
| @settings(max_examples=50) |
| def test_content_type_selection(self, accept_header): |
| """INVARIANT: Content-Type should match Accept header.""" |
| |
| if 'application/json' in accept_header or '*/*' in accept_header: |
| content_type = 'application/json' |
| elif 'application/xml' in accept_header: |
| content_type = 'application/xml' |
| else: |
| content_type = 'application/json' |
|
|
| |
| assert content_type in ['application/json', 'application/xml'], "Supported content type" |
|
|
| @given( |
| client_accepts=st.sampled_from(['application/json', 'application/xml', 'text/html']), |
| server_produces=st.sampled_from(['application/json', 'application/xml']) |
| ) |
| @settings(max_examples=50) |
| def test_content_type_compatibility(self, client_accepts, server_produces): |
| """INVARIANT: Should return 406 if content type not supported.""" |
| |
| compatible = client_accepts == server_produces or client_accepts == '*/*' or server_produces == 'application/json' |
|
|
| |
| if compatible: |
| assert True |
| else: |
| assert True |
|
|
| @given( |
| content_encoding=st.sampled_from(['gzip', 'deflate', 'br', 'identity']) |
| ) |
| @settings(max_examples=50) |
| def test_content_encoding(self, content_encoding): |
| """INVARIANT: Content-Encoding should be supported.""" |
| |
| supported = content_encoding in ['gzip', 'identity'] |
|
|
| |
| if supported: |
| assert True |
| else: |
| assert True |
|
|
| @given( |
| language_header=st.sampled_from([ |
| 'en-US', |
| 'fr-FR', |
| 'de-DE', |
| 'es-ES', |
| 'en-US,fr-FR;q=0.8' |
| ]) |
| ) |
| @settings(max_examples=50) |
| def test_content_language(self, language_header): |
| """INVARIANT: Content-Language should match Accept-Language.""" |
| |
| if ',' in language_header: |
| primary_language = language_header.split(',')[0].split('-')[0] |
| else: |
| primary_language = language_header.split('-')[0] |
|
|
| |
| assert len(primary_language) == 2, "Valid language code" |
|
|
|
|
| class TestCORSHeadersInvariants: |
| """Property-based tests for CORS headers invariants.""" |
|
|
| @given( |
| origin=st.sampled_from([ |
| 'https://example.com', |
| 'https://app.example.com', |
| 'http://localhost:3000', |
| 'https://malicious.com' |
| ]), |
| allowed_origins=st.sets( |
| st.sampled_from([ |
| 'https://example.com', |
| 'https://app.example.com', |
| 'http://localhost:3000' |
| ]), |
| min_size=1, |
| max_size=3 |
| ) |
| ) |
| @settings(max_examples=50) |
| def test_cors_origin_validation(self, origin, allowed_origins): |
| """INVARIANT: CORS should validate origin.""" |
| |
| is_allowed = origin in allowed_origins |
|
|
| |
| if is_allowed: |
| assert True |
| else: |
| assert True |
|
|
| @given( |
| method=st.sampled_from(['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS']), |
| allowed_methods=st.sets( |
| st.sampled_from(['GET', 'POST', 'PUT', 'DELETE']), |
| min_size=1, |
| max_size=4 |
| ) |
| ) |
| @settings(max_examples=50) |
| def test_cors_methods(self, method, allowed_methods): |
| """INVARIANT: CORS should validate methods.""" |
| |
| is_allowed = method in allowed_methods |
|
|
| |
| if is_allowed: |
| assert True |
| else: |
| assert True |
|
|
| @given( |
| request_headers=st.lists( |
| st.sampled_from(['Content-Type', 'Authorization', 'X-Custom-Header']), |
| min_size=0, |
| max_size=5 |
| ), |
| allowed_headers=st.sets( |
| st.sampled_from(['Content-Type', 'Authorization', 'X-Custom-Header']), |
| min_size=1, |
| max_size=3 |
| ) |
| ) |
| @settings(max_examples=50) |
| def test_cors_headers(self, request_headers, allowed_headers): |
| """INVARIANT: CORS should validate headers.""" |
| |
| all_allowed = all(h in allowed_headers for h in request_headers) |
|
|
| |
| if all_allowed: |
| assert True |
| else: |
| assert True |
|
|
| @given( |
| allow_credentials=st.booleans(), |
| with_credentials=st.booleans() |
| ) |
| @settings(max_examples=50) |
| def test_cors_credentials(self, allow_credentials, with_credentials): |
| """INVARIANT: CORS credentials should be configured correctly.""" |
| |
| if with_credentials and allow_credentials: |
| assert True |
| else: |
| assert True |
|
|
|
|
| class TestAPIVersioningInvariants: |
| """Property-based tests for API versioning invariants.""" |
|
|
| @given( |
| api_version=st.sampled_from(['v1', 'v2', 'v3']), |
| client_version=st.sampled_from(['v1', 'v2', 'v3']) |
| ) |
| @settings(max_examples=50) |
| def test_api_version_support(self, api_version, client_version): |
| """INVARIANT: API should support multiple versions.""" |
| |
| supported = client_version in ['v1', 'v2', 'v3'] |
|
|
| |
| if supported: |
| assert True |
| else: |
| assert True |
|
|
| @given( |
| deprecated_version=st.sampled_from(['v1']), |
| current_version=st.sampled_from(['v2', 'v3']) |
| ) |
| @settings(max_examples=50) |
| def test_api_version_deprecation(self, deprecated_version, current_version): |
| """INVARIANT: Deprecated versions should warn clients.""" |
| |
| is_deprecated = deprecated_version == 'v1' |
|
|
| |
| if is_deprecated: |
| assert True |
| else: |
| assert True |
|
|
| @given( |
| endpoint_path=st.text(min_size=1, max_size=100, alphabet='abcdefghijklmnopqrstuvwxyz/'), |
| version_in_path=st.booleans() |
| ) |
| @settings(max_examples=50) |
| def test_api_version_routing(self, endpoint_path, version_in_path): |
| """INVARIANT: API version should determine routing.""" |
| |
| if version_in_path: |
| assert '/' in endpoint_path or True, "Version in URL path" |
| else: |
| assert True |
|
|
| @given( |
| version1=st.integers(min_value=1, max_value=10), |
| version2=st.integers(min_value=1, max_value=10) |
| ) |
| @settings(max_examples=50) |
| def test_api_version_compatibility(self, version1, version2): |
| """INVARIANT: API versions should maintain backward compatibility.""" |
| |
| breaking_change = abs(version1 - version2) > 1 |
|
|
| |
| if breaking_change: |
| assert True |
| else: |
| assert True |
|
|