From 3cd85c95dc34337853b81bdb52e38ba4cb216fed Mon Sep 17 00:00:00 2001 From: Manuel Raynaud Date: Mon, 21 Sep 2026 11:49:54 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(backend)=20add=20a=20`LoadTest`=20con?= =?UTF-8?q?figuration=20and=20its=20`loadtest`=20application?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 2 + src/backend/impress/settings.py | 31 +++ src/backend/loadtest/__init__.py | 6 + src/backend/loadtest/apps.py | 27 ++ src/backend/loadtest/management/__init__.py | 0 .../loadtest/management/commands/__init__.py | 0 .../commands/create_load_test_sessions.py | 139 +++++++++ .../commands/revoke_load_test_sessions.py | 43 +++ src/backend/loadtest/manifests.py | 57 ++++ src/backend/loadtest/sessions.py | 198 +++++++++++++ src/backend/loadtest/tests/__init__.py | 0 src/backend/loadtest/tests/conftest.py | 18 ++ .../loadtest/tests/test_commands_sessions.py | 263 ++++++++++++++++++ src/backend/loadtest/tests/test_settings.py | 92 ++++++ src/backend/pyproject.toml | 3 +- 15 files changed, 878 insertions(+), 1 deletion(-) create mode 100644 src/backend/loadtest/__init__.py create mode 100644 src/backend/loadtest/apps.py create mode 100644 src/backend/loadtest/management/__init__.py create mode 100644 src/backend/loadtest/management/commands/__init__.py create mode 100644 src/backend/loadtest/management/commands/create_load_test_sessions.py create mode 100644 src/backend/loadtest/management/commands/revoke_load_test_sessions.py create mode 100644 src/backend/loadtest/manifests.py create mode 100644 src/backend/loadtest/sessions.py create mode 100644 src/backend/loadtest/tests/__init__.py create mode 100644 src/backend/loadtest/tests/conftest.py create mode 100644 src/backend/loadtest/tests/test_commands_sessions.py create mode 100644 src/backend/loadtest/tests/test_settings.py diff --git a/CHANGELOG.md b/CHANGELOG.md index df8433a9e..5592faf06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/backend/impress/settings.py b/src/backend/impress/settings.py index 9ca7da6ef..aca3cf286 100755 --- a/src/backend/impress/settings.py +++ b/src/backend/impress/settings.py @@ -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 diff --git a/src/backend/loadtest/__init__.py b/src/backend/loadtest/__init__.py new file mode 100644 index 000000000..d15f6557b --- /dev/null +++ b/src/backend/loadtest/__init__.py @@ -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`. +""" diff --git a/src/backend/loadtest/apps.py b/src/backend/loadtest/apps.py new file mode 100644 index 000000000..0118ad3a7 --- /dev/null +++ b/src/backend/loadtest/apps.py @@ -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." + ) diff --git a/src/backend/loadtest/management/__init__.py b/src/backend/loadtest/management/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/backend/loadtest/management/commands/__init__.py b/src/backend/loadtest/management/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/backend/loadtest/management/commands/create_load_test_sessions.py b/src/backend/loadtest/management/commands/create_load_test_sessions.py new file mode 100644 index 000000000..a77de7977 --- /dev/null +++ b/src/backend/loadtest/management/commands/create_load_test_sessions.py @@ -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": ["", …], + "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." + ) + ) diff --git a/src/backend/loadtest/management/commands/revoke_load_test_sessions.py b/src/backend/loadtest/management/commands/revoke_load_test_sessions.py new file mode 100644 index 000000000..f43dcbeed --- /dev/null +++ b/src/backend/loadtest/management/commands/revoke_load_test_sessions.py @@ -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." + ) diff --git a/src/backend/loadtest/manifests.py b/src/backend/loadtest/manifests.py new file mode 100644 index 000000000..7f3e3da04 --- /dev/null +++ b/src/backend/loadtest/manifests.py @@ -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 diff --git a/src/backend/loadtest/sessions.py b/src/backend/loadtest/sessions.py new file mode 100644 index 000000000..2fef973c0 --- /dev/null +++ b/src/backend/loadtest/sessions.py @@ -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) diff --git a/src/backend/loadtest/tests/__init__.py b/src/backend/loadtest/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/backend/loadtest/tests/conftest.py b/src/backend/loadtest/tests/conftest.py new file mode 100644 index 000000000..5f692fee7 --- /dev/null +++ b/src/backend/loadtest/tests/conftest.py @@ -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 diff --git a/src/backend/loadtest/tests/test_commands_sessions.py b/src/backend/loadtest/tests/test_commands_sessions.py new file mode 100644 index 000000000..8c1c55e6a --- /dev/null +++ b/src/backend/loadtest/tests/test_commands_sessions.py @@ -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 diff --git a/src/backend/loadtest/tests/test_settings.py b/src/backend/loadtest/tests/test_settings.py new file mode 100644 index 000000000..e8500f104 --- /dev/null +++ b/src/backend/loadtest/tests/test_settings.py @@ -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() diff --git a/src/backend/pyproject.toml b/src/backend/pyproject.toml index b45bc06d7..d3526cb41 100644 --- a/src/backend/pyproject.toml +++ b/src/backend/pyproject.toml @@ -109,7 +109,8 @@ typeCheckingMode = "standard" module-name = [ "core", "demo", - "impress" + "impress", + "loadtest" ] module-root = "" source-exclude = [