✨(backend) add a LoadTest configuration and its loadtest application

To test the application on real condition we want to authenticate users.
The authentication is made using OIDC and we don't want to depend on
this during the tests. To bypass it, we will create real user sessions,
export thei identifiers and then use it later with the tool firing the
requests. The session are linked to real users, everything is made in a
dedicated django application and this application is explicitely loaded
in a dedicated environment and can't be used in the Production
environment.
This commit is contained in:
Manuel Raynaud
2026-09-23 12:06:05 +02:00
parent 666d9c84a7
commit 3cd85c95dc
15 changed files with 878 additions and 1 deletions
+2
View File
@@ -14,6 +14,8 @@ and this project adheres to
- 🐛(frontend) reduce PostHog volume from web vitals and opt_in spam #2701
- ✨(backend) expose the attachment max size in the config endpoint #2577
- ✨(frontend) warn before uploading an attachment over the size limit #2577
- ✨(backend) add a `LoadTest` configuration and its `loadtest` application,
minting sessions for load tests
- ✨(collaboration) add opt-in prometheus metrics to yhub, server and worker,
protected by a bearer token
- ✨(collaboration) report yhub errors to sentry, configured through
+31
View File
@@ -1235,6 +1235,16 @@ class Base(Configuration):
SILKY_MAX_REQUEST_BODY_SIZE = 0
SILKY_MAX_RESPONSE_BODY_SIZE = 0
# -- Load-test tooling ---------------------------------------------------
# The `loadtest` application mints sessions for existing users, which is
# what lets a load generator act as thousands of them without going through
# the OIDC login. It is not in INSTALLED_APPS: only the `LoadTest`
# configuration below installs it and turns this on. Deliberately not read
# from the environment — no variable can enable it on another configuration —
# and pinned to False again in `Production`, which every deployed
# configuration inherits from.
LOAD_TEST_TOOLS_ENABLED = False
# -- Metrics (django-prometheus) -----------------------------------------
# Opt-in Prometheus instrumentation, OFF by default. When enabled,
# `django_prometheus` is added to INSTALLED_APPS and its two middlewares
@@ -1606,6 +1616,11 @@ class Production(Base):
SESSION_COOKIE_SECURE = True
SESSION_CACHE_ALIAS = "session"
# Never in production: sessions are only minted for a load test, with the
# `LoadTest` configuration. Everything that tooling adds is switched off
# here explicitly, whatever the defaults of `Base` become.
LOAD_TEST_TOOLS_ENABLED = False
# Privacy
SECURE_REFERRER_POLICY = "same-origin"
@@ -1708,6 +1723,22 @@ class PreProduction(Production):
"""
class LoadTest(Production):
"""
Load-test environment settings: a production-like deployment, on anonymised
data, that a load generator can log into.
It is `Production` plus the `loadtest` application, whose commands mint
sessions for existing users (see `documentation/stress-test-plan.md`). Select
it with DJANGO_CONFIGURATION=LoadTest, and never on an instance holding real
users: anybody able to run a command there can act as any of them.
"""
LOAD_TEST_TOOLS_ENABLED = True
# a list of its own: the one of `Base` is shared by every configuration
INSTALLED_APPS = [*Production.INSTALLED_APPS, "loadtest"]
class Demo(Production):
"""
Demonstration environment settings
+6
View File
@@ -0,0 +1,6 @@
"""Load-test tooling for Docs.
Never part of a regular deployment: this application is only installed by the
`LoadTest` configuration (`impress/settings.py`), and refuses to load anywhere
LOAD_TEST_TOOLS_ENABLED is not set — see `apps.py`.
"""
+27
View File
@@ -0,0 +1,27 @@
"""Application configuration of the load-test tooling."""
from django.apps import AppConfig
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
class LoadTestConfig(AppConfig):
"""Tools that log synthetic users in, for load tests only."""
name = "loadtest"
verbose_name = "Load test tooling"
def ready(self):
"""
Refuse to load where the tooling is not enabled.
The `LoadTest` configuration is the only one that installs this
application, and the only one that enables the setting. Whoever adds it
to the INSTALLED_APPS of another configuration gets a process that does
not start, rather than a production able to mint sessions.
"""
if not settings.LOAD_TEST_TOOLS_ENABLED:
raise ImproperlyConfigured(
"The `loadtest` application is installed but LOAD_TEST_TOOLS_ENABLED "
"is not set: it must only be used with the `LoadTest` configuration."
)
@@ -0,0 +1,139 @@
"""create_load_test_sessions — log synthetic users in, for a load test.
Only available with the `LoadTest` configuration. It writes, straight to the
session store, the sessions an OIDC login would have opened for existing users,
and a manifest telling a load generator which cookie to send and which documents
each of those users may open:
{
"cookie_name": "docs_sessionid", "created_at": …, "expires_at": …,
"public_documents": ["<document id>", …],
"sessions": [
{"user_id": …, "session_key": …,
"editable_documents": […], "readonly_documents": […]}, …
]
}
The manifest holds live session keys: it is a secret. It goes to a file only its
owner can read (`--output`) or to a private object of the storage
(`--storage-name`, under `loadtest/`), never to the output of this command.
python manage.py create_load_test_sessions 1000 --output /tmp/manifest.json
python manage.py create_load_test_sessions 5000 --heaviest 50 \\
--storage-name campaign-1.json --ttl-hours 8
Use one virtual user per session: the API throttles per user. Revoke everything
with `revoke_load_test_sessions` once the campaign is over.
"""
from datetime import timedelta
from django.core.management.base import BaseCommand, CommandError
from loadtest import manifests, sessions
class Command(BaseCommand):
"""Mint the sessions of a load test and write their manifest."""
help = __doc__
def add_arguments(self, parser):
"""Define command arguments."""
parser.add_argument("count", type=int, help="Number of users to log in.")
parser.add_argument(
"--heaviest",
type=int,
default=0,
help="How many of them are the users holding the most accesses, "
"instead of a random draw (default: 0).",
)
parser.add_argument(
"--documents-per-user",
type=int,
default=20,
help="Editable and read-only documents listed per user (default: 20 each).",
)
parser.add_argument(
"--public-documents",
type=int,
default=100,
help="Public documents listed for everybody (default: 100).",
)
parser.add_argument(
"--ttl-hours",
type=float,
default=12,
help="Lifetime of the sessions, in hours (default: 12, at most 168).",
)
destination = parser.add_mutually_exclusive_group(required=True)
destination.add_argument(
"--output", help="File to write the manifest to (created with mode 0600)."
)
destination.add_argument(
"--storage-name",
help="Name of the object to write the manifest to, under "
f"`{manifests.STORAGE_PREFIX}` in the default storage.",
)
parser.add_argument(
"--force",
action="store_true",
help="Replace the manifest when it already exists.",
)
def handle(self, *args, **options):
"""Mint the sessions, write the manifest, report counts and nothing else."""
try:
sessions.ensure_enabled()
except sessions.LoadTestToolsDisabled as error:
raise CommandError(str(error)) from error
if options["count"] < 1:
raise CommandError("count must be at least 1.")
ttl = timedelta(hours=options["ttl_hours"])
if ttl <= timedelta(0) or ttl > sessions.MAX_TTL:
raise CommandError(
f"--ttl-hours must be above 0 and at most {sessions.MAX_TTL.days * 24}."
)
if options["storage_name"] is not None:
try:
manifests.storage_key(options["storage_name"])
except ValueError as error:
raise CommandError(str(error)) from error
manifest = sessions.build_manifest(
options["count"],
heaviest=options["heaviest"],
documents_per_user=options["documents_per_user"],
nb_public_documents=options["public_documents"],
ttl=ttl,
)
try:
if options["storage_name"] is None:
manifests.write_file(options["output"], manifest, options["force"])
destination = options["output"]
else:
destination = manifests.write_storage(
options["storage_name"], manifest, options["force"]
)
except FileExistsError as error:
# the sessions exist and nobody holds their keys: take them back
sessions.revoke_all()
raise CommandError(
f"{error.filename or error} already exists, use --force to replace "
"it. The sessions that were just minted have been revoked."
) from error
minted = len(manifest["sessions"])
self.stdout.write(
f"{minted} session(s) minted, valid until {manifest['expires_at']}. "
f"Manifest written to {destination}."
)
if minted < options["count"]:
self.stdout.write(
self.style.WARNING(
f"Only {minted} of the {options['count']} requested users are "
"active, not staff, and hold an access to a live document."
)
)
@@ -0,0 +1,43 @@
"""revoke_load_test_sessions — delete every session `create_load_test_sessions` minted.
The keys are read from the index kept next to the sessions, so the manifest is
not needed. Pass `--storage-name` to delete a manifest stored in the object
storage along the way.
python manage.py revoke_load_test_sessions
python manage.py revoke_load_test_sessions --storage-name campaign-1.json
"""
from django.core.management.base import BaseCommand, CommandError
from loadtest import manifests, sessions
class Command(BaseCommand):
"""Revoke the sessions of a load test."""
help = __doc__
def add_arguments(self, parser):
"""Define command arguments."""
parser.add_argument(
"--storage-name",
help="Also delete this manifest from the default storage.",
)
def handle(self, *args, **options):
"""Revoke, and report counts."""
try:
revoked = sessions.revoke_all()
except sessions.LoadTestToolsDisabled as error:
raise CommandError(str(error)) from error
self.stdout.write(f"{revoked} session(s) revoked.")
if options["storage_name"] is not None:
try:
deleted = manifests.delete_storage(options["storage_name"])
except ValueError as error:
raise CommandError(str(error)) from error
self.stdout.write(
"Manifest deleted." if deleted else "No such manifest in the storage."
)
+57
View File
@@ -0,0 +1,57 @@
"""Where the manifest of a load test is written: a private file, or a private object.
The manifest holds live session keys. It is never printed, and never written
where the application serves files from: the media route only answers for
`{document id}/attachments/…` keys (`core.enums.MEDIA_STORAGE_URL_PATTERN`), so
nothing under `loadtest/` can be fetched through it.
"""
import json
import os
import re
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
STORAGE_PREFIX = "loadtest/"
STORAGE_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
def storage_key(name):
"""The object key of a manifest, always under the load-test prefix."""
if not STORAGE_NAME.match(name):
raise ValueError(
"The name of a stored manifest may only hold letters, digits, dots, "
"dashes and underscores."
)
return f"{STORAGE_PREFIX}{name}"
def write_file(path, manifest, overwrite=False):
"""Write the manifest to a file only its owner can read."""
flags = os.O_WRONLY | os.O_CREAT | (os.O_TRUNC if overwrite else os.O_EXCL)
# the mode only applies to a file that is created: tighten an existing one too
descriptor = os.open(path, flags, 0o600)
with os.fdopen(descriptor, "w", encoding="utf-8") as manifest_file:
os.fchmod(manifest_file.fileno(), 0o600)
json.dump(manifest, manifest_file)
def write_storage(name, manifest, overwrite=False):
"""Write the manifest to the object storage, and return its key."""
key = storage_key(name)
if default_storage.exists(key):
if not overwrite:
raise FileExistsError(key)
default_storage.delete(key)
default_storage.save(key, ContentFile(json.dumps(manifest).encode("utf-8")))
return key
def delete_storage(name):
"""Delete a stored manifest. Returns whether there was one."""
key = storage_key(name)
if not default_storage.exists(key):
return False
default_storage.delete(key)
return True
+198
View File
@@ -0,0 +1,198 @@
"""Mint and revoke the sessions of the synthetic users of a load test.
Docs only authenticates through a session, opened by an OIDC login that a load
generator cannot go through for thousands of users. The sessions are therefore
written straight to the session store, for existing (anonymised) users, exactly
as `django.contrib.auth.login` would have written them. The collaboration server
forwards the same cookie to the backend, so one session covers the API, the
websocket and its http fallback.
Everything here refuses to run unless LOAD_TEST_TOOLS_ENABLED is set, whoever
the caller is.
"""
from datetime import timedelta
from importlib import import_module
from django.conf import settings
from django.contrib.auth import BACKEND_SESSION_KEY, HASH_SESSION_KEY, SESSION_KEY
from django.core.cache import caches
from django.db.models import Count, Exists, OuterRef
from django.utils import timezone
from core import models
from core.choices import LinkReachChoices, RoleChoices
# The backend a real login records in the session. It has to be one of
# AUTHENTICATION_BACKENDS, or Django discards the session as it reads it.
AUTHENTICATION_BACKEND = "core.authentication.backends.OIDCAuthenticationBackend"
# Marks a session as minted here, for whoever inspects the session store.
SESSION_MARKER = "load_test"
# Where the keys of the minted sessions are remembered, next to the sessions
# themselves, so that they can all be revoked without the manifest at hand.
INDEX_CACHE_KEY = "loadtest:session-keys"
EDITING_ROLES = [RoleChoices.EDITOR, RoleChoices.ADMIN, RoleChoices.OWNER]
MAX_TTL = timedelta(days=7)
class LoadTestToolsDisabled(RuntimeError):
"""Raised when the tooling is used where it is not enabled."""
def ensure_enabled():
"""Refuse to go any further unless this environment is a load-test one."""
if not settings.LOAD_TEST_TOOLS_ENABLED:
raise LoadTestToolsDisabled(
"LOAD_TEST_TOOLS_ENABLED is not set: sessions can only be minted with "
"the `LoadTest` configuration."
)
if settings.ENVIRONMENT == "production":
raise LoadTestToolsDisabled(
"Sessions are never minted with the `Production` configuration."
)
def _session_store():
return import_module(settings.SESSION_ENGINE).SessionStore
def _index_cache():
return caches[settings.SESSION_CACHE_ALIAS]
def select_users(count, heaviest=0):
"""
Pick the users to log in: `heaviest` users holding the most accesses, then
a random draw among the others, `count` in total.
Only active users with at least one access to a document that is not deleted:
a virtual user with nothing to open is of no use. Staff and superusers are
never picked — a minted session of theirs would open the admin.
"""
live_accesses = models.DocumentAccess.objects.filter(
user=OuterRef("pk"), document__ancestors_deleted_at__isnull=True
)
candidates = models.User.objects.filter(
Exists(live_accesses), is_active=True, is_staff=False, is_superuser=False
)
users = []
if heaviest > 0:
users = list(
candidates.annotate(nb_accesses=Count("documentaccess")).order_by(
"-nb_accesses", "pk"
)[: min(heaviest, count)]
)
remaining = count - len(users)
if remaining > 0:
users += list(
candidates.exclude(pk__in=[user.pk for user in users]).order_by("?")[
:remaining
]
)
return users
def documents_of(user, limit):
"""
The documents a user was given access to, most recently updated first, split
by what they may do with them: `(editable ids, read-only ids)`.
"""
accesses = (
models.DocumentAccess.objects.filter(
user=user, document__ancestors_deleted_at__isnull=True
)
.order_by("-document__updated_at")
.values_list("document_id", "role")
)
editable, readonly = [], []
for document_id, role in accesses.iterator():
target = editable if role in EDITING_ROLES else readonly
if len(target) < limit:
target.append(str(document_id))
if len(editable) >= limit and len(readonly) >= limit:
break
return editable, readonly
def public_documents(limit):
"""A random draw of documents anybody can open, shared by every virtual user."""
if limit <= 0:
return []
return [
str(document_id)
for document_id in models.Document.objects.filter(
link_reach=LinkReachChoices.PUBLIC, ancestors_deleted_at__isnull=True
)
.order_by("?")
.values_list("pk", flat=True)[:limit]
]
def mint_session(user, ttl):
"""Write the session a login of `user` would have opened, and return its key."""
ensure_enabled()
session = _session_store()()
session[SESSION_KEY] = str(user.pk)
session[BACKEND_SESSION_KEY] = AUTHENTICATION_BACKEND
session[HASH_SESSION_KEY] = user.get_session_auth_hash()
session[SESSION_MARKER] = True
session.set_expiry(int(ttl.total_seconds()))
session.create()
return session.session_key
def remember(session_keys, ttl):
"""Add the keys to the index the revocation reads."""
cache = _index_cache()
known = set(cache.get(INDEX_CACHE_KEY) or [])
known.update(session_keys)
# kept a little longer than the sessions: an index that expires first would
# leave sessions nothing can find anymore
cache.set(INDEX_CACHE_KEY, sorted(known), int(ttl.total_seconds()) + 3600)
def build_manifest(count, *, heaviest, documents_per_user, nb_public_documents, ttl):
"""
Mint one session per selected user and describe them for a load generator.
The manifest holds live session keys: it is a secret, to be written where
only the load generator reads it and never to a log.
"""
ensure_enabled()
if ttl > MAX_TTL:
raise ValueError(f"A load-test session cannot outlive {MAX_TTL.days} days.")
now = timezone.now()
sessions = []
for user in select_users(count, heaviest):
editable, readonly = documents_of(user, documents_per_user)
sessions.append(
{
"user_id": str(user.pk),
"session_key": mint_session(user, ttl),
"editable_documents": editable,
"readonly_documents": readonly,
}
)
remember([session["session_key"] for session in sessions], ttl)
return {
"created_at": now.isoformat(),
"expires_at": (now + ttl).isoformat(),
"cookie_name": settings.SESSION_COOKIE_NAME,
"public_documents": public_documents(nb_public_documents),
"sessions": sessions,
}
def revoke_all():
"""Delete every session minted here that is still known, and return how many."""
ensure_enabled()
cache = _index_cache()
session_keys = cache.get(INDEX_CACHE_KEY) or []
store = _session_store()
for session_key in session_keys:
store(session_key=session_key).delete()
cache.delete(INDEX_CACHE_KEY)
return len(session_keys)
+18
View File
@@ -0,0 +1,18 @@
"""Fixtures for the tests of the load-test tooling."""
from django.core.cache import cache
import pytest
@pytest.fixture(autouse=True)
def clear_cache():
"""The sessions and their index live in the cache: start from an empty one."""
cache.clear()
@pytest.fixture(name="load_test_enabled")
def load_test_enabled_fixture(settings):
"""What the `LoadTest` configuration sets."""
settings.LOAD_TEST_TOOLS_ENABLED = True
return settings
@@ -0,0 +1,263 @@
"""Test the `create_load_test_sessions` and `revoke_load_test_sessions` commands."""
import json
import stat
from io import StringIO
from django.core.cache import cache
from django.core.files.storage import default_storage
from django.core.management import CommandError, call_command
from django.utils import timezone
import pytest
from rest_framework.test import APIClient
from core import factories, models
from loadtest import sessions
from loadtest.management.commands import (
create_load_test_sessions,
revoke_load_test_sessions,
)
pytestmark = pytest.mark.django_db
def create(*args, **kwargs):
"""
Run the creation command. The application is not installed by the `Test`
configuration, so the command is handed over rather than looked up by name.
"""
stdout = StringIO()
call_command(create_load_test_sessions.Command(), *args, stdout=stdout, **kwargs)
return stdout.getvalue()
def revoke(*args, **kwargs):
"""Run the revocation command."""
stdout = StringIO()
call_command(revoke_load_test_sessions.Command(), *args, stdout=stdout, **kwargs)
return stdout.getvalue()
def who_am_i(session_key, settings):
"""Call the API with a session cookie, as a load generator would."""
client = APIClient()
client.cookies[settings.SESSION_COOKIE_NAME] = session_key
return client.get("/api/v1.0/users/me/")
def test_commands_create_sessions_refused_when_disabled(settings, tmp_path):
"""Nothing should be minted where the tooling is not enabled."""
settings.LOAD_TEST_TOOLS_ENABLED = False
factories.UserDocumentAccessFactory()
output = tmp_path / "manifest.json"
with pytest.raises(CommandError, match="LOAD_TEST_TOOLS_ENABLED is not set"):
create("1", output=str(output))
assert not output.exists()
assert cache.get(sessions.INDEX_CACHE_KEY) is None
def test_commands_create_sessions_refused_in_production(load_test_enabled, tmp_path):
"""Not even with the setting on, were it ever forced on a production."""
load_test_enabled.ENVIRONMENT = "production"
factories.UserDocumentAccessFactory()
with pytest.raises(CommandError, match="never minted with the `Production`"):
create("1", output=str(tmp_path / "manifest.json"))
def test_commands_revoke_sessions_refused_when_disabled(settings):
"""The revocation is part of the same tooling."""
settings.LOAD_TEST_TOOLS_ENABLED = False
with pytest.raises(CommandError, match="LOAD_TEST_TOOLS_ENABLED is not set"):
revoke()
def test_commands_create_sessions_logs_users_in(load_test_enabled, tmp_path):
"""A minted session should be accepted by the API as a login of its user."""
accesses = factories.UserDocumentAccessFactory.create_batch(3)
output = tmp_path / "manifest.json"
stdout = create("3", output=str(output))
manifest = json.loads(output.read_text())
assert manifest["cookie_name"] == load_test_enabled.SESSION_COOKIE_NAME
assert {session["user_id"] for session in manifest["sessions"]} == {
str(access.user_id) for access in accesses
}
for session in manifest["sessions"]:
response = who_am_i(session["session_key"], load_test_enabled)
assert response.status_code == 200
assert response.json()["id"] == session["user_id"]
# the keys are a secret: counted in the output, never shown
assert "3 session(s) minted" in stdout
for session in manifest["sessions"]:
assert session["session_key"] not in stdout
@pytest.mark.usefixtures("load_test_enabled")
def test_commands_create_sessions_manifest_is_private(tmp_path):
"""The manifest should be readable by its owner alone, and never replaced silently."""
factories.UserDocumentAccessFactory()
output = tmp_path / "manifest.json"
create("1", output=str(output))
assert stat.S_IMODE(output.stat().st_mode) == 0o600
first = json.loads(output.read_text())["sessions"][0]["session_key"]
with pytest.raises(CommandError, match="already exists"):
create("1", output=str(output))
# the manifest was left alone, and the sessions nobody holds the keys of are gone
assert json.loads(output.read_text())["sessions"][0]["session_key"] == first
assert cache.get(sessions.INDEX_CACHE_KEY) is None
output.chmod(0o644)
create("1", output=str(output), force=True)
assert stat.S_IMODE(output.stat().st_mode) == 0o600
assert json.loads(output.read_text())["sessions"][0]["session_key"] != first
@pytest.mark.usefixtures("load_test_enabled")
def test_commands_create_sessions_selects_usable_users_only(tmp_path):
"""Staff, superusers, inactive users and users with nothing to open are left out."""
expected = factories.UserDocumentAccessFactory().user
factories.UserDocumentAccessFactory(user__is_staff=True)
factories.UserDocumentAccessFactory(user__is_superuser=True)
factories.UserDocumentAccessFactory(user__is_active=False)
factories.UserFactory()
deleted = factories.UserDocumentAccessFactory()
now = timezone.now()
models.Document.objects.filter(pk=deleted.document_id).update(
deleted_at=now, ancestors_deleted_at=now
)
output = tmp_path / "manifest.json"
stdout = create("10", output=str(output))
manifest = json.loads(output.read_text())
assert [session["user_id"] for session in manifest["sessions"]] == [
str(expected.pk)
]
assert "Only 1 of the 10 requested users" in stdout
@pytest.mark.usefixtures("load_test_enabled")
def test_commands_create_sessions_heaviest_users_first(tmp_path):
"""`--heaviest` should pick the users holding the most accesses."""
light = factories.UserFactory()
heavy = factories.UserFactory()
factories.UserDocumentAccessFactory(user=light)
factories.UserDocumentAccessFactory.create_batch(4, user=heavy)
output = tmp_path / "manifest.json"
create("1", heaviest=1, output=str(output))
manifest = json.loads(output.read_text())
assert [session["user_id"] for session in manifest["sessions"]] == [str(heavy.pk)]
@pytest.mark.usefixtures("load_test_enabled")
def test_commands_create_sessions_lists_documents_by_ability(tmp_path):
"""The documents of a user are split by what they may do, and capped."""
user = factories.UserFactory()
# the factory draws a link reach at random: keep these out of the public ones
editable = [
factories.UserDocumentAccessFactory(
user=user, role=role, document__link_reach="restricted"
).document
for role in ["editor", "administrator", "owner"]
]
readonly = [
factories.UserDocumentAccessFactory(
user=user, role=role, document__link_reach="restricted"
).document
for role in ["reader", "commenter"]
]
public = factories.DocumentFactory(link_reach="public")
factories.DocumentFactory(link_reach="restricted")
output = tmp_path / "manifest.json"
create("1", output=str(output))
session = json.loads(output.read_text())["sessions"][0]
assert set(session["editable_documents"]) == {str(doc.pk) for doc in editable}
assert set(session["readonly_documents"]) == {str(doc.pk) for doc in readonly}
assert json.loads(output.read_text())["public_documents"] == [str(public.pk)]
create(
"1", documents_per_user=1, public_documents=0, output=str(output), force=True
)
manifest = json.loads(output.read_text())
assert len(manifest["sessions"][0]["editable_documents"]) == 1
assert len(manifest["sessions"][0]["readonly_documents"]) == 1
assert manifest["public_documents"] == []
@pytest.mark.usefixtures("load_test_enabled")
@pytest.mark.parametrize("ttl_hours", [0, -1, 169])
def test_commands_create_sessions_ttl_is_bounded(tmp_path, ttl_hours):
"""A session of a load test should not outlive the campaign by much."""
factories.UserDocumentAccessFactory()
with pytest.raises(CommandError, match="--ttl-hours"):
create("1", ttl_hours=ttl_hours, output=str(tmp_path / "manifest.json"))
assert cache.get(sessions.INDEX_CACHE_KEY) is None
def test_commands_revoke_sessions(load_test_enabled, tmp_path):
"""Every minted session should stop working, across runs, without the manifest."""
factories.UserDocumentAccessFactory.create_batch(2)
first, second = tmp_path / "first.json", tmp_path / "second.json"
create("1", output=str(first))
create("2", output=str(second))
keys = [
session["session_key"]
for manifest in (first, second)
for session in json.loads(manifest.read_text())["sessions"]
]
assert len(keys) == 3
assert all(who_am_i(key, load_test_enabled).status_code == 200 for key in keys)
assert "3 session(s) revoked" in revoke()
assert all(who_am_i(key, load_test_enabled).status_code == 401 for key in keys)
assert "0 session(s) revoked" in revoke()
@pytest.mark.usefixtures("load_test_enabled")
def test_commands_create_sessions_to_storage():
"""A stored manifest goes under the load-test prefix, and is deleted on request."""
factories.UserDocumentAccessFactory()
stdout = create("1", storage_name="campaign.json")
assert "loadtest/campaign.json" in stdout
with default_storage.open("loadtest/campaign.json") as stored:
manifest = json.loads(stored.read())
assert len(manifest["sessions"]) == 1
with pytest.raises(CommandError, match="already exists"):
create("1", storage_name="campaign.json")
assert "Manifest deleted." in revoke(storage_name="campaign.json")
assert not default_storage.exists("loadtest/campaign.json")
assert "No such manifest" in revoke(storage_name="campaign.json")
@pytest.mark.usefixtures("load_test_enabled")
@pytest.mark.parametrize(
"name", ["../escape.json", "a/b.json", "/etc/passwd", ".hidden", ""]
)
def test_commands_create_sessions_storage_name_cannot_leave_the_prefix(name):
"""The name of a stored manifest is a name, not a path."""
factories.UserDocumentAccessFactory()
with pytest.raises(CommandError):
create("1", storage_name=name)
assert cache.get(sessions.INDEX_CACHE_KEY) is None
@@ -0,0 +1,92 @@
"""The load-test tooling must only ever be reachable with the `LoadTest` configuration."""
import inspect
from django.core.exceptions import ImproperlyConfigured
import pytest
from configurations import Configuration
from impress import settings as project_settings
from impress.settings import Base, LoadTest, Production
from loadtest.apps import LoadTestConfig
CONFIGURATIONS = [
configuration
for _, configuration in inspect.getmembers(project_settings, inspect.isclass)
if issubclass(configuration, Configuration) and configuration is not Configuration
]
def test_loadtest_settings_every_configuration_is_checked():
"""Guard the two tests below against a list that would silently be empty."""
names = {configuration.__name__ for configuration in CONFIGURATIONS}
assert {
"Base",
"Development",
"Test",
"Production",
"PreProduction",
"LoadTest",
} <= (names)
@pytest.mark.parametrize(
"configuration",
[
configuration
for configuration in CONFIGURATIONS
if configuration is not LoadTest
],
ids=lambda configuration: configuration.__name__,
)
def test_loadtest_settings_disabled_everywhere_else(configuration):
"""No other configuration installs the application or enables its setting."""
assert configuration.LOAD_TEST_TOOLS_ENABLED is False
assert "loadtest" not in configuration.INSTALLED_APPS
def test_loadtest_settings_production_pins_it_off():
"""Production says so itself, rather than inheriting whatever `Base` says."""
assert vars(Production)["LOAD_TEST_TOOLS_ENABLED"] is False
def test_loadtest_settings_not_read_from_the_environment(monkeypatch):
"""No environment variable should be able to turn it on."""
monkeypatch.setenv("LOAD_TEST_TOOLS_ENABLED", "True")
monkeypatch.setenv("DJANGO_LOAD_TEST_TOOLS_ENABLED", "True")
class TestSettings(Production):
"""A production configuration set up with the variables above."""
assert TestSettings.LOAD_TEST_TOOLS_ENABLED is False
assert Base.LOAD_TEST_TOOLS_ENABLED is False
def test_loadtest_settings_load_test_configuration():
"""`LoadTest` is production plus the application, on a list of its own."""
assert issubclass(LoadTest, Production)
assert LoadTest.LOAD_TEST_TOOLS_ENABLED is True
# A copy taken when the class is defined. Not compared for equality: the
# `Test` and `Development` configurations append to the shared list of `Base`
# in place when they are instantiated, which a copy is precisely immune to.
assert LoadTest.INSTALLED_APPS is not Base.INSTALLED_APPS
assert LoadTest.INSTALLED_APPS[-1] == "loadtest"
assert LoadTest.INSTALLED_APPS.count("loadtest") == 1
assert set(LoadTest.INSTALLED_APPS[:-1]) <= set(Production.INSTALLED_APPS)
assert "core" in LoadTest.INSTALLED_APPS
def test_loadtest_app_refuses_to_load_when_disabled(settings):
"""Installing the application elsewhere should stop the process from starting."""
settings.LOAD_TEST_TOOLS_ENABLED = False
config = LoadTestConfig.create("loadtest")
with pytest.raises(ImproperlyConfigured, match="LOAD_TEST_TOOLS_ENABLED"):
config.ready()
@pytest.mark.usefixtures("load_test_enabled")
def test_loadtest_app_loads_when_enabled():
"""The application loads with the `LoadTest` configuration."""
LoadTestConfig.create("loadtest").ready()
+2 -1
View File
@@ -109,7 +109,8 @@ typeCheckingMode = "standard"
module-name = [
"core",
"demo",
"impress"
"impress",
"loadtest"
]
module-root = ""
source-exclude = [