Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,6 @@ docs/tickets/*/*/*
.playwright-mcp/
# O estado interno do harness: a trava e as aprovações.
.harness/
docs/research/
docs/research/
# O .env do Compose, com o slot do checkout (docs/lld.md, seção 6, "Ambiente local por slot").
/.env
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand All @@ -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 <serviço> <comando>`, 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).
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,4 +82,6 @@ docker compose -f compose.dev.yaml exec api pnpm start:dev
| API | <http://localhost:3001/v1> |
| E-mails capturados | <http://localhost:8025> |

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).
6 changes: 4 additions & 2 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ MAIL_FROM="Gerenciador de Arquivos <nao-responda@gerenciador.local>"
# 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.
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion apps/api/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ docker compose -f compose.dev.yaml exec api <comando>

| 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`) |
Expand Down
7 changes: 5 additions & 2 deletions apps/api/test/auth-password-recovery.e2e-spec.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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}$/);
Expand Down Expand Up @@ -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 () => {
Expand Down
4 changes: 2 additions & 2 deletions apps/web/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ docker compose -f compose.dev.yaml exec web <comando>

| 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`. |
Expand Down Expand Up @@ -156,7 +156,7 @@ docker compose -f compose.dev.yaml exec playwright <comando>
- **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.
4 changes: 2 additions & 2 deletions apps/web/e2e/download.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down
6 changes: 3 additions & 3 deletions apps/web/e2e/storage-emulator.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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);
Expand Down
8 changes: 8 additions & 0 deletions apps/web/e2e/support/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions apps/web/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
},
};
Expand Down
39 changes: 28 additions & 11 deletions compose.dev.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -116,25 +132,26 @@ 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
# contra ela (docs/lld.md, seção 4.6).
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
Expand Down
Loading
Loading