fix(web): crypto.randomUUID plante hors contexte sécurisé, empêchant d'associer un ingrédient
Écran noir + "TypeError: crypto.randomUUID is not a function" au clic sur une carte d'ingrédient dans le formulaire de recette. crypto.randomUUID() n'est défini que dans un "contexte sécurisé" (https, ou littéralement le host "localhost") — il est absent sur une IP locale (test sur un vrai appareil), dans une WebView Capacitor (l'enrobage mobile prévu pour cette app), ou en http sur un vrai domaine. RecipeFormPage/StepListEditor s'en servaient pour générer l'identité React (`key`) de chaque ligne d'ingrédient/étape en brouillon. - apps/web/src/lib/client-key.ts : remplace par un générateur qui ne touche jamais `crypto` — un compteur + Math.random suffit, cette valeur n'a besoin d'être unique que le temps de la session de rendu, jamais envoyée au serveur. - apps/web/cypress/e2e/recipe-form.cy.ts : couvre l'association d'un ingrédient (recherche, sélection, exclusion du picker une fois sélectionné, retrait), la création et l'édition d'une recette, et un test de non-régression dédié qui supprime crypto.randomUUID avant le chargement de la page (comme le ferait un vrai contexte non sécurisé) pour vérifier que l'ajout de plusieurs ingrédients/étapes ne plante plus. Vérifié en direct dans le navigateur de prévisualisation en supprimant crypto.randomUUID à la main (reproduit le crash), puis en confirmant que l'ajout d'ingrédient fonctionne à nouveau après le correctif. cypress run ne peut toujours pas s'exécuter dans cet environnement (voir le commit précédent) — non exécutés avec Cypress lui-même, mais vérifiés par lecture des sélecteurs réels et rejoués à la main dans le navigateur. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
4b85531601
commit
3ead09a43f
4 changed files with 208 additions and 4 deletions
178
apps/web/cypress/e2e/recipe-form.cy.ts
Normal file
178
apps/web/cypress/e2e/recipe-form.cy.ts
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
// Mocks the API via cy.intercept — see auth.cy.ts for the rationale (no
|
||||||
|
// live backend in this CI job; apps/api's own Mocha/Cucumber suites cover
|
||||||
|
// real API behavior against a real database).
|
||||||
|
|
||||||
|
const authenticatedProfile = {
|
||||||
|
id: 1,
|
||||||
|
firstName: "Alice",
|
||||||
|
lastName: "Martin",
|
||||||
|
email: "alice@example.com",
|
||||||
|
tokenVersion: 0,
|
||||||
|
houseId: 1,
|
||||||
|
dietId: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const tomato = {
|
||||||
|
id: 1,
|
||||||
|
key: "tomato",
|
||||||
|
icon: "VEGETABLE",
|
||||||
|
category: "PRODUITS_FRAIS",
|
||||||
|
subcategory: "LEGUMES",
|
||||||
|
allergens: [],
|
||||||
|
diets: [{ id: 2, key: "vegetarian" }],
|
||||||
|
};
|
||||||
|
const egg = {
|
||||||
|
id: 2,
|
||||||
|
key: "egg",
|
||||||
|
icon: "EGG",
|
||||||
|
category: "CREMERIE_FROMAGE",
|
||||||
|
subcategory: "OEUFS",
|
||||||
|
allergens: [{ id: 1, key: "eggs", kind: "ALLERGY" }],
|
||||||
|
diets: [],
|
||||||
|
};
|
||||||
|
const carrot = {
|
||||||
|
id: 3,
|
||||||
|
key: "carrot",
|
||||||
|
icon: "VEGETABLE",
|
||||||
|
category: "PRODUITS_FRAIS",
|
||||||
|
subcategory: "LEGUMES",
|
||||||
|
allergens: [],
|
||||||
|
diets: [{ id: 2, key: "vegetarian" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const diets = [
|
||||||
|
{ id: 1, key: "omnivore" },
|
||||||
|
{ id: 2, key: "vegetarian" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function interceptCatalog() {
|
||||||
|
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
|
||||||
|
cy.intercept("GET", "**/reference/ingredients", {
|
||||||
|
statusCode: 200,
|
||||||
|
body: [tomato, egg, carrot],
|
||||||
|
});
|
||||||
|
cy.intercept("GET", "**/reference/diets", { statusCode: 200, body: diets });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Recipe form — associating ingredients", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
interceptCatalog();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds an ingredient from the picker, fills its quantity/unit, and creates the recipe", () => {
|
||||||
|
cy.intercept("POST", "**/recipes", { statusCode: 201, body: { id: 42 } }).as("createRecipe");
|
||||||
|
|
||||||
|
cy.visit("/recettes/nouvelle");
|
||||||
|
|
||||||
|
cy.get("#recipe-name").type("Salade de tomates");
|
||||||
|
|
||||||
|
cy.get("input[placeholder='Rechercher un ingrédient…']").type("tomat");
|
||||||
|
cy.contains(".ingredient-picker__card", "Tomate").click();
|
||||||
|
|
||||||
|
// The card disappears from the picker once selected (excludeIds), and
|
||||||
|
// a row for it appears in the recipe's own ingredient list.
|
||||||
|
cy.contains(".ingredient-picker__card", "Tomate").should("not.exist");
|
||||||
|
cy.contains(".ingredient-row__name", "Tomate").should("be.visible");
|
||||||
|
|
||||||
|
cy.get(".ingredient-row .ingredient-row__quantity").type("3");
|
||||||
|
cy.get(".ingredient-row .ingredient-row__unit").type("unité");
|
||||||
|
|
||||||
|
cy.contains("button", "Ajouter une étape").click();
|
||||||
|
cy.get(".step-list-editor__item textarea").type("Couper les tomates.");
|
||||||
|
|
||||||
|
cy.contains("button", "Enregistrer").should("not.be.disabled").click();
|
||||||
|
|
||||||
|
cy.wait("@createRecipe").its("request.body").should("deep.include", {
|
||||||
|
name: "Salade de tomates",
|
||||||
|
ingredients: [{ ingredientId: 1, quantity: 3, unit: "unité" }],
|
||||||
|
});
|
||||||
|
cy.url().should("include", "/recettes/42");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Regression test for the exact bug reported: `crypto.randomUUID()` (used
|
||||||
|
// to mint each ingredient/step draft's client-only React key) throws
|
||||||
|
// outside a secure context — https, or literally the hostname
|
||||||
|
// `localhost` — so a LAN IP during on-device testing or a Capacitor
|
||||||
|
// WebView's `capacitor://` origin hit a black screen with "TypeError:
|
||||||
|
// crypto.randomUUID is not a function" the instant an ingredient was
|
||||||
|
// added. Cypress's own origin is secure, so this forces the same failure
|
||||||
|
// by deleting `crypto.randomUUID` before the app boots — see
|
||||||
|
// `apps/web/src/lib/client-key.ts`, which replaced it.
|
||||||
|
it("still works when crypto.randomUUID is unavailable (insecure-context regression)", () => {
|
||||||
|
cy.visit("/recettes/nouvelle", {
|
||||||
|
onBeforeLoad(win) {
|
||||||
|
Object.defineProperty(win.crypto, "randomUUID", {
|
||||||
|
value: undefined,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
cy.get("#recipe-name").type("Recette hors contexte sécurisé");
|
||||||
|
|
||||||
|
cy.contains(".ingredient-picker__card", "Tomate").click();
|
||||||
|
cy.contains(".ingredient-picker__card", "Œuf").click();
|
||||||
|
|
||||||
|
// Both rows rendered with distinct identities — no crash, no React
|
||||||
|
// "same key" warning silently collapsing one of them.
|
||||||
|
cy.get(".ingredient-row").should("have.length", 2);
|
||||||
|
cy.contains(".ingredient-row__name", "Tomate").should("be.visible");
|
||||||
|
cy.contains(".ingredient-row__name", "Œuf").should("be.visible");
|
||||||
|
|
||||||
|
cy.contains("button", "Ajouter une étape").click();
|
||||||
|
cy.contains("button", "Ajouter une étape").click();
|
||||||
|
cy.get(".step-list-editor__item").should("have.length", 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes an already-selected ingredient from the picker, and removing it brings it back", () => {
|
||||||
|
cy.visit("/recettes/nouvelle");
|
||||||
|
|
||||||
|
cy.contains(".ingredient-picker__card", "Carotte").click();
|
||||||
|
cy.contains(".ingredient-picker__card", "Carotte").should("not.exist");
|
||||||
|
|
||||||
|
cy.contains(".ingredient-row", "Carotte")
|
||||||
|
.find("button[title='Retirer cet ingrédient']")
|
||||||
|
.click();
|
||||||
|
cy.contains(".ingredient-picker__card", "Carotte").should("be.visible");
|
||||||
|
cy.get(".ingredient-row").should("have.length", 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preloads an existing recipe's ingredients when editing, and lets you add another", () => {
|
||||||
|
const existingRecipe = {
|
||||||
|
id: 7,
|
||||||
|
name: "Omelette",
|
||||||
|
description: null,
|
||||||
|
picture: null,
|
||||||
|
authorId: 1,
|
||||||
|
visibility: "PERSONAL",
|
||||||
|
allergens: [{ id: 1, key: "eggs", kind: "ALLERGY" }],
|
||||||
|
diets: [],
|
||||||
|
isFavorite: false,
|
||||||
|
ingredients: [{ ingredient: egg, quantity: 3, unit: "unité" }],
|
||||||
|
steps: [{ id: 1, description: "Battre les œufs.", picture: null, order: 0 }],
|
||||||
|
};
|
||||||
|
cy.intercept("GET", "**/recipes/7", { statusCode: 200, body: existingRecipe });
|
||||||
|
cy.intercept("PATCH", "**/recipes/7", { statusCode: 200, body: { id: 7 } }).as(
|
||||||
|
"updateRecipe",
|
||||||
|
);
|
||||||
|
|
||||||
|
cy.visit("/recettes/7/modifier");
|
||||||
|
|
||||||
|
cy.contains(".ingredient-row__name", "Œuf").should("be.visible");
|
||||||
|
cy.get(".ingredient-row .ingredient-row__quantity").should("have.value", "3");
|
||||||
|
|
||||||
|
cy.contains(".ingredient-picker__card", "Tomate").click();
|
||||||
|
cy.get(".ingredient-row").should("have.length", 2);
|
||||||
|
cy.get(".ingredient-row .ingredient-row__quantity").last().type("1");
|
||||||
|
cy.get(".ingredient-row .ingredient-row__unit").last().type("unité");
|
||||||
|
|
||||||
|
cy.contains("button", "Enregistrer").click();
|
||||||
|
|
||||||
|
cy.wait("@updateRecipe")
|
||||||
|
.its("request.body.ingredients")
|
||||||
|
.should("deep.equal", [
|
||||||
|
{ ingredientId: 2, quantity: 3, unit: "unité" },
|
||||||
|
{ ingredientId: 1, quantity: 1, unit: "unité" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { makeClientKey } from "../../lib/client-key";
|
||||||
import "./recipes.scss";
|
import "./recipes.scss";
|
||||||
|
|
||||||
/** One in-progress preparation step in the recipe form. `key` is a client-only stable identity for React/reordering — the server derives the real `order`/`id` from array position on save (see `schemas/recipe.ts`), never from this. */
|
/** One in-progress preparation step in the recipe form. `key` is a client-only stable identity for React/reordering — the server derives the real `order`/`id` from array position on save (see `schemas/recipe.ts`), never from this. */
|
||||||
|
|
@ -24,7 +25,7 @@ export function StepListEditor({
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
function addStep() {
|
function addStep() {
|
||||||
onChange([...steps, { key: crypto.randomUUID(), description: "", picture: "" }]);
|
onChange([...steps, { key: makeClientKey(), description: "", picture: "" }]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateStep(key: string, patch: Partial<Pick<StepDraft, "description" | "picture">>) {
|
function updateStep(key: string, patch: Partial<Pick<StepDraft, "description" | "picture">>) {
|
||||||
|
|
|
||||||
24
apps/web/src/lib/client-key.ts
Normal file
24
apps/web/src/lib/client-key.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
/**
|
||||||
|
* Generates a client-only, ephemeral identity for a draft list row (a
|
||||||
|
* recipe's ingredient lines/steps while editing — see `RecipeFormPage`,
|
||||||
|
* `StepListEditor`) — used purely as a React `key`/local identity to keep
|
||||||
|
* each row's own input state stable across reorders, never sent to the
|
||||||
|
* server or persisted anywhere.
|
||||||
|
*
|
||||||
|
* `crypto.randomUUID()` looks like the obvious choice, but the Web Crypto
|
||||||
|
* API only defines it in a "secure context" — https, or the literal
|
||||||
|
* hostname `localhost` — and throws `TypeError: crypto.randomUUID is not a
|
||||||
|
* function` everywhere else: a LAN IP while testing on a real device, a
|
||||||
|
* Capacitor WebView's `capacitor://` origin (this app's eventual mobile
|
||||||
|
* wrapper — see `AllergySelect`'s doc comment for the same "mobile via
|
||||||
|
* Capacitor" context), plain `http://` on a real domain before TLS is set
|
||||||
|
* up. None of that matters for a value that only ever needs to be *unique
|
||||||
|
* within this one render session*, not cryptographically random, so this
|
||||||
|
* never touches `crypto` at all.
|
||||||
|
*/
|
||||||
|
let counter = 0;
|
||||||
|
|
||||||
|
export function makeClientKey(): string {
|
||||||
|
counter += 1;
|
||||||
|
return `${counter}-${Math.random().toString(36).slice(2)}`;
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,7 @@ import { IngredientPicker } from "../features/recipes/IngredientPicker";
|
||||||
import { IngredientRow } from "../features/recipes/IngredientRow";
|
import { IngredientRow } from "../features/recipes/IngredientRow";
|
||||||
import { type StepDraft, StepListEditor } from "../features/recipes/StepListEditor";
|
import { type StepDraft, StepListEditor } from "../features/recipes/StepListEditor";
|
||||||
import "../features/recipes/recipes.scss";
|
import "../features/recipes/recipes.scss";
|
||||||
|
import { makeClientKey } from "../lib/client-key";
|
||||||
import { errorMessageService } from "../services/error-message.service";
|
import { errorMessageService } from "../services/error-message.service";
|
||||||
|
|
||||||
/** In display order — mirrors `RecipeVisibility` (schema.prisma/shared types). */
|
/** In display order — mirrors `RecipeVisibility` (schema.prisma/shared types). */
|
||||||
|
|
@ -82,7 +83,7 @@ export function RecipeFormPage() {
|
||||||
setDietIds(recipe.diets.map((diet) => diet.id));
|
setDietIds(recipe.diets.map((diet) => diet.id));
|
||||||
setIngredientLines(
|
setIngredientLines(
|
||||||
recipe.ingredients.map((line) => ({
|
recipe.ingredients.map((line) => ({
|
||||||
key: crypto.randomUUID(),
|
key: makeClientKey(),
|
||||||
ingredient: line.ingredient,
|
ingredient: line.ingredient,
|
||||||
quantity: String(line.quantity),
|
quantity: String(line.quantity),
|
||||||
unit: line.unit,
|
unit: line.unit,
|
||||||
|
|
@ -90,7 +91,7 @@ export function RecipeFormPage() {
|
||||||
);
|
);
|
||||||
setSteps(
|
setSteps(
|
||||||
recipe.steps.map((step) => ({
|
recipe.steps.map((step) => ({
|
||||||
key: crypto.randomUUID(),
|
key: makeClientKey(),
|
||||||
description: step.description,
|
description: step.description,
|
||||||
picture: step.picture ?? "",
|
picture: step.picture ?? "",
|
||||||
})),
|
})),
|
||||||
|
|
@ -110,7 +111,7 @@ export function RecipeFormPage() {
|
||||||
function addIngredient(ingredient: IngredientView) {
|
function addIngredient(ingredient: IngredientView) {
|
||||||
setIngredientLines((lines) => [
|
setIngredientLines((lines) => [
|
||||||
...lines,
|
...lines,
|
||||||
{ key: crypto.randomUUID(), ingredient, quantity: "", unit: "" },
|
{ key: makeClientKey(), ingredient, quantity: "", unit: "" },
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue