diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 820887a07e6..ffddad2c4c8 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -21,14 +21,6 @@ updates: - "rjsparks" - package-ecosystem: "npm" directory: "/" - schedule: - interval: "weekly" - groups: - yarn: - patterns: - - "*" - - package-ecosystem: "npm" - directory: "/playwright" schedule: interval: "weekly" groups: @@ -36,7 +28,7 @@ updates: patterns: - "*" - package-ecosystem: "npm" - directory: "/dev/coverage-action" + directory: "/playwright" schedule: interval: "weekly" groups: diff --git a/.github/workflows/reusable-build.yml b/.github/workflows/reusable-build.yml index 361174ea259..ca38345beee 100644 --- a/.github/workflows/reusable-build.yml +++ b/.github/workflows/reusable-build.yml @@ -49,7 +49,7 @@ jobs: fetch-tags: false - name: Setup Node.js environment - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: 18.x @@ -81,7 +81,7 @@ jobs: echo "Installing node packages..." npm ci echo "Packaging static assets..." - npm run build --base=https://static.ietf.org/dt/${{ inputs.buildVersion }}/ + npm run build -- --base=https://static.ietf.org/dt/${{ inputs.buildVersion }}/ npm run legacy:build echo "Setting version ${{ inputs.buildVersion }}..." sed -i -r -e "s|^__version__ += '.*'$|__version__ = '${{ inputs.buildVersion }}'|" ietf/__init__.py @@ -93,7 +93,7 @@ jobs: run: | echo "Setting production flags in settings.py..." sed -i -r -e 's/^DEBUG *= *.*$/DEBUG = False/' -e "s/^SERVER_MODE *= *.*\$/SERVER_MODE = 'production'/" ietf/settings.py - + - name: Make Tarball env: DEBIAN_FRONTEND: noninteractive @@ -158,7 +158,7 @@ jobs: ghcr.io/ietf-tools/datatracker:${{ inputs.buildVersion }} ${{ env.FEATURE_LATEST_TAG && format('ghcr.io/ietf-tools/datatracker:{0}-latest', env.FEATURE_LATEST_TAG) || null }} - - name: Update CHANGELOG + - name: Generate CHANGELOG id: changelog uses: Requarks/changelog-action@v1 if: ${{ inputs.isReleaseBuild }} @@ -170,29 +170,10 @@ jobs: - name: Download Coverage Results if: ${{ inputs.handleCoverageResults }} - uses: actions/download-artifact@v8.0.1 + uses: actions/download-artifact@v8 with: name: coverage - - name: Prepare Coverage Action - if: ${{ inputs.handleCoverageResults }} - working-directory: ./dev/coverage-action - run: npm install - - - name: Process Coverage Stats + Chart - id: covprocess - uses: ./dev/coverage-action/ - if: ${{ inputs.handleCoverageResults }} - with: - token: ${{ github.token }} - tokenCommon: ${{ secrets.GH_COMMON_TOKEN }} - repoCommon: common - version: ${{ inputs.buildVersion }} - changelog: ${{ steps.changelog.outputs.changes }} - summary: '' - coverageResultsPath: coverage.json - histCoveragePath: historical-coverage.json - - name: Create Release uses: ncipollo/release-action@v1.21.0 if: ${{ inputs.isReleaseBuild }} @@ -202,8 +183,8 @@ jobs: draft: false tag: ${{ inputs.buildVersion }} name: ${{ inputs.buildVersion }} - body: ${{ steps.covprocess.outputs.changelog }} - artifacts: "/home/runner/work/release/release.tar.gz,coverage.json,historical-coverage.json" + body: ${{ steps.changelog.outputs.changes }} + artifacts: "/home/runner/work/release/release.tar.gz,coverage.json" token: ${{ secrets.GITHUB_TOKEN }} - name: Update Baseline Coverage @@ -218,7 +199,7 @@ jobs: replacesArtifacts: true artifacts: "coverage.json" token: ${{ secrets.GITHUB_TOKEN }} - + - name: Upload Build Artifacts uses: actions/upload-artifact@v7 with: diff --git a/.github/workflows/reusable-tests.yml b/.github/workflows/reusable-tests.yml index d60dc9857ce..90d09970acb 100644 --- a/.github/workflows/reusable-tests.yml +++ b/.github/workflows/reusable-tests.yml @@ -104,7 +104,7 @@ jobs: steps: - uses: actions/checkout@v7 - - uses: actions/setup-node@v6 + - uses: actions/setup-node@v7 with: node-version: '26' diff --git a/README.md b/README.md index 7e2ee131f02..48dfdae27e4 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@
-IETF Datatracker +Datatracker + +# Datatracker [![Release](https://img.shields.io/github/release/ietf-tools/datatracker.svg?style=flat&maxAge=300)](https://github.com/ietf-tools/datatracker/releases) [![License](https://img.shields.io/github/license/ietf-tools/datatracker)](https://github.com/ietf-tools/datatracker/blob/main/LICENSE) diff --git a/dev/coverage-action/package-lock.json b/dev/coverage-action/package-lock.json index 1febf8d88b5..a63148186fe 100644 --- a/dev/coverage-action/package-lock.json +++ b/dev/coverage-action/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@actions/core": "3.0.1", "@actions/github": "9.1.1", - "es-toolkit": "1.49.0" + "es-toolkit": "1.50.0" } }, "node_modules/@actions/core": { @@ -222,14 +222,9 @@ } }, "node_modules/es-toolkit": { - "version": "1.49.0", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.49.0.tgz", - "integrity": "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==", - "license": "MIT", - "workspaces": [ - "docs", - "benchmarks" - ] + "version": "1.50.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.50.0.tgz", + "integrity": "sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w==" }, "node_modules/json-with-bigint": { "version": "3.5.8", @@ -418,9 +413,9 @@ "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==" }, "es-toolkit": { - "version": "1.49.0", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.49.0.tgz", - "integrity": "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==" + "version": "1.50.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.50.0.tgz", + "integrity": "sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w==" }, "json-with-bigint": { "version": "3.5.8", diff --git a/dev/coverage-action/package.json b/dev/coverage-action/package.json index f51a78fd879..4607922d81a 100644 --- a/dev/coverage-action/package.json +++ b/dev/coverage-action/package.json @@ -9,6 +9,6 @@ "dependencies": { "@actions/core": "3.0.1", "@actions/github": "9.1.1", - "es-toolkit": "1.49.0" + "es-toolkit": "1.50.0" } } diff --git a/dev/tests/prepare.sh b/dev/tests/prepare.sh index dd8e3d6b4d5..47917e45449 100644 --- a/dev/tests/prepare.sh +++ b/dev/tests/prepare.sh @@ -9,10 +9,10 @@ cp ./dev/tests/settings_local.py ./ietf/settings_local.py echo "Ensure all requirements.txt packages are installed..." pip --disable-pip-version-check --no-cache-dir install -r requirements.txt echo "Compiling native node packages..." -yarn rebuild +npm ci echo "Building static assets..." -yarn build -yarn legacy:build +npm run build +npm run legacy:build echo "Creating data directories..." chmod +x ./docker/scripts/app-create-dirs.sh ./docker/scripts/app-create-dirs.sh diff --git a/ietf/api/__init__.py b/ietf/api/__init__.py index f4bfe8330ee..30254221d12 100644 --- a/ietf/api/__init__.py +++ b/ietf/api/__init__.py @@ -1,28 +1,26 @@ # Copyright The IETF Trust 2014-2020, All Rights Reserved -# -*- coding: utf-8 -*- import datetime import re import sys - from urllib.parse import urlencode +import tastypie.resources +import tastypie.serializers from django.apps import apps as django_apps from django.core.exceptions import ObjectDoesNotExist +from django.db import DataError, transaction from django.http import HttpResponseNotAllowed from django.utils.module_loading import autodiscover_modules - - -import debug # pyflakes:ignore - -import tastypie.resources -import tastypie.serializers from tastypie.api import Api from tastypie.bundle import Bundle -from tastypie.exceptions import ApiFieldError +from tastypie.exceptions import ApiFieldError, BadRequest, InvalidFilterError from tastypie.fields import ApiField +import debug # noqa: F401 (pyflakes:ignore) +from ietf.utils.log import log + _api_list = [] OMITTED_APPS_APIS = ["ietf.status"] @@ -30,16 +28,17 @@ # Pre-py3.11, fromisoformat() does not handle Z or +HH tz offsets HAVE_BROKEN_FROMISOFORMAT = sys.version_info < (3, 11, 0, "", 0) + def populate_api_list(): _module_dict = globals() for app_config in django_apps.get_app_configs(): - if '.' in app_config.name and app_config.name not in OMITTED_APPS_APIS: - _root, _name = app_config.name.split('.', 1) - if _root == 'ietf': - if not '.' in _name: - _api = Api(api_name=_name) - _module_dict[_name] = _api - _api_list.append((_name, _api)) + if "." in app_config.name and app_config.name not in OMITTED_APPS_APIS: + _root, _name = app_config.name.split(".", 1) + if _root == "ietf" and "." not in _name: + _api = Api(api_name=_name) + _module_dict[_name] = _api + _api_list.append((_name, _api)) + def autodiscover(): """ @@ -51,6 +50,40 @@ def autodiscover(): class ModelResource(tastypie.resources.ModelResource): + def dispatch(self, request_type, request, **kwargs): + """Turn a database error caused by request data into a bad request + + Filter values reach the database with very little validation, and some of + them only fail once the query actually runs - below tastypie, and long + after build_filters() had any chance to reject them. Left alone those + surface as unhandled exceptions. + + Only DataError is treated this way: it is the DBAPI error for a problem + with the data in the query, so it is the client's to fix. The other + DatabaseError subclasses (OperationalError, ProgrammingError, + InternalError) indicate a broken database or a bug of ours, and are left + alone so they still raise and report. + + The database's message is logged rather than returned - it can quote the + offending value, and this response body is not escaped. + """ + try: + return super().dispatch(request_type, request, **kwargs) + except DataError as err: + # The failed statement has aborted the transaction if there is one, so + # nothing more can be done with the connection until it is rolled back. + # Requests normally run in autocommit, where there is no transaction to + # roll back and this is a no-op, but without it the guard would quietly + # stop working if ATOMIC_REQUESTS were ever turned on: the 400 would be + # built and then lost when the atomic block failed to commit. + if not transaction.get_autocommit(): + transaction.set_rollback(True) + log(f"DataError handling {request.method} {request.get_full_path()}: {err}") + raise BadRequest( + "The database could not process this request. This is usually a " + "malformed filter value." + ) + def post_detail(self, request, **kwargs): return HttpResponseNotAllowed(["GET"]) @@ -60,11 +93,11 @@ def generate_cache_key(self, *args, **kwargs): This is based off the current api_name/resource_name/args/kwargs. """ - #smooshed = ["%s=%s" % (key, value) for key, value in kwargs.items()] + # smooshed = ["%s=%s" % (key, value) for key, value in kwargs.items()] smooshed = urlencode(kwargs) # Use a list plus a ``.join()`` because it's faster than concatenation. - return "%s:%s:%s:%s" % (self._meta.api_name, self._meta.resource_name, ':'.join(args), smooshed) + return f"{self._meta.api_name}:{self._meta.resource_name}:{':'.join(args)}:{smooshed}" def _z_aware_fromisoformat(self, value: str) -> datetime.datetime: """datetime.datetime.fromisoformat replacement that works with python < 3.11""" @@ -78,9 +111,27 @@ def _z_aware_fromisoformat(self, value: str) -> datetime.datetime: def filter_value_to_python( self, value, field_name, filters, filter_expr, filter_type ): - py_value = super().filter_value_to_python( - value, field_name, filters, filter_expr, filter_type - ) + try: + py_value = super().filter_value_to_python( + value, field_name, filters, filter_expr, filter_type + ) + except TypeError: + # For "in" and "range" filters tastypie calls len() on the value, but + # string_to_python() has already mapped "true"/"false"/"nil"/"none" to a + # bool or None, which have no len(). + raise InvalidFilterError( + f"Invalid value for the '{filter_type}' filter on '{field_name}'" + ) + if filter_type == "range" and len(py_value) != 2: + # Django renders a range lookup as "BETWEEN %s AND %s" and indexes the + # value without checking its length, so anything other than exactly two + # values raises IndexError (or ValueError) when the query is compiled - + # long after this method has returned, where it can only become a 500. + # Reject it here, while it can still be reported as a bad request. + raise InvalidFilterError( + f"The '{filter_type}' filter on '{field_name}' requires exactly two " + f"comma-separated values" + ) if isinstance( self.fields[field_name], tastypie.fields.DateTimeField ) and isinstance(py_value, str): @@ -96,10 +147,13 @@ def filter_value_to_python( return py_value -TIMEDELTA_REGEX = re.compile(r'^(?P\d+d)?\s?(?P\d+h)?\s?(?P\d+m)?\s?(?P\d+s?)$') +TIMEDELTA_REGEX = re.compile( + r"^(?P\d+d)?\s?(?P\d+h)?\s?(?P\d+m)?\s?(?P\d+s?)$" +) + class TimedeltaField(ApiField): - dehydrated_type = 'timedelta' + dehydrated_type = "timedelta" help_text = "A timedelta field, with duration expressed in seconds. Ex: 132" def convert(self, value): @@ -111,33 +165,50 @@ def convert(self, value): if match: data = match.groupdict() - return datetime.timedelta(int(data['days']), int(data['hours']), int(data['minutes']), int(data['seconds'])) + return datetime.timedelta( + int(data["days"]), + int(data["hours"]), + int(data["minutes"]), + int(data["seconds"]), + ) else: - raise ApiFieldError("Timedelta provided to '%s' field doesn't appear to be a valid timedelta string: '%s'" % (self.instance_name, value)) + raise ApiFieldError( + f"Timedelta provided to '{self.instance_name}' field doesn't appear to be a valid timedelta string: '{value}'" + ) return value def hydrate(self, bundle): - value = super(TimedeltaField, self).hydrate(bundle) + value = super().hydrate(bundle) - if value and not hasattr(value, 'seconds'): + if value and not hasattr(value, "seconds"): if isinstance(value, str): try: match = TIMEDELTA_REGEX.search(value) if match: data = match.groupdict() - value = datetime.timedelta(int(data['days']), int(data['hours']), int(data['minutes']), int(data['seconds'])) + value = datetime.timedelta( + int(data["days"]), + int(data["hours"]), + int(data["minutes"]), + int(data["seconds"]), + ) else: raise ValueError() except (ValueError, TypeError): - raise ApiFieldError("Timedelta provided to '%s' field doesn't appear to be a valid datetime string: '%s'" % (self.instance_name, value)) + raise ApiFieldError( + f"Timedelta provided to '{self.instance_name}' field doesn't appear to be a valid datetime string: '{value}'" + ) else: - raise ApiFieldError("Datetime provided to '%s' field must be a string: %s" % (self.instance_name, value)) + raise ApiFieldError( + f"Datetime provided to '{self.instance_name}' field must be a string: {value}" + ) return value + class ToOneField(tastypie.fields.ToOneField): "Subclass of tastypie.fields.ToOneField which adds caching in the dehydrate method." @@ -145,7 +216,7 @@ def dehydrate(self, bundle, for_list=True): foreign_obj = None previous_obj = None attrib = None - + if callable(self.attribute): previous_obj = bundle.obj foreign_obj = self.attribute(bundle) @@ -163,25 +234,34 @@ def dehydrate(self, bundle, for_list=True): if not foreign_obj: if not self.null: if callable(self.attribute): - raise ApiFieldError("The related resource for resource %s could not be found." % (previous_obj)) + raise ApiFieldError( + f"The related resource for resource {previous_obj} could not be found." + ) else: - raise ApiFieldError("The model '%r' has an empty attribute '%s' and doesn't allow a null value." % (previous_obj, attrib)) + raise ApiFieldError( + f"The model '{previous_obj!r}' has an empty attribute '{attrib}' and doesn't allow a null value." + ) return None fk_resource = self.get_related_resource(foreign_obj) # Up to this point we've copied the code from tastypie 0.13.1. Now # we add caching. - cache_key = fk_resource.generate_cache_key('related', pk=foreign_obj.pk, for_list=for_list, ) + cache_key = fk_resource.generate_cache_key( + "related", + pk=foreign_obj.pk, + for_list=for_list, + ) dehydrated = fk_resource._meta.cache.get(cache_key) if dehydrated is None: fk_bundle = Bundle(obj=foreign_obj, request=bundle.request) - dehydrated = self.dehydrate_related(fk_bundle, fk_resource, for_list=for_list) + dehydrated = self.dehydrate_related( + fk_bundle, fk_resource, for_list=for_list + ) fk_resource._meta.cache.set(cache_key, dehydrated) return dehydrated - # XML 1.0 forbids all control characters except tab (#x9), LF (#xA), and CR (#xD). # Replace each with its Unicode control picture (U+2400 + codepoint) so the # substitution is lossless and the result is valid XML. @@ -192,12 +272,19 @@ class Serializer(tastypie.serializers.Serializer): OPTION_ESCAPE_XML_INVALID = "datatracker-escape-xml-invalid" def format_datetime(self, data): - return data.astimezone(datetime.UTC).replace(tzinfo=None).isoformat(timespec="seconds") + "Z" + return ( + data.astimezone(datetime.UTC) + .replace(tzinfo=None) + .isoformat(timespec="seconds") + + "Z" + ) def to_simple(self, data, options): options = options or {} simple_data = super().to_simple(data, options) - if options.get(self.OPTION_ESCAPE_XML_INVALID, False) and isinstance(simple_data, str): + if options.get(self.OPTION_ESCAPE_XML_INVALID, False) and isinstance( + simple_data, str + ): # Replace control chars invalid in XML 1.0 with their Unicode # control pictures (U+2400-U+241F) so lxml won't reject the string. simple_data = _XML_INVALID_CTRL_RE.sub( diff --git a/ietf/api/tests.py b/ietf/api/tests.py index 698bf4f04ce..f7fa1aff02c 100644 --- a/ietf/api/tests.py +++ b/ietf/api/tests.py @@ -12,7 +12,7 @@ from importlib import import_module from pathlib import Path from random import randrange -from urllib.parse import urljoin +from urllib.parse import quote, urlencode, urljoin from django.apps import apps from django.conf import settings @@ -30,7 +30,7 @@ from ietf.doc.storage_utils import retrieve_str from ietf.doc.utils import get_unicode_document_content from ietf.doc.models import RelatedDocument, State -from ietf.doc.factories import IndividualDraftFactory, WgDraftFactory, WgRfcFactory +from ietf.doc.factories import IndividualDraftFactory, WgDraftFactory, WgRfcFactory, RfcAuthorFactory, DocEventFactory from ietf.group.factories import RoleFactory from ietf.meeting.factories import MeetingFactory, SessionFactory from ietf.meeting.models import Session, Registration @@ -1071,6 +1071,145 @@ def test_role_holder_addresses(self): sorted(e.address for e in emails), ) + @override_settings( + APP_API_TOKENS={"ietf.api.views.rfc_authors": ["valid-token"]} + ) + def test_rfc_authors(self): + url = urlreverse("ietf.api.views.rfc_authors") + # auth and method checks + self.assertEqual( + self.client.get(url, headers={}).status_code, 403, "No api token, no access" + ) + self.assertEqual( + self.client.get(url, headers={"X-Api-Key": "not-valid-token"}).status_code, + 403, + "Bad api token, no access", + ) + self.assertEqual( + self.client.post(url, headers={"X-Api-Key": "valid-token"}).status_code, + 405, + "Bad method, no access", + ) + # Timestamps of interest + now = timezone.now() + one_day_ago = now - datetime.timedelta(days=1) + two_days_ago = now - datetime.timedelta(days=2) + three_days_ago = now - datetime.timedelta(days=3) + long_long_ago = now - datetime.timedelta(days=400) + + # A recently published RFC with a known author... + author = PersonFactory(name="Jane Q. Author") + recent_rfc = WgRfcFactory(title="A Recently Published RFC") + DocEventFactory(doc=recent_rfc, type="published_rfc", time=two_days_ago) + RfcAuthorFactory(document=recent_rfc, person=author) + + # ...and an RFC published well outside the default window, which must be excluded. + old_rfc = WgRfcFactory(title="An Old RFC") + DocEventFactory(doc=old_rfc, type="published_rfc", time=long_long_ago) + RfcAuthorFactory(document=old_rfc) + + r = self.client.get(url, headers={"X-Api-Key": "valid-token"}) + self.assertEqual(r.status_code, 200) + self.assertEqual(r.headers["Content-Type"], "application/json") + rows = json.loads(r.content) + + # Only the recent RFC's author appears, as a single aggregated object. + self.assertEqual(len(rows), 1) + row = rows[0] + self.assertEqual(row["name"], "Jane Q. Author") + self.assertEqual(row["email"], author.email().address) + self.assertEqual(row["rfc_number"], str(recent_rfc.rfc_number)) + self.assertEqual(row["rfc_name"], recent_rfc.name) + self.assertEqual(row["rfc_title"], recent_rfc.title) + self.assertEqual( + row["rfc_number_and_title"], + f"RFC {recent_rfc.rfc_number}: {recent_rfc.title}", + ) + self.assertEqual(row["published_date"], str(recent_rfc.pub_date())) + + # A narrow window excludes the recent RFC, too. First, using from-only + r = self.client.get( + url + "?" + urlencode({"from": one_day_ago.isoformat()}), + headers={"X-Api-Key": "valid-token"}, + ) + self.assertEqual(r.status_code, 200) + self.assertEqual(json.loads(r.content), []) + # Second, using both from and to + r = self.client.get( + url + + "?" + + urlencode({"from": one_day_ago.isoformat(), "to": now.isoformat()}), + headers={"X-Api-Key": "valid-token"}, + ) + self.assertEqual(r.status_code, 200) + self.assertEqual(json.loads(r.content), []) + # Third, from and to, but on the other side of the recent event. This also + # confirms that the "to" side comparison is < and not <=. + r = self.client.get( + url + + "?" + + urlencode( + {"from": three_days_ago.isoformat(), "to": two_days_ago.isoformat()} + ), + headers={"X-Api-Key": "valid-token"}, + ) + self.assertEqual(r.status_code, 200) + self.assertEqual(json.loads(r.content), []) + + # Make sure the "from" side is >= and not >. + r = self.client.get( + url + + "?" + + urlencode( + {"from": two_days_ago.isoformat(), "to": one_day_ago.isoformat()} + ), + headers={"X-Api-Key": "valid-token"}, + ) + self.assertEqual(r.status_code, 200) + self.assertEqual(len(json.loads(r.content)), 1) + + # The testing parameter fakes the email domain while keeping the mailbox. + r = self.client.get(url + "?testing", headers={"X-Api-Key": "valid-token"}) + self.assertEqual(r.status_code, 200) + rows = json.loads(r.content) + self.assertEqual(len(rows), 1) + mailbox = author.email().address.split("@", 1)[0] + self.assertEqual(rows[0]["email"], f"{mailbox}@fake.example.com") + # Non-email fields are unaffected. + self.assertEqual(rows[0]["name"], "Jane Q. Author") + + # If in test mode and testaddr parameters are present, records for those + # addresses should be returned with a fake RFC. + r = self.client.get( + url + "?testing&testaddr=fake@a.example.com&testaddr=phony@b.example.com", + headers={"X-Api-Key": "valid-token"}, + ) + self.assertEqual(r.status_code, 200) + rows = json.loads(r.content) + self.assertEqual(len(rows), 3) + fake_author_addr = author.email().address.split("@", 1)[0] + "@fake.example.com" + self.assertCountEqual( + [fake_author_addr, "fake@a.example.com", "phony@b.example.com"], + [r["email"] for r in rows], + ) + + # Can only use testaddr when testing is also present + r = self.client.get( + url + "?testaddr=fake.a.example.com", headers={"X-Api-Key": "valid-token"} + ) + self.assertEqual(r.status_code, 400) + + # Invalid to/from parameters are rejected. + r = self.client.get(url + "?from=garbage", headers={"X-Api-Key": "valid-token"}) + self.assertEqual(r.status_code, 400, "bad from parameter") + r = self.client.get(url + "?to=garbage", headers={"X-Api-Key": "valid-token"}) + self.assertEqual(r.status_code, 400, "bad to parameter") + r = self.client.get( + url + f"?from={two_days_ago.isoformat()}&to={three_days_ago.isoformat()}", + headers={"X-Api-Key": "valid-token"}, + ) + self.assertEqual(r.status_code, 400, "out-of-order from/to parameters") + @override_settings( APP_API_TOKENS={"ietf.api.views.ingest_email": "valid-token", "ietf.api.views.ingest_email_test": "test-token"} ) @@ -1524,6 +1663,93 @@ def test_api_top_level(self): self.assertIn(name, resource_list, "Expected a REST API resource for %s, but didn't find one" % name) + def test_api_top_level_bad_accept_header(self): + """A malformed Accept header is rejected without reflecting its content + + The response body is served as unescaped text/html, and this is an + unauthenticated GET endpoint, so nothing derived from the request may + appear in it. + """ + payload = "" + r = self.client.get("/api/v1/", headers={"accept": payload}) + self.assertEqual(r.status_code, 400) + self.assertNotIn(payload, r.content.decode("utf-8")) + + def _assert_filter_is_bad_request(self, querystring, leaked): + """Assert a filter the database rejects gives a 400 that leaks nothing + + Only one such request per test method: this class runs each test inside a + transaction, and the failed statement aborts it, so a second query in the + same test raises InternalError. Production requests run in autocommit and + are unaffected - each is a single request that returns immediately. + """ + r = self.client.get("/api/v1/doc/document/?format=json&limit=1&" + querystring) + self.assertEqual(r.status_code, 400, "Expected 400 for %s" % querystring) + body = r.content.decode("utf-8") + # the database quotes its own diagnostics - none of that should come back + self.assertNotIn("invalid regular expression", body) + self.assertNotIn(leaked, body) + + def test_database_error_unbalanced_bracket(self): + """A filter value the database rejects is a bad request, not a 500 + + An invalid regex is only rejected once the query runs, below anything + tastypie can validate. See ietf.api.ModelResource.dispatch. + """ + self._assert_filter_is_bad_request("name__regex=%5B", "brackets") + + def test_database_error_unbalanced_paren(self): + self._assert_filter_is_bad_request("name__regex=%28", "parentheses") + + def test_database_error_bad_quantifier(self): + self._assert_filter_is_bad_request("name__iregex=" + quote("a{2,1}"), "quantifier") + + def test_valid_regex_filter_still_works(self): + for q in ("name__regex=^draft-", "name__iregex=^DRAFT-", "name__regex=(quic|tls)"): + r = self.client.get("/api/v1/doc/document/?format=json&limit=1&" + quote(q, safe="=&")) + self.assertEqual(r.status_code, 200, "Expected 200 for %s" % q) + + def test_malformed_range_filter(self): + """A range filter without exactly two values is a bad request, not a 500 + + Django renders a range lookup as "BETWEEN %s AND %s" and indexes the value + without checking its length, so a wrong number of values raises IndexError + when the query is compiled - too late for tastypie to report it as anything + but a 500. See ietf.api.ModelResource.filter_value_to_python. + + Note the test client sets SERVER_NAME to "testserver", which makes tastypie + re-raise unhandled exceptions rather than converting them to a 500, so a + regression here surfaces as an error rather than a wrong status code. + """ + # The double-encoded comma from the request that broke in production: %252C + # decodes to the literal text "%2C", so the value never splits into two. + r = self.client.get( + "/api/v1/doc/document/?format=json&limit=1&rev__range=02%252C99&type__slug=draft" + ) + self.assertEqual(r.status_code, 400) + + # note "%252C" not "%2C" - the latter is just a comma once the URL is decoded + for bad in ("", "02", "02%252C99", "02,99,77", "true", "nil"): + r = self.client.get("/api/v1/doc/document/?format=json&limit=1&rev__range=" + bad) + self.assertEqual(r.status_code, 400, "Expected 400 for rev__range=%s" % bad) + + # a well-formed range filter still works + r = self.client.get("/api/v1/doc/document/?format=json&limit=1&rev__range=00,99") + self.assertEqual(r.status_code, 200) + # ... on a datetime field too, and via repeated parameters + r = self.client.get( + "/api/v1/doc/document/?format=json&limit=1" + "&time__range=2020-01-01T00:00:00Z&time__range=2030-01-01T00:00:00Z" + ) + self.assertEqual(r.status_code, 200) + + # "in" filters accept any number of values, including one + r = self.client.get("/api/v1/doc/document/?format=json&limit=1&rev__in=00") + self.assertEqual(r.status_code, 200) + # but not a value that string_to_python() turns into a bool + r = self.client.get("/api/v1/doc/document/?format=json&limit=1&rev__in=true") + self.assertEqual(r.status_code, 400) + def test_all_model_resources_exist(self): client = Client(Accept='application/json') r = client.get("/api/v1") diff --git a/ietf/api/urls.py b/ietf/api/urls.py index 7a082567b8a..dc1ddb6d0ca 100644 --- a/ietf/api/urls.py +++ b/ietf/api/urls.py @@ -44,6 +44,8 @@ # --- Custom API endpoints, sorted alphabetically --- # Email alias information for drafts url(r'^doc/draft-aliases/$', api_views.draft_aliases), + # Authors of recently published RFCs, as CSV + url(r'^doc/rfc-authors/$', api_views.rfc_authors), # email ingestor url(r'email/$', api_views.ingest_email), # email ingestor diff --git a/ietf/api/views.py b/ietf/api/views.py index 420bc396934..9c4743c152a 100644 --- a/ietf/api/views.py +++ b/ietf/api/views.py @@ -1,5 +1,4 @@ -# Copyright The IETF Trust 2017-2020, All Rights Reserved -# -*- coding: utf-8 -*- +# Copyright The IETF Trust 2017-2026, All Rights Reserved import base64 import binascii @@ -19,6 +18,7 @@ from django.http import HttpResponse, Http404, JsonResponse, HttpResponseBadRequest from django.shortcuts import render, get_object_or_404 from django.urls import reverse +from django.utils import timezone from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt from django.views.decorators.gzip import gzip_page @@ -38,6 +38,7 @@ from ietf.api import _api_list from ietf.api.ietf_utils import is_valid_token, requires_api_token from ietf.api.serializer import JsonExportMixin +from ietf.doc.models import Document from ietf.doc.utils import DraftAliasGenerator, fuzzy_find_documents from ietf.group.utils import GroupAliasGenerator, role_holder_emails from ietf.ietfauth.utils import role_required @@ -67,7 +68,10 @@ def top_level(request): try: desired_format = determine_format(request, serializer) except BadRequest as err: - return HttpResponseBadRequest(str(err)) + # tastypie's message is a fixed string today, but don't reflect a dependency's + # exception text into an unescaped text/html body on an unauthenticated endpoint. + log.log("Bad request determining format for api top_level: %s" % err) + return HttpResponseBadRequest("Invalid Accept header") options = {} @@ -560,6 +564,152 @@ def role_holder_addresses(request): return HttpResponse(status=405) +@requires_api_token +@csrf_exempt +def rfc_authors(request): + """Return authors of published RFCs as a JSON list of objects. + + Finds authors by date, though this could be extended to other selection + criteria in the future. Specify the range as ?from=&to=. + Each is an ISO-8601 timestamp, treated as UTC if it does not include + time zone information. Defaults to `to`=now, `from`=14 days before `to` + + Each author appears once, with their RFC numbers, names, titles, and + publication dates accumulated across every RFC they published in the window. + + When the ?testing query parameter is supplied, each author's real email + address is replaced with a fake one that keeps the original mailbox but uses + the "fake.example.com" domain, so the output can be shared without exposing + real addresses. + + When the ?testing query parameter is supplied, one or more testaddr=ADDRESS query + parameters can also be specified. The value of each parameter is an email + address. When these parameters are present, the response data will include an + entry for each address as though it belonged to the author of a recently published + RFC. + """ + if request.method != "GET": + return HttpResponse(status=405) + + # Test mode parameters + testing = "testing" in request.GET + test_addresses = request.GET.getlist("testaddr", []) + if len(test_addresses) > 0: + if not testing: + return HttpResponseBadRequest( + "Must include the testing parameter when using testaddr" + ) + + # filter parameters + to_param = request.GET.get("to") + if to_param is None: + # Did not receive a to parameter. Default to now(). + time_range_end = datetime.datetime.now(datetime.UTC) + else: + # Did receive a to parameter. Parse it. + try: + time_range_end = datetime.datetime.fromisoformat(to_param) + except ValueError: + return HttpResponseBadRequest("Invalid to parameter") + if time_range_end.tzinfo is None: + time_range_end = time_range_end.replace(tzinfo=datetime.UTC) + + from_param = request.GET.get("from") + if from_param is None: + # Did not receive a from parameter. Default to 14 days before end time. + time_range_start = time_range_end - datetime.timedelta(days=14) + else: + # Did receive a from parameter. Parse it. + try: + time_range_start = datetime.datetime.fromisoformat(from_param) + except ValueError: + return HttpResponseBadRequest("Invalid from parameter") + if time_range_start.tzinfo is None: + time_range_start = time_range_start.replace(tzinfo=datetime.UTC) + if time_range_start > time_range_end: + return HttpResponseBadRequest("Invalid time range, from is later than to") + + rfcs = Document.objects.filter( + type_id="rfc", + docevent__type="published_rfc", + docevent__time__gte=time_range_start, + docevent__time__lt=time_range_end, + ).distinct() + + # Collect per-author data keyed by email so each author gets one row. + # Values accumulate RFC numbers, names, titles, and dates across all RFCs. + author_data = {} + + for rfc in rfcs: + # RfcAuthor is the authoritative source for RFC authors. Documents of + # type "rfc" always have an rfcauthor_set, so no fallback is needed. + authors = [ + { + "name": a.person.name if a.person else a.titlepage_name, + "email": a.person.email().address + if (a.person and a.person.email()) + else None, + } + for a in rfc.rfcauthor_set.select_related("person").order_by("order") + ] + + for author in authors: + if not author["email"]: + continue + + if testing: + mailbox = author["email"].split("@", 1)[0] + email = f"{mailbox}@fake.example.com" + else: + email = author["email"] + + if email not in author_data: + author_data[email] = { + "name": author["name"], + "email": email, + "rfc_numbers": [], + "rfc_names": [], + "rfc_titles": [], + "published_dates": [], + } + author_data[email]["rfc_numbers"].append(str(rfc.rfc_number)) + author_data[email]["rfc_names"].append(rfc.name) + author_data[email]["rfc_titles"].append(rfc.title) + author_data[email]["published_dates"].append(str(rfc.pub_date())) + + if testing: + for n, email in enumerate(test_addresses): + if email in author_data: + continue # author will already be included + author_data[email] = { + "name": f"Test Author {n + 1}", + "email": email, + "rfc_numbers": ["99999"], + "rfc_names": ["rfc99999"], + "rfc_titles": ["A Fake RFC for Testing"], + "published_dates": [str(timezone.now().date())], + } + + rows = [] + for entry in author_data.values(): + rows.append( + { + "name": entry["name"], + "email": entry["email"], + "rfc_number": ", ".join(entry["rfc_numbers"]), + "rfc_name": ", ".join(entry["rfc_names"]), + "rfc_title": ", ".join(entry["rfc_titles"]), + "rfc_number_and_title": ", ".join( + f"RFC {num}: {title}" + for num, title in zip(entry["rfc_numbers"], entry["rfc_titles"]) + ), + "published_date": ", ".join(entry["published_dates"]), + } + ) + + return JsonResponse(rows, safe=False) + + _response_email_json_validator = jsonschema.Draft202012Validator( schema={ "type": "object", diff --git a/ietf/doc/tests.py b/ietf/doc/tests.py index 86731000730..6f15003f92f 100644 --- a/ietf/doc/tests.py +++ b/ietf/doc/tests.py @@ -38,7 +38,7 @@ from ietf.doc.models import (Document, DocRelationshipName, RelatedDocument, State, DocEvent, BallotPositionDocEvent, LastCallDocEvent, WriteupDocEvent, NewRevisionDocEvent, BallotType, - EditedAuthorsDocEvent, StateType, RfcAuthor) + EditedAuthorsDocEvent, StateType, RfcAuthor, RpcAssignmentDocEvent) from ietf.doc.factories import (DocumentFactory, DocEventFactory, CharterFactory, ConflictReviewFactory, WgDraftFactory, IndividualDraftFactory, WgRfcFactory, @@ -1688,6 +1688,28 @@ def _change_state(doc, state): self.assertEqual(r.status_code, 200) self.assertNotContains(r, 'Auth48 status') + def test_rfceditor_queue_status_shown(self): + """A queued draft shows its publication-queue Status in place of the state name.""" + draft = IndividualDraftFactory() + event = StateDocEventFactory(doc=draft, state=('draft-rfceditor', 'in_progress')) + draft.set_state(event.state) + draft.save_with_history([event]) + RpcAssignmentDocEvent.objects.create( + doc=draft, + rev=draft.rev, + by=Person.objects.get(name="(System)"), + type="changed_rpc_assignments", + assignments="In Progress (First Edit)", + desc="RPC status changed to In Progress (First Edit)", + ) + + r = self.client.get(urlreverse("ietf.doc.views_doc.document_main", kwargs=dict(name=draft.name))) + self.assertEqual(r.status_code, 200) + # The composite queue Status (which only comes from the RpcAssignmentDocEvent, + # not from the state name) is shown, confirming it replaces the raw state name. + self.assertContains(r, "In Progress (First Edit)") + self.assertContains(r, "Publication queue entry") + class DocTestCase(TestCase): def test_status_change(self): @@ -2103,6 +2125,23 @@ def test_state_help(self): self.assertEqual(r.status_code, 200) self.assertContains(r, State.objects.get(type="draft-iesg", slug="lc").name) + def test_rfceditor_state_help_has_queue_status_and_legacy_sections(self): + url = urlreverse('ietf.doc.views_help.state_help', kwargs=dict(type="draft-rfceditor")) + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + # New "Queue status" section describing the queue Status values. + self.assertContains(r, "Queue status") + self.assertContains(r, "In Progress (First Edit)") + # Legacy states are moved to their own section with the history note. + self.assertContains(r, "Legacy states") + self.assertContains(r, "appear in the change history") + self.assertContains(r, State.objects.get(type="draft-rfceditor", slug="auth48").name) + # The queue-backing states are not listed among the legacy states. + q = PyQuery(r.content) + legacy_ids = [row.get("id") for row in q("tbody tr")] + self.assertNotIn("in_progress", legacy_ids) + self.assertNotIn("blocked", legacy_ids) + def test_document_nonietf_pubreq_button(self): doc = IndividualDraftFactory() diff --git a/ietf/doc/views_doc.py b/ietf/doc/views_doc.py index af056f6a96b..1472c808eb0 100644 --- a/ietf/doc/views_doc.py +++ b/ietf/doc/views_doc.py @@ -60,7 +60,7 @@ from ietf.doc.models import ( Document, DocHistory, DocEvent, BallotDocEvent, BallotType, ConsensusDocEvent, NewRevisionDocEvent, StoredObject, TelechatDocEvent, WriteupDocEvent, IanaExpertDocEvent, IESG_BALLOT_ACTIVE_STATES, STATUSCHANGE_RELATIONS, DocumentActionHolder, DocumentAuthor, - RelatedDocument, RelatedDocHistory) + RelatedDocument, RelatedDocHistory, RpcAssignmentDocEvent) from ietf.doc.tasks import investigate_fragment_task from ietf.doc.utils import (augment_events_with_revision, can_adopt_draft, can_unadopt_draft, get_chartering_type, get_tags_for_stream_id, @@ -197,6 +197,22 @@ def interesting_doc_relations(doc): return interesting_relations_that, interesting_relations_that_doc + +def rfc_editor_queue_status(doc): + """Human-readable RPC publication queue "Status" for the document, or None. + + While a document is in the RFC Editor queue (draft-rfceditor state + "in_progress" or "blocked"), this is the status text pushed by the RFC + Production Center, matching what the queue website shows. It is displayed in + place of the raw draft-rfceditor state name. Returns None for documents whose + draft-rfceditor state predates the queue integration (they fall back to the + state name). + """ + if doc.get_state_slug("draft-rfceditor") not in ("in_progress", "blocked"): + return None + event = doc.latest_event(RpcAssignmentDocEvent, type="changed_rpc_assignments") + return event.assignments if event else None + def document_main(request, name, rev=None, document_html=False): doc = get_object_or_404(Document.objects.select_related(), name=name) @@ -364,6 +380,7 @@ def document_main(request, name, rev=None, document_html=False): has_errata=doc.pk and doc.tags.filter(slug="errata"), # doc.pk == None if using a fake_history_obj file_urls=file_urls, rfc_editor_state=doc.get_state("draft-rfceditor"), + rfc_editor_queue_status=rfc_editor_queue_status(doc), iana_review_state=doc.get_state("draft-iana-review"), iana_action_state=doc.get_state("draft-iana-action"), iana_experts_state=doc.get_state("draft-iana-experts"), @@ -707,6 +724,7 @@ def document_main(request, name, rev=None, document_html=False): iesg_state=iesg_state, iesg_state_summary=iesg_state_summary, rfc_editor_state=doc.get_state("draft-rfceditor"), + rfc_editor_queue_status=rfc_editor_queue_status(doc), rfc_editor_auth48_url=auth48_url, iana_review_state=doc.get_state("draft-iana-review"), iana_action_state=doc.get_state("draft-iana-action"), diff --git a/ietf/doc/views_help.py b/ietf/doc/views_help.py index 34d29aaccbc..1b6c86394d6 100644 --- a/ietf/doc/views_help.py +++ b/ietf/doc/views_help.py @@ -9,6 +9,43 @@ from ietf.name.models import DocRelationshipName, DocTagName from ietf.doc.utils import get_tags_for_stream_id +# Documentation of the values shown in the RFC Editor queue "Status" field. This +# status is not a stored state; it is derived by the RFC Production Center's +# publication queue from the active editor assignments, pending activities, +# blocking reasons and IANA status of a document, and is rendered to match the +# publication queue site (https://queue.rfc-editor.org/). Keep in sync with +# ietf.sync.tasks.format_rpc_queue_status. +RFC_EDITOR_QUEUE_STATUS_VALUES = [ + ("In Progress (First Edit)", + "The document is being copyedited by the first editor."), + ("In Progress (Second Edit)", + "The document is getting a second review, focusing on complex issues and IANA " + "actions."), + ("In Final Review", + "Awaiting final approval(s) from authors and/or action holders."), + (" (e.g. “formatting”)", + "Another RPC activity is currently underway for the document; the activity is " + "shown by name (for example “formatting”). Reference checking and " + "publication are not shown as their own status."), + ("Awaiting ", + "The document is in the queue waiting for the named activity to begin. Values " + "include “Awaiting Formatting”, “Awaiting Reference Checker”, " + "“Awaiting First editor”, “Awaiting Second editor”, " + "“Awaiting Final review editor”, and “Awaiting Publisher”."), + ("Awaiting Editor Assignment", + "The document is in the queue but nothing has been assigned to it yet."), + ("IANA hold", + "First editing is underway but is held pending completion of IANA actions. (This " + "is distinct from the “IANA Hold” blocking reason below.)"), + ("blocked: ", + "Progress is blocked; one or more blocking reasons are listed after the colon. " + "The possible reasons are: Waiting for Action Holder, Stream Hold, External " + "Reference Hold, Author Input Required, IANA Hold, Reference Not Received, " + "Reference Not Received (2nd Generation), Reference Not Received (3rd Generation), " + "Reference: Second Edit Incomplete, Reference: Publish Incomplete, Final Approval " + "Pending, Tools Issue, and Manual Hold."), +] + def state_index(request): types = StateType.objects.all() names = [ type.slug for type in types ] @@ -67,6 +104,22 @@ def state_help(request, type=None): states = State.objects.filter(used=True, type=state_type).order_by("order") + # The RFC Editor queue status is now driven by the RFC Production Center's + # publication queue rather than by the legacy draft-rfceditor states. The + # "in_progress"/"blocked" states back the queue-status display; the remaining + # states are legacy and only appear in the history of older documents. + queue_status_values = None + legacy_states_note = None + if state_type.slug == "draft-rfceditor": + states = states.exclude(slug__in=("in_progress", "blocked")) + queue_status_values = RFC_EDITOR_QUEUE_STATUS_VALUES + legacy_states_note = ( + "These states predate the current RFC Editor publication queue and are " + "no longer assigned to documents. They are documented here because they " + "still appear in the change history of documents that were processed " + "before the queue integration." + ) + has_next_states = False for state in states: if state.next_states.all(): @@ -88,6 +141,8 @@ def state_help(request, type=None): "states": states, "has_next_states": has_next_states, "tags": tags, + "queue_status_values": queue_status_values, + "legacy_states_note": legacy_states_note, } ) def relationship_help(request,subset=None): diff --git a/ietf/group/milestones.py b/ietf/group/milestones.py index 52f2eaebeee..c6380ffad76 100644 --- a/ietf/group/milestones.py +++ b/ietf/group/milestones.py @@ -21,6 +21,7 @@ from ietf.name.models import GroupMilestoneStateName from ietf.group.mails import email_milestones_changed from ietf.utils.fields import DatepickerDateField +from ietf.utils.log import log from ietf.utils.response import permission_denied class MilestoneForm(forms.Form): @@ -415,7 +416,11 @@ def reset_charter_milestones(request, acronym, group_type=None): try: milestone_ids = [int(v) for v in request.POST.getlist("milestone")] except ValueError as e: - return HttpResponseBadRequest("error in list of ids - %s" % e) + # Log the detail rather than reflecting it - the exception message from int() + # embeds the offending value verbatim, and HttpResponseBadRequest serves its + # content as unescaped text/html. + log("Invalid milestone id in reset_charter_milestones POST: %s" % e) + return HttpResponseBadRequest("error in list of ids") # delete existing for m in charter_milestones: diff --git a/ietf/group/tests_info.py b/ietf/group/tests_info.py index 97ec7ebdb15..4e0096b1859 100644 --- a/ietf/group/tests_info.py +++ b/ietf/group/tests_info.py @@ -1760,6 +1760,41 @@ def test_reset_charter_milestones(self): self.assertEqual(group.charter.docevent_set.count(), events_before + 2) # 1 delete, 1 add + def test_reset_charter_milestones_bad_ids(self): + """A non-integer milestone id is rejected without echoing the submitted value + + int() puts the offending value in its exception message and + HttpResponseBadRequest serves its content as unescaped text/html, so + reflecting the message would be an XSS vector. + """ + m1, m2, group = self.create_test_milestones() + + url = urlreverse('ietf.group.milestones.reset_charter_milestones', kwargs=dict(group_type=group.type_id, acronym=group.acronym)) + login_testing_unauthorized(self, "secretary", url) + + milestones_before = GroupMilestone.objects.count() + events_before = group.charter.docevent_set.count() + + payload = '' + for bad_id in (payload, 'not-a-number', '1.5'): + r = self.client.post(url, dict(milestone=[str(m1.pk), bad_id])) + self.assertEqual(r.status_code, 400) + content = r.content.decode('utf-8') + self.assertIn('error in list of ids', content) + self.assertNotIn(bad_id, content) + self.assertNotIn('invalid literal', content) + + # an empty id is also rejected, without the exception detail + r = self.client.post(url, dict(milestone=[str(m1.pk), ''])) + self.assertEqual(r.status_code, 400) + self.assertNotIn('invalid literal', r.content.decode('utf-8')) + + # nothing was changed + self.assertEqual(GroupMilestone.objects.count(), milestones_before) + self.assertEqual(group.charter.docevent_set.count(), events_before) + self.assertEqual(GroupMilestone.objects.get(pk=m1.pk).state_id, m1.state_id) + self.assertEqual(GroupMilestone.objects.get(pk=m2.pk).state_id, m2.state_id) + def test_edit_sort(self): group = GroupFactory(uses_milestone_dates=False) DatelessGroupMilestoneFactory(group=group,order=1) diff --git a/ietf/meeting/tests_views.py b/ietf/meeting/tests_views.py index eea08be8c77..beaaf8da8a7 100644 --- a/ietf/meeting/tests_views.py +++ b/ietf/meeting/tests_views.py @@ -18,7 +18,7 @@ from icalendar import Calendar from io import StringIO, BytesIO from bs4 import BeautifulSoup -from urllib.parse import urlparse, urlsplit +from urllib.parse import quote, urlparse, urlsplit from PIL import Image from pathlib import Path from tempfile import NamedTemporaryFile @@ -1119,6 +1119,40 @@ def _r(show=(), hide=(), showtypes=(), hidetypes=()): 'Parsed "%s" incorrectly' % qstr, ) + # Unrecognized parameters are ignored, not rejected. The ical views rely on this - + # they cannot report a parse error, so they cannot reflect one back to the client. + for qstr in ( + 'unknown=x', + 'show=a&unknown=x', + '=1', + 'show=', + ): + self.assertIsNotNone( + parse_agenda_filter_params(QueryDict(qstr)), + 'Parsing "%s" should not fail' % qstr, + ) + + def test_ical_filter_params_are_not_reflected(self): + """A query string must never be echoed into an ical view's response + + HttpResponseBadRequest serves text/html without escaping, so reflecting a + parameter name or value would be a reflected XSS on an unauthenticated GET. + """ + meeting = make_meeting_test_data() + payload = '' + for url in ( + urlreverse('ietf.meeting.views.agenda_ical', kwargs={'num': meeting.number}), + urlreverse('ietf.meeting.views.upcoming_ical'), + ): + for querystring in ( + '?%s=1' % quote(payload), + '?show=%s' % quote(payload), + '?unknown=%s' % quote(payload), + ): + r = self.client.get(url + querystring) + self.assertEqual(r.status_code, 200, 'Expected %s%s to be accepted' % (url, querystring)) + self.assertNotIn(payload, r.content.decode('utf-8')) + def do_ical_filter_test(self, meeting, querystring, expected_session_summaries): url = urlreverse('ietf.meeting.views.agenda_ical', kwargs={'num':meeting.number}) r = self.client.get(url + querystring) diff --git a/ietf/meeting/views.py b/ietf/meeting/views.py index 913c8bd3021..e2a15d3e8aa 100644 --- a/ietf/meeting/views.py +++ b/ietf/meeting/views.py @@ -2656,7 +2656,11 @@ def agenda_ical(request, num=None, acronym=None, session_id=None): try: filt_params = parse_agenda_filter_params(request.GET) except ValueError as e: - return HttpResponseBadRequest(str(e)) + # Defensive only - parse_agenda_filter_params ignores unrecognized parameters and + # does not raise. Log the detail rather than reflecting it: the query string is + # attacker-controlled and HttpResponseBadRequest serves unescaped text/html. + log("Invalid agenda filter parameters in agenda_ical: %s" % e) + return HttpResponseBadRequest("Invalid agenda filter parameters") if meeting.type_id == "ietf": return agenda_ical_ietf(meeting, filt_params, acronym, session_id) @@ -4568,8 +4572,10 @@ def upcoming_ical(request): try: filter_params = parse_agenda_filter_params(request.GET) except ValueError as e: - return HttpResponseBadRequest(str(e)) - + # Defensive only - see the corresponding handler in agenda_ical. + log("Invalid agenda filter parameters in upcoming_ical: %s" % e) + return HttpResponseBadRequest("Invalid agenda filter parameters") + today = datetime_today() # get meetings starting 7 days ago -- we'll filter out sessions in the past further down diff --git a/ietf/sync/tasks.py b/ietf/sync/tasks.py index 3af5cb8984d..24d3c77b3be 100644 --- a/ietf/sync/tasks.py +++ b/ietf/sync/tasks.py @@ -299,6 +299,89 @@ def refresh_rfc_index_task(): mark_rfcindex_as_processed(new_processed_time) +# Human-readable labels for the RPC publication queue "Status", mirroring the +# ietf-tools/queue website (website/app/utils/queue.ts, renderAssignmentsByRoles) +# so the datatracker shows the same status text that appears at +# https://queue.rfc-editor.org/. The queue "Status" is not a stored field; it is +# derived from the active assignment roles, pending activities, blocking reasons +# and IANA status carried in the purple pubq queue payload. +RPC_QUEUE_ROLE_LABELS = { + "first_editor": "In Progress (First Edit)", + "second_editor": "In Progress (Second Edit)", + "final_review_editor": "In Final Review", +} +# Roles the queue site does not surface in the Status column. +RPC_QUEUE_HIDDEN_ROLES = {"ref_checker", "publisher"} + + +def _humanize_slug(slug): + return slug.replace("_", " ") + + +def _rpc_role_label(role): + return RPC_QUEUE_ROLE_LABELS.get(role, _humanize_slug(role)) + + +def _rpc_blocking_reason_label(name): + # Special case mirrored from the queue site's humanFriendlyBlockingReason(). + if name == "Reference: First Edit Incomplete": + return "Author Input Required" + return _humanize_slug(name) + + +def format_rpc_queue_status(obj): + """Render the RPC publication queue "Status" for a single queue entry. + + Mirrors renderAssignmentsByRoles() from the ietf-tools/queue website so the + datatracker presents the same status text. ``obj`` is one entry of the purple + pubq queue payload. Roles, pending activities and blocking reasons are sorted + so the result is stable (a change to the string is what triggers a new + RpcAssignmentDocEvent). + """ + roles = { + a["role"] for a in (obj.get("assignment_set") or []) if a.get("role") + } + is_blocked = "blocked" in roles + + parts = [] + + # IANA hold: iana_status "not_completed" while a first_editor is assigned. + iana_status = obj.get("iana_status") or {} + if iana_status.get("slug") == "not_completed" and "first_editor" in roles: + parts.append("IANA hold") + + # Pending activities (only when not blocked): "Awaiting ", skipping any + # role that is already a current assignment. Note the queue site does NOT hide + # ref_checker/publisher here (only for current-role badges below), so e.g. + # "Awaiting Reference Checker" can appear. + if not is_blocked: + for activity in sorted( + obj.get("pending_activities") or [], + key=lambda a: (a.get("name") or a.get("slug") or ""), + ): + slug = activity.get("slug") + if not slug or slug in roles: + continue + parts.append(f"Awaiting {activity.get('name') or _humanize_slug(slug)}") + + # Current assignment roles (ref_checker/publisher hidden). Blocking reason + # names are appended to the "blocked" role. + blocking_names = sorted( + _rpc_blocking_reason_label(br["reason"]["name"]) + for br in (obj.get("blocking_reasons") or []) + if br.get("reason", {}).get("name") + ) + for role in sorted(roles - RPC_QUEUE_HIDDEN_ROLES): + label = _rpc_role_label(role) + if role == "blocked" and blocking_names: + label += ": " + ", ".join(blocking_names) + parts.append(label) + + if not parts: + return "Awaiting Editor Assignment" + return ", ".join(parts) + + @shared_task def process_rpc_queue_task(data: list): in_progress_state = State.objects.get( @@ -366,16 +449,7 @@ def process_rpc_queue_task(data: list): e.save() events.append(e) - roles = sorted(a["role"] for a in obj.get("assignment_set", [])) - next_assignments = ", ".join(roles) - blocking_names = sorted( - br["reason"]["name"] for br in obj.get("blocking_reasons", []) - ) - if blocking_names: - next_assignments += ": " + ", ".join(blocking_names) - - if next_assignments == "": - next_assignments = "Awaiting Editor Assignment" + next_assignments = format_rpc_queue_status(obj) prev_assignments_event = d.latest_event( RpcAssignmentDocEvent, type="changed_rpc_assignments" diff --git a/ietf/sync/tests_tasks.py b/ietf/sync/tests_tasks.py index 57284b72980..264a5f46bb3 100644 --- a/ietf/sync/tests_tasks.py +++ b/ietf/sync/tests_tasks.py @@ -182,7 +182,9 @@ def test_creates_assignment_event_on_first_update(self): RpcAssignmentDocEvent, type="changed_rpc_assignments" ) self.assertIsNotNone(event) - self.assertEqual(event.assignments, "first_editor, second_editor") + self.assertEqual( + event.assignments, "In Progress (First Edit), In Progress (Second Edit)" + ) def test_no_assignment_event_when_unchanged(self): """No new RpcAssignmentDocEvent when assignments match the last recorded ones.""" @@ -192,8 +194,8 @@ def test_no_assignment_event_when_unchanged(self): rev=draft.rev, by=self.system, type="changed_rpc_assignments", - assignments="first_editor", - desc="RPC status changed to first_editor", + assignments="In Progress (First Edit)", + desc="RPC status changed to In Progress (First Edit)", ) events_before = RpcAssignmentDocEvent.objects.filter(doc=draft).count() @@ -211,8 +213,8 @@ def test_assignment_desc_includes_previous_assignments(self): rev=draft.rev, by=self.system, type="changed_rpc_assignments", - assignments="first_editor", - desc="RPC status changed to first_editor", + assignments="In Progress (First Edit)", + desc="RPC status changed to In Progress (First Edit)", ) tasks.process_rpc_queue_task([_make_entry(draft.name, roles=["second_editor"])]) @@ -220,7 +222,7 @@ def test_assignment_desc_includes_previous_assignments(self): event = draft.latest_event( RpcAssignmentDocEvent, type="changed_rpc_assignments" ) - self.assertIn("from first_editor", event.desc) + self.assertIn("from In Progress (First Edit)", event.desc) def test_blocking_reasons_appended_to_assignments(self): """Blocking reason names are appended after ':' in the assignment string, sorted.""" @@ -257,7 +259,9 @@ def test_roles_sorted_in_assignment_string(self): event = draft.latest_event( RpcAssignmentDocEvent, type="changed_rpc_assignments" ) - self.assertEqual(event.assignments, "first_editor, second_editor") + self.assertEqual( + event.assignments, "In Progress (First Edit), In Progress (Second Edit)" + ) def test_empty_roles_uses_awaiting_editor_assignment(self): """Empty assignment_set records 'Awaiting Editor Assignment' rather than an empty string.""" @@ -384,8 +388,8 @@ def test_auth48_url_created_when_assignments_unchanged(self): rev=draft.rev, by=self.system, type="changed_rpc_assignments", - assignments="first_editor", - desc="RPC status changed to first_editor", + assignments="In Progress (First Edit)", + desc="RPC status changed to In Progress (First Edit)", ) tasks.process_rpc_queue_task( @@ -420,8 +424,8 @@ def test_auth48_url_deleted_when_assignments_unchanged(self): rev=draft.rev, by=self.system, type="changed_rpc_assignments", - assignments="first_editor", - desc="RPC status changed to first_editor", + assignments="In Progress (First Edit)", + desc="RPC status changed to In Progress (First Edit)", ) tasks.process_rpc_queue_task([_make_entry(draft.name, roles=["first_editor"])]) @@ -477,6 +481,151 @@ def test_docs_in_queue_retain_rfceditor_state(self): self.assertIsNotNone(draft.get_state("draft-rfceditor")) +class FormatRpcQueueStatusTests(TestCase): + """Unit tests for the queue "Status" renderer, mirroring the ietf-tools/queue site.""" + + def test_editor_roles_get_friendly_labels(self): + self.assertEqual( + tasks.format_rpc_queue_status( + {"assignment_set": [{"role": "first_editor"}]} + ), + "In Progress (First Edit)", + ) + self.assertEqual( + tasks.format_rpc_queue_status( + {"assignment_set": [{"role": "final_review_editor"}]} + ), + "In Final Review", + ) + + def test_roles_sorted_and_joined(self): + self.assertEqual( + tasks.format_rpc_queue_status( + {"assignment_set": [{"role": "second_editor"}, {"role": "first_editor"}]} + ), + "In Progress (First Edit), In Progress (Second Edit)", + ) + + def test_ref_checker_and_publisher_hidden(self): + self.assertEqual( + tasks.format_rpc_queue_status( + { + "assignment_set": [ + {"role": "ref_checker"}, + {"role": "publisher"}, + {"role": "first_editor"}, + ] + } + ), + "In Progress (First Edit)", + ) + + def test_pending_activities_awaiting(self): + self.assertEqual( + tasks.format_rpc_queue_status( + { + "assignment_set": [], + "pending_activities": [{"slug": "first_editor", "name": "First editor"}], + } + ), + "Awaiting First editor", + ) + + def test_pending_ref_checker_and_publisher_are_shown(self): + # ref_checker/publisher are hidden only as current-role badges, not as + # pending activities (matches the queue site). + self.assertEqual( + tasks.format_rpc_queue_status( + { + "assignment_set": [], + "pending_activities": [ + {"slug": "ref_checker", "name": "Reference Checker"}, + {"slug": "publisher", "name": "Publisher"}, + ], + } + ), + "Awaiting Publisher, Awaiting Reference Checker", + ) + + def test_pending_activity_skipped_when_already_assigned(self): + self.assertEqual( + tasks.format_rpc_queue_status( + { + "assignment_set": [{"role": "first_editor"}], + "pending_activities": [{"slug": "first_editor", "name": "First editor"}], + } + ), + "In Progress (First Edit)", + ) + + def test_iana_hold(self): + self.assertEqual( + tasks.format_rpc_queue_status( + { + "assignment_set": [{"role": "first_editor"}], + "iana_status": {"slug": "not_completed"}, + } + ), + "IANA hold, In Progress (First Edit)", + ) + + def test_iana_hold_only_when_first_editor_present(self): + self.assertEqual( + tasks.format_rpc_queue_status( + { + "assignment_set": [{"role": "second_editor"}], + "iana_status": {"slug": "not_completed"}, + } + ), + "In Progress (Second Edit)", + ) + + def test_blocked_with_reasons_sorted(self): + self.assertEqual( + tasks.format_rpc_queue_status( + { + "assignment_set": [{"role": "blocked"}], + "blocking_reasons": [ + {"reason": {"name": "Stream Hold"}}, + {"reason": {"name": "Manual Hold"}}, + ], + } + ), + "blocked: Manual Hold, Stream Hold", + ) + + def test_blocking_reason_special_case(self): + self.assertEqual( + tasks.format_rpc_queue_status( + { + "assignment_set": [{"role": "blocked"}], + "blocking_reasons": [ + {"reason": {"name": "Reference: First Edit Incomplete"}} + ], + } + ), + "blocked: Author Input Required", + ) + + def test_blocked_suppresses_pending_activities(self): + self.assertEqual( + tasks.format_rpc_queue_status( + { + "assignment_set": [{"role": "blocked"}], + "pending_activities": [{"slug": "first_editor", "name": "First editor"}], + "blocking_reasons": [], + } + ), + "blocked", + ) + + def test_empty_is_awaiting_editor_assignment(self): + self.assertEqual( + tasks.format_rpc_queue_status({"assignment_set": []}), + "Awaiting Editor Assignment", + ) + + class UpdateErrataFromRfcEditorTaskTests(TestCase): @mock.patch("ietf.sync.tasks.update_rfc_json_task.delay") @mock.patch("ietf.sync.tasks.update_errata_from_rfceditor") diff --git a/ietf/templates/doc/document_draft.html b/ietf/templates/doc/document_draft.html index 57194144989..a39fdf6eb73 100644 --- a/ietf/templates/doc/document_draft.html +++ b/ietf/templates/doc/document_draft.html @@ -583,13 +583,13 @@ - RFC Editor state + RFC Editor status - {{ rfc_editor_state }} + {{ rfc_editor_queue_status|default:rfc_editor_state }} diff --git a/ietf/templates/doc/state_help.html b/ietf/templates/doc/state_help.html index 606e13cbacf..3a61e048996 100644 --- a/ietf/templates/doc/state_help.html +++ b/ietf/templates/doc/state_help.html @@ -14,6 +14,37 @@

{{ title }}

href="{% static 'ietf/images/iesg-draft-state-diagram.png' %}">View diagram

{% endif %} + {% if queue_status_values %} +

Queue status

+

+ The RFC Editor state shown for a document in the publication queue is its + Status in the RFC Production Center's publication queue, matching + what is shown at + the RFC Editor publication queue. + This status is derived from the document's current editor assignments, pending + activities, blocking reasons and IANA status, so it can take the following forms. + (Processing labels such as github, markdown and + Expedited shown on the publication queue are not part of this status.) +

+ + + + + + + + + {% for value, description in queue_status_values %} + + + + + {% endfor %} + +
StatusDescription
{{ value }}{{ description }}
+

Legacy states

+

{{ legacy_states_note }}

+ {% endif %} diff --git a/ietf/templates/sync/rfc-index.txt b/ietf/templates/sync/rfc-index.txt index 0f01ddfa905..050afd130e7 100644 --- a/ietf/templates/sync/rfc-index.txt +++ b/ietf/templates/sync/rfc-index.txt @@ -21,9 +21,10 @@ or For example: - 1129 Internet Time Synchronization: The Network Time Protocol. D.L. - Mills. October 1989. (Format: TXT, PS, PDF, HTML) (Also RFC1119) - (Status: INFORMATIONAL) (DOI: 10.17487/RFC1129) + 9915 Dynamic Host Configuration Protocol for IPv6 (DHCPv6). T. Mrugalski, + B. Volz, M. Richardson, S. Jiang, T. Winters. January 2026. (Format: + HTML, TXT, PDF, XML) (Obsoletes RFC8415) (Also STD102) (Status: + INTERNET STANDARD) (DOI: 10.17487/RFC9915) Key to citations: diff --git a/ietf/utils/test_runner.py b/ietf/utils/test_runner.py index a23416e87f2..0a46fdf807a 100644 --- a/ietf/utils/test_runner.py +++ b/ietf/utils/test_runner.py @@ -1,4 +1,4 @@ -# Copyright The IETF Trust 2009-2025, All Rights Reserved +# Copyright The IETF Trust 2009-2026, All Rights Reserved # -*- coding: utf-8 -*- # # Portion Copyright (C) 2009 Nokia Corporation and/or its subsidiary(-ies). @@ -1036,7 +1036,7 @@ def validate(self, testcase): with tempfile.NamedTemporaryFile() as stdout: subprocess.run( [ - "yarn", + "npx", "html-validate", "--formatter=json", "--config=" + self.config_file[kind].name, diff --git a/k8s/datatracker.yaml b/k8s/datatracker.yaml index 5183893bc8c..2a96ab63bce 100644 --- a/k8s/datatracker.yaml +++ b/k8s/datatracker.yaml @@ -3,7 +3,7 @@ kind: Deployment metadata: name: datatracker spec: - replicas: 1 + replicas: 2 revisionHistoryLimit: 2 selector: matchLabels: diff --git a/playwright/package-lock.json b/playwright/package-lock.json index c065d20389a..3f354187450 100644 --- a/playwright/package-lock.json +++ b/playwright/package-lock.json @@ -14,8 +14,8 @@ "slugify": "1.6.9" }, "devDependencies": { - "@playwright/test": "1.61.1", - "npm-check-updates": "22.2.9" + "@playwright/test": "1.62.1", + "npm-check-updates": "23.0.1" } }, "node_modules/@faker-js/faker": { @@ -35,19 +35,18 @@ } }, "node_modules/@playwright/test": { - "version": "1.61.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", - "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", "dev": true, - "license": "Apache-2.0", "dependencies": { - "playwright": "1.61.1" + "playwright": "1.62.1" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/fsevents": { @@ -56,7 +55,6 @@ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", "dev": true, "hasInstallScript": true, - "license": "MIT", "optional": true, "os": [ "darwin" @@ -86,50 +84,47 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, "node_modules/npm-check-updates": { - "version": "22.2.9", - "resolved": "https://registry.npmjs.org/npm-check-updates/-/npm-check-updates-22.2.9.tgz", - "integrity": "sha512-DVeZ0KirHfliSsHuR2o7cHE+tW439sVHfJjF6cGWeDiY0Wyl3BI/jS4zV0eixtcMOquFbcF1Su/FsxOvk5MoYA==", + "version": "23.0.1", + "resolved": "https://registry.npmjs.org/npm-check-updates/-/npm-check-updates-23.0.1.tgz", + "integrity": "sha512-e4hu3Rq4waj7SnhzvqAc5UG557mfP5e3JIFFQd7Fg3RiRkJl8yR90NFoZ1GSWUT3S/QacJndJb+7dLQBPeH+iA==", "dev": true, - "license": "Apache-2.0", "bin": { "ncu": "build/cli.js", "npm-check-updates": "build/cli.js" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "node": "^22.22.2 || ^24.15.0 || >=26.0.0", "npm": ">=10.0.0" } }, "node_modules/playwright": { - "version": "1.61.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", - "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", "dev": true, - "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.61.1" + "playwright-core": "1.62.1" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=18" + "node": ">=20" }, "optionalDependencies": { "fsevents": "2.3.2" } }, "node_modules/playwright-core": { - "version": "1.61.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", - "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", "dev": true, - "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" }, "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/seedrandom": { @@ -154,12 +149,12 @@ "integrity": "sha512-bsxD8WLS5lIj7aaoCx1YJkktqYj5vlBUE6HWzu2Q51ksrGJ0H737ECCKlFU7Yf8Br45z9t99frBp/J7kzbMPAg==" }, "@playwright/test": { - "version": "1.61.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", - "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", "dev": true, "requires": { - "playwright": "1.61.1" + "playwright": "1.62.1" } }, "fsevents": { @@ -185,25 +180,25 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, "npm-check-updates": { - "version": "22.2.9", - "resolved": "https://registry.npmjs.org/npm-check-updates/-/npm-check-updates-22.2.9.tgz", - "integrity": "sha512-DVeZ0KirHfliSsHuR2o7cHE+tW439sVHfJjF6cGWeDiY0Wyl3BI/jS4zV0eixtcMOquFbcF1Su/FsxOvk5MoYA==", + "version": "23.0.1", + "resolved": "https://registry.npmjs.org/npm-check-updates/-/npm-check-updates-23.0.1.tgz", + "integrity": "sha512-e4hu3Rq4waj7SnhzvqAc5UG557mfP5e3JIFFQd7Fg3RiRkJl8yR90NFoZ1GSWUT3S/QacJndJb+7dLQBPeH+iA==", "dev": true }, "playwright": { - "version": "1.61.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", - "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", "dev": true, "requires": { "fsevents": "2.3.2", - "playwright-core": "1.61.1" + "playwright-core": "1.62.1" } }, "playwright-core": { - "version": "1.61.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", - "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", "dev": true }, "seedrandom": { diff --git a/playwright/package.json b/playwright/package.json index 63d78a4cb50..d33dca96930 100644 --- a/playwright/package.json +++ b/playwright/package.json @@ -16,7 +16,7 @@ "slugify": "1.6.9" }, "devDependencies": { - "@playwright/test": "1.61.1", - "npm-check-updates": "22.2.9" + "@playwright/test": "1.62.1", + "npm-check-updates": "23.0.1" } } diff --git a/requirements.txt b/requirements.txt index 485e4eb4a3f..9023aa5436e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -85,7 +85,7 @@ scout-apm>=3.4.0 selenium>=4.34.2 tblib>=3.1.0 # So that the django test runner provides tracebacks tlds>=2022042700 # Used to teach bleach about which TLDs currently exist -tqdm>=4.67.1 +tqdm>=4.70.0 unidecode>=1.4.0 urllib3>=2.5.0 weasyprint>=66.0