feat(obs): nommer chaque requete, et rendre les pages d erreur audibles

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 <title> 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.
This commit is contained in:
GGThed
2026-08-11 15:30:05 -04:00
parent 7b9eee4805
commit 70db8a7491
9 changed files with 363 additions and 51 deletions
+29 -1
View File
@@ -249,6 +249,27 @@ def create_app(config=None):
# unconditionally so templates can carry nonce="" beforehand. # unconditionally so templates can carry nonce="" beforehand.
g.csp_nonce = secrets.token_urlsafe(16) 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 @app.url_defaults
def version_static_urls(endpoint, values): def version_static_urls(endpoint, values):
"""Stamp every static URL with the file's modification time. """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 # Roll back any failed database session
db.session.rollback() 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( if request.path.startswith('/users/disponibilities') or request.path.startswith(
'/users/api/' '/users/api/'
): ):
@@ -540,9 +567,10 @@ def create_app(config=None):
{ {
'error': 'Internal server error', 'error': 'Internal server error',
'message': 'An unexpected error occurred. Please try again later.', 'message': 'An unexpected error occurred. Please try again later.',
'request_id': request_id,
} }
), 500 ), 500
return render_template('errors/500.html'), 500 return render_template('errors/500.html', request_id=request_id), 500
@app.errorhandler(HTTPException) @app.errorhandler(HTTPException)
def handle_http_exception(error): def handle_http_exception(error):
+44 -2
View File
@@ -14,6 +14,37 @@ import os
import re import re
from logging.handlers import RotatingFileHandler 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): class SensitiveDataFilter(logging.Filter):
"""Logging filter that redacts sensitive information from log messages. """Logging filter that redacts sensitive information from log messages.
@@ -95,10 +126,17 @@ def configure_logging(app):
# Create the sensitive data filter # Create the sensitive data filter
sensitive_filter = SensitiveDataFilter() 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( 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.setLevel(logging.ERROR)
error_handler.setFormatter(formatter) error_handler.setFormatter(formatter)
error_handler.addFilter(sensitive_filter) error_handler.addFilter(sensitive_filter)
error_handler.addFilter(request_id_filter)
app.logger.addHandler(error_handler) app.logger.addHandler(error_handler)
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
@@ -125,6 +164,7 @@ def configure_logging(app):
auth_handler.setLevel(logging.INFO) auth_handler.setLevel(logging.INFO)
auth_handler.setFormatter(formatter) auth_handler.setFormatter(formatter)
auth_handler.addFilter(sensitive_filter) auth_handler.addFilter(sensitive_filter)
auth_handler.addFilter(request_id_filter)
# Create a named logger specifically for auth events # Create a named logger specifically for auth events
auth_logger = logging.getLogger('team_tryouts.auth') auth_logger = logging.getLogger('team_tryouts.auth')
@@ -143,6 +183,7 @@ def configure_logging(app):
app_handler.setLevel(log_level) app_handler.setLevel(log_level)
app_handler.setFormatter(formatter) app_handler.setFormatter(formatter)
app_handler.addFilter(sensitive_filter) app_handler.addFilter(sensitive_filter)
app_handler.addFilter(request_id_filter)
app.logger.addHandler(app_handler) 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.setLevel(logging.DEBUG if debug_mode else log_level)
console_handler.setFormatter(formatter) console_handler.setFormatter(formatter)
console_handler.addFilter(sensitive_filter) console_handler.addFilter(sensitive_filter)
console_handler.addFilter(request_id_filter)
app.logger.addHandler(console_handler) app.logger.addHandler(console_handler)
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
+6
View File
@@ -8,6 +8,12 @@
</div> </div>
<h2>{{ _('500 — Internal Server Error') }}</h2> <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> <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"> <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') }} <i class="fas fa-redo-alt"></i> {{ _('Try Again') }}
</a> </a>
+14
View File
@@ -201,6 +201,20 @@
<div class="auth-language"> <div class="auth-language">
{% include "layouts/_language_switcher.html" %} {% include "layouts/_language_switcher.html" %}
</div> </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 %} {% block auth_content %}{% endblock %}
</div> </div>
</div> </div>
Binary file not shown.
+28 -24
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: team-tryouts VERSION\n" "Project-Id-Version: team-tryouts VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\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" "PO-Revision-Date: 2026-08-07 20:22-0400\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: en\n" "Language: en\n"
@@ -20,7 +20,7 @@ msgstr ""
"Generated-By: Babel 2.18.0\n" "Generated-By: Babel 2.18.0\n"
#: app/forms.py:37 app/routes/auth.py:224 app/routes/auth.py:374 #: 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 #, python-format
msgid "%(field)s: %(msg)s" msgid "%(field)s: %(msg)s"
msgstr "%(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." msgid "Only coaches can manage availability."
msgstr "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." msgid "Only presidents, managers, and coaches can upload contracts."
msgstr "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." 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." 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." msgid "Only the player can upload their signed contract."
msgstr "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!" msgid "Signed contract uploaded successfully!"
msgstr "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." msgid "You do not have permission to download this contract."
msgstr "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." msgid "No signed contract available."
msgstr "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 " "Something went wrong on our end. The error has been logged and will be "
"investigated. Please try again later." "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" msgid "Try Again"
msgstr "Try Again" msgstr "Try Again"
@@ -889,78 +893,78 @@ msgstr "Try Again"
msgid "Language" msgid "Language"
msgstr "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 #: app/templates/pages/dashboard.html:2 app/templates/pages/dashboard.html:3
msgid "Dashboard" msgid "Dashboard"
msgstr "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 #: app/templates/pages/tryouts.html:3
msgid "Tryouts" msgid "Tryouts"
msgstr "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 #: app/templates/pages/calendar.html:3
msgid "Calendar" msgid "Calendar"
msgstr "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:2
#: app/templates/pages/evaluations.html:3 #: app/templates/pages/evaluations.html:3
msgid "Evaluations" msgid "Evaluations"
msgstr "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 #: app/templates/pages/my_teams.html:3
msgid "My Team(s)" msgid "My Team(s)"
msgstr "My Team(s)" msgstr "My Team(s)"
#: app/templates/layouts/base.html:67 #: app/templates/layouts/base.html:82
msgid "Manage Teams" msgid "Manage Teams"
msgstr "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 #: app/templates/pages/users.html:3
msgid "Manage Users" msgid "Manage Users"
msgstr "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:2
#: app/templates/pages/player_personal_notes.html:3 #: app/templates/pages/player_personal_notes.html:3
msgid "My Notes" msgid "My Notes"
msgstr "My Notes" msgstr "My Notes"
#: app/templates/layouts/base.html:91 #: app/templates/layouts/base.html:106
msgid "Availability" msgid "Availability"
msgstr "Availability" msgstr "Availability"
#: app/templates/layouts/base.html:97 #: app/templates/layouts/base.html:112
msgid "Notes & One on One" msgid "Notes & One on One"
msgstr "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 #: app/templates/pages/contracts.html:3 app/templates/pages/profile.html:9
msgid "Contracts" msgid "Contracts"
msgstr "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 #: app/templates/pages/profile.html:3
msgid "My Profile" msgid "My Profile"
msgstr "My Profile" msgstr "My Profile"
#: app/templates/layouts/base.html:122 #: app/templates/layouts/base.html:137
msgid "Logout" msgid "Logout"
msgstr "Logout" msgstr "Logout"
#: app/templates/layouts/base.html:143 #: app/templates/layouts/base.html:158
msgid "Toggle dark mode" msgid "Toggle dark mode"
msgstr "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" msgid "Dismiss"
msgstr "Dismiss" msgstr "Dismiss"
#: app/templates/layouts/base.html:184 #: app/templates/layouts/base.html:199
msgid "Team Tryout Management System" msgid "Team Tryout Management System"
msgstr "Team Tryout Management System" msgstr "Team Tryout Management System"
Binary file not shown.
+28 -24
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: team-tryouts VERSION\n" "Project-Id-Version: team-tryouts VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\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" "PO-Revision-Date: 2026-08-07 20:22-0400\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: fr\n" "Language: fr\n"
@@ -20,7 +20,7 @@ msgstr ""
"Generated-By: Babel 2.18.0\n" "Generated-By: Babel 2.18.0\n"
#: app/forms.py:37 app/routes/auth.py:224 app/routes/auth.py:374 #: 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 #, python-format
msgid "%(field)s: %(msg)s" msgid "%(field)s: %(msg)s"
msgstr "%(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." msgid "Only coaches can manage availability."
msgstr "Seuls les coachs peuvent gérer leurs disponibilités." 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." msgid "Only presidents, managers, and coaches can upload contracts."
msgstr "Seuls les présidents, gérants et coachs peuvent téléverser un contrat." 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." msgid "You do not have permission to upload a contract for this player."
msgstr "Vous navez pas les droits pour téléverser un contrat pour ce joueur." msgstr "Vous navez 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." msgid "Only the player can upload their signed contract."
msgstr "Seul le joueur peut téléverser son contrat signé." 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!" msgid "Signed contract uploaded successfully!"
msgstr "Contrat signé téléversé." 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." msgid "You do not have permission to download this contract."
msgstr "Vous navez pas les droits pour télécharger ce contrat." msgstr "Vous navez 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." msgid "No signed contract available."
msgstr "Aucun contrat signé disponible." 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 " "Une erreur est survenue de notre côté. Elle a été journalisée et sera "
"examinée. Veuillez réessayer dans un instant." "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" msgid "Try Again"
msgstr "Réessayer" msgstr "Réessayer"
@@ -895,78 +899,78 @@ msgstr "Réessayer"
msgid "Language" msgid "Language"
msgstr "Langue" 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 #: app/templates/pages/dashboard.html:2 app/templates/pages/dashboard.html:3
msgid "Dashboard" msgid "Dashboard"
msgstr "Tableau de bord" 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 #: app/templates/pages/tryouts.html:3
msgid "Tryouts" msgid "Tryouts"
msgstr "Sélections" 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 #: app/templates/pages/calendar.html:3
msgid "Calendar" msgid "Calendar"
msgstr "Calendrier" 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:2
#: app/templates/pages/evaluations.html:3 #: app/templates/pages/evaluations.html:3
msgid "Evaluations" msgid "Evaluations"
msgstr "Évaluations" 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 #: app/templates/pages/my_teams.html:3
msgid "My Team(s)" msgid "My Team(s)"
msgstr "Mon ou mes équipes" msgstr "Mon ou mes équipes"
#: app/templates/layouts/base.html:67 #: app/templates/layouts/base.html:82
msgid "Manage Teams" msgid "Manage Teams"
msgstr "Gestion des équipes" 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 #: app/templates/pages/users.html:3
msgid "Manage Users" msgid "Manage Users"
msgstr "Gestion des utilisateurs" 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:2
#: app/templates/pages/player_personal_notes.html:3 #: app/templates/pages/player_personal_notes.html:3
msgid "My Notes" msgid "My Notes"
msgstr "Mes notes" msgstr "Mes notes"
#: app/templates/layouts/base.html:91 #: app/templates/layouts/base.html:106
msgid "Availability" msgid "Availability"
msgstr "Disponibilités" msgstr "Disponibilités"
#: app/templates/layouts/base.html:97 #: app/templates/layouts/base.html:112
msgid "Notes & One on One" msgid "Notes & One on One"
msgstr "Notes et rencontres individuelles" 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 #: app/templates/pages/contracts.html:3 app/templates/pages/profile.html:9
msgid "Contracts" msgid "Contracts"
msgstr "Contrats" 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 #: app/templates/pages/profile.html:3
msgid "My Profile" msgid "My Profile"
msgstr "Mon profil" msgstr "Mon profil"
#: app/templates/layouts/base.html:122 #: app/templates/layouts/base.html:137
msgid "Logout" msgid "Logout"
msgstr "Déconnexion" msgstr "Déconnexion"
#: app/templates/layouts/base.html:143 #: app/templates/layouts/base.html:158
msgid "Toggle dark mode" msgid "Toggle dark mode"
msgstr "Basculer le mode sombre" 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" msgid "Dismiss"
msgstr "Fermer" msgstr "Fermer"
#: app/templates/layouts/base.html:184 #: app/templates/layouts/base.html:199
msgid "Team Tryout Management System" msgid "Team Tryout Management System"
msgstr "Système de gestion des sélections d’équipe" msgstr "Système de gestion des sélections d’équipe"
+214
View File
@@ -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