Quatre constats de la liste des gains rapides, tous sur auth.py. SEC-017 -- enumeration de comptes Le formulaire repondait "il vous reste 3 tentative(s)" a un compte connu et "verifiez le nom d utilisateur et le mot de passe" a un inconnu. Le decompte lui-meme etait la fuite : la meme information, etalee sur cinq requetes. S y ajoutait un ecart de temps de reponse, check_password n etant appele que si la ligne existait -- scrypt est cher, l ecart est mesurable. Un seul message pour tous les echecs, et la verification s execute desormais sur les deux branches : contre un hachage aleatoire tire une fois par processus quand l identifiant n existe pas. SEC-018 -- verrou de compte declenchable par un tiers Cinq mauvaises reponses mettaient un compte connu hors service pendant quinze minutes, indefiniment renouvelables. Sur un compte president, c est toute l administration, et aucun ecran ne permettait de defaire. Le compteur et la fenetre restent -- ce sont la trace qu un administrateur lit quand un compte est pilonne, et la fenetre double jusqu a un plafond. Ce qui change : de bons identifiants passent, fenetre ouverte ou non, et remettent le compteur a zero. Le proprietaire du compte ne peut plus etre bloque par un tiers. Ce que cela coute, dit franchement : un verrou dur n arretait de toute facon pas un attaquant ayant trouve le mot de passe -- il lui suffisait d attendre. Le debit de tentatives reste borne par la limite de 10/minute par IP. Une limite par couple (compte, IP) demanderait un stockage dedie ; elle attend Alembic. L evenement account.locked devient account.throttled : "locked" affirmait plus que ce qui se passe. SEC-019 -- deconnexion en GET /auth/logout n avait pas de methods, donc GET, donc hors protection CSRF : n importe quelle page pouvait deconnecter un visiteur avec une balise img. La route passe en POST et l entree de navigation devient un formulaire avec jeton. Le style suit -- les regles .nav-links visaient les liens seuls. SEC-020 -- validation de redirection is_safe_url interrogeait urlparse().netloc. urlparse lit /\evil.com comme un chemin, sans netloc ; plusieurs navigateurs normalisent l antislash en barre oblique avant de resoudre, ce qui en fait //evil.com. La fonction refuse maintenant antislash et caracteres de controle, exige un chemin enracine, et compare l origine explicitement. Le xfail(strict) qui documentait SEC-017 est leve. 40 tests dans test_auth_session.py, dont la table des cibles refusees. Co-Authored-By: Claude Opus 5 <[email protected]>
293 lines
11 KiB
Python
293 lines
11 KiB
Python
"""Language selection and translation.
|
|
|
|
French is the primary language of the site; English stays available.
|
|
|
|
Source strings remain in English and act as gettext message ids, with the
|
|
French wording supplied by a catalogue. A string not yet translated
|
|
degrades to English rather than to a raw identifier — which is what lets
|
|
the migration proceed template by template without ever leaving the site
|
|
half broken.
|
|
"""
|
|
|
|
import pytest
|
|
|
|
from app.i18n import DEFAULT_LOCALE, SUPPORTED_LOCALES, select_locale
|
|
|
|
|
|
class TestDefaults:
|
|
def test_french_is_the_default_language(self):
|
|
assert DEFAULT_LOCALE == 'fr'
|
|
|
|
def test_english_is_available(self):
|
|
assert 'en' in SUPPORTED_LOCALES
|
|
|
|
def test_a_visitor_without_preferences_gets_french(self, client):
|
|
body = client.get('/auth/login').get_data(as_text=True)
|
|
|
|
assert 'lang="fr"' in body
|
|
assert 'Se connecter' in body
|
|
|
|
def test_the_html_lang_attribute_follows_the_locale(self, client):
|
|
english = client.get('/auth/login',
|
|
headers={'Accept-Language': 'en-CA,en;q=0.9'})
|
|
assert 'lang="en"' in english.get_data(as_text=True)
|
|
|
|
|
|
class TestBrowserNegotiation:
|
|
def test_an_english_browser_is_served_english(self, client):
|
|
body = client.get('/auth/login',
|
|
headers={'Accept-Language': 'en-CA,en;q=0.9'}
|
|
).get_data(as_text=True)
|
|
assert 'Sign In' in body
|
|
|
|
def test_an_unsupported_language_falls_back_to_french(self, client):
|
|
body = client.get('/auth/login',
|
|
headers={'Accept-Language': 'de-DE,de;q=0.9'}
|
|
).get_data(as_text=True)
|
|
assert 'Se connecter' in body
|
|
|
|
def test_a_french_browser_is_served_french(self, client):
|
|
body = client.get('/auth/login',
|
|
headers={'Accept-Language': 'fr-CA,fr;q=0.9'}
|
|
).get_data(as_text=True)
|
|
assert 'Se connecter' in body
|
|
|
|
|
|
class TestExplicitSwitch:
|
|
def test_switching_to_english_changes_the_page(self, client):
|
|
client.get('/lang/en')
|
|
body = client.get('/auth/login').get_data(as_text=True)
|
|
|
|
assert 'Sign In' in body
|
|
assert 'lang="en"' in body
|
|
|
|
def test_an_explicit_choice_overrides_the_browser_header(self, client):
|
|
"""Someone on an English machine who picks French must keep French."""
|
|
client.get('/lang/fr')
|
|
|
|
body = client.get('/auth/login',
|
|
headers={'Accept-Language': 'en-CA,en;q=0.9'}
|
|
).get_data(as_text=True)
|
|
|
|
assert 'Se connecter' in body
|
|
assert 'lang="fr"' in body
|
|
|
|
def test_the_choice_persists_across_requests(self, client):
|
|
client.get('/lang/en')
|
|
for _ in range(3):
|
|
assert 'Sign In' in client.get('/auth/login').get_data(as_text=True)
|
|
|
|
def test_an_unsupported_locale_is_refused(self, client):
|
|
client.get('/lang/en')
|
|
client.get('/lang/de')
|
|
|
|
body = client.get('/auth/login').get_data(as_text=True)
|
|
assert 'Sign In' in body, 'an unsupported code must not change the locale'
|
|
|
|
def test_the_switcher_redirects_back_to_the_referring_page(self, client):
|
|
response = client.get('/lang/en',
|
|
headers={'Referer': 'http://localhost/auth/register'},
|
|
follow_redirects=False)
|
|
assert response.headers['Location'].endswith('/auth/register')
|
|
|
|
def test_an_external_referer_is_not_followed(self, client):
|
|
"""An unchecked Referer would make this an open redirect."""
|
|
response = client.get('/lang/en',
|
|
headers={'Referer': 'https://evil.test/phishing'},
|
|
follow_redirects=False)
|
|
assert 'evil.test' not in response.headers['Location']
|
|
|
|
|
|
class TestSwitcherAvailability:
|
|
def test_the_switcher_is_visible_before_signing_in(self, client):
|
|
"""Someone who cannot read the current language has to be able to
|
|
change it without signing in first."""
|
|
body = client.get('/auth/login').get_data(as_text=True)
|
|
|
|
assert 'English' in body
|
|
assert '/lang/en' in body
|
|
|
|
def test_the_switcher_is_visible_once_signed_in(self, client, as_role):
|
|
as_role('player')
|
|
body = client.get('/users/profile').get_data(as_text=True)
|
|
|
|
assert 'English' in body
|
|
assert '/lang/en' in body
|
|
|
|
def test_each_language_is_named_in_its_own_language(self, client):
|
|
body = client.get('/auth/login').get_data(as_text=True)
|
|
assert 'English' in body
|
|
|
|
client.get('/lang/en')
|
|
body = client.get('/auth/login').get_data(as_text=True)
|
|
assert 'Français' in body
|
|
|
|
|
|
class TestTranslatedContent:
|
|
def test_navigation_is_translated(self, client, as_role):
|
|
as_role('player')
|
|
body = client.get('/users/profile').get_data(as_text=True)
|
|
|
|
assert 'Tableau de bord' in body
|
|
assert 'Déconnexion' in body
|
|
|
|
def test_error_pages_are_translated(self, client):
|
|
body = client.get('/no-such-page').get_data(as_text=True)
|
|
assert 'Page introuvable' in body
|
|
|
|
@pytest.mark.parametrize('locale,expected', [
|
|
('fr', 'Ce compte a été désactivé.'),
|
|
('en', 'This account has been deactivated.'),
|
|
])
|
|
def test_flash_messages_are_translated(
|
|
self, app, client, make_user, login, locale, expected
|
|
):
|
|
from app.extensions import db
|
|
from app.models import User
|
|
|
|
user_id = make_user('player')
|
|
with app.app_context():
|
|
user = db.session.get(User, user_id)
|
|
user.is_active_account = False
|
|
username = user.username
|
|
db.session.commit()
|
|
|
|
client.get(f'/lang/{locale}')
|
|
body = login(username).get_data(as_text=True)
|
|
|
|
assert expected in body
|
|
|
|
|
|
class TestCatalogueIntegrity:
|
|
"""A missing compiled catalogue is invisible at runtime: the site simply
|
|
serves English everywhere. Worth failing a build over."""
|
|
|
|
@pytest.mark.parametrize('locale', SUPPORTED_LOCALES)
|
|
def test_the_compiled_catalogue_exists(self, locale):
|
|
import os
|
|
|
|
path = os.path.join(
|
|
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
|
'app', 'translations', locale, 'LC_MESSAGES', 'messages.mo',
|
|
)
|
|
assert os.path.exists(path), (
|
|
f'{locale} catalogue is not compiled: run '
|
|
'`pybabel compile -d app/translations`'
|
|
)
|
|
|
|
@pytest.mark.parametrize('locale', SUPPORTED_LOCALES)
|
|
def test_every_message_is_translated(self, locale):
|
|
import io
|
|
import os
|
|
|
|
from babel.messages.pofile import read_po
|
|
|
|
path = os.path.join(
|
|
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
|
'app', 'translations', locale, 'LC_MESSAGES', 'messages.po',
|
|
)
|
|
with io.open(path, encoding='utf-8') as handle:
|
|
catalog = read_po(handle, locale=locale)
|
|
|
|
untranslated = [m.id for m in catalog if m.id and not m.string]
|
|
assert not untranslated, (
|
|
f'{len(untranslated)} untranslated string(s) in {locale}: '
|
|
f'{untranslated[:5]}'
|
|
)
|
|
|
|
|
|
class TestSelectorUnit:
|
|
def test_select_locale_returns_a_supported_code(self, app):
|
|
with app.test_request_context('/'):
|
|
assert select_locale() in SUPPORTED_LOCALES
|
|
|
|
|
|
class TestLocaleSurvivesSessionRotation:
|
|
"""Login and logout both call session.clear() — the language choice is a
|
|
display preference, not state belonging to the account, and used to be
|
|
discarded along with everything else.
|
|
|
|
Concretely: someone who read the login page in English and signed in was
|
|
dropped straight back into French.
|
|
"""
|
|
|
|
def test_the_choice_survives_logging_in(self, client, as_role):
|
|
client.get('/lang/en')
|
|
as_role('player')
|
|
|
|
body = client.get('/users/profile').get_data(as_text=True)
|
|
assert 'lang="en"' in body
|
|
assert 'Dashboard' in body
|
|
|
|
def test_the_choice_survives_logging_out(self, client, as_role):
|
|
client.get('/lang/en')
|
|
as_role('player')
|
|
client.post('/auth/logout')
|
|
|
|
body = client.get('/auth/login').get_data(as_text=True)
|
|
assert 'lang="en"' in body
|
|
assert 'Sign In' in body
|
|
|
|
def test_french_also_survives_logging_in(self, client, as_role):
|
|
"""The English browser case: an explicit French choice must hold."""
|
|
client.get('/lang/fr')
|
|
as_role('player')
|
|
|
|
body = client.get('/users/profile',
|
|
headers={'Accept-Language': 'en-CA,en;q=0.9'}
|
|
).get_data(as_text=True)
|
|
assert 'lang="fr"' in body
|
|
|
|
def test_the_csrf_token_is_still_preserved(self, app, client, make_user, login):
|
|
"""Guard against fixing one preserved key by dropping the other.
|
|
|
|
Seeded by hand rather than relying on Flask-WTF: the test fixture
|
|
runs with CSRF disabled, so no token would exist to preserve.
|
|
"""
|
|
from app.extensions import db
|
|
from app.models import User
|
|
|
|
user_id = make_user('player')
|
|
with app.app_context():
|
|
username = db.session.get(User, user_id).username
|
|
|
|
with client.session_transaction() as sess:
|
|
sess['csrf_token'] = 'sentinel-token'
|
|
sess['locale'] = 'en'
|
|
|
|
login(username)
|
|
|
|
with client.session_transaction() as sess:
|
|
assert sess.get('csrf_token') == 'sentinel-token'
|
|
assert sess.get('locale') == 'en'
|
|
|
|
|
|
class TestFlashMessagesAreTranslated:
|
|
def test_an_access_refusal_is_translated(self, client, as_role):
|
|
as_role('player')
|
|
body = client.get('/users', follow_redirects=True).get_data(as_text=True)
|
|
assert 'Seul le président peut gérer les utilisateurs.' in body
|
|
|
|
def test_a_validation_message_is_translated(self, client, as_role):
|
|
"""From validators.py, which needs lazy_gettext: schema fields are
|
|
built at import time, before any request exists."""
|
|
as_role('admin')
|
|
|
|
body = client.post('/users/create', data={
|
|
'username': 'x', 'email': 'not-an-email',
|
|
'password': 'a', 'full_name': 'X', 'role': 'coach',
|
|
}, follow_redirects=True).get_data(as_text=True)
|
|
|
|
assert 'Le nom d' in body and 'utilisateur doit compter' in body
|
|
|
|
def test_a_message_with_a_value_keeps_it(self, client, as_role):
|
|
as_role('admin')
|
|
|
|
body = client.post('/users/create', data={
|
|
'username': 'recrue', 'email': '[email protected]',
|
|
'password': 'Password123', 'full_name': 'Nouvelle Recrue', 'role': 'coach',
|
|
}, follow_redirects=True).get_data(as_text=True)
|
|
|
|
assert 'Nouvelle Recrue' in body
|
|
assert 'créé avec le rôle coach' in body
|