EduQate, deuxième partie — tout ce que le registre a attrapé
La discipline de vérification du chapitre précédent a tourné pendant des mois. Voici ce qu'elle a trouvé. Certains de ces cas sont dans la section Problèmes avec plus de détail clinique ; ici, je peux les raconter comme des histoires.
Le nombre qui mentait dans chaque réponse
Je corrigeais autre chose — un endpoint de gestion des frais qui rejetait chaque champ que je lui envoyais avec une erreur générique « propriété non reconnue ». Ça s'est révélé être un import type sur le DTO, qui retire les métadonnées d'exécution dont la validation du framework a besoin pour reconnaître un champ tout court. Corrigé.
Ensuite, au lieu de juste vérifier que la requête renvoyait maintenant 200, j'ai lu le corps de la réponse. Et le montant des frais n'était pas un nombre. C'était {"s":1,"e":4,"d":[15000]} — les entrailles internes brutes d'une bibliothèque de décimales de précision, qui fuyaient droit à travers l'API.
Voici le mécanisme, parce que c'est un bon avertissement. Il y a un intercepteur de réponse global dont le rôle est de convertir en chaîne les valeurs BigInt, que JSON ne sait pas sérialiser nativement. Pour le faire de manière générique, il parcourt toute valeur en forme d'objet dans la réponse et la reconstruit propriété par propriété. Cette reconstruction générique arrache le prototype de tout ce qui a la forme d'un objet et passe par là — y compris le type décimal de précision, qui a un parfaitement bon toJSON() qui ne fonctionne que tant que son prototype est intact.
Donc chaque Decimal dans chaque réponse était faux. Frais, paiements, échéances, partout, silencieusement, depuis qui sait combien de temps. Rien ne levait d'exception. Chaque endpoint renvoyait 200. Un typecheck ne peut pas le voir. Un test « est-ce que ça réussit » ne peut pas le voir. Lire le code appelant ne te le montrera pas, parce que la corruption arrive après le handler, dans un middleware partagé que personne ne relit une fois qu'il marche.
Le correctif est une vérification instanceof explicite avant le parcours générique. La leçon est plus grande que le correctif : du code générique qui démonte et reconstruit des objets arbitraires pour résoudre un problème étroit finira par rencontrer un type pour lequel il n'a pas été écrit. Quand ce code occupe une position globale, le rayon de souffle, c'est toute l'API d'un coup.
Le même bug, quatre fois, pour trois raisons différentes
L'affaire du DTO ci-dessus n'était pas isolée. Le symptôme identique — « l'endpoint retire tout mon corps de requête » — est apparu quatre fois distinctes.
D'abord sur les frais, à cause de l'import type. Puis sur les exigences de documents, parce que ce DTO n'avait aucun décorateur de validation du tout, juste des déclarations de propriétés nues, et une propriété non décorée est retirée sous validation en liste blanche. Troisième fois sur la soumission de formulaire, où le champ est une vraie union — une réponse de formulaire peut être une chaîne, un tableau, un nombre, un booléen ou null selon le champ — et personne ne l'avait décoré parce qu'aucun décorateur de vérification de type unique n'est correct pour une union. (La réponse, dont j'ignorais l'existence jusque-là, est un décorateur qui accepte n'importe quelle valeur et exige juste que la clé soit présente.)
Après le deuxième, j'ai arrêté de corriger ça un par un et j'ai simplement grepé toute la base de code pour le motif : des classes DTO avec des déclarations et aucun décorateur de validation. Ça a attrapé une quatrième occurrence avant que j'y brûle un cycle. Quand une cause racine se révèle être une convention non documentée, ratisse-la immédiatement au lieu de trébucher sur chaque instance comme si c'était une surprise à chaque fois.
Chaque client pouvait cliquer sur Postuler. Chaque client heurtait un mur.
L'action la plus importante de tout le produit, pour son utilisateur principal, c'est postuler. Je l'avais testée à fond — en tant que membre du personnel, connecté comme admin, en vérifiant le côté examen et approbation.
Puis, un jour, je me suis connecté en tant que client ordinaire et j'ai cliqué sur le vrai bouton Postuler pour la première fois depuis un moment.
Quatre toasts d'erreur, empilés. Un 403 et trois 404 « aucun formulaire actif trouvé », tous d'un coup. Sur chaque organisation. Pour chaque client. Toujours.
Le 403 venait d'un endpoint que le flux de candidature appelle pour vérifier si une organisation a actuellement une fenêtre d'admission ouverte. Cet endpoint est réservé aux admins par conception — il a été construit pour le personnel, il renvoie des champs internes, et il est protégé par une vérification de propriété qui n'a de sens que pour un compte du personnel lié à une organisation précise. Un client n'a rien à faire à l'appeler. Rien n'était cassé dans l'endpoint. C'était juste la mauvaise porte, et le flux de candidature y frappait depuis qu'il avait été écrit.
Rien de loggué. Rien qui renvoie 500. Rien qui n'alerte personne. Ça transformait juste, discrètement, l'action de conversion centrale du produit en un bouton qui échoue toujours, pour tout le monde, jusqu'à ce que quelqu'un le clique avec la console du navigateur ouverte. Ça aurait pu être livré exactement comme ça.
Le correctif tentant était d'ajouter le rôle client à la liste des autorisés de cet endpoint. J'ai d'abord tracé la chaîne d'autorisation et trouvé une seconde protection indépendante qui vérifiait la propriété organisationnelle — dont un compte client n'a pas et ne devrait pas avoir de données. Assouplir la liste des rôles aurait juste échangé un refus contre un autre, et toucher la protection de propriété aurait changé le comportement de toute autre route qui en dépend. Alors à la place : un nouvel endpoint, délibérément étroit, vraiment public qui ne répond qu'à la seule question dont un client potentiel a besoin — « cette organisation précise accepte-t-elle des candidatures en ce moment » — filtré côté serveur, sans surcharge fournie par le client, sans données organisationnelles attachées. L'endpoint du personnel et ses protections ont été laissés complètement tranquilles.
L'autorisation d'un endpoint peut être entièrement correcte pour l'appelant autour duquel il a été conçu, et être quand même le mauvais endpoint pour un appelant différent qui se met plus tard à en dépendre. « Cet utilisateur a-t-il la permission » et « cet utilisateur devrait-il frapper ici tout court » sont des questions différentes. Tester uniquement en tant que personnel n'en répond qu'à une.
Deux gyroscopes et un crash pour mémoire insuffisante
J'ai reçu un OutOfMemoryError en direct de l'application mobile en fonctionnement — le moteur de rendu incapable d'allouer seize octets sur un tas presque plein. Une petite allocation qui échoue comme ça signifie généralement que quelque chose fuit depuis un moment, pas qu'une grosse allocation a fait sauter le budget.
Deux composants décoratifs — les trucs de géométrie réactifs à l'inclinaison du système de design — s'abonnaient chacun indépendamment au capteur de rotation de l'appareil. Un vrai abonnement natif. Et ils sont utilisés ensemble, constamment, sur presque chaque écran. Donc jusqu'à deux écouteurs de capteur natifs vivants par écran, à deux intervalles de sondage, tant que l'écran restait monté. En plus, le hook de capteur a un antécédent de ne pas démonter proprement son écouteur au hot-reload, et j'avais hot-reloadé un de ces écrans précis à répétition juste avant le crash.
Le correctif rapide était d'arracher le capteur d'un des composants. Personne ne remarquerait un losange décoratif qui a cessé de s'incliner. Je ne l'ai pas fait. J'ai construit un fournisseur partagé qui s'abonne au capteur une fois, à la racine de l'application, et transmet la valeur vers le bas. Le défaut n'était pas que l'effet existe — c'était que deux composants aillent indépendamment chercher la même ressource native au lieu d'un seul propriétaire qui la détient. En supprimer un aurait corrigé le crash de cette nuit-là et laissé le motif libre de se reproduire la prochaine fois que quelque chose voudrait l'orientation de l'appareil. Le même instinct que le pooling de connexions, honnêtement : deux choses qui vont discrètement chercher la même ressource partagée méritent qu'on le remarque même quand chacune a l'air bien seule.
Paiements : différés, puis vraiment débranchés, puis débranchés encore
Quand le cycle de vie central a été vérifié, les paiements étaient les suivants. Je les ai différés — explicitement, dans le registre — plutôt que de demi-vérifier un flux que je n'étais pas prêt à assumer.
Mais « différé » n'est pas un état dans lequel un utilisateur peut se trouver. Une ligne « pas encore vérifiée » dans un registre se lit comme en attente, alors que le statut honnête était pas activé. Et un flux de paiement à moitié construit qu'un utilisateur peut physiquement atteindre est un pire mode de défaillance qu'aucune fonctionnalité du tout. Alors j'ai retiré les points d'entrée. Puis, quelques jours plus tard, sous instruction d'aller plus loin, j'ai ajouté une protection au niveau contrôleur qui renvoie 404 sur toute route de paiement avant qu'elle ne touche la moindre logique de paiement — pour que tout le fait « les paiements sont coupés » vive dans une ligne en haut d'un fichier, et que toute route future sur ce contrôleur soit couverte automatiquement.
Cette seconde passe a aussi attrapé quelque chose que la première avait raté. L'endpoint webhook du fournisseur de paiement est public — il doit l'être, pour que le vrai fournisseur puisse l'appeler. Ce qui en faisait la seule route de paiement vraiment atteignable par un appelant extérieur, pendant tout le temps où je pensais les paiements « débranchés ». La nouvelle protection tourne indépendamment de la couche d'authentification, donc elle est couverte aussi. J'ai vérifié ça avec un vrai jeton de client en main — les quatre routes renvoient 404 — plutôt qu'une requête anonyme qui pourrait juste être bloquée par l'authentification par coïncidence.
« Aucun bouton n'y renvoie » et « ce n'est pas atteignable » sont des affirmations différentes, surtout dans un routeur basé sur les fichiers où tout fichier dans le dossier des routes est atteignable par URL que l'interface y renvoie ou non.
Le jour où le test de charge s'est redirigé lui-même
Je me suis lancé dans une revue de code à profondeur de production. Elle a trouvé trois vrais bugs et une config de surcharge de dépendance qui n'avait jamais rien fait. Puis c'est devenu la construction d'une installation de test de charge k6. Puis le test de charge a fait remonter que le back-end n'avait jamais tourné en plus d'un seul processus. Puis ça a mené au clustering. Puis la question du clustering a exposé une lacune de conception du limiteur de débit. Une session, quatre projets différents, chacun tombant du précédent.
Le test de charge lui-même avait des bugs qu'il a fallu corriger avant qu'il ne produise un nombre digne de confiance — mauvais ports, endpoints qui n'existaient pas, authentification WebSocket envoyée comme un paramètre de requête que le serveur ne lit jamais. Et un auto-infligé : les requêtes de connexion du test lui-même, chacune faisant un vrai hachage de mot de passe (délibérément lent), atterrissaient dans la même métrique de latence que les requêtes qui m'intéressaient vraiment, tirant la moyenne vers le haut. La phase de setup d'un test de charge a besoin de sa propre étiquette ou il se mesure lui-même.
Une fois les chiffres réels : en montant les utilisateurs virtuels contre un seul processus, la latence au 95e centile grimpait fortement au-delà de 100 utilisateurs simultanés. Et retirer le rythme artificiel entre les requêtes rendait le débit pire, pas meilleur — ce qui est la signature d'un processus mono-thread saturé, pas d'un processus avec de la marge. docker stats l'a confirmé en une ligne : un cœur CPU collé à 100 %, dix-neuf inactifs.
Le runtime ne s'exécute jamais que sur un thread par processus. Plus de cœurs sur l'hôte ne font rien pour un seul processus non-clusterisé. Donc : plusieurs processus de travail. Pas Kubernetes — Kubernetes résout la mise à l'échelle entre machines, un problème que je n'ai pas sur un serveur, et il coûte un plan de contrôle et des manifests pour aucun bénéfice ici. Un gestionnaire de processus en mode cluster : quasi zéro code, plus le redémarrage auto et le rechargement sans coupure gratuits.
Avant d'activer ça, j'ai audité chaque bout d'état partagé qui casse sous le clustering. Deux choses sont revenues nickel — le limiteur de débit et la couche temps réel étaient déjà adossés à un stockage partagé. Mais j'ai trouvé trois jobs de fond protégés uniquement par un drapeau en mémoire de processus, dont deux traiteraient joyeusement le même travail en double sur plusieurs processus. Corrigés en une revendication atomique par ligne avant tout effet de bord.
Le limiteur de débit comptait la mauvaise chose
Et puis, en vérifiant si le clustering était sûr, j'ai lu comment le limiteur de débit identifie « un appelant ». Il était clé sur l'adresse réseau. Pour chaque route. Y compris les authentifiées.
Ce qui veut dire : en production, chaque client sur le même WiFi de bureau ou d'immeuble, et chaque membre du personnel derrière le même NAT de bureau, partageaient un unique budget de limitation de débit. Un utilisateur intensif pouvait discrètement étrangler tous les autres sur le même réseau — impossible à distinguer, de l'extérieur, d'une attaque délibérée. Rien dans le code n'avait l'air faux. Le défaut (clé sur l'IP) est un choix tout à fait raisonnable pour le seul cas pour lequel il a été construit — une tentative de connexion anonyme, qui n'a aucune autre identité sur laquelle se caler — et un vraiment mauvais pour chaque requête authentifiée derrière une connexion partagée.
Le correctif se clé sur l'identité de l'utilisateur authentifié quand il y en a une, retombant sur l'adresse réseau seulement pour les requêtes pas encore connectées. Ça a nécessité un petit réordonnancement du pipeline de requête, pour que le composant qui sait qui tu es s'exécute avant que le limiteur de débit ait besoin de cette information.
Je l'ai vérifié de deux façons qui comptent vraiment. Connecté en tant que deux utilisateurs différents depuis la même adresse réseau, martelé un endpoint protégé avec les deux, et vérifié les vraies clés de compteur dans Redis — deux budgets indépendants, pas un partagé. Puis, le test le plus convaincant : fait tourner l'application en deux workers clusterisés avec une limite délibérément minuscule et martelée avec le trafic d'un seul utilisateur. Obtenu exactement la limite partagée, pas le double. Ce qui prouve que le correctif tient entre processus et n'est pas juste du code correct en apparence qui a marché par hasard dans un test mono-processus.
CI : au vert, et pourtant rien n'avait été construit
Monter le pipeline CI pour de vrai, sur un runner auto-hébergé, a pris sept tours dans une seule journée. Le tour trois est celui que je raconte aux gens.
Tout est passé au vert. Chaque job, chaque contrôle, tous réussis. Et rien n'avait été construit. Pas une faute de frappe. Le mécanisme censé enchaîner les jobs de build sur les jobs de test était silencieusement cassé par conception dans les conditions exactes où ces jobs tournaient — il ne les lançait juste… pas, et ne s'en plaignait pas. J'avais précédemment « vérifié » ce pipeline en lisant les fichiers de workflow, ce qui, il s'avère, ne vaut approximativement rien.
Deuxième fois dans ce projet où « la config a l'air correcte » et « la chose s'est réellement passée » se sont révélées être des affirmations complètement différentes. (La première fois, c'était la CSP — mais c'est le chapitre suivant.)
Quinze entreprises, ouvertes et tarifées et incapables de prendre une seule commande
Une refonte a re-clé les formulaires de candidature d'un regroupement vers un plus fin — de organisation-et-catégorie vers organisation-et-catégorie-et-niveau — et a supprimé les anciens chemins de résolution de repli dans le cadre du changement. Cette conséquence a été identifiée à l'avance, écrite, et formellement acceptée par le propriétaire du projet. « Certains anciens enregistrements cesseront de résoudre. » Accepté. On passe à la suite.
Ce que personne n'a fait, c'est l'énumérer.
Puis le jeu de données de démo a été remplacé par un vrai, en forme de production — des dizaines de vraies entreprises avec de vrais noms, de vraies grilles de frais, de vraies coordonnées. Et des données réalistes ont immédiatement fait remonter ce que le jeu de données jouet avait caché : 15 des 25 organisations actives ne pouvaient pas recevoir de candidature, et rien nulle part ne le disait.
Un vrai client pouvait en ouvrir une — note complète, statut actif, badges « ouvert » sur chaque option, une grille de frais complète, un bouton rouge Postuler bien en vue — cliquer, choisir n'importe quoi, et atterrir sur « Aucun formulaire de candidature n'est actuellement disponible » avec le bouton Continuer grisé de façon permanente. Les formulaires à l'ancienne que ces organisations avaient ne pouvaient jamais résoudre sous le nouveau modèle, tout en étant marqués published partout où l'interface regarde.
C'est invisible dans les deux sens. Le tableau de bord du membre du personnel dit que son formulaire est publié, alors il croit être en ligne. Le client voit une fiche entièrement remplie avec de vrais prix et un gros appel à l'action. Rien n'erreur. Pas de 4xx, pas de 5xx, console propre des deux côtés. Le seul moyen de le trouver est de parcourir tout le flux en tant que vrai client sur une organisation que personne n'a pensé à tester.
Et puis le correctif n'était pas le correctif. J'ai lancé le backfill de données. Il a rapporté les lignes qu'il avait écrites. Le flux était toujours mort — parce que le résolveur filtrait aussi, indépendamment, sur une colonne que les enregistrements à l'ancienne ne remplissent pas. Je n'ai trouvé ce second bug qu'en re-parcourant le flux dans un navigateur, plutôt qu'en faisant confiance au nombre de lignes que la migration affichait. Les organisations candidatables sont passées de 8, à 23, sur 25.
Deux choses que je garde de ça. Une migration qui rapporte « 27 lignes écrites » est une preuve sur la migration, pas sur la fonctionnalité — vérifie le résultat visible par l'utilisateur, pas l'étape que tu es en mesure de compter. Et : accepter une conséquence et la mesurer sont des actions différentes, et une seule des deux change quelque chose. « Certains enregistrements cesseront de résoudre » et « ces quinze organisations nommées ne peuvent accepter aucune commande à ce jour » sont le même fait à deux résolutions, et seule la seconde fait bouger quelqu'un. Tout changement qui retire un chemin de résolution devrait être livré avec la requête qui liste exactement ce qui cesse de résoudre.
Se connecter sur ton portable tuait ton téléphone
Il y avait une seule colonne sur l'enregistrement utilisateur contenant un seul identifiant de jeton de session. Ce qui veut dire qu'un compte pouvait structurellement ne représenter qu'une seule session vivante. Connecte-toi sur ton téléphone, et la session de ton portable commence silencieusement à échouer. Reconnecte-toi sur le portable, et ton téléphone meurt.
Ça se lit comme une application capricieuse, pas comme un bug, parce que rien n'erreur au moment où ça arrive — la session survivante continue de marcher très bien jusqu'à l'expiration de son jeton d'accès, jusqu'à deux heures plus tard, et là tu es juste « déconnecté » sans raison visible. C'était en ligne pendant des mois et personne ne l'a signalé clairement, parce que « je me suis fait déconnecter » est ce que les utilisateurs disent de tout problème de session et ça ne sonne jamais structurel.
Prouvé avec deux « appareils » curl avant de toucher à quoi que ce soit, corrigé avec une vraie table de sessions, re-prouvé avec le même script.
Le jour où j'ai supprimé des données de prod de mes propres mains
C'est la partie du projet dont je voudrais le plus me souvenir, alors elle entre dans l'histoire sans vernis.
Je vérifiais le mode maintenance via l'API. J'ai envoyé un PATCH à l'endpoint des paramètres de la plateforme avec un objet de config partiel — juste les deux champs que je testais.
La logique de fusion construisait le nouveau blob de paramètres comme un spread superficiel par-dessus ce que la requête portait. Donc une charge partielle a remplacé le blob entier. Chaque paramètre hors de mes deux champs de test — disparu.
Je l'ai récupéré et j'ai corrigé la fusion. Mais le changement durable est une règle que je suis maintenant sans exception : ne teste pas une écriture avec un corps d'API partiel fait main. Parcours-la via l'interface, qui envoie la forme pour laquelle le serveur a réellement été conçu. Et capture l'état actuel avant de toucher à quoi que ce soit, dans les deux cas. Je testais l'endpoint précis dont la sémantique de fusion était cassée, avec la forme de charge précise qui déclenche le bug. Ces deux règles, suivies, l'auraient chacune empêché indépendamment.
La piste d'audit qui n'auditait rien
J'ai basculé une seule préférence de notification via l'interface puis j'ai lu la table d'audit, m'attendant à voir une ligne décrivant mon changement.
Trois bugs. La liste des « clés à exclure de l'audit » contenait les deux champs blob où chaque valeur de paramètre de chaque onglet vit réellement — donc l'audit enregistrait fidèlement la poignée de champs de haut niveau que personne ne change jamais et ignorait tout le reste. Un bug de comparaison de type faisait que chaque sauvegarde loggait aussi deux lignes fantômes. Et l'endpoint qui te laisse lire le journal d'audit ignorait complètement le tenant, ce que j'ai prouvé en lisant le journal d'une autre organisation avec mes propres identifiants.
Une fonctionnalité d'audit qui tourne, écrit des lignes, et audite les mauvaises choses est pire qu'aucune fonctionnalité d'audit, parce qu'elle produit un document auquel les gens vont faire confiance.
(Lié, trouvé la même semaine : l'horodatage « dernière modification » n'avançait jamais sur 34 des 40 tables. Chaque temps relatif dans le produit — « mis à jour il y a 3 heures » — était discrètement une date de création portant l'étiquette d'une date de modification. Rien ne crashe. Le nombre affiché est un vrai nombre. C'est exactement pour ça que ça a duré.)
L'application était inutilisable sur un téléphone, et une application n'avait aucune navigation du tout
La plainte était une phrase : bien sur ordinateur, très bordélique sur un téléphone, du texte qui déborde des conteneurs, des boutons au bord.
Tout vrai, et pire que je ne le pensais. Mesuré à 390 pixels de large :
Les applications admin et super-admin avaient une barre latérale à largeur fixe sans point de rupture, écrasant la zone de contenu principale à 130 pixels. Les titres de page s'affichaient une lettre par ligne, verticalement. Le texte des éléments de liste mesurait zéro pixel de large.
L'application web côté client, connectée, avait exactement un lien sur toute la page. Pas un élément de nav — un <a href>. Aucun moyen d'atteindre le tableau de bord, les documents, les messages, le profil ou les paramètres, parce que toute la navigation vivait dans une barre latérale réglée pour n'apparaître que sur grand écran, sans équivalent mobile. Déconnecté, l'en-tête débordait de 32 pixels du bord droit de l'écran.
La partie inconfortable : chacun de ces cas est visible en redimensionnant une fenêtre de navigateur, et l'application était passée par plusieurs nettoyages de design sans que personne ne le fasse.
Tout un microservice, tournant parfaitement, que rien n'utilisait
Le propriétaire signale une page lente. Un score Lighthouse de 70, un largest-contentful-paint de 3,3 secondes, une charge de 3,3 mégaoctets. La première question est de savoir si le rendu côté serveur aiderait. Non — ce sont des tableaux de bord derrière authentification, aucun crawler de recherche, et le SSR ne fait que déplacer l'attente des données d'un endroit à un autre.
La vraie cause : il y a un microservice dédié d'optimisation d'images. Écrit en Rust. Il génère des variantes de vignettes redimensionnées de chaque image à l'upload et stocke des références vers elles. Il avait été déployé, en bonne santé, réussissant son health check, et brûlant du CPU à chaque upload pendant des mois — et rien ne servait sa sortie. Chaque résolveur d'images de tout le produit signait une URL vers l'original en pleine résolution à la place. Exactement un chemin de code, dans une fonctionnalité récemment construite, demandait jamais une variante.
Donc toute sa sortie était du poids mort dans le stockage objet. Et ça a fait surface comme une plainte de vitesse de page, des semaines plus tard, sur une métrique que personne ne surveillait — pas comme une alerte, pas comme un contrôle en échec, parce qu'un composant complètement contourné reste au vert pour toujours si son health check ne mesure que sa propre vivacité.
Le health check répondait « puis-je répondre ? » — ce qui était vrai et inutile. Rien nulle part ne répondait « ma sortie atteint-elle un utilisateur ? »
Le correctif a câblé chaque résolveur d'images à une taille, avec un repli sur l'original quand une variante manque. Mais la partie qui a changé ma façon de travailler est le logging livré avec le correctif : le service loggue maintenant chaque variante qu'il a produite, avec dimensions et octets, et le consommateur loggue quelle clé de stockage il a réellement servie et si c'était une variante ou un repli. Parce que le mode de défaillance ici n'était pas un crash — c'était le silence. Et j'ai ajouté un test CI qui lance le vrai binaire, lui envoie une vraie image en HTTP brut, et affirme que les deux variantes reviennent en JPEG valides dans leurs dimensions prévues — pour que « le générateur de vignettes tourne » et « le générateur de vignettes produit une sortie correcte » cessent d'être la même affirmation.
Le mode maintenance devant lequel les navigateurs intégrés passaient tranquillement
Avec le mode maintenance activé, un navigateur normal affichait correctement la page de maintenance. Un navigateur intégré — celui à l'intérieur d'une application de messagerie ou sociale — affichait l'application normale, sans données.
Le tracer a trouvé quelque chose de structurellement faux plutôt qu'une bizarrerie de compatibilité. Le back-end faisait respecter le drapeau correctement. Mais l'écran que les utilisateurs voient était un composant côté client qui ne s'affiche qu'après un poll réseau réussi vers ce back-end. Donc l'application d'un blocage à l'échelle de la plateforme vivait entièrement dans du code tournant sur le client — du code qui devait accomplir un appel réseau cross-origin réussi pour apprendre qu'il devait se bloquer lui-même. Tout client où cet appel échoue silencieusement — ce que les navigateurs intégrés restreints font plus agressivement — affiche l'enveloppe de l'application ordinaire pour toujours, parce que le canal même qui livrerait l'instruction « stop » est celui qui échoue.
Le correctif a déplacé l'application vers le reverse proxy, par lequel chaque requête passe et qu'aucun client ne contrôle. Et il a réutilisé ce qui était déjà là plutôt que d'ajouter un mécanisme parallèle : une route de porte publique laissée hors de la liste d'autorisation de la protection existante, pour que la protection existante produise le blocage sans nouvelle logique ; une réponse non-2xx promue en un statut d'erreur qui s'écoule dans le gestionnaire d'erreurs qui sert déjà la page de marque.
Deux décisions de cadrage l'ont accompagné. Ne vérifier que les vraies navigations de page, pour que les ressources statiques ne paient pas un aller-retour. Et exclure le nom d'hôte de l'opérateur lui-même — parce que sinon activer le mode maintenance te verrouille hors du seul écran qui le désactive. Cette exclusion n'est pas un cas limite. C'est la première chose à réussir.
Un contrôle qui dépend de la coopération de la partie contrôlée n'est pas un contrôle.
La traduction automatique du navigateur crashait l'application
La traduction de page intégrée à Chrome réécrit les nœuds texte sur place, sous le réconciliateur de React, qui essaie alors de retirer ou de remplacer des enfants qui ne sont pas les nœuds dont il se souvient, et l'application meurt avec une exception DOM qui ressemble exactement à un bug React et n'en est pas un. Un seul attribut — translate="no" sur le HTML racine des trois applications web — le corrige. Ça ne se reproduira jamais dans un environnement de test. Il faut un vrai utilisateur dont la langue du navigateur diffère de celle de la page.
Ce qui est en quelque sorte le thème de tout ce chapitre.
Il y en a plus — je n'ai même pas mentionné le programme de couverture qui a fait passer la couverture de lignes du back-end de 30 % à 68 % et construit des suites de tests de sécurité pour l'isolation cross-tenant sur neuf types de ressources, ni la Content-Security-Policy de production qui a silencieusement bloqué à la fois le visualiseur de PDF et le reporting d'erreurs dans le seul environnement où ça comptait, ni les nombreuses fois où l'OAuth Google a échoué à cause d'IP de LAN d'une manière qui ressemblait à un bug d'intégration à chaque fois et ne l'était jamais.
Mais tu saisis la forme. Les bugs coûteux vivent hors de l'application — dans la config edge, la config de déploiement, l'application côté client, le middleware partagé que personne ne relit. Aucun d'eux ne fait échouer un test. Aucun d'eux ne se reproduit en local. Aucun d'eux n'apparaît sur un diagramme d'architecture. Et le seul moyen fiable que j'aie trouvé pour les attraper, c'est d'arrêter de croire que le code fait ce qu'il dit, et d'aller le regarder.