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 | `nome) ?>`, `value="nome) ?>"` | -| `esc_js($v)` | JS string / value inside `` fiquem neutralizados. + * Use em `` dentro do corpo da pagina e dentro de + * atributos delimitados por aspas (`value=""`). * * @param mixed $value */ - function esc_js($value): string + function esc($value): string { - return esc_json($value); + $text = esc_scalar($value); + + return $text === null + ? '' + : htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); } } @@ -141,7 +151,15 @@ function esc_js($value): string /** * Codifica um valor como JSON seguro para ser embutido em `'; + + $this->ci()->session->set_flashdata('error', $payload); + + $html = $this->callControllerRaw('Login', 'index'); + + $this->assertStringNotContainsString($payload, $html, 'O flashdata entrou na página sem escapar.'); + $this->assertStringContainsString('<script>', $html, 'O flashdata deveria aparecer escapado.'); + } + + /** + * GET login/sair: o logout precisa derrubar a sessão. + */ + public function testDestroysTheSessionOnLogout(): void + { + $this->login(); + + $this->assertTrue($this->ci()->session->userdata('logado'), 'O login não deixou a sessão ativa.'); + + $this->callControllerRaw('Login', 'sair'); + + $this->assertSame( + PHP_SESSION_NONE, + session_status(), + 'sair() deveria ter destruído a sessão nativa.' + ); + } + + public function testLeavesNoLoggedInUserForTheNextRequest(): void + { + $this->login(); + $this->callControllerRaw('Login', 'sair'); + + // Simula a requisição seguinte: quem abrir sessão nova tem de estar + // deslogado. + session_start(); + + $this->assertNull( + $this->ci()->session->userdata('logado'), + 'Uma sessão aberta depois do logout não pode vir com o usuário logado.' + ); + $this->assertNull( + $this->ci()->session->userdata('id_admin'), + 'O id_admin não pode sobreviver ao logout.' + ); + } + + public function testRedirectsToTheLoginPageOnLogout(): void + { + $this->callControllerRaw('Login', 'sair'); + + $this->assertSame( + site_url('login'), + $this->ci()->output->get_header('Location'), + 'O logout precisa mandar o usuário de volta para a tela de login.' + ); + } + + /** + * Regressão de open redirect. + * + * O Referer é controlado pelo cliente. Se o logout redirecionasse para ele, + * qualquer terceiro montaria um link que manda o usuário sair do Map-OS e + * cair num site dele. + */ + public function testIgnoresTheRefererWhenRedirecting(): void + { + $_SERVER['HTTP_REFERER'] = 'https://evil.example/pagina'; + + $this->callControllerRaw('Login', 'sair'); + + $location = (string) $this->ci()->output->get_header('Location'); + + $this->assertSame(site_url('login'), $location, 'O logout seguiu o Referer do cliente.'); + $this->assertStringNotContainsString('evil.example', $location); + } + + public function testWorksWhenNobodyIsLoggedIn(): void + { + $location = $this->callControllerRaw('Login', 'sair'); + + $this->assertSame('', $location, 'Um logout sem sessão não deveria devolver corpo.'); + $this->assertSame( + site_url('login'), + $this->ci()->output->get_header('Location'), + 'Um logout sem sessão ainda precisa redirecionar para o login.' + ); + } + + /** + * Os cabeçalhos de CORS saem pelo CI_Output, e não pelo header() do PHP. + * + * Dois motivos, e o teste existe para o segundo: + * + * - o Output só emite os cabeçalhos no fim da requisição, então o header sai + * no lugar certo, e o status 200 do cabeçalho não é afetado; + * - header() do PHP reclama em CLI, e o Whoops transforma o aviso em + * exceção. A suíte envolve a chamada do controller em + * ignoringCliHeaderWarnings(), o que faria uma volta ao header() passar + * despercebida. Afirmar que os cabeçalhos estão no CI_Output é o que + * segura essa regressão: um header() cru não aparece aqui. + */ + public function testSetsTheCorsHeadersThroughTheOutput(): void + { + $this->postLogin('admin@admin.com', 'errada'); + + $this->callController('Login', 'verificarLogin'); + + $output = $this->ci()->output; + + $this->assertSame(base_url(), $output->get_header('Access-Control-Allow-Origin')); + $this->assertSame('POST, GET, OPTIONS', $output->get_header('Access-Control-Allow-Methods')); + $this->assertSame('1000', $output->get_header('Access-Control-Max-Age')); + $this->assertSame('Content-Type', $output->get_header('Access-Control-Allow-Headers')); + } + + /** + * Uma conta sem dataExpiracao não pode ser bloqueada. + * + * dataExpiracao é date DEFAULT NULL, então null é um valor legítimo: conta + * sem expiração. O chk_date() antigo fazia new DateTime(null), que é + * depreciado no PHP 8.1 e viraria erro sob failOnDeprecation. + * + * 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('alert(1)')); + } + + public function testPrintSafeHtmlStripsEventHandlers(): void + { + $this->assertStringNotContainsString('onerror', printSafeHtml('')); + } + + /** + * The bug this test exists for: `os.defeito` and friends are `TEXT NULL`, + * and CI3 writes an explicit NULL when the key is absent from the payload. + * A `string` parameter type turned that into a TypeError, which is a fatal + * 500 on the customer-facing OS pages rather than a cosmetic problem. + */ + public function testPrintSafeHtmlAcceptsTheResultOfANullableColumn(): void + { + $row = new stdClass(); + $row->defeito = null; + + $this->assertSame('', printSafeHtml($row->defeito)); + } + + public function testEscEncodesEveryCharacterThatBreaksOutOfMarkup(): void + { + $this->assertSame('<>"'', esc('<>"\'')); + } + + #[DataProvider('numericValues')] + public function testEscStringifiesNumbers(mixed $value, string $expected): void + { + $this->assertSame($expected, esc($value)); + } + + public static function numericValues(): array + { + return [ + 'int' => [123, '123'], + 'float' => [1.5, '1.5'], + 'zero' => [0, '0'], + ]; + } + + public function testEscScalarClassifiesValuesByWhetherTheyAreText(): void + { + $this->assertSame('123', esc_scalar(123)); + $this->assertSame('1.5', esc_scalar(1.5)); + $this->assertSame('0', esc_scalar(0)); + $this->assertSame('', esc_scalar('')); + } + + /** + * `null`, `bool`, `array` e `object` não são texto, e é `esc_scalar()` que + * decide isso — uma vez, para todos os escapers. O `null` de retorno é o + * que permite a cada meio de saída escolher o seu próprio vazio. + */ + #[DataProvider('nonTextValues')] + public function testEscScalarReturnsNullForValuesThatAreNotText(mixed $value): void + { + $this->assertNull(esc_scalar($value)); + } + + public static function nonTextValues(): array + { + return [ + 'null' => [null], + 'true' => [true], + 'false' => [false], + 'array' => [['a']], + 'empty array' => [[]], + 'object' => [new stdClass()], + ]; + } + + /** + * Comportamento real de `esc()` com valor não-texto: string vazia. Isso + * **não** é "rejeitar" o valor — o nome do teste anterior dizia isso e a + * asserção dizia o contrário. Um booleano não tem representação em + * contexto de texto, e a view que precisar mostrar "Sim"/"Nao" escolhe o + * texto no ternário; o que `esc()` faz é não inventar um `1` nem um + * `Array` na página. + */ + public function testEscRendersValuesThatAreNotTextAsEmptyString(): void + { + $this->assertSame('', esc(null)); + $this->assertSame('', esc(true)); + $this->assertSame('', esc(false)); + $this->assertSame('', esc(['a'])); + $this->assertSame('', esc(new stdClass())); + } + + /** + * O "não é texto" de `esc_scalar()` chega a meios de saída diferentes e + * cada um vira o seu próprio vazio: nada em HTML, `""` num alerta JS, + * `null` num literal JSON. É a razão de `esc_scalar()` existir em vez de + * cada escaper decidir sozinho — a decisão fica em uma linha por meio. + */ + public function testEachOutputMediumPicksItsOwnEmptyValue(): void + { + $this->assertSame('', esc(null)); + $this->assertSame('""', esc_msg(null)); + $this->assertSame('null', esc_json(null)); + } + + /** + * Estruturas **não** são o "não é texto" de `esc_scalar()`: em JSON elas + * são valores legítimos e codificam como tal. + */ + public function testEscJsonEncodesStructuresRatherThanBlankingThem(): void + { + $this->assertSame('{"a":1}', esc_json(['a' => 1])); + $this->assertSame('["a","b"]', esc_json(['a', 'b'])); + $this->assertSame('{}', esc_json(new stdClass())); + } + + public function testEscJsonKeepsTypesSoJsCodeDoesNotSilentlyChange(): void + { + $this->assertSame('true', esc_json(true)); + $this->assertSame('false', esc_json(false)); + $this->assertSame('null', esc_json(null)); + $this->assertSame('"1.5"', esc_json(1.5)); + $this->assertSame('{"a":1}', esc_json(['a' => 1])); + } + + public function testEscJsonNeutralisesScriptTerminator(): void + { + $this->assertStringNotContainsString('', esc_json('')); + } + + #[DataProvider('dangerousUrls')] + public function testEscUrlRejectsExecutableSchemes(string $url): void + { + $this->assertSame('', esc_url($url)); + } + + public static function dangerousUrls(): array + { + return [ + 'javascript' => ['javascript:alert(1)'], + 'javascript upper' => ['JaVaScRiPt:alert(1)'], + 'vbscript' => ['vbscript:msgbox(1)'], + 'data' => ['data:text/html;base64,PHNjcmlwdD4='], + 'control char smuggling' => ["java\nscript:alert(1)"], + ]; + } + + public function testEscUrlAllowsOrdinaryLinks(): void + { + $this->assertSame('https://mapos.com.br', esc_url('https://mapos.com.br')); + $this->assertSame('mailto:contato@mapos.com.br', esc_url('mailto:contato@mapos.com.br')); + } + + public function testEscUrlEscapesWhatItAllows(): void + { + $this->assertSame('https://mapos.com.br/?a=1&b=2', esc_url('https://mapos.com.br/?a=1&b=2')); + } + + public function testEscUrlStaysOutOfSrcWhereADataImageIsLegitimate(): void + { + // A razão de `esc_img_src()` existir. `data:` é perigoso num `href`, + // onde clicar executa, e é o formato normal de um `src` de imagem — os + // QR codes de pagamento são data URIs. Uma função só teria de escolher + // um dos dois, e a escolha errada some com a imagem em silêncio. + $this->assertSame('', esc_url('data:image/svg+xml;base64,AAA')); + $this->assertSame('data:image/svg+xml;base64,AAA', esc_img_src('data:image/svg+xml;base64,AAA')); + } + + #[DataProvider('dangerousImageSources')] + public function testEscImgSrcRejectsEverythingThatIsNotAnImage(string $src): void + { + $this->assertSame('', esc_img_src($src)); + } + + public static function dangerousImageSources(): array + { + return [ + 'javascript' => ['javascript:alert(1)'], + 'javascript upper' => ['JaVaScRiPt:alert(1)'], + 'vbscript' => ['vbscript:msgbox(1)'], + 'file' => ['file:///etc/passwd'], + // `data:image/` passa, mas `data:text/html` não: não há motivo para + // aceitar HTML num `src`, e aceitar tornaria a regra inexplicável. + 'data html' => ['data:text/html;base64,PHNjcmlwdD4='], + 'data with image in the path' => ['data:text/html,'], + 'control char smuggling' => ["java\nscript:alert(1)"], + ]; + } + + public function testEscImgSrcAllowsRelativeAndRemoteImages(): void + { + $this->assertSame('assets/img/User.png', esc_img_src('assets/img/User.png')); + $this->assertSame('/uploads/1.png?v=2', esc_img_src('/uploads/1.png?v=2')); + $this->assertSame('https://mapos.com.br/logo.png', esc_img_src('https://mapos.com.br/logo.png')); + } + + public function testEscImgSrcEscapesWhatItAllows(): void + { + $this->assertSame( + 'https://mapos.com.br/logo.png?a=1&b=2', + esc_img_src('https://mapos.com.br/logo.png?a=1&b=2') + ); + } + + public function testUrlHelpersReturnTheEmptyFormOfTheirOwnMedium(): void + { + // `esc_scalar()` devolve null para o que não é texto; cada meio escolhe + // o seu vazio. É a mesma regra que `testEachOutputMediumPicksItsOwnEmptyValue` + // cobre para os escapers, aplicada aos dois helpers por baixo deles. + foreach ([null, [], new stdClass(), true] as $value) { + $this->assertNull(clean_url($value)); + $this->assertNull(url_scheme('assets/img/User.png')); + $this->assertSame('', esc_url($value)); + $this->assertSame('', esc_img_src($value)); + } + + $this->assertNull(clean_url(' ')); + $this->assertSame('https', url_scheme('HTTPS://mapos.com.br')); + } + + public function testCleanUrlRefusesToLetABrowserReinterpretTheScheme(): void + { + // Espaços e barras de controle são removidos por alguns navegadores ao + // resolver a URL, o que contrabandeia o esquema. Recusar antes de decidir + // qual esquema é o ponto. + $this->assertNull(clean_url("java\tscript:alert(1)")); + $this->assertNull(clean_url("java\0script:alert(1)")); + } + + public function testEscCssDropsTheCharactersThatBreakOutOfADeclaration(): void + { + $escaped = esc_css('red;} body{display:none'); + + $this->assertStringNotContainsString(';', $escaped); + $this->assertStringNotContainsString('{', $escaped); + $this->assertStringNotContainsString('}', $escaped); + $this->assertStringNotContainsString('<', $escaped); + } + + public function testEscMsgTurnsBreakIntoNewlineAndDropsMarkup(): void + { + $this->assertSame('"linha1\nlinha2"', esc_msg('linha1
linha2')); + $this->assertSame('"so texto"', esc_msg('so texto')); + } + + public function testRedirectStatusForPreservesTheMethodOnHttp11(): void + { + $this->assertSame(307, redirect_status_for('GET', 'HTTP/1.1')); + $this->assertSame(303, redirect_status_for('POST', 'HTTP/1.1')); + $this->assertSame(303, redirect_status_for('PUT', 'HTTP/1.1')); + } + + public function testRedirectStatusForFallsBackTo302OutsideHttp11(): void + { + $this->assertSame(302, redirect_status_for('POST', 'HTTP/2')); + $this->assertSame(302, redirect_status_for('POST', null)); + $this->assertSame(302, redirect_status_for(null, 'HTTP/1.1')); + } +} diff --git a/application/tests/Schema/SchemaTest.php b/application/tests/Schema/SchemaTest.php new file mode 100644 index 000000000..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('', $policy)); + $this->assertSame( + 'esc($topo)', + EscapingChecks::preRenderedEscaped('', $policy), + 'escapar markup pronto quebra a página em vez de protegê-la' + ); + $this->assertNull( + EscapingChecks::preRenderedEscaped('', $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("", $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(\'\')', + EscapingChecks::jsonParseEscaper('var c = JSON.parse(\'\');', $policy) + ); + + $this->assertNull( + EscapingChecks::jsonParseEscaper('var c = JSON.parse(\'\');', $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 `assertNotEmpty(PhpExpression::ECHO_SHAPES, 'uma lista vazia passaria sem conferir nada'); + + $forms = [ + '', + '', + '', + '', + '', + ]; + + // 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(\'\');', $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 `` 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 = [ + '', + '', + '', + '', + '', + '', + '\';', + ]; + + /** + * 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('

')); + $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 = ""; + + [$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' + defeito ?> + 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', ''); + $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', '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', 'defeito ?>'); + $this->write('os/editarOs.php', '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', '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', + "

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 @@
-
+
diff --git a/application/views/arquivos/arquivos.php b/application/views/arquivos/arquivos.php index c839fc7fe..4934974c9 100644 --- a/application/views/arquivos/arquivos.php +++ b/application/views/arquivos/arquivos.php @@ -9,7 +9,7 @@
Arquivos
- + permission->checkPermission($this->session->userdata('permissao'), 'aArquivo')) : ?>
@@ -59,7 +59,7 @@ idDocumentos) ?> path)) : ?> - + - @@ -70,11 +70,11 @@ tamanho) ?> KB tipo) ?> permission->checkPermission($this->session->userdata('permissao'), 'vArquivo')) : ?> - + permission->checkPermission($this->session->userdata('permissao'), 'eArquivo')) : ?> - + permission->checkPermission($this->session->userdata('permissao'), 'dArquivo')) : ?> diff --git a/application/views/arquivos/editarArquivo.php b/application/views/arquivos/editarArquivo.php index 76033ab86..69e385672 100644 --- a/application/views/arquivos/editarArquivo.php +++ b/application/views/arquivos/editarArquivo.php @@ -12,7 +12,7 @@
- +
diff --git a/application/views/auditoria/logs.php b/application/views/auditoria/logs.php index 87f4922b5..2a8bae751 100644 --- a/application/views/auditoria/logs.php +++ b/application/views/auditoria/logs.php @@ -29,11 +29,11 @@ '; - echo '' . $r->usuario . ''; + echo '' . esc($r->usuario) . ''; echo '' . date('d/m/Y', strtotime($r->data)) . ''; - echo '' . $r->hora . ''; - echo '' . $r->ip . ''; - echo '' . $r->tarefa . ''; + echo '' . esc($r->hora) . ''; + echo '' . esc($r->ip) . ''; + echo '' . esc($r->tarefa) . ''; echo ''; } ?> diff --git a/application/views/clientes/adicionarCliente.php b/application/views/clientes/adicionarCliente.php index 0fc1daa0c..7d64f0fbe 100644 --- a/application/views/clientes/adicionarCliente.php +++ b/application/views/clientes/adicionarCliente.php @@ -88,7 +88,7 @@ ' . $custom_error . '
'; } ?> - +
diff --git a/application/views/clientes/clientes.php b/application/views/clientes/clientes.php index 3e1ad8766..c7e344487 100644 --- a/application/views/clientes/clientes.php +++ b/application/views/clientes/clientes.php @@ -61,13 +61,13 @@ } foreach ($results as $r) { echo ''; - echo '' . $r->idClientes . ''; - echo '' . $r->nomeCliente . ''; - echo '' . $r->contato . ''; - echo '' . $r->documento . ''; - echo '' . $r->telefone . ''; - echo '' . $r->celular . ''; - echo '' . $r->email . ''; + echo '' . esc($r->idClientes) . ''; + echo '' . esc($r->nomeCliente) . ''; + echo '' . esc($r->contato) . ''; + echo '' . esc($r->documento) . ''; + echo '' . esc($r->telefone) . ''; + echo '' . esc($r->celular) . ''; + echo '' . esc($r->email) . ''; // Verifica se é Fornecedor ou Cliente if ($r->fornecedor == 1) { @@ -78,14 +78,14 @@ echo ''; if ($this->permission->checkPermission($this->session->userdata('permissao'), 'vCliente')) { - echo ''; - echo ''; + echo ''; + echo ''; } if ($this->permission->checkPermission($this->session->userdata('permissao'), 'eCliente')) { - echo ''; + echo ''; } if ($this->permission->checkPermission($this->session->userdata('permissao'), 'dCliente')) { - echo ''; + echo ''; } echo ''; echo ''; diff --git a/application/views/clientes/editarCliente.php b/application/views/clientes/editarCliente.php index 1d11a1385..c6d212844 100644 --- a/application/views/clientes/editarCliente.php +++ b/application/views/clientes/editarCliente.php @@ -88,7 +88,7 @@ ' . $custom_error . '
'; } ?> - +
diff --git a/application/views/clientes/visualizar.php b/application/views/clientes/visualizar.php index da37749a4..303a9ac42 100644 --- a/application/views/clientes/visualizar.php +++ b/application/views/clientes/visualizar.php @@ -189,18 +189,18 @@ $dataInicial = date(('d/m/Y'), strtotime($r->dataInicial)); $dataFinal = date(('d/m/Y'), strtotime($r->dataFinal)); echo ''; - echo '' . $r->idOs . ''; - echo '' . $dataInicial . ''; - echo '' . $dataFinal . ''; + echo '' . esc($r->idOs) . ''; + echo '' . esc($dataInicial) . ''; + echo '' . esc($dataFinal) . ''; echo '' . printSafeHtml($r->descricaoProduto) . ''; echo '' . printSafeHtml($r->defeito) . ''; echo ''; if ($this->permission->checkPermission($this->session->userdata('permissao'), 'vOs')) { - echo ''; + echo ''; } if ($this->permission->checkPermission($this->session->userdata('permissao'), 'eOs')) { - echo ''; + echo ''; } echo ''; @@ -254,17 +254,17 @@ $faturado = 'Não'; } echo ''; - echo '' . $r->idVendas . ''; - echo '' . $dataVenda . ''; - echo '' . $faturado . ''; - echo 'R$' . $r->valorTotal. ''; + echo '' . esc($r->idVendas) . ''; + echo '' . esc($dataVenda) . ''; + echo '' . esc($faturado) . ''; + echo 'R$' . esc($r->valorTotal). ''; echo ''; if ($this->permission->checkPermission($this->session->userdata('permissao'), 'vOs')) { - echo ''; + echo ''; } if ($this->permission->checkPermission($this->session->userdata('permissao'), 'eOs')) { - echo ''; + echo ''; } echo ''; echo ''; @@ -279,7 +279,7 @@