Close the gaps between the documented contract (API-STANDARD, TENANCY, ENTITLEMENTS) and the implementation, and remove committed build artifacts. Security: - verify + process inbound webhooks (HMAC/handler verify, size limit, timestamp tolerance, idempotency via InboundEvent.external_id) - real 2FA login flow (pre-auth challenge; tokens only after verify/recovery) - wire HybridPermission into security/audit views; add API-key rotate and users/<id>/permissions|roles endpoints - tenant-scoped throttling on by default; webhook replay protection - verify MercadoPago webhook signatures - login brute-force guard, configurable password policy, real ALTCHA PoW Correctness: - apply verified billing webhooks idempotently (subscription/entitlement/ invoice/PaymentTransaction); scheduled payment lifecycle jobs - capture audit update diffs automatically; add audit retention purge - working notification retries, per-channel rate limits, log retention - pluggable virus scanner, upload-size limit, pipeline toggle - feature rollout %/environment targeting; settings-driven registrations - workflow guards (instance cap, route depth, self-assignment, clone on re-entry) - wire every previously-dead INFRASYNTH_* setting; drop truly dead ones Delivery: - README + CHANGELOG; CI format check + coverage gate - keep test media out of the tree; untrack .coverage, __pycache__, egg-info, docs/ and invoice artifacts
94 lines
3.5 KiB
Python
94 lines
3.5 KiB
Python
"""Cursor pagination + request id + exception handler (API-STANDARD.md §4-§7)."""
|
|
|
|
import pytest
|
|
from django.http import HttpResponse
|
|
from django.test import RequestFactory
|
|
from rest_framework.exceptions import ValidationError
|
|
|
|
from infrasynth.api.exceptions import envelope_exception_handler
|
|
from infrasynth.api.middleware import RequestIdMiddleware
|
|
from infrasynth.api.pagination import CursorPagination
|
|
from infrasynth.shared.exceptions import EntitlementError
|
|
|
|
|
|
class TestCursorPagination:
|
|
def test_configuration(self):
|
|
pagination = CursorPagination()
|
|
assert pagination.page_size == 25
|
|
assert pagination.max_page_size == 100
|
|
assert pagination.page_size_query_param == "pageSize"
|
|
assert pagination.ordering == "-pk"
|
|
|
|
def test_response_shape(self):
|
|
# get_paginated_response depends on cursor state set during pagination;
|
|
# the envelope renderer test covers the wire shape end-to-end.
|
|
pagination = CursorPagination()
|
|
assert callable(pagination.get_paginated_response)
|
|
|
|
|
|
class TestRequestIdMiddleware:
|
|
def test_generates_request_id(self):
|
|
request = RequestFactory().get("/")
|
|
response = RequestIdMiddleware(lambda r: HttpResponse("ok"))(request)
|
|
assert response["X-Request-Id"] == request.request_id
|
|
assert request.request_id.startswith("req_")
|
|
|
|
def test_echoes_supplied_request_id(self):
|
|
request = RequestFactory().get("/", HTTP_X_REQUEST_ID="req_abc")
|
|
response = RequestIdMiddleware(lambda r: HttpResponse("ok"))(request)
|
|
assert response["X-Request-Id"] == "req_abc"
|
|
assert request.request_id == "req_abc"
|
|
|
|
|
|
class TestEnvelopeExceptionHandler:
|
|
def test_app_error_maps_to_code_and_status(self):
|
|
response = envelope_exception_handler(
|
|
EntitlementError(code="ENTITLEMENT_PLAN_UPGRADE_REQUIRED", app="helpdesk", feature="tickets"),
|
|
{},
|
|
)
|
|
assert response.status_code == 402
|
|
assert response.data["code"] == "ENTITLEMENT_PLAN_UPGRADE_REQUIRED"
|
|
assert response.data["details"][0]["app"] == "helpdesk"
|
|
assert response.data["details"][0]["feature"] == "tickets"
|
|
|
|
def test_drf_validation_error_flattens_details(self):
|
|
response = envelope_exception_handler(ValidationError({"name": ["required"]}), {})
|
|
assert response.status_code == 400
|
|
assert response.data["code"] == "VALIDATION_ERROR"
|
|
assert response.data["details"] == [{"field": "name", "issue": "required"}]
|
|
|
|
def test_unexpected_exception_is_not_masked(self):
|
|
assert envelope_exception_handler(RuntimeError("boom"), {}) is None
|
|
|
|
|
|
@pytest.mark.django_db
|
|
class TestIdempotency:
|
|
def test_replays_first_successful_response(self):
|
|
from django.core.cache import cache
|
|
from rest_framework.response import Response
|
|
|
|
from infrasynth.api.idempotency import idempotent
|
|
|
|
cache.clear()
|
|
|
|
class _View:
|
|
def __init__(self):
|
|
self.calls = 0
|
|
|
|
@idempotent
|
|
def post(self, request):
|
|
self.calls += 1
|
|
return Response({"n": self.calls})
|
|
|
|
class _Request:
|
|
method = "POST"
|
|
path = "/api/v1/billing/checkout/"
|
|
headers = {"Idempotency-Key": "key-1"}
|
|
|
|
view = _View()
|
|
first = view.post(_Request())
|
|
second = view.post(_Request())
|
|
assert first.data == {"n": 1}
|
|
assert second.data == {"n": 1}
|
|
assert second["Idempotent-Replay"] == "true"
|
|
assert view.calls == 1
|