Semaine 16 creation

This commit is contained in:
gauvainboiche
2026-11-02 09:41:22 +01:00
parent b6e66e7c0b
commit 566839415e
17 changed files with 1641 additions and 0 deletions
@@ -0,0 +1,509 @@
# Atelier — Développer une application web sécurisée en Python
**Fil rouge : Bloc&Co, salle d'escalade.**
La salle veut gérer ses adhérents en ligne : inscription, connexion, espace
personnel, et un accès réservé au personnel.
À la fin de l'atelier, vous saurez construire **seul** un module de gestion
d'utilisateurs sécurisé : c'est la brique de base de presque toutes les
applications web, et elle se transpose à n'importe quel domaine métier.
---
## Ce qui est fourni
| Fichier | Contenu |
|---|---|
| `starter/app.py` | La plomberie de la base, et les fonctions à compléter |
| `starter/schema.sql` | La table `adherents`. Ne pas la modifier. |
| `starter/templates/` | Tout le HTML, déjà écrit |
| `starter/static/style.css` | La feuille de style |
| `test_app.py` | Les tests. **Un exercice est réussi quand ses tests passent.** |
## Installation (10 minutes, à faire en premier)
```
py -m venv .venv
.venv\Scripts\activate
pip install flask pytest
python app.py
```
Sur macOS ou Linux, remplacez les deux premières lignes par :
`python3 -m venv .venv` puis `source .venv/bin/activate`
Ouvrez http://127.0.0.1:5000 — la page d'accueil doit s'afficher.
Un compte administrateur existe déjà : `admin@blocandco.fr` / `Admin2026!`
Pour lancer les tests : `pytest -v`
Au départ, la plupart échouent. C'est normal : c'est votre feuille de route.
---
# Partie A — Les bases du web en Python
## Exercice 0 — Monter un projet de zéro
**Notions** : environnement virtuel, dépendances, arborescence d'un projet Flask.
> Le jour de l'épreuve, vous ne saurez qu'en lisant le sujet si un dépôt vous
> est fourni ou si vous partez de rien. **Entraînez-vous aux deux.**
**A. Page blanche.** Sans rien copier, dans un dossier neuf, créez un projet
Flask qui démarre : environnement virtuel, installation, `app.py`, une route
qui affiche un texte. Chronométrez-vous.
```
Objectif : moins de 10 minutes, sans consulter de documentation.
Recommencez demain dans un autre dossier, sans regarder vos notes.
```
**B. Projet existant.** Ouvrez le dossier `starter/` et, **avant de coder
quoi que ce soit**, remplissez ce tableau :
| Question | Votre réponse |
|---|---|
| Combien de routes sont déclarées ? | |
| Où le mot de passe est-il haché ? | |
| Quelles fonctions sont vides ? | |
| Que fait `@app.teardown_appcontext` ? | |
| L'application démarre-t-elle en l'état ? | |
**Pourquoi cet exercice** : reprendre du code écrit par quelqu'un d'autre est
la situation normale en entreprise, et la moitié des candidats perdent une
heure parce qu'ils ont commencé à taper avant d'avoir lu.
La fiche `MEMO-JOUR-J.md` récapitule les deux démarches. Gardez-la ouverte
pendant l'épreuve.
---
## Exercice 1 — Votre première page
**Notions** : route, fonction de vue, serveur de développement.
Créez un fichier `essai.py` séparé contenant une application Flask minimale
avec une route `/bonjour` qui renvoie le texte `Bonjour Bloc&Co`.
Lancez-la, ouvrez-la dans le navigateur.
Ajoutez ensuite une route `/bonjour/<prenom>` qui affiche `Bonjour <prenom>`.
```
http://127.0.0.1:5000/bonjour → Bonjour Bloc&Co
http://127.0.0.1:5000/bonjour/Lea → Bonjour Lea
```
**Erreurs attendues** : oublier `app.run()` · deux routes avec le même nom de
fonction → `AssertionError` · modifier le code sans recharger (pensez à
`debug=True`).
---
## Exercice 2 — Séparer le HTML du Python
**Notions** : templates Jinja, `render_template`, héritage de gabarit.
Ouvrez `templates/base.html` et `templates/accueil.html`. Répondez par écrit,
en une phrase chacune :
1. Que fait `{% extends "base.html" %}` ?
2. Où atterrit le contenu de `{% block contenu %}` ?
3. Pourquoi écrit-on `{{ url_for('profil') }}` plutôt que `/profil` ?
4. Dans `base.html`, pourquoi le lien « Administration » n'apparaît-il pas
pour tout le monde ?
**Pourquoi c'est important** : le jury vous demandera de justifier votre
architecture. « J'ai séparé la présentation de la logique » est une réponse,
encore faut-il pouvoir la montrer dans le code.
---
## Exercice 3 — Un formulaire qui parle au serveur
**Notions** : méthodes GET et POST, `request.form`.
Dans `essai.py`, créez une route `/echo` qui accepte GET et POST :
- en GET, elle affiche un formulaire avec un champ `message`
- en POST, elle réaffiche la page avec le message reçu
```
Saisie : "salut"
Affiché : Vous avez écrit : salut
```
Puis essayez de saisir `<b>salut</b>`. Que s'affiche-t-il à l'écran ?
Gardez la réponse de côté : on y revient à l'exercice 12.
**Erreurs attendues** : oublier `methods=["GET", "POST"]` → erreur 405 ·
`request.form["message"]` sur un champ absent → `KeyError` (préférez
`request.form.get("message", "")`).
---
# Partie B — Enregistrer les données
## Exercice 4 — Parler à la base
**Notions** : SQLite, `SELECT`, `INSERT`, requêtes paramétrées.
Dans un fichier d'essai, ouvrez `blocandco.db` et :
1. affichez tous les adhérents
2. affichez uniquement ceux dont le rôle est `admin`
3. comptez le nombre total d'adhérents
4. insérez un adhérent de test, puis supprimez-le
**Règle non négociable, à appliquer dès maintenant :** une valeur variable ne
s'écrit **jamais** dans le texte de la requête.
```python
# NON — faille d'injection SQL
db.execute("SELECT * FROM adherents WHERE email = '" + email + "'")
# OUI — requête paramétrée
db.execute("SELECT * FROM adherents WHERE email = ?", (email,))
```
**Erreurs attendues** : oublier la virgule dans `(email,)` → le tuple n'en est
pas un · oublier `commit()` après un `INSERT` → rien n'est enregistré ·
`fetchone()` renvoie `None` quand il n'y a pas de résultat, et le code plante
trois lignes plus loin.
---
## Exercice 5 — L'inscription
**Notions** : validation des entrées, hachage de mot de passe, contrainte
d'unicité.
Complétez `valider_inscription()` puis la route `/inscription`.
**Règles de validation** :
| Champ | Règle |
|---|---|
| nom, prénom | non vides |
| email | contient `@` et un point après le `@` |
| mot de passe | 12 caractères minimum, une majuscule, un chiffre |
| confirmation | identique au mot de passe |
| email | pas déjà présent en base |
**Le mot de passe ne doit jamais arriver en clair dans la base.** Utilisez
`generate_password_hash()` de Werkzeug.
```
Entrée : Léa / Dupont / lea@test.fr / "Court1A"
Sortie : « Le mot de passe doit faire au moins 12 caractères. »
Entrée : Léa / Dupont / lea@test.fr / "Grimpe2026ABC"
Sortie : « Compte créé. Vous pouvez vous connecter. »
En base : $scrypt$... (et non "Grimpe2026ABC")
```
**Vérification** : `pytest -k inscription` et `pytest -k mot_de_passe`
**Erreurs attendues** : enregistrer le mot de passe en clair · hacher la
confirmation au lieu du mot de passe · renvoyer le mot de passe dans le
formulaire réaffiché · comparer les e-mails sans normaliser la casse, si bien
que `Lea@Test.fr` et `lea@test.fr` créent deux comptes.
---
# Partie C — Authentification et autorisations
## Exercice 6 — La connexion
**Notions** : session, vérification d'empreinte.
Complétez `utilisateur_courant()` puis la route `/connexion`.
Le principe : on ne déchiffre **jamais** un mot de passe. On rehache celui qui
est saisi et on compare les deux empreintes, avec `check_password_hash()`.
**Point de sécurité, et il compte** : que l'adresse existe ou non, que le mot
de passe soit faux ou non, affichez **exactement le même message**. Sinon un
attaquant découvre quelles adresses sont inscrites chez vous — c'est une
énumération de comptes.
```
Entrée : lea@test.fr / mauvais → « Identifiants incorrects. »
Entrée : inconnu@test.fr / n'importe → « Identifiants incorrects. »
```
**Vérification** : `pytest -k connexion`
**Erreurs attendues** : messages différents selon le cas · stocker tout
l'utilisateur dans la session au lieu de son seul identifiant · oublier
`session.clear()` avant de connecter quelqu'un (fixation de session) · oublier
`app.secret_key` → Flask refuse d'écrire dans la session.
---
## Exercice 7 — Déconnexion et pages protégées
**Notions** : décorateur, redirection.
1. Complétez `/deconnexion` : elle vide la session.
2. Complétez le décorateur `login_requis`.
Un décorateur est une fonction qui en enveloppe une autre pour ajouter un
contrôle avant son exécution. Ici : « si personne n'est connecté, redirige
vers la connexion ; sinon, laisse passer ».
```
Visiteur anonyme sur /profil → redirigé vers /connexion
« Connectez-vous pour accéder à cette page. »
```
**Vérification** : `pytest -k protege` et `pytest -k deconnexion`
**Erreurs attendues** : oublier `@wraps(vue)` → Flask voit deux routes nommées
`wrapper` et refuse de démarrer · placer `@login_requis` **au-dessus** de
`@app.route` (l'ordre compte : la route en premier) · oublier de renvoyer le
résultat de `vue(...)`.
---
## Exercice 8 — L'espace personnel
**Notions** : `UPDATE`, revalidation côté serveur.
Complétez `/profil` : afficher les informations, et permettre de modifier nom,
prénom et niveau.
**Le piège, et c'est le plus important de l'atelier** : le `<select>` du
niveau propose trois valeurs, mais n'importe qui peut modifier le HTML dans
son navigateur et envoyer ce qu'il veut. **Tout ce qui vient du client est
hostile jusqu'à preuve du contraire.** Revalidez contre `NIVEAUX` côté serveur.
```
Entrée : niveau = "confirme" → « Profil mis à jour. »
Entrée : niveau = "super_admin" → « Niveau invalide. »
```
Vérifiez aussi que l'`UPDATE` porte bien sur l'utilisateur **connecté**, et
pas sur un identifiant envoyé par le formulaire.
**Vérification** : `pytest -k profil` et `pytest -k niveau`
**Erreurs attendues** : faire confiance au `<select>` · oublier le `WHERE` dans
l'`UPDATE` → tous les adhérents sont modifiés d'un coup · laisser l'utilisateur
changer son propre rôle par ce formulaire.
---
## Exercice 9 — Les rôles
**Notions** : contrôle d'autorisation, séparation authentification /
autorisation.
Complétez le décorateur `admin_requis`, la route `/admin` et `/admin/promouvoir`.
Retenez la distinction, le jury la pose souvent :
- **authentification** : qui êtes-vous ? (connexion)
- **autorisation** : avez-vous le droit de faire ceci ? (rôle)
Un utilisateur connecté n'est pas un utilisateur autorisé.
```
Visiteur anonyme sur /admin → « Connectez-vous… »
Adhérent connecté sur /admin → « Accès réservé aux administrateurs. »
Administrateur sur /admin → la liste des adhérents
```
**Vérification** : `pytest -k admin` et `pytest -k promotion`
**Erreurs attendues** : masquer le lien dans le menu **sans** protéger la route
(l'URL reste accessible en la tapant) · vérifier le rôle avec `=` au lieu de
`==` · protéger l'affichage mais pas l'action `/admin/promouvoir` — c'est la
faille la plus fréquente et la plus grave.
---
## Exercice 10 — La statistique d'accueil
**Notions** : `COUNT`, passage de variable au template.
Affichez sur la page d'accueil le nombre réel d'adhérents inscrits.
```
Base vide + compte admin → 1 adhérent inscrit
Après une inscription → 2 adhérents inscrits
```
**Vérification** : `pytest -k nombre`
**Erreurs attendues** : compter en Python avec `len(fetchall())` au lieu d'un
`COUNT(*)` en SQL — correct ici, inacceptable sur un million de lignes ·
oublier d'accorder le pluriel.
---
# Partie D — La sécurité, ce que le jury regarde vraiment
> Les quatre exercices suivants se font **sur votre propre application**.
> C'est légal, c'est le but, et c'est exactement ce qu'on vous demandera de
> démontrer.
## Exercice 11 — Injection SQL
**Notions** : OWASP A03, requêtes paramétrées.
1. Dans une copie de votre code, remplacez volontairement la requête de
connexion par une concaténation :
```python
db.execute("SELECT * FROM adherents WHERE email = '" + email + "'")
```
2. Connectez-vous avec l'adresse `lea@test.fr' --` et n'importe quel mot de
passe. Observez.
3. Essayez `' OR '1'='1`. Observez.
4. Revenez à la version paramétrée. Rejouez les deux charges.
5. **Expliquez par écrit, en trois phrases, pourquoi le `?` règle le problème.**
**Vérification** : `pytest -k injection`
La réponse attendue : avec `?`, la requête et les données voyagent séparément.
Le moteur a déjà analysé la structure de la requête quand il reçoit la valeur ;
celle-ci ne peut donc plus être lue comme du code SQL.
---
## Exercice 12 — Injection de script (XSS)
**Notions** : OWASP A03, échappement automatique.
1. Modifiez votre prénom en `<script>alert('XSS')</script>` puis affichez
votre profil. Que se passe-t-il ? Regardez le code source de la page.
2. Dans `profil.html`, remplacez `{{ utilisateur.prenom }}` par
`{{ utilisateur.prenom|safe }}`. Rechargez.
3. Expliquez ce que fait Jinja par défaut, et ce que `|safe` désactive.
4. **Remettez le code dans son état initial.**
**Vérification** : `pytest -k xss`
**À retenir** : Jinja échappe automatiquement. Ce filtre `|safe`, vous le
croiserez dans du vrai code — c'est souvent là qu'est la faille.
---
## Exercice 13 — Contrôle d'accès défaillant
**Notions** : OWASP A01, le risque numéro 1 du classement.
Trois attaques à mener contre votre propre application :
1. **Accès direct par l'URL** — déconnectez-vous, tapez `/admin` à la main.
Êtes-vous bloqué ?
2. **Élévation de privilège** — connectez-vous comme simple adhérent, puis
envoyez une requête POST vers `/admin/promouvoir/1`. Votre rôle change-t-il ?
(Le test `test_promotion_interdite_a_un_adherent` fait exactement cela.)
3. **Accès aux données d'autrui** — si vous aviez écrit `/profil/<id>` au lieu
de `/profil`, que se passerait-il en changeant le numéro dans l'URL ?
Expliquez pourquoi la version sans identifiant est plus sûre.
**Règle** : cacher un bouton n'est pas une mesure de sécurité. Seul le contrôle
exécuté sur le serveur en est une.
---
## Exercice 14 — Les quatre critères, appliqués à votre code
**Notions** : disponibilité, intégrité, confidentialité, traçabilité.
Pour chacun des quatre critères, citez **une ligne précise de votre code** qui
y contribue, ou constatez qu'il manque et dites ce que vous ajouteriez.
| Critère | Dans votre application |
|---|---|
| Confidentialité | ? |
| Intégrité | ? |
| Disponibilité | ? |
| Traçabilité | ? |
Indice pour le dernier : votre application n'enregistre aujourd'hui **aucune**
trace des connexions. Proposez une table `journal` et dites ce que vous y
mettriez — et surtout ce que vous n'y mettriez **jamais**.
---
# Partie E — L'épreuve
## Exercice 15 — Projet de synthèse, en autonomie (4 heures)
**Nouveau cahier des charges, à traiter seul.**
> **Rouletabosse**, association de covoiturage domicile-travail, veut une
> application web. Elle doit permettre à un salarié de créer un compte
> (nom, prénom, e-mail, mot de passe, entreprise), de se connecter, de
> consulter et modifier son profil. Deux rôles : `membre` et `referent`.
> Seul un référent peut voir la liste des membres et valider un compte.
> La page d'accueil affiche le nombre de membres inscrits.
> Front : HTML / CSS. Back : Python. Mots de passe hachés, sessions
> sécurisées.
Vous partez d'une page blanche. Vous avez quatre heures.
**Livrable** : l'application qui tourne en local, plus la présentation de
l'exercice 16.
**Conseil de méthode** : passez les 20 premières minutes à écrire le schéma de
la base et la liste de vos routes, **avant** la première ligne de code. Les
candidats qui codent immédiatement sont ceux qui ne finissent pas.
---
## Exercice 16 — La présentation au jury
Une application qui marche mais qu'on ne sait pas présenter est une
application à moitié rendue. Préparez un support de 12 à 15 diapositives :
1. **Introduction** — le besoin métier, en deux phrases
2. **Schéma de la base de données** — les tables, leurs champs, leurs liens
3. **Modélisation de l'application** — les routes, et le parcours d'un
utilisateur de l'inscription au profil
4. **Choix technologiques** — et leur **justification**. « Flask parce que
c'est léger et que le besoin ne demande pas d'ORM » est une justification.
« Flask parce qu'on l'a vu en cours » n'en est pas une.
5. **Sécurité** — hachage, sessions, requêtes paramétrées, contrôle des rôles,
validation des entrées. Une diapositive par mesure, avec l'extrait de code.
6. **Tests** — votre stratégie, le nombre de tests, une capture de `pytest`
7. **Démonstration** — captures d'écran du parcours complet
8. **Conclusion** — ce qui manque, et ce que vous feriez avec deux jours de plus
**Préparez une réponse à ces trois questions**, elles tombent presque toujours :
- Pourquoi hacher plutôt que chiffrer un mot de passe ?
- Que se passe-t-il si je tape l'URL d'administration sans être connecté ?
- Comment savez-vous que votre application fonctionne ?
---
# Grille d'évaluation
| Compétence du référentiel | Vérifiée par |
|---|---|
| **C15** — procédures d'authentification et d'autorisation intégrées | Exercices 6, 7, 9 |
| **C15** — activités autorisées et interdites définies | Exercices 9, 13 |
| **C15** — gestion sécurisée des données sur tout leur cycle de vie | Exercices 5, 14 |
| **C16** — langages web et logiciel mobilisés (Python, JS…) | Exercices 1 à 10 |
| **C16** — standards de développement respectés | `ruff check .` sur le rendu |
| **C16** — les 4 critères de sécurité respectés | Exercice 14 |
| **C17** — risques identifiés, robustesse aux attaques | Exercices 11, 12, 13 |
| **C17** — composants externes connus et éprouvés | Exercice 16, diapositive 4 |
| Barème indicatif | Points |
|---|---|
| Partie A — bases web (ex. 1-3) | 2 |
| Partie B — données et inscription (ex. 4-5) | 4 |
| Partie C — authentification et rôles (ex. 6-10) | 6 |
| Partie D — sécurité (ex. 11-14) | 4 |
| Partie E — présentation (ex. 16) | 4 |
**Retraits transversaux** : mot de passe en clair en base (**−5, éliminatoire**)
· requête SQL construite par concaténation (−3) · route protégée uniquement par
le masquage d'un lien (−3) · `except:` nu (−1) · `app.secret_key` codée en dur
dans un rendu présenté comme « prêt pour la production » (−1).
@@ -0,0 +1,213 @@
# Fiche — installer Python et Flask
À garder de côté. Vous n'en avez besoin que si `verif_poste.py` affiche une
ligne `[ KO ]`, ou si votre terminal ne reconnaît pas la commande `python`.
**Durée : 10 minutes.** Suivez les étapes dans l'ordre, sans en sauter.
---
# Windows
## Étape 1 — Vérifier ce que vous avez déjà
Ouvrez un terminal : touche **Windows**, tapez `cmd`, **Entrée**.
Tapez :
```
py --list
```
| Ce que vous voyez | Ce que ça veut dire |
|---|---|
| Une liste avec `-V:3.9` ou mieux | **Python est déjà installé.** Passez directement à l'étape 4. |
| `'py' n'est pas reconnu…` | Python n'est pas installé, ou mal installé. Étape 2. |
| Uniquement des versions `3.8` ou moins | Trop ancien pour Flask 3. Étape 2. |
---
## Étape 2 — Télécharger
Allez sur **https://www.python.org/downloads/**
Le gros bouton jaune propose la dernière version pour votre système.
Cliquez, puis lancez le fichier téléchargé.
> N'installez pas Python depuis le Microsoft Store. Il fonctionne, mais il
> isole les fichiers d'une façon qui pose des problèmes avec les
> environnements virtuels — exactement ce qu'on va utiliser.
---
## Étape 3 — Installer : l'écran qui compte
Le premier écran de l'installateur contient **une case à cocher en bas** :
```
☑ Add python.exe to PATH ← COCHEZ-LA
```
**C'est l'étape la plus importante de toute la fiche.** Si vous l'oubliez,
Windows ne saura pas où trouver Python et toutes les commandes échoueront.
Cochez la case, puis cliquez sur **Install Now**.
Si vous avez déjà cliqué sans cocher : relancez l'installateur, choisissez
**Modify**, puis **Repair** — ou désinstallez et recommencez.
À la fin, s'il propose **« Disable path length limit »**, acceptez.
---
## Étape 4 — Vérifier
**Fermez complètement votre terminal et rouvrez-en un neuf.** Le PATH n'est lu
qu'à l'ouverture : sans ça, vous testerez l'ancienne configuration.
```
py --version
```
Vous devez voir un numéro de version, par exemple `Python 3.13.2`.
---
## Étape 5 — Installer Flask
```
py -m pip install flask pytest
```
Puis relancez la vérification :
```
py verif_poste.py
```
Vous devez obtenir « Tout est prêt. Rien à faire. »
---
# macOS
## Étape 1 — Vérifier
Ouvrez **Terminal** (Spotlight : `Cmd + Espace`, tapez `terminal`).
```
python3 --version
```
Si vous obtenez 3.9 ou mieux, passez à l'étape 3.
## Étape 2 — Installer
Téléchargez l'installateur macOS sur **https://www.python.org/downloads/**
et suivez-le. Aucune case particulière à cocher : sur macOS le PATH est
configuré automatiquement.
## Étape 3 — Installer Flask
```
python3 -m pip install flask pytest
```
Sur macOS et Linux, remplacez partout `py` par `python3` dans les commandes
de l'atelier.
---
# Dépannage
## « 'python' n'est pas reconnu en tant que commande interne »
La case **Add python.exe to PATH** n'a pas été cochée.
Solution immédiate : utilisez `py` à la place de `python`. Le lanceur `py`
est installé séparément et fonctionne presque toujours.
Solution durable : relancez l'installateur → **Modify** → **Repair**.
---
## Taper `python` ouvre le Microsoft Store
Windows intercepte la commande avec un raccourci publicitaire.
**Paramètres** → **Applications** → **Paramètres avancés des applications**
→ **Alias d'exécution d'application** → désactivez les deux lignes
`python.exe` et `python3.exe`.
En attendant, utilisez `py` : il n'est pas concerné.
---
## J'ai plusieurs versions de Python et je ne sais pas laquelle tourne
```
py --list
```
Vous choisissez explicitement laquelle utiliser :
```
py -3.13 verif_poste.py # remplacez 3.13 par VOTRE version
py -3.13 -m pip install flask
```
Le numéro après `-` est la version. Utilisez **toujours le même** pendant
tout l'atelier, sinon vous installerez Flask dans une version et lancerez le
code avec une autre — c'est la cause numéro un des `ModuleNotFoundError`.
---
## `pip` échoue avec une erreur de certificat ou de proxy
Vous êtes probablement sur un réseau d'entreprise ou d'école qui filtre.
Essayez d'abord :
```
py -m pip install --user flask pytest
```
Si cela échoue encore, utilisez votre connexion personnelle (partage de
connexion du téléphone) le temps de l'installation. Une fois Flask installé,
le réseau n'a plus d'importance.
---
## `ModuleNotFoundError: No module named 'flask'`
Flask est installé, mais pas pour l'interpréteur qui exécute votre code.
Vérifiez avec quel Python vous travaillez :
```
py verif_poste.py
```
La ligne **Executable** vous donne le chemin exact. Installez Flask pour
celui-là précisément :
```
py -m pip install flask
```
Si vous utilisez un environnement virtuel, vérifiez qu'il est **activé** :
votre ligne de commande doit commencer par `(.venv)`.
---
## Rien ne fonctionne et le cours a commencé
**Ne restez pas bloqué seul.** Dans le chat du cours, envoyez :
1. Le résultat complet de `py verif_poste.py`
2. La commande que vous avez tapée
3. Le message d'erreur **en entier**
En attendant la réponse, suivez sur l'écran partagé et binômez avec un
camarade. Vous rattraperez le code à la pause : ce qui compte maintenant,
c'est de ne pas perdre le fil du raisonnement.
@@ -0,0 +1,139 @@
# Mémo — les 30 premières minutes de l'épreuve
> À garder sous les yeux pendant l'examen.
> Vous avez 4 heures. Les 30 premières décident des 3 h 30 suivantes.
Deux situations possibles. Lisez le sujet : on vous donne un dépôt, ou pas.
---
## Situation A — on vous fournit un dépôt
**Ne codez rien avant d'avoir fait ces six étapes.**
1. **Clonez**, puis **lisez le README en entier.** Cinq minutes de lecture
valent une heure de tâtonnement. Il dit comment lancer le projet.
2. **Cherchez le fichier de dépendances** : `requirements.txt`,
`pyproject.toml` ou `Pipfile`. Il vous dit quel framework est imposé.
3. **Installez et lancez l'application telle quelle**, avant toute
modification. Si elle ne démarre pas, c'est votre premier problème à
résoudre — et vous devez le savoir maintenant, pas à 15 h.
4. **Cartographiez le projet en cinq minutes**, par écrit :
| Question | Réponse |
|---|---|
| Où sont déclarées les routes ? | |
| Où sont les templates ? | |
| Y a-t-il déjà une base de données ? un schéma ? | |
| Qu'est-ce qui marche déjà ? | |
| Qu'est-ce qui est vide ou marqué « à faire » ? | |
5. **Repérez les conventions existantes** : langue des noms de variables,
structure des fonctions, façon d'accéder à la base. Votre code doit
ressembler à celui qui est déjà là. Un jury le remarque.
6. **Faites un premier commit** avant de commencer. Vous aurez un point de
retour si vous cassez tout.
---
## Situation B — page blanche
**L'arborescence à créer, dans cet ordre :**
```
monprojet/
app.py
schema.sql
requirements.txt
templates/
base.html
static/
style.css
```
**Les commandes (Windows)** :
```
mkdir monprojet
cd monprojet
py -m venv .venv
.venv\Scripts\activate
pip install flask
pip freeze > requirements.txt
```
Sur macOS ou Linux, remplacez les deux premières lignes par :
`python3 -m venv .venv` puis `source .venv/bin/activate`
**Le `app.py` minimal, à taper de mémoire :**
```python
from flask import Flask
app = Flask(__name__)
app.secret_key = "a-changer"
@app.route("/")
def accueil():
return "ça tourne"
if __name__ == "__main__":
app.run(debug=True)
```
Lancez-le. **Vous devez voir « ça tourne » dans le navigateur avant d'écrire
la moindre fonctionnalité.** Tant que ce n'est pas le cas, rien d'autre
n'a d'importance.
---
## Dans les deux cas : l'ordre de construction
Ne construisez jamais dans un autre ordre. Chaque étape s'appuie sur la
précédente, et chacune doit marcher avant de passer à la suivante.
| # | Étape | Fini quand… |
|---|---|---|
| 1 | L'application démarre | une page s'affiche |
| 2 | La base et la table existent | vous lisez une ligne en Python |
| 3 | L'inscription | un compte apparaît en base, mot de passe **haché** |
| 4 | La connexion | la session retient qui vous êtes |
| 5 | Le profil | vous voyez et modifiez vos infos |
| 6 | Les rôles | un non-admin est refusé sur la page admin |
| 7 | La statistique | le compteur s'affiche sur l'accueil |
| 8 | **Les captures d'écran** | vous avez de quoi faire la démo |
**À 14 h, arrêtez de coder.** La présentation vaut autant que le code. Une
application à 80 % bien présentée passe mieux qu'une application à 100 %
sans support.
---
## Les pièges qui coûtent des heures
| Symptôme | Cause réelle |
|---|---|
| `ModuleNotFoundError: flask` | environnement virtuel non activé — regardez si `(.venv)` est au début de la ligne |
| `Address already in use` | une ancienne instance tourne encore : fermez-la, ou `app.run(port=5001)` |
| Les modifications n'apparaissent pas | `debug=True` oublié, ou page en cache (Ctrl+F5) |
| `jinja2.TemplateNotFound` | le dossier doit s'appeler exactement `templates` |
| `RuntimeError: The session is unavailable` | `app.secret_key` manquante |
| Les accents s'affichent en `?` | `encoding="utf-8"` oublié à la lecture du fichier |
| L'erreur 405 | la route n'accepte pas POST : `methods=["GET", "POST"]` |
---
## Ce qui est éliminatoire
Quoi qu'il arrive, même à court de temps :
- **Jamais** de mot de passe en clair dans la base
- **Jamais** de requête SQL construite avec `+` ou une f-string
- **Toujours** le contrôle de rôle sur le serveur, pas seulement un lien caché
Si vous devez sacrifier une fonctionnalité, sacrifiez-la. Ne sacrifiez
jamais une de ces trois règles.
@@ -0,0 +1,2 @@
Flask>=3.0
pytest>=8.0
@@ -0,0 +1,18 @@
# Règles de style de l'atelier.
# Les étudiants lancent ruff check . avant de rendre.
# Ce fichier garantit que tout le monde obtient le même résultat.
line-length = 88
[lint]
select = ["E", "F", "W", "I", "UP", "B", "RET"]
ignore = [
# On enseigne date.today(), les fuseaux horaires ne sont pas au programme.
"DTZ011",
]
[lint.per-file-ignores]
# Dans le squelette, ces imports sont fournis d'avance aux étudiants :
# ils deviennent utiles au fur et à mesure des exercices.
"starter/app.py" = ["F401", "RET504", "RET501"]
@@ -0,0 +1,26 @@
# Bloc&Co — squelette de l'atelier
## Démarrer
py -m venv .venv
.venv\Scripts\activate
pip install -r ..\requirements.txt
python app.py
Sur macOS ou Linux, remplacez les deux premières lignes par :
`python3 -m venv .venv` puis `source .venv/bin/activate`
Puis ouvrir http://127.0.0.1:5000
## Ce qui est fourni
- `schema.sql` — la table `adherents`, à ne pas modifier
- `templates/` — tout le HTML, déjà écrit
- `static/style.css` — la feuille de style
- `app.py` — la plomberie de la base de données, et des fonctions à compléter
- `verif_poste.py` — vérifie que votre poste est prêt (à lancer en premier)
## Ce que vous écrivez
Tout ce qui est marqué `# À FAIRE`. Suivez les exercices dans l'ordre :
l'application ne fonctionnera entièrement qu'à la fin de l'exercice 10.
@@ -0,0 +1,221 @@
"""Bloc&Co — gestion des adhérents d'une salle d'escalade.
Squelette de l'atelier. Complétez tout ce qui est marqué « À FAIRE ».
Lancement : python app.py puis http://127.0.0.1:5000
"""
import sqlite3
from datetime import date
from functools import wraps
from pathlib import Path
from flask import Flask, flash, g, redirect, render_template, request, session, url_for
from werkzeug.security import check_password_hash, generate_password_hash
BASE = Path(__file__).parent
DB = BASE / "blocandco.db"
app = Flask(__name__)
app.secret_key = "cle-de-developpement-a-changer"
NIVEAUX = ["debutant", "intermediaire", "confirme"]
# ==========================================================================
# FOURNI — plomberie de la base de données. Ne rien modifier ici.
# ==========================================================================
def get_db():
"""Une connexion par requête, rangée dans g et fermée automatiquement."""
if "db" not in g:
g.db = sqlite3.connect(DB)
g.db.row_factory = sqlite3.Row
return g.db
@app.teardown_appcontext
def close_db(exception):
db = g.pop("db", None)
if db is not None:
db.close()
def init_db():
"""Crée la base au premier lancement et sème un compte administrateur."""
if DB.exists():
return
con = sqlite3.connect(DB)
con.executescript((BASE / "schema.sql").read_text(encoding="utf-8"))
con.execute(
"INSERT INTO adherents (nom, prenom, email, mot_de_passe, niveau, role,"
" date_inscription) VALUES (?, ?, ?, ?, ?, ?, ?)",
("Martin", "Claire", "admin@blocandco.fr",
generate_password_hash("Admin2026!"), "confirme", "admin",
date.today().isoformat()),
)
con.commit()
con.close()
print("Base créée. Compte admin : admin@blocandco.fr / Admin2026!")
@app.context_processor
def injecter_utilisateur():
"""Rend la variable `utilisateur` disponible dans tous les templates."""
return {"utilisateur": utilisateur_courant()}
# ==========================================================================
# EXERCICE 6 — qui est connecté ?
# ==========================================================================
def utilisateur_courant():
"""Renvoie la ligne de l'adhérent connecté, ou None si personne.
À FAIRE :
- si "user_id" n'est pas dans `session`, renvoyer None
- sinon, aller chercher la ligne correspondante dans la base
"""
return None
# ==========================================================================
# EXERCICE 7 — protéger une page
# ==========================================================================
def login_requis(vue):
"""Interdit l'accès à la vue si personne n'est connecté.
À FAIRE : si `utilisateur_courant()` vaut None, afficher un message
et rediriger vers la page de connexion. Sinon, appeler la vue.
"""
@wraps(vue)
def wrapper(*args, **kwargs):
return vue(*args, **kwargs)
return wrapper
# ==========================================================================
# EXERCICE 9 — réserver une page aux administrateurs
# ==========================================================================
def admin_requis(vue):
"""Interdit l'accès à la vue si l'utilisateur n'est pas administrateur.
À FAIRE : refuser les visiteurs non connectés, PUIS refuser ceux dont
le rôle n'est pas "admin". Deux messages différents.
"""
@wraps(vue)
def wrapper(*args, **kwargs):
return vue(*args, **kwargs)
return wrapper
# ==========================================================================
# EXERCICE 5 — valider le formulaire d'inscription
# ==========================================================================
def valider_inscription(nom, prenom, email, mdp, confirmation):
"""Renvoie la liste des messages d'erreur. Liste vide = tout est bon.
À FAIRE, dans cet ordre :
- nom et prénom non vides
- email contenant "@" et un point après le "@"
- mot de passe d'au moins 12 caractères
- au moins un chiffre, au moins une majuscule
- les deux mots de passe identiques
"""
erreurs = []
return erreurs
# ==========================================================================
# ROUTES
# ==========================================================================
@app.route("/")
def accueil():
"""EXERCICE 10 — afficher le nombre d'adhérents inscrits.
À FAIRE : remplacer le 0 par le vrai total, lu dans la base
avec un SELECT COUNT(*).
"""
nb = 0
return render_template("accueil.html", nb_adherents=nb)
@app.route("/inscription", methods=["GET", "POST"])
def inscription():
"""EXERCICE 5 — créer un compte.
À FAIRE :
- en POST, récupérer les champs du formulaire
- les valider avec valider_inscription()
- refuser une adresse e-mail déjà présente dans la base
- hacher le mot de passe avec generate_password_hash()
- insérer l'adhérent avec le rôle "adherent"
- rediriger vers la page de connexion
"""
if request.method == "POST":
pass
return render_template("inscription.html")
@app.route("/connexion", methods=["GET", "POST"])
def connexion():
"""EXERCICE 6 — se connecter.
À FAIRE :
- chercher l'adhérent par son e-mail
- vérifier le mot de passe avec check_password_hash()
- en cas d'échec, afficher LE MÊME message que l'adresse existe ou non
- en cas de succès, vider la session puis y stocker l'identifiant
"""
if request.method == "POST":
pass
return render_template("connexion.html")
@app.route("/deconnexion")
def deconnexion():
"""EXERCICE 7 — se déconnecter.
À FAIRE : vider la session et rediriger vers l'accueil.
"""
return redirect(url_for("accueil"))
@app.route("/profil", methods=["GET", "POST"])
@login_requis
def profil():
"""EXERCICE 8 — consulter et modifier son profil.
À FAIRE :
- en POST, mettre à jour nom, prénom et niveau de l'utilisateur CONNECTÉ
- refuser un niveau qui n'est pas dans NIVEAUX
"""
if request.method == "POST":
pass
return render_template("profil.html", niveaux=NIVEAUX)
@app.route("/admin")
@admin_requis
def admin():
"""EXERCICE 9 — lister les adhérents.
À FAIRE : lire tous les adhérents, les plus récents d'abord.
"""
adherents = []
return render_template("admin.html", adherents=adherents)
@app.route("/admin/promouvoir/<int:adherent_id>", methods=["POST"])
@admin_requis
def promouvoir(adherent_id):
"""EXERCICE 9 (suite) — promouvoir un adhérent administrateur."""
return redirect(url_for("admin"))
if __name__ == "__main__":
init_db()
app.run(debug=True)
@@ -0,0 +1,15 @@
-- Schéma de la base « Bloc&Co »
-- Exécuté une seule fois, au premier démarrage de l'application.
DROP TABLE IF EXISTS adherents;
CREATE TABLE adherents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nom TEXT NOT NULL,
prenom TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
mot_de_passe TEXT NOT NULL, -- empreinte, jamais le mot de passe
niveau TEXT NOT NULL DEFAULT 'debutant',
role TEXT NOT NULL DEFAULT 'adherent',
date_inscription TEXT NOT NULL
);
@@ -0,0 +1,74 @@
:root {
--bleu: #0b6e9e;
--bleu-fonce: #084f71;
--gris: #5c6672;
--bord: #dde3e9;
--fond: #f6f8fa;
--rouge: #a8341f;
--vert: #1f6b45;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Segoe UI", system-ui, sans-serif;
color: #1a1f26;
background: var(--fond);
line-height: 1.55;
}
header {
display: flex; align-items: center; gap: 1.5rem;
padding: 0.9rem 1.5rem; background: #fff;
border-bottom: 1px solid var(--bord);
}
.logo { font-weight: 700; font-size: 1.15rem; color: var(--bleu); text-decoration: none; }
nav { margin-left: auto; display: flex; gap: 1.1rem; align-items: center; }
nav a { color: var(--gris); text-decoration: none; font-size: 0.92rem; }
nav a:hover { color: var(--bleu); }
main { max-width: 780px; margin: 0 auto; padding: 2rem 1.5rem 4rem; }
h1 { font-size: 1.7rem; margin: 0 0 0.4rem; }
h2 { font-size: 1.15rem; margin: 2rem 0 0.6rem; }
.chapeau { color: var(--gris); margin-top: 0; }
.carte {
background: #fff; border: 1px solid var(--bord); border-radius: 8px;
padding: 1.25rem; display: flex; flex-direction: column; gap: 0.85rem;
max-width: 420px;
}
label { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.88rem; color: var(--gris); }
input, select {
padding: 0.5rem 0.65rem; border: 1px solid var(--bord); border-radius: 5px;
font-size: 0.95rem; font-family: inherit; color: #1a1f26; background: #fff;
}
input:focus, select:focus { outline: 2px solid var(--bleu); outline-offset: 1px; border-color: var(--bleu); }
.aide { font-size: 0.8rem; color: var(--gris); margin: 0; }
button, .bouton {
background: var(--bleu); color: #fff; border: none; border-radius: 5px;
padding: 0.55rem 1rem; font-size: 0.95rem; font-family: inherit;
cursor: pointer; text-decoration: none; display: inline-block; text-align: center;
}
button:hover, .bouton:hover { background: var(--bleu-fonce); }
button.lien { background: none; color: var(--bleu); padding: 0; text-decoration: underline; }
button.lien:hover { background: none; color: var(--bleu-fonce); }
.messages { list-style: none; padding: 0; margin: 0 0 1.25rem; display: flex; flex-direction: column; gap: 0.4rem; }
.messages li { padding: 0.6rem 0.85rem; border-radius: 5px; font-size: 0.9rem; border-left: 3px solid; }
.messages .erreur { background: #fbecea; border-color: var(--rouge); color: var(--rouge); }
.messages .succes { background: #e9f3ed; border-color: var(--vert); color: var(--vert); }
.stat {
display: flex; align-items: baseline; gap: 0.6rem;
background: #fff; border: 1px solid var(--bord); border-radius: 8px;
padding: 1.1rem 1.4rem; margin: 1.5rem 0; max-width: 420px;
}
.chiffre { font-size: 2.4rem; font-weight: 700; color: var(--bleu); line-height: 1; }
.libelle { color: var(--gris); }
.infos { display: grid; grid-template-columns: auto 1fr; gap: 0.4rem 1.5rem; }
.infos dt { color: var(--gris); font-size: 0.88rem; }
.infos dd { margin: 0; }
table { width: 100%; border-collapse: collapse; background: #fff; border: 1px solid var(--bord); border-radius: 8px; overflow: hidden; font-size: 0.9rem; }
th, td { text-align: left; padding: 0.6rem 0.8rem; border-bottom: 1px solid var(--bord); }
th { background: #eef2f6; font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--gris); }
tr:last-child td { border-bottom: none; }
@@ -0,0 +1,14 @@
{% extends "base.html" %}
{% block contenu %}
<h1>Salle d'escalade Bloc&amp;Co</h1>
<p class="chapeau">Blocs, voies et cours collectifs, sept jours sur sept.</p>
<div class="stat">
<span class="chiffre">{{ nb_adherents }}</span>
<span class="libelle">adhérent{{ 's' if nb_adherents > 1 else '' }} inscrit{{ 's' if nb_adherents > 1 else '' }}</span>
</div>
{% if not utilisateur %}
<p><a class="bouton" href="{{ url_for('inscription') }}">Créer mon compte</a></p>
{% endif %}
{% endblock %}
@@ -0,0 +1,30 @@
{% extends "base.html" %}
{% block titre %}Administration — Bloc&Co{% endblock %}
{% block contenu %}
<h1>Administration</h1>
<p>{{ adherents|length }} adhérent{{ 's' if adherents|length > 1 else '' }} enregistré{{ 's' if adherents|length > 1 else '' }}.</p>
<table>
<thead>
<tr><th>Nom</th><th>E-mail</th><th>Niveau</th><th>Rôle</th><th>Inscription</th><th></th></tr>
</thead>
<tbody>
{% for a in adherents %}
<tr>
<td>{{ a.prenom }} {{ a.nom }}</td>
<td>{{ a.email }}</td>
<td>{{ a.niveau }}</td>
<td>{{ a.role }}</td>
<td>{{ a.date_inscription }}</td>
<td>
{% if a.role != 'admin' %}
<form method="post" action="{{ url_for('promouvoir', adherent_id=a.id) }}">
<button type="submit" class="lien">Promouvoir</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
@@ -0,0 +1,40 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block titre %}Bloc&Co{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<header>
<a class="logo" href="{{ url_for('accueil') }}">Bloc&amp;Co</a>
<nav>
{% if utilisateur %}
<a href="{{ url_for('profil') }}">Mon profil</a>
{% if utilisateur.role == 'admin' %}
<a href="{{ url_for('admin') }}">Administration</a>
{% endif %}
<a href="{{ url_for('deconnexion') }}">Déconnexion</a>
{% else %}
<a href="{{ url_for('connexion') }}">Connexion</a>
<a class="bouton" href="{{ url_for('inscription') }}">Inscription</a>
{% endif %}
</nav>
</header>
<main>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<ul class="messages">
{% for categorie, texte in messages %}
<li class="{{ categorie }}">{{ texte }}</li>
{% endfor %}
</ul>
{% endif %}
{% endwith %}
{% block contenu %}{% endblock %}
</main>
</body>
</html>
@@ -0,0 +1,15 @@
{% extends "base.html" %}
{% block titre %}Connexion — Bloc&Co{% endblock %}
{% block contenu %}
<h1>Connexion</h1>
<form method="post" class="carte">
<label>Adresse e-mail
<input type="email" name="email" value="{{ email or '' }}" required>
</label>
<label>Mot de passe
<input type="password" name="mot_de_passe" required>
</label>
<button type="submit">Se connecter</button>
</form>
<p>Pas encore de compte ? <a href="{{ url_for('inscription') }}">Inscrivez-vous</a>.</p>
{% endblock %}
@@ -0,0 +1,24 @@
{% extends "base.html" %}
{% block titre %}Inscription — Bloc&Co{% endblock %}
{% block contenu %}
<h1>Créer un compte</h1>
<form method="post" class="carte">
<label>Prénom
<input type="text" name="prenom" value="{{ prenom or '' }}" required>
</label>
<label>Nom
<input type="text" name="nom" value="{{ nom or '' }}" required>
</label>
<label>Adresse e-mail
<input type="email" name="email" value="{{ email or '' }}" required>
</label>
<label>Mot de passe
<input type="password" name="mot_de_passe" required>
</label>
<label>Confirmer le mot de passe
<input type="password" name="confirmation" required>
</label>
<p class="aide">12 caractères minimum, au moins une majuscule et un chiffre.</p>
<button type="submit">S'inscrire</button>
</form>
{% endblock %}
@@ -0,0 +1,29 @@
{% extends "base.html" %}
{% block titre %}Mon profil — Bloc&Co{% endblock %}
{% block contenu %}
<h1>Mon profil</h1>
<dl class="carte infos">
<dt>Adresse e-mail</dt><dd>{{ utilisateur.email }}</dd>
<dt>Rôle</dt><dd>{{ utilisateur.role }}</dd>
<dt>Inscrit depuis le</dt><dd>{{ utilisateur.date_inscription }}</dd>
</dl>
<h2>Modifier mes informations</h2>
<form method="post" class="carte">
<label>Prénom
<input type="text" name="prenom" value="{{ utilisateur.prenom }}" required>
</label>
<label>Nom
<input type="text" name="nom" value="{{ utilisateur.nom }}" required>
</label>
<label>Niveau
<select name="niveau">
{% for n in niveaux %}
<option value="{{ n }}" {{ 'selected' if n == utilisateur.niveau }}>{{ n }}</option>
{% endfor %}
</select>
</label>
<button type="submit">Enregistrer</button>
</form>
{% endblock %}
@@ -0,0 +1,193 @@
"""Tests de l'application Bloc&Co.
Lancement : pytest -v
Ces tests valident le comportement attendu à la fin de l'atelier.
Donnez-les aux étudiants : un exercice est réussi quand ses tests passent.
"""
import sqlite3
import app as application
import pytest
@pytest.fixture()
def client(tmp_path, monkeypatch):
"""Une application neuve, avec une base jetable, pour chaque test."""
base = tmp_path / "test.db"
monkeypatch.setattr(application, "DB", base)
application.init_db()
application.app.config["TESTING"] = True
with application.app.test_client() as c:
yield c
def inscrire(client, email="lea@test.fr", mdp="Grimpe2026ABC"):
return client.post("/inscription", data={
"nom": "Dupont", "prenom": "Lea", "email": email,
"mot_de_passe": mdp, "confirmation": mdp,
}, follow_redirects=True)
def connecter(client, email="lea@test.fr", mdp="Grimpe2026ABC"):
return client.post("/connexion", data={
"email": email, "mot_de_passe": mdp,
}, follow_redirects=True)
# --- Exercice 10 : la statistique ----------------------------------------
def test_accueil_affiche_le_nombre_d_adherents(client):
reponse = client.get("/")
assert reponse.status_code == 200
assert b">1<" in reponse.data # le compte admin seul
inscrire(client)
assert b">2<" in client.get("/").data
# --- Exercice 5 : l'inscription ------------------------------------------
def test_inscription_mot_de_passe_trop_court_refuse(client):
reponse = inscrire(client, mdp="Court1A")
assert b"12 caract" in reponse.data
def test_inscription_mot_de_passe_sans_chiffre_refuse(client):
reponse = inscrire(client, mdp="MotDePasseSansChiffre")
assert b"chiffre" in reponse.data
def test_inscription_email_en_double_refuse(client):
inscrire(client)
reponse = inscrire(client)
assert "déjà utilis".encode() in reponse.data
def test_inscription_email_insensible_a_la_casse(client):
inscrire(client, email="Lea@Test.fr")
reponse = inscrire(client, email="lea@test.fr")
assert "déjà utilis".encode() in reponse.data
def test_inscription_mot_de_passe_jamais_en_clair(client):
inscrire(client)
stocke = sqlite3.connect(application.DB).execute(
"SELECT mot_de_passe FROM adherents WHERE email = 'lea@test.fr'"
).fetchone()[0]
assert stocke != "Grimpe2026ABC"
assert len(stocke) > 40
# --- Exercice 6 : la connexion -------------------------------------------
def test_mauvais_mot_de_passe_refuse(client):
inscrire(client)
reponse = connecter(client, mdp="MauvaisMotDePasse")
assert b"Identifiants incorrects" in reponse.data
def test_compte_inexistant_donne_le_meme_message(client):
"""Le message ne doit pas révéler si l'adresse existe dans la base."""
inscrire(client)
inconnu = connecter(client, email="personne@test.fr", mdp="Grimpe2026ABC")
mauvais = connecter(client, mdp="MauvaisMotDePasse")
assert b"Identifiants incorrects" in inconnu.data
assert b"Identifiants incorrects" in mauvais.data
def test_connexion_reussie(client):
inscrire(client)
reponse = connecter(client)
assert b"Mon profil" in reponse.data
# --- Exercice 7 : les pages protégées ------------------------------------
def test_profil_protege_sans_connexion(client):
reponse = client.get("/profil", follow_redirects=True)
assert b"Connectez-vous" in reponse.data
def test_profil_protege_apres_deconnexion(client):
inscrire(client)
connecter(client)
client.get("/deconnexion")
reponse = client.get("/profil", follow_redirects=True)
assert b"Connectez-vous" in reponse.data
# --- Exercice 8 : le profil ----------------------------------------------
def test_profil_modifiable(client):
inscrire(client)
connecter(client)
reponse = client.post("/profil", data={
"nom": "Dupont", "prenom": "Léa", "niveau": "confirme",
}, follow_redirects=True)
assert "mis à jour".encode() in reponse.data
def test_niveau_invalide_rejete(client):
"""Le formulaire HTML est modifiable : le serveur doit revalider."""
inscrire(client)
connecter(client)
reponse = client.post("/profil", data={
"nom": "Dupont", "prenom": "Lea", "niveau": "super_admin",
}, follow_redirects=True)
assert b"Niveau invalide" in reponse.data
# --- Exercice 9 : les rôles ----------------------------------------------
def test_admin_interdit_a_un_adherent(client):
inscrire(client)
connecter(client)
reponse = client.get("/admin", follow_redirects=True)
assert "réservé aux administrateurs".encode() in reponse.data
def test_admin_accessible_a_l_administrateur(client):
inscrire(client)
connecter(client, email="admin@blocandco.fr", mdp="Admin2026!")
reponse = client.get("/admin")
assert reponse.status_code == 200
assert b"lea@test.fr" in reponse.data
def test_promotion_interdite_a_un_adherent(client):
inscrire(client)
connecter(client)
client.post("/admin/promouvoir/1", follow_redirects=True)
role = sqlite3.connect(application.DB).execute(
"SELECT role FROM adherents WHERE email = 'lea@test.fr'"
).fetchone()[0]
assert role == "adherent"
# --- Exercice 11 : injection SQL -----------------------------------------
def test_injection_sql_sur_la_connexion(client):
"""Avec des requêtes paramétrées, cette charge ne doit rien ouvrir."""
inscrire(client)
reponse = client.post("/connexion", data={
"email": "lea@test.fr' OR '1'='1",
"mot_de_passe": "peu importe",
}, follow_redirects=True)
assert b"Identifiants incorrects" in reponse.data
assert b"Mon profil" not in reponse.data
# --- Exercice 12 : XSS ---------------------------------------------------
def test_xss_dans_le_prenom_est_echappe(client):
inscrire(client)
connecter(client)
client.post("/profil", data={
"nom": "Dupont", "prenom": "<script>alert(1)</script>",
"niveau": "debutant",
}, follow_redirects=True)
reponse = client.get("/profil")
assert b"<script>alert(1)</script>" not in reponse.data
assert b"&lt;script&gt;" in reponse.data
@@ -0,0 +1,79 @@
"""Vérification du poste avant l'atelier.
Lancez simplement : python verif_poste.py
Puis copiez-collez TOUT le résultat au formateur.
Ce script ne modifie rien et n'installe rien.
"""
import platform
import sys
MINIMUM = (3, 9)
def version_python():
v = sys.version_info
actuelle = f"{v.major}.{v.minor}.{v.micro}"
if (v.major, v.minor) >= MINIMUM:
return True, f"Python {actuelle}"
return False, (f"Python {actuelle} — TROP ANCIEN, il faut au moins "
f"{MINIMUM[0]}.{MINIMUM[1]}")
def module_present(nom):
try:
__import__(nom)
except ImportError:
return False, f"{nom} : ABSENT"
try:
from importlib.metadata import version
return True, f"{nom} : {version(nom)}"
except Exception:
return True, f"{nom} : présent"
def dans_un_venv():
actif = sys.prefix != getattr(sys, "base_prefix", sys.prefix)
if actif:
return True, "environnement virtuel : actif"
return True, "environnement virtuel : aucun (ce n'est pas bloquant)"
def main():
print("=" * 56)
print(" VERIFICATION DU POSTE - ATELIER PYTHON WEB")
print("=" * 56)
print(f" Systeme : {platform.system()} {platform.release()}")
print(f" Executable : {sys.executable}")
print("-" * 56)
resultats = [version_python(), dans_un_venv()]
for nom in ("flask", "pytest"):
resultats.append(module_present(nom))
for ok, message in resultats:
print((" [ OK ] " if ok else " [ KO ] ") + message)
print("-" * 56)
bloquant = not resultats[0][0]
modules = zip(("flask", "pytest"), resultats[2:], strict=False)
manquants = [nom for nom, (ok, _) in modules if not ok]
if bloquant:
print(" ACTION : installez une version recente de Python depuis")
print(" python.org, en cochant 'Add python.exe to PATH'.")
print(" Prevenez le formateur des ce soir.")
elif manquants:
print(" ACTION : lancez cette commande, puis relancez ce script :")
print()
print(" python -m pip install " + " ".join(manquants))
else:
print(" Tout est pret. Rien a faire.")
print("=" * 56)
if __name__ == "__main__":
main()