From 70db8a74919d09e0ac6201fdb36aedbbbf7cd656 Mon Sep 17 00:00:00 2001 From: GGThed Date: Tue, 11 Aug 2026 15:30:05 -0400 Subject: [PATCH] feat(obs): nommer chaque requete, et rendre les pages d erreur audibles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OBS-005. Un 500 dans errors.log et les six lignes de app.log qui y menent n etaient relies que par leur horodatage — ce qui n est pas une relation des que le serveur traite plus d une requete a la fois. Et un utilisateur qui dit « ca a plante quand j ai clique sur enregistrer » ne donnait a personne de quoi chercher. Chaque requete recoit un identifiant, porte par toutes les lignes de journal qu elle produit, renvoye en X-Request-Id, et affiche sur la page 500 comme reference a citer. Il est **genere**, jamais lu depuis un en-tete entrant. Accepter celui du client serait pratique pour tracer a travers nginx, et permettrait aussi a n importe qui d ecrire du texte arbitraire — retours a la ligne compris — dans le fichier de journal. C est ainsi qu un journal cesse d etre une preuve. Il n y a de toute facon aucun proxy de confiance tant qu OPS-002 est ouvert. Le test correspondant assure sur l alphabet plutot qu en envoyant un retour a la ligne : le client de test de Werkzeug refuse d emettre un tel en-tete, donc l attaque ne peut meme pas etre construite par la, ce qui ne prouverait rien sur l application. **Defaut trouve en chemin, et repare.** Les cinq gabarits d erreur remplissent le bloc `content`, qui n existait que dans la branche authentifiee de la mise en page. Un visiteur deconnecte tombant sur une erreur — donc typiquement sur la page de connexion — recevait le logo, le selecteur de langue, et **aucun message**. Le code de statut etait bon, les journaux etaient bons, la page etait vide. Le disait quand meme « 404 », ce qui explique en grande partie que personne ne l ait vu. Le bloc est desormais rendu dans les deux branches via self.content(), Jinja refusant deux blocs de meme nom. Un seul cote du if s execute, donc jamais de double rendu — et c est assure, pas suppose. 550 tests. --- app/app.py | 30 ++- app/logging_config.py | 46 ++++- app/templates/errors/500.html | 6 + app/templates/layouts/base.html | 14 ++ app/translations/en/LC_MESSAGES/messages.mo | Bin 44536 -> 44630 bytes app/translations/en/LC_MESSAGES/messages.po | 52 ++--- app/translations/fr/LC_MESSAGES/messages.mo | Bin 48770 -> 48881 bytes app/translations/fr/LC_MESSAGES/messages.po | 52 ++--- tests/test_request_id.py | 214 ++++++++++++++++++++ 9 files changed, 363 insertions(+), 51 deletions(-) create mode 100644 tests/test_request_id.py diff --git a/app/app.py b/app/app.py index 72085d1..7d00949 100644 --- a/app/app.py +++ b/app/app.py @@ -249,6 +249,27 @@ def create_app(config=None): # unconditionally so templates can carry nonce="" beforehand. g.csp_nonce = secrets.token_urlsafe(16) + @app.before_request + def assign_request_id(): + """Give this request a name, so its log lines can be found (OBS-005). + + Every record emitted while handling it carries this id — see + RequestIdFilter — which is what turns "an error happened around + 14:32" into the six lines that led to it. It goes back in + X-Request-Id and onto the 500 page, so that a report of "it broke + when I clicked save" is enough to find the trace. + + Generated here, never taken from an inbound header: with no trusted + proxy settled (OPS-002), an accepted header lets any caller write + arbitrary text — newlines included — into the log file. + """ + g.request_id = secrets.token_hex(8) + + @app.after_request + def expose_request_id(response): + response.headers['X-Request-Id'] = g.get('request_id', '-') + return response + @app.url_defaults def version_static_urls(endpoint, values): """Stamp every static URL with the file's modification time. @@ -533,6 +554,12 @@ def create_app(config=None): # Roll back any failed database session db.session.rollback() + # The id is the only thing that connects a user saying "it broke when + # I clicked save" to the stack trace in errors.log. It identifies one + # request and nothing else — no session, no account, nothing an + # attacker can use — so showing it costs nothing (OBS-005). + request_id = g.get('request_id', '-') + if request.path.startswith('/users/disponibilities') or request.path.startswith( '/users/api/' ): @@ -540,9 +567,10 @@ def create_app(config=None): { 'error': 'Internal server error', 'message': 'An unexpected error occurred. Please try again later.', + 'request_id': request_id, } ), 500 - return render_template('errors/500.html'), 500 + return render_template('errors/500.html', request_id=request_id), 500 @app.errorhandler(HTTPException) def handle_http_exception(error): diff --git a/app/logging_config.py b/app/logging_config.py index 2dd0616..82397e3 100644 --- a/app/logging_config.py +++ b/app/logging_config.py @@ -14,6 +14,37 @@ import os import re from logging.handlers import RotatingFileHandler +#: Value used when a record is emitted outside a request — startup, the +#: Discord bot thread, the scheduler. Short and obviously not an id, so a +#: grep for one never matches it by accident. +NO_REQUEST = '-' + + +class RequestIdFilter(logging.Filter): + """Stamp every record with the id of the request that produced it. + + Without this, a 500 in errors.log and the six lines in app.log that led + to it are related only by their timestamps, which is not a relation when + the server is handling more than one request at a time (OBS-005). + + The id is generated per request and never read from an inbound header. + Accepting one would be convenient for tracing across nginx, and it would + also let any caller write arbitrary text — newlines included — into the + log file, which is how a log gets forged rather than read. There is no + trusted proxy to take it from while OPS-002 is open. + """ + + def filter(self, record): + record.request_id = NO_REQUEST + try: + from flask import g, has_request_context + + if has_request_context(): + record.request_id = g.get('request_id', NO_REQUEST) + except Exception: # noqa: BLE001 — logging must never be the thing that fails + pass + return True + class SensitiveDataFilter(logging.Filter): """Logging filter that redacts sensitive information from log messages. @@ -95,10 +126,17 @@ def configure_logging(app): # Create the sensitive data filter sensitive_filter = SensitiveDataFilter() + request_id_filter = RequestIdFilter() - # Formatter with timestamp, level, module, and message + # Formatter with timestamp, level, module, request id, and message. + # + # request_id comes from RequestIdFilter, which is attached to every + # handler below. A handler that formats with this string and does not + # carry the filter raises on its first record — so if one is ever added, + # add the filter with it. formatter = logging.Formatter( - '[%(asctime)s] %(levelname)s [%(name)s:%(lineno)d] %(message)s', datefmt='%Y-%m-%d %H:%M:%S' + '[%(asctime)s] %(levelname)s [%(name)s:%(lineno)d] [%(request_id)s] %(message)s', + datefmt='%Y-%m-%d %H:%M:%S', ) # ------------------------------------------------------------------------- @@ -112,6 +150,7 @@ def configure_logging(app): error_handler.setLevel(logging.ERROR) error_handler.setFormatter(formatter) error_handler.addFilter(sensitive_filter) + error_handler.addFilter(request_id_filter) app.logger.addHandler(error_handler) # ------------------------------------------------------------------------- @@ -125,6 +164,7 @@ def configure_logging(app): auth_handler.setLevel(logging.INFO) auth_handler.setFormatter(formatter) auth_handler.addFilter(sensitive_filter) + auth_handler.addFilter(request_id_filter) # Create a named logger specifically for auth events auth_logger = logging.getLogger('team_tryouts.auth') @@ -143,6 +183,7 @@ def configure_logging(app): app_handler.setLevel(log_level) app_handler.setFormatter(formatter) app_handler.addFilter(sensitive_filter) + app_handler.addFilter(request_id_filter) app.logger.addHandler(app_handler) # ------------------------------------------------------------------------- @@ -156,6 +197,7 @@ def configure_logging(app): console_handler.setLevel(logging.DEBUG if debug_mode else log_level) console_handler.setFormatter(formatter) console_handler.addFilter(sensitive_filter) + console_handler.addFilter(request_id_filter) app.logger.addHandler(console_handler) # ------------------------------------------------------------------------- diff --git a/app/templates/errors/500.html b/app/templates/errors/500.html index ffaab59..2970cde 100644 --- a/app/templates/errors/500.html +++ b/app/templates/errors/500.html @@ -8,6 +8,12 @@ </div> <h2>{{ _('500 — Internal Server Error') }}</h2> <p>{{ _('Something went wrong on our end. The error has been logged and will be investigated. Please try again later.') }}</p> + {# The reference is what makes a report actionable: it names one request + in errors.log. It identifies nothing else — no session, no account — + so there is nothing to protect here (OBS-005). #} + {% if request_id and request_id != '-' %} + <p class="text-muted small">{{ _('Reference to quote if you report this:') }} <code>{{ request_id }}</code></p> + {% endif %} <a href="{{ url_for('main.dashboard') if current_user.is_authenticated else url_for('auth.login') }}" class="btn btn-primary"> <i class="fas fa-redo-alt"></i> {{ _('Try Again') }} </a> diff --git a/app/templates/layouts/base.html b/app/templates/layouts/base.html index 9e3e992..c7279f7 100644 --- a/app/templates/layouts/base.html +++ b/app/templates/layouts/base.html @@ -201,6 +201,20 @@ <div class="auth-language"> {% include "layouts/_language_switcher.html" %} </div> + {# The same `content` block as the signed-in branch, rendered + here too — `self.content()` rather than a second + `{% block %}`, which Jinja refuses. + + The error pages (400, 403, 404, 429, 500) all fill `content`, + and it existed only inside the `is_authenticated` branch: a + signed-out visitor hitting any of them got the logo, the + language switcher and no message whatsoever. The <title> still + said "404", which is most of why nobody noticed. + + Only one branch of the `if` runs, so this never double-renders. + Sign-in pages fill `auth_content` instead and leave this + empty. #} + {{ self.content() }} {% block auth_content %}{% endblock %} </div> </div> diff --git a/app/translations/en/LC_MESSAGES/messages.mo b/app/translations/en/LC_MESSAGES/messages.mo index 281ff22d9d8631895ba4b8ba974494b4aabe0613..f62402f22ad172315f0a8d535228e6d7f319fa08 100644 GIT binary patch delta 10386 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 OjX&S?|M|+)%>Mv~IZvqo delta 10302 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 UjVSg?Z<gL@@6i#@>boEPAL^$&M*si- diff --git a/app/translations/en/LC_MESSAGES/messages.po b/app/translations/en/LC_MESSAGES/messages.po index 0fce2b3..4055819 100644 --- a/app/translations/en/LC_MESSAGES/messages.po +++ b/app/translations/en/LC_MESSAGES/messages.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: team-tryouts VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2026-08-11 13:32-0400\n" +"POT-Creation-Date: 2026-08-11 15:25-0400\n" "PO-Revision-Date: 2026-08-07 20:22-0400\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Language: en\n" @@ -20,7 +20,7 @@ msgstr "" "Generated-By: Babel 2.18.0\n" #: app/forms.py:37 app/routes/auth.py:224 app/routes/auth.py:374 -#: app/routes/users/contracts.py:95 +#: app/routes/users/contracts.py:96 #, python-format msgid "%(field)s: %(msg)s" msgstr "%(field)s: %(msg)s" @@ -633,11 +633,11 @@ msgstr "User %(full_name)s created as %(role)s!" msgid "Only coaches can manage availability." msgstr "Only coaches can manage availability." -#: app/routes/users/contracts.py:83 +#: app/routes/users/contracts.py:84 msgid "Only presidents, managers, and coaches can upload contracts." msgstr "Only presidents, managers, and coaches can upload contracts." -#: app/routes/users/contracts.py:102 +#: app/routes/users/contracts.py:103 msgid "You do not have permission to upload a contract for this player." msgstr "You do not have permission to upload a contract for this player." @@ -650,15 +650,15 @@ msgstr "Contract uploaded successfully for %(username)s!" msgid "Only the player can upload their signed contract." msgstr "Only the player can upload their signed contract." -#: app/routes/users/contracts.py:176 +#: app/routes/users/contracts.py:175 msgid "Signed contract uploaded successfully!" msgstr "Signed contract uploaded successfully!" -#: app/routes/users/contracts.py:186 app/routes/users/contracts.py:199 +#: app/routes/users/contracts.py:185 app/routes/users/contracts.py:200 msgid "You do not have permission to download this contract." msgstr "You do not have permission to download this contract." -#: app/routes/users/contracts.py:202 +#: app/routes/users/contracts.py:203 msgid "No signed contract available." msgstr "No signed contract available." @@ -881,7 +881,11 @@ msgstr "" "Something went wrong on our end. The error has been logged and will be " "investigated. Please try again later." -#: app/templates/errors/500.html:12 +#: app/templates/errors/500.html:15 +msgid "Reference to quote if you report this:" +msgstr "Reference to quote if you report this:" + +#: app/templates/errors/500.html:18 msgid "Try Again" msgstr "Try Again" @@ -889,78 +893,78 @@ msgstr "Try Again" msgid "Language" msgstr "Language" -#: app/templates/layouts/base.html:33 app/templates/layouts/base.html:139 +#: app/templates/layouts/base.html:48 app/templates/layouts/base.html:154 #: app/templates/pages/dashboard.html:2 app/templates/pages/dashboard.html:3 msgid "Dashboard" msgstr "Dashboard" -#: app/templates/layouts/base.html:39 app/templates/pages/tryouts.html:2 +#: app/templates/layouts/base.html:54 app/templates/pages/tryouts.html:2 #: app/templates/pages/tryouts.html:3 msgid "Tryouts" msgstr "Tryouts" -#: app/templates/layouts/base.html:45 app/templates/pages/calendar.html:2 +#: app/templates/layouts/base.html:60 app/templates/pages/calendar.html:2 #: app/templates/pages/calendar.html:3 msgid "Calendar" msgstr "Calendar" -#: app/templates/layouts/base.html:52 app/templates/pages/dashboard.html:43 +#: app/templates/layouts/base.html:67 app/templates/pages/dashboard.html:43 #: app/templates/pages/evaluations.html:2 #: app/templates/pages/evaluations.html:3 msgid "Evaluations" msgstr "Evaluations" -#: app/templates/layouts/base.html:60 app/templates/pages/my_teams.html:2 +#: app/templates/layouts/base.html:75 app/templates/pages/my_teams.html:2 #: app/templates/pages/my_teams.html:3 msgid "My Team(s)" msgstr "My Team(s)" -#: app/templates/layouts/base.html:67 +#: app/templates/layouts/base.html:82 msgid "Manage Teams" msgstr "Manage Teams" -#: app/templates/layouts/base.html:75 app/templates/pages/users.html:2 +#: app/templates/layouts/base.html:90 app/templates/pages/users.html:2 #: app/templates/pages/users.html:3 msgid "Manage Users" msgstr "Manage Users" -#: app/templates/layouts/base.html:83 +#: app/templates/layouts/base.html:98 #: app/templates/pages/player_personal_notes.html:2 #: app/templates/pages/player_personal_notes.html:3 msgid "My Notes" msgstr "My Notes" -#: app/templates/layouts/base.html:91 +#: app/templates/layouts/base.html:106 msgid "Availability" msgstr "Availability" -#: app/templates/layouts/base.html:97 +#: app/templates/layouts/base.html:112 msgid "Notes & One on One" msgstr "Notes & One on One" -#: app/templates/layouts/base.html:104 app/templates/pages/contracts.html:2 +#: app/templates/layouts/base.html:119 app/templates/pages/contracts.html:2 #: app/templates/pages/contracts.html:3 app/templates/pages/profile.html:9 msgid "Contracts" msgstr "Contracts" -#: app/templates/layouts/base.html:111 app/templates/pages/profile.html:2 +#: app/templates/layouts/base.html:126 app/templates/pages/profile.html:2 #: app/templates/pages/profile.html:3 msgid "My Profile" msgstr "My Profile" -#: app/templates/layouts/base.html:122 +#: app/templates/layouts/base.html:137 msgid "Logout" msgstr "Logout" -#: app/templates/layouts/base.html:143 +#: app/templates/layouts/base.html:158 msgid "Toggle dark mode" msgstr "Toggle dark mode" -#: app/templates/layouts/base.html:155 app/templates/layouts/base.html:174 +#: app/templates/layouts/base.html:170 app/templates/layouts/base.html:189 msgid "Dismiss" msgstr "Dismiss" -#: app/templates/layouts/base.html:184 +#: app/templates/layouts/base.html:199 msgid "Team Tryout Management System" msgstr "Team Tryout Management System" diff --git a/app/translations/fr/LC_MESSAGES/messages.mo b/app/translations/fr/LC_MESSAGES/messages.mo index 5d7872743fcc8f136e4e957bab736f0fe41db45e..e520e0a120149d38cf8c32f25b551fe33c567e3b 100644 GIT binary patch delta 10404 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 delta 10297 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 diff --git a/app/translations/fr/LC_MESSAGES/messages.po b/app/translations/fr/LC_MESSAGES/messages.po index 89827a1..b1dda48 100644 --- a/app/translations/fr/LC_MESSAGES/messages.po +++ b/app/translations/fr/LC_MESSAGES/messages.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: team-tryouts VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2026-08-11 13:32-0400\n" +"POT-Creation-Date: 2026-08-11 15:25-0400\n" "PO-Revision-Date: 2026-08-07 20:22-0400\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Language: fr\n" @@ -20,7 +20,7 @@ msgstr "" "Generated-By: Babel 2.18.0\n" #: app/forms.py:37 app/routes/auth.py:224 app/routes/auth.py:374 -#: app/routes/users/contracts.py:95 +#: app/routes/users/contracts.py:96 #, python-format msgid "%(field)s: %(msg)s" msgstr "%(field)s : %(msg)s" @@ -639,11 +639,11 @@ msgstr "Utilisateur %(full_name)s créé avec le rôle %(role)s." msgid "Only coaches can manage availability." msgstr "Seuls les coachs peuvent gérer leurs disponibilités." -#: app/routes/users/contracts.py:83 +#: app/routes/users/contracts.py:84 msgid "Only presidents, managers, and coaches can upload contracts." msgstr "Seuls les présidents, gérants et coachs peuvent téléverser un contrat." -#: app/routes/users/contracts.py:102 +#: app/routes/users/contracts.py:103 msgid "You do not have permission to upload a contract for this player." msgstr "Vous n’avez pas les droits pour téléverser un contrat pour ce joueur." @@ -656,15 +656,15 @@ msgstr "Contrat téléversé pour %(username)s." msgid "Only the player can upload their signed contract." msgstr "Seul le joueur peut téléverser son contrat signé." -#: app/routes/users/contracts.py:176 +#: app/routes/users/contracts.py:175 msgid "Signed contract uploaded successfully!" msgstr "Contrat signé téléversé." -#: app/routes/users/contracts.py:186 app/routes/users/contracts.py:199 +#: app/routes/users/contracts.py:185 app/routes/users/contracts.py:200 msgid "You do not have permission to download this contract." msgstr "Vous n’avez pas les droits pour télécharger ce contrat." -#: app/routes/users/contracts.py:202 +#: app/routes/users/contracts.py:203 msgid "No signed contract available." msgstr "Aucun contrat signé disponible." @@ -887,7 +887,11 @@ msgstr "" "Une erreur est survenue de notre côté. Elle a été journalisée et sera " "examinée. Veuillez réessayer dans un instant." -#: app/templates/errors/500.html:12 +#: app/templates/errors/500.html:15 +msgid "Reference to quote if you report this:" +msgstr "Référence à indiquer si vous signalez ce problème :" + +#: app/templates/errors/500.html:18 msgid "Try Again" msgstr "Réessayer" @@ -895,78 +899,78 @@ msgstr "Réessayer" msgid "Language" msgstr "Langue" -#: app/templates/layouts/base.html:33 app/templates/layouts/base.html:139 +#: app/templates/layouts/base.html:48 app/templates/layouts/base.html:154 #: app/templates/pages/dashboard.html:2 app/templates/pages/dashboard.html:3 msgid "Dashboard" msgstr "Tableau de bord" -#: app/templates/layouts/base.html:39 app/templates/pages/tryouts.html:2 +#: app/templates/layouts/base.html:54 app/templates/pages/tryouts.html:2 #: app/templates/pages/tryouts.html:3 msgid "Tryouts" msgstr "Sélections" -#: app/templates/layouts/base.html:45 app/templates/pages/calendar.html:2 +#: app/templates/layouts/base.html:60 app/templates/pages/calendar.html:2 #: app/templates/pages/calendar.html:3 msgid "Calendar" msgstr "Calendrier" -#: app/templates/layouts/base.html:52 app/templates/pages/dashboard.html:43 +#: app/templates/layouts/base.html:67 app/templates/pages/dashboard.html:43 #: app/templates/pages/evaluations.html:2 #: app/templates/pages/evaluations.html:3 msgid "Evaluations" msgstr "Évaluations" -#: app/templates/layouts/base.html:60 app/templates/pages/my_teams.html:2 +#: app/templates/layouts/base.html:75 app/templates/pages/my_teams.html:2 #: app/templates/pages/my_teams.html:3 msgid "My Team(s)" msgstr "Mon ou mes équipes" -#: app/templates/layouts/base.html:67 +#: app/templates/layouts/base.html:82 msgid "Manage Teams" msgstr "Gestion des équipes" -#: app/templates/layouts/base.html:75 app/templates/pages/users.html:2 +#: app/templates/layouts/base.html:90 app/templates/pages/users.html:2 #: app/templates/pages/users.html:3 msgid "Manage Users" msgstr "Gestion des utilisateurs" -#: app/templates/layouts/base.html:83 +#: app/templates/layouts/base.html:98 #: app/templates/pages/player_personal_notes.html:2 #: app/templates/pages/player_personal_notes.html:3 msgid "My Notes" msgstr "Mes notes" -#: app/templates/layouts/base.html:91 +#: app/templates/layouts/base.html:106 msgid "Availability" msgstr "Disponibilités" -#: app/templates/layouts/base.html:97 +#: app/templates/layouts/base.html:112 msgid "Notes & One on One" msgstr "Notes et rencontres individuelles" -#: app/templates/layouts/base.html:104 app/templates/pages/contracts.html:2 +#: app/templates/layouts/base.html:119 app/templates/pages/contracts.html:2 #: app/templates/pages/contracts.html:3 app/templates/pages/profile.html:9 msgid "Contracts" msgstr "Contrats" -#: app/templates/layouts/base.html:111 app/templates/pages/profile.html:2 +#: app/templates/layouts/base.html:126 app/templates/pages/profile.html:2 #: app/templates/pages/profile.html:3 msgid "My Profile" msgstr "Mon profil" -#: app/templates/layouts/base.html:122 +#: app/templates/layouts/base.html:137 msgid "Logout" msgstr "Déconnexion" -#: app/templates/layouts/base.html:143 +#: app/templates/layouts/base.html:158 msgid "Toggle dark mode" msgstr "Basculer le mode sombre" -#: app/templates/layouts/base.html:155 app/templates/layouts/base.html:174 +#: app/templates/layouts/base.html:170 app/templates/layouts/base.html:189 msgid "Dismiss" msgstr "Fermer" -#: app/templates/layouts/base.html:184 +#: app/templates/layouts/base.html:199 msgid "Team Tryout Management System" msgstr "Système de gestion des sélections d’équipe" diff --git a/tests/test_request_id.py b/tests/test_request_id.py new file mode 100644 index 0000000..24d53b3 --- /dev/null +++ b/tests/test_request_id.py @@ -0,0 +1,214 @@ +"""Every request has a name, and its log lines carry it (OBS-005). + +Before this, a 500 in errors.log and the lines in app.log that led to it were +related only by their timestamps — which is not a relation once the server is +handling more than one request at a time. And a user saying "it broke when I +clicked save" gave nobody anything to grep for. + +The id is deliberately generated, never read from an inbound header. That is +the test worth reading in this file: accepting one would be convenient for +tracing through nginx, and would also let any caller write arbitrary text — +newlines included — into the log, which is how a log stops being evidence. +""" + +import logging +import re + +import pytest + +from app.logging_config import NO_REQUEST, RequestIdFilter + +ID_PATTERN = re.compile(r'^[0-9a-f]{16}$') + + +class TestTheHeader: + def test_every_response_carries_one(self, client): + response = client.get('/auth/login') + + assert ID_PATTERN.match(response.headers['X-Request-Id']) + + def test_two_requests_get_different_ids(self, client): + first = client.get('/auth/login').headers['X-Request-Id'] + second = client.get('/auth/login').headers['X-Request-Id'] + + assert first != second + + def test_an_error_response_carries_one_too(self, client): + """The case it exists for.""" + response = client.get('/no-such-page') + + assert response.status_code == 404 + assert ID_PATTERN.match(response.headers['X-Request-Id']) + + +class TestInboundHeadersAreIgnored: + """The security property, not a convenience. + + Waitress currently runs with trusted_proxy='*' (OPS-002), so anything in + an inbound header comes from whoever sent the request. + """ + + def test_a_supplied_id_is_not_adopted(self, client): + response = client.get('/auth/login', headers={'X-Request-Id': 'chosen-by-the-caller'}) + + assert response.headers['X-Request-Id'] != 'chosen-by-the-caller' + assert ID_PATTERN.match(response.headers['X-Request-Id']) + + def test_the_id_is_always_hex_so_it_cannot_forge_a_log_line(self, client): + """The property that makes log injection impossible. + + A value carrying a newline writes a second line that looks exactly + like a real log entry — that is how a log stops being evidence. + Since the id is generated from a fixed alphabet rather than taken + from the request, no input reaches the log through this field at all. + + Asserted on the alphabet rather than by sending a newline: Werkzeug's + test client refuses to send such a header, so the attack cannot even + be constructed through it — which proves nothing about the app. + """ + for supplied in ('../../etc/passwd', 'a b c', '<script>', 'x' * 500): + value = client.get('/auth/login', headers={'X-Request-Id': supplied}).headers[ + 'X-Request-Id' + ] + assert ID_PATTERN.match(value), f'{supplied!r} influenced the id' + + +class TestTheFilter: + """RequestIdFilter has to be safe on records emitted from anywhere. + + The Discord bot logs from its own thread, the scheduler from another, and + configure_logging runs before any request exists. A filter that raised + there would take the log down with it. + """ + + @pytest.fixture + def record(self): + return logging.LogRecord('app.test', logging.INFO, __file__, 1, 'hello', None, None) + + def test_outside_a_request_the_record_still_gets_a_value(self, record): + assert RequestIdFilter().filter(record) is True + assert record.request_id == NO_REQUEST + + def test_inside_a_request_it_gets_that_request_id(self, app, record): + with app.test_request_context('/'): + from flask import g + + g.request_id = 'abcdef0123456789' + RequestIdFilter().filter(record) + + assert record.request_id == 'abcdef0123456789' + + def test_a_request_without_the_before_request_hook_does_not_crash(self, app, record): + """g exists but the key does not — the case during app teardown, and + in any test that pushes a bare context.""" + with app.test_request_context('/'): + RequestIdFilter().filter(record) + + assert record.request_id == NO_REQUEST + + def test_the_formatter_never_raises_for_want_of_the_field(self, app, record, caplog): + """The formatter references %(request_id)s. A handler carrying that + format without this filter raises on its first record — which would + turn a logged error into a crash inside the error handler.""" + formatter = logging.Formatter('[%(request_id)s] %(message)s') + RequestIdFilter().filter(record) + + assert formatter.format(record) == f'[{NO_REQUEST}] hello' + + +class TestTheErrorPage: + """Driven through a real failing request rather than by rendering the + template: the id has to survive the whole path — before_request, the + handler, the template — and rendering the file directly would skip all + three.""" + + @pytest.fixture + def exploding_app(self, app): + app.config['PROPAGATE_EXCEPTIONS'] = False + + @app.route('/tests/boom') + def boom(): + raise RuntimeError('deliberate') + + return app + + def test_the_five_hundred_page_quotes_the_reference(self, exploding_app): + """Without it on the page, a user report cannot be tied to a trace.""" + client = exploding_app.test_client() + + response = client.get('/tests/boom') + + assert response.status_code == 500 + page = response.get_data(as_text=True) + assert response.headers['X-Request-Id'] in page + + def test_a_signed_out_visitor_sees_the_page_at_all(self, exploding_app): + """The error templates fill `content`, which used to exist only in + the signed-in branch of the layout: anonymous visitors got the logo + and nothing else, while the <title> still said 500.""" + page = exploding_app.test_client().get('/tests/boom').get_data(as_text=True) + + assert 'error-container' in page + + def test_the_json_paths_carry_it_too(self, exploding_app): + """A fetch that 500s gets the same reference, or the JavaScript half + of the application is untraceable.""" + + @exploding_app.route('/users/api/boom') + def api_boom(): + raise RuntimeError('deliberate') + + response = exploding_app.test_client().get('/users/api/boom') + + assert response.status_code == 500 + assert response.get_json()['request_id'] == response.headers['X-Request-Id'] + + +class TestErrorPagesSpeakToEveryone: + """Every error page must say something, signed in or not (found while + adding the reference above). + + The failure mode is quiet by construction: the <title> comes from a block + outside the branch, so the tab said "404 — Page Not Found" over a page + that carried no message. The status code was right, the logs were right, + and the page was blank. + """ + + ERRORS = { + 400: '/tests/error/400', + 403: '/tests/error/403', + 404: '/no-such-page-anywhere', + 500: '/tests/error/500', + } + + @pytest.fixture + def erroring_app(self, app): + from flask import abort + + app.config['PROPAGATE_EXCEPTIONS'] = False + + @app.route('/tests/error/<int:code>') + def raise_error(code): + if code == 500: + raise RuntimeError('deliberate') + abort(code) + + return app + + @pytest.mark.parametrize('code', sorted(ERRORS)) + def test_a_signed_out_visitor_gets_the_message(self, erroring_app, code): + response = erroring_app.test_client().get(self.ERRORS[code]) + + assert response.status_code == code + page = response.get_data(as_text=True) + assert 'error-container' in page, f'{code} renders no body for a signed-out visitor' + + @pytest.mark.parametrize('code', sorted(ERRORS)) + def test_a_signed_in_visitor_gets_it_once_and_not_twice(self, erroring_app, as_role, code): + """`self.content()` sits in the other branch of the same `if`, so it + can never double-render — asserted rather than assumed.""" + as_role('player') + + page = erroring_app.test_client().get(self.ERRORS[code]).get_data(as_text=True) + + assert page.count('error-container') <= 1