Skip to content

fix(auth,http,programs): ciclo de QA da borda — unicidade de CPF, uuidV4 estrito, magic-byte no logo; Hurl substitui o Bruno (ADR-0074) - #1037

Merged
GabrielAderaldo merged 8 commits into
devfrom
fix/qa-cycle-findings
Oct 6, 2026
Merged

GabrielAderaldo merged 8 commits into
devfrom
fix/qa-cycle-findings

Conversation

@GabrielAderaldo

Copy link
Copy Markdown
Contributor

Fecha o ciclo de QA/pen-test da borda HTTP de 05–06/10 e troca a ferramenta da suíte de coleções.

O que entra

1. Unicidade de CPF em auth_user (achado F7)

Dois usuários podiam ser criados com o mesmo CPF e e-mails diferentes — ambos 201. CPF é documento, então isso é duplicação de identidade: a tabela tinha UNIQUE em email e legacy_id, nenhum em cpf, e o use case só checava findByEmail.

A garantia passa a viver no banco (auth_user_cpf_idx, migration 0010). cpf segue nullable e o InnoDB permite múltiplos NULL num UNIQUE, então quem nasce sem perfil (register/OIDC) não colide — propriedade coberta por teste.

Os três índices UNIQUE que um INSERT em auth_user alcança têm desfechos diferentes, e os dois adapters distinguem pelo nome do índice dentro do errno 1062:

índice desfecho
legacy_id idempotência (skip)
cpf erro nomeado → 409 na borda; na ETL, o use case degrada o campo e re-tenta
email erro nomeado → propaga (e-mail não se degrada: é obrigatório e é a identidade)

Sem essa distinção, dado duplicado se disfarçaria de falha de infra (unavailable) e o usuário legado não seria migrado — defeito que a própria migration criava.

2. uuidV4() estrito na borda (corrige 500 em contracts/:id)

z.uuid() do Zod v4 aceitava UUID bem-formado não-v4 (nil, max, v1/v3/v5); o domínio rejeitava via isUuidV4 e o handler não mapeava o erro, caindo no default 500 — violação da invariante "nunca 500". Schema uuidV4() compartilhado em src/shared/http, com teste de paridade contra o VO do domínio, nas 11 ocorrências da borda. UUID não-v4 passa a devolver 400 uniforme em toda rota :id.

3. Magic-byte no upload de logo (CWE-434)

POST /api/v1/programs/:id/logo confiava no Content-Type declarado: conteúdo arbitrário rotulado como imagem era aceito. As rotas-irmãs já conferiam assinatura; o logo ficou de fora. Agora valida jpeg/png/webp e devolve 422 logo-content-mismatch.

4. Hurl substitui o Bruno; api-collections/ é removida (ADR-0074)

Ver o ADR para a decisão completa. Em resumo: o gatilho foi o critério do próprio ADR-0038 — "um .bru escrito e não executado é cobertura ilusória". Quando o runner único caiu em 05/10, nada o substituiu, e os 266 .bru viraram exatamente o que aquele ADR proibia.

Hurl 8.0.1 resolve os dois limites do formato anterior: [Captures] nativo (o encadeamento sai do JavaScript), --variables-file (o arquivo nasce sem segredo), --report-junit para CI e hurlfmt --check como gate de sintaxe que roda sem servidor. A coleção foi convertida — 241 requests em 23 arquivos, com 24 [Captures] e 77 [Asserts] traduzidos do JS.

BREAKING CHANGE: api-collections/ (266 .bru) e @usebruno/cli saem do repositório. A remoção da devDependency levou 317 pacotes e, com eles, as duas exceções de supply-chain que o Bruno exigia (protobufjs em allowBuilds, semver@5.7.2 || 6.3.1 em trustPolicyExclude) — removidas depois de pnpm why provar que nenhuma outra cadeia as trazia.

Verificação

  • Gate verde em cada commit (hook de pre-commit: format → typecheck → lint → test). Final: 11931 testes, fail 0, 19 skipped e 5 todo pré-existentes.
  • Contra MySQL 8.4 real, não só contra fake: replay das 7 migrations em banco limpo, e o contrato do UserRepository com MYSQL_INTEGRATION=1 passando 11/11 — inclusive a propriedade de múltiplos NULL no UNIQUE, que um fake só pode imitar.
  • /security-review: zero vulnerabilidades novas. Verificado em particular que a sqlMessage do 1062 (que carrega o valor duplicado) não chega a log — os return de erro nomeado precedem o stderr.write nos dois adapters, e CPF/e-mail são PII.
  • /code-review em esforço alto: achados de src/ tratados no último commit.

Limites conhecidos, declarados

  • A suíte Hurl não é gate. Precisa do seed E2E (8 perfis de token) e hoje roda 82/241 — o resto para em variável indefinida. Isso é anterior à troca de ferramenta: a borda HTTP está sem gate E2E desde 05/10, quando o runner caiu. As coleções convertidas estão fora do git por ora; o .hurl é versionável quando virar gate, porque não carrega segredo.
  • [Captures] do Hurl não cruzam arquivos (medido). Ou o arquivo é autocontido, ou o token vem de --variables-file.
  • --jobs 1 é obrigatório: --test paraleliza por default e o rate limit de login é compartilhado.
  • A migration 0010 aborta se houver CPF duplicado pré-existente. ALTER TABLE ADD CONSTRAINT UNIQUE falha com 1062, e o replay em banco limpo é cego para isso (banco limpo não tem linhas). A premissa do F7 é justamente que duplicatas eram aceitas — então o pré-voo em ambiente com dado é necessário, e foi decisão do dono assumir esse risco, não um esquecimento.
  • Registro histórico (handbook/specs, tickets, CHANGELOG) não foi reescrito (ADR-0057 §5) e ainda descreve o Bruno: era verdade quando foi escrito.

Decisões de arquivo

  • Fixtures binários de upload saem de api-collections/ para tests/fixtures/http-edge/ (versionado e neutro), com git mv para preservar histórico.
  • ADR-0034 e ADR-0038 ficam Superseded by ADR-0074, com a cadeia fechada nos YAMLs. O §5 do ADR-0074 reafirma por inteiro o que o 0038 estabeleceu e continua valendo — norma corrente não se apoia em documento superseded.
  • .claude/rules/api-collections.md e supply-chain.md atualizadas: a segunda afirmava que @usebruno/cli era devDependency pinada, que deixou de ser verdade.

Follow-up registrado

Achados do code-review que ficaram fora de src/ e seguem abertos: o docblock do synthetic-cpf promete unicidade que o módulo quebra nas bordas da faixa, e os fixtures movidos não têm consumidor versionado enquanto a suíte Hurl estiver fora do git.

O scripts/e2e/bruno-all.sh era o unico lugar que chamava bru run e estava muito desatualizado; foi substituido pela abordagem de QA + pen-test por arquivos .rest. Remove o script e todas as referencias: o npm-script test:integration:all, os READMEs, a rule api-collections.md (verify block + enforced_by), as clausulas do ADR-0038.yaml que apontavam para o script (enforced_by -> []), e os dois testes que o citavam (migrate-boot-inversion e block-bash-file-io).

Assisted-by: Claude-Code:claude-opus-4-8
…m contracts/:id)

z.uuid() do Zod v4 aceitava UUID bem-formado nao-v4 (nil, max, v1/v3/v5); o dominio rejeita via isUuidV4, e o handler de contracts nao mapeava contract-id-invalid, caindo no default 500 (viola a invariante nunca-500). Introduz um schema uuidV4() compartilhado em src/shared/http (z.uuidv4 estrito, com teste de paridade com o isUuidV4 do dominio) e troca as 11 ocorrencias de z.uuid() da borda, preservando os modificadores. Defesa em profundidade: mapeia contract-id-invalid / amendment-id-invalid / document-id-invalid para 400 no contracts. Efeito medido: UUID nao-v4 passa a devolver 400 uniforme em toda rota :id.

Assisted-by: Claude-Code:claude-opus-4-8
O upload de logo (POST /api/v1/programs/:id/logo) confiava no Content-Type declarado e nao conferia os bytes reais — aceitava conteudo arbitrario rotulado como imagem (provado ao vivo: HTML como image/png -> 200). As rotas-irmas (foto de usuario, documentos fiscais) ja checavam magic-byte; o logo ficou de fora. Adiciona imageMagicBytesMatch (jpeg/png/webp) inline no use case uploadProgramLogo, retornando logo-content-mismatch -> 422, consistente com o photo-content-mismatch da foto.

Assisted-by: Claude-Code:claude-opus-4-8
…movida

ADR-0074, que supersede o ADR-0034 (adocao do @usebruno/cli) e o ADR-0038
(Bruno obrigatorio via CLI + autoria .bru).

O gatilho foi o critério do proprio ADR-0038: "um .bru escrito e nao executado
e cobertura ilusoria". Quando o runner unico (scripts/e2e/bruno-all.sh) caiu em
05/10 por estar desatualizado, nada o substituiu — e os 266 .bru viraram
exatamente o que aquele ADR proibia. Somado a isso, o formato guarda o
encadeamento em JavaScript (bru.setVar) e as assercoes em chai, o que exige o
runtime do Bruno para saber se o arquivo esta correto.

Hurl 8.0.1 resolve os dois pontos: [Captures] nativo (sem JavaScript),
--variables-file (o arquivo nasce SEM segredo), --report-junit para CI e
`hurlfmt --check` como gate de sintaxe que roda sem servidor. A colecao foi
convertida: 241 requests em 23 arquivos, 24 [Captures] e 77 [Asserts] traduzidos
do JS, hurlfmt verde nos 23.

BREAKING CHANGE: api-collections/ (266 .bru) e @usebruno/cli saem do repositorio.
A remocao da devDependency levou 317 pacotes, e com eles as DUAS excecoes de
supply-chain que o Bruno exigia (protobufjs em allowBuilds, semver@5.7.2||6.3.1
em trustPolicyExclude) — removidas depois de `pnpm why` provar que nenhuma outra
cadeia as trazia. Excecao de politica sobrevive a dependencia que a justificou
se ninguem varrer.

Os fixtures binarios de upload (sample.jpg/pdf) migram de api-collections/ para
tests/fixtures/http-edge/, versionado e neutro: as pastas de saida da conversao
sao gitignored e guardar fixture dentro delas tiraria o arquivo do git.

Limites medidos, nao supostos: [Captures] NAO cruzam arquivos (capture em a.hurl
+ uso em b.hurl, mesmo comando e --jobs 1, falha); a suite exige o seed E2E (8
perfis) e hoje roda 82/241, o resto parando em variavel indefinida; --jobs 1 e
obrigatorio porque --test paraleliza por default e o rate limit de login e
compartilhado. Zero 500 nos 82 requests executados.

As colecoes convertidas ficam fora do git por ora (uso interno): o .hurl e
versionavel quando virar gate, porque nao carrega segredo; o .http, nao, porque
so funciona com o token colado dentro do arquivo. O .gitignore tambem passa a
cobrir .cockpit/ (estado de workspace da IDE).

Registro historico (handbook/specs, tickets, CHANGELOG) NAO foi reescrito
(ADR-0057 §5) e ainda descreve o Bruno: era verdade quando foi escrito.

Assisted-by: Claude-Code:claude-opus-5
O ciclo de QA de 05/10 provou que dois usuarios podiam ser criados com o MESMO
cpf e e-mails diferentes — ambos 201. Como cpf e documento, isso e duplicacao de
identidade: `auth_user` tinha uniqueIndex em email e legacy_id, nenhum em cpf, e
`createUserByAdmin` so checava findByEmail.

A garantia passa a viver no banco (`auth_user_cpf_idx`, migration 0010). cpf
segue nullable e o InnoDB permite multiplos NULL num UNIQUE, entao quem nasce
sem perfil (register/OIDC) nao colide — propriedade coberta por teste.

Os DOIS adapters que inserem em auth_user distinguem o 1062 pelo nome do indice
na sqlMessage: `user-repository.drizzle` devolve `cpf-already-registered` (409 na
borda, em create e update) e `provisioned-user-store.drizzle` idem, para a ETL
poder tratar. Sem a distincao, dado duplicado se disfarcaria de falha de infra
(`provisioned-user-store-unavailable`) e o usuario legado NAO seria migrado —
defeito que a propria migration criava.

Em `provisionLegacyUser`, cpf duplicado DEGRADA para null + warning e re-tenta, a
mesma politica que ja valia para cpf invalido (Evans p.82, reconstituicao): um
campo de perfil opcional nao vale o registro inteiro. O id e gerado uma vez e
reusado no retry — reconstruir nao troca identidade.

Testes: o contrato do port ganha CA7 (duplicado -> erro nomeado), CA8 (dois NULL
coexistem) e CA9 (upsert do proprio registro nao e conflito), valendo para as
duas implementacoes; CA7 provado invertido (removida a checagem, acusa). O gate
de schema varre TODAS as migrations, nao a 0000, porque o indice nasceu na 0010.
`user-repository.drizzle.test` passa a honrar AUTH_DATABASE_URL com fallback no
helper canonico, como contracts e financial ja faziam.

Efeito colateral medido e corrigido: 18 testes de rota ficaram vermelhos
(`409 !== 201`) porque as fixtures reusavam um cpf fixo para criar N usuarios. A
correcao e a fixture respeitar o invariante novo, nunca afrouxar o assert:
`tests/support/synthetic-cpf.ts` deriva cpf valido por modulo 11 de um seed.
Gerar em vez de listar numeros e deliberado — fixture e o caminho por onde dado
de cadastro real entra num repositorio publico.

Verificado contra MySQL 8.4 real (nao so fake): replay das 7 migrations em banco
limpo, e o contrato do UserRepository com MYSQL_INTEGRATION=1 passando 11/11.

Assisted-by: Claude-Code:claude-opus-5
`.tmp/` e onde script de medicao/conversao tem de viver: no scratchpad da sessao
o ESM resolve pelo path do ARQUIVO e os imports do projeto dao MODULE_NOT_FOUND.
Mas o diretorio nao entra em tsconfig algum, e o projectService type-aware do
ESLint reprova com `was not found by the project service` — qualquer script
ad-hoc ali travava o `lint` e, com ele, o gate do hook Stop.

A regua ja existia para os irmaos da mesma categoria (descartavel, gitignored,
fora de tsconfig): `tools/bugs-scripts/**` e `tests/reports/**` ja estavam na
lista de ignores, com esse motivo escrito. `.tmp/` ficou de fora — existia num
lugar e faltava no outro.

Assisted-by: Claude-Code:claude-opus-5
… 3a UNIQUE da ETL

Tres correcoes em `src/` apontadas pela revisao da branch.

1. O nome do indice era string magica em tres arquivos. `AUTH_USER_UNIQUE_INDEX`
   (schemas/mysql.ts) passa a ser a fonte unica dos tres nomes, e os dois adapters
   a consomem. Nao e cosmetica: o errno 1062 nao diz QUAL unicidade quebrou — quem
   distingue e o nome do indice dentro da sqlMessage, e e por ele que o adapter
   escolhe entre erro nomeado, idempotencia e falha de infra. Com o nome escrito a
   mao em cada lugar, um `drizzle-kit generate` que o renomeasse passava no gate e
   quebrava a traducao EM SILENCIO: o 409 do cpf duplicado viraria 500 e a ETL leria
   dado duplicado como banco indisponivel.

   O gate novo (schema-hardening, CA10) cobra a PROPRIEDADE, nao a contagem: cada
   valor da constante existe em alguma migration, nenhum adapter repete o literal, e
   os adapters de fato consomem a constante (guarda contra verde por vacuidade). A
   assercao que fecha e a NEGATIVA, e ela foi provada invertida — reintroduzido o
   literal, o gate acusa.

2. `provisioned-user-store` tratava DUAS das tres UNIQUE que o INSERT alcanca. O
   `auth_user_email_idx` caia em `provisioned-user-store-unavailable`: dado duplicado
   disfarcado de falha de infra, o mesmo defeito que o commit anterior corrigiu para
   o cpf. Agora devolve `email-already-registered`, que PROPAGA (email nao se degrada
   — e obrigatorio e e a identidade do login), diferente do cpf, que o use case anula
   e re-tenta. O comentario do arquivo dizia "as duas de auth_user"; eram tres.

3. `upload-program-logo`: o `default` do switch de magic-byte passa a ser
   fail-CLOSED. Hoje e inalcancavel (a allowlist roda antes), mas protege o dia em que
   um MIME novo entrar na allowlist sem a assinatura — com `return true` aquele MIME
   aceitaria conteudo falsificado em silencio (CWE-434), que e o defeito que a funcao
   veio corrigir.

   ⚠️ A funcao irma em `auth/adapters/http/photo-upload.ts` FICA fail-open, de
   proposito: lá o magic-byte roda na BORDA e a allowlist so no use case, entao fechar
   o default trocaria `photo-type-unsupported` por `photo-content-mismatch` — mudanca
   de contrato observavel, nao endurecimento. A assimetria esta documentada nos dois
   arquivos; e ela a razao de as duas copias NAO terem sido fundidas num modulo
   compartilhado nesta passada: unificar exige alinhar a ORDEM primeiro.

Nao mexido, com motivo medido: `uuidV4()` em response schema. A revisao sugeriu que
contrariava o principio do proprio arquivo, mas o `git blame` mostra o oposto — o
comentario de `financial/.../schemas.ts` JA dizia "IDs proprios seguem z.uuid()" antes,
e o commit anterior so trocou por `uuidV4()`. A politica (refs estrangeiras nao
revalidam, ids proprios revalidam) e pre-existente e deliberada. Risco residual medido:
toda fonte de id e `randomUUID()`, os 16 UUID de seed das migrations sao v4 e o banco
local tem zero nao-v4.

Assisted-by: Claude-Code:claude-opus-5
Copilot AI balanced review requested due to automatic review settings October 6, 2026 19:37
@GabrielAderaldo GabrielAderaldo added the ai-assisted PR de sessão de IA — commits exigem trailer Assisted-by (ADR-0054/#549) label Oct 6, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

… verde

O `audit (produção — blocking)` estava vermelho com 16 vulnerabilidades (10 high)
em quatro pacotes. Nao era regressao deste PR: toda a mudanca anterior em
package.json foi a REMOCAO de uma devDependency. O audit so nao rodava na dev
desde 10/09, e as advisories sairam nesse intervalo — este PR foi o primeiro run
depois delas, e revelou o estado em vez de causa-lo.

- `fastify` 5.12.1 -> 5.12.5 (patch, dentro do major 5).
- `nodemailer` 9.1.1 -> 10.0.15 (major). O unico breaking do 10.0.0 e exigir
  Node.js >= 20; estamos em 24. `createTransport`, `sendMail` e `verify()` nao
  mudaram de assinatura, e e toda a superficie que o adapter usa.
- `fast-uri` piso 3.1.6 -> 3.1.7 (resolve 3.1.8) e `brace-expansion` 5.0.9 ->
  5.0.11 (resolve 5.0.12), nos overrides.

Os dois overrides eram a parte interessante: eles estavam SEGURANDO a versao
vulneravel, nao excluindo-a. O comentario do proprio pnpm-workspace.yaml previa
isso por escrito — "o piso fixado para a advisory do dia vira o teto quando sai a
proxima" — e se cumpriu pela QUINTA vez no fast-uri e pela QUARTA no
brace-expansion. Faixa (`>=X <4`) nao empurra ninguem: o piso e ele proprio uma
versao aceitavel, entao `@fastify/ajv-compiler` seguia resolvendo 3.1.6 mesmo com
o fastify em 5.12.5. Expulsar exige SUBIR o piso acima da vulneravel. O historico
de reincidencia fica no comentario; o piso vigente e o novo.

Verificacao, com a ressalva de onde ela alcanca:
- `pnpm run audit` -> "No known vulnerabilities found" (era 16).
- Gate completo verde: 11931 testes, fail 0.
- O boot do Fastify esta exercitado de verdade: 190 arquivos de teste montam o app
  por `fastify.inject`. O `createTransport` real do nodemailer 10 roda em
  `nodemailer-require-tls.test.ts`, nos dois branches (pool e nao-pool).
- NAO houve boot contra SMTP real (o OrbStack estava desligado e subir container
  exige OK do dono): o envio de ponta a ponta segue coberto so por fake.

⚠️ `@types/nodemailer` FICA em `^8`: nao existe `@types/nodemailer@10` — a ultima
publicada e 8.0.2. O nodemailer 10 migrou para TypeScript mas declara `types: null`
e manteve, de proposito, compatibilidade com o layout do @types (esta no changelog
do 10.0.0). Quem tentar "alinhar" os majors nao vai achar o pacote.

⚠️ Divida registrada, nao corrigida aqui: o adapter importa subpath profundo
(`nodemailer/lib/smtp-pool/index.js`). Esse caminho NAO existe mais no v10 — o
pacote publica so `dist/`, e o `exports` mapeia `./lib/*`. Funciona hoje porque sao
`import type`, apagados em runtime, e o tipo vem do @types. Virar import de valor
quebraria. Trocar para o especificador do `exports` e assunto proprio.

Assisted-by: Claude-Code:claude-opus-5
@GabrielAderaldo GabrielAderaldo self-assigned this Oct 6, 2026
@GabrielAderaldo
GabrielAderaldo merged commit e70f584 into dev Oct 6, 2026
20 checks passed
@GabrielAderaldo
GabrielAderaldo deleted the fix/qa-cycle-findings branch October 6, 2026 20:32
GabrielAderaldo added a commit that referenced this pull request Oct 6, 2026
A branch do toolchain estava parada à espera da dev. Merge, não rebase: ela está
publicada e tem o PR #1035 aberto.

Cinco conflitos, dos treze arquivos que os dois lados tocaram:

- `scripts/e2e/bruno-all.sh` (delete/modify) — remoção aceita. O Bruno saiu do
  projeto inteiro pelo ADR-0074, e o runner não se ressuscita.
- `eslint.config.js` (delete/modify, invertido) — remoção aceita. A branch retira
  o ESLint pelo ADR-0072; a dev havia acrescentado `.tmp/**` aos ignores. A entrada
  foi portada para `ignorePatterns` do `.oxlintrc.json`, ao lado dos irmãos de mesma
  categoria (`tools/bugs-scripts/**`, `tests/reports/**`): descartável, gitignored,
  fora de qualquer tsconfig. Sem ela, script ad-hoc em `.tmp/` volta a travar o lint.
- `package.json` — os dois lados removeram coisas diferentes e a união remove ambas:
  sai `test:integration:all` (dev, apontava para o runner do Bruno) e sai
  `@usebruno/cli` (dev), sai `@typescript/native-preview` (branch, ADR-0072).
  Preservadas as flags que o TS 7 tornou redundantes, já retiradas pela branch.
- `pnpm-lock.yaml` — lado da dev aceito e regenerado com `pnpm install`, como manda
  o lockfile. Coexistem as deps do toolchain (oxlint, oxlint-tsgolint, typescript
  7.0.2) e o que a dev trouxe: fastify 5.12.5, nodemailer 10.0.15, zero `usebruno`,
  zero `typescript-eslint`, zero `native-preview`.
- `handbook/architecture/adr/README.md` — conflito aditivo: 0072 e 0073 da branch,
  0074 da dev. Os três preservados em ordem; 0034 e 0038 seguem `Superseded by 0074`.

Um vermelho veio do merge e foi corrigido aqui: `node-flags-not-redundant.test.ts`
listava `scripts/e2e/bruno-all.sh` em `INVOCATION_POINTS`, e o arquivo deixou de
existir. As três falhas tinham essa causa única — o `readSource` estourando ENOENT
derrubava junto os dois testes que apenas iteram a mesma lista, que é exatamente o
cenário descrito no docstring do gate. Nenhum dos dois lados produz esse vermelho
sozinho: a lista é artefato do ADR-0072, o arquivo foi removido pelo ADR-0074.

Gate verde: typecheck, format:check, lint (1017 arquivos, 154 regras, 0 erros) e
11963 testes com fail 0, 20 skipped.

Assisted-by: Claude-Code:claude-opus-5
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-assisted PR de sessão de IA — commits exigem trailer Assisted-by (ADR-0054/#549)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants