Skip to content

feat: testes do auditoria controller - #2823

Merged
Pr3d4dor merged 11 commits into
developfrom
test/auditoria-controller
Sep 30, 2026
Merged

Pr3d4dor merged 11 commits into
developfrom
test/auditoria-controller

Conversation

@Pr3d4dor

@Pr3d4dor Pr3d4dor commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

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.php precisa carregar os escapadores antes de o CI3 conseguir desenhar uma página de erro, e não depois: o Exceptions::show_404() inclui a view por include num ponto em que o Loader ainda não rodou — Loader::initialize(), que executa o autoload e carrega o helper general, só acontece no construtor de CI_Controller, e o 404 do Router acontece antes de existir qualquer controller. Uma rota inexistente respondia 500 com Call 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.php e error_php.php 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 não depende de nada já carregado. Um require_once do helper no index.php resolveria também, e foi descartado — só vale enquanto general_helper.php for nada além de definições de função sob function_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 $message em <p> antes de incluir a view, e o DB_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: 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. Essas três views passam a imprimir a mensagem crua dentro de uma div#body, e a decisão fica registrada: são as seis entradas unescaped do tools/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.php e error_php.php seguem 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 de cli/ seguem sem escaper desde sempre: o destino delas é um terminal, não markup.

clean() era intestável. O redirect() do CI3 chama exit(), e no caminho de sucesso ele encerrava o processo do PHPUnit inteiro — sem summary e sem dizer qual caso morreu. Hoje sai por respond_redirect() (o mesmo que o Login::sair() usa), que emite o Location pelo CI_Output sem encerrar o processo. O status continua sendo o que o redirect() escolheria, porque respond_redirect() resolve o número por redirect_status_for(): 303 num POST, 302 fora de HTTP/1.1, regra que já é coberta em GeneralHelperTest.

A tela lia o controller errado na segunda vez. O Loader::_ci_load() dá vida ao $this de uma view copiando as propriedades do controller para o Loader — mas só copia as que ainda não existem (isset($this->$chave)). Como o Loader é 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 (o pagination do Auditoria::index()) ganha a propriedade nova no controller e funciona lá, mas a view renderizada logo depois lê o retrato velho, com o total_rows de um caso que já virou. Quem exercita a tela pela segunda vez no processo vê os links de página sumirem.

resetLoaderViewAliases() roda em resetSharedState(), antes de cada controller, e unseta no Loader tudo que não for _ci_*. O guard não é defesa teórica: Closure::call() reajusta o escopo para a classe do objeto, então o get_object_vars() enxerga as propriedades protegidas do Loader — sem o filtro o unset levaria junto _ci_models e _ci_classes.

O filho da fronteira de teste nascia sem a configuração que o .env guarda. O FrontendBoundaryTest sobe um php -S como production — é a única forma de ter o roteamento de verdade — e o routes.php só exige o routes_api.php quando $_ENV['API_ENABLED'] é verdadeiro. Essa chave mora no application/.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 .env local mascara isso. O mesmo valia para APP_ENCRYPTION_KEY e GLOBAL_XSS_FILTERING, lidas pelo config.php sem 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 em childEnvironment(), e o WhoopsHook::extractEnvNames() parou de fazer file() no .env incondicionalmente. Um sexto item, APP_LOG_PATH, existe pelo motivo oposto: não para impedir a escrita, mas para mandá-la para outro lugar. O show_404() grava em log_message('error', …) antes de renderizar, então o caso do 404 escreve log de propósito, e um arquivo em application/logs/ faz o composer format:check falhar — aquela checagem não honra .gitignore.

O novo AGENTS.md registra 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

  • Correção de bug (fix)
  • Nova funcionalidade (feat)
  • Documentação (docs)
  • Refatoração sem mudança de comportamento (refactor)
  • Dependências / manutenção (chore)

Como testar

composer test          # 325 testes, 990 asserções, 2 skips pré-existentes
composer format:check  # 0 de 321 arquivos
composer xss:check     # 19 achados = 19 do baseline, 0 novos

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);

testIndexRendersPaginationReflectingCurrentData fica vermelho ao afirmar que o create_links() renderizou. Com a linha de volta, a suíte fecha verde.

O mesmo vale para o caso do 404, pelo outro lado. testAnUnknownRouteRendersTheErrorPage pede uma rota inexistente ao filho real e afirma o 404, o texto 404 Page Not Found no corpo e a ausência de undefined function. Ponha um esc() de volta no error_404.php e ele vira o único caso vermelho da classe, com Failed 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, e clean() continuar emitindo o mesmo flashdata e o mesmo destino)

Checklist

  • O PR resolve um assunto (as correções e a refatoração são consequência do mesmo trabalho de cobertura).
  • O código foi formatado com composer format.
  • Testei manualmente o fluxo afetado. (a suíte automatizada é a verificação deste PR)
  • Não há .env, credenciais, dumps de banco ou arquivos de IDE no diff.
  • A pasta application/vendor/ não foi commitada.
  • Entrada do usuário é validada e a saída é escapada nas views. (a exceção são três páginas de erro do framework, sem escaper por decisão registrada no baseline — ver a Descrição)
  • Consultas ao banco usam Query Builder ou query bindings (sem concatenação de SQL).

Banco de dados

Nenhuma mudança de schema — nenhuma migration, nenhum banco.sql. As escritas dos novos testes ficam em logs e a TransactsDatabase desfaz cada caso.

…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 `&lt;p&gt;The page you requested was not
found.&lt;/p&gt;`. 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&lt;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.
@Pr3d4dor
Pr3d4dor merged commit fa12f0b into develop Sep 30, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant