flowCreate.solutions

Generic Example — Security Tests

Security tests validate your defense-in-depth controls at the API boundary:

  • Input validation (Field(..., max_length=...), type checks) → typically 422
  • Input sanitization (for rich-text HTML fields only) → content is stored/returned safely
  • AuthN/AuthZ rules → 401 unauthenticated, 403 forbidden (as applicable)

This page focuses on generic patterns you should replicate across modules.

Prerequisites (fixtures)

These examples assume:

  • client exists in tests/conftest.py and is bound to your ASGI app (see Generic Example - Conftest).
  • auth_headers exists in tests/conftest.py (reference backend builds this via a real login flow).
  • tenant_id exists in tests/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).