Voltar para todos os artigos

Minha sessão restaurada do Cypress estava mentindo para mim

Um validate() de cy.session que chama uma URL relativa em uma SPA nunca falha. Veja por que sessões expiradas restauram em verde e como validar a credencial que sua aplicação realmente utiliza.

Publicado primeiro em marcelo-costa.com. Mais textos meus sobre Cypress em Cypress no Dev.to
Conteúdo 100% autoral: Todos os artigos, desafios práticos e soluções em código foram escritos por Marcelo Costa sem qualquer uso de IA na redação. A inteligência artificial foi utilizada exclusivamente no auxílio à tradução para múltiplos idiomas.

Nota do Autor / Transparência: Conteúdo 100% autoral baseado em problemas reais de engenharia em produção. Nenhuma inteligência artificial foi utilizada na escrita do texto, análises ou código. A inteligência artificial foi utilizada exclusivamente no auxílio à tradução para múltiplos idiomas.

cy.session() é o maior ganho de velocidade disponível para uma suíte de testes autenticada no Cypress. Você faz login uma única vez, o Cypress tira um snapshot de cookies, localStorage e sessionStorage, e cada spec seguinte restaura esse snapshot em vez de passar novamente pelo fluxo do provedor de identidade.

A rede de segurança é o validate(). O Cypress o executa após restaurar uma sessão em cache; se ele lançar um erro, falhar em uma asserção ou retornar false, o Cypress descarta o snapshot e executa o bloco de setup novamente. Esse é o contrato fundamental: uma sessão inválida é detectada e substituída.

A minha validação nunca falhava. Por semanas. E isso me custou dias depurando testes "flaky" que, na verdade, não tinham nada de instáveis.

O código que parecia perfeito

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)
      },
    },
  )
})

Parece razoável, certo? /connect/userinfo é o endpoint OIDC de informações do usuário. Se a sessão estiver expirada, ele deveria retornar 401, o validate() falharia e o login seria refeito.

Por que ele sempre passa

Dois bugs independentes se somam aqui, e qualquer um deles sozinho é suficiente para anular a verificação.

A URL é relativa. cy.request('/connect/userinfo') resolve contra a baseUrl, que aponta para a aplicação, e não para o provedor de identidade (IdP). Portanto, a requisição nunca atinge o IdP.

A aplicação é uma Single-Page App (SPA). O servidor entrega index.html para qualquer rota que não reconheça, pois esse é o comportamento padrão do roteamento via History API. Uma requisição para /connect/userinfo recebe de volta o shell da SPA com status 200 OK e cabeçalho content-type: text/html.

O validate() verificava o código de status HTTP. Ele recebia 200. Toda e qualquer vez. Inclusive para sessões cujo refresh token havia expirado há mais de três horas.

Você pode confirmar isso com um único comando no terminal, sem abrir o Cypress:

curl -is https://app.example.com/connect/userinfo | head -5

Se o retorno for 200 e text/html, sua validação é puramente decorativa.

Como a falha se parece vista de fora

Esta é a parte que consome tempo precioso da equipe. A sessão é restaurada, o validate() passa, e então o primeiro comando real do seu teste aciona a verificação de autenticação interna do app, que redireciona para o IdP. O seu teste falha assim:

false

Nada aponta para problemas de autenticação. A URL capturada no screenshot de erro é a tela de login do IdP, mas como seu teste estava procurando uma tabela de dados, você perde tempo inspecionando seletores do grid. Em uma suíte grande, isso se manifesta como falhas aleatórias espalhadas nos specs que rodaram após a expiração do token — o padrão exato que equipes rotulam como "flakiness" e tentam resolver adicionando retries.

O sinal revelador: a falha muda de lugar entre execuções, mas sempre atinge a primeira asserção após restaurar uma sessão.

Soluções que não funcionam

Tentei as duas abordagens mais óbvias primeiro.

Apontar para a URL absoluta do IdP. cy.request('https://idp.example.com/connect/userinfo') agora chega ao servidor correto, mas o cy.request envia apenas os cookies do navegador. Se a sua SPA utiliza autenticação por bearer token — e com bibliotecas como oidc-client-ts, MSAL ou angular-auth-oidc-client, ela utiliza — o token reside no web storage e é injetado por interceptores HTTP do próprio app. O cy.request não passa por esses interceptores. Você recebe um 401 para uma sessão perfeitamente válida, o setup roda em todo spec e todo o ganho de velocidade do cy.session é perdido.

Chamar uma API de negócio real da aplicação. O mesmo problema, pelo mesmo motivo. Você está testando se apenas cookies conseguem autenticar, o que não reflete a arquitetura da aplicação.

Ambas as tentativas cometem o mesmo erro inicial: testar um canal de credenciais que a aplicação não utiliza no mundo real.

A solução definitiva: validar o que o app realmente lê

A aplicação decide que o usuário está autenticado lendo o token direto do storage. Portanto, é isso que o validate() deve verificar.

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
}

E a validação no Cypress:

validate() {
  cy.visit('/')
  cy.window({ log: false }).then((win) => {
    const user = readStoredUser(win)
    expect(user, 'registro de autenticação no storage').to.not.be.null

    const expires = expiryMs(user as StoredUser)
    expect(expires, 'campo de expiração do token').to.be.a('number')
    expect(expires as number, 'token ainda válido com margem segura')
      .to.be.greaterThan(Date.now() + 30_000)
  })
}

Três detalhes fundamentais nesta implementação:

O cy.visit('/') não é opcional. O validate() roda antes do cy.visit do seu próprio teste. Até que uma página da mesma origem seja carregada, o frame da aplicação está em branco e o cy.window() retorna um storage vazio, independentemente do estado da sessão. Pular o visit cria o bug inverso: uma validação que nunca passa, forçando o setup a rodar a cada teste. Sempre leia o storage a partir de uma página da origem correta.

A margem de 30 segundos. Um token com apenas quatro segundos de vida passa em uma verificação simples de > Date.now(), mas expira no meio do seu teste. Exija margem suficiente para cobrir a execução do spec. Calibre o tempo com base no seu teste mais longo, não no mais curto.

O prefixo da chave no storage varia conforme a biblioteca. oidc.user:<authority>:<client_id> é o formato do oidc-client-ts. O MSAL divide conta, token e metadados em chaves distintas. Abra o DevTools, veja exatamente o que sua aplicação grava e use isso como chave. Se a sua aplicação usar autenticação por cookies httpOnly, o princípio aponta para outro caminho: teste um endpoint que autentique por cookie, com URL absoluta, e confirme manualmente que ele retorna 401 quando deslogado.

Prove que seu validate realmente falha

Este é o aprendizado mais valioso, porque se aplica a qualquer tipo de teste: uma trava de segurança que você nunca viu falhar não é uma trava confiável.

it('executa o setup novamente quando o token armazenado é removido', () => {
  cy.login(user)
  cy.visit('/')
  cy.window().then((win) => win.sessionStorage.clear())
  cy.login(user)
  cy.get('#app-shell').should('be.visible')
})

Melhor ainda, quebre intencionalmente uma vez: remova o token manualmente, execute o spec e observe o log de comandos do Cypress. Você quer ver o bloco de setup sendo reexecutado. Se ele não rodar, o seu validate() é apenas um comentário com etapas desnecessárias.

Regras de ouro

  • O validate() deve validar a credencial que o app realmente envia, no exato contexto em que ele a lê.
  • URLs relativas no validate() de uma SPA sempre resolvem para o shell HTML da aplicação e retornam 200.
  • O cy.request envia cookies de sessão, nunca o bearer token interno da sua SPA. Não use requests HTTP simples para validar sessões baseadas em tokens.
  • O validate() executa antes do seu cy.visit, portanto acesse a origem correta antes de ler o web storage.
  • Valide o prazo de expiração com margem de segurança, nunca em cima de Date.now().
  • Force uma falha intencional da sessão para certificar-se de que o setup roda novamente. Só assim você tem certeza de que a rede de segurança funciona.