Files
team-tryouts/tests/test_security_headers.py
T
GGThedandClaude Opus 5 1b990a84d9 test: socle de tests executables et fabrique d'application parametrable
Il n'existait aucun test, et le code n'offrait aucune prise pour en ecrire :
create_app() exigeait SECRET_KEY et DATABASE_URL dans l'environnement,
creait les tables et demarrait un bot Discord. C'etait la cause, pas le
symptome.

create_app(config=None)
  Les valeurs par defaut viennent toujours de l'environnement, les
  surcharges de l'appelant sont appliquees ensuite, et la validation
  vient en dernier pour qu'un test puisse fournir les siennes. Deux
  effets de bord passent sous drapeau, actifs par defaut pour que la
  production et le developpement se comportent a l'identique :
    AUTO_CREATE_TABLES   controle db.create_all()
    ENABLE_DISCORD_BOT   controle start_bot()
  FORCE_HTTPS passe egalement en configuration : lu via os.getenv a
  chaque requete, il renvoyait un 301 sur tout appel de test.

Suite de tests : 47 tests, 3 xfail, 32 % de couverture.
  tests/conftest.py            fabriques par role, connexion par le vrai
                               formulaire, base SQLite temporaire
  test_auth_session.py         expiration de session, desactivation de
                               compte, deconnexion
  test_security_headers.py     en-tetes, non-divulgation sur /health,
                               echappement de nl2br
  test_authorization.py        acces anonyme, vertical, horizontal,
                               validation des entrees, CSRF

Les tests marques xfail(strict=True) decrivent des constats non encore
corriges. Ils echouent par construction ; le mode strict transforme une
reussite inattendue en echec, ce qui signale qu'il faut retirer le
marqueur. Trois subsistent : enumeration de comptes (SEC-AUTH-006), CSP
unsafe-inline (SEC-WEB-001), auto-retrogradation du dernier administrateur
(SEC-AUTHZ-007).

pyproject.toml
  Configuration pytest et ruff. Ruff n'avait aucune configuration : la CI
  l'executait avec le jeu de regles par defaut. Les 33 F401 de
  app/models/__init__.py sont ignores par fichier, c'est une facade de
  re-export intentionnelle.

requirements-dev.txt separe l'outillage de test des dependances de
production.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-07 19:46:34 -04:00

83 lines
3.1 KiB
Python

"""HTTP hardening, error disclosure, and template escaping."""
import pytest
from app.app import nl2br
class TestSecurityHeaders:
def test_core_headers_are_present(self, client):
headers = client.get('/auth/login').headers
assert headers['X-Content-Type-Options'] == 'nosniff'
assert headers['X-Frame-Options'] == 'DENY'
assert headers['Referrer-Policy'] == 'strict-origin-when-cross-origin'
assert 'frame-ancestors' in headers['Content-Security-Policy']
def test_deprecated_xss_auditor_header_is_not_sent(self, client):
"""X-XSS-Protection was removed: deprecated, and harmful in its
last implementations."""
assert 'X-XSS-Protection' not in client.get('/auth/login').headers
@pytest.mark.xfail(
strict=True,
reason="SEC-WEB-001: 15 inline <script> blocks still require "
"'unsafe-inline'; lifting it is tracked as OPS-010",
)
def test_csp_does_not_allow_inline_script(self, client):
csp = client.get('/auth/login').headers['Content-Security-Policy']
script_src = [d for d in csp.split(';') if d.strip().startswith('script-src')][0]
assert "'unsafe-inline'" not in script_src
class TestErrorDisclosure:
def test_health_reports_status_without_the_driver_message(self, app, client):
"""/health is unauthenticated. Driver exceptions routinely carry the
host, database name and user of the connection string."""
from app.extensions import db
def boom(*args, **kwargs):
raise RuntimeError(
'FATAL: password authentication failed for user "app" '
'host=db.internal port=5432'
)
original = db.session.execute
db.session.execute = boom
try:
response = client.get('/health')
finally:
db.session.execute = original
assert response.status_code == 503
body = response.get_data(as_text=True)
assert response.get_json()['database'] == 'error'
for leaked in ('password', 'db.internal', '5432', 'FATAL'):
assert leaked not in body, f'{leaked!r} leaked through /health'
def test_health_reports_healthy_when_the_database_answers(self, client):
response = client.get('/health')
assert response.status_code == 200
assert response.get_json()['database'] == 'connected'
class TestTemplateEscaping:
def test_nl2br_escapes_markup(self, app):
"""Markup('<br>'.join(...)) marked attacker text as safe.
Markup('<br>').join(...) escapes each segment first."""
with app.app_context():
rendered = str(nl2br('<script>alert(1)</script>\nsecond'))
assert '<script>' not in rendered
assert '&lt;script&gt;' in rendered
assert '<br>' in rendered, 'the line break itself must stay markup'
def test_nl2br_still_joins_lines(self, app):
with app.app_context():
assert str(nl2br('a\nb')) == 'a<br>b'
def test_nl2br_handles_empty_input(self, app):
with app.app_context():
assert nl2br('') == ''
assert nl2br(None) == ''