Merge branch 'development' of https://git.aduanasoft.com/ADUANASOFT/anexo76 into fix/agentes-aduanales-cache

This commit is contained in:
2026-05-20 14:17:10 -05:00
3 changed files with 33 additions and 135 deletions

36
Jenkinsfile vendored
View File

@@ -169,11 +169,12 @@ pipeline {
stage('E2E (Playwright)') {
steps {
// auth.setup.ts obtiene tokens directamente de Keycloak (password grant)
// sin pasar por el browser ni por Workspace — /login hace 303 inmediato.
// auth.setup.ts navega a /login → Workspace → llena form → /dashboard
// y guarda storageState para los tests dependientes.
//
// Credenciales requeridas en Jenkins:
// a76-public-url-dev : URL pública base — la URL de KC se deriva de ella ({url}/kcauth)
// a76-e2e-credentials : Username with password — cuenta de Workspace para pruebas E2E
// a76-public-url-dev : URL pública base de Anexo76
// a76-e2e-credentials : Username with password — usuario de prueba en Workspace
withCredentials([
string(credentialsId: 'a76-public-url-dev', variable: 'A76_URL'),
usernamePassword(
@@ -200,15 +201,14 @@ pipeline {
-e PLAYWRIGHT_TEST_BASE_URL=http://localhost:5173 \
-e "VITE_API_URL=${A76_URL}/api/" \
-e "INTERNAL_API_URL=${A76_URL}/api/" \
-e "VITE_KEYCLOAK_URL=${A76_URL}/kcauth" \
-e "KEYCLOAK_URL=${A76_URL}/kcauth" \
-e "VITE_KEYCLOAK_URL=${KC_URL}" \
-e "KEYCLOAK_URL=${KC_URL}" \
-e VITE_KEYCLOAK_REALM=master \
-e KEYCLOAK_REALM=master \
-e VITE_KEYCLOAK_CLIENT_ID=anexo76-frontend \
-e KEYCLOAK_CLIENT_ID=anexo76-frontend \
-e VITE_KEYCLOAK_CLIENT_ID=hub-frontend \
-e KEYCLOAK_CLIENT_ID=hub-frontend \
-e ORIGIN=http://localhost:5173 \
-e "KC_URL=${KC_URL}" \
-e "E2E_KEYCLOAK_URL=${KC_URL}" \
-e "E2E_TEST_USER=${E2E_USER}" \
-e "E2E_TEST_PASSWORD=${E2E_PASS}" \
-w /workspace/frontend \
@@ -218,24 +218,6 @@ pipeline {
pnpm install --frozen-lockfile
pnpm run i18n:compile
# Verificar que Keycloak acepta el password grant
# (auth.setup.ts lo usa directamente — sin pasar por /login)
echo "--- Verificando Keycloak token endpoint ---"
KC_TOKEN_EP="${KC_URL}/realms/master/protocol/openid-connect/token"
# -s sin -f: curl no falla en HTTP 4xx/5xx, devuelve el código real
# || echo "000" solo se activa si curl no puede conectar (timeout, DNS, etc.)
KC_STATUS=$(curl -s --max-time 10 -o /dev/null -w "%{http_code}" \
-X POST "$KC_TOKEN_EP" \
-d "grant_type=password&client_id=anexo76-frontend&username=${E2E_TEST_USER}&password=${E2E_TEST_PASSWORD}&scope=openid" \
2>/dev/null || echo "000")
echo "Keycloak token endpoint → HTTP ${KC_STATUS}"
if [ "$KC_STATUS" != "200" ]; then
echo "ERROR: Keycloak no accesible o password grant no habilitado (HTTP ${KC_STATUS})."
echo "URL: ${KC_TOKEN_EP}"
exit 1
fi
echo "Keycloak OK"
# Arrancar dev server
pnpm run dev &
DEV_PID=$!

View File

@@ -1,124 +1,42 @@
/**
* Setup de autenticación para tests E2E de Playwright.
*
* El flujo de login de Anexo76 pasa por Workspace externo (workspace.aduanasoft.com)
* y Keycloak SSO — el browser nunca renderiza un form propio. Por eso NO se puede
* automatizar navegando a /login: ese route hace 303 inmediato al Workspace.
* El flujo de login de Anexo76 pasa por Workspace (workspace.aduanasoft.com):
* 1. Navegar a /login → 303 redirect al formulario de Workspace
* 2. Llenar el form de Workspace con el usuario de prueba (creado en globalSetup)
* 3. Workspace/Keycloak redirige de vuelta a /auth/callback → /dashboard
* 4. Guardar storageState para todos los tests dependientes
*
* Estrategia: obtener tokens directamente del token endpoint de Keycloak
* (Resource Owner Password Credentials grant) e inyectarlos como cookies en el
* contexto de Playwright, replicando exactamente lo que hace /auth/callback.
*
* Requiere en Keycloak: cliente con "Direct Access Grants" habilitado.
* Requiere en Jenkins: credenciales a76-e2e-keycloak-url, a76-e2e-test-user, a76-e2e-test-pass.
* El usuario de prueba es creado y eliminado por globalSetup / globalTeardown.
* Patrón idéntico al de aduanasoft-hub.
*/
import { test as setup } from '@playwright/test'
import { test as setup, expect } from '@playwright/test'
import { mkdirSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
// Lógica de chunking idéntica a access-token-cookie.shared.ts
const ACCESS_TOKEN_MAX_SINGLE = 2800
const ACCESS_TOKEN_CHUNK_SIZE = 2800
const ACCESS_TOKEN_CHUNK_COUNT = 'access_token_chunks'
const chunkName = (i: number) => `access_token_${i}`
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const authFile = path.join(__dirname, '.auth/user.json')
setup('autenticacion', async ({ page, request }) => {
const baseUrl = (process.env.PLAYWRIGHT_TEST_BASE_URL ?? 'http://localhost:5173').replace(/\/$/, '')
setup('autenticacion', async ({ page }) => {
const username = process.env.E2E_TEST_USER ?? ''
const password = process.env.E2E_TEST_PASSWORD ?? ''
// E2E_KEYCLOAK_URL: URL base de Keycloak accesible desde el agente Jenkins.
// Puede diferir de VITE_KEYCLOAK_URL si ese apunta al dominio del propio servidor
// y el contenedor Docker no puede resolver ese dominio (hairpin NAT).
const keycloakUrl = (
process.env.E2E_KEYCLOAK_URL ||
process.env.KEYCLOAK_URL ||
process.env.VITE_KEYCLOAK_URL ||
''
).replace(/\/$/, '')
if (!username) throw new Error('E2E_TEST_USER no está configurado')
if (!password) throw new Error('E2E_TEST_PASSWORD no está configurado')
const realm = process.env.KEYCLOAK_REALM || process.env.VITE_KEYCLOAK_REALM || 'master'
const clientId = process.env.KEYCLOAK_CLIENT_ID || process.env.VITE_KEYCLOAK_CLIENT_ID || 'anexo76-frontend'
const secret = process.env.KEYCLOAK_CLIENT_SECRET || ''
const username = process.env.E2E_TEST_USER || ''
const password = process.env.E2E_TEST_PASSWORD || ''
// ── 1. Navegar a /login — Workspace intercepta y muestra su formulario ──
await page.goto('/login')
if (!keycloakUrl) throw new Error('E2E_KEYCLOAK_URL / KEYCLOAK_URL no está configurado')
if (!username) throw new Error('E2E_TEST_USER no está configurado')
if (!password) throw new Error('E2E_TEST_PASSWORD no está configurado')
// ── 2. Llenar el formulario de Workspace (mismos selectores que hub/login.spec.ts) ──
await page.getByRole('textbox', { name: /usuario o email/i }).fill(username)
await page.getByLabel(/contraseña/i).fill(password)
await page.getByRole('button', { name: /continuar/i }).click()
// ── 1. Obtener tokens vía password grant ──────────────────────────────────
const tokenEndpoint = `${keycloakUrl}/realms/${realm}/protocol/openid-connect/token`
// ── 3. Esperar redirect de vuelta a Anexo76 (/auth/callback → /dashboard) ──
await expect(page).toHaveURL(/\/dashboard/, { timeout: 30_000 })
const form: Record<string, string> = {
grant_type: 'password',
client_id: clientId,
username,
password,
scope: 'openid'
}
if (secret) form.client_secret = secret
const tokenRes = await request.post(tokenEndpoint, { form })
if (!tokenRes.ok()) {
const body = await tokenRes.text()
throw new Error(
`Keycloak password grant falló (HTTP ${tokenRes.status()}).\n` +
`Endpoint: ${tokenEndpoint}\n` +
`Respuesta: ${body}\n` +
`Verifica que el cliente "${clientId}" tenga "Direct Access Grants" habilitado en Keycloak.`
)
}
const tokens = await tokenRes.json() as {
access_token: string
refresh_token?: string
id_token?: string
}
// ── 2. Navegar al app para establecer origen de cookies ───────────────────
await page.goto(baseUrl)
const hostname = new URL(baseUrl).hostname
const secure = baseUrl.startsWith('https')
const base = { domain: hostname, path: '/', sameSite: 'Lax' as const, secure }
// ── 3. Inyectar access_token respetando chunking (igual que setAccessTokenCookies) ──
if (tokens.access_token.length <= ACCESS_TOKEN_MAX_SINGLE) {
await page.context().addCookies([
{ ...base, httpOnly: false, name: 'access_token', value: tokens.access_token }
])
} else {
const parts: string[] = []
for (let i = 0; i < tokens.access_token.length; i += ACCESS_TOKEN_CHUNK_SIZE) {
parts.push(tokens.access_token.slice(i, i + ACCESS_TOKEN_CHUNK_SIZE))
}
await page.context().addCookies([
{ ...base, httpOnly: false, name: ACCESS_TOKEN_CHUNK_COUNT, value: String(parts.length) },
...parts.map((p, i) => ({ ...base, httpOnly: false, name: chunkName(i), value: p }))
])
}
// ── 4. Cookies httpOnly (el servidor las gestiona; Playwright puede setearlas en test) ──
if (tokens.refresh_token) {
await page.context().addCookies([
{ ...base, httpOnly: true, name: 'refresh_token', value: tokens.refresh_token }
])
}
if (tokens.id_token) {
await page.context().addCookies([
{ ...base, httpOnly: true, name: 'id_token', value: tokens.id_token }
])
}
// ── 5. Verificar que la sesión funciona navegando al dashboard ────────────
await page.goto(`${baseUrl}/dashboard`)
await page.waitForURL(/dashboard/, { timeout: 30000 })
// ── 6. Guardar estado para los tests dependientes ─────────────────────────
// ── 4. Guardar estado para los tests dependientes ─────────────────────────
mkdirSync(path.dirname(authFile), { recursive: true })
await page.context().storageState({ path: authFile })
})

View File

@@ -11,10 +11,9 @@ const inCI = process.env.CI === 'true' || Boolean(process.env.JENKINS_URL);
export default defineConfig({
// En CI, falla si queda un .only; en local no
forbidOnly: inCI,
timeout: 60000,
timeout: 60_000,
use: {
baseURL: process.env.PLAYWRIGHT_TEST_BASE_URL || 'http://localhost:5173',
// Sin display en agentes de integración: obligatorio headless
headless: inCI ? true : false
},
workers: 1,
@@ -23,7 +22,6 @@ export default defineConfig({
{
name: 'setup',
testMatch: '**/auth.setup.ts',
// Navegador limpio: user.json se genera aquí y no debe existir al primer run / en CI
use: { storageState: { cookies: [], origins: [] } }
},
{
@@ -33,4 +31,4 @@ export default defineConfig({
use: { storageState: authStatePath }
}
]
});
});