#Probar la garantía que hemos prometido
Una pantalla que funciona con un usuario no demuestra aislamiento ni concurrencia. Vamos a comprobar tres propiedades observables: una identidad no accede a otra organización manipulando URL o POST; dos conexiones no crean reservas incompatibles; y la lista no hace una consulta adicional por cada sala.
Usamos la misma aplicación de las dos fases anteriores. Django creará una base de pruebas separada, normalmente test_reservas, ejecutará migraciones y la destruirá al terminar. El usuario de laboratorio puede crearla. En producción, la cuenta que atiende peticiones no necesita CREATEDB: las pruebas deben usar otro entorno y credenciales.
#Fase 11 · Aislamiento que se comprueba desde fuera
La prueba importante no pregunta si el botón está oculto. Envía el identificador de otra sala en un POST válido para una organización accesible, y observa el rechazo y la ausencia de escritura:
# Fragmento de la clase completa que aparece debajo.
respuesta = self.client.post(self.url, self.datos(sala=str(self.ajena.pk)))
self.assertEqual(respuesta.status_code, 400)
self.assertIn("sala", respuesta.context["form"].errors)
self.assertEqual(Reserva.objects.count(), 0)
self.client es el cliente HTTP simulado de Django: recorre rutas, middleware y views sin abrir un servidor TCP. force_login coloca una identidad autenticada para aislar pruebas de autorización; por eso hay además otra prueba que envía credenciales reales al login. El cliente no comprueba CSRF por defecto; una prueba específica lo activa con Client(enforce_csrf_checks=True).
Un rechazo no basta si la escritura ya ocurrió antes: por eso comprobamos también el número de reservas y su estado. La cancelación se ensaya con autor incorrecto y con el identificador de una reserva de otra organización.
#Fase 12 · Dos conexiones de verdad, una reserva
TestCase envuelve cada prueba en transacciones para restaurar datos rápidamente. Esa comodidad puede ocultar errores de uso de select_for_update y no representa bien dos conexiones que deben observar commits. La prueba concurrente usa TransactionTestCase, que permite confirmar transacciones reales y limpia las tablas al terminar.
# Núcleo de la prueba concurrente completa.
with ThreadPoolExecutor(max_workers=2) as ejecutor:
futuros = [ejecutor.submit(intentar, persona.pk) for persona in (ana, bruno)]
resultados = [futuro.result(timeout=20) for futuro in futuros]
self.assertCountEqual(resultados, ["creada", "conflicto"])
self.assertEqual(Reserva.objects.filter(cancelada=False).count(), 1)
ThreadPoolExecutor es una herramienta de la biblioteca estándar que ejecuta funciones en hilos. Aquí no se propone para servir Django: solo crea dos clientes concurrentes del ORM. Cada hilo abre su conexión y la cierra al terminar. Una Barrier(2) hace que ambos lleguen al punto de partida antes de reservar; el ganador no está fijado. Los límites de espera convierten un bloqueo inesperado en un fallo visible de prueba.
La prueba exige PostgreSQL: SQLite no implementa estos bloqueos ni la restricción de exclusión del proyecto. También insertamos un solape saltándonos el servicio y comprobamos que PostgreSQL identifica reservas_no_solape; así distinguimos la protección del servicio de la garantía de base.
Pruebas completas · sustituir reservas/tests.py
from concurrent.futures import ThreadPoolExecutor
from datetime import UTC, timedelta
from threading import Barrier
from django.contrib.auth import get_user_model
from django.core.exceptions import PermissionDenied, ValidationError
from django.db import IntegrityError, close_old_connections, connection, connections, transaction
from django.test import Client, TestCase, TransactionTestCase
from django.test.utils import CaptureQueriesContext
from django.urls import reverse
from django.utils import timezone
from .forms import ReservaForm
from .models import Membresia, Organizacion, Reserva, Sala
from .services import ConflictoReserva, cancelar, reservar
def escenario():
User = get_user_model()
ana = User.objects.create_user(username="ana", password="solo-laboratorio-123")
bruno = User.objects.create_user(username="bruno", password="solo-laboratorio-456")
org = Organizacion.objects.create(nombre="Estudio Norte")
otra = Organizacion.objects.create(nombre="Estudio Sur")
Membresia.objects.create(usuario=ana, organizacion=org)
Membresia.objects.create(usuario=bruno, organizacion=org)
sala = Sala.objects.create(organizacion=org, nombre="Taller")
ajena = Sala.objects.create(organizacion=otra, nombre="Sala privada")
inicio = (timezone.now() + timedelta(days=2)).replace(minute=0, second=0, microsecond=0)
return ana, bruno, org, otra, sala, ajena, inicio
class ReservasWebTests(TestCase):
def setUp(self):
(self.ana, self.bruno, self.org, self.otra,
self.sala, self.ajena, self.inicio) = escenario()
self.fin = self.inicio + timedelta(hours=1)
self.url = reverse("reservas:panel", args=[self.org.pk])
self.client.force_login(self.ana)
def datos(self, **cambios):
datos = {
"sala": str(self.sala.pk),
"inicio": timezone.localtime(self.inicio).strftime("%Y-%m-%dT%H:%M"),
"fin": timezone.localtime(self.fin).strftime("%Y-%m-%dT%H:%M"),
"motivo": "Ensayo",
}
datos.update(cambios)
return datos
def crear(self, **cambios):
datos = dict(usuario=self.ana, organizacion_id=self.org.pk, sala_id=self.sala.pk,
inicio=self.inicio, fin=self.fin, motivo="Ensayo")
datos.update(cambios)
return reservar(**datos)
def test_visitante_va_al_login(self):
self.client.logout()
self.assertRedirects(self.client.get(self.url), f"/cuentas/login/?next={self.url}")
def test_login_real_y_logout_solo_post(self):
self.client.logout()
respuesta = self.client.post(reverse("login"), {"username": "ana", "password": "solo-laboratorio-123"})
self.assertRedirects(respuesta, reverse("reservas:organizaciones"))
self.assertEqual(self.client.get(reverse("logout")).status_code, 405)
self.assertRedirects(self.client.post(reverse("logout")), reverse("login"))
def test_url_de_otra_organizacion_no_muestra_datos(self):
url = reverse("reservas:panel", args=[self.otra.pk])
self.assertEqual(self.client.get(url).status_code, 404)
self.assertEqual(self.client.post(url, self.datos(sala=str(self.ajena.pk))).status_code, 404)
self.assertEqual(Reserva.objects.count(), 0)
def test_sala_ajena_no_se_acepta_en_post(self):
respuesta = self.client.post(self.url, self.datos(sala=str(self.ajena.pk)))
self.assertEqual(respuesta.status_code, 400)
self.assertIn("sala", respuesta.context["form"].errors)
self.assertEqual(Reserva.objects.count(), 0)
def test_datos_validos_redirigen_y_persisten(self):
self.assertRedirects(self.client.post(self.url, self.datos()), self.url)
self.assertEqual(Reserva.objects.get().creada_por, self.ana)
self.assertContains(self.client.get(self.url), "Ensayo")
def test_duplicado_se_explica_sin_segunda_reserva(self):
self.crear()
respuesta = self.client.post(self.url, self.datos())
self.assertEqual(respuesta.status_code, 400)
self.assertContains(respuesta, "ya está reservada", status_code=400)
self.assertEqual(Reserva.objects.count(), 1)
def test_periodo_invalido_conserva_el_formulario(self):
respuesta = self.client.post(self.url, self.datos(fin=self.datos()["inicio"]))
self.assertEqual(respuesta.status_code, 400)
self.assertEqual(respuesta.context["form"]["motivo"].value(), "Ensayo")
self.assertEqual(Reserva.objects.count(), 0)
def test_csrf_es_obligatorio(self):
cliente = Client(enforce_csrf_checks=True)
cliente.force_login(self.ana)
cliente.get(self.url)
self.assertEqual(cliente.post(self.url, self.datos()).status_code, 403)
datos = self.datos(csrfmiddlewaretoken=cliente.cookies["csrftoken"].value)
self.assertEqual(cliente.post(self.url, datos).status_code, 302)
def test_miembro_no_cancela_la_reserva_de_otro(self):
reserva = self.crear(usuario=self.bruno)
url = reverse("reservas:cancelar", args=[self.org.pk, reserva.pk])
self.assertEqual(self.client.get(url).status_code, 405)
self.assertEqual(self.client.post(url).status_code, 403)
reserva.refresh_from_db()
self.assertFalse(reserva.cancelada)
def test_gestor_puede_cancelar_y_repetir_cancelacion(self):
Membresia.objects.filter(usuario=self.ana, organizacion=self.org).update(gestiona=True)
reserva = self.crear(usuario=self.bruno)
cancelar(usuario=self.ana, organizacion_id=self.org.pk, reserva_id=reserva.pk)
cancelar(usuario=self.ana, organizacion_id=self.org.pk, reserva_id=reserva.pk)
reserva.refresh_from_db()
self.assertTrue(reserva.cancelada)
self.crear()
self.assertEqual(Reserva.objects.filter(cancelada=False).count(), 1)
def test_no_cancelar_de_otra_organizacion_cambiando_identificador(self):
reserva = Reserva.objects.create(sala=self.ajena, creada_por=self.bruno,
inicio=self.inicio, fin=self.fin, motivo="Privada")
url = reverse("reservas:cancelar", args=[self.org.pk, reserva.pk])
self.assertEqual(self.client.post(url).status_code, 404)
reserva.refresh_from_db()
self.assertFalse(reserva.cancelada)
def test_servicio_rechaza_membresia_revocada(self):
Membresia.objects.filter(usuario=self.ana, organizacion=self.org).update(activa=False)
with self.assertRaises(PermissionDenied):
self.crear()
def test_intervalos_adyacentes_son_validos(self):
self.crear()
self.crear(inicio=self.fin, fin=self.fin + timedelta(hours=1))
self.assertEqual(Reserva.objects.count(), 2)
def test_base_rechaza_solape_incluso_sin_servicio(self):
self.crear()
with self.assertRaises(IntegrityError) as fallo:
with transaction.atomic():
Reserva.objects.create(sala=self.sala, creada_por=self.bruno,
inicio=self.inicio + timedelta(minutes=30),
fin=self.fin + timedelta(minutes=30), motivo="SQL directo al ORM")
self.assertEqual(fallo.exception.__cause__.diag.constraint_name, "reservas_no_solape")
def test_base_rechaza_intervalo_vacio(self):
with self.assertRaises(IntegrityError):
with transaction.atomic():
Reserva.objects.create(sala=self.sala, creada_por=self.ana,
inicio=self.inicio, fin=self.inicio, motivo="Inválido")
def test_formulario_rechaza_horas_inexistentes_y_ambiguas(self):
for inicio, fin in [("2027-03-28T02:15", "2027-03-28T03:15"),
("2027-10-31T02:15", "2027-10-31T03:15")]:
form = ReservaForm(self.datos(inicio=inicio, fin=fin), organizacion=self.org)
self.assertFalse(form.is_valid())
self.assertIn("inicio", form.errors)
def test_duracion_real_cruzando_cambio_de_hora(self):
form = ReservaForm(self.datos(inicio="2027-10-31T01:00", fin="2027-10-31T05:00"), organizacion=self.org)
self.assertFalse(form.is_valid())
self.assertIn("cuatro horas", str(form.non_field_errors()))
def test_consultas_no_crecen_por_cada_sala(self):
for dia in range(3):
self.crear(inicio=self.inicio + timedelta(days=dia), fin=self.fin + timedelta(days=dia))
with CaptureQueriesContext(connection) as lentas:
nombres = [r.sala.nombre for r in Reserva.objects.all()]
with CaptureQueriesContext(connection) as rapidas:
optimizados = [r.sala.nombre for r in Reserva.objects.select_related("sala")]
self.assertEqual(nombres, optimizados)
self.assertEqual(len(lentas), 4)
self.assertEqual(len(rapidas), 1)
def test_texto_se_escapa_al_mostrarlo(self):
self.crear(motivo="<script>alert(1)</script>")
respuesta = self.client.get(self.url)
self.assertContains(respuesta, "<script>")
self.assertNotContains(respuesta, "<script>alert(1)</script>")
class CarreraReservaTests(TransactionTestCase):
def test_dos_conexiones_compiten_por_la_misma_sala(self):
if connection.vendor != "postgresql":
self.fail("Esta garantía debe probarse sobre PostgreSQL real.")
ana, bruno, org, _, sala, _, inicio = escenario()
barrera = Barrier(2)
def intentar(usuario_id):
close_old_connections()
try:
usuario = get_user_model().objects.get(pk=usuario_id)
barrera.wait(timeout=5)
try:
reservar(usuario=usuario, organizacion_id=org.pk, sala_id=sala.pk,
inicio=inicio, fin=inicio + timedelta(hours=1), motivo="Concurrente")
return "creada"
except ConflictoReserva:
return "conflicto"
finally:
connections.close_all()
with ThreadPoolExecutor(max_workers=2) as ejecutor:
futuros = [ejecutor.submit(intentar, persona.pk) for persona in (ana, bruno)]
resultados = [futuro.result(timeout=20) for futuro in futuros]
self.assertCountEqual(resultados, ["creada", "conflicto"])
self.assertEqual(Reserva.objects.filter(cancelada=False).count(), 1)
Ejecuta desde la misma terminal con entorno y variables de base activos:
python manage.py test reservas --verbosity 2
Esperas 20 pruebas correctas. El conteo incluye permisos, login/logout, CSRF, conservación de errores, cancelación, intervalos contiguos, restricciones SQL, cambios horarios, escape HTML, N+1 y carrera con dos conexiones. En la prueba N+1, tres reservas producen cuatro consultas antes de select_related y una después, con nombres idénticos.
#Qué demuestra y qué no
Este ensayo demuestra comportamiento con conexiones concurrentes reales y PostgreSQL, no capacidad para miles de reservas por segundo. Una carrera que pasa una vez no sustituye analizar la invariante: la restricción es quien la garantiza; la prueba detecta errores de integración. Tampoco demuestra recuperación de una base dañada ni entrega garantizada de correos. Esas propiedades necesitan mecanismos y ensayos distintos.
El POST de reserva no incorpora una clave de idempotencia. Repetirlo tras una respuesta perdida produce conflicto si la primera reserva ya se guardó. No crea una segunda reserva incompatible, pero el cliente no puede distinguir todavía «mi primera petición se guardó» de «reservó otra persona». Para una API con reintentos automáticos, añade una clave única por organización/usuario/operación, compara la carga y devuelve el resultado persistido. No confundas protección de solapes con idempotencia completa.
#Fase 13 · Separar desarrollo y producción
Gunicorn es el proceso que aceptará HTTP local y ejecutará la aplicación WSGI. El proxy público gestionará HTTPS y archivos estáticos. La base no se publica a Internet. Antes de montar esa cadena, crea config/settings_production.py:
import os
from .settings import *
DEBUG = False
SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
ALLOWED_HOSTS = [host.strip() for host in os.environ["DJANGO_ALLOWED_HOSTS"].split(",") if host.strip()]
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_SSL_REDIRECT = True
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SECURE_HSTS_SECONDS = 3600
SECURE_HSTS_INCLUDE_SUBDOMAINS = False
SECURE_HSTS_PRELOAD = False
STORAGES = {
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"staticfiles": {"BACKEND": "django.contrib.staticfiles.storage.ManifestStaticFilesStorage"},
}
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"handlers": {"console": {"class": "logging.StreamHandler"}},
"root": {"handlers": ["console"], "level": "INFO"},
}
Este módulo importa la configuración común y sobrescribe políticas de producción. Si falta DJANGO_SECRET_KEY o DJANGO_ALLOWED_HOSTS, el proceso falla al arrancar. No usa la clave generada para desarrollo. ALLOWED_HOSTS contiene nombres de host sin protocolo; no se sustituye por * para ocultar errores de configuración.
SECURE_PROXY_SSL_HEADER solo es correcto si controlas el proxy, este sobrescribe X-Forwarded-Proto y nadie puede saltárselo para alcanzar Gunicorn. Confiar en esa cabecera desde Internet permitiría fingir una conexión segura. HSTS se inicia en una hora, sin incluir subdominios ni solicitar precarga: primero se comprueba HTTPS antes de aumentar el compromiso.
#Probar el servidor de aplicación en local
Primero verifica Gunicorn sin políticas HTTPS, usando los ajustes de desarrollo y un puerto local distinto:
python manage.py collectstatic --noinput
gunicorn config.wsgi:application --bind 127.0.0.1:8011 \
--workers 2 --timeout 30 --graceful-timeout 30 \
--access-logfile - --error-logfile -
Abre otra terminal y ejecuta curl -i http://127.0.0.1:8011/cuentas/login/: debe responder 200 con HTML. Gunicorn no sirve esos archivos estáticos por sí mismo; la página puede verse sin CSS hasta conectar el proxy. Esta prueba demuestra que la aplicación funciona bajo un servidor de producción, no que el sitio ya sea un despliegue público seguro.
Los workers son procesos independientes. Dos permiten atender una petición mientras otra espera a la base, pero consumen memoria y conexiones; no fijes un número por una fórmula sin medir. Los timeouts de servidor son el último límite del proceso, no sustituyen los límites de conexiones y consultas.
#Conectar el proxy y los estáticos
En el servidor, coloca código y entorno en /srv/reservas/app, con un usuario de servicio sin permisos administrativos. El fichero de secretos /etc/reservas.env se mantiene fuera del repo, con permisos de lectura solo para administración y el servicio que lo necesite. Debe proporcionar DB_*, DJANGO_SECRET_KEY, DJANGO_ALLOWED_HOSTS y DJANGO_SETTINGS_MODULE=config.settings_production.
El archivo de entorno de systemd usa pares NOMBRE=valor, sin escribir export. Una plantilla mínima es la siguiente; sustituye todos los marcadores antes de activar el servicio y no la publiques con valores reales:
DB_NAME=reservas
DB_USER=reservas_app
DB_PASSWORD=REEMPLAZAR_CON_LA_CLAVE_DEL_ROL
DB_HOST=127.0.0.1
DB_PORT=5432
DJANGO_SECRET_KEY=REEMPLAZAR_CON_UN_SECRETO_ALEATORIO
DJANGO_ALLOWED_HOSTS=reservas.ejemplo.com
DJANGO_SETTINGS_MODULE=config.settings_production
Puedes generar la clave de Django en una terminal privada con python -c "import secrets; print(secrets.token_urlsafe(64))" y trasladarla a ese archivo. No actives trazas ni la pegues en un repo. El archivo puede ser propiedad de root con modo 600: systemd lo lee antes de ejecutar el proceso con el usuario reservas. Para comandos de administración, carga valores equivalentes en su entorno mediante tu mecanismo de secretos; EnvironmentFile no los exporta automáticamente a tu shell. Al migrar, cambia DB_USER y su clave por las del rol de migración, y restablece las credenciales limitadas al ejecutar el servicio.
Con esas variables cargadas en el entorno de administración:
python manage.py check --deploy
python manage.py migrate --plan
python manage.py migrate --noinput
python manage.py collectstatic --noinput
Ejecuta migraciones una vez por despliegue con un rol que pueda cambiar el esquema; no al arrancar simultáneamente cada worker. El rol de ejecución necesita permisos de lectura/escritura sobre las tablas y uso de secuencias, pero no superusuario ni creación de extensiones. Desplegar y mantener Django explica la separación de roles y el cambio de versiones.
Con Caddy —un proxy explicado en Despliegue y HTTPS de Docker— una configuración equivalente en el host sería:
reservas.ejemplo.com {
handle_path /static/* {
root * /srv/reservas/app/staticfiles
file_server
}
handle {
reverse_proxy 127.0.0.1:8011
}
}
Sustituye el dominio por uno real que controles, apunta su DNS al servidor y permite 80/443 para emitir el certificado. handle_path quita /static antes de buscar el fichero en staticfiles. El usuario de Caddy necesita lectura sobre esa carpeta y acceso a sus directorios padre. El ManifestStaticFilesStorage de producción produce nombres con hash de contenido; conserva los estáticos de la versión anterior mientras pueda haber páginas que los referencien.
Para mantener Gunicorn en marcha, en un servidor Linux con systemd puedes usar esta unidad en /etc/systemd/system/reservas.service; ajusta rutas y crea previamente el usuario reservas:
[Unit]
Description=Agenda de reservas Django
After=network.target
[Service]
User=reservas
Group=reservas
WorkingDirectory=/srv/reservas/app
EnvironmentFile=/etc/reservas.env
ExecStart=/srv/reservas/app/.venv/bin/gunicorn config.wsgi:application --bind 127.0.0.1:8011 --workers 2 --timeout 30 --graceful-timeout 30 --access-logfile - --error-logfile -
Restart=on-failure
TimeoutStopSec=45
NoNewPrivileges=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
systemd es el gestor de servicios del host: inicia el proceso, recoge su salida y puede reiniciarlo si falla. Tras instalar la unidad, sudo systemctl daemon-reload recarga su definición y sudo systemctl enable --now reservas la activa. Revisa journalctl -u reservas, confirma que escucha solo en localhost y prueba login, reserva, conflicto y logout por el dominio HTTPS. No hemos ejecutado esta unidad ni solicitado certificados públicos en el laboratorio: estos pasos requieren tu servidor y dominio.
#Fase 14 · Recuperar y evolucionar con un criterio
Antes de cambiar el esquema, crea una copia y ensaya su restauración en otra base siguiendo backup y recuperación. El proyecto guarda datos en PostgreSQL y no admite uploads; debes conservar además secretos, configuración y artefacto de código. Restaurar el dump sin recrear los roles adecuados puede dejar una aplicación que arranca pero no puede escribir.
Para una actualización, prueba migraciones sobre una copia representativa, aplica primero cambios compatibles y después cambia el proceso. Si el código nuevo falla, vuelve al artefacto anterior solo si el esquema sigue siendo compatible. Deshacer una migración que borró datos no recupera automáticamente su contenido. Mide cuánto tarda la restauración y qué intervalo de datos puedes perder con la frecuencia de backup elegida.
El siguiente incremento se elige por una necesidad visible:
| Necesidad | Primera evolución razonable | Garantía que debes conservar |
|---|---|---|
| Avisar por correo | Registrar evento de salida en la misma transacción y enviarlo desde un worker | Un commit no depende del proveedor de correo; reintentos no duplican efectos |
| Integración con otro cliente | Endpoint JSON con contrato, límites e idempotencia | Misma autorización y servicio de reserva |
| Muchas salas y reservas | Medir consultas y paginar por cursor cuando el OFFSET pese | Orden estable y filtros por organización |
| Auditoría detallada | Eventos de creación/cancelación con actor y fecha | No confundir logs volátiles con historial persistente |
| Organizaciones en distintas zonas | Guardar su zona y activarla al interpretar formularios | Instantes conscientes de zona y rechazo de ambigüedades |
Al terminar el laboratorio, docker stop reservas-db-lab detiene la base conservando el volumen. docker rm reservas-db-lab elimina su contenedor; el volumen reservas-datos-lab sigue existiendo. Elimínalo con docker volume rm reservas-datos-lab solo si has decidido descartar esos datos. No uses una limpieza global de Docker.
#Referencias y límites de la verificación
- Herramientas de pruebas de Django.
- Despliegue con Gunicorn.
- Checklist de despliegue.
- Servicio systemd.
Las pruebas publicadas y el código del proyecto se ejecutan extraídos de estas páginas; versiones y resultados concretos se registran en el manifiesto de fuentes del repositorio. La publicación HTTPS, la unidad systemd en un servidor remoto, las prestaciones bajo carga y las futuras extensiones no se presentan como ensayos realizados.