342 lines
12 KiB
Python
342 lines
12 KiB
Python
"""Shared pytest fixtures and RLS helpers for tests against Django CRM.
|
|
|
|
Authenticated API clients, test organizations, users and profiles, the
|
|
helpers that drive the PostgreSQL ``app.current_org`` session variable, and the
|
|
row-level security coverage checks (``org_tables_not_covered`` and
|
|
``rls_policy_gaps``) that downstream suites call with their own tables.
|
|
|
|
This lives inside the ``common`` app rather than in the repository-root
|
|
``conftest.py`` so that it ships in the wheel. Anything defined only in that
|
|
conftest is unreachable from an installed copy: it sits outside every package,
|
|
so hatchling does not collect it, and a downstream test suite could only get at
|
|
it by pointing at a source checkout on disk. The enterprise suite did exactly
|
|
that, loading the file through a symlink by absolute path, which is the last
|
|
thing that tied the two repositories together at runtime.
|
|
|
|
The root ``conftest.py`` re-exports every fixture and RLS context helper below, so
|
|
``from conftest import rls_org`` keeps working unchanged in this repository.
|
|
Downstream consumers should import from ``common.testing`` instead.
|
|
"""
|
|
|
|
import contextlib
|
|
from collections.abc import Iterable, Mapping
|
|
|
|
import pytest
|
|
from django.apps import apps
|
|
from django.core.exceptions import FieldDoesNotExist
|
|
from django.core.signals import request_finished
|
|
from django.db import connection, models
|
|
from rest_framework.test import APIClient
|
|
|
|
from common.models import Org, Profile, User
|
|
from common.rls import (
|
|
INSERT_POLICY,
|
|
ISOLATION_POLICY,
|
|
ORG_SCOPED_TABLES,
|
|
get_check_rls_status_sql,
|
|
get_check_table_exists_sql,
|
|
)
|
|
from common.serializer import OrgAwareRefreshToken
|
|
|
|
# The org a test's bare ORM calls act as. Tracked in a mutable holder rather
|
|
# than passed around because it has to be readable from the `request_finished`
|
|
# receiver below, which takes no test-specific arguments.
|
|
#
|
|
# On SQLite this whole mechanism is inert: RLS is a PostgreSQL feature, the
|
|
# session variable does not exist, and every helper here returns early.
|
|
_ambient_org = {"id": None}
|
|
|
|
|
|
TABLES_WITHOUT_RLS = {
|
|
"security_audit_log": (
|
|
"platform ledger; its rows have no org or straddle two, "
|
|
"which the insert policy refuses (common/0036)"
|
|
),
|
|
"personal_access_token": "looked up by token hash before any org context exists",
|
|
"calendar_feed_token": "the public feed looks it up by token hash to learn the org",
|
|
"profile": "the middleware reads it to learn the org before it sets app.current_org",
|
|
"portal_access_token": "resolved by token before the portal sets the org context",
|
|
"portal_login_token": "resolved by token before the portal sets the org context",
|
|
}
|
|
|
|
|
|
def _org_table_names():
|
|
tables = set()
|
|
for model in apps.get_models():
|
|
if model._meta.proxy or not model._meta.managed:
|
|
continue
|
|
try:
|
|
field = model._meta.get_field("org")
|
|
except FieldDoesNotExist:
|
|
continue
|
|
if isinstance(field, models.ForeignKey) and field.related_model is Org:
|
|
tables.add(model._meta.db_table)
|
|
return tables
|
|
|
|
|
|
def org_tables_not_covered(
|
|
excluded: Mapping[str, str] = TABLES_WITHOUT_RLS,
|
|
) -> list[str]:
|
|
"""Return one ``"<table>: <problem>"`` line per gap between models and RLS.
|
|
|
|
Reads ``ORG_SCOPED_TABLES`` at call time so tables a downstream suite
|
|
appends to it count.
|
|
"""
|
|
org_tables = _org_table_names()
|
|
listed = set(ORG_SCOPED_TABLES)
|
|
problems = []
|
|
for table in sorted(org_tables - listed - set(excluded)):
|
|
problems.append(f"{table}: has an org field but is neither listed nor excluded")
|
|
for table in sorted(set(excluded) - org_tables):
|
|
problems.append(f"{table}: excluded but is not an org table (stale exclusion)")
|
|
for table in sorted(listed & set(excluded)):
|
|
problems.append(f"{table}: both listed and excluded")
|
|
return problems
|
|
|
|
|
|
def rls_policy_gaps(tables: Iterable[str] | None = None) -> list[str]:
|
|
"""Return one ``"<table>: <problem>"`` line per missing RLS control.
|
|
|
|
PostgreSQL only. Defaults to ``ORG_SCOPED_TABLES`` read at call time.
|
|
"""
|
|
if tables is None:
|
|
tables = ORG_SCOPED_TABLES
|
|
problems = []
|
|
with connection.cursor() as cursor:
|
|
for table in tables:
|
|
cursor.execute(get_check_table_exists_sql(), [table])
|
|
if not cursor.fetchone()[0]:
|
|
problems.append(f"{table}: table is missing")
|
|
continue
|
|
cursor.execute(get_check_rls_status_sql(), [table])
|
|
enabled, forced = cursor.fetchone()
|
|
if not enabled:
|
|
problems.append(f"{table}: RLS is not enabled")
|
|
if not forced:
|
|
problems.append(f"{table}: RLS is not forced")
|
|
cursor.execute(
|
|
"SELECT policyname FROM pg_policies "
|
|
"WHERE schemaname = 'public' AND tablename = %s",
|
|
[table],
|
|
)
|
|
present = {row[0] for row in cursor.fetchall()}
|
|
for policy in (ISOLATION_POLICY, INSERT_POLICY):
|
|
if policy not in present:
|
|
problems.append(f"{table}: policy {policy} is absent")
|
|
return problems
|
|
|
|
|
|
def _apply_rls_context(org_id):
|
|
"""Set ``app.current_org`` without the statement counting as a query.
|
|
|
|
Two details, both learned by breaking the suite:
|
|
|
|
* It goes through the raw DBAPI cursor rather than ``connection.cursor()``,
|
|
which would wrap it in Django's instrumentation. This runs from a
|
|
``request_finished`` receiver that fires inside the block
|
|
``django_assert_num_queries`` is measuring, and the harness putting the
|
|
test's own context back is not a query the view performed.
|
|
* It is skipped once the transaction is broken. Several tests deliberately
|
|
provoke an ``IntegrityError`` to prove a unique constraint bites; after
|
|
one of those, any further statement raises ``TransactionManagementError``,
|
|
and running this in teardown turned each of those passes into an error.
|
|
"""
|
|
if connection.vendor != "postgresql":
|
|
return
|
|
if connection.needs_rollback:
|
|
return
|
|
connection.ensure_connection()
|
|
with connection.connection.cursor() as cursor:
|
|
cursor.execute(
|
|
"SELECT set_config('app.current_org', %s, false)", [str(org_id or "")]
|
|
)
|
|
|
|
|
|
def set_rls_context(org):
|
|
"""Make ``org`` the org this test's direct ORM calls read and write as.
|
|
|
|
Needed because ``organization`` and ``profile`` are not org-scoped, but the
|
|
other 58 tables are: under a role that RLS actually binds, a bare
|
|
``Lead.objects.create(org=org_a)`` with no context set is refused by the
|
|
insert-check policy, and a bare read matches nothing.
|
|
"""
|
|
_ambient_org["id"] = org.id
|
|
_apply_rls_context(org.id)
|
|
|
|
|
|
def clear_rls_context():
|
|
"""Clear the PostgreSQL RLS session variable."""
|
|
_ambient_org["id"] = None
|
|
_apply_rls_context(None)
|
|
|
|
|
|
def restore_rls_context():
|
|
"""Re-apply the ambient context after something else cleared it.
|
|
|
|
The Celery tasks that walk every org set ``app.current_org`` per org and
|
|
clear it on the way out, which is right: a worker has no middleware and runs
|
|
on a pooled connection, so leaving the last tenant's id behind would hand it
|
|
to the next borrower. A test that calls such a task directly and then reads
|
|
its own rows needs the context put back, the same way
|
|
``_restore_rls_context_after_each_request`` does it for an HTTP request.
|
|
"""
|
|
_apply_rls_context(_ambient_org["id"])
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def rls_org(org):
|
|
"""Run a block as ``org``, then restore the ambient context.
|
|
|
|
For the cross-tenant tests, which have to plant a row in the *other* org
|
|
before proving it stays invisible. The insert-check policy compares against
|
|
the session variable, so seeding org B's row requires being org B for the
|
|
length of that write and no longer.
|
|
"""
|
|
previous = _ambient_org["id"]
|
|
set_rls_context(org)
|
|
try:
|
|
yield
|
|
finally:
|
|
_ambient_org["id"] = previous
|
|
_apply_rls_context(previous)
|
|
|
|
|
|
def set_modules_off(org, *keys) -> None:
|
|
"""Switch the named modules off for ``org``, touching nothing else."""
|
|
org.modules_off = list(keys)
|
|
org.save(update_fields=["modules_off"])
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _use_db(db):
|
|
"""Ensure all tests have database access."""
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _restore_rls_context_after_each_request(db):
|
|
"""Put the ambient context back once a test client request has finished.
|
|
|
|
Takes ``db`` so pytest finalizes this fixture *before* the database one.
|
|
Without that, the teardown below runs after pytest-django has revoked
|
|
database access and every test in the run reports a teardown error.
|
|
|
|
|
|
``RLSContextMiddleware`` sets ``app.current_org`` from the JWT for the life
|
|
of a request and clears it in a ``finally``, which is correct in production:
|
|
the variable is session-scoped, connections are pooled, and a leftover value
|
|
is how one tenant's context leaks into the next request. It also means that
|
|
in a test, every ``client.get(...)`` wipes whatever the test set up, so the
|
|
familiar "call the endpoint, then assert against the ORM" shape fails on the
|
|
assertion rather than the behaviour under test.
|
|
|
|
Restoring afterwards is not a hole in the enforcement: the request itself
|
|
still ran under exactly the context the middleware derived, so any missing
|
|
org filter still shows up. This only affects the test's own statements
|
|
before and after.
|
|
"""
|
|
if connection.vendor != "postgresql":
|
|
yield
|
|
return
|
|
|
|
def _restore(**kwargs):
|
|
if _ambient_org["id"] is not None:
|
|
_apply_rls_context(_ambient_org["id"])
|
|
|
|
request_finished.connect(_restore)
|
|
try:
|
|
yield
|
|
finally:
|
|
request_finished.disconnect(_restore)
|
|
had_context = _ambient_org["id"] is not None
|
|
_ambient_org["id"] = None
|
|
if had_context:
|
|
try:
|
|
_apply_rls_context(None)
|
|
except RuntimeError:
|
|
# A `django.test.TestCase` re-blocks database access in its own
|
|
# teardown, which for those tests runs before this fixture's.
|
|
# The transaction that carried the context is already rolled
|
|
# back by then, so there is nothing left to clear.
|
|
pass
|
|
|
|
|
|
@pytest.fixture
|
|
def org_a():
|
|
"""The org a test acts as by default.
|
|
|
|
Creating it also makes it the ambient RLS context, so a test that says
|
|
``Lead.objects.create(org=org_a)`` needs no further ceremony. ``org_b``
|
|
deliberately does not do this: it exists to be the *other* tenant, and
|
|
silently repointing the context at it would turn cross-org assertions into
|
|
same-org ones.
|
|
"""
|
|
org = Org.objects.create(name="Test Organization A")
|
|
set_rls_context(org)
|
|
return org
|
|
|
|
|
|
@pytest.fixture
|
|
def org_b():
|
|
return Org.objects.create(name="Test Organization B")
|
|
|
|
|
|
@pytest.fixture
|
|
def admin_user():
|
|
return User.objects.create_user(email="admin@test.com", password="testpass123")
|
|
|
|
|
|
@pytest.fixture
|
|
def regular_user():
|
|
return User.objects.create_user(email="user@test.com", password="testpass123")
|
|
|
|
|
|
@pytest.fixture
|
|
def user_b():
|
|
return User.objects.create_user(email="userb@test.com", password="testpass123")
|
|
|
|
|
|
@pytest.fixture
|
|
def admin_profile(admin_user, org_a):
|
|
return Profile.objects.create(
|
|
user=admin_user, org=org_a, role="ADMIN", is_active=True
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def user_profile(regular_user, org_a):
|
|
return Profile.objects.create(
|
|
user=regular_user, org=org_a, role="USER", is_active=True
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def profile_b(user_b, org_b):
|
|
return Profile.objects.create(user=user_b, org=org_b, role="ADMIN", is_active=True)
|
|
|
|
|
|
def _make_authenticated_client(user, org, profile):
|
|
"""Create an APIClient with JWT auth for the given user/org."""
|
|
client = APIClient()
|
|
token = OrgAwareRefreshToken.for_user_and_org(user, org, profile)
|
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token.access_token}")
|
|
return client
|
|
|
|
|
|
@pytest.fixture
|
|
def admin_client(admin_user, org_a, admin_profile):
|
|
return _make_authenticated_client(admin_user, org_a, admin_profile)
|
|
|
|
|
|
@pytest.fixture
|
|
def user_client(regular_user, org_a, user_profile):
|
|
return _make_authenticated_client(regular_user, org_a, user_profile)
|
|
|
|
|
|
@pytest.fixture
|
|
def org_b_client(user_b, org_b, profile_b):
|
|
return _make_authenticated_client(user_b, org_b, profile_b)
|
|
|
|
|
|
@pytest.fixture
|
|
def unauthenticated_client():
|
|
return APIClient()
|