Mi sesión restaurada de Cypress me estaba mintiendo
Un validate() de cy.session que apunta a una URL relativa en una SPA nunca puede fallar. Aquí explico por qué las sesiones muertas se restauran en verde y cómo validar la credencial que tu aplicación realmente usa.
Nota del Autor / Transparencia: Contenido 100% autoral basado en desafíos reales de ingeniería en producción. No se utilizó ninguna inteligencia artificial en la redacción de este texto, análisis o código. La inteligencia artificial se utilizó exclusivamente para ayudar con la traducción a múltiples idiomas.
cy.session() es la mayor ganancia de velocidad disponible para una suite de Cypress con autenticación. Inicias sesión una vez, Cypress captura cookies, localStorage y sessionStorage, y cada spec posterior restaura esa instantánea en lugar de pasar de nuevo por el proveedor de identidad.
La red de seguridad es validate(). Cypress la ejecuta tras restaurar una sesión en caché; si lanza un error, falla una aserción o devuelve false, Cypress descarta la instantánea y ejecuta setup otra vez. Ese es todo el contrato: una sesión inválida se detecta y se reemplaza.
La mía no podía fallar. Durante semanas. Y me costó días persiguiendo tests "flaky" que en realidad no tenían nada de aleatorios.
El código que parecía estar bien
Cypress.Commands.add('login', (user: User) => {
cy.session(
user.username,
() => {
cy.visit('/')
cy.origin(idpOrigin, { args: user }, ({ username, password }) => {
cy.get('#username').type(username)
cy.get('#password').type(password, { log: false })
cy.get('button[type="submit"]').click()
})
cy.get('#app-shell').should('be.visible')
},
{
cacheAcrossSpecs: true,
validate() {
cy.request('/connect/userinfo').its('status').should('eq', 200)
},
},
)
})
Razonable, ¿verdad? /connect/userinfo es el endpoint de información de usuario de OIDC. Si la sesión expiró debería dar un 401, validate() falla y volvemos a iniciar sesión.
Por qué siempre pasa
Aquí se acumulan dos fallos independientes, y cualquiera de los dos por separado basta para anular la comprobación.
La URL es relativa. cy.request('/connect/userinfo') se resuelve contra baseUrl, que es la aplicación, no el proveedor de identidad. Así que la petición nunca llega al IdP.
La aplicación es una Single-Page App (SPA). Su servidor entrega index.html para cualquier ruta que no reconozca, porque eso es lo que exige el enrutamiento con history API. Una petición a /connect/userinfo recibe el HTML de la SPA con 200 OK y content-type: text/html.
validate() hacía una aserción sobre el código de estado. Recibía un 200. Siempre. Incluso para una sesión cuyo token de refresco había expirado hacía tres horas.
Puedes confirmarlo con un solo comando, sin Cypress:
curl -is https://app.example.com/connect/userinfo | head -5
Si ves 200 y text/html, tu validación es meramente decorativa.
Cómo se ve el fallo desde fuera
Esta es la parte que consume tiempo. La sesión se restaura, validate() pasa y luego el primer comando real de tu test desencadena la comprobación de autenticación propia de la app, que redirige al IdP. Tu spec falla así:
Nada apunta a un fallo de autenticación. La URL en la captura de pantalla es la página de login del IdP, pero como estás buscando un selector de una tabla, vas e inspeccionas la tabla. En una suite grande el patrón aparece como una dispersión de fallos sin aparente relación en aquellos tests que casualmente se ejecutaron tras expirar el token — el síntoma exacto que muchos etiquetan de "flaky" y le añaden reintentos.
La señal delatadora: el fallo se mueve entre ejecuciones, pero siempre ocurre en la primera aserción tras restaurar una sesión.
Soluciones que no funcionan
Probé las dos más evidentes primero.
Apuntar a la URL absoluta del IdP. cy.request('https://idp.example.com/connect/userinfo') ahora llega al servidor correcto, pero cy.request envía las cookies del navegador y nada más. Si tu SPA se autentica con un bearer token — y si estás usando oidc-client-ts, MSAL, angular-auth-oidc-client o similares, lo hace — el token vive en el web storage y se adjunta mediante un interceptor HTTP dentro de la aplicación. cy.request no tiene interceptores. Obtienes un 401 para una sesión perfectamente válida, setup se reejecuta en cada spec y anulas por completo el beneficio de cy.session.
Llamar a una API real de la aplicación. Mismo problema, mismo motivo. Estás probando si solo las cookies bastan para autenticar, que no es cómo funciona la aplicación.
Ambos fallos provienen del mismo error que el bug original, solo que a la inversa: la comprobación ejercita una vía de credenciales que la aplicación no utiliza.
La solución: validar lo que la app realmente lee
La aplicación decide que está autenticada leyendo un token del storage. Así que eso es lo que validate() debe comprobar.
interface StoredUser {
access_token: string
expires_at?: number
}
function readStoredUser(win: Window): StoredUser | null {
const key = Object.keys(win.sessionStorage).find((k) => k.startsWith('oidc.user:'))
if (!key) return null
const raw = win.sessionStorage.getItem(key)
return raw ? (JSON.parse(raw) as StoredUser) : null
}
function expiryMs(user: StoredUser): number | null {
if (user.expires_at) return user.expires_at * 1000
const [, payload] = user.access_token.split('.')
if (!payload) return null
const json = JSON.parse(
atob(payload.replace(/-/g, '+').replace(/_/g, '/')),
) as { exp?: number }
return json.exp ? json.exp * 1000 : null
}
Y la validación en sí:
validate() {
cy.visit('/')
cy.window({ log: false }).then((win) => {
const user = readStoredUser(win)
expect(user, 'registro de auth en storage').to.not.be.null
const expires = expiryMs(user as StoredUser)
expect(expires, 'claim de expiración del token').to.be.a('number')
expect(expires as number, 'token aún válido con margen')
.to.be.greaterThan(Date.now() + 30_000)
})
}
Tres detalles importan más que el resto:
El cy.visit('/') no es opcional. validate() corre antes del cy.visit de tu propio test. Hasta que se cargue una página en el origen de la app, la aplicación bajo prueba es un frame en blanco, y cy.window() te devuelve un storage vacío independientemente del estado de la sesión. Si omites el visit construirás el error opuesto: una validación que nunca puede pasar, ejecutando setup en cada spec. Lee el storage desde una página del origen correspondiente.
El margen de 30 segundos. Un token con cuatro segundos restantes pasará una comprobación ingenua de > Date.now(), pero expirará en mitad de tu test. Exige suficiente margen para terminar el spec. Ajusta el número a tu test más lento, no al más rápido.
El prefijo de la clave en storage depende de la librería. oidc.user:<authority>:<client_id> corresponde a oidc-client-ts. MSAL divide la cuenta, el token y los metadatos en claves separadas. Abre DevTools, mira lo que tu app escribió realmente y fíltralo según eso. Si tu aplicación usa una sesión por cookies httpOnly, el principio se orienta a otra parte: haz la aserción contra un endpoint que autentique por cookie, con URL absoluta, y confirma manualmente que devuelva 401 al cerrar sesión.
Demuestra que tu validate puede fallar
Este es el paso fundamental, porque se extrapola más allá de la autenticación. Un mecanismo de control que nunca has visto fallar no es un mecanismo de control fiable.
it('vuelve a ejecutar setup cuando el token almacenado ya no existe', () => {
cy.login(user)
cy.visit('/')
cy.window().then((win) => win.sessionStorage.clear())
cy.login(user)
cy.get('#app-shell').should('be.visible')
})
Aún mejor, fuérzalo manualmente una vez: borra el token, vuelve a ejecutar el spec y observa el log de comandos de Cypress. Lo que buscas es que el bloque setup se vuelva a ejecutar. Si no lo hace, tu validate() no es más que un comentario con pasos extra.
Reglas clave
validate()debe comprobar la credencial que la aplicación realmente envía, en el contexto en que la aplicación realmente la lee.- Una URL relativa en
validate()en una SPA resuelve al shell de la app. Devolverá200para siempre. cy.requestenvía cookies, nunca el bearer token interno de tu app. No lo uses para verificar sesiones basadas en tokens.validate()se ejecuta antes de tucy.visit, así que lee el web storage solo tras cargar una página en ese origen.- Valida la expiración con un margen de seguridad razonable, no contra
Date.now(). - Rompe deliberadamente la sesión una vez y confirma que
setupse reejecuta. Solo así sabrás que la red de seguridad funciona.
