Compare commits

..

5 commits

Author SHA1 Message Date
Imugiii
b3521f7a56 feat(borne): menu Maxi agrandit la boisson en 50cl + transport explicite du format
All checks were successful
CI / secret-scan (push) Successful in 18s
CI / js-tests (pull_request) Successful in 38s
CI / php-lint (push) Successful in 42s
CI / static-tests (push) Successful in 1m29s
CI / js-tests (push) Successful in 45s
CI / secret-scan (pull_request) Successful in 17s
CI / php-lint (pull_request) Successful in 40s
CI / static-tests (pull_request) Successful in 1m10s
Seed 0006 lie chaque soda fontaine 30cl a sa variante 50cl via
maxi_variant_product_id : en menu Maxi, resolveSelections substitue la
boisson vers la 50cl (meme mecanique que l'accompagnement Grande Frite),
sans code serveur. Les boissons en bouteille (sans variante) restent en
taille standard, le surcout Maxi etant porte par le menu.

La borne transporte desormais le format Normal/Maxi explicitement
(buildMenuCartItem) au lieu de l'inferer de supplement_cents>0 (faux
negatif si maxi==normal) ; checkout.js lit cartItem.format avec repli
historique pour les paniers serialises. Commentaire migration 0007
corrige (la substitution Maxi de la boisson est desormais voulue).

Tests : OrderRepositoryTest (boisson Maxi -> 50cl + bouteille inchangee),
checkout/composer-slots (format transporte). Seed valide idempotent sur
base jetable (5 sodas lies, frites intactes, bouteilles NULL).
2026-06-24 08:57:53 +00:00
8e2e0382ba fix(devops): passer les variables SMTP/MAIL au conteneur wakdo-app (#97)
All checks were successful
CI / secret-scan (push) Successful in 11s
CI / php-lint (push) Successful in 27s
CI / static-tests (push) Successful in 58s
CI / js-tests (push) Successful in 28s
2026-06-23 16:11:31 +02:00
ef71101453 feat(auth): envoi reel de l'email de reset via relais SMTP (Brevo) (#96)
All checks were successful
CI / secret-scan (push) Successful in 14s
CI / php-lint (push) Successful in 28s
CI / static-tests (push) Successful in 1m3s
CI / js-tests (push) Successful in 1m29s
2026-06-23 15:34:27 +02:00
80b8272291 chore(devops): modeles versionnes docker-compose.prod.yml + .env de prod (#95)
All checks were successful
CI / secret-scan (push) Successful in 12s
CI / php-lint (push) Successful in 25s
CI / static-tests (push) Successful in 58s
CI / js-tests (push) Successful in 36s
2026-06-23 15:01:02 +02:00
8c5d942de8 feat(devops): CD push-based vers Vision (prod) + preuve de version (#94)
All checks were successful
CI / secret-scan (push) Successful in 14s
CI / php-lint (push) Successful in 24s
CI / static-tests (push) Successful in 1m4s
CI / js-tests (push) Successful in 35s
2026-06-23 11:32:57 +02:00
25 changed files with 1318 additions and 20 deletions

View file

@ -131,3 +131,16 @@ CRON_TIMEZONE=Europe/Paris
# Nom du reseau Docker externe partage avec le Traefik de l'hote (doit exister # Nom du reseau Docker externe partage avec le Traefik de l'hote (doit exister
# AVANT le up : cree par la stack Traefik, ou `docker network create <nom>`). # AVANT le up : cree par la stack Traefik, ou `docker network create <nom>`).
REVERSE_PROXY_NETWORK=admin_proxy REVERSE_PROXY_NETWORK=admin_proxy
# ===================================================================
# Envoi d'email (reinitialisation mot de passe) - OPTIONNEL
# ===================================================================
# Absentes en local : l'app journalise le lien de reset (LogMailer), aucun envoi.
# Renseigner SMTP_HOST + SMTP_USER + SMTP_PASSWORD active l'envoi via relais SMTP.
# Mettre les vraies valeurs uniquement dans le .env de l'hote (jamais versionnees).
# SMTP_HOST=smtp-relay.brevo.com
# SMTP_PORT=587
# SMTP_USER=
# SMTP_PASSWORD=
# MAIL_FROM_EMAIL=noreply@example.com
# MAIL_FROM_NAME=Wakdo

75
.env.prod.example Normal file
View file

@ -0,0 +1,75 @@
# Modele de configuration de PRODUCTION (derriere Traefik).
#
# cp .env.prod.example .env
# puis renseigner les lignes <REMPLIR> (domaines, mots de passe, reseau Traefik).
#
# Difference avec .env.example (dev) : APP_ENV=prod, APP_DEBUG=false, URLs en HTTPS,
# mots de passe forts, REVERSE_PROXY_NETWORK renseigne.
APP_ENV=prod
APP_DEBUG=false
APP_TIMEZONE=Europe/Paris
# Domaines publics (doivent resoudre en DNS vers l'hote de prod).
APP_HOST_KIOSK=<REMPLIR-domaine-borne>
APP_HOST_ADMIN=<REMPLIR-domaine-admin>
APP_URL_KIOSK=https://<REMPLIR-domaine-borne>
APP_URL_ADMIN=https://<REMPLIR-domaine-admin>
# Base de donnees : mots de passe FORTS en prod (openssl rand -base64 24).
DB_HOST=wakdo-db
DB_PORT=3306
DB_NAME=wakdo
DB_USER=wakdo
DB_PASSWORD=<REMPLIR-mot-de-passe-fort>
DB_ROOT_PASSWORD=<REMPLIR-autre-mot-de-passe-fort>
SESSION_LIFETIME_IDLE=14400
SESSION_LIFETIME_ABSOLUTE=36000
SESSION_NAME=WAKDO_SID
# Doit correspondre EXACTEMENT a APP_URL_KIOSK (pas de wildcard).
CORS_ALLOWED_ORIGIN=https://<REMPLIR-domaine-borne>
ARGON2_MEMORY_COST=65536
ARGON2_TIME_COST=4
ARGON2_THREADS=1
ACCOUNT_LOCKOUT_THRESHOLD=5
ACCOUNT_LOCKOUT_BASE_SECONDS=60
ACCOUNT_LOCKOUT_MAX_SECONDS=900
IP_THROTTLE_WINDOW_SECONDS=900
IP_THROTTLE_MAX_ATTEMPTS=20
STAFF_PIN_MIN_LENGTH=4
STAFF_PIN_MAX_LENGTH=12
PIN_THROTTLE_THRESHOLD=5
PIN_THROTTLE_BASE_SECONDS=30
PIN_THROTTLE_MAX_SECONDS=300
PIN_THROTTLE_WINDOW_SECONDS=900
PASSWORD_RESET_TTL=3600
AUDIT_LOG_RETENTION_DAYS=365
THROTTLE_PURGE_AFTER_HOURS=24
ORDER_RETENTION_DAYS=1095
UPLOAD_MAX_SIZE_MB=5
UPLOAD_ALLOWED_MIME=image/jpeg,image/png,image/webp
CRON_TIMEZONE=Europe/Paris
# Nom du reseau Docker externe du Traefik de l'hote (doit exister avant le up).
REVERSE_PROXY_NETWORK=<REMPLIR-reseau-traefik>
# ===================================================================
# Envoi d'email (reinitialisation mot de passe) - relais SMTP
# ===================================================================
# Si SMTP_HOST + SMTP_USER + SMTP_PASSWORD sont presents, l'app envoie via le
# relais ; sinon elle se rabat sur le journal (LogMailer). Renseigner ces 3
# valeurs UNIQUEMENT ici (jamais dans le depot). Exemple : relais Brevo.
SMTP_HOST=smtp-relay.brevo.com
SMTP_PORT=587
SMTP_USER=<REMPLIR-login-smtp>
SMTP_PASSWORD=<REMPLIR-cle-smtp-secrete>
MAIL_FROM_EMAIL=noreply@a3n.fr
MAIL_FROM_NAME=Wakdo

View file

@ -0,0 +1,45 @@
name: Deploy
# Deploiement continu (CD) vers Vision (prod) a chaque release sur main.
#
# Topologie : le runner tourne sur Stark (dev) et n'a pas le socket Docker. Il ne
# pilote donc PAS Docker lui-meme : il OUVRE une session SSH vers Vision (prod, hote
# distinct) ou une forced command (cote Vision) lance scripts/deploy.sh. La cle CI ne
# peut ainsi declencher QUE le deploiement, rien d'autre.
#
# main n'est alimentee que par des PR dev->main deja passees par la CI : le code
# deploye a donc deja ete teste. Voir docs/architecture/deployment.md pour la mise en
# place cote Vision (utilisateur deploy, forced command) et les secrets a creer.
on:
push:
branches: [main]
jobs:
deploy:
runs-on: docker
steps:
- name: Install SSH client
run: |
apt-get update -qq
apt-get install -y -qq openssh-client >/dev/null
- name: Deploy to Vision over SSH
env:
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_USER: ${{ vars.DEPLOY_USER }}
run: |
set -eu
install -d -m 700 ~/.ssh
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/id_deploy
chmod 600 ~/.ssh/id_deploy
# Cle d'hote epinglee (pas de TOFU) : la connexion echoue si Vision ne
# presente pas la cle attendue.
printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > ~/.ssh/known_hosts
# Aucune commande passee : la forced command cote Vision lance deploy.sh.
# BatchMode : pas de prompt interactif (un echec d'auth echoue vite au lieu
# de pendre le job) ; ConnectTimeout borne l'attente si Vision est injoignable.
ssh -i ~/.ssh/id_deploy -o IdentitiesOnly=yes \
-o StrictHostKeyChecking=yes \
-o BatchMode=yes -o ConnectTimeout=15 \
"$DEPLOY_USER@$DEPLOY_HOST"

3
.gitignore vendored
View file

@ -56,6 +56,9 @@ Thumbs.db
*.log *.log
/logs/ /logs/
# === Marqueur de version (ecrit par scripts/deploy.sh sur l'hote, propre au deploiement) ===
/src/VERSION
# === Data / Uploads / Backups === # === Data / Uploads / Backups ===
# /var/ : contient /var/backups/ (bind-mount des dumps BDD du conteneur cron) # /var/ : contient /var/backups/ (bind-mount des dumps BDD du conteneur cron)
# et tout futur artefact run-time (caches persistes, logs). # et tout futur artefact run-time (caches persistes, logs).

View file

@ -8,10 +8,15 @@
-- domaine commande facture deja par product_id : le flux de commande -- domaine commande facture deja par product_id : le flux de commande
-- reste inchange, la borne resout juste la taille choisie en product_id. -- reste inchange, la borne resout juste la taille choisie en product_id.
-- --
-- Grouping DEDIE, distinct de maxi_variant_product_id (migration 0006) : -- Grouping DEDIE (base_product_id), distinct de maxi_variant_product_id
-- ce dernier pilote la substitution Maxi de l'accompagnement de menu -- (migration 0006) : base_product_id pilote la selection de taille A LA
-- (resolveSelections) ; le reutiliser ferait basculer en 50 cl une -- CARTE (picker 30/50 cl) ; maxi_variant_product_id pilote la substitution
-- boisson 30 cl glissee dans un menu Maxi (effet de bord non voulu). -- Maxi en MENU (resolveSelections). Les deux coexistent sur une boisson :
-- le seed 0006 pointe desormais chaque soda 30 cl vers sa variante 50 cl
-- pour qu'un menu Maxi serve la grande boisson (decision metier). Cet
-- "effet" est VOULU et ne s'applique qu'aux selections de menu au format
-- maxi ; une boisson 30 cl commandee a la carte (resolveLine type product)
-- ne consulte jamais maxi_variant_product_id et reste en 30 cl.
-- Target : MariaDB 11.4 LTS, InnoDB, utf8mb4 / utf8mb4_unicode_ci. -- Target : MariaDB 11.4 LTS, InnoDB, utf8mb4 / utf8mb4_unicode_ci.
-- ============================================================================= -- =============================================================================

View file

@ -0,0 +1,50 @@
-- =============================================================================
-- Wakdo — Seed 0006 : boisson de menu = variante 50 cl automatique en Maxi
-- =============================================================================
-- Purpose : cabler la regle metier "boisson Maxi" sur les donnees seedees, sans
-- toucher au code. En menu Maxi, la boisson fontaine doit passer en
-- grande (50 cl), comme l'accompagnement passe en Grande Frite.
--
-- Mecanique reutilisee : product.maxi_variant_product_id (schema 0006),
-- deja exploite par OrderRepository::resolveSelections (substitution de
-- toute selection de menu au format 'maxi', sans garde sur le slot_type).
-- Il suffit donc de POINTER chaque soda fontaine 30 cl vers sa variante
-- 50 cl (creee par le seed 0005) : aucune ligne de code serveur a ecrire.
-- Le decrement de stock (consumption) frappera la 50 cl, et le snapshot
-- de libelle reflechira "<soda> 50cl".
--
-- Perimetre : seules les boissons fontaine ont une variante 50 cl (Coca Cola, Coca
-- Sans Sucres, Fanta Orange, Ice Tea Peche, Ice Tea Citron). Les boissons en
-- bouteille (Eau, Jus d'Orange, Jus de Pommes Bio) n'ont pas de variante : elles
-- restent en taille standard meme en Maxi (degradation gracieuse, modele fast-food
-- usuel). Le surcout Maxi est porte par le menu (price_maxi_cents), pas par la
-- boisson : aucune incidence de prix sur ces bouteilles.
--
-- Phase : depend du schema 0006 (maxi_variant_product_id) ET du seed 0005 (les
-- variantes 50 cl doivent exister). Joue donc APRES 0005 (ordre
-- lexicographique du runner db/seed.sh).
--
-- Conventions:
-- - Aucun id en dur : la cible est resolue structurellement (la variante 50 cl
-- est la ligne dont base_product_id pointe la base et size_cl = 50).
-- - IDEMPOTENT : UPDATE ... JOIN convergent (repositionne la meme valeur a chaque
-- execution). MariaDB autorise le self-join en UPDATE multi-tables (l'erreur
-- 1093 ne vise que les sous-requetes sur la table cible, pas les JOIN).
-- =============================================================================
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
-- -----------------------------------------------------------------------------
-- Lier chaque boisson de base (30 cl, base_product_id NULL) a sa variante 50 cl.
-- La jointure ne matche que les produits ayant une variante de taille 50 cl :
-- structurellement, les seules boissons fontaine. Les accompagnements (frites,
-- deja relies par 0004) ne sont pas des variantes de taille -> non touches. Les
-- bouteilles sans variante 50 cl ne matchent pas -> maxi_variant_product_id reste
-- NULL.
-- -----------------------------------------------------------------------------
UPDATE product AS base
JOIN product AS variant
ON variant.base_product_id = base.id
AND variant.size_cl = 50
SET base.maxi_variant_product_id = variant.id
WHERE base.base_product_id IS NULL;

View file

@ -0,0 +1,184 @@
# Modele de compose de production (derriere un reverse proxy Traefik).
#
# Entierement pilote par le .env : le meme fichier marche sur n'importe quel hote,
# seules les valeurs du .env changent. Sur l'hote de prod :
# cp docker-compose.prod.yml.example docker-compose.prod.yml
# cp .env.prod.example .env # puis renseigner domaines + mots de passe
# docker compose -f docker-compose.prod.yml up -d --build
#
# Prerequis : le reseau externe ${REVERSE_PROXY_NETWORK} existe (cree par la stack
# Traefik de l'hote). Les entrypoints (websecure) et le certresolver (letsencrypt)
# doivent correspondre a la config Traefik de l'hote.
name: wakdo
networks:
wakdo_internal:
driver: bridge
reverse_proxy:
name: ${REVERSE_PROXY_NETWORK}
external: true
volumes:
wakdo_db_data:
wakdo_uploads:
services:
wakdo-db:
image: mariadb:11.4
container_name: wakdo-db
restart: unless-stopped
environment:
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
MARIADB_DATABASE: ${DB_NAME}
MARIADB_USER: ${DB_USER}
MARIADB_PASSWORD: ${DB_PASSWORD}
MARIADB_AUTO_UPGRADE: "1"
TZ: ${APP_TIMEZONE:-Europe/Paris}
volumes:
- wakdo_db_data:/var/lib/mysql
- ./db/init:/docker-entrypoint-initdb.d:ro
networks:
- wakdo_internal
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 6
start_period: 30s
wakdo-migrate:
image: mariadb:11.4
container_name: wakdo-migrate
restart: "no"
environment:
DB_HOST: ${DB_HOST}
DB_PORT: ${DB_PORT}
DB_NAME: ${DB_NAME}
DB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
volumes:
- ./db:/db:ro
networks:
- wakdo_internal
depends_on:
wakdo-db:
condition: service_healthy
entrypoint: ["bash", "/db/migrate-container.sh"]
wakdo-app:
build:
context: ./docker/php-fpm
dockerfile: Dockerfile
container_name: wakdo-app
restart: unless-stopped
environment:
APP_ENV: ${APP_ENV}
APP_DEBUG: ${APP_DEBUG}
APP_TIMEZONE: ${APP_TIMEZONE}
APP_URL_KIOSK: ${APP_URL_KIOSK}
APP_URL_ADMIN: ${APP_URL_ADMIN}
DB_HOST: ${DB_HOST}
DB_PORT: ${DB_PORT}
DB_NAME: ${DB_NAME}
DB_USER: ${DB_USER}
DB_PASSWORD: ${DB_PASSWORD}
SESSION_LIFETIME_IDLE: ${SESSION_LIFETIME_IDLE}
SESSION_LIFETIME_ABSOLUTE: ${SESSION_LIFETIME_ABSOLUTE}
SESSION_NAME: ${SESSION_NAME}
CORS_ALLOWED_ORIGIN: ${CORS_ALLOWED_ORIGIN}
ARGON2_MEMORY_COST: ${ARGON2_MEMORY_COST}
ARGON2_TIME_COST: ${ARGON2_TIME_COST}
ARGON2_THREADS: ${ARGON2_THREADS}
ACCOUNT_LOCKOUT_THRESHOLD: ${ACCOUNT_LOCKOUT_THRESHOLD}
ACCOUNT_LOCKOUT_BASE_SECONDS: ${ACCOUNT_LOCKOUT_BASE_SECONDS}
ACCOUNT_LOCKOUT_MAX_SECONDS: ${ACCOUNT_LOCKOUT_MAX_SECONDS}
IP_THROTTLE_WINDOW_SECONDS: ${IP_THROTTLE_WINDOW_SECONDS}
IP_THROTTLE_MAX_ATTEMPTS: ${IP_THROTTLE_MAX_ATTEMPTS}
STAFF_PIN_MIN_LENGTH: ${STAFF_PIN_MIN_LENGTH}
STAFF_PIN_MAX_LENGTH: ${STAFF_PIN_MAX_LENGTH}
PIN_THROTTLE_THRESHOLD: ${PIN_THROTTLE_THRESHOLD}
PIN_THROTTLE_BASE_SECONDS: ${PIN_THROTTLE_BASE_SECONDS}
PIN_THROTTLE_MAX_SECONDS: ${PIN_THROTTLE_MAX_SECONDS}
PIN_THROTTLE_WINDOW_SECONDS: ${PIN_THROTTLE_WINDOW_SECONDS}
PASSWORD_RESET_TTL: ${PASSWORD_RESET_TTL}
UPLOAD_MAX_SIZE_MB: ${UPLOAD_MAX_SIZE_MB}
UPLOAD_ALLOWED_MIME: ${UPLOAD_ALLOWED_MIME}
SMTP_HOST: ${SMTP_HOST:-}
SMTP_PORT: ${SMTP_PORT:-587}
SMTP_USER: ${SMTP_USER:-}
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
MAIL_FROM_EMAIL: ${MAIL_FROM_EMAIL:-}
MAIL_FROM_NAME: ${MAIL_FROM_NAME:-Wakdo}
volumes:
- ./src:/var/www/html
- wakdo_uploads:/var/www/html/public/uploads
networks:
- wakdo_internal
depends_on:
wakdo-migrate:
condition: service_completed_successfully
wakdo-db:
condition: service_healthy
wakdo-web:
build:
context: ./docker/apache
dockerfile: Dockerfile
container_name: wakdo-web
restart: unless-stopped
environment:
APP_HOST_KIOSK: ${APP_HOST_KIOSK}
APP_HOST_ADMIN: ${APP_HOST_ADMIN}
volumes:
- ./src:/var/www/html
- wakdo_uploads:/var/www/html/public/uploads
networks:
- wakdo_internal
- reverse_proxy
depends_on:
wakdo-migrate:
condition: service_completed_successfully
wakdo-app:
condition: service_started
wakdo-db:
condition: service_healthy
labels:
- "traefik.enable=true"
- "traefik.docker.network=${REVERSE_PROXY_NETWORK}"
- "traefik.http.routers.wakdo-kiosk.rule=Host(`${APP_HOST_KIOSK}`)"
- "traefik.http.routers.wakdo-kiosk.entrypoints=websecure"
- "traefik.http.routers.wakdo-kiosk.tls=true"
- "traefik.http.routers.wakdo-kiosk.tls.certresolver=letsencrypt"
- "traefik.http.routers.wakdo-kiosk.service=wakdo-kiosk-svc"
- "traefik.http.services.wakdo-kiosk-svc.loadbalancer.server.port=80"
- "traefik.http.routers.wakdo-admin.rule=Host(`${APP_HOST_ADMIN}`)"
- "traefik.http.routers.wakdo-admin.entrypoints=websecure"
- "traefik.http.routers.wakdo-admin.tls=true"
- "traefik.http.routers.wakdo-admin.tls.certresolver=letsencrypt"
- "traefik.http.routers.wakdo-admin.service=wakdo-admin-svc"
- "traefik.http.services.wakdo-admin-svc.loadbalancer.server.port=80"
wakdo-cron:
build:
context: ./docker/cron
dockerfile: Dockerfile
container_name: wakdo-cron
restart: unless-stopped
init: true
environment:
DB_HOST: ${DB_HOST}
DB_PORT: ${DB_PORT}
DB_NAME: ${DB_NAME}
DB_USER: ${DB_USER}
DB_PASSWORD: ${DB_PASSWORD}
AUDIT_LOG_RETENTION_DAYS: ${AUDIT_LOG_RETENTION_DAYS:-365}
THROTTLE_PURGE_AFTER_HOURS: ${THROTTLE_PURGE_AFTER_HOURS:-24}
TZ: ${CRON_TIMEZONE:-Europe/Paris}
volumes:
- ./var/backups:/backups
networks:
- wakdo_internal
depends_on:
wakdo-db:
condition: service_healthy

View file

@ -89,6 +89,12 @@ services:
PASSWORD_RESET_TTL: ${PASSWORD_RESET_TTL} PASSWORD_RESET_TTL: ${PASSWORD_RESET_TTL}
UPLOAD_MAX_SIZE_MB: ${UPLOAD_MAX_SIZE_MB} UPLOAD_MAX_SIZE_MB: ${UPLOAD_MAX_SIZE_MB}
UPLOAD_ALLOWED_MIME: ${UPLOAD_ALLOWED_MIME} UPLOAD_ALLOWED_MIME: ${UPLOAD_ALLOWED_MIME}
SMTP_HOST: ${SMTP_HOST:-}
SMTP_PORT: ${SMTP_PORT:-587}
SMTP_USER: ${SMTP_USER:-}
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
MAIL_FROM_EMAIL: ${MAIL_FROM_EMAIL:-}
MAIL_FROM_NAME: ${MAIL_FROM_NAME:-Wakdo}
volumes: volumes:
- ./src:/var/www/html - ./src:/var/www/html
- wakdo_uploads:/var/www/html/public/uploads - wakdo_uploads:/var/www/html/public/uploads

View file

@ -0,0 +1,115 @@
# Deploiement continu (CD) — Wakdo
Ce document decrit le deploiement automatique vers la production et la mise en place
a faire une seule fois cote serveur. Il complete `scripts/deploy.sh` et
`.forgejo/workflows/deploy.yml`.
## Topologie
| Hote | Role |
|---|---|
| **Thanos** (`git.acadenice.com`) | Forge : depot Git + Forgejo Actions |
| **Stark** | Environnement de dev ; heberge le runner Forgejo |
| **Vision** | Production : la stack Wakdo y tourne, cible du deploiement |
Le runner (sur Stark) n'a pas acces au socket Docker, par choix de securite : un job
CI ne peut pas piloter Docker sur son hote. Le deploiement vers Vision se fait donc
par SSH — ce qui correspond au schema normal d'un deploiement vers un hote distant.
## Flux
```
merge dev -> main (release, deja passee par la CI sur la PR)
Forgejo Actions: workflow Deploy (.forgejo/workflows/deploy.yml)
│ ssh deploy@vision (sans commande : forced command cote Vision)
Vision: scripts/deploy.sh (git ff-only -> VERSION + deploy.log -> compose build/up)
GET /api/health renvoie le nouveau SHA ← preuve du deploiement
```
## Ce qui est automatise (dans le depot)
- `.forgejo/workflows/deploy.yml` : sur push `main`, ouvre la session SSH vers Vision.
- `scripts/deploy.sh` : recupere `main` (fast-forward), ecrit le marqueur de version
(`src/VERSION`) et une ligne dans `deploy.log`, reconstruit et recree la stack.
Mode non-interactif via `DEPLOY_YES=1`.
- `GET /api/health` expose `version` (SHA) et `deployed_at` (date), lus depuis
`src/VERSION`.
## Mise en place cote Vision (une fois)
Prerequis : Docker + docker compose, le depot clone (ex. `/srv/wakdo`).
Le compose et le `.env` de prod ne sont pas versionnes (propres a l'hote) ; ils se
derivent des modeles fournis dans le depot :
```bash
cp docker-compose.prod.yml.example docker-compose.prod.yml
cp .env.prod.example .env # puis renseigner domaines + mots de passe + reseau Traefik
docker compose -f docker-compose.prod.yml up -d --build
```
Le compose est entierement pilote par le `.env` : le meme fichier marche sur tout hote.
1. Creer un utilisateur dedie au deploiement, membre du groupe `docker` :
```bash
sudo useradd -m -G docker deploy
```
2. Lui donner le depot (ou ajuster les droits du clone existant) :
```bash
sudo chown -R deploy:deploy /srv/wakdo
```
3. Autoriser la cle CI avec une **forced command** : la cle ne peut lancer que le
deploiement, aucune autre commande. Dans `~deploy/.ssh/authorized_keys` :
```
command="cd /srv/wakdo && DEPLOY_YES=1 scripts/deploy.sh main",no-pty,no-port-forwarding,no-X11-forwarding,no-agent-forwarding ssh-ed25519 AAAA...CLE_PUBLIQUE... deploy@wakdo-ci
```
`deploy.sh` ne lit pas `$SSH_ORIGINAL_COMMAND` : meme si un appel SSH tentait de
passer une autre commande, elle serait ignoree.
## Generer la cle et la connaitre cote forge
Sur un poste de confiance :
```bash
ssh-keygen -t ed25519 -f wakdo-deploy -C "deploy@wakdo-ci" -N ""
# wakdo-deploy -> cle PRIVEE (secret de la forge, ci-dessous)
# wakdo-deploy.pub -> cle PUBLIQUE (authorized_keys de Vision, etape 3)
ssh-keyscan -t ed25519 <hote-vision> # -> contenu du secret DEPLOY_KNOWN_HOSTS
```
## Secrets et variables a creer sur la forge
Depot -> Settings -> Actions -> Secrets / Variables :
| Type | Nom | Valeur |
|---|---|---|
| Secret | `DEPLOY_SSH_KEY` | contenu de la cle privee `wakdo-deploy` |
| Secret | `DEPLOY_KNOWN_HOSTS` | sortie de `ssh-keyscan` (cle d'hote de Vision) |
| Secret | `DEPLOY_HOST` | nom/IP de Vision |
| Variable | `DEPLOY_USER` | `deploy` |
## Verification
1. Faire une release (`dev -> main`).
2. Suivre le workflow **Deploy** dans l'interface de la forge (il se declenche au push
sur `main`).
3. Interroger la sonde et lire la version deployee :
```bash
curl -s https://<fqdn-admin-prod>/api/health
# { ... "version": "<sha>", "deployed_at": "<date>" }
```
Le `version` correspond au HEAD de `main` apres la release — preuve que Vision a ete
mise a jour sans intervention manuelle.
## Notes de securite
- Cle SSH dediee au seul deploiement, **forced command** + options `no-*` qui retirent
shell, tunnels et forwarding.
- Cle d'hote **epinglee** (`DEPLOY_KNOWN_HOSTS`, `StrictHostKeyChecking=yes`) : pas de
confiance a la premiere connexion.
- Secrets stockes cote forge, hors du depot. `.env` et `docker-compose.prod.yml`
restent gitignores.
- Le runner n'a pas le socket Docker : un job ne peut pas agir sur Docker localement.

View file

@ -52,25 +52,40 @@ if ! command -v docker >/dev/null 2>&1; then
fi fi
echo "Deploiement Wakdo : branche '$BRANCH' depuis '$REMOTE' via $COMPOSE_FILE" echo "Deploiement Wakdo : branche '$BRANCH' depuis '$REMOTE' via $COMPOSE_FILE"
printf 'Confirmer le deploiement en production ? [oui/NON] ' # Mode non-interactif pour le CD : DEPLOY_YES=1 saute la confirmation (la forced
read -r answer # command SSH le pose). On NE lit PAS $SSH_ORIGINAL_COMMAND : la cle CI ne peut
if [ "$answer" != "oui" ]; then # influencer ni la branche ni le compose, seulement declencher CE script.
echo "deploy: annule." if [ "${DEPLOY_YES:-}" = "1" ] || [ "${DEPLOY_YES:-}" = "oui" ]; then
exit 1 echo "deploy: confirmation automatique (DEPLOY_YES)."
else
printf 'Confirmer le deploiement en production ? [oui/NON] '
read -r answer
if [ "$answer" != "oui" ]; then
echo "deploy: annule."
exit 1
fi
fi fi
echo "[1/4] recuperation de '$BRANCH' depuis '$REMOTE' (fast-forward only)" echo "[1/5] recuperation de '$BRANCH' depuis '$REMOTE' (fast-forward only)"
git fetch --prune "$REMOTE" "$BRANCH" git fetch --prune "$REMOTE" "$BRANCH"
git checkout "$BRANCH" git checkout "$BRANCH"
git merge --ff-only "$REMOTE/$BRANCH" git merge --ff-only "$REMOTE/$BRANCH"
echo "[2/4] reconstruction des images depuis les Dockerfiles (--pull rafraichit les bases)" echo "[2/5] marqueur de version (preuve CD cote app)"
SHA="$(git rev-parse --short HEAD)"
NOW="$(date --iso-8601=seconds)"
# Sous src/ pour etre visible dans le conteneur (mount ./src -> /var/www/html),
# lu a chaud par GET /api/health. Journal d'historique a la racine du depot.
printf '%s %s\n' "$SHA" "$NOW" > src/VERSION
printf '[%s] deploy %s (branche %s)\n' "$NOW" "$SHA" "$BRANCH" >> deploy.log
echo "[3/5] reconstruction des images depuis les Dockerfiles (--pull rafraichit les bases)"
docker compose -f "$COMPOSE_FILE" build --pull docker compose -f "$COMPOSE_FILE" build --pull
echo "[3/4] demarrage de la stack (migrate + seed idempotents puis app)" echo "[4/5] demarrage de la stack (migrate + seed idempotents puis app)"
docker compose -f "$COMPOSE_FILE" up -d docker compose -f "$COMPOSE_FILE" up -d
echo "[4/4] etat des services" echo "[5/5] etat des services"
docker compose -f "$COMPOSE_FILE" ps docker compose -f "$COMPOSE_FILE" ps
echo "Deploiement termine." echo "Deploiement termine ($SHA)."

View file

@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace App\Auth;
use RuntimeException;
/**
* Client SMTP minimal (sans dependance) : ESMTP + STARTTLS + AUTH LOGIN, suffisant
* pour un relais authentifie type Brevo. Conduit la conversation contre un
* SmtpTransport injecte ; chaque etape verifie le code de reponse attendu et leve
* en cas d'ecart. La construction du message est laissee a l'appelant (SmtpMailer).
*/
final class SmtpClient
{
public function __construct(
private readonly SmtpTransport $transport,
private readonly string $heloName = 'wakdo',
) {
}
/**
* Ouvre la session, s'authentifie, transmet un message deja assemble
* (en-tetes + corps, lignes en CRLF, dot-stuffing applique) puis ferme.
*/
public function send(
string $host,
int $port,
string $user,
string $password,
string $from,
string $to,
string $message,
): void {
// Defense en profondeur : un CRLF dans une adresse injecterait une commande
// SMTP (RCPT supplementaire) ou un en-tete. On refuse avant toute connexion.
$this->assertNoInjection($from, 'expediteur');
$this->assertNoInjection($to, 'destinataire');
$t = $this->transport;
try {
$t->open($host, $port, 15);
$this->expect($t->readReply(), 220, 'greeting');
$this->command('EHLO ' . $this->heloName, 250, 'EHLO');
$this->command('STARTTLS', 220, 'STARTTLS');
$t->enableCrypto();
// Re-EHLO obligatoire apres bascule TLS (la session repart de zero).
$this->command('EHLO ' . $this->heloName, 250, 'EHLO TLS');
$this->command('AUTH LOGIN', 334, 'AUTH LOGIN');
$this->command(base64_encode($user), 334, 'AUTH user');
$this->command(base64_encode($password), 235, 'AUTH password');
$this->command('MAIL FROM:<' . $from . '>', 250, 'MAIL FROM');
$this->command('RCPT TO:<' . $to . '>', 250, 'RCPT TO');
$this->command('DATA', 354, 'DATA');
// Corps + terminateur "<CRLF>.<CRLF>".
$t->write($message . "\r\n.\r\n");
$this->expect($t->readReply(), 250, 'corps du message');
$t->write("QUIT\r\n");
// La fermeture (221) n'est pas bloquante : le message est deja accepte.
$t->readReply();
} finally {
$t->close();
}
}
private function command(string $line, int $expected, string $stage): void
{
$this->transport->write($line . "\r\n");
$this->expect($this->transport->readReply(), $expected, $stage);
}
private function assertNoInjection(string $address, string $label): void
{
if (preg_match('/[\r\n]/', $address) === 1) {
throw new RuntimeException(
sprintf('SMTP : adresse %s invalide (saut de ligne interdit)', $label),
);
}
}
private function expect(string $reply, int $code, string $stage): void
{
$got = (int) substr(ltrim($reply), 0, 3);
if ($got !== $code) {
// On ne journalise pas le corps : il peut contenir le lien de reset.
throw new RuntimeException(
sprintf('SMTP %s : attendu %d, recu "%s"', $stage, $code, trim($reply)),
);
}
}
}

101
src/app/Auth/SmtpMailer.php Normal file
View file

@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace App\Auth;
use RuntimeException;
/**
* Mailer SMTP reel (relais authentifie type Brevo). Implemente l'interface Mailer
* a la place de LogMailer quand le SMTP est configure (voir PasswordResetController).
* Assemble un message texte/plain UTF-8 conforme puis delegue l'envoi a SmtpClient.
*/
final class SmtpMailer implements Mailer
{
public function __construct(
private readonly SmtpClient $client,
private readonly string $host,
private readonly int $port,
private readonly string $user,
private readonly string $password,
private readonly string $fromEmail,
private readonly string $fromName,
) {
}
public function sendPasswordReset(string $email, string $resetUrl): void
{
// Garde destinataire : une adresse valide ne contient ni CRLF ni structure
// d'injection (verrou en plus de la garde transport de SmtpClient).
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
throw new RuntimeException('SmtpMailer : adresse destinataire invalide');
}
$subject = 'Reinitialisation de votre mot de passe Wakdo';
$body = "Bonjour,\r\n\r\n"
. "Une reinitialisation de mot de passe a ete demandee pour ce compte.\r\n"
. "Pour definir un nouveau mot de passe, ouvrez ce lien :\r\n\r\n"
. $resetUrl . "\r\n\r\n"
. "Ce lien expire rapidement. Si vous n'etes pas a l'origine de la demande, "
. "ignorez cet email.\r\n";
$message = $this->buildMessage($email, $subject, $body);
$this->client->send(
$this->host,
$this->port,
$this->user,
$this->password,
$this->fromEmail,
$email,
$message,
);
}
/** Assemble en-tetes + corps en CRLF, avec dot-stuffing pour la phase DATA. */
private function buildMessage(string $to, string $subject, string $body): string
{
$headers = [
'From: ' . $this->encodeHeader($this->fromName) . ' <' . $this->fromEmail . '>',
'To: <' . $to . '>',
'Subject: ' . $this->encodeHeader($subject),
'MIME-Version: 1.0',
'Content-Type: text/plain; charset=UTF-8',
'Content-Transfer-Encoding: 8bit',
];
$raw = implode("\r\n", $headers) . "\r\n\r\n" . $this->normalizeEol($body);
return $this->dotStuff($raw);
}
/** RFC 2047 (encoded-word base64) si la valeur sort de l'ASCII imprimable. */
private function encodeHeader(string $value): string
{
if (preg_match('/^[\x20-\x7E]*$/', $value) === 1) {
return $value;
}
return '=?UTF-8?B?' . base64_encode($value) . '?=';
}
/** Normalise toutes les fins de ligne en CRLF (LF ou CR isoles -> CRLF). */
private function normalizeEol(string $text): string
{
return (string) preg_replace('/\r\n|\r|\n/', "\r\n", $text);
}
/** Double un point en debut de ligne (RFC 5321 transparency). */
private function dotStuff(string $message): string
{
$lines = explode("\r\n", $message);
foreach ($lines as $i => $line) {
if (isset($line[0]) && $line[0] === '.') {
$lines[$i] = '.' . $line;
}
}
return implode("\r\n", $lines);
}
}

View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Auth;
/**
* Couche transport d'une session SMTP : abstrait le socket reel pour que la
* logique du protocole (SmtpClient) soit testable sans reseau (double en test).
*/
interface SmtpTransport
{
public function open(string $host, int $port, int $timeoutSeconds): void;
/** Ecrit exactement $raw sur la connexion (CRLF inclus par l'appelant). */
public function write(string $raw): void;
/**
* Lit une reponse SMTP complete. Gere le multiligne (RFC 5321 : les lignes
* de continuation ont un '-' en 4e position, la derniere un espace).
*/
public function readReply(): string;
/** Bascule la connexion en TLS (apres STARTTLS). */
public function enableCrypto(): void;
public function close(): void;
}

View file

@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace App\Auth;
use RuntimeException;
/**
* Transport SMTP reel sur socket TCP (stream_socket_client + STARTTLS). Aucune
* dependance externe. Non teste unitairement (effet de bord reseau) : la logique
* du protocole est couverte via SmtpClient + un transport double.
*/
final class StreamSmtpTransport implements SmtpTransport
{
/** @var resource|null */
private $stream = null;
public function open(string $host, int $port, int $timeoutSeconds): void
{
$errno = 0;
$errstr = '';
$stream = @stream_socket_client(
sprintf('tcp://%s:%d', $host, $port),
$errno,
$errstr,
$timeoutSeconds,
);
if ($stream === false) {
throw new RuntimeException(sprintf('SMTP : connexion echouee (%s)', $errstr));
}
stream_set_timeout($stream, $timeoutSeconds);
$this->stream = $stream;
}
public function write(string $raw): void
{
fwrite($this->requireStream(), $raw);
}
public function readReply(): string
{
$stream = $this->requireStream();
$data = '';
$lines = 0;
while (($line = fgets($stream, 515)) !== false) {
$data .= $line;
// Bornes anti-boucle sur reponse malformee (ni ligne finale, ni EOF).
if (++$lines > 100 || strlen($data) > 65536) {
break;
}
// Continuation UNIQUEMENT si '-' en 4e position ; toute autre ligne
// (y compris trop courte) termine la reponse.
if (!(strlen($line) >= 4 && $line[3] === '-')) {
break;
}
}
if ($data === '') {
throw new RuntimeException('SMTP : aucune reponse du serveur');
}
return $data;
}
public function enableCrypto(): void
{
if (!stream_socket_enable_crypto($this->requireStream(), true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
throw new RuntimeException('SMTP : echec de la negociation TLS (STARTTLS)');
}
}
public function close(): void
{
if (is_resource($this->stream)) {
fclose($this->stream);
}
$this->stream = null;
}
/** @return resource */
private function requireStream()
{
if (!is_resource($this->stream)) {
throw new RuntimeException('SMTP : transport non ouvert');
}
return $this->stream;
}
}

View file

@ -12,9 +12,13 @@ use App\Core\Response;
* Sonde de sante. GET /api/health. * Sonde de sante. GET /api/health.
* *
* Le comptage des categories prouve la chaine complete (autoloader -> routeur * Le comptage des categories prouve la chaine complete (autoloader -> routeur
* -> controleur -> PDO -> BDD seedee), pas seulement que PHP repond. * -> controleur -> PDO -> BDD seedee), pas seulement que PHP repond. Expose aussi
* la version deployee (SHA + date), ecrite par scripts/deploy.sh : c'est la preuve
* cote app du CD (apres un deploiement, ce champ reflete le nouveau commit).
*
* Non-final : seam de test (la sous-classe redirige versionFilePath sur une fixture).
*/ */
final class HealthController extends Controller class HealthController extends Controller
{ {
/** /**
* @param array<string, string> $params * @param array<string, string> $params
@ -35,6 +39,8 @@ final class HealthController extends Controller
$httpStatus = 503; $httpStatus = 503;
} }
$version = $this->readVersion();
return $this->json( return $this->json(
[ [
'status' => $dbStatus === 'ok' ? 'ok' : 'degraded', 'status' => $dbStatus === 'ok' ? 'ok' : 'degraded',
@ -42,8 +48,45 @@ final class HealthController extends Controller
'php_version' => PHP_VERSION, 'php_version' => PHP_VERSION,
'db' => $dbStatus, 'db' => $dbStatus,
'categories' => $categories, 'categories' => $categories,
'version' => $version['version'],
'deployed_at' => $version['deployed_at'],
], ],
$httpStatus, $httpStatus,
); );
} }
/**
* Chemin du marqueur de version. Sous le mount du code (./src -> /var/www/html),
* donc lisible a chaud par l'app sans rebuild.
*/
protected function versionFilePath(): string
{
return dirname(__DIR__, 2) . '/VERSION';
}
/**
* Lit "SHA<espace>date" ecrit par deploy.sh. Absence toleree (dev / avant 1er
* deploiement) : les deux champs retombent a null.
*
* @return array{version: ?string, deployed_at: ?string}
*/
private function readVersion(): array
{
$path = $this->versionFilePath();
if (!is_file($path) || !is_readable($path)) {
return ['version' => null, 'deployed_at' => null];
}
$line = trim((string) @file_get_contents($path));
if ($line === '') {
return ['version' => null, 'deployed_at' => null];
}
$parts = explode(' ', $line, 2);
return [
'version' => $parts[0] !== '' ? $parts[0] : null,
'deployed_at' => isset($parts[1]) && $parts[1] !== '' ? $parts[1] : null,
];
}
} }

View file

@ -7,9 +7,13 @@ namespace App\Controllers;
use Throwable; use Throwable;
use App\Auth\Csrf; use App\Auth\Csrf;
use App\Auth\LogMailer; use App\Auth\LogMailer;
use App\Auth\Mailer;
use App\Auth\PasswordHasher; use App\Auth\PasswordHasher;
use App\Auth\PasswordResetService; use App\Auth\PasswordResetService;
use App\Auth\SessionManager; use App\Auth\SessionManager;
use App\Auth\SmtpClient;
use App\Auth\SmtpMailer;
use App\Auth\StreamSmtpTransport;
use App\Core\Controller; use App\Core\Controller;
use App\Core\Response; use App\Core\Response;
@ -124,7 +128,33 @@ class PasswordResetController extends Controller
$this->database, $this->database,
$this->config, $this->config,
new PasswordHasher($this->config), new PasswordHasher($this->config),
new LogMailer(), $this->mailer(),
);
}
/**
* SMTP reel si configure (SMTP_HOST + SMTP_USER + SMTP_PASSWORD presents),
* sinon repli sur LogMailer (le lien est journalise, pas d'envoi) : le dev
* reste sans infra mail, la prod envoie via le relais.
*/
protected function mailer(): Mailer
{
$host = $this->config->get('SMTP_HOST');
$user = $this->config->get('SMTP_USER');
$password = $this->config->get('SMTP_PASSWORD');
if ($host === null || $user === null || $password === null) {
return new LogMailer();
}
return new SmtpMailer(
new SmtpClient(new StreamSmtpTransport()),
$host,
(int) ($this->config->get('SMTP_PORT', '587') ?? '587'),
$user,
$password,
$this->config->get('MAIL_FROM_EMAIL', 'noreply@localhost') ?? 'noreply@localhost',
$this->config->get('MAIL_FROM_NAME', 'Wakdo') ?? 'Wakdo',
); );
} }

View file

@ -8,7 +8,8 @@
* Traduction panier borne -> contrat API : * Traduction panier borne -> contrat API :
* - produit simple -> { type:'product', product_id, quantity } * - produit simple -> { type:'product', product_id, quantity }
* - menu -> { type:'menu', menu_id, quantity, format, selections } * - menu -> { type:'menu', menu_id, quantity, format, selections }
* format = 'maxi' si supplement_cents>0, sinon 'normal'. * format = cartItem.format (choix Normal/Maxi porte par l'item panier) ; repli
* historique sur supplement_cents>0 pour un panier serialise avant cette version.
* selections = [{menu_slot_id, product_id}] reconstruites depuis la composition * selections = [{menu_slot_id, product_id}] reconstruites depuis la composition
* (accompagnement/boisson/sauce) mappee aux slots reels du menu (re-fetch). * (accompagnement/boisson/sauce) mappee aux slots reels du menu (re-fetch).
* - service_mode : 'sur-place' -> 'dine_in', 'a-emporter' -> 'takeaway'. * - service_mode : 'sur-place' -> 'dine_in', 'a-emporter' -> 'takeaway'.
@ -64,7 +65,10 @@ export function buildOrderItem(cartItem, menuSlotsById) {
type: 'menu', type: 'menu',
menu_id: cartItem.id, menu_id: cartItem.id,
quantity: cartItem.quantite, quantity: cartItem.quantite,
format: (cartItem.supplement_cents ?? 0) > 0 ? 'maxi' : 'normal', // Format choisi par l'utilisateur, transporte explicitement. Repli sur
// l'ancienne inference (supplement_cents>0) pour un panier serialise en
// sessionStorage avant l'ajout du champ format.
format: cartItem.format ?? ((cartItem.supplement_cents ?? 0) > 0 ? 'maxi' : 'normal'),
selections: buildSelections(cartItem.composition, menuSlotsById[cartItem.id] || []), selections: buildSelections(cartItem.composition, menuSlotsById[cartItem.id] || []),
}; };
} }

View file

@ -121,6 +121,11 @@ export function buildMenuCartItem(menu, model, { size, selections }) {
quantite: 1, quantite: 1,
image: menu.image, image: menu.image,
supplement_cents: supplement, supplement_cents: supplement,
// format PORTE le choix Normal/Maxi de l'utilisateur, transporte tel quel
// jusqu'au contrat API. Le serveur l'utilise pour le prix Maxi ET la
// substitution des variantes (accompagnement Grande, boisson 50 cl). A NE
// PAS re-deviner depuis supplement_cents (faux negatif si maxi == normal).
format: isMaxi ? 'maxi' : 'normal',
composition, composition,
}; };
} }

View file

@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Tests\Support;
use App\Auth\SmtpTransport;
use RuntimeException;
/**
* Transport SMTP double : rejoue des reponses serveur scriptees et enregistre les
* ecritures du client, pour tester la logique du protocole sans reseau.
*/
final class FakeSmtpTransport implements SmtpTransport
{
/** @var list<string> ce que le client a ecrit, dans l'ordre */
public array $writes = [];
public bool $cryptoEnabled = false;
public bool $closed = false;
public bool $opened = false;
/** @var list<string> reponses a rendre, dans l'ordre des readReply() */
private array $replies;
/** @param list<string> $replies */
public function __construct(array $replies)
{
$this->replies = $replies;
}
public function open(string $host, int $port, int $timeoutSeconds): void
{
$this->opened = true;
}
public function write(string $raw): void
{
$this->writes[] = $raw;
}
public function readReply(): string
{
if ($this->replies === []) {
throw new RuntimeException('FakeSmtpTransport : plus de reponse scriptee');
}
return array_shift($this->replies);
}
public function enableCrypto(): void
{
$this->cryptoEnabled = true;
}
public function close(): void
{
$this->closed = true;
}
/** Concatene toutes les ecritures (pratique pour assertions sur le message). */
public function written(): string
{
return implode('', $this->writes);
}
}

View file

@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Auth;
use PHPUnit\Framework\TestCase;
use App\Auth\SmtpClient;
use App\Tests\Support\FakeSmtpTransport;
use RuntimeException;
final class SmtpClientTest extends TestCase
{
/** @return list<string> sequence nominale de reponses serveur */
private function happyReplies(): array
{
return [
"220 smtp.brevo ready\r\n", // greeting
"250-smtp\r\n250 AUTH LOGIN\r\n", // EHLO (multiligne)
"220 go ahead\r\n", // STARTTLS
"250 ok\r\n", // EHLO post-TLS
"334 VXNlcm5hbWU6\r\n", // AUTH LOGIN
"334 UGFzc3dvcmQ6\r\n", // user
"235 authenticated\r\n", // password
"250 ok\r\n", // MAIL FROM
"250 ok\r\n", // RCPT TO
"354 end data with <CRLF>.<CRLF>\r\n", // DATA
"250 queued\r\n", // body
"221 bye\r\n", // QUIT
];
}
public function testNominalConversationAuthenticatesAndSends(): void
{
$t = new FakeSmtpTransport($this->happyReplies());
$client = new SmtpClient($t);
$client->send('smtp-relay.brevo.com', 587, 'user@x', 'secret', 'from@a.fr', 'to@b.fr', "Subject: hi\r\n\r\ncorps");
self::assertTrue($t->opened);
self::assertTrue($t->cryptoEnabled, 'STARTTLS doit basculer le transport en TLS');
self::assertTrue($t->closed, 'le transport doit etre ferme');
$sent = $t->written();
self::assertStringContainsString("STARTTLS\r\n", $sent);
self::assertStringContainsString("AUTH LOGIN\r\n", $sent);
self::assertStringContainsString(base64_encode('user@x') . "\r\n", $sent);
self::assertStringContainsString(base64_encode('secret') . "\r\n", $sent);
self::assertStringContainsString("MAIL FROM:<from@a.fr>\r\n", $sent);
self::assertStringContainsString("RCPT TO:<to@b.fr>\r\n", $sent);
self::assertStringContainsString("DATA\r\n", $sent);
self::assertStringContainsString("\r\n.\r\n", $sent, 'le corps doit finir par le terminateur DATA');
self::assertStringContainsString("QUIT\r\n", $sent);
}
public function testReEhloHappensAfterStarttls(): void
{
$t = new FakeSmtpTransport($this->happyReplies());
(new SmtpClient($t))->send('h', 587, 'u', 'p', 'f@a.fr', 't@b.fr', "x");
// Deux EHLO : un avant STARTTLS, un apres (session repart a zero apres TLS).
$ehloCount = substr_count($t->written(), 'EHLO ');
self::assertSame(2, $ehloCount);
}
public function testRejectedAuthThrowsAndCloses(): void
{
$replies = $this->happyReplies();
$replies[6] = "535 authentication failed\r\n"; // reponse au mot de passe
$t = new FakeSmtpTransport($replies);
$client = new SmtpClient($t);
try {
$client->send('h', 587, 'u', 'bad', 'f@a.fr', 't@b.fr', 'x');
self::fail('une auth refusee doit lever');
} catch (RuntimeException $e) {
self::assertStringContainsString('AUTH password', $e->getMessage());
}
self::assertTrue($t->closed, 'le transport doit etre ferme meme en cas d echec (finally)');
}
public function testUnexpectedGreetingThrows(): void
{
$t = new FakeSmtpTransport(["554 service unavailable\r\n"]);
$this->expectException(RuntimeException::class);
(new SmtpClient($t))->send('h', 587, 'u', 'p', 'f@a.fr', 't@b.fr', 'x');
}
public function testRejectsCrlfInRecipientBeforeConnecting(): void
{
// Tentative d'injection d'une commande RCPT via le destinataire.
$t = new FakeSmtpTransport($this->happyReplies());
$client = new SmtpClient($t);
try {
$client->send('h', 587, 'u', 'p', 'f@a.fr', "t@b.fr>\r\nRCPT TO:<evil@x.com", 'x');
self::fail('un CRLF dans l adresse doit lever');
} catch (RuntimeException $e) {
self::assertStringContainsString('destinataire', $e->getMessage());
}
self::assertFalse($t->opened, 'aucune connexion ne doit s ouvrir si l adresse est invalide');
self::assertSame([], $t->writes, 'rien ne doit etre emis');
}
}

View file

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Auth;
use PHPUnit\Framework\TestCase;
use App\Auth\SmtpClient;
use App\Auth\SmtpMailer;
use App\Tests\Support\FakeSmtpTransport;
final class SmtpMailerTest extends TestCase
{
/** @return list<string> sequence nominale de reponses serveur */
private function happyReplies(): array
{
return [
"220 ready\r\n", "250 ok\r\n", "220 go\r\n", "250 ok\r\n",
"334 u\r\n", "334 p\r\n", "235 ok\r\n", "250 ok\r\n", "250 ok\r\n",
"354 data\r\n", "250 queued\r\n", "221 bye\r\n",
];
}
private function mailer(FakeSmtpTransport $t): SmtpMailer
{
return new SmtpMailer(
new SmtpClient($t),
'smtp-relay.brevo.com',
587,
'login@smtp-brevo.com',
'secret',
'noreply@a3n.fr',
'Wakdo',
);
}
public function testBuildsAndSendsResetMessage(): void
{
$t = new FakeSmtpTransport($this->happyReplies());
$this->mailer($t)->sendPasswordReset('client@example.fr', 'https://corentin-wakdo-admin.stark.a3n.fr/reset_password?token=abc');
$sent = $t->written();
self::assertStringContainsString('From: Wakdo <noreply@a3n.fr>', $sent);
self::assertStringContainsString('To: <client@example.fr>', $sent);
self::assertStringContainsString('Subject: Reinitialisation de votre mot de passe Wakdo', $sent);
self::assertStringContainsString('Content-Type: text/plain; charset=UTF-8', $sent);
self::assertStringContainsString('https://corentin-wakdo-admin.stark.a3n.fr/reset_password?token=abc', $sent);
// L'enveloppe SMTP doit porter l'expediteur et le destinataire reels.
self::assertStringContainsString('MAIL FROM:<noreply@a3n.fr>', $sent);
self::assertStringContainsString('RCPT TO:<client@example.fr>', $sent);
}
public function testRejectsInvalidRecipient(): void
{
$t = new FakeSmtpTransport($this->happyReplies());
$this->expectException(\RuntimeException::class);
$this->mailer($t)->sendPasswordReset("victim@x.fr\r\nBcc: evil@x.com", 'https://x/reset?token=t');
}
public function testHeaderAndBodySeparatedByBlankLine(): void
{
$t = new FakeSmtpTransport($this->happyReplies());
$this->mailer($t)->sendPasswordReset('c@e.fr', 'https://x/reset?token=t');
// En-tetes et corps separes par une ligne vide (CRLF CRLF).
self::assertStringContainsString("Content-Transfer-Encoding: 8bit\r\n\r\nBonjour,", $t->written());
}
}

View file

@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Controllers;
use PHPUnit\Framework\TestCase;
use App\Controllers\HealthController;
use App\Core\Config;
use App\Core\Database;
use App\Core\Request;
/**
* Sous-classe de test : pointe le fichier VERSION sur une fixture temporaire,
* pour couvrir l'exposition de la version deployee sans dependre d'un deploiement
* reel (le fichier est ecrit par scripts/deploy.sh sur l'hote, jamais en test).
*/
final class TestHealthController extends HealthController
{
public string $versionPath = '';
protected function versionFilePath(): string
{
return $this->versionPath;
}
}
/**
* La sonde expose la version deployee (SHA + date) pour prouver le CD : apres un
* deploiement, GET /api/health doit refleter le nouveau commit. Le test n'a pas de
* base : l'appel DB echoue et degrade le statut, mais les champs version restent
* presents (ils sont independants de la BDD).
*/
final class HealthControllerTest extends TestCase
{
private function controller(string $versionPath): TestHealthController
{
$request = new Request('GET', '/api/health', [], [], '', '203.0.113.5');
$c = new TestHealthController($request, new Config(), new Database(new Config()));
$c->versionPath = $versionPath;
return $c;
}
public function testExposesDeployedVersionWhenFilePresent(): void
{
$fixture = tempnam(sys_get_temp_dir(), 'wakdo_version_');
file_put_contents($fixture, "3dee190 2026-06-23T14:02:11+02:00\n");
try {
$body = $this->controller($fixture)->index()->body();
$payload = json_decode($body, true);
self::assertSame('3dee190', $payload['version']);
self::assertSame('2026-06-23T14:02:11+02:00', $payload['deployed_at']);
} finally {
@unlink($fixture);
}
}
public function testVersionNullWhenFileAbsent(): void
{
$missing = sys_get_temp_dir() . '/wakdo_version_does_not_exist_' . getmypid();
@unlink($missing);
$body = $this->controller($missing)->index()->body();
$payload = json_decode($body, true);
self::assertNull($payload['version']);
self::assertNull($payload['deployed_at']);
}
}

View file

@ -121,6 +121,52 @@ final class OrderRepositoryTest extends TestCase
self::assertSame(8, $sel['slot']); self::assertSame(8, $sel['slot']);
} }
public function testMenuMaxiSwapsDrinkSelectionToLargeVariant(): void
{
// Au format maxi, la boisson fontaine Coca Cola (variante = Coca Cola 50cl,
// id 15) doit etre persistee comme la 50 cl : meme mecanique que l'accompagnement
// Grande Frite (maxi_variant_product_id), pour que le stock decremente la 50 cl
// et que le snapshot reflete "Coca Cola 50cl". Aucune garde sur le slot_type.
$db = new FakeOrderDatabase();
$db->menus[5] = ['id' => 5, 'burger_product_id' => 12, 'name' => 'Menu', 'price_normal_cents' => 990, 'price_maxi_cents' => 1200, 'is_available' => 1];
$db->products[12] = ['id' => 12, 'name' => 'Burger', 'price_cents' => 600, 'vat_rate' => 100, 'is_available' => 1];
$db->products[14] = ['id' => 14, 'name' => 'Coca Cola', 'price_cents' => 190, 'vat_rate' => 100, 'is_available' => 1, 'maxi_variant_product_id' => 15];
$db->products[15] = ['id' => 15, 'name' => 'Coca Cola 50cl', 'price_cents' => 240, 'vat_rate' => 100, 'is_available' => 1, 'maxi_variant_product_id' => null];
$db->slotRows[5] = [['id' => 9, 'name' => 'Boisson', 'slot_type' => 'drink', 'is_required' => 1, 'display_order' => 1, 'product_id' => 14]];
$this->repo($db)->createPending([
'service_mode' => 'takeaway',
'items' => [['type' => 'menu', 'menu_id' => 5, 'quantity' => 1, 'format' => 'maxi',
'selections' => [['menu_slot_id' => 9, 'product_id' => 14]]]], // borne envoie la 30 cl
]);
$sel = $db->firstWrite('INSERT INTO order_item_selection');
self::assertSame(15, $sel['pid']); // swap -> Coca Cola 50cl
self::assertSame('Coca Cola 50cl', $sel['label']);
self::assertSame(9, $sel['slot']);
}
public function testMenuMaxiKeepsBottledDrinkWithoutVariant(): void
{
// Une boisson en bouteille (Eau) n'a pas de variante 50 cl : meme en Maxi la
// selection reste l'Eau de base (degradation gracieuse, modele fast-food).
$db = new FakeOrderDatabase();
$db->menus[5] = ['id' => 5, 'burger_product_id' => 12, 'name' => 'Menu', 'price_normal_cents' => 990, 'price_maxi_cents' => 1200, 'is_available' => 1];
$db->products[12] = ['id' => 12, 'name' => 'Burger', 'price_cents' => 600, 'vat_rate' => 100, 'is_available' => 1];
$db->products[16] = ['id' => 16, 'name' => 'Eau', 'price_cents' => 150, 'vat_rate' => 100, 'is_available' => 1, 'maxi_variant_product_id' => null];
$db->slotRows[5] = [['id' => 9, 'name' => 'Boisson', 'slot_type' => 'drink', 'is_required' => 1, 'display_order' => 1, 'product_id' => 16]];
$this->repo($db)->createPending([
'service_mode' => 'takeaway',
'items' => [['type' => 'menu', 'menu_id' => 5, 'quantity' => 1, 'format' => 'maxi',
'selections' => [['menu_slot_id' => 9, 'product_id' => 16]]]],
]);
$sel = $db->firstWrite('INSERT INTO order_item_selection');
self::assertSame(16, $sel['pid']); // pas de variante -> reste l'Eau
self::assertSame('Eau', $sel['label']);
}
public function testMenuNormalKeepsBaseSideSelection(): void public function testMenuNormalKeepsBaseSideSelection(): void
{ {
// Format normal : aucune substitution, l'accompagnement reste la Moyenne // Format normal : aucune substitution, l'accompagnement reste la Moyenne

View file

@ -60,6 +60,17 @@ test('buildOrderItem: menu normal vs maxi (format + selections)', () => {
assert.equal(maxi.format, 'maxi'); assert.equal(maxi.format, 'maxi');
}); });
test('buildOrderItem: format explicite prime sur l inference (maxi meme si supplement 0)', () => {
// Le choix utilisateur est transporte dans cartItem.format ; il ne doit PAS etre
// re-devine du prix (un menu maxi == normal serait sinon envoye en normal).
const explicit = { id: 1, type: 'menu', quantite: 1, supplement_cents: 0, format: 'maxi',
composition: { boisson: { id: 14 } } };
assert.equal(buildOrderItem(explicit, { 1: slots() }).format, 'maxi');
// Repli historique : un panier serialise sans champ format infere depuis supplement.
const legacy = { id: 1, type: 'menu', quantite: 1, supplement_cents: 150, composition: {} };
assert.equal(buildOrderItem(legacy, { 1: slots() }).format, 'maxi');
});
/* --- buildOrderPayload --------------------------------------------------- */ /* --- buildOrderPayload --------------------------------------------------- */
test('buildOrderPayload: dine_in inclut service_tag ; takeaway l omet', () => { test('buildOrderPayload: dine_in inclut service_tag ; takeaway l omet', () => {

View file

@ -71,6 +71,7 @@ test('buildMenuCartItem Normal: prix normal, pas de supplement, taille N, compos
assert.equal(item.type, 'menu'); assert.equal(item.type, 'menu');
assert.equal(item.prix_cents, 880); assert.equal(item.prix_cents, 880);
assert.equal(item.supplement_cents, 0); assert.equal(item.supplement_cents, 0);
assert.equal(item.format, 'normal'); // format explicite transporte
assert.equal(item.composition.burger.libelle, 'Le 280'); assert.equal(item.composition.burger.libelle, 'Le 280');
// Normal : l'accompagnement garde son nom de base (pas la variante Maxi). // Normal : l'accompagnement garde son nom de base (pas la variante Maxi).
assert.deepEqual(item.composition.accompagnement, { id: 22, libelle: 'Moyenne Frite', taille: 'N' }); assert.deepEqual(item.composition.accompagnement, { id: 22, libelle: 'Moyenne Frite', taille: 'N' });
@ -83,6 +84,7 @@ test('buildMenuCartItem Maxi: supplement = maxi - normal, taille G sur side/drin
const item = buildMenuCartItem(menu, m, { size: 'M', selections: { 1: 14, 16: 22, 31: 47 } }); const item = buildMenuCartItem(menu, m, { size: 'M', selections: { 1: 14, 16: 22, 31: 47 } });
assert.equal(item.prix_cents, 880); assert.equal(item.prix_cents, 880);
assert.equal(item.supplement_cents, 150); // 1030 - 880 assert.equal(item.supplement_cents, 150); // 1030 - 880
assert.equal(item.format, 'maxi'); // format explicite transporte
assert.equal(item.composition.accompagnement.taille, 'G'); assert.equal(item.composition.accompagnement.taille, 'G');
assert.equal(item.composition.boisson.taille, 'G'); assert.equal(item.composition.boisson.taille, 'G');
}); });
@ -91,10 +93,21 @@ test('buildMenuCartItem Maxi: l accompagnement prend sa variante (Grande Frite),
const m = buildComposerSteps(detail(), byId()); const m = buildComposerSteps(detail(), byId());
const item = buildMenuCartItem(menu, m, { size: 'M', selections: { 1: 14, 16: 22, 31: 47 } }); const item = buildMenuCartItem(menu, m, { size: 'M', selections: { 1: 14, 16: 22, 31: 47 } });
assert.equal(item.composition.accompagnement.libelle, 'Grande Frite'); // pas "Moyenne Frite" assert.equal(item.composition.accompagnement.libelle, 'Grande Frite'); // pas "Moyenne Frite"
// Boisson sans maxiNom : garde son nom de base meme en Maxi (le Maxi ne l agrandit pas). // Boisson sans maxiNom : garde son nom de base meme en Maxi (cas bouteille).
assert.equal(item.composition.boisson.libelle, 'Coca'); assert.equal(item.composition.boisson.libelle, 'Coca');
}); });
test('buildMenuCartItem Maxi: la boisson AVEC variante (50cl) prend son nom agrandi', () => {
// Apres le seed 0006, une boisson fontaine porte maxiNom (ex. "Coca Cola 50cl") :
// en Maxi, le libelle et la taille refletent la grande boisson (meme regle que
// l'accompagnement). Aucune logique borne specifique : maxiNom suffit.
const byIdDrinkVariant = { ...byId(), 14: { id: 14, nom: 'Coca Cola', prix: 0, image: 'c.png', type: 'produit', maxiNom: 'Coca Cola 50cl' } };
const m = buildComposerSteps(detail(), byIdDrinkVariant);
const item = buildMenuCartItem(menu, m, { size: 'M', selections: { 1: 14, 16: 22, 31: 47 } });
assert.equal(item.composition.boisson.libelle, 'Coca Cola 50cl');
assert.equal(item.composition.boisson.taille, 'G');
});
test('buildMenuCartItem Normal: l accompagnement garde "Moyenne Frite" (pas de variante)', () => { test('buildMenuCartItem Normal: l accompagnement garde "Moyenne Frite" (pas de variante)', () => {
const m = buildComposerSteps(detail(), byId()); const m = buildComposerSteps(detail(), byId());
const item = buildMenuCartItem(menu, m, { size: 'N', selections: { 1: 14, 16: 22, 31: 47 } }); const item = buildMenuCartItem(menu, m, { size: 'N', selections: { 1: 14, 16: 22, 31: 47 } });