release: dev -> main v0.2.0 #93

Merged
Corentin merged 96 commits from dev into main 2026-06-23 10:09:58 +02:00
6 changed files with 515 additions and 36 deletions
Showing only changes of commit 7a0702ff6e - Show all commits

99
src/app/Core/Cors.php Normal file
View 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);
}
}
}

View file

@ -29,6 +29,7 @@ use App\Controllers\RoleController;
use App\Controllers\UserController;
use App\Core\Autoloader;
use App\Core\Config;
use App\Core\Cors;
use App\Core\Database;
use App\Core\Request;
use App\Core\Response;
@ -46,6 +47,13 @@ header('X-Robots-Tag: noindex, nofollow');
$config = new Config();
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 {
// Acces BDD paresseux : la connexion n'est ouverte qu'au premier query(),
// 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('GET', '/admin/ingredients/{id}/movements', [IngredientController::class, 'movements']);
$response = $router->dispatch(Request::fromGlobals());
$response->send();
// 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();
}
} catch (Throwable $exception) {
// En debug on remonte le message pour iterer ; en prod, reponse generique
// 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' => '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();
}

View file

@ -1,27 +1,25 @@
/*
* data.js Data loading layer for the Wakdo kiosk.
*
* P5 reads static JSON copies in /data/ (same origin).
* In P4, swap the BASE_URL constants to point to REST API endpoints.
* The function signatures and return shapes remain unchanged so that
* page scripts need no modification when the data source changes.
* Source = REST API (P4). La borne (kiosk) consomme l'API catalogue en lecture
* (docs/api/conventions.md section 5.2) : /api/categories, /api/products, /api/menus.
* Les reponses sont enveloppees ({ data: [...], total }) et en forme CANONIQUE
* (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):
* 1=menus 2=boissons 3=burgers 4=frites 5=encas
* 6=wraps 7=salades 8=desserts 9=sauces
* Les allergenes restent un repli statique (data/allergens.json) : leur bascule
* sur /api/allergens est un chunk ulterieur.
*/
/* --- P4 swap point -------------------------------------------------------
* TODO(P4): replace these two paths with API endpoints, e.g.:
* const CATEGORIES_URL = '/api/categories';
* const PRODUCTS_URL = '/api/products';
* The rest of this file is API-agnostic.
* ----------------------------------------------------------------------- */
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 CATEGORIES_URL = '/api/categories';
const PRODUCTS_URL = '/api/products';
const MENUS_URL = '/api/menus';
/* Liste fixe des 14 allergenes INCO (info generale, modale borne). Repli statique
* encore en place : bascule sur '/api/allergens' differee. */
const ALLERGENS_URL = 'data/allergens.json';
/** @type {Array|null} — in-memory cache to avoid repeated fetches */
let _categoriesCache = null;
@ -33,31 +31,87 @@ let _productsCache = 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>}
*/
export async function loadCategories() {
if (_categoriesCache) return _categoriesCache;
const res = await fetch(CATEGORIES_URL);
if (!res.ok) throw new Error(`Failed to load categories: HTTP ${res.status}`);
_categoriesCache = await res.json();
const rows = await fetchCollection(CATEGORIES_URL);
_categoriesCache = rows.map(c => ({
id: c.id,
title: c.name,
slug: c.slug,
image: c.image_path,
}));
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>}
*/
export async function loadProducts() {
if (_productsCache) return _productsCache;
const res = await fetch(PRODUCTS_URL);
if (!res.ok) throw new Error(`Failed to load products: HTTP ${res.status}`);
_productsCache = await res.json();
const [categories, products, menus] = await Promise.all([
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;
}
/**
* 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>}
*/
export async function loadAllergens() {
@ -90,13 +144,24 @@ export async function getCategoryById(id) {
}
/**
* Finds a product by its numeric id, searching all category slates.
* Returns null if not found.
* Finds a product/menu by id. product et menu sont deux espaces d'id DISTINCTS
* (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 {string|null} [categorySlug]
* @returns {Promise<Object|null>}
*/
export async function findProduct(id) {
export async function findProduct(id, categorySlug = null) {
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)) {
const found = data[slug].find(p => p.id === id);
if (found) return { ...found, categorie: slug };
@ -106,8 +171,8 @@ export async function findProduct(id) {
/**
* Maps a category id integer to its slug string.
* Derived from data/categories.json kept here as a convenience
* so page scripts can convert query-string ids without an extra fetch.
* Derived from the seed catalogue kept here as a convenience so page scripts can
* convert query-string ids without an extra fetch.
*/
export const CATEGORY_ID_TO_SLUG = {
1: 'menus',

View file

@ -40,7 +40,7 @@ async function renderProduct() {
}
try {
const product = await findProduct(productId);
const product = await findProduct(productId, categorySlug);
if (!product) {
showError('Ce produit n\'existe pas.');
return;

View 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
View 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);
});