Proyecto compartido. En el taller de Intermodular que abre esta semana has trabajado ensayar la recuperación y publicar el incremento. En Servidor continúas la implementación del mismo producto.
Se explica
25 minutos · explicación y demostración
El primer recorrido de la ampliación funciona. Hoy completarás sus reglas, transacciones y permisos. Una máquina de estados describe estados permitidos y transiciones; resulta útil cuando una operación, como cerrar o aprobar, depende de la situación actual del recurso.
La trampa de la dispersión frente al núcleo funcional
Cuando un desarrollador afronta un proyecto grande, la tentación habitual es crear quince entidades y diez controladores a la vez: la entidad de etiquetas, la de comentarios, la de historial, la de categorías…
- Al final de la jornada tiene miles de líneas de código escritas, pero ningún caso de uso funciona de verdad.
- No puede hacer una demo a su cliente ni pasar un test de integración real.
El desarrollo profesional se rige por la priorización por valor:
- Identifica los dos agregados centrales que dan sentido al negocio (en nuestro caso:
ProyectoyTarea). - Implementa sus relaciones y reglas más complejas antes de añadir adornos secundarios.
La ley del núcleo de negocio
Un proyecto sin entidades secundarias es un producto viable; un proyecto con diez entidades a medias es chatarra.
Construye y prueba a fondo las reglas más críticas del dominio (presupuestos, transiciones de estado e integridad referencial) antes de dedicar tiempo a comentarios, avatares o filtros decorativos.
Reglas de negocio e integridad entre Agregados
En nuestro dominio empresarial, un Proyecto actúa como raíz de agregado (Aggregate Root) sobre sus Tareas:
- 1. Proyecto creado con presupuesto total de 50.000 €
- 2. Alta de Tarea A (coste 20.000 €) -> Aceptada (acumulado 20.000 €)
- 3. Alta de Tarea B (coste 25.000 €) -> Aceptada (acumulado 45.000 €)
- 4. Alta de Tarea C (coste 10.000 €) -> Rechazada (45.000 + 10.000 > 50.000)
- 5. Cierre de Proyecto -> Solo permitido si Tareas A y B están FINALIZADAS
Más allá de los roles: Autorización basada en la propiedad del dato
Comprobar roles (ADMINISTRADOR, JEFE_PROYECTO, DESARROLLADOR) es solo la primera línea de defensa.
- Si el usuario Elena es
JEFE_PROYECTOy el usuario Marcos también esJEFE_PROYECTO, Elena no debe poder modificar el presupuesto ni reasignar tareas del proyecto que gestiona Marcos. - Esto se conoce como Control de Acceso Basado en Atributos (ABAC) o Seguridad a Nivel de Dominio.
- 1. Petición HTTP con Bearer JWT
- 2. Capa 1: ¿Tiene el Rol adecuado? (RBAC: hasRole)
- 3. Capa 2: ¿Es el Propietario del Recurso? (ABAC: esResponsable)
- 4. Ejecución del método de negocio
Se trabaja
140 minutos · implementación guiada sobre el proyecto propio
Paso 1 · Retomar el proyecto y preparar la comprobación
- Abre los criterios pendientes, el servicio ampliado y la matriz de permisos. Dibuja los estados y cambios válidos del recurso si tu operación los necesita.
- Prepara datos para una transición válida, una prohibida y un intento de otro usuario. Anota el estado que debe conservar cada rechazo.
- Localiza las llamadas externas dentro del nuevo recorrido y reutiliza los límites de espera y tratamiento de fallos ya definidos.
Paso 2 · Implementación del núcleo transaccional
El controlador mostrado al final también llama a listarTareasProyecto. Añade este método al mismo TareaService, importando Page y Pageable de Spring Data. Mantén los cuatro componentes de TareaResponse que utiliza esta fase del proyecto:
@Transactional(readOnly = true)
public Page<TareaResponse> listarTareasProyecto(Long proyectoId, Pageable pageable) {
if (!proyectoRepository.existsById(proyectoId)) {
throw new org.springframework.web.server.ResponseStatusException(
org.springframework.http.HttpStatus.NOT_FOUND, "Proyecto no encontrado");
}
return tareaRepository.findByProyectoId(proyectoId, pageable)
.map(tarea -> new TareaResponse(tarea.getId(), tarea.getTitulo(),
tarea.getEstado().name(), tarea.getCosteEstimado()));
}
Conserva los métodos de listado y DTO que tu producto necesite; si amplías un DTO anterior, añade sus componentes también al mapper en lugar de eliminarlos.
Reutiliza el recurso ampliado en la sesión 48. Antes de añadir la consulta de suma, comprueba que presupuesto y coste están definidos como BigDecimal en entidad, DTO y base de datos. Implementa primero la consulta, después la regla en el servicio y por último el endpoint. El bloque de controlador es parcial: conserva o implementa el método de listado al que llama, con su Page y Pageable. Para cada rechazo consulta el estado posterior y verifica que no se ha consumido presupuesto ni creado una tarea.
package com.ejemplo.gestor.tarea.repository;
import com.ejemplo.gestor.tarea.model.EstadoTarea;
import com.ejemplo.gestor.tarea.model.Tarea;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.math.BigDecimal;
public interface TareaRepository extends JpaRepository<Tarea, Long> {
// Paginación eficiente de tareas por proyecto
Page<Tarea> findByProyectoId(Long proyectoId, Pageable pageable);
// Sumatorio de costes en base de datos (rendimiento óptimo en SQL)
@Query("SELECT COALESCE(SUM(t.costeEstimado), 0.0) FROM Tarea t WHERE t.proyecto.id = :proyectoId")
BigDecimal calcularCosteTotalEstimadoProyecto(@Param("proyectoId") Long proyectoId);
// Conteo de tareas no finalizadas para validar el cierre
long countByProyectoIdAndEstadoNot(Long proyectoId, EstadoTarea estado);
}
package com.ejemplo.gestor.tarea.service;
import com.ejemplo.gestor.proyecto.model.Proyecto;
import com.ejemplo.gestor.proyecto.repository.ProyectoRepository;
import com.ejemplo.gestor.tarea.dto.CrearTareaRequest;
import com.ejemplo.gestor.tarea.dto.TareaResponse;
import com.ejemplo.gestor.tarea.model.EstadoTarea;
import com.ejemplo.gestor.tarea.model.Tarea;
import com.ejemplo.gestor.tarea.repository.TareaRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@Service
public class TareaService {
private final TareaRepository tareaRepository;
private final ProyectoRepository proyectoRepository;
public TareaService(TareaRepository tareaRepository, ProyectoRepository proyectoRepository) {
this.tareaRepository = tareaRepository;
this.proyectoRepository = proyectoRepository;
}
@Transactional
public TareaResponse crearTarea(Long proyectoId, CrearTareaRequest request) {
Proyecto proyecto = proyectoRepository.findById(proyectoId)
.orElseThrow(() -> new IllegalArgumentException("Proyecto no encontrado"));
// Regla 1: No se pueden añadir tareas a proyectos cerrados o cancelados
if (proyecto.getEstado().esTerminal()) {
throw new IllegalStateException("No se pueden añadir tareas a un proyecto en estado " + proyecto.getEstado());
}
// Regla 2: El sumatorio de costes no puede superar el presupuesto total del proyecto
BigDecimal costeActual = tareaRepository.calcularCosteTotalEstimadoProyecto(proyectoId);
BigDecimal nuevoTotal = costeActual.add(request.costeEstimado());
if (nuevoTotal.compareTo(proyecto.getPresupuestoTotal()) > 0) {
throw new IllegalArgumentException(String.format(
"Presupuesto excedido. Presupuesto proyecto: %.2f €, Coste actual: %.2f €, Nueva tarea: %.2f €",
proyecto.getPresupuestoTotal(), costeActual, request.costeEstimado()
));
}
Tarea tarea = new Tarea();
tarea.setTitulo(request.titulo());
tarea.setPrioridad(request.prioridad());
tarea.setCosteEstimado(request.costeEstimado());
tarea.setProyecto(proyecto);
tarea.setEstado(EstadoTarea.PENDIENTE);
tarea = tareaRepository.save(tarea);
return new TareaResponse(tarea.getId(), tarea.getTitulo(), tarea.getEstado().name(), tarea.getCosteEstimado());
}
}
@Transactional
public void cerrarProyecto(Long proyectoId) {
Proyecto proyecto = proyectoRepository.findById(proyectoId)
.orElseThrow(() -> new IllegalArgumentException("Proyecto no encontrado"));
// Verificamos si existen tareas pendientes de finalizar
long tareasPendientes = tareaRepository.countByProyectoIdAndEstadoNot(proyectoId, EstadoTarea.FINALIZADA);
if (tareasPendientes > 0) {
// Lanzamos excepción específica de conflicto de negocio
throw new ConflictoNegocioException(String.format(
"No se puede cerrar el proyecto '%s': tiene %d tareas sin finalizar.",
proyecto.getCodigo(), tareasPendientes
));
}
proyecto.setEstado(EstadoProyecto.FINALIZADO);
proyectoRepository.save(proyecto);
}
package com.ejemplo.gestor.tarea.controller;
import com.ejemplo.gestor.tarea.dto.CrearTareaRequest;
import com.ejemplo.gestor.tarea.dto.TareaResponse;
import com.ejemplo.gestor.tarea.service.TareaService;
import jakarta.validation.Valid;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/v1/proyectos/{proyectoId}/tareas")
public class TareaController {
private final TareaService tareaService;
public TareaController(TareaService tareaService) {
this.tareaService = tareaService;
}
@PostMapping
@PreAuthorize("hasAnyRole('JEFE_PROYECTO', 'ADMINISTRADOR')")
public ResponseEntity<TareaResponse> crearTarea(
@PathVariable Long proyectoId,
@Valid @RequestBody CrearTareaRequest request) {
TareaResponse response = tareaService.crearTarea(proyectoId, request);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
@GetMapping
@PreAuthorize("isAuthenticated()")
public ResponseEntity<Page<TareaResponse>> listarTareas(
@PathVariable Long proyectoId,
@PageableDefault(size = 10, sort = "prioridad") Pageable pageable) {
return ResponseEntity.ok(tareaService.listarTareasProyecto(proyectoId, pageable));
}
}
Paso 3 · Batería de pruebas en Bruno
- Creación dentro de presupuesto:
- Lanza
POST /api/v1/proyectos/1/tareascon coste de5000.00 €. - Resultado: Código
201 Created.
- Lanza
- Prueba de estrés de regla de negocio (Exceso de presupuesto):
- Lanza otra tarea con coste
200000.00 €sobre un proyecto que solo dispone de150000.00 €. - Resultado: Código
400 Bad Requestcon detalle:"Presupuesto excedido. Presupuesto proyecto: 150000.00 €, Coste actual: 37000.00 €, Nueva tarea: 200000.00 €". - Ninguna fila queda guardada en la tabla
tareas.
- Lanza otra tarea con coste
- Prueba de conflicto en cierre:
- Lanza
POST /api/v1/proyectos/1/cerrar. - Resultado: Código
409 Conflictindicando que existen tareas pendientes. El estado del proyecto permanece inalterado enEN_CURSO.
- Lanza
Paso 4 · Si algo no sale como dice el guion
| Síntoma | Causa casi segura | Qué mirar |
|---|---|---|
| La regla de negocio se salta cuando llamas desde otro método del servicio | Llamada interna: el proxy de @Transactional no interviene |
Extrae el método a otro bean, o llama siempre desde fuera |
El rollback no revierte nada |
La excepción es comprobada (checked) | Spring solo revierte ante RuntimeException salvo que declares rollbackFor |
| Dos peticiones simultáneas se saltan el límite de presupuesto | Condición de carrera clásica | Es exactamente el reto de esta sesión: bloqueo pesimista sobre la fila del proyecto |
El 409 sale como 500 |
Falta el @ExceptionHandler de tu excepción de negocio |
Tu @RestControllerAdvice de la UD3 debe conocer la excepción nueva |
LazyInitializationException al construir la respuesta |
Estás leyendo una relación fuera de la transacción | Mapea a DTO dentro del servicio, no en el controlador |
Paso 5 · Máquina de estados para Tareas
Escribe la tabla origen → destino permitido y úsala como referencia del código. Crea el DTO del cambio de estado con destino y, cuando proceda, motivo. En el servicio carga la tarea, comprueba permisos, valida la transición y solo entonces cambia sus campos. Conserva una excepción de dominio para el 409 y otra respuesta para una entrada mal formada. Prueba cada transición permitida y una prohibida desde su estado inicial correcto; no ejecutes toda la tabla sobre la misma tarea ya modificada.
Desarrollo II: seguridad e integración
Paso 6 · Bean de seguridad y llamadas salientes resilientes
Amplía la comprobación de propiedad existente en la UD9 o renómbrala mediante refactorización; no mantengas dos servicios con políticas contradictorias. Añade las nuevas expresiones a los métodos existentes y comprueba un usuario propietario y otro del mismo rol. En la integración externa reutiliza el bean RestClient con timeouts y el servicio con caché ya configurados; un bloque de ejemplo no debe crear otro cliente sin esos límites.
package com.ejemplo.gestor.core.security;
import com.ejemplo.gestor.proyecto.repository.ProyectoRepository;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Service;
@Service("seguridadService")
public class SeguridadService {
private final ProyectoRepository proyectoRepository;
public SeguridadService(ProyectoRepository proyectoRepository) {
this.proyectoRepository = proyectoRepository;
}
public boolean esResponsableDeProyecto(Long proyectoId, Authentication authentication) {
if (authentication == null || !authentication.isAuthenticated()) {
return false;
}
// Los administradores tienen acceso maestro universal
if (authentication.getAuthorities().stream().anyMatch(a -> a.getAuthority().equals("ROLE_ADMINISTRADOR"))) {
return true;
}
String username = authentication.getName();
return proyectoRepository.findById(proyectoId)
.map(p -> p.getResponsable().getUsername().equals(username))
.orElse(false);
}
}
Vinculamos la comprobación directamente en la anotación @PreAuthorize:
@PutMapping("/{id}")
@PreAuthorize("hasRole('ADMINISTRADOR') or (hasRole('JEFE_PROYECTO') and @seguridadService.esResponsableDeProyecto(#id, authentication))")
public ResponseEntity<ProyectoDetalleResponse> actualizarProyecto(
@PathVariable Long id,
@Valid @RequestBody ActualizarProyectoRequest request) {
return ResponseEntity.ok(proyectoService.actualizarProyecto(id, request));
}
Conectamos la integración de la UD10 garantizando que el alta de incidencias nunca colapse ante averías de Open-Meteo:
package com.ejemplo.gestor.integration;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestClient;
@Service
public class ClimaService {
private static final Logger log = LoggerFactory.getLogger(ClimaService.class);
private final RestClient climaRestClient;
// Inyectamos el bean openMeteoRestClient de la UD10: es el que trae la
// factoría con connect-timeout y read-timeout ya configurados. Construir
// aquí un cliente nuevo desde el Builder dejaría la llamada sin límite de
// espera, y una API externa lenta bloquearía el hilo indefinidamente.
public ClimaService(RestClient openMeteoRestClient) {
this.climaRestClient = openMeteoRestClient;
}
@Cacheable(value = "climaProyectos", key = "#lat + '_' + #lon")
public ClimaResultado consultarClimaSeguro(double lat, double lon) {
try {
log.info("Consultando Open-Meteo para coordenadas: lat={}, lon={}", lat, lon);
var respuesta = climaRestClient.get()
.uri("/v1/forecast?latitude={lat}&longitude={lon}¤t_weather=true", lat, lon)
.retrieve()
.body(OpenMeteoResponse.class);
return ClimaResultado.disponible(
respuesta.currentWeather().temperature(),
respuesta.currentWeather().weathercode()
);
} catch (ResourceAccessException ex) {
log.warn("Timeout o fallo de red con Open-Meteo: {}. Aplicando degradación elegante.", ex.getMessage());
return ClimaResultado.noDisponible("Servicio meteorológico fuera de línea temporalmente");
} catch (Exception ex) {
log.error("Error inesperado al consultar clima: {}. Se continúa sin enriquecimiento.", ex.getMessage());
return ClimaResultado.noDisponible("Datos climáticos no disponibles");
}
}
}
Los adjuntos ya los sabes tratar: la sesión 43 dejó el almacenamiento con nombre saneado por UUID y validación de tipo MIME. Aquí solo hay que conectarlo al caso de uso y protegerlo como todo lo demás:
- Recupera de la UD10 tu
AlmacenamientoServicey el endpointPOST /api/v1/incidenciasde tipomultipart/form-data. - Protégelo con la misma regla de propiedad: solo el
DESARROLLADORasignado a la tarea, elJEFE_PROYECTOresponsable o unADMINISTRADORpueden adjuntar un parte a una incidencia. - Comprueba que el fichero se guarda con su UUID antes de consultar el clima, para que un fallo de Open-Meteo no deje un adjunto huérfano en disco sin fila en la base de datos.
- Por qué la expresión lleva
@delante - En SpEL,
@nombreDelBeanbusca un bean en el contexto de Spring. Por eso@Service("seguridadService")lleva el nombre escrito a mano: para que la expresión sea legible y no dependa de cómo Spring derive el nombre de la clase. Si cambias el nombre del bean y no el de la expresión, el fallo llega en tiempo de ejecución, no al compilar. #idyauthentication#ides el parámetro del método anotado: solo existe si el método tiene un argumento llamado así.authenticationes una variable que Spring Security pone siempre a tu disposición dentro de estas expresiones, con el usuario ya verificado.- El coste que estás aceptando
esResponsableDeProyectohace una consulta a la base de datos antes de ejecutar el método, y otra dentro. Son dos viajes para una operación. Es asumible en una edición puntual, pero si lo pusieras en un listado tendrías el N+1 de la UD5 disfrazado de seguridad. Regla práctica: la seguridad por propiedad va en operaciones sobre un recurso, no sobre colecciones.- El atajo del administrador va primero, y no es casualidad
- La comprobación de
ROLE_ADMINISTRADORestá antes de tocar el repositorio: un administrador no paga la consulta. Ordenar las condiciones de más barata a más cara es lo que evita que la seguridad se convierta en el cuello de botella.
Paso 7 · Pruebas de matriz de permisos en Bruno
- Prueba de usurpación de proyecto (Caso no autorizado):
- Autentícate como
jefe2(Elena). - Intenta modificar el proyecto
PRJ-2026-001cuyo responsable esjefe1(Marcos):PUT http://localhost:8080/api/v1/proyectos/1. - Resultado esperado: Código
403 Forbidden. Spring Security bloquea la petición antes de ejecutar el servicio.
- Autentícate como
- Prueba como responsable legítimo:
- Autentícate como
jefe1. - Lanza el mismo
PUT. - Resultado esperado: Código
200 OK.
- Autentícate como
- Prueba de degradación de red:
- Apaga tu conexión WiFi o introduce coordenadas simuladas inalcanzables.
- Da de alta una incidencia con fichero adjunto.
- Resultado esperado: Código
201 Created. El informe se guarda con su archivo en disco y el campo clima reporta “Servicio meteorológico fuera de línea temporalmente”.
Paso 8 · Autorización granular en Tareas
Implementa la regla de propiedad para tareas:
-
Añade a
SeguridadServiceel métodopuedeModificarTarea(Long tareaId, Authentication auth). -
Permite la edición si el usuario es
ADMINISTRADOR, o si es elJEFE_PROYECTOdel proyecto padre, o si es elDESARROLLADORque tiene asignada esa tarea. -
Protege el endpoint
PATCH /api/v1/tareas/{id}/estadocon esta comprobación. -
Prueba de la lectura ajena: comprueba también qué pasa cuando
jefe2lee el proyecto dejefe1. Decide si eso debe permitirse o no, anótalo, e impleméntalo. No hay respuesta única —en muchas organizaciones los proyectos son visibles para todos y solo la edición es privada—, pero tiene que ser una decisión tomada y no un descuido.
Paso 9 · Si algo no sale como dice el guion
| Síntoma | Causa casi segura | Qué mirar |
|---|---|---|
EL1008E: Property or field 'seguridadService' cannot be found |
El nombre del bean no coincide | El de @Service("seguridadService") debe ser idéntico al de la expresión |
Todos reciben 403, incluso el responsable |
La comparación falla | ¿getResponsable() llega null por carga perezosa? Compara username, no objetos Usuario |
LazyInitializationException dentro de SeguridadService |
Se accede al responsable fuera de la transacción | Anota el método con @Transactional(readOnly = true), o usa una consulta con JOIN FETCH |
El administrador recibe 403 |
La condición del atajo no encaja | La autoridad almacenada es ROLE_ADMINISTRADOR, con prefijo; la comparación debe incluirlo |
| La regla no se aplica en absoluto | Falta @EnableMethodSecurity |
Igual que en la sesión 38: sin esa anotación, @PreAuthorize es decoración |
| La incidencia se guarda sin adjunto cuando cae Open-Meteo | Orden de operaciones equivocado | Guarda el fichero y la fila antes de enriquecer con el clima, no al revés |
Paso 10 · Comprobar y registrar el resultado del proyecto
- Comprueba las reglas nuevas, sus cambios atómicos y los rechazos por estado o propiedad. No debe quedar una operación parcialmente aplicada.
- Simula el fallo externo previsto y verifica que seguridad, persistencia y respuesta pública mantienen la política acordada para esa ampliación.
Ampliación si has completado el trabajo
Primero termina y verifica los pasos anteriores. Estos retos profundizan en el mismo contenido; no sustituyen la entrega ni obligan a iniciar otro proyecto.
Reto · Control de concurrencia pesimista en presupuestos
Si dos usuarios añaden tareas simultáneamente al mismo proyecto en milisegundos idénticos, ambos podrían leer el mismo coste actual acumulado antes de que el otro guarde su fila (Race Condition), superando el presupuesto total.
Investiga cómo resolver esta condición de carrera:
- Utiliza
@Lock(LockModeType.PESSIMISTIC_WRITE)en la consulta de búsqueda deProyectopara bloquear la fila en PostgreSQL durante la transacción. - Comprueba mediante un test concurrente multihilo que las dos creaciones se serializan y la segunda es rechazada correctamente por falta de saldo.
PESSIMISTIC_WRITE) para blindar el presupuesto ante concurrencia extrema.Ver respuestas
1 · Porque la base de datos procesa millones de filas de forma indexada en milisegundos y devuelve solo un número decimal por la red, mientras que iterar en Java exige transferir miles de entidades y saturar la memoria RAM.
2 · El código 409 Conflict (indica que la petición no puede procesarse debido a un conflicto con el estado actual del recurso).
3 · Para garantizar la atomicidad y el aislamiento ACID: si la comprobación pasa y la tarea se guarda, la operación se confirma; si algo falla, no se modifica la base de datos.
4 · Permite definir valores por defecto sensatos (tamaño de página, campo de ordenación y dirección ascendente/descendente) si el cliente no envía los parámetros en la URL.
Reto · Auditoría de accesos denegados en base de datos
Cada vez que un usuario recibe un código 403 Forbidden puede tratarse de un error inocente o de un ataque malicioso de fuerza bruta / enumeración de IDs.
- Implementa un listener para el evento de Spring Security
AuthorizationFailureEvent. - Registra en una tabla
auditoria_seguridadel usuario, la IP del cliente, la URL intentada y el motivo de denegación.
esResponsableDeProyecto protegiendo recursos frente a accesos ajenos.AuthorizationFailureEvent persistida en base de datos.Ver respuestas
1 · Porque violaría el principio de aislamiento y confidencialidad; cada responsable solo debe gestionar los proyectos y presupuestos formalmente asignados a su cargo.
2 · Proporciona la instancia actual de Authentication del SecurityContext, con el nombre del usuario (getName()), sus roles/autoridades (getAuthorities()) y sus credenciales.
3 · Evita abortar un caso de uso principal válido del usuario (como guardar un informe o incidencia) por culpa de un servicio secundario complementario que no está disponible.
4 · 401 Unauthorized significa que el cliente no se ha identificado (falta el token o es inválido); 403 Forbidden significa que el servidor sabe quién es el usuario pero sus permisos son insuficientes para esa acción.
Cierre
15 minutos · resultado comprobable y explicación individual
Al terminar la sesión:
El backend satisface los criterios del producto sin depender de que exista ya una pantalla para cada operación.
Cada integrante explica una decisión del código apoyándose en una de las comprobaciones realizadas.