Aller au contenu
English

Tests négatifs

  • Leçon 17
  • 55 min
  • Module 6 : Valider et documenter la conception

Ce que vous saurez faire

Testez ce qui doit échouer, pas seulement ce qui doit fonctionner. Exécutez une matrice de tests qui envoie chaque personne, dans chaque organisation, vers chaque route protégée et vérifie le refus exact. Ajoutez les mauvaises identités et les identifiants d’enregistrement de l’autre boutique. Puis brisez volontairement l’API et regardez la matrice détecter le problème.

Avant de commencerCe qu’il faut savoir et avoir sous la mainEnviron 55 minutes

Ce que vous devez savoir

Ce qu’il vous faut sur votre ordinateur

  • Le projet après Changements de cycle de vie : il correspond à l’instantané end-of-m05, et python -m pytest affiche 234 passed, 1 deselected.
  • PostgreSQL et Azurite en marche, avec les données des deux boutiques chargées. Les tests démarrent le fournisseur d’exercice dans le processus de test. Vous n’avez donc pas besoin d’un terminal pour le fournisseur dans cette leçon.

Chaque bogue de cette leçon est un cas construit : écrit volontairement, exécuté, puis retiré. Testé sur macOS (Apple silicon); les commandes Windows sont écrites, mais non testées.

Un musée ferme à six heures. Le gardien de nuit ne vérifie pas seulement que la clé du personnel ouvre la porte principale. Il parcourt tout le bâtiment. Il essaie chaque porte avec la mauvaise clé. Il essaie d’ouvrir la réserve avec un billet de visiteur. Il pousse la sortie de secours depuis l’extérieur. Son travail consiste à trouver une porte qui s’ouvre alors qu’elle devrait rester fermée.

La plupart des tests ressemblent au personnel de jour : ils vérifient que la bonne clé ouvre la bonne porte. Le gardien vérifie l’inverse. Dans cette leçon, vous faites une ronde de nuit dans l’API de billets.

Votre projet compte 234 tests. Plusieurs vérifient l’accès : Omar ne peut pas ajouter de message, Sam reçoit 404 pour le billet de Bramble, Camille reçoit deux réponses. Chaque test a été écrit à la main, pour le cas enseigné dans la leçon.

Faites maintenant une petite erreur volontaire. Dans ticket_api/members.py, la route qui liste les membres demande l’action member.read. Remplacez-la par ticket.read :

ticket_api/members.py (constructed bug: do not keep)
def members(caller: Annotated[Caller, Depends(require("ticket.read"))], db: DB) -> MemberList:

Un développeur pourrait faire cette erreur en copiant une route de tickets.py. Exécutez tous les tests :

Fenêtre de terminal
python -m pytest -q

La dernière ligne :

234 passed, 1 deselected in 20.62s

Tous les tests réussissent. Mais Omar, qui a un accès en lecture seule, peut maintenant lister les membres de Larkfield : identifiants d’utilisateur, noms, rôles et dates d’arrivée. Camille peut lister les membres de Bramble, où son accès est aussi en lecture seule. Aucun test ne l’a vérifié, car aucun n’a envoyé une personne en lecture seule vers la liste des membres. Les tests choisis à la main couvrent les cas auxquels quelqu’un a pensé. Une élévation de privilèges se cache dans un cas auquel personne n’a pensé. Laissez le bogue pour le moment : vous le réutiliserez dans quelques minutes.

La matrice d’accès indique déjà ce que chaque personne peut faire dans chaque organisation. Les tests n’ont donc pas besoin de choisir des cas. Ils peuvent envoyer chaque personne vers chaque route dans chaque organisation et comparer la réponse à la matrice. C’est une matrice de tests : un test paramétré, avec un cas de test par cellule.

Tester ce qui doit échouer porte un nom : les tests négatifs. Un test positif vérifie que Grace peut inviter une personne. Un test négatif vérifie que Sam ne le peut pas et qu’il reçoit le bon refus : 403, pas 500 ni 201. Pour le contrôle d’accès, les tests négatifs comptent plus que les tests positifs. Une autorisation manquante dérange un utilisateur, qui la signale aussitôt. Une autorisation en trop cause du tort sans se faire remarquer.

La matrice de tests du cours comprend trois parties :

Trois boîtes mènent à tests/test_access_matrix.py, 213 vérifications. Chaque personne dans chaque organisation : 7 personnes fois 2 organisations fois 11 routes donnent 154 vérifications. Identifiant d'enregistrement d'une autre organisation : 4 cas, 3 requêtes chacun, donnent 4 vérifications. Mauvaise identité sur chaque route : 5 types fois 11 routes donnent 55 vérifications.
Les 213 vérifications de la matrice de tests : 154 cellules pour les personnes et les organisations, 4 cas avec l'identifiant d'enregistrement de l'autre boutique et 55 mauvaises identités.
Description textuelle du diagramme

Le fichier de tests comprend trois groupes. D’abord, 7 personnes fois 2 organisations fois 11 routes : 154 vérifications. Ensuite, 4 cas où un membre utilise le bon chemin avec l’identifiant d’enregistrement de l’autre organisation; chaque cas envoie 3 requêtes. Enfin, 5 types de mauvaise identité sur chacune des 11 routes : 55 vérifications. Au total : 213.

Chaque groupe teste certains des cinq types d’échec :

Type d’échec Où dans la matrice Réponse nécessaire
Identité absente mauvaise identité : no identity (aucune identité) 401
Identifiants expirés mauvaise identité : expired (expiré) 401
Mauvaise audience mauvaise identité : wrong audience (mauvaise audience), et un ID token (jeton d’identité; son audience est l’application) 401
Rôle insuffisant les cellules des membres en lecture seule et du personnel; aussi no scope (aucune portée) 403
Mauvaise organisation les cellules des personnes externes et l’identifiant d’enregistrement de l’autre boutique 404

Avant le grand fichier, regardez une ligne où toutes les valeurs sont visibles. Omar a un accès en lecture seule chez Larkfield. La matrice dans ticket_api/access.py donne trois actions à read_only : ticket.read, file.read et document.read. Donc, pour Omar chez Larkfield :

Route Action Le rôle d’Omar l’autorise? Résultat attendu
lire un billet ticket.read oui 200
ajouter un message ticket.write non 403
lister les membres member.read non 403
lire le journal d’audit audit.read non 403

Chez Bramble Books, Omar n’a aucune adhésion. Chaque route doit donc renvoyer 404, avec le même corps que pour une organisation inexistante. Le test ne fixe pas ces nombres dans le code. Il demande la décision à decide() et la transforme en code d’état attendu :

tests/test_access_matrix.py (part)
def expected(user: str, tenant: str, route: str) -> int:
action, *_, ok = ROUTES[route]
decision = decide(MEMBERSHIPS.get((tenant, user)), action)
return ok if decision.allowed else decision.status

ok est le code d’état d’un appel réussi : 200 pour une lecture, 201 pour un nouveau message, 202 pour une exportation qui démarre. Ensuite, une fonction de test s’exécute pour chaque cellule. pytest.mark.parametrize crée 154 cas de test à partir d’une fonction et donne à chacun un identifiant lisible comme usr-omar|larkfield|upload a file (Omar chez Larkfield, téléverser un fichier).

  1. Copiez le fichier de tests depuis l’instantané end-of-m06 :

    Fenêtre de terminal
    S=../nextia-learning-labs/C23/snapshots/end-of-m06
    cp "$S/tests/test_access_matrix.py" tests/
  2. Votre API contient toujours le bogue de la liste des membres. Exécutez seulement le nouveau fichier :

    Fenêtre de terminal
    python -m pytest tests/test_access_matrix.py -q
    E AssertionError: {"items":[{"user_id":"usr-grace","name":"Grace","role":"owner","since":"2024-01-08T09:00:00Z"},…
    E assert 200 == 403
    E + where 200 = <Response [200 OK]>.status_code
    E + and 403 = expected('usr-omar', 'larkfield', 'list members')
    …
    FAILED tests/test_access_matrix.py::test_matrix[usr-omar|larkfield|list members] - AssertionError: …
    FAILED tests/test_access_matrix.py::test_matrix[usr-camille|bramble|list members] - AssertionError: …
    2 failed, 211 passed in 19.84s

    La matrice trouve ce que 234 tests ont manqué. Chaque échec nomme la cellule, et l’assertion montre la réponse de l’API : assert 200 == 403. La sortie est abrégée ici. Un code 200 signifie que l’API a envoyé la liste des membres à une personne en lecture seule.

  3. Retirez le bogue : remplacez ticket.read par member.read dans ticket_api/members.py. Exécutez de nouveau la matrice :

    Fenêtre de terminal
    python -m pytest tests/test_access_matrix.py
    collected 213 items
    tests/test_access_matrix.py ............................................ [ 20%]
    ........................................................................ [ 54%]
    ........................................................................ [ 88%]
    ......................... [100%]
    ============================= 213 passed in 18.77s =============================

    La durée varie selon l’ordinateur. Chaque cas crée un nouveau client de test, donc les 213 cas prennent quelques secondes.

  4. Exécutez les lignes d’une seule personne pour voir les identifiants de test :

    Fenêtre de terminal
    python -m pytest tests/test_access_matrix.py -k "usr-omar" -v

    Chaque ligne nomme une personne, une organisation et une route, et chaque ligne affiche PASSED (réussi).

Une liste de 213 noms de tests est difficile à lire. L’image ci-dessous présente les mêmes résultats dans une grille : une ligne par personne dans chaque organisation, une colonne par route. La couleur et le nombre d’une cellule indiquent le code d’état exigé par la matrice d’accès. Toutes les cellules ont réussi ici : l’API a répondu avec exactement ce code d’état.

  • permis (200, 201, 202)
  • 403 : membre, rôle insuffisant
  • 404 : pas un membre
  • 401 : aucune identité valide
La matrice de tests à la fin du cours : 213 vérifications sur 213 ont réussi. Lignes : chaque personne dans chaque organisation, puis cinq identités invalides. Colonnes : les 11 routes protégées.
Description textuelle du graphique

Quatorze lignes montrent les 7 personnes dans les 2 organisations, avec leur rôle dans chacune. Grace (propriétaire) et Ines (propriétaire) reçoivent 200, 201 ou 202 sur les 11 routes de leur propre organisation. Sam et Camille font partie du personnel chez Larkfield : ils reçoivent 403 pour « inviter une personne » et « lire le journal d’audit », et réussissent ailleurs. Omar chez Larkfield et Camille chez Bramble Books ont un accès en lecture seule. Ils réussissent pour lister les billets, lire un billet, consulter les billets semblables, télécharger un fichier et chercher des documents. Ils reçoivent 403 sur les six autres routes. Chaque ligne sans adhésion, dont celles de Tomás et Kwame dans les deux organisations, affiche 404 sur les 11 routes. Sous les personnes figurent cinq lignes de mauvaise identité, envoyées aux routes de Larkfield. Aucune identité, expiration, mauvaise audience et jeton d’identité donnent 401 sur les 11 routes. Un jeton sans portée reçoit 403 sur les 11 routes. Identifiant d’enregistrement de l’autre organisation : 4 cas sur 4 réussissent. 213 vérifications sur 213 ont réussi.

Lisez-la comme une carte. Les blocs gris représentent les personnes externes : des lignes entières de 404. Les blocs orange représentent les membres au rôle plus limité. Les blocs bleus représentent le travail prévu pour chaque rôle. Tomás et Kwame n’ont aucune adhésion, donc leurs deux lignes sont grises. Kwame est administrateur de la plateforme, mais ce n’est pas une adhésion : sans autorisation de soutien, il reçoit 404 comme tout le monde.

Voici maintenant la même image pour l’API avec le bogue de la liste des membres. Les données sont le résultat réel de l’exécution du cas construit :

  • permis (200, 201, 202)
  • 403 : membre, rôle insuffisant
  • 404 : pas un membre
Bogue construit : la liste des membres demande ticket.read. 211 vérifications sur 213 ont réussi; les cellules en échec ont un contour épais.
Description textuelle du graphique

Les mêmes 14 lignes et 11 colonnes. Deux cellules ont un contour épais : Omar chez Larkfield, lister les membres, résultat attendu 403; et Camille chez Bramble Books, lister les membres, résultat attendu 403. Dans les deux cellules, l’API a répondu 200. Toutes les autres cellules ont réussi. 211 vérifications sur 213 ont réussi.

Les deux cellules en échec se trouvent dans les lignes en lecture seule, dans une seule colonne. Cette disposition vous indique où chercher : une route et la vérification d’un rôle. Une cellule en échec dans une ligne grise indiquerait un bogue bien plus grave : une personne externe est entrée.

Les mauvaises identités et les identifiants de l’autre boutique

Section intitulée « Les mauvaises identités et les identifiants de l’autre boutique »

Les deux derniers groupes utilisent la même idée. Cinq types de mauvaise identité sont envoyés à chaque route :

tests/test_access_matrix.py (part)
BAD = ["no identity", "expired", "wrong audience", "ID token", "no scope"]

Les quatre premiers doivent recevoir 401, et le dernier 403 : la personne est connue, mais le jeton n’autorise pas l’action. Dans Ce que signifie un jeton, les 15 jetons inventés étaient envoyés à un seul point de terminaison, /v1/me. Ici, cinq d’entre eux sont envoyés aux 11 routes. Cela vérifie qu’aucune route n’a oublié la vérification d’identité, par exemple une nouvelle route sans require().

L’identifiant d’enregistrement de l’autre boutique correspond à l’attaque au niveau de l’objet présentée dans Exemples de menaces. Sam utilise son propre chemin Larkfield, mais avec le billet T-40001 de Bramble. Lire le billet et ses billets semblables doit donner 404. Ajouter un message doit aussi donner 404, ou 403 pour un membre en lecture seule, car le rôle est vérifié avant la recherche.

Kwame pose une question gênante : « La matrice tire ses réponses attendues de decide(). Et si decide() se trompe? »

Essayez. Ajoutez un deuxième bogue construit dans ticket_api/access.py : donnez aussi l’action ticket.write à read_only. Puis exécutez la matrice et le test de politique :

Fenêtre de terminal
python -m pytest tests/test_access_matrix.py tests/test_access_policy.py -q
FAILED tests/test_access_matrix.py::test_another_organizations_record_id[usr-camille|bramble|T-30002]
FAILED tests/test_access_policy.py::test_the_code_is_the_matrix[2-read_only-ticket.write]
FAILED tests/test_access_policy.py::test_a_role_that_cannot_is_403_and_names_the_rule
FAILED tests/test_access_policy.py::test_camille_gets_two_answers_for_one_action
4 failed, 244 passed in 19.18s

Les lignes d’échec sont abrégées ici. Les 154 cellules de la matrice ont réussi : l’API et decide() concordaient, mais les deux se trompaient. Omar pouvait écrire, et la matrice attendait exactement cela. Une seule attente écrite à la main dans la matrice a détecté le problème, dans un cas avec l’identifiant de l’autre boutique. Trois tests de test_access_policy.py ont échoué. Ce fichier contient la matrice sur papier recopiée à la main, ligne par ligne, et la compare à decide().

Une chaîne de cinq boîtes : docs/access-matrix.md, les règles sur papier; test_access_policy.py, une copie manuscrite du papier, vérifie decide() dans ticket_api/access.py; decide() fournit le code d'état attendu à test_access_matrix.py, qui demande la réponse attendue à decide() et vérifie 213 réponses de l'API : routes, vérification d'adhésion et requêtes limitées au locataire.
Deux tests, deux questions. Le test de politique demande : decide() correspond-il à la matrice convenue? La matrice de tests demande : chaque route respecte-t-elle decide()?
Description textuelle du diagramme

Les règles commencent sur papier, dans docs/access-matrix.md. Le test de politique est une copie du papier écrite à la main, et il vérifie decide(). La matrice de tests demande chaque code d’état attendu à decide() et vérifie les 213 réponses de l’API. Un bogue dans une route fait échouer la matrice de tests. Un bogue dans decide() fait réussir les cellules de la matrice de tests et échouer le test de politique.

Vous avez donc besoin des deux. La matrice de tests trouve une route qui ne respecte pas les règles. Le test de politique trouve des règles qui ne correspondent pas aux règles convenues. Retirez le deuxième bogue avant de continuer.

Un troisième bogue construit : quand le deuxième verrou masque le premier

L’auteur a volontairement retiré l’organisation d’une requête : get_ticket() dans ticket_api/repository.py, sans t.tenant_id = %(tenant_id)s. La matrice de tests a quand même réussi, 213 sur 213. La sécurité au niveau des lignes présentée dans Requêtes sûres pour chaque locataire a tenu : sous ticket_app pour Larkfield, la base de données ne montrait aucune ligne de Bramble. Avec une copie de la matrice qui définit db_row_security=False, les 4 cas avec l’identifiant de l’autre boutique ont échoué avec assert 200 == 404. Le deuxième verrou masquait un premier verrou brisé. Une exécution des tests sans le deuxième verrou montre si le premier fonctionne encore.

Exécutez encore une fois toute la suite, sans bogue dans le code :

Fenêtre de terminal
python -m pytest -q

La dernière ligne :

447 passed, 1 deselected in 38.67s

La matrice prend quelques secondes à chaque exécution, et davantage lorsque vous ajoutez une route ou une personne : les cellules se multiplient. Cela reste peu coûteux. Une nouvelle route demande une ligne dans ROUTES et reçoit aussitôt 14 cellules et 5 mauvaises identités. Une nouvelle personne ou adhésion demande une ligne dans PEOPLE ou MEMBERSHIPS. Quand ne pas utiliser une matrice complète : pour les règles qui dépendent des données, pas du rôle. Par exemple : « L’auteur peut modifier son propre message pendant 5 minutes ». Testez ces règles avec quelques cas écrits à la main, à côté de la matrice.

Erreur courante

La matrice réussit instantanément

Symptôme : python -m pytest tests/test_access_matrix.py affiche 213 skipped in 0.01s (213 tests ignorés en 0,01 seconde).

Cause : PostgreSQL n’est pas en marche. Les tests s’ignorent lorsqu’ils ne peuvent pas joindre la base de données. Un test ignoré n’est pas un test réussi.

Solution : démarrez la base de données (docker compose up -d db azurite) et exécutez de nouveau les tests. Ajoutez -rs pour voir pourquoi des tests ont été ignorés : PostgreSQL is not running (with Docker: docker compose up -d db) (PostgreSQL n’est pas en marche; avec Docker, utilisez la commande indiquée).

Erreur courante

Une nouvelle route n'a aucune cellule

Symptôme : vous ajoutez une route, et la matrice compte toujours 213 vérifications.

Cause : la matrice teste seulement les routes de sa table ROUTES. Une route absente de cette table n’est pas testée.

Solution : ajoutez chaque nouvelle route protégée à ROUTES, avec son action, sa méthode, son chemin, son corps et son code d’état de réussite. La révision d’une nouvelle route devrait poser la question : « Est-elle dans la matrice? »

Camille fait partie du personnel chez Larkfield et a un accès en lecture seule chez Bramble Books. Sans regarder l’image, écrivez ses deux lignes : 11 codes d’état chacune. Ensuite, exécutez python -m pytest tests/test_access_matrix.py -k "usr-camille" -v et comparez.

Afficher la réponse

Chez Larkfield (personnel) : 200, 200, 200, 201, 200, 201, 200, 200, 403 (inviter une personne), 202, 403 (lire le journal d’audit). Chez Bramble Books (lecture seule) : 200, 200, 200, 403, 200, 403, 200, 403 (lister les membres), 403, 403, 403. Les 22 cellules affichent PASSED (réussi), tout comme ses deux cas utilisant l’identifiant de l’autre boutique.

Vérifiez votre compréhension

Nommez les cinq types d'échecs vérifiés par la matrice de tests, et le code d'état attendu pour chacun.

Afficher la réponse

Identité absente : 401. Identifiants expirés : 401. Mauvaise audience (y compris un jeton d’identité envoyé comme jeton d’accès) : 401. Rôle ou portée insuffisants : 403, car la personne est membre et voit déjà l’organisation. Mauvaise organisation : 404, pour une personne externe comme pour l’identifiant d’un enregistrement d’une autre organisation, afin de ne rien révéler.

Vérifiez votre compréhension

Prédisez le résultat : un développeur ajoute une route, DELETE /v1/tenants/{tenant}/tickets/{id}, et oublie require(). Il l'ajoute à ROUTES avec l'action ticket.write. Quelles cellules de la matrice échouent?

Afficher la réponse

Sans require(), la route ne vérifie ni l’identité ni l’adhésion. Les quatre mauvaises identités qui exigent 401 échouent, ainsi que la ligne sans portée qui exige 403. Chaque ligne de personne externe attend 404 et échoue (Grace, Sam et Omar chez Bramble Books, Ines chez Larkfield, Tomás et Kwame dans les deux). Les lignes en lecture seule attendent 403 et échouent. Seules les cellules des propriétaires et du personnel, qui peuvent écrire, réussissent.

Vérifiez votre compréhension

Pourquoi le projet a-t-il besoin de test_access_policy.py alors que la matrice de tests contient déjà 213 vérifications?

Afficher la réponse

La matrice de tests tire ses réponses attendues de decide(). Si decide() se trompe, l’API et la réponse attendue se trompent ensemble, et les cellules de la matrice réussissent. Quand read_only autorisait l’écriture, les 154 cellules réussissaient. Le test de la politique compare decide() à une copie écrite à la main de la matrice papier. Il détecte donc une règle incorrecte.

Vérifiez votre compréhension

Vérification pratique : exécutez la matrice de tests. Combien de vérifications réussissent, et que signifie une vérification ignorée?

Afficher la réponse

python -m pytest tests/test_access_matrix.py affiche 213 passed (213 vérifications réussies). Une vérification ignorée n’a pas été exécutée, généralement parce que PostgreSQL n’est pas en cours d’exécution. Elle ne prouve rien : comptez seulement les vérifications réussies.

Récapitulatif

  • Les tests négatifs vérifient ce qui doit échouer, et le refus exact : 401, 403 ou 404.
  • Une matrice de tests envoie chaque personne, dans chaque organisation, vers chaque route : 7 × 2 × 11 = 154 cellules. Avec 4 cas utilisant l’identifiant de l’autre boutique et 5 mauvaises identités × 11 routes, cela donne 213 vérifications.
  • Un bogue construit dans la liste des membres a réussi les 234 tests précédents et échoué à 2 vérifications de la matrice.
  • La matrice fait confiance à decide(); test_access_policy.py vérifie decide() par rapport à la matrice papier. Gardez les deux.
  • Un test ignoré n’est pas un test réussi.
  • Ensuite : consignez qui a fait quoi, et qui a essayé.

Leçon suivante : Auditabilité