dd8b9671c672f23f7c45d44f90dfd2696e2802db
140
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
dd8b9671c6 |
fix(authz): teams.py etait le module que la validation n avait pas atteint
SEC-16, de l'audit anterieur. La vague G a pose un schema a la frontiere de
matches, team_matches, tryouts et evaluations, et le HANDOFF en a tire une
regle : tout champ de formulaire passe par un schema de app/validators.py,
pas par request.form.get. teams.py ne l'avait jamais appliquee.
Deux defauts, pas un.
int(request.form.get('coach_id')) leve sur une valeur non numerique : une
soumission fabriquee etait un 500.
Et l'identifiant obtenu etait ensuite resolu sans verifier le role du compte,
dans deux des trois endroits qui le faisaient. La branche sync_staff
d'edit_team testait isinstance(user, Coach) ; son autre branche non, et
create_team non plus. Le meme fichier en desaccord avec lui-meme, sur
exactement le defaut que la vague G avait corrige dans tryouts.py -- une
soumission fabriquee pouvait nommer un joueur parmi les coachs d'une equipe.
L'identifiant vient d'un <select> rendu par le navigateur : c'est une valeur
que le client choisit.
_staff_member est la reponse unique, et OrgTeamSchema garantit que les
identifiants arrivent en entiers. Les deux tests qui epinglent le role
tombent si la verification saute : verifie par mutation.
Co-Authored-By: Claude Opus 5 <[email protected]>
|
||
|
|
ad3dea6a15 |
fix(web): une erreur sur un point JSON ne renvoie plus une page HTML
STD-09, trouve en recroisant l'audit anterieur -- celui mene sur le miroir GitHub, jamais repasse depuis qu'on a decouvert que ce n'etait pas la bonne source. Sept gestionnaires d'erreur portaient chacun leur copie d'une liste de prefixes d'URL decidant "JSON ou page HTML". Les copies avaient derive -- trois testaient /users/coach-availability, quatre non -- et toutes manquaient les memes points. Un fetch() qui recoit une page d'erreur HTML leve en la parsant : sur le calendrier, les listes de selections et d'equipes restaient vides, sans message dans la page et sans rien dans le journal. Deux choses apprises en ecrivant le test, aucune n'etait dans le constat. L'approche par prefixe ne pouvait pas etre reparee. Trois des seize vues JSON sont a des chemins qu'aucun prefixe ne distingue des pages HTML voisines -- /matches/<id>/toggle-presence/<id> et ses deux cousins, que les gabarits appellent justement en fetch(). Les vues se declarent donc elles-memes (@json_endpoint, app/api.py), et un test parcourt la carte des URL pour verifier qu'aucune vue appelant jsonify n'a ete oubliee. Et surtout : @login_required n'atteint jamais le gestionnaire 401. Flask-Login intercepte avant et redirige. Les seize points JSON repondaient donc a une session expiree par une 302 vers un formulaire HTML, quoi que dise la liste de prefixes. Reecrire la liste seule aurait eu l'air d'un correctif sans rien changer. Au passage, le message flash de ce gestionnaire etait la seule chaine de l'application qui n'avait jamais ete traduite. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
8d7de75e99 |
chore(ops): nommer le stockage du rate limiting au lieu de le subir
SEC-WEB-004. Limiter() ne nommait aucun stockage, donc Flask-Limiter retombait sur memory://. Le choix n'avait jamais ete fait : c'etait simplement ce qui arrivait. Pour un seul processus Waitress, memory:// est la bonne reponse -- ce qui est precisement pourquoi il fallait l'ecrire. Un deuxieme worker laisserait passer deux fois chaque limite, en silence, avec une configuration qui a l'air inchangee. RATELIMIT_STORAGE_URI rend la valeur lisible dans .env, modifiable en une ligne le jour ou le deploiement gagne un processus, et le demarrage journalise laquelle est active. La part qui reste bloquee est nommee dans le code : un stockage partage ne rend pas les limites solides tant qu'elles sont indexees sur une adresse IP falsifiable, c'est-a-dire tant qu'OPS-002 / SEC-WEB-002 n'est pas tranche avec le developpeur. C'est pour cela que celui-la est le prerequis et pas celui-ci. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
06d6ad7eaa |
feat(ops): un bot qui tombe se releve, ou dit pourquoi il ne peut pas
OPS-004. bot.run() qui rend la main signifie que la connexion est perdue pour de bon : discord.py se reconnecte seul pour tout ce qui est recuperable. Ce qui se passait ensuite, c'etait rien. Le fil se terminait, bot_thread restait non nul donc start_bot n'en relancerait jamais un autre, et l'application web continuait a servir des pages pendant que toutes les notifications et tous les rappels quotidiens s'etaient arretes. La vague E/F avait fait la moitie visibilite (OPS-012), pas la moitie reprise. La panne pouvait durer des semaines. Deux fins sont distinguees, parce que reessayer ne sert que pour l'une. Un jeton rejete ou un intent privilegie manquant est une erreur de configuration : boucler dessus ne fait que marteler le point de connexion de Discord, ce qui est la maniere d'obtenir une limitation ou un bannissement. Le reste est traite comme une panne et reessaye avec une temporisation exponentielle, plafonnee a cinq minutes, tant que le processus vit. La temporisation se reinitialise apres une connexion qui a dure. Sinon un bot qui tourne un mois puis decroche attend cinq minutes avant son premier essai, fort d'un incident depuis longtemps termine. Deux consequences de conception. Une instance neuve a chaque tentative : discord.py ferme le client quand run() rend la main, et un client ferme ne se reconnecte pas -- le reutiliser transforme une reprise en fil qui tourne sur une exception. Et donc la file de messages passe au niveau module, sinon chaque redemarrage emporterait les notifications en attente. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
e76cd7bb23 |
fix(authz): aligner la suppression d equipe, sans elargir l acces
SEC-AUTHZ-006. delete_team etait la seule des dix operations d'equipe gardee par la capacite globale can_manage_teams() ; les neuf autres passent par can_manage_this_org_team(team). Le constat avait raison sur l'incoherence et tort sur le correctif. Appliquer sa recommandation telle quelle -- remplacer par la verification par objet -- ELARGIT l'acces. Coach repond False a la capacite globale et True pour ses propres equipes : la substitution donnait donc a chaque coach le pouvoir de supprimer l'equipe qu'il entraine, avec ses notes d'equipe et son historique de matchs. Le constat raisonnait sur Manager, ou les deux repondent True, et a manque le role ou elles divergent. Les deux sont donc exigees. Le comportement d'aujourd'hui est preserve a l'identique (administrateurs et gerants oui, coachs non) et la dette que le constat visait est bien fermee : le jour ou Manager.can_manage_this_org_team sera resserre -- ce qui est souhaitable -- la suppression se resserrera avec lui au lieu de rester la seule porte ouverte. C'est la quatrieme recommandation d'audit qu'il faut corriger avant de l'appliquer. Le test qui l'epingle echoue si quelqu'un refait la simplification : verifie par mutation. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
bda23dbb67 |
fix(ops): la sauvegarde des contrats archivait le mauvais repertoire
OBS-006. Trois racines etaient baties sur os.getcwd() : le magasin de documents, les journaux et les sauvegardes. La vague G a corrige la premiere, parce qu'elle bloquait aussi OPS-011, et a laisse les deux autres. C'est la lecon deja consignee deux fois : un motif fautif corrige dans une seule couche reste dans les autres. Le plus serieux n'est pas le motif, c'est l'ecart qu'il a ouvert. backup.py gardait sa propre constante DOCUMENTS_DIR sur os.getcwd(), donc il ignorait DOCUMENTS_ROOT -- la variable que la vague G a introduite et que docs/deployment.md dit maintenant de regler pour sortir les televersements des repertoires de version. Des qu'un exploitant suit cette consigne, le script archive un repertoire ou l'application n'a jamais rien ecrit. Et comme il repond a un repertoire absent par une ligne d'information et un code de sortie 0, une tache planifiee qui surveille le code de sortie voit vert indefiniment. Autrement dit : plus l'exploitant suivait correctement la documentation de deploiement, plus surement ses sauvegardes de contrats etaient vides. Les trois racines viennent desormais d'app/storage.py, resolues a l'appel et non a l'import, et la sauvegarde imprime la source qu'elle a utilisee. Le message d'absence nomme le chemin ou elle a cherche : "No documents directory found" se lisait comme "il n'y a pas de documents" plutot que comme "je regarde au mauvais endroit". Le test qui porte est celui qui ouvre l'archive : un zip vide est un fichier de taille non nulle, donc verifier qu'un fichier a ete produit ne prouvait rien. Verifie par mutation. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
66f2838402 |
chore(lint): interdire d avaler une exception sans laisser de trace
Regle BLE de ruff activee. Ce qu'elle enforce n'est pas "ne jamais attraper large" : elle se satisfait d'un logger.exception. C'est exactement la discipline visee — une frontiere peut tout avaler, a condition de laisser de quoi distinguer un defaut d'une panne. Les cinq noqa que j'avais prepares d'avance etaient donc inertes ; la raison reste en commentaire simple. Ce que la regle a trouve, une fois activee : app.py, demarrage du bot — les deux facons d'echouer, jeton invalide et import casse, se lisaient a l'identique sur une seule ligne et aucune n'etait diagnosticable. Passe en error avec exc_info : un club qui ne recoit plus aucun rappel a perdu une fonctionnalite, et warning mettait ca a cote des avis de depreciation. services/notifications.py — le bloc webhook attrapait large autour d'un requests.post. RequestException couvre toutes les facons dont un appel HTTP echoue ; le reste est un defaut. La branche DM et la branche webhook etaient en plus imbriquees dans un seul try alors qu'elles s'excluent. logging_config.py et les deux scripts CLI gardent leur largeur, avec la raison sur la ligne. Le filtre de journalisation est le cas ou la trace que BLE001 reclame est precisement ce qu'il ne faut pas produire : journaliser depuis un filtre rentre dans le meme filtre. RUF100 (noqa inutile) n'est volontairement pas active : il ferait remonter des directives preexistantes sans rapport avec ce chantier. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
546f28571b |
fix(bot): une reaction qui echoue le dit, au lieu de se taire
Chaque gestionnaire de discord_bot.py enveloppait tout son corps dans un except Exception qui journalisait et poursuivait. Un refus de la base, une boite de reception fermee et une faute de frappe dans ce module produisaient la meme ligne, et la personne qui avait clique n'apprenait rien dans aucun des trois cas. Le defaut que l'audit citait en exemple : dans handle_attendance_confirm, le commit et le message "Your attendance has been confirmed!" etaient dans le meme bloc protege. Si le commit levait, rien n'etait envoye et rien n'etait signale. La reaction devenait indiscernable d'un bot arrete. Trois familles, trois reponses. La base refuse : rollback, l'entree pending est conservee pour que la reaction reste reessayable, et la personne est prevenue que rien n'a ete enregistre. Discord est injoignable : apres un commit c'est du meilleur effort, un DM qui rebondit ne defait pas une decision prise. Tout le reste est un defaut et remonte, jusqu'a on_error, qui est ajoute parce que discord.py journalise sur le logger 'discord' que configure_logging ne collecte pas. Trouve en appliquant : deux reactions sur le meme message passent toutes deux le test d'appartenance puis s'attendent sur deux await, et la perdante levait un KeyError qui se lisait comme une erreur sans consequence ; un fetch_channel en echec renvoyait sans un mot, donc un clic sans effet et sans trace ; un start_scheduler en echec supprime tous les rappels a jamais et laissait trois cles de /health au vert, d'ou reminders_scheduled. L'ordre des etapes apres le commit est desormais fixe : oublier l'entree pending avant les messages, sinon un DM rebondi laisse une demande deja approuvee reactivable une seconde fois. Les tests ont ete verifies par mutation du code de production. La deuxieme mutation a trouve une faiblesse dans le test lui-meme, qui ne regardait que le premier message emis. QUA-004 (roadmap) / ARCH-008 (constats). Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
7a1dab21cd |
docs: les deux documents d exploitation qui manquaient
DOC-001 demandait quatre documents courts. Deux existaient — installation (deployment.md) et restauration (database-restore.md). Les deux autres n existaient pas. docs/roles-and-permissions.md decrit le comportement **implemente**, pas celui qu on souhaiterait, et note les endroits ou les deux divergent : un gerant gere toutes les equipes mais pas toutes les selections, asymetrie presente depuis toujours et ecrite nulle part ailleurs. Le piege du double rattachement coach/equipe y est en toutes lettres, avec le motif a ne jamais reintroduire. Le document dit aussi qui a raison en cas de desaccord : les tests. Une documentation d autorisation qui se contredit avec le code est pire que pas de documentation, parce qu on la croit. docs/incident-runbook.md part du symptome, pas du composant. Le reflexe d ouverture est la reference affichee sur la page d erreur — c est ce que l identifiant de requete rend possible, et sans un endroit qui le dise, la fonctionnalite ne sert a personne. Trois affirmations ont ete verifiees contre le code avant d etre ecrites, et deux etaient fausses : le corps de /health en echec dit database="error" et non "disconnected", et les evenements de verrouillage s appellent login.failure et account.throttled. La liste complete des quatorze evenements de auth.log est maintenant dans le manuel. README : index des documents, et deux points d etat corriges — TRUSTED_PROXY est desormais une variable, et l absence de validation des inscriptions par le staff est nommee comme decision en attente. |
||
|
|
709e8a5d51 |
fix(data): supprimer un compte emporte ses contrats
DATA-012. delete_user supprimait les lignes Contract et laissait les PDF. Des contrats nominatifs signes restaient donc sur le serveur apres la suppression du compte, sans plus aucune reference en base : invisibles pour l application, ingerables par elle, et toujours des donnees personnelles. Les chemins sont lus **avant** que les lignes partent — apres, plus rien ne dit ou sont les fichiers — et les fichiers sont retires **apres** le commit. L ordre compte dans ce sens et pas dans l autre : un echec entre les deux doit laisser un fichier sans ligne, ce qui est recuperable et correspond exactement a l etat precedent, plutot qu une ligne sans fichier, qui est un telechargement en 500 pour toujours. Un fichier deja absent est journalise en info et ignore ; un fichier impossible a retirer est journalise en erreur avec ce que ca implique — il devient orphelin, donc plus rien dans l application ne proposera jamais de le supprimer. Rien ici ne peut faire echouer la suppression du compte : le compte est la partie que quelqu un a demandee. Le nombre de fichiers retires part dans le journal d authentification, a cote de account.deleted. 554 tests. |
||
|
|
70db8a7491 |
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. |
||
|
|
7b9eee4805 |
feat(db): mesurer la derive du schema, au lieu de la supposer
DB-001. create_all() cree les tables manquantes et ne fait jamais d ALTER. Une colonne ajoutee a un modele il y a six mois est donc absente de toute base qui possedait deja la table, et rien ne le dit : l application demarre, et la premiere requete qui touche cette colonne echoue a l execution. C est la raison d etre de migrations/add_tryout_coaches.py, ecrit a la main pour rattraper un cas. Personne ne sait combien il y en a d autres. app/supporting_scripts/schema_report.py compare le catalogue d une base vivante aux modeles : tables, colonnes, types, nullabilite, contraintes d unicite, cles etrangeres, index. En **lecture seule** — il ouvre une connexion, lit, imprime, sort. Aucun DDL, aucun DML. Les constats sont classes par ce qu ils coutent, pas par ce qu ils sont : - BLOCKING : les modeles l attendent, la base ne l a pas. C est la derive ; - RISK : la base l a, aucun modele ne le decrit. Inoffensif tant que rien ne bouge — et **un alembic --autogenerate proposera de le supprimer**, avec ses donnees. C est la classe qu on lit en entier ; - DIFFERENCE : types, nullabilite, contraintes. Chacune demande un humain. Les types sont compares apres compilation vers le meme dialecte : opposer String(200) a VARCHAR(200) en chaines aurait signale chaque colonne comme differente, et un rapport qui crie partout ne se lit plus. --check-seed-accounts repond a la question de SEC-003 a laquelle le depot ne peut pas repondre : le compte admin/password seme par clear_db.py existe-t-il encore, et son mot de passe est-il toujours celui-la. 13 tests le pilotent contre des bases SQLite fabriquees pour diverger d une facon connue. Le cas qui compte le plus est la base propre : un rapport qui crie sur une base saine ne sera pas lu, et un rapport qui dit « aucun ecart » sur une base derivee est pire que pas de rapport — c est un feu vert pour laisser autogenerate ecrire la difference en DROP. docs/database-schema.md donne la suite, etape par etape, avec le piege de DB-002 en toutes lettres : la migration initiale doit decrire la base telle qu elle est, pas telle que les modeles la decrivent. Generer depuis les modeles puis estampiller revient a declarer que la derive n existe pas. Alembic n est pas ajoute aux dependances : rien ne l utilise encore, et une dependance que rien n utilise est exactement ce que l audit reprochait ailleurs. Le document dit a quelle etape l ajouter. 530 tests. |
||
|
|
3882b6035f |
fix(ops): defauts surs a la copie, CDN epingles, actions epinglees
Quatre taches de la matrice du rapport, toutes sans dependance, qu aucune liste de « ce qui reste » ne reprenait. OPS-003 — app/.env.exemple disait « copiez ce fichier et remplissez les valeurs pour la production », puis posait FLASK_DEBUG=true, SESSION_COOKIE_SECURE=false et FORCE_HTTPS=false. Le debogueur Werkzeug execute du code soumis par le navigateur : cette ligne transformait un copier-coller en shell distant. Chaque valeur est desormais sure a la copie, et le fichier refuse de demarrer tant que les deux secrets obligatoires ne sont pas remplis plutot que de demarrer grand ouvert. Renomme en .env.example : l orthographe francaise ne correspondait pas a l exception !.env.example du .gitignore, donc le fichier n etait suivi que par accident de l ordre des regles. Les deux points de la decision ouverte du §8 tombent d un seul git mv. OPS-002 — trusted_proxy='*' et HOST ne sont plus soudes dans wsgi.py. Les defauts sont **inchanges**, deliberement : choisir sans connaitre la topologie coupe la prod si nginx est ailleurs, ou casse la limitation de debit pour tout le monde si on cesse de croire X-Forwarded-For alors que c etait la seule source d adresses. Ce sont maintenant des variables, les valeurs sures sont dans .env.example pour un nouveau deploiement, et docs/deployment.md donne les quatre topologies avec la valeur de chacune. wsgi.py avertit au demarrage tant que les deux defauts sont en place. Le commentaire de HOST annoncait « bind to localhost by default » a cote d un defaut a 0.0.0.0 : il decrivait l intention pendant que le code faisait l inverse. Il dit maintenant ce qu il fait. QUA-004 — Font Awesome et FullCalendar etaient charges sans empreinte, depuis des hotes que la CSP autorise nommement. Qui controle ces CDN controlait ce qui s execute sur chaque page. Empreintes posees, avec ce que SRI promet et ce qu il ne promet pas ecrit a cote : ca fige le fichier, ca ne prouve pas qu il etait honnete au moment du calcul. **Le CSS de FullCalendar n existait pas.** La v6 embarque ses styles dans le JS et ce fichier n est pas publie : le <link> repondait 404 a chaque ouverture du calendrier depuis la montee de version. Une feuille de style en echec est silencieuse dans le navigateur, c est ce qui l a fait durer. CI-003 — actions epinglees sur un commit, version en commentaire, dans les deux forges. Un tag est un pointeur mobile : deplacer v4 fait executer du code arbitraire dans le job qui detient la cle SSH de production. Ce job recoit aussi enfin un bloc permissions. 517 tests. |
||
|
|
39808dd04e |
ops: un deploiement qui refuse de partir casse, et qui se verifie
OPS-011, en partie. Ce que le workflow garantit maintenant : - rien ne part d un arbre casse. La suite, ruff check et ruff format tournent sur le runner de deploiement avant tout envoi. Une CI verte sur GitHub ne prouve rien ici : le deploiement se declenche a la main, sur ce que la branche contient a cet instant ; - seuls les fichiers nommes partent. La charge est une liste blanche — app/, wsgi.py, requirements.txt — et non l arbre de travail moins neuf exclusions. C est par cette porte que clear_db.py, la suite de tests et les definitions de CI se sont retrouves sur le noeud de production ; - le deploiement est verifie. /health est interroge pendant deux minutes apres l envoi et le job echoue s il ne repond jamais « healthy ». Avant, un arbre a moitie televerse etait un deploiement vert. Ce qui n est pas garanti, et c est ecrit dans le fichier : la bascule n est pas atomique. Le miroir se fait sur place, donc pendant le transfert la production execute un melange de deux versions. En cherchant a fermer ce point, un defaut a part entiere est apparu. Les contrats etaient ranges a os.getcwd()/documents et leur chemin absolu ecrit en base. La racine de stockage suivait donc le repertoire depuis lequel le processus avait ete lance : redemarrer le serveur ailleurs envoie les nouveaux contrats dans un nouvel arbre et rend les anciens illisibles — la base continuant d affirmer qu ils sont la, la panne se manifeste par un 500 au telechargement, pas par quelque chose d actionnable. app/storage.py fixe la racine et DOCUMENTS_ROOT la deplace. Les nouvelles lignes gardent un chemin relatif, les anciennes gardent leur chemin absolu et continuent de resoudre : aucune migration de donnees n est necessaire, donc ce changement n attend pas Alembic. C etait aussi le troisieme pre-requis de la bascule par repertoires de version. Les deux autres sont hors d atteinte d ici — la commande de demarrage Pterodactyl doit pointer sur current/, et les repertoires partages doivent etre installes sur le noeud. Les deux sont decrits dans docs/deployment.md, avec la procedure de retour arriere qui manquait. 511 tests. |
||
|
|
506a061405 |
test: les regles metier des selections, ecrites noir sur blanc
QUA-003. La suite couvrait securite, autorisation, i18n, CSP et forme des requetes. Elle ne couvrait pas les regles pour lesquelles l application existe : qui entre dans une selection, combien, et ce qui cesse d etre modifiable une fois qu elle est finie. Aucun defaut corrige ici. Ce sont les garanties que les routes tiennent deja, consignees pour qu un remaniement ne puisse pas en laisser tomber une en silence. Deux meritent la lecture, parce qu elles ont l air d etre appliquees et ne le sont qu a moitie : - le plafond de joueurs est un count() suivi d un add(). Deux inscriptions simultanees passent toutes les deux le compte et s inserent toutes les deux. Il n y a aucune contrainte derriere dans le schema (DB-005/006, bloques sur Alembic). Le test enonce la regle pour une requete a la fois, et dit que le cas concurrent n est pas couvert ; - is_ended ferme sur end_date, avec repli sur date, et la comparaison est un < : une selection est encore ouverte le jour meme. C est un choix, et il est facile de l inverser par accident. Le test du plafond a failli passer a vide : une connexion ratee laisse elle aussi exactement une inscription. Il verifie donc que le second joueur est bien entre, et un test temoin montre que sans plafond il s inscrit. C est le piege consigne en vague E — un test qui remplace ou suppose quelque chose doit verifier que ce quelque chose a eu lieu. 501 tests. |
||
|
|
c5e5cfa014 |
refactor(validation): un schema aux frontieres tryout et evaluation
ARCH-005, seconde moitie. Meme forme que pour les matchs : des champs lus a la main sur request.form, deux verifies et le reste cru sur parole. Cote tryout : - game pilote la liste des postes et les champs de gamertag montres au joueur qui s inscrit. Il etait accepte tel quel : une faute de frappe produisait une selection pour laquelle personne ne pouvait etre evalue ; - max_players etait int(x) if x else None — un 500 sur « twelve », et un -3 accepte sans broncher ; - coach_ids etait charge par User.id.in_(...) sans filtre de role. Une soumission fabriquee a la main pouvait donc nommer un joueur coach d une selection, ce qui est une attribution de droits : gerer la selection et evaluer ses joueurs. Ce n est pas un formulaire que l interface propose, et ca marchait. Cote evaluation, validate_score transformait tout ce qui sortait de 1..10 — 11, 0, « bien » — en None. Le critere disparaissait de la moyenne et la page annoncait l evaluation enregistree. Rien ne distinguait « non evalue » de « evalue, refuse, et oublie ». Le calcul de la moyenne remonte sur le modele, en Evaluation.overall_from et apply_scores. Il vivait dans la route, additionnant neuf variables locales, et ne pouvait pas etre exerce sans requete HTTP, session authentifiee et base — c est TEST-002, et c est pourquoi le calcul des scores n avait aucun test. Il en a maintenant six, sans rien monter. Une precision qui compte : aucun critere rempli donne None, pas 0. La grille commence a 1, donc un zero serait une note qu aucun joueur ne peut recevoir, et qui le classerait sous tout le monde dans la liste. Les neuf criteres sont ecrits en toutes lettres dans le schema plutot que generes depuis le modele — un schema se lit — et un test verifie que les deux listes coincident. C est la garde qui empeche la derive, pas l astuce. Douze chaines traduites, dont trois que pybabel avait devinees en fuzzy : une entree fuzzy est ignoree a l execution, le piege est consigne dans docs/translations.md. 30 tests neufs. 477 au total. |
||
|
|
0308eb9eef |
refactor(validation): un schema a la frontiere des matchs
ARCH-005, premiere moitie. matches.py et team_matches.py lisaient une quarantaine de champs sur request.form a la main et les croyaient tous. Ce que ca produisait n etait pas bruyant : - edit_match attrapait une heure invalide et faisait start_time = None, puis annoncait que le match etait mis a jour. Le match perdait son heure et le calendrier l affichait a minuit ; - match_type etait accepte tel quel. Une valeur inconnue creait un match auquel aucun joueur n etait rattache, sans un mot ; - une fin avant le debut etait enregistree telle quelle ; - title est NOT NULL dans le modele et n etait pas verifie dans la route, donc un titre vide etait un 500 ; - 'a,b' dans la selection de joueurs arrivait sur int() sans garde. app/forms.py rassemble les deux fonctions de frontiere, qui vivaient dans users/_shared.py parce que c est la qu elles avaient d abord servi. Elles y restent re-exportees, donc aucun des trente appels n a bouge. Le mixin des schemas lit desormais un champ vide comme un champ absent. C est ce qui rendait ces formulaires invalidables : un formulaire HTML envoie tout ce qu il affiche, donc une date optionnelle non remplie arrive comme '' et non comme rien. Seuls les champs declares optionnels sont concernes ; un champ requis laisse vide doit toujours echouer. Deux duplications absorbees au passage, toutes deux nommees par l audit : la boucle de creation des participants, ecrite deux fois et deja divergee — la copie de edit_match gardait ses identifiants en chaines et appelait int() une ligne plus loin — et le contexte de re-affichage du formulaire, dont les versions courtes faisaient mourir un refus dans tojson sur un Undefined : un message de validation devenait un 500. Limite connue et consignee : le formulaire revient rempli avec les valeurs enregistrees, pas avec la saisie refusee. Reafficher la soumission demande de toucher aux gabarits, c est un autre changement. 19 tests neufs sur ces routes, qui n en avaient aucun. 447 au total. |
||
|
|
d8541678a6 |
fix(auth): retirer un CAPTCHA qui ne protegeait rien, filtrer autrement
SEC-AUTH-008. L addition a deux operandes entre 1 et 10 avait dix-neuf reponses possibles et se resolvait en lisant la question comme une chaine. Elle n arretait aucune inscription automatisee. Elle ajoutait en revanche une etape a chaque personne, lecteur d ecran compris, contre une apparence de protection — ce qui est pire que rien, puisque ca se compte comme une protection. L autre branche proposee par l audit etait un vrai service de CAPTCHA : un tiers, une cle d API, une requete a chaque affichage, et un script etranger remis dans script-src, defaisant le travail qui a ferme SEC-WEB-001. Disproportionne pour le site d un club. A la place, deux verifications invisibles pour un visiteur : un champ piege, cache par la feuille de style et hors du parcours clavier, qu un robot remplisseur complete et qu une personne ne voit jamais ; et un delai minimal entre la remise du formulaire et son retour, l horodatage etant dans la session signee et non dans un champ. Le plafond est dit dans le code plutot que sous-entendu : ceci arrete le pourriel de masse, pas quelqu un qui lit la page. Ce qui filtrerait vraiment les inscriptions serait l activation des comptes par le staff — is_active_account vaut True par defaut. C est une decision de produit. L angle « session forgeable » du constat tombe : avec SECRET_KEY compromise (SEC-001), on forge une session connectee sur n importe quel compte et on n a aucune raison de s inscrire. Au passage, ARCH-005 en partie : le bloc « regenerer, purger les mots de passe, re-rendre » etait recopie quatre fois. Un seul helper, et la purge des mots de passe ne peut plus etre oubliee dans la cinquieme copie. Les refus sont journalises avec leur motif — c est le seul endroit ou un abus du formulaire devient visible — mais restent indistinguables pour l expediteur : nommer la regle indique comment la contourner. 429 tests. |
||
|
|
d0a9e75fe6 |
perf: servir les statiques par nginx, et dire ce que le bot n a pas livre
PERF-006. Le bloc location /static/ etait commente : 59 Ko de CSS et de JS
passaient par Waitress a chaque page. L activer tel quel aurait ete une
regression : ces URL ne changent jamais, donc un cache de 30 jours sert une
feuille de style vieille d un mois apres chaque deploiement, sans moyen de
l invalider. url_for('static') estampille maintenant chaque URL du mtime du
fichier ; c est ce qui rend le immutable vrai et pas seulement rapide.
Deux pieges nginx consignes dans le fichier : un add_header dans un location
annule tous les add_header herites du server (nosniff disparaissait du
JavaScript), et un statique manquant doit renvoyer 404 plutot que retomber
sur Flask, sinon un deploiement casse se cache derriere une page qui marche.
PERF-005. Les objets utilisateur Discord sont mis en cache. A etre precis
sur le gain : un envoi coute deux appels reseau, resoudre puis envoyer, et
seul le premier est economise — un premier match a vingt joueurs fait
toujours vingt resolutions. Ce qui est gagne l est entre notifications, la
ou le bot ecrit aux memes personnes soir apres soir.
Chaque message dit desormais ce qu il est devenu, avec le destinataire et
la raison. Les trois echecs ne se ressemblent pas et ne se lisent plus
pareil : une boite fermee est definitive et ne se retente pas, une erreur
HTTP est passagere, un identifiant sans proprietaire est un compte a
corriger. Le lot quotidien annonce son propre deficit.
Piege trouve en ecrivant les tests : configure_logging met propagate=False
sur le logger 'app', et le handler de caplog est sur la racine. Les
assertions sur les journaux passaient seules et echouaient dans la suite
complete, ou une application avait deja ete construite — elles lisaient un
journal vide, pas un bot silencieux.
417 tests.
|
||
|
|
47ff544848 |
chore(lint): trier les imports, sauf la facade des modeles
Active la regle isort (I) de ruff. 45 fichiers reordonnes, aucun changement de comportement : la suite passe avant comme apres. app/models/__init__.py en est exclu. Ses imports sont ranges en onze couches commentees qui decrivent le graphe de dependances ; trier par ordre alphabetique laisse chaque titre au-dessus d un import qu il ne decrit pas, et ce fichier n a qu un role, etre lu. Commit isole, comme le formatage : un diff de brassage ne doit pas servir de couverture a un changement de comportement. |
||
|
|
ab0b975211 |
fix(arch): l inscription est une operation, donc une transaction
ARCH-006, avec une requalification du constat.
**Le chiffre de l audit surestimait le probleme.** « 58 commit() en routes
pour un seul rollback() » lisait un ratio comme un defaut. Mesure plutot
que suppose :
- Flask-SQLAlchemy demonte la session a la fin de chaque requete, ce qui
annule tout ce qui n a pas ete commite ;
- l unique rollback est dans le gestionnaire 500, c est-a-dire au bon
endroit ;
- depuis la vague D, aucun module de routes ne contient `except
Exception` : les 34 releves sont dans discord_bot.py, les scripts et le
service de notification, ou avaler l erreur est le comportement voulu
et documente.
Ce que le decompte ne pouvait pas voir, c est le vrai defaut : une fonction
qui commite DEUX fois, ou un echec apres le premier commit laisse une
demi-operation persistee. Il y en avait deux dans tout le depot -- une
analyse AST le confirme. edit_user etait la grave, corrigee avec ARCH-008.
register est la seconde : le compte etait commite, puis les gamertags dans
une seconde transaction. Un echec entre les deux laissait un compte dont
les jeux declares etaient absents, l inscription etant annoncee reussie.
Le premier commit devient un flush -- l identifiant est necessaire pour les
lignes suivantes, pas la durabilite.
Les deux commit() de login ne sont pas concernes : ils sont dans des
branches mutuellement exclusives, succes et echec.
tests/test_transactions.py enonce la garantie plutot que de la supposer :
un echec en cours de requete ne laisse aucune ligne, et l inscription est
tout ou rien. Le second echoue sur le code d avant.
Co-Authored-By: Claude Opus 5 <[email protected]>
|
||
|
|
aebc28fb8a |
docs: une seule source d architecture, et un README qui dit vrai
DOC-002. docs/architecture.html (746 l.) et docs/architecture-v3.html (798 l.) coexistaient, en HTML versionne, sans rien qui indique laquelle faisait foi ni ou etait passee la v2. docs/architecture.md reprend la v3 -- la plus complete : 24 classes au lieu de 19, plus la structure des paquets -- en Markdown avec les diagrammes en Mermaid. Lisible en revue, comparable en diff, rendu directement par Gitea comme par GitHub. Ajoute au document ce que les diagrammes ne montrent pas et qu il faut savoir avant de les lire : la double modelisation coach/equipe, l absence de migrations, et le bot dans le meme processus que le web. README Il annoncait « Authorization Checks: Proper ownership validation on all sensitive operations » a une epoque ou trois IDOR etaient ouverts, et « Rate Limiting » sans mentionner que trusted_proxy='*' la rend contournable. Il ne disait pas non plus comment installer, lancer ni tester le projet. Reecrit en francais, avec deux sections distinctes : ce qui est en place et verifie par des tests, et **ce qui ne l est pas** -- migrations absentes, secrets non revoques, proxy de confiance, identite Discord non prouvee. Une liste de securite qui ne mentionne que les bonnes nouvelles est pire qu absente : on s y fie. Instructions d installation, de lancement et de verification ajoutees, avec les deux seuls points d entree (ARCH-007). Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
e5c29d8113 |
fix(ops): rendre visibles les pannes silencieuses du bot Discord
OPS-005, OPS-006, OPS-007, OPS-009 et OPS-012. Cinq constats, un motif commun : le bot pouvait cesser de fonctionner correctement sans que rien, nulle part, ne le dise. OPS-006 -- etat en attente ecrit en place _save_pending ouvrait le fichier de destination en ecriture puis serialisait dedans : toute interruption laissait un JSON tronque. Et _load_pending interceptait l erreur de lecture, la journalisait, puis repartait avec un dictionnaire vide -- toutes les correspondances message Discord <-> demande disparaissaient, les reactions en cours cessaient d avoir un effet, et l interface n en montrait rien. Ecriture par fichier temporaire voisin puis os.replace : la destination contient l ancien contenu ou le nouveau, jamais la moitie d un des deux. A la lecture, un fichier illisible est deplace en .corrupt-<horodatage> plutot qu ecrase, et le message dit ce qui est perdu. Ecart assume avec la recommandation d audit (« echouer bruyamment ») : le bot demarre quand meme. Refuser de demarrer supprimerait toutes les notifications au lieu de celles deja en vol. OPS-007 -- fuite lente Les entrees n etaient retirees qu apres reaction. Elles portent desormais `created_at` et sont purgees au chargement au-dela de 30 jours. Une entree sans horodatage est conservee : elle precede ce champ, la supprimer serait deviner son age. OPS-005 -- planificateur `coalesce=True`, `misfire_grace_time=3600`, `max_instances=1`. Sans delai de grace, un redemarrage a 18 h 05 perdait les rappels du jour sans trace ; sans coalescence, un planificateur en retard envoie un rappel par occurrence manquee, donc des messages en double. OPS-009 -- controle d identite asymetrique handle_one_on_one_approve et _reject comparent depuis toujours le compte qui reagit au coach destinataire. Les deux gestionnaires de presence ne le faisaient pas. Meme forme de message, meme risque, un seul verifiait : c est l asymetrie qui etait le bug. Au passage : confirmer sa presence a un tryout ecrivait un attribut qui n a pas de colonne (DB-008, bloque sur Alembic). Le joueur lisait « confirme » et rien n etait enregistre. Toujours vrai, mais desormais journalise en warning avec l identifiant concerne. OPS-012 -- etat du bot dans /health Le bot tourne dans un fil demon du processus web. Quand ce fil meurt, le site continue de servir des pages et plus aucune notification ne part. /health expose maintenant configured / running / connected / pending. Signale, pas fatal : un club sans rappels Discord est degrade, pas hors service, et un 503 le sortirait du repartiteur de charge pour ca. discord_pending.json passe hors suivi git. La regle d ignore etait en place mais inerte. Consequence non relevee par l audit : le deploiement etant un miroir de fichiers, chaque livraison ecrasait l etat vivant du serveur par celui du depot. 13 tests, sans aucun appel a Discord. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
2e10bbbd62 |
perf: view_tryout, une requete par lot au lieu d une par ligne
PERF-001, la page la plus consultee de l application. Quatre boucles
posaient une requete par ligne :
User.query.get() par inscription
Evaluation.query par joueur inscrit, pour savoir si ce coach
l avait deja evalue
TeamMember.query par equipe
User.query.get() par membre d equipe
Plus, sur chaque match de type player_vs_player, deux interrogations
supplementaires de la relation dynamique `participants` pour trier par
camp -- alors que la liste complete venait d etre chargee douze lignes plus
haut.
Toutes remplacees par un chargement groupe. Les evaluations de ce coach
sont deduites de la liste `evaluations` deja en memoire, pas redemandees.
Mesure, sur un tryout de 10 inscrits, 2 equipes et 1 match :
34 requetes avant, 12 apres. Le test fixe un budget de 25, volontairement
large -- il ne peut que baisser, et il echoue sur le code d avant.
_users_by_id() est le helper partage par les trois chargements ; une ligne
absente est simplement absente du dictionnaire, ce que faisait deja un
get() renvoyant None.
Un second test verifie que les dix joueurs apparaissent toujours sur la
page : une requete groupee qui perd des lignes est le risque reel ici, pas
l erreur bruyante.
Co-Authored-By: Claude Opus 5 <[email protected]>
|
||
|
|
9d0456c2fd |
perf: borner le calendrier et remplacer trois boucles par des requetes
PERF-002, PERF-003, PERF-004. Aucun changement de comportement : chaque reecriture est accompagnee de tests qui enoncent la reponse attendue, pas la methode. PERF-002 -- /matches/api/events Le flux parcourait `tryout.matches` pour chaque tryout visible -- pour un president, tout l historique du club -- puis posait une requete MatchParticipant PAR match pour savoir si la personne qui regarde y figure. Le cout du calendrier croissait avec l historique, a chaque navigation. FullCalendar envoie deja `start` et `end` sur une source d evenements de type URL. Personne ne les lisait. La requete est desormais bornee, et les participants de tous les matchs de la fenetre sont charges en une fois, joueur compris. Des bornes illisibles sont ignorees plutot que refusees : un calendrier qui en montre trop est un probleme de performance, un calendrier qui renvoie 400 est une page blanche. PERF-003 -- get_players_available_at_time Chargeait tous les joueurs actifs, puis une requete PlayerDisponibility par joueur, sur une colonne non indexee. Deux requetes desormais, quelle que soit la taille du club. Mesure dans le test : 7 requetes pour 6 joueurs avant, 2 apres. PERF-004 -- decompte des evaluations en attente Chargeait toutes les inscriptions du club et toutes les evaluations du coach, construisait deux ensembles Python et les soustrayait -- deux lectures de table entiere pour produire un entier. Un COUNT DISTINCT avec NOT EXISTS. Les tests couvrent ce que la reecriture aurait pu changer sans bruit : fin de creneau exclusive, compte desactive exclu, joueur a cheval sur deux creneaux compte une fois, evaluation d un autre coach qui ne libere pas la ligne, double inscription comptee une fois (DB-006 n a pas encore atterri, donc le cas existe). PERF-001 (view_tryout) n est pas fait : c est le plus gros des quatre, il touche la page la plus consultee et merite son propre lot. 23 tests ajoutes, 371 au total. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
0cd9a186ee |
refactor(services): un seul endroit pour annoncer un match planifie
Seconde moitie d ARCH-003.
Les memes vingt lignes vivaient dans trois routes -- matches.create_match,
matches.edit_match et team_matches.create_match : formater la date et
l heure, puis parcourir deux listes paralleles pour apparier un joueur avec
la ligne de participation qu une reaction Discord doit pouvoir retrouver.
Trois copies, donc trois occasions de diverger. Elles avaient deja diverge :
create_match lisait les heures des variables qu il venait d analyser, et
affichait 'TBD' des qu une des deux manquait ;
edit_match les relisait depuis la ligne enregistree et substituait
l heure de debut a une heure de fin absente.
Un match avec une heure de debut et pas de fin annoncait donc une heure
dans une route et 'TBD' dans l autre, pour la meme donnee.
app/services/scheduling.py retient la regle la plus soigneuse des deux.
zip_participants() isole l appariement par index, qui n est correct que
tant que les deux listes sont construites en phase -- desormais un seul
endroit a relire au lieu de trois, et une liste plus courte donne None,
converti en reference vers le match lui-meme.
matches.py 711 -> 687 lignes, team_matches.py 347 -> 337.
9 tests sur le service seul, sans base ni requete.
Co-Authored-By: Claude Opus 5 <[email protected]>
|
||
|
|
5c87064a17 |
refactor(routes): decouper users.py en paquet, extraire les notifications
ARCH-004 et la moitie NotificationService d ARCH-003. app/routes/users.py faisait 1 699 lignes et couvrait six sujets qui ne partageaient rien d autre qu un prefixe d URL. Il devient un paquet : blueprint.py l objet Blueprint, seul _shared.py helpers de formulaire, gamertags, validation de PDF accounts.py 348 l. liste, creation, edition, suppression, fiche availability.py 233 l. disponibilites joueur et creneaux coach contracts.py 206 l. depot, signature, telechargement notes.py 376 l. notes d equipe et notes nominatives one_on_one.py 259 l. demandes de seance individuelle profile.py 132 l. profil de la personne connectee Aucun fichier ne depasse 400 lignes -- le critere d acceptation de l audit. **Un seul blueprint, pas six.** Les endpoints restent `users.*`. Les renommer aurait touche 137 appels `url_for` dans les gabarits, pour un benefice nul : l objectif est un fichier qu on peut lire, pas une carte d URL a reapprendre. Les 30 endpoints sont identiques avant et apres, verifie sur url_map. send_discord_notification part dans app/services/notifications.py. Elle tirait `requests`, `logging` et le bot Discord dans un module dont le sujet est le traitement HTTP, et se trouvait coincee entre deux definitions de route. Son `except Exception` est conserve et documente : une notification qui n arrive pas ne doit pas annuler la transaction qu elle annoncait. tests/test_route_map.py, nouveau : il parcourt gabarits et code, releve tout endpoint nomme litteralement dans un url_for, et verifie qu il existe dans la carte. C est le mode de defaillance de ce genre de decoupage -- pas une erreur a l import, mais un BuildError chez la premiere personne qui ouvre la page concernee. Un test de garde verifie aussi que le scan trouve quelque chose, sinon le reste serait vide de sens. Piege rencontre, et corrige : test_role_change patchait `app.routes.users.update_user_gamertags`. Apres le decoupage ce nom est un reexport, pas celui qu accounts.py resout -- le patch aurait pu laisser la route appeler la vraie fonction et le test passer sans rien verifier. Ici monkeypatch a echoue bruyamment, mais la cible est desormais explicite et le test enregistre que la doublure a bien ete appelee. 347 tests passent (263 + 84, dont 82 parametres par la carte des routes). Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
8e3865f557 |
chore(lint): elargir les regles ruff et rendre le format bloquant en CI
QUA-002, seconde moitie. Le depot etant formate, l elargissement porte sur
des defauts et non sur du brassage.
Ajoute a la selection : B (bugbear), C4, RET, SIM, UP. Le lot entier n a
produit que 24 signalements sur 76 fichiers -- le code etait plus propre
que l audit ne le craignait. Neuf corriges automatiquement, quinze a la
main.
SIM108 est ignore : forcer un ternaire se lit moins bien que le if/else
qu il remplace, au seul endroit ou il se declenche.
isort (I) n est PAS active. Il reordonnerait les imports de 48 fichiers,
soit une seconde passe de pur brassage juste apres le commit de formatage.
A faire, mais seul.
Deux vrais defauts trouves par les nouvelles regles
- team_matches.edit_match faisait `except ValueError: pass` sur l heure de
debut et l heure de fin, trois lignes sous un champ date qui, lui,
signale et redirige. Une heure mal saisie etait donc acceptee par le
formulaire, jetee, l ancienne valeur conservee -- et la page annoncait
la reussite. Meme traitement que la date desormais.
- backup.py levait BackupError depuis deux blocs `except` sans `from`,
ce qui perdait la cause d origine dans la trace.
Ainsi que : un `return` explicite dans force_https, `%`-formatage remplace
dans log_auth_event (operations de chaine avant journalisation, pas des
gabarits de logger -- la redaction n est pas affectee), une compréhension
inutile, un `set(...)` en compréhension d ensemble, `open(..., 'r')`, une
variable de boucle inutilisee, et `contextlib.suppress` dans conftest.
CI : `ruff format --check` remplace le commentaire qui expliquait pourquoi
il etait absent.
263 tests passent. Les deux nouveaux messages sont traduits ; attention,
pybabel les avait apparies en `fuzzy` avec des entrees « date » existantes,
et une entree fuzzy est ignoree a l execution.
Co-Authored-By: Claude Opus 5 <[email protected]>
|
||
|
|
7cec18c139 |
style: formater le depot avec ruff format
QUA-002, premiere moitie. **Ce commit ne fait que reformater** : aucun changement de comportement, aucune ligne de logique touchee. 72 fichiers, 4 restaient deja conformes. Il est isole exprès, pour que `git log -p` sur les commits voisins reste lisible. `quote-style = "preserve"` etait deja pose dans pyproject.toml, ce qui evite le brassage guillemets simples / doubles : le diff porte sur les retours a la ligne, l indentation des appels longs et les virgules finales, pas sur le style de chaine. Verification : 263 tests passent avant et apres, ruff check propre. L activation en CI arrive dans le commit suivant, separement, pour que ce diff-ci ne contienne rien d autre. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
2f40290f00 |
fix(deps): nommer le pilote PostgreSQL, sinon rien ne demarre
QUA-001, volet dialecte.
`postgresql://` ne veut pas dire "le pilote installe" : SQLAlchemy y lit
psycopg2 et importe ce module a la creation du moteur. requirements.txt
epingle psycopg 3 (`psycopg[binary]`) et pas psycopg2. Une installation
propre demarree sur cette URL leve donc
ModuleNotFoundError: No module named 'psycopg2'
avant la premiere requete. Verifie dans le .venv du depot, et c est
exactement la forme que Render distribue -- celle que docs/deployment.md et
docs/database-restore.md donnaient en exemple.
normalise_database_url() nomme le pilote quand l URL n en nomme pas.
`postgres://` (alias hérite, abandonne par SQLAlchemy en 1.4) est traite de
meme. Une URL qui nomme deja son pilote est laissee telle quelle, y compris
`postgresql+psycopg2://` : un environnement qui a psycopg2 garde le choix.
La normalisation a lieu apres l application de la configuration passee en
argument, pour couvrir aussi les appels de test. backup.py n avait pas
besoin d etre touche : il retirait deja le suffixe +pilote.
Documentation alignee sur les trois fichiers qui donnaient l exemple, dont
docs/deployment.md qui proposait sqlite:/// pour DATABASE_URL alors que
create_app refuse de demarrer sans PostgreSQL.
Reste de QUA-001, dit franchement
- les trois paquets parasites (dotenv, login, discord) ne sont plus dans
requirements.txt : deja retires. psycopg est deja epingle.
- la consolidation vers des groupes de dependances n est PAS faite. Le
deploiement est un miroir de fichiers lftp sans etape de construction ;
les groupes PEP 735 demandent pip >= 25.1 sur une machine dont on ne
peut pas verifier la version d ici. A revoir avec OPS-011.
14 tests, dont trois qui prouvent que l echec est reel et non theorique.
Co-Authored-By: Claude Opus 5 <[email protected]>
|
||
|
|
51877b46a0 |
refactor(arch): un point d entree par usage, et retrait du code mort
ARCH-007.
Trois points d entree, trois configurations differentes
python app/app.py 0.0.0.0:10000, debogueur desactive par defaut
python run.py 127.0.0.2:5000, debogueur ACTIVE par defaut
python wsgi.py Waitress, production
Le bloc __main__ de app/app.py disparait : ce module expose la fabrique.
run.py reste le point d entree de developpement, wsgi.py celui de
production, et c est tout.
run.py passait FLASK_DEBUG a 'true' par defaut. Le debogueur Werkzeug
execute du code soumis depuis le navigateur ; un processus lance ainsi et
laisse joignable est un shell distant. Le defaut passe a 'false', avec
l avertissement reecrit pour dire ce que le mode implique reellement.
L hote devient 127.0.0.1 -- 127.0.0.2 est une boucle locale valide mais
inhabituelle -- et hote comme port sont surchargeables par DEV_HOST et
DEV_PORT.
Code mort retire
- discord_bot.py, notify_player_about_one_on_one : jamais appelee, seule
la variante _direct l est.
- evaluations.py, branche else de list_evaluations : elle listait les
evaluations recues, une vue de joueur, alors que les joueurs sont
rediriges au debut de la fonction et que can_evaluate() est vrai pour
les quatre roles restants. Inatteignable.
Les autres elements du constat sont deja resorbes : get_auth_logger est
appelee depuis log_auth_event (OBS-001), et ALLOWED_CONTRACT_EXTENSIONS /
ALLOWED_SIGNED_EXTENSIONS sont lues par pdf_upload_error (SEC-021).
wsgi.py n est pas touche : trusted_proxy et HOST attendent la reponse du
developpeur sur la topologie reelle (nginx sur la meme machine ou non).
Co-Authored-By: Claude Opus 5 <[email protected]>
|
||
|
|
20158a9e7a |
fix(security): verifier les fichiers televerses et retirer un intent Discord
SEC-021 -- rien ne validait le contrat signe upload_signed_contract se contentait d un nom de fichier non vide. ALLOWED_SIGNED_EXTENSIONS etait declaree juste a cote et jamais lue. Le fichier atterrissait sur le disque sous un nom que download_signed_contract sert ensuite : ce qu un joueur televerse est ce qu un gerant ouvre. Le nom seul ne suffisait pas non plus cote upload_contract, qui verifiait `.pdf` en fin de chaine -- payload.pdf ne dit rien des octets. pdf_upload_error() couvre les deux routes : extension dans la liste, puis signature %PDF- en tete de flux. Le flux est rembobine, l appelant enregistre toujours le fichier entier. OPS-014 -- intent Discord privilegie inutile Le bot demandait GUILD_MEMBERS et ne s en servait pas : rien n enumere ni ne recherche de membre de serveur, les personnes sont jointes par le discord_user_id enregistre sur leur compte. Retire. message_content reste : on_raw_reaction_add lit le texte de la reponse d un coach pour consigner un motif de refus. CI-003 et CI-005 sont deja appliques (permissions: contents: read, checkout@v4, exclusions de deploiement). L epinglage par SHA des actions n est pas fait : ce sont des actions GitHub de premiere partie, et l epingler sans Dependabot echange une exposition contre une autre. 11 tests, dont deux verifient que signer un contrat marche toujours et qu un autre joueur ne peut pas le faire. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
835a394d3f |
fix(auth): enumeration, verrou, deconnexion en GET, redirection ouverte
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]> |
||
|
|
d85da32ef5 |
fix(arch): changement de role sans jeter la session, et en une transaction
ARCH-008. `role` est le discriminateur polymorphe, et SQLAlchemy fixe la classe d une instance au chargement. edit_user ecrivait donc la colonne par un UPDATE de niveau instruction puis relisait la ligne -- c est correct. La suite ne l etait pas. db.session.commit() db.session.remove() # discard stale session entirely user = User.query.get(user_id_local) Deux defauts dans ces trois lignes. 1. remove() jette la session entiere. Tout ce que la requete tenait encore se retrouvait detache, current_user compris ; le moindre acces a un attribut ensuite levait DetachedInstanceError. La route ne survivait qu en ayant recopie le nom et l id de l acteur dans des variables locales avant -- un contournement, pas la correction. Un expunge de la seule instance perimee suffit. 2. Le commit intermediaire coupait l edition en deux. Le role etait acquis avant que le reste du formulaire soit applique : une erreur ensuite laissait un compte promu et le reste perdu, sans qu aucune interface ne le signale. Sans ce commit, l UPDATE reste dans la transaction, la relecture le voit, et l ensemble part en un seul commit. Les evenements d audit passent apres le commit. account.role_changed etait journalise avant l UPDATE : le journal affirmait un changement que la transaction pouvait encore annuler. tests/test_role_change.py, 6 tests. Un seul echoue sur le code d avant -- celui de l atomicite ; les cinq autres fixent le comportement qui marchait deja, pour que la suite de la vague D ne le casse pas. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
92d72e4d48 |
refactor(authz): un seul point de verite pour les autorisations d equipe
ARCH-002. La question "a quelles equipes ce coach est-il rattache ?" etait
posee a huit endroits, de cinq facons differentes, et trois d entre elles
donnaient une mauvaise reponse en production.
Le motif fautif, present tel quel dans six routes :
OrgTeam.query.filter_by(coach_id=user.id).first()
Il repond au plus une equipe, et seulement par la colonne heritee. Deux
pannes en decoulaient, silencieuses -- les pages s affichaient, vides :
- un coach rattache uniquement par la relation many-to-many n avait
aucune equipe, donc aucun joueur, aucun contrat, aucune note d equipe,
aucun match a venir sur son tableau de bord ;
- un coach de deux equipes n en voyait qu une. Le formulaire de contrat
lui proposait la moitie de son effectif, alors que la route POST
acceptait l autre moitie.
app/permissions.py devient le module ou la question se pose une fois :
coach_org_teams, manager_org_teams, attached_org_teams, visible_org_teams,
can_manage_org_team, org_team_player_ids, coach_player_ids,
can_manage_player_contract, coach_tryouts, coach_manages_tryout. Toutes
lisent les deux rattachements et toutes les equipes.
Le meme ecart existait dans le modele : Coach.get_visible_tryouts ne lisait
que la relation m2m -- calendrier vide pour un coach rattache par la
colonne -- et can_manage_this_tryout ignorait la colonne pour l equipe
cible. Les deux delegent desormais.
Corrections de portee, au passage
- one_on_one lisait org_team.coach_id : un joueur dont l equipe declare
ses coachs par la relation etait informe qu il n avait pas de coach, et
le formulaire de demande restait ferme. Passe par get_coaches(), qui
retombe deja sur la colonne heritee.
- notes_dashboard conditionnait les notes personnelles du coach a
l existence d une equipe : un coach sans equipe ne voyait pas ses
propres notes.
- can_manage_team_match reformulait can_manage_this_org_team ; la
reformulation avait derive. Elle appelle maintenant la regle.
Limite assumee : le panneau de notes d equipe reste ecrit pour une seule
equipe et affiche donc la premiere. La resolution est corrigee, la mise en
page multi-equipes ne l est pas -- c est un choix produit, pas un bug.
14 tests ajoutes. Cinq echouent sur le code d avant, verifie en remettant
les routes et le modele a leur etat precedent.
ARCH-001 fera disparaitre la colonne heritee ; cela demande une migration
de donnees, donc Alembic. D ici la, ce module est ce qui rend la
duplication inoffensive.
Co-Authored-By: Claude Opus 5 <[email protected]>
|
||
|
|
37f70c89e3 |
feat(i18n): traduire les messages flash et de validation
198 appels flash dans les sept modules de routes, plus les 23 messages de validation de app/validators.py. Le catalogue compte desormais 631 chaines, aucune non traduite. validators.py utilise lazy_gettext : les champs de schema sont construits a l'import, donc avant qu'une requete existe. Un gettext ordinaire s'y resoudrait une seule fois, dans la langue active au demarrage. Un bug introduit par la conversion, puis corrige Le convertisseur automatique ne voyait que le premier litteral d'un appel flash, ce qui a casse deux chaines concatenees sur plusieurs lignes dans users.py -- le resultat n'etait meme pas du Python valide. Ma premiere verification ne l'a pas vu : elle enchainait py_compile sur head, or head reussit toujours, donc le "OK" s'affichait quoi qu'il arrive. Les deux appels sont reecrits et la verification refaite correctement. Un bug plus interessant, revele par le test de fumee La langue choisie ne survivait pas a la connexion. login() et logout() appellent tous deux session.clear() -- l'un contre la fixation de session, l'autre pour terminer la session -- et le choix de langue partait avec le reste. Concretement : quelqu'un qui lisait la page de connexion en anglais se retrouvait en francais des qu'il se connectait. La langue est une preference d'affichage, pas un etat appartenant au compte. Les deux endroits la reportent maintenant explicitement, a cote du jeton CSRF. Quatre tests couvrent le cas, dont un qui verifie que corriger une cle preservee n'a pas fait tomber l'autre. Detail de nommage : le convertisseur avait genere %(value)s pour une expression conditionnelle, ce qui n'aide pas un traducteur. Renomme en %(player)s. Les 14 traductions ecrites avec une apostrophe droite sont normalisees en apostrophe typographique. Sans consequence en HTML, ou ' s'affiche correctement -- mais les blocs <script> ne decodent pas les entites, et autant que le catalogue soit homogene. 200 tests. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
80bc1413f1 |
feat(i18n): traduire l'ensemble des gabarits
Le site s'affiche desormais integralement en francais. 483 chaines, aucune non traduite, dans les deux catalogues. Couvert : navigation et mise en page partagee, connexion, les cinq pages d'erreur, et les 29 gabarits de pages. Methode Marquage semi-automatique, conservateur par construction : seuls des motifs sans ambiguite sont balises -- contenu de balises de texte, attributs placeholder/title/aria-label, texte suivant une icone, blocs title et page_title -- et tout contenu comportant du Jinja ou du balisage imbrique est laisse de cote. Deux passes, la seconde pour td, li, h6, strong, em et caption. 97 entrees etaient marquees fuzzy par pybabel update, c'est-a-dire devinees par similarite. Une entree fuzzy est **ignoree a l'execution** : elles ont donc ete traitees comme non traduites, et le drapeau retire une fois la traduction ecrite. Deux pieges rencontres, tous deux documentes dans docs/translations.md Une entite HTML n'est pas du texte. ×, utilise comme libelle de bouton de fermeture, a ete balise par la passe automatique. Jinja l'echappait alors en &times; et le bouton aurait affiche le texte litteral × au lieu de la croix. Corrige dans cinq gabarits. Huit chaines subsistent dans des blocs <script>. Elles fonctionnent mais restent fragiles : Jinja echappe & < > " ' dans un bloc script, et ces entites n'y sont pas decodees -- une traduction contenant une apostrophe droite arriverait dans la chaine JavaScript sous la forme '. Le francais retenu utilise des apostrophes typographiques, non echappees, donc l'existant est sur. Toute nouvelle chaine a cet endroit devra passer par un attribut data- ou un bloc <script type="application/json">. Vocabulaire retenu -- a valider avec le club tryout -> selection · manager -> gerant · coach -> coach (conserve, terme d'usage en e-sport) · scout -> recruteur · email -> courriel · scrim -> scrim. Les noms de jeux et les postes (Support, Duelist, AWPer) restent en anglais : ce sont les termes employes par les joueurs. Ces choix vivent dans un seul catalogue, donc chacun se change en un endroit. Reste a faire : les messages flash hors routes/auth.py, et les messages de validation de app/validators.py, qui necessitent lazy_gettext puisque les champs de schema sont construits a l'import. Verifie : 13 pages parcourues dans les deux langues, aucune ne laisse de marqueur Jinja non evalue ni d'entite doublement echappee. 193 tests. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
fcb58e8a17 |
feat(csp): retirer unsafe-inline de script-src
SEC-WEB-001 / OPS-010, ferme. C'est cette directive qui laissait s'executer
le XSS stocke de SEC-XSS-001 au lieu de le bloquer.
Les cinq derniers gabarits sont migres : match_form 13, calendar 11,
teams 11, evaluate_player 9, view_tryout 8. Total sur le chantier : 82
gestionnaires en ligne retires dans 17 gabarits. Il n'en reste aucun.
Deux motifs generiques de plus dans main.js
data-mirror affichage direct de la valeur d'un curseur.
evaluate_player repetait le meme
oninput="this.nextElementSibling.textContent = ..."
sur ses neuf curseurs de note.
data-submit-on-change remplace onchange="this.form.submit()"
Markup genere dans des chaines JavaScript
match_form construisait sept gestionnaires par concatenation, en y
injectant l'identifiant du joueur. Le markup portait deja data-player-id :
returnToPool et assignToTeam lisent desormais leurs arguments depuis
l'element clique. Cela supprime a la fois l'attribut en ligne et la
concatenation qui l'alimentait. Meme motif que dans coach_availability.
Bascule
CSP_ALLOW_INLINE_SCRIPT passe a false. script-src vaut maintenant
'self' 'nonce-<aleatoire par requete>' https://cdn.jsdelivr.net.
La variable d'environnement reste, comme issue de secours si un
deploiement rencontrait un gestionnaire oublie -- mais la laisser active
revient a renoncer a la protection.
Le cliquet devient une garde
Le budget par gabarit est vide et les tests deviennent absolus : aucun
gestionnaire en ligne, et tout bloc <script> inline doit porter son
nonce. Sans nonce, un bloc n'est simplement pas execute, et rien dans les
journaux ne le signale -- d'ou le test.
Verifications
22 pages parcourues avec les trois roles : toutes rendent en 200, aucune
ne contient de gestionnaire en ligne, et chaque bloc inline porte bien le
nonce de sa propre reponse. Syntaxe JavaScript de chaque gabarit verifiee
par node --check.
193 tests. Le dernier xfail de SEC-WEB-001 reussissait, le marqueur est
retire. Il n'en reste qu'un : SEC-AUTH-006, enumeration de comptes.
style-src conserve 'unsafe-inline' : les attributs style="" sont partout et
ne sont pas un vecteur XSS a eux seuls. Migration distincte, non prioritaire.
Co-Authored-By: Claude Opus 5 <[email protected]>
|
||
|
|
09453199b8 |
refactor(csp): migrer dix gabarits vers les comportements declaratifs
OPS-010, suite. 76 gestionnaires en ligne -> 52, dans 5 gabarits au lieu de
15. Le cliquet de tests/test_csp.py est abaisse en consequence.
Six motifs recurrents, generalises dans main.js plutot que traites un a un
data-action clic, resolu par un ecouteur delegue
data-change changement -- attribut distinct du clic, sans quoi un
<select> declencherait son gestionnaire des le clic
qui l'ouvre
data-confirm confirmation avant un envoi destructeur, en
remplacement de onsubmit="return confirm(...)". Le
texte reste dans le markup, donc traduisible.
data-navigate navigation sur selection, {value} etant encode
remove-element suppression d'un ancetre designe par data-remove
history-back retour arriere
registerActions()
Les fonctions propres a une page vivent dans son bloc de script et ne
peuvent donc pas figurer dans la table globale. Chaque page declare les
siennes, l'ecouteur delegue reste unique.
Cas particulier, coach_availability
Le gestionnaire y etait construit dans une chaine JavaScript, au moment
de generer la grille de creneaux. Le markup portait deja data-day et
data-time : toggleSlot lit desormais ses arguments depuis l'element, ce
qui supprime a la fois l'attribut en ligne et la concatenation.
Gabarits migres : my_teams, register, users, view_user, one_on_one,
coach_availability, profile, team_matches, notes, contracts.
Restent, par ordre decroissant : match_form 13, calendar 11, teams 11,
evaluate_player 9, view_tryout 8.
Syntaxe JavaScript de chaque bloc modifie verifiee par node --check.
A noter : la traduction de ces dix gabarits reste a faire. Seules les deux
chaines devenues visibles dans le markup au cours de cette migration -- les
messages de confirmation de suppression -- sont balisees et traduites.
192 tests.
Co-Authored-By: Claude Opus 5 <[email protected]>
|
||
|
|
15bfebf4fc |
feat(csp): infrastructure de sortie de unsafe-inline, et couche partagee migree
SEC-WEB-001 / OPS-010. script-src porte toujours 'unsafe-inline' : c'est
pour cela que le XSS stocke de SEC-XSS-001 s'executait au lieu d'etre
bloque. Le retirer n'est pas un changement d'une ligne.
Ce qui bloque reellement
Un nonce autorise des elements <script> ; il ne peut rien pour un
attribut onclick="...". Mesure faite : 76 gestionnaires en ligne repartis
dans 15 gabarits. Tant qu'il en reste un, la politique ne peut pas etre
durcie.
Piege supplementaire, documente dans build_csp() : en CSP niveau 3, un
navigateur ignore 'unsafe-inline' des qu'un nonce est present. Emettre
les deux ne serait donc pas une transition douce -- ce serait couper
d'un coup tous les scripts en ligne et tous les onclick, et uniquement
sur les navigateurs recents. La bascule doit etre atomique, d'ou un
drapeau unique : CSP_ALLOW_INLINE_SCRIPT.
Infrastructure posee
build_csp() assemble l'en-tete selon le drapeau. Un nonce est genere par
requete et n'est emis que lorsque l'inline est interdit. Les 15 blocs
<script> portent deja nonce="{{ csp_nonce }}", inerte aujourd'hui : la
bascule finale sera un changement de configuration, pas de gabarits.
Couche partagee migree en premier
base.html et macros.html sont rendus sur absolument toutes les pages. Six
gestionnaires retires, remplaces par des attributs data-action et un
ecouteur delegue unique dans main.js. La delegation plutot qu'un
ecouteur par widget : le contenu injecte dynamiquement herite du
comportement sans re-attachement.
Un cliquet plutot qu'une promesse
tests/test_csp.py fixe un budget par gabarit qui ne peut que baisser.
Ajouter un gestionnaire en ligne fait echouer la suite ; en retirer sans
mettre le budget a jour aussi, ce qui force a enregistrer la progression
dans le diff. A zero, il ne reste qu'a basculer le drapeau.
Le cliquet a d'ailleurs corrige mon propre relevé : mon grep initial
comptait 83 gestionnaires, la mesure exacte en donne 76 -- le motif ne
verifiait pas l'espace avant l'attribut.
style-src conserve 'unsafe-inline' : les attributs style="" sont partout et
ne constituent pas un vecteur XSS a eux seuls. Migration distincte.
192 tests.
Co-Authored-By: Claude Opus 5 <[email protected]>
|
||
|
|
d15a3de2a1 |
fix(data): reparer les trois suppressions cassees
DATA-004, DATA-005, DATA-006. L'audit les classait "forte probabilite" faute de pouvoir les executer. Les tests les confirment : ce sont des bugs averes, declenchables par tout manager ou administrateur depuis l'interface. Erreurs reellement obtenues avant correction : NOT NULL constraint failed: match_participants.match_id NOT NULL constraint failed: team_matches.org_team_id Supprimer un match (DATA-004) Match.participants n'avait pas de cascade. SQLAlchemy tentait donc de detacher les participants en mettant match_id a NULL, ce que la colonne refuse. Tout match ayant eu des participants etait indestructible. TeamMatch.participants declarait deja delete-orphan ; Match non. Supprimer un tryout (DATA-006) Les PersonalNote pointant vers ses matchs, equipes ou vers lui-meme n'etaient pas traitees. Supprimer une equipe (DATA-005) TeamNote.org_team_id et TeamMatch.org_team_id sont NOT NULL et n'etaient pas traites du tout. De plus la fonction validait trois fois : un echec au troisieme temps laissait les tryouts detaches et les joueurs retires sans que l'equipe soit supprimee -- un etat incoherent que rien ne rattrapait. Une seule transaction desormais. Regle appliquee, uniforme Ce qui n'a de sens que dans le parent est supprime avec lui : participants, membres, notes d'equipe, matchs de saison. Ce qui lui survit est seulement detache : les notes personnelles sont les observations d'un coach sur un joueur, pas des donnees de tryout. Les supprimer avec le tryout detruirait du contenu sans rapport. Idem pour les contrats et les demandes de rencontre individuelle. Fidelite des tests conftest.py active PRAGMA foreign_keys=ON. SQLite ignore les cles etrangeres par defaut ; PostgreSQL les applique toujours. Sans ce reglage, la suite pouvait valider une suppression qui echoue en production -- precisement la classe de bug corrigee ici. Les 146 tests passent avec les contraintes actives. 9 tests, dont trois qui verifient que les entites survivantes survivent vraiment : une note garde son contenu et perd son contexte, un tryout survit a l'equipe qu'il visait. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
a92600c305 |
feat(i18n): francais comme langue principale, anglais accessible
Le site s'affiche desormais en francais par defaut, avec un selecteur de langue permettant de basculer vers l'anglais. Choix de conception : les chaines sources restent en anglais Elles servent d'identifiants gettext, et le francais est fourni par catalogue avec BABEL_DEFAULT_LOCALE = 'fr'. Le code reste ainsi dans une seule langue -- la meme que ses commentaires et docstrings -- tandis que ce qu'un membre voit est du francais. Consequence qui rend la migration praticable : une chaine non encore traduite retombe en anglais, pas sur un identifiant brut. Les gabarits peuvent donc etre migres un par un sans jamais laisser le site a moitie casse. Selection de la langue (app/i18n.py) 1. choix explicite via le selecteur, garde en session 2. sinon en-tete Accept-Language du navigateur, restreint a fr et en 3. sinon francais Un choix explicite prime toujours, y compris sur un navigateur anglophone. Selecteur Extrait en partiel et inclus dans les deux branches de la mise en page : barre laterale une fois connecte, ET page d'authentification. Quelqu'un qui ne lit pas la langue courante doit pouvoir en changer AVANT de se connecter -- le laisser derriere l'authentification aurait ete un defaut d'accessibilite. Chaque langue est ecrite dans sa propre langue. La route /lang/<locale> valide le Referer avant de rediriger : sans ce controle, elle constituait une redirection ouverte. Migre dans cette passe navigation complete, page de connexion, les cinq pages d'erreur, et l'integralite des messages flash de routes/auth.py. 64 chaines, dont aucune non traduite. Verification 25 tests, dont deux garde-fous d'integrite : un catalogue .mo manquant ou une entree non traduite font echouer la suite. Sans cela, une compilation oubliee servirait de l'anglais partout, en silence et sans rien dans les journaux. Un test existant a du etre corrige, et c'est instructif test_login_failure_message_does_not_reveal_account_existence cherchait la sous-chaine anglaise 'attempt(s) remaining'. La page etant desormais en francais, elle etait absente des deux cotes, l'assertion passait, et le mode strict a signale le faux succes. Le test comparait donc l'anglais, pas le comportement. Il compare desormais les messages flash rendus, quelle que soit la langue. La faille SEC-AUTH-006 reste ouverte, et le test la documente toujours. Les catalogues .po ET .mo sont versionnes : le deploiement est un simple miroir de fichiers, sans etape de compilation. messages.pot, regenerable, ne l'est pas. docs/translations.md documente le processus, les deux pieges (concatenation de phrases, traduction a l'import), et l'etat de la migration. A noter pour la suite : les chaines dans les blocs <script> ne peuvent pas etre balisees telles quelles, il faudra les passer par des attributs data- -- ce qui rejoint le chantier de sortie de unsafe-inline (OPS-010). Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
afab7070fb |
fix(authz): une seule regle pour l'acces coach vers joueur
SEC-AUTHZ-004 et SEC-AUTHZ-005. La meme question -- ce coach peut-il agir
sur ce joueur ? -- recevait cinq reponses differentes selon la route :
teams.py:add_player_note verifiait l'appartenance via TeamPlayer
users.py, 4 routes de notes ne verifiaient rien au-dela d'isinstance
contract.py:can_view interrogeait la colonne heritee coach_id, et
traitait un team_id nul comme un joker
Consequences levees
- tout coach pouvait ecrire une note nominative sur tout joueur du club.
Ces notes sont visibles par le joueur concerne.
- tout coach figurant dans OrgTeam.coach_id pouvait lire n'importe quel
contrat sans equipe rattachee. Or upload_contract laisse team_id nul des
que le joueur n'appartient a aucune equipe : la condition
`not self.team_id or ...` ouvrait donc largement.
- symetriquement, un coach rattache uniquement par la relation
many-to-many ne voyait aucun contrat.
app/permissions.py
Premier pas concret vers ARCH-002, sans refonte : un module unique, pas
une couche de services. coach_org_team_ids() lit la relation m2m ET la
colonne heritee, donc le deuxieme coach d'une equipe cesse d'etre
invisible. coach_can_access_player() accorde l'acces si le joueur est sur
une equipe du coach, ou inscrit a un tryout qu'il gere, ou participant a
un match de ce tryout.
14 tests, dont deux verifient que les chemins legitimes fonctionnent
toujours : un coach note bien son propre joueur, et voit bien son contrat.
Note : la regle metier retenue -- equipe OU tryout -- est une lecture du
comportement existant, pas une decision produit. Si le club attend autre
chose, c'est desormais un seul endroit a changer.
Co-Authored-By: Claude Opus 5 <[email protected]>
|
||
|
|
7bf428f9a0 |
fix(ops): sauvegarder reellement la base PostgreSQL
DATA-002 / OPS-001. backup.py ciblait SQLite : import sqlite3, DATABASE_PATH par defaut instance/team_tryouts.db, et l'API de sauvegarde sqlite3. La production tourne sur PostgreSQL, donc le fichier n'existait pas. Le script affichait "[WARNING] Database not found... Skipping database backup" -- puis, main() ne suivant que le resultat de la verification, **sortait avec le code 0**. Toute tache planifiee surveillant le code de sortie voyait vert alors qu'aucune sauvegarde n'avait jamais ete produite. Il n'existait donc aucune sauvegarde applicative de la base. Reecriture pg_dump en --format=custom : compresse, et pg_restore permet une restauration selective, ce qu'un dump SQL a plat ne permet pas. parse_database_url accepte les suffixes de dialecte SQLAlchemy (postgresql+psycopg://) que pg_dump ne comprend pas, et refuse explicitement une URL SQLite -- le cas exact qui passait en silence. Le mot de passe ne figure jamais dans la ligne de commande : il serait visible de tout processus capable de lister argv. Il passe par PGPASSWORD. Il est egalement absent des messages affiches, qui atterrissent dans les journaux du planificateur. verify_backup lit l'archive avec pg_restore --list et exige au moins une table : une archive illisible ne se restaure pas, et une archive sans table signifie que le dump a vise la mauvaise cible. Les deux sont des echecs silencieux qu'il vaut mieux attraper maintenant que pendant un incident. Le code de sortie vaut 0 uniquement si le dump a ete produit ET verifie. L'archive des documents est conservee : les contrats signes n'existent que sur disque, la base ne stocke que des chemins. Restaurer l'une sans l'autre laisse des lignes pointant vers des fichiers absents. docs/database-restore.md Procedure de restauration testable sur une base jetable, requetes de controle, demarrage de l'application sur la copie restauree, plan de reprise par scenario. ENABLE_DISCORD_BOT=false y est signale comme non optionnel : sans lui, l'exercice demarre un vrai bot et envoie de vraies notifications a de vraies personnes, a partir de donnees restaurees. Les points ouverts sont listes tels quels : aucune copie hors site, pas de chiffrement au repos, aucune planification, et l'exercice de restauration n'a jamais ete effectue. 17 tests sur ce qui est verifiable sans serveur PostgreSQL : analyse de l'URL, construction de la commande, non-fuite du mot de passe, et surtout codes de sortie -- le silence ne vaut plus succes. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
ca45be80db |
fix(security): proteger le dernier administrateur, et fermer le CORS permissif
SEC-AUTHZ-007 - auto-verrouillage de l'administration
Le changement de role n'excluait ni l'utilisateur courant, ni le dernier
compte admin actif. Une seule manipulation suffisait a transformer le seul
president en joueur, et plus aucune interface ne permettait de revenir en
arriere : il fallait une intervention directe en base.
Deux gardes distinctes, parce que ce sont deux erreurs differentes :
- changer son propre role est refuse, meme s'il reste d'autres admins.
Un president qui veut se retrograder doit le faire faire par un autre.
- retrograder le dernier admin actif est refuse.
Le decompte exclut les comptes desactives : trois admins dont deux
desactives, cela fait un seul administrateur reel.
SEC-WEB-003 - CORS ouvert par defaut
Sans CORS_ALLOWED_ORIGINS, la branche else appelait
CORS(app, supports_credentials=True) sans argument origins. flask-cors
retient alors '*' et, les identifiants etant autorises, renvoie en echo
l'Origin de l'appelant avec Access-Control-Allow-Credentials: true --
l'inverse exact de ce qu'annonçait le commentaire.
L'exploitation etait bloquee par SESSION_COOKIE_SAMESITE = 'Lax', qui
empeche le navigateur de joindre le cookie de session a une requete
fetch inter-site. Toute la protection tenait donc a ce seul reglage.
Cette application rend du HTML en meme origine : elle n'a besoin
d'aucune politique CORS. La branche par defaut est supprimee, la
configuration explicite reste possible.
5 tests ajoutes, dont deux verifient que les chemins legitimes continuent
de fonctionner : un autre administrateur reste retrogradable, et une
origine explicitement configuree est toujours honoree.
Co-Authored-By: Claude Opus 5 <[email protected]>
|
||
|
|
ab44258d72 |
feat(obs): journaliser les evenements d'authentification
OBS-001. logging_config.py configurait un fichier auth.log avec rotation,
un logger nomme 'team_tryouts.auth' et un filtre de redaction. Mais
get_auth_logger n'etait importe nulle part : le fichier etait cree et
restait vide. Aucune connexion, aucun echec, aucun verrouillage, aucun
changement de role, aucune suppression de compte ne laissait de trace.
En cas de suspicion de compromission, il n'y avait rien a consulter.
Ajout de log_auth_event(event, **fields), qui emet des paires cle=valeur
ordonnees -- greppable sans dependance de journalisation JSON.
Evenements couverts
authentification login.success, login.failure,
login.failure.unknown_user, login.rejected.locked,
login.rejected.deactivated, account.locked, logout,
account.registered
administration account.created_by_admin, account.updated,
account.role_changed (avec ancien et nouveau role),
account.deleted, account.password_reset_by_admin
libre-service account.password_changed
Le champ ip vient de request.remote_addr, donc de X-Forwarded-For. Tant que
Waitress tourne avec trusted_proxy='*' (SEC-WEB-002), cette valeur est
choisie par l'appelant : c'est une indication, pas une preuve. Le point est
documente dans la docstring.
Un test a fait remonter ARCH-003, jusqu'ici classe comme fragilite latente
Journaliser en fin de edit_user levait DetachedInstanceError : le
changement de role appelle db.session.remove() en plein cycle de requete,
ce qui detache current_user de la session. Le code s'en tirait parce qu'il
redirigeait immediatement sans plus y toucher. L'identite de l'acteur est
desormais capturee en debut de traitement. Le constat est donc confirme
comme reel, et non plus seulement probable -- sa correction de fond reste
au programme.
15 tests, dont 4 sur le filtre de redaction lui-meme : c'est un controle de
securite, il doit etre verifie.
Co-Authored-By: Claude Opus 5 <[email protected]>
|
||
|
|
983b7a1f49 |
fix(auth): ajouter le parametre state au flux OAuth2 Discord
SEC-AUTH-004. L'URL d'autorisation ne portait que client_id, redirect_uri,
response_type et scope. Sans state, le callback acceptait n'importe quel
code d'autorisation qu'on lui presentait.
Scenario ferme : un attaquant obtient un code pour SON compte Discord, puis
fait charger l'URL de callback par le navigateur de la victime. Le
formulaire d'inscription de la victime se retrouve pre-rempli avec
l'identite Discord de l'attaquant. C'est le login CSRF decrit par la
RFC 6749 §10.12.
Mise en oeuvre
secrets.token_urlsafe(32) genere le jeton, stocke en session avant la
redirection. Le callback le compare en temps constant avec
secrets.compare_digest, et le consomme systematiquement -- valide ou non --
pour qu'il ne puisse pas etre rejoue. Le controle intervient avant
l'echange du code : un callback rejete ne declenche aucun appel reseau.
Deux corrections accessoires sur le meme chemin
- DISCORD_REDIRECT_URI est desormais verifie au meme titre que
DISCORD_CLIENT_ID. Non defini, il faisait lever requests.utils.quote(None)
au lieu de signaler un probleme de configuration.
- la construction de la chaine de requete passe a urlencode() plutot qu'a
une concatenation manuelle.
9 tests : presence du state, stockage en session, unicite entre deux
demandes, rejet sans state, avec un state forge, sans demande prealable,
et non-rejouabilite.
Reste ouvert : l'identite Discord obtenue reste ensuite reinjectee par un
champ cache du formulaire (SEC-AUTH-005). Le state protege la liaison, pas
encore la valeur elle-meme.
Co-Authored-By: Claude Opus 5 <[email protected]>
|
||
|
|
d05e9cde32 |
fix(security): supprimer le XSS stocke du calendrier
SEC-XSS-001. Chaine complete : un nom d'utilisateur libre arrivait dans le
DOM d'un coach ou d'un administrateur, en meme origine, avec sa session.
La CSP autorisant 'unsafe-inline', rien ne l'arretait.
Cote serveur - la cause
/matches/api/events construisait de la presentation dans un champ JSON :
match_desc = participants_str + f"<br>{match.description}"
Le navigateur deposait cette valeur telle quelle dans innerHTML. Les noms
de joueurs y transitaient sans echappement -- et il ne pouvait pas y en
avoir : c'est du JSON, pas du HTML.
Les deux valeurs etaient deja des cles distinctes du payload. La
concatenation faisait donc aussi afficher les participants deux fois dans
le modal : une fois dans "Teams", une fois en tete de "Description".
Corriger la faille corrige l'affichage.
Cote navigateur - le sink
showEventModal assemblait une chaine HTML puis l'affectait a innerHTML.
Remplace par une construction de noeuds : makeEl / detailItem /
multilineNode / teamNode passent tout texte par textContent. Les retours
a la ligne d'une description restent rendus, via des <br> crees en dur.
Les deux listes deroulantes concatenaient egalement titres de tryout et
noms d'equipe dans innerHTML. Remplacees par new Option(), dont le
premier argument est pose en texte.
Verification
5 tests sur le contrat de l'API, dont un avec un nom d'utilisateur
hostile ecrit directement en base -- ce que la validation refuse
desormais, mais que des lignes anterieures peuvent contenir.
Le JavaScript inline extrait passe `node --check`.
Reste ouvert : la CSP autorise toujours 'unsafe-inline' (SEC-WEB-001), donc
la defense en profondeur manque encore. Suivi en OPS-010.
Co-Authored-By: Claude Opus 5 <[email protected]>
|
||
|
|
32f193c008 |
style: f-strings sans placeholder signalees par ruff (F541)
Aucun changement de comportement. Les chaines concernees ne contenaient aucune substitution. A noter pour plus tard : run_https.py conserve une banniere en caracteres semi-graphiques, du meme type que celle qui faisait planter security_scan.py sur une console Windows en cp1252. Le script n'etant lance qu'en developpement et de facon explicite, le point est signale sans etre corrige ici. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
5ecea55f55 |
fix(ci): rendre la chaine d'integration reellement verifiante
Les quatre jobs existaient ; aucun ne verifiait ce qu'il annoncait.
security-audit
`pip-audit --require-hashes --no-deps || pip-audit`. L'etape
d'installation ne posait que pip-audit, et aucune des deux formes ne
nommait le fichier d'exigences : le repli auditait l'environnement du
runner, qui ne contenait que pip-audit lui-meme. Le job passait au vert
sans avoir examine une seule dependance de l'application. Remplace par
`pip-audit -r requirements.txt`.
security-scan
Appelait `python security_scan.py`, alors que le fichier se trouve dans
app/supporting_scripts/. En echec a chaque execution depuis le
deplacement du fichier. Trois autres defauts sont apparus en le faisant
tourner :
- la CI passe --skip-http, un argument que l'argparse du script
n'acceptait pas : sortie en erreur 2 meme avec le bon chemin.
- check_dependencies lisait data['dependencies'] comme la liste des
vulnerabilites. Ce tableau liste en realite TOUTES les dependances,
chacune portant un champ vulns vide si le paquet est sain. Les ~45
paquets installes etaient donc signales vulnerables a chaque
execution. Le filtrage se fait desormais sur vulns non vide.
- check_flask_config interceptait son exception et renvoyait quand
meme all_ok : ne pas reussir a charger l'application comptait comme
un controle reussi. La section la plus importante du rapport n'avait
jamais tourne. Elle renvoie desormais False, et l'import fonctionne
grace a l'ajout de la racine du projet dans sys.path.
- la banniere en caracteres semi-graphiques faisait planter le script
sur une console Windows en cp1252, la plateforme meme du projet.
Passee en ASCII.
lint
Ruff n'avait aucun fichier de configuration : le job tournait sur le jeu
de regles par defaut. La configuration vit maintenant dans pyproject.toml.
`ruff format --check` est retire pour l'instant : la base n'ayant jamais
ete formatee, il echouerait sur 62 fichiers sur 64 pour des raisons
etrangeres a la correction. Reformatage puis application : QUA-002.
test
Un `echo` protege par continue-on-error : le job annoncait un succes
sans rien executer. Il lance desormais pytest avec couverture, et bloque.
permissions: contents: read au niveau du workflow, aucune etape n'ecrivant
dans le depot.
Deploiement Gitea
actions/checkout@v7 n'existe pas (derniere majeure : v5) : le workflow
echouait des sa premiere etape. Ramene a v4.
Le miroir lftp poussait l'integralite de l'arbre de travail, dont
clear_db.py -- un script qui vide toutes les tables et recree
admin/password -- vers le noeud de production. Liste d'exclusions ajoutee.
--delete reste volontairement absent : les contrats televerses, les
journaux et le .env du serveur vivent sous la racine de deploiement et
sont absents du depot ; les supprimer detruirait des donnees.
Le workflow de deploiement n'a pas pu etre execute depuis ici : la
syntaxe lftp reste a valider lors du prochain deploiement manuel.
Co-Authored-By: Claude Opus 5 <[email protected]>
|