From c1d266a735e3b36220aa93a1c5ad42073bf0a1ea Mon Sep 17 00:00:00 2001 From: Luiz Carlos Date: Mon, 19 Oct 2026 12:43:57 -0300 Subject: [PATCH 1/2] docs: plan the development environment following a slot (LUI-184) Acceptance file docs/tickets/LUI-182/LUI-184/aceite.md (CA1-CA6). No route, contract or schema changes: the ticket changes the development Compose file, harness scripts and documents. The names of the slot variables and the port formula, which this ticket owns, are written in docs/lld.md, section 6, "Ambiente local por slot". Decision: the slot reaches the Compose file only through a root .env that Compose reads by itself, with the same file and command. The .env carries the four ports already computed (WEB_PORT, API_PORT, STORAGE_PORT, MAILPIT_PORT), because Compose interpolation cannot do arithmetic; the formula (today's port plus slot x 10) lives in the LLD and in whoever writes the file. SLOT is in the file only to be read by the front script (LUI-186), and Compose ignores it. Every port has today's value as default, so no .env means slot 0. Decision: the three addresses the API and the browser need (WEB_ORIGIN, STORAGE_PUBLIC_URL, STORAGE_EMULATOR_HOST) are passed through without a default (an environment entry with no value) to the api service, and STORAGE_PUBLIC_URL also to the playwright service. A default in the Compose file would make the variables exist at slot 0 and the resolved configuration would stop being the one of today (CA2); passed through, they are absent without the .env, and the API keeps using apps/api/.env. The process environment wins over apps/api/.env, so a slot value overrides the file. Verified with docker compose config: a pass-through variable reads from the .env and is null when the .env lacks it. Decision: the storage emulator listens inside its container on the slot port (-port and -public-host both from STORAGE_PORT, and the healthcheck too), as the spec says, so the browser rule of the tests (resolve localhost to the storage service, keeping the port) stays valid. The consequence is that the storage address between containers also follows the slot (STORAGE_EMULATOR_HOST, http://storage:4453 at slot 1). It is the only inter-container address that changes; web, API, mailpit and database addresses stay. Decision: the .git mask of the api container uses the long volume syntax with type and source from variables: type ${GIT_MASK_TYPE:-tmpfs} and source ${GIT_MASK_SOURCE:-}. Without the .env it resolves to the same read-only tmpfs of today; the worktree .env sets GIT_MASK_TYPE=bind and GIT_MASK_SOURCE=/dev/null, which covers the .git file with an empty one. Verified: Compose accepts a variable type and an empty source for tmpfs, and in a real worktree /dev/null bound over .git is an empty character device while the tmpfs does not mount over a file. Decision: the secret scan in a worktree sets GIT_DIR (the absolute git dir of the worktree) and GIT_WORK_TREE=/repo and mounts the common .git read-only at the same absolute path, with --mount type=bind,...,readonly. This corrects a premise of the spec: the .git file of a worktree can hold a relative path (worktree.useRelativePaths is on in the maintainer's Git config), and for a sibling worktree the relative path resolved from /repo points nowhere, so mounting the common dir alone is not enough (verified: gitleaks reads 0 commits and errors; with GIT_DIR it reads the commit and finds a planted key). Git picks the dir from GIT_DIR, so it works whatever the .git file says. The short -v src:dst:ro form of the same mount failed in this Docker Desktop, the --mount form works. Outside a worktree (.git is a directory) the command is the one of today. Decision: the argument building of the scan goes in a new scripts/git-mount.sh, sourced by scripts/verify.sh as full-lock.sh is, so a shell test can check the arguments for a repository and for a worktree without Docker. The scan itself (CA5) stays a human review with evidence in the PR. Decision: the tests of CA1 to CA3 are shell tests in scripts/test/dev-env-slot.test.sh, in the report with the @LUI-184-CA tag. They resolve the configuration with docker compose --env-file config --format json and always pass the file, so a .env in the checkout of whoever runs them never changes the result; no container starts. CA2 checks today's values (ports, storage entrypoint, healthcheck, tmpfs mask, no slot variable in the api and playwright), and at implementation the configuration resolved without a .env is also compared once with the one of this plan commit's Compose file. CA3 checks the mask in real Docker, with the postgres image the environment already pulled: the same read-only bind of the folder plus the mask the resolved configuration declares, over a .git directory and over a .git file, plus the default mask over a file as a control that fails to start. Decision: the other suites follow the slot, so the full suite passes at slot 1 (CA4). The API end-to-end test of the password recovery reads the web origin from the injected app configuration instead of the literal http://localhost:3000, and the two web end-to-end tests that expect the storage host localhost:4443 (the emulator proof and the download one) read it from STORAGE_PUBLIC_URL, with today's value as default. The mail links need no change: the web tests keep only the path of a link. Decision: CA4 and CA5 are human review, as the ticket says, with the commands and outputs in the PR. There is no front script yet, so the worktree and its .env are made by hand, following the LLD table. Each environment weighs about 2 GB of memory at rest. Files and interfaces the implementation will touch: - compose.dev.yaml: ports of web, api, storage and mailpit from WEB_PORT, API_PORT, STORAGE_PORT and MAILPIT_PORT; storage entrypoint and healthcheck from STORAGE_PORT; pass-through of WEB_ORIGIN, STORAGE_PUBLIC_URL and STORAGE_EMULATOR_HOST to api and of STORAGE_PUBLIC_URL to playwright; the .git mask from GIT_MASK_TYPE and GIT_MASK_SOURCE. - .gitignore: the root .env. - scripts/git-mount.sh (new): a function that prints the docker run arguments the secret scan needs for the current checkout, nothing extra for a .git directory, the common .git mount, GIT_DIR and GIT_WORK_TREE for a .git file. - scripts/verify.sh: secret_scan builds its docker run from that function. - scripts/test/dev-env-slot.test.sh (new): the tests of CA1 to CA3. - scripts/test/git-mount.test.sh (new): the arguments for a repository and for a worktree, without tag. - apps/api/test/auth-password-recovery.e2e-spec.ts: the web origin from the injected configuration. - apps/web/e2e/storage-emulator.spec.ts, apps/web/e2e/download.spec.ts and apps/web/e2e/support/storage.ts: the storage public host from STORAGE_PUBLIC_URL, default http://localhost:4443. - apps/api/.env.example: a comment saying that in a slot the Compose file overrides WEB_ORIGIN, STORAGE_PUBLIC_URL and STORAGE_EMULATOR_HOST. - docs/lld.md: the section of the storage emulator and the variable table point to the slot (the slot contract of section 6 is already in the plan). - AGENTS.md (root), apps/api/AGENTS.md, apps/web/AGENTS.md and README.md: the address table of the root is the one of slot 0, and the others point to the slot address where they cite localhost with a fixed port. Existing tests whose behavior changes: the password recovery end-to-end test of the API (origin from the configuration), and the emulator and download tests of the web (host from the variable); all keep their result at slot 0. Data changes: none. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01ANcDtWf5vRMzv1r42Ax9ce --- docs/lld.md | 20 +++++++++++ docs/tickets/LUI-182/LUI-184/aceite.md | 49 ++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 docs/tickets/LUI-182/LUI-184/aceite.md diff --git a/docs/lld.md b/docs/lld.md index ee46db2..8586f18 100644 --- a/docs/lld.md +++ b/docs/lld.md @@ -645,6 +645,26 @@ A retenção da lixeira, o prazo de upload pendente, a validade dos tokens, a to **Banco de testes:** os testes da API usam um banco separado no mesmo PostgreSQL, com o nome do banco de `DATABASE_URL` mais o sufixo `_test`. Ele não tem variável própria. +**Ambiente local por slot:** cada checkout tem um **slot**, de 0 a 3, que dá ao ambiente de desenvolvimento um bloco próprio de portas na máquina, para vários ambientes ficarem no ar juntos. O slot vem de um `.env` na raiz do checkout, que o Git ignora e que o Compose lê sozinho, com o mesmo `compose.dev.yaml` e o mesmo comando. Sem o arquivo, vale o slot 0, as portas de hoje, e é assim que ficam o checkout principal e a CI. O `.env` da raiz é do Compose e não é o `apps/api/.env`: o Compose passa à API só o que a tabela abaixo manda. + +- **Fórmula:** a porta de hoje mais slot × 10, para a web, a API, o storage e os e-mails. Os endereços entre contêineres não mudam, com uma exceção: o emulador de storage escuta, dentro do contêiner, na porta do slot, e por isso o endereço dele entre contêineres segue o slot (`STORAGE_EMULATOR_HOST`). +- **Dono:** este documento decide os nomes e a fórmula. Quem grava o `.env` (o script de frentes, ou a pessoa à mão) só os segue. +- **Sem padrão na passagem:** as variáveis da API e do `playwright` abaixo não têm valor padrão no Compose: sem o `.env`, elas não existem no ambiente do serviço, e a API usa o `apps/api/.env`, como hoje. Um padrão no Compose as faria existir no slot 0, e a configuração resolvida deixaria de ser igual à de antes do slot. + +| Variável do `.env` | Slot 0 (padrão do Compose) | Slot 1 | Quem lê | +| --- | --- | --- | --- | +| `SLOT` | não precisa existir | `1` | Só informa: o Compose não a lê, e o script de frentes a usa para achar o slot de cada pasta | +| `WEB_PORT` | 3000 | 3010 | Porta publicada da web | +| `API_PORT` | 3001 | 3011 | Porta publicada da API | +| `STORAGE_PORT` | 4443 | 4453 | Porta publicada do storage, e a em que o emulador escuta e se anuncia (`-port` e `-public-host`) | +| `MAILPIT_PORT` | 8025 | 8035 | Porta publicada da tela e da API HTTP dos e-mails | +| `WEB_ORIGIN` | ausente | `http://localhost:3010` | Passada à API, que a usa nos links dos e-mails e no CORS (tabela acima) | +| `STORAGE_PUBLIC_URL` | ausente | `http://localhost:4453` | Passada à API e ao `playwright` (tabela acima, e a seção 4.6) | +| `STORAGE_EMULATOR_HOST` | ausente | `http://storage:4453` | Passada à API (tabela acima, e a seção 4.6) | +| `GIT_MASK_TYPE`, `GIT_MASK_SOURCE` | `tmpfs` e vazio | `bind` e `/dev/null` | A máscara do `.git` no contêiner da API, abaixo | + +**Máscara do `.git`:** o contêiner da API monta o repositório só para leitura e cobre o `.git` por cima, para o histórico, os hooks e a configuração do Git ficarem fora do alcance do que roda nele. No checkout principal e na CI, o `.git` é uma pasta, e a máscara é um `tmpfs`. Num worktree, o `.git` é um arquivo, onde o `tmpfs` não monta e o contêiner não sobe: o `.env` do worktree traz `GIT_MASK_TYPE=bind` e `GIT_MASK_SOURCE=/dev/null`, que cobre o arquivo com um vazio. A varredura de segredos também precisa do histórico, que num worktree está no `.git` comum, fora da pasta dele: o `scripts/verify.sh` monta esse `.git` e aponta o `GIT_DIR` do Git para o do worktree, porque o arquivo `.git` pode trazer um caminho relativo, que no contêiner não leva a lugar nenhum. + Os valores marcados como segredo vêm do Secret Manager e não ficam em arquivos versionados. diff --git a/docs/tickets/LUI-182/LUI-184/aceite.md b/docs/tickets/LUI-182/LUI-184/aceite.md new file mode 100644 index 0000000..69e5ca1 --- /dev/null +++ b/docs/tickets/LUI-182/LUI-184/aceite.md @@ -0,0 +1,49 @@ +# Aceite do LUI-184 + +Ticket de harness: o ambiente de desenvolvimento passa a seguir um slot, lido de um `.env` na raiz do checkout. O CA1 a CA3 são provados por testes de unidade em shell, que rodam na máquina com o `docker compose config` e entram no relatório dos testes em shell com a tag `@LUI-184-CA`. O CA4 e o CA5 exigem dois ambientes e um worktree de verdade no ar, e são de revisão humana, com a evidência no PR. O CA6 é lido no diff. + +Os testes resolvem a configuração pelo `docker compose -f compose.dev.yaml --env-file config --format json`, sempre com o `--env-file` explícito, para o resultado não depender de um `.env` que exista no checkout de quem roda. Nenhum contêiner sobe. O `.env` do slot 1 que os testes usam é o do contrato que a LLD descreve (seção 6, "Ambiente local por slot"): as quatro portas (`WEB_PORT`, `API_PORT`, `STORAGE_PORT`, `MAILPIT_PORT`), os três endereços que a API e o `playwright` leem (`WEB_ORIGIN`, `STORAGE_PUBLIC_URL`, `STORAGE_EMULATOR_HOST`) e a máscara do `.git` (`GIT_MASK_TYPE` e `GIT_MASK_SOURCE`). O `SLOT` também vai no arquivo, mas só informa: o compose não o lê. + +"Igual à de hoje" (CA2) é conferido pelos valores que o ambiente tem hoje: as portas publicadas, a linha do emulador de storage, o endereço do healthcheck dele, a máscara em `tmpfs` e a ausência das variáveis do slot na API e no `playwright`. Na implementação, a configuração resolvida sem `.env` também é comparada, uma vez, com a do compose do commit de plano, para a diferença que sobrar ser só o que o ticket acrescenta. + +## LUI-184-CA1 + +**Marca:** teste de unidade + +**Cenário:** Dado o `.env` do slot 1 (portas 3010, 3011, 4453 e 8035, os endereços `http://localhost:3010`, `http://localhost:4453` e `http://storage:4453`, e a máscara por arquivo), quando o compose resolve a configuração, então a web publica a 3010 na 3000 do contêiner, a API a 3011 na 3000, o storage a 4453 na 4453 e os e-mails a 8035 na 8025; o emulador de storage inicia com `-port 4453` e `-public-host localhost:4453` e o healthcheck dele confere `127.0.0.1:4453`; a API recebe `WEB_ORIGIN`, `STORAGE_PUBLIC_URL` e `STORAGE_EMULATOR_HOST` do slot, e o `playwright` recebe `STORAGE_PUBLIC_URL`; e os endereços entre contêineres que não são do storage (`http://api:3000`, `http://web:3000`, `http://mailpit:8025`, o banco) seguem como hoje. + +**Teste previsto:** testes dos scripts do harness, `scripts/test/dev-env-slot.test.sh`, "o `.env` do slot 1 move as portas e os endereços do ambiente". + +## LUI-184-CA2 + +**Marca:** teste de unidade + +**Cenário:** Dado um arquivo de ambiente vazio, quando o compose resolve a configuração, então as portas publicadas são a 3000, a 3001, a 4443 e a 8025, o emulador de storage inicia com `-port 4443` e `-public-host localhost:4443` e o healthcheck dele confere `127.0.0.1:4443`, a máscara do `.git` é `tmpfs` somente leitura, e a API e o `playwright` não recebem nenhuma das variáveis do slot. + +**Teste previsto:** testes dos scripts do harness, `scripts/test/dev-env-slot.test.sh`, "sem `.env`, a configuração do ambiente é a de hoje". + +## LUI-184-CA3 + +**Marca:** teste de unidade + +**Cenário:** Dado um repositório temporário com o `.git` como pasta e um worktree temporário com o `.git` como arquivo, quando o contêiner sobe com a máscara que a configuração resolvida declara para o contêiner da API (a do `.env` de um worktree para o arquivo, a padrão para a pasta), então o `.git` visto dentro do contêiner fica vazio nos dois casos, sem o histórico, e o contêiner sobe. Como controle, a máscara padrão num `.git` que é arquivo não sobe, que é o defeito que o ticket corrige. O contêiner do teste é o do `postgres`, que o ambiente já baixou, com o mesmo `.:/repo` somente leitura do contêiner da API. + +**Teste previsto:** testes dos scripts do harness, `scripts/test/dev-env-slot.test.sh`, "a máscara do `.git` serve para o `.git` que é pasta e para o que é arquivo". + +## LUI-184-CA4 + +**Marca:** revisão humana + +**Cenário:** Dado o checkout principal e um worktree no slot 1, com o `.env` feito à mão, quando os dois ambientes sobem, então os dois ficam no ar ao mesmo tempo, as suítes completas dos dois passam, um link de e-mail e uma URL de download do worktree trazem as portas do slot 1 (3010 e 4453), e derrubar um ambiente deixa o outro de pé. A evidência no PR traz os comandos e as saídas: o `docker compose ps` dos dois, o `scripts/verify.sh full` de cada um, o e-mail aberto na tela do slot 1 (porta 8035), a URL de download e o `down` de um com o outro respondendo. + +## LUI-184-CA5 + +**Marca:** revisão humana + +**Cenário:** Dado um worktree irmão do checkout principal, quando se roda o `scripts/verify.sh full` nele, então o passo "Segredos nos commits da branch" passa, e ele acusa um segredo de mentira que a pessoa põe num commit do worktree. Dentro do contêiner da API desse worktree, o `.git` está vazio e o histórico do Git não está ao alcance. A evidência no PR traz os dois resultados do passo e a saída do `ls -A` e do `cat` do `/repo/.git` no contêiner. + +## LUI-184-CA6 + +**Marca:** documento + +**Cenário:** Dado o diff do PR, quando se leem a LLD (a seção do emulador de storage, a tabela de variáveis e o contrato do `.env` do slot), a tabela de endereços do `AGENTS.md` da raiz, os `AGENTS.md` da web e da API e o README, então cada um descreve os endereços por slot onde citava `localhost` com porta fixa: a tabela da raiz diz que é a do slot 0, e a fórmula (a porta de hoje mais slot × 10) e os nomes das variáveis têm um dono só, a LLD. From 6a60e41fcd8399a610dabfbcb19f4b154a5cc2a9 Mon Sep 17 00:00:00 2001 From: Luiz Carlos Date: Mon, 19 Oct 2026 14:24:41 -0300 Subject: [PATCH 2/2] feat: development environment follows a slot (LUI-184) A root .env sets the slot: the published ports of web, API, storage and mailpit, the storage emulator port and public host, and the web origin and public storage address that the API reads. Without the .env the resolved configuration is the one of before. The .git mask of the API container takes its type and source from the .env, so a worktree, where .git is a file, is covered by /dev/null instead of a tmpfs. The secret scan mounts the common .git and sets GIT_DIR in a worktree (scripts/git-mount.sh). The test of the mask uses the storage image, not the postgres one the plan names: it is the smallest of the environment and has sh and grep. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01ANcDtWf5vRMzv1r42Ax9ce --- .gitignore | 4 +- AGENTS.md | 4 +- README.md | 2 + apps/api/.env.example | 6 +- apps/api/AGENTS.md | 2 +- .../test/auth-password-recovery.e2e-spec.ts | 7 +- apps/web/AGENTS.md | 4 +- apps/web/e2e/download.spec.ts | 4 +- apps/web/e2e/storage-emulator.spec.ts | 6 +- apps/web/e2e/support/storage.ts | 8 ++ apps/web/playwright.config.ts | 6 +- compose.dev.yaml | 39 ++++-- docs/lld.md | 12 +- scripts/git-mount.sh | 21 ++++ scripts/test/dev-env-slot.test.sh | 117 ++++++++++++++++++ scripts/test/git-mount.test.sh | 48 +++++++ scripts/verify.sh | 12 +- 17 files changed, 267 insertions(+), 35 deletions(-) create mode 100644 scripts/git-mount.sh create mode 100755 scripts/test/dev-env-slot.test.sh create mode 100755 scripts/test/git-mount.test.sh diff --git a/.gitignore b/.gitignore index 0dcc654..807f90b 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,6 @@ docs/tickets/*/*/* .playwright-mcp/ # O estado interno do harness: a trava e as aprovações. .harness/ -docs/research/ \ No newline at end of file +docs/research/ +# O .env do Compose, com o slot do checkout (docs/lld.md, seção 6, "Ambiente local por slot"). +/.env \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 5295baf..149447a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,7 +37,7 @@ Tudo roda dentro dos contêineres do Docker Compose: instalação de dependênci O ambiente de desenvolvimento usa o `compose.dev.yaml`, na raiz, com seis serviços: -| Serviço | O que é | Endereço na máquina | Endereço entre contêineres | +| Serviço | O que é | Endereço na máquina (slot 0) | Endereço entre contêineres | | --- | --- | --- | --- | | `web` | Frontend em Next.js | `http://localhost:3000` | `http://web:3000` | | `api` | API em NestJS, com as rotas sob `/v1` | `http://localhost:3001` | `http://api:3000` | @@ -46,6 +46,8 @@ O ambiente de desenvolvimento usa o `compose.dev.yaml`, na raiz, com seis servi | `mailpit` | Captura os e-mails enviados e os mostra numa tela | `http://localhost:8025` | SMTP em `mailpit:1025` e API HTTP em `http://mailpit:8025` | | `playwright` | Navegadores dos testes da web | Não é publicado | Não recebe conexões | +A tabela é a do slot 0, o de um checkout sem `.env` na raiz. As portas e os endereços de um slot de 1 a 3 estão no `docs/lld.md`, seção 6, "Ambiente local por slot". + Os contêineres `web` e `api` preparam o próprio ambiente ao subir e depois ficam parados, sem iniciar a aplicação. O `playwright` também fica parado. Os comandos rodam neles com `docker compose -f compose.dev.yaml exec `, e os de cada projeto, inclusive os testes no `playwright`, estão no `AGENTS.md` dele. O `web` e o `playwright` montam a pasta da web em `/app`, e o `api`, a da API. O `api` monta também o repositório em `/repo`, só para leitura e sem o `.git`, onde roda o Node do harness (o "Comando do Node" dos valores da skill de plano, abaixo). diff --git a/README.md b/README.md index 072655d..cc89e6d 100644 --- a/README.md +++ b/README.md @@ -82,4 +82,6 @@ docker compose -f compose.dev.yaml exec api pnpm start:dev | API | | | E-mails capturados | | +São os endereços do slot 0, o de um checkout sem `.env` na raiz. Vários checkouts ao mesmo tempo usam um slot cada um, com portas próprias: o contrato do `.env` está no [`docs/lld.md`](./docs/lld.md), seção 6, "Ambiente local por slot". + Os detalhes do ambiente e os demais comandos estão no [`AGENTS.md`](./AGENTS.md). diff --git a/apps/api/.env.example b/apps/api/.env.example index 3915ce9..0ce0dd1 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -18,7 +18,8 @@ MAIL_FROM="Gerenciador de Arquivos " # SMTP_IDLE_TIMEOUT_MS=60000 # Origem do frontend, usada nos links dos e-mails. É o endereço que a pessoa abre no navegador, -# e por isso é o da máquina, e não o nome do serviço do Compose. +# e por isso é o da máquina, e não o nome do serviço do Compose. Num slot, o Compose passa o do slot +# nesta variável, e o ambiente do processo vale mais do que esta linha (docs/lld.md, seção 6, "Ambiente local por slot"). WEB_ORIGIN=http://localhost:3000 # Validade do link de verificação de e-mail, em segundos. Sem a variável, vale o padrão de produção. @@ -53,7 +54,8 @@ LOGIN_RATE_LIMIT_PER_IP=100000 EMAIL_REQUEST_RATE_LIMIT_PER_IP=100000 # Storage de arquivos. O emulador (`storage`, no Compose) é falado por STORAGE_EMULATOR_HOST, o -# nome do serviço. O navegador só alcança o endereço que o Compose publica, STORAGE_PUBLIC_URL. +# nome do serviço. O navegador só alcança o endereço que o Compose publica, STORAGE_PUBLIC_URL. Num slot, +# o Compose passa os dois endereços do slot, que valem mais do que estas linhas. # A chave que assina as URLs (STORAGE_SIGNER_PRIVATE_KEY) é gerada na primeira subida e gravada # direto no `.env`. Em produção, nenhuma das quatro últimas existe (docs/lld.md, seção 4.6). GCS_BUCKET=app-files diff --git a/apps/api/AGENTS.md b/apps/api/AGENTS.md index 186bdd3..ae26f50 100644 --- a/apps/api/AGENTS.md +++ b/apps/api/AGENTS.md @@ -8,7 +8,7 @@ docker compose -f compose.dev.yaml exec api | Comando | O que faz | | --- | --- | -| `pnpm start:dev` | Sobe a API em modo watch (`http://localhost:3001` na máquina) | +| `pnpm start:dev` | Sobe a API em modo watch (`http://localhost:3001` na máquina, no slot 0) | | `pnpm test` | Suíte de unidade (`*.spec.ts`) | | `pnpm test:int` | Suíte de integração (`*.int-spec.ts`) | | `pnpm test:e2e` | Suíte de ponta a ponta (`*.e2e-spec.ts`) | diff --git a/apps/api/test/auth-password-recovery.e2e-spec.ts b/apps/api/test/auth-password-recovery.e2e-spec.ts index 76942de..710017b 100644 --- a/apps/api/test/auth-password-recovery.e2e-spec.ts +++ b/apps/api/test/auth-password-recovery.e2e-spec.ts @@ -1,5 +1,6 @@ import { INestApplication } from '@nestjs/common'; import { App } from 'supertest/types.js'; +import { appConfig } from '../src/config/app.config.js'; import { authRoutes, PASSWORD, @@ -38,7 +39,7 @@ describe('Recuperação de senha', () => { expect(mail.subject).toBe(RESET_SUBJECT); expect(mail.to).toEqual([email]); expect(mail.text).toContain( - 'http://localhost:3000/redefinir-senha?token=', + `${app.get(appConfig.KEY).webOrigin}/redefinir-senha?token=`, ); expect(mail.text).toContain('1 hora'); expect(resetToken(mail)).toMatch(/^[\w-]{43}$/); @@ -137,7 +138,9 @@ describe('Recuperação de senha', () => { expect(mail.subject).toBe(PASSWORD_CHANGED_SUBJECT); expect(mail.to).toEqual([email]); expect(mail.text).not.toContain('token='); - expect(mail.text).toContain('http://localhost:3000/esqueci-minha-senha'); + expect(mail.text).toContain( + `${app.get(appConfig.KEY).webOrigin}/esqueci-minha-senha`, + ); }); it('aceita o link uma vez só: o segundo uso devolve invalid_token e não troca a senha de novo', async () => { diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md index e3f736d..143aecd 100644 --- a/apps/web/AGENTS.md +++ b/apps/web/AGENTS.md @@ -18,7 +18,7 @@ docker compose -f compose.dev.yaml exec web | Comando | O que faz | | --- | --- | -| `pnpm dev --hostname 0.0.0.0` | Sobe o servidor de desenvolvimento (`http://localhost:3000`) | +| `pnpm dev --hostname 0.0.0.0` | Sobe o servidor de desenvolvimento (`http://localhost:3000` no slot 0) | | `pnpm lint` | ESLint com as regras do `eslint-config-next` | | `pnpm exec tsc --noEmit` | Checagem de tipos | | `pnpm exec next typegen` | Gera os tipos globais das rotas (`PageProps`, `LayoutProps`). O `pnpm dev` e o `pnpm build` já os geram; num clone novo, rode antes do `tsc`. | @@ -156,7 +156,7 @@ docker compose -f compose.dev.yaml exec playwright - **Itens na Pasta raiz:** o teste do próprio envio sobe o arquivo pelo botão "Enviar". Para a lista com vários arquivos, com nome, tipo, tamanho e data escolhidos, `seedRootFiles()`, de `e2e/support/items.ts`, grava arquivos ativos direto no banco da API, pela `DATABASE_URL` do serviço `playwright`. A Cota cheia, que nenhuma tela produz, é gravada do mesmo jeito, por `fillQuota()` (`e2e/support/quota.ts`). Pelo mesmo caminho, `usedBytesOf()`, do mesmo arquivo, lê a Cota usada, que nenhuma tela mostra até a funcionalidade dela. O Usuário já tem de ter entrado, porque a raiz nasce na primeira listagem; depois de gravar, recarregue a página. - **Token de acesso expirado:** o teste não espera os 15 minutos. Ele apaga o cookie do token de acesso, que é o que o navegador faz quando o token expira, e deixa o de renovação. - **E-mails:** `e2e/support/mailpit.ts` lê os e-mails pelo Mailpit. `uniqueEmail()` cria um destinatário que nenhum outro teste usa, `waitForMailTo()` espera o e-mail chegar e `extractLink()` tira dele o link de um caminho. Nenhum teste apaga a caixa do Mailpit, que também serve ao desenvolvimento. -- **Exceção, prova de infraestrutura:** `e2e/storage-emulator.spec.ts` prova o emulador de storage, e não uma tela. A página só serve de origem para o navegador falar com o emulador. O teste cria a sessão por `POST /uploads`, confere o tamanho pela conclusão e pede a URL de leitura a `GET /items/{id}/download-url`, com o token da Sessão (`e2e/support/storage.ts`). O Chromium resolve `localhost` para o serviço `storage` (`playwright.config.ts`), para o endereço público do emulador (`localhost:4443`) valer no contêiner `playwright`. +- **Exceção, prova de infraestrutura:** `e2e/storage-emulator.spec.ts` prova o emulador de storage, e não uma tela. A página só serve de origem para o navegador falar com o emulador. O teste cria a sessão por `POST /uploads`, confere o tamanho pela conclusão e pede a URL de leitura a `GET /items/{id}/download-url`, com o token da Sessão (`e2e/support/storage.ts`). O Chromium resolve `localhost` para o serviço `storage` (`playwright.config.ts`), para o endereço público do emulador (`localhost:4443` no slot 0) valer no contêiner `playwright`. Num slot, o teste lê o host dele de `STORAGE_PUBLIC_URL`, pelo `storagePublicHost()` de `e2e/support/storage.ts`. - **Envio em chunks:** o teste que envia um arquivo maior que um chunk, ou que falha, pausa ou cancela um chunk, chama antes `interceptStorageSession(page)`, de `e2e/support/storage-session.ts` (o porquê está no `docs/lld.md`, seção 4.6). `failOnce` e `hold` acham o chunk pelo tamanho total do arquivo, então arquivos enviados juntos têm tamanhos diferentes. O motor de envio, sem React, é provado por unidade (`lib/uploads/engine.spec.ts`). - **Seletores:** pelo papel e pelo nome acessível (`getByRole`, `getByLabel`), que é como o Usuário acha o elemento. `data-testid` fica para o que não tem papel nem rótulo. - **Depois de navegar por um link, prefira `getByRole`.** O Next.js mantém a tela anterior escondida na página, e `getByLabel` acha também os campos dela. `getByRole` ignora o que está escondido. diff --git a/apps/web/e2e/download.spec.ts b/apps/web/e2e/download.spec.ts index 721ad1e..8f3bca6 100644 --- a/apps/web/e2e/download.spec.ts +++ b/apps/web/e2e/download.spec.ts @@ -3,7 +3,7 @@ import { expect, test, type BrowserContext, type Page } from "@playwright/test"; import { signIn } from "./support/account"; import { uniqueEmail } from "./support/mailpit"; import { accessTokenOf } from "./support/session"; -import { completeUpload, createUpload } from "./support/storage"; +import { completeUpload, createUpload, storagePublicHost } from "./support/storage"; /** * Deixa na lista um arquivo ativo de nome e conteúdo conhecidos: cria o envio pela API, manda os @@ -90,7 +90,7 @@ test("o HTML da lista não traz URL assinada do storage, só o caminho da web", }) => { await signIn(page, uniqueEmail()); const itemId = await seedFile(page, context, "relatorio.txt", "ola!!"); - const forbidden = ["localhost:4443", "X-Goog-Signature", "response-content-disposition"]; + const forbidden = [storagePublicHost(), "X-Goog-Signature", "response-content-disposition"]; const served = await context.request.get("/"); const servedHtml = await served.text(); diff --git a/apps/web/e2e/storage-emulator.spec.ts b/apps/web/e2e/storage-emulator.spec.ts index 18f395d..f28b128 100644 --- a/apps/web/e2e/storage-emulator.spec.ts +++ b/apps/web/e2e/storage-emulator.spec.ts @@ -2,7 +2,7 @@ import { expect, test } from "@playwright/test"; import { signIn } from "./support/account"; import { uniqueEmail } from "./support/mailpit"; import { accessTokenOf } from "./support/session"; -import { completeUpload, createUpload, downloadUrlOf } from "./support/storage"; +import { completeUpload, createUpload, downloadUrlOf, storagePublicHost } from "./support/storage"; const CHUNK = 256 * 1024; @@ -99,8 +99,8 @@ test.describe("Emulador de storage", () => { chunks.push(chunk as Buffer); } - expect(new URL(uploadUrl).host).toBe("localhost:4443"); - expect(new URL(readUrl).host).toBe("localhost:4443"); + expect(new URL(uploadUrl).host).toBe(storagePublicHost()); + expect(new URL(readUrl).host).toBe(storagePublicHost()); expect(status).toBe(200); expect(download.suggestedFilename()).toBe("relatório.pdf"); expect(Buffer.concat(chunks).toString()).toBe(content); diff --git a/apps/web/e2e/support/storage.ts b/apps/web/e2e/support/storage.ts index 4aca3f2..48a7ff6 100644 --- a/apps/web/e2e/support/storage.ts +++ b/apps/web/e2e/support/storage.ts @@ -5,6 +5,14 @@ import { requiredEnv } from "./env"; * envio por `POST /uploads`, o mesmo caminho da tela, conferem o tamanho pela conclusão e pedem a URL de leitura. */ +/** + * O host do endereço público do emulador de storage, o que as URLs da API trazem: o do slot do ambiente + * (`STORAGE_PUBLIC_URL`, que o Compose passa quando há um `.env` de slot) ou o do slot 0. + */ +export function storagePublicHost(): string { + return new URL(process.env.STORAGE_PUBLIC_URL ?? "http://localhost:4443").host; +} + async function api( path: string, accessToken: string, diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts index f36cccd..4e71662 100644 --- a/apps/web/playwright.config.ts +++ b/apps/web/playwright.config.ts @@ -12,9 +12,9 @@ import { requiredEnv } from "./e2e/support/env"; const chromium = { ...devices["Desktop Chrome"], launchOptions: { - // O endereço público do emulador de storage é `localhost:4443`, e as URLs que a API - // entrega valem para o navegador de quem desenvolve. Neste contêiner, `localhost` não é - // o storage: o Chromium o resolve para o serviço `storage` (docs/lld.md, seção 4.6). + // O endereço público do emulador de storage é `localhost:4443` no slot 0 (a porta segue o slot), e as + // URLs que a API entrega valem para o navegador de quem desenvolve. Neste contêiner, `localhost` não + // é o storage: o Chromium o resolve para o serviço `storage`, mantendo a porta (docs/lld.md, seção 4.6). args: ["--host-resolver-rules=MAP localhost storage"], }, }; diff --git a/compose.dev.yaml b/compose.dev.yaml index 1e0363e..fc02c84 100644 --- a/compose.dev.yaml +++ b/compose.dev.yaml @@ -6,7 +6,9 @@ services: # Faz o contêiner parado encerrar na hora no `down`. init: true ports: - - "3000:3000" + # A porta da máquina segue o slot (docs/lld.md, seção 6, "Ambiente local por slot"); sem o `.env`, + # é a de hoje. + - "${WEB_PORT:-3000}:3000" volumes: - ./apps/web:/app environment: @@ -32,17 +34,28 @@ services: dockerfile: Dockerfile.dev init: true ports: - # Na máquina a API fica na 3001; entre contêineres, em http://api:3000. - - "3001:3000" + # Na máquina a API fica na 3001 do slot 0; entre contêineres, em http://api:3000. + - "${API_PORT:-3001}:3000" + environment: + # Os endereços que seguem o slot (docs/lld.md, seção 6, "Ambiente local por slot"). Sem valor: o + # Compose os lê do `.env`, e sem o `.env` eles não existem aqui, e a API usa o `apps/api/.env`. O + # ambiente do processo vale mais do que o `.env` da API. + WEB_ORIGIN: + STORAGE_PUBLIC_URL: + STORAGE_EMULATOR_HOST: volumes: - ./apps/api:/app # O repositório inteiro, só para leitura, para o Node do harness (scripts/node-in-api.sh) ler # os scripts, os arquivos de aceite e os relatórios das apps sem cópia. As escritas continuam # em /app. - .:/repo:ro - # O .git do repositório fica coberto por uma pasta vazia: nada no contêiner usa o Git, e assim o - # histórico, os hooks e a configuração dele ficam fora do alcance do que roda aqui. - - type: tmpfs + # O .git do repositório fica coberto: nada no contêiner usa o Git, e assim o histórico, os hooks e + # a configuração dele ficam fora do alcance do que roda aqui. No checkout principal e na CI, o .git + # é uma pasta, e a máscara é uma pasta vazia (tmpfs). Num worktree, o .git é um arquivo, onde o tmpfs + # não monta: o `.env` do worktree traz GIT_MASK_TYPE=bind e GIT_MASK_SOURCE=/dev/null, que o cobre + # com um arquivo vazio (docs/lld.md, seção 6, "Máscara do `.git`"). + - type: ${GIT_MASK_TYPE:-tmpfs} + source: ${GIT_MASK_SOURCE:-} target: /repo/.git read_only: true depends_on: @@ -83,6 +96,9 @@ services: DATABASE_URL: postgresql://app:app@postgres:5432/app # A API, para os testes criarem o envio e pedirem a URL de leitura (POST /uploads e GET /items/{id}/download-url). API_URL: http://api:3000 + # O endereço público do storage do slot, que os testes conferem nas URLs. Sem valor: o Compose a + # lê do `.env`, e sem o `.env` ela não existe, e os testes usam o do slot 0. + STORAGE_PUBLIC_URL: depends_on: # Usa a `node_modules` que o contêiner `web` instala. web: @@ -116,7 +132,7 @@ services: image: axllent/mailpit:v1.30 ports: # Tela e API HTTP dos e-mails capturados. O SMTP (1025) só é usado entre contêineres. - - "8025:8025" + - "${MAILPIT_PORT:-8025}:8025" storage: # Emulador do Cloud Storage. A versão é fixa: o comportamento do emulador está medido @@ -124,17 +140,18 @@ services: image: fsouza/fake-gcs-server:1.56.1 init: true ports: - # É a porta que o navegador de quem desenvolve alcança (STORAGE_PUBLIC_URL). - - "4443:4443" + # É a porta que o navegador de quem desenvolve alcança (STORAGE_PUBLIC_URL). O emulador escuta + # nela também dentro do contêiner, para o navegador dos testes, que só troca o host, chegar a ele. + - "${STORAGE_PORT:-4443}:${STORAGE_PORT:-4443}" # O emulador monta o endereço da sessão e serve a URL assinada pelo `Host` da requisição, # e só atende a URL assinada quando ele é o `-public-host`. O bucket nasce da pasta # /data/app-files, que o entrypoint cria antes de iniciar o emulador. entrypoint: - sh - -c - - mkdir -p /data/app-files && exec /bin/fake-gcs-server -data /data -scheme http -backend memory -port 4443 -public-host localhost:4443 + - mkdir -p /data/app-files && exec /bin/fake-gcs-server -data /data -scheme http -backend memory -port ${STORAGE_PORT:-4443} -public-host localhost:${STORAGE_PORT:-4443} healthcheck: - test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:4443/storage/v1/b/app-files"] + test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:${STORAGE_PORT:-4443}/storage/v1/b/app-files"] interval: 5s timeout: 3s retries: 5 diff --git a/docs/lld.md b/docs/lld.md index 8586f18..fe8303d 100644 --- a/docs/lld.md +++ b/docs/lld.md @@ -489,10 +489,10 @@ Uma falha do fornecedor (rede, permissão, bucket inexistente) sai como `Storage **Endereço público e assinatura local** -- **Dois endereços do emulador:** a API fala com ele por `STORAGE_EMULATOR_HOST` (`http://storage:4443`, o nome do serviço do Compose). O navegador não alcança esse host, e alcança `http://localhost:4443`, a porta que o Compose publica. Esse é o `STORAGE_PUBLIC_URL`, que só existe no ambiente local. -- **Endereço da sessão:** o emulador monta o endereço da sessão com o `Host` da requisição que recebeu, então ele volta com `storage:4443`. Com `STORAGE_PUBLIC_URL` definido, a implementação troca a origem do endereço por ela antes de devolvê-lo. Sem a variável (staging e produção), o endereço sai como veio do Cloud Storage. +- **Dois endereços do emulador:** a API fala com ele por `STORAGE_EMULATOR_HOST` (`http://storage:4443` no slot 0, o nome do serviço do Compose). O navegador não alcança esse host, e alcança `http://localhost:4443`, a porta que o Compose publica. Esse é o `STORAGE_PUBLIC_URL`, que só existe no ambiente local. Num slot, o emulador escuta e se anuncia na porta do slot, e os dois endereços a seguem (seção 6, "Ambiente local por slot"). +- **Endereço da sessão:** o emulador monta o endereço da sessão com o `Host` da requisição que recebeu, então ele volta com o host do serviço (`storage:4443` no slot 0). Com `STORAGE_PUBLIC_URL` definido, a implementação troca a origem do endereço por ela antes de devolvê-lo. Sem a variável (staging e produção), o endereço sai como veio do Cloud Storage. - **URL assinada:** a implementação passa a origem de `STORAGE_PUBLIC_URL` como `host` da assinatura, e a URL já sai com o endereço que o navegador alcança. O emulador só serve a URL assinada (`//`) quando o `Host` da requisição é igual ao `-public-host` dele, que o Compose define com o mesmo host e porta do `STORAGE_PUBLIC_URL`. -- **Navegador dos testes:** o navegador do serviço `playwright` está noutro contêiner, onde `localhost` não é o storage. A configuração do Playwright o faz resolver `localhost` para o serviço `storage` (`--host-resolver-rules`), e as páginas continuam na web (`http://web:3000`, ou o build servido pelo `scripts/verify.sh`). +- **Navegador dos testes:** o navegador do serviço `playwright` está noutro contêiner, onde `localhost` não é o storage. A configuração do Playwright o faz resolver `localhost` para o serviço `storage` (`--host-resolver-rules`), e as páginas continuam na web (`http://web:3000`, ou o build servido pelo `scripts/verify.sh`). O `--host-resolver-rules` troca só o host e mantém a porta do endereço público, que por isso é também a porta em que o emulador escuta dentro do contêiner. - **Assinatura sem segredo real:** o SDK precisa de uma chave privada para assinar, mesmo apontado para o emulador. Em produção, a assinatura usa a identidade do serviço no Cloud Run, sem chave no ambiente. Localmente, o `scripts/setup-env.mjs` gera, na primeira subida, um par RSA descartável e grava a chave privada e um e-mail fictício no `.env`, como faz com o par do JWT, e não os versiona. O emulador não confere a assinatura nem a validade da URL: uma URL adulterada ou vencida também é servida. - **Origem do SDK:** o SDK também lê `STORAGE_EMULATOR_HOST` do ambiente e, com ela definida, usa o valor como endereço-base sem o `/storage/v1`, o que dá 404 em toda chamada contra o emulador (verificado na versão 8.4.0). A implementação passa o endereço por configuração (`apiEndpoint`) e cria o SDK com a variável fora do ambiente, que volta em seguida. - **Os testes no navegador não têm rota de apoio:** eles falam com o storage pelas rotas do produto. Criam a sessão por `POST /uploads`, conferem o tamanho pela conclusão e pedem a URL de leitura a `GET /items/{id}/download-url`. @@ -618,8 +618,8 @@ A retenção da lixeira, o prazo de upload pendente, a validade dos tokens, a to | `GCS_BUCKET` | api | Nome do bucket privado | | `TASKS_QUEUE`, `WORKER_URL` | api | Fila do Cloud Tasks e endereço do worker | | `QUEUE_DRIVER` | api | `cloud-tasks` em staging e produção, `local` no desenvolvimento (chamada HTTP direta ao worker) | -| `STORAGE_EMULATOR_HOST` | api | Endereço do emulador de storage, só no desenvolvimento (`http://storage:4443`) | -| `STORAGE_PUBLIC_URL` | api | Origem do emulador que o navegador alcança, só no desenvolvimento (`http://localhost:4443`). Obrigatória quando `STORAGE_EMULATOR_HOST` existe. | +| `STORAGE_EMULATOR_HOST` | api | Endereço do emulador de storage, só no desenvolvimento (`http://storage:4443` no slot 0) | +| `STORAGE_PUBLIC_URL` | api | Origem do emulador que o navegador alcança, só no desenvolvimento (`http://localhost:4443` no slot 0). Obrigatória quando `STORAGE_EMULATOR_HOST` existe. | | `STORAGE_SIGNER_EMAIL`, `STORAGE_SIGNER_PRIVATE_KEY` | api | Credencial de assinatura das URLs, só no desenvolvimento: um e-mail fictício e uma chave RSA em PEM gerada pelo `setup-env.mjs`. Obrigatórias quando `STORAGE_EMULATOR_HOST` existe, e ausentes em produção, onde a assinatura usa a identidade do serviço. | | `TRASH_RETENTION_DAYS`, `PENDING_UPLOAD_TTL_HOURS` | api | Padrões de 30 dias e 24 horas. O prazo dos pendentes é um inteiro maior ou igual a 0, e zero faz todo pendente vencer na hora. | | `ACCESS_TOKEN_TTL_SECONDS`, `REFRESH_TOKEN_TTL_SECONDS` | api | Validade do token de acesso e do token de renovação, em segundos. Padrões de 900 (15 minutos) e 2592000 (30 dias). | @@ -631,7 +631,7 @@ A retenção da lixeira, o prazo de upload pendente, a validade dos tokens, a to | `DATABASE_CONNECT_TIMEOUT_MS` | api | Quanto esperar o banco para abrir uma conexão, em milissegundos. Padrão de 10000. | | `SMTP_TIMEOUT_MS` | api | Quanto esperar o servidor SMTP para resolver o nome, conectar e saudar, em milissegundos. Padrão de 10000. | | `SMTP_IDLE_TIMEOUT_MS` | api | Quanto uma conexão SMTP aberta pode ficar sem tráfego, durante um envio ou entre um envio e outro, em milissegundos. Padrão de 60000. | -| `WEB_ORIGIN` | api | Origem do frontend, para CORS e links de e-mail: esquema, host e porta, sem caminho nem barra no fim. No desenvolvimento, é o endereço que a pessoa abre no navegador (`http://localhost:3000`). | +| `WEB_ORIGIN` | api | Origem do frontend, para CORS e links de e-mail: esquema, host e porta, sem caminho nem barra no fim. No desenvolvimento, é o endereço que a pessoa abre no navegador (`http://localhost:3000` no slot 0). | | `INTERNAL_API_SECRET` | api e web | Segredo que autoriza a web a repassar o IP do usuário (segredo). Tem pelo menos 32 caracteres, e o valor é o mesmo nos dois projetos. | | `LOGIN_RATE_LIMIT_PER_EMAIL`, `LOGIN_RATE_LIMIT_PER_IP`, `LOGIN_RATE_LIMIT_WINDOW_SECONDS` | api | Tentativas de login por e-mail e por IP, e a janela, em segundos. Padrões de 5, 20 e 900 (15 minutos). | | `EMAIL_REQUEST_RATE_LIMIT_PER_EMAIL`, `EMAIL_REQUEST_RATE_LIMIT_PER_IP`, `EMAIL_REQUEST_RATE_LIMIT_WINDOW_SECONDS` | api | Cadastros, reenvios de verificação e pedidos de "esqueci a senha" por e-mail e por IP, e a janela, em segundos. Padrões de 3, 10 e 3600 (1 hora). | diff --git a/scripts/git-mount.sh b/scripts/git-mount.sh new file mode 100644 index 0000000..63064ea --- /dev/null +++ b/scripts/git-mount.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# +# O que o contêiner da varredura de segredos do scripts/verify.sh precisa para ler o histórico do Git +# do checkout, além da pasta montada. Carregado com `source`, define `gitleaks_git_args`, que imprime, +# um por linha, os argumentos extras do `docker run` (a pasta atual é montada em /repo). +# +# Num repositório, o `.git` é uma pasta dentro da pasta montada, e nada mais é preciso. Num worktree, é um +# arquivo que aponta para o `.git` comum, fora dela, às vezes com caminho relativo, que no contêiner não +# leva a lugar nenhum: o `.git` comum é montado no mesmo caminho absoluto da máquina, só para leitura, e o +# `GIT_DIR` aponta para a pasta do worktree dentro dele (docs/lld.md, seção 6, "Máscara do `.git`"). A forma +# `--mount` é a que vale: a `-v origem:destino:ro` não monta um caminho igual nos dois lados no Docker Desktop. + +gitleaks_git_args() { + # Um `.git` que é pasta (repositório) ou que não existe (fora do Git) não pede nada. + [[ -f .git ]] || return 0 + local common gitdir + { read -r common && read -r gitdir; } < <(git rev-parse --path-format=absolute --git-common-dir --absolute-git-dir) || + return 1 + printf '%s\n' --mount "type=bind,source=$common,target=$common,readonly" \ + -e "GIT_DIR=$gitdir" -e GIT_WORK_TREE=/repo +} diff --git a/scripts/test/dev-env-slot.test.sh b/scripts/test/dev-env-slot.test.sh new file mode 100755 index 0000000..8ba8739 --- /dev/null +++ b/scripts/test/dev-env-slot.test.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# +# Testes do ambiente de desenvolvimento por slot (compose.dev.yaml), pela configuração que o Compose +# resolve, sem subir contêiner: o `.env` do slot move as portas e os endereços, sem ele o ambiente é o +# de hoje, e a máscara do `.git` serve para um `.git` que é pasta e para um que é arquivo. O contrato do +# `.env` (os nomes das variáveis e a fórmula das portas) é o do docs/lld.md, seção 6, "Ambiente local por +# slot". Rodam na máquina, porque usam o `docker compose`. Quem chama é o scripts/verify.sh. + +set -uo pipefail +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +# shellcheck source=sh-helpers.sh +source "$ROOT/scripts/test/sh-helpers.sh" + +for tool in docker jq git; do + command -v "$tool" >/dev/null || { echo "Falta o \`$tool\` na máquina: o teste do ambiente por slot precisa dele."; exit 2; } +done + +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +# O `.env` do slot 1, como o contrato da LLD o descreve, para o worktree: as portas de hoje mais 10, os +# três endereços que a API e o `playwright` leem e a máscara do `.git` por arquivo. +cat >"$work/slot1.env" <<'ENV' +SLOT=1 +WEB_PORT=3010 +API_PORT=3011 +STORAGE_PORT=4453 +MAILPIT_PORT=8035 +WEB_ORIGIN=http://localhost:3010 +STORAGE_PUBLIC_URL=http://localhost:4453 +STORAGE_EMULATOR_HOST=http://storage:4453 +GIT_MASK_TYPE=bind +GIT_MASK_SOURCE=/dev/null +ENV +# Sem `.env`: um arquivo vazio, passado de forma explícita, para um `.env` do checkout de quem roda não +# entrar no resultado. +: >"$work/slot0.env" + +# As duas configurações resolvidas, em JSON, uma vez para todos os casos. +for slot in slot0 slot1; do + (cd "$ROOT" && docker compose -f compose.dev.yaml --env-file "$work/$slot.env" config --format json) >"$work/$slot.json" || + { echo "O Compose não resolveu a configuração do $slot."; exit 2; } +done + +# has : a configuração resolvida do slot satisfaz o filtro. +has() { + jq -e "$2" "$work/$1.json" >/dev/null +} + +# expect_ports : as portas publicadas, e o storage escutando e se +# anunciando na dele, com o healthcheck na mesma. +expect_ports() { + local slot="$1" web="$2" api="$3" storage="$4" mailpit="$5" + has "$slot" "[.services.web.ports[] | [.published, .target]] == [[\"$web\", 3000]]" && + has "$slot" "[.services.api.ports[] | [.published, .target]] == [[\"$api\", 3000]]" && + has "$slot" "[.services.storage.ports[] | [.published, .target]] == [[\"$storage\", $storage]]" && + has "$slot" "[.services.mailpit.ports[] | [.published, .target]] == [[\"$mailpit\", 8025]]" && + has "$slot" ".services.storage.entrypoint[-1] | contains(\"-port $storage \") and endswith(\"-public-host localhost:$storage\")" && + has "$slot" ".services.storage.healthcheck.test[-1] | contains(\"127.0.0.1:$storage/\")" +} + +# --- CA1: o `.env` do slot 1 move as portas e os endereços --- +ca1() { + expect_ports slot1 3010 3011 4453 8035 && + has slot1 '.services.api.environment | .WEB_ORIGIN == "http://localhost:3010" and .STORAGE_PUBLIC_URL == "http://localhost:4453" and .STORAGE_EMULATOR_HOST == "http://storage:4453"' && + has slot1 '.services.playwright.environment.STORAGE_PUBLIC_URL == "http://localhost:4453"' && + # Os endereços entre contêineres que não são do storage seguem como hoje. + has slot1 '.services.web.environment.API_URL == "http://api:3000" and .services.playwright.environment.WEB_URL == "http://web:3000" and .services.playwright.environment.MAILPIT_URL == "http://mailpit:8025" and .services.playwright.environment.API_URL == "http://api:3000" and .services.playwright.environment.DATABASE_URL == "postgresql://app:app@postgres:5432/app"' +} +check "o .env do slot 1 move as portas e os endereços do ambiente @LUI-184-CA1" ca1 + +# --- CA2: sem `.env`, o ambiente é o de hoje --- +ca2() { + expect_ports slot0 3000 3001 4443 8025 && + # Uma variável sem valor e sem `.env` sai nula: o Compose não a passa ao contêiner. + has slot0 '[.services.api.environment.WEB_ORIGIN, .services.api.environment.STORAGE_PUBLIC_URL, .services.api.environment.STORAGE_EMULATOR_HOST, .services.playwright.environment.STORAGE_PUBLIC_URL] | all(. == null)' && + has slot0 '[.services.api.volumes[] | select(.target == "/repo/.git") | [.type, .read_only]] == [["tmpfs", true]]' +} +check "sem .env, a configuração do ambiente é a de hoje @LUI-184-CA2" ca2 + +# --- CA3: a máscara do `.git` serve para o `.git` que é pasta e para o que é arquivo --- + +# A imagem do storage, a menor das do ambiente e que o `up` já baixou: o teste só precisa de um `sh`. +IMAGE="$(jq -r '.services.storage.image' "$work/slot0.json")" +docker image inspect "$IMAGE" >/dev/null 2>&1 || { + echo "Falta a imagem $IMAGE na máquina: suba o ambiente com \`docker compose -f compose.dev.yaml up -d --wait\` e rode de novo." + exit 2 +} + +# in_container : roda num contêiner que tem a pasta em /repo, só para leitura como +# no contêiner da API, coberta pela máscara que a configuração do slot declara para o `.git` (o tipo, a +# origem se há, e somente leitura). Confere que ele não vê nem o histórico (o HEAD de um `.git` que é pasta) +# nem o ponteiro do worktree (o `gitdir:` de um `.git` que é arquivo). +in_container() { + local mask + mask="$(jq -r '.services.api.volumes[] | select(.target == "/repo/.git") + | "type=\(.type),target=\(.target),readonly" + (if .source then ",source=\(.source)" else "" end)' "$work/$2.json")" + docker run --rm --pull=never --entrypoint sh \ + --mount "type=bind,source=$1,target=/repo,readonly" --mount "$mask" \ + "$IMAGE" -c '[ ! -e /repo/.git/HEAD ] && ! grep -q gitdir /repo/.git 2>/dev/null' >/dev/null 2>&1 +} + +ca3() { + local repo="$work/repo" worktree="$work/worktree" + git init -q "$repo" && + git -C "$repo" -c user.name=t -c user.email=t@t commit -q --allow-empty -m inicial && + git -C "$repo" worktree add -q --detach "$worktree" && + # Pasta: a máscara padrão, a do checkout principal e da CI. + in_container "$repo" slot0 && + # Arquivo: a máscara do `.env` de um worktree. + in_container "$worktree" slot1 && + # Controle: a máscara padrão não sobe sobre um `.git` que é arquivo, o defeito que o ticket corrige. + ! in_container "$worktree" slot0 +} +check "a máscara do .git serve para o .git que é pasta e para o que é arquivo @LUI-184-CA3" ca3 + +finish "do ambiente de desenvolvimento por slot" diff --git a/scripts/test/git-mount.test.sh b/scripts/test/git-mount.test.sh new file mode 100755 index 0000000..ee79ed1 --- /dev/null +++ b/scripts/test/git-mount.test.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# +# Testes do scripts/git-mount.sh, que diz o que o contêiner da varredura de segredos precisa para ler o +# histórico do Git: nada num repositório (onde o `.git` é uma pasta, dentro da pasta montada), e o `.git` +# comum e o `GIT_DIR` num worktree (onde o `.git` é um arquivo que aponta para fora dela, com caminho +# relativo ou absoluto, conforme a configuração do Git de quem o criou). O Docker não entra: o teste confere +# os argumentos. Rodam na máquina, porque usam o `git`. Quem chama é o scripts/verify.sh. + +set -uo pipefail +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +# shellcheck source=sh-helpers.sh +source "$ROOT/scripts/test/sh-helpers.sh" +# shellcheck source=../git-mount.sh +source "$ROOT/scripts/git-mount.sh" + +command -v git >/dev/null || { echo "Falta o \`git\` na máquina: o teste precisa dele."; exit 2; } + +# O caminho físico, sem links simbólicos: é o que o Git imprime. +work="$(cd "$(mktemp -d)" && pwd -P)" +trap 'rm -rf "$work"' EXIT +repo="$work/repo" +git init -q "$repo" +git -C "$repo" -c user.name=t -c user.email=t@t commit -q --allow-empty -m inicial + +# args_in : os argumentos que a função dá para o checkout da pasta, um por linha. +args_in() { + (cd "$1" && gitleaks_git_args) +} + +# Os argumentos de um worktree irmão do repositório, que o Git gravou com o caminho relativo ou o absoluto. +expected_for_worktree() { + printf '%s\n' --mount "type=bind,source=$repo/.git,target=$repo/.git,readonly" \ + -e "GIT_DIR=$repo/.git/worktrees/$1" -e GIT_WORK_TREE=/repo +} + +check "num repositório, onde o .git é uma pasta, não pede nada" test -z "$(args_in "$repo")" + +git -C "$repo" -c worktree.useRelativePaths=true worktree add -q --detach "$work/relativo" +check "o worktree está com o .git relativo, como no Git de quem o cria assim" grep -q '^gitdir: \.\./' "$work/relativo/.git" +check "num worktree com o .git relativo, monta o .git comum e aponta o GIT_DIR" \ + test "$(args_in "$work/relativo")" = "$(expected_for_worktree relativo)" + +git -C "$repo" -c worktree.useRelativePaths=false worktree add -q --detach "$work/absoluto" +check "o worktree está com o .git absoluto" grep -q "^gitdir: $repo/" "$work/absoluto/.git" +check "num worktree com o .git absoluto, pede o mesmo" \ + test "$(args_in "$work/absoluto")" = "$(expected_for_worktree absoluto)" + +finish "do scripts/git-mount.sh" diff --git a/scripts/verify.sh b/scripts/verify.sh index 33a0934..8125822 100755 --- a/scripts/verify.sh +++ b/scripts/verify.sh @@ -78,6 +78,10 @@ APPROVAL_FILE="$HARNESS_DIR/full-ok" FULL_LOCK_FILE="$HARNESS_DIR/full-lock" # shellcheck source=full-lock.sh source scripts/full-lock.sh +# O que o contêiner da varredura de segredos precisa para ler o histórico num worktree, com a função +# `gitleaks_git_args` do scripts/git-mount.sh. +# shellcheck source=git-mount.sh +source scripts/git-mount.sh # A pasta dos tickets. TICKETS_DIR=docs/tickets @@ -335,8 +339,14 @@ require_base() { } secret_scan() { + local extra=() mounts require_base - docker run --rm --user "$(id -u):$(id -g)" -v "$PWD:/repo" "$GITLEAKS_IMAGE" \ + # Num worktree, o histórico está no `.git` comum, fora da pasta montada; num repositório, não há extra. + mounts="$(gitleaks_git_args)" || + abort "não deu para achar o .git comum deste checkout, que a varredura de segredos monta." \ + "confira se o repositório do Git está íntegro, com \`git status\`." + [[ -z "$mounts" ]] || mapfile -t extra <<<"$mounts" + docker run --rm --user "$(id -u):$(id -g)" -v "$PWD:/repo" "${extra[@]}" "$GITLEAKS_IMAGE" \ git /repo --no-banner --redact --log-opts="$BASE_REF..HEAD" }