feat(borne): cablage de la borne sur l'API (CORS + data.js)
All checks were successful
CI / secret-scan (push) Successful in 8s
CI / php-lint (push) Successful in 20s
CI / php-lint (pull_request) Successful in 23s
CI / static-tests (pull_request) Successful in 50s
CI / static-tests (push) Successful in 46s
CI / js-tests (push) Successful in 24s
CI / secret-scan (pull_request) Successful in 9s
CI / js-tests (pull_request) Successful in 25s
All checks were successful
CI / secret-scan (push) Successful in 8s
CI / php-lint (push) Successful in 20s
CI / php-lint (pull_request) Successful in 23s
CI / static-tests (pull_request) Successful in 50s
CI / static-tests (push) Successful in 46s
CI / js-tests (push) Successful in 24s
CI / secret-scan (pull_request) Successful in 9s
CI / js-tests (pull_request) Successful in 25s
La borne kiosk consomme desormais l'API REST catalogue au lieu des fichiers JSON
statiques (docs/api/conventions.md 5.2 et 10).
- Middleware CORS (App\Core\Cors) : origine UNIQUE et EXACTE (CORS_ALLOWED_ORIGIN,
sans joker), scope /api/, GET/POST/OPTIONS, Content-Type, SANS credentials,
fail-closed. Cable dans index.php : preflight OPTIONS -> 204 avant le routeur,
decoration de la reponse y compris le 500 du catch (erreur lisible cross-origin).
CorsTest (10 tests).
- data.js : URLs -> /api/categories|products|menus, deballe l'enveloppe {data},
traduit la forme canonique (name/price_cents/image_path) vers la forme borne
(nom/prix/image/type), regroupe par slug de categorie, glisse les menus sous la
cle 'menus'. Signatures publiques inchangees (pages intactes). findProduct
desambigue par categorie : product et menu ont des espaces d'id distincts (tables
auto-increment separees), un id peut donc collisionner. data.test.js (10 tests).
- loadAllergens reste statique (data/allergens.json) ; consommation des slots
/api/menus + refactor du composeur differes.
This commit is contained in:
parent
a35db88d2f
commit
b896cf9677
6 changed files with 515 additions and 36 deletions
99
src/app/Core/Cors.php
Normal file
99
src/app/Core/Cors.php
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Core;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware CORS de l'API publique kiosk (docs/api/conventions.md section 10).
|
||||||
|
* La borne (kiosk.localhost) appelle l'API (admin.localhost) en CROSS-ORIGIN ;
|
||||||
|
* sans en-tete Access-Control-Allow-Origin, le navigateur bloque la lecture de la
|
||||||
|
* reponse.
|
||||||
|
*
|
||||||
|
* Politique stricte :
|
||||||
|
* - origine UNIQUE et EXACTE (CORS_ALLOWED_ORIGIN, jamais de joker), egale a
|
||||||
|
* l'origine du kiosk ;
|
||||||
|
* - scope aux chemins /api/ uniquement (les pages back-office sont same-origin) ;
|
||||||
|
* - methodes GET/POST/OPTIONS (lecture catalogue + creation/paiement de commande) ;
|
||||||
|
* - en-tete de requete Content-Type (corps JSON) ;
|
||||||
|
* - PAS de credentials : l'API kiosk est anonyme (aucun cookie cross-origin), donc
|
||||||
|
* Access-Control-Allow-Credentials est volontairement absent (et l'origine reste
|
||||||
|
* une valeur exacte, ce qui serait incompatible avec un joker de toute facon).
|
||||||
|
*
|
||||||
|
* Fail-closed : si aucune origine n'est configuree, ou si l'Origin de la requete ne
|
||||||
|
* correspond pas EXACTEMENT, aucun en-tete CORS n'est pose -> le navigateur bloque.
|
||||||
|
*
|
||||||
|
* Decouple de Config (recoit l'origine en chaine) -> testable sans environnement ;
|
||||||
|
* le front controller lit CORS_ALLOWED_ORIGIN et l'injecte.
|
||||||
|
*/
|
||||||
|
final class Cors
|
||||||
|
{
|
||||||
|
private const ALLOW_METHODS = 'GET, POST, OPTIONS';
|
||||||
|
private const ALLOW_HEADERS = 'Content-Type';
|
||||||
|
private const MAX_AGE = '600';
|
||||||
|
|
||||||
|
public function __construct(private readonly string $allowedOrigin)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repond a une requete preliminaire (preflight) : OPTIONS sur /api/ depuis
|
||||||
|
* l'origine autorisee -> 204 avec les en-tetes CORS, court-circuitant le routeur
|
||||||
|
* (qui n'a pas de route OPTIONS). Renvoie null si ce n'est pas un preflight a
|
||||||
|
* traiter ici : le flux normal de dispatch continue.
|
||||||
|
*/
|
||||||
|
public function preflightResponse(Request $request): ?Response
|
||||||
|
{
|
||||||
|
if ($request->method() !== 'OPTIONS' || !$this->isAllowed($request)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = (new Response())->setStatus(204);
|
||||||
|
$this->putHeaders($response, true);
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pose les en-tetes CORS sur une reponse effective (GET/POST), y compris une
|
||||||
|
* reponse d'erreur (le navigateur a besoin de l'en-tete pour lire le corps d'une
|
||||||
|
* 4xx), si la requete vient de l'origine autorisee vers /api/. No-op sinon.
|
||||||
|
*/
|
||||||
|
public function applyTo(Request $request, Response $response): void
|
||||||
|
{
|
||||||
|
if (!$this->isAllowed($request)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->putHeaders($response, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Origine exacte configuree ET requete /api/ ET Origin de la requete identique.
|
||||||
|
* Comparaison stricte par egalite (pas de prefixe, pas de joker).
|
||||||
|
*/
|
||||||
|
private function isAllowed(Request $request): bool
|
||||||
|
{
|
||||||
|
if ($this->allowedOrigin === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!str_starts_with($request->path(), '/api/')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $request->header('origin') === $this->allowedOrigin;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function putHeaders(Response $response, bool $preflight): void
|
||||||
|
{
|
||||||
|
$response->setHeader('Access-Control-Allow-Origin', $this->allowedOrigin);
|
||||||
|
$response->setHeader('Vary', 'Origin');
|
||||||
|
|
||||||
|
if ($preflight) {
|
||||||
|
$response->setHeader('Access-Control-Allow-Methods', self::ALLOW_METHODS);
|
||||||
|
$response->setHeader('Access-Control-Allow-Headers', self::ALLOW_HEADERS);
|
||||||
|
$response->setHeader('Access-Control-Max-Age', self::MAX_AGE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -29,6 +29,7 @@ use App\Controllers\RoleController;
|
||||||
use App\Controllers\UserController;
|
use App\Controllers\UserController;
|
||||||
use App\Core\Autoloader;
|
use App\Core\Autoloader;
|
||||||
use App\Core\Config;
|
use App\Core\Config;
|
||||||
|
use App\Core\Cors;
|
||||||
use App\Core\Database;
|
use App\Core\Database;
|
||||||
use App\Core\Request;
|
use App\Core\Request;
|
||||||
use App\Core\Response;
|
use App\Core\Response;
|
||||||
|
|
@ -46,6 +47,13 @@ header('X-Robots-Tag: noindex, nofollow');
|
||||||
$config = new Config();
|
$config = new Config();
|
||||||
date_default_timezone_set($config->timezone());
|
date_default_timezone_set($config->timezone());
|
||||||
|
|
||||||
|
// Requete + middleware CORS construits AVANT le try : ils ne dependent que de la
|
||||||
|
// config et des globales, et doivent rester accessibles dans le catch pour decorer
|
||||||
|
// la reponse 500 d'une requete /api/ cross-origin (sans quoi le navigateur de la
|
||||||
|
// borne ne peut pas lire le corps de l'erreur).
|
||||||
|
$request = Request::fromGlobals();
|
||||||
|
$cors = new Cors($config->get('CORS_ALLOWED_ORIGIN', '') ?? '');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Acces BDD paresseux : la connexion n'est ouverte qu'au premier query(),
|
// Acces BDD paresseux : la connexion n'est ouverte qu'au premier query(),
|
||||||
// donc la home back-office reste servie meme base indisponible.
|
// donc la home back-office reste servie meme base indisponible.
|
||||||
|
|
@ -178,8 +186,18 @@ try {
|
||||||
$router->add('POST', '/admin/ingredients/{id}/inventory', [IngredientController::class, 'inventory']);
|
$router->add('POST', '/admin/ingredients/{id}/inventory', [IngredientController::class, 'inventory']);
|
||||||
$router->add('GET', '/admin/ingredients/{id}/movements', [IngredientController::class, 'movements']);
|
$router->add('GET', '/admin/ingredients/{id}/movements', [IngredientController::class, 'movements']);
|
||||||
|
|
||||||
$response = $router->dispatch(Request::fromGlobals());
|
// CORS (docs/api/conventions.md section 10) : preflight OPTIONS traite AVANT le
|
||||||
|
// routeur (pas de route OPTIONS) ; sinon dispatch puis decoration de la reponse.
|
||||||
|
// Scope /api/ + origine exacte geres par le middleware (fail-closed). $request et
|
||||||
|
// $cors sont construits hors du try pour que le catch puisse decorer aussi le 500.
|
||||||
|
$preflight = $cors->preflightResponse($request);
|
||||||
|
if ($preflight !== null) {
|
||||||
|
$preflight->send();
|
||||||
|
} else {
|
||||||
|
$response = $router->dispatch($request);
|
||||||
|
$cors->applyTo($request, $response);
|
||||||
$response->send();
|
$response->send();
|
||||||
|
}
|
||||||
} catch (Throwable $exception) {
|
} catch (Throwable $exception) {
|
||||||
// En debug on remonte le message pour iterer ; en prod, reponse generique
|
// En debug on remonte le message pour iterer ; en prod, reponse generique
|
||||||
// pour ne rien divulguer de la pile interne (information disclosure).
|
// pour ne rien divulguer de la pile interne (information disclosure).
|
||||||
|
|
@ -187,5 +205,9 @@ try {
|
||||||
? ['data' => null, 'error' => ['code' => 'INTERNAL_ERROR', 'message' => $exception->getMessage()]]
|
? ['data' => null, 'error' => ['code' => 'INTERNAL_ERROR', 'message' => $exception->getMessage()]]
|
||||||
: ['data' => null, 'error' => ['code' => 'INTERNAL_ERROR', 'message' => 'Internal server error']];
|
: ['data' => null, 'error' => ['code' => 'INTERNAL_ERROR', 'message' => 'Internal server error']];
|
||||||
|
|
||||||
(new Response())->json($payload, 500)->send();
|
// Decore aussi la 500 : une requete /api/ cross-origin (ex. BDD indisponible)
|
||||||
|
// doit rester lisible par le navigateur de la borne (RG enveloppe d'erreur).
|
||||||
|
$errorResponse = (new Response())->json($payload, 500);
|
||||||
|
$cors->applyTo($request, $errorResponse);
|
||||||
|
$errorResponse->send();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,24 @@
|
||||||
/*
|
/*
|
||||||
* data.js — Data loading layer for the Wakdo kiosk.
|
* data.js — Data loading layer for the Wakdo kiosk.
|
||||||
*
|
*
|
||||||
* P5 reads static JSON copies in /data/ (same origin).
|
* Source = REST API (P4). La borne (kiosk) consomme l'API catalogue en lecture
|
||||||
* In P4, swap the BASE_URL constants to point to REST API endpoints.
|
* (docs/api/conventions.md section 5.2) : /api/categories, /api/products, /api/menus.
|
||||||
* The function signatures and return shapes remain unchanged so that
|
* Les reponses sont enveloppees ({ data: [...], total }) et en forme CANONIQUE
|
||||||
* page scripts need no modification when the data source changes.
|
* (snake_case : name, price_cents, image_path...). Cette couche est le point unique
|
||||||
|
* de rapprochement (section 8.3) : elle deballe l'enveloppe et traduit vers la forme
|
||||||
|
* historique attendue par le reste de la borne (nom, prix, image, type ; objet
|
||||||
|
* indexe par slug de categorie ; menus glisses sous la cle 'menus'). Les signatures
|
||||||
|
* publiques et les formes de retour sont inchangees -> les pages n'ont pas bouge.
|
||||||
*
|
*
|
||||||
* Category-to-slug mapping (mirrors data/categories.json id field):
|
* Les allergenes restent un repli statique (data/allergens.json) : leur bascule
|
||||||
* 1=menus 2=boissons 3=burgers 4=frites 5=encas
|
* sur /api/allergens est un chunk ulterieur.
|
||||||
* 6=wraps 7=salades 8=desserts 9=sauces
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/* --- P4 swap point -------------------------------------------------------
|
const CATEGORIES_URL = '/api/categories';
|
||||||
* TODO(P4): replace these two paths with API endpoints, e.g.:
|
const PRODUCTS_URL = '/api/products';
|
||||||
* const CATEGORIES_URL = '/api/categories';
|
const MENUS_URL = '/api/menus';
|
||||||
* const PRODUCTS_URL = '/api/products';
|
/* Liste fixe des 14 allergenes INCO (info generale, modale borne). Repli statique
|
||||||
* The rest of this file is API-agnostic.
|
* encore en place : bascule sur '/api/allergens' differee. */
|
||||||
* ----------------------------------------------------------------------- */
|
|
||||||
const CATEGORIES_URL = 'data/categories.json';
|
|
||||||
const PRODUCTS_URL = 'data/produits.json';
|
|
||||||
/* Liste fixe des 14 allergenes INCO (info generale, modale borne). TODO(P4):
|
|
||||||
* remplacer par '/api/allergens'. Le reste du fichier est API-agnostique. */
|
|
||||||
const ALLERGENS_URL = 'data/allergens.json';
|
const ALLERGENS_URL = 'data/allergens.json';
|
||||||
|
|
||||||
/** @type {Array|null} — in-memory cache to avoid repeated fetches */
|
/** @type {Array|null} — in-memory cache to avoid repeated fetches */
|
||||||
|
|
@ -33,31 +31,87 @@ let _productsCache = null;
|
||||||
let _allergensCache = null;
|
let _allergensCache = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches and caches the categories list.
|
* Recupere une collection enveloppee de l'API et renvoie le tableau `data`.
|
||||||
|
* @param {string} url
|
||||||
|
* @returns {Promise<Array>}
|
||||||
|
*/
|
||||||
|
async function fetchCollection(url) {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) throw new Error(`Failed to load ${url}: HTTP ${res.status}`);
|
||||||
|
const body = await res.json();
|
||||||
|
return Array.isArray(body?.data) ? body.data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches and caches the categories list (forme borne : id, title, slug, image).
|
||||||
* @returns {Promise<Array>}
|
* @returns {Promise<Array>}
|
||||||
*/
|
*/
|
||||||
export async function loadCategories() {
|
export async function loadCategories() {
|
||||||
if (_categoriesCache) return _categoriesCache;
|
if (_categoriesCache) return _categoriesCache;
|
||||||
const res = await fetch(CATEGORIES_URL);
|
const rows = await fetchCollection(CATEGORIES_URL);
|
||||||
if (!res.ok) throw new Error(`Failed to load categories: HTTP ${res.status}`);
|
_categoriesCache = rows.map(c => ({
|
||||||
_categoriesCache = await res.json();
|
id: c.id,
|
||||||
|
title: c.name,
|
||||||
|
slug: c.slug,
|
||||||
|
image: c.image_path,
|
||||||
|
}));
|
||||||
return _categoriesCache;
|
return _categoriesCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches and caches the full products object keyed by category slug.
|
* Fetches and caches the products object keyed by category slug. Les produits et
|
||||||
|
* les menus sont regroupes par slug de leur categorie (les menus tombent sous
|
||||||
|
* 'menus' via leur category_id) et ramenes a la forme borne. Le menu garde son
|
||||||
|
* prix NORMAL (le supplement Maxi est gere par le composeur cote borne).
|
||||||
* @returns {Promise<Object>}
|
* @returns {Promise<Object>}
|
||||||
*/
|
*/
|
||||||
export async function loadProducts() {
|
export async function loadProducts() {
|
||||||
if (_productsCache) return _productsCache;
|
if (_productsCache) return _productsCache;
|
||||||
const res = await fetch(PRODUCTS_URL);
|
|
||||||
if (!res.ok) throw new Error(`Failed to load products: HTTP ${res.status}`);
|
const [categories, products, menus] = await Promise.all([
|
||||||
_productsCache = await res.json();
|
loadCategories(),
|
||||||
|
fetchCollection(PRODUCTS_URL),
|
||||||
|
fetchCollection(MENUS_URL),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const slugByCategoryId = {};
|
||||||
|
const bySlug = {};
|
||||||
|
for (const cat of categories) {
|
||||||
|
slugByCategoryId[cat.id] = cat.slug;
|
||||||
|
bySlug[cat.slug] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const p of products) {
|
||||||
|
const slug = slugByCategoryId[p.category_id];
|
||||||
|
if (slug === undefined) continue;
|
||||||
|
bySlug[slug].push({
|
||||||
|
id: p.id,
|
||||||
|
nom: p.name,
|
||||||
|
prix: p.price_cents,
|
||||||
|
image: p.image_path,
|
||||||
|
type: 'produit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const m of menus) {
|
||||||
|
const slug = slugByCategoryId[m.category_id];
|
||||||
|
if (slug === undefined) continue;
|
||||||
|
bySlug[slug].push({
|
||||||
|
id: m.id,
|
||||||
|
nom: m.name,
|
||||||
|
prix: m.price_normal_cents,
|
||||||
|
image: m.image_path,
|
||||||
|
type: 'menu',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_productsCache = bySlug;
|
||||||
return _productsCache;
|
return _productsCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches and caches the 14 INCO allergens (general info modal).
|
* Fetches and caches the 14 INCO allergens (general info modal). Repli statique :
|
||||||
|
* la reponse est un tableau nu (pas d'enveloppe), conserve tel quel.
|
||||||
* @returns {Promise<Array>}
|
* @returns {Promise<Array>}
|
||||||
*/
|
*/
|
||||||
export async function loadAllergens() {
|
export async function loadAllergens() {
|
||||||
|
|
@ -90,13 +144,24 @@ export async function getCategoryById(id) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Finds a product by its numeric id, searching all category slates.
|
* Finds a product/menu by id. product et menu sont deux espaces d'id DISTINCTS
|
||||||
* Returns null if not found.
|
* (tables auto-increment separees) : un meme id peut designer a la fois un produit
|
||||||
|
* et un menu. categorySlug (le slug de la categorie d'ou vient l'appel) leve
|
||||||
|
* l'ambiguite -- dans une categorie donnee, l'id est unique. Sans categorySlug, on
|
||||||
|
* retombe sur un scan global (best-effort, potentiellement ambigu en cas de
|
||||||
|
* collision d'id). Renvoie null si introuvable.
|
||||||
* @param {number} id
|
* @param {number} id
|
||||||
|
* @param {string|null} [categorySlug]
|
||||||
* @returns {Promise<Object|null>}
|
* @returns {Promise<Object|null>}
|
||||||
*/
|
*/
|
||||||
export async function findProduct(id) {
|
export async function findProduct(id, categorySlug = null) {
|
||||||
const data = await loadProducts();
|
const data = await loadProducts();
|
||||||
|
|
||||||
|
if (categorySlug !== null && Array.isArray(data[categorySlug])) {
|
||||||
|
const found = data[categorySlug].find(p => p.id === id);
|
||||||
|
return found ? { ...found, categorie: categorySlug } : null;
|
||||||
|
}
|
||||||
|
|
||||||
for (const slug of Object.keys(data)) {
|
for (const slug of Object.keys(data)) {
|
||||||
const found = data[slug].find(p => p.id === id);
|
const found = data[slug].find(p => p.id === id);
|
||||||
if (found) return { ...found, categorie: slug };
|
if (found) return { ...found, categorie: slug };
|
||||||
|
|
@ -106,8 +171,8 @@ export async function findProduct(id) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maps a category id integer to its slug string.
|
* Maps a category id integer to its slug string.
|
||||||
* Derived from data/categories.json — kept here as a convenience
|
* Derived from the seed catalogue — kept here as a convenience so page scripts can
|
||||||
* so page scripts can convert query-string ids without an extra fetch.
|
* convert query-string ids without an extra fetch.
|
||||||
*/
|
*/
|
||||||
export const CATEGORY_ID_TO_SLUG = {
|
export const CATEGORY_ID_TO_SLUG = {
|
||||||
1: 'menus',
|
1: 'menus',
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ async function renderProduct() {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const product = await findProduct(productId);
|
const product = await findProduct(productId, categorySlug);
|
||||||
if (!product) {
|
if (!product) {
|
||||||
showError('Ce produit n\'existe pas.');
|
showError('Ce produit n\'existe pas.');
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
126
tests/Unit/Core/CorsTest.php
Normal file
126
tests/Unit/Core/CorsTest.php
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Core;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use App\Core\Cors;
|
||||||
|
use App\Core\Request;
|
||||||
|
use App\Core\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware CORS de l'API kiosk (docs/api/conventions.md section 10). Politique :
|
||||||
|
* origine UNIQUE et EXACTE, scope /api/, methodes GET/POST/OPTIONS, sans credentials.
|
||||||
|
* Fail-closed : pas d'origine configuree ou origine non concordante -> aucun en-tete.
|
||||||
|
*/
|
||||||
|
final class CorsTest extends TestCase
|
||||||
|
{
|
||||||
|
private const ORIGIN = 'http://kiosk.localhost:8080';
|
||||||
|
|
||||||
|
private function request(string $method, string $path, ?string $origin): Request
|
||||||
|
{
|
||||||
|
$headers = $origin === null ? [] : ['origin' => $origin];
|
||||||
|
|
||||||
|
return new Request($method, $path, [], $headers, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function cors(string $allowed = self::ORIGIN): Cors
|
||||||
|
{
|
||||||
|
return new Cors($allowed);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPreflightFromAllowedOriginReturns204WithCorsHeaders(): void
|
||||||
|
{
|
||||||
|
$response = $this->cors()->preflightResponse($this->request('OPTIONS', '/api/categories', self::ORIGIN));
|
||||||
|
|
||||||
|
self::assertNotNull($response);
|
||||||
|
self::assertSame(204, $response->status());
|
||||||
|
self::assertSame(self::ORIGIN, $response->header('Access-Control-Allow-Origin'));
|
||||||
|
self::assertSame('Origin', $response->header('Vary'));
|
||||||
|
$methods = (string) $response->header('Access-Control-Allow-Methods');
|
||||||
|
self::assertStringContainsString('GET', $methods);
|
||||||
|
self::assertStringContainsString('POST', $methods);
|
||||||
|
self::assertStringContainsString('OPTIONS', $methods);
|
||||||
|
self::assertSame('Content-Type', $response->header('Access-Control-Allow-Headers'));
|
||||||
|
self::assertSame('', $response->body());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPreflightFromUnknownOriginIsNotHandled(): void
|
||||||
|
{
|
||||||
|
// Pas de court-circuit : le routeur gerera (405), sans en-tete CORS -> bloque navigateur.
|
||||||
|
$response = $this->cors()->preflightResponse($this->request('OPTIONS', '/api/categories', 'http://evil.example'));
|
||||||
|
|
||||||
|
self::assertNull($response);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPreflightWithoutOriginIsNotHandled(): void
|
||||||
|
{
|
||||||
|
$response = $this->cors()->preflightResponse($this->request('OPTIONS', '/api/categories', null));
|
||||||
|
|
||||||
|
self::assertNull($response);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPreflightOutsideApiIsNotHandled(): void
|
||||||
|
{
|
||||||
|
$response = $this->cors()->preflightResponse($this->request('OPTIONS', '/login', self::ORIGIN));
|
||||||
|
|
||||||
|
self::assertNull($response);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testApplyToAddsOriginHeaderForAllowedApiRequest(): void
|
||||||
|
{
|
||||||
|
$response = (new Response())->json(['data' => []], 200);
|
||||||
|
$this->cors()->applyTo($this->request('GET', '/api/products', self::ORIGIN), $response);
|
||||||
|
|
||||||
|
self::assertSame(self::ORIGIN, $response->header('Access-Control-Allow-Origin'));
|
||||||
|
self::assertSame('Origin', $response->header('Vary'));
|
||||||
|
// Une reponse effective ne porte pas les en-tetes de preflight.
|
||||||
|
self::assertNull($response->header('Access-Control-Allow-Methods'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testApplyToDecoratesErrorResponsesToo(): void
|
||||||
|
{
|
||||||
|
// Le navigateur a besoin de l'en-tete CORS meme sur une 404 pour lire le corps.
|
||||||
|
$response = (new Response())->json(['data' => null, 'error' => ['code' => 'NOT_FOUND']], 404);
|
||||||
|
$this->cors()->applyTo($this->request('GET', '/api/products/999', self::ORIGIN), $response);
|
||||||
|
|
||||||
|
self::assertSame(self::ORIGIN, $response->header('Access-Control-Allow-Origin'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testApplyToIgnoresUnknownOrigin(): void
|
||||||
|
{
|
||||||
|
$response = (new Response())->json(['data' => []], 200);
|
||||||
|
$this->cors()->applyTo($this->request('GET', '/api/products', 'http://evil.example'), $response);
|
||||||
|
|
||||||
|
self::assertNull($response->header('Access-Control-Allow-Origin'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testApplyToIgnoresNonApiPath(): void
|
||||||
|
{
|
||||||
|
$response = (new Response())->html('<x>', 200);
|
||||||
|
$this->cors()->applyTo($this->request('GET', '/login', self::ORIGIN), $response);
|
||||||
|
|
||||||
|
self::assertNull($response->header('Access-Control-Allow-Origin'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDisabledWhenNoAllowedOriginConfigured(): void
|
||||||
|
{
|
||||||
|
$cors = $this->cors('');
|
||||||
|
|
||||||
|
self::assertNull($cors->preflightResponse($this->request('OPTIONS', '/api/categories', self::ORIGIN)));
|
||||||
|
|
||||||
|
$response = (new Response())->json(['data' => []], 200);
|
||||||
|
$cors->applyTo($this->request('GET', '/api/products', self::ORIGIN), $response);
|
||||||
|
self::assertNull($response->header('Access-Control-Allow-Origin'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testOriginMatchIsExactNotSubstring(): void
|
||||||
|
{
|
||||||
|
// Pas de joker ni de prefixe : une origine voisine ne doit jamais matcher.
|
||||||
|
$response = (new Response())->json(['data' => []], 200);
|
||||||
|
$this->cors()->applyTo($this->request('GET', '/api/products', self::ORIGIN . '.evil.com'), $response);
|
||||||
|
|
||||||
|
self::assertNull($response->header('Access-Control-Allow-Origin'));
|
||||||
|
}
|
||||||
|
}
|
||||||
167
tests/js/data.test.js
Normal file
167
tests/js/data.test.js
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
/*
|
||||||
|
* Tests de la couche data.js du front borne (node:test, sans DOM).
|
||||||
|
*
|
||||||
|
* Couvre le swap P4 : data.js consomme l'API REST (/api/categories|products|menus),
|
||||||
|
* deballe l'enveloppe {data}, et traduit la forme canonique (snake_case, name,
|
||||||
|
* price_cents, image_path) vers la forme attendue par la borne (nom, prix, image,
|
||||||
|
* type, objet indexe par slug, menus sous la cle 'menus'). fetch est mocke ; chaque
|
||||||
|
* cas reimporte data.js avec une query unique pour repartir d'un cache vide.
|
||||||
|
*/
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
let _seq = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Installe un mock de fetch route par URL et reimporte data.js avec un cache neuf.
|
||||||
|
* @param {Record<string, unknown>} routes reponses JSON par URL
|
||||||
|
* @param {string[]} [calls] collecteur des URLs appelees (optionnel)
|
||||||
|
*/
|
||||||
|
async function freshData(routes, calls) {
|
||||||
|
global.fetch = async (url) => {
|
||||||
|
if (calls) calls.push(url);
|
||||||
|
if (!(url in routes)) throw new Error(`fetch inattendu: ${url}`);
|
||||||
|
return { ok: true, status: 200, json: async () => routes[url] };
|
||||||
|
};
|
||||||
|
|
||||||
|
return import(`../../src/public/borne/assets/js/data.js?case=${_seq++}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fixtures() {
|
||||||
|
return {
|
||||||
|
'/api/categories': {
|
||||||
|
data: [
|
||||||
|
{ id: 1, name: 'Menus', slug: 'menus', image_path: 'assets/images/categories/menus.png', display_order: 1 },
|
||||||
|
{ id: 3, name: 'Burgers', slug: 'burgers', image_path: 'assets/images/categories/burgers.png', display_order: 3 },
|
||||||
|
],
|
||||||
|
total: 2,
|
||||||
|
},
|
||||||
|
'/api/products': {
|
||||||
|
data: [
|
||||||
|
{ id: 10, category_id: 3, name: 'Big Mac', description: 'Pain, steak, cheddar', price_cents: 600, image_path: 'assets/images/produits/burgers/bigmac.png', display_order: 4 },
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
},
|
||||||
|
'/api/menus': {
|
||||||
|
data: [
|
||||||
|
{ id: 1, category_id: 1, burger_product_id: 10, name: 'Menu Big Mac', description: null, price_normal_cents: 800, price_maxi_cents: 950, image_path: 'assets/images/produits/burgers/bigmac.png', display_order: 1 },
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('loadCategories appelle /api/categories, deballe {data} et mappe name->title, image_path->image', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const { loadCategories } = await freshData(fixtures(), calls);
|
||||||
|
|
||||||
|
const cats = await loadCategories();
|
||||||
|
assert.ok(Array.isArray(cats));
|
||||||
|
assert.equal(cats.length, 2);
|
||||||
|
assert.deepEqual(cats[0], { id: 1, title: 'Menus', slug: 'menus', image: 'assets/images/categories/menus.png' });
|
||||||
|
assert.ok(calls.includes('/api/categories'), 'doit fetch /api/categories');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loadProducts groupe les produits par slug a la forme borne (type produit)', async () => {
|
||||||
|
const { loadProducts } = await freshData(fixtures());
|
||||||
|
|
||||||
|
const data = await loadProducts();
|
||||||
|
assert.deepEqual(data.burgers, [
|
||||||
|
{ id: 10, nom: 'Big Mac', prix: 600, image: 'assets/images/produits/burgers/bigmac.png', type: 'produit' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loadProducts glisse les menus sous la cle menus (type menu, prix = price_normal_cents)', async () => {
|
||||||
|
const { loadProducts } = await freshData(fixtures());
|
||||||
|
|
||||||
|
const data = await loadProducts();
|
||||||
|
assert.deepEqual(data.menus, [
|
||||||
|
{ id: 1, nom: 'Menu Big Mac', prix: 800, image: 'assets/images/produits/burgers/bigmac.png', type: 'menu' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loadProducts consomme bien les trois endpoints /api/*', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const { loadProducts } = await freshData(fixtures(), calls);
|
||||||
|
|
||||||
|
await loadProducts();
|
||||||
|
for (const url of ['/api/categories', '/api/products', '/api/menus']) {
|
||||||
|
assert.ok(calls.includes(url), `doit fetch ${url}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getProductsByCategory derive de loadProducts (forme borne), [] si slug inconnu', async () => {
|
||||||
|
const { getProductsByCategory } = await freshData(fixtures());
|
||||||
|
|
||||||
|
const burgers = await getProductsByCategory('burgers');
|
||||||
|
assert.equal(burgers.length, 1);
|
||||||
|
assert.equal(burgers[0].nom, 'Big Mac');
|
||||||
|
assert.deepEqual(await getProductsByCategory('inexistant'), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('findProduct trouve un produit et l enrichit de sa categorie (slug)', async () => {
|
||||||
|
const { findProduct } = await freshData(fixtures());
|
||||||
|
|
||||||
|
const product = await findProduct(10);
|
||||||
|
assert.equal(product.nom, 'Big Mac');
|
||||||
|
assert.equal(product.prix, 600);
|
||||||
|
assert.equal(product.type, 'produit');
|
||||||
|
assert.equal(product.categorie, 'burgers');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('findProduct trouve un menu (type menu, categorie menus, prix normal)', async () => {
|
||||||
|
const { findProduct } = await freshData(fixtures());
|
||||||
|
|
||||||
|
const menu = await findProduct(1);
|
||||||
|
assert.equal(menu.nom, 'Menu Big Mac');
|
||||||
|
assert.equal(menu.type, 'menu');
|
||||||
|
assert.equal(menu.categorie, 'menus');
|
||||||
|
assert.equal(menu.prix, 800);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un statut HTTP non-ok fait rejeter le chargement', async () => {
|
||||||
|
global.fetch = async () => ({ ok: false, status: 500, json: async () => ({}) });
|
||||||
|
const { loadCategories } = await import(`../../src/public/borne/assets/js/data.js?case=err${_seq++}`);
|
||||||
|
|
||||||
|
await assert.rejects(() => loadCategories());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('findProduct desambigue par categorie quand un produit et un menu partagent un id', async () => {
|
||||||
|
// product et menu sont deux tables auto-increment distinctes : l'id 4 designe a
|
||||||
|
// la fois le burger Big Mac (product) et le Menu Big Mac (menu). Sans categorie,
|
||||||
|
// un scan global renverrait le menu (scanne avant burgers) -> mauvais produit.
|
||||||
|
const colliding = {
|
||||||
|
'/api/categories': {
|
||||||
|
data: [
|
||||||
|
{ id: 1, name: 'Menus', slug: 'menus', image_path: 'm.png', display_order: 1 },
|
||||||
|
{ id: 3, name: 'Burgers', slug: 'burgers', image_path: 'b.png', display_order: 3 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'/api/products': {
|
||||||
|
data: [
|
||||||
|
{ id: 4, category_id: 3, name: 'Big Mac', description: null, price_cents: 600, image_path: 'bigmac.png', display_order: 4 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'/api/menus': {
|
||||||
|
data: [
|
||||||
|
{ id: 4, category_id: 1, burger_product_id: 4, name: 'Menu Big Mac', description: null, price_normal_cents: 800, price_maxi_cents: 950, image_path: 'bigmac.png', display_order: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const { findProduct } = await freshData(colliding);
|
||||||
|
|
||||||
|
const burger = await findProduct(4, 'burgers');
|
||||||
|
assert.equal(burger.type, 'produit', 'la categorie burgers doit donner le produit, pas le menu');
|
||||||
|
assert.equal(burger.nom, 'Big Mac');
|
||||||
|
assert.equal(burger.categorie, 'burgers');
|
||||||
|
|
||||||
|
const menu = await findProduct(4, 'menus');
|
||||||
|
assert.equal(menu.type, 'menu');
|
||||||
|
assert.equal(menu.nom, 'Menu Big Mac');
|
||||||
|
assert.equal(menu.categorie, 'menus');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('findProduct renvoie null si l id est absent de la categorie ciblee', async () => {
|
||||||
|
const { findProduct } = await freshData(fixtures());
|
||||||
|
assert.equal(await findProduct(999, 'burgers'), null);
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue