From b4049db68a6d49ee2c1fda565217e6a86785a33b Mon Sep 17 00:00:00 2001 From: Gianluca Bine Date: Sun, 27 Sep 2026 13:32:27 -0300 Subject: [PATCH 01/17] fix(security): escapa saidas em views e endurece o gate de XSS O `esc_url()` aceitava qualquer esquema, o que abria `javascript:` em qualquer atributo `href`. Ele passa a aceitar apenas http, https, mailto, tel, ftp e ftps, rejeitando controles e nao-ASCII. `esc_img_src()` nasce separado, e nao como opcao do `esc_url()`: as duas funcoes respondem a perguntas diferentes. Clicar num link `data:text/html` executa o conteudo, entao `href` o rejeita; ja o `` de um QR Code e um `data:image/...` legitimo, e `esc_url()` o devolveria vazio, fazendo a imagem sumir silenciosamente. Por isso `data:` so passa em `img src`, e so com o prefixo `data:image/`. O gate de XSS ganha o parser fail-closed, operador virgula, `echo $x;` solto, corte correto de statement, funcoes desconhecidas sem argumento, casts, literais e ternarios. Os 42 `` de QR Code e logo nas 27 views passaram a usar `esc_img_src()`. Corrige tambem a resolucao de linha do relatorio: a linha vinha de um `strpos()` que achava a primeira ocorrencia da expressao no arquivo, e nao do offset do regex. Com `esc_scalar($result->idOs)` em `os/visualizarOs.php` o gate apontou a linha 8, um `echo` de markup legitimo, em vez da 411. O offset do regex ja era a posicao exata. --- application/helpers/general_helper.php | 230 +++++-- application/views/arquivos/arquivos.php | 6 +- .../views/cobrancas/emails/cobranca.php | 4 +- .../views/cobrancas/visualizarCobranca.php | 4 +- .../views/conecte/emails/clientenovasenha.php | 8 +- application/views/conecte/imprimirOs.php | 8 +- application/views/conecte/imprimirVenda.php | 10 +- application/views/conecte/login.php | 2 +- application/views/conecte/minha_os.php | 2 +- application/views/conecte/nova_senha.php | 2 +- .../views/conecte/visualizar_compra.php | 4 +- application/views/conecte/visualizar_os.php | 4 +- .../views/errors/cli/error_exception.php | 12 +- application/views/errors/cli/error_php.php | 12 +- .../views/errors/html/error_exception.php | 6 +- application/views/errors/html/error_php.php | 2 +- application/views/financeiro/lancamentos.php | 2 +- .../views/garantias/imprimirGarantia.php | 2 +- .../views/garantias/imprimirGarantiaOs.php | 2 +- .../views/garantias/visualizarGarantia.php | 2 +- application/views/mapos/configurar.php | 2 +- application/views/mapos/emitente.php | 40 +- application/views/mapos/login.php | 31 +- application/views/mapos/minhaConta.php | 4 +- application/views/mapos/painel.php | 104 ++-- application/views/os/adicionarOs.php | 4 +- application/views/os/editarOs.php | 8 +- application/views/os/emails/clientenovo.php | 8 +- .../views/os/emails/clientenovonotifica.php | 4 +- application/views/os/emails/os.php | 4 +- application/views/os/imprimirOs.php | 22 +- application/views/os/imprimirOsTermica.php | 16 +- application/views/os/visualizarOs.php | 10 +- .../relatorios/imprimir/imprimirClientes.php | 2 +- .../views/relatorios/imprimir/imprimirOs.php | 2 +- .../relatorios/imprimir/imprimirProdutos.php | 2 +- .../relatorios/imprimir/imprimirServicos.php | 2 +- .../relatorios/imprimir/imprimirTopo.php | 2 +- .../relatorios/imprimir/imprimirVendas.php | 2 +- application/views/relatorios/rel_os_topo.php | 2 +- application/views/tema/rodape.php | 2 +- application/views/tema/topo.php | 2 +- application/views/usuarios/usuarios.php | 2 +- application/views/vendas/adicionarVenda.php | 4 +- application/views/vendas/imprimirVenda.php | 10 +- .../views/vendas/imprimirVendaOrcamento.php | 10 +- .../views/vendas/imprimirVendaTermica.php | 4 +- application/views/vendas/visualizarVenda.php | 14 +- tools/check_view_escaping.php | 574 +++++++++++++++--- 49 files changed, 870 insertions(+), 347 deletions(-) diff --git a/application/helpers/general_helper.php b/application/helpers/general_helper.php index ab89aa36c..d401297f4 100644 --- a/application/helpers/general_helper.php +++ b/application/helpers/general_helper.php @@ -97,43 +97,53 @@ function json_decode_legacy(string $raw): mixed } } -if (! function_exists('esc')) { +if (! function_exists('esc_scalar')) { /** - * Escapa um valor para contexto de texto HTML ou atributo entre aspas. + * Reduz um valor a um unico texto renderizavel, ou `null` se ele nao e um. * - * Use em `` dentro do corpo da pagina e dentro de - * atributos delimited por aspas (`value=""`). + * Todos os escapers de texto precisam responder a mesma pergunta — "isto + * e um valor de texto?" — e so depois cada um decide o que o meio de + * saida faz com um `null`. Centralizar a pergunta aqui e o que impede o + * sexto escaper de inventar a sexta politica: `esc()` devolve string vazia, + * `esc_msg()` devolve `""`, `esc_json()` devolve `null`, porque cada meio + * tem sua propria forma de "vazio", e essa traducao fica em uma linha por + * escaper em vez de um `if` copiado. + * + * `null`, `bool`, `array` e `object` nao sao texto. Os dois primeiros + * chegaram aqui porque o CI3 devolve `NULL` explicito para coluna + * `TEXT NULL` ausente do payload, e porque um booleano em contexto de + * texto e bug do chamador, nao algo a renderizar — se a view precisa + * mostrar "Sim"/"Nao", ela escolhe o texto no ternario, em vez de deixar o + * `esc()` adivinhar entre `''` e `'1'`. * * @param mixed $value */ - function esc($value): string + function esc_scalar($value): ?string { - if ($value === null || is_bool($value)) { - return ''; + if ($value === null || is_bool($value) || is_array($value) || is_object($value)) { + return null; } - if (is_array($value) || is_object($value)) { - return ''; - } - - return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + return (string) $value; } } -if (! function_exists('esc_js')) { +if (! function_exists('esc')) { /** - * Escapa um valor para contexto JavaScript. + * Escapa um valor para contexto de texto HTML ou atributo entre aspas. * - * Use dentro de `` fiquem neutralizados. + * Use em `` dentro do corpo da pagina e dentro de + * atributos delimitados por aspas (`value=""`). * * @param mixed $value */ - function esc_js($value): string + function esc($value): string { - return esc_json($value); + $text = esc_scalar($value); + + return $text === null + ? '' + : htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); } } @@ -141,7 +151,15 @@ function esc_js($value): string /** * Codifica um valor como JSON seguro para ser embutido em `'; + + $this->ci()->session->set_flashdata('error', $payload); + + $html = $this->callControllerRaw('Login', 'index'); + + $this->assertStringNotContainsString($payload, $html, 'O flashdata entrou na página sem escapar.'); + $this->assertStringContainsString('<script>', $html, 'O flashdata deveria aparecer escapado.'); + } + + /** + * GET login/sair: o logout precisa derrubar a sessão. + */ + public function testDestroysTheSessionOnLogout(): void + { + $this->login(); + + $this->assertTrue($this->ci()->session->userdata('logado'), 'O login não deixou a sessão ativa.'); + + $this->callControllerRaw('Login', 'sair'); + + $this->assertSame( + PHP_SESSION_NONE, + session_status(), + 'sair() deveria ter destruído a sessão nativa.' + ); + } + + public function testLeavesNoLoggedInUserForTheNextRequest(): void + { + $this->login(); + $this->callControllerRaw('Login', 'sair'); + + // Simula a requisição seguinte: quem abrir sessão nova tem de estar + // deslogado. + session_start(); + + $this->assertNull( + $this->ci()->session->userdata('logado'), + 'Uma sessão aberta depois do logout não pode vir com o usuário logado.' + ); + $this->assertNull( + $this->ci()->session->userdata('id_admin'), + 'O id_admin não pode sobreviver ao logout.' + ); + } + + public function testRedirectsToTheLoginPageOnLogout(): void + { + $this->callControllerRaw('Login', 'sair'); + + $this->assertSame( + site_url('login'), + $this->ci()->output->get_header('Location'), + 'O logout precisa mandar o usuário de volta para a tela de login.' + ); + } + + /** + * Regressão de open redirect. + * + * O Referer é controlado pelo cliente. Se o logout redirecionasse para ele, + * qualquer terceiro montaria um link que manda o usuário sair do Map-OS e + * cair num site dele. + */ + public function testIgnoresTheRefererWhenRedirecting(): void + { + $_SERVER['HTTP_REFERER'] = 'https://evil.example/pagina'; + + $this->callControllerRaw('Login', 'sair'); + + $location = (string) $this->ci()->output->get_header('Location'); + + $this->assertSame(site_url('login'), $location, 'O logout seguiu o Referer do cliente.'); + $this->assertStringNotContainsString('evil.example', $location); + } + + public function testWorksWhenNobodyIsLoggedIn(): void + { + $location = $this->callControllerRaw('Login', 'sair'); + + $this->assertSame('', $location, 'Um logout sem sessão não deveria devolver corpo.'); + $this->assertSame( + site_url('login'), + $this->ci()->output->get_header('Location'), + 'Um logout sem sessão ainda precisa redirecionar para o login.' + ); + } + + /** + * Os cabeçalhos de CORS saem pelo CI_Output, e não pelo header() do PHP. + * + * Dois motivos, e o teste existe para o segundo: + * + * - o Output só emite os cabeçalhos no fim da requisição, então o header sai + * no lugar certo, e o status 200 do cabeçalho não é afetado; + * - header() do PHP reclama em CLI, e o Whoops transforma o aviso em + * exceção. A suíte envolve a chamada do controller em + * ignoringCliHeaderWarnings(), o que faria uma volta ao header() passar + * despercebida. Afirmar que os cabeçalhos estão no CI_Output é o que + * segura essa regressão: um header() cru não aparece aqui. + */ + public function testSetsTheCorsHeadersThroughTheOutput(): void + { + $this->postLogin('admin@admin.com', 'errada'); + + $this->callController('Login', 'verificarLogin'); + + $output = $this->ci()->output; + + $this->assertSame(base_url(), $output->get_header('Access-Control-Allow-Origin')); + $this->assertSame('POST, GET, OPTIONS', $output->get_header('Access-Control-Allow-Methods')); + $this->assertSame('1000', $output->get_header('Access-Control-Max-Age')); + $this->assertSame('Content-Type', $output->get_header('Access-Control-Allow-Headers')); + } + + /** + * Uma conta sem dataExpiracao não pode ser bloqueada. + * + * dataExpiracao é date DEFAULT NULL, então null é um valor legítimo: conta + * sem expiração. O chk_date() antigo fazia new DateTime(null), que é + * depreciado no PHP 8.1 e viraria erro sob failOnDeprecation. + */ + public function testAnAccountWithoutAnExpirationDateCanLogIn(): void + { + $db = $this->ci()->db; + $original = $db->select('dataExpiracao') + ->from('usuarios') + ->where('email', 'admin@admin.com') + ->limit(1) + ->get() + ->row('dataExpiracao'); + + $db->where('email', 'admin@admin.com')->update('usuarios', ['dataExpiracao' => null]); + + try { + $this->postLogin('admin@admin.com', '123456'); + + $response = $this->callController('Login', 'verificarLogin'); + + $this->assertTrue($response['result'], 'Uma conta sem expiração deveria entrar.'); + $this->assertTrue($this->ci()->session->userdata('logado')); + } finally { + $db->where('email', 'admin@admin.com')->update('usuarios', ['dataExpiracao' => $original]); + } + } + + /** + * O redirect do logout precisa continuar sendo o que o CI3 escolheria. + * + * respond_redirect() existe só para não chamar exit(), e o exit() era a + * única diferença em relação ao redirect() do helper url. Se o cálculo do + * status divergir, o navegador passa a tratar o logout como outra coisa: + * 307/303 preservam o método do POST, 302 não. + * + * A regra é verificada em redirect_status_for() e não pelo controller, + * porque o status sai por header() nativo e em CLI não há como lê-lo: o + * CI_Output não expõe getter e xdebug_get_headers() não existe. + */ + public function testTheRedirectStatusFollowsTheCi3Rule(): void + { + // O que o redirect() do CI3 faria para cada caso. + $this->assertSame(307, redirect_status_for('GET', 'HTTP/1.1')); + $this->assertSame(303, redirect_status_for('POST', 'HTTP/1.1')); + $this->assertSame(303, redirect_status_for('DELETE', 'HTTP/1.1')); + + // Fora de HTTP/1.1 não há informação confiável, e o CI3 cai em 302. + $this->assertSame(302, redirect_status_for('POST', 'HTTP/1.0')); + $this->assertSame(302, redirect_status_for('POST', null)); + $this->assertSame(302, redirect_status_for(null, 'HTTP/1.1')); + } + + /** + * Faz login de verdade, para que sair() tenha o que destruir. + */ + private function login(): void + { + $this->postLogin('admin@admin.com', '123456'); + $this->callController('Login', 'verificarLogin'); + } +} diff --git a/application/tests/Helpers/GeneralHelperTest.php b/application/tests/Helpers/GeneralHelperTest.php new file mode 100644 index 000000000..d1e425e95 --- /dev/null +++ b/application/tests/Helpers/GeneralHelperTest.php @@ -0,0 +1,289 @@ +assertSame('', printSafeHtml(null)); + } + + public function testPrintSafeHtmlAcceptsEmptyString(): void + { + $this->assertSame('', printSafeHtml('')); + } + + public function testPrintSafeHtmlKeepsSafeMarkup(): void + { + $this->assertSame('ok', printSafeHtml('ok')); + } + + public function testPrintSafeHtmlStripsScriptTags(): void + { + $this->assertStringNotContainsString('alert(1)')); + } + + public function testPrintSafeHtmlStripsEventHandlers(): void + { + $this->assertStringNotContainsString('onerror', printSafeHtml('')); + } + + /** + * The bug this test exists for: `os.defeito` and friends are `TEXT NULL`, + * and CI3 writes an explicit NULL when the key is absent from the payload. + * A `string` parameter type turned that into a TypeError, which is a fatal + * 500 on the customer-facing OS pages rather than a cosmetic problem. + */ + public function testPrintSafeHtmlAcceptsTheResultOfANullableColumn(): void + { + $row = new stdClass(); + $row->defeito = null; + + $this->assertSame('', printSafeHtml($row->defeito)); + } + + public function testEscEncodesEveryCharacterThatBreaksOutOfMarkup(): void + { + $this->assertSame('<>"'', esc('<>"\'')); + } + + #[DataProvider('numericValues')] + public function testEscStringifiesNumbers(mixed $value, string $expected): void + { + $this->assertSame($expected, esc($value)); + } + + public static function numericValues(): array + { + return [ + 'int' => [123, '123'], + 'float' => [1.5, '1.5'], + 'zero' => [0, '0'], + ]; + } + + public function testEscScalarClassifiesValuesByWhetherTheyAreText(): void + { + $this->assertSame('123', esc_scalar(123)); + $this->assertSame('1.5', esc_scalar(1.5)); + $this->assertSame('0', esc_scalar(0)); + $this->assertSame('', esc_scalar('')); + } + + /** + * `null`, `bool`, `array` e `object` não são texto, e é `esc_scalar()` que + * decide isso — uma vez, para todos os escapers. O `null` de retorno é o + * que permite a cada meio de saída escolher o seu próprio vazio. + */ + #[DataProvider('nonTextValues')] + public function testEscScalarReturnsNullForValuesThatAreNotText(mixed $value): void + { + $this->assertNull(esc_scalar($value)); + } + + public static function nonTextValues(): array + { + return [ + 'null' => [null], + 'true' => [true], + 'false' => [false], + 'array' => [['a']], + 'empty array' => [[]], + 'object' => [new stdClass()], + ]; + } + + /** + * Comportamento real de `esc()` com valor não-texto: string vazia. Isso + * **não** é "rejeitar" o valor — o nome do teste anterior dizia isso e a + * asserção dizia o contrário. Um booleano não tem representação em + * contexto de texto, e a view que precisar mostrar "Sim"/"Nao" escolhe o + * texto no ternário; o que `esc()` faz é não inventar um `1` nem um + * `Array` na página. + */ + public function testEscRendersValuesThatAreNotTextAsEmptyString(): void + { + $this->assertSame('', esc(null)); + $this->assertSame('', esc(true)); + $this->assertSame('', esc(false)); + $this->assertSame('', esc(['a'])); + $this->assertSame('', esc(new stdClass())); + } + + /** + * O "não é texto" de `esc_scalar()` chega a meios de saída diferentes e + * cada um vira o seu próprio vazio: nada em HTML, `""` num alerta JS, + * `null` num literal JSON. É a razão de `esc_scalar()` existir em vez de + * cada escaper decidir sozinho — a decisão fica em uma linha por meio. + */ + public function testEachOutputMediumPicksItsOwnEmptyValue(): void + { + $this->assertSame('', esc(null)); + $this->assertSame('""', esc_msg(null)); + $this->assertSame('null', esc_json(null)); + } + + /** + * Estruturas **não** são o "não é texto" de `esc_scalar()`: em JSON elas + * são valores legítimos e codificam como tal. + */ + public function testEscJsonEncodesStructuresRatherThanBlankingThem(): void + { + $this->assertSame('{"a":1}', esc_json(['a' => 1])); + $this->assertSame('["a","b"]', esc_json(['a', 'b'])); + $this->assertSame('{}', esc_json(new stdClass())); + } + + public function testEscJsonKeepsTypesSoJsCodeDoesNotSilentlyChange(): void + { + $this->assertSame('true', esc_json(true)); + $this->assertSame('false', esc_json(false)); + $this->assertSame('null', esc_json(null)); + $this->assertSame('"1.5"', esc_json(1.5)); + $this->assertSame('{"a":1}', esc_json(['a' => 1])); + } + + public function testEscJsonNeutralisesScriptTerminator(): void + { + $this->assertStringNotContainsString('', esc_json('')); + } + + #[DataProvider('dangerousUrls')] + public function testEscUrlRejectsExecutableSchemes(string $url): void + { + $this->assertSame('', esc_url($url)); + } + + public static function dangerousUrls(): array + { + return [ + 'javascript' => ['javascript:alert(1)'], + 'javascript upper' => ['JaVaScRiPt:alert(1)'], + 'vbscript' => ['vbscript:msgbox(1)'], + 'data' => ['data:text/html;base64,PHNjcmlwdD4='], + 'control char smuggling' => ["java\nscript:alert(1)"], + ]; + } + + public function testEscUrlAllowsOrdinaryLinks(): void + { + $this->assertSame('https://mapos.com.br', esc_url('https://mapos.com.br')); + $this->assertSame('mailto:contato@mapos.com.br', esc_url('mailto:contato@mapos.com.br')); + } + + public function testEscUrlEscapesWhatItAllows(): void + { + $this->assertSame('https://mapos.com.br/?a=1&b=2', esc_url('https://mapos.com.br/?a=1&b=2')); + } + + public function testEscUrlStaysOutOfSrcWhereADataImageIsLegitimate(): void + { + // A razão de `esc_img_src()` existir. `data:` é perigoso num `href`, + // onde clicar executa, e é o formato normal de um `src` de imagem — os + // QR codes de pagamento são data URIs. Uma função só teria de escolher + // um dos dois, e a escolha errada some com a imagem em silêncio. + $this->assertSame('', esc_url('data:image/svg+xml;base64,AAA')); + $this->assertSame('data:image/svg+xml;base64,AAA', esc_img_src('data:image/svg+xml;base64,AAA')); + } + + #[DataProvider('dangerousImageSources')] + public function testEscImgSrcRejectsEverythingThatIsNotAnImage(string $src): void + { + $this->assertSame('', esc_img_src($src)); + } + + public static function dangerousImageSources(): array + { + return [ + 'javascript' => ['javascript:alert(1)'], + 'javascript upper' => ['JaVaScRiPt:alert(1)'], + 'vbscript' => ['vbscript:msgbox(1)'], + 'file' => ['file:///etc/passwd'], + // `data:image/` passa, mas `data:text/html` não: não há motivo para + // aceitar HTML num `src`, e aceitar tornaria a regra inexplicável. + 'data html' => ['data:text/html;base64,PHNjcmlwdD4='], + 'data with image in the path' => ['data:text/html,'], + 'control char smuggling' => ["java\nscript:alert(1)"], + ]; + } + + public function testEscImgSrcAllowsRelativeAndRemoteImages(): void + { + $this->assertSame('assets/img/User.png', esc_img_src('assets/img/User.png')); + $this->assertSame('/uploads/1.png?v=2', esc_img_src('/uploads/1.png?v=2')); + $this->assertSame('https://mapos.com.br/logo.png', esc_img_src('https://mapos.com.br/logo.png')); + } + + public function testEscImgSrcEscapesWhatItAllows(): void + { + $this->assertSame( + 'https://mapos.com.br/logo.png?a=1&b=2', + esc_img_src('https://mapos.com.br/logo.png?a=1&b=2') + ); + } + + public function testUrlHelpersReturnTheEmptyFormOfTheirOwnMedium(): void + { + // `esc_scalar()` devolve null para o que não é texto; cada meio escolhe + // o seu vazio. É a mesma regra que `testEachOutputMediumPicksItsOwnEmptyValue` + // cobre para os escapers, aplicada aos dois helpers por baixo deles. + foreach ([null, [], new stdClass(), true] as $value) { + $this->assertNull(clean_url($value)); + $this->assertNull(url_scheme('assets/img/User.png')); + $this->assertSame('', esc_url($value)); + $this->assertSame('', esc_img_src($value)); + } + + $this->assertNull(clean_url(' ')); + $this->assertSame('https', url_scheme('HTTPS://mapos.com.br')); + } + + public function testCleanUrlRefusesToLetABrowserReinterpretTheScheme(): void + { + // Espaços e barras de controle são removidos por alguns navegadores ao + // resolver a URL, o que contrabandeia o esquema. Recusar antes de decidir + // qual esquema é o ponto. + $this->assertNull(clean_url("java\tscript:alert(1)")); + $this->assertNull(clean_url("java\0script:alert(1)")); + } + + public function testEscCssDropsTheCharactersThatBreakOutOfADeclaration(): void + { + $escaped = esc_css('red;} body{display:none'); + + $this->assertStringNotContainsString(';', $escaped); + $this->assertStringNotContainsString('{', $escaped); + $this->assertStringNotContainsString('}', $escaped); + $this->assertStringNotContainsString('<', $escaped); + } + + public function testEscMsgTurnsBreakIntoNewlineAndDropsMarkup(): void + { + $this->assertSame('"linha1\nlinha2"', esc_msg('linha1
linha2')); + $this->assertSame('"so texto"', esc_msg('so texto')); + } + + public function testRedirectStatusForPreservesTheMethodOnHttp11(): void + { + $this->assertSame(307, redirect_status_for('GET', 'HTTP/1.1')); + $this->assertSame(303, redirect_status_for('POST', 'HTTP/1.1')); + $this->assertSame(303, redirect_status_for('PUT', 'HTTP/1.1')); + } + + public function testRedirectStatusForFallsBackTo302OutsideHttp11(): void + { + $this->assertSame(302, redirect_status_for('POST', 'HTTP/2')); + $this->assertSame(302, redirect_status_for('POST', null)); + $this->assertSame(302, redirect_status_for(null, 'HTTP/1.1')); + } +} diff --git a/application/tests/Schema/SchemaTest.php b/application/tests/Schema/SchemaTest.php new file mode 100644 index 000000000..93cea18e1 --- /dev/null +++ b/application/tests/Schema/SchemaTest.php @@ -0,0 +1,87 @@ + 10, 2, + * + * que o PHP lê como 'constraint' => 10 — o 2 vira elemento posicional e é + * descartado. O dbforge só usa o CONSTRAINT, então saía DECIMAL(10), e o + * MySQL trata isso como DECIMAL(10,0): dinheiro sem centavos. + * + * Quem rodou a migration entre 2012 e a correção ficou com as quatro colunas + * assim, porque nenhuma migration posterior as repara — a 20220320173741 toca + * lancamentos, os, vendas, cobrancas, produtos_os, servicos_os e + * itens_de_vendas. A 20260927111019 é a que repara. + * + * produtos.precoVenda e produtos.precoCompra importam porque + * Relatorios_model::produtosCustom() faz + * SUM(produtos.estoque * produtos.precoVenda) e filtra por + * precoVenda BETWEEN. A avaliação de estoque era arredondada para reais. + * + * @param string $table + * @param string $column + */ + #[DataProvider('moneyColumns')] + public function testMoneyColumnsKeepTheirDecimals(string $table, string $column): void + { + $this->assertSame( + 'decimal(10,2)', + $this->columnType($table, $column), + "{$table}.{$column} perdeu as casas decimais." + ); + } + + /** + * @return iterable + */ + public static function moneyColumns(): iterable + { + yield 'contas.saldo' => ['contas', 'saldo']; + yield 'produtos.precoCompra' => ['produtos', 'precoCompra']; + yield 'produtos.precoVenda' => ['produtos', 'precoVenda']; + yield 'servicos.preco' => ['servicos', 'preco']; + } + + private function columnType(string $table, string $column): string + { + $db = get_instance()->db; + + $type = $db->list_fields($table); + $this->assertContains( + $column, + $type, + "A tabela {$table} não tem a coluna {$column}." + ); + + // list_fields() só devolve os nomes. O tipo vem do information_schema, que + // é o que o SHOW CREATE TABLE do MySQL também usa como fonte. + $row = $db->query( + 'SELECT COLUMN_TYPE FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?', + [$table, $column] + )->row(); + + $this->assertNotNull($row, "{$table}.{$column} não aparece no information_schema."); + + return strtolower((string) $row->COLUMN_TYPE); + } +} diff --git a/application/tests/Support/ControllerTestCase.php b/application/tests/Support/ControllerTestCase.php new file mode 100644 index 000000000..0cc3bd9e1 --- /dev/null +++ b/application/tests/Support/ControllerTestCase.php @@ -0,0 +1,197 @@ +resetApplicationState(); + } + + protected function tearDown(): void + { + $this->resetApplicationState(); + + parent::tearDown(); + } + + protected function ci(): object + { + return get_instance(); + } + + protected function resetApplicationState(): void + { + $_SESSION = []; + $_POST = []; + $_GET = []; + $_COOKIE = []; + $_SERVER['REQUEST_METHOD'] = 'GET'; + + // Um teste que exercite um logout destrói a sessão nativa, e o bootstrap + // só a abre uma vez. Reabrindo aqui, os casos seguintes não herdam a + // sessão morta e o sess_regenerate() do próximo login não volta a emitir + // E_WARNING. + if (session_status() !== PHP_SESSION_ACTIVE) { + session_start(); + } + + $ci = get_instance(); + + if (isset($ci->output)) { + $ci->output->set_output(''); + // Cabeçalhos de uma resposta anterior (o Location de um logout, por + // exemplo) não podem vazar para o caso seguinte. + $ci->output->headers = []; + } + } + + /** + * Monta um POST de login com um token CSRF válido. + * + * A verificação do CI3 (Security::csrf_verify) compara o valor do POST com o + * do cookie por hash_equals, sem armazenamento no servidor. Como setcookie() + * é no-op em CLI, o cookie é escrito à mão. + */ + protected function postLogin(string $email, string $senha): void + { + $hash = (string) $this->ci()->security->get_csrf_hash(); + + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_COOKIE[config_item('csrf_cookie_name')] = $hash; + $_POST[config_item('csrf_token_name')] = $hash; + $_POST['email'] = $email; + $_POST['senha'] = $senha; + } + + /** + * Executa um callback ignorando o aviso de header do setcookie(). + * + * Em CLI não existe resposta HTTP, então o aviso "Cannot modify header + * information" não significa nada. Quem dispara é o Security do próprio CI3: + * o csrf_verify() chama csrf_set_cookie() por dentro, e o setcookie() dele + * abortaria a suíte, porque o Whoops converte o aviso em exceção. + * + * Nenhum código do app precisa mais deste caminho. Os cabeçalhos de CORS do + * Login::verificarLogin() saem por $this->output->set_header(), que só + * emite no fim da requisição e por isso não reclama em CLI. + * + * O handler é trocado apenas durante o callback, e só o aviso de header é + * engolido. Qualquer outro erro é repassado ao handler anterior em vez de + * ser devolvido como `false`: devolver `false` entregaria a decisão ao + * handler interno do PHP, que não é o Whoops, e um erro de verdade dentro do + * controller passaria a ser um aviso silencioso no meio de um teste verde. + * O nível também é conferido, porque a mensagem do aviso do PHP é a mesma + * em qualquer contexto e o que faz sentido silenciar é o aviso, não o + * erro. + * + * A volta usa restore_error_handler(), e não set_error_handler($previous). + * set_error_handler empilha no stack, então restaurar assim deixava duas + * entradas extras e o PHPUnit detectava isso como handler vazado do teste, + * marcando todos os casos como risky. restore_error_handler() desempilha + * exatamente o que foi empilhado. + */ + protected function ignoringCliHeaderWarnings(callable $callback): mixed + { + $previous = set_error_handler( + static function (int $level, string $message) use (&$previous): bool { + if ($level === E_WARNING && str_contains($message, 'Cannot modify header information')) { + return true; + } + + return $previous !== null ? (bool) $previous($level, $message) : false; + } + ); + + try { + return $callback(); + } finally { + restore_error_handler(); + } + } + + /** + * Chama um controller diretamente e devolve o corpo da resposta em JSON. + * + * Instanciar o controller, e não despachar a requisição, é o que torna o + * teste in-process: despachar exigiria reiniciar o ciclo de vida do CI3 a + * cada caso, e o framework não é reentrante. + */ + protected function callController(string $class, string $method): array + { + $body = $this->callControllerRaw($class, $method); + + $decoded = json_decode($body, true); + + $this->assertIsArray( + $decoded, + "A resposta de {$class}::{$method}() não é JSON válido: " . var_export($body, true) + ); + + return $decoded; + } + + /** + * Igual a callController(), mas devolve o corpo cru em vez de decodificar. + * + * Para o que não responde JSON, como uma view renderizada. + */ + protected function callControllerRaw(string $class, string $method): string + { + $ci = $this->ci(); + $ci->output->set_output(''); + + // O CI3 não tem autoloader de controllers: eles são carregados por + // caminho de arquivo, pelo Loader. + $path = APPPATH . 'controllers' . DIRECTORY_SEPARATOR . $class . '.php'; + + if (! is_file($path)) { + $this->fail("Controller não encontrado: {$path}"); + } + + require_once $path; + + $this->assertTrue(class_exists($class), "A classe {$class} não foi declarada por {$path}."); + + TestApplication::resetSharedState(); + + $controller = new $class(); + + ob_start(); + + try { + $this->ignoringCliHeaderWarnings(static function () use ($controller, $method): void { + $controller->{$method}(); + }); + } finally { + $printed = ob_get_clean(); + } + + // A view pode escrever direto na saída, enquanto um controller JSON usa + // $this->output. Nos dois casos o corpo é o que estiver disponível. + $body = $ci->output->get_output(); + + if ($body === '' || $body === null) { + $body = $printed; + } + + return (string) $body; + } +} diff --git a/application/tests/Support/TestApplication.php b/application/tests/Support/TestApplication.php new file mode 100644 index 000000000..b39288ac0 --- /dev/null +++ b/application/tests/Support/TestApplication.php @@ -0,0 +1,192 @@ + true, + 'hooks' => true, + 'config' => true, + 'log' => true, + 'utf8' => true, + 'uri' => true, + 'router' => true, + 'output' => true, + 'security' => true, + 'input' => true, + 'lang' => true, + 'loader' => true, + ]; + + /** + * Sobe o app do CI3 no ambiente de testes. Só pode ser chamado uma vez por + * processo. + * + * O CI3 não foi feito para ser reentrante: incluir o index.php executa o + * ciclo completo da requisição. Por isso o app é inicializado uma única vez e + * quem vier depois instancia os controllers diretamente sobre a instância + * viva. + * + * @param string|null $database Banco a apontar antes do boot. Null usa o + * que o bootstrap já definiu. check-schema-parity + * precisa do seu, porque ele constrói um segundo + * banco — o de migrações — e compara com o + * que veio do banco.sql. Passar o nome por + * parâmetro deixa a dependência visível; o + * script escrevendo em $_ENV por conta própria + * dependia de a ordem das linhas ficar certa. + */ + public static function boot(?string $database = null): void + { + if ($database !== null) { + $_ENV['DB_DATABASE'] = $database; + } + + $sessionPath = sys_get_temp_dir() . '/mapos-test-sessions'; + + if (! is_dir($sessionPath)) { + mkdir($sessionPath, 0700, true); + } + + ini_set('session.save_path', $sessionPath); + ini_set('session.use_cookies', '0'); + ini_set('session.cache_limiter', ''); + + // Nada pode imprimir antes disto: qualquer saída marca os headers como + // enviados e o session_start() passa a reclamar. + session_start(); + + // Sob CLI o Codeigniter tira a rota de $_SERVER['argv'], e não do + // REQUEST_URI (ver URI::_set_uri_string). Sem isto, opções do próprio + // PHPUnit viram rota, caem em 404 e o exit() do show_404() mata o + // processo. + $_SERVER['argv'] = ['index.php']; + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_SERVER['REMOTE_ADDR'] = '127.0.0.1'; + $_SERVER['SERVER_NAME'] = 'localhost'; + $_SERVER['HTTP_HOST'] ??= 'localhost'; + $_SERVER['SCRIPT_NAME'] ??= '/index.php'; + + // O boot despacha o controller inerte e a saída é enviada em + // Codeigniter.php::_display(). Descartar para não poluir a saída. + ob_start(); + require TestDatabase::rootPath() . '/index.php'; + ob_end_clean(); + + // Estado limpo para quem vier a seguir; cada caso recomeça daqui. + $_SESSION = []; + } + + /** + * Mantém o registro de classes do CI3 consistente antes de construir outro + * controller. + * + * O CI3 não é reentrante, e isto só aparece quando o processo constrói mais + * de um controller. No primeiro, CI_Controller::__construct percorre + * is_loaded() ANTES de $this->load->initialize() rodar o autoloader, então o + * registro ainda está vazio e nada quebra. Ao construir um segundo, o + * registro já está cheio, e o foreach chama load_class() com o nome cru. + * + * Isso estoura para qualquer classe que o Loader tenha instanciado, e não o + * load_class(), que só procura um libraries/.php em APPPATH e + * BASEPATH: + * - Session: é a do core, system/libraries/Session/Session.php, classe + * CI_Session. O diretório não é um arquivo, e load_class() não sonda + * diretório nenhum; + * - Permission: existe em application/libraries/Permission.php, mas é + * declarada sem o prefixo CI_ que load_class() pressupõe; + * - Form_validation: é CI_Form_validation e o arquivo existe, mas quem + * injeta $this->CI no construtor é o Loader. Via load_class() ela chega + * nula. + * + * As classes core não têm esse problema: o Codeigniter.php as carrega pelo + * load_class(), que as memoriza no cache estático da função, e o segundo + * controller as recebe do mesmo jeito. + * + * O mesmo vale para models, por um motivo diferente. Loader::model() + * (Loader.php:268) faz: + * + * if (in_array($name, $this->_ci_models, TRUE)) { return $this; } + * + * O retorno sai antes de $CI->$name = $model, ou seja, sem anexar nada ao + * controller. Como o Loader é singleton e a lista sobrevive entre os casos, + * do segundo controller em diante todo model já consta como "carregado" e + * $this->Mapos_model fica null. + */ + public static function resetSharedState(): void + { + $loaded = &is_loaded(); + + foreach (array_keys($loaded) as $name) { + if (! isset(self::CI_CORE_CLASSES[$name])) { + unset($loaded[$name]); + } + } + + // _ci_models é protected, então a closure é amarrada ao escopo do Loader + // para poder escrever na propriedade. + $resetModels = function (): void { + $this->_ci_models = []; + }; + + $resetModels->call(get_instance()->load); + } + + /** + * Roda a cadeia de migrações e devolve o erro, ou string vazia se deu certo. + * + * O Tools::migrate() era chamado em dois scripts com a mesma sequência ao + * redor — reset do estado reentrante, ob_start, migrate, ob_end, leitura do + * error_string(). Essa sequência é o que permite rodar Tools no mesmo + * processo em que o app já foi bootado, então ela não é um detalhe de cada + * script: ficar aqui é o que impede as duas cópias de divergirem de novo. + * + * Exige boot() antes, porque instancia Tools, que é um CI_Controller. + */ + public static function migrate(): string + { + require_once APPPATH . 'controllers/Tools.php'; + + self::resetSharedState(); + + $tools = new \Tools(); + + // O Migrator imprime o relatório do que rodou; é saída, não resultado. + // + // O `finally` não é preciosismo: `migrate()` é justamente a chamada que + // estoura quando a cadeia de migrações está quebrada, que é a falha + // que este método existe para diagnosticar. Sem o `finally`, a exceção + // deixaria o buffer aberto e todo o PHPUnit passaria a escrever ali + // dentro — o relatório de erro sumiria junto, e uma migration + // quebrada viraria um travamento silencioso em vez de um erro legível. + ob_start(); + + try { + $tools->migrate(); + } finally { + ob_end_clean(); + } + + return get_instance()->migration->error_string(); + } +} diff --git a/application/tests/Support/TestApplicationTest.php b/application/tests/Support/TestApplicationTest.php new file mode 100644 index 000000000..2bda92113 --- /dev/null +++ b/application/tests/Support/TestApplicationTest.php @@ -0,0 +1,51 @@ +assertSame( + $before, + ob_get_level(), + 'migrate() não pode deixar um output buffer aberto' + ); + } +} diff --git a/application/tests/Support/TestDatabase.php b/application/tests/Support/TestDatabase.php new file mode 100644 index 000000000..56e8311ac --- /dev/null +++ b/application/tests/Support/TestDatabase.php @@ -0,0 +1,191 @@ +safeLoad(); + + // config.php lê estas duas chaves sem valor padrão, então a ausência + // delas gera aviso no log e deixa a encryption_key vazia. Preenchidas + // só quando faltam, para um .env de desenvolvimento continuar mandando. + $_ENV['APP_ENCRYPTION_KEY'] ??= 'mapos-testing-key'; + $_ENV['GLOBAL_XSS_FILTERING'] ??= 'false'; + + return new self( + $hostname, + $port, + $database, + self::env('MAPOS_TEST_DB_USERNAME', $_ENV['DB_USERNAME'] ?? 'root'), + self::env('MAPOS_TEST_DB_PASSWORD', $_ENV['DB_PASSWORD'] ?? ''), + static::rootPath() + ); + } + + public function database(): string + { + return $this->database; + } + + public function dsn(?string $database = null): string + { + $dsn = "mysql:host={$this->hostname};port={$this->port};charset=utf8mb4"; + + return $database === null ? $dsn : $dsn . ";dbname={$database}"; + } + + public function pdo(?string $database = null): PDO + { + try { + return new PDO($this->dsn($database), $this->username, $this->password, [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]); + } catch (PDOException $e) { + throw new RuntimeException( + "Falha ao conectar em {$this->hostname}:{$this->port}: {$e->getMessage()}", + 0, + $e + ); + } + } + + /** + * Apaga e recria um banco, e devolve a conexão já apontada para ele. + * + * O DROP é o que torna a montagem do banco de testes realmente idempotente. + * O banco.sql usa CREATE TABLE IF NOT EXISTS em todas as 28 tabelas, então + * sem o DROP a segunda execução herda tudo que a primeira deixou para trás. + */ + public function recreate(string $database): PDO + { + static::assertDatabaseNameIsSafe($database); + + $admin = $this->pdo(); + + $admin->exec("DROP DATABASE IF EXISTS `{$database}`"); + $admin->exec("CREATE DATABASE `{$database}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); + + return $this->pdo($database); + } + + public function drop(string $database): void + { + static::assertDatabaseNameIsSafe($database); + + $this->pdo()->exec("DROP DATABASE IF EXISTS `{$database}`"); + } + + + + private static function env(string $name, string $default): string + { + $value = getenv($name); + + // getenv() devolvendo "0" é um valor legítimo; só o false de "não existe" + // cai no padrão. + return $value === false || $value === '' ? $default : $value; + } +} diff --git a/application/tests/Support/TestFixtures.php b/application/tests/Support/TestFixtures.php new file mode 100644 index 000000000..d365c3ef0 --- /dev/null +++ b/application/tests/Support/TestFixtures.php @@ -0,0 +1,119 @@ +run(); + $installed[] = $seed; + } + + $installed = array_merge($installed, self::addExtraUsers()); + } finally { + ob_end_clean(); + } + + return $installed; + } + + /** + * Os dois caminhos de recusa que o Login precisa cobrir. + * + * inativo@admin.com tem situacao 0, então nem chega a ser autenticado. + * expirado@admin.com tem dataExpiracao no passado, então passa pela consulta + * e morre no chk_date(). + * + * A senha é copiada da linha do administrador que a seed acabou de gravar, em + * vez de repetir o hash aqui. Uma segunda cópia do hash seria um contrato + * Parallel: mudar a senha na seed deixaria estes dois usuários com a senha + * antiga e o teste falharia com um "Access denied" sem explicar a causa. + * + * @return string[] + */ + private static function addExtraUsers(): array + { + $db = get_instance()->db; + $senha = $db->select('senha') + ->from('usuarios') + ->where('idUsuarios', 1) + ->limit(1) + ->get() + ->row('senha'); + + if ($senha === null) { + throw new \RuntimeException( + 'A seed Usuarios não gravou o administrador, então a senha dos ' + . 'usuários de teste não tem de onde vir.' + ); + } + + $comum = [ + 'rg' => 'MG-25.502.560', + 'cep' => '01024-900', + 'rua' => 'R. Cantareira', + 'numero' => '306', + 'bairro' => 'Centro Histórico de São Paulo', + 'cidade' => 'São Paulo', + 'estado' => 'SP', + 'senha' => $senha, + 'telefone' => '0000-0000', + 'celular' => '', + 'dataCadastro' => '2018-09-09', + 'permissoes_id' => 1, + ]; + + $db->insert('usuarios', $comum + [ + 'nome' => 'Inativo', + 'cpf' => '517.565.356-38', + 'email' => 'inativo@admin.com', + 'situacao' => 0, + 'dataExpiracao' => '2030-01-01', + ]); + + $db->insert('usuarios', $comum + [ + 'nome' => 'Expirado', + 'cpf' => '517.565.356-37', + 'email' => 'expirado@admin.com', + 'situacao' => 1, + 'dataExpiracao' => '2020-01-01', + ]); + + return ['inativo@admin.com', 'expirado@admin.com']; + } + + private static function load(string $seed): void + { + // A Seeder vive em libraries/ e as seeds em database/seeds/, e nenhuma das + // duas entra no autoload do composer: o CI3 carrega por caminho. + require_once APPPATH . 'libraries/Seeder.php'; + require_once APPPATH . "database/seeds/{$seed}.php"; + } +} diff --git a/application/tests/bin/check-schema-parity.php b/application/tests/bin/check-schema-parity.php new file mode 100644 index 000000000..8a5e57b13 --- /dev/null +++ b/application/tests/bin/check-schema-parity.php @@ -0,0 +1,154 @@ + column => type. + * + * Só a definição da coluna entra na comparação. Charset, collation e a ordem das + * colunas mudam entre o dump e o dbforge sem significar divergência de schema, e + * compará-las só produziria ruído. + */ +$readSchema = static function (PDO $pdo): array { + $schema = []; + + foreach ($pdo->query('SHOW FULL TABLES WHERE Table_type = "BASE TABLE"')->fetchAll() as $table) { + $name = current($table); + + // A tabela de controle do Migrator não faz parte do schema da aplicação. + if ($name === 'migrations') { + continue; + } + + foreach ($pdo->query("SHOW COLUMNS FROM `{$name}`")->fetchAll() as $column) { + $schema[$name][$column['Field']] = $column['Type']; + } + } + + ksort($schema); + + return $schema; +}; + +// Os nomes derivados também precisam terminar em '_test', porque o mesmo guard +// protege o DROP. +$dumpName = 'mapos_parity_dump_test'; +$migrationName = 'mapos_parity_migration_test'; + +// --- Lado 1: o que o install/do_install.php produz, a partir do banco.sql --- + +$dumpPdo = $test->recreate($dumpName); +$dumpPdo->exec((string) file_get_contents($rootPath . '/banco.sql')); +$dumpSchema = $readSchema($dumpPdo); + +// --- Lado 2: o que a cadeia de migrações produz --- + +// O banco precisa existir antes do index.php subir: o autoloader de 'database' +// conecta durante o boot. +$test->recreate($migrationName); + +TestApplication::boot($migrationName); + +$migrationError = TestApplication::migrate(); +$migrationSchema = $readSchema($test->pdo($migrationName)); + +$test->drop($dumpName); +$test->drop($migrationName); + +if ($migrationError !== '') { + fwrite(STDERR, "As migrações falharam: {$migrationError}\n"); + exit(1); +} + +/** + * Diferença entre os dois schemas, como lista de linhas legíveis. + * + * Percorre a união dos nomes de tabela e, dentro de cada uma, a união das + * colunas, de modo que "só no banco.sql" e "só nas migrações" saem do mesmo + * laço. Antes eram dois laços quase idênticos, e o segundo não tinha o laço + * interno de colunas: uma coluna removida de um dos lados aparecia como + * "coluna só no banco.sql" num caminho e ficava invisível no outro, dependendo + * de qual schema fosse percorrido por último. + */ +$diffSchema = static function (array $dump, array $migrations): array { + $diff = []; + + foreach (array_unique([...array_keys($dump), ...array_keys($migrations)]) as $table) { + if (! isset($migrations[$table])) { + $diff[] = "tabela só no banco.sql: {$table}"; + + continue; + } + + if (! isset($dump[$table])) { + $diff[] = "tabela só nas migrações: {$table}"; + + continue; + } + + $dumpColumns = $dump[$table]; + $migrationColumns = $migrations[$table]; + + foreach (array_unique([...array_keys($dumpColumns), ...array_keys($migrationColumns)]) as $column) { + if (! isset($migrationColumns[$column])) { + $diff[] = "coluna só no banco.sql: {$table}.{$column} {$dumpColumns[$column]}"; + } elseif (! isset($dumpColumns[$column])) { + $diff[] = "coluna só nas migrações: {$table}.{$column} {$migrationColumns[$column]}"; + } elseif (strcasecmp($dumpColumns[$column], $migrationColumns[$column]) !== 0) { + $diff[] = sprintf( + 'tipo divergente: %-24s banco.sql=%-18s migracoes=%s', + "{$table}.{$column}", + $dumpColumns[$column], + $migrationColumns[$column] + ); + } + } + } + + return $diff; +}; + +$diff = $diffSchema($dumpSchema, $migrationSchema); + +if ($diff === []) { + fwrite(STDOUT, sprintf( + "Schema em paridade: %d tabelas conferidas entre banco.sql e a cadeia de migrações.\n", + count($dumpSchema) + )); + exit(0); +} + +sort($diff); + +fwrite(STDERR, sprintf("Schema divergente (%d diferenças):\n\n", count($diff))); + +foreach ($diff as $line) { + fwrite(STDERR, " {$line}\n"); +} + +fwrite(STDERR, "\n"); + +exit(1); diff --git a/application/tests/bin/setup-db.php b/application/tests/bin/setup-db.php new file mode 100644 index 000000000..3ae0b2669 --- /dev/null +++ b/application/tests/bin/setup-db.php @@ -0,0 +1,68 @@ +database(); + +$echo = static function (string $message): void { + fwrite(STDOUT, $message . PHP_EOL); +}; + +$echo("Montando o banco '{$database}' a partir das migrações."); + +// O banco precisa existir antes do index.php subir: o autoloader de 'database' +// conecta durante o boot. O DROP é o que torna isto realmente idempotente — sem +// ele, a segunda execução herda tudo que a primeira deixou no schema. +$test->recreate($database); + +TestApplication::boot(); + +$migrationError = TestApplication::migrate(); + +if ($migrationError !== '') { + fwrite(STDERR, "As migrações falharam: {$migrationError}\n"); + exit(1); +} + +$echo('Esquema criado pelas migrações.' . PHP_EOL); + +$installed = TestFixtures::install(); + +$echo(sprintf('Reference data: %s.', implode(', ', $installed))); + +// Confere o resultado, para uma montagem quebrada falhar aqui e não no meio de +// um teste com uma mensagem sem pistas. +$usuarios = get_instance()->db->count_all_results('usuarios'); + +if ($usuarios !== 3) { + fwrite(STDERR, "Esperava 3 usuários e encontrei {$usuarios}.\n"); + exit(1); +} + +$echo('3 usuários disponíveis: admin@admin.com, inativo@admin.com, expirado@admin.com (senha 123456).'); diff --git a/application/tests/bootstrap.php b/application/tests/bootstrap.php new file mode 100644 index 000000000..488a7b05d --- /dev/null +++ b/application/tests/bootstrap.php @@ -0,0 +1,33 @@ + + + 403 Forbidden + + + +

Directory access is forbidden.

+ + + \ No newline at end of file diff --git a/composer.json b/composer.json index ba1c7923a..a2bea2c9b 100644 --- a/composer.json +++ b/composer.json @@ -19,7 +19,8 @@ "require-dev": { "friendsofphp/php-cs-fixer": "^3.93.0", "symfony/var-dumper": "^6.4.32", - "fakerphp/faker": "^1.24.1" + "fakerphp/faker": "^1.24.1", + "phpunit/phpunit": "^12.5" }, "scripts": { "format": [ @@ -33,6 +34,16 @@ ], "xss:baseline": [ "@php tools/check_view_escaping.php --update-baseline" + ], + "test": [ + "@php application/tests/bin/setup-db.php", + "@php application/vendor/bin/phpunit" + ], + "test:db": [ + "@php application/tests/bin/setup-db.php" + ], + "check:parity": [ + "@php application/tests/bin/check-schema-parity.php" ] }, "config": { @@ -43,5 +54,10 @@ "Libraries\\": "application/libraries" } }, + "autoload-dev": { + "psr-4": { + "Tests\\": "application/tests/" + } + }, "type": "project" } diff --git a/composer.lock b/composer.lock index 7bb0e6350..7bf7505be 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "244f4d5535915d46d4baa894dce57bcb", + "content-hash": "2cc03c37302535fcc725b896fe88d270", "packages": [ { "name": "chillerlan/php-qrcode", @@ -1207,20 +1207,20 @@ }, { "name": "myclabs/deep-copy", - "version": "1.13.4", + "version": "1.14.0", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", - "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", "shasum": "" }, "require": { - "php": "^7.1 || ^8.0" + "php": "^8.0" }, "conflict": { "doctrine/collections": "<1.6.8", @@ -1255,15 +1255,15 @@ ], "support": { "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + "source": "https://github.com/myclabs/DeepCopy/tree/1.14.0" }, "funding": [ { - "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", - "type": "tidelift" + "url": "https://github.com/mnapoli", + "type": "github" } ], - "time": "2025-08-01T08:46:24+00:00" + "time": "2026-08-11T10:17:44+00:00" }, { "name": "paragonie/random_compat", @@ -2860,659 +2860,2132 @@ "time": "2026-01-23T17:33:21+00:00" }, { - "name": "psr/container", - "version": "2.0.2", + "name": "nikic/php-parser", + "version": "v5.9.0", "source": { "type": "git", - "url": "https://github.com/php-fig/container.git", - "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", - "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/9e33da9553fe7786f0962b35f4e4ecf01be89def", + "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def", "shasum": "" }, "require": { - "php": ">=7.4.0" + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" }, + "bin": [ + "bin/php-parse" + ], "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0.x-dev" + "dev-master": "5.x-dev" } }, "autoload": { "psr-4": { - "Psr\\Container\\": "src/" + "PhpParser\\": "lib/PhpParser" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "name": "Nikita Popov" } ], - "description": "Common Container Interface (PHP FIG PSR-11)", - "homepage": "https://github.com/php-fig/container", + "description": "A PHP parser written in PHP", "keywords": [ - "PSR-11", - "container", - "container-interface", - "container-interop", - "psr" + "parser", + "php" ], "support": { - "issues": "https://github.com/php-fig/container/issues", - "source": "https://github.com/php-fig/container/tree/2.0.2" + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.9.0" }, - "time": "2021-11-05T16:47:00+00:00" + "time": "2026-09-13T18:51:52+00:00" }, { - "name": "psr/event-dispatcher", - "version": "1.0.0", + "name": "phar-io/manifest", + "version": "2.0.4", "source": { "type": "git", - "url": "https://github.com/php-fig/event-dispatcher.git", - "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", - "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", "shasum": "" }, "require": { - "php": ">=7.2.0" + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0.x-dev" + "dev-master": "2.0.x-dev" } }, "autoload": { - "psr-4": { - "Psr\\EventDispatcher\\": "src/" - } + "classmap": [ + "src/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "PHP-FIG", - "homepage": "http://www.php-fig.org/" + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" } ], - "description": "Standard interfaces for event handling.", - "keywords": [ - "events", - "psr", - "psr-14" - ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", "support": { - "issues": "https://github.com/php-fig/event-dispatcher/issues", - "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" }, - "time": "2019-01-08T18:20:26+00:00" + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" }, { - "name": "react/cache", - "version": "v1.2.0", + "name": "phar-io/version", + "version": "3.2.1", "source": { "type": "git", - "url": "https://github.com/reactphp/cache.git", - "reference": "d47c472b64aa5608225f47965a484b75c7817d5b" + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/cache/zipball/d47c472b64aa5608225f47965a484b75c7817d5b", - "reference": "d47c472b64aa5608225f47965a484b75c7817d5b", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", "shasum": "" }, "require": { - "php": ">=5.3.0", - "react/promise": "^3.0 || ^2.0 || ^1.1" - }, - "require-dev": { - "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35" + "php": "^7.2 || ^8.0" }, "type": "library", "autoload": { - "psr-4": { - "React\\Cache\\": "src/" - } + "classmap": [ + "src/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Christian Lück", - "email": "christian@clue.engineering", - "homepage": "https://clue.engineering/" - }, - { - "name": "Cees-Jan Kiewiet", - "email": "reactphp@ceesjankiewiet.nl", - "homepage": "https://wyrihaximus.net/" + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" }, { - "name": "Jan Sorgalla", - "email": "jsorgalla@gmail.com", - "homepage": "https://sorgalla.com/" + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" }, { - "name": "Chris Boden", - "email": "cboden@gmail.com", - "homepage": "https://cboden.dev/" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" } ], - "description": "Async, Promise-based cache interface for ReactPHP", - "keywords": [ - "cache", - "caching", - "promise", - "reactphp" - ], + "description": "Library for handling version information and constraints", "support": { - "issues": "https://github.com/reactphp/cache/issues", - "source": "https://github.com/reactphp/cache/tree/v1.2.0" + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" }, - "funding": [ - { - "url": "https://opencollective.com/reactphp", - "type": "open_collective" - } - ], - "time": "2022-11-30T15:59:55+00:00" + "time": "2022-02-21T01:04:05+00:00" }, { - "name": "react/child-process", - "version": "v0.6.7", + "name": "phpunit/php-code-coverage", + "version": "12.5.7", "source": { "type": "git", - "url": "https://github.com/reactphp/child-process.git", - "reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3" + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "186dab580576598076de6818596d12b61801880e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/child-process/zipball/970f0e71945556422ee4570ccbabaedc3cf04ad3", - "reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/186dab580576598076de6818596d12b61801880e", + "reference": "186dab580576598076de6818596d12b61801880e", "shasum": "" }, "require": { - "evenement/evenement": "^3.0 || ^2.0 || ^1.0", - "php": ">=5.3.0", - "react/event-loop": "^1.2", - "react/stream": "^1.4" + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.7.0", + "php": ">=8.3", + "phpunit/php-text-template": "^5.0", + "sebastian/complexity": "^5.0", + "sebastian/environment": "^8.1.2", + "sebastian/lines-of-code": "^4.0.1", + "sebastian/version": "^6.0", + "theseer/tokenizer": "^2.0.1" }, "require-dev": { - "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", - "react/socket": "^1.16", - "sebastian/environment": "^5.0 || ^3.0 || ^2.0 || ^1.0" + "phpunit/phpunit": "^12.5.28" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" }, "type": "library", - "autoload": { - "psr-4": { - "React\\ChildProcess\\": "src/" + "extra": { + "branch-alias": { + "dev-main": "12.5.x-dev" } }, + "autoload": { + "classmap": [ + "src/" + ] + }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Christian Lück", - "email": "christian@clue.engineering", - "homepage": "https://clue.engineering/" - }, - { - "name": "Cees-Jan Kiewiet", - "email": "reactphp@ceesjankiewiet.nl", - "homepage": "https://wyrihaximus.net/" - }, - { - "name": "Jan Sorgalla", - "email": "jsorgalla@gmail.com", - "homepage": "https://sorgalla.com/" - }, - { - "name": "Chris Boden", - "email": "cboden@gmail.com", - "homepage": "https://cboden.dev/" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "description": "Event-driven library for executing child processes with ReactPHP.", + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", "keywords": [ - "event-driven", - "process", - "reactphp" + "coverage", + "testing", + "xunit" ], "support": { - "issues": "https://github.com/reactphp/child-process/issues", - "source": "https://github.com/reactphp/child-process/tree/v0.6.7" + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.7" }, "funding": [ { - "url": "https://opencollective.com/reactphp", - "type": "open_collective" + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" } ], - "time": "2025-12-23T15:25:20+00:00" + "time": "2026-06-01T13:24:19+00:00" }, { - "name": "react/dns", - "version": "v1.14.0", + "name": "phpunit/php-file-iterator", + "version": "6.0.2", "source": { "type": "git", - "url": "https://github.com/reactphp/dns.git", - "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3" + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "a248d1640ab059b075f53a2ef0f9856e864e06b5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/dns/zipball/7562c05391f42701c1fccf189c8225fece1cd7c3", - "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/a248d1640ab059b075f53a2ef0f9856e864e06b5", + "reference": "a248d1640ab059b075f53a2ef0f9856e864e06b5", "shasum": "" }, "require": { - "php": ">=5.3.0", - "react/cache": "^1.0 || ^0.6 || ^0.5", - "react/event-loop": "^1.2", - "react/promise": "^3.2 || ^2.7 || ^1.2.1" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", - "react/async": "^4.3 || ^3 || ^2", - "react/promise-timer": "^1.11" + "phpunit/phpunit": "^12.5.33" }, "type": "library", - "autoload": { - "psr-4": { - "React\\Dns\\": "src/" + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" } }, + "autoload": { + "classmap": [ + "src/" + ] + }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Christian Lück", - "email": "christian@clue.engineering", - "homepage": "https://clue.engineering/" - }, - { - "name": "Cees-Jan Kiewiet", - "email": "reactphp@ceesjankiewiet.nl", - "homepage": "https://wyrihaximus.net/" - }, - { - "name": "Jan Sorgalla", - "email": "jsorgalla@gmail.com", - "homepage": "https://sorgalla.com/" - }, - { - "name": "Chris Boden", - "email": "cboden@gmail.com", - "homepage": "https://cboden.dev/" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "description": "Async DNS resolver for ReactPHP", + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", "keywords": [ - "async", - "dns", - "dns-resolver", - "reactphp" + "filesystem", + "iterator" ], "support": { - "issues": "https://github.com/reactphp/dns/issues", - "source": "https://github.com/reactphp/dns/tree/v1.14.0" + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/6.0.2" }, "funding": [ { - "url": "https://opencollective.com/reactphp", - "type": "open_collective" + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" } ], - "time": "2025-11-18T19:34:28+00:00" + "time": "2026-08-25T14:40:53+00:00" }, { - "name": "react/event-loop", - "version": "v1.6.0", + "name": "phpunit/php-invoker", + "version": "6.0.0", "source": { "type": "git", - "url": "https://github.com/reactphp/event-loop.git", - "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a" + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/event-loop/zipball/ba276bda6083df7e0050fd9b33f66ad7a4ac747a", - "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/12b54e689b07a25a9b41e57736dfab6ec9ae5406", + "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406", "shasum": "" }, "require": { - "php": ">=5.3.0" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + "ext-pcntl": "*", + "phpunit/phpunit": "^12.0" }, "suggest": { - "ext-pcntl": "For signal handling support when using the StreamSelectLoop" + "ext-pcntl": "*" }, "type": "library", - "autoload": { - "psr-4": { - "React\\EventLoop\\": "src/" + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" } }, + "autoload": { + "classmap": [ + "src/" + ] + }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Christian Lück", - "email": "christian@clue.engineering", - "homepage": "https://clue.engineering/" - }, - { - "name": "Cees-Jan Kiewiet", - "email": "reactphp@ceesjankiewiet.nl", - "homepage": "https://wyrihaximus.net/" - }, + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/6.0.0" + }, + "funding": [ { - "name": "Jan Sorgalla", - "email": "jsorgalla@gmail.com", - "homepage": "https://sorgalla.com/" - }, + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:58:58+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/e1367a453f0eda562eedb4f659e13aa900d66c53", + "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ { - "name": "Chris Boden", - "email": "cboden@gmail.com", - "homepage": "https://cboden.dev/" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "description": "ReactPHP's core reactor event loop that libraries can use for evented I/O.", + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", "keywords": [ - "asynchronous", - "event-loop" + "template" ], "support": { - "issues": "https://github.com/reactphp/event-loop/issues", - "source": "https://github.com/reactphp/event-loop/tree/v1.6.0" + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/5.0.0" }, "funding": [ { - "url": "https://opencollective.com/reactphp", - "type": "open_collective" + "url": "https://github.com/sebastianbergmann", + "type": "github" } ], - "time": "2025-11-17T20:46:25+00:00" + "time": "2025-02-07T04:59:16+00:00" }, { - "name": "react/promise", - "version": "v3.3.0", + "name": "phpunit/php-timer", + "version": "8.0.0", "source": { "type": "git", - "url": "https://github.com/reactphp/promise.git", - "reference": "23444f53a813a3296c1368bb104793ce8d88f04a" + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/promise/zipball/23444f53a813a3296c1368bb104793ce8d88f04a", - "reference": "23444f53a813a3296c1368bb104793ce8d88f04a", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", + "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", "shasum": "" }, "require": { - "php": ">=7.1.0" + "php": ">=8.3" }, "require-dev": { - "phpstan/phpstan": "1.12.28 || 1.4.10", - "phpunit/phpunit": "^9.6 || ^7.5" + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/8.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:59:38+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "12.5.36", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "ddb775588a0ea447d1c9b190c0f29c174ce81d65" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/ddb775588a0ea447d1c9b190c0f29c174ce81d65", + "reference": "ddb775588a0ea447d1c9b190c0f29c174ce81d65", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-filter": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.14.0", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.3", + "phpunit/php-code-coverage": "^12.5.7", + "phpunit/php-file-iterator": "^6.0.2", + "phpunit/php-invoker": "^6.0.0", + "phpunit/php-text-template": "^5.0.0", + "phpunit/php-timer": "^8.0.0", + "sebastian/cli-parser": "^4.2.1", + "sebastian/comparator": "^7.1.8", + "sebastian/diff": "^7.0.1", + "sebastian/environment": "^8.1.2", + "sebastian/exporter": "^7.0.3", + "sebastian/global-state": "^8.0.3", + "sebastian/object-enumerator": "^7.0.0", + "sebastian/recursion-context": "^7.0.1", + "sebastian/type": "^6.0.4", + "sebastian/version": "^6.0.0", + "staabm/side-effects-detector": "^1.0.5" }, + "bin": [ + "phpunit" + ], "type": "library", + "extra": { + "branch-alias": { + "dev-main": "12.5-dev" + } + }, "autoload": { "files": [ - "src/functions_include.php" + "src/Framework/Assert/Functions.php" ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.36" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsoring.html", + "type": "other" + } + ], + "time": "2026-09-25T08:47:40+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { "psr-4": { - "React\\Promise\\": "src/" + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, + { + "name": "react/cache", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/cache.git", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/cache/zipball/d47c472b64aa5608225f47965a484b75c7817d5b", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/promise": "^3.0 || ^2.0 || ^1.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async, Promise-based cache interface for ReactPHP", + "keywords": [ + "cache", + "caching", + "promise", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/cache/issues", + "source": "https://github.com/reactphp/cache/tree/v1.2.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2022-11-30T15:59:55+00:00" + }, + { + "name": "react/child-process", + "version": "v0.6.7", + "source": { + "type": "git", + "url": "https://github.com/reactphp/child-process.git", + "reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/child-process/zipball/970f0e71945556422ee4570ccbabaedc3cf04ad3", + "reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.0", + "react/event-loop": "^1.2", + "react/stream": "^1.4" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/socket": "^1.16", + "sebastian/environment": "^5.0 || ^3.0 || ^2.0 || ^1.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\ChildProcess\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Event-driven library for executing child processes with ReactPHP.", + "keywords": [ + "event-driven", + "process", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/child-process/issues", + "source": "https://github.com/reactphp/child-process/tree/v0.6.7" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-12-23T15:25:20+00:00" + }, + { + "name": "react/dns", + "version": "v1.14.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/dns.git", + "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/dns/zipball/7562c05391f42701c1fccf189c8225fece1cd7c3", + "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/cache": "^1.0 || ^0.6 || ^0.5", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.7 || ^1.2.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3 || ^2", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Dns\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async DNS resolver for ReactPHP", + "keywords": [ + "async", + "dns", + "dns-resolver", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/dns/issues", + "source": "https://github.com/reactphp/dns/tree/v1.14.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-18T19:34:28+00:00" + }, + { + "name": "react/event-loop", + "version": "v1.6.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/event-loop.git", + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/event-loop/zipball/ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "suggest": { + "ext-pcntl": "For signal handling support when using the StreamSelectLoop" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\EventLoop\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "ReactPHP's core reactor event loop that libraries can use for evented I/O.", + "keywords": [ + "asynchronous", + "event-loop" + ], + "support": { + "issues": "https://github.com/reactphp/event-loop/issues", + "source": "https://github.com/reactphp/event-loop/tree/v1.6.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-17T20:46:25+00:00" + }, + { + "name": "react/promise", + "version": "v3.3.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/promise.git", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/promise/zipball/23444f53a813a3296c1368bb104793ce8d88f04a", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpstan/phpstan": "1.12.28 || 1.4.10", + "phpunit/phpunit": "^9.6 || ^7.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "React\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "A lightweight implementation of CommonJS Promises/A for PHP", + "keywords": [ + "promise", + "promises" + ], + "support": { + "issues": "https://github.com/reactphp/promise/issues", + "source": "https://github.com/reactphp/promise/tree/v3.3.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-08-19T18:57:03+00:00" + }, + { + "name": "react/socket", + "version": "v1.17.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/socket.git", + "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/socket/zipball/ef5b17b81f6f60504c539313f94f2d826c5faa08", + "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.0", + "react/dns": "^1.13", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.6 || ^1.2.1", + "react/stream": "^1.4" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3.3 || ^2", + "react/promise-stream": "^1.4", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Socket\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async, streaming plaintext TCP/IP and secure TLS socket server and client connections for ReactPHP", + "keywords": [ + "Connection", + "Socket", + "async", + "reactphp", + "stream" + ], + "support": { + "issues": "https://github.com/reactphp/socket/issues", + "source": "https://github.com/reactphp/socket/tree/v1.17.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-19T20:47:34+00:00" + }, + { + "name": "react/stream", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/stream.git", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/stream/zipball/1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.8", + "react/event-loop": "^1.2" + }, + "require-dev": { + "clue/stream-filter": "~1.2", + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Stream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Event-driven readable and writable streams for non-blocking I/O in ReactPHP", + "keywords": [ + "event-driven", + "io", + "non-blocking", + "pipe", + "reactphp", + "readable", + "stream", + "writable" + ], + "support": { + "issues": "https://github.com/reactphp/stream/issues", + "source": "https://github.com/reactphp/stream/tree/v1.4.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2024-06-11T12:45:25+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "4.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "7d05781b13f7dec9043a629a21d086ed74582a15" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/7d05781b13f7dec9043a629a21d086ed74582a15", + "reference": "7d05781b13f7dec9043a629a21d086ed74582a15", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/cli-parser", + "type": "tidelift" + } + ], + "time": "2026-05-17T05:29:34+00:00" + }, + { + "name": "sebastian/comparator", + "version": "7.1.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "7c65c1e79836812819705b473a90c12399542485" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/7c65c1e79836812819705b473a90c12399542485", + "reference": "7c65c1e79836812819705b473a90c12399542485", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.3", + "sebastian/diff": "^7.0", + "sebastian/exporter": "^7.0.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.8" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-05-21T04:45:25+00:00" + }, + { + "name": "sebastian/complexity", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/bad4316aba5303d0221f43f8cee37eb58d384bbb", + "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:55:25+00:00" + }, + { + "name": "sebastian/diff", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "cd4cabe39f8a4e8ee6818ba99f10a05561ea4ad6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/cd4cabe39f8a4e8ee6818ba99f10a05561ea4ad6", + "reference": "cd4cabe39f8a4e8ee6818ba99f10a05561ea4ad6", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.33", + "symfony/process": "^7.4.17" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/diff", + "type": "tidelift" + } + ], + "time": "2026-08-25T15:35:54+00:00" + }, + { + "name": "sebastian/environment", + "version": "8.1.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "9d32c685773823b1983e256ae4ecd48a10d6e439" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/9d32c685773823b1983e256ae4ecd48a10d6e439", + "reference": "9d32c685773823b1983e256ae4ecd48a10d6e439", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.26" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/8.1.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" + } + ], + "time": "2026-05-25T13:40:20+00:00" + }, + { + "name": "sebastian/exporter", + "version": "7.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", + "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.3", + "sebastian/recursion-context": "^7.0.1" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2026-05-20T04:37:17+00:00" + }, + { + "name": "sebastian/global-state", + "version": "8.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "b164d3274d6537ab462591c5755f76a8f5b1aae9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/b164d3274d6537ab462591c5755f76a8f5b1aae9", + "reference": "b164d3274d6537ab462591c5755f76a8f5b1aae9", + "shasum": "" + }, + "require": { + "php": ">=8.3", + "sebastian/object-reflector": "^5.0", + "sebastian/recursion-context": "^7.0.1" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^12.5.28" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/8.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state", + "type": "tidelift" + } + ], + "time": "2026-06-01T15:10:33+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d543b8ef219dcd8da262cbb958639a96bedba10e", + "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.7.0", + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/lines-of-code", + "type": "tidelift" + } + ], + "time": "2026-05-19T16:22:07+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "7.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/1effe8e9b8e068e9ae228e542d5d11b5d16db894", + "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894", + "shasum": "" + }, + "require": { + "php": ">=8.3", + "sebastian/object-reflector": "^5.0", + "sebastian/recursion-context": "^7.0" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/7.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:57:48+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "4bfa827c969c98be1e527abd576533293c634f6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/4bfa827c969c98be1e527abd576533293c634f6a", + "reference": "4bfa827c969c98be1e527abd576533293c634f6a", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" ], "authors": [ { - "name": "Jan Sorgalla", - "email": "jsorgalla@gmail.com", - "homepage": "https://sorgalla.com/" - }, - { - "name": "Christian Lück", - "email": "christian@clue.engineering", - "homepage": "https://clue.engineering/" - }, - { - "name": "Cees-Jan Kiewiet", - "email": "reactphp@ceesjankiewiet.nl", - "homepage": "https://wyrihaximus.net/" - }, - { - "name": "Chris Boden", - "email": "cboden@gmail.com", - "homepage": "https://cboden.dev/" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" } ], - "description": "A lightweight implementation of CommonJS Promises/A for PHP", - "keywords": [ - "promise", - "promises" - ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", "support": { - "issues": "https://github.com/reactphp/promise/issues", - "source": "https://github.com/reactphp/promise/tree/v3.3.0" + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/5.0.0" }, "funding": [ { - "url": "https://opencollective.com/reactphp", - "type": "open_collective" + "url": "https://github.com/sebastianbergmann", + "type": "github" } ], - "time": "2025-08-19T18:57:03+00:00" + "time": "2025-02-07T04:58:17+00:00" }, { - "name": "react/socket", - "version": "v1.17.0", + "name": "sebastian/recursion-context", + "version": "7.0.1", "source": { "type": "git", - "url": "https://github.com/reactphp/socket.git", - "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08" + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/socket/zipball/ef5b17b81f6f60504c539313f94f2d826c5faa08", - "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", + "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", "shasum": "" }, "require": { - "evenement/evenement": "^3.0 || ^2.0 || ^1.0", - "php": ">=5.3.0", - "react/dns": "^1.13", - "react/event-loop": "^1.2", - "react/promise": "^3.2 || ^2.6 || ^1.2.1", - "react/stream": "^1.4" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", - "react/async": "^4.3 || ^3.3 || ^2", - "react/promise-stream": "^1.4", - "react/promise-timer": "^1.11" + "phpunit/phpunit": "^12.0" }, "type": "library", - "autoload": { - "psr-4": { - "React\\Socket\\": "src/" + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" } }, + "autoload": { + "classmap": [ + "src/" + ] + }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Christian Lück", - "email": "christian@clue.engineering", - "homepage": "https://clue.engineering/" - }, - { - "name": "Cees-Jan Kiewiet", - "email": "reactphp@ceesjankiewiet.nl", - "homepage": "https://wyrihaximus.net/" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" }, { - "name": "Jan Sorgalla", - "email": "jsorgalla@gmail.com", - "homepage": "https://sorgalla.com/" + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" }, { - "name": "Chris Boden", - "email": "cboden@gmail.com", - "homepage": "https://cboden.dev/" + "name": "Adam Harvey", + "email": "aharvey@php.net" } ], - "description": "Async, streaming plaintext TCP/IP and secure TLS socket server and client connections for ReactPHP", - "keywords": [ - "Connection", - "Socket", - "async", - "reactphp", - "stream" - ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", "support": { - "issues": "https://github.com/reactphp/socket/issues", - "source": "https://github.com/reactphp/socket/tree/v1.17.0" + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/7.0.1" }, "funding": [ { - "url": "https://opencollective.com/reactphp", - "type": "open_collective" + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" } ], - "time": "2025-11-19T20:47:34+00:00" + "time": "2025-08-13T04:44:59+00:00" }, { - "name": "react/stream", - "version": "v1.4.0", + "name": "sebastian/type", + "version": "6.0.4", "source": { "type": "git", - "url": "https://github.com/reactphp/stream.git", - "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d" + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "82ff822c2edc46724be9f7411d3163021f602773" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/stream/zipball/1e5b0acb8fe55143b5b426817155190eb6f5b18d", - "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/82ff822c2edc46724be9f7411d3163021f602773", + "reference": "82ff822c2edc46724be9f7411d3163021f602773", "shasum": "" }, "require": { - "evenement/evenement": "^3.0 || ^2.0 || ^1.0", - "php": ">=5.3.8", - "react/event-loop": "^1.2" + "php": ">=8.3" }, "require-dev": { - "clue/stream-filter": "~1.2", - "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + "phpunit/phpunit": "^12.5.25" }, "type": "library", - "autoload": { - "psr-4": { - "React\\Stream\\": "src/" + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" } }, + "autoload": { + "classmap": [ + "src/" + ] + }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Christian Lück", - "email": "christian@clue.engineering", - "homepage": "https://clue.engineering/" - }, - { - "name": "Cees-Jan Kiewiet", - "email": "reactphp@ceesjankiewiet.nl", - "homepage": "https://wyrihaximus.net/" - }, - { - "name": "Jan Sorgalla", - "email": "jsorgalla@gmail.com", - "homepage": "https://sorgalla.com/" - }, - { - "name": "Chris Boden", - "email": "cboden@gmail.com", - "homepage": "https://cboden.dev/" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "description": "Event-driven readable and writable streams for non-blocking I/O in ReactPHP", - "keywords": [ - "event-driven", - "io", - "non-blocking", - "pipe", - "reactphp", - "readable", - "stream", - "writable" - ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", "support": { - "issues": "https://github.com/reactphp/stream/issues", - "source": "https://github.com/reactphp/stream/tree/v1.4.0" + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/6.0.4" }, "funding": [ { - "url": "https://opencollective.com/reactphp", - "type": "open_collective" + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" } ], - "time": "2024-06-11T12:45:25+00:00" + "time": "2026-05-20T06:45:45+00:00" }, { - "name": "sebastian/diff", - "version": "7.0.0", + "name": "sebastian/version", + "version": "6.0.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "7ab1ea946c012266ca32390913653d844ecd085f" + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/7ab1ea946c012266ca32390913653d844ecd085f", - "reference": "7ab1ea946c012266ca32390913653d844ecd085f", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/3e6ccf7657d4f0a59200564b08cead899313b53c", + "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c", "shasum": "" }, "require": { "php": ">=8.3" }, - "require-dev": { - "phpunit/phpunit": "^12.0", - "symfony/process": "^7.2" - }, "type": "library", "extra": { "branch-alias": { - "dev-main": "7.0-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -3527,33 +5000,76 @@ "authors": [ { "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - }, + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/6.0.0" + }, + "funding": [ { - "name": "Kore Nordmann", - "email": "mail@kore-nordmann.de" + "url": "https://github.com/sebastianbergmann", + "type": "github" } ], - "description": "Diff implementation", - "homepage": "https://github.com/sebastianbergmann/diff", + "time": "2025-02-07T05:00:38+00:00" + }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", "keywords": [ - "diff", - "udiff", - "unidiff", - "unified diff" + "static analysis" ], "support": { - "issues": "https://github.com/sebastianbergmann/diff/issues", - "security": "https://github.com/sebastianbergmann/diff/security/policy", - "source": "https://github.com/sebastianbergmann/diff/tree/7.0.0" + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", + "url": "https://github.com/staabm", "type": "github" } ], - "time": "2025-02-07T04:55:46+00:00" + "time": "2024-10-20T05:08:20+00:00" }, { "name": "symfony/console", @@ -4737,6 +6253,56 @@ } ], "time": "2026-01-01T13:34:06+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^8.1" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/2.0.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-12-08T11:19:18+00:00" } ], "aliases": [], diff --git a/docker/etc/nginx/default.conf b/docker/etc/nginx/default.conf index ecd77ed95..77636a9dc 100644 --- a/docker/etc/nginx/default.conf +++ b/docker/etc/nginx/default.conf @@ -7,6 +7,18 @@ server { index index.html index.php; client_max_body_size 64M; + # A pasta application/ fica dentro da raiz do documento, e nada nela deve + # ser servido. Sem esta regra, qualquer .php sob application/ seria executado + # pelo php-fpm abaixo — inclusive application/tests/bin/setup-db.php, que + # recria um banco, e application/database/seeds/, que trazem as credenciais + # de fábrica. + # + # O ^~ é obrigatório: sem ele, a location ~* \.php$ é avaliada antes (as + # regex têm prioridade sobre prefixo comum) e o acesso passaria. + location ^~ /application/ { + return 404; + } + # . files location ~ /\.(?!well-known) { deny all; diff --git a/docker/etc/nginx/default.template.conf b/docker/etc/nginx/default.template.conf index fcd62c0b7..f178d5006 100644 --- a/docker/etc/nginx/default.template.conf +++ b/docker/etc/nginx/default.template.conf @@ -7,6 +7,18 @@ server { index index.html index.php; client_max_body_size 64M; + # A pasta application/ fica dentro da raiz do documento, e nada nela deve + # ser servido. Sem esta regra, qualquer .php sob application/ seria executado + # pelo php-fpm abaixo — inclusive application/tests/bin/setup-db.php, que + # recria um banco, e application/database/seeds/, que trazem as credenciais + # de fábrica. + # + # O ^~ é obrigatório: sem ele, a location ~* \.php$ é avaliada antes (as + # regex têm prioridade sobre prefixo comum) e o acesso passaria. + location ^~ /application/ { + return 404; + } + # . files location ~ /\.(?!well-known) { deny all; diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 000000000..70632483e --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,40 @@ + + + + + application/tests/Schema + + + application/tests/Helpers + + + application/tests/Controllers + + + application/tests/Support + + + + + application/controllers + application/models + application/helpers + + + + + + + From 37ddfe579d649efda82ba7803e61ef594f7782f4 Mon Sep 17 00:00:00 2001 From: Gianluca Bine Date: Sun, 27 Sep 2026 13:34:58 -0300 Subject: [PATCH 05/17] fix: ignore em arquivos de upload --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index cfbda36ac..179034c41 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,5 @@ assets/userImage # Marcador de ambiente do tooling local; é um mountpoint, não um arquivo do projeto. .ai-jail + +/assets/arquivos/* From c759b2dc52b616f1f84124e1565e7839381016fd Mon Sep 17 00:00:00 2001 From: Gianluca Bine Date: Sun, 27 Sep 2026 13:58:42 -0300 Subject: [PATCH 06/17] =?UTF-8?q?fix(tests):=20mant=C3=A9m=20a=20conex?= =?UTF-8?q?=C3=A3o=20do=20processo=20em=20vez=20de=20reabrir=20por=20contr?= =?UTF-8?q?oller?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O CI3 carrega o banco pelo autoloader, que roda dentro de CI_Controller::__construct() — ou seja, uma vez por controller construído. Loader::database() deveria devolver a conexão já aberta, mas a guarda dele testa isset($CI->db) com $CI = get_instance(), e no meio do construtor get_instance() é o controller que está nascendo, que ainda não tem a propriedade $db. A guarda falha, o Loader abre uma conexão nova e guarda essa, trocando a que o processo inteiro usava. Na web é inofensivo: um request, um controller, uma conexão. Na suíte, não: a conexão é a mesma do processo todo, e é nela que qualquer transação de caso é aberta. Trocá-la no meio do teste deixa a transação órfã num objeto que ninguém mais referencia, e cada escrita do teste é commitada na hora. Havia um segundo defeito no mesmo lugar. CI_Controller::$instance é private static e continua apontado para o último controller construído, de modo que get_instance() devolvia um controller já jogado fora para quem viesse depois. É por ele que Loader::database() resolve o $CI, então o problema se reforçava a cada chamada. resetSharedState() passa a devolver o singletão ao objeto super antes de cada controller, e o harness guarda essa referência em TestApplication::superObject() em vez de confiar em get_instance(). Com isso, callControllerRaw() reaponta o controller para a conexão do processo e fecha a sobra, em vez de deixá-la vazando. Os 76 casos existentes continuam verdes. --- .../tests/Support/ControllerTestCase.php | 22 ++++++++- application/tests/Support/TestApplication.php | 48 ++++++++++++++++++- 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/application/tests/Support/ControllerTestCase.php b/application/tests/Support/ControllerTestCase.php index 0cc3bd9e1..61017abe4 100644 --- a/application/tests/Support/ControllerTestCase.php +++ b/application/tests/Support/ControllerTestCase.php @@ -34,7 +34,7 @@ protected function tearDown(): void protected function ci(): object { - return get_instance(); + return TestApplication::superObject(); } protected function resetApplicationState(): void @@ -174,6 +174,26 @@ protected function callControllerRaw(string $class, string $method): string $controller = new $class(); + // O autoloader roda dentro de CI_Controller::__construct(), uma vez por + // controller construído, e ele carrega o banco. Loader::database() + // deveria devolver a conexão já aberta, mas a guarda dele testa + // isset($CI->db) com $CI = get_instance() — e, no meio do construtor, + // get_instance() é o controller que está nascendo, que ainda não tem a + // propriedade $db. A guarda falha, o Loader abre uma conexão nova e a + // guarda mesmo, que é a que o processo inteiro usa. + // + // Numa requisição web isso é inofensivo: um request, um controller, uma + // conexão. Na suíte não é, porque a conexão é única do processo e é nela + // que a transação do caso é aberta: trocá-la no meio do teste deixa a + // transação órfã e faz toda escrita do teste ser commitada na hora. + // Por isso o controller passa a apontar para a conexão do processo, e a + // sobra é fechada em vez de vazar. + if (isset($controller->db) && $controller->db !== $ci->db) { + $sobra = $controller->db; + $controller->db = $ci->db; + $sobra->close(); + } + ob_start(); try { diff --git a/application/tests/Support/TestApplication.php b/application/tests/Support/TestApplication.php index b39288ac0..2662192a4 100644 --- a/application/tests/Support/TestApplication.php +++ b/application/tests/Support/TestApplication.php @@ -38,6 +38,27 @@ final class TestApplication 'loader' => true, ]; + /** + * O objeto super do CI3, montado uma vez por boot(). + */ + private static ?object $super = null; + + /** + * O objeto super do CI3: o que Codeigniter.php constrói no boot. + * + * Enquanto um controller está em construção, get_instance() devolve o + * controller, não este objeto. O harness chama isto para poder falar com o + * super de forma estável, inclusive entre dois controllers seguidos. + */ + public static function superObject(): object + { + if (self::$super === null) { + throw new \LogicException('TestApplication::boot() precisa rodar antes de superObject().'); + } + + return self::$super; + } + /** * Sobe o app do CI3 no ambiente de testes. Só pode ser chamado uma vez por * processo. @@ -93,6 +114,14 @@ public static function boot(?string $database = null): void require TestDatabase::rootPath() . '/index.php'; ob_end_clean(); + // O objeto super do CI3, o que Codeigniter.php constrói no boot. A partir + // de agora get_instance() passa a devolver controllers, porque + // CI_Controller::__construct() faz self::$instance =& $this, e é por + // isso que o harness precisa guardar esta referência: é ela que continua + // sendo a fonte de $ci->db, $ci->output e $ci->load para o resto do + // processo. + self::$super = get_instance(); + // Estado limpo para quem vier a seguir; cada caso recomeça daqui. $_SESSION = []; } @@ -149,7 +178,24 @@ public static function resetSharedState(): void $this->_ci_models = []; }; - $resetModels->call(get_instance()->load); + $resetModels->call(self::superObject()->load); + + // CI_Controller::$instance é private static, e o último controller + // construído continua apontado para lá. Sem esta volta, o get_instance() + // de quem vem depois entrega um controller já jogado fora — e é por ele + // que Loader::database() resolve o $CI, o que faz a suíte abrir uma + // conexão nova a cada controller. O Closure::bind amarra a closure ao + // escopo da CI_Controller, sem objeto, para poder escrever na + // propriedade privada estática. + $restoreInstance = \Closure::bind( + function (object $super): void { + self::$instance = $super; + }, + null, + \CI_Controller::class + ); + + $restoreInstance(self::$super); } /** From 334b7e7bb0c93b3872ac2fa9ed27033f001a1b3c Mon Sep 17 00:00:00 2001 From: Gianluca Bine Date: Sun, 27 Sep 2026 13:59:07 -0300 Subject: [PATCH 07/17] =?UTF-8?q?test:=20isola=20cada=20caso=20numa=20tran?= =?UTF-8?q?sa=C3=A7=C3=A3o=20com=20a=20trait=20TransactsDatabase?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A suíte escrevia no banco de verdade e por isso exigia conserto à mão. O `logs` era o exemplo mais visível: cada login autenticado insere uma linha pelo log_info(), e o total subia de 5 em 5 a cada execução, porque nada desfazia essas escritas. A trait é opt-in por classe, como no Laravel: `use TransactsDatabase;`. Ela abre a transação num gancho `#[Before]` e a desfaz num `#[After]`, e nada mais precisa lembrar de restaurar o que o caso mexeu. login/verificarLogin é o exemplo do ganho. O caso da conta sem expiração salvava o `dataExpiracao`, escrevia null e devolvia o valor num `finally` — mas o `finally` era pulado por qualquer `fail()` antes dele, e aí o `dataExpiracao` ficava null para os testes seguintes, um vazamento que só apareceria como um "conta expirada" inexplicável em outro caso. Agora o caso grava o null e não restaura nada. Três detalhes do CI3 que a trait teve de respeitar, todos registrados no código porque são armadilhas silenciosas: - trans_begin() aninhado não abre SAVEPOINT: só incrementa _trans_depth, e trans_rollback() só fala com o banco quando o depth é exatamente 1. Por isso o fechamento é um laço que desce até zero. - _trans_depth é protected e não tem getter, então é lido por closure amarrada ao driver. - _trans_rollback() e _trans_commit() ligam de novo o autocommit, e é por isso que `@@autocommit` é a fonte do detector: o `_trans_depth` é um contador em PHP e pode dizer 1 com o banco sem transação nenhuma. Como o MySQL 8.4 não tem @@in_transaction e information_schema.INNODB_TRX não é confiável, o autocommit é o que separa de fato uma transação viva de uma que já acabou. Se o código sob testar commitou ou reverteu, o tearDown acusa em vez de devolver o banco silenciosamente sujo. A detecção não enxerga commit implícito de DDL, e a trait não deve ser usada em caso que rode migration: TestApplicationTest, que é justamente o que migra, fica de fora. Os casos de TransactsDatabaseTest interrogam a conexão de dentro do teste, mas isso não prova o descarte: uma trait que fechasse com trans_complete() passaria em todos eles. O par final é o que prova — um caso grava uma linha em `logs` e confirma que ela está visível dentro da transação, e o caso seguinte, que só roda depois, exige que ela tenha desaparecido. Verifiquei que o par falha de verdade trocando o rollback por commit na trait. `logs` fica em 0 depois de execuções repetidas, e a suíte vai a 82 casos. --- .../tests/Controllers/LoginControllerTest.php | 32 ++- .../tests/Support/TransactsDatabase.php | 254 ++++++++++++++++++ .../tests/Support/TransactsDatabaseTest.php | 221 +++++++++++++++ 3 files changed, 490 insertions(+), 17 deletions(-) create mode 100644 application/tests/Support/TransactsDatabase.php create mode 100644 application/tests/Support/TransactsDatabaseTest.php diff --git a/application/tests/Controllers/LoginControllerTest.php b/application/tests/Controllers/LoginControllerTest.php index 2380aea41..5098ff9a5 100644 --- a/application/tests/Controllers/LoginControllerTest.php +++ b/application/tests/Controllers/LoginControllerTest.php @@ -3,6 +3,7 @@ namespace Tests\Controllers; use Tests\Support\ControllerTestCase; +use Tests\Support\TransactsDatabase; /** * Testa o controller Login inteiro: a tela (GET /index.php/login), o endpoint @@ -42,6 +43,8 @@ */ class LoginControllerTest extends ControllerTestCase { + use TransactsDatabase; + /** * POST login/verificarLogin: autentica e preenche a sessão. */ @@ -443,29 +446,24 @@ public function testSetsTheCorsHeadersThroughTheOutput(): void * dataExpiracao é date DEFAULT NULL, então null é um valor legítimo: conta * sem expiração. O chk_date() antigo fazia new DateTime(null), que é * depreciado no PHP 8.1 e viraria erro sob failOnDeprecation. + * + * O null é gravado direto, sem salvar e restaurar o valor anterior. É a + * TransactsDatabase que desfaz a alteração no fim do caso, e o ganho não é + * só de linhas: o `finally` que fazia a restauração manual era pulado por + * qualquer `fail()` antes dele, e aí o `dataExpiracao` ficava null para os + * testes seguintes — um vazamento que só apareceria como um "conta + * expirada" inexplicável em outro caso. */ public function testAnAccountWithoutAnExpirationDateCanLogIn(): void { - $db = $this->ci()->db; - $original = $db->select('dataExpiracao') - ->from('usuarios') - ->where('email', 'admin@admin.com') - ->limit(1) - ->get() - ->row('dataExpiracao'); - - $db->where('email', 'admin@admin.com')->update('usuarios', ['dataExpiracao' => null]); + $this->ci()->db->where('email', 'admin@admin.com')->update('usuarios', ['dataExpiracao' => null]); - try { - $this->postLogin('admin@admin.com', '123456'); + $this->postLogin('admin@admin.com', '123456'); - $response = $this->callController('Login', 'verificarLogin'); + $response = $this->callController('Login', 'verificarLogin'); - $this->assertTrue($response['result'], 'Uma conta sem expiração deveria entrar.'); - $this->assertTrue($this->ci()->session->userdata('logado')); - } finally { - $db->where('email', 'admin@admin.com')->update('usuarios', ['dataExpiracao' => $original]); - } + $this->assertTrue($response['result'], 'Uma conta sem expiração deveria entrar.'); + $this->assertTrue($this->ci()->session->userdata('logado')); } /** diff --git a/application/tests/Support/TransactsDatabase.php b/application/tests/Support/TransactsDatabase.php new file mode 100644 index 000000000..577781ed0 --- /dev/null +++ b/application/tests/Support/TransactsDatabase.php @@ -0,0 +1,254 @@ +db`), que é a mesma que + * o `database` do autoload abriu no boot e que a suíte in-process compartilha + * entre todos os testes do processo. Por isso o fechamento é defensivo: uma + * transação que vaza de um teste para o outro é exatamente o tipo de falha que + * não dá mensagem nenhuma, e só aparece como um teste que passa sozinho. + * + * Três armadilhas do CI3 que o fechamento trata, e que não têm equivalente no + * Laravel porque lá existe SAVEPOINT: + * + * 1. `trans_begin()` com `_trans_depth` já maior que zero só incrementa o + * contador, sem SAVEPOINT. Um controller sob teste que abre a própria + * transação não consegue commitar a nossa, porque o commit só acontece no + * depth 1. Mas ele também não consegue reverter só o que dele é: um + * rollback interno derruba a transação inteira, o que é o comportamento + * correto em produção e o errado para o isolamento do teste. + * + * 2. `trans_rollback()` só fala com o banco quando o depth é exatamente 1 + * (DB_driver.php:380). Nos níveis acima ele só decrementa. Um controller + * que deixe a pilha em 3 exige três chamadas até chegar no ROLLBACK de + * verdade, então um rollback único não desfaz nada. + * + * 3. `_trans_rollback()` e `_trans_commit()` ligam de novo o autocommit + * (mysqli_driver.php:362). Se o fechamento não rodar, a conexão fica com + * autocommit desligado e todo teste seguinte do processo passa a escrever + * dentro de uma transação que ninguém fecha. Por isso o autocommit é + * religado mesmo quando a transação já desapareceu. + * + * O que esta trait NÃO cobre, e é preciso saber antes de adotá-la: + * + * - DDL. `CREATE`, `ALTER`, `DROP` e `TRUNCATE` fazem commit implícito no + * MySQL e derrubam a transação sem aviso. Por isso + * TestApplicationTest::testMigrateLeavesTheOutputBufferLevelUntouched() + * não usa isto: ele roda Tools::migrate(), que hoje não executa nada + * porque todas as migrations já estão aplicadas — mas isso é uma leitura + * do estado atual, não uma garantia, e a primeira migration nova + * quebraria o isolamento do próprio teste sem nenhuma mensagem. + * + * - MyISAM, que não tem transação. Não é caso aqui: as 28 tabelas do banco + * de teste são InnoDB, e o create_base força o motor com ALTER TABLE + * depois de criar cada tabela. Vale reconferir se um dia alguma tabela + * entrar pelo dump com o motor padrão errado, porque a transação vira + * no-op e passa sem avisar. + * + * - Grupos de conexão além do `default`. O `autoload['libraries']` do CI3 + * tem um grupo só, e é ele que esta trait usa; um + * `$this->load->database('outro')` abriria uma segunda conexão que a + * transação não alcança. + */ +trait TransactsDatabase +{ + /** + * Abre a transação do caso, e recusa continuar se a anterior ficou aberta. + * + * Recusar aqui, e não no tearDown, é o que mantém a falha legível: um + * depth herdado de 1 faria a transação deste caso aninhar na anterior, e + * o rollback do fim desfaria a do teste anterior em vez da dele, produzindo + * um resultado que depende da ordem de execução. + */ + #[Before] + protected function setUpDatabaseTransaction(): void + { + $db = get_instance()->db; + + $herdado = $this->transactionDepth($db); + + if ($herdado !== 0) { + $this->fail( + "A transação do teste anterior não foi fechada (depth {$herdado}). " + . 'Um tearDown sem transação, um controller que chamou ' + . 'trans_rollback() sem trans_start(), ou um teste que ' + . 'levantou exceção antes do fechamento são as causas usuais.' + ); + } + + if ($db->trans_begin() === false) { + $this->fail('A transação do teste não pôde ser aberta: trans_begin() retornou false.'); + } + + $aberto = $this->transactionDepth($db); + + if ($aberto !== 1) { + $this->fail("trans_begin() retornou verdadeiro, mas o depth ficou em {$aberto} em vez de 1."); + } + } + + /** + * Descarta tudo que o caso escreveu, aconteça o que acontecer com ele. + * + * O `finally` é o que evita a armadilha 3 acima quando o caso estoura: sem + * ele, uma exceção deixa o autocommit desligado e o processo inteiro passa + * a vazar estado em silêncio. É também por isso que a verificação da + * transação roubada acontece antes do fechamento e apenas guarda a + * mensagem — falhar ali interromperia o fechamento, que é justamente a + * parte que não pode ser pulada. + */ + #[After] + protected function tearDownDatabaseTransaction(): void + { + $db = get_instance()->db; + + $roubada = $this->describeLostTransaction($db); + + try { + $this->rollbackToDepthZero($db); + } finally { + $this->resetDriverAfterFailedClose($db); + } + + if ($roubada !== null) { + $this->fail($roubada); + } + } + + /** + * Descreve a transação perdida, ou devolve null se ela ainda é nossa. + * + * O CI3 não expõe o `_trans_depth` e o `trans_status()` mente quando + * `trans_strict` está desligado, que devolve o estado anterior para + * permitir o próximo grupo. A pergunta que interessa é outra — a nossa + * transação ainda existe? — e a resposta vem de `@@autocommit`, porque é + * exatamente a informação que o driver usa para decidir isso. + * + * O CI3 implementa a transação como `autocommit(0)` seguido de + * `START TRANSACTION` (mysqli_driver.php:347), e tanto `_trans_commit()` + * quanto `_trans_rollback()` terminam religando o autocommit + * (mysqli_driver.php:362 e 380). Então o autocommit estar desligado é + * equivalente, observável de fora, a "o harness ainda está dentro da + * própria transação", e o `_trans_depth` continuar em 1 é o contador + * mentindo. + * + * As alternativas que parecem melhores não funcionam: + * + * - information_schema.INNODB_TRX não aparece. Uma transação que só fez + * `START TRANSACTION` não se registra ali, e mesmo depois de um DML o + * registro continua ausente, então a consulta responde 0 com a + * transação aberta. Medido no MySQL 8.4 deste projeto. + * + * - `@@in_transaction` seria o nome exato do que se quer, mas é + * variável do MariaDB. No MySQL 8.4 ela não existe, e o CI roda MySQL. + * + * O que este detector não pega: commit implícito por DDL. O MySQL encerra a + * transação, mas o autocommit continua desligado, então a leitura mente. + * É a mesma razão pela qual teste com migration fica fora da trait, e o + * texto de `$roubada` diz isso para quem bater nele. + */ + private function describeLostTransaction(object $db): ?string + { + $row = $db->query('SELECT @@autocommit AS autocommit')->row_array(); + + $autocommit = (int) ($row['autocommit'] ?? 1); + + if ($autocommit === 0) { + return null; + } + + return 'A transação do teste foi commitada ou revertida pelo código sob ' + . 'teste: o autocommit desta conexão está ligado, e quem o religa é ' + . 'o commit ou o rollback do driver, não o fechamento do harness. ' + . 'O isolamento deste teste não valeu. O caminho conhecido que faz ' + . 'isto é Financeiro::excluirLancamento(), que chama trans_complete() ' + . 'e depois trans_rollback() no mesmo bloco: em produção o segundo é ' + . 'no-op no depth 0, e aqui ele derruba a transação do harness. ' + . 'Se o culpado for DDL, o commit foi implícito e este detector não ' + . 'o vê — nenhum teste com migration pode usar esta trait.'; + } + + /** + * Rola para trás até o depth zero, chamando o suficiente para chegar lá. + * + * O laço não é defensive-programming genérico: `trans_rollback()` só + * emite ROLLBACK quando o depth é 1 (DB_driver.php:380), e só decrementa + * acima disso. Uma chamada só deixaria a pilha em N-1 e nada seria + * gravado no banco. + */ + private function rollbackToDepthZero(object $db): void + { + while (($depth = $this->transactionDepth($db)) > 0) { + if ($db->trans_rollback() === false) { + $this->fail("trans_rollback() falhou com o depth em {$depth}; o fechamento parou antes de zerar."); + + return; + } + + if ($this->transactionDepth($db) >= $depth) { + $this->fail('trans_rollback() não decrementou o depth; o laço não progrediria.'); + + return; + } + } + } + + /** + * Recoloca o driver em um estado em que o próximo teste começa limpo. + * + * No caminho normal `_trans_rollback()` já devolve o autocommit e zera o + * depth, e este método não faz nada. Ele existe para o caminho em que o + * fechamento falhou no meio, porque aí sobraria uma conexão com autocommit + * desligado e depth > 0: todos os testes seguintes do processo escrevem + * dentro de uma transação que ninguém abre nem fecha, e nenhum deles + * reclama. + */ + private function resetDriverAfterFailedClose(object $db): void + { + if ($this->transactionDepth($db) === 0) { + return; + } + + (function (): void { + if ($this->conn_id instanceof \mysqli) { + $this->conn_id->autocommit(true); + } + + $this->_trans_depth = 0; + })->call($db); + } + + /** + * Lê o `_trans_depth` do driver, que é protected. + * + * O CI3 não tem `trans_depth()` na API pública (DB_driver.php só expõe + * trans_status()), e Reflection seria mais frágil que uma closure + * amarrada ao escopo do driver — a mesma técnica que TestApplication usa + * para o `_ci_models` do Loader. + */ + private function transactionDepth(object $db): int + { + return (function (): int { + return $this->_trans_depth; + })->call($db); + } +} diff --git a/application/tests/Support/TransactsDatabaseTest.php b/application/tests/Support/TransactsDatabaseTest.php new file mode 100644 index 000000000..323e7dcc6 --- /dev/null +++ b/application/tests/Support/TransactsDatabaseTest.php @@ -0,0 +1,221 @@ +assertSame( + 0, + $this->autocommit(), + 'A trait deveria ter deixado o autocommit desligado, que é como o CI3 marca a transação aberta.' + ); + + $this->assertSame( + 1, + $this->transactionDepth(get_instance()->db), + 'O depth do CI3 deveria ser 1 durante o caso.' + ); + } + + /** + * Um controller sob teste não consegue commitar a transação do harness. + * + * Este é o motivo de a trait existir em vez de um BEGIN/ROLLBACK cru: o CI3 + * não cria SAVEPOINT (DB_driver.php:trans_begin só incrementa o contador + * acima de zero), então o `trans_complete()` de um controller apenas desce o + * depth de 2 para 1 e nada é gravado. Sem essa garantia, qualquer + * controller que commita vazaria o estado do caso para os seguintes. + */ + public function testCodeUnderTestCannotCommitTheHarnessTransaction(): void + { + $db = get_instance()->db; + + $db->trans_start(); + $this->assertSame(2, $this->transactionDepth($db), 'trans_start() aninhado deveria subir o depth.'); + + $db->trans_complete(); + + $this->assertSame(1, $this->transactionDepth($db), 'trans_complete() aninhado deveria só descer o depth.'); + + $this->assertSame( + 0, + $this->autocommit(), + 'O commit aninhado não pode ter religado o autocommit: a transação do harness continua aberta.' + ); + } + + /** + * O caminho sem volta: um rollback interno derruba a transação do harness. + * + * Documenta o limite conhecido em vez de escondê-lo atrás de um caso verde. + * É o que `Financeiro::excluirLancamento()` faz no caminho de erro + * (trans_complete() e depois trans_rollback()), e o tearDown acusaria isso + * como transação roubada. + */ + public function testAnInnerRollbackConsumesTheHarnessTransaction(): void + { + $db = get_instance()->db; + + $db->trans_start(); + $this->assertSame(2, $this->transactionDepth($db)); + + $db->trans_rollback(); + $this->assertSame(1, $this->transactionDepth($db)); + + // Este é o ponto: o rollback interno não tinha SAVEPOINT para usar, então + // o depth voltou para 1 sem desfazer nada, e a transação do harness + // continua no servidor. O commit aninhado do caso anterior é o + // comportamento correto; este é o que o harness não consegue impedir. + $this->assertSame( + 0, + $this->autocommit(), + 'Um rollback interno não pode derrubar a transação do harness, porque ela não tem SAVEPOINT.' + ); + + $this->assertSame(1, $this->transactionDepth($db)); + } + + /** + * Um commit reaching depth zero de fato reverte a transação inteira. + * + * O par trans_complete() + trans_rollback() de Financeiro chega no depth 0 + * com um rollback verdadeiro, e aí o autocommit é religado. É o caminho + * que o tearDown detecta e acusa, então o caso fixa o sintoma para que a + * mensagem tenha um contrajeito verificável. + */ + public function testRollbackAtDepthZeroEndsTheHarnessTransaction(): void + { + $db = get_instance()->db; + + $db->trans_start(); + $this->assertSame(2, $this->transactionDepth($db)); + + $db->trans_complete(); + $db->trans_rollback(); + + $this->assertSame(0, $this->transactionDepth($db)); + $this->assertSame( + 1, + $this->autocommit(), + 'O rollback no depth 0 deveria ter encerrado a transação e religado o autocommit.' + ); + + // O tearDown desta classe roda depois e encontraria a transação perdida. + // Reabrir devolve o processo ao estado em que o caso começou, senão a + // detecção acusaria este próprio teste de vazamento. + $db->trans_begin(); + } + + /** + * Prova de ponta a ponta que o descarte acontece de verdade. + * + * Os casos acima interrogam a conexão de dentro do teste, e todos passam + * mesmo que a trait feche a transação com `trans_complete()` em vez de + * rollback — o `@@autocommit` volta a 1 nos dois casos e o sintoma é o + * mesmo. Só a linha na tabela denuncia a diferença, e é a linha que de + * fato importa: o vazamento que motivou a trait aparecia no `logs`. + * + * Por isso o par de casos. O primeiro grava e confirma que a gravação está + * visível dentro da própria transação; o segundo, que só roda depois, tem + * de encontrá-la ausente. Se o rollback não tivesse acontecido, sobraria + * uma linha a cada execução da suíte — exatamente o `logs` que subia de 5 + * em 5. + */ + public function testTheRowWrittenHereIsVisibleInsideTheTransaction(): void + { + $this->escreverLinhaDeLog(); + $this->assertSame( + 1, + $this->contarLinhasDeLog(), + 'A gravação deveria estar visível de dentro da transação que a trait abriu.' + ); + } + + #[Depends('testTheRowWrittenHereIsVisibleInsideTheTransaction')] + public function testTheRowWrittenByThePreviousTestIsGone(): void + { + $this->assertSame( + 0, + $this->contarLinhasDeLog(), + 'A linha do caso anterior sobreviveu, então a trait está descartando com commit em vez de rollback.' + ); + } + + /** + * Grava uma linha em `logs` com a marca do par de casos. + */ + private function escreverLinhaDeLog(): void + { + get_instance()->db->insert('logs', [ + 'usuario' => 'harness', + 'tarefa' => 'TransactsDatabaseTest', + 'data' => '2026-01-01', + 'hora' => '00:00:00', + 'ip' => '127.0.0.1', + ]); + } + + /** + * Quantas linhas o par de casos deixou em `logs`. + */ + private function contarLinhasDeLog(): int + { + return (int) get_instance()->db + ->query("SELECT COUNT(*) AS total FROM `logs` WHERE `tarefa` = 'TransactsDatabaseTest'") + ->row_array()['total']; + } + + /** + * O autocommit da conexão, que é como o CI3 marca a transação aberta. + * + * Detalhe do protocolo: com a conexão em buffering, o ROLLBACK do caso + * anterior deixaria um resultado não lido e a próxima consulta falharia com + * "Cannot execute queries while other unbuffered queries are active". Por + * isso o CAST para inteiro vem na mesma consulta, sem uma segunda ida ao + * servidor entre a medição e o seu uso. + */ + private function autocommit(): int + { + $row = get_instance()->db->query('SELECT CAST(@@autocommit AS UNSIGNED) AS autocommit')->row_array(); + + return (int) ($row['autocommit'] ?? 1); + } + + /** + * Lê o `_trans_depth`, que é protected. Ver TransactsDatabase. + */ + private function transactionDepth(object $db): int + { + return (function (): int { + return $this->_trans_depth; + })->call($db); + } +} From 6abd04a6f4286a3d0f9a99a1308ce4f61d3e5228 Mon Sep 17 00:00:00 2001 From: Gianluca Bine Date: Sun, 27 Sep 2026 14:20:55 -0300 Subject: [PATCH 08/17] fix: ci --- AGENTS.md | 12 ++++-- application/tests/Support/TestDatabase.php | 48 +++++++++++++++------- 2 files changed, 43 insertions(+), 17 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 565c9facb..67d3d80ad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,9 +34,15 @@ read, and its values win for anything the suite does not set itself. - `MAPOS_TEST_DB_HOSTNAME`, `MAPOS_TEST_DB_PORT`, `MAPOS_TEST_DB_DATABASE`, `MAPOS_TEST_DB_USERNAME` and `MAPOS_TEST_DB_PASSWORD` select the server. They - default to `127.0.0.1:8989` and `mapos_test`, and the bootstrap maps them onto - the `DB_*` keys the app reads, before the immutable Dotenv runs, so a `.env` - cannot redirect the suite at the development database. + default to `127.0.0.1:8989` and `mapos_test`, and the bootstrap resolves them + (falling back to the `.env` for the credentials) and then publishes all five + onto the `DB_*` keys the app reads, so a `.env` cannot redirect the suite at the + development database. All five are published, not just the first three: the + suite opens two connections, the PDO here and the one the `database` autoloader + opens while `index.php` boots, and `config/database.php` only knows `$_ENV`. It + falls back to `enter_db_username` when the key is missing, which passes locally + — the `.env` fills it — and fails in CI, where there is no `.env`. See + `TestDatabase::fromEnvironment()` and `TestDatabaseTest`. - `APP_ENCRYPTION_KEY` and `GLOBAL_XSS_FILTERING` are filled in by the bootstrap only when absent, because `config.php` reads both without a fallback and the missing key would otherwise log a warning. diff --git a/application/tests/Support/TestDatabase.php b/application/tests/Support/TestDatabase.php index 56e8311ac..546eef264 100644 --- a/application/tests/Support/TestDatabase.php +++ b/application/tests/Support/TestDatabase.php @@ -25,12 +25,20 @@ * * 1. As chaves MAPOS_TEST_DB_* vêm de getenv(), que só enxerga o ambiente de * verdade. Um .env não participa, então não consegue redirecionar a suíte. - * 2. As chaves DB_* são escritas em $_ENV ANTES do safeLoad(). O Dotenv é - * criado com createImmutable(), que não sobrescreve o que já existe em - * $_ENV; é essa pré-escrita que impede um .env de desenvolvimento de - * apontar a suíte para o banco de desenvolvimento. - * 3. Só depois do safeLoad() as credenciais são lidas, porque o fallback vem - * de $_ENV e é o Dotenv quem popula. + * 2. O safeLoad() vem antes de resolver as credenciais, porque o fallback delas + * é lido de $_ENV e é o Dotenv quem popula. + * 3. Todas as cinco chaves DB_* são publicadas em $_ENV, e é a publicação — não a + * ordem em relação ao Dotenv — que impede um .env de desenvolvimento de apontar + * a suíte para o banco de desenvolvimento: o valor final é a sobreposição do + * ambiente ou o próprio valor do .env, nunca os dois misturados. + * + * Publicar as cinco, e não só host/porta/banco, é o que impede a suíte de abrir duas + * conexões com credenciais diferentes. O config/database.php não conhece + * MAPOS_TEST_DB_*: ele lê $_ENV['DB_USERNAME'] e cai no placeholder + * 'enter_db_username' quando a chave não existe. Sem a publicação, o PDO do + * TestDatabase conectava com a senha certa e o autoloader 'database' do index.php + * conectava com o placeholder — o que só aparecia no CI, onde não há .env para + * preencher a chave por fora. */ final class TestDatabase { @@ -97,17 +105,29 @@ public static function fromEnvironment(): self $envPath = static::envPath(); - // Ver static::fromEnvironment() para por que a ordem importa. - $_ENV['APP_ENVIRONMENT'] = 'testing'; - $_ENV['DB_HOSTNAME'] = $hostname; - $_ENV['DB_PORT'] = $port; - $_ENV['DB_DATABASE'] = $database; - // safeLoad(), e não load(): o CI roda sem application/.env, e load() // lança exceção quando o arquivo não existe. Quem tem .env continua // sendo lido. Dotenv::createImmutable($envPath)->safeLoad(); + // Depois do safeLoad() porque o fallback lê $_ENV, que é quem o Dotenv + // popula. A chave MAPOS_TEST_DB_* manda quando existe, e é isso que + // permite ao CI definir root/root sem depender de um .env. + $username = self::env('MAPOS_TEST_DB_USERNAME', $_ENV['DB_USERNAME'] ?? 'root'); + $password = self::env('MAPOS_TEST_DB_PASSWORD', $_ENV['DB_PASSWORD'] ?? ''); + + // Publicadas em $_ENV para o config/database.php — que o autoloader do + // index.php lê durante o boot — conectar com as mesmas credenciais que o + // PDO daqui. Sem isto o config cai no placeholder 'enter_db_username' e o + // boot falha com "Access denied" onde não existe .env (ver o docblock da + // classe). + $_ENV['APP_ENVIRONMENT'] = 'testing'; + $_ENV['DB_HOSTNAME'] = $hostname; + $_ENV['DB_PORT'] = $port; + $_ENV['DB_DATABASE'] = $database; + $_ENV['DB_USERNAME'] = $username; + $_ENV['DB_PASSWORD'] = $password; + // config.php lê estas duas chaves sem valor padrão, então a ausência // delas gera aviso no log e deixa a encryption_key vazia. Preenchidas // só quando faltam, para um .env de desenvolvimento continuar mandando. @@ -118,8 +138,8 @@ public static function fromEnvironment(): self $hostname, $port, $database, - self::env('MAPOS_TEST_DB_USERNAME', $_ENV['DB_USERNAME'] ?? 'root'), - self::env('MAPOS_TEST_DB_PASSWORD', $_ENV['DB_PASSWORD'] ?? ''), + $username, + $password, static::rootPath() ); } From 6457735f3b815012809290401f9c8efea7687858 Mon Sep 17 00:00:00 2001 From: Gianluca Bine Date: Sun, 27 Sep 2026 15:19:15 -0300 Subject: [PATCH 09/17] =?UTF-8?q?perf(tests):=20mant=C3=A9m=20o=20schema?= =?UTF-8?q?=20entre=20execu=C3=A7=C3=B5es=20e=20limpa=20os=20dados=20por?= =?UTF-8?q?=20teste?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O setup-db.php derrubava e recriava o banco a cada execução, e isso custava ~9,3s dos ~10s de uma execução. A cadeia de migrations é a parte cara: rodar Tools::migrate() contra um schema em dia leva 2ms. Medido antes e depois, `composer test` sai de ~10s para ~1,06s. A divisão passa a ser *mantém o schema, limpa os dados*, e saber em qual metade uma mudança cai é o ponto. O schema é reaproveitado quando TestDatabase::isSchemaCurrent() concorda, o que exige três coisas: o banco existe, `migrations.version` é a da migration mais recente, e uma impressão digital bate. As três precisam valer, e a terceira não é redundante com a segunda: editar o *conteúdo* de uma migration que já rodou, ou de uma seed, não muda o número do arquivo, então a versão continua em dia e um banco velho passaria pelo teste. A impressão digital cobre toda migration, as seeds e o TestFixtures.php, e mora em sys_get_temp_dir()/mapos-test-schema/.hash — num arquivo ao lado do banco e não numa tabela dentro dele, porque o check-schema-parity.php compara toda BASE TABLE exceto `migrations` e uma tabela a mais aqui leria como drift. No caminho reaproveitado nenhuma seed roda, e é a única coisa ali que não é óbvia: a seed Usuarios grava um idUsuarios explícito, então repetir a inserção numa tabela que já tem a linha aborta com 1062. No lugar das seeds entra a conferência das três contas, que era o que o script já fazia ao final. `Tools::migrate()` continua rodando nos dois caminhos: é um no-op de 2ms quando o schema está em dia, e é o que faz um banco meio montado se consertar sozinho. `composer test:fresh` (setup-db.php --fresh) força a remontagem, e ele apaga a impressão digital junto com o banco: uma montagem que falha no meio deixaria o arquivo de uma versão que não é a do banco, e a execução seguinte acreditaria nele. Os dados são limpos por caso, pela TransactsDatabase, que já existia. Uma classe pede a reinstalação da linha de base sobrescrevendo `resetsBaselineData()` para devolver true; antes de cada caso, dentro da transação que a trait acabou de abrir, ela apaga `usuarios` e reinstala as três contas. DELETE, nunca TRUNCATE, porque TRUNCATE é DDL e faria commit implícito da transação que a trait abriu um instante antes. É opt-in, e não automático, por dois motivos. O primeiro é de custo: uma classe que não escreve no banco não tem o que ganhar aqui, e paga o preço em todo caso. O segundo é o TransactsDatabaseTest, que precisa que `logs` NÃO seja limpa entre os seus dois casos: é a linha que sobrou ou não que prova que a trait descarta em vez de commitar, e limpar `logs` ali tornaria a prova uma tautologia. Por isso resetBaselineData() falha alto quando `logs` não está vazia, em vez de limpar, e aponta o `composer test:fresh`. Uma linha ali é indistinguível de uma transação commitada, que é justamente o que a trait existe para pegar. `configuracoes` não entra na reinstalação: 13 das suas 14 linhas vêm da seed, mas a `email_automatico` vem de uma migration e nenhuma seed a recria — apagar a tabela e rodar a seed de novo deixaria o banco sem essa linha para sempre, e a montagem seguinte nem perceberia, porque reconstrói tudo do zero e voltaria a tê-la. O quality.yml ganhou um passo, e não uma barra de tempo. O runner é limpo e paga a migration inteira de qualquer jeito, então o ganho de CI é zero. O passo existe porque a única forma de o reaproveitamento degradar é silenciosa: ele voltaria a remontar tudo e a suíte passaria igual, só mais devagar. Por isso o passo exige a mensagem de reuso, e não se limita a rodar o script. Um achado de verificação que vale mais que o ganho: a primeira versão do teste da reinstalação sujava a linha de base num caso e conferia no seguinte que a sujeira não tinha chegado. Desligando a reinstalação de propósito, a suíte continuava verde — o rollback do caso anterior já desfaz a sujeira, então o segundo caso vê a linha de base de qualquer jeito. O teste era uma tautologia, e um teste que passa com o recurso desligado não está testando o recurso. Ele foi trocado por um que chama a reinstalação diretamente e verifica as quatro formas de ela quebrar. O que a suíte não consegue observar, e está escrito no código como tal, é se a reinstalação roda dentro ou fora da transação: o estado final é o mesmo nas duas ordens. --- .github/workflows/quality.yml | 28 ++ AGENTS.md | 69 ++- .../tests/Controllers/LoginControllerTest.php | 15 + .../tests/Support/BaselineDataResetTest.php | 248 ++++++++++ application/tests/Support/TestDatabase.php | 274 ++++++++++- .../tests/Support/TestDatabaseTest.php | 461 ++++++++++++++++++ application/tests/Support/TestFixtures.php | 52 +- .../tests/Support/TransactsDatabase.php | 118 +++++ application/tests/bin/setup-db.php | 59 ++- composer.json | 3 + 10 files changed, 1293 insertions(+), 34 deletions(-) create mode 100644 application/tests/Support/BaselineDataResetTest.php create mode 100644 application/tests/Support/TestDatabaseTest.php diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index b90729e76..e07336629 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -67,6 +67,34 @@ jobs: - name: Run the test suite run: composer test + # O runner é limpo, então a execução acima pagou os ~9s da cadeia de + # migrations por completo. Esta segunda execução exercita o caminho que os + # desenvolvedores usam de verdade, o reaproveitamento, e é o único lugar + # onde ele é verificado: localmente ele só roda quando alguém lembra de + # rodar a suíte duas vezes. + # + # Não é uma barra de tempo, e não deveria virar uma. O ganho é do + # desenvolvimento local, onde o banco sobrevive entre execuções; no CI a + # máquina é descartada e o ganho é zero. O que se ganha aqui é a certeza de + # que o reaproveitamento não degradou em silêncio — degradar, aliás, é + # exatamente a forma como ele falha: remontar tudo e passar igual, só mais + # devagar. Por isso o passo exige a mensagem de reuso e não se limita a + # rodar o script. + - name: Run the schema setup again, requiring it to reuse the schema + run: | + saida=$(php application/tests/bin/setup-db.php) + echo "$saida" + + if ! grep -q "reaproveitando" <<< "$saida"; then + echo "::error::A segunda montagem não reaproveitou o schema. O caminho de reuso está quebrado." + exit 1 + fi + + if ! grep -q "^3 usuários disponíveis" <<< "$saida"; then + echo "::error::O schema reaproveitado não tem as três contas de fixture." + exit 1 + fi + # A suíte monta o schema pelas migrations, e o instalador importa o # banco.sql. Nada mais exercita os dois caminhos, então sem esta # comparação eles podem divergir em silêncio — e foi exatamente isso que diff --git a/AGENTS.md b/AGENTS.md index 67d3d80ad..f3c511930 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,13 +49,14 @@ read, and its values win for anything the suite does not set itself. - `phpunit.xml` sets `bootstrap="application/tests/bootstrap.php"` and `failOnWarning="true"`. - The database name **must** end in `_test`; both scripts abort otherwise, so a typo cannot wipe the production database. -- `application/tests/bin/setup-db.php` **drops and recreates** the database, then - builds the schema by running the migration chain (`Tools::migrate()`) and loads - reference data from the canonical seeds in `application/database/seeds/` - (`Permissoes`, `Usuarios`, `Configuracoes`), the same ones `Tools::seed()` runs. - This is deliberate: `banco.sql` is all `CREATE TABLE IF NOT EXISTS` with no - `DROP`, so importing it never exercises a single line of migration, and an - install built that way would never be caught before it reached a user. +- `application/tests/bin/setup-db.php` **reuses the schema when it is current** and + otherwise drops and recreates the database. On a fresh database it builds the + schema by running the migration chain (`Tools::migrate()`) and loads reference + data from the canonical seeds in `application/database/seeds/` (`Permissoes`, + `Usuarios`, `Configuracoes`), the same ones `Tools::seed()` runs. This is + deliberate: `banco.sql` is all `CREATE TABLE IF NOT EXISTS` with no `DROP`, so + importing it never exercises a single line of migration, and an install built + that way would never be caught before it reached a user. - It adds two users on top of the seeded reference data: `inativo@admin.com` (`situacao` 0) and `expirado@admin.com` (`dataExpiracao` in the past), so the two rejection paths in `Login` are coverable. Their password is copied from the @@ -84,13 +85,59 @@ read, and its values win for anything the suite does not set itself. Deprecations in *our* code are still fixed at the source, as with the null-safe `Login::chk_date()`. - Two support classes, split by lifecycle: `TestDatabase` owns credentials, the - `_test` name guard and the PDO lifecycle, and `TestApplication` owns booting - `index.php`, the reentrancy fixups and `Tools::migrate()`. The order is fixed: - the database must exist before the boot, because the `database` autoloader - connects while `index.php` boots. `TestApplication::migrate()` exists so the + `_test` name guard, the PDO lifecycle and the schema currency check, and + `TestApplication` owns booting `index.php`, the reentrancy fixups and + `Tools::migrate()`. The order is fixed: the database must exist before the boot, + because the `database` autoloader connects while `index.php` boots. + `TestApplication::migrate()` exists so the `resetSharedState()` + `ob_start()` + `migrate()` + `error_string()` sequence is written once instead of once per script. +### Maintained schema, per-test data + +`setup-db.php` used to drop and recreate the database on every run, which cost +~9.3s of the ~10s a run took: the migration chain is the expensive part, and +running `Tools::migrate()` against an up-to-date schema takes 2ms. The split now +is **keep the schema, clean the data**, and knowing which half a change belongs to +is the point: + +- The **schema** is reused when `TestDatabase::isSchemaCurrent()` says so, which + needs three things: the database exists, `migrations.version` equals the newest + migration timestamp, and a fingerprint matches. On a reuse, `setup-db.php` runs + no seeds at all — the `Usuarios` seed writes an explicit `idUsuarios`, so + replaying it aborts with 1062 — and instead re-checks the three fixture users. + `Tools::migrate()` still runs on both paths: it is a 2ms no-op when the schema + is current, and it is what makes a half-built database self-heal. +- The fingerprint covers every migration, the seeds and `TestFixtures.php`, and it + is what catches an **edit** to a file that already ran: editing a migration or a + seed does not change its filename, so the version stays current and a stale + database would otherwise be approved. It lives in + `sys_get_temp_dir()/mapos-test-schema/.hash`, not in a table, because + `check-schema-parity.php` compares every `BASE TABLE` except `migrations` and a + new table would read as drift. +- `composer test:fresh` (that is, `setup-db.php --fresh`) forces a full rebuild. + Reach for it when the reuse path is not what you want, and when a test failure + says the schema is short of something. +- The **data** is cleaned per test by `TransactsDatabase`, which wraps each case in + a transaction and rolls it back. A class opts into the baseline reinstall by + overriding `resetsBaselineData()` to return `true`; before each case, inside the + transaction it just opened, it then deletes `usuarios` and reinstalls the three + fixture accounts via `TestFixtures::installUsers()`. `DELETE`, never `TRUNCATE`, + because TRUNCATE is DDL and would implicitly commit the transaction the trait + just opened. It is opt-in, not automatic, and `TransactsDatabaseTest` must not + opt in: its two-case pair is the proof that the trait rolls back rather than + commits, and cleaning `logs` between them would make that proof a tautology. +- `resetBaselineData()` **fails loudly** if `logs` is not empty instead of cleaning + it, naming `composer test:fresh`. A row there is indistinguishable from a + committed transaction, and that is the whole point of the trait. +- Do not extend the reinstall to `configuracoes`: 13 of its 14 rows come from the + seed, but `email_automatico` comes from a migration and no seed recreates it, so + deleting the table and replaying the seed would drop that row for good. +- CI gains nothing in wall-clock time from this — the runner is clean and pays the + full migration either way. `quality.yml` runs the setup a second time and + **requires** the reuse message, because the only way this degrades is silently: + it would just rebuild everything and pass, slower. + Tests are **in-process**: the app boots once inside `application/tests/bootstrap.php` and each test instantiates the controller directly, because restarting the CI3 lifecycle per test is not possible. That exposes reentrancy bugs in the framework which diff --git a/application/tests/Controllers/LoginControllerTest.php b/application/tests/Controllers/LoginControllerTest.php index 5098ff9a5..7fd12344c 100644 --- a/application/tests/Controllers/LoginControllerTest.php +++ b/application/tests/Controllers/LoginControllerTest.php @@ -45,6 +45,21 @@ class LoginControllerTest extends ControllerTestCase { use TransactsDatabase; + /** + * Pede a reinstateção da linha de base antes de cada caso. + * + * A transação sozinha não dá conta desta classe: ela mexe no + * `dataExpiracao` das contas para cobrir o chk_date() do Login, e como uma + * alteração de coluna é um UPDATE comum a transação a desfaz. O que + * precisava mais era o outro sentido — o primeiro caso da execução anterior + * deixa a conta como estava, e qualquer estado que sobre de um teste que + * passou pelo caminho errado chegaria aqui sem ninguém reclamar. + */ + protected function resetsBaselineData(): bool + { + return true; + } + /** * POST login/verificarLogin: autentica e preenche a sessão. */ diff --git a/application/tests/Support/BaselineDataResetTest.php b/application/tests/Support/BaselineDataResetTest.php new file mode 100644 index 000000000..7d4112825 --- /dev/null +++ b/application/tests/Support/BaselineDataResetTest.php @@ -0,0 +1,248 @@ +db; + + $this->assertSame( + 1, + $this->transactionDepth($db), + 'O gancho deveria ter aberto a transação do caso, e a reinstateção deveria ter rodado dentro dela.' + ); + + $this->assertSame( + 0, + $this->autocommit(), + 'A reinstateção não pode rodar fora da transação: um DELETE commitado escapa do rollback do caso.' + ); + + $this->assertSame( + 3, + (int) $db->count_all_results('usuarios'), + 'A reinstateção deveria ter deixado os três usuários das fixtures antes do corpo do caso.' + ); + + $this->assertSame( + 14, + (int) $db->count_all_results('configuracoes'), + 'A reinstateção não pode tocar em `configuracoes`: 13 linhas vêm da seed e a `email_automatico` vem de uma migration, ' + . 'e nenhuma seed recria a última.' + ); + } + + /** + * A reinstateção apaga o que sobrou e devolve as três contas das fixtures. + * + * Este é o caso que pega a reinstateção desligada, e ele existe porque o par + * de casos não pega. A sujeira é gravada aqui dentro, pelo mesmo `db` da + * trait, e então a reinstateção é chamada diretamente. Nada de segunda + * conexão: sob REPEATABLE READ ela não veria a própria escrita, e a + * reinstalação abortaria com 1062 em `usuarios`.PRIMARY. + * + * Os quatro jeitos de a reinstateção quebrar, e o que cada um faria aqui: + * não rodar (4 usuários, em vez de 3), apagar sem reinstalar (0), instalar + * sem apagar (1062 da seed, exception), e reinstalar as contas erradas (a + * conferida pelo e-mail logo abaixo). + */ + public function testTheBaselineResetDeletesAndReinstallsTheFixtureUsers(): void + { + $db = get_instance()->db; + + $db->insert('usuarios', [ + 'nome' => 'Sujo', + 'rg' => 'MG-25.502.561', + 'cpf' => '517.565.356-40', + 'cep' => '01024-900', + 'rua' => 'R. Cantareira', + 'numero' => '306', + 'bairro' => 'Centro Histórico de São Paulo', + 'cidade' => 'São Paulo', + 'estado' => 'SP', + 'email' => 'sujo@admin.com', + 'senha' => 'irrelevante', + 'telefone' => '0000-0000', + 'celular' => '', + 'situacao' => 1, + 'dataCadastro' => '2018-09-09', + 'permissoes_id' => 1, + ]); + + $this->assertSame(4, (int) $db->count_all_results('usuarios'), 'A sujeira plantada deveria estar visível antes da reinstateção.'); + + $this->chamarReinstalacao(); + + $this->assertSame( + 3, + (int) $db->count_all_results('usuarios'), + 'A reinstateção deveria ter apagado o usuário estranho e devolvido as três contas das fixtures.' + ); + + $this->assertSame( + 0, + (int) $db->where('email', 'sujo@admin.com')->count_all_results('usuarios'), + 'O usuário estranho sobreviveu, então o DELETE da reinstateção não rodou.' + ); + + // E as três contas são as de verdade, com a expiração que os testes de + // login dependem. Um DELETE sem reinstall, ou um reinstall de outra + // fonte, deixaria a tabela vazia ou com as contas erradas. + $emails = $db->select('email') + ->from('usuarios') + ->order_by('idUsuarios', 'ASC') + ->get() + ->result_array(); + + $this->assertSame( + ['admin@admin.com', 'inativo@admin.com', 'expirado@admin.com'], + array_column($emails, 'email'), + 'A reinstateção devolveu um conjunto de contas diferente do das fixtures.' + ); + } + + /** + * A reinstateção se recusa a rodar com `logs` suja, e diz como resolver. + * + * A linha é gravada DENTRO da transação do caso, e não por outra conexão, + * pelas mesmas razões de REPEATABLE READ do caso acima. O guard é a mesma + * contagem nos dois casos — o que muda é o MySQL decidir quando enxerga o + * que está commitado por fora, e isso não é o guard. + * + * `logs` é a única tabela que sobra vazia quando a transação descarta, e é por + * isso que ela é a escolha: uma linha aqui é indistinguível de um commit. + */ + public function testTheBaselineResetRefusesToRunWithALoggedLeak(): void + { + get_instance()->db->insert('logs', [ + 'usuario' => 'harness', + 'tarefa' => 'BaselineDataResetTest', + 'data' => '2026-01-01', + 'hora' => '00:00:00', + 'ip' => '127.0.0.1', + ]); + + $falha = $this->chamarReinstalacao(); + + $this->assertNotNull( + $falha, + 'A reinstateção rodou com uma linha em `logs`, que é a assinatura de uma transação commitada em vez de descartada.' + ); + + $this->assertStringContainsString('logs', $falha->getMessage(), 'A falha deveria apontar a tabela.'); + $this->assertStringContainsString( + 'test:fresh', + $falha->getMessage(), + 'A falha deveria dizer como recuperar, senão quem a encontra não sabe para onde ir.' + ); + } + + /** + * Chama a reinstateção e devolve a falha, ou null se ela passou. + * + * Closure amarrada ao escopo do caso, a mesma técnica que a trait usa para o + * `_trans_depth` do driver: o método é privado e Reflection seria mais frágil + * que isso. Chamar direto é necessário porque o `setUp` do PHPUnit não tem como + * ser testado do corpo de um caso, e a falha de um setUp não é capturável. + */ + private function chamarReinstalacao(): ?AssertionFailedError + { + try { + (function (): void { + $this->resetBaselineData(get_instance()->db); + })->call($this); + } catch (AssertionFailedError $e) { + return $e; + } + + return null; + } + + /** + * O autocommit da conexão, que é como o CI3 marca a transação aberta. + */ + private function autocommit(): int + { + $row = get_instance()->db->query('SELECT CAST(@@autocommit AS UNSIGNED) AS autocommit')->row_array(); + + return (int) ($row['autocommit'] ?? 1); + } + + /** + * Lê o `_trans_depth`, que é protected. Ver TransactsDatabase. + */ + private function transactionDepth(object $db): int + { + return (function (): int { + return $this->_trans_depth; + })->call($db); + } +} diff --git a/application/tests/Support/TestDatabase.php b/application/tests/Support/TestDatabase.php index 546eef264..15d6f3fbf 100644 --- a/application/tests/Support/TestDatabase.php +++ b/application/tests/Support/TestDatabase.php @@ -149,6 +149,269 @@ public function database(): string return $this->database; } + /** + * O schema que este banco de teste já tem serve, ou precisa ser remontado. + * + * Montar o schema é a parte cara da suíte: a cadeia de migrations leva ~9s, + * e rodar Tools::migrate() contra um schema já em dia leva 2ms. A diferença + * inteira está no DROP, porque um banco derrubado nunca está em dia. + * + * Três condições, e as três precisam valer: + * + * 1. O banco existe. + * 2. A versão em `migrations` é a da migration mais recente do diretório. + * Ela é mais nova quando falta migration, e mais velha quando o branch + * foi trocado por um que não conhece a que rodou — nos dois casos o + * certo é remontar, porque o Migrator do CI3 só sobe de versão. + * 3. A impressão digital dos arquivos que definem o banco bate com a que + * foi gravada na última montagem. + * + * A terceira não é redundante com a segunda. Editar o *conteúdo* de uma + * migration que já rodou, ou de uma seed, não muda o número do arquivo, então + * a versão continua em dia e um banco velho passaria pelo teste. A impressão + * digital é o que pega isso, e o motivo de ela morar num arquivo ao lado do + * banco — e não numa tabela dentro dele — é que o check-schema-parity.php + * compara toda tabela BASE TABLE exceto `migrations`, então uma tabela a mais + * aqui faria o gate de paridade falhar. + * + * O arquivo some quando o /tmp é limpo, e aí a resposta é remontar uma vez e + * regravá-lo. Reaproveitar um banco de procedência desconhecida seria o tipo de + * atalho que custa horas de depuração; remontar custa 9s uma vez. + */ + public function isSchemaCurrent(string $database): bool + { + static::assertDatabaseNameIsSafe($database); + + $latest = static::latestMigrationVersion(); + + if ($latest === null) { + return false; + } + + $admin = $this->pdo(); + + $exists = $admin->prepare( + 'SELECT COUNT(*) AS total FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = ?' + ); + $exists->execute([$database]); + + if ((int) $exists->fetch()['total'] === 0) { + return false; + } + + // A tabela de controle do Migrator não existe até a primeira migration, e + // um banco meio montado é justamente o que precisa ser remontado. + try { + $version = $admin + ->query("SELECT version FROM `{$database}`.`migrations` ORDER BY version DESC LIMIT 1") + ->fetch(); + } catch (PDOException) { + return false; + } + + if ($version === false) { + return false; + } + + if (static::normalizeVersion((string) $version['version']) !== $latest) { + return false; + } + + $sidecar = static::fingerprintPath($database); + + return is_file($sidecar) && trim((string) file_get_contents($sidecar)) === static::schemaFingerprint(); + } + + /** + * Grava a impressão digital do banco que acabou de ser montado. + * + * Chamada depois de uma montagem bem-sucedida, e é o que permite à próxima + * execução reaproveitar. O conteúdo é só o hash: o arquivo existe para responder + * "isto ainda é o que foi construído?" e não guarda nada do banco. + */ + public function recordSchemaFingerprint(string $database): void + { + static::assertDatabaseNameIsSafe($database); + + $path = static::fingerprintPath($database); + $dir = dirname($path); + + if (! is_dir($dir)) { + mkdir($dir, 0700, true); + } + + file_put_contents($path, static::schemaFingerprint() . PHP_EOL); + } + + /** + * Apaga a impressão digital, para a próxima execução remontar. + * + * É o caminho do `composer test:fresh`, e existe para que o arquivo não vire + * um estado que precisa ser lembrado junto com o resto do harness. + */ + public function forgetSchemaFingerprint(string $database): void + { + static::assertDatabaseNameIsSafe($database); + + $path = static::fingerprintPath($database); + + if (is_file($path)) { + unlink($path); + } + } + + /** + * A versão da migration mais recente, como o Migrator do CI3 a escreve. + * + * O número é o prefixo numérico do nome do arquivo, e é o mesmo que vai para + * a coluna `version` da tabela de controle. A largura é normalizada em + * normalizeVersion() porque a comparação é de string e um nome fora do padrão + * mudaria o resultado sem mudar o significado. + */ + public static function latestMigrationVersion(): ?string + { + $latest = null; + + foreach (static::migrationFiles() as $file) { + $version = static::normalizeVersion(static::versionFromFilename(basename($file))); + + if ($latest === null || $version > $latest) { + $latest = $version; + } + } + + return $latest; + } + + private static function fingerprintPath(string $database): string + { + // O nome já passou por assertDatabaseNameIsSafe(), que exige terminar em + // '_test' e portanto não aceita barra nenhuma: o nome não escapa do diretório. + return sys_get_temp_dir() . '/mapos-test-schema/' . $database . '.hash'; + } + + /** + * O hash dos arquivos que decidem o que o banco contém. + * + * Só entram as migrations, as seeds e o TestFixtures. A migration mais recente + * já entra pela versão; repetir o conteúdo dela aqui é o que faz uma edição + * silenciosa dentro de uma migration que já rodou invalidar a impressão digital + * em vez de passar batido. + */ + private static function schemaFingerprint(): string + { + return static::fingerprintFor(static::schemaFiles()); + } + + /** + * O núcleo puro da impressão digital, público para poder ser testado. + * + * Sem esta separação a única forma de provar que o hash muda quando o CONTEÚDO + * de uma migration muda seria editar uma migration de verdade, dentro de um + * teste. Editar `application/database/migrations/` durante a execução é pior + * do que não testar: um teste que morre no meio deixa o arquivo alterado, e a + * próxima execução_remonta o banco por causa de um resíduo do teste, que é + * um sintoma que não aponta para nada. Passando os arquivos de fora, o mesmo + * arquivo é reescrito entre dois hashes, que é a situação real. + * + * As chaves são rótulos, não caminhos: 'seeds/Usuarios.php' em vez do caminho + * absoluto. É o que impede o hash de depender de onde o projeto está clonado, e + * o que faz o rótulo — e não só o conteúdo — contar, já que renomear uma + * migration muda o schema sem mudar uma linha. + * + * @param array $labelledFiles rótulo => caminho absoluto + */ + public static function fingerprintFor(array $labelledFiles): string + { + if ($labelledFiles === []) { + throw new RuntimeException( + 'Uma impressão digital sem nenhum arquivo não distingue um schema de outro.' + ); + } + + ksort($labelledFiles); + + $manifest = ''; + + foreach ($labelledFiles as $label => $file) { + if (! is_file($file) || ! is_readable($file)) { + throw new RuntimeException( + "Não consegui ler {$file} (rótulo '{$label}') para compor a impressão digital do schema. " + . 'Um arquivo de migration ou seed ausente tornaria o hash incompleto, e um hash ' + . 'incompleto é um banco velho aprovado como atual.' + ); + } + + $manifest .= $label . ':' . hash_file('sha256', $file) . "\n"; + } + + return hash('sha256', $manifest); + } + + /** + * Os arquivos que definem o banco, com o rótulo de cada um. + * + * O único lugar que varre os diretórios, para que a impressão digital e a + * contagem da versão mais recente nunca discordem sobre o que existe. + * + * @return array rótulo => caminho absoluto + */ + private static function schemaFiles(): array + { + $root = static::envPath(); + + $files = [ + 'tests/Support/TestFixtures.php' => $root . '/tests/Support/TestFixtures.php', + ]; + + foreach (['migrations', 'seeds'] as $dir) { + foreach (glob($root . '/database/' . $dir . '/*.php') ?: [] as $file) { + $files[$dir . '/' . basename($file)] = $file; + } + } + + return $files; + } + + /** + * Só as migrations, para a contagem da versão mais recente. + * + * Filtra o mesmo mapa que alimenta a impressão digital, em vez de varrer o + * diretório de novo: dois varredores independentes do mesmo diretório podem + * discordar sobre o que existe, e essa discordância apareceria como "o banco + * está em dia mas a impressão digital não bate" sem causa visível. + * + * @return list + */ + private static function migrationFiles(): array + { + $files = []; + + foreach (static::schemaFiles() as $label => $path) { + if (str_starts_with($label, 'migrations/')) { + $files[] = $path; + } + } + + sort($files); + + return $files; + } + + private static function versionFromFilename(string $filename): string + { + return (string) preg_replace('/[^0-9].*$/', '', $filename); + } + + /** + * A versão como string de largura fixa, para a comparação não depender do tipo + * que o driver devolveu nem do formato do nome do arquivo. + */ + private static function normalizeVersion(string $version): string + { + return str_pad($version, 14, '0', STR_PAD_LEFT); + } + public function dsn(?string $database = null): string { $dsn = "mysql:host={$this->hostname};port={$this->port};charset=utf8mb4"; @@ -175,9 +438,14 @@ public function pdo(?string $database = null): PDO /** * Apaga e recria um banco, e devolve a conexão já apontada para ele. * - * O DROP é o que torna a montagem do banco de testes realmente idempotente. - * O banco.sql usa CREATE TABLE IF NOT EXISTS em todas as 28 tabelas, então - * sem o DROP a segunda execução herda tudo que a primeira deixou para trás. + * O DROP é o que torna a montagem do banco de testes realmente idempotente: o + * banco.sql usa CREATE TABLE IF NOT EXISTS em todas as 28 tabelas, então sem + * o DROP a segunda execução herdaria tudo que a primeira deixou para trás. + * + * Quem chama isto é o setup-db.php, e só quando o schema não está em dia — + * ver isSchemaCurrent(). O caminho que economiza os ~9s da cadeia de migrations + * não passa por aqui, e é por isso que este método continua sendo o caminho + * sem recycle: dados de uma execução anterior nunca sobrevivem a ele. */ public function recreate(string $database): PDO { diff --git a/application/tests/Support/TestDatabaseTest.php b/application/tests/Support/TestDatabaseTest.php new file mode 100644 index 000000000..bbb884349 --- /dev/null +++ b/application/tests/Support/TestDatabaseTest.php @@ -0,0 +1,461 @@ + + */ + private const PUBLISHED_KEYS = [ + 'DB_HOSTNAME' => 'hostname', + 'DB_PORT' => 'port', + 'DB_DATABASE' => 'database', + 'DB_USERNAME' => 'username', + 'DB_PASSWORD' => 'password', + ]; + + /** + * O banco descartável dos casos de reaproveitamento. + * + * Um nome próprio, e não o banco da suíte: estes casos criam e apagam schema, + * e fazer isso no banco que o resto da execução usa trocaria o assunto da + * suíte por um DROP acidental. O sufixo `_test` é o que mantém + * assertDatabaseNameIsSafe() satisfeita — e o que impede este arquivo de ser + * o motivo de o banco de produção sumir. + */ + private string $banco = 'mapos_currency_probe_test'; + + /** + * Um nome que nenhum banco pode ter, para o caso do banco inexistente. + * + * Terminar em `_test` também aqui, porque a guarda é verificada antes de + * qualquer conexão: um caso de "banco que não existe" que violasse o sufixo + * abortaria o processo inteiro em vez de testar o que pretende. + */ + private string $nomeInexistente = 'mapos_nunca_criado_test'; + + /** + * Os arquivos temporários deste caso, para o `#[After]` apagar. + * + * @var list + */ + private array $temporarios = []; + + /** + * Derruba o banco descartável, a impressão digital dele e os temporários. + * + * No `#[After]` e não no fim de cada caso: um caso que falhe no meio deixa o + * banco para trás, e o próximo `composer test` reencontraria um schema + * descartável com a impressão digital certa, o que faria a limpeza parecer + * funcionar por acidente. O `finally` cobre a falha dentro da própria limpeza. + */ + #[After] + public function limparBancoDescartavel(): void + { + $test = TestDatabase::fromEnvironment(); + + try { + $test->pdo()->exec("DROP DATABASE IF EXISTS `{$this->banco}`"); + } finally { + $test->forgetSchemaFingerprint($this->banco); + + foreach ($this->temporarios as $arquivo) { + if (is_file($arquivo)) { + unlink($arquivo); + } + } + + $this->temporarios = []; + } + } + + /** + * O config que o CI3 realmente usa, resolvido agora. + * + * O arquivo é incluído dentro de uma closure em vez do corpo do teste: ele exige + * BASEPATH e ENVIRONMENT definidos, e o `defined('BASEPATH') or exit()` da + * primeira linha derrubaria o processo do PHPUnit inteiro se o boot um dia não os + * tivesse deixado no lugar. Dentro da closure o escopo também é isolado, então o + * $db não vaza para o teste. + * + * @return array + */ + private function ci3DatabaseConfig(): array + { + $this->assertTrue( + defined('BASEPATH') && defined('ENVIRONMENT'), + 'O app precisa estar bootado antes de ler o config/database.php' + ); + + $path = TestDatabase::envPath() . '/config/database.php'; + + $read = static function (string $path): array { + include $path; + + return $db['default']; + }; + + $config = $read($path); + + $this->assertIsArray($config, 'O config/database.php precisa montar $db[\'default\'] como array'); + + return $config; + } + + public function testTheCi3ConfigConnectsWithTheSameDatabaseAsTheHarness(): void + { + $config = $this->ci3DatabaseConfig(); + + foreach (self::PUBLISHED_KEYS as $envKey => $configKey) { + $this->assertArrayHasKey( + $envKey, + $_ENV, + "TestDatabase::fromEnvironment() precisa publicar {$envKey} em \$_ENV para o config/database.php" + ); + + $this->assertSame( + $_ENV[$envKey], + $config[$configKey] ?? null, + "O config/database.php e o harness precisam concordar sobre {$envKey}" + ); + + // A redundância que importa: se um dia o valor publicado voltar a ser + // omitido, a comparação acima acusaria uma diferença, e esta diz o quê. + $this->assertStringNotContainsString( + 'enter_', + (string) ($config[$configKey] ?? ''), + "O config/database.php caiu no placeholder de {$configKey}: fromEnvironment() não publicou a chave" + ); + } + } + + /** + * Um banco recém-montado é reconhecido como em dia. + * + * O `migrations` aqui é um de mentira, com a versão mais recente e mais nada: + * o que este caso exercita é a decisão, não a cadeia de migrations, e essa + * é verificada de verdade pelo setup-db.php, que é quem roda as 32. Montar + * um schema completo levaria os 9s que este caso existe para evitar. + */ + public function testAFreshlyBuiltSchemaIsCurrent(): void + { + $test = $this->bancoDeDescarte(); + $pdo = $test->pdo($this->banco); + + $pdo->exec('CREATE TABLE `migrations` (`version` VARCHAR(20) NOT NULL)'); + $pdo->exec(sprintf( + "INSERT INTO `migrations` (`version`) VALUES ('%s')", + TestDatabase::latestMigrationVersion() + )); + + $this->assertFalse( + $test->isSchemaCurrent($this->banco), + 'Sem o arquivo de impressão digital, a procedência do banco é desconhecida e ele não pode ser reaproveitado.' + ); + + $test->recordSchemaFingerprint($this->banco); + + $this->assertTrue( + $test->isSchemaCurrent($this->banco), + 'Um banco na versão mais recente, com a impressão digital da última montagem, deveria ser reaproveitado.' + ); + } + + /** + * Uma versão antiga não é reaproveitada, e é aqui que o branch trogado pega. + * + * O Migrator do CI3 só sobe de versão, então um banco mais velho que os + * arquivos precisaria de um passo para trás que `migrate()` não dá. A + * resposta tem de ser remontar. + */ + public function testASchemaBehindTheLatestMigrationIsNotCurrent(): void + { + $test = $this->bancoDeDescarte(); + $pdo = $test->pdo($this->banco); + + $pdo->exec('CREATE TABLE `migrations` (`version` VARCHAR(20) NOT NULL)'); + $pdo->exec("INSERT INTO `migrations` (`version`) VALUES ('20210101000000')"); + + $test->recordSchemaFingerprint($this->banco); + + $this->assertFalse( + $test->isSchemaCurrent($this->banco), + 'Um banco numa versão anterior à migration mais recente não pode ser reaproveitado: o Migrator só sobe.' + ); + } + + /** + * Uma impressão digital que não bate significa arquivo editado, e o banco é + * velho sem que a versão tenha mudado. + * + * É o caso que a versão sozinha não pega: editar o conteúdo de uma migration + * que já rodou, ou de uma seed, não muda o número do arquivo, então + * `migrations.version` continua em dia e o banco velho passaria. Aqui a + * impressão digital é a de outro conjunto de arquivos, o que é + * indistinguível de uma edição — que é exatamente o ponto. + */ + public function testAMismatchedFingerprintIsNotCurrent(): void + { + $test = $this->bancoDeDescarte(); + $pdo = $test->pdo($this->banco); + + $pdo->exec('CREATE TABLE `migrations` (`version` VARCHAR(20) NOT NULL)'); + $pdo->exec(sprintf( + "INSERT INTO `migrations` (`version`) VALUES ('%s')", + TestDatabase::latestMigrationVersion() + )); + + // O que o setup-db.php escreveria depois de uma montagem a partir de + // arquivos diferentes destes. + file_put_contents( + $this->caminhoDaImpressaoDigital(), + hash('sha256', 'um conjunto de migrations e seeds que não é o deste') . PHP_EOL + ); + + $this->assertFalse( + $test->isSchemaCurrent($this->banco), + 'A impressão digital não bate, então os arquivos mudaram depois da montagem e o banco precisa ser refeito.' + ); + } + + /** + * Um banco que não existe é o caso mais comum, e o mais fácil de errar. + * + * A conexão de conferência abre sem `dbname` justamente por isso: uma + * tentativa de ler `migrations` de um banco inexistente estouraria exceção + * em vez de devolver um "não está em dia", e o setup-db.php teria que + * distinguir as duas coisas. + */ + public function testAMissingDatabaseIsNotCurrent(): void + { + $test = TestDatabase::fromEnvironment(); + + $this->assertFalse( + $test->isSchemaCurrent($this->nomeInexistente), + 'Um banco inexistente não pode ser reaproveitado, e a conferência disso não pode lançar.' + ); + } + + /** + * A impressão digital fica fora do banco, e o motivo é um gate. + * + * Guardá-la numa tabela faria o check-schema-parity.php falhar: ele compara + * toda BASE TABLE do information_schema contra a cadeia de migrations, + * excluindo só `migrations`. Uma tabela a mais aqui seria um falso positivo + * de drift entre o banco.sql e as migrations — o oposto do que o arquivo + * existe para sinalizar. + */ + public function testTheFingerprintLivesOutsideTheDatabase(): void + { + $test = $this->bancoDeDescarte(); + $test->pdo($this->banco)->exec('CREATE TABLE `migrations` (`version` VARCHAR(20) NOT NULL)'); + + $test->recordSchemaFingerprint($this->banco); + + $tabelas = $test->pdo($this->banco) + ->query('SELECT table_name FROM information_schema.tables WHERE table_schema = DATABASE()') + ->fetchAll(PDO::FETCH_COLUMN); + + $this->assertNotContains( + 'mapos_schema_fingerprint', + $tabelas, + 'A impressão digital não pode virar uma tabela, ou o check-schema-parity.php vai acusar drift.' + ); + + $this->assertFileExists( + $this->caminhoDaImpressaoDigital(), + 'A impressão digital precisa existir em algum lugar, e esse lugar é o filesystem ao lado do banco.' + ); + } + + /** + * O banco descartável, criado vazio. + * + * `recreate()` e não `CREATE DATABASE IF NOT EXISTS`: os casos precisam de um + * schema garantidamente vazio, e o que sobrou de uma execução anterior — ou de + * um caso anterior que falhou antes do `#[After]` — faria a conferência de + * `migrations` começar em cima de algo. A impressao digital é apagada junto, + * pelo mesmo motivo: ela é o estado que precisa não sobreviver. + */ + private function bancoDeDescarte(): TestDatabase + { + $test = TestDatabase::fromEnvironment(); + + $test->recreate($this->banco); + $test->forgetSchemaFingerprint($this->banco); + + return $test; + } + + /** + * A impressão digital muda quando o conteúdo de um arquivo muda. + * + * É a propriedade que justifica a impressão digital existir, e a que a versão + * sozinha não cobre: editar uma migration que JÁ RODOU, ou uma seed, não + * muda o número do arquivo, então `migrations.version` continua em dia e o + * banco velho seria aprovado. Um hash que levasse só o nome do arquivo + * passaria por cima disso, e foi o que este caso encontrou: ele falhou com + * o manifesto sem `hash_file()` e passou sem o manifesto. + * + * O mesmo caminho é reescrito entre os dois hashes, porque é essa a situação + * real — é o mesmo arquivo, editado. Dois arquivos de nomes diferentes + * dariam hash diferente mesmo com o conteúdo igual, e o caso não diria nada. + */ + public function testTheFingerprintChangesWhenTheContentOfAFileChanges(): void + { + $arquivo = $this->arquivoTemporario(); + + file_put_contents($arquivo, "-- conteudo original\n"); + $antes = TestDatabase::fingerprintFor(['seeds/Usuarios.php' => $arquivo]); + + file_put_contents($arquivo, "-- conteudo editado\n"); + $depois = TestDatabase::fingerprintFor(['seeds/Usuarios.php' => $arquivo]); + + $this->assertNotSame( + $antes, + $depois, + 'Editar o conteúdo de uma seed ou migration não mudou a impressão digital, então um banco construído antes da edição seria reaproveitado.' + ); + } + + /** + * A impressão digital é estável para o mesmo conjunto de arquivos. + * + * O outro lado da mesma verdade: se ela mudasse sozinha, nenhum banco seria + * jamais reaproveitado e a otimização vira um `--fresh` disfarçado, que é a + * falha silenciosa oposta. + */ + public function testTheFingerprintIsStableForTheSameFiles(): void + { + $arquivo = $this->arquivoTemporario(); + file_put_contents($arquivo, "-- conteudo\n"); + + $this->assertSame( + TestDatabase::fingerprintFor(['seeds/Usuarios.php' => $arquivo]), + TestDatabase::fingerprintFor(['seeds/Usuarios.php' => $arquivo]), + 'A impressão digital não pode mudar entre duas leituras do mesmo conjunto de arquivos.' + ); + + // E a ordem de entrada não pode importar, senão o hash dependeria da + // ordem em que o glob devolveu os arquivos. + $outro = $this->arquivoTemporario(); + file_put_contents($outro, "-- outra seed\n"); + + $this->assertSame( + TestDatabase::fingerprintFor(['a.php' => $arquivo, 'b.php' => $outro]), + TestDatabase::fingerprintFor(['b.php' => $outro, 'a.php' => $arquivo]), + 'A ordem em que os arquivos são passados não pode mudar a impressão digital.' + ); + } + + /** + * O rótulo entra no hash, porque renomear uma migration muda o schema. + * + * Renomear não altera uma linha de código, mas altera a ordem em que a + * cadeia de migrations roda — que é a definição de schema diferente. + */ + public function testTheFingerprintCountsTheLabelAndNotOnlyTheContent(): void + { + $arquivo = $this->arquivoTemporario(); + file_put_contents($arquivo, "-- conteudo\n"); + + $this->assertNotSame( + TestDatabase::fingerprintFor(['migrations/20260101000000_x.php' => $arquivo]), + TestDatabase::fingerprintFor(['migrations/20260101000001_x.php' => $arquivo]), + 'Dois nomes com o mesmo conteúdo precisam de impressões digitais diferentes.' + ); + } + + /** + * Um arquivo que sumiu faz a impressão digital falhar, e não passar. + * + * O modo de falha perigoso aqui não é a exceção: é o silêncio. Uma seed + * apagada faria o hash cobrir menos arquivos, continuaria sendo um hash + * válido, e o banco construído com a seed presente seria aprovado como + * atual — que é justamente asituação que a impressão digital existe para + * pegar. Por isso a lista vazia também é recusada. + */ + public function testTheFingerprintRefusesToRunOnAnIncompleteFileList(): void + { + $arquivo = $this->arquivoTemporario(); + file_put_contents($arquivo, "-- conteudo\n"); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/Não consegui ler/'); + + TestDatabase::fingerprintFor(['seeds/Usuarios.php' => $arquivo . '.inexistente']); + } + + /** + * Uma lista sem nenhum arquivo não é uma impressão digital. + * + * Sem este caso, um diretório de migrations vazio — o estado de um checkout + * pela metade, por exemplo — produziria um hash de lista vazia, estável, e o + * banco seria aprovado. + */ + public function testTheFingerprintRefusesToRunOnNoFilesAtAll(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/sem nenhum arquivo/'); + + TestDatabase::fingerprintFor([]); + } + + private function caminhoDaImpressaoDigital(): string + { + return sys_get_temp_dir() . '/mapos-test-schema/' . $this->banco . '.hash'; + } + + /** + * Um arquivo descartável fora do repositório. + * + * Fora de propósito: escrever em `application/database/` durante um teste + * transformaria a impressão digital do banco real em resíduo de teste, e o + * banco de testes da suíte passaria a remontar por causa de um arquivo que + * sobrou. + */ + private function arquivoTemporario(): string + { + $path = tempnam(sys_get_temp_dir(), 'mapos-fingerprint-'); + + $this->assertIsString($path, 'tempnam() deveria ter criado um arquivo temporário.'); + + $this->temporarios[] = $path; + + return $path; + } +} diff --git a/application/tests/Support/TestFixtures.php b/application/tests/Support/TestFixtures.php index d365c3ef0..0d207fe6a 100644 --- a/application/tests/Support/TestFixtures.php +++ b/application/tests/Support/TestFixtures.php @@ -20,28 +20,62 @@ final class TestFixtures */ public static function install(): array { - $installed = []; + return array_merge( + self::runSeeds(['Permissoes', 'Usuarios', 'Configuracoes']), + self::addExtraUsers() + ); + } + + /** + * Só os usuários, para a reinstateção por teste. + * + * Separado de install() porque os dois chamadores precisam de coisas opostas. + * A montagem do banco quer as três seeds e nunca pode repeti-las: a seed + * Usuarios grava um idUsuarios explícito, e um segundo INSERT no mesmo + * AUTO_INCREMENT PRIMARY KEY aborta com 1062. A reinstateção por teste quer o + * contrário — apaga e regrava os usuários a cada caso. + * + * `configuracoes` é justamente o que a reinstateção não pode tocar: 13 das suas + * 14 linhas vêm da seed, mas a `email_automatico` vem de uma migration e + * nenhuma seed a recria. Apagar a tabela e rodar a seed de novo deixaria o banco + * sem a linha, e a montagem seguinte nem perceberia, porque reconstrói tudo do + * zero e voltaria a tê-la. + */ + public static function installUsers(): void + { + self::runSeeds(['Usuarios']); + + self::addExtraUsers(); + } - foreach (['Permissoes', 'Usuarios', 'Configuracoes'] as $seed) { + /** + * Roda as seeds indicadas e devolve os nomes, na ordem em que rodaram. + * + * O corpo é único para install() e installUsers() de propósito: são as mesmas + * classes de seed e o mesmo tratamento da saída, que elas ecoam na saída padrão. + * A reinstateção por teste roda isto dentro de um gancho do PHPUnit, então a + * saída também precisaria sumir ali. + * + * @param string[] $seeds + * @return string[] + */ + private static function runSeeds(array $seeds): array + { + foreach ($seeds as $seed) { self::load($seed); } - // As seeds ecoam o progresso na saída padrão, e esta função roda no meio - // do setup-db.php. ob_start(); try { - foreach (['Permissoes', 'Usuarios', 'Configuracoes'] as $seed) { + foreach ($seeds as $seed) { (new $seed())->run(); - $installed[] = $seed; } - - $installed = array_merge($installed, self::addExtraUsers()); } finally { ob_end_clean(); } - return $installed; + return $seeds; } /** diff --git a/application/tests/Support/TransactsDatabase.php b/application/tests/Support/TransactsDatabase.php index 577781ed0..ea3d35e4d 100644 --- a/application/tests/Support/TransactsDatabase.php +++ b/application/tests/Support/TransactsDatabase.php @@ -68,9 +68,50 @@ * tem um grupo só, e é ele que esta trait usa; um * `$this->load->database('outro')` abriria uma segunda conexão que a * transação não alcança. + * + * ## A reinstateção da linha de base + * + * A transação desfaz o que o caso escreve, mas não desfaz o que a execução + * ANTERIOR deixou, e é aí que entra o opt-in. Uma classe que sobrescreve + * `protected function resetsBaselineData(): bool` para devolver true recebe, + * antes de cada caso e dentro da transação que a trait acabou de abrir: + * + * 1. a conferência de que `logs` está vazia; + * 2. o DELETE dos usuários e a reinstallação deles por TestFixtures. + * + * Opt-in, e não automático, por dois motivos. O primeiro é de custo: uma + * classe que não escreve no banco não tem o que ganhar aqui, e paga o preço em + * todo caso. O segundo é o TransactsDatabaseTest, que precisa que `logs` NÃO + * seja limpa entre os seus dois casos: é justamente a linha que sobrou ou não + * que prova que a trait descarta em vez de commitar. Limpar `logs` aqui não + * custaria nada e tornaria essa prova uma tautologia. + * + * Um limite deste opt-in que vale registrar: a chamada em si é invisível de + * dentro da suíte. Como tudo roda dentro de transação, o estado de `usuarios` no + * corpo de um caso é o das fixtures com ou sem a reinstateção — desligá-la por + * completo deixa a suíte inteira verde, o que já foi verificado. O + * BaselineDataResetTest por isso chama o método diretamente, e o que pega a + * direção perigosa (commit em vez de rollback) é o guard de `logs`. A + * reinstalação que chega de uma execução anterior é barrada pelo + * setup-db.php, que confere as três contas antes de reaproveitar o banco. */ trait TransactsDatabase { + /** + * A classe pede a reinstateção da linha de base antes de cada caso. + * + * Um método, e não uma propriedade, por um motivo concreto: o PHP trata a + * colisão entre uma propriedade de trait e uma da classe que a usa como + *_definition diferente_ e aborta o processo inteiro com "define the same + * property" — mesmo quando os tipos batem, porque os valores padrão não + * batem. Um método tem sobrescrita normal, e o padrão `false` é a resposta + * para o caso de a classe não se manifestar. + */ + protected function resetsBaselineData(): bool + { + return false; + } + /** * Abre a transação do caso, e recusa continuar se a anterior ficou aberta. * @@ -104,6 +145,83 @@ protected function setUpDatabaseTransaction(): void if ($aberto !== 1) { $this->fail("trans_begin() retornou verdadeiro, mas o depth ficou em {$aberto} em vez de 1."); } + + // Depois do BEGIN, nunca antes: a reinstateção também tem de ser + // descartada. Feita antes, um DELETE escapa da transação e sobrevive ao + // rollback, que é o vazamento que a trait existe para impedir. + if ($this->resetsBaselineData()) { + $this->resetBaselineData($db); + } + } + + /** + * Devolve a linha de base ao estado em que a montagem do banco a deixou. + * + * "Linha de base" é o que existe quando nada foi escrito ainda: as três + * contas de usuário das fixtures. Nenhuma outra tabela é tocada, e a escolha + * de `usuarios` não é arbitrária — é a única que os testes alteram, porque + * `LoginControllerTest` muda o `dataExpiracao` de uma conta. Quando outra + * passar a ser alterada, é aqui que entra, e a conferência de `logs` abaixo + * não serve de aviso para isso: ela só enxerga o que sobreviveu a um commit. + * + * O DELETE, e não o TRUNCATE, porque TRUNCATE é DDL: ele faz commit + * implícito e derrubaria a transação que a trait acabou de abrir, deixando o + * `trans_begin()` de antes sem efeito e os testes seguintes escrevendo em + * autocommit. O efeito de um DELETE InnoDB dentro de transação é o mesmo e + * o custo é irrelevante para três linhas. + * + * E a comparação na chave primária, e não um `where('1 = 1')`: o CI3 recusa + * um DELETE sem WHERE (DB_query_builder.php:2192, `db_del_must_use_where`), + * e `idUsuarios >` diz o que quer dizer sem depender do query builder + * deixar passar uma condição constante. O operador vai na chave e não como + * segundo argumento — `where('idUsuarios', '>', 0)` monta `idUsuarios = '>'`, + * não casa com nada e ainda assim devolve `true`, que é a forma mais + * silenciosa de um DELETE não apagar nada. + */ + private function resetBaselineData(object $db): void + { + // `logs` crescendo é a assinatura de uma transação que foi commitada em + // vez de descartada, e esta suíte grava uma linha por login bem + // escorado. A conferência é aqui, e não no setup, porque os dois casos + // do TransactsDatabaseTest são a prova de que a trait funciona: se + // alguma coisa limpasse `logs` entre eles, os dois passariam tanto com + // o rollback quanto sem ele. + // + // Limpar aqui esconderia justamente o bug que este arquivo existe para + // pegar, então a falha aponta a saída em vez de mascará-la. + $vazamento = $this->countLogs($db); + + if ($vazamento > 0) { + $this->fail( + "A tabela `logs` tem {$vazamento} linha(s) que sobreviveram à transação de um teste " + . 'anterior. Uma transação foi commitada, ou um tearDown sem transação, ou um teste ' + . 'que levantou exceção antes do fechamento. Este arquivo não limpa `logs` de ' + . "propósito, porque o par que prova o rollback depende delas. Rode 'composer test:fresh' " + . 'se a sujeira veio de fora da suíte.' + ); + } + + $db->where('idUsuarios >', 0)->delete('usuarios'); + + TestFixtures::installUsers(); + + $restaurados = $db->count_all_results('usuarios'); + + if ($restaurados !== 3) { + $this->fail( + "A reinstateção da linha de base deixou {$restaurados} usuário(s) em vez de 3. " + . "O TestFixtures::installUsers() parou no meio, ou outro teste mexeu na tabela " + . "sem transação. Rode 'composer test:fresh' para remontar o banco." + ); + } + } + + /** + * @return int quantas linhas o último teste deixou em `logs` + */ + private function countLogs(object $db): int + { + return (int) $db->count_all_results('logs'); } /** diff --git a/application/tests/bin/setup-db.php b/application/tests/bin/setup-db.php index 3ae0b2669..a34aa9547 100644 --- a/application/tests/bin/setup-db.php +++ b/application/tests/bin/setup-db.php @@ -15,8 +15,13 @@ * check-schema-parity.php faz essa comparação e roda no CI; o banco.sql continua * sendo a fonte do instalador. * - * A referência (permissão, configuração e administrador) vem das seeds - * canônicas, as mesmas do Tools::seed(). + * "Mantém o schema, limpa os dados" é a divisão do trabalho aqui. O schema é caro + * (a cadeia de migrations leva ~9s) e raramente muda, então ele é reaproveitado + * quando isSchemaCurrent() diz que está em dia. Os dados são o que a execução + * anterior suja, e quem os limpa antes de cada caso é a TransactsDatabase, não este + * script — ver o método resetBaselineData(). + * + * `--fresh` ignora a verificação e remonta tudo, para quando o atalho não serve. */ use Tests\Support\TestApplication; @@ -34,12 +39,28 @@ fwrite(STDOUT, $message . PHP_EOL); }; -$echo("Montando o banco '{$database}' a partir das migrações."); +// --fresh monta do zero. O caminho padrão não pergunta: perguntar por padrão é +// interativo, e um script de CI precisa responder sozinho. +$fresh = in_array('--fresh', $argv, true); +$reusable = ! $fresh && $test->isSchemaCurrent($database); + +if ($fresh) { + // A impressão digital é removida junto com o banco. Sem isto, uma montagem + // --fresh que falha no meio deixaria o arquivo de uma versão que não é a do + // banco, e a execução seguinte acreditaria nele. + $test->forgetSchemaFingerprint($database); +} -// O banco precisa existir antes do index.php subir: o autoloader de 'database' -// conecta durante o boot. O DROP é o que torna isto realmente idempotente — sem -// ele, a segunda execução herda tudo que a primeira deixou no schema. -$test->recreate($database); +if ($reusable) { + $echo("Schema de '{$database}' em dia (migrations até " . TestDatabase::latestMigrationVersion() . '); reaproveitando.'); +} else { + $echo("Montando o banco '{$database}' a partir das migrações."); + + // O banco precisa existir antes do index.php subir: o autoloader de 'database' + // conecta durante o boot. O DROP é o que torna isto realmente idempotente — sem + // ele, a segunda execução herda tudo que a primeira deixou no schema. + $test->recreate($database); +} TestApplication::boot(); @@ -50,18 +71,34 @@ exit(1); } -$echo('Esquema criado pelas migrações.' . PHP_EOL); +if ($reusable) { + // As seeds NÃO rodam no caminho reaproveitado, e é a única coisa aqui que não + // é óbvia: a seed Usuarios grava um idUsuarios explícito, então repetir a + // inserção numa tabela que já tem a linha aborta com 1062. O que substitui a + // seed é a conferência de baixo, que roda nos dois caminhos. + $echo('Reference data: reaproveitada.'); +} else { + $echo('Esquema criado pelas migrações.' . PHP_EOL); + + $installed = TestFixtures::install(); -$installed = TestFixtures::install(); + $echo(sprintf('Reference data: %s.', implode(', ', $installed))); -$echo(sprintf('Reference data: %s.', implode(', ', $installed))); + // A impressão digital só vale depois que a montagem deu certo, e é ela que + // autoriza a próxima execução a pular o caminho caro. + $test->recordSchemaFingerprint($database); +} // Confere o resultado, para uma montagem quebrada falhar aqui e não no meio de -// um teste com uma mensagem sem pistas. +// um teste com uma mensagem sem pistas. No caminho reaproveitado esta conferência +// é a verificação de que as fixtures estão lá, já que nenhuma seed rodou. $usuarios = get_instance()->db->count_all_results('usuarios'); if ($usuarios !== 3) { fwrite(STDERR, "Esperava 3 usuários e encontrei {$usuarios}.\n"); + fwrite(STDERR, $reusable + ? "O banco foi reaproveitado sem as fixtures. Rode 'composer test:fresh' para remontá-lo.\n" + : "As fixtures não instalaram os três usuários.\n"); exit(1); } diff --git a/composer.json b/composer.json index a2bea2c9b..073c35b58 100644 --- a/composer.json +++ b/composer.json @@ -42,6 +42,9 @@ "test:db": [ "@php application/tests/bin/setup-db.php" ], + "test:fresh": [ + "@php application/tests/bin/setup-db.php --fresh" + ], "check:parity": [ "@php application/tests/bin/check-schema-parity.php" ] From 581e52c79e88edded26e1d14fb45ee9922eb0637 Mon Sep 17 00:00:00 2001 From: Gianluca Bine Date: Tue, 29 Sep 2026 00:52:47 -0300 Subject: [PATCH 10/17] =?UTF-8?q?fix(security):=20endurece=20o=20gate=20de?= =?UTF-8?q?=20XSS=20contra=20a=20varredura=20que=20n=C3=A3o=20leu=20nada?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O gate de escape reprovava 0 achados com código 0 quando não lia nenhuma view, porque o caminho de `--update-baseline` transformava uma lista vazia em um arquivo de baseline vazio, sem erro. A partir dali o gate aprovava tudo: as 234 entradas de decisão sumiam e nenhuma voltava a ser conferida. A guarda de contagem de arquivos entra ANTES do caminho de escrita, e sai com 2 — distinto do 1, que pede correção numa view. "Não achei nada" e "não havia nada" são respostas diferentes, e só a primeira é um erro. O texto do relatório e o cabeçalho do baseline saem do script de linha de comando para `Report`, e passam a ser gerados a partir do próprio scan: as contagens que o cabeçalho afirma não podem divergir das entradas que ele descreve. A data original é preservada, porque ela é registro e contagem de execução não é. `--update-baseline` passa a mesclar em vez de sobrescrever, o que era a instrução que se anulava sozinha: o AGENTS.md mandava rodar o comando e depois explicar as entradas, e a primeira metade apagava a segunda. O relatório também deixa de ler a convenção de volta a partir da chave. `ViewScanner` devolve o agrupamento por conferência como dado, e ninguém mais precisa saber que o prefixo mora no meio da string. --- application/helpers/redirect_helper.php | 4 +- .../views/arquivos/adicionarArquivo.php | 2 +- application/views/arquivos/arquivos.php | 2 +- application/views/arquivos/editarArquivo.php | 2 +- .../views/clientes/adicionarCliente.php | 2 +- application/views/clientes/editarCliente.php | 2 +- application/views/conecte/adicionarOs.php | 2 +- application/views/conecte/cadastrar.php | 2 +- application/views/conecte/detalhes_os.php | 2 +- application/views/conecte/editar_dados.php | 2 +- application/views/financeiro/lancamentos.php | 6 +- .../views/garantias/adicionarGarantia.php | 2 +- .../views/garantias/editarGarantia.php | 2 +- application/views/mapos/configurar.php | 2 +- application/views/mapos/pesquisa.php | 2 +- application/views/os/adicionarOs.php | 2 +- application/views/os/editarOs.php | 28 +- application/views/os/visualizarOs.php | 2 +- .../views/produtos/adicionarProduto.php | 2 +- application/views/produtos/editarProduto.php | 2 +- .../views/servicos/adicionarServico.php | 2 +- application/views/servicos/editarServico.php | 2 +- application/views/tema/conteudo.php | 8 +- .../views/usuarios/adicionarUsuario.php | 2 +- application/views/usuarios/editarUsuario.php | 2 +- application/views/vendas/adicionarVenda.php | 2 +- application/views/vendas/editarVenda.php | 18 +- tools/ViewEscaping/EscapingChecks.php | 329 +++++++ tools/ViewEscaping/EscapingPolicy.php | 455 +++++++++ tools/ViewEscaping/PhpExpression.php | 520 +++++++++++ tools/ViewEscaping/Report.php | 259 ++++++ tools/ViewEscaping/Rule.php | 40 + tools/ViewEscaping/Rules.php | 164 ++++ tools/ViewEscaping/Verdict.php | 89 ++ tools/ViewEscaping/ViewScanner.php | 167 ++++ tools/check_view_escaping.php | 862 ++---------------- tools/xss-baseline.txt | 508 ++++++++++- 37 files changed, 2657 insertions(+), 844 deletions(-) create mode 100644 tools/ViewEscaping/EscapingChecks.php create mode 100644 tools/ViewEscaping/EscapingPolicy.php create mode 100644 tools/ViewEscaping/PhpExpression.php create mode 100644 tools/ViewEscaping/Report.php create mode 100644 tools/ViewEscaping/Rule.php create mode 100644 tools/ViewEscaping/Rules.php create mode 100644 tools/ViewEscaping/Verdict.php create mode 100644 tools/ViewEscaping/ViewScanner.php diff --git a/application/helpers/redirect_helper.php b/application/helpers/redirect_helper.php index ab4a803e2..74220d861 100644 --- a/application/helpers/redirect_helper.php +++ b/application/helpers/redirect_helper.php @@ -8,7 +8,9 @@ * Separado de respond_redirect() para ser verificável. O status sai por * header() nativo, e em CLI isso não é observável: não há getter no CI_Output e * xdebug_get_headers() não existe. Como uma função pura, a regra fica coberta de - * verdade em vez de ser apenas replicada na documentação. + * verdade em vez de ser apenas replicada na documentação — a tabela toda está em + * GeneralHelperTest, e é o único lugar onde ela deve ser testada. O controller + * não é o dono da regra, e um teste dele aqui seria a mesma tabela duas vezes. * * 307 e 303 preservam o método da requisição, o que é o que se quer num POST. * Fora de HTTP/1.1 não há informação confiável e o redirect() cai em 302. diff --git a/application/views/arquivos/adicionarArquivo.php b/application/views/arquivos/adicionarArquivo.php index f44b45073..4d974b10d 100644 --- a/application/views/arquivos/adicionarArquivo.php +++ b/application/views/arquivos/adicionarArquivo.php @@ -13,7 +13,7 @@
-
+
diff --git a/application/views/arquivos/arquivos.php b/application/views/arquivos/arquivos.php index c04c8503f..4934974c9 100644 --- a/application/views/arquivos/arquivos.php +++ b/application/views/arquivos/arquivos.php @@ -9,7 +9,7 @@
Arquivos
- + permission->checkPermission($this->session->userdata('permissao'), 'aArquivo')) : ?>
- +
diff --git a/application/views/clientes/adicionarCliente.php b/application/views/clientes/adicionarCliente.php index 0fc1daa0c..7d64f0fbe 100644 --- a/application/views/clientes/adicionarCliente.php +++ b/application/views/clientes/adicionarCliente.php @@ -88,7 +88,7 @@ ' . $custom_error . '
'; } ?> - +
diff --git a/application/views/clientes/editarCliente.php b/application/views/clientes/editarCliente.php index 1d11a1385..c6d212844 100644 --- a/application/views/clientes/editarCliente.php +++ b/application/views/clientes/editarCliente.php @@ -88,7 +88,7 @@ ' . $custom_error . '
'; } ?> - +
diff --git a/application/views/conecte/adicionarOs.php b/application/views/conecte/adicionarOs.php index b82133595..b15133b8f 100644 --- a/application/views/conecte/adicionarOs.php +++ b/application/views/conecte/adicionarOs.php @@ -34,7 +34,7 @@
- +
diff --git a/application/views/conecte/cadastrar.php b/application/views/conecte/cadastrar.php index e8ab2d6fc..3fae01cfc 100644 --- a/application/views/conecte/cadastrar.php +++ b/application/views/conecte/cadastrar.php @@ -136,7 +136,7 @@ echo '
' . $custom_error . '
'; } ?>
- +
diff --git a/application/views/conecte/detalhes_os.php b/application/views/conecte/detalhes_os.php index 4e3fb8ab9..1c8ec81b9 100644 --- a/application/views/conecte/detalhes_os.php +++ b/application/views/conecte/detalhes_os.php @@ -264,7 +264,7 @@