Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,23 @@ jobs:
name: Build & Test (.NET 10)
runs-on: ubuntu-latest

# El humo e2e levanta la API con su configuración de desarrollo, que migra PostgreSQL al
# arrancar. Coincide con `ConnectionStrings:DefaultConnection` de appsettings.Development.
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: UmsDev
POSTGRES_USER: postgres
POSTGRES_PASSWORD: Your_password123
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 10

steps:
- name: Checkout repository
uses: actions/checkout@v4
Expand All @@ -44,6 +61,52 @@ jobs:
- name: Test – UMS API (unit + integration, skips SQL Server if Docker absent)
run: dotnet test src/apps/ums.api/Ums.sln --no-build --configuration Release --logger "console;verbosity=minimal"

# ── Humo de extremo a extremo ───────────────────────────────────────
# Vive en este job porque es el único con el SDK de .NET: e2e necesita la API arriba, y la
# API de desarrollo migra PostgreSQL al arrancar — de ahí el servicio `postgres` del job.
#
# Existe por un fallo concreto: el 2026-08-10 el login entró en bucle infinito y NADIE podía
# acceder al portal. El API devolvía 200 y todas las puertas estaban verdes —lint, typecheck,
# 1626 pruebas unitarias, build— porque ninguna abre el navegador. Se descubrió al ir a
# escribir pruebas nuevas, por casualidad.
#
# Es HUMO, no la suite entera: unos pocos casos que prueban que se puede entrar y navegar.
# La suite completa tarda ~4 min y arrastra inestabilidad; lo que aquí no puede fallar nunca
# es el acceso.
- name: Setup Node.js v22 (para el humo e2e)
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
cache-dependency-path: src/package-lock.json

- name: Install web dependencies
working-directory: src
run: npm ci

- name: Install Playwright (chromium)
working-directory: src/apps/ums.web-app
run: npx playwright install --with-deps chromium

- name: Start UMS API (PostgreSQL del servicio del job)
run: |
dotnet run --project src/apps/ums.api/Ums.Presentation/Ums.Presentation.csproj \
--configuration Release --no-build > /tmp/api.log 2>&1 &
for i in $(seq 1 60); do
curl -sf http://localhost:5293/health/live && break || sleep 2
done
curl -sf http://localhost:5293/health/live || { echo "La API no levantó:"; tail -40 /tmp/api.log; exit 1; }
env:
ASPNETCORE_ENVIRONMENT: Development

- name: E2E smoke — se puede entrar y navegar
working-directory: src/apps/ums.web-app
run: npx playwright test login auth navigation --reporter=line

- name: API log on failure
if: failure()
run: tail -80 /tmp/api.log

# ── Security audit ─────────────────────────────────────────────────
- name: NuGet vulnerability audit
run: dotnet list src/apps/ums.api/Ums.sln package --vulnerable --include-transitive
25 changes: 24 additions & 1 deletion src/apps/ums.web-app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { RouteLoader } from './presentation/shared/components/RouteLoader';
import { ProtectedRoute } from './presentation/shared/components/ProtectedRoute';
import { AccessGuard } from './presentation/shared/components/AccessGuard';
import { useThemeStore } from './application/stores/theme.store';
import { useAuthStore } from './application/stores/auth.store';
import { resolverDestinoTrasEntrar } from './application/identity/destino-tras-entrar';
import { ShieldCheck } from 'lucide-react';

const UnauthorizedFallback = () => (
Expand Down Expand Up @@ -58,6 +60,27 @@ const ParameterCatalogScreen = lazy(
const ProfileScreen = lazy(() => import('./presentation/identity/profile/screens/ProfileScreen'));
const LoginScreen = lazy(() => import('./presentation/identity/profile/screens/LoginScreen'));

/**
* Qué hay en `/`.
*
* <p>Antes era un `Navigate` fijo a `/login`, y eso creaba un bucle infinito de redirección: el
* grafo sembrado publica `settings.ui.home_route = "/"`, así que al autenticar la pantalla de
* login navegaba a `/`, `/` devolvía a `/login`, y `/login` —ya con sesión— volvía a navegar a
* `/`. Nadie con esa configuración llegaba a entrar; el navegador giraba hasta que React cortaba
* con «Maximum update depth exceeded».</p>
*
* <p>La raíz no es un destino, es un desvío: quien tiene sesión va a la ruta de inicio real y
* quien no la tiene, a identificarse. Se ignora un `home_route` que apunte de vuelta a `/` o a
* `/login` —serían el mismo bucle publicado desde el servidor— y se cae a `/tenants`.</p>
*/
function RaizSegunSesion() {
const isAuthenticated = useAuthStore(state => state.isAuthenticated);
if (!isAuthenticated) return <Navigate to="/login" replace />;

const publicada = useAuthStore.getState().user?.authorizationGraph?.settings?.ui?.home_route;
return <Navigate to={resolverDestinoTrasEntrar(null, publicada)} replace />;
}

export default function App() {
const isDarkMode = useThemeStore(state => state.isDarkMode);

Expand All @@ -73,7 +96,7 @@ export default function App() {
<MainLayout>
<Suspense fallback={<RouteLoader />}>
<Routes>
<Route path="/" element={<Navigate to="/login" replace />} />
<Route path="/" element={<RaizSegunSesion />} />
<Route path="/login" element={<LoginScreen />} />
<Route
path="/tenants"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { describe, it, expect } from 'vitest';
import { resolverDestinoTrasEntrar, DESTINO_POR_DEFECTO } from './destino-tras-entrar';

/**
* Regresión del bucle de login (2026-08-10).
*
* El grafo sembrado publica `settings.ui.home_route = "/"`. Con la raíz redirigiendo de forma fija
* a `/login`, aterrizar ahí tras autenticar devolvía a identificarse, y la pantalla de login —ya
* con sesión— volvía a navegar a la raíz. Nadie entraba al producto.
*
* El caso de `"/"` NO es hipotético: es la configuración real que rompía el acceso.
*/
describe('resolverDestinoTrasEntrar', () => {
it('respeta la ruta que la persona pidió antes de ser mandada a identificarse', () => {
expect(resolverDestinoTrasEntrar('/system-suites', '/perfil')).toBe('/system-suites');
});

it('usa la ruta de inicio publicada cuando no había ruta pedida', () => {
expect(resolverDestinoTrasEntrar(undefined, '/perfil')).toBe('/perfil');
});

it('cae al respaldo del producto cuando no hay ninguna', () => {
expect(resolverDestinoTrasEntrar(null, null)).toBe(DESTINO_POR_DEFECTO);
});

it('DESCARTA una ruta de inicio que apunte a «/» — el bucle real del grafo sembrado', () => {
expect(resolverDestinoTrasEntrar(undefined, '/')).toBe(DESTINO_POR_DEFECTO);
});

it('descarta «/login» como destino: aterrizar ahí con sesión abierta es el mismo bucle', () => {
expect(resolverDestinoTrasEntrar(undefined, '/login')).toBe(DESTINO_POR_DEFECTO);
});

it('descarta también una ruta PEDIDA que sea de redirección, no solo la publicada', () => {
expect(resolverDestinoTrasEntrar('/', '/perfil')).toBe('/perfil');
expect(resolverDestinoTrasEntrar('/login', null)).toBe(DESTINO_POR_DEFECTO);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* destino-tras-entrar.ts — A dónde va alguien justo después de identificarse.
*
* <p>Vive aparte porque la decisión se toma en DOS sitios —la pantalla de login, tras autenticar,
* y la ruta raíz, cuando alguien con sesión abierta llega a `/`— y tenerla duplicada fue
* exactamente el problema: la raíz redirigía a `/login` de forma fija mientras el grafo publicaba
* `home_route = "/"`, así que ambos se mandaban al otro sin fin. El navegador giraba hasta que
* React cortaba con «Maximum update depth exceeded», y nadie llegaba a entrar al producto.</p>
*/

/**
* Rutas que NO pueden ser destino de llegada por mucho que las publique el servidor: las dos
* devuelven a identificarse, así que aterrizar en ellas con sesión abierta es el bucle.
*/
const NO_SON_DESTINO = ['/', '/login'];

/** Respaldo del producto cuando no hay ningún destino utilizable. */
export const DESTINO_POR_DEFECTO = '/tenants';

/**
* Orden de precedencia:
*
* 1. La ruta que la persona pidió antes de que la mandáramos a identificarse. Es SUYA: llevarla
* a la portada le haría perder lo que estaba haciendo.
* 2. `settings.ui.home_route`, la que publica el sistema (G-178).
* 3. `/tenants`.
*
* Un candidato que apunte a una ruta de redirección se descarta en vez de seguirse. El servidor
* puede publicar `home_route = "/"` —de hecho el grafo sembrado lo publica— y eso no debe poder
* dejar el portal inaccesible.
*/
export function resolverDestinoTrasEntrar(
rutaPedida?: string | null,
rutaDeInicioPublicada?: string | null
): string {
const utilizable = (d?: string | null): d is string => !!d && !NO_SON_DESTINO.includes(d);
return [rutaPedida, rutaDeInicioPublicada].find(utilizable) ?? DESTINO_POR_DEFECTO;
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
* - Accessible design with proper labels
*/
import React, { useState, useEffect, useCallback } from 'react';
import { resolverDestinoTrasEntrar } from '@app/identity/destino-tras-entrar';
import { useNavigate, useLocation } from 'react-router';
import { useAuthStore, detectBrowserTimezone } from '@app/stores/auth.store';
import { useI18nStore } from '@app/stores/i18n.store';
Expand Down Expand Up @@ -68,9 +69,10 @@ export default function LoginScreen(): React.JSX.Element | null {
* 3. `/tenants`, el respaldo del producto.
*/
const destinoTrasEntrar = () =>
redirectTo ||
useAuthStore.getState().user?.authorizationGraph?.settings?.ui?.home_route ||
'/tenants';
resolverDestinoTrasEntrar(
redirectTo,
useAuthStore.getState().user?.authorizationGraph?.settings?.ui?.home_route
);

useEffect(() => {
if (isAuthenticated) {
Expand Down
4 changes: 4 additions & 0 deletions src/test-results/.last-run.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"status": "failed",
"failedTests": []
}
Loading