Testing¶
Running the project's own test suite¶
git clone https://github.com/stackadnan/django-tenant-apikeys
cd django-tenant-apikeys
pip install -e ".[dev]"
pytest --cov=django_tenant_apikeys --cov-report=term-missing
The suite runs against an in-memory SQLite database (tests/settings.py),
with concrete subclasses of AbstractTenantAPIKey defined in
tests/models.py — the abstract model itself can't be instantiated or
queried directly, so something concrete has to stand in for it. CI also
runs the same suite against PostgreSQL
(pytest --ds=tests.settings_postgres), and against the full supported
Python/Django matrix — see
.github/workflows/test.yml.
These are the exact three commands CI runs on every push and PR:
pytest --cov=django_tenant_apikeys --cov-report=term-missing
ruff check .
mypy django_tenant_apikeys
Coverage is enforced at 100% (fail_under = 100 in pyproject.toml). If
you're contributing a change that adds a branch, it needs a test that
exercises it — no line is excluded without a # pragma: no cover and a
reason in a comment.
Testing your own authentication/permission setup¶
An authentication backend is a security boundary, not just a feature — a
bug in one doesn't just misbehave, it can silently let the wrong request
through. Testing one thoroughly looks different from testing a normal view:
you're less interested in the happy path and more interested in proving
every rejection path actually rejects. The patterns below come straight
from how this package tests itself, in
tests/test_authentication.py
and
tests/test_drf_policy_integration.py.
Calling authenticate() directly, without routing¶
from rest_framework.request import Request
from rest_framework.test import APIRequestFactory
_factory = APIRequestFactory()
def build_request(auth_header: str | None = None) -> Request:
extra = {"HTTP_AUTHORIZATION": auth_header} if auth_header is not None else {}
return Request(_factory.get("/", **extra))
APIRequestFactory builds a bare HttpRequest; wrapping it in DRF's
Request makes get_authorization_header() and the rest of DRF's
request-parsing available. This skips routing and views entirely — a
failure can only mean the authentication class itself is wrong, not
something upstream of it.
Testing a full permission chain without URL routing¶
For a policy chain (IP restriction, rate limit, scope — see
How the policies interact),
APIView.as_view() can be called directly on a built request, without
registering a URL at all:
class OrdersView(APIView):
authentication_classes = [TenantAPIKeyAuthentication]
permission_classes = [HasAllowedIP, WithinRateLimit, HasAPIKeyScope]
required_scopes = ["orders:read"]
def get(self, request):
return Response({"ok": True})
def call(raw_key, remote_addr="203.0.113.10"):
request = _factory.get("/", HTTP_AUTHORIZATION=f"Api-Key {raw_key}", REMOTE_ADDR=remote_addr)
return OrdersView.as_view()(request)
This exercises DRF's actual authenticate() → check_permissions() flow —
the real dispatch order — while still avoiding urls.py and a test client.
Testing a Django Ninja view the same way¶
ninja.testing.TestClient runs a real NinjaAPI operation, including
authentication and whatever inline checks the view performs, without a
running server:
from ninja.testing import TestClient
client = TestClient(api)
response = client.get("/orders", headers={"Authorization": f"Api-Key {raw_key}"})
response.status_code
Standing in for an abstract model¶
AbstractTenantAPIKey can't be tested directly. Define a small concrete
model in your own test suite — this package's is tests/models.py:
class TenantAPIKey(AbstractTenantAPIKey):
tenant = models.ForeignKey(Tenant, related_name="api_keys", on_delete=models.CASCADE)
If you're testing behavior that depends on whether a tenant relation
exists at all (like request.tenant attachment), define a second concrete
model with no tenant field — this package's UnlinkedAPIKey exists
specifically to prove request.tenant is never set when there's nothing
to attach.
Enumerate rejection paths, not just the happy path¶
A representative (not exhaustive) list of what's worth a dedicated test, one assertion each, one cause each:
- no
Authorizationheader → not an error,None(so another authenticator gets a turn) - wrong scheme → also
None - an unrecognized prefix → rejected
- a real prefix, tampered/wrong secret → rejected, with the same message as an unrecognized prefix (see Security)
is_active=False→ rejected, distinct message from an invalid keyexpires_atin the past → rejected, distinct message againexpires_at=None→ succeeds — proves "no expiry" isn't accidentally treated as "already expired"- IP outside
allowed_ips→ rejected, and never reaches the rate limiter or scope check - rate limit exceeded → 429, and never reaches the scope check
- missing required scope → rejected, only after IP and rate limit pass
Testing side effects, not just return values¶
record_usage() doesn't show up in authenticate()'s return value — check
the database after the fact, and refresh_from_db() first, since it writes
via a targeted .update() call rather than instance.save():
def test_successful_authentication_records_usage(tenant, api_key):
instance, raw_key = api_key
request = build_request(f"Api-Key {raw_key}")
TenantAPIKeyAuthentication().authenticate(request)
instance.refresh_from_db()
assert instance.last_used_at is not None
Testing configuration errors with override_settings¶
def test_raises_when_unset() -> None:
with override_settings(TENANT_API_KEY_MODEL=None):
with pytest.raises(ImproperlyConfigured, match="not set"):
get_api_key_model()
Scoped to one test — restored automatically on exit, so a broken setting in one test can't leak into the next.
Next¶
- Security — what every one of these rejection paths is actually defending against.
- API reference — the full signature list for everything referenced above.