Por qué mi colección fallaba en la primera corrida del Runner (y funcionaba en la segunda)

La colección pasaba en la segunda corrida pero no en la primera. El problema: ASP.NET Core Antiforgery vincula el token CSRF a la identidad del usuario.

Postman Runner mostrando primera corrida fallida: 6 passed 5 failed, Customer List con 400, Console con logs de CSRF token
Primera corrida (jar limpio): GET Login Page y POST Login pasan, Customer List falla con 400. La Console muestra "No se encontró la cookie CSRF-TOKEN".

Nota para el lector

Este post es parte de la serie de API Testing. Los posts anteriores cubren el análisis con DevTools, la construcción de requests en Postman, el manejo dinámico del token CSRF, y la creación de assertions con pm.test.

Si querés ir al grano:

  • Solo ver la causa y la solución → saltá a "La causa real" y "La solución"
  • Entender el proceso de debugging completo → leé en orden

El problema

Tenía mi colección "Serenity Demo - Auth + Customer API Flow" lista. Tres requests positivos (los que se seleccionan para esta corrida en el Runner):

  1. GET - Login Page → obtiene el token CSRF
  2. POST - Login → autentica con credenciales válidas
  3. POST - Customer List → consulta la grilla de clientes
Postman Runner con configuración de la colección: GET Login Page, POST Login y POST Customer List seleccionados, Advanced Settings visibles
Configuración del Runner: solo los 3 requests positivos seleccionados. "Save cookies after collection run" activado.

Cada request individual funcionaba perfecto. 11 assertions pasando. Todo verde.

Pero cuando corría la colección completa con el Runner, algo raro pasaba:

Primera corrida (cookie jar limpio): GET Login Page ✅, POST Login ✅, POST Customer List ❌ 400 Bad Request. 6 passed, 5 failed.

Postman Runner con Customer List fallido mostrando Request Headers con x-csrf-token y Status Code 400 Bad Request
Customer List responde 400. A la derecha, los Request Headers muestran que las cookies y el x-csrf-token se enviaron. El servidor rechazó igual.

Segunda corrida (sin limpiar el jar): todo verde. 11 passed, 0 failed.

Postman Runner con 11 tests passed, Customer List con Status Code 200 OK y Request Headers visibles
Segunda corrida (jar con cookies de la corrida anterior): 11/11 passed. Customer List responde 200.

Mismo código. Misma colección. Misma configuración del Runner. La única diferencia: el estado del cookie jar.


La configuración del Runner

Antes de entrar al debugging, esta era mi configuración del Runner:

  • Run manually
  • Iterations: 1
  • Delay: 0 ms
  • Persist responses for a session: ✅
  • Stop run if an error occurs: ✅
  • Keep variable values: ✅
  • Save cookies after collection run: ✅
Configuración del Postman Runner: Run manually, 1 iteración, Persist responses, Stop on error, Keep variable values, Save cookies activados
Advanced Settings del Runner. "Save cookies after collection run" explica por qué la segunda corrida funcionaba.

El "Save cookies after collection run" es clave para entender por qué la segunda corrida andaba: el jar guardaba las cookies de la corrida anterior.


La investigación: Console como herramienta de debug

La Console de Postman (abajo a la izquierda) es donde se ve todo lo que pasa entre bastidores. Cada request que sale, cada respuesta que vuelve, cada console.log que ponés en los scripts.

Abrí la Console, limpié las cookies, y corrí el Runner.

Qué me mostraba la Console en la primera corrida

GET https://demo.serenity.is/Account/Login/?ReturnUrl=%2F                    200
"GET Login Page - CSRF cookie:" "CfDJ8CI7vciz_vpFhLi6flAXcC..."
"csrfToken guardado en environment"
"No se encontró la cookie CSRF-TOKEN"                                        
POST https://demo.serenity.is/Account/Login                                  200
"No se encontró CSRF-TOKEN en Customer List"
POST https://demo.serenity.is/Services/Northwind/Customer/List               400
Postman Runner primera corrida fallida con Console mostrando csrfToken guardado, No se encontró la cookie CSRF-TOKEN, Customer List 400
La Console de la primera corrida: el token se guarda en environment, pero los pre-request scripts no lo encuentran en el cookie jar.

Dos líneas llamaron mi atención:

  • "No se encontró la cookie CSRF-TOKEN" → esto venía del pre-request de POST Login
  • "No se encontró CSRF-TOKEN en Customer List" → esto venía del pre-request de Customer List

El GET Login Page encontraba y guardaba el CSRF token en el environment. Pero los pre-request scripts de las siguientes requests no lo estaban leyendo.

Qué me mostraba la Console en la segunda corrida

Corrí de nuevo (sin limpiar el jar). Los logs eran casi idénticos:

GET https://demo.serenity.is/Account/Login/?ReturnUrl=%2F                    200
"GET Login Page - CSRF cookie:" "CfDJ8CI7vciz_vpFhLi6flAXcC..."
"csrfToken guardado en environment"
"No se encontró la cookie CSRF-TOKEN"
POST https://demo.serenity.is/Account/Login                                  200
"No se encontró CSRF-TOKEN en Customer List"
POST https://demo.serenity.is/Services/Northwind/Customer/List               200
Postman Runner segunda corrida exitosa 11 passed, Console mostrando logs de ambas corridas incluyendo la primera fallida con 400 y la segunda exitosa con 200
Console con ambas corridas visibles. Los mismos logs de "No se encontró", pero la segunda corrida pasa porque el jar ya tenía cookies.

Los mismos mensajes de "No se encontró". Pero esta vez Customer List devolvió 200.

Eso descartaba que el problema fuera solo de los scripts. Postman manda las cookies del jar automáticamente en los headers del request, independientemente de lo que pm.cookies.get() devuelva en los pre-request scripts. La diferencia no estaba en el código. Estaba en el estado del cookie jar antes de que empezara la corrida.


Hipótesis 1: los scripts leían de fuentes distintas (era parte del problema)

Mirando mis scripts encontré la primera inconsistencia.

GET Login Page (post-response) guardaba el token en el environment:

const csrfCookie = pm.cookies.get('CSRF-TOKEN');
if (csrfCookie) {
    pm.environment.set('csrfToken', csrfCookie);
    console.log('csrfToken guardado en environment');
}

POST Login (pre-request) leía del cookie jar:

const csrfCookie = pm.cookies.get('CSRF-TOKEN');
if (csrfCookie) {
    pm.variables.set('csrfToken', csrfCookie);
}

Customer List (pre-request) también leía del cookie jar:

const csrfCookie = pm.cookies.get('CSRF-TOKEN');
if (csrfCookie) {
    pm.environment.set("csrf_token", csrfCookie);
}

El problema era claro: GET Login Page guardaba en environment, pero POST Login y Customer List leían del cookie jar. En la primera corrida el jar está vacío (o al menos pm.cookies.get() no propaga las cookies del GET a tiempo para los siguientes pre-request scripts en el Runner).

Además, Customer List no estaba seteando el header x-csrf-token. Solo leía del jar y guardaba en una variable, pero nunca hacía pm.request.headers.upsert() para mandarlo como header.

El fix parcial

Unifiqué todos los scripts para que lean del environment y seteen el header:

POST Login (pre-request):

const csrf = pm.environment.get("csrfToken");
if (csrf) {
    pm.request.headers.upsert({
        key: "x-csrf-token",
        value: csrf
    });
} else {
    console.log("No hay csrfToken en environment para Login");
}

Customer List (pre-request):

const csrf = pm.environment.get("csrfToken");
if (csrf) {
    pm.request.headers.upsert({
        key: "x-csrf-token",
        value: csrf
    });
} else {
    console.log("No hay csrfToken en environment para Customer List");
}

Limpié el jar, corrí de nuevo.

Resultado: Seguía fallando. Customer List → 400.

Pero ahora el x-csrf-token header sí se estaba enviando. Lo podía ver en los Request Headers de la respuesta del Runner. Entonces el problema no era que el token no llegaba al servidor. Era que el token que llegaba no era válido.

Postman Runner con Customer List fallido 400, Request Headers mostrando x-csrf-token enviado correctamente, Console con csrfToken guardado en environment
Después de unificar los scripts: el x-csrf-token se envía (visible en Request Headers), pero el servidor responde 400. El token llega, pero no es válido.

Hipótesis 2: el servidor rota el CSRF token después del login

Mi siguiente idea fue que el servidor emitía un CSRF token nuevo en la respuesta de POST Login. Si el token cambiaba después del login, Customer List estaría mandando el token viejo.

Para verificarlo, puse código de debug en el post-response de POST Login que inspeccionaba los headers Set-Cookie:

const allHeaders = pm.response.headers.all();
const setCookies = allHeaders.filter(h => h.key.toLowerCase() === 'set-cookie');
console.log("=== Set-Cookie headers de POST Login ===");
console.log("Cantidad:", setCookies.length);
setCookies.forEach((h, i) => {
    console.log(`Set-Cookie [${i}]:`, h.value.substring(0, 100) + "...");
});

La Console mostró:

=== Set-Cookie headers de POST Login ===
Cantidad: 1
Set-Cookie [0]: ".AspNetAuth=CfDJ8CI7vciz_vpFhLi6flAXcCXFe9L5xMvXuWWFGvsRmbvb7DfTcPiWvN4SI8CZJF0JF..."
CSRF-TOKEN NO está en Set-Cookie de Login
Postman Runner con Console mostrando debug de Set-Cookie headers de POST Login: Cantidad 1, solo AspNetAuth, CSRF-TOKEN NO está en Set-Cookie
Debug de los Set-Cookie headers: POST Login solo emite .AspNetAuth. El servidor no rota el CSRF token después del login.

Solo un Set-Cookie: la cookie .AspNetAuth (la cookie de autenticación). El servidor no emitía un CSRF token nuevo en la respuesta del login.

Hipótesis descartada.


Hipótesis 3: el token está bien, las cookies están bien... ¿qué más puede ser?

Acá tuve que parar y pensar.

El header x-csrf-token se enviaba. Las cookies .AspNetCore.Antiforgery, CSRF-TOKEN y .AspNetAuth estaban todas en el request. El servidor respondía 400.

Pero en la segunda corrida, con exactamente el mismo código, todo andaba.

La diferencia: en la segunda corrida, el cookie jar ya tenía .AspNetAuth desde antes de que empezara el flujo. Eso significaba que cuando GET Login Page se ejecutaba en la segunda corrida, lo hacía como un request autenticado (porque el jar tenía la cookie de sesión de la corrida anterior).

Y ahí cayó la ficha.


La causa real: ASP.NET Core Antiforgery y la identidad del usuario

ASP.NET Core usa un sistema de protección contra CSRF (Cross-Site Request Forgery) que funciona con dos piezas:

  1. Una cookie (.AspNetCore.Antiforgery_*) que el servidor setea
  2. Un token (CSRF-TOKEN / x-csrf-token) que el cliente manda como header

El servidor valida que las dos piezas correspondan entre sí y que correspondan a la identidad del usuario actual.

El flujo en la primera corrida (jar limpio) era:

  1. GET Login Page → el servidor genera el CSRF token para un usuario anónimo. Setea la cookie CSRF-TOKEN y .AspNetCore.Antiforgery. Mi script guarda el token en el environment.
  2. POST Login → mando el token del environment como header x-csrf-token. El servidor valida (anónimo → anónimo, coincide), autentica, emite .AspNetAuth. Ahora soy un usuario autenticado.
  3. POST Customer List → mando el mismo token del environment. Pero este token fue generado para un usuario anónimo. La identidad cambió después del login. El par cookie/token del antiforgery ya no es válido para la sesión actual. El servidor rechaza: 400 Bad Request.

Por qué la segunda corrida funcionaba: el jar ya tenía .AspNetAuth de la corrida anterior. Cuando GET Login Page se ejecutaba, el servidor veía la cookie de autenticación, sabía que era un usuario autenticado, y generaba un CSRF token para esa sesión autenticada. Entonces cuando Customer List mandaba ese token, el par era válido.

En un browser real esto no pasa porque después del login hay una redirección que carga una nueva página. Esa carga genera automáticamente un CSRF token fresco para la sesión autenticada. En Postman, con requests secuenciales, ese paso intermedio no existe.


La solución: GET Refresh Token (Post-Login)

Agregué un request nuevo en la colección, justo después de POST Login:

GET - Refresh Token (Post-Login)

  • URL: {{baseUrl}}/Account/Login/?ReturnUrl=%2F
  • Método: GET

Con un post-response script:

const csrfCookie = pm.cookies.get("CSRF-TOKEN");
if (csrfCookie) {
    pm.environment.set("csrfToken", csrfCookie);
    console.log("CSRF token refreshed post-login:", csrfCookie.substring(0, 50) + "...");
}
Postman mostrando el request GET Refresh Token Post-Login con su post-response script que actualiza csrfToken en environment
El request nuevo: GET Refresh Token (Post-Login). Su script lee el CSRF token fresco del jar y lo guarda en environment.

La colección quedó así:

1. GET - Login Page              → obtiene CSRF token (anónimo)
2. POST - Login                  → autentica, cambia identidad
3. GET - Refresh Token (Post-Login) → obtiene CSRF token NUEVO (autenticado)
4. POST - Login (user correcto, pass mal)
5. POST - Login (user mal, pass correcta)
6. POST - Login (user y pass incorrectos)
7. POST - Login (user y pass vacíos)
8. POST - Customer List          → usa el token autenticado

Limpié el jar. Corrí el Runner.

All 11  Passed 11  Failed 0  Skipped 0
Postman Runner con solución final: GET Refresh Token en la colección, 11 passed 0 failed, Console con CSRF token refreshed post-login, Customer List 200
Solución final: 11/11 passed en primera corrida con jar limpio. La Console confirma el token refreshed post-login.

La Console confirmó el flujo:

GET Login Page → CSRF cookie capturado, csrfToken guardado en environment
POST Login → 200
GET Refresh Token → 200, CSRF token refreshed post-login (NUEVO valor)
POST Customer List → 200

El detalle del debugging: typo en nombre de variable

Durante la investigación hubo un error que me costó una corrida extra. Tenía:

  • GET Login Page guardaba con: pm.environment.set('csrfToken', ...)
  • POST Login leía con: pm.environment.get("csrf_Token")

csrfTokencsrf_Token. JavaScript es case-sensitive. Postman no te avisa que estás leyendo una variable que no existe, solo devuelve undefined.

Lección: unificá el nombre de la variable en todos los scripts. Elegí uno (csrfToken) y usá ese en todos lados.


Scripts finales

GET - Login Page (post-response)

const csrfCookie = pm.cookies.get('CSRF-TOKEN');
console.log('GET Login Page - CSRF cookie:', csrfCookie);
if (csrfCookie) {
    pm.environment.set('csrfToken', csrfCookie);
    console.log('csrfToken guardado en environment');
} else {
    console.log('No se encontró CSRF-TOKEN en GET - Login Page');
}

pm.test("Status code is 200", function () {
    pm.response.to.have.status(200);
});

pm.test("Response is HTML", function () {
    pm.response.to.have.header("Content-Type");
    pm.expect(pm.response.headers.get("Content-Type")).to.include("text/html");
});

pm.test("CSRF-TOKEN cookie exists", function () {
    const csrfCookie = pm.cookies.get("CSRF-TOKEN");
    pm.expect(csrfCookie).to.not.be.undefined;
});

POST - Login (pre-request)

const csrf = pm.environment.get("csrfToken");
if (csrf) {
    pm.request.headers.upsert({
        key: "x-csrf-token",
        value: csrf
    });
} else {
    console.log("No hay csrfToken en environment para Login");
}

POST - Login (post-response)

pm.test("Status code is 200", function () {
    pm.response.to.have.status(200);
});

pm.test("AspNetAuth cookie exists after login", function () {
    const authCookie = pm.cookies.get(".AspNetAuth");
    pm.expect(authCookie).to.not.be.undefined;
});

pm.test("CSRF-TOKEN cookie still present", function () {
    const csrfCookie = pm.cookies.get("CSRF-TOKEN");
    pm.expect(csrfCookie).to.not.be.undefined;
});

GET - Refresh Token Post-Login (post-response)

const csrfCookie = pm.cookies.get("CSRF-TOKEN");
if (csrfCookie) {
    pm.environment.set("csrfToken", csrfCookie);
    console.log("CSRF token refreshed post-login:", csrfCookie.substring(0, 50) + "...");
}

POST - Customer List (pre-request)

const csrf = pm.environment.get("csrfToken");
if (csrf) {
    pm.request.headers.upsert({
        key: "x-csrf-token",
        value: csrf
    });
} else {
    console.log("No hay csrfToken en environment para Customer List");
}

Takeaways

Sobre ASP.NET Core Antiforgery: El token CSRF no es solo un valor random que el servidor valida. Está vinculado a la identidad del usuario. Si la identidad cambia (de anónimo a autenticado), el token viejo deja de ser válido. Un browser resuelve esto automáticamente con la redirección post-login. En Postman, hay que hacerlo explícitamente.

Sobre Postman:

  • pm.cookies.get() en un pre-request script del Runner no siempre refleja las cookies que acaba de setear el request anterior. El cookie jar y los scripts tienen su propio timing.
  • La Console es la herramienta más valiosa para debugging en Postman. Cada hypothesis que tuve la validé o descarté mirando los logs.
  • pm.environment es la forma más confiable de pasar datos entre requests en una colección.

Sobre debugging en general: El problema tardó varias hipótesis en resolverse. La primera (scripts leían de fuentes distintas) era real pero no era la causa raíz. La segunda (el servidor rota el token) parecía lógica pero los datos la descartaron. La tercera (el token está vinculado a la identidad) explicó todos los síntomas: por qué la primera corrida fallaba, por qué la segunda andaba, y por qué agregar un GET post-login lo resolvía.


Estado actual

La colección completa pasa 11/11 en primera corrida con jar limpio. El flujo simula correctamente lo que haría un browser: obtener token → autenticarse → refrescar token → consultar datos.

El cookie jar después de una corrida exitosa tiene 3 cookies: .AspNetAuth, .AspNetCore.Antiforgery_*, y CSRF-TOKEN.

Postman Cookie Manager mostrando 3 cookies de demo.serenity.is: AspNetAuth, AspNetCore.Antiforgery, CSRF-TOKEN, con Runner exitoso detrás
Cookie jar después de la corrida exitosa: .AspNetAuth, .AspNetCore.Antiforgery y CSRF-TOKEN. Las tres piezas del puzzle.

Lo que sigue: operaciones CRUD reales

Crear, verificar, eliminar, confirmar. La colección pasa de solo lectura a modificación de datos.

Hasta ahora toda la colección consulta datos. Las requests son GET y POST de lectura: login, listar clientes. El siguiente paso es testear operaciones que modifican datos: crear un cliente desde Postman, verificar que exista en la grilla, eliminarlo y confirmar que ya no aparece. Eso es CRUD real, no solo consultas.