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 = [