Supposons qu’Alice appartienne à alpha. Elle demande /workspaces/alpha/documents/doc-b/, mais reçoit un document de beta. Le serveur a reconnu Alice et vérifié son appartenance. Les deux contrôles ont réussi. Où la requête a-t-elle déraillé ?

Cet exemple utilise des documents et identités fictifs. Il nécessite Node.js 22 ou ultérieur ; la méthode s’applique aussi à d’autres langages et frameworks. La question utile est précise : quel espace a été vérifié et lequel a limité la recherche du document ?

Partir d’une requête et de la règle à respecter

Gardez ensemble méthode, chemin, identité et résultat attendu :

GET /workspaces/alpha/documents/doc-b/
Demo identity: alice, a member of alpha only
Stored document: doc-b belongs to beta
Expected: 404, with no document in the response
Broken result: 200, containing Beta release notes

La politique donne aux membres accès à tous les documents de leur espace, sans partage particulier ni exception administrateur. Pour un membre d’alpha, un document hors d’alpha produit le même statut et corps qu’un document absent. Une requête visant un espace non rejoint reçoit 403 avant l’appel au stockage.

Il faut suivre deux relations : l’utilisateur authentifié appartient à l’espace de l’URL, et le document renvoyé appartient à ce même espace. Réussir le premier contrôle laisse le second à la recherche de données.

Dans un vrai rapport, notez aussi la version de l’application ou du source. La route locale doit correspondre au code ayant traité la requête. Utilisez une fixture synthétique pour la reproduction.

Trouver l’enregistrement de route avant le gestionnaire

Téléchargez request-flow.mjs et request-flow.test.mjs dans le même dossier. Cherchez les mots distinctifs du chemin, comme workspaces et documents, dans l’éditeur. Avec ripgrep, ces commandes resserrent les prochaines étapes :

rg -n 'workspaces|documents' request-flow.mjs
rg -n 'middleware|runMiddleware|requireWorkspaceMember|findById|findInWorkspace' request-flow.mjs

Ouvrez createApp. Son objet route réunit méthode, motif de chemin, middleware et gestionnaire choisi :

const route = {
  method: 'GET',
  pattern: /^\/workspaces\/([a-z0-9-]+)\/documents\/([a-z0-9-]+)\/$/,
  middleware: [authenticate, requireWorkspaceMember],
  handler: lookup === 'unscoped' ? getDocumentUnsafe : getDocument,
};

Lisez aussi la suite. handle compare new URL(request.url).pathname, vérifie la méthode et assigne les deux segments capturés à workspaceId et documentId. Aucun ne vient des paramètres de requête. L’exemple accepte lettres minuscules, chiffres et tirets, et exige la barre oblique finale ; ce n’est pas un routeur généraliste.

Dans un framework, le chemin peut être partagé entre un montage parent comme /workspaces/:workspaceId et une route enfant comme /documents/:documentId. Lisez le montage et vérifiez le passage des paramètres parents. Un résultat dans le gestionnaire ne révèle pas les middlewares entourant son appelant.

Lire les middlewares dans leur ordre réel

runMiddleware enveloppe le gestionnaire avec reduceRight. Avec [authenticate, requireWorkspaceMember], viennent l’authentification, l’appartenance puis le gestionnaire, si chaque étape appelle next(). Les valeurs de return next() remontent à handle, dont le catch transforme un HttpError lancé en réponse.

Request → path and method match → authenticate
        → requireWorkspaceMember → document lookup → JSON Response
                                   ↘ thrown error → handle's catch → error Response

authenticate cherche l’en-tête Authorization dans une map fixe et place le résultat dans ctx.user. demo-alice et demo-bob sont des entrées publiques de fixture représentant une session vérifiée. Cela suffit pour suivre l’identité ici ; la vérification réelle de session ou jeton doit être lue séparément.

Ouvrez maintenant le middleware d’appartenance :

async function requireWorkspaceMember(ctx, next) {
  const { workspaceId } = ctx.params;
  const allowed = ctx.user.workspaceIds.includes(workspaceId);
  ctx.trace.push(`membership:${workspaceId}:${allowed ? 'allowed' : 'denied'}`);
  if (!allowed) throw new HttpError(403, 'Forbidden');
  return next();
}

Pour Alice et un chemin nommant alpha, allowed vaut true. Rien n’examine documentId ni ne charge un document. Notez précisément : « Alice peut entrer dans la route alpha. » Écrire seulement « autorisée » invite à surestimer le contrôle.

Marquez aussi les arrêts. Une identité absente ou inconnue donne 401 avant l’appartenance ; un non-membre reçoit 403 avant le gestionnaire. Si le middleware appelle next() avant sa décision, le gestionnaire peut déjà avoir tourné. La position compte autant que le nom.

Distinguer utilisateur, espace demandé et document stocké

Une note utile pour la requête fautive tient en quatre lignes :

Valeur Origine Valeur de la requête Où la suivre
Utilisateur et appartenances authenticate lit la map d’identités Alice, ['alpha'] requireWorkspaceMember
Espace demandé Première capture de route alpha Appartenance, puis arguments de recherche
Document demandé Seconde capture de route doc-b Recherche de document
Espace réel du document Ligne stockée beta Prédicat de recherche et réponse

Ajoutez fichiers et lignes en inspectant chaque producteur et consommateur. Dans une grande application, ce seront peut-être des types et paquets différents. La distinction reste utile : l’URL exprime la demande, l’identité les appartenances, la ligne stockée la propriété.

Les deux identifiants arrivent ensemble. Voyez maintenant s’ils quittent le gestionnaire ensemble.

Ouvrir la recherche, même si le gestionnaire semble ordinaire

Le gestionnaire volontairement non sûr est :

async function getDocumentUnsafe(ctx) {
  ctx.trace.push(`lookup:unscoped:${ctx.params.documentId}`);
  const document = await ctx.store.findById(ctx.params.documentId);
  if (!document) throw new HttpError(404, 'Not found');
  return json(200, document);
}

Seul documentId atteint le stockage. Suivez findById dans createStore : le prédicat est document.id === documentId. Aucun contrôle d’espace ni vérification ultérieure de propriété ne précède la sérialisation. Le middleware a accepté alpha ; cette recherche peut renvoyer n’importe quel espace.

Ni course ni session falsifiée ne sont nécessaires. Alice garde son identité et le chemin autorisé alpha, puis fournit l’identifiant doc-b. L’application vérifie un périmètre de ressource et en lit un autre.

Dans un vrai projet, un nom de fonction serait une preuve plus faible. Une méthode peut ajouter un filtre de locataire, hériter d’une session de base restreinte ou dépendre de politiques par ligne. Lisez implémentation et configuration avant de conclure qu’un argument absent implique une requête non limitée. Ici, le prédicat en mémoire est toute la recherche, donc directement vérifiable.

Exécuter le contre-exemple sur toute la chaîne

Depuis le dossier contenant les deux fichiers, lancez :

node request-flow.mjs
node --test request-flow.test.mjs

Aucune installation, compte ou service. makeRequest construit un Request Web, transmis à handle, qui renvoie une Response Web. L’URL example.invalid est uniquement analysée en mémoire. L’exemple n’appelle jamais fetch et n’ouvre aucun socket d’écoute.

La démo affiche ces réponses, avec les étapes visitées et les appels au stockage sous chacune :

normal: 200 {"id":"doc-a","workspaceId":"alpha","title":"Alpha release notes"}
reproduce: 200 {"id":"doc-b","workspaceId":"beta","title":"Beta release notes"}
fixed: 404 {"error":"Not found"}

Pour la requête fautive, les étapes visitées sont :

request:GET -> route:alpha:doc-b -> authenticate:alice -> membership:alpha:allowed -> lookup:unscoped:doc-b -> response:200

Le tableau de trace est une instrumentation de la fixture, conservée hors réponse. Le test vérifie aussi le document et l’appel enregistré à findById('doc-b'). Ces assertions relient la séquence de branches aux données réellement renvoyées.

Le test reproduce: passe quand la version non sûre renvoie le document de beta. Son vert confirme le défaut reproductible. Un test séparé exige 404 de la version corrigée pour la même requête.

Transmettre l’espace vérifié à la requête de données

Le gestionnaire corrigé conserve les deux valeurs de route :

async function getDocument(ctx) {
  const { workspaceId, documentId } = ctx.params;
  ctx.trace.push(`lookup:scoped:${workspaceId}:${documentId}`);
  const document = await ctx.store.findInWorkspace(workspaceId, documentId);
  if (!document) throw new HttpError(404, 'Not found');
  return json(200, document);
}

Dans findInWorkspace, le prédicat exige document.workspaceId === workspaceId et document.id === documentId. Le middleware a déjà vérifié ce même workspaceId. La fixture ne modifie pas ctx.params entre ces étapes ; documents et appartenances restent fixes durant chaque exécution.

Pour /workspaces/alpha/documents/doc-b/, il n’y a plus de résultat. Un identifiant absent ou d’un autre espace produit 404 avec {"error":"Not found"}. Statut et corps identiques évitent de révéler explicitement le cas ; les tests ne disent rien du temps ou d’autres canaux auxiliaires.

Vérifier document.workspaceId après une lecture globale pourrait aussi bloquer cette fuite précise. Une recherche ciblée rend la restriction explicite au stockage et ne renvoie aucune ligne étrangère au gestionnaire. Ici, Array.find examine toujours les lignes pour évaluer le prédicat. Les appels enregistrés montrent les arguments, pas les E/S de base ni l’isolation physique.

Pour SQL ou un ORM, inspectez la condition émise et la liaison des paramètres, puis testez avec la vraie couche de données. Révocation d’appartenance, déplacements, partages et autres routes exigent leurs propres règles et tests.

Tester le succès et les arrêts obligatoires

La suite contient dix tests : une reproduction volontaire et neuf contrôles de l’app corrigée. Plusieurs couvrent plus d’une entrée :

Requête ou condition Réponse attendue Comportement du stockage
Alice lit alpha/doc-a ; Bob lit beta/doc-b 200, bon document Les deux ID atteignent findInWorkspace
Alice lit alpha/doc-b 404, aucun document Recherche limitée à alpha
Alice lit alpha/missing Même statut et corps que ci-dessus Aucun document correspondant
Identité absente ou inconnue 401 Aucun appel au stockage ; appartenance non atteinte
Alice lit beta/doc-b 403 Aucun appel au stockage
Alice lit alpha/doc-b?workspaceId=beta 404 Le paramètre ne remplace pas le périmètre de route
Chemin inconnu ; mauvaise méthode sur chemin connu 404 ; 405 avec Allow: GET Aucun appel au stockage
Erreur inattendue du stockage 500, corps générique L’erreur atteint l’enveloppe de réponse

Les chemins abrégés correspondent à /workspaces/{workspaceId}/documents/{documentId}/, avec la requête après la barre finale. Les deux succès empêchent une « correction » rejetant tout. Les assertions d’absence d’appel détectent un rejet seulement après accès au stockage. Ensemble, elles vérifient plus que les statuts finaux.

Ces tests couvrent le traitement en processus, l’ordre des middlewares, les arguments et les réponses JSON de cette route. Ils ne testent ni transport HTTP, proxy inverse, identité de production ni base de données. Dans une application, partez de l’adaptateur HTTP réel et vérifiez le choix du gestionnaire et des middlewares suivis.

Laisser une note vérifiable

Présentez le résultat autour de la requête et des points précis d’utilisation de ses valeurs :

Trigger: Alice + GET /workspaces/alpha/documents/doc-b/
Route: createApp captures alpha and doc-b.
Membership: requireWorkspaceMember allows Alice for alpha.
Old read: getDocumentUnsafe passes only doc-b to findById.
Observed: 200 returns a row whose workspaceId is beta.
Fix: getDocument passes alpha and doc-b to findInWorkspace.
Regression: same request returns 404; both valid member reads still return 200.
Scope: in-memory request pipeline; production adapter and storage are untested.

Joignez les emplacements source et la commande de test. Si quelqu’un demande « Cette route n’a-t-elle pas déjà une autorisation ? », la note montre exactement ce que couvre le contrôle et où l’espace cesse d’être utilisé.