diff --git a/.gitignore b/.gitignore index f28393b..03bb53d 100644 --- a/.gitignore +++ b/.gitignore @@ -14,7 +14,6 @@ build/ .coverage htmlcov/ coverage.xml -scripts/probes/out/ coverage.json tests/live/out/ .smoke-venv/ @@ -23,3 +22,8 @@ requirements-audit.txt # arquivos locais do agente (não versionar) CLAUDE.md .claude/ + +# documentação e ferramentas internas (ficam fora do repositório) +openspec/ +docs/contrato/ +scripts/probes/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 624a39e..ddf9f81 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -39,7 +39,7 @@ No GitHub, o CodeQL roda pelo default setup do repositório; não há workflow p 1. **Zero dependências de runtime.** O pacote usa só a biblioteca padrão. Dependências novas só no grupo `dev`. 2. **Contrato vem da OpenAPI e de sonda ao vivo**, nunca de outro SDK. Quando algo não puder ser - observado, registre como inferência explícita (`docs/contrato/`). + observado, registre como inferência explícita na descrição do PR. 3. **Nunca retentar emissão ambígua.** `POST` não é repetido após possível envio. Não mude a tabela de `src/nfeio/_core/retry.py` sem evidência da API. 4. **Segurança:** nada de chave, senha ou documento em log, `repr` ou exceção; TLS sempre diff --git a/docs/contrato/DECISOES-FASE2.md b/docs/contrato/DECISOES-FASE2.md deleted file mode 100644 index fab6b0f..0000000 --- a/docs/contrato/DECISOES-FASE2.md +++ /dev/null @@ -1,112 +0,0 @@ -# Decisões da fase 2 — para o André confirmar - -Decisões de API pública (ou de processo) que **não estavam cobertas** pelo registro de -`DECISOES-PENDENTES.md` nem pelo design, ou em que a sonda trouxe fato novo. Em cada uma escolhi a -opção mais conservadora e reversível e segui em frente. Resposta curta aceita: "F1 ok, F4 opção B…". - -## Modelos e tipos - -**F1. Acesso por chave a valores aninhados.** O registro recomendava que `Mapping` aninhado vire -`NfeObject`. Implementado assim, e decidi também as listas: `obj["lista"]` devolve uma **lista -nova** (mutar não altera o objeto) cujos `Mapping` viram `NfeObject`. Escalares voltam como -vieram. `to_dict()` devolve o fio puro. -- Alternativa: devolver `tuple` (imutável de verdade), mas `obj["filters"] == ["a"]` passaria a - ser `False`, o que surpreende. Mantive `list`. - -**F2. Data sem fuso.** Texto de data/hora sem offset é interpretado como **UTC** nas propriedades -`datetime` (o fio observado sempre traz offset; isto só cobre exceções). Valor inválido → `None`. - -**F3. Documento inteiro sem `type`.** Em `Party.federal_tax_number` (tomador/prestador), quando o -fio manda inteiro e não há `type` (`LegalEntity`/`NaturalPerson`), uso a heurística "até 11 -dígitos = CPF, acima = CNPJ". Com `type` presente (o normal no fio), não há ambiguidade. -Limitação: CNPJ `00.000.000/0001-91` enviado como `191` sem `type` vira CPF `00000000191`. - -**F4. Datas em corpos de requisição.** `date`/`datetime` dentro do corpo são enviados como texto -ISO 8601 (conveniência; antes daria `TypeError`). Reverter é trivial. - -**F5. `pickle` de `NfeObject`.** Objetos de resposta podem ser serializados com `pickle` (útil -para cache/filas como Celery). O SDK nunca desserializa `pickle`; `SecretStr` recusa ser -serializado. Alternativa: proibir também em `NfeObject`. - -**F6. Retorno de `lookups.cnpj_state_taxes`.** Devolve `LegalEntity` (com `stateTaxes` no acesso -por chave), sem modelo próprio para inscrição estadual. - -## Erros, retry e rede - -**F7. `outcome_unknown` em POST com 429.** D8 decidiu não retentar POST em 429. Também marquei -esse erro com `outcome_unknown=True` (não sabemos se o limitador fica antes do processamento), o -que leva a receita de reconciliação a buscar por `externalId` antes de reenviar. Alternativa: -`False` (supõe que 429 nunca processa). - -**F8. `timeout` numérico.** `timeout=90` significa "esta tentativa pode levar até 90 s": -`read = total = 90`, `connect = min(10, 90)`. Um `Timeout(connect, read, total)` dá controle -fino. Limite conhecido da stdlib: a resolução DNS não respeita o timeout de conexão. - -**F9. Busca por `externalId` estrita.** Só `{"serviceInvoices": []}` significa "não existe". -Corpo vazio, forma desconhecida ou redirect levantam `UnexpectedResponseError` — preferi falhar -a deixar uma reconciliação concluir "seguro reemitir" por engano. - -**F10. Sem suporte a proxy.** `HTTPS_PROXY`/`HTTP_PROXY` são **ignorados** (`http.client` não os -lê e eu não quis adotar uma convenção implícita). Quem precisa de proxy hoje injeta um -`Transport`. Proposta para a v0.2: parâmetro explícito `proxy=` (com `CONNECT` tunelado). - -**F11. Paginação por índice mantém `page_count`.** `auto_paging_iter()` da NFS-e usa o -`page_count` da primeira página (trocar para 50 no meio desloca o `pageIndex` e pula itens); no -cursor, as páginas seguintes usam `limit=50`. Paginação que não avança (mesmo cursor ou mesma -primeira nota) levanta `UnexpectedResponseError` em vez de laço infinito. Spec e design já -ajustados. - -## Recursos - -**F12. `companies.delete` é soft delete.** A sonda mostrou que `DELETE /v2/companies/{id}` -responde 204 e a empresa continua consultável com `status: Inactive`. Mantive o nome `delete` -(espelha o verbo da API) e documentei. Alternativa: renomear para `deactivate`. A empresa -descartável `ad95…37dd` ficou `Inactive` na conta de teste — vale pedir ao backend a remoção -definitiva, se fizer diferença. - -**F13. Webhook de teste fora do `example.com`.** A API chama a URI ao criar o webhook; o -`example.com` responde 405 e a criação falha (400 `40001`). Para validar o CRUD usei -`https://httpbin.org/status/200?nfeio-sdkpy-test=1` (serviço público de teste que responde 200 e -não armazena nada; o ping de verificação só leva os dados do webhook de teste, com segredo -descartável). O webhook ficou ativo ~1 s e foi apagado. **Confirme se essa URL serve para as -próximas execuções** ou indique um endpoint próprio da NFE.io. - -**F14. Upload de certificado.** `password` precisa ser texto não vazio e o arquivo ter entre 1 byte -e 1 MiB (validação local). `file` aceita `bytes`, caminho ou arquivo binário aberto. - -**F15. Evento de webhook tipado.** `construct_event` devolve `data` como `ServiceInvoice` quando -`X-Hook-Event` começa com `service_invoice` (formato do cabeçalho é **inferência** da -documentação; nenhuma entrega real de nota foi capturada). Nos demais casos, `NfeObject`. - -## Processo e infraestrutura - -**F16. Opt-in dos testes ao vivo também por opção do pytest.** Além de `NFE_RUN_INTEGRATION=1` e -`NFE_LIVE_WRITE=1`, aceitei `--run-integration` e `--live-write` (o ambiente desta sessão não -permitia prefixar variáveis no comando). O opt-in nunca é lido do `.env`. - -**F17. CI sem `setup-python`/`setup-uv`.** Para reduzir actions de terceiros, o `uv` é instalado -com `pipx install uv==0.10.8` e o Python vem do próprio `uv`. Só `checkout`, `upload/download- -artifact` e `pypa/gh-action-pypi-publish` são usadas. O CodeQL não tem workflow próprio: roda -pelo default setup do repositório no GitHub. O workflow avançado (`codeql.yml`) foi removido -porque o upload do SARIF é recusado quando o default setup está ligado ("configurations cannot be -processed when the default setup is enabled"). - -**F18. Pins de actions não verificados daqui.** Esta sessão não tinha acesso ao GitHub. O SHA do -`actions/checkout` v4.2.2 foi conferido num repositório local; os de `upload-artifact`, -`download-artifact` e `gh-action-pypi-publish` vieram de memória e **precisam -ser conferidos** antes do primeiro push: `GH_TOKEN=… python3 scripts/verify_action_pins.py` -(o job `action-pins` do workflow de segurança faz o mesmo no CI e falha se não baterem). - -**F19. Arquivos locais não versionados.** O arquivo de instruções do assistente na raiz e o -diretório oculto de configuração dele ficaram fora dos commits (a regra de commits proíbe essa -menção em arquivos versionados). Decida se entram no `.gitignore`. - -**F20. Versão.** `_version.py` já diz `0.1.0`; o CHANGELOG marca a seção como "não publicada". -Alternativa: `0.1.0.dev0` até a publicação. - ---- - -## Registro (André, 2026-10-08) - -- **F12:** manter o nome `delete`. A docstring já documenta que a API só desativa (`status == "Inactive"`); o README deve dizer o mesmo. -- **F13:** aprovado `https://httpbin.org/status/200?nfeio-sdkpy-test=1` como URI dos webhooks de teste ao vivo. diff --git a/docs/contrato/DECISOES-PENDENTES.md b/docs/contrato/DECISOES-PENDENTES.md deleted file mode 100644 index 3aa1a5b..0000000 --- a/docs/contrato/DECISOES-PENDENTES.md +++ /dev/null @@ -1,164 +0,0 @@ -# Decisões — SDK Python v0.1 (todas fechadas em 2026-10-08) - -Para o OK do André antes da implementação (change `add-sdk-foundation-v0-1`). Design e specs já -seguem a **recomendação** de cada item. Se a escolha for outra, ajusto specs e tasks antes de codar. -Evidência: `docs/contrato/probe-2026-10-08.md` (SONDA) e `design.md` (§). - -Resposta curta aceita: "D1 ok, D3 opção A, …". - ---- - -## D1. Como representar os modelos de resposta - -| Opção | Como fica | Prós | Contras | -|---|---|---|---| -| A. Objeto dinâmico (`StripeObject`) | `inv.flowStatus` via `__getattr__` | preserva tudo; zero manutenção | tipagem fraca; nomes camelCase; erro de digitação só em runtime | -| B. `dataclass` congelada + `raw` (como PHP/Ruby) | `inv.flow_status`, `inv.raw["x"]` | tipagem forte | mapeamento campo a campo; histórico de DTO incompleto (PHP cobria 13 de 39 campos) | -| C. `TypedDict` do fio (como o Node) | `inv["flowStatus"]` | zero runtime; preserva tudo | camelCase no código Python; sem datas/Decimal; sem métodos | -| **D. Visão tipada (recomendada)** | `inv.flow_status` **e** `inv["flowStatus"]` | tipagem forte nas propriedades; **não perde campo por construção**; `to_dict()` devolve o fio | modelo inédito na família; propriedades escritas à mão | - -**Recomendação: D.** **Trade-off:** mantemos propriedades à mão, protegidas por um teste de alinhamento -com a spec, e ganhamos compatibilidade futura sem esforço. (design §2) - -## D2. Formato do corpo das requisições - -| Opção | Exemplo | Prós | Contras | -|---|---|---|---| -| **A. `TypedDict` com chaves do fio em camelCase (recomendada)** | `create(cid, {"cityServiceCode": "…", "borrower": {...}})` | copia-e-cola da doc da API; nenhuma camada de tradução; campo novo funciona no mesmo dia | mistura estilos: argumentos em snake_case, corpo em camelCase | -| B. kwargs/TypedDict em snake_case traduzidos | `create(cid, city_service_code="…")` | 100% pythônico | tradutor recursivo para corpos de ~80 campos; bug de tradução vira erro fiscal; diverge da doc | -| C. Aceitar os dois | — | flexível | ambiguidade quando vêm os dois; dobra a superfície de teste | - -**Recomendação: A.** Argumentos de método (`company_id`, `external_id`, `page_index`) continuam em -snake_case. **Trade-off:** inconsistência estética em troca de fidelidade ao contrato e de zero -tradução. (design §1) - -## D3. Empresas: API v1 ou v2 no v0.1 - -**Evidência nova (SONDA §4):** -- O cursor da v2 varreu as 662 empresas sem erro, e o "registro venenoso" de jul/2026 hoje responde 200. - O critério 1 do gate deixou de reproduzir. -- As projeções continuam diferentes: na v2, `municipalTaxNumber` guarda o id da inscrição. -- A v1 só enxerga 357 das 662 empresas, porque lista apenas empresas com inscrição municipal. -- A doc oficial marca a v1 como "em descontinuação". - -| Opção | Prós | Contras | -|---|---|---| -| **A. v2 (cursor) para listar/consultar/CRUD (recomendada)** | API que a NFE.io manda usar; enxerga todas as empresas; sem migração futura | escrita v2 não provada ao vivo (create/delete exigem empresa nova, fora da `NFE_COMPANY_ID`); dados de NFS-e (ambiente, RPS, ISS) ficam em `municipaltaxes`, fora do v0.1 | -| B. v1 (offset) como nos irmãos | contrato provado; projeção com campos de NFS-e | nasce em API descontinuada → quebra garantida na v0.2; vê só 357/662 empresas | -| C. v2 só leitura no v0.1; CRUD na v0.2 | só o provado ao vivo | v0.1 não cadastra empresa (o ERP precisaria do painel) | - -**Recomendação: A**, com a escrita v2 validada na fase 2a. Para `create`/`delete`, preciso de autorização -para criar e apagar **uma** empresa descartável na conta de teste. Sem isso, fico com C. -**Trade-off:** quem precisar de ambiente/RPS/ISS espera a v0.2 (`municipal_taxes`) ou lê pelo acesso -cru do objeto. - -## D4. Versão da consulta de CNPJ - -**Evidência (SONDA §9):** -- v1, v2 e v3 respondem. -- `federalTaxNumber` muda de tipo entre elas: v1 devolve string formatada; v2 devolve **inteiro** - (`191`, perdendo zeros); v3 devolve string de 14 caracteres. -- O header da v2 diz `deprecated 2.0 / supported 3.0`. -- Só a v3 aceita CNPJ alfanumérico: o exemplo da RFB chegou à base. - -| Opção | Prós | Contras | -|---|---|---| -| **A. v3 (recomendada)** | pronta para CNPJ alfanumérico (2026); tipo certo; versão suportada | spec v3 não declara host (provado ao vivo no `legalentity`) | -| B. v2, como os 3 irmãos | paridade com os outros SDKs | já marcada como deprecated; inteiro perde zeros; não suporta alfanumérico | - -**Recomendação: A.** **Trade-off:** diverge dos irmãos, mas eles devem migrar também. - -## D5. O que fazer quando a emissão tem resultado incerto - -Contexto: a API ignora `Idempotency-Key`, e um POST de emissão pode devolver 500/504 **com a nota -criada**. O `externalId` é rejeitado com 400 na segunda tentativa. - -| Opção | Comportamento | Prós | Contras | -|---|---|---|---| -| **A. Reconciliação explícita (recomendada)** | erro com `outcome_unknown=True` + `external_id`; duplicidade → `DuplicateExternalIdError`; helper `find_by_external_id(..., wait=30)`; receita na doc | o SDK nunca esconde um efeito fiscal; o ERP decide (fila, alerta, retry) | cada integração escreve ~6 linhas de reconciliação | -| B. Reconciliação automática quando há `external_id` | em erro incerto ou duplicidade, o SDK busca pelo `externalId` e devolve a nota existente | "simplesmente funciona" | pode devolver nota emitida com **outro** conteúdo (`externalId` reaproveitado por bug do ERP) sem avisar; esconde o incidente | -| C. A + gerar `externalId` automático (UUID) quando omitido | dedupe sempre disponível | protege quem esquece | grava id que o ERP não conhece (não serve para reconciliar depois do crash); altera dado fiscal enviado | - -**Recomendação: A.** Também recomendo que a doc trate `external_id` como obrigatório na prática, sem -exigi-lo na assinatura. **Trade-off:** um pouco mais de código no integrador, nenhuma nota fantasma. - -## D6. Tipo dos valores monetários e alíquotas - -| Opção | Prós | Contras | -|---|---|---| -| **A. `Decimal` nas propriedades tipadas; `float` no dict cru (recomendada)** | aritmética fiscal exata onde o usuário calcula; `to_dict()`/`json.dumps` continuam funcionando | dois tipos para o mesmo campo (`inv.services_amount` × `inv["servicesAmount"]`) | -| B. `Decimal` em tudo (`json.loads(parse_float=Decimal)`) | um tipo só | `json.dumps(inv.to_dict())` quebra sem encoder custom; atrito com Odoo/ERPNext (usam `float`) | -| C. `float` em tudo | simples; igual aos ERPs | erro de arredondamento em soma de impostos; má prática para SDK fiscal | - -**Recomendação: A.** Requisições aceitam `int`, `float` ou `Decimal` (este último serializado sem -perda). **Trade-off:** precisa documentar a dualidade. - -## D7. Nomes e agrupamento dos recursos (e onde entra a NFS-e v3) - -Proposta: -- `client.service_invoices`, `client.companies`, `client.certificates`, `client.webhooks`. -- `client.lookups.cnpj(...)`, `.cnpj_state_taxes(...)`, `.cpf(...)`, `.cep(...)`. - -| Opção | Prós | Contras | -|---|---|---| -| **A. Plano + `lookups` agrupado (recomendada)** | curto; `lookups` deixa explícito "usa a chave de dados"; libera `legal_people`/`natural_people` para os tomadores (CRUD da v1) | nome `lookups` não existe nos irmãos | -| B. Espelhar o Node (`legal_entity_lookup`, `natural_person_lookup`, `addresses`) | paridade de nomes | nomes longos; "addresses" sugere CRUD | -| C. Versionado à la stripe v8 (`client.v1.service_invoices`, `client.v3…`) | acomoda NFS-e v3 sem ambiguidade | verboso; a NFE.io versiona por família, não globalmente; v1/v2/v3 coexistem de forma irregular | - -**NFS-e v3** (CNPJ alfanumérico; a v1 devolve 400 para esses CNPJs, VAULT `PHP/review-07-14-2026/11`). -Ela fica fora do v0.1. A recomendação é que, na v0.2, `client.service_invoices` passe a escolher a -versão por empresa ou parâmetro, sem novo atributo, decisão a tomar com a sonda da v3. -**Trade-off:** A pode exigir um parâmetro `api_version` no futuro; C antecipa esse custo agora para -todos. - -## D8. Retentar POST que recebe 429? - -O PHP retenta, supondo que o rate limiter rejeita **antes** de processar. Isso nunca foi confirmado com -o time da API, e não observamos 429 nem `Retry-After` (SONDA §2). - -| Opção | Prós | Contras | -|---|---|---| -| **A. Não retentar POST em 429; erro com `retry_after` (recomendada)** | coerente com a regra 3 (nunca retentar emissão ambígua) enquanto não houver confirmação | rajadas viram erro visível; o integrador re-enfileira | -| B. Retentar como o PHP | absorve picos | se o 429 vier de camada que já processou, duplica nota | -| C. Retentar POST em 429 exceto nas rotas de emissão/cancelamento | absorve picos em webhooks/certificado | regra por rota, mais complexa; ganho pequeno no v0.1 | - -**Recomendação: A**, e abrir a pergunta ao time da API. Se confirmarem que o limitador fica antes do -processamento, liberar é uma mudança aditiva. **Trade-off:** menos conveniência agora, zero risco de -duplicidade. - ---- - -## Itens menores (confirmar junto; não mudam a arquitetura) - -1. **Esquema de auth no fio:** proponho `Authorization: ` (está na spec e foi provado em todos os - hosts), em vez do `X-NFE-APIKEY` dos irmãos (funciona, mas não está em spec alguma). As regras do projeto - cita `X-NFE-APIKEY`; preciso do ok para mudar. (design §3) -2. **Licença:** MIT, como o `client-nodejs` e o plano `ideias-integracoes/06`. -3. **Canal do `SECURITY.md`:** e-mail dedicado ou GitHub Private Vulnerability Reporting. -4. **Certificado da empresa de teste vence em 2026-11-03** (SONDA §5): renovar antes da fase 2a e dos - testes de emissão. -5. **Achado para o time da API:** a chave de dados recebeu 200 em `GET` de companies v1/v2 e webhooks - (SONDA §1). Pode ser escopo maior que o pretendido. Vale reportar? -6. **Nome no PyPI:** `nfe-io`, `nfeio` e `nfe_io` estão livres hoje (consulta de leitura em 2026-10-08). - Reservar o nome cedo exigiria publicar, o que depende do seu ok. - ---- - -## Registro de decisões (André, 2026-10-08) - -| Item | Decisão | -|---|---| -| D1 | **D enxuta** — `NfeObject` = Mapping imutável do fio (preserva tudo, acesso `obj["campo"]`) + propriedades tipadas SÓ onde corrigem/tipam: ids, status, datas, dinheiro (`Decimal`), documentos (CNPJ/CPF normalizados como `str`). Demais campos ficam no acesso por chave. Atributos dinâmicos (`__getattr__`) NÃO são usados — evita a colisão `items`/`keys` do `dict`. | -| D2 | A — `TypedDict` camelCase do fio no corpo; argumentos em snake_case | -| D3 | A — companies v2; **autorizado criar e apagar UMA empresa descartável** na conta de teste para validar a escrita | -| D4 | A — CNPJ v3 | -| D5 | A — reconciliação explícita (`outcome_unknown`, `find_by_external_id`); o SDK nunca reenvia sozinho | -| D6 | A — `Decimal` nas propriedades, `float` no dict cru | -| D7 | A — recursos planos (`service_invoices`, `companies`, `certificates`, `webhooks`) + `client.lookups.{cnpj,cpf,cep}`; NFS-e v3 entra na v0.2 como adição em `service_invoices` | -| D8 | A — não retentar POST em 429 | -| Menor 1 | `Authorization: ` (sem prefixo) | -| Menor 2 | MIT | -| Menor 3 | GitHub Private Vulnerability Reporting | -| Menor 4 | ok — certificado de teste será renovado | -| Menor 5 | Escopo da chave de dados (companies e webhooks) é intencional; não reportar | -| Menor 6 | Publicar como `nfe-io` no PyPI assim que estiver funcional (com ok final antes do upload) | diff --git a/docs/contrato/probe-2026-10-08.md b/docs/contrato/probe-2026-10-08.md deleted file mode 100644 index f7efcfe..0000000 --- a/docs/contrato/probe-2026-10-08.md +++ /dev/null @@ -1,202 +0,0 @@ -# Sonda ao vivo — 2026-10-08 (somente leitura) - -Change: `add-sdk-foundation-v0-1` (fase 1, desenho). Objetivo: confirmar, antes de escrever código, -o contrato do que o v0.1 do SDK Python vai consumir. - -- **Método:** só `GET` (o helper `scripts/probes/_probe.py` recusa qualquer outro método antes de - abrir socket). Redirects **não** são seguidos automaticamente; quando seguidos, é sem credencial. -- **Credenciais:** `.env` (`NFE_API_KEY` = "chave principal", `NFE_DATA_API_KEY` = "chave de dados", - `NFE_COMPANY_ID` = empresa de teste do André). Nenhuma escrita. -- **Scripts:** `scripts/probes/p01_auth_matrix.py` … `p06_cnpj_v3.py` (rodar de dentro de `scripts/probes/`). Saída crua em - `scripts/probes/out/` (gitignored; contém dado real de terceiros). -- **Redação deste documento:** ids de empresa/conta → ``/``; CNPJ público de - amostra (Banco do Brasil) → ``; CNPJ da empresa de teste → ``; URIs de webhook - e segredos omitidos; query de URL pré-assinada reduzida aos nomes dos parâmetros. -- **Legenda:** **PROVADO** = observado hoje no fio. **INFERÊNCIA** = não observável com GET; fonte citada. - -## 1. Autenticação: esquema × host × chave - -| Host / rota amostrada | sem chave | chave principal | chave de dados | -|---|---|---|---| -| `api.nfe.io/v1/companies` | 401 | 200 | **200** | -| `api.nfe.io/v1/companies//serviceinvoices` | 401 | 200 | 403 | -| `api.nfse.io/v2/companies` | 401 | 200 | **200** | -| `api.nfse.io/v2/webhooks` | 401 | 200 | **200** | -| `api.nfe.io/v2/webhooks` | 401 | 200 | **200** | -| `legalentity.api.nfe.io/v{1,2}/legalentities/basicInfo/` | 401 | 403 | 200 | -| `address.api.nfe.io/v2/addresses/01310100` | 401 | 403 | 200 | -| `naturalperson.api.nfe.io/v1/naturalperson/status/{cpf}/{data}` | 401 | 403 | 404 (auth ok, CPF×data não confere) | - -**PROVADO:** -- Hosts de dados (`legalentity`, `address`, `naturalperson`) só aceitam a chave de dados (403 com a principal). -- Emissão NFS-e (`/serviceinvoices`) só aceita a chave principal (403 com a de dados). -- A chave de dados **também** recebe 200 em listagens de companies v1/v2 e webhooks. Isso é - novidade frente à fixture `api-key-host-matrix.json` do Node (que mediu outras rotas de - `api.nfse.io`, com 403). Não generalizar: o SDK continua roteando principal → fiscal, dados → consultas. -- Esquemas aceitos (iguais em todos os hosts testados): `X-NFE-APIKEY: `, `Authorization: `, - `Authorization: Basic ` (chave crua, **sem** base64) e `?apikey=`. - Rejeitados (401): `Basic base64(:)`, `Basic base64()`, `Bearer `. -- 401/403 vêm com **corpo vazio** e sem `Content-Type`; 401 traz `www-authenticate: Bearer`. - -## 2. Cabeçalhos de resposta - -- **PROVADO:** `x-request-id` presente em **todas** as respostas, de todos os hosts, inclusive - 401/403/404 (formato ASP.NET `0HNP51M3F3EVD:00000005`). Também `CF-RAY` (Cloudflare). - O vault registrava "nenhum header de request id" — está desatualizado. -- **PROVADO:** `api-deprecated-versions` / `api-supported-versions`: - `api.nfe.io/v1/companies` → `deprecated: 1.0`; `api.nfse.io/v2/companies` → `deprecated: 2.0`; - `legalentity …/v2/…` → `supported: 3.0, deprecated: 2.0`. -- **PROVADO:** corpos de erro com ProblemDetails trazem `traceId` (W3C `00---01`). -- **INFERÊNCIA:** `Retry-After` em 429 — não observável sem provocar rate limit; nenhum 429 nas sondas. - -## 3. Envelopes de erro observados hoje - -| Forma | Exemplo (redigido) | Onde | -|---|---|---| -| Corpo vazio | — | 401, 403; 404 de rota não servida (sem chave inclusive) | -| String JSON crua | `"pageCount must be between 1 and 50"`, `"Company not found."`, `"service invoice with id (…) was not found"`, `"company id is not valid"` | `api.nfe.io` v1 | -| `{"errors":[{"code":40401,"message":"…"}]}` | `company id not found`, `not found` | companies v1/v2 retrieve, CPF | -| `{"errors":[{"message":"…"}]}` (sem `code`) | `cnpj is null, white space or not valid` | CNPJ | -| `{"errors":[{"code":40001,"message":"…"}]}` | `postalCode with value '00000000' is not valid`, `birth date is not valid` | CEP, CPF | -| ProblemDetails `{type,title,status,traceId[,errors:{campo:[…]}]}` | `"The value 'abc' is not valid."` | binding ASP.NET (v1), 404 de webhook/pdf inexistente | - -**Consequência:** o extrator de mensagem do SDK precisa tratar os 5 formatos e nunca falhar em corpo vazio. - -## 4. Companies - -### v1 (`api.nfe.io/v1/companies`) — **PROVADO** -- Envelope de lista: `{"companies":[…], "page": }` — `page` é **inteiro** (eco do `pageIndex`); - **não há** `totalResults`/`totalPages`. Fim da paginação = página com menos itens que `pageCount`. -- `pageIndex`: 1-based; `0` → 400 `"pageIndex must be greater or equal to 1"`. -- `pageCount`: aceita 2–50; `1` → 400 `"pageCount must be between 1 and 50"` (mensagem mente); - `51`/`100` → 400; omitido → 10 itens. -- Retrieve por id **ou** CNPJ (`/v1/companies/`) → 200 `{"companies": {…}}` — objeto - único sob chave **plural**. -- Id inexistente → 404 `{"errors":[{"code":40401,"message":"company id not found"}]}`. -- Itens trazem `certificate {thumbprint, modifiedOn, expiresOn, status}`. - -### v2 (`api.nfse.io/v2/companies`) — **PROVADO** -- Envelope `{"hasMore": bool, "companies": […]}` (spec não declara `hasMore`). Retrieve → `{"company": {…}}`. -- `limit`: 1–50; `51` → 400 `"limit must be less than 50"` (mensagem mente, 50 é aceito); - `0` → 200 com lista vazia **e `hasMore: true`**; omitido → 10. -- Cursor `startingAfter`/`endingBefore` funciona nos dois sentidos. -- **Varredura completa terminou sem erro:** 662 empresas, sem duplicatas. O "registro venenoso" - (`startingAfter=3450bc62…`) que derrubava o gate em 2026-07 **hoje responde 200**. -- **Paridade v1×v2:** v1 = 357 empresas, v2 = 662; v1 ⊂ v2 (0 só em v1). v1 lista só empresas - com inscrição municipal (escopo NFS-e). -- **Projeções divergem (continua):** v1 tem `certificate, environment, federalTaxDetermination, - fiscalStatus, issRate, lastRpsSent, legalNature, municipalTaxDetermination, rpsNumber, - rpsSerialNumber, specialTaxRegime`; v2 tem `accountId, municipalTaxes, stateTaxes`. - Na empresa de teste, `municipalTaxNumber` é `""` no v1 e, no v2, **contém o id (32-hex) da - inscrição municipal** — mesmo nome, semântica diferente. -- Dados NFS-e (ambiente, RPS, ISS) vivem em `GET /v2/companies/{id}/municipaltaxes` → - `{"hasMore", "municipalTaxes":[{id, companyId, accountId, status, city, taxNumber, - specialTaxRegime, rpsSerialNumber, rpsNumber, issRate, environment, fiscalStatus, …}]}`. - -**Leitura para o gate v1→v2:** critério 1 (varredura quebra com 500) **não reproduz mais**; -critério 2 (projeções/`municipalTaxNumber` divergentes) **continua**. Ver `DECISOES-PENDENTES.md` (D3). - -## 5. Certificado — **PROVADO** - -- `GET api.nfe.io/v1/companies//certificate`, `GET api.nfse.io/v1/…/certificate` e - `GET api.nfse.io/v2/companies//certificates` → os três 200 com o **mesmo** envelope - `{"certificates":[{providerType, resolution, taxPayerId, thumbprint, taxId, subject, validUntil, - modifiedOn, status}]}`. -- **Atenção:** o certificado da empresa de teste vence em **2026-11-03**. Testes de integração de - emissão vão quebrar depois disso se não for renovado. -- Upload (multipart, campo `file`): **INFERÊNCIA** a partir da fixture - `client-nodejs/tests/fixtures/live-contracts/certificate-upload-field.json` (2026-09-01) — não - reprovado hoje por ser escrita. - -## 6. NFS-e (`api.nfe.io/v1/companies/{id}/serviceinvoices`) — **PROVADO** - -- Lista: `{"serviceInvoices":[…], "page": }`. `pageIndex=0` → 400 - `"page index must be greater then zero"` (mensagem diferente da de companies). `pageCount` aceita - **1**–50 aqui (51/100 → 400). Filtros `issuedBegin/issuedEnd` e `hasTotals` aceitos (200), mas - **`hasTotals=true` não acrescenta totais**: o envelope continua `{serviceInvoices, page}` (a spec - promete `totalResults`/`totalPages`). -- Amostra de 50 notas: `flowStatus` ∈ {`Issued`, `Cancelled`}; `status` idêntico ao `flowStatus` - nessas amostras. União de chaves: 43 campos (lista no JSON cru; inclui `paidAmount`, - `paymentMethod`, `nbsCode`, `approximateTotals`, `rpsStatus`). -- Retrieve → 200 objeto nu (sem envelope). -- `GET …/external/{externalId}`: - - existente → 200 `{"serviceInvoices":[{…}], "page":1}` (**lista**, a spec diz objeto); - - inexistente → **200 `{"serviceInvoices":[], "page":1}`** (não 404). -- Id inexistente ou malformado → 404 string `"service invoice with id (…) was not found"`. -- Empresa inexistente (24 ou 32 hex) → **404 `"Company not found."`** (o vault registrava 503 — - mudou, ao menos no GET; para POST continua **INFERÊNCIA** = 503, fonte `PHP/Retry seguro e idempotência.md`). -- `GET …/{id}/status` → **400** ProblemDetails `errors.rpsNumber: "The value 'status' is not valid."` - em 2 amostras. **Não existe rota de status em GET**: o polling tem que usar `GET …/{id}`. -- Downloads: - - `GET …/{id}/pdf` e `…/xml` → **302** para `https://api.nfse.io/v1/blob/download?` - (URL pré-assinada), independente do `Accept`. Seguido **sem credencial** → 200 - `application/pdf` (`%PDF-1.4`, ~55 KB) / `application/xml` (~1,3 KB). - - XML começa em `` **sem** prólogo ``): **INFERÊNCIA** a partir de 3 pings reais capturados em - 2026-06-11 (`client-nodejs/tests/fixtures/webhook-signatures.json`). Não é observável por GET. - -## 9. Consultas de dados — **PROVADO** - -- **CNPJ** (`legalentity.api.nfe.io`, chave de dados): `v1`, `v2` e **`v3`** de `basicInfo` respondem - 200, com **tipos diferentes para `federalTaxNumber`**: v1 = string formatada `"00.000.000/0001-91"` - (objeto nu), v2 = **inteiro** `191` (em `{"legalEntity":…}`), v3 = string de 14 dígitos - `"00000000000191"` (em `{"legalEntity":…}`). O header da v2 anuncia `deprecated 2.0 / supported 3.0`. - `stateTaxInfo/{UF}/{cnpj}` → 200 em v2 e **v3** (v3 também devolve `federalTaxNumber` string de - 14 posições). v3 com o CNPJ alfanumérico de exemplo da RFB (`12ABC34501DE35`, `p06_cnpj_v3.py`) passa - a validação e chega à base: 404 `{"errors":[{"message":"CNPJ not found in Serpro."}]}` — ou seja, a - v3 já aceita o formato alfanumérico. CNPJ com DV errado ou curto → 400 - `{"errors":[{"message":"cnpj is null, white space or not valid"}]}`; CNPJ com máscara no path → 404 vazio. -- **CEP** (`address.api.nfe.io`): `/v2/addresses/{cep}` → `{"address":{state, city{code,name}, - district, additionalInformation, streetSuffix, street, number, postalCode, country}}`; aceita - `01310100` e `01310-100`. `00000000` → 400 `{"errors":[{"code":40001,…}]}`. `/v1/addresses/{cep}` - → objeto nu. Busca `/v2/addresses?$filter=` → 404 (não servida). -- **CPF** (`naturalperson.api.nfe.io/v1/naturalperson/status/{cpf}/{AAAA-MM-DD}`): CPF válido com data - que não confere → 404 `{"errors":[{"code":40401,"message":"not found"}]}`; CPF inválido → 400 - `40001 "federal tax number is not valid"`; data `DD-MM-AAAA` → 400 `"birth date is not valid"`. - Caminho de sucesso (200) **não observado** (não há par CPF×data real de teste) → shape de sucesso é - **INFERÊNCIA** a partir de `cpf-api.yaml`. - -## 10. Rotas não servidas (baseline) - -- `GET api.nfe.io/v1/this-route-does-not-exist` e `api.nfse.io/v2/…` **sem chave** → 404 vazio. - Rota servida sem chave → 401. Isso distingue "rota inexistente" de "recurso inexistente". - -## 11. O que mudou desde o vault (re-teste com dado real) - -| Afirmação anterior | Fonte | Hoje | -|---|---|---| -| Cursor v2 de companies dá 500 em registro venenoso | `Migração companies v1→v2 …` | **200**; varredura completa ok | -| Empresa inexistente em `/serviceinvoices` → 503 | `PHP/Retry seguro …` | **404** (GET); POST não testado | -| `GET …/serviceinvoices/{id}/status` existe | `PHP/review-07-14-2026/02` | **400** binding `rpsNumber`; não usar | -| Nenhum header de request id | digest do vault | **`x-request-id` em toda resposta** | -| Webhooks só provados em `api.nfe.io` | `PHP/review-07-14-2026/08` | `api.nfse.io` também, mesmo conteúdo | -| Chave de dados → 403 em `api.nfse.io` | `api-key-host-matrix.json` | 403 em `/serviceinvoices`; **200** em companies/webhooks | diff --git a/docs/contrato/probe-fase2.md b/docs/contrato/probe-fase2.md deleted file mode 100644 index 55ca594..0000000 --- a/docs/contrato/probe-fase2.md +++ /dev/null @@ -1,94 +0,0 @@ -# Sonda da fase 2 — 2026-10-08 (leitura e escrita controlada, pelo próprio SDK) - -Change: `add-sdk-foundation-v0-1`, fase 2 (implementação). Diferente da sonda da fase 1 -(`probe-2026-10-08.md`, só `GET` com scripts avulsos), esta foi feita **pelo SDK**, nos testes -`tests/live/`, com transporte que grava cada troca redigida. - -- **Como rodar:** `uv run pytest tests/live --run-integration [--live-write]` (ou - `NFE_RUN_INTEGRATION=1` / `NFE_LIVE_WRITE=1`). Chaves do `.env`, nunca impressas. -- **Evidência crua redigida:** `tests/live/out/*.json` (fora do git). **Fixtures versionadas** - (envelope real, valores sintéticos): `tests/fixtures/live-contracts/`, reexecutadas por - `tests/unit/test_live_contracts.py`. -- **Redação deste documento:** ids encurtados (`6ac8…ba46`), sem CNPJ, CPF, chave ou URL - pré-assinada. -- **Legenda:** **PROVADO** = observado hoje no fio. **INFERÊNCIA** = não observado; fonte citada. - -## Escritas executadas (dentro da autorização da fase 2) - -| Permissão | O que foi feito | Resultado | Limpeza | -|---|---|---|---| -| a) NFS-e só na `NFE_COMPANY_ID` (≤ ~5 notas) | **2 notas** emitidas em homologação com `externalId` `sdkpy-`: `6ac8…ba46` (cliente sync) e `6ac8…1369` (cliente async). Uma tentativa de duplicata (recusada pela API, não criou nota) | ambas `Issued` | ambas **canceladas** (`Cancelled`, conferido depois por listagem) | -| b) UMA empresa descartável | `SDK-PY TESTE DESCARTAVEL 2026-10-08`, id `ad95…37dd`, CNPJ aleatório com DV válido (prefixo 99) | criar, consultar, alterar ok | `DELETE` → 204 duas vezes; a API faz **soft delete**: a empresa continua na conta com `status: Inactive` (não há remoção definitiva pela API) | -| c) webhooks de teste | 1ª tentativa em `https://example.com/nfeio-sdkpy-test` **recusada pela API** (verificação da URI, 405 → 400); depois 1 webhook em `https://httpbin.org/status/200?nfeio-sdkpy-test=1` | criar, consultar, alterar ok | apagado (204); conferido que não restou nenhum | - -Fora do escopo e **não** executado: `sendemail`, `ping` de webhook, upload de certificado real, -qualquer escrita em outras empresas. Um único `POST` de certificado com **arquivo falso** foi -enviado à empresa descartável para saber se a API aceita upload sem certificado válido (não -aceita: 500). - -## Resultados — provado × inferência - -### NFS-e (`api.nfe.io/v1`) - -| Ponto | Antes (fase 1) | Hoje | -|---|---|---| -| Emissão | INFERÊNCIA (fixture Node RTC): 202 + `Location` http | **PROVADO**: 202, `Location: http://api.nfe.io/v1/companies/{id}/serviceinvoices/{id}`, corpo = **nota completa** (38 chaves, `flowStatus: WaitingCalculateTaxes`), não só `{id, environment, flowStatus}` | -| Busca por `externalId` logo após o 202 | VAULT: atraso de "segundos" | **PROVADO**: achada na **primeira** chamada (0,21 s). O `wait=30` continua recomendado (o atraso pode voltar) | -| `externalId` repetido | VAULT (jul/2026) | **PROVADO**: 400, corpo string `"service invoice with external id (…) already exists"`, sem `code` | -| Polling | `GET …/{id}` (não há `/status`) | **PROVADO**: `WaitingCalculateTaxes` → `Issued` em segundos | -| PDF/XML | PROVADO (302 → blob) | **PROVADO de novo pelo SDK**: 302 → `api.nfse.io/v1/blob/download?b&e&s`, seguido **sem** `Authorization`; PDF ~56 KB `%PDF-1.4`; XML ~1,3 KB começando em `` | -| Cancelamento | INFERÊNCIA: 202 + `Location` | **PROVADO**: `DELETE` → 202 + `Location` http, corpo de nota; termina em `Cancelled` | -| XML de cancelamento | PROVADO (404 em provedor legado) | **PROVADO**: 404 string "National environment only…" | -| Valores | — | `Decimal("10.00")` enviado como `10.00`; `services_amount == Decimal("10.00")` na volta | -| Tomador com CPF inteiro | PROVADO (fio manda int) | **PROVADO**: `borrower.federal_tax_number == "52998224725"` | -| 500/504 com nota criada | VAULT (incidente) | **INFERÊNCIA** (não provocável sem incidente); o SDK trata como `outcome_unknown` | -| Empresa inexistente em POST → 503 | VAULT | **INFERÊNCIA** (não testado: exigiria POST em empresa alheia) | -| `sendemail` | spec | **INFERÊNCIA** (fora da autorização da fase 2) | - -### Empresas (`api.nfse.io/v2/companies`) - -| Ponto | Hoje | -|---|---| -| Criar | **PROVADO**: `POST` com `{"company": {…}}` → **200** (não 201), corpo `{"company": {…}}`, `federalTaxNumber` volta **inteiro** | -| Consultar / alterar | **PROVADO**: `GET`/`PUT` → 200 com envelope `company` | -| Excluir | **PROVADO**: `DELETE` → 204; **soft delete** — a empresa continua listada e consultável com `status: Inactive`; um segundo `DELETE` também dá 204 | -| Varredura por cursor | **PROVADO** de novo: 14 páginas de 50, sem duplicatas, sem erro | -| Chave principal | **PROVADO**: todas as rotas acima com `api_key` | - -### Certificados - -| Ponto | Hoje | -|---|---| -| Listar `GET /v2/companies/{id}/certificates` | **PROVADO** (envelope `certificates`, `validUntil`, `providerType`) | -| Upload v2, campo multipart `file` | **PROVADO em parte**: com arquivo falso a API respondeu **500** ProblemDetails (não 400 "File field is required"), ou seja, o binding aceitou `file`/`password` e falhou ao ler o PFX — mesmo comportamento da v1 em 2026-09-01. O caminho de sucesso exige PFX válido: **não testado** (teste pulado, registrado) | -| Certificado da empresa de teste | vence em **2026-11-03** (renovação combinada) | - -### Webhooks (`api.nfse.io/v2/webhooks`) - -| Ponto | Hoje | -|---|---| -| Verificação da URI na criação | **PROVADO (novo)**: a API chama a URI ao criar; `example.com` respondeu 405 → **400** `{"errors":[{"code":40001,"message":"WebHook verification failed … Error encountered: 405"}]}` | -| Criar | **PROVADO**: 201, envelope `webHook`; **o segredo é ecoado** no corpo; `status: "Inactive"` enviado na criação foi **ignorado** (voltou `Active`) | -| Alterar | **PROVADO**: `PUT` 200, substituição total; `status: "Inactive"` aplicado | -| Excluir | **PROVADO**: 204 | -| Tipos de evento | **PROVADO** (inclui `service_invoice.issued_successfully`) | -| Assinatura `X-Hub-Signature` | **INFERÊNCIA** a partir de 3 entregas reais de 2026-06-11 (vetores nos testes); `ping` não executado | - -### Consultas e autenticação - -| Ponto | Hoje | -|---|---| -| `Authorization: ` sem prefixo | **PROVADO** em todos os hosts usados | -| Chave de dados em NFS-e / chave principal em CEP | **PROVADO**: 403 com corpo vazio; o SDK acrescenta a dica de qual chave a família usa | -| CNPJ v3 (`basicInfo`, `stateTaxInfo`) | **PROVADO** (`federalTaxNumber` string de 14) | -| CEP v2 | **PROVADO** (`address.city.code`) | -| CPF com data divergente | **PROVADO**: 404 `{"errors":[{"code":40401,"message":"not found"}]}`; formato de sucesso segue **INFERÊNCIA** (spec) | -| `x-request-id` | **PROVADO** em todas as respostas, inclusive 403/404 e no blob | - -## Consequências no SDK - -- Fixtures de contrato novas e teste de replay (`test_live_contracts.py`). -- `companies.delete` documenta o soft delete; os testes ao vivo verificam `status == "Inactive"`. -- Teste ao vivo de webhook usa uma URL que responde 2xx (a verificação na criação inviabiliza - `example.com`); ver `DECISOES-FASE2.md`. -- A busca por `externalId` ficou estrita (só lista vazia explícita significa "não existe"). diff --git a/docs/contrato/revisao-seguranca-v0.1.md b/docs/contrato/revisao-seguranca-v0.1.md deleted file mode 100644 index 2723b82..0000000 --- a/docs/contrato/revisao-seguranca-v0.1.md +++ /dev/null @@ -1,135 +0,0 @@ -# Revisão de segurança — SDK Python v0.1 (2026-10-08) - -Revisão do código final de `src/nfeio/` contra o design §11 e as regras do projeto, feita depois -da implementação e antes de encerrar a fase 2. Cada item diz **onde** está o controle, **como** foi -verificado e o que foi **corrigido** durante a revisão. - -Ferramentas: `bandit -r src` (0 achados), `ruff` com regras `S` (0), `pip-audit` no lock de dev -(0 vulnerabilidades conhecidas), `mypy --strict`, revisão manual e um revisor automático de -segurança que apontou dois itens (ambos corrigidos, abaixo). - -## 1. Segredos em log, `repr` e exceções - -| Superfície | Controle | Verificação | -|---|---|---| -| Chaves no cliente/config | `SecretStr` (`_config.py`): `repr`/`str` mostram `****` + 4 últimos; `pickle` recusado | `test_secret_masking` | -| `RequestOptions(api_key=…)` | `__repr__` próprio mascarado | idem | -| Requisição | `HttpRequest.__repr__` redige `Authorization`/cookies, omite query e corpo, mascara o path | `test_repr_hides_authorization` | -| Cabeçalhos de resposta | `Headers.__repr__` redige `authorization`, `cookie`, `set-cookie`, `x-nfe-apikey` | `test_headers.py` | -| Erros da API | mensagem e `body` passam por `_scrub`: a chave usada (e senha de certificado/segredo de webhook, quando houver) vira `` mesmo se a API ecoar | `test_echoed_key_is_scrubbed`, `test_certificate_upload_500_not_retried_and_password_hidden` | -| Erros de rede | só host e classe da exceção do socket | leitura de `transport.py` | -| Objetos de resposta | `NfeObject.__repr__` mascara chaves `secret`, `password`, `apikey`, `token` (o ping de webhook traz `Secret`; a v1 de empresas traz `loginPassword`) | `test_repr_masks_secrets` | -| Evento de webhook | `WebhookEvent.__repr__` não mostra o corpo | `test_construct_event_ping_and_repr_hides_secret` | -| Logs | logger `nfeio`, só `DEBUG`, `NullHandler` por padrão; registra método, host, path mascarado, status, tentativa, espera e `x-request-id` | `test_retry_log_has_no_secrets`, `test_secret_leak_scan` | - -**Corrigido na revisão:** os logs e o `repr` da requisição gravavam o **path concreto**, que contém -CPF, data de nascimento, CNPJ e `externalId` (dados pessoais e de venda). Agora -`_core/redact.log_path` mantém só segmentos estáticos conhecidos e troca o resto por `*` -(`/v1/naturalperson/status/*/*`). Teste: `test_logs_and_reprs_have_no_personal_data`. - -Varredura completa (`test_secret_leak_scan`): sequência com sucesso, 401, 500, falha de rede e -upload de certificado, com log em `DEBUG`; nenhuma chave, senha ou segredo aparece em logs, -`repr`, `str` ou atributos das exceções. - -## 2. Redirect com credencial - -- Transportes **nunca** seguem redirect (`http.client` não segue; o protocolo exige). -- Só downloads seguem (`requestor.follow_download`): até 3 saltos, **só https**, requisição nova - **sem `Authorization`** nem outro cabeçalho de credencial. Provado ao vivo: o salto para - `api.nfse.io/v1/blob/download` saiu sem `Authorization` (`authorizationSent: false` na fixture). -- `Location` de 202 nunca é seguido como URL: só o path é lido, validado por `fullmatch` e a - empresa precisa coincidir. -- **Corrigido na revisão:** um 3xx fora de download chegava ao parser e, na busca por - `externalId`, podia virar "não existe". Agora `request()` levanta `UnexpectedResponseError` para - 3xx sem `allow_redirect`. Teste: `test_redirect_outside_downloads_is_an_error`. - -## 3. Injeção em path (ids) - -- Todo valor interpolado em path passa por `_core/paths.py`: ids opacos `[A-Za-z0-9_-]{1,64}`; - `externalId` com `quote(safe="")` (um segmento só), rejeitando vazio, `.`/`..`, controle e - > 255; CNPJ (numérico e alfanumérico, DV da RFB), CPF (DV), CEP, UF e data validados. -- Cursores (`starting_after`/`ending_before`) passam pela mesma validação; query via `urlencode`. -- **Corrigido na revisão (revisor automático):** os validadores usavam `^…$` com `re.match`, e em - Python `$` aceita `\n` final (`"abc\n"` passaria). Todos passaram a `fullmatch` (ids, CNPJ, data, - nomes de cabeçalho, `app_info`, campos multipart, `Location`). Teste: - `test_validators_reject_trailing_newline`. -- Cabeçalhos: chave, `Idempotency-Key` e `extra_headers` rejeitam CR/LF/NUL; `extra_headers` não - sobrescreve `Authorization`, `User-Agent` nem `Content-Type`. - -## 4. Tamanho de resposta e desserialização - -- Corpo lido em blocos de 64 KiB até `max_response_bytes` (10 MiB); `Content-Length` maior é - recusado antes de ler; estouro descarta a conexão (`ResponseTooLargeError`). Teste com servidor - local (`test_response_size_limit`). -- `Accept-Encoding: identity` e nenhuma descompressão no SDK (sem bomba de compressão). -- Webhooks: limite de 5 MiB **antes** de verificar; JSON só depois da assinatura. -- Só `json.loads`; nada de `pickle`, `eval`, `yaml.load`, parser de XML (`test_no_dangerous_calls_in_package`). - XML e PDF voltam como `bytes`; a doc recomenda `defusedxml`. -- **Corrigido na revisão:** JSON muito aninhado gerava `RecursionError` fora da hierarquia - `NfeError`; agora é `UnexpectedResponseError` (ou `json_body = None` em erros). Teste: - `test_deeply_nested_json_stays_inside_the_hierarchy`. -- Limite explícito de aninhamento: todo JSON não confiável (respostas, corpos de erro, webhooks) - passa por `_core/jsonutil.loads`, que recusa mais de `MAX_JSON_DEPTH = 128` níveis com - `ValueError` **antes** do `json.loads`, em custo linear (remove as strings e varre só - `[ ] { }`). Antes a proteção dependia do limite de recursão do interpretador, e o Python 3.14 - decodifica 100 mil níveis sem `RecursionError`. Testes: `test_json_depth_limit_is_explicit`, - `test_json_depth_ignores_brackets_inside_strings`, `test_deeply_nested_error_body_and_webhook`. -- Mensagens de erro saneadas (sem caracteres de controle) e truncadas em 1.000 caracteres. - -## 5. TLS - -- `ssl.create_default_context()` + `minimum_version = TLSv1_2`; verificação de cadeia e de nome - sempre ligadas. -- `ssl_context` do usuário é aceito só se `CERT_REQUIRED` e `check_hostname=True`; `ca_bundle` - só acrescenta confiança. Não existe `verify=False`. -- Testes com servidor local autoassinado: falha na fase "não estabelecida" sem a CA e sucesso com - `ca_bundle` (`test_self_signed_rejected_then_trusted_via_ca_bundle`). -- Base URLs só `https`, sem credenciais na URL, sem query/fragmento. - -## 6. Retry e efeitos fiscais - -- `POST` nunca é repetido após possível envio (timeout de leitura, conexão interrompida, 408, 429, - 5xx); só antes de conectar. Pool: `POST` sempre em conexão nova. `Idempotency-Key` não muda isso. -- Erros incertos carregam `outcome_unknown=True` e `external_id`; `DuplicateExternalIdError` é - reconhecido pelo texto. -- **Corrigido na revisão:** `find_by_external_id` tratava corpo vazio/forma desconhecida como "não - existe", o que faria a receita de reconciliação reemitir. Agora só a lista vazia explícita - significa "não existe" (`test_external_lookup_never_reads_garbage_as_not_found`). -- `Retry-After` limitado a 60 s; acima disso, erro com `retry_after` e sem espera. - -## 7. Disponibilidade - -- Timeouts finitos obrigatórios (conexão 10 s, leitura 60 s, total 120 s por tentativa). - Limitação da stdlib: a resolução DNS não respeita o timeout (documentado em `DECISOES-FASE2` F8). -- Polling e busca por `externalId` limitados por prazo; backoff com teto. -- **Corrigido na revisão:** paginação que não avança (mesmo cursor ou mesma página) gerava laço - infinito em `auto_paging_iter`; agora levanta `UnexpectedResponseError`. -- Cliente async: cancelar a task não interrompe a requisição em voo (thread). Cancelar um `create` - em andamento deve ser tratado como resultado incerto: reconcilie por `externalId` (README). - -## 8. Supply chain - -- Zero dependências de runtime (teste de metadados + wheel sem `Requires-Dist` + smoke em venv - limpo com só `nfe-io` instalado). -- `uv.lock` para dev; `pip-audit` no CI; Dependabot só para actions e dev. -- Workflows com `permissions: {}` e permissões mínimas por job; `persist-credentials: false`; - nenhuma interpolação de entrada não confiável em `run:`. -- Release: build único, environment `pypi` com aprovação, Trusted Publishing (OIDC) e attestations - PEP 740. -- **Pendente:** conferir os SHAs das actions (`scripts/verify_action_pins.py`; ver - `DECISOES-FASE2` F18). - -## 9. Webhooks - -- `verify_signature`: exige `sha1=` + 40 hex, HMAC-SHA1 sobre o corpo bruto, `hmac.compare_digest`, - nunca levanta exceção (entradas malformadas, segredo vazio, tipos inesperados → `False`). - Vetores reais (3 pings de 2026-06-11) e negativos da spec do Node. -- Sem proteção anti-replay no protocolo (não há timestamp assinado): a doc manda deduplicar por - `X-Hook-Id`. - -## Riscos aceitos / fora do escopo - -- Sem suporte a proxy na v0.1 (F10). -- `NfeObject` pode ser serializado com `pickle` pelo usuário (F5); o SDK nunca desserializa. -- `check_ssl_context` eleva `minimum_version` do contexto do usuário para TLS 1.2 (efeito colateral - no objeto recebido; documentado aqui). diff --git a/openspec/changes/add-sdk-foundation-v0-1/.openspec.yaml b/openspec/changes/add-sdk-foundation-v0-1/.openspec.yaml deleted file mode 100644 index 01660ba..0000000 --- a/openspec/changes/add-sdk-foundation-v0-1/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-10-08 diff --git a/openspec/changes/add-sdk-foundation-v0-1/design.md b/openspec/changes/add-sdk-foundation-v0-1/design.md deleted file mode 100644 index 510601c..0000000 --- a/openspec/changes/add-sdk-foundation-v0-1/design.md +++ /dev/null @@ -1,583 +0,0 @@ -## Context - -Motivação e escopo: ver `proposal.md`. Este documento decide **como** construir o v0.1. - -Restrições que moldam tudo (regras do projeto, não renegociadas aqui): Python 3.10+, zero dependências de -runtime, sync + async desde o início (async via `asyncio.to_thread`), PyPI `nfe-io` / import `nfeio`, -design inspirado no stripe-python, contrato decidido por spec + sonda ao vivo. - -Fontes de evidência citadas abaixo, por sigla: - -| Sigla | Fonte | -|---|---| -| **SONDA** | `docs/contrato/probe-2026-10-08.md` (seções §1–§11), saída crua em `scripts/probes/out/` | -| **SPEC** | `nfeio-docs/static/api/*` (cópia mais fresca) | -| **FIX** | `../client-nodejs/tests/fixtures/live-contracts/*.json` e `../client-nodejs/tests/fixtures/webhook-signatures.json` | -| **VAULT** | `~/Documents/obsidian/nfeio/SDKs/…` (nota citada pelo nome) | -| **NODE/PHP/RUBY** | código dos irmãos (só como referência de design, nunca de contrato) | - -Decisões marcadas **[D#]** foram **fechadas pelo André em 2026-10-08** (seção "Registro de decisões" de -`docs/contrato/DECISOES-PENDENTES.md`). Resumo do que está valendo: D1 = "D enxuta" (§2), D2 = A, D3 = A -(companies v2 com CRUD), D4 = A (CNPJ v3), D5 = A (reconciliação explícita), D6 = A, D7 = A -(`client.lookups`), D8 = A; auth `Authorization: `; licença MIT; `SECURITY.md` via GitHub Private -Vulnerability Reporting. Decisões novas surgidas na implementação ficam em -`docs/contrato/DECISOES-FASE2.md`. - -## Goals / Non-Goals - -**Goals:** - -- Núcleo único (sans-IO) para sync e async, sem duplicar lógica de retry, paginação e polling. -- Nenhum caminho de código que possa reenviar uma emissão cujo resultado é desconhecido. -- Modelos de resposta tipados (mypy `--strict`) **sem perder** nenhum campo do fio. -- Toda divergência spec × fio conhecida tratada explicitamente e coberta por teste com fixture. -- Superfície pequena e estável: o que entra no v0.1 precisa sobreviver até o 1.0 sem quebra. - -**Non-Goals:** - -- NF-e, NFC-e, CT-e, DC-e, distribuição DF-e, cálculo de impostos, RTC (v0.2+). -- NFS-e v3 (CNPJ alfanumérico) — ver Riscos; o desenho reserva espaço para ela ([D7]). -- Pessoas jurídicas/físicas (`legalpeople`/`naturalpeople`), notificações, `municipaltaxes` (escrita), - `DELETE /v2/webhooks` (apaga todos — fora por segurança). -- Geração de código a partir da OpenAPI (ver §12). -- Transportes httpx/aiohttp (o protocolo permite; não entregamos). - -## Decisions - -### §1. API pública: cliente explícito + serviços por recurso - -```python -from nfeio import NfeClient, AsyncNfeClient, RequestOptions - -client = NfeClient(api_key="…", data_api_key="…") # ou env NFE_API_KEY / NFE_DATA_API_KEY -inv = client.service_invoices.create( - company_id, {"borrower": {...}, "cityServiceCode": "…", "servicesAmount": 100.0}, - external_id="pedido-123", - options=RequestOptions(timeout=90, max_retries=0), -) -inv.flow_status # propriedade tipada snake_case (só campos curados, D1 enxuta) -inv["flowStatus"] # acesso ao campo do fio (sempre disponível) -inv["borrower"]["name"] # aninhado por chave: Mapping vira NfeObject; campo sem propriedade fica na chave -inv.last_response.request_id - -async with AsyncNfeClient(api_key="…") as aclient: - inv = await aclient.service_invoices.create_and_wait(company_id, params, external_id="pedido-123") -``` - -- **Cliente explícito, sem estado global** (stripe-python v8 `StripeClient`; o `stripe.api_key` global foi - a maior fonte de bug multi-tenant no ecossistema). ERPs como Odoo são multi-empresa/multi-chave: cada - `NfeClient` carrega sua configuração imutável. -- **Serviços como atributos** (`client.service_invoices`, `client.companies`, `client.certificates`, - `client.webhooks`, `client.lookups`) — nomes e agrupamento em **[D7]**. -- **Métodos e parâmetros em snake_case** (`company_id`, `external_id`, `page_index`). Corpos de requisição - seguem o fio em camelCase via `TypedDict` **[D2]**. -- **`RequestOptions`** (dataclass congelada, todos os campos opcionais), por chamada, sobrescreve o cliente: - `api_key` (substitui a chave que o recurso usaria), `timeout` (`float | Timeout`), `max_retries`, - `idempotency_key` (enviado como `Idempotency-Key`; **não** muda a política de retry porque a API o - ignora — VAULT `PHP/Ask API — Idempotency-Key…`), `extra_headers` (não pode sobrescrever - `Authorization`, `User-Agent`, `Content-Type`). -- **`external_id` não é `RequestOptions`:** é campo do corpo (`externalId`) com semântica de negócio (chave - de dedupe). Vira argumento nomeado de `service_invoices.create` para ficar visível na assinatura. -- **Chaves:** `api_key` (emissão/gestão) e `data_api_key` (consultas). Se omitidas, lê `NFE_API_KEY` / - `NFE_DATA_API_KEY`. **Sem fallback** de chave principal para hosts de dados: a chave principal recebe - 403 em todos os hosts de dados (SONDA §1), então o fallback só espalharia a chave mais poderosa sem - ganho. Consulta sem `data_api_key` → `ConfigurationError` antes de abrir socket. Quem tem uma chave só - passa o mesmo valor nos dois parâmetros, explicitamente. -- `NfeClient` é thread-safe e reutilizável; `close()` / context manager liberam o pool. - -Alternativas descartadas: módulo global estilo `stripe.api_key` (estado compartilhado); resources como -métodos de classe do modelo (`ServiceInvoice.create(...)`, stripe legado) — mistura dado e transporte. - -### §2. Modelos de resposta: visão tipada sobre o dict do fio [D1] - -| Opção | Tipagem | Campos desconhecidos | Custo de manutenção | Usado por | -|---|---|---|---|---| -| A. Objeto dinâmico (`StripeObject`, dict + `__getattr__`) | fraca (stubs à parte) | preservados | baixo | stripe-python | -| B. `@dataclass(frozen, slots)` + `raw` | forte | só em `raw`; risco de DTO incompleto | alto (mapear cada campo) | PHP (DTO + `raw`), Ruby (`Data.define` + `raw`) | -| C. `TypedDict` do fio (camelCase) | média (chaves) | preservados (é dict) | baixo | Node (interfaces sobre JSON) | -| **D. Visão tipada: `Mapping` imutável do fio + propriedades snake_case tipadas** | forte nas propriedades | **preservados por construção** | médio | — (híbrido de A e B) | - -**Decidido: D enxuta (André, 2026-10-08).** `NfeObject` implementa `Mapping[str, Any]` imutável sobre o -JSON decodificado. Regras: - -- **Propriedades tipadas só onde corrigem ou tipam algo:** ids, status/enums abertos, datas (`datetime` - aware; `date` para datas puras), dinheiro e alíquotas (`Decimal`), documentos (CNPJ/CPF como `str` - normalizada) e os aninhados que contêm documento (`borrower`, `provider`). Texto livre (`name`, - `description`, `cityServiceCode`, `uri`, …) fica **só** no acesso por chave. Cada modelo tem uma lista - pequena; nada de espelhar a spec inteira. -- **Sem `__getattr__` dinâmico.** Evita a colisão com `items`/`keys`/`values`/`get` do `Mapping` (a NF-e - da v0.2 tem campo `items`) e mantém erro de digitação visível para o mypy. -- **`obj["x"]` devolve o valor do fio**, com uma conversão: `Mapping` aninhado vira `NfeObject` genérico - (imutável, com `to_dict()`), e lista vira `list` **nova** cujos `Mapping` também viram `NfeObject` - (mutar a lista devolvida não altera o objeto). Escalares voltam como vieram (`float` para dinheiro, - D6). `obj.to_dict()` continua devolvendo o fio puro (dicts e listas comuns). -- **Aninhado por propriedade** devolve o modelo tipado do aninhado (`inv.borrower` → `Party` com - `federal_tax_number`); por chave devolve `NfeObject` genérico. -- Propriedades são **descritores declarativos** (`StrField("flowStatus")`, `DecimalField(...)`, …) que - registram a chave do fio; o teste de alinhamento (§12) lê esse registro. -- `repr` mascara chaves sensíveis (`secret`, `password`, `apiKey`, sem distinção de maiúsculas) — a - entrega de ping de webhook traz o `Secret` em claro; `to_dict()` não mascara. - -- **Forward-compat sem esforço:** nada é copiado nem filtrado; campo novo no fio aparece em `obj["novo"]` - e em `obj.to_dict()` no mesmo dia. Elimina a classe de bug "DTO cobre 13 de 39 campos" (VAULT - `PHP/DTO ServiceInvoice incompleto`) e o `totalAmount` fantasma. -- **Lição do Ruby:** classes geradas que descartam chaves não listadas (`Data.define` + `from_api`) perderam - dado — D não tem esse modo de falha. -- `to_dict()` devolve cópia profunda, JSON-serializável, com as chaves do fio; `__eq__` compara o dict. -- `last_response` (`status_code`, `headers` case-insensitive, `request_id`) em todo objeto/página. -- **Datas:** propriedades `datetime` (aware) com parser tolerante (sufixo `Z`, 0–7 dígitos fracionários, - data sem hora) porque `datetime.fromisoformat` do 3.10 não aceita os formatos do fio - (`2026-09-01T02:55:33.418+00:00`). Valor não parseável → propriedade `None`; o texto segue em `obj[...]`. - Texto sem fuso é interpretado como UTC (**INFERÊNCIA**, registrada em `DECISOES-FASE2.md`). -- **Dinheiro e alíquotas [D6]:** propriedades devolvem `Decimal` construído de `repr(float)` (recupera o - literal decimal do JSON para até 15 dígitos significativos); o dict cru fica com `float` (JSON nativo, - serializável). Na requisição, `Decimal` é aceito e serializado como número JSON exato. -- **Enums abertos:** `flow_status` é `str`, com constantes em `FlowStatus` e `Literal` para os valores - conhecidos — valor novo do servidor não quebra (ex.: `Error`, `WaitingReturnCancel`, ver §16). -- Tipos de documento fiscal (`federalTaxNumber`) expostos como `str` normalizado (só dígitos/letras), - porque o fio alterna `int` (perde zeros à esquerda: CNPJ `00000000000191` vira `191`), string formatada - e string crua (SONDA §9) — e o CNPJ alfanumérico não cabe em `int`. - -### §3. Matriz host × família × chave - -| Família (v0.1) | Base | Chave | SPEC | Prova | -|---|---|---|---|---| -| NFS-e v1 | `https://api.nfe.io/v1/companies/{id}/serviceinvoices` | principal | `nf-servico-v1.yaml` servers[0] | SONDA §1 (dados → 403), §6 | -| Empresas v2 **[D3]** | `https://api.nfse.io/v2/companies` | principal | `contribuintes-v2.json` | SONDA §1, §4 | -| Empresas v1 (legado; **não usada** — D3 = v2) | `https://api.nfe.io/v1/companies` | principal | `nf-servico-v1.yaml` | SONDA §4 | -| Certificados | `https://api.nfse.io/v2/companies/{id}/certificates` | principal | `contribuintes-v2.json` | SONDA §5 (GET); upload v2 = **INFERÊNCIA** (FIX provou o campo `file` só no `POST /v1/…/certificate`; confirmar na fase 2a, com v1 como plano B) | -| Webhooks | `https://api.nfse.io/v2/webhooks` | principal | `nf-servico-v1.yaml` servers[1] "Webhooks" | SONDA §8 (`api.nfe.io` idem) | -| CNPJ **[D4]** | `https://legalentity.api.nfe.io/v3/legalentities` | dados | `consulta-cnpj-v3.json` (sem host) + `consulta-cnpj.yaml` (host) | SONDA §9 | -| CPF | `https://naturalperson.api.nfe.io/v1/naturalperson` | dados | `cpf-api.yaml` | SONDA §1, §9 | -| CEP | `https://address.api.nfe.io/v2/addresses` | dados | `consulta-endereco.yaml` | SONDA §1, §9 | -| Download (redirect) | `https://api.nfse.io/v1/blob/download?b&e&s` | **nenhuma** | — | SONDA §6 | - -- Cada recurso declara sua `ApiFamily` (`FISCAL` | `DATA`); o requestor escolhe chave e host pela família. - `base_urls` é configurável por família (testes, proxy corporativo), nunca por recurso solto. -- **Esquema de auth: `Authorization: `** (crua, sem prefixo — decidido, Menor 1). A chave **nunca** - segue um redirect (download 302 pré-assinado vai para outro host sem nenhum cabeçalho de credencial). É o único esquema que está **na spec** - (`securitySchemes` de todas as famílias) **e** provado em todos os hosts (SONDA §1). `X-NFE-APIKEY` - também funciona mas não está em spec alguma; e `Authorization` é redigido por padrão por proxies, - APMs e loggers, enquanto um header custom tende a vazar em log. `?apikey=` nunca (vaza em log de URL). -- Não existe host sandbox: ambiente é atributo da empresa/chave (VAULT `PHP/review-07-14-2026/08`). -- A chave de dados recebeu 200 em companies/webhooks (SONDA §1) — anotado, **não** explorado: o - roteamento segue a família. - -### §4. Transporte stdlib plugável - -```python -class Transport(Protocol): - def send(self, request: HttpRequest) -> HttpResponse: ... # nunca segue redirect - def close(self) -> None: ... - -class AsyncTransport(Protocol): - async def send(self, request: HttpRequest) -> HttpResponse: ... - async def aclose(self) -> None: ... -``` - -- **Implementação padrão sobre `http.client`, não `urllib.request`.** Motivo decisivo: classificar a - **fase da falha** (§5). Com `http.client` o `connect()` é explícito: DNS, conexão recusada e handshake - TLS falham **antes** de qualquer byte da requisição sair (`ConnectionNotEstablished`); qualquer falha - depois disso é `RequestMaybeSent`. `urllib` mistura as duas num `URLError`. Além disso `urllib` segue - redirects **reenviando headers ao novo host** — inaceitável com a chave em `Authorization`. -- **Pool:** LIFO de `HTTPSConnection` por host, com lock, máx. 10 ociosas/host. **Requisições não - idempotentes (POST) sempre usam conexão nova**: falha em conexão keep-alive reaproveitada é ambígua - (o servidor pode ou não ter lido), e uma emissão não pode cair nesse caso por economia de handshake. -- **TLS:** `ssl.create_default_context()`, `minimum_version = TLSv1_2`, verificação de hostname e cadeia - sempre. Aceita `ssl_context` ou `ca_bundle` do usuário **somente para acrescentar confiança** (proxy - corporativo); contexto com `CERT_NONE` ou `check_hostname=False` → `ConfigurationError`. Não há flag - `verify=False`. Quem precisar mesmo (laboratório) injeta o próprio `Transport` — escolha explícita e - visível em code review. -- **Timeouts finitos:** padrão `Timeout(connect=10.0, read=60.0)`; `read` é por operação de socket e há - um **prazo total** por tentativa (`total=120.0`) checado entre leituras de chunk. `None`/infinito é - rejeitado. 60 s de leitura acompanha o PHP e o 408 "60s" documentado na emissão (SPEC - `nf-servico-v1.yaml` 408). -- **Limite de resposta:** leitura em chunks de 64 KiB até `max_response_bytes` (padrão 10 MiB, PDFs de NFS-e - têm ~55 KB — SONDA §6); estourou → `ResponseTooLargeError`, conexão descartada. Envia - `Accept-Encoding: identity` (sem gzip ⇒ sem bomba de descompressão). -- **User-Agent honesto:** `nfe-io-python/<__version__> python/. ` - + sufixo opcional `app_info=("odoo-l10n_br_nfse_nfeio", "18.0.1.0")` → `… odoo-l10n_br_nfse_nfeio/18.0.1.0`. - `__version__` vem de um único `src/nfeio/_version.py`, lido pelo hatchling; teste falha se outra literal - de versão aparecer no pacote. Lição do Node v6 (CHANGELOG: 93.995 requisições/30 dias, 23 variantes de - UA, **uma** versão reportada, nome de pacote inexistente). O sufixo `app_info` atende o plano de - telemetria por módulo de ERP (VAULT `ideias-integracoes/03`). -- **Multipart sem dependências** (`_multipart.encode`): boundary `secrets.token_hex(16)`, partes `file` - (filename sanitizado: sem `"`, CR, LF; `Content-Type: application/x-pkcs12`) e `password`; corpo em - `bytes` (certificado A1 tem poucos KB). Nome do campo `file` (minúsculo, SPEC v1 e FIX - `certificate-upload-field.json`: `file`/`File` aceitos, `certificate` → 400). -- **Redirects:** o transporte devolve o 3xx. Só a operação de download segue: até 3 saltos, **só https**, - **sem nenhuma credencial** (a URL pré-assinada é autoportante; o salto muda de `api.nfe.io` para - `api.nfse.io` — SONDA §6). Sem allowlist de host (a inbound usa R2, VAULT `P1/00` ②). -- **`Location` de 202** nunca é seguido como URL: só o **path** é lido (vem `http://` — FIX - `service-invoice-rtc-create.json`), validado contra `/v1/companies/{company_id}/serviceinvoices/{id}` e o - id extraído; a URL de polling é reconstruída a partir da base configurada. - -### §5. Retry ciente de método e de fase (port do PHP `RetryingTransport`) - -Tabela (VAULT `PHP/Retry seguro e idempotência`, PHP `src/Http/RetryingTransport.php:18-22`, adaptada): - -| Método | 429 | 408, 500, 502, 503, 504 | falha antes de conectar | falha após envio / timeout de leitura | -|---|---|---|---|---| -| GET, HEAD, PUT, DELETE | retenta | retenta | retenta | retenta | -| POST (qualquer) | **não** [D8] | **não** | retenta | **não** | - -- Outros 4xx nunca são retentados. 501/505+ não são transitórios. -- **Diferença deliberada do PHP:** o PHP retenta POST com `Idempotency-Key` em tudo. Aqui **não** — a API - ignora o header (VAULT `PHP/Ask API…`), então a premissa é falsa hoje. Quando a API honrar, basta - liberar POST+chave na tabela (mudança aditiva). -- **PUT de webhook é full-replace** (idempotente de fato) → retentável. `DELETE` de cancelamento é - idempotente no efeito; um segundo cancelamento devolve erro de negócio, não duplica. -- Backoff: `delay(n) = min(max_delay, base · 2^(n−1)) · U(0.7, 1.3)`, padrões `max_retries=3` (4 - tentativas), `base=1.0 s`, `max_delay=30 s` — mesmos números de PHP/Ruby/Node para comportamento - previsível entre SDKs. Jitter via `random.SystemRandom` (evita alerta do bandit, sem custo relevante). -- **`Retry-After`:** segundos inteiros **ou** HTTP-date (`email.utils.parsedate_to_datetime`); usado se - ≤ `max_retry_after` (60 s); maior que isso → não retenta, levanta `RateLimitError(retry_after=…)`. - (PHP/Ruby só aceitam inteiro.) **INFERÊNCIA:** a API manda `Retry-After` — nunca observado (SONDA §2). -- Cada tentativa é registrada em `logging.getLogger("nfeio")` em DEBUG, com método, host, path, status, - tentativa, atraso, `x-request-id` — **nunca** headers de auth, corpo ou query. - -**Dedupe de emissão por `externalId` [D5]:** - -- `service_invoices.create(..., external_id=…)` grava `externalId` no corpo. A API rejeita o segundo POST - com o mesmo valor (`400 "service invoice with external id (…) already exists"`, sem código) — VAULT - `PHP/Retry seguro…`, probe 2026-07-05. -- Erros com resultado incerto (5xx, 408, falha após envio, em POST de emissão) saem como a classe normal - (`ServerError`, `APIConnectionError`) **com `outcome_unknown=True` e `external_id`** preenchidos. O 400 - de duplicidade vira `DuplicateExternalIdError(InvalidRequestError)` reconhecido pelo texto (casamento - tolerante: "external id" + "already exists"), carregando `external_id`. -- `find_by_external_id(company_id, external_id, *, wait=0.0) -> ServiceInvoice | None`: `GET - …/external/{id}`; desembrulha a **lista** (`{"serviceInvoices":[…]}`; vazio = `None`, não 404 — - SONDA §6). Com `wait>0`, repete com backoff até o prazo para cobrir o atraso de indexação logo após o - 202 (VAULT: "segundos"). -- Recomendação: **reconciliação explícita** — o SDK nunca reenvia uma emissão sozinho; a doc traz a - receita de 6 linhas (`try create / except outcome_unknown or Duplicate → find_by_external_id(wait=30)`). - -### §6. Hierarquia de erros - -``` -NfeError -├── ConfigurationError (também ValueError) chave ausente, TLS inseguro, opções inválidas -├── InvalidParameterError (também ValueError) ID/CNPJ/CPF/CEP inválido antes da requisição -├── APIError status_code, message, error_code, body (bytes), json_body, headers, -│ │ request_id (x-request-id), trace_id (ProblemDetails), outcome_unknown -│ ├── InvalidRequestError 400, 405, 410, 415, 422 e demais 4xx não listados abaixo -│ │ └── DuplicateExternalIdError -│ ├── AuthenticationError 401 -│ ├── PermissionDeniedError 403 (mensagem orienta: "esta família usa api_key|data_api_key") -│ ├── NotFoundError 404 -│ ├── ConflictError 409 -│ ├── RateLimitError 429 (+ retry_after) -│ └── ServerError 5xx (408 também, por ser timeout do lado do servidor) -├── APIConnectionError falha de rede; phase = NOT_ESTABLISHED | MAYBE_SENT; outcome_unknown -│ └── APITimeoutError -├── ResponseTooLargeError -├── UnexpectedResponseError 2xx com corpo não parseável ou forma impossível (ex.: 202 sem id) -├── InvoiceProcessingError polling terminou em IssueFailed/CancelFailed/Error (+ invoice, flow_message) -├── PollingTimeoutError prazo do wait esgotado (+ último estado) -└── SignatureVerificationError só em construct_event (verify_signature devolve bool) -``` - -- Nomes evitam colidir com builtins (`APITimeoutError`, não `TimeoutError`). -- **Extração de mensagem** cobre os 5 envelopes vistos hoje (SONDA §3): corpo vazio → texto padrão por - status; **string JSON crua**; `{"errors":[{"code","message"}]}` (junta mensagens, `error_code` = 1º - `code`); `{"code","message"}`; ProblemDetails (`title` + `errors{campo:[…]}` → `"campo: msg"`; - `traceId`). Mensagem saneada (sem caracteres de controle) e truncada em 1.000 caracteres; corpo - integral fica em `body`. Node/PHP/Ruby cobrem só parte disso (PHP e Ruby perdem string crua e - ModelState; Node mapeia 403 como `ValidationError`). -- `str(err)` e `repr(err)` nunca incluem headers de auth nem o corpo inteiro. - -### §7. Paginação - -- **Offset v1 (1-based)** — NFS-e: `list(company_id, *, page_index=1, page_count=?, issued_begin=…, - issued_end=…, created_begin=…, created_end=…) -> OffsetPage[ServiceInvoice]`. Nomes espelham o fio - (`pageIndex`/`pageCount`) para casar com a doc da API; `page_count` = itens por página. -- O fio **não traz totais** (`{serviceInvoices, page:int}`, mesmo com `hasTotals=true` — SONDA §6), logo - `has_more = len(data) == page_count` (**inferência** controlada: no pior caso, 1 requisição extra que - volta vazia). -- Validação local: `page_index ≥ 1`; `page_count` em 1–50 para NFS-e, **2–50** para companies v1 (o - servidor rejeita 1 com mensagem que diz "between 1 and 50" — SONDA §4). Limites em uma tabela única. -- **Cursor** — companies v2 (**[D3]**): `list(*, limit=10, starting_after=None, ending_before=None) -> - CursorPage[Company]`; `has_more` do fio. `limit` validado em 1–50 (`limit=0` devolve - `hasMore:true` com lista vazia — loop infinito garantido sem a validação, SONDA §4). -- `page.auto_paging_iter()` (sync: `Iterator[T]`; async: `AsyncIterator[T]`) percorre a página atual e as - seguintes e para em página vazia **independentemente** de `has_more`. No cursor, as páginas seguintes usam - `limit=50`; no offset, mantém o `page_count` da primeira página (trocar o tamanho no meio desloca o - `pageIndex` e pularia itens) — a doc recomenda `list(..., page_count=50).auto_paging_iter()`. Também - `page.next_page() -> Page | None`. -- Sem `list_all()` que materializa tudo em memória (contas com milhares de notas). - -### §8. Fluxo assíncrono 202 + Location e polling - -- `create()` aceita 202 (assíncrono, caso normal), 200/201 (imediato). Com 202: o corpo - (`{id, environment, flowStatus[, flowMessage]}`, FIX `service-invoice-rtc-create.json`) é **preservado** - como `ServiceInvoice` parcial (o Node descarta o corpo); `id` do corpo, ou do path do `Location` se - faltar; sem nenhum dos dois → `UnexpectedResponseError`. -- `cancel()` idem: 202 + `Location` (**INFERÊNCIA** de escrita: VAULT memória - `integration-tests-need-emission-capable-company`, `Node/review-07-20-2026/01`; spec diz 200 string). - Aceita também 200 com corpo de nota ou string. -- `wait(company_id, invoice_id, *, timeout=120, initial_interval=1.0, max_interval=10.0, backoff=1.5, - raise_on_failure=True) -> ServiceInvoice`: faz `GET …/serviceinvoices/{id}` até `flowStatus` terminal. - **Não** usa `/status`: essa rota não existe em GET (400 de binding `rpsNumber` — SONDA §6, contrariando - a review do PHP). -- Terminais: `Issued`, `Cancelled` (sucesso); `IssueFailed`, `CancelFailed`, `Error` (falha → - `InvoiceProcessingError` com `flow_message`). Não terminais conhecidos (SPEC enum + RTC): - `PullFromCityHall`, `WaitingCalculateTaxes`, `WaitingDefineRpsNumber`, `WaitingSend`, `WaitingSendCancel`, - `WaitingReturn`, `WaitingReturnCancel`, `WaitingDownload`. **Valor desconhecido = não terminal** (continua - até o prazo) — mais seguro que declarar sucesso/falha por palpite. -- 404 nos primeiros `not_found_grace=10 s` é tratado como "ainda indexando" (**INFERÊNCIA**, simétrica ao - atraso do `/external`); depois disso propaga. -- Atalhos: `create_and_wait(...)`, `cancel_and_wait(...)`. O `wait` herda o retry de GET; o prazo total é - do `wait`, não de cada tentativa. -- Alternativa descartada: `client.poll_until_complete(location)` genérico do Node (heurística fraca - `status==='completed'`, engole erros). - -### §9. Webhooks - -- **CRUD** em `/v2/webhooks` (`api.nfse.io`, SPEC servers[1]; `api.nfe.io` serve o mesmo conjunto — - SONDA §8): `list()` desembrulha `webHooks`; `retrieve(id)` desembrulha `webHook`; `create(params)` e - `update(id, params)` embrulham em `{"webHook": …}` (sem envelope → 400, NODE spec `account-webhooks`); - `delete(id)`; `ping(id)` (`PUT …/pings`); `event_types()` (`/eventtypes`, desembrulha `eventTypes`). -- **`update` é substituição total no fio** — omitir `status` desativa o webhook (NODE CHANGELOG 5.1.0). - O SDK não faz merge mágico; a doc e o docstring avisam, e o exemplo mostra `retrieve` → alterar → - `update`. -- `contentType`/`status` são **strings** no fio (`"json"`, `"Active"`), apesar da spec dizer inteiro - (SONDA §8, FIX `webhooks-wire-types.json`). Tipados como `str` com constantes conhecidas. -- **`nfeio.webhooks.verify_signature(payload, signature, secret) -> bool`** — função de módulo (não precisa - de cliente): - - `payload: bytes | str` (str é codificada em UTF-8; a doc exige o corpo cru, nunca `json.dumps` de um - dict re-serializado); `signature: str | Sequence[str] | None` (valor de `X-Hub-Signature`; lista → - primeiro); `secret: str | bytes`. - - Exige prefixo `sha1=` (case-insensitive) + exatamente 40 hex; calcula `HMAC-SHA1(secret, payload)`; - compara com **`hmac.compare_digest`** sobre os hex em minúsculas (o servidor manda maiúsculas). - - **Nunca lança**: qualquer entrada malformada, segredo vazio ou exceção interna → `False`. - - Vetores de teste: os 3 pings reais de `FIX webhook-signatures.json` (verificados com `hmac` do - Python em 2026-10-08: os 3 conferem) + os negativos da spec Node `webhook-signature-verification` - (adulteração de 1 byte, `sha256=`, hex sem prefixo, `sha1=abc`, não-hex, segredo vazio, header vazio). -- **`construct_event(payload, headers_or_signature, secret) -> WebhookEvent`**: verifica primeiro (falha → - `SignatureVerificationError`), só então faz `json.loads` (limite de 5 MiB). Envelopes: entrega de evento - `{"action": "issued_successfully", "payload": {nota}}` (SPEC prosa `catalogo-saida-nfse.md`) e ping - `{"action":"ping","webHook":{…}}` (FIX). `WebhookEvent` expõe `action`, `event_type` (`X-Hook-Event`), - `hook_id` (`X-Hook-Id`, chave de idempotência da entrega at-least-once), `data` (`ServiceInvoice` quando - o tipo é `service_invoice`, senão `NfeObject`) e `raw: bytes`. Sem proteção anti-replay no protocolo - (não há timestamp assinado) — a doc manda deduplicar por `X-Hook-Id`. -- `X-NFe-Signature`/SHA-256 legado: não suportado (RUBY `webhook.rb:27-29`). - -### §10. Cliente assíncrono sem duplicar lógica - -Núcleo **sans-IO baseado em geradores**: cada operação é um gerador que produz efeitos e recebe -resultados. - -```python -Op = Generator[Effect, Any, T] # Effect = Send(HttpRequest) | Sleep(seconds) | Now() - -def create_service_invoice(ctx, company_id, params, external_id, options) -> Op[ServiceInvoice]: - resp = yield from send_with_retry(ctx, build_request(...), options) # retry também é um Op - return parse_create(resp, company_id, external_id) - -def run_sync(op, transport, clock) -> T: ... # Send → transport.send; Sleep → time.sleep -async def run_async(op, atransport) -> T: ... # Send → await atransport.send; Sleep → asyncio.sleep -``` - -- Retry, paginação, polling, follow de redirect e reconciliação por `externalId` são escritos **uma vez**, - como `Op`. As fachadas `NfeClient`/`AsyncNfeClient` só repetem **assinaturas** (necessário para tipos e - docs) e delegam: `return self._run(ops.create_service_invoice(...))` / - `return await self._run(ops.create_service_invoice(...))`. -- Teste de paridade: para cada método público, `inspect.signature` sync == async (salvo `async`) — impede - deriva das fachadas. -- `AsyncTransport` padrão = `ThreadedAsyncTransport(transport_sync)` → `asyncio.to_thread(sync.send, req)`. - O executor é o default do loop (o usuário pode trocar via `loop.set_default_executor`). Cancelamento de - task não interrompe a thread em voo; o timeout de socket limita quanto ela vive (documentado). Um - transporte httpx nativo no futuro implementa `AsyncTransport` sem tocar no núcleo. -- Testes do núcleo rodam os `Op` com um driver falso determinístico (sem rede, sem sleep real) — o mesmo - teste vale para sync e async. - -Alternativas: (a) duas implementações à mão — deriva garantida; (b) `unasync` no build — ferramenta extra, -código gerado versionado (regra 5 exigiria `_generated/`) e o caminho sync vira derivado do async, o -oposto do que a maioria dos usuários (Odoo/ERPNext síncronos) roda. - -### §11. Segurança - -- **Segredos:** `api_key`/`data_api_key` guardados em `SecretStr` interno com `__repr__`/`__str__` - mascarados (`'nfe_…a1b2'` → só últimos 4); `repr(client)`, `repr(config)`, `repr(RequestOptions)` - mascarados. Logger `nfeio` nunca recebe headers, query ou corpo; erros não ecoam o header - `Authorization`. Senha de certificado nunca aparece em repr/log/exceção. Teste varre `repr`/logs/ - exceções de uma execução completa procurando a chave. -- **IDs em path:** toda interpolação passa por `_paths.segment(kind, value)`: IDs opacos casam - `^[A-Za-z0-9_-]{1,64}$` (cobre os 24 e 32 hex observados sem cravar tamanho); `external_id` (texto - livre do cliente) é percent-encoded com `quote(safe="")`, rejeita vazio, `.`/`..`, controle e > 255; - CNPJ (14 chars `[0-9A-Z]{12}[0-9]{2}`, aceita máscara, valida DV — algoritmo alfanumérico da RFB), CPF - (11 dígitos + DV), CEP (8 dígitos), data `date`/`AAAA-MM-DD`. Falha → `InvalidParameterError` sem - requisição (consultas de dados podem ser tarifadas — validar DV local evita custo). -- **Desserialização:** só `json.loads` (com limite de tamanho já aplicado); nada de `pickle`, `eval`, - `yaml.load`, `xml` parse no SDK (XML/PDF são devolvidos como `bytes`, o usuário escolhe o parser — - recomendar `defusedxml` na doc). -- **TLS:** §4. **Redirects:** §4 (sem credencial, só https). **Logs:** §5. -- **Supply chain:** zero deps de runtime (a superfície de ataque do pacote instalado é só o próprio - código); `uv.lock` para dev; GitHub Actions com actions **fixadas por SHA**, `permissions: {}` por padrão - e mínimas por job; **PyPI Trusted Publishing (OIDC)** — sem token de PyPI em secret; publicação com - **attestations PEP 740** (Sigstore) pelo `pypa/gh-action-pypi-publish`; build único, artefato - verificado e publicado em job separado com environment `pypi` protegido (aprovação manual); - `pip-audit` sobre `uv export` (dev); Dependabot só para `github-actions` e dev-deps; CodeQL (python) - pelo default setup do repositório no GitHub, sem workflow próprio; `bandit -r src` no CI; `ruff` com regras `S` (flake8-bandit). -- **`SECURITY.md`:** versões suportadas, canal privado = **GitHub Private Vulnerability Reporting** - (decidido, Menor 3), prazo de resposta, escopo (SDK, não a API), política de divulgação coordenada. - -### §12. Código gerado a partir da OpenAPI: não no v0.1 - -- Evidência: PHP gerou 856 arquivos **0% usados em runtime** (VAULT `PHP/review-07-14-2026/08`: "~600 - arquivos ociosos"); Node: 33% de 30.487 linhas geradas sem consumidor; Ruby: 551 arquivos que **descartam - campos**; o gerador do PHP não via schemas inline e emitia nada em silêncio (VAULT `PHP/Codegen ignora - specs inline`) — exatamente o caso de `nf-servico-v1` (resposta 202/200 inline, `checkCode` repetido 4×). -- A spec erra em pontos centrais do v0.1 (§16): gerar a partir dela cristalizaria os erros. -- **No lugar:** modelos à mão (§2) + **teste de alinhamento ancorado em path** (lição do `operationId` - duplicado `ServiceInvoices_idGet`): cada propriedade tipada aponta para uma chave que existe no schema - da spec **ou** numa lista explícita de divergências provadas; TypedDicts de requisição checados contra os - campos do corpo da spec. PyYAML só como dev-dep do teste. -- `src/nfeio/_generated/` fica reservado (só `README` explicando a regra 5) para quando houver ganho - claro (provável: TypedDicts dos corpos enormes de NF-e na v0.2). - -### §13. Tooling - -| Item | Escolha | Motivo | -|---|---|---| -| Ambiente | `uv` (venv local, `uv.lock`) | rápido, lock reproduzível, já em uso | -| Build backend | `hatchling` | padrão PyPA, versão dinâmica de `_version.py`, sem setup.py | -| Lint/format | `ruff` (E, F, W, I, B, UP, S, SIM, RUF, PT, ASYNC; format) | um binário; `S` cobre bandit no editor | -| Tipos | `mypy --strict` + `py.typed` | consumidores tipados (Odoo 18 já usa hints) | -| Testes | `pytest` + `pytest-asyncio` (dev) | async direto nos testes; zero impacto em runtime | -| Cobertura | `coverage` ≥ 90% em `nfeio._core`, `nfeio.errors`, `nfeio.webhooks`; ≥ 85% global | núcleo é onde mora o risco | -| Segurança | `bandit`, `pip-audit`, CodeQL (default setup do repositório) | §11 | -| CI | GitHub Actions; matriz 3.10, 3.11, 3.12, 3.13, 3.14 (ubuntu) + 3.12 em macOS e Windows | 3.14 = atual; 3.10 = piso (Odoo 17/ERPNext 15) | -| Integração | `pytest -m live`, lê `.env` (parser stdlib no conftest), pula sem chave; escrita só com `NFE_LIVE_WRITE=1` e só em `NFE_COMPANY_ID` | regra 2 das regras do projeto | - -Comandos: `uv run pytest`, `uv run ruff check`, `uv run ruff format --check`, `uv run mypy --strict src tests`, -`uv build`. - -### §14. Layout - -``` -src/nfeio/ - __init__.py # API pública: NfeClient, AsyncNfeClient, RequestOptions, Timeout, __version__ - _version.py # única fonte da versão - py.typed - _client.py # fachadas NfeClient / AsyncNfeClient - _config.py # ClientConfig, RequestOptions, Timeout, ApiFamily, hosts, SecretStr - errors.py # hierarquia pública - webhooks.py # verify_signature, construct_event, WebhookEvent (sem cliente) - types.py # FlowStatus e aliases públicos - _core/ - ops.py # Effect, Send, Sleep, run_sync, run_async - transport.py # Transport/AsyncTransport, HttpRequest/HttpResponse, HttpClientTransport, ThreadedAsyncTransport - retry.py backoff.py # política, tabela, Retry-After - requestor.py # monta requisição por família, auth, UA, decodifica, mapeia erro - paths.py # validação/encoding de segmentos, CNPJ/CPF/CEP - multipart.py json.py (Decimal-aware) errors_extract.py pagination.py polling.py user_agent.py - models/ - _base.py # NfeObject, ResponseInfo, parsers de data/Decimal - service_invoice.py company.py certificate.py webhook.py lookups.py - resources/ - service_invoices.py companies.py certificates.py webhooks.py lookups.py # Ops + fachadas - _generated/README.md # reservado (regra 5) -tests/ - unit/ contract/ (replay de fixtures redigidas) live/ (opt-in) fixtures/ -scripts/probes/ # sondas (já existem) -docs/contrato/ # evidência e decisões -``` - -### §15. Escopo de operações do v0.1 (resumo; contrato nas specs) - -- `service_invoices`: `create`, `list`, `retrieve`, `find_by_external_id`, `cancel`, `send_email`, - `download_pdf`, `download_xml`, `download_cancellation_xml`, `wait`, `create_and_wait`, `cancel_and_wait`. -- `companies` [D3]: `list`, `retrieve`, `create`, `update`, `delete`. -- `certificates`: `upload`, `list` (status/validade). -- `lookups` [D4][D7]: `cnpj` (v3), `cnpj_state_taxes` (inscrições estaduais, v3), `cpf`, `cep`. -- `webhooks`: `list`, `retrieve`, `create`, `update`, `delete`, `ping`, `event_types` + módulo - `nfeio.webhooks`. - -### §16. Divergências spec × fio que o SDK trata - -| # | Ponto | Spec diz | Fio faz | Tratamento no SDK | Fonte | -|---|---|---|---|---|---| -| 1 | Auth | `Authorization` / `?apikey` | também `X-NFE-APIKEY`, `Basic `; base64/Bearer → 401 | usa `Authorization: ` | SONDA §1 | -| 2 | Chave por host | — | dados ↔ fiscal complementares (403 cruzado) | família → chave; sem fallback | SONDA §1, FIX matrix | -| 3 | 401/403 | corpo `ErrorsResource` | **corpo vazio** | mensagem padrão + dica de chave | SONDA §1 | -| 4 | Erros | `{errors:[{code,message}]}` | 5 envelopes (string crua, errors c/ e s/ code, code/message, ProblemDetails) | extrator único | SONDA §3, FIX `unreachable-methods` | -| 5 | Request id | não documentado | `x-request-id` em toda resposta; `traceId` no ProblemDetails | `request_id`, `trace_id` | SONDA §2 | -| 6 | POST emissão | 202 sem headers | 202 + `Location` **`http://`** | só o path; id do corpo | FIX `service-invoice-rtc-create` | -| 7 | POST emissão falho | — | 500/504 **com nota criada** | sem retry; `outcome_unknown` | VAULT `Incidente 504`, `Retry seguro` | -| 8 | `Idempotency-Key` | — | ignorado | não altera retry | VAULT `Ask API` | -| 9 | `externalId` duplicado | não documentado | 400 texto livre, sem código | `DuplicateExternalIdError` | VAULT `Retry seguro` | -| 10 | `/external/{id}` | objeto; parâmetro `externalId` ≠ `{id}`; operationId duplicado | **lista**; miss = 200 vazio | desembrulha; `None` | SONDA §6, VAULT `P1/02` | -| 11 | Cancelar | `DELETE` → 200 string | 202 + `Location` (**provado na fase 2**) | trata como assíncrono | `probe-fase2.md` | -| 12 | `/status` | ausente | **não existe** (400 binding) | polling via `GET /{id}` | SONDA §6 | -| 13 | PDF/XML | 200 JSON string | **302** p/ blob pré-assinado em outro host | segue sem credencial; `bytes` | SONDA §6 | -| 14 | XML NFS-e | — | começa em ` bool`; `subject`, -`resolution` e os demais campos ficam no acesso por chave. - -#### Scenario: Validade -- **WHEN** a resposta traz `"validUntil": "2026-11-03T16:18:00+00:00"` -- **THEN** `cert.expires_on` é esse instante e `cert.is_expired(at=datetime(2026,12,1,tzinfo=UTC))` é `True` diff --git a/openspec/changes/add-sdk-foundation-v0-1/specs/client-core/spec.md b/openspec/changes/add-sdk-foundation-v0-1/specs/client-core/spec.md deleted file mode 100644 index b18a377..0000000 --- a/openspec/changes/add-sdk-foundation-v0-1/specs/client-core/spec.md +++ /dev/null @@ -1,160 +0,0 @@ -## Purpose - -Define como o usuário cria e configura o cliente da NFE.io em Python: chaves por família de API, hosts, -opções por chamada, proteção de segredos, validação de identificadores e representação dos modelos de -resposta com preservação integral do que vem no fio. - -## ADDED Requirements - -### Requirement: Cliente explícito sem estado global -O SDK SHALL expor `NfeClient` (síncrono) e `AsyncNfeClient` (assíncrono) como pontos de entrada, cada -instância com configuração própria e imutável. O SDK MUST NOT manter chave de API, host ou opção em -estado global de módulo, de modo que duas instâncias com chaves diferentes coexistam no mesmo processo -sem interferência. - -#### Scenario: Dois clientes com chaves diferentes no mesmo processo -- **WHEN** o usuário cria `NfeClient(api_key="A")` e `NfeClient(api_key="B")` e chama o mesmo método nos dois -- **THEN** cada requisição sai com a chave do seu cliente - -#### Scenario: Recursos expostos como atributos -- **WHEN** o usuário acessa `client.service_invoices`, `client.companies`, `client.certificates`, `client.webhooks` e `client.lookups` -- **THEN** cada atributo devolve o serviço do recurso, com métodos em snake_case - -### Requirement: Chaves por família de API -O SDK SHALL aceitar `api_key` (famílias fiscais e de gestão) e `data_api_key` (consultas de CNPJ, CPF e -CEP) e, quando omitidas, SHALL ler `NFE_API_KEY` e `NFE_DATA_API_KEY` do ambiente. Cada recurso MUST -usar a chave da sua família. O SDK MUST NOT usar a chave principal como substituta da chave de dados. - -#### Scenario: Consulta sem chave de dados -- **WHEN** o cliente foi criado só com `api_key` e o usuário chama uma consulta de CNPJ -- **THEN** o SDK levanta `ConfigurationError` informando que `data_api_key` é necessária, sem abrir conexão - -#### Scenario: Emissão usa a chave principal -- **WHEN** o cliente tem `api_key` e `data_api_key` e o usuário lista notas de serviço -- **THEN** a requisição sai com a `api_key` - -#### Scenario: Nenhuma chave disponível -- **WHEN** o cliente é criado sem `api_key` e sem `NFE_API_KEY` no ambiente e o usuário chama um recurso fiscal -- **THEN** o SDK levanta `ConfigurationError` antes de qualquer requisição - -### Requirement: Autenticação pelo cabeçalho Authorization -O SDK SHALL enviar a chave no cabeçalho `Authorization` com o valor cru da chave, sem prefixo, em todos -os hosts. O SDK MUST NOT enviar a chave na query string nem em requisição a destino de redirect. - -#### Scenario: Cabeçalho de autenticação -- **WHEN** qualquer requisição autenticada é enviada -- **THEN** ela contém `Authorization: ` e a URL não contém a chave - -### Requirement: Hosts por família configuráveis -O SDK SHALL usar, por padrão, `https://api.nfe.io` para NFS-e, `https://api.nfse.io` para empresas, -certificados e webhooks, `https://legalentity.api.nfe.io` para CNPJ, `https://naturalperson.api.nfe.io` -para CPF e `https://address.api.nfe.io` para CEP, e SHALL permitir sobrescrever a base de cada família na -criação do cliente. O SDK MUST rejeitar base que não use `https`. - -#### Scenario: Base sobrescrita para testes -- **WHEN** o usuário cria o cliente com a base da família fiscal apontando para `https://localhost:8443` -- **THEN** as requisições de NFS-e vão para esse host e as das demais famílias continuam nos padrões - -#### Scenario: Base sem TLS -- **WHEN** o usuário informa uma base `http://` -- **THEN** o SDK levanta `ConfigurationError` - -### Requirement: Opções por chamada -Todo método público que faz requisição SHALL aceitar `options: RequestOptions | None`, com `api_key`, -`timeout`, `max_retries`, `idempotency_key` e `extra_headers`, que sobrescrevem a configuração do cliente -só naquela chamada. `extra_headers` MUST NOT sobrescrever `Authorization`, `User-Agent` nem -`Content-Type`. `idempotency_key` SHALL ser enviado como cabeçalho `Idempotency-Key` e MUST NOT alterar a -política de retry. - -#### Scenario: Timeout e retries por chamada -- **WHEN** o usuário chama `create(..., options=RequestOptions(timeout=90, max_retries=0))` -- **THEN** só essa chamada usa 90 s de timeout e nenhuma nova tentativa - -#### Scenario: Cabeçalho protegido -- **WHEN** `extra_headers` contém `Authorization` -- **THEN** o SDK levanta `ConfigurationError` e não envia a requisição - -### Requirement: Segredos mascarados -O SDK MUST NOT expor chaves de API ou senha de certificado em `repr`, `str`, mensagens de exceção ou -registros de log. Representações de cliente, configuração e opções SHALL mostrar no máximo os 4 últimos -caracteres da chave. - -#### Scenario: repr do cliente -- **WHEN** o usuário imprime `repr(client)` -- **THEN** a saída não contém a chave completa e mostra apenas um sufixo mascarado - -#### Scenario: Varredura de vazamento -- **WHEN** uma sequência de chamadas com sucesso, erro 401, erro 500 e falha de rede é executada com log em nível DEBUG -- **THEN** nenhuma linha de log, `repr` ou mensagem de exceção contém a chave ou a senha do certificado - -### Requirement: Validação de identificadores antes de compor o path -O SDK SHALL validar todo valor interpolado em path antes da requisição: identificadores opacos MUST casar -`^[A-Za-z0-9_-]{1,64}$`; texto livre (como `external_id`) MUST ser codificado por percent-encoding de -todos os caracteres reservados e MUST ser rejeitado se vazio, igual a `.` ou `..`, com caractere de -controle ou com mais de 255 caracteres. Valor inválido SHALL levantar `InvalidParameterError` sem -requisição. - -#### Scenario: Tentativa de path traversal -- **WHEN** o usuário chama `retrieve(company_id, "../../v2/webhooks")` -- **THEN** o SDK levanta `InvalidParameterError` e nenhuma requisição é enviada - -#### Scenario: externalId com barra -- **WHEN** o usuário busca o `external_id` `"pedido/123"` -- **THEN** o path enviado contém `pedido%2F123` em um único segmento - -### Requirement: Modelos preservam o fio -Todo objeto de resposta SHALL ser um `Mapping[str, Any]` imutável que dá acesso de leitura a todas as -chaves do JSON recebido, inclusive as que o SDK não conhece, pelo nome original do fio (`obj["chave"]`), -e SHALL oferecer `to_dict()` com cópia profunda serializável em JSON. No acesso por chave, um `Mapping` -aninhado SHALL ser devolvido como `NfeObject` e uma lista SHALL ser devolvida como lista nova cujos -`Mapping` também são `NfeObject`; escalares SHALL ser devolvidos como vieram. Propriedades tipadas em -snake_case SHALL existir apenas para campos que o SDK corrige ou tipa (ids, status, datas, valores -monetários e alíquotas, documentos e aninhados que contêm documento). O SDK MUST NOT descartar campos -desconhecidos e MUST NOT resolver atributos dinamicamente a partir das chaves do fio (`__getattr__`). - -#### Scenario: Campo novo no servidor -- **WHEN** a API passa a devolver `"novoCampo": 1` numa nota de serviço -- **THEN** `invoice["novoCampo"] == 1` e `invoice.to_dict()["novoCampo"] == 1` sem atualização do SDK - -#### Scenario: Propriedade tipada -- **WHEN** a resposta contém `"flowStatus": "Issued"` -- **THEN** `invoice.flow_status == "Issued"` - -#### Scenario: Aninhado por chave -- **WHEN** a resposta contém `"borrower": {"name": "Cliente", "address": {"city": {"code": "3550308"}}}` -- **THEN** `invoice["borrower"]["address"]["city"]["code"] == "3550308"`, `invoice["borrower"]` é um `NfeObject` e `invoice.to_dict()["borrower"]` é um `dict` comum - -#### Scenario: Campo do fio com nome de método de Mapping -- **WHEN** a resposta contém `"items": [{"code": "1"}]` -- **THEN** `obj["items"][0]["code"] == "1"` e `obj.items()` continua sendo o método de `Mapping` - -#### Scenario: Imutabilidade -- **WHEN** o usuário altera a lista devolvida por `obj["filters"]` -- **THEN** `obj["filters"]` e `obj.to_dict()["filters"]` continuam com o valor original - -### Requirement: Tipos de dados robustos ao fio -Propriedades de data SHALL devolver `datetime` com fuso, aceitando sufixo `Z`, offset e de 0 a 7 dígitos -fracionários, e SHALL devolver `None` quando o texto não for interpretável, mantendo o valor original no -acesso cru. Propriedades monetárias e de alíquota SHALL devolver `Decimal` sem erro de representação -binária para valores de até 15 dígitos significativos. Números de documento (CNPJ/CPF) SHALL ser -expostos como `str` normalizada, preservando zeros à esquerda. - -#### Scenario: Data com milissegundos em Python 3.10 -- **WHEN** a resposta contém `"createdOn": "2026-09-01T02:55:33.418+00:00"` -- **THEN** a propriedade devolve o `datetime` correspondente com fuso UTC em todas as versões suportadas - -#### Scenario: Valor monetário -- **WHEN** a resposta contém `"servicesAmount": 1234.56` -- **THEN** a propriedade devolve `Decimal("1234.56")` - -#### Scenario: CNPJ devolvido como inteiro pela API -- **WHEN** uma resposta traz `"federalTaxNumber": 191` num contexto de CNPJ -- **THEN** a propriedade devolve `"00000000000191"` - -### Requirement: Metadados da última resposta -Todo objeto e página devolvidos SHALL expor `last_response` com `status_code`, cabeçalhos -(consulta sem distinção de maiúsculas) e `request_id` lido de `x-request-id`. - -#### Scenario: Request id disponível -- **WHEN** a API responde com `x-request-id: 0HNP51M3F3EVD:00000006` -- **THEN** `obj.last_response.request_id == "0HNP51M3F3EVD:00000006"` diff --git a/openspec/changes/add-sdk-foundation-v0-1/specs/companies/spec.md b/openspec/changes/add-sdk-foundation-v0-1/specs/companies/spec.md deleted file mode 100644 index da4a650..0000000 --- a/openspec/changes/add-sdk-foundation-v0-1/specs/companies/spec.md +++ /dev/null @@ -1,56 +0,0 @@ -## Purpose - -Define o gerenciamento de empresas (contribuintes) pelo SDK sobre a API de Empresas v2 -(`api.nfse.io/v2/companies`), que a documentação oficial indica como substituta da v1 em -descontinuação (decisão D3 = A, 2026-10-08). - -## ADDED Requirements - -### Requirement: Listar empresas por cursor -`companies.list(*, limit=10, starting_after=None, ending_before=None)` SHALL enviar `GET /v2/companies` -com a chave principal e devolver uma página por cursor de `Company` lida de `companies`, com `has_more` -lido de `hasMore`. - -#### Scenario: Varredura de todas as empresas -- **WHEN** a conta tem 662 empresas e o usuário itera `companies.list().auto_paging_iter()` -- **THEN** recebe 662 empresas distintas usando `limit=50` e `startingAfter` com o id do último item - -### Requirement: Consultar empresa -`companies.retrieve(company_id)` SHALL enviar `GET /v2/companies/{company_id}` e devolver a empresa -desembrulhada de `company`. - -#### Scenario: Empresa inexistente -- **WHEN** a API responde 404 com `{"errors":[{"code":40401,"message":"…"}]}` -- **THEN** o SDK levanta `NotFoundError` com `error_code == 40401` - -### Requirement: Criar, alterar e excluir empresa -`companies.create(params)` SHALL enviar `POST /v2/companies` com o corpo embrulhado em `{"company": …}`; -`companies.update(company_id, params)` SHALL enviar `PUT /v2/companies/{company_id}` com o mesmo envelope -e substituição completa; `companies.delete(company_id)` SHALL enviar `DELETE /v2/companies/{company_id}` -e aceitar 204. As respostas com corpo SHALL ser desembrulhadas de `company`. A documentação de -`delete` SHALL avisar que a API faz desativação (soft delete): a empresa continua consultável com -`status` `Inactive` (provado em 2026-10-08). - -#### Scenario: Criação -- **WHEN** o usuário chama `create({"name": "…", "federalTaxNumber": …, "taxRegime": "SimplesNacional", "address": {…}})` -- **THEN** o corpo enviado é `{"company": {…}}` e o retorno é a empresa criada - -#### Scenario: Exclusão -- **WHEN** a API responde 204 -- **THEN** o método retorna `None` sem erro - -#### Scenario: Empresa excluída continua consultável -- **WHEN** o usuário consulta uma empresa depois de `delete` -- **THEN** o SDK devolve a empresa com `status == "Inactive"`, como a API responde - -### Requirement: Modelo Company -`Company` SHALL expor propriedades tipadas apenas para `id`, `account_id`, `federal_tax_number` -(string normalizada de 14 caracteres), `tax_regime`, `status`, `municipal_tax_ids` (lista de ids de -`municipalTaxes`), `state_tax_ids`, `created_on` e `modified_on`; `name`, `tradeName`, `address` e os -demais campos ficam no acesso por chave. O SDK MUST NOT expor -o campo `municipalTaxNumber` da v2 como inscrição municipal, porque o fio o preenche com o id da -inscrição; o valor continua acessível pelo nome do fio. - -#### Scenario: municipalTaxNumber com id -- **WHEN** a resposta v2 traz `"municipalTaxNumber": "0a1b2c3d4e5f60718293a4b5c6d7e8f9"` -- **THEN** não existe propriedade tipada que o apresente como inscrição municipal e `company["municipalTaxNumber"]` devolve o valor cru diff --git a/openspec/changes/add-sdk-foundation-v0-1/specs/data-lookups/spec.md b/openspec/changes/add-sdk-foundation-v0-1/specs/data-lookups/spec.md deleted file mode 100644 index f20004f..0000000 --- a/openspec/changes/add-sdk-foundation-v0-1/specs/data-lookups/spec.md +++ /dev/null @@ -1,68 +0,0 @@ -## Purpose - -Define as consultas de dados cadastrais oferecidas pelo SDK — pessoa jurídica por CNPJ, situação de -pessoa física por CPF e endereço por CEP — que usam a chave de dados e hosts próprios. - -## ADDED Requirements - -### Requirement: Consulta de CNPJ -`lookups.cnpj(cnpj, *, update_address=None, update_city_code=None)` SHALL enviar -`GET https://legalentity.api.nfe.io/v3/legalentities/basicInfo/{cnpj}` com a chave de dados e devolver -`LegalEntity` desembrulhada de `legalEntity`. O SDK SHALL aceitar CNPJ numérico ou alfanumérico, com ou -sem máscara, normalizá-lo para 14 caracteres maiúsculos e validar os dígitos verificadores pelo -algoritmo da Receita Federal antes da requisição. - -#### Scenario: CNPJ com máscara -- **WHEN** o usuário consulta `"00.000.000/0001-91"` -- **THEN** a requisição usa o path `/v3/legalentities/basicInfo/00000000000191` - -#### Scenario: CNPJ alfanumérico -- **WHEN** o usuário consulta `"12.ABC.345/01DE-35"` -- **THEN** a validação local aceita e a requisição usa `12ABC34501DE35` - -#### Scenario: Dígito verificador errado -- **WHEN** o usuário consulta `"00000000000100"` -- **THEN** o SDK levanta `InvalidParameterError` sem requisição - -### Requirement: Inscrições estaduais por CNPJ -`lookups.cnpj_state_taxes(cnpj, state)` SHALL enviar `GET …/v3/legalentities/stateTaxInfo/{UF}/{cnpj}` -com a UF validada entre as 27 unidades federativas. - -#### Scenario: UF inválida -- **WHEN** o usuário informa `state="XX"` -- **THEN** o SDK levanta `InvalidParameterError` sem requisição - -### Requirement: Consulta de CPF -`lookups.cpf(cpf, birth_date)` SHALL enviar -`GET https://naturalperson.api.nfe.io/v1/naturalperson/status/{cpf}/{AAAA-MM-DD}` com a chave de dados, -aceitando `birth_date` como `date` ou texto `AAAA-MM-DD` e validando os dígitos do CPF antes da -requisição. CPF existente com data divergente SHALL resultar em `NotFoundError`. - -#### Scenario: Data divergente -- **WHEN** a API responde 404 com `{"errors":[{"code":40401,"message":"not found"}]}` -- **THEN** o SDK levanta `NotFoundError` com `error_code == 40401` - -#### Scenario: Data em formato brasileiro -- **WHEN** o usuário passa `birth_date="01/01/1990"` -- **THEN** o SDK levanta `InvalidParameterError` sem requisição - -### Requirement: Consulta de CEP -`lookups.cep(cep)` SHALL enviar `GET https://address.api.nfe.io/v2/addresses/{cep}` com a chave de -dados, aceitando CEP com ou sem hífen (normalizado para 8 dígitos), e devolver `Address` desembrulhado -de `address`. O SDK MUST NOT oferecer busca de endereço por termo ou filtro. - -#### Scenario: CEP com hífen -- **WHEN** o usuário consulta `"01310-100"` -- **THEN** a requisição usa o path `/v2/addresses/01310100` e o retorno tem `address["city"]["code"] == "3550308"` - -### Requirement: Modelos de consulta -Os modelos de consulta SHALL seguir a regra enxuta de `client-core`: `LegalEntity` SHALL expor -propriedades tipadas para `federal_tax_number` (string de 14 caracteres), `status`, `opened_on`, -`status_on` e `share_capital` (`Decimal`); `NaturalPerson` para `federal_tax_number` (string de 11 -dígitos), `status` e `birth_on`; `Address` para `postal_code` (8 dígitos). Os demais campos (`name`, -`tradeName`, `address`, `phones`, `economicActivities`, `street`, `district`, `city`, `state`, …) SHALL -estar disponíveis no acesso por chave. - -#### Scenario: Endereço -- **WHEN** a resposta é `{"address":{"state":"SP","city":{"code":"3550308","name":"São Paulo"},"postalCode":"01310-100",…}}` -- **THEN** `address["state"] == "SP"`, `address["city"]["name"] == "São Paulo"` e `address.postal_code == "01310100"` diff --git a/openspec/changes/add-sdk-foundation-v0-1/specs/errors/spec.md b/openspec/changes/add-sdk-foundation-v0-1/specs/errors/spec.md deleted file mode 100644 index 6132438..0000000 --- a/openspec/changes/add-sdk-foundation-v0-1/specs/errors/spec.md +++ /dev/null @@ -1,82 +0,0 @@ -## Purpose - -Define as exceções do SDK: hierarquia por categoria e status HTTP, preservação da resposta da API, -extração de mensagem dos formatos de erro realmente usados pela NFE.io e identificação da requisição -para suporte. - -## ADDED Requirements - -### Requirement: Hierarquia tipada de erros -Todas as exceções do SDK SHALL derivar de `NfeError`. Respostas HTTP de erro SHALL ser mapeadas para -subclasses de `APIError`: 401 → `AuthenticationError`, 403 → `PermissionDeniedError`, 404 → -`NotFoundError`, 409 → `ConflictError`, 429 → `RateLimitError`, 408 e 5xx → `ServerError`, demais 4xx → -`InvalidRequestError`. Falhas de rede SHALL ser `APIConnectionError`, com `APITimeoutError` como -subclasse para timeouts. Erros de configuração e de parâmetro local SHALL derivar também de `ValueError`. - -#### Scenario: 403 da API -- **WHEN** a API responde 403 -- **THEN** o SDK levanta `PermissionDeniedError` com `status_code == 403` - -#### Scenario: 422 da API -- **WHEN** a API responde 422 -- **THEN** o SDK levanta `InvalidRequestError` - -#### Scenario: Captura genérica -- **WHEN** o usuário captura `NfeError` -- **THEN** qualquer exceção levantada pelo SDK é capturada - -### Requirement: Preservação da resposta -`APIError` SHALL expor `status_code`, `message`, `error_code` (quando houver), `body` (bytes integrais), -`json_body` (quando o corpo for JSON), `headers`, `request_id` (de `x-request-id`) e `trace_id` (de -`traceId` em ProblemDetails). - -#### Scenario: Request id no erro -- **WHEN** uma resposta 404 traz `x-request-id: abc:1` -- **THEN** a exceção tem `request_id == "abc:1"` - -### Requirement: Extração de mensagem dos envelopes da API -O SDK SHALL extrair a mensagem de erro de: corpo vazio (mensagem padrão por status), string JSON crua, -`{"errors":[{"code","message"}]}` (todas as mensagens; `error_code` = primeiro `code`), -`{"errors":[{"message"}]}`, `{"code","message"}` e ProblemDetails (`title` mais cada -`campo: mensagem` de `errors`). A mensagem MUST ter caracteres de controle removidos e no máximo 1.000 -caracteres; o corpo integral permanece em `body`. - -#### Scenario: String JSON crua -- **WHEN** a API responde 400 com o corpo `"pageCount must be between 1 and 50"` -- **THEN** `message == "pageCount must be between 1 and 50"` - -#### Scenario: Lista de erros com código -- **WHEN** a API responde 404 com `{"errors":[{"code":40401,"message":"company id not found"}]}` -- **THEN** `message == "company id not found"` e `error_code == 40401` - -#### Scenario: ProblemDetails de validação -- **WHEN** a API responde 400 com `{"title":"One or more validation errors occurred.","errors":{"pageIndex":["The value 'abc' is not valid."]},"traceId":"00-x-y-01"}` -- **THEN** a mensagem contém `pageIndex: The value 'abc' is not valid.` e `trace_id == "00-x-y-01"` - -#### Scenario: 401 com corpo vazio -- **WHEN** a API responde 401 sem corpo -- **THEN** o SDK levanta `AuthenticationError` com mensagem padrão não vazia - -### Requirement: Dica de chave em 403 -`PermissionDeniedError` SHALL informar na mensagem qual parâmetro de chave (`api_key` ou `data_api_key`) -a família do recurso usa. - -#### Scenario: Consulta de CNPJ com chave errada -- **WHEN** uma consulta de CNPJ recebe 403 -- **THEN** a mensagem menciona `data_api_key` - -### Requirement: Erro não expõe segredos -`str`, `repr` e atributos de qualquer exceção MUST NOT conter a chave de API nem o cabeçalho -`Authorization` enviado. - -#### Scenario: Erro de autenticação -- **WHEN** a API responde 401 -- **THEN** `str(err)` e `repr(err)` não contêm a chave usada - -### Requirement: Resposta inesperada -Uma resposta 2xx cujo corpo não seja JSON válido quando JSON é esperado, ou que não permita identificar o -recurso criado, SHALL levantar `UnexpectedResponseError` com o status e o corpo preservados. - -#### Scenario: 202 sem identificação -- **WHEN** a emissão responde 202 sem `id` no corpo e sem `Location` -- **THEN** o SDK levanta `UnexpectedResponseError` diff --git a/openspec/changes/add-sdk-foundation-v0-1/specs/http-transport/spec.md b/openspec/changes/add-sdk-foundation-v0-1/specs/http-transport/spec.md deleted file mode 100644 index f88d3d8..0000000 --- a/openspec/changes/add-sdk-foundation-v0-1/specs/http-transport/spec.md +++ /dev/null @@ -1,116 +0,0 @@ -## Purpose - -Define o transporte HTTP do SDK: implementação só com a biblioteca padrão, substituível por protocolo, -com TLS sempre verificado, timeouts finitos, limite de tamanho de resposta, User-Agent honesto, envio -multipart sem dependências e tratamento seguro de redirects. - -## ADDED Requirements - -### Requirement: Zero dependências de runtime -O pacote instalado MUST NOT declarar dependências de runtime; todo o transporte SHALL usar apenas a -biblioteca padrão do Python. - -#### Scenario: Metadados do pacote -- **WHEN** o wheel publicado é inspecionado -- **THEN** a lista `Requires-Dist` não contém dependências obrigatórias - -### Requirement: Transporte substituível -O SDK SHALL definir protocolos públicos de transporte síncrono e assíncrono e SHALL aceitar uma -implementação do usuário na criação do cliente. O transporte MUST NOT seguir redirects por conta própria. - -#### Scenario: Transporte customizado -- **WHEN** o usuário passa um objeto que implementa o protocolo de transporte -- **THEN** todas as requisições do cliente passam por ele, com retry, erros e paginação funcionando igual - -### Requirement: Classificação da fase da falha de rede -O transporte padrão SHALL distinguir falhas que ocorrem antes de qualquer byte da requisição ser enviado -(resolução de nome, conexão recusada, handshake TLS) das que ocorrem depois (timeout de leitura, -conexão encerrada), expondo a fase na exceção. Requisições POST MUST usar conexão nova, nunca uma -conexão reaproveitada do pool. - -#### Scenario: DNS falha -- **WHEN** o host não resolve -- **THEN** a exceção de conexão informa a fase "não estabelecida" - -#### Scenario: Timeout de leitura -- **WHEN** a conexão é estabelecida, a requisição é enviada e a resposta não chega dentro do timeout -- **THEN** a exceção de timeout informa a fase "pode ter sido enviada" - -### Requirement: TLS sempre verificado -O SDK SHALL verificar certificado e nome do host em toda conexão, com versão mínima TLS 1.2. O SDK MAY -aceitar um contexto TLS ou pacote de CAs do usuário para acrescentar confiança, mas MUST rejeitar com -`ConfigurationError` qualquer contexto que desligue a verificação de certificado ou de nome do host. O -SDK MUST NOT oferecer opção para desligar a verificação. - -#### Scenario: Contexto inseguro -- **WHEN** o usuário passa um `ssl.SSLContext` com `verify_mode = CERT_NONE` -- **THEN** a criação do cliente levanta `ConfigurationError` - -#### Scenario: Certificado inválido no servidor -- **WHEN** o servidor apresenta certificado autoassinado não confiável -- **THEN** a requisição falha com erro de conexão na fase "não estabelecida" - -### Requirement: Timeouts finitos -O SDK SHALL aplicar por padrão 10 s de timeout de conexão, 60 s de timeout de leitura e 120 s de prazo -total por tentativa, configuráveis no cliente e por chamada. O SDK MUST rejeitar timeout nulo, zero, -negativo ou infinito. - -#### Scenario: Timeout infinito recusado -- **WHEN** o usuário configura `timeout=None` -- **THEN** o SDK levanta `ConfigurationError` - -### Requirement: Limite de tamanho de resposta -O SDK SHALL ler respostas em blocos e interromper a leitura ao exceder `max_response_bytes` (padrão -10 MiB), levantando `ResponseTooLargeError` e descartando a conexão. O SDK SHALL pedir respostas sem -compressão (`Accept-Encoding: identity`). - -#### Scenario: Resposta gigante -- **WHEN** o servidor envia um corpo maior que o limite configurado -- **THEN** o SDK levanta `ResponseTooLargeError` sem carregar o corpo inteiro em memória - -### Requirement: User-Agent honesto -Toda requisição SHALL enviar `User-Agent: nfe-io-python/ python/. `, -onde a versão vem da fonte única de versão do pacote, e SHALL acrescentar `/` quando o -usuário informar `app_info`. O pacote MUST NOT conter outra literal de versão. - -#### Scenario: Formato do User-Agent -- **WHEN** o SDK 0.1.0 roda em Python 3.12 no Linux -- **THEN** o cabeçalho é `nfe-io-python/0.1.0 python/3.12 linux` - -#### Scenario: Identificação do módulo de ERP -- **WHEN** o cliente é criado com `app_info=("odoo-l10n_br_nfse_nfeio", "18.0.1.0")` -- **THEN** o cabeçalho termina com ` odoo-l10n_br_nfse_nfeio/18.0.1.0` - -### Requirement: Multipart sem dependências -O SDK SHALL montar corpos `multipart/form-data` com fronteira aleatória imprevisível, nome de arquivo -saneado (sem aspas, CR ou LF) e partes binárias intactas. - -#### Scenario: Nome de arquivo malicioso -- **WHEN** o nome do arquivo contém `"` e quebra de linha -- **THEN** o corpo enviado não contém esses caracteres no cabeçalho da parte - -### Requirement: Redirects seguidos só sem credencial -Quando uma operação de download recebe 3xx, o SDK SHALL seguir no máximo 3 redirects, somente para -URLs `https`, e MUST NOT enviar a chave de API nem outros cabeçalhos de autenticação ao destino do -redirect. Redirect para `http` SHALL levantar erro. - -#### Scenario: Download de PDF -- **WHEN** `GET …/pdf` responde 302 para uma URL pré-assinada em outro host -- **THEN** o SDK busca a URL sem `Authorization` e devolve os bytes do PDF - -#### Scenario: Redirect fora de download -- **WHEN** uma operação que não é download recebe 3xx -- **THEN** o SDK levanta `UnexpectedResponseError` sem seguir o redirect - -#### Scenario: Redirect para http -- **WHEN** o destino do redirect usa `http://` -- **THEN** o SDK levanta `UnexpectedResponseError` sem fazer a requisição - -### Requirement: Location de resposta assíncrona não é seguido como URL -O SDK SHALL extrair somente o path do cabeçalho `Location` de respostas 202, validá-lo contra o formato -esperado do recurso e reconstruir qualquer URL derivada a partir da base configurada. O SDK MUST NOT -fazer requisição ao host ou esquema informados no `Location`. - -#### Scenario: Location com http -- **WHEN** a emissão responde 202 com `Location: http://api.nfe.io/v1/companies/C/serviceinvoices/I` -- **THEN** o SDK registra o id `I` e consultas posteriores vão para a base `https` configurada diff --git a/openspec/changes/add-sdk-foundation-v0-1/specs/packaging-release/spec.md b/openspec/changes/add-sdk-foundation-v0-1/specs/packaging-release/spec.md deleted file mode 100644 index 81239db..0000000 --- a/openspec/changes/add-sdk-foundation-v0-1/specs/packaging-release/spec.md +++ /dev/null @@ -1,60 +0,0 @@ -## Purpose - -Define como o SDK é empacotado, verificado e publicado: metadados, tipagem, qualidade mínima, matriz de -Python suportada, testes de integração opcionais e a cadeia de publicação protegida contra ataques de -supply chain. - -## ADDED Requirements - -### Requirement: Pacote nfe-io importável como nfeio -O SDK SHALL ser distribuído no PyPI como `nfe-io`, importado como `nfeio`, com `requires-python >= 3.10`, -licença MIT declarada, `py.typed` incluído e `nfeio.__version__` igual à versão publicada. - -#### Scenario: Instalação limpa -- **WHEN** o usuário roda `pip install nfe-io` num ambiente vazio com Python 3.10 -- **THEN** `import nfeio` funciona e nenhum outro pacote é instalado - -### Requirement: Qualidade verificada no CI -Cada pull request SHALL passar `ruff check`, `ruff format --check`, `mypy --strict` sobre `src` e `tests`, -e a suíte de testes em Python 3.10, 3.11, 3.12, 3.13 e 3.14 no Linux e em 3.12 no macOS e no Windows, -com cobertura mínima de 90% nos módulos de núcleo, erros e webhooks e de 85% no total. - -#### Scenario: Cobertura abaixo do mínimo -- **WHEN** uma mudança reduz a cobertura do núcleo para 89% -- **THEN** o CI falha - -### Requirement: Testes de integração opcionais e seguros -Os testes contra a API real SHALL ser marcados e pulados quando não houver credenciais; testes de escrita -SHALL exigir opt-in explícito (`NFE_LIVE_WRITE=1`) e MUST operar apenas na empresa `NFE_COMPANY_ID`. - -#### Scenario: Sem credenciais -- **WHEN** a suíte roda sem `NFE_API_KEY` -- **THEN** os testes de integração aparecem como pulados e a suíte passa - -### Requirement: Publicação com Trusted Publishing e attestations -A publicação no PyPI SHALL ocorrer apenas por workflow do GitHub Actions disparado por tag, usando -Trusted Publishing (OIDC) sem token armazenado, com attestations de proveniência, a partir de artefatos -construídos uma única vez num job separado, em environment protegido com aprovação manual. As actions -MUST ser fixadas por SHA de commit e os jobs MUST declarar permissões mínimas. - -#### Scenario: Release -- **WHEN** a tag `v0.1.0` é aprovada no environment de publicação -- **THEN** wheel e sdist publicados no PyPI têm attestations verificáveis vinculadas ao workflow do repositório - -### Requirement: Auditoria de segurança contínua -O CI SHALL executar `bandit` sobre o código do pacote, `pip-audit` sobre as dependências de -desenvolvimento travadas e análise CodeQL (pelo default setup do repositório), e SHALL manter -Dependabot apenas para GitHub Actions e dependências de desenvolvimento. - -#### Scenario: Vulnerabilidade em dependência de dev -- **WHEN** `pip-audit` encontra vulnerabilidade conhecida no lock -- **THEN** o job de auditoria falha - -### Requirement: Política de segurança e changelog -O repositório SHALL conter `SECURITY.md` com versões suportadas, canal privado de reporte (GitHub Private -Vulnerability Reporting) e prazos, e -`CHANGELOG.md` em pt-BR no formato Keep a Changelog, atualizado a cada versão. - -#### Scenario: Reporte de vulnerabilidade -- **WHEN** um pesquisador abre o repositório procurando como reportar falha -- **THEN** `SECURITY.md` indica o canal privado e o prazo de primeira resposta diff --git a/openspec/changes/add-sdk-foundation-v0-1/specs/pagination/spec.md b/openspec/changes/add-sdk-foundation-v0-1/specs/pagination/spec.md deleted file mode 100644 index 9b8c639..0000000 --- a/openspec/changes/add-sdk-foundation-v0-1/specs/pagination/spec.md +++ /dev/null @@ -1,75 +0,0 @@ -## Purpose - -Define como o SDK expõe listagens paginadas da NFE.io — páginas por índice 1-based (API v1) e por cursor -(API v2) — e a iteração automática síncrona e assíncrona, respeitando os limites que a API realmente -aplica. - -## ADDED Requirements - -### Requirement: Página por índice 1-based -Listagens da API v1 SHALL aceitar `page_index` (primeira página = 1) e `page_count` (itens por página) e -SHALL devolver uma página com `data`, `page_index`, `page_count` e `has_more`. Como a API não informa -totais, `has_more` SHALL ser verdadeiro quando a página vier cheia (`len(data) == page_count`). - -#### Scenario: Primeira página -- **WHEN** o usuário lista notas com `page_index=1, page_count=2` e a API devolve 2 itens -- **THEN** a requisição envia `pageIndex=1&pageCount=2` e a página tem `has_more == True` - -#### Scenario: Última página -- **WHEN** a API devolve menos itens que `page_count` -- **THEN** `has_more == False` - -### Requirement: Limites de página validados localmente -O SDK SHALL rejeitar com `InvalidParameterError`, sem requisição, `page_index < 1` e `page_count` fora -do intervalo aceito pela rota (1–50 em listagens de notas de serviço), mantendo os limites por rota numa -tabela única. - -#### Scenario: Índice zero -- **WHEN** o usuário pede `page_index=0` -- **THEN** o SDK levanta `InvalidParameterError` explicando que a paginação começa em 1 - -#### Scenario: Página grande demais -- **WHEN** o usuário pede `page_count=100` -- **THEN** o SDK levanta `InvalidParameterError` informando o máximo de 50 - -### Requirement: Página por cursor -Listagens da API v2 SHALL aceitar `limit`, `starting_after` e `ending_before` e SHALL devolver uma página -com `data` e `has_more` lidos do fio (`hasMore`). `limit` MUST estar entre 1 e 50. - -#### Scenario: Cursor para frente -- **WHEN** o usuário lista com `limit=2, starting_after="X"` -- **THEN** a requisição envia `limit=2&startingAfter=X` - -#### Scenario: limit zero -- **WHEN** o usuário pede `limit=0` -- **THEN** o SDK levanta `InvalidParameterError` sem requisição - -### Requirement: Iteração automática -Toda página SHALL oferecer `auto_paging_iter()`, que percorre os itens de todas as páginas seguintes sob -demanda, e `next_page()`, que devolve a próxima página ou `None`. Na paginação por cursor, as páginas -seguintes SHALL usar o tamanho máximo (`limit=50`); na paginação por índice, SHALL manter o `page_count` -da página inicial, porque trocar o tamanho desloca o índice e pularia itens. A iteração MUST parar ao -receber página vazia, mesmo que a API indique `hasMore: true`, e MUST levantar -`UnexpectedResponseError` quando a próxima página repetir o cursor ou a primeira nota da anterior -(paginação que não avança). O SDK MUST NOT -oferecer método que carregue todas as páginas em memória de uma vez. - -#### Scenario: Varredura completa -- **WHEN** existem 120 notas e o usuário itera `list(...).auto_paging_iter()` -- **THEN** recebe as 120 notas em ordem, buscando páginas sob demanda - -#### Scenario: Paginação que não avança -- **WHEN** a API devolve de novo o mesmo cursor da página anterior -- **THEN** a iteração levanta `UnexpectedResponseError` em vez de repetir indefinidamente - -#### Scenario: hasMore inconsistente -- **WHEN** a API devolve `{"hasMore": true, "companies": []}` -- **THEN** a iteração termina sem nova requisição - -### Requirement: Iteração assíncrona -No cliente assíncrono, `auto_paging_iter()` SHALL devolver um iterador assíncrono (`async for`) com o -mesmo comportamento da versão síncrona. - -#### Scenario: async for -- **WHEN** o usuário faz `async for inv in (await aclient.service_invoices.list(cid)).auto_paging_iter()` -- **THEN** recebe todos os itens sem bloquear o event loop diff --git a/openspec/changes/add-sdk-foundation-v0-1/specs/polling/spec.md b/openspec/changes/add-sdk-foundation-v0-1/specs/polling/spec.md deleted file mode 100644 index 92b94ab..0000000 --- a/openspec/changes/add-sdk-foundation-v0-1/specs/polling/spec.md +++ /dev/null @@ -1,68 +0,0 @@ -## Purpose - -Define como o SDK trata operações fiscais assíncronas da NFE.io (resposta 202 com `Location`) e como o -usuário espera a nota chegar a um estado final, com prazos, backoff e erros claros. - -## ADDED Requirements - -### Requirement: Resposta 202 preservada -Ao receber 202 numa emissão ou cancelamento, o SDK SHALL devolver a nota parcial construída a partir do -corpo da resposta (por exemplo `id`, `environment`, `flowStatus`), usando o id extraído do path do -`Location` quando o corpo não trouxer `id`. - -#### Scenario: Corpo com id -- **WHEN** a emissão responde 202 com `{"id":"I","flowStatus":"WaitingCalculateTaxes"}` -- **THEN** o objeto devolvido tem `id == "I"` e `flow_status == "WaitingCalculateTaxes"` - -#### Scenario: Corpo de nota completa no 202 -- **WHEN** a emissão responde 202 com a nota completa (como observado em 2026-10-08) -- **THEN** o objeto devolvido preserva todos os campos e `flow_status` reflete o estado inicial - -#### Scenario: Corpo vazio com Location -- **WHEN** a emissão responde 202 sem corpo e com `Location: http://api.nfe.io/v1/companies/C/serviceinvoices/I` -- **THEN** o objeto devolvido tem `id == "I"` - -### Requirement: Espera por estado terminal -`service_invoices.wait(company_id, invoice_id, ...)` SHALL consultar a nota por `GET` do recurso até que -`flowStatus` seja terminal, com intervalo inicial de 1 s multiplicado por 1,5 a cada consulta até o -máximo de 10 s e prazo total padrão de 120 s, todos configuráveis. O SDK MUST NOT usar a rota -`/serviceinvoices/{id}/status`. - -#### Scenario: Emissão concluída -- **WHEN** a nota passa de `WaitingSend` para `Issued` -- **THEN** `wait` devolve a nota com `flow_status == "Issued"` - -#### Scenario: Prazo esgotado -- **WHEN** a nota continua `WaitingReturn` até o fim do prazo -- **THEN** o SDK levanta `PollingTimeoutError` contendo o último estado observado - -### Requirement: Estados terminais e falhas de processamento -O SDK SHALL tratar `Issued` e `Cancelled` como terminais de sucesso e `IssueFailed`, `CancelFailed` e -`Error` como terminais de falha. Ao atingir falha, `wait` SHALL levantar `InvoiceProcessingError` com a -nota e o `flowMessage`, a menos que o usuário peça `raise_on_failure=False`. Valores de `flowStatus` não -conhecidos MUST ser tratados como não terminais. - -#### Scenario: Rejeição da prefeitura -- **WHEN** a nota chega a `IssueFailed` com `flowMessage` "max retry …" -- **THEN** o SDK levanta `InvoiceProcessingError` com `flow_message` preenchido - -#### Scenario: Estado novo desconhecido -- **WHEN** a API devolve `flowStatus: "WaitingSomethingNew"` -- **THEN** `wait` continua consultando até um estado terminal ou o prazo - -### Requirement: Tolerância a atraso de indexação -Durante os primeiros `not_found_grace` segundos (padrão 10 s) de uma espera, respostas 404 da consulta -SHALL ser tratadas como "ainda processando"; depois disso, SHALL propagar `NotFoundError`. - -#### Scenario: 404 logo após a emissão -- **WHEN** a primeira consulta do `wait` recebe 404 e a segunda recebe a nota -- **THEN** `wait` continua normalmente - -### Requirement: Atalhos de emitir e cancelar com espera -O SDK SHALL oferecer `create_and_wait` e `cancel_and_wait`, que executam a operação e em seguida `wait` -com os mesmos parâmetros de espera, propagando `external_id` e marcação de resultado incerto quando a -operação inicial falhar. - -#### Scenario: Cancelar e esperar -- **WHEN** o usuário chama `cancel_and_wait(company_id, invoice_id)` e a nota chega a `Cancelled` -- **THEN** o método devolve a nota com `flow_status == "Cancelled"` diff --git a/openspec/changes/add-sdk-foundation-v0-1/specs/retry-idempotency/spec.md b/openspec/changes/add-sdk-foundation-v0-1/specs/retry-idempotency/spec.md deleted file mode 100644 index d43f0a4..0000000 --- a/openspec/changes/add-sdk-foundation-v0-1/specs/retry-idempotency/spec.md +++ /dev/null @@ -1,113 +0,0 @@ -## Purpose - -Define quando o SDK repete uma requisição e quando não pode repetir, para que falhas transitórias sejam -absorvidas sem nunca duplicar uma emissão fiscal, e como o usuário reconcilia emissões de resultado -incerto usando `externalId`. - -## ADDED Requirements - -### Requirement: Retry ciente de método e de fase -O SDK SHALL repetir requisições GET, HEAD, PUT e DELETE em respostas 408, 429, 500, 502, 503 e 504 e em -falhas de rede de qualquer fase. Para POST, o SDK SHALL repetir apenas falhas de rede ocorridas antes de -a conexão ser estabelecida, e MUST NOT repetir em resposta 408, 429 ou 5xx nem em falha ocorrida depois -do envio. Demais respostas 4xx MUST NOT ser repetidas. - -#### Scenario: GET com 503 -- **WHEN** um GET recebe 503 e depois 200 -- **THEN** o SDK devolve o resultado do 200 após uma nova tentativa - -#### Scenario: POST de emissão com 504 -- **WHEN** o POST de emissão recebe 504 -- **THEN** o SDK não faz nova tentativa e levanta `ServerError` com `outcome_unknown=True` - -#### Scenario: POST com conexão recusada -- **WHEN** o POST falha por conexão recusada antes do envio e a nova tentativa conecta -- **THEN** o SDK envia a requisição uma única vez ao servidor e devolve a resposta - -#### Scenario: POST com timeout de leitura -- **WHEN** o POST é enviado e a leitura da resposta estoura o timeout -- **THEN** o SDK não repete e levanta `APITimeoutError` com `outcome_unknown=True` - -#### Scenario: POST com 429 -- **WHEN** um POST recebe 429 -- **THEN** o SDK não repete e levanta `RateLimitError` com `retry_after` quando informado - -### Requirement: Idempotency-Key não libera retry -O SDK MUST NOT tratar a presença de `Idempotency-Key` como autorização para repetir POST, enquanto a API -não honrar esse cabeçalho. - -#### Scenario: POST com idempotency_key e 500 -- **WHEN** o usuário envia um POST com `idempotency_key` e recebe 500 -- **THEN** o SDK não repete a requisição - -### Requirement: Backoff exponencial com jitter -O SDK SHALL esperar entre tentativas `min(max_delay, base · 2^(n−1))` multiplicado por fator aleatório -uniforme entre 0,7 e 1,3, com padrões `max_retries=3`, `base=1,0 s` e `max_delay=30 s`, todos -configuráveis no cliente, e `max_retries` também por chamada. - -#### Scenario: Retries desligados -- **WHEN** a chamada usa `max_retries=0` e recebe 503 num GET -- **THEN** o SDK levanta `ServerError` após uma única tentativa - -#### Scenario: Limite de tentativas -- **WHEN** um GET recebe 503 em todas as tentativas com a configuração padrão -- **THEN** o SDK faz 4 tentativas no total e levanta `ServerError` - -### Requirement: Respeito a Retry-After -Em resposta repetível com `Retry-After`, o SDK SHALL aguardar o valor informado, em segundos inteiros ou -data HTTP, quando ele for menor ou igual a `max_retry_after` (padrão 60 s); acima disso, o SDK MUST NOT -repetir e SHALL levantar o erro correspondente com `retry_after` preenchido. - -#### Scenario: Retry-After em data HTTP -- **WHEN** um GET recebe 429 com `Retry-After` em formato de data 5 s no futuro -- **THEN** o SDK espera cerca de 5 s antes da nova tentativa - -#### Scenario: Retry-After longo demais -- **WHEN** um GET recebe 429 com `Retry-After: 3600` -- **THEN** o SDK levanta `RateLimitError` com `retry_after == 3600` sem esperar - -### Requirement: Registro de tentativas sem dados sensíveis -O SDK SHALL registrar cada nova tentativa no logger `nfeio` em nível DEBUG com método, host, path, -status, número da tentativa, espera e request id, e MUST NOT registrar cabeçalhos de autenticação, query -string ou corpo. - -#### Scenario: Log de retry -- **WHEN** um GET é repetido após 502 com log DEBUG ativo -- **THEN** existe um registro com status 502 e tentativa 1, sem a chave de API - -### Requirement: Dedupe de emissão por externalId -`service_invoices.create` SHALL aceitar `external_id` e enviá-lo como `externalId` no corpo. Quando a API -rejeitar a emissão por `externalId` já existente, o SDK SHALL levantar `DuplicateExternalIdError` com o -`external_id`. Erros de resultado incerto em emissão SHALL carregar `outcome_unknown=True` e o -`external_id`. O SDK MUST NOT reenviar uma emissão automaticamente após resultado incerto. - -#### Scenario: externalId repetido -- **WHEN** a API responde 400 com `service invoice with external id (pedido-1) already exists` -- **THEN** o SDK levanta `DuplicateExternalIdError` com `external_id == "pedido-1"` - -#### Scenario: Resultado incerto -- **WHEN** a emissão com `external_id="pedido-2"` recebe 500 -- **THEN** o erro tem `outcome_unknown=True` e `external_id == "pedido-2"` e nenhuma segunda emissão é enviada - -### Requirement: Busca por externalId com espera de indexação -`service_invoices.find_by_external_id(company_id, external_id, wait=0)` SHALL devolver a nota quando a -API retornar lista com itens e `None` quando retornar lista vazia, e, com `wait > 0`, SHALL repetir a -busca com backoff até encontrar ou esgotar o prazo. Qualquer outra resposta (corpo vazio, forma -desconhecida, redirect) MUST levantar `UnexpectedResponseError` e MUST NOT ser interpretada como -"nota inexistente". - -#### Scenario: Nota encontrada -- **WHEN** a API responde `{"serviceInvoices":[{…}], "page":1}` -- **THEN** o método devolve o primeiro item como `ServiceInvoice` - -#### Scenario: Nota inexistente -- **WHEN** a API responde 200 com `{"serviceInvoices":[], "page":1}` -- **THEN** o método devolve `None` sem levantar erro - -#### Scenario: Resposta inesperada na busca -- **WHEN** a busca responde 200 com corpo vazio -- **THEN** o SDK levanta `UnexpectedResponseError` em vez de devolver `None` - -#### Scenario: Indexação atrasada -- **WHEN** `wait=30` e a primeira busca volta vazia e a terceira traz a nota -- **THEN** o método devolve a nota diff --git a/openspec/changes/add-sdk-foundation-v0-1/specs/service-invoices/spec.md b/openspec/changes/add-sdk-foundation-v0-1/specs/service-invoices/spec.md deleted file mode 100644 index f18fb35..0000000 --- a/openspec/changes/add-sdk-foundation-v0-1/specs/service-invoices/spec.md +++ /dev/null @@ -1,101 +0,0 @@ -## Purpose - -Define as operações de Nota Fiscal de Serviço eletrônica (NFS-e, API v1 em `api.nfe.io`) oferecidas pelo -SDK: emitir, listar, consultar por id e por `externalId`, cancelar, reenviar e-mail e baixar PDF e XML, -seguindo o contrato observado no fio. - -## ADDED Requirements - -### Requirement: Emitir NFS-e -`service_invoices.create(company_id, params, *, external_id=None, options=None)` SHALL enviar -`POST /v1/companies/{company_id}/serviceinvoices` com o corpo informado (chaves do fio) acrescido de -`externalId` quando `external_id` for informado, e SHALL aceitar 202 (assíncrono) e 200/201 (imediato). - -#### Scenario: Emissão assíncrona -- **WHEN** a API responde 202 -- **THEN** o SDK devolve a nota parcial com `id` e `flow_status` e `last_response.status_code == 202` - -#### Scenario: Conflito de externalId informado nos dois lugares -- **WHEN** o usuário passa `external_id="A"` e `params["externalId"] == "B"` -- **THEN** o SDK levanta `InvalidParameterError` sem requisição - -### Requirement: Listar NFS-e -`service_invoices.list(company_id, *, page_index=1, page_count=10, issued_begin=None, issued_end=None, -created_begin=None, created_end=None)` SHALL enviar `GET /v1/companies/{company_id}/serviceinvoices` -com os filtros informados (datas em `AAAA-MM-DD`) e SHALL devolver uma página por índice de -`ServiceInvoice` lida de `serviceInvoices`. - -#### Scenario: Filtro por data de emissão -- **WHEN** o usuário lista com `issued_begin=date(2026,1,1)` e `issued_end=date(2026,12,31)` -- **THEN** a requisição envia `issuedBegin=2026-01-01&issuedEnd=2026-12-31` - -### Requirement: Consultar NFS-e -`service_invoices.retrieve(company_id, invoice_id)` SHALL enviar `GET …/serviceinvoices/{invoice_id}` e -devolver a nota (objeto sem envelope). - -#### Scenario: Nota inexistente -- **WHEN** a API responde 404 com a string `service invoice with id (X) was not found` -- **THEN** o SDK levanta `NotFoundError` com essa mensagem - -### Requirement: Consultar por externalId -`service_invoices.find_by_external_id` SHALL enviar `GET …/serviceinvoices/external/{external_id}` com o -valor codificado e SHALL tratar a resposta como lista conforme a capacidade `retry-idempotency`. - -#### Scenario: Codificação do externalId -- **WHEN** o usuário busca `external_id="WOO NFE/110"` -- **THEN** o path enviado termina em `/external/WOO%20NFE%2F110` - -### Requirement: Cancelar NFS-e -`service_invoices.cancel(company_id, invoice_id)` SHALL enviar `DELETE …/serviceinvoices/{invoice_id}` e -SHALL aceitar 202 (assíncrono, tratado como na capacidade `polling`) e 200 (corpo de nota ou texto). - -#### Scenario: Cancelamento assíncrono -- **WHEN** a API responde 202 com `Location` -- **THEN** o SDK devolve a nota parcial com o id da nota cancelada - -### Requirement: Reenviar e-mail -`service_invoices.send_email(company_id, invoice_id)` SHALL enviar -`PUT …/serviceinvoices/{invoice_id}/sendemail` sem corpo e SHALL concluir sem erro em resposta 2xx. - -#### Scenario: Reenvio aceito -- **WHEN** a API responde 200 -- **THEN** o método retorna sem levantar exceção - -### Requirement: Baixar PDF e XML -`download_pdf`, `download_xml` e `download_cancellation_xml` SHALL enviar `GET` para `…/{invoice_id}/pdf`, -`…/xml` e `…/cancellation-xml` e SHALL devolver os `bytes` do documento, seguindo redirects conforme a -capacidade `http-transport`. O XML SHALL ser devolvido sem alteração, inclusive quando não tiver prólogo -`` -- **THEN** os bytes devolvidos são idênticos aos recebidos - -#### Scenario: XML de cancelamento indisponível -- **WHEN** a API responde 404 com a explicação de que o XML de cancelamento só existe no ambiente nacional -- **THEN** o SDK levanta `NotFoundError` com essa mensagem - -### Requirement: Modelo ServiceInvoice -`ServiceInvoice` SHALL expor propriedades tipadas apenas para `id`, `external_id`, `environment`, -`flow_status`, `flow_message`, `status`, `services_amount`, `base_tax_amount`, `deductions_amount`, -`iss_rate`, `iss_tax_amount`, `amount_net`, `issued_on`, `cancelled_on`, `created_on`, `modified_on`, -`provider` e `borrower` (estes dois como `Party`, com `id` e `federal_tax_number` normalizado), cada uma -correspondendo a uma chave presente na spec ou observada no fio, e SHALL preservar todas as demais chaves -(por exemplo `number`, `checkCode`, `rpsNumber`, `description`, `cityServiceCode`) no acesso por chave -conforme a capacidade `client-core`. - -#### Scenario: Tomador com documento numérico -- **WHEN** a resposta traz `"borrower": {"federalTaxNumber": 52998224725}` -- **THEN** `invoice.borrower.federal_tax_number == "52998224725"` - -### Requirement: Corpo de emissão tipado -O SDK SHALL publicar um tipo de corpo de emissão com as chaves do fio documentadas na spec de NFS-e v1, -aceitando valores monetários como `int`, `float` ou `Decimal`, serializados como número JSON sem perda. - -#### Scenario: Valor Decimal na emissão -- **WHEN** o corpo contém `"servicesAmount": Decimal("100.10")` -- **THEN** o JSON enviado contém `"servicesAmount":100.10` diff --git a/openspec/changes/add-sdk-foundation-v0-1/specs/webhooks/spec.md b/openspec/changes/add-sdk-foundation-v0-1/specs/webhooks/spec.md deleted file mode 100644 index e8917e2..0000000 --- a/openspec/changes/add-sdk-foundation-v0-1/specs/webhooks/spec.md +++ /dev/null @@ -1,86 +0,0 @@ -## Purpose - -Define o gerenciamento dos webhooks da conta (`/v2/webhooks`) e o tratamento seguro das entregas -recebidas: verificação de assinatura HMAC-SHA1 em tempo constante e interpretação do evento. - -## ADDED Requirements - -### Requirement: CRUD de webhooks da conta -O SDK SHALL oferecer em `client.webhooks`: `list()` (`GET /v2/webhooks`, desembrulha `webHooks`), -`retrieve(id)` (desembrulha `webHook`), `create(params)` e `update(id, params)` (corpo embrulhado em -`{"webHook": …}`), `delete(id)` e `ping(id)` (`PUT /v2/webhooks/{id}/pings`), usando a chave principal. -O SDK MUST NOT oferecer a remoção de todos os webhooks numa chamada. - -#### Scenario: Criação com envelope -- **WHEN** o usuário chama `create({"uri": "https://erp.exemplo/hook", "secret": "…", "filters": ["service_invoice.issued_successfully"]})` -- **THEN** o corpo enviado é `{"webHook": {…}}` e o retorno é o webhook desembrulhado - -#### Scenario: URI recusada na verificação -- **WHEN** a API não consegue validar a URI na criação e responde 400 com `code` 40001 -- **THEN** o SDK levanta `InvalidRequestError` com `error_code == 40001` e a mensagem da API - -#### Scenario: Valores textuais do fio -- **WHEN** a listagem traz `"contentType": "json"` e `"status": "Active"` -- **THEN** `hook.content_type == "json"` e `hook.status == "Active"` - -### Requirement: Atualização é substituição completa -`webhooks.update` SHALL enviar o objeto informado como substituição completa, sem mesclar com o estado -atual, e sua documentação MUST avisar que omitir `status` desativa o webhook. - -#### Scenario: Update sem merge -- **WHEN** o usuário chama `update(id, {"uri": "https://novo"})` -- **THEN** o corpo enviado contém apenas `{"webHook": {"uri": "https://novo"}}` - -### Requirement: Tipos de evento -`webhooks.event_types()` SHALL enviar `GET /v2/webhooks/eventtypes` e devolver a lista lida de -`eventTypes`, com `id` tratado como texto livre. - -#### Scenario: Lista de tipos -- **WHEN** a API devolve 47 tipos, incluindo identificadores fora do padrão -- **THEN** o método devolve os 47 sem erro - -### Requirement: Verificação de assinatura -`nfeio.webhooks.verify_signature(payload, signature, secret) -> bool` SHALL devolver `True` somente -quando `signature` tiver o prefixo `sha1=` (sem distinção de maiúsculas) seguido de exatamente 40 -dígitos hexadecimais iguais, sem distinção de maiúsculas, ao HMAC-SHA1 de `payload` com `secret`. A -comparação MUST usar `hmac.compare_digest`. A função MUST NOT levantar exceção para nenhuma entrada: -assinatura ausente, vazia, malformada, com outro algoritmo, segredo vazio ou tipos inesperados SHALL -resultar em `False`. `payload` SHALL aceitar `bytes` ou `str` (codificada em UTF-8) e `signature` -SHALL aceitar `str`, sequência de `str` (usa o primeiro) ou `None`. Não requer cliente. - -#### Scenario: Entregas reais -- **WHEN** a função recebe cada um dos 3 corpos de ping reais do arquivo de vetores com o segredo e o `X-Hub-Signature` capturados -- **THEN** devolve `True` para os três - -#### Scenario: Hex em minúsculas -- **WHEN** a assinatura válida é enviada em minúsculas -- **THEN** a função devolve `True` - -#### Scenario: Corpo adulterado -- **WHEN** um byte do corpo é alterado -- **THEN** a função devolve `False` - -#### Scenario: Entradas inválidas -- **WHEN** a assinatura é `sha256=`, ou 40 hex sem prefixo, ou `sha1=abc`, ou contém não hex, ou `None`, ou o segredo é vazio -- **THEN** a função devolve `False` sem levantar exceção - -### Requirement: Construção do evento -`nfeio.webhooks.construct_event(payload, headers, secret) -> WebhookEvent` SHALL verificar a assinatura -lida de `X-Hub-Signature` antes de interpretar o corpo e SHALL levantar `SignatureVerificationError` -quando ela não for válida. Só após a verificação o corpo SHALL ser decodificado como JSON, com limite de -5 MiB. `WebhookEvent` SHALL expor `action`, `event_type` (de `X-Hook-Event`), `hook_id` (de `X-Hook-Id`), -`data` e `raw` (bytes recebidos). Para corpo `{"action", "payload"}`, `data` SHALL ser o `payload` -(como `ServiceInvoice` quando `event_type` for `service_invoice`); para ping -`{"action":"ping","webHook"}`, `data` SHALL ser o `webHook`. - -#### Scenario: Entrega de nota emitida -- **WHEN** chega `{"action":"issued_successfully","payload":{"id":"…","flowStatus":"Issued"}}` com `X-Hook-Event: service_invoice` e assinatura válida -- **THEN** `event.action == "issued_successfully"` e `event.data.flow_status == "Issued"` - -#### Scenario: Assinatura inválida -- **WHEN** a assinatura não confere -- **THEN** o SDK levanta `SignatureVerificationError` sem decodificar o corpo - -#### Scenario: Cabeçalhos sem distinção de maiúsculas -- **WHEN** os cabeçalhos chegam como `x-hub-signature` e `x-hook-id` -- **THEN** o evento é construído normalmente diff --git a/openspec/changes/add-sdk-foundation-v0-1/tasks.md b/openspec/changes/add-sdk-foundation-v0-1/tasks.md deleted file mode 100644 index 0827057..0000000 --- a/openspec/changes/add-sdk-foundation-v0-1/tasks.md +++ /dev/null @@ -1,105 +0,0 @@ -## 0. Pré-requisitos (bloqueiam a implementação) - -- [x] 0.1 Obter o OK do André em D1–D8 de `docs/contrato/DECISOES-PENDENTES.md` e ajustar specs/design ao que for decidido (D1 = "D enxuta"); verificar com `openspec validate add-sdk-foundation-v0-1 --strict` -- [x] 0.2 Sonda de escrita controlada, feita pelos testes live do próprio SDK (seção 15), dentro das permissões da fase 2: (a) NFS-e só na `NFE_COMPANY_ID`, `externalId` único `sdkpy-`, no máximo ~5 notas: emitir, repetir o POST (esperar 400 de duplicidade), `find_by_external_id` logo após o 202 (medir atraso de indexação), baixar PDF/XML, cancelar (confirmar 202 + `Location`); (b) criar e apagar UMA empresa descartável `SDK-PY TESTE DESCARTAVEL ` (CRUD v2); (c) criar e apagar webhooks apontando para `https://example.com/nfeio-sdkpy-test`. Fora do escopo: `sendemail` ao vivo e upload de certificado real. Registrar em `docs/contrato/probe-fase2.md` e evidências redigidas em `tests/fixtures/live-contracts/` -- [x] 0.3 Renovação do certificado da empresa de teste (Menor 4: ok, será renovado) e canal do `SECURITY.md` (Menor 3: GitHub Private Vulnerability Reporting) — registrados no DECISOES - -## 1. Fundação do repositório - -- [x] 1.1 Criar `pyproject.toml` (hatchling, `name = "nfe-io"`, `requires-python = ">=3.10"`, sem `dependencies`, versão dinâmica de `src/nfeio/_version.py`, grupos dev) e `uv.lock`; verificar com `uv sync` e `uv build` gerando wheel sem `Requires-Dist` -- [x] 1.2 Criar `src/nfeio/__init__.py`, `_version.py`, `py.typed` e `_generated/README.md` (regra 5); verificar `uv run python -c "import nfeio; print(nfeio.__version__)"` -- [x] 1.3 Configurar ruff (regras E, F, W, I, B, UP, S, SIM, RUF, PT, ASYNC + format), mypy `--strict`, pytest (marcador `live`), coverage com mínimos por módulo; verificar os quatro comandos verdes no repositório vazio -- [x] 1.4 Adicionar `LICENSE`, `SECURITY.md`, `CHANGELOG.md` (pt-BR, Keep a Changelog, seção `[Não lançado]`) e `README.md` mínimo; verificar presença no sdist com `tar tzf dist/*.tar.gz` -- [x] 1.5 Teste que falha se houver literal de versão fora de `_version.py`; verificar introduzindo uma literal falsa e vendo o teste falhar - -## 2. Núcleo sans-IO - -- [x] 2.1 Implementar `Effect` (`Send`, `Sleep`, `Now`), `run_sync` e `run_async` em `_core/ops.py`; verificar com testes de um `Op` de exemplo rodando nos dois drivers com transporte falso -- [x] 2.2 Criar `FakeTransport` de teste (fila de respostas roteiro, registro de requisições) e relógio falso; verificar que testes não fazem I/O real nem `sleep` real (tempo da suíte unitária < 5 s) - -## 3. Configuração, segredos e paths - -- [x] 3.1 Implementar `ClientConfig`, `RequestOptions`, `Timeout`, `ApiFamily` e tabela de hosts, com leitura de `NFE_API_KEY`/`NFE_DATA_API_KEY` e validações (https, timeouts finitos, cabeçalhos protegidos); verificar cenários de `client-core` -- [x] 3.2 Implementar `SecretStr` e `repr` mascarado de cliente/config/opções; verificar teste de varredura de vazamento (log DEBUG + repr + exceções) -- [x] 3.3 Implementar `_core/paths.py`: segmento opaco, `external_id` percent-encoded, CNPJ (numérico e alfanumérico, DV da RFB), CPF, CEP, UF, data; verificar com testes tabulares incluindo `../`, vazio, controle, 256 chars, CNPJ `12ABC34501DE35` e `00000000000100` - -## 4. Transporte - -- [x] 4.1 Implementar protocolos `Transport`/`AsyncTransport`, `HttpRequest`/`HttpResponse` (cabeçalhos case-insensitive); verificar com transporte customizado nos testes -- [x] 4.2 Implementar `HttpClientTransport` sobre `http.client`: connect explícito, classificação de fase, pool LIFO por host com lock, POST sempre em conexão nova; verificar com servidor local de teste (DNS inválido, porta fechada, servidor que não responde, servidor que fecha a conexão) -- [x] 4.3 TLS: contexto padrão TLS ≥ 1.2, aceitar `ssl_context`/`ca_bundle` só com verificação ligada; verificar rejeição de `CERT_NONE`/`check_hostname=False` e falha contra servidor local autoassinado -- [x] 4.4 Timeouts (connect, read, prazo total) e limite de resposta em chunks com `Accept-Encoding: identity`; verificar com servidor local lento e com corpo maior que o limite -- [x] 4.5 User-Agent e `app_info`; verificar formato exato em teste -- [x] 4.6 Encoder multipart; verificar corpo byte a byte e saneamento de filename -- [x] 4.7 `ThreadedAsyncTransport` (`asyncio.to_thread`); verificar teste de concorrência com `asyncio.gather` e tarefa paralela progredindo - -## 5. Erros - -- [x] 5.1 Implementar hierarquia de `nfeio.errors` e mapeamento status → classe; verificar tabela de status (400, 401, 403, 404, 405, 408, 409, 415, 422, 429, 500, 502, 503, 504) -- [x] 5.2 Implementar extrator de mensagem para os 5 envelopes + corpo vazio, saneamento e truncamento, `request_id`/`trace_id`; verificar com as respostas reais redigidas de `scripts/probes/out/` convertidas em fixtures de teste -- [x] 5.3 Dica de chave em `PermissionDeniedError`; verificar mensagem para família fiscal e de dados - -## 6. Retry e idempotência - -- [x] 6.1 Implementar tabela método × status × fase, backoff com jitter e `Retry-After` (segundos e data HTTP, teto); verificar cada célula da tabela em teste parametrizado e nos dois drivers -- [x] 6.2 Marcar `outcome_unknown` em erros de POST incertos e logging DEBUG sem dados sensíveis; verificar cenários de `retry-idempotency` -- [x] 6.3 `DuplicateExternalIdError` por casamento tolerante da mensagem; verificar com a mensagem real registrada no vault e variações de caixa - -## 7. Modelos - -- [x] 7.1 Implementar `NfeObject` (Mapping imutável sem `__getattr__`, aninhados por chave como `NfeObject`, listas copiadas, `to_dict`, `__eq__`, `last_response`, `repr` com chaves sensíveis mascaradas), descritores de campo tipado e parser tolerante de datas/`Decimal`; verificar cenários de `client-core` em Python 3.10 e 3.14 -- [x] 7.2 Encoder JSON que aceita `Decimal` sem perda; verificar `Decimal("100.10")` → `100.10` -- [x] 7.3 Teste de alinhamento ancorado em path: cada propriedade tipada aponta para chave da spec ou da lista de divergências provadas; verificar falha ao incluir propriedade fantasma - -## 8. Paginação - -- [x] 8.1 Implementar `OffsetPage`/`CursorPage` com `next_page` e `auto_paging_iter` (sync e async), parada em página vazia e tabela de limites por rota; verificar cenários de `pagination`, incluindo `hasMore:true` com lista vazia - -## 9. Polling - -- [x] 9.1 Implementar parse de 202 (corpo + path do `Location`), `wait` com backoff, estados terminais, tolerância a 404 inicial e `PollingTimeoutError`/`InvoiceProcessingError`; verificar cenários de `polling` com relógio falso - -## 10. NFS-e - -- [x] 10.1 Implementar `service_invoices.create/list/retrieve/find_by_external_id/cancel/send_email`; verificar cenários de `service-invoices` com fixtures redigidas -- [x] 10.2 Implementar `download_pdf/xml/cancellation_xml` com follow de redirect sem credencial; verificar que a requisição ao destino não tem `Authorization` e que o XML sem prólogo volta intacto -- [x] 10.3 Implementar `create_and_wait`/`cancel_and_wait`; verificar sequência 202 → WaitingSend → Issued -- [x] 10.4 Modelo `ServiceInvoice` (+ `Borrower`, `Provider`, `Address`) e `TypedDict` do corpo de emissão; verificar com a nota real redigida da sonda - -## 11. Empresas e certificados - -- [x] 11.1 Implementar `companies.list/retrieve/create/update/delete` conforme D3; verificar cenários de `companies` -- [x] 11.2 Implementar `certificates.upload/list` e modelo `Certificate` com `expires_on`/`is_expired`; verificar cenários de `certificates` e ausência da senha em logs/erros - -## 12. Consultas de dados - -- [x] 12.1 Implementar `lookups.cnpj`, `cnpj_state_taxes`, `cpf`, `cep` com chave de dados e validação local; verificar cenários de `data-lookups` - -## 13. Webhooks - -- [x] 13.1 Implementar `client.webhooks` (list, retrieve, create, update, delete, ping, event_types) com envelopes; verificar cenários de CRUD -- [x] 13.2 Implementar `nfeio.webhooks.verify_signature` e `construct_event`; verificar com os 3 vetores reais de `client-nodejs/tests/fixtures/webhook-signatures.json` (copiados para `tests/fixtures/`) e todos os negativos da spec - -## 14. Cliente assíncrono - -- [x] 14.1 Implementar fachadas `AsyncNfeClient` delegando aos mesmos `Op`; verificar teste de paridade de assinaturas e teste de comportamento idêntico (mesmo roteiro de respostas nos dois clientes) -- [x] 14.2 `with`/`close` e `async with`/`aclose`; verificar que o pool fica vazio após o bloco - -## 15. Testes de integração (opt-in) - -- [x] 15.1 Conftest com parser stdlib de `.env`, marcador `live`, pulo sem chave e trava `NFE_LIVE_WRITE=1` + `NFE_COMPANY_ID`; verificar `uv run pytest` sem `.env` (pulados) e `uv run pytest -m live` com `.env` (leitura passa) -- [x] 15.2 Testes live de leitura: auth por família, varredura de companies v2, lista/retrieve/external/downloads de NFS-e, certificados, webhooks, CNPJ/CEP; verificar contra a conta de teste -- [x] 15.3 Testes live de escrita (opt-in `NFE_LIVE_WRITE=1`): NFS-e só na `NFE_COMPANY_ID` (`create_and_wait` com `external_id`, duplicidade, `find_by_external_id(wait=30)`, downloads, `cancel_and_wait`; pular se o certificado estiver vencido); CRUD v2 de UMA empresa descartável com remoção garantida em `finally`; CRUD de webhook de teste com remoção garantida; upload de certificado só unitário (ou pulado e registrado) - -## 16. Documentação - -- [x] 16.1 README (instalação, chaves por família, exemplo sync/async, paginação, polling) e guia "emissão segura" com a receita de reconciliação por `external_id`; verificar exemplos executados por teste de documentação contra `FakeTransport` -- [x] 16.2 Guia de webhooks (corpo cru em Django/Flask/FastAPI, deduplicação por `X-Hook-Id`, aviso de update full-replace); verificar exemplos por teste -- [x] 16.3 Atualizar `CHANGELOG.md` com a entrada 0.1.0 em pt-BR; verificar formato Keep a Changelog - -## 17. CI e release (sem publicar) - -- [ ] 17.1 Workflow de CI: lint, format, mypy, testes na matriz 3.10–3.14 (Linux) + 3.12 (macOS, Windows), cobertura, build; actions fixadas por SHA e `permissions` mínimas; verificar execução verde num PR — **escrito** (`.github/workflows/ci.yml`); comandos equivalentes verdes localmente em 3.10–3.14; falta a primeira execução no GitHub (sem push nesta fase) -- [ ] 17.2 Workflows de segurança: bandit, pip-audit (`uv export` dev), CodeQL; Dependabot para actions e dev; verificar execução verde — **escrito** (`security.yml`, `dependabot.yml`; o CodeQL roda pelo default setup do repositório no GitHub, e o `codeql.yml` próprio foi removido porque o GitHub recusa o SARIF com o default setup ligado); bandit e pip-audit verdes localmente; pins de actions a conferir com `scripts/verify_action_pins.py` (sem acesso ao GitHub nesta sessão) -- [ ] 17.3 Workflow de release por tag com build único, environment `pypi` protegido, Trusted Publishing OIDC e attestations; verificar em dry-run contra TestPyPI **somente após OK do André** — **escrito** (`release.yml`); dry-run no TestPyPI aguarda OK do André -- [ ] 17.4 Revisão final: `openspec validate add-sdk-foundation-v0-1 --strict`, checklist de segurança do design §11 item a item; verificar e registrar no PR — validate `--strict` ok e checklist registrado em `docs/contrato/revisao-seguranca-v0.1.md`; falta anexar ao PR diff --git a/openspec/config.yaml b/openspec/config.yaml deleted file mode 100644 index 302559b..0000000 --- a/openspec/config.yaml +++ /dev/null @@ -1,28 +0,0 @@ -schema: spec-driven - -context: | - Language: pt-BR - All artifacts must be written in pt-BR. - Keep OpenSpec structural headings and SHALL/MUST keywords in English. - -# Per-artifact rules (optional) -# Add custom rules for specific artifacts. -# Example: -# rules: -# proposal: -# - Keep proposals under 500 words -# - Always include a "Non-goals" section -# tasks: -# - Break tasks into chunks of max 2 hours - -# Per-operation guidance (optional) -# Add advisory guidance for how apply and archive work should be conducted. -# This is separate from artifact rules above. -# Example: -# operations: -# apply: -# guidance: -# - Keep test summaries concise -# archive: -# guidance: -# - Summarize the archive outcome before finishing diff --git a/pyproject.toml b/pyproject.toml index 95b23aa..08afb44 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,7 +74,7 @@ exclude = ["/tests/live/out"] line-length = 100 target-version = "py310" src = ["src", "tests"] -extend-exclude = ["scripts/probes", "*.md"] +extend-exclude = ["*.md"] [tool.ruff.lint] select = ["E", "F", "W", "I", "B", "UP", "S", "SIM", "RUF", "PT", "ASYNC"] diff --git a/scripts/probes/README.md b/scripts/probes/README.md deleted file mode 100644 index 2ade13c..0000000 --- a/scripts/probes/README.md +++ /dev/null @@ -1,21 +0,0 @@ -# Sondas ao vivo (somente leitura) - -Scripts stdlib que confirmam o contrato da API antes de qualquer decisão do SDK -(regra 1 das regras do projeto). Resumo redigido: `docs/contrato/probe-2026-10-08.md`. - -```bash -cd scripts/probes -python3 p01_auth_matrix.py # chave × host × esquema de auth -python3 p02_companies.py # companies v1 (offset) e v2 (cursor), limites, paridade -python3 p03_service_invoices.py # NFS-e: lista, retrieve, externalId, downloads (redirect) -python3 p04_certs_webhooks_lookups.py -python3 p05_rechecks.py # reconfere afirmações antigas do vault -python3 p06_cnpj_v3.py -``` - -- Credenciais vêm de `../../.env` (`NFE_API_KEY`, `NFE_DATA_API_KEY`, `NFE_COMPANY_ID`). -- `_probe.request()` recusa qualquer método além de GET/HEAD. Sondas de escrita (fase 2a) vão em - scripts próprios, com trava para operar só na `NFE_COMPANY_ID`. -- Saída crua em `out/` (gitignored): pode conter dado real de terceiros. Credenciais e query de URL - pré-assinada são removidas antes de gravar; `save()` aborta se uma chave vazar. -- Consultas de CNPJ/CPF/CEP podem ser tarifadas: rode só o necessário. diff --git a/scripts/probes/_probe.py b/scripts/probes/_probe.py deleted file mode 100644 index 3d3f38f..0000000 --- a/scripts/probes/_probe.py +++ /dev/null @@ -1,236 +0,0 @@ -"""Read-only live probe helpers for the NFE.io API (stdlib only). - -Safety rails: -- Only GET and HEAD are allowed; any other method raises before touching the network. -- API keys are read from ``.env`` and never written to disk or stdout. Request headers - saved to ``out/`` have credentials replaced by ````. -- Redirects are NOT followed automatically so the raw 3xx (e.g. pre-signed download URLs) - is observable. Pre-signed query strings are stripped before saving. -- Response bodies are capped at ``MAX_BODY`` bytes. - -Raw output goes to ``scripts/probes/out/`` (gitignored). It can contain real third-party -data; the versioned summary in ``docs/contrato/`` must be redacted by hand. -""" - -from __future__ import annotations - -import base64 -import json -import os -import platform -import ssl -import sys -import time -import urllib.error -import urllib.parse -import urllib.request -from pathlib import Path -from typing import Any - -ROOT = Path(__file__).resolve().parents[2] -OUT = Path(__file__).resolve().parent / "out" -MAX_BODY = 512 * 1024 -ALLOWED_METHODS = frozenset({"GET", "HEAD"}) -USER_AGENT = ( - f"nfe-io-python-probe/0.0 python/{sys.version_info.major}.{sys.version_info.minor} " - f"{platform.system().lower()}" -) -SENSITIVE_HEADERS = frozenset({"authorization", "x-nfe-apikey", "apikey"}) - - -def load_env() -> dict[str, str]: - env: dict[str, str] = {} - path = ROOT / ".env" - for raw in path.read_text(encoding="utf-8").splitlines(): - line = raw.strip() - if not line or line.startswith("#") or "=" not in line: - continue - k, v = line.split("=", 1) - env[k.strip()] = v.strip().strip('"').strip("'") - return env - - -ENV = load_env() -KEYS = {"main": ENV.get("NFE_API_KEY", ""), "data": ENV.get("NFE_DATA_API_KEY", "")} -COMPANY_ID = ENV.get("NFE_COMPANY_ID", "") - - -class _NoRedirect(urllib.request.HTTPRedirectHandler): - def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[no-untyped-def] - return None - - -_OPENER = urllib.request.build_opener( - _NoRedirect(), urllib.request.HTTPSHandler(context=ssl.create_default_context()) -) - - -def _auth(key_name: str | None, scheme: str) -> tuple[dict[str, str], dict[str, str]]: - """Return (headers, query) for the chosen key and auth scheme.""" - if key_name is None: - return {}, {} - key = KEYS[key_name] - if not key: - raise SystemExit(f"missing key {key_name} in .env") - if scheme == "x-nfe-apikey": - return {"X-NFE-APIKEY": key}, {} - if scheme == "authorization": - return {"Authorization": key}, {} - if scheme == "basic": - token = base64.b64encode(f"{key}:".encode()).decode() - return {"Authorization": f"Basic {token}"}, {} - if scheme == "query": - return {}, {"apikey": key} - raise ValueError(scheme) - - -def _strip_presigned(url: str) -> str: - """Keep only query parameter NAMES of a redirect target (values may be bearer tokens).""" - parts = urllib.parse.urlsplit(url) - if not parts.query: - return url - names = ",".join(k for k, _ in urllib.parse.parse_qsl(parts.query, keep_blank_values=True)) - return urllib.parse.urlunsplit(parts._replace(query=f"")) - - -def request( - host: str, - path: str, - *, - key: str | None = "main", - scheme: str = "x-nfe-apikey", - method: str = "GET", - params: dict[str, Any] | None = None, - accept: str = "application/json", - timeout: float = 30.0, -) -> dict[str, Any]: - method = method.upper() - if method not in ALLOWED_METHODS: - raise RuntimeError(f"probe refused: {method} is not read-only") - auth_headers, auth_query = _auth(key, scheme) - query = {**(params or {}), **auth_query} - url = f"https://{host}{path}" - if query: - url += "?" + urllib.parse.urlencode(query) - headers = {"Accept": accept, "User-Agent": USER_AGENT, **auth_headers} - req = urllib.request.Request(url, method=method, headers=headers) - started = time.monotonic() - status: int | None - try: - resp = _OPENER.open(req, timeout=timeout) - status, resp_headers, body = resp.status, dict(resp.headers.items()), resp.read(MAX_BODY + 1) - except urllib.error.HTTPError as e: - status, resp_headers, body = e.code, dict(e.headers.items()), e.read(MAX_BODY + 1) - except (urllib.error.URLError, TimeoutError, OSError) as e: - status, resp_headers, body = None, {}, f"".encode() - elapsed = round(time.monotonic() - started, 3) - truncated = len(body) > MAX_BODY - body = body[:MAX_BODY] - location = next((v for k, v in resp_headers.items() if k.lower() == "location"), None) - for k in [k for k in resp_headers if k.lower() == "location"]: - resp_headers[k] = _strip_presigned(resp_headers[k]) - safe_query = {k: ("" if k == "apikey" else v) for k, v in query.items()} - return { - "request": { - "method": method, - "host": host, - "path": path, - "query": safe_query, - "key": key, - "authScheme": scheme if key else None, - "headers": { - k: ("" if k.lower() in SENSITIVE_HEADERS else v) for k, v in headers.items() - }, - }, - "status": status, - "elapsedSeconds": elapsed, - "headers": resp_headers, - "bodyLength": len(body), - "bodyTruncated": truncated, - "body": _decode(body, _ct(resp_headers)), - # raw redirect target kept in memory only (never saved): see follow_redirect() - "_location": location, - } - - -def _ct(headers: dict[str, str]) -> str: - return next((v for k, v in headers.items() if k.lower() == "content-type"), "") - - -def follow_redirect(r: dict[str, Any], accept: str = "*/*") -> dict[str, Any] | None: - """GET the 3xx target WITHOUT credentials (an SDK must not forward the key cross-host).""" - location = r.pop("_location", None) - if not location: - return None - parts = urllib.parse.urlsplit(location) - req = urllib.request.Request(location, method="GET", headers={"Accept": accept, "User-Agent": USER_AGENT}) - try: - resp = _OPENER.open(req, timeout=60) - status, hdrs, body = resp.status, dict(resp.headers.items()), resp.read(MAX_BODY + 1) - except urllib.error.HTTPError as e: - status, hdrs, body = e.code, dict(e.headers.items()), e.read(MAX_BODY + 1) - for k in [k for k in hdrs if k.lower() == "location"]: - hdrs[k] = _strip_presigned(hdrs[k]) - return { - "request": {"method": "GET", "host": parts.netloc, "path": parts.path, "key": None, - "authScheme": None, "note": "redirect target fetched without credentials"}, - "status": status, - "headers": hdrs, - "bodyLength": min(len(body), MAX_BODY), - "bodyTruncated": len(body) > MAX_BODY, - "body": _decode(body[:MAX_BODY], _ct(hdrs)), - } - - -def _decode(body: bytes, content_type: str) -> Any: - ct = content_type.lower() - if "json" in ct: - try: - return json.loads(body.decode("utf-8-sig")) - except (UnicodeDecodeError, json.JSONDecodeError): - pass - if ct.startswith("text/") or "xml" in ct or "json" in ct or not ct: - try: - text = body.decode("utf-8-sig") - return {"_text_head": text[:2000], "_startsWith": text[:40]} - except UnicodeDecodeError: - pass - return {"_binary_head_hex": body[:16].hex(), "_length": len(body)} - - -def save(name: str, results: Any) -> Path: - OUT.mkdir(parents=True, exist_ok=True) - path = OUT / f"{name}.json" - _drop_private(results) - text = json.dumps(results, ensure_ascii=False, indent=2, default=str) - for secret in (*KEYS.values(),): - if secret: - assert secret not in text, "secret leaked into probe output" - path.write_text(text, encoding="utf-8") - return path - - -def _drop_private(node: Any) -> None: - if isinstance(node, dict): - node.pop("_location", None) - for v in node.values(): - _drop_private(v) - elif isinstance(node, list): - for v in node: - _drop_private(v) - - -def brief(r: dict[str, Any]) -> str: - req = r["request"] - body = r["body"] - shape: Any - if isinstance(body, dict): - shape = sorted(body.keys())[:12] - elif isinstance(body, list): - shape = f"list[{len(body)}]" - else: - shape = type(body).__name__ - return ( - f"{r['status']!s:>4} {req['method']} {req['host']}{req['path']} " - f"key={req['key']} auth={req['authScheme']} ct={_ct(r['headers']) or None} shape={shape}" - ) diff --git a/scripts/probes/p01_auth_matrix.py b/scripts/probes/p01_auth_matrix.py deleted file mode 100644 index 4ae13a7..0000000 --- a/scripts/probes/p01_auth_matrix.py +++ /dev/null @@ -1,50 +0,0 @@ -"""P01 — which key and which auth scheme each host accepts (read-only). - -For each family endpoint, try: no key, main key, data key; and for the accepted key, -each auth scheme (X-NFE-APIKEY, raw Authorization, Basic, ?apikey=). -""" - -from __future__ import annotations - -from _probe import COMPANY_ID, brief, request, save - -# Public, non-personal sample values (Banco do Brasil CNPJ; Av. Paulista CEP; documented test CPF). -CNPJ = "00000000000191" -CEP = "01310100" -CPF = "52998224725" -BIRTH = "1990-01-01" - -TARGETS = [ - ("api.nfe.io", "/v1/companies", {"pageIndex": 1, "pageCount": 2}), - ("api.nfe.io", f"/v1/companies/{COMPANY_ID}/serviceinvoices", {"pageIndex": 1, "pageCount": 2}), - ("api.nfse.io", "/v2/companies", {"limit": 2}), - ("api.nfse.io", "/v2/webhooks", None), - ("api.nfe.io", "/v2/webhooks", None), - ("legalentity.api.nfe.io", f"/v2/legalentities/basicInfo/{CNPJ}", None), - ("legalentity.api.nfe.io", f"/v1/legalentities/basicInfo/{CNPJ}", None), - ("address.api.nfe.io", f"/v2/addresses/{CEP}", None), - ("naturalperson.api.nfe.io", f"/v1/naturalperson/status/{CPF}/{BIRTH}", None), -] -SCHEMES = ["x-nfe-apikey", "authorization", "basic", "query"] - - -def main() -> None: - results = [] - for host, path, params in TARGETS: - accepted = None - for key in (None, "main", "data"): - r = request(host, path, key=key, params=params) - results.append(r) - print(brief(r)) - if accepted is None and r["status"] is not None and r["status"] < 400: - accepted = key - if accepted: - for scheme in SCHEMES[1:]: - r = request(host, path, key=accepted, scheme=scheme, params=params) - results.append(r) - print(brief(r)) - print("saved", save("p01_auth_matrix", results)) - - -if __name__ == "__main__": - main() diff --git a/scripts/probes/p02_companies.py b/scripts/probes/p02_companies.py deleted file mode 100644 index 0867b14..0000000 --- a/scripts/probes/p02_companies.py +++ /dev/null @@ -1,105 +0,0 @@ -"""P02 — companies v1 (offset, 1-based?) and v2 (cursor) shape, limits and parity (read-only).""" - -from __future__ import annotations - -from typing import Any - -from _probe import COMPANY_ID, brief, request, save - - -def main() -> None: - results: list[dict[str, Any]] = [] - - def run(host: str, path: str, **kw: Any) -> dict[str, Any]: - r = request(host, path, **kw) - results.append(r) - page = r["body"].get("page") if isinstance(r["body"], dict) else None - extra = "" - if isinstance(r["body"], dict): - for k in ("companies",): - if isinstance(r["body"].get(k), list): - extra += f" n={len(r['body'][k])}" - if "hasMore" in r["body"]: - extra += f" hasMore={r['body']['hasMore']}" - print(brief(r), f"page={page}{extra}") - return r - - # v1 pagination semantics: index 0 vs 1, pageCount limits - for idx in (0, 1, 2): - run("api.nfe.io", "/v1/companies", params={"pageIndex": idx, "pageCount": 2}) - for count in (1, 2, 50, 51, 100): - run("api.nfe.io", "/v1/companies", params={"pageIndex": 1, "pageCount": count}) - run("api.nfe.io", "/v1/companies") # server defaults - # v1 retrieve by id and by tax number - one = run("api.nfe.io", f"/v1/companies/{COMPANY_ID}") - tax = None - if isinstance(one["body"], dict): - c = one["body"].get("companies") or one["body"] - if isinstance(c, dict): - tax = c.get("federalTaxNumber") - if tax: - run("api.nfe.io", f"/v1/companies/{tax}") - run("api.nfe.io", "/v1/companies/000000000000000000000000") # unknown id -> error shape - - # v2 cursor - first = run("api.nfse.io", "/v2/companies", params={"limit": 2}) - if isinstance(first["body"], dict) and first["body"].get("companies"): - last = first["body"]["companies"][-1]["id"] - nxt = run("api.nfse.io", "/v2/companies", params={"limit": 2, "startingAfter": last}) - if isinstance(nxt["body"], dict) and nxt["body"].get("companies"): - run( - "api.nfse.io", - "/v2/companies", - params={"limit": 2, "endingBefore": nxt["body"]["companies"][0]["id"]}, - ) - for limit in (0, 1, 50, 51, 100): - run("api.nfse.io", "/v2/companies", params={"limit": limit}) - run("api.nfse.io", "/v2/companies") # server default limit - run("api.nfse.io", f"/v2/companies/{COMPANY_ID}") - run("api.nfse.io", "/v2/companies/000000000000000000000000") - - # parity: total count v1 vs full v2 iteration (bounded) - v1_total = None - r = run("api.nfe.io", "/v1/companies", params={"pageIndex": 1, "pageCount": 50}) - if isinstance(r["body"], dict): - # observed 2026-10-08: "page" is an int (echo of pageIndex), no totals in the envelope - page = r["body"].get("page") - v1_total = page.get("totalResults") if isinstance(page, dict) else None - v2_ids: list[str] = [] - cursor = None - for _ in range(30): - params: dict[str, Any] = {"limit": 50} - if cursor: - params["startingAfter"] = cursor - r = request("api.nfse.io", "/v2/companies", params=params) - body = r["body"] if isinstance(r["body"], dict) else {} - batch = body.get("companies") or [] - v2_ids += [c["id"] for c in batch] - if not body.get("hasMore") or not batch: - break - cursor = batch[-1]["id"] - v1_ids: list[str] = [] - for idx in range(1, 40): - r = request("api.nfe.io", "/v1/companies", params={"pageIndex": idx, "pageCount": 50}) - batch = (r["body"] or {}).get("companies") or [] if isinstance(r["body"], dict) else [] - v1_ids += [c["id"] for c in batch] - if len(batch) < 50: - break - parity = { - "v1_totalResults": v1_total, - "v1_iterated": len(v1_ids), - "v1_unique": len(set(v1_ids)), - "v2_iterated": len(v2_ids), - "v2_unique": len(set(v2_ids)), - "only_in_v1": len(set(v1_ids) - set(v2_ids)), - "only_in_v2": len(set(v2_ids) - set(v1_ids)), - "test_company_in_v1": COMPANY_ID in v1_ids, - "test_company_in_v2": COMPANY_ID in v2_ids, - } - print("parity", parity) - results.append({"parity": parity}) - print("saved", save("p02_companies", results)) - - -if __name__ == "__main__": - main() diff --git a/scripts/probes/p03_service_invoices.py b/scripts/probes/p03_service_invoices.py deleted file mode 100644 index 44b2392..0000000 --- a/scripts/probes/p03_service_invoices.py +++ /dev/null @@ -1,75 +0,0 @@ -"""P03 — NFS-e read paths on the test company: list, retrieve, external id, downloads (read-only).""" - -from __future__ import annotations - -from collections import Counter -from typing import Any - -from _probe import COMPANY_ID, brief, follow_redirect, request, save - -BASE = f"/v1/companies/{COMPANY_ID}/serviceinvoices" - - -def main() -> None: - results: list[dict[str, Any]] = [] - - def run(path: str, **kw: Any) -> dict[str, Any]: - r = request("api.nfe.io", path, **kw) - results.append(r) - loc = next((v for k, v in r["headers"].items() if k.lower() == "location"), None) - print(brief(r), "| Location:", loc) - return r - - def follow(r: dict[str, Any]) -> None: - t = follow_redirect(r) - if t: - results.append(t) - body = t["body"] - head = body.get("_startsWith") or body.get("_binary_head_hex") if isinstance(body, dict) else None - print(" ->", t["status"], t["request"]["host"], t["request"]["path"], t["headers"].get("Content-Type"), - t["bodyLength"], repr(head)) - - run(BASE, params={"pageIndex": 0, "pageCount": 2}) - page1 = run(BASE, params={"pageIndex": 1, "pageCount": 50}) - run(BASE, params={"pageIndex": 1, "pageCount": 51}) - run(BASE, params={"pageIndex": 1, "pageCount": 100}) - run(BASE, params={"pageIndex": 1, "pageCount": 2, "hasTotals": "true"}) - run(BASE, params={"pageIndex": 1, "pageCount": 2, "issuedBegin": "2026-01-01", "issuedEnd": "2026-12-31"}) - invoices = page1["body"].get("serviceInvoices", []) if isinstance(page1["body"], dict) else [] - stats = { - "flowStatus": Counter(i.get("flowStatus") for i in invoices), - "status": Counter(i.get("status") for i in invoices), - "withExternalId": sum(1 for i in invoices if i.get("externalId")), - "keys_union": sorted({k for i in invoices for k in i}), - } - print("stats", stats) - results.append({"stats": {k: (dict(v) if isinstance(v, Counter) else v) for k, v in stats.items()}}) - - issued = next((i for i in invoices if i.get("flowStatus") == "Issued"), None) - cancelled = next((i for i in invoices if i.get("flowStatus") == "Cancelled"), None) - with_ext = next((i for i in invoices if i.get("externalId")), None) - if invoices: - run(f"{BASE}/{invoices[0]['id']}") - if issued: - run(f"{BASE}/{issued['id']}/pdf", accept="application/pdf") - follow(run(f"{BASE}/{issued['id']}/pdf", accept="*/*")) - run(f"{BASE}/{issued['id']}/xml", accept="application/xml") - follow(run(f"{BASE}/{issued['id']}/xml", accept="*/*")) - # second issued invoice, to avoid generalising from one sample - other = next((i for i in invoices if i.get("flowStatus") == "Issued" and i is not issued), None) - if other: - follow(run(f"{BASE}/{other['id']}/xml", accept="*/*")) - if cancelled: - run(f"{BASE}/{cancelled['id']}") - run(f"{BASE}/{cancelled['id']}/cancellation-xml", accept="*/*") - if with_ext: - run(f"{BASE}/external/{with_ext['externalId']}") - run(f"{BASE}/external/sdk-python-probe-does-not-exist") - run(f"{BASE}/000000000000000000000000") - run(f"{BASE}/not-a-valid-id") - run(f"{BASE}/000000000000000000000000/pdf", accept="*/*") - print("saved", save("p03_service_invoices", results)) - - -if __name__ == "__main__": - main() diff --git a/scripts/probes/p04_certs_webhooks_lookups.py b/scripts/probes/p04_certs_webhooks_lookups.py deleted file mode 100644 index 9bff23d..0000000 --- a/scripts/probes/p04_certs_webhooks_lookups.py +++ /dev/null @@ -1,84 +0,0 @@ -"""P04 — certificate envelopes, webhooks list/event types, data lookups and error shapes (read-only).""" - -from __future__ import annotations - -from typing import Any - -from _probe import COMPANY_ID, KEYS, brief, request, save - -CNPJ = "00000000000191" -CEP = "01310100" - - -def main() -> None: - results: list[dict[str, Any]] = [] - - def run(host: str, path: str, **kw: Any) -> dict[str, Any]: - r = request(host, path, **kw) - results.append(r) - print(brief(r)) - return r - - # certificates - run("api.nfe.io", f"/v1/companies/{COMPANY_ID}/certificate") - run("api.nfse.io", f"/v1/companies/{COMPANY_ID}/certificate") - run("api.nfse.io", f"/v2/companies/{COMPANY_ID}/certificates") - - # webhooks (secrets are redacted before saving) - hooks = run("api.nfse.io", "/v2/webhooks") - run("api.nfse.io", "/v2/webhooks/eventtypes") - run("api.nfse.io", "/v2/webhooks/eventTypes") - run("api.nfe.io", "/v1/hooks") - run("api.nfe.io", "/v1/eventTypes") - body = hooks["body"] if isinstance(hooks["body"], dict) else {} - for h in body.get("webHooks") or []: - if h.get("id"): - results.append(request("api.nfse.io", f"/v2/webhooks/{h['id']}")) - print(brief(results[-1])) - break - run("api.nfse.io", "/v2/webhooks/000000000000000000000000") - for r in results: - _redact_secrets(r.get("body")) - - # data lookups - run("legalentity.api.nfe.io", f"/v1/legalentities/basicInfo/{CNPJ}", key="data") - run("legalentity.api.nfe.io", f"/v2/legalentities/basicInfo/{CNPJ}", key="data") - run("legalentity.api.nfe.io", f"/v3/legalentities/basicInfo/{CNPJ}", key="data") - run("legalentity.api.nfe.io", f"/v2/legalentities/stateTaxInfo/DF/{CNPJ}", key="data") - run("legalentity.api.nfe.io", "/v2/legalentities/basicInfo/00000000000100", key="data") # bad DV - run("legalentity.api.nfe.io", "/v2/legalentities/basicInfo/123", key="data") - run("legalentity.api.nfe.io", "/v2/legalentities/basicInfo/00.000.000/0001-91", key="data") - run("address.api.nfe.io", f"/v2/addresses/{CEP}", key="data") - run("address.api.nfe.io", "/v2/addresses/01310-100", key="data") - run("address.api.nfe.io", "/v2/addresses/00000000", key="data") - run("address.api.nfe.io", "/v1/addresses/" + CEP, key="data") - run("address.api.nfe.io", "/v2/addresses", key="data", params={"$filter": "city eq 'Curitiba'"}) - run("naturalperson.api.nfe.io", "/v1/naturalperson/status/52998224725/1990-01-01", key="data") - run("naturalperson.api.nfe.io", "/v1/naturalperson/status/12345678900/1990-01-01", key="data") - run("naturalperson.api.nfe.io", "/v1/naturalperson/status/52998224725/01-01-1990", key="data") - - # error shapes with a well-formed but wrong key (401 with key present) - KEYS["bogus"] = "0" * 32 - run("api.nfe.io", "/v1/companies", key="bogus") - run("api.nfse.io", "/v2/companies", key="bogus") - run("api.nfe.io", "/v1/companies", params={"pageIndex": 1, "pageCount": 0}) - run("api.nfe.io", "/v1/companies", params={"pageIndex": -1, "pageCount": 2}) - run("api.nfe.io", "/v1/companies", params={"pageIndex": "abc"}) - del KEYS["bogus"] - print("saved", save("p04_certs_webhooks_lookups", results)) - - -def _redact_secrets(node: Any) -> None: - if isinstance(node, dict): - for k in list(node): - if k.lower() in {"secret", "password"} and node[k]: - node[k] = "" - else: - _redact_secrets(node[k]) - elif isinstance(node, list): - for x in node: - _redact_secrets(x) - - -if __name__ == "__main__": - main() diff --git a/scripts/probes/p05_rechecks.py b/scripts/probes/p05_rechecks.py deleted file mode 100644 index e986414..0000000 --- a/scripts/probes/p05_rechecks.py +++ /dev/null @@ -1,104 +0,0 @@ -"""P05 — re-check vault claims that may have changed (read-only). - -- Basic auth variants ("Basic " vs base64). -- Webhooks: same list on api.nfe.io and api.nfse.io? -- Companies v2 "poison record" cursor 500 (repro from the 2026-07 gate note). -- municipalTaxNumber v1 vs v2 for the test company. -- Off-spec NFS-e /status route; 503 vs 404 for an unknown company; people listings. -- CNPJ v3 for the test company's own CNPJ (alphanumeric-ready string?). -""" - -from __future__ import annotations - -import base64 -from typing import Any - -import _probe -from _probe import COMPANY_ID, KEYS, brief, request, save - -POISON = "3450bc6208cb4b97a1a3616b89f77a4b" - - -def main() -> None: - results: list[dict[str, Any]] = [] - - def run(host: str, path: str, **kw: Any) -> dict[str, Any]: - r = request(host, path, **kw) - results.append(r) - print(brief(r)) - return r - - # Basic variants: patch the auth header builder for two extra schemes - key = KEYS["main"] - original = _probe._auth - - def patched(key_name: str | None, scheme: str) -> tuple[dict[str, str], dict[str, str]]: - if scheme == "basic-raw": - return {"Authorization": f"Basic {key}"}, {} - if scheme == "basic-b64-nocolon": - return {"Authorization": "Basic " + base64.b64encode(key.encode()).decode()}, {} - if scheme == "bearer": - return {"Authorization": f"Bearer {key}"}, {} - return original(key_name, scheme) - - _probe._auth = patched - for scheme in ("basic-raw", "basic-b64-nocolon", "bearer"): - run("api.nfe.io", "/v1/companies", scheme=scheme, params={"pageIndex": 1, "pageCount": 2}) - run("api.nfse.io", "/v2/companies", scheme=scheme, params={"limit": 2}) - _probe._auth = original - - # Webhooks parity across hosts - a = run("api.nfe.io", "/v2/webhooks") - b = run("api.nfse.io", "/v2/webhooks") - ids_a = sorted(h["id"] for h in (a["body"] or {}).get("webHooks", [])) - ids_b = sorted(h["id"] for h in (b["body"] or {}).get("webHooks", [])) - parity = {"webhooks_same_ids_both_hosts": ids_a == ids_b, "n_api_nfe_io": len(ids_a), "n_api_nfse_io": len(ids_b)} - print(parity) - results.append({"webhooksHostParity": parity}) - - # Poison cursor repro - run("api.nfse.io", "/v2/companies", params={"limit": 1, "startingAfter": POISON}) - run("api.nfse.io", f"/v2/companies/{POISON}") - - # municipalTaxNumber v1 vs v2 (test company) - v1 = run("api.nfe.io", f"/v1/companies/{COMPANY_ID}") - v2 = run("api.nfse.io", f"/v2/companies/{COMPANY_ID}") - c1 = (v1["body"] or {}).get("companies") or {} - c2 = (v2["body"] or {}).get("company") or {} - cmp = { - "v1_keys_not_in_v2": sorted(set(c1) - set(c2)), - "v2_keys_not_in_v1": sorted(set(c2) - set(c1)), - "same_municipalTaxNumber": c1.get("municipalTaxNumber") == c2.get("municipalTaxNumber"), - "federalTaxNumber_types": [type(c1.get("federalTaxNumber")).__name__, type(c2.get("federalTaxNumber")).__name__], - } - print(cmp) - results.append({"companyV1vsV2": cmp}) - if c2.get("federalTaxNumber"): - tax = str(c2["federalTaxNumber"]).zfill(14) - run("legalentity.api.nfe.io", f"/v3/legalentities/basicInfo/{tax}", key="data") - for mt in (c2.get("municipalTaxes") or [])[:1]: - mt_id = mt.get("id") if isinstance(mt, dict) else mt - run("api.nfse.io", f"/v2/companies/{COMPANY_ID}/municipaltaxes/{mt_id}") - run("api.nfse.io", f"/v2/companies/{COMPANY_ID}/municipaltaxes", params={"limit": 5}) - - # NFS-e /status (off-spec) and unknown company behaviour - lst = request("api.nfe.io", f"/v1/companies/{COMPANY_ID}/serviceinvoices", params={"pageIndex": 1, "pageCount": 3}) - for inv in (lst["body"] or {}).get("serviceInvoices", [])[:2]: - run("api.nfe.io", f"/v1/companies/{COMPANY_ID}/serviceinvoices/{inv['id']}/status") - run("api.nfe.io", "/v1/companies/000000000000000000000000/serviceinvoices", params={"pageIndex": 1, "pageCount": 2}) - run("api.nfe.io", "/v1/companies/00000000000000000000000000000000/serviceinvoices", params={"pageIndex": 1, "pageCount": 2}) - run("api.nfe.io", f"/v1/companies/{COMPANY_ID}/serviceinvoices", params={"pageIndex": 0, "pageCount": 2}) - run("api.nfe.io", f"/v1/companies/{COMPANY_ID}/serviceinvoices", params={"pageIndex": 1, "pageCount": 1}) - - # people listings (24-hex vs 32-hex company id) - run("api.nfe.io", f"/v1/companies/{COMPANY_ID}/legalpeople") - run("api.nfe.io", f"/v1/companies/{COMPANY_ID}/naturalpeople") - - # unserved path baseline (to distinguish "not served" 404 from "not found" 404) - run("api.nfe.io", "/v1/this-route-does-not-exist", key=None) - run("api.nfse.io", "/v2/this-route-does-not-exist", key=None) - print("saved", save("p05_rechecks", results)) - - -if __name__ == "__main__": - main() diff --git a/scripts/probes/p06_cnpj_v3.py b/scripts/probes/p06_cnpj_v3.py deleted file mode 100644 index dc65734..0000000 --- a/scripts/probes/p06_cnpj_v3.py +++ /dev/null @@ -1,22 +0,0 @@ -"""P06 — CNPJ v3 routes declared in consulta-cnpj-v3.json (read-only, data key).""" - -from __future__ import annotations - -from _probe import brief, request, save - -CNPJ = "00000000000191" # public sample (Banco do Brasil) - - -def main() -> None: - results = [ - request("legalentity.api.nfe.io", f"/v3/legalentities/stateTaxInfo/DF/{CNPJ}", key="data"), - request("legalentity.api.nfe.io", "/v3/legalentities/basicInfo/00000000000100", key="data"), - request("legalentity.api.nfe.io", "/v3/legalentities/basicInfo/12ABC34501DE35", key="data"), - ] - for r in results: - print(brief(r), str(r["body"])[:200]) - print("saved", save("p06_cnpj_v3", results)) - - -if __name__ == "__main__": - main() diff --git a/tests/fixtures/error-envelopes.json b/tests/fixtures/error-envelopes.json index dab7e7e..e2e5730 100644 --- a/tests/fixtures/error-envelopes.json +++ b/tests/fixtures/error-envelopes.json @@ -1,5 +1,5 @@ { - "_source": "Corpos de erro reais observados na sonda de leitura de 2026-10-08 (scripts/probes/out, gitignored). Ids e traceIds substituídos por valores sintéticos; texto das mensagens mantido como veio da API.", + "_source": "Corpos de erro reais observados na API em 2026-10-08. Ids e traceIds substituídos por valores sintéticos; texto das mensagens mantido como veio da API.", "cases": [ {"label": "401 corpo vazio", "status": 401, "body": "", "expect_class": "AuthenticationError", "expect_message_contains": "api_key", "expect_code": null}, {"label": "403 corpo vazio (chave de dados em rota fiscal)", "status": 403, "body": "", "expect_class": "PermissionDeniedError", "expect_message_contains": "api_key", "expect_code": null},