From a8899994db8de8938ca634fc008a8415489ecb01 Mon Sep 17 00:00:00 2001 From: razyd Date: Mon, 5 Oct 2026 14:44:52 +0200 Subject: [PATCH 01/20] docs: plan the demo on the showcase site before going public - Add ADR-037: the demo runs on four-points.stackbp.es with its database, demo users see the whole app and can generate schedules but not touch users, roles or passwords, and the repository goes public after it - TODO: rewrite the demo entry for the same site, with the solver limits, the daily reset including schedules and the local first-admin setup - ROADMAP and SESSION: the original is archived; going public waits for the demo Why: the site is only a showcase, and without a demo nobody reaching the repository can try the app. --- docs/DECISIONS.md | 23 +++++++++++++++++++++++ docs/ROADMAP.md | 20 ++++++++++---------- docs/SESSION.md | 16 +++++++++------- docs/TODO.md | 46 +++++++++++++++++++++++++--------------------- 4 files changed, 67 insertions(+), 38 deletions(-) diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index d35537a..3914684 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -708,6 +708,8 @@ - **Estado:** ✅ aceptada (2026-10-05) · 📌 orden revisado por [ADR-036](#adr-036--el-original-se-archiva-al-final-y-la-rotación-va-después-de-publicar) + · 📌 momento de publicar revisado por + [ADR-037](#adr-037--la-demo-va-en-la-propia-web-y-el-repositorio-se-publica-después) - **Fecha:** 2026-10-05 - **Decisión:** - Se limpian y se publican **solo `main` y @@ -753,3 +755,24 @@ prueba. El procedimiento describe paneles, equipos y claves concretas que no sirven a quien lea el repositorio. - **Revisa:** ADR-035 en el orden; ADR-007 en el momento de la rotación. + +## ADR-037 — La demo va en la propia web y el repositorio se publica después + +- **Estado:** ✅ aceptada (2026-10-05) +- **Fecha:** 2026-10-05 +- **Decisión:** + - `four-points.stackbp.es` es solo un escaparate: la demo pública va en esa + misma web y su BD, con usuarios demo por rol, datos ficticios + (`mock-data.sql`) y reinicio diario, también de los horarios. + - Los usuarios demo ven toda la app y pueden generar horarios, pero no tocan + la configuración: ni usuarios, ni roles, ni contraseñas. + - El repositorio se hace público cuando la demo esté lista. +- **Motivo:** decisión del propietario. Nadie usa la web para trabajar, y una + segunda web con su BD gastaría más horas del plan gratuito de Render. Sin + demo, quien llegue al repositorio no tiene cómo probar la aplicación: + `MASTER_INSTALL.sql` no crea ningún usuario. +- **Rechazado:** la demo aparte en `demo.four-points.stackbp.es` con la BD + `hotel_demo` (plan del 2026-10-04); publicar antes de la demo. +- **Consecuencias:** si la web llega a usarse de verdad, la demo se separa + entonces. Fase 5 antes de terminar la 4. +- **Revisa:** ADR-035 en el momento de hacer público el repositorio. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index c6a45bd..0696c35 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -54,16 +54,16 @@ Historial de `main` y `claude/compassionate-planck-gh6aof` reescrito y auditado ## Fase 4 — Publicación ← **actual** -El historial limpio está en `bpstack/four-points`, todavía privado, y Vercel y -Render ya despliegan desde él. Falta archivar el repositorio original, pasar el -nuevo a público y activar secret scanning y push protection. La rotación de -credenciales (ADR-007) no bloquea: va en «Después de publicar» de `TODO.md`. +El historial limpio está en `bpstack/four-points`, todavía privado; Vercel y +Render despliegan desde él y el original está archivado. Pasar a público (con +secret scanning y push protection) espera a la demo de la fase 5 (ADR-037). La +rotación de credenciales (ADR-007) no bloquea: va en «Después de publicar» de +`TODO.md`. ## Fase 5 — Demo pública -Un entorno para que cualquiera pruebe la aplicación sin tocar producción: -`demo.four-points.stackbp.es` con su propia BD (`hotel_demo` en Aiven), datos -ficticios, secretos propios, usuarios demo públicos por rol y reinicio nocturno -de la BD. Reutiliza la infraestructura del preview y el rol `demo-admin`. -Diseño y decisiones pendientes en `TODO.md`, «Demo pública» (propuesta aceptada -por el propietario el 2026-10-04). +Que cualquiera pruebe la aplicación en la propia web, que es solo un escaparate +(2026-10-05): usuarios demo públicos por rol, datos ficticios, generación de +horarios y reinicio diario. Reutiliza el rol `demo-admin`. Diseño y decisiones +pendientes en `TODO.md`, «Demo pública en la propia web». El repositorio se hace +público cuando la demo esté lista. diff --git a/docs/SESSION.md b/docs/SESSION.md index b7cf448..b8c4a4e 100644 --- a/docs/SESSION.md +++ b/docs/SESSION.md @@ -38,13 +38,15 @@ nuevo»). Decisiones en `DECISIONS.md`, hasta ADR-036. ## Siguiente -1. Push de la rama y comprobar que Vercel y Render despliegan solos desde el - repositorio nuevo (queda sin comprobar en `VERIFY.md`). -2. Fusionar la rama en `main` por PR con merge commit (ADR-031). -3. Archivar `four-points-archive` (ADR-036). -4. Pasar `four-points` a público y activar secret scanning y push protection. -5. Después, «Después de publicar» de `TODO.md`, empezando por la rotación de - credenciales. +1. Planificar y construir la demo en la propia web: `TODO.md`, «Demo pública en + la propia web» (ADR-037). +2. Pasar `four-points` a público y activar secret scanning y push protection. +3. «Después de publicar» de `TODO.md`, empezando por la rotación de + credenciales (se puede adelantar). + +Hecho el 2026-10-05: Vercel y Render despliegan solos desde este repositorio +(producción y preview), `four-points-archive` está archivado y Aiven tiene +usuarios anonimizados y datos ficticios (`mock-data.sql`). ## Esperando decisión diff --git a/docs/TODO.md b/docs/TODO.md index 9316194..5e37033 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -299,27 +299,31 @@ sigue siendo donde se arregla este fichero y se prueba en el entorno preview. pestaña muestra las rechazadas. Una factura incorrecta hoy se borra. El dueño decidió el 2026-10-04 no añadirlo porque casi nunca pasa; si se añade, hace falta también reabrir (`rejected` → `pending`). -- [ ] **Demo pública** (fase 5 de `ROADMAP.md`; propuesta aceptada por el dueño - el 2026-10-04) — un entorno aparte que la gente pueda probar sin tocar - producción: - - **BD propia**: base `hotel_demo` en el mismo servicio Aiven, con datos - inventados de un hotel ficticio. - - **Backend y frontend propios** en `demo.four-points.stackbp.es`, - reutilizando la infraestructura del preview al acabar la fase 1c (sin - más horas de Render). - - **Secretos propios**: otro `SECRET_JWT_KEY` y otras credenciales; un - token de la demo nunca vale en producción. - - **Reinicio nocturno**: un cron borra y vuelve a sembrar la BD demo. - - **Usuarios demo públicos** en la pantalla de login: `demo_admin`, - `demo_recepcion`, `demo_mantenimiento`. - - **Nivel de libertad** (recomendado: admin casi completo): crear, editar y - borrar de todo, porque el reinicio lo arregla. Bloqueado: cambiar - contraseñas, crear o borrar usuarios, subidas a Cloudinary (desactivadas - o en una carpeta `demo/` vaciada cada noche) y límites de peticiones más - estrictos. La alternativa es solo lectura con la lista blanca de - `demoRestriction` (más segura, demo pobre). - - **Nunca** sobre la BD de producción: cuando haya datos reales del hotel, - cualquier demo ahí queda descartada. +- [ ] **Demo pública en la propia web** (fase 5 de `ROADMAP.md`) — la web es + solo un escaparate (decidido por el dueño el 2026-10-05), así que la demo + va en `four-points.stackbp.es` con su BD, sin subdominio ni BD aparte + como proponía el plan del 2026-10-04. Si algún día se usa de verdad, la + demo se separa entonces. + - **Usuarios demo públicos por rol** (admin, recepción, mantenimiento y + grupos), con la contraseña a la vista en el login. Las cuentas del + dueño (`admin`, `qa_*`) siguen aparte, con contraseñas solo suyas. + - **Acceso:** ven toda la app y pueden crear, editar y borrar, porque se + reinicia. Bloqueado: la configuración (crear, editar o borrar usuarios + y roles, y cambiar contraseñas), subir ficheros a Cloudinary (o carpeta + `demo/` vaciada cada noche) y límites de peticiones más estrictos. + - **Generar horarios:** abrir el solver al rol demo (hoy toda la ruta de + horarios es solo `admin`). Con un límite por usuario o IP y un tiempo + máximo: el solver carga OR-Tools en el plan gratuito de Render (512 MB) + y el semáforo solo lo hace secuencial. + - **Reinicio diario:** un cron vuelve a cargar `mock-data.sql` y también + horarios, que el mock no toca: borrar los meses creados por la demo y + restaurar una base fija de meses y asignaciones. Esa base se genera + con el solver; no se saca de los meses actuales, que vienen de bajas + reales. + - **Arranque en local:** documentar cómo crear el primer admin y los + departamentos antes del mock; `MASTER_INSTALL.sql` no crea ninguno. + - **Al hacerlo:** ADR que revisa la propuesta del subdominio y la fase 5 + de `ROADMAP.md`. El repositorio se hace público cuando la demo esté. - [ ] **Reutilizar el rol `demo-admin` para la demo pública** (antes «retirarlo»; ver «Demo pública» y la fase 5 de `ROADMAP.md`) — el único usuario con ese rol, `demo`, está desactivado (`is_active = 0`, comprobado en Aiven el From e332b8adf864ffae4831f155f963f1033125c25e Mon Sep 17 00:00:00 2001 From: razyd Date: Mon, 5 Oct 2026 15:03:45 +0200 Subject: [PATCH 02/20] docs: settle the demo design and the local install - DECISIONS: ADR-038, one restricted demo admin entered with a button, an is_demo flag instead of the demo-admin role, daily reset on the first demo login, local install with MySQL in Docker; ADR-037 points to it - TODO: demo entry rewritten as seven steps; demo-admin entry now retires it - ROADMAP: phase 5 describes the agreed demo - SESSION: next step starts with the is_demo flag Why: the owner chose the simplest demo for visitors and to maintain, and a local install with full admin rights for whoever wants to see everything. --- docs/DECISIONS.md | 43 +++++++++++++++++++++++++++++- docs/ROADMAP.md | 10 +++---- docs/SESSION.md | 6 ++--- docs/TODO.md | 66 ++++++++++++++++++++++++++--------------------- 4 files changed, 87 insertions(+), 38 deletions(-) diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 3914684..f43948f 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -758,7 +758,9 @@ ## ADR-037 — La demo va en la propia web y el repositorio se publica después -- **Estado:** ✅ aceptada (2026-10-05) +- **Estado:** ✅ aceptada (2026-10-05) · 📌 usuarios demo y acceso revisados + por + [ADR-038](#adr-038--un-solo-admin-demo-restringido-y-la-instalación-local-con-mysql-en-docker) - **Fecha:** 2026-10-05 - **Decisión:** - `four-points.stackbp.es` es solo un escaparate: la demo pública va en esa @@ -776,3 +778,42 @@ - **Consecuencias:** si la web llega a usarse de verdad, la demo se separa entonces. Fase 5 antes de terminar la 4. - **Revisa:** ADR-035 en el momento de hacer público el repositorio. + +## ADR-038 — Un solo admin demo restringido y la instalación local con MySQL en Docker + +- **Estado:** ✅ aceptada (2026-10-05) +- **Fecha:** 2026-10-05 +- **Decisión:** + - **Un solo usuario demo, con rol admin**, en lugar de uno por rol: como + admin ve todos los módulos. Se entra con un botón «Probar la demo» en el + login, sin contraseña: el usuario tiene una aleatoria que nadie conoce, y el + botón y su ruta solo existen con `DEMO_MODE=true` en el backend. + - **Marca `is_demo`** en el usuario, que viaja en el token. Un middleware + bloquea solo lo prohibido: gestión de usuarios y contraseñas, el propio + perfil, departamentos, toda subida de ficheros (cualquier `multipart`), el + registro de la demo y la configuración de horarios (turnos, parámetros, + empleados y contratos). Generar horarios, crear meses y peticiones sí. El + rol `demo-admin` se retira. + - **Reinicio diario** con la primera entrada a la demo del día: recarga + `mock-data.sql` y restaura una foto de las tablas de horarios guardada en la + BD. + - **En local, admin completo**, sin modo demo. MySQL en Docker, opcional y + **solo MySQL** (un `docker-compose.yml`); backend y frontend arrancan con + pnpm como ahora. `pnpm setup:local` crea las tablas, el primer admin y los + departamentos, y carga el mock. +- **Motivo:** simplicidad, decisión del propietario. Un admin demo prueba toda + la app de una vez, y un botón no obliga a escribir nada. Con roles reales, + los permisos funcionan igual que para cualquier usuario y la restricción vive + en un solo sitio. Render gratuito se duerme, así que un cron a hora fija + puede no ejecutarse. Docker ahorra instalar MySQL; meter en él backend, + frontend y solver sería un segundo sistema que producción no usa. +- **Rechazado:** usuarios demo por rol y contraseña pública en el login + (ADR-037); lista blanca de escrituras con el rol `demo-admin`; reinicio por + GitHub Actions (credenciales de Aiven en GitHub y tareas programadas que se + desactivan tras 60 días sin actividad); la app entera en Docker. +- **Consecuencias:** CSRF sigue cubierto por `SameSite=Lax` y el rechazo de + orígenes ajenos; se añade un test. Para XSS se comprueba que un enlace + `javascript:` en las notas del checklist no se ejecuta. Límites nuevos para + el solver y las escrituras del usuario demo. El README pasa a explicar la + instalación local. +- **Revisa:** ADR-037 en los usuarios demo y su acceso. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 0696c35..c6ce631 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -21,7 +21,7 @@ cosa va a `TODO.md`. | 1c | «Antes de publicar» de `TODO.md` resuelto y probado en producción | 1b | | 2 | Clon con el historial limpio (dos ramas) + `docs/GITCLEAN.md` | 1c | | 4 | Repositorio público nuevo con el historial limpio (dos ramas) | 2 | -| 5 | Demo pública en un entorno aparte, con datos ficticios y reinicio diario | 4 | +| 5 | Demo pública en la propia web, con datos ficticios y reinicio diario | 4 | El orden lo fija ADR-001, revisado por ADR-022 (fase 1b) y ADR-027 (fase 1c, que además absorbe la antigua fase 3 de tareas pendientes). ADR-033 limita la 1c a lo @@ -63,7 +63,7 @@ rotación de credenciales (ADR-007) no bloquea: va en «Después de publicar» d ## Fase 5 — Demo pública Que cualquiera pruebe la aplicación en la propia web, que es solo un escaparate -(2026-10-05): usuarios demo públicos por rol, datos ficticios, generación de -horarios y reinicio diario. Reutiliza el rol `demo-admin`. Diseño y decisiones -pendientes en `TODO.md`, «Demo pública en la propia web». El repositorio se hace -público cuando la demo esté lista. +(ADR-037): un admin demo restringido que entra con un botón, datos ficticios, +generación de horarios y reinicio diario; y quien quiera verlo todo lo instala +en local con admin completo (ADR-038). Pasos en `TODO.md`, «Demo pública en la +propia web». El repositorio se hace público cuando la demo esté lista. diff --git a/docs/SESSION.md b/docs/SESSION.md index b8c4a4e..88b2d63 100644 --- a/docs/SESSION.md +++ b/docs/SESSION.md @@ -21,7 +21,7 @@ y auditado (`GITCLEAN.md`). El historial limpio está en `bpstack/four-points`, **todavía privado**. El original sigue privado como `four-points-archive`. Vercel y Render despliegan ya desde el nuevo, en producción y en preview (`VERIFY.md`, «Cambio al repositorio -nuevo»). Decisiones en `DECISIONS.md`, hasta ADR-036. +nuevo»). Decisiones en `DECISIONS.md`, hasta ADR-038. ## ⚠️ Empieza por aquí @@ -38,8 +38,8 @@ nuevo»). Decisiones en `DECISIONS.md`, hasta ADR-036. ## Siguiente -1. Planificar y construir la demo en la propia web: `TODO.md`, «Demo pública en - la propia web» (ADR-037). +1. Construir la demo en la propia web, paso a paso: `TODO.md`, «Demo pública + en la propia web» (ADR-037, ADR-038). Empieza por la marca `is_demo`. 2. Pasar `four-points` a público y activar secret scanning y push protection. 3. «Después de publicar» de `TODO.md`, empezando por la rotación de credenciales (se puede adelantar). diff --git a/docs/TODO.md b/docs/TODO.md index 5e37033..43c03bf 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -299,33 +299,41 @@ sigue siendo donde se arregla este fichero y se prueba en el entorno preview. pestaña muestra las rechazadas. Una factura incorrecta hoy se borra. El dueño decidió el 2026-10-04 no añadirlo porque casi nunca pasa; si se añade, hace falta también reabrir (`rejected` → `pending`). -- [ ] **Demo pública en la propia web** (fase 5 de `ROADMAP.md`) — la web es - solo un escaparate (decidido por el dueño el 2026-10-05), así que la demo - va en `four-points.stackbp.es` con su BD, sin subdominio ni BD aparte - como proponía el plan del 2026-10-04. Si algún día se usa de verdad, la - demo se separa entonces. - - **Usuarios demo públicos por rol** (admin, recepción, mantenimiento y - grupos), con la contraseña a la vista en el login. Las cuentas del - dueño (`admin`, `qa_*`) siguen aparte, con contraseñas solo suyas. - - **Acceso:** ven toda la app y pueden crear, editar y borrar, porque se - reinicia. Bloqueado: la configuración (crear, editar o borrar usuarios - y roles, y cambiar contraseñas), subir ficheros a Cloudinary (o carpeta - `demo/` vaciada cada noche) y límites de peticiones más estrictos. - - **Generar horarios:** abrir el solver al rol demo (hoy toda la ruta de - horarios es solo `admin`). Con un límite por usuario o IP y un tiempo - máximo: el solver carga OR-Tools en el plan gratuito de Render (512 MB) - y el semáforo solo lo hace secuencial. - - **Reinicio diario:** un cron vuelve a cargar `mock-data.sql` y también - horarios, que el mock no toca: borrar los meses creados por la demo y - restaurar una base fija de meses y asignaciones. Esa base se genera - con el solver; no se saca de los meses actuales, que vienen de bajas - reales. - - **Arranque en local:** documentar cómo crear el primer admin y los - departamentos antes del mock; `MASTER_INSTALL.sql` no crea ninguno. - - **Al hacerlo:** ADR que revisa la propuesta del subdominio y la fase 5 - de `ROADMAP.md`. El repositorio se hace público cuando la demo esté. -- [ ] **Reutilizar el rol `demo-admin` para la demo pública** (antes «retirarlo»; - ver «Demo pública» y la fase 5 de `ROADMAP.md`) — el único usuario con +- [ ] **Demo pública en la propia web** (fase 5 de `ROADMAP.md`, ADR-037 y + ADR-038) — la web es solo un escaparate, así que la demo va en + `four-points.stackbp.es` con su BD. Un paso por PR, probado en preview: + 1. **Marca `is_demo`** en `users` (migración en `scripts/`), en el token y + en un middleware que bloquea, para usuarios demo: `/api/users` (salvo + lecturas), `/api/auth/register`, `/api/auth/me/*`, escrituras en + `/api/departments`, toda petición `multipart`, `/api/demo-activity` y + la configuración de horarios (turnos, `config`, empleados y + contratos). Tests de cada bloqueo y de que el resto pasa. + 2. **Un solo usuario demo, rol admin**, con contraseña aleatoria que nadie + conoce. Ruta de entrada sin contraseña y botón «Probar la demo» en el + login, los dos solo con `DEMO_MODE=true`; la ruta con límite propio. + Las cuentas del dueño (`admin`, `qa_*`) siguen aparte. + 3. **Límites:** generar horarios unas 5 veces por hora por IP y usuario + (el solver ya tiene 30 s de máximo y va de uno en uno; Render + gratuito tiene 512 MB) y escrituras más estrictas para el usuario + demo. + 4. **Reinicio diario** con la primera entrada a la demo del día: recarga + `mock-data.sql` (fechas relativas a hoy; hay que poder ejecutarlo desde + Node, y hoy usa `DELIMITER`) y restaura una foto de las tablas de + horarios guardada en la BD. La base de horarios se genera una vez con + el solver; no se saca de los meses actuales, que vienen de bajas + reales. Ojo: el mock elige autores por rol y verá al usuario demo. + 5. **Instalación local con admin completo:** `docker-compose.yml` solo + con MySQL (opcional), `pnpm setup:local` (tablas con + `MASTER_INSTALL.sql`, primer admin con contraseña que da quien + instala, departamentos y mock) y README en inglés con los pasos y el + entorno de Python del solver. Comprobar antes que `MASTER_INSTALL.sql` + deja una BD igual que la actual. + 6. **Seguridad:** test de que un `POST` desde otro origen recibe 403 + (CSRF) y de que un enlace `javascript:` en las notas del checklist no + se ejecuta (XSS). + 7. **Retirar `demo-admin`** (ver entrada siguiente) y documentar. +- [ ] **Retirar el rol `demo-admin`** (lo sustituye la marca `is_demo`, + ADR-038) — el único usuario con ese rol, `demo`, está desactivado (`is_active = 0`, comprobado en Aiven el 2026-10-04). El rol sigue en los middlewares de `roleCheck.ts`, en `demoRestriction` (lista blanca de escrituras y registro de intentos @@ -333,8 +341,8 @@ sigue siendo donde se arregla este fichero y se prueba en el entorno preview. reglas añadidas el 2026-10-02 ya no lo incluyen. En producción, mientras exista, `demo-admin` entra en backoffice y lee los PDFs de facturas por `/pdf-url`, `/pdf-download` y el ZIP (antes un punto 🔴 aparte; se movió - aquí el 2026-10-04 porque no hay ninguna cuenta activa): en la demo vale, - en producción no debe haber cuentas con ese rol. + aquí el 2026-10-04 porque no hay ninguna cuenta activa). Retirarlo + incluye la migración que quita el usuario `demo` y el rol. - [ ] **Conciliation: `GET /api/conciliations` sin paginar ni filtrar** — devuelve todo el histórico a cualquier rol con acceso. _Según la revisión `security` L3 del 2026-09-28 (fichero y línea en el informe); no repasado From 5789e8350ec251b694088f13065207b431d1ff28 Mon Sep 17 00:00:00 2001 From: razyd Date: Mon, 5 Oct 2026 15:12:39 +0200 Subject: [PATCH 03/20] feat(auth): restrict the public demo account with an is_demo flag - users.is_demo column (idempotent migration, logged in INDEX.md) - login and refresh put a demo claim in the token; authenticateToken exposes it as req.user.isDemo (the old demo-admin role counts as demo) - demoRestriction blocks every multipart upload for the demo account; new denyDemo and denyDemoWrites guards on the routes it must not touch: user management, register, own profile/password/avatar, departments, the demo activity log, backoffice categories and the scheduling configuration (config, shifts, rules, employees, contracts) - tests for the guards, the token claim and a source check that keeps every guard on its route Why: the demo account is a real admin so the app works for it as for anyone, and only what could break the app, touch other accounts or survive the daily reset is blocked (ADR-038). Guards live on the routes, so they do not depend on how the URL is written. --- backend/controllers/auth/auth-controllers.ts | 2 + backend/db-mysql/INDEX.md | 1 + .../scripts/20261005_add_is_demo_to_users.sql | 34 +++++++ backend/middlewares/authenticateToken.ts | 2 + backend/middlewares/demoRestriction.ts | 88 ++++++++++++------- backend/models/auth/index.ts | 4 + backend/repositories/auth/user-repository.ts | 2 + backend/routes/auth/auth-routes.ts | 18 ++-- backend/routes/auth/user-routes.ts | 3 + .../routes/backoffice/backoffice-routes.ts | 4 +- backend/routes/demo/demo-activity-routes.ts | 3 + .../routes/departments/departments-routes.ts | 3 + .../routes/scheduling/scheduling-routes.ts | 5 ++ backend/services/auth/tokenService.ts | 3 + backend/tests/auth/demo-restriction.test.ts | 87 +++++++++++++++++- backend/tests/auth/token-service.test.ts | 14 +++ backend/tests/security/demo-guards.test.ts | 68 ++++++++++++++ backend/types/express.d.ts | 2 + 18 files changed, 305 insertions(+), 38 deletions(-) create mode 100644 backend/db-mysql/scripts/20261005_add_is_demo_to_users.sql create mode 100644 backend/tests/security/demo-guards.test.ts diff --git a/backend/controllers/auth/auth-controllers.ts b/backend/controllers/auth/auth-controllers.ts index 1915b68..5ccca85 100644 --- a/backend/controllers/auth/auth-controllers.ts +++ b/backend/controllers/auth/auth-controllers.ts @@ -86,6 +86,7 @@ export const login = async (req: Request, res: Response): Promise => { id: user.id, username: user.username, role: user.role, + demo: Boolean(user.is_demo), type: 'access', } @@ -213,6 +214,7 @@ export const refreshToken = async (req: Request, res: Response): Promise = id: user.id, username: user.username, role: user.role, + demo: Boolean(user.is_demo), type: 'access', } diff --git a/backend/db-mysql/INDEX.md b/backend/db-mysql/INDEX.md index 4afcee8..ca50ccf 100644 --- a/backend/db-mysql/INDEX.md +++ b/backend/db-mysql/INDEX.md @@ -104,6 +104,7 @@ db-mysql/ | 2026-09-29 | `20260929_fix_cashier_daily_trigger.sql` | Recrea `trg_cashier_shift_update_daily`: separa el cálculo de `income` (suma directa de `cashier_shifts`) del de pagos electrónicos (subquery agregado), eliminando la inflación por LEFT JOIN. No toca esquema; solo DDL del trigger. Aplicado manualmente en Aiven + recálculo de las 10 filas de `cashier_daily` desde origen. | ✅ local · ✅ Aiven | | 2026-10-04 | `20261004_add_messages_to_notifications_module.sql` | Añade `messages` al ENUM `notifications.module`. Desde `9be16b1` el aviso de mensaje urgente se guarda con ese módulo y el INSERT fallaba en silencio («Data truncated»). Solo cambia la columna si tiene la definición anterior exacta. En local ya estaba (BD montada desde el baseline); en Aiven se aplicó el 2026-10-04. | ✅ local · ✅ Aiven | | 2026-10-04 | `20261004_fix_parking_availability_triggers.sql` | Recrea `trg_free_availability_on_status_change` (solo libera los días de la propia reserva) y `trg_update_availability_on_date_change` (solo ocupa días libres o propios). Antes completar, cancelar o mover una reserva podía liberar u ocupar días de otra. La comprobación de solapes está en `bookings.repository.ts`. Local tenía otro cuerpo de `date_change`; se parte del de Aiven. | ⏳ local · ✅ Aiven | +| 2026-10-05 | `20261005_add_is_demo_to_users.sql` | Añade `users.is_demo` (0 por defecto): marca la cuenta demo pública, que el backend lleva en el token y `demoRestriction` limita (ADR-038). Hay que aplicarla **antes** de desplegar el código: el login lee la columna. Solo añade la columna si no existe. | ✅ local · ⏳ Aiven | ### Verificación (99) diff --git a/backend/db-mysql/scripts/20261005_add_is_demo_to_users.sql b/backend/db-mysql/scripts/20261005_add_is_demo_to_users.sql new file mode 100644 index 0000000..cb21cba --- /dev/null +++ b/backend/db-mysql/scripts/20261005_add_is_demo_to_users.sql @@ -0,0 +1,34 @@ +-- ============================================================================= +-- 20261005_add_is_demo_to_users.sql +-- ============================================================================= +-- Adds `users.is_demo`: marks the public demo account. The backend puts it in +-- the token, and middlewares/demoRestriction.ts blocks that account from user +-- management, its own profile, departments, file uploads, the demo log and the +-- scheduling configuration. Every existing user keeps 0. +-- +-- Must run BEFORE deploying the code that reads it: login selects u.is_demo. +-- +-- Idempotent: adds the column only when it does not exist. +-- +-- Applies to: LOCAL + AIVEN. +-- Rollback: ALTER TABLE users DROP COLUMN is_demo; +-- (only after the code that reads it is gone) +-- ============================================================================= + +SET @has_col := ( + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'users' + AND COLUMN_NAME = 'is_demo' +); + +SET @ddl := IF( + @has_col = 0, + 'ALTER TABLE users ADD COLUMN is_demo TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''Public demo account: restricted by demoRestriction'' AFTER is_active', + 'SELECT ''users.is_demo already exists, skipping'' AS info' +); + +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/backend/middlewares/authenticateToken.ts b/backend/middlewares/authenticateToken.ts index cf990bb..94cdafb 100644 --- a/backend/middlewares/authenticateToken.ts +++ b/backend/middlewares/authenticateToken.ts @@ -56,6 +56,8 @@ export function authenticateToken(req: Request, res: Response, next: NextFunctio username: decoded.username, email: '', // No está en el token, se puede obtener de la BD si es necesario role: decoded.role, + // demo-admin is the old demo role, restricted the same way until it is retired + isDemo: decoded.demo === true || decoded.role === 'demo-admin', } // ✅ DEMO: Verificar restricciones de usuario demo diff --git a/backend/middlewares/demoRestriction.ts b/backend/middlewares/demoRestriction.ts index 1c8783d..4b2cade 100644 --- a/backend/middlewares/demoRestriction.ts +++ b/backend/middlewares/demoRestriction.ts @@ -1,19 +1,20 @@ // middlewares/demoRestriction.ts /** - * Middleware para restringir acciones de escritura a usuarios demo. + * Restricciones de la cuenta demo pública (users.is_demo, ADR-038). * - * El usuario demo puede VER todo (igual que admin), pero solo puede - * hacer escrituras específicas (whitelist). + * La cuenta demo es un admin: ve y cambia todo lo que se reinicia cada día. + * Se le bloquea lo que podría romper la app o tocar otras cuentas: + * - en todas las rutas: subir ficheros (demoRestriction) + * - en cada ruta sensible: denyDemo o denyDemoWrites * - * DISEÑO NO INVASIVO: Fácil de eliminar - solo quitar este archivo - * y la línea en index.ts que lo importa. + * El rol demo-admin (antiguo) mantiene su lista blanca hasta que se retire. */ import { Request, Response, NextFunction } from 'express' import { DemoActivityRepository } from '../repositories/demo/demo-activity-repository.js' import { logger } from '../config/logger.js' -// Rol del usuario demo +// Rol demo antiguo, con su lista blanca de escrituras const DEMO_ROLE = 'demo-admin' /** @@ -83,44 +84,69 @@ function logBlockedAttempt( }) } +function block(req: Request, res: Response): void { + logBlockedAttempt(req, req.user?.id, req.user?.username || 'demo-user', req.originalUrl) + + res.status(403).json({ + success: false, + error: + 'Acción no disponible en modo demo. Esta es una cuenta de demostración con funcionalidad limitada.', + demo: true, + }) +} + +// Uploads go to Cloudinary and the daily reset does not clean them; checking the +// header blocks every upload route before multer reads the body +function isMultipart(req: Request): boolean { + return String(req.headers['content-type'] || '') + .toLowerCase() + .includes('multipart') +} + /** - * Middleware que restringe escrituras para usuarios demo. + * Runs inside authenticateToken, on every authenticated request. * - * - GET requests: siempre permitidos - * - POST/PUT/PATCH/DELETE: solo si están en whitelist + * - demo-admin (old demo role): only the whitelisted writes + * - demo account (users.is_demo): no file uploads; the rest is limited per + * route with denyDemo and denyDemoWrites */ export function demoRestriction(req: Request, res: Response, next: NextFunction): void { - // Si no hay usuario autenticado, dejar pasar (authenticateToken ya lo manejará) - if (!req.user) { - next() + if (req.user?.role === DEMO_ROLE) { + if (req.method === 'GET' || isAllowedForDemo(req.method, req.originalUrl)) { + next() + return + } + block(req, res) return } - // Si no es usuario demo, dejar pasar sin restricciones - if (req.user.role !== DEMO_ROLE) { - next() + if (req.user?.isDemo && isMultipart(req)) { + block(req, res) return } - // GET siempre permitido para demo (puede ver todo) - if (req.method === 'GET') { - next() + next() +} + +/** + * Blocks the demo account from a route entirely (reads too). Declared on the + * route itself, so it does not depend on how the URL is written. + */ +export function denyDemo(req: Request, res: Response, next: NextFunction): void { + if (req.user?.isDemo) { + block(req, res) return } + next() +} - // Verificar si la ruta está en whitelist (usar originalUrl para ruta completa) - if (isAllowedForDemo(req.method, req.originalUrl)) { - next() +/** + * Lets the demo account read a route but not change anything through it. + */ +export function denyDemoWrites(req: Request, res: Response, next: NextFunction): void { + if (req.user?.isDemo && !['GET', 'HEAD', 'OPTIONS'].includes(req.method)) { + block(req, res) return } - - // Bloquear y registrar en BD - logBlockedAttempt(req, req.user.id, req.user.username || 'demo-user', req.originalUrl) - - res.status(403).json({ - success: false, - error: - 'Acción no disponible en modo demo. Esta es una cuenta de demostración con funcionalidad limitada.', - demo: true, - }) + next() } diff --git a/backend/models/auth/index.ts b/backend/models/auth/index.ts index 9ecc123..c7b0db0 100644 --- a/backend/models/auth/index.ts +++ b/backend/models/auth/index.ts @@ -13,6 +13,7 @@ export interface UserWithRole extends RowDataPacket { password?: string role: string is_active: number | boolean + is_demo?: number | boolean created_at: Date updated_at?: Date | null avatar_url?: string | null @@ -34,6 +35,7 @@ export interface User { email: string role: string is_active: number | boolean + is_demo?: number | boolean created_at: Date updated_at?: Date | null avatar_url?: string | null @@ -79,6 +81,8 @@ export interface TokenPayload { id: string username: string role: string + // Public demo account (users.is_demo); absent for everyone else + demo?: boolean type: 'access' | 'refresh' iat?: number exp?: number diff --git a/backend/repositories/auth/user-repository.ts b/backend/repositories/auth/user-repository.ts index 813f53a..17f186c 100644 --- a/backend/repositories/auth/user-repository.ts +++ b/backend/repositories/auth/user-repository.ts @@ -141,6 +141,7 @@ export class UserRepository { u.email, u.password, u.is_active, + u.is_demo, u.created_at, u.avatar_url, r.name AS role @@ -228,6 +229,7 @@ export class UserRepository { u.email, u.created_at, u.is_active, + u.is_demo, u.updated_at, u.avatar_url, r.name AS role diff --git a/backend/routes/auth/auth-routes.ts b/backend/routes/auth/auth-routes.ts index 9cedae2..0af9b31 100644 --- a/backend/routes/auth/auth-routes.ts +++ b/backend/routes/auth/auth-routes.ts @@ -13,6 +13,7 @@ import { deleteAvatar, } from '../../controllers/auth/auth-controllers.js' import { authenticateToken } from '../../middlewares/authenticateToken.js' +import { denyDemo } from '../../middlewares/demoRestriction.js' import { singleImage } from '../../middlewares/imageUpload.js' import { loginLimiter, @@ -39,7 +40,7 @@ router.post('/logout', logout) // ======================================== // Solo admins reales pueden crear usuarios (no demo-admin) -router.post('/register', authenticateToken, isRealAdmin, register) +router.post('/register', authenticateToken, denyDemo, isRealAdmin, register) // ======================================== // PROTECTED ROUTES (require authentication) @@ -48,11 +49,18 @@ router.post('/register', authenticateToken, isRealAdmin, register) router.get('/me', authenticateToken, me) // Profile management (with rate limiting) -router.patch('/me/profile', authenticateToken, profileUpdateLimiter, updateProfile) -router.patch('/me/password', authenticateToken, passwordChangeLimiter, updatePassword) +// The demo account cannot change its username, password or avatar (it is shared) +router.patch('/me/profile', authenticateToken, denyDemo, profileUpdateLimiter, updateProfile) +router.patch('/me/password', authenticateToken, denyDemo, passwordChangeLimiter, updatePassword) // Avatar management -router.post('/me/avatar', authenticateToken, singleImage('avatar', 2 * 1024 * 1024), uploadAvatar) -router.delete('/me/avatar', authenticateToken, deleteAvatar) +router.post( + '/me/avatar', + authenticateToken, + denyDemo, + singleImage('avatar', 2 * 1024 * 1024), + uploadAvatar +) +router.delete('/me/avatar', authenticateToken, denyDemo, deleteAvatar) export default router diff --git a/backend/routes/auth/user-routes.ts b/backend/routes/auth/user-routes.ts index 1979f35..b29a26b 100644 --- a/backend/routes/auth/user-routes.ts +++ b/backend/routes/auth/user-routes.ts @@ -12,6 +12,7 @@ import { import { authenticateToken } from '../../middlewares/authenticateToken.js' import { isAdmin, isOwnerOrAdmin, isRealAdmin } from '../../middlewares/roleCheck.js' +import { denyDemoWrites } from '../../middlewares/demoRestriction.js' const router: Router = express.Router() @@ -19,6 +20,8 @@ const router: Router = express.Router() // ALL routes require authentication // ======================================== router.use(authenticateToken) +// The demo account sees users but cannot create, edit, delete or reset them +router.use(denyDemoWrites) // ======================================== // ADMIN ONLY ROUTES diff --git a/backend/routes/backoffice/backoffice-routes.ts b/backend/routes/backoffice/backoffice-routes.ts index 660e81a..6f4c9c1 100644 --- a/backend/routes/backoffice/backoffice-routes.ts +++ b/backend/routes/backoffice/backoffice-routes.ts @@ -13,6 +13,7 @@ import multer from 'multer' import { BackofficeController } from '../../controllers/backoffice/backoffice-controller.js' import { authenticateToken } from '../../middlewares/authenticateToken.js' import { canAccessBackoffice, isRealAdmin } from '../../middlewares/roleCheck.js' +import { denyDemo } from '../../middlewares/demoRestriction.js' const router = Router() @@ -70,7 +71,8 @@ router.get('/categories/:id', BackofficeController.getCategoryById) * @desc Crear nueva categoría * @access Private (admin only) */ -router.post('/categories', isRealAdmin, BackofficeController.createCategory) +// Categories are a catalog the daily reset keeps: the demo account cannot add them +router.post('/categories', isRealAdmin, denyDemo, BackofficeController.createCategory) // ======================================== // RUTAS DE PROVEEDORES diff --git a/backend/routes/demo/demo-activity-routes.ts b/backend/routes/demo/demo-activity-routes.ts index 6662823..1a2aca7 100644 --- a/backend/routes/demo/demo-activity-routes.ts +++ b/backend/routes/demo/demo-activity-routes.ts @@ -8,11 +8,14 @@ import { Router } from 'express' import { DemoActivityController } from '../../controllers/demo/demo-activity-controller.js' import { authenticateToken } from '../../middlewares/authenticateToken.js' import { isRealAdmin } from '../../middlewares/roleCheck.js' +import { denyDemo } from '../../middlewares/demoRestriction.js' const router = Router() // Aplicar autenticación y verificar que es admin real router.use(authenticateToken) +// The log holds visitors' IPs: never for the demo account, which is an admin +router.use(denyDemo) router.use(isRealAdmin) // ======================================== diff --git a/backend/routes/departments/departments-routes.ts b/backend/routes/departments/departments-routes.ts index c8a722d..7e13062 100644 --- a/backend/routes/departments/departments-routes.ts +++ b/backend/routes/departments/departments-routes.ts @@ -11,6 +11,7 @@ import { import { authenticateToken } from '../../middlewares/authenticateToken.js' import { isAdmin, excludeMantenimiento } from '../../middlewares/roleCheck.js' +import { denyDemoWrites } from '../../middlewares/demoRestriction.js' const router = Router() @@ -20,6 +21,8 @@ const router = Router() // ======================================== router.use(authenticateToken) router.use(excludeMantenimiento) +// Departments are configuration: the demo account only reads them +router.use(denyDemoWrites) // ======================================== // RUTAS diff --git a/backend/routes/scheduling/scheduling-routes.ts b/backend/routes/scheduling/scheduling-routes.ts index 364d43a..da6c10a 100644 --- a/backend/routes/scheduling/scheduling-routes.ts +++ b/backend/routes/scheduling/scheduling-routes.ts @@ -73,6 +73,7 @@ import { generateSchedule } from '../../controllers/scheduling/schedule-generate // Middlewares import { authenticateToken } from '../../middlewares/authenticateToken.js' import { isAdmin } from '../../middlewares/roleCheck.js' +import { denyDemoWrites } from '../../middlewares/demoRestriction.js' const router: Router = express.Router() @@ -87,6 +88,10 @@ const router: Router = express.Router() router.use(authenticateToken) router.use(isAdmin) +// The demo account cannot change the configuration; months, assignments, +// requests and generating stay open, and are reset daily (ADR-038) +router.use(['/config', '/shifts', '/rules', '/employees', '/contracts'], denyDemoWrites) + // ======================================== // CONFIG ROUTES // ======================================== diff --git a/backend/services/auth/tokenService.ts b/backend/services/auth/tokenService.ts index 5ee8ee6..d0561a2 100644 --- a/backend/services/auth/tokenService.ts +++ b/backend/services/auth/tokenService.ts @@ -14,6 +14,7 @@ interface UserForToken { id: string username?: string role?: string + demo?: boolean type?: 'access' | 'refresh' } @@ -29,6 +30,7 @@ export function generateAccessToken(user: UserForToken): string { id: user.id, username: user.username || '', role: user.role || 'user', + ...(user.demo ? { demo: true } : {}), type: 'access', } @@ -50,6 +52,7 @@ export function generateRefreshToken(user: UserForToken): string { id: user.id, username: user.username || '', role: user.role || 'user', + ...(user.demo ? { demo: true } : {}), type: 'refresh', } diff --git a/backend/tests/auth/demo-restriction.test.ts b/backend/tests/auth/demo-restriction.test.ts index c1ae01a..b686f7a 100644 --- a/backend/tests/auth/demo-restriction.test.ts +++ b/backend/tests/auth/demo-restriction.test.ts @@ -13,7 +13,8 @@ vi.mock('../../repositories/demo/demo-activity-repository.js', () => ({ }, })) -const { demoRestriction } = await import('../../middlewares/demoRestriction.js') +const { demoRestriction, denyDemo, denyDemoWrites } = + await import('../../middlewares/demoRestriction.js') const { DemoActivityRepository } = await import('../../repositories/demo/demo-activity-repository.js') @@ -21,6 +22,7 @@ function buildReq(overrides: Partial = {}): Request { return { method: 'GET', originalUrl: '/', + headers: {}, body: {}, ip: '127.0.0.1', socket: { remoteAddress: '127.0.0.1' } as any, @@ -162,3 +164,86 @@ describe('demoRestriction — blocked-attempt log', () => { }) }) }) + +// The public demo account (users.is_demo) is a real admin: only what is +// declared here or on the route is blocked +const DEMO_USER = { id: 'd1', username: 'demo', role: 'admin', isDemo: true } as any +const OWNER = { id: 'a1', username: 'admin', role: 'admin' } as any + +describe('demoRestriction — demo account (is_demo)', () => { + it.each(['multipart/form-data; boundary=x', 'Multipart/Form-Data', 'multipart/mixed'])( + 'blocks any upload (content-type %s)', + (contentType) => { + const req = buildReq({ + user: DEMO_USER, + method: 'POST', + originalUrl: '/api/maintenance/1/images', + headers: { 'content-type': contentType } as any, + }) + const res = buildRes() + demoRestriction(req, res, next) + expect(next).not.toHaveBeenCalled() + expect(res.status).toHaveBeenCalledWith(403) + expect(DemoActivityRepository.logActivity).toHaveBeenCalledOnce() + } + ) + + it('lets JSON writes through (the route decides)', () => { + const req = buildReq({ + user: DEMO_USER, + method: 'DELETE', + originalUrl: '/api/parking/bookings/ABC', + headers: { 'content-type': 'application/json' } as any, + }) + const res = buildRes() + demoRestriction(req, res, next) + expect(next).toHaveBeenCalledOnce() + }) + + it('lets the owner upload', () => { + const req = buildReq({ + user: OWNER, + method: 'POST', + headers: { 'content-type': 'multipart/form-data; boundary=x' } as any, + }) + const res = buildRes() + demoRestriction(req, res, next) + expect(next).toHaveBeenCalledOnce() + }) +}) + +describe('denyDemo', () => { + it.each(['GET', 'POST', 'DELETE'])('blocks the demo account on %s', (method) => { + const res = buildRes() + denyDemo(buildReq({ user: DEMO_USER, method }), res, next) + expect(next).not.toHaveBeenCalled() + expect(res.status).toHaveBeenCalledWith(403) + }) + + it('lets the owner through', () => { + const res = buildRes() + denyDemo(buildReq({ user: OWNER, method: 'POST' }), res, next) + expect(next).toHaveBeenCalledOnce() + }) +}) + +describe('denyDemoWrites', () => { + it.each(['GET', 'HEAD', 'OPTIONS'])('lets the demo account %s', (method) => { + const res = buildRes() + denyDemoWrites(buildReq({ user: DEMO_USER, method }), res, next) + expect(next).toHaveBeenCalledOnce() + }) + + it.each(['POST', 'PUT', 'PATCH', 'DELETE'])('blocks the demo account on %s', (method) => { + const res = buildRes() + denyDemoWrites(buildReq({ user: DEMO_USER, method }), res, next) + expect(next).not.toHaveBeenCalled() + expect(res.status).toHaveBeenCalledWith(403) + }) + + it('lets the owner write', () => { + const res = buildRes() + denyDemoWrites(buildReq({ user: OWNER, method: 'DELETE' }), res, next) + expect(next).toHaveBeenCalledOnce() + }) +}) diff --git a/backend/tests/auth/token-service.test.ts b/backend/tests/auth/token-service.test.ts index 1ab2cf2..1d83597 100644 --- a/backend/tests/auth/token-service.test.ts +++ b/backend/tests/auth/token-service.test.ts @@ -73,3 +73,17 @@ describe('tokenService — error cases', () => { expect(() => verifyToken(expired)).toThrow(/expirado/i) }) }) + +describe('tokenService — demo claim', () => { + it('marks the demo account in both tokens', () => { + const user = { ...VALID_USER, demo: true } + expect(verifyToken(generateAccessToken(user)).demo).toBe(true) + expect(verifyToken(generateRefreshToken(user)).demo).toBe(true) + }) + + it('leaves the claim out for everyone else', () => { + for (const user of [VALID_USER, { ...VALID_USER, demo: false }]) { + expect(verifyToken(generateAccessToken(user))).not.toHaveProperty('demo') + } + }) +}) diff --git a/backend/tests/security/demo-guards.test.ts b/backend/tests/security/demo-guards.test.ts new file mode 100644 index 0000000..eb927af --- /dev/null +++ b/backend/tests/security/demo-guards.test.ts @@ -0,0 +1,68 @@ +// tests/security/demo-guards.test.ts +// The public demo account is an admin (ADR-038): what it must not touch is +// guarded on each route with denyDemo or denyDemoWrites. Locks those guards so +// a refactor cannot drop one silently. Reads the sources; no database needed. + +import { describe, it, expect } from 'vitest' +import { readFileSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +const routes = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'routes') +const read = (file: string) => readFileSync(join(routes, file), 'utf8') + +// Whole routers: the guard runs on every route right after authentication +const routerGuards: Array<[string, string]> = [ + ['auth/user-routes.ts', 'denyDemoWrites'], + ['departments/departments-routes.ts', 'denyDemoWrites'], + ['demo/demo-activity-routes.ts', 'denyDemo'], +] + +// Scheduling configuration: reads stay open, writes are blocked +const schedulingConfig = ['/config', '/shifts', '/rules', '/employees', '/contracts'] + +// Single routes, as method and path +const routeGuards: Array<[string, string, string]> = [ + ['auth/auth-routes.ts', 'post', '/register'], + ['auth/auth-routes.ts', 'patch', '/me/profile'], + ['auth/auth-routes.ts', 'patch', '/me/password'], + ['auth/auth-routes.ts', 'post', '/me/avatar'], + ['auth/auth-routes.ts', 'delete', '/me/avatar'], + ['backoffice/backoffice-routes.ts', 'post', '/categories'], +] + +// The whole `router.method('path', ...)` statement, also when Prettier splits it +function statement(source: string, method: string, path: string): string | undefined { + return source.split(/\n(?=router\.)/).find( + (s) => + s.startsWith(`router.${method}(`) && + s + .slice(method.length + 8) + .trimStart() + .startsWith(`'${path}'`) + ) +} + +describe('demo account guards', () => { + it.each(routerGuards)('%s runs %s after authenticateToken', (file, guard) => { + const source = read(file) + const auth = source.indexOf('router.use(authenticateToken)') + const guarded = source.indexOf(`router.use(${guard})`) + expect(auth).toBeGreaterThanOrEqual(0) + expect(guarded).toBeGreaterThan(auth) + }) + + it('scheduling blocks writes to its configuration after isAdmin', () => { + const source = read('scheduling/scheduling-routes.ts') + const line = source.split('\n').find((l) => l.includes('denyDemoWrites)')) + expect(line).toBeDefined() + for (const path of schedulingConfig) expect(line).toContain(`'${path}'`) + expect(source.indexOf(line!)).toBeGreaterThan(source.indexOf('router.use(isAdmin)')) + }) + + it.each(routeGuards)('%s: %s %s carries denyDemo', (file, method, path) => { + const found = statement(read(file), method, path) + expect(found, `${method} ${path} not found`).toBeDefined() + expect(found).toMatch(/\bdenyDemo\b/) + }) +}) diff --git a/backend/types/express.d.ts b/backend/types/express.d.ts index 879bc74..be23b0e 100644 --- a/backend/types/express.d.ts +++ b/backend/types/express.d.ts @@ -8,6 +8,8 @@ declare global { username: string email: string role: string + // Public demo account, restricted by demoRestriction + isDemo?: boolean } } } From a306d3674b052a333baadaff3a450f39c555d1d0 Mon Sep 17 00:00:00 2001 From: razyd Date: Mon, 5 Oct 2026 15:15:19 +0200 Subject: [PATCH 04/20] docs(db): mark the is_demo migration as applied on Aiven - INDEX.md: 20261005_add_is_demo_to_users.sql applied on Aiven Why: the column has to exist before the code that reads it reaches preview. --- backend/db-mysql/INDEX.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/db-mysql/INDEX.md b/backend/db-mysql/INDEX.md index ca50ccf..69ab3a7 100644 --- a/backend/db-mysql/INDEX.md +++ b/backend/db-mysql/INDEX.md @@ -104,7 +104,7 @@ db-mysql/ | 2026-09-29 | `20260929_fix_cashier_daily_trigger.sql` | Recrea `trg_cashier_shift_update_daily`: separa el cálculo de `income` (suma directa de `cashier_shifts`) del de pagos electrónicos (subquery agregado), eliminando la inflación por LEFT JOIN. No toca esquema; solo DDL del trigger. Aplicado manualmente en Aiven + recálculo de las 10 filas de `cashier_daily` desde origen. | ✅ local · ✅ Aiven | | 2026-10-04 | `20261004_add_messages_to_notifications_module.sql` | Añade `messages` al ENUM `notifications.module`. Desde `9be16b1` el aviso de mensaje urgente se guarda con ese módulo y el INSERT fallaba en silencio («Data truncated»). Solo cambia la columna si tiene la definición anterior exacta. En local ya estaba (BD montada desde el baseline); en Aiven se aplicó el 2026-10-04. | ✅ local · ✅ Aiven | | 2026-10-04 | `20261004_fix_parking_availability_triggers.sql` | Recrea `trg_free_availability_on_status_change` (solo libera los días de la propia reserva) y `trg_update_availability_on_date_change` (solo ocupa días libres o propios). Antes completar, cancelar o mover una reserva podía liberar u ocupar días de otra. La comprobación de solapes está en `bookings.repository.ts`. Local tenía otro cuerpo de `date_change`; se parte del de Aiven. | ⏳ local · ✅ Aiven | -| 2026-10-05 | `20261005_add_is_demo_to_users.sql` | Añade `users.is_demo` (0 por defecto): marca la cuenta demo pública, que el backend lleva en el token y `demoRestriction` limita (ADR-038). Hay que aplicarla **antes** de desplegar el código: el login lee la columna. Solo añade la columna si no existe. | ✅ local · ⏳ Aiven | +| 2026-10-05 | `20261005_add_is_demo_to_users.sql` | Añade `users.is_demo` (0 por defecto): marca la cuenta demo pública, que el backend lleva en el token y `demoRestriction` limita (ADR-038). Hay que aplicarla **antes** de desplegar el código: el login lee la columna. Solo añade la columna si no existe. En Aiven se aplicó el 2026-10-05, antes del código. | ✅ local · ✅ Aiven | ### Verificación (99) From 85539636c166535be378bd49e67a8ab0a888f3e0 Mon Sep 17 00:00:00 2001 From: razyd Date: Mon, 5 Oct 2026 15:26:24 +0200 Subject: [PATCH 05/20] fix(auth): answer 403 to blocked demo requests without a JSON body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - demoRestriction: the blocked-attempt log no longer throws when req.body is undefined (Express 5 leaves it so for DELETE and uploads) - authenticateToken: demoRestriction runs after the token try/catch - tests for blocking with no body and for an upload before multer Why: logging a blocked DELETE or upload threw, so the request got a 500, or a 401 "Token inválido" when it happened inside the token check. The request was never executed, but the answer was wrong. --- backend/middlewares/authenticateToken.ts | 8 +++---- backend/middlewares/demoRestriction.ts | 4 +++- backend/tests/auth/demo-restriction.test.ts | 23 +++++++++++++++++++++ 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/backend/middlewares/authenticateToken.ts b/backend/middlewares/authenticateToken.ts index 94cdafb..976571c 100644 --- a/backend/middlewares/authenticateToken.ts +++ b/backend/middlewares/authenticateToken.ts @@ -59,10 +59,6 @@ export function authenticateToken(req: Request, res: Response, next: NextFunctio // demo-admin is the old demo role, restricted the same way until it is retired isDemo: decoded.demo === true || decoded.role === 'demo-admin', } - - // ✅ DEMO: Verificar restricciones de usuario demo - // Para eliminar: quitar esta línea y el import de demoRestriction - demoRestriction(req, res, next) } catch (error) { // Diferencia entre token expirado y token inválido let message = 'Token inválido' @@ -76,5 +72,9 @@ export function authenticateToken(req: Request, res: Response, next: NextFunctio } res.status(401).json({ error: message }) + return } + + // Demo account restrictions, outside the try: an error here is not a bad token + demoRestriction(req, res, next) } diff --git a/backend/middlewares/demoRestriction.ts b/backend/middlewares/demoRestriction.ts index 4b2cade..88a838d 100644 --- a/backend/middlewares/demoRestriction.ts +++ b/backend/middlewares/demoRestriction.ts @@ -54,7 +54,9 @@ function isAllowedForDemo(method: string, originalUrl: string): boolean { const SENSITIVE_FIELDS = new Set(['password', 'currentPassword', 'newPassword', 'confirmPassword']) function sanitizeBodyForLog(body: unknown): string { - if (!body || typeof body !== 'object') return JSON.stringify(body).substring(0, 500) + // Express 5 leaves req.body undefined without a JSON body (DELETE, uploads) + if (!body || typeof body !== 'object') + return String(JSON.stringify(body ?? null)).substring(0, 500) const cleaned: Record = {} for (const [k, v] of Object.entries(body as Record)) { cleaned[k] = SENSITIVE_FIELDS.has(k) ? '[REDACTED]' : v diff --git a/backend/tests/auth/demo-restriction.test.ts b/backend/tests/auth/demo-restriction.test.ts index b686f7a..1c6c0c8 100644 --- a/backend/tests/auth/demo-restriction.test.ts +++ b/backend/tests/auth/demo-restriction.test.ts @@ -247,3 +247,26 @@ describe('denyDemoWrites', () => { expect(next).toHaveBeenCalledOnce() }) }) + +describe('blocking a request without a JSON body', () => { + it.each(['DELETE', 'POST'])('answers 403 on %s with req.body undefined', (method) => { + const res = buildRes() + denyDemo(buildReq({ user: DEMO_USER, method, body: undefined }), res, next) + expect(res.status).toHaveBeenCalledWith(403) + expect(DemoActivityRepository.logActivity).toHaveBeenCalledWith( + expect.objectContaining({ body_preview: 'null' }) + ) + }) + + it('answers 403 on an upload before multer reads the body', () => { + const req = buildReq({ + user: DEMO_USER, + method: 'POST', + body: undefined, + headers: { 'content-type': 'multipart/form-data; boundary=x' } as any, + }) + const res = buildRes() + demoRestriction(req, res, next) + expect(res.status).toHaveBeenCalledWith(403) + }) +}) From 7507d58678dd3090804b9c0c3b1a255c0eeeba38 Mon Sep 17 00:00:00 2001 From: razyd Date: Mon, 5 Oct 2026 15:26:24 +0200 Subject: [PATCH 06/20] feat(auth): enter the public demo with one button, without a password - POST /api/auth/demo: only with DEMO_MODE=true (404 otherwise), own rate limit per IP, signs in the active is_demo account with demo tokens - password login rejects the demo account, even with the right password - scripts/create-demo-user.ts (pnpm demo:user, demo:user:aiven): creates or converts the `demo` user, admin role, is_demo, random unknown password - login page: "Prueba nuestra demo" button and hint, shown only with NEXT_PUBLIC_DEMO_MODE=true; useAuth.loginDemo shares the sign-in flow - DEMO_MODE and NEXT_PUBLIC_DEMO_MODE in both .env.example - tests for the route, its tokens and the password login Why: visitors try the whole app with one click (ADR-038); with no public password there is nothing to change or leak, and the entry exists only where the demo is switched on. --- backend/.env.example | 2 + backend/config/error-codes.ts | 1 + backend/controllers/auth/auth-controllers.ts | 46 ++++++++++ backend/middlewares/rateLimiter.ts | 20 +++++ backend/package.json | 2 + backend/repositories/auth/user-repository.ts | 32 +++++++ backend/routes/auth/auth-routes.ts | 4 + backend/scripts/create-demo-user.ts | 67 +++++++++++++++ backend/tests/auth/demo-login.test.ts | 83 +++++++++++++++++++ .../tests/auth/demo-password-login.test.ts | 34 ++++++++ frontend/.env.example | 3 + frontend/app/(auth)/login/page.tsx | 27 +++++- frontend/app/lib/auth/authService.ts | 12 +++ frontend/app/lib/auth/useAuth.tsx | 29 +++++-- frontend/app/lib/env.ts | 2 + frontend/messages/en/auth.json | 4 +- frontend/messages/es/auth.json | 4 +- 17 files changed, 364 insertions(+), 8 deletions(-) create mode 100644 backend/scripts/create-demo-user.ts create mode 100644 backend/tests/auth/demo-login.test.ts create mode 100644 backend/tests/auth/demo-password-login.test.ts diff --git a/backend/.env.example b/backend/.env.example index 4fd0135..fecb261 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -11,6 +11,8 @@ NODE_ENV=development LOG_LEVEL= # Extra allowed CORS origin, on top of the ones in index.ts (optional) FRONTEND_URL= +# Public demo: "true" enables POST /api/auth/demo (entry without password) +DEMO_MODE= # ============================= # Security diff --git a/backend/config/error-codes.ts b/backend/config/error-codes.ts index 901872b..39d90af 100644 --- a/backend/config/error-codes.ts +++ b/backend/config/error-codes.ts @@ -26,6 +26,7 @@ export const ERROR_CODES = { // ============================================================================ AUTH_CREDENTIALS_REQUIRED: 'AUTH_CREDENTIALS_REQUIRED', AUTH_INVALID_CREDENTIALS: 'AUTH_INVALID_CREDENTIALS', + AUTH_DEMO_UNAVAILABLE: 'AUTH_DEMO_UNAVAILABLE', AUTH_REFRESH_TOKEN_MISSING: 'AUTH_REFRESH_TOKEN_MISSING', AUTH_REFRESH_TOKEN_INVALID: 'AUTH_REFRESH_TOKEN_INVALID', AUTH_USER_NOT_FOUND: 'AUTH_USER_NOT_FOUND', diff --git a/backend/controllers/auth/auth-controllers.ts b/backend/controllers/auth/auth-controllers.ts index 5ccca85..573a388 100644 --- a/backend/controllers/auth/auth-controllers.ts +++ b/backend/controllers/auth/auth-controllers.ts @@ -122,6 +122,52 @@ export const login = async (req: Request, res: Response): Promise => { } } +/** + * Entrada a la demo pública, sin contraseña (ADR-038) + * Solo existe con DEMO_MODE=true; la cuenta demo la limita demoRestriction + */ +export const demoLogin = async (_req: Request, res: Response): Promise => { + if (process.env.DEMO_MODE !== 'true') { + res.status(404).json({ error: 'Ruta no encontrada' }) + return + } + + try { + const user = await UserRepository.getDemoUser() + if (!user) { + logger.error({ event: 'demo_login_failed' }, '[AUTH] no active demo account') + res.status(503).json({ + error: ERROR_CODES.AUTH_DEMO_UNAVAILABLE, + code: ERROR_CODES.AUTH_DEMO_UNAVAILABLE, + }) + return + } + + const tokenPayload: TokenPayload = { + id: user.id, + username: user.username, + role: user.role, + demo: true, + type: 'access', + } + + res.cookie('access_token', generateAccessToken(tokenPayload), { + ...cookieOptions, + maxAge: 15 * 60 * 1000, + }) + res.cookie('refresh_token', generateRefreshToken({ ...tokenPayload, type: 'refresh' }), { + ...cookieOptions, + maxAge: 7 * 24 * 60 * 60 * 1000, + }) + + logger.info({ event: 'demo_login' }, '[AUTH] demo login') + res.status(200).json({ success: true, user }) + } catch (error) { + logger.error({ err: error }, '[AUTH] demo login error') + res.status(500).json({ error: ERROR_CODES.INTERNAL_ERROR, code: ERROR_CODES.INTERNAL_ERROR }) + } +} + /** * Registro de nuevo usuario */ diff --git a/backend/middlewares/rateLimiter.ts b/backend/middlewares/rateLimiter.ts index e4aea7b..6c7f9a6 100644 --- a/backend/middlewares/rateLimiter.ts +++ b/backend/middlewares/rateLimiter.ts @@ -75,6 +75,26 @@ export const loginIpLimiter = rateLimit({ }, }) +/** + * Rate limiter for the demo entry (POST /api/auth/demo), per IP + */ +export const demoLoginLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 10, + standardHeaders: true, + legacyHeaders: false, + keyGenerator: (req: Request) => `demo-login-${getIpKey(req)}`, + handler: (req: Request, res: Response) => { + logger.warn( + { event: 'rate_limit_exceeded', kind: 'demo_login', ip: getIpKey(req) }, + '[SECURITY] demo login rate limit exceeded' + ) + res.status(429).json({ + error: 'Demasiadas entradas a la demo. Intenta de nuevo en 15 minutos.', + }) + }, +}) + /** * Rate limiter for password change attempts * Stricter limits to prevent brute force on password verification diff --git a/backend/package.json b/backend/package.json index 2510376..aa0a961 100644 --- a/backend/package.json +++ b/backend/package.json @@ -8,6 +8,8 @@ "dev": "tsx watch index.ts", "dev:local": "cross-env DB_ENVIRONMENT=local tsx watch index.ts", "dev:aiven": "cross-env DB_ENVIRONMENT=aiven tsx watch index.ts", + "demo:user": "cross-env DB_ENVIRONMENT=local tsx scripts/create-demo-user.ts", + "demo:user:aiven": "cross-env DB_ENVIRONMENT=aiven tsx scripts/create-demo-user.ts", "build": "python3 -m venv scheduling-solver/venv && scheduling-solver/venv/bin/pip install --no-cache-dir -r scheduling-solver/requirements.txt", "start": "tsx index.ts", "typecheck": "tsc --noEmit", diff --git a/backend/repositories/auth/user-repository.ts b/backend/repositories/auth/user-repository.ts index 17f186c..1ca7ad7 100644 --- a/backend/repositories/auth/user-repository.ts +++ b/backend/repositories/auth/user-repository.ts @@ -177,6 +177,15 @@ export class UserRepository { throw new Error('Usuario inactivo') } + // The demo account has no usable password: it is entered only through POST /api/auth/demo + if (user.is_demo) { + logger.warn( + { event: 'login_failed', reason: 'demo_account', username }, + '[SECURITY] login failed' + ) + throw new Error('Credenciales inválidas') + } + if (!isPasswordValid) { logger.warn( { event: 'login_failed', reason: 'invalid_password', username }, @@ -245,6 +254,29 @@ export class UserRepository { } } + /** + * The public demo account (users.is_demo), if there is an active one + */ + static async getDemoUser(): Promise { + const [rows] = await db.query( + `SELECT + u.id, + u.username, + u.email, + u.created_at, + u.is_active, + u.is_demo, + u.avatar_url, + r.name AS role + FROM users u + INNER JOIN roles r ON r.id = u.role_id + WHERE u.is_demo = 1 AND u.is_active = 1 + ORDER BY u.created_at, u.id + LIMIT 1` + ) + return (rows[0] as User) || null + } + /** * Obtener por username con JOIN directo */ diff --git a/backend/routes/auth/auth-routes.ts b/backend/routes/auth/auth-routes.ts index 0af9b31..afce366 100644 --- a/backend/routes/auth/auth-routes.ts +++ b/backend/routes/auth/auth-routes.ts @@ -3,6 +3,7 @@ import express, { Router } from 'express' import { login, + demoLogin, register, refreshToken, logout, @@ -17,6 +18,7 @@ import { denyDemo } from '../../middlewares/demoRestriction.js' import { singleImage } from '../../middlewares/imageUpload.js' import { loginLimiter, + demoLoginLimiter, loginIpLimiter, refreshLimiter, passwordChangeLimiter, @@ -34,6 +36,8 @@ router.post('/login', loginIpLimiter, loginLimiter, login) router.post('/refresh-token', refreshLimiter, refreshToken) // Public: only clears the HttpOnly cookies, which the browser cannot do itself once the access token expired router.post('/logout', logout) +// Demo entry without password; answers 404 unless DEMO_MODE=true (ADR-038) +router.post('/demo', demoLoginLimiter, demoLogin) // ======================================== // ADMIN ONLY ROUTES diff --git a/backend/scripts/create-demo-user.ts b/backend/scripts/create-demo-user.ts new file mode 100644 index 0000000..dc5da06 --- /dev/null +++ b/backend/scripts/create-demo-user.ts @@ -0,0 +1,67 @@ +/** + * create-demo-user.ts + * + * Crea o actualiza la cuenta demo pública (ADR-038): usuario `demo`, rol admin, + * `is_demo = 1`. Su contraseña es aleatoria y no se guarda en ningún sitio: a + * la cuenta solo se entra por POST /api/auth/demo, y el login con contraseña la + * rechaza. Si ya existe un usuario `demo` (el antiguo, con rol demo-admin), lo + * convierte. + * + * Necesita la columna users.is_demo (db-mysql/scripts/20261005_add_is_demo_to_users.sql). + * + * Uso: + * pnpm demo:user (BD local) + * pnpm demo:user:aiven (Aiven) + */ + +import bcrypt from 'bcrypt' +import crypto from 'node:crypto' +import type { RowDataPacket } from 'mysql2' +import db from '../config/db.js' +import { SALT_ROUNDS } from '../config/config.js' + +const USERNAME = 'demo' +const EMAIL = 'demo@example.com' + +async function main(): Promise { + const [roles] = await db.query("SELECT id FROM roles WHERE name = 'admin'") + if (roles.length === 0) throw new Error("No existe el rol 'admin'") + const roleId = roles[0].id as number + + const password = await bcrypt.hash(crypto.randomBytes(32).toString('base64url'), SALT_ROUNDS) + + const [existing] = await db.query('SELECT id FROM users WHERE username = ?', [ + USERNAME, + ]) + + if (existing.length > 0) { + await db.query( + `UPDATE users + SET role_id = ?, password = ?, email = ?, is_active = 1, is_demo = 1, avatar_url = NULL, + avatar_public_id = NULL + WHERE id = ?`, + [roleId, password, EMAIL, existing[0].id] + ) + console.log(`Cuenta demo actualizada: ${USERNAME} (${existing[0].id})`) + } else { + const id = crypto.randomUUID() + await db.query( + `INSERT INTO users (id, username, email, password, role_id, created_at, is_active, is_demo) + VALUES (?, ?, ?, ?, ?, NOW(), 1, 1)`, + [id, USERNAME, EMAIL, password, roleId] + ) + console.log(`Cuenta demo creada: ${USERNAME} (${id})`) + } + + const [demo] = await db.query( + 'SELECT COUNT(*) AS n FROM users WHERE is_demo = 1 AND is_active = 1' + ) + console.log(`Cuentas demo activas: ${demo[0].n}`) +} + +main() + .catch((error: unknown) => { + console.error(error instanceof Error ? error.message : error) + process.exitCode = 1 + }) + .finally(() => db.end()) diff --git a/backend/tests/auth/demo-login.test.ts b/backend/tests/auth/demo-login.test.ts new file mode 100644 index 0000000..bc399a2 --- /dev/null +++ b/backend/tests/auth/demo-login.test.ts @@ -0,0 +1,83 @@ +// tests/auth/demo-login.test.ts +// POST /api/auth/demo (ADR-038): exists only with DEMO_MODE=true, enters the +// active demo account and marks its tokens as demo, so demoRestriction limits it. + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import type { Request, Response } from 'express' + +process.env.SECRET_JWT_KEY ??= 'test-secret-key-for-vitest-only-not-prod' + +vi.mock('../../repositories/auth/user-repository.js', () => ({ + UserRepository: { getDemoUser: vi.fn() }, +})) + +const { UserRepository } = await import('../../repositories/auth/user-repository.js') +const { demoLogin } = await import('../../controllers/auth/auth-controllers.js') +const { verifyToken } = await import('../../services/auth/tokenService.js') + +function fakeRes() { + const res = { + statusCode: 0, + cookies: {} as Record, + status: vi.fn((code: number) => { + res.statusCode = code + return res + }), + json: vi.fn(() => res), + cookie: vi.fn((name: string, value: string) => { + res.cookies[name] = value + return res + }), + } + return res +} + +const req = {} as Request +const DEMO = { id: 'd1', username: 'demo', role: 'admin', is_demo: 1, is_active: 1 } + +beforeEach(() => vi.clearAllMocks()) +afterEach(() => { + delete process.env.DEMO_MODE +}) + +describe('demoLogin', () => { + it.each([undefined, 'false', '1', 'TRUE'])('answers 404 with DEMO_MODE=%s', async (mode) => { + if (mode !== undefined) process.env.DEMO_MODE = mode + const res = fakeRes() + await demoLogin(req, res as unknown as Response) + expect(res.statusCode).toBe(404) + expect(res.cookie).not.toHaveBeenCalled() + expect(UserRepository.getDemoUser).not.toHaveBeenCalled() + }) + + it('answers 503 without an active demo account', async () => { + process.env.DEMO_MODE = 'true' + vi.mocked(UserRepository.getDemoUser).mockResolvedValueOnce(null) + const res = fakeRes() + await demoLogin(req, res as unknown as Response) + expect(res.statusCode).toBe(503) + expect(res.cookie).not.toHaveBeenCalled() + }) + + it('enters the demo account with tokens marked as demo', async () => { + process.env.DEMO_MODE = 'true' + vi.mocked(UserRepository.getDemoUser).mockResolvedValueOnce(DEMO as never) + const res = fakeRes() + await demoLogin(req, res as unknown as Response) + expect(res.statusCode).toBe(200) + + const access = verifyToken(res.cookies.access_token) + const refresh = verifyToken(res.cookies.refresh_token) + expect(access).toMatchObject({ id: 'd1', role: 'admin', demo: true, type: 'access' }) + expect(refresh).toMatchObject({ id: 'd1', demo: true, type: 'refresh' }) + }) + + it('answers 500 without the error text when the database fails', async () => { + process.env.DEMO_MODE = 'true' + vi.mocked(UserRepository.getDemoUser).mockRejectedValueOnce(new Error('ER_SECRET_TABLE')) + const res = fakeRes() + await demoLogin(req, res as unknown as Response) + expect(res.statusCode).toBe(500) + expect(JSON.stringify(res.json.mock.calls)).not.toContain('ER_SECRET_TABLE') + }) +}) diff --git a/backend/tests/auth/demo-password-login.test.ts b/backend/tests/auth/demo-password-login.test.ts new file mode 100644 index 0000000..dac9686 --- /dev/null +++ b/backend/tests/auth/demo-password-login.test.ts @@ -0,0 +1,34 @@ +// tests/auth/demo-password-login.test.ts +// The demo account is entered only through POST /api/auth/demo: the password +// login rejects it even with the right password (ADR-038). + +import { describe, it, expect, vi } from 'vitest' +import bcrypt from 'bcrypt' + +const PASSWORD = 'CorrectPassword123!' +const hash = await bcrypt.hash(PASSWORD, 4) + +const query = vi.fn() +vi.mock('../../config/db.js', () => ({ default: { query } })) + +const { UserRepository } = await import('../../repositories/auth/user-repository.js') + +const row = (is_demo: number) => [ + [{ id: 'u1', username: 'someone', password: hash, is_active: 1, is_demo, role: 'admin' }], +] + +describe('password login and the demo account', () => { + it('rejects the demo account with the right password', async () => { + query.mockResolvedValueOnce(row(1)) + await expect(UserRepository.login({ username: 'someone', password: PASSWORD })).rejects.toThrow( + /credenciales/i + ) + }) + + it('still accepts any other account with the right password', async () => { + query.mockResolvedValueOnce(row(0)) + await expect( + UserRepository.login({ username: 'someone', password: PASSWORD }) + ).resolves.toMatchObject({ id: 'u1' }) + }) +}) diff --git a/frontend/.env.example b/frontend/.env.example index 59e9a56..349d925 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -5,6 +5,9 @@ NEXT_PUBLIC_API_URL=http://localhost:4000 # (optional) Different API URL for server-side requests only # NEXT_SERVER_API_URL=http://localhost:4000 +# Public demo: "true" shows the demo button on the login page (backend needs DEMO_MODE=true) +# NEXT_PUBLIC_DEMO_MODE=true + # Hotel name printed in the footer of printed checklists (default: Hotel PMS) # NEXT_PUBLIC_HOTEL_NAME=My Hotel diff --git a/frontend/app/(auth)/login/page.tsx b/frontend/app/(auth)/login/page.tsx index 86b9c87..6d8d5ee 100644 --- a/frontend/app/(auth)/login/page.tsx +++ b/frontend/app/(auth)/login/page.tsx @@ -15,6 +15,7 @@ import { Fa4 } from 'react-icons/fa6' import { TbTransformPointTopLeft } from 'react-icons/tb' import { SimpleThemeButton } from '@/app/components/theme/SetThemeButton' import { useAuth } from '@/app/lib/auth/useAuth' +import { DEMO_MODE } from '@/app/lib/env' import Link from 'next/link' export default function LoginPage() { @@ -25,7 +26,7 @@ export default function LoginPage() { const [error, setError] = useState(null) const [fieldErrors, setFieldErrors] = useState>({}) const [success, setSuccess] = useState(false) - const { login, loading } = useAuth() + const { login, loginDemo, loading } = useAuth() const validateUsername = (value: string) => { if (value.length === 0) return @@ -53,6 +54,16 @@ export default function LoginPage() { } } + const handleDemo = async () => { + setError(null) + setFieldErrors({}) + try { + await loginDemo() + } catch { + setError(t('errors.demoUnavailable')) + } + } + return (
@@ -195,6 +206,20 @@ export default function LoginPage() { {!loading && !success && } + + {DEMO_MODE && ( +
+ +

{t('login.demoHint')}

+
+ )}
diff --git a/frontend/app/lib/auth/authService.ts b/frontend/app/lib/auth/authService.ts index bc0093e..8eda4a1 100644 --- a/frontend/app/lib/auth/authService.ts +++ b/frontend/app/lib/auth/authService.ts @@ -36,6 +36,18 @@ export const authLogin = { return data }, + // Demo entry without password (ADR-038) + demo: async () => { + const res = await fetch(`${API_BASE}/demo`, { + method: 'POST', + credentials: 'include', + cache: 'no-store', + }) + + if (!res.ok) throw new Error('demo-unavailable') + return res.json() + }, + logout: async () => { console.log(`[authLogin.logout] Cerrando sesión... (${isDev ? 'DEV' : 'PROD'})`) diff --git a/frontend/app/lib/auth/useAuth.tsx b/frontend/app/lib/auth/useAuth.tsx index 3d5d172..9e97851 100644 --- a/frontend/app/lib/auth/useAuth.tsx +++ b/frontend/app/lib/auth/useAuth.tsx @@ -39,6 +39,7 @@ interface AuthContextType { loading: boolean isAuthenticated: boolean login: (username: string, password: string) => Promise + loginDemo: () => Promise logout: () => Promise refreshUser: () => Promise } @@ -112,14 +113,14 @@ export function AuthProvider({ children }: { children: ReactNode }) { }, [pathname, fetchUserData, user]) /** - * Login de usuario + * Inicio de sesión común: guarda el usuario y va a la página de destino */ - const login = useCallback( - async (username: string, password: string) => { + const startSession = useCallback( + async (signIn: () => Promise<{ user: User }>) => { setLoading(true) try { - const data = await authLogin.login(username, password) + const data = await signIn() setUser({ ...data.user, @@ -144,6 +145,23 @@ export function AuthProvider({ children }: { children: ReactNode }) { [router] ) + /** + * Login de usuario + */ + const login = useCallback( + async (username: string, password: string) => { + await startSession(() => authLogin.login(username, password)) + }, + [startSession] + ) + + /** + * Entrada a la demo pública, sin contraseña + */ + const loginDemo = useCallback(async () => { + await startSession(() => authLogin.demo()) + }, [startSession]) + /** * Logout de usuario */ @@ -179,10 +197,11 @@ export function AuthProvider({ children }: { children: ReactNode }) { loading, isAuthenticated: !!user, login, + loginDemo, logout, refreshUser, }), - [user, loading, login, logout, refreshUser] + [user, loading, login, loginDemo, logout, refreshUser] ) return {children} diff --git a/frontend/app/lib/env.ts b/frontend/app/lib/env.ts index e03039d..fa644c0 100644 --- a/frontend/app/lib/env.ts +++ b/frontend/app/lib/env.ts @@ -1,3 +1,5 @@ // Centraliza las URLs de API para cliente y servidor export const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000' export const SERVER_API_BASE_URL = process.env.NEXT_SERVER_API_URL || API_BASE_URL +// Shows the demo button on the login page; the backend needs DEMO_MODE=true too +export const DEMO_MODE = process.env.NEXT_PUBLIC_DEMO_MODE === 'true' diff --git a/frontend/messages/en/auth.json b/frontend/messages/en/auth.json index 3aa5e14..aa6f865 100644 --- a/frontend/messages/en/auth.json +++ b/frontend/messages/en/auth.json @@ -21,7 +21,8 @@ "askManagers": "Ask the managers", "signUp": "Sign up", "orContinueWith": "Or continue with", - "liveDemo": "Try our live demo" + "liveDemo": "Try our live demo", + "demoHint": "You sign in as an administrator with fictitious data that resets every day. User management and settings are locked." }, "register": { "title": "Create Account", @@ -68,6 +69,7 @@ }, "errors": { "invalidCredentials": "Invalid credentials", + "demoUnavailable": "The demo is not available right now. Please try again later.", "emailRequired": "Email is required", "passwordRequired": "Password is required", "invalidEmail": "Please enter a valid email", diff --git a/frontend/messages/es/auth.json b/frontend/messages/es/auth.json index fb15378..a21ea3f 100644 --- a/frontend/messages/es/auth.json +++ b/frontend/messages/es/auth.json @@ -21,7 +21,8 @@ "askManagers": "Pregunta a los gerentes", "signUp": "Regístrate", "orContinueWith": "O continúa con", - "liveDemo": "Prueba nuestra demo" + "liveDemo": "Prueba nuestra demo", + "demoHint": "Entras como administrador con datos ficticios que se reinician cada día. La gestión de usuarios y la configuración están bloqueadas." }, "register": { "title": "Crear Cuenta", @@ -68,6 +69,7 @@ }, "errors": { "invalidCredentials": "Credenciales inválidas", + "demoUnavailable": "La demo no está disponible ahora mismo. Inténtalo más tarde.", "emailRequired": "El correo es requerido", "passwordRequired": "La contraseña es requerida", "invalidEmail": "Por favor ingresa un correo válido", From d8c23af17f8958357f0afd12bf33c74558a3d076 Mon Sep 17 00:00:00 2001 From: razyd Date: Mon, 5 Oct 2026 15:28:21 +0200 Subject: [PATCH 07/20] feat(auth): rate limit the demo account's writes and schedule generation - demoWriteLimiter: 60 writes per 15 minutes per IP for the demo account, run by authenticateToken after demoRestriction; reads are not counted - demoGenerateLimiter on POST /months/:id/generate: 5 per hour per IP and 30 per hour across all demo visitors - other accounts are never limited by them; tests for both limits Why: the demo account is shared, so it is limited by IP; the solver loads OR-Tools on one 512 MB Render instance and runs one request at a time, so it also gets a total cap (ADR-038). --- backend/middlewares/authenticateToken.ts | 3 +- backend/middlewares/rateLimiter.ts | 62 +++++++++++ .../routes/scheduling/scheduling-routes.ts | 3 +- backend/tests/auth/demo-limits.test.ts | 100 ++++++++++++++++++ 4 files changed, 166 insertions(+), 2 deletions(-) create mode 100644 backend/tests/auth/demo-limits.test.ts diff --git a/backend/middlewares/authenticateToken.ts b/backend/middlewares/authenticateToken.ts index 976571c..b08a62c 100644 --- a/backend/middlewares/authenticateToken.ts +++ b/backend/middlewares/authenticateToken.ts @@ -3,6 +3,7 @@ import { Request, Response, NextFunction } from 'express' import { verifyToken } from '../services/auth/tokenService.js' import { demoRestriction } from './demoRestriction.js' +import { demoWriteLimiter } from './rateLimiter.js' import { logger } from '../config/logger.js' /** @@ -76,5 +77,5 @@ export function authenticateToken(req: Request, res: Response, next: NextFunctio } // Demo account restrictions, outside the try: an error here is not a bad token - demoRestriction(req, res, next) + demoRestriction(req, res, () => demoWriteLimiter(req, res, next)) } diff --git a/backend/middlewares/rateLimiter.ts b/backend/middlewares/rateLimiter.ts index 6c7f9a6..4c60a83 100644 --- a/backend/middlewares/rateLimiter.ts +++ b/backend/middlewares/rateLimiter.ts @@ -95,6 +95,68 @@ export const demoLoginLimiter = rateLimit({ }, }) +// The demo account is shared by every visitor, so its limits go by IP, plus a +// total cap where one Render instance is at stake (ADR-038) +const READ_METHODS = ['GET', 'HEAD', 'OPTIONS'] + +function demoLimitHandler(kind: string, error: string) { + return (req: Request, res: Response) => { + logger.warn( + { event: 'rate_limit_exceeded', kind, ip: getIpKey(req) }, + '[SECURITY] demo rate limit exceeded' + ) + res.status(429).json({ error }) + } +} + +/** + * Demo account writes, per IP + */ +export const demoWriteLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 60, + standardHeaders: true, + legacyHeaders: false, + skip: (req: Request) => !req.user?.isDemo || READ_METHODS.includes(req.method), + keyGenerator: (req: Request) => `demo-write-${getIpKey(req)}`, + handler: demoLimitHandler( + 'demo_write', + 'Demasiados cambios en la demo. Intenta de nuevo en 15 minutos.' + ), +}) + +/** + * Schedule generation by the demo account: the solver loads OR-Tools on a + * 512 MB instance and runs one at a time. Per IP, and in total + */ +const demoGeneratePerIp = rateLimit({ + windowMs: 60 * 60 * 1000, + max: 5, + standardHeaders: true, + legacyHeaders: false, + skip: (req: Request) => !req.user?.isDemo, + keyGenerator: (req: Request) => `demo-generate-${getIpKey(req)}`, + handler: demoLimitHandler( + 'demo_generate', + 'Has generado demasiados horarios en la demo. Intenta de nuevo en una hora.' + ), +}) + +const demoGenerateTotal = rateLimit({ + windowMs: 60 * 60 * 1000, + max: 30, + standardHeaders: false, + legacyHeaders: false, + skip: (req: Request) => !req.user?.isDemo, + keyGenerator: () => 'demo-generate-total', + handler: demoLimitHandler( + 'demo_generate_total', + 'La demo ha generado demasiados horarios esta hora. Intenta de nuevo más tarde.' + ), +}) + +export const demoGenerateLimiter = [demoGeneratePerIp, demoGenerateTotal] + /** * Rate limiter for password change attempts * Stricter limits to prevent brute force on password verification diff --git a/backend/routes/scheduling/scheduling-routes.ts b/backend/routes/scheduling/scheduling-routes.ts index da6c10a..b51eb59 100644 --- a/backend/routes/scheduling/scheduling-routes.ts +++ b/backend/routes/scheduling/scheduling-routes.ts @@ -74,6 +74,7 @@ import { generateSchedule } from '../../controllers/scheduling/schedule-generate import { authenticateToken } from '../../middlewares/authenticateToken.js' import { isAdmin } from '../../middlewares/roleCheck.js' import { denyDemoWrites } from '../../middlewares/demoRestriction.js' +import { demoGenerateLimiter } from '../../middlewares/rateLimiter.js' const router: Router = express.Router() @@ -154,7 +155,7 @@ router.delete('/months/:id', isAdmin, deleteMonth) router.post('/months/:id/validate', validateSchedule) // POST - Generate schedule with CP-SAT solver (admin only) -router.post('/months/:id/generate', isAdmin, generateSchedule) +router.post('/months/:id/generate', isAdmin, demoGenerateLimiter, generateSchedule) // POST - Reset month (clear assignments, re-load approved constraints) router.post('/months/:id/reset', isAdmin, resetMonth) diff --git a/backend/tests/auth/demo-limits.test.ts b/backend/tests/auth/demo-limits.test.ts new file mode 100644 index 0000000..fb8483e --- /dev/null +++ b/backend/tests/auth/demo-limits.test.ts @@ -0,0 +1,100 @@ +// tests/auth/demo-limits.test.ts +// Limits for the shared demo account (ADR-038): schedule generation per IP and +// in total (one 512 MB Render instance runs the solver), and writes per IP. +// Other accounts are never limited by them. + +import { describe, it, expect, vi } from 'vitest' +import type { Request, Response, NextFunction } from 'express' + +const { demoGenerateLimiter, demoWriteLimiter } = await import('../../middlewares/rateLimiter.js') + +const DEMO = { id: 'd1', username: 'demo', role: 'admin', isDemo: true } +const OWNER = { id: 'a1', username: 'admin', role: 'admin' } + +function fakeReq(ip: string, user: object, method = 'POST'): Request { + return { + ip, + method, + user, + headers: {}, + socket: { remoteAddress: ip }, + app: { get: () => 3 }, + get: () => undefined, + } as unknown as Request +} + +function fakeRes() { + const res = { + statusCode: 200, + headersSent: false, + setHeader: vi.fn(), + getHeader: vi.fn(), + append: vi.fn(), + status: vi.fn((code: number) => { + res.statusCode = code + return res + }), + json: vi.fn(() => res), + send: vi.fn(() => res), + on: vi.fn(), + } + return res +} + +// Runs a middleware chain; returns the status (200 when every step called next) +async function run( + chain: Array<(req: Request, res: Response, next: NextFunction) => unknown>, + req: Request +) { + const res = fakeRes() + for (const mw of chain) { + let passed = false + await mw(req, res as unknown as Response, () => { + passed = true + }) + if (!passed) return res.statusCode + } + return 200 +} + +describe('demo schedule generation limit', () => { + it('allows 5 per hour per IP, then answers 429', async () => { + const codes = [] + for (let i = 0; i < 6; i++) + codes.push(await run(demoGenerateLimiter, fakeReq('198.51.100.1', DEMO))) + expect(codes).toEqual([200, 200, 200, 200, 200, 429]) + }) + + it('caps all visitors together at 30 per hour', async () => { + const codes = [] + // 25 more, from other IPs, after the 5 above + for (let i = 0; i < 26; i++) + codes.push(await run(demoGenerateLimiter, fakeReq(`198.51.100.${10 + i}`, DEMO))) + expect(codes.slice(0, 25).every((c) => c === 200)).toBe(true) + expect(codes[25]).toBe(429) + }) + + it('never limits other accounts', async () => { + for (let i = 0; i < 40; i++) { + expect(await run(demoGenerateLimiter, fakeReq('198.51.100.1', OWNER))).toBe(200) + } + }) +}) + +describe('demo write limit', () => { + it('allows 60 writes per 15 minutes per IP, then answers 429', async () => { + const codes = [] + for (let i = 0; i < 61; i++) + codes.push(await run([demoWriteLimiter], fakeReq('203.0.113.1', DEMO))) + expect(codes.filter((c) => c === 200)).toHaveLength(60) + expect(codes[60]).toBe(429) + }) + + it('does not count reads', async () => { + expect(await run([demoWriteLimiter], fakeReq('203.0.113.1', DEMO, 'GET'))).toBe(200) + }) + + it('never limits other accounts', async () => { + expect(await run([demoWriteLimiter], fakeReq('203.0.113.1', OWNER))).toBe(200) + }) +}) From db8b3562e1f950c817f14fbbdcddc99f4ba199cd Mon Sep 17 00:00:00 2001 From: razyd Date: Mon, 5 Oct 2026 15:39:15 +0200 Subject: [PATCH 08/20] feat(demo): reset the demo data daily and from Settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - demo_reset_log table (idempotent migration, logged in INDEX.md) - services/demo/demo-reset.service.ts: reloads mock-data.sql on its own multi-statement connection (DELIMITER blocks turned into plain SQL) and restores the scheduling base from demo_snapshot_* tables in one transaction; GET_LOCK keeps a single reset at a time - the demo entry resets once a day, on the first entry (Madrid date) - /api/demo/status, /reset and /snapshot: only with DEMO_MODE=true, for admins that are not the demo account - Settings → Demo tab (only with NEXT_PUBLIC_DEMO_MODE=true): last reset, saved base, reset now and save current schedules as the base - tests for the DELIMITER conversion, the route guards and the reset call Why: visitors may change anything, so the demo goes back to the same state every day (ADR-038). Render's free instance sleeps, so the reset runs on the first entry instead of a cron; the manual button covers the case where the owner needs it sooner. --- backend/config/db.ts | 3 + backend/controllers/auth/auth-controllers.ts | 4 + .../controllers/demo/demo-reset-controller.ts | 45 +++ backend/db-mysql/INDEX.md | 1 + .../scripts/20261005_add_demo_reset_log.sql | 29 ++ backend/index.ts | 3 + backend/routes/demo/demo-reset-routes.ts | 33 +++ backend/services/demo/demo-reset.service.ts | 277 ++++++++++++++++++ backend/tests/auth/demo-login.test.ts | 3 + backend/tests/security/demo-reset.test.ts | 69 +++++ frontend/app/components/profile/DemoTab.tsx | 136 +++++++++ .../app/components/profile/SettingsPanel.tsx | 20 +- frontend/app/lib/logbooks/types.ts | 2 + frontend/messages/en/profile.json | 24 +- frontend/messages/es/profile.json | 24 +- 15 files changed, 668 insertions(+), 5 deletions(-) create mode 100644 backend/controllers/demo/demo-reset-controller.ts create mode 100644 backend/db-mysql/scripts/20261005_add_demo_reset_log.sql create mode 100644 backend/routes/demo/demo-reset-routes.ts create mode 100644 backend/services/demo/demo-reset.service.ts create mode 100644 backend/tests/security/demo-reset.test.ts create mode 100644 frontend/app/components/profile/DemoTab.tsx diff --git a/backend/config/db.ts b/backend/config/db.ts index 1d3979d..fda0dca 100644 --- a/backend/config/db.ts +++ b/backend/config/db.ts @@ -122,4 +122,7 @@ async function connectWithRetry(retries = 3, delayMs = 3000): Promise { connectWithRetry() +// For one-off connections with other options (services/demo: multipleStatements) +export { config as dbConfig } + export default pool diff --git a/backend/controllers/auth/auth-controllers.ts b/backend/controllers/auth/auth-controllers.ts index 573a388..7a18d20 100644 --- a/backend/controllers/auth/auth-controllers.ts +++ b/backend/controllers/auth/auth-controllers.ts @@ -13,6 +13,7 @@ import { generateRefreshToken, verifyToken, } from '../../services/auth/tokenService.js' +import { resetIfStale } from '../../services/demo/demo-reset.service.js' import { CloudinaryService } from '../../services/blacklist/cloudinary-service.js' import { ERROR_CODES, SUCCESS_CODES } from '../../config/error-codes.js' import { logger } from '../../config/logger.js' @@ -143,6 +144,9 @@ export const demoLogin = async (_req: Request, res: Response): Promise => return } + // First entry of the day: back to the mock data and the scheduling base + await resetIfStale() + const tokenPayload: TokenPayload = { id: user.id, username: user.username, diff --git a/backend/controllers/demo/demo-reset-controller.ts b/backend/controllers/demo/demo-reset-controller.ts new file mode 100644 index 0000000..feeed28 --- /dev/null +++ b/backend/controllers/demo/demo-reset-controller.ts @@ -0,0 +1,45 @@ +// controllers/demo/demo-reset-controller.ts +/** + * Configuración → Demo (ADR-038): estado, reinicio a mano y guardar la base de + * horarios. Solo existe con DEMO_MODE=true y solo para admins que no son la + * cuenta demo (demo-reset-routes.ts). + */ + +import type { Request, Response } from 'express' +import { + getDemoStatus, + resetDemoData, + saveSchedulingSnapshot, + type DemoResetResult, +} from '../../services/demo/demo-reset.service.js' +import { ERROR_CODES } from '../../config/error-codes.js' +import { logger } from '../../config/logger.js' + +function answer(res: Response, result: DemoResetResult): void { + if (result.status === 'busy') { + res.status(409).json({ success: false, error: 'Ya hay un reinicio de la demo en marcha' }) + return + } + if (result.status === 'failed') { + res.status(500).json({ success: false, error: ERROR_CODES.INTERNAL_ERROR }) + return + } + res.status(200).json({ success: true, data: result }) +} + +export async function getStatus(_req: Request, res: Response): Promise { + try { + res.status(200).json({ success: true, data: await getDemoStatus() }) + } catch (error) { + logger.error({ err: error }, '[DEMO] status error') + res.status(500).json({ success: false, error: ERROR_CODES.INTERNAL_ERROR }) + } +} + +export async function resetNow(req: Request, res: Response): Promise { + answer(res, await resetDemoData('manual', req.user!.id)) +} + +export async function saveSnapshot(req: Request, res: Response): Promise { + answer(res, await saveSchedulingSnapshot(req.user!.id)) +} diff --git a/backend/db-mysql/INDEX.md b/backend/db-mysql/INDEX.md index 69ab3a7..3439224 100644 --- a/backend/db-mysql/INDEX.md +++ b/backend/db-mysql/INDEX.md @@ -105,6 +105,7 @@ db-mysql/ | 2026-10-04 | `20261004_add_messages_to_notifications_module.sql` | Añade `messages` al ENUM `notifications.module`. Desde `9be16b1` el aviso de mensaje urgente se guarda con ese módulo y el INSERT fallaba en silencio («Data truncated»). Solo cambia la columna si tiene la definición anterior exacta. En local ya estaba (BD montada desde el baseline); en Aiven se aplicó el 2026-10-04. | ✅ local · ✅ Aiven | | 2026-10-04 | `20261004_fix_parking_availability_triggers.sql` | Recrea `trg_free_availability_on_status_change` (solo libera los días de la propia reserva) y `trg_update_availability_on_date_change` (solo ocupa días libres o propios). Antes completar, cancelar o mover una reserva podía liberar u ocupar días de otra. La comprobación de solapes está en `bookings.repository.ts`. Local tenía otro cuerpo de `date_change`; se parte del de Aiven. | ⏳ local · ✅ Aiven | | 2026-10-05 | `20261005_add_is_demo_to_users.sql` | Añade `users.is_demo` (0 por defecto): marca la cuenta demo pública, que el backend lleva en el token y `demoRestriction` limita (ADR-038). Hay que aplicarla **antes** de desplegar el código: el login lee la columna. Solo añade la columna si no existe. En Aiven se aplicó el 2026-10-05, antes del código. | ✅ local · ✅ Aiven | +| 2026-10-05 | `20261005_add_demo_reset_log.sql` | Crea `demo_reset_log`: un registro por cada reinicio diario de la demo y por cada base de horarios guardada (ADR-038). La base vive en tablas `demo_snapshot_scheduling_*` que crea el backend al guardarla. Hay que aplicarla antes de desplegar el código. `CREATE TABLE IF NOT EXISTS`. | ✅ local · ⏳ Aiven | ### Verificación (99) diff --git a/backend/db-mysql/scripts/20261005_add_demo_reset_log.sql b/backend/db-mysql/scripts/20261005_add_demo_reset_log.sql new file mode 100644 index 0000000..f1a35e8 --- /dev/null +++ b/backend/db-mysql/scripts/20261005_add_demo_reset_log.sql @@ -0,0 +1,29 @@ +-- ============================================================================= +-- 20261005_add_demo_reset_log.sql +-- ============================================================================= +-- Adds `demo_reset_log`: one row per daily reset of the public demo and per +-- saved scheduling base (ADR-038). The backend reads it to reset only once a +-- day, on the first demo entry, and shows it in Settings → Demo. +-- +-- The scheduling base itself lives in `demo_snapshot_scheduling_*` tables, +-- created by the backend when an admin saves it (services/demo). +-- +-- Idempotent: CREATE TABLE IF NOT EXISTS. +-- +-- Applies to: LOCAL + AIVEN. +-- Rollback: DROP TABLE demo_reset_log; +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS demo_reset_log ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + action ENUM('reset', 'snapshot') NOT NULL COMMENT 'reset: mock + scheduling base; snapshot: base saved', + trigger_type ENUM('auto', 'manual') NOT NULL COMMENT 'auto: first demo entry of the day', + user_id VARCHAR(36) NULL COMMENT 'Admin who ran it (manual)', + started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + finished_at DATETIME NULL, + ok TINYINT(1) NOT NULL DEFAULT 0, + details VARCHAR(500) NULL COMMENT 'Summary or error code, never SQL text', + PRIMARY KEY (id), + KEY idx_demo_reset_log_action_started (action, started_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + COMMENT='Public demo: daily resets and saved scheduling bases (ADR-038)'; diff --git a/backend/index.ts b/backend/index.ts index 91bd949..db8f36d 100644 --- a/backend/index.ts +++ b/backend/index.ts @@ -31,6 +31,7 @@ import backofficeRoutes from './routes/backoffice/backoffice-routes.js' import schedulingRoutes from './routes/scheduling/scheduling-routes.js' import searchRoutes from './routes/search/search-routes.js' import demoActivityRoutes from './routes/demo/demo-activity-routes.js' +import demoResetRoutes from './routes/demo/demo-reset-routes.js' import checklistRoutes from './routes/checklist/checklist-routes.js' import fnbRoutes from './routes/fnb/fnb-routes.js' @@ -166,6 +167,8 @@ app.use('/api/search', searchRoutes) // Rutas de actividad demo (solo admin) app.use('/api/demo-activity', demoActivityRoutes) +// Demo reset and scheduling base (only with DEMO_MODE=true) +app.use('/api/demo', demoResetRoutes) app.use('/api/checklists', checklistRoutes) app.use('/api/fnb', fnbRoutes) diff --git a/backend/routes/demo/demo-reset-routes.ts b/backend/routes/demo/demo-reset-routes.ts new file mode 100644 index 0000000..62aa3d9 --- /dev/null +++ b/backend/routes/demo/demo-reset-routes.ts @@ -0,0 +1,33 @@ +// routes/demo/demo-reset-routes.ts +/** + * Configuración → Demo (ADR-038). Solo con DEMO_MODE=true: el reinicio borra + * los datos de todos los módulos, así que en cualquier otra instalación la + * ruta no existe. + */ + +import { Router, type Request, type Response, type NextFunction } from 'express' +import { getStatus, resetNow, saveSnapshot } from '../../controllers/demo/demo-reset-controller.js' +import { authenticateToken } from '../../middlewares/authenticateToken.js' +import { denyDemo } from '../../middlewares/demoRestriction.js' +import { isRealAdmin } from '../../middlewares/roleCheck.js' +import { isDemoMode } from '../../services/demo/demo-reset.service.js' + +const router = Router() + +router.use((_req: Request, res: Response, next: NextFunction) => { + if (!isDemoMode()) { + res.status(404).json({ error: 'Ruta no encontrada' }) + return + } + next() +}) +router.use(authenticateToken) +// The demo account is an admin: it must not reset the demo for everyone +router.use(denyDemo) +router.use(isRealAdmin) + +router.get('/status', getStatus) +router.post('/reset', resetNow) +router.post('/snapshot', saveSnapshot) + +export default router diff --git a/backend/services/demo/demo-reset.service.ts b/backend/services/demo/demo-reset.service.ts new file mode 100644 index 0000000..b91c6b3 --- /dev/null +++ b/backend/services/demo/demo-reset.service.ts @@ -0,0 +1,277 @@ +// services/demo/demo-reset.service.ts +/** + * Reinicio diario de la demo pública (ADR-038). + * + * Un reinicio: + * 1. vuelve a cargar db-mysql/mock-data.sql (datos de los módulos, con fechas + * relativas a hoy; no toca usuarios ni horarios); + * 2. restaura la base de horarios guardada en las tablas demo_snapshot_*. + * + * Se lanza solo con la primera entrada a la demo de cada día (Render gratuito + * se duerme, así que un cron a hora fija podría no ejecutarse) o a mano desde + * Configuración → Demo. La base de horarios la guarda un admin, también desde + * ahí, después de generar los meses con el solver. + * + * Todo corre en una conexión propia: GET_LOCK evita dos reinicios a la vez y + * multipleStatements permite enviar el mock entero, que es un fichero del + * repositorio y nunca lleva datos de la petición. + */ + +import { readFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import mysql from 'mysql2/promise' +import type { Connection, RowDataPacket } from 'mysql2/promise' +import db, { dbConfig } from '../../config/db.js' +import { formatDateMadrid, getTodayMadrid } from '../../config/date-utils.js' +import { logger } from '../../config/logger.js' + +const MOCK_FILE = join( + dirname(fileURLToPath(import.meta.url)), + '..', + '..', + 'db-mysql', + 'mock-data.sql' +) + +const LOCK_NAME = 'four_points_demo_reset' + +// Every scheduling table: the base is the whole module, configuration included +export const SNAPSHOT_TABLES = [ + 'scheduling_config', + 'scheduling_shifts', + 'scheduling_employees', + 'scheduling_employee_contracts', + 'scheduling_employee_rules', + 'scheduling_employee_requests', + 'scheduling_months', + 'scheduling_days', + 'scheduling_constraints', + 'scheduling_assignments', + 'scheduling_history', + 'scheduling_solver_runs', +] as const + +const snapshotName = (table: string) => `demo_snapshot_${table}` + +export type DemoTrigger = 'auto' | 'manual' + +export interface DemoResetResult { + status: 'done' | 'busy' | 'failed' + scheduling?: 'restored' | 'no-snapshot' +} + +export const isDemoMode = (): boolean => process.env.DEMO_MODE === 'true' + +/** + * mock-data.sql for one multi-statement query: DELIMITER is a command of the + * mysql client, not SQL. The server already parses CREATE PROCEDURE ... END as + * one statement, so the custom delimiter goes back to ';'. + */ +export function toMultiStatement(sql: string): string { + let delimiter = ';' + const out: string[] = [] + for (const line of sql.split(/\r?\n/)) { + const change = /^\s*DELIMITER\s+(\S+)\s*$/i.exec(line) + if (change) { + delimiter = change[1] + continue + } + out.push( + delimiter !== ';' && line.trimEnd().endsWith(delimiter) + ? line.trimEnd().slice(0, -delimiter.length) + ';' + : line + ) + } + return out.join('\n') +} + +async function openConnection(): Promise { + return mysql.createConnection({ ...dbConfig, multipleStatements: true }) +} + +async function startLog( + action: 'reset' | 'snapshot', + trigger: DemoTrigger, + userId?: string +): Promise { + const [result] = await db.query( + 'INSERT INTO demo_reset_log (action, trigger_type, user_id) VALUES (?, ?, ?)', + [action, trigger, userId ?? null] + ) + return result.insertId +} + +async function finishLog(id: number, ok: boolean, details: string): Promise { + await db.query( + 'UPDATE demo_reset_log SET finished_at = NOW(), ok = ?, details = ? WHERE id = ?', + [ok ? 1 : 0, details.slice(0, 500), id] + ) +} + +async function snapshotExists(conn: Connection): Promise { + const [rows] = await conn.query( + `SELECT COUNT(*) AS n FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_name IN (?)`, + [SNAPSHOT_TABLES.map(snapshotName)] + ) + return Number(rows[0].n) === SNAPSHOT_TABLES.length +} + +async function restoreScheduling(conn: Connection): Promise { + await conn.query('START TRANSACTION') + try { + // Foreign keys off: the tables are emptied and refilled as a whole + await conn.query('SET FOREIGN_KEY_CHECKS = 0') + for (const table of SNAPSHOT_TABLES) { + await conn.query(`DELETE FROM \`${table}\``) + await conn.query(`INSERT INTO \`${table}\` SELECT * FROM \`${snapshotName(table)}\``) + } + await conn.query('SET FOREIGN_KEY_CHECKS = 1') + await conn.query('COMMIT') + } catch (error) { + await conn.query('ROLLBACK') + await conn.query('SET FOREIGN_KEY_CHECKS = 1') + throw error + } +} + +async function withLock( + work: (conn: Connection) => Promise +): Promise<{ busy: true } | { busy: false; value: T }> { + const conn = await openConnection() + try { + const [rows] = await conn.query('SELECT GET_LOCK(?, 0) AS got', [LOCK_NAME]) + if (Number(rows[0].got) !== 1) return { busy: true } + try { + return { busy: false, value: await work(conn) } + } finally { + await conn.query('SELECT RELEASE_LOCK(?)', [LOCK_NAME]) + } + } finally { + await conn.end() + } +} + +/** + * Reloads the mock data and restores the scheduling base, if one was saved + */ +export async function resetDemoData( + trigger: DemoTrigger, + userId?: string +): Promise { + const startedAt = Date.now() + // Logged only once the lock is held: a reset that finds another running leaves no row + let logId: number | undefined + + try { + const result = await withLock(async (conn) => { + logId = await startLog('reset', trigger, userId) + const mock = toMultiStatement(await readFile(MOCK_FILE, 'utf8')) + try { + await conn.query(mock) + } catch (error) { + // The mock opens its own transaction: undo whatever it left half done + await conn.query('ROLLBACK').catch(() => undefined) + throw error + } + + if (!(await snapshotExists(conn))) return 'no-snapshot' as const + await restoreScheduling(conn) + return 'restored' as const + }) + + if (result.busy) return { status: 'busy' } + + const ms = Date.now() - startedAt + await finishLog(logId!, true, `scheduling=${result.value}; ${ms} ms`) + logger.info({ event: 'demo_reset', trigger, scheduling: result.value, ms }, '[DEMO] reset') + return { status: 'done', scheduling: result.value } + } catch (error) { + logger.error({ err: error, trigger }, '[DEMO] reset failed') + // A code, never the SQL text (it names tables and columns) + const code = (error as { code?: string }).code ?? 'ERROR' + if (logId) await finishLog(logId, false, `failed: ${code}`).catch(() => undefined) + return { status: 'failed' } + } +} + +/** + * Saves the current scheduling tables as the base the daily reset restores + */ +export async function saveSchedulingSnapshot(userId: string): Promise { + let logId: number | undefined + try { + const result = await withLock(async (conn) => { + logId = await startLog('snapshot', 'manual', userId) + for (const table of SNAPSHOT_TABLES) { + const snapshot = snapshotName(table) + await conn.query(`DROP TABLE IF EXISTS \`${snapshot}\``) + await conn.query(`CREATE TABLE \`${snapshot}\` LIKE \`${table}\``) + await conn.query(`INSERT INTO \`${snapshot}\` SELECT * FROM \`${table}\``) + } + const [rows] = await conn.query( + `SELECT COUNT(*) AS n FROM \`${snapshotName('scheduling_months')}\`` + ) + return Number(rows[0].n) + }) + + if (result.busy) return { status: 'busy' } + await finishLog(logId!, true, `months=${result.value}`) + logger.info({ event: 'demo_snapshot', months: result.value }, '[DEMO] scheduling base saved') + return { status: 'done' } + } catch (error) { + logger.error({ err: error }, '[DEMO] saving the scheduling base failed') + const code = (error as { code?: string }).code ?? 'ERROR' + if (logId) await finishLog(logId, false, `failed: ${code}`).catch(() => undefined) + return { status: 'failed' } + } +} + +/** + * Resets once a day: only when there is no successful reset since midnight + * (Madrid). Called on each demo entry; never throws, so the entry goes on. + */ +export async function resetIfStale(): Promise { + try { + const [rows] = await db.query( + `SELECT started_at FROM demo_reset_log + WHERE action = 'reset' AND ok = 1 + ORDER BY started_at DESC LIMIT 1` + ) + const last = rows[0]?.started_at as Date | undefined + if (last && formatDateMadrid(last) === getTodayMadrid()) return + await resetDemoData('auto') + } catch (error) { + logger.error({ err: error }, '[DEMO] daily reset check failed') + } +} + +export interface DemoStatus { + lastReset: { startedAt: Date; ok: boolean; trigger: DemoTrigger; details: string | null } | null + snapshot: { savedAt: Date; details: string | null } | null +} + +export async function getDemoStatus(): Promise { + const [resets] = await db.query( + `SELECT started_at, ok, trigger_type, details FROM demo_reset_log + WHERE action = 'reset' ORDER BY started_at DESC, id DESC LIMIT 1` + ) + const [snapshots] = await db.query( + `SELECT started_at, details FROM demo_reset_log + WHERE action = 'snapshot' AND ok = 1 ORDER BY started_at DESC, id DESC LIMIT 1` + ) + const reset = resets[0] + const snapshot = snapshots[0] + return { + lastReset: reset + ? { + startedAt: reset.started_at, + ok: Boolean(reset.ok), + trigger: reset.trigger_type, + details: reset.details, + } + : null, + snapshot: snapshot ? { savedAt: snapshot.started_at, details: snapshot.details } : null, + } +} diff --git a/backend/tests/auth/demo-login.test.ts b/backend/tests/auth/demo-login.test.ts index bc399a2..3cd2632 100644 --- a/backend/tests/auth/demo-login.test.ts +++ b/backend/tests/auth/demo-login.test.ts @@ -10,10 +10,12 @@ process.env.SECRET_JWT_KEY ??= 'test-secret-key-for-vitest-only-not-prod' vi.mock('../../repositories/auth/user-repository.js', () => ({ UserRepository: { getDemoUser: vi.fn() }, })) +vi.mock('../../services/demo/demo-reset.service.js', () => ({ resetIfStale: vi.fn() })) const { UserRepository } = await import('../../repositories/auth/user-repository.js') const { demoLogin } = await import('../../controllers/auth/auth-controllers.js') const { verifyToken } = await import('../../services/auth/tokenService.js') +const { resetIfStale } = await import('../../services/demo/demo-reset.service.js') function fakeRes() { const res = { @@ -70,6 +72,7 @@ describe('demoLogin', () => { const refresh = verifyToken(res.cookies.refresh_token) expect(access).toMatchObject({ id: 'd1', role: 'admin', demo: true, type: 'access' }) expect(refresh).toMatchObject({ id: 'd1', demo: true, type: 'refresh' }) + expect(resetIfStale).toHaveBeenCalledOnce() }) it('answers 500 without the error text when the database fails', async () => { diff --git a/backend/tests/security/demo-reset.test.ts b/backend/tests/security/demo-reset.test.ts new file mode 100644 index 0000000..51620ae --- /dev/null +++ b/backend/tests/security/demo-reset.test.ts @@ -0,0 +1,69 @@ +// tests/security/demo-reset.test.ts +// The demo reset (ADR-038) wipes every module: its routes exist only with +// DEMO_MODE=true and only for admins that are not the demo account. The mock +// file is sent as one multi-statement query, so its DELIMITER blocks must turn +// into plain SQL. Reads the sources; no database needed. + +import { describe, it, expect, vi } from 'vitest' +import { readFileSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +vi.mock('../../config/db.js', () => ({ default: {}, dbConfig: {} })) + +const { toMultiStatement } = await import('../../services/demo/demo-reset.service.js') + +const backend = join(dirname(fileURLToPath(import.meta.url)), '..', '..') +const read = (...p: string[]) => readFileSync(join(backend, ...p), 'utf8') + +describe('toMultiStatement', () => { + it('drops DELIMITER lines and ends the block with ;', () => { + const sql = [ + 'SET @a := 1;', + 'DELIMITER $$', + 'CREATE PROCEDURE p()', + 'BEGIN', + ' SELECT 1;', + 'END$$', + 'DELIMITER ;', + 'CALL p();', + ].join('\r\n') + expect(toMultiStatement(sql)).toBe( + ['SET @a := 1;', 'CREATE PROCEDURE p()', 'BEGIN', ' SELECT 1;', 'END;', 'CALL p();'].join( + '\n' + ) + ) + }) + + it('leaves mock-data.sql without DELIMITER and with its procedure closed', () => { + const out = toMultiStatement(read('db-mysql', 'mock-data.sql')) + expect(out).not.toMatch(/^\s*DELIMITER/im) + expect(out).not.toContain('$$') + expect(out).toMatch(/END;\s*\nCALL mock_require_admin\(\);/) + }) +}) + +describe('demo reset routes', () => { + const routes = read('routes', 'demo', 'demo-reset-routes.ts') + + it('answer 404 unless DEMO_MODE is on, before anything else', () => { + const gate = routes.indexOf('isDemoMode()') + expect(gate).toBeGreaterThan(-1) + expect(gate).toBeLessThan(routes.indexOf('router.use(authenticateToken)')) + }) + + it('block the demo account before checking the admin role', () => { + const auth = routes.indexOf('router.use(authenticateToken)') + const deny = routes.indexOf('router.use(denyDemo)') + const admin = routes.indexOf('router.use(isRealAdmin)') + expect(auth).toBeGreaterThan(-1) + expect(deny).toBeGreaterThan(auth) + expect(admin).toBeGreaterThan(deny) + }) + + it('reset only with DEMO_MODE exactly "true"', () => { + expect(read('services', 'demo', 'demo-reset.service.ts')).toContain( + "process.env.DEMO_MODE === 'true'" + ) + }) +}) diff --git a/frontend/app/components/profile/DemoTab.tsx b/frontend/app/components/profile/DemoTab.tsx new file mode 100644 index 0000000..ed9e77a --- /dev/null +++ b/frontend/app/components/profile/DemoTab.tsx @@ -0,0 +1,136 @@ +// app/components/profile/DemoTab.tsx +'use client' + +/** + * Configuración → Demo (ADR-038): estado del reinicio diario, reinicio a mano y + * guardar los horarios actuales como base. Solo con NEXT_PUBLIC_DEMO_MODE=true + * y para admins que no son la cuenta demo; el backend lo comprueba igual. + */ + +import { useCallback, useEffect, useState } from 'react' +import { useTranslations } from 'next-intl' +import { toast } from 'react-hot-toast' +import { FiRefreshCw, FiSave, FiAlertTriangle } from 'react-icons/fi' +import { apiClient } from '@/app/lib/apiClient' +import { API_BASE_URL } from '@/app/lib/env' +import { CenterModal, CenterModalFooterButtons } from '@/app/ui/panels' + +interface DemoStatus { + lastReset: { + startedAt: string + ok: boolean + trigger: 'auto' | 'manual' + details: string | null + } | null + snapshot: { savedAt: string; details: string | null } | null +} + +type Action = 'reset' | 'snapshot' + +const API = `${API_BASE_URL}/api/demo` + +const formatDate = (iso: string) => + new Date(iso).toLocaleString('es-ES', { dateStyle: 'short', timeStyle: 'short' }) + +export function DemoTab() { + const t = useTranslations('profile.settings.demo') + const [status, setStatus] = useState(null) + const [confirm, setConfirm] = useState(null) + const [running, setRunning] = useState(false) + + const load = useCallback(async () => { + try { + const res = await apiClient.get<{ data: DemoStatus }>(`${API}/status`) + setStatus(res.data) + } catch { + toast.error(t('loadError')) + } + }, [t]) + + useEffect(() => { + load() + }, [load]) + + const run = async () => { + if (!confirm) return + setRunning(true) + try { + await apiClient.post(`${API}/${confirm}`) + toast.success(confirm === 'reset' ? t('resetDone') : t('snapshotDone')) + setConfirm(null) + await load() + } catch { + toast.error(t('actionError')) + } finally { + setRunning(false) + } + } + + return ( +
+
+

{t('title')}

+

{t('description')}

+ +
+
+
{t('lastReset')}
+
+ {status?.lastReset + ? `${formatDate(status.lastReset.startedAt)} · ${ + status.lastReset.trigger === 'auto' ? t('auto') : t('manual') + } · ${status.lastReset.ok ? t('ok') : t('failed')}` + : t('never')} +
+
+
+
{t('snapshot')}
+
+ {status?.snapshot ? formatDate(status.snapshot.savedAt) : t('noSnapshot')} +
+
+
+ +
+ + +
+
+ + !running && setConfirm(null)} + title={confirm === 'reset' ? t('resetButton') : t('snapshotButton')} + size="sm" + headerIcon={} + footer={ + setConfirm(null)} + onSubmit={run} + submitText={t('confirm')} + isSubmitting={running} + submitVariant="danger" + /> + } + > +

+ {confirm === 'reset' ? t('resetWarning') : t('snapshotWarning')} +

+
+
+ ) +} diff --git a/frontend/app/components/profile/SettingsPanel.tsx b/frontend/app/components/profile/SettingsPanel.tsx index 2cc86de..1a46134 100644 --- a/frontend/app/components/profile/SettingsPanel.tsx +++ b/frontend/app/components/profile/SettingsPanel.tsx @@ -6,7 +6,7 @@ import { useState, useEffect, useRef, useCallback, useMemo } from 'react' import { useSearchParams, useRouter } from 'next/navigation' import { useTranslations } from 'next-intl' import { apiClient, isDemoError } from '@/app/lib/apiClient' -import { API_BASE_URL } from '@/app/lib/env' +import { API_BASE_URL, DEMO_MODE } from '@/app/lib/env' import { useAuth } from '@/app/lib/auth/useAuth' import NewUserModal from '@/app/components/auth/NewUserModal' import { UsersTableSkeleton } from '@/app/ui/skeletons' @@ -35,6 +35,7 @@ import { FiEyeOff, } from 'react-icons/fi' import { ReportsTab } from './reports' +import { DemoTab } from './DemoTab' import { CenterModal, CenterModalFooterButtons, FormField, inputClassName } from '@/app/ui/panels' import { GlobalNotificationModal } from '@/app/components/notifications/GlobalNotificationModal' import { SelectDropdown } from '@/app/ui/components/SelectDropdown' @@ -60,7 +61,7 @@ interface FormattedDepartment extends Department { displayName: string } -type SettingsTab = 'users' | 'notifications' | 'security' | 'reports' | 'departments' +type SettingsTab = 'users' | 'notifications' | 'security' | 'reports' | 'departments' | 'demo' const API_URL = API_BASE_URL @@ -91,6 +92,8 @@ export function SettingsPanel() { const router = useRouter() const searchParams = useSearchParams() const isUserAdmin = isAdminRole(currentUser?.role) + // Demo reset: only where the demo is on, and never for the demo account itself + const showDemoTab = DEMO_MODE && isUserAdmin && !currentUser?.is_demo const t = useTranslations('profile.settings') // Define tabs with translations (inside component to access t()) @@ -121,8 +124,18 @@ export function SettingsPanel() { icon: , adminOnly: true, }, + ...(showDemoTab + ? [ + { + id: 'demo' as const, + label: t('tabs.demo'), + icon: , + adminOnly: true, + }, + ] + : []), ], - [t] + [t, showDemoTab] ) const activeTab = @@ -298,6 +311,7 @@ export function SettingsPanel() { {activeTab === 'notifications' && } {activeTab === 'security' && } {activeTab === 'reports' && isUserAdmin && } + {activeTab === 'demo' && showDemoTab && } {/* Modal */} diff --git a/frontend/app/lib/logbooks/types.ts b/frontend/app/lib/logbooks/types.ts index 7410a00..9a77abf 100644 --- a/frontend/app/lib/logbooks/types.ts +++ b/frontend/app/lib/logbooks/types.ts @@ -62,6 +62,8 @@ export interface User { email?: string role?: string avatar_url?: string | null + // Public demo account (ADR-038) + is_demo?: number | boolean } // ========== HISTORIAL ========== diff --git a/frontend/messages/en/profile.json b/frontend/messages/en/profile.json index 6c11c1b..c61e29b 100644 --- a/frontend/messages/en/profile.json +++ b/frontend/messages/en/profile.json @@ -87,7 +87,8 @@ "departments": "Departments", "notifications": "Notifications", "security": "Security", - "reports": "Reports" + "reports": "Reports", + "demo": "Demo" }, "users": { "title": "User Management", @@ -156,6 +157,27 @@ "groupAdmin": "Group Admin", "receptionist": "Receptionist", "maintenance": "Maintenance" + }, + "demo": { + "title": "Public demo", + "description": "Every day, on the first demo entry, the fictitious data of every module is loaded again and the saved scheduling base is restored. You can also do it by hand here.", + "lastReset": "Last reset", + "snapshot": "Saved scheduling base", + "never": "Never", + "noSnapshot": "Not saved: the reset leaves schedules alone", + "auto": "automatic", + "manual": "by hand", + "ok": "ok", + "failed": "failed", + "resetButton": "Reset the data now", + "snapshotButton": "Save current schedules as the base", + "resetWarning": "The data of every module (logbooks, parking, groups, cashier…) is deleted and the fictitious data loaded. Schedules go back to the saved base. Users are not touched.", + "snapshotWarning": "The current schedules, with their configuration, become the base restored every day. It replaces the previous base.", + "confirm": "Confirm", + "resetDone": "Demo data reset", + "snapshotDone": "Scheduling base saved", + "loadError": "Could not load the demo status", + "actionError": "Could not finish. A reset may already be running." } }, "messages": { diff --git a/frontend/messages/es/profile.json b/frontend/messages/es/profile.json index 68bfb32..08544c2 100644 --- a/frontend/messages/es/profile.json +++ b/frontend/messages/es/profile.json @@ -87,7 +87,8 @@ "departments": "Departamentos", "notifications": "Notificaciones", "security": "Seguridad", - "reports": "Reportes" + "reports": "Reportes", + "demo": "Demo" }, "users": { "title": "Gestión de Usuarios", @@ -156,6 +157,27 @@ "groupAdmin": "Admin de Grupos", "receptionist": "Recepcionista", "maintenance": "Mantenimiento" + }, + "demo": { + "title": "Demo pública", + "description": "Cada día, con la primera entrada a la demo, se vuelven a cargar los datos ficticios de todos los módulos y se restaura la base de horarios guardada. Desde aquí puedes hacerlo a mano.", + "lastReset": "Último reinicio", + "snapshot": "Base de horarios guardada", + "never": "Nunca", + "noSnapshot": "Sin guardar: el reinicio no toca los horarios", + "auto": "automático", + "manual": "a mano", + "ok": "correcto", + "failed": "falló", + "resetButton": "Reiniciar los datos ahora", + "snapshotButton": "Guardar horarios actuales como base", + "resetWarning": "Se borran los datos de todos los módulos (partes, parking, grupos, caja…) y se cargan los ficticios. Los horarios vuelven a la base guardada. Los usuarios no se tocan.", + "snapshotWarning": "Los horarios actuales, con su configuración, pasan a ser la base que se restaura cada día. Sustituye a la base anterior.", + "confirm": "Confirmar", + "resetDone": "Datos de la demo reiniciados", + "snapshotDone": "Base de horarios guardada", + "loadError": "No se pudo cargar el estado de la demo", + "actionError": "No se pudo completar. Puede que ya haya un reinicio en marcha." } }, "messages": { From a2395a7f0a0064ff0036b4d09dea31843bd26e24 Mon Sep 17 00:00:00 2001 From: razyd Date: Mon, 5 Oct 2026 15:41:01 +0200 Subject: [PATCH 09/20] docs(db): mark the demo reset log migration as applied on Aiven - INDEX.md: 20261005_add_demo_reset_log.sql applied on Aiven Why: the table has to exist before the reset code reaches preview. --- backend/db-mysql/INDEX.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/db-mysql/INDEX.md b/backend/db-mysql/INDEX.md index 3439224..bb7042e 100644 --- a/backend/db-mysql/INDEX.md +++ b/backend/db-mysql/INDEX.md @@ -105,7 +105,7 @@ db-mysql/ | 2026-10-04 | `20261004_add_messages_to_notifications_module.sql` | Añade `messages` al ENUM `notifications.module`. Desde `9be16b1` el aviso de mensaje urgente se guarda con ese módulo y el INSERT fallaba en silencio («Data truncated»). Solo cambia la columna si tiene la definición anterior exacta. En local ya estaba (BD montada desde el baseline); en Aiven se aplicó el 2026-10-04. | ✅ local · ✅ Aiven | | 2026-10-04 | `20261004_fix_parking_availability_triggers.sql` | Recrea `trg_free_availability_on_status_change` (solo libera los días de la propia reserva) y `trg_update_availability_on_date_change` (solo ocupa días libres o propios). Antes completar, cancelar o mover una reserva podía liberar u ocupar días de otra. La comprobación de solapes está en `bookings.repository.ts`. Local tenía otro cuerpo de `date_change`; se parte del de Aiven. | ⏳ local · ✅ Aiven | | 2026-10-05 | `20261005_add_is_demo_to_users.sql` | Añade `users.is_demo` (0 por defecto): marca la cuenta demo pública, que el backend lleva en el token y `demoRestriction` limita (ADR-038). Hay que aplicarla **antes** de desplegar el código: el login lee la columna. Solo añade la columna si no existe. En Aiven se aplicó el 2026-10-05, antes del código. | ✅ local · ✅ Aiven | -| 2026-10-05 | `20261005_add_demo_reset_log.sql` | Crea `demo_reset_log`: un registro por cada reinicio diario de la demo y por cada base de horarios guardada (ADR-038). La base vive en tablas `demo_snapshot_scheduling_*` que crea el backend al guardarla. Hay que aplicarla antes de desplegar el código. `CREATE TABLE IF NOT EXISTS`. | ✅ local · ⏳ Aiven | +| 2026-10-05 | `20261005_add_demo_reset_log.sql` | Crea `demo_reset_log`: un registro por cada reinicio diario de la demo y por cada base de horarios guardada (ADR-038). La base vive en tablas `demo_snapshot_scheduling_*` que crea el backend al guardarla. Hay que aplicarla antes de desplegar el código. `CREATE TABLE IF NOT EXISTS`. | ✅ local · ✅ Aiven | ### Verificación (99) From 7e7ed393cd68ab77c1f737e1da97b105ab15a932 Mon Sep 17 00:00:00 2001 From: razyd Date: Mon, 5 Oct 2026 15:46:46 +0200 Subject: [PATCH 10/20] test(auth): mock the demo reset in the login timing test - login-timing.test.ts mocks services/demo/demo-reset.service.js Why: the auth controller now imports the demo reset, which opens the database config; CI has no .env, so the suite failed to load. --- backend/tests/auth/login-timing.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/tests/auth/login-timing.test.ts b/backend/tests/auth/login-timing.test.ts index cf1da2c..6c0c54e 100644 --- a/backend/tests/auth/login-timing.test.ts +++ b/backend/tests/auth/login-timing.test.ts @@ -9,6 +9,8 @@ import type { Request, Response } from 'express' vi.mock('../../repositories/auth/user-repository.js', () => ({ UserRepository: { login: vi.fn() }, })) +// The demo reset opens its own database connection: not part of this test +vi.mock('../../services/demo/demo-reset.service.js', () => ({ resetIfStale: vi.fn() })) const { UserRepository } = await import('../../repositories/auth/user-repository.js') const { login, LOGIN_FAILURE_MIN_MS } = await import('../../controllers/auth/auth-controllers.js') From ee9cbc0166ed5fa2e991d8b52807f8dff2764201 Mon Sep 17 00:00:00 2001 From: razyd Date: Mon, 5 Oct 2026 16:08:59 +0200 Subject: [PATCH 11/20] fix(db): load the mock data on a database with a single admin - mock-data.sql: cashier_shift_users uses INSERT IGNORE Why: with one user every role falls back to the same admin, so shift 2 listed it twice and the unique key stopped the load; a fresh local install (pnpm setup:local) has exactly one user. --- backend/db-mysql/mock-data.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/db-mysql/mock-data.sql b/backend/db-mysql/mock-data.sql index 412ab6a..a3e4ee7 100644 --- a/backend/db-mysql/mock-data.sql +++ b/backend/db-mysql/mock-data.sql @@ -264,7 +264,7 @@ INSERT INTO cashier_shifts (id, shift_date, shift_type, status, initial_fund, in (5, CURDATE(), 'night', 'closed', 200.00, 120.00, 300.00, 300.00, 0.00, 150.00, 270.00, @recep2, @recep2, NOW() - INTERVAL 3 HOUR), (6, CURDATE(), 'morning', 'open', 200.00, 0.00, 0.00, 180.00, 0.00, 0.00, 0.00, @recep1, NULL, NULL); -INSERT INTO cashier_shift_users (shift_id, user_id, is_primary) VALUES +INSERT IGNORE INTO cashier_shift_users (shift_id, user_id, is_primary) VALUES (1, @recep2, 1), (2, @recep1, 1), (2, @recep2, 0), (3, @recep2, 1), (4, @recep1, 1), (5, @recep2, 1), (6, @recep1, 1); From a8882c42e98eeb7c77edabbfcb5c7b931c793baa Mon Sep 17 00:00:00 2001 From: razyd Date: Mon, 5 Oct 2026 16:08:59 +0200 Subject: [PATCH 12/20] feat(db): install a local database from scratch with pnpm setup:local - docker-compose.yml: MySQL 8.0 only, bound to 127.0.0.1, optional - scripts/setup-local.ts: MASTER_INSTALL.sql plus the scripts after its baseline, departments, an admin with a random password shown once and the mock data; refuses non-local hosts, another database name or a database with tables (unless --force) - fixes in memory, as exact replacements, the two baseline bugs that stop a fresh install (duplicate FK name in 11_cashier, FK to a later table in 19_scheduling); a test fails if the baseline text changes - 20261005_complete_fresh_install.sql: creates cashier_history, scheduling_assignments and demo_activity_log if missing and widens roles.name (no-op on Aiven; created demo_activity_log locally) - config/db-config.ts: connection settings without opening the pool, so a script can read them before the database exists - services/db/sql-script.ts: SOURCE and DELIMITER handling shared by the demo reset and the setup - README: try the demo, run it locally; .env.example defaults to local Why: anyone who clones the repository can run it as a full administrator (ADR-038). Checked on a clean MySQL 8.0 container: same columns, indexes, keys, triggers and routines as Aiven (1161 of 1161) and the same catalog rows; the app signs in with the generated admin. --- README.md | 42 ++++- backend/.env.example | 4 +- backend/config/db-config.ts | 85 +++++++++ backend/config/db.ts | 83 +-------- backend/db-mysql/INDEX.md | 1 + .../20261005_complete_fresh_install.sql | 99 ++++++++++ backend/package.json | 1 + backend/scripts/setup-local.ts | 169 ++++++++++++++++++ backend/services/db/sql-script.ts | 50 ++++++ backend/services/demo/demo-reset.service.ts | 27 +-- backend/tests/security/demo-reset.test.ts | 6 +- backend/tests/security/setup-local.test.ts | 54 ++++++ docker-compose.yml | 26 +++ 13 files changed, 530 insertions(+), 117 deletions(-) create mode 100644 backend/config/db-config.ts create mode 100644 backend/db-mysql/scripts/20261005_complete_fresh_install.sql create mode 100644 backend/scripts/setup-local.ts create mode 100644 backend/services/db/sql-script.ts create mode 100644 backend/tests/security/setup-local.test.ts create mode 100644 docker-compose.yml diff --git a/README.md b/README.md index 5abd022..5b75dcd 100644 --- a/README.md +++ b/README.md @@ -24,15 +24,47 @@ Full documentation lives in [`docs/`](docs/general/README.md): - [Blacklist](docs/blacklist/README.md) - [Conciliation](docs/conciliation/README.md) -## Local development +## Try it + +A public demo runs on the project site: **Try our live demo** on the login +page signs you in as an administrator, with fictitious data that resets every +day. User management and settings are locked there. To see everything, run it +locally. + +## Run it locally + +You need Node ≥ 22.16, pnpm, Python 3.11+ (for the scheduling solver) and +MySQL 8, either installed or through Docker. `frontend/` and `backend/` are +independent pnpm projects, each with its own lockfile. ```bash -cd backend && pnpm install && pnpm dev:aiven # API against Aiven; frontend looks for it on :4000 -cd frontend && pnpm install && pnpm dev # http://localhost:3000 +# 1. Settings: copy and fill in (LOCAL_DB_USER=root, a LOCAL_DB_PASSWORD of +# your choice, a long random SECRET_JWT_KEY) +cp backend/.env.example backend/.env +cp frontend/.env.example frontend/.env.local + +# 2. MySQL in Docker (skip it if you already run MySQL 8; if that one uses +# port 3306, set LOCAL_DB_PORT=3307 for Docker) +docker compose --env-file backend/.env up -d + +# 3. Database: tables, departments, an admin and fictitious data. +# It prints the admin password once; it creates hotel_db and refuses to +# touch one that already has tables (add -- --force to replace it) +cd backend && pnpm install && pnpm setup:local + +# 4. Scheduling solver (once) +cd scheduling-solver && python -m venv venv +venv/Scripts/pip install -r requirements.txt # Linux/macOS: venv/bin/pip +cd .. + +# 5. Run +pnpm dev:local # API on :4000 +cd ../frontend && pnpm install && pnpm dev # http://localhost:3000 ``` -Node ≥ 22.16, pnpm. `frontend/` and `backend/` are independent pnpm projects, -each with its own lockfile. +Locally you are a full administrator: users, roles and settings included. +Files (photos, PDFs) need a Cloudinary account in `backend/.env`; without it +everything else works. ## License diff --git a/backend/.env.example b/backend/.env.example index fecb261..41aac9c 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -22,9 +22,9 @@ SECRET_JWT_KEY=change-me-to-a-random-string-of-32-or-more-characters SALT_ROUNDS=10 # ============================= -# MySQL: which preset config/db.ts uses (local | aiven, default: local) +# MySQL: which preset config/db-config.ts uses (local | aiven, default: local) # ============================= -DB_ENVIRONMENT=aiven +DB_ENVIRONMENT=local # Local MySQL LOCAL_DB_HOST=localhost diff --git a/backend/config/db-config.ts b/backend/config/db-config.ts new file mode 100644 index 0000000..51e4fd9 --- /dev/null +++ b/backend/config/db-config.ts @@ -0,0 +1,85 @@ +// config/db-config.ts +// Connection settings only, without opening anything: scripts that create the +// database (scripts/setup-local.ts) read them before it exists. config/db.ts +// opens the pool with them. + +import dotenv from 'dotenv' +import fs from 'fs' +import path from 'path' +import { fileURLToPath } from 'url' + +dotenv.config() + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +// Detectar entorno +const isDevelopment = process.env.NODE_ENV === 'development' + +// ======================================== +// 🔧 INTERFAZ DE CONFIGURACIÓN +// ======================================== +interface DBConfig { + // ← AÑADIDO + host: string + port: number + user: string + password: string | undefined + database: string + ssl?: any +} + +// ======================================== +// 🎯 PRESETS DE CONFIGURACIÓN +// ======================================== +const presets: Record = { + // ← MODIFICADO (añadido tipo) + local: { + host: process.env.LOCAL_DB_HOST || 'localhost', + port: parseInt(process.env.LOCAL_DB_PORT || '3306'), // ← MODIFICADO (añadido fallback) + user: process.env.LOCAL_DB_USER || 'root', + password: process.env.LOCAL_DB_PASSWORD, + database: process.env.LOCAL_DB_NAME || 'hotel_db', + }, + aiven: { + host: process.env.AIVEN_DB_HOST || '', // ← MODIFICADO (añadido fallback) + port: parseInt(process.env.AIVEN_DB_PORT || ''), + user: process.env.AIVEN_DB_USER || '', + password: process.env.AIVEN_PASSWORD, + database: process.env.AIVEN_DB_NAME || 'hotel_db', + ssl: isDevelopment + ? { rejectUnauthorized: false } + : { + ca: fs.readFileSync(path.join(__dirname, 'certs', 'ca-certificate.pem')), + rejectUnauthorized: true, + }, + }, +} + +// ======================================== +// 🔍 VALIDACIÓN Y SELECCIÓN DE ENTORNO +// ======================================== +export const environment = process.env.DB_ENVIRONMENT || 'local' +const config = presets[environment] + +// Validar que el entorno existe +if (!config) { + throw new Error(`❌ Entorno no válido: ${environment}`) +} + +// Validar credenciales críticas +if (!config.password) { + throw new Error( + `❌ Falta ${environment === 'local' ? 'LOCAL_DB_PASSWORD' : 'AIVEN_PASSWORD'} en .env` + ) +} + +if (environment === 'aiven' && !config.host) { + throw new Error('❌ Falta AIVEN_DB_HOST en .env') +} + +if (environment === 'aiven' && (!config.user || !Number.isInteger(config.port))) { + throw new Error('❌ Faltan AIVEN_DB_USER o AIVEN_DB_PORT en .env') +} + +export const dbConfig = config diff --git a/backend/config/db.ts b/backend/config/db.ts index fda0dca..fa1c0cf 100644 --- a/backend/config/db.ts +++ b/backend/config/db.ts @@ -3,86 +3,7 @@ import mysql from 'mysql2/promise' import type { Pool } from 'mysql2/promise' // ← AÑADIDO import { logger } from './logger.js' -import dotenv from 'dotenv' -import fs from 'fs' -import path from 'path' -import { fileURLToPath } from 'url' - -// Cargar variables de entorno -dotenv.config() - -// Para usar __dirname en ES modules -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) - -// Detectar entorno -const isDevelopment = process.env.NODE_ENV === 'development' - -// ======================================== -// 🔧 INTERFAZ DE CONFIGURACIÓN -// ======================================== -interface DBConfig { - // ← AÑADIDO - host: string - port: number - user: string - password: string | undefined - database: string - ssl?: any -} - -// ======================================== -// 🎯 PRESETS DE CONFIGURACIÓN -// ======================================== -const presets: Record = { - // ← MODIFICADO (añadido tipo) - local: { - host: process.env.LOCAL_DB_HOST || 'localhost', - port: parseInt(process.env.LOCAL_DB_PORT || '3306'), // ← MODIFICADO (añadido fallback) - user: process.env.LOCAL_DB_USER || 'root', - password: process.env.LOCAL_DB_PASSWORD, - database: process.env.LOCAL_DB_NAME || 'hotel_db', - }, - aiven: { - host: process.env.AIVEN_DB_HOST || '', // ← MODIFICADO (añadido fallback) - port: parseInt(process.env.AIVEN_DB_PORT || ''), - user: process.env.AIVEN_DB_USER || '', - password: process.env.AIVEN_PASSWORD, - database: process.env.AIVEN_DB_NAME || 'hotel_db', - ssl: isDevelopment - ? { rejectUnauthorized: false } - : { - ca: fs.readFileSync(path.join(__dirname, 'certs', 'ca-certificate.pem')), - rejectUnauthorized: true, - }, - }, -} - -// ======================================== -// 🔍 VALIDACIÓN Y SELECCIÓN DE ENTORNO -// ======================================== -const environment = process.env.DB_ENVIRONMENT || 'local' -const config = presets[environment] - -// Validar que el entorno existe -if (!config) { - throw new Error(`❌ Entorno no válido: ${environment}`) -} - -// Validar credenciales críticas -if (!config.password) { - throw new Error( - `❌ Falta ${environment === 'local' ? 'LOCAL_DB_PASSWORD' : 'AIVEN_PASSWORD'} en .env` - ) -} - -if (environment === 'aiven' && !config.host) { - throw new Error('❌ Falta AIVEN_DB_HOST en .env') -} - -if (environment === 'aiven' && (!config.user || !Number.isInteger(config.port))) { - throw new Error('❌ Faltan AIVEN_DB_USER o AIVEN_DB_PORT en .env') -} +import { dbConfig as config, environment } from './db-config.js' // ======================================== // 📊 LOG DE CONFIGURACIÓN (sin passwords) @@ -123,6 +44,6 @@ async function connectWithRetry(retries = 3, delayMs = 3000): Promise { connectWithRetry() // For one-off connections with other options (services/demo: multipleStatements) -export { config as dbConfig } +export { dbConfig } from './db-config.js' export default pool diff --git a/backend/db-mysql/INDEX.md b/backend/db-mysql/INDEX.md index bb7042e..eb7326b 100644 --- a/backend/db-mysql/INDEX.md +++ b/backend/db-mysql/INDEX.md @@ -106,6 +106,7 @@ db-mysql/ | 2026-10-04 | `20261004_fix_parking_availability_triggers.sql` | Recrea `trg_free_availability_on_status_change` (solo libera los días de la propia reserva) y `trg_update_availability_on_date_change` (solo ocupa días libres o propios). Antes completar, cancelar o mover una reserva podía liberar u ocupar días de otra. La comprobación de solapes está en `bookings.repository.ts`. Local tenía otro cuerpo de `date_change`; se parte del de Aiven. | ⏳ local · ✅ Aiven | | 2026-10-05 | `20261005_add_is_demo_to_users.sql` | Añade `users.is_demo` (0 por defecto): marca la cuenta demo pública, que el backend lleva en el token y `demoRestriction` limita (ADR-038). Hay que aplicarla **antes** de desplegar el código: el login lee la columna. Solo añade la columna si no existe. En Aiven se aplicó el 2026-10-05, antes del código. | ✅ local · ✅ Aiven | | 2026-10-05 | `20261005_add_demo_reset_log.sql` | Crea `demo_reset_log`: un registro por cada reinicio diario de la demo y por cada base de horarios guardada (ADR-038). La base vive en tablas `demo_snapshot_scheduling_*` que crea el backend al guardarla. Hay que aplicarla antes de desplegar el código. `CREATE TABLE IF NOT EXISTS`. | ✅ local · ✅ Aiven | +| 2026-10-05 | `20261005_complete_fresh_install.sql` | Completa una instalación desde cero: el baseline congelado no crea `cashier_history` (nombre de clave duplicado), `scheduling_assignments` (referencia a una tabla creada después) ni `demo_activity_log`, y deja `roles.name` en `varchar(50)`. Definiciones copiadas de Aiven. En Aiven no cambia nada; en local creó `demo_activity_log`, que faltaba. `pnpm setup:local` además corrige esos dos fallos del baseline en memoria. | ✅ local · ⏳ Aiven (sin cambios) | ### Verificación (99) diff --git a/backend/db-mysql/scripts/20261005_complete_fresh_install.sql b/backend/db-mysql/scripts/20261005_complete_fresh_install.sql new file mode 100644 index 0000000..fb6ba07 --- /dev/null +++ b/backend/db-mysql/scripts/20261005_complete_fresh_install.sql @@ -0,0 +1,99 @@ +-- ============================================================================= +-- 20261005_complete_fresh_install.sql +-- ============================================================================= +-- Completes a database installed from scratch with MASTER_INSTALL.sql, so it +-- ends up like Aiven (compared column by column on 2026-10-05, ADR-038). The +-- frozen baseline (aiven/NN, ADR-021) leaves out three tables: +-- +-- - cashier_history: aiven/11_cashier.sql names its foreign key +-- fk_history_user, already used by logbook_history, and the CREATE fails. +-- - scheduling_assignments: aiven/19_scheduling.sql creates it before +-- scheduling_constraints, which it references, and the CREATE fails. +-- - demo_activity_log: MASTER_INSTALL.sql skips aiven/15_demo_user.sql and its +-- migration predates the baseline. +-- +-- And roles.name is varchar(50) there, varchar(255) on Aiven. +-- +-- Definitions copied from Aiven (SHOW CREATE TABLE). On Aiven and on any +-- database that already has them it does nothing. +-- +-- Idempotent: CREATE TABLE IF NOT EXISTS; the ALTER runs only on varchar(50). +-- +-- Applies to: LOCAL + AIVEN (no-op on both today) + fresh installs. +-- Rollback: not needed; it only adds what a fresh install lacks. +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS `cashier_history` ( + `id` int NOT NULL AUTO_INCREMENT, + `shift_id` int NOT NULL COMMENT 'Turno afectado', + `action` enum('created','updated','deleted','status_changed','adjustment','voucher_created','voucher_repaid','daily_closed','daily_reopened') NOT NULL, + `table_affected` varchar(100) DEFAULT NULL COMMENT 'Tabla modificada', + `record_id` int DEFAULT NULL COMMENT 'ID del registro modificado', + `field_changed` varchar(100) DEFAULT NULL COMMENT 'Campo modificado', + `old_value` text COMMENT 'Valor anterior', + `new_value` text COMMENT 'Valor nuevo', + `changed_by` char(36) DEFAULT NULL COMMENT 'Usuario que realizó el cambio', + `changed_at` datetime DEFAULT CURRENT_TIMESTAMP, + `notes` text COMMENT 'Notas adicionales', + PRIMARY KEY (`id`), + KEY `fk_cashier_history_user` (`changed_by`), + KEY `idx_history_shift` (`shift_id`), + KEY `idx_history_action` (`action`), + KEY `idx_history_changed_at` (`changed_at`), + CONSTRAINT `fk_cashier_history_shift` FOREIGN KEY (`shift_id`) REFERENCES `cashier_shifts` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_cashier_history_user` FOREIGN KEY (`changed_by`) REFERENCES `users` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `scheduling_assignments` ( + `id` int NOT NULL AUTO_INCREMENT, + `month_id` int NOT NULL COMMENT 'ID del planning mensual', + `day_id` int NOT NULL COMMENT 'ID del día', + `employee_id` char(36) NOT NULL COMMENT 'ID del empleado', + `shift_code` varchar(5) NOT NULL COMMENT 'Código del turno asignado', + `source_constraint_id` int DEFAULT NULL COMMENT 'Constraint origen (si la celda está precargada/bloqueada)', + `notes` varchar(255) DEFAULT NULL COMMENT 'Notas de la asignación', + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `libre_number` int DEFAULT NULL COMMENT 'Par de libre semanal al que pertenece esta asignación (1-45)', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_day_employee` (`day_id`,`employee_id`), + KEY `idx_month_id` (`month_id`), + KEY `idx_employee_id` (`employee_id`), + KEY `idx_shift_code` (`shift_code`), + KEY `idx_source_constraint_id` (`source_constraint_id`), + CONSTRAINT `fk_sched_assign_day` FOREIGN KEY (`day_id`) REFERENCES `scheduling_days` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_sched_assign_employee` FOREIGN KEY (`employee_id`) REFERENCES `users` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_sched_assign_month` FOREIGN KEY (`month_id`) REFERENCES `scheduling_months` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_sched_assign_shift` FOREIGN KEY (`shift_code`) REFERENCES `scheduling_shifts` (`code`) ON DELETE RESTRICT, + CONSTRAINT `fk_sched_assign_source_constraint` FOREIGN KEY (`source_constraint_id`) REFERENCES `scheduling_constraints` (`id`) ON DELETE SET NULL ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `demo_activity_log` ( + `id` int NOT NULL AUTO_INCREMENT, + `timestamp` datetime DEFAULT CURRENT_TIMESTAMP, + `user_id` varchar(36) DEFAULT NULL, + `username` varchar(100) DEFAULT NULL, + `method` varchar(10) NOT NULL, + `route` varchar(255) NOT NULL, + `body_preview` text, + `ip_address` varchar(45) DEFAULT NULL, + `user_agent` text, + `blocked` tinyint(1) DEFAULT '1', + PRIMARY KEY (`id`), + KEY `idx_demo_log_timestamp` (`timestamp`), + KEY `idx_demo_log_username` (`username`), + KEY `idx_demo_log_route` (`route`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +SET @roles_name := ( + SELECT COLUMN_TYPE FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'roles' AND COLUMN_NAME = 'name' +); +SET @ddl := IF( + @roles_name = 'varchar(50)', + 'ALTER TABLE roles MODIFY COLUMN name VARCHAR(255) NOT NULL', + 'SELECT ''roles.name already wider than varchar(50), skipping'' AS info' +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/backend/package.json b/backend/package.json index aa0a961..0ad318a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -8,6 +8,7 @@ "dev": "tsx watch index.ts", "dev:local": "cross-env DB_ENVIRONMENT=local tsx watch index.ts", "dev:aiven": "cross-env DB_ENVIRONMENT=aiven tsx watch index.ts", + "setup:local": "cross-env DB_ENVIRONMENT=local tsx scripts/setup-local.ts", "demo:user": "cross-env DB_ENVIRONMENT=local tsx scripts/create-demo-user.ts", "demo:user:aiven": "cross-env DB_ENVIRONMENT=aiven tsx scripts/create-demo-user.ts", "build": "python3 -m venv scheduling-solver/venv && scheduling-solver/venv/bin/pip install --no-cache-dir -r scheduling-solver/requirements.txt", diff --git a/backend/scripts/setup-local.ts b/backend/scripts/setup-local.ts new file mode 100644 index 0000000..8fbf758 --- /dev/null +++ b/backend/scripts/setup-local.ts @@ -0,0 +1,169 @@ +/** + * setup-local.ts — `pnpm setup:local` + * + * Monta una BD local desde cero para probar la aplicación con un admin + * completo (ADR-038): + * 1. tablas: MASTER_INSTALL.sql y las migraciones posteriores a su baseline; + * 2. departamentos que usan los módulos y el mock; + * 3. un admin con contraseña aleatoria, que se muestra una sola vez; + * 4. datos ficticios (mock-data.sql). + * + * MASTER_INSTALL.sql borra y crea `hotel_db`: el script se niega a seguir si + * la BD no es local, si no se llama hotel_db o si ya tiene tablas (salvo + * --force). + * + * Uso: + * pnpm setup:local # admin `admin` + * pnpm setup:local -- --admin maria # otro nombre + * pnpm setup:local -- --force # borra una hotel_db que ya exista + */ + +import bcrypt from 'bcrypt' +import crypto from 'node:crypto' +import { readdir } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import mysql from 'mysql2/promise' +import type { RowDataPacket } from 'mysql2/promise' +import { dbConfig } from '../config/db-config.js' +import { SALT_ROUNDS } from '../config/config.js' +import { readSqlScript } from '../services/db/sql-script.js' + +const DB_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'db-mysql') +const DATABASE = 'hotel_db' // fixed in MASTER_INSTALL.sql + +// MASTER_INSTALL.sql is the baseline frozen on 2026-05-20; later changes are +// the incremental scripts from this date on (db-mysql/INDEX.md) +const FIRST_SCRIPT_AFTER_BASELINE = '20260521' + +// Used by the modules and by mock-data.sql, which looks them up by name +const DEPARTMENTS = [ + 'pisos', + 'mantenimiento', + 'reservas', + 'parking', + 'clientes', + 'backoffice', + 'blacklist', + 'grupos', +] + +const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '::1']) + +// Bugs of the frozen baseline (aiven/NN, ADR-021) that stop a fresh install; +// on Aiven those tables were created another way. Fixed here, in memory, as +// exact replacements: if the text no longer matches, the setup stops instead +// of guessing. scripts/20261005_complete_fresh_install.sql completes the same +// tables for an install made by hand with the mysql client. +const BASELINE_FIXES: Array<{ file: string; from: string; to: string; why: string }> = [ + { + file: '11_cashier.sql', + from: 'CONSTRAINT fk_history_user FOREIGN KEY (changed_by)', + to: 'CONSTRAINT fk_cashier_history_user FOREIGN KEY (changed_by)', + why: 'fk_history_user already belongs to logbook_history; Aiven uses this name', + }, + { + file: '11_cashier.sql', + from: 'CONSTRAINT fk_history_shift FOREIGN KEY (shift_id)', + to: 'CONSTRAINT fk_cashier_history_shift FOREIGN KEY (shift_id)', + why: 'same name as on Aiven', + }, + { + file: '19_scheduling.sql', + from: 'DROP TABLE IF EXISTS scheduling_config;\n\nSET FOREIGN_KEY_CHECKS = 1;', + to: 'DROP TABLE IF EXISTS scheduling_config;\n\nSET FOREIGN_KEY_CHECKS = 0;', + why: 'scheduling_assignments references scheduling_constraints, created after it', + }, +] + +function fixBaseline(path: string, text: string): string { + for (const fix of BASELINE_FIXES.filter((f) => path.endsWith(f.file))) { + if (text.split(fix.from).length !== 2) { + throw new Error(`El baseline ${fix.file} ha cambiado; revisa BASELINE_FIXES (${fix.why})`) + } + text = text.replace(fix.from, fix.to) + } + return text +} + +function arg(name: string): string | undefined { + const i = process.argv.indexOf(`--${name}`) + return i === -1 ? undefined : process.argv[i + 1] +} + +async function main(): Promise { + if ((process.env.DB_ENVIRONMENT || 'local') !== 'local' || !LOCAL_HOSTS.has(dbConfig.host)) { + throw new Error('Solo para una BD local (DB_ENVIRONMENT=local y LOCAL_DB_HOST=localhost)') + } + if (dbConfig.database !== DATABASE) { + throw new Error(`MASTER_INSTALL.sql crea "${DATABASE}": pon LOCAL_DB_NAME=${DATABASE}`) + } + + const adminName = arg('admin') ?? 'admin' + const { database: _db, ...server } = dbConfig + const conn = await mysql.createConnection({ ...server, multipleStatements: true }) + + try { + const [existing] = await conn.query( + 'SELECT COUNT(*) AS n FROM information_schema.tables WHERE table_schema = ?', + [DATABASE] + ) + if (Number(existing[0].n) > 0 && !process.argv.includes('--force')) { + throw new Error( + `"${DATABASE}" ya tiene tablas y MASTER_INSTALL.sql la borraría. ` + + 'Si es lo que quieres: pnpm setup:local -- --force' + ) + } + + console.log('1/4 Tablas (MASTER_INSTALL.sql)') + await conn.query(await readSqlScript(join(DB_DIR, 'MASTER_INSTALL.sql'), DB_DIR, fixBaseline)) + await conn.query(`USE \`${DATABASE}\``) + + const scripts = (await readdir(join(DB_DIR, 'scripts'))) + .filter((f) => f.endsWith('.sql') && f.slice(0, 8) >= FIRST_SCRIPT_AFTER_BASELINE) + .sort() + for (const file of scripts) { + console.log(` + scripts/${file}`) + await conn.query(await readSqlScript(join(DB_DIR, 'scripts', file))) + } + + console.log('2/4 Departamentos') + await conn.query('INSERT IGNORE INTO departments (name) VALUES ?', [ + DEPARTMENTS.map((name) => [name]), + ]) + + console.log('3/4 Admin') + const password = crypto.randomBytes(12).toString('base64url') + const [roles] = await conn.query("SELECT id FROM roles WHERE name = 'admin'") + await conn.query( + `INSERT INTO users (id, username, email, password, role_id, created_at, is_active) + VALUES (?, ?, ?, ?, ?, NOW(), 1)`, + [ + crypto.randomUUID(), + adminName, + `${adminName}@example.com`, + await bcrypt.hash(password, SALT_ROUNDS), + roles[0].id, + ] + ) + + console.log('4/4 Datos ficticios (mock-data.sql)') + // MASTER_INSTALL.sql leaves safe updates on for this session; the mock empties whole tables + await conn.query('SET SQL_SAFE_UPDATES = 0') + await conn.query(await readSqlScript(join(DB_DIR, 'mock-data.sql'))) + + console.log('\nListo. Entra en http://localhost:3000 con:') + console.log(` usuario: ${adminName}`) + console.log(` contraseña: ${password}`) + console.log('Guárdala: no se vuelve a mostrar. Puedes cambiarla en Perfil.') + } finally { + await conn.end() + } +} + +main() + .then(() => process.exit(0)) + .catch((error: unknown) => { + console.error(`\n✗ ${error instanceof Error ? error.message : error}`) + process.exit(1) + }) diff --git a/backend/services/db/sql-script.ts b/backend/services/db/sql-script.ts new file mode 100644 index 0000000..cffa77c --- /dev/null +++ b/backend/services/db/sql-script.ts @@ -0,0 +1,50 @@ +// services/db/sql-script.ts +/** + * Ficheros .sql del repositorio para enviarlos en una sola petición + * multi-statement desde Node, sin el cliente mysql: SOURCE y DELIMITER son + * comandos de ese cliente, no SQL. Solo para ficheros del repositorio, nunca + * para texto que venga de una petición. + */ + +import { readFile } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' + +/** + * Removes DELIMITER lines; the server already parses CREATE PROCEDURE ... END + * as one statement, so the custom delimiter goes back to ';' + */ +export function toMultiStatement(sql: string): string { + let delimiter = ';' + const out: string[] = [] + for (const line of sql.split(/\r?\n/)) { + const change = /^\s*DELIMITER\s+(\S+)\s*$/i.exec(line) + if (change) { + delimiter = change[1] + continue + } + out.push( + delimiter !== ';' && line.trimEnd().endsWith(delimiter) + ? line.trimEnd().slice(0, -delimiter.length) + ';' + : line + ) + } + return out.join('\n') +} + +/** + * Reads a .sql file and inlines its `SOURCE path;` lines (relative to root, as + * MASTER_INSTALL.sql expects). `transform` may adjust each file's text first + */ +export async function readSqlScript( + file: string, + root = dirname(file), + transform: (path: string, text: string) => string = (_path, text) => text +): Promise { + const text = transform(file, (await readFile(file, 'utf8')).replace(/\r\n/g, '\n')) + const parts: string[] = [] + for (const line of text.split('\n')) { + const source = /^\s*SOURCE\s+(.+?);?\s*$/i.exec(line) + parts.push(source ? await readSqlScript(resolve(root, source[1]), root, transform) : line) + } + return toMultiStatement(parts.join('\n')) +} diff --git a/backend/services/demo/demo-reset.service.ts b/backend/services/demo/demo-reset.service.ts index b91c6b3..a03ca6c 100644 --- a/backend/services/demo/demo-reset.service.ts +++ b/backend/services/demo/demo-reset.service.ts @@ -17,7 +17,6 @@ * repositorio y nunca lleva datos de la petición. */ -import { readFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import mysql from 'mysql2/promise' @@ -25,6 +24,7 @@ import type { Connection, RowDataPacket } from 'mysql2/promise' import db, { dbConfig } from '../../config/db.js' import { formatDateMadrid, getTodayMadrid } from '../../config/date-utils.js' import { logger } from '../../config/logger.js' +import { readSqlScript } from '../db/sql-script.js' const MOCK_FILE = join( dirname(fileURLToPath(import.meta.url)), @@ -63,29 +63,6 @@ export interface DemoResetResult { export const isDemoMode = (): boolean => process.env.DEMO_MODE === 'true' -/** - * mock-data.sql for one multi-statement query: DELIMITER is a command of the - * mysql client, not SQL. The server already parses CREATE PROCEDURE ... END as - * one statement, so the custom delimiter goes back to ';'. - */ -export function toMultiStatement(sql: string): string { - let delimiter = ';' - const out: string[] = [] - for (const line of sql.split(/\r?\n/)) { - const change = /^\s*DELIMITER\s+(\S+)\s*$/i.exec(line) - if (change) { - delimiter = change[1] - continue - } - out.push( - delimiter !== ';' && line.trimEnd().endsWith(delimiter) - ? line.trimEnd().slice(0, -delimiter.length) + ';' - : line - ) - } - return out.join('\n') -} - async function openConnection(): Promise { return mysql.createConnection({ ...dbConfig, multipleStatements: true }) } @@ -167,7 +144,7 @@ export async function resetDemoData( try { const result = await withLock(async (conn) => { logId = await startLog('reset', trigger, userId) - const mock = toMultiStatement(await readFile(MOCK_FILE, 'utf8')) + const mock = await readSqlScript(MOCK_FILE) try { await conn.query(mock) } catch (error) { diff --git a/backend/tests/security/demo-reset.test.ts b/backend/tests/security/demo-reset.test.ts index 51620ae..32b2212 100644 --- a/backend/tests/security/demo-reset.test.ts +++ b/backend/tests/security/demo-reset.test.ts @@ -4,14 +4,12 @@ // file is sent as one multi-statement query, so its DELIMITER blocks must turn // into plain SQL. Reads the sources; no database needed. -import { describe, it, expect, vi } from 'vitest' +import { describe, it, expect } from 'vitest' import { readFileSync } from 'node:fs' import { join, dirname } from 'node:path' import { fileURLToPath } from 'node:url' -vi.mock('../../config/db.js', () => ({ default: {}, dbConfig: {} })) - -const { toMultiStatement } = await import('../../services/demo/demo-reset.service.js') +import { toMultiStatement } from '../../services/db/sql-script.js' const backend = join(dirname(fileURLToPath(import.meta.url)), '..', '..') const read = (...p: string[]) => readFileSync(join(backend, ...p), 'utf8') diff --git a/backend/tests/security/setup-local.test.ts b/backend/tests/security/setup-local.test.ts new file mode 100644 index 0000000..21927c6 --- /dev/null +++ b/backend/tests/security/setup-local.test.ts @@ -0,0 +1,54 @@ +// tests/security/setup-local.test.ts +// pnpm setup:local (ADR-038) installs from the frozen baseline and fixes, in +// memory, the bugs that stop a fresh install. Each fix is an exact +// replacement: these tests fail as soon as the baseline text changes, and keep +// the guards that stop MASTER_INSTALL.sql (it drops hotel_db) from running +// anywhere but a local, empty database. Reads the sources; no database needed. + +import { describe, it, expect } from 'vitest' +import { readFileSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +const backend = join(dirname(fileURLToPath(import.meta.url)), '..', '..') +const read = (...p: string[]) => readFileSync(join(backend, ...p), 'utf8').replace(/\r\n/g, '\n') +const setup = read('scripts', 'setup-local.ts') + +// The fixes, as written in the script (file, from) +const fixes = [...setup.matchAll(/file: '([^']+)',\s*from: '([^']+)'/g)].map((m) => ({ + file: m[1], + // The source writes newlines as the two characters \ and n + from: m[2].split(String.raw`\n`).join('\n'), +})) + +describe('setup:local baseline fixes', () => { + it('lists the known fixes', () => { + expect(fixes.map((f) => f.file)).toEqual([ + '11_cashier.sql', + '11_cashier.sql', + '19_scheduling.sql', + ]) + }) + + it.each(fixes)('$file still has the text it replaces, exactly once', ({ file, from }) => { + expect(read('db-mysql', 'aiven', file).split(from)).toHaveLength(2) + }) +}) + +describe('setup:local guards', () => { + it('runs only on a local database host', () => { + expect(setup).toContain("new Set(['localhost', '127.0.0.1', '::1'])") + expect(setup).toMatch(/DB_ENVIRONMENT \|\| 'local'\) !== 'local'/) + }) + + it('refuses a database that already has tables unless --force', () => { + expect(setup).toContain("process.argv.includes('--force')") + expect(setup.indexOf("includes('--force')")).toBeLessThan( + setup.indexOf("MASTER_INSTALL.sql'), DB_DIR") + ) + }) + + it('never prints or stores a fixed admin password', () => { + expect(setup).toContain('crypto.randomBytes(12)') + }) +}) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8b74e3f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,26 @@ +# Local MySQL only (ADR-038). The backend and the frontend still run with pnpm. +# +# docker compose --env-file backend/.env up -d # start (port LOCAL_DB_PORT, default 3306) +# docker compose --env-file backend/.env down # stop; add -v to delete the data +# +# backend/.env must have LOCAL_DB_USER=root and the LOCAL_DB_PASSWORD you choose. +# If you already run MySQL on 3306, set LOCAL_DB_PORT=3307 in backend/.env. +services: + mysql: + image: mysql:8.0 + container_name: four-points-mysql + environment: + MYSQL_ROOT_PASSWORD: ${LOCAL_DB_PASSWORD:?Set LOCAL_DB_PASSWORD in backend/.env} + command: --character-set-server=utf8mb4 --collation-server=utf8mb4_0900_ai_ci + ports: + # Only this machine can reach it + - '127.0.0.1:${LOCAL_DB_PORT:-3306}:3306' + volumes: + - mysql-data:/var/lib/mysql + healthcheck: + test: ['CMD', 'mysqladmin', 'ping', '-h', '127.0.0.1', '--silent'] + interval: 5s + retries: 20 + +volumes: + mysql-data: From 933671699e3720142acc6e36ae83dcb471b40529 Mon Sep 17 00:00:00 2001 From: razyd Date: Mon, 5 Oct 2026 16:11:25 +0200 Subject: [PATCH 13/20] feat(db): give the local install a documented admin password - setup:local creates admin / fourpoints-local instead of a random password - README documents it; a test keeps script and README in agreement Why: whoever installs locally signs in without copying a one-off password; the account can only exist in a local database (the script refuses any other host), never on Aiven or the site. --- README.md | 12 ++++++++---- backend/scripts/setup-local.ts | 12 ++++++++---- backend/tests/security/setup-local.test.ts | 6 ++++-- 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 5b75dcd..8295330 100644 --- a/README.md +++ b/README.md @@ -47,9 +47,9 @@ cp frontend/.env.example frontend/.env.local # port 3306, set LOCAL_DB_PORT=3307 for Docker) docker compose --env-file backend/.env up -d -# 3. Database: tables, departments, an admin and fictitious data. -# It prints the admin password once; it creates hotel_db and refuses to -# touch one that already has tables (add -- --force to replace it) +# 3. Database: tables, departments, the local admin and fictitious data. +# It creates hotel_db and refuses to touch one that already has tables +# (add -- --force to replace it) cd backend && pnpm install && pnpm setup:local # 4. Scheduling solver (once) @@ -62,7 +62,11 @@ pnpm dev:local # API on :4000 cd ../frontend && pnpm install && pnpm dev # http://localhost:3000 ``` -Locally you are a full administrator: users, roles and settings included. +Sign in as **`admin`** / **`fourpoints-local`**: a full administrator, +users, roles and settings included. The password is public on purpose: +`setup:local` only runs against a local database, so this account never +exists anywhere else. Change it in Profile if you like. + Files (photos, PDFs) need a Cloudinary account in `backend/.env`; without it everything else works. diff --git a/backend/scripts/setup-local.ts b/backend/scripts/setup-local.ts index 8fbf758..9a0092d 100644 --- a/backend/scripts/setup-local.ts +++ b/backend/scripts/setup-local.ts @@ -5,7 +5,8 @@ * completo (ADR-038): * 1. tablas: MASTER_INSTALL.sql y las migraciones posteriores a su baseline; * 2. departamentos que usan los módulos y el mock; - * 3. un admin con contraseña aleatoria, que se muestra una sola vez; + * 3. el admin local `admin` / `fourpoints-local`, público y documentado en el + * README: solo puede existir en una BD local; * 4. datos ficticios (mock-data.sql). * * MASTER_INSTALL.sql borra y crea `hotel_db`: el script se niega a seguir si @@ -13,7 +14,7 @@ * --force). * * Uso: - * pnpm setup:local # admin `admin` + * pnpm setup:local # admin `admin` / `fourpoints-local` * pnpm setup:local -- --admin maria # otro nombre * pnpm setup:local -- --force # borra una hotel_db que ya exista */ @@ -50,6 +51,8 @@ const DEPARTMENTS = [ const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '::1']) +export const LOCAL_ADMIN_PASSWORD = 'fourpoints-local' + // Bugs of the frozen baseline (aiven/NN, ADR-021) that stop a fresh install; // on Aiven those tables were created another way. Fixed here, in memory, as // exact replacements: if the text no longer matches, the setup stops instead @@ -100,6 +103,8 @@ async function main(): Promise { } const adminName = arg('admin') ?? 'admin' + // Public on purpose (README): this script only runs against a local database + const password = LOCAL_ADMIN_PASSWORD const { database: _db, ...server } = dbConfig const conn = await mysql.createConnection({ ...server, multipleStatements: true }) @@ -133,7 +138,6 @@ async function main(): Promise { ]) console.log('3/4 Admin') - const password = crypto.randomBytes(12).toString('base64url') const [roles] = await conn.query("SELECT id FROM roles WHERE name = 'admin'") await conn.query( `INSERT INTO users (id, username, email, password, role_id, created_at, is_active) @@ -155,7 +159,7 @@ async function main(): Promise { console.log('\nListo. Entra en http://localhost:3000 con:') console.log(` usuario: ${adminName}`) console.log(` contraseña: ${password}`) - console.log('Guárdala: no se vuelve a mostrar. Puedes cambiarla en Perfil.') + console.log('Es la contraseña pública del README: cámbiala en Perfil si quieres.') } finally { await conn.end() } diff --git a/backend/tests/security/setup-local.test.ts b/backend/tests/security/setup-local.test.ts index 21927c6..59be909 100644 --- a/backend/tests/security/setup-local.test.ts +++ b/backend/tests/security/setup-local.test.ts @@ -48,7 +48,9 @@ describe('setup:local guards', () => { ) }) - it('never prints or stores a fixed admin password', () => { - expect(setup).toContain('crypto.randomBytes(12)') + it('uses the public local admin password documented in the README', () => { + const readme = readFileSync(join(backend, '..', 'README.md'), 'utf8') + expect(setup).toContain("LOCAL_ADMIN_PASSWORD = 'fourpoints-local'") + expect(readme).toContain('fourpoints-local') }) }) From e3a642c5a048fb0538bbb742db4d61a0cf66cdf0 Mon Sep 17 00:00:00 2001 From: razyd Date: Mon, 5 Oct 2026 16:15:30 +0200 Subject: [PATCH 14/20] fix(db): group the backoffice monthly summary view by its period - 20261005_fix_bo_monthly_summary_view.sql: v_bo_monthly_summary groups by year, month and '%Y-%m' (same columns and groups as before) - INDEX.md: logged; complete_fresh_install marked as applied on Aiven Why: the view returned the period without grouping by it, so with ONLY_FULL_GROUP_BY every query failed with error 1055 and GET /api/backoffice/stats/monthly always answered 500. Applied on local and Aiven, checked against a direct aggregate of bo_invoices. --- backend/db-mysql/INDEX.md | 3 +- .../20261005_fix_bo_monthly_summary_view.sql | 35 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 backend/db-mysql/scripts/20261005_fix_bo_monthly_summary_view.sql diff --git a/backend/db-mysql/INDEX.md b/backend/db-mysql/INDEX.md index eb7326b..93b2def 100644 --- a/backend/db-mysql/INDEX.md +++ b/backend/db-mysql/INDEX.md @@ -106,7 +106,8 @@ db-mysql/ | 2026-10-04 | `20261004_fix_parking_availability_triggers.sql` | Recrea `trg_free_availability_on_status_change` (solo libera los días de la propia reserva) y `trg_update_availability_on_date_change` (solo ocupa días libres o propios). Antes completar, cancelar o mover una reserva podía liberar u ocupar días de otra. La comprobación de solapes está en `bookings.repository.ts`. Local tenía otro cuerpo de `date_change`; se parte del de Aiven. | ⏳ local · ✅ Aiven | | 2026-10-05 | `20261005_add_is_demo_to_users.sql` | Añade `users.is_demo` (0 por defecto): marca la cuenta demo pública, que el backend lleva en el token y `demoRestriction` limita (ADR-038). Hay que aplicarla **antes** de desplegar el código: el login lee la columna. Solo añade la columna si no existe. En Aiven se aplicó el 2026-10-05, antes del código. | ✅ local · ✅ Aiven | | 2026-10-05 | `20261005_add_demo_reset_log.sql` | Crea `demo_reset_log`: un registro por cada reinicio diario de la demo y por cada base de horarios guardada (ADR-038). La base vive en tablas `demo_snapshot_scheduling_*` que crea el backend al guardarla. Hay que aplicarla antes de desplegar el código. `CREATE TABLE IF NOT EXISTS`. | ✅ local · ✅ Aiven | -| 2026-10-05 | `20261005_complete_fresh_install.sql` | Completa una instalación desde cero: el baseline congelado no crea `cashier_history` (nombre de clave duplicado), `scheduling_assignments` (referencia a una tabla creada después) ni `demo_activity_log`, y deja `roles.name` en `varchar(50)`. Definiciones copiadas de Aiven. En Aiven no cambia nada; en local creó `demo_activity_log`, que faltaba. `pnpm setup:local` además corrige esos dos fallos del baseline en memoria. | ✅ local · ⏳ Aiven (sin cambios) | +| 2026-10-05 | `20261005_complete_fresh_install.sql` | Completa una instalación desde cero: el baseline congelado no crea `cashier_history` (nombre de clave duplicado), `scheduling_assignments` (referencia a una tabla creada después) ni `demo_activity_log`, y deja `roles.name` en `varchar(50)`. Definiciones copiadas de Aiven. En Aiven no cambia nada; en local creó `demo_activity_log`, que faltaba. `pnpm setup:local` además corrige esos dos fallos del baseline en memoria. | ✅ local · ✅ Aiven (sin cambios) | +| 2026-10-05 | `20261005_fix_bo_monthly_summary_view.sql` | Recrea `v_bo_monthly_summary` con `period` en el `GROUP BY`. Con `ONLY_FULL_GROUP_BY` (activo en Aiven y local) toda consulta a la vista fallaba con el error 1055 y `GET /api/backoffice/stats/monthly` respondía siempre 500. Mismas columnas y mismos grupos; comprobada contra un cálculo directo sobre `bo_invoices`. | ✅ local · ✅ Aiven | ### Verificación (99) diff --git a/backend/db-mysql/scripts/20261005_fix_bo_monthly_summary_view.sql b/backend/db-mysql/scripts/20261005_fix_bo_monthly_summary_view.sql new file mode 100644 index 0000000..c6d4f8d --- /dev/null +++ b/backend/db-mysql/scripts/20261005_fix_bo_monthly_summary_view.sql @@ -0,0 +1,35 @@ +-- ============================================================================= +-- 20261005_fix_bo_monthly_summary_view.sql +-- ============================================================================= +-- Recreates v_bo_monthly_summary with `period` in its GROUP BY. The view +-- returned DATE_FORMAT(invoice_date, '%Y-%m') without grouping by it, and +-- with ONLY_FULL_GROUP_BY (on in Aiven and local) every SELECT on it failed +-- with error 1055: GET /api/backoffice/stats/monthly always answered 500. +-- Found on 2026-10-05 while comparing a fresh install with Aiven. +-- +-- Same columns and the same groups as before: year + month and '%Y-%m' split +-- the invoices the same way. +-- +-- Idempotent: CREATE OR REPLACE VIEW. +-- +-- Applies to: LOCAL + AIVEN. +-- Rollback: the previous body is in aiven/16_backoffice.sql (it fails while +-- ONLY_FULL_GROUP_BY is on). +-- ============================================================================= + +CREATE OR REPLACE VIEW v_bo_monthly_summary AS +SELECT + YEAR(i.invoice_date) AS year, + MONTH(i.invoice_date) AS month, + DATE_FORMAT(i.invoice_date, '%Y-%m') AS period, + COUNT(*) AS total_invoices, + COUNT(CASE WHEN i.status = 'pending' THEN 1 END) AS pending_count, + COUNT(CASE WHEN i.status = 'paid' THEN 1 END) AS paid_count, + SUM(i.amount_without_vat) AS total_without_vat, + SUM(i.amount_with_vat) AS total_with_vat, + SUM(CASE WHEN i.status = 'pending' THEN i.amount_with_vat ELSE 0 END) AS pending_total, + SUM(CASE WHEN i.status = 'paid' THEN i.amount_with_vat ELSE 0 END) AS paid_total +FROM bo_invoices i +WHERE i.is_deleted = 0 +GROUP BY YEAR(i.invoice_date), MONTH(i.invoice_date), DATE_FORMAT(i.invoice_date, '%Y-%m') +ORDER BY year DESC, month DESC; From 9991d79c52f3f4a709051147e92c6557e7cd68e0 Mon Sep 17 00:00:00 2001 From: razyd Date: Mon, 5 Oct 2026 16:24:11 +0200 Subject: [PATCH 15/20] test(security): check the CSRF defence and the session cookies by behaviour - config/cors.ts: the Origin check moved out of index.ts, unchanged; a refused origin is answered 403 there (same body the error handler gave) - csrf-origin.test.ts: a real Express server with the real middleware; JSON, form, multipart and text/plain posts from another site, look-alike origins and localhost in production get 403 and never reach the route; our own site gets through with credentials - session-cookies.test.ts: the options login and the demo entry pass to res.cookie in production (HttpOnly, Secure, SameSite=Lax, shared domain) Why: the public demo opens writes to anyone, so the defences it relies on must be proven by what they do (ADR-038). Both suites fail when the check is weakened (callback(null, false), sameSite none). --- backend/config/cors.ts | 55 +++++++++++++ backend/index.ts | 37 +-------- backend/tests/auth/session-cookies.test.ts | 66 +++++++++++++++ backend/tests/security/csrf-origin.test.ts | 95 ++++++++++++++++++++++ 4 files changed, 219 insertions(+), 34 deletions(-) create mode 100644 backend/config/cors.ts create mode 100644 backend/tests/auth/session-cookies.test.ts create mode 100644 backend/tests/security/csrf-origin.test.ts diff --git a/backend/config/cors.ts b/backend/config/cors.ts new file mode 100644 index 0000000..612a034 --- /dev/null +++ b/backend/config/cors.ts @@ -0,0 +1,55 @@ +// config/cors.ts +/** + * CORS, which is also the CSRF defence: a request whose Origin is not one of + * ours is answered 403 before any route runs, including simple requests that + * a browser sends without a preflight (a form POST, a multipart upload). + * Requests without Origin pass: browsers always send it on cross-site writes, + * and server-to-server calls or curl do not carry cookies from a victim. + */ + +import cors from 'cors' +import type { Request, Response, NextFunction, RequestHandler } from 'express' +import { logger } from './logger.js' + +export function allowedOrigins( + nodeEnv = process.env.NODE_ENV, + frontendUrl = process.env.FRONTEND_URL +): string[] { + return [ + ...(nodeEnv === 'production' ? [] : ['http://localhost:3000']), + 'https://four-points.stackbp.es', + 'https://four-points.vercel.app', + 'https://api.four-points.stackbp.es', + 'https://four-points.onrender.com', + frontendUrl, // URL adicional si es necesario + ].filter(Boolean) as string[] +} + +export function corsMiddleware(origins = allowedOrigins()): RequestHandler { + const handler = cors({ + origin: (origin, callback) => { + // Permitir requests sin origin (Postman, server-to-server, mobile apps) + if (!origin || origins.includes(origin)) { + callback(null, true) + return + } + logger.warn({ origin }, '[CORS] Blocked origin') + // Must stay an error: callback(null, false) would let simple requests reach the routes + callback(new Error('Origen no permitido')) + }, + credentials: true, + methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization', 'Cookie'], + exposedHeaders: ['Set-Cookie'], + }) + + return (req: Request, res: Response, next: NextFunction) => { + handler(req, res, (err?: unknown) => { + if (err) { + res.status(403).json({ error: 'Origen no permitido' }) + return + } + next() + }) + } +} diff --git a/backend/index.ts b/backend/index.ts index db8f36d..0dafd4e 100644 --- a/backend/index.ts +++ b/backend/index.ts @@ -2,13 +2,13 @@ import express, { Request, Response, NextFunction } from 'express' import cookieParser from 'cookie-parser' -import cors from 'cors' import helmet from 'helmet' import { PORT } from './config/config.js' import { logServerInfo } from './config/startup-logger.js' import { logger } from './config/logger.js' import { apiLimiter } from './middlewares/rateLimiter.js' import { noStore } from './middlewares/noStore.js' +import { corsMiddleware } from './config/cors.js' import { CronService } from './services/cron/cron-service.js' import { warmupSolver } from './services/scheduling/solver-client.js' @@ -55,39 +55,8 @@ app.disable('x-powered-by') // CSP deshabilitado — API pura JSON, no sirve HTML ni assets app.use(helmet({ contentSecurityPolicy: false })) -// CORS (antes de todo). Also the CSRF defence: a request carrying any other Origin -// is rejected before the routes, including simple (non-preflighted) requests. -const allowedOrigins = [ - ...(process.env.NODE_ENV === 'production' ? [] : ['http://localhost:3000']), - 'https://four-points.stackbp.es', - 'https://four-points.vercel.app', - 'https://api.four-points.stackbp.es', - 'https://four-points.onrender.com', - process.env.FRONTEND_URL, // URL adicional si es necesario -].filter(Boolean) as string[] - -app.use( - cors({ - origin: (origin, callback) => { - // Permitir requests sin origin (Postman, server-to-server, mobile apps) - if (!origin) { - callback(null, true) - return - } - if (allowedOrigins.includes(origin)) { - callback(null, true) - } else { - logger.warn({ origin }, '[CORS] Blocked origin') - // Must stay an error: callback(null, false) would let simple requests reach the routes - callback(Object.assign(new Error('Origen no permitido'), { status: 403 })) - } - }, - credentials: true, - methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], - allowedHeaders: ['Content-Type', 'Authorization', 'Cookie'], - exposedHeaders: ['Set-Cookie'], - }) -) +// CORS (antes de todo); also the CSRF defence (config/cors.ts) +app.use(corsMiddleware()) // ======================================== // MIDDLEWARES GLOBALES diff --git a/backend/tests/auth/session-cookies.test.ts b/backend/tests/auth/session-cookies.test.ts new file mode 100644 index 0000000..cc04a3c --- /dev/null +++ b/backend/tests/auth/session-cookies.test.ts @@ -0,0 +1,66 @@ +// tests/auth/session-cookies.test.ts +// The session cookies a sign-in sets in production: not readable by scripts +// (XSS cannot steal them), only over HTTPS, and SameSite=Lax so a POST from +// another site does not carry them (CSRF). What res.cookie actually receives. + +import { describe, it, expect, vi, afterAll } from 'vitest' +import type { Request, Response } from 'express' + +const original = { NODE_ENV: process.env.NODE_ENV, DEMO_MODE: process.env.DEMO_MODE } +process.env.NODE_ENV = 'production' +process.env.SECRET_JWT_KEY ??= 'test-secret-key-for-vitest-only-not-prod' +process.env.DEMO_MODE = 'true' + +vi.mock('../../repositories/auth/user-repository.js', () => ({ + UserRepository: { + login: vi.fn().mockResolvedValue({ id: 'u1', username: 'someone', role: 'admin' }), + getDemoUser: vi.fn().mockResolvedValue({ id: 'd1', username: 'demo', role: 'admin' }), + }, +})) +vi.mock('../../services/demo/demo-reset.service.js', () => ({ resetIfStale: vi.fn() })) + +const { login, demoLogin } = await import('../../controllers/auth/auth-controllers.js') +// The cookie options are read at import; put the environment back for other files +process.env.NODE_ENV = original.NODE_ENV +afterAll(() => { + if (original.DEMO_MODE === undefined) delete process.env.DEMO_MODE + else process.env.DEMO_MODE = original.DEMO_MODE +}) + +function fakeRes() { + const cookies: Array<{ name: string; options: Record }> = [] + const res = { + cookies, + status: vi.fn(() => res), + json: vi.fn(() => res), + cookie: vi.fn((name: string, _value: string, options: Record) => { + cookies.push({ name, options }) + return res + }), + } + return res +} + +describe.each([ + [ + 'password login', + (res: Response) => + login({ body: { username: 'someone', password: 'CorrectPassword123!' } } as Request, res), + ], + ['demo entry', (res: Response) => demoLogin({} as Request, res)], +])('%s in production', (_name, signIn) => { + it('sets both session cookies HttpOnly, Secure, SameSite=Lax on the shared domain', async () => { + const res = fakeRes() + await signIn(res as unknown as Response) + expect(res.cookies.map((c) => c.name).sort()).toEqual(['access_token', 'refresh_token']) + for (const { options } of res.cookies) { + expect(options).toMatchObject({ + httpOnly: true, + secure: true, + sameSite: 'lax', + path: '/', + domain: '.four-points.stackbp.es', + }) + } + }) +}) diff --git a/backend/tests/security/csrf-origin.test.ts b/backend/tests/security/csrf-origin.test.ts new file mode 100644 index 0000000..ba721c9 --- /dev/null +++ b/backend/tests/security/csrf-origin.test.ts @@ -0,0 +1,95 @@ +// tests/security/csrf-origin.test.ts +// The CSRF defence is the Origin check in config/cors.ts: a write from another +// site must be refused before the route runs. A real Express server with the +// real middleware; requests as a browser would send them. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest' +import express from 'express' +import type { Server } from 'node:http' +import type { AddressInfo } from 'node:net' +import { allowedOrigins, corsMiddleware } from '../../config/cors.js' + +let server: Server +let base: string +let ran = 0 + +beforeAll(async () => { + const app = express() + app.use(corsMiddleware(allowedOrigins('production', undefined))) + app.post('/api/write', (_req, res) => { + ran++ + res.json({ ok: true }) + }) + server = app.listen(0) + await new Promise((resolve) => server.once('listening', resolve)) + base = `http://127.0.0.1:${(server.address() as AddressInfo).port}` +}) + +afterAll(() => new Promise((resolve) => server.close(resolve))) + +async function post(headers: Record, body = '{}') { + const before = ran + const res = await fetch(`${base}/api/write`, { method: 'POST', headers, body }) + return { + status: res.status, + ran: ran > before, + allow: res.headers.get('access-control-allow-origin'), + } +} + +describe('a write from another site', () => { + it.each([ + ['a JSON request', { 'Content-Type': 'application/json' }], + // What an HTML form on another site sends: no preflight in the browser + ['a form post', { 'Content-Type': 'application/x-www-form-urlencoded' }], + ['a multipart upload', { 'Content-Type': 'multipart/form-data; boundary=x' }], + ['a text/plain post', { 'Content-Type': 'text/plain' }], + ])('is refused before the route runs: %s', async (_name, headers) => { + const res = await post({ ...headers, Origin: 'https://evil.example' }) + expect(res.status).toBe(403) + expect(res.ran).toBe(false) + expect(res.allow).toBeNull() + }) + + it.each([ + 'https://four-points.stackbp.es.evil.example', + 'https://evil-four-points.stackbp.es', + 'null', + ])('is refused for a look-alike origin: %s', async (origin) => { + const res = await post({ 'Content-Type': 'application/json', Origin: origin }) + expect(res.status).toBe(403) + expect(res.ran).toBe(false) + }) + + it('is refused from localhost in production', async () => { + const res = await post({ 'Content-Type': 'application/json', Origin: 'http://localhost:3000' }) + expect(res.status).toBe(403) + expect(res.ran).toBe(false) + }) +}) + +describe('a write from our own site', () => { + it('reaches the route and may read the answer with cookies', async () => { + const res = await fetch(`${base}/api/write`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: 'https://four-points.stackbp.es' }, + body: '{}', + }) + expect(res.status).toBe(200) + expect(res.headers.get('access-control-allow-origin')).toBe('https://four-points.stackbp.es') + expect(res.headers.get('access-control-allow-credentials')).toBe('true') + }) +}) + +describe('allowed origins', () => { + it('include localhost only outside production', () => { + expect(allowedOrigins('development', undefined)).toContain('http://localhost:3000') + expect(allowedOrigins('production', undefined)).not.toContain('http://localhost:3000') + }) + + it('add FRONTEND_URL when set', () => { + expect(allowedOrigins('production', 'https://preview.four-points.stackbp.es')).toContain( + 'https://preview.four-points.stackbp.es' + ) + }) +}) From 4449b7aa7fdb5a6d365db18fdccf63d9d123ff6e Mon Sep 17 00:00:00 2001 From: razyd Date: Mon, 5 Oct 2026 16:24:12 +0200 Subject: [PATCH 16/20] fix(checklist): render unsafe links in step notes as plain text - lib/helpers/safeHref.ts: allows http, https, mailto, tel and relative links, parsed with URL as the browser does - ChecklistNoteBanner: its own [text](url) parser now goes through it - safe-href.test.ts: javascript:, data:, vbscript:, file: and the spellings browsers still run (case, tabs, newlines, leading spaces) Why: the banner turned any URL in a note into a link, javascript: included. Notes come from repository JSON today, but nothing stopped one. The guide and reference pages use react-markdown, which already drops those URLs. --- backend/tests/frontend/safe-href.test.ts | 46 +++++++++++++++++++ .../checklist/ChecklistNoteBanner.tsx | 10 +++- frontend/app/lib/helpers/safeHref.ts | 21 +++++++++ 3 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 backend/tests/frontend/safe-href.test.ts create mode 100644 frontend/app/lib/helpers/safeHref.ts diff --git a/backend/tests/frontend/safe-href.test.ts b/backend/tests/frontend/safe-href.test.ts new file mode 100644 index 0000000..0be113f --- /dev/null +++ b/backend/tests/frontend/safe-href.test.ts @@ -0,0 +1,46 @@ +// tests/frontend/safe-href.test.ts +// Links written in checklist content are rendered through safeHref: a +// javascript: or data: URL must never become a clickable href, in any of the +// spellings browsers still execute. Runs the real helper (the frontend has +// no test runner). + +import { describe, it, expect } from 'vitest' +import { safeHref } from '../../../frontend/app/lib/helpers/safeHref.js' + +describe('safeHref refuses executable URLs', () => { + it.each([ + 'javascript:alert(1)', + 'JavaScript:alert(1)', + ' javascript:alert(1)', + 'java\nscript:alert(1)', + 'java\tscript:alert(1)', + 'java\rscript:alert(1)', + '\u0000javascript:alert(1)', + 'javascript:alert(1)'.replace(':', ':'), + 'data:text/html,', + 'vbscript:msgbox(1)', + 'file:///etc/passwd', + ])('%j', (href) => { + expect(safeHref(href)).toBeUndefined() + }) +}) + +describe('safeHref keeps ordinary links', () => { + it.each([ + '/dashboard/checklist', + 'checklist/night-audit', + '#paso-3', + 'https://www.example.com/manual', + 'http://intranet.example.com', + 'mailto:recepcion@example.com', + 'tel:+34900000000', + ])('%j', (href) => { + expect(safeHref(href)).toBe(href) + }) + + it('returns undefined for empty values', () => { + expect(safeHref('')).toBeUndefined() + expect(safeHref(undefined)).toBeUndefined() + expect(safeHref(null)).toBeUndefined() + }) +}) diff --git a/frontend/app/components/checklist/ChecklistNoteBanner.tsx b/frontend/app/components/checklist/ChecklistNoteBanner.tsx index 2550a32..c8bb518 100644 --- a/frontend/app/components/checklist/ChecklistNoteBanner.tsx +++ b/frontend/app/components/checklist/ChecklistNoteBanner.tsx @@ -1,4 +1,5 @@ import Link from 'next/link' +import { safeHref } from '@/app/lib/helpers/safeHref' function parseNoteText(text: string) { const parts: React.ReactNode[] = [] @@ -10,10 +11,17 @@ function parseNoteText(text: string) { if (match.index > lastIndex) { parts.push(text.slice(lastIndex, match.index)) } + // A link that is not safe (javascript:, data:…) stays as plain text + const href = safeHref(match[2]) + if (!href) { + parts.push(match[1]) + lastIndex = regex.lastIndex + continue + } parts.push( {match[1]} diff --git a/frontend/app/lib/helpers/safeHref.ts b/frontend/app/lib/helpers/safeHref.ts new file mode 100644 index 0000000..11a216d --- /dev/null +++ b/frontend/app/lib/helpers/safeHref.ts @@ -0,0 +1,21 @@ +// app/lib/helpers/safeHref.ts + +// Schemes a link written in content (checklist notes, guides) may use. +// Anything else (javascript:, data:, vbscript:…) is not rendered as a link. +const ALLOWED_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'tel:']) + +/** + * The href if it is safe to render, otherwise undefined. Parsed with URL, as + * the browser does: it drops tabs and newlines and ignores case, so + * "java\nscript:" or "JavaScript:" are caught as javascript:. Relative paths + * resolve against an https base and pass. + */ +export function safeHref(href: string | undefined | null): string | undefined { + if (!href) return undefined + try { + const { protocol } = new URL(href, 'https://four-points.invalid/') + return ALLOWED_PROTOCOLS.has(protocol) ? href : undefined + } catch { + return undefined + } +} From 143882d53f3bfdb7d3c1c703aa2413bd759c29e4 Mon Sep 17 00:00:00 2001 From: razyd Date: Mon, 5 Oct 2026 16:29:12 +0200 Subject: [PATCH 17/20] refactor(auth): retire the demo-admin role - roleCheck: demo-admin removed from every role list; isRealAdmin, which only told admin from demo-admin, replaced by isAdmin in the routes - demoRestriction: the old demo-admin write whitelist removed; the demo account is limited by is_demo alone (uploads, denyDemo, denyDemoWrites) - authenticateToken: isDemo comes only from the token's demo claim - module-access and the frontend isAdminRole: admin only - 20261005_drop_demo_admin_role.sql: deletes the role when no user has it - tests: the whitelist tests go; the log redaction test now uses the demo account; route sources follow the rename Why: the public demo is an admin marked is_demo (ADR-038); keeping a second, unused demo mechanism meant two sets of rules to keep in step. --- backend/controllers/auth/user-controllers.ts | 2 +- .../scripts/20261005_drop_demo_admin_role.sql | 22 +++++ backend/middlewares/authenticateToken.ts | 3 +- backend/middlewares/demoRestriction.ts | 50 +--------- backend/middlewares/roleCheck.ts | 60 +++--------- backend/routes/auth/auth-routes.ts | 6 +- backend/routes/auth/user-routes.ts | 4 +- .../routes/backoffice/backoffice-routes.ts | 74 +++++++-------- backend/routes/demo/demo-activity-routes.ts | 16 ++-- backend/routes/demo/demo-reset-routes.ts | 4 +- backend/services/auth/module-access.ts | 4 +- .../tests/activity/activity-access.test.ts | 2 +- backend/tests/auth/demo-restriction.test.ts | 91 ++----------------- .../tests/backoffice/invoice-status.test.ts | 2 +- .../tests/maintenance/deleted-reports.test.ts | 2 +- backend/tests/search/search-access.test.ts | 2 +- backend/tests/security/demo-reset.test.ts | 2 +- frontend/app/lib/helpers/utils.ts | 10 +- 18 files changed, 114 insertions(+), 242 deletions(-) create mode 100644 backend/db-mysql/scripts/20261005_drop_demo_admin_role.sql diff --git a/backend/controllers/auth/user-controllers.ts b/backend/controllers/auth/user-controllers.ts index 6ddd17d..d790ab2 100644 --- a/backend/controllers/auth/user-controllers.ts +++ b/backend/controllers/auth/user-controllers.ts @@ -64,7 +64,7 @@ export const getUsersByRole = async (req: Request, res: Response): Promise /** * Actualiza un usuario por ID - * Solo administradores (isRealAdmin en la ruta) + * Solo administradores (isAdmin en la ruta) */ export const updateUser = async (req: Request, res: Response): Promise => { const validation = validateUpdateUser(req.body) diff --git a/backend/db-mysql/scripts/20261005_drop_demo_admin_role.sql b/backend/db-mysql/scripts/20261005_drop_demo_admin_role.sql new file mode 100644 index 0000000..9fdf819 --- /dev/null +++ b/backend/db-mysql/scripts/20261005_drop_demo_admin_role.sql @@ -0,0 +1,22 @@ +-- ============================================================================= +-- 20261005_drop_demo_admin_role.sql +-- ============================================================================= +-- Removes the `demo-admin` role (ADR-038). The public demo account is now an +-- admin marked with users.is_demo, restricted by demoRestriction; the code no +-- longer knows the old role. Its only user, `demo`, was converted by +-- scripts/create-demo-user.ts. +-- +-- Deletes the role only when no user has it: if one still did, that user +-- would be left without a role. +-- +-- Idempotent: deletes nothing the second time. +-- +-- Applies to: AIVEN (local never had the role). +-- Rollback: INSERT INTO roles (id, name) VALUES (7, 'demo-admin'); +-- ============================================================================= + +DELETE r FROM roles r +WHERE r.name = 'demo-admin' + AND NOT EXISTS (SELECT 1 FROM users u WHERE u.role_id = r.id); + +SELECT CONCAT('demo-admin roles left: ', COUNT(*)) AS info FROM roles WHERE name = 'demo-admin'; diff --git a/backend/middlewares/authenticateToken.ts b/backend/middlewares/authenticateToken.ts index b08a62c..c9ffb5d 100644 --- a/backend/middlewares/authenticateToken.ts +++ b/backend/middlewares/authenticateToken.ts @@ -57,8 +57,7 @@ export function authenticateToken(req: Request, res: Response, next: NextFunctio username: decoded.username, email: '', // No está en el token, se puede obtener de la BD si es necesario role: decoded.role, - // demo-admin is the old demo role, restricted the same way until it is retired - isDemo: decoded.demo === true || decoded.role === 'demo-admin', + isDemo: decoded.demo === true, } } catch (error) { // Diferencia entre token expirado y token inválido diff --git a/backend/middlewares/demoRestriction.ts b/backend/middlewares/demoRestriction.ts index 88a838d..b0e702a 100644 --- a/backend/middlewares/demoRestriction.ts +++ b/backend/middlewares/demoRestriction.ts @@ -6,47 +6,12 @@ * Se le bloquea lo que podría romper la app o tocar otras cuentas: * - en todas las rutas: subir ficheros (demoRestriction) * - en cada ruta sensible: denyDemo o denyDemoWrites - * - * El rol demo-admin (antiguo) mantiene su lista blanca hasta que se retire. */ import { Request, Response, NextFunction } from 'express' import { DemoActivityRepository } from '../repositories/demo/demo-activity-repository.js' import { logger } from '../config/logger.js' -// Rol demo antiguo, con su lista blanca de escrituras -const DEMO_ROLE = 'demo-admin' - -/** - * Rutas que el usuario demo SÍ puede usar (whitelist). - * Formato: { method: 'POST|PATCH|PUT|DELETE', pattern: RegExp } - */ -const DEMO_ALLOWED_ROUTES: Array<{ method: string; pattern: RegExp }> = [ - // Auth - puede hacer logout - { method: 'POST', pattern: /^\/api\/auth\/logout$/ }, - - // Parking - puede crear reservas de prueba - { method: 'POST', pattern: /^\/api\/parking\/bookings$/ }, - - // Logbooks - puede agregar comentarios - { method: 'POST', pattern: /^\/api\/logbooks\/\d+\/comments$/ }, - - // Maintenance - puede crear reportes de prueba - { method: 'POST', pattern: /^\/api\/maintenance$/ }, -] - -/** - * Verifica si una ruta está en la whitelist para demo - * Usa originalUrl para obtener la ruta completa (incluyendo /api/...) - */ -function isAllowedForDemo(method: string, originalUrl: string): boolean { - // Quitar query string si existe - const pathOnly = originalUrl.split('?')[0] - return DEMO_ALLOWED_ROUTES.some( - (route) => route.method === method && route.pattern.test(pathOnly) - ) -} - /** * Registra intentos bloqueados en la base de datos * Ejecuta de forma asíncrona sin bloquear la respuesta @@ -107,21 +72,10 @@ function isMultipart(req: Request): boolean { /** * Runs inside authenticateToken, on every authenticated request. - * - * - demo-admin (old demo role): only the whitelisted writes - * - demo account (users.is_demo): no file uploads; the rest is limited per - * route with denyDemo and denyDemoWrites + * The demo account (users.is_demo) cannot upload files; the rest is + * limited per route with denyDemo and denyDemoWrites. */ export function demoRestriction(req: Request, res: Response, next: NextFunction): void { - if (req.user?.role === DEMO_ROLE) { - if (req.method === 'GET' || isAllowedForDemo(req.method, req.originalUrl)) { - next() - return - } - block(req, res) - return - } - if (req.user?.isDemo && isMultipart(req)) { block(req, res) return diff --git a/backend/middlewares/roleCheck.ts b/backend/middlewares/roleCheck.ts index c0d7219..96230a2 100644 --- a/backend/middlewares/roleCheck.ts +++ b/backend/middlewares/roleCheck.ts @@ -7,10 +7,9 @@ type RoleCheckMiddleware = (req: Request, res: Response, next: NextFunction) => /** * Verifica que el usuario tenga rol de administrador * Uso: Para rutas que SOLO admins pueden acceder - * Nota: demo-admin tiene acceso visual pero sus escrituras están limitadas por demoRestriction */ export const isAdmin: RoleCheckMiddleware = (req, res, next) => { - const allowedRoles = ['admin', 'demo-admin'] + const allowedRoles = ['admin'] if (!req.user?.role) { res.status(403).json({ error: 'No se pudo verificar el rol del usuario' }) @@ -29,15 +28,14 @@ export const isAdmin: RoleCheckMiddleware = (req, res, next) => { /** * Verifica que el usuario sea el dueño del recurso O sea administrador * Uso: Para rutas donde un usuario puede ver/editar SU propio perfil - * Nota: demo-admin tiene acceso visual como admin */ export const isOwnerOrAdmin: RoleCheckMiddleware = (req, res, next) => { const resourceUserId = req.params.id // ID del usuario en la URL const requestingUserId = req.user?.id // ID del usuario autenticado const userRole = req.user?.role?.toLowerCase() - // Si es admin o demo-admin, tiene acceso total - if (userRole === 'admin' || userRole === 'demo-admin') { + // Si es admin, tiene acceso total + if (userRole === 'admin') { next() return } @@ -57,10 +55,9 @@ export const isOwnerOrAdmin: RoleCheckMiddleware = (req, res, next) => { /** * Verifica que el usuario pueda gestionar grupos * Uso: Para rutas de creación/edición/eliminación de grupos - * Nota: demo-admin incluido para acceso visual */ export const canManageGroups: RoleCheckMiddleware = (req, res, next) => { - const allowedRoles = ['admin', 'group-admin', 'demo-admin'] + const allowedRoles = ['admin', 'group-admin'] if (!req.user?.role) { res.status(403).json({ error: 'No se pudo verificar el rol del usuario' }) @@ -82,7 +79,7 @@ export const canManageGroups: RoleCheckMiddleware = (req, res, next) => { * Nota: mantenimiento incluido para acceso a notificaciones del profile */ export const canViewGroups: RoleCheckMiddleware = (req, res, next) => { - const allowedRoles = ['admin', 'recepcionista', 'group-admin', 'mantenimiento', 'demo-admin'] + const allowedRoles = ['admin', 'recepcionista', 'group-admin', 'mantenimiento'] if (!req.user?.role) { res.status(403).json({ error: 'No se pudo verificar el rol del usuario' }) @@ -101,10 +98,9 @@ export const canViewGroups: RoleCheckMiddleware = (req, res, next) => { /** * Verifica que el usuario pueda gestionar caja * Uso: Para crear, editar, cerrar turnos, vales, etc. - * Nota: demo-admin incluido para acceso visual */ export const canManageCashier: RoleCheckMiddleware = (req, res, next) => { - const allowedRoles = ['admin', 'recepcionista', 'group-admin', 'demo-admin'] + const allowedRoles = ['admin', 'recepcionista', 'group-admin'] if (!req.user?.role) { res.status(403).json({ error: 'No se pudo verificar el rol del usuario' }) @@ -123,10 +119,9 @@ export const canManageCashier: RoleCheckMiddleware = (req, res, next) => { /** * Verifica que el usuario pueda ver reportes de caja * Uso: Para reportes, estadísticas, históricos completos - * Nota: demo-admin incluido para acceso visual */ export const canViewReports: RoleCheckMiddleware = (req, res, next) => { - const allowedRoles = ['admin', 'demo-admin'] + const allowedRoles = ['admin'] if (!req.user?.role) { res.status(403).json({ error: 'No se pudo verificar el rol del usuario' }) @@ -145,10 +140,9 @@ export const canViewReports: RoleCheckMiddleware = (req, res, next) => { /** * Verifica que el usuario pueda acceder al módulo de mantenimiento * Uso: Para rutas de creación/edición/consulta de reportes de mantenimiento - * Nota: TODOS los roles pueden acceder a mantenimiento (incluido demo-admin) */ export const canAccessMaintenance: RoleCheckMiddleware = (req, res, next) => { - const allowedRoles = ['admin', 'recepcionista', 'group-admin', 'mantenimiento', 'demo-admin'] + const allowedRoles = ['admin', 'recepcionista', 'group-admin', 'mantenimiento'] if (!req.user?.role) { res.status(403).json({ error: 'No se pudo verificar el rol del usuario' }) @@ -189,10 +183,10 @@ export const excludeMantenimiento: RoleCheckMiddleware = (req, res, next) => { /** * Verifica que el usuario pueda acceder al módulo de backoffice * Uso: Para rutas de facturas, proveedores, etc. - * SOLO admin y demo-admin pueden acceder + * SOLO admin puede acceder */ export const canAccessBackoffice: RoleCheckMiddleware = (req, res, next) => { - const allowedRoles = ['admin', 'demo-admin'] + const allowedRoles = ['admin'] if (!req.user?.role) { res.status(403).json({ error: 'No se pudo verificar el rol del usuario' }) @@ -211,10 +205,10 @@ export const canAccessBackoffice: RoleCheckMiddleware = (req, res, next) => { /** * Verifica que el usuario pueda resetear checklists * Uso: Botón de reset en el módulo Check List - * Roles permitidos: admin, recepcionista, demo-admin + * Roles permitidos: admin, recepcionista */ export const canResetChecklist: RoleCheckMiddleware = (req, res, next) => { - const allowedRoles = ['admin', 'recepcionista', 'demo-admin'] + const allowedRoles = ['admin', 'recepcionista'] if (!req.user?.role) { res.status(403).json({ error: 'No se pudo verificar el rol del usuario' }) @@ -232,10 +226,10 @@ export const canResetChecklist: RoleCheckMiddleware = (req, res, next) => { /** * Verifica acceso al módulo F&B Revenue - * Roles permitidos: admin, recepcionista, demo-admin, group-admin + * Roles permitidos: admin, recepcionista, group-admin */ export const canAccessFnb: RoleCheckMiddleware = (req, res, next) => { - const allowedRoles = ['admin', 'recepcionista', 'demo-admin', 'group-admin'] + const allowedRoles = ['admin', 'recepcionista', 'group-admin'] if (!req.user?.role) { res.status(403).json({ error: 'No se pudo verificar el rol del usuario' }) @@ -253,7 +247,7 @@ export const canAccessFnb: RoleCheckMiddleware = (req, res, next) => { /** * Lanzar a mano la generación de notificaciones pendientes - * Roles permitidos: admin, group-admin (demo-admin está deshabilitado) + * Roles permitidos: admin, group-admin */ export const canRunNotificationCheck: RoleCheckMiddleware = (req, res, next) => { const allowedRoles = ['admin', 'group-admin'] @@ -269,27 +263,3 @@ export const canRunNotificationCheck: RoleCheckMiddleware = (req, res, next) => res.status(403).json({ error: 'Acceso denegado. Se requiere rol de admin o group-admin' }) } } - -/** - * Verifica que el usuario sea admin real (NO demo-admin) - * Uso: Para operaciones de escritura en backoffice - * demo-admin tiene acceso de solo lectura - */ -export const isRealAdmin: RoleCheckMiddleware = (req, res, next) => { - if (!req.user?.role) { - res.status(403).json({ error: 'No se pudo verificar el rol del usuario' }) - return - } - - if (req.user.role.toLowerCase() === 'admin') { - next() - } else if (req.user.role.toLowerCase() === 'demo-admin') { - res.status(403).json({ - error: 'Acceso de solo lectura. El rol demo-admin no puede realizar modificaciones', - }) - } else { - res.status(403).json({ - error: 'Acceso denegado. Se requiere rol de administrador', - }) - } -} diff --git a/backend/routes/auth/auth-routes.ts b/backend/routes/auth/auth-routes.ts index afce366..58625d9 100644 --- a/backend/routes/auth/auth-routes.ts +++ b/backend/routes/auth/auth-routes.ts @@ -24,7 +24,7 @@ import { passwordChangeLimiter, profileUpdateLimiter, } from '../../middlewares/rateLimiter.js' -import { isRealAdmin } from '../../middlewares/roleCheck.js' +import { isAdmin } from '../../middlewares/roleCheck.js' const router: Router = express.Router() @@ -43,8 +43,8 @@ router.post('/demo', demoLoginLimiter, demoLogin) // ADMIN ONLY ROUTES // ======================================== -// Solo admins reales pueden crear usuarios (no demo-admin) -router.post('/register', authenticateToken, denyDemo, isRealAdmin, register) +// Solo admins pueden crear usuarios (nunca la cuenta demo) +router.post('/register', authenticateToken, denyDemo, isAdmin, register) // ======================================== // PROTECTED ROUTES (require authentication) diff --git a/backend/routes/auth/user-routes.ts b/backend/routes/auth/user-routes.ts index b29a26b..3a1dce8 100644 --- a/backend/routes/auth/user-routes.ts +++ b/backend/routes/auth/user-routes.ts @@ -11,7 +11,7 @@ import { } from '../../controllers/auth/user-controllers.js' import { authenticateToken } from '../../middlewares/authenticateToken.js' -import { isAdmin, isOwnerOrAdmin, isRealAdmin } from '../../middlewares/roleCheck.js' +import { isAdmin, isOwnerOrAdmin } from '../../middlewares/roleCheck.js' import { denyDemoWrites } from '../../middlewares/demoRestriction.js' const router: Router = express.Router() @@ -32,7 +32,7 @@ router.get('/role/:role', isAdmin, getUsersByRole) router.delete('/:id', isAdmin, deleteUser) router.post('/:id/reset-password', isAdmin, resetUserPassword) // Users edit their own username via PATCH /api/auth/me/profile (requires current password) -router.put('/:id', isRealAdmin, updateUser) +router.put('/:id', isAdmin, updateUser) // ======================================== // OWNER OR ADMIN ROUTES diff --git a/backend/routes/backoffice/backoffice-routes.ts b/backend/routes/backoffice/backoffice-routes.ts index 6f4c9c1..481823d 100644 --- a/backend/routes/backoffice/backoffice-routes.ts +++ b/backend/routes/backoffice/backoffice-routes.ts @@ -5,14 +5,14 @@ * * Permisos: * - admin: CRUD completo - * - demo-admin: solo lectura (GET) + * - cuenta demo (admin): todo salvo subir ficheros y crear categorías */ import { Router } from 'express' import multer from 'multer' import { BackofficeController } from '../../controllers/backoffice/backoffice-controller.js' import { authenticateToken } from '../../middlewares/authenticateToken.js' -import { canAccessBackoffice, isRealAdmin } from '../../middlewares/roleCheck.js' +import { canAccessBackoffice, isAdmin } from '../../middlewares/roleCheck.js' import { denyDemo } from '../../middlewares/demoRestriction.js' const router = Router() @@ -36,14 +36,14 @@ router.use(canAccessBackoffice) /** * @route GET /api/backoffice/stats * @desc Obtener estadísticas generales - * @access Private (admin, demo-admin) + * @access Private (admin) */ router.get('/stats', BackofficeController.getStats) /** * @route GET /api/backoffice/stats/monthly * @desc Obtener resumen mensual - * @access Private (admin, demo-admin) + * @access Private (admin) * @query year (opcional) */ router.get('/stats/monthly', BackofficeController.getMonthlySummary) @@ -55,14 +55,14 @@ router.get('/stats/monthly', BackofficeController.getMonthlySummary) /** * @route GET /api/backoffice/categories * @desc Obtener todas las categorías - * @access Private (admin, demo-admin) + * @access Private (admin) */ router.get('/categories', BackofficeController.getCategories) /** * @route GET /api/backoffice/categories/:id * @desc Obtener categoría por ID - * @access Private (admin, demo-admin) + * @access Private (admin) */ router.get('/categories/:id', BackofficeController.getCategoryById) @@ -72,7 +72,7 @@ router.get('/categories/:id', BackofficeController.getCategoryById) * @access Private (admin only) */ // Categories are a catalog the daily reset keeps: the demo account cannot add them -router.post('/categories', isRealAdmin, denyDemo, BackofficeController.createCategory) +router.post('/categories', isAdmin, denyDemo, BackofficeController.createCategory) // ======================================== // RUTAS DE PROVEEDORES @@ -81,7 +81,7 @@ router.post('/categories', isRealAdmin, denyDemo, BackofficeController.createCat /** * @route GET /api/backoffice/suppliers * @desc Obtener todos los proveedores con filtros - * @access Private (admin, demo-admin) + * @access Private (admin) * @query category_id, periodicity, payment_method, is_active, search */ router.get('/suppliers', BackofficeController.getSuppliers) @@ -89,7 +89,7 @@ router.get('/suppliers', BackofficeController.getSuppliers) /** * @route GET /api/backoffice/suppliers/:id * @desc Obtener proveedor por ID con estadísticas y facturas - * @access Private (admin, demo-admin) + * @access Private (admin) */ router.get('/suppliers/:id', BackofficeController.getSupplierById) @@ -98,35 +98,35 @@ router.get('/suppliers/:id', BackofficeController.getSupplierById) * @desc Crear nuevo proveedor * @access Private (admin only) */ -router.post('/suppliers', isRealAdmin, BackofficeController.createSupplier) +router.post('/suppliers', isAdmin, BackofficeController.createSupplier) /** * @route PATCH /api/backoffice/suppliers/:id * @desc Actualizar proveedor * @access Private (admin only) */ -router.patch('/suppliers/:id', isRealAdmin, BackofficeController.updateSupplier) +router.patch('/suppliers/:id', isAdmin, BackofficeController.updateSupplier) /** * @route DELETE /api/backoffice/suppliers/:id * @desc Eliminar proveedor permanentemente (solo si no tiene facturas) * @access Private (admin only) */ -router.delete('/suppliers/:id', isRealAdmin, BackofficeController.deleteSupplier) +router.delete('/suppliers/:id', isAdmin, BackofficeController.deleteSupplier) /** * @route POST /api/backoffice/suppliers/:id/inactivate * @desc Marcar proveedor como inactivo (preserva histórico) * @access Private (admin only) */ -router.post('/suppliers/:id/inactivate', isRealAdmin, BackofficeController.inactivateSupplier) +router.post('/suppliers/:id/inactivate', isAdmin, BackofficeController.inactivateSupplier) /** * @route POST /api/backoffice/suppliers/:id/activate * @desc Reactivar proveedor inactivo * @access Private (admin only) */ -router.post('/suppliers/:id/activate', isRealAdmin, BackofficeController.activateSupplier) +router.post('/suppliers/:id/activate', isAdmin, BackofficeController.activateSupplier) // ======================================== // RUTAS DE FACTURAS @@ -135,7 +135,7 @@ router.post('/suppliers/:id/activate', isRealAdmin, BackofficeController.activat /** * @route GET /api/backoffice/invoices * @desc Obtener todas las facturas con filtros y paginación - * @access Private (admin, demo-admin) + * @access Private (admin) * @query status, supplier_id, category_id, payment_method, date_from, date_to, search, include_deleted, page, limit */ router.get('/invoices', BackofficeController.getInvoices) @@ -143,7 +143,7 @@ router.get('/invoices', BackofficeController.getInvoices) /** * @route POST /api/backoffice/invoices/download-zip * @desc Descargar múltiples facturas validadas como ZIP - * @access Private (admin, demo-admin) + * @access Private (admin) * @body invoice_ids: number[] (max 100) */ router.post('/invoices/download-zip', BackofficeController.downloadValidatedInvoicesZip) @@ -151,7 +151,7 @@ router.post('/invoices/download-zip', BackofficeController.downloadValidatedInvo /** * @route GET /api/backoffice/invoices/batch-pay/preview * @desc Preview batch payment - muestra cuántas facturas se marcarían como pagadas - * @access Private (admin, demo-admin) + * @access Private (admin) * @query year, month (opcional, default: mes anterior) */ router.get('/invoices/batch-pay/preview', BackofficeController.previewBatchPayment) @@ -162,12 +162,12 @@ router.get('/invoices/batch-pay/preview', BackofficeController.previewBatchPayme * @access Private (admin only) * @body { year?: number, month?: number } (opcional, default: mes anterior) */ -router.post('/invoices/batch-pay', isRealAdmin, BackofficeController.executeBatchPayment) +router.post('/invoices/batch-pay', isAdmin, BackofficeController.executeBatchPayment) /** * @route GET /api/backoffice/invoices/batch-pay/revert/preview * @desc Preview revert batch payment - muestra cuántas facturas se revertirían a validated - * @access Private (admin, demo-admin) + * @access Private (admin) * @query year, month (requeridos) */ router.get('/invoices/batch-pay/revert/preview', BackofficeController.previewRevertBatchPayment) @@ -178,19 +178,19 @@ router.get('/invoices/batch-pay/revert/preview', BackofficeController.previewRev * @access Private (admin only) * @body { year: number, month: number } (requeridos) */ -router.post('/invoices/batch-pay/revert', isRealAdmin, BackofficeController.revertBatchPayment) +router.post('/invoices/batch-pay/revert', isAdmin, BackofficeController.revertBatchPayment) /** * @route GET /api/backoffice/invoices/:id * @desc Obtener factura por ID con historial - * @access Private (admin, demo-admin) + * @access Private (admin) */ router.get('/invoices/:id', BackofficeController.getInvoiceById) /** * @route GET /api/backoffice/invoices/:id/history * @desc Obtener historial de una factura - * @access Private (admin, demo-admin) + * @access Private (admin) */ router.get('/invoices/:id/history', BackofficeController.getInvoiceHistory) @@ -200,14 +200,14 @@ router.get('/invoices/:id/history', BackofficeController.getInvoiceHistory) * @access Private (admin only) * @body invoice_number, supplier_id, amount_without_vat, amount_with_vat, invoice_date, payment_method, etc. */ -router.post('/invoices', isRealAdmin, BackofficeController.createInvoice) +router.post('/invoices', isAdmin, BackofficeController.createInvoice) /** * @route PATCH /api/backoffice/invoices/:id * @desc Actualizar factura * @access Private (admin only) */ -router.patch('/invoices/:id', isRealAdmin, BackofficeController.updateInvoice) +router.patch('/invoices/:id', isAdmin, BackofficeController.updateInvoice) /** * @route POST /api/backoffice/invoices/:id/validate @@ -215,7 +215,7 @@ router.patch('/invoices/:id', isRealAdmin, BackofficeController.updateInvoice) * @access Private (admin only) * @body validated_pdf_url, validated_pdf_public_id, validation_notes (opcional) */ -router.post('/invoices/:id/validate', isRealAdmin, BackofficeController.validateInvoice) +router.post('/invoices/:id/validate', isAdmin, BackofficeController.validateInvoice) /** * @route POST /api/backoffice/invoices/:id/reject @@ -223,7 +223,7 @@ router.post('/invoices/:id/validate', isRealAdmin, BackofficeController.validate * @access Private (admin only) * @body notes (requerido) */ -router.post('/invoices/:id/reject', isRealAdmin, BackofficeController.rejectInvoice) +router.post('/invoices/:id/reject', isAdmin, BackofficeController.rejectInvoice) /** * @route POST /api/backoffice/invoices/:id/unvalidate @@ -231,7 +231,7 @@ router.post('/invoices/:id/reject', isRealAdmin, BackofficeController.rejectInvo * @access Private (admin only) * @body notes (opcional) */ -router.post('/invoices/:id/unvalidate', isRealAdmin, BackofficeController.unvalidateInvoice) +router.post('/invoices/:id/unvalidate', isAdmin, BackofficeController.unvalidateInvoice) /** * @route POST /api/backoffice/invoices/:id/pay @@ -239,21 +239,21 @@ router.post('/invoices/:id/unvalidate', isRealAdmin, BackofficeController.unvali * @access Private (admin only) * @body paid_date (requerido) */ -router.post('/invoices/:id/pay', isRealAdmin, BackofficeController.markAsPaid) +router.post('/invoices/:id/pay', isAdmin, BackofficeController.markAsPaid) /** * @route POST /api/backoffice/invoices/:id/unpay * @desc Revertir el pago de una factura (paid -> validated) * @access Private (admin only) */ -router.post('/invoices/:id/unpay', isRealAdmin, BackofficeController.revertPayment) +router.post('/invoices/:id/unpay', isAdmin, BackofficeController.revertPayment) /** * @route DELETE /api/backoffice/invoices/:id * @desc Eliminar factura (hard delete - eliminación permanente) * @access Private (admin only) */ -router.delete('/invoices/:id', isRealAdmin, BackofficeController.deleteInvoice) +router.delete('/invoices/:id', isAdmin, BackofficeController.deleteInvoice) /** * @route POST /api/backoffice/invoices/:id/pdf @@ -264,7 +264,7 @@ router.delete('/invoices/:id', isRealAdmin, BackofficeController.deleteInvoice) */ router.post( '/invoices/:id/pdf', - isRealAdmin, + isAdmin, upload.single('pdf'), BackofficeController.uploadInvoicePdf ) @@ -273,7 +273,7 @@ router.post( * @route GET /api/backoffice/invoices/:id/pdf-download * @desc PDF de la factura. Los ficheros son privados en Cloudinary: la API * los descarga y los sirve; su URL firmada no sale nunca del servidor - * @access Private (admin, demo-admin) + * @access Private (admin) * @query type (original/validated) */ router.get('/invoices/:id/pdf-download', BackofficeController.downloadInvoicePdf) @@ -285,7 +285,7 @@ router.get('/invoices/:id/pdf-download', BackofficeController.downloadInvoicePdf /** * @route GET /api/backoffice/assets * @desc Obtener todos los assets - * @access Private (admin, demo-admin) + * @access Private (admin) * @query type (stamp/signature) opcional */ router.get('/assets', BackofficeController.getAssets) @@ -293,7 +293,7 @@ router.get('/assets', BackofficeController.getAssets) /** * @route GET /api/backoffice/assets/:id/file * @desc Imagen del sello o la firma (fichero privado servido por la API) - * @access Private (admin, demo-admin) + * @access Private (admin) */ router.get('/assets/:id/file', BackofficeController.getAssetFile) @@ -304,20 +304,20 @@ router.get('/assets/:id/file', BackofficeController.getAssetFile) * @body type (stamp/signature), name, is_default (opcional) * @file image (multipart/form-data) - PNG o WebP */ -router.post('/assets', isRealAdmin, upload.single('image'), BackofficeController.createAsset) +router.post('/assets', isAdmin, upload.single('image'), BackofficeController.createAsset) /** * @route DELETE /api/backoffice/assets/:id * @desc Eliminar asset * @access Private (admin only) */ -router.delete('/assets/:id', isRealAdmin, BackofficeController.deleteAsset) +router.delete('/assets/:id', isAdmin, BackofficeController.deleteAsset) /** * @route PATCH /api/backoffice/assets/:id/default * @desc Establecer asset como predeterminado * @access Private (admin only) */ -router.patch('/assets/:id/default', isRealAdmin, BackofficeController.setDefaultAsset) +router.patch('/assets/:id/default', isAdmin, BackofficeController.setDefaultAsset) export default router diff --git a/backend/routes/demo/demo-activity-routes.ts b/backend/routes/demo/demo-activity-routes.ts index 1a2aca7..8771aae 100644 --- a/backend/routes/demo/demo-activity-routes.ts +++ b/backend/routes/demo/demo-activity-routes.ts @@ -1,22 +1,22 @@ // backend/routes/demo/demo-activity-routes.ts /** * Rutas para consultar y gestionar logs de actividad demo - * Solo accesible para administradores reales (no demo-admin) + * Solo accesible para administradores (nunca la cuenta demo) */ import { Router } from 'express' import { DemoActivityController } from '../../controllers/demo/demo-activity-controller.js' import { authenticateToken } from '../../middlewares/authenticateToken.js' -import { isRealAdmin } from '../../middlewares/roleCheck.js' +import { isAdmin } from '../../middlewares/roleCheck.js' import { denyDemo } from '../../middlewares/demoRestriction.js' const router = Router() -// Aplicar autenticación y verificar que es admin real +// Aplicar autenticación y verificar que es admin router.use(authenticateToken) // The log holds visitors' IPs: never for the demo account, which is an admin router.use(denyDemo) -router.use(isRealAdmin) +router.use(isAdmin) // ======================================== // RUTAS DE LOGS @@ -25,7 +25,7 @@ router.use(isRealAdmin) /** * @route GET /api/demo-activity/logs * @desc Obtener logs de actividad demo con paginación y filtros - * @access Private (admin only, not demo-admin) + * @access Private (admin, not the demo account) * @query limit, offset, username, method, route, startDate, endDate, blocked */ router.get('/logs', DemoActivityController.getLogs) @@ -33,7 +33,7 @@ router.get('/logs', DemoActivityController.getLogs) /** * @route GET /api/demo-activity/stats * @desc Obtener estadísticas de actividad demo - * @access Private (admin only, not demo-admin) + * @access Private (admin, not the demo account) * @query days (default 30, max 365) */ router.get('/stats', DemoActivityController.getStats) @@ -41,7 +41,7 @@ router.get('/stats', DemoActivityController.getStats) /** * @route GET /api/demo-activity/export * @desc Exportar logs a formato markdown (descargar registrosDemo.md) - * @access Private (admin only, not demo-admin) + * @access Private (admin, not the demo account) * @query limit (default 100, max 500) */ router.get('/export', DemoActivityController.exportToMarkdown) @@ -49,7 +49,7 @@ router.get('/export', DemoActivityController.exportToMarkdown) /** * @route DELETE /api/demo-activity/cleanup * @desc Limpiar logs antiguos - * @access Private (admin only, not demo-admin) + * @access Private (admin, not the demo account) * @query daysToKeep (default 90, min 30) */ router.delete('/cleanup', DemoActivityController.cleanupOldLogs) diff --git a/backend/routes/demo/demo-reset-routes.ts b/backend/routes/demo/demo-reset-routes.ts index 62aa3d9..c8edd18 100644 --- a/backend/routes/demo/demo-reset-routes.ts +++ b/backend/routes/demo/demo-reset-routes.ts @@ -9,7 +9,7 @@ import { Router, type Request, type Response, type NextFunction } from 'express' import { getStatus, resetNow, saveSnapshot } from '../../controllers/demo/demo-reset-controller.js' import { authenticateToken } from '../../middlewares/authenticateToken.js' import { denyDemo } from '../../middlewares/demoRestriction.js' -import { isRealAdmin } from '../../middlewares/roleCheck.js' +import { isAdmin } from '../../middlewares/roleCheck.js' import { isDemoMode } from '../../services/demo/demo-reset.service.js' const router = Router() @@ -24,7 +24,7 @@ router.use((_req: Request, res: Response, next: NextFunction) => { router.use(authenticateToken) // The demo account is an admin: it must not reset the demo for everyone router.use(denyDemo) -router.use(isRealAdmin) +router.use(isAdmin) router.get('/status', getStatus) router.post('/reset', resetNow) diff --git a/backend/services/auth/module-access.ts b/backend/services/auth/module-access.ts index b9fb997..13fff2f 100644 --- a/backend/services/auth/module-access.ts +++ b/backend/services/auth/module-access.ts @@ -8,7 +8,7 @@ export type AppModule = 'parking' | 'maintenance' | 'groups' | 'blacklist' | 'cashier' | 'logbook' const allButMaintenanceRole = (role: string) => role !== 'mantenimiento' -const staffRoles = ['admin', 'recepcionista', 'group-admin', 'mantenimiento', 'demo-admin'] +const staffRoles = ['admin', 'recepcionista', 'group-admin', 'mantenimiento'] const MODULE_ACCESS: Record boolean> = { parking: allButMaintenanceRole, // excludeMantenimiento @@ -31,7 +31,7 @@ export function openableModules( return new Set(modules.filter((m) => canOpenModule(role, m))) } -// Only admin: the demo-admin user is disabled, so new rules leave that role out +// Only admin (the public demo account is an admin too, limited by demoRestriction) export function isAdminRole(role: string | undefined): boolean { return role?.toLowerCase() === 'admin' } diff --git a/backend/tests/activity/activity-access.test.ts b/backend/tests/activity/activity-access.test.ts index 2726802..8920a6f 100644 --- a/backend/tests/activity/activity-access.test.ts +++ b/backend/tests/activity/activity-access.test.ts @@ -12,7 +12,7 @@ describe('readableSources', () => { }) it('gives the other roles every source', () => { - for (const role of ['admin', 'recepcionista', 'group-admin', 'demo-admin']) { + for (const role of ['admin', 'recepcionista', 'group-admin']) { expect(readableSources(role).size).toBe(4) } }) diff --git a/backend/tests/auth/demo-restriction.test.ts b/backend/tests/auth/demo-restriction.test.ts index 1c6c0c8..f440ea6 100644 --- a/backend/tests/auth/demo-restriction.test.ts +++ b/backend/tests/auth/demo-restriction.test.ts @@ -1,6 +1,6 @@ // tests/auth/demo-restriction.test.ts -// Regression tests for demoRestriction middleware — pure logic against mocked req/res. -// Locks the positive-list of routes a demo-admin can mutate. +// Regression tests for the demo account guards (users.is_demo, ADR-038) — +// pure logic against mocked req/res. import { describe, it, expect, vi, beforeEach } from 'vitest' import type { Request, Response, NextFunction } from 'express' @@ -45,6 +45,11 @@ beforeEach(() => { vi.clearAllMocks() }) +// The public demo account (users.is_demo) is a real admin: only what is +// declared here or on the route is blocked +const DEMO_USER = { id: 'd1', username: 'demo', role: 'admin', isDemo: true } as any +const OWNER = { id: 'a1', username: 'admin', role: 'admin' } as any + describe('demoRestriction — pass-through cases', () => { it('lets through requests without an authenticated user (handled upstream)', () => { const req = buildReq() @@ -64,85 +69,12 @@ describe('demoRestriction — pass-through cases', () => { demoRestriction(req, res, next) expect(next).toHaveBeenCalledOnce() }) - - it('lets demo-admin perform any GET', () => { - const req = buildReq({ - user: { id: 'demo', role: 'demo-admin' } as any, - method: 'GET', - originalUrl: '/api/scheduling/months/77', - }) - const res = buildRes() - demoRestriction(req, res, next) - expect(next).toHaveBeenCalledOnce() - }) -}) - -describe('demoRestriction — demo whitelist (POSTs allowed)', () => { - const allowed: Array<[string, string]> = [ - ['POST', '/api/auth/logout'], - ['POST', '/api/parking/bookings'], - ['POST', '/api/logbooks/42/comments'], - ['POST', '/api/maintenance'], - ] - - for (const [method, url] of allowed) { - it(`allows demo-admin: ${method} ${url}`, () => { - const req = buildReq({ - user: { id: 'demo', role: 'demo-admin' } as any, - method, - originalUrl: url, - }) - const res = buildRes() - demoRestriction(req, res, next) - expect(next).toHaveBeenCalledOnce() - expect(res.status).not.toHaveBeenCalled() - }) - } - - it('also matches the whitelist with a query string appended', () => { - const req = buildReq({ - user: { id: 'demo', role: 'demo-admin' } as any, - method: 'POST', - originalUrl: '/api/parking/bookings?foo=bar', - }) - const res = buildRes() - demoRestriction(req, res, next) - expect(next).toHaveBeenCalledOnce() - }) -}) - -describe('demoRestriction — demo writes blocked (deny-by-default)', () => { - const blocked: Array<[string, string]> = [ - ['POST', '/api/scheduling/months'], - ['POST', '/api/scheduling/months/77/generate'], - ['DELETE', '/api/parking/bookings/123'], - ['PATCH', '/api/maintenance/abc'], - ['PUT', '/api/users/123'], - ['POST', '/api/auth/register'], - ['POST', '/api/logbooks'], // logbook root, not /:id/comments - ] - - for (const [method, url] of blocked) { - it(`blocks demo-admin: ${method} ${url}`, () => { - const req = buildReq({ - user: { id: 'demo', role: 'demo-admin' } as any, - method, - originalUrl: url, - }) - const res = buildRes() - demoRestriction(req, res, next) - expect(next).not.toHaveBeenCalled() - expect(res.status).toHaveBeenCalledWith(403) - const payload = (res.json as any).mock.calls[0][0] - expect(payload.demo).toBe(true) - }) - } }) describe('demoRestriction — blocked-attempt log', () => { it('redacts password fields from the logged body preview', () => { const req = buildReq({ - user: { id: 'demo', role: 'demo-admin' } as any, + user: DEMO_USER, method: 'PATCH', originalUrl: '/api/auth/me/password', body: { @@ -152,7 +84,7 @@ describe('demoRestriction — blocked-attempt log', () => { note: 'keep', }, }) - demoRestriction(req, buildRes(), next) + denyDemo(req, buildRes(), next) const logged = (DemoActivityRepository.logActivity as any).mock.calls[0][0] .body_preview as string expect(logged).not.toMatch(/secret/) @@ -165,11 +97,6 @@ describe('demoRestriction — blocked-attempt log', () => { }) }) -// The public demo account (users.is_demo) is a real admin: only what is -// declared here or on the route is blocked -const DEMO_USER = { id: 'd1', username: 'demo', role: 'admin', isDemo: true } as any -const OWNER = { id: 'a1', username: 'admin', role: 'admin' } as any - describe('demoRestriction — demo account (is_demo)', () => { it.each(['multipart/form-data; boundary=x', 'Multipart/Form-Data', 'multipart/mixed'])( 'blocks any upload (content-type %s)', diff --git a/backend/tests/backoffice/invoice-status.test.ts b/backend/tests/backoffice/invoice-status.test.ts index ec592e8..6119d0b 100644 --- a/backend/tests/backoffice/invoice-status.test.ts +++ b/backend/tests/backoffice/invoice-status.test.ts @@ -69,7 +69,7 @@ describe('controller', () => { it('unpay is admin only', () => { expect(routes).toContain( - "router.post('/invoices/:id/unpay', isRealAdmin, BackofficeController.revertPayment)" + "router.post('/invoices/:id/unpay', isAdmin, BackofficeController.revertPayment)" ) }) }) diff --git a/backend/tests/maintenance/deleted-reports.test.ts b/backend/tests/maintenance/deleted-reports.test.ts index 883bec1..8bd9841 100644 --- a/backend/tests/maintenance/deleted-reports.test.ts +++ b/backend/tests/maintenance/deleted-reports.test.ts @@ -24,7 +24,7 @@ function method(source: string, name: string): string { } describe('isAdminRole', () => { - it('is admin only: the demo-admin user is disabled', () => { + it('is admin only (demo-admin is a retired role)', () => { for (const role of ['admin', 'ADMIN']) expect(isAdminRole(role), role).toBe(true) for (const role of [ 'demo-admin', diff --git a/backend/tests/search/search-access.test.ts b/backend/tests/search/search-access.test.ts index 8a52786..1f236a6 100644 --- a/backend/tests/search/search-access.test.ts +++ b/backend/tests/search/search-access.test.ts @@ -12,7 +12,7 @@ describe('searchableModules', () => { }) it('gives the other roles every module', () => { - for (const role of ['admin', 'recepcionista', 'group-admin', 'demo-admin', 'ADMIN']) { + for (const role of ['admin', 'recepcionista', 'group-admin', 'ADMIN']) { expect([...searchableModules(role)].sort()).toEqual([ 'blacklist', 'groups', diff --git a/backend/tests/security/demo-reset.test.ts b/backend/tests/security/demo-reset.test.ts index 32b2212..508ee64 100644 --- a/backend/tests/security/demo-reset.test.ts +++ b/backend/tests/security/demo-reset.test.ts @@ -53,7 +53,7 @@ describe('demo reset routes', () => { it('block the demo account before checking the admin role', () => { const auth = routes.indexOf('router.use(authenticateToken)') const deny = routes.indexOf('router.use(denyDemo)') - const admin = routes.indexOf('router.use(isRealAdmin)') + const admin = routes.indexOf('router.use(isAdmin)') expect(auth).toBeGreaterThan(-1) expect(deny).toBeGreaterThan(auth) expect(admin).toBeGreaterThan(deny) diff --git a/frontend/app/lib/helpers/utils.ts b/frontend/app/lib/helpers/utils.ts index 8614ec6..445270e 100644 --- a/frontend/app/lib/helpers/utils.ts +++ b/frontend/app/lib/helpers/utils.ts @@ -56,21 +56,21 @@ export function daysUntil(date: string | Date): number { } /** - * Verifica si el usuario tiene rol de administrador (incluye demo-admin) + * Verifica si el usuario tiene rol de administrador * Usado para mostrar/ocultar elementos de UI admin-only * - * NOTA: demo-admin puede VER todo pero sus escrituras están limitadas - * por el middleware demoRestriction en el backend. + * La cuenta demo pública también es admin: el backend limita lo que puede + * cambiar (demoRestriction). */ export function isAdminRole(role: string | undefined | null): boolean { if (!role) return false const normalizedRole = role.toLowerCase().trim() - return normalizedRole === 'admin' || normalizedRole === 'demo-admin' + return normalizedRole === 'admin' } /** * Roles del middleware canRunNotificationCheck del backend: lanzan a mano la - * generación de avisos pendientes (demo-admin está deshabilitado) + * generación de avisos pendientes */ export function canRunNotificationCheckRole(role: string | undefined | null): boolean { const normalizedRole = role?.toLowerCase().trim() From 4884e7816fe5b211e25089a08665e6dbb99ac9e8 Mon Sep 17 00:00:00 2001 From: razyd Date: Mon, 5 Oct 2026 16:32:06 +0200 Subject: [PATCH 18/20] docs: describe the public demo account instead of demo-admin - module docs and AGENTS.md: demo-admin removed from every role list; backoffice reads and writes are admin only - general/README: the public demo account and what it cannot do; local development points to the root README, with DEMO_MODE and NEXT_PUBLIC_DEMO_MODE among the variables - general/database: the demo tables and the two baseline bugs a fresh install works around - db-mysql/AGENTS.md: role 7 removed Why: the demo-admin role is retired; the docs described a mechanism the code no longer has. --- backend/db-mysql/AGENTS.md | 1 - backend/services/backoffice/AGENTS.md | 11 ++++++----- backend/services/cashier/AGENTS.md | 4 ++-- backend/services/fnb/AGENTS.md | 4 ++-- backend/services/group/AGENTS.md | 2 +- docs/backoffice/README.md | 3 +-- docs/cashier/README.md | 6 +++--- docs/checklist/README.md | 2 +- docs/fnb/README.md | 4 ++-- docs/general/README.md | 26 +++++++++++++++++--------- docs/general/database/README.md | 11 +++++++++-- docs/groups/README.md | 2 +- 12 files changed, 45 insertions(+), 31 deletions(-) diff --git a/backend/db-mysql/AGENTS.md b/backend/db-mysql/AGENTS.md index 545dc3b..2eda1fe 100644 --- a/backend/db-mysql/AGENTS.md +++ b/backend/db-mysql/AGENTS.md @@ -136,7 +136,6 @@ See `aiven/aiven-conexion.md` for connection details. SSL required. | 2 | `admin` | Full admin | | 3 | `mantenimiento` | Maintenance staff | | 6 | `group-admin` | Group administrator | -| 7 | `demo-admin` | Demo user (disabled since 2026-05-12) | ## Backups diff --git a/backend/services/backoffice/AGENTS.md b/backend/services/backoffice/AGENTS.md index 5b01b38..9967091 100644 --- a/backend/services/backoffice/AGENTS.md +++ b/backend/services/backoffice/AGENTS.md @@ -111,13 +111,14 @@ A `revertBatchPayment` endpoint exists for undo (also with a preview). ## Role boundaries -- **Read anything** (stats, categories, suppliers, invoices, assets) — `admin`, - `demo-admin` (`canAccessBackoffice`) +- **Read anything** (stats, categories, suppliers, invoices, assets) — `admin` + (`canAccessBackoffice`) - **Create/update/delete suppliers, invoices, assets; validate/reject/pay - invoices; batch operations** — `admin` only (`isRealAdmin`) + invoices; batch operations** — `admin` only (`isAdmin`) -`canAccessBackoffice` = `['admin', 'demo-admin']`. `isRealAdmin` = `['admin']` -only. +`canAccessBackoffice` = `isAdmin` = `['admin']`. The public demo account is an +admin: it cannot create categories or upload PDFs/assets (`denyDemo`, +`demoRestriction`). ## Frontend layout diff --git a/backend/services/cashier/AGENTS.md b/backend/services/cashier/AGENTS.md index 693410d..dffd971 100644 --- a/backend/services/cashier/AGENTS.md +++ b/backend/services/cashier/AGENTS.md @@ -113,9 +113,9 @@ patches. - **Read daily, shifts, vouchers** — all authenticated (excl. `mantenimiento`) - **Create/update/close shifts, payments, denominations, vouchers** — `admin`, - `recepcionista`, `group-admin`, `demo-admin` (`canManageCashier`) + `recepcionista`, `group-admin` (`canManageCashier`) - **Reopen shifts or days, delete any resource** — `admin` only (`isAdmin`) -- **Reports, history, stats** — `admin`, `demo-admin` (`canViewReports`) +- **Reports, history, stats** — `admin` (`canViewReports`) `mantenimiento` role is excluded at the router level (`excludeMantenimiento`). diff --git a/backend/services/fnb/AGENTS.md b/backend/services/fnb/AGENTS.md index c0fa051..00cac41 100644 --- a/backend/services/fnb/AGENTS.md +++ b/backend/services/fnb/AGENTS.md @@ -128,7 +128,7 @@ it might become viable again. Don't upgrade without measuring. - **`DELETE /api/fnb/day/:date`**: delete all records for a day The whole subroute sits behind `authenticateToken` + `canAccessFnb`. Allowed -roles: `admin`, `recepcionista`, `demo-admin`, **`group-admin`** (the last one +roles: `admin`, `recepcionista`, **`group-admin`** (the last one added in `00bab83`). **About `/entries` (manual):** the manual path exists for when Opera doesn't @@ -188,7 +188,7 @@ monthlyExpenses) is also hardcoded — part of the mocked phase. ## Auth -`canAccessFnb` allows: `admin`, `group-admin`, `demo-admin`, `recepcionista`. +`canAccessFnb` allows: `admin`, `group-admin`, `recepcionista`. **Mantenimiento blocked.** No admin-only restrictions on endpoints — reception can upload PDFs and edit manually. diff --git a/backend/services/group/AGENTS.md b/backend/services/group/AGENTS.md index a78cada..bf91c63 100644 --- a/backend/services/group/AGENTS.md +++ b/backend/services/group/AGENTS.md @@ -108,7 +108,7 @@ the notification is saved but the email is skipped silently. - **Read groups, contacts, rooms, payments, status, history**: all authenticated (`canViewGroups` — includes `mantenimiento`) - **Create/update groups, contacts, rooms, payments, status**: `admin`, - `group-admin`, `demo-admin` (`canManageGroups`) + `group-admin` (`canManageGroups`) - **Delete group**: `admin` only (`canManageGroups` + `isAdmin`) `mantenimiento` can **view** groups (needed for notification access from the diff --git a/docs/backoffice/README.md b/docs/backoffice/README.md index 718d9a8..5deba49 100644 --- a/docs/backoffice/README.md +++ b/docs/backoffice/README.md @@ -15,8 +15,7 @@ in one step. ## Who uses it -Only **`admin`**. `demo-admin` can see it but not change anything. No other role -has access. +Only **`admin`**. No other role has access. ## What it can do diff --git a/docs/cashier/README.md b/docs/cashier/README.md index cc9359e..a03380b 100644 --- a/docs/cashier/README.md +++ b/docs/cashier/README.md @@ -18,12 +18,12 @@ when every shift is closed. Every role **except `mantenimiento`**: - **Everyone with access** sees days, shifts and vouchers. -- **`admin`, `recepcionista`, `group-admin` and `demo-admin`** start a day, fill - in and close shifts, record payments and counts, create vouchers and close the +- **`admin`, `recepcionista` and `group-admin`** start a day, fill in and close + shifts, record payments and counts, create vouchers and close the day. - **Only `admin`** reopens a shift or a day, cancels or deletes a voucher, deletes a shift and changes who worked a shift. -- **Reports, history and statistics** are for `admin` (and `demo-admin`). +- **Reports, history and statistics** are for `admin`. - Closed shifts and closed days **stay editable** by those same roles. This is deliberate for now (ADR-032). diff --git a/docs/checklist/README.md b/docs/checklist/README.md index 020fe1e..6300c9f 100644 --- a/docs/checklist/README.md +++ b/docs/checklist/README.md @@ -16,7 +16,7 @@ away. Every morning the lists start fresh. Every role **except `mantenimiento`**. Anyone with access ticks and unticks steps and adds comments or images. Restarting a checklist by hand is for -`admin`, `recepcionista` and `demo-admin`. A comment or image can be deleted by +`admin` and `recepcionista`. A comment or image can be deleted by its author or by an `admin`. ## What it can do diff --git a/docs/fnb/README.md b/docs/fnb/README.md index 518adec..e2e9d82 100644 --- a/docs/fnb/README.md +++ b/docs/fnb/README.md @@ -20,8 +20,8 @@ phase (product inventory, supplier orders) that has not started. ## Who uses it -Every role **except `mantenimiento`**: `admin`, `recepcionista`, `group-admin` -and `demo-admin`. There are no admin-only actions — any of these roles can +Every role **except `mantenimiento`**: `admin`, `recepcionista` and +`group-admin`. There are no admin-only actions — any of these roles can upload a PDF, edit a day by hand, or delete a day's revenue, of any date. This is deliberate for now (ADR-032). diff --git a/docs/general/README.md b/docs/general/README.md index 1985039..04c49c3 100644 --- a/docs/general/README.md +++ b/docs/general/README.md @@ -25,9 +25,11 @@ Hotel staff, each with **a role**: receives their notices. - **`mantenimiento`**: only the maintenance module, plus the common functions (profile, messages, notifications, search). -- **`demo-admin`**: sees everything as `admin`, but can only write in a - whitelist (log out, create a parking booking, comment in the logbook, open a - maintenance report). Each blocked attempt is recorded. +- **Public demo account** (`users.is_demo = 1`, only where `DEMO_MODE=true`): + an `admin` entered with the demo button, without a password. It can do + everything the daily reset undoes, but not manage users, departments or the + scheduling configuration, upload files, or reset the demo. Each blocked + attempt is recorded. The side menu only hides backoffice and scheduling links from non-admins. **The backend is what actually decides**: each route checks the role and returns `403` @@ -244,16 +246,22 @@ Hostinger. Delete or deactivate the `qa_*` users and the `QA-` test data, and ## Local development +Installing from scratch (MySQL in Docker or your own, `pnpm setup:local`, the +local admin `admin` / `fourpoints-local`) is in the root +[`README.md`](../../README.md#run-it-locally). Day to day: + ```bash -cd backend && pnpm install && pnpm dev:aiven # API against Aiven; frontend looks for it on :4000 -cd frontend && pnpm install && pnpm dev # http://localhost:3000 +cd backend && pnpm dev:local # API on :4000 against the local MySQL (dev:aiven: against Aiven) +cd frontend && pnpm dev # http://localhost:3000 ``` Backend variables: `PORT` (4000 locally), `NODE_ENV`, `SECRET_JWT_KEY`, -`SALT_ROUNDS`, `DB_ENVIRONMENT=aiven`, `AIVEN_DB_*` + `AIVEN_PASSWORD`, -`CLOUDINARY_*`, `LOG_LEVEL`, `FRONTEND_URL`. Frontend: `NEXT_PUBLIC_API_URL`, -`NEXT_SERVER_API_URL` (optional) and `NEXT_PUBLIC_APP_URL`, which the user -creation route uses to accept the request origin. +`SALT_ROUNDS`, `DB_ENVIRONMENT` (`local` or `aiven`), `LOCAL_DB_*` or +`AIVEN_DB_*` + `AIVEN_PASSWORD`, `CLOUDINARY_*`, `LOG_LEVEL`, `FRONTEND_URL` +and `DEMO_MODE` (`true` only where the public demo runs). Frontend: +`NEXT_PUBLIC_API_URL`, `NEXT_SERVER_API_URL` (optional), `NEXT_PUBLIC_APP_URL`, +which the user creation route uses to accept the request origin, and +`NEXT_PUBLIC_DEMO_MODE`, which shows the demo button on the login page. **Tests**: `pnpm test` in `backend/` (also `test:watch` and `test:coverage`). Some suites (login, checklist, F&B, scheduling requests) use the configured diff --git a/docs/general/database/README.md b/docs/general/database/README.md index 52ad22d..338d5b8 100644 --- a/docs/general/database/README.md +++ b/docs/general/database/README.md @@ -31,10 +31,12 @@ Tables by module: **maintenance** (3), **messaging** (3), **notifications** (2), **backoffice** (5 tables + 3 views `v_bo_*`), **scheduling** (12), **checklist** (6), **F&B** (2). -- **Demo mode**: `demo_activity_log`. +- **Public demo**: `demo_activity_log` (blocked attempts), `demo_reset_log` + (daily resets and saved scheduling bases) and the `demo_snapshot_scheduling_*` + copies the reset restores, created when an admin saves the base. **Roles** (table `roles`): 1 `recepcionista`, 2 `admin`, 3 `mantenimiento`, 6 -`group-admin`, 7 `demo-admin` (the demo user is deactivated). +`group-admin`. The public demo account is an `admin` with `users.is_demo = 1`. **Logic that lives in the database itself** (not in the code): @@ -57,6 +59,11 @@ Tables by module: - **`aiven/` is the frozen base schema** as of 2026-05-20. The name is misleading: it is not "the Aiven database", but the initial installation that runs `MASTER_INSTALL.sql`. It is never run against a database with data. + Two of its files fail on an empty server (a duplicate foreign key name in + `11_cashier.sql`, a foreign key to a later table in `19_scheduling.sql`): + `pnpm setup:local` fixes both in memory, and + `20261005_complete_fresh_install.sql` adds what an install made by hand + misses. - **Character set**: `utf8mb4` with `utf8mb4_0900_ai_ci`, which does not distinguish case or accents when comparing texts. - **Time**: the Aiven server is in **UTC**. What the database calculates with diff --git a/docs/groups/README.md b/docs/groups/README.md index 9b69d8e..945db34 100644 --- a/docs/groups/README.md +++ b/docs/groups/README.md @@ -16,7 +16,7 @@ lines in parallel, and sends in-app reminders before the important dates. - **Every role can view groups**, including `recepcionista` and `mantenimiento` (the latter needs it to open group notifications from the profile). -- **`admin`, `group-admin` and `demo-admin`** create and edit groups, payments, +- **`admin` and `group-admin`** create and edit groups, payments, contacts, rooms and statuses, and send manual notifications. - **Only `admin`** deletes a group. From fa744b8d59c4e124c19b73e16ff2efeb9b7dc437 Mon Sep 17 00:00:00 2001 From: razyd Date: Mon, 5 Oct 2026 16:32:06 +0200 Subject: [PATCH 19/20] docs: record the demo as built and what is left before going public - DECISIONS: ADR-039, the local database is a documented install again and Aiven stays the only shared one; ADR-015 points to it - TODO: the demo entry now lists only what is left (scheduling base, role migration on Aiven, production variables); demo-admin, local database removal and the schema comparison entries go; three findings added (excludeMantenimiento is a deny list, the old disable_demo_user script, blank Settings tab) - SESSION and ROADMAP: phase 5 built and tested on preview; next steps - VERIFY: one row per commit since the switch to the new repository - INDEX.md: 20261005_drop_demo_admin_role.sql logged Why: ADR-038 relies on the local database ADR-015 had dropped, and the pending list has to match what is done. --- backend/db-mysql/INDEX.md | 1 + docs/DECISIONS.md | 27 ++++++++++++- docs/ROADMAP.md | 6 ++- docs/SESSION.md | 21 +++++++--- docs/TODO.md | 83 +++++++++++++-------------------------- docs/VERIFY.md | 21 ++++++++++ 6 files changed, 95 insertions(+), 64 deletions(-) diff --git a/backend/db-mysql/INDEX.md b/backend/db-mysql/INDEX.md index 93b2def..a910fb1 100644 --- a/backend/db-mysql/INDEX.md +++ b/backend/db-mysql/INDEX.md @@ -108,6 +108,7 @@ db-mysql/ | 2026-10-05 | `20261005_add_demo_reset_log.sql` | Crea `demo_reset_log`: un registro por cada reinicio diario de la demo y por cada base de horarios guardada (ADR-038). La base vive en tablas `demo_snapshot_scheduling_*` que crea el backend al guardarla. Hay que aplicarla antes de desplegar el código. `CREATE TABLE IF NOT EXISTS`. | ✅ local · ✅ Aiven | | 2026-10-05 | `20261005_complete_fresh_install.sql` | Completa una instalación desde cero: el baseline congelado no crea `cashier_history` (nombre de clave duplicado), `scheduling_assignments` (referencia a una tabla creada después) ni `demo_activity_log`, y deja `roles.name` en `varchar(50)`. Definiciones copiadas de Aiven. En Aiven no cambia nada; en local creó `demo_activity_log`, que faltaba. `pnpm setup:local` además corrige esos dos fallos del baseline en memoria. | ✅ local · ✅ Aiven (sin cambios) | | 2026-10-05 | `20261005_fix_bo_monthly_summary_view.sql` | Recrea `v_bo_monthly_summary` con `period` en el `GROUP BY`. Con `ONLY_FULL_GROUP_BY` (activo en Aiven y local) toda consulta a la vista fallaba con el error 1055 y `GET /api/backoffice/stats/monthly` respondía siempre 500. Mismas columnas y mismos grupos; comprobada contra un cálculo directo sobre `bo_invoices`. | ✅ local · ✅ Aiven | +| 2026-10-05 | `20261005_drop_demo_admin_role.sql` | Borra el rol `demo-admin` si ningún usuario lo tiene (ADR-038): la cuenta demo es un admin con `is_demo` y el código ya no conoce ese rol. En local no existía. | ✅ local (sin cambios) · ⏳ Aiven | ### Verificación (99) diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index f43948f..0711f11 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -244,7 +244,8 @@ ## ADR-015 — Una sola base de datos: Aiven, con datos de prueba -- **Estado:** ✅ aceptada (2026-09-28) +- **Estado:** ✅ aceptada (2026-09-28) · 📌 BD local revisada por + [ADR-039](#adr-039--la-bd-local-vuelve-como-instalación-documentada-aiven-sigue-siendo-la-única-compartida) - **Fecha:** 2026-09-28 - **Decisión:** el proyecto trabaja contra **una única BD, la de Aiven**, que solo contiene datos de ejemplo. No se mantiene una BD local de desarrollo ni @@ -817,3 +818,27 @@ el solver y las escrituras del usuario demo. El README pasa a explicar la instalación local. - **Revisa:** ADR-037 en los usuarios demo y su acceso. + +## ADR-039 — La BD local vuelve como instalación documentada; Aiven sigue siendo la única compartida + +- **Estado:** ✅ aceptada (2026-10-05) +- **Fecha:** 2026-10-05 +- **Decisión:** + - **Aiven** sigue siendo la única BD del proyecto que se comparte: + producción, preview y la demo pública. + - **La BD local** vuelve a ser parte documentada del proyecto: quien clona el + repositorio la monta con `pnpm setup:local` (con MySQL propio o en Docker) + y el propietario puede seguir usando la suya con `pnpm dev:local`. Se + mantienen `DB_ENVIRONMENT`, el preset `local` y `LOCAL_DB_*`. +- **Motivo:** ADR-038: quien quiera ver la aplicación entera la instala en + local con un admin completo. Y lo que ADR-015 rechazaba ya existe: una + instalación desde los scripts del repositorio, comprobada el 2026-10-05 en + un MySQL limpio, deja la misma estructura que Aiven (1161 de 1161 columnas, + índices, claves, triggers y procedimientos) y los mismos catálogos. +- **Rechazado:** quitar el soporte de BD local (la tarea pendiente de + ADR-015). +- **Consecuencias:** las migraciones se siguen aplicando en local y en Aiven + (`INDEX.md`). Dos fallos del baseline congelado, que impedían instalar desde + cero, se corrigen en `setup:local` y con + `20261005_complete_fresh_install.sql` sin tocar `aiven/` (ADR-021). +- **Revisa:** ADR-015 en la BD local. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index c6ce631..dadc513 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -65,5 +65,7 @@ rotación de credenciales (ADR-007) no bloquea: va en «Después de publicar» d Que cualquiera pruebe la aplicación en la propia web, que es solo un escaparate (ADR-037): un admin demo restringido que entra con un botón, datos ficticios, generación de horarios y reinicio diario; y quien quiera verlo todo lo instala -en local con admin completo (ADR-038). Pasos en `TODO.md`, «Demo pública en la -propia web». El repositorio se hace público cuando la demo esté lista. +en local con admin completo (ADR-038, ADR-039). Hecho en código y probado en +preview el 2026-10-05; lo que falta, en `TODO.md`, «Demo pública en la propia +web: lo que falta». El repositorio se hace público cuando la demo esté en +producción. diff --git a/docs/SESSION.md b/docs/SESSION.md index 88b2d63..27e0d9c 100644 --- a/docs/SESSION.md +++ b/docs/SESSION.md @@ -21,7 +21,12 @@ y auditado (`GITCLEAN.md`). El historial limpio está en `bpstack/four-points`, **todavía privado**. El original sigue privado como `four-points-archive`. Vercel y Render despliegan ya desde el nuevo, en producción y en preview (`VERIFY.md`, «Cambio al repositorio -nuevo»). Decisiones en `DECISIONS.md`, hasta ADR-038. +nuevo»). Decisiones en `DECISIONS.md`, hasta ADR-039. + +La demo pública (fase 5) está hecha en código y probada en preview el +2026-10-05: cuenta demo con botón, bloqueos, límites, reinicio diario, +Configuración → Demo, instalación local con `pnpm setup:local` y `demo-admin` +retirado. `DEMO_MODE` y `NEXT_PUBLIC_DEMO_MODE` solo están en preview. ## ⚠️ Empieza por aquí @@ -38,10 +43,12 @@ nuevo»). Decisiones en `DECISIONS.md`, hasta ADR-038. ## Siguiente -1. Construir la demo en la propia web, paso a paso: `TODO.md`, «Demo pública - en la propia web» (ADR-037, ADR-038). Empieza por la marca `is_demo`. -2. Pasar `four-points` a público y activar secret scanning y push protection. -3. «Después de publicar» de `TODO.md`, empezando por la rotación de +1. Terminar la demo: `TODO.md`, «Demo pública en la propia web: lo que + falta» (base de horarios, migración del rol en Aiven, variables en + producción tras el merge). +2. PR a `main` y probar el botón demo en la web. +3. Pasar `four-points` a público y activar secret scanning y push protection. +4. «Después de publicar» de `TODO.md`, empezando por la rotación de credenciales (se puede adelantar). Hecho el 2026-10-05: Vercel y Render despliegan solos desde este repositorio @@ -64,6 +71,10 @@ Nada ahora mismo. `SCHEDULING-CONSTRAINTS.md §7.5`** en un comentario: los scripts 01–19 no se tocan (ADR-021). La sección equivalente es `docs/scheduling/constraints.md §7.5`. +- **La cuenta demo es un admin** (`users.is_demo = 1`): lo que no deba tocar + se bloquea en la ruta con `denyDemo`/`denyDemoWrites` o en + `demoRestriction`. Una ruta nueva que escriba fuera de lo que reinicia el + mock (usuarios, catálogos, configuración) necesita su guarda. - **Enlaces rotos conocidos** tras archivar: `backend/README.md` (enlaces a `docs/backend/…`) y comentarios que citan `Global-Plan.md` o `SCHEDULING-SOLVER-PLAN.md`. Se arreglan al escribir cada módulo (ADR-010). diff --git a/docs/TODO.md b/docs/TODO.md index 43c03bf..f1aa835 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -136,15 +136,6 @@ sigue siendo donde se arregla este fichero y se prueba en el entorno preview. `PK-AAAAMMDD` del trigger, `CURRENT_TIMESTAMP` de las tablas) van 1–2 h por detrás de Madrid: entre las 00:00 y las 02:00 «hoy» sigue siendo ayer. _Comprobado el 2026-09-28._ -- [ ] **Quitar el soporte de BD local del código** (ADR-015) — `DB_ENVIRONMENT` - y el preset `local` de `backend/config/db.ts`, el script `dev:local`, las - variables `LOCAL_DB_*` y las menciones a «local primero» en - `backend/db-mysql/`. -- [ ] **Comprobar que los scripts del repo reproducen Aiven** — hoy Aiven es la - única BD y `backend/db-mysql/` la fuente de verdad (ADR-016), pero nadie - ha verificado que coincidan. Pista: la BD local, instalada desde esos - scripts, difería de Aiven en `demo_activity_log`, `notifications.module`, - `roles.name` y 7 claves foráneas (comparación del 2026-09-28). - [ ] **Logbook: papelera e historial solo ocultos en la pantalla** — el backend da `/trashed`, `include_trashed`, el historial y los comentarios de entradas borradas a cualquier rol con acceso. _Según la revisión @@ -288,6 +279,20 @@ sigue siendo donde se arregla este fichero y se prueba en el entorno preview. ### 🟢 Baja +- [ ] **`excludeMantenimiento` es una lista de exclusión** — parking, lista + negra, caja, partes, conciliación y departamentos dejan pasar cualquier + rol salvo `mantenimiento`, y `services/auth/module-access.ts` lo replica. + Hoy solo hay roles conocidos (el rol va en el token firmado y sale de + `roles`), pero un rol nuevo tendría acceso sin que nadie lo decida. Pasar + a lista de permitidos. _Comprobado el 2026-10-05 al retirar + `demo-admin`._ +- [ ] **`20260512_disable_demo_user.sql` desactivaría la cuenta demo** si se + volviera a ejecutar (`UPDATE users SET is_active = 0 WHERE username = + 'demo'`). `setup:local` no la ejecuta (es anterior al baseline); no + aplicarla a mano. _Comprobado el 2026-10-05._ +- [ ] **Configuración: `?tab=` de una pestaña sin permiso deja el contenido en + blanco** — p. ej. recepción con `?tab=users`; debería caer en la primera + pestaña disponible. _Visto el 2026-10-05._ - [ ] **Scheduling: vista de solo lectura para los demás roles** — hoy `/dashboard/scheduling` es solo de `admin` y el resto no ve el cuadrante. El dueño quiere que los demás (salvo `mantenimiento`) vean el cuadrante @@ -299,57 +304,23 @@ sigue siendo donde se arregla este fichero y se prueba en el entorno preview. pestaña muestra las rechazadas. Una factura incorrecta hoy se borra. El dueño decidió el 2026-10-04 no añadirlo porque casi nunca pasa; si se añade, hace falta también reabrir (`rejected` → `pending`). -- [ ] **Demo pública en la propia web** (fase 5 de `ROADMAP.md`, ADR-037 y - ADR-038) — la web es solo un escaparate, así que la demo va en - `four-points.stackbp.es` con su BD. Un paso por PR, probado en preview: - 1. **Marca `is_demo`** en `users` (migración en `scripts/`), en el token y - en un middleware que bloquea, para usuarios demo: `/api/users` (salvo - lecturas), `/api/auth/register`, `/api/auth/me/*`, escrituras en - `/api/departments`, toda petición `multipart`, `/api/demo-activity` y - la configuración de horarios (turnos, `config`, empleados y - contratos). Tests de cada bloqueo y de que el resto pasa. - 2. **Un solo usuario demo, rol admin**, con contraseña aleatoria que nadie - conoce. Ruta de entrada sin contraseña y botón «Probar la demo» en el - login, los dos solo con `DEMO_MODE=true`; la ruta con límite propio. - Las cuentas del dueño (`admin`, `qa_*`) siguen aparte. - 3. **Límites:** generar horarios unas 5 veces por hora por IP y usuario - (el solver ya tiene 30 s de máximo y va de uno en uno; Render - gratuito tiene 512 MB) y escrituras más estrictas para el usuario - demo. - 4. **Reinicio diario** con la primera entrada a la demo del día: recarga - `mock-data.sql` (fechas relativas a hoy; hay que poder ejecutarlo desde - Node, y hoy usa `DELIMITER`) y restaura una foto de las tablas de - horarios guardada en la BD. La base de horarios se genera una vez con - el solver; no se saca de los meses actuales, que vienen de bajas - reales. Ojo: el mock elige autores por rol y verá al usuario demo. - 5. **Instalación local con admin completo:** `docker-compose.yml` solo - con MySQL (opcional), `pnpm setup:local` (tablas con - `MASTER_INSTALL.sql`, primer admin con contraseña que da quien - instala, departamentos y mock) y README en inglés con los pasos y el - entorno de Python del solver. Comprobar antes que `MASTER_INSTALL.sql` - deja una BD igual que la actual. - 6. **Seguridad:** test de que un `POST` desde otro origen recibe 403 - (CSRF) y de que un enlace `javascript:` en las notas del checklist no - se ejecuta (XSS). - 7. **Retirar `demo-admin`** (ver entrada siguiente) y documentar. -- [ ] **Retirar el rol `demo-admin`** (lo sustituye la marca `is_demo`, - ADR-038) — el único usuario con - ese rol, `demo`, está desactivado (`is_active = 0`, comprobado en Aiven el - 2026-10-04). El rol sigue en los middlewares de `roleCheck.ts`, en - `demoRestriction` (lista blanca de escrituras y registro de intentos - bloqueados), en las rutas `/demo` y en el frontend (`isAdminRole`). Las - reglas añadidas el 2026-10-02 ya no lo incluyen. En producción, mientras - exista, `demo-admin` entra en backoffice y lee los PDFs de facturas por - `/pdf-url`, `/pdf-download` y el ZIP (antes un punto 🔴 aparte; se movió - aquí el 2026-10-04 porque no hay ninguna cuenta activa). Retirarlo - incluye la migración que quita el usuario `demo` y el rol. +- [ ] **Demo pública en la propia web: lo que falta** (fase 5 de `ROADMAP.md`, + ADR-037 y ADR-038) — el código está hecho y probado en preview el + 2026-10-05 (cuenta demo, bloqueos, límites, reinicio diario, pestaña + Configuración → Demo, `setup:local`). Queda: + 1. **Base de horarios:** borrar los meses actuales de Aiven (vienen de + bajas reales), generar 2 o 3 meses limpios con el solver y pulsar + «Guardar horarios actuales como base». Hasta entonces el reinicio no + toca horarios y lo que genere un visitante se queda. + 2. **Aplicar en Aiven `20261005_drop_demo_admin_role.sql`** (borra el rol + sin usuarios; enseñar antes qué borra). + 3. **Producción:** tras el merge, `DEMO_MODE=true` en el servicio de + producción de Render y `NEXT_PUBLIC_DEMO_MODE=true` en Production de + Vercel (hoy solo están en preview). Probar el botón en la web. - [ ] **Conciliation: `GET /api/conciliations` sin paginar ni filtrar** — devuelve todo el histórico a cualquier rol con acceso. _Según la revisión `security` L3 del 2026-09-28 (fichero y línea en el informe); no repasado por mí._ -- [ ] **Backoffice: `demo-admin` ve IBAN, CIF y datos de contacto completos** de - los proveedores. _Según la revisión `security` L3 del 2026-09-28 (fichero - y línea en el informe); no repasado por mí._ - [ ] **Checklist: `checklist_config` existe pero no se usa.** _Comprobado por mí el 2026-09-28._ - [ ] **Scheduling: reiniciar un mes no usa transacción** — si falla a mitad, el diff --git a/docs/VERIFY.md b/docs/VERIFY.md index e9401db..6a54b55 100644 --- a/docs/VERIFY.md +++ b/docs/VERIFY.md @@ -231,3 +231,24 @@ Vercel y Render se reconectaron de `four-points-archive` a `bpstack/four-points` | `d197ad6` | docs(todo): drop the local path of the harness repository | (documentación) | no aplica | — | | `5b216a0` | docs(roadmap): close phases 1c and 2 and make publication current | (documentación) | no aplica | — | | `0f237d0` | docs(decisions): record the publication order and when to rotate | (documentación) | no aplica | — | +| `2009f39` | docs(verify): record the switch to the new repository | (documentación) | no aplica | — | +| `8194031` | docs(session): move the session to the publication phase | (documentación) | no aplica | — | +| `0bb4c1f` | fix(db): require the Aiven user and port instead of defaulting them | Puerto y usuario de Aiven por defecto en el código | Producción arranca y conecta tras el merge (PR #2) | ✅ 2026-10-05 | +| `a474007` | chore(db): rewrite the mock data without users and for the current schema | Datos ficticios para Aiven | Cargado en local y en Aiven; horarios intactos | ✅ 2026-10-05 | +| `a889999` | docs: plan the demo on the showcase site before going public | (documentación) | no aplica | — | +| `e332b8a` | docs: settle the demo design and the local install | (documentación) | no aplica | — | +| `5789e83` | feat(auth): restrict the public demo account with an is_demo flag | Demo: marca `is_demo` y bloqueos | Preview, sesión demo real: 18 rutas, lecturas 200 y bloqueadas 403 | ✅ 2026-10-05 | +| `a306d36` | docs(db): mark the is_demo migration as applied on Aiven | (documentación) | no aplica | — | +| `8553963` | fix(auth): answer 403 to blocked demo requests without a JSON body | — | Local: DELETE y subidas bloqueadas dan 403, no 500/401 | ✅ 2026-10-05 | +| `7507d58` | feat(auth): enter the public demo with one button, without a password | Demo: entrada con botón | Preview: botón visible, entra como Demo · Admin; login con contraseña de `demo` da 401 | ✅ 2026-10-05 | +| `d8c23af` | feat(auth): rate limit the demo account's writes and schedule generation | Demo: límites | `demo-limits.test.ts` (5/h por IP, 30/h en total, 60 escrituras/15 min) | 🧪 | +| `db8b356` | feat(demo): reset the demo data daily and from Settings | Demo: reinicio diario | Preview: primer reinicio del día registrado en Aiven (734 ms); local: base de horarios restaurada, fallo deshecho; pestaña Demo solo para admins | ✅ 2026-10-05 | +| `a2395a7` | docs(db): mark the demo reset log migration as applied on Aiven | (documentación) | no aplica | — | +| `7e7ed39` | test(auth): mock the demo reset in the login timing test | — | CI en verde | ✅ 2026-10-05 | +| `ee9cbc0` | fix(db): load the mock data on a database with a single admin | — | `setup:local` en un MySQL limpio carga el mock | ✅ 2026-10-05 | +| `a8882c4` | feat(db): install a local database from scratch with pnpm setup:local | Demo: instalación local | MySQL 8.0 limpio en Docker: estructura igual que Aiven (1161 de 1161) y mismos catálogos; la app entra con el admin | ✅ 2026-10-05 | +| `9336716` | feat(db): give the local install a documented admin password | — | `setup-local.test.ts` | 🧪 | +| `e3a642c` | fix(db): group the backoffice monthly summary view by its period | — (hallazgo del 2026-10-05) | `GET /api/backoffice/stats/monthly` en preview da 200; la vista coincide con un cálculo directo | ✅ 2026-10-05 | +| `9991d79` | test(security): check the CSRF defence and the session cookies by behaviour | Demo: CSRF | `csrf-origin.test.ts` (servidor Express real), `session-cookies.test.ts`; preview: origen ajeno da 403 | ✅ 2026-10-05 | +| `4449b7a` | fix(checklist): render unsafe links in step notes as plain text | Demo: XSS | `safe-href.test.ts` (11 URL maliciosas) | 🧪 | +| `143882d` | refactor(auth): retire the demo-admin role | Retirar `demo-admin` | ⏳ preview tras el push; la migración del rol, pendiente en Aiven | ⏳ | From 4c9238e962683892a1a8aa061cf32e059ee0b8f3 Mon Sep 17 00:00:00 2001 From: razyd Date: Mon, 5 Oct 2026 18:15:23 +0200 Subject: [PATCH 20/20] docs: record the scheduling base and the demo-admin role removal - INDEX.md: 20261005_drop_demo_admin_role.sql applied on Aiven - TODO and SESSION: the demo base is saved (October to December 2026, built by the solver; the months from real sick leave are gone); only the production variables are left before going public - VERIFY: 143882d checked on preview Why: the pending list has to match what is done. --- backend/db-mysql/INDEX.md | 2 +- docs/SESSION.md | 14 +++++++------- docs/TODO.md | 12 ++++-------- docs/VERIFY.md | 2 +- 4 files changed, 13 insertions(+), 17 deletions(-) diff --git a/backend/db-mysql/INDEX.md b/backend/db-mysql/INDEX.md index a910fb1..e0de418 100644 --- a/backend/db-mysql/INDEX.md +++ b/backend/db-mysql/INDEX.md @@ -108,7 +108,7 @@ db-mysql/ | 2026-10-05 | `20261005_add_demo_reset_log.sql` | Crea `demo_reset_log`: un registro por cada reinicio diario de la demo y por cada base de horarios guardada (ADR-038). La base vive en tablas `demo_snapshot_scheduling_*` que crea el backend al guardarla. Hay que aplicarla antes de desplegar el código. `CREATE TABLE IF NOT EXISTS`. | ✅ local · ✅ Aiven | | 2026-10-05 | `20261005_complete_fresh_install.sql` | Completa una instalación desde cero: el baseline congelado no crea `cashier_history` (nombre de clave duplicado), `scheduling_assignments` (referencia a una tabla creada después) ni `demo_activity_log`, y deja `roles.name` en `varchar(50)`. Definiciones copiadas de Aiven. En Aiven no cambia nada; en local creó `demo_activity_log`, que faltaba. `pnpm setup:local` además corrige esos dos fallos del baseline en memoria. | ✅ local · ✅ Aiven (sin cambios) | | 2026-10-05 | `20261005_fix_bo_monthly_summary_view.sql` | Recrea `v_bo_monthly_summary` con `period` en el `GROUP BY`. Con `ONLY_FULL_GROUP_BY` (activo en Aiven y local) toda consulta a la vista fallaba con el error 1055 y `GET /api/backoffice/stats/monthly` respondía siempre 500. Mismas columnas y mismos grupos; comprobada contra un cálculo directo sobre `bo_invoices`. | ✅ local · ✅ Aiven | -| 2026-10-05 | `20261005_drop_demo_admin_role.sql` | Borra el rol `demo-admin` si ningún usuario lo tiene (ADR-038): la cuenta demo es un admin con `is_demo` y el código ya no conoce ese rol. En local no existía. | ✅ local (sin cambios) · ⏳ Aiven | +| 2026-10-05 | `20261005_drop_demo_admin_role.sql` | Borra el rol `demo-admin` si ningún usuario lo tiene (ADR-038): la cuenta demo es un admin con `is_demo` y el código ya no conoce ese rol. En local no existía. | ✅ local (sin cambios) · ✅ Aiven | ### Verificación (99) diff --git a/docs/SESSION.md b/docs/SESSION.md index 27e0d9c..cec3f38 100644 --- a/docs/SESSION.md +++ b/docs/SESSION.md @@ -26,7 +26,8 @@ nuevo»). Decisiones en `DECISIONS.md`, hasta ADR-039. La demo pública (fase 5) está hecha en código y probada en preview el 2026-10-05: cuenta demo con botón, bloqueos, límites, reinicio diario, Configuración → Demo, instalación local con `pnpm setup:local` y `demo-admin` -retirado. `DEMO_MODE` y `NEXT_PUBLIC_DEMO_MODE` solo están en preview. +retirado. Base de horarios guardada (octubre a diciembre de 2026, generados +con el solver). `DEMO_MODE` y `NEXT_PUBLIC_DEMO_MODE` solo están en preview. ## ⚠️ Empieza por aquí @@ -43,12 +44,11 @@ retirado. `DEMO_MODE` y `NEXT_PUBLIC_DEMO_MODE` solo están en preview. ## Siguiente -1. Terminar la demo: `TODO.md`, «Demo pública en la propia web: lo que - falta» (base de horarios, migración del rol en Aiven, variables en - producción tras el merge). -2. PR a `main` y probar el botón demo en la web. -3. Pasar `four-points` a público y activar secret scanning y push protection. -4. «Después de publicar» de `TODO.md`, empezando por la rotación de +1. PR a `main`, poner `DEMO_MODE` y `NEXT_PUBLIC_DEMO_MODE` en producción y + probar el botón demo en la web (`TODO.md`, «Demo pública en la propia web: + lo que falta»). +2. Pasar `four-points` a público y activar secret scanning y push protection. +3. «Después de publicar» de `TODO.md`, empezando por la rotación de credenciales (se puede adelantar). Hecho el 2026-10-05: Vercel y Render despliegan solos desde este repositorio diff --git a/docs/TODO.md b/docs/TODO.md index f1aa835..b759f76 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -307,14 +307,10 @@ sigue siendo donde se arregla este fichero y se prueba en el entorno preview. - [ ] **Demo pública en la propia web: lo que falta** (fase 5 de `ROADMAP.md`, ADR-037 y ADR-038) — el código está hecho y probado en preview el 2026-10-05 (cuenta demo, bloqueos, límites, reinicio diario, pestaña - Configuración → Demo, `setup:local`). Queda: - 1. **Base de horarios:** borrar los meses actuales de Aiven (vienen de - bajas reales), generar 2 o 3 meses limpios con el solver y pulsar - «Guardar horarios actuales como base». Hasta entonces el reinicio no - toca horarios y lo que genere un visitante se queda. - 2. **Aplicar en Aiven `20261005_drop_demo_admin_role.sql`** (borra el rol - sin usuarios; enseñar antes qué borra). - 3. **Producción:** tras el merge, `DEMO_MODE=true` en el servicio de + Configuración → Demo, `setup:local`) y la base de horarios guardada + (octubre a diciembre de 2026, generados con el solver; los meses que + venían de bajas reales se borraron). Queda: + 1. **Producción:** tras el merge, `DEMO_MODE=true` en el servicio de producción de Render y `NEXT_PUBLIC_DEMO_MODE=true` en Production de Vercel (hoy solo están en preview). Probar el botón en la web. - [ ] **Conciliation: `GET /api/conciliations` sin paginar ni filtrar** — diff --git a/docs/VERIFY.md b/docs/VERIFY.md index 6a54b55..98e1322 100644 --- a/docs/VERIFY.md +++ b/docs/VERIFY.md @@ -251,4 +251,4 @@ Vercel y Render se reconectaron de `four-points-archive` a `bpstack/four-points` | `e3a642c` | fix(db): group the backoffice monthly summary view by its period | — (hallazgo del 2026-10-05) | `GET /api/backoffice/stats/monthly` en preview da 200; la vista coincide con un cálculo directo | ✅ 2026-10-05 | | `9991d79` | test(security): check the CSRF defence and the session cookies by behaviour | Demo: CSRF | `csrf-origin.test.ts` (servidor Express real), `session-cookies.test.ts`; preview: origen ajeno da 403 | ✅ 2026-10-05 | | `4449b7a` | fix(checklist): render unsafe links in step notes as plain text | Demo: XSS | `safe-href.test.ts` (11 URL maliciosas) | 🧪 | -| `143882d` | refactor(auth): retire the demo-admin role | Retirar `demo-admin` | ⏳ preview tras el push; la migración del rol, pendiente en Aiven | ⏳ | +| `143882d` | refactor(auth): retire the demo-admin role | Retirar `demo-admin` | Preview tras el push: la entrada demo, los bloqueos y horarios funcionan; rol borrado en Aiven (no tenía usuarios) | ✅ 2026-10-05 |