diff --git a/.claude/skills/verificar-local/SKILL.md b/.claude/skills/verificar-local/SKILL.md index 76a4bd1..cd689eb 100644 --- a/.claude/skills/verificar-local/SKILL.md +++ b/.claude/skills/verificar-local/SKILL.md @@ -14,11 +14,13 @@ Prefixe todo comando com `MSYS_NO_PATHCONV=1`. Sem isso o Git Bash converte `/ap ## Ciclo rápido (containers já de pé) ```bash -MSYS_NO_PATHCONV=1 docker cp . tk-ruby:/app && MSYS_NO_PATHCONV=1 docker exec -e RAILS_ENV=test -e DB_HOST=tk-pg -e DB_USERNAME=postgres -e DB_PASSWORD=postgres -e DB_NAME=task_keeper_api_test -e SECRET_KEY_BASE=dummy tk-ruby bash -c 'cd /app && bundle lock --add-platform x86_64-linux >/dev/null && bundle exec rubocop --format simple | tail -2 && bundle exec rspec 2>&1 | tail -4' +MSYS_NO_PATHCONV=1 docker cp . tk-ruby:/app && MSYS_NO_PATHCONV=1 docker exec -e RAILS_ENV=test -e DB_HOST=tk-pg -e DB_USERNAME=postgres -e DB_PASSWORD=postgres -e DB_NAME=task_keeper_api_test -e SECRET_KEY_BASE=dummy -e CHROME_BIN=/usr/bin/chromium tk-ruby bash -c 'cd /app && bundle lock --add-platform x86_64-linux >/dev/null && bundle exec rubocop --format simple | tail -2 && bundle exec rspec 2>&1 | tail -4' ``` Leva ~20s. O `add-platform` está ali porque o `docker cp` sobrescreve o `Gemfile.lock` do container com o do worktree, que não tem a plataforma Linux. As gems ficam em `/usr/local/bundle`, fora do `/app`, então recopiar o código não obriga a reinstalar nada. +`CHROME_BIN` aponta os system specs (`spec/system`) para o Chromium do Debian — no runner do GitHub o binário é o `google-chrome`, que o Selenium acha sozinho, mas aqui não existe com esse nome. Sem a variável **a suíte inteira falha**, não só os cinco system specs: eles fazem parte do `bundle exec rspec` normal, de propósito, para que "rodar os testes" signifique a mesma coisa aqui e no CI. + Rodou uma migration? Acrescente `bin/rails db:migrate` antes do RSpec, senão o `maintain_test_schema!` recarrega o schema antigo e o RSpec aborta com "Migrations are pending". **`docker cp` acrescenta e sobrescreve, mas nunca apaga.** Um arquivo removido aqui continua existindo no container e segue sendo lintado e executado — dá para ver um RuboCop vermelho ou uma contagem de exemplos maior que a real, por causa de um arquivo que não existe mais. Se os números não baterem, compare a contagem (`bundle exec rubocop` diz quantos arquivos inspecionou) e remova o resíduo com `docker exec tk-ruby rm -f /app/`. @@ -32,12 +34,12 @@ MSYS_NO_PATHCONV=1 docker network create tk-test; MSYS_NO_PATHCONV=1 docker run Depois, dentro do `tk-ruby`: copie o código, remova o `/app/.git` (é um arquivo de worktree apontando para um caminho Windows que não existe lá), instale `build-essential` e `libpq-dev`, fixe o Bundler na versão do `Gemfile.lock` (`BUNDLED WITH`) e rode `bundle install`. ```bash -MSYS_NO_PATHCONV=1 docker cp . tk-ruby:/app && MSYS_NO_PATHCONV=1 docker exec tk-ruby bash -c 'rm -f /app/.git && apt-get update -qq && apt-get install -y --no-install-recommends build-essential libpq-dev >/dev/null 2>&1 && gem install bundler -v 4.0.18 --no-document >/dev/null && cd /app && bundle lock --add-platform x86_64-linux >/dev/null && bundle install --jobs 4 --retry 3 2>&1 | tail -2' +MSYS_NO_PATHCONV=1 docker cp . tk-ruby:/app && MSYS_NO_PATHCONV=1 docker exec tk-ruby bash -c 'rm -f /app/.git && apt-get update -qq && apt-get install -y --no-install-recommends build-essential libpq-dev chromium chromium-driver >/dev/null 2>&1 && gem install bundler -v 4.0.18 --no-document >/dev/null && cd /app && bundle lock --add-platform x86_64-linux >/dev/null && bundle install --jobs 4 --retry 3 2>&1 | tail -2' ``` ## Verificação no navegador -Obrigatória para mudança em JS, CSS, HAML ou CSP — o `Gemfile` não tem driver Capybara/JS, então nada disso tem spec. +Obrigatória para mudança em JS, CSS, HAML ou CSP. Os system specs cobrem CSP e o alto contraste sobrevivendo ao Turbo — mas tour guiado, dropdown de busca, busca por voz e o widget do VLibras continuam sem spec, e são justamente os que só o olho pega. Congele a imagem com as gems e suba a app numa porta publicada: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 166beab..3ce883f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,6 +82,11 @@ jobs: - name: Zeitwerk check (eager load isolado, fora do processo do RSpec) run: bin/rails zeitwerk:check + # Inclui os system specs (spec/system, Capybara + Chrome headless). + # Não há passo de instalação de navegador porque a imagem do runner + # já traz o Google Chrome, e o Selenium o encontra sozinho — o + # container de verificação local, que usa o Chromium do Debian, + # precisa de CHROME_BIN (ver a skill verificar-local). - name: RSpec run: bundle exec rspec diff --git a/CLAUDE.md b/CLAUDE.md index 17399b0..fc2502f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,9 +34,14 @@ Na ordem, todos dentro do container: ## O que os specs não cobrem -O `Gemfile` não tem driver Capybara/JS. Nada que dependa de JavaScript no navegador tem teste automatizado: barra de acessibilidade, tour guiado, dropdown de busca, widget do VLibras, e o CSP inteiro. +Existem cinco system specs (`spec/system`, Capybara + Chrome headless) e eles cobrem só o que falha em **silêncio**: violação de CSP em página pública, autenticada e depois de uma navegação do Turbo; o alto contraste sobrevivendo ao Turbo; e a confirmação do Turbo ao excluir. -Se você mexeu nessas áreas, suba a app e verifique de verdade — console sem erros, elemento aparecendo, clique funcionando, **e o mesmo depois de uma navegação do Turbo Drive**. O CSP deste projeto foi corrigido duas vezes por causa de coisas que só o navegador mostrou, e nenhuma delas quebraria um spec. +Continua sem teste: tour guiado, dropdown de busca, busca por voz e o widget do VLibras. Se você mexeu nisso — ou em qualquer JS, CSS, HAML ou CSP —, suba a app e verifique de verdade: console sem erros, elemento aparecendo, clique funcionando, **e o mesmo depois de uma navegação do Turbo Drive**. + +Dois detalhes que mordem ao escrever system spec aqui: + +- **o container de verificação precisa de Chromium** (`apt-get install -y chromium chromium-driver` + `CHROME_BIN=/usr/bin/chromium`). Sem ele `bundle exec rspec` falha inteiro, não só os system specs — ver a skill `verificar-local`; +- **o tour guiado dispara sozinho** no painel de quem tem `tour_completed_at` nulo, e o overlay intercepta todo clique. Use `create(:user, ..., tour_completed_at: 1.day.ago)` nos specs que precisam clicar. ## Armadilhas deste repositório diff --git a/Gemfile b/Gemfile index 1701572..8326be3 100644 --- a/Gemfile +++ b/Gemfile @@ -71,4 +71,19 @@ end group :test do gem 'rails-controller-testing' + + # Cobertura de navegador (ver spec/system e o README, seção "Testes"). + # Até aqui nada que dependesse de JavaScript tinha teste — e o CSP deste + # projeto precisou ser corrigido DUAS vezes por comportamento que só + # aparece num navegador de verdade (o widget do VLibras e o nonce por + # requisição quebrando o Turbo Drive). Nenhuma das duas quebraria um + # spec de request. + # + # É a única dependência pesada admitida no projeto, e entra só no grupo + # de teste: não vai para a imagem de produção (BUNDLE_WITHOUT no + # Dockerfile). selenium-webdriver em vez de cuprite/ferrum por ser o + # driver padrão dos system tests do Rails — o Chrome já vem instalado no + # runner do GitHub, então não exige pacote de sistema no CI. + gem 'capybara' + gem 'selenium-webdriver' end diff --git a/Gemfile.lock b/Gemfile.lock index 3078ceb..9204a03 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -75,6 +75,8 @@ GEM securerandom (>= 0.3) tzinfo (~> 2.0, >= 2.0.5) uri (>= 0.13.1) + addressable (2.9.0) + public_suffix (>= 2.0.2, < 8.0) ast (2.4.3) base64 (0.3.0) bcrypt (3.1.22) @@ -84,6 +86,15 @@ GEM msgpack (~> 1.5) builder (3.3.0) cancancan (3.6.1) + capybara (3.40.0) + addressable + matrix + mini_mime (>= 0.1.3) + nokogiri (~> 1.11) + rack (>= 1.6.0) + rack-test (>= 0.6.3) + regexp_parser (>= 1.5, < 3.0) + xpath (~> 3.2) concurrent-ruby (1.3.8) connection_pool (3.0.2) crass (1.0.7) @@ -190,6 +201,7 @@ GEM actionpack (>= 7.0.0) activesupport (>= 7.0.0) rack + public_suffix (7.0.5) puma (8.0.2) nio4r (~> 2.0) raabro (1.5.0) @@ -257,6 +269,7 @@ GEM responders (3.2.0) actionpack (>= 7.0) railties (>= 7.0) + rexml (3.4.4) rspec-core (3.13.6) rspec-support (~> 3.13.0) rspec-expectations (3.13.5) @@ -299,7 +312,14 @@ GEM regexp_parser (>= 2.0) rubocop (~> 1.86, >= 1.86.2) ruby-progressbar (1.13.0) + rubyzip (3.6.0) securerandom (0.4.1) + selenium-webdriver (4.48.0) + base64 (~> 0.2) + logger (~> 1.4) + rexml (~> 3.2, >= 3.2.5) + rubyzip (>= 1.2.2, < 4.0) + websocket (~> 1.0) shoulda-matchers (8.0.1) activesupport (>= 7.2) solid_queue (1.7.0) @@ -335,10 +355,13 @@ GEM actionview (>= 8.0.0) bindex (>= 0.4.0) railties (>= 8.0.0) + websocket (1.2.11) websocket-driver (0.8.2) base64 websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) + xpath (3.2.0) + nokogiri (~> 1.8) zeitwerk (2.8.3) PLATFORMS @@ -348,6 +371,7 @@ PLATFORMS DEPENDENCIES bootsnap cancancan + capybara debug devise factory_bot_rails @@ -366,6 +390,7 @@ DEPENDENCIES rubocop rubocop-rails rubocop-rspec + selenium-webdriver shoulda-matchers solid_queue stimulus-rails @@ -386,6 +411,7 @@ CHECKSUMS activerecord (8.1.3.1) sha256=0a2fb6c28f4938f6b013a3a549bec0a7e37d535f3dc8990e804bcc3258c0403b activestorage (8.1.3.1) sha256=f555254f387b1cffa499d2fd3115d12635eadc5b15206a8534316a67036163ef activesupport (8.1.3.1) sha256=85458765f25ea48b9019c46b6bb3fa5683197bf4280d9f06710a6e8d7a831376 + addressable (2.9.0) ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b bcrypt (3.1.22) sha256=1f0072e88c2d705d94aff7f2c5cb02eb3f1ec4b8368671e19112527489f29032 @@ -395,6 +421,7 @@ CHECKSUMS builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f bundler (4.0.18) sha256=02d9a17429de1847b4e0c9f27a9ee4b20c0a74c0a641b4e77195d6019e3618ac cancancan (3.6.1) sha256=975c1d5cbf58d5df48a9452a7f61ae3d254608cd87570402f5925a8864c56b62 + capybara (3.40.0) concurrent-ruby (1.3.8) sha256=b2f1be836e968ccc78ccfce277ea79c72a88633f22306782c16ff23fb415d1e1 connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a crass (1.0.7) sha256=94868719948664c89ddcaf0a37c65048413dfcb1c869470a5f7a7ceb5390b295 @@ -446,6 +473,7 @@ CHECKSUMS prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193 prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 propshaft (1.3.2) sha256=1d56a3e56a92c21bfc29caf07406b5386b00d4c47ddf357cf989a5a234b1389e + public_suffix (7.0.5) puma (8.0.2) sha256=c8ed871dfbbe66448ea9ffd46692342d9804d4071522b52b5331b7b6e7b686fb raabro (1.5.0) sha256=3f998a7bc84f9c84df3ab580634d2e0a5bda4f0841168d56035f529c9877440a racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f @@ -466,6 +494,7 @@ CHECKSUMS regexp_parser (2.12.0) sha256=35a916a1d63190ab5c9009457136ae5f3c0c7512d60291d0d1378ba18ce08ebb reline (0.7.0) sha256=5b012d8e55dbf9d450f12bde2cf7d15ff546ae80b3f8f3b30e570d431815583d responders (3.2.0) sha256=89c2d6ac0ae16f6458a11524cae4a8efdceba1a3baea164d28ee9046bd3df55a + rexml (3.4.4) rspec-core (3.13.6) sha256=a8823c6411667b60a8bca135364351dda34cd55e44ff94c4be4633b37d828b2d rspec-expectations (3.13.5) sha256=33a4d3a1d95060aea4c94e9f237030a8f9eae5615e9bd85718fe3a09e4b58836 rspec-mocks (3.13.8) sha256=086ad3d3d17533f4237643de0b5c42f04b66348c28bf6b9c2d3f4a3b01af1d47 @@ -476,7 +505,9 @@ CHECKSUMS rubocop-rails (2.37.0) sha256=6e1645add5060e0328f8ddda0d820f55697c591394398bf14bb9dccb62f14b7e rubocop-rspec (3.10.2) sha256=0b3e2ecc592cd10ecbf0095bb58d1e357905276e069643523cc19eb7495f65e2 ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 + rubyzip (3.6.0) securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 + selenium-webdriver (4.48.0) shoulda-matchers (8.0.1) sha256=5dbb46e5765b9da225111b085e0819e8c8a121ff94bba430a153eb1ea2c60288 solid_queue (1.7.0) sha256=6566b70b801d1c317c81bba7bcdd5677c019afac584a30374b4164002ca356d3 stimulus-rails (1.3.4) sha256=765676ffa1f33af64ce026d26b48e8ffb2e0b94e0f50e9119e11d6107d67cb06 @@ -495,8 +526,10 @@ CHECKSUMS useragent (0.16.11) sha256=700e6413ad4bb954bb63547fa098dddf7b0ebe75b40cc6f93b8d54255b173844 warden (1.2.9) sha256=46684f885d35a69dbb883deabf85a222c8e427a957804719e143005df7a1efd0 web-console (4.3.0) sha256=e13b71301cdfc2093f155b5aa3a622db80b4672d1f2f713119cc7ec7ac6a6da4 + websocket (1.2.11) websocket-driver (0.8.2) sha256=97c556b019bf3410b4961002ac501621e9322d3f8a7bc02161a09301cc4c4146 websocket-extensions (0.1.5) sha256=1c6ba63092cda343eb53fc657110c71c754c56484aad42578495227d717a8241 + xpath (3.2.0) zeitwerk (2.8.3) sha256=2c85125a8467ce069e20123d1e709a08955c9d29c118c25b46b7b7fafdbb92e5 RUBY VERSION diff --git a/README.md b/README.md index f2b195f..b062117 100644 --- a/README.md +++ b/README.md @@ -215,7 +215,7 @@ A configuração vive em três arquivos versionados: O CI é a fonte de verdade — roda em Linux, com as versões travadas. Mas descobrir uma ofensa de estilo só depois do push custa um commit extra e um ciclo de dois minutos; foi o que aconteceu duas vezes antes deste fluxo existir. O container encurta o laço sem substituir o CI. -Ele também permite o que o CI não faz: **subir a aplicação e olhar no navegador**. O `Gemfile` não tem driver Capybara/JS, então nada que dependa de JavaScript tem teste — barra de acessibilidade, tour guiado, busca, widget do VLibras e o CSP inteiro. A política de CSP deste projeto foi corrigida **duas vezes** por causa de comportamento que só apareceu num navegador de verdade, e nenhuma das duas quebraria um spec. +Ele também permite o que o CI não faz: **subir a aplicação e olhar no navegador**. Existe hoje uma cobertura automatizada de navegador (`spec/system`, ver "Testes"), mas ela é pequena de propósito e cobre a classe de bug que já mordeu — CSP e o JS da aplicação sobrevivendo ao Turbo. Fora disso continua sem teste: tour guiado, dropdown de busca, busca por voz e o widget do VLibras. Olhar de verdade continua valendo para mudança em JS, CSS, HAML ou CSP. ### O hook @@ -258,7 +258,23 @@ Cenários validados explicitamente: - a API rejeita com `415` qualquer `POST`/`PATCH`/`DELETE` sem `Content-Type: application/json` (proteção contra CSRF — ver seção "Endpoints principais"), sem afetar `GET`; - ao tentar acessar uma tela protegida sem login, ou ao errar e-mail/senha, a mensagem aparece traduzida em pt-BR (não `Translation missing` — ver seção "Mensagens em pt-BR"). -O que **não** tem cobertura automatizada, e por quê: interações que são só JavaScript/CSS (tamanho de fonte, alto contraste, o tooltip de ajuda do Chat ID) não têm teste, porque o `Gemfile` não inclui um driver Capybara/JS — foram verificadas manualmente (incluindo screenshots) antes de cada merge. +### Navegador (`spec/system`) + +Capybara + Chrome headless, e deliberadamente **poucos** exemplos: eles não replicam o que os request specs já cobrem, cobrem os lugares onde a falha é **silenciosa** — a página renderiza, o servidor responde `200`, e só o navegador sabe que algo foi bloqueado ou deixou de rodar. + +| Exemplo | O que quebraria sem ele | +|---|---| +| página pública sem violação de CSP | uma diretiva nova bloqueando um recurso legítimo, sem sintoma no servidor | +| página autenticada sem violação de CSP | idem, nas telas com importmap e widget do VLibras | +| **sem violação depois de uma navegação do Turbo Drive** | o bug que já aconteceu: nonce por requisição contra um CSP que o Turbo mantém em vigor da primeira resposta | +| alto contraste sobrevive à navegação do Turbo | `application.js` não executando — nonce do importmap recusado deixa a página bonita e inerte | +| confirmação do Turbo ao excluir | `data-turbo-confirm` deixando de funcionar, e a exclusão passando a acontecer sem perguntar | + +Violação de CSP não levanta erro nem quebra a renderização: o navegador recusa o recurso e escreve no console. Por isso o driver liga `goog:loggingPrefs` e os exemplos leem o log — sem isso veriam uma página aparentemente perfeita, que é exatamente como as duas quebras anteriores passaram despercebidas. + +O filtro é pelo texto `Content Security Policy` do próprio navegador, e não "console limpo": o layout carrega Bootstrap, fontes do Google e o widget do VLibras de CDNs externos, e uma falha de rede do runner vira `Failed to load resource` — ruído que não é o que estes exemplos verificam. + +O que **continua** sem cobertura automatizada: tour guiado, dropdown de busca, busca por voz e o widget do VLibras em si. São verificados à mão (incluindo screenshots) quando mudam. ## Endpoints principais diff --git a/app/assets/images/icon.svg b/app/assets/images/icon.svg new file mode 100644 index 0000000..a2257dc --- /dev/null +++ b/app/assets/images/icon.svg @@ -0,0 +1,13 @@ + + Task Keeper API + + + + + + + + + + diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index c7721e7..0cd1a21 100644 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -4,6 +4,13 @@ %meta{ charset: "utf-8" } %meta{ name: "viewport", content: "width=device-width, initial-scale=1" } %title= content_for?(:page_title) ? "#{yield(:page_title)} · Task Keeper API" : "Task Keeper API" + -# Sem um declarado, todo navegador pede /favicon.ico + -# por conta própria — e como não existe rota para isso, cada primeira + -# visita virava um ActionController::RoutingError no log. Ninguém tinha + -# percebido porque nenhum teste usava um navegador de verdade; o + -# primeiro system spec (spec/system) falhou exatamente nisso. + -# Declarando o ícone, o navegador usa este e não pede o .ico. + %link{ rel: "icon", href: asset_path("icon.svg"), type: "image/svg+xml" } %link{ rel: "preconnect", href: "https://fonts.googleapis.com" } %link{ rel: "preconnect", href: "https://fonts.gstatic.com", crossorigin: "anonymous" } %link{ rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=Kanit:wght@300;400;500;600;700&family=Open+Sans:wght@300;400;600&display=swap" } diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 29afbc5..7cff093 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -8,6 +8,11 @@ require 'rspec/rails' require 'cancan/matchers' +# Explícito, e não um Dir[] varrendo spec/support: só há dois arquivos +# ali, e um require nomeado diz de onde vem o driver dos system specs +# (ver o comentário em spec/support/factory_bot.rb sobre o glob que este +# projeto optou por não usar). +require_relative 'support/system' begin ActiveRecord::Migration.maintain_test_schema! @@ -24,6 +29,21 @@ config.include FactoryBot::Syntax::Methods config.include Devise::Test::IntegrationHelpers, type: :request + # Nos system specs o navegador é um cliente HTTP de verdade, então os + # helpers de request do Devise não valem — quem autentica é o Warden em + # modo de teste, que injeta a sessão no servidor que a Capybara sobe no + # mesmo processo. É o que evita ter que preencher o formulário de login + # em todo spec só para chegar na tela que interessa. + config.include Warden::Test::Helpers, type: :system + config.include ConsoleDoNavegador, type: :system + + config.before(type: :system) do + driven_by :chrome_headless + Warden.test_mode! + end + + config.after(type: :system) { Warden.test_reset! } + # O throttle das telas de autenticação (ver AuthThrottling) guarda o # contador de tentativas no cache, que em teste é :memory_store e # sobrevive entre exemplos. Sem limpar, um spec que faz vários POST em diff --git a/spec/support/system.rb b/spec/support/system.rb new file mode 100644 index 0000000..4082dff --- /dev/null +++ b/spec/support/system.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require 'capybara/rspec' +require 'selenium-webdriver' + +# Driver dos system specs (ver spec/system). Headless de propósito: a +# suíte roda em container e em CI, onde não há display — e um navegador +# visível não acrescenta nada a uma verificação automatizada. +Capybara.register_driver :chrome_headless do |app| + options = Selenium::WebDriver::Chrome::Options.new + + options.add_argument('--headless=new') + options.add_argument('--window-size=1400,1400') + # Necessários dentro de container: sem --no-sandbox o Chrome não sobe + # como root, e /dev/shm no Docker é pequeno demais para o navegador. + options.add_argument('--no-sandbox') + options.add_argument('--disable-dev-shm-usage') + + # A parte que faz o teste de CSP existir. Uma violação de CSP não + # levanta erro nem quebra a renderização: o navegador só recusa o + # recurso e escreve no console. Sem pedir o log do console, o spec + # veria uma página aparentemente perfeita — que é exatamente como as + # duas quebras anteriores passaram despercebidas. + options.add_option('goog:loggingPrefs', { browser: 'ALL' }) + + # CHROME_BIN existe para o container de verificação local, onde o + # binário é o "chromium" do Debian e não o "google-chrome" que o + # runner do GitHub traz pronto. + chrome_bin = ENV.fetch('CHROME_BIN', nil) + options.binary = chrome_bin if chrome_bin.present? + + Capybara::Selenium::Driver.new(app, browser: :chrome, options: options) +end + +# Ajuda dos system specs a olharem o console do navegador. +module ConsoleDoNavegador + # Violações de CSP e erros de JavaScript aparecem os dois como entrada + # de console, e a distinção importa: a rede do runner pode falhar ao + # buscar um CDN externo (o layout carrega Bootstrap, fontes do Google e + # o widget do VLibras), e isso vira "Failed to load resource", não + # violação de política. Filtrar pelo texto do próprio navegador mantém + # o spec olhando só para o que ele se propõe a verificar. + MARCA_DE_VIOLACAO = 'Content Security Policy' + + # O log do Chrome é drenado a cada leitura: chamar isto duas vezes + # devolve só o que apareceu desde a última chamada. + def violacoes_de_csp + page.driver.browser.logs.get(:browser) + .map(&:message) + .select { |mensagem| mensagem.include?(MARCA_DE_VIOLACAO) } + end +end diff --git a/spec/system/navegador_spec.rb b/spec/system/navegador_spec.rb new file mode 100644 index 0000000..10a9b52 --- /dev/null +++ b/spec/system/navegador_spec.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +require 'rails_helper' + +# A única cobertura de navegador do projeto, e ela é deliberadamente +# pequena: não replica o que os request specs já verificam. Cobre os +# lugares onde a falha é SILENCIOSA — a página continua renderizando, o +# servidor continua respondendo 200, e só o navegador sabe que algo foi +# bloqueado ou deixou de rodar. +# +# O custo de não ter isto não é teórico. A CSP introduzida na v2.0.0 +# precisou ser corrigida duas vezes por comportamento que só aparece num +# navegador de verdade: o widget do VLibras carregando imagem de outro +# CDN, e o nonce por requisição quebrando a navegação do Turbo Drive. +# Nenhuma das duas quebraria um spec de request. +RSpec.describe 'Navegador', type: :system do + # tour_completed_at preenchido de propósito: o tour guiado dispara + # sozinho no painel de quem nunca o completou (ver + # app/views/dashboard/index.html.haml), e o overlay dele cobre a tela + # inteira, interceptando qualquer clique. Quem estes exemplos + # representam é o usuário do dia a dia, que já passou por ele. + # + # Isso só ficou visível aqui: com um navegador de verdade o overlay + # existe e intercepta; num request spec ele é só markup no HTML. + let(:usuario) { create(:user, :lider, tour_completed_at: 1.day.ago) } + + describe 'política de CSP' do + it 'carrega a página pública sem nenhuma violação' do + visit acessibilidade_path + + expect(page).to have_css('h1') + expect(violacoes_de_csp).to be_empty + end + + it 'carrega uma página autenticada sem nenhuma violação' do + login_as(usuario, scope: :user) + + visit root_path + + expect(page).to have_css('.tk-a11y-bar') + expect(violacoes_de_csp).to be_empty + end + + # O caso que já quebrou. O Turbo troca o sem criar documento + # novo, então continua valendo o CSP da PRIMEIRA resposta — um nonce + # diferente na segunda página é bloqueado por uma política que não o + # conhece. Ver o comentário em config/initializers/content_security_policy.rb. + it 'continua sem violações depois de uma navegação do Turbo Drive' do + login_as(usuario, scope: :user) + visit root_path + violacoes_de_csp # drena o que veio da primeira página + + click_link 'Demandas' + + expect(page).to have_current_path(demandas_path) + expect(violacoes_de_csp).to be_empty + end + end + + # Estes dois não olham o console: olham o efeito. Se o nonce do + # importmap fosse recusado, application.js não rodaria e nada abaixo + # aconteceria — a página continuaria bonita e inerte, que é o modo de + # falha que um request spec não enxerga. + describe 'JavaScript da aplicação' do + before { login_as(usuario, scope: :user) } + + it 'alterna o alto contraste, e o estado sobrevive à navegação do Turbo' do + visit root_path + + find('#tk-contrast-toggle').click + expect(page).to have_css('html.tk-high-contrast') + + click_link 'Demandas' + + expect(page).to have_current_path(demandas_path) + # O não é recriado pelo Turbo, mas o botão é — e é o + # application.js que precisa reaplicar o estado guardado. + expect(page).to have_css('html.tk-high-contrast') + expect(page).to have_css('#tk-contrast-toggle[aria-pressed="true"]') + end + + it 'pede confirmação do Turbo antes de excluir uma demanda' do + demanda = create(:demanda, title: 'Demanda a excluir') + visit demandas_path + + accept_confirm('Tem certeza que deseja excluir esta demanda?') do + find("form[action='#{demanda_path(demanda)}'] button").click + end + + expect(page).to have_content('Demanda excluída com sucesso.') + expect(Demanda.exists?(demanda.id)).to be false + end + end +end