Generic Example — Security Tests
Security tests validate your defense-in-depth controls at the API boundary:
- Input validation (
Field(..., max_length=...), type checks) → typically422 - Input sanitization (for rich-text HTML fields only) → content is stored/returned safely
- AuthN/AuthZ rules →
401unauthenticated,403forbidden (as applicable)
This page focuses on generic patterns you should replicate across modules.
Prerequisites (fixtures)
These examples assume:
clientexists intests/conftest.pyand is bound to your ASGI app (seeGeneric Example - Conftest).auth_headersexists intests/conftest.py(reference backend builds this via a real login flow).tenant_idexists intests/conftest.py(or equivalent “scope id” for your app).
Template: input sanitization (XSS prevention)
This test verifies that dangerous HTML/script input is sanitized for a field that is explicitly intended to store/render HTML (rich text).
See docs/backend/01_security/xss_prevention.md for the standard sanitizer guidance.
import pytest
from httpx import AsyncClient
pytestmark = pytest.mark.asyncio
async def test_xss_input_is_sanitized(client: AsyncClient, auth_headers: dict[str, str], tenant_id: str):
xss_input = "<script>alert('xss')</script><p>Safe rich text</p>"
response = await client.post(
"/api/v1/items",
headers={**auth_headers, "X-Tenant-Id": tenant_id},
# Use a field that your schema treats as HTML/rich text (example: description_html).
json={"name": "Item Name", "description_html": xss_input, "price_cents": 50},
)
assert response.status_code == 200, response.text
html = response.json()["content"]["item"]["description_html"]
# Assert the dangerous parts are removed/escaped, but safe text remains.
assert "<script>" not in html
assert "Safe rich text" in html
Template: max-length validation
This test verifies schemas enforce declared max lengths (or equivalent validation rules).
import pytest
from httpx import AsyncClient
pytestmark = pytest.mark.asyncio
async def test_max_length_is_enforced(client: AsyncClient, auth_headers: dict[str, str], tenant_id: str):
# Example: if schema says `max_length=100`
long_name = "x" * 101
response = await client.post(
"/api/v1/items",
headers={**auth_headers, "X-Tenant-Id": tenant_id},
json={"name": long_name, "price_cents": 50},
)
assert response.status_code == 422, response.text
Template: authorization guardrails (optional)
Use this pattern when access should be denied based on role/tenant/resource rules.
import pytest
from httpx import AsyncClient
pytestmark = pytest.mark.asyncio
async def test_forbidden_when_user_lacks_access(client: AsyncClient, auth_headers: dict[str, str]):
response = await client.get("/api/v1/admin-only-endpoint", headers=auth_headers)
assert response.status_code in (403, 404) # choose your convention; document it in your API standards
Common pitfalls
- Testing sanitization only in the sanitizer unit: also test it at the API boundary so schema + sanitizer integration is verified.
- Asserting exact validation error strings: prefer status + presence of error fields; error messages can change across library versions.
- Hard-coding role semantics: keep tests aligned to your documented auth rules (see
Authentication+ any API conventions).