diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml
index 61c4808a5..02f150a9b 100644
--- a/.github/workflows/quality.yml
+++ b/.github/workflows/quality.yml
@@ -8,10 +8,39 @@ on:
permissions:
contents: read
+# Um job só, e não um por verificação. Os passos que não precisam de MySQL
+# (escaping, estilo) e os que precisam (suíte, paridade) dividiam a mesma
+# instalação de dependências e a mesma configuração de PHP, então dois jobs
+# pagavam duas vezes por download do Composer e podiam divergir na versão do PHP
+# sem ninguém perceber. A barreira de paridade precisa do mesmo banco e da
+# mesma versão da suíte, então separá-las não compraria isolamento nenhum.
jobs:
- checks:
+ quality:
runs-on: ubuntu-latest
+ services:
+ mysql:
+ image: mysql:8.4
+ env:
+ MYSQL_ROOT_PASSWORD: root
+ # A conexão do runner chega pelo bridge do Docker, não por socket, e
+ # por isso não casa com o root@localhost que a imagem cria.
+ MYSQL_ROOT_HOST: '%'
+ ports:
+ - 3306:3306
+ options: >-
+ --health-cmd="mysqladmin ping"
+ --health-interval=10s
+ --health-timeout=5s
+ --health-retries=5
+
+ env:
+ MAPOS_TEST_DB_HOSTNAME: 127.0.0.1
+ MAPOS_TEST_DB_PORT: '3306'
+ MAPOS_TEST_DB_DATABASE: mapos_test
+ MAPOS_TEST_DB_USERNAME: root
+ MAPOS_TEST_DB_PASSWORD: root
+
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -34,3 +63,46 @@ jobs:
- name: Check code style
run: composer format:check
+
+ - 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
+
+ # A contagem das contas é conferida dentro do setup-db.php, que sai com
+ # erro se ela não bater. Aqui o que interessa é o caminho de reuso ter
+ # chegado até o fim, e o número fica de fora do grep de propósito: ele
+ # é TestFixtures::USER_COUNT, e escrevê-lo aqui seria uma segunda
+ # fonte para a mesma verdade.
+ if ! grep -q "usuários disponíveis" <<< "$saida"; then
+ echo "::error::O schema reaproveitado não percorreu a conferência de fixtures."
+ 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
+ # escondeu as colunas DECIMAL(10,0) do create_base por anos.
+ - name: Check banco.sql against the migrations
+ run: composer check:parity
diff --git a/.gitignore b/.gitignore
index 500bc9249..179034c41 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,9 +23,15 @@ vendor/
.idea/
.php_cs.cache
.php-cs-fixer.cache
+.phpunit.cache
application/logs/*
ci_sessions/
uploads
application/.env
assets/userImage
+
+# Marcador de ambiente do tooling local; é um mountpoint, não um arquivo do projeto.
+.ai-jail
+
+/assets/arquivos/*
diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php
index de8924f05..66f1bf06d 100644
--- a/.php-cs-fixer.php
+++ b/.php-cs-fixer.php
@@ -2,7 +2,6 @@
$finder = Symfony\Component\Finder\Finder::create()
->notPath('vendor')
- ->notPath('bootstrap')
->notPath('storage')
// Local development mounts a MySQL data directory here; it is not project code.
->exclude('docker/data')
diff --git a/AGENTS.md b/AGENTS.md
index fb710cc7f..364edcaa0 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -22,11 +22,244 @@ Map-OS is an open-source Service Order and Business Management system built in P
- `application/config/`: Configuration files (`config.php`, `database.php`, `routes.php`, etc.).
- `application/database/migrations/`: Database schema migration files.
- `docker/`: Docker Compose configuration for local development.
+- `application/tests/`: PHPUnit suite (`Controllers/`, `Support/`, `bootstrap.php`, `bin/setup-db.php`, `bin/check-schema-parity.php`). One test class per controller, named `ControllerTest`; several controllers in `application/controllers/api/` map to `ControllerTest` too. It lives inside the document root and `bin/setup-db.php` rebuilds a database, so access is blocked in two places: `application/tests/.htaccess` (Apache) and the `location ^~ /application/ { return 404; }` rule in `docker/etc/nginx/default.conf` and `default.template.conf` (nginx). Nginx does **not** read `.htaccess`, so changing one without the other silently reopens the folder. The `^~` is required, otherwise the `~* \.php$` location is matched first and the files are executed. `ServedPathsTest` enforces the pairing, because the two nginx files are near-copies with no link between them and `docker-compose.yml` renders `default.template.conf` over `default.conf` — the template is the one that is live.
+- `tools/`: the XSS gate (`check_view_escaping.php` and `ViewEscaping/`). Blocked the same way, `tools/.htaccess` plus `location ^~ /tools/`. The script refuses to run outside CLI, so nothing here is executable over HTTP, but `tools/xss-baseline.txt` is data: it lists every value that reaches a page without an escaper, with file, expression and line. Serving it publishes a map of what is unescaped, and `ServedPathsTest` covers this folder along with `application/`.
+
+## Testing
+
+The suite runs with `composer test`, which calls `application/tests/bin/setup-db.php` and then
+PHPUnit. The connection comes from the environment, and `application/.env` is
+optional: `application/tests/bootstrap.php` and `application/tests/bin/setup-db.php` use
+`Dotenv::safeLoad()`, so a missing file is not an error. A local `.env` is still
+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 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.
+- `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` **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
+ row the `Usuarios` seed just wrote, not repeated here, so the two cannot drift.
+ All three accounts use the password `123456`.
+- `application/tests/bin/check-schema-parity.php` builds a second database from
+ `banco.sql` and compares it against the migration-built one, table by table and
+ column by column, failing on any difference. It is the barrier that keeps the
+ installer's dump and the migration chain from drifting apart, since nothing
+ else exercises both. `composer check:parity` runs it; CI runs it too. Note it
+ compares table and column **types** only, not indexes, foreign keys, charset or
+ collation, and it excludes the `migrations` control table.
+- The suite writes its CI3 logs to a temporary directory, not to `application/logs`
+ (`application/config/testing/config.php`). The logs are runtime output, and
+ leaving them in the source tree made `format:check` fail on a generated file.
+ The PHPUnit result cache lives in `.phpunit.cache/`. Nothing about the suite
+ needs an exception in `.php-cs-fixer.php` anymore.
+- `application/config/testing/config.php` and `.../routes.php` are loaded because
+ `ENVIRONMENT` is `testing`, and CI3 includes those two files *after* their
+ production counterparts. `routes.php` is the one to remember: it points
+ `default_controller` and `404_override` at the inert `Phpunit` controller, so the
+ boot request neither touches data nor lets a `show_404()` `exit()` kill the
+ PHPUnit process.
+- `failOnDeprecation` stays `false`. The suite would otherwise fail on deprecations
+ raised inside CI3 and its dependencies, which this project does not control.
+ 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, 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 the fingerprint says it is current, which needs
+ three things: the database exists, `migrations.version` equals the newest
+ migration timestamp, and the 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.
+
+### Running the suite in parallel
+
+`composer test` is single-process and stays that way: it is what CI runs and what
+you get by default. `composer test:parallel` is the opt-in ParaTest path, and it
+exists because of one hard fact about this suite — `TransactsDatabase` rolls each
+case back, and `LoginControllerTest` and `BaselineDataResetTest` both `DELETE` and
+re-`INSERT` from `usuarios`, so on a **shared** database they take an X-lock on
+InnoDB rows and serialize against each other. Two processes that were meant to run
+at once stop running at once.
+
+ParaTest does not isolate databases, so `application/tests/Support/Clone/TestSchemaClone.php`
+does. It runs before the app boots, in `bootstrap.php`, and only when `TEST_TOKEN`
+is set:
+
+- The **model** is `mapos_test`, the one `setup-db.php` builds. Each worker gets
+ its own database named from the token *before* the `_test` suffix —
+ `mapos_1_test` — because `assertDatabaseNameIsSafe()` requires that suffix and
+ is not negotiable. `TestDatabase::workerDatabaseName()` owns that rule and is
+ pure, so it can be tested without a database.
+- The clone is `CREATE TABLE ... LIKE` for all 28 tables, `INSERT ... SELECT` to
+ copy the data, then a **replay of the 26 foreign keys**. `CREATE TABLE ... LIKE`
+ does not copy foreign keys, and the inline form fails because the dependency
+ order is wrong (`anexos` references `os`), so the constraints go on afterwards
+ in dependency order. The reference is qualified with the worker's own database,
+ which is what keeps the constraint inside the worker.
+- Reuse is by schema fingerprint, exactly as for the model. The first run of a
+ token costs ~3.7s; every later run of that token costs ~2ms. That asymmetry is
+ the whole reason the tool is opt-in rather than the default.
+
+**Measure before you raise the worker count.** The cold cost is linear in workers,
+because each worker pays its own clone and MySQL serializes DDL. A snapshot on a
+253-test suite, same machine, `--processes=4` and serial:
+
+| run | warm | cold |
+| --- | --- | --- |
+| serial | 2.32s | ~2.0s (not re-measured) |
+| 4 workers | 1.37s | 9.88s |
+
+Only the two rows above are current; the earlier 104-test table also had 2, 8 and
+28 workers, and those three were **not** re-measured, so treat the shape — linear
+in workers when cold, flat when warm — as what they showed and not as a number you
+can quote. Re-measure the whole table when the suite changes shape again, and in
+particular before changing the pinned count.
+
+Warm is flat because the fingerprints persist, so the number that decides whether
+this is worth anything is the *cold* one, and today cold parallel loses to serial
+outright: 9.88s against 2.32s, to save 0.95s once the workers exist.
+`test:parallel` therefore pins `--processes=4` and **CI does not run it**: a clean
+runner would pay four clones to save a fraction of one second. The honest summary
+is that this is infrastructure for a suite that has not grown into it yet. Revisit
+when the suite is long enough that the test time dominates the clone, and
+re-measure the table above when you do.
+
+- Anything that creates a database in a test must put the token in the name.
+ `workerDatabaseName()` in `DatabaseGuard` is the one place that derives it, and
+ `DatabaseGuardTest` is what covers it — along with
+ `TestSchemaCloneForeignKeysTest` and `TestSchemaCloneReproductionTest`, which build
+ a worker database. A hardcoded name is a race: every worker drops and
+ recreates the same schema, one reads the origin mid-build, and the parity check
+ reports a difference that is not there. That bug cost 4 workers 7.1s instead of
+ 1.5s before it was found.
+- `composer test:clean` drops the worker databases the ParaTest runs left behind.
+ It never touches the model, and it re-derives each candidate name with
+ `workerDatabaseName()` instead of matching a pattern written in the script, so
+ it cannot delete a name the clone code could not have produced.
+- Credentials are read through `TestDatabase::env()`, which consults `$_ENV`,
+ `$_SERVER` and `getenv()` in turn. This is not defensive padding: Dotenv does
+ not wire the putenv adapter on every setup, and `$_ENV` follows
+ `variables_order`, which arrives **empty in a ParaTest worker** while
+ `$_SERVER` is full. Reading `$_ENV` directly worked in the serial suite and
+ failed in the parallel one, with the worker opening the database as `root` with
+ no password.
+
+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
+`ControllerTestCase` works around, and any new test touching controllers must keep
+using it:
+
+- `TestApplication::resetSharedState()` must run before each controller is
+ constructed. `CI_Controller::__construct()` walks `is_loaded()` and calls
+ `load_class()` with the bare class name, and `load_class()` only looks for a
+ lowercase `APPPATH/libraries/.php` / `BASEPATH/libraries/.php`. That
+ misses every class the Loader instantiated some other way: the core `Session`
+ (really `system/libraries/Session/Session.php`, class `CI_Session`), the
+ app's `Permission` (no `CI_` prefix) and `Form_validation` (the Loader
+ injects `$this->CI`, so via `load_class()` it arrives null).
+- `Loader::_ci_models` must be cleared too. `Loader::model()` returns early on
+ `in_array($name, $this->_ci_models, TRUE)` *before* attaching the model, so from
+ the second controller onwards `$this->Some_model` is null.
+- `ignoringCliHeaderWarnings()` wraps the one call left that hits `setcookie()`:
+ `csrf_verify()` inside the CI3 `Security` library. It warns in CLI and Whoops
+ turns that into an exception. It must restore with `restore_error_handler()`,
+ never `set_error_handler($previous)`: the latter pushes another entry onto the
+ stack and PHPUnit reports a leaked handler, flagging every test as risky. No
+ app code needs this path any more — `Login::verificarLogin()` sends its CORS
+ headers through `CI_Output::set_header()`, which only emits at the end of the
+ request.
+
+Known limits of the in-process approach:
+
+- The `CI_Session` library aborts under CLI, so session assertions read `$_SESSION`
+ only; nothing is persisted to `ci_sessions`.
+- An **invalid** CSRF token cannot be tested: `Security::csrf_verify()` calls
+ `show_error(403)`, which ends the process. Only the accepting path is covered.
+- A controller must be refactored away from `echo`/`exit` to be testable, so it builds
+ the payload and returns it through `$this->output` (see `Login::verificarLogin()`).
+- A view must not declare a function or class at the top level. The CI3 `include`s the
+ view on every render, so a second render in the same process dies with "Cannot
+ redeclare". Guard it with `function_exists()` (as `saudacao()` does in
+ `views/mapos/login.php`) or move it to a helper. This never showed up in web
+ requests, where the process ends with the response, but it breaks the suite and
+ any queue worker that renders the same view.
+- `callControllerRaw()` returns the body as-is, for pages. `callController()` wraps
+ it and requires JSON.
## Coding Standards & Guidelines
1. **Code Formatting:**
- Always format PHP code using project standards: `composer format` (which invokes `application/vendor/bin/php-cs-fixer fix`).
+ - Run `composer test` after changing a controller, a model, or anything else that touches the database (see [Testing](#testing)).
2. **Security & Input/Output Handling:**
- Always validate user input.
- **Always escape output in views.** Use the helpers in `application/helpers/general_helper.php`, picking the one that matches the output context (see table below). Never `echo` a database row, `$_GET`/`$_POST` value, session value or config value raw.
@@ -45,30 +278,46 @@ common source of XSS, so match the row, not just the habit.
| Helper | Use for | Example |
| --- | --- | --- |
| `esc($v)` | HTML text and quoted attributes | `| = esc($row->nome) ?> | `, `value="= esc($row->nome) ?>"` |
-| `esc_js($v)` | JS string / value inside `` fiquem neutralizados.
+ * Use em `= esc($valor) ?>` dentro do corpo da pagina e dentro de
+ * atributos delimitados por aspas (`value="= esc($valor) ?>"`).
*
* @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.
+ *
+ * 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
+ {
+ $this->ci()->db->where('email', 'admin@admin.com')->update('usuarios', ['dataExpiracao' => null]);
+
+ $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'));
+ }
+
+ /**
+ * 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(''));
+ }
+
+ 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..9dded83d5
--- /dev/null
+++ b/application/tests/Schema/SchemaTest.php
@@ -0,0 +1,88 @@
+ 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'];
+ }
+
+ /**
+ * O tipo declarado de uma coluna, lido pelo mesmo caminho que o resto da suíte.
+ *
+ * Passa pela SchemaReader em vez da própria consulta a information_schema
+ * porque este arquivo afirma um valor absoluto: uma segunda leitura do schema
+ * seria uma segunda resposta para a mesma pergunta, e as duas poderiam divergir
+ * sem que nenhuma delas estivesse errada. `strtolower()` é do teste, e não da
+ * leitora, porque a normalização pertence a quem compara.
+ */
+ private function columnType(string $table, string $column): string
+ {
+ $test = TestDatabase::fromEnvironment();
+
+ $type = SchemaReader::columnType($test->pdo(), $test->database(), $table, $column);
+
+ $this->assertNotNull(
+ $type,
+ "A tabela {$table} não tem a coluna {$column}, ou ela não aparece no information_schema."
+ );
+
+ return strtolower($type);
+ }
+}
diff --git a/application/tests/Support/App/Ci3Introspection.php b/application/tests/Support/App/Ci3Introspection.php
new file mode 100644
index 000000000..5495dffd8
--- /dev/null
+++ b/application/tests/Support/App/Ci3Introspection.php
@@ -0,0 +1,176 @@
+_ci_models) ANTES de
+ * anexar o model. A lista sobrevive entre casos, então do segundo controller
+ * em diante $this->Some_model é null.
+ * - CI_Controller::__construct() faz self::$instance =& $this. O último
+ * controller continua apontado para lá, e Loader::database() resolve o $CI por
+ * ali: sem a volta, a suíte abre uma conexão nova a cada controller.
+ * - O driver conta a profundidade em _trans_depth, que é protected, e o CI3 só
+ * expõe trans_status(). Um rollback que falha deixa o contador alto, e o caso
+ * seguinte passa a rodar dentro de uma transação que ninguém abriu nem fecha.
+ *
+ * A quarta escrita desta lista, `TestApplication::resetSharedState()`, mora no
+ * outro arquivo e não está aqui: ela é o que o bootstrap chama uma vez por
+ * controller, e o que permite que estes três pontos existam como método em vez de
+ * escape manual espalhado pelos testes.
+ *
+ * Por que Closure::bind e não Reflection: `Closure::call()` amarra ao escopo do
+ * objeto, alcança protected e private sem mexer em visibilidade, e o escopo some no
+ * fim da chamada. Reflection precisaria de setAccessible() — global e permanente — e
+ * um nome de propriedade digitado errado viraria um PropertyNotFoundException em vez
+ * de um teste vermelho sobre o que a suíte tentava medir.
+ */
+final class Ci3Introspection
+{
+ /**
+ * A profundidade de transação do driver, que é protected.
+ *
+ * O CI3 não tem trans_depth() na API pública — DB_driver.php só expõe
+ * trans_status() — e é este número que decide se a suíte ainda está dentro de
+ * uma transação.
+ */
+ public static function transactionDepth(object $db): int
+ {
+ return (function (): int {
+ return $this->_trans_depth;
+ })->call($db);
+ }
+
+ /**
+ * O autocommit da conexão, que é como o CI3 marca a transação aberta.
+ *
+ * O driver não expõe isto, e era a informação que três lugares reescreviam à
+ * mão — a trait e dois testes, com dois textos diferentes para o mesmo SELECT
+ * (`CAST(@@autocommit AS UNSIGNED)` num, `@@autocommit` no outro). O cast é
+ * redundante: o (int) abaixo faz o mesmo trabalho, e uma divergência entre os
+ * dois textos seria uma divergência silenciosa, porque as duas versões devolvem
+ * o mesmo número.
+ *
+ * O CI3 faz 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 (linhas 362 e 380). Desligado equivale, de
+ * fora, a "o harness ainda está na própria transação" — e é essa leitura, e
+ * não `_trans_depth`, que denuncia uma transação perdida.
+ *
+ * O que a leitura não pega: commit implícito por DDL. O MySQL encerra a
+ * transação, mas o autocommit continua desligado e a leitura mente.
+ *
+ * Detalhe do protocolo: com a conexão em buffering, um 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
+ * a conversão para int acontece no PHP, sobre a linha única que o SELECT
+ * devolve, sem uma segunda ida ao servidor entre a medição e o seu uso.
+ */
+ public static function autocommit(object $db): int
+ {
+ $row = $db->query('SELECT @@autocommit AS autocommit')->row_array();
+
+ return (int) ($row['autocommit'] ?? 1);
+ }
+
+ /**
+ * Devolve o driver ao estado fechado, depois de um rollback que não rodou.
+ *
+ * `_trans_depth` sozinho não basta: o mysqli fica com autocommit desligado
+ * enquanto a transação é aberta, e zerar o contador sem religar o autocommit
+ * deixa a conexão num estado que o próximo caso não espera e que nenhum assert
+ * sinaliza.
+ *
+ * Só o mysqli é tratado, e um driver que não seja ele lança em vez de ser
+ *tratado pela metade. A versão anterior fazia o `instanceof` pular o
+ * `autocommit(true)` e zerar o contador assim mesmo, para qualquer driver: o
+ * contador voltava a zero, o método devolvia nada, e o `tearDown` terminava
+ * tendo mentido — a conexão continuava com autocommit desligado e com a
+ * transação aberta, que é o estado que este método existe para desfazer. Um
+ * driver diferente do mysqli é configuração não suportada, e falhar alto custa
+ * um erro na hora da configuração, contra uma conexão envenenada que só aparece
+ * como um teste que falha três linhas depois por um motivo que aponta para outro
+ * lugar.
+ *
+ * O PDO é justamente o driver que o `TransactsDatabase` pode encontrar, porque
+ * `config/database.php` configurá-lo é uma escolha de quem instala, e por isso
+ * ele é reconhecido e fechado: `inTransaction()` é a consulta de estado
+ * equivalente ao `_trans_depth` do mysqli, e o PDO não tem `autocommit()` para
+ * religar porque volta a auto-commit sozinho quando a transação termina.
+ */
+ public static function closeDriverTransaction(object $db): void
+ {
+ if (self::transactionDepth($db) === 0) {
+ return;
+ }
+
+ (function (): void {
+ if ($this->conn_id instanceof \mysqli) {
+ $this->conn_id->autocommit(true);
+ }
+
+ if (! $this->conn_id instanceof \mysqli && ! $this->conn_id instanceof \PDO) {
+ throw new \RuntimeException(
+ 'closeDriverTransaction() só conhece mysqli e PDO, e este banco usa '
+ . get_debug_type($this->conn_id) . '. Zerar o contador sem fechar a '
+ . 'transação do driver deixa a conexão num estado que o próximo caso não '
+ . 'espera, e nenhum assert sinaliza. Trocar o driver em '
+ . 'application/config/database.php exige tratar o driver neste método.'
+ );
+ }
+
+ // O PDO não tem autocommit() para religar: `inTransaction()` é o próprio
+ // estado, e ele se desfaz no commit/rollback. Não há o que escrever além
+ // do contador, que é o que o CI3 consulta para saber se está transacionando.
+ $this->_trans_depth = 0;
+ })->call($db);
+ }
+
+ /**
+ * Esvazia o registro de models do Loader, que é protected.
+ *
+ * Sem isto, `Loader::model()` sai no in_array() e devolve null, e o primeiro
+ * sintoma é um teste que passa com o model null e falha três linhas depois por
+ * um motivo que aponta para outro lugar.
+ */
+ public static function clearModelRegistry(object $loader): void
+ {
+ (function (): void {
+ $this->_ci_models = [];
+ })->call($loader);
+ }
+
+ /**
+ * Aponta CI_Controller::$instance para o superobjeto, que é private static.
+ *
+ * O bind é feito sem objeto (`null`) e com o escopo da CI_Controller, que é o
+ * que dá acesso à propriedade estática. Precisa rodar depois de cada
+ * controller construído, não só no fim do caso.
+ *
+ * O parâmetro é `object` e não `?object` porque quem chama já conferiu: um
+ * superobjeto nulo é a condição que o `boot()` transforma em exceção, e aceitar
+ * null aqui só permitiria que um chamador futuro reintroduzisse a mesma
+ * verificação num lugar a um arquivo de distância — que é o que o
+ * `RuntimeException` dentro deste método evitava.
+ */
+ public static function restoreControllerInstance(object $super): void
+ {
+ Closure::bind(
+ function (object $super): void {
+ self::$instance = $super;
+ },
+ null,
+ CI_Controller::class
+ )($super);
+ }
+}
diff --git a/application/tests/Support/App/Ci3IntrospectionTest.php b/application/tests/Support/App/Ci3IntrospectionTest.php
new file mode 100644
index 000000000..40854a649
--- /dev/null
+++ b/application/tests/Support/App/Ci3IntrospectionTest.php
@@ -0,0 +1,136 @@
+ 0, e todos os testes seguintes do
+ * processo escreveriam dentro de uma transação que ninguém abre nem fecha — sem um
+ * único assert reclamando.
+ *
+ * Este arquivo existe porque esse `tearDown` tem uma propriedade que o caminho
+ * feliz não exercita e que é a que mais importa: ele se recusa a fingir que
+ * terminou. A versão anterior zerava o contador para qualquer driver que não fosse
+ * mysqli, o que produzia exatamente o estado que o método existe para desfazer.
+ */
+final class Ci3IntrospectionTest extends TestCase
+{
+ /**
+ * Um driver que não é mysqli nem PDO é configuração não suportada, e o
+ * fechamento avisa em vez de deixar a conexão meio fechada.
+ *
+ * O caminho do `finally` é o que torna isso importante. Uma exceção aqui
+ * sobe pelo `tearDown` e aparece como erro do caso — legível, na hora da
+ * configuração. A versão anterior não lançava nada: zerava `_trans_depth` e
+ * devolvia, e o processo seguia com autocommit desligado e a transação aberta.
+ * O sintoma disso não era uma falha, era a ausência de uma: o próximo teste
+ * escrevia, o `ROLLBACK` do `tearDown` seguinte não encontrava depth para
+ * desfazer, e a série inteira passava com o banco num estado que ninguém
+ * autorizou.
+ *
+ * O objeto é um `stdClass` porque o método só precisa de `conn_id` e
+ * `_trans_depth`: nenhum driver real é instalado, e um banco de verdade aqui
+ * testaria a configuração de quem roda, e não a decisão deste método.
+ */
+ #[Test]
+ public function testAnUnsupportedDriverIsRefusedInsteadOfHalfClosed(): void
+ {
+ $db = new class {
+ /** @var object um driver que o CI3 não carrega por padrão */
+ public $conn_id;
+
+ public int $_trans_depth = 1;
+
+ public function __construct()
+ {
+ $this->conn_id = new class {
+ };
+ }
+ };
+
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessageMatches('/só conhece mysqli e PDO/');
+
+ Ci3Introspection::closeDriverTransaction($db);
+ }
+
+ /**
+ * O depth fica intacto quando o driver é recusado, porque a exceção sai antes.
+ *
+ * A ordem dentro do método importa e é o que o caso anterior não prende: se o
+ * `_trans_depth = 0` viesse antes da checagem, o método continuaria levantando
+ * a exceção — o `expectException` acima passaria igual — e mesmo assim teria
+ * zerado o contador, que é o estado que o `tearDown` do próximo caso espera
+ * encontrar. Um teste que passa com as duas ordens não está prendendo a ordem,
+ * e a ordem é a coisa que este método faz.
+ */
+ #[Test]
+ public function testTheDepthSurvivesTheRefusal(): void
+ {
+ $db = new class {
+ /** @var object um driver que o CI3 não carrega por padrão */
+ public $conn_id;
+
+ public int $_trans_depth = 1;
+
+ public function __construct()
+ {
+ $this->conn_id = new class {
+ };
+ }
+ };
+
+ try {
+ Ci3Introspection::closeDriverTransaction($db);
+ } catch (RuntimeException) {
+ // A exceção é o outro teste. O que este caso afirma é o estado em que a
+ // conexão ficou depois dela.
+ }
+
+ $this->assertSame(
+ 1,
+ Ci3Introspection::transactionDepth($db),
+ 'O contador zerado junto com a exceção faria o tearDown do caso seguinte achar que '
+ . 'não há transação para desfazer, e o estado poisonous passaria a parecer limpo.'
+ );
+ }
+
+ /**
+ * Um depth zero sai antes de olhar o driver, e é por isso que o caminho normal
+ * não paga a checagem.
+ *
+ * `tearDownDatabaseTransaction()` chama este método no `finally` de TODOS os
+ * casos transacionados, e no caminho normal — rollback que funcionou — o depth
+ * já é zero. Se a recusa fosse avaliada antes do `return`, uma conexão PDO de
+ * uma configuração suportada derrubaria o `tearDown` inteiro em todos os casos
+ * da série, em vez de só nos que realmente deixaram transação aberta.
+ */
+ #[Test]
+ public function testAClosedTransactionNeverLooksAtTheDriver(): void
+ {
+ $db = new class {
+ /** @var object um driver que o CI3 não carrega por padrão */
+ public $conn_id;
+
+ public int $_trans_depth = 0;
+
+ public function __construct()
+ {
+ $this->conn_id = new class {
+ };
+ }
+ };
+
+ Ci3Introspection::closeDriverTransaction($db);
+
+ $this->assertSame(0, Ci3Introspection::transactionDepth($db));
+ }
+}
diff --git a/application/tests/Support/App/TestApplication.php b/application/tests/Support/App/TestApplication.php
new file mode 100644
index 000000000..2b9903500
--- /dev/null
+++ b/application/tests/Support/App/TestApplication.php
@@ -0,0 +1,208 @@
+ true,
+ 'hooks' => true,
+ 'config' => true,
+ 'log' => true,
+ 'utf8' => true,
+ 'uri' => true,
+ 'router' => true,
+ 'output' => true,
+ 'security' => true,
+ 'input' => true,
+ 'lang' => true,
+ '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.
+ *
+ * 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();
+
+ // 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 = [];
+ }
+
+ /**
+ * 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 — e load_class() só procura um
+ * libraries/.php em APPPATH e BASEPATH, o que estoura para
+ * qualquer classe que o Loader tenha instanciado por outro caminho: o core
+ * `Session`, o `Permission` da aplicação (sem o prefixo CI_) e o
+ * `Form_validation` (que via load_class() chega com $this->CI nulo).
+ *
+ * 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.
+ *
+ * O mesmo vale para models, por um motivo diferente. Loader::model()
+ * (Loader.php:268) retorna cedo em in_array($name, $this->_ci_models, TRUE),
+ * ANTES de $CI->$name = $model — ou seja, sem anexar nada. 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.
+ *
+ * As escritas que isso exige estão em Ci3Introspection, e AGENTS.md traz a
+ * lista das classes afetadas.
+ */
+ public static function resetSharedState(): void
+ {
+ $loaded = &is_loaded();
+
+ foreach (array_keys($loaded) as $name) {
+ if (! isset(self::CI_CORE_CLASSES[$name])) {
+ unset($loaded[$name]);
+ }
+ }
+
+ Ci3Introspection::clearModelRegistry(self::superObject()->load);
+ Ci3Introspection::restoreControllerInstance(self::superObject());
+ }
+
+ /**
+ * 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/App/TestApplicationTest.php b/application/tests/Support/App/TestApplicationTest.php
new file mode 100644
index 000000000..c333aa4bf
--- /dev/null
+++ b/application/tests/Support/App/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/Clone/TestSchemaClone.php b/application/tests/Support/Clone/TestSchemaClone.php
new file mode 100644
index 000000000..e1220684e
--- /dev/null
+++ b/application/tests/Support/Clone/TestSchemaClone.php
@@ -0,0 +1,317 @@
+test->schemaFingerprint()->isCurrent($worker)) {
+ return false;
+ }
+
+ if (! $this->test->schemaFingerprint()->isCurrent($template)) {
+ throw new RuntimeException(
+ "O banco modelo '{$template}' não está em dia, então o clone para '{$worker}' seria "
+ . 'feito a partir de um schema velho — e um worker velho não é um worker, é um teste '
+ . "que passa ou falha por um motivo que não tem nada a ver com o código. Rode "
+ . "'composer test:db' antes da execução paralela. Esta classe não monta o schema de "
+ . 'propósito: N processos montando ao mesmo tempo sobre o mesmo banco seria uma corrida '
+ . 'de DDL, não uma montagem.'
+ );
+ }
+
+ // A impressão digital vai com o banco. Sem esta linha, um worker deixado
+ // por uma execução anterior com a impressão de uma versão diferente do
+ // modelo seria recreate()'ado — que é o certo — mas o caminho inverso, um
+ // worker recreate()'ado cuja impressão ficou, faria isCurrent()
+ // aprovar um banco meio montado.
+ $this->test->schemaFingerprint()->forget($worker);
+ $this->test->recreate($worker);
+
+ $pdo = $this->test->pdo();
+ $tables = SchemaReader::tableNames($pdo, $template);
+
+ foreach ($tables as $table) {
+ $pdo->exec(sprintf(
+ 'CREATE TABLE %s LIKE %s',
+ SchemaReader::qualified($worker, $table),
+ SchemaReader::qualified($template, $table)
+ ));
+ }
+
+ foreach ($tables as $table) {
+ $pdo->exec(sprintf(
+ 'INSERT INTO %s SELECT * FROM %s',
+ SchemaReader::qualified($worker, $table),
+ SchemaReader::qualified($template, $table)
+ ));
+ }
+
+ self::replayForeignKeys($pdo, $worker, $template);
+
+ // Só depois de tudo dar certo. A impressão é o que autoriza a próxima
+ // execução a pular a cópia, e gravada antes do fim ela aprobaria um
+ // worker pela metade.
+ $this->test->schemaFingerprint()->record($worker);
+
+ return true;
+ }
+
+ /**
+ * Recria as chaves estrangeiras do modelo no worker.
+ *
+ * Os nomes são preservados de propósito: a comparação do describe() é por
+ * nome, e um clone cujas constraints tivessem sido renomeadas passaria numa
+ * conferência por contagem e falharia nesta. Em MySQL o nome da constraint é
+ * por schema, então repetir o nome do modelo no worker não colide com nada.
+ *
+ * Os índices exigidos pela constraint vêm junto no passo 1, porque
+ * `CREATE TABLE ... LIKE` copia índices — e é por isso que o InnoDB aceita
+ * recriar a chave sem pedir um índice novo.
+ */
+ private static function replayForeignKeys(PDO $pdo, string $worker, string $template): void
+ {
+ foreach (self::foreignKeyConstraints($pdo, $template) as $name => $constraint) {
+ $pdo->exec(self::addForeignKeyStatement($worker, (string) $name, $constraint));
+ }
+ }
+
+ /**
+ * As chaves estrangeiras do banco, agrupadas por constraint.
+ *
+ * A consulta e o agrupamento vivem em `SchemaReader::foreignKeys()`, e é por
+ * isso que os dois consumidores concordam: `replayForeignKeys()` escreve as
+ * constraints no worker e `describeForeignKeys()` confere as que chegaram, e as
+ * duas já foram cópia uma da outra — 26 linhas idênticas exceto pelo nome da
+ * variável. Essa cópia era um defeito esperando: editar o `JOIN` em uma delas e
+ * não na outra fazia a conferência validar uma consulta diferente da que escreveu
+ * os dados, que é exatamente a classe de bug que o `describe()` existe para
+ * pegar. Uma consulta só, e a impossibilidade de divergirem volta a existir.
+ *
+ * Este método virou a delegação, porque o `SchemaReader` é o lugar declarado
+ * para a leitura e havia mais uma cópia da mesma consulta no
+ * `TestSchemaCloneWorkerParityTest`, lendo `REFERENCED_TABLE_SCHEMA` com o
+ * mesmo `JOIN`. Duas leituras da mesma pergunta podem divergir, e a divergência
+ * de uma delas aparece como um clone que gravou uma coisa e uma conferência que
+ * validou outra.
+ *
+ * A ordem de entrada é a do `ORDER BY` da consulta (tabela, nome, posição) e ela
+ * é intencional: `replayForeignKeys()` aplica os `ALTER` nessa ordem. O `ksort`
+ * fica em `describeForeignKeys()`, que só quer comparação estável, e não aqui.
+ *
+ * @return array, referenced_table: string, referenced_columns: list, update_rule: string, delete_rule: string}>
+ */
+ private static function foreignKeyConstraints(PDO $pdo, string $database): array
+ {
+ return SchemaReader::foreignKeys($pdo, $database);
+ }
+
+ /**
+ * O `ALTER TABLE` que recria uma chave estrangeira, montado de fora do banco.
+ *
+ * A montagem é uma função separada e pública para poder ser testada sem um
+ * banco: o erro que importa aqui não é a exceção, é a constraint que não é
+ * recriada e a suíte que continua verde. Um método testado contra um
+ * information_schema real só é testado no banco que ele deveria estar
+ * conferindo, e o teste passa quando o defeito está justamente ali.
+ *
+ * UPDATE_RULE e DELETE_RULE vão explícitos mesmo quando são RESTRICT, que é o
+ * padrão: omitir o que é o default esconde a diferença entre uma constraint
+ * replicada e uma que virou o default por acidente.
+ *
+ * O `REFERENCES` qualifica o WORKER, e não o modelo, e essa é a linha mais
+ * importante do arquivo. Qualificar pelo modelo funciona — o MySQL aceita, e a
+ * comparação continuaria batendo, porque ela confere a forma da constraint e
+ * não o schema para onde ela aponta. Só que o resultado é um worker cujas
+ * tabelas-filhas estão presas ao pai do MODELO, e aí nada do que esta classe
+ * existe para entregar acontece de fato:
+ *
+ * - a cópia do pai dentro do worker deixa de valer para a constraint, então um
+ * `INSERT` de filho com pai existente só no worker passa, e a suíte passa a
+ * poder gravar órfão que a produção rejeita;
+ * - todos os workers passam a segurar X-lock nas mesmas linhas do pai no
+ * modelo, que é exatamente a contenção entre processos que o banco por worker
+ * foi criado para eliminar — trocada por uma menor e mais difícil de ver,
+ * porque aparece só de vez em quando.
+ *
+ * O defeito era invisível para a comparação de paridade e para os testes de
+ * SQL, e só apareceu ao ler `REFERENCED_TABLE_SCHEMA` no `information_schema`
+ * do worker. Daí o teste que exige que o schema referenciado seja o do próprio
+ * worker.
+ *
+ * @param array{table: string, columns: list, referenced_table: string, referenced_columns: list, update_rule: string, delete_rule: string} $constraint
+ */
+ public static function addForeignKeyStatement(
+ string $worker,
+ string $name,
+ array $constraint
+ ): string {
+ $columns = implode(', ', array_map(
+ static fn (string $column): string => SchemaReader::identifier($column),
+ $constraint['columns']
+ ));
+
+ $referenced = implode(', ', array_map(
+ static fn (string $column): string => SchemaReader::identifier($column),
+ $constraint['referenced_columns']
+ ));
+
+ return sprintf(
+ 'ALTER TABLE %s ADD CONSTRAINT %s FOREIGN KEY (%s) REFERENCES %s (%s) ON UPDATE %s ON DELETE %s',
+ SchemaReader::qualified($worker, $constraint['table']),
+ SchemaReader::identifier($name),
+ $columns,
+ SchemaReader::qualified($worker, $constraint['referenced_table']),
+ $referenced,
+ self::foreignKeyRule($constraint['update_rule']),
+ self::foreignKeyRule($constraint['delete_rule'])
+ );
+ }
+
+ /**
+ * As chaves estrangeiras do banco, como mapa nome => descrição.
+ *
+ * A descrição inclui a regra de UPDATE e a de DELETE porque `CASCADE` e
+ * `SET NULL` mudam o que acontece com as linhas filhas quando a pai é
+ * apagada, e uma suíte que roda contra um banco sem essa regra estar certa
+ * não está mais testando o schema que a produção tem.
+ *
+ * @return array
+ */
+ public static function describeForeignKeys(PDO $pdo, string $database): array
+ {
+ $constraints = self::foreignKeyConstraints($pdo, $database);
+
+ ksort($constraints);
+
+ $descriptions = [];
+
+ foreach ($constraints as $name => $constraint) {
+ $descriptions[$name] = sprintf(
+ '%s(%s) -> %s(%s) ON UPDATE %s ON DELETE %s',
+ $constraint['table'],
+ implode(',', $constraint['columns']),
+ $constraint['referenced_table'],
+ implode(',', $constraint['referenced_columns']),
+ $constraint['update_rule'],
+ $constraint['delete_rule']
+ );
+ }
+
+ return $descriptions;
+ }
+
+ /**
+ * As regras de referência, que são palavras-chave e não identificadores.
+ *
+ * `UPDATE_RULE` e `DELETE_RULE` chegam do information_schema como `NO ACTION`
+ * ou `CASCADE`, com espaço — não é um nome de objeto, e por isso não passa
+ * por identifier(). A lista é fechada de propósito: ela transforma um valor
+ * inesperado numa exceção que diz o que aconteceu, em vez de uma string que
+ * entrava no SQL do clone sem ninguém ter conferido. `NO ACTION` precisa
+ * estar na lista mesmo sendo o que o MySQL grava em vez de `RESTRICT`, que
+ * é a diferença que faria a constraint recriada não ser a mesma.
+ */
+ private static function foreignKeyRule(string $rule): string
+ {
+ $allowed = ['CASCADE', 'SET NULL', 'RESTRICT', 'NO ACTION'];
+
+ if (! in_array($rule, $allowed, true)) {
+ throw new RuntimeException(
+ "Regra de referência '{$rule}' fora do conjunto que o MySQL usa "
+ . '(' . implode(', ', $allowed) . '). Ela vem de information_schema.REFERENTIAL_CONSTRAINTS, '
+ . 'e um valor inesperado aqui significa que a coluna mudou de sentido — o que '
+ . 'aconteceria se alguém passasse a ler outra tabela no lugar dela.'
+ );
+ }
+
+ return $rule;
+ }
+}
diff --git a/application/tests/Support/Clone/TestSchemaCloneForeignKeysTest.php b/application/tests/Support/Clone/TestSchemaCloneForeignKeysTest.php
new file mode 100644
index 000000000..a90afcf00
--- /dev/null
+++ b/application/tests/Support/Clone/TestSchemaCloneForeignKeysTest.php
@@ -0,0 +1,194 @@
+pdo();
+
+ (new TestSchemaClone($test))->ensureWorkerDatabase(self::destination(), self::origin());
+
+ $fromOrigin = TestSchemaClone::describeForeignKeys($pdo, self::origin());
+
+ $this->assertNotEmpty(
+ $fromOrigin,
+ 'A origem ficou sem chave estrangeira nenhuma, o que significa que este caso não está '
+ . 'testando o que pensa.'
+ );
+
+ $this->assertCount(
+ 2,
+ $fromOrigin,
+ 'A origem tem duas constraints e a cópia tem ' . count($fromOrigin) . '. Uma chave de duas '
+ . 'colunas contada como duas de uma coluna daria o mesmo total com nomes errados.'
+ );
+
+ $fromClone = TestSchemaClone::describeForeignKeys($pdo, self::destination());
+
+ foreach ($fromOrigin as $name => $description) {
+ $this->assertArrayHasKey(
+ $name,
+ $fromClone,
+ "A chave estrangeira '{$name}' existe na origem e não no clone: {$description}. "
+ . 'É o que `CREATE TABLE ... LIKE` não copia, e é a razão de o TestSchemaClone '
+ . 'recriar as constraints.'
+ );
+
+ $this->assertSame(
+ $description,
+ $fromClone[$name],
+ "A chave estrangeira '{$name}' mudou de regra ou de coluna entre a origem e o clone. "
+ . 'Trocar NO ACTION por CASCADE muda o que acontece com as linhas filhas quando a '
+ . 'pai é apagada, e uma suíte que roda contra a regra errada não está testando o '
+ . 'schema que a produção tem.'
+ );
+ }
+
+ // Os dois casos que a contagem acima não separa: a composta tem de ter
+ // as duas colunas, e o CASCADE tem de ter vindo junto.
+ $this->assertStringContainsString(
+ '(pai_id,pai_seg) -> pai(id,seg)',
+ $fromClone['fk_composta'] ?? '',
+ 'A chave de duas colunas perdeu uma das colunas no caminho. Count() continuaria dizendo 2.'
+ );
+
+ $this->assertStringContainsString(
+ 'ON UPDATE CASCADE',
+ $fromClone['fk_composta'] ?? '',
+ 'A regra de UPDATE não sobreviveu ao clone. O statement é aceito pelo MySQL sem ela, e o '
+ . 'default é NO ACTION — que só aparece como bug no dia em que alguém precisar do CASCADE.'
+ );
+ }
+
+ /**
+ * As duas regras entram no `ALTER TABLE` mesmo quando são o default.
+ *
+ * `ADD CONSTRAINT` sem `ON UPDATE`/`ON DELETE` explícito usa RESTRICT, e um
+ * `SET NULL` ou um `CASCADE` passariam a sumir do clone sem erro nenhum — o
+ * statement executa, o banco aceita, e a diferença só aparece quando um teste
+ * que depende do CASCADE falha. A montagem fica numa função pública e
+ * separada para poder ser conferida sem banco nenhum.
+ */
+ #[Test]
+ public function testTheForeignKeyStatementCarriesBothRulesAndPointsInsideTheWorker(): void
+ {
+ $sql = TestSchemaClone::addForeignKeyStatement(
+ 'mapos_1_test',
+ 'fk_anexos_os1',
+ [
+ 'table' => 'anexos',
+ 'columns' => ['os_id'],
+ 'referenced_table' => 'os',
+ 'referenced_columns' => ['idOs'],
+ 'update_rule' => 'CASCADE',
+ 'delete_rule' => 'SET NULL',
+ ]
+ );
+
+ $this->assertSame(
+ 'ALTER TABLE `mapos_1_test`.`anexos` ADD CONSTRAINT `fk_anexos_os1` '
+ . 'FOREIGN KEY (`os_id`) REFERENCES `mapos_1_test`.`os` (`idOs`) '
+ . 'ON UPDATE CASCADE ON DELETE SET NULL',
+ $sql,
+ 'O `ALTER TABLE` precisa trazer as duas regras e qualificar as duas pontas. O `REFERENCES` '
+ . 'é o WORKER, e não o modelo: um filho que aponta para o pai do modelo não é conferido '
+ . 'contra a cópia do pai que está no worker, e ainda segura lock na linha do modelo '
+ . 'que ele queria isolar.'
+ );
+ }
+
+ /**
+ * Uma regra de referência desconhecida é recusada, e não interpolada.
+ *
+ * `UPDATE_RULE` e `DELETE_RULE` são palavras-chave com espaço (`NO ACTION`), não
+ * identificadores, então não passam pela checagem de nome de objeto. Elas têm
+ * uma lista fechada, e é contra essa lista que são conferidas: um valor
+ * inesperado ali significa que a coluna do information_schema mudou de
+ * sentido, e a falha precisa dizer isso em vez de montar um SQL que o MySQL
+ * talvez aceite.
+ */
+ #[Test]
+ public function testAnUnknownReferentialRuleIsRefused(): void
+ {
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessageMatches('/Regra de referência/');
+
+ TestSchemaClone::addForeignKeyStatement(
+ 'mapos_1_test',
+ 'fk_qualquer',
+ [
+ 'table' => 'anexos',
+ 'columns' => ['os_id'],
+ 'referenced_table' => 'os',
+ 'referenced_columns' => ['idOs'],
+ 'update_rule' => 'DROP EVERYTHING',
+ 'delete_rule' => 'CASCADE',
+ ]
+ );
+ }
+
+ /**
+ * Um identificador que não é nome é recusado antes de chegar ao SQL.
+ *
+ * Os nomes vêm do information_schema e do nome do banco, e os dois são
+ * normalmente inofensivos. A concatenação continua sendo a forma de injeção
+ * que o projeto proíbe, e a garantia de que eles são inofensivos não é deste
+ * arquivo: é do `assertDatabaseNameIsSafe()`. Um dia uma constraint se chamar
+ * `` `x`, `DROP TABLE `os `` e o clone passa a executar o que o nome mandava.
+ * Checar custa uma regex.
+ */
+ #[Test]
+ public function testAnIdentifierThatIsNotANameIsRefused(): void
+ {
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessageMatches('/Identificador fora do esperado/');
+
+ TestSchemaClone::addForeignKeyStatement(
+ 'mapos_1_test',
+ 'fk_qualquer',
+ [
+ 'table' => 'anexos`, `x`, `DROP TABLE `os',
+ 'columns' => ['os_id'],
+ 'referenced_table' => 'os',
+ 'referenced_columns' => ['idOs'],
+ 'update_rule' => 'NO ACTION',
+ 'delete_rule' => 'NO ACTION',
+ ]
+ );
+ }
+}
diff --git a/application/tests/Support/Clone/TestSchemaCloneGuardsTest.php b/application/tests/Support/Clone/TestSchemaCloneGuardsTest.php
new file mode 100644
index 000000000..6aee4a5d8
--- /dev/null
+++ b/application/tests/Support/Clone/TestSchemaCloneGuardsTest.php
@@ -0,0 +1,58 @@
+expectException(RuntimeException::class);
+ $this->expectExceptionMessageMatches('/composer test:db/');
+
+ (new TestSchemaClone(TestDatabase::fromEnvironment()))
+ ->ensureWorkerDatabase(self::destination(), 'mapos_nunca_construido_test');
+ }
+
+ /**
+ * Worker e modelo com o mesmo nome não é caso de clone.
+ *
+ * É o caminho da execução serial, e ele tem que devolver false sem tocar em
+ * nada: se clonasse, o `composer test` normal derrubaria e remontaria o banco
+ * da suíte antes de começar.
+ */
+ #[Test]
+ public function testTheSameNameIsNotCloned(): void
+ {
+ $this->assertFalse(
+ (new TestSchemaClone(TestDatabase::fromEnvironment()))
+ ->ensureWorkerDatabase('mapos_test', 'mapos_test'),
+ 'Com os dois nomes iguais não há o que clonar, e a execução serial depende de não haver.'
+ );
+ }
+}
diff --git a/application/tests/Support/Clone/TestSchemaCloneReproductionTest.php b/application/tests/Support/Clone/TestSchemaCloneReproductionTest.php
new file mode 100644
index 000000000..1a5ccd108
--- /dev/null
+++ b/application/tests/Support/Clone/TestSchemaCloneReproductionTest.php
@@ -0,0 +1,71 @@
+assertTrue(
+ $clone->ensureWorkerDatabase(self::destination(), self::origin()),
+ 'A primeira chamada tem que clonar. Se devolveu false, o destino já estava em dia e o caso não testou nada.'
+ );
+
+ $pdo = $test->pdo();
+
+ $this->assertSame(
+ self::describe($pdo, self::origin()),
+ self::describe($pdo, self::destination()),
+ 'O clone não é igual à origem. Se a diferença estiver nas chaves estrangeiras, é o '
+ . '`CREATE TABLE ... LIKE` silenciosamente sem elas; se estiver em `linhas`, o `INSERT '
+ . '... SELECT` pulou alguma tabela; se estiver em `colunas` ou `indices`, a origem mudou '
+ . 'depois do clone.'
+ );
+
+ // O mesmo banco, uma segunda vez, e sem pagar a cópia. A verificação mora
+ // neste caso e não num próprio porque o clone que ela confere é este: um
+ // caso separado teria de clonar de novo para ter o que conferir, e um
+ // clone do schema de duas tabelas custa ~400ms de DDL.
+ //
+ // O que está protegido é o custo. São 3,7s na primeira vez e 2ms depois
+ // no schema real, e um clone que refizesse a cópia a cada execução seria
+ // 3,7s por execução — o que jogaria fora o ganho inteiro do parallelismo.
+ $this->assertFalse(
+ $clone->ensureWorkerDatabase(self::destination(), self::origin()),
+ 'A segunda chamada devolveu que clonou de novo, o que significa que a impressão digital do '
+ . 'worker não foi lida ou não foi gravada.'
+ );
+ }
+}
diff --git a/application/tests/Support/Clone/TestSchemaCloneShared.php b/application/tests/Support/Clone/TestSchemaCloneShared.php
new file mode 100644
index 000000000..f8ee4f5a9
--- /dev/null
+++ b/application/tests/Support/Clone/TestSchemaCloneShared.php
@@ -0,0 +1,123 @@
+, indices: int, linhas: int}>, chaves_estrangeiras: array}
+ */
+ private static function describe(PDO $pdo, string $database): array
+ {
+ $columns = SchemaReader::columnTypes($pdo, $database);
+ $indexes = self::indexCounts($pdo, $database);
+
+ $tables = [];
+
+ foreach (SchemaReader::tableNames($pdo, $database) as $table) {
+ $tables[$table] = [
+ 'colunas' => $columns[$table] ?? [],
+ 'indices' => $indexes[$table] ?? 0,
+ 'linhas' => (int) $pdo
+ ->query(sprintf('SELECT COUNT(*) AS total FROM %s', SchemaReader::qualified($database, $table)))
+ ->fetch()['total'],
+ ];
+ }
+
+ return [
+ 'tabelas' => $tables,
+ 'chaves_estrangeiras' => TestSchemaClone::describeForeignKeys($pdo, $database),
+ ];
+ }
+
+ /**
+ * Quantos índices cada tabela tem, contados por par (tabela, índice).
+ *
+ * Delegado a `SchemaReader::indexCounts()`, que é onde a consulta vive. Ela
+ * ficava escrita aqui e é a mesma que o `SchemaReader` existe para evitar:
+ * duas leituras de `information_schema.statistics` podem divergir no `DISTINCT`
+ * e ninguém perceberia, porque a divergência apareceria como "o clone perdeu um
+ * índice", que é uma conclusão plausível demais para chamar a atenção.
+ *
+ * @return array
+ */
+ private static function indexCounts(PDO $pdo, string $database): array
+ {
+ return SchemaReader::indexCounts($pdo, $database);
+ }
+}
diff --git a/application/tests/Support/Clone/TestSchemaCloneSyntheticOrigin.php b/application/tests/Support/Clone/TestSchemaCloneSyntheticOrigin.php
new file mode 100644
index 000000000..eba8225de
--- /dev/null
+++ b/application/tests/Support/Clone/TestSchemaCloneSyntheticOrigin.php
@@ -0,0 +1,161 @@
+exec('CREATE TABLE `pai` (
+ `id` INT NOT NULL AUTO_INCREMENT,
+ `seg` INT NOT NULL DEFAULT 0,
+ `nome` VARCHAR(50) NOT NULL,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uniq_pai` (`id`, `seg`)
+ ) ENGINE=InnoDB');
+
+ $pdo->exec('CREATE TABLE `filho` (
+ `id` INT NOT NULL AUTO_INCREMENT,
+ `pai_id` INT NOT NULL,
+ `pai_seg` INT NOT NULL DEFAULT 0,
+ `nota` VARCHAR(10) DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ KEY `idx_filho_pai` (`pai_id`),
+ CONSTRAINT `fk_simples` FOREIGN KEY (`pai_id`) REFERENCES `pai` (`id`) ON DELETE CASCADE,
+ CONSTRAINT `fk_composta` FOREIGN KEY (`pai_id`, `pai_seg`) REFERENCES `pai` (`id`, `seg`) ON UPDATE CASCADE
+ ) ENGINE=InnoDB');
+
+ $pdo->exec("INSERT INTO `pai` (`seg`, `nome`) VALUES (1, 'primeiro'), (2, 'segundo')");
+ $pdo->exec("INSERT INTO `filho` (`pai_id`, `pai_seg`, `nota`) VALUES (1, 1, 'a'), (2, 2, 'b')");
+
+ // A tabela de controle do Migrator, com a versão mais recente e mais nada.
+ //
+ // Não é decoração: ensureWorkerDatabase() se recusa a clonar um modelo que
+ // a impressão digital diz estar velha, e essa verificação olha `migrations`
+ // antes de olhar qualquer outra coisa. Sem esta tabela o clone seria
+ // recusado por um motivo que não tem nada a ver com as constraints, e o
+ // caso estaria medindo a guarda em vez do mecanismo.
+ $pdo->exec('CREATE TABLE `migrations` (`version` VARCHAR(20) NOT NULL)');
+ $pdo->exec(sprintf(
+ "INSERT INTO `migrations` (`version`) VALUES ('%s')",
+ SchemaFingerprint::latestMigrationVersion()
+ ));
+ }
+
+ /**
+ * A origem, montada uma vez e compartilhada pelos casos da classe.
+ *
+ * No `#[BeforeClass]` e não por caso, e a distinção é entre as duas metades do
+ * banco: a origem é SÓ LIDA por tudo que isto faz — o clone faz
+ * `CREATE TABLE ... LIKE` a partir dela, os `INSERT ... SELECT` copiam dela, e
+ * o `describe()` só a lê. Nada aqui escreve nela, então montá-la por caso seria
+ * o mesmo custo de DDL para chegar ao mesmo estado.
+ *
+ * O destino é o contrário, e é por isso que ele é apagado no `#[After]` de
+ * cada caso: é nele que a cópia acontece, e a impressão digital que o
+ * `ensureWorkerDatabase()` grava sobrevive à chamada. Sem apagar, o primeiro
+ * caso que clonasse deixaria o destino em dia e o seguinte receberia um false
+ * do `ensureWorkerDatabase()` — um teste que falha por causa da ordem em que o
+ * PHPUnit escolheu rodar, que é a forma mais cara de teste quebrado.
+ */
+ #[BeforeClass]
+ public static function setUpOrigin(): void
+ {
+ $test = TestDatabase::fromEnvironment();
+
+ $test->recreate(self::origin());
+ $test->drop(self::destination());
+ $test->schemaFingerprint()->forget(self::origin());
+ $test->schemaFingerprint()->forget(self::destination());
+
+ self::createOrigin($test->pdo(self::origin()));
+
+ // A impressão é gravada porque a origem foi montada agora, por estas
+ // linhas. ensureWorkerDatabase() se recusa a clonar um modelo que
+ // a impressão digital diz estar velha, e sem isto o caso mediria essa
+ // guarda em vez do mecanismo de cópia.
+ $test->schemaFingerprint()->record(self::origin());
+ }
+
+ /**
+ * Apaga o destino antes e depois de cada caso que usa o banco.
+ *
+ * No `#[After]` e não num helper chamado pelo fim de cada caso: um caso que
+ * falhe no meio deixa o destino para trás, e o próximo `composer test`
+ * reencontraria um destino com a impressão digital certa — o que faria a
+ * limpeza parecer funcionar por acaso.
+ *
+ * A impressão digital vai junto do banco porque é o que autoriza um banco
+ * como atual: um banco apagado com a impressão no lugar faz a próxima
+ * execução acreditar num schema que não existe. O `finally` cobre a falha
+ * dentro da própria limpeza.
+ */
+ #[Before]
+ public function clearDestination(): void
+ {
+ $this->dropDestination();
+ }
+
+ #[After]
+ public function dropDestination(): void
+ {
+ $test = TestDatabase::fromEnvironment();
+
+ try {
+ $test->drop(self::destination());
+ } finally {
+ $test->schemaFingerprint()->forget(self::destination());
+ }
+ }
+
+ #[AfterClass]
+ public static function tearDownSyntheticSchema(): void
+ {
+ $test = TestDatabase::fromEnvironment();
+
+ try {
+ $test->drop(self::origin());
+ $test->drop(self::destination());
+ } finally {
+ $test->schemaFingerprint()->forget(self::origin());
+ $test->schemaFingerprint()->forget(self::destination());
+ }
+ }
+}
diff --git a/application/tests/Support/Clone/TestSchemaCloneWorkerParityTest.php b/application/tests/Support/Clone/TestSchemaCloneWorkerParityTest.php
new file mode 100644
index 000000000..721736023
--- /dev/null
+++ b/application/tests/Support/Clone/TestSchemaCloneWorkerParityTest.php
@@ -0,0 +1,143 @@
+database() === $test->templateDatabase()) {
+ $this->markTestSkipped(
+ 'Sem TEST_TOKEN este processo usa o banco modelo diretamente, então compará-lo com o '
+ . 'modelo seria a mesma consulta ao mesmo banco. O caminho de código é conferido pelo '
+ . 'TestSchemaCloneReproductionTest::testTheCloneReproducesTheOriginSchema(), e o clone '
+ . 'do schema real só existe quando alguém roda `composer test:parallel` — nenhum job de '
+ . 'CI roda, porque o custo frio de quatro clones é maior que o que a série economiza.'
+ );
+ }
+
+ $pdo = $test->pdo();
+
+ $this->assertSame(
+ self::describe($pdo, $test->templateDatabase()),
+ self::describe($pdo, $test->database()),
+ 'O banco deste processo não é cópia do modelo. A consequência prática é que a suíte está '
+ . 'rodando contra um schema sem as chaves estrangeiras que a produção tem, e ela não '
+ . 'vai avisar: um banco sem constraint é um banco que aceita mais escrita do que devia.'
+ );
+ }
+
+ /**
+ * A constraint do worker aponta para o pai DO WORKER, e é o banco real que diz.
+ *
+ * Este caso existe por causa de um defeito que a paridade não enxergava. O
+ * `REFERENCES` era qualificado com o nome do modelo, e tanto a comparação de
+ * `describe()` quanto o teste do SQL acima passavam: a forma da constraint
+ * estava certa, e o schema para onde ela aponta é uma informação que nenhuma
+ * das duas conferia.
+ *
+ * A consequência era o oposto do que este arquivo existe para entregar. O pai
+ * copiado para dentro do worker deixava de valer para a constraint — então um
+ * filho órfão passava, e a suíte podia gravar o que a produção recusa — e todos
+ * os workers terminavam segurando X-lock nas mesmas linhas do pai no modelo,
+ * que é a contenção entre processos que o banco por worker veio para matar,
+ * trocada por uma menor e muito mais difícil de ver.
+ *
+ * Ler `REFERENCED_TABLE_SCHEMA` é o que enxerga isso. `describe()` não traz o
+ * schema de propósito: ele compara worker contra worker, e o nome do banco de
+ * cada um é diferente justamente por isso.
+ *
+ * A leitura é `SchemaReader::foreignKeyTargetSchemas()`, e a consulta em vez de
+ * `CONSTRAINT_SCHEMA = DATABASE()` que este caso usava. `DATABASE()` é o banco
+ * que a CONEXÃO tem selecionado, o que é uma suposição sobre quem conectou, e
+ * a suposição quebrava em silêncio: passar um `$schema` diferente do selecionado
+ * media um banco e afirmava sobre o outro. O nome vai explícito, e é a mesma
+ * leitura que serve o resto da suíte.
+ *
+ * O banco inspecionado é `$test->database()`, que é o worker do próprio
+ * processo — a cópia que o bootstrap já fez antes do boot do CI3. Nenhuma cópia
+ * é feita aqui, e isso é uma correção e não uma economia: este caso já clonava
+ * para `self::destination()`, que é um banco à parte, e depois media o worker.
+ * A cópia não era lida por nenhuma das afirmações, então ela pagava o custo de
+ * uma clonagem — a operação mais cara da suíte — para não responder nada. Num
+ * fingerprint frio são os ~3.7s que o AGENTS.md documenta, e o resultado ficava
+ * lá esperando uma leitura que não existia.
+ */
+ #[Test]
+ public function testTheClonedConstraintPointsInsideTheWorker(): void
+ {
+ $test = TestDatabase::fromEnvironment();
+
+ // Mesmo motivo do caso acima, e pelo mesmo motivo ele não é um detalhe: sem
+ // worker, `$test->database()` é o modelo, e a consulta mediria o modelo
+ // apontando para si mesmo. Isso é verdade e não prova nada sobre cópia.
+ if ($test->database() === $test->templateDatabase()) {
+ $this->markTestSkipped(
+ 'Sem TEST_TOKEN este processo usa o banco modelo, então não há cópia de worker para '
+ . 'conferir. O caso roda em cada worker do ParaTest, e é lá que ele vale.'
+ );
+ }
+
+ $schema = $test->database();
+
+ $this->assertNotEmpty(
+ $targets = SchemaReader::foreignKeyTargetSchemas($test->pdo($schema), $schema),
+ 'O destino clonado não tem nenhuma constraint; a cópia não levou as FKs.'
+ );
+
+ $this->assertSame(
+ [$schema],
+ $targets,
+ 'Há constraint apontando para fora do worker. Uma chave estrangeira que cruza de banco '
+ . 'não confere a cópia local do pai e ainda segura lock no modelo, que é o que o '
+ . 'banco por worker existe para não acontecer.'
+ );
+ }
+}
diff --git a/application/tests/Support/ControllerTestCase.php b/application/tests/Support/ControllerTestCase.php
new file mode 100644
index 000000000..c62b45acc
--- /dev/null
+++ b/application/tests/Support/ControllerTestCase.php
@@ -0,0 +1,227 @@
+resetApplicationState();
+ }
+
+ protected function tearDown(): void
+ {
+ $this->resetApplicationState();
+
+ parent::tearDown();
+ }
+
+ protected function ci(): object
+ {
+ return TestApplication::superObject();
+ }
+
+ 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('');
+ // Limpa os cabeçalhos acumulados, para o caso seguinte não ler um
+ // Location de um logout anterior. A propriedade é pública no CI_Output e
+ // não tem método que a zere, porque em requisição web cada resposta
+ // nasce de um Output novo; aqui o mesmo objeto serve a todos os casos.
+ //
+ // Apendar outro Location não resolveria: get_header() varre de trás
+ // para frente e devolve a última ocorrência, então quem só escreve o
+ // header que espera continuaria lendo o valor certo por acidente — e
+ // um caso que afirma a AUSÊNCIA de um header leria o antigo.
+ $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 $password): 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'] = $password;
+ }
+
+ /**
+ * 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();
+
+ // 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) {
+ $left = $controller->db;
+ $controller->db = $ci->db;
+ $left->close();
+ }
+
+ 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/Database/DatabaseGuard.php b/application/tests/Support/Database/DatabaseGuard.php
new file mode 100644
index 000000000..2dd93cf16
--- /dev/null
+++ b/application/tests/Support/Database/DatabaseGuard.php
@@ -0,0 +1,221 @@
+ 64) {
+ throw new RuntimeException(
+ "O nome do worker '{$name}' tem " . strlen($name) . ' caracteres e o MySQL aceita 64. '
+ . 'Encurte MAPOS_TEST_DB_DATABASE.'
+ );
+ }
+
+ return $name;
+ }
+
+ /**
+ * Este nome é o de um worker derivado DESTE modelo?
+ *
+ * A conferida é o que separa o drop-worker-databases.php de um `DROP` preguiçoso,
+ * e ela é feita refazendo o nome em vez de comparando com um padrão escrito
+ * aqui. Passar por esta função significa que nada é apagado por inferência: só
+ * sai daqui um nome que o próprio código de clonagem sabe produzir. Um banco de
+ * teste que alguém tenha criado com cara parecida é ignorado e relatado, para
+ * quem o criou decidir.
+ *
+ * Comparar com um padrão duplicaria a regra do token, e uma regra duplicada
+ * diverge no dia em que alguém aceita um caractere novo no token — que é
+ * exatamente quando este script passaria a apagar o banco errado sem reclamar.
+ *
+ * A pergunta é "este nome é meu?", e não "este nome parece com o de um worker?":
+ * com um modelo `mapos_test`, um padrão como `/_\d+_test$/` também casaria com
+ * `financeiro_2_test`, que é de outro dono e de outro branch.
+ */
+ public static function isWorkerDatabaseName(string $name, string $template): bool
+ {
+ // O token é o que está entre a base e o sufixo. Recusar aqui é a mesma
+ // resposta que `workerDatabaseName()` dá, e é a que interessa: um token que
+ // ela recusaria é um token que ela não teria gerado.
+ $prefix = self::modelBase($template) . '_';
+ $suffix = '_test';
+
+ if (! str_starts_with($name, $prefix) || ! str_ends_with($name, $suffix)) {
+ return false;
+ }
+
+ $token = substr($name, strlen($prefix), -strlen($suffix));
+
+ if ($token === '') {
+ return false;
+ }
+
+ try {
+ return self::workerDatabaseName($template, $token) === $name;
+ } catch (RuntimeException) {
+ return false;
+ }
+ }
+}
diff --git a/application/tests/Support/Database/DatabaseGuardTest.php b/application/tests/Support/Database/DatabaseGuardTest.php
new file mode 100644
index 000000000..fde18b4b4
--- /dev/null
+++ b/application/tests/Support/Database/DatabaseGuardTest.php
@@ -0,0 +1,329 @@
+addToAssertionCount(4);
+ }
+
+ /**
+ * Qualquer nome sem o sufixo é recusado, e o nome recusado aparece na mensagem.
+ *
+ * `mapos` e `test` são os dois jeitos de errar o sufixo: um esqueceu de
+ * escrever, o outro escreveu do lado errado. E `mapos_teste` é o caso que só
+ * aparece quando alguém usa o nome em português — a regex é ancorada no fim
+ * justamente para ele não passar.
+ *
+ * A mensagem importa porque é o que o operador lê quando o CI falha por
+ * configuração, e um "abortado" sem o nome recebido não diz o que corrigir.
+ */
+ #[DataProvider('provideUnsafeDatabaseNames')]
+ #[Test]
+ public function testANameWithoutTheTestSuffixIsRefused(string $name): void
+ {
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessage("'" . $name . "'");
+
+ DatabaseGuard::assertDatabaseNameIsSafe($name);
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function provideUnsafeDatabaseNames(): iterable
+ {
+ // Um nome só, dois rótulos, é o caso repetido: `mapos` era a entrada duas
+ // vezes, uma como "sem sufixo" e outra como "o banco de desenvolvimento".
+ // O rótulo que ficou é o concreto, porque é ele que diz por que a regra
+ // existe: sem ele, o conjunto parece cobrir a forma do nome e o valor
+ // concreto da regra, e não cobre.
+ yield 'o banco de desenvolvimento, que é o que a regra existe para proteger' => ['mapos'];
+ yield 'sufixo trocado' => ['mapos_teste'];
+ yield 'sufixo no meio' => ['test_mapos'];
+ yield 'só o sufixo, sem nome' => ['test'];
+ yield 'vazio' => [''];
+ yield 'produção' => ['mapos_producao'];
+ }
+
+ /**
+ * O sufixo `_test` sozinho não autoriza o nome: ele também tem que ser um
+ * identificador.
+ *
+ * Os dois casos daqui passam na exigência do sufixo e falham na do identificador,
+ * e os dois são o mesmo defeito visto de dois lados: o nome entra concatenado
+ * entre crases num `DROP DATABASE` e num `CREATE DATABASE`, e num qualificado
+ * `banco`.`tabela`. Uma barra, uma crase, um espaço ou um ponto-e-vírgula no meio
+ * do nome fecham a instrução antes do fim.
+ *
+ * O primeiro é travessia de caminho: `../../x_test` termina em `_test`, então a
+ * guarda antiga o aceitava, e `SchemaFingerprint::fingerprintPath()` o usava
+ * para montar o arquivo de impressão, escrevendo fora do diretório que ela
+ * documentava não ser escapável. O segundo é injeção de DDL, e é o que o
+ * comentário da guarda chamava de "uma única resposta para isto é seguro para
+ * concatenar numa instrução" — resposta que a guarda não dava.
+ *
+ * O terceiro é o caso incidental, e é o que uma pessoa depara na prática: um
+ * `MAPOS_TEST_DB_DATABASE` escrito com traço ou ponto, que hoje funciona e
+ * depois de aqui passa a ser recusado com a mensagem dizendo o que é aceito.
+ * Recusar é a direção certa para a única barreira entre um teste e o banco de
+ * desenvolvimento.
+ *
+ * @return iterable
+ */
+ #[DataProvider('provideTestSuffixedNamesThatAreNotIdentifiers')]
+ #[Test]
+ public function testATestSuffixedNameThatIsNotAnIdentifierIsRefused(string $name): void
+ {
+ $this->assertSame(
+ 1,
+ preg_match('/_test$/', $name),
+ 'o caso precisa passar na exigência do sufixo, senão não prova a do identificador'
+ );
+
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessage("'" . $name . "'");
+
+ DatabaseGuard::assertDatabaseNameIsSafe($name);
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function provideTestSuffixedNamesThatAreNotIdentifiers(): iterable
+ {
+ yield 'travessia de caminho, que escapa do diretório de impressões digitais' => ['../../x_test'];
+ yield 'crase e ponto-e-vírgula, que fecham a instrução antes do fim' => ['x`;DROP DATABASE mapos;-- _test'];
+ yield 'espaço, que o MySQL aceitaria entre aspas' => ['meu banco_test'];
+ yield 'traço, que alguém usa por hábito' => ['mapos-dev_test'];
+ yield 'acento, que um nome em português traz' => ['mapos_josé_test'];
+ }
+
+ /**
+ * O nome do worker entra antes do sufixo, e não depois.
+ *
+ * Este é o caso que a ordem do nome obedece: `mapos_test_1` é recusado pela
+ * guarda acima, e é recusado DE PROPÓSITO. Se o token fosse depois do sufixo, o
+ * nome do worker não passaria pela única barreira que existe contra um `DROP`
+ * no banco errado, e a proteção passaria a depender de cada script lembrar de
+ * checar o sufixo por conta própria.
+ */
+ #[Test]
+ public function testTheWorkerTokenGoesBeforeTheTestSuffix(): void
+ {
+ $this->assertSame('mapos_1_test', DatabaseGuard::workerDatabaseName('mapos_test', '1'));
+ $this->assertSame('mapos_abc_test', DatabaseGuard::workerDatabaseName('mapos_test', 'abc'));
+
+ $this->expectException(RuntimeException::class);
+ DatabaseGuard::assertDatabaseNameIsSafe('mapos_test_1');
+ }
+
+ /**
+ * A mesma chamada é idempotente: chamar duas vezes dá o mesmo nome.
+ *
+ * O `workerDatabaseName()` é chamado de dois lugares independentes — o clone e
+ * a conferência do drop-worker-databases — e os dois precisam chegar ao mesmo
+ * nome. Se a função lembrasse de um token anterior, os dois lados divergiriam e
+ * o drop pararia de reconhecer o banco que o clone criou.
+ */
+ #[Test]
+ public function testTheWorkerNameIsIdempotent(): void
+ {
+ $this->assertSame(
+ DatabaseGuard::workerDatabaseName('mapos_test', '7'),
+ DatabaseGuard::workerDatabaseName('mapos_test', '7')
+ );
+ }
+
+ /**
+ * Um token com caractere que não pode entrar num nome é recusado, não limpo.
+ *
+ * Sanitizar '1-a' e '1_a' para o mesmo banco seria uma falha de isolamento
+ * criada em silêncio, e ela só apareceria quando um worker esperasse o lock do
+ * outro — intermitente por definição. Recusar é a resposta que transforma esse
+ * silêncio num erro de primeira linha.
+ */
+ #[DataProvider('provideUnsafeTokens')]
+ #[Test]
+ public function testATokenWithAnUnsafeCharacterIsRefused(string $token): void
+ {
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessage("'{$token}'");
+
+ DatabaseGuard::workerDatabaseName('mapos_test', $token);
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function provideUnsafeTokens(): iterable
+ {
+ yield 'hífen' => ['1-a'];
+ yield 'ponto' => ['1.a'];
+ yield 'espaço' => ['1 a'];
+ yield 'barra' => ['1/a'];
+ yield 'vazio' => [''];
+ yield 'cedilha' => ['1ç'];
+ yield 'asterisco' => ['1*'];
+ }
+
+ /**
+ * Um nome de worker que não cabe em 64 caracteres é recusado, e não truncado.
+ *
+ * O MySQL trunca um identificador longo em silêncio, e truncar
+ * 'mapos_x_12_test' pode virar 'mapos_x_1_test' — dois workers no mesmo banco,
+ * que é exatamente a linha que o clone existe para não contestar. A recusa
+ * acontece antes do CREATE por isso.
+ */
+ #[Test]
+ public function testAWorkerNameThatWouldOverflowTheMysqlIdentifierLimitIsRefused(): void
+ {
+ $long = str_repeat('x', 60) . '_test';
+ $this->assertSame(65, strlen($long));
+
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessage('64');
+
+ DatabaseGuard::workerDatabaseName($long, '1');
+ }
+
+ /**
+ * A base do modelo é o nome sem o sufixo, e um modelo sem base é recusado.
+ */
+ #[Test]
+ public function testTheModelBaseIsTheNameWithoutTheSuffix(): void
+ {
+ $this->assertSame('mapos', DatabaseGuard::modelBase('mapos_test'));
+ $this->assertSame('mapos_1', DatabaseGuard::modelBase('mapos_1_test'));
+
+ $this->expectException(RuntimeException::class);
+ DatabaseGuard::modelBase('_test');
+ }
+
+ /**
+ * Só o nome que `workerDatabaseName()` produziria é reconhecido como de worker.
+ *
+ * Esta é a conferida que separa o drop-worker-databases de um `DROP` preguiçoso,
+ * e ela é feita refazendo o nome em vez de comparando com um padrão escrito à
+ * mão. Os casos abaixo são os que um padrão escrito à mão aceitaria por engano.
+ */
+ #[Test]
+ public function testOnlyAReDerivableWorkerNameIsRecognised(): void
+ {
+ $model = 'mapos_test';
+
+ $this->assertTrue(DatabaseGuard::isWorkerDatabaseName('mapos_1_test', $model));
+ $this->assertTrue(DatabaseGuard::isWorkerDatabaseName('mapos_abc_test', $model));
+
+ // O token mais simples que é legal é 'test', e `mapos_test_test` é um nome
+ // de worker verdadeiro. A conferida refaz o nome, e o que ela recusa é o
+ // que o clone não produziria — não o que parece esquisito. Um padrão
+ // escrito à mão teria de listar as exceções, e a lista é infinita.
+ $this->assertTrue(DatabaseGuard::isWorkerDatabaseName('mapos_test_test', $model));
+
+ // O próprio modelo nunca é um worker, e é o banco que mais caro seria
+ // apagar: reconstruí-lo custa a cadeia inteira de migrations.
+ $this->assertFalse(DatabaseGuard::isWorkerDatabaseName('mapos_test', $model));
+
+ // Sem token no meio.
+ $this->assertFalse(DatabaseGuard::isWorkerDatabaseName('mapos__test', $model));
+
+ // De outro modelo, visto pelo modelo daqui. Com um padrão como
+ // `_\d+_test$` este nome passaria, e é o banco de outro branch — é o
+ // WorkerParityTest e o clone de outro token que o usariam, e este script
+ // não tem por que mandar embora num banco que não é dele.
+ $this->assertFalse(DatabaseGuard::isWorkerDatabaseName('financeiro_1_test', $model));
+
+ // E o worker do outro modelo continua sendo dele, o que é o outro lado da
+ // mesma regra: quem decide é o modelo que se está limpando.
+ $this->assertTrue(DatabaseGuard::isWorkerDatabaseName('financeiro_2_test', 'financeiro_test'));
+
+ // Não é nome de worker de jeito nenhum.
+ $this->assertFalse(DatabaseGuard::isWorkerDatabaseName('mapos', $model));
+ $this->assertFalse(DatabaseGuard::isWorkerDatabaseName('', $model));
+ $this->assertFalse(DatabaseGuard::isWorkerDatabaseName('mapos_1', $model));
+ }
+
+ /**
+ * Um token que o nome recusa não transforma o banco num worker.
+ *
+ * `mapos_1-a_test` tem cara de nome de worker e um token que
+ * `workerDatabaseName()` jamais produziria. A conferida refaz o nome, o
+ * `workerDatabaseName()` recusa o token, e a resposta é não — que é o que
+ * impede o script de apagar um banco que ninguém do ParaTest criou.
+ */
+ #[Test]
+ public function testATokenTheNameRefusesDoesNotMakeItAWorkerDatabase(): void
+ {
+ $this->assertFalse(DatabaseGuard::isWorkerDatabaseName('mapos_1-a_test', 'mapos_test'));
+ $this->assertFalse(DatabaseGuard::isWorkerDatabaseName('mapos_1.a_test', 'mapos_test'));
+ }
+
+ /**
+ * O identificador que pode entrar num SQL é um só, e ele recusa o que não é nome.
+ *
+ * `SchemaReader::identifier()` e `workerDatabaseName()` faziam a mesma pergunta
+ * com a mesma regex em dois arquivos. A resposta única mora aqui, e este caso
+ * fixa o que ela aceita para que uma mudança de um lado não mude só o outro.
+ */
+ #[DataProvider('provideIdentifiers')]
+ #[Test]
+ public function testASafeIdentifierIsAlettersAndDigitsAndUnderscoreOnly(string $value, bool $expected): void
+ {
+ $this->assertSame($expected, DatabaseGuard::isSafeIdentifier($value));
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function provideIdentifiers(): iterable
+ {
+ yield 'simples' => ['os', true];
+ yield 'com sublinhado' => ['id_os', true];
+ yield 'com dígito' => ['tabela1', true];
+ yield 'vazio' => ['', false];
+ yield 'com ponto' => ['mapos.test', false];
+ yield 'com hífen' => ['mapos-test', false];
+ yield 'com crase' => ['`os`', false];
+ yield 'com aspas' => ['"os"', false];
+ yield 'com espaço' => ['id os', false];
+ yield 'com ponto e vírgula' => ['os; DROP TABLE x', false];
+ yield 'tentativa de injeção' => ['os`; DROP TABLE `x', false];
+ }
+}
diff --git a/application/tests/Support/Database/SchemaDiffTest.php b/application/tests/Support/Database/SchemaDiffTest.php
new file mode 100644
index 000000000..fa1117ad3
--- /dev/null
+++ b/application/tests/Support/Database/SchemaDiffTest.php
@@ -0,0 +1,203 @@
+ ['idClientes' => 'int', 'nome' => 'varchar(255)'],
+ 'os' => ['idOs' => 'int', 'valor' => 'decimal(10,2)'],
+ ];
+
+ $this->assertSame([], schemaDiffBetween($schema, $schema));
+ }
+
+ /**
+ * Uma tabela que só existe de um dos lados é nomeada pelo lado que a tem.
+ *
+ * Os dois sentidos, porque a versão anterior da função comparava a união das
+ * chaves e reportava a tabela ausente pelo nome do lado que não a tinha — o
+ * que produzia "tabela só nas migrações" para uma tabela que estava no
+ * banco.sql, invertendo a acusação.
+ */
+ #[Test]
+ public function testATableOnlyOnOneSideIsReportedByThatSide(): void
+ {
+ $dump = ['clientes' => ['idClientes' => 'int'], 'so_no_dump' => ['id' => 'int']];
+ $migrations = ['clientes' => ['idClientes' => 'int'], 'so_nas_migrations' => ['id' => 'int']];
+
+ $this->assertSame(
+ [
+ 'tabela só no banco.sql: so_no_dump',
+ 'tabela só nas migrações: so_nas_migrations',
+ ],
+ schemaDiffBetween($dump, $migrations)
+ );
+ }
+
+ /**
+ * Uma coluna removida de um dos lados aparece, qualquer que seja o lado.
+ *
+ * Este é o defeito real que a função já teve, e o teste existe para o
+ * próximo laço assimétrico não voltar. A versão anterior eram dois laços
+ * quase idênticos, e o segundo não tinha o laço interno de colunas: uma coluna
+ * removida aparecia num caminho e ficava invisível no outro, dependendo de qual
+ * schema fosse percorrido por último. Um teste que exercitasse só este par,
+ * nesta ordem, passaria com os dois laços.
+ *
+ * Por isso os dois sentidos e a ordem invertida: a mesma coluna removida, com
+ * os mapas trocados de posição, tem de continuar sendo reportada.
+ */
+ #[Test]
+ public function testAColumnRemovedOnEitherSideIsReportedInBothDirections(): void
+ {
+ $dump = ['os' => ['idOs' => 'int', 'numero' => 'int', 'despejo' => 'int']];
+ $migrations = ['os' => ['idOs' => 'int', 'numero' => 'int']];
+
+ $this->assertSame(
+ ['coluna só no banco.sql: os.despejo int'],
+ schemaDiffBetween($dump, $migrations),
+ 'coluna que o banco.sql tem e a migration não'
+ );
+
+ $this->assertSame(
+ ['coluna só nas migrações: os.despejo int'],
+ schemaDiffBetween($migrations, $dump),
+ 'a mesma coluna, com os lados trocados'
+ );
+ }
+
+ /**
+ * A tabela precisa existir dos dois lados para a coluna ser comparada.
+ *
+ * Este caso é o que separa "coluna divergente" de "tabela divergente": uma
+ * coluna que existe num lado de uma tabela ausente no outro não é uma coluna
+ * a mais, é uma tabela a mais, e reportar as duas coisas seria contar a mesma
+ * diferença duas vezes.
+ */
+ #[Test]
+ public function testAColumnIsNotReportedWhenTheWholeTableIsMissingOnTheOtherSide(): void
+ {
+ $dump = ['os' => ['idOs' => 'int']];
+ $migrations = ['clientes' => ['idClientes' => 'int']];
+
+ $this->assertSame(
+ [
+ 'tabela só no banco.sql: os',
+ 'tabela só nas migrações: clientes',
+ ],
+ schemaDiffBetween($dump, $migrations)
+ );
+ }
+
+ /**
+ * Tipo diferente na mesma coluna nomeia os dois lados e o valor de cada um.
+ *
+ * A mensagem é o produto desta função: é ela que o CI imprime quando o gate
+ * falha, e quem lê é alguém procurando qual migration corrigir. Um relatório
+ * que dissesse só "diferença em os.valor" obrigaria a abrir os dois lados
+ * na mão.
+ */
+ #[Test]
+ public function testADivergentTypeNamesBothSidesAndBothValues(): void
+ {
+ $dump = ['os' => ['valor' => 'decimal(10,0)']];
+ $migrations = ['os' => ['valor' => 'decimal(12,2)']];
+
+ $this->assertSame(
+ ['tipo divergente: os.valor banco.sql=decimal(10,0) migracoes=decimal(12,2)'],
+ schemaDiffBetween($dump, $migrations)
+ );
+ }
+
+ /**
+ * A comparação de tipo não distingue maiúscula de minúscula.
+ *
+ * `SHOW COLUMNS` e `information_schema.columns` grafam o mesmo tipo do mesmo
+ * jeito, mas o gate já comparava com strcasecmp e continua comparando: mudar
+ * isso aqui viraria um falso "divergente" por causa de caixa, que é o tipo de
+ * falha que faz alguém caçar um bug de schema que não existe.
+ */
+ #[Test]
+ public function testTypeComparisonIgnoresCase(): void
+ {
+ $this->assertSame(
+ [],
+ schemaDiffBetween(
+ ['os' => ['valor' => 'VARCHAR(255)']],
+ ['os' => ['valor' => 'varchar(255)']]
+ )
+ );
+ }
+
+ /**
+ * A ordem em que as tabelas entraram no mapa não muda o resultado.
+ *
+ * Os dois mapas abaixo têm o mesmo conteúdo e as mesmas chaves em ordens
+ * inversas. A diferença tem de ser a mesma nos dois casos, senão o gate
+ * reportaria uma divergência que depende de como o MySQL devolveu as linhas.
+ *
+ * O que NÃO é invariante é trocar os argumentos: `schemaDiffBetween($dump, $migrations)`
+ * nomeia `banco.sql` como o lado do primeiro argumento, e inverter os dois
+ * inverte os rótulos. Isso é o comportamento certo — a mensagem existe para
+ * dizer qual lado tem o quê, e um rótulo que não acompanhasse o argumento
+ * apontaria o developer para a migration quando o problema está no banco.sql.
+ */
+ #[Test]
+ public function testTheKeyInsertionOrderDoesNotChangeTheResult(): void
+ {
+ $dump = ['a' => ['x' => 'int'], 'b' => ['y' => 'int']];
+ $migrations = ['b' => ['y' => 'varchar(4)'], 'a' => ['x' => 'int']];
+
+ $sameContentOtherOrder = [
+ 'a' => ['x' => 'int'],
+ 'b' => ['y' => 'int'],
+ ];
+ $sameContentOtherOrderMigrations = [
+ 'a' => ['x' => 'int'],
+ 'b' => ['y' => 'varchar(4)'],
+ ];
+
+ $expected = [
+ 'tipo divergente: b.y banco.sql=int migracoes=varchar(4)',
+ ];
+
+ $this->assertSame($expected, schemaDiffBetween($dump, $migrations));
+ $this->assertSame(
+ $expected,
+ schemaDiffBetween($sameContentOtherOrder, $sameContentOtherOrderMigrations)
+ );
+ }
+}
diff --git a/application/tests/Support/Database/SchemaFingerprint.php b/application/tests/Support/Database/SchemaFingerprint.php
new file mode 100644
index 000000000..b13371561
--- /dev/null
+++ b/application/tests/Support/Database/SchemaFingerprint.php
@@ -0,0 +1,321 @@
+test->pdo();
+
+ if (! SchemaReader::databaseExists($admin, $database)) {
+ 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 ' . SchemaReader::qualified($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)) === $this->stamp($admin, $database);
+ }
+
+ /**
+ * 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 é o carimbo — hash e contagem — e nada mais
+ * do banco: o arquivo existe para responder "isto ainda é o que foi
+ * construído?", e não guarda nada que o próprio banco não responda.
+ */
+ public function record(string $database): void
+ {
+ DatabaseGuard::assertDatabaseNameIsSafe($database);
+
+ $path = static::fingerprintPath($database);
+ $dir = dirname($path);
+
+ if (! is_dir($dir)) {
+ mkdir($dir, 0700, true);
+ }
+
+ file_put_contents($path, $this->stamp($this->test->pdo(), $database) . PHP_EOL);
+ }
+
+ /**
+ * O carimbo do banco: o hash dos arquivos, e quantas tabelas ele tem.
+ *
+ * Uma linha só, e os dois lados dela comparados com `===` sobre a string
+ * inteira. Um sidecar no formato antigo — só o hash — não casa, e o banco é
+ * remontado: é o comportamento certo para um arquivo cujo formato mudou, e o
+ * preço é uma montagem de ~9s uma vez.
+ *
+ * A conexão chega por parâmetro porque `pdo()` abre uma nova a cada chamada, e
+ * `isCurrent()` já tem uma em mãos.
+ */
+ private function stamp(PDO $pdo, string $database): string
+ {
+ $tables = count(SchemaReader::tableNames($pdo, $database));
+
+ return static::schemaFingerprint() . ' ' . $tables;
+ }
+
+ /**
+ * 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 forget(string $database): void
+ {
+ DatabaseGuard::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;
+ }
+
+ /**
+ * 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);
+ }
+
+ /**
+ * Onde mora a impressão digital deste banco.
+ *
+ * Pública para o teste escrever no MESMO caminho que a produção lê. A versão
+ * anterior reconstruía a string aqui, byte a byte, e isso é uma divergência
+ * silenciosa esperando: dois leitores independentes que discordem aparecem como
+ * "o banco está em dia mas a impressão digital não bate", sem causa visível —
+ * e `isCurrent()` faz curto-circuito em `is_file()`, então um caminho errado no
+ * teste produz um teste que passa pelo motivo errado, para sempre.
+ */
+ public 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());
+ }
+
+ /**
+ * 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.
+ *
+ * O TestFixtures é relativo a `__DIR__`, e não montado com a raiz: ele mora ao
+ * lado desta classe, e a versão anterior escrevia o rótulo e o caminho como o
+ * mesmo literal duas vezes — o que codificava a reorganização do próprio
+ * branch (o arquivo saiu de `Support/` para `Support/Database/`), e fazia
+ * `fingerprintFor()` lançar em vez de responder. `__DIR__` não pode dessincronizar
+ * do rótulo: os dois mudam juntos ou nenhum dos dois muda.
+ *
+ * @return array rótulo => caminho absoluto
+ */
+ private static function schemaFiles(): array
+ {
+ $root = TestDatabase::envPath();
+
+ $files = [
+ 'tests/Support/Database/TestFixtures.php' => __DIR__ . '/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);
+ }
+}
diff --git a/application/tests/Support/Database/SchemaFingerprintTest.php b/application/tests/Support/Database/SchemaFingerprintTest.php
new file mode 100644
index 000000000..43e8e0cc4
--- /dev/null
+++ b/application/tests/Support/Database/SchemaFingerprintTest.php
@@ -0,0 +1,466 @@
+
+ */
+ private array $temporaryFiles = [];
+
+ /**
+ * Deriva os dois nomes do processo e deixa o banco descartável pronto.
+ *
+ * O que se pede é o que DatabaseGuard::assertDatabaseNameIsSafe() exige e o que
+ * o MySQL aceita: o nome tem de terminar em `_test`, e a guarda roda antes de
+ * qualquer conexão — um nome fora do padrão aborta o processo inteiro em vez de
+ * testar o que pretende. A base sem o sufixo é o que entra no
+ * workerDatabaseName(), que devolve o nome com `_test` no fim.
+ */
+ #[Before]
+ public function prepareDatabaseNames(): void
+ {
+ $token = TestDatabase::parallelToken() ?? 'solo';
+
+ $this->database = DatabaseGuard::workerDatabaseName('mapos_schema_probe', $token);
+ $this->missingDatabaseName = DatabaseGuard::workerDatabaseName('mapos_nunca_criado', $token);
+ }
+
+ /**
+ * 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.
+ *
+ * O `drop()` de `TestDatabase`, e não um `DROP DATABASE` escrito aqui. Este
+ * arquivo mantinha a própria instrução, com o nome concatenado entre crases, e
+ * assim a única barreira contra um `DROP` no banco errado — `DatabaseGuard` — não
+ * era consultada neste caminho. O nome vinha de `workerDatabaseName()`, que já é
+ * verificado, mas a verificação acontecia por acaso e não por construção: a
+ * segunda escrita do `DROP` é a que deixa de depender do que o chamador fez.
+ */
+ #[After]
+ public function cleanDisposableDatabase(): void
+ {
+ $test = TestDatabase::fromEnvironment();
+
+ try {
+ $test->drop($this->database);
+ } finally {
+ $test->schemaFingerprint()->forget($this->database);
+
+ foreach ($this->temporaryFiles as $file) {
+ if (is_file($file)) {
+ unlink($file);
+ }
+ }
+
+ $this->temporaryFiles = [];
+ }
+ }
+
+ /**
+ * 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->disposableDatabase();
+ $pdo = $test->pdo($this->database);
+
+ $pdo->exec('CREATE TABLE `migrations` (`version` VARCHAR(20) NOT NULL)');
+ $pdo->exec(sprintf(
+ "INSERT INTO `migrations` (`version`) VALUES ('%s')",
+ SchemaFingerprint::latestMigrationVersion()
+ ));
+
+ $this->assertFalse(
+ $test->schemaFingerprint()->isCurrent($this->database),
+ 'Sem o arquivo de impressão digital, a procedência do banco é desconhecida e ele não pode ser reaproveitado.'
+ );
+
+ $test->schemaFingerprint()->record($this->database);
+
+ $this->assertTrue(
+ $test->schemaFingerprint()->isCurrent($this->database),
+ '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 trocado 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->disposableDatabase();
+ $pdo = $test->pdo($this->database);
+
+ $pdo->exec('CREATE TABLE `migrations` (`version` VARCHAR(20) NOT NULL)');
+ $pdo->exec("INSERT INTO `migrations` (`version`) VALUES ('20210101000000')");
+
+ $test->schemaFingerprint()->record($this->database);
+
+ $this->assertFalse(
+ $test->schemaFingerprint()->isCurrent($this->database),
+ '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.
+ *
+ * O `record()` antes de sobrescrever é a linha que torna este caso real.
+ * Sem ele, `is_file()` faz curto-circuito, `isCurrent()` responde falso pelo
+ * motivo errado, e o caso passa de forma idêntica nos dois motivos: se
+ * `fingerprintPath()` mudasse de lugar, ele continuaria verde para sempre sem
+ * nunca ter exercitado a comparação de hash. Por isso o carimbo gravado
+ * primeiro é lido de volta e conferido, e só o HASH é trocado — um carimbo
+ * malformado também reprovaria, e reprovaria pelo mesmo motivo errado.
+ */
+ public function testAMismatchedFingerprintIsNotCurrent(): void
+ {
+ $test = $this->disposableDatabase();
+ $pdo = $test->pdo($this->database);
+
+ $pdo->exec('CREATE TABLE `migrations` (`version` VARCHAR(20) NOT NULL)');
+ $pdo->exec(sprintf(
+ "INSERT INTO `migrations` (`version`) VALUES ('%s')",
+ SchemaFingerprint::latestMigrationVersion()
+ ));
+
+ $test->schemaFingerprint()->record($this->database);
+
+ $this->assertTrue(
+ $test->schemaFingerprint()->isCurrent($this->database),
+ 'Sem isto, o que se prova abaixo é que um sidecar ausente reprova — e não que um hash divergente reprova.'
+ );
+
+ // O carimbo que o setup-db.php teria escrito depois de uma montagem a partir
+ // de arquivos diferentes destes: mesmo formato, mesmo número de tabelas, e
+ // só o hash trocado.
+ [, $tables] = explode(' ', trim((string) file_get_contents(SchemaFingerprint::fingerprintPath($this->database))), 2);
+
+ file_put_contents(
+ SchemaFingerprint::fingerprintPath($this->database),
+ hash('sha256', 'um conjunto de migrations e seeds que não é o deste') . ' ' . $tables . PHP_EOL
+ );
+
+ $this->assertFalse(
+ $test->schemaFingerprint()->isCurrent($this->database),
+ 'A impressão digital não bate, então os arquivos mudaram depois da montagem e o banco precisa ser refeito.'
+ );
+ }
+
+ /**
+ * Um banco que perdeu uma tabela não é o banco que foi montado.
+ *
+ * Esta é a única das condições de reaproveitamento que fala do schema, e ela
+ * existe porque as outras três são todas sobre metadado: o arquivo existe, a
+ * versão bate, o hash bate. Um `DROP TABLE os` à mão deixa as três verdadeiras
+ * e a suíte inteira roda contra 27 tabelas — a mesma classe de falha que a
+ * impressão digital existe para impedir, alcançada por outra porta.
+ *
+ * A troca é feita na contagem e não no arquivo: gravar um carimbo errado
+ * reprovaria pelo motivo do caso acima, e o que se quer provar é que a
+ * CONTAGAM do banco, e não o conteúdo do sidecar, é lida.
+ */
+ public function testASchemaThatLostATableIsNotCurrent(): void
+ {
+ $test = $this->disposableDatabase();
+ $pdo = $test->pdo($this->database);
+
+ $pdo->exec('CREATE TABLE `migrations` (`version` VARCHAR(20) NOT NULL)');
+ $pdo->exec(sprintf(
+ "INSERT INTO `migrations` (`version`) VALUES ('%s')",
+ SchemaFingerprint::latestMigrationVersion()
+ ));
+ $pdo->exec('CREATE TABLE `probe` (`id` INT NOT NULL)');
+
+ $test->schemaFingerprint()->record($this->database);
+
+ $this->assertTrue(
+ $test->schemaFingerprint()->isCurrent($this->database),
+ 'O banco tem o que foi registrado, então ele é reaproveitável antes do DROP.'
+ );
+
+ $pdo->exec('DROP TABLE `probe`');
+
+ $this->assertFalse(
+ $test->schemaFingerprint()->isCurrent($this->database),
+ 'O banco perdeu uma tabela depois de ser montado, e as três condições de metadado continuam verdadeiras: a contagem é o que pega.'
+ );
+ }
+
+ /**
+ * 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->schemaFingerprint()->isCurrent($this->missingDatabaseName),
+ '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.
+ *
+ * A lista de tabelas é lida por `SchemaReader::tableNames()`, e não pela consulta
+ * que este caso escrevia. Este é o teste vizinho do `SchemaTest.php:69`, que
+ * foi convertido e até comenta o porquê; a conversão não tinha chegado aqui. A
+ * consequência de ficar para trás é que a afirmação "¿a impressão virou uma
+ * tabela?" era respondida por uma leitura de `information_schema.tables` que
+ * ninguém mais da suíte usa, e o filtro `table_schema` dela podia divergir do
+ * `SchemaReader` sem que nada percebesse — um teste que passa conferindo o
+ * conjunto errado é pior do que não conferir, porque ocupa o lugar de uma
+ * conferência que não existe.
+ */
+ public function testTheFingerprintLivesOutsideTheDatabase(): void
+ {
+ $test = $this->disposableDatabase();
+ $test->pdo($this->database)->exec('CREATE TABLE `migrations` (`version` VARCHAR(20) NOT NULL)');
+
+ $test->schemaFingerprint()->record($this->database);
+
+ $tables = SchemaReader::tableNames($test->pdo($this->database), $this->database);
+
+ $this->assertNotContains(
+ 'mapos_schema_fingerprint',
+ $tables,
+ 'A impressão digital não pode virar uma tabela, ou o check-schema-parity.php vai acusar drift.'
+ );
+
+ $this->assertFileExists(
+ SchemaFingerprint::fingerprintPath($this->database),
+ 'A impressão digital precisa existir em algum lugar, e esse lugar é o filesystem ao lado do banco.'
+ );
+ }
+
+ /**
+ * 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
+ {
+ $file = $this->temporaryFile();
+
+ file_put_contents($file, "-- conteudo original\n");
+ $before = SchemaFingerprint::fingerprintFor(['seeds/Usuarios.php' => $file]);
+
+ file_put_contents($file, "-- conteudo editado\n");
+ $after = SchemaFingerprint::fingerprintFor(['seeds/Usuarios.php' => $file]);
+
+ $this->assertNotSame(
+ $before,
+ $after,
+ '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
+ {
+ $file = $this->temporaryFile();
+ file_put_contents($file, "-- conteudo\n");
+
+ $this->assertSame(
+ SchemaFingerprint::fingerprintFor(['seeds/Usuarios.php' => $file]),
+ SchemaFingerprint::fingerprintFor(['seeds/Usuarios.php' => $file]),
+ '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.
+ $other = $this->temporaryFile();
+ file_put_contents($other, "-- outra seed\n");
+
+ $this->assertSame(
+ SchemaFingerprint::fingerprintFor(['a.php' => $file, 'b.php' => $other]),
+ SchemaFingerprint::fingerprintFor(['b.php' => $other, 'a.php' => $file]),
+ '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
+ {
+ $file = $this->temporaryFile();
+ file_put_contents($file, "-- conteudo\n");
+
+ $this->assertNotSame(
+ SchemaFingerprint::fingerprintFor(['migrations/20260101000000_x.php' => $file]),
+ SchemaFingerprint::fingerprintFor(['migrations/20260101000001_x.php' => $file]),
+ '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 a situação que a impressão digital existe para
+ * pegar. Por isso a lista vazia também é recusada.
+ */
+ public function testTheFingerprintRefusesToRunOnAnIncompleteFileList(): void
+ {
+ $file = $this->temporaryFile();
+ file_put_contents($file, "-- conteudo\n");
+
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessageMatches('/Não consegui ler/');
+
+ SchemaFingerprint::fingerprintFor(['seeds/Usuarios.php' => $file . '.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/');
+
+ SchemaFingerprint::fingerprintFor([]);
+ }
+
+ /**
+ * 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 impressão digital é apagada junto,
+ * pelo mesmo motivo: ela é o estado que precisa não sobreviver.
+ */
+ private function disposableDatabase(): TestDatabase
+ {
+ $test = TestDatabase::fromEnvironment();
+
+ $test->recreate($this->database);
+ $test->schemaFingerprint()->forget($this->database);
+
+ return $test;
+ }
+
+ /**
+ * 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 temporaryFile(): string
+ {
+ $path = tempnam(sys_get_temp_dir(), 'mapos-fingerprint-');
+
+ $this->assertIsString($path, 'tempnam() deveria ter criado um arquivo temporário.');
+
+ $this->temporaryFiles[] = $path;
+
+ return $path;
+ }
+}
diff --git a/application/tests/Support/Database/SchemaReader.php b/application/tests/Support/Database/SchemaReader.php
new file mode 100644
index 000000000..40f5dee8c
--- /dev/null
+++ b/application/tests/Support/Database/SchemaReader.php
@@ -0,0 +1,329 @@
+
+ */
+ public static function tableNames(PDO $pdo, string $database): array
+ {
+ $statement = $pdo->prepare(
+ "SELECT table_name FROM information_schema.tables
+ WHERE table_schema = ? AND table_type = 'BASE TABLE'
+ ORDER BY table_name"
+ );
+ $statement->execute([$database]);
+
+ return array_map('strval', $statement->fetchAll(PDO::FETCH_COLUMN));
+ }
+
+ /**
+ * As colunas de cada tabela, como mapa tabela => coluna => tipo.
+ *
+ * Tabelas de controle inclusive: quem filtra é quem sabe por quê, e o clone
+ * precisa delas.
+ *
+ * @return array> tabela => coluna => tipo
+ */
+ public static function columnTypes(PDO $pdo, string $database): array
+ {
+ $statement = $pdo->prepare(
+ 'SELECT table_name, column_name, column_type FROM information_schema.columns
+ WHERE table_schema = ? ORDER BY table_name, ordinal_position'
+ );
+ $statement->execute([$database]);
+
+ $columns = [];
+
+ foreach ($statement->fetchAll() as $row) {
+ $columns[(string) $row['TABLE_NAME']][(string) $row['COLUMN_NAME']] = (string) $row['COLUMN_TYPE'];
+ }
+
+ return $columns;
+ }
+
+ /**
+ * O tipo de uma coluna, ou null se a coluna não existe.
+ *
+ * Existe para o teste que afirma um valor absoluto sobre o schema. Ele lia
+ * information_schema com a sua própria consulta, e isso é a forma de divergência
+ * que a classe se propõe a eliminar: uma segunda leitura pode perguntar a fonte
+ * errada, esquecer o filtro `table_schema`, ou responder diferente da outra
+ * numa correção futura, e o sintoma seria um teste que passa com um schema que
+ * o resto da suíte leu de outro jeito.
+ *
+ * O tipo sai cru, como em columnTypes(), e a comparação decide o que fazer com a
+ * caixa. O gate de paridade usa strcasecmp() e o SchemaTest usa strtolower(), e
+ * normalizar aqui deixaria um dos dois clientes acreditando que a normalização
+ * não existe.
+ */
+ public static function columnType(PDO $pdo, string $database, string $table, string $column): ?string
+ {
+ $statement = $pdo->prepare(
+ 'SELECT column_type FROM information_schema.columns
+ WHERE table_schema = ? AND table_name = ? AND column_name = ?'
+ );
+ $statement->execute([$database, $table, $column]);
+
+ $type = $statement->fetchColumn();
+
+ return $type === false ? null : (string) $type;
+ }
+
+ /**
+ * O schema da aplicação como mapa tabela => coluna => tipo, ordenado e sem as
+ * tabelas de controle.
+ *
+ * A forma que o gate de paridade compara, e a única que o projeto documenta
+ * como "o schema": só definição de coluna. Charset, collation e ordem de
+ * colunas mudam entre o dump e o dbforge sem significar divergência, e compará-los
+ * só produziria ruído.
+ *
+ * @return array>
+ */
+ public static function applicationSchema(PDO $pdo, string $database): array
+ {
+ $columns = self::columnTypes($pdo, $database);
+
+ $schema = [];
+
+ foreach (array_diff(self::tableNames($pdo, $database), self::CONTROL_TABLES) as $table) {
+ $schema[$table] = $columns[$table] ?? [];
+ }
+
+ return $schema;
+ }
+
+ /**
+ * O banco existe, com nome exato.
+ *
+ * Existia como um `SELECT COUNT(*)` de `SCHEMATA` escrito à mão em dois lugares:
+ * o `SchemaFingerprint::isCurrent()` e o `drop-worker-databases.php`. As duas
+ * leituras podiam divergir — uma contava, a outra filtrava por `LIKE` — e a
+ * divergência de uma delas aparece como "o banco não existe" num contexto que
+ * trata isso como "o schema precisa ser remontado", que é uma decisão tomada por
+ * um número que ninguém conferiu.
+ */
+ public static function databaseExists(PDO $pdo, string $database): bool
+ {
+ $statement = $pdo->prepare(
+ 'SELECT COUNT(*) AS total FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = ?'
+ );
+ $statement->execute([$database]);
+
+ return (int) $statement->fetch()['total'] > 0;
+ }
+
+ /**
+ * Os bancos cujo nome casa com um padrão, em ordem estável.
+ *
+ * O `LIKE` chega aqui como argumento, e o `ESCAPE` do MySQL é o que permite
+ * que a base com underscore seja comparada como wildcard. Sem o `ESCAPE`, um
+ * nome de base com `_` casaria com qualquer caractere, e a busca por
+ * `mapos_1_test` traria `maposX1_test` junto — que o chamador pode apagar.
+ *
+ * O `default` do escape é o que torna isso seguro: sem o segundo argumento de
+ * `LIKE`, o MySQL usa a barra invertida, e o padrão que o chamador escreve já
+ * precisa ter escapado os coringas. Por isso o método escapa `\`, `_` e `%` do
+ * padrão recebido, e quem chama passa a base como está.
+ *
+ * @return list
+ */
+ public static function databasesLike(PDO $pdo, string $pattern): array
+ {
+ $escaped = str_replace(['\\', '_', '%'], ['\\\\', '\\_', '\\%'], $pattern);
+
+ $statement = $pdo->prepare(
+ 'SELECT SCHEMA_NAME FROM information_schema.SCHEMATA
+ WHERE SCHEMA_NAME LIKE ? ORDER BY SCHEMA_NAME'
+ );
+ $statement->execute([$escaped]);
+
+ return array_map('strval', $statement->fetchAll(PDO::FETCH_COLUMN));
+ }
+
+ /**
+ * As chaves estrangeiras, como mapa nome => definição.
+ *
+ * Uma constraint por entrada, e não uma linha por coluna: a de duas colunas
+ * aparece duas vezes em `KEY_COLUMN_USAGE`, e as linhas contíguas do `ORDER BY`
+ * são o que permite agrupá-las sem índice intermediário. A ordem de entrada é a
+ * do `ORDER BY` (tabela, nome, posição) e ela é a que `replayForeignKeys()`
+ * consome, e por isso o `ksort` fica em `describeForeignKeys()`, que só quer
+ * comparação estável.
+ *
+ * Esta consulta já era a de `TestSchemaClone::foreignKeyConstraints()`, e é a
+ * mesma que `TestSchemaCloneWorkerParityTest` escrevia por conta própria para
+ * ler `REFERENCED_TABLE_SCHEMA`. Duas leituras da mesma pergunta podem
+ * divergir no `JOIN` ou no filtro, e a divergência aparece como um clone que
+ * escreve uma coisa e uma conferência que valida outra.
+ *
+ * @return array, referenced_table: string, referenced_columns: list, update_rule: string, delete_rule: string}>
+ */
+ public static function foreignKeys(PDO $pdo, string $database): array
+ {
+ $statement = $pdo->prepare(
+ 'SELECT kcu.CONSTRAINT_NAME, kcu.TABLE_NAME, kcu.COLUMN_NAME,
+ kcu.REFERENCED_TABLE_NAME, kcu.REFERENCED_COLUMN_NAME,
+ rc.UPDATE_RULE, rc.DELETE_RULE
+ FROM information_schema.KEY_COLUMN_USAGE kcu
+ JOIN information_schema.REFERENTIAL_CONSTRAINTS rc
+ ON rc.CONSTRAINT_SCHEMA = kcu.CONSTRAINT_SCHEMA
+ AND rc.CONSTRAINT_NAME = kcu.CONSTRAINT_NAME
+ AND rc.TABLE_NAME = kcu.TABLE_NAME
+ WHERE kcu.TABLE_SCHEMA = ? AND kcu.REFERENCED_TABLE_NAME IS NOT NULL
+ ORDER BY kcu.TABLE_NAME, kcu.CONSTRAINT_NAME, kcu.ORDINAL_POSITION'
+ );
+ $statement->execute([$database]);
+
+ $constraints = [];
+
+ foreach ($statement->fetchAll() as $row) {
+ $name = (string) $row['CONSTRAINT_NAME'];
+
+ $constraints[$name] ??= [
+ 'table' => (string) $row['TABLE_NAME'],
+ 'columns' => [],
+ 'referenced_table' => (string) $row['REFERENCED_TABLE_NAME'],
+ 'referenced_columns' => [],
+ 'update_rule' => (string) $row['UPDATE_RULE'],
+ 'delete_rule' => (string) $row['DELETE_RULE'],
+ ];
+
+ $constraints[$name]['columns'][] = (string) $row['COLUMN_NAME'];
+ $constraints[$name]['referenced_columns'][] = (string) $row['REFERENCED_COLUMN_NAME'];
+ }
+
+ return $constraints;
+ }
+
+ /**
+ * Os schemas de tabela que as chaves estrangeiras referenciam, distintos.
+ *
+ * Existe para a pergunta que `describeForeignKeys()` não responde: se as
+ * constraints de um banco apontam para DENTRO dele. A descrição compara worker
+ * contra worker e por isso não traz o schema de propósito, já que o nome do
+ * banco de cada um é justamente o que difere. Ler `REFERENCED_TABLE_SCHEMA` é o
+ * que enxerga uma constraint que aponta para o modelo: ela é bem formada,
+ * `describe()` passa, e o filho órfão também passa — que é o defeito que
+ * qualifier as duas pontas do `ALTER` previne.
+ *
+ * @return list
+ */
+ public static function foreignKeyTargetSchemas(PDO $pdo, string $database): array
+ {
+ $statement = $pdo->prepare(
+ 'SELECT DISTINCT REFERENCED_TABLE_SCHEMA
+ FROM information_schema.KEY_COLUMN_USAGE
+ WHERE CONSTRAINT_SCHEMA = ? AND REFERENCED_TABLE_NAME IS NOT NULL'
+ );
+ $statement->execute([$database]);
+
+ return array_map('strval', $statement->fetchAll(PDO::FETCH_COLUMN));
+ }
+
+ /**
+ * Quantos índices cada tabela tem, contados por par (tabela, índice).
+ *
+ * O `DISTINCT` não é estilo: o `information_schema.statistics` repete uma linha
+ * por coluna do índice, e a contagem de linhas é a de colunas, não a de índices.
+ *
+ * @return array
+ */
+ public static function indexCounts(PDO $pdo, string $database): array
+ {
+ $statement = $pdo->prepare(
+ 'SELECT table_name, COUNT(DISTINCT index_name) AS total FROM information_schema.statistics
+ WHERE table_schema = ? GROUP BY table_name'
+ );
+ $statement->execute([$database]);
+
+ $counts = [];
+
+ foreach ($statement->fetchAll() as $row) {
+ $counts[(string) $row['TABLE_NAME']] = (int) $row['total'];
+ }
+
+ return $counts;
+ }
+
+ public static function qualified(string $database, string $table): string
+ {
+ return self::identifier($database) . '.' . self::identifier($table);
+ }
+
+ /**
+ * Um identificador entre crases, recusando o que não seja um nome.
+ *
+ * Os nomes vêm do information_schema e do nome do banco, que já passou por
+ * assertDatabaseNameIsSafe(). A verificação existe porque concatenar nome de
+ * tabela em SQL é a forma de injeção que o projeto proíbe, e um
+ * information_schema comprometeria a garantia de todo: um dia uma tabela se
+ * chamar `` `x`; DROP ... `` e quem monta o SQL passa a executar o que o nome
+ * mandava. A recusa é muito mais barata que confiar.
+ *
+ * A resposta é a de `DatabaseGuard::isSafeIdentifier()`, e é a mesma por um
+ * motivo só: as duas perguntas são a mesma pergunta — "isto pode entrar num SQL
+ * como identificador?" — e eram respondidas por duas regex em dois arquivos. Um
+ * dia alguém aceita um caractere novo numa e a outra continua recusando, e o
+ * lugar onde a regra é mais frouxa é onde o buraco se abre.
+ */
+ public static function identifier(string $identifier): string
+ {
+ if (! DatabaseGuard::isSafeIdentifier($identifier)) {
+ throw new RuntimeException(
+ "Identificador fora do esperado: '{$identifier}'. "
+ . 'Esperado apenas letras, dígitos e sublinhado, porque o nome entra no SQL.'
+ );
+ }
+
+ return '`' . $identifier . '`';
+ }
+}
diff --git a/application/tests/Support/Database/SchemaReaderTest.php b/application/tests/Support/Database/SchemaReaderTest.php
new file mode 100644
index 000000000..395efd24f
--- /dev/null
+++ b/application/tests/Support/Database/SchemaReaderTest.php
@@ -0,0 +1,348 @@
+pdo();
+ }
+
+ /**
+ * O nome entre crases volta intacto quando é um nome.
+ */
+ #[Test]
+ public function testTheIdentifierAcceptsAName(): void
+ {
+ $this->assertSame('`os`', SchemaReader::identifier('os'));
+ $this->assertSame('`mapos_1_test`', SchemaReader::identifier('mapos_1_test'));
+ }
+
+ /**
+ * A recusa é o que fecha a concatenação de nome no SQL.
+ *
+ * O caso do ponto e vírgula é o que importa: `` `x`; DROP TABLE os; `` é o
+ * nome de uma tabela que escapa das crases e vira SQL. Um nome real nunca
+ * precisa de nada fora de letra, dígito e sublinhado, então a recusa não
+ * custa nenhuma tabela de verdade.
+ */
+ #[Test]
+ public function testTheIdentifierRefusesAnythingThatIsNotAName(): void
+ {
+ $this->expectException(RuntimeException::class);
+
+ SchemaReader::identifier('os`; DROP TABLE os; --');
+ }
+
+ /**
+ * Nome vazio e nome com espaço caem na mesma regra, pela mesma recusa.
+ */
+ #[Test]
+ public function testTheIdentifierRefusesAnEmptyName(): void
+ {
+ $this->expectException(RuntimeException::class);
+
+ SchemaReader::identifier('');
+ }
+
+ /**
+ * O par qualificado valida as duas metades, não só a tabela.
+ *
+ * Se validasse só a tabela, o nome do banco passaria direto para o SQL: ele é
+ * o que vem do ambiente, não do information_schema, e é a metade que nenhum
+ * dos leitores enxerga.
+ */
+ #[Test]
+ public function testTheQualifiedNameValidatesBothHalves(): void
+ {
+ $this->assertSame('`mapos_test`.`os`', SchemaReader::qualified('mapos_test', 'os'));
+
+ $this->expectException(RuntimeException::class);
+
+ SchemaReader::qualified('mapos_test`; --', 'os');
+ }
+
+ /**
+ * A lista de tabelas vem em ordem estável e não traz as de controle.
+ *
+ * A ordem não é estética: a comparação do gate de paridade afirma igualdade
+ * entre dois bancos com assertSame, e a ordem em que o information_schema
+ * devolve as linhas não é a ordem em que as tabelas foram criadas.
+ */
+ #[Test]
+ public function testTheTableListIsSortedAndSpelledOutInUppercase(): void
+ {
+ $pdo = self::database();
+ $database = TestDatabase::fromEnvironment()->database();
+
+ $names = SchemaReader::tableNames($pdo, $database);
+
+ $this->assertContains('migrations', $names, 'tableNames() é a lista crua: o clone precisa de `migrations`.');
+
+ $sorted = $names;
+ sort($sorted, SORT_STRING);
+
+ $this->assertSame($sorted, $names, 'A lista precisa vir ordenada, senão assertSame passa a comparar ordem.');
+ }
+
+ /**
+ * As chaves de tipo de coluna saem com o nome que o MySQL entrega.
+ *
+ * Escrever `SELECT column_name` e esperar a chave `column_name` produz um
+ * aviso de chave indefinida, e com `failOnWarning` o teste fica vermelho com
+ * uma mensagem que não aponta para a consulta.
+ */
+ #[Test]
+ public function testTheColumnMapIsKeyedByTheNameMysqlDelivers(): void
+ {
+ $pdo = self::database();
+ $database = TestDatabase::fromEnvironment()->database();
+
+ $columns = SchemaReader::columnTypes($pdo, $database);
+
+ $this->assertArrayHasKey('os', $columns);
+ $this->assertArrayHasKey('idOs', $columns['os']);
+ $this->assertMatchesRegularExpression('/^int/', $columns['os']['idOs']);
+ }
+
+ /**
+ * A leitura de uma coluna devolve o tipo, e null quando a coluna não existe.
+ *
+ * Os dois lados no mesmo caso porque o null é a parte que se perde: uma leitura
+ * que devolve string vazia para "não achou" e para "achou vazio" empurra quem
+ * chama a decidir o que fazer, e a decisão errada é afirmar que o tipo está
+ * errado num banco onde ele simplesmente não está.
+ *
+ * O tipo sai cru, e é essa a forma de `columnTypes()`: quem compara escolhe o
+ * que fazer com a caixa. O SchemaTest usa strtolower() e o gate de paridade usa
+ * strcasecmp(); normalizar aqui deixaria um dos dois acreditando que a
+ * normalização não existe.
+ */
+ #[Test]
+ public function testASingleColumnReadsItsTypeAndReportsAMissingOneAsNull(): void
+ {
+ $pdo = self::database();
+ $database = TestDatabase::fromEnvironment()->database();
+
+ $type = SchemaReader::columnType($pdo, $database, 'os', 'idOs');
+
+ $this->assertIsString($type, 'os.idOs existe: a suíte roda contra uma cadeia de migrations.');
+ $this->assertMatchesRegularExpression('/^int/', $type);
+
+ $this->assertNull(
+ SchemaReader::columnType($pdo, $database, 'os', 'colunaQueNaoExiste'),
+ 'Coluna ausente é null, e não string vazia: quem chama decide a partir disso.'
+ );
+
+ $this->assertNull(
+ SchemaReader::columnType($pdo, $database, 'tabelaQueNaoExiste', 'idOs'),
+ 'Tabela ausente também é null, e não uma exceção: o filtro table_schema é o mesmo dos dois lados.'
+ );
+ }
+
+ /**
+ * `migrations` é tabela de controle, e é exatamente por isso que a exclusão
+ * é explícita e não um filtro de nome.
+ *
+ * A tabela aparece depois da primeira migration. Se ela contasse como
+ * schema, um banco recém-criado pareceria divergir do outro por causa do
+ * próprio histórico de instalação.
+ */
+ #[Test]
+ public function testTheApplicationSchemaLeavesTheControlTablesOut(): void
+ {
+ $test = TestDatabase::fromEnvironment();
+ $pdo = self::database();
+
+ $schema = SchemaReader::applicationSchema($pdo, $test->database());
+
+ $this->assertArrayNotHasKey('migrations', $schema);
+ $this->assertArrayHasKey('os', $schema);
+ $this->assertSame(
+ array_values(array_diff(SchemaReader::tableNames($pdo, $test->database()), SchemaReader::CONTROL_TABLES)),
+ array_keys($schema),
+ 'A diferença entre a lista crua e a de aplicação tem que ser só as tabelas de controle.'
+ );
+
+ $sorted = array_keys($schema);
+ sort($sorted, SORT_STRING);
+
+ $this->assertSame(
+ $sorted,
+ array_keys($schema),
+ 'A ordem vem do ORDER BY de tableNames(); um ksort aqui seria redundante, e perdê-lo '
+ . 'faria o assertSame do gate de paridade passar a comparar ordem.'
+ );
+ }
+
+ /**
+ * O banco existe, e a resposta é sobre o nome exato, não sobre um prefixo.
+ *
+ * `isCurrent()` tratava o banco inexistente como "remontar", e essa decisão é
+ * tomada a partir deste número. A leitura existia escrita à mão no
+ * `SchemaFingerprint` e em mais um lugar, e as duas podiam divergir: uma
+ * contava linhas de `SCHEMATA` e a outra filtrava por `LIKE`, e a segunda trazia
+ * o modelo junto sem querer. O sintoma de uma divergência dessas é um
+ * "o banco não existe" num contexto que trata isso como "o schema precisa ser
+ * remontado", ou seja, um DROP de 10s disparado por um número que ninguém
+ * conferiu.
+ */
+ #[Test]
+ public function testTheDatabaseExistsIsAboutTheExactName(): void
+ {
+ $test = TestDatabase::fromEnvironment();
+ $pdo = self::database();
+
+ $this->assertTrue(SchemaReader::databaseExists($pdo, $test->database()));
+
+ $this->assertFalse(
+ SchemaReader::databaseExists($pdo, $test->database() . '_nao_existe'),
+ 'A leitura precisa casar o nome inteiro. Um LIKE traria o modelo como "existe" para '
+ . 'qualquer nome derivado dele, e o chamador não tem como saber que recebeu a resposta '
+ . 'da consulta errada.'
+ );
+ }
+
+ /**
+ * O `LIKE` de `databasesLike()` escapa os coringas, e é por isso que ele acha
+ * o worker e não o molde de nome dele.
+ *
+ * O nome do modelo é `mapos_test` e o de um worker é `mapos_1_test`: os dois
+ * casam com `mapos%_test`, mas só o segundo é worker, e é o script de limpeza que
+ * decide o que pode apagar. Sem o `ESCAPE`, o `_` do padrão casa com qualquer
+ * caractere e a busca por `mapos_1_test` traz `maposX1_test` junto — que o
+ * chamador pode apagar, porque o nome real passou pelo filtro.
+ */
+ #[Test]
+ public function testTheWildcardSearchTreatsTheUnderscoreAsText(): void
+ {
+ $test = TestDatabase::fromEnvironment();
+ $pdo = self::database();
+
+ $base = DatabaseGuard::modelBase($test->templateDatabase());
+ $found = SchemaReader::databasesLike($pdo, $base . '_%_test');
+
+ $this->assertNotContains(
+ $test->templateDatabase(),
+ $found,
+ 'O modelo NÃO pode aparecer no padrão do worker, e essa é a razão de o padrão exigir '
+ . 'um token no meio. `mapos_test` contra `mapos\_%\_test` é o `LIKE` recusando o '
+ . 'modelo por construção, e é o que impede o script de limpeza de derrubá-lo: '
+ . 'reconstruí-lo custa os ~9,8s da cadeia de migrations.'
+ );
+
+ foreach ($found as $name) {
+ $this->assertMatchesRegularExpression(
+ '/^' . preg_quote($base, '/') . '_[^_]+_test$/',
+ $name,
+ 'O padrão não pode trazer um nome em que o token tem mais de um caractere. '
+ . 'Um `_` do padrão que casou com `_` está funcionando como coringa, e é '
+ . 'exatamente o que faria a limpeza apagar um banco que ninguém criou.'
+ );
+ }
+ }
+
+ /**
+ * As chaves estrangeiras voltam agrupadas por constraint, e não por linha.
+ *
+ * Uma constraint de duas colunas aparece duas vezes em `KEY_COLUMN_USAGE`, e o
+ * agrupamento é o que impede o clone de criar duas constraints com o mesmo nome
+ * — o que o MySQL rejeitaria. O agrupamento por linha também faria a contagem
+ * de FKs não bater com as 26, e essa contagem é o que o AGENTS.md documenta.
+ */
+ #[Test]
+ public function testTheForeignKeysComeBackGroupedByConstraint(): void
+ {
+ $test = TestDatabase::fromEnvironment();
+ $constraints = SchemaReader::foreignKeys($test->pdo(), $test->database());
+
+ $this->assertNotEmpty($constraints, 'O modelo tem 26 chaves estrangeiras; uma lista vazia é o schema sem elas.');
+
+ foreach ($constraints as $name => $constraint) {
+ $this->assertCount(
+ count($constraint['columns']),
+ $constraint['referenced_columns'],
+ "A constraint {$name} tem uma coluna de origem por coluna referenciada, e as duas "
+ . 'listas precisam ter o mesmo tamanho. Uma delas maior significa que o '
+ . 'agrupamento pegou linhas de outra constraint, que é o defeito que a cópia '
+ . 'das tabelas-filhas não pegaria.'
+ );
+
+ $this->assertNotSame('', $constraint['table']);
+ $this->assertNotSame('', $constraint['referenced_table']);
+ }
+
+ $described = array_keys(TestSchemaClone::describeForeignKeys($test->pdo(), $test->database()));
+ $read = array_keys($constraints);
+ sort($described, SORT_STRING);
+ sort($read, SORT_STRING);
+
+ $this->assertSame(
+ $read,
+ $described,
+ 'A descrição e a lista bruta precisam vir do mesmo conjunto. É a mesma consulta '
+ . 'alimentando as duas, e por isso a impossibilidade de divergirem existe: se uma '
+ . 'delas ganhasse um filtro, o clone gravaria uma coisa e a conferência validaria outra.'
+ );
+
+ // A ordem de entrada é a do ORDER BY (tabela, nome, posição), e é ela que o
+ // replayForeignKeys() consome. O ksort fica em describeForeignKeys(), que só
+ // quer comparação estável, então a ordem aqui é a do SQL, não a alfabética.
+ $tables = array_map(
+ static fn (array $constraint): string => $constraint['table'],
+ array_values($constraints)
+ );
+ $byTable = $tables;
+ sort($byTable, SORT_STRING);
+
+ $this->assertSame(
+ $byTable,
+ $tables,
+ 'As constraints voltam agrupadas por tabela, na ordem do ORDER BY. Perder esse ORDER BY '
+ . 'faria o clone recriar os ALTER numa ordem arbitrária, e `anexos` é o caso que '
+ . 'estrava: ele referencia `os`, e a forma inline da constraint falhava nessa ordem.'
+ );
+ }
+
+ /**
+ * As constraints de um banco apontam para dentro dele, e é a única leitura que
+ * pergunta isso.
+ *
+ * `describeForeignKeys()` compara a forma e não o schema de destino, de propósito:
+ * ele compara worker contra worker, e o nome do banco de cada um é justamente o
+ * que difere. O sintoma de uma constraint que aponta para fora é um clone cuja
+ * cópia local do pai não vale para nada — a suíte grava órfão que a produção
+ * recusa — e ele passa por essa comparação.
+ */
+ #[Test]
+ public function testTheForeignKeysPointInsideTheDatabaseThatOwnsThem(): void
+ {
+ $test = TestDatabase::fromEnvironment();
+
+ $this->assertSame(
+ [$test->database()],
+ SchemaReader::foreignKeyTargetSchemas($test->pdo($test->database()), $test->database()),
+ 'Uma constraint que cruza de banco não confere a cópia local do pai e ainda segura '
+ . 'lock no modelo, que é o que o banco por worker existe para não acontecer.'
+ );
+ }
+}
diff --git a/application/tests/Support/Database/TestDatabase.php b/application/tests/Support/Database/TestDatabase.php
new file mode 100644
index 000000000..fba1f6d73
--- /dev/null
+++ b/application/tests/Support/Database/TestDatabase.php
@@ -0,0 +1,355 @@
+safeLoad();
+
+ // Depois do safeLoad() porque o fallback é lido daqui, que é quem o Dotenv
+ // populou. A chave MAPOS_TEST_DB_* manda quando existe, e é isso que
+ // permite ao CI definir root/root sem depender de um .env.
+ //
+ // O fallback passa por env() e não por um $_ENV[...] direto: o `$_ENV` é
+ // um dos lugares onde o Dotenv pode ter escrito, e não o único nem
+ // sempre um deles. Ler só um deles já fez o CI quebrar só na execução
+ // paralela, com o worker caindo no placeholder 'root' e o .env presente
+ // no disco.
+ $username = self::env('MAPOS_TEST_DB_USERNAME', self::env('DB_USERNAME', 'root'));
+ $password = self::env('MAPOS_TEST_DB_PASSWORD', self::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).
+ //
+ // DB_DATABASE recebe o nome do worker, e não o do modelo: é o banco onde
+ // este processo escreve, e é nele que a transação do TransactsDatabase
+ // roda. Um worker que publicasse o nome do modelo faria dois processos
+ // disputarem as mesmas linhas de `usuarios` — que é exatamente o que a
+ // execução paralela existe para evitar.
+ $_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.
+ $_ENV['APP_ENCRYPTION_KEY'] ??= 'mapos-testing-key';
+ $_ENV['GLOBAL_XSS_FILTERING'] ??= 'false';
+
+ return new self(
+ $hostname,
+ $port,
+ $database,
+ $username,
+ $password,
+ static::rootPath(),
+ $template
+ );
+ }
+
+ /**
+ * O banco que este processo USA.
+ *
+ * É o que já está publicado em $_ENV['DB_DATABASE'] e o que a transação do
+ * TransactsDatabase abre. Em execução de processo único ele coincide com o
+ * modelo, que é o que faz o caminho serial não mudar em nada.
+ */
+ public function database(): string
+ {
+ return $this->database;
+ }
+
+ /**
+ * O banco modelo: de onde o worker tira a cópia, e o que o setup-db.php monta.
+ *
+ * Os dois nomes são necessários e é o worker que precisa dos dois. O modelo
+ * é a origem da cópia; o efetivo é onde este processo escreve. Sob ParaTest
+ * eles são bancos diferentes, e é essa diferença que impede dois processos
+ * de disputarem as mesmas linhas.
+ */
+ public function templateDatabase(): string
+ {
+ return $this->template;
+ }
+
+ /**
+ * A impressão digital do schema, ligada a esta conexão.
+ *
+ * Existe para que quem precisa da decisão ("este banco ainda serve?") não
+ * precise conhecer a classe que a toma, e para que a dependência fique numa
+ * direção só: SchemaFingerprint abre conexão, TestDatabase não sabe que ela
+ * existe.
+ */
+ public function schemaFingerprint(): SchemaFingerprint
+ {
+ return new SchemaFingerprint($this);
+ }
+
+ /**
+ * O DSN do servidor, com o banco opcional.
+ *
+ * Sem o nome do banco o PDO conecta sem selecionar um schema, e é assim que
+ * o CREATE DATABASE do worker funciona: o banco ainda não existe, e um
+ * `dbname=` no DSN o faria o driver recusar a conexão.
+ */
+ private function dsn(?string $database = null): string
+ {
+ $dsn = "mysql:host={$this->hostname};port={$this->port};charset=utf8mb4";
+
+ return $database === null ? $dsn : $dsn . ";dbname={$database}";
+ }
+
+ /**
+ * Uma conexão nova, para o banco dado ou para o banco do processo.
+ *
+ * Nova a cada chamada, de propósito: a suíte compartilha a conexão do CI3
+ * através de get_instance()->db, e um PDO separado é o que permite ao
+ * setup-db.php recriar o schema sem derrubar a transação que o caso abriu.
+ * Passar null é o mesmo que passar $this->database(), e é o que os chamadores
+ * que não pensam em banco fazem.
+ *
+ * @throws RuntimeException se a conexão recusar, com o host:port na mensagem
+ */
+ 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 $exception) {
+ throw new RuntimeException(
+ "Falha ao conectar em {$this->hostname}:{$this->port}: {$exception->getMessage()}",
+ 0,
+ $exception
+ );
+ }
+ }
+
+ /**
+ * 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 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 SchemaFingerprint::isCurrent(). 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
+ {
+ DatabaseGuard::assertDatabaseNameIsSafe($database);
+
+ $admin = $this->pdo();
+
+ $this->withoutForeignKeys($admin, static function () use ($admin, $database): void {
+ $admin->exec('DROP DATABASE IF EXISTS ' . SchemaReader::identifier($database));
+ });
+
+ $admin->exec('CREATE DATABASE ' . SchemaReader::identifier($database) . ' CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci');
+
+ return $this->pdo($database);
+ }
+
+ public function drop(string $database): void
+ {
+ DatabaseGuard::assertDatabaseNameIsSafe($database);
+
+ $admin = $this->pdo();
+
+ $this->withoutForeignKeys($admin, function () use ($admin, $database): void {
+ $admin->exec('DROP DATABASE IF EXISTS ' . SchemaReader::identifier($database));
+ });
+ }
+
+ /**
+ * Roda um DROP de schema com a verificação de chaves desligada.
+ *
+ * `DROP DATABASE` escolhe a ordem em que derruba as tabelas, e em um schema
+ * com chave estrangeira a escolha nem sempre é uma ordem válida: o MySQL
+ * aborta com "Cannot drop table 'pai' referenced by a foreign key constraint
+ * 'fk_composta' on table 'filho'" (erro 3730) quando a tabela pai entra
+ * antes da filha. Não é uma hipótese — é o que o schema de duas tabelas do
+ * TestSchemaCloneSyntheticOrigin provoca, e a mesma falha alcança qualquer um
+ * dos bancos de teste que tenha ganhado uma constraint.
+ *
+ * Desligar a verificação é a única forma de o DROP acontecer, e ela vale para
+ * todo o schema de uma vez. O risco de desligar isso seria um DELETE que
+ * violasse integridade referencial, e aqui o que roda dentro do bloco é um
+ * `DROP DATABASE` de um banco descartável — não há dado que possa sobrar
+ * órfão, porque não sobra nada.
+ *
+ * A volta a ligar é no `finally` e na MESMA conexão: `FOREIGN_KEY_CHECKS` é
+ * de sessão, e o `pdo()` daqui abre uma conexão nova a cada chamada, então
+ * religar numa outra não religaria esta.
+ */
+ private function withoutForeignKeys(PDO $admin, callable $operation): void
+ {
+ $admin->exec('SET FOREIGN_KEY_CHECKS = 0');
+
+ try {
+ $operation();
+ } finally {
+ $admin->exec('SET FOREIGN_KEY_CHECKS = 1');
+ }
+ }
+
+ /**
+ * Uma variável de ambiente, de onde ela estiver.
+ *
+ * As três fontes são consultadas porque o Dotenv não escreve nas três, e as
+ * duas consequências disso já custaram uma execução: `getenv()` devolvia falso
+ * mesmo com a variável no `.env`, e `$_ENV` chega vazia num worker do ParaTest
+ * enquanto `$_SERVER` chega cheia — o worker abria o banco como `root` sem
+ * senha. A ordem não é arbitrária: quando a variável é REAL o PHP a coloca em
+ * todas as fontes com o mesmo valor, e o Dotenv é imutável e não a sobrescreve.
+ * O `.env` é o único caso em que as fontes divergem — ele só escreve onde a
+ * chave ainda não existe — e aí ela está em `$_ENV`/`$_SERVER` e não em
+ * getenv(), que é exatamente a ordem lida aqui. AGENTS.md traz o resumo.
+ */
+ private static function env(string $name, string $default): string
+ {
+ foreach ([$_ENV[$name] ?? null, $_SERVER[$name] ?? null, getenv($name)] as $value) {
+ // getenv() devolvendo "0" é um valor legítimo, e só o false de "não
+ // existe" — e o "" de "setado para vazio" — cai no padrão.
+ if ($value === false || $value === null || $value === '') {
+ continue;
+ }
+
+ return (string) $value;
+ }
+
+ return $default;
+ }
+}
diff --git a/application/tests/Support/Database/TestDatabaseTest.php b/application/tests/Support/Database/TestDatabaseTest.php
new file mode 100644
index 000000000..0f39470a1
--- /dev/null
+++ b/application/tests/Support/Database/TestDatabaseTest.php
@@ -0,0 +1,107 @@
+
+ */
+ private const PUBLISHED_KEYS = [
+ 'DB_HOSTNAME' => 'hostname',
+ 'DB_PORT' => 'port',
+ 'DB_DATABASE' => 'database',
+ 'DB_USERNAME' => 'username',
+ 'DB_PASSWORD' => 'password',
+ ];
+
+ 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"
+ );
+ }
+ }
+
+ /**
+ * 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;
+ }
+}
diff --git a/application/tests/Support/Database/TestFixtures.php b/application/tests/Support/Database/TestFixtures.php
new file mode 100644
index 000000000..21fb197ac
--- /dev/null
+++ b/application/tests/Support/Database/TestFixtures.php
@@ -0,0 +1,205 @@
+>
+ */
+ private const EXTRA_USERS = [
+ [
+ 'nome' => 'Inativo',
+ 'cpf' => '517.565.356-38',
+ 'email' => 'inativo@admin.com',
+ 'situacao' => 0,
+ 'dataExpiracao' => '2030-01-01',
+ ],
+ [
+ 'nome' => 'Expirado',
+ 'cpf' => '517.565.356-37',
+ 'email' => 'expirado@admin.com',
+ 'situacao' => 1,
+ 'dataExpiracao' => '2020-01-01',
+ ],
+ ];
+
+ /**
+ * Os e-mails de todas as contas, na ordem em que aparecem na saída da montagem.
+ *
+ * Derivado, e não escrito: um quarto e-mail aqui sem um quarto INSERT é o tipo
+ * de erro que a montagem do banco só denuncia quando alguma coisa já a leu.
+ *
+ * @return string[]
+ */
+ public static function userEmails(): array
+ {
+ return array_merge(
+ ['admin@admin.com'],
+ array_column(self::EXTRA_USERS, 'email')
+ );
+ }
+
+ /**
+ * @return string[] uma entrada por grupo de fixture instalado
+ */
+ public static function install(): array
+ {
+ return array_merge(
+ self::runSeeds(['Permissoes', 'Usuarios', 'Configuracoes']),
+ self::addExtraUsers()
+ );
+ }
+
+ /**
+ * Só os usuários, para a reinstalaçã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 reinstalação por teste quer o
+ * contrário — apaga e regrava os usuários a cada caso.
+ *
+ * `configuracoes` é justamente o que a reinstalaçã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();
+ }
+
+ /**
+ * 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 reinstalaçã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);
+ }
+
+ ob_start();
+
+ try {
+ foreach ($seeds as $seed) {
+ (new $seed())->run();
+ }
+ } finally {
+ ob_end_clean();
+ }
+
+ return $seeds;
+ }
+
+ /**
+ * Os dois caminhos de recusa que o Login precisa cobrir.
+ *
+ * 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
+ * paralelo: 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.
+ *
+ * Os campos que diferenciam um usuário do outro estão em `EXTRA_USERS`, e o que
+ * é igual aos dois — endereço, contato, permissão, a senha copiada — fica aqui.
+ * A linha inteira é montada com o `+`, que dá preferência à chave da esquerda,
+ * e por isso a lista fica à esquerda: os campos de `EXTRA_USERS` vencem.
+ *
+ * @return string[] os e-mails gravados
+ */
+ private static function addExtraUsers(): array
+ {
+ $db = get_instance()->db;
+ $password = $db->select('senha')
+ ->from('usuarios')
+ ->where('idUsuarios', 1)
+ ->limit(1)
+ ->get()
+ ->row('senha');
+
+ if ($password === 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.'
+ );
+ }
+
+ $shared = [
+ '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' => $password,
+ 'telefone' => '0000-0000',
+ 'celular' => '',
+ 'dataCadastro' => '2018-09-09',
+ 'permissoes_id' => 1,
+ ];
+
+ $emails = [];
+
+ foreach (self::EXTRA_USERS as $user) {
+ $db->insert('usuarios', $user + $shared);
+ $emails[] = $user['email'];
+ }
+
+ return $emails;
+ }
+
+ 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/Support/Database/TestFixturesTest.php b/application/tests/Support/Database/TestFixturesTest.php
new file mode 100644
index 000000000..26a000b30
--- /dev/null
+++ b/application/tests/Support/Database/TestFixturesTest.php
@@ -0,0 +1,179 @@
+assertCount(
+ TestFixtures::USER_COUNT,
+ $emails,
+ 'USER_COUNT não bate com a lista de e-mails. Um dos dois foi editado sem o outro, e o '
+ . 'erro só apareceria como uma montagem que se diz incompleta.'
+ );
+ }
+
+ /**
+ * A conta da seed é uma, e as de recusa são as que a suíte precisa.
+ *
+ * A lista é o contrato com quem consome: `setup-db.php` a imprime, e
+ * LoginControllerTest usa `inativo@` e `expirado@` para cobrir os dois caminhos
+ * de recusa. A ordem também é contrato, porque a montagem mostra os e-mails
+ * nessa ordem e quem lê a saída compara com a saída de ontem.
+ */
+ #[Test]
+ public function testTheEmailListIsTheSeedUserFollowedByTheRefusalAccounts(): void
+ {
+ $this->assertSame(
+ ['admin@admin.com', 'inativo@admin.com', 'expirado@admin.com'],
+ TestFixtures::userEmails()
+ );
+ }
+
+ /**
+ * A conta da seed vem de `Usuarios`, e a lista não a inventa.
+ *
+ * `admin@admin.com` não está em `EXTRA_USERS` e não pode estar: a senha dela
+ * é escrita pela seed, e a suíte não tem como reescrever o hash sem perder a
+ * correspondência que o Login testa. Se um dia alguém colocar o admin em
+ * `EXTRA_USERS`, o INSERT passaria por cima da seed e o hash ficaria o que o
+ * `addExtraUsers()` copiasse — que é o hash de quem ele acabou de ler.
+ */
+ #[Test]
+ public function testTheSeedAccountIsNotOneOfTheExtraAccounts(): void
+ {
+ $this->assertNotContains(
+ 'admin@admin.com',
+ array_column($this->extraUsers(), 'email'),
+ 'A conta da seed não pode estar entre as extras: a senha dela é da seed, e seria '
+ . 'sobrescrita por uma cópia de si mesma.'
+ );
+ }
+
+ /**
+ * As duas contas de recusa recusam por motivos diferentes, e nenhum dos dois
+ * é o mesmo motivo.
+ *
+ * `situacao` 0 morre antes da autenticação, e `dataExpiracao` no passado morre
+ * depois dela, no `chk_date()`. Se as duas tivessem o mesmo valor, um dos dois
+ * caminhos de recusa do Login deixaria de ser coberto sem nada reclamar: a
+ * conta existiria, o teste passaria, e a linha nova do controller não teria
+ * quem a exercitasse.
+ */
+ #[Test]
+ public function testTheRefusalAccountsFailForTwoDifferentReasons(): void
+ {
+ $extra = $this->extraUsers();
+
+ $this->assertCount(2, $extra, 'São dois caminhos de recusa, e cada um com a sua conta.');
+
+ $inactive = $this->accountNamed($extra, 'Inativo');
+ $expired = $this->accountNamed($extra, 'Expirado');
+
+ $this->assertSame(0, $inactive['situacao'], 'A conta inativa tem de ser recusada por situacao.');
+ $this->assertSame(1, $expired['situacao'], 'A conta expirada tem de passar a autenticação para morrer depois.');
+
+ $this->assertGreaterThan(
+ time(),
+ strtotime((string) $inactive['dataExpiracao']),
+ 'A conta dita inativa não pode estar expirada, senão os dois caminhos de recusa são o mesmo.'
+ );
+ $this->assertLessThan(
+ time(),
+ strtotime((string) $expired['dataExpiracao']),
+ 'A conta dita expirada precisa de uma data no passado, que é o que o chk_date() olha.'
+ );
+ }
+
+ /**
+ * Os CPFs das contas são distintos entre si e não são o da seed.
+ *
+ * Não é uma regra do Login: é uma regra de fixture. Dois usuários com o mesmo
+ * CPF passam a ser o mesmo usuário para qualquer consulta que filtre por ele, e
+ * um teste que conte por CPF passa a contar um usuário onde há dois — sem erro,
+ * porque a consulta concorda com o que a tabela tem.
+ */
+ #[Test]
+ public function testTheRefusalAccountsHaveDistinctCpfs(): void
+ {
+ $cpfs = array_column($this->extraUsers(), 'cpf');
+
+ $this->assertSame(
+ $cpfs,
+ array_unique($cpfs),
+ 'Duas contas de fixture com o mesmo CPF viram um usuário só para qualquer consulta por CPF.'
+ );
+ }
+
+ /**
+ * A conta pedida por nome, ou o caso falha dizendo qual sumiu.
+ *
+ * @param list> $extra
+ * @return array
+ */
+ private function accountNamed(array $extra, string $name): array
+ {
+ foreach ($extra as $user) {
+ if ($user['nome'] === $name) {
+ return $user;
+ }
+ }
+
+ $this->fail("A conta '{$name}' sumiu de EXTRA_USERS, e o Login precisa dela para cobrir um dos dois caminhos de recusa.");
+ }
+
+ /**
+ * As contas extras, pela reflexão que a constante privada não expõe.
+ *
+ * Ler a constante por reflexão é feio, e a alternativa — torná-la pública e
+ * duplicar a lista de e-mails — seria pior: um segundo lugar para os mesmos
+ * dois usuários divergirem. O teste é o único consumidor que precisa do
+ * detalhe, e o detalhe é privado justamente para o resto da suíte usar
+ * `userEmails()`.
+ *
+ * @return list>
+ */
+ private function extraUsers(): array
+ {
+ $constant = new \ReflectionClass(TestFixtures::class);
+
+ /** @var list> $users */
+ $users = $constant->getConstant('EXTRA_USERS');
+
+ return $users;
+ }
+}
diff --git a/application/tests/Support/Infra/ServedPathsTest.php b/application/tests/Support/Infra/ServedPathsTest.php
new file mode 100644
index 000000000..7e4345aba
--- /dev/null
+++ b/application/tests/Support/Infra/ServedPathsTest.php
@@ -0,0 +1,179 @@
+
+ */
+ public static function provideNginxBlockedDirectories(): iterable
+ {
+ yield 'application/ tem php executavel e credenciais de fabrica' => ['application'];
+ yield 'tools/ tem o inventario do que nao esta escapado' => ['tools'];
+ }
+
+ /**
+ * As pastas que precisam de `.htaccess` que negue, para o Apache.
+ *
+ * O caminho do nginx e o do Apache são defesas de servidores diferentes, e quem
+ * instala o Map-OS pode estar em qualquer um dos dois. A regra do nginx sozinha
+ * deixa o Apache servindo, e vice-versa. `application/tests/` e `tools/` são as
+ * duas com o par completo.
+ *
+ * @return iterable
+ */
+ public static function provideDenyHtaccessDirectories(): iterable
+ {
+ yield 'tests/ recria banco e roda setup-db.php' => ['application/tests'];
+ yield 'tools/ tem o inventario do que nao esta escapado' => ['tools'];
+ }
+
+ /**
+ * As duas configs do nginx bloqueiam a mesma pasta, com o `^~` de prefixo.
+ *
+ * O `^~` não é estilo. As regex têm prioridade sobre prefixo comum no nginx, e
+ * `location ~* \.php$` existe mais abaixo nos dois arquivos: sem o `^~`, um
+ * `.php` dentro de uma pasta "protegida" seria avaliado pela regex antes de a
+ * pasta ser considerada, e o acesso passaria. A regra parece redundante e não é.
+ *
+ * @param string $directory
+ */
+ #[DataProvider('provideNginxBlockedDirectories')]
+ #[Test]
+ public function testBothNginxConfigsBlockTheDirectoryWithAPrefixMatch(string $directory): void
+ {
+ foreach ($this->nginxConfigs() as $file) {
+ $this->assertStringContainsString(
+ "location ^~ /{$directory}/ {",
+ $this->contents($file),
+ "{$file} não bloqueia /{$directory}/ com location ^~, e sem o ^~ a "
+ . "location ~* \.php$ é avaliada antes e o acesso passa"
+ );
+ }
+ }
+
+ /**
+ * Os dois arquivos do nginx bloqueiam o mesmo conjunto de pastas.
+ *
+ * Este é o caso que pega a edição pela metade, e o que o AGENTS.md descreve sem
+ * conseguir impor: os dois arquivos são cópias com duas linhas trocadas, não
+ * existem juntos em lugar nenhum, e uma regra nova em um deles é uma regra que
+ * não vale no container que o `docker-compose.yml` sobe — porque ele monta
+ * `default.template.conf` por cima de `default.conf` na linha de comando do
+ * serviço, e é o template que está valendo.
+ */
+ #[Test]
+ public function testBothNginxConfigsBlockTheSameDirectories(): void
+ {
+ $blocked = [];
+
+ foreach ($this->nginxConfigs() as $file) {
+ preg_match_all('/location \^~ (\/[a-z]+\/)/', $this->contents($file), $matches);
+ sort($matches[1]);
+ $blocked[$file] = $matches[1];
+ }
+
+ $files = array_keys($blocked);
+
+ $this->assertSame(
+ $blocked[$files[0]],
+ $blocked[$files[1]],
+ 'os dois arquivos do nginx bloqueiam pastas diferentes: um deles está servindo '
+ . 'o que o outro bloqueia, e o compose sobe o template por cima do conf'
+ );
+
+ $this->assertNotSame(
+ [],
+ $blocked[$files[0]],
+ 'nenhuma pasta bloqueada: a regex não casou com nada, e um teste que passa '
+ . 'por não ter encontrado o padrão não prova que a regra existe'
+ );
+ }
+
+ /**
+ * Toda pasta com par completo tem um `.htaccess` que nega, para o Apache.
+ *
+ * @param string $directory
+ */
+ #[DataProvider('provideDenyHtaccessDirectories')]
+ #[Test]
+ public function testEachProtectedDirectoryAlsoDeniesApache(string $directory): void
+ {
+ $file = $this->root() . "/{$directory}/.htaccess";
+
+ $this->assertFileExists($file, "{$file} não existe, e o nginx não lê .htaccess: sem ele o Apache serve a pasta");
+
+ $this->assertMatchesRegularExpression(
+ '/(Require all denied|Deny from all)/',
+ $this->contents($file),
+ "{$file} existe mas não nega"
+ );
+ }
+
+ /**
+ * @return list caminhos absolutos
+ */
+ private function nginxConfigs(): array
+ {
+ return [
+ $this->root() . '/docker/etc/nginx/default.conf',
+ $this->root() . '/docker/etc/nginx/default.template.conf',
+ ];
+ }
+
+ private function root(): string
+ {
+ return dirname(__DIR__, 4);
+ }
+
+ private function contents(string $file): string
+ {
+ $contents = is_file($file) ? file_get_contents($file) : false;
+
+ if ($contents === false) {
+ self::fail("Não consegui ler {$file}.");
+ }
+
+ return $contents;
+ }
+}
diff --git a/application/tests/Support/Transaction/BaselineDataResetTest.php b/application/tests/Support/Transaction/BaselineDataResetTest.php
new file mode 100644
index 000000000..5f6b34bea
--- /dev/null
+++ b/application/tests/Support/Transaction/BaselineDataResetTest.php
@@ -0,0 +1,258 @@
+db;
+
+ $this->assertSame(
+ 1,
+ Ci3Introspection::transactionDepth($db),
+ 'O gancho deveria ter aberto a transação do caso, e a reinstalação deveria ter rodado dentro dela.'
+ );
+
+ $this->assertSame(
+ 0,
+ $this->autocommit(),
+ 'A reinstalaçã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 reinstalaçã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 reinstalaçã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.'
+ );
+ }
+
+ /**
+ * O gancho do setUp realmente chamou a reinstalação, e não só o método dela.
+ *
+ * Esta classe é a única que pede a reinstalação, e é por isso que o contador
+ * da trait é conferido aqui. Sem esta afirmação, apagar a chamada em
+ * `setUpDatabaseTransaction()` deixava a suíte inteira verde: como tudo roda em
+ * transação, o estado de `usuarios` dentro do caso é o das fixtures com e sem a
+ * reinstalação, e nenhum dos casos acima notaria a falta.
+ */
+ public function testTheSetUpHookActuallyCalledTheReinstall(): void
+ {
+ $this->assertGreaterThan(
+ 0,
+ $this->baselineResetCount(),
+ 'O gancho de setUp não chamou a reinstalação. Se esta linha quebrar, a chamada em '
+ . 'setUpDatabaseTransaction() foi removida — e nada mais na suíte vai perceber, porque o '
+ . 'estado dentro de um caso é o mesmo com e sem ela.'
+ );
+ }
+
+ /**
+ * A reinstalação apaga o que sobrou e devolve as três contas das fixtures.
+ *
+ * Este é o caso que pega a reinstalaçã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 reinstalaçã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 reinstalaçã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 reinstalação.');
+
+ $this->invokeReinstall();
+
+ $this->assertSame(
+ 3,
+ (int) $db->count_all_results('usuarios'),
+ 'A reinstalaçã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 reinstalaçã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 reinstalação devolveu um conjunto de contas diferente do das fixtures.'
+ );
+ }
+
+ /**
+ * A reinstalaçã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',
+ ]);
+
+ $failure = $this->invokeReinstall();
+
+ $this->assertNotNull(
+ $failure,
+ 'A reinstalação rodou com uma linha em `logs`, que é a assinatura de uma transação commitada em vez de descartada.'
+ );
+
+ $this->assertStringContainsString('logs', $failure->getMessage(), 'A falha deveria apontar a tabela.');
+ $this->assertStringContainsString(
+ 'test:fresh',
+ $failure->getMessage(),
+ 'A falha deveria dizer como recuperar, senão quem a encontra não sabe para onde ir.'
+ );
+ }
+
+ /**
+ * Chama a reinstalação e devolve a falha, ou null se ela passou.
+ *
+ * Chamar direto, e não por closure amarrada ao escopo do caso: resetBaselineData
+ * é private e a trait vem para a classe que a usa, então o chamador está no
+ * mesmo arquivo e a closure só acrescentaria uma camada sem mudar o que é
+ * visível. O que este método existe para é capturar a AssertionFailedError,
+ * porque a falha de um setUp do PHPUnit não é capturável do corpo de um caso.
+ */
+ private function invokeReinstall(): ?AssertionFailedError
+ {
+ try {
+ $this->resetBaselineData(get_instance()->db);
+ } catch (AssertionFailedError $exception) {
+ return $exception;
+ }
+
+ return null;
+ }
+
+ /**
+ * O autocommit da conexão, que é como o CI3 marca a transação aberta.
+ *
+ * A leitura e o porquê de ela ser uma só estão em
+ * `Ci3Introspection::autocommit()`.
+ */
+ private function autocommit(): int
+ {
+ return Ci3Introspection::autocommit(get_instance()->db);
+ }
+}
diff --git a/application/tests/Support/Transaction/TransactsDatabase.php b/application/tests/Support/Transaction/TransactsDatabase.php
new file mode 100644
index 000000000..7545219d6
--- /dev/null
+++ b/application/tests/Support/Transaction/TransactsDatabase.php
@@ -0,0 +1,314 @@
+db`), que a suíte
+ * in-process compartilha entre todos os testes do processo, e por isso o
+ * fechamento é defensivo: uma transação que vaza de um caso para o outro não dá
+ * mensagem nenhuma e só aparece como um teste que passa sozinho.
+ *
+ * ## As três armadilhas do CI3 que o fechamento trata
+ *
+ * Não têm equivalente no Laravel porque lá existe SAVEPOINT.
+ *
+ * 1. `trans_begin()` com depth acima de zero só incrementa o contador. 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 — nem reverter só o que é
+ * dele, porque um rollback interno derruba a transação inteira.
+ * 2. `trans_rollback()` só fala com o banco no depth exatamente 1
+ * (DB_driver.php:380); nos acima, só decrementa. Uma pilha deixada em 3 exige
+ * três chamadas até chegar no ROLLBACK de verdade.
+ * 3. `_trans_rollback()` e `_trans_commit()` religam o autocommit
+ * (mysqli_driver.php:362). Sem o fechamento, todo caso seguinte escreve numa
+ * transação que ninguém fecha — por isso o autocommit é religado mesmo quando
+ * a transação já sumiu.
+ *
+ * ## O que esta trait NÃO cobre
+ *
+ * - DDL. `CREATE`, `ALTER`, `DROP` e `TRUNCATE` fazem commit implícito e
+ * derrubam a transação sem aviso. É por isso que
+ * TestApplicationTest::testMigrateLeavesTheOutputBufferLevelUntouched() não
+ * usa isto: ele roda Tools::migrate(), que hoje não executa nada porque as
+ * migrations já estão aplicadas. Isso é uma leitura do estado atual, não uma
+ * garantia — a primeira migration nova quebraria o isolamento do próprio teste.
+ * - MyISAM, que não tem transação. As 28 tabelas do banco de teste são
+ * InnoDB e o create_base força o motor com ALTER TABLE depois de cada
+ * CREATE. Vale reconferir se alguma vez 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 do CI3 tem um grupo só;
+ * um `$this->load->database('outro')` abriria uma segunda conexão que a
+ * transação não alcança.
+ *
+ * ## A reinstalação da linha de base
+ *
+ * `resetsBaselineData(): true` reinstala os usuários antes de cada caso, dentro da
+ * transação que a trait acabou de abrir; AGENTS.md traz o opt-in, o `DELETE` em
+ * vez de `TRUNCATE` e o motivo de TransactsDatabaseTest não optar.
+ *
+ * Um limite que AGENTS.md não registra, e que o contador `baselineResets()`
+ * resolve: como tudo roda em transação, o estado de `usuarios` no corpo de um caso
+ * é o das fixtures com ou sem a reinstalação. Apagar a chamada no
+ * `setUpDatabaseTransaction()` deixava a suíte inteira verde, e ninguém acharia.
+ * BaselineDataResetTest afirma que o gancho rodou, então a direção perigosa — a
+ * reinstalação existir e não ser chamada, que é a que importa — vira um teste
+ * vermelho. O que pega a outra direção, commit em vez de rollback, é o guard de
+ * `logs`.
+ */
+trait TransactsDatabase
+{
+ /**
+ * A classe pede a reinstalaçã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.
+ *
+ * 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;
+
+ $inherited = Ci3Introspection::transactionDepth($db);
+
+ if ($inherited !== 0) {
+ $this->fail(
+ "A transação do teste anterior não foi fechada (depth {$inherited}). "
+ . '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.');
+ }
+
+ $openedDepth = Ci3Introspection::transactionDepth($db);
+
+ if ($openedDepth !== 1) {
+ $this->fail("trans_begin() retornou verdadeiro, mas o depth ficou em {$openedDepth} em vez de 1.");
+ }
+
+ // Depois do BEGIN, nunca antes: a reinstalaçã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 contas 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 o guard de `logs` não serve de aviso para isso: ele só
+ * enxerga o que sobreviveu a um commit.
+ *
+ * A comparação vai na chave primária, 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. Por isso o operador está dentro da string da chave.
+ *
+ * @see \Tests\Support\Database\TestFixtures::installUsers()
+ */
+ private function resetBaselineData(object $db): void
+ {
+ // `logs` crescendo é a assinatura de uma transação commitada em vez de
+ // descartada. A conferência fica 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.
+ $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();
+
+ self::$baselineResets++;
+
+ $restored = $db->count_all_results('usuarios');
+
+ if ($restored !== TestFixtures::USER_COUNT) {
+ $this->fail(
+ sprintf(
+ 'A reinstalação da linha de base deixou %d usuário(s) em vez de %d. ',
+ $restored,
+ TestFixtures::USER_COUNT
+ )
+ . 'O TestFixtures::installUsers() parou no meio, ou outro teste mexeu na tabela '
+ . "sem transação. Rode 'composer test:fresh' para remontar o banco."
+ );
+ }
+ }
+
+ /**
+ * Descreve a transação perdida, ou devolve null se ela ainda é nossa.
+ *
+ * A pergunta é "a nossa transação ainda existe?", e a resposta vem de
+ * `Ci3Introspection::autocommit()`, que é a informação que o driver usa para
+ * decidir isso e que o `_trans_depth` em 1 não é. O porquê inteiro, inclusive
+ * por que INNODB_TRX e `@@in_transaction` não servem, está no método.
+ *
+ * O que este detector não pega: commit implícito por DDL. O MySQL encerra a
+ * transação, mas o autocommit continua desligado e a leitura mente. É a mesma
+ * razão pela qual teste com migration fica fora da trait, e a mensagem abaixo
+ * diz isso para quem bater nela.
+ */
+ private function describeLostTransaction(object $db): ?string
+ {
+ if (Ci3Introspection::autocommit($db) === 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.';
+ }
+
+ /**
+ * Quantas vezes a reinstalação da linha de base rodou nesta classe.
+ *
+ * Um contador, e não um log do que foi reinstalado, porque a pergunta que a
+ * suíte não conseguia responder é "o gancho chegou a ser chamado?", e a
+ * resposta é um número. Ele fecha o buraco que este arquivo registra: sem isto,
+ * apagar a chamada em `setUpDatabaseTransaction()` deixa a suíte inteira verde,
+ * porque o estado de `usuarios` dentro de um caso é o mesmo com e sem a
+ * reinstalação — tudo roda em transação.
+ *
+ * Estático, e por necessidade: o gancho roda em `$this` e o teste que quer
+ * conferir isso é um caso a parte, que não é a mesma instância. A leitura é por
+ * método de instância, e não `TransactsDatabase::baselineResets()`: uma chamada
+ * estática pelo nome do trait resolve para outra propriedade que a incremento
+ * não toca, e devolve sempre zero. Cada classe que usa a trait tem a sua
+ * própria, o que é o que interessa — a pergunta é sobre esta classe.
+ */
+ private static int $baselineResets = 0;
+
+ protected function baselineResetCount(): int
+ {
+ return self::$baselineResets;
+ }
+
+ /**
+ * Quantas linhas o último teste deixou em `logs`.
+ *
+ * Só o guard de resetBaselineData() usa, e o valor não é interessante por si:
+ * interessa que ele seja maior que zero.
+ */
+ private function countLogs(object $db): int
+ {
+ return (int) $db->count_all_results('logs');
+ }
+
+ /**
+ * 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;
+
+ $diagnosis = $this->describeLostTransaction($db);
+
+ try {
+ $this->rollbackToDepthZero($db);
+ } finally {
+ $this->resetDriverAfterFailedClose($db);
+ }
+
+ if ($diagnosis !== null) {
+ $this->fail($diagnosis);
+ }
+ }
+
+ /**
+ * 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 = Ci3Introspection::transactionDepth($db)) > 0) {
+ if ($db->trans_rollback() === false) {
+ $this->fail("trans_rollback() falhou com o depth em {$depth}; o fechamento parou antes de zerar.");
+ }
+
+ if (Ci3Introspection::transactionDepth($db) >= $depth) {
+ $this->fail('trans_rollback() não decrementou o depth; o laço não progrediria.');
+ }
+ }
+ }
+
+ /**
+ * 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
+ {
+ Ci3Introspection::closeDriverTransaction($db);
+ }
+}
diff --git a/application/tests/Support/Transaction/TransactsDatabaseTest.php b/application/tests/Support/Transaction/TransactsDatabaseTest.php
new file mode 100644
index 000000000..3f29a822d
--- /dev/null
+++ b/application/tests/Support/Transaction/TransactsDatabaseTest.php
@@ -0,0 +1,208 @@
+assertSame(
+ 0,
+ $this->autocommit(),
+ 'A trait deveria ter deixado o autocommit desligado, que é como o CI3 marca a transação aberta.'
+ );
+
+ $this->assertSame(
+ 1,
+ Ci3Introspection::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, Ci3Introspection::transactionDepth($db), 'trans_start() aninhado deveria subir o depth.');
+
+ $db->trans_complete();
+
+ $this->assertSame(1, Ci3Introspection::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, Ci3Introspection::transactionDepth($db));
+
+ $db->trans_rollback();
+ $this->assertSame(1, Ci3Introspection::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, Ci3Introspection::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, Ci3Introspection::transactionDepth($db));
+
+ $db->trans_complete();
+ $db->trans_rollback();
+
+ $this->assertSame(0, Ci3Introspection::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->writeLogRow();
+ $this->assertSame(
+ 1,
+ $this->countLogRows(),
+ '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->countLogRows(),
+ '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 writeLogRow(): 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 countLogRows(): 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.
+ *
+ * A leitura e o porquê de ela ser uma só estão em
+ * `Ci3Introspection::autocommit()`.
+ */
+ private function autocommit(): int
+ {
+ return Ci3Introspection::autocommit(get_instance()->db);
+ }
+}
diff --git a/application/tests/Support/ViewEscaping/EscapingChecksTest.php b/application/tests/Support/ViewEscaping/EscapingChecksTest.php
new file mode 100644
index 000000000..8b36671b3
--- /dev/null
+++ b/application/tests/Support/ViewEscaping/EscapingChecksTest.php
@@ -0,0 +1,727 @@
+load->view()` case com o helper `view`. Com a lista única, `$row->esc($x)`
+ * passava pelo gate sendo um método qualquer com o nome do escaper, e o gate é a
+ * única barreira entre uma view e um XSS.
+ *
+ * Os casos do resto são fail-closed: a propriedade que importa é que nenhuma saída
+ * alternativa exista. Um gate que grita lobo é ignorado, e um gate que fica calado
+ * é o pior dos dois.
+ */
+final class EscapingChecksTest extends TestCase
+{
+ #[Test]
+ public function testAnEscaperCalledAsAMethodIsNotAnEscaper(): void
+ {
+ $policy = EscapingPolicy::default();
+
+ // A falha real: um método qualquer cujo nome por acaso colide com o de um
+ // escaper era aprovado. `$row->esc()` não escapa nada — é um método do
+ // objeto que a view tem em mãos.
+ $this->assertSame('$x', EscapingChecks::unescaped('$row->esc($x)', $policy));
+ $this->assertSame('$x', EscapingChecks::unescaped('$this->esc($x)', $policy));
+ $this->assertSame('$x', EscapingChecks::unescaped('$obj->htmlspecialchars($x)', $policy));
+ $this->assertSame('$x', EscapingChecks::unescaped('$o->printSafeHtml($x)', $policy));
+
+ // E o mesmo nome chamado como função continua sendo o escaper. É a outra
+ // metade da regra, e é ela que impede que o conserto vire Tools em que
+ // ninguém mais escapa nada.
+ $this->assertNull(EscapingChecks::unescaped('esc($x)', $policy));
+ $this->assertNull(EscapingChecks::unescaped('htmlspecialchars($x, ENT_QUOTES)', $policy));
+ }
+
+ /**
+ * Um helper do CI3 casa na forma de método, e é por isso que `calleeName()`
+ * existe.
+ *
+ * Estes três são os únicos que precisam disso, e todos aparecem em views reais:
+ * `create_links()` em 16, `view()` em 2 e `count_all()` em 1. Se este teste
+ * falhar, a correção não é apertar a regra — é remover um helper da lista, e o
+ * relatório de achados novos aponta a view.
+ *
+ * `segment()` saiu daqui: ele é o caminho da requisição, e é a razão de a
+ * quarta lista existir. Ver `testARequestSourceIsReportedEvenWhenItsArgumentsAreLiterals()`.
+ */
+ #[Test]
+ public function testCiHelpersStillMatchInTheirMethodForm(): void
+ {
+ $policy = EscapingPolicy::default();
+
+ $this->assertNull(EscapingChecks::unescaped('$this->load->view($a, $b)', $policy));
+ $this->assertNull(EscapingChecks::unescaped('$this->pagination->create_links()', $policy));
+ $this->assertNull(EscapingChecks::unescaped('$this->db->count_all("os")', $policy));
+
+ // E na forma de função, porque todos os três também são funções livres.
+ $this->assertNull(EscapingChecks::unescaped('create_links()', $policy));
+ }
+
+ /**
+ * Uma fonte de requisição reprova mesmo tendo argumentos literais.
+ *
+ * Este é o defeito que a lista de fontes conserta, e o teste o fixa pelo
+ * mecanismo exato que o produzia: os argumentos de `$this->input->get('campo')`
+ * são o nome do campo, um literal, então uma conferência que reprova a chamada
+ * e depois inspeciona os argumentos aprova a linha sem olhar para dentro. Medido
+ * nesta árvore antes do conserto: `input->get`, `input->post`, `uri->segment`,
+ * `uri->uri_string`, `uri->current_url` e `request->getVar` saíam todos aprovados.
+ *
+ * A segunda metade é o que impede o conserto de virar "Tools em que ninguém mais
+ * lê nada": um escaper em volta continua vencendo, porque o que protege a linha
+ * é o escaper, e não o nome do que está dentro dele.
+ */
+ #[DataProvider('provideRequestSources')]
+ #[Test]
+ public function testARequestSourceIsReportedEvenWhenItsArgumentsAreLiterals(string $expr): void
+ {
+ $this->assertSame($expr, EscapingChecks::unescaped($expr, EscapingPolicy::default()));
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function provideRequestSources(): iterable
+ {
+ yield 'input->get' => ['$this->input->get("pesquisa")'];
+ yield 'input->post' => ['$this->input->post("campo")'];
+ yield 'input->cookie' => ['$this->input->cookie("sessao")'];
+ yield 'input->ip_address' => ['$this->input->ip_address()'];
+ yield 'request->getVar' => ['$this->request->getVar("x")'];
+ yield 'uri->segment' => ['$this->uri->segment(1)'];
+ yield 'uri->uri_string' => ['$this->uri->uri_string()'];
+ yield 'uri->current_url' => ['$this->uri->current_url()'];
+ yield 'session->userdata' => ['$this->session->userdata("nome")'];
+ yield 'session->flashdata' => ['$this->session->flashdata("error")'];
+ yield 'segment() livre' => ['segment(1)'];
+ yield 'current_url() livre' => ['current_url()'];
+ }
+
+ #[Test]
+ public function testAnEscaperStillWinsOverASourceInsideIt(): void
+ {
+ $policy = EscapingPolicy::default();
+
+ $this->assertNull(EscapingChecks::unescaped('esc($this->input->post("campo"))', $policy));
+ $this->assertNull(EscapingChecks::unescaped('html_escape($this->input->get("pesquisa"))', $policy));
+ $this->assertNull(EscapingChecks::unescaped('esc_url(current_url())', $policy));
+
+ // E a fonte continua reprovando quando a linha só a embrulha num transformador
+ // que não escapa, que era o caminho pelo qual ela voltava a passar. O
+ // trecho reportado é o que reprovou, e não a chamada inteira — é o mesmo
+ // que `strtoupper($a)` reporta como `$a`.
+ $this->assertSame(
+ '$this->uri->segment(1)',
+ EscapingChecks::unescaped('ucfirst($this->uri->segment(1))', $policy)
+ );
+ }
+
+ /**
+ * Um helper não-escaper continua aceito na forma de método, por desenho.
+ *
+ * Este é o preço honesto da regra, e ele está escrito aqui para não ser
+ * "descoberto" depois: `view` e `count_all` são helpers que produzem markup ou
+ * uma contagem, e ambos precisam casar na forma de método. `$r->view($a, $b)`
+ * passa pelo gate, e é o preço de `$this->load->view($a, $b)` passar. A
+ * diferença em relação a um escaper é que nenhum dos dois devolve valor de
+ * usuário como HTML escapado por dentro: eles produzem markup por construção.
+ */
+ #[Test]
+ public function testANonEscapingHelperStillMatchesInItsMethodFormByDesign(): void
+ {
+ $policy = EscapingPolicy::default();
+
+ $this->assertNull(EscapingChecks::unescaped('$r->view($a, $b)', $policy));
+ $this->assertNull(EscapingChecks::unescaped('$m->count($x)', $policy));
+ $this->assertNull(EscapingChecks::unescaped('$c->get_class($x)', $policy));
+ }
+
+ /**
+ * Uma política menor roda sem tocar na global.
+ *
+ * Este é o motivo de `EscapingPolicy` ser um valor com construtor público, e é o
+ * que a versão anterior das listas soltas não permitia: testar "só `esc` passa"
+ * exigia um array montado à mão em cada chamada, e ninguém escrevia o teste.
+ */
+ #[Test]
+ public function testTheCheckRunsAgainstAWhoMadePolicyWithoutTouchingTheDefault(): void
+ {
+ $permissive = new EscapingPolicy(
+ escapers: ['esc'],
+ helpers: ['strtoupper'],
+ sources: ['post'],
+ preRendered: [],
+ );
+
+ $this->assertNull(EscapingChecks::unescaped('esc($a)', $permissive));
+ $this->assertNull(EscapingChecks::unescaped('strtoupper($a)', $permissive));
+ $this->assertNull(
+ EscapingChecks::unescaped('$row->strtoupper($a)', $permissive),
+ 'helper continua aceito na forma de método'
+ );
+ $this->assertSame(
+ '$this->input->post("a")',
+ EscapingChecks::unescaped('$this->input->post("a")', $permissive),
+ 'a lista de fontes também é a da política menor'
+ );
+ $this->assertSame(
+ '$a',
+ EscapingChecks::unescaped('esc_url($a)', $permissive),
+ 'o que não está na política menor continua reprovando'
+ );
+ $this->assertSame(
+ '$topo',
+ EscapingChecks::unescaped('$topo', $permissive),
+ 'a lista de pré-renderizados também é a da política'
+ );
+
+ // E a política padrão não foi tocada por nenhuma das conferências acima.
+ $default = EscapingPolicy::default();
+
+ $this->assertSame('$a', EscapingChecks::unescaped('strtoupper($a)', $default));
+ $this->assertNull(EscapingChecks::unescaped('$topo', $default));
+ }
+
+ /**
+ * O que reprova: valor cru chegando à página.
+ *
+ * O esperado é o TRECHO que reprovou, não um booleano, porque é o trecho que o
+ * relatório mostra ao humano. E a ordem importa: `$a . esc($b)` reprova só em
+ * `$a`, e reprovar a linha inteira seria dizer que `esc()` não escapa.
+ */
+ #[DataProvider('provideUnescapedExpressions')]
+ #[Test]
+ public function testAnUnescapedExpressionReportsTheOffendingSnippet(string $expr, ?string $expected): void
+ {
+ $this->assertSame($expected, EscapingChecks::unescaped($expr, EscapingPolicy::default()));
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function provideUnescapedExpressions(): iterable
+ {
+ yield 'variável crua' => ['$a', '$a'];
+ yield 'propriedade crua' => ['$row->nome', '$row->nome'];
+ yield 'leitura de sessão' => ['$this->session->userdata("nome")', '$this->session->userdata("nome")'];
+ yield 'fonte de requisição num ternário' => ['$x ? $this->input->get("a") : esc($b)', '$this->input->get("a")'];
+ yield 'fonte de requisição concatenada' => ['esc($a) . current_url()', 'current_url()'];
+ yield 'concatenação de dois crus' => ['$a . $b', '$a , $b'];
+ yield 'o primeiro de dois, um escapado' => ['$a . esc($b)', '$a'];
+ yield 'o segundo de dois, um escapado' => ['esc($a) . $b', '$b'];
+ yield 'os dois dentro do escaper' => ['esc($a . $b)', null];
+ yield 'ternário com um braço escapado' => ['$x ? esc($y) : $z', '$z'];
+ yield 'ternário com os dois crus' => ['$x ? $y : $z', '$y , $z'];
+ yield 'ternário abreviado' => ['$x ?: $y', '$x , $y'];
+ yield 'coalescência não é ternário' => ['$a ?? $b', '$a ?? $b'];
+ yield 'função que não escapa' => ['strtoupper($a)', '$a'];
+ yield 'função que não escapa, aninhada' => ['nl2br($a)', '$a'];
+ yield 'argumento de uma função desconhecida' => ['implode(", ", $arr) . $b', '$arr , $b'];
+ yield 'chamada sem argumento' => ['foo()', 'foo()'];
+ yield 'parêntese que embrulha' => ['($a)', '$a'];
+ yield 'parêntese que embrulha duas vezes' => ['(($a))', '$a'];
+ yield 'índice com variável' => ['$a[$i]', '$a[$i]'];
+ yield 'literal com variável' => ['$a . "x"', '$a'];
+ yield 'marshal com vírgula' => ['$msg, $br', '$msg , $br'];
+ }
+
+ /**
+ * O que passa, e por quê.
+ *
+ * Os literais e os casts entram porque não carregam valor de usuário; o
+ * `` porque é markup pronto por desenho; o `isBareValue()`
+ * não aparece aqui porque reprova, e está nos casos de cima.
+ */
+ #[DataProvider('provideSafeExpressions')]
+ #[Test]
+ public function testASafeExpressionReportsNothing(string $expr): void
+ {
+ $this->assertNull(EscapingChecks::unescaped($expr, EscapingPolicy::default()));
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function provideSafeExpressions(): iterable
+ {
+ yield 'string vazia' => [''];
+ yield 'literal simples' => ["'texto'"];
+ yield 'literal duplo' => ['"texto"'];
+ yield 'número' => ['42'];
+ yield 'float' => ['4.2'];
+ yield 'booleano' => ['true'];
+ yield 'nulo' => ['null'];
+ yield 'cast para int' => ['(int) $a'];
+ yield 'cast para string' => ['(string) $a'];
+ yield 'escaper de html' => ['esc($a)'];
+ yield 'escaper de json' => ['esc_json($a)'];
+ yield 'escaper de url' => ['esc_url($a)'];
+ yield 'escaper de src' => ['esc_img_src($a)'];
+ yield 'escaper de css' => ['esc_css($a)'];
+ yield 'escaper de mensagem' => ['esc_msg($a)'];
+ yield 'html_escape' => ['html_escape($a)'];
+ yield 'printSafeHtml' => ['printSafeHtml($a)'];
+ yield 'coerção numérica' => ['intval($a)'];
+ yield 'contagem' => ['count($arr)'];
+ yield 'formatação' => ['number_format($a, 2, ",", ".")'];
+ yield 'formatação com aspas' => ["date('d/m/Y', \$quando)"];
+ yield 'reflexão' => ['get_class($a)'];
+ yield 'csrf' => ['get_csrf_token_name()'];
+ yield 'form helper' => ['form_input("nome")'];
+ yield 'markup pré-renderizado' => ['$topo'];
+ yield 'markup pré-renderizado com modal' => ['$modalGerarPagamento'];
+ yield 'markup concatenado com um valor' => ['$topo . esc($a)'];
+ }
+
+ /**
+ * O texto de uma linha de conteúdo é uma expressão, ou não é nada.
+ *
+ * Estas duas conferências são as que reprovam por padrão de arquivo inteiro, e
+ * as duas dependem de a política estar com a lista certa: o padrão de
+ * `preRenderedEscaped()` é montado a partir da mesma lista que a isenção usa. Se
+ * as duas divergissem, um valor poderia sair cru num ponto e ser escapado noutro,
+ * e nenhuma das duas acusaria nada.
+ */
+ #[Test]
+ public function testTheTwoFileWideChecksReadTheSameListAsTheExemption(): void
+ {
+ $policy = EscapingPolicy::default();
+
+ $this->assertNull(EscapingChecks::preRenderedEscaped('= $topo ?>', $policy));
+ $this->assertSame(
+ 'esc($topo)',
+ EscapingChecks::preRenderedEscaped('= esc($topo) ?>', $policy),
+ 'escapar markup pronto quebra a página em vez de protegê-la'
+ );
+ $this->assertNull(
+ EscapingChecks::preRenderedEscaped('= esc($a) ?>', $policy),
+ 'escapar um valor normal é o certo'
+ );
+ }
+
+ /**
+ * A conferência de pronto-renderizado cobre TODOS os escapers da política.
+ *
+ * Este é o teste que impede a lista escrita à mão de voltar. A versão anterior
+ * do padrão tinha quatro nomes — `esc`, `esc_html`, `html_escape`,
+ * `htmlspecialchars` — e a política tem nove: acrescentar um escaper novo
+ *cq não fazia aquela conferência reprovar, e ninguém veria isso, porque a
+ * conferência é uma varredura de arquivo inteiro que só fala quando acha
+ * alguma coisa. Um `esc_html` na lista era ainda o sintoma: um escaper que não
+ * existe em lugar nenhum do código, e cujo lugar na lista era impossível de
+ * justificar lendo o resto.
+ *
+ * O teste percorre a lista em vez de nomear casos, e é por isso que falha se
+ * alguém voltar a escrever os nomes à mão: um quarto escaper coberto e um
+ * décimo não fariam diferença para um teste que só citasse dois.
+ */
+ #[Test]
+ public function testEveryEscaperInThePolicyIsCaughtWrappingPreRenderedMarkup(): void
+ {
+ $policy = EscapingPolicy::default();
+
+ $this->assertNotEmpty($policy->escapers, 'uma lista vazia passaria este teste sem conferir nada');
+
+ foreach ($policy->escapers as $escaper) {
+ $this->assertSame(
+ "{$escaper}(\$topo)",
+ EscapingChecks::preRenderedEscaped("= {$escaper}(\$topo) ?>", $policy),
+ "{$escaper}() em volta de markup pronto deveria ser reprovado, e a conferência tem nomes próprios"
+ );
+ }
+ }
+
+ /**
+ * Um JSON.parse() alimentado por um escaper que emite JSON cru não é uma string.
+ */
+ #[Test]
+ public function testAJsonParseFedAnEscaperIsReported(): void
+ {
+ $policy = EscapingPolicy::default();
+
+ $this->assertSame(
+ 'JSON.parse(\'= esc_json($cfg) ?>\')',
+ EscapingChecks::jsonParseEscaper('var c = JSON.parse(\'= esc_json($cfg) ?>\');', $policy)
+ );
+
+ $this->assertNull(
+ EscapingChecks::jsonParseEscaper('var c = JSON.parse(\'= json_encode($cfg) ?>\');', $policy),
+ 'json_encode() devolve JSON, então o parse funciona'
+ );
+ }
+
+ /**
+ * A conferência do `JSON.parse()` cobre as formas de saída que podem carregar uma
+ * chamada, e não duas delas.
+ *
+ * O método casava `=` e `assertNotEmpty(PhpExpression::ECHO_SHAPES, 'uma lista vazia passaria sem conferir nada');
+
+ $forms = [
+ '= esc_json($cfg) ?>',
+ '',
+ '',
+ '',
+ '',
+ ];
+
+ // Cinco das seis formas estão na lista acima. A que falta é o `echo ...;`
+ // de statement solto, e ela não pode ser testada aqui: o valor precisa
+ // começar com `$`, então uma chamada de escaper nunca cabe nela. É o
+ // motivo de a lista ter um item a menos que a de formas, e não um
+ // buraco no teste.
+ $this->assertCount(count($forms) + 1, PhpExpression::ECHO_SHAPES, 'a lista de formas mudou de tamanho');
+
+ foreach ($forms as $index => $form) {
+ $this->assertNotNull(
+ EscapingChecks::jsonParseEscaper('var c = JSON.parse(\'' . $form . '\');', $policy),
+ "a forma de saída {$index} alimenta um JSON.parse() e não foi conferida"
+ );
+ }
+ }
+
+ /**
+ * A lista de quem devolve JSON é percorrida, não citada.
+ *
+ * O teste anterior citava `json_encode` e `esc_json` por nome, e por isso não
+ * falharia se um terceiro nome devolvesse JSON. `jsonParseEscaper()` não reprova
+ * quem devolve JSON — reprova o escaper que devolve JSON, porque `json_encode()`
+ * parseia e `esc_json()` não —, então este teste percorre os dois lados: todo
+ * escaper que devolve JSON reprova, e um escaper que não devolve JSON passa.
+ */
+ #[Test]
+ public function testEveryJsonEmittingEscaperIsCaughtAndEveryOtherEscaperIsNot(): void
+ {
+ $policy = EscapingPolicy::default();
+
+ $this->assertNotEmpty($policy->jsonEscapers);
+
+ foreach ($policy->escapers as $escaper) {
+ $snippet = EscapingChecks::jsonParseEscaper('var c = JSON.parse(\'= ' . $escaper . '($cfg) ?>\');', $policy);
+ $expected = $policy->returnsJson($escaper) ? $snippet : null;
+
+ $this->assertSame(
+ $expected,
+ $snippet,
+ "{$escaper} devolve JSON: " . ($policy->returnsJson($escaper) ? 'deveria reprovar' : 'não deveria reprovar')
+ );
+ }
+ }
+
+ /**
+ * Um nome nas duas listas de fonte e de escaper é um bug de escrita, e não uma
+ * questão de quem ganha.
+ *
+ * A invariante estava afirmada em três comentários e aplicada em lugar nenhum.
+ * Hoje ela é conferida na construção da política, e a falha é na construção: um
+ * nome que está nos dois lados reprovaria ou passaria conforme a ordem das
+ * regras, e a ordem muda quando alguém precisa mexer nela.
+ */
+ #[Test]
+ public function testAPolicyWithTheSameNameAsAnEscaperAndASourceIsRefused(): void
+ {
+ $this->expectException(\InvalidArgumentException::class);
+
+ new EscapingPolicy(
+ escapers: ['esc', 'segment'],
+ helpers: [],
+ sources: ['segment'],
+ preRendered: [],
+ );
+ }
+
+ #[Test]
+ public function testAPolicyWithTheSameNameAsAHelperAndASourceIsRefused(): void
+ {
+ $this->expectException(\InvalidArgumentException::class);
+
+ new EscapingPolicy(
+ escapers: [],
+ helpers: ['count', 'segment'],
+ sources: ['segment'],
+ preRendered: [],
+ );
+ }
+
+ /**
+ * A ordem das regras é um dado, e o teste a fixa inteira.
+ *
+ * A lista é conferida como lista, e não caso a caso, porque o que este arquivo
+ * trava é a ordem. Um gate com as mesmas sete regras em outra sequência aprova
+ * exatamente o que a sequência errada aprova, e nenhum teste de desfecho pega
+ * isso: os desfechos são os mesmos na maioria das expressões. O que muda é o
+ * trecho do relatório e a aprovação que não deveria existir, e é para isso que
+ * servem os casos de paragem abaixo.
+ */
+ #[Test]
+ public function testTheRulesAreReadInTheOrderThePolicyDeclares(): void
+ {
+ $this->assertSame(
+ ['preRendered', 'literalOrCast', 'bareValue', 'ternary', 'concatenation', 'functionCall', 'unreadable'],
+ EscapingPolicy::default()->ruleOrder
+ );
+ }
+
+ /**
+ * `unreadable` é a última, e reprova sem condição.
+ *
+ * A regra é a rede do gate: ela cobre o que nenhuma das outras sabe ler. Rede
+ * que pode não se aplicar não é rede, e por isso ela não devolve null nunca — o
+ * que também quer dizer que ela tem de vir depois de todas as outras, senão
+ * reprovaria a primeira expressão que aparecesse e o gate deixaria de examinar
+ * qualquer coisa.
+ */
+ #[Test]
+ public function testTheUnreadableRuleIsLastAndAlwaysReports(): void
+ {
+ $order = EscapingPolicy::default()->ruleOrder;
+ $this->assertSame('unreadable', end($order));
+
+ $last = $this->ruleNamed($order[count($order) - 1], EscapingPolicy::default());
+ $verdict = $last->judge('$x < 5 ? $a : $b', EscapingPolicy::default());
+
+ $this->assertNotNull($verdict, 'a rede não pode recusar a expressão');
+ $this->assertTrue($verdict->isReport());
+ }
+
+ /**
+ * `preRendered` antes de `bareValue`: `$topo` é uma variável crua.
+ *
+ * Invertidas as duas, o achado de markup pronto vira achado de XSS, e o relatório
+ * passa a dizer que há uma falha onde o valor é markup pronto por desenho. O
+ * conserto errado para esse sintoma é apagar `$topo` da lista de pré-renderizados,
+ * que é o que transforma um bug de configuração em XSS de verdade.
+ *
+ * As duas regras não são vizinhas na lista — `literalOrCast` está no meio —, e
+ * por isso a mutação aqui é a lista inteira com as duas trocadas de lugar, e não
+ * a troca de um par vizinho. Um par não vizinho ainda pode decidir: basta que a
+ * regra do meio não se aplique, e `(int)` não se aplica a `$topo`.
+ */
+ #[Test]
+ public function testBareValueMustNotComeBeforePreRendered(): void
+ {
+ $order = EscapingPolicy::default()->ruleOrder;
+
+ $this->assertNull($this->judgeWith($order, '$topo'));
+ $this->assertSame(
+ '$topo',
+ $this->judgeWith(
+ ['bareValue', 'preRendered', 'literalOrCast', 'ternary', 'concatenation', 'functionCall', 'unreadable'],
+ '$topo'
+ )
+ );
+ }
+
+ /**
+ * `ternary` antes de `concatenation`: o `?` e o `:` de um ternário não são
+ * operadores de concatenação.
+ *
+ * Invertidas as duas, `$a ? $b . $c : $d` é cortado no ponto, e o corte devolve
+ * `$a ? $b` e `$c : $d` — o reprovado passa a ser a condição do ternário, e o
+ * relatório entrega ao autor uma linha que ele não consegue corrigir.
+ *
+ * `$a ? esc($b) : $c`, o exemplo da versão anterior deste arquivo, também não
+ * prova nada: o `?` de um ternário sem concatenação dentro não faz
+ * `splitTopLevel()` dividir, e as duas ordens devolvem `$c`.
+ */
+ #[Test]
+ public function testConcatenationMustNotComeBeforeTernary(): void
+ {
+ $order = EscapingPolicy::default()->ruleOrder;
+
+ $this->assertSame('$b , $c , $d', $this->judgeWith($order, '$a ? $b . $c : $d'));
+ $this->assertSame(
+ '$a ? $b , $c : $d',
+ $this->judgeWith($this->movedUp('concatenation'), '$a ? $b . $c : $d')
+ );
+ }
+
+ /**
+ * `concatenation` antes de `functionCall`: `esc($a) . foo($b)` termina em
+ * parêntese, e o nome da chamada é `esc`.
+ *
+ * Invertidas as duas, o escaper da frente responde pelo lado nu, e o gate aprova a
+ * linha inteira com `$b` e `$c` saindo crus. É o XSS mais fácil de escrever e o
+ * mais difícil de ver, porque a linha tem um escaper nela.
+ *
+ * O exemplo que `Rules` usava antes, `esc($a) . $y`, foi medido e não prova nada:
+ * `$y` faz a expressão não terminar em parêntese, `isFunctionCall()` devolve null
+ * nas duas ordens, e o resultado é `$y` nos dois casos.
+ */
+ #[Test]
+ public function testFunctionCallMustNotComeBeforeConcatenation(): void
+ {
+ $order = EscapingPolicy::default()->ruleOrder;
+
+ $this->assertSame('$b', $this->judgeWith($order, 'esc($a) . foo($b)'));
+ $this->assertNull($this->judgeWith($this->movedUp('functionCall'), 'esc($a) . foo($b)'));
+ $this->assertSame('$b , $c', $this->judgeWith($order, 'esc($a) . $b . foo($c)'));
+ }
+
+ /**
+ * A lista de regras tem exatamente três posições que decidem alguma coisa, e este
+ * teste é o que as nomeia.
+ *
+ * Puxar cada regra uma posição para cima e medir o que muda no corpus dá três
+ * respostas, e as três são a documentação de `EscapingPolicy::RULES` com prova
+ * anexa. A primeira troca o trecho reportado, a segunda aprova uma linha que tem
+ * um escaper nela, e a terceira troca o trecho de um caso de fallback.
+ *
+ * O valor deste teste é o contrapositivo: uma regra nova, ou uma movida, entra na
+ * lista sem que ninguém decida onde, e a lista é lida sem teste de nada. Aqui
+ * qualquer posição nova que passe a decidir aparece como uma linha a mais no
+ * relatório do teste, e a decisão deixa de ser uma escolha invisível.
+ */
+ #[Test]
+ public function testOnlyThreeRulePositionsDecideAnything(): void
+ {
+ $order = EscapingPolicy::default()->ruleOrder;
+ $deciding = [];
+
+ for ($i = 1, $total = count($order); $i < $total; $i++) {
+ foreach (self::PRECEDENCE_CORPUS as $expr) {
+ $declared = $this->judgeWith($order, $expr);
+ $moved = $this->judgeWith($this->movedUp($order[$i]), $expr);
+
+ if ($declared !== $moved) {
+ $deciding[] = "{$order[$i]} antes de {$order[$i - 1]}: "
+ . var_export($declared, true) . ' vira ' . var_export($moved, true);
+ break;
+ }
+ }
+ }
+
+ $this->assertSame([
+ 'concatenation antes de ternary: \'$b , $c , $d\' vira \'$a ? $b , $c : $d\'',
+ 'functionCall antes de concatenation: \'$b\' vira NULL',
+ 'unreadable antes de functionCall: \'$c\' vira \'esc($b) , $c\'',
+ ], $deciding);
+ }
+
+ /**
+ * O corpus que o teste de precedência percorre.
+ *
+ * Cada linha existe por um motivo, e a lista é curta de propósito: um corpus grande
+ * faria toda troca parecer importante, que é o oposto de informar. A primeira linha
+ * de cada grupo é a expressão em que duas regras se aplicam ao mesmo tempo; as outras
+ * são o caso comum, que nenhuma troca pode quebrar.
+ */
+ private const PRECEDENCE_CORPUS = [
+ // preRendered x bareValue
+ '$topo',
+ '$os->defeito',
+ // ternary x concatenation
+ '$a ? $b . $c : $d',
+ '$a ? $b : $c . $d',
+ '$a ? esc($b) : $c',
+ // concatenation x functionCall
+ 'esc($a) . foo($b)',
+ 'esc($a) . $y',
+ 'esc($a)',
+ 'foo()',
+ '(int) $x',
+ 'esc($a) . esc($b)',
+ '$a . $b',
+ ];
+
+ /**
+ * A lista de regras com uma delas puxada uma posição para cima.
+ *
+ * É a mutação que um autor faz sem querer ao inserir uma regra no lugar "que
+ * parecia certo", e ela é direcional por natureza: puxar `functionCall` para cima
+ * põe a chamada antes da concatenação, e é isso que o gate precisa proibir.
+ *
+ * Uma troca de posições seria simétrica e não diria nada — `swapped('a', 'b')` e
+ * `swapped('b', 'a')` são a mesma ordem, e o teste passaria a provar que o injetor
+ * funciona em vez de que a ordem importa.
+ *
+ * @return list
+ */
+ private function movedUp(string $name): array
+ {
+ $order = EscapingPolicy::default()->ruleOrder;
+ $at = array_search($name, $order, true);
+
+ $this->assertIsInt($at, "{$name} não está na lista de regras");
+ $this->assertGreaterThan(0, $at, "{$name} já é a primeira regra, e não há para cima");
+
+ return array_values(array_merge(
+ array_slice($order, 0, $at - 1),
+ [$name, $order[$at - 1]],
+ array_slice($order, $at + 1)
+ ));
+ }
+
+ /**
+ * A política do gate com a lista de regras trocada, e nada mais mudado.
+ */
+ private function policyWithOrder(array $order): EscapingPolicy
+ {
+ $policy = EscapingPolicy::default();
+
+ return new EscapingPolicy(
+ escapers: $policy->escapers,
+ helpers: $policy->helpers,
+ sources: $policy->sources,
+ preRendered: $policy->preRendered,
+ jsonEscapers: $policy->jsonEscapers,
+ ruleOrder: $order,
+ );
+ }
+
+ /**
+ * O veredito de `unescaped()` sob uma lista de regras específica.
+ */
+ private function judgeWith(array $order, string $expr): ?string
+ {
+ return EscapingChecks::unescaped($expr, $this->policyWithOrder($order));
+ }
+
+ /**
+ * A regra de um nome, pela lista da política.
+ */
+ private function ruleNamed(string $name, EscapingPolicy $policy): Rule
+ {
+ foreach ($policy->rules() as $rule) {
+ if ($rule->name === $name) {
+ return $rule;
+ }
+ }
+
+ $this->fail("{$name} não está na lista de regras");
+ }
+}
diff --git a/application/tests/Support/ViewEscaping/FailClosedTest.php b/application/tests/Support/ViewEscaping/FailClosedTest.php
new file mode 100644
index 000000000..c5c287b8a
--- /dev/null
+++ b/application/tests/Support/ViewEscaping/FailClosedTest.php
@@ -0,0 +1,332 @@
+`
+ * não casa com nenhuma das seis formas, `unescaped()` — que reprovaria
+ * corretamente — nunca é chamado, e o relatório sai limpo. Um valor que o gate não
+ * leu não é um valor que o gate aprovou, e a diferença entre as duas coisas é um XSS
+ * que ninguém viu.
+ *
+ * A geração é determinística (`mt_srand` com semente fixa): uma propriedade que
+ * muda de resultado a cada rodada não é uma propriedade, é um teste que às vezes
+ * passa. A semente está em uma constante e não em um literal espalhado, porque um
+ * número mágico repetido em dois lugares diverge na primeira edição de um deles.
+ */
+final class FailClosedTest extends TestCase
+{
+ private const SEED = 20260928;
+
+ /**
+ * Os átomos de onde as views são montadas, e as formas como eles se combinam.
+ *
+ * Metade são valores, metade são coisas que NÃO devem ser tratadas como valor:
+ * um `print` de CSS, um `echo` de comentário, `?>` no meio da linha. É contra
+ * estas que a extração erra, e um gerador que só produzisse `= $a ?>` passaria
+ * com qualquer regex do mundo.
+ */
+ private const ATOMS = [
+ '$a', '$r->idOs', '$this->view', 'self::$y', 'esc($a)', 'esc_url($a)',
+ "'a'", '"a"', '1', '$topo', 'current_url()', "date('d/m/Y', \$r->d)",
+ 'f($a, $b)', 'ucfirst($r->tipo)', "number_format(\$v, 2, ',', '.')",
+ '', 'print {', '// print', '?>', ';', '.', ',',
+ ];
+
+ /**
+ * Token de saída + o que vem depois dele, montado de duas maneiras: a que a
+ * varredura real usa e a que a varredura real NÃO usa.
+ *
+ * A segunda coluna é a que interessa. Um token solto (`echo $a` sem `;`, sem
+ * `?>`) não é um statement de PHP, e ainda assim é o que a guarda tem de
+ * reportar: se ela calar aqui, cala em view quebrada, que é quando mais importa.
+ */
+ private const OUTPUTS = [
+ '= %s ?>',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '\';',
+ ];
+
+ /**
+ * A propriedade: nenhum token de saída escapa das duas conferências.
+ *
+ * Para cada view gerada, ou alguma forma de `ECHO_SHAPES` casou, ou a guarda
+ * devolveu um achado. As duas respostas juntas cobrem o arquivo; a segunda é a
+ * que fecha o buraco quando a primeira falha, e é por isso que a asserção
+ * permite as duas. O que a asserção PROÍE é o terceiro estado, que é o
+ * silencioso.
+ */
+ #[Test]
+ public function testNenhumaFormaDeSaidaEscapaDasDuasConferencias(): void
+ {
+ $unreported = 0;
+ $covered = 0;
+
+ foreach ($this->generateViews() as $view) {
+ $covered += $this->coveredBy($view);
+
+ // A guarda devolve o PRIMEIRO token sem cobertura, e o arquivo inteiro
+ // está coberto por ele — é o que o relatório faz também. Então o silêncio
+ // da guarda é o único estado em que a cobertura precisa ser conferida
+ // token por token, e é nele que a propriedade é decidível.
+ if (EscapingChecks::unrecognizedOutput($view) === null) {
+ $unreported += $this->uncoveredTokens($view);
+ }
+ }
+
+ $this->assertGreaterThan(0, $covered, 'o corpus não exercita nenhuma forma de saída');
+ $this->assertSame(
+ 0,
+ $unreported,
+ 'há token de saída que nenhuma forma de ECHO_SHAPES cobriu e que '
+ . 'unrecognizedOutput() não reportou: a cobertura falhou em silêncio'
+ );
+ }
+
+ /**
+ * A guarda cobre, sozinha, o que a forma de saída não cobre.
+ *
+ * Se a guarda calasse em tudo, a propriedade acima passaria por acidente: o
+ * corpus inteiro cairia no primeiro braço e `unreported()` valeria 0 sem que
+ * nada tivesse sido conferido. Este teste é o que impede essa leitura, e ele
+ * falha se a guarda virar no-op.
+ */
+ #[Test]
+ public function testAGuardaReportaOQueNenhumaFormaCobre(): void
+ {
+ // O caso que o inventário achou nas views: um `echo` de statement solto,
+ // sem ponto e vírgula e sem tag de fechamento, que nenhuma das seis
+ // formas reconhece.
+ $view = 'nome';
+
+ $this->assertSame(
+ 0,
+ $this->coveredBy($view),
+ 'esta view de teste era para ser um buraco de cobertura, e uma forma '
+ . 'passou a cobrir: o teste de cima deixaria de provar a guarda'
+ );
+
+ $this->assertNotNull(
+ EscapingChecks::unrecognizedOutput($view),
+ 'a guarda calou diante de um token de saída que nenhuma forma cobre'
+ );
+ }
+
+ /**
+ * A guarda reporta também o que NÃO é PHP, e é por isso que ela existe.
+ *
+ * `OUTPUT_TOKENS` é largo de propósito, então o `print` do `@media print` e o
+ * `print()` do JavaScript chegam nele. Um gate que escondesse esses casos para
+ * parecer limpo estaria trocando cobertura comprovável por ausência de
+ * evidência, e o relatório voltaria a ser o de antes: verde porque ninguém
+ * perguntou. Cada um destes é um achado que alguém precisa decidir, e é
+ * exatamente por isso que o arquivo de baseline existe.
+ *
+ * A asserção é a união, e não a guarda sozinha: um token que não é PHP pode
+ * mesmo assim ter sido coberto por uma das formas, e nesse caso ele vira achado
+ * da varredura comum em vez de achado da guarda. O que não pode é nenhum dos
+ * dois caminhos calar. O `` é o exemplo do segundo: a sexta
+ * forma casa nele, e ele sai como `unescaped` — o que é o resultado certo, e é
+ * por isso que a forma existe.
+ */
+ #[Test]
+ public function testTokenQueNaoEPhpNaoSomeDoRelatorio(): void
+ {
+ foreach ([
+ '',
+ '',
+ '',
+ ] as $view) {
+ $this->assertTrue(
+ EscapingChecks::unrecognizedOutput($view) !== null
+ || $this->reportsUnescaped($view),
+ "um token de saída sumiu do relatório: {$view}"
+ );
+ }
+ }
+
+ /**
+ * O relatório da varredura comum, do jeito que a CLI o monta.
+ *
+ * @return list
+ */
+ private function reportsUnescaped(string $view): array
+ {
+ $findings = [];
+
+ foreach (PhpExpression::ECHO_SHAPES as $shape) {
+ if (! preg_match_all($shape['re'], $view, $matches)) {
+ continue;
+ }
+
+ foreach ($matches[1] as $expression) {
+ $hit = EscapingChecks::unescaped(
+ $shape['terminated'] ? PhpExpression::cutAtTopLevelSemicolon($expression) : $expression,
+ EscapingPolicy::default()
+ );
+
+ if ($hit !== null) {
+ $findings[] = $hit;
+ }
+ }
+ }
+
+ return $findings;
+ }
+
+ /**
+ * Uma view sem saída nenhuma é aprovada, e é o outro lado da propriedade.
+ *
+ * Sem este caso, uma guarda que reprovasse tudo também passaria no teste
+ * principal — ela nunca calaria. Fail-closed que reprova view sem valor é
+ * fail-closed que ninguém vai olhar em uma semana.
+ */
+ #[Test]
+ public function testViewSemSaidaNaoEUmAchado(): void
+ {
+ $this->assertNull(EscapingChecks::unrecognizedOutput('= esc($a) ?>
'));
+ $this->assertNull(EscapingChecks::unrecognizedOutput(''));
+ }
+
+ /**
+ * O snippet da guarda é um pedaço do arquivo, e é por isso que a linha sai certa.
+ *
+ * A varredura descobre a linha procurando o snippet no texto. A primeira
+ * versão da guarda devolvia a linha inteira com o token colado no fim, que não
+ * está em lugar nenhum do arquivo: toda linha saía 0, e um relatório com
+ * linha 0 é um relatório que ninguém pode usar para ir ver o caso.
+ */
+ #[Test]
+ public function testOSnippetDaGuardaEstaNoArquivo(): void
+ {
+ $view = "texto
\nnome\n
\n";
+ $snippet = EscapingChecks::unrecognizedOutput($view);
+
+ $this->assertNotNull($snippet);
+
+ [, $line] = PhpExpression::locate($view, $snippet);
+
+ $this->assertSame(2, $line, "o snippet {$snippet} não foi localizado na linha do token");
+ }
+
+ /**
+ * Quantas formas de saída a varredura real encontrou nesta view.
+ */
+ private function coveredBy(string $view): int
+ {
+ $count = 0;
+
+ foreach (PhpExpression::ECHO_SHAPES as $shape) {
+ $count += (int) preg_match_all($shape['re'], $view);
+ }
+
+ return $count;
+ }
+
+ /**
+ * Quantos tokens de saída nenhuma das formas de `ECHO_SHAPES` cobriu.
+ *
+ * É a mesma conta que `EscapingChecks::coveredOutputRanges()` faz, reescrita
+ * aqui de propósito. Um teste que chamasse o método privado da guarda provaria
+ * que a guarda é consistente consigo mesma, e a propriedade que importa é outra:
+ * que a guarda e a extração concordam sobre o que é cobertura. Escrevendo a
+ * conta uma segunda vez, as duas implementasi divergem no dia em que uma delas
+ * regredir, e é esse o dia que o teste existe para pegar.
+ */
+ private function uncoveredTokens(string $view): int
+ {
+ if (! preg_match_all(PhpExpression::OUTPUT_TOKENS, $view, $tokens, PREG_OFFSET_CAPTURE)) {
+ return 0;
+ }
+
+ $ranges = [];
+
+ foreach (PhpExpression::ECHO_SHAPES as $shape) {
+ if (! preg_match_all($shape['re'], $view, $matches, PREG_OFFSET_CAPTURE)) {
+ continue;
+ }
+
+ foreach ($matches[0] as [$match, $offset]) {
+ $ranges[] = [$offset, $offset + strlen($match)];
+ }
+ }
+
+ $uncovered = 0;
+
+ foreach ($tokens[0] as [, $offset]) {
+ foreach ($ranges as [$start, $end]) {
+ if ($offset >= $start && $offset < $end) {
+ continue 2;
+ }
+ }
+
+ $uncovered++;
+ }
+
+ return $uncovered;
+ }
+
+ /**
+ * O corpus de views, gerado com semente fixa.
+ *
+ * @return list
+ */
+ private function generateViews(): array
+ {
+ $atoms = self::ATOMS;
+ $views = [];
+
+ foreach (self::OUTPUTS as $output) {
+ foreach ($atoms as $a) {
+ foreach ($atoms as $b) {
+ $views[] = 'ok
' . sprintf($output, $a) . '' . $b . '
';
+ }
+ }
+ }
+
+ mt_srand(self::SEED);
+
+ for ($i = 0; $i < 2000; $i++) {
+ $parts = [];
+
+ for ($j = 0, $n = mt_rand(1, 5); $j < $n; $j++) {
+ $parts[] = $atoms[mt_rand(0, count($atoms) - 1)];
+ }
+
+ $joiners = [' . ', ' ? ', ', ', ' '];
+ $body = implode($joiners[mt_rand(0, 3)], $parts);
+ $views[] = sprintf(self::OUTPUTS[mt_rand(0, count(self::OUTPUTS) - 1)], $body);
+ }
+
+ return $views;
+ }
+}
diff --git a/application/tests/Support/ViewEscaping/PhpExpressionTest.php b/application/tests/Support/ViewEscaping/PhpExpressionTest.php
new file mode 100644
index 000000000..17011fc63
--- /dev/null
+++ b/application/tests/Support/ViewEscaping/PhpExpressionTest.php
@@ -0,0 +1,474 @@
+load->view()`
+ * precisa casar com o helper `view`. Por isso `calleeName()` devolve "view" e
+ * não "$this->load->view". O preço é que o nome sozinho não distingue um
+ * escaper de `$row->esc()`, e é por isso que a política olha os dois juntos.
+ */
+ #[Test]
+ public function testTheNameIsTheLastSegmentAndTheShapeIsWhatSaysFunctionFromMethod(): void
+ {
+ $this->assertSame('view', PhpExpression::calleeName('$this->load->view'));
+ $this->assertSame('esc', PhpExpression::calleeName('$row->esc'));
+ $this->assertSame('esc', PhpExpression::calleeName('esc'));
+
+ $this->assertTrue(PhpExpression::isMethodCall('$row->esc'));
+ $this->assertTrue(PhpExpression::isMethodCall('$this->session->userdata'));
+ $this->assertTrue(PhpExpression::isMethodCall('Foo::bar'));
+ $this->assertFalse(PhpExpression::isMethodCall('esc'));
+ $this->assertFalse(PhpExpression::isMethodCall('htmlspecialchars'));
+ }
+
+ /**
+ * A forma da chamada é reconhecida só com o parêntese de abertura e o de
+ * fechamento, e devolve o callable e os argumentos separados.
+ *
+ * O regex aceita uma cadeia `$a->b->c` ou um nome simples. Não aceitar `::` é
+ * proposital: uma chamada estática não casa aqui e cai no fim de `unescaped()`
+ * como caso não lido, que é fail-closed.
+ */
+ #[Test]
+ public function testAFunctionCallIsSplitIntoCalleeAndArguments(): void
+ {
+ $this->assertSame(['esc', '$a'], PhpExpression::isFunctionCall('esc($a)'));
+ $this->assertSame(
+ ['$this->load->view', '$a, $b'],
+ PhpExpression::isFunctionCall('$this->load->view($a, $b)')
+ );
+ $this->assertSame(
+ ['date', "'d/m/Y', \$quando"],
+ PhpExpression::isFunctionCall('date(\'d/m/Y\', $quando)')
+ );
+
+ // Sem parêntese não é chamada, e é o que separa `esc($a) . $y` de `esc($a)`.
+ $this->assertNull(PhpExpression::isFunctionCall('$a'));
+ $this->assertNull(PhpExpression::isFunctionCall('esc($a) . $y'));
+ }
+
+ /**
+ * O corte no `;` de nível superior ignora o que está entre aspas e dentro de
+ * parênteses ou colchetes.
+ *
+ * É o que impede que o `;` de um `date('d/m;Y')` corte a linha ao meio, e o
+ * `;` de um `array(...)` idem. Sem isso a expressão chegava à conferência
+ * partida em dois pedaços e o relatório apontava a linha errada.
+ */
+ #[Test]
+ public function testTheTopLevelSemicolonCutIgnoresQuotesAndNesting(): void
+ {
+ $this->assertSame('$a', PhpExpression::cutAtTopLevelSemicolon('$a;'));
+ $this->assertSame('$a . $b', PhpExpression::cutAtTopLevelSemicolon('$a . $b;'));
+ $this->assertSame(
+ "date('d/m;Y', \$quando)",
+ PhpExpression::cutAtTopLevelSemicolon("date('d/m;Y', \$quando);")
+ );
+ $this->assertSame(
+ 'implode(";", $a)',
+ PhpExpression::cutAtTopLevelSemicolon('implode(";", $a);')
+ );
+ $this->assertSame(
+ "f(\$a, ['x' => 1;2])",
+ PhpExpression::cutAtTopLevelSemicolon("f(\$a, ['x' => 1;2]);")
+ );
+ }
+
+ /**
+ * A concatenação parte em operandos, e a vírgula também é separador.
+ *
+ * A vírgula estar na lista não é detalhe: é o idioma dos templates de erro do
+ * CI3, que escrevem a mensagem e a quebra de linha como dois operandos
+ * separados por vírgula. Sem partir nela, a linha caía no fim de
+ * `unescaped()` como "não consegui ler" e acusava uma linha já escapada.
+ *
+ * Os pedaços saem crus, com o espaço que os separava. Nenhum consumidor nota
+ * porque `unescaped()` normaliza a expressão inteira antes e `EscapingChecks`
+ * apara cada trecho reprovado — mas a primitiva não reescreve conteúdo, e é
+ * isso que a distingue de `normalizeExpression()`.
+ */
+ #[Test]
+ public function testTopLevelConcatenationSplitsOnBothDotAndComma(): void
+ {
+ $this->assertSame(['esc($a) ', ' $b'], PhpExpression::splitTopLevel('esc($a) . $b'));
+ $this->assertSame(['esc($a)', ' $b', ' $c'], PhpExpression::splitTopLevel('esc($a), $b, $c'));
+ $this->assertSame(
+ ["'a;b' ", ' $c'],
+ PhpExpression::splitTopLevel("'a;b' . \$c"),
+ 'o ponto e a vírgula dentro de aspas não separam'
+ );
+ $this->assertSame(
+ ['f($a, $b) ', ' $c'],
+ PhpExpression::splitTopLevel('f($a, $b) . $c'),
+ 'a vírgula dentro do parêntese não separa'
+ );
+ }
+
+ /**
+ * Um operador só, ou nada, não é uma divisão.
+ *
+ * `splitTopLevel()` devolve null para não-dividido, e o gate trata isso como
+ * "não é concatenação" e segue para o próximo teste. Devolver um array de um
+ * elemento faria a linha entrar no ramo de concatenação sem ser uma.
+ */
+ #[Test]
+ public function testAnExpressionWithoutASeparatorDoesNotSplit(): void
+ {
+ $this->assertNull(PhpExpression::splitTopLevel('$a'));
+ $this->assertNull(PhpExpression::splitTopLevel('esc($a)'));
+ $this->assertNull(PhpExpression::splitTopLevel('f($a, $b)'), 'a vírgula está dentro do parêntese');
+ }
+
+ /**
+ * O ternário devolve condição, braço verdadeiro e braço falso.
+ *
+ * O caso `??` está aqui porque é o que faz o `?` ser lido como ternário quando
+ * não é: `$a ?? $b` tem dois `?` e nenhum `:`, e sem a guarda viraria um ternário
+ * com a condição errada. O caso abreviado `?:` devolve a condição nos dois
+ * braços, que é o que a linguagem quer dizer.
+ */
+ #[Test]
+ public function testTheTernarySplitsIntoConditionAndBothBranches(): void
+ {
+ $this->assertSame(
+ ['$c ', ' esc($y) ', ' $z'],
+ PhpExpression::splitTernary('$c ? esc($y) : $z')
+ );
+ $this->assertSame(
+ ['$c ', ' esc($y) ', ' esc($w)'],
+ PhpExpression::splitTernary('$c ? esc($y) : esc($w)')
+ );
+
+ $this->assertNull(
+ PhpExpression::splitTernary('$a ?? $b'),
+ '?? não é ternário'
+ );
+ $this->assertSame(
+ ['$c ', ' $a["x:y"] ', ' $z'],
+ PhpExpression::splitTernary('$c ? $a["x:y"] : $z'),
+ 'o : dentro das aspas não fecha o ternário'
+ );
+ }
+
+ /**
+ * Os argumentos de uma chamada partem na vírgula de nível superior.
+ *
+ * O caso de zero argumentos é o que a guarda no topo de `splitCallArgs()`
+ * protege: sem ela o acumulador devolvia `['']`, e `unescaped()` recebia uma
+ * string vazia no lugar de uma lista — o que fazia `foo()` passar como seguro,
+ * já que não havia argumento para reportar.
+ */
+ #[Test]
+ public function testCallArgumentsSplitOnTopLevelCommas(): void
+ {
+ $this->assertSame(['$a', ' $b'], PhpExpression::splitCallArgs('$a, $b'));
+ $this->assertSame(["'d/m/Y'", ' $quando'], PhpExpression::splitCallArgs("'d/m/Y', \$quando"));
+ $this->assertSame(['g($a, $b)', ' $c'], PhpExpression::splitCallArgs('g($a, $b), $c'));
+
+ $this->assertSame([], PhpExpression::splitCallArgs(''));
+ $this->assertSame([], PhpExpression::splitCallArgs(' '));
+ }
+
+ /**
+ * O parêntese que embrulha a expressão inteira é desembrulhado, o que está
+ * dentro dele não.
+ *
+ * `($a)` e `($a . $b)` são a mesma expressão para quem lê, e desembrulhar as
+ * duas é o que permite que o gate as reconheça. O par que não é o par de fora é
+ * uma concatenação entre parênteses, e desembrulhar esse sim mudaria o
+ * significado: `(1 + 2) . $b` viraria `1 + 2 . $b`.
+ */
+ #[Test]
+ public function testTheWrappingParenthesisIsStrippedButAnInnerOneIsNot(): void
+ {
+ $this->assertSame('$a', PhpExpression::normalizeExpression('($a)'));
+ $this->assertSame('$a', PhpExpression::normalizeExpression('(($a))'));
+ $this->assertSame('$a . $b', PhpExpression::normalizeExpression('($a . $b)'));
+ $this->assertSame('$a', PhpExpression::normalizeExpression('$a;'));
+ $this->assertSame('$a', PhpExpression::normalizeExpression(' $a ; '));
+
+ $this->assertSame(
+ '(1 + 2) . $b',
+ PhpExpression::normalizeExpression('(1 + 2) . $b'),
+ 'o par de fora não é o par que embrulha'
+ );
+ }
+
+ /**
+ * Literais e casts não são valor de usuário, e por isso passam.
+ */
+ #[Test]
+ public function testLiteralsAndScalarCastsAreRecognisedAsSafe(): void
+ {
+ $this->assertTrue(PhpExpression::isLiteral("'texto'"));
+ $this->assertTrue(PhpExpression::isLiteral('"texto"'));
+ $this->assertTrue(PhpExpression::isLiteral('42'));
+ $this->assertTrue(PhpExpression::isLiteral('4.2'));
+ $this->assertTrue(PhpExpression::isLiteral('true'));
+ $this->assertTrue(PhpExpression::isLiteral('null'));
+ $this->assertFalse(PhpExpression::isLiteral(''));
+ $this->assertFalse(PhpExpression::isLiteral('$a'));
+
+ $this->assertTrue(PhpExpression::isScalarCast('(int) $a'));
+ $this->assertTrue(PhpExpression::isScalarCast('(float)$a'));
+ $this->assertFalse(PhpExpression::isScalarCast('(array) $a'));
+ }
+
+ /**
+ * A forma de valor puro é a que o gate reprova sem olhar dentro.
+ *
+ * A cadeia de acesso conta como valor puro: `$row->nome` é o caso mais comum de
+ * achado do gate. Não há caso especial para o CI3 aqui — a leitura de sessão é
+ * uma FONTE, e fonte é política, em `EscapingPolicy::isSource()`. Quando essa
+ * leitura morava neste arquivo, ela era o único acréscimo de CI3 na classe, e
+ * ela tinha que ser aplicada acessor por acessor, um por um, com o resto
+ * compartilhando o defeito.
+ */
+ #[Test]
+ public function testBareValuesAreRecognised(): void
+ {
+ $this->assertTrue(PhpExpression::isBareValue('$a'));
+ $this->assertTrue(PhpExpression::isBareValue('$row->nome'));
+ $this->assertTrue(PhpExpression::isBareValue('$row["nome"]'));
+ $this->assertTrue(PhpExpression::isBareValue('$_SESSION["nome"]'));
+ $this->assertFalse(PhpExpression::isBareValue('esc($a)'));
+ $this->assertFalse(PhpExpression::isBareValue('$this->session->userdata("nome")'));
+ }
+
+ /**
+ * A linha de um trecho é a do offset, e offset inexistente é 0 e não 1.
+ *
+ * O `false` virando 0 é o que faz o relatório dizer "linha 0" quando o trecho
+ * não foi encontrado, em vez de apontar para a primeira linha do arquivo. Uma
+ * posição inventada manda o leitor para o lugar errado com a confiança de quem
+ * estava certo.
+ */
+ #[Test]
+ public function testTheLineComesFromTheOffsetAndAMissingOffsetIsZero(): void
+ {
+ $content = "linha 1\nlinha 2\nlinha 3";
+
+ $this->assertSame(1, PhpExpression::lineAt($content, 0));
+ $this->assertSame(2, PhpExpression::lineAt($content, 8));
+ $this->assertSame(3, PhpExpression::lineAt($content, 16));
+ $this->assertSame(0, PhpExpression::lineAt($content, false));
+ $this->assertSame(0, PhpExpression::lineAt($content, -1));
+ }
+
+ /**
+ * `locate()` aponta a primeira ocorrência do trecho normalizado no arquivo.
+ *
+ * A primeira é a resposta, e não uma limitação: o único caller é a varredura
+ * das duas conferências de arquivo inteiro, que casam um padrão no conteúdo
+ * todo e por isso não têm grupo de captura que aponte a posição. O achado delas
+ * é "isto acontece neste arquivo", e a primeira ocorrência é a que o leitor
+ * encontra primeiro. A conferência das formas de saída, que sim tem o offset
+ * exato do regex, não passa por aqui.
+ */
+ #[Test]
+ public function testLocateReportsTheFirstOccurrenceOfTheSnippet(): void
+ {
+ $content = "esc(\$a);\npreenchido\n" . str_repeat("x\n", 400) . "esc(\$a);\n";
+
+ [$offset, $line] = PhpExpression::locate($content, 'esc($a)');
+
+ $this->assertSame(1, $line);
+ $this->assertSame(0, $offset);
+ }
+
+ /**
+ * Um trecho com barra é localizável, e o delimitador do padrão é escapado.
+ *
+ * O padrão de `offsetOfNormalized()` é montado entre barras, e o `preg_quote`
+ * precisa receber esse delimitador para escapar a barra de dentro do trecho.
+ * Sem ele, a primeira barra do trecho fecha o padrão e o resto é lido como
+ * modificador: `preg_match()` devolvia `false` com "Unknown modifier", o que em
+ * produção é um erro fatal que derruba o gate.
+ *
+ * O trecho que dispara não é exótico, e a razão está na guarda de saída
+ * ilegível: ela devolve do token até o fim da linha, e numa view de uma linha
+ * só isso inclui o resto do documento. Uma folha de estilo embutida é
+ * suficiente — `print { a { color: red } }` tem duas barras, e a
+ * primeira delas é a que fechava o padrão.
+ */
+ #[Test]
+ public function testASnippetContainingASlashIsStillFound(): void
+ {
+ $content = '';
+
+ [$offset, $line] = PhpExpression::locate($content, 'print { a { color: red } }');
+
+ $this->assertSame(1, $line);
+ $this->assertSame(
+ strpos($content, 'print {'),
+ $offset,
+ 'o offset é onde o TRECHO começa, e não onde a linha começa'
+ );
+ }
+
+ /**
+ * O pedaço de um achado normalizado é procurado tolerando a indentação que ele
+ * teve no arquivo.
+ *
+ * A chave do baseline é o trecho normalizado, e é por isso que a busca também é:
+ * uma entrada sobrevive a uma mudança que só mexeu em espaços.
+ */
+ #[Test]
+ public function testTheNormalizedSnippetIsFoundAcrossDifferingWhitespace(): void
+ {
+ $content = "= esc(\$a)\n . \$b ?>";
+
+ [$offset, $line] = PhpExpression::locate($content, 'esc($a) . $b');
+
+ $this->assertSame(1, $line);
+ $this->assertIsInt($offset);
+ }
+
+ /**
+ * O `;` de uma forma `echo $x;` não faz parte da expressão conferida.
+ *
+ * A quinta forma de saída não vem terminada pela tag de fim, e o `;` já é parte
+ * do match. Cortar de novo comeria o operando seguinte, que é o que
+ * `cutAtTopLevelSemicolon()` existe para não fazer.
+ */
+ #[Test]
+ public function testATrailingSemicolonIsNotPartOfTheCheckedExpression(): void
+ {
+ $this->assertSame('$a', PhpExpression::normalizeExpression(PhpExpression::cutAtTopLevelSemicolon('$a;')));
+ }
+
+ /**
+ * @param string $expr
+ */
+ #[DataProvider('provideBareValues')]
+ #[Test]
+ public function testBareValueDetectionIsNotConfusedByConcatenation(string $expr, bool $expected): void
+ {
+ $this->assertSame($expected, PhpExpression::isBareValue($expr));
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function provideBareValues(): iterable
+ {
+ yield 'variável simples' => ['$a', true];
+ yield 'propriedade' => ['$os->defeito', true];
+ yield 'cadeia de propriedades' => ['$this->session->userdata', true];
+ yield 'índice' => ['$arr[0]', true];
+ yield 'índice com variável' => ['$arr[$i]', true];
+ yield 'concatenação' => ['$a . $b', false];
+ yield 'chamada' => ['esc($a)', false];
+ yield 'ternário' => ['$a ? $b : $c', false];
+ }
+
+ /**
+ * O `\` dentro de uma aspa come o caractere seguinte, e o par chega ao
+ * callback em duas chamadas separadas: a do `\` e a do que ele protege.
+ *
+ * São os dois casos que nenhum dos quatro callbacks de produção alcança — os
+ * quatro só param com `$quote === null`, e o escape só existe com ela aberta.
+ * São eles que torneiam o contrato de parada, e o teste entra por reflexão
+ * porque nenhum caminho público chega lá.
+ */
+ #[Test]
+ public function testTheCallbackSeesTheBackslashAndTheCharacterItEscapes(): void
+ {
+ $visited = $this->walkWithLogger('"a\\bcd"');
+
+ $this->assertSame(['"', 'a', '\\', 'b', 'c', 'd', '"'], $visited);
+ }
+
+ #[Test]
+ public function testAStopRequestOnTheEscapedCharacterEndsTheWalk(): void
+ {
+ // O `b` é o caractere escapado: ele só chega ao callback pela segunda
+ // chamada, a que acontece dentro do `if ($ch === '\\')`. Parar nele tem de
+ // parar a varredura. Com os dois retornos compostos por `||`, o `false`
+ // desta chamada era engolido pelo `true` da anterior e o `walk()` seguia
+ // até o fim da expressão.
+ $visited = $this->walkUntil('"a\\bcd"', 'b');
+
+ $this->assertSame(['"', 'a', '\\', 'b'], $visited);
+ }
+
+ #[Test]
+ public function testAStopRequestOnTheBackslashItselfEndsTheWalk(): void
+ {
+ $visited = $this->walkUntil('"a\\bcd"', '\\');
+
+ $this->assertSame(['"', 'a', '\\'], $visited);
+ }
+
+ #[Test]
+ public function testAStopRequestAfterAnEscapeStillEndsTheWalk(): void
+ {
+ // O caso que o `||` não quebrava, fixado para que a correção não seja
+ // feita removendo a segunda chamada: parar em `c` precisa parar em `c`.
+ $visited = $this->walkUntil('"a\\bcd"', 'c');
+
+ $this->assertSame(['"', 'a', '\\', 'b', 'c'], $visited);
+ }
+
+ /**
+ * Os caracteres que o `walk()`visitou, na ordem.
+ *
+ * @return list
+ */
+ private function walkWithLogger(string $expr): array
+ {
+ return $this->walk($expr, static fn (string $ch): bool => true);
+ }
+
+ /**
+ * Os caracteres que o `walk()`visitou até pedir parada em `$stopAt`.
+ *
+ * @return list
+ */
+ private function walkUntil(string $expr, string $stopAt): array
+ {
+ return $this->walk($expr, static fn (string $ch): bool => $ch !== $stopAt);
+ }
+
+ /**
+ * @param callable(string): bool $visit
+ * @return list
+ */
+ private function walk(string $expr, callable $visit): array
+ {
+ $method = new ReflectionMethod(PhpExpression::class, 'walk');
+ $visited = [];
+ $method->invoke(null, $expr, static function (string $ch) use ($visit, &$visited): bool {
+ $visited[] = $ch;
+
+ return $visit($ch);
+ });
+
+ return $visited;
+ }
+}
diff --git a/application/tests/Support/ViewEscaping/ReportTest.php b/application/tests/Support/ViewEscaping/ReportTest.php
new file mode 100644
index 000000000..1e1fb3144
--- /dev/null
+++ b/application/tests/Support/ViewEscaping/ReportTest.php
@@ -0,0 +1,221 @@
+assertSame(
+ $policyPrefixes,
+ $reportPrefixes,
+ 'os prefixos de EscapingPolicy e as seções de Report são a mesma lista, '
+ . 'e uma conferência nova sem seção sai com a orientação errada'
+ );
+ }
+
+ /**
+ * Toda seção tem um título e uma orientação, e nenhuma das duas é vazia.
+ *
+ * O título vazio sai como uma seção sem nome, e a orientação vazia sai como um
+ * achado reprovado sem dizer o que fazer, que é o pior resultado possível
+ * para quem está lendo a saída de um gate.
+ */
+ #[Test]
+ public function testEverySectionHasATitleAndAdvice(): void
+ {
+ foreach (Report::prefixes() as $prefix) {
+ $section = Report::section($prefix);
+
+ $this->assertNotNull($section, "prefixo {$prefix} sem seção");
+ [$title, $advice] = $section;
+
+ $this->assertNotSame('', trim($title), "prefixo {$prefix} com título vazio");
+ $this->assertNotSame('', trim($advice), "prefixo {$prefix} com orientação vazia");
+ }
+ }
+
+ /**
+ * As seções saem na ordem em que `prefixes()` declara, e uma seção sem achado
+ * novo não aparece.
+ *
+ * A ordem é a de leitura: primeiro o que é XSS working, depois os três defeitos
+ * de forma. Um título com nada embaixo é ruído que sugere que o gate achou
+ * algo e não mostrou, e por isso a seção vazia some em vez de imprimir.
+ */
+ #[Test]
+ public function testSectionsAreGroupedByCategoryAndEmptyOnesDisappear(): void
+ {
+ $findings = [
+ 'os.php|$os->defeito' => ['snippet' => '$os->defeito', 'line' => 4],
+ 'cfg.php|json-parse: JSON.parse("x")' => ['snippet' => 'JSON.parse("x")', 'line' => 9],
+ ];
+
+ $categories = [
+ '' => ['os.php|$os->defeito'],
+ EscapingPolicy::JSON_PARSE_PREFIX => ['cfg.php|json-parse: JSON.parse("x")'],
+ ];
+
+ $this->assertSame(
+ ['', EscapingPolicy::JSON_PARSE_PREFIX],
+ array_keys(Report::sections($findings, $categories)),
+ 'a ordem de leitura é a ordem das seções, e as vazias não saem'
+ );
+ }
+
+ /**
+ * A seção recebe os achados que a varredura marcar como novos, e nada mais.
+ *
+ * A distinção é a que o `array_diff_key` do CLI faz, e ela precisa acontecer
+ * dentro de `Report`: a lista de categorias vem da varredura completa, e o
+ * relatório só imprime o que ainda não está no baseline. Uma entrada já
+ * registrada que aparecesse aqui sairia reprovada sem ser nova.
+ */
+ #[Test]
+ public function testOnlyNewFindingsReachASection(): void
+ {
+ $categories = [
+ '' => ['velha.php|$a', 'nova.php|$b'],
+ ];
+
+ $new = [
+ 'nova.php|$b' => ['snippet' => '$b', 'line' => 2],
+ ];
+
+ $sections = Report::sections($new, $categories);
+
+ $this->assertSame(['nova.php|$b'], array_keys($sections['']));
+ }
+
+ /**
+ * O cabeçalho recounts o que a varredura encontrou, e não o que alguém escreveu.
+ *
+ * A contagem que já divergiu é a de arquivos: o header dizia 46 e o número
+ * real era 48, porque o header era um literal mantido à mão. Aqui as três
+ * contagens saem dos arrays, e o caso falha se alguma delas deixar de
+ * bater com o que foi passado.
+ */
+ #[Test]
+ public function testTheHeaderCountsWhatWasScanned(): void
+ {
+ $findings = [
+ 'application/views/os/a.php|$a' => ['snippet' => '$a', 'line' => 1],
+ 'application/views/os/b.php|$b' => ['snippet' => '$b', 'line' => 1],
+ 'application/views/os/b.php|unrecognized-output: print();' => ['snippet' => 'print();', 'line' => 3],
+ ];
+
+ $categories = [
+ '' => ['application/views/os/a.php|$a', 'application/views/os/b.php|$b'],
+ EscapingPolicy::UNRECOGNIZED_OUTPUT_PREFIX => ['application/views/os/b.php|unrecognized-output: print();'],
+ ];
+
+ $header = Report::baselineHeader($findings, $categories, null);
+
+ $this->assertStringContainsString('The 2 "unescaped" entries', $header);
+ $this->assertStringContainsString('The 1 "unrecognized-output" entries', $header);
+ $this->assertStringContainsString('per context in 2 files', $header, 'arquivos distintos, não entradas');
+ }
+
+ /**
+ * O cabeçalho sobrevive a uma reescrita, e é por isso que ele existe.
+ *
+ * O defeito que este caso cobre é o `--update-baseline` do script de entrada
+ * apagando as 234 entradas porque a pasta de views não estava lá: sem a
+ * guarda de cobertura, uma escrita com zero achados produz um arquivo vazio e
+ * código 0. O cabeçalho gerado é a parte do arquivo que a reescrita sempre
+ * repõe, e o registro das entradas nunca depende de ele.
+ *
+ * A data é preservada de propósito: ela diz quando a dívida foi registrada, e
+ * regravá-la a cada escrita faria alguém que acrescentou uma entrada legítima
+ * parecer que revisou as 221.
+ */
+ #[Test]
+ public function testTheHeaderIsRegeneratedAndKeepsTheRecordedDate(): void
+ {
+ $findings = ['application/views/os/a.php|$a' => ['snippet' => '$a', 'line' => 1]];
+ $categories = ['' => ['application/views/os/a.php|$a']];
+
+ $previous = "# Baseline for tools/check_view_escaping.php\n"
+ . "#\n"
+ . "# 2026-09-28 - The 221 \"unescaped\" entries below are NOT reviewed decisions.\n"
+ . "# Older wording that should be replaced.\n";
+
+ $header = Report::baselineHeader($findings, $categories, $previous);
+
+ $this->assertStringContainsString('# 2026-09-28 - The 1 "unescaped" entries', $header);
+ $this->assertStringNotContainsString('Older wording', $header, 'o header antigo foi regravado, não copiado');
+ }
+
+ /**
+ * Sem header anterior, a data de hoje é a data do registro.
+ *
+ * É a primeira escrita, e não há marco anterior para preservar. O que não pode
+ * acontecer é a ausência de data: um header sem quando é um header que não
+ * responde "as entradas valem até quando".
+ */
+ #[Test]
+ public function testAFreshHeaderIsDatedToday(): void
+ {
+ $header = Report::baselineHeader([], [], null);
+
+ $this->assertStringContainsString(
+ '# ' . date('Y-m-d') . ' - ',
+ $header
+ );
+
+ $this->assertStringContainsString('no unescaped entries', $header);
+ $this->assertStringContainsString(
+ 'no "unrecognized-output" entries',
+ $header,
+ 'o caso de zero legível é escrito por extenso, e não como um número'
+ );
+ }
+}
diff --git a/application/tests/Support/ViewEscaping/ViewScannerTest.php b/application/tests/Support/ViewEscaping/ViewScannerTest.php
new file mode 100644
index 000000000..f1434ddb4
--- /dev/null
+++ b/application/tests/Support/ViewEscaping/ViewScannerTest.php
@@ -0,0 +1,305 @@
+views = sys_get_temp_dir() . '/mapos-view-scanner-' . bin2hex(random_bytes(6));
+
+ if (! is_dir($this->views) && ! mkdir($this->views, 0o777, true)) {
+ self::fail("Não consegui criar o diretório temporário {$this->views}.");
+ }
+ }
+
+ protected function tearDown(): void
+ {
+ if (! is_dir($this->views)) {
+ return;
+ }
+
+ $entries = new RecursiveIteratorIterator(
+ new RecursiveDirectoryIterator($this->views, \FilesystemIterator::SKIP_DOTS),
+ RecursiveIteratorIterator::CHILD_FIRST
+ );
+
+ foreach ($entries as $entry) {
+ /** @var SplFileInfo $entry */
+ $entry->isDir() ? rmdir($entry->getPathname()) : unlink($entry->getPathname());
+ }
+
+ rmdir($this->views);
+ }
+
+ /**
+ * O mesmo trecho duas vezes na mesma view conta uma vez.
+ *
+ * A chave é "caminho|trecho normalizado", e ela é semeada no mapa. Sem a
+ * semeadura o relatório mostra a mesma linha duas vezes, uma por forma de saída
+ * que casou — e isso não é raridade: a forma de abre-expressão e a forma
+ * `echo ...;` casam o mesmo código, e qualquer view que use as duas repete o
+ * achado.
+ *
+ * Duplicar não é grave por si, e é por isso que o defeito precisaria de um
+ * segundo efeito para ser notado: o relatório é a única saída do gate, e um
+ * relatório com a mesma linha duas vezes ensina quem lê a ignorar a coluna da
+ * linha. A linha relatada tem de ser a PRIMEIRA ocorrência, e o offset do regex
+ * já é a posição exata — procurar o texto de novo no conteúdo acharia a
+ * primeira, que nem sempre é a mesma, e é por isso que a semeadura guarda o
+ * primeiro achado e ignora os seguintes.
+ */
+ #[Test]
+ public function testTheSameSnippetTwiceInOneFileCountsOnce(): void
+ {
+ $this->write('os.php', <<<'PHP'
+ = $os->defeito ?> |
+ = $os->defeito ?> |
+ PHP);
+
+ $scan = ViewScanner::scan($this->views, $this->views);
+
+ $this->assertSame(1, $scan['files']);
+ $this->assertCount(1, $scan['findings'], 'a segunda ocorrência não é um achado novo');
+ $this->assertArrayHasKey('os.php|$os->defeito', $scan['findings']);
+ $this->assertSame(
+ '$os->defeito',
+ $scan['findings']['os.php|$os->defeito']['snippet'],
+ 'a chave é caminho e trecho normalizado, e o snippet é o trecho'
+ );
+ $this->assertSame(1, $scan['findings']['os.php|$os->defeito']['line']);
+ }
+
+ /**
+ * `terminated` decide se a captura é cortada, e as duas metadas erram ao contrário.
+ *
+ * Os dois arquivos deste caso têm o MESMO texto capturado, `$a; $b`, e produzem
+ * chaves diferentes. É esse o ponto: o texto é o mesmo, o que muda é se a forma
+ * de saída já traz a tag de fim na captura.
+ *
+ * Na forma terminada — a abre-expressão curta — o `;` está ali só por
+ * construção, e a expressão precisa ser cortada ANTES de ser analisada. Sem o
+ * corte, o que é reprovado é o pedaço errado, e o relatório aponta a linha
+ * inteira em vez da linha do valor.
+ *
+ * Na forma não terminada — `print( ... )` — o `;` já é parte do match, e cortar
+ * de novo comeria o operando seguinte. É o mesmo `;` produzindo o defeito
+ * oposto, e é por isso que `terminated` é um dado da forma e não um detalhe do
+ * regex: as duas metades no mesmo caso porque um teste de uma só deixaria a
+ * outra sem cobertura, e a forma errada é sempre a que ninguém exercita.
+ */
+ #[Test]
+ public function testTheTerminatedFlagDecidesWhetherTheCaptureIsCut(): void
+ {
+ $this->write('terminada.php', '= $a; $b ?>');
+ $this->write('solta.php', '');
+
+ $scan = ViewScanner::scan($this->views, $this->views);
+
+ $this->assertArrayHasKey(
+ 'terminada.php|$a',
+ $scan['findings'],
+ 'forma terminada: o ponto-e-vírgula é construção do regex e precisa ser cortado'
+ );
+ $this->assertArrayNotHasKey(
+ 'terminada.php|$a; $b',
+ $scan['findings'],
+ 'sem o corte, a linha relatada seria a do arquivo inteiro em vez de a do valor'
+ );
+
+ $this->assertArrayHasKey(
+ 'solta.php|$a; $b',
+ $scan['findings'],
+ 'forma não terminada: o ponto-e-vírgula é parte do statement, e cortar comeria o operando'
+ );
+ $this->assertArrayNotHasKey(
+ 'solta.php|$a',
+ $scan['findings'],
+ 'cortar aqui é o defeito espelhado, e produz um achado que aponta a linha errada'
+ );
+ }
+
+ /**
+ * A política injetada decide o que é achado, e é este o caso que faltava.
+ *
+ * A costura existia — `scan()` já tinha um parâmetro para isso — e não servia
+ * para nada, porque `scanFile()` montava a política padrão por conta própria e a
+ * ignorava. O único teste que exercitava a costura provava o avesso: montava uma
+ * política menor e conferia a conferência, ou seja, a camada de baixo, nunca a
+ * varredura. O campo que a costura controlava era justamente o que o teste não
+ * alcançava.
+ *
+ * Aqui a prova é a inversa: a MESMA view, com a política padrão e com uma
+ * política que aprova aquele valor, precisa dar resultados diferentes. Se a
+ * costura voltar a ser decorativa, as duas chamadas dão o mesmo mapa e este
+ * teste falha — que é o que um parâmetro ignorado faria.
+ *
+ * A lista de isenções é a do campo que decide o que é reprovado, e a contagem
+ * de arquivos é a mesma nas duas: a travessia não depende da política, e um
+ * teste que só conferisse o mapa vazio passaria também com um `scan()` que não
+ * tivesse aberto arquivo nenhum.
+ */
+ #[Test]
+ public function testAnInjectedPolicyDecidesWhatTheScanFinds(): void
+ {
+ $this->write('os.php', '= $os->defeito ?> | ');
+
+ $default = ViewScanner::scan($this->views, $this->views);
+ $this->assertNotSame([], $default['findings'], 'a política padrão reprova o valor solto');
+
+ $aprovadora = new EscapingPolicy(
+ escapers: ['esc'],
+ helpers: ['strtoupper'],
+ sources: [],
+ preRendered: ['$os->defeito'],
+ );
+
+ $permissive = ViewScanner::scan($this->views, $this->views, $aprovadora);
+
+ $this->assertSame(
+ 1,
+ $permissive['files'],
+ 'a varredura percorreu o mesmo arquivo: a contagem não depende da política'
+ );
+ $this->assertSame(
+ [],
+ $permissive['findings'],
+ 'a mesma view é limpa para a política injetada, e achada para a padrão'
+ );
+ }
+
+ /**
+ * A travessia desce em subdiretórios, e a contagem é de arquivos PHP.
+ *
+ * Um subdiretório é a forma como as views do projeto estão organizadas: `tema/`,
+ * `os/`, `financeiro/`. Um teste que só escrevesse na raiz passaria com um
+ * `RecursiveDirectoryIterator` trocado por `scandir`, que é a troca que a
+ * contagem de arquivos denuncia.
+ */
+ #[Test]
+ public function testSubdirectoriesAreWalkedAndNonPhpFilesAreNotCounted(): void
+ {
+ mkdir($this->views . '/os');
+ $this->write('os.php', '= $os->defeito ?> | ');
+ $this->write('os/editarOs.php', '= $os->defeito ?> | ');
+ $this->write('estilo.css', 'td { color: red }');
+
+ $scan = ViewScanner::scan($this->views, $this->views);
+
+ $this->assertSame(2, $scan['files'], 'o CSS não é view, e o subdiretório é');
+ $this->assertArrayHasKey('os.php|$os->defeito', $scan['findings']);
+ $this->assertArrayHasKey('os/editarOs.php|$os->defeito', $scan['findings']);
+ }
+
+ /**
+ * O agrupamento por conferência viaja junto dos achados, e não é lido de volta
+ * a partir da chave.
+ *
+ * O prefixo fica DENTRO da chave, depois do caminho, e é por isso que quem só
+ * recebe o mapa de achados teria de procurá-lo com `str_contains`. A varredura
+ * sabe qual conferência reprovou cada entrada — está no laço, linha a linha — e
+ * por isso devolve esse agrupamento como dado.
+ *
+ * A consequência que este caso trava é a Coverage, não a convenção: toda chave
+ * de `findings` tem que aparecer em exatamente um dos grupos. Um achado que
+ * existisse em `findings` e em nenhum grupo sumiria do relatório sem erro, e um
+ * que aparecesse em dois grupos sairia duas vezes.
+ */
+ #[Test]
+ public function testEveryFindingIsFiledUnderExactlyOneCategory(): void
+ {
+ $this->write('os.php', '= $os->defeito ?> | ');
+ $this->write(
+ 'config.php',
+ ''
+ );
+ $this->write('css.php', '');
+
+ $scan = ViewScanner::scan($this->views, $this->views);
+
+ $grouped = array_merge(...array_values($scan['categories']));
+
+ $this->assertSame(
+ [],
+ array_values(array_diff(array_keys($scan['findings']), $grouped)),
+ 'um achado que não está em nenhum grupo desaparece do relatório sem erro'
+ );
+
+ $this->assertSame(
+ [],
+ array_values(array_diff_assoc($grouped, array_unique($grouped))),
+ 'um achado em dois grupos sai duas vezes no relatório'
+ );
+
+ $this->assertCount(
+ 1,
+ $scan['categories'][''],
+ 'o achado de escaping comum é o que não tem prefixo'
+ );
+
+ $this->assertCount(
+ 1,
+ $scan['categories'][EscapingPolicy::JSON_PARSE_PREFIX],
+ 'o achado de JSON.parse() é agrupado pelo prefixo da conferência, não pela chave'
+ );
+
+ $this->assertCount(
+ 1,
+ $scan['categories'][EscapingPolicy::UNRECOGNIZED_OUTPUT_PREFIX],
+ 'a guarda de saída ilegível tem grupo próprio'
+ );
+ }
+
+ /**
+ * A varredura de um diretório inexistente devolve a mesma forma, com o mapa de
+ * categorias presente e vazio.
+ *
+ * A forma é a mesma para o script de entrada ler `categories` sem `isset`, e
+ * o vazio é o que permite a guarda de cobertura do CLI ler `files` e recusar
+ * uma varredura que não conferiu nada.
+ */
+ #[Test]
+ public function testAMissingDirectoryHasTheSameShapeAndNoCategories(): void
+ {
+ $scan = ViewScanner::scan($this->views . '/nao-existe', $this->views);
+
+ $this->assertSame(0, $scan['files']);
+ $this->assertSame([], $scan['findings']);
+ $this->assertSame([], $scan['categories']);
+ }
+
+ private function write(string $name, string $content): void
+ {
+ file_put_contents($this->views . '/' . $name, $content);
+ }
+}
diff --git a/application/tests/Support/ViewEscaping/ZeroViewGuardTest.php b/application/tests/Support/ViewEscaping/ZeroViewGuardTest.php
new file mode 100644
index 000000000..892e311f7
--- /dev/null
+++ b/application/tests/Support/ViewEscaping/ZeroViewGuardTest.php
@@ -0,0 +1,278 @@
+nome ?>\n# registro que precisa sobreviver\n";
+
+ private string $root;
+
+ protected function setUp(): void
+ {
+ $this->root = sys_get_temp_dir() . '/mapos-zero-view-guard-' . bin2hex(random_bytes(6));
+
+ mkdir($this->root . '/application/views', 0700, true);
+ mkdir($this->root . '/tools', 0700, true);
+
+ // O autoload do Composer resolve `Tools\ViewEscaping\*` pelo mapa do
+ // repositório real, então as classes carregadas aqui são as de produção.
+ // Só o caminho do arquivo precisa existir, e um link serve.
+ symlink(self::repositoryRoot() . '/application/vendor', $this->root . '/application/vendor');
+
+ copy(
+ self::repositoryRoot() . '/tools/check_view_escaping.php',
+ $this->root . '/tools/check_view_escaping.php'
+ );
+
+ $this->copyDirectory(
+ self::repositoryRoot() . '/tools/ViewEscaping',
+ $this->root . '/tools/ViewEscaping'
+ );
+
+ file_put_contents($this->root . '/tools/xss-baseline.txt', self::SENTINEL);
+ }
+
+ protected function tearDown(): void
+ {
+ $this->removeDirectory($this->root);
+ }
+
+ /**
+ * `--update-baseline` não escreve nada quando não leu view nenhuma.
+ *
+ * Este é o caso destrutivo, e é o que a ordem da guarda protege. O
+ * executável sem views produz `$findings` vazio; o caminho de escrita que o
+ * segue transforma isso em um baseline de zero entradas e sai com 0, que é um
+ * gate verde com o registro de decisões apagado. A asserção que segura é a do
+ * conteúdo: mesmo que o código de saída mudasse, um arquivo diferente do que
+ * estava ali é o dano, e ele tem que aparecer.
+ */
+ #[Test]
+ public function testTheUpdateRunLeavesTheBaselineUntouched(): void
+ {
+ $result = $this->runCli(['--update-baseline']);
+
+ $this->assertSame(
+ 2,
+ $result['code'],
+ "O gate não conseguiu ler nenhuma view, e isso precisa ser o 2 — o código que pede "
+ . "olhar o ambiente, não o 1 que pede corrigir uma view.\n"
+ . $result['output']
+ );
+
+ $this->assertSame(
+ self::SENTINEL,
+ file_get_contents($this->root . '/tools/xss-baseline.txt'),
+ 'O baseline foi reescrito sem ter lido nada. A partir daqui o gate aprova tudo, '
+ . 'porque nenhuma decisão está mais registrada.'
+ );
+ }
+
+ /**
+ * A conferência simples também sai com 2, e não com um verde de "nada achado".
+ *
+ * Sem este caso, a guarda poderia existir só no caminho de escrita e o gate do
+ * dia a dia continuaria reportando 0 achados e 0 arquivos com código 0 — a
+ * mesma mentira, com outro uniforme. `assertNotSame(0, ...)` é o que prende
+ * os dois caminhos ao mesmo código.
+ */
+ #[Test]
+ public function testTheCheckRunAlsoReportsThatItReadNothing(): void
+ {
+ $result = $this->runCli([]);
+
+ $this->assertSame(
+ 2,
+ $result['code'],
+ "Uma pasta de views vazia é um erro de ambiente, e não um gate em ordem. O código 0 "
+ . "aqui diria que as 234 entradas conferem, que é o oposto do que aconteceu.\n"
+ . $result['output']
+ );
+
+ $this->assertStringNotContainsString(
+ 'New (unsuppressed) : 0',
+ $result['output'],
+ 'O relatório de uma execução que não leu nada não pode parecer o de uma execução em ordem.'
+ );
+ }
+
+ /**
+ * A mensagem diz onde olhou, porque o erro quase sempre é um caminho.
+ *
+ * A causa de um diretório vazio é quase sempre um: volume que não montou, pasta
+ * renomeada, erro de digitação. Os três se distinguem pelo caminho, e uma
+ * mensagem sem ele deixa quem recebe o 2 sem nada para corrigir. A asserção é
+ * frouxa de propósito — o que precisa estar presente é o diretório, não uma
+ * frase exata que alguém vai reescrever.
+ */
+ #[Test]
+ public function testTheMessageNamesTheDirectoryItTriedToRead(): void
+ {
+ $result = $this->runCli(['--update-baseline']);
+
+ $this->assertStringContainsString(
+ $this->root . '/application/views',
+ $result['output'],
+ 'A mensagem precisa dizer qual diretório foi lido. "Nenhuma view lida" sem o caminho '
+ . 'não distingue um volume que não montou de uma pasta renomeada.'
+ );
+ }
+
+ /**
+ * Com uma view de verdade, o gate roda e o baseline passa a ser reescrito.
+ *
+ * É o contra-teste dos três acima, e ele é o que impede que a guarda seja
+ * confundida com "o script nunca faz nada". Se a guarda disparasse com views
+ * presentes, os três casos passariam e o gate estaria quebrado de um jeito bem
+ * mais silencioso: verde permanente, sem jamais conferir nada.
+ */
+ #[Test]
+ public function testARealViewLetsTheUpdateRunWriteTheBaseline(): void
+ {
+ file_put_contents(
+ $this->root . '/application/views/qualquer.php',
+ "= \$row->nome ?>
\n"
+ );
+
+ $result = $this->runCli(['--update-baseline']);
+
+ $this->assertSame(
+ 0,
+ $result['code'],
+ "Com uma view legível o caminho de escrita tem que funcionar, e é isso que os outros "
+ . "três casos supõem. Se a guarda disparasse aqui, o gate ficaria verde para sempre.\n"
+ . $result['output']
+ );
+
+ $written = (string) file_get_contents($this->root . '/tools/xss-baseline.txt');
+
+ $this->assertStringNotContainsString(
+ self::SENTINEL,
+ $written,
+ 'O baseline foi reescrito, e o registro anterior tem que ter ido com ele.'
+ );
+
+ $this->assertStringContainsString(
+ 'qualquer.php',
+ $written,
+ 'A entrada nova precisa estar no arquivo, que é o que o gate passa a comparar.'
+ );
+ }
+
+ /**
+ * @param list $arguments
+ * @return array{code: int, output: string}
+ */
+ private function runCli(array $arguments): array
+ {
+ $command = escapeshellarg(PHP_BINARY)
+ . ' ' . escapeshellarg($this->root . '/tools/check_view_escaping.php');
+
+ foreach ($arguments as $argument) {
+ $command .= ' ' . escapeshellarg($argument);
+ }
+
+ $descriptors = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
+ $process = proc_open($command, $descriptors, $pipes, $this->root);
+
+ $this->assertIsResource($process, 'Não foi possível executar o gate de escape.');
+
+ $output = stream_get_contents($pipes[1]) . stream_get_contents($pipes[2]);
+
+ fclose($pipes[1]);
+ fclose($pipes[2]);
+
+ return ['code' => proc_close($process), 'output' => $output];
+ }
+
+ /**
+ * A raiz do repositório, para não repetir a contagem de `dirname` em cada uso.
+ *
+ * `__DIR__` é `application/tests/Support/ViewEscaping`, e subir quatro níveis
+ * chega na raiz. A contagem está escrita uma vez porque espalhada é o jeito de
+ * errar: três níveis resolve para `application/`, e o erro aparece como um
+ * `copy()` reclamando de um arquivo que existe.
+ */
+ private static function repositoryRoot(): string
+ {
+ return dirname(__DIR__, 4);
+ }
+
+ private function copyDirectory(string $from, string $to): void
+ {
+ mkdir($to, 0700, true);
+
+ foreach (scandir($from) ?: [] as $entry) {
+ if ($entry === '.' || $entry === '..') {
+ continue;
+ }
+
+ $source = $from . '/' . $entry;
+ $destination = $to . '/' . $entry;
+
+ is_dir($source) ? $this->copyDirectory($source, $destination) : copy($source, $destination);
+ }
+ }
+
+ /**
+ * Apaga a árvore, incluindo o link de `vendor`, que `unlink` e não `rmdir`
+ * resolve — e é por isso que o `is_link()` vem antes do `is_dir()`.
+ *
+ * Sem essa distinção, o `scandir` desce no autoload do Composer e apaga o
+ * `vendor/` do repositório. O `rmdir` num link também falharia, então as duas
+ * ordens importam: link primeiro, e um `is_dir` que só vale para diretório
+ * verdade.
+ */
+ private function removeDirectory(string $path): void
+ {
+ if (! is_dir($path)) {
+ return;
+ }
+
+ foreach (scandir($path) ?: [] as $entry) {
+ if ($entry === '.' || $entry === '..') {
+ continue;
+ }
+
+ $child = $path . '/' . $entry;
+
+ if (is_link($child)) {
+ unlink($child);
+ } elseif (is_dir($child)) {
+ $this->removeDirectory($child);
+ } else {
+ unlink($child);
+ }
+ }
+
+ rmdir($path);
+ }
+}
diff --git a/application/tests/bin/check-schema-parity.php b/application/tests/bin/check-schema-parity.php
new file mode 100644
index 000000000..2a296d76f
--- /dev/null
+++ b/application/tests/bin/check-schema-parity.php
@@ -0,0 +1,97 @@
+ SchemaReader::applicationSchema($pdo, $database);
+
+// Os nomes derivados também precisam terminar em '_test', porque o mesmo guard
+// protege o DROP.
+//
+// O token do worker entra no nome pelo mesmo motivo do clone: sem ele, dois
+// processos de paridade rodando ao mesmo tempo recriam os mesmos dois bancos e
+// cada um lê o schema do meio da reconstrução do outro. O token vem antes do
+// sufixo, que é o que assertDatabaseNameIsSafe() exige, e o nome é montado por
+// workerDatabaseName() — a mesma função que o clone usa, para as duas metades
+// do gate não saberem cada uma a sua regra.
+// `solo` é o token da execução de processo único, o mesmo que o clone usa: sem
+// TEST_TOKEN não há isolamento a fazer, e o nome precisa ser estável para o
+// gate poder ser repetido.
+$token = TestDatabase::parallelToken() ?? 'solo';
+
+$dumpName = DatabaseGuard::workerDatabaseName('mapos_parity_dump', $token);
+$migrationName = DatabaseGuard::workerDatabaseName('mapos_parity_migration', $token);
+
+// --- 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, $dumpName);
+
+// --- 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), $migrationName);
+
+$test->drop($dumpName);
+$test->drop($migrationName);
+
+if ($migrationError !== '') {
+ fwrite(STDERR, "As migrações falharam: {$migrationError}\n");
+ exit(1);
+}
+
+$diff = schemaDiffBetween($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/drop-worker-databases.php b/application/tests/bin/drop-worker-databases.php
new file mode 100644
index 000000000..e130f4f3b
--- /dev/null
+++ b/application/tests/bin/drop-worker-databases.php
@@ -0,0 +1,82 @@
+templateDatabase();
+$base = DatabaseGuard::modelBase($model);
+
+// O filtro é barato e a conferência é a que vale: ele restringe a lista a nomes
+// que começam como um worker e termina como um worker, e
+// `DatabaseGuard::isWorkerDatabaseName()` logo abaixo refaz o nome pela MESMA
+// função que o clone usa. O filtro é a rede, e a conferida é a autoridade — é ela
+// que garante que só sai daqui um nome que `workerDatabaseName()` produziu.
+$found = SchemaReader::databasesLike($test->pdo(), $base . '_%_test');
+
+$deleted = [];
+$skipped = [];
+
+foreach ($found as $name) {
+ // O modelo não está no conjunto por construção — `mapos\_%\_test` exige um
+ // token no meio, e `mapos_test` não tem — e a conferida abaixo o recusaria do
+ // mesmo jeito. Fica escrito de propósito: o passo é o que este script não pode
+ // dar errado, e um nome que se prove não ser worker deve ser relatado, não
+ // silenciosamente pulado.
+ if ($name === $model) {
+ continue;
+ }
+
+ if (! DatabaseGuard::isWorkerDatabaseName($name, $model)) {
+ $skipped[] = $name;
+
+ continue;
+ }
+
+ $test->drop($name);
+ $test->schemaFingerprint()->forget($name);
+
+ $deleted[] = $name;
+}
+
+foreach ($skipped as $name) {
+ fwrite(STDOUT, "Ignorado, não é um nome que workerDatabaseName() produziria: {$name}\n");
+}
+
+if ($deleted === []) {
+ fwrite(STDOUT, 'Nenhum banco de worker para apagar. Modelo preservado: ' . $model . PHP_EOL);
+
+ exit(0);
+}
+
+fwrite(STDOUT, sprintf(
+ 'Apagados %d banco(s) de worker: %s%s',
+ count($deleted),
+ implode(', ', $deleted),
+ PHP_EOL
+));
diff --git a/application/tests/bin/lib/_boot.php b/application/tests/bin/lib/_boot.php
new file mode 100644
index 000000000..8ddb71afb
--- /dev/null
+++ b/application/tests/bin/lib/_boot.php
@@ -0,0 +1,41 @@
+getMessage() . PHP_EOL);
+ exit(1);
+ }
+}
diff --git a/application/tests/bin/lib/schema-diff.php b/application/tests/bin/lib/schema-diff.php
new file mode 100644
index 000000000..7cd58bceb
--- /dev/null
+++ b/application/tests/bin/lib/schema-diff.php
@@ -0,0 +1,80 @@
+> $dump mapa do banco.sql
+ * @param array> $migrations mapa das migrations
+ * @return list
+ */
+ function schemaDiffBetween(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 --git a/application/tests/bin/setup-db.php b/application/tests/bin/setup-db.php
new file mode 100644
index 000000000..9b733dcb0
--- /dev/null
+++ b/application/tests/bin/setup-db.php
@@ -0,0 +1,109 @@
+database();
+
+$echo = static function (string $message): void {
+ fwrite(STDOUT, $message . PHP_EOL);
+};
+
+// --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->schemaFingerprint()->isCurrent($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->schemaFingerprint()->forget($database);
+}
+
+if ($reusable) {
+ $echo("Schema de '{$database}' em dia (migrations até " . SchemaFingerprint::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();
+
+$migrationError = TestApplication::migrate();
+
+if ($migrationError !== '') {
+ fwrite(STDERR, "As migrações falharam: {$migrationError}\n");
+ exit(1);
+}
+
+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();
+
+ $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->schemaFingerprint()->record($database);
+}
+
+// Confere o resultado, para uma montagem quebrada falhar aqui e não no meio de
+// 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.
+$users = get_instance()->db->count_all_results('usuarios');
+
+if ($users !== TestFixtures::USER_COUNT) {
+ fwrite(STDERR, sprintf("Esperava %d usuários e encontrei %d.\n", TestFixtures::USER_COUNT, $users));
+ 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);
+}
+
+$echo(sprintf(
+ '%d usuários disponíveis: %s (senha 123456).',
+ TestFixtures::USER_COUNT,
+ implode(', ', TestFixtures::userEmails())
+));
diff --git a/application/tests/bootstrap.php b/application/tests/bootstrap.php
new file mode 100644
index 000000000..17c7b498d
--- /dev/null
+++ b/application/tests/bootstrap.php
@@ -0,0 +1,58 @@
+ensureWorkerDatabase($test->database(), $test->templateDatabase());
+}
+
+TestApplication::boot();
+
+register_shutdown_function(static function (): void {
+ if (session_status() === PHP_SESSION_ACTIVE) {
+ session_destroy();
+ }
+});
diff --git a/application/tests/index.html b/application/tests/index.html
new file mode 100644
index 000000000..c942a79ce
--- /dev/null
+++ b/application/tests/index.html
@@ -0,0 +1,10 @@
+
+
+ 403 Forbidden
+
+
+
+Directory access is forbidden.
+
+
+
\ No newline at end of file
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 @@