feat: testes do auditoria controller - #2823
Merged
Merged
Conversation
…eções MY_Controller e os guards de permissão de Auditoria, Permissoes e Usuarios chamavam redirect(), que chama exit() — o que impedia a suíte in-process de observar qualquer coisa, já que um exit() no meio de um request acaba com o processo do PHPUnit, sem summary e sem dizer qual caso morreu. Os guards agora lançam AuthenticationRequired (401) e AuthorizationDenied (403), e o index.php captura e devolve exatamente a resposta de antes: 307 para o login quando não há sessão, redirect para a home no 403, e JSON na API em vez de redirecionar. A exceção carrega só o status. Quem decide o destino é o renderizador, porque uma URL escolhida pelo guard a partir de dado de requisição seria um redirecionamento aberto — e a diferença entre 401 e 403 importa para quem usa a tela: quem não entrou ainda pode entrar, quem entrou sem permissão não resolve nada entrando de novo. A cobertura mede as duas metades. AuthorizationGuardControllerTest roda os guards in-process e afirma tipo, status, mensagem e flashdata; FrontendBoundaryTest sobe um php -S como processo filho e afirma o que sai do servidor, o 307 com Location e o 401 JSON da API inalterado, que é a parte que header() em CLI não deixa conferir.
…nd_redirect() As tres strings do clean() viram constantes public, porque a tela e o proprio log da auditoria passam a ter uma fonte so: o teste as consome em vez de repetir o texto, e uma troca de wording na tela deixa de quebrar a suite em silencio. O redirect() do CI3 chama exit(), e no caminho de sucesso do clean() isso fechava o processo do PHPUnit inteiro — sem summary e sem dizer qual caso morreu. respond_redirect() (o mesmo que o Login::sair() usa) emite o Location pelo CI_Output sem encerrar o processo, e o status continua sendo o que o redirect() escolheria, porque resolve o numero por redirect_status_for(): 303 num POST, regra que ja e coberta em GeneralHelperTest.
…primeira view O Loader::_ci_load() e quem da vida ao $this dentro de uma view: ele copia para o Loader, por referencia, as propriedades do controller — e so copia a que ainda nao existe (isset($this->$chave)). Como o Loader e singleton, a primeira view do processo congela ali o retrato do primeiro controller, e todos os outros leem aquele objeto, que nao existe mais. O sintoma e sutil, e nada no controller o explica. Uma library carregada no meio do metodo (o pagination do Auditoria::index()) ganha a propriedade nova do controller, e o $this->pagination do controller funciona; mas a view renderizada logo depois le o retrato velho, que aponta para o objeto do controller ANTERIOR, com o total_rows de um caso que ja virou. Quem exercita a tela pela segunda vez no processo ve os links de pagina sumirem. resetLoaderViewAliases() roda em resetSharedState(), antes de cada controller, e unseta no Loader tudo que nao for _ci_*. O guard nao e defesa teorica: Closure::call() reajusta o escopo para a classe do objeto, entao o get_object_vars() enxerga as propriedades protegidas do Loader. Sem o filtro, o unset levaria junto _ci_models e _ci_classes, e o Loader que deveria esquecer o controller velho passaria a esquecer tambem os models que ele carrega. Verifiquei que o teste e carga de verdade: desligando o reset de proposito, testIndexRendersPaginationReflectingCurrentData falha ao afirmar que o create_links() renderizou.
Oito casos para o index() e o clean(), que estavam sem cobertura. O guard de construtor nao e repetido aqui: AuthorizationGuardControllerTest ja o usa como representante da familia. O caso de paginacao renderiza duas vezes no mesmo teste — vazio, depois com onze linhas — em vez de depender de rodar depois de outro caso. E o que torna o reset do Loader verificavel: a primeira renderizacao congela um retrato vazio, e a segunda leria o retrato velho, com total_rows de zero, se o harness nao o tivesse descartado. Um teste de uma renderizacao so pegaria isso por dependencia de ordem, e nao como prova. Verifiquei desligando o reset de proposito: o caso fica vermelho. A limpeza e afirmada por identidade de tarefa, e nao por contagem total. Um count_all() sobre o que sobrou confunde a remocao com a linha que o proprio clean() registra no log_info(), e as duas coisas se cancelariam. O corte de 30 dias e firmado pelo dia — 31 e 60 saem, 30 fica — porque o model compara com < estrito, e trocar por <= e o tipo de troca que nenhum teste perceberia. O conteudo do log entra escapado, com um <script> que precisa chegar como texto.
O retrato de view entra na lista de reentrancia que resetSharedState() tem de consertar, junto do _ci_models e do CI_Controller::$instance. Vale o detalhe de por que o sintoma so aparece quando alguma view renderiza tema/*: ate la, o objeto congelado no Loader nao tem nada que um controller seguinte leia de forma diferente. E a regra 5 dos padroes: identificadores em ingles, comentario em portugues, texto de tela em portugues. A suite in-process le codigo applicado com nomes em portugues, e a convencao precisa estar escrita para não virar divergencia.
… guarda O CI falhava em dois casos do FrontendBoundaryTest, e nenhum deles tinha a ver com a auditoria — os dois reproduzi em develop, sem este branch. O primeiro: /index.php/api/v1/clientes respondia 404 onde o caso exige 401. O routes.php so exige o routes_api.php quando $_ENV['API_ENABLED'] e verdadeiro, e essa chave mora no application/.env, que o git ignora. No CI, sem .env, a tabela de rotas da API nao existe e o caso que deveria medir o 401 da API media o 404. Na maquina de quem tem .env com API_ENABLED=true o caso passa, e o furo so aparece onde ninguem tem .env. O segundo e consequencia do primeiro: o 404 e logado em nivel ERROR, e com log_threshold = 1 ERROR vira arquivo, entao o processo filho escrevia application/logs/log-<data>.php — que e exatamente o que o outro caso proibe. Ver a cadeia inteira: o 404 fatalizou em esc() na view de erro 404, e esse fatal tb virou log. Quem escreve no log nao era so o 404. O config.php le APP_ENCRYPTION_KEY, GLOBAL_XSS_FILTERING, API_JWT_KEY e API_TOKEN_EXPIRE_TIME de $_ENV sem valor padrao, entao cada ausencia e um aviso que o CI3 grava no log. E o WhoopsHook::extractEnvNames() fazia file() no .env sem Existence of the conditional — um .env e opcional por construcao, ja que o index.php so o carrega com file_exists, entao num install sem ele essa linha era mais um aviso, a cada requisicao. As cinco chaves passam a ir para o filho por childEnvironment(), que e o metodo que existe para isso, e vem do $_ENV que o TestDatabase::fromEnvironment() acabou de publicar: o .env de quem desenvolve continua mandando e a ausencia no CI deixa de ser diferenca de comportamento. O extractEnvNames() ganha o is_file() que ele ja devia ter. Verifiquei nos dois lados: 324 casos verdes na arvore de trabalho, e 316 verdes num worktree de develop sem application/.env, que e a condicao do CI. O worktree sem .env e o que prova a correcao — antes, os dois casos falhavam exatamente la.
O AGENTS.md ja dizia que os logs da suíte vão para o diretório temporário e que a FrontendBoundaryTest é a exceção: o filho roda como production para ter o roteamento de verdade, então escreve em application/logs/, e um caso afirma que a árvore fica limpa. Faltava o porquê do lado do caminho de configuração. Vale registrar porque a armadilha é invisível na máquina de desenvolvimento: application/.env é gitignored, e um teste que lê config que o app só pega dali passa em quem tem o arquivo e falha em todo runner. Foi assim que API_ENABLED — que decide se routes.php exige o routes_api.php — fez o caso da API medir um 404 em vez do 401.
Uma rota inexistente respondia 500 em vez de 404, com "Call to undefined
function esc()". A pagina de erro do Router e incluida por
`Exceptions::show_404()` antes de existir qualquer controller, e portanto antes
de `Loader::initialize()` -- que e o unico lugar onde o autoload roda e carrega o
helper `general`. As sete views `errors/{html,cli}/error_*.php` que chamavam
`esc()` morriam nesse ponto, trocando a pagina de erro por um fatal.
Elas passam a escapar com `htmlspecialchars((string) $x, ENT_QUOTES |
ENT_SUBSTITUTE, 'UTF-8')`: as mesmas flags de `esc()`, mas PHP core, que funciona
em qualquer ponto do boot e nao depende de nada ja carregado. O guarda de tipo de
`esc_scalar()` nao se perde de verdade -- `show_error()` ja transformou `$message`
em string antes de a view ver, e o `(string)` cobre o severity e os numeros de
linha de `error_php.php` e `error_exception.php`.
A alternativa tentada era um `require_once` do helper no `index.php`, e ela foi
descartada: so vale enquanto `general_helper.php` for nada alem de definicoes de
funcao sob `function_exists`, nada obriga isso, e a quebra cairia no unico
arquivo por onde passa toda requisicao.
Alem disso, `show_error()` embrulha `$message` em `<p>` antes de incluir a view --
e `DB_driver::display_error()` ainda a monta com `implode('</p><p>', ...)` --
entao o que chega ja vem com markup de outra pessoa. Escapado, isso virava texto
visivel: um 404 mostrava `<p>The page you requested was not
found.</p>`. As tres views que imprimem a mensagem solta tiram as tags de
paragrafo e emitem o `<p>` delas. `strip_tags()` nao serve: ele come o
`WHERE x<3` de um erro de SQL de verdade.
Os tres templates de CLI que nunca usaram `esc()` seguem intactos: o que sai
deles e texto para um terminal, nao markup.
`testAnUnknownRouteRendersTheErrorPage` pede uma rota inexistente ao filho real
e afirma o 404, o texto "404 Page Not Found" no corpo e a ausencia de "undefined
function". O status sozinho nao bastava: um 404 com HTML de erro do PHP tambem
passaria, e e o texto que separa os dois.
O caso carrega uma mudanca de ambiente que ele mesmo exige. `show_404()` chama
`log_message('error', ...)` antes de renderizar, entao um 404 escreve log de
proposito -- e um arquivo em `application/logs/` faz o `composer format:check`
falhar, porque essa checagem nao honra `.gitignore`. Da a `APP_LOG_PATH`, que o
`config.php` passa a respeitar, e o filho aponta o `log_path` para o mesmo
diretorio temporario que `config/testing/config.php` usa. Era o caminho que a
mensagem de falha de `testTheChildLeavesNoLogFileInTheSourceTree` ja pedia, e
agora ele existe para responder a ela.
Aquele outro caso tambem mudou, e nao por recorte: a referencia passou de
`startServer()` para imediatamente antes das proprias requisicoes. Medindo com o
estado do boot, ele dependia da ordem em que a classe roda, e a escrita
legitima do 404 virava a linha de base dele -- um teste que afirma "nada foi
escrito" nao pode ter a escrita de outro teste como referencia.
Verificado com um `esc()` de volta no `error_404.php`: o 404 vira 500 e este e o
unico caso que falha.
`show_error()` embrulha `$message` em `<p>` ANTES de incluir a view, e `DB_driver::display_error()` junta um array de erros com `</p><p>`. O que chega na view ja e markup, e e do CodeIgniter. Escapar isso mostrava ao usuario `<p>The page you requested was not found.</p>` como texto literal, dentro de uma pagina de erro que existe justamente para explicar a falha. Tirar as tags de paragrafo seria a outra opcao, e e pior: um `display_error()` de verdade entrega um erro de SQL de verdade, e o `strip_tags()` come o `x<3` de um `WHERE x<3` -- a mensagem daquela pagina que voce menos pode embaralhar. As tres views que imprimem a mensagem solta passam a imprimir crua dentro de uma `div#body`, e isso e uma decisao registrada: sao as seis entradas "unescaped" do `tools/xss-baseline.txt`, com a exposicao que sobra escrita no header do arquivo (a database_error repetindo um valor da query). `error_exception.php` e `error_php.php` seguem escapando cada valor com `htmlspecialchars((string) $x, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')`, e sao a referencia do que as outras tres seriam se a entrada delas um dia virasse controlada por quem fez a request. As tres de CLI seguem sem escaper: o destino delas e um terminal, nao markup. Verificado renderizando as duas: o 404 sai com o paragrafo como markup, e o erro de banco sai com os dois paragrafos e o `x<3` inteiro.
O baseline ganhou seis entradas "unescaped" -- `$heading` e `$message` em `error_404`, `error_db` e `error_general` -- e elas precisam da justificativa escrita, porque sao a unica parte do arquivo que hoje e uma decisao sobre o que NAO e escapado. O header gerado pelo `--update-baseline` foi trocado por texto proprio: ele descreve as entradas como divida de um furo no gate, o que aqui seria mentira -- estas foram revisadas, e a razao delas e o outro lado do problema de include. Fica escrito o que fica sem escapar (a mensagem do próprio framework), o que sobra exposto (um erro de banco repetindo um valor da query) e que as duas views que escapam tudo sao a referencia do que as outras tres seriam se a entrada delas virasse controlada por quem fez a request. O `AGENTS.md` dizia o contrario do que o código faz -- "espejar com htmlspecialchars" e "tirar as tags de paragrafo" -- e mandaria o próximo agente para o caminho que produz a página quebrada.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Descrição
Cobre a tela de auditoria (
GET /index.php/auditoria) e a limpeza de logs (auditoria/clean), que estavam sem nenhum teste, e conserta o que esses testes revelaram. O caminho passa por dois defeitos na própria página de erro do CI3, que aparecia como um 500 em cima de toda rota inexistente.A página de erro morria antes de renderizar. O
index.phpprecisa carregar os escapadores antes de o CI3 conseguir desenhar uma página de erro, e não depois: oExceptions::show_404()inclui a view porincludenum ponto em que oLoaderainda não rodou —Loader::initialize(), que executa o autoload e carrega o helpergeneral, só acontece no construtor deCI_Controller, e o 404 do Router acontece antes de existir qualquer controller. Uma rota inexistente respondia 500 comCall to undefined function esc()em vez de 404, ou seja, o usuário recebia um erro fatal dentro da página que existe para explicar a falha.error_exception.phpeerror_php.phppassam a escapar comhtmlspecialchars((string) $x, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'): as mesmas flags deesc(), mas PHP core, que funciona em qualquer ponto do boot e não depende de nada já carregado. Umrequire_oncedo helper noindex.phpresolveria também, e foi descartado — só vale enquantogeneral_helper.phpfor nada além de definições de função sobfunction_exists, nada obriga isso, e a quebra cairia no único arquivo por onde passa toda requisição.Escapar a mensagem das três views que a imprimem sozinha quebrava a página. O
show_error()embrulha$messageem<p>antes de incluir a view, e oDB_driver::display_error()junta um array de erros com</p><p>— o que chega na view já é markup, e é do CodeIgniter. Escapado, o usuário lia<p>The page you requested was not found.</p>como texto literal. Tirar as tags de parágrafo seria a outra opção e é pior: umdisplay_error()de verdade entrega um erro de SQL de verdade, e ostrip_tags()come ox<3de umWHERE x<3. Essas três views passam a imprimir a mensagem crua dentro de umadiv#body, e a decisão fica registrada: são as seis entradasunescapeddotools/xss-baseline.txt, com o que sobra exposto escrito no header do arquivo — a mensagem do próprio framework, restando o caso de um erro de banco que repetisse um valor vindo da query.error_exception.phpeerror_php.phpseguem escapando cada valor e são a referência do que as outras três seriam se a entrada delas um dia virasse controlada por quem fez a requisição. As três views decli/seguem sem escaper desde sempre: o destino delas é um terminal, não markup.clean()era intestável. Oredirect()do CI3 chamaexit(), e no caminho de sucesso ele encerrava o processo do PHPUnit inteiro — sem summary e sem dizer qual caso morreu. Hoje sai porrespond_redirect()(o mesmo que oLogin::sair()usa), que emite oLocationpeloCI_Outputsem encerrar o processo. O status continua sendo o que oredirect()escolheria, porquerespond_redirect()resolve o número porredirect_status_for(): 303 num POST, 302 fora de HTTP/1.1, regra que já é coberta emGeneralHelperTest.A tela lia o controller errado na segunda vez. O
Loader::_ci_load()dá vida ao$thisde uma view copiando as propriedades do controller para oLoader— mas só copia as que ainda não existem (isset($this->$chave)). Como oLoaderé singleton, a primeira view do processo congela ali o retrato do primeiro controller, e todas as outras leem aquele objeto, que não existe mais. O sintoma é sutil e nada no controller o explica: uma library carregada no meio do método (opaginationdoAuditoria::index()) ganha a propriedade nova no controller e funciona lá, mas a view renderizada logo depois lê o retrato velho, com ototal_rowsde um caso que já virou. Quem exercita a tela pela segunda vez no processo vê os links de página sumirem.resetLoaderViewAliases()roda emresetSharedState(), antes de cada controller, e unseta noLoadertudo que não for_ci_*. O guard não é defesa teórica:Closure::call()reajusta o escopo para a classe do objeto, então oget_object_vars()enxerga as propriedades protegidas doLoader— sem o filtro o unset levaria junto_ci_modelse_ci_classes.O filho da fronteira de teste nascia sem a configuração que o
.envguarda. OFrontendBoundaryTestsobe umphp -Scomoproduction— é a única forma de ter o roteamento de verdade — e oroutes.phpsó exige oroutes_api.phpquando$_ENV['API_ENABLED']é verdadeiro. Essa chave mora noapplication/.env, que é ignorado pelo git, então o caso que deveria medir o 401 da API media um 404 — em toda máquina sem.env, que é o CI inteiro. O.envlocal mascara isso. O mesmo valia paraAPP_ENCRYPTION_KEYeGLOBAL_XSS_FILTERING, lidas peloconfig.phpsem valor padrão: a ausência delas é um aviso, e um aviso é uma linha de log em nível ERROR. O filho agora recebe as chaves explicitamente emchildEnvironment(), e oWhoopsHook::extractEnvNames()parou de fazerfile()no.envincondicionalmente. Um sexto item,APP_LOG_PATH, existe pelo motivo oposto: não para impedir a escrita, mas para mandá-la para outro lugar. Oshow_404()grava emlog_message('error', …)antes de renderizar, então o caso do 404 escreve log de propósito, e um arquivo emapplication/logs/faz ocomposer format:checkfalhar — aquela checagem não honra.gitignore.O novo
AGENTS.mdregistra os dois achados na lista de reentrância que o harness conserta, a regra sobre as páginas de erro, e a regra 5 de padrão: identificadores em inglês, comentário em português, texto de tela em português.Issue relacionada
(nenhuma)
Tipo de mudança
fix)feat)docs)refactor)chore)Como testar
Para confirmar que a cobertura da paginação é carga de verdade e não tautologia, desligue a linha em
TestApplication::resetSharedState():// Ci3Introspection::resetLoaderViewAliases(self::superObject()->load);testIndexRendersPaginationReflectingCurrentDatafica vermelho ao afirmar que ocreate_links()renderizou. Com a linha de volta, a suíte fecha verde.O mesmo vale para o caso do 404, pelo outro lado.
testAnUnknownRouteRendersTheErrorPagepede uma rota inexistente ao filho real e afirma o 404, o texto404 Page Not Foundno corpo e a ausência deundefined function. Ponha umesc()de volta noerror_404.phpe ele vira o único caso vermelho da classe, comFailed asserting that 500 is identical to 404— que é exatamente o defeito que o commit anterior corrigiu.Capturas de tela
(não se aplica — a página de erro sai com o mesmo texto de antes; o que mudou foi o
<p>deixar de aparecer escapado dentro dela, eclean()continuar emitindo o mesmo flashdata e o mesmo destino)Checklist
composer format..env, credenciais, dumps de banco ou arquivos de IDE no diff.application/vendor/não foi commitada.Banco de dados
Nenhuma mudança de schema — nenhuma migration, nenhum
banco.sql. As escritas dos novos testes ficam emlogse aTransactsDatabasedesfaz cada caso.