diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1a38d46..a9af13d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -87,6 +87,63 @@ jobs: GFRAME_TEST_NGINX: /usr/sbin/nginx GFRAME_TEST_PHP_FPM: /usr/sbin/php-fpm8.3 + infrastructure-84: + name: MySQL, HTTP y Redis reales con PHP 8.4 + runs-on: ubuntu-24.04 + services: + mysql: + image: mysql:8.0 + env: + MYSQL_ROOT_PASSWORD: gframe_test + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping -h 127.0.0.1 -pgframe_test" + --health-interval=10s --health-timeout=5s --health-retries=10 + redis: + image: redis:7-alpine + ports: + - 6379:6379 + options: >- + --health-cmd="redis-cli ping" + --health-interval=5s --health-timeout=3s --health-retries=10 + env: + GFRAME_TEST_MYSQL: '1' + GFRAME_TEST_MYSQL_DSN: mysql:host=127.0.0.1;port=3306 + GFRAME_TEST_MYSQL_USER: root + GFRAME_TEST_MYSQL_PASSWORD: gframe_test + GFRAME_TEST_REDIS: '1' + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + extensions: mbstring, openssl, pdo_mysql, pdo_sqlite, redis + coverage: none + - uses: ramsey/composer-install@v3 + with: + dependency-versions: locked + - run: php packages/phpunit/phpunit/phpunit --filter 'MySqlMigrationIntegration|DatabasePreflight|InstallerTest|InstallerHttp|InstallerWizardHttp|RedisSessionIntegration' --fail-on-skipped + + nginx-cgi: + name: Routing real con Nginx y PHP-CGI + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + extensions: mbstring, openssl, pdo_sqlite + coverage: none + - uses: ramsey/composer-install@v3 + with: + dependency-versions: locked + - run: sudo apt-get update && sudo apt-get install -y nginx php8.3-cgi php8.3-sqlite3 php8.3-mbstring php8.3-xml + - run: php packages/phpunit/phpunit/phpunit --filter 'ServerRoutingTest::testRealNginxRoutesRequestsAndServerErrorsToTheInstalledFramework' --fail-on-skipped + env: + GFRAME_TEST_NGINX: /usr/sbin/nginx + GFRAME_TEST_PHP_CGI: /usr/bin/php-cgi8.3 + javascript: name: JavaScript (sin navegador) runs-on: ubuntu-latest @@ -98,6 +155,36 @@ jobs: package-manager-cache: false - run: node --test --test-reporter=tap tests/js/*.test.cjs + browser: + name: Recorridos reales con Chromium + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + extensions: mbstring, openssl, pdo_sqlite, gd + coverage: none + - uses: ramsey/composer-install@v3 + with: + dependency-versions: locked + - uses: actions/setup-node@v7 + with: + node-version: '24' + package-manager-cache: false + - name: Instalar navegador de pruebas en el directorio temporal + run: | + npm install --prefix "$RUNNER_TEMP/gframe-browser" --no-package-lock --no-audit --no-fund playwright@1.62.1 + "$RUNNER_TEMP/gframe-browser/node_modules/.bin/playwright" install --with-deps chromium + echo "NODE_PATH=$RUNNER_TEMP/gframe-browser/node_modules" >> "$GITHUB_ENV" + - name: Ejecutar todos los recorridos de navegador + run: | + export GFRAME_TEST_PHP="$(command -v php)" + export GFRAME_TEST_BROWSER="$(node -p 'require("playwright").chromium.executablePath()')" + set -o pipefail + node --test --test-concurrency=1 --test-reporter=tap tests/js/*.test.cjs | tee "$RUNNER_TEMP/gframe-browser.tap" + grep -Fx '# skipped 0' "$RUNNER_TEMP/gframe-browser.tap" + skills-metadata: name: Metadata YAML de skills runs-on: ubuntu-latest diff --git a/maintenance/cierre-auditorias-20261025.md b/maintenance/cierre-auditorias-20261025.md index a1007f3..8de32a6 100644 --- a/maintenance/cierre-auditorias-20261025.md +++ b/maintenance/cierre-auditorias-20261025.md @@ -49,12 +49,12 @@ Los nueve deltas se marcan `auditada` porque la última pasada de su etapa los i | [BridgeFrame](bridgeframe-pending.md) | El usuario confirmó la adaptación v2 el 6 de octubre | Retirado del backlog de GFrame; no acredita integración externa en vivo | | [Skills y contratos](inventario-skills-contratos.md), [distribución](distribucion-skills.md) | Lotes 31–33, instalación/carga en Codex y resolución K-01–K-04/M-01 documentadas | No quedan esos cambios pospuestos de código; Claude fue excluido expresamente de esa validación | -## Verificaciones que no se marcan como realizadas +## Estado de verificaciones complementarias Estos puntos son límites concretos de evidencia, no nuevas funcionalidades ni razones para reabrir una etapa cuyo alcance aprobado ya se auditó: -- **V-01 — cobertura visual restante:** prueba visual de cada biblioteca opcional y de todas las vistas/recursos de todos los módulos en una instalación limpia. Selector/editor y recorridos concretos sí tienen auditorías y pruebas. No equivalen a esa cobertura universal. Fuente: [auditoría de módulos](auditoria-modulos.md). -- **V-02 — infraestructura restante:** routing general con Nginx/PHP-CGI, integración MySQL/HTTP específicamente en PHP 8.4 y sesiones Redis con servicio real. Nginx/PHP-FPM para cron y la matriz PHP sin esas integraciones sí están acreditados. Fuente: [preparación histórica](preparacion-0.9.0.md) y [CI/lint](cobertura-checks-20261006.md). +- **V-01 — parcial:** los 99 casos de la suite con Chromium pasan sin fallos ni omisiones en CI 40171975482, incluidos instalador, selector, gestión y autoguardado. [Evidencia de la continuación](verificacion-infraestructura-20261025.md). Permanece sin acreditar la prueba visual de cada biblioteca opcional y de todas las vistas/recursos de todos los módulos en una instalación limpia; esos recorridos no equivalen a una cobertura universal. Fuente: [auditoría de módulos](auditoria-modulos.md). +- **V-02 — cerrada en continuación:** routing general con Nginx/PHP-CGI (1 prueba/85 aserciones), MySQL/HTTP con PHP 8.4 y Redis real (36/764), sin omisiones, en CI 40171556708. [Evidencia y límites](verificacion-infraestructura-20261025.md). La primera prueba Nginx tenía una expectativa CSRF obsoleta; el test corregido comprueba rechazo sin token y solicitud válida con sesión/token, sin cambiar el core. El resultado no certifica producción. - **V-03 — correo externo:** diagnóstico de recepción del registro inicial y compatibilidad en clientes remotos. Pruebas de render, cola y transporte falso no prueban aceptación SMTP ni recepción. No se envió correo ni se accedió a proveedores en este cierre. Fuente: [plantillas](auditorias/plantillas-correo/AUDIT.md). - **V-04 — personalización del pie del correo:** el año y la homogeneidad están implementados; el contenido personalizado de créditos del pie web no se reutiliza automáticamente en las plantillas de correo. Se conserva como decisión/integración pendiente; no se cambia el motor por marcar una lista. Fuente: [plantillas](auditorias/plantillas-correo/AUDIT.md). - **V-05 — hosting y consumidores:** cabeceras y bloqueos efectivos del servidor de producción, despliegue de cada aplicación y pruebas con proveedores reales. Las comprobaciones de fixtures no certifican esos entornos. Fuente: [seguridad inicial](auditorias/alineacion-4.3.0/SEGURIDAD-INICIAL.md). diff --git a/maintenance/verificacion-infraestructura-20261025.md b/maintenance/verificacion-infraestructura-20261025.md new file mode 100644 index 0000000..6bc4670 --- /dev/null +++ b/maintenance/verificacion-infraestructura-20261025.md @@ -0,0 +1,43 @@ +# Verificación de infraestructura pendiente + +Base: main `d477d760a3b2a3661b0c120dd258fed42a30a230`. Continuación de V-02 del [seguimiento histórico](cierre-auditorias-20261025.md). Solo pruebas y CI; no se modifica el core ni se instalan servicios en proyectos del usuario. + +Convenciones seleccionadas: orchestrator, framework-maintenance y auth-access, con los contratos de sesiones y revocación. El resultado requiere ejecuciones reales; un test omitido impide aprobar el job dedicado. + +## Comprobaciones preparadas + +- PHP 8.4 contra MySQL 8: migraciones, preflight, instalación y recorridos HTTP de fixtures temporales. +- phpredis contra Redis 7: lectura/escritura, revocación desde otra instancia, escritura tardía que no restaura sesiones, bloqueo/desbloqueo, versiones de autorización global/usuario/tenant y expiración real. La cuenta se consulta en SQLite temporal; los Lua y TTL se ejecutan en Redis real. Limpieza limitada al prefijo aleatorio de cada prueba, sin vaciar bases Redis. +- Routing Nginx/PHP-CGI: ejecutar el test existente con recursos públicos, rutas virtuales, archivos internos protegidos, errores y canales. + +Los resultados de CI se registrarán después de la ejecución. No se presenta esta preparación como V-02 resuelta. SMTP, personalización del pie, hosting real y cobertura visual universal conservan sus límites. + +## Primera ejecución + +[CI 40171290826](https://github.com/gorvet/gframe-framework/actions/runs/40171290826), commit `6574d5c`: MySQL/HTTP y Redis reales, PHP 8.4.27, 36 pruebas/764 aserciones, sin omisiones ni fallos; job 121102466808. Redis 7.4.11 y MySQL 8.0.46 reales en contenedores temporales. + +Nginx/PHP-CGI llegó a 79 aserciones y falló porque el test antiguo esperaba `recovery_requested` enviando un POST sin CSRF; el framework devolvió correctamente `fail_csrf`. Job 121102466609. El resto de la matriz pasó. Se conserva este fallo como evidencia de una expectativa obsoleta del fixture, no se cambia la protección del framework. + +La corrección de la prueba conserva el caso negativo y añade GET del formulario, cookie de sesión y token/timestamp reales antes del POST válido. Requiere una nueva ejecución de Nginx/PHP-CGI para cerrar ese escenario. + +## Segunda ejecución de infraestructura + +[CI 40171556708](https://github.com/gorvet/gframe-framework/actions/runs/40171556708), commit `6897107`: doce jobs aprobados. Nginx/PHP-CGI: 1 prueba/85 aserciones, sin omisiones; job 121103266424. MySQL/HTTP y Redis con PHP 8.4.27: 36 pruebas/764 aserciones, sin omisiones; job 121103266379. Los tres escenarios concretos de V-02 quedan acreditados, sin extender el resultado a producción. + +## Recorridos de navegador + +Suite local completa con Chrome, Playwright 1.62.1 y PHP 8.3.35, base d477d76: 99 casos, 95 aprobados, cuatro fallidos, sin omisiones. Instalador, gestión y selector fallan al navegar a loopback con `ERR_NETWORK_ACCESS_DENIED`; no se presentan como fallos demostrados del producto. El autoguardado falla al comparar timestamps tomados cuando Node recibe los eventos de red, en lugar del instante de envío en el navegador. La repetición aislada vuelve a fallar. + +Sonda independiente en memoria, sin editar el runtime: envíos separados por 5005,6 ms en `performance.now()` y 5005 ms en Date.now() del navegador; recepción de esos mismos eventos en Node separada por 4933 ms. La sonda completa termina aprobada, 1 caso. La llegada del evento al arnés no es el reloj del planificador; la demora de cada evento varía. + +El test conserva el umbral de 4800 ms y observa el envío real de XMLHttpRequest mediante el reloj monótono del navegador. No modifica temporizadores ni tráfico del producto. La nueva ejecución con demora deliberada del arnés debe confirmar que no se confunde esa demora con el intervalo del editor. + +Sonda con 500 ms de demora deliberada antes de registrar el primer evento en Node: el navegador separa sus envíos 4999,2 ms, mientras el arnés registra 4410 ms. El criterio corregido mantiene el umbral y mide la propiedad exigida; el anterior rechazaría un intervalo correcto por la demora introducida en la observación. Recorrido completo con la demora inyectada: 1 caso aprobado, sin omisiones, 56,7 segundos. Log local `~/.gframe-agents/autosave-demora-controlada.log`. + +Se añade un job Chromium que ejecuta la suite completa en Linux, donde no se hereda el bloqueo local. Dependencias solo en el directorio temporal del runner; sin package.json ni cambios del core. La cobertura de todos los paquetes visuales opcionales sigue siendo más amplia que estos recorridos y no se da por completada automáticamente. + +## Cierre de los recorridos existentes + +[CI 40171975482](https://github.com/gorvet/gframe-framework/actions/runs/40171975482), commit `8c1e303`, job Chromium 121104534537: **99 casos aprobados, cero fallos, cero omisiones**, 219 segundos. Incluye instalador, gestión y selector que el entorno local impedía abrir, además del autoguardado con la medición corregida. La condición posterior del job exige explícitamente `# skipped 0`, conservando el fallo del proceso mediante pipefail. + +V-02 está cerrada para sus tres escenarios identificados. V-01 tiene ahora todos los recorridos existentes de navegador ejecutados, pero la cobertura visual universal de cada biblioteca/vista sigue sin acreditarse. V-03, V-04 y V-05 conservan su alcance: no hubo acceso a SMTP externo, cambios de pie personalizados ni despliegue o certificación del hosting. diff --git a/tests/RedisSessionIntegrationTest.php b/tests/RedisSessionIntegrationTest.php new file mode 100644 index 0000000..a86e180 --- /dev/null +++ b/tests/RedisSessionIntegrationTest.php @@ -0,0 +1,112 @@ +prefix = 'gframe-test:' . bin2hex(random_bytes(12)) . ':'; + $this->redis = new \Redis(); + self::assertTrue($this->redis->connect(getenv('GFRAME_TEST_REDIS_HOST') ?: '127.0.0.1', (int)(getenv('GFRAME_TEST_REDIS_PORT') ?: 6379), 2)); + define('DB_DEFAULT_CONNECTION', 'redis-test'); + define('DB_CONNECTIONS', ['redis-test' => ['driver' => 'sqlite', 'path' => ':memory:']]); + $database = \DatabaseManager::connection(); + self::assertInstanceOf(\PDO::class, $database); + $database->exec("CREATE TABLE users(user_id INTEGER PRIMARY KEY,status TEXT); INSERT INTO users VALUES(7,'verify'),(8,'verify'),(9,'suspended')"); + SessionRuntime::clearAuthorizationStale(); + } + + protected function tearDown(): void + { + if (isset($this->redis) && $this->redis->isConnected()) { + $keys = $this->redis->keys($this->prefix . '*'); + if ($keys !== []) $this->redis->del($keys); + $this->redis->close(); + } + \DatabaseManager::disconnect(); + SessionRuntime::clearAuthorizationStale(); + } + + public function testRevocationAcrossHandlersDoesNotResurrectSessions(): void + { + $writer = new RedisSessionHandler($this->redis, $this->prefix, 60); + foreach (['desktop', 'mobile'] as $id) { + $writer->register(7, $id); + self::assertTrue($writer->write($id, $id . '-payload')); + } + $writer->register(8, 'other'); + $writer->write('other', 'other-payload'); + $stale = new RedisSessionHandler($this->redis, $this->prefix, 60); + self::assertSame('desktop-payload', $stale->read('desktop')); + $revoker = new RedisSessionHandler($this->redis, $this->prefix, 60); + self::assertSame(2, $revoker->revokeUser(7)); + self::assertTrue($stale->write('desktop', 'late-payload')); + self::assertTrue($stale->updateTimestamp('desktop', 'late-payload')); + self::assertFalse($writer->validateId('desktop')); + self::assertFalse($writer->validateId('mobile')); + self::assertSame('', $writer->read('desktop')); + self::assertSame('other-payload', $writer->read('other')); + $writer->register(7, 'new-login'); + $writer->write('new-login', 'new-payload'); + self::assertSame('new-payload', $writer->read('new-login')); + } + + public function testBlockingAndAuthorizationVersionsUseRealRedisState(): void + { + $handler = new RedisSessionHandler($this->redis, $this->prefix, 60); + $handler->register(7, 'authorized', ['role_id' => 3, 'role_version' => 1, 'authorization_version' => 1]); + $handler->write('authorized', 'payload'); + $handler->publishRoleVersion(3, 2); + self::assertSame('payload', $handler->read('authorized')); + self::assertTrue(SessionRuntime::authorizationStale()); + SessionRuntime::clearAuthorizationStale(); + $handler->updateAuthorization(7, 'authorized', 3, 2, 1); + $handler->publishUserAuthorizationVersion(7, 2); + $handler->read('authorized'); + self::assertTrue(SessionRuntime::authorizationStale()); + SessionRuntime::clearAuthorizationStale(); + $handler->updateAuthorization(7, 'authorized', 3, 2, 2); + $handler->updateTenantAuthorization(7, 'authorized', 4, 1); + $handler->publishRoleVersion(4, 2); + $handler->read('authorized'); + self::assertTrue(SessionRuntime::authorizationStale()); + self::assertSame(1, $handler->revokeUser(7, true)); + try { + $handler->register(7, 'blocked'); + self::fail('Una cuenta bloqueada no debe registrar una sesión.'); + } catch (\RuntimeException $exception) { + self::assertSame('La cuenta no admite nuevas sesiones.', $exception->getMessage()); + } + $handler->allowUser(7); + $handler->register(7, 'allowed'); + self::assertTrue($handler->validateId('allowed')); + $this->expectException(\RuntimeException::class); + $handler->register(9, 'suspended'); + } + + public function testStoredSessionExpiresInRedis(): void + { + $handler = new RedisSessionHandler($this->redis, $this->prefix, 2); + $handler->register(7, 'short'); + $handler->write('short', 'expires'); + self::assertSame('expires', $handler->read('short')); + $deadline = microtime(true) + 5; + while ($handler->validateId('short') && microtime(true) < $deadline) usleep(50000); + self::assertFalse($handler->validateId('short')); + self::assertSame('', $handler->read('short')); + self::assertTrue($handler->write('short', 'late')); + self::assertFalse($handler->validateId('short')); + } +} diff --git a/tests/ServerRoutingTest.php b/tests/ServerRoutingTest.php index fd09b07..011dbba 100644 --- a/tests/ServerRoutingTest.php +++ b/tests/ServerRoutingTest.php @@ -110,6 +110,21 @@ public function testRealNginxRoutesRequestsAndServerErrorsToTheInstalledFramewor self::assertStringNotContainsString('request($base . '/ajax/lostpassword', 'POST', 'recovery_email=unknown%40example.test&middle_name='); self::assertSame(200, $status); + self::assertSame('fail_csrf', json_decode($body, true)['code'] ?? null); + [$status, $form, $headers] = $this->request($base . '/login/lostpassword'); + self::assertSame(200, $status); + self::assertSame(1, preg_match('/name="csrfToken" value="([^"]+)"/', $form, $token)); + self::assertSame(1, preg_match('/name="csrfTimestamp" value="([^"]+)"/', $form, $timestamp)); + $cookies = []; + foreach ($headers as $header) { + if (preg_match('/^Set-Cookie:\s*([^;]+)/i', $header, $cookie)) $cookies[] = $cookie[1]; + } + self::assertNotEmpty($cookies); + [$status, $body] = $this->request($base . '/ajax/lostpassword', 'POST', http_build_query([ + 'recovery_email' => 'unknown@example.test', 'middle_name' => '', + 'csrfToken' => $token[1], 'csrfTimestamp' => $timestamp[1], + ]), ['Cookie: ' . implode('; ', $cookies)]); + self::assertSame(200, $status); self::assertSame('recovery_requested', json_decode($body, true)['code'] ?? null); } finally { foreach (array_reverse($processes) as $process) { proc_terminate($process); proc_close($process); } diff --git a/tests/js/content-autosave.browser.test.cjs b/tests/js/content-autosave.browser.test.cjs index 898b362..d513337 100644 --- a/tests/js/content-autosave.browser.test.cjs +++ b/tests/js/content-autosave.browser.test.cjs @@ -20,6 +20,15 @@ test('Autoguardado privado real: recuperación, petición en vuelo, conflictos y const {chromium} = require('playwright'); const browser = await chromium.launch({headless: true, executablePath: process.env.GFRAME_TEST_BROWSER}); const context = await browser.newContext({viewport: {width: 1440, height: 1000}}); + await context.addInitScript(() => { + window.__autosaveRequestTimes = []; + const send = XMLHttpRequest.prototype.send; + XMLHttpRequest.prototype.send = function(body) { + const action = new URLSearchParams(body || '').get('action'); + if (action) window.__autosaveRequestTimes.push({action, time: performance.now()}); + return send.apply(this, arguments); + }; + }); const requests = [], errors = []; let hold, heldStarted, release, failSaves = 0, active = 0, maximum = 0; context.on('page', page => { page.on('pageerror', e => errors.push(e.message)); page.on('dialog', dialog => dialog.accept()); page.setDefaultTimeout(12000); }); await context.route('**/*', async route => { @@ -55,7 +64,8 @@ test('Autoguardado privado real: recuperación, petición en vuelo, conflictos y let state = await inspect(); assert.equal(state.article.content, old.article.content); assert.equal(state.draft.data.content, '

Privado inicial

'); assert.equal(await page.evaluate(() => AdminRichTextEditor.hasUnsavedChanges()), true, 'Autoguardar no equivale a publicar.'); assert.ok(requests.every(req => req.data.csrfToken === 'fixture-token')); - assert.ok(requests.find(req => req.action === 'save').time - requests[0].time >= 4800, 'Respeta el intervalo mínimo entre peticiones.'); + const attempts = await page.evaluate(() => window.__autosaveRequestTimes); + assert.ok(attempts.find(req => req.action === 'save').time - attempts[0].time >= 4800, 'Respeta el intervalo mínimo entre peticiones en el reloj del navegador.'); await page.reload(); await ready(page); await page.locator('[data-autosave-recovery]').waitFor({state:'visible'}); assert.equal(await get(page), '

Publicado

');